From 8c4dfc0b710c9314061911eea0daacfd855326e4 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 8 Aug 2026 19:49:26 +0200 Subject: Safeguard against invalid provider ids --- .../Entities/ProviderIdsExtensions.cs | 59 +++++++++- MediaBrowser.Providers/Manager/MetadataService.cs | 42 +++++++- .../Music/AlbumInfoExtensions.cs | 23 ++-- .../Tmdb/BoxSets/TmdbBoxSetImageProvider.cs | 4 +- .../Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs | 5 +- .../Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs | 4 +- .../Plugins/Tmdb/Movies/TmdbMovieProvider.cs | 24 +++-- .../Plugins/Tmdb/People/TmdbPersonImageProvider.cs | 5 +- .../Plugins/Tmdb/People/TmdbPersonProvider.cs | 9 +- .../Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs | 6 +- .../Plugins/Tmdb/TV/TmdbEpisodeProvider.cs | 3 +- .../Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs | 6 +- .../Plugins/Tmdb/TV/TmdbSeasonProvider.cs | 5 +- .../Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs | 8 +- .../Plugins/Tmdb/TV/TmdbSeriesProvider.cs | 4 +- MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs | 28 +++++ .../Entities/ProviderIdsExtensionsTests.cs | 43 ++++++++ .../Manager/MetadataServiceRefreshTests.cs | 120 +++++++++++++++++++++ .../Music/AlbumInfoExtensionsTests.cs | 59 ++++++++++ .../Tmdb/TmdbUtilsTests.cs | 37 +++++++ 20 files changed, 437 insertions(+), 57 deletions(-) create mode 100644 tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs create mode 100644 tests/Jellyfin.Providers.Tests/Music/AlbumInfoExtensionsTests.cs diff --git a/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs b/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs index 385a86d31c..27d7a4654b 100644 --- a/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs +++ b/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs @@ -1,14 +1,16 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Linq; +using System.Text.RegularExpressions; namespace MediaBrowser.Model.Entities; /// /// Class ProviderIdsExtensions. /// -public static class ProviderIdsExtensions +public static partial class ProviderIdsExtensions { /// /// Case-insensitive dictionary of string representation. @@ -20,6 +22,27 @@ public static class ProviderIdsExtensions enumValue => enumValue.ToString(), StringComparer.OrdinalIgnoreCase); + /// + /// The known id formats, keyed by provider name. + /// + private static readonly Dictionary> _providerIdValidators = + new(StringComparer.OrdinalIgnoreCase) + { + [MetadataProvider.Imdb.ToString()] = value => ImdbIdRegex().IsMatch(value), + [MetadataProvider.Tmdb.ToString()] = IsPositiveNumber, + [MetadataProvider.TmdbCollection.ToString()] = IsPositiveNumber, + [MetadataProvider.AudioDbArtist.ToString()] = IsPositiveNumber, + [MetadataProvider.AudioDbAlbum.ToString()] = IsPositiveNumber, + + // Every MusicBrainz id is an MBID. + [MetadataProvider.MusicBrainzAlbum.ToString()] = IsGuid, + [MetadataProvider.MusicBrainzAlbumArtist.ToString()] = IsGuid, + [MetadataProvider.MusicBrainzArtist.ToString()] = IsGuid, + [MetadataProvider.MusicBrainzReleaseGroup.ToString()] = IsGuid, + [MetadataProvider.MusicBrainzRecording.ToString()] = IsGuid, + [MetadataProvider.MusicBrainzTrack.ToString()] = IsGuid + }; + /// /// Checks if this instance has an id for the given provider. /// @@ -101,6 +124,26 @@ public static class ProviderIdsExtensions return instance.GetProviderId(provider.ToString()); } + /// + /// Checks whether a value can be an id of the given provider. + /// + /// The provider name. + /// The provider id. + /// true if the value has a plausible format for the provider; otherwise, false. + /// + /// Providers regularly hand out an id belonging to a different service, e.g. an IMDb person id in the + /// TMDb field. Such an id is not just useless, it also makes the owning provider fail for the item. + /// + public static bool IsValidProviderId(string? name, string? value) + { + if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(value)) + { + return false; + } + + return !_providerIdValidators.TryGetValue(name, out var isValid) || isValid(value); + } + /// /// Sets a provider id. /// @@ -116,7 +159,8 @@ public static class ProviderIdsExtensions // When name contains a '=' it can't be deserialized from the database if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(value) - || name.Contains('=', StringComparison.Ordinal)) + || name.Contains('=', StringComparison.Ordinal) + || !IsValidProviderId(name, value)) { return false; } @@ -213,4 +257,15 @@ public static class ProviderIdsExtensions instance.ProviderIds?.Remove(provider.ToString()); } + + private static bool IsPositiveNumber(string value) + => long.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var id) && id > 0; + + private static bool IsGuid(string value) + => Guid.TryParse(value, CultureInfo.InvariantCulture, out _); + + // An IMDb id is a type prefix (tt for titles, nm for people, co for companies, ...) followed by + // digits. The prefix is optional because a bare number has always been accepted for a title. + [GeneratedRegex(@"^(tt|nm|co|ev|ch|ni)?[0-9]+$", RegexOptions.IgnoreCase)] + private static partial Regex ImdbIdRegex(); } diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index 40f2775bd3..fb1781accc 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -858,7 +858,10 @@ namespace MediaBrowser.Providers.Manager { if (refreshResult.UpdateType > ItemUpdateType.None) { - if (!options.RemoveOldMetadata) + // A provider that failed contributed nothing, so the result is not the complete + // replacement the caller asked for. Keeping the existing values stops a provider being + // temporarily unreachable, or choking on a bad id, from deleting the data it owns. + if (!options.RemoveOldMetadata || refreshResult.Failures > 0) { // Add existing metadata to provider result if it does not exist there MergeData(metadata, temp, [], false, false); @@ -932,6 +935,8 @@ namespace MediaBrowser.Providers.Manager { result.Provider = provider.Name; + LogInvalidProviderIds(result.Item, providerName, logName); + MergeData(result, temp, [], replaceData, false); MergeNewData(temp.Item, id); @@ -957,6 +962,29 @@ namespace MediaBrowser.Providers.Manager return refreshResult; } + /// + /// Reports the ids a provider returned that cannot belong to the provider they are filed under. + /// + /// + /// The ids are dropped when merging, this names the provider that produced them so the source of a + /// recurring bad id can be found. + /// + private void LogInvalidProviderIds(TItemType item, string providerName, string logName) + { + if (item?.ProviderIds is null || !Logger.IsEnabled(LogLevel.Debug)) + { + return; + } + + foreach (var (key, value) in item.ProviderIds) + { + if (!ProviderIdsExtensions.IsValidProviderId(key, value)) + { + Logger.LogDebug("Discarding {Key} id '{Value}' returned by {Provider} for {Item}", key, value, providerName, logName); + } + } + } + private void MergeNewData(TItemType source, TIdType lookupInfo) { // Copy new provider id's that may have been obtained @@ -964,6 +992,11 @@ namespace MediaBrowser.Providers.Manager { var key = providerId.Key; + if (!ProviderIdsExtensions.IsValidProviderId(key, providerId.Value)) + { + continue; + } + // Don't replace existing Id's. lookupInfo.ProviderIds.TryAdd(key, providerId.Value); } @@ -1175,6 +1208,13 @@ namespace MediaBrowser.Providers.Manager { var key = id.Key; + // An id that cannot belong to the provider it is filed under only breaks that provider on + // the next refresh, so never let one in - not even when replacing all metadata. + if (!ProviderIdsExtensions.IsValidProviderId(key, id.Value)) + { + continue; + } + // Don't replace existing Id's. if (replaceData) { diff --git a/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs b/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs index d3fce37c71..d50e2c6c11 100644 --- a/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs +++ b/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs @@ -1,5 +1,7 @@ #pragma warning disable CS1591 +using System; +using System.Globalization; using System.Linq; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; @@ -23,11 +25,11 @@ namespace MediaBrowser.Providers.Music public static string? GetReleaseGroupId(this AlbumInfo info) { - var id = info.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup); + var id = MusicBrainzId(info.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup)); if (string.IsNullOrEmpty(id)) { - return info.SongInfos.Select(i => i.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup)) + return info.SongInfos.Select(i => MusicBrainzId(i.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup))) .FirstOrDefault(i => !string.IsNullOrEmpty(i)); } @@ -36,11 +38,11 @@ namespace MediaBrowser.Providers.Music public static string? GetReleaseId(this AlbumInfo info) { - var id = info.GetProviderId(MetadataProvider.MusicBrainzAlbum); + var id = MusicBrainzId(info.GetProviderId(MetadataProvider.MusicBrainzAlbum)); if (string.IsNullOrEmpty(id)) { - return info.SongInfos.Select(i => i.GetProviderId(MetadataProvider.MusicBrainzAlbum)) + return info.SongInfos.Select(i => MusicBrainzId(i.GetProviderId(MetadataProvider.MusicBrainzAlbum))) .FirstOrDefault(i => !string.IsNullOrEmpty(i)); } @@ -50,15 +52,17 @@ namespace MediaBrowser.Providers.Music public static string? GetMusicBrainzArtistId(this AlbumInfo info) { info.ProviderIds.TryGetValue(MetadataProvider.MusicBrainzAlbumArtist.ToString(), out string? id); + id = MusicBrainzId(id); if (string.IsNullOrEmpty(id)) { info.ArtistProviderIds.TryGetValue(MetadataProvider.MusicBrainzArtist.ToString(), out id); + id = MusicBrainzId(id); } if (string.IsNullOrEmpty(id)) { - return info.SongInfos.Select(i => i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist)) + return info.SongInfos.Select(i => MusicBrainzId(i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist))) .FirstOrDefault(i => !string.IsNullOrEmpty(i)); } @@ -68,14 +72,21 @@ namespace MediaBrowser.Providers.Music public static string? GetMusicBrainzArtistId(this ArtistInfo info) { info.ProviderIds.TryGetValue(MetadataProvider.MusicBrainzArtist.ToString(), out var id); + id = MusicBrainzId(id); if (string.IsNullOrEmpty(id)) { - return info.SongInfos.Select(i => i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist)) + return info.SongInfos.Select(i => MusicBrainzId(i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist))) .FirstOrDefault(i => !string.IsNullOrEmpty(i)); } return id; } + + /// + /// Returns the id if it can be a MusicBrainz id, otherwise null. + /// + private static string? MusicBrainzId(string? id) + => Guid.TryParse(id, CultureInfo.InvariantCulture, out _) ? id : null; } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs index 78be5804e3..23f8d89c67 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs @@ -1,6 +1,4 @@ -using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Net.Http; using System.Threading; @@ -56,7 +54,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets /// public async Task> GetImages(BaseItem item, CancellationToken cancellationToken) { - var tmdbId = Convert.ToInt32(item.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); + item.TryGetTmdbId(out var tmdbId); if (tmdbId <= 0) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs index a7bba2d539..11ac477378 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -42,7 +41,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets /// public async Task> GetSearchResults(BoxSetInfo searchInfo, CancellationToken cancellationToken) { - var tmdbId = Convert.ToInt32(searchInfo.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); + searchInfo.TryGetTmdbId(out var tmdbId); var language = searchInfo.MetadataLanguage; if (tmdbId > 0) @@ -97,7 +96,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets /// public async Task> GetMetadata(BoxSetInfo info, CancellationToken cancellationToken) { - var tmdbId = Convert.ToInt32(info.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); + info.TryGetTmdbId(out var tmdbId); var language = info.MetadataLanguage; // We don't already have an Id, need to fetch it diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs index b188f5deb4..e686577311 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs @@ -1,6 +1,4 @@ -using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Net.Http; using System.Threading; @@ -61,7 +59,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies var language = item.GetPreferredMetadataLanguage(); var countryCode = item.GetPreferredMetadataCountryCode(); - var movieTmdbId = Convert.ToInt32(item.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); + item.TryGetTmdbId(out var movieTmdbId); if (movieTmdbId <= 0) { var movieImdbId = item.GetProviderId(MetadataProvider.Imdb); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs index 8811a1787a..ef952082da 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs @@ -54,11 +54,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies /// public async Task> GetSearchResults(MovieInfo searchInfo, CancellationToken cancellationToken) { - if (searchInfo.TryGetProviderId(MetadataProvider.Tmdb, out var id)) + if (searchInfo.TryGetTmdbId(out var tmdbId)) { var movie = await _tmdbClientManager .GetMovieAsync( - int.Parse(id, CultureInfo.InvariantCulture), + tmdbId, searchInfo.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode), searchInfo.MetadataCountryCode, @@ -90,7 +90,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies } IReadOnlyList? movieResults = null; - if (searchInfo.TryGetProviderId(MetadataProvider.Imdb, out id)) + if (searchInfo.TryGetProviderId(MetadataProvider.Imdb, out var id)) { var result = await _tmdbClientManager.FindByExternalIdAsync( id, @@ -151,11 +151,13 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies /// public async Task> GetMetadata(MovieInfo info, CancellationToken cancellationToken) { - var tmdbId = info.GetProviderId(MetadataProvider.Tmdb); + // A stored id that is not a TMDb id is treated as no id, so the search below can repair it + // rather than the lookup failing for as long as the bad id stays on the item. + info.TryGetTmdbId(out var tmdbId); var imdbId = info.GetProviderId(MetadataProvider.Imdb); var config = Plugin.Instance.Configuration; - if (string.IsNullOrEmpty(tmdbId) && string.IsNullOrEmpty(imdbId)) + if (tmdbId <= 0 && string.IsNullOrEmpty(imdbId)) { // ParseName is required here. // Caller provides the filename with extension stripped and NOT the parsed filename @@ -166,26 +168,26 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies if (searchResults?.Count > 0) { - tmdbId = searchResults[0].Id.ToString(CultureInfo.InvariantCulture); + tmdbId = searchResults[0].Id; } } - if (string.IsNullOrEmpty(tmdbId) && !string.IsNullOrEmpty(imdbId)) + if (tmdbId <= 0 && !string.IsNullOrEmpty(imdbId)) { var movieResultFromImdbId = await _tmdbClientManager.FindByExternalIdAsync(imdbId, FindExternalSource.Imdb, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false); if (movieResultFromImdbId?.MovieResults?.Count > 0) { - tmdbId = movieResultFromImdbId.MovieResults[0].Id.ToString(CultureInfo.InvariantCulture); + tmdbId = movieResultFromImdbId.MovieResults[0].Id; } } - if (string.IsNullOrEmpty(tmdbId)) + if (tmdbId <= 0) { return new MetadataResult(); } var movieResult = await _tmdbClientManager - .GetMovieAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken) + .GetMovieAsync(tmdbId, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken) .ConfigureAwait(false); if (movieResult is null) @@ -208,7 +210,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies Item = movie }; - movie.SetProviderId(MetadataProvider.Tmdb, tmdbId); + movie.SetProviderId(MetadataProvider.Tmdb, tmdbId.ToString(CultureInfo.InvariantCulture)); movie.TrySetProviderId(MetadataProvider.Imdb, movieResult.ImdbId); if (movieResult.BelongsToCollection is not null) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs index 33888ddf4f..d38614811c 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Net.Http; using System.Threading; @@ -54,14 +53,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People { var person = (Person)item; - if (!person.TryGetProviderId(MetadataProvider.Tmdb, out var personTmdbId)) + if (!person.TryGetTmdbId(out var personTmdbId)) { return Enumerable.Empty(); } var language = item.GetPreferredMetadataLanguage(); var countryCode = item.GetPreferredMetadataCountryCode(); - var personResult = await _tmdbClientManager.GetPersonAsync(int.Parse(personTmdbId, CultureInfo.InvariantCulture), language, countryCode, cancellationToken).ConfigureAwait(false); + var personResult = await _tmdbClientManager.GetPersonAsync(personTmdbId, language, countryCode, cancellationToken).ConfigureAwait(false); if (personResult?.Images?.Profiles is null) { return Enumerable.Empty(); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs index 64ab98b262..61294676f7 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Globalization; using System.Net.Http; @@ -37,9 +36,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People /// public async Task> GetSearchResults(PersonLookupInfo searchInfo, CancellationToken cancellationToken) { - if (searchInfo.TryGetProviderId(MetadataProvider.Tmdb, out var personTmdbId)) + if (searchInfo.TryGetTmdbId(out var personTmdbId)) { - var personResult = await _tmdbClientManager.GetPersonAsync(int.Parse(personTmdbId, CultureInfo.InvariantCulture), searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false); + var personResult = await _tmdbClientManager.GetPersonAsync(personTmdbId, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken).ConfigureAwait(false); if (personResult is not null) { @@ -89,7 +88,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People /// public async Task> GetMetadata(PersonLookupInfo info, CancellationToken cancellationToken) { - var personTmdbId = Convert.ToInt32(info.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); + // A person can carry another provider's id under the TMDb key, which is no more usable here + // than no id at all, so both take the search path and get the stored id repaired. + info.TryGetTmdbId(out var personTmdbId); // We don't already have an Id, need to fetch it if (personTmdbId <= 0) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs index 7ae54cdcd3..1f8c87397d 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs @@ -1,6 +1,4 @@ -using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Net.Http; using System.Threading; @@ -56,9 +54,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV var episode = (Controller.Entities.TV.Episode)item; var series = episode.Series; - var seriesTmdbId = Convert.ToInt32(series?.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); + var seriesTmdbId = 0; - if (series is null || seriesTmdbId <= 0) + if (series?.TryGetTmdbId(out seriesTmdbId) != true) { return Enumerable.Empty(); } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs index 21b822c97c..8172ab14df 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs @@ -91,8 +91,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV info.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out string? tmdbId); - var seriesTmdbId = Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture); - if (seriesTmdbId <= 0) + if (!TmdbUtils.TryParseTmdbId(tmdbId, out var seriesTmdbId)) { return metadataResult; } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs index 5b2f0d26e4..bc44d0266d 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs @@ -1,6 +1,4 @@ -using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Net.Http; using System.Threading; @@ -57,9 +55,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV var season = (Season)item; var series = season?.Series; - var seriesTmdbId = Convert.ToInt32(series?.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); + var seriesTmdbId = 0; - if (seriesTmdbId <= 0 || season?.IndexNumber is null) + if (season?.IndexNumber is null || series?.TryGetTmdbId(out seriesTmdbId) != true) { return Enumerable.Empty(); } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs index 9c41d64253..06313810a1 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs @@ -1,4 +1,3 @@ -using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -48,13 +47,13 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV var seasonNumber = info.IndexNumber; - if (string.IsNullOrWhiteSpace(seriesTmdbId) || !seasonNumber.HasValue) + if (!seasonNumber.HasValue || !TmdbUtils.TryParseTmdbId(seriesTmdbId, out var seriesId)) { return result; } var seasonResult = await _tmdbClientManager - .GetSeasonAsync(Convert.ToInt32(seriesTmdbId, CultureInfo.InvariantCulture), seasonNumber.Value, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken) + .GetSeasonAsync(seriesId, seasonNumber.Value, info.MetadataLanguage, TmdbUtils.GetImageLanguagesParam(info.MetadataLanguage, info.MetadataCountryCode), info.MetadataCountryCode, cancellationToken) .ConfigureAwait(false); if (seasonResult is null) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs index f2e7d0c6e4..dc4f860604 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs @@ -1,6 +1,4 @@ -using System; using System.Collections.Generic; -using System.Globalization; using System.Linq; using System.Net.Http; using System.Threading; @@ -57,9 +55,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV /// public async Task> GetImages(BaseItem item, CancellationToken cancellationToken) { - var tmdbId = item.GetProviderId(MetadataProvider.Tmdb); - - if (string.IsNullOrEmpty(tmdbId)) + if (!item.TryGetTmdbId(out var tmdbId)) { return Enumerable.Empty(); } @@ -68,7 +64,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV // TODO use image languages if All Languages isn't toggled, but there's currently no way to get that value in here var series = await _tmdbClientManager - .GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), null, null, null, cancellationToken) + .GetSeriesAsync(tmdbId, null, null, null, cancellationToken) .ConfigureAwait(false); if (series?.Images is null) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs index 9bb15ca479..9e201f2d7c 100755 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs @@ -54,10 +54,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV /// public async Task> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken) { - if (searchInfo.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbId)) + if (searchInfo.TryGetTmdbId(out var tmdbId)) { var series = await _tmdbClientManager - .GetSeriesAsync(Convert.ToInt32(tmdbId, CultureInfo.InvariantCulture), searchInfo.MetadataLanguage, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken) + .GetSeriesAsync(tmdbId, searchInfo.MetadataLanguage, searchInfo.MetadataLanguage, searchInfo.MetadataCountryCode, cancellationToken) .ConfigureAwait(false); if (series is not null) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs index 7e6b9beee9..c83174f97f 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Frozen; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using System.Text.RegularExpressions; using Jellyfin.Data.Enums; using MediaBrowser.Model.Entities; @@ -62,6 +63,33 @@ namespace MediaBrowser.Providers.Plugins.Tmdb [GeneratedRegex(@"[\W_-[·]]+")] private static partial Regex NonWordRegex(); + /// + /// Gets the TMDb id of an item, if it has one TMDb can be queried with. + /// + /// The item. + /// The TMDb id. + /// true if the item has a usable TMDb id; otherwise, false. + public static bool TryGetTmdbId(this IHasProviderIds instance, out int tmdbId) + { + instance.TryGetProviderId(MetadataProvider.Tmdb, out var value); + + return TryParseTmdbId(value, out tmdbId); + } + + /// + /// Parses a TMDb id. + /// + /// The stored id. + /// The TMDb id. + /// true if the value is a usable TMDb id; otherwise, false. + public static bool TryParseTmdbId(string? value, out int tmdbId) + { + // Another provider can have filed one of its own ids under the TMDb key, e.g. an IMDb person + // id. Reporting that as "no id" lets the caller fall back to a search and repair the id, + // instead of throwing on every refresh of the item. + return int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out tmdbId) && tmdbId > 0; + } + /// /// Cleans the name according to TMDb requirements. /// diff --git a/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs b/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs index a6f4164144..0fae58fe67 100644 --- a/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs +++ b/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs @@ -186,6 +186,49 @@ namespace Jellyfin.Model.Tests.Entities Assert.Null(nullProvider.ProviderIds); } + [Theory] + [InlineData(nameof(MetadataProvider.Imdb), "tt0113375", true)] + [InlineData(nameof(MetadataProvider.Imdb), "nm0000123", true)] + [InlineData(nameof(MetadataProvider.Imdb), "0113375", true)] + [InlineData(nameof(MetadataProvider.Imdb), "https://www.imdb.com/title/tt0113375", false)] + [InlineData(nameof(MetadataProvider.Tmdb), "11", true)] + [InlineData(nameof(MetadataProvider.Tmdb), "nm0000123", false)] + [InlineData(nameof(MetadataProvider.Tmdb), "0", false)] + [InlineData(nameof(MetadataProvider.Tmdb), "-11", false)] + [InlineData(nameof(MetadataProvider.TmdbCollection), "nm0000123", false)] + [InlineData(nameof(MetadataProvider.AudioDbArtist), "111239", true)] + [InlineData(nameof(MetadataProvider.AudioDbArtist), "a3cb23fc-acd3-4ce0-8f36-1e5aa6a18432", false)] + [InlineData(nameof(MetadataProvider.MusicBrainzArtist), "a3cb23fc-acd3-4ce0-8f36-1e5aa6a18432", true)] + [InlineData(nameof(MetadataProvider.MusicBrainzArtist), "111239", false)] + [InlineData(nameof(MetadataProvider.MusicBrainzAlbum), "not-an-mbid", false)] + [InlineData(nameof(MetadataProvider.Tvdb), "anything-goes", true)] + [InlineData("SomePlugin", "anything-goes", true)] + [InlineData(nameof(MetadataProvider.Tmdb), null, false)] + [InlineData(null, "11", false)] + public void IsValidProviderId_ChecksKnownFormats(string? name, string? value, bool expected) + { + Assert.Equal(expected, ProviderIdsExtensions.IsValidProviderId(name, value)); + } + + [Fact] + public void TrySetProviderId_ForeignId_False() + { + var provider = new ProviderIdsExtensionsTestsObject(); + + Assert.False(provider.TrySetProviderId(MetadataProvider.Tmdb, "nm0000123")); + Assert.Empty(provider.ProviderIds); + } + + [Fact] + public void TrySetProviderId_ForeignId_KeepsExisting() + { + var provider = new ProviderIdsExtensionsTestsObject(); + provider.ProviderIds[MetadataProvider.Tmdb.ToString()] = "11"; + + Assert.False(provider.TrySetProviderId(MetadataProvider.Tmdb, "nm0000123")); + Assert.Equal("11", provider.GetProviderId(MetadataProvider.Tmdb)); + } + [Fact] public void RemoveProviderId_Null_Remove() { diff --git a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs new file mode 100644 index 0000000000..449abb2e6a --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.IO; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Persistence; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.IO; +using MediaBrowser.Providers.Manager; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Providers.Tests.Manager +{ + public class MetadataServiceRefreshTests + { + [Theory] + [InlineData(false, "existing overview")] + [InlineData(true, null)] + public async Task RefreshWithProviders_ReplaceAllMetadata_KeepsExistingDataOnProviderFailure(bool allProvidersSucceed, string? expectedOverview) + { + var item = new Movie + { + Name = "Test Movie", + Overview = "existing overview" + }; + + // The provider owning the overview fails, so it contributes nothing to the replacement. + var failing = new Mock>(MockBehavior.Loose); + failing.Setup(p => p.Name).Returns("Failing"); + failing.Setup(p => p.GetMetadata(It.IsAny(), It.IsAny())) + .Returns(allProvidersSucceed + ? Task.FromResult(new MetadataResult { HasMetadata = true, Item = new Movie() }) + : Task.FromException>(new FormatException("bad id"))); + + var succeeding = new Mock>(MockBehavior.Loose); + succeeding.Setup(p => p.Name).Returns("Succeeding"); + succeeding.Setup(p => p.GetMetadata(It.IsAny(), It.IsAny())) + .ReturnsAsync(new MetadataResult + { + HasMetadata = true, + Item = new Movie { Name = "Test Movie", Tagline = "new tagline" } + }); + + var service = new TestMetadataService(); + var result = await service.RefreshWithProvidersInternal( + new MetadataResult { Item = item }, + new MovieInfo { Name = item.Name }, + new MetadataRefreshOptions(Mock.Of()) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true, + RemoveOldMetadata = true + }, + [failing.Object, succeeding.Object]).ConfigureAwait(true); + + Assert.Equal(allProvidersSucceed ? 0 : 1, result.Failures); + Assert.Equal("new tagline", item.Tagline); + Assert.Equal(expectedOverview, item.Overview); + } + + [Fact] + public async Task RefreshWithProviders_ForeignProviderId_NotStored() + { + var item = new Movie { Name = "Test Movie" }; + + var provider = new Mock>(MockBehavior.Loose); + provider.Setup(p => p.Name).Returns("Provider"); + provider.Setup(p => p.GetMetadata(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => + { + var found = new Movie { Name = "Test Movie" }; + found.ProviderIds[MetadataProvider.Tmdb.ToString()] = "nm0000123"; + found.ProviderIds[MetadataProvider.Imdb.ToString()] = "tt0113375"; + return new MetadataResult { HasMetadata = true, Item = found }; + }); + + var service = new TestMetadataService(); + await service.RefreshWithProvidersInternal( + new MetadataResult { Item = item }, + new MovieInfo { Name = item.Name }, + new MetadataRefreshOptions(Mock.Of()) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true + }, + [provider.Object]).ConfigureAwait(true); + + Assert.False(item.HasProviderId(MetadataProvider.Tmdb)); + Assert.Equal("tt0113375", item.GetProviderId(MetadataProvider.Imdb)); + } + + private sealed class TestMetadataService : MetadataService + { + public TestMetadataService() + : base( + Mock.Of(), + NullLogger>.Instance, + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of()) + { + } + + public Task RefreshWithProvidersInternal( + MetadataResult metadata, + MovieInfo id, + MetadataRefreshOptions options, + ICollection providers) + => RefreshWithProviders(metadata, id, options, providers, ImageProvider, false, CancellationToken.None); + } + } +} diff --git a/tests/Jellyfin.Providers.Tests/Music/AlbumInfoExtensionsTests.cs b/tests/Jellyfin.Providers.Tests/Music/AlbumInfoExtensionsTests.cs new file mode 100644 index 0000000000..c5ec0de02c --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Music/AlbumInfoExtensionsTests.cs @@ -0,0 +1,59 @@ +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using MediaBrowser.Providers.Music; +using Xunit; + +namespace Jellyfin.Providers.Tests.Music; + +public static class AlbumInfoExtensionsTests +{ + private const string ExampleMbid = "59b5a40b-e2fd-3f18-a218-e8c9aae12ab5"; + private const string SongMbid = "6c301dbd-6ccb-3403-a6c4-6a22240a0297"; + + [Theory] + [InlineData(ExampleMbid, ExampleMbid)] + // Another provider's id under a MusicBrainz key reads as no id, so the caller searches instead of + // handing a value the MusicBrainz client throws on. + [InlineData("111239", null)] + [InlineData("", null)] + public static void GetReleaseId_OnlyReturnsMbids(string id, string? expected) + { + var info = new AlbumInfo(); + info.ProviderIds[MetadataProvider.MusicBrainzAlbum.ToString()] = id; + + Assert.Equal(expected, info.GetReleaseId()); + } + + [Fact] + public static void GetReleaseId_ForeignId_FallsBackToSongs() + { + var song = new SongInfo(); + song.ProviderIds[MetadataProvider.MusicBrainzAlbum.ToString()] = SongMbid; + + var info = new AlbumInfo { SongInfos = [song] }; + info.ProviderIds[MetadataProvider.MusicBrainzAlbum.ToString()] = "111239"; + + Assert.Equal(SongMbid, info.GetReleaseId()); + } + + [Fact] + public static void GetMusicBrainzArtistId_ForeignId_FallsBackToArtistIds() + { + var info = new AlbumInfo(); + info.ProviderIds[MetadataProvider.MusicBrainzAlbumArtist.ToString()] = "111239"; + info.ArtistProviderIds[MetadataProvider.MusicBrainzArtist.ToString()] = ExampleMbid; + + Assert.Equal(ExampleMbid, info.GetMusicBrainzArtistId()); + } + + [Theory] + [InlineData(ExampleMbid, ExampleMbid)] + [InlineData("111239", null)] + public static void GetMusicBrainzArtistId_ArtistInfo_OnlyReturnsMbids(string id, string? expected) + { + var info = new ArtistInfo(); + info.ProviderIds[MetadataProvider.MusicBrainzArtist.ToString()] = id; + + Assert.Equal(expected, info.GetMusicBrainzArtistId()); + } +} diff --git a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs index fb0a08c29c..4c4dd5e92f 100644 --- a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs +++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsTests.cs @@ -1,3 +1,5 @@ +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Model.Entities; using MediaBrowser.Providers.Plugins.Tmdb; using Xunit; @@ -34,5 +36,40 @@ namespace Jellyfin.Providers.Tests.Tmdb { Assert.Equal(expected, TmdbUtils.AdjustImageLanguage(imageLanguage, requestLanguage)); } + + [Theory] + [InlineData("11", true, 11)] + // An id another provider filed under the TMDb key must not throw, it is simply not a TMDb id. + [InlineData("nm0000123", false, 0)] + [InlineData("tt0113375", false, 0)] + [InlineData("11.0", false, 0)] + [InlineData("-11", false, 0)] + [InlineData("0", false, 0)] + [InlineData("", false, 0)] + [InlineData(null, false, 0)] + public static void TryParseTmdbId_OnlyAcceptsTmdbIds(string? value, bool expected, int expectedId) + { + Assert.Equal(expected, TmdbUtils.TryParseTmdbId(value, out var tmdbId)); + Assert.Equal(expectedId, tmdbId); + } + + [Theory] + [InlineData("11", true, 11)] + [InlineData("nm0000123", false, 0)] + public static void TryGetTmdbId_OnlyAcceptsTmdbIds(string value, bool expected, int expectedId) + { + var item = new Movie(); + item.ProviderIds[MetadataProvider.Tmdb.ToString()] = value; + + Assert.Equal(expected, item.TryGetTmdbId(out var tmdbId)); + Assert.Equal(expectedId, tmdbId); + } + + [Fact] + public static void TryGetTmdbId_NoId_False() + { + Assert.False(new Movie().TryGetTmdbId(out var tmdbId)); + Assert.Equal(0, tmdbId); + } } } -- cgit v1.2.3 From f82101332d19a64739e35ba7cf3d54b2a537c032 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 10 Aug 2026 22:46:31 +0200 Subject: Fix master build --- Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs index d46f7b3c4c..40300b7fa2 100644 --- a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs +++ b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs @@ -72,7 +72,7 @@ public class LinkedChildrenService : ILinkedChildrenService return dbContext.LinkedChildren .Where(lc => lc.ChildType == DbLinkedChildType.LocalAlternateVersion || lc.ChildType == DbLinkedChildType.LinkedAlternateVersion) - .WhereOneOrMany(itemIds as IList ?? itemIds.ToList(), lc => lc.ParentId) + .WhereOneOrMany(itemIds, lc => lc.ParentId) .Select(lc => lc.ParentId) .Distinct() .ToHashSet(); -- cgit v1.2.3 From 4e9713a03284a88d01d2d114e49d3a90d65aaebc Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 10 Aug 2026 23:10:33 +0200 Subject: Apply review suggestions --- MediaBrowser.Providers/Manager/MetadataService.cs | 28 +++++++++++++++ .../Manager/MetadataServiceRefreshTests.cs | 41 ++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index fb1781accc..c6c15198be 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -1137,6 +1137,8 @@ namespace MediaBrowser.Providers.Manager if (!lockedFields.Contains(MetadataField.Cast)) { + RemoveInvalidProviderIds(sourceResult.People); + if (replaceData || targetResult.People is null || targetResult.People.Count == 0) { targetResult.People = sourceResult.People; @@ -1291,6 +1293,32 @@ namespace MediaBrowser.Providers.Manager } } + private static void RemoveInvalidProviderIds(IReadOnlyList people) + { + if (people is null) + { + return; + } + + foreach (var person in people) + { + if (person.ProviderIds is null || person.ProviderIds.Count == 0) + { + continue; + } + + var invalidKeys = person.ProviderIds + .Where(id => !ProviderIdsExtensions.IsValidProviderId(id.Key, id.Value)) + .Select(id => id.Key) + .ToArray(); + + foreach (var key in invalidKeys) + { + person.ProviderIds.Remove(key); + } + } + } + private static void MergePeople(IReadOnlyList source, IReadOnlyList target) { var sourceByName = source.ToLookup(p => p.Name.RemoveDiacritics(), StringComparer.OrdinalIgnoreCase); diff --git a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs index 449abb2e6a..cbc8a65577 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs @@ -2,7 +2,9 @@ using System; using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; @@ -95,6 +97,45 @@ namespace Jellyfin.Providers.Tests.Manager Assert.Equal("tt0113375", item.GetProviderId(MetadataProvider.Imdb)); } + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task RefreshWithProviders_ForeignPersonProviderId_NotStored(bool replaceAllMetadata) + { + var item = new Movie { Name = "Test Movie" }; + var existing = new MetadataResult { Item = item }; + existing.AddPerson(new PersonInfo { Name = "Some Actor", Type = PersonKind.Actor }); + + var provider = new Mock>(MockBehavior.Loose); + provider.Setup(p => p.Name).Returns("Provider"); + provider.Setup(p => p.GetMetadata(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => + { + var person = new PersonInfo { Name = "Some Actor", Type = PersonKind.Actor }; + person.ProviderIds[MetadataProvider.Tmdb.ToString()] = "nm0000123"; + person.ProviderIds[MetadataProvider.Imdb.ToString()] = "nm0000123"; + + var found = new MetadataResult { HasMetadata = true, Item = new Movie { Name = "Test Movie" } }; + found.AddPerson(person); + return found; + }); + + var service = new TestMetadataService(); + await service.RefreshWithProvidersInternal( + existing, + new MovieInfo { Name = item.Name }, + new MetadataRefreshOptions(Mock.Of()) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = replaceAllMetadata + }, + [provider.Object]).ConfigureAwait(true); + + var mergedPerson = Assert.Single(existing.People); + Assert.False(mergedPerson.HasProviderId(MetadataProvider.Tmdb)); + Assert.Equal("nm0000123", mergedPerson.GetProviderId(MetadataProvider.Imdb)); + } + private sealed class TestMetadataService : MetadataService { public TestMetadataService() -- cgit v1.2.3 From fa7fdf58840567c07e85ffb00be4318e12fd021e Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 11 Aug 2026 14:17:36 +0200 Subject: Optimize query helper memory --- .../DescendantQueryHelper.cs | 243 +-- .../MediaStreamInfoConfiguration.cs | 1 + ...45224_AddMediaStreamTypeItemIdIndex.Designer.cs | 1813 ++++++++++++++++++++ ...20260811145224_AddMediaStreamTypeItemIdIndex.cs | 27 + .../Migrations/JellyfinDbModelSnapshot.cs | 2 + .../Item/BaseItemRepositoryStreamFilterTests.cs | 224 +++ .../Item/DescendantQueryHelperTests.cs | 515 ++++++ 7 files changed, 2717 insertions(+), 108 deletions(-) create mode 100644 src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260811145224_AddMediaStreamTypeItemIdIndex.Designer.cs create mode 100644 src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260811145224_AddMediaStreamTypeItemIdIndex.cs create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs index bfd0fac34a..9a42c86f7d 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs @@ -8,7 +8,7 @@ using Jellyfin.Database.Implementations.MatchCriteria; namespace Jellyfin.Database.Implementations; /// -/// Provides methods for querying item hierarchies using iterative traversal. +/// Provides methods for querying item hierarchies. /// Uses AncestorIds and LinkedChildren tables for parent-child traversal. /// public static class DescendantQueryHelper @@ -32,11 +32,18 @@ public static class DescendantQueryHelper { ArgumentNullException.ThrowIfNull(context); - var descendants = TraverseHierarchyDown(context, [parentId]); + var (closureRoots, linkRoots) = ResolveLinkedRoots(context, parentId); - descendants.Remove(parentId); + var hierarchyDescendants = ClosureDescendants(context, closureRoots); - return descendants.AsQueryable(); + var linkedDescendants = context.LinkedChildren + .WhereOneOrMany(linkRoots, e => e.ParentId) + .Select(e => e.ChildId); + + return hierarchyDescendants + .Concat(linkedDescendants) + .Where(e => !e.Equals(parentId)) + .Distinct(); } /// @@ -51,11 +58,9 @@ public static class DescendantQueryHelper { ArgumentNullException.ThrowIfNull(context); - var descendants = TraverseHierarchyDownOwned(context, [parentId]); - - descendants.Remove(parentId); - - return descendants.AsQueryable(); + return ClosureDescendants(context, [parentId]) + .Where(e => !e.Equals(parentId)) + .Distinct(); } /// @@ -76,11 +81,12 @@ public static class DescendantQueryHelper return []; } - var seedSet = new HashSet(parentIds); - var descendants = TraverseHierarchyDownOwned(context, seedSet); + var descendants = ClosureDescendants(context, parentIds) + .Distinct() + .ToHashSet(); - // Remove the seed IDs — callers want only descendants - descendants.ExceptWith(seedSet); + // The callers want only descendants, and an item is never its own descendant. + descendants.ExceptWith(parentIds); return descendants; } @@ -96,28 +102,48 @@ public static class DescendantQueryHelper { ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(criteria); + var matchingItemIds = criteria switch { HasSubtitles => context.MediaStreamInfos .Where(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle) - .Select(ms => ms.ItemId) - .Distinct() - .ToHashSet(), + .Select(ms => ms.ItemId), HasChapterImages => context.Chapters .Where(c => c.ImagePath != null) - .Select(c => c.ItemId) - .Distinct() - .ToHashSet(), + .Select(c => c.ItemId), HasMediaStreamType m => GetMatchingMediaStreamItemIds(context, m), _ => throw new ArgumentOutOfRangeException(nameof(criteria), $"Unknown criteria type: {criteria.GetType().Name}") }; - var ancestors = TraverseHierarchyUp(context, matchingItemIds); + // One hop up the closure covers every ancestor level. + var hierarchyAncestors = context.AncestorIds + .Where(e => matchingItemIds.Contains(e.ItemId)) + .Select(e => e.ParentItemId); - return ancestors.AsQueryable(); + var linkParents = ResolveLinkParents(context, matchingItemIds, hierarchyAncestors); + + // The link parents are resolved ids, so they are read back as a sub-select to keep the result + // composable. An id without a BaseItem row could never match a caller's row anyway. + var linkedParents = context.BaseItems + .WhereOneOrMany(linkParents, e => e.Id) + .Select(e => e.Id); + + var linkedParentAncestors = context.AncestorIds + .WhereOneOrMany(linkParents, e => e.ItemId) + .Select(e => e.ParentItemId); + + var seamAncestors = context.AncestorIds + .Where(e => hierarchyAncestors.Contains(e.ItemId) || linkedParentAncestors.Contains(e.ItemId)) + .Select(e => e.ParentItemId); + + return hierarchyAncestors + .Concat(linkedParents) + .Concat(linkedParentAncestors) + .Concat(seamAncestors) + .Distinct(); } - private static HashSet GetMatchingMediaStreamItemIds(JellyfinDbContext context, HasMediaStreamType criteria) + private static IQueryable GetMatchingMediaStreamItemIds(JellyfinDbContext context, HasMediaStreamType criteria) { var query = context.MediaStreamInfos .Where(ms => ms.StreamType == criteria.StreamType @@ -130,130 +156,131 @@ public static class DescendantQueryHelper query = query.Where(ms => ms.IsExternal == isExternal); } - return query.Select(ms => ms.ItemId).Distinct().ToHashSet(); + return query.Select(ms => ms.ItemId); } - /// - /// Traverses DOWN the hierarchy from parent folders to find all descendants. - /// - private static HashSet TraverseHierarchyDown(JellyfinDbContext context, ICollection startIds) + private static IQueryable ClosureDescendants(JellyfinDbContext context, IReadOnlyList roots) { - var visited = new HashSet(startIds); - var folderStack = new HashSet(startIds); + var direct = context.AncestorIds + .WhereOneOrMany(roots, e => e.ParentItemId) + .Select(e => e.ItemId); - while (folderStack.Count != 0) - { - var currentFolders = folderStack.ToArray(); - folderStack.Clear(); + // An item carries its own chain plus its collection folders, never the UserRootFolder. + var indirect = context.AncestorIds + .Where(e => direct.Contains(e.ParentItemId)) + .Select(e => e.ItemId); - var directChildren = context.AncestorIds - .WhereOneOrMany(currentFolders, e => e.ParentItemId) - .Select(e => e.ItemId) - .ToArray(); + return direct.Concat(indirect); + } - var linkedChildren = context.LinkedChildren - .WhereOneOrMany(currentFolders, e => e.ParentId) - .Select(e => e.ChildId) - .ToArray(); + /// + /// Resolves every folder that reaches one of the matching items through a linked edge. + /// + /// The ids of the folders whose linked children lead, at any depth, to a matching item. + private static List ResolveLinkParents(JellyfinDbContext context, IQueryable matchingItemIds, IQueryable ancestorsOfMatches) + { + // A link sits above the closure as well as above another link: a BoxSet holds a Series whose + // episode matches, and another BoxSet holds that BoxSet. So the hop repeats until it stops + // finding anything new, and each hop takes the links landing on the set itself or on a folder + // that contains it. Only folders owning linked children are ever collected, which bounds this + // by the number of BoxSets and Playlists rather than by the item count. + var resolved = context.LinkedChildren + .Where(e => matchingItemIds.Contains(e.ChildId) || ancestorsOfMatches.Contains(e.ChildId)) + .Select(e => e.ParentId) + .Distinct() + .ToHashSet(); + + var frontier = resolved.ToList(); + + while (frontier.Count != 0) + { + var containingFolders = context.AncestorIds + .WhereOneOrMany(frontier, e => e.ItemId) + .Select(e => e.ParentItemId); - var allChildren = directChildren.Concat(linkedChildren).Distinct().ToArray(); + var directLinkParents = context.LinkedChildren + .WhereOneOrMany(frontier, e => e.ChildId) + .Select(e => e.ParentId); - if (allChildren.Length == 0) - { - break; - } + var indirectLinkParents = context.LinkedChildren + .Where(e => containingFolders.Contains(e.ChildId)) + .Select(e => e.ParentId); - var childFolders = context.BaseItems - .WhereOneOrMany(allChildren, e => e.Id) - .Where(e => e.IsFolder) - .Select(e => e.Id) - .ToHashSet(); + var next = directLinkParents + .Concat(indirectLinkParents) + .Distinct() + .ToArray(); - foreach (var childId in allChildren) + frontier = []; + foreach (var id in next) { - if (visited.Add(childId) && childFolders.Contains(childId)) + // Cyclic links (a BoxSet holding itself, directly or not) terminate on the resolved set. + if (resolved.Add(id)) { - folderStack.Add(childId); + frontier.Add(id); } } } - return visited; + return [.. resolved]; } /// - /// Traverses DOWN the hierarchy using only AncestorIds (ownership), not LinkedChildren. + /// Resolves the roots the descendant sub-selects have to be anchored on. /// - private static HashSet TraverseHierarchyDownOwned(JellyfinDbContext context, ICollection startIds) + /// + /// The roots whose AncestorIds closure belongs to the result, and the roots whose LinkedChildren + /// belong to the result. + /// + private static (List ClosureRoots, List LinkRoots) ResolveLinkedRoots(JellyfinDbContext context, Guid parentId) { - var visited = new HashSet(startIds); - var folderStack = new HashSet(startIds); + // A folder found through the closure needs no closure hop of its own. + var closureRoots = new List { parentId }; + var linkRoots = new List { parentId }; + var visited = new HashSet { parentId }; + var frontier = new List { parentId }; - while (folderStack.Count != 0) + while (frontier.Count != 0) { - var currentFolders = folderStack.ToArray(); - folderStack.Clear(); + var closureIds = ClosureDescendants(context, frontier); - var directChildren = context.AncestorIds - .WhereOneOrMany(currentFolders, e => e.ParentItemId) - .Select(e => e.ItemId) - .ToArray(); + var linkedIds = context.LinkedChildren + .WhereOneOrMany(frontier, e => e.ParentId) + .Select(e => e.ChildId); - if (directChildren.Length == 0) - { - break; - } + // Folders that own linked children, i.e. the only items whose links are worth following. + var linkOwners = context.BaseItems + .Where(e => e.IsFolder + && (closureIds.Contains(e.Id) || linkedIds.Contains(e.Id)) + && context.LinkedChildren.Any(l => l.ParentId.Equals(e.Id))) + .Select(e => e.Id) + .ToArray(); - var childFolders = context.BaseItems - .WhereOneOrMany(directChildren, e => e.Id) - .Where(e => e.IsFolder) + var linkedFolders = context.BaseItems + .Where(e => e.IsFolder && linkedIds.Contains(e.Id)) .Select(e => e.Id) .ToHashSet(); - foreach (var childId in directChildren) + frontier = []; + foreach (var id in linkOwners.Concat(linkedFolders)) { - if (visited.Add(childId) && childFolders.Contains(childId)) + if (!visited.Add(id)) { - folderStack.Add(childId); + continue; } - } - } - - return visited; - } - - /// - /// Traverses UP the hierarchy from items to find all ancestor folders. - /// - private static HashSet TraverseHierarchyUp(JellyfinDbContext context, ICollection startIds) - { - var ancestors = new HashSet(); - var itemStack = new HashSet(startIds); - while (itemStack.Count != 0) - { - var currentItems = itemStack.ToArray(); - itemStack.Clear(); + frontier.Add(id); + linkRoots.Add(id); - var ancestorParents = context.AncestorIds - .WhereOneOrMany(currentItems, e => e.ItemId) - .Select(e => e.ParentItemId) - .ToArray(); - - var linkedParents = context.LinkedChildren - .WhereOneOrMany(currentItems, e => e.ChildId) - .Select(e => e.ParentId) - .ToArray(); - - foreach (var parentId in ancestorParents.Concat(linkedParents)) - { - if (ancestors.Add(parentId)) + // Only a folder reached through a link contributes a closure that is not covered by + // the roots already collected. + if (linkedFolders.Contains(id)) { - itemStack.Add(parentId); + closureRoots.Add(id); } } } - return ancestors; + return (closureRoots, linkRoots); } } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs index afa9eee363..fc4e96cf8a 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs @@ -13,5 +13,6 @@ public class MediaStreamInfoConfiguration : IEntityTypeConfiguration builder) { builder.HasKey(e => new { e.ItemId, e.StreamIndex }); + builder.HasIndex(e => new { e.StreamType, e.ItemId }); } } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260811145224_AddMediaStreamTypeItemIdIndex.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260811145224_AddMediaStreamTypeItemIdIndex.Designer.cs new file mode 100644 index 0000000000..79362985be --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260811145224_AddMediaStreamTypeItemIdIndex.Designer.cs @@ -0,0 +1,1813 @@ +// +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("20260811145224_AddMediaStreamTypeItemIdIndex")] + partial class AddMediaStreamTypeItemIdIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("EndHour") + .HasColumnType("REAL"); + + b.Property("StartHour") + .HasColumnType("REAL"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("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("ItemId") + .HasColumnType("TEXT"); + + b.Property("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("ItemId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Filename") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Album") + .HasColumnType("TEXT"); + + b.Property("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property("Artists") + .HasColumnType("TEXT"); + + b.Property("Audio") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("CleanName") + .HasColumnType("TEXT"); + + b.Property("CommunityRating") + .HasColumnType("REAL"); + + b.Property("CriticRating") + .HasColumnType("REAL"); + + b.Property("CustomRating") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasColumnType("TEXT"); + + b.Property("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property("ExtraType") + .HasColumnType("INTEGER"); + + b.Property("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingSubValue") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property("IsFolder") + .HasColumnType("INTEGER"); + + b.Property("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsMovie") + .HasColumnType("INTEGER"); + + b.Property("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property("IsSeries") + .HasColumnType("INTEGER"); + + b.Property("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property("LUFS") + .HasColumnType("REAL"); + + b.Property("MediaType") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizationGain") + .HasColumnType("REAL"); + + b.Property("OfficialRating") + .HasColumnType("TEXT"); + + b.Property("OriginalLanguage") + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property("PremiereDate") + .HasColumnType("TEXT"); + + b.Property("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("TEXT"); + + b.Property("SeasonName") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasColumnType("TEXT"); + + b.Property("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("ShowId") + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Studios") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TopParentId") + .HasColumnType("TEXT"); + + b.Property("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UnratedType") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("PrimaryVersionId") + .HasFilter("\"PrimaryVersionId\" IS NOT NULL"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Blurhash") + .HasColumnType("BLOB"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("ImageType") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("Id") + .HasColumnType("INTEGER"); + + b.Property("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("ItemId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .HasColumnType("INTEGER"); + + b.Property("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("ItemId") + .HasColumnType("TEXT"); + + b.Property("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property("ImagePath") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("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("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("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("ItemValueId") + .HasColumnType("TEXT"); + + b.Property("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("ItemId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("KeyframeTicks") + .HasColumnType("TEXT"); + + b.Property("TotalDuration") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId"); + + b.ToTable("KeyframeData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("ChildId") + .HasColumnType("TEXT"); + + b.Property("ChildType") + .HasColumnType("INTEGER"); + + b.HasKey("ParentId", "SortOrder"); + + b.HasIndex("ChildId", "ChildType"); + + b.HasIndex("ParentId", "ChildType"); + + b.ToTable("LinkedChildren", (string)null); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EndTicks") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("AspectRatio") + .HasColumnType("TEXT"); + + b.Property("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property("BitDepth") + .HasColumnType("INTEGER"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property("ColorSpace") + .HasColumnType("TEXT"); + + b.Property("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property("DvLevel") + .HasColumnType("INTEGER"); + + b.Property("DvProfile") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Hdr10PlusPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property("IsAvc") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsExternal") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property("IsOriginal") + .HasColumnType("INTEGER"); + + b.Property("KeyFrames") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("REAL"); + + b.Property("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PixelFormat") + .HasColumnType("TEXT"); + + b.Property("Profile") + .HasColumnType("TEXT"); + + b.Property("RealFrameRate") + .HasColumnType("REAL"); + + b.Property("RefFrames") + .HasColumnType("INTEGER"); + + b.Property("Rotation") + .HasColumnType("INTEGER"); + + b.Property("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .HasColumnType("INTEGER"); + + b.Property("TimeBase") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamType", "ItemId"); + + b.ToTable("MediaStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("ItemId") + .HasColumnType("TEXT"); + + b.Property("PeopleId") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("ListOrder") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId", "Role"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.HasIndex("PeopleId", "ItemId"); + + b.ToTable("PeopleBaseItemMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomName") + .HasColumnType("TEXT"); + + b.Property("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("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property("InternalId") + .HasColumnType("INTEGER"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalRatingScore") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalRatingSubScore") + .HasColumnType("INTEGER"); + + b.Property("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property("Played") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RetentionDate") + .HasColumnType("TEXT"); + + b.Property("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/20260811145224_AddMediaStreamTypeItemIdIndex.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260811145224_AddMediaStreamTypeItemIdIndex.cs new file mode 100644 index 0000000000..e8f0514952 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260811145224_AddMediaStreamTypeItemIdIndex.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Database.Providers.Sqlite.Migrations +{ + /// + public partial class AddMediaStreamTypeItemIdIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_MediaStreamInfos_StreamType_ItemId", + table: "MediaStreamInfos", + columns: ["StreamType", "ItemId"]); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_MediaStreamInfos_StreamType_ItemId", + table: "MediaStreamInfos"); + } + } +} 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 cdf5c84826..ca19a85fc1 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs @@ -1012,6 +1012,8 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasKey("ItemId", "StreamIndex"); + b.HasIndex("StreamType", "ItemId"); + b.ToTable("MediaStreamInfos"); b.HasAnnotation("Sqlite:UseSqlReturningClause", false); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs new file mode 100644 index 0000000000..717085b440 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs @@ -0,0 +1,224 @@ +using System; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Configuration; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; +using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// +/// Covers the filters that resolve "folders with a matching descendant" through +/// , both in their positive and their +/// negated form, so the sub-selects they build stay translatable on the SQLite provider. +/// +public sealed class BaseItemRepositoryStreamFilterTests : IDisposable +{ + private const string FolderType = "MediaBrowser.Controller.Entities.Folder"; + private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet"; + private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie"; + + private readonly SqliteConnection _connection; + private readonly DbContextOptions _dbOptions; + private readonly BaseItemRepository _repository; + + private readonly Guid _library = Guid.NewGuid(); + private readonly Guid _withSubtitles = Guid.NewGuid(); + private readonly Guid _withoutSubtitles = Guid.NewGuid(); + private readonly Guid _collection = Guid.NewGuid(); + private readonly Guid _linkedSeries = Guid.NewGuid(); + private readonly Guid _linkedEpisode = Guid.NewGuid(); + + public BaseItemRepositoryStreamFilterTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + using (var ctx = CreateDbContext()) + { + ctx.Database.EnsureCreated(); + Seed(ctx); + } + + var factory = new Mock>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + + var serverConfigurationManager = new Mock(); + serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration()); + + _repository = new BaseItemRepository( + factory.Object, + new Mock().Object, + new ItemTypeLookup(), + serverConfigurationManager.Object, + NullLogger.Instance); + } + + public void Dispose() => _connection.Dispose(); + + [Fact] + public void HasSubtitles_MatchesTheItemAndItsParentFolder() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = true }); + + Assert.Contains(_withSubtitles, ids); + // The library is a folder, and it has a descendant with subtitles. + Assert.Contains(_library, ids); + Assert.DoesNotContain(_withoutSubtitles, ids); + } + + [Fact] + public void HasSubtitles_Negated_ExcludesTheItemAndItsParentFolder() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = false }); + + Assert.Contains(_withoutSubtitles, ids); + Assert.DoesNotContain(_withSubtitles, ids); + Assert.DoesNotContain(_library, ids); + } + + [Fact] + public void SubtitleLanguages_MatchesTheRequestedLanguageOnly() + { + Assert.Contains(_withSubtitles, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["ger"] })); + Assert.DoesNotContain(_withSubtitles, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["fre"] })); + } + + [Fact] + public void HasNoSubtitleTrackWithLanguage_ExcludesTheMatchingItemAndFolder() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasNoSubtitleTrackWithLanguage = "ger" }); + + Assert.Contains(_withoutSubtitles, ids); + Assert.DoesNotContain(_withSubtitles, ids); + Assert.DoesNotContain(_library, ids); + } + + [Fact] + public void HasSubtitles_MatchesACollectionLinkingAFolder() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = true }); + + // The collection links the series, and the subtitles hang off the series' episode. + Assert.Contains(_linkedSeries, ids); + Assert.Contains(_collection, ids); + } + + [Fact] + public void HasSubtitles_Negated_ExcludesACollectionLinkingAFolder() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = false }); + + Assert.DoesNotContain(_linkedSeries, ids); + Assert.DoesNotContain(_collection, ids); + } + + [Fact] + public void HasChapterImages_MatchesTheItemAndItsParentFolder() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasChapterImages = true }); + + Assert.Contains(_withSubtitles, ids); + Assert.Contains(_library, ids); + Assert.DoesNotContain(_withoutSubtitles, ids); + } + + private void Seed(JellyfinDbContext context) + { + context.BaseItems.Add(new BaseItemEntity { Id = _library, Type = FolderType, Name = "Library", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _withSubtitles, Type = MovieType, Name = "With subtitles" }); + context.BaseItems.Add(new BaseItemEntity { Id = _withoutSubtitles, Type = MovieType, Name = "Without subtitles" }); + + foreach (var itemId in new[] { _withSubtitles, _withoutSubtitles }) + { + context.AncestorIds.Add(new AncestorId + { + ItemId = itemId, + ParentItemId = _library, + Item = null!, + ParentItem = null! + }); + + context.MediaStreamInfos.Add(new MediaStreamInfo + { + ItemId = itemId, + StreamIndex = 0, + StreamType = MediaStreamTypeEntity.Video, + Item = null! + }); + } + + context.MediaStreamInfos.Add(new MediaStreamInfo + { + ItemId = _withSubtitles, + StreamIndex = 1, + StreamType = MediaStreamTypeEntity.Subtitle, + Language = "ger", + Item = null! + }); + + // A collection linking a folder: the match is two edges away, one link then one closure hop. + context.BaseItems.Add(new BaseItemEntity { Id = _collection, Type = BoxSetType, Name = "Collection", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _linkedSeries, Type = FolderType, Name = "Linked series", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _linkedEpisode, Type = MovieType, Name = "Linked episode" }); + + context.AncestorIds.Add(new AncestorId + { + ItemId = _linkedEpisode, + ParentItemId = _linkedSeries, + Item = null!, + ParentItem = null! + }); + + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = _collection, + ChildId = _linkedSeries, + ChildType = LinkedChildType.Manual, + SortOrder = 0 + }); + + context.MediaStreamInfos.Add(new MediaStreamInfo + { + ItemId = _linkedEpisode, + StreamIndex = 0, + StreamType = MediaStreamTypeEntity.Subtitle, + Language = "ger", + Item = null! + }); + + context.Chapters.Add(new Chapter + { + ItemId = _withSubtitles, + ChapterIndex = 0, + StartPositionTicks = 0, + ImagePath = "/chapter.jpg", + Item = null! + }); + + context.SaveChanges(); + } + + private JellyfinDbContext CreateDbContext() + => new JellyfinDbContext( + _dbOptions, + NullLogger.Instance, + new SqliteDatabaseProvider(null!, NullLogger.Instance), + new NoLockBehavior(NullLogger.Instance)); +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs new file mode 100644 index 0000000000..fae2dd0628 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs @@ -0,0 +1,515 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Implementations.MatchCriteria; +using Jellyfin.Database.Providers.Sqlite; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// +/// Verifies the descendant traversals against the SQLite provider: the sets they resolve, and that +/// they stay sub-selects instead of inlining every descendant id into the statement. +/// +public sealed class DescendantQueryHelperTests : IDisposable +{ + private const string FolderType = "MediaBrowser.Controller.Entities.Folder"; + private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet"; + private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie"; + + private readonly Dictionary _linkCounters = new(); + private readonly SqliteConnection _connection; + private readonly DbContextOptions _dbOptions; + + public DescendantQueryHelperTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + using var ctx = CreateDbContext(); + ctx.Database.EnsureCreated(); + } + + public void Dispose() => _connection.Dispose(); + + [Fact] + public void GetAllDescendantIds_Hierarchy_ReturnsEveryLevelWithoutTheParent() + { + var library = Guid.NewGuid(); + var series = Guid.NewGuid(); + var season = Guid.NewGuid(); + var episode = Guid.NewGuid(); + + using (var ctx = CreateDbContext()) + { + AddFolder(ctx, library); + AddFolder(ctx, series); + AddFolder(ctx, season); + AddItem(ctx, episode, MovieType); + + // AncestorIds is a closure: production writes one row per ancestor, not just the parent. + AddAncestors(ctx, series, library); + AddAncestors(ctx, season, series, library); + AddAncestors(ctx, episode, season, series, library); + ctx.SaveChanges(); + } + + using (var ctx = CreateDbContext()) + { + var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, library).ToHashSet(); + + Assert.Equal(new[] { series, season, episode }.Order(), descendants.Order()); + Assert.DoesNotContain(library, descendants); + } + } + + [Fact] + public void GetAllDescendantIds_LinkedFolder_IncludesItsOwnDescendants() + { + var boxSet = Guid.NewGuid(); + var series = Guid.NewGuid(); + var episode = Guid.NewGuid(); + + using (var ctx = CreateDbContext()) + { + AddItem(ctx, boxSet, BoxSetType, isFolder: true); + AddFolder(ctx, series); + AddItem(ctx, episode, MovieType); + + AddAncestors(ctx, episode, series); + AddLink(ctx, boxSet, series); + ctx.SaveChanges(); + } + + using (var ctx = CreateDbContext()) + { + var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, boxSet).ToHashSet(); + + // The link reaches the series, and the series' own closure reaches the episode. + Assert.Contains(series, descendants); + Assert.Contains(episode, descendants); + } + } + + // Timeout so that a missing termination guard fails the test instead of hanging the run. + [Fact(Timeout = 30000)] + public void GetAllDescendantIds_NestedLinks_AreFollowedAndCyclesTerminate() + { + var outer = Guid.NewGuid(); + var inner = Guid.NewGuid(); + var movie = Guid.NewGuid(); + + using (var ctx = CreateDbContext()) + { + AddItem(ctx, outer, BoxSetType, isFolder: true); + AddItem(ctx, inner, BoxSetType, isFolder: true); + AddItem(ctx, movie, MovieType); + + AddLink(ctx, outer, inner); + AddLink(ctx, inner, movie); + // Cycle back to the outer set: the traversal must not spin on it. + AddLink(ctx, inner, outer); + ctx.SaveChanges(); + } + + using (var ctx = CreateDbContext()) + { + var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, outer).ToHashSet(); + + Assert.Contains(inner, descendants); + Assert.Contains(movie, descendants); + Assert.DoesNotContain(outer, descendants); + } + } + + [Fact] + public void GetAllDescendantIds_LinksOfNonFolders_AreNotFollowed() + { + var library = Guid.NewGuid(); + var movie = Guid.NewGuid(); + var alternateVersion = Guid.NewGuid(); + + using (var ctx = CreateDbContext()) + { + AddFolder(ctx, library); + AddItem(ctx, movie, MovieType); + AddItem(ctx, alternateVersion, MovieType); + + AddAncestors(ctx, movie, library); + // An alternate version hangs off the movie by link, and the movie is not a folder. + AddLink(ctx, movie, alternateVersion); + ctx.SaveChanges(); + } + + using (var ctx = CreateDbContext()) + { + var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, library).ToHashSet(); + + Assert.Contains(movie, descendants); + Assert.DoesNotContain(alternateVersion, descendants); + } + } + + [Fact] + public void GetAllDescendantIds_ClosureSeamAboveTheCollectionFolder_IsCrossed() + { + var userRoot = Guid.NewGuid(); + var collectionFolder = Guid.NewGuid(); + var series = Guid.NewGuid(); + var episode = Guid.NewGuid(); + var boxSet = Guid.NewGuid(); + var linkedMovie = Guid.NewGuid(); + + using (var ctx = CreateDbContext()) + { + AddFolder(ctx, userRoot); + AddFolder(ctx, collectionFolder); + AddFolder(ctx, series); + AddItem(ctx, episode, MovieType); + AddItem(ctx, boxSet, BoxSetType, isFolder: true); + AddItem(ctx, linkedMovie, MovieType); + + // How production writes it: an item carries its own chain plus its collection folder, but + // not the user root above that folder - so one hop from the user root stops there. + AddAncestors(ctx, collectionFolder, userRoot); + AddAncestors(ctx, series, collectionFolder); + AddAncestors(ctx, episode, series, collectionFolder); + AddAncestors(ctx, boxSet, collectionFolder); + // The box set is only reachable across the seam, and its links have to be followed too. + AddLink(ctx, boxSet, linkedMovie); + ctx.SaveChanges(); + } + + using (var ctx = CreateDbContext()) + { + var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, userRoot).ToHashSet(); + + Assert.Equal( + new[] { collectionFolder, series, episode, boxSet, linkedMovie }.Order(), + descendants.Order()); + } + } + + [Fact] + public void GetOwnedDescendantIds_ClosureSeamAboveTheCollectionFolder_IsCrossed() + { + var userRoot = Guid.NewGuid(); + var collectionFolder = Guid.NewGuid(); + var series = Guid.NewGuid(); + var episode = Guid.NewGuid(); + var boxSet = Guid.NewGuid(); + var linkedMovie = Guid.NewGuid(); + + using (var ctx = CreateDbContext()) + { + AddFolder(ctx, userRoot); + AddFolder(ctx, collectionFolder); + AddFolder(ctx, series); + AddItem(ctx, episode, MovieType); + AddItem(ctx, boxSet, BoxSetType, isFolder: true); + AddItem(ctx, linkedMovie, MovieType); + + AddAncestors(ctx, collectionFolder, userRoot); + AddAncestors(ctx, series, collectionFolder); + AddAncestors(ctx, episode, series, collectionFolder); + AddAncestors(ctx, boxSet, collectionFolder); + AddLink(ctx, boxSet, linkedMovie); + ctx.SaveChanges(); + } + + using (var ctx = CreateDbContext()) + { + // Owned only: the linked movie stays out, or deleting a library would delete it. + var expected = new[] { collectionFolder, series, episode, boxSet }.Order(); + + Assert.Equal(expected, DescendantQueryHelper.GetOwnedDescendantIds(ctx, userRoot).ToHashSet().Order()); + Assert.Equal(expected, DescendantQueryHelper.GetOwnedDescendantIdsBatch(ctx, [userRoot]).Order()); + } + } + + [Fact] + public void GetFolderIdsMatching_LinkAboveAClosure_ReturnsTheLinkingFolder() + { + var collections = Guid.NewGuid(); + var boxSet = Guid.NewGuid(); + var library = Guid.NewGuid(); + var series = Guid.NewGuid(); + var episode = Guid.NewGuid(); + var otherLibrary = Guid.NewGuid(); + var otherBoxSet = Guid.NewGuid(); + var silentMovie = Guid.NewGuid(); + + using (var ctx = CreateDbContext()) + { + AddFolder(ctx, collections); + AddItem(ctx, boxSet, BoxSetType, isFolder: true); + AddFolder(ctx, library); + AddFolder(ctx, series); + AddItem(ctx, episode, MovieType); + + AddAncestors(ctx, boxSet, collections); + AddAncestors(ctx, series, library); + AddAncestors(ctx, episode, series, library); + // The link lands on the series, not on the episode that carries the subtitles. + AddLink(ctx, boxSet, series); + AddStream(ctx, episode, MediaStreamTypeEntity.Subtitle); + + // A second collection, over an item without subtitles, must not be picked up. + AddFolder(ctx, otherLibrary); + AddItem(ctx, otherBoxSet, BoxSetType, isFolder: true); + AddItem(ctx, silentMovie, MovieType); + AddAncestors(ctx, otherBoxSet, collections); + AddAncestors(ctx, silentMovie, otherLibrary); + AddLink(ctx, otherBoxSet, silentMovie); + // A stream of another type: the criteria, not the mere presence of a stream, decides. + AddStream(ctx, silentMovie, MediaStreamTypeEntity.Video); + + ctx.SaveChanges(); + } + + using (var ctx = CreateDbContext()) + { + var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet(); + + Assert.Equal(new[] { library, series, boxSet, collections }.Order(), folders.Order()); + } + } + + [Fact(Timeout = 30000)] + public void GetFolderIdsMatching_NestedLinks_AreFollowedAndCyclesTerminate() + { + var outer = Guid.NewGuid(); + var inner = Guid.NewGuid(); + var movie = Guid.NewGuid(); + var silentSet = Guid.NewGuid(); + var silentMovie = Guid.NewGuid(); + + using (var ctx = CreateDbContext()) + { + AddItem(ctx, outer, BoxSetType, isFolder: true); + AddItem(ctx, inner, BoxSetType, isFolder: true); + AddItem(ctx, movie, MovieType); + + AddLink(ctx, outer, inner); + AddLink(ctx, inner, movie); + // Cycle back to the outer set: resolving the link parents must not spin on it. + AddLink(ctx, inner, outer); + AddStream(ctx, movie, MediaStreamTypeEntity.Subtitle); + + AddItem(ctx, silentSet, BoxSetType, isFolder: true); + AddItem(ctx, silentMovie, MovieType); + AddLink(ctx, silentSet, silentMovie); + AddStream(ctx, silentMovie, MediaStreamTypeEntity.Video); + + ctx.SaveChanges(); + } + + using (var ctx = CreateDbContext()) + { + var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet(); + + Assert.Equal(new[] { inner, outer }.Order(), folders.Order()); + } + } + + [Fact] + public void GetFolderIdsMatching_ClosureSeamAboveTheCollectionFolder_IsCrossed() + { + var userRoot = Guid.NewGuid(); + var collectionFolder = Guid.NewGuid(); + var series = Guid.NewGuid(); + var episode = Guid.NewGuid(); + + using (var ctx = CreateDbContext()) + { + AddFolder(ctx, userRoot); + AddFolder(ctx, collectionFolder); + AddFolder(ctx, series); + AddItem(ctx, episode, MovieType); + + // How production writes it: an item carries its own chain plus its collection folder, but + // not the user root above that folder - so the closure is not transitive at this seam. + AddAncestors(ctx, episode, series, collectionFolder); + AddAncestors(ctx, series, collectionFolder); + AddAncestors(ctx, collectionFolder, userRoot); + AddStream(ctx, episode, MediaStreamTypeEntity.Subtitle); + ctx.SaveChanges(); + } + + using (var ctx = CreateDbContext()) + { + var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet(); + + Assert.Equal(new[] { series, collectionFolder, userRoot }.Order(), folders.Order()); + } + } + + [Fact] + public void GetFolderIdsMatching_LinkedFolder_MatchesOnLanguageOnly() + { + var boxSet = Guid.NewGuid(); + var series = Guid.NewGuid(); + var episode = Guid.NewGuid(); + + using (var ctx = CreateDbContext()) + { + AddItem(ctx, boxSet, BoxSetType, isFolder: true); + AddFolder(ctx, series); + AddItem(ctx, episode, MovieType); + + AddAncestors(ctx, episode, series); + AddLink(ctx, boxSet, series); + AddStream(ctx, episode, MediaStreamTypeEntity.Subtitle, "ger"); + ctx.SaveChanges(); + } + + using (var ctx = CreateDbContext()) + { + var german = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, ["ger"]); + var french = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, ["fre"]); + + Assert.Equal(new[] { series, boxSet }.Order(), DescendantQueryHelper.GetFolderIdsMatching(ctx, german).ToHashSet().Order()); + Assert.Empty(DescendantQueryHelper.GetFolderIdsMatching(ctx, french).ToArray()); + } + } + + [Fact] + public void GetOwnedDescendantIds_IgnoresLinkedChildren() + { + var boxSet = Guid.NewGuid(); + var owned = Guid.NewGuid(); + var linked = Guid.NewGuid(); + + using (var ctx = CreateDbContext()) + { + AddItem(ctx, boxSet, BoxSetType, isFolder: true); + AddItem(ctx, owned, MovieType); + AddItem(ctx, linked, MovieType); + + AddAncestors(ctx, owned, boxSet); + AddLink(ctx, boxSet, linked); + ctx.SaveChanges(); + } + + using (var ctx = CreateDbContext()) + { + Assert.Equal([owned], DescendantQueryHelper.GetOwnedDescendantIds(ctx, boxSet).ToArray()); + Assert.Equal([owned], DescendantQueryHelper.GetOwnedDescendantIdsBatch(ctx, [boxSet]).ToArray()); + } + } + + [Fact] + public void GetAllDescendantIds_StatementSizeDoesNotGrowWithTheLibrary() + { + var small = SeedLibrary(10); + var large = SeedLibrary(500); + + using var ctx = CreateDbContext(); + + var smallSql = CountingQuery(ctx, small).ToQueryString(); + var largeSql = CountingQuery(ctx, large).ToQueryString(); + + // Reading the ids into memory and passing them back as AsQueryable() makes EF inline one + // literal per descendant, which is what made a large library allocate megabytes per call. + Assert.Equal(smallSql.Length, largeSql.Length); + Assert.Contains("AncestorIds", smallSql, StringComparison.Ordinal); + Assert.Equal(10, CountingQuery(ctx, small).Count()); + Assert.Equal(500, CountingQuery(ctx, large).Count()); + } + + private static IQueryable CountingQuery(JellyfinDbContext context, Guid libraryId) + { + var descendantIds = DescendantQueryHelper.GetAllDescendantIds(context, libraryId); + + return context.BaseItems + .AsNoTracking() + .Where(b => descendantIds.Contains(b.Id)) + .Where(DescendantQueryHelper.IsCountableLeaf); + } + + private Guid SeedLibrary(int childCount) + { + var library = Guid.NewGuid(); + + using var ctx = CreateDbContext(); + AddFolder(ctx, library); + for (var i = 0; i < childCount; i++) + { + var child = Guid.NewGuid(); + AddItem(ctx, child, MovieType); + AddAncestors(ctx, child, library); + } + + ctx.SaveChanges(); + + return library; + } + + private static void AddFolder(JellyfinDbContext context, Guid id) + => AddItem(context, id, FolderType, isFolder: true); + + private static void AddItem(JellyfinDbContext context, Guid id, string type, bool isFolder = false) + => context.BaseItems.Add(new BaseItemEntity + { + Id = id, + Type = type, + Name = type + " " + id, + IsFolder = isFolder + }); + + private static void AddStream(JellyfinDbContext context, Guid itemId, MediaStreamTypeEntity type, string? language = null) + => context.MediaStreamInfos.Add(new MediaStreamInfo + { + ItemId = itemId, + StreamIndex = 0, + StreamType = type, + Language = language, + Item = null! + }); + + private static void AddAncestors(JellyfinDbContext context, Guid itemId, params Guid[] ancestorIds) + { + foreach (var ancestorId in ancestorIds) + { + context.AncestorIds.Add(new AncestorId + { + ItemId = itemId, + ParentItemId = ancestorId, + Item = null!, + ParentItem = null! + }); + } + } + + // LinkedChildren is keyed on (ParentId, SortOrder), so every link of a parent needs its own slot. + private void AddLink(JellyfinDbContext context, Guid parentId, Guid childId) + { + _linkCounters.TryGetValue(parentId, out var sortOrder); + _linkCounters[parentId] = sortOrder + 1; + + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = parentId, + ChildId = childId, + ChildType = LinkedChildType.Manual, + SortOrder = sortOrder + }); + } + + private JellyfinDbContext CreateDbContext() + => new JellyfinDbContext( + _dbOptions, + NullLogger.Instance, + new SqliteDatabaseProvider(null!, NullLogger.Instance), + new NoLockBehavior(NullLogger.Instance)); +} -- cgit v1.2.3 From 9ae6ffe441586764554ea0e347b540365f069a00 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Wed, 12 Aug 2026 07:42:29 +0200 Subject: Share the SQLite fixture across the item tests --- .../Item/AlternateVersionQueryTranslationTests.cs | 35 +-------- .../BaseItemRepositoryByNameTotalCountTests.cs | 54 +------------- .../Item/BaseItemRepositoryGroupingTests.cs | 54 +------------- .../Item/BaseItemRepositoryStreamFilterTests.cs | 51 ++----------- .../Item/DescendantQueryHelperTests.cs | 45 +++--------- .../Item/ItemPersistenceOwnedRowTests.cs | 41 ++--------- .../Item/PeopleRepositoryUpdatePeopleTests.cs | 38 +--------- .../Item/SqliteDbTestFixture.cs | 85 ++++++++++++++++++++++ 8 files changed, 115 insertions(+), 288 deletions(-) create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs index b7fca74310..2dbcd41a41 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs @@ -1,14 +1,9 @@ -#pragma warning disable RS0030 // Do not use banned APIs: Guid == is required inside EF expression trees to mirror the production query shapes. +#pragma warning disable RS0030 // Do not use banned APIs: Guid == is required inside EF expression trees to mirror the production query shapes. using System; using System.Linq; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; -using Jellyfin.Database.Implementations.Locking; -using Jellyfin.Database.Providers.Sqlite; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace Jellyfin.Server.Implementations.Tests.Item; @@ -18,22 +13,10 @@ namespace Jellyfin.Server.Implementations.Tests.Item; /// (BaseItemRepository.TranslateQuery) and the DatePlayed ordering (OrderMapper) translate /// and evaluate correctly on the SQLite provider. /// -public sealed class AlternateVersionQueryTranslationTests : IDisposable +public sealed class AlternateVersionQueryTranslationTests : SqliteDbTestFixture { - private readonly SqliteConnection _connection; - private readonly DbContextOptions _dbOptions; - public AlternateVersionQueryTranslationTests() { - _connection = new SqliteConnection("Data Source=:memory:"); - _connection.Open(); - - _dbOptions = new DbContextOptionsBuilder() - .UseSqlite(_connection) - .Options; - - using var ctx = CreateDbContext(); - ctx.Database.EnsureCreated(); } [Fact] @@ -220,18 +203,4 @@ public sealed class AlternateVersionQueryTranslationTests : IDisposable ctx.SaveChanges(); return (user.Id, primary.Id, versionA.Id, versionB.Id); } - - private JellyfinDbContext CreateDbContext() - { - return new JellyfinDbContext( - _dbOptions, - NullLogger.Instance, - new SqliteDatabaseProvider(null!, NullLogger.Instance), - new NoLockBehavior(NullLogger.Instance)); - } - - public void Dispose() - { - _connection.Dispose(); - } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs index f675621e21..7dbaea2fb5 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs @@ -1,19 +1,10 @@ -using System; +using System; using System.Linq; using Emby.Server.Implementations.Data; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; -using Jellyfin.Database.Implementations.Locking; -using Jellyfin.Database.Providers.Sqlite; using Jellyfin.Server.Implementations.Item; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; -using MediaBrowser.Model.Configuration; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; using Xunit; using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; @@ -24,46 +15,16 @@ namespace Jellyfin.Server.Implementations.Tests.Item; /// GetItemValues. A query without a Limit used to have its total record count /// silently disabled, so callers got a populated Items array next to a zero total. /// -public sealed class BaseItemRepositoryByNameTotalCountTests : IDisposable +public sealed class BaseItemRepositoryByNameTotalCountTests : SqliteDbTestFixture { - private readonly SqliteConnection _connection; - private readonly DbContextOptions _dbOptions; private readonly BaseItemRepository _repository; private readonly ItemTypeLookup _itemTypeLookup; public BaseItemRepositoryByNameTotalCountTests() { - _connection = new SqliteConnection("Data Source=:memory:"); - _connection.Open(); - - _dbOptions = new DbContextOptionsBuilder() - .UseSqlite(_connection) - .Options; - - using (var ctx = CreateDbContext()) - { - ctx.Database.EnsureCreated(); - } - - var factory = new Mock>(); - factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); - _itemTypeLookup = new ItemTypeLookup(); - var serverConfigurationManager = new Mock(); - serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration()); - - _repository = new BaseItemRepository( - factory.Object, - new Mock().Object, - _itemTypeLookup, - serverConfigurationManager.Object, - NullLogger.Instance); - } - - public void Dispose() - { - _connection.Dispose(); + _repository = CreateBaseItemRepository(_itemTypeLookup); } [Fact] @@ -187,13 +148,4 @@ public sealed class BaseItemRepositoryByNameTotalCountTests : IDisposable ctx.SaveChanges(); } - - private JellyfinDbContext CreateDbContext() - { - return new JellyfinDbContext( - _dbOptions, - NullLogger.Instance, - new SqliteDatabaseProvider(null!, NullLogger.Instance), - new NoLockBehavior(NullLogger.Instance)); - } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs index 083f725db9..5dd648a2b8 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs @@ -1,66 +1,27 @@ -using System; +using System; using System.Linq; using Emby.Server.Implementations.Data; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; -using Jellyfin.Database.Implementations.Locking; -using Jellyfin.Database.Providers.Sqlite; using Jellyfin.Server.Implementations.Item; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; -using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; using Xunit; using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; namespace Jellyfin.Server.Implementations.Tests.Item; -public sealed class BaseItemRepositoryGroupingTests : IDisposable +public sealed class BaseItemRepositoryGroupingTests : SqliteDbTestFixture { - private readonly SqliteConnection _connection; - private readonly DbContextOptions _dbOptions; private readonly BaseItemRepository _repository; private readonly string _movieTypeName; public BaseItemRepositoryGroupingTests() { - _connection = new SqliteConnection("Data Source=:memory:"); - _connection.Open(); - - _dbOptions = new DbContextOptionsBuilder() - .UseSqlite(_connection) - .Options; - - using (var ctx = CreateDbContext()) - { - ctx.Database.EnsureCreated(); - } - - var factory = new Mock>(); - factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); - var itemTypeLookup = new ItemTypeLookup(); _movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie]; - var serverConfigurationManager = new Mock(); - serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration()); - - _repository = new BaseItemRepository( - factory.Object, - new Mock().Object, - itemTypeLookup, - serverConfigurationManager.Object, - NullLogger.Instance); - } - - public void Dispose() - { - _connection.Dispose(); + _repository = CreateBaseItemRepository(itemTypeLookup); } [Fact] @@ -132,13 +93,4 @@ public sealed class BaseItemRepositoryGroupingTests : IDisposable IsVirtualItem = false }; } - - private JellyfinDbContext CreateDbContext() - { - return new JellyfinDbContext( - _dbOptions, - NullLogger.Instance, - new SqliteDatabaseProvider(null!, NullLogger.Instance), - new NoLockBehavior(NullLogger.Instance)); - } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs index 717085b440..3407e2130b 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs @@ -1,37 +1,25 @@ -using System; +using System; using System.Linq; using Emby.Server.Implementations.Data; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; -using Jellyfin.Database.Implementations.Locking; -using Jellyfin.Database.Providers.Sqlite; using Jellyfin.Server.Implementations.Item; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; -using MediaBrowser.Model.Configuration; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging.Abstractions; -using Moq; using Xunit; using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; namespace Jellyfin.Server.Implementations.Tests.Item; /// -/// Covers the filters that resolve "folders with a matching descendant" through -/// , both in their positive and their -/// negated form, so the sub-selects they build stay translatable on the SQLite provider. +/// Covers the filters resolving "folders with a matching descendant" through +/// , positive and negated. /// -public sealed class BaseItemRepositoryStreamFilterTests : IDisposable +public sealed class BaseItemRepositoryStreamFilterTests : SqliteDbTestFixture { private const string FolderType = "MediaBrowser.Controller.Entities.Folder"; private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet"; private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie"; - private readonly SqliteConnection _connection; - private readonly DbContextOptions _dbOptions; private readonly BaseItemRepository _repository; private readonly Guid _library = Guid.NewGuid(); @@ -43,35 +31,14 @@ public sealed class BaseItemRepositoryStreamFilterTests : IDisposable public BaseItemRepositoryStreamFilterTests() { - _connection = new SqliteConnection("Data Source=:memory:"); - _connection.Open(); - - _dbOptions = new DbContextOptionsBuilder() - .UseSqlite(_connection) - .Options; - using (var ctx = CreateDbContext()) { - ctx.Database.EnsureCreated(); Seed(ctx); } - var factory = new Mock>(); - factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); - - var serverConfigurationManager = new Mock(); - serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration()); - - _repository = new BaseItemRepository( - factory.Object, - new Mock().Object, - new ItemTypeLookup(), - serverConfigurationManager.Object, - NullLogger.Instance); + _repository = CreateBaseItemRepository(new ItemTypeLookup()); } - public void Dispose() => _connection.Dispose(); - [Fact] public void HasSubtitles_MatchesTheItemAndItsParentFolder() { @@ -115,7 +82,6 @@ public sealed class BaseItemRepositoryStreamFilterTests : IDisposable { var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = true }); - // The collection links the series, and the subtitles hang off the series' episode. Assert.Contains(_linkedSeries, ids); Assert.Contains(_collection, ids); } @@ -214,11 +180,4 @@ public sealed class BaseItemRepositoryStreamFilterTests : IDisposable context.SaveChanges(); } - - private JellyfinDbContext CreateDbContext() - => new JellyfinDbContext( - _dbOptions, - NullLogger.Instance, - new SqliteDatabaseProvider(null!, NullLogger.Instance), - new NoLockBehavior(NullLogger.Instance)); } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs index fae2dd0628..6cd31d9243 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs @@ -1,14 +1,10 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; -using Jellyfin.Database.Implementations.Locking; using Jellyfin.Database.Implementations.MatchCriteria; -using Jellyfin.Database.Providers.Sqlite; -using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging.Abstractions; using Xunit; namespace Jellyfin.Server.Implementations.Tests.Item; @@ -17,31 +13,18 @@ namespace Jellyfin.Server.Implementations.Tests.Item; /// Verifies the descendant traversals against the SQLite provider: the sets they resolve, and that /// they stay sub-selects instead of inlining every descendant id into the statement. /// -public sealed class DescendantQueryHelperTests : IDisposable +public sealed class DescendantQueryHelperTests : SqliteDbTestFixture { private const string FolderType = "MediaBrowser.Controller.Entities.Folder"; private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet"; private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie"; private readonly Dictionary _linkCounters = new(); - private readonly SqliteConnection _connection; - private readonly DbContextOptions _dbOptions; public DescendantQueryHelperTests() { - _connection = new SqliteConnection("Data Source=:memory:"); - _connection.Open(); - - _dbOptions = new DbContextOptionsBuilder() - .UseSqlite(_connection) - .Options; - - using var ctx = CreateDbContext(); - ctx.Database.EnsureCreated(); } - public void Dispose() => _connection.Dispose(); - [Fact] public void GetAllDescendantIds_Hierarchy_ReturnsEveryLevelWithoutTheParent() { @@ -95,7 +78,6 @@ public sealed class DescendantQueryHelperTests : IDisposable { var descendants = DescendantQueryHelper.GetAllDescendantIds(ctx, boxSet).ToHashSet(); - // The link reaches the series, and the series' own closure reaches the episode. Assert.Contains(series, descendants); Assert.Contains(episode, descendants); } @@ -117,7 +99,7 @@ public sealed class DescendantQueryHelperTests : IDisposable AddLink(ctx, outer, inner); AddLink(ctx, inner, movie); - // Cycle back to the outer set: the traversal must not spin on it. + // The traversal must not spin on this cycle. AddLink(ctx, inner, outer); ctx.SaveChanges(); } @@ -179,8 +161,8 @@ public sealed class DescendantQueryHelperTests : IDisposable AddItem(ctx, boxSet, BoxSetType, isFolder: true); AddItem(ctx, linkedMovie, MovieType); - // How production writes it: an item carries its own chain plus its collection folder, but - // not the user root above that folder - so one hop from the user root stops there. + // An item carries its own chain plus its collection folder, but not the user root above + // it, so one hop from the user root stops at the collection folder. AddAncestors(ctx, collectionFolder, userRoot); AddAncestors(ctx, series, collectionFolder); AddAncestors(ctx, episode, series, collectionFolder); @@ -264,7 +246,6 @@ public sealed class DescendantQueryHelperTests : IDisposable AddLink(ctx, boxSet, series); AddStream(ctx, episode, MediaStreamTypeEntity.Subtitle); - // A second collection, over an item without subtitles, must not be picked up. AddFolder(ctx, otherLibrary); AddItem(ctx, otherBoxSet, BoxSetType, isFolder: true); AddItem(ctx, silentMovie, MovieType); @@ -302,7 +283,7 @@ public sealed class DescendantQueryHelperTests : IDisposable AddLink(ctx, outer, inner); AddLink(ctx, inner, movie); - // Cycle back to the outer set: resolving the link parents must not spin on it. + // Resolving the link parents must not spin on this cycle. AddLink(ctx, inner, outer); AddStream(ctx, movie, MediaStreamTypeEntity.Subtitle); @@ -337,8 +318,7 @@ public sealed class DescendantQueryHelperTests : IDisposable AddFolder(ctx, series); AddItem(ctx, episode, MovieType); - // How production writes it: an item carries its own chain plus its collection folder, but - // not the user root above that folder - so the closure is not transitive at this seam. + // The closure is not transitive at this seam: no item records the user root. AddAncestors(ctx, episode, series, collectionFolder); AddAncestors(ctx, series, collectionFolder); AddAncestors(ctx, collectionFolder, userRoot); @@ -419,8 +399,8 @@ public sealed class DescendantQueryHelperTests : IDisposable var smallSql = CountingQuery(ctx, small).ToQueryString(); var largeSql = CountingQuery(ctx, large).ToQueryString(); - // Reading the ids into memory and passing them back as AsQueryable() makes EF inline one - // literal per descendant, which is what made a large library allocate megabytes per call. + // Reading the ids into memory and handing them back as AsQueryable() makes EF inline one + // literal per descendant, which is what allocated megabytes per call. Assert.Equal(smallSql.Length, largeSql.Length); Assert.Contains("AncestorIds", smallSql, StringComparison.Ordinal); Assert.Equal(10, CountingQuery(ctx, small).Count()); @@ -505,11 +485,4 @@ public sealed class DescendantQueryHelperTests : IDisposable SortOrder = sortOrder }); } - - private JellyfinDbContext CreateDbContext() - => new JellyfinDbContext( - _dbOptions, - NullLogger.Instance, - new SqliteDatabaseProvider(null!, NullLogger.Instance), - new NoLockBehavior(NullLogger.Instance)); } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs index 6324706452..9b78a609ab 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs @@ -1,51 +1,29 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; using Jellyfin.Database.Implementations; -using Jellyfin.Database.Implementations.Locking; -using Jellyfin.Database.Providers.Sqlite; using Jellyfin.Server.Implementations.Item; -using MediaBrowser.Common.Configuration; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; namespace Jellyfin.Server.Implementations.Tests.Item; -public sealed class ItemPersistenceOwnedRowTests : IDisposable +public sealed class ItemPersistenceOwnedRowTests : SqliteDbTestFixture { - private readonly SqliteConnection _connection; - private readonly DbContextOptions _dbOptions; private readonly ItemPersistenceService _service; - private readonly IApplicationPaths _applicationPaths; private readonly ILibraryManager? _previousLibraryManager; private readonly IServerConfigurationManager? _previousConfigurationManager; public ItemPersistenceOwnedRowTests() { - _applicationPaths = new Mock().Object; - - _connection = new SqliteConnection("Data Source=:memory:"); - _connection.Open(); - - _dbOptions = new DbContextOptionsBuilder() - .UseSqlite(_connection) - .Options; - - using (var ctx = CreateDbContext()) - { - ctx.Database.EnsureCreated(); - } - // BaseItem resolves these through process-wide statics; restored in Dispose. _previousLibraryManager = BaseItem.LibraryManager; _previousConfigurationManager = BaseItem.ConfigurationManager; @@ -59,20 +37,17 @@ public sealed class ItemPersistenceOwnedRowTests : IDisposable configurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration()); BaseItem.ConfigurationManager = configurationManager.Object; - var factory = new Mock>(); - factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); - _service = new ItemPersistenceService( - factory.Object, + CreateDbContextFactory(), new Mock().Object, NullLogger.Instance); } - public void Dispose() + protected override void Dispose(bool disposing) { BaseItem.LibraryManager = _previousLibraryManager!; BaseItem.ConfigurationManager = _previousConfigurationManager!; - _connection.Dispose(); + base.Dispose(disposing); } [Fact] @@ -140,10 +115,4 @@ public sealed class ItemPersistenceOwnedRowTests : IDisposable book.SetImage(new ItemImageInfo { Path = "/img/primary.jpg", Type = ImageType.Primary }, 0); return book; } - - private JellyfinDbContext CreateDbContext() => new( - _dbOptions, - NullLogger.Instance, - new SqliteDatabaseProvider(_applicationPaths, NullLogger.Instance), - new NoLockBehavior(NullLogger.Instance)); } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs index 70d8e1f833..83465245fa 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs @@ -1,45 +1,30 @@ -using System; +using System; using System.Linq; using Emby.Server.Implementations.Data; using Jellyfin.Data.Enums; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; -using Jellyfin.Database.Implementations.Locking; -using Jellyfin.Database.Providers.Sqlite; using Jellyfin.Server.Implementations.Item; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Persistence; -using Microsoft.Data.Sqlite; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; namespace Jellyfin.Server.Implementations.Tests.Item; -public sealed class PeopleRepositoryUpdatePeopleTests : IDisposable +public sealed class PeopleRepositoryUpdatePeopleTests : SqliteDbTestFixture { private static readonly Guid _itemId = Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); - private readonly SqliteConnection _connection; - private readonly DbContextOptions _dbOptions; private readonly PeopleRepository _repository; public PeopleRepositoryUpdatePeopleTests() { - _connection = new SqliteConnection("Data Source=:memory:"); - _connection.Open(); - - _dbOptions = new DbContextOptionsBuilder() - .UseSqlite(_connection) - .Options; - var itemTypeLookup = new ItemTypeLookup(); using (var ctx = CreateDbContext()) { - ctx.Database.EnsureCreated(); ctx.BaseItems.Add(new BaseItemEntity { Id = _itemId, @@ -53,20 +38,12 @@ public sealed class PeopleRepositoryUpdatePeopleTests : IDisposable ctx.SaveChanges(); } - var factory = new Mock>(); - factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); - _repository = new PeopleRepository( - factory.Object, + CreateDbContextFactory(), itemTypeLookup, new Mock().Object); } - public void Dispose() - { - _connection.Dispose(); - } - [Fact] public void UpdatePeople_SamePersonAndTypeWithDifferentRoles_KeepsEveryCredit() { @@ -174,13 +151,4 @@ public sealed class PeopleRepositoryUpdatePeopleTests : IDisposable Role = role }; } - - private JellyfinDbContext CreateDbContext() - { - return new JellyfinDbContext( - _dbOptions, - NullLogger.Instance, - new SqliteDatabaseProvider(null!, NullLogger.Instance), - new NoLockBehavior(NullLogger.Instance)); - } } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs new file mode 100644 index 0000000000..6be8244c1e --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs @@ -0,0 +1,85 @@ +using System; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Model.Configuration; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// +/// Base fixture for the item tests that run against the SQLite provider: one in-memory database per +/// test class, plus the wiring the repositories under test need. The connection owns the database, so +/// it stays open for the lifetime of the fixture. Derived classes seed in their own constructor. +/// +public abstract class SqliteDbTestFixture : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions _dbOptions; + + protected SqliteDbTestFixture() + { + ApplicationPaths = new Mock().Object; + + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + using var context = CreateDbContext(); + context.Database.EnsureCreated(); + } + + protected IApplicationPaths ApplicationPaths { get; } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected JellyfinDbContext CreateDbContext() => new( + _dbOptions, + NullLogger.Instance, + new SqliteDatabaseProvider(ApplicationPaths, NullLogger.Instance), + new NoLockBehavior(NullLogger.Instance)); + + protected IDbContextFactory CreateDbContextFactory() + { + var factory = new Mock>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + + return factory.Object; + } + + protected BaseItemRepository CreateBaseItemRepository(ItemTypeLookup itemTypeLookup) + { + var serverConfigurationManager = new Mock(); + serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration()); + + return new BaseItemRepository( + CreateDbContextFactory(), + new Mock().Object, + itemTypeLookup, + serverConfigurationManager.Object, + NullLogger.Instance); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _connection.Dispose(); + } + } +} -- cgit v1.2.3 From 2b5625d1c4a2bfad9baad07348e83ebad7b8330c Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Wed, 12 Aug 2026 07:42:38 +0200 Subject: Resolve link owners from LinkedChildren --- .../DescendantQueryHelper.cs | 60 +++++++++------------- 1 file changed, 25 insertions(+), 35 deletions(-) diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs index 9a42c86f7d..7425ebde83 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; @@ -85,7 +85,6 @@ public static class DescendantQueryHelper .Distinct() .ToHashSet(); - // The callers want only descendants, and an item is never its own descendant. descendants.ExceptWith(parentIds); return descendants; @@ -122,16 +121,18 @@ public static class DescendantQueryHelper var linkParents = ResolveLinkParents(context, matchingItemIds, hierarchyAncestors); - // The link parents are resolved ids, so they are read back as a sub-select to keep the result - // composable. An id without a BaseItem row could never match a caller's row anyway. - var linkedParents = context.BaseItems - .WhereOneOrMany(linkParents, e => e.Id) - .Select(e => e.Id); + // Read back as a sub-select so the result stays composable. LinkedChildren is the cheapest + // source: owning a link is what put an id in the set, and ParentId is its leading key. + var linkedParents = context.LinkedChildren + .WhereOneOrMany(linkParents, e => e.ParentId) + .Select(e => e.ParentId); var linkedParentAncestors = context.AncestorIds .WhereOneOrMany(linkParents, e => e.ItemId) .Select(e => e.ParentItemId); + // The chain an item carries stops at its collection folders, so this hop crosses that seam to + // the UserRootFolder above them. One statement for both sides beats a sub-select per side. var seamAncestors = context.AncestorIds .Where(e => hierarchyAncestors.Contains(e.ItemId) || linkedParentAncestors.Contains(e.ItemId)) .Select(e => e.ParentItemId); @@ -173,17 +174,11 @@ public static class DescendantQueryHelper return direct.Concat(indirect); } - /// - /// Resolves every folder that reaches one of the matching items through a linked edge. - /// - /// The ids of the folders whose linked children lead, at any depth, to a matching item. + // Resolves the folders whose linked children lead, at any depth, to a matching item. private static List ResolveLinkParents(JellyfinDbContext context, IQueryable matchingItemIds, IQueryable ancestorsOfMatches) { - // A link sits above the closure as well as above another link: a BoxSet holds a Series whose - // episode matches, and another BoxSet holds that BoxSet. So the hop repeats until it stops - // finding anything new, and each hop takes the links landing on the set itself or on a folder - // that contains it. Only folders owning linked children are ever collected, which bounds this - // by the number of BoxSets and Playlists rather than by the item count. + // A link sits above the closure and above another link alike, so the hop repeats until nothing + // new turns up. Only link owners are collected, which bounds it by BoxSets and Playlists. var resolved = context.LinkedChildren .Where(e => matchingItemIds.Contains(e.ChildId) || ancestorsOfMatches.Contains(e.ChildId)) .Select(e => e.ParentId) @@ -214,7 +209,7 @@ public static class DescendantQueryHelper frontier = []; foreach (var id in next) { - // Cyclic links (a BoxSet holding itself, directly or not) terminate on the resolved set. + // Cyclic links terminate on the resolved set. if (resolved.Add(id)) { frontier.Add(id); @@ -225,16 +220,10 @@ public static class DescendantQueryHelper return [.. resolved]; } - /// - /// Resolves the roots the descendant sub-selects have to be anchored on. - /// - /// - /// The roots whose AncestorIds closure belongs to the result, and the roots whose LinkedChildren - /// belong to the result. - /// + // Resolves the roots the descendant sub-selects are anchored on: those contributing their closure, + // and those contributing their linked children. private static (List ClosureRoots, List LinkRoots) ResolveLinkedRoots(JellyfinDbContext context, Guid parentId) { - // A folder found through the closure needs no closure hop of its own. var closureRoots = new List { parentId }; var linkRoots = new List { parentId }; var visited = new HashSet { parentId }; @@ -248,19 +237,21 @@ public static class DescendantQueryHelper .WhereOneOrMany(frontier, e => e.ParentId) .Select(e => e.ChildId); - // Folders that own linked children, i.e. the only items whose links are worth following. - var linkOwners = context.BaseItems - .Where(e => e.IsFolder - && (closureIds.Contains(e.Id) || linkedIds.Contains(e.Id)) - && context.LinkedChildren.Any(l => l.ParentId.Equals(e.Id))) - .Select(e => e.Id) - .ToArray(); - var linkedFolders = context.BaseItems .Where(e => e.IsFolder && linkedIds.Contains(e.Id)) .Select(e => e.Id) .ToHashSet(); + // Folders whose own links have to be followed. Driven off LinkedChildren because owning a + // link is the rare property, so the folder check only reaches rows that can qualify. That + // check stays: a non-folder owns links too (a movie and its alternate versions). + var linkOwners = context.LinkedChildren + .Where(e => (closureIds.Contains(e.ParentId) || linkedIds.Contains(e.ParentId)) + && e.Parent!.IsFolder) + .Select(e => e.ParentId) + .Distinct() + .ToArray(); + frontier = []; foreach (var id in linkOwners.Concat(linkedFolders)) { @@ -272,8 +263,7 @@ public static class DescendantQueryHelper frontier.Add(id); linkRoots.Add(id); - // Only a folder reached through a link contributes a closure that is not covered by - // the roots already collected. + // Only a folder reached through a link adds a closure the roots so far do not cover. if (linkedFolders.Contains(id)) { closureRoots.Add(id); -- cgit v1.2.3 From 9a07d4336b9766573c8ae60743d1294750ca3a22 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Wed, 12 Aug 2026 07:43:12 +0200 Subject: Make the media stream filter index covering --- .../MediaStreamInfoConfiguration.cs | 5 +- ...45224_AddMediaStreamTypeItemIdIndex.Designer.cs | 1813 -------------------- ...20260811145224_AddMediaStreamTypeItemIdIndex.cs | 27 - ...812050902_AddMediaStreamFilterIndex.Designer.cs | 1813 ++++++++++++++++++++ .../20260812050902_AddMediaStreamFilterIndex.cs | 27 + .../Migrations/JellyfinDbModelSnapshot.cs | 2 +- 6 files changed, 1845 insertions(+), 1842 deletions(-) delete mode 100644 src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260811145224_AddMediaStreamTypeItemIdIndex.Designer.cs delete mode 100644 src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260811145224_AddMediaStreamTypeItemIdIndex.cs create mode 100644 src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.Designer.cs create mode 100644 src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.cs diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs index fc4e96cf8a..48c537bbd3 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/MediaStreamInfoConfiguration.cs @@ -13,6 +13,9 @@ public class MediaStreamInfoConfiguration : IEntityTypeConfiguration builder) { builder.HasKey(e => new { e.ItemId, e.StreamIndex }); - builder.HasIndex(e => new { e.StreamType, e.ItemId }); + + // Covering index for the stream filters. ItemId comes second because it is what they project and + // dedupe on; Language and IsExternal follow only to keep their predicates off the table. + builder.HasIndex(e => new { e.StreamType, e.ItemId, e.Language, e.IsExternal }); } } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260811145224_AddMediaStreamTypeItemIdIndex.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260811145224_AddMediaStreamTypeItemIdIndex.Designer.cs deleted file mode 100644 index 79362985be..0000000000 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260811145224_AddMediaStreamTypeItemIdIndex.Designer.cs +++ /dev/null @@ -1,1813 +0,0 @@ -// -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("20260811145224_AddMediaStreamTypeItemIdIndex")] - partial class AddMediaStreamTypeItemIdIndex - { - /// - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); - - modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("DayOfWeek") - .HasColumnType("INTEGER"); - - b.Property("EndHour") - .HasColumnType("REAL"); - - b.Property("StartHour") - .HasColumnType("REAL"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("DateCreated") - .HasColumnType("TEXT"); - - b.Property("ItemId") - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("LogSeverity") - .HasColumnType("INTEGER"); - - b.Property("Name") - .IsRequired() - .HasMaxLength(512) - .HasColumnType("TEXT"); - - b.Property("Overview") - .HasMaxLength(512) - .HasColumnType("TEXT"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .HasColumnType("INTEGER"); - - b.Property("ShortOverview") - .HasMaxLength(512) - .HasColumnType("TEXT"); - - b.Property("Type") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("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("ItemId") - .HasColumnType("TEXT"); - - b.Property("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("ItemId") - .HasColumnType("TEXT"); - - b.Property("Index") - .HasColumnType("INTEGER"); - - b.Property("Codec") - .HasColumnType("TEXT"); - - b.Property("CodecTag") - .HasColumnType("TEXT"); - - b.Property("Comment") - .HasColumnType("TEXT"); - - b.Property("Filename") - .HasColumnType("TEXT"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("Album") - .HasColumnType("TEXT"); - - b.Property("AlbumArtists") - .HasColumnType("TEXT"); - - b.Property("Artists") - .HasColumnType("TEXT"); - - b.Property("Audio") - .HasColumnType("INTEGER"); - - b.Property("ChannelId") - .HasColumnType("TEXT"); - - b.Property("CleanName") - .HasColumnType("TEXT"); - - b.Property("CommunityRating") - .HasColumnType("REAL"); - - b.Property("CriticRating") - .HasColumnType("REAL"); - - b.Property("CustomRating") - .HasColumnType("TEXT"); - - b.Property("Data") - .HasColumnType("TEXT"); - - b.Property("DateCreated") - .HasColumnType("TEXT"); - - b.Property("DateLastMediaAdded") - .HasColumnType("TEXT"); - - b.Property("DateLastRefreshed") - .HasColumnType("TEXT"); - - b.Property("DateLastSaved") - .HasColumnType("TEXT"); - - b.Property("DateModified") - .HasColumnType("TEXT"); - - b.Property("EndDate") - .HasColumnType("TEXT"); - - b.Property("EpisodeTitle") - .HasColumnType("TEXT"); - - b.Property("ExternalId") - .HasColumnType("TEXT"); - - b.Property("ExternalSeriesId") - .HasColumnType("TEXT"); - - b.Property("ExternalServiceId") - .HasColumnType("TEXT"); - - b.Property("ExtraType") - .HasColumnType("INTEGER"); - - b.Property("ForcedSortName") - .HasColumnType("TEXT"); - - b.Property("Genres") - .HasColumnType("TEXT"); - - b.Property("Height") - .HasColumnType("INTEGER"); - - b.Property("IndexNumber") - .HasColumnType("INTEGER"); - - b.Property("InheritedParentalRatingSubValue") - .HasColumnType("INTEGER"); - - b.Property("InheritedParentalRatingValue") - .HasColumnType("INTEGER"); - - b.Property("IsFolder") - .HasColumnType("INTEGER"); - - b.Property("IsInMixedFolder") - .HasColumnType("INTEGER"); - - b.Property("IsLocked") - .HasColumnType("INTEGER"); - - b.Property("IsMovie") - .HasColumnType("INTEGER"); - - b.Property("IsRepeat") - .HasColumnType("INTEGER"); - - b.Property("IsSeries") - .HasColumnType("INTEGER"); - - b.Property("IsVirtualItem") - .HasColumnType("INTEGER"); - - b.Property("LUFS") - .HasColumnType("REAL"); - - b.Property("MediaType") - .HasColumnType("TEXT"); - - b.Property("Name") - .HasColumnType("TEXT"); - - b.Property("NormalizationGain") - .HasColumnType("REAL"); - - b.Property("OfficialRating") - .HasColumnType("TEXT"); - - b.Property("OriginalLanguage") - .HasColumnType("TEXT"); - - b.Property("OriginalTitle") - .HasColumnType("TEXT"); - - b.Property("Overview") - .HasColumnType("TEXT"); - - b.Property("OwnerId") - .HasColumnType("TEXT"); - - b.Property("ParentId") - .HasColumnType("TEXT"); - - b.Property("ParentIndexNumber") - .HasColumnType("INTEGER"); - - b.Property("Path") - .HasColumnType("TEXT"); - - b.Property("PreferredMetadataCountryCode") - .HasColumnType("TEXT"); - - b.Property("PreferredMetadataLanguage") - .HasColumnType("TEXT"); - - b.Property("PremiereDate") - .HasColumnType("TEXT"); - - b.Property("PresentationUniqueKey") - .HasColumnType("TEXT"); - - b.Property("PrimaryVersionId") - .HasColumnType("TEXT"); - - b.Property("ProductionLocations") - .HasColumnType("TEXT"); - - b.Property("ProductionYear") - .HasColumnType("INTEGER"); - - b.Property("RunTimeTicks") - .HasColumnType("INTEGER"); - - b.Property("SeasonId") - .HasColumnType("TEXT"); - - b.Property("SeasonName") - .HasColumnType("TEXT"); - - b.Property("SeriesId") - .HasColumnType("TEXT"); - - b.Property("SeriesName") - .HasColumnType("TEXT"); - - b.Property("SeriesPresentationUniqueKey") - .HasColumnType("TEXT"); - - b.Property("ShowId") - .HasColumnType("TEXT"); - - b.Property("Size") - .HasColumnType("INTEGER"); - - b.Property("SortName") - .HasColumnType("TEXT"); - - b.Property("StartDate") - .HasColumnType("TEXT"); - - b.Property("Studios") - .HasColumnType("TEXT"); - - b.Property("Tagline") - .HasColumnType("TEXT"); - - b.Property("Tags") - .HasColumnType("TEXT"); - - b.Property("TopParentId") - .HasColumnType("TEXT"); - - b.Property("TotalBitrate") - .HasColumnType("INTEGER"); - - b.Property("Type") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("UnratedType") - .HasColumnType("TEXT"); - - b.Property("Width") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.HasIndex("Name"); - - b.HasIndex("OwnerId"); - - b.HasIndex("ParentId"); - - b.HasIndex("Path"); - - b.HasIndex("PresentationUniqueKey"); - - b.HasIndex("PrimaryVersionId") - .HasFilter("\"PrimaryVersionId\" IS NOT NULL"); - - 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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("Blurhash") - .HasColumnType("BLOB"); - - b.Property("DateModified") - .HasColumnType("TEXT"); - - b.Property("Height") - .HasColumnType("INTEGER"); - - b.Property("ImageType") - .HasColumnType("INTEGER"); - - b.Property("ItemId") - .HasColumnType("TEXT"); - - b.Property("Path") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("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("Id") - .HasColumnType("INTEGER"); - - b.Property("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("ItemId") - .HasColumnType("TEXT"); - - b.Property("ProviderId") - .HasColumnType("TEXT"); - - b.Property("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("Id") - .HasColumnType("INTEGER"); - - b.Property("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("ItemId") - .HasColumnType("TEXT"); - - b.Property("ChapterIndex") - .HasColumnType("INTEGER"); - - b.Property("ImageDateModified") - .HasColumnType("TEXT"); - - b.Property("ImagePath") - .HasColumnType("TEXT"); - - b.Property("Name") - .HasColumnType("TEXT"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("Client") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("ItemId") - .HasColumnType("TEXT"); - - b.Property("Key") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasColumnType("TEXT"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("ChromecastVersion") - .HasColumnType("INTEGER"); - - b.Property("Client") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("DashboardTheme") - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("EnableNextVideoInfoOverlay") - .HasColumnType("INTEGER"); - - b.Property("IndexBy") - .HasColumnType("INTEGER"); - - b.Property("ItemId") - .HasColumnType("TEXT"); - - b.Property("ScrollDirection") - .HasColumnType("INTEGER"); - - b.Property("ShowBackdrop") - .HasColumnType("INTEGER"); - - b.Property("ShowSidebar") - .HasColumnType("INTEGER"); - - b.Property("SkipBackwardLength") - .HasColumnType("INTEGER"); - - b.Property("SkipForwardLength") - .HasColumnType("INTEGER"); - - b.Property("TvHome") - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("DisplayPreferencesId") - .HasColumnType("INTEGER"); - - b.Property("Order") - .HasColumnType("INTEGER"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("LastModified") - .HasColumnType("TEXT"); - - b.Property("Path") - .IsRequired() - .HasMaxLength(512) - .HasColumnType("TEXT"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("Client") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("IndexBy") - .HasColumnType("INTEGER"); - - b.Property("ItemId") - .HasColumnType("TEXT"); - - b.Property("RememberIndexing") - .HasColumnType("INTEGER"); - - b.Property("RememberSorting") - .HasColumnType("INTEGER"); - - b.Property("SortBy") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("SortOrder") - .HasColumnType("INTEGER"); - - b.Property("UserId") - .HasColumnType("TEXT"); - - b.Property("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("ItemValueId") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("CleanValue") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("Type") - .HasColumnType("INTEGER"); - - b.Property("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("ItemValueId") - .HasColumnType("TEXT"); - - b.Property("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("ItemId") - .HasColumnType("TEXT"); - - b.PrimitiveCollection("KeyframeTicks") - .HasColumnType("TEXT"); - - b.Property("TotalDuration") - .HasColumnType("INTEGER"); - - b.HasKey("ItemId"); - - b.ToTable("KeyframeData"); - - b.HasAnnotation("Sqlite:UseSqlReturningClause", false); - }); - - modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => - { - b.Property("ParentId") - .HasColumnType("TEXT"); - - b.Property("SortOrder") - .HasColumnType("INTEGER"); - - b.Property("ChildId") - .HasColumnType("TEXT"); - - b.Property("ChildType") - .HasColumnType("INTEGER"); - - b.HasKey("ParentId", "SortOrder"); - - b.HasIndex("ChildId", "ChildType"); - - b.HasIndex("ParentId", "ChildType"); - - b.ToTable("LinkedChildren", (string)null); - - b.HasAnnotation("Sqlite:UseSqlReturningClause", false); - }); - - modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("EndTicks") - .HasColumnType("INTEGER"); - - b.Property("ItemId") - .HasColumnType("TEXT"); - - b.Property("SegmentProviderId") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("StartTicks") - .HasColumnType("INTEGER"); - - b.Property("Type") - .HasColumnType("INTEGER"); - - b.HasKey("Id"); - - b.ToTable("MediaSegments"); - - b.HasAnnotation("Sqlite:UseSqlReturningClause", false); - }); - - modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => - { - b.Property("ItemId") - .HasColumnType("TEXT"); - - b.Property("StreamIndex") - .HasColumnType("INTEGER"); - - b.Property("AspectRatio") - .HasColumnType("TEXT"); - - b.Property("AverageFrameRate") - .HasColumnType("REAL"); - - b.Property("BitDepth") - .HasColumnType("INTEGER"); - - b.Property("BitRate") - .HasColumnType("INTEGER"); - - b.Property("BlPresentFlag") - .HasColumnType("INTEGER"); - - b.Property("ChannelLayout") - .HasColumnType("TEXT"); - - b.Property("Channels") - .HasColumnType("INTEGER"); - - b.Property("Codec") - .HasColumnType("TEXT"); - - b.Property("CodecTag") - .HasColumnType("TEXT"); - - b.Property("CodecTimeBase") - .HasColumnType("TEXT"); - - b.Property("ColorPrimaries") - .HasColumnType("TEXT"); - - b.Property("ColorSpace") - .HasColumnType("TEXT"); - - b.Property("ColorTransfer") - .HasColumnType("TEXT"); - - b.Property("Comment") - .HasColumnType("TEXT"); - - b.Property("DvBlSignalCompatibilityId") - .HasColumnType("INTEGER"); - - b.Property("DvLevel") - .HasColumnType("INTEGER"); - - b.Property("DvProfile") - .HasColumnType("INTEGER"); - - b.Property("DvVersionMajor") - .HasColumnType("INTEGER"); - - b.Property("DvVersionMinor") - .HasColumnType("INTEGER"); - - b.Property("ElPresentFlag") - .HasColumnType("INTEGER"); - - b.Property("Hdr10PlusPresentFlag") - .HasColumnType("INTEGER"); - - b.Property("Height") - .HasColumnType("INTEGER"); - - b.Property("IsAnamorphic") - .HasColumnType("INTEGER"); - - b.Property("IsAvc") - .HasColumnType("INTEGER"); - - b.Property("IsDefault") - .HasColumnType("INTEGER"); - - b.Property("IsExternal") - .HasColumnType("INTEGER"); - - b.Property("IsForced") - .HasColumnType("INTEGER"); - - b.Property("IsHearingImpaired") - .HasColumnType("INTEGER"); - - b.Property("IsInterlaced") - .HasColumnType("INTEGER"); - - b.Property("IsOriginal") - .HasColumnType("INTEGER"); - - b.Property("KeyFrames") - .HasColumnType("TEXT"); - - b.Property("Language") - .HasColumnType("TEXT"); - - b.Property("Level") - .HasColumnType("REAL"); - - b.Property("NalLengthSize") - .HasColumnType("TEXT"); - - b.Property("Path") - .HasColumnType("TEXT"); - - b.Property("PixelFormat") - .HasColumnType("TEXT"); - - b.Property("Profile") - .HasColumnType("TEXT"); - - b.Property("RealFrameRate") - .HasColumnType("REAL"); - - b.Property("RefFrames") - .HasColumnType("INTEGER"); - - b.Property("Rotation") - .HasColumnType("INTEGER"); - - b.Property("RpuPresentFlag") - .HasColumnType("INTEGER"); - - b.Property("SampleRate") - .HasColumnType("INTEGER"); - - b.Property("StreamType") - .HasColumnType("INTEGER"); - - b.Property("TimeBase") - .HasColumnType("TEXT"); - - b.Property("Title") - .HasColumnType("TEXT"); - - b.Property("Width") - .HasColumnType("INTEGER"); - - b.HasKey("ItemId", "StreamIndex"); - - b.HasIndex("StreamType", "ItemId"); - - b.ToTable("MediaStreamInfos"); - - b.HasAnnotation("Sqlite:UseSqlReturningClause", false); - }); - - modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("Name") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("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("ItemId") - .HasColumnType("TEXT"); - - b.Property("PeopleId") - .HasColumnType("TEXT"); - - b.Property("Role") - .HasColumnType("TEXT"); - - b.Property("ListOrder") - .HasColumnType("INTEGER"); - - b.Property("SortOrder") - .HasColumnType("INTEGER"); - - b.HasKey("ItemId", "PeopleId", "Role"); - - b.HasIndex("ItemId", "ListOrder"); - - b.HasIndex("ItemId", "SortOrder"); - - b.HasIndex("PeopleId", "ItemId"); - - b.ToTable("PeopleBaseItemMap"); - - b.HasAnnotation("Sqlite:UseSqlReturningClause", false); - }); - - modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("Kind") - .HasColumnType("INTEGER"); - - b.Property("Permission_Permissions_Guid") - .HasColumnType("TEXT"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .HasColumnType("INTEGER"); - - b.Property("UserId") - .HasColumnType("TEXT"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("Kind") - .HasColumnType("INTEGER"); - - b.Property("Preference_Preferences_Guid") - .HasColumnType("TEXT"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .HasColumnType("INTEGER"); - - b.Property("UserId") - .HasColumnType("TEXT"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("AccessToken") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("DateCreated") - .HasColumnType("TEXT"); - - b.Property("DateLastActivity") - .HasColumnType("TEXT"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("AccessToken") - .IsRequired() - .HasColumnType("TEXT"); - - b.Property("AppName") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("AppVersion") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("DateCreated") - .HasColumnType("TEXT"); - - b.Property("DateLastActivity") - .HasColumnType("TEXT"); - - b.Property("DateModified") - .HasColumnType("TEXT"); - - b.Property("DeviceId") - .IsRequired() - .HasMaxLength(256) - .HasColumnType("TEXT"); - - b.Property("DeviceName") - .IsRequired() - .HasMaxLength(64) - .HasColumnType("TEXT"); - - b.Property("IsActive") - .HasColumnType("INTEGER"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("INTEGER"); - - b.Property("CustomName") - .HasColumnType("TEXT"); - - b.Property("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("ItemId") - .HasColumnType("TEXT"); - - b.Property("Width") - .HasColumnType("INTEGER"); - - b.Property("Bandwidth") - .HasColumnType("INTEGER"); - - b.Property("Height") - .HasColumnType("INTEGER"); - - b.Property("Interval") - .HasColumnType("INTEGER"); - - b.Property("ThumbnailCount") - .HasColumnType("INTEGER"); - - b.Property("TileHeight") - .HasColumnType("INTEGER"); - - b.Property("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("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("AudioLanguagePreference") - .HasMaxLength(255) - .HasColumnType("TEXT"); - - b.Property("AuthenticationProviderId") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("TEXT"); - - b.Property("CastReceiverId") - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("DisplayCollectionsView") - .HasColumnType("INTEGER"); - - b.Property("DisplayMissingEpisodes") - .HasColumnType("INTEGER"); - - b.Property("EnableAutoLogin") - .HasColumnType("INTEGER"); - - b.Property("EnableLocalPassword") - .HasColumnType("INTEGER"); - - b.Property("EnableNextEpisodeAutoPlay") - .HasColumnType("INTEGER"); - - b.Property("EnableUserPreferenceAccess") - .HasColumnType("INTEGER"); - - b.Property("HidePlayedInLatest") - .HasColumnType("INTEGER"); - - b.Property("InternalId") - .HasColumnType("INTEGER"); - - b.Property("InvalidLoginAttemptCount") - .HasColumnType("INTEGER"); - - b.Property("LastActivityDate") - .HasColumnType("TEXT"); - - b.Property("LastLoginDate") - .HasColumnType("TEXT"); - - b.Property("LoginAttemptsBeforeLockout") - .HasColumnType("INTEGER"); - - b.Property("MaxActiveSessions") - .HasColumnType("INTEGER"); - - b.Property("MaxParentalRatingScore") - .HasColumnType("INTEGER"); - - b.Property("MaxParentalRatingSubScore") - .HasColumnType("INTEGER"); - - b.Property("MustUpdatePassword") - .HasColumnType("INTEGER"); - - b.Property("NormalizedUsername") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("TEXT"); - - b.Property("Password") - .HasMaxLength(65535) - .HasColumnType("TEXT"); - - b.Property("PasswordResetProviderId") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("TEXT"); - - b.Property("PlayDefaultAudioTrack") - .HasColumnType("INTEGER"); - - b.Property("RememberAudioSelections") - .HasColumnType("INTEGER"); - - b.Property("RememberSubtitleSelections") - .HasColumnType("INTEGER"); - - b.Property("RemoteClientBitrateLimit") - .HasColumnType("INTEGER"); - - b.Property("RowVersion") - .IsConcurrencyToken() - .HasColumnType("INTEGER"); - - b.Property("SubtitleLanguagePreference") - .HasMaxLength(255) - .HasColumnType("TEXT"); - - b.Property("SubtitleMode") - .HasColumnType("INTEGER"); - - b.Property("SyncPlayAccess") - .HasColumnType("INTEGER"); - - b.Property("Username") - .IsRequired() - .HasMaxLength(255) - .HasColumnType("TEXT"); - - b.HasKey("Id"); - - b.HasIndex("NormalizedUsername") - .IsUnique(); - - b.HasIndex("Username") - .IsUnique(); - - b.ToTable("Users"); - - b.HasAnnotation("Sqlite:UseSqlReturningClause", false); - }); - - modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => - { - b.Property("ItemId") - .HasColumnType("TEXT"); - - b.Property("UserId") - .HasColumnType("TEXT"); - - b.Property("CustomDataKey") - .HasColumnType("TEXT"); - - b.Property("AudioStreamIndex") - .HasColumnType("INTEGER"); - - b.Property("IsFavorite") - .HasColumnType("INTEGER"); - - b.Property("LastPlayedDate") - .HasColumnType("TEXT"); - - b.Property("Likes") - .HasColumnType("INTEGER"); - - b.Property("PlayCount") - .HasColumnType("INTEGER"); - - b.Property("PlaybackPositionTicks") - .HasColumnType("INTEGER"); - - b.Property("Played") - .HasColumnType("INTEGER"); - - b.Property("Rating") - .HasColumnType("REAL"); - - b.Property("RetentionDate") - .HasColumnType("TEXT"); - - b.Property("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/20260811145224_AddMediaStreamTypeItemIdIndex.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260811145224_AddMediaStreamTypeItemIdIndex.cs deleted file mode 100644 index e8f0514952..0000000000 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260811145224_AddMediaStreamTypeItemIdIndex.cs +++ /dev/null @@ -1,27 +0,0 @@ -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable - -namespace Jellyfin.Database.Providers.Sqlite.Migrations -{ - /// - public partial class AddMediaStreamTypeItemIdIndex : Migration - { - /// - protected override void Up(MigrationBuilder migrationBuilder) - { - migrationBuilder.CreateIndex( - name: "IX_MediaStreamInfos_StreamType_ItemId", - table: "MediaStreamInfos", - columns: ["StreamType", "ItemId"]); - } - - /// - protected override void Down(MigrationBuilder migrationBuilder) - { - migrationBuilder.DropIndex( - name: "IX_MediaStreamInfos_StreamType_ItemId", - table: "MediaStreamInfos"); - } - } -} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.Designer.cs new file mode 100644 index 0000000000..afa6840a97 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.Designer.cs @@ -0,0 +1,1813 @@ +// +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("20260812050902_AddMediaStreamFilterIndex")] + partial class AddMediaStreamFilterIndex + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("EndHour") + .HasColumnType("REAL"); + + b.Property("StartHour") + .HasColumnType("REAL"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("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("ItemId") + .HasColumnType("TEXT"); + + b.Property("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("ItemId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Filename") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Album") + .HasColumnType("TEXT"); + + b.Property("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property("Artists") + .HasColumnType("TEXT"); + + b.Property("Audio") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("CleanName") + .HasColumnType("TEXT"); + + b.Property("CommunityRating") + .HasColumnType("REAL"); + + b.Property("CriticRating") + .HasColumnType("REAL"); + + b.Property("CustomRating") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasColumnType("TEXT"); + + b.Property("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property("ExtraType") + .HasColumnType("INTEGER"); + + b.Property("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingSubValue") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property("IsFolder") + .HasColumnType("INTEGER"); + + b.Property("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsMovie") + .HasColumnType("INTEGER"); + + b.Property("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property("IsSeries") + .HasColumnType("INTEGER"); + + b.Property("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property("LUFS") + .HasColumnType("REAL"); + + b.Property("MediaType") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizationGain") + .HasColumnType("REAL"); + + b.Property("OfficialRating") + .HasColumnType("TEXT"); + + b.Property("OriginalLanguage") + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property("PremiereDate") + .HasColumnType("TEXT"); + + b.Property("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("TEXT"); + + b.Property("SeasonName") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasColumnType("TEXT"); + + b.Property("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("ShowId") + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Studios") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TopParentId") + .HasColumnType("TEXT"); + + b.Property("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UnratedType") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("PrimaryVersionId") + .HasFilter("\"PrimaryVersionId\" IS NOT NULL"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Blurhash") + .HasColumnType("BLOB"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("ImageType") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("Id") + .HasColumnType("INTEGER"); + + b.Property("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("ItemId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .HasColumnType("INTEGER"); + + b.Property("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("ItemId") + .HasColumnType("TEXT"); + + b.Property("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property("ImagePath") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("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("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("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("ItemValueId") + .HasColumnType("TEXT"); + + b.Property("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("ItemId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("KeyframeTicks") + .HasColumnType("TEXT"); + + b.Property("TotalDuration") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId"); + + b.ToTable("KeyframeData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("ChildId") + .HasColumnType("TEXT"); + + b.Property("ChildType") + .HasColumnType("INTEGER"); + + b.HasKey("ParentId", "SortOrder"); + + b.HasIndex("ChildId", "ChildType"); + + b.HasIndex("ParentId", "ChildType"); + + b.ToTable("LinkedChildren", (string)null); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EndTicks") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("AspectRatio") + .HasColumnType("TEXT"); + + b.Property("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property("BitDepth") + .HasColumnType("INTEGER"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property("ColorSpace") + .HasColumnType("TEXT"); + + b.Property("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property("DvLevel") + .HasColumnType("INTEGER"); + + b.Property("DvProfile") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Hdr10PlusPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property("IsAvc") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsExternal") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property("IsOriginal") + .HasColumnType("INTEGER"); + + b.Property("KeyFrames") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("REAL"); + + b.Property("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PixelFormat") + .HasColumnType("TEXT"); + + b.Property("Profile") + .HasColumnType("TEXT"); + + b.Property("RealFrameRate") + .HasColumnType("REAL"); + + b.Property("RefFrames") + .HasColumnType("INTEGER"); + + b.Property("Rotation") + .HasColumnType("INTEGER"); + + b.Property("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .HasColumnType("INTEGER"); + + b.Property("TimeBase") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.HasIndex("StreamType", "ItemId", "Language", "IsExternal"); + + b.ToTable("MediaStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("ItemId") + .HasColumnType("TEXT"); + + b.Property("PeopleId") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("ListOrder") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId", "Role"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.HasIndex("PeopleId", "ItemId"); + + b.ToTable("PeopleBaseItemMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomName") + .HasColumnType("TEXT"); + + b.Property("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("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property("InternalId") + .HasColumnType("INTEGER"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalRatingScore") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalRatingSubScore") + .HasColumnType("INTEGER"); + + b.Property("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property("Played") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RetentionDate") + .HasColumnType("TEXT"); + + b.Property("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/20260812050902_AddMediaStreamFilterIndex.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.cs new file mode 100644 index 0000000000..66e4d1deee --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.cs @@ -0,0 +1,27 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Database.Providers.Sqlite.Migrations +{ + /// + public partial class AddMediaStreamFilterIndex : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_MediaStreamInfos_StreamType_ItemId_Language_IsExternal", + table: "MediaStreamInfos", + columns: new[] { "StreamType", "ItemId", "Language", "IsExternal" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_MediaStreamInfos_StreamType_ItemId_Language_IsExternal", + table: "MediaStreamInfos"); + } + } +} 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 ca19a85fc1..6116640e54 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs @@ -1012,7 +1012,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasKey("ItemId", "StreamIndex"); - b.HasIndex("StreamType", "ItemId"); + b.HasIndex("StreamType", "ItemId", "Language", "IsExternal"); b.ToTable("MediaStreamInfos"); -- cgit v1.2.3 From a4ff630d1f7881e781f055576d40f3be89c82720 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Wed, 12 Aug 2026 08:39:00 +0200 Subject: Fix English metadata blocking localized providers ranked below it --- .../Providers/MetadataResult.cs | 8 +-- .../Books/ComicBookInfo/ComicBookInfoProvider.cs | 2 +- .../Books/ComicInfo/ExternalComicInfoProvider.cs | 2 +- .../Books/ComicInfo/InternalComicInfoProvider.cs | 2 +- .../Manager/MetadataLanguageUtils.cs | 44 +++++++++++++++ MediaBrowser.Providers/Manager/MetadataService.cs | 24 ++++++++ .../Plugins/AudioDb/AudioDbAlbumProvider.cs | 64 ++++++++++++---------- .../Plugins/AudioDb/AudioDbArtistProvider.cs | 64 ++++++++++++---------- .../Plugins/Omdb/OmdbEpisodeProvider.cs | 4 +- .../Plugins/Omdb/OmdbItemProvider.cs | 4 +- .../Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs | 5 +- .../Plugins/Tmdb/People/TmdbPersonProvider.cs | 5 +- .../Plugins/Tmdb/TV/TmdbSeasonProvider.cs | 5 +- .../Manager/MetadataLanguageUtilsTests.cs | 38 +++++++++++++ 14 files changed, 202 insertions(+), 69 deletions(-) create mode 100644 MediaBrowser.Providers/Manager/MetadataLanguageUtils.cs create mode 100644 tests/Jellyfin.Providers.Tests/Manager/MetadataLanguageUtilsTests.cs diff --git a/MediaBrowser.Controller/Providers/MetadataResult.cs b/MediaBrowser.Controller/Providers/MetadataResult.cs index ef69885fcf..48fc22a0fb 100644 --- a/MediaBrowser.Controller/Providers/MetadataResult.cs +++ b/MediaBrowser.Controller/Providers/MetadataResult.cs @@ -16,11 +16,6 @@ namespace MediaBrowser.Controller.Providers private List<(string Url, ImageType Type)> _remoteImages; private List _people; - public MetadataResult() - { - ResultLanguage = "en"; - } - public List Images { get => _images ??= []; @@ -43,6 +38,9 @@ namespace MediaBrowser.Controller.Providers public T Item { get; set; } + /// + /// Gets or sets the language the fetched metadata is in. + /// public string ResultLanguage { get; set; } public string Provider { get; set; } diff --git a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs index 2bd2676ceb..a06de95fce 100644 --- a/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs +++ b/MediaBrowser.Providers/Books/ComicBookInfo/ComicBookInfoProvider.cs @@ -204,7 +204,7 @@ public class ComicBookInfoProvider : IComicProvider { try { - return CultureInfo.GetCultureInfo(language).DisplayName; + return CultureInfo.GetCultureInfo(language).TwoLetterISOLanguageName; } catch (CultureNotFoundException) { diff --git a/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs index cfd22a850e..e3d1f544cf 100644 --- a/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs +++ b/MediaBrowser.Providers/Books/ComicInfo/ExternalComicInfoProvider.cs @@ -52,7 +52,7 @@ public class ExternalComicInfoProvider : IComicProvider var metadataResult = new MetadataResult { Item = book, HasMetadata = true }; ComicInfoReader.ReadPeopleMetadata(comicInfoXml, metadataResult); - ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName); + ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.TwoLetterISOLanguageName); return metadataResult; } diff --git a/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs index 19062452b9..4b14837441 100644 --- a/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs +++ b/MediaBrowser.Providers/Books/ComicInfo/InternalComicInfoProvider.cs @@ -50,7 +50,7 @@ public class InternalComicInfoProvider : IComicProvider var metadataResult = new MetadataResult { Item = book, HasMetadata = true }; ComicInfoReader.ReadPeopleMetadata(comicInfoXml, metadataResult); - ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName); + ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.TwoLetterISOLanguageName); return metadataResult; } diff --git a/MediaBrowser.Providers/Manager/MetadataLanguageUtils.cs b/MediaBrowser.Providers/Manager/MetadataLanguageUtils.cs new file mode 100644 index 0000000000..92a16feaee --- /dev/null +++ b/MediaBrowser.Providers/Manager/MetadataLanguageUtils.cs @@ -0,0 +1,44 @@ +using System; + +namespace MediaBrowser.Providers.Manager; + +/// +/// Helpers for comparing the language of fetched metadata with the language that was requested. +/// +internal static class MetadataLanguageUtils +{ + /// + /// Gets the language subtag of a language tag, e.g. "es" for "es-ES". + /// + /// The language tag. + /// The language subtag, lowercased, or null if none was given. + public static string? GetLanguageSubtag(string? language) + { + if (string.IsNullOrEmpty(language)) + { + return null; + } + + var separator = language.IndexOf('-', StringComparison.Ordinal); + + return (separator == -1 ? language : language[..separator]).ToLowerInvariant(); + } + + /// + /// Determines whether a provider result can be considered to be in the requested language. + /// + /// The language the provider reported for its result, if any. + /// The language that was requested, if any. + /// true if the result is in the requested language or either language is unknown. + public static bool MatchesPreferredLanguage(string? resultLanguage, string? preferredLanguage) + { + // A provider that doesn't report a language cannot be judged, assume it honored the request + if (string.IsNullOrEmpty(resultLanguage) || string.IsNullOrEmpty(preferredLanguage)) + { + return true; + } + + // Compare on the language subtag only so that e.g. "es" matches "es-ES" + return string.Equals(GetLanguageSubtag(resultLanguage), GetLanguageSubtag(preferredLanguage), StringComparison.Ordinal); + } +} diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index 40f2775bd3..8788c860bc 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -913,6 +913,10 @@ namespace MediaBrowser.Providers.Manager private async Task ExecuteRemoteProviders(MetadataResult temp, string logName, bool replaceData, TIdType id, IEnumerable> providers, CancellationToken cancellationToken) { var refreshResult = new RefreshResult(); + var preferredLanguage = id?.MetadataLanguage; + + var overviewIsFallback = false; + var taglineIsFallback = false; if (id is not null) { @@ -932,6 +936,26 @@ namespace MediaBrowser.Providers.Manager { result.Provider = provider.Name; + if (MetadataLanguageUtils.MatchesPreferredLanguage(result.ResultLanguage, preferredLanguage)) + { + if (overviewIsFallback && !string.IsNullOrEmpty(result.Item.Overview)) + { + temp.Item.Overview = null; + overviewIsFallback = false; + } + + if (taglineIsFallback && !string.IsNullOrEmpty(result.Item.Tagline)) + { + temp.Item.Tagline = null; + taglineIsFallback = false; + } + } + else + { + overviewIsFallback |= string.IsNullOrEmpty(temp.Item.Overview) && !string.IsNullOrEmpty(result.Item.Overview); + taglineIsFallback |= string.IsNullOrEmpty(temp.Item.Tagline) && !string.IsNullOrEmpty(result.Item.Tagline); + } + MergeData(result, temp, [], replaceData, false); MergeNewData(temp.Item, id); diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs index 0acd44afbe..1903adfbdd 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs @@ -21,6 +21,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; +using MediaBrowser.Providers.Manager; using MediaBrowser.Providers.Music; namespace MediaBrowser.Providers.Plugins.AudioDb @@ -77,7 +78,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb { result.Item = new MusicAlbum(); result.HasMetadata = true; - ProcessResult(result.Item, obj.album[0], info.MetadataLanguage); + ProcessResult(result, obj.album[0], info.MetadataLanguage); } } } @@ -85,8 +86,10 @@ namespace MediaBrowser.Providers.Plugins.AudioDb return result; } - private void ProcessResult(MusicAlbum item, Album result, string preferredLanguage) + private void ProcessResult(MetadataResult metadataResult, Album result, string preferredLanguage) { + var item = metadataResult.Item; + if (Plugin.Instance.Configuration.ReplaceAlbumName && !string.IsNullOrWhiteSpace(result.strAlbum)) { item.Album = result.strAlbum; @@ -113,43 +116,48 @@ namespace MediaBrowser.Providers.Plugins.AudioDb item.SetProviderId(MetadataProvider.MusicBrainzAlbumArtist, result.strMusicBrainzArtistID); item.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, result.strMusicBrainzID); - string overview = null; - - if (string.Equals(preferredLanguage, "de", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strDescriptionDE; - } - else if (string.Equals(preferredLanguage, "fr", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strDescriptionFR; - } - else if (string.Equals(preferredLanguage, "nl", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strDescriptionNL; - } - else if (string.Equals(preferredLanguage, "ru", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strDescriptionRU; - } - else if (string.Equals(preferredLanguage, "it", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strDescriptionIT; - } - else if ((preferredLanguage ?? string.Empty).StartsWith("pt", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strDescriptionPT; - } + var language = MetadataLanguageUtils.GetLanguageSubtag(preferredLanguage); + var overview = GetDescription(result, language); if (string.IsNullOrWhiteSpace(overview)) { overview = string.IsNullOrWhiteSpace(result.strDescriptionEN) ? result.strDescription : result.strDescriptionEN; + + // The description is not in the requested language, mark it as English so it does not + // block a provider further down the list that can serve the requested language + metadataResult.ResultLanguage = "en"; + } + else + { + metadataResult.ResultLanguage = language; } item.Overview = (overview ?? string.Empty).StripHtml(); } + private static string GetDescription(Album result, string language) + => language switch + { + "de" => result.strDescriptionDE, + "en" => result.strDescriptionEN, + "es" => result.strDescriptionES, + "fr" => result.strDescriptionFR, + "he" => result.strDescriptionIL, + "hu" => result.strDescriptionHU, + "it" => result.strDescriptionIT, + "ja" => result.strDescriptionJP, + "nl" => result.strDescriptionNL, + "no" or "nb" or "nn" => result.strDescriptionNO, + "pl" => result.strDescriptionPL, + "pt" => result.strDescriptionPT, + "ru" => result.strDescriptionRU, + "sv" => result.strDescriptionSE, + "zh" => result.strDescriptionCN, + _ => null + }; + internal async Task EnsureInfo(string musicBrainzReleaseGroupId, CancellationToken cancellationToken) { var xmlPath = GetAlbumInfoPath(_config.ApplicationPaths, musicBrainzReleaseGroupId); diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs index c4f4833857..2d9fe4448f 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs @@ -22,6 +22,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; +using MediaBrowser.Providers.Manager; using MediaBrowser.Providers.Music; namespace MediaBrowser.Providers.Plugins.AudioDb @@ -148,7 +149,7 @@ namespace MediaBrowser.Providers.Plugins.AudioDb { result.Item = new MusicArtist(); result.HasMetadata = true; - ProcessResult(result.Item, artist, info.MetadataLanguage); + ProcessResult(result, artist, info.MetadataLanguage); } return result; @@ -193,8 +194,10 @@ namespace MediaBrowser.Providers.Plugins.AudioDb return null; } - private void ProcessResult(MusicArtist item, Artist result, string preferredLanguage) + private void ProcessResult(MetadataResult metadataResult, Artist result, string preferredLanguage) { + var item = metadataResult.Item; + if (!string.IsNullOrWhiteSpace(result.strWebsite)) { item.HomePageUrl = result.strWebsite; @@ -229,43 +232,48 @@ namespace MediaBrowser.Providers.Plugins.AudioDb item.SetProviderId(MetadataProvider.AudioDbArtist, result.idArtist); item.SetProviderId(MetadataProvider.MusicBrainzArtist, result.strMusicBrainzID); - string overview = null; - - if (string.Equals(preferredLanguage, "de", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strBiographyDE; - } - else if (string.Equals(preferredLanguage, "fr", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strBiographyFR; - } - else if (string.Equals(preferredLanguage, "nl", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strBiographyNL; - } - else if (string.Equals(preferredLanguage, "ru", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strBiographyRU; - } - else if (string.Equals(preferredLanguage, "it", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strBiographyIT; - } - else if ((preferredLanguage ?? string.Empty).StartsWith("pt", StringComparison.OrdinalIgnoreCase)) - { - overview = result.strBiographyPT; - } + var language = MetadataLanguageUtils.GetLanguageSubtag(preferredLanguage); + var overview = GetBiography(result, language); if (string.IsNullOrWhiteSpace(overview)) { overview = string.IsNullOrWhiteSpace(result.strBiographyEN) ? result.strBiography : result.strBiographyEN; + + // The biography is not in the requested language, mark it as English so it does not + // block a provider further down the list that can serve the requested language + metadataResult.ResultLanguage = "en"; + } + else + { + metadataResult.ResultLanguage = language; } item.Overview = (overview ?? string.Empty).StripHtml(); } + private static string GetBiography(Artist result, string language) + => language switch + { + "de" => result.strBiographyDE, + "en" => result.strBiographyEN, + "es" => result.strBiographyES, + "fr" => result.strBiographyFR, + "he" => result.strBiographyIL, + "hu" => result.strBiographyHU, + "it" => result.strBiographyIT, + "ja" => result.strBiographyJP, + "nl" => result.strBiographyNL, + "no" or "nb" or "nn" => result.strBiographyNO, + "pl" => result.strBiographyPL, + "pt" => result.strBiographyPT, + "ru" => result.strBiographyRU, + "sv" => result.strBiographySE, + "zh" => result.strBiographyCN, + _ => null + }; + internal async Task EnsureArtistInfo(string musicBrainzId, CancellationToken cancellationToken) { var xmlPath = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId); diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs index ccff31ebaa..437a997c11 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbEpisodeProvider.cs @@ -44,7 +44,9 @@ namespace MediaBrowser.Providers.Plugins.Omdb var result = new MetadataResult { Item = new Episode(), - QueriedById = true + QueriedById = true, + // OMDb is not localized, everything it returns is English + ResultLanguage = "en" }; // Allowing this will dramatically increase scan times diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs index e84f1359b7..7b245ea5a7 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs @@ -218,7 +218,9 @@ namespace MediaBrowser.Providers.Plugins.Omdb var result = new MetadataResult { Item = new T(), - QueriedById = true + QueriedById = true, + // OMDb is not localized, everything it returns is English + ResultLanguage = "en" }; var imdbId = info.GetProviderId(MetadataProvider.Imdb); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs index a7bba2d539..1bc2d3654f 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs @@ -115,7 +115,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets } } - var result = new MetadataResult(); + var result = new MetadataResult + { + ResultLanguage = language + }; if (tmdbId > 0) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs index 64ab98b262..e456b4e881 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs @@ -101,7 +101,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People } } - var result = new MetadataResult(); + var result = new MetadataResult + { + ResultLanguage = info.MetadataLanguage + }; if (personTmdbId > 0) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs index 9c41d64253..9a997c80b1 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs @@ -41,7 +41,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV /// public async Task> GetMetadata(SeasonInfo info, CancellationToken cancellationToken) { - var result = new MetadataResult(); + var result = new MetadataResult + { + ResultLanguage = info.MetadataLanguage + }; var config = Plugin.Instance.Configuration; info.SeriesProviderIds.TryGetValue(MetadataProvider.Tmdb.ToString(), out string? seriesTmdbId); diff --git a/tests/Jellyfin.Providers.Tests/Manager/MetadataLanguageUtilsTests.cs b/tests/Jellyfin.Providers.Tests/Manager/MetadataLanguageUtilsTests.cs new file mode 100644 index 0000000000..d3b0b47465 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Manager/MetadataLanguageUtilsTests.cs @@ -0,0 +1,38 @@ +using MediaBrowser.Providers.Manager; +using Xunit; + +namespace Jellyfin.Providers.Tests.Manager +{ + public class MetadataLanguageUtilsTests + { + [Theory] + [InlineData("es", "es")] + [InlineData("es-ES", "es")] + [InlineData("pt-BR", "pt")] + [InlineData("ES", "es")] + [InlineData(null, null)] + [InlineData("", null)] + public void GetLanguageSubtag_ReturnsLowercasedSubtag(string? language, string? expected) + { + Assert.Equal(expected, MetadataLanguageUtils.GetLanguageSubtag(language)); + } + + [Theory] + [InlineData("es", "es", true)] + [InlineData("es", "es-ES", true)] + [InlineData("es-MX", "es-ES", true)] + [InlineData("ES", "es", true)] + [InlineData("en", "en", true)] + [InlineData("en", "es-ES", false)] + [InlineData("en", "es", false)] + // An unknown language on either side cannot be judged and is assumed to match + [InlineData(null, "es", true)] + [InlineData("", "es", true)] + [InlineData("en", null, true)] + [InlineData("en", "", true)] + public void MatchesPreferredLanguage_ComparesLanguageSubtag(string? resultLanguage, string? preferredLanguage, bool expected) + { + Assert.Equal(expected, MetadataLanguageUtils.MatchesPreferredLanguage(resultLanguage, preferredLanguage)); + } + } +} -- cgit v1.2.3 From 7e6709f023bab9421a1219e5a59f34e1b8208147 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Wed, 12 Aug 2026 08:48:42 +0200 Subject: Fix formatting --- .../Jellyfin.Database.Implementations/DescendantQueryHelper.cs | 2 +- .../Migrations/20260812050902_AddMediaStreamFilterIndex.cs | 2 +- .../Item/AlternateVersionQueryTranslationTests.cs | 2 +- .../Item/BaseItemRepositoryByNameTotalCountTests.cs | 2 +- .../Item/BaseItemRepositoryGroupingTests.cs | 2 +- .../Item/BaseItemRepositoryStreamFilterTests.cs | 2 +- .../Item/DescendantQueryHelperTests.cs | 2 +- .../Item/ItemPersistenceOwnedRowTests.cs | 2 +- .../Item/PeopleRepositoryUpdatePeopleTests.cs | 2 +- tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs | 2 +- 10 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs index 7425ebde83..92adc37ccc 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.cs index 66e4d1deee..75187c6c4b 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260812050902_AddMediaStreamFilterIndex.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Migrations; #nullable disable diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs index 2dbcd41a41..2d520f8b8b 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/AlternateVersionQueryTranslationTests.cs @@ -1,4 +1,4 @@ -#pragma warning disable RS0030 // Do not use banned APIs: Guid == is required inside EF expression trees to mirror the production query shapes. +#pragma warning disable RS0030 // Do not use banned APIs: Guid == is required inside EF expression trees to mirror the production query shapes. using System; using System.Linq; diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs index 7dbaea2fb5..0cee47f660 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryByNameTotalCountTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using Emby.Server.Implementations.Data; using Jellyfin.Database.Implementations; diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs index 5dd648a2b8..535961a66c 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryGroupingTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using Emby.Server.Implementations.Data; using Jellyfin.Database.Implementations; diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs index 3407e2130b..cc6b097664 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using Emby.Server.Implementations.Data; using Jellyfin.Database.Implementations; diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs index 6cd31d9243..bb14c3897c 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using Jellyfin.Database.Implementations; diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs index 9b78a609ab..82614c3156 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemPersistenceOwnedRowTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading; diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs index 83465245fa..54565c5787 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using Emby.Server.Implementations.Data; using Jellyfin.Data.Enums; diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs index 6be8244c1e..87efa8fea5 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/SqliteDbTestFixture.cs @@ -1,4 +1,4 @@ -using System; +using System; using Emby.Server.Implementations.Data; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Locking; -- cgit v1.2.3 From c77649d21e6bd53a662b217c6431b7d123d656b9 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 14 Aug 2026 07:39:49 +0200 Subject: Skip alternate version links when resolving link parents --- .../DescendantQueryHelper.cs | 16 ++++++--- .../Item/DescendantQueryHelperTests.cs | 42 ++++++++++++++++++++-- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs index 92adc37ccc..1e1c8780e8 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs @@ -177,9 +177,17 @@ public static class DescendantQueryHelper // Resolves the folders whose linked children lead, at any depth, to a matching item. private static List ResolveLinkParents(JellyfinDbContext context, IQueryable matchingItemIds, IQueryable ancestorsOfMatches) { + // An alternate version is a second file for the item that links it, not a child of it, so that + // edge is not walked. It is also the one link a non-folder owns, and there is one per remuxed + // movie: walking it would swell this list from the BoxSet and Playlist count to the item count, + // and the list is bound into every statement the returned queryable is embedded in. + var containerLinks = context.LinkedChildren + .Where(e => e.ChildType != LinkedChildType.LocalAlternateVersion + && e.ChildType != LinkedChildType.LinkedAlternateVersion); + // A link sits above the closure and above another link alike, so the hop repeats until nothing - // new turns up. Only link owners are collected, which bounds it by BoxSets and Playlists. - var resolved = context.LinkedChildren + // new turns up. + var resolved = containerLinks .Where(e => matchingItemIds.Contains(e.ChildId) || ancestorsOfMatches.Contains(e.ChildId)) .Select(e => e.ParentId) .Distinct() @@ -193,11 +201,11 @@ public static class DescendantQueryHelper .WhereOneOrMany(frontier, e => e.ItemId) .Select(e => e.ParentItemId); - var directLinkParents = context.LinkedChildren + var directLinkParents = containerLinks .WhereOneOrMany(frontier, e => e.ChildId) .Select(e => e.ParentId); - var indirectLinkParents = context.LinkedChildren + var indirectLinkParents = containerLinks .Where(e => containingFolders.Contains(e.ChildId)) .Select(e => e.ParentId); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs index bb14c3897c..f2ecfadd50 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/DescendantQueryHelperTests.cs @@ -363,6 +363,44 @@ public sealed class DescendantQueryHelperTests : SqliteDbTestFixture } } + [Fact] + public void GetFolderIdsMatching_AlternateVersionLinks_AreNotWalked() + { + var collections = Guid.NewGuid(); + var boxSet = Guid.NewGuid(); + var library = Guid.NewGuid(); + var movie = Guid.NewGuid(); + var alternateVersion = Guid.NewGuid(); + + using (var ctx = CreateDbContext()) + { + AddFolder(ctx, collections); + AddItem(ctx, boxSet, BoxSetType, isFolder: true); + AddFolder(ctx, library); + AddItem(ctx, movie, MovieType); + AddItem(ctx, alternateVersion, MovieType); + + AddAncestors(ctx, boxSet, collections); + AddAncestors(ctx, movie, library); + AddAncestors(ctx, alternateVersion, library); + // Only the second file carries the subtitles, and it hangs off the movie by an alternate + // version link. The movie is not a folder, so that link is not a parent-child edge. + AddLink(ctx, movie, alternateVersion, LinkedChildType.LocalAlternateVersion); + AddLink(ctx, boxSet, movie); + AddStream(ctx, alternateVersion, MediaStreamTypeEntity.Subtitle); + ctx.SaveChanges(); + } + + using (var ctx = CreateDbContext()) + { + var folders = DescendantQueryHelper.GetFolderIdsMatching(ctx, new HasSubtitles()).ToHashSet(); + + // The library still matches: the alternate version carries its own closure. The box set does + // not, matching the descendant side, which does not follow a non-folder's links either. + Assert.Equal([library], folders); + } + } + [Fact] public void GetOwnedDescendantIds_IgnoresLinkedChildren() { @@ -472,7 +510,7 @@ public sealed class DescendantQueryHelperTests : SqliteDbTestFixture } // LinkedChildren is keyed on (ParentId, SortOrder), so every link of a parent needs its own slot. - private void AddLink(JellyfinDbContext context, Guid parentId, Guid childId) + private void AddLink(JellyfinDbContext context, Guid parentId, Guid childId, LinkedChildType childType = LinkedChildType.Manual) { _linkCounters.TryGetValue(parentId, out var sortOrder); _linkCounters[parentId] = sortOrder + 1; @@ -481,7 +519,7 @@ public sealed class DescendantQueryHelperTests : SqliteDbTestFixture { ParentId = parentId, ChildId = childId, - ChildType = LinkedChildType.Manual, + ChildType = childType, SortOrder = sortOrder }); } -- cgit v1.2.3 From 40a449c6f23feb4ebebdfd20e3cba39f49d441f6 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 14 Aug 2026 08:37:24 +0200 Subject: Count alternate versions in the media filters and align filter conditions --- .../Item/BaseItemRepository.TranslateQuery.cs | 128 +++++++++++++++----- .../DescendantQueryHelper.cs | 72 +++++++++-- .../Item/BaseItemRepositoryStreamFilterTests.cs | 131 +++++++++++++++++++++ 3 files changed, 290 insertions(+), 41 deletions(-) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 4be9b04baa..d2f8e5060c 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -35,6 +35,18 @@ public sealed partial class BaseItemRepository // instance across several lambdas, and this filter is combined into a tree more than once. private static Expression> IsFolderFilter => e => e.IsFolder; + // "und" is the language filters' stand-in for a track that declares no language at all. + private static bool IsUndetermined(string language) + => string.Equals(language, "und", StringComparison.OrdinalIgnoreCase); + + // The primary versions whose alternate version satisfies a dimension bound. Anchored on + // PrimaryVersionId so the filtered index carries it rather than a scan of every item. + private static IQueryable VersionsMatchingDimension(JellyfinDbContext context, Expression> bound) + => context.BaseItems + .Where(v => v.PrimaryVersionId != null) + .Where(bound) + .Select(v => v.PrimaryVersionId!.Value); + /// public IQueryable TranslateQuery( IQueryable baseQuery, @@ -70,15 +82,28 @@ public sealed partial class BaseItemRepository include4K = true; } + // A 4K remux of an SD primary is a version of the same item, so the resolution a caller + // filters on is the best any of the item's versions offers, not just the primary file's. + // The filtered PrimaryVersionId index keeps this to the few items that have versions. + var versionsAtResolution = context.BaseItems + .Where(v => v.PrimaryVersionId != null + && v.Width > 0 + && ((includeSD && v.Width < HDWidth) + || (includeHD && v.Width >= HDWidth && !(v.Width >= UHDWidth || v.Height >= UHDHeight)) + || (include4K && (v.Width >= UHDWidth || v.Height >= UHDHeight)))) + .Select(v => v.PrimaryVersionId!.Value); + // Non-folders: check own resolution directly (no subquery). // Folders (Series, BoxSets): EXISTS check on descendants/linked children. // Using navigation properties (a.Item, lc.Child) produces efficient // EXISTS + JOIN instead of nested IN (SELECT ...) subqueries. baseQuery = baseQuery.Where(e => - (!e.IsFolder && e.Width > 0 - && ((includeSD && e.Width < HDWidth) - || (includeHD && e.Width >= HDWidth && !(e.Width >= UHDWidth || e.Height >= UHDHeight)) - || (include4K && (e.Width >= UHDWidth || e.Height >= UHDHeight)))) + (!e.IsFolder + && ((e.Width > 0 + && ((includeSD && e.Width < HDWidth) + || (includeHD && e.Width >= HDWidth && !(e.Width >= UHDWidth || e.Height >= UHDHeight)) + || (include4K && (e.Width >= UHDWidth || e.Height >= UHDHeight)))) + || versionsAtResolution.Contains(e.Id))) || (e.IsFolder && (e.Children!.Any(a => a.Item.Width > 0 @@ -93,24 +118,31 @@ public sealed partial class BaseItemRepository || (include4K && (lc.Child.Width >= UHDWidth || lc.Child.Height >= UHDHeight))))))); } + // Same reasoning as the resolution filter: a dimension bound is met if any version meets it. if (minWidth.HasValue) { - baseQuery = baseQuery.Where(e => e.Width >= minWidth); + var versionsWideEnough = VersionsMatchingDimension(context, v => v.Width >= minWidth); + baseQuery = baseQuery.Where(e => e.Width >= minWidth || versionsWideEnough.Contains(e.Id)); } if (filter.MinHeight.HasValue) { - baseQuery = baseQuery.Where(e => e.Height >= filter.MinHeight); + var minHeight = filter.MinHeight; + var versionsTallEnough = VersionsMatchingDimension(context, v => v.Height >= minHeight); + baseQuery = baseQuery.Where(e => e.Height >= minHeight || versionsTallEnough.Contains(e.Id)); } if (maxWidth.HasValue) { - baseQuery = baseQuery.Where(e => e.Width <= maxWidth); + var versionsNarrowEnough = VersionsMatchingDimension(context, v => v.Width <= maxWidth); + baseQuery = baseQuery.Where(e => e.Width <= maxWidth || versionsNarrowEnough.Contains(e.Id)); } if (filter.MaxHeight.HasValue) { - baseQuery = baseQuery.Where(e => e.Height <= filter.MaxHeight); + var maxHeight = filter.MaxHeight; + var versionsShortEnough = VersionsMatchingDimension(context, v => v.Height <= maxHeight); + baseQuery = baseQuery.Where(e => e.Height <= maxHeight || versionsShortEnough.Contains(e.Id)); } if (filter.IsLocked.HasValue) @@ -762,103 +794,143 @@ public sealed partial class BaseItemRepository if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage)) { var lang = filter.HasNoAudioTrackWithLanguage; - var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, lang)); + var undetermined = IsUndetermined(lang); + var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Audio, lang); + // A track only an alternate version carries still belongs to the item a caller sees, so the + // item's own streams alone do not decide this. Same for every stream filter below. + var versionsWithAudio = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); baseQuery = baseQuery .Where(e => - (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Audio && ms.Language == lang)) + (!e.IsFolder + && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Audio + && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language)))) + && !versionsWithAudio.Contains(e.Id)) || (e.IsFolder && !foldersWithAudio.Contains(e.Id))); } if (!string.IsNullOrWhiteSpace(filter.HasNoInternalSubtitleTrackWithLanguage)) { var lang = filter.HasNoInternalSubtitleTrackWithLanguage; - var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: false)); + var undetermined = IsUndetermined(lang); + var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: false); + var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); baseQuery = baseQuery .Where(e => - (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && !ms.IsExternal && ms.Language == lang)) + (!e.IsFolder + && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && !ms.IsExternal + && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language)))) + && !versionsWithSubtitles.Contains(e.Id)) || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); } if (!string.IsNullOrWhiteSpace(filter.HasNoExternalSubtitleTrackWithLanguage)) { var lang = filter.HasNoExternalSubtitleTrackWithLanguage; - var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: true)); + var undetermined = IsUndetermined(lang); + var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: true); + var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); baseQuery = baseQuery .Where(e => - (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.IsExternal && ms.Language == lang)) + (!e.IsFolder + && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.IsExternal + && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language)))) + && !versionsWithSubtitles.Contains(e.Id)) || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); } if (!string.IsNullOrWhiteSpace(filter.HasNoSubtitleTrackWithLanguage)) { var lang = filter.HasNoSubtitleTrackWithLanguage; - var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang)); + var undetermined = IsUndetermined(lang); + var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang); + var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); baseQuery = baseQuery .Where(e => - (!e.IsFolder && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle && ms.Language == lang)) + (!e.IsFolder + && !e.MediaStreams!.Any(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle + && (ms.Language == lang || (undetermined && string.IsNullOrEmpty(ms.Language)))) + && !versionsWithSubtitles.Contains(e.Id)) || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); } if (filter.HasSubtitles.HasValue) { var hasSubtitles = filter.HasSubtitles.Value; - var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasSubtitles()); + var criteria = new HasSubtitles(); + var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); if (hasSubtitles) { baseQuery = baseQuery .Where(e => - (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle)) + (!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle) + || versionsWithSubtitles.Contains(e.Id))) || (e.IsFolder && foldersWithSubtitles.Contains(e.Id))); } else { baseQuery = baseQuery .Where(e => - (!e.IsFolder && !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle)) + (!e.IsFolder && !e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle) + && !versionsWithSubtitles.Contains(e.Id)) || (e.IsFolder && !foldersWithSubtitles.Contains(e.Id))); } } if (filter.SubtitleLanguages.Count > 0) { - var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, filter.SubtitleLanguages)); + var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, filter.SubtitleLanguages); + var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); baseQuery = baseQuery .Where(e => - (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle - && (filter.SubtitleLanguages.Contains(f.Language) || (filter.SubtitleLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))) + (!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Subtitle + && (filter.SubtitleLanguages.Contains(f.Language) || (filter.SubtitleLanguages.Contains("und") && string.IsNullOrEmpty(f.Language)))) + || versionsWithSubtitles.Contains(e.Id))) || (e.IsFolder && foldersWithSubtitles.Contains(e.Id))); } if (filter.AudioLanguages.Count > 0) { - var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, new HasMediaStreamType(MediaStreamTypeEntity.Audio, filter.AudioLanguages)); + var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Audio, filter.AudioLanguages); + var versionsWithAudio = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithAudio = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); baseQuery = baseQuery .Where(e => - (!e.IsFolder && e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio - && (filter.AudioLanguages.Contains(f.Language) || (filter.AudioLanguages.Contains("und") && string.IsNullOrEmpty(f.Language))))) + (!e.IsFolder && (e.MediaStreams!.Any(f => f.StreamType == MediaStreamTypeEntity.Audio + && (filter.AudioLanguages.Contains(f.Language) || (filter.AudioLanguages.Contains("und") && string.IsNullOrEmpty(f.Language)))) + || versionsWithAudio.Contains(e.Id))) || (e.IsFolder && foldersWithAudio.Contains(e.Id))); } if (filter.HasChapterImages.HasValue) { var hasChapterImages = filter.HasChapterImages.Value; - var foldersWithChapterImages = DescendantQueryHelper.GetFolderIdsMatching(context, new HasChapterImages()); + var criteria = new HasChapterImages(); + var versionsWithChapterImages = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); + var foldersWithChapterImages = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); if (hasChapterImages) { baseQuery = baseQuery .Where(e => - (!e.IsFolder && e.Chapters!.Any(f => f.ImagePath != null)) + (!e.IsFolder && (e.Chapters!.Any(f => f.ImagePath != null) + || versionsWithChapterImages.Contains(e.Id))) || (e.IsFolder && foldersWithChapterImages.Contains(e.Id))); } else { baseQuery = baseQuery .Where(e => - (!e.IsFolder && !e.Chapters!.Any(f => f.ImagePath != null)) + (!e.IsFolder && !e.Chapters!.Any(f => f.ImagePath != null) + && !versionsWithChapterImages.Contains(e.Id)) || (e.IsFolder && !foldersWithChapterImages.Contains(e.Id))); } } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs index 1e1c8780e8..5a17a46d9e 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs @@ -102,17 +102,7 @@ public static class DescendantQueryHelper ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(criteria); - var matchingItemIds = criteria switch - { - HasSubtitles => context.MediaStreamInfos - .Where(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle) - .Select(ms => ms.ItemId), - HasChapterImages => context.Chapters - .Where(c => c.ImagePath != null) - .Select(c => c.ItemId), - HasMediaStreamType m => GetMatchingMediaStreamItemIds(context, m), - _ => throw new ArgumentOutOfRangeException(nameof(criteria), $"Unknown criteria type: {criteria.GetType().Name}") - }; + var matchingItemIds = GetItemIdsMatching(context, criteria); // One hop up the closure covers every ancestor level. var hierarchyAncestors = context.AncestorIds @@ -144,7 +134,63 @@ public static class DescendantQueryHelper .Distinct(); } - private static IQueryable GetMatchingMediaStreamItemIds(JellyfinDbContext context, HasMediaStreamType criteria) + /// + /// Gets a queryable of the IDs of the items whose media matches the criteria. + /// + /// Database context. + /// The matching criteria to apply. + /// Queryable of item IDs. + /// + /// An alternate version is a second file for its primary version and is never listed on its own, so a + /// track only that file carries is reported against the primary: the item a caller can actually see. + /// + public static IQueryable GetItemIdsMatching(JellyfinDbContext context, FolderMatchCriteria criteria) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(criteria); + + return MatchingMediaOwners(context, criteria) + .Select(e => e.PrimaryVersionId ?? e.Id); + } + + /// + /// Gets a queryable of the IDs of the primary versions whose alternate version's media matches the + /// criteria. + /// + /// Database context. + /// The matching criteria to apply. + /// Queryable of primary version item IDs. + /// + /// For callers that already test an item's own media with their own indexed predicate: this covers + /// exactly what such a predicate misses, and the filtered PrimaryVersionId index keeps it to the few + /// items that have versions at all. + /// + public static IQueryable GetPrimaryVersionIdsMatching(JellyfinDbContext context, FolderMatchCriteria criteria) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(criteria); + + return MatchingMediaOwners(context, criteria) + .Where(e => e.PrimaryVersionId.HasValue) + .Select(e => e.PrimaryVersionId!.Value); + } + + // The items whose own media matches, as their BaseItems rows so the version group can be read off + // them. One definition of "matches" per criteria, so the projections above cannot drift apart. + private static IQueryable MatchingMediaOwners(JellyfinDbContext context, FolderMatchCriteria criteria) + => criteria switch + { + HasSubtitles => context.MediaStreamInfos + .Where(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle) + .Select(ms => ms.Item), + HasChapterImages => context.Chapters + .Where(c => c.ImagePath != null) + .Select(c => c.Item), + HasMediaStreamType m => GetMatchingMediaStreams(context, m).Select(ms => ms.Item), + _ => throw new ArgumentOutOfRangeException(nameof(criteria), $"Unknown criteria type: {criteria.GetType().Name}") + }; + + private static IQueryable GetMatchingMediaStreams(JellyfinDbContext context, HasMediaStreamType criteria) { var query = context.MediaStreamInfos .Where(ms => ms.StreamType == criteria.StreamType @@ -157,7 +203,7 @@ public static class DescendantQueryHelper query = query.Where(ms => ms.IsExternal == isExternal); } - return query.Select(ms => ms.ItemId); + return query; } private static IQueryable ClosureDescendants(JellyfinDbContext context, IReadOnlyList roots) diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs index cc6b097664..12a7fc1aef 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs @@ -29,6 +29,12 @@ public sealed class BaseItemRepositoryStreamFilterTests : SqliteDbTestFixture private readonly Guid _linkedSeries = Guid.NewGuid(); private readonly Guid _linkedEpisode = Guid.NewGuid(); + // A version group in a library of its own, so it cannot move the assertions above: an SD primary + // that carries nothing, and a 4K second file carrying the subtitles, chapter image and audio. + private readonly Guid _versionLibrary = Guid.NewGuid(); + private readonly Guid _versionedMovie = Guid.NewGuid(); + private readonly Guid _alternateVersion = Guid.NewGuid(); + public BaseItemRepositoryStreamFilterTests() { using (var ctx = CreateDbContext()) @@ -105,6 +111,74 @@ public sealed class BaseItemRepositoryStreamFilterTests : SqliteDbTestFixture Assert.DoesNotContain(_withoutSubtitles, ids); } + [Fact] + public void HasSubtitles_MatchesAnItemWhoseAlternateVersionCarriesThem() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = true }); + + Assert.Contains(_versionedMovie, ids); + Assert.Contains(_versionLibrary, ids); + // The second file is never listed on its own, which is why its tracks have to count for the primary. + Assert.DoesNotContain(_alternateVersion, ids); + } + + [Fact] + public void HasSubtitles_Negated_ExcludesAnItemWhoseAlternateVersionCarriesThem() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasSubtitles = false }); + + Assert.DoesNotContain(_versionedMovie, ids); + Assert.DoesNotContain(_versionLibrary, ids); + } + + [Fact] + public void SubtitleLanguages_MatchesTheLanguageOnAnAlternateVersion() + { + Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["ger"] })); + Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { SubtitleLanguages = ["fre"] })); + } + + [Fact] + public void HasNoSubtitleTrackWithLanguage_ExcludesAnItemWhoseAlternateVersionHasIt() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasNoSubtitleTrackWithLanguage = "ger" }); + + Assert.DoesNotContain(_versionedMovie, ids); + Assert.DoesNotContain(_versionLibrary, ids); + } + + [Fact] + public void AudioLanguages_MatchesTheLanguageOnAnAlternateVersion() + { + Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { AudioLanguages = ["fre"] })); + } + + [Fact] + public void HasNoAudioTrackWithLanguage_ExcludesAnItemWhoseAlternateVersionHasIt() + { + Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { HasNoAudioTrackWithLanguage = "fre" })); + } + + [Fact] + public void HasChapterImages_MatchesAnItemWhoseAlternateVersionCarriesThem() + { + Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { HasChapterImages = true })); + } + + [Fact] + public void Is4K_MatchesAnItemWhoseAlternateVersionIs4K() + { + // The primary file is SD; the resolution a caller can actually play is the 4K second file's. + Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { Is4K = true })); + } + + [Fact] + public void MinWidth_MatchesAnItemWhoseAlternateVersionIsWideEnough() + { + Assert.Contains(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { MinWidth = 3000 })); + Assert.DoesNotContain(_withSubtitles, _repository.GetItemIdsList(new InternalItemsQuery { MinWidth = 3000 })); + } + private void Seed(JellyfinDbContext context) { context.BaseItems.Add(new BaseItemEntity { Id = _library, Type = FolderType, Name = "Library", IsFolder = true }); @@ -178,6 +252,63 @@ public sealed class BaseItemRepositoryStreamFilterTests : SqliteDbTestFixture Item = null! }); + SeedVersionGroup(context); + context.SaveChanges(); } + + // An SD primary whose only extras live on a 4K second file, so every filter has to reach through + // PrimaryVersionId to answer correctly. + private void SeedVersionGroup(JellyfinDbContext context) + { + context.BaseItems.Add(new BaseItemEntity { Id = _versionLibrary, Type = FolderType, Name = "Version library", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _versionedMovie, Type = MovieType, Name = "Versioned movie", Width = 720, Height = 480 }); + context.BaseItems.Add(new BaseItemEntity + { + Id = _alternateVersion, + Type = MovieType, + Name = "Versioned movie 4K", + PrimaryVersionId = _versionedMovie, + Width = 3840, + Height = 2160 + }); + + foreach (var itemId in new[] { _versionedMovie, _alternateVersion }) + { + context.AncestorIds.Add(new AncestorId + { + ItemId = itemId, + ParentItemId = _versionLibrary, + Item = null!, + ParentItem = null! + }); + } + + context.MediaStreamInfos.Add(new MediaStreamInfo + { + ItemId = _alternateVersion, + StreamIndex = 0, + StreamType = MediaStreamTypeEntity.Subtitle, + Language = "ger", + Item = null! + }); + + context.MediaStreamInfos.Add(new MediaStreamInfo + { + ItemId = _alternateVersion, + StreamIndex = 1, + StreamType = MediaStreamTypeEntity.Audio, + Language = "fre", + Item = null! + }); + + context.Chapters.Add(new Chapter + { + ItemId = _alternateVersion, + ChapterIndex = 0, + StartPositionTicks = 0, + ImagePath = "/alternate-chapter.jpg", + Item = null! + }); + } } -- cgit v1.2.3 From 80fe8c67e97a8b239bd733eb295222ca52c5126d Mon Sep 17 00:00:00 2001 From: theguymadmax Date: Wed, 19 Aug 2026 18:12:10 -0400 Subject: Fix latest items for mixed libraries --- .../Library/UserViewManager.cs | 6 ++ .../Item/BaseItemRepository.Querying.cs | 64 ++++++++++++++++------ 2 files changed, 53 insertions(+), 17 deletions(-) diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs index 9512b0ffd7..49d76e195d 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -396,6 +396,12 @@ namespace Emby.Server.Implementations.Library query.Limit = limit; return _libraryManager.GetLatestItemList(query, parents, CollectionType.movies); } + + if (collectionType is null) + { + query.Limit = limit; + return _libraryManager.GetLatestItemList(query, parents, CollectionType.unknown); + } } return _libraryManager.GetItemList(query, parents); diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs index c7acf72043..c9e08b1b5d 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.Querying.cs @@ -110,7 +110,7 @@ public sealed partial class BaseItemRepository PrepareFilterQuery(filter); // Early exit if collection type is not supported - if (collectionType is not CollectionType.movies and not CollectionType.tvshows and not CollectionType.music) + if (collectionType is not CollectionType.movies and not CollectionType.tvshows and not CollectionType.music and not CollectionType.unknown) { return []; } @@ -121,30 +121,27 @@ public sealed partial class BaseItemRepository var baseQuery = PrepareItemQuery(context, filter); baseQuery = TranslateQuery(baseQuery, context, filter); - if (collectionType == CollectionType.tvshows) + if (collectionType is CollectionType.tvshows) { return GetLatestTvShowItems(context, baseQuery, filter, limit); } if (collectionType is CollectionType.movies) { - // Pick, per PresentationUniqueKey, the newest item; return the newest `limit` of those. - // Build up until limit by streaming through results and deduplicating on the fly. - var orderedIds = baseQuery - .Where(e => e.PresentationUniqueKey != null) - .OrderByDescending(e => e.DateCreated) - .ThenByDescending(e => e.Id) - .Select(e => new { e.Id, e.PresentationUniqueKey }); - - // DistinctBy and Take are lazy, so enumeration stops as soon as limit distinct keys are read. - var firstIds = orderedIds - .AsEnumerable() - .DistinctBy(row => row.PresentationUniqueKey) - .Select(row => row.Id) + return GetLatestMovieItems(context, baseQuery, filter, limit); + } + + if (collectionType is CollectionType.unknown) + { + var moviesQuery = baseQuery.Where(e => e.SeriesName == null); + var latestMovies = GetLatestMovieItems(context, moviesQuery, filter, limit); + var latestShows = GetLatestTvShowItems(context, baseQuery, filter, limit); + + return latestMovies.Concat(latestShows) + .OrderByDescending(dto => dto.DateCreated) + .ThenByDescending(dto => dto.Id) .Take(limit ?? int.MaxValue) .ToList(); - - return LoadLatestByIds(context, firstIds, filter); } var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]!; @@ -225,6 +222,39 @@ public sealed partial class BaseItemRepository .ToArray()!; } + /// + /// Gets the latest movies, deduplicated so each movie only appears once. + /// + /// The database context. + /// The query to pull movies from, with filters already applied. + /// The original query filter, used when loading the final items. + /// How many items to return. + /// The latest movies, newest first. + private IReadOnlyList GetLatestMovieItems( + JellyfinDbContext context, + IQueryable baseQuery, + InternalItemsQuery filter, + int? limit) + { + // Pick, per PresentationUniqueKey, the newest item; return the newest `limit` of those. + // Build up until limit by streaming through results and deduplicating on the fly. + var orderedIds = baseQuery + .Where(e => e.PresentationUniqueKey != null) + .OrderByDescending(e => e.DateCreated) + .ThenByDescending(e => e.Id) + .Select(e => new { e.Id, e.PresentationUniqueKey }); + + // DistinctBy and Take are lazy, so enumeration stops as soon as limit distinct keys are read. + var firstIds = orderedIds + .AsEnumerable() + .DistinctBy(row => row.PresentationUniqueKey) + .Select(row => row.Id) + .Take(limit ?? int.MaxValue) + .ToList(); + + return LoadLatestByIds(context, firstIds, filter); + } + /// /// Gets the latest TV show items with smart Season/Series container selection. /// -- cgit v1.2.3 From fb50b4df8b5e6dec9567772aa8baecfbccb34124 Mon Sep 17 00:00:00 2001 From: Tim Eisele Date: Thu, 20 Aug 2026 18:35:26 +0200 Subject: Stop user updates from orphaning permission and preference rows (#17645) * Stop user updates from orphaning permission and preference rows * Make UserId non-nullable * Remove unnecessary ToList * Update Jellyfin.Server.Implementations/Users/UserManager.cs Co-authored-by: Claus Vium --------- Co-authored-by: Claus Vium --- .../Users/UserManager.cs | 69 +- .../Entities/Permission.cs | 2 +- .../Entities/Preference.cs | 2 +- .../Entities/User.cs | 3 - .../ModelConfiguration/PermissionConfiguration.cs | 2 - .../ModelConfiguration/PreferenceConfiguration.cs | 1 - ...phanedUserPermissionsAndPreferences.Designer.cs | 1805 ++++++++++++++++++++ ..._RemoveOrphanedUserPermissionsAndPreferences.cs | 120 ++ .../Migrations/JellyfinDbModelSnapshot.cs | 24 +- .../Users/UserManagerProfileImageTests.cs | 23 - .../Users/UserManagerUpdateUserTests.cs | 181 ++ 11 files changed, 2173 insertions(+), 59 deletions(-) create mode 100644 src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.Designer.cs create mode 100644 src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.cs create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 932ced547a..fea6084267 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -225,19 +225,8 @@ namespace Jellyfin.Server.Implementations.Users ?? throw new ResourceNotFoundException(nameof(user.Id)); dbContext.Entry(dbUser).CurrentValues.SetValues(user); - dbContext.Permissions.RemoveRange(dbUser.Permissions); - dbUser.Permissions.Clear(); - foreach (var permission in user.Permissions) - { - dbUser.Permissions.Add(new Permission(permission.Kind, permission.Value)); - } - - dbContext.Preferences.RemoveRange(dbUser.Preferences); - dbUser.Preferences.Clear(); - foreach (var preference in user.Preferences) - { - dbUser.Preferences.Add(new Preference(preference.Kind, preference.Value)); - } + SyncPermissions(dbUser, user.Permissions); + SyncPreferences(dbUser, user.Preferences); dbUser.AccessSchedules.Clear(); foreach (var accessSchedule in user.AccessSchedules) @@ -271,6 +260,60 @@ namespace Jellyfin.Server.Implementations.Users } } + private static void SyncPermissions(User dbUser, ICollection source) + { + var incoming = new Dictionary(); + foreach (var permission in source) + { + incoming[permission.Kind] = permission.Value; + } + + foreach (var existing in dbUser.Permissions) + { + if (incoming.Remove(existing.Kind, out var value)) + { + // EF only marks the row modified if the value actually differs, so an update that + // touches nothing but the user row - a session activity stamp - writes no children. + existing.Value = value; + } + else + { + dbUser.Permissions.Remove(existing); + } + } + + foreach (var (kind, value) in incoming) + { + dbUser.Permissions.Add(new Permission(kind, value)); + } + } + + private static void SyncPreferences(User dbUser, ICollection source) + { + var incoming = new Dictionary(); + foreach (var preference in source) + { + incoming[preference.Kind] = preference.Value; + } + + foreach (var existing in dbUser.Preferences) + { + if (incoming.Remove(existing.Kind, out var value)) + { + existing.Value = value; + } + else + { + dbUser.Preferences.Remove(existing); + } + } + + foreach (var (kind, value) in incoming) + { + dbUser.Preferences.Add(new Preference(kind, value)); + } + } + internal async Task CreateUserInternalAsync(string name, JellyfinDbContext dbContext) { // TODO: Remove after user item data is migrated. diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs index 84b86574cc..eae02dda1c 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Permission.cs @@ -37,7 +37,7 @@ namespace Jellyfin.Database.Implementations.Entities /// /// Gets or sets the id of the associated user. /// - public Guid? UserId { get; set; } + public Guid UserId { get; set; } /// /// Gets the type of this permission. diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs index c02ea7375a..9bd159f2cf 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/Preference.cs @@ -35,7 +35,7 @@ namespace Jellyfin.Database.Implementations.Entities /// /// Gets or sets the id of the associated user. /// - public Guid? UserId { get; set; } + public Guid UserId { get; set; } /// /// Gets the type of this preference. diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs index b10e210e5d..bf6568d10a 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/User.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.ComponentModel.DataAnnotations.Schema; using System.Text.Json.Serialization; using Jellyfin.Database.Implementations.Enums; using Jellyfin.Database.Implementations.Interfaces; @@ -326,7 +325,6 @@ namespace Jellyfin.Database.Implementations.Entities /// /// Gets the list of permissions this user has. /// - [ForeignKey("Permission_Permissions_Guid")] public virtual ICollection Permissions { get; private set; } /* @@ -339,7 +337,6 @@ namespace Jellyfin.Database.Implementations.Entities /// /// Gets the list of preferences this user has. /// - [ForeignKey("Preference_Preferences_Guid")] public virtual ICollection Preferences { get; private set; } /// diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs index d2aed54eb1..ae53a36724 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PermissionConfiguration.cs @@ -14,10 +14,8 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration { // Used to get a user's permissions or a specific permission for a user. // Also prevents multiple values being created for a user. - // Filtered over non-null user ids for when other entities (groups, API keys) get permissions builder .HasIndex(p => new { p.UserId, p.Kind }) - .HasFilter("[UserId] IS NOT NULL") .IsUnique(); } } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs index 207051bcd1..5306078ed4 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/PreferenceConfiguration.cs @@ -14,7 +14,6 @@ namespace Jellyfin.Database.Implementations.ModelConfiguration { builder .HasIndex(p => new { p.UserId, p.Kind }) - .HasFilter("[UserId] IS NOT NULL") .IsUnique(); } } diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.Designer.cs new file mode 100644 index 0000000000..9c5d2cb36c --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.Designer.cs @@ -0,0 +1,1805 @@ +// +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("20260815063607_RemoveOrphanedUserPermissionsAndPreferences")] + partial class RemoveOrphanedUserPermissionsAndPreferences + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property("EndHour") + .HasColumnType("REAL"); + + b.Property("StartHour") + .HasColumnType("REAL"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("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("ItemId") + .HasColumnType("TEXT"); + + b.Property("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("ItemId") + .HasColumnType("TEXT"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Filename") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Album") + .HasColumnType("TEXT"); + + b.Property("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property("Artists") + .HasColumnType("TEXT"); + + b.Property("Audio") + .HasColumnType("INTEGER"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("CleanName") + .HasColumnType("TEXT"); + + b.Property("CommunityRating") + .HasColumnType("REAL"); + + b.Property("CriticRating") + .HasColumnType("REAL"); + + b.Property("CustomRating") + .HasColumnType("TEXT"); + + b.Property("Data") + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property("ExternalId") + .HasColumnType("TEXT"); + + b.Property("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property("ExtraType") + .HasColumnType("INTEGER"); + + b.Property("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property("Genres") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingSubValue") + .HasColumnType("INTEGER"); + + b.Property("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property("IsFolder") + .HasColumnType("INTEGER"); + + b.Property("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property("IsLocked") + .HasColumnType("INTEGER"); + + b.Property("IsMovie") + .HasColumnType("INTEGER"); + + b.Property("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property("IsSeries") + .HasColumnType("INTEGER"); + + b.Property("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property("LUFS") + .HasColumnType("REAL"); + + b.Property("MediaType") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizationGain") + .HasColumnType("REAL"); + + b.Property("OfficialRating") + .HasColumnType("TEXT"); + + b.Property("OriginalLanguage") + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property("PremiereDate") + .HasColumnType("TEXT"); + + b.Property("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property("SeasonId") + .HasColumnType("TEXT"); + + b.Property("SeasonName") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("SeriesName") + .HasColumnType("TEXT"); + + b.Property("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property("ShowId") + .HasColumnType("TEXT"); + + b.Property("Size") + .HasColumnType("INTEGER"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Studios") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TopParentId") + .HasColumnType("TEXT"); + + b.Property("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UnratedType") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("PrimaryVersionId") + .HasFilter("\"PrimaryVersionId\" IS NOT NULL"); + + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Blurhash") + .HasColumnType("BLOB"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("ImageType") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("Id") + .HasColumnType("INTEGER"); + + b.Property("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("ItemId") + .HasColumnType("TEXT"); + + b.Property("ProviderId") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .HasColumnType("INTEGER"); + + b.Property("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("ItemId") + .HasColumnType("TEXT"); + + b.Property("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property("ImagePath") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("IndexBy") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("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("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.Property("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("ItemValueId") + .HasColumnType("TEXT"); + + b.Property("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("ItemId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("KeyframeTicks") + .HasColumnType("TEXT"); + + b.Property("TotalDuration") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId"); + + b.ToTable("KeyframeData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("ChildId") + .HasColumnType("TEXT"); + + b.Property("ChildType") + .HasColumnType("INTEGER"); + + b.HasKey("ParentId", "SortOrder"); + + b.HasIndex("ChildId", "ChildType"); + + b.HasIndex("ParentId", "ChildType"); + + b.ToTable("LinkedChildren", (string)null); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaSegment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EndTicks") + .HasColumnType("INTEGER"); + + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StartTicks") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property("AspectRatio") + .HasColumnType("TEXT"); + + b.Property("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property("BitDepth") + .HasColumnType("INTEGER"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasColumnType("TEXT"); + + b.Property("CodecTag") + .HasColumnType("TEXT"); + + b.Property("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property("ColorSpace") + .HasColumnType("TEXT"); + + b.Property("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property("DvLevel") + .HasColumnType("INTEGER"); + + b.Property("DvProfile") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Hdr10PlusPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property("IsAvc") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsExternal") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property("IsOriginal") + .HasColumnType("INTEGER"); + + b.Property("KeyFrames") + .HasColumnType("TEXT"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("REAL"); + + b.Property("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.Property("PixelFormat") + .HasColumnType("TEXT"); + + b.Property("Profile") + .HasColumnType("TEXT"); + + b.Property("RealFrameRate") + .HasColumnType("REAL"); + + b.Property("RefFrames") + .HasColumnType("INTEGER"); + + b.Property("Rotation") + .HasColumnType("INTEGER"); + + b.Property("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property("SampleRate") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .HasColumnType("INTEGER"); + + b.Property("TimeBase") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("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("ItemId") + .HasColumnType("TEXT"); + + b.Property("PeopleId") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("ListOrder") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId", "Role"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.HasIndex("PeopleId", "ItemId"); + + b.ToTable("PeopleBaseItemMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique(); + + b.ToTable("Permissions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique(); + + b.ToTable("Preferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DateCreated") + .HasColumnType("TEXT"); + + b.Property("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property("DateModified") + .HasColumnType("TEXT"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CustomName") + .HasColumnType("TEXT"); + + b.Property("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("ItemId") + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.Property("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Interval") + .HasColumnType("INTEGER"); + + b.Property("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property("TileHeight") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property("InternalId") + .HasColumnType("INTEGER"); + + b.Property("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalRatingScore") + .HasColumnType("INTEGER"); + + b.Property("MaxParentalRatingSubScore") + .HasColumnType("INTEGER"); + + b.Property("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.Property("ItemId") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property("Likes") + .HasColumnType("INTEGER"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property("Played") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RetentionDate") + .HasColumnType("TEXT"); + + b.Property("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) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + 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/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.cs new file mode 100644 index 0000000000..3d4cf90441 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260815063607_RemoveOrphanedUserPermissionsAndPreferences.cs @@ -0,0 +1,120 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Database.Providers.Sqlite.Migrations +{ + /// + public partial class RemoveOrphanedUserPermissionsAndPreferences : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("DELETE FROM Permissions WHERE UserId IS NULL;"); + migrationBuilder.Sql("DELETE FROM Preferences WHERE UserId IS NULL;"); + + migrationBuilder.DropIndex( + name: "IX_Preferences_UserId_Kind", + table: "Preferences"); + + migrationBuilder.DropIndex( + name: "IX_Permissions_UserId_Kind", + table: "Permissions"); + + migrationBuilder.DropColumn( + name: "Preference_Preferences_Guid", + table: "Preferences"); + + migrationBuilder.DropColumn( + name: "Permission_Permissions_Guid", + table: "Permissions"); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "Preferences", + type: "TEXT", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "Permissions", + type: "TEXT", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Preferences_UserId_Kind", + table: "Preferences", + columns: ["UserId", "Kind"], + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Permissions_UserId_Kind", + table: "Permissions", + columns: ["UserId", "Kind"], + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_Preferences_UserId_Kind", + table: "Preferences"); + + migrationBuilder.DropIndex( + name: "IX_Permissions_UserId_Kind", + table: "Permissions"); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "Preferences", + type: "TEXT", + nullable: true, + oldClrType: typeof(Guid), + oldType: "TEXT"); + + migrationBuilder.AddColumn( + name: "Preference_Preferences_Guid", + table: "Preferences", + type: "TEXT", + nullable: true); + + migrationBuilder.AlterColumn( + name: "UserId", + table: "Permissions", + type: "TEXT", + nullable: true, + oldClrType: typeof(Guid), + oldType: "TEXT"); + + migrationBuilder.AddColumn( + name: "Permission_Permissions_Guid", + table: "Permissions", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateIndex( + name: "IX_Preferences_UserId_Kind", + table: "Preferences", + columns: ["UserId", "Kind"], + unique: true, + filter: "[UserId] IS NOT NULL"); + + migrationBuilder.CreateIndex( + name: "IX_Permissions_UserId_Kind", + table: "Permissions", + columns: ["UserId", "Kind"], + unique: true, + filter: "[UserId] IS NOT NULL"); + } + } +} 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 cdf5c84826..35a62d4907 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs @@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => { @@ -1078,14 +1078,11 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property("Kind") .HasColumnType("INTEGER"); - b.Property("Permission_Permissions_Guid") - .HasColumnType("TEXT"); - b.Property("RowVersion") .IsConcurrencyToken() .HasColumnType("INTEGER"); - b.Property("UserId") + b.Property("UserId") .HasColumnType("TEXT"); b.Property("Value") @@ -1094,8 +1091,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasKey("Id"); b.HasIndex("UserId", "Kind") - .IsUnique() - .HasFilter("[UserId] IS NOT NULL"); + .IsUnique(); b.ToTable("Permissions"); @@ -1111,14 +1107,11 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property("Kind") .HasColumnType("INTEGER"); - b.Property("Preference_Preferences_Guid") - .HasColumnType("TEXT"); - b.Property("RowVersion") .IsConcurrencyToken() .HasColumnType("INTEGER"); - b.Property("UserId") + b.Property("UserId") .HasColumnType("TEXT"); b.Property("Value") @@ -1129,8 +1122,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasKey("Id"); b.HasIndex("UserId", "Kind") - .IsUnique() - .HasFilter("[UserId] IS NOT NULL"); + .IsUnique(); b.ToTable("Preferences"); @@ -1699,7 +1691,8 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) .WithMany("Permissions") .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade); + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); }); modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => @@ -1707,7 +1700,8 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) .WithMany("Preferences") .HasForeignKey("UserId") - .OnDelete(DeleteBehavior.Cascade); + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); }); modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs index 778b888735..cb714a4014 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerProfileImageTests.cs @@ -1,6 +1,5 @@ using System; using System.IO; -using System.Linq; using System.Threading; using System.Threading.Tasks; using Jellyfin.Database.Implementations; @@ -92,28 +91,6 @@ namespace Jellyfin.Server.Implementations.Tests.Users new NoLockBehavior(NullLogger.Instance)); } - [Fact] - public async Task UpdateUserAsync_DoesNotLeaveOrphanedPermissionsOrPreferences() - { - var user = await _userManager.CreateUserAsync("updateduser"); - var permissionCount = user.Permissions.Count; - var preferenceCount = user.Preferences.Count; - - user.LastActivityDate = DateTime.UtcNow; - await _userManager.UpdateUserAsync(user); - await _userManager.UpdateUserAsync(user); - - await using var context = CreateDbContext(); - Assert.Empty(await context.Permissions - .Where(permission => !permission.UserId.HasValue) - .ToListAsync(TestContext.Current.CancellationToken)); - Assert.Empty(await context.Preferences - .Where(preference => !preference.UserId.HasValue) - .ToListAsync(TestContext.Current.CancellationToken)); - Assert.Equal(permissionCount, await context.Permissions.CountAsync(TestContext.Current.CancellationToken)); - Assert.Equal(preferenceCount, await context.Preferences.CountAsync(TestContext.Current.CancellationToken)); - } - [Fact] public async Task ClearProfileImageAsync_WhenInMemoryImageHasTemporaryKey_RemovesPersistedImage() { diff --git a/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs new file mode 100644 index 0000000000..c940f92109 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Users/UserManagerUpdateUserTests.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Database.Implementations.Locking; +using Jellyfin.Database.Providers.Sqlite; +using Jellyfin.Server.Implementations.Users; +using MediaBrowser.Common; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Drawing; +using MediaBrowser.Controller.Events; +using MediaBrowser.Model.Cryptography; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.Users; + +public sealed class UserManagerUpdateUserTests : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly DbContextOptions _dbOptions; + private readonly UserManager _userManager; + + public UserManagerUpdateUserTests() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + + _dbOptions = new DbContextOptionsBuilder() + .UseSqlite(_connection) + .Options; + + // Create the schema + using var ctx = CreateDbContext(); + ctx.Database.EnsureCreated(); + + var factory = new Mock>(); + factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + factory.Setup(f => f.CreateDbContextAsync(It.IsAny())) + .ReturnsAsync(CreateDbContext); + + var cryptoProvider = new Mock(); + var configManager = new Mock(); + var appPaths = new Mock(); + appPaths.Setup(x => x.ProgramDataPath).Returns(Path.GetTempPath()); + configManager.Setup(x => x.ApplicationPaths).Returns(appPaths.Object); + + var appHost = new Mock(); + + var defaultAuthProvider = new DefaultAuthenticationProvider( + NullLogger.Instance, + cryptoProvider.Object); + var invalidAuthProvider = new InvalidAuthProvider(); + var defaultPasswordResetProvider = new DefaultPasswordResetProvider( + configManager.Object, + appHost.Object); + + _userManager = new UserManager( + factory.Object, + new NoopEventManager(), + new Mock().Object, + appHost.Object, + new Mock().Object, + NullLogger.Instance, + configManager.Object, + [defaultPasswordResetProvider], + [defaultAuthProvider, invalidAuthProvider]); + } + + public void Dispose() + { + _userManager.Dispose(); + _connection.Dispose(); + } + + [Fact] + public async Task UpdateUserAsync_DoesNotDetachPermissionsOrPreferences() + { + var user = await _userManager.CreateUserAsync("orphanuser"); + var permissionCount = user.Permissions.Count; + var preferenceCount = user.Preferences.Count; + + user.LastActivityDate = DateTime.UtcNow; + await _userManager.UpdateUserAsync(user); + await _userManager.UpdateUserAsync(user); + + await using var context = CreateDbContext(); + Assert.Equal(permissionCount, await context.Permissions.CountAsync(TestContext.Current.CancellationToken)); + Assert.Equal(preferenceCount, await context.Preferences.CountAsync(TestContext.Current.CancellationToken)); + Assert.All( + await context.Permissions.ToListAsync(TestContext.Current.CancellationToken), + permission => Assert.Equal(user.Id, permission.UserId)); + Assert.All( + await context.Preferences.ToListAsync(TestContext.Current.CancellationToken), + preference => Assert.Equal(user.Id, preference.UserId)); + } + + [Fact] + public async Task UpdateUserAsync_WhenOnlyTheUserRowChanged_LeavesChildRowsUntouched() + { + var user = await _userManager.CreateUserAsync("churnuser"); + var before = await ReadChildRowsAsync(); + + // A session activity stamp goes through the same path. It must not rewrite all 37 child + // rows, which is what tearing the collections down and rebuilding them used to do. + user.LastActivityDate = DateTime.UtcNow; + await _userManager.UpdateUserAsync(user); + + Assert.Equal(before, await ReadChildRowsAsync()); + } + + [Fact] + public async Task UpdateUserAsync_AppliesPermissionAndPreferenceChanges() + { + var user = await _userManager.CreateUserAsync("policyuser"); + Assert.False(user.HasPermission(PermissionKind.IsAdministrator)); + + user.SetPermission(PermissionKind.IsAdministrator, true); + user.SetPreference(PreferenceKind.BlockedTags, ["spoilers"]); + user.Permissions.Remove(user.Permissions.First(permission => permission.Kind == PermissionKind.EnableAllChannels)); + + await _userManager.UpdateUserAsync(user); + + var reloaded = _userManager.GetUserById(user.Id)!; + Assert.True(reloaded.HasPermission(PermissionKind.IsAdministrator)); + Assert.Equal(new[] { "spoilers" }, reloaded.GetPreference(PreferenceKind.BlockedTags)); + Assert.DoesNotContain(reloaded.Permissions, permission => permission.Kind == PermissionKind.EnableAllChannels); + + await using var context = CreateDbContext(); + Assert.Equal(reloaded.Permissions.Count, await context.Permissions.CountAsync(TestContext.Current.CancellationToken)); + } + + private JellyfinDbContext CreateDbContext() + { + return new JellyfinDbContext( + _dbOptions, + NullLogger.Instance, + new SqliteDatabaseProvider(null!, NullLogger.Instance), + new NoLockBehavior(NullLogger.Instance)); + } + + /// + /// Reads the identity and concurrency token of every permission and preference row. + /// + private async Task> ReadChildRowsAsync() + { + await using var context = CreateDbContext(); + var permissions = await context.Permissions + .OrderBy(permission => permission.Id) + .Select(permission => new ValueTuple("Permission", permission.Id, (int)permission.Kind, permission.RowVersion)) + .ToListAsync(TestContext.Current.CancellationToken); + var preferences = await context.Preferences + .OrderBy(preference => preference.Id) + .Select(preference => new ValueTuple("Preference", preference.Id, (int)preference.Kind, preference.RowVersion)) + .ToListAsync(TestContext.Current.CancellationToken); + + return permissions.Concat(preferences).ToList(); + } + + private sealed class NoopEventManager : IEventManager + { + public void Publish(T eventArgs) + where T : EventArgs + { + } + + public Task PublishAsync(T eventArgs) + where T : EventArgs + => Task.CompletedTask; + } +} -- cgit v1.2.3 From 678975fbd7d6bcfda0713483c02bbf7daee29474 Mon Sep 17 00:00:00 2001 From: therealhampus Date: Thu, 20 Aug 2026 02:17:12 -0400 Subject: Translated using Weblate (Swedish) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/sv/ --- Emby.Server.Implementations/Localization/Core/sv.json | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/sv.json b/Emby.Server.Implementations/Localization/Core/sv.json index 30c85baaba..7d741bca36 100644 --- a/Emby.Server.Implementations/Localization/Core/sv.json +++ b/Emby.Server.Implementations/Localization/Core/sv.json @@ -116,5 +116,10 @@ "NameExtraScene": "Scen", "NameExtraShort": "Kortfilm", "NameExtraThemeSong": "Signaturmelodi", - "NameExtraTrailer": "Trailer" + "NameExtraTrailer": "Trailer", + "NameExtraClip": "Klipp", + "NameExtraFeaturette": "Kortfilm", + "NameExtraSample": "Prov", + "NameExtraThemeVideo": "Signaturvideo", + "NameExtraUnknown": "Extra" } -- cgit v1.2.3 From 40fbe4a7715030fc775da9721f275148c86cc674 Mon Sep 17 00:00:00 2001 From: theguymadmax Date: Thu, 20 Aug 2026 15:25:37 -0400 Subject: Allow direct play for HDHomeRun tuners --- src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs index e1f87a7bd4..15b1368939 100644 --- a/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs +++ b/src/Jellyfin.LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs @@ -326,7 +326,7 @@ namespace Jellyfin.LiveTv.TunerHosts.HdHomerun BufferMs = 0, Container = "ts", Id = id, - SupportsDirectPlay = false, + SupportsDirectPlay = true, SupportsDirectStream = true, SupportsTranscoding = true, IsInfiniteStream = true, -- cgit v1.2.3 From d6bcad3b59aa8f8069cdcba075b4878c845b780b Mon Sep 17 00:00:00 2001 From: Gabriel Popa Date: Thu, 20 Aug 2026 17:34:36 -0400 Subject: Translated using Weblate (Romanian) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ro/ --- Emby.Server.Implementations/Localization/Core/ro.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json index ea83b88951..358c19881f 100644 --- a/Emby.Server.Implementations/Localization/Core/ro.json +++ b/Emby.Server.Implementations/Localization/Core/ro.json @@ -108,5 +108,8 @@ "CleanupUserDataTask": "Sarcina de curatare a datelor utilizatorului", "CleanupUserDataTaskDescription": "Sterge toate datele utilizatorului (starea vizionarii, starea favoritelor etc.) de pe suporturile media care nu mai sunt prezente timp de cel puțin 90 de zile.", "LyricDownloadFailureFromForItem": "Versurile nu au putut fi descărcate din {0} pentru {1}", - "Original": "Original" + "Original": "Original", + "NameExtraBehindTheScenes": "În culise", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Scenă ștearsă" } -- cgit v1.2.3 From a8da0664a387aa871f7c0ee03fd3f53c82d00346 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 22 May 2026 22:17:11 +0200 Subject: Fix GHSA-wwwm-px48-fpvq # Conflicts: # MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs --- MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs | 4 ++-- MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 9a68889352..57c130fa4b 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -1318,7 +1318,7 @@ namespace MediaBrowser.Controller.MediaEncoding arg.Append(canvasArgs); } - arg.Append(" -i file:\"").Append(subtitlePath).Append('\"'); + arg.Append(" -i file:\"").Append(subtitlePath.Replace("\"", "\\\"", StringComparison.Ordinal)).Append('\"'); } if (state.AudioStream is not null && state.AudioStream.IsExternal) @@ -1330,7 +1330,7 @@ namespace MediaBrowser.Controller.MediaEncoding arg.Append(' ').Append(seekAudioParam); } - arg.Append(" -i \"").Append(state.AudioStream.Path).Append('"'); + arg.Append(" -i \"").Append(state.AudioStream.Path.Replace("\"", "\\\"", StringComparison.Ordinal)).Append('"'); } // Disable auto inserted SW scaler for HW decoders in case of changed resolution. diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index bd516f0a9f..b4626b93fa 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -21,6 +21,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; +using MediaBrowser.MediaEncoding.Encoder; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -453,7 +454,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles encodingParam = " -sub_charenc " + encodingParam; } - var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath); + var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, EncodingUtils.NormalizePath(inputPath), EncodingUtils.NormalizePath(outputPath)); await ExtractSubtitlesForFile( inputPath, @@ -631,7 +632,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles streamIndex, outputCodec, outputFormatOption, - outputPath); + EncodingUtils.NormalizePath(outputPath)); } await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false); @@ -689,7 +690,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles streamIndex, outputCodec, outputFormatOption, - outputPath); + EncodingUtils.NormalizePath(outputPath)); } if (outputPaths.Count > 0) -- cgit v1.2.3 From 9ba91d4583f3671eaacae8e073fb53288f9b8715 Mon Sep 17 00:00:00 2001 From: Cody Robibero Date: Thu, 20 Aug 2026 18:10:50 -0400 Subject: Normalize fix, apply in more places --- .../ScheduledTasks/Tasks/AudioNormalizationTask.cs | 2 +- Jellyfin.Api/Controllers/DynamicHlsController.cs | 5 ++- .../MediaEncoding/EncodingHelper.cs | 4 +-- .../Attachments/AttachmentExtractor.cs | 5 ++- .../Encoder/EncodingUtils.cs | 17 ++-------- .../Subtitles/SubtitleEncoder.cs | 8 ++--- src/Jellyfin.Extensions/StringExtensions.cs | 37 ++++++++++++++++++++++ src/Jellyfin.LiveTv/IO/EncodedRecorder.cs | 4 +-- .../StringExtensionsTests.cs | 23 ++++++++++++++ 9 files changed, 76 insertions(+), 29 deletions(-) diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs index e4939205c9..29b633530f 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/AudioNormalizationTask.cs @@ -174,7 +174,7 @@ public partial class AudioNormalizationTask : IScheduledTask if (!t.NormalizationGain.HasValue && !t.LUFS.HasValue && t.IsFileProtocol) { t.LUFS = await CalculateLUFSAsync( - string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.Replace("\"", "\\\"", StringComparison.Ordinal)), + string.Format(CultureInfo.InvariantCulture, "-i \"{0}\"", t.Path.EscapeProcessArgument()), false, cancellationToken).ConfigureAwait(false); toSaveDbItems.Add(t); diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index a6555a2beb..034a9dea55 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -20,7 +20,6 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Streaming; -using MediaBrowser.MediaEncoding.Encoder; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Entities; @@ -1652,9 +1651,9 @@ public class DynamicHlsController : BaseJellyfinApiController segmentFormat, startNumber.ToString(CultureInfo.InvariantCulture), baseUrlParam, - EncodingUtils.NormalizePath(outputTsArg), + outputTsArg.EscapeProcessArgument(), hlsArguments, - EncodingUtils.NormalizePath(outputPath)).Trim(); + outputPath.EscapeProcessArgument()).Trim(); } /// diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 57c130fa4b..10c21ee03c 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -1318,7 +1318,7 @@ namespace MediaBrowser.Controller.MediaEncoding arg.Append(canvasArgs); } - arg.Append(" -i file:\"").Append(subtitlePath.Replace("\"", "\\\"", StringComparison.Ordinal)).Append('\"'); + arg.Append(" -i file:\"").Append(subtitlePath.EscapeProcessArgument()).Append('\"'); } if (state.AudioStream is not null && state.AudioStream.IsExternal) @@ -1330,7 +1330,7 @@ namespace MediaBrowser.Controller.MediaEncoding arg.Append(' ').Append(seekAudioParam); } - arg.Append(" -i \"").Append(state.AudioStream.Path.Replace("\"", "\\\"", StringComparison.Ordinal)).Append('"'); + arg.Append(" -i \"").Append(state.AudioStream.Path.EscapeProcessArgument()).Append('"'); } // Disable auto inserted SW scaler for HW decoders in case of changed resolution. diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs index 12a5ab877c..fbe8afc66e 100644 --- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs +++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs @@ -14,7 +14,6 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.MediaEncoding.Encoder; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -160,7 +159,7 @@ namespace MediaBrowser.MediaEncoding.Attachments CultureInfo.InvariantCulture, "-dump_attachment:{0} \"{1}\" ", attachment.Index, - EncodingUtils.NormalizePath(attachmentPath)); + attachmentPath.EscapeProcessArgument()); missingPaths.Add(attachmentPath); } @@ -425,7 +424,7 @@ namespace MediaBrowser.MediaEncoding.Attachments "-dump_attachment:{1} \"{2}\" -i {0} {3}", inputPath, attachmentStreamIndex, - EncodingUtils.NormalizePath(outputPath), + outputPath.EscapeProcessArgument(), hasVideoOrAudioStream ? "-t 0 -f null null" : string.Empty); int exitCode; diff --git a/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs b/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs index 2daeac7343..a525dcfa62 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncodingUtils.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Globalization; using System.Linq; +using Jellyfin.Extensions; using MediaBrowser.Model.MediaInfo; namespace MediaBrowser.MediaEncoding.Encoder @@ -42,7 +43,7 @@ namespace MediaBrowser.MediaEncoding.Encoder // If there's more than one we'll need to use the concat command if (inputFiles.Count > 1) { - var files = string.Join('|', inputFiles.Select(NormalizePath)); + var files = string.Join('|', inputFiles.Select(f => f.EscapeProcessArgument())); return string.Format(CultureInfo.InvariantCulture, "concat:\"{0}\"", files); } @@ -64,21 +65,9 @@ namespace MediaBrowser.MediaEncoding.Encoder return string.Format(CultureInfo.InvariantCulture, "\"{0}\"", path); } - // Quotes are valid path characters in linux and they need to be escaped here with a leading \ - path = NormalizePath(path); + path = path.EscapeProcessArgument(); return string.Format(CultureInfo.InvariantCulture, "{1}:\"{0}\"", path, inputPrefix); } - - /// - /// Normalizes the path. - /// - /// The path. - /// System.String. - public static string NormalizePath(string path) - { - // Quotes are valid path characters in linux and they need to be escaped here with a leading \ - return path.Replace("\"", "\\\"", StringComparison.Ordinal); - } } } diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index b4626b93fa..e8c636e7fb 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -12,6 +12,7 @@ using System.Text; using System.Threading; using System.Threading.Tasks; using AsyncKeyedLock; +using Jellyfin.Extensions; using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; @@ -21,7 +22,6 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; -using MediaBrowser.MediaEncoding.Encoder; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -454,7 +454,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles encodingParam = " -sub_charenc " + encodingParam; } - var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, EncodingUtils.NormalizePath(inputPath), EncodingUtils.NormalizePath(outputPath)); + var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath.EscapeProcessArgument(), outputPath.EscapeProcessArgument()); await ExtractSubtitlesForFile( inputPath, @@ -632,7 +632,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles streamIndex, outputCodec, outputFormatOption, - EncodingUtils.NormalizePath(outputPath)); + outputPath.EscapeProcessArgument()); } await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false); @@ -690,7 +690,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles streamIndex, outputCodec, outputFormatOption, - EncodingUtils.NormalizePath(outputPath)); + outputPath.EscapeProcessArgument()); } if (outputPaths.Count > 0) diff --git a/src/Jellyfin.Extensions/StringExtensions.cs b/src/Jellyfin.Extensions/StringExtensions.cs index 906efbcbcc..38f1cf738f 100644 --- a/src/Jellyfin.Extensions/StringExtensions.cs +++ b/src/Jellyfin.Extensions/StringExtensions.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text; using System.Text.RegularExpressions; using ICU4N.Text; @@ -173,5 +174,41 @@ namespace Jellyfin.Extensions return cleaned; } + + /// + /// Escapes an argument so that it survives command line parsing as a single argument when it is wrapped in double quotes by the caller. + /// + /// The argument to escape. + /// The escaped argument. + public static string EscapeProcessArgument(this string value) + { + ArgumentNullException.ThrowIfNull(value); + + var span = value.AsSpan(); + if (!span.Contains('"')) + { + var trailing = span.Length - span.TrimEnd('\\').Length; + return trailing == 0 ? value : string.Concat(value, new string('\\', trailing)); + } + + var escaped = new StringBuilder(value.Length + 8); + var backslashes = 0; + + foreach (var character in span) + { + if (character == '\\') + { + backslashes++; + continue; + } + + escaped + .Append('\\', character == '"' ? (backslashes * 2) + 1 : backslashes) + .Append(character); + backslashes = 0; + } + + return escaped.Append('\\', backslashes * 2).ToString(); + } } } diff --git a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs index 19c4514766..633c4f95ed 100644 --- a/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs +++ b/src/Jellyfin.LiveTv/IO/EncodedRecorder.cs @@ -188,8 +188,8 @@ namespace Jellyfin.LiveTv.IO var commandLineArgs = string.Format( CultureInfo.InvariantCulture, "-i \"{0}\" {2} -map_metadata -1 -threads {6} {3}{4}{5} -y \"{1}\"", - inputTempFile, - targetFile.Replace("\"", "\\\"", StringComparison.Ordinal), // Escape quotes in filename + inputTempFile.EscapeProcessArgument(), + targetFile.EscapeProcessArgument(), videoArgs, GetAudioArgs(mediaSource), subtitleArgs, diff --git a/tests/Jellyfin.Extensions.Tests/StringExtensionsTests.cs b/tests/Jellyfin.Extensions.Tests/StringExtensionsTests.cs index 028f12afa7..0851570396 100644 --- a/tests/Jellyfin.Extensions.Tests/StringExtensionsTests.cs +++ b/tests/Jellyfin.Extensions.Tests/StringExtensionsTests.cs @@ -75,5 +75,28 @@ namespace Jellyfin.Extensions.Tests var result = str.AsSpan().RightPart(needle).ToString(); Assert.Equal(expectedResult, result); } + + [Theory] + [InlineData("", "")] + [InlineData("/media/movies/Film.mkv", "/media/movies/Film.mkv")] + [InlineData(@"C:\media\movies\Film.mkv", @"C:\media\movies\Film.mkv")] + [InlineData(@"/media/a""b.mkv", @"/media/a\""b.mkv")] + [InlineData(@"/media/a\""b.mkv", @"/media/a\\\""b.mkv")] + [InlineData(@"/media/a\\""b.mkv", @"/media/a\\\\\""b.mkv")] + [InlineData(@"/media/a\b""c.mkv", @"/media/a\b\""c.mkv")] + [InlineData(@"/media/trailing\", @"/media/trailing\\")] + [InlineData(@"/media/evil\"" -f lavfi -i sine .mkv", @"/media/evil\\\"" -f lavfi -i sine .mkv")] + public void EscapeProcessArgument_ValidInput_Corrects(string input, string expectedResult) + { + Assert.Equal(expectedResult, input.EscapeProcessArgument()); + } + + [Theory] + [InlineData("/media/movies/Film with spaces.mkv")] + [InlineData(@"C:\media\movies\Film.mkv")] + public void EscapeProcessArgument_NothingToEscape_ReturnsSameInstance(string input) + { + Assert.Same(input, input.EscapeProcessArgument()); + } } } -- cgit v1.2.3 From 9fa0533506d299537b2295183115b8c1f15b2fa0 Mon Sep 17 00:00:00 2001 From: Joel Sprouse Date: Fri, 21 Aug 2026 11:49:32 -0400 Subject: Translated using Weblate (English (United Kingdom)) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/en_GB/ --- Emby.Server.Implementations/Localization/Core/en-GB.json | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json index 298d60d277..5f1759e9d0 100644 --- a/Emby.Server.Implementations/Localization/Core/en-GB.json +++ b/Emby.Server.Implementations/Localization/Core/en-GB.json @@ -108,5 +108,17 @@ "CleanupUserDataTask": "User data cleanup task", "CleanupUserDataTaskDescription": "Cleans all user data (Watch state, favourite status etc) from media that is no longer present for at least 90 days.", "LyricDownloadFailureFromForItem": "Lyrics failed to download from {0} for {1}", - "Original": "Original" + "Original": "Original", + "NameExtraBehindTheScenes": "Behind The Scenes", + "NameExtraClip": "Clip", + "NameExtraDeletedScene": "Deleted Scene", + "NameExtraFeaturette": "Featurette", + "NameExtraInterview": "Interview", + "NameExtraSample": "Sample", + "NameExtraScene": "Scene", + "NameExtraShort": "Short", + "NameExtraThemeSong": "Theme Song", + "NameExtraThemeVideo": "Theme Video", + "NameExtraTrailer": "Trailer", + "NameExtraUnknown": "Extra" } -- cgit v1.2.3 From 4df580f0e899f1ba71ec732f21f9820330400e35 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 21 Aug 2026 20:40:52 +0200 Subject: Fall back to the ancestor filter when a view has no top parents --- .../Library/LibraryManager.cs | 44 +++++++++++++--------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 6a39b2177d..2bba659a23 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -1914,14 +1914,14 @@ namespace Emby.Server.Implementations.Library } // Optimize by querying against top level views - query.TopParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray(); - query.AncestorIds = []; - - // Prevent searching in all libraries due to empty filter - if (query.TopParentIds.Length == 0) + var topParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray(); + if (topParentIds.Length == 0) { - query.TopParentIds = [Guid.NewGuid()]; + return; } + + query.TopParentIds = topParentIds; + query.AncestorIds = []; } public QueryResult<(BaseItem Item, ItemCounts ItemCounts)> GetAlbumArtists(InternalItemsQuery query) @@ -1967,12 +1967,15 @@ namespace Emby.Server.Implementations.Library if (parents.All(i => i is ICollectionFolder || i is UserView)) { // Optimize by querying against top level views - query.TopParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray(); + var topParentIds = parents.SelectMany(i => GetTopParentIdsForQuery(i, query.User)).ToArray(); - // Prevent searching in all libraries due to empty filter - if (query.TopParentIds.Length == 0) + if (topParentIds.Length > 0) { - query.TopParentIds = [Guid.NewGuid()]; + query.TopParentIds = topParentIds; + } + else + { + SetAncestorIds(query, parents); } } else if (parents.Count == 1 && parents.First() is Folder folder @@ -1996,19 +1999,24 @@ namespace Emby.Server.Implementations.Library } else { - // We need to be able to query from any arbitrary ancestor up the tree - query.AncestorIds = parents.SelectMany(i => i.GetIdsForAncestorQuery()).ToArray(); - - // Prevent searching in all libraries due to empty filter - if (query.AncestorIds.Length == 0) - { - query.AncestorIds = [Guid.NewGuid()]; - } + SetAncestorIds(query, parents); } query.Parent = null; } + private static void SetAncestorIds(InternalItemsQuery query, IReadOnlyCollection parents) + { + // We need to be able to query from any arbitrary ancestor up the tree + query.AncestorIds = parents.SelectMany(i => i.GetIdsForAncestorQuery()).ToArray(); + + // Prevent searching in all libraries due to empty filter + if (query.AncestorIds.Length == 0) + { + query.AncestorIds = [Guid.NewGuid()]; + } + } + private void AddUserToQuery(InternalItemsQuery query, User user, bool allowExternalContent = true) { if (query.User is null) -- cgit v1.2.3 From 8e80677bdd6471d04748cfcca41f997f2f48b341 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 21 Aug 2026 22:48:47 +0200 Subject: Fix series merging leaking across libraries and under-counting merged children --- .../Item/ItemCountService.cs | 144 ++++++++++++++++++- ...0260723120000_RecomputeSeriesPresentationKey.cs | 102 ------------- ...0260821120000_RecomputeSeriesPresentationKey.cs | 151 +++++++++++++++++++ MediaBrowser.Controller/Entities/TV/Series.cs | 23 ++- .../DescendantQueryHelper.cs | 25 ++++ .../Item/ItemCountServiceTests.cs | 159 ++++++++++++++++++++- 6 files changed, 489 insertions(+), 115 deletions(-) delete mode 100644 Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs create mode 100644 Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs index a320ba89d1..b276a14536 100644 --- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs @@ -260,19 +260,21 @@ public class ItemCountService : IItemCountService /// public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId) { + ArgumentNullException.ThrowIfNull(filter); ArgumentNullException.ThrowIfNull(filter.User); using var dbContext = _dbProvider.CreateDbContext(); - var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId); + var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId); return baseQuery.Count(b => b.UserData!.Any(u => u.UserId == filter.User.Id && u.Played)); } /// public int GetTotalCount(InternalItemsQuery filter, Guid ancestorId) { + ArgumentNullException.ThrowIfNull(filter); using var dbContext = _dbProvider.CreateDbContext(); - var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId); + var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId); return baseQuery.Count(); } @@ -283,10 +285,23 @@ public class ItemCountService : IItemCountService ArgumentNullException.ThrowIfNull(filter.User); using var dbContext = _dbProvider.CreateDbContext(); - var baseQuery = _queryHelpers.BuildAccessFilteredDescendantsQuery(dbContext, filter, ancestorId); + var baseQuery = BuildGroupedDescendantsQuery(dbContext, filter, ancestorId); return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id); } + private IQueryable BuildGroupedDescendantsQuery(JellyfinDbContext dbContext, InternalItemsQuery filter, Guid ancestorId) + { + var ancestorIds = GetPresentationKeyGroups(dbContext, [ancestorId])[ancestorId]; + var descendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, ancestorIds); + + var baseQuery = dbContext.BaseItems + .AsNoTracking() + .Where(b => descendantIds.Contains(b.Id)) + .Where(DescendantQueryHelper.IsCountableLeaf); + + return _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter); + } + /// public (int Played, int Total) GetPlayedAndTotalCountFromLinkedChildren(InternalItemsQuery filter, Guid parentId) { @@ -330,9 +345,17 @@ public class ItemCountService : IItemCountService .Select(g => new { ParentId = g.Key, Count = g.Count() }) .ToDictionary(x => x.ParentId, x => x.Count); + var mergedChildCounts = GetMergedChildCounts(dbContext, parentIdsArray); + var result = new Dictionary(); foreach (var parentId in parentIds) { + if (mergedChildCounts.TryGetValue(parentId, out var mergedCount)) + { + result[parentId] = mergedCount; + continue; + } + var hierarchicalCount = hierarchicalCounts.GetValueOrDefault(parentId, 0); var linkedCount = linkedCounts.GetValueOrDefault(parentId, 0); @@ -342,6 +365,50 @@ public class ItemCountService : IItemCountService return result; } + private static Dictionary GetMergedChildCounts(JellyfinDbContext dbContext, IReadOnlyList parentIds) + { + var mergedGroups = GetPresentationKeyGroups(dbContext, parentIds) + .Where(group => group.Value.Count > 1) + .ToArray(); + + if (mergedGroups.Length == 0) + { + return []; + } + + // Only merged folders. + var memberIds = mergedGroups.SelectMany(group => group.Value).Distinct().ToArray(); + var children = dbContext.BaseItems + .AsNoTracking() + .Where(b => b.ParentId.HasValue) + .WhereOneOrMany(memberIds, b => b.ParentId!.Value) + .Select(b => new { ParentId = b.ParentId!.Value, b.Id, b.PresentationUniqueKey }) + .ToArray() + .GroupBy(b => b.ParentId) + .ToDictionary( + g => g.Key, + g => g.Select(b => string.IsNullOrEmpty(b.PresentationUniqueKey) + ? b.Id.ToString("N", CultureInfo.InvariantCulture) + : b.PresentationUniqueKey).ToArray()); + + var result = new Dictionary(); + foreach (var (parentId, members) in mergedGroups) + { + var childKeys = new HashSet(StringComparer.Ordinal); + foreach (var member in members) + { + if (children.TryGetValue(member, out var keys)) + { + childKeys.UnionWith(keys); + } + } + + result[parentId] = childKeys.Count; + } + + return result; + } + /// public Dictionary GetPlayedAndTotalCountBatch(IReadOnlyList folderIds, User user) { @@ -354,10 +421,13 @@ public class ItemCountService : IItemCountService } using var dbContext = _dbProvider.CreateDbContext(); - var folderIdsArray = folderIds.ToArray(); var filter = new InternalItemsQuery(user); var userId = user.Id; + // Merged series and seasons are stored as one row per folder-item sharing a presentation key. + var groups = GetPresentationKeyGroups(dbContext, folderIds); + var folderIdsArray = groups.Values.SelectMany(members => members).Distinct().ToArray(); + var leafItems = dbContext.BaseItems .Where(DescendantQueryHelper.IsCountableLeaf); leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter); @@ -399,7 +469,7 @@ public class ItemCountService : IItemCountService b => b.Id, (x, b) => new { FolderId = x.ParentId, b.Id, b.Played }); - var results = ancestorLeaves + var countsByFolder = ancestorLeaves .Union(linkedLeaves) .Union(linkedFolderLeaves) .GroupBy(x => x.FolderId) @@ -411,9 +481,73 @@ public class ItemCountService : IItemCountService }) .ToDictionary(x => x.FolderId, x => (x.Played, x.Total)); + var results = new Dictionary(); + foreach (var (folderId, members) in groups) + { + var played = 0; + var total = 0; + + // Members of a group are distinct folders, so their leaves cannot overlap. + foreach (var member in members) + { + if (countsByFolder.TryGetValue(member, out var counts)) + { + played += counts.Played; + total += counts.Total; + } + } + + if (total > 0 || played > 0) + { + results[folderId] = (played, total); + } + } + return results; } + private static Dictionary> GetPresentationKeyGroups(JellyfinDbContext dbContext, IReadOnlyList folderIds) + { + var requested = dbContext.BaseItems + .AsNoTracking() + .WhereOneOrMany(folderIds, e => e.Id) + .Select(e => new { e.Id, e.PresentationUniqueKey }) + .ToArray(); + + var keys = requested + .Select(e => e.PresentationUniqueKey) + .Where(key => !string.IsNullOrEmpty(key)) + .Distinct(StringComparer.Ordinal) + .ToArray(); + + // Every item that is not merged carries a key derived from its own id, so in the common case + // each group resolves back to the single folder that was asked for. + var membersByKey = keys.Length == 0 + ? [] + : dbContext.BaseItems + .AsNoTracking() + .Where(e => e.IsFolder) + .WhereOneOrMany(keys, e => e.PresentationUniqueKey!) + .Select(e => new { e.Id, Key = e.PresentationUniqueKey! }) + .ToArray() + .GroupBy(e => e.Key, StringComparer.Ordinal) + .ToDictionary(g => g.Key, g => g.Select(e => e.Id).ToList(), StringComparer.Ordinal); + + var keyById = requested.ToDictionary(e => e.Id, e => e.PresentationUniqueKey); + var groups = new Dictionary>(); + foreach (var folderId in folderIds) + { + groups[folderId] = keyById.TryGetValue(folderId, out var key) + && !string.IsNullOrEmpty(key) + && membersByKey.TryGetValue(key, out var members) + && members.Count > 0 + ? members + : [folderId]; + } + + return groups; + } + private static (int Played, int Total) GetPlayedAndTotalCountFromQuery(IQueryable query, Guid userId) { var result = query diff --git a/Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs b/Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs deleted file mode 100644 index 60bb3fd1db..0000000000 --- a/Jellyfin.Server/Migrations/Routines/20260723120000_RecomputeSeriesPresentationKey.cs +++ /dev/null @@ -1,102 +0,0 @@ -using System; -using System.Diagnostics; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Data.Enums; -using Jellyfin.Database.Implementations; -using Jellyfin.Server.ServerSetupApp; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.TV; -using MediaBrowser.Controller.Library; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Logging; - -namespace Jellyfin.Server.Migrations.Routines; - -/// -/// Recomputes the presentation unique key for every series so existing items adopt the folder-set-free key format. -/// -[JellyfinMigration("2026-07-23T12:00:00", nameof(RecomputeSeriesPresentationKey))] -[JellyfinMigrationBackup(JellyfinDb = true)] -internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine -{ - private readonly IStartupLogger _logger; - private readonly ILibraryManager _libraryManager; - private readonly IDbContextFactory _dbProvider; - - /// - /// Initializes a new instance of the class. - /// - /// The startup logger. - /// The library manager. - /// The database context factory. - public RecomputeSeriesPresentationKey( - IStartupLogger logger, - ILibraryManager libraryManager, - IDbContextFactory dbProvider) - { - _logger = logger; - _libraryManager = libraryManager; - _dbProvider = dbProvider; - } - - /// - public async Task PerformAsync(CancellationToken cancellationToken) - { - var series = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = [BaseItemKind.Series] - }).OfType().ToArray(); - - _logger.LogInformation("Recomputing presentation unique key for {Count} series", series.Length); - - const int ProgressInterval = 250; - var sw = Stopwatch.StartNew(); - var processed = 0; - var updated = 0; - - var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); - await using (dbContext.ConfigureAwait(false)) - { - foreach (var item in series) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (++processed % ProgressInterval == 0) - { - _logger.LogInformation("Processed {Processed}/{Total} series - Updated: {Updated} - Time: {Elapsed}", processed, series.Length, updated, sw.Elapsed); - } - - var oldKey = item.PresentationUniqueKey; - var newKey = item.CreatePresentationUniqueKey(); - if (string.Equals(oldKey, newKey, StringComparison.Ordinal)) - { - continue; - } - - // Write only the changed column instead of re-persisting the whole item. - var id = item.Id; - await dbContext.BaseItems - .Where(e => e.Id.Equals(id)) - .ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken) - .ConfigureAwait(false); - - // Seasons and episodes cache the series key in SeriesPresentationUniqueKey and are matched - // to the series by it. Re-point every child still carrying the old key in a single set-based - // update so they stay attached without waiting for the next scan. - if (!string.IsNullOrEmpty(oldKey)) - { - await dbContext.BaseItems - .Where(e => e.SeriesPresentationUniqueKey == oldKey) - .ExecuteUpdateAsync(e => e.SetProperty(f => f.SeriesPresentationUniqueKey, newKey), cancellationToken) - .ConfigureAwait(false); - } - - updated++; - } - } - - _logger.LogInformation("Recomputed presentation unique key for {Updated} of {Count} series in {Elapsed}", updated, series.Length, sw.Elapsed); - } -} diff --git a/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs b/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs new file mode 100644 index 0000000000..0e50ec2f47 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260821120000_RecomputeSeriesPresentationKey.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Library; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// +/// Recomputes the presentation unique key of every series and season so merged series are scoped to their own library. +/// +[JellyfinMigration("2026-08-21T12:00:00", nameof(RecomputeSeriesPresentationKey))] +[JellyfinMigrationBackup(JellyfinDb = true)] +internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine +{ + private readonly IStartupLogger _logger; + private readonly ILibraryManager _libraryManager; + private readonly IDbContextFactory _dbProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The startup logger. + /// The library manager. + /// The database context factory. + public RecomputeSeriesPresentationKey( + IStartupLogger logger, + ILibraryManager libraryManager, + IDbContextFactory dbProvider) + { + _logger = logger; + _libraryManager = libraryManager; + _dbProvider = dbProvider; + } + + /// + public async Task PerformAsync(CancellationToken cancellationToken) + { + var series = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Series] + }).OfType().ToArray(); + + _logger.LogInformation("Recomputing presentation unique key for {Count} series", series.Length); + + const int ProgressInterval = 250; + var sw = Stopwatch.StartNew(); + var newSeriesKeys = new Dictionary(); + var processed = 0; + var updated = 0; + + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + foreach (var item in series) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (++processed % ProgressInterval == 0) + { + _logger.LogInformation("Processed {Processed}/{Total} series - Updated: {Updated} - Time: {Elapsed}", processed, series.Length, updated, sw.Elapsed); + } + + var newKey = item.CreatePresentationUniqueKey(); + newSeriesKeys[item.Id] = newKey; + + if (string.Equals(item.PresentationUniqueKey, newKey, StringComparison.Ordinal)) + { + continue; + } + + // Write only the changed column instead of re-persisting the whole item. + var id = item.Id; + await dbContext.BaseItems + .Where(e => e.Id.Equals(id)) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken) + .ConfigureAwait(false); + + // Seasons and episodes are matched to their series by SeriesPresentationUniqueKey, so + // re-point them here instead of waiting for the next scan. Scoped by SeriesId rather than + // by the old key: that key can be shared by every library holding the series, so matching + // on it would drag the other libraries' children along. + await dbContext.BaseItems + .Where(e => e.SeriesId.HasValue && e.SeriesId.Value.Equals(id)) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.SeriesPresentationUniqueKey, newKey), cancellationToken) + .ConfigureAwait(false); + + updated++; + } + + var updatedSeasons = await RecomputeSeasonsAsync(dbContext, newSeriesKeys, cancellationToken).ConfigureAwait(false); + + _logger.LogInformation( + "Recomputed presentation unique key for {Updated} of {Count} series and {UpdatedSeasons} seasons in {Elapsed}", + updated, + series.Length, + updatedSeasons, + sw.Elapsed); + } + } + + private async Task RecomputeSeasonsAsync(JellyfinDbContext dbContext, Dictionary newSeriesKeys, CancellationToken cancellationToken) + { + // A season's own key embeds its series' key, so it goes stale with it. + var seasons = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.Season] + }).OfType().ToArray(); + + var updated = 0; + + foreach (var season in seasons) + { + cancellationToken.ThrowIfCancellationRequested(); + + // Without an index number the season keeps the base key, which carries no series key at all. + if (!season.IndexNumber.HasValue + || !newSeriesKeys.TryGetValue(season.SeriesId, out var seriesKey)) + { + continue; + } + + // Mirrors Season.CreatePresentationUniqueKey. + var newKey = seriesKey + "-" + season.IndexNumber.Value.ToString("000", CultureInfo.InvariantCulture); + if (string.Equals(season.PresentationUniqueKey, newKey, StringComparison.Ordinal)) + { + continue; + } + + var id = season.Id; + await dbContext.BaseItems + .Where(e => e.Id.Equals(id)) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken) + .ConfigureAwait(false); + + updated++; + } + + return updated; + } +} diff --git a/MediaBrowser.Controller/Entities/TV/Series.cs b/MediaBrowser.Controller/Entities/TV/Series.cs index 3ce241aca8..1a1da84b7a 100644 --- a/MediaBrowser.Controller/Entities/TV/Series.cs +++ b/MediaBrowser.Controller/Entities/TV/Series.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text.Json.Serialization; using System.Threading; @@ -89,15 +90,14 @@ namespace MediaBrowser.Controller.Entities.TV if (!string.IsNullOrEmpty(groupingKey)) { - return AppendPreferredLanguage(groupingKey); + return AddLibrariesToPresentationUniqueKey(groupingKey); } } return base.CreatePresentationUniqueKey(); } - // The owning libraries are deliberately NOT part of the key. - private string AppendPreferredLanguage(string key) + private string AddLibrariesToPresentationUniqueKey(string key) { var lang = GetPreferredMetadataLanguage(); if (!string.IsNullOrEmpty(lang)) @@ -105,7 +105,17 @@ namespace MediaBrowser.Controller.Entities.TV key += "-" + lang; } - return key; + var folders = LibraryManager.GetCollectionFolders(this) + .Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture)) + .Order(StringComparer.Ordinal) + .ToArray(); + + if (folders.Length == 0) + { + return key; + } + + return key + "-" + string.Join('-', folders); } private string GetNameBasedGroupingKey() @@ -125,20 +135,19 @@ namespace MediaBrowser.Controller.Entities.TV { var seriesKey = GetUniqueSeriesKey(this); - var result = LibraryManager.GetCount(new InternalItemsQuery(user) + var result = LibraryManager.GetItemIds(new InternalItemsQuery(user) { AncestorWithPresentationUniqueKey = null, SeriesPresentationUniqueKey = seriesKey, IncludeItemTypes = new[] { BaseItemKind.Season }, IsVirtualItem = false, - Limit = 0, DtoOptions = new DtoOptions(false) { EnableImages = false } }); - return result; + return result.Count; } public override int GetRecursiveChildCount(User user) diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs index bfd0fac34a..909609e35d 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs @@ -39,6 +39,31 @@ public static class DescendantQueryHelper return descendants.AsQueryable(); } + /// + /// Gets all descendant IDs for multiple parent items in a single traversal. + /// Traverses AncestorIds and LinkedChildren, like . + /// + /// Database context. + /// Parent item IDs. + /// Set of all descendant item IDs (excluding the parent IDs themselves). + public static HashSet GetAllDescendantIdsBatch(JellyfinDbContext context, IReadOnlyList parentIds) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(parentIds); + + if (parentIds.Count == 0) + { + return []; + } + + var seedSet = new HashSet(parentIds); + var descendants = TraverseHierarchyDown(context, seedSet); + + descendants.ExceptWith(seedSet); + + return descendants; + } + /// /// Gets a queryable of all owned descendant IDs for a parent item. /// Traverses only AncestorIds (hierarchical ownership), NOT LinkedChildren (associations). diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs index 0766ca8d1e..947cf54d85 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/ItemCountServiceTests.cs @@ -7,6 +7,7 @@ using Jellyfin.Database.Implementations.Locking; using Jellyfin.Database.Providers.Sqlite; using Jellyfin.Server.Implementations.Item; using MediaBrowser.Common.Configuration; +using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; using Microsoft.Data.Sqlite; @@ -14,6 +15,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; +using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; namespace Jellyfin.Server.Implementations.Tests.Item; @@ -43,10 +45,18 @@ public sealed class ItemCountServiceTests : IDisposable var factory = new Mock>(); factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext); + var queryHelpers = new Mock(); + queryHelpers + .Setup(h => h.ApplyAccessFiltering( + It.IsAny(), + It.IsAny>(), + It.IsAny())) + .Returns((JellyfinDbContext _, IQueryable query, InternalItemsQuery _) => query); + _service = new ItemCountService( factory.Object, new Mock().Object, - new Mock().Object); + queryHelpers.Object); } public void Dispose() @@ -106,6 +116,153 @@ public sealed class ItemCountServiceTests : IDisposable Assert.Equal(parentIds.Count, result.Count); } + [Fact] + public void GetCounts_MergedFolders_CountLeavesOfEveryFolderInTheGroup() + { + // Two folder-items of one merged series: same presentation key, a leaf each, one of them played. + var (user, seriesA, seriesB) = SeedMergedSeries(out var playedLeafId); + + var filter = new InternalItemsQuery(user); + + // Either folder-item stands for the whole merged series, so both must report the group. + foreach (var seriesId in new[] { seriesA, seriesB }) + { + Assert.Equal(2, _service.GetTotalCount(filter, seriesId)); + Assert.Equal(1, _service.GetPlayedCount(filter, seriesId)); + Assert.Equal((1, 2), _service.GetPlayedAndTotalCount(filter, seriesId)); + } + + var batch = _service.GetPlayedAndTotalCountBatch([seriesA], user); + Assert.Equal((1, 2), batch[seriesA]); + + Assert.NotEqual(Guid.Empty, playedLeafId); + } + + [Fact] + public void GetCounts_UnmergedFolder_CountsOnlyItsOwnLeaves() + { + var (user, _, _) = SeedMergedSeries(out _); + + // A folder with a key of its own must not pick up anything from the merged pair. + var loneSeriesId = Guid.NewGuid(); + var loneLeafId = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + var lone = CreateItem(loneSeriesId); + lone.PresentationUniqueKey = "lone-series"; + context.BaseItems.Add(lone); + context.BaseItems.Add(CreateLeaf(loneLeafId)); + context.SaveChanges(); + AddAncestor(context, loneLeafId, loneSeriesId); + context.SaveChanges(); + } + + var filter = new InternalItemsQuery(user); + + Assert.Equal(1, _service.GetTotalCount(filter, loneSeriesId)); + Assert.Equal(0, _service.GetPlayedCount(filter, loneSeriesId)); + Assert.Equal((0, 1), _service.GetPlayedAndTotalCount(filter, loneSeriesId)); + } + + [Fact] + public void GetChildCountBatch_MergedFolders_CountsDistinctChildKeys() + { + var seriesA = Guid.NewGuid(); + var seriesB = Guid.NewGuid(); + + using (var context = CreateDbContext()) + { + foreach (var id in new[] { seriesA, seriesB }) + { + var series = CreateItem(id); + series.PresentationUniqueKey = "merged-series"; + context.BaseItems.Add(series); + } + + // Each folder-item holds a "Season 1"; those two share a key and are one season to the user. + var sharedSeasonA = CreateItem(Guid.NewGuid(), seriesA); + sharedSeasonA.PresentationUniqueKey = "merged-series-001"; + var sharedSeasonB = CreateItem(Guid.NewGuid(), seriesB); + sharedSeasonB.PresentationUniqueKey = "merged-series-001"; + var ownSeason = CreateItem(Guid.NewGuid(), seriesB); + ownSeason.PresentationUniqueKey = "merged-series-002"; + + context.BaseItems.AddRange(sharedSeasonA, sharedSeasonB, ownSeason); + context.SaveChanges(); + } + + var result = _service.GetChildCountBatch([seriesA, seriesB], null); + + Assert.Equal(2, result[seriesA]); + Assert.Equal(2, result[seriesB]); + } + + private (User User, Guid SeriesA, Guid SeriesB) SeedMergedSeries(out Guid playedLeafId) + { + var user = new User("count-test", "provider", "reset"); + var seriesA = Guid.NewGuid(); + var seriesB = Guid.NewGuid(); + var leafA = Guid.NewGuid(); + var leafB = Guid.NewGuid(); + playedLeafId = leafA; + + using (var context = CreateDbContext()) + { + context.Users.Add(user); + + foreach (var id in new[] { seriesA, seriesB }) + { + var series = CreateItem(id); + series.PresentationUniqueKey = "merged-series"; + context.BaseItems.Add(series); + } + + context.BaseItems.AddRange(CreateLeaf(leafA), CreateLeaf(leafB)); + context.SaveChanges(); + + AddAncestor(context, leafA, seriesA); + AddAncestor(context, leafB, seriesB); + + context.UserData.Add(new UserData + { + ItemId = leafA, + UserId = user.Id, + CustomDataKey = string.Empty, + Played = true, + Item = null, + User = null + }); + + context.SaveChanges(); + } + + return (user, seriesA, seriesB); + } + + private static void AddAncestor(JellyfinDbContext context, Guid itemId, Guid parentItemId) + { + context.AncestorIds.Add(new AncestorId + { + ItemId = itemId, + ParentItemId = parentItemId, + Item = null!, + ParentItem = null! + }); + } + + private static BaseItemEntity CreateLeaf(Guid id) + { + return new BaseItemEntity + { + Id = id, + Type = "Episode", + IsFolder = false, + IsVirtualItem = false, + PresentationUniqueKey = id.ToString("N") + }; + } + private static BaseItemEntity CreateItem(Guid id, Guid? parentId = null) { return new BaseItemEntity -- cgit v1.2.3 From 335d97d8688957c7813b13e82210c291ae555683 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Fri, 21 Aug 2026 22:59:14 +0200 Subject: Use WhereOneOrMany --- Jellyfin.Server.Implementations/Item/ItemCountService.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs index b276a14536..c42b5f9581 100644 --- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs @@ -292,11 +292,11 @@ public class ItemCountService : IItemCountService private IQueryable BuildGroupedDescendantsQuery(JellyfinDbContext dbContext, InternalItemsQuery filter, Guid ancestorId) { var ancestorIds = GetPresentationKeyGroups(dbContext, [ancestorId])[ancestorId]; - var descendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, ancestorIds); + var descendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, ancestorIds).ToArray(); var baseQuery = dbContext.BaseItems .AsNoTracking() - .Where(b => descendantIds.Contains(b.Id)) + .WhereOneOrMany(descendantIds, b => b.Id) .Where(DescendantQueryHelper.IsCountableLeaf); return _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter); @@ -309,9 +309,9 @@ public class ItemCountService : IItemCountService ArgumentNullException.ThrowIfNull(filter.User); using var dbContext = _dbProvider.CreateDbContext(); - var allDescendantIds = DescendantQueryHelper.GetAllDescendantIds(dbContext, parentId); + var allDescendantIds = DescendantQueryHelper.GetAllDescendantIdsBatch(dbContext, [parentId]).ToArray(); var baseQuery = dbContext.BaseItems - .Where(b => allDescendantIds.Contains(b.Id)) + .WhereOneOrMany(allDescendantIds, b => b.Id) .Where(DescendantQueryHelper.IsCountableLeaf); baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter); -- cgit v1.2.3 From a5880c862269a6919f9f76973b7812cbe26c1c38 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:30:29 +0000 Subject: Update dependency UTF.Unknown to 2.7.0 --- Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index ac9830fc7e..4d5f0efa9e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -82,7 +82,7 @@ - + -- cgit v1.2.3 From 587f06dccc67a6ad67b43cf8889b6b7605950bcf Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 22 Aug 2026 07:42:56 +0200 Subject: Multiple fixes and improvements Co-Authored-By: Cody Robibero --- .../Item/BaseItemRepository.TranslateQuery.cs | 103 +++++---- .../DescendantQueryHelper.cs | 88 ++++---- .../Item/BaseItemRepositoryStreamFilterTests.cs | 239 +++++++++++++++++++++ 3 files changed, 349 insertions(+), 81 deletions(-) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index d2f8e5060c..623c1ea0ab 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -36,8 +36,8 @@ public sealed partial class BaseItemRepository private static Expression> IsFolderFilter => e => e.IsFolder; // "und" is the language filters' stand-in for a track that declares no language at all. - private static bool IsUndetermined(string language) - => string.Equals(language, "und", StringComparison.OrdinalIgnoreCase); + private static string NormalizeLanguage(string language) + => string.Equals(language, "und", StringComparison.OrdinalIgnoreCase) ? "und" : language; // The primary versions whose alternate version satisfies a dimension bound. Anchored on // PrimaryVersionId so the filtered index carries it rather than a scan of every item. @@ -82,40 +82,57 @@ public sealed partial class BaseItemRepository include4K = true; } - // A 4K remux of an SD primary is a version of the same item, so the resolution a caller - // filters on is the best any of the item's versions offers, not just the primary file's. - // The filtered PrimaryVersionId index keeps this to the few items that have versions. - var versionsAtResolution = context.BaseItems - .Where(v => v.PrimaryVersionId != null - && v.Width > 0 - && ((includeSD && v.Width < HDWidth) - || (includeHD && v.Width >= HDWidth && !(v.Width >= UHDWidth || v.Height >= UHDHeight)) - || (include4K && (v.Width >= UHDWidth || v.Height >= UHDHeight)))) - .Select(v => v.PrimaryVersionId!.Value); - - // Non-folders: check own resolution directly (no subquery). - // Folders (Series, BoxSets): EXISTS check on descendants/linked children. - // Using navigation properties (a.Item, lc.Child) produces efficient - // EXISTS + JOIN instead of nested IN (SELECT ...) subqueries. + // A 4K remux of an SD primary is a version of the same item, so the bucket a caller filters + // on is the best any of the item's versions offers, not just the primary file's. Three sets, + // because a bucket is as much about what the version group does not have as what it does, and + // because an unprobed primary can still be placed by a version that does carry dimensions. + // The filtered PrimaryVersionId index keeps all three to the few items that have versions. + var versionsSd = VersionsMatchingDimension(context, v => v.Width > 0 && v.Width < HDWidth); + var versionsHd = VersionsMatchingDimension(context, v => v.Width >= HDWidth); + var versions4K = VersionsMatchingDimension(context, v => v.Width >= UHDWidth || v.Height >= UHDHeight); + + // Only the SD test needs the Width > 0 guard against a row with no dimensions: such a row + // cannot reach the HD or 4K bound anyway, and EF lowers the HD bucket's negated "not itself + // 4K" guard to CASE WHEN ... THEN 0 ELSE 1, which already reads unknown as not 4K rather + // than propagating a null. Folders (Series, BoxSets) answer on their descendants, bucketed + // exactly as a top-level item is so that the two cannot disagree; the navigation properties + // (a.Item, lc.Child) give EXISTS + JOIN rather than nested IN (SELECT ...). baseQuery = baseQuery.Where(e => (!e.IsFolder - && ((e.Width > 0 - && ((includeSD && e.Width < HDWidth) - || (includeHD && e.Width >= HDWidth && !(e.Width >= UHDWidth || e.Height >= UHDHeight)) - || (include4K && (e.Width >= UHDWidth || e.Height >= UHDHeight)))) - || versionsAtResolution.Contains(e.Id))) + && ((includeSD + && ((e.Width > 0 && e.Width < HDWidth) || versionsSd.Contains(e.Id)) + && !versionsHd.Contains(e.Id) + && !versions4K.Contains(e.Id)) + || (includeHD + && (e.Width >= HDWidth || versionsHd.Contains(e.Id)) + && !(e.Width >= UHDWidth || e.Height >= UHDHeight) + && !versions4K.Contains(e.Id)) + || (include4K + && (e.Width >= UHDWidth || e.Height >= UHDHeight || versions4K.Contains(e.Id))))) || (e.IsFolder && (e.Children!.Any(a => - a.Item.Width > 0 - && ((includeSD && a.Item.Width < HDWidth) - || (includeHD && a.Item.Width >= HDWidth && !(a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight)) - || (include4K && (a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight)))) + (includeSD + && ((a.Item.Width > 0 && a.Item.Width < HDWidth) || versionsSd.Contains(a.ItemId)) + && !versionsHd.Contains(a.ItemId) + && !versions4K.Contains(a.ItemId)) + || (includeHD + && (a.Item.Width >= HDWidth || versionsHd.Contains(a.ItemId)) + && !(a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight) + && !versions4K.Contains(a.ItemId)) + || (include4K + && (a.Item.Width >= UHDWidth || a.Item.Height >= UHDHeight || versions4K.Contains(a.ItemId)))) || context.LinkedChildren.Any(lc => lc.ParentId == e.Id - && lc.Child!.Width > 0 - && ((includeSD && lc.Child.Width < HDWidth) - || (includeHD && lc.Child.Width >= HDWidth && !(lc.Child.Width >= UHDWidth || lc.Child.Height >= UHDHeight)) - || (include4K && (lc.Child.Width >= UHDWidth || lc.Child.Height >= UHDHeight))))))); + && ((includeSD + && ((lc.Child!.Width > 0 && lc.Child!.Width < HDWidth) || versionsSd.Contains(lc.ChildId)) + && !versionsHd.Contains(lc.ChildId) + && !versions4K.Contains(lc.ChildId)) + || (includeHD + && (lc.Child!.Width >= HDWidth || versionsHd.Contains(lc.ChildId)) + && !(lc.Child!.Width >= UHDWidth || lc.Child!.Height >= UHDHeight) + && !versions4K.Contains(lc.ChildId)) + || (include4K + && (lc.Child!.Width >= UHDWidth || lc.Child!.Height >= UHDHeight || versions4K.Contains(lc.ChildId)))))))); } // Same reasoning as the resolution filter: a dimension bound is met if any version meets it. @@ -132,17 +149,19 @@ public sealed partial class BaseItemRepository baseQuery = baseQuery.Where(e => e.Height >= minHeight || versionsTallEnough.Contains(e.Id)); } + // An upper bound inverts that: it is met only if no version breaches it, since the item's + // resolution is the best its version group offers. if (maxWidth.HasValue) { - var versionsNarrowEnough = VersionsMatchingDimension(context, v => v.Width <= maxWidth); - baseQuery = baseQuery.Where(e => e.Width <= maxWidth || versionsNarrowEnough.Contains(e.Id)); + var versionsTooWide = VersionsMatchingDimension(context, v => v.Width > maxWidth); + baseQuery = baseQuery.Where(e => e.Width <= maxWidth && !versionsTooWide.Contains(e.Id)); } if (filter.MaxHeight.HasValue) { var maxHeight = filter.MaxHeight; - var versionsShortEnough = VersionsMatchingDimension(context, v => v.Height <= maxHeight); - baseQuery = baseQuery.Where(e => e.Height <= maxHeight || versionsShortEnough.Contains(e.Id)); + var versionsTooTall = VersionsMatchingDimension(context, v => v.Height > maxHeight); + baseQuery = baseQuery.Where(e => e.Height <= maxHeight && !versionsTooTall.Contains(e.Id)); } if (filter.IsLocked.HasValue) @@ -793,8 +812,8 @@ public sealed partial class BaseItemRepository if (!string.IsNullOrWhiteSpace(filter.HasNoAudioTrackWithLanguage)) { - var lang = filter.HasNoAudioTrackWithLanguage; - var undetermined = IsUndetermined(lang); + var lang = NormalizeLanguage(filter.HasNoAudioTrackWithLanguage); + var undetermined = string.Equals(lang, "und", StringComparison.Ordinal); var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Audio, lang); // A track only an alternate version carries still belongs to the item a caller sees, so the // item's own streams alone do not decide this. Same for every stream filter below. @@ -812,8 +831,8 @@ public sealed partial class BaseItemRepository if (!string.IsNullOrWhiteSpace(filter.HasNoInternalSubtitleTrackWithLanguage)) { - var lang = filter.HasNoInternalSubtitleTrackWithLanguage; - var undetermined = IsUndetermined(lang); + var lang = NormalizeLanguage(filter.HasNoInternalSubtitleTrackWithLanguage); + var undetermined = string.Equals(lang, "und", StringComparison.Ordinal); var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: false); var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); @@ -829,8 +848,8 @@ public sealed partial class BaseItemRepository if (!string.IsNullOrWhiteSpace(filter.HasNoExternalSubtitleTrackWithLanguage)) { - var lang = filter.HasNoExternalSubtitleTrackWithLanguage; - var undetermined = IsUndetermined(lang); + var lang = NormalizeLanguage(filter.HasNoExternalSubtitleTrackWithLanguage); + var undetermined = string.Equals(lang, "und", StringComparison.Ordinal); var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang, IsExternal: true); var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); @@ -846,8 +865,8 @@ public sealed partial class BaseItemRepository if (!string.IsNullOrWhiteSpace(filter.HasNoSubtitleTrackWithLanguage)) { - var lang = filter.HasNoSubtitleTrackWithLanguage; - var undetermined = IsUndetermined(lang); + var lang = NormalizeLanguage(filter.HasNoSubtitleTrackWithLanguage); + var undetermined = string.Equals(lang, "und", StringComparison.Ordinal); var criteria = new HasMediaStreamType(MediaStreamTypeEntity.Subtitle, lang); var versionsWithSubtitles = DescendantQueryHelper.GetPrimaryVersionIdsMatching(context, criteria); var foldersWithSubtitles = DescendantQueryHelper.GetFolderIdsMatching(context, criteria); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs index 5a17a46d9e..6b08f8dd7e 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs @@ -102,20 +102,25 @@ public static class DescendantQueryHelper ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(criteria); - var matchingItemIds = GetItemIdsMatching(context, criteria); + // Both sides of a version group can hold a folder a caller would see as matching: the + // alternate carries its own AncestorIds rows and may sit in a different library than the + // primary it is reported against, and the primary is the item that becomes visible. + var reportedItemIds = MatchingMediaOwnerIds(context, criteria) + .Concat(GetPrimaryVersionIdsMatching(context, criteria)) + .Distinct(); // One hop up the closure covers every ancestor level. var hierarchyAncestors = context.AncestorIds - .Where(e => matchingItemIds.Contains(e.ItemId)) + .Where(e => reportedItemIds.Contains(e.ItemId)) .Select(e => e.ParentItemId); - var linkParents = ResolveLinkParents(context, matchingItemIds, hierarchyAncestors); + var linkParents = ResolveLinkParents(context, reportedItemIds, hierarchyAncestors); - // Read back as a sub-select so the result stays composable. LinkedChildren is the cheapest - // source: owning a link is what put an id in the set, and ParentId is its leading key. - var linkedParents = context.LinkedChildren - .WhereOneOrMany(linkParents, e => e.ParentId) - .Select(e => e.ParentId); + // Read back as a sub-select so the result stays composable. Off the primary key, which is one + // row per id: LinkedChildren would yield one row per link and lean on the outer Distinct. + var linkedParents = context.BaseItems + .WhereOneOrMany(linkParents, e => e.Id) + .Select(e => e.Id); var linkedParentAncestors = context.AncestorIds .WhereOneOrMany(linkParents, e => e.ItemId) @@ -134,25 +139,6 @@ public static class DescendantQueryHelper .Distinct(); } - /// - /// Gets a queryable of the IDs of the items whose media matches the criteria. - /// - /// Database context. - /// The matching criteria to apply. - /// Queryable of item IDs. - /// - /// An alternate version is a second file for its primary version and is never listed on its own, so a - /// track only that file carries is reported against the primary: the item a caller can actually see. - /// - public static IQueryable GetItemIdsMatching(JellyfinDbContext context, FolderMatchCriteria criteria) - { - ArgumentNullException.ThrowIfNull(context); - ArgumentNullException.ThrowIfNull(criteria); - - return MatchingMediaOwners(context, criteria) - .Select(e => e.PrimaryVersionId ?? e.Id); - } - /// /// Gets a queryable of the IDs of the primary versions whose alternate version's media matches the /// criteria. @@ -170,23 +156,47 @@ public static class DescendantQueryHelper ArgumentNullException.ThrowIfNull(context); ArgumentNullException.ThrowIfNull(criteria); - return MatchingMediaOwners(context, criteria) - .Where(e => e.PrimaryVersionId.HasValue) - .Select(e => e.PrimaryVersionId!.Value); + // Anchored on the alternates rather than on the matches: "has a primary version" is served by + // the partial PrimaryVersionId index, which holds only the few items that are second files, so + // this costs a seek each into the stream index instead of a second pass over every stream row. + var alternates = context.BaseItems.Where(v => v.PrimaryVersionId.HasValue); + + if (criteria is HasChapterImages) + { + return alternates + .Where(v => context.Chapters.Any(c => c.ItemId.Equals(v.Id) && c.ImagePath != null)) + .Select(v => v.PrimaryVersionId!.Value); + } + + var matchingStreams = MatchingMediaStreams(context, criteria); + + return alternates + .Where(v => matchingStreams.Any(ms => ms.ItemId.Equals(v.Id))) + .Select(v => v.PrimaryVersionId!.Value); } - // The items whose own media matches, as their BaseItems rows so the version group can be read off - // them. One definition of "matches" per criteria, so the projections above cannot drift apart. - private static IQueryable MatchingMediaOwners(JellyfinDbContext context, FolderMatchCriteria criteria) + // The ids of the items whose own media matches. Kept to the stream and chapter tables so their + // covering indexes answer this outright: projecting the BaseItems navigation instead would add a + // primary-key lookup per stream row rather than one per matching item, and the leading key of both + // indexes leaves the ids already grouped, so the Distinct costs no sort. + private static IQueryable MatchingMediaOwnerIds(JellyfinDbContext context, FolderMatchCriteria criteria) + => criteria is HasChapterImages + ? context.Chapters + .Where(c => c.ImagePath != null) + .Select(c => c.ItemId) + .Distinct() + : MatchingMediaStreams(context, criteria) + .Select(ms => ms.ItemId) + .Distinct(); + + // The stream rows a criteria matches. One definition, so the owner projection and the alternate + // projection cannot drift apart despite reading it from opposite ends. + private static IQueryable MatchingMediaStreams(JellyfinDbContext context, FolderMatchCriteria criteria) => criteria switch { HasSubtitles => context.MediaStreamInfos - .Where(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle) - .Select(ms => ms.Item), - HasChapterImages => context.Chapters - .Where(c => c.ImagePath != null) - .Select(c => c.Item), - HasMediaStreamType m => GetMatchingMediaStreams(context, m).Select(ms => ms.Item), + .Where(ms => ms.StreamType == MediaStreamTypeEntity.Subtitle), + HasMediaStreamType m => GetMatchingMediaStreams(context, m), _ => throw new ArgumentOutOfRangeException(nameof(criteria), $"Unknown criteria type: {criteria.GetType().Name}") }; diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs index 12a7fc1aef..4e8d84850b 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryStreamFilterTests.cs @@ -35,6 +35,29 @@ public sealed class BaseItemRepositoryStreamFilterTests : SqliteDbTestFixture private readonly Guid _versionedMovie = Guid.NewGuid(); private readonly Guid _alternateVersion = Guid.NewGuid(); + // A series in the same library, so the folder branch of the resolution filter has a version group + // to reach through as well: an SD episode whose second file is 4K. + private readonly Guid _versionedSeries = Guid.NewGuid(); + private readonly Guid _versionedEpisode = Guid.NewGuid(); + private readonly Guid _episodeAlternate = Guid.NewGuid(); + + // An unprobed primary: only its second file carries dimensions, and they are SD. + private readonly Guid _unprobedMovie = Guid.NewGuid(); + private readonly Guid _unprobedAlternate = Guid.NewGuid(); + + // A plain SD movie with no second file, as the control the version groups are read against. + private readonly Guid _sdMovie = Guid.NewGuid(); + + // An unprobed primary whose only second file is HD, so the HD bucket has to place it off nulls. + private readonly Guid _hdOnlyByVersion = Guid.NewGuid(); + private readonly Guid _hdOnlyAlternate = Guid.NewGuid(); + + // Three files for one movie: the HD one would place it in the HD bucket on its own, the 4K one has + // to win. Only a group holding both can tell the HD bucket's upper guard from its lower one. + private readonly Guid _threeWayMovie = Guid.NewGuid(); + private readonly Guid _threeWayHd = Guid.NewGuid(); + private readonly Guid _threeWay4K = Guid.NewGuid(); + public BaseItemRepositoryStreamFilterTests() { using (var ctx = CreateDbContext()) @@ -179,6 +202,90 @@ public sealed class BaseItemRepositoryStreamFilterTests : SqliteDbTestFixture Assert.DoesNotContain(_withSubtitles, _repository.GetItemIdsList(new InternalItemsQuery { MinWidth = 3000 })); } + [Fact] + public void MaxWidth_ExcludesAnItemWhoseAlternateVersionBreachesTheBound() + { + // The SD primary is narrow enough on its own, but the 4K second file is what a caller would play. + Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxWidth = 1920 })); + Assert.Contains(_sdMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxWidth = 1920 })); + } + + [Fact] + public void MaxHeight_ExcludesAnItemWhoseAlternateVersionBreachesTheBound() + { + Assert.DoesNotContain(_versionedMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxHeight = 1080 })); + Assert.Contains(_sdMovie, _repository.GetItemIdsList(new InternalItemsQuery { MaxHeight = 1080 })); + } + + [Fact] + public void IsHD_False_ExcludesAnSdPrimaryWhoseAlternateVersionIsBetter() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { IsHD = false }); + + // 720x480 on its own, but the version group tops out at 4K. + Assert.DoesNotContain(_versionedMovie, ids); + Assert.Contains(_sdMovie, ids); + } + + [Fact] + public void IsHD_False_MatchesAPrimaryPlacedOnlyByItsAlternateVersion() + { + // The primary carries no dimensions at all; the SD second file is the group's best. + Assert.Contains(_unprobedMovie, _repository.GetItemIdsList(new InternalItemsQuery { IsHD = false })); + } + + [Fact] + public void IsHD_True_ExcludesAnItemWhoseVersionGroupReaches4K() + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { IsHD = true }); + + Assert.DoesNotContain(_versionedMovie, ids); + Assert.DoesNotContain(_unprobedMovie, ids); + // The 1920-wide second file alone would say HD; the 4K third file is the group's best. + Assert.DoesNotContain(_threeWayMovie, ids); + } + + [Fact] + public void Is4K_MatchesAnItemWhoseVersionGroupHoldsBothHdAnd4K() + { + Assert.Contains(_threeWayMovie, _repository.GetItemIdsList(new InternalItemsQuery { Is4K = true })); + } + + [Fact] + public void IsHD_True_MatchesAPrimaryPlacedOnlyByItsAlternateVersion() + { + // The primary carries no dimensions of its own; the HD second file is the group's best. + Assert.Contains(_hdOnlyByVersion, _repository.GetItemIdsList(new InternalItemsQuery { IsHD = true })); + } + + [Fact] + public void Is4K_MatchesTheSeriesOfAnEpisodeWhoseAlternateVersionIs4K() + { + // The folder branch buckets a descendant the same way the item branch buckets a top-level item. + Assert.Contains(_versionedSeries, _repository.GetItemIdsList(new InternalItemsQuery { Is4K = true })); + } + + [Fact] + public void IsHD_False_ExcludesTheSeriesOfAnSdEpisodeWithABetterAlternateVersion() + { + // Before the version group was consulted on descendants too, the SD episode alone matched here + // while the same pair at top level did not. + Assert.DoesNotContain(_versionedSeries, _repository.GetItemIdsList(new InternalItemsQuery { IsHD = false })); + } + + [Theory] + [InlineData("und")] + [InlineData("UND")] + public void HasNoAudioTrackWithLanguage_TreatsUndeterminedCaseInsensitively(string language) + { + var ids = _repository.GetItemIdsList(new InternalItemsQuery { HasNoAudioTrackWithLanguage = language }); + + // The alternate version carries an audio track with no language, which is what "und" stands for, + // so the item it is reported against does have one. + Assert.DoesNotContain(_unprobedMovie, ids); + Assert.Contains(_versionedMovie, ids); + } + private void Seed(JellyfinDbContext context) { context.BaseItems.Add(new BaseItemEntity { Id = _library, Type = FolderType, Name = "Library", IsFolder = true }); @@ -302,6 +409,9 @@ public sealed class BaseItemRepositoryStreamFilterTests : SqliteDbTestFixture Item = null! }); + SeedVersionedSeries(context); + SeedUnprobedVersionGroup(context); + context.Chapters.Add(new Chapter { ItemId = _alternateVersion, @@ -311,4 +421,133 @@ public sealed class BaseItemRepositoryStreamFilterTests : SqliteDbTestFixture Item = null! }); } + + // The same SD primary / 4K second file pair one level down, so the resolution filter has to answer + // for the series off its descendants. + private void SeedVersionedSeries(JellyfinDbContext context) + { + context.BaseItems.Add(new BaseItemEntity { Id = _versionedSeries, Type = FolderType, Name = "Versioned series", IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = _versionedEpisode, Type = MovieType, Name = "Versioned episode", Width = 720, Height = 480 }); + context.BaseItems.Add(new BaseItemEntity + { + Id = _episodeAlternate, + Type = MovieType, + Name = "Versioned episode 4K", + PrimaryVersionId = _versionedEpisode, + Width = 3840, + Height = 2160 + }); + + context.AncestorIds.Add(new AncestorId + { + ItemId = _versionedSeries, + ParentItemId = _versionLibrary, + Item = null!, + ParentItem = null! + }); + + foreach (var itemId in new[] { _versionedEpisode, _episodeAlternate }) + { + context.AncestorIds.Add(new AncestorId + { + ItemId = itemId, + ParentItemId = _versionedSeries, + Item = null!, + ParentItem = null! + }); + } + } + + // A primary that was never probed, so only its second file can place it in a bucket. Its audio track + // declares no language, which is what the "und" filters stand in for. + private void SeedUnprobedVersionGroup(JellyfinDbContext context) + { + context.BaseItems.Add(new BaseItemEntity { Id = _sdMovie, Type = MovieType, Name = "SD movie", Width = 720, Height = 480 }); + context.AncestorIds.Add(new AncestorId + { + ItemId = _sdMovie, + ParentItemId = _versionLibrary, + Item = null!, + ParentItem = null! + }); + + context.BaseItems.Add(new BaseItemEntity { Id = _unprobedMovie, Type = MovieType, Name = "Unprobed movie" }); + context.BaseItems.Add(new BaseItemEntity + { + Id = _unprobedAlternate, + Type = MovieType, + Name = "Unprobed movie SD", + PrimaryVersionId = _unprobedMovie, + Width = 720, + Height = 480 + }); + + foreach (var itemId in new[] { _unprobedMovie, _unprobedAlternate }) + { + context.AncestorIds.Add(new AncestorId + { + ItemId = itemId, + ParentItemId = _versionLibrary, + Item = null!, + ParentItem = null! + }); + } + + context.MediaStreamInfos.Add(new MediaStreamInfo + { + ItemId = _unprobedAlternate, + StreamIndex = 0, + StreamType = MediaStreamTypeEntity.Audio, + Item = null! + }); + + SeedMixedVersionGroups(context); + } + + // The two groups that separate the HD bucket's lower bound from its upper one: one that only a 4K + // third file keeps out of HD, and one that only an HD second file puts into it. + private void SeedMixedVersionGroups(JellyfinDbContext context) + { + context.BaseItems.Add(new BaseItemEntity { Id = _threeWayMovie, Type = MovieType, Name = "Three-way movie", Width = 720, Height = 480 }); + context.BaseItems.Add(new BaseItemEntity + { + Id = _threeWayHd, + Type = MovieType, + Name = "Three-way movie HD", + PrimaryVersionId = _threeWayMovie, + Width = 1920, + Height = 1080 + }); + context.BaseItems.Add(new BaseItemEntity + { + Id = _threeWay4K, + Type = MovieType, + Name = "Three-way movie 4K", + PrimaryVersionId = _threeWayMovie, + Width = 3840, + Height = 2160 + }); + + context.BaseItems.Add(new BaseItemEntity { Id = _hdOnlyByVersion, Type = MovieType, Name = "HD only by version" }); + context.BaseItems.Add(new BaseItemEntity + { + Id = _hdOnlyAlternate, + Type = MovieType, + Name = "HD only by version, HD file", + PrimaryVersionId = _hdOnlyByVersion, + Width = 1920, + Height = 1080 + }); + + foreach (var itemId in new[] { _threeWayMovie, _threeWayHd, _threeWay4K, _hdOnlyByVersion, _hdOnlyAlternate }) + { + context.AncestorIds.Add(new AncestorId + { + ItemId = itemId, + ParentItemId = _versionLibrary, + Item = null!, + ParentItem = null! + }); + } + } } -- cgit v1.2.3 From 8e8cf5702571c1d34ee475792c672727e9fecf82 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 22 Aug 2026 07:43:48 +0200 Subject: Mark breakOnNonKeyFrames as XMLIgnore --- MediaBrowser.Model/Dlna/TranscodingProfile.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/MediaBrowser.Model/Dlna/TranscodingProfile.cs b/MediaBrowser.Model/Dlna/TranscodingProfile.cs index f49b24976a..b5adee173b 100644 --- a/MediaBrowser.Model/Dlna/TranscodingProfile.cs +++ b/MediaBrowser.Model/Dlna/TranscodingProfile.cs @@ -141,6 +141,7 @@ public class TranscodingProfile /// Gets or sets a value indicating whether breaking the video stream on non-keyframes is supported. /// [DefaultValue(false)] + [XmlIgnore] [XmlAttribute("breakOnNonKeyFrames")] [Obsolete("This is always false")] public bool? BreakOnNonKeyFrames { get; set; } -- cgit v1.2.3 From 42c70fba63d72a4113a7b8f2e06293e512cc6914 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 22 Aug 2026 08:50:14 +0200 Subject: Additional fixes Co-Authored-By: Cody Robibero --- Jellyfin.Api/Controllers/ItemLookupController.cs | 3 +- Jellyfin.Api/Controllers/ItemUpdateController.cs | 10 +- .../Entities/ProviderIdsExtensions.cs | 38 +++++--- MediaBrowser.Providers/Manager/MetadataService.cs | 107 +++++++++++++++++---- .../Music/AlbumInfoExtensions.cs | 26 +++-- .../Entities/ProviderIdsExtensionsTests.cs | 60 ++++++++++++ .../Manager/MetadataServiceRefreshTests.cs | 100 ++++++++++++++++++- 7 files changed, 285 insertions(+), 59 deletions(-) diff --git a/Jellyfin.Api/Controllers/ItemLookupController.cs b/Jellyfin.Api/Controllers/ItemLookupController.cs index d009f80a96..39ba5ab186 100644 --- a/Jellyfin.Api/Controllers/ItemLookupController.cs +++ b/Jellyfin.Api/Controllers/ItemLookupController.cs @@ -13,6 +13,7 @@ using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; using Microsoft.AspNetCore.Authorization; @@ -263,7 +264,7 @@ public class ItemLookupController : BaseJellyfinApiController searchResult.ProviderIds); // Since the refresh process won't erase provider Ids, we need to set this explicitly now. - item.ProviderIds = searchResult.ProviderIds; + item.SetProviderIds(searchResult.ProviderIds); await _providerManager.RefreshFullItem( item, new MetadataRefreshOptions(new DirectoryService(_fileSystem)) diff --git a/Jellyfin.Api/Controllers/ItemUpdateController.cs b/Jellyfin.Api/Controllers/ItemUpdateController.cs index 36c82cf461..65fffc4181 100644 --- a/Jellyfin.Api/Controllers/ItemUpdateController.cs +++ b/Jellyfin.Api/Controllers/ItemUpdateController.cs @@ -428,15 +428,7 @@ public class ItemUpdateController : BaseJellyfinApiController if (request.ProviderIds is not null) { - foreach (var pair in request.ProviderIds.ToList()) - { - if (string.IsNullOrEmpty(pair.Value)) - { - request.ProviderIds.Remove(pair.Key); - } - } - - item.ProviderIds = request.ProviderIds; + item.SetProviderIds(request.ProviderIds); } if (item is Video video) diff --git a/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs b/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs index 27d7a4654b..09eba92d9e 100644 --- a/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs +++ b/MediaBrowser.Model/Entities/ProviderIdsExtensions.cs @@ -159,8 +159,15 @@ public static partial class ProviderIdsExtensions // When name contains a '=' it can't be deserialized from the database if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(value) - || name.Contains('=', StringComparison.Ordinal) - || !IsValidProviderId(name, value)) + || name.Contains('=', StringComparison.Ordinal)) + { + return false; + } + + name = name.Trim(); + value = value.Trim(); + + if (!IsValidProviderId(name, value)) { return false; } @@ -197,7 +204,6 @@ public static partial class ProviderIdsExtensions /// The instance. /// The name, this should not contain a '=' character. /// The value. - /// Due to how deserialization from the database works the name cannot contain '='. public static void SetProviderId(this IHasProviderIds instance, string name, string value) { ArgumentNullException.ThrowIfNull(instance); @@ -210,17 +216,27 @@ public static partial class ProviderIdsExtensions throw new ArgumentException("Provider id name cannot contain '='", nameof(name)); } - // Ensure it exists - instance.ProviderIds ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + instance.TrySetProviderId(name, value); + } - // Match on internal MetadataProvider enum string values before adding arbitrary providers - if (_metadataProviderEnumDictionary.TryGetValue(name, out var enumValue)) + /// + /// Replaces all provider ids, dropping the ones that cannot belong to the provider they are filed under. + /// + /// The instance. + /// The provider ids to set. + public static void SetProviderIds(this IHasProviderIds instance, IReadOnlyDictionary? providerIds) + { + ArgumentNullException.ThrowIfNull(instance); + + instance.ProviderIds = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (providerIds is null) { - instance.ProviderIds[enumValue] = value; + return; } - else + + foreach (var (name, value) in providerIds) { - instance.ProviderIds[name] = value; + instance.TrySetProviderId(name, value); } } @@ -259,7 +275,7 @@ public static partial class ProviderIdsExtensions } private static bool IsPositiveNumber(string value) - => long.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var id) && id > 0; + => int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var id) && id > 0; private static bool IsGuid(string value) => Guid.TryParse(value, CultureInfo.InvariantCulture, out _); diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index c6c15198be..d11db8f531 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -260,21 +260,40 @@ namespace MediaBrowser.Providers.Manager switch (lookupInfo) { case EpisodeInfo episodeInfo: - episodeInfo.SeriesProviderIds = result.ProviderIds; + episodeInfo.SeriesProviderIds = GetValidProviderIds(result.ProviderIds); episodeInfo.ProviderIds.Clear(); break; case SeasonInfo seasonInfo: - seasonInfo.SeriesProviderIds = result.ProviderIds; + seasonInfo.SeriesProviderIds = GetValidProviderIds(result.ProviderIds); seasonInfo.ProviderIds.Clear(); break; default: - lookupInfo.ProviderIds = result.ProviderIds; + lookupInfo.SetProviderIds(result.ProviderIds); lookupInfo.Name = result.Name; lookupInfo.Year = result.ProductionYear; break; } } + private static Dictionary GetValidProviderIds(IReadOnlyDictionary providerIds) + { + var validProviderIds = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (providerIds is null) + { + return validProviderIds; + } + + foreach (var (name, value) in providerIds) + { + if (ProviderIdsExtensions.IsValidProviderId(name, value)) + { + validProviderIds[name] = value; + } + } + + return validProviderIds; + } + protected async Task SaveItemAsync(MetadataResult result, ItemUpdateType reason, bool reattachUserData, CancellationToken cancellationToken) { await result.Item.UpdateToRepositoryAsync(reason, cancellationToken).ConfigureAwait(false); @@ -835,6 +854,7 @@ namespace MediaBrowser.Providers.Manager } } + var hasRemoteMetadata = false; var isLocalLocked = temp.Item.IsLocked; if (!isLocalLocked && (options.ReplaceAllMetadata || options.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly)) { @@ -849,6 +869,7 @@ namespace MediaBrowser.Providers.Manager var remoteResult = await ExecuteRemoteProviders(temp, logName, false, id, remoteProviders, cancellationToken).ConfigureAwait(false); + hasRemoteMetadata = remoteResult.UpdateType.HasFlag(ItemUpdateType.MetadataDownload); refreshResult.UpdateType |= remoteResult.UpdateType; refreshResult.ErrorMessage = remoteResult.ErrorMessage; refreshResult.Failures += remoteResult.Failures; @@ -858,10 +879,12 @@ namespace MediaBrowser.Providers.Manager { if (refreshResult.UpdateType > ItemUpdateType.None) { - // A provider that failed contributed nothing, so the result is not the complete - // replacement the caller asked for. Keeping the existing values stops a provider being - // temporarily unreachable, or choking on a bad id, from deleting the data it owns. - if (!options.RemoveOldMetadata || refreshResult.Failures > 0) + // Erasing the old values is only safe when a remote provider returned something to + // replace them with. If every one of them failed there is no replacement, and wiping the + // item would turn a provider being temporarily unreachable into permanent data loss. + // A single failure is not enough: Identify asks for the erasure precisely because the + // previous match was wrong, and an unrelated provider throwing must not undo that. + if (!options.RemoveOldMetadata || (refreshResult.Failures > 0 && !hasRemoteMetadata)) { // Add existing metadata to provider result if it does not exist there MergeData(metadata, temp, [], false, false); @@ -935,7 +958,7 @@ namespace MediaBrowser.Providers.Manager { result.Provider = provider.Name; - LogInvalidProviderIds(result.Item, providerName, logName); + LogInvalidProviderIds(result, providerName, logName); MergeData(result, temp, [], replaceData, false); MergeNewData(temp.Item, id); @@ -969,19 +992,48 @@ namespace MediaBrowser.Providers.Manager /// The ids are dropped when merging, this names the provider that produced them so the source of a /// recurring bad id can be found. /// - private void LogInvalidProviderIds(TItemType item, string providerName, string logName) + private void LogInvalidProviderIds(MetadataResult result, string providerName, string logName) + { + if (!Logger.IsEnabled(LogLevel.Debug)) + { + return; + } + + LogInvalidProviderIds(result.Item?.ProviderIds, providerName, logName, null); + + if (result.People is null) + { + return; + } + + foreach (var person in result.People) + { + LogInvalidProviderIds(person.ProviderIds, providerName, logName, person.Name); + } + } + + private void LogInvalidProviderIds(IReadOnlyDictionary providerIds, string providerName, string logName, string personName) { - if (item?.ProviderIds is null || !Logger.IsEnabled(LogLevel.Debug)) + if (providerIds is null) { return; } - foreach (var (key, value) in item.ProviderIds) + foreach (var (key, value) in providerIds) { - if (!ProviderIdsExtensions.IsValidProviderId(key, value)) + if (ProviderIdsExtensions.IsValidProviderId(key, value)) + { + continue; + } + + if (personName is null) { Logger.LogDebug("Discarding {Key} id '{Value}' returned by {Provider} for {Item}", key, value, providerName, logName); } + else + { + Logger.LogDebug("Discarding {Key} id '{Value}' returned by {Provider} for {Person} of {Item}", key, value, providerName, personName, logName); + } } } @@ -997,8 +1049,13 @@ namespace MediaBrowser.Providers.Manager continue; } - // Don't replace existing Id's. - lookupInfo.ProviderIds.TryAdd(key, providerId.Value); + // Don't replace existing Id's, unless the one already there is unusable - handing that + // one to the providers that have yet to run is what makes them fail. + if (!lookupInfo.ProviderIds.TryGetValue(key, out var existingId) + || !ProviderIdsExtensions.IsValidProviderId(key, existingId)) + { + lookupInfo.ProviderIds[key] = providerId.Value; + } } } @@ -1138,6 +1195,7 @@ namespace MediaBrowser.Providers.Manager if (!lockedFields.Contains(MetadataField.Cast)) { RemoveInvalidProviderIds(sourceResult.People); + RemoveInvalidProviderIds(targetResult.People); if (replaceData || targetResult.People is null || targetResult.People.Count == 0) { @@ -1217,15 +1275,24 @@ namespace MediaBrowser.Providers.Manager continue; } - // Don't replace existing Id's. - if (replaceData) + // Don't replace existing Id's, unless the stored one is unusable - that one is the bad + // match the refresh is meant to repair. + if (replaceData + || !target.ProviderIds.TryGetValue(key, out var existingId) + || !ProviderIdsExtensions.IsValidProviderId(key, existingId)) { target.ProviderIds[key] = id.Value; } - else - { - target.ProviderIds.TryAdd(key, id.Value); - } + } + + // A bad id no provider offered a replacement for still has to go, otherwise the item keeps + // failing the same way on every refresh. + foreach (var key in target.ProviderIds + .Where(id => !ProviderIdsExtensions.IsValidProviderId(id.Key, id.Value)) + .Select(id => id.Key) + .ToArray()) + { + target.ProviderIds.Remove(key); } if (replaceData || !target.CriticRating.HasValue) diff --git a/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs b/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs index d50e2c6c11..2923dd3290 100644 --- a/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs +++ b/MediaBrowser.Providers/Music/AlbumInfoExtensions.cs @@ -1,7 +1,5 @@ #pragma warning disable CS1591 -using System; -using System.Globalization; using System.Linq; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; @@ -25,11 +23,11 @@ namespace MediaBrowser.Providers.Music public static string? GetReleaseGroupId(this AlbumInfo info) { - var id = MusicBrainzId(info.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup)); + var id = MusicBrainzId(MetadataProvider.MusicBrainzReleaseGroup, info.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup)); if (string.IsNullOrEmpty(id)) { - return info.SongInfos.Select(i => MusicBrainzId(i.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup))) + return info.SongInfos.Select(i => MusicBrainzId(MetadataProvider.MusicBrainzReleaseGroup, i.GetProviderId(MetadataProvider.MusicBrainzReleaseGroup))) .FirstOrDefault(i => !string.IsNullOrEmpty(i)); } @@ -38,11 +36,11 @@ namespace MediaBrowser.Providers.Music public static string? GetReleaseId(this AlbumInfo info) { - var id = MusicBrainzId(info.GetProviderId(MetadataProvider.MusicBrainzAlbum)); + var id = MusicBrainzId(MetadataProvider.MusicBrainzAlbum, info.GetProviderId(MetadataProvider.MusicBrainzAlbum)); if (string.IsNullOrEmpty(id)) { - return info.SongInfos.Select(i => MusicBrainzId(i.GetProviderId(MetadataProvider.MusicBrainzAlbum))) + return info.SongInfos.Select(i => MusicBrainzId(MetadataProvider.MusicBrainzAlbum, i.GetProviderId(MetadataProvider.MusicBrainzAlbum))) .FirstOrDefault(i => !string.IsNullOrEmpty(i)); } @@ -52,17 +50,17 @@ namespace MediaBrowser.Providers.Music public static string? GetMusicBrainzArtistId(this AlbumInfo info) { info.ProviderIds.TryGetValue(MetadataProvider.MusicBrainzAlbumArtist.ToString(), out string? id); - id = MusicBrainzId(id); + id = MusicBrainzId(MetadataProvider.MusicBrainzAlbumArtist, id); if (string.IsNullOrEmpty(id)) { info.ArtistProviderIds.TryGetValue(MetadataProvider.MusicBrainzArtist.ToString(), out id); - id = MusicBrainzId(id); + id = MusicBrainzId(MetadataProvider.MusicBrainzArtist, id); } if (string.IsNullOrEmpty(id)) { - return info.SongInfos.Select(i => MusicBrainzId(i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist))) + return info.SongInfos.Select(i => MusicBrainzId(MetadataProvider.MusicBrainzAlbumArtist, i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist))) .FirstOrDefault(i => !string.IsNullOrEmpty(i)); } @@ -72,11 +70,11 @@ namespace MediaBrowser.Providers.Music public static string? GetMusicBrainzArtistId(this ArtistInfo info) { info.ProviderIds.TryGetValue(MetadataProvider.MusicBrainzArtist.ToString(), out var id); - id = MusicBrainzId(id); + id = MusicBrainzId(MetadataProvider.MusicBrainzArtist, id); if (string.IsNullOrEmpty(id)) { - return info.SongInfos.Select(i => MusicBrainzId(i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist))) + return info.SongInfos.Select(i => MusicBrainzId(MetadataProvider.MusicBrainzAlbumArtist, i.GetProviderId(MetadataProvider.MusicBrainzAlbumArtist))) .FirstOrDefault(i => !string.IsNullOrEmpty(i)); } @@ -84,9 +82,9 @@ namespace MediaBrowser.Providers.Music } /// - /// Returns the id if it can be a MusicBrainz id, otherwise null. + /// Returns the id if it can be an id of the given provider, otherwise null. /// - private static string? MusicBrainzId(string? id) - => Guid.TryParse(id, CultureInfo.InvariantCulture, out _) ? id : null; + private static string? MusicBrainzId(MetadataProvider provider, string? id) + => ProviderIdsExtensions.IsValidProviderId(provider.ToString(), id) ? id : null; } } diff --git a/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs b/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs index 0fae58fe67..2347c08961 100644 --- a/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs +++ b/tests/Jellyfin.Model.Tests/Entities/ProviderIdsExtensionsTests.cs @@ -229,6 +229,66 @@ namespace Jellyfin.Model.Tests.Entities Assert.Equal("11", provider.GetProviderId(MetadataProvider.Tmdb)); } + [Theory] + [InlineData(nameof(MetadataProvider.Imdb), " tt0113375 ")] + [InlineData(" Imdb", ExampleImdbId)] + public void TrySetProviderId_SurroundingWhitespace_Trimmed(string name, string value) + { + var provider = new ProviderIdsExtensionsTestsObject(); + + Assert.True(provider.TrySetProviderId(name, value)); + Assert.Equal(ExampleImdbId, provider.GetProviderId(MetadataProvider.Imdb)); + } + + [Fact] + public void SetProviderIds_ReplacesAll() + { + var provider = new ProviderIdsExtensionsTestsObject(); + provider.ProviderIds[MetadataProvider.Tvdb.ToString()] = "12345"; + + provider.SetProviderIds(new Dictionary + { + [MetadataProvider.Imdb.ToString()] = ExampleImdbId + }); + + Assert.Equal(ExampleImdbId, provider.GetProviderId(MetadataProvider.Imdb)); + Assert.False(provider.HasProviderId(MetadataProvider.Tvdb)); + } + + [Fact] + public void SetProviderIds_ForeignId_Dropped() + { + var provider = new ProviderIdsExtensionsTestsObject(); + + provider.SetProviderIds(new Dictionary + { + [MetadataProvider.Tmdb.ToString()] = "nm0000123", + [MetadataProvider.Imdb.ToString()] = ExampleImdbId, + [MetadataProvider.Tvdb.ToString()] = string.Empty + }); + + Assert.False(provider.HasProviderId(MetadataProvider.Tmdb)); + Assert.False(provider.HasProviderId(MetadataProvider.Tvdb)); + Assert.Equal(ExampleImdbId, provider.GetProviderId(MetadataProvider.Imdb)); + } + + [Fact] + public void SetProviderIds_Null_Clears() + { + var provider = new ProviderIdsExtensionsTestsObject(); + provider.ProviderIds[MetadataProvider.Imdb.ToString()] = ExampleImdbId; + + provider.SetProviderIds(null); + + Assert.Empty(provider.ProviderIds); + } + + [Fact] + public void SetProviderIds_NullInstance_ThrowsArgumentNullException() + { + Assert.Throws(() => ProviderIdsExtensions.SetProviderIds(null!, new Dictionary())); + } + [Fact] public void RemoveProviderId_Null_Remove() { diff --git a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs index cbc8a65577..1d2fb2e760 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; @@ -22,9 +23,13 @@ namespace Jellyfin.Providers.Tests.Manager public class MetadataServiceRefreshTests { [Theory] - [InlineData(false, "existing overview")] - [InlineData(true, null)] - public async Task RefreshWithProviders_ReplaceAllMetadata_KeepsExistingDataOnProviderFailure(bool allProvidersSucceed, string? expectedOverview) + // RemoveOldMetadata is only ever set by an explicit user action - a refresh with "replace all + // metadata", or Identify. A provider failing must not silently downgrade that to a merge: the + // providers that did answer supplied the replacement, and the old values are the wrong match + // the user asked to get rid of. + [InlineData(false)] + [InlineData(true)] + public async Task RefreshWithProviders_ReplaceAllMetadata_ErasesOldDataWhenAProviderAnswers(bool allProvidersSucceed) { var item = new Movie { @@ -63,7 +68,51 @@ namespace Jellyfin.Providers.Tests.Manager Assert.Equal(allProvidersSucceed ? 0 : 1, result.Failures); Assert.Equal("new tagline", item.Tagline); - Assert.Equal(expectedOverview, item.Overview); + Assert.Null(item.Overview); + } + + [Fact] + public async Task RefreshWithProviders_ReplaceAllMetadata_KeepsExistingDataWhenEveryRemoteProviderFails() + { + var item = new Movie + { + Name = "Test Movie", + Overview = "existing overview" + }; + + // Something has to contribute for the merge to run at all, otherwise the item is never touched + // and the case is moot. The local provider is the replacement the remote ones did not deliver. + var local = new Mock>(MockBehavior.Loose); + local.Setup(p => p.Name).Returns("Local"); + local.Setup(p => p.GetMetadata(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new MetadataResult + { + HasMetadata = true, + Item = new Movie { Name = "Test Movie", Tagline = "new tagline" } + }); + + var remote = new Mock>(MockBehavior.Loose); + remote.Setup(p => p.Name).Returns("Failing"); + remote.Setup(p => p.GetMetadata(It.IsAny(), It.IsAny())) + .Returns(Task.FromException>(new HttpRequestException("unreachable"))); + + var service = new TestMetadataService(); + var result = await service.RefreshWithProvidersInternal( + new MetadataResult { Item = item }, + new MovieInfo { Name = item.Name }, + new MetadataRefreshOptions(Mock.Of()) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true, + RemoveOldMetadata = true + }, + [local.Object, remote.Object]).ConfigureAwait(true); + + Assert.Equal(1, result.Failures); + Assert.Equal("new tagline", item.Tagline); + + // No remote provider answered, so erasing the overview would lose it for good. + Assert.Equal("existing overview", item.Overview); } [Fact] @@ -97,6 +146,49 @@ namespace Jellyfin.Providers.Tests.Manager Assert.Equal("tt0113375", item.GetProviderId(MetadataProvider.Imdb)); } + [Fact] + public async Task RefreshWithProviders_ForeignProviderId_ReplacedInLookupInfo() + { + var item = new Movie { Name = "Test Movie" }; + var lookupInfo = new MovieInfo { Name = item.Name }; + lookupInfo.ProviderIds[MetadataProvider.Tmdb.ToString()] = "nm0000123"; + + var answering = new Mock>(MockBehavior.Loose); + answering.Setup(p => p.Name).Returns("Answering"); + answering.Setup(p => p.GetMetadata(It.IsAny(), It.IsAny())) + .ReturnsAsync(() => + { + var found = new Movie { Name = "Test Movie" }; + found.ProviderIds[MetadataProvider.Tmdb.ToString()] = "12345"; + return new MetadataResult { HasMetadata = true, Item = found }; + }); + + string? tmdbIdSeenBySecondProvider = null; + var following = new Mock>(MockBehavior.Loose); + following.Setup(p => p.Name).Returns("Following"); + following.Setup(p => p.GetMetadata(It.IsAny(), It.IsAny())) + .ReturnsAsync((MovieInfo info, CancellationToken _) => + { + tmdbIdSeenBySecondProvider = info.GetProviderId(MetadataProvider.Tmdb); + return new MetadataResult { HasMetadata = false }; + }); + + var service = new TestMetadataService(); + await service.RefreshWithProvidersInternal( + new MetadataResult { Item = item }, + lookupInfo, + new MetadataRefreshOptions(Mock.Of()) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ReplaceAllMetadata = true + }, + [answering.Object, following.Object]).ConfigureAwait(true); + + // The stored id cannot be a TMDb one, so the provider that still has to run must get the id + // that was just found instead of failing on the same bad one. + Assert.Equal("12345", tmdbIdSeenBySecondProvider); + } + [Theory] [InlineData(true)] [InlineData(false)] -- cgit v1.2.3 From 20b4a5928123f5eaa9f663625efdbb3c4f713252 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 22 Aug 2026 11:22:31 +0200 Subject: Look up people by item via the credit map instead of a full scan --- Jellyfin.Server.Implementations/Item/PeopleRepository.cs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index a592d0e6e2..aaa363b046 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -351,7 +351,11 @@ public class PeopleRepository(IDbContextFactory dbProvider, I if (!filter.ItemId.IsEmpty()) { - query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.ItemId))); + var itemId = filter.ItemId; + query = query.Where(e => context.PeopleBaseItemMap + .Where(m => m.ItemId.Equals(itemId)) + .Select(m => m.PeopleId) + .Contains(e.Id)); } if (filter.ParentId != null) @@ -361,7 +365,11 @@ public class PeopleRepository(IDbContextFactory dbProvider, I if (!filter.AppearsInItemId.IsEmpty()) { - query = query.Where(e => e.BaseItems!.Any(w => w.ItemId.Equals(filter.AppearsInItemId))); + var appearsInItemId = filter.AppearsInItemId; + query = query.Where(e => context.PeopleBaseItemMap + .Where(m => m.ItemId.Equals(appearsInItemId)) + .Select(m => m.PeopleId) + .Contains(e.Id)); } var queryPersonTypes = filter.PersonTypes.Where(IsValidPersonType).ToList(); -- cgit v1.2.3 From ea2c794cb2520ccb2d322da8c186875b7cebe006 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sat, 22 Aug 2026 17:22:41 +0200 Subject: Adapt to master --- .../DescendantQueryHelper.cs | 43 ++++++++++++---------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs index ac47f00a18..b821476390 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/DescendantQueryHelper.cs @@ -32,23 +32,15 @@ public static class DescendantQueryHelper { ArgumentNullException.ThrowIfNull(context); - var (closureRoots, linkRoots) = ResolveLinkedRoots(context, parentId); - - var hierarchyDescendants = ClosureDescendants(context, closureRoots); - - var linkedDescendants = context.LinkedChildren - .WhereOneOrMany(linkRoots, e => e.ParentId) - .Select(e => e.ChildId); - - return hierarchyDescendants - .Concat(linkedDescendants) + return AllDescendants(context, [parentId]) .Where(e => !e.Equals(parentId)) .Distinct(); } /// /// Gets all descendant IDs for multiple parent items in a single traversal. - /// Traverses AncestorIds and LinkedChildren, like . + /// Traverses AncestorIds and LinkedChildren, like , but resolves + /// the roots once for all seeds instead of once per seed. /// /// Database context. /// Parent item IDs. @@ -63,10 +55,11 @@ public static class DescendantQueryHelper return []; } - var seedSet = new HashSet(parentIds); - var descendants = TraverseHierarchyDown(context, seedSet); + var descendants = AllDescendants(context, parentIds) + .Distinct() + .ToHashSet(); - descendants.ExceptWith(seedSet); + descendants.ExceptWith(parentIds); return descendants; } @@ -241,6 +234,18 @@ public static class DescendantQueryHelper return query; } + private static IQueryable AllDescendants(JellyfinDbContext context, IReadOnlyList parentIds) + { + var (closureRoots, linkRoots) = ResolveLinkedRoots(context, parentIds); + + var linkedDescendants = context.LinkedChildren + .WhereOneOrMany(linkRoots, e => e.ParentId) + .Select(e => e.ChildId); + + return ClosureDescendants(context, closureRoots) + .Concat(linkedDescendants); + } + private static IQueryable ClosureDescendants(JellyfinDbContext context, IReadOnlyList roots) { var direct = context.AncestorIds @@ -311,12 +316,12 @@ public static class DescendantQueryHelper // Resolves the roots the descendant sub-selects are anchored on: those contributing their closure, // and those contributing their linked children. - private static (List ClosureRoots, List LinkRoots) ResolveLinkedRoots(JellyfinDbContext context, Guid parentId) + private static (List ClosureRoots, List LinkRoots) ResolveLinkedRoots(JellyfinDbContext context, IReadOnlyList parentIds) { - var closureRoots = new List { parentId }; - var linkRoots = new List { parentId }; - var visited = new HashSet { parentId }; - var frontier = new List { parentId }; + var visited = new HashSet(parentIds); + var closureRoots = visited.ToList(); + var linkRoots = visited.ToList(); + var frontier = visited.ToList(); while (frontier.Count != 0) { -- cgit v1.2.3 From 0b7b53a9fd0ef95eb1d7591deb76caf6fb9e21cd Mon Sep 17 00:00:00 2001 From: krvi <27792771+krvi@users.noreply.github.com> Date: Sat, 22 Aug 2026 11:58:44 -0400 Subject: Translated using Weblate (Faroese) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/ --- Emby.Server.Implementations/Localization/Core/fo.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json index d1e9065d97..127b7ff5fd 100644 --- a/Emby.Server.Implementations/Localization/Core/fo.json +++ b/Emby.Server.Implementations/Localization/Core/fo.json @@ -111,5 +111,7 @@ "NameExtraNumbered": "{0} {1}", "NameExtraFeaturette": "Stuttur heimildarfilmur", "TaskAudioNormalization": "Ljóðjavnan", - "TaskAudioNormalizationDescription": "Kannar fílur fyri dátum til ljóðjavnan." + "TaskAudioNormalizationDescription": "Kannar fílur fyri dátum til ljóðjavnan.", + "NameExtraSample": "Kut", + "TaskRefreshTrickplayImages": "Framleið Trickplay-myndir" } -- cgit v1.2.3 From c3ed1407ca698b0905de99da87b67415e6a62dbd Mon Sep 17 00:00:00 2001 From: krvi <27792771+krvi@users.noreply.github.com> Date: Sat, 22 Aug 2026 13:06:41 -0400 Subject: Translated using Weblate (Faroese) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/ --- Emby.Server.Implementations/Localization/Core/fo.json | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json index 127b7ff5fd..377ad8d69e 100644 --- a/Emby.Server.Implementations/Localization/Core/fo.json +++ b/Emby.Server.Implementations/Localization/Core/fo.json @@ -113,5 +113,13 @@ "TaskAudioNormalization": "Ljóðjavnan", "TaskAudioNormalizationDescription": "Kannar fílur fyri dátum til ljóðjavnan.", "NameExtraSample": "Kut", - "TaskRefreshTrickplayImages": "Framleið Trickplay-myndir" + "TaskRefreshTrickplayImages": "Framleið Trickplay-myndir", + "TaskRefreshTrickplayImagesDescription": "Framleiðir trickplay-myndir fyri kykmyndir í søvnunm har tað er virkt.", + "TaskMoveTrickplayImages": "Flyt Trickplay-myndagoymslustað", + "TaskMoveTrickplayImagesDescription": "Flytur verandi trickplay-fílur sambært savnsstillingunum.", + "NameExtraThemeVideo": "Eyðkenniskykmynd", + "NameExtraDeletedScene": "Úrtikin mynd (scena)", + "NameExtraScene": "Mynd (scena)", + "NameExtraUnknown": "Eykatilfar", + "Original": "Upprunalig(t/ur)" } -- cgit v1.2.3 From 19235909fefe8d114a4e9be5284a176e83c12ba4 Mon Sep 17 00:00:00 2001 From: Koralski Date: Sun, 23 Aug 2026 03:21:02 -0400 Subject: Translated using Weblate (Bulgarian) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/bg/ --- .../Localization/Core/bg-BG.json | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/bg-BG.json b/Emby.Server.Implementations/Localization/Core/bg-BG.json index 0710a39708..3d49675c63 100644 --- a/Emby.Server.Implementations/Localization/Core/bg-BG.json +++ b/Emby.Server.Implementations/Localization/Core/bg-BG.json @@ -106,5 +106,20 @@ "TaskMoveTrickplayImagesDescription": "Премества съществуващите trickplay изображения спрямо настройките на библиотеката.", "TaskExtractMediaSegments": "Сканиране за сегменти", "CleanupUserDataTask": "Задача за почистване на потребителски данни", - "CleanupUserDataTaskDescription": "Почиства всички потребителски данни (статус на гледане, любими и т.н.) от медия, която вече не е налична от поне 90 дни." + "CleanupUserDataTaskDescription": "Почиства всички потребителски данни (статус на гледане, любими и т.н.) от медия, която вече не е налична от поне 90 дни.", + "LyricDownloadFailureFromForItem": "Текстът на песента не успя да се изтегли от {0} за {1}", + "NameExtraBehindTheScenes": "Зад кулисите", + "NameExtraScene": "Сцена", + "NameExtraShort": "Откъс", + "NameExtraThemeVideo": "Тематично видео", + "NameExtraTrailer": "Трейлър", + "NameExtraUnknown": "Екстра", + "NameExtraClip": "Клип", + "NameExtraDeletedScene": "Изтрита Сцена", + "NameExtraFeaturette": "Кратък филм", + "NameExtraInterview": "Интервю", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "Пример", + "NameExtraThemeSong": "Тема-песен", + "Original": "Оригинал" } -- cgit v1.2.3 From 090b610eb131eb416ce3e4527dc9b568bf130c14 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 23 Aug 2026 09:27:26 +0200 Subject: Fix person metadata not being fetched on demand or by the people task --- Jellyfin.Api/Controllers/UserLibraryController.cs | 37 +++++++++++++++-------- MediaBrowser.Providers/Manager/MetadataService.cs | 5 ++- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs index ea134a4619..da03032249 100644 --- a/Jellyfin.Api/Controllers/UserLibraryController.cs +++ b/Jellyfin.Api/Controllers/UserLibraryController.cs @@ -34,6 +34,8 @@ namespace Jellyfin.Api.Controllers; [Tags("Library")] public class UserLibraryController : BaseJellyfinApiController { + private static readonly TimeSpan RefreshOnDemandTimeout = TimeSpan.FromSeconds(3); + private readonly IUserManager _userManager; private readonly IUserDataManager _userDataRepository; private readonly ILibraryManager _libraryManager; @@ -79,7 +81,7 @@ public class UserLibraryController : BaseJellyfinApiController /// An containing the item. [HttpGet("Items/{itemId}")] [ProducesResponseType(StatusCodes.Status200OK)] - public ActionResult GetItem( + public async Task> GetItem( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { @@ -98,7 +100,7 @@ public class UserLibraryController : BaseJellyfinApiController return NotFound(); } - QueueRefreshOnDemandIfNeeded(item); + await RefreshOnDemandIfNeeded(item).ConfigureAwait(false); var dtoOptions = new DtoOptions(); @@ -116,7 +118,7 @@ public class UserLibraryController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status200OK)] [Obsolete("Kept for backwards compatibility")] [ApiExplorerSettings(IgnoreApi = true)] - public ActionResult GetItemLegacy( + public Task> GetItemLegacy( [FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) => GetItem(userId, itemId); @@ -643,7 +645,7 @@ public class UserLibraryController : BaseJellyfinApiController limit, groupItems); - private void QueueRefreshOnDemandIfNeeded(BaseItem item) + private async Task RefreshOnDemandIfNeeded(BaseItem item) { if (item is not Person) { @@ -656,15 +658,24 @@ public class UserLibraryController : BaseJellyfinApiController return; } - _providerManager.QueueRefresh( - item.Id, - new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - MetadataRefreshMode = MetadataRefreshMode.FullRefresh, - ImageRefreshMode = MetadataRefreshMode.FullRefresh, - ForceSave = true - }, - RefreshPriority.High); + var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ForceSave = true + }; + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(HttpContext.RequestAborted); + timeout.CancelAfter(RefreshOnDemandTimeout); + + try + { + await item.RefreshMetadata(options, timeout.Token).ConfigureAwait(false); + } + catch (OperationCanceledException) when (!HttpContext.RequestAborted.IsCancellationRequested) + { + _providerManager.QueueRefresh(item.Id, options, RefreshPriority.High); + } } /// diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index d11db8f531..1b43bc23fa 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -209,7 +209,10 @@ namespace MediaBrowser.Providers.Manager } } - if (hasRefreshedMetadata && hasRefreshedImages) + var attemptedFetch = refreshOptions.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly + || refreshOptions.ImageRefreshMode > MetadataRefreshMode.ValidationOnly; + + if (hasRefreshedMetadata && hasRefreshedImages && attemptedFetch) { item.DateLastRefreshed = DateTime.UtcNow; updateType |= item.OnMetadataChanged(); -- cgit v1.2.3 From 7758beba995f50b33ce5542d8027d281b1bcebeb Mon Sep 17 00:00:00 2001 From: DeaDvey Date: Sun, 23 Aug 2026 04:50:02 -0400 Subject: Translated using Weblate (English (United Kingdom)) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/en_GB/ --- Emby.Server.Implementations/Localization/Core/en-GB.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json index 5f1759e9d0..1f69fc1f55 100644 --- a/Emby.Server.Implementations/Localization/Core/en-GB.json +++ b/Emby.Server.Implementations/Localization/Core/en-GB.json @@ -24,8 +24,8 @@ "Music": "Music", "MusicVideos": "Music Videos", "NameInstallFailed": "{0} installation failed", - "NameSeasonNumber": "Season {0}", - "NameSeasonUnknown": "Season Unknown", + "NameSeasonNumber": "Series {0}", + "NameSeasonUnknown": "Series Unknown", "NewVersionIsAvailable": "A new version of Jellyfin Server is available for download.", "NotificationOptionApplicationUpdateAvailable": "Application update available", "NotificationOptionApplicationUpdateInstalled": "Application update installed", -- cgit v1.2.3 From e8927bc300fabb2204a86cb34d514bc7afd012f3 Mon Sep 17 00:00:00 2001 From: chiphead2332 Date: Sun, 23 Aug 2026 06:45:52 -0400 Subject: Translated using Weblate (Portuguese (Brazil)) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/pt_BR/ --- Emby.Server.Implementations/Localization/Core/pt-BR.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/pt-BR.json b/Emby.Server.Implementations/Localization/Core/pt-BR.json index 031c6e17c4..997d534fea 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-BR.json +++ b/Emby.Server.Implementations/Localization/Core/pt-BR.json @@ -120,5 +120,6 @@ "NameExtraThemeVideo": "Vídeo de Abertura", "NameExtraTrailer": "Trailer", "NameExtraUnknown": "Extra", - "NameExtraFeaturette": "Nos Bastidores" + "NameExtraFeaturette": "Nos Bastidores", + "NameExtraInterview": "Entrevista" } -- cgit v1.2.3 From 484291b0c1bef6d7f45cd24ab423203ed3c8cd12 Mon Sep 17 00:00:00 2001 From: Karan Singh BHardwaj Date: Sun, 23 Aug 2026 06:16:04 -0400 Subject: Translated using Weblate (Hindi) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/hi/ --- Emby.Server.Implementations/Localization/Core/hi.json | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/hi.json b/Emby.Server.Implementations/Localization/Core/hi.json index 5fbf61c627..f4b1f86d1d 100644 --- a/Emby.Server.Implementations/Localization/Core/hi.json +++ b/Emby.Server.Implementations/Localization/Core/hi.json @@ -106,5 +106,20 @@ "TaskMoveTrickplayImages": "ट्रिकप्ले छवि स्थान माइग्रेट करें", "TaskMoveTrickplayImagesDescription": "लाइब्रेरी सेटिंग्स के अनुसार मौजूदा ट्रिकप्ले फ़ाइलों को स्थानांतरित करता है।", "CleanupUserDataTask": "यूज़र डेटा सफाई कार्य", - "Original": "असली" + "Original": "असली", + "LyricDownloadFailureFromForItem": "{0} के लिए {1} से बोल (Lyrics) डाउनलोड करने में विफल रहा", + "NameExtraBehindTheScenes": "परदे के पीछे", + "NameExtraClip": "क्लिप", + "NameExtraDeletedScene": "हटाया गया दृश्य", + "NameExtraFeaturette": "फीचरेट", + "NameExtraInterview": "साक्षात्कार", + "NameExtraNumbered": "{0} {1}", + "NameExtraSample": "नमूना", + "NameExtraScene": "दृश्य", + "NameExtraShort": "शॉर्ट", + "NameExtraThemeSong": "थीम सॉन्ग", + "NameExtraThemeVideo": "थीम वीडियो", + "NameExtraTrailer": "ट्रेलर", + "NameExtraUnknown": "अतिरिक्त", + "CleanupUserDataTaskDescription": "कम से कम 90 दिनों से अनुपस्थित मीडिया से सभी उपयोगकर्ता डेटा (देखने की स्थिति, पसंदीदा स्थिति आदि) को साफ़ करता है।" } -- cgit v1.2.3 From 4cd24a19d40caded21ca9f599ecbc36da54d26fa Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Sun, 23 Aug 2026 14:35:13 +0200 Subject: Use FullRefresh --- .../ScheduledTasks/Tasks/PeopleValidationTask.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs index dff9a473af..bd73f63aa7 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs @@ -243,8 +243,8 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) { - ImageRefreshMode = hasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default, - MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.Default + ImageRefreshMode = hasImage ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.FullRefresh, + MetadataRefreshMode = hasOverview ? MetadataRefreshMode.ValidationOnly : MetadataRefreshMode.FullRefresh }; await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false); -- cgit v1.2.3 From 971be1b658ad0e5e1f06fdc652f9c5e3fc067c9b Mon Sep 17 00:00:00 2001 From: Vitalijus Date: Sun, 23 Aug 2026 16:49:40 -0400 Subject: Translated using Weblate (Lithuanian) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/lt/ --- Emby.Server.Implementations/Localization/Core/lt-LT.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/lt-LT.json b/Emby.Server.Implementations/Localization/Core/lt-LT.json index b0fb6c52ba..dbfeabd88e 100644 --- a/Emby.Server.Implementations/Localization/Core/lt-LT.json +++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json @@ -87,7 +87,7 @@ "TaskCleanActivityLog": "Išvalyti veiklos žurnalą", "Undefined": "Neapibrėžtas", "Forced": "Priverstinis", - "Default": "Numatytas", + "Default": "Numatytasis", "TaskCleanActivityLogDescription": "Ištrina senesnius nei nustatytas amžius veiklos žurnalo įrašus.", "TaskOptimizeDatabase": "Optimizuoti duomenų bazę", "TaskKeyframeExtractorDescription": "Iš vaizdo įrašo paruošia reikšminius kadrus, kad būtų sukuriamas tikslenis HLS grojaraštis. Šios užduoties vykdymas gali ilgai užtrukti.", -- cgit v1.2.3 From 4c524f033f66d7a3a3a8b9b8ec706ea10330ea31 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 24 Aug 2026 21:49:53 +0200 Subject: Fix OMDB People handling --- .../Plugins/Omdb/OmdbProvider.cs | 53 +++++++++++----------- .../Omdb/OmdbProviderTests.cs | 51 +++++++++++++++++++++ 2 files changed, 77 insertions(+), 27 deletions(-) create mode 100644 tests/Jellyfin.Providers.Tests/Omdb/OmdbProviderTests.cs diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs index f562d64ddd..7a8e2b3ce7 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs @@ -420,41 +420,40 @@ namespace MediaBrowser.Providers.Plugins.Omdb return; } - if (!string.IsNullOrWhiteSpace(result.Director)) - { - var person = new PersonInfo - { - Name = result.Director.Trim(), - Type = PersonKind.Director - }; - - itemResult.AddPerson(person); - } + AddPeople(itemResult, result.Director, PersonKind.Director); + AddPeople(itemResult, result.Writer, PersonKind.Writer); + AddPeople(itemResult, result.Actors, PersonKind.Actor); + } - if (!string.IsNullOrWhiteSpace(result.Writer)) + internal static void AddPeople(MetadataResult itemResult, string credits, PersonKind type) + where T : BaseItem + { + if (string.IsNullOrWhiteSpace(credits)) { - var person = new PersonInfo - { - Name = result.Writer.Trim(), - Type = PersonKind.Writer - }; - - itemResult.AddPerson(person); + return; } - if (!string.IsNullOrWhiteSpace(result.Actors)) + foreach (var credit in credits.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { - var actorList = result.Actors.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - foreach (var actor in actorList) + // OMDb annotates the credited role in parentheses, e.g. "Mari Okada (screenplay)". The same + // person can be credited more than once this way, so strip it and let AddPerson deduplicate. + var name = credit; + var annotation = name.IndexOf('(', StringComparison.Ordinal); + if (annotation >= 0) { - var person = new PersonInfo - { - Name = actor, - Type = PersonKind.Actor - }; + name = name[..annotation].TrimEnd(); + } - itemResult.AddPerson(person); + if (string.IsNullOrEmpty(name)) + { + continue; } + + itemResult.AddPerson(new PersonInfo + { + Name = name, + Type = type + }); } } diff --git a/tests/Jellyfin.Providers.Tests/Omdb/OmdbProviderTests.cs b/tests/Jellyfin.Providers.Tests/Omdb/OmdbProviderTests.cs new file mode 100644 index 0000000000..d18c8c21a2 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Omdb/OmdbProviderTests.cs @@ -0,0 +1,51 @@ +using System.Linq; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Providers.Plugins.Omdb; +using Xunit; + +namespace Jellyfin.Providers.Tests.Omdb +{ + public class OmdbProviderTests + { + [Fact] + public void AddPeople_CommaSeparatedList_SplitsIntoIndividualPeople() + { + var result = new MetadataResult(); + + OmdbProvider.AddPeople(result, "Philip G. Epstein, Julius J. Epstein, Howard Koch", PersonKind.Writer); + + Assert.Equal( + new[] { "Philip G. Epstein", "Julius J. Epstein", "Howard Koch" }, + result.People!.Select(p => p.Name)); + Assert.All(result.People!, p => Assert.Equal(PersonKind.Writer, p.Type)); + } + + [Fact] + public void AddPeople_RoleAnnotations_AreStrippedAndDeduplicated() + { + var result = new MetadataResult(); + + OmdbProvider.AddPeople(result, "Mari Okada (screenplay), Mari Okada (story), Jun'ichi Satô (screenplay), Jun'ichi Satô (story)", PersonKind.Writer); + + Assert.Equal( + new[] { "Mari Okada", "Jun'ichi Satô" }, + result.People!.Select(p => p.Name)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("(uncredited)")] + public void AddPeople_NoUsableName_AddsNothing(string? credits) + { + var result = new MetadataResult(); + + OmdbProvider.AddPeople(result, credits!, PersonKind.Actor); + + Assert.Null(result.People); + } + } +} -- cgit v1.2.3 From 9cc47c4fd6c289118d2c4df0d4866faff083cba2 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 24 Aug 2026 21:52:35 +0200 Subject: Persist the refresh stamp so the people task stops redoing its work --- .../ScheduledTasks/Tasks/PeopleValidationTask.cs | 59 +++++------ MediaBrowser.Providers/Manager/MetadataService.cs | 14 ++- .../Manager/MetadataServiceRefreshTests.cs | 112 +++++++++++++++++++++ 3 files changed, 150 insertions(+), 35 deletions(-) diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs index bd73f63aa7..afb27ddf9e 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/PeopleValidationTask.cs @@ -177,56 +177,51 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30); var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person]; + List peopleIds; + var context = await _dbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); await using (context.ConfigureAwait(false)) { - const int PartitionSize = 100; - - var numPeople = await context.BaseItems + // Read the candidates in one go rather than paging them. A refresh stamps the person and takes + // it out of this set, so a growing offset over a shrinking set walks past people it never visits. + peopleIds = await context.BaseItems .AsNoTracking() .Where(b => b.Type == personTypeName) .Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo) .Where(b => !b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) || string.IsNullOrEmpty(b.Overview)) - .CountAsync(cancellationToken) + .OrderBy(b => b.Id) + .Select(b => b.Id) + .ToListAsync(cancellationToken) .ConfigureAwait(false); + } - _logger.LogDebug("Found {Count} people needing image/overview refresh", numPeople); + _logger.LogDebug("Found {Count} people needing image/overview refresh", peopleIds.Count); - if (numPeople == 0) - { - progress.Report(100); - return; - } + if (peopleIds.Count == 0) + { + progress.Report(100); + return; + } - var numComplete = 0; - var numRefreshed = 0; + var numComplete = 0; + var numRefreshed = 0; - await foreach (var entry in context.BaseItems - .AsNoTracking() - .Where(b => b.Type == personTypeName) - .Where(b => b.DateLastRefreshed == null || b.DateLastRefreshed < thirtyDaysAgo) - .Where(b => - !b.Images!.Any(i => i.ImageType == ImageInfoImageType.Primary) || - string.IsNullOrEmpty(b.Overview)) - .OrderBy(b => b.Id) - .WithPartitionProgress(partition => _logger.LogDebug("Processing people partition {Partition}", partition)) - .PartitionEagerAsync(PartitionSize, cancellationToken) - .WithCancellation(cancellationToken) - .ConfigureAwait(false)) - { - if (await RefreshPersonAsync(entry.Id, cancellationToken).ConfigureAwait(false)) - { - numRefreshed++; - } + foreach (var personId in peopleIds) + { + cancellationToken.ThrowIfCancellationRequested(); - numComplete++; - progress.Report(100.0 * numComplete / numPeople); + if (await RefreshPersonAsync(personId, cancellationToken).ConfigureAwait(false)) + { + numRefreshed++; } - _logger.LogInformation("Refreshed metadata for {Count} people missing images or overview", numRefreshed); + numComplete++; + progress.Report(100.0 * numComplete / peopleIds.Count); } + + _logger.LogInformation("Refreshed metadata for {Count} people missing images or overview", numRefreshed); } private async Task RefreshPersonAsync(Guid personId, CancellationToken cancellationToken) diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index 26dc8f9930..fe5285bf65 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -212,22 +212,30 @@ namespace MediaBrowser.Providers.Manager var attemptedFetch = refreshOptions.MetadataRefreshMode > MetadataRefreshMode.ValidationOnly || refreshOptions.ImageRefreshMode > MetadataRefreshMode.ValidationOnly; + var refreshStampNeedsSaving = false; + if (hasRefreshedMetadata && hasRefreshedImages && attemptedFetch) { item.DateLastRefreshed = DateTime.UtcNow; updateType |= item.OnMetadataChanged(); + + // A full refresh queries every provider whether or not anything looks stale. When they all + // come back empty the stamp is the only thing that changed, and without it nothing records + // that the lookup happened, so the next pass repeats the same fruitless queries forever. + refreshStampNeedsSaving = refreshOptions.MetadataRefreshMode == MetadataRefreshMode.FullRefresh + || refreshOptions.ImageRefreshMode == MetadataRefreshMode.FullRefresh; } - updateType = await SaveInternal(item, refreshOptions, updateType, isFirstRefresh, requiresRefresh, metadataResult, cancellationToken).ConfigureAwait(false); + updateType = await SaveInternal(item, refreshOptions, updateType, isFirstRefresh, requiresRefresh, refreshStampNeedsSaving, metadataResult, cancellationToken).ConfigureAwait(false); await AfterMetadataRefresh(itemOfType, refreshOptions, cancellationToken).ConfigureAwait(false); return updateType; - async Task SaveInternal(BaseItem item, MetadataRefreshOptions refreshOptions, ItemUpdateType updateType, bool isFirstRefresh, bool requiresRefresh, MetadataResult metadataResult, CancellationToken cancellationToken) + async Task SaveInternal(BaseItem item, MetadataRefreshOptions refreshOptions, ItemUpdateType updateType, bool isFirstRefresh, bool requiresRefresh, bool refreshStampNeedsSaving, MetadataResult metadataResult, CancellationToken cancellationToken) { // Save if changes were made, or it's never been saved before - if (refreshOptions.ForceSave || updateType > ItemUpdateType.None || isFirstRefresh || refreshOptions.ReplaceAllMetadata || requiresRefresh) + if (refreshOptions.ForceSave || updateType > ItemUpdateType.None || isFirstRefresh || refreshOptions.ReplaceAllMetadata || requiresRefresh || refreshStampNeedsSaving) { if (item.IsFileProtocol) { diff --git a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs index 1d2fb2e760..465a032328 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs @@ -4,6 +4,7 @@ using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; +using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; @@ -11,8 +12,10 @@ using MediaBrowser.Controller.IO; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; +using MediaBrowser.Model.MediaInfo; using MediaBrowser.Providers.Manager; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -228,6 +231,100 @@ namespace Jellyfin.Providers.Tests.Manager Assert.Equal("nm0000123", mergedPerson.GetProviderId(MetadataProvider.Imdb)); } + [Theory] + [InlineData(MetadataRefreshMode.FullRefresh, true)] + [InlineData(MetadataRefreshMode.Default, false)] + public async Task RefreshMetadata_ProvidersFoundNothing_PersistsRefreshDateOnFullRefresh(MetadataRefreshMode mode, bool expectSaved) + { + var peoplePath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "people"); + + var item = new Person + { + Id = Guid.NewGuid(), + Name = "Test Person", + Path = System.IO.Path.Combine(peoplePath, "T", "Test Person"), + PreferredMetadataLanguage = "en", + PreferredMetadataCountryCode = "US", + DateLastRefreshed = DateTime.UtcNow.AddDays(-60), + DateLastSaved = DateTime.UtcNow.AddDays(-60) + }; + item.PresentationUniqueKey = item.CreatePresentationUniqueKey(); + + var stampBefore = item.DateLastRefreshed; + + var provider = new Mock>(MockBehavior.Loose); + provider.Setup(p => p.Name).Returns("Provider"); + provider.Setup(p => p.GetMetadata(It.IsAny(), It.IsAny())) + .ReturnsAsync(new MetadataResult { HasMetadata = false }); + + var libraryOptions = new LibraryOptions(); + + var libraryManager = new Mock(MockBehavior.Loose); + libraryManager.Setup(l => l.GetLibraryOptions(It.IsAny())).Returns(libraryOptions); + + var providerManager = new Mock(MockBehavior.Loose); + providerManager.Setup(p => p.GetImageProviders(It.IsAny(), It.IsAny())) + .Returns(Array.Empty()); + providerManager.Setup(p => p.GetMetadataProviders(It.IsAny(), It.IsAny())) + .Returns(new[] { (IMetadataProvider)provider.Object }); + providerManager.Setup(p => p.GetMetadataSavers(It.IsAny(), It.IsAny())) + .Returns(Array.Empty()); + + var itemRepository = new Mock(MockBehavior.Loose); + itemRepository.Setup(r => r.ItemExistsAsync(It.IsAny())).ReturnsAsync(true); + + var applicationPaths = new Mock(MockBehavior.Loose); + applicationPaths.Setup(a => a.PeoplePath).Returns(peoplePath); + var configurationManager = new Mock(MockBehavior.Loose); + configurationManager.Setup(c => c.ApplicationPaths).Returns(applicationPaths.Object); + configurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration()); + + var fileSystem = new Mock(MockBehavior.Loose); + fileSystem.Setup(f => f.GetFileSystemInfo(It.IsAny())).Returns(new FileSystemMetadata { Exists = false }); + fileSystem.Setup(f => f.GetValidFilename(It.IsAny())).Returns(name => name); + + var mediaSourceManager = new Mock(MockBehavior.Loose); + mediaSourceManager.Setup(m => m.GetPathProtocol(It.IsAny())).Returns(MediaProtocol.File); + + var previousLibraryManager = BaseItem.LibraryManager; + var previousConfigurationManager = BaseItem.ConfigurationManager; + var previousFileSystem = BaseItem.FileSystem; + var previousMediaSourceManager = BaseItem.MediaSourceManager; + BaseItem.LibraryManager = libraryManager.Object; + BaseItem.ConfigurationManager = configurationManager.Object; + BaseItem.FileSystem = fileSystem.Object; + BaseItem.MediaSourceManager = mediaSourceManager.Object; + try + { + var service = new TestPersonMetadataService(libraryManager.Object, providerManager.Object, itemRepository.Object, fileSystem.Object); + + await service.RefreshMetadata( + item, + new MetadataRefreshOptions(Mock.Of()) + { + MetadataRefreshMode = mode, + ImageRefreshMode = mode + }, + CancellationToken.None).ConfigureAwait(true); + } + finally + { + BaseItem.LibraryManager = previousLibraryManager; + BaseItem.ConfigurationManager = previousConfigurationManager; + BaseItem.FileSystem = previousFileSystem; + BaseItem.MediaSourceManager = previousMediaSourceManager; + } + + libraryManager.Verify( + l => l.UpdateItemAsync(item, It.IsAny(), It.IsAny(), It.IsAny()), + expectSaved ? Times.Once() : Times.Never()); + + if (expectSaved) + { + Assert.True(item.DateLastRefreshed > stampBefore); + } + } + private sealed class TestMetadataService : MetadataService { public TestMetadataService() @@ -249,5 +346,20 @@ namespace Jellyfin.Providers.Tests.Manager ICollection providers) => RefreshWithProviders(metadata, id, options, providers, ImageProvider, false, CancellationToken.None); } + + private sealed class TestPersonMetadataService : MetadataService + { + public TestPersonMetadataService(ILibraryManager libraryManager, IProviderManager providerManager, IItemRepository itemRepository, IFileSystem fileSystem) + : base( + Mock.Of(), + NullLogger>.Instance, + providerManager, + fileSystem, + libraryManager, + Mock.Of(), + itemRepository) + { + } + } } } -- cgit v1.2.3 From 3fafdbc2811af754a41ee89e56dc71bd1fb1a099 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 24 Aug 2026 22:12:57 +0200 Subject: Say which image and item failed instead of logging a blank path --- .../Library/LibraryManager.cs | 35 ++++++++++++++++++---- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 2bba659a23..789823ddbf 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -2527,9 +2527,15 @@ namespace Emby.Server.Implementations.Library } } - if (!File.Exists(image.Path)) - { - _logger.LogWarning("Image not found at {ImagePath}", image.Path); + if (string.IsNullOrEmpty(image.Path) || !File.Exists(image.Path)) + { + _logger.LogWarning( + "{ImageType} image for {ItemName} ({ItemId}) not found at \"{ImagePath}\", source was {SourcePath}", + img.Type, + item.Name, + item.Id, + image.Path, + img.Path); continue; } @@ -3603,7 +3609,20 @@ namespace Emby.Server.Implementations.Library await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); - return item.GetImageInfo(image.Type, imageIndex); + var localImage = item.GetImageInfo(image.Type, imageIndex); + if (localImage is null) + { + throw new InvalidOperationException(string.Format( + CultureInfo.InvariantCulture, + "Downloaded {0} image {1} from {2} is not attached to {3} ({4})", + image.Type, + imageIndex, + url, + item.Name, + item.Id)); + } + + return localImage; } catch (HttpRequestException ex) { @@ -3625,7 +3644,13 @@ namespace Emby.Server.Implementations.Library await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); } - throw new InvalidOperationException("Unable to convert any images to local"); + throw new InvalidOperationException(string.Format( + CultureInfo.InvariantCulture, + "Unable to convert any {0} image url in \"{1}\" to a local file for {2} ({3})", + image.Type, + image.Path, + item.Name, + item.Id)); } public async Task AddVirtualFolder(string name, CollectionTypeOptions? collectionType, LibraryOptions options, bool refreshLibrary) -- cgit v1.2.3 From 38093e2952f634ddbdeaede12c30b0566c64a464 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 24 Aug 2026 22:27:11 +0200 Subject: Fix test --- .../Manager/MetadataServiceRefreshTests.cs | 113 +++++++++------------ 1 file changed, 50 insertions(+), 63 deletions(-) diff --git a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs index 465a032328..3b4d6fc9bb 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/MetadataServiceRefreshTests.cs @@ -1,10 +1,10 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Enums; -using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; @@ -15,7 +15,6 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; -using MediaBrowser.Model.MediaInfo; using MediaBrowser.Providers.Manager; using Microsoft.Extensions.Logging.Abstractions; using Moq; @@ -236,13 +235,10 @@ namespace Jellyfin.Providers.Tests.Manager [InlineData(MetadataRefreshMode.Default, false)] public async Task RefreshMetadata_ProvidersFoundNothing_PersistsRefreshDateOnFullRefresh(MetadataRefreshMode mode, bool expectSaved) { - var peoplePath = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "people"); - - var item = new Person + var item = new TestItem { Id = Guid.NewGuid(), - Name = "Test Person", - Path = System.IO.Path.Combine(peoplePath, "T", "Test Person"), + Name = "Test Item", PreferredMetadataLanguage = "en", PreferredMetadataCountryCode = "US", DateLastRefreshed = DateTime.UtcNow.AddDays(-60), @@ -252,72 +248,38 @@ namespace Jellyfin.Providers.Tests.Manager var stampBefore = item.DateLastRefreshed; - var provider = new Mock>(MockBehavior.Loose); + var provider = new Mock>(MockBehavior.Loose); provider.Setup(p => p.Name).Returns("Provider"); - provider.Setup(p => p.GetMetadata(It.IsAny(), It.IsAny())) - .ReturnsAsync(new MetadataResult { HasMetadata = false }); - - var libraryOptions = new LibraryOptions(); + provider.Setup(p => p.GetMetadata(It.IsAny(), It.IsAny())) + .ReturnsAsync(new MetadataResult { HasMetadata = false }); var libraryManager = new Mock(MockBehavior.Loose); - libraryManager.Setup(l => l.GetLibraryOptions(It.IsAny())).Returns(libraryOptions); + libraryManager.Setup(l => l.GetLibraryOptions(It.IsAny())).Returns(new LibraryOptions()); var providerManager = new Mock(MockBehavior.Loose); providerManager.Setup(p => p.GetImageProviders(It.IsAny(), It.IsAny())) .Returns(Array.Empty()); - providerManager.Setup(p => p.GetMetadataProviders(It.IsAny(), It.IsAny())) - .Returns(new[] { (IMetadataProvider)provider.Object }); + providerManager.Setup(p => p.GetMetadataProviders(It.IsAny(), It.IsAny())) + .Returns(new[] { (IMetadataProvider)provider.Object }); providerManager.Setup(p => p.GetMetadataSavers(It.IsAny(), It.IsAny())) .Returns(Array.Empty()); var itemRepository = new Mock(MockBehavior.Loose); itemRepository.Setup(r => r.ItemExistsAsync(It.IsAny())).ReturnsAsync(true); - var applicationPaths = new Mock(MockBehavior.Loose); - applicationPaths.Setup(a => a.PeoplePath).Returns(peoplePath); - var configurationManager = new Mock(MockBehavior.Loose); - configurationManager.Setup(c => c.ApplicationPaths).Returns(applicationPaths.Object); - configurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration()); - - var fileSystem = new Mock(MockBehavior.Loose); - fileSystem.Setup(f => f.GetFileSystemInfo(It.IsAny())).Returns(new FileSystemMetadata { Exists = false }); - fileSystem.Setup(f => f.GetValidFilename(It.IsAny())).Returns(name => name); - - var mediaSourceManager = new Mock(MockBehavior.Loose); - mediaSourceManager.Setup(m => m.GetPathProtocol(It.IsAny())).Returns(MediaProtocol.File); - - var previousLibraryManager = BaseItem.LibraryManager; - var previousConfigurationManager = BaseItem.ConfigurationManager; - var previousFileSystem = BaseItem.FileSystem; - var previousMediaSourceManager = BaseItem.MediaSourceManager; - BaseItem.LibraryManager = libraryManager.Object; - BaseItem.ConfigurationManager = configurationManager.Object; - BaseItem.FileSystem = fileSystem.Object; - BaseItem.MediaSourceManager = mediaSourceManager.Object; - try - { - var service = new TestPersonMetadataService(libraryManager.Object, providerManager.Object, itemRepository.Object, fileSystem.Object); - - await service.RefreshMetadata( - item, - new MetadataRefreshOptions(Mock.Of()) - { - MetadataRefreshMode = mode, - ImageRefreshMode = mode - }, - CancellationToken.None).ConfigureAwait(true); - } - finally - { - BaseItem.LibraryManager = previousLibraryManager; - BaseItem.ConfigurationManager = previousConfigurationManager; - BaseItem.FileSystem = previousFileSystem; - BaseItem.MediaSourceManager = previousMediaSourceManager; - } + var service = new TestItemMetadataService(libraryManager.Object, providerManager.Object, itemRepository.Object); - libraryManager.Verify( - l => l.UpdateItemAsync(item, It.IsAny(), It.IsAny(), It.IsAny()), - expectSaved ? Times.Once() : Times.Never()); + await service.RefreshMetadata( + item, + new MetadataRefreshOptions(Mock.Of()) + { + MetadataRefreshMode = mode, + ImageRefreshMode = mode + }, + CancellationToken.None).ConfigureAwait(true); + + // Nothing was found, so on a full refresh the advanced stamp is the only reason to write the row. + Assert.Equal(expectSaved, item.Saved); if (expectSaved) { @@ -325,6 +287,31 @@ namespace Jellyfin.Providers.Tests.Manager } } + /// + /// Stands in for a real item so the refresh stays off the shared BaseItem statics, which other + /// test classes in this assembly overwrite while xUnit runs them in parallel. + /// + internal sealed class TestItem : BaseItem + { + public bool Saved { get; private set; } + + public override bool RequiresRefresh() => false; + + public override bool IsSaveLocalMetadataEnabled() => false; + + public override string CreatePresentationUniqueKey() => Id.ToString("N", CultureInfo.InvariantCulture); + + public override ItemUpdateType OnMetadataChanged() => ItemUpdateType.None; + + public override bool BeforeMetadataRefresh(bool replaceAllMetadata) => false; + + public override Task UpdateToRepositoryAsync(ItemUpdateType updateReason, CancellationToken cancellationToken) + { + Saved = true; + return Task.CompletedTask; + } + } + private sealed class TestMetadataService : MetadataService { public TestMetadataService() @@ -347,14 +334,14 @@ namespace Jellyfin.Providers.Tests.Manager => RefreshWithProviders(metadata, id, options, providers, ImageProvider, false, CancellationToken.None); } - private sealed class TestPersonMetadataService : MetadataService + private sealed class TestItemMetadataService : MetadataService { - public TestPersonMetadataService(ILibraryManager libraryManager, IProviderManager providerManager, IItemRepository itemRepository, IFileSystem fileSystem) + public TestItemMetadataService(ILibraryManager libraryManager, IProviderManager providerManager, IItemRepository itemRepository) : base( Mock.Of(), - NullLogger>.Instance, + NullLogger>.Instance, providerManager, - fileSystem, + Mock.Of(), libraryManager, Mock.Of(), itemRepository) -- cgit v1.2.3 From 5e621d0e3f2102177210f162e72a5d73378063fb Mon Sep 17 00:00:00 2001 From: Piotr Niełacny Date: Tue, 25 Aug 2026 15:19:38 +0200 Subject: Order IsPlayed and IsUnplayed by the played state the filter reports Ordering mapped both keys to the item's own stored UserData row. Folders do not have one: a series, season or box set counts as played when no descendant is left unplayed, which is what the isPlayed filter and the DTO both report. A mixed library therefore sorted every series and box set into the unplayed group, and a query could filter and sort by two different notions of "played". Extract the filter's predicate into BuildIsPlayedFilter and route both sort keys through it so the two cannot drift apart again. --- .../Item/BaseItemRepository.QueryBuilding.cs | 19 ++- .../Item/BaseItemRepository.TranslateQuery.cs | 38 +++-- .../Item/BaseItemRepositoryPlayedOrderingTests.cs | 189 +++++++++++++++++++++ 3 files changed, 228 insertions(+), 18 deletions(-) create mode 100644 tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedOrderingTests.cs diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs index 05ff720ddf..c0067d8392 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs @@ -12,6 +12,7 @@ using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Enums; using Jellyfin.Extensions; +using Jellyfin.Server.Implementations.Extensions; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Querying; @@ -323,10 +324,21 @@ public sealed partial class BaseItemRepository orderedQuery = query.OrderBy(relevanceExpression); } + // Folders carry no played flag of their own, so these two keys go through the same predicate + // the isPlayed filter uses rather than through the stored-column lookup in OrderMapper. + Expression> MapOrderByField(ItemSortBy sortBy) => sortBy switch + { + ItemSortBy.IsPlayed when filter.User is not null + => AsOrderKey(BuildIsPlayedFilter(context, filter.User)), + ItemSortBy.IsUnplayed when filter.User is not null + => AsOrderKey(BuildIsPlayedFilter(context, filter.User).Not()), + _ => OrderMapper.MapOrderByField(sortBy, filter, context) + }; + if (orderBy.Length > 0) { var firstOrdering = orderBy[0]; - var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter, context); + var expression = MapOrderByField(firstOrdering.OrderBy); if (orderedQuery is null) { @@ -350,7 +362,7 @@ public sealed partial class BaseItemRepository foreach (var item in orderBy.Skip(1)) { - expression = OrderMapper.MapOrderByField(item.OrderBy, filter, context); + expression = MapOrderByField(item.OrderBy); orderedQuery = item.SortOrder == SortOrder.Ascending ? orderedQuery.ThenBy(expression) : orderedQuery.ThenByDescending(expression); @@ -666,6 +678,9 @@ public sealed partial class BaseItemRepository return ApplyAccessFiltering(context, leafItems, new InternalItemsQuery(user) { IncludeOwnedItems = includeOwnedItems }); } + private static Expression> AsOrderKey(Expression> predicate) + => Expression.Lambda>(Expression.Convert(predicate.Body, typeof(object)), predicate.Parameters); + /// public Expression> BuildHasDescendantFilter(JellyfinDbContext context, IQueryable descendants) { diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index 623c1ea0ab..1e30f0164e 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -35,6 +35,27 @@ public sealed partial class BaseItemRepository // instance across several lambdas, and this filter is combined into a tree more than once. private static Expression> IsFolderFilter => e => e.IsFolder; + // Shared by the isPlayed filter and the IsPlayed/IsUnplayed ordering so the two cannot disagree. + private Expression> BuildIsPlayedFilter(JellyfinDbContext context, User user) + { + var userId = user.Id; + + // Leaf items carry their own played state. + var playedItemIds = context.UserData + .Where(ud => ud.UserId == userId && ud.Played) + .Select(ud => ud.ItemId); + + // Folders (Series, Seasons, BoxSets, albums, ...) have none and count as played once no + // descendant is left unplayed, matching what the DTO reports for them. This has to key off + // the item itself rather than off the requested item types: tag and collection listings mix + // folders and leaf items in a single query. + var unplayedLeafItems = GetAccessFilteredLeafItemsQuery(context, user) + .Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)); + + return IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not()) + .Or(IsFolderFilter.Not().And(e => playedItemIds.Contains(e.Id))); + } + // "und" is the language filters' stand-in for a track that declares no language at all. private static string NormalizeLanguage(string language) => string.Equals(language, "und", StringComparison.OrdinalIgnoreCase) ? "und" : language; @@ -523,22 +544,7 @@ public sealed partial class BaseItemRepository if (filter.IsPlayed.HasValue) { - var userId = filter.User!.Id; - - // Leaf items carry their own played state. - var playedItemIds = context.UserData - .Where(ud => ud.UserId == userId && ud.Played) - .Select(ud => ud.ItemId); - - // Folders (Series, Seasons, BoxSets, albums, ...) have none and count as played once no - // descendant is left unplayed, matching what the DTO reports for them. This has to key off - // the item itself rather than off the requested item types: tag and collection listings mix - // folders and leaf items in a single query. - var unplayedLeafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!) - .Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)); - - var isPlayedFilter = IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not()) - .Or(IsFolderFilter.Not().And(e => playedItemIds.Contains(e.Id))); + var isPlayedFilter = BuildIsPlayedFilter(context, filter.User!); baseQuery = baseQuery.Where(filter.IsPlayed.Value ? isPlayedFilter : isPlayedFilter.Not()); } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedOrderingTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedOrderingTests.cs new file mode 100644 index 0000000000..c2518f13b2 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/BaseItemRepositoryPlayedOrderingTests.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Emby.Server.Implementations.Data; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Server.Implementations.Item; +using MediaBrowser.Controller.Entities; +using Xunit; +using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind; +using ItemSortBy = Jellyfin.Data.Enums.ItemSortBy; +using LinkedChildType = Jellyfin.Database.Implementations.Entities.LinkedChildType; + +namespace Jellyfin.Server.Implementations.Tests.Item; + +/// +/// Covers ordering by and , which +/// has to read the played state the isPlayed filter reports: folders hold none of their own and count +/// as played once no descendant is left unplayed. +/// +public sealed class BaseItemRepositoryPlayedOrderingTests : SqliteDbTestFixture +{ + private const string SeriesType = "MediaBrowser.Controller.Entities.TV.Series"; + private const string EpisodeType = "MediaBrowser.Controller.Entities.TV.Episode"; + private const string BoxSetType = "MediaBrowser.Controller.Entities.Movies.BoxSet"; + private const string MovieType = "MediaBrowser.Controller.Entities.Movies.Movie"; + + private readonly BaseItemRepository _repository; + private readonly User _user = new("test", "auth-provider", "reset-provider"); + + // Names run A..F so name order interleaves the two groups: a dropped or inverted played key shows + // up as a different sequence rather than as the expected one by luck. + private readonly Guid _watchedSeries = Guid.NewGuid(); + private readonly Guid _unwatchedSeries = Guid.NewGuid(); + private readonly Guid _partiallyWatchedSeries = Guid.NewGuid(); + private readonly Guid _secondUnwatchedSeries = Guid.NewGuid(); + private readonly Guid _thirdUnwatchedSeries = Guid.NewGuid(); + private readonly Guid _secondWatchedSeries = Guid.NewGuid(); + + // Box sets reach their children through LinkedChildren instead of the ancestor chain. + private readonly Guid _watchedBoxSet = Guid.NewGuid(); + private readonly Guid _unwatchedBoxSet = Guid.NewGuid(); + + private readonly HashSet _unwatchedSeriesIds; + + public BaseItemRepositoryPlayedOrderingTests() + { + _unwatchedSeriesIds = [_unwatchedSeries, _partiallyWatchedSeries, _secondUnwatchedSeries, _thirdUnwatchedSeries]; + + using (var context = CreateDbContext()) + { + Seed(context); + } + + _repository = CreateBaseItemRepository(new ItemTypeLookup()); + } + + [Fact] + public void IsPlayed_OrdersUnwatchedSeriesBeforeWatchedOnes() + { + Assert.Equal( + [_unwatchedSeries, _partiallyWatchedSeries, _secondUnwatchedSeries, _thirdUnwatchedSeries, _watchedSeries, _secondWatchedSeries], + SeriesIds(ItemSortBy.IsPlayed)); + } + + [Fact] + public void IsPlayed_CountsAPartiallyWatchedSeriesAsUnwatched() + { + var ids = SeriesIds(ItemSortBy.IsPlayed); + + Assert.True(ids.IndexOf(_partiallyWatchedSeries) < ids.IndexOf(_watchedSeries)); + } + + [Fact] + public void IsUnplayed_ReversesTheGroups() + { + Assert.Equal( + [_watchedSeries, _secondWatchedSeries, _unwatchedSeries, _partiallyWatchedSeries, _secondUnwatchedSeries, _thirdUnwatchedSeries], + SeriesIds(ItemSortBy.IsUnplayed)); + } + + [Fact] + public void IsPlayed_OrdersAnUnwatchedBoxSetBeforeAWatchedOne() + { + var ids = _repository + .GetItemList(Query(BaseItemKind.BoxSet, (ItemSortBy.IsPlayed, SortOrder.Ascending))) + .Select(i => i.Id); + + Assert.Equal([_unwatchedBoxSet, _watchedBoxSet], ids); + } + + [Fact] + public void IsPlayedThenRandom_StillPlacesEveryUnwatchedSeriesFirst() + { + var order = _repository + .GetItemList(Query(BaseItemKind.Series, (ItemSortBy.IsPlayed, SortOrder.Ascending), (ItemSortBy.Random, SortOrder.Ascending))) + .Select(i => i.Id); + + Assert.Equal(_unwatchedSeriesIds, order.Take(_unwatchedSeriesIds.Count).ToHashSet()); + } + + [Fact] + public void IsPlayedThenRandom_FillsAPageWithUnwatchedSeries() + { + var page = _repository.GetItems(new InternalItemsQuery(_user) + { + IncludeItemTypes = [BaseItemKind.Series], + OrderBy = [(ItemSortBy.IsPlayed, SortOrder.Ascending), (ItemSortBy.Random, SortOrder.Ascending)], + Limit = 4, + EnableTotalRecordCount = true + }); + + Assert.Equal(6, page.TotalRecordCount); + Assert.Equal(_unwatchedSeriesIds, page.Items.Select(i => i.Id).ToHashSet()); + } + + private List SeriesIds(ItemSortBy sortBy) + => _repository + .GetItemList(Query(BaseItemKind.Series, (sortBy, SortOrder.Ascending))) + .Select(i => i.Id) + .ToList(); + + private InternalItemsQuery Query(BaseItemKind kind, params (ItemSortBy OrderBy, SortOrder SortOrder)[] orderBy) + => new(_user) + { + IncludeItemTypes = [kind], + OrderBy = orderBy + }; + + private void Seed(JellyfinDbContext context) + { + context.Users.Add(_user); + + AddSeries(context, _watchedSeries, "A watched", playedEpisodes: 1, unplayedEpisodes: 0); + AddSeries(context, _unwatchedSeries, "B unwatched", playedEpisodes: 0, unplayedEpisodes: 1); + AddSeries(context, _partiallyWatchedSeries, "C partially watched", playedEpisodes: 1, unplayedEpisodes: 1); + AddSeries(context, _secondUnwatchedSeries, "D unwatched", playedEpisodes: 0, unplayedEpisodes: 1); + AddSeries(context, _thirdUnwatchedSeries, "E unwatched", playedEpisodes: 0, unplayedEpisodes: 1); + AddSeries(context, _secondWatchedSeries, "F watched", playedEpisodes: 1, unplayedEpisodes: 0); + + AddBoxSet(context, _watchedBoxSet, "A watched set", played: true); + AddBoxSet(context, _unwatchedBoxSet, "B unwatched set", played: false); + + context.SaveChanges(); + } + + private void AddSeries(JellyfinDbContext context, Guid id, string name, int playedEpisodes, int unplayedEpisodes) + { + context.BaseItems.Add(new BaseItemEntity { Id = id, Type = SeriesType, Name = name, SortName = name, PresentationUniqueKey = id.ToString("N"), IsFolder = true }); + + for (var i = 0; i < playedEpisodes + unplayedEpisodes; i++) + { + var episodeId = Guid.NewGuid(); + context.BaseItems.Add(new BaseItemEntity { Id = episodeId, Type = EpisodeType, Name = $"{name} {i}", PresentationUniqueKey = episodeId.ToString("N"), SeriesId = id }); + context.AncestorIds.Add(new AncestorId { ItemId = episodeId, ParentItemId = id, Item = null!, ParentItem = null! }); + + if (i < playedEpisodes) + { + AddPlayedUserData(context, episodeId); + } + } + } + + private void AddBoxSet(JellyfinDbContext context, Guid id, string name, bool played) + { + var movieId = Guid.NewGuid(); + + context.BaseItems.Add(new BaseItemEntity { Id = id, Type = BoxSetType, Name = name, SortName = name, PresentationUniqueKey = id.ToString("N"), IsFolder = true }); + context.BaseItems.Add(new BaseItemEntity { Id = movieId, Type = MovieType, Name = $"{name} movie", PresentationUniqueKey = movieId.ToString("N") }); + context.LinkedChildren.Add(new LinkedChildEntity { ParentId = id, ChildId = movieId, ChildType = LinkedChildType.Manual, SortOrder = 0 }); + + if (played) + { + AddPlayedUserData(context, movieId); + } + } + + private void AddPlayedUserData(JellyfinDbContext context, Guid itemId) + => context.UserData.Add(new UserData + { + ItemId = itemId, + UserId = _user.Id, + CustomDataKey = itemId.ToString("N"), + Played = true, + Item = null!, + User = null! + }); +} -- cgit v1.2.3 From 8c0775e9412082ab427bb93cf40ecca6ce83c492 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 25 Aug 2026 20:19:52 +0200 Subject: Bind the folder name an item-by-name entity resolves to --- .../Entities/Audio/MusicArtist.cs | 5 +-- .../Entities/Audio/MusicGenre.cs | 5 +-- MediaBrowser.Controller/Entities/BaseItem.cs | 41 +++++++++++++++++ MediaBrowser.Controller/Entities/Genre.cs | 5 +-- MediaBrowser.Controller/Entities/Person.cs | 5 +-- MediaBrowser.Controller/Entities/Studio.cs | 5 +-- MediaBrowser.Controller/Entities/Year.cs | 5 +-- .../Entities/BaseItemTests.cs | 52 ++++++++++++++++++++++ 8 files changed, 99 insertions(+), 24 deletions(-) diff --git a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs index c25694aba5..1e2d94d2a4 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicArtist.cs @@ -173,10 +173,7 @@ namespace MediaBrowser.Controller.Entities.Audio public static string GetPath(string name, bool normalizeName) { - // Trim the period at the end because windows will have a hard time with that - var validName = normalizeName ? - FileSystem.GetValidFilename(name).Trim().TrimEnd('.') : - name; + var validName = normalizeName ? GetItemByNameFolderName(name) : name; return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.ArtistsPath, validName); } diff --git a/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs b/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs index 65669e6804..23b3341dbc 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicGenre.cs @@ -80,10 +80,7 @@ namespace MediaBrowser.Controller.Entities.Audio public static string GetPath(string name, bool normalizeName) { - // Trim the period at the end because windows will have a hard time with that - var validName = normalizeName ? - FileSystem.GetValidFilename(name).Trim().TrimEnd('.') : - name; + var validName = normalizeName ? GetItemByNameFolderName(name) : name; return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.MusicGenrePath, validName); } diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 28f40cb7fa..d030c8f420 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -48,6 +48,10 @@ namespace MediaBrowser.Controller.Entities public const string ThemeSongFileName = "theme"; + // Well below the 255 byte limit of the common Linux filesystems and the 255 character limit + // of Windows, so the files inside the folder still fit within MAX_PATH. + private const int MaxItemByNameFolderNameBytes = 128; + /// /// The supported image extensions. /// @@ -941,6 +945,43 @@ namespace MediaBrowser.Controller.Entities return GetSortName(Name, EnableAlphaNumericSorting, ConfigurationManager.Configuration); } + /// + /// Turns an item-by-name entity's name into a folder name every supported filesystem accepts. + /// + /// The entity's name. + /// The folder name. + public static string GetItemByNameFolderName(string name) + { + // Trim the period at the end because windows will have a hard time with that + var validName = FileSystem.GetValidFilename(name).Trim().TrimEnd('.'); + + // Most Linux filesystems cap a path component at 255 bytes, so a name past that cannot be + // turned into a folder at all - and an entity with no folder can never be created, which + // leaves the credit behind it stuck: not refreshable, not deletable, retried on every scan. + // Only broken provider data gets this long, but it still has to resolve to something, so + // keep a readable prefix and let a hash of the whole name tell two of them apart. + if (Encoding.UTF8.GetByteCount(validName) <= MaxItemByNameFolderNameBytes) + { + return validName; + } + + var suffix = "-" + validName.GetMD5().ToString("N", CultureInfo.InvariantCulture); + var budget = MaxItemByNameFolderNameBytes - suffix.Length; + var length = Math.Min(validName.Length, budget); + while (length > 0 && Encoding.UTF8.GetByteCount(validName.AsSpan(0, length)) > budget) + { + length--; + } + + // Never cut a surrogate pair in half, the lone half is not a valid file name character. + if (length > 0 && char.IsHighSurrogate(validName[length - 1])) + { + length--; + } + + return string.Concat(validName.AsSpan(0, length).TrimEnd().TrimEnd('.'), suffix); + } + /// /// Cleans a raw name into its sortable form by applying the configured sort rules. /// diff --git a/MediaBrowser.Controller/Entities/Genre.cs b/MediaBrowser.Controller/Entities/Genre.cs index 6ec78a270e..ef8acaef92 100644 --- a/MediaBrowser.Controller/Entities/Genre.cs +++ b/MediaBrowser.Controller/Entities/Genre.cs @@ -83,10 +83,7 @@ namespace MediaBrowser.Controller.Entities public static string GetPath(string name, bool normalizeName) { - // Trim the period at the end because windows will have a hard time with that - var validName = normalizeName ? - FileSystem.GetValidFilename(name).Trim().TrimEnd('.') : - name; + var validName = normalizeName ? GetItemByNameFolderName(name) : name; return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.GenrePath, validName); } diff --git a/MediaBrowser.Controller/Entities/Person.cs b/MediaBrowser.Controller/Entities/Person.cs index 14325d971a..bba5005eed 100644 --- a/MediaBrowser.Controller/Entities/Person.cs +++ b/MediaBrowser.Controller/Entities/Person.cs @@ -98,10 +98,7 @@ namespace MediaBrowser.Controller.Entities public static string GetPath(string name, bool normalizeName) { - // Trim the period at the end because windows will have a hard time with that - var validFilename = normalizeName ? - FileSystem.GetValidFilename(name).Trim().TrimEnd('.') : - name; + var validFilename = normalizeName ? GetItemByNameFolderName(name) : name; string subFolderPrefix = null; diff --git a/MediaBrowser.Controller/Entities/Studio.cs b/MediaBrowser.Controller/Entities/Studio.cs index 9103b09a95..a944b356c8 100644 --- a/MediaBrowser.Controller/Entities/Studio.cs +++ b/MediaBrowser.Controller/Entities/Studio.cs @@ -78,10 +78,7 @@ namespace MediaBrowser.Controller.Entities public static string GetPath(string name, bool normalizeName) { - // Trim the period at the end because windows will have a hard time with that - var validName = normalizeName ? - FileSystem.GetValidFilename(name).Trim().TrimEnd('.') : - name; + var validName = normalizeName ? GetItemByNameFolderName(name) : name; return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.StudioPath, validName); } diff --git a/MediaBrowser.Controller/Entities/Year.cs b/MediaBrowser.Controller/Entities/Year.cs index 37820296cc..03fb2156d3 100644 --- a/MediaBrowser.Controller/Entities/Year.cs +++ b/MediaBrowser.Controller/Entities/Year.cs @@ -85,10 +85,7 @@ namespace MediaBrowser.Controller.Entities public static string GetPath(string name, bool normalizeName) { - // Trim the period at the end because windows will have a hard time with that - var validName = normalizeName ? - FileSystem.GetValidFilename(name).Trim().TrimEnd('.') : - name; + var validName = normalizeName ? GetItemByNameFolderName(name) : name; return System.IO.Path.Combine(ConfigurationManager.ApplicationPaths.YearPath, validName); } diff --git a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs index e34eb0bda3..86bac4256a 100644 --- a/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs +++ b/tests/Jellyfin.Controller.Tests/Entities/BaseItemTests.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Reflection; +using System.Text; using System.Threading; using System.Threading.Tasks; using Jellyfin.Database.Implementations.Entities; @@ -27,6 +28,57 @@ namespace Jellyfin.Controller.Tests.Entities; public class BaseItemTests { + [Fact] + public void GetItemByNameFolderName_ShortName_IsKeptAsIs() + { + SetupPassThroughFileSystem(); + + Assert.Equal("Mairghread Scott", BaseItem.GetItemByNameFolderName("Mairghread Scott.")); + } + + [Fact] + public void GetItemByNameFolderName_OverlongName_FitsInAPathComponent() + { + SetupPassThroughFileSystem(); + + // What a provider result that concatenated a whole credit list into one name looks like. + var name = string.Join(", ", Enumerable.Repeat("Jerry Siegel (created by: Superman)", 20)); + + var folderName = BaseItem.GetItemByNameFolderName(name); + + Assert.True(Encoding.UTF8.GetByteCount(folderName) <= 128); + Assert.StartsWith("Jerry Siegel (created by: Superman)", folderName, StringComparison.Ordinal); + } + + [Fact] + public void GetItemByNameFolderName_OverlongNamesSharingAPrefix_StayApart() + { + SetupPassThroughFileSystem(); + + var prefix = new string('a', 200); + + Assert.NotEqual( + BaseItem.GetItemByNameFolderName(prefix + "Joe Shuster"), + BaseItem.GetItemByNameFolderName(prefix + "Bob Kane")); + } + + [Fact] + public void GetItemByNameFolderName_OverlongName_IsStable() + { + SetupPassThroughFileSystem(); + + var name = new string('a', 300); + + Assert.Equal(BaseItem.GetItemByNameFolderName(name), BaseItem.GetItemByNameFolderName(name)); + } + + private static void SetupPassThroughFileSystem() + { + var fileSystem = new Mock(); + fileSystem.Setup(x => x.GetValidFilename(It.IsAny())).Returns((string name) => name); + BaseItem.FileSystem = fileSystem.Object; + } + [Theory] [InlineData("", "")] [InlineData("1", "0000000001")] -- cgit v1.2.3 From 6978dfc29441eb1a37571be14fe165c622bff3c7 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 25 Aug 2026 20:23:14 +0200 Subject: Delete a credit once no item maps to it any more --- .../Library/LibraryManager.cs | 6 ++ .../Library/Validators/PeopleValidator.cs | 10 ++- .../Item/PeopleRepository.cs | 32 ++++++++++ MediaBrowser.Controller/Library/ILibraryManager.cs | 6 ++ .../Persistence/IPeopleRepository.cs | 6 ++ .../Item/PeopleRepositoryUpdatePeopleTests.cs | 73 ++++++++++++++++++++++ 6 files changed, 132 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 2bba659a23..6cf9f33e6e 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -3558,6 +3558,12 @@ namespace Emby.Server.Implementations.Library return _peopleRepository.GetPeopleNames(query); } + /// + public int DeleteOrphanedCredits() + { + return _peopleRepository.DeleteOrphanedCredits(); + } + /// public IReadOnlyDictionary> GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes) { diff --git a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs index dacef102dd..078a0b921d 100644 --- a/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs +++ b/Emby.Server.Implementations/Library/Validators/PeopleValidator.cs @@ -49,6 +49,14 @@ public class PeopleValidator /// Task. public async Task ValidatePeople(CancellationToken cancellationToken, IProgress progress) { + // Before the refresh below walks them: a credit no item maps to any more stands for nothing, + // and while it is there the person it names cannot reach the dead-person sweep either. + var numOrphaned = _libraryManager.DeleteOrphanedCredits(); + if (numOrphaned > 0) + { + _logger.LogDebug("Deleted {Amount} credits no item maps to", numOrphaned); + } + var people = _libraryManager.GetPeopleNames(new InternalPeopleQuery()); var numComplete = 0; @@ -115,6 +123,6 @@ public class PeopleValidator progress.Report(100); - _logger.LogInformation("People validation complete"); + _logger.LogInformation("People validation complete, deleted {Orphaned} orphaned credits", numOrphaned); } } diff --git a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs index aaa363b046..da2ad033ec 100644 --- a/Jellyfin.Server.Implementations/Item/PeopleRepository.cs +++ b/Jellyfin.Server.Implementations/Item/PeopleRepository.cs @@ -194,12 +194,44 @@ public class PeopleRepository(IDbContextFactory dbProvider, I listOrder++; } + var droppedCredits = existingMaps.Select(e => e.PeopleId).Distinct().ToArray(); context.PeopleBaseItemMap.RemoveRange(existingMaps); + context.SaveChanges(); + + // Nothing else ever deletes a credit row, so one left without a single mapping outlives the + // credit it stood for: it keeps a person of that name off the dead-person sweep, which only + // sees items no credit names, and keeps the name in every by-name list. That is how a credit + // a provider dropped, or one a broken provider result invented, becomes impossible to clean up. + DeleteCreditsWithoutMapping(context, droppedCredits); + context.SaveChanges(); transaction.Commit(); } + /// + public int DeleteOrphanedCredits() + { + using var context = _dbProvider.CreateDbContext(); + + return DeleteCreditsWithoutMapping(context, null); + } + + // A null candidate list sweeps every credit, anything else only the ones just unmapped. + private int DeleteCreditsWithoutMapping(JellyfinDbContext context, IReadOnlyList? candidates) + { + if (candidates is not null && candidates.Count == 0) + { + return 0; + } + + var credits = candidates is null + ? context.Peoples.AsQueryable() + : context.Peoples.WhereOneOrMany(candidates, e => e.Id); + + return credits.Where(e => !context.PeopleBaseItemMap.Any(f => f.PeopleId == e.Id)).ExecuteDelete(); + } + /// public IReadOnlyDictionary> GetPeopleNamesByItems(IReadOnlyList itemIds, IReadOnlyList personTypes) { diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs index ca686fbd9d..2a6ea214b8 100644 --- a/MediaBrowser.Controller/Library/ILibraryManager.cs +++ b/MediaBrowser.Controller/Library/ILibraryManager.cs @@ -605,6 +605,12 @@ namespace MediaBrowser.Controller.Library /// List<System.String>. IReadOnlyList GetPeopleNames(InternalPeopleQuery query); + /// + /// Deletes every credit that no item maps to any more. + /// + /// The number of credits that were deleted. + int DeleteOrphanedCredits(); + /// /// Gets the distinct people names per item for multiple items. /// diff --git a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs index 9811241d31..15183a8806 100644 --- a/MediaBrowser.Controller/Persistence/IPeopleRepository.cs +++ b/MediaBrowser.Controller/Persistence/IPeopleRepository.cs @@ -33,6 +33,12 @@ public interface IPeopleRepository /// The list of people names matching the filter. IReadOnlyList GetPeopleNames(InternalPeopleQuery filter); + /// + /// Deletes every credit that no item maps to any more. + /// + /// The number of credits that were deleted. + int DeleteOrphanedCredits(); + /// /// Gets the distinct people names per item for multiple items efficiently by querying from the mapping table. /// diff --git a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs index 54565c5787..649458f733 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Item/PeopleRepositoryUpdatePeopleTests.cs @@ -142,6 +142,79 @@ public sealed class PeopleRepositoryUpdatePeopleTests : SqliteDbTestFixture Assert.Equal("Hero", map.Role); } + [Fact] + public void UpdatePeople_CreditDroppedByTheProvider_LeavesNoCreditRowBehind() + { + _repository.UpdatePeople(_itemId, [ + CreatePerson("Person A", PersonKind.Actor, "Hero"), + CreatePerson("Person B", PersonKind.Actor, "Villain") + ]); + + _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]); + + using var ctx = CreateDbContext(); + Assert.Equal(["Person A"], ctx.Peoples.Select(e => e.Name).ToArray()); + } + + [Fact] + public void UpdatePeople_CreditStillHeldByAnotherItem_IsKept() + { + _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]); + _repository.UpdatePeople(AddMovie("Other Movie"), [CreatePerson("Person A", PersonKind.Actor, "Hero")]); + + _repository.UpdatePeople(_itemId, []); + + using var after = CreateDbContext(); + Assert.Single(after.Peoples); + Assert.Single(after.PeopleBaseItemMap); + } + + [Fact] + public void DeleteOrphanedCredits_CreditNoItemMapsTo_IsDeleted() + { + _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]); + using (var ctx = CreateDbContext()) + { + // The state a credit was left in before UpdatePeople cleaned up after itself. + ctx.PeopleBaseItemMap.RemoveRange(ctx.PeopleBaseItemMap); + ctx.SaveChanges(); + } + + Assert.Equal(1, _repository.DeleteOrphanedCredits()); + + using var after = CreateDbContext(); + Assert.Empty(after.Peoples); + } + + [Fact] + public void DeleteOrphanedCredits_CreditAnItemMapsTo_IsKept() + { + _repository.UpdatePeople(_itemId, [CreatePerson("Person A", PersonKind.Actor, "Hero")]); + + Assert.Equal(0, _repository.DeleteOrphanedCredits()); + + using var after = CreateDbContext(); + Assert.Single(after.Peoples); + } + + private Guid AddMovie(string name) + { + var id = Guid.NewGuid(); + using var ctx = CreateDbContext(); + ctx.BaseItems.Add(new BaseItemEntity + { + Id = id, + Type = new ItemTypeLookup().BaseItemKindNames[BaseItemKind.Movie], + Name = name, + MediaType = "Video", + IsMovie = true, + IsFolder = false, + IsVirtualItem = false + }); + ctx.SaveChanges(); + return id; + } + private static PersonInfo CreatePerson(string name, PersonKind type, string role) { return new PersonInfo -- cgit v1.2.3 From 4147a83b9356a9b6d101dde8217aec21fefb51f4 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Mon, 24 Aug 2026 22:20:33 +0200 Subject: Handle generational suffixes --- .../Plugins/Omdb/OmdbProvider.cs | 32 +++++++++++++++++++++- .../Omdb/OmdbProviderTests.cs | 16 +++++++++++ 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs index 7a8e2b3ce7..7262cddd33 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs @@ -27,6 +27,9 @@ namespace MediaBrowser.Providers.Plugins.Omdb /// Provider for OMDB service. public class OmdbProvider { + /// Generational suffixes that OMDb separates from the name with a comma. + private static readonly string[] NameSuffixes = ["Jr", "Jnr", "Sr", "Snr", "II", "III", "IV", "V"]; + private readonly IFileSystem _fileSystem; private readonly IServerConfigurationManager _configurationManager; private readonly IHttpClientFactory _httpClientFactory; @@ -425,6 +428,11 @@ namespace MediaBrowser.Providers.Plugins.Omdb AddPeople(itemResult, result.Actors, PersonKind.Actor); } + /// Adds the people from a comma separated OMDb credit list. + /// The item type. + /// The metadata result to add the people to. + /// The comma separated OMDb credit list. + /// The kind of person each credit describes. internal static void AddPeople(MetadataResult itemResult, string credits, PersonKind type) where T : BaseItem { @@ -433,6 +441,8 @@ namespace MediaBrowser.Providers.Plugins.Omdb return; } + var names = new List(); + foreach (var credit in credits.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { // OMDb annotates the credited role in parentheses, e.g. "Mari Okada (screenplay)". The same @@ -444,11 +454,24 @@ namespace MediaBrowser.Providers.Plugins.Omdb name = name[..annotation].TrimEnd(); } - if (string.IsNullOrEmpty(name)) + if (name.Length == 0) { continue; } + // A generational suffix is separated from the name it belongs to by the same comma the list + // uses, e.g. "Jack Salvatore, Jr.", so it has to be joined back instead of becoming a credit. + if (names.Count > 0 && IsNameSuffix(name)) + { + names[^1] = names[^1] + ", " + name; + continue; + } + + names.Add(name); + } + + foreach (var name in names) + { itemResult.AddPerson(new PersonInfo { Name = name, @@ -457,6 +480,13 @@ namespace MediaBrowser.Providers.Plugins.Omdb } } + private static bool IsNameSuffix(string value) + { + var suffix = value.EndsWith('.') ? value[..^1] : value; + + return NameSuffixes.Contains(suffix, StringComparer.OrdinalIgnoreCase); + } + private static bool IsConfiguredForEnglish(BaseItem item, string language) { if (string.IsNullOrEmpty(language)) diff --git a/tests/Jellyfin.Providers.Tests/Omdb/OmdbProviderTests.cs b/tests/Jellyfin.Providers.Tests/Omdb/OmdbProviderTests.cs index d18c8c21a2..5e053943af 100644 --- a/tests/Jellyfin.Providers.Tests/Omdb/OmdbProviderTests.cs +++ b/tests/Jellyfin.Providers.Tests/Omdb/OmdbProviderTests.cs @@ -34,6 +34,22 @@ namespace Jellyfin.Providers.Tests.Omdb result.People!.Select(p => p.Name)); } + [Theory] + [InlineData("Jack Salvatore, Jr.", "Jack Salvatore, Jr.")] + [InlineData("Efrem Zimbalist, Jr., Tom Hanks", "Efrem Zimbalist, Jr.|Tom Hanks")] + [InlineData("Tom Hanks, Sammy Davis, Jr", "Tom Hanks|Sammy Davis, Jr")] + [InlineData("Harold Ramis, Ken Griffey, III (voice)", "Harold Ramis|Ken Griffey, III")] + [InlineData("Robert Downey Jr., Gwyneth Paltrow", "Robert Downey Jr.|Gwyneth Paltrow")] + [InlineData("Jr., Tom Hanks", "Jr.|Tom Hanks")] + public void AddPeople_GenerationalSuffix_StaysWithItsName(string credits, string expected) + { + var result = new MetadataResult(); + + OmdbProvider.AddPeople(result, credits, PersonKind.Actor); + + Assert.Equal(expected.Split('|'), result.People!.Select(p => p.Name)); + } + [Theory] [InlineData(null)] [InlineData("")] -- cgit v1.2.3 From 1d24c1df170d743d5b0d2c1c8c503e44486c6875 Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 25 Aug 2026 20:12:52 +0200 Subject: Keep an OMDb credit whole when its annotation holds a comma --- .../Plugins/Omdb/OmdbProvider.cs | 32 ++++++++++++++++++++-- .../Omdb/OmdbProviderTests.cs | 14 ++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs index 7262cddd33..d51d913caa 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs @@ -443,7 +443,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb var names = new List(); - foreach (var credit in credits.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + foreach (var credit in SplitCredits(credits)) { // OMDb annotates the credited role in parentheses, e.g. "Mari Okada (screenplay)". The same // person can be credited more than once this way, so strip it and let AddPerson deduplicate. @@ -451,9 +451,10 @@ namespace MediaBrowser.Providers.Plugins.Omdb var annotation = name.IndexOf('(', StringComparison.Ordinal); if (annotation >= 0) { - name = name[..annotation].TrimEnd(); + name = name[..annotation]; } + name = name.Trim(); if (name.Length == 0) { continue; @@ -480,6 +481,33 @@ namespace MediaBrowser.Providers.Plugins.Omdb } } + // Only the commas between credits, never one inside an annotation: "Jerry Siegel (created by: + // Superman, Superboy)" is one credit, and splitting it blindly invents a person called "Superboy)". + private static IEnumerable SplitCredits(string credits) + { + var depth = 0; + var start = 0; + + for (var i = 0; i < credits.Length; i++) + { + switch (credits[i]) + { + case '(': + depth++; + break; + case ')': + depth = Math.Max(0, depth - 1); + break; + case ',' when depth == 0: + yield return credits[start..i]; + start = i + 1; + break; + } + } + + yield return credits[start..]; + } + private static bool IsNameSuffix(string value) { var suffix = value.EndsWith('.') ? value[..^1] : value; diff --git a/tests/Jellyfin.Providers.Tests/Omdb/OmdbProviderTests.cs b/tests/Jellyfin.Providers.Tests/Omdb/OmdbProviderTests.cs index 5e053943af..bd50a903f1 100644 --- a/tests/Jellyfin.Providers.Tests/Omdb/OmdbProviderTests.cs +++ b/tests/Jellyfin.Providers.Tests/Omdb/OmdbProviderTests.cs @@ -34,6 +34,20 @@ namespace Jellyfin.Providers.Tests.Omdb result.People!.Select(p => p.Name)); } + [Theory] + [InlineData( + "Jerry Siegel (created by: Superman, Superboy), Bob Kane (created by: Batman)", + "Jerry Siegel|Bob Kane")] + [InlineData("Alan Moore (created by: John Constantine)", "Alan Moore")] + public void AddPeople_CommaInsideAnAnnotation_StaysOneCredit(string credits, string expected) + { + var result = new MetadataResult(); + + OmdbProvider.AddPeople(result, credits, PersonKind.Writer); + + Assert.Equal(expected.Split('|'), result.People!.Select(p => p.Name)); + } + [Theory] [InlineData("Jack Salvatore, Jr.", "Jack Salvatore, Jr.")] [InlineData("Efrem Zimbalist, Jr., Tom Hanks", "Efrem Zimbalist, Jr.|Tom Hanks")] -- cgit v1.2.3 From a81b90f570f681d0f5088637d03bb14a54ef6e2e Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 25 Aug 2026 20:29:10 +0200 Subject: Fix tests --- .../Collections/CollectionManager.cs | 3 +- .../Library/LibraryManager.cs | 24 +- .../Library/Resolvers/TV/SeasonResolver.cs | 2 +- .../Library/UserViewManager.cs | 6 +- ...20260825200000_ConsolidateLocalizedUserViews.cs | 334 +++++++++++++++++++++ src/Jellyfin.LiveTv/LiveTvManager.cs | 2 +- .../Library/SeasonResolverTests.cs | 2 +- 7 files changed, 362 insertions(+), 11 deletions(-) create mode 100644 Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs index 295efd456c..84d50f5121 100644 --- a/Emby.Server.Implementations/Collections/CollectionManager.cs +++ b/Emby.Server.Implementations/Collections/CollectionManager.cs @@ -107,7 +107,8 @@ namespace Emby.Server.Implementations.Collections SaveLocalMetadata = true }; - var name = _localizationManager.GetLocalizedString("Collections"); + // This names a library for the whole server, so ignore the requesting client's language. + var name = _localizationManager.GetServerLocalizedString("Collections"); await _libraryManager.AddVirtualFolder(name, CollectionTypeOptions.boxsets, libraryOptions, true).ConfigureAwait(false); diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index 2bba659a23..cc0e8231b6 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -2927,7 +2927,8 @@ namespace Emby.Server.Implementations.Library "views", _fileSystem.GetValidFilename(viewType.ToString())); - var id = GetNewItemId(path + "_namedview_" + name, typeof(UserView)); + // The display name is localized, so it must not take part in the id. + var id = GetNewItemId(path + "_namedview_" + viewType.ToString(), typeof(UserView)); var item = GetItemById(id) as UserView; @@ -2951,6 +2952,13 @@ namespace Emby.Server.Implementations.Library refresh = true; } + else if (!string.Equals(item.Name, name, StringComparison.Ordinal)) + { + item.Name = name; + item.ForcedSortName = sortName; + + refresh = true; + } if (refresh) { @@ -2971,7 +2979,9 @@ namespace Emby.Server.Implementations.Library var parentIdString = parentId.IsEmpty() ? null : parentId.ToString("N", CultureInfo.InvariantCulture); - var idValues = "38_namedview_" + name + user.Id.ToString("N", CultureInfo.InvariantCulture) + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty); + + // The name is either localized (grouped views) or the library folder's own name. + var idValues = "38_namedview_" + user.Id.ToString("N", CultureInfo.InvariantCulture) + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty); var id = GetNewItemId(idValues, typeof(UserView)); @@ -3001,6 +3011,11 @@ namespace Emby.Server.Implementations.Library isNew = true; } + else if (!string.Equals(item.Name, name, StringComparison.Ordinal)) + { + item.Name = name; + item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); + } var lastRefreshedUtc = item.DateLastRefreshed; var refresh = isNew || DateTime.UtcNow - lastRefreshedUtc >= _viewRefreshInterval; @@ -3102,7 +3117,7 @@ namespace Emby.Server.Implementations.Library var parentIdString = parentId.IsEmpty() ? null : parentId.ToString("N", CultureInfo.InvariantCulture); - var idValues = "37_namedview_" + name + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty); + var idValues = "37_namedview_" + (parentIdString ?? string.Empty) + (viewType?.ToString() ?? string.Empty); if (!string.IsNullOrEmpty(uniqueId)) { idValues += uniqueId; @@ -3136,9 +3151,10 @@ namespace Emby.Server.Implementations.Library isNew = true; } - if (viewType != item.ViewType) + if (viewType != item.ViewType || !string.Equals(item.Name, name, StringComparison.Ordinal)) { item.ViewType = viewType; + item.Name = name; item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); } diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs index 6e9a38fd34..6624d0125f 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs @@ -99,7 +99,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV args.LibraryOptions.SeasonZeroDisplayName : string.Format( CultureInfo.InvariantCulture, - _localization.GetLocalizedString("NameSeasonNumber"), + _localization.GetServerLocalizedString("NameSeasonNumber"), seasonNumber, args.LibraryOptions.PreferredMetadataLanguage); } diff --git a/Emby.Server.Implementations/Library/UserViewManager.cs b/Emby.Server.Implementations/Library/UserViewManager.cs index 49d76e195d..47b3891901 100644 --- a/Emby.Server.Implementations/Library/UserViewManager.cs +++ b/Emby.Server.Implementations/Library/UserViewManager.cs @@ -112,7 +112,7 @@ namespace Emby.Server.Implementations.Library if (_config.Configuration.EnableFolderView) { - var name = _localizationManager.GetLocalizedString("Folders"); + var name = _localizationManager.GetServerLocalizedString("Folders"); list.Add(_libraryManager.GetNamedView(name, CollectionType.folders, string.Empty)); } @@ -168,7 +168,7 @@ namespace Emby.Server.Implementations.Library public UserView GetUserSubView(Guid parentId, CollectionType? type, string localizationKey, string sortName) { - var name = _localizationManager.GetLocalizedString(localizationKey); + var name = _localizationManager.GetServerLocalizedString(localizationKey); return GetUserSubViewWithName(name, parentId, type, sortName); } @@ -191,7 +191,7 @@ namespace Emby.Server.Implementations.Library return GetUserView((Folder)parents[0], viewType, string.Empty); } - var name = _localizationManager.GetLocalizedString(localizationKey); + var name = _localizationManager.GetServerLocalizedString(localizationKey); return _libraryManager.GetNamedView(user, name, viewType, sortName); } diff --git a/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs b/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs new file mode 100644 index 0000000000..3fc2387e09 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260825200000_ConsolidateLocalizedUserViews.cs @@ -0,0 +1,334 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using Jellyfin.Database.Implementations.Enums; +using Jellyfin.Server.ServerSetupApp; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; +using MediaBrowser.Model.IO; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// +/// Moves the views whose id used to be derived from their localized name onto their name independent id. +/// +[JellyfinMigration("2026-08-25T20:00:00", nameof(ConsolidateLocalizedUserViews))] +[JellyfinMigrationBackup(JellyfinDb = true)] +internal class ConsolidateLocalizedUserViews : IAsyncMigrationRoutine +{ + private readonly IStartupLogger _logger; + private readonly ILibraryManager _libraryManager; + private readonly IServerConfigurationManager _configurationManager; + private readonly IFileSystem _fileSystem; + private readonly IDbContextFactory _dbProvider; + + /// + /// Initializes a new instance of the class. + /// + /// The startup logger. + /// The library manager. + /// The server configuration manager. + /// The file system. + /// The database context factory. + public ConsolidateLocalizedUserViews( + IStartupLogger logger, + ILibraryManager libraryManager, + IServerConfigurationManager configurationManager, + IFileSystem fileSystem, + IDbContextFactory dbProvider) + { + _logger = logger; + _libraryManager = libraryManager; + _configurationManager = configurationManager; + _fileSystem = fileSystem; + _dbProvider = dbProvider; + } + + /// + public async Task PerformAsync(CancellationToken cancellationToken) + { + // The Live TV view is the one that hurts: every channel and program is parented to it, so a + // translation update or a change of UI culture used to leave them behind under a view nothing + // looks up any more. + var views = _libraryManager.GetItemList(new InternalItemsQuery + { + IncludeItemTypes = [BaseItemKind.UserView] + }).OfType().Where(view => view.ViewType.HasValue).ToArray(); + + if (views.Length == 0) + { + return; + } + + var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + foreach (var group in views.GroupBy(view => view.ViewType!.Value)) + { + cancellationToken.ThrowIfCancellationRequested(); + + var viewType = group.Key; + var folderName = _fileSystem.GetValidFilename(viewType.ToString()); + var path = Path.Combine(_configurationManager.ApplicationPaths.InternalMetadataPath, "views", folderName); + + // Only the views created for a view type as a whole are named after it. The per user and + // per parent ones get a folder of their own, and carry no children to lose. Match on the + // folder rather than the whole path so a metadata directory that has since moved still + // lines up. + var candidates = group + .Where(view => !string.IsNullOrEmpty(view.Path) + && string.Equals(Path.GetFileName(view.Path.TrimEnd(Path.DirectorySeparatorChar)), folderName, StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (candidates.Length == 0) + { + continue; + } + + // Mirrors LibraryManager.GetNamedView(name, viewType, sortName). + var canonicalId = _libraryManager.GetNewItemId(path + "_namedview_" + viewType.ToString(), typeof(UserView)); + + var stale = candidates.Where(view => !view.Id.Equals(canonicalId)).ToArray(); + if (stale.Length == 0) + { + continue; + } + + await ConsolidateAsync(dbContext, viewType, path, canonicalId, candidates, stale, cancellationToken).ConfigureAwait(false); + } + } + } + + private async Task ConsolidateAsync( + JellyfinDbContext dbContext, + CollectionType viewType, + string path, + Guid canonicalId, + IReadOnlyList candidates, + IReadOnlyList stale, + CancellationToken cancellationToken) + { + var staleIds = stale.Select(view => view.Id).ToArray(); + Guid? newParentId = canonicalId; + var sourceId = Guid.Empty; + + if (!candidates.Any(view => view.Id.Equals(canonicalId))) + { + // Whichever of the old views the items ended up under is the one worth keeping, so give the + // canonical id a copy of it. + var source = await PickSourceAsync(dbContext, stale, staleIds, cancellationToken).ConfigureAwait(false); + sourceId = source.Id; + + _libraryManager.CreateItem( + new UserView + { + Path = path, + Id = canonicalId, + DateCreated = source.DateCreated, + DateModified = source.DateModified, + Name = source.Name, + ViewType = viewType, + ForcedSortName = source.ForcedSortName + }, + null); + } + + var reparented = await dbContext.BaseItems + .Where(e => e.ParentId.HasValue) + .WhereOneOrMany(staleIds, e => e.ParentId!.Value) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.ParentId, newParentId), cancellationToken) + .ConfigureAwait(false); + + await dbContext.BaseItems + .Where(e => e.TopParentId.HasValue) + .WhereOneOrMany(staleIds, e => e.TopParentId!.Value) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.TopParentId, newParentId), cancellationToken) + .ConfigureAwait(false); + + await MoveAncestorsAsync(dbContext, canonicalId, staleIds, cancellationToken).ConfigureAwait(false); + await MoveUserSettingsAsync(dbContext, canonicalId, sourceId, staleIds, cancellationToken).ConfigureAwait(false); + + // Nothing points at them any more, and BaseItems cascades on ParentId, so this has to come last. + await dbContext.BaseItems + .WhereOneOrMany(staleIds, e => e.Id) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + + _logger.LogInformation( + "Moved {Reparented} items and dropped {Stale} stale {ViewType} views in favour of {CanonicalId}", + reparented, + staleIds.Length, + viewType, + canonicalId); + } + + private async Task PickSourceAsync( + JellyfinDbContext dbContext, + IReadOnlyList stale, + IReadOnlyList staleIds, + CancellationToken cancellationToken) + { + var childCounts = await dbContext.BaseItems + .Where(e => e.ParentId.HasValue) + .WhereOneOrMany(staleIds, e => e.ParentId!.Value) + .GroupBy(e => e.ParentId!.Value) + .Select(g => new { ParentId = g.Key, Count = g.Count() }) + .ToDictionaryAsync(e => e.ParentId, e => e.Count, cancellationToken) + .ConfigureAwait(false); + + return stale + .OrderByDescending(view => childCounts.GetValueOrDefault(view.Id)) + .ThenBy(view => view.DateCreated) + .First(); + } + + private static async Task MoveUserSettingsAsync( + JellyfinDbContext dbContext, + Guid canonicalId, + Guid sourceId, + IReadOnlyList staleIds, + CancellationToken cancellationToken) + { + // Everything below is keyed by the view's id, and a view holding no children still holds the + // ordering it was given and whether it was hidden. Only the view that was promoted can hand + // those over - the rest would collide on the one row per user, item and client - so the others + // are dropped instead. + var dropped = staleIds.Where(id => !id.Equals(sourceId)).ToArray(); + + if (!sourceId.Equals(Guid.Empty)) + { + var moved = new[] { sourceId }; + + await dbContext.DisplayPreferences + .WhereOneOrMany(moved, e => e.ItemId) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.ItemId, canonicalId), cancellationToken) + .ConfigureAwait(false); + + await dbContext.ItemDisplayPreferences + .WhereOneOrMany(moved, e => e.ItemId) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.ItemId, canonicalId), cancellationToken) + .ConfigureAwait(false); + + await dbContext.CustomItemDisplayPreferences + .WhereOneOrMany(moved, e => e.ItemId) + .ExecuteUpdateAsync(e => e.SetProperty(f => f.ItemId, canonicalId), cancellationToken) + .ConfigureAwait(false); + } + + if (dropped.Length > 0) + { + await dbContext.DisplayPreferences.WhereOneOrMany(dropped, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + await dbContext.ItemDisplayPreferences.WhereOneOrMany(dropped, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + await dbContext.CustomItemDisplayPreferences.WhereOneOrMany(dropped, e => e.ItemId).ExecuteDeleteAsync(cancellationToken).ConfigureAwait(false); + } + + var stale = staleIds.ToHashSet(); + var preferences = await dbContext.Preferences + .Where(e => e.Kind == PreferenceKind.OrderedViews || e.Kind == PreferenceKind.MyMediaExcludes) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + var changed = false; + + foreach (var preference in preferences) + { + var values = preference.Value.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var rewritten = new List(values.Length); + var seen = new HashSet(); + var touched = false; + + foreach (var value in values) + { + // Clients write these in both the dashed and the plain form, so compare them parsed. + if (!Guid.TryParse(value, out var parsed)) + { + rewritten.Add(value); + continue; + } + + var isStale = stale.Contains(parsed); + if (isStale) + { + parsed = canonicalId; + touched = true; + } + + // The same view can be listed twice once both of its ids point at the same place. + if (!seen.Add(parsed)) + { + continue; + } + + rewritten.Add(isStale + ? parsed.ToString(value.Contains('-', StringComparison.Ordinal) ? "D" : "N", CultureInfo.InvariantCulture) + : value); + } + + if (!touched) + { + continue; + } + + preference.Value = string.Join(',', rewritten); + changed = true; + } + + if (changed) + { + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } + } + + private static async Task MoveAncestorsAsync( + JellyfinDbContext dbContext, + Guid canonicalId, + IReadOnlyList staleIds, + CancellationToken cancellationToken) + { + var items = await dbContext.AncestorIds + .WhereOneOrMany(staleIds, e => e.ParentItemId) + .Select(e => e.ItemId) + .Distinct() + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + await dbContext.AncestorIds + .WhereOneOrMany(staleIds, e => e.ParentItemId) + .ExecuteDeleteAsync(cancellationToken) + .ConfigureAwait(false); + + if (items.Count == 0) + { + return; + } + + // The pair is the primary key, so anything already recorded against the canonical view stays put. + var existing = await dbContext.AncestorIds + .Where(e => e.ParentItemId.Equals(canonicalId)) + .Select(e => e.ItemId) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + foreach (var itemId in items.Except(existing)) + { + dbContext.AncestorIds.Add(new AncestorId + { + ItemId = itemId, + ParentItemId = canonicalId, + Item = null!, + ParentItem = null! + }); + } + + await dbContext.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/Jellyfin.LiveTv/LiveTvManager.cs b/src/Jellyfin.LiveTv/LiveTvManager.cs index 173d3c3e8e..2edf7681db 100644 --- a/src/Jellyfin.LiveTv/LiveTvManager.cs +++ b/src/Jellyfin.LiveTv/LiveTvManager.cs @@ -1262,7 +1262,7 @@ namespace Jellyfin.LiveTv public Folder GetInternalLiveTvFolder(CancellationToken cancellationToken) { - var name = _localization.GetLocalizedString("HeaderLiveTV"); + var name = _localization.GetServerLocalizedString("HeaderLiveTV"); return _libraryManager.GetNamedView(name, CollectionType.livetv, name); } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs index 133a3f7d47..feb2d8a625 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/SeasonResolverTests.cs @@ -21,7 +21,7 @@ namespace Jellyfin.Server.Implementations.Tests.Library { var localizationMock = new Mock(); localizationMock - .Setup(l => l.GetLocalizedString(It.IsAny())) + .Setup(l => l.GetServerLocalizedString(It.IsAny())) .Returns("Season {0}"); _resolver = new SeasonResolver( -- cgit v1.2.3 From 79c37bfcd5c823711bb7675d5ec46f158c1154df Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Tue, 25 Aug 2026 21:00:05 +0200 Subject: Build a TMDb series cast from the aggregated credits --- .../Plugins/Tmdb/TV/TmdbSeriesProvider.cs | 41 ++------ .../Plugins/Tmdb/TmdbClientManager.cs | 2 +- MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs | 97 +++++++++++++++++++ .../Tmdb/TmdbUtilsCastTests.cs | 105 +++++++++++++++++++++ 4 files changed, 212 insertions(+), 33 deletions(-) create mode 100644 tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsCastTests.cs diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs index 9e201f2d7c..6163e20194 100755 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs @@ -363,39 +363,16 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV { var config = Plugin.Instance.Configuration; - if (seriesResult.Credits?.Cast is not null) + // The aggregated credits are what hold an actor's several characters apart; the flat ones + // put them in a single string. Only the aggregated list carries the whole run, so prefer it + // and fall back for the rare show TMDb has no aggregation for. + var cast = seriesResult.AggregateCredits?.Cast is { Count: > 0 } aggregated + ? TmdbUtils.MapAggregateCast(aggregated, config, _tmdbClientManager.GetProfileUrl) + : TmdbUtils.MapCast(seriesResult.Credits?.Cast, config, _tmdbClientManager.GetProfileUrl); + + foreach (var actor in cast) { - IEnumerable castQuery = seriesResult.Credits.Cast.OrderBy(a => a.Order); - - if (config.HideMissingCastMembers) - { - castQuery = castQuery.Where(a => !string.IsNullOrEmpty(a.ProfilePath)); - } - - foreach (var actor in castQuery.Take(config.MaxCastMembers)) - { - if (string.IsNullOrWhiteSpace(actor.Name)) - { - continue; - } - - var personInfo = new PersonInfo - { - Name = actor.Name.Trim(), - Role = actor.Character?.Trim() ?? string.Empty, - Type = PersonKind.Actor, - SortOrder = actor.Order, - // NOTE: Null values are filtered out above - ImageUrl = _tmdbClientManager.GetProfileUrl(actor.ProfilePath!) - }; - - if (actor.Id > 0) - { - personInfo.SetProviderId(MetadataProvider.Tmdb, actor.Id.ToString(CultureInfo.InvariantCulture)); - } - - yield return personInfo; - } + yield return actor; } if (seriesResult.Credits?.Crew is not null) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs index c8e3a7aa52..5379796465 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbClientManager.cs @@ -137,7 +137,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb await EnsureClientConfigAsync().ConfigureAwait(false); - var extraMethods = TvShowMethods.Credits | TvShowMethods.Images | TvShowMethods.ExternalIds | TvShowMethods.Videos | TvShowMethods.ContentRatings | TvShowMethods.EpisodeGroups; + var extraMethods = TvShowMethods.Credits | TvShowMethods.CreditsAggregate | TvShowMethods.Images | TvShowMethods.ExternalIds | TvShowMethods.Videos | TvShowMethods.ContentRatings | TvShowMethods.EpisodeGroups; if (!(Plugin.Instance?.Configuration.ExcludeTagsSeries).GetValueOrDefault()) { extraMethods |= TvShowMethods.Keywords; diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs index c83174f97f..44a2f7291e 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs @@ -3,10 +3,13 @@ using System.Collections.Frozen; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Globalization; +using System.Linq; using System.Text.RegularExpressions; using Jellyfin.Data.Enums; using MediaBrowser.Model.Entities; using TMDbLib.Objects.General; +using TMDbLib.Objects.TvShows; +using PersonInfo = MediaBrowser.Controller.Entities.PersonInfo; namespace MediaBrowser.Providers.Plugins.Tmdb { @@ -129,6 +132,100 @@ namespace MediaBrowser.Providers.Plugins.Tmdb return PersonKind.Unknown; } + /// + /// Maps an aggregated TMDb cast list, whose entries hold every role their member played. + /// + /// The aggregated cast list, or null. + /// The configuration deciding how much of the cast to keep. + /// Resolves a profile path into an absolute image url. + /// One credit per role played. + internal static IEnumerable MapAggregateCast( + IReadOnlyList? cast, + PluginConfiguration config, + Func getProfileUrl) + { + if (cast is null) + { + yield break; + } + + var billed = cast + .Where(member => !string.IsNullOrWhiteSpace(member.Name)) + .Where(member => !config.HideMissingCastMembers || !string.IsNullOrEmpty(member.ProfilePath)) + .OrderBy(member => member.Order) + .Take(config.MaxCastMembers); + + foreach (var member in billed) + { + // An actor playing several characters over the run gets one aggregated entry holding + // every role, so each of them becomes a credit of its own here. Their own billing puts + // the character they played the longest first. + var characters = member.Roles? + .Where(role => !string.IsNullOrWhiteSpace(role.Character)) + .OrderByDescending(role => role.EpisodeCount) + .Select(role => role.Character!.Trim()) + .ToArray(); + + if (characters is null || characters.Length == 0) + { + characters = [string.Empty]; + } + + foreach (var character in characters) + { + yield return CreateCredit(member.Name!, member.Id, member.ProfilePath, member.Order, character, getProfileUrl); + } + } + } + + /// + /// Maps a TMDb cast list whose entries hold the one character their member is credited for. + /// + /// The cast list, or null. + /// The configuration deciding how much of the cast to keep. + /// Resolves a profile path into an absolute image url. + /// One credit per cast entry. + internal static IEnumerable MapCast( + IReadOnlyList? cast, + PluginConfiguration config, + Func getProfileUrl) + { + if (cast is null) + { + yield break; + } + + var billed = cast + .Where(member => !string.IsNullOrWhiteSpace(member.Name)) + .Where(member => !config.HideMissingCastMembers || !string.IsNullOrEmpty(member.ProfilePath)) + .OrderBy(member => member.Order) + .Take(config.MaxCastMembers); + + foreach (var member in billed) + { + yield return CreateCredit(member.Name!, member.Id, member.ProfilePath, member.Order, member.Character?.Trim() ?? string.Empty, getProfileUrl); + } + } + + private static PersonInfo CreateCredit(string name, int id, string? profilePath, int? order, string role, Func getProfileUrl) + { + var personInfo = new PersonInfo + { + Name = name.Trim(), + Role = role, + Type = PersonKind.Actor, + SortOrder = order, + ImageUrl = getProfileUrl(profilePath) + }; + + if (id > 0) + { + personInfo.SetProviderId(MetadataProvider.Tmdb, id.ToString(CultureInfo.InvariantCulture)); + } + + return personInfo; + } + /// /// Determines whether a video is a trailer. /// diff --git a/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsCastTests.cs b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsCastTests.cs new file mode 100644 index 0000000000..182e7c52eb --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Tmdb/TmdbUtilsCastTests.cs @@ -0,0 +1,105 @@ +using System.Collections.Generic; +using System.Linq; +using Jellyfin.Data.Enums; +using MediaBrowser.Model.Entities; +using MediaBrowser.Providers.Plugins.Tmdb; +using TMDbLib.Objects.TvShows; +using Xunit; + +namespace Jellyfin.Providers.Tests.Tmdb +{ + public class TmdbUtilsCastTests + { + private static readonly PluginConfiguration _config = new() { MaxCastMembers = 10 }; + + [Fact] + public void MapAggregateCast_MemberWithSeveralRoles_YieldsOneCreditPerRole() + { + var cast = new List + { + CreateAggregate("Megumi Toyoguchi", 1, 0, ("Tabby (voice)", 3), ("Mimiru (voice)", 12)) + }; + + var people = TmdbUtils.MapAggregateCast(cast, _config, _ => null).ToArray(); + + // The character they played the longest comes first, which is their own billing. + Assert.Equal(["Mimiru (voice)", "Tabby (voice)"], people.Select(p => p.Role)); + Assert.All(people, p => Assert.Equal("Megumi Toyoguchi", p.Name)); + Assert.All(people, p => Assert.Equal(PersonKind.Actor, p.Type)); + Assert.All(people, p => Assert.Equal("1", p.GetProviderId(MetadataProvider.Tmdb))); + } + + [Fact] + public void MapAggregateCast_MemberWithoutARole_IsStillCredited() + { + var cast = new List { CreateAggregate("Uncredited Actor", 2, 0) }; + + var person = Assert.Single(TmdbUtils.MapAggregateCast(cast, _config, _ => null)); + + Assert.Equal(string.Empty, person.Role); + } + + [Fact] + public void MapAggregateCast_MoreThanConfigured_KeepsTheTopBilled() + { + var cast = Enumerable.Range(0, 5) + .Select(i => CreateAggregate($"Actor {4 - i}", i + 1, 4 - i, ($"Role {4 - i}", 1))) + .ToList(); + + var people = TmdbUtils.MapAggregateCast(cast, new PluginConfiguration { MaxCastMembers = 2 }, _ => null); + + Assert.Equal(["Actor 0", "Actor 1"], people.Select(p => p.Name)); + } + + [Fact] + public void MapAggregateCast_HideMissingCastMembers_DropsTheOnesWithoutAProfile() + { + var withProfile = CreateAggregate("Has Profile", 1, 0, ("Hero", 1)); + withProfile.ProfilePath = "/profile.jpg"; + var cast = new List { withProfile, CreateAggregate("No Profile", 2, 1, ("Villain", 1)) }; + + var people = TmdbUtils.MapAggregateCast( + cast, + new PluginConfiguration { MaxCastMembers = 10, HideMissingCastMembers = true }, + _ => null); + + Assert.Equal(["Has Profile"], people.Select(p => p.Name)); + } + + [Fact] + public void MapCast_FlatCredits_YieldOneCreditEach() + { + var cast = new List + { + new() { Name = "Kevin Conroy", Id = 1, Order = 0, Character = " Batman (voice) " }, + new() { Name = " ", Id = 2, Order = 1, Character = "Nobody" } + }; + + var person = Assert.Single(TmdbUtils.MapCast(cast, _config, _ => null)); + + Assert.Equal("Kevin Conroy", person.Name); + Assert.Equal("Batman (voice)", person.Role); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void MapCast_NoCast_YieldsNothing(bool aggregate) + { + Assert.Empty(aggregate + ? TmdbUtils.MapAggregateCast(null, _config, _ => null) + : TmdbUtils.MapCast(null, _config, _ => null)); + } + + private static CastAggregate CreateAggregate(string name, int id, int order, params (string Character, int Episodes)[] roles) + { + return new CastAggregate + { + Name = name, + Id = id, + Order = order, + Roles = roles.Select(role => new CastRole { Character = role.Character, EpisodeCount = role.Episodes }).ToList() + }; + } + } +} -- cgit v1.2.3 From 1cc490fb190d01c34c3c7bed0f9f8df6e122ade0 Mon Sep 17 00:00:00 2001 From: Dan Bishop Date: Wed, 26 Aug 2026 09:45:28 -0400 Subject: Translated using Weblate (English (United Kingdom)) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/en_GB/ --- Emby.Server.Implementations/Localization/Core/en-GB.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json index 1f69fc1f55..a053fc2da9 100644 --- a/Emby.Server.Implementations/Localization/Core/en-GB.json +++ b/Emby.Server.Implementations/Localization/Core/en-GB.json @@ -120,5 +120,6 @@ "NameExtraThemeSong": "Theme Song", "NameExtraThemeVideo": "Theme Video", "NameExtraTrailer": "Trailer", - "NameExtraUnknown": "Extra" + "NameExtraUnknown": "Extra", + "NameExtraNumbered": "{0} {1}" } -- cgit v1.2.3 From 2aad6047c857bfb4781ffff8c00e3670ff07d70e Mon Sep 17 00:00:00 2001 From: Shadowghost Date: Wed, 26 Aug 2026 18:44:28 +0200 Subject: Fix children count on virtual items --- Emby.Server.Implementations/Dto/DtoService.cs | 6 ++- .../Dto/DtoServiceTests.cs | 57 +++++++++++++++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index 6fa057702c..2462a754ae 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -611,7 +611,11 @@ namespace Emby.Server.Implementations.Dto // For these types we can try to optimize and assume these values will be equal if (item is MusicAlbum || item is Season || item is Playlist) { - dto.ChildCount = dto.RecursiveItemCount; + if (dto.RecursiveItemCount > 0) + { + dto.ChildCount = dto.RecursiveItemCount; + } + var folderChildCount = folder.LinkedChildren.Length; // The default is an empty array, so we can't reliably use the count when it's empty if (folderChildCount > 0) diff --git a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs index 9c247d54b9..bdac59c013 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Dto/DtoServiceTests.cs @@ -1,5 +1,7 @@ using System; +using System.Collections.Generic; using Emby.Server.Implementations.Dto; +using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Common; using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.Drawing; @@ -21,11 +23,13 @@ namespace Jellyfin.Server.Implementations.Tests.Dto; public class DtoServiceTests { private readonly Mock _libraryManagerMock; + private readonly Mock _userDataManagerMock; private readonly DtoService _dtoService; public DtoServiceTests() { _libraryManagerMock = new Mock(); + _userDataManagerMock = new Mock(); var imageProcessor = new Mock(); // Deterministic tag derived from the image so each item gets a distinct, assertable tag. @@ -42,7 +46,7 @@ public class DtoServiceTests _dtoService = new DtoService( NullLogger.Instance, _libraryManagerMock.Object, - new Mock().Object, + _userDataManagerMock.Object, imageProcessor.Object, new Mock().Object, new Mock().Object, @@ -105,6 +109,57 @@ public class DtoServiceTests Assert.Null(dto.ParentPrimaryImageItemId); } + [Fact] + public void GetBaseItemDtos_SeasonWithNoRealEpisodes_ReportsVirtualEpisodesAsChildCount() + { + // No episode has aired yet, so RecursiveItemCount is 0. ChildCount must still report the + // virtual episodes clients get back for the season. This deliberately does not track + // Season.IsVirtualItem: that flag is recomputed only on a full refresh, so a season can + // carry it while already holding real episodes. + var (season, user) = BuildSeason(playedCount: 0, totalCount: 0, childCount: 10); + var options = new DtoOptions(false) { EnableImages = false, Fields = [ItemFields.ChildCount, ItemFields.RecursiveItemCount] }; + + var dto = _dtoService.GetBaseItemDtos([season], options, user, skipVisibilityCheck: true)[0]; + + Assert.Equal(0, dto.RecursiveItemCount); + Assert.Equal(10, dto.ChildCount); + } + + [Fact] + public void GetBaseItemDtos_SeasonWithRealEpisodes_KeepsRecursiveItemCountAsChildCount() + { + var (season, user) = BuildSeason(playedCount: 2, totalCount: 9, childCount: 11); + var options = new DtoOptions(false) { EnableImages = false, Fields = [ItemFields.ChildCount, ItemFields.RecursiveItemCount] }; + + var dto = _dtoService.GetBaseItemDtos([season], options, user, skipVisibilityCheck: true)[0]; + + Assert.Equal(9, dto.RecursiveItemCount); + // The shortcut still wins over the batched child count, which also counts virtual episodes. + Assert.Equal(9, dto.ChildCount); + } + + private (Season Season, User User) BuildSeason(int playedCount, int totalCount, int childCount) + { + var user = new User("user", "auth-provider", "reset-provider"); + var season = new Season { Id = Guid.NewGuid(), Name = "Season 2", SeriesId = Guid.NewGuid() }; + + _userDataManagerMock + .Setup(x => x.GetUserDataBatch(It.IsAny>(), user)) + .Returns(new Dictionary { [season.Id] = new UserItemData { Key = "key" } }); + _userDataManagerMock + .Setup(x => x.GetResumeUserDataBatch(It.IsAny>(), user)) + .Returns(new Dictionary()); + + _libraryManagerMock + .Setup(x => x.GetPlayedAndTotalCountBatch(It.IsAny>(), user)) + .Returns(new Dictionary { [season.Id] = (playedCount, totalCount) }); + _libraryManagerMock + .Setup(x => x.GetChildCountBatch(It.IsAny>(), It.IsAny())) + .Returns(new Dictionary { [season.Id] = childCount }); + + return (season, user); + } + private (Episode Episode, Season Season, Series Series) BuildEpisode(bool seasonHasPoster, bool seriesHasPoster = true) { // Non-local (http) paths keep aspect-ratio resolution off the image processor and on the -- cgit v1.2.3 From 6da85a0aaad557bc220f1a017a52dbc3f04e9a59 Mon Sep 17 00:00:00 2001 From: theguymadmax Date: Wed, 26 Aug 2026 21:51:31 -0400 Subject: Fix ParentId for episodes in virtual seasons --- MediaBrowser.Providers/TV/SeriesMetadataService.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs index b350f482c3..803fab538f 100644 --- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs +++ b/MediaBrowser.Providers/TV/SeriesMetadataService.cs @@ -364,7 +364,7 @@ public class SeriesMetadataService : MetadataService foreach (var episode in episodes) { var season = seasons.FirstOrDefault(i => i.IndexNumber == episode.ParentIndexNumber); - if (season is null || episode.SeasonId.Equals(season.Id)) + if (season is null || (episode.SeasonId.Equals(season.Id) && episode.ParentId.Equals(season.Id))) { continue; } @@ -372,6 +372,11 @@ public class SeriesMetadataService : MetadataService // Assign the correct season id and name to episode. episode.SeasonId = season.Id; episode.SeasonName = season.Name; + + // We need to set ParentId here for episodes in virtual seasons (e.g., flat structures), otherwise it retains the + // ParentId from the series. + episode.SetParent(season); + await episode.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false); } } -- cgit v1.2.3 From 7116d3bb72b2635c3c8cd42c03d9519f4c3d549f Mon Sep 17 00:00:00 2001 From: Pavel Miniutka Date: Fri, 28 Aug 2026 03:26:27 -0400 Subject: Translated using Weblate (Belarusian) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/be/ --- Emby.Server.Implementations/Localization/Core/be.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/be.json b/Emby.Server.Implementations/Localization/Core/be.json index 5d0ef65842..97680fdd3d 100644 --- a/Emby.Server.Implementations/Localization/Core/be.json +++ b/Emby.Server.Implementations/Localization/Core/be.json @@ -106,5 +106,6 @@ "TaskExtractMediaSegments": "Сканіраванне медыя-сегмента", "TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay", "CleanupUserDataTask": "Задача па ачыстцы даных карыстальніка", - "CleanupUserDataTaskDescription": "Ачышчае ўсе даныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён." + "CleanupUserDataTaskDescription": "Ачышчае ўсе даныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён.", + "LyricDownloadFailureFromForItem": "Не ўдалося загрузіць тэкст песні з {0} для {1}" } -- cgit v1.2.3 From b46e66627c05b64f09e2c533cf19f1d0ddd6f174 Mon Sep 17 00:00:00 2001 From: Pavel Miniutka Date: Fri, 28 Aug 2026 03:27:31 -0400 Subject: Translated using Weblate (Belarusian) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/be/ --- Emby.Server.Implementations/Localization/Core/be.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/be.json b/Emby.Server.Implementations/Localization/Core/be.json index 97680fdd3d..7189ae82f0 100644 --- a/Emby.Server.Implementations/Localization/Core/be.json +++ b/Emby.Server.Implementations/Localization/Core/be.json @@ -107,5 +107,7 @@ "TaskMoveTrickplayImages": "Перанесці месцазнаходжанне выявы Trickplay", "CleanupUserDataTask": "Задача па ачыстцы даных карыстальніка", "CleanupUserDataTaskDescription": "Ачышчае ўсе даныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён.", - "LyricDownloadFailureFromForItem": "Не ўдалося загрузіць тэкст песні з {0} для {1}" + "LyricDownloadFailureFromForItem": "Не ўдалося загрузіць тэкст песні з {0} для {1}", + "NameExtraDeletedScene": "Выдаленая сцэна", + "NameExtraInterview": "Інтэрв'ю" } -- cgit v1.2.3 From 6ad1e341b18432a7c7309cbd3f744cf6c2cb5ffe Mon Sep 17 00:00:00 2001 From: Pavel Miniutka Date: Fri, 28 Aug 2026 03:43:14 -0400 Subject: Translated using Weblate (Belarusian) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/be/ --- Emby.Server.Implementations/Localization/Core/be.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Emby.Server.Implementations/Localization/Core/be.json b/Emby.Server.Implementations/Localization/Core/be.json index 7189ae82f0..49ebc45f06 100644 --- a/Emby.Server.Implementations/Localization/Core/be.json +++ b/Emby.Server.Implementations/Localization/Core/be.json @@ -109,5 +109,8 @@ "CleanupUserDataTaskDescription": "Ачышчае ўсе даныя карыстальніка (стан прагляду, абранае і г.д.) для медыяфайлаў, што адсутнічаюць больш за 90 дзён.", "LyricDownloadFailureFromForItem": "Не ўдалося загрузіць тэкст песні з {0} для {1}", "NameExtraDeletedScene": "Выдаленая сцэна", - "NameExtraInterview": "Інтэрв'ю" + "NameExtraInterview": "Інтэрв'ю", + "NameExtraNumbered": "{0} {1}", + "NameExtraScene": "Сцэна", + "NameExtraTrailer": "Трэйлер" } -- cgit v1.2.3 From fbb0f1afbcd52a15d6e56742bba0f338a5ced88f Mon Sep 17 00:00:00 2001 From: krvi <27792771+krvi@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:08:24 -0400 Subject: Translated using Weblate (Faroese) Translation: Jellyfin/Jellyfin Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/ --- Emby.Server.Implementations/Localization/Core/fo.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json index 377ad8d69e..6aa72908cb 100644 --- a/Emby.Server.Implementations/Localization/Core/fo.json +++ b/Emby.Server.Implementations/Localization/Core/fo.json @@ -13,7 +13,7 @@ "FailedLoginAttemptWithUserName": "Miseydnað innritanarroynd frá {0}", "HeaderFavoriteEpisodes": "Yndispartar", "LabelIpAddressValue": "IP-atsetur: {0}", - "AuthenticationSucceededWithUserName": "{0} varð samgildur", + "AuthenticationSucceededWithUserName": "{0} var samgildur", "HeaderFavoriteShows": "Yndisrøðir", "HeaderLiveTV": "Beinleiðis sjónvarp", "HearingImpaired": "Hoyrnarveik", @@ -68,7 +68,7 @@ "NotificationOptionServerRestartRequired": "Tørvur er á ambætaraendurbyrjan", "TasksApplicationCategory": "Nýtsluskipan", "NotificationOptionApplicationUpdateAvailable": "Skipanardagføring er tøk", - "NotificationOptionApplicationUpdateInstalled": "Skipanardagføring varð innløgd", + "NotificationOptionApplicationUpdateInstalled": "Skipanardagføring var innløgd", "UserStoppedPlayingItemWithValues": "{0} er liðugur at spæla {1} á {2}", "HomeVideos": "Heimaupptøkur", "StartupEmbyServerIsLoading": "Jellyfin-ambætarin er undir byrjanarinnlesing. Vinaliga royn aftur um eitt bil.", -- cgit v1.2.3