diff options
| author | Shadowghost <Ghost_of_Stone@web.de> | 2026-08-01 21:41:16 +0200 |
|---|---|---|
| committer | Shadowghost <Ghost_of_Stone@web.de> | 2026-08-01 21:41:16 +0200 |
| commit | 705368ee495cfb8967d3f1651318fbdb85ad89e6 (patch) | |
| tree | d0c99252541c73ddeaddb036079a00808ab9a955 | |
| parent | cbc2c7c32345f670d928f7840e5fd7422241845d (diff) | |
| parent | e16c8a07bd8e21973a88231eeed83c9699fd58b2 (diff) | |
Merge remote-tracking branch 'upstream/master' into fix-byname-queries
# Conflicts:
# src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs
33 files changed, 3005 insertions, 608 deletions
diff --git a/.github/workflows/issue-stale.yml b/.github/workflows/issue-stale.yml index 9adac6b995..2ad326e894 100644 --- a/.github/workflows/issue-stale.yml +++ b/.github/workflows/issue-stale.yml @@ -16,7 +16,7 @@ jobs: runs-on: ubuntu-latest if: ${{ contains(github.repository, 'jellyfin/') }} steps: - - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 + - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 with: repo-token: ${{ secrets.JF_BOT_TOKEN }} ascending: true diff --git a/.github/workflows/pull-request-stale.yaml b/.github/workflows/pull-request-stale.yaml index f92a9be9d5..2f1a8c7dce 100644 --- a/.github/workflows/pull-request-stale.yaml +++ b/.github/workflows/pull-request-stale.yaml @@ -15,7 +15,7 @@ jobs: runs-on: ubuntu-latest if: ${{ contains(github.repository, 'jellyfin/') }} steps: - - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0 + - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0 with: repo-token: ${{ secrets.JF_BOT_TOKEN }} ascending: true diff --git a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs index 74c1f69616..d6513fc79c 100644 --- a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using Emby.Server.Implementations.Playlists; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Library; @@ -46,6 +47,19 @@ namespace Emby.Server.Implementations.Library.Resolvers }; } + // Anything directly inside the internal playlists folder is a playlist, even when its + // playlist.xml is missing: failing to resolve here makes the library scan treat the + // playlist as deleted from disk and remove it, taking its items with it. + if (args.Parent is PlaylistsFolder) + { + return new Playlist + { + Path = args.Path, + Name = filename, + OpenAccess = true + }; + } + // It's a directory-based playlist if the directory contains a playlist file IEnumerable<string> filePaths; try diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json index d2bba38cb7..3b0bd2ed92 100644 --- a/Emby.Server.Implementations/Localization/Core/fo.json +++ b/Emby.Server.Implementations/Localization/Core/fo.json @@ -51,5 +51,37 @@ "Music": "Tónleikur", "UserStartedPlayingItemWithValues": "{0} spælur {1} á {2}", "HeaderContinueWatching": "Hald áfram at hyggja", - "MusicVideos": "Sjónbandaløg" + "MusicVideos": "Sjónbandaløg", + "TaskUpdatePluginsDescription": "Niðurtekur og innleggur dagføringar til ískoytisforrit ið eru stillaði til at dagførast sjálvvirkandi.", + "TaskCleanTranscodeDescription": "Strikar umkotaðar fílar ið eru eldri enn 1 dag.", + "TaskOptimizeDatabase": "Albøt dátugrunn", + "NameSeasonNumber": "Sesong {0}", + "NameSeasonUnknown": "Ókend sesong", + "ScheduledTaskFailedWithName": "{0} miseydnaðist", + "Undefined": "Óskilmarkað", + "TasksMaintenanceCategory": "Viðlíkahald", + "TaskCleanLogs": "Reinsa gerðalistaskjáttu", + "UserOnlineFromDevice": "{0} er íbundin frá {1}", + "HeaderNextUp": "Næst á skránni", + "NotificationOptionPluginError": "Brek í ískoytisforriti", + "NotificationOptionInstallationFailed": "Innleggingarbrek", + "NotificationOptionServerRestartRequired": "Tørvur er á ambætaraendurbyrjan", + "TasksApplicationCategory": "Nýtsluskipan", + "NotificationOptionApplicationUpdateAvailable": "Skipanardagføring er tøk", + "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.", + "UserOfflineFromDevice": "{0} breyt av á {1}", + "UserPasswordChangedWithName": "Loyniorðið hjá brúkaranum {0} er broytt", + "TasksChannelsCategory": "Alnetsrásir", + "TaskCleanActivityLog": "Reinsa virksemisskrá", + "TaskCleanActivityLogDescription": "Strikar skrásetingar eldri enn ásetta aldur.", + "TaskCleanCache": "Reinsa kovaskjáttu", + "TaskCleanCacheDescription": "Strikar kovafílar ið kervið ikki hevur tørv á longur.", + "TaskCleanTranscode": "Reinsa umkotuskjáttu", + "TaskDownloadMissingLyrics": "Niðurtak vantandi sangtekstir", + "TaskDownloadMissingSubtitles": "Niðurtak vantandi undirtekstir", + "CleanupUserDataTaskDescription": "Strikar allar brúkaradátur, so sum spælistøðu, yndislistastøðu o.s.fr., fyri miðlar ið ikki hava verið tøkir í í minsta lagi 90 dagar.", + "CleanupUserDataTask": "Koyrsla ið reinsar brúkaradátur" } diff --git a/Emby.Server.Implementations/Localization/Core/oc.json b/Emby.Server.Implementations/Localization/Core/oc.json index cad5640763..4f573b3c58 100644 --- a/Emby.Server.Implementations/Localization/Core/oc.json +++ b/Emby.Server.Implementations/Localization/Core/oc.json @@ -1,3 +1,27 @@ { - "AppDeviceValues": "Aplicacion: {0}, Periferic: {1}" + "AppDeviceValues": "Aplicacion: {0}, Periferic: {1}", + "Books": "Libres", + "Artists": "Artistas", + "Collections": "Collecciones", + "ChapterNameValue": "Capitol {0}", + "External": "Extèrn", + "Folders": "Dorsièrs", + "Favorites": "Favorits", + "HeaderContinueWatching": "Contunhar de regardar", + "HeaderFavoriteEpisodes": "Episòdis Favorits", + "AuthenticationSucceededWithUserName": "{0} autentificat amb succès", + "HeaderFavoriteShows": "Serias Favoritas", + "HeaderLiveTV": "TV en dirècte", + "HeaderNextUp": "Seguent", + "HearingImpaired": "Amb de deficiéncias auditivas", + "Movies": "Filmes", + "Music": "Musica", + "Latest": "Darrièr", + "Forced": "Forçat", + "Default": "Defaut", + "Genres": "Genres", + "HomeVideos": "Vidèos d'Acuèlh", + "Inherit": "Eiretar", + "LabelIpAddressValue": "Adreça IP: {0}", + "LabelRunningTimeValue": "Temps d'execucion : {0}" } diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index 409414139c..308faed8cc 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -219,28 +219,15 @@ namespace Emby.Server.Implementations.Playlists var playlist = _libraryManager.GetItemById(playlistId) as Playlist ?? throw new ArgumentException("No Playlist exists with Id " + playlistId); - // Retrieve all the items to be added to the playlist + // Retrieve all the items to be added to the playlist. var newItems = GetPlaylistItems(newItemIds, user, options) .Where(i => i.SupportsAddingToPlaylist); - // Filter out duplicate items - var existingIds = playlist.LinkedChildren.Select(c => c.ItemId).ToHashSet(); - newItems = newItems - .Where(i => !existingIds.Contains(i.Id)) - .Distinct(); - // Create a list of the new linked children to add to the playlist var childrenToAdd = newItems .Select(LinkedChild.Create) .ToList(); - // Log duplicates that have been ignored, if any - int numDuplicates = newItemIds.Count - childrenToAdd.Count; - if (numDuplicates > 0) - { - _logger.LogWarning("Ignored adding {DuplicateCount} duplicate items to playlist {PlaylistName}.", numDuplicates, playlist.Name); - } - // Do nothing else if there are no items to add to the playlist if (childrenToAdd.Count == 0) { diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index 838f48949d..4aa728b5bf 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -1607,8 +1607,9 @@ public class DynamicHlsController : BaseJellyfinApiController if (state.VideoStream is not null && state.IsOutputVideo) { - // fMP4 needs this flag to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT - hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont"; + // fMP4 needs frag_discont to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT + // HLS does not use SIDX, and skipping it avoids FFmpeg rewriting open-GOP boundary packet PTS + hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont+skip_sidx"; } segmentFormat = "fmp4" + outputFmp4HeaderArg; diff --git a/Jellyfin.Api/Controllers/UserLibraryController.cs b/Jellyfin.Api/Controllers/UserLibraryController.cs index a718035528..ea134a4619 100644 --- a/Jellyfin.Api/Controllers/UserLibraryController.cs +++ b/Jellyfin.Api/Controllers/UserLibraryController.cs @@ -40,6 +40,7 @@ public class UserLibraryController : BaseJellyfinApiController private readonly IDtoService _dtoService; private readonly IUserViewManager _userViewManager; private readonly IFileSystem _fileSystem; + private readonly IProviderManager _providerManager; /// <summary> /// Initializes a new instance of the <see cref="UserLibraryController"/> class. @@ -50,13 +51,15 @@ public class UserLibraryController : BaseJellyfinApiController /// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param> /// <param name="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param> /// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param> + /// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param> public UserLibraryController( IUserManager userManager, IUserDataManager userDataRepository, ILibraryManager libraryManager, IDtoService dtoService, IUserViewManager userViewManager, - IFileSystem fileSystem) + IFileSystem fileSystem, + IProviderManager providerManager) { _userManager = userManager; _userDataRepository = userDataRepository; @@ -64,6 +67,7 @@ public class UserLibraryController : BaseJellyfinApiController _dtoService = dtoService; _userViewManager = userViewManager; _fileSystem = fileSystem; + _providerManager = providerManager; } /// <summary> @@ -75,7 +79,7 @@ public class UserLibraryController : BaseJellyfinApiController /// <returns>An <see cref="OkResult"/> containing the item.</returns> [HttpGet("Items/{itemId}")] [ProducesResponseType(StatusCodes.Status200OK)] - public async Task<ActionResult<BaseItemDto>> GetItem( + public ActionResult<BaseItemDto> GetItem( [FromQuery] Guid? userId, [FromRoute, Required] Guid itemId) { @@ -94,7 +98,7 @@ public class UserLibraryController : BaseJellyfinApiController return NotFound(); } - await RefreshItemOnDemandIfNeeded(item).ConfigureAwait(false); + QueueRefreshOnDemandIfNeeded(item); var dtoOptions = new DtoOptions(); @@ -112,7 +116,7 @@ public class UserLibraryController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status200OK)] [Obsolete("Kept for backwards compatibility")] [ApiExplorerSettings(IgnoreApi = true)] - public Task<ActionResult<BaseItemDto>> GetItemLegacy( + public ActionResult<BaseItemDto> GetItemLegacy( [FromRoute, Required] Guid userId, [FromRoute, Required] Guid itemId) => GetItem(userId, itemId); @@ -639,25 +643,28 @@ public class UserLibraryController : BaseJellyfinApiController limit, groupItems); - private async Task RefreshItemOnDemandIfNeeded(BaseItem item) + private void QueueRefreshOnDemandIfNeeded(BaseItem item) { - if (item is Person) + if (item is not Person) { - var hasMetadata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary); - var performFullRefresh = !hasMetadata && (DateTime.UtcNow - item.DateLastRefreshed).TotalDays >= 3; + return; + } - if (performFullRefresh) - { - var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - MetadataRefreshMode = MetadataRefreshMode.FullRefresh, - ImageRefreshMode = MetadataRefreshMode.FullRefresh, - ForceSave = true - }; - - await item.RefreshMetadata(options, CancellationToken.None).ConfigureAwait(false); - } + var hasMetadata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary); + if (hasMetadata || (DateTime.UtcNow - item.DateLastRefreshed).TotalDays < 3) + { + return; } + + _providerManager.QueueRefresh( + item.Id, + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + MetadataRefreshMode = MetadataRefreshMode.FullRefresh, + ImageRefreshMode = MetadataRefreshMode.FullRefresh, + ForceSave = true + }, + RefreshPriority.High); } /// <summary> diff --git a/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs b/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs index d70ac672f2..0f166fc6e0 100644 --- a/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs +++ b/Jellyfin.Server.Implementations/Extensions/ExpressionExtensions.cs @@ -40,6 +40,19 @@ public static class ExpressionExtensions } /// <summary> + /// Negates a predicate. + /// </summary> + /// <typeparam name="T">The predicate parameter type.</typeparam> + /// <param name="predicate">The predicate expression to negate.</param> + /// <returns>A new expression representing the negation of the input predicate.</returns> + public static Expression<Func<T, bool>> Not<T>(this Expression<Func<T, bool>> predicate) + { + ArgumentNullException.ThrowIfNull(predicate); + + return Expression.Lambda<Func<T, bool>>(Expression.Not(predicate.Body), predicate.Parameters); + } + + /// <summary> /// Combines two predicates into a single predicate using a logical AND operation. /// </summary> /// <typeparam name="T">The predicate parameter type.</typeparam> diff --git a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs index c64e6ac068..958d11e21e 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemMapper.cs @@ -183,7 +183,7 @@ public static class BaseItemMapper if (dto is Folder folder) { folder.DateLastMediaAdded = entity.DateLastMediaAdded ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc); - if (entity.LinkedChildEntities is not null && entity.LinkedChildEntities.Count > 0) + if (entity.LinkedChildEntities is not null) { folder.LinkedChildren = entity.LinkedChildEntities .OrderBy(e => e.SortOrder) diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs index 96a23d2d12..5aa2d7c46b 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs @@ -621,62 +621,31 @@ public sealed partial class BaseItemRepository } /// <inheritdoc /> - public IQueryable<Guid> GetFullyPlayedFolderIdsQuery(JellyfinDbContext context, IQueryable<Guid> folderIds, User user) + public IQueryable<BaseItemEntity> GetAccessFilteredLeafItemsQuery(JellyfinDbContext context, User user, bool includeOwnedItems = false) { ArgumentNullException.ThrowIfNull(context); - ArgumentNullException.ThrowIfNull(folderIds); ArgumentNullException.ThrowIfNull(user); - var filter = new InternalItemsQuery(user); - var userId = user.Id; - var leafItems = context.BaseItems .AsNoTracking() - .Where(b => !b.IsFolder && !b.IsVirtualItem); - leafItems = ApplyAccessFiltering(context, leafItems, filter); - - var playedLeafItems = leafItems - .Select(b => new { b.Id, Played = b.UserData!.Any(ud => ud.UserId == userId && ud.Played) }); - - var ancestorLeaves = context.AncestorIds - .Where(a => folderIds.Contains(a.ParentItemId)) - .Join( - playedLeafItems, - a => a.ItemId, - b => b.Id, - (a, b) => new { FolderId = a.ParentItemId, b.Id, b.Played }); + .Where(e => !e.IsFolder && !e.IsVirtualItem); - var linkedLeaves = context.LinkedChildren - .Where(lc => folderIds.Contains(lc.ParentId)) - .Join( - playedLeafItems, - lc => lc.ChildId, - b => b.Id, - (lc, b) => new { FolderId = lc.ParentId, b.Id, b.Played }); + return ApplyAccessFiltering(context, leafItems, new InternalItemsQuery(user) { IncludeOwnedItems = includeOwnedItems }); + } - var linkedFolderLeaves = context.LinkedChildren - .Where(lc => folderIds.Contains(lc.ParentId)) - .Join( - context.BaseItems.Where(b => b.IsFolder), - lc => lc.ChildId, - b => b.Id, - (lc, b) => new { lc.ParentId, FolderChildId = b.Id }) - .Join( - context.AncestorIds, - x => x.FolderChildId, - a => a.ParentItemId, - (x, a) => new { x.ParentId, DescendantId = a.ItemId }) - .Join( - playedLeafItems, - x => x.DescendantId, - b => b.Id, - (x, b) => new { FolderId = x.ParentId, b.Id, b.Played }); - - return ancestorLeaves - .Union(linkedLeaves) - .Union(linkedFolderLeaves) - .GroupBy(x => x.FolderId) - .Where(g => g.Select(x => x.Id).Distinct().Count() == g.Where(x => x.Played).Select(x => x.Id).Distinct().Count()) - .Select(g => g.Key); + /// <inheritdoc /> + public Expression<Func<BaseItemEntity, bool>> BuildHasDescendantFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> descendants) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(descendants); + + // Descendants are reachable through the ancestor chain and - for BoxSets and Playlists - as + // linked children, which can themselves be folders contributing their own descendants. + // Every step is a correlated index seek, so only the rows the outer query keeps are visited + // and a folder is left as soon as its first matching descendant is found. + return e => context.AncestorIds.Any(a => a.ParentItemId == e.Id && descendants.Any(d => d.Id == a.ItemId)) + || context.LinkedChildren.Any(lc => lc.ParentId == e.Id + && (descendants.Any(d => d.Id == lc.ChildId) + || context.AncestorIds.Any(a => a.ParentItemId == lc.ChildId && descendants.Any(d => d.Id == a.ItemId)))); } } diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs index fb9bbf0d47..1694e89179 100644 --- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs +++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.TranslateQuery.cs @@ -31,6 +31,10 @@ public sealed partial class BaseItemRepository private static readonly string TmdbProviderName = MetadataProvider.Tmdb.ToString().ToLowerInvariant(); private static readonly string TvdbProviderName = MetadataProvider.Tvdb.ToString().ToLowerInvariant(); + // A fresh expression per access: EF rejects a query tree that reuses one lambda parameter + // instance across several lambdas, and this filter is combined into a tree more than once. + private static Expression<Func<BaseItemEntity, bool>> IsFolderFilter => e => e.IsFolder; + /// <inheritdoc /> public IQueryable<BaseItemEntity> TranslateQuery( IQueryable<BaseItemEntity> baseQuery, @@ -466,97 +470,45 @@ public sealed partial class BaseItemRepository if (filter.IsPlayed.HasValue) { - var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series); - var hasBoxSet = filter.IncludeItemTypes.Contains(BaseItemKind.BoxSet); + var userId = filter.User!.Id; - if (hasSeries || hasBoxSet) - { - var userId = filter.User!.Id; - var isPlayed = filter.IsPlayed.Value; - var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; - var boxSetTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.BoxSet]; - - // Series: played = at least one episode AND all episodes played; unplayed = otherwise. - IQueryable<Guid> playedSeriesIds = hasSeries - ? context.BaseItems - .AsNoTracking() - .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue) - .GroupBy(e => e.SeriesId!.Value) - .Where(g => !g.Any(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played))) - .Select(g => g.Key) - : Enumerable.Empty<Guid>().AsQueryable(); - - // BoxSet: played = all children played. - IQueryable<Guid> playedBoxSetIds = hasBoxSet - ? GetFullyPlayedFolderIdsQuery( - context, - baseQuery.Where(e => e.Type == boxSetTypeName).Select(e => e.Id), - filter.User!) - : Enumerable.Empty<Guid>().AsQueryable(); - - // Non-folder items: check UserData directly - var playedItemIds = context.UserData - .Where(ud => ud.UserId == userId && ud.Played) - .Select(ud => ud.ItemId); - - if (isPlayed) - { - baseQuery = baseQuery.Where(e => - (e.Type == seriesTypeName && playedSeriesIds.Contains(e.Id)) - || (e.Type == boxSetTypeName && playedBoxSetIds.Contains(e.Id)) - || (e.Type != seriesTypeName && e.Type != boxSetTypeName && playedItemIds.Contains(e.Id))); - } - else - { - baseQuery = baseQuery.Where(e => - (e.Type == seriesTypeName && !playedSeriesIds.Contains(e.Id)) - || (e.Type == boxSetTypeName && !playedBoxSetIds.Contains(e.Id)) - || (e.Type != seriesTypeName && e.Type != boxSetTypeName && !playedItemIds.Contains(e.Id))); - } - } - else - { - var playedItemIds = context.UserData - .Where(ud => ud.UserId == filter.User!.Id && ud.Played) - .Select(ud => ud.ItemId); - var isPlayedItem = filter.IsPlayed.Value; - baseQuery = baseQuery.Where(e => playedItemIds.Contains(e.Id) == isPlayedItem); - } + // 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))); + + baseQuery = baseQuery.Where(filter.IsPlayed.Value ? isPlayedFilter : isPlayedFilter.Not()); } if (filter.IsResumable.HasValue) { - var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series); var userId = filter.User!.Id; var isResumable = filter.IsResumable.Value; - var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series]; // In-progress user data rows; alternate versions track their own progress. var inProgress = context.UserData .Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0); - IQueryable<Guid>? resumableSeriesIds = null; - if (hasSeries) - { - // Aggregate per series in a single GROUP BY pass, instead of three full scans. - var seriesEpisodeStats = context.BaseItems - .AsNoTracking() - .Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue) - .GroupBy(e => e.SeriesId!.Value) - .Select(g => new - { - SeriesId = g.Key, - HasInProgress = g.Any(e => e.UserData!.Any(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0)), - HasPlayed = g.Any(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)), - HasUnplayed = g.Any(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)) - }); - - // A series is resumable if it has an in-progress episode, - // or if it has both played and unplayed episodes (partially watched). - resumableSeriesIds = seriesEpisodeStats - .Where(s => s.HasInProgress || (s.HasPlayed && s.HasUnplayed)) - .Select(s => s.SeriesId); - } + // Folders are resumable when a descendant is in progress, or when they hold both played and + // unplayed descendants (partially watched). Alternate versions keep their own progress, so + // they count towards the in-progress check but not towards the played/unplayed one. + var leafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!); + var inProgressLeafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!, includeOwnedItems: true) + .Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0)); + + var folderResumableFilter = BuildHasDescendantFilter(context, inProgressLeafItems) + .Or(BuildHasDescendantFilter(context, leafItems.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played))) + .And(BuildHasDescendantFilter(context, leafItems.Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played))))); if (isResumable) { @@ -564,18 +516,15 @@ public sealed partial class BaseItemRepository // Match each version on its own progress rather than coalescing onto the primary. var inProgressIds = inProgress.Select(ud => ud.ItemId); - baseQuery = hasSeries - ? baseQuery.Where(e => - (e.Type == seriesTypeName && resumableSeriesIds!.Contains(e.Id)) - || (e.Type != seriesTypeName && inProgressIds.Contains(e.Id))) - : baseQuery.Where(e => inProgressIds.Contains(e.Id)); + baseQuery = baseQuery.Where(IsFolderFilter.And(folderResumableFilter) + .Or(IsFolderFilter.Not().And(e => inProgressIds.Contains(e.Id)))); // When several versions of the same item are in progress, keep only the most recently played one, use id as tiebreaker. // Only in-progress siblings can eliminate a candidate: a version without progress has a NULL max LastPlayedDate, // which is never greater and never ties. Restricting the sibling scan to the in-progress set keeps this bounded by // the user's Continue Watching count instead of forcing a full BaseItems scan (COALESCE keys are non-indexable) per row. // Items in no version group at all have no sibling that could eliminate them, so short-circuit the scan for those. - baseQuery = baseQuery.Where(e => e.Type == seriesTypeName + baseQuery = baseQuery.Where(e => e.IsFolder || (e.PrimaryVersionId == null && !context.BaseItems.Any(a => a.PrimaryVersionId == e.Id)) || !context.BaseItems .Where(s => s.Id != e.Id @@ -594,11 +543,8 @@ public sealed partial class BaseItemRepository var resumableMovieIds = inProgress .Join(context.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id); - baseQuery = hasSeries - ? baseQuery.Where(e => - (e.Type == seriesTypeName && !resumableSeriesIds!.Contains(e.Id)) - || (e.Type != seriesTypeName && !resumableMovieIds.Contains(e.Id))) - : baseQuery.Where(e => !resumableMovieIds.Contains(e.Id)); + baseQuery = baseQuery.Where(IsFolderFilter.And(folderResumableFilter.Not()) + .Or(IsFolderFilter.Not().And(e => !resumableMovieIds.Contains(e.Id)))); } } diff --git a/Jellyfin.Server.Implementations/Item/ItemCountService.cs b/Jellyfin.Server.Implementations/Item/ItemCountService.cs index 604db9f839..4aa65769fd 100644 --- a/Jellyfin.Server.Implementations/Item/ItemCountService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemCountService.cs @@ -141,32 +141,32 @@ public class ItemCountService : IItemCountService switch (kind) { case BaseItemKind.Person: - baseQuery = context.PeopleBaseItemMap + baseQuery = ItemsById(context, context.PeopleBaseItemMap .AsNoTracking() .Where(m => m.People.Name == item.Name) - .Select(m => m.Item); + .Select(m => m.ItemId)); break; case BaseItemKind.MusicArtist: - baseQuery = context.ItemValuesMap + baseQuery = ItemsById(context, context.ItemValuesMap .AsNoTracking() .Where(ivm => ivm.ItemValue.CleanValue == item.CleanName && (ivm.ItemValue.Type == ItemValueType.Artist || ivm.ItemValue.Type == ItemValueType.AlbumArtist)) - .Select(ivm => ivm.Item); + .Select(ivm => ivm.ItemId)); break; case BaseItemKind.Genre: case BaseItemKind.MusicGenre: - baseQuery = context.ItemValuesMap + baseQuery = ItemsById(context, context.ItemValuesMap .AsNoTracking() .Where(ivm => ivm.ItemValue.CleanValue == item.CleanName && ivm.ItemValue.Type == ItemValueType.Genre) - .Select(ivm => ivm.Item); + .Select(ivm => ivm.ItemId)); break; case BaseItemKind.Studio: - baseQuery = context.ItemValuesMap + baseQuery = ItemsById(context, context.ItemValuesMap .AsNoTracking() .Where(ivm => ivm.ItemValue.CleanValue == item.CleanName && ivm.ItemValue.Type == ItemValueType.Studios) - .Select(ivm => ivm.Item); + .Select(ivm => ivm.ItemId)); break; case BaseItemKind.Year: if (int.TryParse(item.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year)) @@ -254,6 +254,9 @@ public class ItemCountService : IItemCountService return result; } + private static IQueryable<BaseItemEntity> ItemsById(JellyfinDbContext context, IQueryable<Guid> itemIds) + => context.BaseItems.AsNoTracking().Where(e => itemIds.Contains(e.Id)); + /// <inheritdoc/> public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId) { diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs index b10f7c527e..827c766449 100644 --- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs +++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs @@ -428,106 +428,144 @@ public class ItemPersistenceService : IItemPersistenceService foreach (var item in tuples) { - if (item.Item is Folder folder) + // A container that was never hydrated cannot be used to rewrite its links: its empty + // array means "unknown", so clearing the stored rows would silently empty the item. + if (item.Item is Folder { LinkedChildrenLoaded: false }) { - var existingLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(item.Item.Id)?.ToList() ?? new List<LinkedChildEntity>(); - if (folder.LinkedChildren.Length > 0) + continue; + } + + if (item.Item is Folder or Video + && allLinkedChildrenByParent.TryGetValue(item.Item.Id, out var existingLinks) + && existingLinks.Count > 0) + { + // A video only owns its alternate version links; any other link on that parent is + // written by the folder branch below and must survive. + var staleLinks = item.Item is Folder + ? existingLinks + : existingLinks + .Where(e => e.ChildType is DbLinkedChildType.LocalAlternateVersion or DbLinkedChildType.LinkedAlternateVersion) + .ToList(); + + if (staleLinks.Count > 0) { + context.LinkedChildren.RemoveRange(staleLinks); + } + } + } + + context.SaveChanges(); + + // A LinkedChild's ItemId is only a cache. + var cachedChildIds = tuples + .Select(t => t.Item) + .OfType<Folder>() + .Where(f => f.LinkedChildrenLoaded) + .SelectMany(f => f.LinkedChildren) + .Where(lc => lc.ItemId.HasValue && !lc.ItemId.Value.IsEmpty()) + .Select(lc => lc.ItemId!.Value) + .Distinct() + .ToList(); + + var knownChildIds = cachedChildIds.Count > 0 + ? context.BaseItems + .WhereOneOrMany(cachedChildIds, e => e.Id) + .Select(e => e.Id) + .ToHashSet() + : []; + + foreach (var item in tuples) + { + if (item.Item is Folder { LinkedChildrenLoaded: true } folder && folder.LinkedChildren.Length > 0) + { #pragma warning disable CS0618 // Type or member is obsolete - legacy path resolution for old data - var pathsToResolve = folder.LinkedChildren - .Where(lc => (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty()) && !string.IsNullOrEmpty(lc.Path)) - .Select(lc => lc.Path) - .Distinct() - .ToList(); + var pathsToResolve = folder.LinkedChildren + .Where(lc => !string.IsNullOrEmpty(lc.Path) + && (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty() || !knownChildIds.Contains(lc.ItemId.Value))) + .Select(lc => lc.Path) + .Distinct() + .ToList(); - var pathToIdMap = pathsToResolve.Count > 0 - ? context.BaseItems - .Where(e => e.Path != null && pathsToResolve.Contains(e.Path)) - .Select(e => new { e.Path, e.Id }) - .GroupBy(e => e.Path!) - .ToDictionary(g => g.Key, g => g.First().Id) - : []; + var pathToIdMap = pathsToResolve.Count > 0 + ? context.BaseItems + .Where(e => e.Path != null && pathsToResolve.Contains(e.Path)) + .Select(e => new { e.Path, e.Id }) + .GroupBy(e => e.Path!) + .ToDictionary(g => g.Key, g => g.First().Id) + : []; - var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>(); - foreach (var linkedChild in folder.LinkedChildren) + var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>(); + foreach (var linkedChild in folder.LinkedChildren) + { + var childItemId = linkedChild.ItemId; + if (!childItemId.HasValue || childItemId.Value.IsEmpty() || !knownChildIds.Contains(childItemId.Value)) { - var childItemId = linkedChild.ItemId; - if (!childItemId.HasValue || childItemId.Value.IsEmpty()) + if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var resolvedId)) { - if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var resolvedId)) - { - childItemId = resolvedId; - } + childItemId = resolvedId; } -#pragma warning restore CS0618 - - if (childItemId.HasValue && !childItemId.Value.IsEmpty()) + else if (Guid.TryParse(linkedChild.LibraryItemId, out var libraryItemId) && !libraryItemId.IsEmpty()) { - resolvedChildren.Add((linkedChild, childItemId.Value)); + childItemId = libraryItemId; } } +#pragma warning restore CS0618 + if (childItemId.HasValue && !childItemId.Value.IsEmpty()) + { + resolvedChildren.Add((linkedChild, childItemId.Value)); + } + } + + // Playlists may legitimately contain the same item multiple times (e.g. a song repeated + // in an .m3u file). Every other container type keeps a single entry per child. + var isPlaylist = folder is Playlist; + if (!isPlaylist) + { resolvedChildren = resolvedChildren .GroupBy(c => c.ChildId) .Select(g => g.Last()) .ToList(); + } - var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).ToList(); - var existingChildIds = childIdsToCheck.Count > 0 - ? context.BaseItems - .Where(e => childIdsToCheck.Contains(e.Id)) - .Select(e => e.Id) - .ToHashSet() - : []; - - var isPlaylist = folder is Playlist; - var sortOrder = 0; - foreach (var (linkedChild, childId) in resolvedChildren) - { - if (!existingChildIds.Contains(childId)) - { - _logger.LogWarning( - "Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} does not exist in database", - item.Item.Name, - item.Item.Id, - childId); - continue; - } - - var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId); - if (existingLink is null) - { - context.LinkedChildren.Add(new LinkedChildEntity() - { - ParentId = item.Item.Id, - ChildId = childId, - ChildType = (DbLinkedChildType)linkedChild.Type, - SortOrder = isPlaylist ? sortOrder : null - }); - } - else - { - existingLink.SortOrder = isPlaylist ? sortOrder : null; - existingLink.ChildType = (DbLinkedChildType)linkedChild.Type; - existingLinkedChildren.Remove(existingLink); - } + var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).Distinct().ToList(); + var existingChildIds = childIdsToCheck.Count > 0 + ? context.BaseItems + .Where(e => childIdsToCheck.Contains(e.Id)) + .Select(e => e.Id) + .ToHashSet() + : []; - sortOrder++; + var sortOrder = 0; + foreach (var (linkedChild, childId) in resolvedChildren) + { + if (!existingChildIds.Contains(childId)) + { +#pragma warning disable CS0618 // Type or member is obsolete - legacy path is logged for diagnostics + _logger.LogWarning( + "Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} (path {ChildPath}) does not exist in database", + item.Item.Name, + item.Item.Id, + childId, + linkedChild.Path ?? "unknown"); +#pragma warning restore CS0618 + continue; } - } - if (existingLinkedChildren.Count > 0) - { - context.LinkedChildren.RemoveRange(existingLinkedChildren); + context.LinkedChildren.Add(new LinkedChildEntity() + { + ParentId = item.Item.Id, + ChildId = childId, + ChildType = (DbLinkedChildType)linkedChild.Type, + SortOrder = sortOrder + }); + + sortOrder++; } } if (item.Item is Video video) { - var existingLinkedChildren = (allLinkedChildrenByParent.GetValueOrDefault(video.Id) ?? new List<LinkedChildEntity>()) - .Where(e => (int)e.ChildType == 2 || (int)e.ChildType == 3) - .ToList(); - var newLinkedChildren = new List<(Guid ChildId, LinkedChildType Type)>(); if (video.LocalAlternateVersions.Length > 0) @@ -577,7 +615,7 @@ public class ItemPersistenceService : IItemPersistenceService .ToHashSet() : []; - int sortOrder = 0; + var sortOrder = 0; foreach (var (childId, childType) in newLinkedChildren) { if (!existingChildIds.Contains(childId)) @@ -590,36 +628,27 @@ public class ItemPersistenceService : IItemPersistenceService continue; } - var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId); - if (existingLink is null) + context.LinkedChildren.Add(new LinkedChildEntity { - context.LinkedChildren.Add(new LinkedChildEntity - { - ParentId = video.Id, - ChildId = childId, - ChildType = (DbLinkedChildType)childType, - SortOrder = sortOrder - }); - } - else - { - existingLink.ChildType = (DbLinkedChildType)childType; - existingLink.SortOrder = sortOrder; - existingLinkedChildren.Remove(existingLink); - } + ParentId = video.Id, + ChildId = childId, + ChildType = (DbLinkedChildType)childType, + SortOrder = sortOrder + }); sortOrder++; } - if (existingLinkedChildren.Count > 0) + // A previously-linked LocalAlternateVersion that is no longer present becomes orphaned; + var previousLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(video.Id); + if (previousLinkedChildren is { Count: > 0 }) { - var orphanedLocalVersionIds = existingLinkedChildren - .Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion) + var newChildIds = newLinkedChildren.Select(c => c.ChildId).ToHashSet(); + var orphanedLocalVersionIds = previousLinkedChildren + .Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion && !newChildIds.Contains(e.ChildId)) .Select(e => e.ChildId) .ToList(); - context.LinkedChildren.RemoveRange(existingLinkedChildren); - if (orphanedLocalVersionIds.Count > 0) { var orphanedItems = context.BaseItems diff --git a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs index 5e5ce320a5..5f1d9bf87a 100644 --- a/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs +++ b/Jellyfin.Server.Implementations/Item/LinkedChildrenService.cs @@ -159,12 +159,16 @@ public class LinkedChildrenService : ILinkedChildrenService if (existingLink is null) { + var nextSortOrder = (context.LinkedChildren + .Where(lc => lc.ParentId == parentId) + .Max(lc => (int?)lc.SortOrder) ?? -1) + 1; + context.LinkedChildren.Add(new Jellyfin.Database.Implementations.Entities.LinkedChildEntity { ParentId = parentId, ChildId = childId, ChildType = dbChildType, - SortOrder = null + SortOrder = nextSortOrder }); } else diff --git a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs index 13c7895f83..0989ce84ba 100644 --- a/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs +++ b/Jellyfin.Server.Implementations/StorageHelpers/StorageHelper.cs @@ -87,8 +87,9 @@ public static class StorageHelper /// </summary> private static string ResolvePath(string path) { - var parts = path.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries); - var current = Path.DirectorySeparatorChar.ToString(); + var root = Path.GetPathRoot(path) ?? Path.DirectorySeparatorChar.ToString(); + var parts = path.Substring(root.Length).Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries); + var current = root; foreach (var part in parts) { current = Path.Combine(current, part); diff --git a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs index a10be76e05..beafc3916f 100644 --- a/Jellyfin.Server/Migrations/JellyfinMigrationService.cs +++ b/Jellyfin.Server/Migrations/JellyfinMigrationService.cs @@ -193,10 +193,15 @@ internal class JellyfinMigrationService { var historyRepository = dbContext.GetService<IHistoryRepository>(); var migrationsAssembly = dbContext.GetService<IMigrationsAssembly>(); - (string Key, IInternalMigration Migration)[] migrations = []; + var completedMigrations = 0; + string? lastMigrationKey = null; - do - { // migrations may alter the migration state. Reevaluate the applicable migrations after every stage ran until there are no more to apply. + while (true) + { + // A single migration can change which migrations still apply: IMigrator.MigrateAsync treats its argument as the + // state to end up in, so it reverts everything applied after it, and a reverted migration can take code migrations + // with it (AddNormalizedUsername.Down drops the UpdateNormalizedUsername history row). Anything computed before + // that point is stale, so only ever run the next migration and then work out the pending set again. var appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false); var pendingCodeMigrations = migrationStage .Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId())) @@ -212,73 +217,86 @@ internal class JellyfinMigrationService } (string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations]; - logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage); - migrations = pendingMigrations.OrderBy(e => e.Key).ToArray(); + if (pendingMigrations.Length == 0) + { + break; + } - var migrationIndex = 0; - foreach (var item in migrations) + if (completedMigrations == 0) { - // Surface generic "Running migration X of Y" progress in the always-visible startup UI header. - SetupServer.ReportActivity(StartupActivity.Migration(++migrationIndex, migrations.Length)); - var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}"); - try - { - migrationLogger.LogInformation("Perform migration {Name}", item.Key); - await item.Migration.PerformAsync(migrationLogger).ConfigureAwait(false); - migrationLogger.LogInformation("Migration {Name} was successfully applied", item.Key); - } - catch (Exception ex) - { - migrationLogger.LogCritical("Error: {Error}", ex.Message); - migrationLogger.LogError(ex, "Migration {Name} failed", item.Key); + logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingMigrations.Length, stage); + } + + var item = pendingMigrations.OrderBy(e => e.Key, StringComparer.Ordinal).First(); + if (string.Equals(item.Key, lastMigrationKey, StringComparison.Ordinal)) + { + throw new InvalidOperationException($"Migration {item.Key} ran but did not record itself as applied and would repeat indefinitely."); + } + + lastMigrationKey = item.Key; + + // Surface generic "Running migration X of Y" progress in the always-visible startup UI header. + SetupServer.ReportActivity(StartupActivity.Migration(completedMigrations + 1, completedMigrations + pendingMigrations.Length)); + var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}"); + try + { + migrationLogger.LogInformation("Perform migration {Name}", item.Key); + await item.Migration.PerformAsync(migrationLogger).ConfigureAwait(false); + migrationLogger.LogInformation("Migration {Name} was successfully applied", item.Key); + } + catch (Exception ex) + { + migrationLogger.LogCritical("Error: {Error}", ex.Message); + migrationLogger.LogError(ex, "Migration {Name} failed", item.Key); - if (_backupKey != default && _backupService is not null && _jellyfinDatabaseProvider is not null) + if (_backupKey != default && _backupService is not null && _jellyfinDatabaseProvider is not null) + { + if (_backupKey.LibraryDb is not null) { - if (_backupKey.LibraryDb is not null) + migrationLogger.LogInformation("Attempt to rollback librarydb."); + try { - migrationLogger.LogInformation("Attempt to rollback librarydb."); - try - { - var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename); - File.Move(_backupKey.LibraryDb, libraryDbPath, true); - } - catch (Exception inner) - { - migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.LibraryDb); - } + var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename); + File.Move(_backupKey.LibraryDb, libraryDbPath, true); } - - if (_backupKey.JellyfinDb is not null) + catch (Exception inner) { - migrationLogger.LogInformation("Attempt to rollback JellyfinDb."); - try - { - await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false); - } - catch (Exception inner) - { - migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.JellyfinDb); - } + migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.LibraryDb); } + } - if (_backupKey.FullBackup is not null) + if (_backupKey.JellyfinDb is not null) + { + migrationLogger.LogInformation("Attempt to rollback JellyfinDb."); + try { - migrationLogger.LogInformation("Attempt to rollback from backup."); - try - { - await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false); - } - catch (Exception inner) - { - migrationLogger.LogCritical(inner, "Could not rollback from backup {Backup}. Manual intervention might be required to restore a operational state.", _backupKey.FullBackup.Path); - } + await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception inner) + { + migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.JellyfinDb); } } - throw; + if (_backupKey.FullBackup is not null) + { + migrationLogger.LogInformation("Attempt to rollback from backup."); + try + { + await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false); + } + catch (Exception inner) + { + migrationLogger.LogCritical(inner, "Could not rollback from backup {Backup}. Manual intervention might be required to restore a operational state.", _backupKey.FullBackup.Path); + } + } } + + throw; } - } while (migrations.Length != 0); + + completedMigrations++; + } } } diff --git a/Jellyfin.Server/Migrations/Routines/20250420190000_RemoveDuplicatePlaylistChildren.cs b/Jellyfin.Server/Migrations/Routines/20250420190000_RemoveDuplicatePlaylistChildren.cs deleted file mode 100644 index 1545ebdc8e..0000000000 --- a/Jellyfin.Server/Migrations/Routines/20250420190000_RemoveDuplicatePlaylistChildren.cs +++ /dev/null @@ -1,61 +0,0 @@ -using System; -using System.Linq; -using System.Threading; -using Jellyfin.Data.Enums; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Playlists; - -namespace Jellyfin.Server.Migrations.Routines; - -/// <summary> -/// Remove duplicate playlist entries. -/// </summary> -#pragma warning disable CS0618 // Type or member is obsolete -[JellyfinMigration("2025-04-20T19:00:00", nameof(RemoveDuplicatePlaylistChildren), "96C156A2-7A13-4B3B-A8B8-FB80C94D20C0")] -internal class RemoveDuplicatePlaylistChildren : IMigrationRoutine -#pragma warning restore CS0618 // Type or member is obsolete -{ - private readonly ILibraryManager _libraryManager; - private readonly IPlaylistManager _playlistManager; - - public RemoveDuplicatePlaylistChildren( - ILibraryManager libraryManager, - IPlaylistManager playlistManager) - { - _libraryManager = libraryManager; - _playlistManager = playlistManager; - } - - /// <inheritdoc/> - public void Perform() - { - var playlists = _libraryManager.GetItemList(new InternalItemsQuery - { - IncludeItemTypes = [BaseItemKind.Playlist] - }) - .Cast<Playlist>() - .Where(p => !p.OpenAccess || !p.OwnerUserId.Equals(Guid.Empty)) - .ToArray(); - - if (playlists.Length > 0) - { - foreach (var playlist in playlists) - { - var linkedChildren = playlist.LinkedChildren; - if (linkedChildren.Length > 0) - { - var newLinkedChildren = linkedChildren - .Where(c => c.ItemId is null || c.ItemId.Value.Equals(Guid.Empty)) - .Concat(linkedChildren - .Where(c => c.ItemId.HasValue && !c.ItemId.Value.Equals(Guid.Empty)) - .DistinctBy(c => c.ItemId)) - .ToArray(); - playlist.LinkedChildren = newLinkedChildren; - playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult(); - _playlistManager.SavePlaylistFile(playlist); - } - } - } - } -} diff --git a/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs index c433c1d043..4a6e74c229 100644 --- a/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs +++ b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs @@ -63,7 +63,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine var itemsWithData = context.BaseItems .Where(b => b.Data != null && (containerTypes.Contains(b.Type) || videoTypes.Contains(b.Type))) - .Select(b => new { b.Id, b.Data, b.Type }) + .Select(b => new { b.Id, b.Data, b.Type, b.Path, b.IsFolder }) .ToList(); _logger.LogInformation("Found {Count} potential items with LinkedChildren data to process.", itemsWithData.Count); @@ -74,6 +74,15 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine .GroupBy(b => b.Path!) .ToDictionary(g => g.Key, g => g.First().Id); + // Needed to tell a stale cached ItemId apart from one that still points at a real item. + var allItemIds = context.BaseItems.Select(b => b.Id).ToHashSet(); + + var playlistParentIds = itemsWithData + .Where(b => b.Type == "MediaBrowser.Controller.Playlists.Playlist") + .Select(b => b.Id) + .ToHashSet(); + + var droppedChildren = 0; var linkedChildrenToAdd = new List<LinkedChildEntity>(); var processedCount = 0; const int progressLogStep = 1000; @@ -100,7 +109,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine // Handle Video alternate versions if (isVideo) { - ProcessVideoAlternateVersions(doc.RootElement, item.Id, pathToIdMap, linkedChildrenToAdd); + ProcessVideoAlternateVersions(doc.RootElement, item.Id, pathToIdMap, allItemIds, linkedChildrenToAdd); } // Handle LinkedChildren (for containers and other items) @@ -110,46 +119,22 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine continue; } - var isPlaylist = item.Type == "MediaBrowser.Controller.Playlists.Playlist"; + // Legacy entries may hold a path relative to the container that holds them, so the + // container's own location has to be a real path, not a virtual one. + var itemPath = item.Path is null ? null : _appHost.ExpandVirtualPath(item.Path); + var containingFolderPath = item.IsFolder ? itemPath : Path.GetDirectoryName(itemPath); var sortOrder = 0; foreach (var childElement in linkedChildrenElement.EnumerateArray()) { - Guid? childId = null; - if (childElement.TryGetProperty("ItemId", out var itemIdProp) && itemIdProp.ValueKind != JsonValueKind.Null) - { - var itemIdStr = itemIdProp.GetString(); - if (!string.IsNullOrEmpty(itemIdStr) && Guid.TryParse(itemIdStr, out var parsedId)) - { - childId = parsedId; - } - } - - if (!childId.HasValue || childId.Value.IsEmpty()) - { - if (childElement.TryGetProperty("Path", out var pathProp)) - { - var path = pathProp.GetString(); - if (!string.IsNullOrEmpty(path) && pathToIdMap.TryGetValue(path, out var resolvedId)) - { - childId = resolvedId; - } - } - } - - if (!childId.HasValue || childId.Value.IsEmpty()) - { - if (childElement.TryGetProperty("LibraryItemId", out var libIdProp)) - { - var libIdStr = libIdProp.GetString(); - if (!string.IsNullOrEmpty(libIdStr) && Guid.TryParse(libIdStr, out var parsedLibId)) - { - childId = parsedLibId; - } - } - } - - if (!childId.HasValue || childId.Value.IsEmpty()) + var childId = ResolveChildId(childElement, containingFolderPath, pathToIdMap, allItemIds); + if (!childId.HasValue) { + droppedChildren++; + _logger.LogWarning( + "Dropping unresolvable LinkedChild of {ParentId}: ItemId {ItemId}, path {ChildPath}", + item.Id, + GetStringProperty(childElement, "ItemId") ?? "none", + GetStringProperty(childElement, "Path") ?? "none"); continue; } @@ -175,7 +160,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine ParentId = item.Id, ChildId = childId.Value, ChildType = childType, - SortOrder = isPlaylist ? sortOrder : null + SortOrder = sortOrder }); sortOrder++; @@ -197,23 +182,37 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine .Select(lc => new { lc.ParentId, lc.ChildId }) .ToHashSet(); + // A playlist may list the same child more than once, so it cannot be keyed by + // (ParentId, ChildId): skip a playlist wholesale if it already has rows instead, which + // keeps the routine re-runnable without collapsing repeated entries. + var populatedParentIds = context.LinkedChildren + .Select(lc => lc.ParentId) + .Distinct() + .ToHashSet(); + var toInsert = linkedChildrenToAdd - .Where(lc => !existingKeys.Contains(new { lc.ParentId, lc.ChildId })) + .Where(lc => playlistParentIds.Contains(lc.ParentId) + ? !populatedParentIds.Contains(lc.ParentId) + : !existingKeys.Contains(new { lc.ParentId, lc.ChildId })) .ToList(); if (toInsert.Count > 0) { - // Deduplicate by composite key (ParentId, ChildId) + // Every container type other than a playlist keeps a single entry per child. // Priority: LocalAlternateVersion > LinkedAlternateVersion > Other - toInsert = toInsert - .OrderBy(lc => lc.ChildType switch - { - LinkedChildType.LocalAlternateVersion => 0, - LinkedChildType.LinkedAlternateVersion => 1, - _ => 2 - }) - .DistinctBy(lc => new { lc.ParentId, lc.ChildId }) - .ToList(); + toInsert = + [ + .. toInsert.Where(lc => playlistParentIds.Contains(lc.ParentId)), + .. toInsert + .Where(lc => !playlistParentIds.Contains(lc.ParentId)) + .OrderBy(lc => lc.ChildType switch + { + LinkedChildType.LocalAlternateVersion => 0, + LinkedChildType.LinkedAlternateVersion => 1, + _ => 2 + }) + .DistinctBy(lc => new { lc.ParentId, lc.ChildId }) + ]; var childIds = toInsert.Select(lc => lc.ChildId).Distinct().ToList(); var existingChildIds = context.BaseItems @@ -267,7 +266,10 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine _logger.LogInformation("No LinkedChildren data found to migrate."); } - _logger.LogInformation("LinkedChildren migration completed. Processed {Count} items.", processedCount); + _logger.LogInformation( + "LinkedChildren migration completed. Processed {Count} items, dropped {DroppedCount} unresolvable children.", + processedCount, + droppedChildren); CleanupWrongTypeAlternateVersions(context); CleanupOrphanedAlternateVersionBaseItems(context); @@ -418,6 +420,12 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine var internalMetadataPath = _appPaths.InternalMetadataPath; + // An item outside every library location is normally left over from a removed media path, but + // it looks exactly the same as one whose storage failed to mount (a wrong bind mount on the + // first container start, for example). Only act on it while every location is readable. + var canRemoveUnrootedItems = inaccessiblePaths.Count == 0; + var skippedUnrootedItems = 0; + var staleIds = new List<Guid>(); foreach (var item in itemsWithPaths) { @@ -436,6 +444,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine // Directory check covers BDMV/DVD items whose Path points to a folder if (!File.Exists(path) && !Directory.Exists(path)) { + _logger.LogDebug("Removing item {ItemId}: file {Path} no longer exists.", item.Id, path); staleIds.Add(item.Id); } } @@ -443,12 +452,28 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine { // Item is not under ANY library location (accessible or not) — // it's orphaned from all libraries (e.g. media path was removed from config) - staleIds.Add(item.Id); + if (canRemoveUnrootedItems) + { + _logger.LogDebug("Removing item {ItemId}: path {Path} is outside every library location.", item.Id, path); + staleIds.Add(item.Id); + } + else + { + skippedUnrootedItems++; + } } // Otherwise: item is under an inaccessible location — skip (storage may be offline) } + if (skippedUnrootedItems > 0) + { + _logger.LogWarning( + "Keeping {Count} items that are outside every library location because {LocationCount} library location(s) are currently unavailable.", + skippedUnrootedItems, + inaccessiblePaths.Count); + } + if (staleIds.Count == 0) { _logger.LogInformation("No stale items found."); @@ -518,18 +543,86 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine orphanedLinkedChildren.AddRange(orphanedByParent); } - // Remove all orphaned records - var distinctOrphaned = orphanedLinkedChildren.DistinctBy(lc => new { lc.ParentId, lc.ChildId }).ToList(); + // Remove all orphaned records. Both queries can return the same row, and a playlist may hold + // several rows for one child, so the position is what identifies an entry here. + var distinctOrphaned = orphanedLinkedChildren.DistinctBy(lc => new { lc.ParentId, lc.SortOrder }).ToList(); context.LinkedChildren.RemoveRange(distinctOrphaned); context.SaveChanges(); _logger.LogInformation("Successfully removed {Count} orphaned LinkedChildren records.", distinctOrphaned.Count); } + /// <summary> + /// Resolves the item a legacy LinkedChild entry points at. + /// </summary> + private static Guid? ResolveChildId( + JsonElement childElement, + string? containingFolderPath, + Dictionary<string, Guid> pathToIdMap, + HashSet<Guid> allItemIds) + { + // Pre-12 data only cached ItemId and re-resolved it from the path whenever the cached value + // went stale (BaseItem.GetLinkedChild in 10.x). An id that no longer exists must therefore + // fall through to the path, or the entry is lost even though its file is still in the library. + if (TryGetGuidProperty(childElement, "ItemId", out var itemId) && allItemIds.Contains(itemId)) + { + return itemId; + } + + var path = GetStringProperty(childElement, "Path"); + if (!string.IsNullOrEmpty(path)) + { + if (pathToIdMap.TryGetValue(path, out var idByPath)) + { + return idByPath; + } + + // 10.x resolved entries relative to the container that holds them. + if (!Path.IsPathRooted(path) && !string.IsNullOrEmpty(containingFolderPath)) + { + string? absolutePath = null; + try + { + absolutePath = Path.GetFullPath(Path.Combine(containingFolderPath, path)); + } + catch (ArgumentException) + { + // Malformed path, nothing to resolve. + } + + if (absolutePath is not null && pathToIdMap.TryGetValue(absolutePath, out var idByAbsolutePath)) + { + return idByAbsolutePath; + } + } + } + + if (TryGetGuidProperty(childElement, "LibraryItemId", out var libraryItemId) && allItemIds.Contains(libraryItemId)) + { + return libraryItemId; + } + + return null; + } + + private static string? GetStringProperty(JsonElement element, string propertyName) + => element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String + ? property.GetString() + : null; + + private static bool TryGetGuidProperty(JsonElement element, string propertyName, out Guid value) + { + value = Guid.Empty; + var raw = GetStringProperty(element, propertyName); + + return !string.IsNullOrEmpty(raw) && Guid.TryParse(raw, out value) && !value.IsEmpty(); + } + private void ProcessVideoAlternateVersions( JsonElement root, Guid parentId, Dictionary<string, Guid> pathToIdMap, + HashSet<Guid> allItemIds, List<LinkedChildEntity> linkedChildrenToAdd) { int sortOrder = 0; @@ -582,45 +675,8 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine { foreach (var linkedChildElement in linkedAlternateVersionsElement.EnumerateArray()) { - Guid? childId = null; - - // Try to get ItemId - if (linkedChildElement.TryGetProperty("ItemId", out var itemIdProp) && itemIdProp.ValueKind != JsonValueKind.Null) - { - var itemIdStr = itemIdProp.GetString(); - if (!string.IsNullOrEmpty(itemIdStr) && Guid.TryParse(itemIdStr, out var parsedId)) - { - childId = parsedId; - } - } - - // Try to get from Path if ItemId not available - if (!childId.HasValue || childId.Value.IsEmpty()) - { - if (linkedChildElement.TryGetProperty("Path", out var pathProp)) - { - var path = pathProp.GetString(); - if (!string.IsNullOrEmpty(path) && pathToIdMap.TryGetValue(path, out var resolvedId)) - { - childId = resolvedId; - } - } - } - - // Try LibraryItemId as fallback - if (!childId.HasValue || childId.Value.IsEmpty()) - { - if (linkedChildElement.TryGetProperty("LibraryItemId", out var libIdProp)) - { - var libIdStr = libIdProp.GetString(); - if (!string.IsNullOrEmpty(libIdStr) && Guid.TryParse(libIdStr, out var parsedLibId)) - { - childId = parsedLibId; - } - } - } - - if (!childId.HasValue || childId.Value.IsEmpty()) + var childId = ResolveChildId(linkedChildElement, null, pathToIdMap, allItemIds); + if (!childId.HasValue) { _logger.LogWarning("Could not resolve LinkedAlternateVersion child ID for parent {ParentId}", parentId); continue; diff --git a/Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs b/Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs new file mode 100644 index 0000000000..16ac6cb5e5 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/20260729120000_RestorePlaylistChildrenFromMetadata.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml; +using Jellyfin.Database.Implementations; +using Jellyfin.Database.Implementations.Entities; +using MediaBrowser.Controller; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Restores playlist entries from playlist.xml for playlists that lost all of their children. +/// </summary> +[JellyfinMigration("2026-07-29T12:00:00", nameof(RestorePlaylistChildrenFromMetadata))] +internal class RestorePlaylistChildrenFromMetadata : IDatabaseMigrationRoutine +{ + private const string PlaylistTypeName = "MediaBrowser.Controller.Playlists.Playlist"; + private const string PlaylistFileName = "playlist.xml"; + + private readonly ILogger<RestorePlaylistChildrenFromMetadata> _logger; + private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; + private readonly IServerApplicationHost _appHost; + + public RestorePlaylistChildrenFromMetadata( + ILoggerFactory loggerFactory, + IDbContextFactory<JellyfinDbContext> dbProvider, + IServerApplicationHost appHost) + { + _logger = loggerFactory.CreateLogger<RestorePlaylistChildrenFromMetadata>(); + _dbProvider = dbProvider; + _appHost = appHost; + } + + /// <inheritdoc/> + public void Perform() + { + using var context = _dbProvider.CreateDbContext(); + + var playlists = context.BaseItems + .Where(b => b.Type == PlaylistTypeName && b.Path != null) + .Select(b => new { b.Id, b.Name, b.Path }) + .ToList(); + + if (playlists.Count == 0) + { + return; + } + + var childCountByPlaylist = context.LinkedChildren + .Where(lc => context.BaseItems.Any(b => b.Id.Equals(lc.ParentId) && b.Type == PlaylistTypeName)) + .GroupBy(lc => lc.ParentId) + .Select(g => new { ParentId = g.Key, Count = g.Count() }) + .ToDictionary(g => g.ParentId, g => g.Count); + + var pathToIdMap = context.BaseItems + .Where(b => b.Path != null) + .Select(b => new { b.Id, b.Path }) + .GroupBy(b => b.Path!) + .ToDictionary(g => g.Key, g => g.First().Id); + + var restoredPlaylists = 0; + var restoredEntries = 0; + + foreach (var playlist in playlists) + { + // Only directory-based (Jellyfin-managed) playlists keep their entries in playlist.xml. + // A playlist that is itself a file (.m3u and friends) is re-read by the library scan. + var playlistPath = _appHost.ExpandVirtualPath(playlist.Path!); + var metadataPath = Path.Combine(playlistPath, PlaylistFileName); + if (!Directory.Exists(playlistPath) || !File.Exists(metadataPath)) + { + continue; + } + + var storedPaths = ReadEntryPaths(metadataPath, playlist.Id); + if (storedPaths.Count == 0) + { + continue; + } + + var childCount = childCountByPlaylist.GetValueOrDefault(playlist.Id); + if (childCount > 0) + { + // Merging into a playlist that still has entries would resurrect anything the user + // removed while the metadata file was not rewritten, and there is no way to tell the + // two apart. Report the mismatch instead so it can be checked by hand. + if (storedPaths.Count > childCount) + { + _logger.LogWarning( + "Playlist {PlaylistName} ({PlaylistId}) holds {ChildCount} entries but {MetadataPath} lists {StoredCount}. Not restoring automatically.", + playlist.Name, + playlist.Id, + childCount, + metadataPath, + storedPaths.Count); + } + + continue; + } + + var sortOrder = 0; + foreach (var storedPath in storedPaths) + { + if (!pathToIdMap.TryGetValue(storedPath, out var childId)) + { + _logger.LogWarning( + "Cannot restore entry {EntryPath} of playlist {PlaylistName}: no library item has that path.", + storedPath, + playlist.Name); + continue; + } + + context.LinkedChildren.Add(new LinkedChildEntity + { + ParentId = playlist.Id, + ChildId = childId, + ChildType = LinkedChildType.Manual, + SortOrder = sortOrder + }); + + sortOrder++; + } + + if (sortOrder > 0) + { + restoredPlaylists++; + restoredEntries += sortOrder; + _logger.LogInformation( + "Restored {Count} entries of empty playlist {PlaylistName} ({PlaylistId}) from {MetadataPath}.", + sortOrder, + playlist.Name, + playlist.Id, + metadataPath); + } + } + + if (restoredEntries > 0) + { + context.SaveChanges(); + _logger.LogInformation("Restored {EntryCount} entries across {PlaylistCount} playlists.", restoredEntries, restoredPlaylists); + } + } + + private List<string> ReadEntryPaths(string metadataPath, Guid playlistId) + { + var paths = new List<string>(); + var settings = new XmlReaderSettings + { + IgnoreComments = true, + IgnoreWhitespace = true, + IgnoreProcessingInstructions = true, + DtdProcessing = DtdProcessing.Prohibit + }; + + try + { + using var reader = XmlReader.Create(metadataPath, settings); + var inEntry = false; + while (reader.Read()) + { + if (reader.NodeType != XmlNodeType.Element) + { + continue; + } + + if (string.Equals(reader.Name, "PlaylistItem", StringComparison.Ordinal)) + { + inEntry = true; + } + else if (inEntry && string.Equals(reader.Name, "Path", StringComparison.Ordinal)) + { + inEntry = false; + var value = reader.ReadElementContentAsString(); + if (!string.IsNullOrWhiteSpace(value)) + { + paths.Add(value.Trim()); + } + } + } + } + catch (Exception ex) when (ex is XmlException or IOException or UnauthorizedAccessException) + { + _logger.LogWarning(ex, "Could not read playlist metadata {MetadataPath} of playlist {PlaylistId}.", metadataPath, playlistId); + } + + return paths; + } +} diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index b1f7f29bad..f475379cc3 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -43,11 +43,7 @@ namespace MediaBrowser.Controller.Entities public class Folder : BaseItem { private IEnumerable<BaseItem> _children; - - public Folder() - { - LinkedChildren = Array.Empty<LinkedChild>(); - } + private LinkedChild[] _linkedChildren = []; public static IUserViewManager UserViewManager { get; set; } @@ -63,7 +59,27 @@ namespace MediaBrowser.Controller.Entities /// Gets or sets the linked children. /// </summary> [JsonIgnore] - public LinkedChild[] LinkedChildren { get; set; } + public LinkedChild[] LinkedChildren + { + get => _linkedChildren; + set + { + _linkedChildren = value; + + // Assigning the collection means the caller knows the complete set of links. + LinkedChildrenLoaded = true; + } + } + + /// <summary> + /// Gets a value indicating whether <see cref="LinkedChildren"/> holds the stored set of links. + /// </summary> + /// <remarks> + /// An unloaded instance carries an empty array that means "unknown", not "no children" — + /// persisting it would delete every link the item has. + /// </remarks> + [JsonIgnore] + public bool LinkedChildrenLoaded { get; private set; } [JsonIgnore] public DateTime? DateLastMediaAdded { get; set; } diff --git a/MediaBrowser.Controller/Entities/UserViewBuilder.cs b/MediaBrowser.Controller/Entities/UserViewBuilder.cs index 9ba103cc8b..aed11e5cc3 100644 --- a/MediaBrowser.Controller/Entities/UserViewBuilder.cs +++ b/MediaBrowser.Controller/Entities/UserViewBuilder.cs @@ -461,11 +461,12 @@ namespace MediaBrowser.Controller.Entities var counts = libraryManager.GetPlayedAndTotalCountBatch(folderIds, user); var isPlayedValue = query.IsPlayed.Value; - return itemList.Where(i => + return itemList.Where(item => { - if (i.IsFolder && counts.TryGetValue(i.Id, out var c)) + if (item is Folder) { - return (c.Total > 0 && c.Played == c.Total) == isPlayedValue; + var itemCount = counts.GetValueOrDefault(item.Id); + return (itemCount.Played >= itemCount.Total) == isPlayedValue; } return true; diff --git a/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs b/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs index 2e29cbdbba..f9a050d591 100644 --- a/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs +++ b/MediaBrowser.Controller/Persistence/IItemQueryHelpers.cs @@ -1,5 +1,6 @@ using System; using System.Linq; +using System.Linq.Expressions; using Jellyfin.Database.Implementations; using Jellyfin.Database.Implementations.Entities; using MediaBrowser.Controller.Entities; @@ -79,17 +80,26 @@ public interface IItemQueryHelpers Guid ancestorId); /// <summary> - /// Builds an <see cref="IQueryable{Guid}"/> of folder IDs whose descendants are all played - /// for the given user. Composable into outer queries to avoid an extra DB roundtrip. + /// Builds a query for the playable leaf items a user can access. /// </summary> /// <param name="context">The database context the resulting query is bound to.</param> - /// <param name="folderIds">A query yielding candidate folder IDs.</param> - /// <param name="user">The user for access filtering and played status.</param> - /// <returns>An <see cref="IQueryable{Guid}"/> of fully-played folder IDs.</returns> - IQueryable<Guid> GetFullyPlayedFolderIdsQuery( + /// <param name="user">The user to filter accessible items for.</param> + /// <param name="includeOwnedItems">Whether to include alternate versions and owned items.</param> + /// <returns>The access-filtered leaf item queryable.</returns> + IQueryable<BaseItemEntity> GetAccessFilteredLeafItemsQuery( JellyfinDbContext context, - IQueryable<Guid> folderIds, - User user); + User user, + bool includeOwnedItems = false); + + /// <summary> + /// Builds a filter matching items that have at least one of <paramref name="descendants"/> below them. + /// </summary> + /// <param name="context">The database context the resulting filter is bound to.</param> + /// <param name="descendants">A query yielding the descendants to look for.</param> + /// <returns>A filter expression matching items with a matching descendant.</returns> + Expression<Func<BaseItemEntity, bool>> BuildHasDescendantFilter( + JellyfinDbContext context, + IQueryable<BaseItemEntity> descendants); /// <summary> /// Deserializes a <see cref="BaseItemEntity"/> into a <see cref="BaseItem"/>. diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index f09c4c876c..40f2775bd3 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -1038,6 +1038,11 @@ namespace MediaBrowser.Providers.Manager target.OriginalTitle = source.OriginalTitle; } + if (replaceData || string.IsNullOrEmpty(target.HomePageUrl)) + { + target.HomePageUrl = source.HomePageUrl; + } + if (replaceData || string.IsNullOrEmpty(target.OriginalLanguage)) { target.OriginalLanguage = source.OriginalLanguage; diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs index 88730f34d2..28cfc8f9a4 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistImageProvider.cs @@ -3,32 +3,24 @@ #pragma warning disable CS1591 using System.Collections.Generic; -using System.IO; using System.Net.Http; -using System.Text.Json; using System.Threading; using System.Threading.Tasks; -using Jellyfin.Extensions.Json; using MediaBrowser.Common.Net; -using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; -using MediaBrowser.Model.IO; using MediaBrowser.Model.Providers; namespace MediaBrowser.Providers.Plugins.AudioDb { public class AudioDbArtistImageProvider : IRemoteImageProvider, IHasOrder { - private readonly IServerConfigurationManager _config; private readonly IHttpClientFactory _httpClientFactory; - private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; - public AudioDbArtistImageProvider(IServerConfigurationManager config, IHttpClientFactory httpClientFactory) + public AudioDbArtistImageProvider(IHttpClientFactory httpClientFactory) { - _config = config; _httpClientFactory = httpClientFactory; } @@ -54,22 +46,14 @@ namespace MediaBrowser.Providers.Plugins.AudioDb /// <inheritdoc /> public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) { - if (item.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out var id)) - { - await AudioDbArtistProvider.Current.EnsureArtistInfo(id, cancellationToken).ConfigureAwait(false); - - var path = AudioDbArtistProvider.GetArtistInfoPath(_config.ApplicationPaths, id); + item.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out var musicBrainzId); + item.TryGetProviderId(MetadataProvider.AudioDbArtist, out var audioDbId); - FileStream jsonStream = AsyncFile.OpenRead(path); - await using (jsonStream.ConfigureAwait(false)) - { - var obj = await JsonSerializer.DeserializeAsync<AudioDbArtistProvider.RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); + var artist = await AudioDbArtistProvider.Current.GetArtist(musicBrainzId, audioDbId, cancellationToken).ConfigureAwait(false); - if (obj is not null && obj.artists is not null && obj.artists.Count > 0) - { - return GetImages(obj.artists[0]); - } - } + if (artist is not null) + { + return GetImages(artist); } return []; diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs index d8cb6b4b24..c4f4833857 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs @@ -4,9 +4,11 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Net.Http; +using System.Net.Http.Json; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -52,45 +54,176 @@ namespace MediaBrowser.Providers.Plugins.AudioDb public int Order => 1; /// <inheritdoc /> - public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ArtistInfo searchInfo, CancellationToken cancellationToken) - => Task.FromResult(Enumerable.Empty<RemoteSearchResult>()); - - /// <inheritdoc /> - public async Task<MetadataResult<MusicArtist>> GetMetadata(ArtistInfo info, CancellationToken cancellationToken) + public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ArtistInfo searchInfo, CancellationToken cancellationToken) { - var result = new MetadataResult<MusicArtist>(); - var id = info.GetMusicBrainzArtistId(); + // Prefer a known TheAudioDB artist id. + var audioDbId = searchInfo.GetProviderId(MetadataProvider.AudioDbArtist); + if (!string.IsNullOrWhiteSpace(audioDbId)) + { + var artists = await FetchArtists(BaseUrl + "/artist.php?i=" + audioDbId, cancellationToken).ConfigureAwait(false); + return artists.Select(ToRemoteSearchResult); + } - if (!string.IsNullOrWhiteSpace(id)) + // Fall back to the MusicBrainz artist id, reusing the on-disk cache also used by GetMetadata. + var musicBrainzId = searchInfo.GetMusicBrainzArtistId(); + if (!string.IsNullOrWhiteSpace(musicBrainzId)) { - await EnsureArtistInfo(id, cancellationToken).ConfigureAwait(false); + await EnsureArtistInfo(musicBrainzId, cancellationToken).ConfigureAwait(false); - var path = GetArtistInfoPath(_config.ApplicationPaths, id); + var path = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId); FileStream jsonStream = AsyncFile.OpenRead(path); await using (jsonStream.ConfigureAwait(false)) { var obj = await JsonSerializer.DeserializeAsync<RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); - if (obj is not null && obj.artists is not null && obj.artists.Count > 0) + if (obj is not null && obj.artists is not null) { - result.Item = new MusicArtist(); - result.HasMetadata = true; - ProcessResult(result.Item, obj.artists[0], info.MetadataLanguage); + return obj.artists.Select(ToRemoteSearchResult); } } + + return []; + } + + // Finally, search by name. + if (!string.IsNullOrWhiteSpace(searchInfo.Name)) + { + var artists = await FetchArtists(BaseUrl + "/search.php?s=" + Uri.EscapeDataString(searchInfo.Name), cancellationToken).ConfigureAwait(false); + return artists.Select(ToRemoteSearchResult); + } + + return []; + } + + private async Task<List<Artist>> FetchArtists(string url, CancellationToken cancellationToken) + { + using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + + var obj = await response.Content.ReadFromJsonAsync<RootObject>(_jsonOptions, cancellationToken).ConfigureAwait(false); + + return obj?.artists ?? []; + } + + private RemoteSearchResult ToRemoteSearchResult(Artist artist) + { + var result = new RemoteSearchResult + { + Name = artist.strArtist, + ImageUrl = artist.strArtistThumb, + SearchProviderName = Name, + Overview = (artist.strBiographyEN ?? string.Empty).StripHtml() + }; + + if (!string.IsNullOrEmpty(artist.idArtist)) + { + result.SetProviderId(MetadataProvider.AudioDbArtist, artist.idArtist); + } + + if (!string.IsNullOrEmpty(artist.strMusicBrainzID)) + { + result.SetProviderId(MetadataProvider.MusicBrainzArtist, artist.strMusicBrainzID); + } + + if (int.TryParse(artist.intFormedYear, NumberStyles.Integer, CultureInfo.InvariantCulture, out var formedYear)) + { + result.ProductionYear = formedYear; } return result; } + /// <inheritdoc /> + public async Task<MetadataResult<MusicArtist>> GetMetadata(ArtistInfo info, CancellationToken cancellationToken) + { + var result = new MetadataResult<MusicArtist>(); + + var artist = await GetArtist( + info.GetMusicBrainzArtistId(), + info.GetProviderId(MetadataProvider.AudioDbArtist), + cancellationToken).ConfigureAwait(false); + + if (artist is not null) + { + result.Item = new MusicArtist(); + result.HasMetadata = true; + ProcessResult(result.Item, artist, info.MetadataLanguage); + } + + return result; + } + + /// <summary> + /// Resolves the cached AudioDB artist, preferring the MusicBrainz id and falling back to the AudioDB id. + /// </summary> + /// <param name="musicBrainzId">The MusicBrainz artist id, if known.</param> + /// <param name="audioDbId">The TheAudioDB artist id, if known.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>The matching artist, or <c>null</c> if none could be resolved.</returns> + internal async Task<Artist> GetArtist(string musicBrainzId, string audioDbId, CancellationToken cancellationToken) + { + string path; + if (!string.IsNullOrWhiteSpace(musicBrainzId)) + { + await EnsureArtistInfo(musicBrainzId, cancellationToken).ConfigureAwait(false); + path = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId); + } + else if (!string.IsNullOrWhiteSpace(audioDbId)) + { + await EnsureArtistInfoByAudioDbId(audioDbId, cancellationToken).ConfigureAwait(false); + path = GetArtistInfoPath(_config.ApplicationPaths, audioDbId); + } + else + { + return null; + } + + FileStream jsonStream = AsyncFile.OpenRead(path); + await using (jsonStream.ConfigureAwait(false)) + { + var obj = await JsonSerializer.DeserializeAsync<RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); + + if (obj is not null && obj.artists is not null && obj.artists.Count > 0) + { + return obj.artists[0]; + } + } + + return null; + } + private void ProcessResult(MusicArtist item, Artist result, string preferredLanguage) { - // item.HomePageUrl = result.strWebsite; + if (!string.IsNullOrWhiteSpace(result.strWebsite)) + { + item.HomePageUrl = result.strWebsite; + } + + var genres = new List<string>(); + if (!string.IsNullOrWhiteSpace(result.strGenre)) + { + genres.Add(result.strGenre); + } + + if (!string.IsNullOrWhiteSpace(result.strSubGenre)) + { + genres.Add(result.strSubGenre); + } + + if (genres.Count > 0) + { + item.Genres = genres.ToArray(); + } + + if (int.TryParse(result.intFormedYear, NumberStyles.Integer, CultureInfo.InvariantCulture, out var formedYear)) + { + item.ProductionYear = formedYear; + } - if (!string.IsNullOrEmpty(result.strGenre)) + if (!string.IsNullOrWhiteSpace(result.strCountry)) { - item.Genres = new[] { result.strGenre }; + item.ProductionLocations = new[] { result.strCountry }; } item.SetProviderId(MetadataProvider.AudioDbArtist, result.idArtist); @@ -150,13 +283,32 @@ namespace MediaBrowser.Providers.Plugins.AudioDb internal async Task DownloadArtistInfo(string musicBrainzId, CancellationToken cancellationToken) { - cancellationToken.ThrowIfCancellationRequested(); - var url = BaseUrl + "/artist-mb.php?i=" + musicBrainzId; + await DownloadArtistInfo(url, GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId), cancellationToken).ConfigureAwait(false); + } + + internal async Task EnsureArtistInfoByAudioDbId(string audioDbId, CancellationToken cancellationToken) + { + var xmlPath = GetArtistInfoPath(_config.ApplicationPaths, audioDbId); + + var fileInfo = _fileSystem.GetFileSystemInfo(xmlPath); + + if (fileInfo.Exists + && (DateTime.UtcNow - _fileSystem.GetLastWriteTimeUtc(fileInfo)).TotalDays <= 2) + { + return; + } + + var url = BaseUrl + "/artist.php?i=" + audioDbId; + await DownloadArtistInfo(url, xmlPath, cancellationToken).ConfigureAwait(false); + } + + private async Task DownloadArtistInfo(string url, string path, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); - var path = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId); Directory.CreateDirectory(Path.GetDirectoryName(path)); var fileStreamOptions = AsyncFile.WriteOptions; diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs index 715bdd9da4..397c916a4f 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs @@ -155,7 +155,6 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu /// <inheritdoc /> public async Task<MetadataResult<MusicAlbum>> GetMetadata(AlbumInfo info, CancellationToken cancellationToken) { - // TODO: This sets essentially nothing. As-is, it's mostly useless. Make it actually pull metadata and use it. var query = MusicBrainz.Plugin.Instance!.MusicBrainzQuery; var releaseId = info.GetReleaseId(); var releaseGroupId = info.GetReleaseGroupId(); @@ -169,13 +168,8 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu if (string.IsNullOrWhiteSpace(releaseId) && !string.IsNullOrWhiteSpace(releaseGroupId)) { // TODO: Actually try to match the release. Simply taking the first result is stupid. - var releaseGroup = await query.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.None, null, cancellationToken).ConfigureAwait(false); - var release = releaseGroup.Releases?.Count > 0 ? releaseGroup.Releases[0] : null; - if (release is not null) - { - releaseId = release.Id.ToString(); - result.HasMetadata = true; - } + var releaseGroupLookup = await query.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.None, null, cancellationToken).ConfigureAwait(false); + releaseId = releaseGroupLookup.Releases?.Count > 0 ? releaseGroupLookup.Releases[0].Id.ToString() : null; } // If there is no release ID, lookup a release with the info we have @@ -205,43 +199,106 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu { releaseGroupId = releaseResult.ReleaseGroup.Id.ToString(); } - - result.HasMetadata = true; - result.Item.ProductionYear = releaseResult.Date?.Year; - result.Item.Overview = releaseResult.Annotation; } } - // If we have a release ID but not a release group ID, lookup the release group - if (!string.IsNullOrWhiteSpace(releaseId) && string.IsNullOrWhiteSpace(releaseGroupId)) + if (string.IsNullOrWhiteSpace(releaseId) && string.IsNullOrWhiteSpace(releaseGroupId)) { - var release = await query.LookupReleaseAsync(new Guid(releaseId), Include.ReleaseGroups, cancellationToken).ConfigureAwait(false); - releaseGroupId = release.ReleaseGroup?.Id.ToString(); - result.HasMetadata = true; + return result; } - // If we have a release ID and a release group ID - if (!string.IsNullOrWhiteSpace(releaseId) || !string.IsNullOrWhiteSpace(releaseGroupId)) + // Fetch the full release (and its release group) so we can populate everything MusicBrainz returns. + IRelease? release = null; + if (!string.IsNullOrWhiteSpace(releaseId)) { - result.HasMetadata = true; - } + release = await query.LookupReleaseAsync( + new Guid(releaseId), + Include.Artists | Include.ReleaseGroups | Include.Labels | Include.Genres | Include.Tags, + cancellationToken).ConfigureAwait(false); - if (result.HasMetadata) - { - if (!string.IsNullOrEmpty(releaseId)) + if (string.IsNullOrWhiteSpace(releaseGroupId) && release?.ReleaseGroup?.Id is not null) { - result.Item.SetProviderId(MetadataProvider.MusicBrainzAlbum, releaseId); + releaseGroupId = release.ReleaseGroup.Id.ToString(); } + } - if (!string.IsNullOrEmpty(releaseGroupId)) - { - result.Item.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, releaseGroupId); - } + IReleaseGroup? releaseGroup = null; + if (!string.IsNullOrWhiteSpace(releaseGroupId)) + { + releaseGroup = await query.LookupReleaseGroupAsync( + new Guid(releaseGroupId), + Include.Artists | Include.Genres | Include.Tags, + null, + cancellationToken).ConfigureAwait(false); + } + + result.HasMetadata = true; + + if (!string.IsNullOrEmpty(releaseId)) + { + result.Item.SetProviderId(MetadataProvider.MusicBrainzAlbum, releaseId); + } + + if (!string.IsNullOrEmpty(releaseGroupId)) + { + result.Item.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, releaseGroupId); } + Populate(result.Item, release, releaseGroup); + return result; } + private static void Populate(MusicAlbum item, IRelease? release, IReleaseGroup? releaseGroup) + { + // Prefer the release group (album-level) data, falling back to the specific release. + // The release group's first release date is the original album date. + var date = releaseGroup?.FirstReleaseDate ?? release?.Date; + if (date is not null) + { + item.PremiereDate = date.NearestDate; + item.ProductionYear = date.Year; + } + + var artistCredit = release?.ArtistCredit ?? releaseGroup?.ArtistCredit; + if (artistCredit is not null && artistCredit.Count > 0) + { + item.AlbumArtists = artistCredit + .Select(credit => credit.Name) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToArray(); + } + + var genres = releaseGroup?.Genres ?? release?.Genres; + if (genres is not null && genres.Count > 0) + { + item.Genres = genres + .OrderByDescending(genre => genre.VoteCount) + .Select(genre => genre.Name) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToArray(); + } + + var tags = releaseGroup?.Tags ?? release?.Tags; + if (tags is not null && tags.Count > 0) + { + item.Tags = tags + .OrderByDescending(tag => tag.VoteCount) + .Select(tag => tag.Name) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToArray(); + } + + if (release?.LabelInfo is not null && release.LabelInfo.Count > 0) + { + item.Studios = release.LabelInfo + .Where(labelInfo => !string.IsNullOrWhiteSpace(labelInfo.Label?.Name)) + .Select(labelInfo => labelInfo.Label!.Name!) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + } + /// <inheritdoc /> public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs index 0fe4e6bb16..a9e950fb64 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs @@ -40,6 +40,11 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar return GetResultFromResponse(artistResult).SingleItemAsEnumerable(); } + if (string.IsNullOrWhiteSpace(searchInfo.Name)) + { + return []; + } + var artistSearchResults = await query.FindArtistsAsync($"\"{searchInfo.Name}\"", null, null, false, cancellationToken) .ConfigureAwait(false); if (artistSearchResults.Results.Count > 0) @@ -58,7 +63,7 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar } } - return Enumerable.Empty<RemoteSearchResult>(); + return []; } private IEnumerable<RemoteSearchResult> GetResultsFromResponse(IEnumerable<ISearchResult<IArtist>>? releaseSearchResults) @@ -96,28 +101,67 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar var musicBrainzId = info.GetMusicBrainzArtistId(); + // If we don't have an id yet, resolve one by name so we can look the artist up. if (string.IsNullOrWhiteSpace(musicBrainzId)) { var searchResults = await GetSearchResults(info, cancellationToken).ConfigureAwait(false); + musicBrainzId = searchResults.FirstOrDefault()?.GetProviderId(MetadataProvider.MusicBrainzArtist); + } - var singleResult = searchResults.FirstOrDefault(); + if (string.IsNullOrWhiteSpace(musicBrainzId)) + { + return result; + } - if (singleResult is not null) - { - musicBrainzId = singleResult.GetProviderId(MetadataProvider.MusicBrainzArtist); - result.Item.Overview = singleResult.Overview; + var query = Plugin.Instance!.MusicBrainzQuery; + var artist = await query.LookupArtistAsync(new Guid(musicBrainzId), Include.Genres | Include.Tags, null, null, cancellationToken).ConfigureAwait(false); - if (Plugin.Instance!.Configuration.ReplaceArtistName) - { - result.Item.Name = singleResult.Name; - } - } + if (artist is null) + { + return result; + } + + result.HasMetadata = true; + result.Item.SetProviderId(MetadataProvider.MusicBrainzArtist, artist.Id.ToString()); + + if (Plugin.Instance!.Configuration.ReplaceArtistName && !string.IsNullOrWhiteSpace(artist.Name)) + { + result.Item.Name = artist.Name; + } + + if (artist.LifeSpan?.Begin is not null) + { + result.Item.PremiereDate = artist.LifeSpan.Begin.NearestDate; + result.Item.ProductionYear = artist.LifeSpan.Begin.Year; + } + + if (artist.LifeSpan?.End is not null) + { + result.Item.EndDate = artist.LifeSpan.End.NearestDate; + } + + var location = string.IsNullOrWhiteSpace(artist.Area?.Name) ? artist.Country : artist.Area!.Name; + if (!string.IsNullOrWhiteSpace(location)) + { + result.Item.ProductionLocations = [location]; + } + + if (artist.Genres is not null && artist.Genres.Count > 0) + { + result.Item.Genres = artist.Genres + .OrderByDescending(genre => genre.VoteCount) + .Select(genre => genre.Name) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToArray(); } - if (!string.IsNullOrWhiteSpace(musicBrainzId)) + if (artist.Tags is not null && artist.Tags.Count > 0) { - result.HasMetadata = true; - result.Item.SetProviderId(MetadataProvider.MusicBrainzArtist, musicBrainzId); + result.Item.Tags = artist.Tags + .OrderByDescending(tag => tag.VoteCount) + .Select(tag => tag.Name) + .Where(name => !string.IsNullOrWhiteSpace(name)) + .ToArray(); } return result; diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/LinkedChildEntity.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/LinkedChildEntity.cs index 7361775711..be315f1b2c 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/LinkedChildEntity.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/Entities/LinkedChildEntity.cs @@ -25,7 +25,7 @@ public class LinkedChildEntity /// <summary> /// Gets or sets the sort order. /// </summary> - public int? SortOrder { get; set; } + public int SortOrder { get; set; } /// <summary> /// Gets or sets the parent item navigation property. diff --git a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/LinkedChildConfiguration.cs b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/LinkedChildConfiguration.cs index 2abccd41f0..b4013a394f 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/LinkedChildConfiguration.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Implementations/ModelConfiguration/LinkedChildConfiguration.cs @@ -13,8 +13,7 @@ public class LinkedChildConfiguration : IEntityTypeConfiguration<LinkedChildEnti public void Configure(EntityTypeBuilder<LinkedChildEntity> builder) { builder.ToTable("LinkedChildren"); - builder.HasKey(e => new { e.ParentId, e.ChildId }); - builder.HasIndex(e => new { e.ParentId, e.SortOrder }); + builder.HasKey(e => new { e.ParentId, e.SortOrder }); builder.HasIndex(e => new { e.ParentId, e.ChildType }); builder.HasIndex(e => new { e.ChildId, e.ChildType }); diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260723111547_AllowDuplicatePlaylistChildren.Designer.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260723111547_AllowDuplicatePlaylistChildren.Designer.cs new file mode 100644 index 0000000000..d5b6bd1d51 --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260723111547_AllowDuplicatePlaylistChildren.Designer.cs @@ -0,0 +1,1808 @@ +// <auto-generated /> +using System; +using Jellyfin.Database.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Database.Providers.Sqlite.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20260723111547_AllowDuplicatePlaylistChildren")] + partial class AllowDuplicatePlaylistChildren + { + /// <inheritdoc /> + 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<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DayOfWeek") + .HasColumnType("INTEGER"); + + b.Property<double>("EndHour") + .HasColumnType("REAL"); + + b.Property<double>("StartHour") + .HasColumnType("REAL"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AccessSchedules"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ActivityLog", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<string>("ItemId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<int>("LogSeverity") + .HasColumnType("INTEGER"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Overview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("ShortOverview") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<string>("Type") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<Guid>("ParentItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ParentItemId"); + + b.HasIndex("ParentItemId"); + + b.ToTable("AncestorIds"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("Index") + .HasColumnType("INTEGER"); + + b.Property<string>("Codec") + .HasColumnType("TEXT"); + + b.Property<string>("CodecTag") + .HasColumnType("TEXT"); + + b.Property<string>("Comment") + .HasColumnType("TEXT"); + + b.Property<string>("Filename") + .HasColumnType("TEXT"); + + b.Property<string>("MimeType") + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "Index"); + + b.ToTable("AttachmentStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("Album") + .HasColumnType("TEXT"); + + b.Property<string>("AlbumArtists") + .HasColumnType("TEXT"); + + b.Property<string>("Artists") + .HasColumnType("TEXT"); + + b.Property<int?>("Audio") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("ChannelId") + .HasColumnType("TEXT"); + + b.Property<string>("CleanName") + .HasColumnType("TEXT"); + + b.Property<float?>("CommunityRating") + .HasColumnType("REAL"); + + b.Property<float?>("CriticRating") + .HasColumnType("REAL"); + + b.Property<string>("CustomRating") + .HasColumnType("TEXT"); + + b.Property<string>("Data") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateLastMediaAdded") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateLastRefreshed") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateLastSaved") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DateModified") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("EndDate") + .HasColumnType("TEXT"); + + b.Property<string>("EpisodeTitle") + .HasColumnType("TEXT"); + + b.Property<string>("ExternalId") + .HasColumnType("TEXT"); + + b.Property<string>("ExternalSeriesId") + .HasColumnType("TEXT"); + + b.Property<string>("ExternalServiceId") + .HasColumnType("TEXT"); + + b.Property<int?>("ExtraType") + .HasColumnType("INTEGER"); + + b.Property<string>("ForcedSortName") + .HasColumnType("TEXT"); + + b.Property<string>("Genres") + .HasColumnType("TEXT"); + + b.Property<int?>("Height") + .HasColumnType("INTEGER"); + + b.Property<int?>("IndexNumber") + .HasColumnType("INTEGER"); + + b.Property<int?>("InheritedParentalRatingSubValue") + .HasColumnType("INTEGER"); + + b.Property<int?>("InheritedParentalRatingValue") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsFolder") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsInMixedFolder") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsLocked") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsMovie") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsRepeat") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsSeries") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsVirtualItem") + .HasColumnType("INTEGER"); + + b.Property<float?>("LUFS") + .HasColumnType("REAL"); + + b.Property<string>("MediaType") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .HasColumnType("TEXT"); + + b.Property<float?>("NormalizationGain") + .HasColumnType("REAL"); + + b.Property<string>("OfficialRating") + .HasColumnType("TEXT"); + + b.Property<string>("OriginalLanguage") + .HasColumnType("TEXT"); + + b.Property<string>("OriginalTitle") + .HasColumnType("TEXT"); + + b.Property<string>("Overview") + .HasColumnType("TEXT"); + + b.Property<Guid?>("OwnerId") + .HasColumnType("TEXT"); + + b.Property<Guid?>("ParentId") + .HasColumnType("TEXT"); + + b.Property<int?>("ParentIndexNumber") + .HasColumnType("INTEGER"); + + b.Property<string>("Path") + .HasColumnType("TEXT"); + + b.Property<string>("PreferredMetadataCountryCode") + .HasColumnType("TEXT"); + + b.Property<string>("PreferredMetadataLanguage") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("PremiereDate") + .HasColumnType("TEXT"); + + b.Property<string>("PresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property<Guid?>("PrimaryVersionId") + .HasColumnType("TEXT"); + + b.Property<string>("ProductionLocations") + .HasColumnType("TEXT"); + + b.Property<int?>("ProductionYear") + .HasColumnType("INTEGER"); + + b.Property<long?>("RunTimeTicks") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("SeasonId") + .HasColumnType("TEXT"); + + b.Property<string>("SeasonName") + .HasColumnType("TEXT"); + + b.Property<Guid?>("SeriesId") + .HasColumnType("TEXT"); + + b.Property<string>("SeriesName") + .HasColumnType("TEXT"); + + b.Property<string>("SeriesPresentationUniqueKey") + .HasColumnType("TEXT"); + + b.Property<string>("ShowId") + .HasColumnType("TEXT"); + + b.Property<long?>("Size") + .HasColumnType("INTEGER"); + + b.Property<string>("SortName") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("StartDate") + .HasColumnType("TEXT"); + + b.Property<string>("Studios") + .HasColumnType("TEXT"); + + b.Property<string>("Tagline") + .HasColumnType("TEXT"); + + b.Property<string>("Tags") + .HasColumnType("TEXT"); + + b.Property<Guid?>("TopParentId") + .HasColumnType("TEXT"); + + b.Property<int?>("TotalBitrate") + .HasColumnType("INTEGER"); + + b.Property<string>("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("UnratedType") + .HasColumnType("TEXT"); + + b.Property<int?>("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.HasIndex("OwnerId"); + + b.HasIndex("ParentId"); + + b.HasIndex("Path"); + + b.HasIndex("PresentationUniqueKey"); + + b.HasIndex("SeasonId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("SeriesName"); + + b.HasIndex("ExtraType", "OwnerId"); + + b.HasIndex("TopParentId", "Id"); + + b.HasIndex("Type", "CleanName"); + + b.HasIndex("TopParentId", "Type", "IsVirtualItem") + .HasFilter("\"PrimaryVersionId\" IS NULL AND (\"OwnerId\" IS NULL OR \"ExtraType\" IS NOT NULL)"); + + b.HasIndex("Type", "TopParentId", "Id"); + + b.HasIndex("Type", "TopParentId", "PresentationUniqueKey"); + + b.HasIndex("Type", "TopParentId", "SortName"); + + b.HasIndex("Type", "TopParentId", "StartDate"); + + b.HasIndex("MediaType", "TopParentId", "IsVirtualItem", "PresentationUniqueKey"); + + b.HasIndex("TopParentId", "IsFolder", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "MediaType", "IsVirtualItem", "DateCreated"); + + b.HasIndex("TopParentId", "Type", "IsVirtualItem", "DateCreated"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "IsFolder", "IsVirtualItem"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "ParentIndexNumber", "IndexNumber"); + + b.HasIndex("Type", "SeriesPresentationUniqueKey", "PresentationUniqueKey", "SortName"); + + b.HasIndex("IsFolder", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.HasIndex("Type", "TopParentId", "IsVirtualItem", "PresentationUniqueKey", "DateCreated"); + + b.ToTable("BaseItems"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + + b.HasData( + new + { + Id = new Guid("00000000-0000-0000-0000-000000000001"), + IsFolder = false, + IsInMixedFolder = false, + IsLocked = false, + IsMovie = false, + IsRepeat = false, + IsSeries = false, + IsVirtualItem = false, + Name = "This is a placeholder item for UserData that has been detached from its original item", + Type = "PLACEHOLDER" + }); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<byte[]>("Blurhash") + .HasColumnType("BLOB"); + + b.Property<DateTime?>("DateModified") + .HasColumnType("TEXT"); + + b.Property<int>("Height") + .HasColumnType("INTEGER"); + + b.Property<int>("ImageType") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<int>("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ItemId", "ImageType"); + + b.ToTable("BaseItemImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.Property<int>("Id") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemMetadataFields"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("ProviderId") + .HasColumnType("TEXT"); + + b.Property<string>("ProviderValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemId", "ProviderId"); + + b.HasIndex("ProviderId", "ItemId", "ProviderValue"); + + b.ToTable("BaseItemProviders"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.Property<int>("Id") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("Id", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("BaseItemTrailerTypes"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("ChapterIndex") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("ImageDateModified") + .HasColumnType("TEXT"); + + b.Property<string>("ImagePath") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .HasColumnType("TEXT"); + + b.Property<long>("StartPositionTicks") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "ChapterIndex"); + + b.ToTable("Chapters"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.CustomItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("Value") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client", "Key") + .IsUnique(); + + b.ToTable("CustomItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("ChromecastVersion") + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<string>("DashboardTheme") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<bool>("EnableNextVideoInfoOverlay") + .HasColumnType("INTEGER"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("ScrollDirection") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowBackdrop") + .HasColumnType("INTEGER"); + + b.Property<bool>("ShowSidebar") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipBackwardLength") + .HasColumnType("INTEGER"); + + b.Property<int>("SkipForwardLength") + .HasColumnType("INTEGER"); + + b.Property<string>("TvHome") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "ItemId", "Client") + .IsUnique(); + + b.ToTable("DisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("DisplayPreferencesId") + .HasColumnType("INTEGER"); + + b.Property<int>("Order") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DisplayPreferencesId"); + + b.ToTable("HomeSection"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<DateTime>("LastModified") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("ImageInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Client") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<int?>("IndexBy") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<bool>("RememberIndexing") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSorting") + .HasColumnType("INTEGER"); + + b.Property<string>("SortBy") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<int>("ViewType") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("ItemDisplayPreferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Property<Guid>("ItemValueId") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("CleanValue") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.Property<string>("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId"); + + b.HasIndex("Type", "CleanValue"); + + b.HasIndex("Type", "Value") + .IsUnique(); + + b.ToTable("ItemValues"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.Property<Guid>("ItemValueId") + .HasColumnType("TEXT"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.HasKey("ItemValueId", "ItemId"); + + b.HasIndex("ItemId"); + + b.ToTable("ItemValuesMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.PrimitiveCollection<string>("KeyframeTicks") + .HasColumnType("TEXT"); + + b.Property<long>("TotalDuration") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId"); + + b.ToTable("KeyframeData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.Property<Guid>("ParentId") + .HasColumnType("TEXT"); + + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ChildId") + .HasColumnType("TEXT"); + + b.Property<int>("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<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<long>("EndTicks") + .HasColumnType("INTEGER"); + + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<string>("SegmentProviderId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<long>("StartTicks") + .HasColumnType("INTEGER"); + + b.Property<int>("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("MediaSegments"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("StreamIndex") + .HasColumnType("INTEGER"); + + b.Property<string>("AspectRatio") + .HasColumnType("TEXT"); + + b.Property<float?>("AverageFrameRate") + .HasColumnType("REAL"); + + b.Property<int?>("BitDepth") + .HasColumnType("INTEGER"); + + b.Property<int?>("BitRate") + .HasColumnType("INTEGER"); + + b.Property<int?>("BlPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<string>("ChannelLayout") + .HasColumnType("TEXT"); + + b.Property<int?>("Channels") + .HasColumnType("INTEGER"); + + b.Property<string>("Codec") + .HasColumnType("TEXT"); + + b.Property<string>("CodecTag") + .HasColumnType("TEXT"); + + b.Property<string>("CodecTimeBase") + .HasColumnType("TEXT"); + + b.Property<string>("ColorPrimaries") + .HasColumnType("TEXT"); + + b.Property<string>("ColorSpace") + .HasColumnType("TEXT"); + + b.Property<string>("ColorTransfer") + .HasColumnType("TEXT"); + + b.Property<string>("Comment") + .HasColumnType("TEXT"); + + b.Property<int?>("DvBlSignalCompatibilityId") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvLevel") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvProfile") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvVersionMajor") + .HasColumnType("INTEGER"); + + b.Property<int?>("DvVersionMinor") + .HasColumnType("INTEGER"); + + b.Property<int?>("ElPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<bool?>("Hdr10PlusPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<int?>("Height") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsAnamorphic") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsAvc") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsDefault") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsExternal") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsForced") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsHearingImpaired") + .HasColumnType("INTEGER"); + + b.Property<bool?>("IsInterlaced") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsOriginal") + .HasColumnType("INTEGER"); + + b.Property<string>("KeyFrames") + .HasColumnType("TEXT"); + + b.Property<string>("Language") + .HasColumnType("TEXT"); + + b.Property<float?>("Level") + .HasColumnType("REAL"); + + b.Property<string>("NalLengthSize") + .HasColumnType("TEXT"); + + b.Property<string>("Path") + .HasColumnType("TEXT"); + + b.Property<string>("PixelFormat") + .HasColumnType("TEXT"); + + b.Property<string>("Profile") + .HasColumnType("TEXT"); + + b.Property<float?>("RealFrameRate") + .HasColumnType("REAL"); + + b.Property<int?>("RefFrames") + .HasColumnType("INTEGER"); + + b.Property<int?>("Rotation") + .HasColumnType("INTEGER"); + + b.Property<int?>("RpuPresentFlag") + .HasColumnType("INTEGER"); + + b.Property<int?>("SampleRate") + .HasColumnType("INTEGER"); + + b.Property<int>("StreamType") + .HasColumnType("INTEGER"); + + b.Property<string>("TimeBase") + .HasColumnType("TEXT"); + + b.Property<string>("Title") + .HasColumnType("TEXT"); + + b.Property<int?>("Width") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "StreamIndex"); + + b.ToTable("MediaStreamInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("PersonType") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name"); + + b.ToTable("Peoples"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<Guid>("PeopleId") + .HasColumnType("TEXT"); + + b.Property<string>("Role") + .HasColumnType("TEXT"); + + b.Property<int?>("ListOrder") + .HasColumnType("INTEGER"); + + b.Property<int?>("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "PeopleId", "Role"); + + b.HasIndex("PeopleId"); + + b.HasIndex("ItemId", "ListOrder"); + + b.HasIndex("ItemId", "SortOrder"); + + b.ToTable("PeopleBaseItemMap"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("Permission_Permissions_Guid") + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.Property<bool>("Value") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Permissions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<Guid?>("Preference_Preferences_Guid") + .HasColumnType("TEXT"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<Guid?>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("Value") + .IsRequired() + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Kind") + .IsUnique() + .HasFilter("[UserId] IS NOT NULL"); + + b.ToTable("Preferences"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.ApiKey", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken") + .IsUnique(); + + b.ToTable("ApiKeys"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AccessToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("AppName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<string>("AppVersion") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateCreated") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateLastActivity") + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateModified") + .HasColumnType("TEXT"); + + b.Property<string>("DeviceId") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property<string>("DeviceName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property<bool>("IsActive") + .HasColumnType("INTEGER"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccessToken", "DateLastActivity"); + + b.HasIndex("DeviceId", "DateLastActivity"); + + b.HasIndex("UserId", "DeviceId"); + + b.ToTable("Devices"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.DeviceOptions", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("CustomName") + .HasColumnType("TEXT"); + + b.Property<string>("DeviceId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId") + .IsUnique(); + + b.ToTable("DeviceOptions"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.TrickplayInfo", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<int>("Width") + .HasColumnType("INTEGER"); + + b.Property<int>("Bandwidth") + .HasColumnType("INTEGER"); + + b.Property<int>("Height") + .HasColumnType("INTEGER"); + + b.Property<int>("Interval") + .HasColumnType("INTEGER"); + + b.Property<int>("ThumbnailCount") + .HasColumnType("INTEGER"); + + b.Property<int>("TileHeight") + .HasColumnType("INTEGER"); + + b.Property<int>("TileWidth") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "Width"); + + b.ToTable("TrickplayInfos"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<bool>("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property<bool>("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property<bool>("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property<long>("InternalId") + .HasColumnType("INTEGER"); + + b.Property<int>("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property<int?>("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property<int>("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property<int?>("MaxParentalRatingScore") + .HasColumnType("INTEGER"); + + b.Property<int?>("MaxParentalRatingSubScore") + .HasColumnType("INTEGER"); + + b.Property<bool>("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property<string>("NormalizedUsername") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property<string>("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<bool>("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property<int?>("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<int>("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property<int>("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property<string>("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.Property<Guid>("ItemId") + .HasColumnType("TEXT"); + + b.Property<Guid>("UserId") + .HasColumnType("TEXT"); + + b.Property<string>("CustomDataKey") + .HasColumnType("TEXT"); + + b.Property<int?>("AudioStreamIndex") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsFavorite") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("LastPlayedDate") + .HasColumnType("TEXT"); + + b.Property<bool?>("Likes") + .HasColumnType("INTEGER"); + + b.Property<int>("PlayCount") + .HasColumnType("INTEGER"); + + b.Property<long>("PlaybackPositionTicks") + .HasColumnType("INTEGER"); + + b.Property<bool>("Played") + .HasColumnType("INTEGER"); + + b.Property<double?>("Rating") + .HasColumnType("REAL"); + + b.Property<DateTime?>("RetentionDate") + .HasColumnType("TEXT"); + + b.Property<int?>("SubtitleStreamIndex") + .HasColumnType("INTEGER"); + + b.HasKey("ItemId", "UserId", "CustomDataKey"); + + b.HasIndex("ItemId", "UserId", "IsFavorite"); + + b.HasIndex("ItemId", "UserId", "LastPlayedDate"); + + b.HasIndex("ItemId", "UserId", "PlaybackPositionTicks"); + + b.HasIndex("ItemId", "UserId", "Played"); + + b.HasIndex("UserId", "IsFavorite", "ItemId"); + + b.HasIndex("UserId", "ItemId", "LastPlayedDate"); + + b.HasIndex("UserId", "Played", "ItemId"); + + b.ToTable("UserData"); + + b.HasAnnotation("Sqlite:UseSqlReturningClause", false); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("AccessSchedules") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AncestorId", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Parents") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "ParentItem") + .WithMany("Children") + .HasForeignKey("ParentItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ParentItem"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AttachmentStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Owner") + .WithMany("Extras") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.NoAction); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "DirectParent") + .WithMany("DirectChildren") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("DirectParent"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Images") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemMetadataField", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("LockedFields") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemProvider", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Provider") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemTrailerType", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("TrailerTypes") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Chapter", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Chapters") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("DisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.HomeSection", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.DisplayPreferences", null) + .WithMany("HomeSections") + .HasForeignKey("DisplayPreferencesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ImageInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithOne("ProfileImage") + .HasForeignKey("Jellyfin.Database.Implementations.Entities.ImageInfo", "UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemDisplayPreferences", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("ItemDisplayPreferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValueMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("ItemValues") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.ItemValue", "ItemValue") + .WithMany("BaseItemsMap") + .HasForeignKey("ItemValueId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("ItemValue"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.KeyframeData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany() + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.LinkedChildEntity", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Child") + .WithMany("LinkedChildOfEntities") + .HasForeignKey("ChildId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Parent") + .WithMany("LinkedChildEntities") + .HasForeignKey("ParentId") + .OnDelete(DeleteBehavior.NoAction) + .IsRequired(); + + b.Navigation("Child"); + + b.Navigation("Parent"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.MediaStreamInfo", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("MediaStreams") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.PeopleBaseItemMap", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("Peoples") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.People", "People") + .WithMany("BaseItems") + .HasForeignKey("PeopleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("People"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Permission", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Permissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Preference", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", null) + .WithMany("Preferences") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.Security.Device", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.UserData", b => + { + b.HasOne("Jellyfin.Database.Implementations.Entities.BaseItemEntity", "Item") + .WithMany("UserData") + .HasForeignKey("ItemId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Jellyfin.Database.Implementations.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Item"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.BaseItemEntity", b => + { + b.Navigation("Chapters"); + + b.Navigation("Children"); + + b.Navigation("DirectChildren"); + + b.Navigation("Extras"); + + b.Navigation("Images"); + + b.Navigation("ItemValues"); + + b.Navigation("LinkedChildEntities"); + + b.Navigation("LinkedChildOfEntities"); + + b.Navigation("LockedFields"); + + b.Navigation("MediaStreams"); + + b.Navigation("Parents"); + + b.Navigation("Peoples"); + + b.Navigation("Provider"); + + b.Navigation("TrailerTypes"); + + b.Navigation("UserData"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.DisplayPreferences", b => + { + b.Navigation("HomeSections"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.ItemValue", b => + { + b.Navigation("BaseItemsMap"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.People", b => + { + b.Navigation("BaseItems"); + }); + + modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.User", b => + { + b.Navigation("AccessSchedules"); + + b.Navigation("DisplayPreferences"); + + b.Navigation("ItemDisplayPreferences"); + + b.Navigation("Permissions"); + + b.Navigation("Preferences"); + + b.Navigation("ProfileImage"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260723111547_AllowDuplicatePlaylistChildren.cs b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260723111547_AllowDuplicatePlaylistChildren.cs new file mode 100644 index 0000000000..173213034e --- /dev/null +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/20260723111547_AllowDuplicatePlaylistChildren.cs @@ -0,0 +1,89 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Database.Providers.Sqlite.Migrations +{ + /// <inheritdoc /> + public partial class AllowDuplicatePlaylistChildren : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + // Rows that predate the composite (ParentId, SortOrder) primary key stored a null SortOrder + // (e.g. BoxSet and Collection children). Assign each such row a stable 0-based position within + // its parent so the rows stay unique once SortOrder becomes part of the primary key; otherwise + // they would all collapse to the column default (0) and collide during the table rebuild. + migrationBuilder.Sql( + @"UPDATE ""LinkedChildren"" + SET ""SortOrder"" = ( + SELECT COUNT(*) + FROM ""LinkedChildren"" AS lc2 + WHERE lc2.""ParentId"" = ""LinkedChildren"".""ParentId"" + AND lc2.""rowid"" < ""LinkedChildren"".""rowid"" + ) + WHERE ""SortOrder"" IS NULL;"); + + migrationBuilder.DropPrimaryKey( + name: "PK_LinkedChildren", + table: "LinkedChildren"); + + migrationBuilder.DropIndex( + name: "IX_LinkedChildren_ParentId_SortOrder", + table: "LinkedChildren"); + + migrationBuilder.AlterColumn<int>( + name: "SortOrder", + table: "LinkedChildren", + type: "INTEGER", + nullable: false, + defaultValue: 0, + oldClrType: typeof(int), + oldType: "INTEGER", + oldNullable: true); + + migrationBuilder.AddPrimaryKey( + name: "PK_LinkedChildren", + table: "LinkedChildren", + columns: new[] { "ParentId", "SortOrder" }); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + // The (ParentId, ChildId) primary key cannot represent the same child more than once per + // parent. Drop any duplicate entries (keeping the first by SortOrder) that may have been + // created while duplicates were allowed, so the old key can be restored. This is lossy by + // nature — duplicate playlist entries cannot survive a downgrade. + migrationBuilder.Sql( + @"DELETE FROM ""LinkedChildren"" + WHERE ""rowid"" NOT IN ( + SELECT MIN(""rowid"") + FROM ""LinkedChildren"" + GROUP BY ""ParentId"", ""ChildId"" + );"); + + migrationBuilder.DropPrimaryKey( + name: "PK_LinkedChildren", + table: "LinkedChildren"); + + migrationBuilder.AlterColumn<int>( + name: "SortOrder", + table: "LinkedChildren", + type: "INTEGER", + nullable: true, + oldClrType: typeof(int), + oldType: "INTEGER"); + + migrationBuilder.AddPrimaryKey( + name: "PK_LinkedChildren", + table: "LinkedChildren", + columns: new[] { "ParentId", "ChildId" }); + + migrationBuilder.CreateIndex( + name: "IX_LinkedChildren_ParentId_SortOrder", + table: "LinkedChildren", + columns: new[] { "ParentId", "SortOrder" }); + } + } +} 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 589468b839..cdf5c84826 100644 --- a/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs +++ b/src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs @@ -815,23 +815,21 @@ namespace Jellyfin.Server.Implementations.Migrations b.Property<Guid>("ParentId") .HasColumnType("TEXT"); + b.Property<int>("SortOrder") + .HasColumnType("INTEGER"); + b.Property<Guid>("ChildId") .HasColumnType("TEXT"); b.Property<int>("ChildType") .HasColumnType("INTEGER"); - b.Property<int?>("SortOrder") - .HasColumnType("INTEGER"); - - b.HasKey("ParentId", "ChildId"); + b.HasKey("ParentId", "SortOrder"); b.HasIndex("ChildId", "ChildType"); b.HasIndex("ParentId", "ChildType"); - b.HasIndex("ParentId", "SortOrder"); - b.ToTable("LinkedChildren", (string)null); b.HasAnnotation("Sqlite:UseSqlReturningClause", false); |
