aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/ci-codeql-analysis.yml6
-rw-r--r--.github/workflows/ci-tests.yml2
-rw-r--r--CONTRIBUTORS.md2
-rw-r--r--Emby.Naming/AudioBook/AudioBookNameParserResult.cs2
-rw-r--r--Emby.Naming/Emby.Naming.csproj2
-rw-r--r--Emby.Naming/TV/SeasonPathParser.cs4
-rw-r--r--Emby.Naming/Video/CleanDateTimeResult.cs2
-rw-r--r--Emby.Server.Implementations/ApplicationHost.cs4
-rw-r--r--Emby.Server.Implementations/Dto/DtoService.cs52
-rw-r--r--Emby.Server.Implementations/Library/MediaSourceManager.cs54
-rw-r--r--Emby.Server.Implementations/Library/PathExtensions.cs10
-rw-r--r--Emby.Server.Implementations/Library/PathManager.cs58
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs26
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs34
-rw-r--r--Emby.Server.Implementations/Localization/Core/ar.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/ca.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/cs.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/de.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/en-US.json1
-rw-r--r--Emby.Server.Implementations/Localization/Core/es.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/ga.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/hr.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/ht.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/hu.json5
-rw-r--r--Emby.Server.Implementations/Localization/Core/it.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/lv.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/ne.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/nl.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/pl.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt-PT.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/ru.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/sv.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/uk.json3
-rw-r--r--Emby.Server.Implementations/Localization/Core/vi.json3
-rw-r--r--Emby.Server.Implementations/Localization/LocalizationManager.cs77
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/ca.json15
-rw-r--r--Emby.Server.Implementations/Serialization/MyXmlSerializer.cs12
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs21
-rw-r--r--Jellyfin.Api/Controllers/ArtistsController.cs3
-rw-r--r--Jellyfin.Api/Controllers/DashboardController.cs1
-rw-r--r--Jellyfin.Api/Controllers/InstantMixController.cs3
-rw-r--r--Jellyfin.Api/Controllers/ItemRefreshController.cs1
-rw-r--r--Jellyfin.Api/Controllers/ItemUpdateController.cs1
-rw-r--r--Jellyfin.Api/Controllers/ItemsController.cs4
-rw-r--r--Jellyfin.Api/Controllers/PlaystateController.cs2
-rw-r--r--Jellyfin.Api/Controllers/SessionController.cs2
-rw-r--r--Jellyfin.Api/Controllers/StartupController.cs18
-rw-r--r--Jellyfin.Api/Controllers/UserController.cs18
-rw-r--r--Jellyfin.Api/Controllers/UserLibraryController.cs5
-rw-r--r--Jellyfin.Data/Jellyfin.Data.csproj2
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemMapper.cs2
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs141
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs73
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs3
-rw-r--r--Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs10
-rw-r--r--Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs7
-rw-r--r--Jellyfin.Server.Implementations/Item/NextUpService.cs18
-rw-r--r--Jellyfin.Server.Implementations/Item/OrderMapper.cs4
-rw-r--r--Jellyfin.Server.Implementations/Item/PeopleRepository.cs33
-rw-r--r--Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs4
-rw-r--r--Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs4
-rw-r--r--Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs2
-rw-r--r--Jellyfin.Server.Implementations/Users/UserManager.cs681
-rw-r--r--Jellyfin.Server/Configuration/StartupMode.cs24
-rw-r--r--Jellyfin.Server/Migrations/JellyfinMigrationService.cs6
-rw-r--r--Jellyfin.Server/Migrations/Routines/DisableLegacyAuthorization.cs32
-rw-r--r--Jellyfin.Server/Migrations/Routines/MergeDuplicateMusicArtists.cs204
-rw-r--r--Jellyfin.Server/Migrations/Routines/MergeDuplicatePeople.cs294
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs42
-rw-r--r--Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs3
-rw-r--r--Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs10
-rw-r--r--Jellyfin.Server/Program.cs34
-rw-r--r--Jellyfin.Server/StartupOptions.cs8
-rw-r--r--MediaBrowser.Common/MediaBrowser.Common.csproj2
-rw-r--r--MediaBrowser.Common/Net/NetworkUtils.cs37
-rw-r--r--MediaBrowser.Controller/Entities/BaseItem.cs3
-rw-r--r--MediaBrowser.Controller/IO/IPathManager.cs16
-rw-r--r--MediaBrowser.Controller/Library/IUserManager.cs27
-rw-r--r--MediaBrowser.Controller/MediaBrowser.Controller.csproj2
-rw-r--r--MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs6
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs26
-rw-r--r--MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs56
-rw-r--r--MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs10
-rw-r--r--MediaBrowser.Controller/Providers/DirectoryService.cs9
-rw-r--r--MediaBrowser.Controller/Providers/IDirectoryService.cs2
-rw-r--r--MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs13
-rw-r--r--MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs13
-rw-r--r--MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs32
-rw-r--r--MediaBrowser.Model/Configuration/ServerConfiguration.cs2
-rw-r--r--MediaBrowser.Model/Dlna/ConditionProcessor.cs6
-rw-r--r--MediaBrowser.Model/Dlna/ProfileConditionValue.cs3
-rw-r--r--MediaBrowser.Model/Dlna/StreamBuilder.cs43
-rw-r--r--MediaBrowser.Model/Drawing/ImageDimensions.cs1
-rw-r--r--MediaBrowser.Model/Dto/BaseItemDto.cs2
-rw-r--r--MediaBrowser.Model/Entities/MediaStream.cs243
-rw-r--r--MediaBrowser.Model/MediaBrowser.Model.csproj2
-rw-r--r--MediaBrowser.Model/Session/TranscodeReason.cs1
-rw-r--r--MediaBrowser.Providers/Manager/MetadataService.cs5
-rw-r--r--MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs8
-rw-r--r--MediaBrowser.Providers/Movies/ImdbExternalUrlProvider.cs13
-rw-r--r--MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs83
-rw-r--r--MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs69
-rw-r--r--MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs102
-rw-r--r--MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs1
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs5
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeExternalId.cs25
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonExternalId.cs25
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs5
-rw-r--r--MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs5
-rw-r--r--MediaBrowser.Providers/Subtitles/SubtitleManager.cs9
-rw-r--r--MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs10
-rw-r--r--MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs11
-rw-r--r--README.md4
-rw-r--r--SharedVersion.cs4
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemEntity.cs2
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/ItemMetadata.cs10
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/MediaStreamInfo.cs2
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260504180809_AddOriginalLanguage.Designer.cs1802
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260504180809_AddOriginalLanguage.cs47
-rw-r--r--src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs6
-rw-r--r--src/Jellyfin.Extensions/Jellyfin.Extensions.csproj2
-rw-r--r--src/Jellyfin.LiveTv/LiveTvManager.cs4
-rw-r--r--src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs2
-rw-r--r--src/Jellyfin.Networking/Manager/NetworkManager.cs4
-rw-r--r--tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs3
-rw-r--r--tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs3
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidTVExoPlayer-NoHevcRotation.json162
-rw-r--r--tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-hevc-aac-4000k-r180.json56
-rw-r--r--tests/Jellyfin.Networking.Tests/NetworkParseTests.cs38
-rw-r--r--tests/Jellyfin.Providers.Tests/ExternalId/AudioDbExternalUrlProviderTests.cs89
-rw-r--r--tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs56
-rw-r--r--tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs45
-rw-r--r--tests/Jellyfin.Providers.Tests/ExternalId/ImdbExternalUrlProviderTests.cs125
-rw-r--r--tests/Jellyfin.Providers.Tests/ExternalId/IsbnExternalUrlProviderTests.cs45
-rw-r--r--tests/Jellyfin.Providers.Tests/ExternalId/MusicBrainzExternalUrlProviderTests.cs202
-rw-r--r--tests/Jellyfin.Providers.Tests/ExternalId/TmdbExternalUrlProviderTests.cs193
-rw-r--r--tests/Jellyfin.Providers.Tests/ExternalId/Zap2ItExternalUrlProviderTests.cs33
-rw-r--r--tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs18
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/EpisodeResolverTest.cs88
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs116
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs145
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Library/SeriesResolverTests.cs124
-rw-r--r--tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs34
-rw-r--r--tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs2
-rw-r--r--tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs43
-rw-r--r--tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating.nfo5
-rw-r--r--tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating_Comma.nfo5
-rw-r--r--tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating_OutOfRange.nfo5
149 files changed, 5646 insertions, 956 deletions
diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml
index 65752af977..7b1d8b4132 100644
--- a/.github/workflows/ci-codeql-analysis.yml
+++ b/.github/workflows/ci-codeql-analysis.yml
@@ -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
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
index 2a26bf15a4..3c7ba54acf 100644
--- a/.github/workflows/ci-tests.yml
+++ b/.github/workflows/ci-tests.yml
@@ -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/"
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index c42962786d..09a7198afe 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -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
diff --git a/Emby.Naming/AudioBook/AudioBookNameParserResult.cs b/Emby.Naming/AudioBook/AudioBookNameParserResult.cs
index 3f2d7b2b0b..de78e75a91 100644
--- a/Emby.Naming/AudioBook/AudioBookNameParserResult.cs
+++ b/Emby.Naming/AudioBook/AudioBookNameParserResult.cs
@@ -1,3 +1,5 @@
+#pragma warning disable CA1815
+
namespace Emby.Naming.AudioBook
{
/// <summary>
diff --git a/Emby.Naming/Emby.Naming.csproj b/Emby.Naming/Emby.Naming.csproj
index 97b52e42af..9f98970df5 100644
--- a/Emby.Naming/Emby.Naming.csproj
+++ b/Emby.Naming/Emby.Naming.csproj
@@ -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>
diff --git a/Emby.Naming/TV/SeasonPathParser.cs b/Emby.Naming/TV/SeasonPathParser.cs
index 72adfb2d96..ea4875e00a 100644
--- a/Emby.Naming/TV/SeasonPathParser.cs
+++ b/Emby.Naming/TV/SeasonPathParser.cs
@@ -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)]
diff --git a/Emby.Naming/Video/CleanDateTimeResult.cs b/Emby.Naming/Video/CleanDateTimeResult.cs
index c675a19d0f..e367f92213 100644
--- a/Emby.Naming/Video/CleanDateTimeResult.cs
+++ b/Emby.Naming/Video/CleanDateTimeResult.cs
@@ -1,3 +1,5 @@
+#pragma warning disable CA1815
+
namespace Emby.Naming.Video
{
/// <summary>
diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs
index b7aa2f3d06..3e98a5276c 100644
--- a/Emby.Server.Implementations/ApplicationHost.cs
+++ b/Emby.Server.Implementations/ApplicationHost.cs
@@ -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;
diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs
index cc57d183b6..321c7da1c4 100644
--- a/Emby.Server.Implementations/Dto/DtoService.cs
+++ b/Emby.Server.Implementations/Dto/DtoService.cs
@@ -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))
diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs
index c667fb0600..fdb4c7328b 100644
--- a/Emby.Server.Implementations/Library/MediaSourceManager.cs
+++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs
@@ -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)
diff --git a/Emby.Server.Implementations/Library/PathExtensions.cs b/Emby.Server.Implementations/Library/PathExtensions.cs
index fc63251ad0..cfa3e7c31d 100644
--- a/Emby.Server.Implementations/Library/PathExtensions.cs
+++ b/Emby.Server.Implementations/Library/PathExtensions.cs
@@ -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;
}
diff --git a/Emby.Server.Implementations/Library/PathManager.cs b/Emby.Server.Implementations/Library/PathManager.cs
index a9b7a1274b..ef5edb9afa 100644
--- a/Emby.Server.Implementations/Library/PathManager.cs
+++ b/Emby.Server.Implementations/Library/PathManager.cs
@@ -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;
}
}
diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs
index 5fd23c9f50..85bf20cc2a 100644
--- a/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/TV/EpisodeResolver.cs
@@ -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);
+ }
}
}
diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
index 6cb63a28a2..6e9a38fd34 100644
--- a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs
@@ -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);
+ }
}
}
diff --git a/Emby.Server.Implementations/Localization/Core/ar.json b/Emby.Server.Implementations/Localization/Core/ar.json
index b80737d3b9..e48939b4d7 100644
--- a/Emby.Server.Implementations/Localization/Core/ar.json
+++ b/Emby.Server.Implementations/Localization/Core/ar.json
@@ -135,5 +135,6 @@
"TaskMoveTrickplayImages": "نقل موقع صور معاينات التنقل",
"TaskMoveTrickplayImagesDescription": "ينقل ملفات معاينات التنقل الحالية وفقاً لإعدادات المكتبة.",
"CleanupUserDataTask": "مهمة تنظيف بيانات المستخدم",
- "CleanupUserDataTaskDescription": "ينظف جميع بيانات المستخدم (مثل حالة المشاهدة وحالة المفضلة وغيرها) للمحتوى الذي لم يعد موجوداً لمدة 90 يوماً على الأقل."
+ "CleanupUserDataTaskDescription": "ينظف جميع بيانات المستخدم (مثل حالة المشاهدة وحالة المفضلة وغيرها) للمحتوى الذي لم يعد موجوداً لمدة 90 يوماً على الأقل.",
+ "Original": "فريد"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ca.json b/Emby.Server.Implementations/Localization/Core/ca.json
index f9543e6f4c..14838e8c34 100644
--- a/Emby.Server.Implementations/Localization/Core/ca.json
+++ b/Emby.Server.Implementations/Localization/Core/ca.json
@@ -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"
}
diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json
index 8d43839110..3fc1895842 100644
--- a/Emby.Server.Implementations/Localization/Core/cs.json
+++ b/Emby.Server.Implementations/Localization/Core/cs.json
@@ -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"
}
diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json
index ab1a7d2cbd..b628f45ea7 100644
--- a/Emby.Server.Implementations/Localization/Core/de.json
+++ b/Emby.Server.Implementations/Localization/Core/de.json
@@ -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"
}
diff --git a/Emby.Server.Implementations/Localization/Core/en-US.json b/Emby.Server.Implementations/Localization/Core/en-US.json
index 45b1cbb6a0..9b5049c8c7 100644
--- a/Emby.Server.Implementations/Localization/Core/en-US.json
+++ b/Emby.Server.Implementations/Localization/Core/en-US.json
@@ -64,6 +64,7 @@
"NotificationOptionUserLockedOut": "User locked out",
"NotificationOptionVideoPlayback": "Video playback started",
"NotificationOptionVideoPlaybackStopped": "Video playback stopped",
+ "Original": "Original",
"Photos": "Photos",
"Playlists": "Playlists",
"Plugin": "Plugin",
diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json
index cf118077c6..4f6a3544e4 100644
--- a/Emby.Server.Implementations/Localization/Core/es.json
+++ b/Emby.Server.Implementations/Localization/Core/es.json
@@ -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"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ga.json b/Emby.Server.Implementations/Localization/Core/ga.json
index 5742e6224d..ee6e8b8368 100644
--- a/Emby.Server.Implementations/Localization/Core/ga.json
+++ b/Emby.Server.Implementations/Localization/Core/ga.json
@@ -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"
}
diff --git a/Emby.Server.Implementations/Localization/Core/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json
index e3bea78a3f..5800764587 100644
--- a/Emby.Server.Implementations/Localization/Core/hr.json
+++ b/Emby.Server.Implementations/Localization/Core/hr.json
@@ -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"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ht.json b/Emby.Server.Implementations/Localization/Core/ht.json
index 183c422a85..f1ff775155 100644
--- a/Emby.Server.Implementations/Localization/Core/ht.json
+++ b/Emby.Server.Implementations/Localization/Core/ht.json
@@ -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"
}
diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json
index 8d9e5b08ba..31df91693c 100644
--- a/Emby.Server.Implementations/Localization/Core/hu.json
+++ b/Emby.Server.Implementations/Localization/Core/hu.json
@@ -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"
}
diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json
index 782f5ce53d..41d97442ed 100644
--- a/Emby.Server.Implementations/Localization/Core/it.json
+++ b/Emby.Server.Implementations/Localization/Core/it.json
@@ -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"
}
diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json
index 1083e3c299..4bf6ed4752 100644
--- a/Emby.Server.Implementations/Localization/Core/lv.json
+++ b/Emby.Server.Implementations/Localization/Core/lv.json
@@ -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"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ne.json b/Emby.Server.Implementations/Localization/Core/ne.json
index 7c6b08fb36..0e52e32c1b 100644
--- a/Emby.Server.Implementations/Localization/Core/ne.json
+++ b/Emby.Server.Implementations/Localization/Core/ne.json
@@ -45,7 +45,7 @@
"Genres": "विधाहरू",
"Folders": "फोल्डरहरू",
"Favorites": "मनपर्ने",
- "FailedLoginAttemptWithUserName": "{0}को लग इन प्रयास असफल",
+ "FailedLoginAttemptWithUserName": "असफल लग इन प्रयास {0} देखि",
"DeviceOnlineWithName": "{0}को साथ जडित",
"DeviceOfflineWithName": "{0}बाट विच्छेदन भयो",
"Collections": "संग्रह",
diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json
index bf1cbdacd1..de4c277ce7 100644
--- a/Emby.Server.Implementations/Localization/Core/nl.json
+++ b/Emby.Server.Implementations/Localization/Core/nl.json
@@ -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"
}
diff --git a/Emby.Server.Implementations/Localization/Core/pl.json b/Emby.Server.Implementations/Localization/Core/pl.json
index a741fc14c0..e5af2c7801 100644
--- a/Emby.Server.Implementations/Localization/Core/pl.json
+++ b/Emby.Server.Implementations/Localization/Core/pl.json
@@ -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"
}
diff --git a/Emby.Server.Implementations/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json
index 1d31efcdc9..93dfa7e7f5 100644
--- a/Emby.Server.Implementations/Localization/Core/pt-PT.json
+++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json
@@ -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"
}
diff --git a/Emby.Server.Implementations/Localization/Core/pt.json b/Emby.Server.Implementations/Localization/Core/pt.json
index 82da1f0aff..ce288223bb 100644
--- a/Emby.Server.Implementations/Localization/Core/pt.json
+++ b/Emby.Server.Implementations/Localization/Core/pt.json
@@ -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"
}
diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json
index 38920b6ede..c9a1c7eb87 100644
--- a/Emby.Server.Implementations/Localization/Core/ru.json
+++ b/Emby.Server.Implementations/Localization/Core/ru.json
@@ -135,5 +135,6 @@
"TaskExtractMediaSegmentsDescription": "Извлекает или получает медиасегменты из плагинов MediaSegment.",
"TaskMoveTrickplayImagesDescription": "Перемещает существующие файлы trickplay в соответствии с настройками медиатеки.",
"CleanupUserDataTask": "Задача очистки пользовательских данных",
- "CleanupUserDataTaskDescription": "Очищает все пользовательские данные (состояние просмотра, статус избранного и т.д.) с медиа, отсутствующих по меньшей мере в течение 90 дней."
+ "CleanupUserDataTaskDescription": "Очищает все пользовательские данные (состояние просмотра, статус избранного и т.д.) с медиа, отсутствующих по меньшей мере в течение 90 дней.",
+ "Original": "Оригинальный"
}
diff --git a/Emby.Server.Implementations/Localization/Core/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json
index a47ed248e9..015f59af25 100644
--- a/Emby.Server.Implementations/Localization/Core/sv.json
+++ b/Emby.Server.Implementations/Localization/Core/sv.json
@@ -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"
}
diff --git a/Emby.Server.Implementations/Localization/Core/uk.json b/Emby.Server.Implementations/Localization/Core/uk.json
index 9246d9de20..61d5d6964c 100644
--- a/Emby.Server.Implementations/Localization/Core/uk.json
+++ b/Emby.Server.Implementations/Localization/Core/uk.json
@@ -135,5 +135,6 @@
"TaskMoveTrickplayImages": "Змінити місце розташування прев'ю-зображень",
"TaskExtractMediaSegmentsDescription": "Витягує або отримує медіа-сегменти з плагінів з підтримкою MediaSegment.",
"CleanupUserDataTask": "Завдання очищення даних користувача",
- "CleanupUserDataTaskDescription": "Очищає всі дані користувача (стан перегляду, статус обраного тощо) з медіа, які перестали бути доступними щонайменше 90 днів тому."
+ "CleanupUserDataTaskDescription": "Очищає всі дані користувача (стан перегляду, статус обраного тощо) з медіа, які перестали бути доступними щонайменше 90 днів тому.",
+ "Original": "Оригінал"
}
diff --git a/Emby.Server.Implementations/Localization/Core/vi.json b/Emby.Server.Implementations/Localization/Core/vi.json
index 947a2c80de..831ec62fb3 100644
--- a/Emby.Server.Implementations/Localization/Core/vi.json
+++ b/Emby.Server.Implementations/Localization/Core/vi.json
@@ -135,5 +135,6 @@
"TaskMoveTrickplayImagesDescription": "Di chuyển các tập tin trickplay hiện có theo cài đặt thư viện.",
"TaskExtractMediaSegments": "Quét Phân Đoạn Phương Tiện",
"CleanupUserDataTask": "Tác vụ dọn dẹp dữ liệu người dùng",
- "CleanupUserDataTaskDescription": "Làm sạch tất cả dữ liệu người dùng (trạng thái xem, trạng thái yêu thích, v.v.) từ phương tiện không còn có mặt trong ít nhất 90 ngày."
+ "CleanupUserDataTaskDescription": "Làm sạch tất cả dữ liệu người dùng (trạng thái xem, trạng thái yêu thích, v.v.) từ phương tiện không còn có mặt trong ít nhất 90 ngày.",
+ "Original": "Gốc"
}
diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs
index 6fca5bc1ba..d8797e612b 100644
--- a/Emby.Server.Implementations/Localization/LocalizationManager.cs
+++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs
@@ -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,33 +353,68 @@ 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());
- }
+ return result;
}
- // Handle prefix country code to handle "DE-18"
- if (rating.Contains('-', StringComparison.OrdinalIgnoreCase))
+ return null;
+ }
+
+ private bool TryGetRatingScoreBySeparator(string rating, char separator, out ParentalRatingScore? result)
+ {
+ result = null;
+
+ if (rating.IndexOf(separator, StringComparison.Ordinal) < 0)
{
- var ratingSpan = rating.AsSpan();
+ return false;
+ }
- // Extract culture from country prefix
- var culture = FindLanguageInfo(ratingSpan.LeftPart('-').ToString());
+ var ratingSpan = rating.AsSpan();
+ var countryPart = ratingSpan.LeftPart(separator).Trim().ToString();
+ var ratingPart = ratingSpan.RightPart(separator).Trim().ToString();
+ if (ratingPart.Length == 0)
+ {
+ return false;
+ }
- var ratingLevelRightPart = ratingSpan.RightPart('-');
- if (ratingLevelRightPart.Length != 0)
+ string? resolvedCountryCode = null;
+
+ if (_allParentalRatings.ContainsKey(countryPart))
+ {
+ resolvedCountryCode = countryPart;
+ }
+ else
+ {
+ var culture = FindLanguageInfo(countryPart);
+ if (culture is not null)
{
- // Check rating system of culture
- return GetRatingScore(ratingLevelRightPart.ToString(), culture?.TwoLetterISOLanguageName);
+ resolvedCountryCode = culture.TwoLetterISOLanguageName;
}
}
- return null;
+ 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 />
diff --git a/Emby.Server.Implementations/Localization/Ratings/ca.json b/Emby.Server.Implementations/Localization/Ratings/ca.json
index fa43a8f2b7..76550b64c3 100644
--- a/Emby.Server.Implementations/Localization/Ratings/ca.json
+++ b/Emby.Server.Implementations/Localization/Ratings/ca.json
@@ -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
@@ -24,13 +24,20 @@
}
},
{
- "ratingStrings": ["PG", "TV-PG"],
+ "ratingStrings": ["C8"],
"ratingScore": {
- "score": 9,
+ "score": 8,
"subScore": 0
}
},
{
+ "ratingStrings": ["PG", "TV-PG"],
+ "ratingScore": {
+ "score": 8,
+ "subScore": 1
+ }
+ },
+ {
"ratingStrings": ["14A"],
"ratingScore": {
"score": 14,
@@ -38,7 +45,7 @@
}
},
{
- "ratingStrings": ["TV-14"],
+ "ratingStrings": ["14+", "TV-14"],
"ratingScore": {
"score": 14,
"subScore": 1
diff --git a/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs b/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs
index aa5fbbdf73..5c9a94cd36 100644
--- a/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs
+++ b/Emby.Server.Implementations/Serialization/MyXmlSerializer.cs
@@ -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;
}
}
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index e2ddf86c7a..1782b53e10 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -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);
+ 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 session = GetSession(info.SessionId);
-
- session.StopAutomaticProgress();
-
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();
diff --git a/Jellyfin.Api/Controllers/ArtistsController.cs b/Jellyfin.Api/Controllers/ArtistsController.cs
index f97ab414ce..f19ca77818 100644
--- a/Jellyfin.Api/Controllers/ArtistsController.cs
+++ b/Jellyfin.Api/Controllers/ArtistsController.cs
@@ -87,6 +87,7 @@ public class ArtistsController : BaseJellyfinApiController
/// <returns>An <see cref="OkResult"/> containing the artists.</returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [Obsolete("Use GetPersons")]
public ActionResult<QueryResult<BaseItemDto>> GetArtists(
[FromQuery] double? minCommunityRating,
[FromQuery] int? startIndex,
@@ -258,6 +259,7 @@ public class ArtistsController : BaseJellyfinApiController
/// <returns>An <see cref="OkResult"/> containing the album artists.</returns>
[HttpGet("AlbumArtists")]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [Obsolete("Use GetPersons")]
public ActionResult<QueryResult<BaseItemDto>> GetAlbumArtists(
[FromQuery] double? minCommunityRating,
[FromQuery] int? startIndex,
@@ -399,6 +401,7 @@ public class ArtistsController : BaseJellyfinApiController
/// <returns>An <see cref="OkResult"/> containing the artist.</returns>
[HttpGet("{name}")]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [Obsolete("Use GetPerson")]
public ActionResult<BaseItemDto> GetArtistByName([FromRoute, Required] string name, [FromQuery] Guid? userId)
{
userId = RequestHelpers.GetUserId(User, userId);
diff --git a/Jellyfin.Api/Controllers/DashboardController.cs b/Jellyfin.Api/Controllers/DashboardController.cs
index ee912a9be8..b9958867e7 100644
--- a/Jellyfin.Api/Controllers/DashboardController.cs
+++ b/Jellyfin.Api/Controllers/DashboardController.cs
@@ -20,6 +20,7 @@ namespace Jellyfin.Api.Controllers;
/// The dashboard controller.
/// </summary>
[Route("")]
+[Tags("Plugin")]
public class DashboardController : BaseJellyfinApiController
{
private readonly ILogger<DashboardController> _logger;
diff --git a/Jellyfin.Api/Controllers/InstantMixController.cs b/Jellyfin.Api/Controllers/InstantMixController.cs
index f80d32d149..8cd79645a8 100644
--- a/Jellyfin.Api/Controllers/InstantMixController.cs
+++ b/Jellyfin.Api/Controllers/InstantMixController.cs
@@ -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,
diff --git a/Jellyfin.Api/Controllers/ItemRefreshController.cs b/Jellyfin.Api/Controllers/ItemRefreshController.cs
index 7effe61e49..5fc4ad88b6 100644
--- a/Jellyfin.Api/Controllers/ItemRefreshController.cs
+++ b/Jellyfin.Api/Controllers/ItemRefreshController.cs
@@ -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;
diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs
index 4faec060d8..4d697ab854 100644
--- a/Jellyfin.Api/Controllers/ItemUpdateController.cs
+++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs
@@ -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;
diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs
index 53656186c8..e6ba4e7f29 100644
--- a/Jellyfin.Api/Controllers/ItemsController.cs
+++ b/Jellyfin.Api/Controllers/ItemsController.cs
@@ -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,
diff --git a/Jellyfin.Api/Controllers/PlaystateController.cs b/Jellyfin.Api/Controllers/PlaystateController.cs
index aa22bdf6af..4ff1eef413 100644
--- a/Jellyfin.Api/Controllers/PlaystateController.cs
+++ b/Jellyfin.Api/Controllers/PlaystateController.cs
@@ -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)
diff --git a/Jellyfin.Api/Controllers/SessionController.cs b/Jellyfin.Api/Controllers/SessionController.cs
index 9886d03dee..a144961d74 100644
--- a/Jellyfin.Api/Controllers/SessionController.cs
+++ b/Jellyfin.Api/Controllers/SessionController.cs
@@ -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()
diff --git a/Jellyfin.Api/Controllers/StartupController.cs b/Jellyfin.Api/Controllers/StartupController.cs
index 09f20558fe..4373a46adc 100644
--- a/Jellyfin.Api/Controllers/StartupController.cs
+++ b/Jellyfin.Api/Controllers/StartupController.cs
@@ -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();
diff --git a/Jellyfin.Api/Controllers/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs
index 536b95dbb5..657bda4d15 100644
--- a/Jellyfin.Api/Controllers/UserController.cs
+++ b/Jellyfin.Api/Controllers/UserController.cs
@@ -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)
{
diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs
index b908f92be6..779186942a 100644
--- a/Jellyfin.Api/Controllers/UserLibraryController.cs
+++ b/Jellyfin.Api/Controllers/UserLibraryController.cs
@@ -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,
diff --git a/Jellyfin.Data/Jellyfin.Data.csproj b/Jellyfin.Data/Jellyfin.Data.csproj
index f7660f35dd..c8983480c7 100644
--- a/Jellyfin.Data/Jellyfin.Data.csproj
+++ b/Jellyfin.Data/Jellyfin.Data.csproj
@@ -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>
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs
index 67a233c41d..736388e9eb 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs
@@ -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;
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
index 380c6e582c..e4fd3204e1 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.ByName.cs
@@ -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();
}
+ var query = ApplyNavigations(
+ context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => representativeIds.Contains(e.Id)),
+ filter);
+
+ query = ApplyOrder(query, filter, context);
+
if (filter.StartIndex.HasValue && filter.StartIndex.Value > 0)
{
- orderedMasterQuery = orderedMasterQuery.Skip(filter.StartIndex.Value);
+ query = query.Skip(filter.StartIndex.Value);
}
if (filter.Limit.HasValue)
{
- orderedMasterQuery = orderedMasterQuery.Take(filter.Limit.Value);
+ query = query.Take(filter.Limit.Value);
}
- var masterIds = orderedMasterQuery.ToList();
-
- var query = ApplyNavigations(
- context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => masterIds.Contains(e.Id)),
- filter);
-
- query = ApplyOrder(query, filter, context);
-
+ 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),
+ });
+ }
}
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
index d516bc0d13..dc16c3b1b3 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs
@@ -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)
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
index 0abe981af8..59e61cfd65 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs
@@ -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
{
diff --git a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs
index 415510b2f4..9e11b6be62 100644
--- a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs
+++ b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs
@@ -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)
diff --git a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs
index 64874ccad7..dd0446f49a 100644
--- a/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/MediaStreamRepository.cs
@@ -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,
diff --git a/Jellyfin.Server.Implementations/Item/NextUpService.cs b/Jellyfin.Server.Implementations/Item/NextUpService.cs
index d78e246691..725b4cfaac 100644
--- a/Jellyfin.Server.Implementations/Item/NextUpService.cs
+++ b/Jellyfin.Server.Implementations/Item/NextUpService.cs
@@ -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))
diff --git a/Jellyfin.Server.Implementations/Item/OrderMapper.cs b/Jellyfin.Server.Implementations/Item/OrderMapper.cs
index ada86c8b87..d327b218a9 100644
--- a/Jellyfin.Server.Implementations/Item/OrderMapper.cs
+++ b/Jellyfin.Server.Implementations/Item/OrderMapper.cs
@@ -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,
diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
index cfc4eb2162..8f8741d00f 100644
--- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs
@@ -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,24 +103,23 @@ 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)
.ToArray();
var toAdd = people
- .Where(e => e.Type is not PersonKind.Artist && e.Type is not PersonKind.AlbumArtist)
- .Where(e => !existingPersons.Any(f => f.Name == e.Name && f.PersonType == e.Type.ToString()))
+ .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();
@@ -124,13 +132,8 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
foreach (var person in people)
{
- if (person.Type == PersonKind.Artist || person.Type == PersonKind.AlbumArtist)
- {
- continue;
- }
-
- var entityPerson = personsEntities.First(e => e.Name == person.Name && e.PersonType == person.Type.ToString());
- var existingMap = existingMaps.FirstOrDefault(e => e.People.Name == person.Name && e.People.PersonType == person.Type.ToString() && e.Role == person.Role);
+ 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 +234,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())
diff --git a/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs b/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs
index c514735688..249df476a0 100644
--- a/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs
+++ b/Jellyfin.Server.Implementations/MediaSegments/MediaSegmentManager.cs
@@ -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)
diff --git a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
index 63319831e1..0791e04e85 100644
--- a/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
+++ b/Jellyfin.Server.Implementations/Trickplay/TrickplayManager.cs
@@ -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)
{
diff --git a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs
index 49a9fda943..7371545914 100644
--- a/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs
+++ b/Jellyfin.Server.Implementations/Users/DefaultPasswordResetProvider.cs
@@ -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);
}
diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs
index 7292e9c7a9..8c0cbbd448 100644
--- a/Jellyfin.Server.Implementations/Users/UserManager.cs
+++ b/Jellyfin.Server.Implementations/Users/UserManager.cs
@@ -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(user).ChangePassword(user, newPassword).ConfigureAwait(false);
- await UpdateUserAsync(user).ConfigureAwait(false);
+ await GetAuthenticationProvider(dbUser).ChangePassword(dbUser, newPassword).ConfigureAwait(false);
+ await dbContext.SaveChangesAsync().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 (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))
+ {
+ user.AuthenticationProviderId = providerId;
+ await UpdateUserInternalAsync(user).ConfigureAwait(false);
+ }
+ }
- if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase))
+ if (user is null)
{
- user.AuthenticationProviderId = providerId;
- await UpdateUserAsync(user).ConfigureAwait(false);
+ _logger.LogInformation(
+ "Authentication request for {UserName} has been denied (IP: {IP}).",
+ username,
+ remoteEndPoint);
+ throw new AuthenticationException("Invalid username or password entered.");
}
- }
- 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.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.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.");
+ }
- 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;
+ }
- // Update LastActivityDate and LastLoginDate, then save
- if (success)
- {
- if (isUserSession)
+ user.InvalidLoginAttemptCount = 0;
+ await UpdateUserInternalAsync(user).ConfigureAwait(false);
+ _logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username);
+ }
+ else
{
- user.LastActivityDate = user.LastLoginDate = DateTime.UtcNow;
+ await IncrementInvalidLoginAttemptCount(user).ConfigureAwait(false);
+ _logger.LogInformation(
+ "Authentication request for {UserName} has been denied (IP: {IP}).",
+ user.Username,
+ remoteEndPoint);
}
-
- 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);
}
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())
+ var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
+ await using (dbContext.ConfigureAwait(false))
{
- return;
- }
+ if (await dbContext.Users.AnyAsync().ConfigureAwait(false))
+ {
+ return;
+ }
- var defaultName = Environment.UserName;
- if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName))
- {
- defaultName = "MyJellyfinUser";
- }
+ var defaultName = Environment.UserName;
+ if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName))
+ {
+ defaultName = "MyJellyfinUser";
+ }
- _logger.LogWarning("No users, creating one with username {UserName}", defaultName);
+ _logger.LogWarning("No users, creating one with username {UserName}", defaultName);
- var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
- await using (dbContext.ConfigureAwait(false))
- {
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);
+ 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);
+ dbContext.Update(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);
-
- user.AccessSchedules.Clear();
- foreach (var policyAccessSchedule in policy.AccessSchedules)
- {
- user.AccessSchedules.Add(policyAccessSchedule);
- }
+ // 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.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);
- _users[user.Id] = 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);
+ 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();
+ }
+ }
}
}
diff --git a/Jellyfin.Server/Configuration/StartupMode.cs b/Jellyfin.Server/Configuration/StartupMode.cs
new file mode 100644
index 0000000000..e1d18f1dd6
--- /dev/null
+++ b/Jellyfin.Server/Configuration/StartupMode.cs
@@ -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
+}
diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
index 188d3c4a9a..d664b718bc 100644
--- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
+++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs
@@ -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);
diff --git a/Jellyfin.Server/Migrations/Routines/DisableLegacyAuthorization.cs b/Jellyfin.Server/Migrations/Routines/DisableLegacyAuthorization.cs
deleted file mode 100644
index 6edfcbcfd5..0000000000
--- a/Jellyfin.Server/Migrations/Routines/DisableLegacyAuthorization.cs
+++ /dev/null
@@ -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;
- }
-}
diff --git a/Jellyfin.Server/Migrations/Routines/MergeDuplicateMusicArtists.cs b/Jellyfin.Server/Migrations/Routines/MergeDuplicateMusicArtists.cs
new file mode 100644
index 0000000000..f598848465
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/MergeDuplicateMusicArtists.cs
@@ -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);
+ }
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/MergeDuplicatePeople.cs b/Jellyfin.Server/Migrations/Routines/MergeDuplicatePeople.cs
new file mode 100644
index 0000000000..d092555139
--- /dev/null
+++ b/Jellyfin.Server/Migrations/Routines/MergeDuplicatePeople.cs
@@ -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);
+ }
+}
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs b/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs
index 14ae535531..74f03f5107 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateLinkedChildren.cs
@@ -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)
diff --git a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs
index 2a6db01cf3..ed92c34aa3 100644
--- a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs
+++ b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs
@@ -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
diff --git a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs b/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs
index fbf9c16377..cfc1628782 100644
--- a/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs
+++ b/Jellyfin.Server/Migrations/Routines/MoveExtractedFiles.cs
@@ -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);
diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs
index 93ba672535..af0d424aad 100644
--- a/Jellyfin.Server/Program.cs
+++ b/Jellyfin.Server/Program.cs
@@ -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);
}
diff --git a/Jellyfin.Server/StartupOptions.cs b/Jellyfin.Server/StartupOptions.cs
index 4890ccbb2e..4716bc1746 100644
--- a/Jellyfin.Server/StartupOptions.cs
+++ b/Jellyfin.Server/StartupOptions.cs
@@ -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
@@ -80,6 +81,13 @@ namespace Jellyfin.Server
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>
/// <returns>The configuration dictionary.</returns>
diff --git a/MediaBrowser.Common/MediaBrowser.Common.csproj b/MediaBrowser.Common/MediaBrowser.Common.csproj
index c128c2b6bb..de07c7f2cb 100644
--- a/MediaBrowser.Common/MediaBrowser.Common.csproj
+++ b/MediaBrowser.Common/MediaBrowser.Common.csproj
@@ -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>
diff --git a/MediaBrowser.Common/Net/NetworkUtils.cs b/MediaBrowser.Common/Net/NetworkUtils.cs
index 5c854b39d5..71539b8b78 100644
--- a/MediaBrowser.Common/Net/NetworkUtils.cs
+++ b/MediaBrowser.Common/Net/NetworkUtils.cs
@@ -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.
diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs
index 822b21c062..4cdcaabbb1 100644
--- a/MediaBrowser.Controller/Entities/BaseItem.cs
+++ b/MediaBrowser.Controller/Entities/BaseItem.cs
@@ -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>
diff --git a/MediaBrowser.Controller/IO/IPathManager.cs b/MediaBrowser.Controller/IO/IPathManager.cs
index eb67437545..30961c7610 100644
--- a/MediaBrowser.Controller/IO/IPathManager.cs
+++ b/MediaBrowser.Controller/IO/IPathManager.cs
@@ -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.
diff --git a/MediaBrowser.Controller/Library/IUserManager.cs b/MediaBrowser.Controller/Library/IUserManager.cs
index 0109cf4b7d..e2b54ea7a7 100644
--- a/MediaBrowser.Controller/Library/IUserManager.cs
+++ b/MediaBrowser.Controller/Library/IUserManager.cs
@@ -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.
@@ -48,6 +48,12 @@ namespace MediaBrowser.Controller.Library
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>
/// <param name="name">The name.</param>
@@ -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.
diff --git a/MediaBrowser.Controller/MediaBrowser.Controller.csproj b/MediaBrowser.Controller/MediaBrowser.Controller.csproj
index 0025080cc9..06188ad511 100644
--- a/MediaBrowser.Controller/MediaBrowser.Controller.csproj
+++ b/MediaBrowser.Controller/MediaBrowser.Controller.csproj
@@ -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>
diff --git a/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs b/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs
index 10f2f04af6..34826982af 100644
--- a/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs
+++ b/MediaBrowser.Controller/MediaEncoding/BaseEncodingJobOptions.cs
@@ -92,6 +92,12 @@ namespace MediaBrowser.Controller.MediaEncoding
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>
/// <value>The framerate.</value>
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
index a0e04eae63..65f6b79656 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs
@@ -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))
diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
index 7d0384ef27..3a1897a244 100644
--- a/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
+++ b/MediaBrowser.Controller/MediaEncoding/EncodingJobInfo.cs
@@ -571,62 +571,50 @@ namespace MediaBrowser.Controller.MediaEncoding
public string[] GetRequestedProfiles(string codec)
{
- if (!string.IsNullOrEmpty(BaseRequest.Profile))
- {
- return BaseRequest.Profile.Split(_separators, StringSplitOptions.RemoveEmptyEntries);
- }
+ var profile = BaseRequest.Profile;
- if (!string.IsNullOrEmpty(codec))
+ if (string.IsNullOrEmpty(profile) && !string.IsNullOrEmpty(codec))
{
- var profile = BaseRequest.GetOption(codec, "profile");
-
- if (!string.IsNullOrEmpty(profile))
- {
- return profile.Split(_separators, StringSplitOptions.RemoveEmptyEntries);
- }
+ profile = BaseRequest.GetOption(codec, "profile");
}
- return Array.Empty<string>();
+ return (profile ?? string.Empty).Split(_separators, StringSplitOptions.RemoveEmptyEntries);
}
public string[] GetRequestedRangeTypes(string codec)
{
- if (!string.IsNullOrEmpty(BaseRequest.VideoRangeType))
- {
- return BaseRequest.VideoRangeType.Split(_separators, StringSplitOptions.RemoveEmptyEntries);
- }
+ var rangetype = BaseRequest.VideoRangeType;
- if (!string.IsNullOrEmpty(codec))
+ if (string.IsNullOrEmpty(rangetype) && !string.IsNullOrEmpty(codec))
{
- var rangetype = BaseRequest.GetOption(codec, "rangetype");
-
- if (!string.IsNullOrEmpty(rangetype))
- {
- return rangetype.Split(_separators, StringSplitOptions.RemoveEmptyEntries);
- }
+ rangetype = BaseRequest.GetOption(codec, "rangetype");
}
- 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)
diff --git a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs
index 1e0d77fe51..6b1eac8047 100644
--- a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs
+++ b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs
@@ -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)
@@ -57,6 +52,11 @@ namespace MediaBrowser.Controller.Net
}
/// <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>
/// <value>The type.</value>
diff --git a/MediaBrowser.Controller/Providers/DirectoryService.cs b/MediaBrowser.Controller/Providers/DirectoryService.cs
index 5b5af75a47..6060d051a5 100644
--- a/MediaBrowser.Controller/Providers/DirectoryService.cs
+++ b/MediaBrowser.Controller/Providers/DirectoryService.cs
@@ -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;
}
diff --git a/MediaBrowser.Controller/Providers/IDirectoryService.cs b/MediaBrowser.Controller/Providers/IDirectoryService.cs
index 1babf73af8..8a3fa33da3 100644
--- a/MediaBrowser.Controller/Providers/IDirectoryService.cs
+++ b/MediaBrowser.Controller/Providers/IDirectoryService.cs
@@ -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);
}
diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
index f7a1581a76..7f40f4fd3e 100644
--- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
+++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs
@@ -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(
diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
index a4d17e4f9d..06060988e2 100644
--- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
+++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs
@@ -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))
diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
index 894d0a3574..8ad66fce40 100644
--- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
+++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs
@@ -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);
diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs
index ac5c12304e..a58c01c960 100644
--- a/MediaBrowser.Model/Configuration/ServerConfiguration.cs
+++ b/MediaBrowser.Model/Configuration/ServerConfiguration.cs
@@ -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;
}
diff --git a/MediaBrowser.Model/Dlna/ConditionProcessor.cs b/MediaBrowser.Model/Dlna/ConditionProcessor.cs
index 79ee683a2d..a6018f369d 100644
--- a/MediaBrowser.Model/Dlna/ConditionProcessor.cs
+++ b/MediaBrowser.Model/Dlna/ConditionProcessor.cs
@@ -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;
}
diff --git a/MediaBrowser.Model/Dlna/ProfileConditionValue.cs b/MediaBrowser.Model/Dlna/ProfileConditionValue.cs
index b66a15840b..c6171c7ab2 100644
--- a/MediaBrowser.Model/Dlna/ProfileConditionValue.cs
+++ b/MediaBrowser.Model/Dlna/ProfileConditionValue.cs
@@ -28,6 +28,7 @@ namespace MediaBrowser.Model.Dlna
AudioSampleRate = 22,
AudioBitDepth = 23,
VideoRangeType = 24,
- NumStreams = 25
+ NumStreams = 25,
+ VideoRotation = 26
}
}
diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs
index c9697c685c..44697837ca 100644
--- a/MediaBrowser.Model/Dlna/StreamBuilder.cs
+++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs
@@ -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>
diff --git a/MediaBrowser.Model/Drawing/ImageDimensions.cs b/MediaBrowser.Model/Drawing/ImageDimensions.cs
index f84fe68305..49528ef8ae 100644
--- a/MediaBrowser.Model/Drawing/ImageDimensions.cs
+++ b/MediaBrowser.Model/Drawing/ImageDimensions.cs
@@ -1,4 +1,5 @@
#pragma warning disable CS1591
+#pragma warning disable CA1815
using System.Globalization;
diff --git a/MediaBrowser.Model/Dto/BaseItemDto.cs b/MediaBrowser.Model/Dto/BaseItemDto.cs
index e96bba0464..062034327e 100644
--- a/MediaBrowser.Model/Dto/BaseItemDto.cs
+++ b/MediaBrowser.Model/Dto/BaseItemDto.cs
@@ -800,5 +800,7 @@ namespace MediaBrowser.Model.Dto
/// </summary>
/// <value>The current program.</value>
public BaseItemDto CurrentProgram { get; set; }
+
+ public string OriginalLanguage { get; set; }
}
}
diff --git a/MediaBrowser.Model/Entities/MediaStream.cs b/MediaBrowser.Model/Entities/MediaStream.cs
index 4491fb5ace..dad4a6e149 100644
--- a/MediaBrowser.Model/Entities/MediaStream.cs
+++ b/MediaBrowser.Model/Entities/MediaStream.cs
@@ -260,6 +260,8 @@ namespace MediaBrowser.Model.Entities
public string LocalizedLanguage { get; set; }
+ public string LocalizedOriginal { get; set; }
+
public string DisplayTitle
{
get
@@ -267,162 +269,167 @@ namespace MediaBrowser.Model.Entities
switch (Type)
{
case MediaStreamType.Audio:
- {
- 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));
- }
+ var attributes = new List<string>();
- if (!string.IsNullOrEmpty(Profile) && !string.Equals(Profile, "lc", StringComparison.OrdinalIgnoreCase))
- {
- attributes.Add(Profile);
- }
- else if (!string.IsNullOrEmpty(Codec))
- {
- attributes.Add(AudioCodec.GetFriendlyName(Codec));
- }
+ // 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(ChannelLayout))
- {
- attributes.Add(StringHelper.FirstToUpper(ChannelLayout));
- }
- else if (Channels.HasValue)
- {
- attributes.Add(Channels.Value.ToString(CultureInfo.InvariantCulture) + " ch");
- }
+ if (!string.IsNullOrEmpty(Profile) && !string.Equals(Profile, "lc", StringComparison.OrdinalIgnoreCase))
+ {
+ attributes.Add(Profile);
+ }
+ else if (!string.IsNullOrEmpty(Codec))
+ {
+ attributes.Add(AudioCodec.GetFriendlyName(Codec));
+ }
- if (IsDefault)
- {
- attributes.Add(string.IsNullOrEmpty(LocalizedDefault) ? "Default" : LocalizedDefault);
- }
+ if (!string.IsNullOrEmpty(ChannelLayout))
+ {
+ attributes.Add(StringHelper.FirstToUpper(ChannelLayout));
+ }
+ else if (Channels.HasValue)
+ {
+ attributes.Add(Channels.Value.ToString(CultureInfo.InvariantCulture) + " ch");
+ }
- if (IsExternal)
- {
- attributes.Add(string.IsNullOrEmpty(LocalizedExternal) ? "External" : LocalizedExternal);
- }
+ if (IsDefault)
+ {
+ attributes.Add(string.IsNullOrEmpty(LocalizedDefault) ? "Default" : LocalizedDefault);
+ }
- if (!string.IsNullOrEmpty(Title))
- {
- var result = new StringBuilder(Title);
- foreach (var tag in attributes)
+ if (IsExternal)
+ {
+ attributes.Add(string.IsNullOrEmpty(LocalizedExternal) ? "External" : LocalizedExternal);
+ }
+
+ if (IsOriginal)
+ {
+ attributes.Add(string.IsNullOrEmpty(LocalizedOriginal) ? "Original" : LocalizedOriginal);
+ }
+
+ if (!string.IsNullOrEmpty(Title))
{
- // Keep Tags that are not already in Title.
- if (!Title.Contains(tag, StringComparison.OrdinalIgnoreCase))
+ var result = new StringBuilder(Title);
+ foreach (var tag in attributes)
{
- result.Append(" - ").Append(tag);
+ // Keep Tags that are not already in Title.
+ if (!Title.Contains(tag, StringComparison.OrdinalIgnoreCase))
+ {
+ result.Append(" - ").Append(tag);
+ }
}
+
+ return result.ToString();
}
- return result.ToString();
+ return string.Join(" - ", attributes);
}
- return string.Join(" - ", attributes);
- }
-
case MediaStreamType.Video:
- {
- var attributes = new List<string>();
+ {
+ var attributes = new List<string>();
- var resolutionText = GetResolutionText();
+ var resolutionText = GetResolutionText();
- if (!string.IsNullOrEmpty(resolutionText))
- {
- attributes.Add(resolutionText);
- }
+ if (!string.IsNullOrEmpty(resolutionText))
+ {
+ attributes.Add(resolutionText);
+ }
- if (!string.IsNullOrEmpty(Codec))
- {
- attributes.Add(Codec.ToUpperInvariant());
- }
+ if (!string.IsNullOrEmpty(Codec))
+ {
+ attributes.Add(Codec.ToUpperInvariant());
+ }
- if (VideoDoViTitle is not null)
- {
- attributes.Add(VideoDoViTitle);
- }
- else if (VideoRange != VideoRange.Unknown)
- {
- attributes.Add(VideoRange.ToString());
- }
+ 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(Title))
{
- // Keep Tags that are not already in Title.
- if (!Title.Contains(tag, StringComparison.OrdinalIgnoreCase))
+ var result = new StringBuilder(Title);
+ foreach (var tag in attributes)
{
- result.Append(" - ").Append(tag);
+ // Keep Tags that are not already in Title.
+ if (!Title.Contains(tag, StringComparison.OrdinalIgnoreCase))
+ {
+ result.Append(" - ").Append(tag);
+ }
}
+
+ return result.ToString();
}
- return result.ToString();
+ return string.Join(' ', attributes);
}
- return string.Join(' ', attributes);
- }
-
case MediaStreamType.Subtitle:
- {
- 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);
- }
+ var attributes = new List<string>();
- if (IsHearingImpaired == true)
- {
- attributes.Add(string.IsNullOrEmpty(LocalizedHearingImpaired) ? "Hearing Impaired" : LocalizedHearingImpaired);
- }
+ 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 (IsDefault)
- {
- attributes.Add(string.IsNullOrEmpty(LocalizedDefault) ? "Default" : LocalizedDefault);
- }
+ if (IsHearingImpaired == true)
+ {
+ attributes.Add(string.IsNullOrEmpty(LocalizedHearingImpaired) ? "Hearing Impaired" : LocalizedHearingImpaired);
+ }
- if (IsForced)
- {
- attributes.Add(string.IsNullOrEmpty(LocalizedForced) ? "Forced" : LocalizedForced);
- }
+ if (IsDefault)
+ {
+ attributes.Add(string.IsNullOrEmpty(LocalizedDefault) ? "Default" : LocalizedDefault);
+ }
- if (!string.IsNullOrEmpty(Codec))
- {
- attributes.Add(Codec.ToUpperInvariant());
- }
+ if (IsForced)
+ {
+ attributes.Add(string.IsNullOrEmpty(LocalizedForced) ? "Forced" : LocalizedForced);
+ }
- if (IsExternal)
- {
- attributes.Add(string.IsNullOrEmpty(LocalizedExternal) ? "External" : LocalizedExternal);
- }
+ if (!string.IsNullOrEmpty(Codec))
+ {
+ attributes.Add(Codec.ToUpperInvariant());
+ }
- if (!string.IsNullOrEmpty(Title))
- {
- var result = new StringBuilder(Title);
- foreach (var tag in attributes)
+ if (IsExternal)
+ {
+ attributes.Add(string.IsNullOrEmpty(LocalizedExternal) ? "External" : LocalizedExternal);
+ }
+
+ if (!string.IsNullOrEmpty(Title))
{
- // Keep Tags that are not already in Title.
- if (!Title.Contains(tag, StringComparison.OrdinalIgnoreCase))
+ var result = new StringBuilder(Title);
+ foreach (var tag in attributes)
{
- result.Append(" - ").Append(tag);
+ // Keep Tags that are not already in Title.
+ if (!Title.Contains(tag, StringComparison.OrdinalIgnoreCase))
+ {
+ result.Append(" - ").Append(tag);
+ }
}
+
+ return result.ToString();
}
- return result.ToString();
+ return string.Join(" - ", attributes);
}
- return string.Join(" - ", attributes);
- }
-
default:
return null;
}
@@ -500,6 +507,12 @@ namespace MediaBrowser.Model.Entities
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>
/// <value>The height.</value>
diff --git a/MediaBrowser.Model/MediaBrowser.Model.csproj b/MediaBrowser.Model/MediaBrowser.Model.csproj
index c655c4ccb3..2dddd39ef4 100644
--- a/MediaBrowser.Model/MediaBrowser.Model.csproj
+++ b/MediaBrowser.Model/MediaBrowser.Model.csproj
@@ -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>
diff --git a/MediaBrowser.Model/Session/TranscodeReason.cs b/MediaBrowser.Model/Session/TranscodeReason.cs
index 902bab9a6e..4ea60f115a 100644
--- a/MediaBrowser.Model/Session/TranscodeReason.cs
+++ b/MediaBrowser.Model/Session/TranscodeReason.cs
@@ -24,6 +24,7 @@ namespace MediaBrowser.Model.Session
VideoResolutionNotSupported = 1 << 8,
VideoBitDepthNotSupported = 1 << 9,
VideoFramerateNotSupported = 1 << 10,
+ VideoRotationNotSupported = 1 << 27,
RefFramesNotSupported = 1 << 11,
AnamorphicVideoNotSupported = 1 << 12,
InterlacedVideoNotSupported = 1 << 13,
diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs
index abdfb1e3b7..c2e523cfaf 100644
--- a/MediaBrowser.Providers/Manager/MetadataService.cs
+++ b/MediaBrowser.Providers/Manager/MetadataService.cs
@@ -1023,6 +1023,11 @@ namespace MediaBrowser.Providers.Manager
target.OriginalTitle = source.OriginalTitle;
}
+ if (replaceData || string.IsNullOrEmpty(target.OriginalLanguage))
+ {
+ target.OriginalLanguage = source.OriginalLanguage;
+ }
+
if (replaceData || !target.CommunityRating.HasValue)
{
target.CommunityRating = source.CommunityRating;
diff --git a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs
index 0716cdfa01..6f9d5f19da 100644
--- a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs
+++ b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs
@@ -218,12 +218,12 @@ namespace MediaBrowser.Providers.MediaInfo
return Array.Empty<ExternalPathParserResult>();
}
- var files = directoryService.GetFilePaths(folder, clearCache, true).ToList();
+ var files = directoryService.GetFilePaths(folder, clearCache).ToList();
files.Remove(video.Path);
var internalMetadataPath = video.GetInternalMetadataPath();
if (_fileSystem.DirectoryExists(internalMetadataPath))
{
- files.AddRange(directoryService.GetFilePaths(internalMetadataPath, clearCache, true));
+ files.AddRange(directoryService.GetFilePaths(internalMetadataPath, clearCache));
}
if (files.Count == 0)
@@ -270,12 +270,12 @@ namespace MediaBrowser.Providers.MediaInfo
}
string folder = audio.ContainingFolderPath;
- var files = directoryService.GetFilePaths(folder, clearCache, true).ToList();
+ var files = directoryService.GetFilePaths(folder, clearCache).ToList();
files.Remove(audio.Path);
var internalMetadataPath = audio.GetInternalMetadataPath();
if (_fileSystem.DirectoryExists(internalMetadataPath))
{
- files.AddRange(directoryService.GetFilePaths(internalMetadataPath, clearCache, true));
+ files.AddRange(directoryService.GetFilePaths(internalMetadataPath, clearCache));
}
if (files.Count == 0)
diff --git a/MediaBrowser.Providers/Movies/ImdbExternalUrlProvider.cs b/MediaBrowser.Providers/Movies/ImdbExternalUrlProvider.cs
index 980bac102e..67cb85de69 100644
--- a/MediaBrowser.Providers/Movies/ImdbExternalUrlProvider.cs
+++ b/MediaBrowser.Providers/Movies/ImdbExternalUrlProvider.cs
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
@@ -17,6 +18,18 @@ public class ImdbExternalUrlProvider : IExternalUrlProvider
public IEnumerable<string> GetExternalUrls(BaseItem item)
{
var baseUrl = "https://www.imdb.com/";
+
+ if (item is Season season)
+ {
+ if (season.Series?.TryGetProviderId(MetadataProvider.Imdb, out var seriesImdbId) == true
+ && season.IndexNumber.HasValue)
+ {
+ yield return baseUrl + $"title/{seriesImdbId}/episodes/?season={season.IndexNumber.Value}";
+ }
+
+ yield break;
+ }
+
if (item.TryGetProviderId(MetadataProvider.Imdb, out var externalId))
{
if (item is Person)
diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs
index 88c8e4f7c9..715bdd9da4 100644
--- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs
+++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs
@@ -9,81 +9,41 @@ using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Providers;
using MediaBrowser.Providers.Music;
-using MediaBrowser.Providers.Plugins.MusicBrainz.Configuration;
using MetaBrainz.MusicBrainz;
using MetaBrainz.MusicBrainz.Interfaces.Entities;
using MetaBrainz.MusicBrainz.Interfaces.Searches;
-using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Plugins.MusicBrainz;
/// <summary>
/// Music album metadata provider for MusicBrainz.
/// </summary>
-public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, AlbumInfo>, IHasOrder, IDisposable
+public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, AlbumInfo>, IHasOrder
{
- private readonly ILogger<MusicBrainzAlbumProvider> _logger;
- private Query _musicBrainzQuery;
-
- /// <summary>
- /// Initializes a new instance of the <see cref="MusicBrainzAlbumProvider"/> class.
- /// </summary>
- /// <param name="logger">The logger.</param>
- public MusicBrainzAlbumProvider(ILogger<MusicBrainzAlbumProvider> logger)
- {
- _logger = logger;
- _musicBrainzQuery = new Query();
- ReloadConfig(null, MusicBrainz.Plugin.Instance!.Configuration);
- MusicBrainz.Plugin.Instance!.ConfigurationChanged += ReloadConfig;
- }
-
/// <inheritdoc />
public string Name => "MusicBrainz";
/// <inheritdoc />
public int Order => 0;
- private void ReloadConfig(object? sender, BasePluginConfiguration e)
- {
- var configuration = (PluginConfiguration)e;
- if (Uri.TryCreate(configuration.Server, UriKind.Absolute, out var server))
- {
- Query.DefaultServer = server.DnsSafeHost;
- Query.DefaultPort = server.Port;
- Query.DefaultUrlScheme = server.Scheme;
- }
- else
- {
- // Fallback to official server
- _logger.LogWarning("Invalid MusicBrainz server specified, falling back to official server");
- var defaultServer = new Uri(PluginConfiguration.DefaultServer);
- Query.DefaultServer = defaultServer.Host;
- Query.DefaultPort = defaultServer.Port;
- Query.DefaultUrlScheme = defaultServer.Scheme;
- }
-
- Query.DelayBetweenRequests = configuration.RateLimit;
- _musicBrainzQuery = new Query();
- }
-
/// <inheritdoc />
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(AlbumInfo searchInfo, CancellationToken cancellationToken)
{
+ var query = MusicBrainz.Plugin.Instance!.MusicBrainzQuery;
var releaseId = searchInfo.GetReleaseId();
var releaseGroupId = searchInfo.GetReleaseGroupId();
if (!string.IsNullOrEmpty(releaseId))
{
- var releaseResult = await _musicBrainzQuery.LookupReleaseAsync(new Guid(releaseId), Include.Artists | Include.ReleaseGroups, cancellationToken).ConfigureAwait(false);
+ var releaseResult = await query.LookupReleaseAsync(new Guid(releaseId), Include.Artists | Include.ReleaseGroups, cancellationToken).ConfigureAwait(false);
return GetReleaseResult(releaseResult).SingleItemAsEnumerable();
}
if (!string.IsNullOrEmpty(releaseGroupId))
{
- var releaseGroupResult = await _musicBrainzQuery.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.Releases, null, cancellationToken).ConfigureAwait(false);
+ var releaseGroupResult = await query.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.Releases, null, cancellationToken).ConfigureAwait(false);
// No need to pass the cancellation token to GetReleaseGroupResultAsync as we're already passing it to ToBlockingEnumerable
return GetReleaseGroupResultAsync(releaseGroupResult.Releases, CancellationToken.None).ToBlockingEnumerable(cancellationToken);
@@ -93,7 +53,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
if (!string.IsNullOrWhiteSpace(artistMusicBrainzId))
{
- var releaseSearchResults = await _musicBrainzQuery.FindReleasesAsync($"\"{searchInfo.Name}\" AND arid:{artistMusicBrainzId}", null, null, false, cancellationToken)
+ var releaseSearchResults = await query.FindReleasesAsync($"\"{searchInfo.Name}\" AND arid:{artistMusicBrainzId}", null, null, false, cancellationToken)
.ConfigureAwait(false);
if (releaseSearchResults.Results.Count > 0)
@@ -106,7 +66,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
// I'm sure there is a better way but for now it resolves search for 12" Mixes
var queryName = searchInfo.Name.Replace("\"", string.Empty, StringComparison.Ordinal);
- var releaseSearchResults = await _musicBrainzQuery.FindReleasesAsync($"\"{queryName}\" AND artist:\"{searchInfo.GetAlbumArtist()}\"c", null, null, false, cancellationToken)
+ var releaseSearchResults = await query.FindReleasesAsync($"\"{queryName}\" AND artist:\"{searchInfo.GetAlbumArtist()}\"c", null, null, false, cancellationToken)
.ConfigureAwait(false);
if (releaseSearchResults.Results.Count > 0)
@@ -138,10 +98,11 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
yield break;
}
+ var query = MusicBrainz.Plugin.Instance!.MusicBrainzQuery;
foreach (var result in releaseSearchResults)
{
// Fetch full release info, otherwise artists are missing
- var fullResult = await _musicBrainzQuery.LookupReleaseAsync(result.Id, Include.Artists | Include.ReleaseGroups, cancellationToken).ConfigureAwait(false);
+ var fullResult = await query.LookupReleaseAsync(result.Id, Include.Artists | Include.ReleaseGroups, cancellationToken).ConfigureAwait(false);
yield return GetReleaseResult(fullResult);
}
}
@@ -195,6 +156,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
public async Task<MetadataResult<MusicAlbum>> GetMetadata(AlbumInfo info, CancellationToken cancellationToken)
{
// TODO: This sets essentially nothing. As-is, it's mostly useless. Make it actually pull metadata and use it.
+ var query = MusicBrainz.Plugin.Instance!.MusicBrainzQuery;
var releaseId = info.GetReleaseId();
var releaseGroupId = info.GetReleaseGroupId();
@@ -207,7 +169,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
if (string.IsNullOrWhiteSpace(releaseId) && !string.IsNullOrWhiteSpace(releaseGroupId))
{
// TODO: Actually try to match the release. Simply taking the first result is stupid.
- var releaseGroup = await _musicBrainzQuery.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.None, null, cancellationToken).ConfigureAwait(false);
+ var releaseGroup = await query.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.None, null, cancellationToken).ConfigureAwait(false);
var release = releaseGroup.Releases?.Count > 0 ? releaseGroup.Releases[0] : null;
if (release is not null)
{
@@ -224,13 +186,13 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
if (!string.IsNullOrEmpty(artistMusicBrainzId))
{
- var releaseSearchResults = await _musicBrainzQuery.FindReleasesAsync($"\"{info.Name}\" AND arid:{artistMusicBrainzId}", null, null, false, cancellationToken)
+ var releaseSearchResults = await query.FindReleasesAsync($"\"{info.Name}\" AND arid:{artistMusicBrainzId}", null, null, false, cancellationToken)
.ConfigureAwait(false);
releaseResult = releaseSearchResults.Results.Count > 0 ? releaseSearchResults.Results[0].Item : null;
}
else if (!string.IsNullOrEmpty(info.GetAlbumArtist()))
{
- var releaseSearchResults = await _musicBrainzQuery.FindReleasesAsync($"\"{info.Name}\" AND artist:{info.GetAlbumArtist()}", null, null, false, cancellationToken)
+ var releaseSearchResults = await query.FindReleasesAsync($"\"{info.Name}\" AND artist:{info.GetAlbumArtist()}", null, null, false, cancellationToken)
.ConfigureAwait(false);
releaseResult = releaseSearchResults.Results.Count > 0 ? releaseSearchResults.Results[0].Item : null;
}
@@ -253,7 +215,7 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
// If we have a release ID but not a release group ID, lookup the release group
if (!string.IsNullOrWhiteSpace(releaseId) && string.IsNullOrWhiteSpace(releaseGroupId))
{
- var release = await _musicBrainzQuery.LookupReleaseAsync(new Guid(releaseId), Include.ReleaseGroups, cancellationToken).ConfigureAwait(false);
+ var release = await query.LookupReleaseAsync(new Guid(releaseId), Include.ReleaseGroups, cancellationToken).ConfigureAwait(false);
releaseGroupId = release.ReleaseGroup?.Id.ToString();
result.HasMetadata = true;
}
@@ -285,23 +247,4 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
{
throw new NotImplementedException();
}
-
- /// <inheritdoc />
- public void Dispose()
- {
- Dispose(true);
- GC.SuppressFinalize(this);
- }
-
- /// <summary>
- /// Dispose all resources.
- /// </summary>
- /// <param name="disposing">Whether to dispose.</param>
- protected virtual void Dispose(bool disposing)
- {
- if (disposing)
- {
- _musicBrainzQuery.Dispose();
- }
- }
}
diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs
index 9df21596c5..0fe4e6bb16 100644
--- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs
+++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs
@@ -8,37 +8,19 @@ using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
-using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Providers;
using MediaBrowser.Providers.Music;
-using MediaBrowser.Providers.Plugins.MusicBrainz.Configuration;
using MetaBrainz.MusicBrainz;
using MetaBrainz.MusicBrainz.Interfaces.Entities;
using MetaBrainz.MusicBrainz.Interfaces.Searches;
-using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Plugins.MusicBrainz;
/// <summary>
/// MusicBrainz artist provider.
/// </summary>
-public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, ArtistInfo>, IDisposable, IHasOrder
+public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, ArtistInfo>, IHasOrder
{
- private readonly ILogger<MusicBrainzArtistProvider> _logger;
- private Query _musicBrainzQuery;
-
- /// <summary>
- /// Initializes a new instance of the <see cref="MusicBrainzArtistProvider"/> class.
- /// </summary>
- /// <param name="logger">The logger.</param>
- public MusicBrainzArtistProvider(ILogger<MusicBrainzArtistProvider> logger)
- {
- _logger = logger;
- _musicBrainzQuery = new Query();
- ReloadConfig(null, MusicBrainz.Plugin.Instance!.Configuration);
- MusicBrainz.Plugin.Instance!.ConfigurationChanged += ReloadConfig;
- }
-
/// <inheritdoc />
public string Name => "MusicBrainz";
@@ -46,41 +28,19 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar
/// Runs first to populate the MusicBrainz artist ID used by downstream providers.
public int Order => 0;
- private void ReloadConfig(object? sender, BasePluginConfiguration e)
- {
- var configuration = (PluginConfiguration)e;
- if (Uri.TryCreate(configuration.Server, UriKind.Absolute, out var server))
- {
- Query.DefaultServer = server.DnsSafeHost;
- Query.DefaultPort = server.Port;
- Query.DefaultUrlScheme = server.Scheme;
- }
- else
- {
- // Fallback to official server
- _logger.LogWarning("Invalid MusicBrainz server specified, falling back to official server");
- var defaultServer = new Uri(PluginConfiguration.DefaultServer);
- Query.DefaultServer = defaultServer.Host;
- Query.DefaultPort = defaultServer.Port;
- Query.DefaultUrlScheme = defaultServer.Scheme;
- }
-
- Query.DelayBetweenRequests = configuration.RateLimit;
- _musicBrainzQuery = new Query();
- }
-
/// <inheritdoc />
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ArtistInfo searchInfo, CancellationToken cancellationToken)
{
+ var query = MusicBrainz.Plugin.Instance!.MusicBrainzQuery;
var artistId = searchInfo.GetMusicBrainzArtistId();
if (!string.IsNullOrWhiteSpace(artistId))
{
- var artistResult = await _musicBrainzQuery.LookupArtistAsync(new Guid(artistId), Include.Aliases, null, null, cancellationToken).ConfigureAwait(false);
+ var artistResult = await query.LookupArtistAsync(new Guid(artistId), Include.Aliases, null, null, cancellationToken).ConfigureAwait(false);
return GetResultFromResponse(artistResult).SingleItemAsEnumerable();
}
- var artistSearchResults = await _musicBrainzQuery.FindArtistsAsync($"\"{searchInfo.Name}\"", null, null, false, cancellationToken)
+ var artistSearchResults = await query.FindArtistsAsync($"\"{searchInfo.Name}\"", null, null, false, cancellationToken)
.ConfigureAwait(false);
if (artistSearchResults.Results.Count > 0)
{
@@ -90,7 +50,7 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar
if (searchInfo.Name.HasDiacritics())
{
// Try again using the search with an accented characters query
- var artistAccentsSearchResults = await _musicBrainzQuery.FindArtistsAsync($"artistaccent:\"{searchInfo.Name}\"", null, null, false, cancellationToken)
+ var artistAccentsSearchResults = await query.FindArtistsAsync($"artistaccent:\"{searchInfo.Name}\"", null, null, false, cancellationToken)
.ConfigureAwait(false);
if (artistAccentsSearchResults.Results.Count > 0)
{
@@ -168,23 +128,4 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar
{
throw new NotImplementedException();
}
-
- /// <inheritdoc />
- public void Dispose()
- {
- Dispose(true);
- GC.SuppressFinalize(this);
- }
-
- /// <summary>
- /// Dispose all resources.
- /// </summary>
- /// <param name="disposing">Whether to dispose.</param>
- protected virtual void Dispose(bool disposing)
- {
- if (disposing)
- {
- _musicBrainzQuery.Dispose();
- }
- }
}
diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs
index 39cfd727f3..69225d0b95 100644
--- a/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs
+++ b/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs
@@ -1,6 +1,8 @@
using System;
using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
using System.Net.Http.Headers;
+using System.Threading;
using MediaBrowser.Common;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
@@ -8,30 +10,42 @@ using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
using MediaBrowser.Providers.Plugins.MusicBrainz.Configuration;
using MetaBrainz.MusicBrainz;
+using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Plugins.MusicBrainz;
/// <summary>
/// Plugin instance.
/// </summary>
-public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
+public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages, IDisposable
{
+ private readonly ILogger<Plugin> _logger;
+ private readonly Lock _queryLock = new();
+ private Query _musicBrainzQuery;
+ private bool _disposed;
+
/// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class.
/// </summary>
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
/// <param name="applicationHost">Instance of the <see cref="IApplicationHost"/> interface.</param>
- public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, IApplicationHost applicationHost)
+ /// <param name="logger">Instance of the <see cref="ILogger{Plugin}"/> interface.</param>
+ public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, IApplicationHost applicationHost, ILogger<Plugin> logger)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
+ _logger = logger;
// TODO: Change this to "JellyfinMusicBrainzPlugin" once we take it out of the server repo.
Query.DefaultUserAgent.Add(new ProductInfoHeaderValue(applicationHost.Name.Replace(' ', '-'), applicationHost.ApplicationVersionString));
Query.DefaultUserAgent.Add(new ProductInfoHeaderValue($"({applicationHost.ApplicationUserAgentAddress})"));
- Query.DelayBetweenRequests = Instance.Configuration.RateLimit;
- Query.DefaultServer = Instance.Configuration.Server;
+
+ ApplyServerConfig(Configuration);
+ Query.DelayBetweenRequests = Configuration.RateLimit;
+ _musicBrainzQuery = new Query();
+
+ ConfigurationChanged += OnConfigurationChanged;
}
/// <summary>
@@ -52,6 +66,25 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
// TODO remove when plugin removed from server.
public override string ConfigurationFileName => "Jellyfin.Plugin.MusicBrainz.xml";
+ /// <summary>
+ /// Gets the current MusicBrainz query client.
+ /// </summary>
+ /// <remarks>
+ /// Always read this property anew before each request — the underlying instance is
+ /// replaced when the server URL changes. Old instances are intentionally left alive
+ /// so in-flight requests can finish; their unmanaged resources leak until GC.
+ /// </remarks>
+ public Query MusicBrainzQuery
+ {
+ get
+ {
+ lock (_queryLock)
+ {
+ return _musicBrainzQuery;
+ }
+ }
+ }
+
/// <inheritdoc />
public IEnumerable<PluginPageInfo> GetPages()
{
@@ -61,4 +94,65 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
EmbeddedResourcePath = GetType().Namespace + ".Configuration.config.html"
};
}
+
+ /// <inheritdoc />
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ /// <summary>
+ /// Releases unmanaged and managed resources.
+ /// </summary>
+ /// <param name="disposing">Whether to dispose managed resources.</param>
+ protected virtual void Dispose(bool disposing)
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ if (disposing)
+ {
+ ConfigurationChanged -= OnConfigurationChanged;
+ lock (_queryLock)
+ {
+ _musicBrainzQuery.Dispose();
+ }
+ }
+
+ _disposed = true;
+ }
+
+ [SuppressMessage("IDisposableAnalyzers.Correctness", "IDISP003:Dispose previous before re-assigning", Justification = "The previous Query may still be in use by in-flight async requests; disposing it would cause ObjectDisposedException. The orphan is intentionally left for GC.")]
+ private void OnConfigurationChanged(object? sender, BasePluginConfiguration e)
+ {
+ var configuration = (PluginConfiguration)e;
+ ApplyServerConfig(configuration);
+ Query.DelayBetweenRequests = configuration.RateLimit;
+
+ lock (_queryLock)
+ {
+ _musicBrainzQuery = new Query();
+ }
+ }
+
+ private void ApplyServerConfig(PluginConfiguration configuration)
+ {
+ if (Uri.TryCreate(configuration.Server, UriKind.Absolute, out var server))
+ {
+ Query.DefaultServer = server.DnsSafeHost;
+ Query.DefaultPort = server.Port;
+ Query.DefaultUrlScheme = server.Scheme;
+ }
+ else
+ {
+ _logger.LogWarning("Invalid MusicBrainz server specified, falling back to official server");
+ var defaultServer = new Uri(PluginConfiguration.DefaultServer);
+ Query.DefaultServer = defaultServer.Host;
+ Query.DefaultPort = defaultServer.Port;
+ Query.DefaultUrlScheme = defaultServer.Scheme;
+ }
+ }
}
diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
index 82c6e3011a..4882822766 100644
--- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs
@@ -413,6 +413,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb
}
item.Overview = result.Plot;
+ item.OriginalLanguage = result.Language;
if (!Plugin.Instance.Configuration.CastAndCrew)
{
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs
index ff584ba1de..8811a1787a 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs
@@ -379,6 +379,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
movie.RemoteTrailers = trailers;
}
+ if (!string.IsNullOrEmpty(movieResult.OriginalLanguage))
+ {
+ movie.OriginalLanguage = movieResult.OriginalLanguage;
+ }
+
return metadataResult;
}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeExternalId.cs
new file mode 100644
index 0000000000..8d9d2d354b
--- /dev/null
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeExternalId.cs
@@ -0,0 +1,25 @@
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Providers;
+
+namespace MediaBrowser.Providers.Plugins.Tmdb.TV
+{
+ /// <summary>
+ /// External id for a TMDb episode.
+ /// </summary>
+ public class TmdbEpisodeExternalId : IExternalId
+ {
+ /// <inheritdoc />
+ public string ProviderName => TmdbUtils.ProviderName;
+
+ /// <inheritdoc />
+ public string Key => MetadataProvider.Tmdb.ToString();
+
+ /// <inheritdoc />
+ public ExternalIdMediaType? Type => ExternalIdMediaType.Episode;
+
+ /// <inheritdoc />
+ public bool Supports(IHasProviderIds item) => item is Episode;
+ }
+}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonExternalId.cs
new file mode 100644
index 0000000000..8191446363
--- /dev/null
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonExternalId.cs
@@ -0,0 +1,25 @@
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Providers;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Providers;
+
+namespace MediaBrowser.Providers.Plugins.Tmdb.TV
+{
+ /// <summary>
+ /// External id for a TMDb season.
+ /// </summary>
+ public class TmdbSeasonExternalId : IExternalId
+ {
+ /// <inheritdoc />
+ public string ProviderName => TmdbUtils.ProviderName;
+
+ /// <inheritdoc />
+ public string Key => MetadataProvider.Tmdb.ToString();
+
+ /// <inheritdoc />
+ public ExternalIdMediaType? Type => ExternalIdMediaType.Season;
+
+ /// <inheritdoc />
+ public bool Supports(IHasProviderIds item) => item is Season;
+ }
+}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs
index 840cec9841..477bcc6f0c 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs
@@ -20,9 +20,6 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
public ExternalIdMediaType? Type => ExternalIdMediaType.Series;
/// <inheritdoc />
- public bool Supports(IHasProviderIds item)
- {
- return item is Series;
- }
+ public bool Supports(IHasProviderIds item) => item is Series;
}
}
diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
index 7e36c1e204..1eb411f0f6 100644
--- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
+++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs
@@ -329,6 +329,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
}
}
+ if (!string.IsNullOrEmpty(seriesResult.OriginalLanguage))
+ {
+ series.OriginalLanguage = seriesResult.OriginalLanguage;
+ }
+
return series;
}
diff --git a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs
index a78ec995cf..c3458d4b2a 100644
--- a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs
+++ b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs
@@ -228,10 +228,11 @@ namespace MediaBrowser.Providers.Subtitles
var mediaFolderPath = Path.GetFullPath(Path.Combine(video.ContainingFolderPath, saveFileName));
savePaths.Add(mediaFolderPath);
}
-
- var internalPath = Path.GetFullPath(Path.Combine(video.GetInternalMetadataPath(), saveFileName));
-
- savePaths.Add(internalPath);
+ else
+ {
+ var internalPath = Path.GetFullPath(Path.Combine(video.GetInternalMetadataPath(), saveFileName));
+ savePaths.Add(internalPath);
+ }
await TrySaveToFiles(memoryStream, savePaths, video, response.Format.ToLowerInvariant()).ConfigureAwait(false);
}
diff --git a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs
index 3f83f1d829..d3f0bfb5d4 100644
--- a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs
+++ b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs
@@ -543,6 +543,16 @@ namespace MediaBrowser.XbmcMetadata.Parsers
case "ratings":
FetchFromRatingsNode(reader, item);
break;
+ // For NFO files that have a separate community rating tag instead of using the ratings node with a name, or standard rating tag
+ case "communityrating":
+ var communityRatingText = reader.ReadElementContentAsString().Replace(',', '.');
+ if (float.TryParse(communityRatingText, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var communityRatingValue)
+ && communityRatingValue >= 0 && communityRatingValue <= 10)
+ {
+ item.CommunityRating = communityRatingValue;
+ }
+
+ break;
case "aired":
case "formed":
case "premiered":
diff --git a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs
index 4ca3aa9ef5..ed32e6c76a 100644
--- a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs
+++ b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs
@@ -67,6 +67,7 @@ namespace MediaBrowser.XbmcMetadata.Savers
"id",
"credits",
"originaltitle",
+ "originallanguage",
"watched",
"playcount",
"lastplayed",
@@ -376,6 +377,11 @@ namespace MediaBrowser.XbmcMetadata.Savers
writer.WriteElementString("default", stream.IsDefault.ToString(CultureInfo.InvariantCulture));
writer.WriteElementString("forced", stream.IsForced.ToString(CultureInfo.InvariantCulture));
+ if (stream.IsOriginal)
+ {
+ writer.WriteElementString("original", stream.IsOriginal.ToString(CultureInfo.InvariantCulture));
+ }
+
if (stream.Type == MediaStreamType.Video)
{
var runtimeTicks = item.RunTimeTicks;
@@ -484,6 +490,11 @@ namespace MediaBrowser.XbmcMetadata.Savers
writer.WriteElementString("originaltitle", item.OriginalTitle);
}
+ if (!string.IsNullOrWhiteSpace(item.OriginalLanguage))
+ {
+ writer.WriteElementString("originallanguage", item.OriginalLanguage);
+ }
+
var people = libraryManager.GetPeople(item);
var directors = people
diff --git a/README.md b/README.md
index 7531481860..5e066f3d31 100644
--- a/README.md
+++ b/README.md
@@ -76,7 +76,7 @@ These instructions will help you get set up with a local development environment
### Prerequisites
-Before the project can be built, you must first install the [.NET 9.0 SDK](https://dotnet.microsoft.com/download/dotnet) on your system.
+Before the project can be built, you must first install the [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet) on your system.
Instructions to run this project from the command line are included here, but you will also need to install an IDE if you want to debug the server while it is running. Any IDE that supports .NET 6 development will work, but two options are recent versions of [Visual Studio](https://visualstudio.microsoft.com/downloads/) (at least 2022) and [Visual Studio Code](https://code.visualstudio.com/Download).
@@ -195,7 +195,5 @@ Since this is a common scenario, there is also a separate launch profile defined
This project is supported by:
<br/>
<br/>
-<a href="https://www.digitalocean.com"><img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_blue.svg" height="50px" alt="DigitalOcean"></a>
- &nbsp;
<a href="https://www.jetbrains.com"><img src="https://gist.githubusercontent.com/anthonylavado/e8b2403deee9581e0b4cb8cd675af7db/raw/199ae22980ef5da64882ec2de3e8e5c03fe535b8/jetbrains.svg" height="50px" alt="JetBrains logo"></a>
</p>
diff --git a/SharedVersion.cs b/SharedVersion.cs
index 3b394d28b2..1d4a368aa2 100644
--- a/SharedVersion.cs
+++ b/SharedVersion.cs
@@ -1,4 +1,4 @@
using System.Reflection;
-[assembly: AssemblyVersion("10.12.0")]
-[assembly: AssemblyFileVersion("10.12.0")]
+[assembly: AssemblyVersion("12.0.0")]
+[assembly: AssemblyFileVersion("12.0.0")]
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemEntity.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemEntity.cs
index 76c847e5f0..6a6c8e1a6a 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemEntity.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/BaseItemEntity.cs
@@ -96,6 +96,8 @@ public class BaseItemEntity
public string? OriginalTitle { get; set; }
+ public string? OriginalLanguage { get; set; }
+
public Guid? PrimaryVersionId { get; set; }
public DateTime? DateLastMediaAdded { get; set; }
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/ItemMetadata.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/ItemMetadata.cs
index e5cbab7e45..3e2e0bb7ae 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/ItemMetadata.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Libraries/ItemMetadata.cs
@@ -63,6 +63,16 @@ namespace Jellyfin.Database.Implementations.Entities.Libraries
public string? OriginalTitle { get; set; }
/// <summary>
+ /// Gets or sets the original language.
+ /// </summary>
+ /// <remarks>
+ /// Max length = 1024.
+ /// </remarks>
+ [MaxLength(1024)]
+ [StringLength(1024)]
+ public string? OriginalLanguage { get; set; }
+
+ /// <summary>
/// Gets or sets the sort title.
/// </summary>
/// <remarks>
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/MediaStreamInfo.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/MediaStreamInfo.cs
index b80b764ba3..6953f9c859 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/MediaStreamInfo.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/MediaStreamInfo.cs
@@ -40,6 +40,8 @@ public class MediaStreamInfo
public bool IsExternal { get; set; }
+ public bool IsOriginal { get; set; }
+
public int? Height { get; set; }
public int? Width { get; set; }
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260504180809_AddOriginalLanguage.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260504180809_AddOriginalLanguage.Designer.cs
new file mode 100644
index 0000000000..e0f5125da1
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260504180809_AddOriginalLanguage.Designer.cs
@@ -0,0 +1,1802 @@
+// <auto-generated />
+using System;
+using Jellyfin.Database.Implementations;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+
+#nullable disable
+
+namespace Jellyfin.Database.Providers.Sqlite.Migrations
+{
+ [DbContext(typeof(JellyfinDbContext))]
+ [Migration("20260504180809_AddOriginalLanguage")]
+ partial class AddOriginalLanguage
+ {
+ /// <inheritdoc />
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder.HasAnnotation("ProductVersion", "10.0.7");
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("DayOfWeek")
+ .HasColumnType("INTEGER");
+
+ b.Property<double>("EndHour")
+ .HasColumnType("REAL");
+
+ b.Property<double>("StartHour")
+ .HasColumnType("REAL");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("AccessSchedules");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ItemId")
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("LogSeverity")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Overview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ShortOverview")
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Type")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DateCreated");
+
+ b.ToTable("ActivityLogs");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ParentItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "ParentItemId");
+
+ b.HasIndex("ParentItemId");
+
+ b.ToTable("AncestorIds");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Index")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Codec")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTag")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Comment")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Filename")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("MimeType")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "Index");
+
+ b.ToTable("AttachmentStreamInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Album")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AlbumArtists")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Artists")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Audio")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("ChannelId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CleanName")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("CommunityRating")
+ .HasColumnType("REAL");
+
+ b.Property<float?>("CriticRating")
+ .HasColumnType("REAL");
+
+ b.Property<string>("CustomRating")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Data")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateLastMediaAdded")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateLastRefreshed")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateLastSaved")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("EndDate")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("EpisodeTitle")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExternalId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExternalSeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ExternalServiceId")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ExtraType")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ForcedSortName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Genres")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("IndexNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("InheritedParentalRatingSubValue")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("InheritedParentalRatingValue")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsFolder")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsInMixedFolder")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsLocked")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsMovie")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsRepeat")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsSeries")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsVirtualItem")
+ .HasColumnType("INTEGER");
+
+ b.Property<float?>("LUFS")
+ .HasColumnType("REAL");
+
+ b.Property<string>("MediaType")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("NormalizationGain")
+ .HasColumnType("REAL");
+
+ b.Property<string>("OfficialRating")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("OriginalLanguage")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("OriginalTitle")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Overview")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("OwnerId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("ParentId")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ParentIndexNumber")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Path")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PreferredMetadataCountryCode")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PreferredMetadataLanguage")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("PremiereDate")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PresentationUniqueKey")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("PrimaryVersionId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProductionLocations")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ProductionYear")
+ .HasColumnType("INTEGER");
+
+ b.Property<long?>("RunTimeTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("SeasonId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SeasonName")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("SeriesId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SeriesName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SeriesPresentationUniqueKey")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ShowId")
+ .HasColumnType("TEXT");
+
+ b.Property<long?>("Size")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SortName")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("StartDate")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Studios")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Tagline")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Tags")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("TopParentId")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("TotalBitrate")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Type")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("UnratedType")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name");
+
+ b.HasIndex("OwnerId");
+
+ b.HasIndex("ParentId");
+
+ b.HasIndex("Path");
+
+ b.HasIndex("PresentationUniqueKey");
+
+ b.HasIndex("SeasonId");
+
+ b.HasIndex("SeriesId");
+
+ b.HasIndex("SeriesName");
+
+ b.HasIndex("ExtraType", "OwnerId");
+
+ b.HasIndex("TopParentId", "Id");
+
+ b.HasIndex("Type", "CleanName");
+
+ b.HasIndex("TopParentId", "Type", "IsVirtualItem")
+ .HasFilter("\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)");
+
+ b.HasIndex("Type", "TopParentId", "Id");
+
+ b.HasIndex("Type", "TopParentId", "PresentationUniqueKey");
+
+ b.HasIndex("Type", "TopParentId", "SortName");
+
+ b.HasIndex("Type", "TopParentId", "StartDate");
+
+ b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey");
+
+ b.HasIndex("TopParentId", "IsFolder", "IsVirtualItem", "DateCreated");
+
+ b.HasIndex("TopParentId", "MediaType", "IsVirtualItem", "DateCreated");
+
+ b.HasIndex("TopParentId", "Type", "IsVirtualItem", "DateCreated");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "ParentIndexNumber", "IndexNumber");
+
+ b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName");
+
+ b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+ b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated");
+
+ b.ToTable("BaseItems");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+
+ b.HasData(
+ new
+ {
+ Id = new Guid("00000000-0000-0000-0000-000000000001"),
+ IsFolder = false,
+ IsInMixedFolder = false,
+ IsLocked = false,
+ IsMovie = false,
+ IsRepeat = false,
+ IsSeries = false,
+ IsVirtualItem = false,
+ Name = "This is a placeholder item for UserData that has been detached from its original item",
+ Type = "PLACEHOLDER"
+ });
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<byte[]>("Blurhash")
+ .HasColumnType("BLOB");
+
+ b.Property<DateTime?>("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ImageType")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ItemId", "ImageType");
+
+ b.ToTable("BaseItemImageInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b =>
+ {
+ b.Property<int>("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemMetadataFields");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProviderId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ProviderValue")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemId", "ProviderId");
+
+ b.HasIndex("ProviderId", "ItemId", "ProviderValue");
+
+ b.ToTable("BaseItemProviders");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b =>
+ {
+ b.Property<int>("Id")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("BaseItemTrailerTypes");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ChapterIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("ImageDateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ImagePath")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .HasColumnType("TEXT");
+
+ b.Property<long>("StartPositionTicks")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "ChapterIndex");
+
+ b.ToTable("Chapters");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Key")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Value")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "ItemId", "Client", "Key")
+ .IsUnique();
+
+ b.ToTable("CustomItemDisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ChromecastVersion")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DashboardTheme")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("EnableNextVideoInfoOverlay")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ScrollDirection")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("ShowBackdrop")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("ShowSidebar")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SkipBackwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SkipForwardLength")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("TvHome")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "ItemId", "Client")
+ .IsUnique();
+
+ b.ToTable("DisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("DisplayPreferencesId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Order")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DisplayPreferencesId");
+
+ b.ToTable("HomeSection");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime>("LastModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .IsRequired()
+ .HasMaxLength(512)
+ .HasColumnType("TEXT");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId")
+ .IsUnique();
+
+ b.ToTable("ImageInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Client")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("IndexBy")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("RememberIndexing")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberSorting")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SortBy")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ViewType")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("ItemDisplayPreferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b =>
+ {
+ b.Property<Guid>("ItemValueId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CleanValue")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Value")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemValueId");
+
+ b.HasIndex("Type", "CleanValue");
+
+ b.HasIndex("Type", "Value")
+ .IsUnique();
+
+ b.ToTable("ItemValues");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b =>
+ {
+ b.Property<Guid>("ItemValueId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("ItemValueId", "ItemId");
+
+ b.HasIndex("ItemId");
+
+ b.ToTable("ItemValuesMap");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.PrimitiveCollection<string>("KeyframeTicks")
+ .HasColumnType("TEXT");
+
+ b.Property<long>("TotalDuration")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId");
+
+ b.ToTable("KeyframeData");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b =>
+ {
+ b.Property<Guid>("ParentId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("ChildId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("ChildType")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ParentId", "ChildId");
+
+ b.HasIndex("ChildId", "ChildType");
+
+ b.HasIndex("ParentId", "ChildType");
+
+ b.HasIndex("ParentId", "SortOrder");
+
+ b.ToTable("LinkedChildren", (string)null);
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<long>("EndTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("SegmentProviderId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<long>("StartTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Type")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.ToTable("MediaSegments");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("StreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AspectRatio")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("AverageFrameRate")
+ .HasColumnType("REAL");
+
+ b.Property<int?>("BitDepth")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("BitRate")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("BlPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("ChannelLayout")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Channels")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Codec")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTag")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CodecTimeBase")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorPrimaries")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorSpace")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("ColorTransfer")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Comment")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("DvBlSignalCompatibilityId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvLevel")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvProfile")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvVersionMajor")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("DvVersionMinor")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("ElPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("Hdr10PlusPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsAnamorphic")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsAvc")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsDefault")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsExternal")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsForced")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsHearingImpaired")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool?>("IsInterlaced")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsOriginal")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("KeyFrames")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Language")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("Level")
+ .HasColumnType("REAL");
+
+ b.Property<string>("NalLengthSize")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Path")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PixelFormat")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Profile")
+ .HasColumnType("TEXT");
+
+ b.Property<float?>("RealFrameRate")
+ .HasColumnType("REAL");
+
+ b.Property<int?>("RefFrames")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("Rotation")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("RpuPresentFlag")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("SampleRate")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("StreamType")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("TimeBase")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Title")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("Width")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "StreamIndex");
+
+ b.ToTable("MediaStreamInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PersonType")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Name");
+
+ b.ToTable("Peoples");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("PeopleId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Role")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("ListOrder")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("SortOrder")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "PeopleId", "Role");
+
+ b.HasIndex("PeopleId");
+
+ b.HasIndex("ItemId", "ListOrder");
+
+ b.HasIndex("ItemId", "SortOrder");
+
+ b.ToTable("PeopleBaseItemMap");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("Permission_Permissions_Guid")
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("Value")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Kind")
+ .IsUnique()
+ .HasFilter("[UserId] IS NOT NULL");
+
+ b.ToTable("Permissions");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Kind")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("Preference_Preferences_Guid")
+ .HasColumnType("TEXT");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid?>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Value")
+ .IsRequired()
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "Kind")
+ .IsUnique()
+ .HasFilter("[UserId] IS NOT NULL");
+
+ b.ToTable("Preferences");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AccessToken")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateLastActivity")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("Name")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AccessToken")
+ .IsUnique();
+
+ b.ToTable("ApiKeys");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("AccessToken")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AppName")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AppVersion")
+ .IsRequired()
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateCreated")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateLastActivity")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime>("DateModified")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DeviceId")
+ .IsRequired()
+ .HasMaxLength(256)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DeviceName")
+ .IsRequired()
+ .HasMaxLength(64)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("IsActive")
+ .HasColumnType("INTEGER");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("AccessToken", "DateLastActivity");
+
+ b.HasIndex("DeviceId", "DateLastActivity");
+
+ b.HasIndex("UserId", "DeviceId");
+
+ b.ToTable("Devices");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b =>
+ {
+ b.Property<int>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("CustomName")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("DeviceId")
+ .IsRequired()
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeviceId")
+ .IsUnique();
+
+ b.ToTable("DeviceOptions");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<int>("Width")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Bandwidth")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Height")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("Interval")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("ThumbnailCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("TileHeight")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("TileWidth")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "Width");
+
+ b.ToTable("TrickplayInfos");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b =>
+ {
+ b.Property<Guid>("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AudioLanguagePreference")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("AuthenticationProviderId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CastReceiverId")
+ .HasMaxLength(32)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("DisplayCollectionsView")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("DisplayMissingEpisodes")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableAutoLogin")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableLocalPassword")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableNextEpisodeAutoPlay")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("EnableUserPreferenceAccess")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("HidePlayedInLatest")
+ .HasColumnType("INTEGER");
+
+ b.Property<long>("InternalId")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("InvalidLoginAttemptCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("LastActivityDate")
+ .HasColumnType("TEXT");
+
+ b.Property<DateTime?>("LastLoginDate")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("LoginAttemptsBeforeLockout")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("MaxActiveSessions")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("MaxParentalRatingScore")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("MaxParentalRatingSubScore")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("MustUpdatePassword")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Password")
+ .HasMaxLength(65535)
+ .HasColumnType("TEXT");
+
+ b.Property<string>("PasswordResetProviderId")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<bool>("PlayDefaultAudioTrack")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberAudioSelections")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("RememberSubtitleSelections")
+ .HasColumnType("INTEGER");
+
+ b.Property<int?>("RemoteClientBitrateLimit")
+ .HasColumnType("INTEGER");
+
+ b.Property<uint>("RowVersion")
+ .IsConcurrencyToken()
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("SubtitleLanguagePreference")
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.Property<int>("SubtitleMode")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("SyncPlayAccess")
+ .HasColumnType("INTEGER");
+
+ b.Property<string>("Username")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("Username")
+ .IsUnique();
+
+ b.ToTable("Users");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b =>
+ {
+ b.Property<Guid>("ItemId")
+ .HasColumnType("TEXT");
+
+ b.Property<Guid>("UserId")
+ .HasColumnType("TEXT");
+
+ b.Property<string>("CustomDataKey")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("AudioStreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("IsFavorite")
+ .HasColumnType("INTEGER");
+
+ b.Property<DateTime?>("LastPlayedDate")
+ .HasColumnType("TEXT");
+
+ b.Property<bool?>("Likes")
+ .HasColumnType("INTEGER");
+
+ b.Property<int>("PlayCount")
+ .HasColumnType("INTEGER");
+
+ b.Property<long>("PlaybackPositionTicks")
+ .HasColumnType("INTEGER");
+
+ b.Property<bool>("Played")
+ .HasColumnType("INTEGER");
+
+ b.Property<double?>("Rating")
+ .HasColumnType("REAL");
+
+ b.Property<DateTime?>("RetentionDate")
+ .HasColumnType("TEXT");
+
+ b.Property<int?>("SubtitleStreamIndex")
+ .HasColumnType("INTEGER");
+
+ b.HasKey("ItemId", "UserId", "CustomDataKey");
+
+ b.HasIndex("ItemId", "UserId", "IsFavorite");
+
+ b.HasIndex("ItemId", "UserId", "LastPlayedDate");
+
+ b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks");
+
+ b.HasIndex("ItemId", "UserId", "Played");
+
+ b.HasIndex("UserId", "IsFavorite", "ItemId");
+
+ b.HasIndex("UserId", "ItemId", "LastPlayedDate");
+
+ b.HasIndex("UserId", "Played", "ItemId");
+
+ b.ToTable("UserData");
+
+ b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("AccessSchedules")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Parents")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem")
+ .WithMany("Children")
+ .HasForeignKey("ParentItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("ParentItem");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany()
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Owner")
+ .WithMany("Extras")
+ .HasForeignKey("OwnerId")
+ .OnDelete(DeleteBehavior.NoAction);
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "DirectParent")
+ .WithMany("DirectChildren")
+ .HasForeignKey("ParentId")
+ .OnDelete(DeleteBehavior.Cascade);
+
+ b.Navigation("DirectParent");
+
+ b.Navigation("Owner");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Images")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("LockedFields")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Provider")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("TrailerTypes")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Chapters")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("DisplayPreferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null)
+ .WithMany("HomeSections")
+ .HasForeignKey("DisplayPreferencesId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithOne("ProfileImage")
+ .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("ItemDisplayPreferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("ItemValues")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue")
+ .WithMany("BaseItemsMap")
+ .HasForeignKey("ItemValueId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("ItemValue");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany()
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Child")
+ .WithMany("LinkedChildOfEntities")
+ .HasForeignKey("ChildId")
+ .OnDelete(DeleteBehavior.NoAction)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Parent")
+ .WithMany("LinkedChildEntities")
+ .HasForeignKey("ParentId")
+ .OnDelete(DeleteBehavior.NoAction)
+ .IsRequired();
+
+ b.Navigation("Child");
+
+ b.Navigation("Parent");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("MediaStreams")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("Peoples")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People")
+ .WithMany("BaseItems")
+ .HasForeignKey("PeopleId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("People");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("Permissions")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", null)
+ .WithMany("Preferences")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade);
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b =>
+ {
+ b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item")
+ .WithMany("UserData")
+ .HasForeignKey("ItemId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User")
+ .WithMany()
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("Item");
+
+ b.Navigation("User");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b =>
+ {
+ b.Navigation("Chapters");
+
+ b.Navigation("Children");
+
+ b.Navigation("DirectChildren");
+
+ b.Navigation("Extras");
+
+ b.Navigation("Images");
+
+ b.Navigation("ItemValues");
+
+ b.Navigation("LinkedChildEntities");
+
+ b.Navigation("LinkedChildOfEntities");
+
+ b.Navigation("LockedFields");
+
+ b.Navigation("MediaStreams");
+
+ b.Navigation("Parents");
+
+ b.Navigation("Peoples");
+
+ b.Navigation("Provider");
+
+ b.Navigation("TrailerTypes");
+
+ b.Navigation("UserData");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b =>
+ {
+ b.Navigation("HomeSections");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b =>
+ {
+ b.Navigation("BaseItemsMap");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b =>
+ {
+ b.Navigation("BaseItems");
+ });
+
+ modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b =>
+ {
+ b.Navigation("AccessSchedules");
+
+ b.Navigation("DisplayPreferences");
+
+ b.Navigation("ItemDisplayPreferences");
+
+ b.Navigation("Permissions");
+
+ b.Navigation("Preferences");
+
+ b.Navigation("ProfileImage");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260504180809_AddOriginalLanguage.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260504180809_AddOriginalLanguage.cs
new file mode 100644
index 0000000000..cda226309a
--- /dev/null
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260504180809_AddOriginalLanguage.cs
@@ -0,0 +1,47 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+
+#nullable disable
+
+namespace Jellyfin.Database.Providers.Sqlite.Migrations
+{
+ /// <inheritdoc />
+ public partial class AddOriginalLanguage : Migration
+ {
+ /// <inheritdoc />
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn<bool>(
+ name: "IsOriginal",
+ table: "MediaStreamInfos",
+ type: "INTEGER",
+ nullable: false,
+ defaultValue: false);
+
+ migrationBuilder.AddColumn<string>(
+ name: "OriginalLanguage",
+ table: "BaseItems",
+ type: "TEXT",
+ nullable: true);
+
+ migrationBuilder.UpdateData(
+ table: "BaseItems",
+ keyColumn: "Id",
+ keyValue: new Guid("00000000-0000-0000-0000-000000000001"),
+ column: "OriginalLanguage",
+ value: null);
+ }
+
+ /// <inheritdoc />
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "IsOriginal",
+ table: "MediaStreamInfos");
+
+ migrationBuilder.DropColumn(
+ name: "OriginalLanguage",
+ table: "BaseItems");
+ }
+ }
+}
diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs
index 2c74d47edc..86b838d64e 100644
--- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs
+++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs
@@ -264,6 +264,9 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<string>("OfficialRating")
.HasColumnType("TEXT");
+ b.Property<string>("OriginalLanguage")
+ .HasColumnType("TEXT");
+
b.Property<string>("OriginalTitle")
.HasColumnType("TEXT");
@@ -955,6 +958,9 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<bool?>("IsInterlaced")
.HasColumnType("INTEGER");
+ b.Property<bool>("IsOriginal")
+ .HasColumnType("INTEGER");
+
b.Property<string>("KeyFrames")
.HasColumnType("TEXT");
diff --git a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
index 9a7cf4aabe..5518d9b954 100644
--- a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
+++ b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
@@ -15,7 +15,7 @@
<PropertyGroup>
<Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Extensions</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>
diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs
index 1d18ade9dc..2abc8a8c09 100644
--- a/src/Jellyfin.LiveTv/LiveTvManager.cs
+++ b/src/Jellyfin.LiveTv/LiveTvManager.cs
@@ -1204,7 +1204,7 @@ namespace Jellyfin.LiveTv
{
Services = services,
IsEnabled = services.Length > 0,
- EnabledUsers = _userManager.Users
+ EnabledUsers = _userManager.GetUsers()
.Where(IsLiveTvEnabled)
.Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture))
.ToArray()
@@ -1220,7 +1220,7 @@ namespace Jellyfin.LiveTv
public IEnumerable<User> GetEnabledUsers()
{
- return _userManager.Users
+ return _userManager.GetUsers()
.Where(IsLiveTvEnabled);
}
diff --git a/src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs b/src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs
index a5d186ce18..4b0f63b041 100644
--- a/src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs
+++ b/src/Jellyfin.LiveTv/Recordings/RecordingNotifier.cs
@@ -79,7 +79,7 @@ namespace Jellyfin.LiveTv.Recordings
private async Task SendMessage(SessionMessageType name, TimerEventInfo info)
{
- var users = _userManager.Users
+ var users = _userManager.GetUsers()
.Where(i => i.HasPermission(PermissionKind.EnableLiveTvAccess))
.Select(i => i.Id)
.ToList();
diff --git a/src/Jellyfin.Networking/Manager/NetworkManager.cs b/src/Jellyfin.Networking/Manager/NetworkManager.cs
index 6a8a91fa51..0fe2fc43ad 100644
--- a/src/Jellyfin.Networking/Manager/NetworkManager.cs
+++ b/src/Jellyfin.Networking/Manager/NetworkManager.cs
@@ -316,7 +316,7 @@ public class NetworkManager : INetworkManager, IDisposable
var subnets = config.LocalNetworkSubnets;
// If no LAN addresses are specified, all private subnets and Loopback are deemed to be the LAN
- if (!NetworkUtils.TryParseToSubnets(subnets, out var lanSubnets, false) || lanSubnets.Count == 0)
+ if (!NetworkUtils.TryParseToSubnets(subnets, out var lanSubnets, false, _logger) || lanSubnets.Count == 0)
{
_logger.LogDebug("Using LAN interface addresses as user provided no LAN details.");
@@ -343,7 +343,7 @@ public class NetworkManager : INetworkManager, IDisposable
_lanSubnets = lanSubnets.Select(x => x.Subnet).ToArray();
}
- _excludedSubnets = NetworkUtils.TryParseToSubnets(subnets, out var excludedSubnets, true)
+ _excludedSubnets = NetworkUtils.TryParseToSubnets(subnets, out var excludedSubnets, true, _logger)
? excludedSubnets.Select(x => x.Subnet).ToArray()
: Array.Empty<IPNetwork>();
}
diff --git a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs
index 3369af0e84..198cdaa4fc 100644
--- a/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs
+++ b/tests/Jellyfin.MediaEncoding.Tests/Probing/ProbeResultNormalizerTests.cs
@@ -105,10 +105,12 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
var audio1 = res.MediaStreams[1];
Assert.Equal("eac3", audio1.Codec);
+ Assert.True(audio1.IsOriginal);
Assert.Equal(AudioSpatialFormat.DolbyAtmos, audio1.AudioSpatialFormat);
var audio2 = res.MediaStreams[2];
Assert.Equal("dts", audio2.Codec);
+ Assert.False(audio2.IsOriginal);
Assert.Equal(AudioSpatialFormat.DTSX, audio2.AudioSpatialFormat);
Assert.Empty(res.Chapters);
@@ -156,6 +158,7 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
Assert.Equal("aac", res.MediaStreams[1].Codec);
Assert.Equal(7, res.MediaStreams[1].Channels);
Assert.True(res.MediaStreams[1].IsDefault);
+ Assert.False(res.MediaStreams[1].IsOriginal);
Assert.Equal("eng", res.MediaStreams[1].Language);
Assert.Equal("Surround 6.1", res.MediaStreams[1].Title);
diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
index 8269ae58cd..0b103debad 100644
--- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
+++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs
@@ -171,6 +171,9 @@ namespace Jellyfin.Model.Tests
[InlineData("AndroidTVExoPlayer", "mkv-vp9-aac-srt-2600k", PlayMethod.DirectPlay)]
[InlineData("AndroidTVExoPlayer", "mkv-vp9-ac3-srt-2600k", PlayMethod.DirectPlay)]
[InlineData("AndroidTVExoPlayer", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.Transcode, TranscodeReason.AudioCodecNotSupported, "Transcode")] // Full transcode because profile only has ts which does not allow vp9
+ [InlineData("AndroidTVExoPlayer", "mp4-hevc-aac-4000k-r180", PlayMethod.DirectPlay)] // #13712
+ // AndroidTV NoHevcRotation
+ [InlineData("AndroidTVExoPlayer-NoHevcRotation", "mp4-hevc-aac-4000k-r180", PlayMethod.Transcode, TranscodeReason.VideoRotationNotSupported, "Transcode")] // #13712
// Tizen 3 Stereo
[InlineData("Tizen3-stereo", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)]
[InlineData("Tizen3-stereo", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay)]
diff --git a/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidTVExoPlayer-NoHevcRotation.json b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidTVExoPlayer-NoHevcRotation.json
new file mode 100644
index 0000000000..341638bc52
--- /dev/null
+++ b/tests/Jellyfin.Model.Tests/Test Data/DeviceProfile-AndroidTVExoPlayer-NoHevcRotation.json
@@ -0,0 +1,162 @@
+{
+ "Name": "Jellyfin AndroidTV-ExoPlayer",
+ "EnableAlbumArtInDidl": false,
+ "EnableSingleAlbumArtLimit": false,
+ "EnableSingleSubtitleLimit": false,
+ "SupportedMediaTypes": "Audio,Photo,Video",
+ "MaxAlbumArtWidth": 0,
+ "MaxAlbumArtHeight": 0,
+ "MaxStreamingBitrate": 120000000,
+ "MaxStaticBitrate": 100000000,
+ "MusicStreamingTranscodingBitrate": 192000,
+ "TimelineOffsetSeconds": 0,
+ "RequiresPlainVideoItems": false,
+ "RequiresPlainFolders": false,
+ "EnableMSMediaReceiverRegistrar": false,
+ "IgnoreTranscodeByteRangeRequests": false,
+ "DirectPlayProfiles": [
+ {
+ "Container": "m4v,mov,xvid,vob,mkv,wmv,asf,ogm,ogv,mp4,webm",
+ "AudioCodec": "aac,mp3,mp2,aac_latm,alac,ac3,eac3,dca,dts,mlp,truehd,pcm_alaw,pcm_mulaw",
+ "VideoCodec": "h264,hevc,vp8,vp9,mpeg,mpeg2video",
+ "Type": "Video",
+ "$type": "DirectPlayProfile"
+ },
+ {
+ "Container": "aac,mp3,mp2,aac_latm,alac,ac3,eac3,dca,dts,mlp,truehd,pcm_alaw,pcm_mulaw,,pa,flac,wav,wma,ogg,oga,webma,ape,opus",
+ "Type": "Audio",
+ "$type": "DirectPlayProfile"
+ },
+ {
+ "Container": "jpg,jpeg,png,gif,web",
+ "Type": "Photo",
+ "$type": "DirectPlayProfile"
+ }
+ ],
+ "CodecProfiles": [
+ {
+ "Type": "Video",
+ "Conditions": [
+ {
+ "Condition": "EqualsAny",
+ "Property": "VideoProfile",
+ "Value": "main|main 10",
+ "IsRequired": false,
+ "$type": "ProfileCondition"
+ },
+ {
+ "Condition": "LessThanEqual",
+ "Property": "VideoLevel",
+ "Value": "51",
+ "IsRequired": false,
+ "$type": "ProfileCondition"
+ },
+ {
+ "Condition": "Equals",
+ "Property": "VideoRotation",
+ "Value": "0",
+ "IsRequired": false,
+ "$type": "ProfileCondition"
+ }
+ ],
+ "Codec": "hevc",
+ "$type": "CodecProfile"
+ }
+ ],
+ "TranscodingProfiles": [
+ {
+ "Container": "ts",
+ "Type": "Video",
+ "VideoCodec": "h264",
+ "AudioCodec": "aac,mp3",
+ "Protocol": "hls",
+ "EstimateContentLength": false,
+ "EnableMpegtsM2TsMode": false,
+ "TranscodeSeekInfo": "Auto",
+ "CopyTimestamps": false,
+ "Context": "Streaming",
+ "EnableSubtitlesInManifest": false,
+ "MinSegments": 0,
+ "SegmentLength": 0,
+ "$type": "TranscodingProfile"
+ },
+ {
+ "Container": "mp3",
+ "Type": "Audio",
+ "AudioCodec": "mp3",
+ "Protocol": "http",
+ "EstimateContentLength": false,
+ "EnableMpegtsM2TsMode": false,
+ "TranscodeSeekInfo": "Auto",
+ "CopyTimestamps": false,
+ "Context": "Streaming",
+ "EnableSubtitlesInManifest": false,
+ "MinSegments": 0,
+ "SegmentLength": 0,
+ "$type": "TranscodingProfile"
+ }
+ ],
+ "SubtitleProfiles": [
+ {
+ "Format": "srt",
+ "Method": "Embed",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "srt",
+ "Method": "External",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "subrip",
+ "Method": "Embed",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "subrip",
+ "Method": "External",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "ass",
+ "Method": "Encode",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "ssa",
+ "Method": "Encode",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "pgs",
+ "Method": "Encode",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "pgssub",
+ "Method": "Encode",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "dvdsub",
+ "Method": "Encode",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "vtt",
+ "Method": "Embed",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "sub",
+ "Method": "Embed",
+ "$type": "SubtitleProfile"
+ },
+ {
+ "Format": "idx",
+ "Method": "Embed",
+ "$type": "SubtitleProfile"
+ }
+ ],
+ "$type": "DeviceProfile"
+}
diff --git a/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-hevc-aac-4000k-r180.json b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-hevc-aac-4000k-r180.json
new file mode 100644
index 0000000000..393b10171d
--- /dev/null
+++ b/tests/Jellyfin.Model.Tests/Test Data/MediaSourceInfo-mp4-hevc-aac-4000k-r180.json
@@ -0,0 +1,56 @@
+{
+ "Id": "b7a9e2d4c815f36b0d9241a7e58c3f42",
+ "Path": "/Media/MyVideo-1080p.mp4",
+ "Container": "mov,mp4,m4a,3gp,3g2,mj2",
+ "Size": 1421636271,
+ "Name": "MyVideo-1080p",
+ "ETag": "d8e2a1b5c4f907e8a1d2b3c4e5f6a7b8",
+ "RunTimeTicks": 25801230336,
+ "SupportsTranscoding": true,
+ "SupportsDirectStream": true,
+ "SupportsDirectPlay": true,
+ "SupportsProbing": true,
+ "MediaStreams": [
+ {
+ "Codec": "hevc",
+ "CodecTag": "hvc1",
+ "Language": "eng",
+ "TimeBase": "1/11988",
+ "VideoRange": "SDR",
+ "DisplayTitle": "1080p HEVC SDR",
+ "NalLengthSize": "0",
+ "BitRate": 4014613,
+ "BitDepth": 8,
+ "RefFrames": 1,
+ "IsDefault": true,
+ "Height": 1080,
+ "Width": 1920,
+ "AverageFrameRate": 23.976,
+ "RealFrameRate": 23.976,
+ "Profile": "Main",
+ "Type": 1,
+ "AspectRatio": "16:9",
+ "PixelFormat": "yuv420p",
+ "Level": 50,
+ "Rotation": 180
+ },
+ {
+ "Codec": "aac",
+ "CodecTag": "mp4a",
+ "Language": "eng",
+ "TimeBase": "1/48000",
+ "DisplayTitle": "En - AAC - Stereo - Default",
+ "ChannelLayout": "stereo",
+ "BitRate": 125427,
+ "Channels": 2,
+ "SampleRate": 48000,
+ "IsDefault": true,
+ "Profile": "LC",
+ "Index": 1,
+ "Score": 203
+ }
+ ],
+ "Bitrate": 4331578,
+ "DefaultAudioStreamIndex": 1,
+ "DefaultSubtitleStreamIndex": 2
+}
diff --git a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs
index b63009d6a5..66eec077dc 100644
--- a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs
+++ b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs
@@ -7,6 +7,7 @@ using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Model.Net;
using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
@@ -94,10 +95,47 @@ namespace Jellyfin.Networking.Tests
[InlineData("256.128.0.0.0.1")]
[InlineData("fd23:184f:2029:0:3139:7386:67d7:d517:1231")]
[InlineData("[fd23:184f:2029:0:3139:7386:67d7:d517:1231]")]
+ [InlineData("fd23:184f:2029:0100/56")]
public static void TryParseInvalidIPStringsFalse(string address)
=> Assert.False(NetworkUtils.TryParseToSubnet(address, out _));
/// <summary>
+ /// Verifies that <see cref="NetworkUtils.TryParseToSubnets"/> emits a targeted warning
+ /// for IPv6 prefix-only notation and a generic warning for other malformed entries.
+ /// </summary>
+ [Fact]
+ public static void TryParseToSubnets_InvalidEntries_LogsWarnings()
+ {
+ var logger = new Mock<ILogger>();
+
+ var values = new[] { "10.0.0.0/8", "fd23:184f:2029:0100/56", "not-an-address" };
+ Assert.True(NetworkUtils.TryParseToSubnets(values, out var result, false, logger.Object));
+ Assert.NotNull(result);
+ Assert.Single(result);
+
+ // IPv6 prefix-only notation should produce a specific, actionable warning.
+ logger.Verify(
+ l => l.Log(
+ LogLevel.Warning,
+ It.IsAny<EventId>(),
+ It.Is<It.IsAnyType>((state, _) => state.ToString()!.Contains("IPv6 prefix-only", StringComparison.Ordinal)
+ && state.ToString()!.Contains("fd23:184f:2029:0100/56", StringComparison.Ordinal)),
+ It.IsAny<Exception>(),
+ It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
+ Times.Once);
+
+ // Other malformed entries should still produce a generic warning.
+ logger.Verify(
+ l => l.Log(
+ LogLevel.Warning,
+ It.IsAny<EventId>(),
+ It.Is<It.IsAnyType>((state, _) => state.ToString()!.Contains("not-an-address", StringComparison.Ordinal)),
+ It.IsAny<Exception>(),
+ It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
+ Times.Once);
+ }
+
+ /// <summary>
/// Checks if IPv4 address is within a defined subnet.
/// </summary>
/// <param name="netMask">Network mask.</param>
diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/AudioDbExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/AudioDbExternalUrlProviderTests.cs
new file mode 100644
index 0000000000..a9161a0402
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/ExternalId/AudioDbExternalUrlProviderTests.cs
@@ -0,0 +1,89 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Providers.Plugins.AudioDb;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.ExternalId
+{
+ public sealed class AudioDbExternalUrlProviderTests
+ {
+ private readonly AudioDbAlbumExternalUrlProvider _albumProvider = new();
+ private readonly AudioDbArtistExternalUrlProvider _artistProvider = new();
+
+ [Fact]
+ public void GetExternalUrls_MusicAlbumWithAudioDbAlbumId_ReturnsCorrectUrl()
+ {
+ var album = new MusicAlbum();
+ album.SetProviderId(MetadataProvider.AudioDbAlbum, "12345");
+
+ var urls = _albumProvider.GetExternalUrls(album);
+
+ Assert.Contains("https://www.theaudiodb.com/album/12345", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_MusicAlbumWithNoAudioDbAlbumId_ReturnsNoUrl()
+ {
+ var album = new MusicAlbum();
+
+ var urls = _albumProvider.GetExternalUrls(album);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_NonAlbumWithAudioDbAlbumId_ReturnsNoUrl()
+ {
+ var artist = new MusicArtist();
+ artist.SetProviderId(MetadataProvider.AudioDbAlbum, "12345");
+
+ var urls = _albumProvider.GetExternalUrls(artist);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_MusicArtistWithAudioDbArtistId_ReturnsCorrectUrl()
+ {
+ var artist = new MusicArtist();
+ artist.SetProviderId(MetadataProvider.AudioDbArtist, "67890");
+
+ var urls = _artistProvider.GetExternalUrls(artist);
+
+ Assert.Contains("https://www.theaudiodb.com/artist/67890", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_PersonWithAudioDbArtistId_ReturnsCorrectUrl()
+ {
+ var person = new Person();
+ person.SetProviderId(MetadataProvider.AudioDbArtist, "67890");
+
+ var urls = _artistProvider.GetExternalUrls(person);
+
+ Assert.Contains("https://www.theaudiodb.com/artist/67890", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_MusicArtistWithNoAudioDbArtistId_ReturnsNoUrl()
+ {
+ var artist = new MusicArtist();
+
+ var urls = _artistProvider.GetExternalUrls(artist);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_NonArtistWithAudioDbArtistId_ReturnsNoUrl()
+ {
+ var album = new MusicAlbum();
+ album.SetProviderId(MetadataProvider.AudioDbArtist, "67890");
+
+ var urls = _artistProvider.GetExternalUrls(album);
+
+ Assert.Empty(urls);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs
new file mode 100644
index 0000000000..99604e0933
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/ExternalId/ComicVineExternalUrlProviderTests.cs
@@ -0,0 +1,56 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Providers.Plugins.ComicVine;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.ExternalId
+{
+ public sealed class ComicVineExternalUrlProviderTests
+ {
+ private readonly ComicVineExternalUrlProvider _provider = new();
+
+ [Fact]
+ public void GetExternalUrls_PersonWithComicVineId_ReturnsCorrectUrl()
+ {
+ var person = new Person();
+ person.SetProviderId("ComicVine", "person/4005-1234");
+
+ var urls = _provider.GetExternalUrls(person);
+
+ Assert.Contains("https://comicvine.gamespot.com/person/4005-1234", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_BookWithComicVineId_ReturnsCorrectUrl()
+ {
+ var book = new Book();
+ book.SetProviderId("ComicVine", "issue/4000-5678");
+
+ var urls = _provider.GetExternalUrls(book);
+
+ Assert.Contains("https://comicvine.gamespot.com/issue/4000-5678", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_PersonWithNoComicVineId_ReturnsNoUrl()
+ {
+ var person = new Person();
+
+ var urls = _provider.GetExternalUrls(person);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_NonSupportedItemWithComicVineId_ReturnsNoUrl()
+ {
+ var series = new Series();
+ series.SetProviderId("ComicVine", "volume/4050-9999");
+
+ var urls = _provider.GetExternalUrls(series);
+
+ Assert.Empty(urls);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs
new file mode 100644
index 0000000000..eec64ac53f
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/ExternalId/GoogleBooksExternalUrlProviderTests.cs
@@ -0,0 +1,45 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Providers.Plugins.GoogleBooks;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.ExternalId
+{
+ public sealed class GoogleBooksExternalUrlProviderTests
+ {
+ private readonly GoogleBooksExternalUrlProvider _provider = new();
+
+ [Fact]
+ public void GetExternalUrls_BookWithGoogleBooksId_ReturnsCorrectUrl()
+ {
+ var book = new Book();
+ book.SetProviderId("GoogleBooks", "buc0AAAAMAAJ");
+
+ var urls = _provider.GetExternalUrls(book);
+
+ Assert.Contains("https://books.google.com/books?id=buc0AAAAMAAJ", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_BookWithNoGoogleBooksId_ReturnsNoUrl()
+ {
+ var book = new Book();
+
+ var urls = _provider.GetExternalUrls(book);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_NonBookWithGoogleBooksId_ReturnsNoUrl()
+ {
+ var series = new Series();
+ series.SetProviderId("GoogleBooks", "buc0AAAAMAAJ");
+
+ var urls = _provider.GetExternalUrls(series);
+
+ Assert.Empty(urls);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/ImdbExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/ImdbExternalUrlProviderTests.cs
new file mode 100644
index 0000000000..ed4a8e7478
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/ExternalId/ImdbExternalUrlProviderTests.cs
@@ -0,0 +1,125 @@
+using System;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Providers.Movies;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.ExternalId
+{
+ // put tests that mock the static LibraryManager in the same collection to avoid test interference
+ [Collection("LibraryManagerTests")]
+ public sealed class ImdbExternalUrlProviderTests : IDisposable
+ {
+ private readonly ImdbExternalUrlProvider _provider = new();
+ private readonly Mock<ILibraryManager> _libraryManagerMock = new();
+ private readonly ILibraryManager? _previousLibraryManager;
+
+ public ImdbExternalUrlProviderTests()
+ {
+ _previousLibraryManager = BaseItem.LibraryManager;
+ BaseItem.LibraryManager = _libraryManagerMock.Object;
+ }
+
+ public void Dispose()
+ {
+ BaseItem.LibraryManager = _previousLibraryManager;
+ }
+
+ [Fact]
+ public void GetExternalUrls_MovieWithImdbId_ReturnsCorrectUrl()
+ {
+ var movie = new Movie();
+ movie.SetProviderId(MetadataProvider.Imdb, "tt1234567");
+
+ var urls = _provider.GetExternalUrls(movie);
+
+ Assert.Contains("https://www.imdb.com/title/tt1234567", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_SeriesWithImdbId_ReturnsCorrectUrl()
+ {
+ var series = new Series();
+ series.SetProviderId(MetadataProvider.Imdb, "tt7654321");
+
+ var urls = _provider.GetExternalUrls(series);
+
+ Assert.Contains("https://www.imdb.com/title/tt7654321", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_EpisodeWithImdbId_ReturnsCorrectUrl()
+ {
+ var episode = new Episode();
+ episode.SetProviderId(MetadataProvider.Imdb, "tt9999999");
+
+ var urls = _provider.GetExternalUrls(episode);
+
+ Assert.Contains("https://www.imdb.com/title/tt9999999", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_SeasonWithSeriesImdbId_ReturnsSeasonEpisodesUrl()
+ {
+ var series = new Series { Id = Guid.NewGuid() };
+ series.SetProviderId(MetadataProvider.Imdb, "tt1234567");
+
+ var season = new Season { IndexNumber = 2, SeriesId = series.Id };
+ _libraryManagerMock.Setup(m => m.GetItemById(series.Id)).Returns(series);
+
+ var urls = _provider.GetExternalUrls(season);
+
+ Assert.Contains("https://www.imdb.com/title/tt1234567/episodes/?season=2", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_SeasonWithNoSeriesImdbId_ReturnsNoUrl()
+ {
+ var series = new Series { Id = Guid.NewGuid() };
+ var season = new Season { IndexNumber = 1, SeriesId = series.Id };
+ _libraryManagerMock.Setup(m => m.GetItemById(series.Id)).Returns(series);
+
+ var urls = _provider.GetExternalUrls(season);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_SeasonWithNoIndexNumber_ReturnsNoUrl()
+ {
+ var series = new Series { Id = Guid.NewGuid() };
+ series.SetProviderId(MetadataProvider.Imdb, "tt1234567");
+ var season = new Season { IndexNumber = null, SeriesId = series.Id };
+ _libraryManagerMock.Setup(m => m.GetItemById(series.Id)).Returns(series);
+
+ var urls = _provider.GetExternalUrls(season);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_SeasonWithUnknownSeriesId_ReturnsNoUrl()
+ {
+ var season = new Season { IndexNumber = 1, SeriesId = Guid.NewGuid() };
+ _libraryManagerMock.Setup(m => m.GetItemById(It.IsAny<Guid>())).Returns((BaseItem?)null);
+
+ var urls = _provider.GetExternalUrls(season);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_ItemWithNoImdbId_ReturnsNoUrl()
+ {
+ var movie = new Movie();
+
+ var urls = _provider.GetExternalUrls(movie);
+
+ Assert.Empty(urls);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/IsbnExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/IsbnExternalUrlProviderTests.cs
new file mode 100644
index 0000000000..228a9d2656
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/ExternalId/IsbnExternalUrlProviderTests.cs
@@ -0,0 +1,45 @@
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Providers.Books.Isbn;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.ExternalId
+{
+ public sealed class IsbnExternalUrlProviderTests
+ {
+ private readonly IsbnExternalUrlProvider _provider = new();
+
+ [Fact]
+ public void GetExternalUrls_BookWithIsbnId_ReturnsCorrectUrl()
+ {
+ var book = new Book();
+ book.SetProviderId("ISBN", "9780306406157");
+
+ var urls = _provider.GetExternalUrls(book);
+
+ Assert.Contains("https://search.worldcat.org/search?q=bn:9780306406157", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_BookWithNoIsbnId_ReturnsNoUrl()
+ {
+ var book = new Book();
+
+ var urls = _provider.GetExternalUrls(book);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_NonBookWithIsbnId_ReturnsNoUrl()
+ {
+ var series = new Series();
+ series.SetProviderId("ISBN", "9780306406157");
+
+ var urls = _provider.GetExternalUrls(series);
+
+ Assert.Empty(urls);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/MusicBrainzExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/MusicBrainzExternalUrlProviderTests.cs
new file mode 100644
index 0000000000..d35211f387
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/ExternalId/MusicBrainzExternalUrlProviderTests.cs
@@ -0,0 +1,202 @@
+using System;
+using System.Reflection;
+using MediaBrowser.Common;
+using MediaBrowser.Common.Configuration;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Audio;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Serialization;
+using MediaBrowser.Providers.Plugins.MusicBrainz;
+using MediaBrowser.Providers.Plugins.MusicBrainz.Configuration;
+using Microsoft.Extensions.Logging.Abstractions;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.ExternalId
+{
+ public sealed class MusicBrainzExternalUrlProviderTests : IDisposable
+ {
+ private static readonly PropertyInfo _instanceProperty =
+ typeof(Plugin).GetProperty("Instance", BindingFlags.Public | BindingFlags.Static)!;
+
+ private static readonly MethodInfo _instanceSetter =
+ _instanceProperty.GetSetMethod(nonPublic: true)!;
+
+ private readonly Plugin? _previousPlugin;
+
+ public MusicBrainzExternalUrlProviderTests()
+ {
+ _previousPlugin = Plugin.Instance;
+
+ var appPathsMock = new Mock<IApplicationPaths>();
+ appPathsMock.Setup(p => p.PluginsPath).Returns(System.IO.Path.GetTempPath());
+ appPathsMock.Setup(p => p.PluginConfigurationsPath).Returns(System.IO.Path.GetTempPath());
+
+ var xmlSerializerMock = new Mock<IXmlSerializer>();
+ xmlSerializerMock
+ .Setup(s => s.DeserializeFromFile(typeof(PluginConfiguration), It.IsAny<string>()))
+ .Returns(new PluginConfiguration());
+
+ var appHostMock = new Mock<IApplicationHost>();
+ appHostMock.Setup(h => h.Name).Returns("Jellyfin");
+ appHostMock.Setup(h => h.ApplicationVersionString).Returns("1.0.0");
+ appHostMock.Setup(h => h.ApplicationUserAgentAddress).Returns("localhost");
+
+ _ = new Plugin(appPathsMock.Object, xmlSerializerMock.Object, appHostMock.Object, NullLogger<Plugin>.Instance);
+ }
+
+ public void Dispose()
+ {
+ _instanceSetter.Invoke(null, new object?[] { _previousPlugin });
+ }
+
+ [Fact]
+ public void GetExternalUrls_MusicAlbumWithMusicBrainzAlbumId_ReturnsCorrectUrl()
+ {
+ var album = new MusicAlbum();
+ album.SetProviderId(MetadataProvider.MusicBrainzAlbum, "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
+
+ var urls = new MusicBrainzAlbumExternalUrlProvider().GetExternalUrls(album);
+
+ Assert.Contains(PluginConfiguration.DefaultServer + "/release/a1b2c3d4-e5f6-7890-abcd-ef1234567890", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_MusicAlbumWithNoMusicBrainzAlbumId_ReturnsNoUrl()
+ {
+ var album = new MusicAlbum();
+
+ var urls = new MusicBrainzAlbumExternalUrlProvider().GetExternalUrls(album);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_NonAlbumWithMusicBrainzAlbumId_ReturnsNoUrl()
+ {
+ var artist = new MusicArtist();
+ artist.SetProviderId(MetadataProvider.MusicBrainzAlbum, "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
+
+ var urls = new MusicBrainzAlbumExternalUrlProvider().GetExternalUrls(artist);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_MusicAlbumWithMusicBrainzAlbumArtistId_ReturnsCorrectUrl()
+ {
+ var album = new MusicAlbum();
+ album.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
+
+ var urls = new MusicBrainzAlbumArtistExternalUrlProvider().GetExternalUrls(album);
+
+ Assert.Contains(PluginConfiguration.DefaultServer + "/artist/a1b2c3d4-e5f6-7890-abcd-ef1234567890", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_MusicAlbumWithNoMusicBrainzAlbumArtistId_ReturnsNoUrl()
+ {
+ var album = new MusicAlbum();
+
+ var urls = new MusicBrainzAlbumArtistExternalUrlProvider().GetExternalUrls(album);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_MusicArtistWithMusicBrainzArtistId_ReturnsCorrectUrl()
+ {
+ var artist = new MusicArtist();
+ artist.SetProviderId(MetadataProvider.MusicBrainzArtist, "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
+
+ var urls = new MusicBrainzArtistExternalUrlProvider().GetExternalUrls(artist);
+
+ Assert.Contains(PluginConfiguration.DefaultServer + "/artist/a1b2c3d4-e5f6-7890-abcd-ef1234567890", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_PersonWithMusicBrainzArtistId_ReturnsCorrectUrl()
+ {
+ var person = new Person();
+ person.SetProviderId(MetadataProvider.MusicBrainzArtist, "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
+
+ var urls = new MusicBrainzArtistExternalUrlProvider().GetExternalUrls(person);
+
+ Assert.Contains(PluginConfiguration.DefaultServer + "/artist/a1b2c3d4-e5f6-7890-abcd-ef1234567890", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_MusicArtistWithNoMusicBrainzArtistId_ReturnsNoUrl()
+ {
+ var artist = new MusicArtist();
+
+ var urls = new MusicBrainzArtistExternalUrlProvider().GetExternalUrls(artist);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_NonArtistWithMusicBrainzArtistId_ReturnsNoUrl()
+ {
+ var album = new MusicAlbum();
+ album.SetProviderId(MetadataProvider.MusicBrainzArtist, "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
+
+ var urls = new MusicBrainzArtistExternalUrlProvider().GetExternalUrls(album);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_MusicAlbumWithMusicBrainzReleaseGroupId_ReturnsCorrectUrl()
+ {
+ var album = new MusicAlbum();
+ album.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
+
+ var urls = new MusicBrainzReleaseGroupExternalUrlProvider().GetExternalUrls(album);
+
+ Assert.Contains(PluginConfiguration.DefaultServer + "/release-group/a1b2c3d4-e5f6-7890-abcd-ef1234567890", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_MusicAlbumWithNoMusicBrainzReleaseGroupId_ReturnsNoUrl()
+ {
+ var album = new MusicAlbum();
+
+ var urls = new MusicBrainzReleaseGroupExternalUrlProvider().GetExternalUrls(album);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_AudioWithMusicBrainzTrackId_ReturnsCorrectUrl()
+ {
+ var audio = new Audio();
+ audio.SetProviderId(MetadataProvider.MusicBrainzTrack, "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
+
+ var urls = new MusicBrainzTrackExternalUrlProvider().GetExternalUrls(audio);
+
+ Assert.Contains(PluginConfiguration.DefaultServer + "/track/a1b2c3d4-e5f6-7890-abcd-ef1234567890", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_AudioWithNoMusicBrainzTrackId_ReturnsNoUrl()
+ {
+ var audio = new Audio();
+
+ var urls = new MusicBrainzTrackExternalUrlProvider().GetExternalUrls(audio);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_NonAudioWithMusicBrainzTrackId_ReturnsNoUrl()
+ {
+ var album = new MusicAlbum();
+ album.SetProviderId(MetadataProvider.MusicBrainzTrack, "a1b2c3d4-e5f6-7890-abcd-ef1234567890");
+
+ var urls = new MusicBrainzTrackExternalUrlProvider().GetExternalUrls(album);
+
+ Assert.Empty(urls);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/TmdbExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/TmdbExternalUrlProviderTests.cs
new file mode 100644
index 0000000000..814375a49c
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/ExternalId/TmdbExternalUrlProviderTests.cs
@@ -0,0 +1,193 @@
+using System;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Providers.Plugins.Tmdb;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.ExternalId
+{
+ // put tests that mock the static LibraryManager in the same collection to avoid test interference
+ [Collection("LibraryManagerTests")]
+ public sealed class TmdbExternalUrlProviderTests : IDisposable
+ {
+ private readonly TmdbExternalUrlProvider _provider = new();
+ private readonly Mock<ILibraryManager> _libraryManagerMock = new();
+ private readonly ILibraryManager? _previousLibraryManager;
+
+ public TmdbExternalUrlProviderTests()
+ {
+ _previousLibraryManager = BaseItem.LibraryManager;
+ BaseItem.LibraryManager = _libraryManagerMock.Object;
+ }
+
+ public void Dispose()
+ {
+ BaseItem.LibraryManager = _previousLibraryManager;
+ }
+
+ [Fact]
+ public void GetExternalUrls_SeriesWithTmdbId_ReturnsCorrectUrl()
+ {
+ var series = new Series();
+ series.SetProviderId(MetadataProvider.Tmdb, "1399");
+
+ var urls = _provider.GetExternalUrls(series);
+
+ Assert.Contains(TmdbUtils.BaseTmdbUrl + "tv/1399", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_SeriesWithNoTmdbId_ReturnsNoUrl()
+ {
+ var series = new Series();
+
+ var urls = _provider.GetExternalUrls(series);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_SeasonWithSeriesTmdbId_ReturnsCorrectUrl()
+ {
+ var series = new Series { Id = Guid.NewGuid() };
+ series.SetProviderId(MetadataProvider.Tmdb, "1399");
+
+ var season = new Season { IndexNumber = 3, SeriesId = series.Id };
+ _libraryManagerMock.Setup(m => m.GetItemById(series.Id)).Returns(series);
+
+ var urls = _provider.GetExternalUrls(season);
+
+ Assert.Contains(TmdbUtils.BaseTmdbUrl + "tv/1399/season/3", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_SeasonWithNoSeriesTmdbId_ReturnsNoUrl()
+ {
+ var series = new Series { Id = Guid.NewGuid() };
+ var season = new Season { IndexNumber = 1, SeriesId = series.Id };
+ _libraryManagerMock.Setup(m => m.GetItemById(series.Id)).Returns(series);
+
+ var urls = _provider.GetExternalUrls(season);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_SeasonWithNoIndexNumber_ReturnsNoUrl()
+ {
+ var series = new Series { Id = Guid.NewGuid() };
+ series.SetProviderId(MetadataProvider.Tmdb, "1399");
+ var season = new Season { IndexNumber = null, SeriesId = series.Id };
+ _libraryManagerMock.Setup(m => m.GetItemById(series.Id)).Returns(series);
+
+ var urls = _provider.GetExternalUrls(season);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_EpisodeWithSeriesTmdbId_ReturnsCorrectUrl()
+ {
+ var series = new Series { Id = Guid.NewGuid() };
+ series.SetProviderId(MetadataProvider.Tmdb, "1399");
+
+ var season = new Season { Id = Guid.NewGuid(), IndexNumber = 2, SeriesId = series.Id };
+
+ var episode = new Episode
+ {
+ IndexNumber = 5,
+ SeasonId = season.Id,
+ SeriesId = series.Id
+ };
+
+ _libraryManagerMock.Setup(m => m.GetItemById(series.Id)).Returns(series);
+ _libraryManagerMock.Setup(m => m.GetItemById(season.Id)).Returns(season);
+
+ var urls = _provider.GetExternalUrls(episode);
+
+ Assert.Contains(TmdbUtils.BaseTmdbUrl + "tv/1399/season/2/episode/5", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_EpisodeWithNoSeriesTmdbId_ReturnsNoUrl()
+ {
+ var series = new Series { Id = Guid.NewGuid() };
+ var season = new Season { Id = Guid.NewGuid(), IndexNumber = 1, SeriesId = series.Id };
+ var episode = new Episode { IndexNumber = 1, SeasonId = season.Id, SeriesId = series.Id };
+
+ _libraryManagerMock.Setup(m => m.GetItemById(series.Id)).Returns(series);
+ _libraryManagerMock.Setup(m => m.GetItemById(season.Id)).Returns(season);
+
+ var urls = _provider.GetExternalUrls(episode);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_MovieWithTmdbId_ReturnsCorrectUrl()
+ {
+ var movie = new Movie();
+ movie.SetProviderId(MetadataProvider.Tmdb, "550");
+
+ var urls = _provider.GetExternalUrls(movie);
+
+ Assert.Contains(TmdbUtils.BaseTmdbUrl + "movie/550", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_MovieWithNoTmdbId_ReturnsNoUrl()
+ {
+ var movie = new Movie();
+
+ var urls = _provider.GetExternalUrls(movie);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_PersonWithTmdbId_ReturnsCorrectUrl()
+ {
+ var person = new Person();
+ person.SetProviderId(MetadataProvider.Tmdb, "6384");
+
+ var urls = _provider.GetExternalUrls(person);
+
+ Assert.Contains(TmdbUtils.BaseTmdbUrl + "person/6384", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_PersonWithNoTmdbId_ReturnsNoUrl()
+ {
+ var person = new Person();
+
+ var urls = _provider.GetExternalUrls(person);
+
+ Assert.Empty(urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_BoxSetWithTmdbId_ReturnsCorrectUrl()
+ {
+ var boxSet = new BoxSet();
+ boxSet.SetProviderId(MetadataProvider.Tmdb, "10");
+
+ var urls = _provider.GetExternalUrls(boxSet);
+
+ Assert.Contains(TmdbUtils.BaseTmdbUrl + "collection/10", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_BoxSetWithNoTmdbId_ReturnsNoUrl()
+ {
+ var boxSet = new BoxSet();
+
+ var urls = _provider.GetExternalUrls(boxSet);
+
+ Assert.Empty(urls);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Providers.Tests/ExternalId/Zap2ItExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/Zap2ItExternalUrlProviderTests.cs
new file mode 100644
index 0000000000..dbe46d8fb1
--- /dev/null
+++ b/tests/Jellyfin.Providers.Tests/ExternalId/Zap2ItExternalUrlProviderTests.cs
@@ -0,0 +1,33 @@
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Providers.TV;
+using Xunit;
+
+namespace Jellyfin.Providers.Tests.ExternalId
+{
+ public sealed class Zap2ItExternalUrlProviderTests
+ {
+ private readonly Zap2ItExternalUrlProvider _provider = new();
+
+ [Fact]
+ public void GetExternalUrls_ItemWithZap2ItId_ReturnsCorrectUrl()
+ {
+ var series = new Series();
+ series.SetProviderId(MetadataProvider.Zap2It, "EP012345678901");
+
+ var urls = _provider.GetExternalUrls(series);
+
+ Assert.Contains("http://tvlistings.zap2it.com/overview.html?programSeriesId=EP012345678901", urls);
+ }
+
+ [Fact]
+ public void GetExternalUrls_ItemWithNoZap2ItId_ReturnsNoUrl()
+ {
+ var series = new Series();
+
+ var urls = _provider.GetExternalUrls(series);
+
+ Assert.Empty(urls);
+ }
+ }
+}
diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs
index 222e624aa2..876f18741f 100644
--- a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs
+++ b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs
@@ -123,13 +123,13 @@ public class MediaInfoResolverTests
var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
// any path other than test target exists and provides an empty listing
- directoryService.Setup(ds => ds.GetFilePaths(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<bool>()))
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(Array.Empty<string>());
_subtitleResolver.GetExternalFiles(video.Object, directoryService.Object, false);
directoryService.Verify(
- ds => ds.GetFilePaths(It.IsRegex(pathNotFoundRegex), It.IsAny<bool>(), It.IsAny<bool>()),
+ ds => ds.GetFilePaths(It.IsRegex(pathNotFoundRegex), It.IsAny<bool>()),
Times.Never);
}
@@ -196,7 +196,7 @@ public class MediaInfoResolverTests
};
var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
- directoryService.Setup(ds => ds.GetFilePaths(It.IsAny<string>(), It.IsAny<bool>(), It.IsAny<bool>()))
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsAny<string>(), It.IsAny<bool>()))
.Returns(Array.Empty<string>());
var mediaEncoder = Mock.Of<IMediaEncoder>(MockBehavior.Strict);
@@ -341,9 +341,9 @@ public class MediaInfoResolverTests
}
var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
- directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>()))
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>()))
.Returns(files);
- directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>()))
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>()))
.Returns(Array.Empty<string>());
List<MediaStream> GenerateMediaStreams()
@@ -413,16 +413,16 @@ public class MediaInfoResolverTests
var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
if (useMetadataDirectory)
{
- directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>()))
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>()))
.Returns(Array.Empty<string>());
- directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>()))
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>()))
.Returns(new[] { MetadataDirectoryPath + "/" + file });
}
else
{
- directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>()))
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>()))
.Returns(new[] { VideoDirectoryPath + "/" + file });
- directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>(), It.IsAny<bool>()))
+ directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>()))
.Returns(Array.Empty<string>());
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/EpisodeResolverTest.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/EpisodeResolverTest.cs
index cc2e47c33a..16b601dc3c 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/EpisodeResolverTest.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/EpisodeResolverTest.cs
@@ -61,6 +61,94 @@ namespace Jellyfin.Server.Implementations.Tests.Library
Assert.NotNull(episodeResolver.Resolve(itemResolveArgs));
}
+ [Theory]
+ [InlineData("/media/Show/Season 01/Show S01E01 [tvdbid=12345].mkv", MetadataProvider.Tvdb, "12345")]
+ [InlineData("/media/Show/Season 01/Show S01E01 [tvdbid-12345].mkv", MetadataProvider.Tvdb, "12345")]
+ [InlineData("/media/Show/Season 01/Show S01E01 (tvdbid=12345).mkv", MetadataProvider.Tvdb, "12345")]
+ [InlineData("/media/Show/Season 02/Show S02E03 [tvmazeid=67890].mkv", MetadataProvider.TvMaze, "67890")]
+ [InlineData("/media/Show/Season 02/Show S02E03 [tvmazeid-67890].mkv", MetadataProvider.TvMaze, "67890")]
+ [InlineData("/media/Show/Season 03/Show S03E04 [tmdbid=99999].mkv", MetadataProvider.Tmdb, "99999")]
+ [InlineData("/media/Show/Season 03/Show S03E04 [tmdbid-99999].mkv", MetadataProvider.Tmdb, "99999")]
+ [InlineData("/media/Show/Season 04/Show S04E05 [imdbid=tt1234567].mkv", MetadataProvider.Imdb, "tt1234567")]
+ [InlineData("/media/Show/Season 04/Show S04E05 [imdbid-tt1234567].mkv", MetadataProvider.Imdb, "tt1234567")]
+ public void Resolve_EpisodeFileWithProviderId_SetsProviderId(string path, MetadataProvider provider, string expectedId)
+ {
+ var series = new Series { Name = "Show" };
+ var episodeResolver = new EpisodeResolverMock(Mock.Of<ILogger<EpisodeResolver>>(), _namingOptions, Mock.Of<IDirectoryService>());
+ var itemResolveArgs = new ItemResolveArgs(
+ Mock.Of<IServerApplicationPaths>(),
+ null)
+ {
+ Parent = series,
+ CollectionType = CollectionType.tvshows,
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = path,
+ IsDirectory = false
+ }
+ };
+
+ var episode = episodeResolver.Resolve(itemResolveArgs);
+
+ Assert.NotNull(episode);
+ Assert.True(episode.TryGetProviderId(provider, out var actualId));
+ Assert.Equal(expectedId, actualId);
+ }
+
+ [Fact]
+ public void Resolve_EpisodeFileWithProviderIdsOnAllLevels_OnlyUsesEpisodeLevelId()
+ {
+ // Series folder has tvdbid=11111, season folder has tvdbid=22222, episode file has tvdbid=33333.
+ // The episode should only pick up its own ID, not the series- or season-level ones.
+ var series = new Series { Name = "Show" };
+ var episodeResolver = new EpisodeResolverMock(Mock.Of<ILogger<EpisodeResolver>>(), _namingOptions, Mock.Of<IDirectoryService>());
+ var itemResolveArgs = new ItemResolveArgs(
+ Mock.Of<IServerApplicationPaths>(),
+ null)
+ {
+ Parent = series,
+ CollectionType = CollectionType.tvshows,
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = "/media/Show [tvdbid=11111]/Season 01 [tvdbid=22222]/Show S01E01 [tvdbid=33333].mkv",
+ IsDirectory = false
+ }
+ };
+
+ var episode = episodeResolver.Resolve(itemResolveArgs);
+
+ Assert.NotNull(episode);
+ Assert.True(episode.TryGetProviderId(MetadataProvider.Tvdb, out var tvdbId));
+ Assert.Equal("33333", tvdbId);
+ }
+
+ [Fact]
+ public void Resolve_EpisodeFileWithMultipleProviderIds_SetsAll()
+ {
+ var series = new Series { Name = "Show" };
+ var episodeResolver = new EpisodeResolverMock(Mock.Of<ILogger<EpisodeResolver>>(), _namingOptions, Mock.Of<IDirectoryService>());
+ var itemResolveArgs = new ItemResolveArgs(
+ Mock.Of<IServerApplicationPaths>(),
+ null)
+ {
+ Parent = series,
+ CollectionType = CollectionType.tvshows,
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = "/media/Show/Season 01/Show S01E01 [tvdbid=12345][tmdbid=99999].mkv",
+ IsDirectory = false
+ }
+ };
+
+ var episode = episodeResolver.Resolve(itemResolveArgs);
+
+ Assert.NotNull(episode);
+ Assert.True(episode.TryGetProviderId(MetadataProvider.Tvdb, out var tvdbId));
+ Assert.Equal("12345", tvdbId);
+ Assert.True(episode.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbId));
+ Assert.Equal("99999", tmdbId);
+ }
+
private sealed class EpisodeResolverMock : EpisodeResolver
{
public EpisodeResolverMock(ILogger<EpisodeResolver> logger, NamingOptions namingOptions, IDirectoryService directoryService) : base(logger, namingOptions, directoryService)
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
index 8ed3d8b944..facdb2bc2e 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/MediaSourceManagerTests.cs
@@ -1,9 +1,18 @@
+using System;
using AutoFixture;
using AutoFixture.AutoMoq;
+using Castle.Components.DictionaryAdapter;
using Emby.Server.Implementations.IO;
using Emby.Server.Implementations.Library;
+using Jellyfin.Database.Implementations.Entities;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
+using Moq;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Library
@@ -11,12 +20,28 @@ namespace Jellyfin.Server.Implementations.Tests.Library
public class MediaSourceManagerTests
{
private readonly MediaSourceManager _mediaSourceManager;
+ private readonly Mock<IUserDataManager> _mockUserDataManager;
+ private readonly Mock<ILocalizationManager> _mockLocalizationManager;
+ private Video _item;
+ private User _user;
public MediaSourceManagerTests()
{
IFixture fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true });
fixture.Inject<IFileSystem>(fixture.Create<ManagedFileSystem>());
+
+ _mockUserDataManager = fixture.Freeze<Mock<IUserDataManager>>();
+ _mockUserDataManager.Setup(m => m.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>())).Returns(new UserItemData() { Key = "key" });
+
+ _mockLocalizationManager = fixture.Create<Mock<ILocalizationManager>>();
+ _mockLocalizationManager.Setup(m => m.FindLanguageInfo(It.IsAny<string>())).Returns((string s) => string.IsNullOrEmpty(s) ? null : new CultureDto(s, s, s, new EditableList<string> { s }));
+ fixture.Inject(_mockLocalizationManager.Object);
+
_mediaSourceManager = fixture.Create<MediaSourceManager>();
+
+ _item = new Video { Id = Guid.NewGuid(), OwnerId = Guid.Empty, ParentId = Guid.Empty };
+
+ _user = fixture.Create<User>();
}
[Theory]
@@ -28,5 +53,96 @@ namespace Jellyfin.Server.Implementations.Tests.Library
[InlineData("rtsp://media.example.com:554/twister/audiotrack", MediaProtocol.Rtsp)]
public void GetPathProtocol_ValidArg_Correct(string path, MediaProtocol expected)
=> Assert.Equal(expected, _mediaSourceManager.GetPathProtocol(path));
+
+ [Theory]
+ [InlineData(5, "eng", "eng", false, true)]
+ [InlineData(5, "eng", "eng", true, true)]
+ [InlineData(2, "ger", "eng", false, true)]
+ [InlineData(2, "ger", "eng", true, true)]
+ [InlineData(1, "fre", "eng", false, true)]
+ [InlineData(2, "fre", "eng", true, true)]
+ [InlineData(5, "OriginalLanguage", "eng", false, false)]
+ [InlineData(4, "OriginalLanguage", "eng", false, true)]
+ [InlineData(5, "OriginalLanguage", "eng", true, false)]
+ [InlineData(5, "OriginalLanguage", "eng", true, true)]
+ [InlineData(2, "OriginalLanguage", "jpn", true, true)]
+ [InlineData(2, "OriginalLanguage", "jpn", false, true)]
+ [InlineData(2, "OriginalLanguage", "jpn,eng", false, true)]
+ [InlineData(4, "OriginalLanguage", null, false, true)]
+ [InlineData(2, "OriginalLanguage", null, true, true)]
+ [InlineData(4, "OriginalLanguage", "", false, true)]
+ [InlineData(2, "OriginalLanguage", "", false, false)]
+ [InlineData(2, "OriginalLanguage", "ger", false, true)]
+ [InlineData(2, "OriginalLanguage", "ger", false, false)]
+ [InlineData(1, "OriginalLanguage", "fre", false, false)]
+ [InlineData(2, "OriginalLanguage", "fre", true, true)]
+ [InlineData(2, "OriginalLanguage", "fre", true, false)]
+ public void SetDefaultAudioStreamIndex_Index_Correct(
+ int expectedIndex,
+ string prefferedLanguage,
+ string? originalLanguage,
+ bool playDefault,
+ bool originalExist)
+ {
+ var streams = new MediaStream[]
+ {
+ new()
+ {
+ Index = 0,
+ Type = MediaStreamType.Video,
+ IsDefault = true
+ },
+ new()
+ {
+ Index = 1,
+ Type = MediaStreamType.Audio,
+ Language = "fre",
+ IsDefault = false,
+ IsOriginal = false
+ },
+ new()
+ {
+ Index = 2,
+ Type = MediaStreamType.Audio,
+ Language = "jpn",
+ IsDefault = true,
+ IsOriginal = false
+ },
+ new()
+ {
+ Index = 3,
+ Type = MediaStreamType.Audio,
+ Language = "eng",
+ IsDefault = false,
+ IsOriginal = false
+ },
+ new()
+ {
+ Index = 4,
+ Type = MediaStreamType.Audio,
+ Language = "eng",
+ IsDefault = false,
+ IsOriginal = originalExist,
+ },
+ new()
+ {
+ Index = 5,
+ Type = MediaStreamType.Audio,
+ Language = "eng",
+ IsDefault = true,
+ IsOriginal = false,
+ }
+ };
+ var mediaInfo = new MediaSourceInfo
+ {
+ MediaStreams = streams
+ };
+ _user.AudioLanguagePreference = prefferedLanguage;
+ _user.PlayDefaultAudioTrack = playDefault;
+ _item.OriginalLanguage = originalLanguage;
+
+ _mediaSourceManager.SetDefaultAudioAndSubtitleStreamIndices(_item, mediaInfo, _user);
+ Assert.Equal(expectedIndex, mediaInfo.DefaultAudioStreamIndex);
+ }
}
}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs
new file mode 100644
index 0000000000..133a3f7d47
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs
@@ -0,0 +1,145 @@
+using Emby.Naming.Common;
+using Emby.Server.Implementations.Library.Resolvers.TV;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Model.Configuration;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Globalization;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Library
+{
+ public class SeasonResolverTests
+ {
+ private static readonly NamingOptions _namingOptions = new();
+ private readonly SeasonResolver _resolver;
+
+ public SeasonResolverTests()
+ {
+ var localizationMock = new Mock<ILocalizationManager>();
+ localizationMock
+ .Setup(l => l.GetLocalizedString(It.IsAny<string>()))
+ .Returns("Season {0}");
+
+ _resolver = new SeasonResolver(
+ _namingOptions,
+ localizationMock.Object,
+ Mock.Of<ILogger<SeasonResolver>>());
+ }
+
+ [Theory]
+ [InlineData("/media/Show/Season 01 [tvdbid=12345]", MetadataProvider.Tvdb, "12345")]
+ [InlineData("/media/Show/Season 01 [tvdbid-12345]", MetadataProvider.Tvdb, "12345")]
+ [InlineData("/media/Show/Season 01 (tvdbid=12345)", MetadataProvider.Tvdb, "12345")]
+ [InlineData("/media/Show/Season 02 [tvmazeid=67890]", MetadataProvider.TvMaze, "67890")]
+ [InlineData("/media/Show/Season 02 [tvmazeid-67890]", MetadataProvider.TvMaze, "67890")]
+ [InlineData("/media/Show/Season 03 [tmdbid=99999]", MetadataProvider.Tmdb, "99999")]
+ [InlineData("/media/Show/Season 03 [tmdbid-99999]", MetadataProvider.Tmdb, "99999")]
+ public void Resolve_SeasonFolderWithProviderId_SetsProviderId(string path, MetadataProvider provider, string expectedId)
+ {
+ var series = new Series { Path = "/media/Show" };
+
+ var args = new MediaBrowser.Controller.Library.ItemResolveArgs(
+ Mock.Of<IServerApplicationPaths>(),
+ null)
+ {
+ Parent = series,
+ LibraryOptions = new LibraryOptions(),
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = path,
+ IsDirectory = true
+ }
+ };
+
+ var season = _resolver.Resolve(args);
+
+ Assert.NotNull(season);
+ Assert.True(season.TryGetProviderId(provider, out var actualId));
+ Assert.Equal(expectedId, actualId);
+ }
+
+ [Fact]
+ public void Resolve_SeasonFolderWithMultipleProviderIds_SetsAll()
+ {
+ var series = new Series { Path = "/media/Show" };
+
+ var args = new MediaBrowser.Controller.Library.ItemResolveArgs(
+ Mock.Of<IServerApplicationPaths>(),
+ null)
+ {
+ Parent = series,
+ LibraryOptions = new LibraryOptions(),
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = "/media/Show/Season 01 [tvdbid=12345][tmdbid=99999]",
+ IsDirectory = true
+ }
+ };
+
+ var season = _resolver.Resolve(args);
+
+ Assert.NotNull(season);
+ Assert.True(season.TryGetProviderId(MetadataProvider.Tvdb, out var tvdbId));
+ Assert.Equal("12345", tvdbId);
+ Assert.True(season.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbId));
+ Assert.Equal("99999", tmdbId);
+ }
+
+ [Fact]
+ public void Resolve_SeasonFolderWithSeriesProviderIdInParentPath_DoesNotInheritSeriesId()
+ {
+ // Series folder has tvdbid=11111, season folder has tvdbid=22222.
+ // The season should only pick up its own ID, not the series-level one.
+ var series = new Series { Path = "/media/Show [tvdbid=11111]" };
+
+ var args = new MediaBrowser.Controller.Library.ItemResolveArgs(
+ Mock.Of<IServerApplicationPaths>(),
+ null)
+ {
+ Parent = series,
+ LibraryOptions = new LibraryOptions(),
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = "/media/Show [tvdbid=11111]/Season 01 [tvdbid=22222]",
+ IsDirectory = true
+ }
+ };
+
+ var season = _resolver.Resolve(args);
+
+ Assert.NotNull(season);
+ Assert.True(season.TryGetProviderId(MetadataProvider.Tvdb, out var tvdbId));
+ Assert.Equal("22222", tvdbId);
+ }
+
+ [Fact]
+ public void Resolve_SeasonFolderWithNoProviderId_HasNoProviderIds()
+ {
+ var series = new Series { Path = "/media/Show" };
+
+ var args = new MediaBrowser.Controller.Library.ItemResolveArgs(
+ Mock.Of<IServerApplicationPaths>(),
+ null)
+ {
+ Parent = series,
+ LibraryOptions = new LibraryOptions(),
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = "/media/Show/Season 01",
+ IsDirectory = true
+ }
+ };
+
+ var season = _resolver.Resolve(args);
+
+ Assert.NotNull(season);
+ Assert.False(season.TryGetProviderId(MetadataProvider.Tvdb, out _));
+ Assert.False(season.TryGetProviderId(MetadataProvider.TvMaze, out _));
+ Assert.False(season.TryGetProviderId(MetadataProvider.Tmdb, out _));
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/SeriesResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/SeriesResolverTests.cs
new file mode 100644
index 0000000000..8dbd5f5b41
--- /dev/null
+++ b/tests/Jellyfin.Server.Implementations.Tests/Library/SeriesResolverTests.cs
@@ -0,0 +1,124 @@
+using Emby.Naming.Common;
+using Emby.Server.Implementations.Library.Resolvers.TV;
+using Jellyfin.Data.Enums;
+using MediaBrowser.Controller;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.IO;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Server.Implementations.Tests.Library
+{
+ public class SeriesResolverTests
+ {
+ private static readonly NamingOptions _namingOptions = new();
+ private readonly SeriesResolver _resolver;
+ private readonly Mock<ILibraryManager> _libraryManagerMock;
+
+ public SeriesResolverTests()
+ {
+ _libraryManagerMock = new Mock<ILibraryManager>();
+ // Return null so that configuredContentType != CollectionType.tvshows, allowing series resolution.
+ _libraryManagerMock
+ .Setup(m => m.GetConfiguredContentType(It.IsAny<string>()))
+ .Returns((CollectionType?)null);
+
+ _resolver = new SeriesResolver(Mock.Of<ILogger<SeriesResolver>>(), _namingOptions);
+ }
+
+ private MediaBrowser.Controller.Library.ItemResolveArgs MakeTvArgs(string path) =>
+ new(Mock.Of<IServerApplicationPaths>(), _libraryManagerMock.Object)
+ {
+ CollectionType = CollectionType.tvshows,
+ FileSystemChildren = [],
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = path,
+ IsDirectory = true
+ }
+ };
+
+ [Theory]
+ [InlineData("/media/Show [tvdbid=12345]", MetadataProvider.Tvdb, "12345")]
+ [InlineData("/media/Show [tvdbid-12345]", MetadataProvider.Tvdb, "12345")]
+ [InlineData("/media/Show (tvdbid=12345)", MetadataProvider.Tvdb, "12345")]
+ [InlineData("/media/Show [tvmazeid=67890]", MetadataProvider.TvMaze, "67890")]
+ [InlineData("/media/Show [tvmazeid-67890]", MetadataProvider.TvMaze, "67890")]
+ [InlineData("/media/Show [tmdbid=99999]", MetadataProvider.Tmdb, "99999")]
+ [InlineData("/media/Show [tmdbid-99999]", MetadataProvider.Tmdb, "99999")]
+ [InlineData("/media/Show [imdbid=tt1234567]", MetadataProvider.Imdb, "tt1234567")]
+ [InlineData("/media/Show [imdbid-tt1234567]", MetadataProvider.Imdb, "tt1234567")]
+ public void ResolvePath_SeriesFolderWithProviderId_SetsProviderId(string path, MetadataProvider provider, string expectedId)
+ {
+ var series = _resolver.ResolvePath(MakeTvArgs(path)) as Series;
+
+ Assert.NotNull(series);
+ Assert.True(series.TryGetProviderId(provider, out var actualId));
+ Assert.Equal(expectedId, actualId);
+ }
+
+ [Theory]
+ [InlineData("/media/Show [anidbid=11111]", "AniDB", "11111")]
+ [InlineData("/media/Show [anilistid=22222]", "AniList", "22222")]
+ [InlineData("/media/Show [anisearchid=33333]", "AniSearch", "33333")]
+ public void ResolvePath_SeriesFolderWithAniProviderId_SetsProviderId(string path, string providerKey, string expectedId)
+ {
+ var series = _resolver.ResolvePath(MakeTvArgs(path)) as Series;
+
+ Assert.NotNull(series);
+ Assert.True(series.TryGetProviderId(providerKey, out var actualId));
+ Assert.Equal(expectedId, actualId);
+ }
+
+ [Fact]
+ public void ResolvePath_SeriesFolderWithMultipleProviderIds_SetsAll()
+ {
+ var series = _resolver.ResolvePath(MakeTvArgs("/media/Show [tvdbid=12345][tmdbid=99999]")) as Series;
+
+ Assert.NotNull(series);
+ Assert.True(series.TryGetProviderId(MetadataProvider.Tvdb, out var tvdbId));
+ Assert.Equal("12345", tvdbId);
+ Assert.True(series.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbId));
+ Assert.Equal("99999", tmdbId);
+ }
+
+ [Fact]
+ public void ResolvePath_SeriesFolderWithNoProviderId_HasNoProviderIds()
+ {
+ var series = _resolver.ResolvePath(MakeTvArgs("/media/Show")) as Series;
+
+ Assert.NotNull(series);
+ Assert.False(series.TryGetProviderId(MetadataProvider.Tvdb, out _));
+ Assert.False(series.TryGetProviderId(MetadataProvider.TvMaze, out _));
+ Assert.False(series.TryGetProviderId(MetadataProvider.Tmdb, out _));
+ Assert.False(series.TryGetProviderId(MetadataProvider.Imdb, out _));
+ Assert.False(series.TryGetProviderId("AniDB", out _));
+ Assert.False(series.TryGetProviderId("AniList", out _));
+ Assert.False(series.TryGetProviderId("AniSearch", out _));
+ }
+
+ [Fact]
+ public void ResolvePath_SeriesFolderNotInTvShowsCollection_DoesNotResolve()
+ {
+ // Without CollectionType.tvshows, a plain folder with no tvshow.nfo and
+ // no season/episode children should not resolve as a Series.
+ var args = new MediaBrowser.Controller.Library.ItemResolveArgs(
+ Mock.Of<IServerApplicationPaths>(),
+ _libraryManagerMock.Object)
+ {
+ CollectionType = null,
+ FileSystemChildren = [],
+ FileInfo = new FileSystemMetadata
+ {
+ FullName = "/media/Show [tvdbid=12345]",
+ IsDirectory = true
+ }
+ };
+
+ Assert.Null(_resolver.ResolvePath(args));
+ }
+ }
+}
diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
index 5bcfc580ff..acabaf3acb 100644
--- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
+++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs
@@ -242,6 +242,40 @@ namespace Jellyfin.Server.Implementations.Tests.Localization
}
[Theory]
+ [InlineData("US:INVALID", "US")] // Colon separator, known country code, unknown rating
+ [InlineData("us:INVALID", "US")] // Colon separator, lowercase country code
+ [InlineData("DE-INVALID", "US")] // Hyphen separator, known language prefix, unknown rating
+ [InlineData("ca:INVALID", "US")] // Colon separator, known country code (Canada)
+ public async Task GetRatingScore_UnknownRatingWithKnownCountry_ReturnsNull(string rating, string countryCode)
+ {
+ var localizationManager = Setup(new ServerConfiguration
+ {
+ MetadataCountryCode = countryCode
+ });
+ await localizationManager.LoadAll();
+
+ Assert.Null(localizationManager.GetRatingScore(rating));
+ }
+
+ [Theory]
+ [InlineData("us:R", "DE", 17, 0)] // Colon separator, explicit US country, valid US rating
+ [InlineData("US:PG-13", "DE", 13, 0)] // Colon separator, explicit US country, valid US rating
+ [InlineData("ca:R", "US", 18, 1)] // Colon separator, Canada country code, valid CA rating
+ public async Task GetRatingScore_ValidRatingWithCountrySeparator_ReturnsScore(string rating, string countryCode, int expectedScore, int? expectedSubScore)
+ {
+ var localizationManager = Setup(new ServerConfiguration
+ {
+ MetadataCountryCode = countryCode
+ });
+ await localizationManager.LoadAll();
+
+ var score = localizationManager.GetRatingScore(rating);
+ Assert.NotNull(score);
+ Assert.Equal(expectedScore, score.Score);
+ Assert.Equal(expectedSubScore, score.SubScore);
+ }
+
+ [Theory]
[InlineData("Default", "Default")]
[InlineData("HeaderLiveTV", "Live TV")]
public void GetLocalizedString_Valid_Success(string key, string expected)
diff --git a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs
index 0952fb8b63..54f443de2d 100644
--- a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs
+++ b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs
@@ -111,7 +111,7 @@ namespace Jellyfin.Server.Integration.Tests
var appHost = (TestAppHost)host.Services.GetRequiredService<IApplicationHost>();
appHost.ServiceProvider = host.Services;
var applicationPaths = appHost.ServiceProvider.GetRequiredService<IApplicationPaths>();
- Program.ApplyStartupMigrationAsync((ServerApplicationPaths)applicationPaths, appHost.ServiceProvider.GetRequiredService<IConfiguration>()).GetAwaiter().GetResult();
+ Program.ApplyStartupMigrationAsync((ServerApplicationPaths)applicationPaths, appHost.ServiceProvider.GetRequiredService<IConfiguration>(), new()).GetAwaiter().GetResult();
Program.ApplyCoreMigrationsAsync(appHost.ServiceProvider, Migrations.Stages.JellyfinMigrationStageTypes.CoreInitialisation).GetAwaiter().GetResult();
appHost.InitializeServices(Mock.Of<IConfiguration>()).GetAwaiter().GetResult();
Program.ApplyCoreMigrationsAsync(appHost.ServiceProvider, Migrations.Stages.JellyfinMigrationStageTypes.AppInitialisation).GetAwaiter().GetResult();
diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs
index 1e8652f4b9..4142831c31 100644
--- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs
+++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs
@@ -294,5 +294,48 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers
// Verify that the lowercase "tmdbcol" is NOT in the provider IDs
Assert.False(item.ProviderIds.ContainsKey("tmdbcol"));
}
+
+ [Fact]
+ public void Parse_CommunityRating_ValidRating_Success()
+ {
+ var result = new MetadataResult<Video>()
+ {
+ Item = new Movie()
+ };
+
+ _parser.Fetch(result, "Test Data/CommunityRating.nfo", CancellationToken.None);
+ var item = (Movie)result.Item;
+
+ Assert.Equal(7.5f, item.CommunityRating);
+ }
+
+ [Fact]
+ public void Parse_CommunityRating_OutOfRange_Ignored()
+ {
+ var result = new MetadataResult<Video>()
+ {
+ Item = new Movie()
+ };
+
+ _parser.Fetch(result, "Test Data/CommunityRating_OutOfRange.nfo", CancellationToken.None);
+ var item = (Movie)result.Item;
+
+ // Rating should not be set if outside 0-10 range
+ Assert.Null(item.CommunityRating);
+ }
+
+ [Fact]
+ public void Parse_CommunityRating_Comma()
+ {
+ var result = new MetadataResult<Video>()
+ {
+ Item = new Movie()
+ };
+
+ _parser.Fetch(result, "Test Data/CommunityRating_Comma.nfo", CancellationToken.None);
+ var item = (Movie)result.Item;
+
+ Assert.Equal(7.5f, item.CommunityRating);
+ }
}
}
diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating.nfo b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating.nfo
new file mode 100644
index 0000000000..387de10c0e
--- /dev/null
+++ b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating.nfo
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<movie>
+ <title>Test Movie</title>
+ <communityrating>7.5</communityrating>
+</movie> \ No newline at end of file
diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating_Comma.nfo b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating_Comma.nfo
new file mode 100644
index 0000000000..4ec215e2e1
--- /dev/null
+++ b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating_Comma.nfo
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<movie>
+ <title>Test Movie</title>
+ <communityrating>7,5</communityrating>
+</movie> \ No newline at end of file
diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating_OutOfRange.nfo b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating_OutOfRange.nfo
new file mode 100644
index 0000000000..126854edd3
--- /dev/null
+++ b/tests/Jellyfin.XbmcMetadata.Tests/Test Data/CommunityRating_OutOfRange.nfo
@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<movie>
+ <title>Test Movie</title>
+ <communityrating>15.5</communityrating>
+</movie> \ No newline at end of file