Compare commits

...

327 Commits

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

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

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

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

* chore: trim verbose comment in Dockerfile.runtime

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

---------

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

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

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

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

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

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

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

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

Closes #28

* fix: SA1516 — blank line in InMemoryTranscodeSessionStore between helpers

---------

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

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

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

* ci: trigger CI run for PR #27 review

---------

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

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

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

* Fix Redis exception propagation in GetActiveSessionsAsync for safe abort behavior

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

* fix: use KeysAsync to resolve CA1849 analyzer violation

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

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

Line 161 in RedisTranscodeSessionStore.GetActiveSessionsAsync.

---------

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

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

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

---------

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

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

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

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

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

---------

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

---------

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

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

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

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

---------

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

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

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

* Remove accidentally committed build artifacts from PostgreSQL provider

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

---------

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

* feat: implement PostgreSqlDatabaseProvider all methods + DI registration

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

---------

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

* Issue 1.1: Scaffold PostgreSQL provider project

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

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: ZoltyMat <177592743+ZoltyMat@users.noreply.github.com>
2026-03-31 22:55:45 -04:00
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
Bond-009 ace30afcf8 Merge pull request #15016 from jellyfin/renovate/ci-deps
Update github/codeql-action action to v4.30.9
2025-10-19 11:14:52 +02:00
rimasx fc056b6273 Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/et/
2025-10-19 05:33:11 +00:00
rimasx ac5efb4775 Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/et/
2025-10-18 11:54:27 +00:00
renovate[bot] fefd676adc Update github/codeql-action action to v4.30.9 2025-10-17 15:56:06 +00:00
Bond-009 59c17a663c Merge pull request #15006 from jellyfin/renovate/dotnet-monorepo
Update dependency dotnet-ef to v9.0.10
2025-10-15 17:39:47 +02:00
Bond-009 641551e164 Merge pull request #15007 from jellyfin/renovate/microsoft
Update Microsoft to 9.0.10
2025-10-15 17:39:32 +02:00
renovate[bot] bd543d7ac3 Update Microsoft to 9.0.10 2025-10-15 00:34:53 +00:00
renovate[bot] 545e412259 Update dependency dotnet-ef to v9.0.10 2025-10-15 00:34:44 +00:00
Cody Robibero 7dff92bb82 Use TryAdd instead of Add (#14997) 2025-10-13 22:18:37 +02:00
Cody Robibero b36aab9399 Validate encoder path (#14996) 2025-10-13 14:16:05 -06:00
Erik W 2c7d2d4719 Handle es-419 in TMDb (#14946) 2025-10-13 13:47:16 -06:00
Tim Eisele 5c519270b8 Remove chapters on file change (#14984) 2025-10-13 12:32:41 -06:00
theguymadmax 55047b1183 Fix exception when saving user data to NFO files (#14993) 2025-10-13 10:09:40 -06:00
theguymadmax 794e1361d7 Fix contributing artist query (#14991) 2025-10-13 09:09:09 -06:00
kreaxv 27c9c9c0ed Translated using Weblate (Mongolian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/mn/
2025-10-13 12:42:46 +00:00
rimasx 68636b2390 Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/et/
2025-10-12 18:15:54 +00:00
Joshua M. Boniface 2e6430c4f4 Merge pull request #14965 from KGT1/mapTmdbcol
add xmbc nfo uniqueid type norminalisation
2025-10-11 17:21:32 -04:00
Joshua M. Boniface c88d792963 Merge pull request #14960 from karm235/13697-fix-lufs-detection
Fix LUFS detection deadlock per issue #13697
2025-10-11 17:20:52 -04:00
Joshua M. Boniface 73dbc9e89f Merge pull request #14978 from theguymadmax/fix-playlistfolder
Prevent PlaylistsFolder deletion during library removal
2025-10-11 17:20:40 -04:00
Joshua M. Boniface cf3edd9875 Merge pull request #14971 from theguymadmax/skip-artist-album-persontype
Skip creating Person entities for Artist and AlbumArtist types
2025-10-11 17:20:31 -04:00
Joshua M. Boniface ef0131ad69 Merge pull request #14976 from JPVenson/bugfix/ItemCounterSorting
apply sort on ItemValue query
2025-10-11 17:20:23 -04:00
rimasx 056c318f04 Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/et/
2025-10-11 11:46:41 +00:00
theguymadmax 49c3443b0c Prevent PlaylistsFolder deletion during library removal 2025-10-10 18:34:37 -04:00
Bond-009 e415718fe7 Merge pull request #14975 from jellyfin/renovate/ci-deps
Update github/codeql-action action to v4.30.8
2025-10-10 22:09:46 +02:00
JPVenson 8abcfb2a80 Fix ordering query 2025-10-10 20:08:59 +00:00
Bond-009 9aadf97958 Merge pull request #14945 from jellyfin/renovate/asynckeyedlock-7.x
Update dependency AsyncKeyedLock to 7.1.7
2025-10-10 18:10:38 +02:00
renovate[bot] 9e57121171 Update github/codeql-action action to v4.30.8 2025-10-10 16:02:22 +00:00
Bond-009 b471811920 Merge pull request #14959 from jellyfin/renovate/github-codeql-action-4.x
Update github/codeql-action action to v4
2025-10-10 18:01:21 +02:00
renovate[bot] 3cb99add76 Update github/codeql-action action to v4 2025-10-10 15:51:16 +00:00
Bond-009 001f1c4377 Merge pull request #14954 from jellyfin/renovate/ci-deps
Update CI dependencies
2025-10-10 17:50:15 +02:00
Bond-009 9ef3706b44 Merge pull request #14969 from theguymadmax/fix-artist-external-id
Fix artist external Url
2025-10-10 17:49:39 +02:00
rimasx 864d6d0b8f Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/et/
2025-10-10 10:58:43 +00:00
faquino a565e4896e Translated using Weblate (Galician)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/gl/
2025-10-10 10:58:43 +00:00
JPVenson ceef9143ad cleanup 2025-10-10 09:18:05 +00:00
JPVenson a7a92509c7 fixes #14952 apply sort on ItemValue query 2025-10-10 09:10:21 +00:00
taham e876e784da Translated using Weblate (Urdu)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ur/
2025-10-10 05:25:11 +00:00
taham 9b7d5edc86 Translated using Weblate (Urdu (Pakistan))
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ur_PK/
2025-10-10 05:25:10 +00:00
JPVenson f01cddf273 Add migration attribute 2025-10-09 19:45:43 +00:00
JPVenson 0d4bd0495b Add migration to remove artist and album artists from database 2025-10-09 19:44:07 +00:00
theguymadmax 6f9c4dea6e Skip creating Person entities for Artist and AlbumArtist types 2025-10-09 11:00:36 -05:00
rimasx 8c51920911 Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/et/
2025-10-09 12:39:38 +00:00
faquino 8f2fd65810 Translated using Weblate (Galician)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/gl/
2025-10-09 12:39:38 +00:00
rimasx 953659980f Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/et/
2025-10-09 10:47:27 +00:00
renovate[bot] 8ab1fecb70 Update CI dependencies 2025-10-09 10:20:29 +00:00
theguymadmax f5d42ee180 Fix artist external Url 2025-10-09 01:14:49 -05:00
KGT1 e28d547006 add test for new uniqueid nfo key normalisation 2025-10-08 15:32:30 +00:00
KGT1 b3b9f74014 also apply provider normalisation on uniqueid type tag 2025-10-08 15:23:50 +00:00
theguymadmax 07d31c6ba5 Improve performance on people query (#14963) 2025-10-08 09:23:20 -06:00
KGT1 a9198e865e map tmdbcol nfo property to TmdbCollection 2025-10-08 14:33:50 +00:00
theguymadmax 79ff0b0b00 Fix collections folder duplication (#14925) 2025-10-08 08:32:00 -06:00
theguymadmax 2b45a984dd Clean up missing image references (#14962) 2025-10-08 08:23:12 -06:00
Milo Ivir 739642b330 Translated using Weblate (Croatian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/hr/
2025-10-07 19:45:38 +00:00
karm235 6097045d71 cleanup 2025-10-07 12:20:08 -05:00
karm235 51e20a14c2 Fix LUFS detection deadlock on albums with verbose output 2025-10-07 12:11:02 -05:00
rimasx eb0d05cf1e Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/et/
2025-10-06 08:38:24 +00:00
Bond-009 d3d5915f31 Truncate password reset file on open for writing (#14948) 2025-10-05 11:24:12 -06:00
Bond-009 288640a5d0 Merge pull request #14940 from jellyfin/renovate/ci-deps
Update actions/stale action to v10.1.0
2025-10-04 21:15:57 +02:00
Cody Robibero ff0a1b999f Handle xx as TMDb no language for backdrops (#14941) 2025-10-04 21:04:35 +02:00
renovate[bot] da0fe7455e Update dependency AsyncKeyedLock to 7.1.7 2025-10-04 14:59:22 +00:00
Thomas Jones bf69f9d8a8 Validate wizard-created libraries immediately instead of only doing it after a library refresh was triggered (#14942)
Co-authored-by: Derpipose <90276123+Derpipose@users.noreply.github.com>
2025-10-04 08:58:51 -06:00
Nyanmisaka badf22fcc2 Limit decoder thread count on AMD AMF to save VRAM (#14943) 2025-10-04 08:04:25 -06:00
renovate[bot] b59e9f90f0 Update actions/stale action to v10.1.0 2025-10-03 22:04:26 +00:00
renovate[bot] 056b92dbd5 Update dependency Microsoft.NET.Test.Sdk to v18 (#14930)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-02 17:34:06 -06:00
renovate[bot] ba80f5e416 Update peter-evans/create-or-update-comment action to v5 (#14933)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-02 17:33:54 -06:00
lostb1t 97ec4c1da2 fix: get total count after grouping (#14931) 2025-10-02 17:33:50 -06:00
renovate[bot] 894ba1a410 Update github/codeql-action action to v3.30.6 (#14932)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-10-02 17:33:37 -06:00
gnattu 0a0aaefad5 Fix mka-style tagging key (#14936) 2025-10-02 17:33:31 -06:00
JPVenson c8b97bf533 Readd wildcard search (#14934) 2025-10-02 17:33:01 -06:00
Bond-009 cfa4e357ea Merge pull request #14923 from jellyfin/renovate/peter-evans-find-comment-4.x
Update peter-evans/find-comment action to v4
2025-10-02 20:50:59 +02:00
theguymadmax 0f42aa892e Fix BoxSet sorting (#14919)
Co-authored-by: Cody Robibero <cody@robibe.ro>
2025-10-01 21:10:31 -06:00
JPVenson cce6bf27e0 Add check for processing recursive data structures (#14897) 2025-10-01 17:26:56 -06:00
theguymadmax d6cebf1e67 Add tag filtering and random sorting to GetSimilarItems (#14918) 2025-10-01 17:26:48 -06:00
theguymadmax c053a6cd78 Fix parental ratings logic (#14909) 2025-10-01 17:26:30 -06:00
renovate[bot] d8c62420bf Update peter-evans/find-comment action to v4 2025-10-01 22:51:24 +00:00
Joshua M. Boniface d483c3efe6 Merge pull request #14887 from JPVenson/bugfix/fixMigrationReferences
Add explicit reference check to migration
2025-09-28 12:44:09 -04:00
Joshua M. Boniface 275c1a3cc1 Merge pull request #14883 from crobibero/code-analysis
Only include custom code analysis for debug builds
2025-09-28 12:34:21 -04:00
Joshua M. Boniface 4942b2c15f Merge pull request #14890 from nielsvanvelzen/destructive-migration
Fix AddProperParentChildRelationBaseItemWithCascade migration deleting all items
2025-09-28 12:26:40 -04:00
Niels van Velzen 3fc71293b4 Fix AddProperParentChildRelationBaseItemWithCascade migration deleting all items 2025-09-28 13:14:10 +02:00
JPVenson 8ea9bece03 Add explicit reference check to migration 2025-09-28 08:46:31 +00:00
Nicolas N baa7f5f0b0 Translated using Weblate (Haitian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ht/
2025-09-28 06:47:40 +00:00
Cody Robibero b9c96f3d2c Revert "Add Jellyfin.CodeAnalysis project to abi diff (#14875)"
This reverts commit 526ec83305.
2025-09-27 16:41:01 -06:00
Cody Robibero 08f9b932ac Only include CodeAnalysis in debug mode 2025-09-27 16:39:40 -06:00
231 changed files with 15167 additions and 1625 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "9.0.9",
"version": "9.0.11",
"commands": [
"dotnet-ef"
]
+8 -6
View File
@@ -11,7 +11,9 @@ on:
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
# Disabled in fork — upstream CodeQL requires specific GitHub org permissions and .NET 10 support
if: false
runs-on: [self-hosted, k3s, linux, amd64]
strategy:
fail-fast: false
@@ -20,18 +22,18 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with:
dotnet-version: '9.0.x'
- name: Initialize CodeQL
uses: github/codeql-action/init@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
with:
languages: ${{ matrix.language }}
queries: +security-extended
- name: Autobuild
uses: github/codeql-action/autobuild@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
uses: github/codeql-action/autobuild@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@3599b3baa15b485a2e49ef411a7a4bb2452e7f93 # v3.30.5
uses: github/codeql-action/analyze@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
+14 -14
View File
@@ -1,6 +1,6 @@
name: ABI Compatibility
on:
pull_request_target:
pull_request:
permissions: {}
@@ -11,13 +11,13 @@ jobs:
permissions: read-all
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.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@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with:
dotnet-version: '9.0.x'
@@ -26,7 +26,7 @@ jobs:
dotnet build Jellyfin.Server -o ./out
- name: Upload Head
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: abi-head
retention-days: 14
@@ -40,14 +40,14 @@ jobs:
permissions: read-all
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.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@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with:
dotnet-version: '9.0.x'
@@ -65,7 +65,7 @@ jobs:
dotnet build Jellyfin.Server -o ./out
- name: Upload Head
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: abi-base
retention-days: 14
@@ -77,7 +77,7 @@ jobs:
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
name: ABI - Difference
if: ${{ github.event_name == 'pull_request_target' }}
if: ${{ github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
needs:
- abi-head
@@ -85,13 +85,13 @@ jobs:
steps:
- name: Download abi-head
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: abi-head
path: abi-head
- name: Download abi-base
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: abi-base
path: abi-base
@@ -105,7 +105,7 @@ jobs:
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 Jellyfin.CodeAnalysis.dll; do
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 )"
if [ "APICompat ran successfully without finding any breaking changes." != "${COMPAT_OUTPUT}" ]; then
printf "\n${file}\n${COMPAT_OUTPUT}\n"
@@ -115,7 +115,7 @@ jobs:
} >> $GITHUB_OUTPUT
- name: Find difference comment
uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}
@@ -123,7 +123,7 @@ jobs:
body-includes: abi-diff-workflow-comment
- name: Reply or edit difference comment (changed)
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
if: ${{ steps.diff.outputs.body != '' }}
with:
issue-number: ${{ github.event.pull_request.number }}
@@ -142,7 +142,7 @@ jobs:
</details>
- name: Reply or edit difference comment (unchanged)
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
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 }}
+18 -18
View File
@@ -5,7 +5,7 @@ on:
- master
tags:
- 'v*'
pull_request_target:
pull_request:
permissions: {}
@@ -16,18 +16,18 @@ jobs:
permissions: read-all
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.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@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with:
dotnet-version: '9.0.x'
- name: Generate openapi.json
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
- name: Upload openapi.json
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: openapi-head
retention-days: 14
@@ -41,7 +41,7 @@ jobs:
permissions: read-all
steps:
- name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -55,13 +55,13 @@ jobs:
ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/$HEAD_REF)
git checkout --progress --force $ANCESTOR_REF
- name: Setup .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with:
dotnet-version: '9.0.x'
- name: Generate openapi.json
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
- name: Upload openapi.json
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with:
name: openapi-base
retention-days: 14
@@ -73,19 +73,19 @@ jobs:
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
name: OpenAPI - Difference
if: ${{ github.event_name == 'pull_request_target' }}
if: ${{ github.event_name == 'pull_request' }}
runs-on: ubuntu-latest
needs:
- openapi-head
- openapi-base
steps:
- name: Download openapi-head
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: openapi-head
path: openapi-head
- name: Download openapi-base
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: openapi-base
path: openapi-base
@@ -120,14 +120,14 @@ jobs:
echo "" >> openapi-changes-reply.md
echo "</details>" >> openapi-changes-reply.md
- name: Find difference comment
uses: peter-evans/find-comment@3eae4d37986fb5a8592848f6a574fdf654e61f9e # v3.1.0
uses: peter-evans/find-comment@b30e6a3c0ed37e7c023ccd3f1db5c6c0b0c23aad # v4.0.0
id: find-comment
with:
issue-number: ${{ github.event.pull_request.number }}
direction: last
body-includes: openapi-diff-workflow-comment
- name: Reply or edit difference comment (changed)
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
if: ${{ steps.read-diff.outputs.ApiChanged == '1' }}
with:
issue-number: ${{ github.event.pull_request.number }}
@@ -135,7 +135,7 @@ jobs:
edit-mode: replace
body-path: openapi-changes-reply.md
- name: Edit difference comment (unchanged)
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0
if: ${{ steps.read-diff.outputs.ApiChanged == '0' && steps.find-comment.outputs.comment-id != '' }}
with:
issue-number: ${{ github.event.pull_request.number }}
@@ -148,7 +148,7 @@ jobs:
publish-unstable:
name: OpenAPI - Publish Unstable Spec
if: ${{ github.event_name != 'pull_request_target' && !startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
if: ${{ github.event_name != 'pull_request' && !startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
runs-on: ubuntu-latest
needs:
- openapi-head
@@ -158,7 +158,7 @@ jobs:
run: |-
echo "JELLYFIN_VERSION=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_ENV
- name: Download openapi-head
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: openapi-head
path: openapi-head
@@ -172,7 +172,7 @@ jobs:
strip_components: 1
target: "/srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
- name: Move openapi.json (unstable) into place
uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2
uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 # v1.2.4
with:
host: "${{ secrets.REPO_HOST }}"
username: "${{ secrets.REPO_USER }}"
@@ -220,7 +220,7 @@ jobs:
run: |-
echo "JELLYFIN_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
- name: Download openapi-head
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0
uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with:
name: openapi-head
path: openapi-head
@@ -234,7 +234,7 @@ jobs:
strip_components: 1
target: "/srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
- name: Move openapi.json (stable) into place
uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2
uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 # v1.2.4
with:
host: "${{ secrets.REPO_HOST }}"
username: "${{ secrets.REPO_USER }}"
+74 -17
View File
@@ -13,29 +13,32 @@ env:
jobs:
run-tests:
strategy:
matrix:
os: ["ubuntu-latest", "macos-latest", "windows-latest"]
fail-fast: false
runs-on: "${{ matrix.os }}"
runs-on: [self-hosted, k3s, linux, amd64]
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0
with:
dotnet-version: ${{ env.SDK_VERSION }}
# Install .NET — use GITHUB_ENV (not GITHUB_PATH) to set PATH because
# ARC self-hosted runner pods don't pick up GITHUB_PATH between steps.
- name: Install .NET SDK
run: |
DOTNET_INSTALL_DIR="$HOME/.dotnet"
mkdir -p "$DOTNET_INSTALL_DIR"
curl -fsSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --channel 10.0 --install-dir "$DOTNET_INSTALL_DIR"
echo "DOTNET_ROOT=$DOTNET_INSTALL_DIR" >> "$GITHUB_ENV"
echo "PATH=$DOTNET_INSTALL_DIR:$PATH" >> "$GITHUB_ENV"
- name: Run DotNet CLI Tests
run: >
dotnet test Jellyfin.sln
--configuration Release
--collect:"XPlat Code Coverage"
--settings tests/coverletArgs.runsettings
--verbosity minimal
run: |
export PATH="$HOME/.dotnet:$PATH"
dotnet test Jellyfin.sln \
--configuration Release \
--collect:"XPlat Code Coverage" \
--settings tests/coverletArgs.runsettings \
--verbosity minimal \
--filter "Category!=RequiresDocker&FullyQualifiedName!~Integration"
- name: Merge code coverage results
uses: danielpalme/ReportGenerator-GitHub-Action@1978db745da4a573ca4baa2d0f67175df51a148c # v5.4.16
uses: danielpalme/ReportGenerator-GitHub-Action@ee0ae774f6d3afedcbd1683c1ab21b83670bdf8e # v5.5.1
with:
reports: "**/coverage.cobertura.xml"
targetdir: "merged/"
@@ -43,3 +46,57 @@ jobs:
# TODO - which action / tool to use to publish code coverage results?
# - name: Publish code coverage results
# Phase 5 transcode coverage gate — runs in parallel with run-tests.
# Explicitly targets the three test assemblies most affected by Phase 5 HLS
# session-sharing and PostgreSQL media-encoding changes so failures surface
# with a dedicated check status independent of the full test matrix.
run-phase5-tests:
runs-on: [self-hosted, k3s, linux, amd64]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install .NET SDK
run: |
DOTNET_INSTALL_DIR="$HOME/.dotnet"
mkdir -p "$DOTNET_INSTALL_DIR"
curl -fsSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --channel 10.0 --install-dir "$DOTNET_INSTALL_DIR"
echo "DOTNET_ROOT=$DOTNET_INSTALL_DIR" >> "$GITHUB_ENV"
echo "PATH=$DOTNET_INSTALL_DIR:$PATH" >> "$GITHUB_ENV"
- name: Run Phase 5 Transcode Tests (API)
run: |
export PATH="$HOME/.dotnet:$PATH"
dotnet test tests/Jellyfin.Api.Tests/Jellyfin.Api.Tests.csproj \
--configuration Release \
--collect:"XPlat Code Coverage" \
--settings tests/coverletArgs.runsettings \
--verbosity minimal \
--filter "Category!=RequiresDocker"
- name: Run Phase 5 Transcode Tests (HLS)
run: |
export PATH="$HOME/.dotnet:$PATH"
dotnet test tests/Jellyfin.MediaEncoding.Hls.Tests/Jellyfin.MediaEncoding.Hls.Tests.csproj \
--configuration Release \
--collect:"XPlat Code Coverage" \
--settings tests/coverletArgs.runsettings \
--verbosity minimal \
--filter "Category!=RequiresDocker"
- name: Run Phase 5 Transcode Tests (Server.Implementations)
run: |
export PATH="$HOME/.dotnet:$PATH"
dotnet test tests/Jellyfin.Server.Implementations.Tests/Jellyfin.Server.Implementations.Tests.csproj \
--configuration Release \
--collect:"XPlat Code Coverage" \
--settings tests/coverletArgs.runsettings \
--verbosity minimal \
--filter "Category!=RequiresDocker"
- name: Merge Phase 5 code coverage results
uses: danielpalme/ReportGenerator-GitHub-Action@2a7030e9775aab6c78e80cb66843051acdacee3e # v5.5.2
with:
reports: "**/coverage.cobertura.xml"
targetdir: "merged-phase5/"
reporttypes: "Cobertura"
+6 -6
View File
@@ -4,7 +4,7 @@ on:
types:
- created
- edited
pull_request_target:
pull_request:
types:
- labeled
- synchronize
@@ -17,14 +17,14 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Notify as seen
uses: peter-evans/create-or-update-comment@71345be0265236311c031f5c7866368bd1eff043 # v4.0.0
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
token: ${{ secrets.JF_BOT_TOKEN }}
fetch-depth: 0
@@ -40,13 +40,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: pull in script
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
repository: jellyfin/jellyfin-triage-script
- name: install python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: '3.13'
python-version: '3.14'
cache: 'pip'
- name: install python packages
run: pip install -r rename/requirements.txt
+93
View File
@@ -0,0 +1,93 @@
name: HA Build & Push to ECR
on:
push:
branches:
- master
- main
- "feat/ha-*"
- "feat/phase*"
- "copilot/*"
# pull_request intentionally removed: this workflow runs on self-hosted k3s
# runners. Allowing pull_request events from a public repo would let any
# internet user execute arbitrary code inside the cluster network.
# CI build feedback on PRs is provided by ci-tests.yml (GitHub-hosted runners).
# Cancel in-progress runs when a new push arrives on the same branch.
concurrency:
group: ha-build-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-push:
runs-on: [self-hosted, k3s, linux, amd64]
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502 # v4.0.2
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Login to Amazon ECR
id: ecr-login
uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 # v2.0.1
- name: Set image metadata
id: meta
run: |
REPO="${{ steps.ecr-login.outputs.registry }}/${{ secrets.ECR_REPOSITORY }}"
SHORT_SHA="${GITHUB_SHA::7}"
echo "image_repo=${REPO}" >> "$GITHUB_OUTPUT"
echo "short_sha=${SHORT_SHA}" >> "$GITHUB_OUTPUT"
- name: Install .NET SDK
# Build on the runner host filesystem (native I/O) to avoid DinD
# overlay-on-overlay throttling which makes dotnet publish ~20x slower.
run: |
DOTNET_INSTALL_DIR="$HOME/.dotnet"
mkdir -p "$DOTNET_INSTALL_DIR"
if ! "$DOTNET_INSTALL_DIR/dotnet" --version 2>/dev/null | grep -q "^10\\."; then
curl -fsSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin --channel 10.0 --install-dir "$DOTNET_INSTALL_DIR"
fi
echo "DOTNET_ROOT=$DOTNET_INSTALL_DIR" >> "$GITHUB_ENV"
echo "PATH=$DOTNET_INSTALL_DIR:$PATH" >> "$GITHUB_ENV"
- name: Restore NuGet packages
run: |
export PATH="$HOME/.dotnet:$PATH"
dotnet restore Jellyfin.Server/Jellyfin.Server.csproj --runtime linux-x64
- name: Publish Jellyfin server
run: |
export PATH="$HOME/.dotnet:$PATH"
dotnet publish Jellyfin.Server/Jellyfin.Server.csproj \
--configuration Release \
--runtime linux-x64 \
--self-contained false \
--no-restore \
-p:TreatWarningsAsErrors=false \
--output ./publish-output
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
file: Dockerfile.runtime
platforms: linux/amd64
# Only push the image on direct branch pushes, not on pull_request events.
push: ${{ github.event_name == 'push' }}
provenance: false
tags: |
${{ steps.meta.outputs.image_repo }}:${{ steps.meta.outputs.short_sha }}
${{ steps.meta.outputs.image_repo }}:latest
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ contains(github.repository, 'jellyfin/') }}
steps:
- uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
with:
repo-token: ${{ secrets.JF_BOT_TOKEN }}
ascending: true
+3 -3
View File
@@ -10,13 +10,13 @@ jobs:
issues: write
steps:
- name: pull in script
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
repository: jellyfin/jellyfin-triage-script
- name: install python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with:
python-version: '3.13'
python-version: '3.14'
cache: 'pip'
- name: install python packages
run: pip install -r main-repo-triage/requirements.txt
+1 -1
View File
@@ -4,7 +4,7 @@ on:
push:
branches:
- master
pull_request_target:
pull_request:
issue_comment:
permissions: {}
+2 -2
View File
@@ -4,7 +4,7 @@ on:
push:
branches:
- master
pull_request_target:
pull_request:
issue_comment:
permissions: {}
@@ -16,7 +16,7 @@ jobs:
steps:
- name: Apply label
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}}
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request'}}
with:
dirtyLabel: 'merge conflict'
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ contains(github.repository, 'jellyfin/') }}
steps:
- uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
with:
repo-token: ${{ secrets.JF_BOT_TOKEN }}
ascending: true
+2 -2
View File
@@ -33,7 +33,7 @@ jobs:
yq-version: v4.9.8
- name: Checkout Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ env.TAG_BRANCH }}
@@ -66,7 +66,7 @@ jobs:
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
steps:
- name: Checkout Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ env.TAG_BRANCH }}
+3
View File
@@ -117,6 +117,7 @@
- [sachk](https://github.com/sachk)
- [sammyrc34](https://github.com/sammyrc34)
- [samuel9554](https://github.com/samuel9554)
- [SapientGuardian](https://github.com/SapientGuardian)
- [scheidleon](https://github.com/scheidleon)
- [sebPomme](https://github.com/sebPomme)
- [SegiH](https://github.com/SegiH)
@@ -205,6 +206,8 @@
- [theshoeshiner](https://github.com/theshoeshiner)
- [TokerX](https://github.com/TokerX)
- [GeneMarks](https://github.com/GeneMarks)
- [martenumberto](https://github.com/martenumberto)
- [MarcoCoreDuo](https://github.com/MarcoCoreDuo)
# Emby Contributors
+1 -1
View File
@@ -20,7 +20,7 @@
</ItemGroup>
<!-- Custom Analyzers -->
<ItemGroup Condition=" '$(MSBuildProjectName)' != 'Jellyfin.CodeAnalysis' ">
<ItemGroup Condition=" '$(MSBuildProjectName)' != 'Jellyfin.CodeAnalysis' AND '$(Configuration)' == 'Debug' ">
<ProjectReference Include="$(MSBuildThisFileDirectory)src/Jellyfin.CodeAnalysis/Jellyfin.CodeAnalysis.csproj" OutputItemType="Analyzer" />
</ItemGroup>
+37 -32
View File
@@ -4,7 +4,8 @@
</PropertyGroup>
<!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.-->
<ItemGroup Label="Package Dependencies">
<PackageVersion Include="AsyncKeyedLock" Version="7.1.6" />
<PackageVersion Include="AsyncKeyedLock" Version="7.1.8" />
<PackageVersion Include="AWSSDK.S3" Version="3.7.410.2" />
<PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" />
<PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" />
<PackageVersion Include="AutoFixture" Version="4.18.1" />
@@ -17,7 +18,7 @@
<PackageVersion Include="Diacritics" Version="4.0.17" />
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
<PackageVersion Include="FsCheck.Xunit" Version="3.3.1" />
<PackageVersion Include="FsCheck.Xunit" Version="3.3.2" />
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.1" />
<PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" />
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
@@ -26,49 +27,51 @@
<PackageVersion Include="libse" Version="4.0.12" />
<PackageVersion Include="LrcParser" Version="2025.623.0" />
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.11" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.11" />
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.14.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.9" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.9" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.9" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.9" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.9" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.9" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.9" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.9" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.9" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.9" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.9" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.9" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.9" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.9" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.9" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.11" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageVersion Include="MimeTypes" Version="2.5.2" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageVersion Include="Morestachio" Version="5.0.1.631" />
<PackageVersion Include="Moq" Version="4.18.4" />
<PackageVersion Include="NEbml" Version="1.1.0.5" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<PackageVersion Include="Npgsql" Version="9.0.4" />
<PackageVersion Include="PlaylistsNET" Version="1.4.1" />
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
<PackageVersion Include="prometheus-net" Version="8.2.1" />
<PackageVersion Include="Polly" Version="8.6.4" />
<PackageVersion Include="Polly" Version="8.6.5" />
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
<PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" />
<PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
@@ -84,16 +87,18 @@
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" />
<PackageVersion Include="System.Globalization" Version="4.3.0" />
<PackageVersion Include="System.Linq.Async" Version="6.0.3" />
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.9" />
<PackageVersion Include="System.Text.Json" Version="9.0.9" />
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.9" />
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.11" />
<PackageVersion Include="System.Text.Json" Version="9.0.11" />
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.11" />
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
<PackageVersion Include="z440.atl.core" Version="7.5.0" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.4.0" />
<PackageVersion Include="z440.atl.core" Version="7.9.0" />
<PackageVersion Include="TMDbLib" Version="2.3.0" />
<PackageVersion Include="UTF.Unknown" Version="2.6.0" />
<PackageVersion Include="StackExchange.Redis" Version="2.8.16" />
<PackageVersion Include="Xunit.Priority" Version="1.1.6" />
<PackageVersion Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.23" />
<PackageVersion Include="xunit" Version="2.9.3" />
</ItemGroup>
</Project>
</Project>
+96
View File
@@ -0,0 +1,96 @@
# syntax=docker/dockerfile:1
# ── Build stage ──────────────────────────────────────────────────────────────
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/sdk:10.0 AS build
WORKDIR /src
# Restore dependencies first (layer-cache friendly)
COPY ["Jellyfin.sln", "global.json", "nuget.config", "Directory.Build.props", "Directory.Packages.props", "./"]
COPY ["SharedVersion.cs", "BannedSymbols.txt", "stylecop.json", "./"]
# Copy all project files so dotnet restore can resolve the full dependency graph
COPY Emby.Naming/ Emby.Naming/
COPY Emby.Photos/ Emby.Photos/
COPY Emby.Server.Implementations/ Emby.Server.Implementations/
COPY Jellyfin.Api/ Jellyfin.Api/
COPY Jellyfin.Data/ Jellyfin.Data/
COPY Jellyfin.Server/ Jellyfin.Server/
COPY Jellyfin.Server.Implementations/ Jellyfin.Server.Implementations/
COPY MediaBrowser.Common/ MediaBrowser.Common/
COPY MediaBrowser.Controller/ MediaBrowser.Controller/
COPY MediaBrowser.LocalMetadata/ MediaBrowser.LocalMetadata/
COPY MediaBrowser.MediaEncoding/ MediaBrowser.MediaEncoding/
COPY MediaBrowser.Model/ MediaBrowser.Model/
COPY MediaBrowser.Providers/ MediaBrowser.Providers/
COPY MediaBrowser.XbmcMetadata/ MediaBrowser.XbmcMetadata/
COPY src/ src/
RUN dotnet restore Jellyfin.Server/Jellyfin.Server.csproj \
--runtime linux-x64
# Publish the server (and all transitive dependencies, including the
# PostgreSQL provider assembly added by this fork).
# Note: TreatWarningsAsErrors is disabled for the Docker build — StyleCop
# analyzer violations in upstream src/ projects would otherwise block the
# image build. StyleCop is enforced in the CI pipeline, not the Dockerfile.
RUN dotnet publish Jellyfin.Server/Jellyfin.Server.csproj \
--configuration Release \
--runtime linux-x64 \
--self-contained false \
--no-restore \
-p:TreatWarningsAsErrors=false \
--output /app
# ── Web client stage ──────────────────────────────────────────────────────────
# Install jellyfin-web via the official Jellyfin apt repo.
# Package suffix in the bookworm repo is +deb12 (e.g. 10.11.6+deb12).
# Web assets land at /usr/share/jellyfin/web/ — stable, prebuilt, no npm required.
# 10.11.6 is the latest stable web client; API-compatible with the 10.12.0 server.
FROM --platform=linux/amd64 debian:bookworm-slim AS webclient
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl gnupg ca-certificates \
&& curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key \
| gpg --dearmor -o /usr/share/keyrings/jellyfin.gpg \
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/jellyfin.gpg] https://repo.jellyfin.org/debian bookworm main" \
> /etc/apt/sources.list.d/jellyfin.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends "jellyfin-web=10.11.6+deb12" \
&& rm -rf /var/lib/apt/lists/* \
&& echo "Web client files:" && ls /usr/share/jellyfin/web/ | head -10
# ── Runtime stage ─────────────────────────────────────────────────────────────
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/aspnet:10.0
# Install FFmpeg and native dependencies required by SkiaSharp and fontconfig.
# libicu, libssl, and liblttng-ust are already present in the dotnet/aspnet base image.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ffmpeg \
fontconfig \
libfontconfig1 \
libfreetype6 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /jellyfin
COPY --from=build /app .
COPY --from=webclient /usr/share/jellyfin/web ./jellyfin-web/
# Jellyfin default ports
EXPOSE 8096
EXPOSE 8920
# Data / config volumes
VOLUME ["/config", "/cache", "/media"]
ENV JELLYFIN_DATA_DIR=/config \
JELLYFIN_CACHE_DIR=/cache \
JELLYFIN_LOG_DIR=/config/log \
JELLYFIN_CONFIG_DIR=/config
ENTRYPOINT ["./jellyfin", \
"--datadir", "/config", \
"--cachedir", "/cache", \
"--webdir", "/jellyfin/jellyfin-web"]
+55
View File
@@ -0,0 +1,55 @@
# syntax=docker/dockerfile:1
# Runtime-only image — the .NET publish step runs on the CI host (runner),
# not inside this Dockerfile.
# ── Web client stage ──────────────────────────────────────────────────────────
# Install jellyfin-web via the official Jellyfin apt repo.
# Package suffix in the bookworm repo is +deb12 (e.g. 10.11.6+deb12).
# Web assets land at /usr/share/jellyfin/web/ — stable, prebuilt, no npm required.
# 10.11.6 is the latest stable web client; API-compatible with the 10.12.0 server.
FROM --platform=linux/amd64 debian:bookworm-slim AS webclient
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl gnupg ca-certificates \
&& curl -fsSL https://repo.jellyfin.org/jellyfin_team.gpg.key \
| gpg --dearmor -o /usr/share/keyrings/jellyfin.gpg \
&& echo "deb [arch=amd64 signed-by=/usr/share/keyrings/jellyfin.gpg] https://repo.jellyfin.org/debian bookworm main" \
> /etc/apt/sources.list.d/jellyfin.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends "jellyfin-web=10.11.6+deb12" \
&& rm -rf /var/lib/apt/lists/*
# ── Runtime stage ─────────────────────────────────────────────────────────────
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/aspnet:10.0
# Install FFmpeg and native dependencies required by SkiaSharp and fontconfig.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
ffmpeg \
fontconfig \
libfontconfig1 \
libfreetype6 \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /jellyfin
# Copy the pre-built publish output produced by `dotnet publish` on the CI host.
COPY publish-output/ .
# Copy the jellyfin-web client assets from the webclient stage.
COPY --from=webclient /usr/share/jellyfin/web ./jellyfin-web/
# Jellyfin default ports
EXPOSE 8096
EXPOSE 8920
# Data / config volumes
VOLUME ["/config", "/cache", "/media"]
ENV JELLYFIN_DATA_DIR=/config \
JELLYFIN_CACHE_DIR=/cache \
JELLYFIN_LOG_DIR=/config/log
ENTRYPOINT ["./jellyfin", \
"--datadir", "/config", \
"--cachedir", "/cache", \
"--webdir", "/jellyfin/jellyfin-web"]
+1 -1
View File
@@ -36,7 +36,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Naming</PackageId>
<VersionPrefix>10.11.0</VersionPrefix>
<VersionPrefix>10.11.7</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
+30 -33
View File
@@ -10,12 +10,17 @@ namespace Emby.Naming.TV
/// </summary>
public static partial class SeasonPathParser
{
[GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[]*|[]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<rightpart>.*)$")]
private static readonly Regex CleanNameRegex = new(@"[ ._\-\[\]]", RegexOptions.Compiled);
[GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[]*|[]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
private static partial Regex ProcessPre();
[GeneratedRegex(@"^\s*(?:[[]*|[]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<seasonnumber>(?>\d+)(?!\s*[Ee]\d+))(?<rightpart>.*)$")]
[GeneratedRegex(@"^\s*(?:[[]*|[]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<seasonnumber>\d+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
private static partial Regex ProcessPost();
[GeneratedRegex(@"[sS](\d{1,4})(?!\d|[eE]\d)(?=\.|_|-|\[|\]|\s|$)", RegexOptions.None)]
private static partial Regex SeasonPrefix();
/// <summary>
/// Attempts to parse season number from path.
/// </summary>
@@ -56,44 +61,34 @@ namespace Emby.Naming.TV
bool supportSpecialAliases,
bool supportNumericSeasonFolders)
{
string filename = Path.GetFileName(path);
filename = Regex.Replace(filename, "[ ._-]", string.Empty);
var fileName = Path.GetFileName(path);
var seasonPrefixMatch = SeasonPrefix().Match(fileName);
if (seasonPrefixMatch.Success &&
int.TryParse(seasonPrefixMatch.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
{
return (val, true);
}
string filename = CleanNameRegex.Replace(fileName, string.Empty);
if (parentFolderName is not null)
{
parentFolderName = Regex.Replace(parentFolderName, "[ ._-]", string.Empty);
filename = filename.Replace(parentFolderName, string.Empty, StringComparison.OrdinalIgnoreCase);
var cleanParent = CleanNameRegex.Replace(parentFolderName, string.Empty);
filename = filename.Replace(cleanParent, string.Empty, StringComparison.OrdinalIgnoreCase);
}
if (supportSpecialAliases)
if (supportSpecialAliases &&
(filename.Equals("specials", StringComparison.OrdinalIgnoreCase) ||
filename.Equals("extras", StringComparison.OrdinalIgnoreCase)))
{
if (string.Equals(filename, "specials", StringComparison.OrdinalIgnoreCase))
{
return (0, true);
}
if (string.Equals(filename, "extras", StringComparison.OrdinalIgnoreCase))
{
return (0, true);
}
return (0, true);
}
if (supportNumericSeasonFolders)
if (supportNumericSeasonFolders &&
int.TryParse(filename, NumberStyles.Integer, CultureInfo.InvariantCulture, out val))
{
if (int.TryParse(filename, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
{
return (val, true);
}
}
if (filename.StartsWith('s'))
{
var testFilename = filename.AsSpan()[1..];
if (int.TryParse(testFilename, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
{
return (val, true);
}
return (val, true);
}
var preMatch = ProcessPre().Match(filename);
@@ -113,8 +108,10 @@ namespace Emby.Naming.TV
var numberString = match.Groups["seasonnumber"];
if (numberString.Success)
{
var seasonNumber = int.Parse(numberString.Value, CultureInfo.InvariantCulture);
return (seasonNumber, true);
if (int.TryParse(numberString.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seasonNumber))
{
return (seasonNumber, true);
}
}
return (null, false);
@@ -107,10 +107,20 @@ namespace Emby.Server.Implementations.AppBase
private void CheckOrCreateMarker(string path, string markerName, bool recursive = false)
{
var otherMarkers = GetMarkers(path, recursive).FirstOrDefault(e => Path.GetFileName(e) != markerName);
string? otherMarkers = null;
try
{
otherMarkers = GetMarkers(path, recursive).FirstOrDefault(e => !Path.GetFileName(e.AsSpan()).Equals(markerName, StringComparison.OrdinalIgnoreCase));
}
catch
{
// Error while checking for marker files, assume none exist and keep going
// TODO: add some logging
}
if (otherMarkers is not null)
{
throw new InvalidOperationException($"Exepected to find only {markerName} but found marker for {otherMarkers}.");
throw new InvalidOperationException($"Expected to find only {markerName} but found marker for {otherMarkers}.");
}
var markerPath = Path.Combine(path, markerName);
@@ -1,6 +1,5 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading;
@@ -224,7 +223,7 @@ public class ChapterManager : IChapterManager
if (saveChapters && changesMade)
{
_chapterRepository.SaveChapters(video.Id, chapters);
SaveChapters(video, chapters);
}
DeleteDeadImages(currentImages, chapters);
@@ -235,7 +234,9 @@ public class ChapterManager : IChapterManager
/// <inheritdoc />
public void SaveChapters(Video video, IReadOnlyList<ChapterInfo> chapters)
{
_chapterRepository.SaveChapters(video.Id, chapters);
// Remove any chapters that are outside of the runtime of the video
var validChapters = chapters.Where(c => c.StartPositionTicks < video.RunTimeTicks).ToList();
_chapterRepository.SaveChapters(video.Id, validChapters);
}
/// <inheritdoc />
@@ -251,23 +252,9 @@ public class ChapterManager : IChapterManager
}
/// <inheritdoc />
public void DeleteChapterImages(Video video)
public async Task DeleteChapterDataAsync(Guid itemId, CancellationToken cancellationToken)
{
var path = _pathManager.GetChapterImageFolderPath(video);
try
{
if (Directory.Exists(path))
{
_logger.LogInformation("Removing chapter images for {Name} [{Id}]", video.Name, video.Id);
Directory.Delete(path, true);
}
}
catch (Exception ex)
{
_logger.LogWarning("Failed to remove chapter image folder for {Item}: {Exception}", video.Id, ex);
}
_chapterRepository.DeleteChapters(video.Id);
await _chapterRepository.DeleteChaptersAsync(itemId, cancellationToken).ConfigureAwait(false);
}
private IReadOnlyList<string> GetSavedChapterImages(Video video, IDirectoryService directoryService)
@@ -104,6 +104,8 @@ namespace Emby.Server.Implementations.Collections
await _libraryManager.AddVirtualFolder(name, CollectionTypeOptions.boxsets, libraryOptions, true).ConfigureAwait(false);
_libraryManager.RootFolder.Children = null;
return FindFolders(path).First();
}
+19 -34
View File
@@ -1051,16 +1051,16 @@ namespace Emby.Server.Implementations.Dto
// Include artists that are not in the database yet, e.g., just added via metadata editor
// var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList();
dto.ArtistItems = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))])
.Where(e => e.Value.Length > 0)
.Select(i =>
{
return new NameGuidPair
{
Name = i.Key,
Id = i.Value.First().Id
};
}).Where(i => i is not null).ToArray();
var artistsLookup = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))]);
dto.ArtistItems = hasArtist.Artists
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct()
.Select(name => artistsLookup.TryGetValue(name, out var artists) && artists.Length > 0
? new NameGuidPair { Name = name, Id = artists[0].Id }
: null)
.Where(item => item is not null)
.ToArray();
}
if (item is IHasAlbumArtist hasAlbumArtist)
@@ -1085,31 +1085,16 @@ namespace Emby.Server.Implementations.Dto
// })
// .ToList();
var albumArtistsLookup = _libraryManager.GetArtists([.. hasAlbumArtist.AlbumArtists.Where(e => !string.IsNullOrWhiteSpace(e))]);
dto.AlbumArtists = hasAlbumArtist.AlbumArtists
// .Except(foundArtists, new DistinctNameComparer())
.Select(i =>
{
// This should not be necessary but we're seeing some cases of it
if (string.IsNullOrEmpty(i))
{
return null;
}
var artist = _libraryManager.GetArtist(i, new DtoOptions(false)
{
EnableImages = false
});
if (artist is not null)
{
return new NameGuidPair
{
Name = artist.Name,
Id = artist.Id
};
}
return null;
}).Where(i => i is not null).ToArray();
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct()
.Select(name => albumArtistsLookup.TryGetValue(name, out var albumArtists) && albumArtists.Length > 0
? new NameGuidPair { Name = name, Id = albumArtists[0].Id }
: null)
.Where(item => item is not null)
.ToArray();
}
// Add video info
@@ -66,6 +66,7 @@
<ItemGroup>
<PackageReference Include="Ignore" />
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>
<ItemGroup>
@@ -352,6 +352,12 @@ namespace Emby.Server.Implementations.IO
return;
}
var fileInfo = _fileSystem.GetFileSystemInfo(path);
if (DotIgnoreIgnoreRule.IsIgnored(fileInfo, null))
{
return;
}
// Ignore certain files, If the parent of an ignored path has a change event, ignore that too
foreach (var i in _tempIgnoredPaths.Keys)
{
@@ -6,6 +6,7 @@ using System.Linq;
using System.Security;
using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.IO;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
@@ -152,6 +153,10 @@ namespace Emby.Server.Implementations.IO
/// <inheritdoc />
public void MoveDirectory(string source, string destination)
{
// Make sure parent directory of target exists
var parent = Directory.GetParent(destination);
parent?.Create();
try
{
Directory.Move(source, destination);
@@ -248,47 +253,40 @@ namespace Emby.Server.Implementations.IO
{
result.IsDirectory = info is DirectoryInfo || (info.Attributes & FileAttributes.Directory) == FileAttributes.Directory;
// if (!result.IsDirectory)
// {
// result.IsHidden = (info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden;
// }
if (info is FileInfo fileInfo)
{
result.Length = fileInfo.Length;
// Issue #2354 get the size of files behind symbolic links. Also Enum.HasFlag is bad as it boxes!
if ((fileInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint)
result.CreationTimeUtc = GetCreationTimeUtc(info);
result.LastWriteTimeUtc = GetLastWriteTimeUtc(info);
if (fileInfo.LinkTarget is not null)
{
try
{
using (var fileHandle = File.OpenHandle(fileInfo.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
var targetFileInfo = FileSystemHelper.ResolveLinkTarget(fileInfo, returnFinalTarget: true);
if (targetFileInfo is not null)
{
result.Length = RandomAccess.GetLength(fileHandle);
result.Exists = targetFileInfo.Exists;
if (result.Exists)
{
result.Length = targetFileInfo.Length;
result.CreationTimeUtc = GetCreationTimeUtc(targetFileInfo);
result.LastWriteTimeUtc = GetLastWriteTimeUtc(targetFileInfo);
}
}
else
{
result.Exists = false;
}
}
catch (FileNotFoundException ex)
{
// Dangling symlinks cannot be detected before opening the file unfortunately...
_logger.LogError(ex, "Reading the file size of the symlink at {Path} failed. Marking the file as not existing.", fileInfo.FullName);
result.Exists = false;
}
catch (UnauthorizedAccessException ex)
{
_logger.LogError(ex, "Reading the file at {Path} failed due to a permissions exception.", fileInfo.FullName);
}
catch (IOException ex)
{
// IOException generally means the file is not accessible due to filesystem issues
// Catch this exception and mark the file as not exist to ignore it
_logger.LogError(ex, "Reading the file at {Path} failed due to an IO Exception. Marking the file as not existing", fileInfo.FullName);
result.Exists = false;
}
}
else
{
result.Length = fileInfo.Length;
}
}
result.CreationTimeUtc = GetCreationTimeUtc(info);
result.LastWriteTimeUtc = GetLastWriteTimeUtc(info);
}
else
{
@@ -499,8 +497,17 @@ namespace Emby.Server.Implementations.IO
/// <inheritdoc />
public virtual bool AreEqual(string path1, string path2)
{
return Path.TrimEndingDirectorySeparator(path1).Equals(
Path.TrimEndingDirectorySeparator(path2),
if (string.IsNullOrWhiteSpace(path1) || string.IsNullOrWhiteSpace(path2))
{
return false;
}
var normalized1 = Path.TrimEndingDirectorySeparator(path1);
var normalized2 = Path.TrimEndingDirectorySeparator(path2);
return string.Equals(
normalized1,
normalized2,
_isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
}
@@ -267,22 +267,24 @@ namespace Emby.Server.Implementations.Images
{
var image = item.GetImageInfo(type, 0);
if (image is not null)
if (image is null)
{
if (!image.IsLocalFile)
{
return false;
}
return GetItemsWithImages(item).Count is not 0;
}
if (!FileSystem.ContainsSubPath(item.GetInternalMetadataPath(), image.Path))
{
return false;
}
if (!image.IsLocalFile)
{
return false;
}
if (!HasChangedByDate(item, image))
{
return false;
}
if (!FileSystem.ContainsSubPath(item.GetInternalMetadataPath(), image.Path))
{
return false;
}
if (!HasChangedByDate(item, image))
{
return false;
}
return true;
@@ -98,5 +98,11 @@ namespace Emby.Server.Implementations.Images
return base.CreateImage(item, itemsWithImages, outputPath, imageType, imageIndex);
}
protected override bool HasChangedByDate(BaseItem item, ItemImageInfo image)
{
var age = DateTime.UtcNow - image.DateModified;
return age.TotalDays > 7;
}
}
}
@@ -1,6 +1,8 @@
using System;
using System.IO;
using System.Text.RegularExpressions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Resolvers;
using MediaBrowser.Model.IO;
@@ -11,28 +13,24 @@ namespace Emby.Server.Implementations.Library;
/// </summary>
public class DotIgnoreIgnoreRule : IResolverIgnoreRule
{
private static readonly bool IsWindows = OperatingSystem.IsWindows();
private static FileInfo? FindIgnoreFile(DirectoryInfo directory)
{
var ignoreFile = new FileInfo(Path.Join(directory.FullName, ".ignore"));
if (ignoreFile.Exists)
for (var current = directory; current is not null; current = current.Parent)
{
return ignoreFile;
var ignorePath = Path.Join(current.FullName, ".ignore");
if (File.Exists(ignorePath))
{
return new FileInfo(ignorePath);
}
}
var parentDir = directory.Parent;
if (parentDir is null)
{
return null;
}
return FindIgnoreFile(parentDir);
return null;
}
/// <inheritdoc />
public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent)
{
return IsIgnored(fileInfo, parent);
}
public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent) => IsIgnored(fileInfo, parent);
/// <summary>
/// Checks whether or not the file is ignored.
@@ -42,60 +40,101 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
/// <returns>True if the file should be ignored.</returns>
public static bool IsIgnored(FileSystemMetadata fileInfo, BaseItem? parent)
{
if (fileInfo.IsDirectory)
{
var dirIgnoreFile = FindIgnoreFile(new DirectoryInfo(fileInfo.FullName));
if (dirIgnoreFile is null)
{
return false;
}
var searchDirectory = fileInfo.IsDirectory
? new DirectoryInfo(fileInfo.FullName)
: new DirectoryInfo(Path.GetDirectoryName(fileInfo.FullName) ?? string.Empty);
// Fast path in case the ignore files isn't a symlink and is empty
if ((dirIgnoreFile.Attributes & FileAttributes.ReparsePoint) == 0
&& dirIgnoreFile.Length == 0)
{
return true;
}
// ignore the directory only if the .ignore file is empty
// evaluate individual files otherwise
return string.IsNullOrWhiteSpace(GetFileContent(dirIgnoreFile));
}
var parentDirPath = Path.GetDirectoryName(fileInfo.FullName);
if (string.IsNullOrEmpty(parentDirPath))
if (string.IsNullOrEmpty(searchDirectory.FullName))
{
return false;
}
var folder = new DirectoryInfo(parentDirPath);
var ignoreFile = FindIgnoreFile(folder);
var ignoreFile = FindIgnoreFile(searchDirectory);
if (ignoreFile is null)
{
return false;
}
string ignoreFileString = GetFileContent(ignoreFile);
if (string.IsNullOrWhiteSpace(ignoreFileString))
// Fast path in case the ignore files isn't a symlink and is empty
if (ignoreFile.LinkTarget is null && ignoreFile.Length == 0)
{
// Ignore directory if we just have the file
return true;
}
// If file has content, base ignoring off the content .gitignore-style rules
var ignoreRules = ignoreFileString.Split('\n', StringSplitOptions.RemoveEmptyEntries);
var ignore = new Ignore.Ignore();
ignore.Add(ignoreRules);
return ignore.IsIgnored(fileInfo.FullName);
var content = GetFileContent(ignoreFile);
return string.IsNullOrWhiteSpace(content)
|| CheckIgnoreRules(fileInfo.FullName, content, fileInfo.IsDirectory);
}
private static string GetFileContent(FileInfo dirIgnoreFile)
private static bool CheckIgnoreRules(string path, string ignoreFileContent, bool isDirectory)
{
using (var reader = dirIgnoreFile.OpenText())
// If file has content, base ignoring off the content .gitignore-style rules
var rules = ignoreFileContent.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return CheckIgnoreRules(path, rules, isDirectory);
}
/// <summary>
/// Checks whether a path should be ignored based on an array of ignore rules.
/// </summary>
/// <param name="path">The path to check.</param>
/// <param name="rules">The array of ignore rules.</param>
/// <param name="isDirectory">Whether the path is a directory.</param>
/// <returns>True if the path should be ignored.</returns>
internal static bool CheckIgnoreRules(string path, string[] rules, bool isDirectory)
=> CheckIgnoreRules(path, rules, isDirectory, IsWindows);
/// <summary>
/// Checks whether a path should be ignored based on an array of ignore rules.
/// </summary>
/// <param name="path">The path to check.</param>
/// <param name="rules">The array of ignore rules.</param>
/// <param name="isDirectory">Whether the path is a directory.</param>
/// <param name="normalizePath">Whether to normalize backslashes to forward slashes (for Windows paths).</param>
/// <returns>True if the path should be ignored.</returns>
internal static bool CheckIgnoreRules(string path, string[] rules, bool isDirectory, bool normalizePath)
{
var ignore = new Ignore.Ignore();
// Add each rule individually to catch and skip invalid patterns
var validRulesAdded = 0;
foreach (var rule in rules)
{
return reader.ReadToEnd();
try
{
ignore.Add(rule);
validRulesAdded++;
}
catch (RegexParseException)
{
// Ignore invalid patterns
}
}
// If no valid rules were added, fall back to ignoring everything (like an empty .ignore file)
if (validRulesAdded == 0)
{
return true;
}
// Mitigate the problem of the Ignore library not handling Windows paths correctly.
// See https://github.com/jellyfin/jellyfin/issues/15484
var pathToCheck = normalizePath ? path.NormalizePath('/') : path;
// Add trailing slash for directories to match "folder/"
if (isDirectory)
{
pathToCheck = string.Concat(pathToCheck.AsSpan().TrimEnd('/'), "/");
}
return ignore.IsIgnored(pathToCheck);
}
private static string GetFileContent(FileInfo ignoreFile)
{
ignoreFile = FileSystemHelper.ResolveLinkTarget(ignoreFile, returnFinalTarget: true) ?? ignoreFile;
return ignoreFile.Exists
? File.ReadAllText(ignoreFile.FullName)
: string.Empty;
}
}
@@ -3,6 +3,7 @@ using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Chapters;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.MediaSegments;
@@ -20,6 +21,7 @@ public class ExternalDataManager : IExternalDataManager
private readonly IMediaSegmentManager _mediaSegmentManager;
private readonly IPathManager _pathManager;
private readonly ITrickplayManager _trickplayManager;
private readonly IChapterManager _chapterManager;
private readonly ILogger<ExternalDataManager> _logger;
/// <summary>
@@ -29,18 +31,21 @@ public class ExternalDataManager : IExternalDataManager
/// <param name="mediaSegmentManager">The media segment manager.</param>
/// <param name="pathManager">The path manager.</param>
/// <param name="trickplayManager">The trickplay manager.</param>
/// <param name="chapterManager">The chapter manager.</param>
/// <param name="logger">The logger.</param>
public ExternalDataManager(
IKeyframeManager keyframeManager,
IMediaSegmentManager mediaSegmentManager,
IPathManager pathManager,
ITrickplayManager trickplayManager,
IChapterManager chapterManager,
ILogger<ExternalDataManager> logger)
{
_keyframeManager = keyframeManager;
_mediaSegmentManager = mediaSegmentManager;
_pathManager = pathManager;
_trickplayManager = trickplayManager;
_chapterManager = chapterManager;
_logger = logger;
}
@@ -67,5 +72,6 @@ public class ExternalDataManager : IExternalDataManager
await _keyframeManager.DeleteKeyframeDataAsync(itemId, cancellationToken).ConfigureAwait(false);
await _mediaSegmentManager.DeleteSegmentsAsync(itemId, cancellationToken).ConfigureAwait(false);
await _trickplayManager.DeleteTrickplayDataAsync(itemId, cancellationToken).ConfigureAwait(false);
await _chapterManager.DeleteChapterDataAsync(itemId, cancellationToken).ConfigureAwait(false);
}
}
@@ -50,6 +50,10 @@ namespace Emby.Server.Implementations.Library
"**/lost+found",
"**/subs/**",
"**/subs",
"**/.snapshots/**",
"**/.snapshots",
"**/.snapshot/**",
"**/.snapshot",
// Trickplay files
"**/*.trickplay",
@@ -457,6 +457,12 @@ namespace Emby.Server.Implementations.Library
_cache.TryRemove(child.Id, out _);
}
if (parent is Folder folder)
{
folder.Children = null;
folder.UserData = null;
}
ReportItemRemoved(item, parent);
}
@@ -528,7 +534,7 @@ namespace Emby.Server.Implementations.Library
{
Genre => _configurationManager.ApplicationPaths.GenrePath,
MusicArtist => _configurationManager.ApplicationPaths.ArtistsPath,
MusicGenre => _configurationManager.ApplicationPaths.GenrePath,
MusicGenre => _configurationManager.ApplicationPaths.MusicGenrePath,
Person => _configurationManager.ApplicationPaths.PeoplePath,
Studio => _configurationManager.ApplicationPaths.StudioPath,
Year => _configurationManager.ApplicationPaths.YearPath,
@@ -1052,6 +1058,7 @@ namespace Emby.Server.Implementations.Library
{
IncludeItemTypes = [BaseItemKind.MusicArtist],
Name = name,
UseRawName = true,
DtoOptions = options
}).Cast<MusicArtist>()
.OrderBy(i => i.IsAccessedByName ? 1 : 0)
@@ -1993,6 +2000,12 @@ namespace Emby.Server.Implementations.Library
RegisterItem(item);
}
if (parent is Folder folder)
{
folder.Children = null;
folder.UserData = null;
}
if (ItemAdded is not null)
{
foreach (var item in items)
@@ -2129,6 +2142,8 @@ namespace Emby.Server.Implementations.Library
}
}
item.ValidateImages();
_itemRepository.SaveImages(item);
RegisterItem(item);
@@ -2148,6 +2163,12 @@ namespace Emby.Server.Implementations.Library
_itemRepository.SaveItems(items, cancellationToken);
if (parent is Folder folder)
{
folder.Children = null;
folder.UserData = null;
}
if (ItemUpdated is not null)
{
foreach (var item in items)
@@ -2181,6 +2202,12 @@ namespace Emby.Server.Implementations.Library
public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
=> UpdateItemsAsync([item], parent, updateReason, cancellationToken);
/// <inheritdoc />
public async Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken)
{
await _itemRepository.ReattachUserDataAsync(item, cancellationToken).ConfigureAwait(false);
}
public async Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason)
{
if (item.IsFileProtocol)
@@ -3051,10 +3078,10 @@ namespace Emby.Server.Implementations.Library
}
finally
{
await ValidateTopLibraryFolders(CancellationToken.None).ConfigureAwait(false);
if (refreshLibrary)
{
await ValidateTopLibraryFolders(CancellationToken.None).ConfigureAwait(false);
StartScanInBackground();
}
else
@@ -3174,19 +3201,7 @@ namespace Emby.Server.Implementations.Library
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
var shortcutFilename = Path.GetFileNameWithoutExtension(path);
var lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
while (File.Exists(lnk))
{
shortcutFilename += "1";
lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
}
_fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path));
RemoveContentTypeOverrides(path);
CreateShortcut(virtualFolderPath, pathInfo);
if (saveLibraryOptions)
{
@@ -3351,5 +3366,24 @@ namespace Emby.Server.Implementations.Library
return item is UserRootFolder || item.IsVisibleStandalone(user);
}
public void CreateShortcut(string virtualFolderPath, MediaPathInfo pathInfo)
{
var path = pathInfo.Path;
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
var shortcutFilename = Path.GetFileNameWithoutExtension(path);
var lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
while (File.Exists(lnk))
{
shortcutFilename += "1";
lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
}
_fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path));
RemoveContentTypeOverrides(path);
}
}
}
@@ -226,6 +226,11 @@ namespace Emby.Server.Implementations.Library
/// <inheritdoc />>
public MediaProtocol GetPathProtocol(string path)
{
if (string.IsNullOrEmpty(path))
{
return MediaProtocol.File;
}
if (path.StartsWith("Rtsp", StringComparison.OrdinalIgnoreCase))
{
return MediaProtocol.Rtsp;
@@ -28,7 +28,9 @@ namespace Emby.Server.Implementations.Library
public IReadOnlyList<BaseItem> GetInstantMixFromSong(Audio item, User? user, DtoOptions dtoOptions)
{
return GetInstantMixFromGenres(item.Genres, user, dtoOptions);
var instantMixItems = GetInstantMixFromGenres(item.Genres, user, dtoOptions);
return [item, .. instantMixItems.Where(i => !i.Id.Equals(item.Id))];
}
/// <inheritdoc />
@@ -369,13 +369,16 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
// We need to only look at the name of this actual item (not parents)
var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path.AsSpan()) : Path.GetFileName(item.ContainingFolderPath.AsSpan());
if (!justName.IsEmpty)
var tmdbid = justName.GetAttributeValue("tmdbid");
// If not in a mixed folder and ID not found in folder path, check filename
if (string.IsNullOrEmpty(tmdbid) && !item.IsInMixedFolder)
{
// Check for TMDb id
var tmdbid = justName.GetAttributeValue("tmdbid");
item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid);
tmdbid = Path.GetFileName(item.Path.AsSpan()).GetAttributeValue("tmdbid");
}
item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid);
if (!string.IsNullOrEmpty(item.Path))
{
// Check for IMDb id - we use full media path, as we can assume that this will match in any use case (whether id in parent dir or in file name)
@@ -1,14 +1,14 @@
{
"TaskCleanActivityLogDescription": "Kustutab määratud ajast vanemad tegevuslogi kirjed.",
"UserDownloadingItemWithValues": "{0} laeb alla {1}",
"UserDownloadingItemWithValues": "{0} laadib alla {1}",
"HeaderRecordingGroups": "Salvestusrühmad",
"TaskOptimizeDatabaseDescription": "Tihendab ja puhastab andmebaasi. Selle toimingu tegemine pärast meediakogu andmebaasiga seotud muudatuste skannimist võib jõudlust parandada.",
"TaskOptimizeDatabase": "Optimeeri andmebaasi",
"TaskDownloadMissingSubtitlesDescription": "Otsib veebist puuduvaid subtiitreid vastavalt määratud metaandmete seadetele.",
"TaskDownloadMissingSubtitles": "Laadi alla puuduvad subtiitrid",
"TaskDownloadMissingSubtitles": "Hangi puuduvad subtiitrid",
"TaskRefreshChannelsDescription": "Värskendab veebikanalite teavet.",
"TaskRefreshChannels": "Värskenda kanaleid",
"TaskCleanTranscodeDescription": "Kustutab üle ühe päeva vanused transkodeerimisfailid.",
"TaskCleanTranscodeDescription": "Kustutab üle ühe päeva vanused transkoodimisfailid.",
"TaskCleanTranscode": "Puhasta transkoodimise kataloog",
"TaskUpdatePluginsDescription": "Laadib alla ja paigaldab nende pluginate uuendused, mis on seadistatud automaatselt uuenduma.",
"TaskUpdatePlugins": "Uuenda pluginaid",
@@ -41,10 +41,10 @@
"StartupEmbyServerIsLoading": "Jellyfin server laadib. Proovi varsti uuesti.",
"User": "Kasutaja",
"Undefined": "Määratlemata",
"TvShows": "Seriaalid",
"TvShows": "Sarjad",
"System": "Süsteem",
"Sync": "Sünkrooni",
"Songs": "Laulud",
"Songs": "Lood",
"Shows": "Sarjad",
"ServerNameNeedsToBeRestarted": "{0} tuleb taaskäivitada",
"ScheduledTaskFailedWithName": "{0} nurjus",
@@ -92,7 +92,7 @@
"HeaderNextUp": "Järgmisena",
"HeaderLiveTV": "Otse TV",
"HeaderFavoriteSongs": "Lemmiklood",
"HeaderFavoriteShows": "Lemmikseriaalid",
"HeaderFavoriteShows": "Lemmiksarjad",
"HeaderFavoriteEpisodes": "Lemmikepisoodid",
"HeaderFavoriteArtists": "Lemmikesitajad",
"HeaderFavoriteAlbums": "Lemmikalbumid",
@@ -122,18 +122,20 @@
"UserOnlineFromDevice": "{0} on ühendatud seadmest {1}",
"External": "Väline",
"HearingImpaired": "Kuulmispuudega",
"TaskKeyframeExtractorDescription": "Eraldab videofailidest võtmekaadreid, et luua täpsemaid HLS-i esitusloendeid. See ülesanne võib kesta pikka aega.",
"TaskKeyframeExtractor": "Võtmekaadri ekstraktor",
"TaskRefreshTrickplayImages": "Loo eelvaate pildid",
"TaskRefreshTrickplayImagesDescription": "Loob eelvaated videotele, kus lubatud.",
"TaskAudioNormalization": "Heli Normaliseerimine",
"TaskAudioNormalizationDescription": "Skaneerib faile heli normaliseerimise andmete jaoks.",
"TaskCleanCollectionsAndPlaylistsDescription": "Eemaldab kogumikest ja esitusloenditest asjad, mida enam ei eksisteeri.",
"TaskKeyframeExtractorDescription": "Eraldab videofailidest võtmekaadrid, et luua täpsemaid HLS-i esitusloendeid. See võib kesta pikka aega.",
"TaskKeyframeExtractor": "Eralda võtmekaadrid",
"TaskRefreshTrickplayImages": "Loo trickplay pildid",
"TaskRefreshTrickplayImagesDescription": "Loob trickplay eelvaated videotele lubatud meediakogudes.",
"TaskAudioNormalization": "Normaliseeri helitugevus",
"TaskAudioNormalizationDescription": "Otsib failidest helitugevuse normaliseerimise teavet.",
"TaskCleanCollectionsAndPlaylistsDescription": "Eemaldab kogumikest ja esitusloenditest üksused, mida enam ei eksisteeri.",
"TaskCleanCollectionsAndPlaylists": "Puhasta kogumikud ja esitusloendid",
"TaskDownloadMissingLyrics": "Lae alla puuduolev lüürika",
"TaskDownloadMissingLyricsDescription": "Lae lauludele alla lüürika",
"TaskDownloadMissingLyrics": "Hangi puuduvad laulusõnad",
"TaskDownloadMissingLyricsDescription": "Laulusõnade allalaadimine",
"TaskMoveTrickplayImagesDescription": "Liigutab trickplay pildid meediakogu sätete kohaselt.",
"TaskExtractMediaSegments": "Meediasegmentide skaneerimine",
"TaskExtractMediaSegments": "Skaneeri meediasegmente",
"TaskExtractMediaSegmentsDescription": "Eraldab või võtab meediasegmendid MediaSegment'i lubavatest pluginatest.",
"TaskMoveTrickplayImages": "Migreeri trickplay piltide asukoht"
"TaskMoveTrickplayImages": "Muuda trickplay piltide asukoht",
"CleanupUserDataTask": "Puhasta kasutajaandmed",
"CleanupUserDataTaskDescription": "Puhastab kõik kasutajaandmed (vaatamise olek, lemmikute olek jne) meediast, mis pole enam vähemalt 90 päeva saadaval olnud."
}
@@ -1,74 +1,74 @@
{
"Albums": "Álbumes",
"Albums": "Álbums",
"Collections": "Coleccións",
"ChapterNameValue": "Capítulo {0}",
"Channels": "Canles",
"CameraImageUploadedFrom": "Cargouse unha nova imaxe da cámara desde {0}",
"CameraImageUploadedFrom": "Cargouse unha nova imaxe de cámara dende {0}",
"Books": "Libros",
"AuthenticationSucceededWithUserName": "{0} autenticouse correctamente",
"Artists": "Artistas",
"Application": "Aplicativo",
"NotificationOptionServerRestartRequired": "Necesario un reinicio do servidor",
"NotificationOptionPluginUpdateInstalled": "Actualización do Plugin instalada",
"Application": "Aplicación",
"NotificationOptionServerRestartRequired": "Necesario o reinicio do servidor",
"NotificationOptionPluginUpdateInstalled": "Actualización do plugin instalada",
"NotificationOptionPluginUninstalled": "Plugin desinstalado",
"NotificationOptionPluginInstalled": "Plugin instalado",
"NotificationOptionPluginError": "Fallo do Plugin",
"NotificationOptionPluginError": "Fallo do plugin",
"NotificationOptionNewLibraryContent": "Novo contido engadido",
"NotificationOptionInstallationFailed": "Fallo na instalación",
"NotificationOptionCameraImageUploaded": "Imaxe da cámara subida",
"NotificationOptionAudioPlaybackStopped": "Reproducción de audio parada",
"NotificationOptionCameraImageUploaded": "Imaxe da cámara cargada",
"NotificationOptionAudioPlaybackStopped": "Reproducción de audio detida",
"NotificationOptionAudioPlayback": "Reproducción de audio comezada",
"NotificationOptionApplicationUpdateInstalled": "Actualización da aplicación instalada",
"NotificationOptionApplicationUpdateAvailable": "Actualización da aplicación dispoñible",
"NewVersionIsAvailable": "Unha nova versión do Servidor Jellyfin está dispoñible para descarga.",
"NewVersionIsAvailable": "Nova versión do Servidor Jellyfin dispoñible para descargar.",
"NameSeasonUnknown": "Tempada descoñecida",
"NameSeasonNumber": "Tempada {0}",
"NameInstallFailed": "{0} instalación fallida",
"MusicVideos": "Vídeos Musicais",
"MusicVideos": "Vídeos musicais",
"Music": "Música",
"Movies": "Películas",
"MixedContent": "Contido Mixto",
"MessageServerConfigurationUpdated": "A configuración do servidor foi actualizada",
"MessageNamedServerConfigurationUpdatedWithValue": "A sección de configuración {0} do servidor foi actualizada",
"MessageApplicationUpdatedTo": "O servidor Jellyfin foi actualizado a {0}",
"MessageApplicationUpdated": "O servidor Jellyfin foi actualizado",
"MixedContent": "Contido mixto",
"MessageServerConfigurationUpdated": "Actualizouse a configuración do servidor",
"MessageNamedServerConfigurationUpdatedWithValue": "Actualizouse a sección de configuración {0} do servidor",
"MessageApplicationUpdatedTo": "O servidor Jellyfin actualizouse a {0}",
"MessageApplicationUpdated": "O servidor Jellyfin actualizouse",
"Latest": "Último",
"LabelRunningTimeValue": "Tempo de execución: {0}",
"LabelRunningTimeValue": "Tempo en execución: {0}",
"LabelIpAddressValue": "Enderezo IP: {0}",
"ItemRemovedWithName": "{0} foi eliminado da biblioteca",
"ItemAddedWithName": "{0} foi engadido a biblioteca",
"ItemRemovedWithName": "{0} eliminouse da biblioteca",
"ItemAddedWithName": "{0} engadiuse á biblioteca",
"Inherit": "Herdar",
"HomeVideos": "Videos caseiros",
"HeaderRecordingGroups": "Grupos de Grabación",
"HeaderRecordingGroups": "Grupos de grabación",
"HeaderNextUp": "De seguido",
"HeaderLiveTV": "TV en directo",
"HeaderFavoriteSongs": "Cancións Favoritas",
"HeaderFavoriteShows": "Series de TV Favoritas",
"HeaderFavoriteEpisodes": "Episodios Favoritos",
"HeaderFavoriteArtists": "Artistas Favoritos",
"HeaderFavoriteAlbums": "Álbunes Favoritos",
"HeaderFavoriteSongs": "Cancións favoritas",
"HeaderFavoriteShows": "Series de TV favoritas",
"HeaderFavoriteEpisodes": "Episodios favoritos",
"HeaderFavoriteArtists": "Artistas favoritos",
"HeaderFavoriteAlbums": "Álbums favoritos",
"HeaderContinueWatching": "Seguir vendo",
"HeaderAlbumArtists": "Artistas do Album",
"HeaderAlbumArtists": "Artistas do álbum",
"Genres": "Xéneros",
"Forced": "Forzado",
"Folders": "Cartafoles",
"Favorites": "Favoritos",
"FailedLoginAttemptWithUserName": "Intento de incio de sesión fallido {0}",
"FailedLoginAttemptWithUserName": "Fallo de intento de inicio de sesión dende {0}",
"DeviceOnlineWithName": "{0} conectouse",
"DeviceOfflineWithName": "{0} desconectouse",
"Default": "Por defecto",
"AppDeviceValues": "Aplicación: {0}, Dispositivo: {1}",
"TaskCleanLogs": "Limpar Carpeta de Rexistros",
"TaskCleanActivityLog": "Limpar Rexistro de Actividade",
"TasksChannelsCategory": "Canáis de Internet",
"TaskUpdatePlugins": "Actualizar Plugins",
"TaskCleanLogs": "Limpar directorio de rexistros",
"TaskCleanActivityLog": "Limpar rexistro de actividade",
"TasksChannelsCategory": "Canles da Internet",
"TaskUpdatePlugins": "Actualizar plugins",
"User": "Usuario",
"Undefined": "Sen definir",
"TvShows": "Programas de TV",
"System": "Sistema",
"Sync": "Sincronizar",
"SubtitleDownloadFailureFromForItem": "Fallou a descarga de subtítulos para {1} dende {0}",
"StartupEmbyServerIsLoading": "O Servidor Jellyfin está cargando. Por favor, reinténteo en breve.",
"StartupEmbyServerIsLoading": "O servidor Jellyfin está cargando. Por favor, ténteo axiña outra vez.",
"Songs": "Cancións",
"Shows": "Programas",
"ServerNameNeedsToBeRestarted": "{0} precisa ser reiniciado",
@@ -85,57 +85,57 @@
"UserDeletedWithName": "O usuario {0} foi borrado",
"UserCreatedWithName": "O usuario {0} foi creado",
"Plugin": "Plugin",
"NotificationOptionVideoPlaybackStopped": "Reproducción de vídeo parada",
"NotificationOptionVideoPlaybackStopped": "Reproducción de vídeo detida",
"NotificationOptionVideoPlayback": "Reproducción de vídeo iniciada",
"NotificationOptionUserLockedOut": "Usuario bloqueado",
"NotificationOptionTaskFailed": "Falla na tarefa axendada",
"TaskCleanTranscodeDescription": "Borra os arquivos de transcode anteriores a un día.",
"TaskCleanTranscode": "Limpar Directorio de Transcode",
"TaskCleanTranscodeDescription": "Borra os ficheiros de transcodificación de hai más dun día.",
"TaskCleanTranscode": "Limpar o directorio de transcodificación",
"UserStoppedPlayingItemWithValues": "{0} rematou de reproducir {1} en {2}",
"UserStartedPlayingItemWithValues": "{0} está reproducindo {1} en {2}",
"TaskDownloadMissingSubtitlesDescription": "Busca en internet por subtítulos que faltan baseado na configuración de metadatos.",
"UserStartedPlayingItemWithValues": "{0} está a reproducir {1} en {2}",
"TaskDownloadMissingSubtitlesDescription": "Procura na internet os subtítulos que faltan segundo a configuración de metadatos.",
"TaskDownloadMissingSubtitles": "Descargar subtítulos que faltan",
"TaskRefreshChannelsDescription": "Refresca a información do canle de internet.",
"TaskRefreshChannels": "Refrescar Canles",
"TaskUpdatePluginsDescription": "Descarga e instala actualizacións para plugins que están configurados para actualizarse automáticamente.",
"TaskRefreshPeopleDescription": "Actualiza os metadatos dos actores e directores na túa libraría multimedia.",
"TaskRefreshPeople": "Refrescar Persoas",
"TaskCleanLogsDescription": "Borra arquivos de rexistro que son mais antigos que {0} días.",
"TaskRefreshLibraryDescription": "Escanea a tua libraría multimedia buscando novos arquivos e refrescando os metadatos.",
"TaskRefreshLibrary": "Escanear Libraría Multimedia",
"TaskRefreshChapterImagesDescription": "Crea previsualizacións para videos que teñen capítulos.",
"TaskRefreshChapterImages": "Extraer Imaxes dos Capítulos",
"TaskRefreshChannelsDescription": "Refresca a información da canle de internet.",
"TaskRefreshChannels": "Refrescar canles",
"TaskUpdatePluginsDescription": "Descarga e instala actualizacións dos plugins configurados para actualizarse automáticamente.",
"TaskRefreshPeopleDescription": "Actualiza os metadatos dos actores e directores na túa biblioteca de medios.",
"TaskRefreshPeople": "Refrescar persoas",
"TaskCleanLogsDescription": "Borra ficheiros de rexistro con máis de {0} días de antigüidade.",
"TaskRefreshLibraryDescription": "Escanea a túa biblioteca de medios á procura de novos ficheiros e refresca os metadatos.",
"TaskRefreshLibrary": "Escanear a biblioteca de medios",
"TaskRefreshChapterImagesDescription": "Crea miniaturas dos vídeos que teñen capítulos.",
"TaskRefreshChapterImages": "Extraer imaxes dos capítulos",
"TaskCleanCacheDescription": "Borra ficheiros da caché que xa non son necesarios para o sistema.",
"TaskCleanCache": "Limpa Directorio de Caché",
"TaskCleanActivityLogDescription": "Borra as entradas no rexistro de actividade anteriores á data configurada.",
"TaskCleanCache": "Limpar directorio de caché",
"TaskCleanActivityLogDescription": "Borra do rexistro de actividade as entradas anteriores á data configurada.",
"TasksApplicationCategory": "Aplicación",
"ValueSpecialEpisodeName": "Especial - {0}",
"ValueHasBeenAddedToLibrary": "{0} foi engadido a túa libraría multimedia",
"TasksLibraryCategory": "Libraría",
"ValueHasBeenAddedToLibrary": "{0} engadiuse á túa biblioteca de medios",
"TasksLibraryCategory": "Biblioteca",
"TasksMaintenanceCategory": "Mantemento",
"VersionNumber": "Versión {0}",
"UserPolicyUpdatedWithName": "A política de usuario foi actualizada para {0}",
"UserPasswordChangedWithName": "Cambiouse o contrasinal para o usuario {0}",
"UserOnlineFromDevice": "{0} está en liña desde {1}",
"UserOfflineFromDevice": "{0} desconectouse desde {1}",
"TaskOptimizeDatabaseDescription": "Compacta e libera o espazo libre da base de datos. Executar esta tarefa logo de realizar mudanzas que impliquen modificacións da base de datos ou despois de escanear a biblioteca pode traer mellorías de desempeño.",
"UserOfflineFromDevice": "{0} desconectouse dende {1}",
"TaskOptimizeDatabaseDescription": "Compacta e libera espazo na base de datos. Executar esta tarefa logo de facer cambios que muden a base de datos ou despois de escanear a biblioteca pode mellorar o rendemento.",
"TaskOptimizeDatabase": "Optimizar base de datos",
"TaskKeyframeExtractorDescription": "Extrae fragmentos do vídeo para crear listas de reprodución HLS máis precisas. Podería levarlle bastante tempo.",
"TaskKeyframeExtractorDescription": "Extrae fotogramas clave dos vídeos para crear listas de reprodución HLS máis precisas. Podería levar moito tempo.",
"External": "Externo",
"HearingImpaired": "Problemas de audición",
"TaskKeyframeExtractor": "Extractor de fragmentos",
"TaskAudioNormalization": "Normalización do audio",
"TaskRefreshTrickplayImagesDescription": "Crea vistas previas de reprodución con truco para vídeos en bibliotecas activadas.",
"TaskKeyframeExtractor": "Extractor de fotogramas clave",
"TaskAudioNormalization": "Normalización de volume",
"TaskRefreshTrickplayImagesDescription": "Crea miniaturas de previsualización para os vídeos nas bibliotecas habilitadas.",
"TaskDownloadMissingLyrics": "Descargar letras que faltan",
"TaskDownloadMissingLyricsDescription": "Descargas de letras das cancións",
"TaskDownloadMissingLyricsDescription": "Descarga as letras das cancións",
"TaskCleanCollectionsAndPlaylists": "Limpar coleccións e listas de reprodución",
"TaskCleanCollectionsAndPlaylistsDescription": "Elimina elementos de coleccións e listas de reprodución que xa non existen.",
"TaskExtractMediaSegmentsDescription": "Extrae ou obtén segmentos multimedia de complementos habilitados para o Segmento de medios.",
"TaskExtractMediaSegments": "Escaneo de segmentos multimedia",
"TaskMoveTrickplayImages": "Migrar a localización da imaxe de Trickplay",
"TaskMoveTrickplayImagesDescription": "Move os ficheiros de reprodución con trickplay existentes segundo a configuración da biblioteca.",
"TaskRefreshTrickplayImages": "Xerar imaxes de Trickplay",
"TaskAudioNormalizationDescription": "Analiza ficheiros para obter datos de normalización de audio.",
"CleanupUserDataTask": "Tarefa de limpeza de datos do usuario",
"CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (Estado de visualización, estado de favorito, etc) da multimedia que leve non presente polo menos durante 90 días."
"TaskCleanCollectionsAndPlaylistsDescription": "Quita ítems que xa non existen das coleccións e listas de reprodución.",
"TaskExtractMediaSegmentsDescription": "Procura segmentos de medios cos plugins habilitados.",
"TaskExtractMediaSegments": "Escaneo de segmentos de medios",
"TaskMoveTrickplayImages": "Migrar as miniaturas de previsualización a outra ubicación",
"TaskMoveTrickplayImagesDescription": "Move as miniaturas de previsualización segundo a configuración da biblioteca.",
"TaskRefreshTrickplayImages": "Xerar miniaturas de previsualización",
"TaskAudioNormalizationDescription": "Escanea ficheiros á procura de datos de normalización de volume.",
"CleanupUserDataTask": "Tarefa de limpeza de datos dos usuarios",
"CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días."
}
@@ -125,8 +125,8 @@
"TaskKeyframeExtractor": "Izvoditelj ključnog okvira",
"TaskOptimizeDatabaseDescription": "Sažima bazu podataka i uklanja prazan prostor. Pokretanje ovog zadatka, može poboljšati performanse nakon provođenja indeksiranja biblioteke ili provođenja drugih promjena koje utječu na bazu podataka.",
"HearingImpaired": "Oštećen sluh",
"TaskRefreshTrickplayImages": "Generiraj Trickplay Slike",
"TaskRefreshTrickplayImagesDescription": "Kreira trickplay pretpreglede za videe u omogućenim knjižnicama.",
"TaskRefreshTrickplayImages": "Generiraj slike brzog pregledavanja",
"TaskRefreshTrickplayImagesDescription": "Stvara preglede brzog pregledavanja za videa u aktiviranim bibliotekama.",
"TaskAudioNormalization": "Normalizacija zvuka",
"TaskAudioNormalizationDescription": "Skenira datoteke u potrazi za podacima o normalizaciji zvuka.",
"TaskCleanCollectionsAndPlaylistsDescription": "Uklanja stavke iz zbirki i popisa za reprodukciju koje više ne postoje.",
@@ -135,8 +135,8 @@
"TaskDownloadMissingLyrics": "Preuzmi tekstove koji nedostaju",
"TaskDownloadMissingLyricsDescription": "Preuzmi tekstove pjesama",
"TaskExtractMediaSegmentsDescription": "Izvlači ili pribavlja dijelove medija iz omogućenih media pluginova.",
"TaskMoveTrickplayImages": "Preseli lokaciju Trickplay slika",
"TaskMoveTrickplayImagesDescription": "Preseli lokaciju Trickplay slika prema postavkama zbirke.",
"TaskMoveTrickplayImages": "Premjesti mjesto slika brzog pregledavanja",
"TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke brzog pregledavanja prema postavkama biblioteke.",
"CleanupUserDataTask": "Zadatak čišćenja korisničkih podataka",
"CleanupUserDataTaskDescription": "Briše sve korisničke podatke (stanje gledanja, status favorita itd.) s medija koji više nisu prisutni najmanje 90 dana."
}
@@ -1,3 +1,62 @@
{
"Books": "liv"
"Books": "Liv",
"TasksLibraryCategory": "Libreri",
"Albums": "Albòm yo",
"Artists": "Atis yo",
"Application": "Aplikasyon",
"Channels": "Kanal yo",
"ChapterNameValue": "Chapit {0}",
"Default": "Defo",
"DeviceOnlineWithName": "{0} konekte",
"DeviceOfflineWithName": "{0} dekonekte",
"External": "Extèn",
"Collections": "Koleksyon yo",
"Favorites": "Pi Renmen",
"Folders": "Dosye",
"Genres": "Jan yo",
"Forced": "Fòse",
"HeaderAlbumArtists": "Albòm Atis",
"HeaderContinueWatching": "Kontinye Kade",
"HeaderFavoriteAlbums": "Albòm Pi Renmen",
"HeaderFavoriteArtists": "Atis Pi Renmen",
"HeaderFavoriteEpisodes": "Epizòd Pi Renmen",
"HeaderFavoriteShows": "Emisyon Pi Renmen",
"HeaderFavoriteSongs": "Mizik Pi Renmen",
"HeaderLiveTV": "Televizyon an Direk",
"HeaderNextUp": "Pwochen an",
"HomeVideos": "Videyo Lakay",
"Latest": "Pi Resan",
"MessageApplicationUpdated": "Sèvè Jellyfin met a jou",
"MessageApplicationUpdatedTo": "Sèvè Jellyfin met a jou sou {0}",
"Movies": "Fim",
"MixedContent": "Kontni Melanje",
"Music": "Mizik",
"MusicVideos": "Videyo Mizik",
"NameInstallFailed": "{0} enstalasyon fe fayit",
"NameSeasonNumber": "Sezon {0}",
"NameSeasonUnknown": "Sezon Enkoni",
"NotificationOptionCameraImageUploaded": "Imaj Kamera telechaje",
"NotificationOptionInstallationFailed": "Enstalasyon echwe",
"Photos": "Foto",
"PluginInstalledWithName": "{0} te enstale",
"PluginUninstalledWithName": "{0} te dezenstale",
"PluginUpdatedWithName": "{0} te mi a jou",
"ScheduledTaskFailedWithName": "{0} echwe",
"ScheduledTaskStartedWithName": "{0} komanse",
"Songs": "Mizik yo",
"Shows": "Emisyon yo",
"System": "Sistèm",
"TvShows": "Emisyon Tele",
"User": "Itilizatè",
"UserCreatedWithName": "Itilizatè {0} kreye",
"UserDeletedWithName": "Itilizatè {0} a efase",
"UserDownloadingItemWithValues": "{0} ap telechaje {1}",
"UserOfflineFromDevice": "{0} dekonekte de {1}",
"UserStartedPlayingItemWithValues": "{0} ap jwe {1} sou {2}",
"UserStoppedPlayingItemWithValues": "{0} fin jwe {1} sou {2}",
"UserPasswordChangedWithName": "Modpas la chanje pou Itilizatè {0}",
"ValueSpecialEpisodeName": "Spesyal - {0}",
"VersionNumber": "Vesyon {0}",
"TasksApplicationCategory": "Aplikasyon",
"TasksMaintenanceCategory": "Antretyen"
}
@@ -1,16 +1,16 @@
{
"Books": "Номууд",
"Books": "Номнууд",
"HeaderNextUp": "Дараа нь",
"HeaderContinueWatching": "Үргэлжлүүлэн үзэх",
"Songs": "Дуунууд",
"Playlists": "Тоглуулах жагсаалт",
"Movies": "Кино",
"Playlists": "Playlist-ууд",
"Movies": "Кинонууд",
"Latest": "Сүүлийн үеийн",
"Genres": "Төрлүүд",
"Favorites": "Дуртай",
"Collections": "Багц",
"Collections": "Цуглуулгууд",
"Artists": "Уран бүтээлчид",
"Albums": "Цомгууд",
"Albums": "Дуут цомгууд",
"TaskExtractMediaSegments": "Медиа сегмент шалга",
"TaskExtractMediaSegmentsDescription": "MediaSegment идэвхжүүлсэн залгаасуудаас медиа сегментүүдийг задлах эсвэл олж авах.",
"TaskMoveTrickplayImages": "Трикплэй зургуудын байршлыг шилжүүлэх",
@@ -63,11 +63,11 @@
"CameraImageUploadedFrom": "{0}-с шинэ зураг байршуулагдлаа",
"Channels": "Сувгууд",
"ChapterNameValue": "{0}-р бүлэг",
"Default": "Өгөгдмөл",
"Default": "Анхдагч",
"DeviceOfflineWithName": "{0}-н холболт саллаа",
"DeviceOnlineWithName": "{0} холбогдлоо",
"FailedLoginAttemptWithUserName": "{0}-н нэвтрэх оролдлого амжилтгүй",
"Folders": "Хавтаснууд",
"Folders": "Хавтасууд",
"Forced": "Хүчээр",
"HeaderAlbumArtists": "Цомгийн уран бүтээлчид",
"HeaderFavoriteAlbums": "Дуртай цомгууд",
@@ -84,8 +84,8 @@
"MessageApplicationUpdatedTo": "Jellyfin Server {0} болж шинэчлэгдлээ",
"MessageServerConfigurationUpdated": "Server-н тохиргоо шинэчлэгдлээ",
"MixedContent": "Холимог агуулга",
"Music": "Дуу",
"MusicVideos": "Дууны клип",
"Music": "Хөгжим",
"MusicVideos": "Дууны клипүүд",
"NameInstallFailed": "{0} суулгахад алдаа гарлаа",
"NameSeasonNumber": "{0}-р улирал",
"NameSeasonUnknown": "Улирал олдсонгүй",
@@ -101,14 +101,14 @@
"NotificationOptionUserLockedOut": "Хэрэглэгчийг түгжив",
"NotificationOptionVideoPlayback": "Бичлэгийг тоглуулж эхлэв",
"Photos": "Зургууд",
"Plugin": "Plugin",
"Plugin": "Плагин",
"PluginInstalledWithName": "{0}-г суулгалаа",
"PluginUninstalledWithName": "{0}-г устгалаа",
"PluginUpdatedWithName": "{0}-г шинэчиллээ",
"ProviderValue": "Нийлүүлэгч: {0}",
"ScheduledTaskStartedWithName": "{0}-г эхлүүлэв",
"ServerNameNeedsToBeRestarted": "{0}-г дахин асаана уу",
"Shows": "Нэвтрүүлгүүд",
"Shows": "Шоу",
"Sync": "Дахин",
"System": "Систем",
"TvShows": "ТВ нэвтрүүлгүүд",
@@ -122,7 +122,7 @@
"UserPolicyUpdatedWithName": "Хэрэглэгчийн журмыг {0}-д зориулан шинэчиллээ",
"UserStartedPlayingItemWithValues": "{0}-г {2} дээр {1}-г тоглуулж байна",
"UserStoppedPlayingItemWithValues": "{0}-г {2} дээр {1}-г тоглуулж дуусгалаа",
"ValueSpecialEpisodeName": "Тусгай - {0}",
"ValueSpecialEpisodeName": "Онцгой - {0}",
"VersionNumber": "Хувилбар {0}",
"TasksMaintenanceCategory": "Засвар",
"TasksLibraryCategory": "Сан",
@@ -1,3 +1,16 @@
{
"Books": "کتابیں"
"Books": "کتابیں",
"AppDeviceValues": "ایپ: {0}، ڈیوائس: {1}",
"Albums": "البمز",
"Application": "ایپلی کیشن",
"Artists": "فنکار",
"AuthenticationSucceededWithUserName": "{0} کی کامیابی سے تصدیق ہو چکی ہے",
"CameraImageUploadedFrom": "ایک نئی کیمرے کی تصویر {0} سے اپ لوڈ کی گئی ہے",
"Channels": "چینلز",
"ChapterNameValue": "باب {0}",
"Collections": "مجموعے",
"Default": "ڈیفالٹ",
"DeviceOfflineWithName": "{0} نے رابطہ منقطع کر دیا ہے",
"DeviceOnlineWithName": "{0} منسلک ہے",
"External": "بیرونی"
}
@@ -123,5 +123,9 @@
"TaskCleanActivityLogDescription": "تشکیل شدہ عمر سے زیادہ پرانی سرگرمی لاگ اندراجات کو حذف کرتا ہے۔",
"External": "بیرونی",
"HearingImpaired": "قوت سماعت سے محروم",
"TaskCleanActivityLog": "سرگرمی لاگ کو صاف کریں"
"TaskCleanActivityLog": "سرگرمی لاگ کو صاف کریں",
"TaskDownloadMissingLyrics": "غائب بول ڈاؤن لوڈ کریں",
"TaskDownloadMissingLyricsDescription": "گانے کے غائب بول ڈاؤن لوڈ کریں",
"TaskAudioNormalization": "آڈیو نارملائزیشن",
"TaskAudioNormalizationDescription": "آڈیو نارملائزیشن ڈیٹا کے لیے فائلوں کو سکین کرتا ہے۔"
}
@@ -38,6 +38,7 @@ namespace Emby.Server.Implementations.Localization
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
private readonly ConcurrentDictionary<string, CultureDto?> _cultureCache = new(StringComparer.OrdinalIgnoreCase);
private List<CultureDto> _cultures = [];
private FrozenDictionary<string, string> _iso6392BtoT = null!;
@@ -161,6 +162,7 @@ namespace Emby.Server.Implementations.Localization
list.Add(new CultureDto(name, displayname, twoCharName, threeLetterNames));
}
_cultureCache.Clear();
_cultures = list;
_iso6392BtoT = iso6392BtoTdict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
}
@@ -169,20 +171,31 @@ namespace Emby.Server.Implementations.Localization
/// <inheritdoc />
public CultureDto? FindLanguageInfo(string language)
{
// TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs
for (var i = 0; i < _cultures.Count; i++)
if (string.IsNullOrEmpty(language))
{
var culture = _cultures[i];
if (language.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase)
|| language.Equals(culture.Name, StringComparison.OrdinalIgnoreCase)
|| culture.ThreeLetterISOLanguageNames.Contains(language, StringComparison.OrdinalIgnoreCase)
|| language.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase))
{
return culture;
}
return null;
}
return default;
return _cultureCache.GetOrAdd(
language,
static (lang, cultures) =>
{
// TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs
for (var i = 0; i < cultures.Count; i++)
{
var culture = cultures[i];
if (lang.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase)
|| lang.Equals(culture.Name, StringComparison.OrdinalIgnoreCase)
|| culture.ThreeLetterISOLanguageNames.Contains(lang, StringComparison.OrdinalIgnoreCase)
|| lang.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase))
{
return culture;
}
}
return null;
},
_cultures);
}
/// <inheritdoc />
@@ -311,15 +324,19 @@ namespace Emby.Server.Implementations.Localization
else
{
// Fall back to server default language for ratings check
// If it has no ratings, use the US ratings
var ratingsDictionary = GetParentalRatingsDictionary() ?? GetParentalRatingsDictionary("us");
var ratingsDictionary = GetParentalRatingsDictionary();
if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRatingScore? value))
{
return value;
}
}
// If we don't find anything, check all ratings systems
// If we don't find anything, check all ratings systems, starting with US
if (_allParentalRatings.TryGetValue("us", out var usRatings) && usRatings.TryGetValue(rating, out var usValue))
{
return usValue;
}
foreach (var dictionary in _allParentalRatings.Values)
{
if (dictionary.TryGetValue(rating, out var value))
@@ -347,8 +347,8 @@ pli||pi|Pali|pali
pol||pl|Polish|polonais
pon|||Pohnpeian|pohnpei
por||pt|Portuguese|portugais
por||pt-pt|Portuguese (Portugal)|portugais (pt-pt)
por||pt-br|Portuguese (Brazil)|portugais (pt-br)
pop||pt-pt|Portuguese (Portugal)|portugais (pt-pt)
pob||pt-br|Portuguese (Brazil)|portugais (pt-br)
pra|||Prakrit languages|prâkrit, langues
pro|||Provençal, Old (to 1500)|provençal ancien (jusqu'à 1500)
pus||ps|Pushto; Pashto|pachto
@@ -0,0 +1,270 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.MediaEncoding;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using StackExchange.Redis;
namespace Emby.Server.Implementations.MediaEncoding;
/// <summary>
/// A Redis-backed implementation of <see cref="ITranscodeSessionStore"/> that provides
/// durable, distributed session tracking with lease-based ownership between pods.
/// </summary>
public sealed class RedisTranscodeSessionStore : ITranscodeSessionStore
{
private const string KeyPrefix = "jellyfin:transcode:";
private const string LiveStreamKeyPrefix = "jellyfin:livestream:";
/// <summary>
/// Lua script for atomic takeover: reads the stored session, checks whether the lease has
/// expired (comparing <c>LeaseExpiresUtc.Ticks</c> against the caller-supplied current ticks),
/// and if expired, updates the owner and expiry before returning 1; returns 0 otherwise.
/// </summary>
private const string TakeoverScript = @"
local raw = redis.call('GET', KEYS[1])
if not raw then return 0 end
local session = cjson.decode(raw)
local currentTicks = tonumber(ARGV[1])
if session['LeaseExpiresUtc'] > currentTicks then return 0 end
session['OwnerPod'] = ARGV[2]
local leaseDurationMs = tonumber(ARGV[3])
local newTicks = currentTicks + (leaseDurationMs * 10000)
session['LeaseExpiresUtc'] = newTicks
redis.call('SET', KEYS[1], cjson.encode(session), 'PX', leaseDurationMs)
return 1";
private readonly IConnectionMultiplexer _redis;
private readonly IDatabase _db;
private readonly TranscodeStoreOptions _options;
private readonly ILogger<RedisTranscodeSessionStore> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="RedisTranscodeSessionStore"/> class.
/// </summary>
/// <param name="redis">The Redis connection multiplexer.</param>
/// <param name="options">The transcode store configuration options.</param>
/// <param name="logger">The logger.</param>
public RedisTranscodeSessionStore(
IConnectionMultiplexer redis,
IOptions<TranscodeStoreOptions> options,
ILogger<RedisTranscodeSessionStore> logger)
{
_redis = redis;
_db = redis.GetDatabase();
_options = options.Value;
_logger = logger;
}
/// <inheritdoc />
public async Task SetAsync(TranscodeSession session, CancellationToken cancellationToken = default)
{
var key = GetKey(session.PlaySessionId);
var json = JsonSerializer.Serialize(session);
var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000;
await _db.StringSetAsync(key, json, TimeSpan.FromMilliseconds(leaseDurationMs)).ConfigureAwait(false);
_logger.LogDebug("Set transcode session {PlaySessionId} in Redis.", session.PlaySessionId);
}
/// <inheritdoc />
public async Task<TranscodeSession?> TryGetAsync(string playSessionId, CancellationToken cancellationToken = default)
{
var key = GetKey(playSessionId);
var raw = await _db.StringGetAsync(key).ConfigureAwait(false);
if (!raw.HasValue)
{
return null;
}
var session = JsonSerializer.Deserialize<TranscodeSession>(raw.ToString());
// Check LeaseExpiresUtc in addition to Redis TTL to guard against the window between
// Redis TTL evaluation and the GET result being returned to the caller.
if (session is null || session.LeaseExpiresUtc <= DateTime.UtcNow)
{
return null;
}
return session;
}
/// <inheritdoc />
public async Task RenewLeaseAsync(string playSessionId, CancellationToken cancellationToken = default)
{
var key = GetKey(playSessionId);
var raw = await _db.StringGetAsync(key).ConfigureAwait(false);
if (!raw.HasValue)
{
return;
}
var session = JsonSerializer.Deserialize<TranscodeSession>(raw.ToString());
if (session is null)
{
return;
}
var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000;
session.LeaseExpiresUtc = DateTime.UtcNow.AddMilliseconds(leaseDurationMs);
var json = JsonSerializer.Serialize(session);
await _db.StringSetAsync(key, json, TimeSpan.FromMilliseconds(leaseDurationMs)).ConfigureAwait(false);
_logger.LogDebug("Renewed lease for transcode session {PlaySessionId}.", playSessionId);
}
/// <inheritdoc />
public async Task DeleteAsync(string playSessionId, CancellationToken cancellationToken = default)
{
var key = GetKey(playSessionId);
await _db.KeyDeleteAsync(key).ConfigureAwait(false);
_logger.LogDebug("Deleted transcode session {PlaySessionId} from Redis.", playSessionId);
}
/// <inheritdoc />
public async Task<bool> TryTakeoverAsync(string playSessionId, string claimingPod, CancellationToken cancellationToken = default)
{
var key = GetKey(playSessionId);
var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000;
var currentTicks = DateTime.UtcNow.Ticks;
var result = (long?)await _db.ScriptEvaluateAsync(
TakeoverScript,
keys: new RedisKey[] { key },
values: new RedisValue[] { currentTicks, claimingPod, leaseDurationMs }).ConfigureAwait(false);
var succeeded = result == 1;
if (succeeded)
{
_logger.LogInformation("Pod {ClaimingPod} successfully took over transcode session {PlaySessionId}.", claimingPod, playSessionId);
}
return succeeded;
}
private static string GetKey(string playSessionId) => KeyPrefix + playSessionId;
private static string GetLiveStreamKey(string liveStreamId, string sessionIdOrPlaySessionId)
=> LiveStreamKeyPrefix + liveStreamId + ":" + sessionIdOrPlaySessionId;
/// <inheritdoc />
public async Task<IEnumerable<TranscodeSession>> GetActiveSessionsAsync(CancellationToken cancellationToken = default)
{
var sessions = new List<TranscodeSession>();
var servers = _redis.GetServers();
foreach (var server in servers)
{
if (!server.IsConnected)
{
continue;
}
var keys = new List<RedisKey>();
await foreach (var key in server.KeysAsync(database: _db.Database, pattern: KeyPrefix + "*", pageSize: 1000).WithCancellation(cancellationToken).ConfigureAwait(false))
{
keys.Add(key);
}
var tasks = keys.Select(key => _db.StringGetAsync(key)).ToList();
var values = await Task.WhenAll(tasks).ConfigureAwait(false);
foreach (var raw in values)
{
if (!raw.HasValue)
{
continue;
}
TranscodeSession? session;
try
{
session = JsonSerializer.Deserialize<TranscodeSession>(raw.ToString());
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to deserialize transcode session from Redis.");
continue;
}
if (session is not null)
{
sessions.Add(session);
}
}
}
return sessions;
}
/// <inheritdoc />
public async Task SetLiveStreamAsync(LiveStreamSession session, CancellationToken cancellationToken = default)
{
var key = GetLiveStreamKey(session.LiveStreamId, session.SessionId);
var json = JsonSerializer.Serialize(session);
// Live stream records use the same lease duration as transcode sessions.
var leaseDurationMs = (long)_options.LeaseDurationSeconds * 1000;
await _db.StringSetAsync(key, json, TimeSpan.FromMilliseconds(leaseDurationMs)).ConfigureAwait(false);
// Also index by play session id so the caller can look up by either key.
if (!string.IsNullOrEmpty(session.PlaySessionId))
{
var playKey = GetLiveStreamKey(session.LiveStreamId, session.PlaySessionId);
await _db.StringSetAsync(playKey, json, TimeSpan.FromMilliseconds(leaseDurationMs)).ConfigureAwait(false);
}
_logger.LogDebug(
"Set live stream session {LiveStreamId}/{SessionId} in Redis.",
session.LiveStreamId,
session.SessionId);
}
/// <inheritdoc />
public async Task<LiveStreamSession?> TryGetLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default)
{
var key = GetLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId);
var raw = await _db.StringGetAsync(key).ConfigureAwait(false);
if (!raw.HasValue)
{
return null;
}
return JsonSerializer.Deserialize<LiveStreamSession>(raw.ToString());
}
/// <inheritdoc />
public async Task DeleteLiveStreamAsync(string liveStreamId, string sessionIdOrPlaySessionId, CancellationToken cancellationToken = default)
{
var key = GetLiveStreamKey(liveStreamId, sessionIdOrPlaySessionId);
var raw = await _db.StringGetAsync(key).ConfigureAwait(false);
if (raw.HasValue)
{
var session = JsonSerializer.Deserialize<LiveStreamSession>(raw.ToString());
if (session is not null)
{
// Remove both the session-id key and the play-session-id key if present.
var keysToDelete = new System.Collections.Generic.List<RedisKey>
{
GetLiveStreamKey(liveStreamId, session.SessionId)
};
if (!string.IsNullOrEmpty(session.PlaySessionId))
{
keysToDelete.Add(GetLiveStreamKey(liveStreamId, session.PlaySessionId));
}
await _db.KeyDeleteAsync(keysToDelete.ToArray()).ConfigureAwait(false);
_logger.LogDebug(
"Deleted live stream session {LiveStreamId}/{SessionId} from Redis.",
liveStreamId,
session.SessionId);
return;
}
}
// Fallback: delete just the key that was supplied.
await _db.KeyDeleteAsync(key).ConfigureAwait(false);
}
}
@@ -244,6 +244,7 @@ namespace Emby.Server.Implementations.Playlists
// Update the playlist in the repository
playlist.LinkedChildren = [.. playlist.LinkedChildren, .. childrenToAdd];
playlist.DateLastMediaAdded = DateTime.UtcNow;
await UpdatePlaylistInternal(playlist).ConfigureAwait(false);
@@ -261,14 +261,22 @@ public partial class AudioNormalizationTask : IScheduledTask
using var reader = process.StandardError;
float? lufs = null;
var foundLufs = false;
await foreach (var line in reader.ReadAllLinesAsync(cancellationToken).ConfigureAwait(false))
{
Match match = LUFSRegex().Match(line);
if (match.Success)
if (foundLufs)
{
lufs = float.Parse(match.Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat);
break;
continue;
}
Match match = LUFSRegex().Match(line);
if (!match.Success)
{
continue;
}
lufs = float.Parse(match.Groups[1].ValueSpan, CultureInfo.InvariantCulture.NumberFormat);
foundLufs = true;
}
if (lufs is null)
@@ -5,6 +5,7 @@ using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Tasks;
@@ -21,6 +22,7 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
private readonly IConfigurationManager _configurationManager;
private readonly IFileSystem _fileSystem;
private readonly ILocalizationManager _localization;
private readonly ITranscodeSessionStore _sessionStore;
/// <summary>
/// Initializes a new instance of the <see cref="DeleteTranscodeFileTask"/> class.
@@ -29,16 +31,19 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
/// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
/// <param name="sessionStore">Instance of the <see cref="ITranscodeSessionStore"/> interface.</param>
public DeleteTranscodeFileTask(
ILogger<DeleteTranscodeFileTask> logger,
IFileSystem fileSystem,
IConfigurationManager configurationManager,
ILocalizationManager localization)
ILocalizationManager localization,
ITranscodeSessionStore sessionStore)
{
_logger = logger;
_fileSystem = fileSystem;
_configurationManager = configurationManager;
_localization = localization;
_sessionStore = sessionStore;
}
/// <inheritdoc />
@@ -78,25 +83,39 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
}
/// <inheritdoc />
public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
var minDateModified = DateTime.UtcNow.AddDays(-1);
progress.Report(50);
DeleteTempFilesFromDirectory(_configurationManager.GetTranscodePath(), minDateModified, progress, cancellationToken);
IEnumerable<TranscodeSession> activeSessions;
try
{
activeSessions = await _sessionStore.GetActiveSessionsAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to retrieve active transcode sessions. Skipping deletion to avoid removing files in use.");
progress.Report(100);
return;
}
return Task.CompletedTask;
DeleteTempFilesFromDirectory(_configurationManager.GetTranscodePath(), minDateModified, activeSessions, progress, cancellationToken);
}
/// <summary>
/// Deletes the transcoded temp files from directory with a last write time less than a given date.
/// Deletes the transcoded temp files from directory with a last write time less than a given date,
/// skipping any files that belong to an active transcode session.
/// </summary>
/// <param name="directory">The directory.</param>
/// <param name="minDateModified">The min date modified.</param>
/// <param name="activeSessions">The currently active transcode sessions.</param>
/// <param name="progress">The progress.</param>
/// <param name="cancellationToken">The task cancellation token.</param>
private void DeleteTempFilesFromDirectory(string directory, DateTime minDateModified, IProgress<double> progress, CancellationToken cancellationToken)
private void DeleteTempFilesFromDirectory(string directory, DateTime minDateModified, IEnumerable<TranscodeSession> activeSessions, IProgress<double> progress, CancellationToken cancellationToken)
{
var activeSessionList = activeSessions.ToList();
var filesToDelete = _fileSystem.GetFiles(directory, true)
.Where(f => _fileSystem.GetLastWriteTimeUtc(f) < minDateModified)
.ToList();
@@ -112,6 +131,13 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
cancellationToken.ThrowIfCancellationRequested();
if (IsFileProtectedByActiveSession(file.FullName, activeSessionList))
{
_logger.LogDebug("Skipping deletion of {FilePath} as it belongs to an active transcode session.", file.FullName);
index++;
continue;
}
FileSystemHelper.DeleteFile(_fileSystem, file.FullName, _logger);
index++;
@@ -121,4 +147,24 @@ public class DeleteTranscodeFileTask : IScheduledTask, IConfigurableScheduledTas
progress.Report(100);
}
private static bool IsFileProtectedByActiveSession(string filePath, IList<TranscodeSession> activeSessions)
{
foreach (var session in activeSessions)
{
if (!string.IsNullOrEmpty(session.ManifestPath) &&
string.Equals(filePath, session.ManifestPath, StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (!string.IsNullOrEmpty(session.SegmentPathPrefix) &&
filePath.StartsWith(session.SegmentPathPrefix, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}
@@ -28,6 +28,7 @@ using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Events.Authentication;
using MediaBrowser.Controller.Events.Session;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Dto;
@@ -60,6 +61,7 @@ namespace Emby.Server.Implementations.Session
private readonly IMediaSourceManager _mediaSourceManager;
private readonly IServerApplicationHost _appHost;
private readonly IDeviceManager _deviceManager;
private readonly ITranscodeSessionStore _transcodeSessionStore;
private readonly CancellationTokenRegistration _shutdownCallback;
private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections
= new(StringComparer.OrdinalIgnoreCase);
@@ -89,6 +91,7 @@ namespace Emby.Server.Implementations.Session
/// <param name="deviceManager">Instance of <see cref="IDeviceManager"/> interface.</param>
/// <param name="mediaSourceManager">Instance of <see cref="IMediaSourceManager"/> interface.</param>
/// <param name="hostApplicationLifetime">Instance of <see cref="IHostApplicationLifetime"/> interface.</param>
/// <param name="transcodeSessionStore">Instance of <see cref="ITranscodeSessionStore"/> interface.</param>
public SessionManager(
ILogger<SessionManager> logger,
IEventManager eventManager,
@@ -102,7 +105,8 @@ namespace Emby.Server.Implementations.Session
IServerApplicationHost appHost,
IDeviceManager deviceManager,
IMediaSourceManager mediaSourceManager,
IHostApplicationLifetime hostApplicationLifetime)
IHostApplicationLifetime hostApplicationLifetime,
ITranscodeSessionStore transcodeSessionStore)
{
_logger = logger;
_eventManager = eventManager;
@@ -116,6 +120,7 @@ namespace Emby.Server.Implementations.Session
_appHost = appHost;
_deviceManager = deviceManager;
_mediaSourceManager = mediaSourceManager;
_transcodeSessionStore = transcodeSessionStore;
_shutdownCallback = hostApplicationLifetime.ApplicationStopping.Register(OnApplicationStopping);
_deviceManager.DeviceOptionsUpdated += OnDeviceManagerDeviceOptionsUpdated;
@@ -343,9 +348,38 @@ namespace Emby.Server.Implementations.Session
_activeLiveStreamSessions.TryRemove(liveStreamId, out _);
}
}
else
{
// In-memory state is absent — this pod may have taken over from a crashed pod.
// Check the durable store to determine whether the live stream record exists.
LiveStreamSession durableRecord = null;
try
{
durableRecord = await _transcodeSessionStore.TryGetLiveStreamAsync(liveStreamId, sessionIdOrPlaySessionId).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to query live stream session {LiveStreamId}/{SessionId} from durable store.", liveStreamId, sessionIdOrPlaySessionId);
}
if (durableRecord is not null)
{
liveStreamNeedsToBeClosed = true;
}
}
// Remove the durable record regardless of which code path set liveStreamNeedsToBeClosed.
if (liveStreamNeedsToBeClosed)
{
try
{
await _transcodeSessionStore.DeleteLiveStreamAsync(liveStreamId, sessionIdOrPlaySessionId).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete live stream session {LiveStreamId}/{SessionId} from durable store.", liveStreamId, sessionIdOrPlaySessionId);
}
try
{
await _mediaSourceManager.CloseLiveStream(liveStreamId).ConfigureAwait(false);
@@ -776,7 +810,7 @@ namespace Emby.Server.Implementations.Session
if (!string.IsNullOrEmpty(info.LiveStreamId))
{
UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId);
await UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId).ConfigureAwait(false);
}
var eventArgs = new PlaybackStartEventArgs
@@ -836,7 +870,7 @@ namespace Emby.Server.Implementations.Session
return OnPlaybackProgress(info, false);
}
private void UpdateLiveStreamActiveSessionMappings(string liveStreamId, string sessionId, string playSessionId)
private async Task UpdateLiveStreamActiveSessionMappings(string liveStreamId, string sessionId, string playSessionId)
{
var activeSessionMappings = _activeLiveStreamSessions.GetOrAdd(liveStreamId, _ => new ConcurrentDictionary<string, string>());
@@ -860,6 +894,26 @@ namespace Emby.Server.Implementations.Session
activeSessionMappings[sessionId] = string.Empty;
}
}
// Persist to the durable store so a takeover pod can discover open live streams.
var ownerPod = Environment.GetEnvironmentVariable("JELLYFIN_INSTANCE_ID") ?? Environment.MachineName;
var liveStreamSession = new LiveStreamSession
{
LiveStreamId = liveStreamId,
SessionId = sessionId,
PlaySessionId = playSessionId ?? string.Empty,
OwnerPod = ownerPod,
OpenedAtUtc = DateTime.UtcNow,
};
try
{
await _transcodeSessionStore.SetLiveStreamAsync(liveStreamSession).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to persist live stream session {LiveStreamId}/{SessionId} to durable store.", liveStreamId, sessionId);
}
}
/// <summary>
@@ -904,7 +958,7 @@ namespace Emby.Server.Implementations.Session
if (!string.IsNullOrEmpty(info.LiveStreamId))
{
UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId);
await UpdateLiveStreamActiveSessionMappings(info.LiveStreamId, info.SessionId, info.PlaySessionId).ConfigureAwait(false);
}
var eventArgs = new PlaybackProgressEventArgs
@@ -1175,7 +1229,8 @@ namespace Emby.Server.Implementations.Session
return session;
}
private SessionInfoDto ToSessionInfoDto(SessionInfo sessionInfo)
/// <inheritdoc />
public SessionInfoDto ToSessionInfoDto(SessionInfo sessionInfo)
{
return new SessionInfoDto
{
@@ -156,6 +156,11 @@ namespace Emby.Server.Implementations.Updates
_logger.LogError(ex, "The URL configured for the plugin repository manifest URL is not valid: {Manifest}", manifest);
return Array.Empty<PackageInfo>();
}
catch (NotSupportedException ex)
{
_logger.LogError(ex, "The URL scheme configured for the plugin repository is not supported: {Manifest}", manifest);
return Array.Empty<PackageInfo>();
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "An error occurred while accessing the plugin manifest: {Manifest}", manifest);
@@ -223,15 +228,14 @@ namespace Emby.Server.Implementations.Updates
Guid id = default,
Version? specificVersion = null)
{
if (name is not null)
{
availablePackages = availablePackages.Where(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
}
if (!id.IsEmpty())
{
availablePackages = availablePackages.Where(x => x.Id.Equals(id));
}
else if (name is not null)
{
availablePackages = availablePackages.Where(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
}
if (specificVersion is not null)
{
+12 -12
View File
@@ -92,18 +92,18 @@ public class AudioController : BaseJellyfinApiController
[ProducesAudioFile]
public async Task<ActionResult> GetAudioStream(
[FromRoute, Required] Guid itemId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? container,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container,
[FromQuery] bool? @static,
[FromQuery] string? @params,
[FromQuery] string? tag,
[FromQuery, ParameterObsolete] string? deviceProfileId,
[FromQuery] string? playSessionId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
[FromQuery] int? segmentLength,
[FromQuery] int? minSegments,
[FromQuery] string? mediaSourceId,
[FromQuery] string? deviceId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
[FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy,
@@ -114,7 +114,7 @@ public class AudioController : BaseJellyfinApiController
[FromQuery] int? audioChannels,
[FromQuery] int? maxAudioChannels,
[FromQuery] string? profile,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
[FromQuery] float? framerate,
[FromQuery] float? maxFramerate,
[FromQuery] bool? copyTimestamps,
@@ -133,8 +133,8 @@ public class AudioController : BaseJellyfinApiController
[FromQuery] int? cpuCoreLimit,
[FromQuery] string? liveStreamId,
[FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
[FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
@@ -259,18 +259,18 @@ public class AudioController : BaseJellyfinApiController
[ProducesAudioFile]
public async Task<ActionResult> GetAudioStreamByContainer(
[FromRoute, Required] Guid itemId,
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container,
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
[FromQuery] bool? @static,
[FromQuery] string? @params,
[FromQuery] string? tag,
[FromQuery, ParameterObsolete] string? deviceProfileId,
[FromQuery] string? playSessionId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
[FromQuery] int? segmentLength,
[FromQuery] int? minSegments,
[FromQuery] string? mediaSourceId,
[FromQuery] string? deviceId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
[FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy,
@@ -281,7 +281,7 @@ public class AudioController : BaseJellyfinApiController
[FromQuery] int? audioChannels,
[FromQuery] int? maxAudioChannels,
[FromQuery] string? profile,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
[FromQuery] float? framerate,
[FromQuery] float? maxFramerate,
[FromQuery] bool? copyTimestamps,
@@ -300,8 +300,8 @@ public class AudioController : BaseJellyfinApiController
[FromQuery] int? cpuCoreLimit,
[FromQuery] string? liveStreamId,
[FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
[FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
+194 -52
View File
@@ -60,6 +60,7 @@ public class DynamicHlsController : BaseJellyfinApiController
private readonly IDynamicHlsPlaylistGenerator _dynamicHlsPlaylistGenerator;
private readonly DynamicHlsHelper _dynamicHlsHelper;
private readonly EncodingOptions _encodingOptions;
private readonly ITranscodeSessionStore _transcodeSessionStore;
/// <summary>
/// Initializes a new instance of the <see cref="DynamicHlsController"/> class.
@@ -75,6 +76,7 @@ public class DynamicHlsController : BaseJellyfinApiController
/// <param name="dynamicHlsHelper">Instance of <see cref="DynamicHlsHelper"/>.</param>
/// <param name="encodingHelper">Instance of <see cref="EncodingHelper"/>.</param>
/// <param name="dynamicHlsPlaylistGenerator">Instance of <see cref="IDynamicHlsPlaylistGenerator"/>.</param>
/// <param name="transcodeSessionStore">Instance of the <see cref="ITranscodeSessionStore"/> interface used to register and renew HLS transcoding session leases in the durable store.</param>
public DynamicHlsController(
ILibraryManager libraryManager,
IUserManager userManager,
@@ -86,7 +88,8 @@ public class DynamicHlsController : BaseJellyfinApiController
ILogger<DynamicHlsController> logger,
DynamicHlsHelper dynamicHlsHelper,
EncodingHelper encodingHelper,
IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator)
IDynamicHlsPlaylistGenerator dynamicHlsPlaylistGenerator,
ITranscodeSessionStore transcodeSessionStore)
{
_libraryManager = libraryManager;
_userManager = userManager;
@@ -99,6 +102,7 @@ public class DynamicHlsController : BaseJellyfinApiController
_dynamicHlsHelper = dynamicHlsHelper;
_encodingHelper = encodingHelper;
_dynamicHlsPlaylistGenerator = dynamicHlsPlaylistGenerator;
_transcodeSessionStore = transcodeSessionStore;
_encodingOptions = serverConfigurationManager.GetEncodingOptions();
}
@@ -167,18 +171,18 @@ public class DynamicHlsController : BaseJellyfinApiController
[ProducesPlaylistFile]
public async Task<ActionResult> GetLiveHlsStream(
[FromRoute, Required] Guid itemId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? container,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container,
[FromQuery] bool? @static,
[FromQuery] string? @params,
[FromQuery] string? tag,
[FromQuery, ParameterObsolete] string? deviceProfileId,
[FromQuery] string? playSessionId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
[FromQuery] int? segmentLength,
[FromQuery] int? minSegments,
[FromQuery] string? mediaSourceId,
[FromQuery] string? deviceId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
[FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy,
@@ -189,7 +193,7 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? audioChannels,
[FromQuery] int? maxAudioChannels,
[FromQuery] string? profile,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
[FromQuery] float? framerate,
[FromQuery] float? maxFramerate,
[FromQuery] bool? copyTimestamps,
@@ -208,8 +212,8 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? cpuCoreLimit,
[FromQuery] string? liveStreamId,
[FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
[FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
@@ -309,15 +313,36 @@ public class DynamicHlsController : BaseJellyfinApiController
// If the playlist doesn't already exist, startup ffmpeg
try
{
// Check whether this session is already registered in the HA store (takeover scenario).
var isHaMode = false;
if (!string.IsNullOrEmpty(playSessionId))
{
try
{
var existingSession = await _transcodeSessionStore.TryGetAsync(playSessionId, cancellationToken).ConfigureAwait(false);
isHaMode = existingSession is not null;
}
catch (Exception haEx)
{
_logger.LogWarning(haEx, "Failed to check HA mode for live-stream session {PlaySessionId}.", playSessionId);
}
}
job = await _transcodeManager.StartFfMpeg(
state,
playlistPath,
GetCommandLineArguments(playlistPath, state, true, 0),
GetCommandLineArguments(playlistPath, state, true, 0, isHaMode),
Request.HttpContext.User.GetUserId(),
TranscodingJobType,
cancellationTokenSource)
.ConfigureAwait(false);
job.IsLiveOutput = true;
await RegisterTranscodeSessionAsync(
playSessionId ?? string.Empty,
mediaSourceId ?? string.Empty,
cancellationToken)
.ConfigureAwait(false);
StartLeaseRenewal(playSessionId ?? string.Empty, cancellationToken);
}
catch
{
@@ -416,12 +441,12 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] string? tag,
[FromQuery, ParameterObsolete] string? deviceProfileId,
[FromQuery] string? playSessionId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
[FromQuery] int? segmentLength,
[FromQuery] int? minSegments,
[FromQuery, Required] string mediaSourceId,
[FromQuery] string? deviceId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
[FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy,
@@ -432,7 +457,7 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? audioChannels,
[FromQuery] int? maxAudioChannels,
[FromQuery] string? profile,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
[FromQuery] float? framerate,
[FromQuery] float? maxFramerate,
[FromQuery] bool? copyTimestamps,
@@ -453,8 +478,8 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? cpuCoreLimit,
[FromQuery] string? liveStreamId,
[FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
[FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
@@ -592,12 +617,12 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] string? tag,
[FromQuery, ParameterObsolete] string? deviceProfileId,
[FromQuery] string? playSessionId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
[FromQuery] int? segmentLength,
[FromQuery] int? minSegments,
[FromQuery, Required] string mediaSourceId,
[FromQuery] string? deviceId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
[FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy,
@@ -609,7 +634,7 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? audioChannels,
[FromQuery] int? maxAudioChannels,
[FromQuery] string? profile,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
[FromQuery] float? framerate,
[FromQuery] float? maxFramerate,
[FromQuery] bool? copyTimestamps,
@@ -628,8 +653,8 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? cpuCoreLimit,
[FromQuery] string? liveStreamId,
[FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
[FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
@@ -762,12 +787,12 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] string? tag,
[FromQuery, ParameterObsolete] string? deviceProfileId,
[FromQuery] string? playSessionId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
[FromQuery] int? segmentLength,
[FromQuery] int? minSegments,
[FromQuery] string? mediaSourceId,
[FromQuery] string? deviceId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
[FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy,
@@ -778,7 +803,7 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? audioChannels,
[FromQuery] int? maxAudioChannels,
[FromQuery] string? profile,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
[FromQuery] float? framerate,
[FromQuery] float? maxFramerate,
[FromQuery] bool? copyTimestamps,
@@ -799,8 +824,8 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? cpuCoreLimit,
[FromQuery] string? liveStreamId,
[FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
[FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
@@ -934,12 +959,12 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] string? tag,
[FromQuery, ParameterObsolete] string? deviceProfileId,
[FromQuery] string? playSessionId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
[FromQuery] int? segmentLength,
[FromQuery] int? minSegments,
[FromQuery] string? mediaSourceId,
[FromQuery] string? deviceId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
[FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy,
@@ -951,7 +976,7 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? audioChannels,
[FromQuery] int? maxAudioChannels,
[FromQuery] string? profile,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
[FromQuery] float? framerate,
[FromQuery] float? maxFramerate,
[FromQuery] bool? copyTimestamps,
@@ -970,8 +995,8 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? cpuCoreLimit,
[FromQuery] string? liveStreamId,
[FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
[FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
@@ -1107,7 +1132,7 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromRoute, Required] Guid itemId,
[FromRoute, Required] string playlistId,
[FromRoute, Required] int segmentId,
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container,
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
[FromQuery, Required] long runtimeTicks,
[FromQuery, Required] long actualSegmentLengthTicks,
[FromQuery] bool? @static,
@@ -1115,12 +1140,12 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] string? tag,
[FromQuery, ParameterObsolete] string? deviceProfileId,
[FromQuery] string? playSessionId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
[FromQuery] int? segmentLength,
[FromQuery] int? minSegments,
[FromQuery] string? mediaSourceId,
[FromQuery] string? deviceId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
[FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy,
@@ -1131,7 +1156,7 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? audioChannels,
[FromQuery] int? maxAudioChannels,
[FromQuery] string? profile,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
[FromQuery] float? framerate,
[FromQuery] float? maxFramerate,
[FromQuery] bool? copyTimestamps,
@@ -1152,8 +1177,8 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? cpuCoreLimit,
[FromQuery] string? liveStreamId,
[FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
[FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
@@ -1292,7 +1317,7 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromRoute, Required] Guid itemId,
[FromRoute, Required] string playlistId,
[FromRoute, Required] int segmentId,
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container,
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
[FromQuery, Required] long runtimeTicks,
[FromQuery, Required] long actualSegmentLengthTicks,
[FromQuery] bool? @static,
@@ -1300,12 +1325,12 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] string? tag,
[FromQuery, ParameterObsolete] string? deviceProfileId,
[FromQuery] string? playSessionId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
[FromQuery] int? segmentLength,
[FromQuery] int? minSegments,
[FromQuery] string? mediaSourceId,
[FromQuery] string? deviceId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
[FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy,
@@ -1317,7 +1342,7 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? audioChannels,
[FromQuery] int? maxAudioChannels,
[FromQuery] string? profile,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
[FromQuery] float? framerate,
[FromQuery] float? maxFramerate,
[FromQuery] bool? copyTimestamps,
@@ -1336,8 +1361,8 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? cpuCoreLimit,
[FromQuery] string? liveStreamId,
[FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
[FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
@@ -1421,10 +1446,20 @@ public class DynamicHlsController : BaseJellyfinApiController
cancellationTokenSource.Token)
.ConfigureAwait(false);
var mediaSourceId = state.BaseRequest.MediaSourceId;
double fps = state.TargetFramerate ?? 0.0f;
int segmentLength = state.SegmentLength * 1000;
// If video is transcoded and framerate is fractional (i.e. 23.976), we need to slightly adjust segment length
if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && Math.Abs(fps - Math.Floor(fps + 0.001f)) > 0.001)
{
double nearestIntFramerate = Math.Ceiling(fps);
segmentLength = (int)Math.Ceiling(segmentLength * (nearestIntFramerate / fps));
}
var request = new CreateMainPlaylistRequest(
mediaSourceId is null ? null : Guid.Parse(mediaSourceId),
state.MediaPath,
state.SegmentLength * 1000,
segmentLength,
state.RunTimeTicks ?? 0,
state.Request.SegmentContainer ?? string.Empty,
"hls1/main/",
@@ -1525,14 +1560,35 @@ public class DynamicHlsController : BaseJellyfinApiController
streamingRequest.StartTimeTicks = streamingRequest.CurrentRuntimeTicks;
// Check whether this session is already registered in the HA store (takeover scenario).
var isHaMode = false;
if (!string.IsNullOrEmpty(streamingRequest.PlaySessionId))
{
try
{
var existingSession = await _transcodeSessionStore.TryGetAsync(streamingRequest.PlaySessionId, cancellationToken).ConfigureAwait(false);
isHaMode = existingSession is not null;
}
catch (Exception haEx)
{
_logger.LogWarning(haEx, "Failed to check HA mode for segment session {PlaySessionId}.", streamingRequest.PlaySessionId);
}
}
state.WaitForPath = segmentPath;
job = await _transcodeManager.StartFfMpeg(
state,
playlistPath,
GetCommandLineArguments(playlistPath, state, false, segmentId),
GetCommandLineArguments(playlistPath, state, false, segmentId, isHaMode),
Request.HttpContext.User.GetUserId(),
TranscodingJobType,
cancellationTokenSource).ConfigureAwait(false);
await RegisterTranscodeSessionAsync(
streamingRequest.PlaySessionId ?? string.Empty,
streamingRequest.MediaSourceId ?? string.Empty,
cancellationToken)
.ConfigureAwait(false);
StartLeaseRenewal(streamingRequest.PlaySessionId ?? string.Empty, cancellationToken);
}
catch
{
@@ -1560,6 +1616,77 @@ public class DynamicHlsController : BaseJellyfinApiController
private static double[] GetSegmentLengths(StreamState state)
=> GetSegmentLengthsInternal(state.RunTimeTicks ?? 0, state.SegmentLength);
private async Task RegisterTranscodeSessionAsync(string playSessionId, string mediaSourceId, CancellationToken cancellationToken)
{
try
{
var session = new TranscodeSession
{
PlaySessionId = playSessionId,
OwnerPod = Environment.GetEnvironmentVariable("JELLYFIN_INSTANCE_ID")
?? Environment.MachineName,
LeaseExpiresUtc = DateTime.UtcNow.AddSeconds(30),
ManifestPath = string.Empty,
SegmentPathPrefix = string.Empty,
MediaSourceId = mediaSourceId,
LastCompletedSegmentIndex = 0,
LastDurablePlaybackOffset = 0L,
};
await _transcodeSessionStore.SetAsync(session, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to register HLS session {PlaySessionId} in durable store.", playSessionId);
}
}
private void StartLeaseRenewal(string playSessionId, CancellationToken cancellationToken)
{
_ = Task.Run(
async () =>
{
try
{
while (!cancellationToken.IsCancellationRequested)
{
try
{
await Task.Delay(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
break;
}
try
{
await _transcodeSessionStore.RenewLeaseAsync(playSessionId, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to renew lease for HLS session {PlaySessionId}.", playSessionId);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Lease renewal loop for HLS session {PlaySessionId} encountered an unexpected error.", playSessionId);
}
finally
{
try
{
await _transcodeSessionStore.DeleteAsync(playSessionId, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete HLS session {PlaySessionId} from durable store.", playSessionId);
}
}
},
CancellationToken.None);
}
internal static double[] GetSegmentLengthsInternal(long runtimeTicks, int segmentlength)
{
var segmentLengthTicks = TimeSpan.FromSeconds(segmentlength).Ticks;
@@ -1581,7 +1708,7 @@ public class DynamicHlsController : BaseJellyfinApiController
return segments;
}
private string GetCommandLineArguments(string outputPath, StreamState state, bool isEventPlaylist, int startNumber)
private string GetCommandLineArguments(string outputPath, StreamState state, bool isEventPlaylist, int startNumber, bool isHaMode = false)
{
var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions);
var threads = EncodingHelper.GetNumberOfThreads(state, _encodingOptions, videoCodec);
@@ -1604,10 +1731,20 @@ public class DynamicHlsController : BaseJellyfinApiController
var outputExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
var outputTsArg = outputPrefix + "%d" + outputExtension;
// In HA mode, use shorter segments and a bounded rolling buffer for faster failover recovery.
// state.SegmentLength is already validated by the streaming pipeline; RecoverySegmentLengthSeconds
// comes from EncodingOptions (user-editable config) so it is clamped here.
var effectiveSegmentLength = isHaMode
? Math.Clamp(_encodingOptions.RecoverySegmentLengthSeconds, 1, 6)
: state.SegmentLength;
var hlsListSize = isHaMode
? Math.Clamp(_encodingOptions.RecoverySegmentBufferCount, 2, 10)
: 0;
var segmentFormat = string.Empty;
var segmentContainer = outputExtension.TrimStart('.');
var inputModifier = _encodingHelper.GetInputModifier(state, _encodingOptions, segmentContainer);
var hlsArguments = $"-hls_playlist_type {(isEventPlaylist ? "event" : "vod")} -hls_list_size 0";
var hlsArguments = $"-hls_playlist_type {(isEventPlaylist ? "event" : "vod")} -hls_list_size {hlsListSize}";
if (string.Equals(segmentContainer, "ts", StringComparison.OrdinalIgnoreCase))
{
@@ -1625,8 +1762,11 @@ public class DynamicHlsController : BaseJellyfinApiController
var useLegacySegmentOption = _mediaEncoder.EncoderVersion < _minFFmpegHlsSegmentOptions;
// fMP4 needs this flag to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT
hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont";
if (state.VideoStream is not null && state.IsOutputVideo)
{
// fMP4 needs this flag to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT
hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont";
}
segmentFormat = "fmp4" + outputFmp4HeaderArg;
}
@@ -1656,10 +1796,10 @@ public class DynamicHlsController : BaseJellyfinApiController
_encodingHelper.GetInputArgument(state, _encodingOptions, segmentContainer),
threads,
mapArgs,
GetVideoArguments(state, startNumber, isEventPlaylist, segmentContainer),
GetVideoArguments(state, startNumber, isEventPlaylist, segmentContainer, effectiveSegmentLength),
GetAudioArguments(state),
maxMuxingQueueSize,
state.SegmentLength.ToString(CultureInfo.InvariantCulture),
effectiveSegmentLength.ToString(CultureInfo.InvariantCulture),
segmentFormat,
startNumber.ToString(CultureInfo.InvariantCulture),
baseUrlParam,
@@ -1801,8 +1941,9 @@ public class DynamicHlsController : BaseJellyfinApiController
/// <param name="startNumber">The first number in the hls sequence.</param>
/// <param name="isEventPlaylist">Whether the playlist is EVENT or VOD.</param>
/// <param name="segmentContainer">The segment container.</param>
/// <param name="segmentLength">The effective segment length in seconds (overrides <see cref="StreamState.SegmentLength"/> when HA mode is active).</param>
/// <returns>The command line arguments for video transcoding.</returns>
private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist, string segmentContainer)
private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist, string segmentContainer, int? segmentLength = null)
{
if (state.VideoStream is null)
{
@@ -1836,8 +1977,9 @@ public class DynamicHlsController : BaseJellyfinApiController
{
if (isActualOutputVideoCodecHevc)
{
// Prefer dvh1 to dvhe
args += " -tag:v:0 dvh1 -strict -2";
// Use hvc1 for 8.4. This is what Dolby uses for its official sample streams. Tagging with dvh1 would break some players with strict tag checking like Apple Safari.
var codecTag = state.VideoStream.VideoRangeType == VideoRangeType.DOVIWithHLG ? "hvc1" : "dvh1";
args += $" -tag:v:0 {codecTag} -strict -2";
}
else if (isActualOutputVideoCodecAv1)
{
@@ -1876,7 +2018,7 @@ public class DynamicHlsController : BaseJellyfinApiController
args += _encodingHelper.GetVideoQualityParam(state, codec, _encodingOptions, isEventPlaylist ? DefaultEventEncoderPreset : DefaultVodEncoderPreset);
// Set the key frame params for video encoding to match the hls segment time.
args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, state.SegmentLength, isEventPlaylist, startNumber);
args += _encodingHelper.GetHlsVideoKeyFrameArguments(state, codec, segmentLength ?? state.SegmentLength, isEventPlaylist, startNumber);
// Currently b-frames in libx265 breaks the FMP4-HLS playback on iOS, disable it for now.
if (string.Equals(codec, "libx265", StringComparison.OrdinalIgnoreCase)
@@ -418,7 +418,7 @@ public class ItemUpdateController : BaseJellyfinApiController
{
if (item is IHasAlbumArtist hasAlbumArtists)
{
hasAlbumArtists.AlbumArtists = Array.ConvertAll(request.AlbumArtists, i => i.Name);
hasAlbumArtists.AlbumArtists = Array.ConvertAll(request.AlbumArtists, i => i.Name.Trim());
}
}
@@ -426,7 +426,7 @@ public class ItemUpdateController : BaseJellyfinApiController
{
if (item is IHasArtist hasArtists)
{
hasArtists.Artists = Array.ConvertAll(request.ArtistItems, i => i.Name);
hasArtists.Artists = Array.ConvertAll(request.ArtistItems, i => i.Name.Trim());
}
}
+16 -2
View File
@@ -23,6 +23,7 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Activity;
@@ -700,7 +701,18 @@ public class LibraryController : BaseJellyfinApiController
// Quotes are valid in linux. They'll possibly cause issues here.
var filename = Path.GetFileName(item.Path)?.Replace("\"", string.Empty, StringComparison.Ordinal);
return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), filename, true);
var filePath = item.Path;
if (item.IsFileProtocol)
{
// PhysicalFile does not work well with symlinks at the moment.
var resolved = FileSystemHelper.ResolveLinkTarget(filePath, returnFinalTarget: true);
if (resolved is not null && resolved.Exists)
{
filePath = resolved.FullName;
}
}
return PhysicalFile(filePath, MimeTypes.GetMimeType(filePath), filename, true);
}
/// <summary>
@@ -779,12 +791,14 @@ public class LibraryController : BaseJellyfinApiController
var query = new InternalItemsQuery(user)
{
Genres = item.Genres,
Tags = item.Tags,
Limit = limit,
IncludeItemTypes = includeItemTypes.ToArray(),
DtoOptions = dtoOptions,
EnableTotalRecordCount = !isMovie ?? true,
EnableGroupByMetadataKey = isMovie ?? false,
ExcludeItemIds = [itemId]
ExcludeItemIds = [itemId],
OrderBy = [(ItemSortBy.Random, SortOrder.Ascending)]
};
// ExcludeArtistIds
@@ -342,6 +342,17 @@ public class LibraryStructureController : BaseJellyfinApiController
return NotFound();
}
LibraryOptions options = item.GetLibraryOptions();
foreach (var mediaPath in request.LibraryOptions!.PathInfos)
{
if (options.PathInfos.Any(i => i.Path == mediaPath.Path))
{
continue;
}
_libraryManager.CreateShortcut(item.Path, mediaPath);
}
item.UpdateLibraryOptions(request.LibraryOptions);
return NoContent();
}
+10 -10
View File
@@ -458,7 +458,7 @@ public class LiveTvController : BaseJellyfinApiController
/// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpPost("Tuners/{tunerId}/Reset")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[Authorize(Policy = Policies.LiveTvManagement)]
[Authorize(Policy = Policies.RequiresElevation)]
public async Task<ActionResult> ResetTuner([FromRoute, Required] string tunerId)
{
await _liveTvManager.ResetTuner(tunerId, CancellationToken.None).ConfigureAwait(false);
@@ -983,7 +983,7 @@ public class LiveTvController : BaseJellyfinApiController
/// <response code="200">Created tuner host returned.</response>
/// <returns>A <see cref="OkResult"/> containing the created tuner host.</returns>
[HttpPost("TunerHosts")]
[Authorize(Policy = Policies.LiveTvManagement)]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<TunerHostInfo>> AddTunerHost([FromBody] TunerHostInfo tunerHostInfo)
=> await _tunerHostManager.SaveTunerHost(tunerHostInfo).ConfigureAwait(false);
@@ -995,7 +995,7 @@ public class LiveTvController : BaseJellyfinApiController
/// <response code="204">Tuner host deleted.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpDelete("TunerHosts")]
[Authorize(Policy = Policies.LiveTvManagement)]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult DeleteTunerHost([FromQuery] string? id)
{
@@ -1028,7 +1028,7 @@ public class LiveTvController : BaseJellyfinApiController
/// <response code="200">Created listings provider returned.</response>
/// <returns>A <see cref="OkResult"/> containing the created listings provider.</returns>
[HttpPost("ListingProviders")]
[Authorize(Policy = Policies.LiveTvManagement)]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status200OK)]
[SuppressMessage("Microsoft.Performance", "CA5350:RemoveSha1", MessageId = "AddListingProvider", Justification = "Imported from ServiceStack")]
public async Task<ActionResult<ListingsProviderInfo>> AddListingProvider(
@@ -1054,7 +1054,7 @@ public class LiveTvController : BaseJellyfinApiController
/// <response code="204">Listing provider deleted.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpDelete("ListingProviders")]
[Authorize(Policy = Policies.LiveTvManagement)]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult DeleteListingProvider([FromQuery] string? id)
{
@@ -1087,7 +1087,7 @@ public class LiveTvController : BaseJellyfinApiController
/// <response code="200">Available countries returned.</response>
/// <returns>A <see cref="FileResult"/> containing the available countries.</returns>
[HttpGet("ListingProviders/SchedulesDirect/Countries")]
[Authorize(Policy = Policies.LiveTvAccess)]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesFile(MediaTypeNames.Application.Json)]
public async Task<ActionResult> GetSchedulesDirectCountries()
@@ -1108,7 +1108,7 @@ public class LiveTvController : BaseJellyfinApiController
/// <response code="200">Channel mapping options returned.</response>
/// <returns>An <see cref="OkResult"/> containing the channel mapping options.</returns>
[HttpGet("ChannelMappingOptions")]
[Authorize(Policy = Policies.LiveTvAccess)]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status200OK)]
public Task<ChannelMappingOptionsDto> GetChannelMappingOptions([FromQuery] string? providerId)
=> _listingsManager.GetChannelMappingOptions(providerId);
@@ -1120,7 +1120,7 @@ public class LiveTvController : BaseJellyfinApiController
/// <response code="200">Created channel mapping returned.</response>
/// <returns>An <see cref="OkResult"/> containing the created channel mapping.</returns>
[HttpPost("ChannelMappings")]
[Authorize(Policy = Policies.LiveTvManagement)]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status200OK)]
public Task<TunerChannelMapping> SetChannelMapping([FromBody, Required] SetChannelMappingDto dto)
=> _listingsManager.SetChannelMapping(dto.ProviderId, dto.TunerChannelId, dto.ProviderChannelId);
@@ -1144,7 +1144,7 @@ public class LiveTvController : BaseJellyfinApiController
/// <returns>An <see cref="OkResult"/> containing the tuners.</returns>
[HttpGet("Tuners/Discvover", Name = "DiscvoverTuners")]
[HttpGet("Tuners/Discover")]
[Authorize(Policy = Policies.LiveTvManagement)]
[Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status200OK)]
public IAsyncEnumerable<TunerHostInfo> DiscoverTuners([FromQuery] bool newDevicesOnly = false)
=> _tunerHostManager.DiscoverTuners(newDevicesOnly);
@@ -1192,7 +1192,7 @@ public class LiveTvController : BaseJellyfinApiController
[ProducesVideoFile]
public ActionResult GetLiveStreamFile(
[FromRoute, Required] string streamId,
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container)
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container)
{
var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfoByUniqueId(streamId);
if (liveStreamInfo is null)
@@ -58,7 +58,7 @@ public class SyncPlayController : BaseJellyfinApiController
[FromBody, Required] NewGroupRequestDto requestData)
{
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
var syncPlayRequest = new NewGroupRequest(requestData.GroupName);
var syncPlayRequest = new NewGroupRequest(requestData.GroupName.Trim());
return Ok(_syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None));
}
@@ -86,7 +86,7 @@ public class TrickplayController : BaseJellyfinApiController
[FromRoute, Required] int index,
[FromQuery] Guid? mediaSourceId)
{
var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId());
var item = _libraryManager.GetItemById<BaseItem>(mediaSourceId ?? itemId, User.GetUserId());
if (item is null)
{
return NotFound();
@@ -102,13 +102,13 @@ public class UniversalAudioController : BaseJellyfinApiController
[FromQuery] string? mediaSourceId,
[FromQuery] string? deviceId,
[FromQuery] Guid? userId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
[FromQuery] int? maxAudioChannels,
[FromQuery] int? transcodingAudioChannels,
[FromQuery] int? maxStreamingBitrate,
[FromQuery] int? audioBitRate,
[FromQuery] long? startTimeTicks,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? transcodingContainer,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? transcodingContainer,
[FromQuery] MediaStreamProtocol? transcodingProtocol,
[FromQuery] int? maxAudioSampleRate,
[FromQuery] int? maxAudioBitDepth,
+12 -12
View File
@@ -315,18 +315,18 @@ public class VideosController : BaseJellyfinApiController
[ProducesVideoFile]
public async Task<ActionResult> GetVideoStream(
[FromRoute, Required] Guid itemId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? container,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container,
[FromQuery] bool? @static,
[FromQuery] string? @params,
[FromQuery] string? tag,
[FromQuery, ParameterObsolete] string? deviceProfileId,
[FromQuery] string? playSessionId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
[FromQuery] int? segmentLength,
[FromQuery] int? minSegments,
[FromQuery] string? mediaSourceId,
[FromQuery] string? deviceId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
[FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy,
@@ -337,7 +337,7 @@ public class VideosController : BaseJellyfinApiController
[FromQuery] int? audioChannels,
[FromQuery] int? maxAudioChannels,
[FromQuery] string? profile,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
[FromQuery] float? framerate,
[FromQuery] float? maxFramerate,
[FromQuery] bool? copyTimestamps,
@@ -358,8 +358,8 @@ public class VideosController : BaseJellyfinApiController
[FromQuery] int? cpuCoreLimit,
[FromQuery] string? liveStreamId,
[FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
[FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
@@ -556,18 +556,18 @@ public class VideosController : BaseJellyfinApiController
[ProducesVideoFile]
public Task<ActionResult> GetVideoStreamByContainer(
[FromRoute, Required] Guid itemId,
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container,
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
[FromQuery] bool? @static,
[FromQuery] string? @params,
[FromQuery] string? tag,
[FromQuery] string? deviceProfileId,
[FromQuery] string? playSessionId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
[FromQuery] int? segmentLength,
[FromQuery] int? minSegments,
[FromQuery] string? mediaSourceId,
[FromQuery] string? deviceId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
[FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy,
@@ -578,7 +578,7 @@ public class VideosController : BaseJellyfinApiController
[FromQuery] int? audioChannels,
[FromQuery] int? maxAudioChannels,
[FromQuery] string? profile,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
[FromQuery] float? framerate,
[FromQuery] float? maxFramerate,
[FromQuery] bool? copyTimestamps,
@@ -599,8 +599,8 @@ public class VideosController : BaseJellyfinApiController
[FromQuery] int? cpuCoreLimit,
[FromQuery] string? liveStreamId,
[FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
[FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex,
+24 -1
View File
@@ -1,4 +1,8 @@
using System;
using System.Net.Mime;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Formatters;
namespace Jellyfin.Api.Formatters;
@@ -6,7 +10,7 @@ namespace Jellyfin.Api.Formatters;
/// <summary>
/// Xml output formatter.
/// </summary>
public sealed class XmlOutputFormatter : StringOutputFormatter
public sealed class XmlOutputFormatter : TextOutputFormatter
{
/// <summary>
/// Initializes a new instance of the <see cref="XmlOutputFormatter"/> class.
@@ -15,5 +19,24 @@ public sealed class XmlOutputFormatter : StringOutputFormatter
{
SupportedMediaTypes.Clear();
SupportedMediaTypes.Add(MediaTypeNames.Text.Xml);
SupportedEncodings.Add(Encoding.UTF8);
SupportedEncodings.Add(Encoding.Unicode);
}
/// <inheritdoc />
public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(selectedEncoding);
var valueAsString = context.Object?.ToString();
if (string.IsNullOrEmpty(valueAsString))
{
return;
}
var response = context.HttpContext.Response;
await response.WriteAsync(valueAsString, selectedEncoding).ConfigureAwait(false);
}
}
+63 -49
View File
@@ -154,7 +154,7 @@ public class DynamicHlsHelper
// from universal audio service, need to override the AudioCodec when the actual request differs from original query
if (!string.Equals(state.OutputAudioCodec, _httpContextAccessor.HttpContext.Request.Query["AudioCodec"].ToString(), StringComparison.OrdinalIgnoreCase))
{
var newQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(_httpContextAccessor.HttpContext.Request.QueryString.ToString());
var newQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
newQuery["AudioCodec"] = state.OutputAudioCodec;
queryString = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(string.Empty, newQuery);
}
@@ -173,10 +173,21 @@ public class DynamicHlsHelper
queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons;
}
// Main stream
var playlistUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
// Video rotation metadata is only supported in fMP4 remuxing
if (state.VideoStream is not null
&& state.VideoRequest is not null
&& (state.VideoStream?.Rotation ?? 0) != 0
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
&& !string.IsNullOrWhiteSpace(state.Request.SegmentContainer)
&& !string.Equals(state.Request.SegmentContainer, "mp4", StringComparison.OrdinalIgnoreCase))
{
queryString += "&AllowVideoStreamCopy=false";
}
playlistUrl += queryString;
// Main stream
var baseUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
var playlistUrl = baseUrl + queryString;
var playlistQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
var subtitleStreams = state.MediaSource
.MediaStreams
@@ -198,37 +209,36 @@ public class DynamicHlsHelper
AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User);
}
// Video rotation metadata is only supported in fMP4 remuxing
if (state.VideoStream is not null
&& state.VideoRequest is not null
&& (state.VideoStream?.Rotation ?? 0) != 0
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
&& !string.IsNullOrWhiteSpace(state.Request.SegmentContainer)
&& !string.Equals(state.Request.SegmentContainer, "mp4", StringComparison.OrdinalIgnoreCase))
{
playlistUrl += "&AllowVideoStreamCopy=false";
}
var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup);
if (state.VideoStream is not null && state.VideoRequest is not null)
{
var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
// Provide SDR HEVC entrance for backward compatibility.
if (encodingOptions.AllowHevcEncoding
&& !encodingOptions.AllowAv1Encoding
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
&& state.VideoStream.VideoRange == VideoRange.HDR
&& string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
// Provide AV1 and HEVC SDR entrances for backward compatibility.
foreach (var sdrVideoCodec in new[] { "av1", "hevc" })
{
var requestedVideoProfiles = state.GetRequestedProfiles("hevc");
if (requestedVideoProfiles is not null && requestedVideoProfiles.Length > 0)
var isAv1EncodingAllowed = encodingOptions.AllowAv1Encoding
&& string.Equals(sdrVideoCodec, "av1", StringComparison.OrdinalIgnoreCase)
&& string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase);
var isHevcEncodingAllowed = encodingOptions.AllowHevcEncoding
&& string.Equals(sdrVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
&& string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase);
var isEncodingAllowed = isAv1EncodingAllowed || isHevcEncodingAllowed;
if (isEncodingAllowed
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
&& state.VideoStream.VideoRange == VideoRange.HDR)
{
// Force HEVC Main Profile and disable video stream copy.
state.OutputVideoCodec = "hevc";
var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(',', requestedVideoProfiles), "main");
sdrVideoUrl += "&AllowVideoStreamCopy=false";
// Force AV1 and HEVC Main Profile and disable video stream copy.
state.OutputVideoCodec = sdrVideoCodec;
var sdrPlaylistQuery = playlistQuery;
sdrPlaylistQuery["VideoCodec"] = sdrVideoCodec;
sdrPlaylistQuery[sdrVideoCodec + "-profile"] = "main";
sdrPlaylistQuery["AllowVideoStreamCopy"] = "false";
var sdrVideoUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, sdrPlaylistQuery);
// HACK: Use the same bitrate so that the client can choose by other attributes, such as color range.
AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup);
@@ -238,12 +248,30 @@ public class DynamicHlsHelper
}
}
// Provide H.264 SDR entrance for backward compatibility.
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
&& state.VideoStream.VideoRange == VideoRange.HDR)
{
// Force H.264 and disable video stream copy.
state.OutputVideoCodec = "h264";
var sdrPlaylistQuery = playlistQuery;
sdrPlaylistQuery["VideoCodec"] = "h264";
sdrPlaylistQuery["AllowVideoStreamCopy"] = "false";
var sdrVideoUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, sdrPlaylistQuery);
// HACK: Use the same bitrate so that the client can choose by other attributes, such as color range.
AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup);
// Restore the video codec
state.OutputVideoCodec = "copy";
}
// Provide Level 5.0 entrance for backward compatibility.
// e.g. Apple A10 chips refuse the master playlist containing SDR HEVC Main Level 5.1 video,
// but in fact it is capable of playing videos up to Level 6.1.
if (encodingOptions.AllowHevcEncoding
&& !encodingOptions.AllowAv1Encoding
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
&& state.VideoStream.Level.HasValue
&& state.VideoStream.Level > 150
&& state.VideoStream.VideoRange == VideoRange.SDR
@@ -273,12 +301,15 @@ public class DynamicHlsHelper
var variation = GetBitrateVariation(totalBitrate);
var newBitrate = totalBitrate - variation;
var variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
var variantQuery = playlistQuery;
variantQuery["VideoBitrate"] = (requestedVideoBitrate - variation).ToString(CultureInfo.InvariantCulture);
var variantUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, variantQuery);
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
variation *= 2;
newBitrate = totalBitrate - variation;
variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation);
variantQuery["VideoBitrate"] = (requestedVideoBitrate - variation).ToString(CultureInfo.InvariantCulture);
variantUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, variantQuery);
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
}
@@ -863,23 +894,6 @@ public class DynamicHlsHelper
return variation;
}
private string ReplaceVideoBitrate(string url, int oldValue, int newValue)
{
return url.Replace(
"videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture),
"videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture),
StringComparison.OrdinalIgnoreCase);
}
private string ReplaceProfile(string url, string codec, string oldValue, string newValue)
{
string profileStr = codec + "-profile=";
return url.Replace(
profileStr + oldValue,
profileStr + newValue,
StringComparison.OrdinalIgnoreCase);
}
private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue)
{
var oldPlaylist = playlist.ToString();
+26 -7
View File
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Extensions;
@@ -17,9 +18,7 @@ using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Streaming;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.Net.Http.Headers;
namespace Jellyfin.Api.Helpers;
@@ -159,6 +158,13 @@ public static class StreamingHelpers
string? containerInternal = Path.GetExtension(state.RequestedUrl);
if (string.IsNullOrEmpty(containerInternal)
&& (!string.IsNullOrWhiteSpace(streamingRequest.LiveStreamId)
|| (mediaSource != null && mediaSource.IsInfiniteStream)))
{
containerInternal = ".ts";
}
if (!string.IsNullOrEmpty(streamingRequest.Container))
{
containerInternal = streamingRequest.Container;
@@ -415,14 +421,18 @@ public static class StreamingHelpers
request.Static = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
break;
case 4:
if (videoRequest is not null)
if (videoRequest is not null && IsValidCodecName(val))
{
videoRequest.VideoCodec = val;
}
break;
case 5:
request.AudioCodec = val;
if (IsValidCodecName(val))
{
request.AudioCodec = val;
}
break;
case 6:
if (videoRequest is not null)
@@ -476,7 +486,7 @@ public static class StreamingHelpers
request.StartTimeTicks = long.Parse(val, CultureInfo.InvariantCulture);
break;
case 15:
if (videoRequest is not null)
if (videoRequest is not null && Regex.IsMatch(val, EncodingHelper.LevelValidationRegexStr))
{
videoRequest.Level = val;
}
@@ -497,7 +507,7 @@ public static class StreamingHelpers
break;
case 18:
if (videoRequest is not null)
if (videoRequest is not null && IsValidCodecName(val))
{
videoRequest.Profile = val;
}
@@ -556,7 +566,11 @@ public static class StreamingHelpers
break;
case 30:
request.SubtitleCodec = val;
if (IsValidCodecName(val))
{
request.SubtitleCodec = val;
}
break;
case 31:
if (videoRequest is not null)
@@ -579,6 +593,11 @@ public static class StreamingHelpers
}
}
private static bool IsValidCodecName(string val)
{
return EncodingHelper.ContainerValidationRegex().IsMatch(val);
}
/// <summary>
/// Parses the container into its file extension.
/// </summary>
@@ -1,3 +1,5 @@
using System.ComponentModel.DataAnnotations;
namespace Jellyfin.Api.Models.SyncPlayDtos;
/// <summary>
@@ -17,5 +19,6 @@ public class NewGroupRequestDto
/// Gets or sets the group name.
/// </summary>
/// <value>The name of the new group.</value>
[StringLength(200, ErrorMessage = "Group name must not exceed 200 characters.")]
public string GroupName { get; set; }
}
@@ -7,6 +7,7 @@ using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Session;
using Microsoft.Extensions.Logging;
@@ -15,7 +16,7 @@ namespace Jellyfin.Api.WebSocketListeners;
/// <summary>
/// Class SessionInfoWebSocketListener.
/// </summary>
public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<SessionInfo>, WebSocketListenerState>
public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<SessionInfoDto>, WebSocketListenerState>
{
private readonly ISessionManager _sessionManager;
private bool _disposed;
@@ -52,24 +53,26 @@ public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnume
/// Gets the data to send.
/// </summary>
/// <returns>Task{SystemInfo}.</returns>
protected override Task<IEnumerable<SessionInfo>> GetDataToSend()
protected override Task<IEnumerable<SessionInfoDto>> GetDataToSend()
{
return Task.FromResult(_sessionManager.Sessions);
return Task.FromResult(_sessionManager.Sessions.Select(_sessionManager.ToSessionInfoDto));
}
/// <inheritdoc />
protected override Task<IEnumerable<SessionInfo>> GetDataToSendForConnection(IWebSocketConnection connection)
protected override Task<IEnumerable<SessionInfoDto>> GetDataToSendForConnection(IWebSocketConnection connection)
{
var sessions = _sessionManager.Sessions;
// For non-admin users, filter the sessions to only include their own sessions
if (connection.AuthorizationInfo?.User is not null &&
!connection.AuthorizationInfo.IsApiKey &&
!connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator))
{
var userId = connection.AuthorizationInfo.User.Id;
return Task.FromResult(_sessionManager.Sessions.Where(s => s.UserId.Equals(userId) || s.ContainsUser(userId)));
sessions = sessions.Where(s => s.UserId.Equals(userId) || s.ContainsUser(userId));
}
return Task.FromResult(_sessionManager.Sessions);
return Task.FromResult(sessions.Select(_sessionManager.ToSessionInfoDto));
}
/// <inheritdoc />
+1 -1
View File
@@ -18,7 +18,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Data</PackageId>
<VersionPrefix>10.11.0</VersionPrefix>
<VersionPrefix>10.11.7</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>
+1 -1
View File
@@ -185,7 +185,7 @@ public static class UserEntityExtensions
entity.Permissions.Add(new Permission(PermissionKind.EnableSyncTranscoding, true));
entity.Permissions.Add(new Permission(PermissionKind.EnableAudioPlaybackTranscoding, true));
entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvAccess, true));
entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvManagement, true));
entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvManagement, false));
entity.Permissions.Add(new Permission(PermissionKind.EnableSharedDeviceControl, true));
entity.Permissions.Add(new Permission(PermissionKind.EnableVideoPlaybackTranscoding, true));
entity.Permissions.Add(new Permission(PermissionKind.ForceRemoteSourceTranscoding, false));
@@ -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:
@@ -118,17 +118,24 @@ public class BackupService : IBackupService
throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jellyfin ({manifest.ServerVersion}) and cannot be loaded in this version.");
}
void CopyDirectory(string source, string target)
void CopyDirectory(string source, string target, string[]? exclude = null)
{
var fullSourcePath = NormalizePathSeparator(Path.GetFullPath(source) + Path.DirectorySeparatorChar);
var fullTargetRoot = Path.GetFullPath(target) + Path.DirectorySeparatorChar;
var excludePaths = exclude?.Select(e => $"{source}/{e}/").ToArray();
foreach (var item in zipArchive.Entries)
{
var sourcePath = NormalizePathSeparator(Path.GetFullPath(item.FullName));
var targetPath = Path.GetFullPath(Path.Combine(target, Path.GetRelativePath(source, item.FullName)));
if (excludePaths is not null && excludePaths.Any(e => item.FullName.StartsWith(e, StringComparison.Ordinal)))
{
continue;
}
if (!sourcePath.StartsWith(fullSourcePath, StringComparison.Ordinal)
|| !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal))
|| !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal)
|| Path.EndsInDirectorySeparator(item.FullName))
{
continue;
}
@@ -141,8 +148,10 @@ public class BackupService : IBackupService
}
CopyDirectory("Config", _applicationPaths.ConfigurationDirectoryPath);
CopyDirectory("Data", _applicationPaths.DataPath);
CopyDirectory("Data", _applicationPaths.DataPath, exclude: ["metadata", "metadata-default"]);
CopyDirectory("Root", _applicationPaths.RootFolderPath);
CopyDirectory("Data/metadata", _applicationPaths.InternalMetadataPath);
CopyDirectory("Data/metadata-default", _applicationPaths.DefaultInternalMetadataPath);
if (manifest.Options.Database)
{
@@ -199,7 +208,7 @@ public class BackupService : IBackupService
var zipEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.Type.Name}.json")));
if (zipEntry is null)
{
_logger.LogInformation("No backup of expected table {Table} is present in backup. Continue anyway.", entityType.Type.Name);
_logger.LogInformation("No backup of expected table {Table} is present in backup, continuing anyway", entityType.Type.Name);
continue;
}
@@ -223,7 +232,7 @@ public class BackupService : IBackupService
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not store entity {Entity} continue anyway.", item);
_logger.LogError(ex, "Could not store entity {Entity}, continuing anyway", item);
}
}
@@ -233,11 +242,11 @@ public class BackupService : IBackupService
_logger.LogInformation("Try restore Database");
await dbContext.SaveChangesAsync().ConfigureAwait(false);
_logger.LogInformation("Restored database.");
_logger.LogInformation("Restored database");
}
}
_logger.LogInformation("Restored Jellyfin system from {Date}.", manifest.DateCreated);
_logger.LogInformation("Restored Jellyfin system from {Date}", manifest.DateCreated);
}
}
@@ -263,6 +272,8 @@ public class BackupService : IBackupService
Options = Map(backupOptions)
};
_logger.LogInformation("Running database optimization before backup");
await _jellyfinDatabaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false);
var backupFolder = Path.Combine(_applicationPaths.BackupPath);
@@ -281,130 +292,163 @@ public class BackupService : IBackupService
}
var backupPath = Path.Combine(backupFolder, $"jellyfin-backup-{manifest.DateCreated.ToLocalTime():yyyyMMddHHmmss}.zip");
_logger.LogInformation("Attempt to create a new backup at {BackupPath}", backupPath);
var fileStream = File.OpenWrite(backupPath);
await using (fileStream.ConfigureAwait(false))
using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, false))
try
{
_logger.LogInformation("Start backup process.");
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
_logger.LogInformation("Attempting to create a new backup at {BackupPath}", backupPath);
var fileStream = File.OpenWrite(backupPath);
await using (fileStream.ConfigureAwait(false))
using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, false))
{
dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
static IAsyncEnumerable<object> GetValues(IQueryable dbSet)
_logger.LogInformation("Starting backup process");
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
var method = dbSet.GetType().GetMethod(nameof(DbSet<object>.AsAsyncEnumerable))!;
var enumerable = method.Invoke(dbSet, null)!;
return (IAsyncEnumerable<object>)enumerable;
}
dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
// include the migration history as well
var historyRepository = dbContext.GetService<IHistoryRepository>();
var migrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
ICollection<(Type Type, string SourceName, Func<IAsyncEnumerable<object>> ValueFactory)> entityTypes = [
.. typeof(JellyfinDbContext)
.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
.Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
.Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!, ValueFactory: new Func<IAsyncEnumerable<object>>(() => GetValues((IQueryable)e.GetValue(dbContext)!)))),
(Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), ValueFactory: () => migrations.ToAsyncEnumerable())
];
manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray();
var transaction = await dbContext.Database.BeginTransactionAsync().ConfigureAwait(false);
await using (transaction.ConfigureAwait(false))
{
_logger.LogInformation("Begin Database backup");
foreach (var entityType in entityTypes)
static IAsyncEnumerable<object> GetValues(IQueryable dbSet)
{
_logger.LogInformation("Begin backup of entity {Table}", entityType.SourceName);
var zipEntry = zipArchive.CreateEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.SourceName}.json")));
var entities = 0;
var zipEntryStream = zipEntry.Open();
await using (zipEntryStream.ConfigureAwait(false))
var method = dbSet.GetType().GetMethod(nameof(DbSet<object>.AsAsyncEnumerable))!;
var enumerable = method.Invoke(dbSet, null)!;
return (IAsyncEnumerable<object>)enumerable;
}
// include the migration history as well
var historyRepository = dbContext.GetService<IHistoryRepository>();
var migrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
ICollection<(Type Type, string SourceName, Func<IAsyncEnumerable<object>> ValueFactory)> entityTypes =
[
.. typeof(JellyfinDbContext)
.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
.Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
.Select(e => (Type: e.PropertyType, dbContext.Model.FindEntityType(e.PropertyType.GetGenericArguments()[0])!.GetSchemaQualifiedTableName()!, ValueFactory: new Func<IAsyncEnumerable<object>>(() => GetValues((IQueryable)e.GetValue(dbContext)!)))),
(Type: typeof(HistoryRow), SourceName: nameof(HistoryRow), ValueFactory: () => migrations.ToAsyncEnumerable())
];
manifest.DatabaseTables = entityTypes.Select(e => e.Type.Name).ToArray();
var transaction = await dbContext.Database.BeginTransactionAsync().ConfigureAwait(false);
await using (transaction.ConfigureAwait(false))
{
_logger.LogInformation("Begin Database backup");
foreach (var entityType in entityTypes)
{
var jsonSerializer = new Utf8JsonWriter(zipEntryStream);
await using (jsonSerializer.ConfigureAwait(false))
_logger.LogInformation("Begin backup of entity {Table}", entityType.SourceName);
var zipEntry = zipArchive.CreateEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.SourceName}.json")));
var entities = 0;
var zipEntryStream = zipEntry.Open();
await using (zipEntryStream.ConfigureAwait(false))
{
jsonSerializer.WriteStartArray();
var set = entityType.ValueFactory().ConfigureAwait(false);
await foreach (var item in set.ConfigureAwait(false))
var jsonSerializer = new Utf8JsonWriter(zipEntryStream);
await using (jsonSerializer.ConfigureAwait(false))
{
entities++;
try
jsonSerializer.WriteStartArray();
var set = entityType.ValueFactory().ConfigureAwait(false);
await foreach (var item in set.ConfigureAwait(false))
{
JsonSerializer.SerializeToDocument(item, _serializerSettings).WriteTo(jsonSerializer);
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not load entity {Entity}", item);
throw;
entities++;
try
{
using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings);
document.WriteTo(jsonSerializer);
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not load entity {Entity}", item);
throw;
}
}
jsonSerializer.WriteEndArray();
}
jsonSerializer.WriteEndArray();
}
}
_logger.LogInformation("backup of entity {Table} with {Number} created", entityType.Type.Name, entities);
_logger.LogInformation("Backup of entity {Table} with {Number} created", entityType.SourceName, entities);
}
}
}
}
_logger.LogInformation("Backup of folder {Table}", _applicationPaths.ConfigurationDirectoryPath);
foreach (var item in Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.xml", SearchOption.TopDirectoryOnly)
.Union(Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.json", SearchOption.TopDirectoryOnly)))
{
zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine("Config", Path.GetFileName(item))));
}
void CopyDirectory(string source, string target, string filter = "*")
{
if (!Directory.Exists(source))
_logger.LogInformation("Backup of folder {Table}", _applicationPaths.ConfigurationDirectoryPath);
foreach (var item in Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.xml", SearchOption.TopDirectoryOnly)
.Union(Directory.EnumerateFiles(_applicationPaths.ConfigurationDirectoryPath, "*.json", SearchOption.TopDirectoryOnly)))
{
return;
zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine("Config", Path.GetFileName(item))));
}
_logger.LogInformation("Backup of folder {Table}", source);
foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories))
void CopyDirectory(string source, string target, string filter = "*")
{
zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine(target, Path.GetRelativePath(source, item))));
if (!Directory.Exists(source))
{
return;
}
_logger.LogInformation("Backup of folder {Table}", source);
foreach (var item in Directory.EnumerateFiles(source, filter, SearchOption.AllDirectories))
{
zipArchive.CreateEntryFromFile(item, NormalizePathSeparator(Path.Combine(target, Path.GetRelativePath(source, item))));
}
}
CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "users"), Path.Combine("Config", "users"));
CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "ScheduledTasks"), Path.Combine("Config", "ScheduledTasks"));
CopyDirectory(Path.Combine(_applicationPaths.RootFolderPath), "Root");
CopyDirectory(Path.Combine(_applicationPaths.DataPath, "collections"), Path.Combine("Data", "collections"));
CopyDirectory(Path.Combine(_applicationPaths.DataPath, "playlists"), Path.Combine("Data", "playlists"));
CopyDirectory(Path.Combine(_applicationPaths.DataPath, "ScheduledTasks"), Path.Combine("Data", "ScheduledTasks"));
if (backupOptions.Subtitles)
{
CopyDirectory(Path.Combine(_applicationPaths.DataPath, "subtitles"), Path.Combine("Data", "subtitles"));
}
if (backupOptions.Trickplay)
{
CopyDirectory(Path.Combine(_applicationPaths.DataPath, "trickplay"), Path.Combine("Data", "trickplay"));
}
if (backupOptions.Metadata)
{
CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata"));
// If a custom metadata path is configured, the default location may still contain data.
if (!string.Equals(
Path.GetFullPath(_applicationPaths.DefaultInternalMetadataPath),
Path.GetFullPath(_applicationPaths.InternalMetadataPath),
StringComparison.OrdinalIgnoreCase))
{
CopyDirectory(Path.Combine(_applicationPaths.DefaultInternalMetadataPath), Path.Combine("Data", "metadata-default"));
}
}
var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open();
await using (manifestStream.ConfigureAwait(false))
{
await JsonSerializer.SerializeAsync(manifestStream, manifest).ConfigureAwait(false);
}
}
CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "users"), Path.Combine("Config", "users"));
CopyDirectory(Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "ScheduledTasks"), Path.Combine("Config", "ScheduledTasks"));
CopyDirectory(Path.Combine(_applicationPaths.RootFolderPath), "Root");
CopyDirectory(Path.Combine(_applicationPaths.DataPath, "collections"), Path.Combine("Data", "collections"));
CopyDirectory(Path.Combine(_applicationPaths.DataPath, "playlists"), Path.Combine("Data", "playlists"));
CopyDirectory(Path.Combine(_applicationPaths.DataPath, "ScheduledTasks"), Path.Combine("Data", "ScheduledTasks"));
if (backupOptions.Subtitles)
{
CopyDirectory(Path.Combine(_applicationPaths.DataPath, "subtitles"), Path.Combine("Data", "subtitles"));
}
if (backupOptions.Trickplay)
{
CopyDirectory(Path.Combine(_applicationPaths.DataPath, "trickplay"), Path.Combine("Data", "trickplay"));
}
if (backupOptions.Metadata)
{
CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata"));
}
var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open();
await using (manifestStream.ConfigureAwait(false))
{
await JsonSerializer.SerializeAsync(manifestStream, manifest).ConfigureAwait(false);
}
_logger.LogInformation("Backup created");
return Map(manifest, backupPath);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create backup, removing {BackupPath}", backupPath);
try
{
if (File.Exists(backupPath))
{
File.Delete(backupPath);
}
}
catch (Exception innerEx)
{
_logger.LogWarning(innerEx, "Unable to remove failed backup");
}
_logger.LogInformation("Backup created");
return Map(manifest, backupPath);
throw;
}
}
/// <inheritdoc/>
@@ -422,7 +466,7 @@ public class BackupService : IBackupService
}
catch (Exception ex)
{
_logger.LogError(ex, "Tried to load archive from {Path} but failed.", archivePath);
_logger.LogWarning(ex, "Tried to load manifest from archive {Path} but failed", archivePath);
return null;
}
@@ -459,7 +503,7 @@ public class BackupService : IBackupService
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not load {BackupArchive} path.", item);
_logger.LogWarning(ex, "Tried to load manifest from archive {Path} but failed", item);
}
}
@@ -75,6 +75,7 @@ public sealed class BaseItemRepository
private static readonly IReadOnlyList<ItemValueType> _getAlbumArtistValueTypes = [ItemValueType.AlbumArtist];
private static readonly IReadOnlyList<ItemValueType> _getStudiosValueTypes = [ItemValueType.Studios];
private static readonly IReadOnlyList<ItemValueType> _getGenreValueTypes = [ItemValueType.Genre];
private static readonly IReadOnlyList<char> SearchWildcardTerms = ['%', '_', '[', ']', '^'];
/// <summary>
/// Initializes a new instance of the <see cref="BaseItemRepository"/> class.
@@ -266,13 +267,15 @@ public sealed class BaseItemRepository
IQueryable<BaseItemEntity> dbQuery = PrepareItemQuery(context, filter);
dbQuery = TranslateQuery(dbQuery, context, filter);
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
if (filter.EnableTotalRecordCount)
{
result.TotalRecordCount = dbQuery.Count();
}
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
dbQuery = ApplyQueryPaging(dbQuery, filter);
dbQuery = ApplyNavigations(dbQuery, filter);
result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
result.StartIndex = filter.StartIndex ?? 0;
@@ -293,6 +296,26 @@ public sealed class BaseItemRepository
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
dbQuery = ApplyQueryPaging(dbQuery, filter);
var hasRandomSort = filter.OrderBy.Any(e => e.OrderBy == ItemSortBy.Random);
if (hasRandomSort)
{
var orderedIds = dbQuery.Select(e => e.Id).ToList();
if (orderedIds.Count == 0)
{
return Array.Empty<BaseItemDto>();
}
var itemsById = ApplyNavigations(context.BaseItems.Where(e => orderedIds.Contains(e.Id)), filter)
.AsEnumerable()
.Select(w => DeserializeBaseItem(w, filter.SkipDeserialization))
.Where(dto => dto is not null)
.ToDictionary(i => i!.Id);
return orderedIds.Where(itemsById.ContainsKey).Select(id => itemsById[id]).ToArray()!;
}
dbQuery = ApplyNavigations(dbQuery, filter);
return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
}
@@ -335,6 +358,8 @@ public sealed class BaseItemRepository
mainquery = ApplyGroupingFilter(context, mainquery, filter);
mainquery = ApplyQueryPaging(mainquery, filter);
mainquery = ApplyNavigations(mainquery, filter);
return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
}
@@ -397,19 +422,32 @@ public sealed class BaseItemRepository
dbQuery = dbQuery.Distinct();
}
dbQuery = ApplyOrder(dbQuery, filter);
dbQuery = ApplyNavigations(dbQuery, filter);
dbQuery = ApplyOrder(dbQuery, filter, context);
return dbQuery;
}
private static IQueryable<BaseItemEntity> ApplyNavigations(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
{
dbQuery = dbQuery.Include(e => e.TrailerTypes)
.Include(e => e.Provider)
.Include(e => e.LockedFields)
.Include(e => e.UserData);
if (filter.TrailerTypes.Length > 0 || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer))
{
dbQuery = dbQuery.Include(e => e.TrailerTypes);
}
if (filter.DtoOptions.ContainsField(ItemFields.ProviderIds))
{
dbQuery = dbQuery.Include(e => e.Provider);
}
if (filter.DtoOptions.ContainsField(ItemFields.Settings))
{
dbQuery = dbQuery.Include(e => e.LockedFields);
}
if (filter.DtoOptions.EnableUserData)
{
dbQuery = dbQuery.Include(e => e.UserData);
}
if (filter.DtoOptions.EnableImages)
{
@@ -444,6 +482,7 @@ public sealed class BaseItemRepository
dbQuery = TranslateQuery(dbQuery, context, filter);
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
dbQuery = ApplyQueryPaging(dbQuery, filter);
dbQuery = ApplyNavigations(dbQuery, filter);
return dbQuery;
}
@@ -597,7 +636,6 @@ public sealed class BaseItemRepository
var ids = tuples.Select(f => f.Item.Id).ToArray();
var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToArray();
var newItems = tuples.Where(e => !existingItems.Contains(e.Item.Id)).ToArray();
foreach (var item in tuples)
{
@@ -612,25 +650,25 @@ public sealed class BaseItemRepository
else
{
context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete();
context.BaseItemImageInfos.Where(e => e.ItemId == entity.Id).ExecuteDelete();
context.BaseItemMetadataFields.Where(e => e.ItemId == entity.Id).ExecuteDelete();
if (entity.Images is { Count: > 0 })
{
context.BaseItemImageInfos.AddRange(entity.Images);
}
if (entity.LockedFields is { Count: > 0 })
{
context.BaseItemMetadataFields.AddRange(entity.LockedFields);
}
context.BaseItems.Attach(entity).State = EntityState.Modified;
}
}
context.SaveChanges();
foreach (var item in newItems)
{
// reattach old userData entries
var userKeys = item.UserDataKey.ToArray();
var retentionDate = (DateTime?)null;
context.UserData
.Where(e => e.ItemId == PlaceholderId)
.Where(e => userKeys.Contains(e.CustomDataKey))
.ExecuteUpdate(e => e
.SetProperty(f => f.ItemId, item.Item.Id)
.SetProperty(f => f.RetentionDate, retentionDate));
}
var itemValueMaps = tuples
.Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags)))
.ToArray();
@@ -726,6 +764,43 @@ public sealed class BaseItemRepository
transaction.Commit();
}
/// <inheritdoc />
public async Task ReattachUserDataAsync(BaseItemDto item, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(item);
cancellationToken.ThrowIfCancellationRequested();
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await using (transaction.ConfigureAwait(false))
{
var userKeys = item.GetUserDataKeys().ToArray();
var retentionDate = (DateTime?)null;
await dbContext.UserData
.Where(e => e.ItemId == PlaceholderId)
.Where(e => userKeys.Contains(e.CustomDataKey))
.ExecuteUpdateAsync(
e => e
.SetProperty(f => f.ItemId, item.Id)
.SetProperty(f => f.RetentionDate, retentionDate),
cancellationToken).ConfigureAwait(false);
// Rehydrate the cached userdata
item.UserData = await dbContext.UserData
.AsNoTracking()
.Where(e => e.ItemId == item.Id)
.ToArrayAsync(cancellationToken)
.ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
}
}
/// <inheritdoc />
public BaseItemDto? RetrieveItem(Guid id)
{
@@ -833,7 +908,7 @@ public sealed class BaseItemRepository
}
dto.ExtraIds = string.IsNullOrWhiteSpace(entity.ExtraIds) ? [] : entity.ExtraIds.Split('|').Select(e => Guid.Parse(e)).ToArray();
dto.ProductionLocations = entity.ProductionLocations?.Split('|') ?? [];
dto.ProductionLocations = entity.ProductionLocations?.Split('|', StringSplitOptions.RemoveEmptyEntries) ?? [];
dto.Studios = entity.Studios?.Split('|') ?? [];
dto.Tags = string.IsNullOrWhiteSpace(entity.Tags) ? [] : entity.Tags.Split('|');
@@ -995,7 +1070,7 @@ public sealed class BaseItemRepository
}
entity.ExtraIds = dto.ExtraIds is not null ? string.Join('|', dto.ExtraIds) : null;
entity.ProductionLocations = dto.ProductionLocations is not null ? string.Join('|', dto.ProductionLocations) : null;
entity.ProductionLocations = dto.ProductionLocations is not null ? string.Join('|', dto.ProductionLocations.Where(p => !string.IsNullOrWhiteSpace(p))) : null;
entity.Studios = dto.Studios is not null ? string.Join('|', dto.Studios) : null;
entity.Tags = dto.Tags is not null ? string.Join('|', dto.Tags) : null;
entity.LockedFields = dto.LockedFields is not null ? dto.LockedFields
@@ -1230,8 +1305,20 @@ public sealed class BaseItemRepository
ExcludeItemIds = filter.ExcludeItemIds
};
var query = TranslateQuery(innerQuery, context, outerQueryFilter)
.GroupBy(e => e.PresentationUniqueKey);
var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter)
.GroupBy(e => e.PresentationUniqueKey)
.Select(e => e.FirstOrDefault())
.Select(e => e!.Id);
var query = context.BaseItems
.Include(e => e.TrailerTypes)
.Include(e => e.Provider)
.Include(e => e.LockedFields)
.Include(e => e.Images)
.AsSingleQuery()
.Where(e => masterQuery.Contains(e.Id));
query = ApplyOrder(query, filter, context);
var result = new QueryResult<(BaseItemDto, ItemCounts?)>();
if (filter.EnableTotalRecordCount)
@@ -1286,12 +1373,7 @@ public sealed class BaseItemRepository
var resultQuery = query.Select(e => new
{
item = e.AsQueryable()
.Include(e => e.TrailerTypes)
.Include(e => e.Provider)
.Include(e => e.LockedFields)
.Include(e => e.Images)
.AsSingleQuery().First(),
item = e,
// TODO: This is bad refactor!
itemCount = new ItemCounts()
{
@@ -1323,13 +1405,6 @@ public sealed class BaseItemRepository
result.Items =
[
.. query
.Select(e => e.AsQueryable()
.Include(e => e.TrailerTypes)
.Include(e => e.Provider)
.Include(e => e.LockedFields)
.Include(e => e.Images)
.AsSingleQuery()
.First())
.AsEnumerable()
.Where(e => e is not null)
.Select<BaseItemEntity, (BaseItemDto, ItemCounts?)>(e =>
@@ -1509,51 +1584,58 @@ public sealed class BaseItemRepository
|| query.IncludeItemTypes.Contains(BaseItemKind.Season);
}
private IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter)
private IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter, JellyfinDbContext context)
{
var orderBy = filter.OrderBy;
var orderBy = filter.OrderBy.Where(e => e.OrderBy != ItemSortBy.Default).ToArray();
var hasSearch = !string.IsNullOrEmpty(filter.SearchTerm);
if (hasSearch)
{
orderBy = filter.OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy];
orderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy];
}
else if (orderBy.Count == 0)
else if (orderBy.Length == 0)
{
return query.OrderBy(e => e.SortName);
}
IOrderedQueryable<BaseItemEntity>? orderedQuery = null;
// When searching, prioritize by match quality: exact match > prefix match > contains
if (hasSearch)
{
orderedQuery = query.OrderBy(OrderMapper.MapSearchRelevanceOrder(filter.SearchTerm!));
}
var firstOrdering = orderBy.FirstOrDefault();
if (firstOrdering != default)
{
var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter);
if (firstOrdering.SortOrder == SortOrder.Ascending)
var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter, context);
if (orderedQuery is null)
{
orderedQuery = query.OrderBy(expression);
// No search relevance ordering, start fresh
orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending
? query.OrderBy(expression)
: query.OrderByDescending(expression);
}
else
{
orderedQuery = query.OrderByDescending(expression);
// Search relevance ordering already applied, chain with ThenBy
orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending
? orderedQuery.ThenBy(expression)
: orderedQuery.ThenByDescending(expression);
}
if (firstOrdering.OrderBy is ItemSortBy.Default or ItemSortBy.SortName)
{
if (firstOrdering.SortOrder is SortOrder.Ascending)
{
orderedQuery = orderedQuery.ThenBy(e => e.Name);
}
else
{
orderedQuery = orderedQuery.ThenByDescending(e => e.Name);
}
orderedQuery = firstOrdering.SortOrder is SortOrder.Ascending
? orderedQuery.ThenBy(e => e.Name)
: orderedQuery.ThenByDescending(e => e.Name);
}
}
foreach (var item in orderBy.Skip(1))
{
var expression = OrderMapper.MapOrderByField(item.OrderBy, filter);
var expression = OrderMapper.MapOrderByField(item.OrderBy, filter, context);
if (item.SortOrder == SortOrder.Ascending)
{
orderedQuery = orderedQuery!.ThenBy(expression);
@@ -1635,19 +1717,18 @@ public sealed class BaseItemRepository
var tags = filter.Tags.ToList();
var excludeTags = filter.ExcludeTags.ToList();
if (filter.IsMovie == true)
if (filter.IsMovie.HasValue)
{
if (filter.IncludeItemTypes.Length == 0
|| filter.IncludeItemTypes.Contains(BaseItemKind.Movie)
|| filter.IncludeItemTypes.Contains(BaseItemKind.Trailer))
var shouldIncludeAllMovieTypes = filter.IsMovie.Value
&& (filter.IncludeItemTypes.Length == 0
|| filter.IncludeItemTypes.Contains(BaseItemKind.Movie)
|| filter.IncludeItemTypes.Contains(BaseItemKind.Trailer));
if (!shouldIncludeAllMovieTypes)
{
baseQuery = baseQuery.Where(e => e.IsMovie);
baseQuery = baseQuery.Where(e => e.IsMovie == filter.IsMovie.Value);
}
}
else if (filter.IsMovie.HasValue)
{
baseQuery = baseQuery.Where(e => e.IsMovie == filter.IsMovie);
}
if (filter.IsSeries.HasValue)
{
@@ -1692,8 +1773,17 @@ public sealed class BaseItemRepository
if (!string.IsNullOrEmpty(filter.SearchTerm))
{
var searchTerm = filter.SearchTerm.ToLower();
baseQuery = baseQuery.Where(e => e.CleanName!.ToLower().Contains(searchTerm) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().Contains(searchTerm)));
var cleanedSearchTerm = GetCleanValue(filter.SearchTerm);
var originalSearchTerm = filter.SearchTerm.ToLower();
if (SearchWildcardTerms.Any(f => cleanedSearchTerm.Contains(f)))
{
cleanedSearchTerm = $"%{cleanedSearchTerm.Trim('%')}%";
baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName!, cleanedSearchTerm) || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle.ToLower(), originalSearchTerm)));
}
else
{
baseQuery = baseQuery.Where(e => e.CleanName!.Contains(cleanedSearchTerm) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().Contains(originalSearchTerm)));
}
}
if (filter.IsFolder.HasValue)
@@ -1746,7 +1836,8 @@ public sealed class BaseItemRepository
if (!string.IsNullOrWhiteSpace(filter.Path))
{
baseQuery = baseQuery.Where(e => e.Path == filter.Path);
var pathToQuery = GetPathToSave(filter.Path);
baseQuery = baseQuery.Where(e => e.Path == pathToQuery);
}
if (!string.IsNullOrWhiteSpace(filter.PresentationUniqueKey))
@@ -1865,10 +1956,17 @@ public sealed class BaseItemRepository
if (filter.PersonIds.Length > 0)
{
var peopleEntityIds = context.BaseItems
.WhereOneOrMany(filter.PersonIds, b => b.Id)
.Join(
context.Peoples,
b => b.Name,
p => p.Name,
(b, p) => p.Id);
baseQuery = baseQuery
.Where(e =>
context.PeopleBaseItemMap.Where(w => context.BaseItems.Where(r => filter.PersonIds.Contains(r.Id)).Any(f => f.Name == w.People.Name))
.Any(f => f.ItemId == e.Id));
.Where(e => context.PeopleBaseItemMap
.Any(m => m.ItemId == e.Id && peopleEntityIds.Contains(m.PeopleId)));
}
if (!string.IsNullOrWhiteSpace(filter.Person))
@@ -1896,34 +1994,50 @@ public sealed class BaseItemRepository
if (!string.IsNullOrWhiteSpace(filter.Name))
{
var cleanName = GetCleanValue(filter.Name);
baseQuery = baseQuery.Where(e => e.CleanName == cleanName);
if (filter.UseRawName == true)
{
baseQuery = baseQuery.Where(e => e.Name == filter.Name);
}
else
{
var cleanName = GetCleanValue(filter.Name);
baseQuery = baseQuery.Where(e => e.CleanName == cleanName);
}
}
// These are the same, for now
var nameContains = filter.NameContains;
if (!string.IsNullOrWhiteSpace(nameContains))
{
baseQuery = baseQuery.Where(e =>
e.CleanName!.Contains(nameContains)
|| e.OriginalTitle!.ToLower().Contains(nameContains!));
if (SearchWildcardTerms.Any(f => nameContains.Contains(f)))
{
nameContains = $"%{nameContains.Trim('%')}%";
baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName, nameContains) || EF.Functions.Like(e.OriginalTitle, nameContains));
}
else
{
baseQuery = baseQuery.Where(e =>
e.CleanName!.Contains(nameContains)
|| e.OriginalTitle!.ToLower().Contains(nameContains!));
}
}
if (!string.IsNullOrWhiteSpace(filter.NameStartsWith))
{
baseQuery = baseQuery.Where(e => e.SortName!.StartsWith(filter.NameStartsWith));
var startsWithLower = filter.NameStartsWith.ToLowerInvariant();
baseQuery = baseQuery.Where(e => e.SortName!.StartsWith(startsWithLower));
}
if (!string.IsNullOrWhiteSpace(filter.NameStartsWithOrGreater))
{
// i hate this
baseQuery = baseQuery.Where(e => e.SortName!.FirstOrDefault() > filter.NameStartsWithOrGreater[0] || e.Name!.FirstOrDefault() > filter.NameStartsWithOrGreater[0]);
var startsOrGreaterLower = filter.NameStartsWithOrGreater.ToLowerInvariant();
baseQuery = baseQuery.Where(e => e.SortName!.CompareTo(startsOrGreaterLower) >= 0);
}
if (!string.IsNullOrWhiteSpace(filter.NameLessThan))
{
// i hate this
baseQuery = baseQuery.Where(e => e.SortName!.FirstOrDefault() < filter.NameLessThan[0] || e.Name!.FirstOrDefault() < filter.NameLessThan[0]);
var lessThanLower = filter.NameLessThan.ToLowerInvariant();
baseQuery = baseQuery.Where(e => e.SortName!.CompareTo(lessThanLower ) < 0);
}
if (filter.ImageTypes.Length > 0)
@@ -1989,7 +2103,7 @@ public sealed class BaseItemRepository
if (filter.ArtistIds.Length > 0)
{
baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Artist, filter.ArtistIds);
baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ArtistIds);
}
if (filter.AlbumArtistIds.Length > 0)
@@ -1999,7 +2113,18 @@ public sealed class BaseItemRepository
if (filter.ContributingArtistIds.Length > 0)
{
baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Artist, filter.ContributingArtistIds);
var contributingNames = context.BaseItems
.Where(b => filter.ContributingArtistIds.Contains(b.Id))
.Select(b => b.CleanName);
baseQuery = baseQuery.Where(e =>
e.ItemValues!.Any(ivm =>
ivm.ItemValue.Type == ItemValueType.Artist &&
contributingNames.Contains(ivm.ItemValue.CleanValue))
&&
!e.ItemValues!.Any(ivm =>
ivm.ItemValue.Type == ItemValueType.AlbumArtist &&
contributingNames.Contains(ivm.ItemValue.CleanValue)));
}
if (filter.AlbumIds.Length > 0)
@@ -2010,7 +2135,7 @@ public sealed class BaseItemRepository
if (filter.ExcludeArtistIds.Length > 0)
{
baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Artist, filter.ExcludeArtistIds, true);
baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true);
}
if (filter.GenreIds.Count > 0)
@@ -2054,22 +2179,26 @@ public sealed class BaseItemRepository
if (filter.MinParentalRating != null)
{
var min = filter.MinParentalRating;
minParentalRatingFilter = e => e.InheritedParentalRatingValue >= min.Score || e.InheritedParentalRatingValue == null;
if (min.SubScore != null)
{
minParentalRatingFilter = minParentalRatingFilter.And(e => e.InheritedParentalRatingValue >= min.SubScore || e.InheritedParentalRatingValue == null);
}
var minScore = min.Score;
var minSubScore = min.SubScore ?? 0;
minParentalRatingFilter = e =>
e.InheritedParentalRatingValue == null ||
e.InheritedParentalRatingValue > minScore ||
(e.InheritedParentalRatingValue == minScore && (e.InheritedParentalRatingSubValue ?? 0) >= minSubScore);
}
Expression<Func<BaseItemEntity, bool>>? maxParentalRatingFilter = null;
if (filter.MaxParentalRating != null)
{
var max = filter.MaxParentalRating;
maxParentalRatingFilter = e => e.InheritedParentalRatingValue <= max.Score || e.InheritedParentalRatingValue == null;
if (max.SubScore != null)
{
maxParentalRatingFilter = maxParentalRatingFilter.And(e => e.InheritedParentalRatingValue <= max.SubScore || e.InheritedParentalRatingValue == null);
}
var maxScore = max.Score;
var maxSubScore = max.SubScore ?? 0;
maxParentalRatingFilter = e =>
e.InheritedParentalRatingValue == null ||
e.InheritedParentalRatingValue < maxScore ||
(e.InheritedParentalRatingValue == maxScore && (e.InheritedParentalRatingSubValue ?? 0) <= maxSubScore);
}
if (filter.HasParentalRating ?? false)
@@ -2313,17 +2442,23 @@ public sealed class BaseItemRepository
if (filter.HasImdbId.HasValue)
{
baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "imdb"));
baseQuery = filter.HasImdbId.Value
? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Imdb.ToString().ToLower()))
: baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Imdb.ToString().ToLower()));
}
if (filter.HasTmdbId.HasValue)
{
baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "tmdb"));
baseQuery = filter.HasTmdbId.Value
? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Tmdb.ToString().ToLower()))
: baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Tmdb.ToString().ToLower()));
}
if (filter.HasTvdbId.HasValue)
{
baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "tvdb"));
baseQuery = filter.HasTvdbId.Value
? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Tvdb.ToString().ToLower()))
: baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Tvdb.ToString().ToLower()));
}
var queryTopParentIds = filter.TopParentIds;
@@ -2361,40 +2496,24 @@ public sealed class BaseItemRepository
if (filter.ExcludeInheritedTags.Length > 0)
{
baseQuery = baseQuery
.Where(e => !e.ItemValues!.Where(w => w.ItemValue.Type == ItemValueType.InheritedTags || w.ItemValue.Type == ItemValueType.Tags)
.Any(f => filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue)));
var excludedTags = filter.ExcludeInheritedTags;
baseQuery = baseQuery.Where(e =>
!e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue))
&& (!e.SeriesId.HasValue || !context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue))));
}
if (filter.IncludeInheritedTags.Length > 0)
{
// Episodes do not store inherit tags from their parents in the database, and the tag may be still required by the client.
// In addition to the tags for the episodes themselves, we need to manually query its parent (the season)'s tags as well.
if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Episode)
{
baseQuery = baseQuery
.Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags)
.Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))
||
(e.ParentId.HasValue && context.ItemValuesMap.Where(w => w.ItemId == e.ParentId.Value && (w.ItemValue.Type == ItemValueType.InheritedTags || w.ItemValue.Type == ItemValueType.Tags))
.Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))));
}
var includeTags = filter.IncludeInheritedTags;
var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist;
baseQuery = baseQuery.Where(e =>
e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))
// A playlist should be accessible to its owner regardless of allowed tags.
else if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Playlist)
{
baseQuery = baseQuery
.Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags)
.Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))
|| e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""));
// d ^^ this is stupid it hate this.
}
else
{
baseQuery = baseQuery
.Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags)
.Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)));
}
// For seasons and episodes, we also need to check the parent series' tags.
|| (e.SeriesId.HasValue && context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)))
// A playlist should be accessible to its owner regardless of allowed tags
|| (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\"")));
}
if (filter.SeriesStatuses.Length > 0)
@@ -2548,6 +2667,21 @@ public sealed class BaseItemRepository
.Where(e => artistNames.Contains(e.Name))
.ToArray();
return artists.GroupBy(e => e.Name).ToDictionary(e => e.Key!, e => e.Select(f => DeserializeBaseItem(f)).Cast<MusicArtist>().ToArray());
var lookup = artists
.GroupBy(e => e.Name!)
.ToDictionary(
g => g.Key,
g => g.Select(f => DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray());
var result = new Dictionary<string, MusicArtist[]>(artistNames.Count);
foreach (var name in artistNames)
{
if (lookup.TryGetValue(name, out var artistArray))
{
result[name] = artistArray;
}
}
return result;
}
}
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Drawing;
@@ -82,11 +84,14 @@ public class ChapterRepository : IChapterRepository
}
/// <inheritdoc />
public void DeleteChapters(Guid itemId)
public async Task DeleteChaptersAsync(Guid itemId, CancellationToken cancellationToken)
{
using var context = _dbProvider.CreateDbContext();
context.Chapters.Where(c => c.ItemId.Equals(itemId)).ExecuteDelete();
context.SaveChanges();
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
await dbContext.Chapters.Where(c => c.ItemId.Equals(itemId)).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false);
await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
}
private Chapter Map(ChapterInfo chapterInfo, int index, Guid itemId)
@@ -1,8 +1,12 @@
#pragma warning disable RS0030 // Do not use banned APIs
using System;
using System.Linq;
using System.Linq.Expressions;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities;
using Microsoft.EntityFrameworkCore;
@@ -18,40 +22,77 @@ public static class OrderMapper
/// </summary>
/// <param name="sortBy">Item property to sort by.</param>
/// <param name="query">Context Query.</param>
/// <param name="jellyfinDbContext">Context.</param>
/// <returns>Func to be executed later for sorting query.</returns>
public static Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy, InternalItemsQuery query)
public static Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy, InternalItemsQuery query, JellyfinDbContext jellyfinDbContext)
{
return sortBy switch
return (sortBy, query.User) switch
{
ItemSortBy.AirTime => e => e.SortName, // TODO
ItemSortBy.Runtime => e => e.RunTimeTicks,
ItemSortBy.Random => e => EF.Functions.Random(),
ItemSortBy.DatePlayed => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.LastPlayedDate,
ItemSortBy.PlayCount => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.PlayCount,
ItemSortBy.IsFavoriteOrLiked => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.IsFavorite,
ItemSortBy.IsFolder => e => e.IsFolder,
ItemSortBy.IsPlayed => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
ItemSortBy.IsUnplayed => e => !e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
ItemSortBy.DateLastContentAdded => e => e.DateLastMediaAdded,
ItemSortBy.Artist => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Artist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
ItemSortBy.AlbumArtist => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.AlbumArtist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
ItemSortBy.Studio => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Studios).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
ItemSortBy.OfficialRating => e => e.InheritedParentalRatingValue,
// ItemSortBy.SeriesDatePlayed => "(Select MAX(LastPlayedDate) from TypedBaseItems B" + GetJoinUserDataText(query) + " where Played=1 and B.SeriesPresentationUniqueKey=A.PresentationUniqueKey)",
ItemSortBy.SeriesSortName => e => e.SeriesName,
(ItemSortBy.AirTime, _) => e => e.SortName, // TODO
(ItemSortBy.Runtime, _) => e => e.RunTimeTicks,
(ItemSortBy.Random, _) => e => EF.Functions.Random(),
(ItemSortBy.DatePlayed, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.LastPlayedDate,
(ItemSortBy.PlayCount, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.PlayCount,
(ItemSortBy.IsFavoriteOrLiked, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.IsFavorite,
(ItemSortBy.IsFolder, _) => e => e.IsFolder,
(ItemSortBy.IsPlayed, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
(ItemSortBy.IsUnplayed, _) => e => !e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
(ItemSortBy.DateLastContentAdded, _) => e => e.DateLastMediaAdded,
(ItemSortBy.Artist, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Artist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
(ItemSortBy.AlbumArtist, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.AlbumArtist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
(ItemSortBy.Studio, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Studios).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
(ItemSortBy.OfficialRating, _) => e => e.InheritedParentalRatingValue,
(ItemSortBy.SeriesSortName, _) => e => e.SeriesName,
(ItemSortBy.Album, _) => e => e.Album,
(ItemSortBy.DateCreated, _) => e => e.DateCreated,
(ItemSortBy.PremiereDate, _) => e => (e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null)),
(ItemSortBy.StartDate, _) => e => e.StartDate,
(ItemSortBy.Name, _) => e => e.CleanName,
(ItemSortBy.CommunityRating, _) => e => e.CommunityRating,
(ItemSortBy.ProductionYear, _) => e => e.ProductionYear,
(ItemSortBy.CriticRating, _) => e => e.CriticRating,
(ItemSortBy.VideoBitRate, _) => e => e.TotalBitrate,
(ItemSortBy.ParentIndexNumber, _) => e => e.ParentIndexNumber,
(ItemSortBy.IndexNumber, _) => e => e.IndexNumber,
(ItemSortBy.SeriesDatePlayed, not null) => e =>
jellyfinDbContext.BaseItems
.Where(w => w.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
.Join(jellyfinDbContext.UserData.Where(w => w.UserId == query.User.Id && w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate)
.Max(f => f),
(ItemSortBy.SeriesDatePlayed, null) => e => jellyfinDbContext.BaseItems.Where(w => w.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
.Join(jellyfinDbContext.UserData.Where(w => w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate)
.Max(f => f),
// ItemSortBy.SeriesDatePlayed => e => jellyfinDbContext.UserData
// .Where(u => u.Item!.SeriesPresentationUniqueKey == e.PresentationUniqueKey && u.Played)
// .Max(f => f.LastPlayedDate),
// ItemSortBy.AiredEpisodeOrder => "AiredEpisodeOrder",
ItemSortBy.Album => e => e.Album,
ItemSortBy.DateCreated => e => e.DateCreated,
ItemSortBy.PremiereDate => e => (e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null)),
ItemSortBy.StartDate => e => e.StartDate,
ItemSortBy.Name => e => e.CleanName,
ItemSortBy.CommunityRating => e => e.CommunityRating,
ItemSortBy.ProductionYear => e => e.ProductionYear,
ItemSortBy.CriticRating => e => e.CriticRating,
ItemSortBy.VideoBitRate => e => e.TotalBitrate,
ItemSortBy.ParentIndexNumber => e => e.ParentIndexNumber,
ItemSortBy.IndexNumber => e => e.IndexNumber,
_ => e => e.SortName
};
}
/// <summary>
/// Creates an expression to order search results by match quality.
/// Prioritizes: exact match (0) > prefix match with word boundary (1) > prefix match (2) > contains (3).
/// </summary>
/// <param name="searchTerm">The search term to match against.</param>
/// <returns>An expression that returns an integer representing match quality (lower is better).</returns>
public static Expression<Func<BaseItemEntity, int>> MapSearchRelevanceOrder(string searchTerm)
{
var cleanSearchTerm = GetCleanValue(searchTerm);
var searchPrefix = cleanSearchTerm + " ";
return e =>
e.CleanName == cleanSearchTerm ? 0 :
e.CleanName!.StartsWith(searchPrefix) ? 1 :
e.CleanName!.StartsWith(cleanSearchTerm) ? 2 : 3;
}
private static string GetCleanValue(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return value;
}
return value.RemoveDiacritics().ToLowerInvariant();
}
}
@@ -95,6 +95,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
.ToArray();
var toAdd = people
.Where(e => e.Type is not PersonKind.Artist && e.Type is not PersonKind.AlbumArtist)
.Where(e => !existingPersons.Any(f => f.Name == e.Name && f.PersonType == e.Type.ToString()))
.Select(Map);
context.Peoples.AddRange(toAdd);
@@ -108,6 +109,11 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
foreach (var person in people)
{
if (person.Type == PersonKind.Artist || person.Type == PersonKind.AlbumArtist)
{
continue;
}
var entityPerson = personsEntities.First(e => e.Name == person.Name && e.PersonType == person.Type.ToString());
var existingMap = existingMaps.FirstOrDefault(e => e.People.Name == person.Name && e.People.PersonType == person.Type.ToString() && e.Role == person.Role);
if (existingMap is null)
@@ -36,6 +36,7 @@
<ProjectReference Include="..\MediaBrowser.Controller\MediaBrowser.Controller.csproj" />
<ProjectReference Include="..\MediaBrowser.Model\MediaBrowser.Model.csproj" />
<ProjectReference Include="..\src\Jellyfin.Database\Jellyfin.Database.Implementations\Jellyfin.Database.Implementations.csproj" />
<ProjectReference Include="..\src\Jellyfin.Database\Jellyfin.Database.Providers.PostgreSQL\Jellyfin.Database.Providers.PostgreSQL.csproj" />
<ProjectReference Include="..\src\Jellyfin.Database\Jellyfin.Database.Providers.Sqlite\Jellyfin.Database.Providers.Sqlite.csproj" />
</ItemGroup>
@@ -13,8 +13,7 @@ namespace Jellyfin.Server.Implementations.StorageHelpers;
public static class StorageHelper
{
private const long TwoGigabyte = 2_147_483_647L;
private const long FiveHundredAndTwelveMegaByte = 536_870_911L;
private static readonly string[] _byteHumanizedSuffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"];
private static readonly string[] _byteHumanizedSuffixes = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"];
/// <summary>
/// Tests the available storage capacity on the jellyfin paths with estimated minimum values.
@@ -24,10 +23,8 @@ public static class StorageHelper
public static void TestCommonPathsForStorageCapacity(IApplicationPaths applicationPaths, ILogger logger)
{
TestDataDirectorySize(applicationPaths.DataPath, logger, TwoGigabyte);
TestDataDirectorySize(applicationPaths.LogDirectoryPath, logger, FiveHundredAndTwelveMegaByte);
TestDataDirectorySize(applicationPaths.CachePath, logger, TwoGigabyte);
TestDataDirectorySize(applicationPaths.ProgramDataPath, logger, TwoGigabyte);
TestDataDirectorySize(applicationPaths.TempDirectory, logger, TwoGigabyte);
}
/// <summary>
@@ -77,7 +74,7 @@ public static class StorageHelper
var drive = new DriveInfo(path);
if (threshold != -1 && drive.AvailableFreeSpace < threshold)
{
throw new InvalidOperationException($"The path `{path}` has insufficient free space. Required: at least {HumanizeStorageSize(threshold)}.");
throw new InvalidOperationException($"The path `{path}` has insufficient free space. Available: {HumanizeStorageSize(drive.AvailableFreeSpace)}, Required: {HumanizeStorageSize(threshold)}.");
}
logger.LogInformation(
@@ -254,10 +254,10 @@ public class TrickplayManager : ITrickplayManager
}
// We support video backdrops, but we should not generate trickplay images for them
var parentDirectory = Directory.GetParent(mediaPath);
var parentDirectory = Directory.GetParent(video.Path);
if (parentDirectory is not null && string.Equals(parentDirectory.Name, "backdrops", StringComparison.OrdinalIgnoreCase))
{
_logger.LogDebug("Ignoring backdrop media found at {Path} for item {ItemID}", mediaPath, video.Id);
_logger.LogDebug("Ignoring backdrop media found at {Path} for item {ItemID}", video.Path, video.Id);
return;
}
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Security.Cryptography;
using System.Text.Json;
@@ -92,33 +93,38 @@ namespace Jellyfin.Server.Implementations.Users
}
/// <inheritdoc />
public async Task<ForgotPasswordResult> StartForgotPasswordProcess(User user, bool isInNetwork)
public async Task<ForgotPasswordResult> StartForgotPasswordProcess(User? user, string enteredUsername, bool isInNetwork)
{
byte[] bytes = new byte[4];
RandomNumberGenerator.Fill(bytes);
string pin = BitConverter.ToString(bytes);
DateTime expireTime = DateTime.UtcNow.AddMinutes(30);
string filePath = _passwordResetFileBase + user.Id + ".json";
SerializablePasswordReset spr = new SerializablePasswordReset
{
ExpirationDate = expireTime,
Pin = pin,
PinFile = filePath,
UserName = user.Username
};
var usernameHash = enteredUsername.ToUpperInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture);
var pinFile = _passwordResetFileBase + usernameHash + ".json";
FileStream fileStream = AsyncFile.OpenWrite(filePath);
await using (fileStream.ConfigureAwait(false))
if (user is not null && isInNetwork)
{
await JsonSerializer.SerializeAsync(fileStream, spr).ConfigureAwait(false);
byte[] bytes = new byte[4];
RandomNumberGenerator.Fill(bytes);
string pin = BitConverter.ToString(bytes);
SerializablePasswordReset spr = new SerializablePasswordReset
{
ExpirationDate = expireTime,
Pin = pin,
PinFile = pinFile,
UserName = user.Username
};
FileStream fileStream = AsyncFile.Create(pinFile);
await using (fileStream.ConfigureAwait(false))
{
await JsonSerializer.SerializeAsync(fileStream, spr).ConfigureAwait(false);
}
}
return new ForgotPasswordResult
{
Action = ForgotPasswordAction.PinCode,
PinExpirationDate = expireTime,
PinFile = filePath
PinFile = pinFile
};
}
@@ -149,7 +149,7 @@ namespace Jellyfin.Server.Implementations.Users
ThrowIfInvalidUsername(newName);
if (user.Username.Equals(newName, StringComparison.OrdinalIgnoreCase))
if (user.Username.Equals(newName, StringComparison.Ordinal))
{
throw new ArgumentException("The new and old names must be different.");
}
@@ -508,23 +508,18 @@ namespace Jellyfin.Server.Implementations.Users
public async Task<ForgotPasswordResult> StartForgotPasswordProcess(string enteredUsername, bool isInNetwork)
{
var user = string.IsNullOrWhiteSpace(enteredUsername) ? null : GetUserByName(enteredUsername);
var passwordResetProvider = GetPasswordResetProvider(user);
var result = await passwordResetProvider
.StartForgotPasswordProcess(user, enteredUsername, isInNetwork)
.ConfigureAwait(false);
if (user is not null && isInNetwork)
{
var passwordResetProvider = GetPasswordResetProvider(user);
var result = await passwordResetProvider
.StartForgotPasswordProcess(user, isInNetwork)
.ConfigureAwait(false);
await UpdateUserAsync(user).ConfigureAwait(false);
return result;
}
return new ForgotPasswordResult
{
Action = ForgotPasswordAction.InNetworkRequired,
PinFile = string.Empty
};
return result;
}
/// <inheritdoc/>
@@ -760,8 +755,13 @@ namespace Jellyfin.Server.Implementations.Users
return GetAuthenticationProviders(user)[0];
}
private IPasswordResetProvider GetPasswordResetProvider(User user)
private IPasswordResetProvider GetPasswordResetProvider(User? user)
{
if (user is null)
{
return _defaultPasswordResetProvider;
}
return GetPasswordResetProviders(user)[0];
}
+31
View File
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Reflection;
using Emby.Server.Implementations;
using Emby.Server.Implementations.MediaEncoding;
using Emby.Server.Implementations.Session;
using Jellyfin.Api.WebSocketListeners;
using Jellyfin.Database.Implementations;
@@ -23,6 +24,7 @@ using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Events;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Lyrics;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Security;
using MediaBrowser.Controller.Trickplay;
@@ -31,6 +33,7 @@ using MediaBrowser.Providers.Lyric;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using StackExchange.Redis;
namespace Jellyfin.Server
{
@@ -39,6 +42,8 @@ namespace Jellyfin.Server
/// </summary>
public class CoreAppHost : ApplicationHost
{
private readonly IConfiguration _startupConfig;
/// <summary>
/// Initializes a new instance of the <see cref="CoreAppHost" /> class.
/// </summary>
@@ -57,6 +62,7 @@ namespace Jellyfin.Server
options,
startupConfig)
{
_startupConfig = startupConfig;
}
/// <inheritdoc/>
@@ -98,6 +104,31 @@ namespace Jellyfin.Server
serviceCollection.AddScoped<IAuthenticationManager, AuthenticationManager>();
// Transcode session store: Redis-backed when configured, no-op otherwise.
serviceCollection.Configure<TranscodeStoreOptions>(_startupConfig.GetSection("Jellyfin:TranscodeStore"));
var redisConnectionString = _startupConfig["Jellyfin:TranscodeStore:RedisConnectionString"];
if (!string.IsNullOrEmpty(redisConnectionString))
{
serviceCollection.AddSingleton<IConnectionMultiplexer>(sp =>
{
try
{
return ConnectionMultiplexer.Connect(redisConnectionString);
}
catch (Exception ex)
{
sp.GetRequiredService<ILogger<CoreAppHost>>()
.LogError(ex, "Failed to connect to Redis. Check the Jellyfin:TranscodeStore:RedisConnectionString configuration.");
throw;
}
});
serviceCollection.AddSingleton<ITranscodeSessionStore, RedisTranscodeSessionStore>();
}
else
{
serviceCollection.AddSingleton<ITranscodeSessionStore, NullTranscodeSessionStore>();
}
foreach (var type in GetExportTypes<ILyricProvider>())
{
serviceCollection.AddSingleton(typeof(ILyricProvider), type);
@@ -33,9 +33,11 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Cors.Infrastructure;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Interfaces;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.Swagger;
using Swashbuckle.AspNetCore.SwaggerGen;
using AuthenticationSchemes = Jellyfin.Api.Constants.AuthenticationSchemes;
@@ -259,7 +261,8 @@ namespace Jellyfin.Server.Extensions
c.OperationFilter<FileRequestFilter>();
c.OperationFilter<ParameterObsoleteFilter>();
c.DocumentFilter<AdditionalModelFilter>();
});
})
.Replace(ServiceDescriptor.Transient<ISwaggerProvider, CachingOpenApiProvider>());
}
private static void AddPolicy(this AuthorizationOptions authorizationOptions, string policyName, IAuthorizationRequirement authorizationRequirement)
@@ -0,0 +1,89 @@
using System;
using System.Threading;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.Swagger;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace Jellyfin.Server.Filters;
/// <summary>
/// OpenApi provider with caching.
/// </summary>
internal sealed class CachingOpenApiProvider : ISwaggerProvider
{
private const string CacheKey = "openapi.json";
private static readonly MemoryCacheEntryOptions _cacheOptions = new() { SlidingExpiration = TimeSpan.FromMinutes(5) };
private static readonly SemaphoreSlim _lock = new(1, 1);
private static readonly TimeSpan _lockTimeout = TimeSpan.FromSeconds(1);
private readonly IMemoryCache _memoryCache;
private readonly SwaggerGenerator _swaggerGenerator;
private readonly SwaggerGeneratorOptions _swaggerGeneratorOptions;
/// <summary>
/// Initializes a new instance of the <see cref="CachingOpenApiProvider"/> class.
/// </summary>
/// <param name="optionsAccessor">The options accessor.</param>
/// <param name="apiDescriptionsProvider">The api descriptions provider.</param>
/// <param name="schemaGenerator">The schema generator.</param>
/// <param name="memoryCache">The memory cache.</param>
public CachingOpenApiProvider(
IOptions<SwaggerGeneratorOptions> optionsAccessor,
IApiDescriptionGroupCollectionProvider apiDescriptionsProvider,
ISchemaGenerator schemaGenerator,
IMemoryCache memoryCache)
{
_swaggerGeneratorOptions = optionsAccessor.Value;
_swaggerGenerator = new SwaggerGenerator(_swaggerGeneratorOptions, apiDescriptionsProvider, schemaGenerator);
_memoryCache = memoryCache;
}
/// <inheritdoc />
public OpenApiDocument GetSwagger(string documentName, string? host = null, string? basePath = null)
{
if (_memoryCache.TryGetValue(CacheKey, out OpenApiDocument? openApiDocument) && openApiDocument is not null)
{
return AdjustDocument(openApiDocument, host, basePath);
}
var acquired = _lock.Wait(_lockTimeout);
try
{
if (_memoryCache.TryGetValue(CacheKey, out openApiDocument) && openApiDocument is not null)
{
return AdjustDocument(openApiDocument, host, basePath);
}
if (!acquired)
{
throw new InvalidOperationException("OpenApi document is generating");
}
openApiDocument = _swaggerGenerator.GetSwagger(documentName);
_memoryCache.Set(CacheKey, openApiDocument, _cacheOptions);
return AdjustDocument(openApiDocument, host, basePath);
}
finally
{
if (acquired)
{
_lock.Release();
}
}
}
private OpenApiDocument AdjustDocument(OpenApiDocument document, string? host, string? basePath)
{
document.Servers = _swaggerGeneratorOptions.Servers.Count != 0
? _swaggerGeneratorOptions.Servers
: string.IsNullOrEmpty(host) && string.IsNullOrEmpty(basePath)
? []
: [new OpenApiServer { Url = $"{host}{basePath}" }];
return document;
}
}
@@ -8,7 +8,7 @@ internal class RetryOnTemporarilyUnavailableFilter : IOperationFilter
{
public void Apply(OpenApiOperation operation, OperationFilterContext context)
{
operation.Responses.Add(
operation.Responses.TryAdd(
"503",
new OpenApiResponse
{
@@ -66,15 +66,8 @@ public class SecurityRequirementsOperationFilter : IOperationFilter
return;
}
if (!operation.Responses.ContainsKey("401"))
{
operation.Responses.Add("401", new OpenApiResponse { Description = "Unauthorized" });
}
if (!operation.Responses.ContainsKey("403"))
{
operation.Responses.Add("403", new OpenApiResponse { Description = "Forbidden" });
}
operation.Responses.TryAdd("401", new OpenApiResponse { Description = "Unauthorized" });
operation.Responses.TryAdd("403", new OpenApiResponse { Description = "Forbidden" });
var scheme = new OpenApiSecurityScheme
{
@@ -1,151 +0,0 @@
// The MIT License (MIT)
//
// Copyright (c) .NET Foundation and Contributors
//
// All rights reserved.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.Logging;
using Microsoft.Net.Http.Headers;
namespace Jellyfin.Server.Infrastructure
{
/// <inheritdoc />
public class SymlinkFollowingPhysicalFileResultExecutor : PhysicalFileResultExecutor
{
/// <summary>
/// Initializes a new instance of the <see cref="SymlinkFollowingPhysicalFileResultExecutor"/> class.
/// </summary>
/// <param name="loggerFactory">An instance of the <see cref="ILoggerFactory"/> interface.</param>
public SymlinkFollowingPhysicalFileResultExecutor(ILoggerFactory loggerFactory) : base(loggerFactory)
{
}
/// <inheritdoc />
protected override FileMetadata GetFileInfo(string path)
{
var fileInfo = new FileInfo(path);
var length = fileInfo.Length;
// This may or may not be fixed in .NET 6, but looks like it will not https://github.com/dotnet/aspnetcore/issues/34371
if ((fileInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint)
{
using var fileHandle = File.OpenHandle(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
length = RandomAccess.GetLength(fileHandle);
}
return new FileMetadata
{
Exists = fileInfo.Exists,
Length = length,
LastModified = fileInfo.LastWriteTimeUtc
};
}
/// <inheritdoc />
protected override async Task WriteFileAsync(ActionContext context, PhysicalFileResult result, RangeItemHeaderValue? range, long rangeLength)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(result);
if (range is not null && rangeLength == 0)
{
return;
}
// It's a bit of wasted IO to perform this check again, but non-symlinks shouldn't use this code
if (!IsSymLink(result.FileName))
{
await base.WriteFileAsync(context, result, range, rangeLength).ConfigureAwait(false);
return;
}
var response = context.HttpContext.Response;
if (range is not null)
{
await SendFileAsync(
result.FileName,
response,
offset: range.From ?? 0L,
count: rangeLength).ConfigureAwait(false);
return;
}
await SendFileAsync(
result.FileName,
response,
offset: 0,
count: null).ConfigureAwait(false);
}
private async Task SendFileAsync(string filePath, HttpResponse response, long offset, long? count, CancellationToken cancellationToken = default)
{
var fileInfo = GetFileInfo(filePath);
if (offset < 0 || offset > fileInfo.Length)
{
throw new ArgumentOutOfRangeException(nameof(offset), offset, string.Empty);
}
if (count.HasValue
&& (count.Value < 0 || count.Value > fileInfo.Length - offset))
{
throw new ArgumentOutOfRangeException(nameof(count), count, string.Empty);
}
// Copied from SendFileFallback.SendFileAsync
const int BufferSize = 1024 * 16;
var useRequestAborted = !cancellationToken.CanBeCanceled;
var localCancel = useRequestAborted ? response.HttpContext.RequestAborted : cancellationToken;
var fileStream = new FileStream(
filePath,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite,
bufferSize: BufferSize,
options: FileOptions.Asynchronous | FileOptions.SequentialScan);
await using (fileStream.ConfigureAwait(false))
{
try
{
localCancel.ThrowIfCancellationRequested();
fileStream.Seek(offset, SeekOrigin.Begin);
await StreamCopyOperation
.CopyToAsync(fileStream, response.Body, count, BufferSize, localCancel)
.ConfigureAwait(true);
}
catch (OperationCanceledException) when (useRequestAborted)
{
}
}
}
private static bool IsSymLink(string path) => (File.GetAttributes(path) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint;
}
}
+2 -1
View File
@@ -59,6 +59,7 @@
<PackageReference Include="Serilog.Sinks.Console" />
<PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="Serilog.Sinks.Graylog" />
<PackageReference Include="StackExchange.Redis" />
</ItemGroup>
<ItemGroup>
@@ -78,7 +79,7 @@
<None Update="wwwroot\api-docs\swagger\custom.css">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="wwwroot\api-docs\banner-dark.svg">
<None Update="wwwroot\api-docs\jellyfin.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="ServerSetupApp/index.mstemplate.html">
@@ -0,0 +1,47 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Server.ServerSetupApp;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Cleans up all Music artists that have been migrated in the 10.11 RC migrations.
/// </summary>
[JellyfinMigration("2025-10-09T20:00:00", nameof(CleanMusicArtist))]
[JellyfinMigrationBackup(JellyfinDb = true)]
public class CleanMusicArtist : IAsyncMigrationRoutine
{
private readonly IStartupLogger<CleanMusicArtist> _startupLogger;
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
/// <summary>
/// Initializes a new instance of the <see cref="CleanMusicArtist"/> class.
/// </summary>
/// <param name="startupLogger">The startup logger.</param>
/// <param name="dbContextFactory">The Db context factory.</param>
public CleanMusicArtist(IStartupLogger<CleanMusicArtist> startupLogger, IDbContextFactory<JellyfinDbContext> dbContextFactory)
{
_startupLogger = startupLogger;
_dbContextFactory = dbContextFactory;
}
/// <inheritdoc/>
public async Task PerformAsync(CancellationToken cancellationToken)
{
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
var peoples = context.Peoples.Where(e => e.PersonType == nameof(PersonKind.Artist) || e.PersonType == nameof(PersonKind.AlbumArtist));
_startupLogger.LogInformation("Delete {Number} Artist and Album Artist person types from db", await peoples.CountAsync(cancellationToken).ConfigureAwait(false));
await peoples
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
}
}
}
@@ -0,0 +1,106 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Globalization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Migration to fix broken library subtitle download languages.
/// </summary>
[JellyfinMigration("2026-02-06T20:00:00", nameof(FixLibrarySubtitleDownloadLanguages))]
internal class FixLibrarySubtitleDownloadLanguages : IAsyncMigrationRoutine
{
private readonly ILocalizationManager _localizationManager;
private readonly ILibraryManager _libraryManager;
private readonly ILogger _logger;
/// <summary>
/// Initializes a new instance of the <see cref="FixLibrarySubtitleDownloadLanguages"/> class.
/// </summary>
/// <param name="localizationManager">The Localization manager.</param>
/// <param name="startupLogger">The startup logger for Startup UI integration.</param>
/// <param name="libraryManager">The Library manager.</param>
/// <param name="logger">The logger.</param>
public FixLibrarySubtitleDownloadLanguages(
ILocalizationManager localizationManager,
IStartupLogger<FixLibrarySubtitleDownloadLanguages> startupLogger,
ILibraryManager libraryManager,
ILogger<FixLibrarySubtitleDownloadLanguages> logger)
{
_localizationManager = localizationManager;
_libraryManager = libraryManager;
_logger = startupLogger.With(logger);
}
/// <inheritdoc />
public Task PerformAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting to fix library subtitle download languages.");
var virtualFolders = _libraryManager.GetVirtualFolders(false);
foreach (var virtualFolder in virtualFolders)
{
var options = virtualFolder.LibraryOptions;
if (options.SubtitleDownloadLanguages is null || options.SubtitleDownloadLanguages.Length == 0)
{
continue;
}
// Some virtual folders don't have a proper item id.
if (!Guid.TryParse(virtualFolder.ItemId, out var folderId))
{
continue;
}
var collectionFolder = _libraryManager.GetItemById<CollectionFolder>(folderId);
if (collectionFolder is null)
{
_logger.LogWarning("Could not find collection folder for virtual folder '{LibraryName}' with id '{FolderId}'. Skipping.", virtualFolder.Name, folderId);
continue;
}
var fixedLanguages = new List<string>();
foreach (var language in options.SubtitleDownloadLanguages)
{
var foundLanguage = _localizationManager.FindLanguageInfo(language)?.ThreeLetterISOLanguageName;
if (foundLanguage is not null)
{
// Converted ISO 639-2/B to T (ger to deu)
if (!string.Equals(foundLanguage, language, StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Converted '{Language}' to '{ResolvedLanguage}' in library '{LibraryName}'.", language, foundLanguage, virtualFolder.Name);
}
if (fixedLanguages.Contains(foundLanguage, StringComparer.OrdinalIgnoreCase))
{
_logger.LogInformation("Language '{Language}' already exists for library '{LibraryName}'. Skipping duplicate.", foundLanguage, virtualFolder.Name);
continue;
}
fixedLanguages.Add(foundLanguage);
}
else
{
_logger.LogInformation("Could not resolve language '{Language}' in library '{LibraryName}'. Skipping.", language, virtualFolder.Name);
}
}
options.SubtitleDownloadLanguages = [.. fixedLanguages];
collectionFolder.UpdateLibraryOptions(options);
}
_logger.LogInformation("Library subtitle download languages fixed.");
return Task.CompletedTask;
}
}
@@ -55,9 +55,25 @@ namespace Jellyfin.Server.Migrations.Routines
};
var dataPath = _paths.DataPath;
using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}"))
var activityLogPath = Path.Combine(dataPath, DbFilename);
if (!File.Exists(activityLogPath))
{
_logger.LogWarning("{ActivityLogDb} doesn't exist, nothing to migrate", activityLogPath);
return;
}
using (var connection = new SqliteConnection($"Filename={activityLogPath}"))
{
connection.Open();
var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='ActivityLog';");
foreach (var row in tableQuery)
{
if (row.GetInt32(0) == 0)
{
_logger.LogWarning("Table 'ActivityLog' doesn't exist in {ActivityLogPath}, nothing to migrate", activityLogPath);
return;
}
}
using var userDbConnection = new SqliteConnection($"Filename={Path.Combine(dataPath, "users.db")}");
userDbConnection.Open();
@@ -50,9 +50,28 @@ namespace Jellyfin.Server.Migrations.Routines
public void Perform()
{
var dataPath = _appPaths.DataPath;
using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}"))
var dbFilePath = Path.Combine(dataPath, DbFilename);
if (!File.Exists(dbFilePath))
{
_logger.LogWarning("{Path} doesn't exist, nothing to migrate", dbFilePath);
return;
}
using (var connection = new SqliteConnection($"Filename={dbFilePath}"))
{
connection.Open();
var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='Tokens';");
foreach (var row in tableQuery)
{
if (row.GetInt32(0) == 0)
{
_logger.LogWarning("Table 'Tokens' doesn't exist in {Path}, nothing to migrate", dbFilePath);
return;
}
}
using var dbContext = _dbProvider.CreateDbContext();
var authenticatedDevices = connection.Query("SELECT * FROM Tokens");
@@ -78,9 +78,27 @@ namespace Jellyfin.Server.Migrations.Routines
var displayPrefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var customDisplayPrefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var dbFilePath = Path.Combine(_paths.DataPath, DbFilename);
if (!File.Exists(dbFilePath))
{
_logger.LogWarning("{Path} doesn't exist, nothing to migrate", dbFilePath);
return;
}
using (var connection = new SqliteConnection($"Filename={dbFilePath}"))
{
connection.Open();
var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='userdisplaypreferences';");
foreach (var row in tableQuery)
{
if (row.GetInt32(0) == 0)
{
_logger.LogWarning("Table 'userdisplaypreferences' doesn't exist in {Path}, nothing to migrate", dbFilePath);
return;
}
}
using var dbContext = _provider.CreateDbContext();
var results = connection.Query("SELECT * FROM userdisplaypreferences");
@@ -122,6 +122,16 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine
{
lastWriteTimeUtc = File.GetLastWriteTimeUtc(filePath);
}
catch (ArgumentOutOfRangeException e)
{
_logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message);
return null;
}
catch (UnauthorizedAccessException e)
{
_logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message);
return null;
}
catch (IOException e)
{
_logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message);
@@ -135,14 +145,21 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine
return Path.Join(keyframeCachePath, prefix, filename);
}
private static bool TryReadFromCache(string? cachePath, [NotNullWhen(true)] out MediaEncoding.Keyframes.KeyframeData? cachedResult)
private bool TryReadFromCache(string? cachePath, [NotNullWhen(true)] out MediaEncoding.Keyframes.KeyframeData? cachedResult)
{
if (File.Exists(cachePath))
{
var bytes = File.ReadAllBytes(cachePath);
cachedResult = JsonSerializer.Deserialize<MediaEncoding.Keyframes.KeyframeData>(bytes, _jsonOptions);
try
{
var bytes = File.ReadAllBytes(cachePath);
cachedResult = JsonSerializer.Deserialize<MediaEncoding.Keyframes.KeyframeData>(bytes, _jsonOptions);
return cachedResult is not null;
return cachedResult is not null;
}
catch (JsonException jsonException)
{
_logger.LogWarning(jsonException, "Failed to read {Path}", cachePath);
}
}
cachedResult = null;
@@ -257,6 +257,11 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
continue;
}
if (!baseItemIds.Contains(refItem.Id))
{
continue;
}
userData.ItemId = refItem.Id;
operation.JellyfinDbContext.UserData.Add(userData);
}
@@ -287,7 +292,13 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
{
foreach (SqliteDataReader dto in connection.Query(mediaStreamQuery))
{
operation.JellyfinDbContext.MediaStreamInfos.Add(GetMediaStream(dto));
var entity = GetMediaStream(dto);
if (!baseItemIds.Contains(entity.ItemId))
{
continue;
}
operation.JellyfinDbContext.MediaStreamInfos.Add(entity);
}
}
@@ -310,7 +321,13 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
{
foreach (SqliteDataReader dto in connection.Query(mediaAttachmentQuery))
{
operation.JellyfinDbContext.AttachmentStreamInfos.Add(GetMediaAttachment(dto));
var entity = GetMediaAttachment(dto);
if (!baseItemIds.Contains(entity.ItemId))
{
continue;
}
operation.JellyfinDbContext.AttachmentStreamInfos.Add(entity);
}
}
@@ -366,8 +383,6 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
});
}
baseItemIds.Clear();
foreach (var item in peopleCache)
{
operation.JellyfinDbContext.Peoples.Add(item.Value.Person);
@@ -396,6 +411,11 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
foreach (SqliteDataReader dto in connection.Query(chapterQuery))
{
var chapter = GetChapter(dto);
if (!baseItemIds.Contains(chapter.ItemId))
{
continue;
}
operation.JellyfinDbContext.Chapters.Add(chapter);
}
}
@@ -422,6 +442,11 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
foreach (SqliteDataReader dto in connection.Query(ancestorIdsQuery))
{
var ancestorId = GetAncestorId(dto);
if (!baseItemIds.Contains(ancestorId.ItemId) || !baseItemIds.Contains(ancestorId.ParentItemId))
{
continue;
}
operation.JellyfinDbContext.AncestorIds.Add(ancestorId);
}
}
@@ -439,6 +464,16 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
SqliteConnection.ClearAllPools();
using (var checkpointConnection = new SqliteConnection($"Filename={libraryDbPath}"))
{
checkpointConnection.Open();
using var cmd = checkpointConnection.CreateCommand();
cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE);";
cmd.ExecuteNonQuery();
}
SqliteConnection.ClearAllPools();
_logger.LogInformation("Move {0} to {1}.", libraryDbPath, libraryDbPath + ".old");
File.Move(libraryDbPath, libraryDbPath + ".old", true);
}
@@ -1138,7 +1173,9 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
Item = null!,
ProviderId = e[0],
ProviderValue = string.Join('|', e.Skip(1))
}).ToArray();
})
.DistinctBy(e => e.ProviderId)
.ToArray();
}
if (reader.TryGetString(index++, out var imageInfos))
@@ -57,11 +57,28 @@ public class MigrateUserDb : IMigrationRoutine
public void Perform()
{
var dataPath = _paths.DataPath;
var userDbPath = Path.Combine(dataPath, DbFilename);
if (!File.Exists(userDbPath))
{
_logger.LogWarning("{UserDbPath} doesn't exist, nothing to migrate", userDbPath);
return;
}
_logger.LogInformation("Migrating the user database may take a while, do not stop Jellyfin.");
using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}"))
using (var connection = new SqliteConnection($"Filename={userDbPath}"))
{
connection.Open();
var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='LocalUsersv2';");
foreach (var row in tableQuery)
{
if (row.GetInt32(0) == 0)
{
_logger.LogWarning("Table 'LocalUsersv2' doesn't exist in {UserDbPath}, nothing to migrate", userDbPath);
return;
}
}
using var dbContext = _provider.CreateDbContext();
var queryResult = connection.Query("SELECT * FROM LocalUsersv2");
@@ -224,6 +224,18 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine
return null;
}
catch (UnauthorizedAccessException e)
{
_logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", attachmentStreamIndex, mediaPath, e.Message);
return null;
}
catch (ArgumentOutOfRangeException e)
{
_logger.LogDebug("Skipping attachment at index {Index} for {Path}: {Exception}", attachmentStreamIndex, mediaPath, e.Message);
return null;
}
filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Value.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture);
}
@@ -263,6 +275,18 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine
{
date = File.GetLastWriteTimeUtc(path);
}
catch (ArgumentOutOfRangeException e)
{
_logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message);
return null;
}
catch (UnauthorizedAccessException e)
{
_logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message);
return null;
}
catch (IOException e)
{
_logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message);
+11
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;
@@ -184,6 +185,12 @@ namespace Jellyfin.Server
.AddSingleton<IServiceCollection>(e))
.Build();
/*
* Initialize the transcode path marker so we avoid starting Jellyfin in a broken state.
* This should really be a part of IApplicationPaths but this path is configured differently.
*/
_ = appHost.ConfigurationManager.GetTranscodePath();
// Re-use the host service provider in the app host since ASP.NET doesn't allow a custom service collection.
appHost.ServiceProvider = _jellyfinHost.Services;
PrepareDatabaseProvider(appHost.ServiceProvider);
@@ -279,6 +286,10 @@ namespace Jellyfin.Server
.AddJellyfinDbContext(startupConfigurationManager, startupConfig)
.AddSingleton<IApplicationPaths>(appPaths)
.AddSingleton<ServerApplicationPaths>(appPaths)
// Required by NpgsqlDataSource factory in AddJellyfinDbContext when
// DatabaseType=Jellyfin-PostgreSQL — the factory resolves this from DI
// to read CustomProviderOptions and pool settings.
.AddSingleton<IServerConfigurationManager>(startupConfigurationManager)
.RegisterStartupLogger();
migrationStartupServiceProvider.AddSingleton(migrationStartupServiceProvider);
@@ -250,6 +250,7 @@ public sealed class SetupServer : IDisposable
{ "isInReportingMode", _isUnhealthy },
{ "retryValue", retryAfterValue },
{ "logs", startupLogEntries },
{ "networkManagerReady", networkManager is not null },
{ "localNetworkRequest", networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress) }
},
new ByteCounterStream(context.Response.BodyWriter.AsStream(), IODefaults.FileStreamBufferSize, true, _startupUiRenderer.ParserOptions))
@@ -213,7 +213,12 @@
</ol>
</div>
{{#ELSE}}
{{#IF networkManagerReady}}
<p>Please visit this page from your local network to view detailed startup logs.</p>
{{#ELSE}}
<p>Initializing network settings. Please wait.</p>
{{/ELSE}}
{{/IF}}
{{/ELSE}}
{{/IF}}
</div>
-5
View File
@@ -16,15 +16,12 @@ using Jellyfin.Networking.HappyEyeballs;
using Jellyfin.Server.Extensions;
using Jellyfin.Server.HealthChecks;
using Jellyfin.Server.Implementations.Extensions;
using Jellyfin.Server.Infrastructure;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Extensions;
using MediaBrowser.XbmcMetadata;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -69,8 +66,6 @@ namespace Jellyfin.Server
options.HttpsPort = _serverApplicationHost.HttpsPort;
});
// TODO remove once this is fixed upstream https://github.com/dotnet/aspnetcore/issues/34371
services.AddSingleton<IActionResultExecutor<PhysicalFileResult>, SymlinkFollowingPhysicalFileResultExecutor>();
services.AddJellyfinApi(_serverApplicationHost.GetApiPluginAssemblies(), _serverConfigurationManager.GetNetworkConfiguration());
services.AddJellyfinDbContext(_serverApplicationHost.ConfigurationManager, _configuration);
services.AddJellyfinApiSwagger();
@@ -1,34 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- ***** BEGIN LICENSE BLOCK *****
- Part of the Jellyfin project (https://jellyfin.media)
-
- All copyright belongs to the Jellyfin contributors; a full list can
- be found in the file CONTRIBUTORS.md
-
- This work is licensed under the Creative Commons Attribution-ShareAlike 4.0 International License.
- To view a copy of this license, visit http://creativecommons.org/licenses/by-sa/4.0/.
- ***** END LICENSE BLOCK ***** -->
<svg id="banner-dark" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1536 512">
<defs>
<linearGradient id="linear-gradient" x1="110.25" y1="213.3" x2="496.14" y2="436.09" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#aa5cc3"/>
<stop offset="1" stop-color="#00a4dc"/>
</linearGradient>
</defs>
<title>banner-dark</title>
<g id="banner-dark">
<g id="banner-dark-icon">
<path id="inner-shape" d="M261.42,201.62c-20.44,0-86.24,119.29-76.2,139.43s142.48,19.92,152.4,0S281.86,201.63,261.42,201.62Z" fill="url(#linear-gradient)"/>
<path id="outer-shape" d="M261.42,23.3C199.83,23.3,1.57,382.73,31.8,443.43s429.34,60,459.24,0S323,23.3,261.42,23.3ZM411.9,390.76c-19.59,39.33-281.08,39.77-300.9,0S221.1,115.48,261.45,115.48,431.49,351.42,411.9,390.76Z" fill="url(#linear-gradient)"/>
</g>
<g id="jellyfin-light-outlines" style="isolation:isolate" transform="translate(43.8)">
<path d="M556.64,350.75a67,67,0,0,1-22.87-27.47,8.91,8.91,0,0,1-1.49-4.75,7.42,7.42,0,0,1,2.83-5.94,9.25,9.25,0,0,1,6.09-2.38c3.16,0,5.94,1.69,8.31,5.05a48.09,48.09,0,0,0,16.34,20.34,40.59,40.59,0,0,0,24,7.58q20.51,0,33.27-12.62t12.77-33.12V159a8.44,8.44,0,0,1,2.67-6.39,9.56,9.56,0,0,1,6.83-2.52,9,9,0,0,1,6.68,2.52,8.7,8.7,0,0,1,2.53,6.39v138.4a64.7,64.7,0,0,1-8.32,32.67,59,59,0,0,1-23,22.72Q608.62,361,589.9,361A57.21,57.21,0,0,1,556.64,350.75Z" fill="#fff"/>
<path d="M831.66,279.47a8.77,8.77,0,0,1-6.24,2.53H713.16q0,17.82,7.27,31.92a54.91,54.91,0,0,0,20.79,22.28q13.51,8.18,31.93,8.17a54,54,0,0,0,25.54-5.94,52.7,52.7,0,0,0,18.12-15.15,10,10,0,0,1,6.24-2.67,8.14,8.14,0,0,1,7.72,7.72,8.81,8.81,0,0,1-3,6.24,74.7,74.7,0,0,1-23.91,19A65.56,65.56,0,0,1,773.45,361q-22.87,0-40.4-9.8a69.51,69.51,0,0,1-27.32-27.48q-9.79-17.66-9.8-40.83,0-24.36,9.65-42.62t25.69-27.92a65.2,65.2,0,0,1,34.16-9.65A70,70,0,0,1,798.84,211a65.78,65.78,0,0,1,25.39,24.36q9.81,16,10.1,38A8.07,8.07,0,0,1,831.66,279.47ZM733.5,231.8Q718.8,243.68,714.64,266H815.92v-2.38A46.91,46.91,0,0,0,807,240.27a48.47,48.47,0,0,0-18.56-15.15,54,54,0,0,0-23-5.2Q748.2,219.92,733.5,231.8Z" fill="#fff"/>
<path d="M888.24,355.5a8.92,8.92,0,0,1-15.3-6.38v-202a8.91,8.91,0,1,1,17.82,0v202A8.65,8.65,0,0,1,888.24,355.5Z" fill="#fff"/>
<path d="M956.55,355.5a8.92,8.92,0,0,1-15.3-6.38v-202a8.91,8.91,0,1,1,17.82,0v202A8.65,8.65,0,0,1,956.55,355.5Z" fill="#fff"/>
<path d="M1122.86,206.11a8.7,8.7,0,0,1,2.53,6.39v131q0,23.44-9.21,40.09a61.58,61.58,0,0,1-25.54,25.25q-16.34,8.61-36.83,8.61a96.73,96.73,0,0,1-23.31-2.68,61.72,61.72,0,0,1-18-7.12q-6.24-3.87-6.24-8.62a17.94,17.94,0,0,1,.6-3,8.06,8.06,0,0,1,3-4.45,7.49,7.49,0,0,1,4.45-1.49,7.91,7.91,0,0,1,3.56.89q19,10.39,36.24,10.4,24.65,0,39.06-15.44t14.4-42.18V333.38a54.37,54.37,0,0,1-21.38,20,62.55,62.55,0,0,1-30.3,7.58q-25.83,0-39.2-15.45t-13.37-41.87V212.5a8.91,8.91,0,1,1,17.82,0V301q0,21.39,9.36,32.38t29.25,11a48,48,0,0,0,23.32-6.09,49.88,49.88,0,0,0,17.82-16,37.44,37.44,0,0,0,6.68-21.24V212.5a9,9,0,0,1,15.29-6.39Z" fill="#fff"/>
<path d="M1210.18,161.41q-5.21,6.24-5.2,17.23v30.59h33.27a8.19,8.19,0,0,1,5.79,2.38,8.26,8.26,0,0,1,0,11.88,8.22,8.22,0,0,1-5.79,2.37H1205V349.12a8.91,8.91,0,1,1-17.82,0V225.86h-21.68a7.83,7.83,0,0,1-5.94-2.52,8.21,8.21,0,0,1-2.37-5.79,8,8,0,0,1,2.37-6.09,8.33,8.33,0,0,1,5.94-2.23h21.68V178.64q0-18.7,10.84-29t29-10.24a46.1,46.1,0,0,1,15.45,2.52q7.13,2.53,7.12,8.17a8.07,8.07,0,0,1-2.37,5.94,7.37,7.37,0,0,1-5.35,2.37,18.81,18.81,0,0,1-6.53-1.48,42,42,0,0,0-10.4-1.78Q1215.37,155.18,1210.18,161.41ZM1276,180.87c-2.19-1.88-3.27-4.61-3.27-8.17v-3q0-5.34,3.41-8.17t9.36-2.82q11.88,0,11.88,11v3c0,3.56-1,6.29-3.12,8.17s-5.1,2.82-9.06,2.82S1278.14,182.75,1276,180.87Zm15.59,174.63a8.92,8.92,0,0,1-15.3-6.38V212.5a8.91,8.91,0,1,1,17.82,0V349.12A8.65,8.65,0,0,1,1291.56,355.5Z" fill="#fff"/>
<path d="M1452.53,218.88q12.92,16.2,12.92,42.92v87.32a8.4,8.4,0,0,1-2.67,6.38,8.8,8.8,0,0,1-6.24,2.53,8.64,8.64,0,0,1-8.91-8.91V262.69q0-19.31-9.65-31.33t-29.85-12a53.28,53.28,0,0,0-42.77,21.83,36.24,36.24,0,0,0-7.13,21.53v86.43a8.91,8.91,0,1,1-17.82,0V216.06a8.91,8.91,0,1,1,17.82,0V232.4q8-12.77,23-21.24A61.84,61.84,0,0,1,1412,202.7Q1439.61,202.7,1452.53,218.88Z" fill="#fff"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.7 KiB

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