diff options
64 files changed, 3176 insertions, 201 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 e5478a7ba6..09a7198afe 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -230,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/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 94e2468719..321c7da1c4 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -1067,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; 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/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/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/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/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/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/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.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.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/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index 8d30513cc8..a0ffe9aea0 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -46,9 +46,10 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I { // 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. + // 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) + .GroupBy(e => e.Name.ToLower()) .Select(g => g.Min(e => e.Id)); dbQuery = context.Peoples.AsNoTracking() .Where(p => representativeIds.Contains(p.Id)) @@ -102,16 +103,16 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I person.Role = person.Role?.Trim() ?? string.Empty; } - // multiple metadata providers can provide the _same_ person - people = people.DistinctBy(e => e.Name + "-" + e.Type).ToArray(); - var personKeys = people.Select(e => e.Name + "-" + e.Type).ToArray(); + // multiple metadata providers can provide the _same_ person; dedupe case-insensitively. + people = people.DistinctBy(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray(); + var personKeys = people.Select(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray(); using var context = _dbProvider.CreateDbContext(); using var transaction = context.Database.BeginTransaction(); var existingPersons = context.Peoples.Select(e => new { item = e, - SelectionKey = e.Name + "-" + e.PersonType + SelectionKey = e.Name.ToLower() + "-" + e.PersonType }) .Where(p => personKeys.Contains(p.SelectionKey)) .Select(f => f.item) @@ -119,7 +120,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I var toAdd = people .Where(e => e.Type is not PersonKind.Artist && e.Type is not PersonKind.AlbumArtist) - .Where(e => !existingPersons.Any(f => f.Name == e.Name && f.PersonType == e.Type.ToString())) + .Where(e => !existingPersons.Any(f => string.Equals(f.Name, e.Name, StringComparison.OrdinalIgnoreCase) && f.PersonType == e.Type.ToString())) .Select(Map); context.Peoples.AddRange(toAdd); context.SaveChanges(); @@ -137,8 +138,8 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I continue; } - var entityPerson = personsEntities.First(e => e.Name == person.Name && e.PersonType == person.Type.ToString()); - var existingMap = existingMaps.FirstOrDefault(e => e.People.Name == person.Name && e.People.PersonType == person.Type.ToString() && e.Role == person.Role); + var entityPerson = personsEntities.First(e => string.Equals(e.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.PersonType == person.Type.ToString()); + var existingMap = existingMaps.FirstOrDefault(e => string.Equals(e.People.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.People.PersonType == person.Type.ToString() && e.Role == person.Role); if (existingMap is null) { context.PeopleBaseItemMap.Add(new PeopleBaseItemMap() @@ -239,7 +240,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I if (queryExcludePersonTypes.Count > 0) { - query = query.Where(e => !queryPersonTypes.Contains(e.PersonType)); + query = query.Where(e => !queryExcludePersonTypes.Contains(e.PersonType)); } if (filter.MaxListOrder.HasValue && !filter.ItemId.IsEmpty()) 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/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/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/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..04b13a6f3c 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -2466,6 +2466,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.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.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/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/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.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/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.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/MusicBrainzExternalUrlProviderTests.cs b/tests/Jellyfin.Providers.Tests/ExternalId/MusicBrainzExternalUrlProviderTests.cs index 920529bf73..d35211f387 100644 --- a/tests/Jellyfin.Providers.Tests/ExternalId/MusicBrainzExternalUrlProviderTests.cs +++ b/tests/Jellyfin.Providers.Tests/ExternalId/MusicBrainzExternalUrlProviderTests.cs @@ -8,6 +8,7 @@ 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; @@ -41,7 +42,7 @@ namespace Jellyfin.Providers.Tests.ExternalId appHostMock.Setup(h => h.ApplicationVersionString).Returns("1.0.0"); appHostMock.Setup(h => h.ApplicationUserAgentAddress).Returns("localhost"); - _ = new Plugin(appPathsMock.Object, xmlSerializerMock.Object, appHostMock.Object); + _ = new Plugin(appPathsMock.Object, xmlSerializerMock.Object, appHostMock.Object, NullLogger<Plugin>.Instance); } public void Dispose() 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); + } } } |
