Compare commits

..

292 Commits

Author SHA1 Message Date
benvin ec581b5e5f Merge pull request 'fix(ha): gate library tasks on the scan leader by default' (#11) from benvin/scan-leader-default into main
ci/woodpecker/push/ci Pipeline was successful
Reviewed-on: #11
2026-09-13 15:04:38 +10:00
unkin-agent b77702c851 merge: resolve CoreAppHost conflict with main, drop dead redisConnectionString local
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
2026-09-13 14:48:26 +10:00
benvin 2370787471 Merge pull request 'fix(db): collapse presentation-key groups without min(uuid)' (#10) from benvin/fix-guid-aggregate-pg into main
ci/woodpecker/push/ci Pipeline was successful
Reviewed-on: #10
2026-09-13 14:25:19 +10:00
benvin a652d718ca Merge pull request 'fix(ha): read transcode store config from the variable form deployments set' (#9) from benvin/ha-config-wiring into main
ci/woodpecker/push/ci Pipeline was canceled
Reviewed-on: #9
2026-09-13 14:24:11 +10:00
benvin 0c707c7989 Merge pull request 'fix(devices): read devices and device options through to the database' (#8) from benvin/fix-device-cache into main
ci/woodpecker/push/ci Pipeline was canceled
Reviewed-on: #8
2026-09-13 14:23:17 +10:00
unkin-agent 91655fcb0a fix(ha): gate library tasks on the scan leader by default
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Jellyfin:ScanLeader:Enabled defaults to false and nothing sets it, so
leader election never runs and every replica executes the timer-driven
library tasks concurrently - the exact behaviour the lease prevents.

- Enable gating by default when a Redis connection string is configured
- Honour an explicit Enabled setting either way
- Carry the effective decision onto the bound options the task worker reads
- Log at startup whether gating is active
- Warn when gating is enabled but no Redis connection string is configured
2026-09-13 13:19:48 +10:00
unkin-agent 6e405af1dc fix(db): collapse presentation-key groups without min(uuid)
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
PostgreSQL has no min(uuid) aggregate, so every query that picked a group
representative with MIN over the item id failed with 42883: library browse,
search, Recently Added, the by-name endpoints and Upcoming.

- Pick the representative with an anti-join on (primary version, id)
- Cover the collapse with a repository test against a real PostgreSQL
2026-09-13 13:18:11 +10:00
unkin-agent 95220e2ed6 fix(ha): read transcode store config from the variable form deployments set
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
The startup configuration only reads JELLYFIN_ prefixed environment
variables, so the bare Jellyfin__TranscodeStore__* form used by the chart,
the manifests and the README is dropped and the Redis store is never
registered. Nothing logs the selected store, so the fallback is invisible.

- Read bare Jellyfin__* variables into the Jellyfin:* configuration root
- Keep an explicit JELLYFIN_ variable winning over the bare form
- Log the selected transcode session store at startup
- Ping Redis once at startup and log an unreachable store at Error
- Log endpoints only, never the connection string
2026-09-13 13:10:52 +10:00
unkin-agent 56919b9565 fix(devices): read devices and device options through to the database
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
The device cache was filled once at construction, so a token minted by one
replica was unknown to every other replica already running and a token revoked
on one replica stayed valid on the others until they restarted.

- drop the eager device and device options dictionaries
- read devices and device options from the database on every query
- push the device query filters and ordering into SQL
- open the request's database context only for the api key fallback
- cover both directions against real PostgreSQL with two manager instances
2026-09-13 13:08:47 +10:00
benvin 25269263ce Merge pull request 'test(db): run the startup migration chain against PostgreSQL in CI' (#7) from benvin/ci-postgres-migration-chain into main
ci/woodpecker/push/ci Pipeline was successful
Reviewed-on: #7
2026-09-12 22:22:12 +10:00
benvin b183bec94b Merge pull request 'fix(db): materialise the rating list before updating' (#6) from benvin/fix-rating-levels-pg into main
ci/woodpecker/push/ci Pipeline was canceled
Reviewed-on: #6
2026-09-12 22:21:30 +10:00
unkin-agent bc42ff4b28 test(db): drop a leftover test database before recreating it
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
A server handed in through JELLYFIN_TEST_POSTGRES outlives the run, so a second
run finds the databases the first one created. Also name the failures in
Jellyfin.Database.Tests.PostgreSQL the CI step steps around.
2026-09-12 21:36:09 +10:00
unkin-agent b501ba9620 test(db): run the startup migration chain against PostgreSQL in CI
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Every PostgreSQL test carries Category=RequiresDocker and the pipeline filters
that category out, so the provider production runs on was never exercised and
two upgrade-path bugs reached it in a row. Nothing covered the whole startup
sequence either - code and schema migrations interleaved the way the migration
service orders them.

- run both database stages through JellyfinMigrationService against a seeded
  library and against a fresh install
- take the server from JELLYFIN_TEST_POSTGRES when it is set, else start a
  container
- add a pipeline step that runs the PostgreSQL tests on every push and pull
  request, with the server inside the step: the kubernetes backend has no
  docker daemon, and a service container deadlocks on the workspace volume
2026-09-12 21:28:17 +10:00
unkin-agent acab85ada3 merge the rating levels fix so the chain test has it 2026-09-12 21:20:27 +10:00
unkin-agent ad834167d5 ci: probe service containers
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/probe Pipeline was canceled
2026-09-12 21:03:15 +10:00
unkin-agent f59ab11c43 fix(db): materialise the rating list before updating
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
MigrateRatingLevels issued an ExecuteUpdate while the SELECT DISTINCT reader
was still open on the same connection. SQLite tolerates that, Npgsql does not,
so the AppInitialisation stage aborted and every PostgreSQL instance
crash-looped on startup.

- read the distinct ratings into a list before the update loop
- cover the migration against a real PostgreSQL, with NULL and empty ratings
2026-09-12 21:00:18 +10:00
benvin 10fed9409d Merge pull request 'fix(db): restore the PostgreSQL upgrade path' (#4) from benvin/fix-postgres-upgrade into main
ci/woodpecker/push/ci Pipeline was successful
Reviewed-on: #4
2026-09-12 17:55:56 +10:00
benvin f2166df463 Merge pull request 'fix(config): read an unusable encoding.xml EncoderPreset as the default' (#5) from benvin/fix-encoder-preset-fallback into main
ci/woodpecker/push/ci Pipeline was successful
Reviewed-on: #5
2026-09-12 17:43:53 +10:00
unkin-agent 3b60289502 fix(config): read an unusable encoding.xml EncoderPreset as the default
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
An element the enum cannot parse throws, and the configuration manager
catches that and returns defaults, so one bad preset discarded every other
encoding setting.

- serialize EncoderPreset through a string surrogate in XML
- fall back to auto for an empty, unknown or out-of-range value
2026-09-12 16:26:01 +10:00
unkin-agent 2f9b8888e9 revert: move the encoding.xml EncoderPreset fix to its own branch
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
It is an unrelated subsystem and ships as a separate change.
2026-09-12 16:21:47 +10:00
unkin-agent 65af2bcbd7 fix(db): harden the PostgreSQL upgrade against odd legacy data
- accept only the two id shapes the uuid cast parses, so misplaced hyphens
  null out instead of aborting the migration
- clear an OwnerId that already is the detached placeholder, matching the
  SQLite chain, so CleanupOrphanedExtras cannot delete the item
- recreate the placeholder item before the repoint if it is missing
- compare the migrated schema against the model, constraints included
2026-09-12 16:21:42 +10:00
unkin-agent 2e3d24cc05 fix(db): restore the PostgreSQL upgrade path
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
The v12.0 rebase replaced the PostgreSQL provider's initial migration instead of
adding to it, so an existing database kept the pre-12.0 schema while the code
migrations ran against it and startup aborted on a missing NormalizedUsername.

- restore 20260305010333_InitialPostgreSql as the baseline
- add 20260306000000_UpgradeToServer12Schema carrying it to the 12.0 model
- convert OwnerId and PrimaryVersionId to uuid with explicit casts, clear
  unparseable ids and repoint dangling owners at the placeholder item
- drop orphaned permissions and preferences before UserId becomes non-nullable
- add 20260524120336_AddUniqueNormalizedUsernameIndex after the code migration
  that fills the column in
- read an empty or unknown encoding.xml EncoderPreset as the default
- cover both paths against a real PostgreSQL and guard the migration ordering
2026-09-12 15:32:12 +10:00
benvin dcb6ae396c Merge pull request 'Re-apply HA fork patches on upstream v12.0' (#3) from benvin/rebase-v12 into main
ci/woodpecker/push/ci Pipeline was successful
Reviewed-on: #3
2026-09-12 12:48:35 +10:00
unkin-agent c438ec17ed feat(helm): expose the transcode session retention window
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
The chart sets the lease duration but not the retention window that keeps an
orphaned session record available for takeover.

- add ha.transcodeStore.sessionRetentionSeconds and pass it to the statefulset
2026-09-12 12:43:46 +10:00
unkin-agent f54bc76767 chore: merge main history into the v12.0 rebase
The branch is a fresh rebase onto the v12.0 tag, so it shares no history with
main and cannot be merged normally.

- record main as a parent without changing a single file
2026-09-12 10:19:36 +10:00
unkin-agent baa16b6586 fix: make transcode leases ownership-checked and cleanup-aware
Cleanup never matched a live session because the controller registered empty
manifest and segment paths, renewal was a read-modify-write that could revert a
takeover, and the takeover script compared an ISO date to a number, so it errored.

- populate the session record's manifest and segment paths from the playlist path
- renew the lease via a Lua compare-and-set on the owning pod
- store the lease expiry as unix milliseconds so the scripts can compare it
- retain the session record past its lease so an orphan can still be taken over
- test the Redis store against a real Redis, including the renew-vs-takeover race
- drop the live stream record nothing ever read back
2026-09-12 10:19:29 +10:00
unkin-agent d825f8ac81 ci: keep the package cache off the workspace volume
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
The workspace volume is 9.8G and the clone plus build output already fill 5.7G,
so a shared package cache there leaves too little free space to test against.

- restore, build and test in one step with the cache on ephemeral storage
- exclude BackupServiceTests, which requires 5GiB free on the workspace
- print workspace free space before the test run
2026-09-12 01:03:59 +10:00
unkin-agent 0148f374ee ci: grant ephemeral storage and harden the package install
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed
The backup service test needs 5GiB free on the workspace and the apt mirror
occasionally serves a half-synced index, so both fail the pipeline at random.

- request and limit ephemeral storage on every step
- retry apt-get update and verify fontconfig is loadable before testing
- report workspace free space before the test run
- run the full test filter again
2026-09-12 00:57:51 +10:00
unkin-agent d8c7eb5d3d ci: skip the backup service test on the pipeline workspace
ci/woodpecker/pr/ci Pipeline was successful
ci/woodpecker/push/ci Pipeline failed
BackupService refuses to write a backup with less than 5GiB free, which the
pipeline workspace volume does not have, so the suite fails on disk size alone.

- exclude BackupServiceTests from the test filter
- keep restore, build and test as separate steps
- install fontconfig for the Skia tests before running them
2026-09-12 00:51:47 +10:00
unkin-agent 03ac3bbc00 ci: run each test suite as its own step
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed
A solution-wide test run reports one exit code, so a failing suite cannot be
identified without the pipeline logs.

- run the suites carrying HA tests as separate named steps
- run the remaining upstream suites in one step after installing fontconfig
2026-09-12 00:44:47 +10:00
unkin-agent e235673f12 ci: probe system packages before building
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed
A failure anywhere in the chain currently looks the same from outside, so there
is no way to tell a package mirror problem from a compile error.

- run the fontconfig install as the first step
- split restore, build and test into named steps
- point the package cache at the shared pipeline volume
2026-09-12 00:35:03 +10:00
unkin-agent b6f7732f13 ci: run the pipeline as one step again
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed
Separate steps do not share the SDK package cache, so the build step could not
see what the restore step downloaded.

- restore, build and test in a single container
- install fontconfig after the build so a mirror failure cannot be read as a build failure
- raise the memory limit to 8Gi
2026-09-12 00:27:48 +10:00
unkin-agent 43b4f143eb ci: split the pipeline into restore, build and test steps
ci/woodpecker/push/ci Pipeline failed
ci/woodpecker/pr/ci Pipeline failed
A single step reports every failure the same way, so a mirror, compiler or test
failure all look identical from the outside.

- split restore, build and test into separate named steps
- probe the fontconfig package install in its own step
- share the NuGet cache through the workspace and raise the memory limit
2026-09-12 00:24:26 +10:00
unkin-agent 9b8748c778 docs: document the HA layer and the v12.0 fork base
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/push/ci Pipeline failed
The README and fork notes still described upstream and a pre-10.11 snapshot, so
there was nothing accurate to hand an operator setting this up.

- rewrite the README as an HA setup and configuration guide
- add architecture, contributing and transcoding design notes
- rewrite FORK-DIFF.md against the v12.0 base
2026-09-11 23:58:18 +10:00
unkin-agent 673d186de8 feat(deploy): add jellyfin-ha Helm chart
Running the HA build needs Redis, PostgreSQL and shared transcode storage wired
together, which is a lot of manifests to keep in sync by hand.

- add a chart deploying the server as a StatefulSet with a PodDisruptionBudget
- ship optional Redis and PostgreSQL dependencies and a runtime config ConfigMap
- template shared media, config and transcode volume claims
- add ingress and ServiceMonitor templates
2026-09-11 23:56:51 +10:00
unkin-agent 14d843897c ci: build on Woodpecker and drop GitHub Actions
The fork is hosted on Gitea, where the upstream GitHub Actions workflows never
run and only produce noise.

- add a Woodpecker pipeline restoring, building and testing the solution
- remove the upstream .github/workflows definitions
- add Dockerfile and Dockerfile.runtime building the server image with jellyfin-web 12.0
2026-09-11 23:56:51 +10:00
unkin-agent 3b416c7fb2 feat: gate periodic library tasks behind a scan-leader lease
Timer-driven library tasks fire on every replica, so a library refresh or a
database optimise runs once per pod against the same shared library.

- add IScanLeaderLease with a Redis TTL implementation and a no-op default
- skip timer-driven runs of the gated tasks on instances without the lease
- treat an unreachable Redis as holding the lease so tasks never stop running
- leave manual and API-triggered runs ungated
2026-09-11 23:56:51 +10:00
unkin-agent e3571eb2a4 feat(session): record open live streams in the session store
Live stream ownership is only tracked in process memory, so no replica can tell
which pod holds a stream open once that pod is gone.

- persist a LiveStreamSession record when the live stream mappings change
- delete the record when the stream is closed
- keep closing the stream when the store is unreachable
2026-09-11 23:56:35 +10:00
unkin-agent 6ccabd4c72 feat(hls): register transcode sessions and renew their lease
A transcode that is not in the store is invisible to the other replicas, so the
HLS entry points have to publish and hold the lease themselves.

- register the play session in the store when ffmpeg starts
- renew the lease on a background loop and delete the session when it ends
- shorten segments and bound the playlist window when resuming a stored session
- add RecoverySegmentLengthSeconds and RecoverySegmentBufferCount encoding options
2026-09-11 23:56:35 +10:00
unkin-agent c43992630e feat: skip transcode files held by an active session
Every replica runs the transcode cleanup task against the same shared transcode
directory, so one pod deletes segments another pod is still streaming.

- read the active sessions from ITranscodeSessionStore before deleting
- keep files matching an active session manifest path or segment prefix
- abort the sweep when the store cannot be reached
2026-09-11 23:56:35 +10:00
unkin-agent e23d1a23be feat: add a durable transcode session store
Transcode state lives only in the process that started ffmpeg, so a pod restart
drops every in-flight HLS stream with no way for a peer to pick it up.

- add ITranscodeSessionStore plus the TranscodeSession and LiveStreamSession records
- add RedisTranscodeSessionStore with TTL leases and an atomic Lua takeover script
- add NullTranscodeSessionStore for single-instance deployments
- pick the store from Jellyfin:TranscodeStore:RedisConnectionString at startup
2026-09-11 23:56:19 +10:00
unkin-agent c8a693ae17 feat(tools): add SQLite to PostgreSQL migration tool
Existing installs hold their whole library in jellyfin.db, so switching to the
PostgreSQL provider needs a one-shot data move.

- add Jellyfin.DbMigrator reading SQLite tables and bulk-writing them to PostgreSQL
- validate table names against the source schema before generating SQL
- upload a pre-migration copy of the SQLite file to S3 when S3_BACKUP_BUCKET is set
- emit a per-table row-count report and support a dry-run mode
2026-09-11 23:56:19 +10:00
unkin-agent e68455f0ed feat(db): add opt-in PostgreSQL database provider
Multi-replica deployments cannot share a SQLite file, so the database has to
move to a server engine before the rest of the HA work is usable.

- add Jellyfin.Database.Providers.PostgreSQL with an EF Core Npgsql provider
- register the provider and a pooled NpgsqlDataSource when DatabaseType is Jellyfin-PostgreSQL
- accept postgresql:// URIs and POSTGRES_CONNECTION_STRING alongside CustomProviderOptions
- add container-backed provider, CRUD, concurrency and migration tests
2026-09-11 23:56:04 +10:00
benvin 2e1e445e47 Merge pull request 'ci: add Woodpecker build+test pipeline, drop GitHub Actions' (#2) from benvin/woodpecker-ci into main
ci/woodpecker/push/ci Pipeline was successful
Reviewed-on: #2
2026-08-11 21:14:33 +10:00
benvin 4920aa871a Merge pull request 'Gate periodic library-mutating tasks behind a scan-leader lease' (#1) from benvin/scan-leader-election into main
HA Build & Push to ECR / build-and-push (push) Has been cancelled
Reviewed-on: #1
2026-08-11 21:11:58 +10:00
unkinben a06b11980e ci: add Woodpecker build+test pipeline, drop GitHub Actions
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/pr/ci Pipeline was successful
Why:
- The fork inherited GitHub Actions workflows that target the upstream's
  self-hosted GitHub runners and do not run on this Gitea/Woodpecker
  infrastructure, so source changes (such as the scan-leader lease work)
  currently land without any CI validation.

How:
- Add .woodpecker/ci.yaml running restore, build and test of Jellyfin.sln
  on pull_request and push, using the .NET 9 SDK that global.json pins.
- Filter out RequiresDocker and Integration tests, mirroring the upstream
  test selection so the suite runs without extra services.
- Set memory-heavy resource requests/limits and a dedicated
  serviceAccountName for the build+test step.
- Remove the inherited .github/workflows/ pipelines that only run on the
  upstream's GitHub Actions runners.
2026-08-11 07:23:43 +10:00
unkinben 0008bde28e Gate periodic library-mutating tasks behind a scan-leader lease
ABI Compatibility / ABI - HEAD (pull_request) Has been cancelled
ABI Compatibility / ABI - BASE (pull_request) Has been cancelled
OpenAPI / OpenAPI - HEAD (pull_request) Has been cancelled
OpenAPI / OpenAPI - BASE (pull_request) Has been cancelled
Tests / run-phase5-tests (pull_request) Has been cancelled
Tests / run-tests (pull_request) Has been cancelled
Project Automation / Project board (pull_request) Has been cancelled
Merge Conflict Labeler / Labeling (pull_request) Has been cancelled
ABI Compatibility / ABI - Difference (pull_request) Has been cancelled
OpenAPI / OpenAPI - Difference (pull_request) Has been cancelled
OpenAPI / OpenAPI - Publish Unstable Spec (pull_request) Has been cancelled
OpenAPI / OpenAPI - Publish Stable Spec (pull_request) Has been cancelled
In a multi-pod deployment every pod runs the scheduled-task timers, so
periodic library-mutating tasks (library refresh, people/chapter refresh,
audio normalization, media-segment and keyframe extraction, collection and
user-data cleanup, database optimization) fire concurrently against the shared
database and library, duplicating work and racing each other.

Add an IScanLeaderLease abstraction that elects a single scan leader via a
Redis TTL lease keyed on the pod identity, mirroring the existing transcode
lease machinery. RedisScanLeaderLease acquires or renews the lease with an
atomic Lua script and fails safe by treating the pod as leader whenever Redis
is unreachable, so scans never stall. NullScanLeaderLease preserves the
single-instance behavior when election is disabled or no Redis connection is
configured.

Gate only the timer-driven path in ScheduledTaskWorker: when election is
enabled and a task key is in the gated set, a non-leader re-arms its trigger
and skips enqueueing. Manual and API-triggered runs bypass this path and still
run on any pod. Wiring is additive and the new worker constructor parameters
are optional, so existing behavior is unchanged when election is off.

Signed-off-by: Ben Vincent <ben@unkin.net>
2026-08-10 23:51:18 +10:00
mat d4f9c12c22 fix: downgrade PostgreSQL provider to net9.0/EF Core 9.x for v10.11.7 compat
HA Build & Push to ECR / build-and-push (push) Has been cancelled
Upstream Jellyfin 10.11.7 targets net9.0. Our PostgreSQL provider was on
net10.0 with Npgsql.EntityFrameworkCore.PostgreSQL 10.0.0 which requires
EF Core 10+. Downgrade to 9.0.4 to match upstream's EF Core 9.0.11.

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

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

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

* chore: trim verbose comment in Dockerfile.runtime

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

---------

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

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

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

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

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

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

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

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

Closes #28

* fix: SA1516 — blank line in InMemoryTranscodeSessionStore between helpers

---------

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

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

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

* ci: trigger CI run for PR #27 review

---------

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

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

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

* Fix Redis exception propagation in GetActiveSessionsAsync for safe abort behavior

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

* fix: use KeysAsync to resolve CA1849 analyzer violation

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

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

Line 161 in RedisTranscodeSessionStore.GetActiveSessionsAsync.

---------

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

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

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

---------

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

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

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

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

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

---------

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

---------

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

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

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

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

---------

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

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

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

* Remove accidentally committed build artifacts from PostgreSQL provider

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

---------

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

* feat: implement PostgreSqlDatabaseProvider all methods + DI registration

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

---------

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

* Issue 1.1: Scaffold PostgreSQL provider project

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>
2026-03-31 22:55:45 -04:00
Joshua M. Boniface b2aa80ce5c Fix invalid regex comparison 2026-03-31 19:59:33 -04:00
Joshua M. Boniface ff365dae34 Fix invalid merge conflict fix 2026-03-31 19:46:47 -04:00
Jellyfin Release Bot 52aebfb7d3 Bump version to 10.11.7 2026-03-31 19:33:11 -04:00
Joshua M. Boniface 66ea1b50e6 Merge commit from fork
Fix GHSA-8fw7-f233-ffr8 with improved sanitization
2026-03-31 19:17:17 -04:00
Joshua M. Boniface 3f656ade7a Merge remote-tracking branch 'upstream/release-10.11.z' into advisory-fix-1 2026-03-31 19:16:19 -04:00
Joshua M. Boniface 8bf0d372c6 Merge commit from fork
Fix GHSA-jh22-fw8w-2v9x
2026-03-31 17:46:01 -04:00
Joshua M. Boniface 202d7b5829 Merge branch 'release-10.11.z' into advisory-fix-1 2026-03-31 17:44:59 -04:00
Joshua M. Boniface 352e4f3aba Merge commit from fork
Fix GHSA v2jv-54xj-h76w
2026-03-31 17:43:02 -04:00
Joshua M. Boniface c5f6d00c94 Merge commit from fork
Fix GHSA-j2hf-x4q5-47j3 with improved sanitization
2026-03-31 17:38:46 -04:00
Shadowghost e8d1d94436 Lock down tuner API to be admin-only 2026-03-31 16:35:15 +02:00
Shadowghost 50dc37065b Fix GHSA-jh22-fw8w-2v9x 2026-03-31 09:30:45 +02:00
Niels van Velzen 7e88b18192 Merge pull request #16522 from Bond-009/CA1810
Fix CA1810 build error
2026-03-30 18:44:15 +02:00
Bond-009 89e914c7f1 Merge pull request #16519 from jellyfin/check-h264-profile-null
Fix Null was not checked before using the H264 profile
2026-03-30 18:39:45 +02:00
Bond_009 1932ac4765 Fix CA1810 build error 2026-03-30 18:33:56 +02:00
Bond-009 ec33c74ec4 Merge pull request #16440 from Molier/fix/subtitle-extraction-flush
Remove -copyts and add -flush_packets 1 to subtitle extraction
2026-03-30 18:30:58 +02:00
nyanmisaka 2184ed1b16 Fix Null was not checked before using the H264 profile
This is rare, but not impossible.

Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2026-03-30 20:51:11 +08:00
Shadowghost d3907afde7 Add additional validations 2026-03-30 10:48:51 +02:00
theguymadmax e12d933531 Revet lint fix 2026-03-30 04:21:26 -04:00
theguymadmax c0ba29d917 fix lint issue 2026-03-30 04:14:23 -04:00
Shadowghost d1fd81c382 Fix GHSA v2jv-54xj-h76w 2026-03-30 09:40:01 +02:00
Joshua M. Boniface e038045494 Fix lint 2026-03-29 19:11:40 -04:00
Joshua M. Boniface e1691e649e Merge pull request #16514 from theguymadmax/release-10.11.z-fixup 2026-03-29 19:04:53 -04:00
theguymadmax 8d28497d29 Fix lint issue 2026-03-29 18:25:57 -04:00
Joshua M. Boniface fddd4e7e6b Fix GHSA-8fw7-f233-ffr8 with improved sanitization
Co-Authored-By: Shadowghost <Ghost_of_Stone@web.de>
2026-03-29 17:30:09 -04:00
Joshua M. Boniface 0581cd6610 Fix GHSA-j2hf-x4q5-47j3 with improved sanitization
Co-Authored-By: Shadowghost <Ghost_of_Stone@web.de>
2026-03-29 17:22:14 -04:00
Joshua M. Boniface 0f1732e5f5 Merge pull request #16425 from theguymadmax/fix-metadata-backup 2026-03-29 15:22:14 -04:00
Bond-009 41c2d51d8c Merge pull request #16369 from Bond-009/skiafonts
Fix nullref ex in font handling
2026-03-29 20:27:17 +02:00
Bond-009 29b2361857 Merge pull request #16423 from nyanmisaka/fix-ffmpeg8-readrate-option
Fix readrate options in FFmpeg 8.1
2026-03-23 21:25:35 +01:00
Bond-009 ce867f9834 Merge pull request #16449 from theguymadmax/fix-collection-number
Fix NFO saver using wrong provider ID for collectionnumber
2026-03-23 19:23:34 +01:00
theguymadmax 4034bf9d7e Save collection id instead of moive id 2026-03-22 12:49:33 -04:00
Oscar 3d2658fa43 Remove -copyts and add -flush_packets 1 to subtitle extraction
-copyts is unnecessary for -c:s copy to SRT and slows extraction ~5x.
Without -flush_packets 1, ffmpeg buffers all output until exit, leaving
.srt files at 0 bytes for minutes while the player shows no subtitles.

Fixes #16438
2026-03-19 22:54:05 +01:00
theguymadmax 61b19688ff Backup default metadata location 2026-03-17 23:13:20 -04:00
theguymadmax e8d72bf6a3 Fix restore backup metadata location 2026-03-16 10:32:09 -04:00
nyanmisaka 348b14f7b7 Fix readrate options in FFmpeg 8.1
Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2026-03-16 17:58:53 +08:00
IceStormNG fda49a5a49 Apply analyzeduration and probesize for subtitle streams to improve codec parameter detection (#16293)
Apply analyzeduration and probesize for subtitle streams to improve codec parameter detection
2026-03-13 20:26:25 +01:00
Bond-009 55c00d76bb Merge pull request #16392 from nyanmisaka/fix-ffmpeg8-filter-detection
Fix filter detection in FFmpeg 8.1
2026-03-13 20:24:18 +01:00
nyanmisaka 519d2113eb Fix filter detection in FFmpeg 8.1
Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2026-03-11 17:46:07 +08:00
Bond_009 f34f6b6941 Fix nullref ex in font handling
Don't add fallback fonts if they are null
The default font can still be null, however unlikely
2026-03-08 12:46:25 +01:00
Joshua M. Boniface 6864e108b8 Merge pull request #16257 from lowbit/fix/subtitle-empty-file-cache 2026-03-07 00:53:56 -05:00
Bond-009 09ba04662a Merge pull request #16341 from crimsonspecter/fix-fractional-hls-time-for-remux
Fix hls segment length adjustment for remuxed content
2026-03-06 22:39:59 +01:00
crimsonspecter 9cd2418095 Fix: don't apply segment length adjustment for remuxed content 2026-03-04 20:27:06 +01:00
Bond-009 b6a96513de Merge pull request #16253 from theguymadmax/use-backupdatabase
Checkpoint WAL before moving library.db in migration
2026-02-28 10:08:36 +01:00
Bond-009 ca57166e95 Merge pull request #16204 from MBR-0001/fix-bad-subtitle-settings
Fix broken library subtitle download settings
2026-02-28 10:08:20 +01:00
MBR#0001 33496c1693 Fix broken library subtitle download settings 2026-02-26 19:54:24 +01:00
Bond-009 b65daeca0b Merge pull request #16150 from dfederm/nullref-season-series
Fix nullref in Season.GetEpisodes when the season is detached from a series
2026-02-22 13:49:41 +01:00
David Federman 286cc6d720 Fix nullref in Season.GetEpisodes when the season is detached from a series 2026-02-20 22:56:30 -08:00
Andrew Rabert aa4f09c799 Mitigate pull_request_target privilege escalation
Hotfix — replaces pull_request_target with pull_request to stop
granting write permissions and secrets to fork PRs. Some workflows
will break; can be fixed properly later.
2026-02-20 19:10:40 -05:00
rijads afd3c0d9f3 Fix subtitle extraxtion caching empty files 2026-02-19 17:53:47 +01:00
theguymadmax 5597d8e1a7 Checkpoint wal 2026-02-18 15:11:57 -05:00
theguymadmax 0166362258 Use BackupDatabase() instead of File.Move in library.db migration 2026-02-17 22:56:45 -05:00
Bond-009 58c330b63d Merge pull request #16226 from dfederm/fix/16134-migration-unique-constraint
Deduplicate provider IDs during MigrateLibraryDb migration
2026-02-14 11:46:43 +01:00
Bond-009 be71295693 Merge pull request #16227 from dfederm/fix/16149-watch-state-episode-replace
Reattach user data after item removal during library scan
2026-02-14 11:45:51 +01:00
Bond-009 8cd3090cee Merge pull request #16231 from theguymadmax/skip-image-for-empty-folders
Skip image checks for empty folders
2026-02-14 11:43:24 +01:00
David Federman 7bf08daeec Reattach user data after removing items during library scan
When items are removed during a library scan, their user data is
detached to a placeholder. If a replacement item already exists
(e.g., a new version of the same episode was added before the old
file was deleted), the user data would be stranded in the placeholder
because the replacement item's initial ReattachUserDataAsync call
happened before the old item was deleted.

This fix checks for remaining valid children that share user data
keys with removed items and reattaches any detached user data to them.

Fixes #16149
2026-02-12 20:38:28 -08:00
David Federman 290463fe7b Fix migration UNIQUE constraint on BaseItemProviders
Deduplicate ProviderIds by ProviderId during MigrateLibraryDb migration
to prevent UNIQUE constraint violations when legacy data contains
duplicate provider entries for the same item.

Fixes #16134
2026-02-12 20:37:26 -08:00
theguymadmax 1b2d9c100a Skip image checks for empty folders 2026-02-12 18:04:27 -05:00
Bond-009 caa05c1bf2 Merge pull request #16116 from saltpi/bugfix
Fix TMDB image URLs missing size parameter
2026-02-02 20:48:21 +01:00
Niels van Velzen a37ead86df Merge pull request #16098 from theguymadmax/fix-random-sort
Fix random sort returning duplicate items
2026-01-27 11:31:41 +01:00
Niels van Velzen e65aff8bc6 Merge pull request #16109 from nielsvanvelzen/ws-session-info-dto-backport
Fix SessionInfoWebSocketListener not using SessionInfoDto
2026-01-27 11:31:15 +01:00
endpne 9734494eb6 Fix TMDB image URLs missing size parameter 2026-01-26 13:25:03 +08:00
Niels van Velzen d41e302418 Fix SessionInfoWebSocketListener not using SessionInfoDto 2026-01-25 21:21:48 +01:00
theguymadmax 80ba517294 Fix random sort returning duplicate items 2026-01-24 13:48:05 -05:00
MarcoCoreDuo 95d08b264f Rehydrate cached UserData after reattachment (#16071) 2026-01-22 17:43:05 -07:00
IceStormNG 893a849f28 Slightly adjust segment length for fractional framerates (#16053)
Co-authored-by: Carsten Braun <carsten.braun@braun-cloud.de>
2026-01-22 17:41:51 -07:00
theguymadmax 673f617994 Fix TMDB crew department mapping (#16066) 2026-01-22 17:40:35 -07:00
theguymadmax 644327eb76 Revert hidden directory ignore pattern (#16077) 2026-01-22 17:39:55 -07:00
Jellyfin Release Bot 10662e75e4 Bump version to 10.11.6 2026-01-18 20:02:59 -05:00
Joshua M. Boniface a2b1936e73 Merge pull request #15816 from theguymadmax/preserve-artist-order
Fix artist display order
2026-01-18 19:48:17 -05:00
theguymadmax 2df546af6d Deduplicate using Distinct 2026-01-18 18:16:45 -05:00
Claus Vium 338b480217 Merge pull request #16046 from theguymadmax/restore-weekly-images
Restore weekly refresh for library folder images
2026-01-18 16:36:45 +01:00
theguymadmax 2943bb6fdd Restore collection folder image refresh 2026-01-18 01:51:51 -05:00
theguymadmax 94edcbd2d1 Fix artist ordering DtoServices 2026-01-17 10:14:41 -05:00
theguymadmax a8d1cdefac Address review comments 2026-01-17 10:14:41 -05:00
Tim Eisele a518160a6f Prioritize better matches on search (#15983) 2026-01-16 19:05:46 -07:00
Tim Eisele b56de6493f Be more strict about PersonType assignments (#15872) 2026-01-16 19:03:13 -07:00
theguymadmax 093cfc3f3b Trim music artist names (#15808) 2026-01-16 18:51:48 -07:00
theguymadmax 49775b1f6a Fix birthplace not saving correctly (#16020) 2026-01-16 18:47:40 -07:00
Collin T Swisher 22d593b8e9 Add mblink creation logic to library update endpoint. (#15965) 2026-01-16 18:47:04 -07:00
theguymadmax 2cb7fb52d2 Skip hidden directories and .ignore paths in library monitoring (#16029) 2026-01-16 18:45:19 -07:00
Joshua M. Boniface 8433b6d8a4 Merge pull request #15899 from MarcoCoreDuo/fix-watch-state-not-kept
Fix watched state not kept on Media replace/rename
2026-01-16 16:40:36 -05:00
Bond-009 32d2414de0 Merge pull request #15950 from theguymadmax/revert-sort-index-number
Revert "always sort season by index number"
2026-01-09 18:38:23 +01:00
Bond-009 317a3a47c3 Merge pull request #15961 from theguymadmax/fix-bad-plugin-url
Fix crash when plugin repository has an invalid URL
2026-01-09 18:24:48 +01:00
theguymadmax 845b8cdc8f Fix crash when plugin repository has an invalid URL 2026-01-06 11:57:25 -05:00
theguymadmax c86f6439c5 Revert "always sort season by index number"
This reverts commit e16ea7b236.
2026-01-05 11:06:25 -05:00
theguymadmax 559e0088e5 Fix tag inheritance for Continue Watching queries (#15931) 2026-01-04 11:20:34 -07:00
MarcoCoreDuo adaca95590 make db context creation async 2025-12-31 07:43:07 +01:00
MarcoCoreDuo 09a1c31fa3 Refactor ReattachUserData methods to be asynchronous 2025-12-31 03:06:07 +01:00
MarcoCoreDuo e4b82025b8 move reattaching user data to own function and call it only after fetching metadata for the first time 2025-12-30 22:04:59 +01:00
Collin T Swisher 78e3702cb0 Fix playlist item de-duplication (#15858) 2025-12-24 07:50:15 -07:00
Bond-009 01b20d3b75 Merge pull request #15833 from nyanmisaka/fix-h264-av1-sdr-hls-fallback
Fix missing H.264 and AV1 SDR fallbacks in HLS playlist
2025-12-24 10:28:33 +01:00
Tim Eisele 156761405e Prefer US rating on fallback (#15793) 2025-12-19 20:41:09 -07:00
Claus Vium 1805f2259f add CultureDto cache (#15826) 2025-12-19 20:38:54 -07:00
Nyanmisaka 4c587776d6 Fix the use of HWA in unsupported H.264 Hi422P/Hi444PP (#15819) 2025-12-19 19:58:56 -07:00
gnattu 8379b4634a Enforce more strict webm check (#15807) 2025-12-19 19:57:08 -07:00
Nyanmisaka 9470439cfa Fix video lacking SAR and DAR are marked as anamorphic (#15834) 2025-12-19 19:54:48 -07:00
gnattu 18096e48e0 Use hvc1 codectag for Dolby Vision 8.4 (#15835) 2025-12-19 19:53:28 -07:00
nyanmisaka f2d0ac7b28 Fix missing H.264 and AV1 SDR fallbacks in HLS playlist
Previously, if HEVC encoding was disabled on the server,
SDR fallbacks would not be provided.

Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2025-12-19 20:33:24 +08:00
theguymadmax 2ccf08f547 Fix artist display order 2025-12-17 01:09:13 -05:00
Jellyfin Release Bot 1e27f460fe Bump version to 10.11.5 2025-12-14 21:44:14 -05:00
Andrew Rabert 4cdd8c8233 Fix unnecessary database JOINs in ApplyNavigations (#15666) 2025-12-13 10:58:08 -07:00
Tim Eisele 6e60634c9f Skip invalid ignore rules (#15746) 2025-12-13 08:39:49 -07:00
theguymadmax 12c5d6b636 Fix backdrop images being deleted when stored with media (#15766) 2025-12-13 08:29:17 -07:00
theguymadmax b617c62f8e Fix NullReferenceException in ApplyOrder method (#15768) 2025-12-13 08:28:31 -07:00
Nyanmisaka 035b5895b0 Fix AV1 decoding hang regression on RK3588 (#15776) 2025-12-13 08:27:29 -07:00
theguymadmax 22da5187c8 Fix collection display order (#15767) 2025-12-13 08:27:01 -07:00
theguymadmax 5804d6840c Fix parental rating comparison with sub-scores (#15786) 2025-12-13 08:25:48 -07:00
Bond-009 b50ce1ad6b Merge pull request #15752 from Collin-Swish/fix-name-case-insensitivity
Fix case sensitivity edge case
2025-12-12 21:39:22 +01:00
Bond-009 481ee03f35 Merge pull request #15757 from theguymadmax/fix-trickplays-for-alt-versions
Fix trickplay images using wrong item on alternate versions
2025-12-12 21:31:52 +01:00
Bond-009 d91adb5d54 Merge pull request #15662 from SapientGuardian/issue15661
Fix blocking in async context in LimitedConcurrencyLibraryScheduler
2025-12-10 20:37:57 +01:00
theguymadmax ef7f138a4e Fix trickplay images using wrong item on alternate versions 2025-12-09 14:21:09 -05:00
Collin Swisher 2e8d9a311b Fix case sensitivity edge case 2025-12-08 17:41:48 -06:00
gnattu 4c5a3fbff3 Use original name for MusicAritist matching (#15689) 2025-12-05 19:30:02 -07:00
liszto 636908fc4d Fix thumbnails not being deleted from temp folder 2025-12-05 19:29:54 -07:00
Tim Eisele 997362fc97 Backport dependency updates (#15723) 2025-12-05 19:27:30 -07:00
Noah Potash c5147341e3 Fixes 15661. Replace BlockingCollection with Channel in LimitedConcurrencyLibraryScheduler to prevent blocking in an asynchronous context. 2025-12-03 21:50:08 -05:00
Noah Potash ca33bcebf0 Add SapientGuardian to CONTRIBUTORS.md 2025-12-03 21:27:26 -05:00
Ivan Kara d32f487e8e Fix symlinked file size (#15681) 2025-12-03 19:04:59 -07:00
theguymadmax fb65f8f853 Fix ItemAdded event triggering when updating metadata (#15680) 2025-12-03 19:02:55 -07:00
martenumberto 2a0b90e385 Fix: Add .ts fallback for video streams to prevent crash (#15690) 2025-12-03 19:02:39 -07:00
myzhysz dde70fd8a2 Fix stack overflow while scanning (#15698) 2025-12-03 19:02:04 -07:00
Niels van Velzen 98d1d0cb35 Merge pull request #15670 from nyanmisaka/fix-mjpeg-rk3576
Fix the empty output of trickplay on RK3576
2025-12-02 13:48:51 +01:00
Jellyfin Release Bot ba76a8f3ad Bump version to 10.11.4 2025-11-30 21:33:32 -05:00
Anthony Lavado 8cd5652157 Merge pull request #15672 from jellyfin/openapi-cache-z
Cache OpenApi document generation
2025-11-30 21:22:29 -05:00
crobibero 8aff4227d9 Implement caching for OpenAPI document 2025-11-30 09:19:19 -07:00
nyanmisaka 026f7472cb Fix the empty output of trickplay on RK3576
Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2025-11-30 21:38:47 +08:00
MBR-0001 daca285568 Revert "Localization/iso6392.txt: change pob and pop" (#15555) 2025-11-23 19:20:29 +01:00
theguymadmax fbb9a0b2c7 Fix ResolveLinkTarget crashing on exFAT drives (#15568) 2025-11-21 21:14:39 -07:00
Ziyuan Qu 29b3aa8543 Add hidden file check in bdInfo (#15582) 2025-11-21 21:14:30 -07:00
theguymadmax 94f3725208 Fix isMovie filter logic (#15594) 2025-11-21 21:14:03 -07:00
theguymadmax 0ee81e87be Fix locked fields on not saving (#15564) 2025-11-19 17:02:53 +01:00
theguymadmax c491a918c2 Save item to database before providers run to prevent FK constraint errors (#15563) 2025-11-19 17:01:13 +01:00
gnattu 1e7e46cb82 Prevent copying HDR streams when only SDR is supported (#15556) 2025-11-18 18:37:35 -07:00
theguymadmax 5ae444d96d Fix NullReferenceException in filesystem path comparison (#15548) 2025-11-18 18:37:09 -07:00
gnattu ee7ad83427 Restrict first video frame probing to file protocol (#15557) 2025-11-18 18:36:59 -07:00
Jellyfin Release Bot 921d7d3364 Bump version to 10.11.3 2025-11-16 17:40:07 -05:00
theguymadmax f8e012582a Fix movie titles using folder name when NFOs saver is enabled (#15529) 2025-11-16 13:59:58 -07:00
theguymadmax def5956cd1 Fix tmdbid not detected in single movie folder (#14955) 2025-11-16 13:36:35 -07:00
theguymadmax abfbaca336 Fix series DateLastMediaAdded not updating when new episodes are added (#15472) 2025-11-16 13:35:43 -07:00
theguymadmax 6566188e45 Add 1 minute tolerance for NFO change detection (#15514) 2025-11-15 08:39:25 -07:00
theguymadmax 078f9584ed Fix playlist DateCreated and DateLastMediaAdded not being set (#15508) 2025-11-14 15:19:40 -07:00
Iksas ee34c75386 fix missing font extraction for certain transcoding settings (#15502) 2025-11-13 18:30:18 -07:00
theguymadmax e8150428b6 Fix .ignore handling for directories (#15501) 2025-11-13 18:23:18 -07:00
theguymadmax 4b38e35bbb Remove InheritedTags and update tag filtering logic (#15493) 2025-11-13 18:23:03 -07:00
Huo Jiacheng 435bb14bb2 Fix gitignore-style not working properly on windows. (#15487) 2025-11-12 19:43:13 -07:00
theguymadmax 2e5ced5098 Improve season folder parsing (#15404) 2025-11-12 17:36:57 -07:00
Bond-009 f4a846aa4d Don't error out when searching for marker files fails (#15466)
Fixes #15445
2025-11-11 15:45:47 -07:00
Joshua M. Boniface 7c1063177f Merge pull request #15462 from theguymadmax/fix-exception-for-empty-strm-files
Fix NullReferenceException in GetPathProtocol when path is null
2025-11-10 19:30:38 -05:00
Joshua M. Boniface 5878b1ffc5 Merge pull request #15468 from Bond-009/carefulWithLastMinChanges
Check if target exists before trying to follow it
2025-11-10 19:12:24 -05:00
Bond_009 3c3c2aee0d Check if target exists before trying to follow it
Exception got caught in ManagedFileSystem and wrong file info got returned
2025-11-10 23:19:17 +01:00
theguymadmax 511223aac4 Fix NullReferenceException in GetPathProtocol when path is null 2025-11-10 02:30:49 -05:00
Mikal S. 3b2d64995a Resolve symlinks for static media source infos (#15263) 2025-11-09 09:45:02 -07:00
theguymadmax 13c4517a66 Fix collection grouping in mixed libraries (#15373) 2025-11-09 09:35:50 -07:00
theguymadmax 177b6464ca Don't clear baseitemids (#15446) 2025-11-09 09:22:09 -07:00
Bond-009 5a9a8363f4 Merge pull request #15441 from IceStormNG/fix-nullreference-role-null-10.11
Fix System.NullReferenceException when people's role is null (10.11.z)
2025-11-08 18:25:03 +01:00
theguymadmax 49efd68fc7 Invalidate parent folder's cache on deletion/creation (#15423) 2025-11-08 08:30:04 -07:00
Carsten Braun 90a8a26c6e Copy-Pasting is sometimes hard.... 2025-11-08 15:00:11 +01:00
Carsten Braun 002c83e6f5 Fix NullReferenceExceltop when role is null. 2025-11-08 14:32:14 +01:00
theguymadmax 7222910b05 Fix filters to use SortName (#15381) 2025-11-07 18:21:41 -07:00
Bond-009 097cb87f6f Don't enforce a minimum amount of free space for the tmp and log dirs (#15390) 2025-11-07 18:21:10 -07:00
JPVenson 91c3b1617e Fixed missing sort argument (#15413) 2025-11-07 18:20:42 -07:00
theguymadmax 8f71922734 Fix item count display for collapsed items (#15380) 2025-11-07 18:20:10 -07:00
Niels van Velzen d140630208 Update branding in Swagger page (#15422) 2025-11-07 18:19:30 -07:00
theguymadmax 63a3e55297 Fix search terms using diacritics (#15435) 2025-11-07 18:18:24 -07:00
evanreichard c2e5081d64 feat(sqlite): add timeout config (#15369) 2025-11-07 18:17:43 -07:00
Jellyfin Release Bot 4187c6f620 Bump version to 10.11.2 2025-11-02 21:28:56 -05:00
Tim Eisele e7dbb3afec Skip too large extracted season numbers (#15326) 2025-11-02 09:11:48 -07:00
vinnyspb f994dd6211 Update file size when refreshing metadata (#15325) 2025-11-01 14:18:19 -06:00
Cody Robibero da254ee968 return instead of break, add check to more migrations (#15322) 2025-11-01 14:17:22 -06:00
Bill Thornton 4ad3141875 Update password reset to always return the same response structure (#15254) 2025-11-01 14:17:09 -06:00
evanreichard b5f0199a25 fix: in optimistic locking, key off table is locked (#15328) 2025-11-01 14:15:26 -06:00
Nyanmisaka 6bf88c049e Ignore initial delay in audio-only containers (#15247) 2025-10-29 20:40:28 -06:00
Jellyfin Release Bot 40a33da2a5 Bump version to 10.11.1 2025-10-26 22:02:09 -04:00
Joshua M. Boniface 3596fc0693 Fix bump_version to handle spaced filename 2025-10-26 21:50:38 -04:00
Jellyfin Release Bot 93824dad97 Bump version to 10.11.1 2025-10-26 21:41:27 -04:00
Tim Eisele e5656af1f2 Improve symlink handling (#15209) 2025-10-26 15:10:13 -06:00
Niels van Velzen c127c10458 Merge pull request #15225 from Bond-009/z440ATL
Update dependency z440.atl.core to 7.6.0
2025-10-26 18:50:04 +01:00
Tim Eisele 7d1824ea27 Fix pagination and sorting for folders (#15187) 2025-10-26 11:34:11 -06:00
Cody Robibero 2966d27c97 Skip invalid database migration (#15212) 2025-10-26 11:34:04 -06:00
Ivan Kara 618ec4543e Add season number fallback for OMDB and TMDB plugins (#15113) 2025-10-26 11:33:55 -06:00
Cody Robibero 0e4031ae52 Skip extracting directory entry when restoring (#15196) 2025-10-26 11:33:47 -06:00
CeruleanRed 442af96ed9 Only save chapters that are within the runtime of the video file (#15176) 2025-10-26 10:37:16 -06:00
JJBlue a305204cfa Skip extracted files in migration if bad timestamp or no access (#15220)
Fixes #15024
2025-10-26 10:30:43 -06:00
theguymadmax 75f472e6a7 Normalize paths in database queries (#15217) 2025-10-26 10:30:12 -06:00
Bond_009 cc32e8f7cb Update dependency z440.atl.core to 7.6.0 2025-10-26 15:16:08 +01:00
MBR-0001 14b3085ff1 Fix Has(Imdb/Tmdb/Tvdb)Id checks (#15126) 2025-10-25 16:00:55 -06:00
Cody Robibero 5691eee4f1 Prefer filting by package id instead of name (#15197) 2025-10-25 09:37:09 -06:00
theguymadmax 1520a697ad Play selected song first with instant mix (#15133) 2025-10-25 09:33:11 -06:00
Cody Robibero 81b8b0ca4a Add the transcode marker during startup instead of first transcode (#15194) 2025-10-25 09:32:15 -06:00
Cody Robibero ac3fa3c376 Clean up backup service (#15170) 2025-10-24 17:57:34 -06:00
Tim Eisele 7a1c1cd342 Skip extracted files in migration if bad timestamp or no access (#15112) 2025-10-24 17:57:19 -06:00
gnattu 70c32a26fa Make priority class setting more robust (#15177) 2025-10-24 17:57:02 -06:00
Cody Robibero 2b94bb54aa Fix xml formatter (#15164) 2025-10-24 17:56:38 -06:00
Bond-009 0a6e8146be Lower required tmp dir size to 512MiB (#15098) 2025-10-23 16:38:27 -06:00
theguymadmax 305b0fdca3 Make season paths case-insensitive (#15102) 2025-10-23 16:38:06 -06:00
theguymadmax d738386fe2 Fix LiveTV images not saving to database (#15083) 2025-10-23 16:37:55 -06:00
Tim Eisele ca830d5be7 Speed-up trickplay migration (#15054) 2025-10-23 16:37:47 -06:00
theguymadmax a5bc4524d8 Optimize artist query (#15087) 2025-10-23 16:37:29 -06:00
Nyanmisaka 175ee12bbc Fix videos with cropping metadata are probed as anamorphic (#15144) 2025-10-23 16:31:11 -06:00
Nyanmisaka a725220c21 Reject stream copy of HDR10+ video if the client does not support HDR10 (#15072) 2025-10-21 17:20:56 -06:00
gnattu a245605152 Log the message more clear when network manager is not ready (#15055) 2025-10-21 17:18:26 -06:00
Tim Eisele f4a53209f4 Skip invalid keyframe cache data (#15032) 2025-10-21 17:17:56 -06:00
Jellyfin Release Bot 877251bcae Bump version to 10.11.0 2025-10-19 20:45:12 -04:00
119 changed files with 18956 additions and 1173 deletions
-44
View File
@@ -1,44 +0,0 @@
name: "CodeQL"
on:
push:
branches: [ master ]
pull_request:
branches: [ master ]
schedule:
- cron: '24 2 * * 4'
permissions:
contents: read
security-events: write
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
language: [ 'csharp' ]
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: '10.0.x'
- name: Initialize CodeQL
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
languages: ${{ matrix.language }}
queries: +security-extended
- name: Autobuild
uses: github/codeql-action/autobuild@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
-159
View File
@@ -1,159 +0,0 @@
name: ABI Compatibility
on:
pull_request:
permissions: {}
jobs:
abi-head:
name: ABI - HEAD
runs-on: ubuntu-latest
permissions: read-all
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Setup .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: '10.0.x'
- name: Build
run: |
dotnet build Jellyfin.Server -o ./out
- name: Upload Head
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: abi-head
retention-days: 14
if-no-files-found: error
path: out/
abi-base:
name: ABI - BASE
if: ${{ github.base_ref != '' }}
runs-on: ubuntu-latest
permissions: read-all
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
fetch-depth: 0
- name: Setup .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: '10.0.x'
- name: Checkout common ancestor
env:
HEAD_REF: ${{ github.head_ref }}
run: |
git remote add upstream https://github.com/${{ github.event.pull_request.base.repo.full_name }}
git -c protocol.version=2 fetch --prune --progress --no-recurse-submodules upstream +refs/heads/*:refs/remotes/upstream/* +refs/tags/*:refs/tags/*
ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/$HEAD_REF)
git checkout --progress --force $ANCESTOR_REF
- name: Build
run: |
dotnet build Jellyfin.Server -o ./out
- name: Upload Head
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: abi-base
retention-days: 14
if-no-files-found: error
path: out/
abi-diff:
permissions:
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
name: ABI - Difference
if: ${{ github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
needs:
- abi-head
- abi-base
steps:
- name: Download abi-head
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: abi-head
path: abi-head
- name: Download abi-base
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: abi-base
path: abi-base
- name: Setup ApiCompat
run: |
dotnet tool install --global Microsoft.DotNet.ApiCompat.Tool
- name: Run ApiCompat
id: diff
run: |
{
echo 'body<<EOF'
for file in Jellyfin.Data.dll MediaBrowser.Common.dll MediaBrowser.Controller.dll MediaBrowser.Model.dll Emby.Naming.dll Jellyfin.Extensions.dll Jellyfin.MediaEncoding.Keyframes.dll Jellyfin.Database.Implementations.dll; do
COMPAT_OUTPUT="$( { apicompat --left ./abi-base/${file} --right ./abi-head/${file}; } 2>&1 || true )"
if [ "APICompat ran successfully without finding any breaking changes." != "${COMPAT_OUTPUT}" ]; then
printf "\n${file}\n${COMPAT_OUTPUT}\n"
fi
done
echo EOF
} >> $GITHUB_OUTPUT
- name: Find difference comment
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}
direction: last
body-includes: abi-diff-workflow-comment
- name: Reply or edit difference comment (changed)
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
if: ${{ steps.diff.outputs.body != '' }}
with:
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.find-comment.outputs.comment-id }}
edit-mode: replace
token: ${{ secrets.JF_BOT_TOKEN }}
body: |
<!--abi-diff-workflow-comment-->
<details>
<summary>ABI Difference</summary>
```
${{ steps.diff.outputs.body }}
```
</details>
- name: Reply or edit difference comment (unchanged)
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
if: ${{ steps.diff.outputs.body == '' && steps.find-comment.outputs.comment-id != '' }}
with:
issue-number: ${{ github.event.pull_request.number }}
comment-id: ${{ steps.find-comment.outputs.comment-id }}
edit-mode: replace
token: ${{ secrets.JF_BOT_TOKEN }}
body: |
<!--abi-diff-workflow-comment-->
<details>
<summary>ABI Difference</summary>
No changes to the ABI found. See history of this comment for previous changes.
</details>
-25
View File
@@ -1,25 +0,0 @@
name: Format
on:
push:
branches:
- master
# Run formatter against the forked branch, but
# do not allow access to secrets
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflows-in-forked-repositories
pull_request:
env:
SDK_VERSION: "10.0.x"
jobs:
format-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ env.SDK_VERSION }}
- name: Run DotNet Format
run: dotnet format --verify-no-changes --verbosity minimal
-45
View File
@@ -1,45 +0,0 @@
name: Tests
on:
push:
branches:
- master
# Run tests against the forked branch, but
# do not allow access to secrets
# https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows#workflows-in-forked-repositories
pull_request:
env:
SDK_VERSION: "10.0.x"
jobs:
run-tests:
strategy:
matrix:
os: ["ubuntu-latest", "macos-latest", "windows-latest"]
fail-fast: false
runs-on: "${{ matrix.os }}"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ env.SDK_VERSION }}
- name: Run DotNet CLI Tests
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@d3ebf1f760f7d8ab92cc44d9bcfee7ad73722a31 # v5.5.11
with:
reports: "**/coverage.cobertura.xml"
targetdir: "merged/"
reporttypes: "Cobertura"
# TODO - which action / tool to use to publish code coverage results?
# - name: Publish code coverage results
-63
View File
@@ -1,63 +0,0 @@
name: Commands
on:
issue_comment:
types:
- created
- edited
pull_request:
types:
- labeled
- synchronize
permissions: {}
jobs:
rebase:
name: Rebase
if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '@jellyfin-bot rebase') && github.event.comment.author_association == 'MEMBER'
runs-on: ubuntu-latest
steps:
- name: Notify as seen
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
with:
token: ${{ secrets.JF_BOT_TOKEN }}
comment-id: ${{ github.event.comment.id }}
reactions: '+1'
- name: Checkout the latest code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
token: ${{ secrets.JF_BOT_TOKEN }}
fetch-depth: 0
- name: Automatic Rebase
uses: cirrus-actions/rebase@b87d48154a87a85666003575337e27b8cd65f691 # 1.8
env:
GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
rename:
name: Rename
if: contains(github.event.comment.body, '@jellyfin-bot rename')
runs-on: ubuntu-latest
steps:
- name: pull in script
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: jellyfin/jellyfin-triage-script
- name: install python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.14'
cache: 'pip'
- name: install python packages
run: pip install -r rename/requirements.txt
- name: run rename script
run: python3 rename.py
working-directory: ./rename
env:
GH_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
GH_REPO: ${{ github.repository }}
ISSUE: ${{ github.event.issue.number }}
COMMENT_ID: ${{ github.event.comment.id }}
-35
View File
@@ -1,35 +0,0 @@
name: Stale Issue Labeler
on:
schedule:
- cron: '30 1 * * *'
workflow_dispatch:
permissions:
issues: write
pull-requests: write
actions: write
jobs:
issues:
name: Check for stale issues
runs-on: ubuntu-latest
if: ${{ contains(github.repository, 'jellyfin/') }}
steps:
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
with:
repo-token: ${{ secrets.JF_BOT_TOKEN }}
ascending: true
days-before-stale: 120
days-before-pr-stale: -1
days-before-close: 21
days-before-pr-close: -1
operations-per-run: 500
exempt-issue-labels: regression,security,roadmap,future,feature,enhancement,confirmed
stale-issue-label: stale
stale-issue-message: |-
This issue has gone 120 days without an update and will be closed within 21 days if there is no new activity. To prevent this issue from being closed, please confirm the issue has not already been fixed by providing updated examples or logs.
If you have any questions you can use one of several ways to [contact us](https://jellyfin.org/contact).
close-issue-message: |-
This issue was closed due to inactivity.
@@ -1,32 +0,0 @@
name: Check Issue Template
on:
issues:
types:
- opened
jobs:
check_issue:
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: pull in script
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: jellyfin/jellyfin-triage-script
- name: install python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.14'
cache: 'pip'
- name: install python packages
run: pip install -r main-repo-triage/requirements.txt
- name: check and comment issue
working-directory: ./main-repo-triage
run: python3 single_issue_gha.py
env:
GH_TOKEN: ${{ secrets.JF_BOT_TOKEN }}
GH_REPO: ${{ github.repository }}
ISSUE: ${{ github.event.issue.number }}
-44
View File
@@ -1,44 +0,0 @@
name: OpenAPI Generate
on:
workflow_call:
inputs:
ref:
required: true
type: string
repository:
required: true
type: string
artifact:
required: true
type: string
permissions:
contents: read
jobs:
main:
name: Main
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.ref }}
repository: ${{ inputs.repository }}
- name: Configure .NET
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: '10.0.x'
- name: Create File
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter Jellyfin.Server.Integration.Tests.OpenApiSpecTests
- name: Upload Artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: ${{ inputs.artifact }}
path: tests/Jellyfin.Server.Integration.Tests/bin/Release/net10.0/openapi.json
retention-days: 14
if-no-files-found: error
-140
View File
@@ -1,140 +0,0 @@
name: OpenAPI Publish
on:
push:
branches:
- master
tags:
- 'v*'
jobs:
publish-openapi:
name: OpenAPI - Publish Artifact
uses: ./.github/workflows/openapi-generate.yml
permissions:
contents: read
with:
ref: ${{ github.sha }}
repository: ${{ github.repository }}
artifact: openapi-head
publish-unstable:
name: OpenAPI - Publish Unstable Spec
if: ${{ github.event_name != 'pull_request' && !startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
runs-on: ubuntu-latest
needs:
- publish-openapi
steps:
- name: Set unstable dated version
id: version
run: |-
echo "JELLYFIN_VERSION=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_ENV
- name: Download openapi-head
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: openapi-head
path: openapi-head
- name: Upload openapi.json (unstable) to repository server
uses: appleboy/scp-action@ff85246acaad7bdce478db94a363cd2bf7c90345 # v1.0.0
with:
host: "${{ secrets.REPO_HOST }}"
username: "${{ secrets.REPO_USER }}"
key: "${{ secrets.REPO_KEY }}"
source: openapi-head/openapi.json
strip_components: 1
target: "/srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
- name: Move openapi.json (unstable) into place
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: "${{ secrets.REPO_HOST }}"
username: "${{ secrets.REPO_USER }}"
key: "${{ secrets.REPO_KEY }}"
debug: false
script: |
if ! test -d /run/workflows; then
sudo mkdir -p /run/workflows
sudo chown ${{ secrets.REPO_USER }} /run/workflows
fi
(
flock -x -w 300 200 || exit 1
TGT_DIR="/srv/repository/main/openapi"
LAST_SPEC="$( ls -lt ${TGT_DIR}/unstable/ | grep 'jellyfin-openapi' | head -1 | awk '{ print $NF }' )"
# If new and previous spec don't differ (diff retcode 0), remove incoming and finish
if diff /srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}/openapi.json ${TGT_DIR}/unstable/${LAST_SPEC} &>/dev/null; then
rm -r /srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}
exit 0
fi
# Move new spec into place
sudo mv /srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}/openapi.json ${TGT_DIR}/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}.json
# Delete previous jellyfin-openapi-unstable_previous.json
sudo rm ${TGT_DIR}/jellyfin-openapi-unstable_previous.json
# Move current jellyfin-openapi-unstable.json symlink to jellyfin-openapi-unstable_previous.json
sudo mv ${TGT_DIR}/jellyfin-openapi-unstable.json ${TGT_DIR}/jellyfin-openapi-unstable_previous.json
# Create new jellyfin-openapi-unstable.json symlink
sudo ln -s unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}.json ${TGT_DIR}/jellyfin-openapi-unstable.json
# Check that the previous openapi unstable spec link is correct
if [[ "$( readlink ${TGT_DIR}/jellyfin-openapi-unstable_previous.json )" != "unstable/${LAST_SPEC}" ]]; then
sudo rm ${TGT_DIR}/jellyfin-openapi-unstable_previous.json
sudo ln -s unstable/${LAST_SPEC} ${TGT_DIR}/jellyfin-openapi-unstable_previous.json
fi
) 200>/run/workflows/openapi-unstable.lock
publish-stable:
name: OpenAPI - Publish Stable Spec
if: ${{ startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
runs-on: ubuntu-latest
needs:
- publish-openapi
steps:
- name: Set version number
id: version
run: |-
echo "JELLYFIN_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
- name: Download openapi-head
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: openapi-head
path: openapi-head
- name: Upload openapi.json (stable) to repository server
uses: appleboy/scp-action@ff85246acaad7bdce478db94a363cd2bf7c90345 # v1.0.0
with:
host: "${{ secrets.REPO_HOST }}"
username: "${{ secrets.REPO_USER }}"
key: "${{ secrets.REPO_KEY }}"
source: openapi-head/openapi.json
strip_components: 1
target: "/srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
- name: Move openapi.json (stable) into place
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: "${{ secrets.REPO_HOST }}"
username: "${{ secrets.REPO_USER }}"
key: "${{ secrets.REPO_KEY }}"
debug: false
script: |
if ! test -d /run/workflows; then
sudo mkdir -p /run/workflows
sudo chown ${{ secrets.REPO_USER }} /run/workflows
fi
(
flock -x -w 300 200 || exit 1
TGT_DIR="/srv/repository/main/openapi"
LAST_SPEC="$( ls -lt ${TGT_DIR}/stable/ | grep 'jellyfin-openapi' | head -1 | awk '{ print $NF }' )"
# If new and previous spec don't differ (diff retcode 0), remove incoming and finish
if diff /srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}/openapi.json ${TGT_DIR}/stable/${LAST_SPEC} &>/dev/null; then
rm -r /srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}
exit 0
fi
# Move new spec into place
sudo mv /srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}/openapi.json ${TGT_DIR}/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}.json
# Delete previous jellyfin-openapi-stable_previous.json
sudo rm ${TGT_DIR}/jellyfin-openapi-stable_previous.json
# Move current jellyfin-openapi-stable.json symlink to jellyfin-openapi-stable_previous.json
sudo mv ${TGT_DIR}/jellyfin-openapi-stable.json ${TGT_DIR}/jellyfin-openapi-stable_previous.json
# Create new jellyfin-openapi-stable.json symlink
sudo ln -s stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}.json ${TGT_DIR}/jellyfin-openapi-stable.json
# Check that the previous openapi stable spec link is correct
if [[ "$( readlink ${TGT_DIR}/jellyfin-openapi-stable_previous.json )" != "stable/${LAST_SPEC}" ]]; then
sudo rm ${TGT_DIR}/jellyfin-openapi-stable_previous.json
sudo ln -s stable/${LAST_SPEC} ${TGT_DIR}/jellyfin-openapi-stable_previous.json
fi
) 200>/run/workflows/openapi-stable.lock
@@ -1,80 +0,0 @@
name: OpenAPI Check
on:
pull_request:
jobs:
ancestor:
name: Common Ancestor
runs-on: ubuntu-latest
outputs:
base_ref: ${{ steps.ancestor.outputs.base_ref }}
steps:
- name: Checkout Repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
fetch-depth: 0
- name: Search History
id: ancestor
run: |
git remote add upstream https://github.com/${{ github.event.pull_request.base.repo.full_name }}
git fetch --prune --progress --no-recurse-submodules upstream +refs/heads/*:refs/remotes/upstream/* +refs/tags/*:refs/tags/*
ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} HEAD)
echo "ref: ${ANCESTOR_REF}"
echo "base_ref=${ANCESTOR_REF}" >> "$GITHUB_OUTPUT"
head:
name: Head Artifact
uses: ./.github/workflows/openapi-generate.yml
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
artifact: openapi-head
base:
name: Base Artifact
uses: ./.github/workflows/openapi-generate.yml
needs:
- ancestor
with:
ref: ${{ needs.ancestor.outputs.base_ref }}
repository: ${{ github.event.pull_request.base.repo.full_name }}
artifact: openapi-base
diff:
name: Generate Report
runs-on: ubuntu-latest
needs:
- head
- base
steps:
- name: Download Head
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: openapi-head
path: openapi-head
- name: Download Base
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: openapi-base
path: openapi-base
- name: Detect Changes
id: openapi-diff
run: |
sed -i 's:allOf:oneOf:g' openapi-head/openapi.json
sed -i 's:allOf:oneOf:g' openapi-base/openapi.json
mkdir -p /tmp/openapi-report
mv openapi-head/openapi.json /tmp/openapi-report/head.json
mv openapi-base/openapi.json /tmp/openapi-report/base.json
docker run -v /tmp/openapi-report:/data openapitools/openapi-diff:2.1.6 /data/base.json /data/head.json --state -l ERROR --markdown /data/openapi-report.md
- name: Upload Artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: openapi-report
path: /tmp/openapi-report/openapi-report.md
@@ -1,59 +0,0 @@
name: OpenAPI Report
on:
workflow_run:
workflows:
- OpenAPI Check
types:
- completed
jobs:
metadata:
name: Generate Metadata
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
outputs:
pr_number: ${{ steps.pr_number.outputs.pr_number }}
steps:
- name: Get Pull Request Number
id: pr_number
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ github.event.workflow_run.head_sha }}
run: |
API_RESPONSE=$(gh pr list --repo "${GITHUB_REPOSITORY}" --search "${HEAD_SHA}" --state open --json number)
PR_NUMBER=$(echo "${API_RESPONSE}" | jq '.[0].number')
echo "repository: ${GITHUB_REPOSITORY}"
echo "sha: ${HEAD_SHA}"
echo "response: ${API_RESPONSE}"
echo "pr: ${PR_NUMBER}"
echo "pr_number=${PR_NUMBER}" >> "${GITHUB_OUTPUT}"
comment:
name: Pull Request Comment
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
needs:
- metadata
permissions:
pull-requests: write
actions: read
contents: read
steps:
- name: Download OpenAPI Report
id: download_report
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: openapi-report
path: openapi-report
run-id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Push Comment
uses: thollander/actions-comment-pull-request@24bffb9b452ba05a4f3f77933840a6a841d1b32b # v3.0.1
with:
github-token: ${{ secrets.JF_BOT_TOKEN }}
file-path: ${{ steps.download_report.outputs.download-path }}/openapi-report.md
pr-number: ${{ needs.metadata.outputs.pr_number }}
comment-tag: openapi-report
-66
View File
@@ -1,66 +0,0 @@
name: Project Automation
on:
push:
branches:
- master
pull_request:
issue_comment:
permissions: {}
jobs:
project:
name: Project board
runs-on: ubuntu-latest
if: ${{ github.repository == 'jellyfin/jellyfin' }}
steps:
- name: Remove from 'Current Release' project
uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0
if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport')
continue-on-error: true
with:
project: Current Release
action: delete
column: In progress
repo-token: ${{ secrets.JF_BOT_TOKEN }}
- name: Add to 'Release Next' project
uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0
if: (github.event.pull_request || github.event.issue.pull_request) && github.event.action == 'opened'
continue-on-error: true
with:
project: Release Next
column: In progress
repo-token: ${{ secrets.JF_BOT_TOKEN }}
- name: Add to 'Current Release' project
uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0
if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport')
continue-on-error: true
with:
project: Current Release
column: In progress
repo-token: ${{ secrets.JF_BOT_TOKEN }}
- name: Check number of comments from the team member
if: github.event.issue.pull_request == '' && github.event.comment.author_association == 'MEMBER'
id: member_comments
run: echo "::set-output name=number::$(curl -s ${{ github.event.issue.comments_url }} | jq '.[] | select(.author_association == "MEMBER") | .author_association' | wc -l)"
- name: Move issue to needs triage
uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0
if: github.event.issue.pull_request == '' && github.event.comment.author_association == 'MEMBER' && steps.member_comments.outputs.number <= 1
continue-on-error: true
with:
project: Issue Triage for Main Repo
column: Needs triage
repo-token: ${{ secrets.JF_BOT_TOKEN }}
- name: Add issue to triage project
uses: alex-page/github-project-automation-plus@303f24a24c67ce7adf565a07e96720faf126fe36 # v0.9.0
if: github.event.issue.pull_request == '' && github.event.action == 'opened'
continue-on-error: true
with:
project: Issue Triage for Main Repo
column: Pending response
repo-token: ${{ secrets.JF_BOT_TOKEN }}
@@ -1,24 +0,0 @@
name: Merge Conflict Labeler
on:
push:
branches:
- master
pull_request_target:
types: [synchronize]
permissions: {}
jobs:
main:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write
if: ${{ github.repository == 'jellyfin/jellyfin' }}
steps:
- name: Apply label
uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
with:
dirtyLabel: 'merge conflict'
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
repoToken: ${{ secrets.JF_BOT_TOKEN }}
-30
View File
@@ -1,30 +0,0 @@
name: Stale PR Check
on:
schedule:
- cron: '30 */12 * * *'
workflow_dispatch:
permissions:
pull-requests: write
actions: write
jobs:
prs-stale-conflicts:
name: Check PRs with merge conflicts
runs-on: ubuntu-latest
if: ${{ contains(github.repository, 'jellyfin/') }}
steps:
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
with:
repo-token: ${{ secrets.JF_BOT_TOKEN }}
ascending: true
operations-per-run: 150
# The merge conflict action will remove the label when updated
remove-stale-when-updated: false
days-before-stale: -1
days-before-close: 90
days-before-issue-close: -1
stale-pr-label: merge conflict
close-pr-message: |-
This PR has been closed due to having unresolved merge conflicts.
@@ -1,82 +0,0 @@
name: '🆙 Auto bump_version'
on:
release:
types:
- published
workflow_dispatch:
inputs:
TAG_BRANCH:
required: true
description: release-x.y.z
NEXT_VERSION:
required: true
description: x.y.z
jobs:
auto_bump_version:
runs-on: ubuntu-latest
if: ${{ github.event_name == 'release' && !contains(github.event.release.tag_name, 'rc') }}
env:
TAG_BRANCH: ${{ github.event.release.target_commitish }}
steps:
- name: Wait for deploy checks to finish
uses: jitterbit/await-check-suites@292a541bb7618078395b2ce711a0d89cfb8a568a # v1
with:
ref: ${{ env.TAG_BRANCH }}
intervalSeconds: 60
timeoutSeconds: 3600
- name: Setup YQ
uses: chrisdickinson/setup-yq@fa3192edd79d6eb0e4e12de8dde3a0c26f2b853b # latest
with:
yq-version: v4.9.8
- name: Checkout Repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ env.TAG_BRANCH }}
- name: Setup EnvVars
run: |-
CURRENT_VERSION=$(yq e '.version' build.yaml)
CURRENT_MAJOR_MINOR=${CURRENT_VERSION%.*}
CURRENT_PATCH=${CURRENT_VERSION##*.}
echo "CURRENT_VERSION=${CURRENT_VERSION}" >> $GITHUB_ENV
echo "CURRENT_MAJOR_MINOR=${CURRENT_MAJOR_MINOR}" >> $GITHUB_ENV
echo "CURRENT_PATCH=${CURRENT_PATCH}" >> $GITHUB_ENV
echo "NEXT_VERSION=${CURRENT_MAJOR_MINOR}.$(($CURRENT_PATCH + 1))" >> $GITHUB_ENV
- name: Run bump_version
run: ./bump_version ${{ env.NEXT_VERSION }}
- name: Commit Changes
run: |-
git config user.name "jellyfin-bot"
git config user.email "team@jellyfin.org"
git checkout ${{ env.TAG_BRANCH }}
git commit -am "Bump version to ${{ env.NEXT_VERSION }}"
git push origin ${{ env.TAG_BRANCH }}
manual_bump_version:
runs-on: ubuntu-latest
if: ${{ github.event_name == 'workflow_dispatch' }}
env:
TAG_BRANCH: ${{ github.event.inputs.TAG_BRANCH }}
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
steps:
- name: Checkout Repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ env.TAG_BRANCH }}
- name: Run bump_version
run: ./bump_version ${{ env.NEXT_VERSION }}
- name: Commit Changes
run: |-
git config user.name "jellyfin-bot"
git config user.email "team@jellyfin.org"
git checkout ${{ env.TAG_BRANCH }}
git commit -am "Bump version to ${{ env.NEXT_VERSION }}"
git push origin ${{ env.TAG_BRANCH }}
+79
View File
@@ -0,0 +1,79 @@
when:
- event: pull_request
- event: push
steps:
# Restore, build and test in one step so the NuGet cache stays on the step's
# own ephemeral storage instead of the 10Gi workspace volume.
# global.json pins the .NET 10 SDK (10.0.0, rollForward latestMinor).
- name: build-test
image: mcr.microsoft.com/dotnet/sdk:10.0
environment:
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
DOTNET_NOLOGO: "1"
commands:
- dotnet --info
- dotnet restore Jellyfin.sln
- dotnet build Jellyfin.sln -c Release --no-restore
# libSkiaSharp needs fontconfig to load and the SDK image does not ship it.
# The mirror occasionally serves a half-synced index, so update is retried.
- apt-get -o Acquire::Retries=3 update || apt-get -o Acquire::Retries=3 update
- apt-get install -y --no-install-recommends libfontconfig1
- ldconfig -p | grep -q libfontconfig
# BackupServiceTests is excluded because BackupService refuses to write a
# backup with less than 5GiB free on its target path. The clone and build
# output fill 5.7G of the 9.8G workspace volume, leaving 4.1G. Raising the
# agent's workspace volume size is the only way to run it here.
- df -h .
- dotnet test Jellyfin.sln -c Release --no-build --verbosity minimal --filter "Category!=RequiresDocker&FullyQualifiedName!~Integration&FullyQualifiedName!~BackupServiceTests"
backend_options:
kubernetes:
serviceAccountName: jellyfin-ha-src
resources:
requests:
memory: 2Gi
cpu: 2
ephemeral-storage: 10Gi
limits:
memory: 8Gi
cpu: 4
ephemeral-storage: 20Gi
# The PostgreSQL migration tests are the only ones that run the startup migration chain against
# the provider production uses, and the filter above has always skipped them.
# The server runs inside this step: the kubernetes backend has no docker daemon for
# testcontainers, and a postgres service container deadlocks the step because the backend mounts
# the ReadWriteOnce workspace volume into service pods and schedules them on another node.
# Its data directory lives on the step's ephemeral storage, not on the workspace volume.
# Scoped to Jellyfin.Server.Tests: the three classes in Jellyfin.Database.Tests.PostgreSQL still
# start their own container, and PostgreSqlProviderTests already fails on main - on an EF 10
# scalar query and on its own data - which a third test in the class then inherits.
- name: postgres-migration-chain
image: mcr.microsoft.com/dotnet/sdk:10.0
depends_on:
- build-test
environment:
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
DOTNET_NOLOGO: "1"
JELLYFIN_TEST_POSTGRES: "Host=127.0.0.1;Port=5432;Database=postgres;Username=postgres"
commands:
- apt-get -o Acquire::Retries=3 update || apt-get -o Acquire::Retries=3 update
- DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends postgresql
- install -d -o postgres -g postgres /tmp/pgdata /tmp/pgrun
- PGBIN=$(ls -d /usr/lib/postgresql/*/bin | tail -1)
- su postgres -c "$PGBIN/initdb -D /tmp/pgdata -A trust -U postgres"
- su postgres -c "$PGBIN/pg_ctl -D /tmp/pgdata -o \"-c listen_addresses=127.0.0.1 -k /tmp/pgrun\" -l /tmp/pg.log -w start"
- dotnet build tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj -c Release
- dotnet test tests/Jellyfin.Server.Tests/Jellyfin.Server.Tests.csproj -c Release --no-build --verbosity minimal --filter "Category=RequiresDocker"
backend_options:
kubernetes:
serviceAccountName: jellyfin-ha-src
resources:
requests:
memory: 2Gi
cpu: 2
ephemeral-storage: 6Gi
limits:
memory: 6Gi
cpu: 4
ephemeral-storage: 12Gi
+7
View File
@@ -8,6 +8,7 @@
<PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" />
<PackageVersion Include="AutoFixture.Xunit3" Version="4.19.0" />
<PackageVersion Include="AutoFixture" Version="4.18.1" />
<PackageVersion Include="AWSSDK.S3" Version="4.0.103.1" />
<PackageVersion Include="BDInfo" Version="0.8.0" />
<PackageVersion Include="BitFaster.Caching" Version="2.6.1" />
<PackageVersion Include="BlurHashSharp.SkiaSharp" Version="1.4.0-pre.1" />
@@ -46,6 +47,7 @@
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.11" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.9.0" />
<PackageVersion Include="MimeTypes" Version="2.5.2" />
@@ -53,6 +55,8 @@
<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="10.0.3" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageVersion Include="PDFtoImage" Version="5.2.1" />
<PackageVersion Include="PlaylistsNET" Version="1.4.1" />
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
@@ -74,12 +78,15 @@
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" />
<PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="3.119.4" />
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
<PackageVersion Include="StackExchange.Redis" Version="2.13.17" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
<PackageVersion Include="Svg.Skia" Version="3.7.0" />
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.3" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
<PackageVersion Include="System.Text.Json" Version="10.0.11" />
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.15.0" />
<PackageVersion Include="Testcontainers.Redis" Version="4.15.0" />
<PackageVersion Include="z440.atl.core" Version="7.16.0" />
<PackageVersion Include="TMDbLib" Version="3.0.0" />
<PackageVersion Include="UTF.Unknown" Version="2.7.0" />
+96
View File
@@ -0,0 +1,96 @@
# syntax=docker/dockerfile:1
# ── Build stage ──────────────────────────────────────────────────────────────
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Restore dependencies first (layer-cache friendly)
COPY ["Jellyfin.sln", "global.json", "nuget.config", "Directory.Build.props", "Directory.Packages.props", "./"]
COPY ["SharedVersion.cs", "BannedSymbols.txt", "stylecop.json", "./"]
# Copy all project files so dotnet restore can resolve the full dependency graph
COPY Emby.Naming/ Emby.Naming/
COPY Emby.Photos/ Emby.Photos/
COPY Emby.Server.Implementations/ Emby.Server.Implementations/
COPY Jellyfin.Api/ Jellyfin.Api/
COPY Jellyfin.Data/ Jellyfin.Data/
COPY Jellyfin.Server/ Jellyfin.Server/
COPY Jellyfin.Server.Implementations/ Jellyfin.Server.Implementations/
COPY MediaBrowser.Common/ MediaBrowser.Common/
COPY MediaBrowser.Controller/ MediaBrowser.Controller/
COPY MediaBrowser.LocalMetadata/ MediaBrowser.LocalMetadata/
COPY MediaBrowser.MediaEncoding/ MediaBrowser.MediaEncoding/
COPY MediaBrowser.Model/ MediaBrowser.Model/
COPY MediaBrowser.Providers/ MediaBrowser.Providers/
COPY MediaBrowser.XbmcMetadata/ MediaBrowser.XbmcMetadata/
COPY src/ src/
RUN dotnet restore Jellyfin.Server/Jellyfin.Server.csproj \
--runtime linux-x64
# Publish the server (and all transitive dependencies, including the
# PostgreSQL provider assembly added by this fork).
# Note: TreatWarningsAsErrors is disabled for the Docker build — StyleCop
# analyzer violations in upstream src/ projects would otherwise block the
# image build. StyleCop is enforced in the CI pipeline, not the Dockerfile.
RUN dotnet publish Jellyfin.Server/Jellyfin.Server.csproj \
--configuration Release \
--runtime linux-x64 \
--self-contained false \
--no-restore \
-p:TreatWarningsAsErrors=false \
--output /app
# ── Web client stage ──────────────────────────────────────────────────────────
# Install jellyfin-web via the official Jellyfin apt repo.
# Package suffix in the bookworm repo is +deb12 (e.g. 12.0+deb12).
# Web assets land at /usr/share/jellyfin/web/ — stable, prebuilt, no npm required.
# The web client version must match the server version this fork is based on.
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=12.0+deb12" \
&& rm -rf /var/lib/apt/lists/* \
&& echo "Web client files:" && ls /usr/share/jellyfin/web/ | head -10
# ── Runtime stage ─────────────────────────────────────────────────────────────
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/aspnet:10.0
# Install FFmpeg and native dependencies required by SkiaSharp and fontconfig.
# libicu, libssl, and liblttng-ust are already present in the dotnet/aspnet base image.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ffmpeg \
fontconfig \
libfontconfig1 \
libfreetype6 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /jellyfin
COPY --from=build /app .
COPY --from=webclient /usr/share/jellyfin/web ./jellyfin-web/
# Jellyfin default ports
EXPOSE 8096
EXPOSE 8920
# Data / config volumes
VOLUME ["/config", "/cache", "/media"]
ENV JELLYFIN_DATA_DIR=/config \
JELLYFIN_CACHE_DIR=/cache \
JELLYFIN_LOG_DIR=/config/log \
JELLYFIN_CONFIG_DIR=/config
ENTRYPOINT ["./jellyfin", \
"--datadir", "/config", \
"--cachedir", "/cache", \
"--webdir", "/jellyfin/jellyfin-web"]
+55
View File
@@ -0,0 +1,55 @@
# syntax=docker/dockerfile:1
# Runtime-only image — the .NET publish step runs on the CI host (runner),
# not inside this Dockerfile.
# ── Web client stage ──────────────────────────────────────────────────────────
# Install jellyfin-web via the official Jellyfin apt repo.
# Package suffix in the bookworm repo is +deb12 (e.g. 12.0+deb12).
# Web assets land at /usr/share/jellyfin/web/ — stable, prebuilt, no npm required.
# The web client version must match the server version this fork is based on.
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=12.0+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"]
@@ -65,6 +65,7 @@
<ItemGroup>
<PackageReference Include="Ignore" />
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,235 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
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:";
/// <summary>
/// Lua script for atomic takeover: reads the stored session, checks whether the lease has
/// expired (comparing the stored expiry against the caller-supplied current time) and, if it
/// has, claims it for the calling pod 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)
if tonumber(session['LeaseExpiresUtc']) > tonumber(ARGV[1]) then return 0 end
session['OwnerPod'] = ARGV[2]
session['LeaseExpiresUtc'] = tonumber(ARGV[1]) + tonumber(ARGV[3])
redis.call('SET', KEYS[1], cjson.encode(session), 'PX', tonumber(ARGV[4]))
return 1";
/// <summary>
/// Lua script for atomic, ownership-checked renewal: extends the lease only while the calling
/// pod still owns it, so a renewal racing a successful takeover cannot revert the new owner.
/// </summary>
private const string RenewScript = @"
local raw = redis.call('GET', KEYS[1])
if not raw then return 0 end
local session = cjson.decode(raw)
if session['OwnerPod'] ~= ARGV[2] then return 0 end
session['LeaseExpiresUtc'] = tonumber(ARGV[1]) + tonumber(ARGV[3])
redis.call('SET', KEYS[1], cjson.encode(session), 'PX', tonumber(ARGV[4]))
return 1";
// The lease expiry is serialized as unix milliseconds because the Lua scripts compare it
// numerically; an ISO-8601 string cannot be compared against a number in Lua.
private static readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions
{
Converters = { new UnixMillisecondsDateTimeConverter() }
};
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;
}
private long LeaseDurationMs => (long)_options.LeaseDurationSeconds * 1000;
private long RetentionMs => Math.Max((long)_options.SessionRetentionSeconds * 1000, LeaseDurationMs);
/// <inheritdoc />
public async Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default)
{
var key = GetKey(session.PlaySessionId);
var json = JsonSerializer.Serialize(session, _jsonOptions);
await _db.StringSetAsync(key, json, TimeSpan.FromMilliseconds(RetentionMs)).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(), _jsonOptions);
// The record outlives the lease so that an orphaned session can still be taken over,
// so the lease has to be checked explicitly here.
if (session is null || session.LeaseExpiresUtc <= DateTime.UtcNow)
{
return null;
}
return session;
}
/// <inheritdoc />
public async Task<bool> RenewLeaseAsync(string playSessionId, string ownerPod, CancellationToken cancellationToken = default)
{
var result = (long?)await _db.ScriptEvaluateAsync(
RenewScript,
keys: new RedisKey[] { GetKey(playSessionId) },
values: new RedisValue[] { UnixMillisecondsNow(), ownerPod, LeaseDurationMs, RetentionMs }).ConfigureAwait(false);
if (result != 1)
{
_logger.LogWarning(
"Pod {OwnerPod} no longer owns transcode session {PlaySessionId}; lease not renewed.",
ownerPod,
playSessionId);
return false;
}
_logger.LogDebug("Renewed lease for transcode session {PlaySessionId}.", playSessionId);
return true;
}
/// <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 result = (long?)await _db.ScriptEvaluateAsync(
TakeoverScript,
keys: new RedisKey[] { GetKey(playSessionId) },
values: new RedisValue[] { UnixMillisecondsNow(), claimingPod, LeaseDurationMs, RetentionMs }).ConfigureAwait(false);
var succeeded = result == 1;
if (succeeded)
{
_logger.LogInformation("Pod {ClaimingPod} successfully took over transcode session {PlaySessionId}.", claimingPod, playSessionId);
}
return succeeded;
}
/// <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(), _jsonOptions);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to deserialize transcode session from Redis.");
continue;
}
if (session is not null && session.LeaseExpiresUtc > DateTime.UtcNow)
{
sessions.Add(session);
}
}
}
return sessions;
}
private static string GetKey(string playSessionId) => KeyPrefix + playSessionId;
private static long UnixMillisecondsNow() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
private sealed class UnixMillisecondsDateTimeConverter : JsonConverter<DateTime>
{
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
var milliseconds = reader.TryGetInt64(out var value) ? value : (long)reader.GetDouble();
return DateTimeOffset.FromUnixTimeMilliseconds(milliseconds).UtcDateTime;
}
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
{
var utc = value.Kind switch
{
DateTimeKind.Utc => value,
DateTimeKind.Local => value.ToUniversalTime(),
_ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
};
writer.WriteNumberValue(new DateTimeOffset(utc, TimeSpan.Zero).ToUnixTimeMilliseconds());
}
}
}
@@ -0,0 +1,56 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.MediaEncoding;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
namespace Emby.Server.Implementations.MediaEncoding;
/// <summary>
/// Pings the configured Redis transcode session store once at startup so an unreachable store is
/// reported there instead of being discovered as a silent loss of cross-pod takeover.
/// </summary>
public sealed class TranscodeStoreConnectivityProbe : IHostedService
{
private readonly IServiceProvider _serviceProvider;
private readonly ILogger<TranscodeStoreConnectivityProbe> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="TranscodeStoreConnectivityProbe"/> class.
/// </summary>
/// <param name="serviceProvider">The service provider used to resolve the Redis connection.</param>
/// <param name="logger">The logger.</param>
public TranscodeStoreConnectivityProbe(IServiceProvider serviceProvider, ILogger<TranscodeStoreConnectivityProbe> logger)
{
_serviceProvider = serviceProvider;
_logger = logger;
}
/// <inheritdoc />
public async Task StartAsync(CancellationToken cancellationToken)
{
try
{
// Resolved here rather than injected: connecting must not be able to abort startup.
var redis = _serviceProvider.GetRequiredService<IConnectionMultiplexer>();
var roundTrip = await redis.GetDatabase().PingAsync().ConfigureAwait(false);
_logger.LogInformation(
"Redis transcode session store is reachable ({RoundTripMs}ms round trip). HA transcode takeover is active.",
(long)roundTrip.TotalMilliseconds);
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Redis transcode session store is configured but UNREACHABLE. HA transcode takeover is not working: sessions stay local to this instance and are lost when it restarts. Check {Key}.",
TranscodeStoreOptions.RedisConnectionStringKey);
}
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
@@ -0,0 +1,81 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.ScheduledTasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
namespace Emby.Server.Implementations.ScheduledTasks;
/// <summary>
/// A Redis-backed <see cref="IScanLeaderLease"/> that elects a single scan-leader instance using a
/// TTL lease on a shared key. The lease value is this instance's pod identity; a leader that keeps
/// renewing retains the lease, and any instance can claim it once the previous leader's lease expires.
/// </summary>
public sealed class RedisScanLeaderLease : IScanLeaderLease
{
private const string LeaderKey = "jellyfin:scanleader";
/// <summary>
/// Lua script for atomic acquire-or-renew: if the key is unset (missing or already expired) it is
/// set to this pod for the lease duration and 1 is returned; if it already holds this pod the TTL is
/// extended and 1 is returned; otherwise another pod owns a live lease and 0 is returned.
/// </summary>
private const string AcquireOrRenewScript = @"
local current = redis.call('GET', KEYS[1])
if not current then
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
return 1
elseif current == ARGV[1] then
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return 1
else
return 0
end";
private readonly IDatabase _db;
private readonly ScanLeaderOptions _options;
private readonly ILogger<RedisScanLeaderLease> _logger;
private readonly string _podId;
/// <summary>
/// Initializes a new instance of the <see cref="RedisScanLeaderLease"/> class.
/// </summary>
/// <param name="redis">The Redis connection multiplexer.</param>
/// <param name="options">The scan-leader configuration options.</param>
/// <param name="logger">The logger.</param>
public RedisScanLeaderLease(
IConnectionMultiplexer redis,
IOptions<ScanLeaderOptions> options,
ILogger<RedisScanLeaderLease> logger)
{
_db = redis.GetDatabase();
_options = options.Value;
_logger = logger;
_podId = Environment.GetEnvironmentVariable("JELLYFIN_INSTANCE_ID") ?? Environment.MachineName;
}
/// <inheritdoc />
public async Task<bool> TryAcquireOrRenewAsync(CancellationToken cancellationToken = default)
{
var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000;
try
{
var result = (long?)await _db.ScriptEvaluateAsync(
AcquireOrRenewScript,
keys: new RedisKey[] { LeaderKey },
values: new RedisValue[] { _podId, leaseDurationMs }).ConfigureAwait(false);
return result == 1;
}
catch (Exception ex)
{
// Fail-safe: if Redis is unreachable, treat this instance as the leader so scheduled scans
// keep running. Every instance scanning is preferable to no instance scanning.
_logger.LogWarning(ex, "Scan-leader lease evaluation failed; treating {PodId} as leader.", _podId);
return true;
}
}
}
@@ -13,6 +13,7 @@ using Jellyfin.Data.Events;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller.ScheduledTasks;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
@@ -27,6 +28,8 @@ public class ScheduledTaskWorker : IScheduledTaskWorker
private readonly IApplicationPaths _applicationPaths;
private readonly ILogger _logger;
private readonly ITaskManager _taskManager;
private readonly IScanLeaderLease _scanLeaderLease;
private readonly ScanLeaderOptions _scanLeaderOptions;
private readonly Lock _lastExecutionResultSyncLock = new();
private bool _readFromFile;
private TaskResult _lastExecutionResult;
@@ -41,6 +44,8 @@ public class ScheduledTaskWorker : IScheduledTaskWorker
/// <param name="applicationPaths">The application paths.</param>
/// <param name="taskManager">The task manager.</param>
/// <param name="logger">The logger.</param>
/// <param name="scanLeaderLease">The scan-leader lease used to gate periodic library-mutating tasks, or <c>null</c> to disable gating.</param>
/// <param name="scanLeaderOptions">The scan-leader options, or <c>null</c> to disable gating.</param>
/// <exception cref="ArgumentNullException">
/// scheduledTask
/// or
@@ -52,7 +57,13 @@ public class ScheduledTaskWorker : IScheduledTaskWorker
/// or
/// logger.
/// </exception>
public ScheduledTaskWorker(IScheduledTask scheduledTask, IApplicationPaths applicationPaths, ITaskManager taskManager, ILogger logger)
public ScheduledTaskWorker(
IScheduledTask scheduledTask,
IApplicationPaths applicationPaths,
ITaskManager taskManager,
ILogger logger,
IScanLeaderLease scanLeaderLease = null,
ScanLeaderOptions scanLeaderOptions = null)
{
ArgumentNullException.ThrowIfNull(scheduledTask);
ArgumentNullException.ThrowIfNull(applicationPaths);
@@ -63,6 +74,8 @@ public class ScheduledTaskWorker : IScheduledTaskWorker
_applicationPaths = applicationPaths;
_taskManager = taskManager;
_logger = logger;
_scanLeaderLease = scanLeaderLease;
_scanLeaderOptions = scanLeaderOptions;
InitTriggerEvents();
}
@@ -268,6 +281,20 @@ public class ScheduledTaskWorker : IScheduledTaskWorker
trigger.Stop();
if (_scanLeaderLease is not null
&& _scanLeaderOptions is not null
&& _scanLeaderOptions.Enabled
&& _scanLeaderOptions.GatedTaskKeys is not null
&& _scanLeaderOptions.GatedTaskKeys.Contains(ScheduledTask.Key, StringComparer.Ordinal)
&& !await _scanLeaderLease.TryAcquireOrRenewAsync().ConfigureAwait(false))
{
_logger.LogDebug("Skipping gated task {Task}: this instance does not hold the scan-leader lease.", Name);
// Re-arm the trigger for the next interval without enqueueing on this instance.
trigger.Start(LastExecutionResult, _logger, Name, false);
return;
}
_taskManager.QueueScheduledTask(ScheduledTask, trigger.TaskOptions);
await Task.Delay(1000).ConfigureAwait(false);
@@ -5,8 +5,10 @@ using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Data.Events;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.ScheduledTasks;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Emby.Server.Implementations.ScheduledTasks;
@@ -23,18 +25,26 @@ public class TaskManager : ITaskManager
private readonly IApplicationPaths _applicationPaths;
private readonly ILogger<TaskManager> _logger;
private readonly IScanLeaderLease? _scanLeaderLease;
private readonly ScanLeaderOptions? _scanLeaderOptions;
/// <summary>
/// Initializes a new instance of the <see cref="TaskManager" /> class.
/// </summary>
/// <param name="applicationPaths">The application paths.</param>
/// <param name="logger">The logger.</param>
/// <param name="scanLeaderLease">The scan-leader lease used to gate periodic library-mutating tasks, or <c>null</c> to disable gating.</param>
/// <param name="scanLeaderOptions">The scan-leader options, or <c>null</c> to disable gating.</param>
public TaskManager(
IApplicationPaths applicationPaths,
ILogger<TaskManager> logger)
ILogger<TaskManager> logger,
IScanLeaderLease? scanLeaderLease = null,
IOptions<ScanLeaderOptions>? scanLeaderOptions = null)
{
_applicationPaths = applicationPaths;
_logger = logger;
_scanLeaderLease = scanLeaderLease;
_scanLeaderOptions = scanLeaderOptions?.Value;
ScheduledTasks = [];
}
@@ -175,7 +185,7 @@ public class TaskManager : ITaskManager
/// <inheritdoc />
public void AddTasks(IEnumerable<IScheduledTask> tasks)
{
var list = tasks.Select(t => new ScheduledTaskWorker(t, _applicationPaths, this, _logger));
var list = tasks.Select(t => new ScheduledTaskWorker(t, _applicationPaths, this, _logger, _scanLeaderLease, _scanLeaderOptions));
ScheduledTasks = ScheduledTasks.Concat(list).ToArray();
}
@@ -5,6 +5,7 @@ using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Tasks;
@@ -21,6 +22,7 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
private readonly IConfigurationManager _configurationManager;
private readonly IFileSystem _fileSystem;
private readonly ILocalizationManager _localization;
private readonly ITranscodeSessionStore _sessionStore;
/// <summary>
/// Initializes a new instance of the <see cref="DeleteTranscodeFileTask"/> class.
@@ -29,16 +31,19 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
/// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
/// <param name="sessionStore">Instance of the <see cref="ITranscodeSessionStore"/> interface.</param>
public DeleteTranscodeFileTask(
ILogger<DeleteTranscodeFileTask> logger,
IFileSystem fileSystem,
IConfigurationManager configurationManager,
ILocalizationManager localization)
ILocalizationManager localization,
ITranscodeSessionStore sessionStore)
{
_logger = logger;
_fileSystem = fileSystem;
_configurationManager = configurationManager;
_localization = localization;
_sessionStore = sessionStore;
}
/// <inheritdoc />
@@ -78,25 +83,39 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
}
/// <inheritdoc />
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
var minDateModified = DateTime.UtcNow.AddDays(-1);
progress.Report(50);
DeleteTempFilesFromDirectory(_configurationManager.GetTranscodePath(), minDateModified, progress, cancellationToken);
IEnumerable<TranscodeSession> activeSessions;
try
{
activeSessions = await _sessionStore.GetActiveSessionsAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve active transcode sessions. Skipping deletion to avoid removing files in use.");
progress.Report(100);
return;
}
return Task.CompletedTask;
DeleteTempFilesFromDirectory(_configurationManager.GetTranscodePath(), minDateModified, activeSessions, progress, cancellationToken);
}
/// <summary>
/// Deletes the transcoded temp files from directory with a last write time less than a given date.
/// Deletes the transcoded temp files from directory with a last write time less than a given date,
/// skipping any files that belong to an active transcode session.
/// </summary>
/// <param name="directory">The directory.</param>
/// <param name="minDateModified">The min date modified.</param>
/// <param name="activeSessions">The currently active transcode sessions.</param>
/// <param name="progress">The progress.</param>
/// <param name="cancellationToken">The task cancellation token.</param>
private void DeleteTempFilesFromDirectory(string directory, DateTime minDateModified, IProgress<double> progress, CancellationToken cancellationToken)
private void DeleteTempFilesFromDirectory(string directory, DateTime minDateModified, IEnumerable<TranscodeSession> activeSessions, IProgress<double> progress, CancellationToken cancellationToken)
{
var activeSessionList = activeSessions.ToList();
var filesToDelete = _fileSystem.GetFiles(directory, true)
.Where(f => _fileSystem.GetLastWriteTimeUtc(f) < minDateModified)
.ToList();
@@ -112,6 +131,13 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
cancellationToken.ThrowIfCancellationRequested();
if (IsFileProtectedByActiveSession(file.FullName, activeSessionList))
{
_logger.LogDebug("Skipping deletion of {FilePath} as it belongs to an active transcode session.", file.FullName);
index++;
continue;
}
FileSystemHelper.DeleteFile(_fileSystem, file.FullName, _logger);
index++;
@@ -121,4 +147,24 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
progress.Report(100);
}
private static bool IsFileProtectedByActiveSession(string filePath, IList<TranscodeSession> activeSessions)
{
foreach (var session in activeSessions)
{
if (!string.IsNullOrEmpty(session.ManifestPath) &&
string.Equals(filePath, session.ManifestPath, StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (!string.IsNullOrEmpty(session.SegmentPathPrefix) &&
filePath.StartsWith(session.SegmentPathPrefix, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}
@@ -256,7 +256,7 @@ namespace Emby.Server.Implementations.Session
ArgumentException.ThrowIfNullOrEmpty(deviceId);
var activityDate = DateTime.UtcNow;
var session = GetSessionInfo(appName, appVersion, deviceId, deviceName, remoteEndPoint, user);
var session = await GetSessionInfo(appName, appVersion, deviceId, deviceName, remoteEndPoint, user).ConfigureAwait(false);
var lastActivityDate = session.LastActivityDate;
session.LastActivityDate = activityDate;
@@ -491,7 +491,7 @@ namespace Emby.Server.Implementations.Session
/// <param name="remoteEndPoint">The remote end point.</param>
/// <param name="user">The user.</param>
/// <returns>SessionInfo.</returns>
private SessionInfo GetSessionInfo(
private async Task<SessionInfo> GetSessionInfo(
string appName,
string appVersion,
string deviceId,
@@ -504,7 +504,7 @@ namespace Emby.Server.Implementations.Session
ArgumentException.ThrowIfNullOrEmpty(deviceId);
var key = GetSessionKey(appName, deviceId, user?.Id ?? Guid.Empty);
SessionInfo newSession = CreateSessionInfo(key, appName, appVersion, deviceId, deviceName, remoteEndPoint, user);
SessionInfo newSession = await CreateSessionInfo(key, appName, appVersion, deviceId, deviceName, remoteEndPoint, user).ConfigureAwait(false);
SessionInfo sessionInfo = _activeConnections.GetOrAdd(key, newSession);
if (ReferenceEquals(newSession, sessionInfo))
{
@@ -532,7 +532,7 @@ namespace Emby.Server.Implementations.Session
return sessionInfo;
}
private SessionInfo CreateSessionInfo(
private async Task<SessionInfo> CreateSessionInfo(
string key,
string appName,
string appVersion,
@@ -562,7 +562,7 @@ namespace Emby.Server.Implementations.Session
deviceName = "Network Device";
}
var deviceOptions = _deviceManager.GetDeviceOptions(deviceId) ?? new()
var deviceOptions = await _deviceManager.GetDeviceOptions(deviceId).ConfigureAwait(false) ?? new()
{
DeviceId = deviceId
};
@@ -1768,12 +1768,12 @@ namespace Emby.Server.Implementations.Session
// This should be validated above, but if it isn't don't delete all tokens.
ArgumentException.ThrowIfNullOrEmpty(deviceId);
var existing = _deviceManager.GetDevices(
var existing = (await _deviceManager.GetDevices(
new DeviceQuery
{
DeviceId = deviceId,
UserId = user.Id
}).Items;
}).ConfigureAwait(false)).Items;
foreach (var auth in existing)
{
@@ -1801,12 +1801,12 @@ namespace Emby.Server.Implementations.Session
ArgumentException.ThrowIfNullOrEmpty(accessToken);
var existing = _deviceManager.GetDevices(
var existing = (await _deviceManager.GetDevices(
new DeviceQuery
{
Limit = 1,
AccessToken = accessToken
}).Items;
}).ConfigureAwait(false)).Items;
if (existing.Count > 0)
{
@@ -1845,10 +1845,10 @@ namespace Emby.Server.Implementations.Session
{
CheckDisposed();
var existing = _deviceManager.GetDevices(new DeviceQuery
var existing = await _deviceManager.GetDevices(new DeviceQuery
{
UserId = userId
});
}).ConfigureAwait(false);
foreach (var info in existing.Items)
{
@@ -2045,11 +2045,11 @@ namespace Emby.Server.Implementations.Session
/// <inheritdoc />
public async Task<SessionInfo> GetSessionByAuthenticationToken(string token, string deviceId, string remoteEndpoint)
{
var items = _deviceManager.GetDevices(new DeviceQuery
var items = (await _deviceManager.GetDevices(new DeviceQuery
{
AccessToken = token,
Limit = 1
}).Items;
}).ConfigureAwait(false)).Items;
if (items.Count == 0)
{
+13 -8
View File
@@ -50,10 +50,10 @@ public class DevicesController : BaseJellyfinApiController
/// <returns>An <see cref="OkResult"/> containing the list of devices.</returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<QueryResult<DeviceInfoDto>> GetDevices([FromQuery] Guid? userId)
public async Task<ActionResult<QueryResult<DeviceInfoDto>>> GetDevices([FromQuery] Guid? userId)
{
userId = RequestHelpers.GetUserId(User, userId);
return _deviceManager.GetDevicesForUser(userId);
return await _deviceManager.GetDevicesForUser(userId).ConfigureAwait(false);
}
/// <summary>
@@ -66,9 +66,9 @@ public class DevicesController : BaseJellyfinApiController
[HttpGet("Info")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<DeviceInfoDto> GetDeviceInfo([FromQuery, Required] string id)
public async Task<ActionResult<DeviceInfoDto>> GetDeviceInfo([FromQuery, Required] string id)
{
var deviceInfo = _deviceManager.GetDevice(id);
var deviceInfo = await _deviceManager.GetDevice(id).ConfigureAwait(false);
if (deviceInfo is null)
{
return NotFound();
@@ -87,9 +87,9 @@ public class DevicesController : BaseJellyfinApiController
[HttpGet("Options")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<DeviceOptionsDto> GetDeviceOptions([FromQuery, Required] string id)
public async Task<ActionResult<DeviceOptionsDto>> GetDeviceOptions([FromQuery, Required] string id)
{
var deviceInfo = _deviceManager.GetDeviceOptions(id);
var deviceInfo = await _deviceManager.GetDeviceOptions(id).ConfigureAwait(false);
if (deviceInfo is null)
{
return NotFound();
@@ -127,7 +127,12 @@ public class DevicesController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult> DeleteDevice([FromQuery] string[] id)
{
var devices = id.Select(_deviceManager.GetDevice).ToArray();
var devices = new List<DeviceInfoDto?>(id.Length);
foreach (var deviceId in id)
{
devices.Add(await _deviceManager.GetDevice(deviceId).ConfigureAwait(false));
}
if (devices.Any(f => f is null))
{
return BadRequest();
@@ -135,7 +140,7 @@ public class DevicesController : BaseJellyfinApiController
foreach (var device in devices)
{
var sessions = _deviceManager.GetDevices(new DeviceQuery { DeviceId = device!.Id });
var sessions = await _deviceManager.GetDevices(new DeviceQuery { DeviceId = device!.Id }).ConfigureAwait(false);
foreach (var session in sessions.Items)
{
@@ -29,6 +29,7 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace Jellyfin.Api.Controllers;
@@ -44,6 +45,8 @@ public class DynamicHlsController : BaseJellyfinApiController
private const EncoderPreset DefaultEventEncoderPreset = EncoderPreset.superfast;
private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls;
private static readonly string _podIdentity = Environment.GetEnvironmentVariable("JELLYFIN_INSTANCE_ID") ?? Environment.MachineName;
private readonly Version _minFFmpegFlacInMp4 = new Version(6, 0);
private readonly Version _minFFmpegX265BframeInFmp4 = new Version(7, 0, 1);
private readonly Version _minFFmpegHlsSegmentOptions = new Version(5, 0);
@@ -60,6 +63,8 @@ public class DynamicHlsController : BaseJellyfinApiController
private readonly IDynamicHlsPlaylistGenerator _dynamicHlsPlaylistGenerator;
private readonly DynamicHlsHelper _dynamicHlsHelper;
private readonly EncodingOptions _encodingOptions;
private readonly ITranscodeSessionStore _transcodeSessionStore;
private readonly TranscodeStoreOptions _transcodeStoreOptions;
/// <summary>
/// Initializes a new instance of the <see cref="DynamicHlsController"/> class.
@@ -75,6 +80,8 @@ 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>
/// <param name="transcodeStoreOptions">The <see cref="TranscodeStoreOptions"/> holding the session lease duration.</param>
public DynamicHlsController(
ILibraryManager libraryManager,
IUserManager userManager,
@@ -86,7 +93,9 @@ public class DynamicHlsController : BaseJellyfinApiController
ILogger<DynamicHlsController> logger,
DynamicHlsHelper dynamicHlsHelper,
EncodingHelper encodingHelper,
IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator)
IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator,
ITranscodeSessionStore transcodeSessionStore,
IOptions<TranscodeStoreOptions> transcodeStoreOptions)
{
_libraryManager = libraryManager;
_userManager = userManager;
@@ -99,6 +108,8 @@ public class DynamicHlsController : BaseJellyfinApiController
_dynamicHlsHelper = dynamicHlsHelper;
_encodingHelper = encodingHelper;
_dynamicHlsPlaylistGenerator = dynamicHlsPlaylistGenerator;
_transcodeSessionStore = transcodeSessionStore;
_transcodeStoreOptions = transcodeStoreOptions.Value;
_encodingOptions = serverConfigurationManager.GetEncodingOptions();
}
@@ -306,15 +317,24 @@ public class DynamicHlsController : BaseJellyfinApiController
// If the playlist doesn't already exist, startup ffmpeg
try
{
var isHaMode = await IsHaTakeoverAsync(playSessionId, cancellationToken).ConfigureAwait(false);
job = await _transcodeManager.StartFfMpeg(
state,
playlistPath,
GetCommandLineArguments(playlistPath, state, true, 0),
GetCommandLineArguments(playlistPath, state, true, 0, isHaMode),
Request.HttpContext.User.GetUserId(),
TranscodingJobType,
cancellationTokenSource)
.ConfigureAwait(false);
job.IsLiveOutput = true;
await RegisterTranscodeSessionAsync(
playSessionId ?? string.Empty,
mediaSourceId ?? string.Empty,
playlistPath,
cancellationToken)
.ConfigureAwait(false);
StartLeaseRenewal(playSessionId ?? string.Empty, cancellationToken);
}
catch
{
@@ -1511,14 +1531,23 @@ public class DynamicHlsController : BaseJellyfinApiController
streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks;
var isHaMode = await IsHaTakeoverAsync(streamingRequest.PlaySessionId, cancellationToken).ConfigureAwait(false);
state.WaitForPath = segmentPath;
job = await _transcodeManager.StartFfMpeg(
state,
playlistPath,
GetCommandLineArguments(playlistPath, state, false, segmentId),
GetCommandLineArguments(playlistPath, state, false, segmentId, isHaMode),
Request.HttpContext.User.GetUserId(),
TranscodingJobType,
cancellationTokenSource).ConfigureAwait(false);
await RegisterTranscodeSessionAsync(
streamingRequest.PlaySessionId ?? string.Empty,
streamingRequest.MediaSourceId ?? string.Empty,
playlistPath,
cancellationToken)
.ConfigureAwait(false);
StartLeaseRenewal(streamingRequest.PlaySessionId ?? string.Empty, cancellationToken);
}
catch
{
@@ -1543,6 +1572,31 @@ public class DynamicHlsController : BaseJellyfinApiController
}
}
/// <summary>
/// Gets the segment length ffmpeg is told to use. HA mode shortens segments so a takeover pod
/// has to re-encode less; the configured value is user-editable so it is clamped.
/// </summary>
/// <param name="isHaMode">Whether the session is being resumed from the durable store.</param>
/// <param name="requestedSegmentLength">The segment length requested by the streaming pipeline.</param>
/// <param name="encodingOptions">The encoding options holding the recovery segment length.</param>
/// <returns>The segment length in seconds.</returns>
internal static int GetEffectiveSegmentLength(bool isHaMode, int requestedSegmentLength, EncodingOptions encodingOptions)
=> isHaMode
? Math.Clamp(encodingOptions.RecoverySegmentLengthSeconds, 1, 6)
: requestedSegmentLength;
/// <summary>
/// Gets the ffmpeg <c>hls_list_size</c>. HA mode keeps a bounded rolling buffer; 0 (unbounded)
/// otherwise.
/// </summary>
/// <param name="isHaMode">Whether the session is being resumed from the durable store.</param>
/// <param name="encodingOptions">The encoding options holding the recovery buffer count.</param>
/// <returns>The number of segments to keep in the playlist, or 0 for all of them.</returns>
internal static int GetHlsListSize(bool isHaMode, EncodingOptions encodingOptions)
=> isHaMode
? Math.Clamp(encodingOptions.RecoverySegmentBufferCount, 2, 10)
: 0;
internal static async Task WaitForActiveTranscodingRequests(TranscodingJob? job, CancellationToken cancellationToken)
{
while (job?.ActiveRequestCount > 0)
@@ -1554,6 +1608,116 @@ public class DynamicHlsController : BaseJellyfinApiController
private static double[] GetSegmentLengths(StreamState state)
=> GetSegmentLengthsInternal(state.RunTimeTicks ?? 0, state.SegmentLength);
/// <summary>
/// Determines whether the play session is already present in the durable store, which means
/// another pod owned it and this request is resuming it after a failover.
/// </summary>
private async Task<bool> IsHaTakeoverAsync(string? playSessionId, CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(playSessionId))
{
return false;
}
try
{
return await _transcodeSessionStore.TryGetAsync(playSessionId, cancellationToken).ConfigureAwait(false) is not null;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to check HA mode for session {PlaySessionId}.", playSessionId);
return false;
}
}
/// <summary>
/// Builds the durable record for an HLS output owned by this instance. The manifest and segment
/// paths have to be real, otherwise <c>DeleteTranscodeFileTask</c> cannot tell which files
/// belong to a live session.
/// </summary>
/// <param name="playSessionId">The play session identifier.</param>
/// <param name="mediaSourceId">The media source identifier.</param>
/// <param name="playlistPath">The absolute path of the HLS playlist this instance is writing.</param>
/// <param name="leaseDuration">The initial lease duration.</param>
/// <returns>The session record to store.</returns>
internal static TranscodeSession CreateSessionRecord(string playSessionId, string mediaSourceId, string playlistPath, TimeSpan leaseDuration)
=> TranscodeSession.CreateForPlaylist(playSessionId, mediaSourceId, _podIdentity, playlistPath, leaseDuration);
private async Task RegisterTranscodeSessionAsync(string playSessionId, string mediaSourceId, string playlistPath, CancellationToken cancellationToken)
{
try
{
var session = CreateSessionRecord(
playSessionId,
mediaSourceId,
playlistPath,
TimeSpan.FromSeconds(_transcodeStoreOptions.LeaseDurationSeconds));
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)
{
var renewalInterval = TimeSpan.FromSeconds(Math.Max(1, _transcodeStoreOptions.LeaseDurationSeconds / 3));
_ = Task.Run(
async () =>
{
var ownsLease = true;
try
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
await Task.Delay(renewalInterval, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
try
{
if (!await _transcodeSessionStore.RenewLeaseAsync(playSessionId, _podIdentity, cancellationToken).ConfigureAwait(false))
{
// Another pod owns the session now; its record must not be touched.
ownsLease = false;
break;
}
}
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
{
if (ownsLease)
{
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;
@@ -1575,7 +1739,7 @@ public class DynamicHlsController : BaseJellyfinApiController
return segments;
}
private string GetCommandLineArguments(string outputPath, StreamState state, bool isEventPlaylist, int startNumber)
private string GetCommandLineArguments(string outputPath, StreamState state, bool isEventPlaylist, int startNumber, bool isHaMode = false)
{
var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
@@ -1588,10 +1752,13 @@ public class DynamicHlsController : BaseJellyfinApiController
var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
var outputTsArg = outputPrefix + "%d" + outputExtension;
var effectiveSegmentLength = GetEffectiveSegmentLength(isHaMode, state.SegmentLength, _encodingOptions);
var hlsListSize = GetHlsListSize(isHaMode, _encodingOptions);
var segmentFormat = string.Empty;
var segmentContainer = outputExtension.TrimStart('.');
var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions, segmentContainer);
var hlsArguments = $"-hls_playlist_type {(isEventPlaylist ? "event" : "vod")} -hls_list_size 0";
var hlsArguments = $"-hls_playlist_type {(isEventPlaylist ? "event" : "vod")} -hls_list_size {hlsListSize}";
if (string.Equals(segmentContainer, "ts", StringComparison.OrdinalIgnoreCase))
{
@@ -1644,10 +1811,10 @@ public class DynamicHlsController : BaseJellyfinApiController
_encodingHelper.GetInputArgument(state, _encodingOptions, segmentContainer),
threads,
mapArgs,
GetVideoArguments(state, startNumber, isEventPlaylist, segmentContainer),
GetVideoArguments(state, startNumber, isEventPlaylist, segmentContainer, effectiveSegmentLength),
GetAudioArguments(state),
maxMuxingQueueSize,
state.SegmentLength.ToString(CultureInfo.InvariantCulture),
effectiveSegmentLength.ToString(CultureInfo.InvariantCulture),
segmentFormat,
startNumber.ToString(CultureInfo.InvariantCulture),
baseUrlParam,
@@ -1784,8 +1951,9 @@ public class DynamicHlsController : BaseJellyfinApiController
/// <param name="startNumber">The first number in the hls sequence.</param>
/// <param name="isEventPlaylist">Whether the playlist is EVENT or VOD.</param>
/// <param name="segmentContainer">The segment container.</param>
/// <param name="segmentLength">The effective segment length in seconds (overrides <see cref="StreamState.SegmentLength"/> when HA mode is active).</param>
/// <returns>The command line arguments for video transcoding.</returns>
private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist, string segmentContainer)
private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist, string segmentContainer, int? segmentLength = null)
{
if (state.VideoStream is null)
{
@@ -1860,7 +2028,7 @@ public class DynamicHlsController : BaseJellyfinApiController
args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, isEventPlaylist ? DefaultEventEncoderPreset : DefaultVodEncoderPreset);
// Set the key frame params for video encoding to match the hls segment time.
args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, isEventPlaylist, startNumber);
args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, segmentLength ?? state.SegmentLength, isEventPlaylist, startNumber);
// Currently b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now.
if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)
@@ -31,8 +31,6 @@ namespace Jellyfin.Server.Implementations.Devices
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly IUserManager _userManager;
private readonly ConcurrentDictionary<string, ClientCapabilities> _capabilitiesMap = new();
private readonly ConcurrentDictionary<int, Device> _devices;
private readonly ConcurrentDictionary<string, DeviceOptions> _deviceOptions;
/// <summary>
/// Initializes a new instance of the <see cref="DeviceManager"/> class.
@@ -43,23 +41,6 @@ namespace Jellyfin.Server.Implementations.Devices
{
_dbProvider = dbProvider;
_userManager = userManager;
_devices = new ConcurrentDictionary<int, Device>();
_deviceOptions = new ConcurrentDictionary<string, DeviceOptions>();
using var dbContext = _dbProvider.CreateDbContext();
foreach (var device in dbContext.Devices
.OrderBy(d => d.Id)
.AsEnumerable())
{
_devices.TryAdd(device.Id, device);
}
foreach (var deviceOption in dbContext.DeviceOptions
.OrderBy(d => d.Id)
.AsEnumerable())
{
_deviceOptions.TryAdd(deviceOption.DeviceId, deviceOption);
}
}
/// <inheritdoc />
@@ -89,8 +70,6 @@ namespace Jellyfin.Server.Implementations.Devices
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
_deviceOptions[deviceId] = deviceOptions;
DeviceOptionsUpdated?.Invoke(this, new GenericEventArgs<Tuple<string, DeviceOptions>>(new Tuple<string, DeviceOptions>(deviceId, deviceOptions)));
}
@@ -102,21 +81,24 @@ namespace Jellyfin.Server.Implementations.Devices
{
dbContext.Devices.Add(device);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
_devices.TryAdd(device.Id, device);
}
return device;
}
/// <inheritdoc />
public DeviceOptionsDto? GetDeviceOptions(string deviceId)
public async Task<DeviceOptionsDto?> GetDeviceOptions(string deviceId)
{
if (_deviceOptions.TryGetValue(deviceId, out var deviceOptions))
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
return ToDeviceOptionsDto(deviceOptions);
}
var deviceOptions = await dbContext.DeviceOptions
.AsNoTracking()
.FirstOrDefaultAsync(dev => dev.DeviceId == deviceId)
.ConfigureAwait(false);
return null;
return deviceOptions is null ? null : ToDeviceOptionsDto(deviceOptions);
}
}
/// <inheritdoc />
@@ -133,43 +115,79 @@ namespace Jellyfin.Server.Implementations.Devices
}
/// <inheritdoc />
public DeviceInfoDto? GetDevice(string id)
public async Task<DeviceInfoDto?> GetDevice(string id)
{
var device = _devices.Values.Where(d => d.DeviceId == id).OrderByDescending(d => d.DateLastActivity).FirstOrDefault();
_deviceOptions.TryGetValue(id, out var deviceOption);
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
var device = await dbContext.Devices
.AsNoTracking()
.Where(d => d.DeviceId == id)
.OrderByDescending(d => d.DateLastActivity)
.FirstOrDefaultAsync()
.ConfigureAwait(false);
var deviceInfo = device is null ? null : ToDeviceInfo(device, deviceOption);
return deviceInfo is null ? null : ToDeviceInfoDto(deviceInfo);
if (device is null)
{
return null;
}
var deviceOption = await dbContext.DeviceOptions
.AsNoTracking()
.FirstOrDefaultAsync(dev => dev.DeviceId == id)
.ConfigureAwait(false);
return ToDeviceInfoDto(ToDeviceInfo(device, deviceOption));
}
}
/// <inheritdoc />
public QueryResult<Device> GetDevices(DeviceQuery query)
public async Task<QueryResult<Device>> GetDevices(DeviceQuery query)
{
IEnumerable<Device> devices = _devices.Values
.Where(device => !query.UserId.HasValue || device.UserId.Equals(query.UserId.Value))
.Where(device => query.DeviceId is null || device.DeviceId == query.DeviceId)
.Where(device => query.AccessToken is null || device.AccessToken == query.AccessToken)
.OrderBy(d => d.Id)
.ToList();
var count = devices.Count();
if (query.Skip.HasValue)
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
devices = devices.Skip(query.Skip.Value);
}
IQueryable<Device> filtered = dbContext.Devices.AsNoTracking();
if (query.Limit.HasValue && query.Limit.Value > 0)
{
devices = devices.Take(query.Limit.Value);
}
if (query.UserId.HasValue)
{
filtered = filtered.Where(device => device.UserId.Equals(query.UserId.Value));
}
return new QueryResult<Device>(query.Skip, count, devices.ToList());
if (query.DeviceId is not null)
{
filtered = filtered.Where(device => device.DeviceId == query.DeviceId);
}
if (query.AccessToken is not null)
{
filtered = filtered.Where(device => device.AccessToken == query.AccessToken);
}
// Every filter is an exact match on an indexed column and the table holds one row per
// user and device, so paging the materialised set costs less than a second round trip.
var matched = await filtered.OrderBy(d => d.Id).ToListAsync().ConfigureAwait(false);
IEnumerable<Device> devices = matched;
if (query.Skip.HasValue)
{
devices = devices.Skip(query.Skip.Value);
}
if (query.Limit.HasValue && query.Limit.Value > 0)
{
devices = devices.Take(query.Limit.Value);
}
return new QueryResult<Device>(query.Skip, matched.Count, devices.ToList());
}
}
/// <inheritdoc />
public QueryResult<DeviceInfo> GetDeviceInfos(DeviceQuery query)
public async Task<QueryResult<DeviceInfo>> GetDeviceInfos(DeviceQuery query)
{
var devices = GetDevices(query);
var devices = await GetDevices(query).ConfigureAwait(false);
return new QueryResult<DeviceInfo>(
devices.StartIndex,
@@ -178,38 +196,49 @@ namespace Jellyfin.Server.Implementations.Devices
}
/// <inheritdoc />
public QueryResult<DeviceInfoDto> GetDevicesForUser(Guid? userId)
public async Task<QueryResult<DeviceInfoDto>> GetDevicesForUser(Guid? userId)
{
IEnumerable<Device> devices = _devices.Values
.OrderByDescending(d => d.DateLastActivity)
.ThenBy(d => d.DeviceId);
if (!userId.IsNullOrEmpty())
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
var user = _userManager.GetUserById(userId.Value);
if (user is null)
IEnumerable<Device> devices = await dbContext.Devices
.AsNoTracking()
.OrderByDescending(d => d.DateLastActivity)
.ThenBy(d => d.DeviceId)
.ToListAsync()
.ConfigureAwait(false);
if (!userId.IsNullOrEmpty())
{
throw new ResourceNotFoundException();
var user = _userManager.GetUserById(userId.Value);
if (user is null)
{
throw new ResourceNotFoundException();
}
devices = devices.Where(i => CanAccessDevice(user, i.DeviceId));
}
devices = devices.Where(i => CanAccessDevice(user, i.DeviceId));
var options = await dbContext.DeviceOptions
.AsNoTracking()
.ToDictionaryAsync(dev => dev.DeviceId)
.ConfigureAwait(false);
var array = devices.Select(device =>
{
options.TryGetValue(device.DeviceId, out var option);
return ToDeviceInfo(device, option);
})
.Select(ToDeviceInfoDto)
.ToArray();
return new QueryResult<DeviceInfoDto>(array);
}
var array = devices.Select(device =>
{
_deviceOptions.TryGetValue(device.DeviceId, out var option);
return ToDeviceInfo(device, option);
})
.Select(ToDeviceInfoDto)
.ToArray();
return new QueryResult<DeviceInfoDto>(array);
}
/// <inheritdoc />
public async Task DeleteDevice(Device device)
{
_devices.TryRemove(device.Id, out _);
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
@@ -229,8 +258,6 @@ namespace Jellyfin.Server.Implementations.Devices
dbContext.Devices.Update(device);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
_devices[device.Id] = device;
}
/// <inheritdoc />
@@ -6,12 +6,14 @@ using System.Reflection;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.DbConfiguration;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.PostgreSQL;
using Jellyfin.Database.Providers.Sqlite;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using JellyfinDbProviderFactory = System.Func<System.IServiceProvider, Jellyfin.Database.Implementations.IJellyfinDatabaseProvider>;
namespace Jellyfin.Server.Implementations.Extensions;
@@ -24,6 +26,13 @@ public static class ServiceCollectionExtensions
private static IEnumerable<Type> DatabaseProviderTypes()
{
yield return typeof(SqliteDatabaseProvider);
yield return typeof(PostgreSqlDatabaseProvider);
}
private static int GetPoolOption(IEnumerable<CustomDatabaseOption>? options, string key, int defaultValue)
{
var value = options?.FirstOrDefault(o => o.Key.Equals(key, StringComparison.OrdinalIgnoreCase))?.Value;
return int.TryParse(value, out var parsed) ? parsed : defaultValue;
}
private static IDictionary<string, JellyfinDbProviderFactory> GetSupportedDbProviders()
@@ -123,6 +132,50 @@ public static class ServiceCollectionExtensions
serviceCollection.AddSingleton<IJellyfinDatabaseProvider>(providerFactory!);
if (efCoreConfiguration.DatabaseType.Equals("Jellyfin-PostgreSQL", StringComparison.OrdinalIgnoreCase))
{
serviceCollection.AddSingleton<NpgsqlDataSource>(static sp =>
{
var config = sp.GetRequiredService<IServerConfigurationManager>().GetConfiguration<DatabaseConfigurationOptions>("database");
var options = config.CustomProviderOptions?.Options;
var connectionString =
Environment.GetEnvironmentVariable("POSTGRES_CONNECTION_STRING")
?? options
?.FirstOrDefault(o => o.Key.Equals("ConnectionString", StringComparison.OrdinalIgnoreCase))
?.Value
?? config.CustomProviderOptions?.ConnectionString
?? throw new InvalidOperationException(
"No PostgreSQL connection string found. Set the POSTGRES_CONNECTION_STRING environment variable, " +
"or provide it via CustomProviderOptions.Options[\"ConnectionString\"] or CustomProviderOptions.ConnectionString.");
// Support postgresql:// / postgres:// URI format (e.g. DATABASE_URL convention).
// NpgsqlDataSourceBuilder requires ADO.NET key=value format; convert if needed.
if (connectionString.StartsWith("postgresql://", StringComparison.OrdinalIgnoreCase)
|| connectionString.StartsWith("postgres://", StringComparison.OrdinalIgnoreCase))
{
var uri = new Uri(connectionString);
var userInfoParts = uri.UserInfo.Split(':', 2);
connectionString = new NpgsqlConnectionStringBuilder
{
Host = uri.Host,
Port = uri.Port > 0 ? uri.Port : 5432,
Database = uri.AbsolutePath.TrimStart('/'),
Username = userInfoParts.Length > 0 ? Uri.UnescapeDataString(userInfoParts[0]) : null,
Password = userInfoParts.Length > 1 ? Uri.UnescapeDataString(userInfoParts[1]) : null,
}.ToString();
}
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connectionString);
dataSourceBuilder.ConnectionStringBuilder.MinPoolSize = GetPoolOption(options, "MinPoolSize", 2);
dataSourceBuilder.ConnectionStringBuilder.MaxPoolSize = GetPoolOption(options, "MaxPoolSize", 20);
dataSourceBuilder.ConnectionStringBuilder.CommandTimeout = GetPoolOption(options, "CommandTimeout", 30);
return dataSourceBuilder.Build();
});
}
switch (efCoreConfiguration.LockingBehavior)
{
case DatabaseLockingBehaviorTypes.NoLock:
@@ -219,9 +219,11 @@ public sealed partial class BaseItemRepository
}
else
{
// The representative is the row no other row in its group sorts before, not MIN(Id):
// PostgreSQL has no min(uuid) aggregate, while comparing two uuids is supported everywhere.
representativeIds = masterQuery
.GroupBy(e => e.PresentationUniqueKey)
.Select(g => g.Min(e => e.Id))
.Where(e => !masterQuery.Any(o => o.PresentationUniqueKey == e.PresentationUniqueKey && o.Id.CompareTo(e.Id) < 0))
.Select(e => e.Id)
.ToList();
}
@@ -96,22 +96,36 @@ public sealed partial class BaseItemRepository
// primary version (PrimaryVersionId is null) so detail pages and actions target it instead
// of an arbitrary alternate. Keep the grouped ids as an IQueryable sub-select; materializing
// to a List would inline one bound parameter per id and hit SQLite's variable cap.
// The representative is the row no other row in its group sorts before, not MIN(Id):
// PostgreSQL has no min(uuid) aggregate, while comparing two uuids is supported everywhere.
// The anti-join reads the filtered set twice, so it has to close over a local that the
// reassignment below cannot reach - capturing dbQuery itself makes the tree self-referential.
var candidates = dbQuery;
var enableGroupByPresentationUniqueKey = EnableGroupByPresentationUniqueKey(filter);
if (enableGroupByPresentationUniqueKey && filter.GroupBySeriesPresentationUniqueKey)
{
var groupedIds = dbQuery.GroupBy(e => new { e.PresentationUniqueKey, e.SeriesPresentationUniqueKey })
.Select(g => g.Where(e => e.PrimaryVersionId == null).Min(e => (Guid?)e.Id) ?? g.Min(e => (Guid?)e.Id));
var groupedIds = candidates
.Where(e => !candidates.Any(o => o.PresentationUniqueKey == e.PresentationUniqueKey
&& o.SeriesPresentationUniqueKey == e.SeriesPresentationUniqueKey
&& ((o.PrimaryVersionId == null && e.PrimaryVersionId != null)
|| ((o.PrimaryVersionId == null) == (e.PrimaryVersionId == null) && o.Id.CompareTo(e.Id) < 0))))
.Select(e => e.Id);
dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id));
}
else if (enableGroupByPresentationUniqueKey)
{
var groupedIds = dbQuery.GroupBy(e => e.PresentationUniqueKey)
.Select(g => g.Where(e => e.PrimaryVersionId == null).Min(e => (Guid?)e.Id) ?? g.Min(e => (Guid?)e.Id));
var groupedIds = candidates
.Where(e => !candidates.Any(o => o.PresentationUniqueKey == e.PresentationUniqueKey
&& ((o.PrimaryVersionId == null && e.PrimaryVersionId != null)
|| ((o.PrimaryVersionId == null) == (e.PrimaryVersionId == null) && o.Id.CompareTo(e.Id) < 0))))
.Select(e => e.Id);
dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id));
}
else if (filter.GroupBySeriesPresentationUniqueKey)
{
var groupedIds = dbQuery.GroupBy(e => e.SeriesPresentationUniqueKey).Select(e => e.Min(x => x.Id));
var groupedIds = candidates
.Where(e => !candidates.Any(o => o.SeriesPresentationUniqueKey == e.SeriesPresentationUniqueKey && o.Id.CompareTo(e.Id) < 0))
.Select(e => e.Id);
dbQuery = context.BaseItems.AsNoTracking().Where(e => groupedIds.Contains(e.Id));
}
else
@@ -35,6 +35,7 @@
<ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" />
<ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" />
<ProjectReference Include="..\src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj" />
<ProjectReference Include="..\src\Jellyfin.Database\Jellyfin.Database.Providers.PostgreSQL\Jellyfin.Database.Providers.PostgreSQL.csproj" />
<ProjectReference Include="..\src\Jellyfin.Database\Jellyfin.Database.Providers.Sqlite\Jellyfin.Database.Providers.Sqlite.csproj" />
</ItemGroup>
@@ -126,95 +126,95 @@ namespace Jellyfin.Server.Implementations.Security
return authInfo;
}
var device = (await _deviceManager.GetDevices(
new DeviceQuery { AccessToken = token }).ConfigureAwait(false)).Items.FirstOrDefault();
if (device is not null)
{
authInfo.IsAuthenticated = true;
var updateToken = false;
// TODO: Remove these checks for IsNullOrWhiteSpace
if (string.IsNullOrWhiteSpace(authInfo.Client))
{
authInfo.Client = device.AppName;
}
if (string.IsNullOrWhiteSpace(authInfo.DeviceId))
{
authInfo.DeviceId = device.DeviceId;
}
// Temporary. TODO - allow clients to specify that the token has been shared with a casting device
var allowTokenInfoUpdate = !authInfo.Client.Contains("chromecast", StringComparison.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(authInfo.Device))
{
authInfo.Device = device.DeviceName;
}
else if (!string.Equals(authInfo.Device, device.DeviceName, StringComparison.OrdinalIgnoreCase))
{
if (allowTokenInfoUpdate)
{
updateToken = true;
device.DeviceName = authInfo.Device;
}
}
if (string.IsNullOrWhiteSpace(authInfo.Version))
{
authInfo.Version = device.AppVersion;
}
else if (!string.Equals(authInfo.Version, device.AppVersion, StringComparison.OrdinalIgnoreCase))
{
if (allowTokenInfoUpdate)
{
updateToken = true;
device.AppVersion = authInfo.Version;
}
}
if ((DateTime.UtcNow - device.DateLastActivity).TotalMinutes > 3)
{
device.DateLastActivity = DateTime.UtcNow;
updateToken = true;
}
authInfo.User = _userManager.GetUserById(device.UserId);
if (updateToken)
{
await _deviceManager.UpdateDevice(device).ConfigureAwait(false);
}
return authInfo;
}
var dbContext = await _jellyfinDbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
var device = _deviceManager.GetDevices(
new DeviceQuery { AccessToken = token }).Items.FirstOrDefault();
if (device is not null)
var key = await dbContext.ApiKeys.FirstOrDefaultAsync(apiKey => apiKey.AccessToken == token).ConfigureAwait(false);
if (key is not null)
{
authInfo.IsAuthenticated = true;
var updateToken = false;
// TODO: Remove these checks for IsNullOrWhiteSpace
if (string.IsNullOrWhiteSpace(authInfo.Client))
{
authInfo.Client = device.AppName;
}
authInfo.Client = key.Name;
authInfo.Token = key.AccessToken;
if (string.IsNullOrWhiteSpace(authInfo.DeviceId))
{
authInfo.DeviceId = device.DeviceId;
authInfo.DeviceId = _serverApplicationHost.SystemId;
}
// Temporary. TODO - allow clients to specify that the token has been shared with a casting device
var allowTokenInfoUpdate = !authInfo.Client.Contains("chromecast", StringComparison.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(authInfo.Device))
{
authInfo.Device = device.DeviceName;
}
else if (!string.Equals(authInfo.Device, device.DeviceName, StringComparison.OrdinalIgnoreCase))
{
if (allowTokenInfoUpdate)
{
updateToken = true;
device.DeviceName = authInfo.Device;
}
authInfo.Device = _serverApplicationHost.Name;
}
if (string.IsNullOrWhiteSpace(authInfo.Version))
{
authInfo.Version = device.AppVersion;
}
else if (!string.Equals(authInfo.Version, device.AppVersion, StringComparison.OrdinalIgnoreCase))
{
if (allowTokenInfoUpdate)
{
updateToken = true;
device.AppVersion = authInfo.Version;
}
authInfo.Version = _serverApplicationHost.ApplicationVersionString;
}
if ((DateTime.UtcNow - device.DateLastActivity).TotalMinutes > 3)
{
device.DateLastActivity = DateTime.UtcNow;
updateToken = true;
}
authInfo.User = _userManager.GetUserById(device.UserId);
if (updateToken)
{
await _deviceManager.UpdateDevice(device).ConfigureAwait(false);
}
}
else
{
var key = await dbContext.ApiKeys.FirstOrDefaultAsync(apiKey => apiKey.AccessToken == token).ConfigureAwait(false);
if (key is not null)
{
authInfo.IsAuthenticated = true;
authInfo.Client = key.Name;
authInfo.Token = key.AccessToken;
if (string.IsNullOrWhiteSpace(authInfo.DeviceId))
{
authInfo.DeviceId = _serverApplicationHost.SystemId;
}
if (string.IsNullOrWhiteSpace(authInfo.Device))
{
authInfo.Device = _serverApplicationHost.Name;
}
if (string.IsNullOrWhiteSpace(authInfo.Version))
{
authInfo.Version = _serverApplicationHost.ApplicationVersionString;
}
authInfo.IsApiKey = true;
}
authInfo.IsApiKey = true;
}
return authInfo;
@@ -61,10 +61,10 @@ public sealed class DeviceAccessHost : IHostedService
private async Task UpdateDeviceAccess(User user)
{
var existing = _deviceManager.GetDevices(new DeviceQuery
var existing = (await _deviceManager.GetDevices(new DeviceQuery
{
UserId = user.Id
}).Items;
}).ConfigureAwait(false)).Items;
foreach (var device in existing)
{
+14
View File
@@ -2,12 +2,15 @@ using System;
using System.Collections.Generic;
using System.Reflection;
using Emby.Server.Implementations;
using Emby.Server.Implementations.MediaEncoding;
using Emby.Server.Implementations.ScheduledTasks;
using Emby.Server.Implementations.Session;
using Jellyfin.Api.WebSocketListeners;
using Jellyfin.Database.Implementations;
using Jellyfin.Drawing;
using Jellyfin.Drawing.Skia;
using Jellyfin.LiveTv;
using Jellyfin.Server.Extensions;
using Jellyfin.Server.Implementations.Activity;
using Jellyfin.Server.Implementations.Devices;
using Jellyfin.Server.Implementations.Events;
@@ -23,6 +26,7 @@ using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Lyrics;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Security;
using MediaBrowser.Controller.Trickplay;
@@ -39,6 +43,8 @@ namespace Jellyfin.Server
/// </summary>
public class CoreAppHost : ApplicationHost
{
private readonly IConfiguration _startupConfig;
/// <summary>
/// Initializes a new instance of the <see cref="CoreAppHost" /> class.
/// </summary>
@@ -57,6 +63,7 @@ namespace Jellyfin.Server
options,
startupConfig)
{
_startupConfig = startupConfig;
}
/// <inheritdoc/>
@@ -98,6 +105,13 @@ namespace Jellyfin.Server
serviceCollection.AddScoped<IAuthenticationManager, AuthenticationManager>();
// Transcode session store: Redis-backed when configured, no-op otherwise.
serviceCollection.AddTranscodeSessionStore(_startupConfig, Logger);
// Scan-leader lease: gates periodic library-mutating scheduled tasks to a single leader
// instance. Active by default once a Redis connection is configured, no-op otherwise.
serviceCollection.AddScanLeaderLease(_startupConfig, Logger);
foreach (var type in GetExportTypes<ILyricProvider>())
{
serviceCollection.AddSingleton(typeof(ILyricProvider), type);
@@ -0,0 +1,51 @@
using System.Collections.Generic;
using System.Linq;
using Microsoft.Extensions.Configuration;
namespace Jellyfin.Server.Extensions;
/// <summary>
/// Extensions for building the application configuration.
/// </summary>
public static class ConfigurationBuilderExtensions
{
/// <summary>
/// The environment variable prefix that maps onto this fork's own <c>Jellyfin:*</c> configuration
/// keys, for example <c>Jellyfin__TranscodeStore__RedisConnectionString</c>.
/// </summary>
public const string JellyfinSectionEnvironmentPrefix = "Jellyfin__";
/// <summary>
/// The configuration section root this fork keeps its own settings under.
/// </summary>
public const string JellyfinSectionRoot = "Jellyfin";
/// <summary>
/// Adds environment variables named <c>Jellyfin__Section__Key</c> as the configuration keys
/// <c>Jellyfin:Section:Key</c>.
/// </summary>
/// <remarks>
/// The base configuration only reads <c>JELLYFIN_</c> prefixed environment variables, so the
/// unprefixed form every manifest, chart and document uses would otherwise be dropped and the
/// feature it configures would stay off with no error.
/// </remarks>
/// <param name="builder">The configuration builder.</param>
/// <returns>The updated configuration builder.</returns>
public static IConfigurationBuilder AddJellyfinSectionEnvironmentVariables(this IConfigurationBuilder builder)
{
// Read through the framework provider so "__" to ":" normalisation and case handling match the
// prefixed form exactly; the prefix it strips is then put back as the section root.
var scoped = new ConfigurationBuilder()
.AddEnvironmentVariables(JellyfinSectionEnvironmentPrefix)
.Build();
var entries = scoped.AsEnumerable()
.Where(entry => entry.Value is not null)
.Select(entry => new KeyValuePair<string, string?>(
ConfigurationPath.Combine(JellyfinSectionRoot, entry.Key),
entry.Value))
.ToList();
return builder.AddInMemoryCollection(entries);
}
}
@@ -0,0 +1,71 @@
using System;
using Emby.Server.Implementations.ScheduledTasks;
using MediaBrowser.Controller.ScheduledTasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Extensions;
/// <summary>
/// Extensions for registering the scan-leader lease.
/// </summary>
public static class ScanLeaderServiceCollectionExtensions
{
private const string RedisConnectionStringKey = "Jellyfin:TranscodeStore:RedisConnectionString";
/// <summary>
/// Registers the scan-leader lease and reports at <see cref="LogLevel.Information"/> whether
/// timer-driven library tasks are gated to a single instance.
/// </summary>
/// <remarks>
/// Gating is on by default once a Redis connection string is configured: that is only set for a
/// multi-instance deployment, which is the only shape where running library scans on every
/// instance is wrong. Single-instance installs have no Redis and keep running every task locally.
/// </remarks>
/// <param name="serviceCollection">The service collection.</param>
/// <param name="configuration">The configuration to read <c>Jellyfin:ScanLeader</c> from.</param>
/// <param name="logger">The logger to report the gating decision on.</param>
/// <returns>The updated service collection.</returns>
public static IServiceCollection AddScanLeaderLease(
this IServiceCollection serviceCollection,
IConfiguration configuration,
ILogger logger)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(logger);
serviceCollection.Configure<ScanLeaderOptions>(configuration.GetSection(ScanLeaderOptions.ConfigurationSection));
var redisConfigured = !string.IsNullOrEmpty(configuration[RedisConnectionStringKey]);
var requested = bool.TryParse(configuration[ScanLeaderOptions.EnabledKey], out var explicitChoice)
? explicitChoice
: redisConfigured;
var active = requested && redisConfigured;
// The task worker reads Enabled from the bound options, so the effective decision has to land
// there as well as on the lease registration below.
serviceCollection.PostConfigure<ScanLeaderOptions>(options => options.Enabled = active);
if (active)
{
logger.LogInformation(
"Scan-leader gating is active: timer-driven library tasks run only on the instance holding the Redis scan-leader lease.");
return serviceCollection.AddSingleton<IScanLeaderLease, RedisScanLeaderLease>();
}
if (requested)
{
logger.LogWarning(
"Scan-leader gating is enabled but no Redis connection string is configured ({Key}), so it cannot run: timer-driven library tasks run on every instance.",
RedisConnectionStringKey);
}
else
{
logger.LogInformation("Scan-leader gating is off: timer-driven library tasks run on every instance.");
}
return serviceCollection.AddSingleton<IScanLeaderLease, NullScanLeaderLease>();
}
}
@@ -0,0 +1,87 @@
using System;
using System.Linq;
using Emby.Server.Implementations.MediaEncoding;
using MediaBrowser.Controller.MediaEncoding;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
namespace Jellyfin.Server.Extensions;
/// <summary>
/// Extensions for registering the transcode session store.
/// </summary>
public static class TranscodeStoreServiceCollectionExtensions
{
/// <summary>
/// Registers the transcode session store, Redis-backed when a connection string is configured and
/// no-op otherwise, and reports the selected store at <see cref="LogLevel.Information"/>.
/// </summary>
/// <param name="serviceCollection">The service collection.</param>
/// <param name="configuration">The configuration to read <c>Jellyfin:TranscodeStore</c> from.</param>
/// <param name="logger">The logger to report the selected store on.</param>
/// <returns>The updated service collection.</returns>
public static IServiceCollection AddTranscodeSessionStore(
this IServiceCollection serviceCollection,
IConfiguration configuration,
ILogger logger)
{
ArgumentNullException.ThrowIfNull(configuration);
ArgumentNullException.ThrowIfNull(logger);
serviceCollection.Configure<TranscodeStoreOptions>(configuration.GetSection(TranscodeStoreOptions.ConfigurationSection));
var redisConnectionString = configuration[TranscodeStoreOptions.RedisConnectionStringKey];
if (string.IsNullOrEmpty(redisConnectionString))
{
logger.LogInformation(
"Transcode session store: {Store}. Cross-pod transcode takeover is off; set {Key} to enable it.",
nameof(NullTranscodeSessionStore),
TranscodeStoreOptions.RedisConnectionStringKey);
return serviceCollection.AddSingleton<ITranscodeSessionStore, NullTranscodeSessionStore>();
}
logger.LogInformation(
"Transcode session store: {Store} on {Endpoints}.",
nameof(RedisTranscodeSessionStore),
DescribeEndpoints(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 {Key} configuration.", TranscodeStoreOptions.RedisConnectionStringKey);
throw;
}
});
serviceCollection.AddSingleton<ITranscodeSessionStore, RedisTranscodeSessionStore>();
serviceCollection.AddHostedService<TranscodeStoreConnectivityProbe>();
return serviceCollection;
}
/// <summary>
/// Renders the endpoints of a connection string for logging. The connection string itself is never
/// logged because it can carry a password.
/// </summary>
private static string DescribeEndpoints(string redisConnectionString)
{
try
{
return string.Join(
',',
ConfigurationOptions.Parse(redisConnectionString).EndPoints.Select(endpoint => endpoint.ToString()));
}
catch (ArgumentException)
{
return "(unparsable connection string)";
}
}
}
+1
View File
@@ -56,6 +56,7 @@
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="Serilog.Sinks.Graylog" />
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>
<ItemGroup>
@@ -36,7 +36,9 @@ internal class MigrateRatingLevels : IDatabaseMigrationRoutine
_logger.LogInformation("Recalculating parental rating levels based on rating string.");
using var context = _provider.CreateDbContext();
using var transaction = context.Database.BeginTransaction();
var ratings = context.BaseItems.AsNoTracking().Select(e => e.OfficialRating).Distinct();
// Read the whole list up front: the updates below run on the same connection, and a provider
// that cannot multiplex commands rejects them while the reader is still open.
var ratings = context.BaseItems.AsNoTracking().Select(e => e.OfficialRating).Distinct().ToList();
foreach (var rating in ratings)
{
if (string.IsNullOrEmpty(rating))
+7
View File
@@ -24,6 +24,7 @@ using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using Microsoft.AspNetCore.Hosting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
@@ -304,6 +305,10 @@ namespace Jellyfin.Server
.AddJellyfinDbContext(startupConfigurationManager, startupConfig)
.AddSingleton<IApplicationPaths>(appPaths)
.AddSingleton<ServerApplicationPaths>(appPaths)
// Required by the NpgsqlDataSource factory in AddJellyfinDbContext when
// DatabaseType=Jellyfin-PostgreSQL — the factory resolves this from DI
// to read CustomProviderOptions and pool settings.
.AddSingleton<IServerConfigurationManager>(startupConfigurationManager)
.RegisterStartupLogger();
var startupService = migrationStartupServiceProvider.BuildServiceProvider();
@@ -386,6 +391,8 @@ namespace Jellyfin.Server
.AddInMemoryCollection(inMemoryDefaultConfig)
.AddJsonFile(LoggingConfigFileDefault, optional: false, reloadOnChange: true)
.AddJsonFile(LoggingConfigFileSystem, optional: true, reloadOnChange: true)
// Added before the prefixed source so an explicit JELLYFIN_ variable still wins.
.AddJellyfinSectionEnvironmentVariables()
.AddEnvironmentVariables("JELLYFIN_")
.AddInMemoryCollection(commandLineOpts.ConvertToConfig());
}
+23
View File
@@ -68,6 +68,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Server.Tests", "te
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Server.Integration.Tests", "tests\Jellyfin.Server.Integration.Tests\Jellyfin.Server.Integration.Tests.csproj", "{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Database.Tests.PostgreSQL", "tests\Jellyfin.Database.Tests.PostgreSQL\Jellyfin.Database.Tests.PostgreSQL.csproj", "{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Providers.Tests", "tests\Jellyfin.Providers.Tests\Jellyfin.Providers.Tests.csproj", "{A964008C-2136-4716-B6CB-B3426C22320A}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}"
@@ -95,10 +97,16 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Jellyfin.Database", "Jellyf
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Providers.Sqlite", "src\Jellyfin.Database\Jellyfin.Database.Providers.Sqlite\Jellyfin.Database.Providers.Sqlite.csproj", "{A5590358-33CC-4B39-BDE7-DC62FEB03C76}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Providers.PostgreSQL", "src\Jellyfin.Database\Jellyfin.Database.Providers.PostgreSQL\Jellyfin.Database.Providers.PostgreSQL.csproj", "{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Database.Implementations", "src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj", "{8C9F9221-8415-496C-B1F5-E7756F03FA59}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.CodeAnalysis", "src\Jellyfin.CodeAnalysis\Jellyfin.CodeAnalysis.csproj", "{11643D0F-6761-4EF7-AB71-6F9F8DE00714}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{3C85DA50-31AC-40D3-BCF4-F1B14C420996}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.DbMigrator", "tools\Jellyfin.DbMigrator\Jellyfin.DbMigrator.csproj", "{6F7187CB-E1CB-4583-98CF-0FB87F21E844}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Jellyfin.Drawing.Skia.Tests", "tests\Jellyfin.Drawing.Skia.Tests\Jellyfin.Drawing.Skia.Tests.csproj", "{E24A279C-9A37-419A-8F9C-853C11FBE753}"
EndProject
Global
@@ -219,6 +227,10 @@ Global
{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E}.Release|Any CPU.Build.0 = Release|Any CPU
{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF}.Release|Any CPU.Build.0 = Release|Any CPU
{A964008C-2136-4716-B6CB-B3426C22320A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A964008C-2136-4716-B6CB-B3426C22320A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A964008C-2136-4716-B6CB-B3426C22320A}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -259,6 +271,10 @@ Global
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A5590358-33CC-4B39-BDE7-DC62FEB03C76}.Release|Any CPU.Build.0 = Release|Any CPU
{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E}.Release|Any CPU.Build.0 = Release|Any CPU
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8C9F9221-8415-496C-B1F5-E7756F03FA59}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -267,6 +283,10 @@ Global
{11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Debug|Any CPU.Build.0 = Debug|Any CPU
{11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Release|Any CPU.ActiveCfg = Release|Any CPU
{11643D0F-6761-4EF7-AB71-6F9F8DE00714}.Release|Any CPU.Build.0 = Release|Any CPU
{6F7187CB-E1CB-4583-98CF-0FB87F21E844}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6F7187CB-E1CB-4583-98CF-0FB87F21E844}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6F7187CB-E1CB-4583-98CF-0FB87F21E844}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6F7187CB-E1CB-4583-98CF-0FB87F21E844}.Release|Any CPU.Build.0 = Release|Any CPU
{E24A279C-9A37-419A-8F9C-853C11FBE753}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E24A279C-9A37-419A-8F9C-853C11FBE753}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E24A279C-9A37-419A-8F9C-853C11FBE753}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -290,6 +310,7 @@ Global
{42816EA8-4511-4CBF-A9C7-7791D5DDDAE6} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
{3ADBCD8C-C0F2-4956-8FDC-35D686B74CF9} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
{68B0B823-A5AC-4E8B-82EA-965AAC7BF76E} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
{B5F2C3A1-9D4E-4F8A-B123-456789ABCDEF} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
{A964008C-2136-4716-B6CB-B3426C22320A} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
{750B8757-BE3D-4F8C-941A-FBAD94904ADA} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
{332A5C7A-F907-47CA-910E-BE6F7371B9E0} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
@@ -301,8 +322,10 @@ Global
{8C6B2B13-58A4-4506-9DAB-1F882A093FE0} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
{4C54CE05-69C8-48FA-8785-39F7F6DB1CAD} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
{A5590358-33CC-4B39-BDE7-DC62FEB03C76} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
{B3C4D5E6-F7A8-4B9C-0D1E-2F3A4B5C6D7E} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
{8C9F9221-8415-496C-B1F5-E7756F03FA59} = {4C54CE05-69C8-48FA-8785-39F7F6DB1CAD}
{11643D0F-6761-4EF7-AB71-6F9F8DE00714} = {C9F0AB5D-F4D7-40C8-A353-3305C86D6D4C}
{6F7187CB-E1CB-4583-98CF-0FB87F21E844} = {3C85DA50-31AC-40D3-BCF4-F1B14C420996}
{E24A279C-9A37-419A-8F9C-853C11FBE753} = {FBBB5129-006E-4AD7-BAD5-8B7CA1D10ED6}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
@@ -47,29 +47,29 @@ public interface IDeviceManager
/// Gets the device information.
/// </summary>
/// <param name="id">The identifier.</param>
/// <returns>DeviceInfoDto.</returns>
DeviceInfoDto? GetDevice(string id);
/// <returns>A <see cref="Task"/> representing the retrieval of the device information.</returns>
Task<DeviceInfoDto?> GetDevice(string id);
/// <summary>
/// Gets devices based on the provided query.
/// </summary>
/// <param name="query">The device query.</param>
/// <returns>A <see cref="Task{QueryResult}"/> representing the retrieval of the devices.</returns>
QueryResult<Device> GetDevices(DeviceQuery query);
Task<QueryResult<Device>> GetDevices(DeviceQuery query);
/// <summary>
/// Gets device information based on the provided query.
/// </summary>
/// <param name="query">The device query.</param>
/// <returns>A <see cref="Task{QueryResult}"/> representing the retrieval of the device information.</returns>
QueryResult<DeviceInfo> GetDeviceInfos(DeviceQuery query);
Task<QueryResult<DeviceInfo>> GetDeviceInfos(DeviceQuery query);
/// <summary>
/// Gets the device information.
/// </summary>
/// <param name="userId">The user's id, or <c>null</c>.</param>
/// <returns>IEnumerable&lt;DeviceInfoDto&gt;.</returns>
QueryResult<DeviceInfoDto> GetDevicesForUser(Guid? userId);
/// <returns>A <see cref="Task{QueryResult}"/> representing the retrieval of the device information.</returns>
Task<QueryResult<DeviceInfoDto>> GetDevicesForUser(Guid? userId);
/// <summary>
/// Deletes a device.
@@ -105,8 +105,8 @@ public interface IDeviceManager
/// Gets the options of a device.
/// </summary>
/// <param name="deviceId">The device id.</param>
/// <returns><see cref="DeviceOptions"/> of the device.</returns>
DeviceOptionsDto? GetDeviceOptions(string deviceId);
/// <returns>A <see cref="Task"/> representing the retrieval of the <see cref="DeviceOptions"/> of the device.</returns>
Task<DeviceOptionsDto?> GetDeviceOptions(string deviceId);
/// <summary>
/// Gets the dto for client capabilities.
@@ -0,0 +1,79 @@
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.
/// The renewal is rejected when <paramref name="ownerPod"/> no longer owns the lease, so a
/// renewal in flight while another pod wins <see cref="TryTakeoverAsync"/> cannot revert the takeover.
/// </summary>
/// <param name="playSessionId">The play session identifier.</param>
/// <param name="ownerPod">The name of the pod that believes it owns the lease.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>
/// <c>true</c> if the lease was renewed; <c>false</c> if the session no longer exists or is
/// owned by another pod.
/// </returns>
Task<bool> RenewLeaseAsync(string playSessionId, string ownerPod, 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);
}
@@ -0,0 +1,37 @@
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<bool> RenewLeaseAsync(string playSessionId, string ownerPod, CancellationToken cancellationToken = default)
=> Task.FromResult(true);
/// <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>());
}
@@ -0,0 +1,86 @@
using System;
using System.IO;
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; }
/// <summary>
/// Creates a session record for an HLS output, deriving <see cref="ManifestPath"/> and
/// <see cref="SegmentPathPrefix"/> from the playlist path so that cleanup can recognise
/// every file the session owns.
/// </summary>
/// <param name="playSessionId">The play session identifier.</param>
/// <param name="mediaSourceId">The media source identifier.</param>
/// <param name="ownerPod">The name of the pod that owns the session.</param>
/// <param name="playlistPath">The absolute path of the HLS playlist (.m3u8) file.</param>
/// <param name="leaseDuration">The initial lease duration.</param>
/// <returns>The new <see cref="TranscodeSession"/>.</returns>
public static TranscodeSession CreateForPlaylist(
string playSessionId,
string mediaSourceId,
string ownerPod,
string playlistPath,
TimeSpan leaseDuration)
=> new TranscodeSession
{
PlaySessionId = playSessionId,
OwnerPod = ownerPod,
LeaseExpiresUtc = DateTime.UtcNow.Add(leaseDuration),
ManifestPath = playlistPath,
SegmentPathPrefix = GetSegmentPathPrefix(playlistPath),
MediaSourceId = mediaSourceId,
};
/// <summary>
/// Gets the prefix every segment file of the HLS output at <paramref name="playlistPath"/> starts with.
/// Segments are written as <c>&lt;playlist path without extension&gt;&lt;index&gt;&lt;segment extension&gt;</c>.
/// </summary>
/// <param name="playlistPath">The absolute path of the HLS playlist (.m3u8) file.</param>
/// <returns>The segment path prefix.</returns>
public static string GetSegmentPathPrefix(string playlistPath)
=> Path.ChangeExtension(playlistPath, null) ?? playlistPath;
}
@@ -0,0 +1,36 @@
namespace MediaBrowser.Controller.MediaEncoding;
/// <summary>
/// Configuration options for the transcode session store.
/// </summary>
public sealed class TranscodeStoreOptions
{
/// <summary>
/// The configuration section these options bind from.
/// </summary>
public const string ConfigurationSection = "Jellyfin:TranscodeStore";
/// <summary>
/// The configuration key holding the Redis connection string.
/// </summary>
public const string RedisConnectionStringKey = ConfigurationSection + ":RedisConnectionString";
/// <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;
/// <summary>
/// Gets or sets how long in seconds a session record is retained after its lease was last renewed.
/// The record must outlive the lease, otherwise an orphaned session is gone before another pod
/// can take it over.
/// </summary>
public int SessionRetentionSeconds { get; set; } = 300;
}
@@ -0,0 +1,21 @@
using System.Threading;
using System.Threading.Tasks;
namespace MediaBrowser.Controller.ScheduledTasks;
/// <summary>
/// Provides a distributed leader lease that gates periodic, library-mutating scheduled tasks
/// to a single instance across a multi-pod deployment.
/// </summary>
public interface IScanLeaderLease
{
/// <summary>
/// Attempts to acquire the scan-leader lease, or renews it when this instance already holds it.
/// </summary>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>
/// <c>true</c> if this instance holds the leader lease and gated periodic tasks may run here;
/// otherwise <c>false</c>.
/// </returns>
Task<bool> TryAcquireOrRenewAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,16 @@
using System.Threading;
using System.Threading.Tasks;
namespace MediaBrowser.Controller.ScheduledTasks;
/// <summary>
/// A no-op <see cref="IScanLeaderLease"/> used when scan-leader election is disabled or no Redis
/// connection is configured. Every instance is treated as the leader, preserving the default
/// single-instance behavior where all periodic tasks run locally.
/// </summary>
public sealed class NullScanLeaderLease : IScanLeaderLease
{
/// <inheritdoc />
public Task<bool> TryAcquireOrRenewAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(true);
}
@@ -0,0 +1,48 @@
namespace MediaBrowser.Controller.ScheduledTasks;
/// <summary>
/// Configuration options for scan-leader election, which gates periodic library-mutating
/// scheduled tasks to a single leader instance in a multi-pod deployment.
/// </summary>
public sealed class ScanLeaderOptions
{
/// <summary>
/// The configuration section these options bind from.
/// </summary>
public const string ConfigurationSection = "Jellyfin:ScanLeader";
/// <summary>
/// The configuration key that overrides the default enablement.
/// </summary>
public const string EnabledKey = ConfigurationSection + ":Enabled";
/// <summary>
/// Gets or sets a value indicating whether scan-leader election is enabled. When disabled,
/// every instance runs its periodic tasks as before. Left unset, election is enabled whenever a
/// Redis connection string is configured, because that is the only deployment shape that needs it.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Gets or sets the duration in seconds for which the scan-leader lease is held before it must
/// be renewed. A leader that stops renewing loses the lease after this duration.
/// </summary>
public int LeaseDurationSeconds { get; set; } = 60;
/// <summary>
/// Gets or sets the set of scheduled task keys whose periodic (timer-driven) execution is gated
/// to the scan leader. Tasks not listed here run on every instance, and manual or API-triggered
/// runs are never gated.
/// </summary>
public string[] GatedTaskKeys { get; set; } =
{
"RefreshLibrary",
"RefreshPeople",
"RefreshChapterImages",
"AudioNormalization",
"TaskExtractMediaSegments",
"KeyframeExtraction",
"CleanupUserDataTask",
"OptimizeDatabaseTask"
};
}
@@ -1,7 +1,10 @@
#pragma warning disable CA1819 // XML serialization handles collections improperly, so we need to use arrays
#nullable disable
using System;
using System.ComponentModel;
using System.Text.Json.Serialization;
using System.Xml.Serialization;
using MediaBrowser.Model.Entities;
namespace MediaBrowser.Model.Configuration;
@@ -25,6 +28,8 @@ public class EncodingOptions
ThrottleDelaySeconds = 180;
EnableSegmentDeletion = false;
SegmentKeepSeconds = 720;
RecoverySegmentLengthSeconds = 2;
RecoverySegmentBufferCount = 5;
EncodingThreadCount = -1;
// This is a DRM device that is almost guaranteed to be there on every intel platform,
// plus it's the default one in ffmpeg if you don't specify anything
@@ -125,6 +130,20 @@ public class EncodingOptions
/// </summary>
public int SegmentKeepSeconds { get; set; }
/// <summary>
/// Gets or sets the HLS segment length in seconds to use when HA recovery mode is active.
/// Shorter segments allow a takeover pod to resume playback faster after a peer failure.
/// Default is <c>2</c>. Valid range is 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>
@@ -218,8 +237,27 @@ public class EncodingOptions
/// <summary>
/// Gets or sets the encoder preset.
/// </summary>
[XmlIgnore]
public EncoderPreset EncoderPreset { get; set; }
/// <summary>
/// Gets or sets the encoder preset as it is stored in the configuration file. Files written before the preset
/// list was validated can carry an empty element, which has to read back as the default rather than throw and
/// take the rest of the configuration down with it.
/// </summary>
[JsonIgnore]
[XmlElement(nameof(EncoderPreset))]
public string EncoderPresetXml
{
get => EncoderPreset.ToString();
// TryParse also accepts a number, which would yield a preset value nothing maps to, so the result has to be
// a declared one.
set => EncoderPreset = Enum.TryParse<EncoderPreset>(value, true, out var encoderPreset) && Enum.IsDefined(encoderPreset)
? encoderPreset
: EncoderPreset.auto;
}
/// <summary>
/// Gets or sets a value indicating whether the framerate is doubled when deinterlacing.
/// </summary>
+551 -42
View File
@@ -1,64 +1,571 @@
<h1 align="center">Jellyfin</h1>
<h3 align="center">The Free Software Media System</h3>
# jellyfin-ha
**A fork of [Jellyfin](https://github.com/jellyfin/jellyfin) adding high-availability transcoding support for multi-pod Kubernetes deployments.**
[![License: GPL v2](https://img.shields.io/badge/License-GPL_v2-blue.svg)](https://www.gnu.org/licenses/old-licenses/gpl-2.0.en.html)
[![.NET 10](https://img.shields.io/badge/.NET-10.0-purple)](https://dotnet.microsoft.com/download/dotnet/10.0)
[![Upstream](https://img.shields.io/badge/upstream-jellyfin%2Fjellyfin-informational)](https://github.com/jellyfin/jellyfin)
---
<p align="center">
<img alt="Logo Banner" src="https://raw.githubusercontent.com/jellyfin/jellyfin-ux/master/branding/SVG/banner-logo-solid.svg?sanitize=true"/>
<br/>
<br/>
<a href="https://github.com/jellyfin/jellyfin"><img alt="GPL 2.0 License" src="https://img.shields.io/github/license/jellyfin/jellyfin.svg"/></a>
<a href="https://github.com/jellyfin/jellyfin/releases"><img alt="Current Release" src="https://img.shields.io/github/release/jellyfin/jellyfin.svg"/></a>
<a href="https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/?utm_source=widget"><img alt="Translation Status" src="https://translate.jellyfin.org/widgets/jellyfin/-/jellyfin-core/svg-badge.svg"/></a>
<a href="https://hub.docker.com/r/jellyfin/jellyfin"><img alt="Docker Pull Count" src="https://img.shields.io/docker/pulls/jellyfin/jellyfin.svg"/></a>
<br/>
<a href="https://opencollective.com/jellyfin"><img alt="Donate" src="https://img.shields.io/opencollective/all/jellyfin.svg?label=backers"/></a>
<a href="https://features.jellyfin.org"><img alt="Submit Feature Requests" src="https://img.shields.io/badge/fider-vote%20on%20features-success.svg"/></a>
<a href="https://matrix.to/#/#jellyfinorg:matrix.org"><img alt="Chat on Matrix" src="https://img.shields.io/matrix/jellyfinorg:matrix.org.svg?logo=matrix"/></a>
<a href="https://github.com/jellyfin/jellyfin/releases.atom"><img alt="Release RSS Feed" src="https://img.shields.io/badge/rss-releases-ffa500?logo=rss" /></a>
<a href="https://github.com/jellyfin/jellyfin/commits/master.atom"><img alt="Master Commits RSS Feed" src="https://img.shields.io/badge/rss-commits-ffa500?logo=rss" /></a>
</p>
## What is this?
Jellyfin's default assumption is that exactly one server instance is running at a time. Transcode state is held entirely in-memory — when the process dies, so do all active HLS streams. For homelab deployments that want Kubernetes-managed redundancy (rolling restarts, node drain, pod rescheduling), that's a problem.
This fork adds a thin HA layer on top of unmodified Jellyfin core:
- **`ITranscodeSessionStore`** — a new interface for durable, distributed transcode session tracking
- **`RedisTranscodeSessionStore`** — a Redis-backed implementation using atomic Lua takeover scripts and TTL-based lease expiry
- **`NullTranscodeSessionStore`** — a no-op fallback so single-instance deployments work with zero configuration change
- **Lease-aware `DeleteTranscodeFileTask`** — coordinates cleanup across replicas so a restarting pod doesn't delete segments another pod is actively streaming
- **PostgreSQL database provider** — alternative to SQLite for shared-database HA setups (experimental, under `src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL`)
---
Jellyfin is a Free Software Media System that puts you in control of managing and streaming your media. It is an alternative to the proprietary Emby and Plex, to provide media from a dedicated server to end-user devices via multiple apps. Jellyfin is descended from Emby's 3.5.2 release and ported to the .NET platform to enable full cross-platform support.
## Architecture
There are no strings attached, no premium licenses or features, and no hidden agendas: just a team that wants to build something better and work together to achieve it. We welcome anyone who is interested in joining us in our quest!
```
┌─────────────┐ ┌─────────────┐
│ Jellyfin │ │ Jellyfin │
│ Pod A │ │ Pod B │
│ │ │ │
│ ┌─────────┐ │ │ ┌─────────┐ │
│ │Transcode│ │ │ │Transcode│ │
│ │Manager │ │ │ │Manager │ │
│ └────┬────┘ │ │ └────┬────┘ │
└──────┼──────┘ └──────┼──────┘
│ │
└─────────┬─────────┘
┌───────▼───────┐
│ Redis │ ← ITranscodeSessionStore
│ (lease store)│ TTL-based ownership
└───────────────┘
For further details, please see [our documentation page](https://jellyfin.org/docs/). To receive the latest updates, get help with Jellyfin, and join the community, please visit [one of our communication channels](https://jellyfin.org/docs/general/getting-help). For more information about the project, please see our [about page](https://jellyfin.org/docs/general/about).
┌─────────────────────┐
│ Shared NAS / NFS │ ← HLS segments + manifests
│ (shared storage) │
└─────────────────────┘
```
<strong>Want to get started?</strong><br/>
Check out our <a href="https://jellyfin.org/downloads">downloads page</a> or our <a href="https://jellyfin.org/docs/general/installation/">installation guide</a>, then see our <a href="https://jellyfin.org/docs/general/quick-start">quick start guide</a>. You can also <a href="https://jellyfin.org/docs/general/installation/source">build from source</a>.<br/>
**How takeover works:**
<strong>Something not working right?</strong><br/>
Open an <a href="https://jellyfin.org/docs/general/contributing/issues">Issue</a> on GitHub.<br/>
<strong>Want to contribute?</strong><br/>
Check out our <a href="https://jellyfin.org/contribute">contributing choose-your-own-adventure</a> to see where you can help, then see our <a href="https://jellyfin.org/docs/general/contributing/">contributing guide</a> and our <a href="https://jellyfin.org/docs/general/community-standards">community standards</a>.<br/>
<strong>New idea or improvement?</strong><br/>
Check out our <a href="https://features.jellyfin.org/?view=most-wanted">feature request hub</a>.<br/>
<strong>Don't see Jellyfin in your language?</strong><br/>
Check out our <a href="https://translate.jellyfin.org">Weblate instance</a> to help translate Jellyfin and its subprojects.<br/>
<a href="https://translate.jellyfin.org/engage/jellyfin/?utm_source=widget">
<img src="https://translate.jellyfin.org/widgets/jellyfin/-/jellyfin-web/multi-auto.svg" alt="Detailed Translation Status"/>
</a>
1. Pod A starts an HLS transcode and writes a `TranscodeSession` to Redis with a 30-second lease.
2. Pod A renews the lease every `LeaseDurationSeconds / 3` seconds; the renewal is rejected if Pod A no longer owns it.
3. If Pod A dies, the lease expires in Redis after 30 seconds.
4. Pod B receives a client request for the same play session, calls `TryTakeoverAsync`, and atomically claims ownership via a Lua script.
5. Pod B resumes FFmpeg from the last durable segment index. The client sees a brief stutter, not an error.
---
## Jellyfin Server
## Quick Start
This repository contains the code for Jellyfin's backend server. Note that this is only one of many projects under the Jellyfin GitHub [organization](https://github.com/jellyfin/) on GitHub. If you want to contribute, you can start by checking out our [documentation](https://jellyfin.org/docs/general/contributing/index.html) to see what to work on.
### Single instance (no Redis)
## Server Development
No configuration required. `NullTranscodeSessionStore` is used automatically. Behavior is identical to upstream Jellyfin.
These instructions will help you get set up with a local development environment in order to contribute to this repository. Before you start, please be sure to completely read our [guidelines on development contributions](https://jellyfin.org/docs/general/contributing/development.html). Note that this project is supported on all major operating systems except FreeBSD, which is still incompatible.
```bash
dotnet run --project Jellyfin.Server/Jellyfin.Server.csproj -- \
--datadir /var/lib/jellyfin \
--webdir /usr/share/jellyfin/web
```
### HA mode with Redis
Set the `Jellyfin:TranscodeStore:RedisConnectionString` configuration key. You can pass it as a `Jellyfin__TranscodeStore__RedisConnectionString` environment variable, as the equivalent `JELLYFIN_` prefixed variable, 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
}
}
}
```
The selected store is logged at startup, so HA transcoding is never on or off without a signal:
```
Transcode session store: RedisTranscodeSessionStore on valkey:6379.
Redis transcode session store is reachable (2ms round trip). HA transcode takeover is active.
```
Without a connection string the line reads `Transcode session store: NullTranscodeSessionStore`. A configured but unreachable store is logged at `Error`; the server keeps serving with per-instance sessions rather than refusing to start.
---
## 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. |
| `Jellyfin:TranscodeStore:SessionRetentionSeconds` | `300` | How long an unrenewed session record is kept so another pod can still take it over. |
### Redis connection string examples
```
# Standalone Redis
redis:6379
# With password
redis:6379,password=secret
# With TLS
redis.example.com:6380,ssl=true,abortConnect=false
# Redis Sentinel
sentinel-host:26379,serviceName=mymaster
```
Standard [StackExchange.Redis connection string format](https://stackexchange.github.io/StackExchange.Redis/Configuration) is accepted.
---
## Deployment
> **This project is designed to run as a container.** Running it as a bare `dotnet` process is fine for development and testing, but the HA benefits only materialize when you have multiple replicas managed by a container orchestrator. Docker Compose gets you Redis + Jellyfin wired together locally. Kubernetes (k3s, k8s, or a managed cloud cluster) gets you the actual pod-death-and-recovery story.
>
> Don't have a Kubernetes cluster yet? [DigitalOcean Kubernetes](https://www.digitalocean.com/?refcode=b9012919f7ff&utm_campaign=Referral_Invite&utm_medium=Referral_Program&utm_source=badge) is the fastest path to a managed cluster if you don't want to run your own nodes.
---
### Option 1 — Local HA with Docker Compose
The simplest way to test the full HA stack locally: two Jellyfin replicas sharing a Redis instance and a local volume for transcode output.
```yaml
# docker-compose.yml
version: "3.9"
services:
redis:
image: redis:7-alpine
ports:
- "6379:6379"
jellyfin-1:
build:
context: .
dockerfile: Dockerfile.runtime
environment:
Jellyfin__TranscodeStore__RedisConnectionString: "redis:6379,abortConnect=false"
Jellyfin__TranscodeStore__LeaseDurationSeconds: "30"
JELLYFIN_HA_POD_NAME: "jellyfin-1"
volumes:
- ./data/config:/config
- ./data/media:/media:ro
- transcode-tmp:/transcode
ports:
- "8096:8096"
depends_on:
- redis
jellyfin-2:
build:
context: .
dockerfile: Dockerfile.runtime
environment:
Jellyfin__TranscodeStore__RedisConnectionString: "redis:6379,abortConnect=false"
Jellyfin__TranscodeStore__LeaseDurationSeconds: "30"
JELLYFIN_HA_POD_NAME: "jellyfin-2"
volumes:
- ./data/config:/config
- ./data/media:/media:ro
- transcode-tmp:/transcode
ports:
- "8097:8096"
depends_on:
- redis
volumes:
transcode-tmp:
```
Build the image first (the `dotnet publish` step runs outside Docker for I/O performance):
```bash
dotnet publish Jellyfin.Server/Jellyfin.Server.csproj \
--configuration Release \
--runtime linux-x64 \
--self-contained false \
--output ./publish-output
docker compose up
```
Both replicas share the `transcode-tmp` volume and register sessions in Redis. Kill one container mid-stream (`docker kill jellyfin-1`) and the other takes over within `LeaseDurationSeconds`.
---
### Option 2 — Kubernetes (k3s / k8s)
This is the intended production deployment. You need:
1. A Kubernetes cluster (k3s, kubeadm, EKS, GKE, DigitalOcean Kubernetes, etc.)
2. A Redis instance (in-cluster or managed)
3. A `ReadWriteMany` storage class for shared transcode scratch space (NFS, Longhorn RWX, Ceph RBD, or a cloud-managed RWX PVC)
#### Redis (in-cluster, standalone)
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: redis
namespace: jellyfin
spec:
replicas: 1
selector:
matchLabels:
app: redis
template:
metadata:
labels:
app: redis
spec:
containers:
- name: redis
image: redis:7-alpine
ports:
- containerPort: 6379
---
apiVersion: v1
kind: Service
metadata:
name: redis
namespace: jellyfin
spec:
selector:
app: redis
ports:
- port: 6379
```
#### Redis connection secret
```bash
kubectl create secret generic jellyfin-redis \
--namespace jellyfin \
--from-literal=connection-string="redis.jellyfin.svc.cluster.local:6379,abortConnect=false"
```
#### Shared transcode PVC (RWX)
```yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: jellyfin-transcode
namespace: jellyfin
spec:
accessModes:
- ReadWriteMany
storageClassName: longhorn # or nfs-client, csi-driver-nfs, etc.
resources:
requests:
storage: 20Gi
```
#### Jellyfin Deployment
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: jellyfin
namespace: jellyfin
spec:
replicas: 2
selector:
matchLabels:
app: jellyfin
template:
metadata:
labels:
app: jellyfin
spec:
containers:
- name: jellyfin
image: your-registry/jellyfin-ha:latest
ports:
- containerPort: 8096
env:
- name: Jellyfin__TranscodeStore__RedisConnectionString
valueFrom:
secretKeyRef:
name: jellyfin-redis
key: connection-string
- name: Jellyfin__TranscodeStore__LeaseDurationSeconds
value: "30"
- name: JELLYFIN_HA_POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
volumeMounts:
- name: config
mountPath: /config
- name: media
mountPath: /media
readOnly: true
- name: transcode
mountPath: /transcode
livenessProbe:
httpGet:
path: /health
port: 8096
initialDelaySeconds: 30
periodSeconds: 10
readinessProbe:
httpGet:
path: /health
port: 8096
initialDelaySeconds: 10
periodSeconds: 5
volumes:
- name: config
persistentVolumeClaim:
claimName: jellyfin-config # RWO is fine — config is single-writer
- name: media
nfs:
server: your-nas.local
path: /media
- name: transcode
persistentVolumeClaim:
claimName: jellyfin-transcode # Must be RWX
---
apiVersion: v1
kind: Service
metadata:
name: jellyfin
namespace: jellyfin
spec:
type: ClusterIP
selector:
app: jellyfin
ports:
- port: 8096
targetPort: 8096
```
#### Important: storage requirements
| Volume | Access mode | Why |
|--------|-------------|-----|
| Config (`/config`) | `ReadWriteOnce` | One writer, SQLite DB lives here |
| Media (`/media`) | `ReadOnlyMany` | All pods read the same library |
| Transcode (`/transcode`) | **`ReadWriteMany`** | Pods read each other's HLS segments during takeover |
The transcode volume is the critical one. If it's `ReadWriteOnce`, pod takeover will fail because Pod B cannot read the `.ts` segments Pod A wrote. Use NFS, Longhorn with RWX enabled, or a cloud-managed RWX storage class.
#### Building the image
```bash
# Publish (run on host, not inside Docker)
dotnet publish Jellyfin.Server/Jellyfin.Server.csproj \
--configuration Release \
--runtime linux-x64 \
--self-contained false \
--output ./publish-output
# Build for amd64 (required for most clusters)
docker buildx build \
--platform linux/amd64 \
--provenance=false \
-f Dockerfile.runtime \
-t your-registry/jellyfin-ha:latest \
--push .
```
> Note: `--provenance=false` is required if your cluster runs containerd (k3s, most kubeadm setups). Without it, Docker adds OCI attestation manifests that containerd cannot resolve.
---
### Option 3 — Bare dotnet (development only)
For local development and testing without containers. HA mode still works — you just run two terminal sessions pointing at the same Redis and a shared local directory.
**Terminal 1:**
```bash
export Jellyfin__TranscodeStore__RedisConnectionString="localhost:6379"
export JELLYFIN_HA_POD_NAME="dev-pod-1"
dotnet run --project Jellyfin.Server/Jellyfin.Server.csproj -- \
--datadir /tmp/jellyfin-1/data \
--cachedir /tmp/jellyfin-1/cache \
--transcodes /tmp/jellyfin-shared/transcode \
--webdir /usr/share/jellyfin/web \
--port 8096
```
**Terminal 2:**
```bash
export Jellyfin__TranscodeStore__RedisConnectionString="localhost:6379"
export JELLYFIN_HA_POD_NAME="dev-pod-2"
dotnet run --project Jellyfin.Server/Jellyfin.Server.csproj -- \
--datadir /tmp/jellyfin-2/data \
--cachedir /tmp/jellyfin-2/cache \
--transcodes /tmp/jellyfin-shared/transcode \
--webdir /usr/share/jellyfin/web \
--port 8097
```
Both instances share `/tmp/jellyfin-shared/transcode`. Kill one process mid-stream to test takeover. Start a local Redis with `redis-server` or `docker run -p 6379:6379 redis:7-alpine`.
---
## PostgreSQL (experimental)
This fork includes a PostgreSQL database provider under `src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL`. It is experimental — the SQLite provider remains the default and the recommended choice for most deployments.
To use PostgreSQL, set the migration provider at startup and run migrations:
```bash
dotnet ef migrations add InitialCreate \
--project "src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL" \
-- --migration-provider Jellyfin-PostgreSQL
```
See `src/Jellyfin.Database/readme.md` for full migration instructions.
---
## Building and Testing
### Prerequisites
Before the project can be built, you must first install the [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet) on your system.
- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0)
### Build
```bash
dotnet build Jellyfin.Server/Jellyfin.Server.csproj
```
### Run all tests
```bash
dotnet test Jellyfin.sln \
--configuration Release \
--filter "Category!=RequiresDocker&FullyQualifiedName!~Integration"
```
### Run the PostgreSQL migration tests
They run the startup migration chain against a real server. Set `JELLYFIN_TEST_POSTGRES` to a
connection string for an already running server and they use it; without it they start a container
through testcontainers.
```bash
dotnet test tests/Jellyfin.Server.Tests \
--configuration Release \
--filter "Category=RequiresDocker"
```
### Run HA-specific tests
The transcode session store and HA recovery tests live in:
- `tests/Jellyfin.Server.Implementations.Tests/MediaEncoding/RedisTranscodeSessionStoreTests.cs` (real Redis, `Category=RequiresDocker`)
- `tests/Jellyfin.MediaEncoding.Tests/Transcoding/InMemoryTranscodeSessionStoreTests.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 ← real Redis via Testcontainers
Jellyfin.MediaEncoding.Tests/
Fakes/InMemoryTranscodeSessionStore.cs
Transcoding/InMemoryTranscodeSessionStoreTests.cs
```
---
## Contributing
This is a personal experiment, not an officially maintained fork. Issues and PRs are welcome but response time may vary.
If you're interested in getting proper HA transcoding into upstream Jellyfin, that conversation belongs in the [upstream repo](https://github.com/jellyfin/jellyfin). The changes here are deliberately narrow and designed to be upstream-friendly if there's maintainer interest.
**Code conventions** follow the upstream Jellyfin rules:
- `async`/`await` everywhere — no `.Result` or `.Wait()`
- All public members need XML doc comments
- Use `Directory.Packages.props` for NuGet versions — never add `Version=` to a `<PackageReference>`
- `.NET 10` required
- Warnings are treated as errors
---
## Relationship to upstream
This fork tracks [jellyfin/jellyfin](https://github.com/jellyfin/jellyfin) release tags and is currently based on `v12.0`. The HA additions are intentionally isolated to:
1. New interfaces and models in `MediaBrowser.Controller`
2. New implementations in `Emby.Server.Implementations`
3. DI wiring in `Jellyfin.Server/CoreAppHost.cs`
4. New test projects
No core Jellyfin logic was modified — only extended via existing DI extension points.
---
## License
GPL-2.0, same as upstream Jellyfin. See [LICENSE](LICENSE).
---
*Upstream README preserved below for reference.*
---
Instructions to run this project from the command line are included here, but you will also need to install an IDE if you want to debug the server while it is running. Any IDE that supports .NET 6 development will work, but two options are recent versions of [Visual Studio](https://visualstudio.microsoft.com/downloads/) (at least 2022) and [Visual Studio Code](https://code.visualstudio.com/Download).
@@ -177,5 +684,7 @@ Since this is a common scenario, there is also a separate launch profile defined
This project is supported by:
<br/>
<br/>
<a href="https://www.digitalocean.com"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" height="50px" alt="DigitalOcean"></a>
&nbsp;
<a href="https://www.jetbrains.com"><img src="https://gist.githubusercontent.com/anthonylavado/e8b2403deee9581e0b4cb8cd675af7db/raw/199ae22980ef5da64882ec2de3e8e5c03fe535b8/jetbrains.svg" height="50px" alt="JetBrains logo"></a>
</p>
+22
View File
@@ -0,0 +1,22 @@
apiVersion: v2
name: jellyfin-ha
description: >
High-availability Jellyfin media server with Redis-backed transcode session
store, lease-aware segment cleanup, and optional PostgreSQL database provider
for multi-pod Kubernetes deployments.
type: application
version: 0.1.0
appVersion: "12.0.0"
keywords:
- jellyfin
- media-server
- high-availability
- redis
- kubernetes
home: https://github.com/ZoltyMat/jellyfin-ha
sources:
- https://github.com/ZoltyMat/jellyfin-ha
maintainers:
- name: ZoltyMat
url: https://github.com/ZoltyMat
icon: https://raw.githubusercontent.com/jellyfin/jellyfin/master/Jellyfin.Server/Resources/Images/jellyfin-icon-solid.png
@@ -0,0 +1,49 @@
1. Jellyfin HA has been deployed.
{{- if eq (int .Values.replicaCount) 1 }}
⚠ replicaCount=1 — running in single-instance mode. Set replicaCount >= 2 and
ha.enabled=true to enable HA transcoding.
{{- else }}
✔ Running {{ .Values.replicaCount }} replicas.
{{- if include "jellyfin-ha.haEnabled" . }}
✔ HA mode: ACTIVE — transcode sessions replicated via Redis.
{{- else }}
⚠ HA mode: INACTIVE — NullTranscodeSessionStore in use.
Set redis.enabled=true (or ha.transcodeStore.redisConnectionString) to enable HA.
{{- end }}
{{- end }}
2. Get the Jellyfin URL:
{{- if .Values.ingress.enabled }}
{{- range .Values.ingress.hosts }}
https://{{ .host }}/
{{- end }}
{{- else if .Values.traefikIngressRoute.enabled }}
https://{{ .Values.traefikIngressRoute.host }}/
{{- else }}
Access via port-forward:
kubectl port-forward -n {{ .Release.Namespace }} svc/{{ include "jellyfin-ha.fullname" . }} 8096:{{ .Values.service.port }}
http://localhost:8096/
{{- end }}
3. PostgreSQL:
{{- if .Values.postgresql.enabled }}
✔ In-cluster PostgreSQL deployed. Ensure the secret "{{ .Values.postgresql.existingSecret }}"
exists in namespace {{ .Release.Namespace }} before starting the server.
{{- else if eq .Values.config.databaseType "Jellyfin-PostgreSQL" }}
⚠ config.databaseType=Jellyfin-PostgreSQL but postgresql.enabled=false.
Make sure you have an external PostgreSQL and the correct DATABASE_URL env var set.
{{- else }}
Using SQLite (default). Enable postgresql.enabled=true for a shared database backend.
{{- end }}
4. Transcode storage:
The transcode PVC must be ReadWriteMany when replicaCount > 1.
Current accessMode: {{ .Values.persistence.transcode.accessMode }}
{{- if and (gt (int .Values.replicaCount) 1) (ne .Values.persistence.transcode.accessMode "ReadWriteMany") }}
⚠ WARNING: replicaCount > 1 but transcode accessMode is not ReadWriteMany.
Pod B cannot read Pod A's HLS segments during session takeover.
Set persistence.transcode.accessMode=ReadWriteMany or use an NFS / Longhorn RWX PVC.
{{- end }}
@@ -0,0 +1,137 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "jellyfin-ha.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "jellyfin-ha.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart label.
*/}}
{{- define "jellyfin-ha.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels.
*/}}
{{- define "jellyfin-ha.labels" -}}
helm.sh/chart: {{ include "jellyfin-ha.chart" . }}
{{ include "jellyfin-ha.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
{{- end }}
{{/*
Selector labels.
*/}}
{{- define "jellyfin-ha.selectorLabels" -}}
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: server
{{- end }}
{{/*
Service account name.
*/}}
{{- define "jellyfin-ha.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "jellyfin-ha.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}
{{/*
Fully qualified name of the in-cluster Redis service.
*/}}
{{- define "jellyfin-ha.redis.fullname" -}}
{{- printf "%s-redis" (include "jellyfin-ha.fullname" .) }}
{{- end }}
{{/*
Fully qualified name of the in-cluster PostgreSQL service.
*/}}
{{- define "jellyfin-ha.postgres.fullname" -}}
{{- printf "%s-postgres" (include "jellyfin-ha.fullname" .) }}
{{- end }}
{{/*
Compute the Redis connection string.
Priority:
1. existingSecret (mounted as env var in the statefulset template)
2. explicit ha.transcodeStore.redisConnectionString value
3. auto-compose from the in-cluster Redis service name when redis.enabled=true
Returns empty string if none of the above apply (= single-instance / NullStore mode).
This helper returns the literal string only for cases 2 and 3; case 1 is handled
directly in the container env block via secretKeyRef.
*/}}
{{- define "jellyfin-ha.redisConnectionString" -}}
{{- if .Values.ha.transcodeStore.redisConnectionString }}
{{- .Values.ha.transcodeStore.redisConnectionString }}
{{- else if .Values.redis.enabled }}
{{- printf "%s:6379,abortConnect=false" (include "jellyfin-ha.redis.fullname" .) }}
{{- end }}
{{- end }}
{{/*
Return true if HA mode is active and Redis should be wired up.
*/}}
{{- define "jellyfin-ha.haEnabled" -}}
{{- if and .Values.ha.enabled (or .Values.redis.enabled .Values.ha.transcodeStore.redisConnectionString .Values.ha.transcodeStore.existingSecret) }}
{{- "true" }}
{{- end }}
{{- end }}
{{/*
Config PVC claim name either the existing claim or the chart-managed one.
*/}}
{{- define "jellyfin-ha.configPvcName" -}}
{{- if .Values.persistence.config.existingClaim }}
{{- .Values.persistence.config.existingClaim }}
{{- else }}
{{- printf "%s-config" (include "jellyfin-ha.fullname" .) }}
{{- end }}
{{- end }}
{{/*
Transcode PVC claim name either the existing claim or the chart-managed one.
*/}}
{{- define "jellyfin-ha.transcodePvcName" -}}
{{- if .Values.persistence.transcode.existingClaim }}
{{- .Values.persistence.transcode.existingClaim }}
{{- else }}
{{- printf "%s-transcode" (include "jellyfin-ha.fullname" .) }}
{{- end }}
{{- end }}
{{/*
Media PVC claim name either the existing claim or the chart-managed NFS PVC.
*/}}
{{- define "jellyfin-ha.mediaPvcName" -}}
{{- if .Values.persistence.media.existingClaim }}
{{- .Values.persistence.media.existingClaim }}
{{- else if .Values.persistence.media.nfs.enabled }}
{{- printf "%s-media" (include "jellyfin-ha.fullname" .) }}
{{- end }}
{{- end }}
@@ -0,0 +1,15 @@
{{- if .Values.runtimeConfig.enabled }}
# jellyfin.runtimeconfig.json ConfigMap.
# Mount path: /jellyfin/jellyfin.runtimeconfig.json
# Use this to set .NET runtime configuration switches (e.g. Intel QSV codec flags).
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "jellyfin-ha.fullname" . }}-runtimeconfig
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
data:
jellyfin.runtimeconfig.json: |
{{- .Values.runtimeConfig.json | nindent 4 }}
{{- end }}
@@ -0,0 +1,97 @@
{{- if .Values.ingress.enabled }}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "jellyfin-ha.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
tls:
{{- toYaml .Values.ingress.tls | nindent 4 }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "jellyfin-ha.fullname" $ }}
port:
name: http
{{- end }}
{{- end }}
{{- end }}
---
{{- if .Values.traefikIngressRoute.enabled }}
# Traefik v3 IngressRoute (used by k3s default ingress controller).
# Enables sticky session cookies — required for multi-replica Jellyfin so that
# a client always lands on the same pod (session affinity).
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: {{ include "jellyfin-ha.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
{{- if .Values.traefikIngressRoute.tls.enabled }}
annotations:
cert-manager.io/cluster-issuer: {{ .Values.traefikIngressRoute.tls.clusterIssuer }}
{{- end }}
spec:
entryPoints:
{{- toYaml .Values.traefikIngressRoute.entryPoints | nindent 4 }}
routes:
- match: Host(`{{ .Values.traefikIngressRoute.host }}`)
kind: Rule
services:
- name: {{ include "jellyfin-ha.fullname" . }}
port: {{ .Values.service.port }}
{{- if .Values.traefikIngressRoute.sticky.enabled }}
sticky:
cookie:
name: {{ .Values.traefikIngressRoute.sticky.cookieName }}
httpOnly: {{ .Values.traefikIngressRoute.sticky.httpOnly }}
secure: {{ .Values.traefikIngressRoute.sticky.secure }}
{{- end }}
{{- if .Values.traefikIngressRoute.tls.enabled }}
tls:
secretName: {{ .Values.traefikIngressRoute.tls.secretName }}
{{- end }}
---
{{- if .Values.traefikIngressRoute.tls.enabled }}
# cert-manager Certificate for Traefik TLS termination.
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: {{ include "jellyfin-ha.fullname" . }}-tls
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
spec:
secretName: {{ .Values.traefikIngressRoute.tls.secretName }}
issuerRef:
name: {{ .Values.traefikIngressRoute.tls.clusterIssuer }}
kind: ClusterIssuer
dnsNames:
{{- if .Values.traefikIngressRoute.tls.dnsNames }}
{{- toYaml .Values.traefikIngressRoute.tls.dnsNames | nindent 4 }}
{{- else }}
- {{ .Values.traefikIngressRoute.host }}
{{- end }}
{{- end }}
{{- end }}
@@ -0,0 +1,14 @@
{{- if .Values.podDisruptionBudget.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "jellyfin-ha.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
spec:
minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
selector:
matchLabels:
{{- include "jellyfin-ha.selectorLabels" . | nindent 6 }}
{{- end }}
@@ -0,0 +1,20 @@
{{- if .Values.postgresql.enabled }}
# PersistentVolumeClaim for the in-cluster PostgreSQL data directory.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "jellyfin-ha.postgres.fullname" . }}-data
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: database
spec:
accessModes:
- ReadWriteOnce
{{- if .Values.postgresql.persistence.storageClass }}
storageClassName: {{ .Values.postgresql.persistence.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.postgresql.persistence.size }}
{{- end }}
@@ -0,0 +1,21 @@
{{- if .Values.postgresql.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "jellyfin-ha.postgres.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: database
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: database
ports:
- name: postgres
port: {{ .Values.postgresql.service.port }}
targetPort: postgres
protocol: TCP
{{- end }}
@@ -0,0 +1,84 @@
{{- if .Values.postgresql.enabled }}
# In-cluster PostgreSQL StatefulSet — experimental.
# The credentials secret must be created manually before first deploy:
#
# kubectl create secret generic {{ .Values.postgresql.existingSecret }} \
# --namespace {{ .Release.Namespace }} \
# --from-literal=POSTGRES_USER=jellyfin \
# --from-literal=POSTGRES_PASSWORD=<strong-password> \
# --from-literal=POSTGRES_DB=jellyfin \
# --from-literal=DATABASE_URL="postgresql://jellyfin:<password>@{{ include "jellyfin-ha.postgres.fullname" . }}:5432/jellyfin"
#
# SECURITY: Do NOT add a Secret resource here. Applying this file must not
# overwrite a live secret.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "jellyfin-ha.postgres.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: database
spec:
replicas: 1
serviceName: {{ include "jellyfin-ha.postgres.fullname" . }}
selector:
matchLabels:
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: database
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: database
spec:
containers:
- name: postgres
image: "{{ .Values.postgresql.image.repository }}:{{ .Values.postgresql.image.tag }}"
imagePullPolicy: {{ .Values.postgresql.image.pullPolicy }}
ports:
- name: postgres
containerPort: 5432
protocol: TCP
env:
- name: POSTGRES_USER
valueFrom:
secretKeyRef:
name: {{ .Values.postgresql.existingSecret }}
key: POSTGRES_USER
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.postgresql.existingSecret }}
key: POSTGRES_PASSWORD
- name: POSTGRES_DB
valueFrom:
secretKeyRef:
name: {{ .Values.postgresql.existingSecret }}
key: POSTGRES_DB
- name: PGDATA
value: /var/lib/postgresql/data/pgdata
resources:
{{- toYaml .Values.postgresql.resources | nindent 12 }}
livenessProbe:
exec:
command: ["pg_isready", "-U", "$(POSTGRES_USER)"]
initialDelaySeconds: 30
periodSeconds: 20
timeoutSeconds: 5
readinessProbe:
exec:
command: ["pg_isready", "-U", "$(POSTGRES_USER)"]
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 3
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
volumes:
- name: data
persistentVolumeClaim:
claimName: {{ include "jellyfin-ha.postgres.fullname" . }}-data
{{- end }}
@@ -0,0 +1,90 @@
{{- if not .Values.persistence.config.existingClaim }}
# Shared config PVC — used by all Jellyfin replicas.
# Must be ReadWriteMany when replicaCount > 1.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "jellyfin-ha.fullname" . }}-config
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: storage
spec:
accessModes:
- {{ .Values.persistence.config.accessMode }}
{{- if .Values.persistence.config.storageClass }}
storageClassName: {{ .Values.persistence.config.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.config.size }}
{{- end }}
---
{{- if not .Values.persistence.transcode.existingClaim }}
# Shared transcode PVC — must be ReadWriteMany so pod takeover can read
# HLS segments written by the previous owner pod.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "jellyfin-ha.fullname" . }}-transcode
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: storage
spec:
accessModes:
- {{ .Values.persistence.transcode.accessMode }}
{{- if .Values.persistence.transcode.storageClass }}
storageClassName: {{ .Values.persistence.transcode.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.transcode.size }}
{{- end }}
---
{{- if and .Values.persistence.media.nfs.enabled (not .Values.persistence.media.existingClaim) }}
# NFS PersistentVolume and PersistentVolumeClaim for the media library.
# Enable persistence.media.nfs.enabled and provide server/path to use this.
# Alternatively, set persistence.media.existingClaim to reuse an existing PVC.
apiVersion: v1
kind: PersistentVolume
metadata:
name: {{ include "jellyfin-ha.fullname" . }}-media-nfs
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: storage
spec:
capacity:
storage: {{ .Values.persistence.media.nfs.size }}
accessModes:
- ReadOnlyMany
persistentVolumeReclaimPolicy: Retain
{{- if .Values.persistence.media.nfs.storageClass }}
storageClassName: {{ .Values.persistence.media.nfs.storageClass | quote }}
{{- end }}
nfs:
server: {{ .Values.persistence.media.nfs.server | quote }}
path: {{ .Values.persistence.media.nfs.path | quote }}
readOnly: true
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ include "jellyfin-ha.fullname" . }}-media
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: storage
spec:
accessModes:
- ReadOnlyMany
{{- if .Values.persistence.media.nfs.storageClass }}
storageClassName: {{ .Values.persistence.media.nfs.storageClass | quote }}
{{- end }}
resources:
requests:
storage: {{ .Values.persistence.media.nfs.size }}
volumeName: {{ include "jellyfin-ha.fullname" . }}-media-nfs
{{- end }}
@@ -0,0 +1,15 @@
{{- if .Values.redis.enabled }}
# ConfigMap holding the Redis configuration file.
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "jellyfin-ha.redis.fullname" . }}-config
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: redis
data:
redis.conf: |
maxmemory {{ .Values.redis.maxmemory }}
maxmemory-policy {{ .Values.redis.maxmemoryPolicy }}
{{- end }}
@@ -0,0 +1,58 @@
{{- if .Values.redis.enabled }}
# In-cluster Redis Deployment for jellyifn-ha transcode session store.
# No persistence — lease data is small and reconstructable on restart.
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "jellyfin-ha.redis.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: redis
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
template:
metadata:
labels:
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
spec:
containers:
- name: redis
image: "{{ .Values.redis.image.repository }}:{{ .Values.redis.image.tag }}"
imagePullPolicy: {{ .Values.redis.image.pullPolicy }}
args: ["redis-server", "/etc/redis/redis.conf"]
ports:
- name: redis
containerPort: 6379
protocol: TCP
resources:
{{- toYaml .Values.redis.resources | nindent 12 }}
livenessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 15
periodSeconds: 20
timeoutSeconds: 5
readinessProbe:
exec:
command: ["redis-cli", "ping"]
initialDelaySeconds: 5
periodSeconds: 10
timeoutSeconds: 3
volumeMounts:
- name: config
mountPath: /etc/redis
volumes:
- name: config
configMap:
name: {{ include "jellyfin-ha.redis.fullname" . }}-config
{{- end }}
@@ -0,0 +1,21 @@
{{- if .Values.redis.enabled }}
apiVersion: v1
kind: Service
metadata:
name: {{ include "jellyfin-ha.redis.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
app.kubernetes.io/component: redis
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: {{ include "jellyfin-ha.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
app.kubernetes.io/component: redis
ports:
- name: redis
port: 6379
targetPort: redis
protocol: TCP
{{- end }}
@@ -0,0 +1,20 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "jellyfin-ha.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
{{- with .Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
selector:
{{- include "jellyfin-ha.selectorLabels" . | nindent 4 }}
ports:
- name: http
port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
@@ -0,0 +1,27 @@
{{- if .Values.serviceMonitor.enabled }}
# Prometheus ServiceMonitor.
# Jellyfin does not expose a native /metrics endpoint. Enable this if you have
# a Prometheus sidecar or plan to add one. The kube-state-metrics replica count
# alert is the primary health signal for Jellyfin without a native exporter.
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: {{ include "jellyfin-ha.fullname" . }}
namespace: {{ .Release.Namespace }}
labels:
{{- include "jellyfin-ha.labels" . | nindent 4 }}
{{- with .Values.serviceMonitor.additionalLabels }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
selector:
matchLabels:
{{- include "jellyfin-ha.selectorLabels" . | nindent 6 }}
endpoints:
- port: http
path: {{ .Values.serviceMonitor.path }}
interval: {{ .Values.serviceMonitor.interval }}
namespaceSelector:
matchNames:
- {{ .Release.Namespace }}
{{- end }}
@@ -0,0 +1,279 @@
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 }}
- name: Jellyfin__TranscodeStore__SessionRetentionSeconds
value: {{ .Values.ha.transcodeStore.sessionRetentionSeconds | 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 }}
+408
View File
@@ -0,0 +1,408 @@
# 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
# -- How long (seconds) an unrenewed session record is kept so another pod can still take it over.
sessionRetentionSeconds: 300
# -- Secret containing the Redis connection string.
# If set, the connection string is read from this secret instead of the value above.
# The secret must have a key named by existingSecret.key.
existingSecret: ""
existingSecretKey: "connection-string"
# ---------------------------------------------------------------------------
# In-cluster Redis (for transcode session store)
# ---------------------------------------------------------------------------
redis:
# -- Deploy an in-cluster Redis instance.
# Disable and set ha.transcodeStore.redisConnectionString to use an external Redis.
enabled: true
image:
repository: redis
tag: "7.4.2-alpine3.21"
pullPolicy: IfNotPresent
# -- Maximum memory for Redis to use.
maxmemory: "256mb"
# -- LRU eviction policy when maxmemory is reached.
maxmemoryPolicy: "allkeys-lru"
resources:
requests:
cpu: 25m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi
# ---------------------------------------------------------------------------
# Jellyfin application configuration
# ---------------------------------------------------------------------------
config:
# -- The externally-reachable URL Jellyfin reports to clients.
publishedServerUrl: ""
# -- Disable UDP auto-discovery (port 7359).
# Recommended when running multiple replicas to prevent duplicate discovery responses.
autoDiscovery: false
# -- Jellyfin config directory inside the container.
# Must differ from the data/root directory to pass Jellyfin's sanity check.
configDir: "/config/config"
# -- Database provider: "SQLite" (default) or "Jellyfin-PostgreSQL" (experimental).
# When set to "Jellyfin-PostgreSQL", an init container will inject database.xml
# and the postgresql.enabled section (or an external connection string) must be configured.
databaseType: "SQLite"
# -- Extra environment variables to set on the Jellyfin container.
# Example:
# - name: JELLYFIN_Network__BaseUrl
# value: "/jellyfin"
extraEnv: []
# ---------------------------------------------------------------------------
# PostgreSQL (experimental — only needed when config.databaseType = Jellyfin-PostgreSQL)
# ---------------------------------------------------------------------------
postgresql:
# -- Deploy an in-cluster PostgreSQL instance.
enabled: false
image:
repository: postgres
tag: "16.6-alpine3.21"
pullPolicy: IfNotPresent
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
cpu: "1"
memory: 1Gi
persistence:
storageClass: ""
size: 5Gi
# -- Name of an existing secret with PostgreSQL credentials.
# Required when postgresql.enabled=true. The secret must contain:
# POSTGRES_USER, POSTGRES_PASSWORD, POSTGRES_DB, DATABASE_URL
# Create it with:
# kubectl create secret generic jellyfin-postgres-credentials \
# --from-literal=POSTGRES_USER=jellyfin \
# --from-literal=POSTGRES_PASSWORD=<password> \
# --from-literal=POSTGRES_DB=jellyfin \
# --from-literal=DATABASE_URL="postgresql://jellyfin:<password>@<host>:5432/jellyfin"
existingSecret: "jellyfin-postgres-credentials"
service:
port: 5432
# ---------------------------------------------------------------------------
# GPU / hardware transcoding
# ---------------------------------------------------------------------------
gpu:
# -- Enable Intel QSV / VA-API hardware transcoding.
# Mounts /dev/dri from the host and sets the required security context.
enabled: false
intel:
# -- Node affinity label to prefer GPU-capable nodes.
nodeLabel:
key: gpu
value: intel-uhd-630
# -- Toleration for the GPU node taint.
toleration:
key: gpu
value: "true"
effect: NoSchedule
# -- Mount /dev/dri from the host (required for VA-API; implies privileged=true).
mountDri: true
# ---------------------------------------------------------------------------
# Persistence
# ---------------------------------------------------------------------------
persistence:
# Config volume — single-writer; RWO is fine for single-replica deployments.
# For multi-replica: use an RWX storage class (e.g. Longhorn RWX, NFS) or
# point all pods at an existing shared PVC via existingClaim.
config:
# -- Size of the config PVC.
size: 5Gi
# -- Storage class. Leave empty to use the cluster default.
storageClass: ""
# -- Access mode. Use ReadWriteMany when replicaCount > 1 and sharing one PVC.
accessMode: ReadWriteMany
# -- Reuse an existing PVC. When set, no new PVC is created.
existingClaim: ""
# Media volume — read-only mount shared by all pods.
# Configure one of: existingClaim (for an existing PVC), nfs (to create an NFS PV+PVC),
# or existingClaim pointing at a pre-created PVC.
media:
# -- Reuse an existing media PVC (most common for homelab NFS/Longhorn setups).
existingClaim: ""
# -- Create an NFS-backed PV and PVC for the media library.
nfs:
enabled: false
server: "your-nas.local"
path: "/media"
size: 1Ti
storageClass: ""
# Transcode volume — MUST be ReadWriteMany when replicaCount > 1 so that
# a recovering pod can read HLS segments written by the pod it is replacing.
# When replicaCount=1, ReadWriteOnce is acceptable.
transcode:
size: 30Gi
storageClass: ""
accessMode: ReadWriteMany
existingClaim: ""
# Per-pod cache volume — local to each pod; always RWO.
# Created via StatefulSet volumeClaimTemplates (one PVC per pod).
cache:
size: 30Gi
storageClass: ""
# ---------------------------------------------------------------------------
# Service
# ---------------------------------------------------------------------------
service:
type: ClusterIP
port: 8096
# -- Annotations for the Service resource.
annotations: {}
# ---------------------------------------------------------------------------
# Ingress (standard Kubernetes Ingress)
# ---------------------------------------------------------------------------
ingress:
enabled: false
# -- Ingress class name (e.g. "nginx", "traefik").
className: ""
annotations: {}
# cert-manager.io/cluster-issuer: letsencrypt-prod
# nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
hosts:
- host: jellyfin.example.com
paths:
- path: /
pathType: Prefix
tls: []
# - secretName: jellyfin-tls
# hosts:
# - jellyfin.example.com
# ---------------------------------------------------------------------------
# Traefik IngressRoute (Traefik v3 CRD — used by k3s default ingress)
# ---------------------------------------------------------------------------
traefikIngressRoute:
enabled: false
entryPoints:
- websecure
# -- Hostname for the Traefik routing rule.
host: "jellyfin.example.com"
# -- Enable sticky session cookie (recommended for multi-replica Jellyfin).
sticky:
enabled: true
cookieName: "jellyfin-server-id"
httpOnly: true
secure: true
# -- cert-manager Certificate resource for TLS.
tls:
enabled: false
secretName: "jellyfin-tls"
clusterIssuer: "letsencrypt-prod"
dnsNames: []
# - jellyfin.example.com
# ---------------------------------------------------------------------------
# Resource requests and limits for the Jellyfin container
# ---------------------------------------------------------------------------
resources:
requests:
cpu: 500m
memory: 1Gi
limits:
cpu: "4"
memory: 4Gi
# ---------------------------------------------------------------------------
# Liveness and readiness probes
# ---------------------------------------------------------------------------
livenessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 30
periodSeconds: 30
timeoutSeconds: 5
failureThreshold: 3
readinessProbe:
httpGet:
path: /health
port: http
initialDelaySeconds: 10
periodSeconds: 10
timeoutSeconds: 5
failureThreshold: 3
# ---------------------------------------------------------------------------
# Security context
# ---------------------------------------------------------------------------
# Container-level security context.
securityContext:
# -- Set to true only when GPU passthrough via /dev/dri is required.
# privileged=true is required for DRM ioctls (VA-API). Omit (false) for
# software-only transcoding.
privileged: false
# -- UID for the Jellyfin process. Use 10010 to match the svc-jellyfin NAS account
# when NFS root_squash is enabled.
runAsUser: 1000
runAsGroup: 1000
# Pod-level security context.
podSecurityContext:
# -- fsGroup ensures mounted volumes are group-writable.
fsGroup: 1000
# -- Additional groups for /dev/dri access (video=44, render=109 or 991).
supplementalGroups: []
# - 44 # video
# - 109 # render (legacy)
# - 991 # render (Debian 13 trixie)
seccompProfile:
type: RuntimeDefault
# ---------------------------------------------------------------------------
# Service account
# ---------------------------------------------------------------------------
serviceAccount:
create: false
name: ""
annotations: {}
# ---------------------------------------------------------------------------
# Pod Disruption Budget
# ---------------------------------------------------------------------------
podDisruptionBudget:
enabled: true
minAvailable: 1
# ---------------------------------------------------------------------------
# Pod anti-affinity (spread replicas across nodes for node-level HA)
# ---------------------------------------------------------------------------
podAntiAffinity:
enabled: true
# -- "preferred" won't block scheduling if nodes are insufficient.
# Use "required" to enforce strict cross-node placement.
type: preferred
weight: 100
# ---------------------------------------------------------------------------
# Prometheus ServiceMonitor
# Note: Jellyfin has no native /metrics endpoint. This ServiceMonitor is
# included for future use (e.g. if you add a sidecar exporter) or for
# blackbox-style readiness monitoring. Disable if not using kube-prometheus-stack.
# ---------------------------------------------------------------------------
serviceMonitor:
enabled: false
# -- Scrape interval.
interval: "30s"
# -- Scrape path (Jellyfin does not expose Prometheus metrics natively).
path: /metrics
# -- Additional labels to add to the ServiceMonitor (e.g. to match a Prometheus release label).
additionalLabels: {}
# release: kube-prometheus-stack
# ---------------------------------------------------------------------------
# Runtime config (jellyfin.runtimeconfig.json)
# Set dotnet runtime switches here if needed. Leave empty for defaults.
# ---------------------------------------------------------------------------
runtimeConfig:
enabled: false
# -- Raw JSON content for jellyfin.runtimeconfig.json.
# See jellyfin-runtimeconfig ConfigMap in the existing manifests for an example.
json: |
{
"configProperties": {}
}
# ---------------------------------------------------------------------------
# Extra Kubernetes resources
# ---------------------------------------------------------------------------
# -- Additional volumes to attach to the Jellyfin pod.
extraVolumes: []
# - name: my-extra-config
# configMap:
# name: my-configmap
# -- Additional volume mounts for the Jellyfin container.
extraVolumeMounts: []
# - name: my-extra-config
# mountPath: /etc/my-config
# -- Additional init containers.
extraInitContainers: []
# -- Annotations to add to the StatefulSet.
annotations: {}
# -- Annotations to add to individual pods.
podAnnotations: {}
# -- Labels to add to the StatefulSet.
labels: {}
# -- Labels to add to individual pods.
podLabels: {}
# -- Update strategy for the StatefulSet.
updateStrategy:
type: RollingUpdate
+202
View File
@@ -0,0 +1,202 @@
> **Last updated: 2026-03-04**
# Jellyfin Server Architecture
High-level overview of the Jellyfin server structure, layer responsibilities, and key subsystems.
## Runtime
| Component | Value |
|---|---|
| Framework | .NET 10 / ASP.NET Core 10 |
| Target | `net10.0` |
| Entry point | `Jellyfin.Server` |
| Version | `12.0.0` (see `SharedVersion.cs`) |
---
## Layer Diagram
```
┌───────────────────────────────────────────────────────────┐
│ HTTP Clients │
│ (Jellyfin Web, mobile apps, 3rd-party) │
└────────────────────────┬──────────────────────────────────┘
│ REST / WebSocket
┌────────────────────────▼──────────────────────────────────┐
│ Jellyfin.Api │
│ ASP.NET Core controllers, middleware, auth, Swashbuckle │
└────────────────────────┬──────────────────────────────────┘
│ Interfaces (ILibraryManager, etc.)
┌────────────────────────▼──────────────────────────────────┐
│ MediaBrowser.Controller │
│ Core domain interfaces — no implementation here │
└────────────────────────┬──────────────────────────────────┘
│ Implementations
┌────────────────────────▼──────────────────────────────────┐
│ Emby.Server.Implementations / Jellyfin.Server.Impl │
│ Library manager, item repos, scheduled tasks, HTTP server│
└────────┬───────────────────────────────┬──────────────────┘
│ │
┌────────▼────────┐ ┌────────▼────────┐
│ Jellyfin.Data │ │ MediaBrowser │
│ EF Core DbCtx │ │ MediaEncoding │
│ SQLite via │ │ FFmpeg, HLS, │
│ Microsoft.Data │ │ Trickplay │
│ .Sqlite │ └─────────────────┘
└─────────────────┘
┌────────▼─────────────────────────────────────────────────┐
│ MediaBrowser.Model │
│ Pure DTOs, enums, no logic (shared by all layers) │
└──────────────────────────────────────────────────────────┘
```
---
## Project Responsibilities
### `Jellyfin.Server`
Entry point. Handles:
- CLI argument parsing (`CommandLineParser`)
- Serilog configuration (console, file, Graylog sinks)
- DI container wiring (`ApplicationHost`)
- ASP.NET Core host startup
### `Jellyfin.Api`
All HTTP surface. Handles:
- ASP.NET Core controllers (`Controllers/`)
- Authentication middleware (`Auth/`)
- Swashbuckle/OpenAPI configuration
- Request/response formatting (camelCase + PascalCase JSON)
- WebSocket listeners (`WebSocketListeners/`)
Controllers inherit from `BaseJellyfinApiController` which sets default route, produces JSON, and provides typed `Ok<T>()` helpers.
### `MediaBrowser.Controller`
Core domain interfaces. Key examples:
- `ILibraryManager` — media library operations
- `IMediaEncoder` — FFmpeg wrapper
- `IProviderManager` — metadata provider coordination
- `IUserManager` — user management
- `IPlaybackManager` — playback session tracking
**No implementations live here.** This keeps the domain decoupled from infrastructure.
### `Emby.Server.Implementations`
Primary implementation assembly. Contains:
- `ApplicationHost.cs` — DI wiring and startup
- `Data/` — SQLite queries and EF Core repositories
- `Library/``LibraryManager`, `LibraryMonitor`
- `Images/` — image processing pipeline (SkiaSharp)
- `HttpServer/` — HTTP server wiring
### `Jellyfin.Server.Implementations`
Secondary implementation assembly split from `Emby.Server.Implementations`. Contains newer implementations using EF Core patterns.
### `Jellyfin.Data`
EF Core data models and `DbContext`. Migrations managed here.
### `MediaBrowser.Model`
Pure data-transfer objects (DTOs) and enums. No logic. Consumed by all layers and by external clients. Changes here are API-breaking.
### `MediaBrowser.Providers`
Online metadata providers:
- TMDB (movies, TV)
- MusicBrainz (audio)
- OMDB
- TV Maze, TheTVDB
Uses `IMetadataProvider<T>` interface from `MediaBrowser.Controller`.
### `MediaBrowser.MediaEncoding`
FFmpeg process management, HLS streaming, keyframe extraction, subtitle transcoding, trickplay image generation.
### `Emby.Naming`
Media file path parsing — resolves series/season/episode structure, detects extras, parses video codecs from filenames.
### `MediaBrowser.LocalMetadata` / `MediaBrowser.XbmcMetadata`
Local NFO/XML metadata providers (Kodi-compatible `.nfo` sidecar files).
### `src/Jellyfin.CodeAnalysis`
Custom Roslyn analyzer. Runs only in Debug builds. Enforces project-specific rules.
---
## Key Subsystems
### Authentication
- Session-based API keys (stored in SQLite)
- Quick Connect (pairing flow)
- Auth middleware in `Jellyfin.Api/Auth/`
- Policies defined in `Jellyfin.Api/Constants/Policies.cs`
### Library Scanning
1. `LibraryMonitor` watches filesystem for changes
2. `LibraryManager` resolves paths → `BaseItem` subclasses
3. `Emby.Naming` parses filenames → metadata hints
4. `IProviderManager` fetches remote metadata and saves locally
5. Results persisted to SQLite via EF Core
### Transcoding
1. Client requests a stream via `MediaInfoController` or `DynamicHlsController`
2. `MediaInfoHelper` determines if transcoding is needed (codec matrix)
3. `MediaEncoder` spawns an FFmpeg subprocess with computed arguments
4. HLS segments or direct stream served via `AudioController` / `VideosController`
### Metrics
prometheus-net serves metrics at `/metrics`. Key meters:
- `prometheus-net.AspNetCore` — HTTP request duration/count
- `prometheus-net.DotNetRuntime` — GC, thread pool, JIT metrics
- Custom counters can be added via `Metrics.CreateCounter(...)` in any service
### Logging
Serilog pipeline:
- Console sink (structured)
- File sink (rolling, default `%APPDATA%/jellyfin/logs/`)
- Graylog GELF sink (optional, configured via `logging.json`)
---
## Database
SQLite database at `{DataDir}/data/jellyfin.db`. Accessed via:
- EF Core (`Jellyfin.Data.JellyfinDbContext`) for new data access
- `Microsoft.Data.Sqlite` direct queries for legacy paths
**All EF Core operations must use async methods** (`ToListAsync`, `FirstOrDefaultAsync`, etc.).
---
## Test Layout
```
tests/
Jellyfin.Api.Tests/ Controller + middleware unit tests
Jellyfin.Common.Tests/ MediaBrowser.Common utilities
Jellyfin.Controller.Tests/ Interface contracts and helpers
Jellyfin.MediaEncoding.Tests/ FFmpeg argument building
Jellyfin.Naming.Tests/ File path parsing
Jellyfin.Providers.Tests/ Provider logic
Jellyfin.Server.Integration.Tests/ Full-stack HTTP tests + OpenAPI spec gen
Jellyfin.Server.Tests/ Server startup and DI tests
```
Test stack: xUnit + AutoFixture + Moq + FsCheck. See `.github/instructions/testing.instructions.md`.
+164
View File
@@ -0,0 +1,164 @@
> **Last updated: 2026-03-04**
# Contributing to Jellyfin Server
This guide covers everything you need to develop, build, test, and submit changes to the Jellyfin server.
## Prerequisites
| Tool | Version | Notes |
|---|---|---|
| .NET SDK | 10.0.x | See `global.json``rollForward: latestMinor` |
| Git | any recent | `git clone` with submodules not required |
| FFmpeg | 7.x | Required for transcoding tests; install via devcontainer or manually |
| Docker | optional | For devcontainer workflow |
### macOS (Homebrew)
```bash
brew install dotnet
```
### Linux (Debian/Ubuntu)
```bash
wget https://dot.net/v1/dotnet-install.sh && bash dotnet-install.sh --channel 10.0
```
### Windows
Download the [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10) installer.
### DevContainer (recommended for new contributors)
Open the repo in VS Code and accept the "Reopen in Container" prompt. The devcontainer installs:
- .NET 10
- FFmpeg
- All recommended VS Code extensions
---
## Build
```bash
# Build the server entry point
dotnet build Jellyfin.Server/Jellyfin.Server.csproj
# Build the entire solution (all projects)
dotnet build Jellyfin.sln
```
Debug builds activate all code analyzers (StyleCop, BannedApiAnalyzers, IDisposableAnalyzers, MultithreadingAnalyzer). **Expect build failures if your code has missing XML docs or uses banned APIs.**
---
## Run Locally
```bash
dotnet run --project Jellyfin.Server/Jellyfin.Server.csproj \
-- --datadir /tmp/jellyfin-data --webdir /tmp/jellyfin-web --nowebclient
```
The server starts on `http://localhost:8096` by default.
---
## Test
```bash
# Run all tests (cross-platform matrix: Linux, macOS, Windows)
dotnet test Jellyfin.sln --configuration Release --verbosity minimal
# Run a single test project
dotnet test tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj
# Run tests matching a name filter
dotnet test Jellyfin.sln --filter "ClassName=MyServiceTests"
# Run with code coverage
dotnet test Jellyfin.sln \
--configuration Release \
--collect:"XPlat Code Coverage" \
--settings tests/coverletArgs.runsettings
```
Coverage output: `merged/Cobertura.xml` (merged by ReportGenerator in CI).
### Regenerate OpenAPI Spec
After adding or changing any API endpoint:
```bash
dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj \
-c Release \
--filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
```
Commit the updated `openapi.json` — the CI diff job will flag unintentional breaking changes.
---
## Code Style
All style rules are enforced by the compiler in Debug builds. Key rules:
- **Nullable enabled** — mark nullable types with `?`, never silence with `null!` without a comment
- **Warnings as errors** — fix every warning; do not suppress with `#pragma warning disable`
- **XML docs** — every `public` type and member must have `/// <summary>`
- **No `Task.Result`** — always `await` instead
- **Central NuGet versions** — versions in `Directory.Packages.props` only, never in `.csproj`
- **File-scoped namespaces** — use `namespace Jellyfin.Example;` (not block-scoped)
See `.github/instructions/csharp.instructions.md` for the full ruleset.
---
## Pull Request Process
1. Fork the repo and create a feature branch from `main`
2. Make your changes; ensure `dotnet build` and `dotnet test` pass locally
3. Fill out the PR template (`.github/pull_request_template.md`):
- **Changes**: 15 sentence summary
- **Issues**: tag with `Fixes #NNN`
4. CI runs automatically:
- `ci-tests.yml` — tests on Linux, macOS, Windows
- `ci-openapi.yml` — OpenAPI diff
- `ci-codeql-analysis.yml` — security scan
5. A maintainer will review and merge
### Title format
Use the imperative mood:
-`Add lyrics endpoint for audio items`
-`Fix null reference in LibraryController`
-`Added lyrics endpoint`
-`Fixed null reference`
---
## Adding a New Package Dependency
1. Add the version to `Directory.Packages.props`:
```xml
<PackageVersion Include="SomePackage" Version="1.2.3" />
```
2. Add the reference to the relevant `.csproj` (no `Version=` attribute):
```xml
<PackageReference Include="SomePackage" />
```
**Never** specify both a version in `Directory.Packages.props` AND in the `.csproj` — that causes `NU1008`.
---
## Project Conventions
See `.github/instructions/` for detailed instructions per concern:
| Topic | File |
|---|---|
| C# style | `csharp.instructions.md` |
| API controllers | `api.instructions.md` |
| Tests | `testing.instructions.md` |
| CI/CD workflows | `ci-cd.instructions.md` |
| Documentation | `docs.instructions.md` |
+140
View File
@@ -0,0 +1,140 @@
# Fork Diff: `unkin/jellyfin-ha-src` vs `jellyfin/jellyfin`
> **Base:** `v12.0` (`6c073e19ddf604b2369c638716164fdab4c952dc`)
> **Head:** `origin/main`
> **Summary:** 56 files added · 16 upstream files modified · 15 upstream files deleted
---
## What changed and why
This fork adds a **high-availability layer** so Jellyfin can run as more than one
replica against shared storage. The design principle: extend through dependency
injection, touch as little upstream code as possible. No core media, library,
auth or plugin logic is rewritten.
| Bucket | Files | Lines added |
|--------|-------|-------------|
| HA contracts and models (`MediaBrowser.Controller`) | 8 | ~330 |
| Redis implementations (`Emby.Server.Implementations`) | 2 | ~350 |
| PostgreSQL database provider | 6 | ~4,980 |
| SQLite to PostgreSQL migration tool | 6 | ~720 |
| Modified upstream files | 16 | ~420 |
| Tests | 14 | ~2,790 |
| Helm chart | 17 | ~1,370 |
| CI and container build | 3 | ~175 |
---
## New components
### Transcode session store
| File | Purpose |
|------|---------|
| `MediaBrowser.Controller/MediaEncoding/ITranscodeSessionStore.cs` | DI contract for durable transcode session tracking |
| `MediaBrowser.Controller/MediaEncoding/TranscodeSession.cs` | Session record: owning pod, lease expiry, manifest and segment paths, last durable segment |
| `MediaBrowser.Controller/MediaEncoding/TranscodeStoreOptions.cs` | `RedisConnectionString`, `LeaseDurationSeconds` and `SessionRetentionSeconds` |
| `MediaBrowser.Controller/MediaEncoding/NullTranscodeSessionStore.cs` | No-op store used when no Redis connection is configured |
| `Emby.Server.Implementations/MediaEncoding/RedisTranscodeSessionStore.cs` | Redis store; sessions under `jellyfin:transcode:{playSessionId}`, key TTL is the retention window so an orphaned session outlives its lease |
| `Emby.Server.Implementations/MediaEncoding/TranscodeStoreConnectivityProbe.cs` | Startup ping; an unreachable configured store is logged at `Error` instead of failing open silently |
| `Jellyfin.Server/Extensions/TranscodeStoreServiceCollectionExtensions.cs` | Store selection, logged at `Information` so the active store is visible at startup |
| `Jellyfin.Server/Extensions/ConfigurationBuilderExtensions.cs` | Reads bare `Jellyfin__*` environment variables into the `Jellyfin:*` configuration root |
Lease takeover and renewal each run as a single Lua script, so concurrent pods cannot both
claim an expired lease and a renewal cannot revert a takeover. The expiry is stored as unix
milliseconds so the script can compare it:
```lua
local raw = redis.call('GET', KEYS[1])
if not raw then return 0 end
local session = cjson.decode(raw)
-- takeover: only an expired lease may be claimed
if tonumber(session['LeaseExpiresUtc']) > tonumber(ARGV[1]) then return 0 end
-- renewal: only the pod that still owns the lease may extend it
-- if session['OwnerPod'] ~= ARGV[2] then return 0 end
session['OwnerPod'] = ARGV[2]
-- update expiry and SET with PX in the same script
return 1
```
### Scan-leader lease
| File | Purpose |
|------|---------|
| `MediaBrowser.Controller/ScheduledTasks/IScanLeaderLease.cs` | DI contract for the leader lease |
| `MediaBrowser.Controller/ScheduledTasks/ScanLeaderOptions.cs` | `Enabled` (defaults to on when Redis is configured), `LeaseDurationSeconds`, `GatedTaskKeys` |
| `MediaBrowser.Controller/ScheduledTasks/NullScanLeaderLease.cs` | Always-leader default, preserving single-instance behaviour |
| `Emby.Server.Implementations/ScheduledTasks/RedisScanLeaderLease.cs` | Redis TTL lease; an unreachable Redis is treated as holding the lease |
Gated by default: `RefreshLibrary`, `RefreshPeople`, `RefreshChapterImages`,
`AudioNormalization`, `TaskExtractMediaSegments`, `KeyframeExtraction`,
`CleanupUserDataTask`, `OptimizeDatabaseTask`. Only timer-driven runs are gated;
manual and API-triggered runs always execute locally.
Gating is on whenever `Jellyfin:TranscodeStore:RedisConnectionString` is set, because that is only
set for a multi-instance deployment. Set `Jellyfin:ScanLeader:Enabled=false` to opt out.
Startup logs which way it went:
```
Scan-leader gating is active: timer-driven library tasks run only on the instance holding the Redis scan-leader lease.
Scan-leader gating is off: timer-driven library tasks run on every instance.
```
`Enabled=true` with no Redis connection string logs a warning, because gating cannot run.
### PostgreSQL provider
`src/Jellyfin.Database/Jellyfin.Database.Providers.PostgreSQL/` is an EF Core
provider parallel to the SQLite one, selected with
`DatabaseType: Jellyfin-PostgreSQL`. It carries a single initial migration
generated against the `v12.0` model. SQLite remains the default, so existing
deployments are unaffected.
`tools/Jellyfin.DbMigrator/` moves an existing `jellyfin.db` into PostgreSQL,
optionally uploading a pre-migration copy of the SQLite file to S3.
---
## Modified upstream files
| File | Change |
|------|--------|
| `Jellyfin.Server/CoreAppHost.cs` (+46) | Registers the Redis or null transcode store and scan-leader lease from startup config |
| `Jellyfin.Api/Controllers/DynamicHlsController.cs` | Registers the play session with its manifest and segment paths, renews the lease under this pod's identity, and shortens segments when resuming a stored session |
| `Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs` (+52/-6) | Keeps files belonging to an active session in the store |
| `Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs` (+28/-1) | Skips timer-driven gated tasks without the leader lease |
| `Emby.Server.Implementations/ScheduledTasks/TaskManager.cs` (+12/-2) | Passes the lease and options to each worker |
| `Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs` (+53) | Registers the PostgreSQL provider and a pooled `NpgsqlDataSource` |
| `Jellyfin.Server/Program.cs` (+5) | Exposes `IServerConfigurationManager` to the startup migration container |
| `MediaBrowser.Model/Configuration/EncodingOptions.cs` (+16) | Adds `RecoverySegmentLengthSeconds` and `RecoverySegmentBufferCount` |
| `Directory.Packages.props`, `Jellyfin.sln`, four `.csproj` files | New packages, projects and references |
| `tests/.../SessionManagerTests.cs`, `tests/.../IdlePlaybackTests.cs` | Pass the new constructor argument |
---
## Deleted upstream files
`.github/workflows/*` (15 files). The fork is hosted on Gitea and builds on
Woodpecker via `.woodpecker/ci.yaml`.
---
## Rebasing onto a newer upstream release
```bash
git remote add upstream https://github.com/jellyfin/jellyfin.git
git fetch upstream --tags
# files this fork owns outright
git diff v12.0...HEAD --name-only --diff-filter=A
# files that need re-porting onto the new release
git diff v12.0...HEAD --name-only --diff-filter=M
# upstream churn on a touched file since this base
git log v12.0..<new-tag> --oneline -- <path>
```
Re-apply the modified-file changes onto the new upstream code rather than
reverting upstream's changes to make a patch apply.
+83
View File
@@ -0,0 +1,83 @@
# Jellyfin HA transcoding fork: Redis-backed session failover + experimental PostgreSQL provider
I've been working on a fork of Jellyfin focused on one specific problem: making HLS transcoding survive pod restarts in a multi-replica Kubernetes deployment.
## What it does
Right now, Jellyfin assumes transcode state lives in one server process. If that pod dies, active transcodes die with it. This fork adds a small HA layer so transcode ownership can survive a pod restart:
- A new `ITranscodeSessionStore` abstraction for durable transcode session tracking
- A `RedisTranscodeSessionStore` implementation with lease-based ownership
- Atomic pod takeover using a Redis Lua script when a lease expires
- Lease-aware cleanup so one pod does not delete segments another pod still needs
- A `NullTranscodeSessionStore` fallback, so single-instance deployments behave exactly like upstream with no config changes
I also added an experimental PostgreSQL provider for shared-database deployments, since SQLite is not a good fit once multiple replicas are involved.
## What the HA flow looks like
- Pod A starts an HLS transcode and registers the session in Redis
- Pod A renews the lease while it owns the session
- If Pod A dies, the lease expires
- Pod B receives the next request, atomically claims the expired lease, and resumes from the last completed segment on shared storage
- The client sees a short buffer pause instead of a hard failure
## How to run it
There are three practical modes:
### 1. Single instance
No config needed. It falls back to the no-op store automatically.
### 2. Local HA test
Run two Jellyfin instances against:
- the same Redis
- the same shared transcode directory
That is enough to test failover behavior locally.
### 3. Kubernetes / k3s
This is the intended deployment model. You need:
- 2+ Jellyfin replicas
- Redis
- shared RWX storage for transcode output
- shared media storage
- ideally PostgreSQL if you want a proper shared DB setup
The key config is:
```text
Jellyfin:TranscodeStore:RedisConnectionString
Jellyfin:TranscodeStore:LeaseDurationSeconds
```
Repo and write-up:
- Source: https://github.com/ZoltyMat/jellyfin-ha
- Full change summary vs upstream: https://github.com/ZoltyMat/jellyfin-ha/blob/main/docs/FORK-DIFF.md
- Write-up with diagrams and k8s manifests: https://blog.zolty.systems/posts/jellyfin-ha-kubernetes
## What would be required to merge upstream
I do not expect this to be merged as-is without discussion. If there is interest, I think the realistic path is to split it into small pieces:
1. Introduce `ITranscodeSessionStore`, `TranscodeSession`, and `NullTranscodeSessionStore` only
2. Add the DI wiring with no behavior change unless configured
3. Add HLS session registration and lease renewal hooks
4. Add lease-aware cleanup in `DeleteTranscodeFileTask`
5. Add takeover logic in the HLS/session path
6. Discuss whether Redis should be the first supported distributed store, or whether the interface should land before any concrete implementation
7. Treat PostgreSQL as a separate discussion entirely
I think the HA transcode work has a better chance of review if it is separated from the PostgreSQL provider and migration tooling.
## Why I'm posting it
I'm not trying to maintain a permanent hard fork. I built this to see whether Jellyfin could be made to behave well in a replicated environment without rewriting major subsystems. The answer seems to be yes, but it needs maintainers to decide whether this kind of deployment is something upstream wants to support.
If there's interest, I'm happy to break the work into smaller PRs, clean up anything that does not match project direction, and rework the design around maintainer feedback.
+473
View File
@@ -0,0 +1,473 @@
# HA Transcoding Design — Phase 5.1.1 Audit
> **Status**: Design audit only. No functional code changes in this document.
> **Purpose**: Map the exact transcode lifecycle before Phase 5.2 code changes begin.
> **Last updated**: 2026-03-07
## Table of Contents
1. [Sequence Diagram: Full Transcode Lifecycle](#sequence-diagram-full-transcode-lifecycle)
2. [Key In-Memory State Fields](#key-in-memory-state-fields)
3. [Why `playSessionId` Is Insufficient](#why-playsessionid-is-insufficient)
4. [Why `DeleteTranscodeFileTask` Is Unsafe for Shared Storage](#why-deletetranscodfiletask-is-unsafe-for-shared-storage)
5. [How `SessionManager._activeLiveStreamSessions` Works](#how-sessionmanager_activelivestreamsessions-works)
6. [NFSv3 Lock Recovery on Pod Death](#nfsv3-lock-recovery-on-pod-death)
7. [Minimum Recovery State](#minimum-recovery-state)
8. [HA Failure Scenario Walk-Through](#ha-failure-scenario-walk-through)
9. [Open Questions Before Phase 5.2](#open-questions-before-phase-52)
10. [Cross-References](#cross-references)
---
## Sequence Diagram: Full Transcode Lifecycle
The following describes the path from a client HLS manifest request through
FFmpeg startup to segment delivery and session cleanup.
```
Client DynamicHlsController StreamingHelpers TranscodeManager
| | | |
| GET /Videos/{id}/live.m3u8 | | |
|------------------------------->| | |
| | GetStreamingState() | |
| |-------------------------->| |
| | StreamState | |
| |<--------------------------| |
| | | |
| | File.Exists(playlistPath)?| |
| |---------- NO ----------> | |
| | | |
| | LockAsync(playlistPath) | |
| |--------------------------------------------->| |
| | (async keyed lock held) | | |
| | | | |
| | StartFfMpeg(state, ...) | |
| |------------------------------------------>| |
| | | OnTranscodeBeginning()
| | | _activeTranscodingJobs.Add(job)
| | | Process.Start(ffmpeg)
| | TranscodingJob | |
| |<------------------------------------------| |
| | | |
| | WaitForMinimumSegmentCount() (if minSegments > 0) |
| |------------------------------------------ ... ---|
| | | |
| 200 OK (m3u8 playlist text) | | |
|<-------------------------------| | |
| | | |
| GET /Videos/{id}/hls/segment0.ts | |
|------------------------------->| | |
| | GetStreamingState() | |
| |-------------------------->| |
| | | |
| | File.Exists(playlistPath)?| |
| |---------- YES ----------> | |
| | | |
| | OnTranscodeBeginRequest(playlistPath, type) |
| |------------------------------------------>| |
| | job (from _activeTranscodingJobs by path) |
| |<------------------------------------------| |
| | | |
| | PingTranscodingJob(playSessionId) |
| | (resets kill timer, marks active) |
| | | |
| 200 OK (segment data) | | |
|<-------------------------------| | |
| | | |
| (client stops requesting) | | |
| | | |
| [kill timer fires after inactivity timeout] | |
| | | |
| | OnTranscodeKillTimerStopped() |
| |------------------------------------------>| |
| | KillTranscodingJob(job, ...) |
| | Process.Kill(ffmpeg) |
| | DeletePartialStreamFiles(path) |
| | _activeTranscodingJobs.Remove(job) |
```
### `GetStreamingState()` — What It Does
`StreamingHelpers.GetStreamingState()` (in `Jellyfin.Api/Helpers/StreamingHelpers.cs`)
constructs a `StreamState` object from the inbound `StreamingRequestDto`. It:
- Resolves the `MediaSourceInfo` for the request
- Computes `OutputFilePath` from `IApplicationPaths.TranscodePath` + a hash-derived subdirectory
- Applies encoding parameters from the request and the device profile
- Does **not** consult any durable store — state is recomputed from scratch on every request
### `StartFfMpeg()` — What It Does
`TranscodeManager.StartFfMpeg()` (line ~371, `MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs`):
1. Calls `OnTranscodeBeginning()` → creates a `TranscodingJob`, adds it to `_activeTranscodingJobs`
2. Calls `AcquireResources()` (waits `MediaSource.BufferMs` if set)
3. Starts FFmpeg process with the generated command line
4. Calls `StartThrottler()` and `StartSegmentCleaner()` if applicable
5. Returns the `TranscodingJob` to the caller
### `OnTranscodeBeginRequest()` — What It Does
Called when the playlist already exists on disk. Looks up a job in `_activeTranscodingJobs`
by filesystem path and `TranscodingJobType`. Returns `null` if no matching in-memory job
exists (which is exactly the pod-takeover failure scenario).
---
## Key In-Memory State Fields
### `TranscodeManager._activeTranscodingJobs`
**Location**: `MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs`, line 48
```csharp
private readonly List<TranscodingJob> _activeTranscodingJobs = new();
```
- Protected by `lock(_activeTranscodingJobs)` (monitor lock)
- **Process-local**: not shared between pods, not persisted to any durable store
- Contains one `TranscodingJob` per active FFmpeg process
- Looked up by `PlaySessionId` (string) or by path + type pair
Key `TranscodingJob` fields relevant to recovery:
| Field | Type | Notes |
|---|---|---|
| `PlaySessionId` | `string?` | Caller-supplied; can be null |
| `Path` | `string` | Absolute path to the m3u8 playlist file |
| `Type` | `TranscodingJobType` | `HLS`, `Progressive`, etc. |
| `DeviceId` | `string` | Client device identifier |
| `Process` | `Process?` | The live FFmpeg process handle |
| `IsLiveOutput` | `bool` | Set to `true` for live HLS streams |
| `Id` | `string` | `Guid.NewGuid().ToString("N")` — per-job, not durable |
### `SessionManager._activeLiveStreamSessions`
**Location**: `Emby.Server.Implementations/Session/SessionManager.cs`, line ~67
```csharp
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, string>> _activeLiveStreamSessions
```
- Maps `liveStreamId → (sessionId → playSessionId)`
- Updated by `UpdateLiveStreamActiveSessionMappings()` (line ~849)
- Queried in media-open paths to prevent double-opening a live stream
- **Process-local**: cleared on pod shutdown (`_activeLiveStreamSessions.Clear()` on line ~2151)
- A takeover pod **cannot** inherit these mappings without explicit rehydration from a durable store
---
## Why `playSessionId` Is Insufficient
`playSessionId` is an **optional, caller-supplied** query parameter:
```csharp
// DynamicHlsController.cs, GetLiveHlsStream():
[FromQuery] string? playSessionId,
```
It is passed directly to `StreamingRequestDto.PlaySessionId` and from there into
`TranscodingJob.PlaySessionId`. This creates three failure modes for HA:
### Failure Mode 1: Two clients collide on the same ID
If two clients supply the same `playSessionId` string, `GetTranscodingJob(playSessionId)`
returns the first matching job regardless of which device owns it. The second client's
segment requests will ping the first client's kill timer, potentially extending an
unrelated session indefinitely.
### Failure Mode 2: `null` PlaySessionId is common
When the Jellyfin web client does not supply a `playSessionId`, the field is `null`.
`GetTranscodingJob(string playSessionId)` does an `OrdinalIgnoreCase` compare:
```csharp
return _activeTranscodingJobs.FirstOrDefault(j =>
string.Equals(j.PlaySessionId, playSessionId, StringComparison.OrdinalIgnoreCase));
```
If `playSessionId` is null, `string.Equals(null, null)` returns `true`, so the lookup
returns the **first job in the list with a null PlaySessionId**, regardless of path,
device, or item. On a shared filesystem with two pods, this creates an ambiguity
between jobs running on different pods.
### Failure Mode 3: Insufficient as a durable recovery key
`playSessionId` is not generated by the server — it is client-supplied. There is no
guarantee it is present, globally unique, or stable across client reconnects. A durable
recovery store (Issue 5.2.1) must use a server-generated, correlation-stable key that
includes at minimum: server-assigned UUID, item ID, media source ID, and owner pod name.
---
## Why `DeleteTranscodeFileTask` Is Unsafe for Shared Storage
**Location**: `Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs`
```csharp
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
var minDateModified = DateTime.UtcNow.AddDays(-1);
// ...
DeleteTempFilesFromDirectory(_configurationManager.GetTranscodePath(), minDateModified, ...);
return Task.CompletedTask;
}
private void DeleteTempFilesFromDirectory(string directory, DateTime minDateModified, ...)
{
var filesToDelete = _fileSystem.GetFiles(directory, true)
.Where(f => _fileSystem.GetLastWriteTimeUtc(f) < minDateModified) // ← age only
.ToList();
// deletes without any lease check
}
```
**Triggers**: startup + every 24h.
**Problem for shared NFS storage**: The task deletes *any* file not written to in the
last 24 hours. When a pod dies and a takeover pod attempts recovery, it needs to:
1. Read the existing `.m3u8` manifest to find segment path prefix
2. Determine the last fully-written `.ts` segment
3. Restart FFmpeg from one segment before that point
If those files have an `mtime` older than 24 hours (e.g., the original pod started an
overnight transcode), the cleanup task running on any pod that boots after 24h will
delete them before the recovery pod can read them. There is **no lease or ownership check**.
**Required fix (Phase 5.2.2b)**: Before deleting a file, check whether a valid recovery
lease exists in the durable store (`ITranscodeSessionStore`). Skip deletion for any path
covered by an active or recently-expired lease.
---
## How `SessionManager._activeLiveStreamSessions` Works
When a Jellyfin client opens a live stream, `OpenMediaSource()` calls
`UpdateLiveStreamActiveSessionMappings(liveStreamId, sessionId, playSessionId)`:
```csharp
// SessionManager.cs, line ~849
private void UpdateLiveStreamActiveSessionMappings(string liveStreamId, string sessionId, string playSessionId)
{
var activeSessionMappings = _activeLiveStreamSessions.GetOrAdd(
liveStreamId, _ => new ConcurrentDictionary<string, string>());
activeSessionMappings[sessionId] = playSessionId;
}
```
This prevents two sessions from opening the same live stream without coordination. It is
consulted when another `OpenMediaSource` call arrives for the same `liveStreamId`.
**Why this breaks in HA**:
- The mapping lives only in the pod that originally opened the stream
- When the owning pod dies, active session mappings are gone
- A takeover pod has no record that liveStreamId `X` is in use
- `CloseLiveStream()` on pod B will never be called for a stream opened on pod A
- The live stream source (e.g., a TV tuner) may stay locked open indefinitely
**Recovery approach (Phase 5.2.1/5.3.1)**: The durable `ITranscodeSessionStore` must
persist `(liveStreamId → sessionId, playSessionId, ownerPod, openedAt)` and allow
takeover pods to query and claim abandoned streams.
---
## NFSv3 Lock Recovery on Pod Death
**NFS version confirmed**: `nfsvers=3` — from `kubernetes/apps/media/nfs-pv.yaml` mount
options used for all existing media NFS PersistentVolumes.
### NFSv3 Lock (`lockd`) Behavior on Pod Death
NFSv3 uses the Network Lock Manager (`lockd`) for advisory file locks. When a client
(pod) terminates:
1. The NFS client kernel module sends an `NSM` (Network Status Monitor) notification
to the NFS server
2. The NFS server's `lockd` releases all locks held by that client after a grace period
(typically the `sm-notify` retry window, default ~15s)
3. **Not guaranteed**: If the pod is killed abruptly (OOM/SIGKILL) and cannot send NSM
notification, the NFS server detects the client has disappeared via TCP keep-alive
timeout (typically 20120s depending on server configuration)
### Implications for Segment Files
FFmpeg writes `.ts` files sequentially. A typical write pattern:
1. Open `segment_N.ts` for write
2. Write video/audio data (24 MB for a 24s segment)
3. Close and rename/flush
If the pod dies **mid-write** of `segment_N.ts`:
- The file may be 0 bytes, partially filled, or have a corrupted end
- NFSv3 does **not** guarantee close-to-open consistency for concurrent readers
— another pod may see a stale cached version or a partial file
- The NFS server releases the lock within seconds to minutes, but the file
content is not rolled back
**Recovery rule (must implement in Phase 5.2)**:
> When resuming from a manifest on shared storage, identify the last `.ts` segment
> that appears in the `.m3u8` `#EXTINF` entries AND is non-zero in size AND has a
> stable mtime (not being written). Restart FFmpeg from **one segment before** that
> point to ensure the last segment is re-written cleanly.
This is analogous to the WAL recovery principle: never trust the last write from a
crashed writer.
### NFS Lock Hold-Up on Active Pod
When a Jellyfin pod has an open file handle on the NFS mount and the NAS becomes
unreachable, NFSv3 with `hard` mount option (confirmed in existing PVs) will block
I/O indefinitely — the pod will not crash, but it will stall. This is the correct
behavior for transcode recovery: FFmpeg stalls rather than emitting corrupt segments.
Test this in Issue 5.1.2 NAS outage test.
---
## Minimum Recovery State
For a takeover pod to resume an orphaned transcode session, the following minimum
state must be durably stored (Phase 5.2.1):
| Field | Source | Why Needed |
|---|---|---|
| `sessionId` | server-generated UUID | Stable correlation key; not client-supplied |
| `playSessionId` | client-supplied (may be null) | Needed to match kill-timer pings |
| `ownerPod` | k8s `POD_NAME` env var | Identify which pod is current owner |
| `manifestPath` | `OutputFilePath` with `.m3u8` extension | Entry point for takeover pod |
| `segmentPathPrefix` | derived from `manifestPath` directory | Find `.ts` files |
| `mediaSourceId` | `StreamState.MediaSource.Id` | Re-open the same stream |
| `itemId` | `StreamState.Request.ItemId` | Re-construct `StreamingRequestDto` |
| `encodingParams` | serialized subset of `StreamState` | Restart FFmpeg with identical params |
| `lastHeartbeatUtc` | updated by owner pod on segment write | Orphan detection: > 120s = orphaned |
| `lastCompletedSegmentIndex` | updated on each segment flush | Recovery knows where to seek |
| `deviceId` | `StreamState.Request.DeviceId` | Kill-job scope on cleanup |
---
## HA Failure Scenario Walk-Through
### Scenario: Pod A dies mid-transcode, Pod B receives next segment request
```
Pod A (owner) Redis (durable store) Pod B (takeover)
| | |
| write sessionKey → Redis | |
|-------------------------------->| |
| | |
| heartbeat every 30s | |
|-------------------------------->| |
| | |
DIES (OOMKill / node drain) | |
| GET segment_N+1.ts
|<------------------------|
| session key exists |
| lastHeartbeat > 120s ago
| ownerPod != me |
| |
[today, WITHOUT Phase 5.2]: |
| |
| _activeTranscodingJobs is empty on Pod B
| OnTranscodeBeginRequest() → null
| No ffmpeg started
| Client receives stale m3u8, then 404s on segment
| Playback stalls indefinitely
| |
[with Phase 5.2]: |
| |
| CAS: set ownerPod = pod-B |
|<------------------------|
| |
| recover from segment_N-1 |
| StartFfMpeg(resumeFrom=N-1)
|<------------------------|
| |
| client resumes from segment N-1 (~4s rewind)
```
### Current State (Without Phase 5.2)
1. Client sends `GET .../segment_100.ts` to pod B (Traefik sticky session cookie
`jellyfin-server-id` routes to pod B because pod A is gone)
2. Pod B calls `GetStreamingState()` → computes same `OutputFilePath` (deterministic hash)
3. Pod B calls `File.Exists(playlistPath)`**true** (file exists on NFS from pod A)
4. Pod B calls `OnTranscodeBeginRequest(playlistPath, HLS)`**null** (no job in pod B's `_activeTranscodingJobs`)
5. `job is null``OnTranscodeEndRequest` not called, no ping, no FFmpeg restart
6. Pod B reads and returns the existing `.m3u8` from disk
7. Client requests segment 100 → pod B tries to serve `segment_100.ts`
- If the file exists and is complete: **success** (but no new segments will be produced)
- If the file does not exist yet (pod A was mid-write): **404**, client stalls
Without Phase 5.2, the transcode stream terminates on pod death. No recovery happens
automatically. The client must re-initiate playback from the beginning or from a
seek point.
---
## Open Questions Before Phase 5.2
| # | Question | Who Answers | When |
|---|---|---|---|
| Q1 | What is the actual `leasetime` configured on the Ugreen DXP4800 NFS server? (default 90s, but UGOS Pro may differ) | Issue 5.1.2 benchmark pod | 5.1.2 |
| Q2 | Does the NFS mount use `nfsvers=3` exclusively, or does UGOS Pro negotiate v4 when requested? | `nfsstat -m` in test pod | 5.1.2 |
| Q3 | What is the minimum HLS segment duration in practice? (affects recovery seek distance) | FFmpeg log inspection | 5.1.1 follow-on |
| Q4 | Does the Jellyfin web client re-supply a stable `playSessionId` on reconnect, or generate a new one? | Client code inspection | 5.2.2a |
| Q5 | Does `StackExchange.Redis` in the fork use connection multiplexing that survives pod address changes? | 5.2.1a implementation | 5.2.1a |
---
## Cross-References
- [jellyfin-ha-plan.md](../home_k3s_cluster/docs/jellyfin-ha/jellyfin-ha-plan.md) — overall HA plan and phase structure
- [jellyfin-ha-phase5-transcoding.md](../home_k3s_cluster/docs/jellyfin-ha/jellyfin-ha-phase5-transcoding.md) — Phase 5 issue list, rollback matrix, Go/No-Go preconditions
- [jellyfin-ha-failover-test.md](../home_k3s_cluster/docs/jellyfin-ha/jellyfin-ha-failover-test.md) — SLO baselines, failover test procedures
- [ci-cd.md](../home_k3s_cluster/docs/ci-cd.md) — Phase 5 CI/CD paths
- `MediaBrowser.MediaEncoding/Transcoding/TranscodeManager.cs``_activeTranscodingJobs`, `StartFfMpeg()`, `KillTranscodingJob()`
- `Jellyfin.Api/Controllers/DynamicHlsController.cs``GetLiveHlsStream()`, segment lookup
- `Emby.Server.Implementations/ScheduledTasks/Tasks/DeleteTranscodeFileTask.cs` — age-only cleanup
- `Emby.Server.Implementations/Session/SessionManager.cs``_activeLiveStreamSessions`
- `kubernetes/apps/media/nfs-pv.yaml``nfsvers=3` confirmed
---
## Bitrate/Segment Tradeoffs
### Why shorter segments trade throughput for faster failover
HLS streaming works by dividing a media stream into a series of short, independently decodable
segments. The segment length is a fundamental trade-off: longer segments reduce per-segment HTTP
overhead and allow FFmpeg to apply more aggressive compression across each chunk, improving overall
bitrate efficiency. Shorter segments, however, mean that when a pod fails mid-transcode, a takeover
pod only needs to rewind to the previous segment boundary — not the start of a much longer one.
With the default 6-second segment length, a client could stall for up to 6 seconds before the
takeover pod produces a new segment for it to consume. With the HA recovery default of 2 seconds
(`RecoverySegmentLengthSeconds = 2`), that stall window is reduced to at most 2 seconds of rewind,
dramatically improving the perceived continuity of playback during a pod failover.
### The rolling segment buffer and disk usage
In HA mode, `RecoverySegmentBufferCount` (default `5`) controls how many segments are retained in
the HLS playlist at any one time. This creates a rolling on-disk buffer of `5 × 2 s = 10 seconds`
of media that a takeover pod can serve immediately while it restarts FFmpeg from the last known
position. Keeping fewer segments wastes less NFS storage but shrinks the window in which a newly
promoted pod can respond to in-flight client requests without waiting for new segments to be
produced. Keeping more segments lengthens the recovery window but increases NFS write pressure and
disk usage proportionally. The valid range (210) was chosen so that the minimum buffer is always
at least 4 seconds (2 × 2 s) and the maximum stays under 20 seconds (10 × 2 s), balancing storage
cost against recovery robustness.
### Tuning guidance and rollback
The two knobs, `RecoverySegmentLengthSeconds` and `RecoverySegmentBufferCount`, can be adjusted in
the Jellyfin server's encoding options without restarting the service; the new values take effect on
the next transcode session that enters HA mode. To reduce disk I/O at the cost of a slightly longer
stall window, increase `RecoverySegmentLengthSeconds` toward its maximum of 6 (matching the
throughput-optimized default). To shrink the NFS footprint at the cost of a narrower recovery
window, lower `RecoverySegmentBufferCount` toward its minimum of 2. To roll back to the
pre-HA-mode behavior entirely, set `RecoverySegmentLengthSeconds = 6` and ensure that no active
session is registered in the `ITranscodeSessionStore` (which disables HA mode detection in
`DynamicHlsController`). All changes are backwards-compatible: in single-pod deployments where the
store is a no-op, these settings have no effect on the FFmpeg command generated.
@@ -0,0 +1,31 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<GenerateAssemblyInfo>false</GenerateAssemblyInfo>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\..\..\SharedVersion.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\MediaBrowser.Common\MediaBrowser.Common.csproj" />
<ProjectReference Include="..\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,585 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jellyfin.Database.Providers.PostgreSQL.Migrations
{
/// <inheritdoc />
public partial class UpgradeToServer12Schema : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// UserId becomes non-nullable below, so the rows that never had an owner have to go first.
migrationBuilder.Sql(@"DELETE FROM ""Permissions"" WHERE ""UserId"" IS NULL;");
migrationBuilder.Sql(@"DELETE FROM ""Preferences"" WHERE ""UserId"" IS NULL;");
migrationBuilder.DropIndex(
name: "IX_UserData_UserId",
table: "UserData");
migrationBuilder.DropIndex(
name: "IX_Preferences_UserId_Kind",
table: "Preferences");
migrationBuilder.DropIndex(
name: "IX_Permissions_UserId_Kind",
table: "Permissions");
migrationBuilder.DropIndex(
name: "IX_PeopleBaseItemMap_PeopleId",
table: "PeopleBaseItemMap");
migrationBuilder.DropIndex(
name: "IX_MediaStreamInfos_StreamIndex",
table: "MediaStreamInfos");
migrationBuilder.DropIndex(
name: "IX_MediaStreamInfos_StreamIndex_StreamType",
table: "MediaStreamInfos");
migrationBuilder.DropIndex(
name: "IX_MediaStreamInfos_StreamIndex_StreamType_Language",
table: "MediaStreamInfos");
migrationBuilder.DropIndex(
name: "IX_MediaStreamInfos_StreamType",
table: "MediaStreamInfos");
migrationBuilder.DropIndex(
name: "IX_Devices_DeviceId",
table: "Devices");
migrationBuilder.DropIndex(
name: "IX_BaseItems_Id_Type_IsFolder_IsVirtualItem",
table: "BaseItems");
migrationBuilder.DropIndex(
name: "IX_BaseItemProviders_ProviderId_ProviderValue_ItemId",
table: "BaseItemProviders");
migrationBuilder.DropIndex(
name: "IX_BaseItemImageInfos_ItemId",
table: "BaseItemImageInfos");
migrationBuilder.DropColumn(
name: "Preference_Preferences_Guid",
table: "Preferences");
migrationBuilder.DropColumn(
name: "Permission_Permissions_Guid",
table: "Permissions");
// ExtraIds and OriginalLanguage are unrelated columns that EF pairs up as a rename because both are
// nullable text; the extra ids are superseded by the OwnerId relation and their content must not survive.
migrationBuilder.DropColumn(
name: "ExtraIds",
table: "BaseItems");
migrationBuilder.AddColumn<string>(
name: "OriginalLanguage",
table: "BaseItems",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "NormalizedUsername",
table: "Users",
type: "character varying(255)",
maxLength: 255,
nullable: false,
defaultValue: string.Empty);
migrationBuilder.AlterColumn<Guid>(
name: "UserId",
table: "Preferences",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
oldClrType: typeof(Guid),
oldType: "uuid",
oldNullable: true);
migrationBuilder.AlterColumn<Guid>(
name: "UserId",
table: "Permissions",
type: "uuid",
nullable: false,
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
oldClrType: typeof(Guid),
oldType: "uuid",
oldNullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsOriginal",
table: "MediaStreamInfos",
type: "boolean",
nullable: false,
defaultValue: false);
// PostgreSQL refuses an implicit text to uuid conversion, so both columns are converted with an explicit
// USING cast. Only the two shapes the cast accepts survive: 32 hexadecimal digits, or the hyphenated
// 8-4-4-4-12 form. A digit count alone is not enough, because misplaced hyphens keep the count and still
// fail the cast, which would abort the whole migration.
migrationBuilder.Sql(
"""
UPDATE "BaseItems" SET "PrimaryVersionId" = NULL
WHERE "PrimaryVersionId" IS NOT NULL
AND ("PrimaryVersionId" !~ '^([0-9a-fA-F]{32}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$'
OR replace("PrimaryVersionId", '-', '') = '00000000000000000000000000000000');
ALTER TABLE "BaseItems"
ALTER COLUMN "PrimaryVersionId" TYPE uuid USING "PrimaryVersionId"::uuid;
""");
// The detached-item placeholder is cleared as well, matching the SQLite chain: leaving it in place would
// make CleanupOrphanedExtras delete an item that has a real owner relation only by coincidence.
migrationBuilder.Sql(
"""
UPDATE "BaseItems" SET "OwnerId" = NULL
WHERE "OwnerId" IS NOT NULL
AND ("OwnerId" !~ '^([0-9a-fA-F]{32}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$'
OR replace("OwnerId", '-', '') IN ('00000000000000000000000000000000', '00000000000000000000000000000001'));
ALTER TABLE "BaseItems"
ALTER COLUMN "OwnerId" TYPE uuid USING "OwnerId"::uuid;
""");
migrationBuilder.CreateTable(
name: "LinkedChildren",
columns: table => new
{
ParentId = table.Column<Guid>(type: "uuid", nullable: false),
SortOrder = table.Column<int>(type: "integer", nullable: false),
ChildId = table.Column<Guid>(type: "uuid", nullable: false),
ChildType = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LinkedChildren", x => new { x.ParentId, x.SortOrder });
table.ForeignKey(
name: "FK_LinkedChildren_BaseItems_ChildId",
column: x => x.ChildId,
principalTable: "BaseItems",
principalColumn: "Id");
table.ForeignKey(
name: "FK_LinkedChildren_BaseItems_ParentId",
column: x => x.ParentId,
principalTable: "BaseItems",
principalColumn: "Id");
});
migrationBuilder.UpdateData(
table: "BaseItems",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000001"),
columns: new[] { "Name", "OwnerId", "PrimaryVersionId" },
values: new object[] { "This is a placeholder item for UserData that has been detached from its original item", null, null });
migrationBuilder.CreateIndex(
name: "IX_UserData_UserId_IsFavorite_ItemId",
table: "UserData",
columns: new[] { "UserId", "IsFavorite", "ItemId" });
migrationBuilder.CreateIndex(
name: "IX_UserData_UserId_ItemId_LastPlayedDate",
table: "UserData",
columns: new[] { "UserId", "ItemId", "LastPlayedDate" });
migrationBuilder.CreateIndex(
name: "IX_UserData_UserId_Played_ItemId",
table: "UserData",
columns: new[] { "UserId", "Played", "ItemId" });
migrationBuilder.CreateIndex(
name: "IX_Preferences_UserId_Kind",
table: "Preferences",
columns: new[] { "UserId", "Kind" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Permissions_UserId_Kind",
table: "Permissions",
columns: new[] { "UserId", "Kind" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_PeopleBaseItemMap_PeopleId_ItemId",
table: "PeopleBaseItemMap",
columns: new[] { "PeopleId", "ItemId" });
migrationBuilder.CreateIndex(
name: "IX_MediaStreamInfos_StreamType_ItemId_Language_IsExternal",
table: "MediaStreamInfos",
columns: new[] { "StreamType", "ItemId", "Language", "IsExternal" });
migrationBuilder.CreateIndex(
name: "IX_BaseItems_ExtraType_OwnerId",
table: "BaseItems",
columns: new[] { "ExtraType", "OwnerId" });
migrationBuilder.CreateIndex(
name: "IX_BaseItems_Name",
table: "BaseItems",
column: "Name");
migrationBuilder.CreateIndex(
name: "IX_BaseItems_OwnerId",
table: "BaseItems",
column: "OwnerId");
migrationBuilder.CreateIndex(
name: "IX_BaseItems_PrimaryVersionId",
table: "BaseItems",
column: "PrimaryVersionId",
filter: "\"PrimaryVersionId\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_BaseItems_SeasonId",
table: "BaseItems",
column: "SeasonId");
migrationBuilder.CreateIndex(
name: "IX_BaseItems_SeriesId",
table: "BaseItems",
column: "SeriesId");
migrationBuilder.CreateIndex(
name: "IX_BaseItems_SeriesName",
table: "BaseItems",
column: "SeriesName");
migrationBuilder.CreateIndex(
name: "IX_BaseItems_TopParentId_IsFolder_IsVirtualItem_DateCreated",
table: "BaseItems",
columns: new[] { "TopParentId", "IsFolder", "IsVirtualItem", "DateCreated" });
migrationBuilder.CreateIndex(
name: "IX_BaseItems_TopParentId_MediaType_IsVirtualItem_DateCreated",
table: "BaseItems",
columns: new[] { "TopParentId", "MediaType", "IsVirtualItem", "DateCreated" });
migrationBuilder.CreateIndex(
name: "IX_BaseItems_TopParentId_Type_IsVirtualItem",
table: "BaseItems",
columns: new[] { "TopParentId", "Type", "IsVirtualItem" },
filter: "\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)");
migrationBuilder.CreateIndex(
name: "IX_BaseItems_TopParentId_Type_IsVirtualItem_DateCreated",
table: "BaseItems",
columns: new[] { "TopParentId", "Type", "IsVirtualItem", "DateCreated" });
migrationBuilder.CreateIndex(
name: "IX_BaseItems_Type_CleanName",
table: "BaseItems",
columns: new[] { "Type", "CleanName" });
migrationBuilder.CreateIndex(
name: "IX_BaseItems_Type_SeriesPresentationUniqueKey_ParentIndexNumbe~",
table: "BaseItems",
columns: new[] { "Type", "SeriesPresentationUniqueKey", "ParentIndexNumber", "IndexNumber" });
migrationBuilder.CreateIndex(
name: "IX_BaseItems_Type_TopParentId_SortName",
table: "BaseItems",
columns: new[] { "Type", "TopParentId", "SortName" });
migrationBuilder.CreateIndex(
name: "IX_BaseItemProviders_ProviderId_ItemId_ProviderValue",
table: "BaseItemProviders",
columns: new[] { "ProviderId", "ItemId", "ProviderValue" });
migrationBuilder.CreateIndex(
name: "IX_BaseItemImageInfos_ItemId_ImageType",
table: "BaseItemImageInfos",
columns: new[] { "ItemId", "ImageType" });
migrationBuilder.CreateIndex(
name: "IX_LinkedChildren_ChildId_ChildType",
table: "LinkedChildren",
columns: new[] { "ChildId", "ChildType" });
migrationBuilder.CreateIndex(
name: "IX_LinkedChildren_ParentId_ChildType",
table: "LinkedChildren",
columns: new[] { "ParentId", "ChildType" });
// The baseline seeds the placeholder, but the repoint below and the foreign key both depend on it, so it is
// recreated if anything removed it rather than letting the migration abort.
migrationBuilder.Sql(
"""
INSERT INTO "BaseItems" ("Id", "Type", "Name", "IsMovie", "IsLocked", "IsSeries", "IsRepeat",
"IsInMixedFolder", "IsFolder", "IsVirtualItem")
VALUES ('00000000-0000-0000-0000-000000000001', 'PLACEHOLDER',
'This is a placeholder item for UserData that has been detached from its original item',
false, false, false, false, false, false, false)
ON CONFLICT ("Id") DO NOTHING;
""");
// Owners that no longer exist are repointed at the detached-item placeholder so the new self referencing
// foreign key holds. The CleanupOrphanedExtras routine removes the items afterwards.
migrationBuilder.Sql(
"""
UPDATE "BaseItems"
SET "OwnerId" = '00000000-0000-0000-0000-000000000001'
WHERE "OwnerId" IS NOT NULL
AND "OwnerId" NOT IN (SELECT "Id" FROM "BaseItems");
""");
migrationBuilder.AddForeignKey(
name: "FK_BaseItems_BaseItems_OwnerId",
table: "BaseItems",
column: "OwnerId",
principalTable: "BaseItems",
principalColumn: "Id");
// Those defaults only existed to fill the rows that predate the columns. The model declares none, so they
// are dropped again to leave exactly the schema a model generated migration would create.
migrationBuilder.Sql(
"""
ALTER TABLE "Users" ALTER COLUMN "NormalizedUsername" DROP DEFAULT;
ALTER TABLE "Permissions" ALTER COLUMN "UserId" DROP DEFAULT;
ALTER TABLE "Preferences" ALTER COLUMN "UserId" DROP DEFAULT;
ALTER TABLE "MediaStreamInfos" ALTER COLUMN "IsOriginal" DROP DEFAULT;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_BaseItems_BaseItems_OwnerId",
table: "BaseItems");
migrationBuilder.DropTable(
name: "LinkedChildren");
migrationBuilder.DropIndex(
name: "IX_UserData_UserId_IsFavorite_ItemId",
table: "UserData");
migrationBuilder.DropIndex(
name: "IX_UserData_UserId_ItemId_LastPlayedDate",
table: "UserData");
migrationBuilder.DropIndex(
name: "IX_UserData_UserId_Played_ItemId",
table: "UserData");
migrationBuilder.DropIndex(
name: "IX_Preferences_UserId_Kind",
table: "Preferences");
migrationBuilder.DropIndex(
name: "IX_Permissions_UserId_Kind",
table: "Permissions");
migrationBuilder.DropIndex(
name: "IX_PeopleBaseItemMap_PeopleId_ItemId",
table: "PeopleBaseItemMap");
migrationBuilder.DropIndex(
name: "IX_MediaStreamInfos_StreamType_ItemId_Language_IsExternal",
table: "MediaStreamInfos");
migrationBuilder.DropIndex(
name: "IX_BaseItems_ExtraType_OwnerId",
table: "BaseItems");
migrationBuilder.DropIndex(
name: "IX_BaseItems_Name",
table: "BaseItems");
migrationBuilder.DropIndex(
name: "IX_BaseItems_OwnerId",
table: "BaseItems");
migrationBuilder.DropIndex(
name: "IX_BaseItems_PrimaryVersionId",
table: "BaseItems");
migrationBuilder.DropIndex(
name: "IX_BaseItems_SeasonId",
table: "BaseItems");
migrationBuilder.DropIndex(
name: "IX_BaseItems_SeriesId",
table: "BaseItems");
migrationBuilder.DropIndex(
name: "IX_BaseItems_SeriesName",
table: "BaseItems");
migrationBuilder.DropIndex(
name: "IX_BaseItems_TopParentId_IsFolder_IsVirtualItem_DateCreated",
table: "BaseItems");
migrationBuilder.DropIndex(
name: "IX_BaseItems_TopParentId_MediaType_IsVirtualItem_DateCreated",
table: "BaseItems");
migrationBuilder.DropIndex(
name: "IX_BaseItems_TopParentId_Type_IsVirtualItem",
table: "BaseItems");
migrationBuilder.DropIndex(
name: "IX_BaseItems_TopParentId_Type_IsVirtualItem_DateCreated",
table: "BaseItems");
migrationBuilder.DropIndex(
name: "IX_BaseItems_Type_CleanName",
table: "BaseItems");
migrationBuilder.DropIndex(
name: "IX_BaseItems_Type_SeriesPresentationUniqueKey_ParentIndexNumbe~",
table: "BaseItems");
migrationBuilder.DropIndex(
name: "IX_BaseItems_Type_TopParentId_SortName",
table: "BaseItems");
migrationBuilder.DropIndex(
name: "IX_BaseItemProviders_ProviderId_ItemId_ProviderValue",
table: "BaseItemProviders");
migrationBuilder.DropIndex(
name: "IX_BaseItemImageInfos_ItemId_ImageType",
table: "BaseItemImageInfos");
migrationBuilder.DropColumn(
name: "NormalizedUsername",
table: "Users");
// Reverting the column takes the code migration that populates it with it.
migrationBuilder.Sql(
"""
DELETE FROM "__EFMigrationsHistory"
WHERE "MigrationId" = '20260522092304_UpdateNormalizedUsername';
""");
migrationBuilder.DropColumn(
name: "IsOriginal",
table: "MediaStreamInfos");
migrationBuilder.DropColumn(
name: "OriginalLanguage",
table: "BaseItems");
migrationBuilder.AddColumn<string>(
name: "ExtraIds",
table: "BaseItems",
type: "text",
nullable: true);
migrationBuilder.AlterColumn<Guid>(
name: "UserId",
table: "Preferences",
type: "uuid",
nullable: true,
oldClrType: typeof(Guid),
oldType: "uuid");
migrationBuilder.AddColumn<Guid>(
name: "Preference_Preferences_Guid",
table: "Preferences",
type: "uuid",
nullable: true);
migrationBuilder.AlterColumn<Guid>(
name: "UserId",
table: "Permissions",
type: "uuid",
nullable: true,
oldClrType: typeof(Guid),
oldType: "uuid");
migrationBuilder.AddColumn<Guid>(
name: "Permission_Permissions_Guid",
table: "Permissions",
type: "uuid",
nullable: true);
migrationBuilder.Sql(
"""
ALTER TABLE "BaseItems"
ALTER COLUMN "PrimaryVersionId" TYPE text USING "PrimaryVersionId"::text;
ALTER TABLE "BaseItems"
ALTER COLUMN "OwnerId" TYPE text USING "OwnerId"::text;
""");
migrationBuilder.UpdateData(
table: "BaseItems",
keyColumn: "Id",
keyValue: new Guid("00000000-0000-0000-0000-000000000001"),
columns: new[] { "Name", "OwnerId", "PrimaryVersionId" },
values: new object[] { "This is a placeholder item for UserData that has been detacted from its original item", null, null });
migrationBuilder.CreateIndex(
name: "IX_UserData_UserId",
table: "UserData",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_Preferences_UserId_Kind",
table: "Preferences",
columns: new[] { "UserId", "Kind" },
unique: true,
filter: "\"UserId\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_Permissions_UserId_Kind",
table: "Permissions",
columns: new[] { "UserId", "Kind" },
unique: true,
filter: "\"UserId\" IS NOT NULL");
migrationBuilder.CreateIndex(
name: "IX_PeopleBaseItemMap_PeopleId",
table: "PeopleBaseItemMap",
column: "PeopleId");
migrationBuilder.CreateIndex(
name: "IX_MediaStreamInfos_StreamIndex",
table: "MediaStreamInfos",
column: "StreamIndex");
migrationBuilder.CreateIndex(
name: "IX_MediaStreamInfos_StreamIndex_StreamType",
table: "MediaStreamInfos",
columns: new[] { "StreamIndex", "StreamType" });
migrationBuilder.CreateIndex(
name: "IX_MediaStreamInfos_StreamIndex_StreamType_Language",
table: "MediaStreamInfos",
columns: new[] { "StreamIndex", "StreamType", "Language" });
migrationBuilder.CreateIndex(
name: "IX_MediaStreamInfos_StreamType",
table: "MediaStreamInfos",
column: "StreamType");
migrationBuilder.CreateIndex(
name: "IX_Devices_DeviceId",
table: "Devices",
column: "DeviceId");
migrationBuilder.CreateIndex(
name: "IX_BaseItems_Id_Type_IsFolder_IsVirtualItem",
table: "BaseItems",
columns: new[] { "Id", "Type", "IsFolder", "IsVirtualItem" });
migrationBuilder.CreateIndex(
name: "IX_BaseItemProviders_ProviderId_ProviderValue_ItemId",
table: "BaseItemProviders",
columns: new[] { "ProviderId", "ProviderValue", "ItemId" });
migrationBuilder.CreateIndex(
name: "IX_BaseItemImageInfos_ItemId",
table: "BaseItemImageInfos",
column: "ItemId");
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jellyfin.Database.Providers.PostgreSQL.Migrations
{
/// <inheritdoc />
public partial class AddUniqueNormalizedUsernameIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_Users_NormalizedUsername",
table: "Users",
column: "NormalizedUsername",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Users_NormalizedUsername",
table: "Users");
}
}
}
@@ -0,0 +1,112 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.DbConfiguration;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace Jellyfin.Database.Providers.PostgreSQL;
/// <summary>
/// Configures Jellyfin to use a PostgreSQL database.
/// </summary>
[JellyfinDatabaseProviderKey("Jellyfin-PostgreSQL")]
public sealed class PostgreSqlDatabaseProvider : IJellyfinDatabaseProvider
{
// Sentinel returned by MigrationBackupFast to signal that no file backup was
// created (PostgreSQL backups are handled externally by jellyfin-pg-backup CronJob).
private const string NoAutomatedBackupKey = "postgresql-no-automated-backup";
private readonly NpgsqlDataSource _dataSource;
/// <summary>
/// Initializes a new instance of the <see cref="PostgreSqlDatabaseProvider"/> class.
/// </summary>
/// <param name="dataSource">The <see cref="NpgsqlDataSource"/> used for PostgreSQL connections.</param>
public PostgreSqlDatabaseProvider(NpgsqlDataSource dataSource)
{
_dataSource = dataSource;
}
/// <inheritdoc/>
public IDbContextFactory<JellyfinDbContext>? DbContextFactory { get; set; }
/// <inheritdoc/>
public void Initialise(DbContextOptionsBuilder options, DatabaseConfigurationOptions databaseConfiguration)
{
options.UseNpgsql(
_dataSource,
o => o.MigrationsAssembly(GetType().Assembly.FullName));
}
/// <inheritdoc/>
public void OnModelCreating(ModelBuilder modelBuilder)
{
}
/// <inheritdoc/>
public void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
}
/// <inheritdoc/>
public async Task RunScheduledOptimisation(CancellationToken cancellationToken)
{
var context = await DbContextFactory!.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
await context.Database.ExecuteSqlRawAsync("ANALYZE", cancellationToken).ConfigureAwait(false);
}
}
/// <inheritdoc/>
public Task RunShutdownTask(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task<string> MigrationBackupFast(CancellationToken cancellationToken)
{
// PostgreSQL pre-migration backups are handled externally by the
// jellyfin-pg-backup CronJob. Return a sentinel so callers know no
// file backup was created and the migration can proceed safely.
return Task.FromResult(NoAutomatedBackupKey);
}
/// <inheritdoc/>
public Task RestoreBackupFast(string key, CancellationToken cancellationToken)
{
// No automated backup was taken; nothing to restore.
return Task.CompletedTask;
}
/// <inheritdoc/>
public Task DeleteBackup(string key)
{
// No automated backup was taken; nothing to delete.
return Task.CompletedTask;
}
/// <inheritdoc/>
public async Task PurgeDatabase(JellyfinDbContext dbContext, IEnumerable<string>? tableNames)
{
ArgumentNullException.ThrowIfNull(tableNames);
await dbContext.Database.ExecuteSqlRawAsync("SET session_replication_role = 'replica'").ConfigureAwait(false);
try
{
foreach (var tableName in tableNames)
{
var truncateSql = "TRUNCATE TABLE \"" + tableName + "\" CASCADE";
await dbContext.Database.ExecuteSqlRawAsync(truncateSql).ConfigureAwait(false);
}
}
finally
{
await dbContext.Database.ExecuteSqlRawAsync("SET session_replication_role = 'origin'").ConfigureAwait(false);
}
}
}
@@ -0,0 +1,42 @@
using System;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Locking;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Microsoft.Extensions.Logging.Abstractions;
using Npgsql;
namespace Jellyfin.Database.Providers.PostgreSQL;
/// <summary>
/// The design time factory for <see cref="JellyfinDbContext"/> using PostgreSQL.
/// This is only used for the creation of migrations and not during runtime.
/// </summary>
internal sealed class PostgreSqlDesignTimeJellyfinDbFactory : IDesignTimeDbContextFactory<JellyfinDbContext>
{
/// <inheritdoc/>
public JellyfinDbContext CreateDbContext(string[] args)
{
var connectionString =
Environment.GetEnvironmentVariable("POSTGRES_CONNECTION_STRING")
?? "Host=localhost;Database=jellyfin;Username=postgres;Password=postgres";
var optionsBuilder = new DbContextOptionsBuilder<JellyfinDbContext>();
// Build a NpgsqlDataSource for EF Core configuration. The DI-owned singleton data source
// is not available in design-time context; this instance is intentionally not disposed here
// because EF Core holds a reference to it for the lifetime of the returned context.
// As a design-time-only factory (used only for dotnet-ef CLI operations), the process
// exits after the migration is applied, which releases all resources.
#pragma warning disable CA2000 // Dispose objects before losing scope
var dataSource = new NpgsqlDataSourceBuilder(connectionString).Build();
#pragma warning restore CA2000 // Dispose objects before losing scope
optionsBuilder.UseNpgsql(dataSource, o => o.MigrationsAssembly(GetType().Assembly));
return new JellyfinDbContext(
optionsBuilder.Options,
NullLogger<JellyfinDbContext>.Instance,
new PostgreSqlDatabaseProvider(dataSource),
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
}
@@ -0,0 +1,105 @@
using System;
using System.Globalization;
using System.IO;
using Jellyfin.Api.Controllers;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Model.Configuration;
using Xunit;
namespace Jellyfin.Api.Tests.Controllers
{
/// <summary>
/// Tests for the HA behaviour of <see cref="DynamicHlsController"/>: the session record it
/// registers in <see cref="ITranscodeSessionStore"/> and the ffmpeg segmenting options it
/// picks once a session is resumed on another pod.
/// </summary>
public class DynamicHlsHaModeTests
{
/// <summary>
/// The registered session has to name the files it owns, otherwise
/// <c>DeleteTranscodeFileTask</c> cannot recognise and protect them.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public void CreateSessionRecord_PopulatesPathsFromPlaylistPath()
{
const string PlaylistPath = "/transcodes/9e1c6f.m3u8";
var session = DynamicHlsController.CreateSessionRecord("play-1", "media-1", PlaylistPath, TimeSpan.FromSeconds(30));
Assert.Equal(PlaylistPath, session.ManifestPath);
Assert.Equal("/transcodes/9e1c6f", session.SegmentPathPrefix);
Assert.Equal("play-1", session.PlaySessionId);
Assert.Equal("media-1", session.MediaSourceId);
Assert.NotEmpty(session.OwnerPod);
Assert.True(session.LeaseExpiresUtc > DateTime.UtcNow);
}
/// <summary>
/// Every segment ffmpeg writes for the playlist has to start with the recorded prefix.
/// Segment paths are built as <c>&lt;playlist without extension&gt;&lt;index&gt;&lt;extension&gt;</c>.
/// </summary>
[Theory]
[InlineData(".ts")]
[InlineData(".mp4")]
[Trait("Category", "UnitTest")]
public void GetSegmentPathPrefix_CoversEverySegmentPath(string segmentExtension)
{
const string PlaylistPath = "/transcodes/9e1c6f.m3u8";
var prefix = TranscodeSession.GetSegmentPathPrefix(PlaylistPath);
for (var index = 0; index < 5; index++)
{
var segmentPath = Path.Combine(
Path.GetDirectoryName(PlaylistPath)!,
Path.GetFileNameWithoutExtension(PlaylistPath) + index.ToString(CultureInfo.InvariantCulture) + segmentExtension);
Assert.StartsWith(prefix, segmentPath, StringComparison.Ordinal);
}
}
/// <summary>
/// HA mode shortens segments and bounds the rolling buffer; the configured values are
/// user-editable so they are clamped.
/// </summary>
[Theory]
[InlineData(2, 2)]
[InlineData(0, 1)]
[InlineData(60, 6)]
[Trait("Category", "UnitTest")]
public void GetEffectiveSegmentLength_InHaMode_ClampsConfiguredRecoveryLength(int configured, int expected)
{
var options = new EncodingOptions { RecoverySegmentLengthSeconds = configured };
Assert.Equal(expected, DynamicHlsController.GetEffectiveSegmentLength(true, 6, options));
}
/// <summary>
/// Outside HA mode the requested segment length is used unchanged and the playlist is unbounded.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public void GetSegmentingOptions_OutsideHaMode_UsesRequestedValues()
{
var options = new EncodingOptions { RecoverySegmentLengthSeconds = 2, RecoverySegmentBufferCount = 5 };
Assert.Equal(6, DynamicHlsController.GetEffectiveSegmentLength(false, 6, options));
Assert.Equal(0, DynamicHlsController.GetHlsListSize(false, options));
}
/// <summary>
/// HA mode keeps a bounded rolling buffer of segments for a takeover pod to serve.
/// </summary>
[Theory]
[InlineData(5, 5)]
[InlineData(0, 2)]
[InlineData(100, 10)]
[Trait("Category", "UnitTest")]
public void GetHlsListSize_InHaMode_ClampsConfiguredBufferCount(int configured, int expected)
{
var options = new EncodingOptions { RecoverySegmentBufferCount = configured };
Assert.Equal(expected, DynamicHlsController.GetHlsListSize(true, options));
}
}
}
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Npgsql" />
<PackageReference Include="Testcontainers.PostgreSql" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Jellyfin.Database\Jellyfin.Database.Providers.PostgreSQL\Jellyfin.Database.Providers.PostgreSQL.csproj" />
<ProjectReference Include="..\..\src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,129 @@
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("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="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask 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="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask 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(
() => InsertBatchAsync(taskIndex, insertsPerTask),
TestContext.Current.CancellationToken));
}
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", TestContext.Current.CancellationToken);
Assert.Equal(parallelTasks * insertsPerTask, count);
}
}
private async Task InsertBatchAsync(int taskIndex, int insertsPerTask)
{
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);
}
}
private JellyfinDbContext CreateContext()
{
var optionsBuilder = new DbContextOptionsBuilder<JellyfinDbContext>();
_provider!.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" });
return new JellyfinDbContext(
optionsBuilder.Options,
NullLogger<JellyfinDbContext>.Instance,
_provider,
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
}
@@ -0,0 +1,98 @@
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("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="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask 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="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask 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(TestContext.Current.CancellationToken);
var pendingMigrations = await context.Database.GetPendingMigrationsAsync(TestContext.Current.CancellationToken);
Assert.Empty(pendingMigrations);
}
}
/// <summary>
/// Verifies that no pending model changes exist for the PostgreSQL provider,
/// acting as a CI gate that fails when model changes are added without a corresponding migration.
/// </summary>
[Fact]
public void CheckForUnappliedMigrations_PostgreSql()
{
// Use a dummy connection string; HasPendingModelChanges() is a purely in-memory check
// that compares the current compiled model with the migration snapshots — no real DB needed.
const string dummyConnectionString = "Host=localhost;Database=jellyfin;Username=postgres;Password=postgres";
using var dataSource = new NpgsqlDataSourceBuilder(dummyConnectionString).Build();
using var context = CreateContext(dataSource);
Assert.False(
context.Database.HasPendingModelChanges(),
"There are unapplied changes to the EFCore model for PostgreSQL. Please create a Migration.");
}
private static JellyfinDbContext CreateContext(NpgsqlDataSource dataSource)
{
var optionsBuilder = new DbContextOptionsBuilder<JellyfinDbContext>();
var provider = new PostgreSqlDatabaseProvider(dataSource);
provider.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" });
return new JellyfinDbContext(
optionsBuilder.Options,
NullLogger<JellyfinDbContext>.Instance,
provider,
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
}
@@ -0,0 +1,335 @@
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("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="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask 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="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask 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(TestContext.Current.CancellationToken);
var userId = user.Id;
// Read
var read = await ctx.Users.FindAsync([userId], TestContext.Current.CancellationToken);
Assert.NotNull(read);
Assert.Equal("testuser", read.Username);
// Update
read.Username = "updateduser";
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
var updated = await ctx.Users.FindAsync([userId], TestContext.Current.CancellationToken);
Assert.Equal("updateduser", updated!.Username);
// Delete
ctx.Users.Remove(updated);
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
var deleted = await ctx.Users.FindAsync([userId], TestContext.Current.CancellationToken);
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(TestContext.Current.CancellationToken);
var logId = log.Id;
// Read
var read = await ctx.ActivityLogs.FindAsync([logId], TestContext.Current.CancellationToken);
Assert.NotNull(read);
Assert.Equal("Test activity", read.Name);
// Update
read.Overview = "Updated overview";
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
var updated = await ctx.ActivityLogs.FindAsync([logId], TestContext.Current.CancellationToken);
Assert.Equal("Updated overview", updated!.Overview);
// Delete
ctx.ActivityLogs.Remove(updated);
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
var deleted = await ctx.ActivityLogs.FindAsync([logId], TestContext.Current.CancellationToken);
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(TestContext.Current.CancellationToken);
var prefsId = prefs.Id;
// Read
var read = await ctx.DisplayPreferences.FindAsync([prefsId], TestContext.Current.CancellationToken);
Assert.NotNull(read);
Assert.Equal("TestClient", read.Client);
// Update
read.ShowSidebar = true;
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
var updated = await ctx.DisplayPreferences.FindAsync([prefsId], TestContext.Current.CancellationToken);
Assert.True(updated!.ShowSidebar);
// Delete
ctx.DisplayPreferences.Remove(updated);
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
var deleted = await ctx.DisplayPreferences.FindAsync([prefsId], TestContext.Current.CancellationToken);
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(TestContext.Current.CancellationToken);
// 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(TestContext.Current.CancellationToken);
// Read
var readItem = await ctx.BaseItems
.Include(i => i.Chapters)
.Include(i => i.MediaStreams)
.FirstOrDefaultAsync(i => i.Id.Equals(itemId), TestContext.Current.CancellationToken);
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(TestContext.Current.CancellationToken);
var updated = await ctx.BaseItems.FindAsync([itemId], TestContext.Current.CancellationToken);
Assert.Equal("Updated Movie", updated!.Name);
// Delete (cascades to Chapter and MediaStreamInfo)
ctx.BaseItems.Remove(updated);
await ctx.SaveChangesAsync(TestContext.Current.CancellationToken);
var deleted = await ctx.BaseItems.FindAsync([itemId], TestContext.Current.CancellationToken);
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(TestContext.Current.CancellationToken);
Assert.True(await ctx.ActivityLogs.AnyAsync(TestContext.Current.CancellationToken));
// 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(TestContext.Current.CancellationToken);
Assert.Equal("origin", role);
}
// Verify table is empty via a fresh context
var freshCtx = CreateContext();
await using (freshCtx)
{
Assert.False(await freshCtx.ActivityLogs.AnyAsync(TestContext.Current.CancellationToken));
}
}
private JellyfinDbContext CreateContext()
{
var optionsBuilder = new DbContextOptionsBuilder<JellyfinDbContext>();
_provider!.Initialise(optionsBuilder, new DatabaseConfigurationOptions { DatabaseType = "PostgreSQL" });
return new JellyfinDbContext(
optionsBuilder.Options,
NullLogger<JellyfinDbContext>.Instance,
_provider,
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
/// <summary>
/// A minimal <see cref="IDbContextFactory{TContext}"/> wrapper that returns a pre-existing context.
/// </summary>
private sealed class TestDbContextFactory : IDbContextFactory<JellyfinDbContext>
{
private readonly JellyfinDbContext _context;
/// <summary>
/// Initializes a new instance of the <see cref="TestDbContextFactory"/> class.
/// </summary>
/// <param name="context">The context to return from <see cref="CreateDbContext"/>.</param>
public TestDbContextFactory(JellyfinDbContext context)
{
_context = context;
}
/// <summary>
/// Returns the pre-existing <see cref="JellyfinDbContext"/> instance.
/// </summary>
/// <returns>The pre-existing <see cref="JellyfinDbContext"/> instance.</returns>
public JellyfinDbContext CreateDbContext() => _context;
/// <summary>
/// Returns the pre-existing <see cref="JellyfinDbContext"/> instance as a completed task.
/// </summary>
/// <param name="cancellationToken">A cancellation token (unused).</param>
/// <returns>A <see cref="Task{TResult}"/> containing the pre-existing <see cref="JellyfinDbContext"/> instance.</returns>
public Task<JellyfinDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default)
=> Task.FromResult(_context);
}
}
@@ -0,0 +1,120 @@
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 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<bool> RenewLeaseAsync(string playSessionId, string ownerPod, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (!_sessions.TryGetValue(playSessionId, out var session)
|| !string.Equals(session.OwnerPod, ownerPod, StringComparison.Ordinal))
{
return Task.FromResult(false);
}
session.LeaseExpiresUtc = DateTime.UtcNow.Add(DefaultLeaseDuration);
return Task.FromResult(true);
}
}
/// <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);
}
}
private static TranscodeSession Clone(TranscodeSession source)
=> new TranscodeSession
{
PlaySessionId = source.PlaySessionId,
OwnerPod = source.OwnerPod,
LeaseExpiresUtc = source.LeaseExpiresUtc,
ManifestPath = source.ManifestPath,
SegmentPathPrefix = source.SegmentPathPrefix,
MediaSourceId = source.MediaSourceId,
LastCompletedSegmentIndex = source.LastCompletedSegmentIndex,
LastDurablePlaybackOffset = source.LastDurablePlaybackOffset,
};
}
@@ -0,0 +1,186 @@
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 <see cref="ITranscodeSessionStore"/> contract — lease expiry, double-claim
/// prevention, ownership-checked renewal and stale-session cleanup — against
/// <see cref="InMemoryTranscodeSessionStore"/>, the reference implementation. The Redis-backed
/// implementation is covered by <c>RedisTranscodeSessionStoreTests</c> against a real Redis.
/// </summary>
public class InMemoryTranscodeSessionStoreTests
{
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, TestContext.Current.CancellationToken);
var result = await store.TryGetAsync("session-expired", TestContext.Current.CancellationToken);
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, TestContext.Current.CancellationToken);
var result = await store.TryGetAsync("session-live", TestContext.Current.CancellationToken);
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, TestContext.Current.CancellationToken);
var firstAttempt = await store.TryTakeoverAsync("session-valid", "pod-b", TestContext.Current.CancellationToken);
var secondAttempt = await store.TryTakeoverAsync("session-valid", "pod-c", TestContext.Current.CancellationToken);
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, TestContext.Current.CancellationToken);
// First pod wins; its takeover renews the lease atomically.
var firstTakeover = await store.TryTakeoverAsync("session-stale", "pod-b", TestContext.Current.CancellationToken);
// Second pod is too late pod-b already holds a fresh lease.
var secondTakeover = await store.TryTakeoverAsync("session-stale", "pod-c", TestContext.Current.CancellationToken);
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, TestContext.Current.CancellationToken);
Assert.True(await store.RenewLeaseAsync("session-renew", "pod-a", TestContext.Current.CancellationToken));
var renewed = await store.TryGetAsync("session-renew", TestContext.Current.CancellationToken);
Assert.NotNull(renewed);
Assert.True(
renewed.LeaseExpiresUtc > originalExpiry,
"Renewed lease expiry should be later than the original expiry.");
}
/// <summary>
/// A renewal from a pod that no longer owns the lease must fail and must not revert ownership.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task RenewLeaseAsync_ByNonOwner_ReturnsFalse()
{
var store = new InMemoryTranscodeSessionStore();
var session = CreateSession("session-renew-other", "pod-a", DateTime.UtcNow.AddMilliseconds(-1));
await store.SetAsync(session, TestContext.Current.CancellationToken);
Assert.True(await store.TryTakeoverAsync("session-renew-other", "pod-b", TestContext.Current.CancellationToken));
Assert.False(await store.RenewLeaseAsync("session-renew-other", "pod-a", TestContext.Current.CancellationToken));
var current = await store.TryGetAsync("session-renew-other", TestContext.Current.CancellationToken);
Assert.NotNull(current);
Assert.Equal("pod-b", current.OwnerPod);
}
/// <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, TestContext.Current.CancellationToken);
var ex = await Record.ExceptionAsync(() => store.DeleteAsync("session-delete", TestContext.Current.CancellationToken));
Assert.Null(ex);
var result = await store.TryGetAsync("session-delete", TestContext.Current.CancellationToken);
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", TestContext.Current.CancellationToken));
Assert.Null(ex);
}
}
@@ -0,0 +1,52 @@
using System.IO;
using System.Xml.Serialization;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
using Xunit;
namespace Jellyfin.Model.Tests.Configuration;
public class EncodingOptionsTests
{
[Theory]
[InlineData("<EncoderPreset></EncoderPreset>")]
[InlineData("<EncoderPreset />")]
[InlineData("<EncoderPreset> </EncoderPreset>")]
[InlineData("<EncoderPreset>notapreset</EncoderPreset>")]
[InlineData("<EncoderPreset>42</EncoderPreset>")]
[InlineData("<EncoderPreset>-1</EncoderPreset>")]
public void Deserialize_UnreadableEncoderPreset_FallsBackToDefault(string encoderPresetElement)
{
var options = Deserialize($"<EncodingOptions>{encoderPresetElement}<H264Crf>21</H264Crf></EncodingOptions>");
Assert.Equal(EncoderPreset.auto, options.EncoderPreset);
// The rest of the file has to survive: a throwing preset used to discard every other encoding setting.
Assert.Equal(21, options.H264Crf);
}
[Fact]
public void Deserialize_KnownEncoderPreset_IsKept()
{
var options = Deserialize("<EncodingOptions><EncoderPreset>veryfast</EncoderPreset></EncodingOptions>");
Assert.Equal(EncoderPreset.veryfast, options.EncoderPreset);
}
[Fact]
public void Serialize_WritesTheEncoderPresetElement()
{
var serializer = new XmlSerializer(typeof(EncodingOptions));
using var writer = new StringWriter();
serializer.Serialize(writer, new EncodingOptions { EncoderPreset = EncoderPreset.slow });
Assert.Contains("<EncoderPreset>slow</EncoderPreset>", writer.ToString(), System.StringComparison.Ordinal);
}
private static EncodingOptions Deserialize(string xml)
{
var serializer = new XmlSerializer(typeof(EncodingOptions));
using var reader = new StringReader(xml);
return (EncodingOptions)serializer.Deserialize(reader)!;
}
}
@@ -18,6 +18,8 @@
<PackageReference Include="AutoFixture.AutoMoq" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Moq" />
<PackageReference Include="StackExchange.Redis" />
<PackageReference Include="Testcontainers.Redis" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio">
<PrivateAssets>all</PrivateAssets>
@@ -0,0 +1,285 @@
using System;
using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using Emby.Server.Implementations.MediaEncoding;
using MediaBrowser.Controller.MediaEncoding;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
using Testcontainers.Redis;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.MediaEncoding;
/// <summary>
/// Integration tests for <see cref="RedisTranscodeSessionStore"/> and its Lua scripts against a
/// real Redis container.
/// </summary>
[Trait("Category", "RequiresDocker")]
public sealed class RedisTranscodeSessionStoreTests : IAsyncLifetime
{
private readonly RedisContainer _container;
private IConnectionMultiplexer? _redis;
/// <summary>
/// Initializes a new instance of the <see cref="RedisTranscodeSessionStoreTests"/> class.
/// </summary>
public RedisTranscodeSessionStoreTests()
{
_container = new RedisBuilder("redis:7-alpine").Build();
}
/// <summary>
/// Starts the Redis container before any tests in the class run.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask InitializeAsync()
{
await _container.StartAsync().ConfigureAwait(false);
_redis = await ConnectionMultiplexer.ConnectAsync(_container.GetConnectionString()).ConfigureAwait(false);
}
/// <summary>
/// Stops and removes the Redis container after all tests in the class have run.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
if (_redis is not null)
{
await _redis.DisposeAsync().ConfigureAwait(false);
}
await _container.DisposeAsync().ConfigureAwait(false);
}
/// <summary>
/// A stored session round-trips through Redis with the paths cleanup relies on intact.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task SetAsync_RoundTripsSession()
{
var store = CreateStore();
var id = NewSessionId();
var session = TranscodeSession.CreateForPlaylist(id, "media-1", "pod-a", "/transcodes/abc.m3u8", TimeSpan.FromSeconds(30));
await store.SetAsync(session, TestContext.Current.CancellationToken);
var stored = await store.TryGetAsync(id, TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("pod-a", stored.OwnerPod);
Assert.Equal("media-1", stored.MediaSourceId);
Assert.Equal("/transcodes/abc.m3u8", stored.ManifestPath);
Assert.Equal("/transcodes/abc", stored.SegmentPathPrefix);
Assert.True(stored.LeaseExpiresUtc > DateTime.UtcNow);
}
/// <summary>
/// The owning pod can extend its own lease.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task RenewLeaseAsync_ByOwner_ExtendsLease()
{
var store = CreateStore(leaseSeconds: 4);
var id = NewSessionId();
await store.SetAsync(NewSession(id, "pod-a", TimeSpan.FromSeconds(4)), TestContext.Current.CancellationToken);
var before = await store.TryGetAsync(id, TestContext.Current.CancellationToken);
await Task.Delay(TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken);
Assert.True(await store.RenewLeaseAsync(id, "pod-a", TestContext.Current.CancellationToken));
var after = await store.TryGetAsync(id, TestContext.Current.CancellationToken);
Assert.NotNull(before);
Assert.NotNull(after);
Assert.True(after.LeaseExpiresUtc > before.LeaseExpiresUtc);
Assert.Equal("pod-a", after.OwnerPod);
}
/// <summary>
/// A pod that does not own the lease cannot renew it, and its attempt leaves the owner alone.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task RenewLeaseAsync_ByNonOwner_ReturnsFalse()
{
var store = CreateStore();
var id = NewSessionId();
await store.SetAsync(NewSession(id, "pod-a", TimeSpan.FromSeconds(30)), TestContext.Current.CancellationToken);
Assert.False(await store.RenewLeaseAsync(id, "pod-b", TestContext.Current.CancellationToken));
var stored = await store.TryGetAsync(id, TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("pod-a", stored.OwnerPod);
}
/// <summary>
/// Renewal of a session that is gone fails instead of recreating it.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task RenewLeaseAsync_AfterDelete_ReturnsFalse()
{
var store = CreateStore();
var id = NewSessionId();
await store.SetAsync(NewSession(id, "pod-a", TimeSpan.FromSeconds(30)), TestContext.Current.CancellationToken);
await store.DeleteAsync(id, TestContext.Current.CancellationToken);
Assert.False(await store.RenewLeaseAsync(id, "pod-a", TestContext.Current.CancellationToken));
Assert.Null(await store.TryGetAsync(id, TestContext.Current.CancellationToken));
}
/// <summary>
/// A valid lease blocks takeover.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task TryTakeoverAsync_WhileLeaseValid_ReturnsFalse()
{
var store = CreateStore();
var id = NewSessionId();
await store.SetAsync(NewSession(id, "pod-a", TimeSpan.FromSeconds(30)), TestContext.Current.CancellationToken);
Assert.False(await store.TryTakeoverAsync(id, "pod-b", TestContext.Current.CancellationToken));
var stored = await store.TryGetAsync(id, TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("pod-a", stored.OwnerPod);
}
/// <summary>
/// Once the lease expires the session record is still retained, so another pod can claim it.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task TryTakeoverAsync_AfterLeaseExpires_TransfersOwnership()
{
var store = CreateStore(leaseSeconds: 1);
var id = NewSessionId();
await store.SetAsync(NewSession(id, "pod-a", TimeSpan.FromSeconds(1)), TestContext.Current.CancellationToken);
await Task.Delay(TimeSpan.FromMilliseconds(1200), TestContext.Current.CancellationToken);
Assert.Null(await store.TryGetAsync(id, TestContext.Current.CancellationToken));
Assert.True(await store.TryTakeoverAsync(id, "pod-b", TestContext.Current.CancellationToken));
var stored = await store.TryGetAsync(id, TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("pod-b", stored.OwnerPod);
Assert.Equal("/transcodes/" + id + ".m3u8", stored.ManifestPath);
}
/// <summary>
/// Only one of several pods racing for an expired lease wins it.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task TryTakeoverAsync_ConcurrentClaims_OnlyOneWins()
{
var store = CreateStore(leaseSeconds: 1);
var id = NewSessionId();
await store.SetAsync(NewSession(id, "pod-a", TimeSpan.FromSeconds(1)), TestContext.Current.CancellationToken);
await Task.Delay(TimeSpan.FromMilliseconds(1200), TestContext.Current.CancellationToken);
var claims = Enumerable.Range(0, 10)
.Select(i => store.TryTakeoverAsync(id, "pod-" + i.ToString(CultureInfo.InvariantCulture), TestContext.Current.CancellationToken))
.ToList();
var results = await Task.WhenAll(claims);
Assert.Equal(1, results.Count(won => won));
}
/// <summary>
/// The race behind the non-atomic renewal: after another pod wins the takeover, a renewal from
/// the previous owner must fail rather than restore its own ownership and lease.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task RenewLeaseAsync_AfterLosingTakeover_DoesNotRevertOwner()
{
var store = CreateStore(leaseSeconds: 1);
var id = NewSessionId();
await store.SetAsync(NewSession(id, "pod-a", TimeSpan.FromSeconds(1)), TestContext.Current.CancellationToken);
await Task.Delay(TimeSpan.FromMilliseconds(1200), TestContext.Current.CancellationToken);
Assert.True(await store.TryTakeoverAsync(id, "pod-b", TestContext.Current.CancellationToken));
Assert.False(await store.RenewLeaseAsync(id, "pod-a", TestContext.Current.CancellationToken));
var stored = await store.TryGetAsync(id, TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("pod-b", stored.OwnerPod);
}
/// <summary>
/// Renewal and takeover racing at the moment the lease expires always leave exactly one owner:
/// the claiming pod when the takeover wins, the original pod when the renewal got in first.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task RenewLeaseAsync_RacingTakeover_LeavesSingleConsistentOwner()
{
var store = CreateStore(leaseSeconds: 1);
for (var i = 0; i < 5; i++)
{
var id = NewSessionId();
await store.SetAsync(NewSession(id, "pod-a", TimeSpan.FromSeconds(1)), TestContext.Current.CancellationToken);
// Both calls are issued at the expiry boundary so their order is genuinely undefined.
await Task.Delay(TimeSpan.FromMilliseconds(1000), TestContext.Current.CancellationToken);
var renewal = store.RenewLeaseAsync(id, "pod-a", TestContext.Current.CancellationToken);
var takeover = store.TryTakeoverAsync(id, "pod-b", TestContext.Current.CancellationToken);
var renewed = await renewal;
var tookOver = await takeover;
var stored = await store.TryGetAsync(id, TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.False(renewed && tookOver, "A renewal and a takeover must not both succeed for the same lease.");
Assert.Equal(tookOver ? "pod-b" : "pod-a", stored.OwnerPod);
}
}
/// <summary>
/// Cleanup reads the active sessions, so a session whose lease has lapsed must not be reported
/// as active even while its record is retained for takeover.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task GetActiveSessionsAsync_ExcludesExpiredLeases()
{
var store = CreateStore(leaseSeconds: 1);
var expiredId = NewSessionId();
await store.SetAsync(NewSession(expiredId, "pod-a", TimeSpan.FromSeconds(1)), TestContext.Current.CancellationToken);
await Task.Delay(TimeSpan.FromMilliseconds(1200), TestContext.Current.CancellationToken);
var liveId = NewSessionId();
await store.SetAsync(NewSession(liveId, "pod-a", TimeSpan.FromSeconds(30)), TestContext.Current.CancellationToken);
var active = (await store.GetActiveSessionsAsync(TestContext.Current.CancellationToken)).ToList();
Assert.Contains(active, s => string.Equals(s.PlaySessionId, liveId, StringComparison.Ordinal));
Assert.DoesNotContain(active, s => string.Equals(s.PlaySessionId, expiredId, StringComparison.Ordinal));
}
private static string NewSessionId() => "play-" + Guid.NewGuid().ToString("N");
private static TranscodeSession NewSession(string id, string pod, TimeSpan leaseDuration)
=> TranscodeSession.CreateForPlaylist(id, "media-" + id, pod, "/transcodes/" + id + ".m3u8", leaseDuration);
private RedisTranscodeSessionStore CreateStore(int leaseSeconds = 30, int retentionSeconds = 300)
=> new RedisTranscodeSessionStore(
_redis!,
Options.Create(new TranscodeStoreOptions
{
LeaseDurationSeconds = leaseSeconds,
SessionRetentionSeconds = retentionSeconds
}),
NullLogger<RedisTranscodeSessionStore>.Instance);
}
@@ -0,0 +1,103 @@
using System;
using System.IO;
using System.Threading.Tasks;
using Emby.Server.Implementations.MediaEncoding;
using Jellyfin.Server;
using Jellyfin.Server.Extensions;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.MediaEncoding;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using StackExchange.Redis;
using Testcontainers.Redis;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.MediaEncoding;
/// <summary>
/// Drives the whole configuration path a deployment uses: a bare <c>Jellyfin__TranscodeStore__*</c>
/// environment variable, the server's own configuration builder, the store registration, and a
/// session round-trip against a real Valkey server.
/// </summary>
[Trait("Category", "RequiresDocker")]
public sealed class TranscodeStoreWiringTests : IAsyncLifetime
{
private const string RedisConnectionStringVariable = "Jellyfin__TranscodeStore__RedisConnectionString";
private readonly RedisContainer _container;
private string _configDirectory = string.Empty;
/// <summary>
/// Initializes a new instance of the <see cref="TranscodeStoreWiringTests"/> class.
/// </summary>
public TranscodeStoreWiringTests()
{
_container = new RedisBuilder("valkey/valkey:8-alpine").Build();
}
/// <summary>
/// Starts Valkey and lays out the configuration directory the server reads at startup.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask InitializeAsync()
{
await _container.StartAsync().ConfigureAwait(false);
_configDirectory = Directory.CreateTempSubdirectory("jellyfin-wiring-test").FullName;
await File.WriteAllTextAsync(Path.Combine(_configDirectory, "logging.default.json"), "{}").ConfigureAwait(false);
}
/// <summary>
/// Removes the environment variable, configuration directory and container.
/// </summary>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
Environment.SetEnvironmentVariable(RedisConnectionStringVariable, null);
if (_configDirectory.Length > 0)
{
Directory.Delete(_configDirectory, true);
}
await _container.DisposeAsync().ConfigureAwait(false);
}
/// <summary>
/// The variable form deployments set selects the Redis store and that store really talks to Valkey.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
[Fact]
public async Task ManifestStyleEnvironmentVariable_Should_Reach_Valkey()
{
Environment.SetEnvironmentVariable(
RedisConnectionStringVariable,
_container.GetConnectionString() + ",abortConnect=false");
var appPaths = new Mock<IApplicationPaths>();
appPaths.Setup(paths => paths.ConfigurationDirectoryPath).Returns(_configDirectory);
var configuration = Program.CreateAppConfiguration(new StartupOptions(), appPaths.Object);
var services = new ServiceCollection();
services.AddLogging();
services.AddTranscodeSessionStore(configuration, NullLogger.Instance);
await using var provider = services.BuildServiceProvider();
var store = provider.GetRequiredService<ITranscodeSessionStore>();
Assert.IsType<RedisTranscodeSessionStore>(store);
var playSessionId = Guid.NewGuid().ToString("N");
await store.SetAsync(
TranscodeSession.CreateForPlaylist(playSessionId, "media-1", "pod-a", "/transcodes/abc.m3u8", TimeSpan.FromSeconds(30)),
TestContext.Current.CancellationToken);
var stored = await store.TryGetAsync(playSessionId, TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal("pod-a", stored.OwnerPod);
var redis = provider.GetRequiredService<IConnectionMultiplexer>();
Assert.True(await redis.GetDatabase().KeyExistsAsync("jellyfin:transcode:" + playSessionId));
}
}
@@ -0,0 +1,477 @@
using System;
using System.Collections.Generic;
using System.IO;
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 the lease-aware cleanup behaviour of <c>DeleteTranscodeFileTask</c>: files belonging
/// to a session whose lease is still live must survive a cleanup pass, and a store failure must
/// abort the pass rather than risk deleting files in use.
/// </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, TestContext.Current.CancellationToken);
// The cleanup task should query the store before deleting.
var liveSession = await store.TryGetAsync("cleanup-session-1", TestContext.Current.CancellationToken);
// 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, TestContext.Current.CancellationToken);
var liveSession = await store.TryGetAsync("cleanup-session-2", TestContext.Current.CancellationToken);
// 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", TestContext.Current.CancellationToken);
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, TestContext.Current.CancellationToken);
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, TestContext.Current.CancellationToken);
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>
/// The files of a live session, named exactly as the HLS pipeline writes them and recorded by
/// the same production factory the controller uses, survive a cleanup pass while an unrelated
/// stale file from a finished session is deleted.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task ExecuteAsync_WithLiveSessionRegisteredByProduction_KeepsItsFilesAndDeletesTheRest()
{
const string TranscodePath = "/transcode";
var playlistPath = Path.Combine(TranscodePath, "9e1c6f.m3u8");
var session = TranscodeSession.CreateForPlaylist("play-1", "media-1", "pod-a", playlistPath, TimeSpan.FromMinutes(5));
var sessionFiles = new[]
{
playlistPath,
TranscodeSession.GetSegmentPathPrefix(playlistPath) + "0.ts",
TranscodeSession.GetSegmentPathPrefix(playlistPath) + "1.ts",
TranscodeSession.GetSegmentPathPrefix(playlistPath) + "-1.mp4",
};
var orphanedFile = Path.Combine(TranscodePath, "abandoned.m3u8");
var store = new CleanupTestSessionStore();
await store.SetAsync(session, TestContext.Current.CancellationToken);
var deletedFiles = new List<string>();
var fileSystemMock = new Mock<IFileSystem>();
fileSystemMock
.Setup(fs => fs.GetFiles(TranscodePath, true))
.Returns(sessionFiles.Append(orphanedFile).Select(path => new FileSystemMetadata { FullName = path, IsDirectory = false }));
fileSystemMock
.Setup(fs => fs.GetLastWriteTimeUtc(It.IsAny<FileSystemMetadata>()))
.Returns(DateTime.UtcNow.AddDays(-2));
fileSystemMock
.Setup(fs => fs.DeleteFile(It.IsAny<string>()))
.Callback<string>(deletedFiles.Add);
fileSystemMock
.Setup(fs => fs.GetDirectories(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(Enumerable.Empty<FileSystemMetadata>());
var localizationMock = new Mock<MediaBrowser.Model.Globalization.ILocalizationManager>();
localizationMock
.Setup(l => l.GetLocalizedString(It.IsAny<string>()))
.Returns<string>(key => key);
var task = new Emby.Server.Implementations.ScheduledTasks.Tasks.DeleteTranscodeFileTask(
new Mock<Microsoft.Extensions.Logging.ILogger<Emby.Server.Implementations.ScheduledTasks.Tasks.DeleteTranscodeFileTask>>().Object,
fileSystemMock.Object,
CreateConfigMock(TranscodePath).Object,
localizationMock.Object,
store);
await task.ExecuteAsync(new Progress<double>(), CancellationToken.None);
Assert.Equal(new[] { orphanedFile }, 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<bool> RenewLeaseAsync(string playSessionId, string ownerPod, CancellationToken cancellationToken = default)
{
lock (_lock)
{
if (!_sessions.TryGetValue(playSessionId, out var s)
|| !string.Equals(s.OwnerPod, ownerPod, StringComparison.Ordinal))
{
return Task.FromResult(false);
}
s.LeaseExpiresUtc = DateTime.UtcNow.Add(LeaseDuration);
return Task.FromResult(true);
}
}
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);
}
}
private static TranscodeSession Clone(TranscodeSession source)
=> new TranscodeSession
{
PlaySessionId = source.PlaySessionId,
OwnerPod = source.OwnerPod,
LeaseExpiresUtc = source.LeaseExpiresUtc,
ManifestPath = source.ManifestPath,
SegmentPathPrefix = source.SegmentPathPrefix,
MediaSourceId = source.MediaSourceId,
LastCompletedSegmentIndex = source.LastCompletedSegmentIndex,
LastDurablePlaybackOffset = source.LastDurablePlaybackOffset,
};
}
}
@@ -0,0 +1,256 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Emby.Server.Implementations.ScheduledTasks;
using MediaBrowser.Controller.ScheduledTasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using StackExchange.Redis;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.ScheduledTasks;
/// <summary>
/// Tests for scan-leader lease behavior. The acquire/renew/takeover state machine is exercised
/// through an in-memory reference implementation that mirrors the Redis Lua contract (no real Redis
/// required), while the fail-safe and success paths of <see cref="RedisScanLeaderLease"/> are
/// exercised against a mocked <see cref="IConnectionMultiplexer"/>.
/// </summary>
public class ScanLeaderLeaseTests
{
/// <summary>
/// Verifies that the first instance to call the lease becomes the leader.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task Acquire_WhenUnheld_ReturnsTrue()
{
var store = new FakeLeaderStore();
var clock = new TestClock(DateTime.UtcNow);
var podA = new ReferenceScanLeaderLease(store, "pod-a", clock, TimeSpan.FromSeconds(60));
Assert.True(await podA.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
Assert.Equal("pod-a", store.Owner);
}
/// <summary>
/// Verifies that the current leader renewing its own lease succeeds.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task Renew_BySameInstance_ReturnsTrue()
{
var store = new FakeLeaderStore();
var clock = new TestClock(DateTime.UtcNow);
var podA = new ReferenceScanLeaderLease(store, "pod-a", clock, TimeSpan.FromSeconds(60));
Assert.True(await podA.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
clock.Advance(TimeSpan.FromSeconds(10));
Assert.True(await podA.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
Assert.Equal("pod-a", store.Owner);
}
/// <summary>
/// Verifies that a second instance cannot acquire the lease while the leader's lease is still valid.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task Acquire_BySecondInstance_WhileLeaseValid_ReturnsFalse()
{
var store = new FakeLeaderStore();
var clock = new TestClock(DateTime.UtcNow);
var podA = new ReferenceScanLeaderLease(store, "pod-a", clock, TimeSpan.FromSeconds(60));
var podB = new ReferenceScanLeaderLease(store, "pod-b", clock, TimeSpan.FromSeconds(60));
Assert.True(await podA.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
clock.Advance(TimeSpan.FromSeconds(30));
Assert.False(await podB.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
Assert.Equal("pod-a", store.Owner);
}
/// <summary>
/// Verifies that a second instance takes over the lease once the previous leader's lease has expired.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task Acquire_BySecondInstance_AfterLeaseExpires_ReturnsTrue()
{
var store = new FakeLeaderStore();
var clock = new TestClock(DateTime.UtcNow);
var podA = new ReferenceScanLeaderLease(store, "pod-a", clock, TimeSpan.FromSeconds(60));
var podB = new ReferenceScanLeaderLease(store, "pod-b", clock, TimeSpan.FromSeconds(60));
Assert.True(await podA.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
// Advance past pod-a's lease expiry without pod-a renewing.
clock.Advance(TimeSpan.FromSeconds(61));
Assert.True(await podB.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
Assert.Equal("pod-b", store.Owner);
}
/// <summary>
/// Verifies that <see cref="NullScanLeaderLease"/> always reports the caller as the leader.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task NullScanLeaderLease_AlwaysReturnsTrue()
{
var lease = new NullScanLeaderLease();
Assert.True(await lease.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
Assert.True(await lease.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
}
/// <summary>
/// Verifies that <see cref="RedisScanLeaderLease"/> returns <c>true</c> (fail-safe) when the Redis
/// evaluation throws, so that scheduled scans keep running when Redis is unreachable.
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task RedisScanLeaderLease_WhenRedisThrows_ReturnsTrue()
{
var dbMock = new Mock<IDatabase>();
dbMock
.Setup(d => d.ScriptEvaluateAsync(
It.IsAny<string>(),
It.IsAny<RedisKey[]>(),
It.IsAny<RedisValue[]>(),
It.IsAny<CommandFlags>()))
.ThrowsAsync(new InvalidOperationException("Redis unavailable"));
var lease = new RedisScanLeaderLease(
CreateMultiplexer(dbMock.Object),
Options.Create(new ScanLeaderOptions { Enabled = true, LeaseDurationSeconds = 60 }),
new Mock<ILogger<RedisScanLeaderLease>>().Object);
Assert.True(await lease.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
}
/// <summary>
/// Verifies that <see cref="RedisScanLeaderLease"/> reports leadership when the Redis script
/// returns 1 (lease acquired or renewed).
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task RedisScanLeaderLease_WhenScriptReturnsOne_ReturnsTrue()
{
var dbMock = new Mock<IDatabase>();
dbMock
.Setup(d => d.ScriptEvaluateAsync(
It.IsAny<string>(),
It.IsAny<RedisKey[]>(),
It.IsAny<RedisValue[]>(),
It.IsAny<CommandFlags>()))
.ReturnsAsync(RedisResult.Create((RedisValue)1L));
var lease = new RedisScanLeaderLease(
CreateMultiplexer(dbMock.Object),
Options.Create(new ScanLeaderOptions { Enabled = true, LeaseDurationSeconds = 60 }),
new Mock<ILogger<RedisScanLeaderLease>>().Object);
Assert.True(await lease.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
}
/// <summary>
/// Verifies that <see cref="RedisScanLeaderLease"/> reports non-leadership when the Redis script
/// returns 0 (another instance holds a live lease).
/// </summary>
[Fact]
[Trait("Category", "UnitTest")]
public async Task RedisScanLeaderLease_WhenScriptReturnsZero_ReturnsFalse()
{
var dbMock = new Mock<IDatabase>();
dbMock
.Setup(d => d.ScriptEvaluateAsync(
It.IsAny<string>(),
It.IsAny<RedisKey[]>(),
It.IsAny<RedisValue[]>(),
It.IsAny<CommandFlags>()))
.ReturnsAsync(RedisResult.Create((RedisValue)0L));
var lease = new RedisScanLeaderLease(
CreateMultiplexer(dbMock.Object),
Options.Create(new ScanLeaderOptions { Enabled = true, LeaseDurationSeconds = 60 }),
new Mock<ILogger<RedisScanLeaderLease>>().Object);
Assert.False(await lease.TryAcquireOrRenewAsync(TestContext.Current.CancellationToken));
}
private static IConnectionMultiplexer CreateMultiplexer(IDatabase database)
{
var muxMock = new Mock<IConnectionMultiplexer>();
muxMock
.Setup(m => m.GetDatabase(It.IsAny<int>(), It.IsAny<object>()))
.Returns(database);
return muxMock.Object;
}
private sealed class FakeLeaderStore
{
public string? Owner { get; set; }
public DateTime ExpiresUtc { get; set; }
}
private sealed class TestClock
{
private DateTime _now;
public TestClock(DateTime now)
{
_now = now;
}
public DateTime UtcNow => _now;
public void Advance(TimeSpan by) => _now += by;
}
/// <summary>
/// In-memory reference lease that mirrors the Redis Lua acquire-or-renew contract: a key that is
/// unset or expired is claimed by the caller; a key already owned by the caller is renewed; a key
/// owned by a different, still-valid holder is refused.
/// </summary>
private sealed class ReferenceScanLeaderLease : IScanLeaderLease
{
private readonly FakeLeaderStore _store;
private readonly string _podId;
private readonly TestClock _clock;
private readonly TimeSpan _ttl;
public ReferenceScanLeaderLease(FakeLeaderStore store, string podId, TestClock clock, TimeSpan ttl)
{
_store = store;
_podId = podId;
_clock = clock;
_ttl = ttl;
}
public Task<bool> TryAcquireOrRenewAsync(CancellationToken cancellationToken = default)
{
var now = _clock.UtcNow;
var currentOwner = _store.Owner is not null && now < _store.ExpiresUtc ? _store.Owner : null;
if (currentOwner is null)
{
_store.Owner = _podId;
_store.ExpiresUtc = now + _ttl;
return Task.FromResult(true);
}
if (string.Equals(currentOwner, _podId, StringComparison.Ordinal))
{
_store.ExpiresUtc = now + _ttl;
return Task.FromResult(true);
}
return Task.FromResult(false);
}
}
}
@@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using Emby.Server.Implementations.ScheduledTasks.Tasks;
using MediaBrowser.Controller.ScheduledTasks;
using MediaBrowser.Model.Tasks;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.ScheduledTasks;
public class ScanLeaderOptionsTests
{
/// <summary>
/// A gated key that matches no registered task silently stops gating anything, so the default
/// set is pinned to the task keys that actually exist in the build.
/// </summary>
[Fact]
public void DefaultGatedTaskKeys_Should_MatchRegisteredScheduledTasks()
{
var registeredKeys = DiscoverScheduledTaskKeys();
Assert.NotEmpty(registeredKeys);
Assert.Empty(new ScanLeaderOptions().GatedTaskKeys.Except(registeredKeys, StringComparer.Ordinal));
}
private static HashSet<string> DiscoverScheduledTaskKeys()
{
var keys = new HashSet<string>(StringComparer.Ordinal);
var assemblies = new[]
{
typeof(DeleteTranscodeFileTask).Assembly,
typeof(Jellyfin.MediaEncoding.Hls.ScheduledTasks.KeyframeExtractionScheduledTask).Assembly
};
foreach (var type in assemblies.SelectMany(a => a.GetTypes()))
{
if (type.IsAbstract || type.IsInterface || !typeof(IScheduledTask).IsAssignableFrom(type))
{
continue;
}
// Task keys are constant expressions, so an uninitialised instance is enough to read
// them without standing up each task's dependency graph.
var task = (IScheduledTask)RuntimeHelpers.GetUninitializedObject(type);
keys.Add(task.Key);
}
return keys;
}
}

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