Compare commits

..

122 Commits

Author SHA1 Message Date
Nicolas N
6014ad6ef2 Translated using Weblate (Haitian)
Some checks are pending
CodeQL / Analyze (csharp) (push) Waiting to run
Tests / run-tests (macos-latest) (push) Waiting to run
Tests / run-tests (ubuntu-latest) (push) Waiting to run
Tests / run-tests (windows-latest) (push) Waiting to run
OpenAPI Publish / OpenAPI - Publish Artifact (push) Waiting to run
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Blocked by required conditions
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Blocked by required conditions
Project Automation / Project board (push) Waiting to run
Merge Conflict Labeler / Labeling (push) Waiting to run
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ht/
2026-05-12 23:26:19 +00:00
serzh-photograf
2f27e9b5b6 Translated using Weblate (Ukrainian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/uk/
2026-05-12 23:26:19 +00:00
Gallyam Biktashev
7504875ccf Translated using Weblate (Russian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ru/
2026-05-12 23:26:19 +00:00
Gargotaire
1872340a01 Translated using Weblate (Catalan)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ca/
2026-05-12 23:26:18 +00:00
Bond-009
e9942c3857 Merge pull request #16831 from Bond-009/netVersion
Some checks failed
CodeQL / Analyze (csharp) (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / Labeling (push) Has been cancelled
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
Reference correct .NET version in README
2026-05-12 20:01:27 +02:00
Bond_009
04ecf77d97 Reference correct .NET version in README 2026-05-12 18:35:37 +02:00
nyanmisaka
b9ee9b0660 Backport pull request #16819 from jellyfin/release-10.11.z
Fix rate control in av1_amf encoder

Original-merge: a023b9c88d

Merged-by: Bond-009 <bond.009@outlook.com>

Backported-by: Bond_009 <bond.009@outlook.com>
2026-05-12 12:26:13 -04:00
Bond-009
27a3ccb7e4 consolidate OpenAPI categories and deprecate startup routes (#16757) 2026-05-12 18:13:11 +02:00
Tim Eisele
a9865367d8 Safeguard against invalid GUIDs (#16813)
Safeguard against invalid GUIDs
2026-05-12 18:12:54 +02:00
José Fonseca
c169184e01 Translated using Weblate (Portuguese)
Some checks failed
CodeQL / Analyze (csharp) (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / Labeling (push) Has been cancelled
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/pt/
2026-05-11 20:13:48 +00:00
José Fonseca
5bcea608c5 Translated using Weblate (Portuguese (Portugal))
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/pt_PT/
2026-05-11 20:13:48 +00:00
Milo Ivir
22bf421be6 Translated using Weblate (Croatian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/hr/
2026-05-11 20:13:48 +00:00
GolanGitHub
6d3a7b6f69 Translated using Weblate (Spanish)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/es/
2026-05-11 20:13:48 +00:00
Bond-009
406aaabefd Use SortName when sorting by name (#16804)
Some checks failed
CodeQL / Analyze (csharp) (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / Labeling (push) Has been cancelled
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
Stale Issue Labeler / Check for stale issues (push) Has been cancelled
* Use SortName when sorting by name

* Preserve ordering in item values query
2026-05-11 18:22:26 +02:00
Aindriú Mac Giolla Eoin
ef64f19eac Translated using Weblate (Irish)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ga/
2026-05-11 14:04:44 +00:00
Anrijs Vitolins
2800ae3b8a Translated using Weblate (Latvian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/lv/
2026-05-11 14:04:44 +00:00
Vincenzo Reale
7ead333f6e Translated using Weblate (Italian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/it/
2026-05-11 14:04:44 +00:00
Szilki077
9be1246749 Translated using Weblate (Hungarian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/hu/
2026-05-11 14:04:44 +00:00
alxhu
2229851689 Translated using Weblate (German)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/de/
2026-05-11 14:04:43 +00:00
BitToby
21f12a1ad0 Suppress CA2213 false positive on ApplicationHost._pluginManager (#2149) (#16792)
Some checks failed
CodeQL / Analyze (csharp) (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / Labeling (push) Has been cancelled
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
2026-05-11 06:29:05 +02:00
renovate[bot]
be0f0b9761 Update danielpalme/ReportGenerator-GitHub-Action action to v5.5.10 (#16820) 2026-05-11 06:28:43 +02:00
Tim Eisele
f24709f11c Print warning on invalid Subnets in Network/Proxy configuration (#16793)
Some checks failed
CodeQL / Analyze (csharp) (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / Labeling (push) Has been cancelled
Print warning on invalid Subnets in Network/Proxy configuration
2026-05-10 20:34:26 +02:00
Bond-009
4f238ca9b3 Merge pull request #16803 from nyanmisaka/new-profile-condition-video-rotation
Add videoRotation profile condition
2026-05-10 20:32:40 +02:00
Bond-009
42870986a8 Merge pull request #16807 from Shadowghost/fix-artist-duplicates
Fix artist duplicates
2026-05-10 20:28:36 +02:00
Bas
82120732ca Translated using Weblate (Dutch)
Some checks failed
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / Labeling (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Stale Issue Labeler / Check for stale issues (push) Has been cancelled
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/nl/
2026-05-09 13:53:22 +00:00
lednurb
9a3dfec151 Translated using Weblate (Dutch)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/nl/
2026-05-09 13:53:22 +00:00
Shadowghost
02835c6144 Add People Deduplication 2026-05-09 11:18:05 +02:00
nyanmisaka
6d6dee9492 Add tests for videoRotation profile condition
Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2026-05-09 15:20:29 +08:00
nyanmisaka
fc251265d9 Check videoRotation in video stream copy
Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2026-05-09 15:20:29 +08:00
nyanmisaka
5cb6ac521a Add videoRotation profile condition
Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2026-05-09 15:20:29 +08:00
Bond-009
4dd19b990b Merge pull request #16290 from benbenmoss/fix/io-excemption-from-strm
Some checks failed
CodeQL / Analyze (csharp) (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / Labeling (push) Has been cancelled
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
IOException with STRM HTTP URLs
2026-05-09 08:19:41 +02:00
Bond-009
efa502a65f Merge pull request #16798 from Shadowghost/fix-segment-provider
Fix Segment Provider Configuration
2026-05-09 08:17:02 +02:00
Shadowghost
b7b405dc83 Fix artist duplicates 2026-05-09 02:07:26 +02:00
Shadowghost
149649a6cf Preserve ordering in item values query 2026-05-09 02:06:01 +02:00
Fjuro
169745fddb Translated using Weblate (Czech)
Some checks failed
CodeQL / Analyze (csharp) (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / Labeling (push) Has been cancelled
Stale Issue Labeler / Check for stale issues (push) Has been cancelled
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/cs/
2026-05-08 22:52:08 +00:00
Translation expert
c4c521719e Translated using Weblate (Arabic)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ar/
2026-05-08 20:31:00 +00:00
Shadowghost
e9cad048e9 Use SortName when sorting by name 2026-05-08 20:34:36 +02:00
Bond-009
de64a69c7a Merge pull request #14847 from tjwalkr3/warnings-5
Some checks failed
CodeQL / Analyze (csharp) (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / Labeling (push) Has been cancelled
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
Fix CA1051 and CA1815 warnings, Change public fields to auto properties
2026-05-08 17:55:25 +02:00
Bond-009
c328dbef10 Update github/codeql-action action to v4.35.4 (#16796)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-05-08 17:51:47 +02:00
dkanada
8437866ffa consolidate OpenAPI categories and deprecate startup routes 2026-05-08 12:53:26 +09:00
Shadowghost
b84bd34c67 Fix Segment Provider Configuration 2026-05-08 01:05:14 +02:00
Erik W
82b51a60a5 Translated using Weblate (Swedish)
Some checks failed
CodeQL / Analyze (csharp) (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / Labeling (push) Has been cancelled
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
Stale Issue Labeler / Check for stale issues (push) Has been cancelled
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/sv/
2026-05-07 21:29:41 +00:00
Kityn
5cdfb9bfac Translated using Weblate (Polish)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/pl/
2026-05-07 21:29:38 +00:00
Bas
6c59f9a03d Translated using Weblate (Dutch)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/nl/
2026-05-07 19:59:14 +00:00
Erik W
e1e18e8da0 Add OriginalLanguage as option to PreferredAudioLanguage (#12579)
* Add OriginalLanguage as option to PreferredAudioLanguage

* Support for multiple original languages

* Add original audio stream indicator

* Fetch OriginalLanguage from TMDB

* Adapt to EFCore refactor

* Fix PlayDefaultAudioTrack OriginalLanguage behavior

* Fix better PlayDefaultAudioTrack OriginalLanguage behavior

* Add comment to ItemFields

* Improved PlayDefaultAudioTrack behavior

* Add migration for original language

* Use sting.Equals for string comparisons

* Always set dto OriginalLanguage

* Remove OriginalLanguage from ItemFields

---------

Co-authored-by: Lampan-git <lampan-git@users.noreply.github.com>
2026-05-07 20:07:23 +02:00
renovate[bot]
63a078b720 Update github/codeql-action action to v4.35.4 2026-05-07 18:05:21 +00:00
theguymadmax
d636b82e83 Allow tmdb as an alias for tmdbid provider id (#16433)
Allow tmdb as an alias for tmdbid provider id
2026-05-07 20:04:28 +02:00
Tim Eisele
bc074b5283 Switch to new version scheme (#16758)
Some checks failed
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / Labeling (push) Has been cancelled
Stale Issue Labeler / Check for stale issues (push) Has been cancelled
2026-05-06 17:41:34 -04:00
dkanada
3876a0ad3d fix person type exclusion in api response (#16784) 2026-05-06 17:39:45 -04:00
Marc Brooks
a629080c89 Fix MusicBrainz test. (#16789) 2026-05-06 17:38:56 -04:00
Bond-009
2fbb821581 Merge pull request #16472 from IDisposable/feature/season-provider-id-from-path
Parse provider IDs from season and episode folder/file names
2026-05-06 20:49:28 +02:00
Bond-009
33ed52b8ee Merge branch 'master' into feature/season-provider-id-from-path 2026-05-06 20:49:19 +02:00
Bond-009
d1ab428476 Merge pull request #16321 from WizardOfYendor1/fix/livetv-consumer-leak-negative-position-ticks
Fix live stream consumer leak on negative PositionTicks
2026-05-06 20:46:13 +02:00
Bond-009
142b89eab5 Merge pull request #16319 from JPVenson/feat/MigrationStartupSwitch
Add startup mode to migrate or seed the database on cmd
2026-05-06 20:38:53 +02:00
Bond-009
1bbbc1c823 Merge pull request #16328 from Shadowghost/rating-fix
Fix Canadian rating and fallback to unrated if we have a CountryCode but no matching rating
2026-05-06 20:33:58 +02:00
Niels van Velzen
a8f361f8c0 Merge pull request #16788 from theguymadmax/do-more-like-no2
Remove DigitalOcean from sponsors section
2026-05-06 19:08:43 +02:00
Niels van Velzen
c0593281ff Merge pull request #16466 from PERSONALPANCHIWIRIS/fix/issue#16308_community_rating_not_updating
Fix #16308: Community ratings not updating after changing .nfo file.
2026-05-06 19:08:19 +02:00
Niels van Velzen
d648aba881 Merge pull request #16611 from LmanTW/master
Ignore season directories with no video for TV Shows
2026-05-06 19:08:05 +02:00
Niels van Velzen
44d5954205 Merge pull request #16783 from Shadowghost/fix-people-without-item-id
Fix unique people response for query if no item ID is supplied
2026-05-06 19:07:13 +02:00
Niels van Velzen
10c42d70ca Merge pull request #16780 from Shadowghost/fix-muscibrainz-dispose
Move MusicBrainz Query client to plugin instance
2026-05-06 19:07:03 +02:00
Niels van Velzen
da88a06ede Merge pull request #16779 from Shadowghost/fix-subtitle-save
Fix subtitle save path
2026-05-06 19:07:00 +02:00
Bond-009
842a5efdcf Merge pull request #16782 from JPVenson/backport/15368
Fix UserManager after EFcore refactor (backport #15368)
2026-05-06 18:02:30 +02:00
Bond-009
e84fd95bcc Merge pull request #16246 from lcorbasson/lcorbasson-patch-1
Some checks failed
CodeQL / Analyze (csharp) (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / Labeling (push) Has been cancelled
Add the filename to exceptions in DeserializeFromFile()
2026-05-06 17:41:20 +02:00
Bond-009
28546f535c Merge pull request #16646 from Biosias/cz-sk-season-parse
Add different Slovak and Czech translation of Season for parsing
2026-05-06 17:40:47 +02:00
Bond-009
e50aeb914a Merge pull request #16322 from poytiis/remove-ToList-calls
Remove unnecessary ToList calls in TrickplayManager
2026-05-06 17:40:12 +02:00
Bond-009
087e745e7a Merge pull request #16778 from Shadowghost/fix-rewatch
Fix rewatch query
2026-05-06 17:37:13 +02:00
theguymadmax
c8c890b9e5 Remove DigitalOcean from sponsors section 2026-05-06 08:21:23 -04:00
Shadowghost
4b8be6bc91 Fix unique people response for query if no item ID is supplied 2026-05-05 20:21:44 +02:00
JPVenson
ec054f6a34 Backport changes from #15368 2026-05-05 17:57:27 +00:00
Shadowghost
d3e6079d38 Move MusicBrainz Query client to plugin instance 2026-05-05 17:43:37 +02:00
Shadowghost
9ade1fb8f6 Fix subtitle save path 2026-05-05 17:32:58 +02:00
Shadowghost
8a43b1c784 Fix rewatch query 2026-05-05 17:31:19 +02:00
Niels van Velzen
4178e0ebaf Merge pull request #16220 from Shadowghost/epg-fixes
Some checks failed
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Merge Conflict Labeler / Labeling (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Stale Issue Labeler / Check for stale issues (push) Has been cancelled
Fix EPG issues
2026-05-05 15:53:19 +02:00
Niels van Velzen
064fd8c5c0 Merge pull request #16756 from Shadowghost/artist-speedup
Speed-up LatestItems for Music
2026-05-05 15:49:02 +02:00
Niels van Velzen
7be1350205 Merge pull request #16769 from llaforest/fix/playback-progress-null-overwrite
Guard against null-overwrite of saved audio/subtitle track selections
2026-05-05 15:48:53 +02:00
Niels van Velzen
b227f3e85b Merge pull request #16777 from gabeluci/fix-directoryservice-sort
Fix use of thread-unsafe List<T>.Sort()
2026-05-05 15:48:46 +02:00
Joshua M. Boniface
fd6badf096 Merge pull request #16754 from nielsvanvelzen/keep-legacy-auth
Keep legacy authorization enabled
2026-05-05 09:46:38 -04:00
samurato
7e4727fff8 Translated using Weblate (Nepali)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ne/
2026-05-05 12:31:14 +00:00
Gabriel Luci
6f2e42c20c Fix use of thread-unsafe List<T>.Sort() 2026-05-04 23:46:00 -04:00
Shadowghost
d4f91ab5ca Fixup 2026-05-04 23:48:09 +02:00
Shadowghost
0d58c773f9 Fix review comments 2026-05-04 21:42:31 +02:00
Shadowghost
6be96100c7 Fix review and CodeQL comments 2026-05-04 21:33:10 +02:00
Shadowghost
57c0fcd674 Merge remote-tracking branch 'upstream/master' into epg-fixes 2026-05-04 21:26:26 +02:00
llaforest
6ea2f05497 Guard against null-overwrite of saved audio/subtitle track selections
Some clients omit AudioStreamIndex or SubtitleStreamIndex in playback progress reports and it causes previously saved track selections to be erased.
Add .HasValue checks so only explicit track changes are persisted.
2026-05-04 13:26:08 -04:00
Shadowghost
2365cea626 Only consider Album creation date 2026-05-04 15:13:00 +02:00
Shadowghost
88cad2ad1a Speed-up LatestItems for Music 2026-05-04 02:00:38 +02:00
Niels van Velzen
b8e25b49b3 Keep legacy authorization enabled 2026-05-03 22:20:24 +02:00
Shadowghost
68ab585894 Merge remote-tracking branch 'upstream/master' into epg-fixes 2026-04-29 22:18:47 +02:00
Francisco Ernesto Planas Pestana
c483619928 Fix jellyfin#16308: Community ratings not updating after changing .nfo file.
When "replace all metadata" was issued on a film, with the Web metadata
scrapers and "save to local metadata" disabled, after changing the
.nfo file, 'Community rating' was not updated in the server, remaining
the cached value.

Fixed by adding, in the .nfo parser, an option for
'communityrating' along with value conformity verifiers.

Validation tests were added.
2026-04-21 10:45:23 +01:00
Shadowghost
b717754ed8 Merge remote-tracking branch 'upstream/master' into epg-fixes 2026-04-20 08:16:45 +02:00
Biosias
31e8e197cf Add differen Slovak and Czech translation of Season for parsing 2026-04-16 13:14:59 +02:00
Shadowghost
60e01e1f22 Apply review suggestions 2026-04-11 18:00:41 +02:00
Shadowghost
e0f50f504a Merge remote-tracking branch 'upstream/master' into epg-fixes 2026-04-11 17:48:00 +02:00
LmanTW
3ef7ada736 Ignore season directories with no video for TV Shows 2026-04-10 18:59:59 +08:00
Tim Eisele
a12736a0ce Apply suggestions from code review
Co-authored-by: theguymadmax <theguymadmax@proton.me>
2026-03-29 10:28:15 +02:00
WizardOfYendor1
3a4dff8cc4 Fix live stream consumer leak when PositionTicks is negative
When a stalled HLS client sends ReportPlaybackStopped with a negative
PositionTicks (e.g. HLS.js reporting position on buffer stall), the
ArgumentOutOfRangeException was thrown before GetSession was called,
causing CloseLiveStreamIfNeededAsync to never run. This left the
ILiveStream.ConsumerCount permanently incremented, permanently holding
a tuner open with no active viewer.

Fix by acquiring the session and performing live stream cleanup before
throwing the validation exception, so tuner resources are always freed
even when playback reporting fails with invalid position data.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 20:11:26 -04:00
Tim Eisele
f793acc1aa Update Emby.Server.Implementations/Localization/LocalizationManager.cs
Co-authored-by: theguymadmax <theguymadmax@proton.me>
2026-03-26 10:06:50 +01:00
Marc Brooks
aa96ff42e6 Parse provider IDs from season and episode folder/file names
Season and episode directories/files can now include provider ID
attributes in their names (e.g. "Season 01 [tvdbid=22222]" or
"Show S01E01 [tmdbid=99999].mkv"), consistent with the existing
behavior for series folders.

Supported providers: imdbid, tvdbid, tvmazeid, tmdbid.

Adds TmdbSeasonExternalId and TmdbEpisodeExternalId so that
the TMDB season and episode IDs are surfaced in the metadata editor.

Seasons do not have their own IMDb IDs, so we don't support imdbid parsing
in SeasonResolver. Instead, generate IMDb season URLs via
ImdbExternalUrlProvider using the parent series' IMDb ID and the
season number, matching the IMDb URL format:
imdb.com/title/{seriesId}/episodes/?season={N}

Add tests for the ExternalUrlProviders.
2026-03-25 18:47:40 -05:00
Francisco Ernesto Planas Pestana
e065015d6d Fix #16308: Community ratings not updating after changing .nfo file.
When "replace all metadata" was issued on a film, with the Web metadata
scrapers and "save to local metadata" disabled, after changing the
.nfo file, 'Community rating' was not updated in the server, remaining
the cached value.

Fixed by changing the flag on the 'MergeData' call
(line 809 - MetadataService.cs) to use the option defined by the
user and not the erroneous absolute 'false' value.

Also, in the .nfo parser an option for 'communityrating' was added
along with value conformity verifiers.

Validation tests were added.
2026-03-25 14:07:53 +00:00
Shadowghost
d5fb6f99ef Refresh rating levels 2026-03-02 09:14:34 +01:00
Shadowghost
3d4e4c4572 If we have a country code in the rating, treat as unrated if the country does not have the rating 2026-03-02 09:14:23 +01:00
Shadowghost
11e16df596 Fix Canadian ratings 2026-03-02 09:04:40 +01:00
JPVenson
febfd7f94a Stop server immediately on migration only mode 2026-03-01 15:52:22 +00:00
Teemu Pöytäniemi
8271568677 Add poytiis to CONTRIBUTORS.md 2026-03-01 16:35:24 +02:00
Teemu Pöytäniemi
b22c8882d6 Remove unnecessary ToList calls 2026-03-01 16:28:55 +02:00
JPVenson
63c4fc297a Update naming 2026-03-01 13:12:51 +00:00
JPVenson
e70eaf8bc1 Add startup mode to migrate or seed the database on cmd 2026-03-01 13:10:32 +00:00
Shadowghost
b7da5c1860 Apply review suggestions 2026-02-25 14:51:53 +01:00
ghis@am.com
41d2070008 adding strm & http ignore to extract timestamp 2026-02-24 10:58:47 +11:00
Shadowghost
100d6bb38c Gracefully handle empty listingId 2026-02-23 21:17:52 +01:00
Shadowghost
d63b2b2657 Apply review suggestion 2026-02-22 12:37:14 +01:00
Shadowghost
ed43ad0968 Persistence 2026-02-22 11:32:55 +01:00
Shadowghost
27396bffc6 Handle 5002, 5003 and add caches 2026-02-22 11:14:15 +01:00
Shadowghost
d156e04c9a Fix Skipping 2026-02-21 22:56:53 +01:00
Shadowghost
97340edf02 Fix image failure response handling in batch endpoint 2026-02-21 16:47:08 +01:00
Shadowghost
c4c3e9ea4d Fix batch requests 2026-02-21 11:40:18 +01:00
Shadowghost
679664ca28 Add early returns 2026-02-20 15:14:03 +01:00
Shadowghost
b0eec00e1c Properly handle SD internal error codes 2026-02-20 14:58:12 +01:00
Shadowghost
e49d71707c Fix EPG issues 2026-02-20 14:35:56 +01:00
Loïc CORBASSON
893188ab28 Add the filename to the exception's trace to facilitate error resolution (see #12254, #9508, ...) 2026-02-16 17:23:52 +01:00
Thomas Jones
a014cb538e Fixed 3 different CA1051 warnings. Changed them to auto properties and rearranged the members to fit styling rules.
Co-authored-by: Derpipose <90276123+Derpipose@users.noreply.github.com>
2025-09-23 14:18:15 -06:00
Thomas Jones
378ba937b6 Added in pragma warning disable for CA1815 warning.
Co-authored-by: Derpipose <90276123+Derpipose@users.noreply.github.com>
2025-09-23 13:17:52 -06:00
162 changed files with 6308 additions and 1110 deletions

View File

@@ -32,13 +32,13 @@ jobs:
dotnet-version: '10.0.x'
- name: Initialize CodeQL
uses: github/codeql-action/init@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
languages: ${{ matrix.language }}
queries: +security-extended
- name: Autobuild
uses: github/codeql-action/autobuild@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@e46ed2cbd01164d986452f91f178727624ae40d7 # v4.35.3
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4

View File

@@ -35,7 +35,7 @@ jobs:
--verbosity minimal
- name: Merge code coverage results
uses: danielpalme/ReportGenerator-GitHub-Action@c31aa4ed4f12f147061186cf2a029f307b5c3636 # v5.5.9
uses: danielpalme/ReportGenerator-GitHub-Action@049f7ec958c672fd31d5cc1cb01622dc8d2e23ab # v5.5.10
with:
reports: "**/coverage.cobertura.xml"
targetdir: "merged/"

View File

@@ -118,6 +118,7 @@
- [Phlogi](https://github.com/Phlogi)
- [pjeanjean](https://github.com/pjeanjean)
- [ploughpuff](https://github.com/ploughpuff)
- [poytiis](https://github.com/poytiis)
- [pR0Ps](https://github.com/pR0Ps)
- [PrplHaz4](https://github.com/PrplHaz4)
- [RazeLighter777](https://github.com/RazeLighter777)
@@ -229,6 +230,7 @@
- [LiHRaM](https://github.com/LiHRaM)
- [MSalman5230](https://github.com/MSalman5230)
- [dwandw](https://github.com/dwandw)
- [Lampan-git](https://github.com/Lampan-git)
# Emby Contributors

View File

@@ -9,7 +9,7 @@
<PackageVersion Include="AutoFixture.Xunit3" Version="4.19.0" />
<PackageVersion Include="AutoFixture" Version="4.18.1" />
<PackageVersion Include="BDInfo" Version="0.8.0" />
<PackageVersion Include="BitFaster.Caching" Version="2.6.0" />
<PackageVersion Include="BitFaster.Caching" Version="2.5.4" />
<PackageVersion Include="BlurHashSharp.SkiaSharp" Version="1.4.0-pre.1" />
<PackageVersion Include="BlurHashSharp" Version="1.4.0-pre.1" />
<PackageVersion Include="CommandLineParser" Version="2.9.1" />

View File

@@ -1,3 +1,5 @@
#pragma warning disable CA1815
namespace Emby.Naming.AudioBook
{
/// <summary>

View File

@@ -36,7 +36,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Naming</PackageId>
<VersionPrefix>10.12.0</VersionPrefix>
<VersionPrefix>12.0.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>

View File

@@ -12,10 +12,10 @@ namespace Emby.Naming.TV
{
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)]
[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|érie|éria|erie|eria)*|[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+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
[GeneratedRegex(@"^\s*(?:[[]*|[]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul|érie|éria|erie|eria)*|[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)]

View File

@@ -1,3 +1,5 @@
#pragma warning disable CA1815
namespace Emby.Naming.Video
{
/// <summary>

View File

@@ -166,8 +166,6 @@ namespace Emby.Server.Implementations
ConfigurationManager.Configuration,
ApplicationPaths.PluginsPath,
ApplicationVersion);
_disposableParts.Add(_pluginManager);
}
/// <summary>
@@ -1014,6 +1012,8 @@ namespace Emby.Server.Implementations
}
_disposableParts.Clear();
_pluginManager?.Dispose();
}
_disposed = true;

View File

@@ -203,6 +203,39 @@ namespace Emby.Server.Implementations.Dto
}
}
// Batch-fetch MusicArtist lookups across all items to avoid N+1 queries.
IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null;
var artistNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var item in accessibleItems)
{
if (item is IHasArtist hasArtist)
{
foreach (var name in hasArtist.Artists)
{
if (!string.IsNullOrWhiteSpace(name))
{
artistNames.Add(name);
}
}
}
if (item is IHasAlbumArtist hasAlbumArtist)
{
foreach (var name in hasAlbumArtist.AlbumArtists)
{
if (!string.IsNullOrWhiteSpace(name))
{
artistNames.Add(name);
}
}
}
}
if (artistNames.Count > 0)
{
artistsBatch = _libraryManager.GetArtists(artistNames.ToArray());
}
for (int index = 0; index < accessibleItems.Count; index++)
{
var item = accessibleItems[index];
@@ -214,7 +247,8 @@ namespace Emby.Server.Implementations.Dto
userDataBatch?.GetValueOrDefault(item.Id),
allCollectionFolders,
childCountBatch,
playedCountBatch);
playedCountBatch,
artistsBatch);
if (item is LiveTvChannel tvChannel)
{
@@ -274,7 +308,8 @@ namespace Emby.Server.Implementations.Dto
UserItemData? userData = null,
List<Folder>? allCollectionFolders = null,
Dictionary<Guid, int>? childCountBatch = null,
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null)
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null,
IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null)
{
var dto = new BaseItemDto
{
@@ -334,7 +369,7 @@ namespace Emby.Server.Implementations.Dto
AttachStudios(dto, item);
}
AttachBasicFields(dto, item, owner, options);
AttachBasicFields(dto, item, owner, options, artistsBatch);
if (options.ContainsField(ItemFields.CanDelete))
{
@@ -907,7 +942,8 @@ namespace Emby.Server.Implementations.Dto
/// <param name="item">The item.</param>
/// <param name="owner">The owner.</param>
/// <param name="options">The options.</param>
private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options)
/// <param name="artistsBatch">Optional pre-fetched artist lookup shared across a batch of items.</param>
private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null)
{
if (options.ContainsField(ItemFields.DateCreated))
{
@@ -1031,6 +1067,8 @@ namespace Emby.Server.Implementations.Dto
dto.OriginalTitle = item.OriginalTitle;
}
dto.OriginalLanguage = item.OriginalLanguage;
if (options.ContainsField(ItemFields.ParentId))
{
dto.ParentId = item.DisplayParentId;
@@ -1152,7 +1190,8 @@ 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();
var artistsLookup = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))]);
var artistsLookup = artistsBatch
?? _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))]);
dto.ArtistItems = hasArtist.Artists
.Where(name => !string.IsNullOrWhiteSpace(name))
@@ -1186,7 +1225,8 @@ namespace Emby.Server.Implementations.Dto
// })
// .ToList();
var albumArtistsLookup = _libraryManager.GetArtists([.. hasAlbumArtist.AlbumArtists.Where(e => !string.IsNullOrWhiteSpace(e))]);
var albumArtistsLookup = artistsBatch
?? _libraryManager.GetArtists([.. hasAlbumArtist.AlbumArtists.Where(e => !string.IsNullOrWhiteSpace(e))]);
dto.AlbumArtists = hasAlbumArtist.AlbumArtists
.Where(name => !string.IsNullOrWhiteSpace(name))

View File

@@ -23,6 +23,7 @@ using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.MediaEncoding;
@@ -423,7 +424,7 @@ namespace Emby.Server.Implementations.Library
MediaStreamSelector.SetSubtitleStreamScores(source.MediaStreams, preferredSubs, user.SubtitleMode, audioLanguage);
}
private void SetDefaultAudioStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection)
private void SetDefaultAudioStreamIndex(MediaSourceInfo source, UserItemData userData, User user, bool allowRememberingSelection, string originalLanguage)
{
if (userData is not null && userData.AudioStreamIndex.HasValue && user.RememberAudioSelections && allowRememberingSelection)
{
@@ -437,7 +438,42 @@ namespace Emby.Server.Implementations.Library
}
}
var preferredAudio = NormalizeLanguage(user.AudioLanguagePreference);
if (string.Equals(user.AudioLanguagePreference, "OriginalLanguage", StringComparison.OrdinalIgnoreCase))
{
originalLanguage = !string.IsNullOrWhiteSpace(originalLanguage)
? originalLanguage.Split(',').FirstOrDefault()
: null;
if (user.PlayDefaultAudioTrack)
{
source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex(
source.MediaStreams,
NormalizeLanguage(originalLanguage),
user.PlayDefaultAudioTrack);
return;
}
var originalIndex = source.MediaStreams.FindIndex(i => i.Type == MediaStreamType.Audio && i.IsOriginal);
if (!string.IsNullOrWhiteSpace(originalLanguage) && originalIndex != -1)
{
var mediaLanguageOriginal = source.MediaStreams[originalIndex].Language;
if (NormalizeLanguage(mediaLanguageOriginal).Contains(NormalizeLanguage(originalLanguage).FirstOrDefault()))
{
source.DefaultAudioStreamIndex = originalIndex;
return;
}
}
else if (originalIndex != -1)
{
source.DefaultAudioStreamIndex = originalIndex;
return;
}
}
var preferredAudio = string.Equals(user.AudioLanguagePreference, "OriginalLanguage", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(originalLanguage)
? NormalizeLanguage(originalLanguage)
: NormalizeLanguage(user.AudioLanguagePreference);
source.DefaultAudioStreamIndex = MediaStreamSelector.GetDefaultAudioStreamIndex(source.MediaStreams, preferredAudio, user.PlayDefaultAudioTrack);
if (user.PlayDefaultAudioTrack)
@@ -462,7 +498,19 @@ namespace Emby.Server.Implementations.Library
var allowRememberingSelection = item is null || item.EnableRememberingTrackSelections;
SetDefaultAudioStreamIndex(source, userData, user, allowRememberingSelection);
var originalLanguage = item?.OriginalLanguage ?? item switch
{
Episode episode => episode.Series.OriginalLanguage,
Video video => video.GetOwner() switch
{
Episode ownerEpisode => ownerEpisode.OriginalLanguage ?? ownerEpisode.Series.OriginalLanguage,
BaseItem owner => owner.OriginalLanguage,
null => null
},
_ => null
};
SetDefaultAudioStreamIndex(source, userData, user, allowRememberingSelection, originalLanguage);
SetDefaultSubtitleStreamIndex(source, userData, user, allowRememberingSelection);
}
else if (mediaType == MediaType.Audio)

View File

@@ -70,6 +70,16 @@ namespace Emby.Server.Implementations.Library
return match ? imdbId.ToString() : null;
}
// Allow tmdb as an alias for tmdbid
if (attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase))
{
var tmdbValue = str.GetAttributeValue("tmdb");
if (tmdbValue is not null)
{
return tmdbValue;
}
}
return null;
}

View File

@@ -6,6 +6,7 @@ using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Library;
@@ -14,18 +15,22 @@ namespace Emby.Server.Implementations.Library;
/// </summary>
public class PathManager : IPathManager
{
private readonly ILogger<PathManager> _logger;
private readonly IServerConfigurationManager _config;
private readonly IApplicationPaths _appPaths;
/// <summary>
/// Initializes a new instance of the <see cref="PathManager"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="config">The server configuration manager.</param>
/// <param name="appPaths">The application paths.</param>
public PathManager(
ILogger<PathManager> logger,
IServerConfigurationManager config,
IApplicationPaths appPaths)
{
_logger = logger;
_config = config;
_appPaths = appPaths;
}
@@ -35,31 +40,43 @@ public class PathManager : IPathManager
private string AttachmentCachePath => Path.Combine(_appPaths.DataPath, "attachments");
/// <inheritdoc />
public string GetAttachmentPath(string mediaSourceId, string fileName)
public string? GetAttachmentPath(string mediaSourceId, string fileName)
{
return Path.Combine(GetAttachmentFolderPath(mediaSourceId), fileName);
var folder = GetAttachmentFolderPath(mediaSourceId);
return folder is null ? null : Path.Combine(folder, fileName);
}
/// <inheritdoc />
public string GetAttachmentFolderPath(string mediaSourceId)
public string? GetAttachmentFolderPath(string mediaSourceId)
{
var id = Guid.Parse(mediaSourceId).ToString("D", CultureInfo.InvariantCulture).AsSpan();
if (!Guid.TryParse(mediaSourceId, out var parsed))
{
_logger.LogDebug("MediaSource Id '{MediaSourceId}' is not a GUID; no on-disk attachment folder.", mediaSourceId);
return null;
}
var id = parsed.ToString("D", CultureInfo.InvariantCulture).AsSpan();
return Path.Join(AttachmentCachePath, id[..2], id);
}
/// <inheritdoc />
public string GetSubtitleFolderPath(string mediaSourceId)
public string? GetSubtitleFolderPath(string mediaSourceId)
{
var id = Guid.Parse(mediaSourceId).ToString("D", CultureInfo.InvariantCulture).AsSpan();
if (!Guid.TryParse(mediaSourceId, out var parsed))
{
_logger.LogDebug("MediaSource Id '{MediaSourceId}' is not a GUID; no on-disk subtitle folder.", mediaSourceId);
return null;
}
var id = parsed.ToString("D", CultureInfo.InvariantCulture).AsSpan();
return Path.Join(SubtitleCachePath, id[..2], id);
}
/// <inheritdoc />
public string GetSubtitlePath(string mediaSourceId, int streamIndex, string extension)
public string? GetSubtitlePath(string mediaSourceId, int streamIndex, string extension)
{
return Path.Combine(GetSubtitleFolderPath(mediaSourceId), streamIndex.ToString(CultureInfo.InvariantCulture) + extension);
var folder = GetSubtitleFolderPath(mediaSourceId);
return folder is null ? null : Path.Combine(folder, streamIndex.ToString(CultureInfo.InvariantCulture) + extension);
}
/// <inheritdoc />
@@ -90,12 +107,23 @@ public class PathManager : IPathManager
public IReadOnlyList<string> GetExtractedDataPaths(BaseItem item)
{
var mediaSourceId = item.Id.ToString("N", CultureInfo.InvariantCulture);
return [
GetAttachmentFolderPath(mediaSourceId),
GetSubtitleFolderPath(mediaSourceId),
GetTrickplayDirectory(item, false),
GetTrickplayDirectory(item, true),
GetChapterImageFolderPath(item)
];
List<string> paths = [];
var attachmentFolder = GetAttachmentFolderPath(mediaSourceId);
if (attachmentFolder is not null)
{
paths.Add(attachmentFolder);
}
var subtitleFolder = GetSubtitleFolderPath(mediaSourceId);
if (subtitleFolder is not null)
{
paths.Add(subtitleFolder);
}
paths.Add(GetTrickplayDirectory(item, false));
paths.Add(GetTrickplayDirectory(item, true));
paths.Add(GetChapterImageFolderPath(item));
return paths;
}
}

View File

@@ -1,8 +1,10 @@
#nullable disable
using System;
using System.IO;
using System.Linq;
using Emby.Naming.Common;
using Emby.Server.Implementations.Library;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
@@ -81,10 +83,34 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
episode.ParentIndexNumber = 1;
}
SetProviderIdFromPath(episode, args.Path);
return episode;
}
return null;
}
/// <summary>
/// Sets provider ids from the episode file name.
/// </summary>
/// <param name="item">The episode.</param>
/// <param name="path">The episode file path.</param>
private static void SetProviderIdFromPath(Episode item, string path)
{
var justName = Path.GetFileNameWithoutExtension(path.AsSpan());
var imdbId = justName.GetAttributeValue("imdbid");
item.TrySetProviderId(MetadataProvider.Imdb, imdbId);
var tvdbId = justName.GetAttributeValue("tvdbid");
item.TrySetProviderId(MetadataProvider.Tvdb, tvdbId);
var tvmazeId = justName.GetAttributeValue("tvmazeid");
item.TrySetProviderId(MetadataProvider.TvMaze, tvmazeId);
var tmdbId = justName.GetAttributeValue("tmdbid");
item.TrySetProviderId(MetadataProvider.Tmdb, tmdbId);
}
}
}

View File

@@ -1,10 +1,15 @@
#nullable disable
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using Emby.Naming.Common;
using Emby.Naming.TV;
using Emby.Server.Implementations.Library;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
using Microsoft.Extensions.Logging;
@@ -77,6 +82,14 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
return null;
}
var hasAnyVideo = Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)
.Any(file => _namingOptions.VideoFileExtensions.Contains(Path.GetExtension(file)));
if (!hasAnyVideo)
{
return null;
}
}
if (season.IndexNumber.HasValue && string.IsNullOrEmpty(season.Name))
@@ -91,10 +104,31 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV
args.LibraryOptions.PreferredMetadataLanguage);
}
SetProviderIdFromPath(season, path);
return season;
}
return null;
}
/// <summary>
/// Sets provider ids from the season folder name.
/// </summary>
/// <param name="item">The season.</param>
/// <param name="path">The season folder path.</param>
private static void SetProviderIdFromPath(Season item, string path)
{
var justName = Path.GetFileName(path.AsSpan());
var tvdbId = justName.GetAttributeValue("tvdbid");
item.TrySetProviderId(MetadataProvider.Tvdb, tvdbId);
var tvmazeId = justName.GetAttributeValue("tvmazeid");
item.TrySetProviderId(MetadataProvider.TvMaze, tvmazeId);
var tmdbId = justName.GetAttributeValue("tmdbid");
item.TrySetProviderId(MetadataProvider.Tmdb, tmdbId);
}
}
}

View File

@@ -135,5 +135,6 @@
"TaskMoveTrickplayImages": "نقل موقع صور معاينات التنقل",
"TaskMoveTrickplayImagesDescription": "ينقل ملفات معاينات التنقل الحالية وفقاً لإعدادات المكتبة.",
"CleanupUserDataTask": "مهمة تنظيف بيانات المستخدم",
"CleanupUserDataTaskDescription": "ينظف جميع بيانات المستخدم (مثل حالة المشاهدة وحالة المفضلة وغيرها) للمحتوى الذي لم يعد موجوداً لمدة 90 يوماً على الأقل."
"CleanupUserDataTaskDescription": "ينظف جميع بيانات المستخدم (مثل حالة المشاهدة وحالة المفضلة وغيرها) للمحتوى الذي لم يعد موجوداً لمدة 90 يوماً على الأقل.",
"Original": "فريد"
}

View File

@@ -135,5 +135,6 @@
"TaskMoveTrickplayImages": "Migració de la ubicació de la imatge de previsualització",
"TaskMoveTrickplayImagesDescription": "Mou els fitxers existents d'imatges de previsualització segons la configuració de la mediateca.",
"CleanupUserDataTaskDescription": "Neteja totes les dades d'usuari (estat de la visualització, estat dels preferits, etc.) del contingut multimèdia que no ha estat present durant almenys 90 dies.",
"CleanupUserDataTask": "Tasca de neteja de dades d'usuari"
"CleanupUserDataTask": "Tasca de neteja de dades d'usuari",
"Original": "Original"
}

View File

@@ -135,5 +135,6 @@
"TaskMoveTrickplayImages": "Přesunout úložiště obrázků Trickplay",
"TaskMoveTrickplayImagesDescription": "Přesune existující soubory Trickplay podle nastavení knihovny.",
"CleanupUserDataTaskDescription": "Odstraní všechna uživatelská data (stav zhlédnutí, oblíbené atd.) z médií, které již neexistují více než 90 dní.",
"CleanupUserDataTask": "Pročistit uživatelská data"
"CleanupUserDataTask": "Pročistit uživatelská data",
"Original": "Originál"
}

View File

@@ -135,5 +135,6 @@
"TaskMoveTrickplayImages": "Verzeichnis für Trickplay-Bilder migrieren",
"TaskMoveTrickplayImagesDescription": "Trickplay-Bilder werden entsprechend der Bibliothekseinstellungen verschoben.",
"CleanupUserDataTask": "Aufgabe zur Bereinigung von Benutzerdaten",
"CleanupUserDataTaskDescription": "Löscht alle Benutzerdaten (Abspielstatus, Favoritenstatus, usw.) von Medien, die seit mindestens 90 Tagen nicht mehr vorhanden sind."
"CleanupUserDataTaskDescription": "Löscht alle Benutzerdaten (Abspielstatus, Favoritenstatus, usw.) von Medien, die seit mindestens 90 Tagen nicht mehr vorhanden sind.",
"Original": "Original"
}

View File

@@ -64,6 +64,7 @@
"NotificationOptionUserLockedOut": "User locked out",
"NotificationOptionVideoPlayback": "Video playback started",
"NotificationOptionVideoPlaybackStopped": "Video playback stopped",
"Original": "Original",
"Photos": "Photos",
"Playlists": "Playlists",
"Plugin": "Plugin",

View File

@@ -135,5 +135,6 @@
"TaskExtractMediaSegmentsDescription": "Extrae u obtiene segmentos de medios de plugins habilitados para MediaSegment.",
"TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay",
"CleanupUserDataTask": "Tarea de limpieza de datos del usuario",
"CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, favoritos, etc.) de los medios que ya no están disponibles desde hace al menos 90 días."
"CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, favoritos, etc.) de los medios que ya no están disponibles desde hace al menos 90 días.",
"Original": "Original"
}

View File

@@ -135,5 +135,6 @@
"TaskCleanTranscode": "Eolaire Transcode Glan",
"TaskDownloadMissingSubtitles": "Íosluchtaigh fotheidil ar iarraidh",
"CleanupUserDataTask": "Tasc glantacháin sonraí úsáideora",
"CleanupUserDataTaskDescription": "Glanann sé gach sonraí úsáideora (stádas faire, stádas is fearr leat srl.) ó mheáin nach bhfuil i láthair a thuilleadh ar feadh 90 lá ar a laghad."
"CleanupUserDataTaskDescription": "Glanann sé gach sonraí úsáideora (stádas faire, stádas is fearr leat srl.) ó mheáin nach bhfuil i láthair a thuilleadh ar feadh 90 lá ar a laghad.",
"Original": "Bunaidh"
}

View File

@@ -135,5 +135,6 @@
"TaskMoveTrickplayImages": "Premjesti mjesto slika brzog pregledavanja",
"TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke brzog pregledavanja u postavke 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."
"CleanupUserDataTaskDescription": "Briše sve korisničke podatke (stanje gledanja, status favorita itd.) s medija koji više nisu prisutni najmanje 90 dana.",
"Original": "Original"
}

View File

@@ -61,5 +61,7 @@
"TasksMaintenanceCategory": "Antretyen",
"AppDeviceValues": "Aplikasyon: {0}, Aparèy: {1}",
"AuthenticationSucceededWithUserName": "{0} otantifye avèk siksè",
"CameraImageUploadedFrom": "Une nouvelle image de la caméra a été téléchargée depuis {0}"
"CameraImageUploadedFrom": "Une nouvelle image de la caméra a été téléchargée depuis {0}",
"Original": "Original",
"Playlists": "Pleliss"
}

View File

@@ -39,7 +39,7 @@
"MixedContent": "Vegyes tartalom",
"Movies": "Filmek",
"Music": "Zenék",
"MusicVideos": "Zenei videóklipek",
"MusicVideos": "Zenei videók",
"NameInstallFailed": "{0} sikertelen telepítés",
"NameSeasonNumber": "{0}. évad",
"NameSeasonUnknown": "Ismeretlen évad",
@@ -135,5 +135,6 @@
"TaskMoveTrickplayImagesDescription": "A médiatár-beállításoknak megfelelően áthelyezi a meglévő trickplay fájlokat.",
"TaskExtractMediaSegmentsDescription": "Kinyeri vagy megszerzi a médiaszegmenseket a MediaSegment támogatással rendelkező bővítményekből.",
"CleanupUserDataTaskDescription": "Legalább 90 napja nem elérhető médiákhoz kapcsolódó összes felhasználói adat (pl. megtekintési állapot, kedvencek) törlése.",
"CleanupUserDataTask": "Felhasználói adatok tisztítása feladat"
"CleanupUserDataTask": "Felhasználói adatok tisztítása feladat",
"Original": "Eredeti"
}

View File

@@ -135,5 +135,6 @@
"TaskExtractMediaSegmentsDescription": "Estrae o ottiene segmenti multimediali dai plugin abilitati MediaSegment.",
"TaskExtractMediaSegments": "Scansiona Segmento Media",
"CleanupUserDataTask": "Task di pulizia dei dati utente",
"CleanupUserDataTaskDescription": "Pulisce tutti i dati utente (stato di visione, status preferiti, ecc.) dai contenuti non più presenti da almeno 90 giorni."
"CleanupUserDataTaskDescription": "Pulisce tutti i dati utente (stato di visione, status preferiti, ecc.) dai contenuti non più presenti da almeno 90 giorni.",
"Original": "Originale"
}

View File

@@ -135,5 +135,6 @@
"TaskDownloadMissingLyrics": "Lejupielādēt trūkstošos vārdus",
"TaskDownloadMissingLyricsDescription": "Lejupielādēt vārdus dziesmām",
"CleanupUserDataTask": "Lietotāju datu tīrīšanas uzdevums",
"CleanupUserDataTaskDescription": "Notīra visus lietotāja datus (skatīšanās stāvokļus, favorītu statusi utt.) no medijiem, kas vairs nav pieejami vismaz 90 dienas."
"CleanupUserDataTaskDescription": "Notīra visus lietotāja datus (skatīšanās stāvokļus, favorītu statusi utt.) no medijiem, kas vairs nav pieejami vismaz 90 dienas.",
"Original": "Oriģināls"
}

View File

@@ -45,7 +45,7 @@
"Genres": "विधाहरू",
"Folders": "फोल्डरहरू",
"Favorites": "मनपर्ने",
"FailedLoginAttemptWithUserName": "{0}को लग इन प्रयास असफल",
"FailedLoginAttemptWithUserName": "असफल लग इन प्रयास {0} देखि",
"DeviceOnlineWithName": "{0}को साथ जडित",
"DeviceOfflineWithName": "{0}बाट विच्छेदन भयो",
"Collections": "संग्रह",

View File

@@ -135,5 +135,6 @@
"CleanupUserDataTaskDescription": "Wist alle gebruikersgegevens (kijkstatus, favorieten, etc.) van media die al minstens 90 dagen niet meer aanwezig zijn.",
"CleanupUserDataTask": "Opruimtaak gebruikersdata",
"Albums": "Albums",
"Genres": "Genres"
"Genres": "Genres",
"Original": "Oorspronkelijk"
}

View File

@@ -135,5 +135,6 @@
"TaskExtractMediaSegmentsDescription": "Wyodrębnia lub pobiera segmenty mediów z wtyczek obsługujących MediaSegment.",
"TaskMoveTrickplayImagesDescription": "Przenosi istniejące pliki Trickplay zgodnie z ustawieniami biblioteki.",
"CleanupUserDataTaskDescription": "Usuwa wszystkie dane użytkownika (stan oglądanych, status ulubionych itp.) z mediów, które nie są dostępne od co najmniej 90 dni.",
"CleanupUserDataTask": "Zadanie czyszczenia danych użytkownika"
"CleanupUserDataTask": "Zadanie czyszczenia danych użytkownika",
"Original": "Oryginalny"
}

View File

@@ -135,5 +135,6 @@
"TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.",
"TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.",
"CleanupUserDataTaskDescription": "Apaga todos os dados de utilizador (estados de reprodução, favoritos, etc) de arquivos média não presentes há 90 dias ou mais.",
"CleanupUserDataTask": "Limpeza de dados de utilizador"
"CleanupUserDataTask": "Limpeza de dados de utilizador",
"Original": "Original"
}

View File

@@ -135,5 +135,6 @@
"TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.",
"TaskMoveTrickplayImages": "Migrar a localização da imagem do Trickplay",
"CleanupUserDataTask": "Task de limpeza de dados do usuário",
"CleanupUserDataTaskDescription": "Remove todos os dados do usuário (progresso, favoritos etc) de mídias que não estão presentes há pelo menos 90 dias."
"CleanupUserDataTaskDescription": "Remove todos os dados do usuário (progresso, favoritos etc) de mídias que não estão presentes há pelo menos 90 dias.",
"Original": "Original"
}

View File

@@ -135,5 +135,6 @@
"TaskExtractMediaSegmentsDescription": "Извлекает или получает медиасегменты из плагинов MediaSegment.",
"TaskMoveTrickplayImagesDescription": "Перемещает существующие файлы trickplay в соответствии с настройками медиатеки.",
"CleanupUserDataTask": "Задача очистки пользовательских данных",
"CleanupUserDataTaskDescription": "Очищает все пользовательские данные (состояние просмотра, статус избранного и т.д.) с медиа, отсутствующих по меньшей мере в течение 90 дней."
"CleanupUserDataTaskDescription": "Очищает все пользовательские данные (состояние просмотра, статус избранного и т.д.) с медиа, отсутствующих по меньшей мере в течение 90 дней.",
"Original": "Оригинальный"
}

View File

@@ -135,5 +135,6 @@
"TaskMoveTrickplayImages": "Migrera platsen för Trickplay-bilder",
"TaskMoveTrickplayImagesDescription": "Flyttar befintliga trickplay-filer enligt bibliotekets inställningar.",
"CleanupUserDataTaskDescription": "Tar bort all användardata (såsom vad du sett, favoriter med mera) för media som inte funnits på enheten på minst 90 dagar.",
"CleanupUserDataTask": "Uppgift för rensning av användardata"
"CleanupUserDataTask": "Uppgift för rensning av användardata",
"Original": "Original"
}

View File

@@ -135,5 +135,6 @@
"TaskMoveTrickplayImages": "Змінити місце розташування прев'ю-зображень",
"TaskExtractMediaSegmentsDescription": "Витягує або отримує медіа-сегменти з плагінів з підтримкою MediaSegment.",
"CleanupUserDataTask": "Завдання очищення даних користувача",
"CleanupUserDataTaskDescription": "Очищає всі дані користувача (стан перегляду, статус обраного тощо) з медіа, які перестали бути доступними щонайменше 90 днів тому."
"CleanupUserDataTaskDescription": "Очищає всі дані користувача (стан перегляду, статус обраного тощо) з медіа, які перестали бути доступними щонайменше 90 днів тому.",
"Original": "Оригінал"
}

View File

@@ -320,6 +320,14 @@ namespace Emby.Server.Implementations.Localization
{
return value;
}
if (ratingsDictionary is not null && rating.Length > countryCode.Length
&& rating.StartsWith(countryCode, StringComparison.OrdinalIgnoreCase)
&& (rating[countryCode.Length] == '-' || rating[countryCode.Length] == ':')
&& ratingsDictionary.TryGetValue(rating[(countryCode.Length + 1)..].Trim(), out var normalizedValue))
{
return normalizedValue;
}
}
else
{
@@ -345,35 +353,70 @@ namespace Emby.Server.Implementations.Localization
}
}
// Try splitting by : to handle "Germany: FSK-18"
if (rating.Contains(':', StringComparison.OrdinalIgnoreCase))
// Try splitting by country prefix separator to handle "US:PG-13", "Germany: FSK-18", "DE-FSK-18"
if (TryGetRatingScoreBySeparator(rating, ':', out var result)
|| TryGetRatingScoreBySeparator(rating, '-', out result))
{
var ratingLevelRightPart = rating.AsSpan().RightPart(':');
if (ratingLevelRightPart.Length != 0)
{
return GetRatingScore(ratingLevelRightPart.ToString());
}
}
// Handle prefix country code to handle "DE-18"
if (rating.Contains('-', StringComparison.OrdinalIgnoreCase))
{
var ratingSpan = rating.AsSpan();
// Extract culture from country prefix
var culture = FindLanguageInfo(ratingSpan.LeftPart('-').ToString());
var ratingLevelRightPart = ratingSpan.RightPart('-');
if (ratingLevelRightPart.Length != 0)
{
// Check rating system of culture
return GetRatingScore(ratingLevelRightPart.ToString(), culture?.TwoLetterISOLanguageName);
}
return result;
}
return null;
}
private bool TryGetRatingScoreBySeparator(string rating, char separator, out ParentalRatingScore? result)
{
result = null;
if (rating.IndexOf(separator, StringComparison.Ordinal) < 0)
{
return false;
}
var ratingSpan = rating.AsSpan();
var countryPart = ratingSpan.LeftPart(separator).Trim().ToString();
var ratingPart = ratingSpan.RightPart(separator).Trim().ToString();
if (ratingPart.Length == 0)
{
return false;
}
string? resolvedCountryCode = null;
if (_allParentalRatings.ContainsKey(countryPart))
{
resolvedCountryCode = countryPart;
}
else
{
var culture = FindLanguageInfo(countryPart);
if (culture is not null)
{
resolvedCountryCode = culture.TwoLetterISOLanguageName;
}
}
if (resolvedCountryCode is not null
&& _allParentalRatings.TryGetValue(resolvedCountryCode, out var countryRatings))
{
if (countryRatings.TryGetValue(ratingPart, out result))
{
return true;
}
_logger.LogWarning(
"Rating '{Rating}' not found in the '{CountryCode}' rating system, treating as unrated",
rating,
resolvedCountryCode);
return true;
}
// Country not identified or no rating data available, try recursive lookup
result = GetRatingScore(ratingPart, resolvedCountryCode);
return true;
}
/// <inheritdoc />
public string GetLocalizedString(string phrase)
{

View File

@@ -3,7 +3,7 @@
"supportsSubScores": true,
"ratings": [
{
"ratingStrings": ["E", "G", "TV-Y", "TV-G"],
"ratingStrings": ["C", "E", "G", "TV-Y", "TV-G"],
"ratingScore": {
"score": 0,
"subScore": 0
@@ -23,11 +23,18 @@
"subScore": 1
}
},
{
"ratingStrings": ["C8"],
"ratingScore": {
"score": 8,
"subScore": 0
}
},
{
"ratingStrings": ["PG", "TV-PG"],
"ratingScore": {
"score": 9,
"subScore": 0
"score": 8,
"subScore": 1
}
},
{
@@ -38,7 +45,7 @@
}
},
{
"ratingStrings": ["TV-14"],
"ratingStrings": ["14+", "TV-14"],
"ratingScore": {
"score": 14,
"subScore": 1

View File

@@ -85,9 +85,17 @@ namespace Emby.Server.Implementations.Serialization
/// <returns>System.Object.</returns>
public object? DeserializeFromFile(Type type, string file)
{
using (var stream = File.OpenRead(file))
try
{
return DeserializeFromStream(type, stream);
using (var stream = File.OpenRead(file))
{
return DeserializeFromStream(type, stream);
}
}
catch (Exception ex)
{
ex.Data.Add("Filename", file);
throw;
}
}

View File

@@ -973,7 +973,7 @@ namespace Emby.Server.Implementations.Session
if (user.RememberAudioSelections)
{
if (data.AudioStreamIndex != info.AudioStreamIndex)
if (info.AudioStreamIndex.HasValue && data.AudioStreamIndex != info.AudioStreamIndex)
{
data.AudioStreamIndex = info.AudioStreamIndex;
changed = true;
@@ -990,7 +990,7 @@ namespace Emby.Server.Implementations.Session
if (user.RememberSubtitleSelections)
{
if (data.SubtitleStreamIndex != info.SubtitleStreamIndex)
if (info.SubtitleStreamIndex.HasValue && data.SubtitleStreamIndex != info.SubtitleStreamIndex)
{
data.SubtitleStreamIndex = info.SubtitleStreamIndex;
changed = true;
@@ -1021,15 +1021,22 @@ namespace Emby.Server.Implementations.Session
ArgumentNullException.ThrowIfNull(info);
if (info.PositionTicks.HasValue && info.PositionTicks.Value < 0)
{
throw new ArgumentOutOfRangeException(nameof(info), "The PlaybackStopInfo's PositionTicks was negative.");
}
var session = GetSession(info.SessionId);
session.StopAutomaticProgress();
if (info.PositionTicks.HasValue && info.PositionTicks.Value < 0)
{
// Ensure live stream is cleaned up before throwing, to prevent tuner
// resource leaks when stalled clients report a negative PositionTicks.
if (!string.IsNullOrEmpty(info.LiveStreamId))
{
await CloseLiveStreamIfNeededAsync(info.LiveStreamId, session.Id).ConfigureAwait(false);
}
throw new ArgumentOutOfRangeException(nameof(info), "The PlaybackStopInfo's PositionTicks was negative.");
}
var libraryItem = info.ItemId.IsEmpty()
? null
: GetNowPlayingItem(session, info.ItemId);
@@ -2049,7 +2056,7 @@ namespace Emby.Server.Implementations.Session
{
CheckDisposed();
var adminUserIds = _userManager.Users
var adminUserIds = _userManager.GetUsers()
.Where(i => i.HasPermission(PermissionKind.IsAdministrator))
.Select(i => i.Id)
.ToList();

View File

@@ -20,6 +20,7 @@ namespace Jellyfin.Api.Controllers;
/// The dashboard controller.
/// </summary>
[Route("")]
[Tags("Plugin")]
public class DashboardController : BaseJellyfinApiController
{
private readonly ILogger<DashboardController> _logger;

View File

@@ -196,6 +196,7 @@ public class InstantMixController : BaseJellyfinApiController
/// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the playlist items.</returns>
[HttpGet("MusicGenres/{name}/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Obsolete("Use GetInstantMixFromItem")]
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreByName(
[FromRoute, Required] string name,
[FromQuery] Guid? userId,
@@ -359,7 +360,7 @@ public class InstantMixController : BaseJellyfinApiController
[HttpGet("MusicGenres/InstantMix")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Obsolete("Use GetInstantMixFromMusicGenreByName")]
[Obsolete("Use GetInstantMixFromItem")]
public ActionResult<QueryResult<BaseItemDto>> GetInstantMixFromMusicGenreById(
[FromQuery, Required] Guid id,
[FromQuery] Guid? userId,

View File

@@ -20,6 +20,7 @@ namespace Jellyfin.Api.Controllers;
/// </summary>
[Route("Items")]
[Authorize(Policy = Policies.RequiresElevation)]
[Tags("Library")]
public class ItemRefreshController : BaseJellyfinApiController
{
private readonly ILibraryManager _libraryManager;

View File

@@ -242,6 +242,7 @@ public class ItemUpdateController : BaseJellyfinApiController
item.ForcedSortName = request.ForcedSortName;
item.OriginalTitle = string.IsNullOrWhiteSpace(request.OriginalTitle) ? null : request.OriginalTitle;
item.OriginalLanguage = string.IsNullOrWhiteSpace(request.OriginalLanguage) ? null : request.OriginalLanguage;
item.CriticRating = request.CriticRating;

View File

@@ -31,7 +31,7 @@ namespace Jellyfin.Api.Controllers;
/// </summary>
[Route("")]
[Authorize]
[Tags("Item")]
[Tags("Library")]
public class ItemsController : BaseJellyfinApiController
{
private readonly IUserManager _userManager;
@@ -955,6 +955,7 @@ public class ItemsController : BaseJellyfinApiController
[HttpGet("UserItems/{itemId}/UserData")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Tags("UserData")]
public ActionResult<UserItemDataDto?> GetItemUserData(
[FromQuery] Guid? userId,
[FromRoute, Required] Guid itemId)
@@ -1010,6 +1011,7 @@ public class ItemsController : BaseJellyfinApiController
[HttpPost("UserItems/{itemId}/UserData")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Tags("UserData")]
public ActionResult<UserItemDataDto?> UpdateItemUserData(
[FromQuery] Guid? userId,
[FromRoute, Required] Guid itemId,

View File

@@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Net.Http;
using System.Net.Mime;
using System.Security.Cryptography;
using System.Text;
@@ -18,8 +17,6 @@ using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Common.Api;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
@@ -49,12 +46,11 @@ public class LiveTvController : BaseJellyfinApiController
private readonly IListingsManager _listingsManager;
private readonly IRecordingsManager _recordingsManager;
private readonly IUserManager _userManager;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILibraryManager _libraryManager;
private readonly IDtoService _dtoService;
private readonly IMediaSourceManager _mediaSourceManager;
private readonly IConfigurationManager _configurationManager;
private readonly ITranscodeManager _transcodeManager;
private readonly ISchedulesDirectService _schedulesDirectService;
/// <summary>
/// Initializes a new instance of the <see cref="LiveTvController"/> class.
@@ -65,12 +61,11 @@ public class LiveTvController : BaseJellyfinApiController
/// <param name="listingsManager">Instance of the <see cref="IListingsManager"/> interface.</param>
/// <param name="recordingsManager">Instance of the <see cref="IRecordingsManager"/> interface.</param>
/// <param name="userManager">Instance of the <see cref="IUserManager"/> interface.</param>
/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
/// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param>
/// <param name="transcodeManager">Instance of the <see cref="ITranscodeManager"/> interface.</param>
/// <param name="schedulesDirectService">Instance of the <see cref="ISchedulesDirectService"/> interface.</param>
public LiveTvController(
ILiveTvManager liveTvManager,
IGuideManager guideManager,
@@ -78,12 +73,11 @@ public class LiveTvController : BaseJellyfinApiController
IListingsManager listingsManager,
IRecordingsManager recordingsManager,
IUserManager userManager,
IHttpClientFactory httpClientFactory,
ILibraryManager libraryManager,
IDtoService dtoService,
IMediaSourceManager mediaSourceManager,
IConfigurationManager configurationManager,
ITranscodeManager transcodeManager)
ITranscodeManager transcodeManager,
ISchedulesDirectService schedulesDirectService)
{
_liveTvManager = liveTvManager;
_guideManager = guideManager;
@@ -91,12 +85,11 @@ public class LiveTvController : BaseJellyfinApiController
_listingsManager = listingsManager;
_recordingsManager = recordingsManager;
_userManager = userManager;
_httpClientFactory = httpClientFactory;
_libraryManager = libraryManager;
_dtoService = dtoService;
_mediaSourceManager = mediaSourceManager;
_configurationManager = configurationManager;
_transcodeManager = transcodeManager;
_schedulesDirectService = schedulesDirectService;
}
/// <summary>
@@ -345,20 +338,6 @@ public class LiveTvController : BaseJellyfinApiController
[Authorize(Policy = Policies.LiveTvAccess)]
[Obsolete("This endpoint is obsolete.")]
[ApiExplorerSettings(IgnoreApi = true)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "channelId", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "groupId", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "startIndex", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "limit", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "status", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "isInProgress", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "seriesTimerId", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImages", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "imageTypeLimit", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableImageTypes", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "fields", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableUserData", Justification = "Imported from ServiceStack")]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "enableTotalRecordCount", Justification = "Imported from ServiceStack")]
public ActionResult<QueryResult<BaseItemDto>> GetRecordingsSeries(
[FromQuery] string? channelId,
[FromQuery] Guid? userId,
@@ -389,7 +368,6 @@ public class LiveTvController : BaseJellyfinApiController
[Authorize(Policy = Policies.LiveTvAccess)]
[Obsolete("This endpoint is obsolete.")]
[ApiExplorerSettings(IgnoreApi = true)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "userId", Justification = "Imported from ServiceStack")]
public ActionResult<QueryResult<BaseItemDto>> GetRecordingGroups([FromQuery] Guid? userId)
{
return new QueryResult<BaseItemDto>();
@@ -834,7 +812,6 @@ public class LiveTvController : BaseJellyfinApiController
[HttpPost("Timers/{timerId}")]
[Authorize(Policy = Policies.LiveTvManagement)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")]
public async Task<ActionResult> UpdateTimer([FromRoute, Required] string timerId, [FromBody] TimerInfoDto timerInfo)
{
await _liveTvManager.UpdateTimer(timerInfo, CancellationToken.None).ConfigureAwait(false);
@@ -924,7 +901,6 @@ public class LiveTvController : BaseJellyfinApiController
[HttpPost("SeriesTimers/{timerId}")]
[Authorize(Policy = Policies.LiveTvManagement)]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "timerId", Justification = "Imported from ServiceStack")]
public async Task<ActionResult> UpdateSeriesTimer([FromRoute, Required] string timerId, [FromBody] SeriesTimerInfoDto seriesTimerInfo)
{
await _liveTvManager.UpdateSeriesTimer(seriesTimerInfo, CancellationToken.None).ConfigureAwait(false);
@@ -980,9 +956,7 @@ public class LiveTvController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult DeleteTunerHost([FromQuery] string? id)
{
var config = _configurationManager.GetConfiguration<LiveTvOptions>("livetv");
config.TunerHosts = config.TunerHosts.Where(i => !string.Equals(id, i.Id, StringComparison.OrdinalIgnoreCase)).ToArray();
_configurationManager.SaveConfiguration("livetv", config);
_tunerHostManager.DeleteTunerHost(id);
return NoContent();
}
@@ -1073,13 +1047,8 @@ public class LiveTvController : BaseJellyfinApiController
[ProducesFile(MediaTypeNames.Application.Json)]
public async Task<ActionResult> GetSchedulesDirectCountries()
{
var client = _httpClientFactory.CreateClient(NamedClient.Default);
// https://json.schedulesdirect.org/20141201/available/countries
// Can't dispose the response as it's required up the call chain.
var response = await client.GetAsync(new Uri("https://json.schedulesdirect.org/20141201/available/countries"))
.ConfigureAwait(false);
return File(await response.Content.ReadAsStreamAsync().ConfigureAwait(false), MediaTypeNames.Application.Json);
var stream = await _schedulesDirectService.GetAvailableCountries(CancellationToken.None).ConfigureAwait(false);
return File(stream, MediaTypeNames.Application.Json);
}
/// <summary>

View File

@@ -72,6 +72,7 @@ public class PlaystateController : BaseJellyfinApiController
[HttpPost("UserPlayedItems/{itemId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Tags("UserData")]
public async Task<ActionResult<UserItemDataDto?>> MarkPlayedItem(
[FromQuery] Guid? userId,
[FromRoute, Required] Guid itemId,
@@ -138,6 +139,7 @@ public class PlaystateController : BaseJellyfinApiController
[HttpDelete("UserPlayedItems/{itemId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[Tags("UserData")]
public async Task<ActionResult<UserItemDataDto?>> MarkUnplayedItem(
[FromQuery] Guid? userId,
[FromRoute, Required] Guid itemId)

View File

@@ -432,6 +432,7 @@ public class SessionController : BaseJellyfinApiController
/// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the auth providers.</returns>
[HttpGet("Auth/Providers")]
[Authorize(Policy = Policies.RequiresElevation)]
[Tags("Authentication")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<IEnumerable<NameIdPair>> GetAuthProviders()
{
@@ -444,6 +445,7 @@ public class SessionController : BaseJellyfinApiController
/// <response code="200">Password reset providers retrieved.</response>
/// <returns>An <see cref="IEnumerable{NameIdPair}"/> with the password reset providers.</returns>
[HttpGet("Auth/PasswordResetProviders")]
[Tags("Authentication")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Authorize(Policy = Policies.RequiresElevation)]
public ActionResult<IEnumerable<NameIdPair>> GetPasswordResetProviders()

View File

@@ -1,7 +1,6 @@
using System;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Models.StartupDtos;
using MediaBrowser.Common.Api;
using MediaBrowser.Common.Net;
@@ -54,6 +53,7 @@ public class StartupController : BaseJellyfinApiController
/// <returns>An <see cref="OkResult"/> containing the initial startup wizard configuration.</returns>
[HttpGet("Configuration")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Obsolete("Use configuration endpoints")]
public ActionResult<StartupConfigurationDto> GetStartupConfiguration()
{
return new StartupConfigurationDto
@@ -73,6 +73,7 @@ public class StartupController : BaseJellyfinApiController
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("Configuration")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[Obsolete("Use configuration endpoints")]
public ActionResult UpdateInitialConfiguration([FromBody, Required] StartupConfigurationDto startupConfiguration)
{
_config.Configuration.ServerName = startupConfiguration.ServerName ?? string.Empty;
@@ -91,6 +92,7 @@ public class StartupController : BaseJellyfinApiController
/// <returns>A <see cref="NoContentResult"/> indicating success.</returns>
[HttpPost("RemoteAccess")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[Obsolete("Use configuration endpoints")]
public ActionResult SetRemoteAccess([FromBody, Required] StartupRemoteAccessDto startupRemoteAccessDto)
{
NetworkConfiguration settings = _config.GetNetworkConfiguration();
@@ -107,11 +109,12 @@ public class StartupController : BaseJellyfinApiController
[HttpGet("User")]
[HttpGet("FirstUser", Name = "GetFirstUser_2")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Obsolete("Use authentication endpoints")]
public async Task<StartupUserDto> GetFirstUser()
{
// TODO: Remove this method when startup wizard no longer requires an existing user.
await _userManager.InitializeAsync().ConfigureAwait(false);
var user = _userManager.Users.First();
var user = _userManager.GetFirstUser() ?? throw new InvalidOperationException("No user exists after initialization.");
return new StartupUserDto
{
Name = user.Username
@@ -131,7 +134,12 @@ public class StartupController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status204NoContent)]
public async Task<ActionResult> UpdateStartupUser([FromBody] StartupUserDto startupUserDto)
{
var user = _userManager.Users.First();
var user = _userManager.GetFirstUser();
if (user is null)
{
return NotFound();
}
if (string.IsNullOrWhiteSpace(startupUserDto.Password))
{
return BadRequest("Password must not be empty");
@@ -146,7 +154,7 @@ public class StartupController : BaseJellyfinApiController
if (!string.IsNullOrEmpty(startupUserDto.Password))
{
await _userManager.ChangePassword(user, startupUserDto.Password).ConfigureAwait(false);
await _userManager.ChangePassword(user.Id, startupUserDto.Password).ConfigureAwait(false);
}
return NoContent();

View File

@@ -208,6 +208,7 @@ public class UserController : BaseJellyfinApiController
/// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns>
[HttpPost("AuthenticateByName")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Tags("Authentication")]
public async Task<ActionResult<AuthenticationResult>> AuthenticateUserByName([FromBody, Required] AuthenticateUserByName request)
{
var auth = await _authContext.GetAuthorizationInfo(Request).ConfigureAwait(false);
@@ -243,6 +244,7 @@ public class UserController : BaseJellyfinApiController
/// <returns>A <see cref="Task"/> containing an <see cref="AuthenticationRequest"/> with information about the new session.</returns>
[HttpPost("AuthenticateWithQuickConnect")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Tags("Authentication")]
public ActionResult<AuthenticationResult> AuthenticateWithQuickConnect([FromBody, Required] QuickConnectDto request)
{
try
@@ -288,7 +290,7 @@ public class UserController : BaseJellyfinApiController
if (request.ResetPassword)
{
await _userManager.ResetPassword(user).ConfigureAwait(false);
await _userManager.ResetPassword(user.Id).ConfigureAwait(false);
}
else
{
@@ -306,7 +308,7 @@ public class UserController : BaseJellyfinApiController
}
}
await _userManager.ChangePassword(user, request.NewPw ?? string.Empty).ConfigureAwait(false);
await _userManager.ChangePassword(user.Id, request.NewPw ?? string.Empty).ConfigureAwait(false);
var currentToken = User.GetToken();
@@ -369,7 +371,7 @@ public class UserController : BaseJellyfinApiController
if (!string.Equals(user.Username, updateUser.Name, StringComparison.Ordinal))
{
await _userManager.RenameUser(user, updateUser.Name).ConfigureAwait(false);
await _userManager.RenameUser(user.Id, user.Username, updateUser.Name).ConfigureAwait(false);
}
await _userManager.UpdateConfigurationAsync(requestUserId, updateUser.Configuration).ConfigureAwait(false);
@@ -425,7 +427,7 @@ public class UserController : BaseJellyfinApiController
// If removing admin access
if (!newPolicy.IsAdministrator && user.HasPermission(PermissionKind.IsAdministrator))
{
if (_userManager.Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1)
if (_userManager.GetUsers().Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1)
{
return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one user in the system with administrative access.");
}
@@ -440,7 +442,7 @@ public class UserController : BaseJellyfinApiController
// If disabling
if (newPolicy.IsDisabled && !user.HasPermission(PermissionKind.IsDisabled))
{
if (_userManager.Users.Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1)
if (_userManager.GetUsers().Count(i => !i.HasPermission(PermissionKind.IsDisabled)) == 1)
{
return StatusCode(StatusCodes.Status403Forbidden, "There must be at least one enabled user in the system.");
}
@@ -522,7 +524,7 @@ public class UserController : BaseJellyfinApiController
// no need to authenticate password for new user
if (request.Password is not null)
{
await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false);
await _userManager.ChangePassword(newUser.Id, request.Password).ConfigureAwait(false);
}
var result = _userManager.GetUserDto(newUser, HttpContext.GetNormalizedRemoteIP().ToString());
@@ -538,6 +540,7 @@ public class UserController : BaseJellyfinApiController
/// <returns>A <see cref="Task"/> containing a <see cref="ForgotPasswordResult"/>.</returns>
[HttpPost("ForgotPassword")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Tags("Authentication")]
public async Task<ActionResult<ForgotPasswordResult>> ForgotPassword([FromBody, Required] ForgotPasswordDto forgotPasswordRequest)
{
var ip = HttpContext.GetNormalizedRemoteIP();
@@ -562,6 +565,7 @@ public class UserController : BaseJellyfinApiController
/// <returns>A <see cref="Task"/> containing a <see cref="PinRedeemResult"/>.</returns>
[HttpPost("ForgotPassword/Pin")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Tags("Authentication")]
public async Task<ActionResult<PinRedeemResult>> ForgotPasswordPin([FromBody, Required] ForgotPasswordPinDto forgotPasswordPinRequest)
{
var result = await _userManager.RedeemPasswordResetPin(forgotPasswordPinRequest.Pin).ConfigureAwait(false);
@@ -597,7 +601,7 @@ public class UserController : BaseJellyfinApiController
private IEnumerable<UserDto> Get(bool? isHidden, bool? isDisabled, bool filterByDevice, bool filterByNetwork)
{
var users = _userManager.Users;
var users = _userManager.GetUsers();
if (isDisabled.HasValue)
{

View File

@@ -31,6 +31,7 @@ namespace Jellyfin.Api.Controllers;
/// </summary>
[Route("")]
[Authorize]
[Tags("Library")]
public class UserLibraryController : BaseJellyfinApiController
{
private readonly IUserManager _userManager;
@@ -212,6 +213,7 @@ public class UserLibraryController : BaseJellyfinApiController
/// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
[HttpPost("UserFavoriteItems/{itemId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Tags("UserData")]
public ActionResult<UserItemDataDto> MarkFavoriteItem(
[FromQuery] Guid? userId,
[FromRoute, Required] Guid itemId)
@@ -259,6 +261,7 @@ public class UserLibraryController : BaseJellyfinApiController
/// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
[HttpDelete("UserFavoriteItems/{itemId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Tags("UserData")]
public ActionResult<UserItemDataDto> UnmarkFavoriteItem(
[FromQuery] Guid? userId,
[FromRoute, Required] Guid itemId)
@@ -306,6 +309,7 @@ public class UserLibraryController : BaseJellyfinApiController
/// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
[HttpDelete("UserItems/{itemId}/Rating")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Tags("UserData")]
public ActionResult<UserItemDataDto?> DeleteUserItemRating(
[FromQuery] Guid? userId,
[FromRoute, Required] Guid itemId)
@@ -354,6 +358,7 @@ public class UserLibraryController : BaseJellyfinApiController
/// <returns>An <see cref="OkResult"/> containing the <see cref="UserItemDataDto"/>.</returns>
[HttpPost("UserItems/{itemId}/Rating")]
[ProducesResponseType(StatusCodes.Status200OK)]
[Tags("UserData")]
public ActionResult<UserItemDataDto?> UpdateUserItemRating(
[FromQuery] Guid? userId,
[FromRoute, Required] Guid itemId,

View File

@@ -18,7 +18,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Data</PackageId>
<VersionPrefix>10.12.0</VersionPrefix>
<VersionPrefix>12.0.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>

View File

@@ -68,6 +68,7 @@ internal static class BaseItemMapper
dto.CriticRating = entity.CriticRating;
dto.PresentationUniqueKey = entity.PresentationUniqueKey;
dto.OriginalTitle = entity.OriginalTitle;
dto.OriginalLanguage = entity.OriginalLanguage;
dto.Album = entity.Album;
dto.LUFS = entity.LUFS;
dto.NormalizationGain = entity.NormalizationGain;
@@ -243,6 +244,7 @@ internal static class BaseItemMapper
entity.CriticRating = dto.CriticRating;
entity.PresentationUniqueKey = dto.PresentationUniqueKey;
entity.OriginalTitle = dto.OriginalTitle;
entity.OriginalLanguage = dto.OriginalLanguage;
entity.Album = dto.Album;
entity.LUFS = dto.LUFS;
entity.NormalizationGain = dto.NormalizationGain;

View File

@@ -5,7 +5,6 @@ using System.Collections.Generic;
using System.Linq;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Querying;
@@ -170,92 +169,40 @@ public sealed partial class BaseItemRepository
ExcludeItemIds = filter.ExcludeItemIds
};
// Build the master query and collapse rows that share a PresentationUniqueKey
// (e.g. alternate versions) by picking the lowest Id per group.
// Collapse rows that share a PresentationUniqueKey (e.g. alternate versions) by picking
// the lowest Id per group. Keep as an IQueryable sub-select so paging is applied AFTER
// ApplyOrder runs the caller's actual sort.
var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter);
var orderedMasterQuery = ApplyOrder(masterQuery, filter, context)
var representativeIds = masterQuery
.GroupBy(e => e.PresentationUniqueKey)
.Select(g => g.Min(e => e.Id));
var result = new QueryResult<(BaseItemDto, ItemCounts?)>();
if (filter.EnableTotalRecordCount)
{
result.TotalRecordCount = orderedMasterQuery.Count();
result.TotalRecordCount = representativeIds.Count();
}
if (filter.StartIndex.HasValue && filter.StartIndex.Value > 0)
{
orderedMasterQuery = orderedMasterQuery.Skip(filter.StartIndex.Value);
}
if (filter.Limit.HasValue)
{
orderedMasterQuery = orderedMasterQuery.Take(filter.Limit.Value);
}
var masterIds = orderedMasterQuery.ToList();
var query = ApplyNavigations(
context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => masterIds.Contains(e.Id)),
context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => representativeIds.Contains(e.Id)),
filter);
query = ApplyOrder(query, filter, context);
if (filter.StartIndex.HasValue && filter.StartIndex.Value > 0)
{
query = query.Skip(filter.StartIndex.Value);
}
if (filter.Limit.HasValue)
{
query = query.Take(filter.Limit.Value);
}
result.StartIndex = filter.StartIndex ?? 0;
if (filter.IncludeItemTypes.Length > 0)
{
var typeSubQuery = new InternalItemsQuery(filter.User)
{
ExcludeItemTypes = filter.ExcludeItemTypes,
IncludeItemTypes = filter.IncludeItemTypes,
MediaTypes = filter.MediaTypes,
AncestorIds = filter.AncestorIds,
ExcludeItemIds = filter.ExcludeItemIds,
ItemIds = filter.ItemIds,
TopParentIds = filter.TopParentIds,
ParentId = filter.ParentId,
IsPlayed = filter.IsPlayed
};
var itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery)
.Where(e => e.ItemValues!.Any(f => itemValueTypes!.Contains(f.ItemValue.Type)));
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode];
var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum];
var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist];
var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio];
var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer];
var itemIds = itemCountQuery.Select(e => e.Id);
// Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite)
// Instead, start from ItemValueMaps and join with BaseItems
var countsByCleanName = context.ItemValuesMap
.Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type))
.Where(ivm => itemIds.Contains(ivm.ItemId))
.Join(
context.BaseItems,
ivm => ivm.ItemId,
e => e.Id,
(ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type })
.GroupBy(x => new { x.CleanName, x.Type })
.Select(g => new { g.Key.CleanName, g.Key.Type, Count = g.Count() })
.GroupBy(x => x.CleanName)
.ToDictionary(
g => g.Key,
g => new ItemCounts
{
SeriesCount = g.Where(x => x.Type == seriesTypeName).Sum(x => x.Count),
EpisodeCount = g.Where(x => x.Type == episodeTypeName).Sum(x => x.Count),
MovieCount = g.Where(x => x.Type == movieTypeName).Sum(x => x.Count),
AlbumCount = g.Where(x => x.Type == musicAlbumTypeName).Sum(x => x.Count),
ArtistCount = g.Where(x => x.Type == musicArtistTypeName).Sum(x => x.Count),
SongCount = g.Where(x => x.Type == audioTypeName).Sum(x => x.Count),
TrailerCount = g.Where(x => x.Type == trailerTypeName).Sum(x => x.Count),
});
result.StartIndex = filter.StartIndex ?? 0;
var countsByCleanName = BuildItemCountsByCleanName(context, filter, itemValueTypes);
result.Items =
[
.. query
@@ -273,7 +220,6 @@ public sealed partial class BaseItemRepository
}
else
{
result.StartIndex = filter.StartIndex ?? 0;
result.Items =
[
.. query
@@ -287,4 +233,61 @@ public sealed partial class BaseItemRepository
return result;
}
private Dictionary<string, ItemCounts> BuildItemCountsByCleanName(
Database.Implementations.JellyfinDbContext context,
InternalItemsQuery filter,
IReadOnlyList<ItemValueType> itemValueTypes)
{
var typeSubQuery = new InternalItemsQuery(filter.User)
{
ExcludeItemTypes = filter.ExcludeItemTypes,
IncludeItemTypes = filter.IncludeItemTypes,
MediaTypes = filter.MediaTypes,
AncestorIds = filter.AncestorIds,
ExcludeItemIds = filter.ExcludeItemIds,
ItemIds = filter.ItemIds,
TopParentIds = filter.TopParentIds,
ParentId = filter.ParentId,
IsPlayed = filter.IsPlayed
};
var itemCountQuery = TranslateQuery(context.BaseItems.AsNoTracking().Where(e => e.Id != EF.Constant(PlaceholderId)), context, typeSubQuery)
.Where(e => e.ItemValues!.Any(f => itemValueTypes!.Contains(f.ItemValue.Type)));
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode];
var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum];
var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist];
var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio];
var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer];
var itemIds = itemCountQuery.Select(e => e.Id);
// Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite)
// Instead, start from ItemValueMaps and join with BaseItems
return context.ItemValuesMap
.Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type))
.Where(ivm => itemIds.Contains(ivm.ItemId))
.Join(
context.BaseItems,
ivm => ivm.ItemId,
e => e.Id,
(ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type })
.GroupBy(x => new { x.CleanName, x.Type })
.Select(g => new { g.Key.CleanName, g.Key.Type, Count = g.Count() })
.GroupBy(x => x.CleanName)
.ToDictionary(
g => g.Key,
g => new ItemCounts
{
SeriesCount = g.Where(x => x.Type == seriesTypeName).Sum(x => x.Count),
EpisodeCount = g.Where(x => x.Type == episodeTypeName).Sum(x => x.Count),
MovieCount = g.Where(x => x.Type == movieTypeName).Sum(x => x.Count),
AlbumCount = g.Where(x => x.Type == musicAlbumTypeName).Sum(x => x.Count),
ArtistCount = g.Where(x => x.Type == musicArtistTypeName).Sum(x => x.Count),
SongCount = g.Where(x => x.Type == audioTypeName).Sum(x => x.Count),
TrailerCount = g.Where(x => x.Type == trailerTypeName).Sum(x => x.Count),
});
}
}

View File

@@ -3,7 +3,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
@@ -125,45 +124,53 @@ public sealed partial class BaseItemRepository
return GetLatestTvShowItems(context, baseQuery, filter, limit);
}
// Find the top N group keys ordered by most recent DateCreated.
// Movies group by PresentationUniqueKey (alternate versions like 4K/1080p share a key).
// Music groups by Album.
Expression<Func<BaseItemEntity, bool>> groupKeyFilter;
Expression<Func<BaseItemEntity, string?>> groupKeySelector;
if (collectionType is CollectionType.movies)
{
groupKeyFilter = e => e.PresentationUniqueKey != null;
groupKeySelector = e => e.PresentationUniqueKey;
}
else
{
groupKeyFilter = e => e.Album != null;
groupKeySelector = e => e.Album;
// Group by PresentationUniqueKey, pick the newest item per group.
var topGroupItems = baseQuery
.Where(e => e.PresentationUniqueKey != null)
.GroupBy(e => e.PresentationUniqueKey)
.Select(g => new
{
MaxDate = g.Max(e => e.DateCreated),
FirstId = g.OrderByDescending(e => e.DateCreated).ThenByDescending(e => e.Id).Select(e => e.Id).First()
})
.OrderByDescending(g => g.MaxDate);
var firstIdsQuery = filter.Limit.HasValue
? topGroupItems.Take(filter.Limit.Value).Select(g => g.FirstId)
: topGroupItems.Select(g => g.FirstId);
return LoadLatestByIds(context, firstIdsQuery, filter);
}
// Group by GroupKey, pick the latest item per group (correlated subquery: ORDER BY DateCreated DESC, Id DESC LIMIT 1),
// order groups by group max date, take the top N — all in a single SQL statement.
// ThenByDescending(Id) is the tiebreaker for deterministic ordering when items share a DateCreated.
var topGroupItems = baseQuery
.Where(groupKeyFilter)
.GroupBy(groupKeySelector)
.Select(g => new
{
MaxDate = g.Max(e => e.DateCreated),
FirstId = g.OrderByDescending(e => e.DateCreated).ThenByDescending(e => e.Id).Select(e => e.Id).First()
})
.OrderByDescending(g => g.MaxDate);
// Albums whose Id is the parent of any track matching the user's filter.
var albumIdsWithMatchingTrack = context.AncestorIds
.Join(baseQuery, ai => ai.ItemId, t => t.Id, (ai, _) => ai.ParentItemId);
var firstIdsQuery = filter.Limit.HasValue
? topGroupItems.Take(filter.Limit.Value).Select(g => g.FirstId)
: topGroupItems.Select(g => g.FirstId);
var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]!;
var topAlbumsQuery = context.BaseItems.AsNoTracking()
.Where(album => album.Type == musicAlbumTypeName)
.Where(album => albumIdsWithMatchingTrack.Contains(album.Id))
.OrderByDescending(album => album.DateCreated)
.ThenByDescending(album => album.Id);
var firstIds = firstIdsQuery.ToList();
var albumIdsQuery = filter.Limit.HasValue
? topAlbumsQuery.Take(filter.Limit.Value).Select(a => a.Id)
: topAlbumsQuery.Select(a => a.Id);
// Single bound JSON / array parameter via WhereOneOrMany — keeps SQL small regardless of N.
var itemsQuery = context.BaseItems.AsNoTracking().WhereOneOrMany(firstIds, e => e.Id);
itemsQuery = ApplyNavigations(itemsQuery, filter);
return LoadLatestByIds(context, albumIdsQuery, filter);
}
// Keeping idsQuery deferred lets EF emit `WHERE Id IN (<subquery>)`.
private IReadOnlyList<BaseItemDto> LoadLatestByIds(
JellyfinDbContext context,
IQueryable<Guid> idsQuery,
InternalItemsQuery filter)
{
var itemsQuery = ApplyNavigations(
context.BaseItems.AsNoTracking().Where(e => idsQuery.Contains(e.Id)),
filter);
return itemsQuery
.OrderByDescending(e => e.DateCreated)

View File

@@ -390,7 +390,8 @@ public sealed partial class BaseItemRepository
{
if (filter.UseRawName == true)
{
baseQuery = baseQuery.Where(e => e.Name == filter.Name);
var nameLower = filter.Name.ToLowerInvariant();
baseQuery = baseQuery.Where(e => e.Name!.ToLower() == nameLower);
}
else
{

View File

@@ -1,4 +1,6 @@
#pragma warning disable RS0030 // Do not use banned APIs
#pragma warning disable CA1304 // Specify CultureInfo
#pragma warning disable CA1311 // Specify a culture or use an invariant version
using System;
using System.Collections.Generic;
@@ -62,17 +64,19 @@ public class LinkedChildrenService : ILinkedChildrenService
{
using var dbContext = _dbProvider.CreateDbContext();
var lowerNames = artistNames.Select(n => n.ToLowerInvariant()).ToArray();
var artists = dbContext.BaseItems
.AsNoTracking()
.Where(e => e.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist]!)
.Where(e => artistNames.Contains(e.Name))
.Where(e => lowerNames.Contains(e.Name!.ToLower()))
.ToArray();
var lookup = artists
.GroupBy(e => e.Name!)
.GroupBy(e => e.Name!, StringComparer.OrdinalIgnoreCase)
.ToDictionary(
g => g.Key,
g => g.Select(f => _queryHelpers.DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray());
g => g.Select(f => _queryHelpers.DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray(),
StringComparer.OrdinalIgnoreCase);
var result = new Dictionary<string, MusicArtist[]>(artistNames.Count);
foreach (var name in artistNames)

View File

@@ -123,6 +123,7 @@ public class MediaStreamRepository : IMediaStreamRepository
dto.IsDefault = entity.IsDefault;
dto.IsForced = entity.IsForced;
dto.IsExternal = entity.IsExternal;
dto.IsOriginal = entity.IsOriginal;
dto.Height = entity.Height;
dto.Width = entity.Width;
dto.AverageFrameRate = entity.AverageFrameRate;
@@ -164,6 +165,11 @@ public class MediaStreamRepository : IMediaStreamRepository
dto.LocalizedLanguage = culture?.DisplayName;
}
if (dto.Type is MediaStreamType.Audio)
{
dto.LocalizedOriginal = _localization.GetLocalizedString("Original");
}
if (dto.Type is MediaStreamType.Subtitle)
{
dto.LocalizedUndefined = _localization.GetLocalizedString("Undefined");
@@ -198,6 +204,7 @@ public class MediaStreamRepository : IMediaStreamRepository
IsDefault = dto.IsDefault,
IsForced = dto.IsForced,
IsExternal = dto.IsExternal,
IsOriginal = dto.IsOriginal,
Height = dto.Height,
Width = dto.Width,
AverageFrameRate = dto.AverageFrameRate,

View File

@@ -127,15 +127,21 @@ public class NextUpService : INextUpService
.AsNoTracking()
.Where(e => e.Type == episodeTypeName)
.Where(e => e.SeriesPresentationUniqueKey != null && seriesKeys.Contains(e.SeriesPresentationUniqueKey))
.Where(e => e.ParentIndexNumber != 0)
.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
.Where(e => e.ParentIndexNumber != 0);
lastWatchedByDateBase = _queryHelpers.ApplyAccessFiltering(context, lastWatchedByDateBase, filter);
// Use lightweight projection + client-side grouping instead of
// SelectMany+GroupBy+OrderByDescending+FirstOrDefault (correlated subquery).
// Use an explicit Join (INNER JOIN) instead of SelectMany on a collection navigation.
// SelectMany on UserData with a correlated Where would translate to APPLY,
// which SQLite does not support.
var playedWithDates = lastWatchedByDateBase
.SelectMany(e => e.UserData!.Where(ud => ud.UserId == userId && ud.Played)
.Select(ud => new { EpisodeId = e.Id, e.SeriesPresentationUniqueKey, ud.LastPlayedDate }))
.Join(
context.UserData
.AsNoTracking()
.Where(ud => ud.ItemId != EF.Constant(BaseItemRepository.PlaceholderId))
.Where(ud => ud.Played),
e => new { UserId = userId, ItemId = e.Id },
ud => new { ud.UserId, ud.ItemId },
(e, ud) => new { EpisodeId = e.Id, e.SeriesPresentationUniqueKey, ud.LastPlayedDate })
.ToList();
foreach (var group in playedWithDates.GroupBy(x => x.SeriesPresentationUniqueKey))

View File

@@ -48,9 +48,9 @@ public static class OrderMapper
(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.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.Name, _) => e => e.SortName,
(ItemSortBy.CommunityRating, _) => e => e.CommunityRating,
(ItemSortBy.ProductionYear, _) => e => e.ProductionYear,
(ItemSortBy.CriticRating, _) => e => e.CriticRating,

View File

@@ -44,7 +44,16 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
}
else
{
dbQuery = dbQuery.OrderBy(e => e.Name);
// The Peoples table has one row per (Name, PersonType), so the same person can
// appear multiple times (e.g. as Actor and GuestStar). Collapse to one row per
// name so /Persons doesn't return the same BaseItem id repeatedly. Lowercase the
// grouping key so case-only duplicates collapse together.
var representativeIds = dbQuery
.GroupBy(e => e.Name.ToLower())
.Select(g => g.Min(e => e.Id));
dbQuery = context.Peoples.AsNoTracking()
.Where(p => representativeIds.Contains(p.Id))
.OrderBy(e => e.Name);
}
var count = dbQuery.Count();
@@ -94,16 +103,16 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
person.Role = person.Role?.Trim() ?? string.Empty;
}
// multiple metadata providers can provide the _same_ person
people = people.DistinctBy(e => e.Name + "-" + e.Type).ToArray();
var personKeys = people.Select(e => e.Name + "-" + e.Type).ToArray();
// multiple metadata providers can provide the _same_ person; dedupe case-insensitively.
people = people.DistinctBy(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray();
var personKeys = people.Select(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray();
using var context = _dbProvider.CreateDbContext();
using var transaction = context.Database.BeginTransaction();
var existingPersons = context.Peoples.Select(e => new
{
item = e,
SelectionKey = e.Name + "-" + e.PersonType
SelectionKey = e.Name.ToLower() + "-" + e.PersonType
})
.Where(p => personKeys.Contains(p.SelectionKey))
.Select(f => f.item)
@@ -111,7 +120,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
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()))
.Where(e => !existingPersons.Any(f => string.Equals(f.Name, e.Name, StringComparison.OrdinalIgnoreCase) && f.PersonType == e.Type.ToString()))
.Select(Map);
context.Peoples.AddRange(toAdd);
context.SaveChanges();
@@ -129,8 +138,8 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
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);
var entityPerson = personsEntities.First(e => string.Equals(e.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.PersonType == person.Type.ToString());
var existingMap = existingMaps.FirstOrDefault(e => string.Equals(e.People.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.People.PersonType == person.Type.ToString() && e.Role == person.Role);
if (existingMap is null)
{
context.PeopleBaseItemMap.Add(new PeopleBaseItemMap()
@@ -231,7 +240,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
if (queryExcludePersonTypes.Count > 0)
{
query = query.Where(e => !queryPersonTypes.Contains(e.PersonType));
query = query.Where(e => !queryExcludePersonTypes.Contains(e.PersonType));
}
if (filter.MaxListOrder.HasValue && !filter.ItemId.IsEmpty())

View File

@@ -54,7 +54,7 @@ public class MediaSegmentManager : IMediaSegmentManager
public async Task RunSegmentPluginProviders(BaseItem baseItem, LibraryOptions libraryOptions, bool forceOverwrite, CancellationToken cancellationToken)
{
var providers = _segmentProviders
.Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name)))
.Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(e.Name, StringComparer.OrdinalIgnoreCase))
.OrderBy(i =>
{
var index = libraryOptions.MediaSegmentProviderOrder.IndexOf(i.Name);
@@ -224,7 +224,7 @@ public class MediaSegmentManager : IMediaSegmentManager
if (filterByProvider)
{
var providerIds = _segmentProviders
.Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(GetProviderId(e.Name)))
.Where(e => !libraryOptions.DisabledMediaSegmentProviders.Contains(e.Name, StringComparer.OrdinalIgnoreCase))
.Select(f => GetProviderId(f.Name))
.ToArray();
if (providerIds.Length == 0)

View File

@@ -198,13 +198,13 @@ public class TrickplayManager : ITrickplayManager
// Cleanup old trickplay files
if (Directory.Exists(trickplayDirectory))
{
var existingFolders = Directory.GetDirectories(trickplayDirectory).ToList();
var existingFolders = Directory.GetDirectories(trickplayDirectory);
var trickplayInfos = await dbContext.TrickplayInfos
.AsNoTracking()
.Where(i => i.ItemId.Equals(video.Id))
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var expectedFolders = trickplayInfos.Select(i => GetTrickplayDirectory(video, i.TileWidth, i.TileHeight, i.Width, saveWithMedia)).ToList();
var expectedFolders = trickplayInfos.Select(i => GetTrickplayDirectory(video, i.TileWidth, i.TileHeight, i.Width, saveWithMedia));
var foldersToRemove = existingFolders.Except(expectedFolders);
foreach (var folder in foldersToRemove)
{

View File

@@ -74,7 +74,7 @@ namespace Jellyfin.Server.Implementations.Users
var resetUser = userManager.GetUserByName(spr.UserName)
?? throw new ResourceNotFoundException($"User with a username of {spr.UserName} not found");
await userManager.ChangePassword(resetUser, pin).ConfigureAwait(false);
await userManager.ChangePassword(resetUser.Id, pin).ConfigureAwait(false);
usersReset.Add(resetUser.Username);
File.Delete(resetFile);
}

View File

@@ -1,12 +1,14 @@
#pragma warning disable CA1307
#pragma warning disable RS0030 // Do not use banned APIs
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using AsyncKeyedLock;
using Jellyfin.Data;
using Jellyfin.Data.Enums;
using Jellyfin.Data.Events;
@@ -35,7 +37,7 @@ namespace Jellyfin.Server.Implementations.Users
/// <summary>
/// Manages the creation and retrieval of <see cref="User"/> instances.
/// </summary>
public partial class UserManager : IUserManager
public partial class UserManager : IUserManager, IDisposable
{
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly IEventManager _eventManager;
@@ -50,7 +52,7 @@ namespace Jellyfin.Server.Implementations.Users
private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider;
private readonly IServerConfigurationManager _serverConfigurationManager;
private readonly IDictionary<Guid, User> _users;
private readonly AsyncKeyedLocker<Guid> _userLock = new();
/// <summary>
/// Initializes a new instance of the <see cref="UserManager"/> class.
@@ -89,29 +91,28 @@ namespace Jellyfin.Server.Implementations.Users
_invalidAuthProvider = _authenticationProviders.OfType<InvalidAuthProvider>().First();
_defaultAuthenticationProvider = _authenticationProviders.OfType<DefaultAuthenticationProvider>().First();
_defaultPasswordResetProvider = _passwordResetProviders.OfType<DefaultPasswordResetProvider>().First();
_users = new ConcurrentDictionary<Guid, User>();
using var dbContext = _dbProvider.CreateDbContext();
foreach (var user in dbContext.Users
.AsSingleQuery()
.Include(user => user.Permissions)
.Include(user => user.Preferences)
.Include(user => user.AccessSchedules)
.Include(user => user.ProfileImage)
.AsEnumerable())
{
_users.Add(user.Id, user);
}
}
/// <inheritdoc/>
public event EventHandler<GenericEventArgs<User>>? OnUserUpdated;
/// <inheritdoc/>
public IEnumerable<User> Users => _users.Values;
public IEnumerable<User> GetUsers()
{
using var dbContext = _dbProvider.CreateDbContext();
return UserQuery(dbContext)
.ToArray();
}
/// <inheritdoc/>
public IEnumerable<Guid> UsersIds => _users.Keys;
public IEnumerable<Guid> GetUsersIds()
{
using var dbContext = _dbProvider.CreateDbContext();
return dbContext.Users
.AsNoTracking()
.Select(user => user.Id)
.ToArray();
}
// This is some regex that matches only on unicode "word" characters, as well as -, _ and @
// In theory this will cut out most if not all 'control' characters which should help minimize any weirdness
@@ -127,8 +128,27 @@ namespace Jellyfin.Server.Implementations.Users
throw new ArgumentException("Guid can't be empty", nameof(id));
}
_users.TryGetValue(id, out var user);
return user;
using var dbContext = _dbProvider.CreateDbContext();
return UserQuery(dbContext)
.FirstOrDefault(user => user.Id == id);
}
private static IQueryable<User> UserQuery(JellyfinDbContext dbContext)
{
return dbContext.Users
.AsSingleQuery()
.Include(user => user.Permissions)
.Include(user => user.Preferences)
.Include(user => user.AccessSchedules)
.Include(user => user.ProfileImage)
.AsNoTracking();
}
/// <inheritdoc/>
public User? GetFirstUser()
{
using var dbContext = _dbProvider.CreateDbContext();
return UserQuery(dbContext).FirstOrDefault();
}
/// <inheritdoc/>
@@ -139,42 +159,57 @@ namespace Jellyfin.Server.Implementations.Users
throw new ArgumentException("Invalid username", nameof(name));
}
return _users.Values.FirstOrDefault(u => string.Equals(u.Username, name, StringComparison.OrdinalIgnoreCase));
using var dbContext = _dbProvider.CreateDbContext();
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
return UserQuery(dbContext)
.FirstOrDefault(u => u.Username.ToUpper() == name.ToUpper());
#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
}
/// <inheritdoc/>
public async Task RenameUser(User user, string newName)
public async Task RenameUser(Guid userId, string oldName, string newName)
{
ArgumentNullException.ThrowIfNull(user);
ThrowIfInvalidUsername(newName);
if (user.Username.Equals(newName, StringComparison.Ordinal))
if (oldName.Equals(newName, StringComparison.OrdinalIgnoreCase))
{
throw new ArgumentException("The new and old names must be different.");
}
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
User user = null!; // user is never actually null where its used afterwards so we can just ignore.
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
if (await dbContext.Users
.AnyAsync(u => u.Username.ToUpper() == newName.ToUpper() && !u.Id.Equals(user.Id))
.ConfigureAwait(false))
{
throw new ArgumentException(string.Format(
CultureInfo.InvariantCulture,
"A user with the name '{0}' already exists.",
newName));
}
if (await dbContext.Users
.AnyAsync(u => u.Username.ToUpper() == newName.ToUpper() && u.Id != userId)
.ConfigureAwait(false))
{
throw new ArgumentException(string.Format(
CultureInfo.InvariantCulture,
"A user with the name '{0}' already exists.",
newName));
}
#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
user.Username = newName;
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
user = await UserQuery(dbContext)
.AsTracking()
.FirstOrDefaultAsync(u => u.Id == userId)
.ConfigureAwait(false)
?? throw new ResourceNotFoundException(nameof(userId));
user.Username = newName;
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
}
}
var eventArgs = new UserUpdatedEventArgs(user);
@@ -185,10 +220,9 @@ namespace Jellyfin.Server.Implementations.Users
/// <inheritdoc/>
public async Task UpdateUserAsync(User user)
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
using (await _userLock.LockAsync(user.Id).ConfigureAwait(false))
{
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
await UpdateUserInternalAsync(user).ConfigureAwait(false);
}
}
@@ -218,23 +252,30 @@ namespace Jellyfin.Server.Implementations.Users
{
ThrowIfInvalidUsername(name);
if (Users.Any(u => u.Username.Equals(name, StringComparison.OrdinalIgnoreCase)))
{
throw new ArgumentException(string.Format(
CultureInfo.InvariantCulture,
"A user with the name '{0}' already exists.",
name));
}
User newUser;
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
#pragma warning disable CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
#pragma warning disable CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
#pragma warning disable CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
if (await dbContext.Users
.AnyAsync(u => u.Username.ToUpper() == name.ToUpper())
.ConfigureAwait(false))
{
throw new ArgumentException(string.Format(
CultureInfo.InvariantCulture,
"A user with the name '{0}' already exists.",
name));
}
#pragma warning restore CA1304 // The behavior of 'string.ToUpper()' could vary based on the current user's locale settings
#pragma warning restore CA1311 // Specify a culture or use an invariant version to avoid implicit dependency on current culture
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false);
dbContext.Users.Add(newUser);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
_users.Add(newUser.Id, newUser);
}
await _eventManager.PublishAsync(new UserCreatedEventArgs(newUser)).ConfigureAwait(false);
@@ -245,62 +286,82 @@ namespace Jellyfin.Server.Implementations.Users
/// <inheritdoc/>
public async Task DeleteUserAsync(Guid userId)
{
if (!_users.TryGetValue(userId, out var user))
User? user;
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
{
throw new ResourceNotFoundException(nameof(userId));
}
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
user = await dbContext.Users
.Include(u => u.Permissions)
.FirstOrDefaultAsync(u => u.Id.Equals(userId))
.ConfigureAwait(false);
if (user is null)
{
throw new ResourceNotFoundException(nameof(userId));
}
if (_users.Count == 1)
{
throw new InvalidOperationException(string.Format(
CultureInfo.InvariantCulture,
"The user '{0}' cannot be deleted because there must be at least one user in the system.",
user.Username));
}
var userCount = await dbContext.Users.CountAsync().ConfigureAwait(false);
if (userCount == 1)
{
throw new InvalidOperationException(string.Format(
CultureInfo.InvariantCulture,
"The user '{0}' cannot be deleted because there must be at least one user in the system.",
user.Username));
}
if (user.HasPermission(PermissionKind.IsAdministrator)
&& Users.Count(i => i.HasPermission(PermissionKind.IsAdministrator)) == 1)
{
throw new ArgumentException(
string.Format(
CultureInfo.InvariantCulture,
"The user '{0}' cannot be deleted because there must be at least one admin user in the system.",
user.Username),
nameof(userId));
}
if (user.HasPermission(PermissionKind.IsAdministrator)
&& await dbContext.Users
.CountAsync(i => i.Permissions.Any(p => p.Kind == PermissionKind.IsAdministrator && p.Value))
.ConfigureAwait(false) == 1)
{
throw new ArgumentException(
string.Format(
CultureInfo.InvariantCulture,
"The user '{0}' cannot be deleted because there must be at least one admin user in the system.",
user.Username),
nameof(userId));
}
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
dbContext.Users.Attach(user);
dbContext.Users.Remove(user);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
dbContext.Users.Remove(user);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
}
_users.Remove(userId);
await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false);
}
/// <inheritdoc/>
public Task ResetPassword(User user)
public Task ResetPassword(Guid userId)
{
return ChangePassword(user, string.Empty);
return ChangePassword(userId, string.Empty);
}
/// <inheritdoc/>
public async Task ChangePassword(User user, string newPassword)
public async Task ChangePassword(Guid userId, string newPassword)
{
ArgumentNullException.ThrowIfNull(user);
if (user.HasPermission(PermissionKind.IsAdministrator) && string.IsNullOrWhiteSpace(newPassword))
User dbUser = null!;
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
{
throw new ArgumentException("Admin user passwords must not be empty", nameof(newPassword));
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
dbUser = await UserQuery(dbContext)
.AsTracking()
.FirstOrDefaultAsync(u => u.Id == userId)
.ConfigureAwait(false)
?? throw new ResourceNotFoundException(nameof(userId));
if (dbUser.HasPermission(PermissionKind.IsAdministrator) && string.IsNullOrWhiteSpace(newPassword))
{
throw new ArgumentException("Admin user passwords must not be empty", nameof(newPassword));
}
await GetAuthenticationProvider(dbUser).ChangePassword(dbUser, newPassword).ConfigureAwait(false);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
}
await GetAuthenticationProvider(user).ChangePassword(user, newPassword).ConfigureAwait(false);
await UpdateUserAsync(user).ConfigureAwait(false);
await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(user)).ConfigureAwait(false);
await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(dbUser)).ConfigureAwait(false);
}
/// <inheritdoc/>
@@ -400,102 +461,114 @@ namespace Jellyfin.Server.Implementations.Users
throw new ArgumentNullException(nameof(username));
}
var user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase));
var authResult = await AuthenticateLocalUser(username, password, user)
.ConfigureAwait(false);
var authenticationProvider = authResult.AuthenticationProvider;
var success = authResult.Success;
if (user is null)
bool success;
var user = GetUserByName(username);
using (await _userLock.LockAsync(user?.Id ?? Guid.Empty).ConfigureAwait(false))
{
string updatedUsername = authResult.Username;
if (success
&& authenticationProvider is not null
&& authenticationProvider is not DefaultAuthenticationProvider)
// Reload the user now that we hold the lock so the RowVersion is current.
// GetUserByName uses AsNoTracking and the snapshot may be stale if another
// write (e.g. a concurrent login) incremented RowVersion after our initial load.
if (user is not null)
{
// Trust the username returned by the authentication provider
username = updatedUsername;
user = GetUserById(user.Id) ?? user;
}
// Search the database for the user again
// the authentication provider might have created it
user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase));
var authResult = await AuthenticateLocalUser(username, password, user)
.ConfigureAwait(false);
var authenticationProvider = authResult.AuthenticationProvider;
success = authResult.Success;
if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null)
if (user is null)
{
string updatedUsername = authResult.Username;
if (success
&& authenticationProvider is not null
&& authenticationProvider is not DefaultAuthenticationProvider)
{
await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false);
// Trust the username returned by the authentication provider
username = updatedUsername;
// Search the database for the user again
// the authentication provider might have created it
user = GetUserByName(username);
if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null)
{
await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false);
}
}
}
}
if (success && user is not null && authenticationProvider is not null)
{
var providerId = authenticationProvider.GetType().FullName;
if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase))
if (success && user is not null && authenticationProvider is not null)
{
user.AuthenticationProviderId = providerId;
await UpdateUserAsync(user).ConfigureAwait(false);
}
}
var providerId = authenticationProvider.GetType().FullName;
if (user is null)
{
_logger.LogInformation(
"Authentication request for {UserName} has been denied (IP: {IP}).",
username,
remoteEndPoint);
throw new AuthenticationException("Invalid username or password entered.");
}
if (user.HasPermission(PermissionKind.IsDisabled))
{
_logger.LogInformation(
"Authentication request for {UserName} has been denied because this account is currently disabled (IP: {IP}).",
username,
remoteEndPoint);
throw new SecurityException(
$"The {user.Username} account is currently disabled. Please consult with your administrator.");
}
if (!user.HasPermission(PermissionKind.EnableRemoteAccess) &&
!_networkManager.IsInLocalNetwork(remoteEndPoint))
{
_logger.LogInformation(
"Authentication request for {UserName} forbidden: remote access disabled and user not in local network (IP: {IP}).",
username,
remoteEndPoint);
throw new SecurityException("Forbidden.");
}
if (!user.IsParentalScheduleAllowed())
{
_logger.LogInformation(
"Authentication request for {UserName} is not allowed at this time due parental restrictions (IP: {IP}).",
username,
remoteEndPoint);
throw new SecurityException("User is not allowed access at this time.");
}
// Update LastActivityDate and LastLoginDate, then save
if (success)
{
if (isUserSession)
{
user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow;
if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase))
{
user.AuthenticationProviderId = providerId;
await UpdateUserInternalAsync(user).ConfigureAwait(false);
}
}
user.InvalidLoginAttemptCount = 0;
await UpdateUserAsync(user).ConfigureAwait(false);
_logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username);
}
else
{
await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false);
_logger.LogInformation(
"Authentication request for {UserName} has been denied (IP: {IP}).",
user.Username,
remoteEndPoint);
if (user is null)
{
_logger.LogInformation(
"Authentication request for {UserName} has been denied (IP: {IP}).",
username,
remoteEndPoint);
throw new AuthenticationException("Invalid username or password entered.");
}
if (user.HasPermission(PermissionKind.IsDisabled))
{
_logger.LogInformation(
"Authentication request for {UserName} has been denied because this account is currently disabled (IP: {IP}).",
username,
remoteEndPoint);
throw new SecurityException(
$"The {user.Username} account is currently disabled. Please consult with your administrator.");
}
if (!user.HasPermission(PermissionKind.EnableRemoteAccess) &&
!_networkManager.IsInLocalNetwork(remoteEndPoint))
{
_logger.LogInformation(
"Authentication request for {UserName} forbidden: remote access disabled and user not in local network (IP: {IP}).",
username,
remoteEndPoint);
throw new SecurityException("Forbidden.");
}
if (!user.IsParentalScheduleAllowed())
{
_logger.LogInformation(
"Authentication request for {UserName} is not allowed at this time due parental restrictions (IP: {IP}).",
username,
remoteEndPoint);
throw new SecurityException("User is not allowed access at this time.");
}
// Update LastActivityDate and LastLoginDate, then save
if (success)
{
if (isUserSession)
{
user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow;
}
user.InvalidLoginAttemptCount = 0;
await UpdateUserInternalAsync(user).ConfigureAwait(false);
_logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username);
}
else
{
await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false);
_logger.LogInformation(
"Authentication request for {UserName} has been denied (IP: {IP}).",
user.Username,
remoteEndPoint);
}
}
return success ? user : null;
@@ -539,22 +612,22 @@ namespace Jellyfin.Server.Implementations.Users
public async Task InitializeAsync()
{
// TODO: Refactor the startup wizard so that it doesn't require a user to already exist.
if (_users.Any())
{
return;
}
var defaultName = Environment.UserName;
if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName))
{
defaultName = "MyJellyfinUser";
}
_logger.LogWarning("No users, creating one with username {UserName}", defaultName);
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
if (await dbContext.Users.AnyAsync().ConfigureAwait(false))
{
return;
}
var defaultName = Environment.UserName;
if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName))
{
defaultName = "MyJellyfinUser";
}
_logger.LogWarning("No users, creating one with username {UserName}", defaultName);
var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false);
newUser.SetPermission(PermissionKind.IsAdministrator, true);
newUser.SetPermission(PermissionKind.EnableContentDeletion, true);
@@ -562,7 +635,6 @@ namespace Jellyfin.Server.Implementations.Users
dbContext.Users.Add(newUser);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
_users.Add(newUser.Id, newUser);
}
}
@@ -599,124 +671,120 @@ namespace Jellyfin.Server.Implementations.Users
/// <inheritdoc/>
public async Task UpdateConfigurationAsync(Guid userId, UserConfiguration config)
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
{
var user = dbContext.Users
.Include(u => u.Permissions)
.Include(u => u.Preferences)
.Include(u => u.AccessSchedules)
.Include(u => u.ProfileImage)
.AsSingleQuery()
.FirstOrDefault(u => u.Id.Equals(userId))
?? throw new ArgumentException("No user exists with given Id!");
user.SubtitleMode = config.SubtitleMode;
user.HidePlayedInLatest = config.HidePlayedInLatest;
user.EnableLocalPassword = config.EnableLocalPassword;
user.PlayDefaultAudioTrack = config.PlayDefaultAudioTrack;
user.DisplayCollectionsView = config.DisplayCollectionsView;
user.DisplayMissingEpisodes = config.DisplayMissingEpisodes;
user.AudioLanguagePreference = config.AudioLanguagePreference;
user.RememberAudioSelections = config.RememberAudioSelections;
user.EnableNextEpisodeAutoPlay = config.EnableNextEpisodeAutoPlay;
user.RememberSubtitleSelections = config.RememberSubtitleSelections;
user.SubtitleLanguagePreference = config.SubtitleLanguagePreference;
// Only set cast receiver id if it is passed in and it exists in the server config.
if (!string.IsNullOrEmpty(config.CastReceiverId)
&& _serverConfigurationManager.Configuration.CastReceiverApplications.Any(c => string.Equals(c.Id, config.CastReceiverId, StringComparison.Ordinal)))
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
user.CastReceiverId = config.CastReceiverId;
var user = UserQuery(dbContext)
.AsTracking()
.FirstOrDefault(u => u.Id.Equals(userId))
?? throw new ArgumentException("No user exists with given Id!");
user.SubtitleMode = config.SubtitleMode;
user.HidePlayedInLatest = config.HidePlayedInLatest;
user.EnableLocalPassword = config.EnableLocalPassword;
user.PlayDefaultAudioTrack = config.PlayDefaultAudioTrack;
user.DisplayCollectionsView = config.DisplayCollectionsView;
user.DisplayMissingEpisodes = config.DisplayMissingEpisodes;
user.AudioLanguagePreference = config.AudioLanguagePreference;
user.RememberAudioSelections = config.RememberAudioSelections;
user.EnableNextEpisodeAutoPlay = config.EnableNextEpisodeAutoPlay;
user.RememberSubtitleSelections = config.RememberSubtitleSelections;
user.SubtitleLanguagePreference = config.SubtitleLanguagePreference;
// Only set cast receiver id if it is passed in and it exists in the server config.
if (!string.IsNullOrEmpty(config.CastReceiverId)
&& _serverConfigurationManager.Configuration.CastReceiverApplications.Any(c => string.Equals(c.Id, config.CastReceiverId, StringComparison.Ordinal)))
{
user.CastReceiverId = config.CastReceiverId;
}
user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews);
user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders);
user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes);
user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes);
dbContext.Update(user);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews);
user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders);
user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes);
user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes);
dbContext.Update(user);
_users[user.Id] = user;
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
}
/// <inheritdoc/>
public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy)
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
using (await _userLock.LockAsync(userId).ConfigureAwait(false))
{
var user = dbContext.Users
.Include(u => u.Permissions)
.Include(u => u.Preferences)
.Include(u => u.AccessSchedules)
.Include(u => u.ProfileImage)
.AsSingleQuery()
.FirstOrDefault(u => u.Id.Equals(userId))
?? throw new ArgumentException("No user exists with given Id!");
// The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0"
int? maxLoginAttempts = policy.LoginAttemptsBeforeLockout switch
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
-1 => null,
0 => 3,
_ => policy.LoginAttemptsBeforeLockout
};
var user = UserQuery(dbContext)
.AsTracking()
.FirstOrDefault(u => u.Id.Equals(userId))
?? throw new ArgumentException("No user exists with given Id!");
user.MaxParentalRatingScore = policy.MaxParentalRating;
user.MaxParentalRatingSubScore = policy.MaxParentalSubRating;
user.EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess;
user.RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit;
user.AuthenticationProviderId = policy.AuthenticationProviderId;
user.PasswordResetProviderId = policy.PasswordResetProviderId;
user.InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount;
user.LoginAttemptsBeforeLockout = maxLoginAttempts;
user.MaxActiveSessions = policy.MaxActiveSessions;
user.SyncPlayAccess = policy.SyncPlayAccess;
user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator);
user.SetPermission(PermissionKind.IsHidden, policy.IsHidden);
user.SetPermission(PermissionKind.IsDisabled, policy.IsDisabled);
user.SetPermission(PermissionKind.EnableSharedDeviceControl, policy.EnableSharedDeviceControl);
user.SetPermission(PermissionKind.EnableRemoteAccess, policy.EnableRemoteAccess);
user.SetPermission(PermissionKind.EnableLiveTvManagement, policy.EnableLiveTvManagement);
user.SetPermission(PermissionKind.EnableLiveTvAccess, policy.EnableLiveTvAccess);
user.SetPermission(PermissionKind.EnableMediaPlayback, policy.EnableMediaPlayback);
user.SetPermission(PermissionKind.EnableAudioPlaybackTranscoding, policy.EnableAudioPlaybackTranscoding);
user.SetPermission(PermissionKind.EnableVideoPlaybackTranscoding, policy.EnableVideoPlaybackTranscoding);
user.SetPermission(PermissionKind.EnableContentDeletion, policy.EnableContentDeletion);
user.SetPermission(PermissionKind.EnableContentDownloading, policy.EnableContentDownloading);
user.SetPermission(PermissionKind.EnableSyncTranscoding, policy.EnableSyncTranscoding);
user.SetPermission(PermissionKind.EnableMediaConversion, policy.EnableMediaConversion);
user.SetPermission(PermissionKind.EnableAllChannels, policy.EnableAllChannels);
user.SetPermission(PermissionKind.EnableAllDevices, policy.EnableAllDevices);
user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders);
user.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, policy.EnableRemoteControlOfOtherUsers);
user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing);
user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement);
user.SetPermission(PermissionKind.EnableSubtitleManagement, policy.EnableSubtitleManagement);
user.SetPermission(PermissionKind.EnableLyricManagement, policy.EnableLyricManagement);
user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding);
user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing);
// The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0"
int? maxLoginAttempts = policy.LoginAttemptsBeforeLockout switch
{
-1 => null,
0 => 3,
_ => policy.LoginAttemptsBeforeLockout
};
user.AccessSchedules.Clear();
foreach (var policyAccessSchedule in policy.AccessSchedules)
{
user.AccessSchedules.Add(policyAccessSchedule);
user.MaxParentalRatingScore = policy.MaxParentalRating;
user.MaxParentalRatingSubScore = policy.MaxParentalSubRating;
user.EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess;
user.RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit;
user.AuthenticationProviderId = policy.AuthenticationProviderId;
user.PasswordResetProviderId = policy.PasswordResetProviderId;
user.InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount;
user.LoginAttemptsBeforeLockout = maxLoginAttempts;
user.MaxActiveSessions = policy.MaxActiveSessions;
user.SyncPlayAccess = policy.SyncPlayAccess;
user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator);
user.SetPermission(PermissionKind.IsHidden, policy.IsHidden);
user.SetPermission(PermissionKind.IsDisabled, policy.IsDisabled);
user.SetPermission(PermissionKind.EnableSharedDeviceControl, policy.EnableSharedDeviceControl);
user.SetPermission(PermissionKind.EnableRemoteAccess, policy.EnableRemoteAccess);
user.SetPermission(PermissionKind.EnableLiveTvManagement, policy.EnableLiveTvManagement);
user.SetPermission(PermissionKind.EnableLiveTvAccess, policy.EnableLiveTvAccess);
user.SetPermission(PermissionKind.EnableMediaPlayback, policy.EnableMediaPlayback);
user.SetPermission(PermissionKind.EnableAudioPlaybackTranscoding, policy.EnableAudioPlaybackTranscoding);
user.SetPermission(PermissionKind.EnableVideoPlaybackTranscoding, policy.EnableVideoPlaybackTranscoding);
user.SetPermission(PermissionKind.EnableContentDeletion, policy.EnableContentDeletion);
user.SetPermission(PermissionKind.EnableContentDownloading, policy.EnableContentDownloading);
user.SetPermission(PermissionKind.EnableSyncTranscoding, policy.EnableSyncTranscoding);
user.SetPermission(PermissionKind.EnableMediaConversion, policy.EnableMediaConversion);
user.SetPermission(PermissionKind.EnableAllChannels, policy.EnableAllChannels);
user.SetPermission(PermissionKind.EnableAllDevices, policy.EnableAllDevices);
user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders);
user.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, policy.EnableRemoteControlOfOtherUsers);
user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing);
user.SetPermission(PermissionKind.EnableCollectionManagement, policy.EnableCollectionManagement);
user.SetPermission(PermissionKind.EnableSubtitleManagement, policy.EnableSubtitleManagement);
user.SetPermission(PermissionKind.EnableLyricManagement, policy.EnableLyricManagement);
user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding);
user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing);
user.AccessSchedules.Clear();
foreach (var policyAccessSchedule in policy.AccessSchedules)
{
user.AccessSchedules.Add(policyAccessSchedule);
}
// TODO: fix this at some point
user.SetPreference(PreferenceKind.BlockUnratedItems, policy.BlockUnratedItems ?? Array.Empty<UnratedItem>());
user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags);
user.SetPreference(PreferenceKind.AllowedTags, policy.AllowedTags);
user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels);
user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices);
user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders);
user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
dbContext.Update(user);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
// TODO: fix this at some point
user.SetPreference(PreferenceKind.BlockUnratedItems, policy.BlockUnratedItems ?? Array.Empty<UnratedItem>());
user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags);
user.SetPreference(PreferenceKind.AllowedTags, policy.AllowedTags);
user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels);
user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices);
user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders);
user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders);
dbContext.Update(user);
_users[user.Id] = user;
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
}
@@ -728,15 +796,17 @@ namespace Jellyfin.Server.Implementations.Users
return;
}
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
using (await _userLock.LockAsync(user.Id).ConfigureAwait(false))
{
dbContext.Remove(user.ProfileImage);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
dbContext.Remove(user.ProfileImage);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
user.ProfileImage = null;
_users[user.Id] = user;
user.ProfileImage = null;
}
}
internal static void ThrowIfInvalidUsername(string name)
@@ -882,15 +952,42 @@ namespace Jellyfin.Server.Implementations.Users
user.InvalidLoginAttemptCount);
}
await UpdateUserAsync(user).ConfigureAwait(false);
await UpdateUserInternalAsync(user).ConfigureAwait(false);
}
private async Task UpdateUserInternalAsync(User user)
{
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false);
}
}
private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user)
{
dbContext.Users.Attach(user);
dbContext.Entry(user).State = EntityState.Modified;
_users[user.Id] = user;
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
/// <inheritdoc/>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Disposes all members of this class.
/// </summary>
/// <param name="disposing">Defines if the class has been cleaned up by a dispose or finalizer.</param>
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
_userLock.Dispose();
}
}
}
}

View File

@@ -0,0 +1,24 @@
using MediaBrowser.Model.Configuration;
namespace Jellyfin.Server.Configuration;
/// <summary>
/// Defines types for usage with the <see cref="StartupOptions.StartupMode"/>.
/// </summary>
public enum StartupMode
{
/// <summary>
/// Default startup mode, runs the jellyfin server in normal operation.
/// </summary>
MediaServer = 0,
/// <summary>
/// Attempts to Migrate the system only then shuts down.
/// </summary>
MigrateSystem = 1,
/// <summary>
/// Runs the Database seed function regardless of <see cref="BaseApplicationConfiguration.IsStartupWizardCompleted"/> state.
/// </summary>
SeedSystem = 2
}

View File

@@ -90,7 +90,7 @@ internal class JellyfinMigrationService
private HashSet<MigrationStage> Migrations { get; set; }
public async Task CheckFirstTimeRunOrMigration(IApplicationPaths appPaths)
public async Task CheckFirstTimeRunOrMigration(IApplicationPaths appPaths, StartupOptions startupOptions)
{
var logger = _startupLogger.With(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migration Startup");
logger.LogInformation("Initialise Migration service.");
@@ -98,9 +98,9 @@ internal class JellyfinMigrationService
var serverConfig = File.Exists(appPaths.SystemConfigurationFilePath)
? (ServerConfiguration)xmlSerializer.DeserializeFromFile(typeof(ServerConfiguration), appPaths.SystemConfigurationFilePath)!
: new ServerConfiguration();
if (!serverConfig.IsStartupWizardCompleted)
if (!serverConfig.IsStartupWizardCompleted || startupOptions.StartupMode is Configuration.StartupMode.SeedSystem)
{
logger.LogInformation("System initialisation detected. Seed data.");
logger.LogInformation("System initialization detected. Seed data. Startup mode is: {StartupMode}", startupOptions.StartupMode ?? Configuration.StartupMode.MediaServer);
var flatApplyMigrations = Migrations.SelectMany(e => e.Where(f => !f.Metadata.RunMigrationOnSetup)).ToArray();
var dbContext = await _dbContextFactory.CreateDbContextAsync().ConfigureAwait(false);

View File

@@ -1,32 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Configuration;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Migration to disable legacy authorization in the system config.
/// </summary>
[JellyfinMigration("2025-11-18T16:00:00", nameof(DisableLegacyAuthorization))]
public class DisableLegacyAuthorization : IAsyncMigrationRoutine
{
private readonly IServerConfigurationManager _serverConfigurationManager;
/// <summary>
/// Initializes a new instance of the <see cref="DisableLegacyAuthorization"/> class.
/// </summary>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
public DisableLegacyAuthorization(IServerConfigurationManager serverConfigurationManager)
{
_serverConfigurationManager = serverConfigurationManager;
}
/// <inheritdoc />
public Task PerformAsync(CancellationToken cancellationToken)
{
_serverConfigurationManager.Configuration.EnableLegacyAuthorization = false;
_serverConfigurationManager.SaveConfiguration();
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,204 @@
#pragma warning disable RS0030 // Do not use banned APIs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Merges MusicArtist records that differ only by Name casing. Prior to the case-insensitive
/// dedup lookup added alongside this migration, the artist validator would create a second
/// MusicArtist whenever a track tagged the artist with a different casing than the
/// resolver-created one (e.g. "Thirty Seconds To Mars" vs. "Thirty Seconds to Mars").
/// </summary>
[JellyfinMigration("2026-05-08T12:00:00", nameof(MergeDuplicateMusicArtists))]
[JellyfinMigrationBackup(JellyfinDb = true)]
public class MergeDuplicateMusicArtists : IAsyncMigrationRoutine
{
private const string MusicArtistType = "MediaBrowser.Controller.Entities.Audio.MusicArtist";
private readonly IStartupLogger<MergeDuplicateMusicArtists> _logger;
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
private readonly ILibraryManager _libraryManager;
private readonly IItemPersistenceService _persistenceService;
/// <summary>
/// Initializes a new instance of the <see cref="MergeDuplicateMusicArtists"/> class.
/// </summary>
/// <param name="logger">The startup logger.</param>
/// <param name="dbContextFactory">The database context factory.</param>
/// <param name="libraryManager">The library manager.</param>
/// <param name="persistenceService">The item persistence service.</param>
public MergeDuplicateMusicArtists(
IStartupLogger<MergeDuplicateMusicArtists> logger,
IDbContextFactory<JellyfinDbContext> dbContextFactory,
ILibraryManager libraryManager,
IItemPersistenceService persistenceService)
{
_logger = logger;
_dbContextFactory = dbContextFactory;
_libraryManager = libraryManager;
_persistenceService = persistenceService;
}
/// <inheritdoc/>
public async Task PerformAsync(CancellationToken cancellationToken)
{
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
var artists = await context.BaseItems
.Where(b => b.Type == MusicArtistType && b.Name != null)
.Select(b => new { b.Id, b.Name, b.DateCreated })
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var groups = artists
.GroupBy(a => a.Name!.ToLowerInvariant())
.Where(g => g.Count() > 1)
.ToList();
if (groups.Count == 0)
{
_logger.LogInformation("No case-only duplicate MusicArtist records found.");
return;
}
_logger.LogInformation("Found {Count} groups of case-only duplicate MusicArtist records.", groups.Count);
var idsToDelete = new List<Guid>();
foreach (var group in groups)
{
cancellationToken.ThrowIfCancellationRequested();
var groupIds = group.Select(g => g.Id).ToArray();
// Pick the keeper: the artist with the most child references is the "real" one
// (the resolver-created artist with a filesystem path); the duplicates are usually
// empty stubs created by the validator's case-sensitive miss.
var stats = await context.BaseItems
.Where(b => groupIds.Contains(b.Id))
.Select(b => new
{
b.Id,
b.Name,
b.DateCreated,
ChildCount = context.BaseItems.Count(c => c.ParentId == b.Id),
AncestorCount = context.AncestorIds.Count(a => a.ParentItemId == b.Id),
LinkedCount = context.LinkedChildren.Count(l => l.ParentId == b.Id || l.ChildId == b.Id),
})
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var keeper = stats
.OrderByDescending(s => s.ChildCount)
.ThenByDescending(s => s.AncestorCount)
.ThenByDescending(s => s.LinkedCount)
.ThenBy(s => s.DateCreated)
.First();
foreach (var dup in stats.Where(s => s.Id != keeper.Id))
{
var keeperId = keeper.Id;
var dupId = dup.Id;
await context.BaseItems
.Where(b => b.ParentId == dupId)
.ExecuteUpdateAsync(s => s.SetProperty(b => b.ParentId, keeperId), cancellationToken)
.ConfigureAwait(false);
await context.BaseItems
.Where(b => b.OwnerId == dupId)
.ExecuteUpdateAsync(s => s.SetProperty(b => b.OwnerId, keeperId), cancellationToken)
.ConfigureAwait(false);
// AncestorIds PK is (ItemId, ParentItemId); drop rows that would collide before redirecting.
await context.AncestorIds
.Where(a => a.ParentItemId == dupId
&& context.AncestorIds.Any(k => k.ParentItemId == keeperId && k.ItemId == a.ItemId))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
await context.AncestorIds
.Where(a => a.ParentItemId == dupId)
.ExecuteUpdateAsync(s => s.SetProperty(a => a.ParentItemId, keeperId), cancellationToken)
.ConfigureAwait(false);
// LinkedChildren PK is (ParentId, ChildId); drop colliding rows in both directions.
await context.LinkedChildren
.Where(l => l.ParentId == dupId
&& context.LinkedChildren.Any(k => k.ParentId == keeperId && k.ChildId == l.ChildId))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
await context.LinkedChildren
.Where(l => l.ParentId == dupId)
.ExecuteUpdateAsync(s => s.SetProperty(l => l.ParentId, keeperId), cancellationToken)
.ConfigureAwait(false);
await context.LinkedChildren
.Where(l => l.ChildId == dupId
&& context.LinkedChildren.Any(k => k.ChildId == keeperId && k.ParentId == l.ParentId))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
await context.LinkedChildren
.Where(l => l.ChildId == dupId)
.ExecuteUpdateAsync(s => s.SetProperty(l => l.ChildId, keeperId), cancellationToken)
.ConfigureAwait(false);
// UserData has UNIQUE(UserId, CustomDataKey); keep the dup's row only when the
// keeper has no equivalent row, otherwise the keeper's value wins.
await context.UserData
.Where(u => u.ItemId == dupId
&& context.UserData.Any(k => k.ItemId == keeperId && k.UserId == u.UserId && k.CustomDataKey == u.CustomDataKey))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
await context.UserData
.Where(u => u.ItemId == dupId)
.ExecuteUpdateAsync(s => s.SetProperty(u => u.ItemId, keeperId), cancellationToken)
.ConfigureAwait(false);
idsToDelete.Add(dupId);
}
_logger.LogDebug(
"Merged duplicates for '{Name}' into {KeeperId} ({Removed} removed).",
keeper.Name,
keeper.Id,
stats.Count - 1);
}
if (idsToDelete.Count == 0)
{
return;
}
// Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the
// %MetadataPath%/artists/<Name> directories that the duplicate stubs left behind.
// Fall back to the persistence service for any items the LibraryManager can't resolve.
var itemsToDelete = idsToDelete
.Select(id => _libraryManager.GetItemById(id))
.Where(item => item is not null)
.ToList();
if (itemsToDelete.Count > 0)
{
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
}
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
var unresolvedIds = idsToDelete.Where(id => !deletedIds.Contains(id)).ToList();
if (unresolvedIds.Count > 0)
{
_persistenceService.DeleteItem(unresolvedIds);
}
_logger.LogInformation("Removed {Count} duplicate MusicArtist records.", idsToDelete.Count);
}
}
}

View File

@@ -0,0 +1,294 @@
#pragma warning disable RS0030 // Do not use banned APIs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Persistence;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Merges case-only duplicate people. Two passes:
/// 1) Person BaseItems whose Name differs only by casing — Person.GetPath hashes the name
/// verbatim, so two casings produce two distinct Person rows in BaseItems.
/// 2) Peoples lookup rows whose Name differs only by casing within the same PersonType —
/// UpdatePeople used to insert a second Peoples row when a metadata provider returned
/// a different casing than the row already in the table.
/// Both bugs cause the /Persons endpoint to list the same person twice.
/// </summary>
[JellyfinMigration("2026-05-08T13:00:00", nameof(MergeDuplicatePeople))]
[JellyfinMigrationBackup(JellyfinDb = true)]
public class MergeDuplicatePeople : IAsyncMigrationRoutine
{
private const string PersonType = "MediaBrowser.Controller.Entities.Person";
private readonly IStartupLogger<MergeDuplicatePeople> _logger;
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
private readonly ILibraryManager _libraryManager;
private readonly IItemPersistenceService _persistenceService;
/// <summary>
/// Initializes a new instance of the <see cref="MergeDuplicatePeople"/> class.
/// </summary>
/// <param name="logger">The startup logger.</param>
/// <param name="dbContextFactory">The database context factory.</param>
/// <param name="libraryManager">The library manager.</param>
/// <param name="persistenceService">The item persistence service.</param>
public MergeDuplicatePeople(
IStartupLogger<MergeDuplicatePeople> logger,
IDbContextFactory<JellyfinDbContext> dbContextFactory,
ILibraryManager libraryManager,
IItemPersistenceService persistenceService)
{
_logger = logger;
_dbContextFactory = dbContextFactory;
_libraryManager = libraryManager;
_persistenceService = persistenceService;
}
/// <inheritdoc/>
public async Task PerformAsync(CancellationToken cancellationToken)
{
var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
await MergePersonBaseItemsAsync(context, cancellationToken).ConfigureAwait(false);
await MergePeoplesRowsAsync(context, cancellationToken).ConfigureAwait(false);
}
}
private async Task MergePersonBaseItemsAsync(JellyfinDbContext context, CancellationToken cancellationToken)
{
var persons = await context.BaseItems
.Where(b => b.Type == PersonType && b.Name != null)
.Select(b => new { b.Id, b.Name, b.DateCreated })
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var groups = persons
.GroupBy(p => p.Name!.ToLowerInvariant())
.Where(g => g.Count() > 1)
.ToList();
if (groups.Count == 0)
{
_logger.LogInformation("No case-only duplicate Person BaseItems found.");
return;
}
_logger.LogInformation("Found {Count} groups of case-only duplicate Person BaseItems.", groups.Count);
var idsToDelete = new List<Guid>();
foreach (var group in groups)
{
cancellationToken.ThrowIfCancellationRequested();
var groupIds = group.Select(g => g.Id).ToArray();
// Pick the keeper: the Person with the most UserData rows (favorites, image
// refresh state) is the one users have actually interacted with.
var stats = await context.BaseItems
.Where(b => groupIds.Contains(b.Id))
.Select(b => new
{
b.Id,
b.Name,
b.DateCreated,
UserDataCount = context.UserData.Count(u => u.ItemId == b.Id),
LinkedCount = context.LinkedChildren.Count(l => l.ParentId == b.Id || l.ChildId == b.Id),
})
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var keeper = stats
.OrderByDescending(s => s.UserDataCount)
.ThenByDescending(s => s.LinkedCount)
.ThenBy(s => s.DateCreated)
.First();
foreach (var dup in stats.Where(s => s.Id != keeper.Id))
{
var keeperId = keeper.Id;
var dupId = dup.Id;
await context.BaseItems
.Where(b => b.ParentId == dupId)
.ExecuteUpdateAsync(s => s.SetProperty(b => b.ParentId, keeperId), cancellationToken)
.ConfigureAwait(false);
await context.BaseItems
.Where(b => b.OwnerId == dupId)
.ExecuteUpdateAsync(s => s.SetProperty(b => b.OwnerId, keeperId), cancellationToken)
.ConfigureAwait(false);
await context.AncestorIds
.Where(a => a.ParentItemId == dupId
&& context.AncestorIds.Any(k => k.ParentItemId == keeperId && k.ItemId == a.ItemId))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
await context.AncestorIds
.Where(a => a.ParentItemId == dupId)
.ExecuteUpdateAsync(s => s.SetProperty(a => a.ParentItemId, keeperId), cancellationToken)
.ConfigureAwait(false);
await context.LinkedChildren
.Where(l => l.ParentId == dupId
&& context.LinkedChildren.Any(k => k.ParentId == keeperId && k.ChildId == l.ChildId))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
await context.LinkedChildren
.Where(l => l.ParentId == dupId)
.ExecuteUpdateAsync(s => s.SetProperty(l => l.ParentId, keeperId), cancellationToken)
.ConfigureAwait(false);
await context.LinkedChildren
.Where(l => l.ChildId == dupId
&& context.LinkedChildren.Any(k => k.ChildId == keeperId && k.ParentId == l.ParentId))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
await context.LinkedChildren
.Where(l => l.ChildId == dupId)
.ExecuteUpdateAsync(s => s.SetProperty(l => l.ChildId, keeperId), cancellationToken)
.ConfigureAwait(false);
await context.UserData
.Where(u => u.ItemId == dupId
&& context.UserData.Any(k => k.ItemId == keeperId && k.UserId == u.UserId && k.CustomDataKey == u.CustomDataKey))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
await context.UserData
.Where(u => u.ItemId == dupId)
.ExecuteUpdateAsync(s => s.SetProperty(u => u.ItemId, keeperId), cancellationToken)
.ConfigureAwait(false);
idsToDelete.Add(dupId);
}
_logger.LogDebug(
"Merged Person BaseItems for '{Name}' into {KeeperId} ({Removed} removed).",
keeper.Name,
keeper.Id,
stats.Count - 1);
}
if (idsToDelete.Count == 0)
{
return;
}
// Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the
// %MetadataPath%/People/<Letter>/<Name> directories the duplicate stubs left behind.
var itemsToDelete = idsToDelete
.Select(id => _libraryManager.GetItemById(id))
.Where(item => item is not null)
.ToList();
if (itemsToDelete.Count > 0)
{
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
}
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
var unresolvedIds = idsToDelete.Where(id => !deletedIds.Contains(id)).ToList();
if (unresolvedIds.Count > 0)
{
_persistenceService.DeleteItem(unresolvedIds);
}
_logger.LogInformation("Removed {Count} duplicate Person BaseItems.", idsToDelete.Count);
}
private async Task MergePeoplesRowsAsync(JellyfinDbContext context, CancellationToken cancellationToken)
{
var people = await context.Peoples
.Select(p => new { p.Id, p.Name, p.PersonType })
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var groups = people
.GroupBy(p => (Name: p.Name.ToLowerInvariant(), p.PersonType))
.Where(g => g.Count() > 1)
.ToList();
if (groups.Count == 0)
{
_logger.LogInformation("No case-only duplicate Peoples rows found.");
return;
}
_logger.LogInformation("Found {Count} groups of case-only duplicate Peoples rows.", groups.Count);
var idsToDelete = new List<Guid>();
foreach (var group in groups)
{
cancellationToken.ThrowIfCancellationRequested();
var groupIds = group.Select(g => g.Id).ToArray();
// Pick the keeper: the row referenced by the most BaseItems is the one most
// tracks/movies already point at; the duplicates are usually orphan stubs left
// by a casing-mismatched insert.
var stats = await context.Peoples
.Where(p => groupIds.Contains(p.Id))
.Select(p => new
{
p.Id,
p.Name,
MapCount = context.PeopleBaseItemMap.Count(m => m.PeopleId == p.Id),
})
.ToListAsync(cancellationToken)
.ConfigureAwait(false);
var keeper = stats
.OrderByDescending(s => s.MapCount)
.ThenBy(s => s.Id)
.First();
foreach (var dup in stats.Where(s => s.Id != keeper.Id))
{
var keeperId = keeper.Id;
var dupId = dup.Id;
// PeopleBaseItemMap PK is (ItemId, PeopleId, Role); drop dup rows that would
// collide on (ItemId, Role) before redirecting PeopleId. Role is nullable, so
// match nulls explicitly.
await context.PeopleBaseItemMap
.Where(m => m.PeopleId == dupId
&& context.PeopleBaseItemMap.Any(k => k.PeopleId == keeperId
&& k.ItemId == m.ItemId
&& (k.Role == m.Role || (k.Role == null && m.Role == null))))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
await context.PeopleBaseItemMap
.Where(m => m.PeopleId == dupId)
.ExecuteUpdateAsync(s => s.SetProperty(m => m.PeopleId, keeperId), cancellationToken)
.ConfigureAwait(false);
idsToDelete.Add(dupId);
}
_logger.LogDebug(
"Merged Peoples rows for '{Name}' into {KeeperId} ({Removed} removed).",
keeper.Name,
keeper.Id,
stats.Count - 1);
}
if (idsToDelete.Count == 0)
{
return;
}
await context.Peoples
.Where(p => idsToDelete.Contains(p.Id))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation("Removed {Count} duplicate Peoples rows.", idsToDelete.Count);
}
}

View File

@@ -7,6 +7,7 @@ using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -283,9 +284,9 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
.Select(id => _libraryManager.GetItemById(id))
.Where(item => item is not null)
.ToList();
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
var deleted = DeleteItems(itemsToDelete!);
_logger.LogInformation("Removed {Count} wrong-type alternate version items. They will be recreated with the correct type on next library scan.", itemsToDelete.Count);
_logger.LogInformation("Removed {Count} wrong-type alternate version items. They will be recreated with the correct type on next library scan.", deleted);
}
private void CleanupOrphanedAlternateVersionBaseItems(JellyfinDbContext context)
@@ -314,9 +315,9 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
.Select(id => _libraryManager.GetItemById(id))
.Where(item => item is not null)
.ToList();
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
var deleted = DeleteItems(itemsToDelete!);
_logger.LogInformation("Removed {Count} orphaned alternate version BaseItems.", itemsToDelete.Count);
_logger.LogInformation("Removed {Count} orphaned alternate version BaseItems.", deleted);
}
private void CleanupItemsFromDeletedLibraries(JellyfinDbContext context)
@@ -343,9 +344,9 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
.Select(id => _libraryManager.GetItemById(id))
.Where(item => item is not null)
.ToList();
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
var deleted = DeleteItems(itemsToDelete!);
_logger.LogInformation("Removed {Count} items from deleted libraries.", itemsToDelete.Count);
_logger.LogInformation("Removed {Count} items from deleted libraries.", deleted);
}
private void CleanupStaleFileEntries(JellyfinDbContext context)
@@ -431,9 +432,34 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
.Select(id => _libraryManager.GetItemById(id))
.Where(item => item is not null)
.ToList();
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
var deleted = DeleteItems(itemsToDelete!);
_logger.LogInformation("Removed {Count} stale items.", itemsToDelete.Count);
_logger.LogInformation("Removed {Count} stale items.", deleted);
}
private int DeleteItems(IReadOnlyCollection<BaseItem> items)
{
if (items.Count == 0)
{
return 0;
}
var options = new DeleteOptions { DeleteFileLocation = false, DeleteFromExternalProvider = false };
var deleted = 0;
foreach (var item in items)
{
try
{
_libraryManager.DeleteItem(item, options);
deleted++;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Skipping item {ItemId} ({ItemName}): delete failed.", item.Id, item.Name ?? "Unknown");
}
}
return deleted;
}
private void CleanupOrphanedLinkedChildren(JellyfinDbContext context)

View File

@@ -1,4 +1,3 @@
using System;
using System.Linq;
using Jellyfin.Database.Implementations;
using Jellyfin.Server.ServerSetupApp;
@@ -12,7 +11,7 @@ namespace Jellyfin.Server.Migrations.Routines;
/// Migrate rating levels.
/// </summary>
#pragma warning disable CS0618 // Type or member is obsolete
[JellyfinMigration("2025-04-20T22:00:00", nameof(MigrateRatingLevels))]
[JellyfinMigration("2026-03-02T09:00:00", nameof(MigrateRatingLevels))]
[JellyfinMigrationBackup(JellyfinDb = true)]
#pragma warning restore CS0618 // Type or member is obsolete
internal class MigrateRatingLevels : IDatabaseMigrationRoutine

View File

@@ -144,6 +144,11 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine
}
var newSubtitleCachePath = _pathManager.GetSubtitlePath(itemIdString, mediaStreamIndex, extension);
if (newSubtitleCachePath is null)
{
continue;
}
if (File.Exists(newSubtitleCachePath))
{
File.Delete(oldSubtitleCachePath);
@@ -182,6 +187,11 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine
}
var newAttachmentPath = _pathManager.GetAttachmentPath(itemIdString, attachment.Filename ?? attachmentIndex.ToString(CultureInfo.InvariantCulture));
if (newAttachmentPath is null)
{
continue;
}
if (File.Exists(newAttachmentPath))
{
File.Delete(oldAttachmentPath);

View File

@@ -137,7 +137,7 @@ namespace Jellyfin.Server
StartupHelpers.PerformStaticInitialization();
await ApplyStartupMigrationAsync(appPaths, startupConfig).ConfigureAwait(false);
await ApplyStartupMigrationAsync(appPaths, startupConfig, options).ConfigureAwait(false);
do
{
@@ -214,13 +214,17 @@ namespace Jellyfin.Server
{
configurationCompleted = true;
await _setupServer!.StopAsync().ConfigureAwait(false);
await _jellyfinHost.StartAsync().ConfigureAwait(false);
if (!OperatingSystem.IsWindows() && startupConfig.UseUnixSocket())
if (options.StartupMode is null or Configuration.StartupMode.MediaServer)
{
var socketPath = StartupHelpers.GetUnixSocketPath(startupConfig, appPaths);
await _jellyfinHost.StartAsync().ConfigureAwait(false);
StartupHelpers.SetUnixSocketPermissions(startupConfig, socketPath, _logger);
if (!OperatingSystem.IsWindows() && startupConfig.UseUnixSocket())
{
var socketPath = StartupHelpers.GetUnixSocketPath(startupConfig, appPaths);
StartupHelpers.SetUnixSocketPermissions(startupConfig, socketPath, _logger);
}
}
}
catch (Exception)
@@ -229,11 +233,14 @@ namespace Jellyfin.Server
throw;
}
await appHost.RunStartupTasksAsync().ConfigureAwait(false);
if (options.StartupMode is null or Configuration.StartupMode.MediaServer)
{
await appHost.RunStartupTasksAsync().ConfigureAwait(false);
_logger.LogInformation("Startup complete {Time:g}", Stopwatch.GetElapsedTime(_startTimestamp));
_logger.LogInformation("Startup complete {Time:g}", Stopwatch.GetElapsedTime(_startTimestamp));
await _jellyfinHost.WaitForShutdownAsync().ConfigureAwait(false);
}
await _jellyfinHost.WaitForShutdownAsync().ConfigureAwait(false);
_restartOnShutdown = appHost.ShouldRestart;
_restoreFromBackup = appHost.RestoreBackupPath;
}
@@ -244,7 +251,11 @@ namespace Jellyfin.Server
if (_setupServer!.IsAlive && !configurationCompleted)
{
_setupServer!.SoftStop();
await Task.Delay(TimeSpan.FromMinutes(10)).ConfigureAwait(false);
if (options.StartupMode is null or Configuration.StartupMode.MediaServer)
{
await Task.Delay(TimeSpan.FromMinutes(10)).ConfigureAwait(false);
}
await _setupServer!.StopAsync().ConfigureAwait(false);
}
}
@@ -275,8 +286,9 @@ namespace Jellyfin.Server
/// </remarks>
/// <param name="appPaths">Application Paths.</param>
/// <param name="startupConfig">Startup Config.</param>
/// <param name="startupOptions">The applications startup options.</param>
/// <returns>A task.</returns>
public static async Task ApplyStartupMigrationAsync(ServerApplicationPaths appPaths, IConfiguration startupConfig)
public static async Task ApplyStartupMigrationAsync(ServerApplicationPaths appPaths, IConfiguration startupConfig, StartupOptions startupOptions)
{
_migrationLogger = StartupLogger.Logger.BeginGroup<JellyfinMigrationService>($"Migration Service");
var startupConfigurationManager = new ServerConfigurationManager(appPaths, _loggerFactory, new MyXmlSerializer());
@@ -294,7 +306,7 @@ namespace Jellyfin.Server
PrepareDatabaseProvider(startupService);
var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(startupService);
await jellyfinMigrationService.CheckFirstTimeRunOrMigration(appPaths).ConfigureAwait(false);
await jellyfinMigrationService.CheckFirstTimeRunOrMigration(appPaths, startupOptions).ConfigureAwait(false);
await jellyfinMigrationService.MigrateStepAsync(Migrations.Stages.JellyfinMigrationStageTypes.PreInitialisation, startupService).ConfigureAwait(false);
}

View File

@@ -1,6 +1,7 @@
using System.Collections.Generic;
using CommandLine;
using Emby.Server.Implementations;
using Jellyfin.Server.Configuration;
using static MediaBrowser.Controller.Extensions.ConfigurationExtensions;
namespace Jellyfin.Server
@@ -79,6 +80,13 @@ namespace Jellyfin.Server
[Option("restore-archive", Required = false, HelpText = "Path to a Jellyfin backup archive to restore from")]
public string? RestoreArchive { get; set; }
/// <summary>
/// Gets or sets the mode of operation the server should perform when started.
/// Defaults to: <see cref="StartupMode.MediaServer"/>.
/// </summary>
[Option("mode", Required = false, HelpText = "Mode which selects what action the jellyfin server should perform when started.")]
public StartupMode? StartupMode { get; set; }
/// <summary>
/// Gets the command line options as a dictionary that can be used in the .NET configuration system.
/// </summary>

View File

@@ -8,7 +8,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Common</PackageId>
<VersionPrefix>10.12.0</VersionPrefix>
<VersionPrefix>12.0.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>

View File

@@ -7,6 +7,7 @@ using System.Net.Sockets;
using System.Text.RegularExpressions;
using Jellyfin.Extensions;
using MediaBrowser.Model.Net;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Common.Net;
@@ -166,8 +167,9 @@ public static partial class NetworkUtils
/// <param name="values">Input string array to be parsed.</param>
/// <param name="result">Collection of <see cref="IPNetwork"/>.</param>
/// <param name="negated">Boolean signaling if negated or not negated values should be parsed.</param>
/// <param name="logger">Optional logger used to warn about entries that fail to parse.</param>
/// <returns><c>True</c> if parsing was successful.</returns>
public static bool TryParseToSubnets(string[] values, [NotNullWhen(true)] out IReadOnlyList<IPData>? result, bool negated = false)
public static bool TryParseToSubnets(string[] values, [NotNullWhen(true)] out IReadOnlyList<IPData>? result, bool negated = false, ILogger? logger = null)
{
if (values is null || values.Length == 0)
{
@@ -182,12 +184,45 @@ public static partial class NetworkUtils
{
(tmpResult ??= new()).Add(innerResult);
}
else
{
LogInvalidSubnet(logger, values[a]);
}
}
result = tmpResult;
return result is not null;
}
private static void LogInvalidSubnet(ILogger? logger, string value)
{
if (logger is null)
{
return;
}
var trimmed = value.AsSpan().Trim();
if (trimmed.StartsWith('!'))
{
trimmed = trimmed[1..];
}
var slash = trimmed.IndexOf('/');
if (slash != -1
&& trimmed.Contains(':')
&& trimmed.IndexOf("::", StringComparison.Ordinal) == -1)
{
logger.LogWarning(
"Invalid IPv6 subnet '{Subnet}': IPv6 prefix-only notation is not supported. Use the full notation including '::' (e.g. '{Example}::/{Prefix}').",
value,
trimmed[..slash].ToString(),
trimmed[(slash + 1)..].ToString());
return;
}
logger.LogWarning("Invalid subnet '{Subnet}' will be ignored.", value);
}
/// <summary>
/// Try parsing a string into an <see cref="IPData"/>, respecting exclusions.
/// Inputs without a subnet mask will be represented as <see cref="IPData"/> with a single IP.

View File

@@ -216,6 +216,9 @@ namespace MediaBrowser.Controller.Entities
[JsonIgnore]
public string OriginalTitle { get; set; }
[JsonIgnore]
public string OriginalLanguage { get; set; }
/// <summary>
/// Gets or sets the id.
/// </summary>

View File

@@ -22,30 +22,30 @@ public interface IPathManager
/// <param name="mediaSourceId">The media source id.</param>
/// <param name="streamIndex">The stream index.</param>
/// <param name="extension">The subtitle file extension.</param>
/// <returns>The absolute path.</returns>
public string GetSubtitlePath(string mediaSourceId, int streamIndex, string extension);
/// <returns>The absolute path, or <c>null</c> if <paramref name="mediaSourceId"/> is not a valid GUID.</returns>
public string? GetSubtitlePath(string mediaSourceId, int streamIndex, string extension);
/// <summary>
/// Gets the path to the subtitle file.
/// </summary>
/// <param name="mediaSourceId">The media source id.</param>
/// <returns>The absolute path.</returns>
public string GetSubtitleFolderPath(string mediaSourceId);
/// <returns>The absolute path, or <c>null</c> if <paramref name="mediaSourceId"/> is not a valid GUID.</returns>
public string? GetSubtitleFolderPath(string mediaSourceId);
/// <summary>
/// Gets the path to the attachment file.
/// </summary>
/// <param name="mediaSourceId">The media source id.</param>
/// <param name="fileName">The attachmentFileName index.</param>
/// <returns>The absolute path.</returns>
public string GetAttachmentPath(string mediaSourceId, string fileName);
/// <returns>The absolute path, or <c>null</c> if <paramref name="mediaSourceId"/> is not a valid GUID.</returns>
public string? GetAttachmentPath(string mediaSourceId, string fileName);
/// <summary>
/// Gets the path to the attachment folder.
/// </summary>
/// <param name="mediaSourceId">The media source id.</param>
/// <returns>The absolute path.</returns>
public string GetAttachmentFolderPath(string mediaSourceId);
/// <returns>The absolute path, or <c>null</c> if <paramref name="mediaSourceId"/> is not a valid GUID.</returns>
public string? GetAttachmentFolderPath(string mediaSourceId);
/// <summary>
/// Gets the chapter images data path.

View File

@@ -24,14 +24,14 @@ namespace MediaBrowser.Controller.Library
/// <summary>
/// Gets the users.
/// </summary>
/// <value>The users.</value>
IEnumerable<User> Users { get; }
/// <returns>The users.</returns>
IEnumerable<User> GetUsers();
/// <summary>
/// Gets the user ids.
/// </summary>
/// <value>The users ids.</value>
IEnumerable<Guid> UsersIds { get; }
/// <returns>The users ids.</returns>
IEnumerable<Guid> GetUsersIds();
/// <summary>
/// Initializes the user manager and ensures that a user exists.
@@ -47,6 +47,12 @@ namespace MediaBrowser.Controller.Library
/// <exception cref="ArgumentException"><c>id</c> is an empty Guid.</exception>
User? GetUserById(Guid id);
/// <summary>
/// Gets the first available user.
/// </summary>
/// <returns>The first user, or <c>null</c> if no users exist.</returns>
User? GetFirstUser();
/// <summary>
/// Gets the name of the user by.
/// </summary>
@@ -57,12 +63,13 @@ namespace MediaBrowser.Controller.Library
/// <summary>
/// Renames the user.
/// </summary>
/// <param name="user">The user.</param>
/// <param name="userId">The UserId to change.</param>
/// <param name="oldName">The old Username.</param>
/// <param name="newName">The new name.</param>
/// <returns>Task.</returns>
/// <exception cref="ArgumentNullException">If user is <c>null</c>.</exception>
/// <exception cref="ArgumentException">If the provided user doesn't exist.</exception>
Task RenameUser(User user, string newName);
Task RenameUser(Guid userId, string oldName, string newName);
/// <summary>
/// Updates the user.
@@ -92,17 +99,17 @@ namespace MediaBrowser.Controller.Library
/// <summary>
/// Resets the password.
/// </summary>
/// <param name="user">The user.</param>
/// <param name="userId">The users Id.</param>
/// <returns>Task.</returns>
Task ResetPassword(User user);
Task ResetPassword(Guid userId);
/// <summary>
/// Changes the password.
/// </summary>
/// <param name="user">The user.</param>
/// <param name="userId">The users id.</param>
/// <param name="newPassword">New password to use.</param>
/// <returns>Awaitable task.</returns>
Task ChangePassword(User user, string newPassword);
Task ChangePassword(Guid userId, string newPassword);
/// <summary>
/// Gets the user dto.

View File

@@ -0,0 +1,31 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace MediaBrowser.Controller.LiveTv;
/// <summary>
/// Provides Schedules Direct specific operations.
/// </summary>
public interface ISchedulesDirectService
{
/// <summary>
/// Gets the available countries from the Schedules Direct API, using a file cache.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A stream containing the raw JSON response.</returns>
Task<Stream> GetAvailableCountries(CancellationToken cancellationToken);
/// <summary>
/// Gets a value indicating whether the Schedules Direct daily image download limit is currently active.
/// </summary>
/// <returns><c>true</c> if the image limit has been hit and has not yet reset; otherwise <c>false</c>.</returns>
bool IsImageDailyLimitActive();
/// <summary>
/// Gets a value indicating whether the Schedules Direct service is available.
/// Returns <c>false</c> if a permanent account error has occurred or a transient backoff is active.
/// </summary>
/// <returns><c>true</c> if the service can accept requests; otherwise <c>false</c>.</returns>
bool IsServiceAvailable();
}

View File

@@ -37,6 +37,12 @@ public interface ITunerHostManager
/// <returns>The <see cref="TunerHostInfo"/>s.</returns>
IAsyncEnumerable<TunerHostInfo> DiscoverTuners(bool newDevicesOnly);
/// <summary>
/// Deletes a tuner host by id, cleans up associated caches, and triggers a guide refresh.
/// </summary>
/// <param name="id">The tuner host id to delete.</param>
void DeleteTunerHost(string? id);
/// <summary>
/// Scans for tuner devices that have changed URLs.
/// </summary>

View File

@@ -109,7 +109,7 @@ namespace MediaBrowser.Controller.LiveTv
{
if (double.TryParse(Number, CultureInfo.InvariantCulture, out double number))
{
return string.Format(CultureInfo.InvariantCulture, "{0:00000.0}", number) + "-" + (Name ?? string.Empty);
return string.Format(CultureInfo.InvariantCulture, "{0:0000000000.00000}", number) + "-" + (Name ?? string.Empty);
}
return (Number ?? string.Empty) + "-" + (Name ?? string.Empty);

View File

@@ -8,7 +8,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Controller</PackageId>
<VersionPrefix>10.12.0</VersionPrefix>
<VersionPrefix>12.0.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>

View File

@@ -91,6 +91,12 @@ namespace MediaBrowser.Controller.MediaEncoding
/// <value>The codec tag.</value>
public string CodecTag { get; set; }
/// <summary>
/// Gets or sets the rotation.
/// </summary>
/// <value>The video rotation angle, usually 0 or +-90/180.</value>
public string Rotation { get; set; }
/// <summary>
/// Gets or sets the framerate.
/// </summary>

View File

@@ -1645,10 +1645,9 @@ namespace MediaBrowser.Controller.MediaEncoding
}
if (string.Equals(videoCodec, "h264_amf", StringComparison.OrdinalIgnoreCase)
|| string.Equals(videoCodec, "hevc_amf", StringComparison.OrdinalIgnoreCase)
|| string.Equals(videoCodec, "av1_amf", StringComparison.OrdinalIgnoreCase))
|| string.Equals(videoCodec, "hevc_amf", StringComparison.OrdinalIgnoreCase))
{
// Override the too high default qmin 18 in transcoding preset
// Override the too high default qmin 18 in transcoding preset in legacy h26x_amf
return FormattableString.Invariant($" -rc cbr -qmin 0 -qmax 32 -b:v {bitrate} -maxrate {bitrate} -bufsize {bufsize}");
}
@@ -1880,10 +1879,12 @@ namespace MediaBrowser.Controller.MediaEncoding
var sub2videoParam = enableSub2video ? ":sub2video=1" : string.Empty;
var fontPath = _pathManager.GetAttachmentFolderPath(state.MediaSource.Id);
var fontParam = string.Format(
CultureInfo.InvariantCulture,
":fontsdir='{0}'",
_mediaEncoder.EscapeSubtitleFilterPath(fontPath));
var fontParam = fontPath is null
? string.Empty
: string.Format(
CultureInfo.InvariantCulture,
":fontsdir='{0}'",
_mediaEncoder.EscapeSubtitleFilterPath(fontPath));
if (state.SubtitleStream.IsExternal)
{
@@ -2466,6 +2467,17 @@ namespace MediaBrowser.Controller.MediaEncoding
}
}
var requestedRotations = state.GetRequestedRotations(videoStream.Codec);
if (requestedRotations.Length > 0)
{
var rotation = state.VideoStream?.Rotation ?? 0;
if (rotation != 0
&& !requestedRotations.Contains(rotation.ToString(CultureInfo.InvariantCulture), StringComparison.Ordinal))
{
return false;
}
}
// Video width must fall within requested value
if (request.MaxWidth.HasValue
&& (!videoStream.Width.HasValue || videoStream.Width.Value > request.MaxWidth.Value))

View File

@@ -571,62 +571,50 @@ namespace MediaBrowser.Controller.MediaEncoding
public string[] GetRequestedProfiles(string codec)
{
if (!string.IsNullOrEmpty(BaseRequest.Profile))
var profile = BaseRequest.Profile;
if (string.IsNullOrEmpty(profile) && !string.IsNullOrEmpty(codec))
{
return BaseRequest.Profile.Split(_separators, StringSplitOptions.RemoveEmptyEntries);
profile = BaseRequest.GetOption(codec, "profile");
}
if (!string.IsNullOrEmpty(codec))
{
var profile = BaseRequest.GetOption(codec, "profile");
if (!string.IsNullOrEmpty(profile))
{
return profile.Split(_separators, StringSplitOptions.RemoveEmptyEntries);
}
}
return Array.Empty<string>();
return (profile ?? string.Empty).Split(_separators, StringSplitOptions.RemoveEmptyEntries);
}
public string[] GetRequestedRangeTypes(string codec)
{
if (!string.IsNullOrEmpty(BaseRequest.VideoRangeType))
var rangetype = BaseRequest.VideoRangeType;
if (string.IsNullOrEmpty(rangetype) && !string.IsNullOrEmpty(codec))
{
return BaseRequest.VideoRangeType.Split(_separators, StringSplitOptions.RemoveEmptyEntries);
rangetype = BaseRequest.GetOption(codec, "rangetype");
}
if (!string.IsNullOrEmpty(codec))
{
var rangetype = BaseRequest.GetOption(codec, "rangetype");
if (!string.IsNullOrEmpty(rangetype))
{
return rangetype.Split(_separators, StringSplitOptions.RemoveEmptyEntries);
}
}
return Array.Empty<string>();
return (rangetype ?? string.Empty).Split(_separators, StringSplitOptions.RemoveEmptyEntries);
}
public string[] GetRequestedCodecTags(string codec)
{
if (!string.IsNullOrEmpty(BaseRequest.CodecTag))
var codectag = BaseRequest.CodecTag;
if (string.IsNullOrEmpty(codectag) && !string.IsNullOrEmpty(codec))
{
return BaseRequest.CodecTag.Split(_separators, StringSplitOptions.RemoveEmptyEntries);
codectag = BaseRequest.GetOption(codec, "codectag");
}
if (!string.IsNullOrEmpty(codec))
{
var codectag = BaseRequest.GetOption(codec, "codectag");
return (codectag ?? string.Empty).Split(_separators, StringSplitOptions.RemoveEmptyEntries);
}
if (!string.IsNullOrEmpty(codectag))
{
return codectag.Split(_separators, StringSplitOptions.RemoveEmptyEntries);
}
public string[] GetRequestedRotations(string codec)
{
var rotation = BaseRequest.Rotation;
if (string.IsNullOrEmpty(rotation) && !string.IsNullOrEmpty(codec))
{
rotation = BaseRequest.GetOption(codec, "rotation");
}
return Array.Empty<string>();
return (rotation ?? string.Empty).Split(_separators, StringSplitOptions.RemoveEmptyEntries);
}
public string GetRequestedLevel(string codec)

View File

@@ -40,11 +40,6 @@ namespace MediaBrowser.Controller.Net
/// </summary>
private readonly List<(IWebSocketConnection Connection, CancellationTokenSource CancellationTokenSource, TStateType State)> _activeConnections = new();
/// <summary>
/// The logger.
/// </summary>
protected readonly ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> Logger;
private readonly Task _messageConsumerTask;
protected BasePeriodicWebSocketListener(ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> logger)
@@ -56,6 +51,11 @@ namespace MediaBrowser.Controller.Net
_messageConsumerTask = HandleMessages();
}
/// <summary>
/// Gets the Logger.
/// </summary>
protected ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> Logger { get; }
/// <summary>
/// Gets the type used for the messages sent to the client.
/// </summary>

View File

@@ -105,7 +105,7 @@ namespace MediaBrowser.Controller.Providers
public IReadOnlyList<string> GetFilePaths(string path)
=> GetFilePaths(path, false);
public IReadOnlyList<string> GetFilePaths(string path, bool clearCache, bool sort = false)
public IReadOnlyList<string> GetFilePaths(string path, bool clearCache)
{
if (clearCache)
{
@@ -118,7 +118,7 @@ namespace MediaBrowser.Controller.Providers
{
try
{
return fileSystem.GetFilePaths(p).ToList();
return fileSystem.GetFilePaths(p).OrderBy(x => x).ToList();
}
catch (DirectoryNotFoundException)
{
@@ -127,11 +127,6 @@ namespace MediaBrowser.Controller.Providers
},
_fileSystem);
if (sort)
{
filePaths.Sort();
}
return filePaths;
}

View File

@@ -21,7 +21,7 @@ namespace MediaBrowser.Controller.Providers
IReadOnlyList<string> GetFilePaths(string path);
IReadOnlyList<string> GetFilePaths(string path, bool clearCache, bool sort = false);
IReadOnlyList<string> GetFilePaths(string path, bool clearCache);
bool IsAccessible(string path);
}

View File

@@ -129,6 +129,12 @@ namespace MediaBrowser.MediaEncoding.Attachments
ArgumentException.ThrowIfNullOrEmpty(inputPath);
var outputFolder = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
if (outputFolder is null)
{
_logger.LogDebug("Skipping attachment extraction for input {InputFile}: MediaSource Id is not a GUID.", inputFile);
return;
}
using (await _semaphoreLocks.LockAsync(outputFolder, cancellationToken).ConfigureAwait(false))
{
var directory = Directory.CreateDirectory(outputFolder);
@@ -241,9 +247,14 @@ namespace MediaBrowser.MediaEncoding.Attachments
CancellationToken cancellationToken)
{
var attachmentFolderPath = _pathManager.GetAttachmentFolderPath(mediaSource.Id);
if (attachmentFolderPath is null)
{
throw new ResourceNotFoundException($"MediaSource {mediaSource.Id} has no attachment cache (non-GUID Id, e.g. Live TV stream).");
}
using (await _semaphoreLocks.LockAsync(attachmentFolderPath, cancellationToken).ConfigureAwait(false))
{
var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAttachment.Index.ToString(CultureInfo.InvariantCulture));
var attachmentPath = _pathManager.GetAttachmentPath(mediaSource.Id, mediaAttachment.FileName ?? mediaAttachment.Index.ToString(CultureInfo.InvariantCulture))!;
if (!File.Exists(attachmentPath))
{
await ExtractAttachmentInternal(

View File

@@ -729,6 +729,7 @@ namespace MediaBrowser.MediaEncoding.Probing
stream.Type = MediaStreamType.Audio;
stream.LocalizedDefault = _localization.GetLocalizedString("Default");
stream.LocalizedExternal = _localization.GetLocalizedString("External");
stream.LocalizedOriginal = _localization.GetLocalizedString("Original");
stream.LocalizedLanguage = string.IsNullOrEmpty(stream.Language)
? null
: _localization.FindLanguageInfo(stream.Language)?.DisplayName;
@@ -1031,6 +1032,11 @@ namespace MediaBrowser.MediaEncoding.Probing
{
stream.IsHearingImpaired = true;
}
if (disposition.GetValueOrDefault("original") == 1)
{
stream.IsOriginal = true;
}
}
NormalizeStreamTitle(stream);
@@ -1702,6 +1708,13 @@ namespace MediaBrowser.MediaEncoding.Probing
return;
}
// Skip timestamp extration for remote resource (http, rtsp, etc.)
// as they cannot be opened with FileStream
if (video.Protocol != MediaProtocol.File)
{
return;
}
if (!string.Equals(video.Container, "mpeg2ts", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(video.Container, "m2ts", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(video.Container, "ts", StringComparison.OrdinalIgnoreCase))

View File

@@ -212,7 +212,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
var outputFileExtension = GetExtractableSubtitleFileExtension(subtitleStream);
var outputFormat = GetExtractableSubtitleFormat(subtitleStream);
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + outputFileExtension);
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + outputFileExtension)
?? throw new ResourceNotFoundException($"MediaSource {mediaSource.Id} has no subtitle cache (non-GUID Id, e.g. Live TV stream).");
return new SubtitleInfo()
{
@@ -242,7 +243,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
if (!_subtitleParser.SupportsFileExtension(currentFormat))
{
// Convert
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, ".srt");
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, ".srt")
?? throw new ResourceNotFoundException($"MediaSource {mediaSource.Id} has no subtitle cache (non-GUID Id, e.g. Live TV stream).");
await ConvertTextSubtitleToSrt(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false);
@@ -520,6 +522,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream));
if (outputPath is null)
{
continue;
}
var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false);
@@ -591,6 +597,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream));
if (outputPath is null)
{
continue;
}
var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
@@ -636,6 +647,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
var outputPath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + GetExtractableSubtitleFileExtension(subtitleStream));
if (outputPath is null)
{
continue;
}
var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
@@ -968,7 +984,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
}
private string GetSubtitleCachePath(MediaSourceInfo mediaSource, int subtitleStreamIndex, string outputSubtitleExtension)
private string? GetSubtitleCachePath(MediaSourceInfo mediaSource, int subtitleStreamIndex, string outputSubtitleExtension)
{
return _pathManager.GetSubtitlePath(mediaSource.Id, subtitleStreamIndex, outputSubtitleExtension);
}
@@ -981,9 +997,13 @@ namespace MediaBrowser.MediaEncoding.Subtitles
if (path.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
{
path = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + subtitleCodec);
await ExtractTextSubtitle(mediaSource, subtitleStream, subtitleCodec, path, cancellationToken)
.ConfigureAwait(false);
var cachePath = GetSubtitleCachePath(mediaSource, subtitleStream.Index, "." + subtitleCodec);
if (cachePath is not null)
{
path = cachePath;
await ExtractTextSubtitle(mediaSource, subtitleStream, subtitleCodec, path, cancellationToken)
.ConfigureAwait(false);
}
}
var result = await DetectCharset(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false);

View File

@@ -287,5 +287,5 @@ public class ServerConfiguration : BaseApplicationConfiguration
/// <summary>
/// Gets or sets a value indicating whether old authorization methods are allowed.
/// </summary>
public bool EnableLegacyAuthorization { get; set; }
public bool EnableLegacyAuthorization { get; set; } = true;
}

View File

@@ -33,6 +33,7 @@ namespace MediaBrowser.Model.Dlna
/// <param name="numAudioStreams">The number of audio streams.</param>
/// <param name="videoCodecTag">The video codec tag.</param>
/// <param name="isAvc">A value indicating whether the video is AVC.</param>
/// <param name="videoRotation">The video rotation angle, usually 0 or +-90/180.</param>
/// <returns><b>True</b> if the condition is satisfied.</returns>
public static bool IsVideoConditionSatisfied(
ProfileCondition condition,
@@ -53,7 +54,8 @@ namespace MediaBrowser.Model.Dlna
int? numVideoStreams,
int? numAudioStreams,
string? videoCodecTag,
bool? isAvc)
bool? isAvc,
int? videoRotation)
{
switch (condition.Property)
{
@@ -93,6 +95,8 @@ namespace MediaBrowser.Model.Dlna
return IsConditionSatisfied(condition, numVideoStreams);
case ProfileConditionValue.VideoTimestamp:
return IsConditionSatisfied(condition, timestamp);
case ProfileConditionValue.VideoRotation:
return IsConditionSatisfied(condition, videoRotation);
default:
return true;
}

View File

@@ -28,6 +28,7 @@ namespace MediaBrowser.Model.Dlna
AudioSampleRate = 22,
AudioBitDepth = 23,
VideoRangeType = 24,
NumStreams = 25
NumStreams = 25,
VideoRotation = 26
}
}

View File

@@ -22,7 +22,7 @@ namespace MediaBrowser.Model.Dlna
internal const TranscodeReason ContainerReasons = TranscodeReason.ContainerNotSupported | TranscodeReason.ContainerBitrateExceedsLimit;
internal const TranscodeReason AudioCodecReasons = TranscodeReason.AudioBitrateNotSupported | TranscodeReason.AudioChannelsNotSupported | TranscodeReason.AudioProfileNotSupported | TranscodeReason.AudioSampleRateNotSupported | TranscodeReason.SecondaryAudioNotSupported | TranscodeReason.AudioBitDepthNotSupported | TranscodeReason.AudioIsExternal;
internal const TranscodeReason AudioReasons = TranscodeReason.AudioCodecNotSupported | AudioCodecReasons;
internal const TranscodeReason VideoCodecReasons = TranscodeReason.VideoResolutionNotSupported | TranscodeReason.AnamorphicVideoNotSupported | TranscodeReason.InterlacedVideoNotSupported | TranscodeReason.VideoBitDepthNotSupported | TranscodeReason.VideoBitrateNotSupported | TranscodeReason.VideoFramerateNotSupported | TranscodeReason.VideoLevelNotSupported | TranscodeReason.RefFramesNotSupported | TranscodeReason.VideoRangeTypeNotSupported | TranscodeReason.VideoProfileNotSupported;
internal const TranscodeReason VideoCodecReasons = TranscodeReason.VideoResolutionNotSupported | TranscodeReason.AnamorphicVideoNotSupported | TranscodeReason.InterlacedVideoNotSupported | TranscodeReason.VideoBitDepthNotSupported | TranscodeReason.VideoBitrateNotSupported | TranscodeReason.VideoFramerateNotSupported | TranscodeReason.VideoLevelNotSupported | TranscodeReason.RefFramesNotSupported | TranscodeReason.VideoRangeTypeNotSupported | TranscodeReason.VideoProfileNotSupported | TranscodeReason.VideoRotationNotSupported;
internal const TranscodeReason VideoReasons = TranscodeReason.VideoCodecNotSupported | VideoCodecReasons;
internal const TranscodeReason DirectStreamReasons = AudioReasons | TranscodeReason.ContainerNotSupported | TranscodeReason.VideoCodecTagNotSupported;
@@ -380,6 +380,9 @@ namespace MediaBrowser.Model.Dlna
case ProfileConditionValue.VideoRangeType:
return TranscodeReason.VideoRangeTypeNotSupported;
case ProfileConditionValue.VideoRotation:
return TranscodeReason.VideoRotationNotSupported;
case ProfileConditionValue.VideoTimestamp:
// TODO
return 0;
@@ -1040,6 +1043,7 @@ namespace MediaBrowser.Model.Dlna
bool? isInterlaced = videoStream?.IsInterlaced;
string? videoCodecTag = videoStream?.CodecTag;
bool? isAvc = videoStream?.IsAVC;
int? videoRotation = videoStream?.Rotation;
TransportStreamTimestamp? timestamp = videoStream is null ? TransportStreamTimestamp.None : item.Timestamp;
int? packetLength = videoStream?.PacketLength;
@@ -1054,7 +1058,7 @@ namespace MediaBrowser.Model.Dlna
var appliedVideoConditions = options.Profile.CodecProfiles
.Where(i => i.Type == CodecType.Video &&
i.ContainsAnyCodec(playlistItem.VideoCodecs, container, useSubContainer) &&
i.ApplyConditions.All(applyCondition => ConditionProcessor.IsVideoConditionSatisfied(applyCondition, width, height, bitDepth, videoBitrate, videoProfile, videoRangeType, videoLevel, videoFramerate, packetLength, timestamp, isAnamorphic, isInterlaced, refFrames, numStreams, numVideoStreams, numAudioStreams, videoCodecTag, isAvc)))
i.ApplyConditions.All(applyCondition => ConditionProcessor.IsVideoConditionSatisfied(applyCondition, width, height, bitDepth, videoBitrate, videoProfile, videoRangeType, videoLevel, videoFramerate, packetLength, timestamp, isAnamorphic, isInterlaced, refFrames, numStreams, numVideoStreams, numAudioStreams, videoCodecTag, isAvc, videoRotation)))
// Reverse codec profiles for backward compatibility - first codec profile has higher priority
.Reverse();
foreach (var condition in appliedVideoConditions)
@@ -2059,6 +2063,38 @@ namespace MediaBrowser.Model.Dlna
break;
}
case ProfileConditionValue.VideoRotation:
{
if (string.IsNullOrEmpty(qualifier))
{
continue;
}
// change from split by | to comma
// strip spaces to avoid having to encode
var values = value
.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (condition.Condition == ProfileConditionType.Equals)
{
item.SetOption(qualifier, "rotation", string.Join(',', values));
}
else if (condition.Condition == ProfileConditionType.EqualsAny)
{
var currentValue = item.GetOption(qualifier, "rotation");
if (!string.IsNullOrEmpty(currentValue) && values.Any(v => string.Equals(v, currentValue, StringComparison.OrdinalIgnoreCase)))
{
item.SetOption(qualifier, "rotation", currentValue);
}
else
{
item.SetOption(qualifier, "rotation", string.Join(',', values));
}
}
break;
}
case ProfileConditionValue.Height:
{
if (!enableNonQualifiedConditions)
@@ -2281,6 +2317,7 @@ namespace MediaBrowser.Model.Dlna
bool? isInterlaced = videoStream?.IsInterlaced;
string? videoCodecTag = videoStream?.CodecTag;
bool? isAvc = videoStream?.IsAVC;
int? videoRotation = videoStream?.Rotation;
TransportStreamTimestamp? timestamp = videoStream is null ? TransportStreamTimestamp.None : mediaSource.Timestamp;
int? packetLength = videoStream?.PacketLength;
@@ -2290,7 +2327,7 @@ namespace MediaBrowser.Model.Dlna
int? numAudioStreams = mediaSource.GetStreamCount(MediaStreamType.Audio);
int? numVideoStreams = mediaSource.GetStreamCount(MediaStreamType.Video);
return conditions.Where(applyCondition => !ConditionProcessor.IsVideoConditionSatisfied(applyCondition, width, height, bitDepth, videoBitrate, videoProfile, videoRangeType, videoLevel, videoFramerate, packetLength, timestamp, isAnamorphic, isInterlaced, refFrames, numStreams, numVideoStreams, numAudioStreams, videoCodecTag, isAvc));
return conditions.Where(applyCondition => !ConditionProcessor.IsVideoConditionSatisfied(applyCondition, width, height, bitDepth, videoBitrate, videoProfile, videoRangeType, videoLevel, videoFramerate, packetLength, timestamp, isAnamorphic, isInterlaced, refFrames, numStreams, numVideoStreams, numAudioStreams, videoCodecTag, isAvc, videoRotation));
}
/// <summary>

View File

@@ -1,4 +1,5 @@
#pragma warning disable CS1591
#pragma warning disable CA1815
using System.Globalization;

View File

@@ -800,5 +800,7 @@ namespace MediaBrowser.Model.Dto
/// </summary>
/// <value>The current program.</value>
public BaseItemDto CurrentProgram { get; set; }
public string OriginalLanguage { get; set; }
}
}

View File

@@ -260,6 +260,8 @@ namespace MediaBrowser.Model.Entities
public string LocalizedLanguage { get; set; }
public string LocalizedOriginal { get; set; }
public string DisplayTitle
{
get
@@ -267,161 +269,166 @@ namespace MediaBrowser.Model.Entities
switch (Type)
{
case MediaStreamType.Audio:
{
var attributes = new List<string>();
{
var attributes = new List<string>();
// Do not display the language code in display titles if unset or set to a special code. Show it in all other cases (possibly expanded).
if (!string.IsNullOrEmpty(Language) && !_specialCodes.Contains(Language, StringComparison.OrdinalIgnoreCase))
{
// Use pre-resolved localized language name, falling back to raw language code.
attributes.Add(StringHelper.FirstToUpper(LocalizedLanguage ?? Language));
}
if (!string.IsNullOrEmpty(Profile) && !string.Equals(Profile, "lc", StringComparison.OrdinalIgnoreCase))
{
attributes.Add(Profile);
}
else if (!string.IsNullOrEmpty(Codec))
{
attributes.Add(AudioCodec.GetFriendlyName(Codec));
}
if (!string.IsNullOrEmpty(ChannelLayout))
{
attributes.Add(StringHelper.FirstToUpper(ChannelLayout));
}
else if (Channels.HasValue)
{
attributes.Add(Channels.Value.ToString(CultureInfo.InvariantCulture) + " ch");
}
if (IsDefault)
{
attributes.Add(string.IsNullOrEmpty(LocalizedDefault) ? "Default" : LocalizedDefault);
}
if (IsExternal)
{
attributes.Add(string.IsNullOrEmpty(LocalizedExternal) ? "External" : LocalizedExternal);
}
if (!string.IsNullOrEmpty(Title))
{
var result = new StringBuilder(Title);
foreach (var tag in attributes)
// Do not display the language code in display titles if unset or set to a special code. Show it in all other cases (possibly expanded).
if (!string.IsNullOrEmpty(Language) && !_specialCodes.Contains(Language, StringComparison.OrdinalIgnoreCase))
{
// Keep Tags that are not already in Title.
if (!Title.Contains(tag, StringComparison.OrdinalIgnoreCase))
{
result.Append(" - ").Append(tag);
}
// Use pre-resolved localized language name, falling back to raw language code.
attributes.Add(StringHelper.FirstToUpper(LocalizedLanguage ?? Language));
}
return result.ToString();
}
if (!string.IsNullOrEmpty(Profile) && !string.Equals(Profile, "lc", StringComparison.OrdinalIgnoreCase))
{
attributes.Add(Profile);
}
else if (!string.IsNullOrEmpty(Codec))
{
attributes.Add(AudioCodec.GetFriendlyName(Codec));
}
return string.Join(" - ", attributes);
}
if (!string.IsNullOrEmpty(ChannelLayout))
{
attributes.Add(StringHelper.FirstToUpper(ChannelLayout));
}
else if (Channels.HasValue)
{
attributes.Add(Channels.Value.ToString(CultureInfo.InvariantCulture) + " ch");
}
if (IsDefault)
{
attributes.Add(string.IsNullOrEmpty(LocalizedDefault) ? "Default" : LocalizedDefault);
}
if (IsExternal)
{
attributes.Add(string.IsNullOrEmpty(LocalizedExternal) ? "External" : LocalizedExternal);
}
if (IsOriginal)
{
attributes.Add(string.IsNullOrEmpty(LocalizedOriginal) ? "Original" : LocalizedOriginal);
}
if (!string.IsNullOrEmpty(Title))
{
var result = new StringBuilder(Title);
foreach (var tag in attributes)
{
// Keep Tags that are not already in Title.
if (!Title.Contains(tag, StringComparison.OrdinalIgnoreCase))
{
result.Append(" - ").Append(tag);
}
}
return result.ToString();
}
return string.Join(" - ", attributes);
}
case MediaStreamType.Video:
{
var attributes = new List<string>();
var resolutionText = GetResolutionText();
if (!string.IsNullOrEmpty(resolutionText))
{
attributes.Add(resolutionText);
}
var attributes = new List<string>();
if (!string.IsNullOrEmpty(Codec))
{
attributes.Add(Codec.ToUpperInvariant());
}
var resolutionText = GetResolutionText();
if (VideoDoViTitle is not null)
{
attributes.Add(VideoDoViTitle);
}
else if (VideoRange != VideoRange.Unknown)
{
attributes.Add(VideoRange.ToString());
}
if (!string.IsNullOrEmpty(Title))
{
var result = new StringBuilder(Title);
foreach (var tag in attributes)
if (!string.IsNullOrEmpty(resolutionText))
{
// Keep Tags that are not already in Title.
if (!Title.Contains(tag, StringComparison.OrdinalIgnoreCase))
{
result.Append(" - ").Append(tag);
}
attributes.Add(resolutionText);
}
return result.ToString();
}
if (!string.IsNullOrEmpty(Codec))
{
attributes.Add(Codec.ToUpperInvariant());
}
return string.Join(' ', attributes);
}
if (VideoDoViTitle is not null)
{
attributes.Add(VideoDoViTitle);
}
else if (VideoRange != VideoRange.Unknown)
{
attributes.Add(VideoRange.ToString());
}
if (!string.IsNullOrEmpty(Title))
{
var result = new StringBuilder(Title);
foreach (var tag in attributes)
{
// Keep Tags that are not already in Title.
if (!Title.Contains(tag, StringComparison.OrdinalIgnoreCase))
{
result.Append(" - ").Append(tag);
}
}
return result.ToString();
}
return string.Join(' ', attributes);
}
case MediaStreamType.Subtitle:
{
var attributes = new List<string>();
{
var attributes = new List<string>();
if (!string.IsNullOrEmpty(Language))
{
// Use pre-resolved localized language name, falling back to raw language code.
attributes.Add(StringHelper.FirstToUpper(LocalizedLanguage ?? Language));
}
else
{
attributes.Add(string.IsNullOrEmpty(LocalizedUndefined) ? "Und" : LocalizedUndefined);
}
if (IsHearingImpaired == true)
{
attributes.Add(string.IsNullOrEmpty(LocalizedHearingImpaired) ? "Hearing Impaired" : LocalizedHearingImpaired);
}
if (IsDefault)
{
attributes.Add(string.IsNullOrEmpty(LocalizedDefault) ? "Default" : LocalizedDefault);
}
if (IsForced)
{
attributes.Add(string.IsNullOrEmpty(LocalizedForced) ? "Forced" : LocalizedForced);
}
if (!string.IsNullOrEmpty(Codec))
{
attributes.Add(Codec.ToUpperInvariant());
}
if (IsExternal)
{
attributes.Add(string.IsNullOrEmpty(LocalizedExternal) ? "External" : LocalizedExternal);
}
if (!string.IsNullOrEmpty(Title))
{
var result = new StringBuilder(Title);
foreach (var tag in attributes)
if (!string.IsNullOrEmpty(Language))
{
// Keep Tags that are not already in Title.
if (!Title.Contains(tag, StringComparison.OrdinalIgnoreCase))
{
result.Append(" - ").Append(tag);
}
// Use pre-resolved localized language name, falling back to raw language code.
attributes.Add(StringHelper.FirstToUpper(LocalizedLanguage ?? Language));
}
else
{
attributes.Add(string.IsNullOrEmpty(LocalizedUndefined) ? "Und" : LocalizedUndefined);
}
return result.ToString();
}
if (IsHearingImpaired == true)
{
attributes.Add(string.IsNullOrEmpty(LocalizedHearingImpaired) ? "Hearing Impaired" : LocalizedHearingImpaired);
}
return string.Join(" - ", attributes);
}
if (IsDefault)
{
attributes.Add(string.IsNullOrEmpty(LocalizedDefault) ? "Default" : LocalizedDefault);
}
if (IsForced)
{
attributes.Add(string.IsNullOrEmpty(LocalizedForced) ? "Forced" : LocalizedForced);
}
if (!string.IsNullOrEmpty(Codec))
{
attributes.Add(Codec.ToUpperInvariant());
}
if (IsExternal)
{
attributes.Add(string.IsNullOrEmpty(LocalizedExternal) ? "External" : LocalizedExternal);
}
if (!string.IsNullOrEmpty(Title))
{
var result = new StringBuilder(Title);
foreach (var tag in attributes)
{
// Keep Tags that are not already in Title.
if (!Title.Contains(tag, StringComparison.OrdinalIgnoreCase))
{
result.Append(" - ").Append(tag);
}
}
return result.ToString();
}
return string.Join(" - ", attributes);
}
default:
return null;
@@ -499,6 +506,12 @@ namespace MediaBrowser.Model.Entities
/// <value><c>true</c> if this instance is for the hearing impaired; otherwise, <c>false</c>.</value>
public bool IsHearingImpaired { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this instance is original.
/// </summary>
/// <value><c>true</c> if this instance is original; otherwise, <c>false</c>.</value>
public bool IsOriginal { get; set; }
/// <summary>
/// Gets or sets the height.
/// </summary>

View File

@@ -8,7 +8,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Model</PackageId>
<VersionPrefix>10.12.0</VersionPrefix>
<VersionPrefix>12.0.0</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup>

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