using ErsatzTV.Core.Api.LibraryBrowse; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Core.Search; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Search; using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Application.LibraryBrowse; public class GetLibraryBrowseItemsHandler( ISearchIndex searchIndex, IDbContextFactory dbContextFactory) : IRequestHandler { private const string LibraryIdField = "library_id"; public async Task Handle( GetLibraryBrowseItems request, CancellationToken cancellationToken) { // Drill-in for detail pages: read a parent's children directly (bypassing Lucene) so the SPA can // expand a show into its seasons (#180), a season into its episodes, or an artist into its music // videos (#141/#161). Each reads in the natural display order for that kind. if (request.ParentId.HasValue) { switch (request.MediaType) { case LibraryBrowseMediaType.TelevisionSeason: { await using TvContext seasonContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); return await BrowseSeasonsForShow(seasonContext, request, cancellationToken); } case LibraryBrowseMediaType.Episode: { await using TvContext episodeContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); return await BrowseEpisodesForSeason(episodeContext, request, cancellationToken); } case LibraryBrowseMediaType.MusicVideo: { await using TvContext musicVideoContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); return await BrowseMusicVideosForArtist(musicVideoContext, request, cancellationToken); } } } int offset = request.PageNum * request.PageSize; SearchResult mediaResult = await SearchMedia(request, offset, request.PageSize, cancellationToken); await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); List mediaItems = await HydrateMediaItems( dbContext, mediaResult.Items, cancellationToken); int collectionTotal = await CountCollections(dbContext, request, cancellationToken); // Lucene total count can briefly include stale media ids until the async rescan catches up; collection // paging may drift in that window, matching the staleness behavior accepted by the existing search UI. int collectionSkip = Math.Max(0, offset - mediaResult.TotalCount); int collectionTake = request.PageSize - mediaItems.Count; List collectionItems = collectionTake > 0 ? await GetCollectionItems(dbContext, request, collectionSkip, collectionTake, cancellationToken) : []; return new PagedLibraryBrowseItemsResponseModel( mediaResult.TotalCount + collectionTotal, mediaItems.Concat(collectionItems).ToList()); } private async Task SearchMedia( GetLibraryBrowseItems request, int offset, int pageSize, CancellationToken cancellationToken) { List typeFilters = MediaTypesFor(request.MediaType).ToList(); if (typeFilters.Count == 0) { return new SearchResult([], 0); } var clauses = new List { typeFilters.Count == 1 ? $"type:{typeFilters[0]}" : $"({string.Join(" OR ", typeFilters.Map(t => $"type:{t}"))})" }; if (request.LibraryId.HasValue) { clauses.Add($"{LibraryIdField}:{request.LibraryId.Value}"); } if (!string.IsNullOrWhiteSpace(request.Query)) { clauses.Add($"({request.Query})"); } return await searchIndex.Search( string.Join(" AND ", clauses), string.Empty, offset, pageSize, [LuceneSearchIndex.TitleAndYearSearchField], cancellationToken); } private static IEnumerable MediaTypesFor(LibraryBrowseMediaType? mediaType) => mediaType switch { LibraryBrowseMediaType.Movie => [LuceneSearchIndex.MovieType], LibraryBrowseMediaType.TelevisionShow => [LuceneSearchIndex.ShowType], LibraryBrowseMediaType.TelevisionSeason => [LuceneSearchIndex.SeasonType], LibraryBrowseMediaType.Artist => [LuceneSearchIndex.ArtistType], LibraryBrowseMediaType.Episode => [LuceneSearchIndex.EpisodeType], LibraryBrowseMediaType.MusicVideo => [LuceneSearchIndex.MusicVideoType], LibraryBrowseMediaType.Song => [LuceneSearchIndex.SongType], LibraryBrowseMediaType.OtherVideo => [LuceneSearchIndex.OtherVideoType], LibraryBrowseMediaType.Image => [LuceneSearchIndex.ImageType], LibraryBrowseMediaType.RemoteStream => [LuceneSearchIndex.RemoteStreamType], null => [ LuceneSearchIndex.MovieType, LuceneSearchIndex.ShowType, LuceneSearchIndex.SeasonType, LuceneSearchIndex.ArtistType, LuceneSearchIndex.EpisodeType, LuceneSearchIndex.MusicVideoType, LuceneSearchIndex.SongType, LuceneSearchIndex.OtherVideoType, LuceneSearchIndex.ImageType, LuceneSearchIndex.RemoteStreamType ], _ => [] }; private static async Task> HydrateMediaItems( TvContext dbContext, List searchItems, CancellationToken cancellationToken) { if (searchItems.Count == 0) { return []; } List movieIds = searchItems.Where(i => i.Type == LuceneSearchIndex.MovieType).Select(i => i.Id).ToList(); List showIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ShowType).Select(i => i.Id).ToList(); List seasonIds = searchItems.Where(i => i.Type == LuceneSearchIndex.SeasonType).Select(i => i.Id).ToList(); List artistIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ArtistType).Select(i => i.Id).ToList(); List episodeIds = searchItems.Where(i => i.Type == LuceneSearchIndex.EpisodeType).Select(i => i.Id).ToList(); List musicVideoIds = searchItems.Where(i => i.Type == LuceneSearchIndex.MusicVideoType).Select(i => i.Id).ToList(); List songIds = searchItems.Where(i => i.Type == LuceneSearchIndex.SongType).Select(i => i.Id).ToList(); List otherVideoIds = searchItems.Where(i => i.Type == LuceneSearchIndex.OtherVideoType).Select(i => i.Id).ToList(); List imageIds = searchItems.Where(i => i.Type == LuceneSearchIndex.ImageType).Select(i => i.Id).ToList(); List remoteStreamIds = searchItems.Where(i => i.Type == LuceneSearchIndex.RemoteStreamType).Select(i => i.Id).ToList(); Dictionary<(string Type, int Id), LibraryBrowseItemResponseModel> hydrated = []; foreach (LibraryBrowseItemResponseModel item in await LibraryBrowseItemMapper.GetMovies(dbContext, movieIds, cancellationToken)) { hydrated[(LuceneSearchIndex.MovieType, item.Id)] = item; } foreach (LibraryBrowseItemResponseModel item in await LibraryBrowseItemMapper.GetShows(dbContext, showIds, cancellationToken)) { hydrated[(LuceneSearchIndex.ShowType, item.Id)] = item; } foreach (LibraryBrowseItemResponseModel item in await LibraryBrowseItemMapper.GetSeasons(dbContext, seasonIds, cancellationToken)) { hydrated[(LuceneSearchIndex.SeasonType, item.Id)] = item; } foreach (LibraryBrowseItemResponseModel item in await LibraryBrowseItemMapper.GetArtists(dbContext, artistIds, cancellationToken)) { hydrated[(LuceneSearchIndex.ArtistType, item.Id)] = item; } foreach (LibraryBrowseItemResponseModel item in await LibraryBrowseItemMapper.GetEpisodes(dbContext, episodeIds, cancellationToken)) { hydrated[(LuceneSearchIndex.EpisodeType, item.Id)] = item; } foreach (LibraryBrowseItemResponseModel item in await LibraryBrowseItemMapper.GetMusicVideos(dbContext, musicVideoIds, cancellationToken)) { hydrated[(LuceneSearchIndex.MusicVideoType, item.Id)] = item; } foreach (LibraryBrowseItemResponseModel item in await LibraryBrowseItemMapper.GetSongs(dbContext, songIds, cancellationToken)) { hydrated[(LuceneSearchIndex.SongType, item.Id)] = item; } foreach (LibraryBrowseItemResponseModel item in await LibraryBrowseItemMapper.GetOtherVideos(dbContext, otherVideoIds, cancellationToken)) { hydrated[(LuceneSearchIndex.OtherVideoType, item.Id)] = item; } foreach (LibraryBrowseItemResponseModel item in await LibraryBrowseItemMapper.GetImages(dbContext, imageIds, cancellationToken)) { hydrated[(LuceneSearchIndex.ImageType, item.Id)] = item; } foreach (LibraryBrowseItemResponseModel item in await LibraryBrowseItemMapper.GetRemoteStreams(dbContext, remoteStreamIds, cancellationToken)) { hydrated[(LuceneSearchIndex.RemoteStreamType, item.Id)] = item; } return searchItems .Where(i => hydrated.ContainsKey((i.Type, i.Id))) .Select(i => hydrated[(i.Type, i.Id)]) .ToList(); } private static async Task BrowseSeasonsForShow( TvContext dbContext, GetLibraryBrowseItems request, CancellationToken cancellationToken) { List allSeasonIds = await dbContext.Seasons .AsNoTracking() .Where(s => s.ShowId == request.ParentId.Value) .OrderBy(s => s.SeasonNumber) .Select(s => s.Id) .ToListAsync(cancellationToken); int total = allSeasonIds.Count; List pageIds = allSeasonIds .Skip(request.PageNum * request.PageSize) .Take(request.PageSize) .ToList(); List seasons = await LibraryBrowseItemMapper.GetSeasons(dbContext, pageIds, cancellationToken); // GetSeasons groups by season id, so restore the requested season-number order. Dictionary byId = seasons.ToDictionary(s => s.Id); List ordered = pageIds .Where(byId.ContainsKey) .Select(id => byId[id]) .ToList(); return new PagedLibraryBrowseItemsResponseModel(total, ordered); } // Drill-in: episodes of a specific season, in episode-number order (#141/#161). private static async Task BrowseEpisodesForSeason( TvContext dbContext, GetLibraryBrowseItems request, CancellationToken cancellationToken) { List allEpisodeIds = await dbContext.EpisodeMetadata .AsNoTracking() .Where(em => em.Episode.SeasonId == request.ParentId.Value) .OrderBy(em => em.EpisodeNumber) .ThenBy(em => em.EpisodeId) .Select(em => em.EpisodeId) .ToListAsync(cancellationToken); // Distinct preserves order (LINQ-to-Objects) for episodes with multiple metadata rows. allEpisodeIds = allEpisodeIds.Distinct().ToList(); int total = allEpisodeIds.Count; List pageIds = allEpisodeIds .Skip(request.PageNum * request.PageSize) .Take(request.PageSize) .ToList(); List episodes = await LibraryBrowseItemMapper.GetEpisodes(dbContext, pageIds, cancellationToken); Dictionary byId = episodes.ToDictionary(e => e.Id); List ordered = pageIds .Where(byId.ContainsKey) .Select(id => byId[id]) .ToList(); return new PagedLibraryBrowseItemsResponseModel(total, ordered); } // Drill-in: music videos of a specific artist, in album/track/title order (#141/#161). private static async Task BrowseMusicVideosForArtist( TvContext dbContext, GetLibraryBrowseItems request, CancellationToken cancellationToken) { List allMusicVideoIds = await dbContext.MusicVideoMetadata .AsNoTracking() .Where(mvm => mvm.MusicVideo.ArtistId == request.ParentId.Value) .OrderBy(mvm => mvm.Album) .ThenBy(mvm => mvm.Track) .ThenBy(mvm => mvm.Title) .ThenBy(mvm => mvm.MusicVideoId) .Select(mvm => mvm.MusicVideoId) .ToListAsync(cancellationToken); allMusicVideoIds = allMusicVideoIds.Distinct().ToList(); int total = allMusicVideoIds.Count; List pageIds = allMusicVideoIds .Skip(request.PageNum * request.PageSize) .Take(request.PageSize) .ToList(); List musicVideos = await LibraryBrowseItemMapper.GetMusicVideos(dbContext, pageIds, cancellationToken); Dictionary byId = musicVideos.ToDictionary(mv => mv.Id); List ordered = pageIds .Where(byId.ContainsKey) .Select(id => byId[id]) .ToList(); return new PagedLibraryBrowseItemsResponseModel(total, ordered); } private static async Task CountCollections( TvContext dbContext, GetLibraryBrowseItems request, CancellationToken cancellationToken) { int count = 0; if (ShouldInclude(request.MediaType, LibraryBrowseMediaType.Collection)) { count += await FilterCollections(dbContext.Collections.AsNoTracking(), request) .CountAsync(cancellationToken); } if (!request.LibraryId.HasValue && ShouldInclude(request.MediaType, LibraryBrowseMediaType.SmartCollection)) { count += await FilterByName(dbContext.SmartCollections.AsNoTracking(), request.Query) .CountAsync(cancellationToken); } if (!request.LibraryId.HasValue && ShouldInclude(request.MediaType, LibraryBrowseMediaType.MultiCollection)) { count += await FilterByName(dbContext.MultiCollections.AsNoTracking(), request.Query) .CountAsync(cancellationToken); } if (!request.LibraryId.HasValue && ShouldInclude(request.MediaType, LibraryBrowseMediaType.RerunCollection)) { count += await FilterByName(dbContext.RerunCollections.AsNoTracking(), request.Query) .CountAsync(cancellationToken); } if (!request.LibraryId.HasValue && ShouldInclude(request.MediaType, LibraryBrowseMediaType.Playlist)) { count += await FilterByName(dbContext.Playlists.AsNoTracking(), request.Query) .CountAsync(cancellationToken); } return count; } private static async Task> GetCollectionItems( TvContext dbContext, GetLibraryBrowseItems request, int skip, int take, CancellationToken cancellationToken) { var results = new List(); int remainingSkip = skip; if (ShouldInclude(request.MediaType, LibraryBrowseMediaType.Collection)) { IQueryable collectionQuery = FilterCollections(dbContext.Collections.AsNoTracking(), request); int count = await collectionQuery.CountAsync(cancellationToken); int pageSkip = Math.Min(remainingSkip, count); remainingSkip -= pageSkip; List collections = await collectionQuery .OrderBy(c => c.Name) .Skip(pageSkip) .Take(take) .ToListAsync(cancellationToken); List collectionIds = collections.Map(c => c.Id).ToList(); Dictionary itemCounts = await dbContext.CollectionItems .AsNoTracking() .Where(ci => collectionIds.Contains(ci.CollectionId)) .GroupBy(ci => ci.CollectionId) .Select(g => new { CollectionId = g.Key, Count = g.Count() }) .ToDictionaryAsync(g => g.CollectionId, g => g.Count, cancellationToken); Dictionary durations = await GetManualCollectionDurations( dbContext, collectionIds, cancellationToken); Dictionary artwork = await GetManualCollectionArtwork(dbContext, collectionIds, cancellationToken); results.AddRange(collections.Map(c => new LibraryBrowseItemResponseModel( c.Id, LibraryBrowseMediaType.Collection, c.Name, null, null, artwork.TryGetValue(c.Id, out string poster) ? poster : string.Empty, durations.TryGetValue(c.Id, out TimeSpan? duration) ? duration : null, itemCounts.TryGetValue(c.Id, out int itemCount) ? itemCount : 0, "Manual", CollectionType.Collection, c.Id, null, null, null, null, null))); } if (results.Count >= take || request.LibraryId.HasValue) { return results.Take(take).ToList(); } remainingSkip = await AppendSmartCollections(dbContext, request, results, remainingSkip, take, cancellationToken); remainingSkip = await AppendMultiCollections(dbContext, request, results, remainingSkip, take, cancellationToken); remainingSkip = await AppendRerunCollections(dbContext, request, results, remainingSkip, take, cancellationToken); await AppendPlaylists(dbContext, request, results, remainingSkip, take, cancellationToken); return results.Take(take).ToList(); } private static async Task AppendSmartCollections( TvContext dbContext, GetLibraryBrowseItems request, List results, int skip, int take, CancellationToken cancellationToken) { if (!ShouldInclude(request.MediaType, LibraryBrowseMediaType.SmartCollection) || results.Count >= take) { return skip; } IQueryable query = FilterByName(dbContext.SmartCollections.AsNoTracking(), request.Query); int count = await query.CountAsync(cancellationToken); int pageSkip = Math.Min(skip, count); List page = await query .OrderBy(c => c.Name) .Skip(pageSkip) .Take(take - results.Count) .ToListAsync(cancellationToken); results.AddRange(page.Map(c => new LibraryBrowseItemResponseModel( c.Id, LibraryBrowseMediaType.SmartCollection, c.Name, null, null, string.Empty, null, null, "Smart", CollectionType.SmartCollection, null, null, c.Id, null, null, null))); return skip - pageSkip; } private static async Task AppendMultiCollections( TvContext dbContext, GetLibraryBrowseItems request, List results, int skip, int take, CancellationToken cancellationToken) { if (!ShouldInclude(request.MediaType, LibraryBrowseMediaType.MultiCollection) || results.Count >= take) { return skip; } IQueryable query = FilterByName(dbContext.MultiCollections.AsNoTracking(), request.Query); int count = await query.CountAsync(cancellationToken); int pageSkip = Math.Min(skip, count); List page = await query .OrderBy(c => c.Name) .Skip(pageSkip) .Take(take - results.Count) .ToListAsync(cancellationToken); List ids = page.Map(c => c.Id).ToList(); Dictionary collectionCounts = await dbContext.Set() .AsNoTracking() .Where(i => ids.Contains(i.MultiCollectionId)) .GroupBy(i => i.MultiCollectionId) .Select(g => new { MultiCollectionId = g.Key, Count = g.Count() }) .ToDictionaryAsync(g => g.MultiCollectionId, g => g.Count, cancellationToken); Dictionary smartCollectionCounts = await dbContext.Set() .AsNoTracking() .Where(i => ids.Contains(i.MultiCollectionId)) .GroupBy(i => i.MultiCollectionId) .Select(g => new { MultiCollectionId = g.Key, Count = g.Count() }) .ToDictionaryAsync(g => g.MultiCollectionId, g => g.Count, cancellationToken); results.AddRange(page.Map(c => new LibraryBrowseItemResponseModel( c.Id, LibraryBrowseMediaType.MultiCollection, c.Name, null, null, string.Empty, null, (collectionCounts.TryGetValue(c.Id, out int collectionCount) ? collectionCount : 0) + (smartCollectionCounts.TryGetValue(c.Id, out int smartCollectionCount) ? smartCollectionCount : 0), "Multi", CollectionType.MultiCollection, null, c.Id, null, null, null, null))); return skip - pageSkip; } private static async Task AppendRerunCollections( TvContext dbContext, GetLibraryBrowseItems request, List results, int skip, int take, CancellationToken cancellationToken) { if (!ShouldInclude(request.MediaType, LibraryBrowseMediaType.RerunCollection) || results.Count >= take) { return skip; } IQueryable query = FilterByName(dbContext.RerunCollections.AsNoTracking(), request.Query); int count = await query.CountAsync(cancellationToken); int pageSkip = Math.Min(skip, count); List page = await query .OrderBy(c => c.Name) .Skip(pageSkip) .Take(take - results.Count) .ToListAsync(cancellationToken); results.AddRange(page.Map(c => new LibraryBrowseItemResponseModel( c.Id, LibraryBrowseMediaType.RerunCollection, c.Name, null, null, string.Empty, null, null, "Rerun", // RerunFirstRun addresses the first-run side; the SPA can substitute RerunRerun for rerun schedule items. CollectionType.RerunFirstRun, null, null, null, c.Id, null, null))); return skip - pageSkip; } private static async Task AppendPlaylists( TvContext dbContext, GetLibraryBrowseItems request, List results, int skip, int take, CancellationToken cancellationToken) { if (!ShouldInclude(request.MediaType, LibraryBrowseMediaType.Playlist) || results.Count >= take) { return; } IQueryable query = FilterByName(dbContext.Playlists.AsNoTracking(), request.Query); int count = await query.CountAsync(cancellationToken); int pageSkip = Math.Min(skip, count); List page = await query .OrderBy(c => c.Name) .Skip(pageSkip) .Take(take - results.Count) .ToListAsync(cancellationToken); List ids = page.Map(c => c.Id).ToList(); Dictionary itemCounts = await dbContext.PlaylistItems .AsNoTracking() .Where(i => ids.Contains(i.PlaylistId)) .GroupBy(i => i.PlaylistId) .Select(g => new { PlaylistId = g.Key, Count = g.Count() }) .ToDictionaryAsync(g => g.PlaylistId, g => g.Count, cancellationToken); results.AddRange(page.Map(c => new LibraryBrowseItemResponseModel( c.Id, LibraryBrowseMediaType.Playlist, c.Name, null, null, string.Empty, null, itemCounts.TryGetValue(c.Id, out int itemCount) ? itemCount : 0, null, CollectionType.Playlist, null, null, null, null, null, c.Id))); } private static IQueryable FilterCollections( IQueryable query, GetLibraryBrowseItems request) { query = FilterByName(query, request.Query); if (request.LibraryId.HasValue) { query = query.Where(c => c.MediaItems.Any(mi => mi.LibraryPath.LibraryId == request.LibraryId.Value)); } return query; } private static IQueryable FilterByName(IQueryable query, string searchQuery) where T : class { if (string.IsNullOrWhiteSpace(searchQuery)) { return query; } return query.Where(c => EF.Functions.Like(EF.Property(c, "Name"), $"%{EscapeLike(searchQuery)}%", "\\")); } private static bool ShouldInclude(LibraryBrowseMediaType? requestType, LibraryBrowseMediaType itemType) => requestType is null || requestType == itemType; private static async Task> GetManualCollectionDurations( TvContext dbContext, List collectionIds, CancellationToken cancellationToken) { List items = await GetCollectionMediaItems(dbContext, collectionIds, cancellationToken); List mediaItemIds = items.Map(i => i.MediaItemId).Distinct().ToList(); Dictionary mediaItemDurations = []; foreach (Movie movie in await dbContext.Movies .AsNoTracking() .Where(m => mediaItemIds.Contains(m.Id)) .Include(m => m.MediaVersions) .ToListAsync(cancellationToken)) { TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(movie.MediaVersions); if (duration.HasValue) { mediaItemDurations[movie.Id] = duration.Value; } } foreach (Episode episode in await dbContext.Episodes .AsNoTracking() .Where(e => mediaItemIds.Contains(e.Id)) .Include(e => e.MediaVersions) .ToListAsync(cancellationToken)) { TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(episode.MediaVersions); if (duration.HasValue) { mediaItemDurations[episode.Id] = duration.Value; } } foreach (MusicVideo musicVideo in await dbContext.MusicVideos .AsNoTracking() .Where(mv => mediaItemIds.Contains(mv.Id)) .Include(mv => mv.MediaVersions) .ToListAsync(cancellationToken)) { TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(musicVideo.MediaVersions); if (duration.HasValue) { mediaItemDurations[musicVideo.Id] = duration.Value; } } foreach (OtherVideo otherVideo in await dbContext.OtherVideos .AsNoTracking() .Where(ov => mediaItemIds.Contains(ov.Id)) .Include(ov => ov.MediaVersions) .ToListAsync(cancellationToken)) { TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(otherVideo.MediaVersions); if (duration.HasValue) { mediaItemDurations[otherVideo.Id] = duration.Value; } } foreach (Song song in await dbContext.Songs .AsNoTracking() .Where(s => mediaItemIds.Contains(s.Id)) .Include(s => s.MediaVersions) .ToListAsync(cancellationToken)) { TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(song.MediaVersions); if (duration.HasValue) { mediaItemDurations[song.Id] = duration.Value; } } foreach (Image image in await dbContext.Images .AsNoTracking() .Where(i => mediaItemIds.Contains(i.Id)) .Include(i => i.MediaVersions) .ToListAsync(cancellationToken)) { TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(image.MediaVersions); if (duration.HasValue) { mediaItemDurations[image.Id] = duration.Value; } } foreach (RemoteStream remoteStream in await dbContext.RemoteStreams .AsNoTracking() .Where(rs => mediaItemIds.Contains(rs.Id)) .Include(rs => rs.MediaVersions) .ToListAsync(cancellationToken)) { TimeSpan? duration = LibraryBrowseItemMapper.BestDuration(remoteStream.MediaVersions); if (duration.HasValue) { mediaItemDurations[remoteStream.Id] = duration.Value; } } return items .Where(i => mediaItemDurations.ContainsKey(i.MediaItemId)) .GroupBy(i => i.CollectionId) .ToDictionary( g => g.Key, g => (TimeSpan?)g.Aggregate(TimeSpan.Zero, (sum, item) => sum + mediaItemDurations[item.MediaItemId])); } private static async Task> GetManualCollectionArtwork( TvContext dbContext, List collectionIds, CancellationToken cancellationToken) { List items = await GetCollectionMediaItems(dbContext, collectionIds, cancellationToken); List mediaItemIds = items.Map(i => i.MediaItemId).Distinct().ToList(); Dictionary mediaItemArtwork = []; foreach (MovieMetadata metadata in await dbContext.MovieMetadata .AsNoTracking() .Where(mm => mediaItemIds.Contains(mm.MovieId)) .Include(mm => mm.Artwork) .OrderBy(mm => mm.Id) .ToListAsync(cancellationToken)) { string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster); if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.MovieId)) { mediaItemArtwork[metadata.MovieId] = poster; } } foreach (ShowMetadata metadata in await dbContext.ShowMetadata .AsNoTracking() .Where(sm => mediaItemIds.Contains(sm.ShowId)) .Include(sm => sm.Artwork) .OrderBy(sm => sm.Id) .ToListAsync(cancellationToken)) { string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster); if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.ShowId)) { mediaItemArtwork[metadata.ShowId] = poster; } } foreach (SeasonMetadata metadata in await dbContext.SeasonMetadata .AsNoTracking() .Where(sm => mediaItemIds.Contains(sm.SeasonId)) .Include(sm => sm.Artwork) .OrderBy(sm => sm.Id) .ToListAsync(cancellationToken)) { string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster); if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.SeasonId)) { mediaItemArtwork[metadata.SeasonId] = poster; } } foreach (OtherVideoMetadata metadata in await dbContext.OtherVideoMetadata .AsNoTracking() .Where(ovm => mediaItemIds.Contains(ovm.OtherVideoId)) .Include(ovm => ovm.Artwork) .OrderBy(ovm => ovm.Id) .ToListAsync(cancellationToken)) { string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster); if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.OtherVideoId)) { mediaItemArtwork[metadata.OtherVideoId] = poster; } } foreach (SongMetadata metadata in await dbContext.SongMetadata .AsNoTracking() .Where(sm => mediaItemIds.Contains(sm.SongId)) .Include(sm => sm.Artwork) .OrderBy(sm => sm.Id) .ToListAsync(cancellationToken)) { string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster); if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.SongId)) { mediaItemArtwork[metadata.SongId] = poster; } } foreach (ImageMetadata metadata in await dbContext.ImageMetadata .AsNoTracking() .Where(im => mediaItemIds.Contains(im.ImageId)) .Include(im => im.Artwork) .OrderBy(im => im.Id) .ToListAsync(cancellationToken)) { string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster); if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.ImageId)) { mediaItemArtwork[metadata.ImageId] = poster; } } foreach (RemoteStreamMetadata metadata in await dbContext.RemoteStreamMetadata .AsNoTracking() .Where(rsm => mediaItemIds.Contains(rsm.RemoteStreamId)) .Include(rsm => rsm.Artwork) .OrderBy(rsm => rsm.Id) .ToListAsync(cancellationToken)) { string poster = LibraryBrowseItemMapper.Artwork(metadata, ArtworkKind.Poster); if (!string.IsNullOrWhiteSpace(poster) && !mediaItemArtwork.ContainsKey(metadata.RemoteStreamId)) { mediaItemArtwork[metadata.RemoteStreamId] = poster; } } return items .Where(i => mediaItemArtwork.ContainsKey(i.MediaItemId)) .GroupBy(i => i.CollectionId) .ToDictionary( g => g.Key, g => mediaItemArtwork[g.OrderBy(i => i.CustomIndex ?? int.MaxValue).ThenBy(i => i.MediaItemId).First().MediaItemId]); } private static async Task> GetCollectionMediaItems( TvContext dbContext, List collectionIds, CancellationToken cancellationToken) => await dbContext.CollectionItems .AsNoTracking() .Where(ci => collectionIds.Contains(ci.CollectionId)) .Select(ci => new CollectionMediaItem(ci.CollectionId, ci.MediaItemId, ci.CustomIndex)) .ToListAsync(cancellationToken); private static string EscapeLike(string searchQuery) => searchQuery .Replace("\\", "\\\\", StringComparison.Ordinal) .Replace("%", "\\%", StringComparison.Ordinal) .Replace("_", "\\_", StringComparison.Ordinal); private sealed record CollectionMediaItem(int CollectionId, int MediaItemId, int? CustomIndex); }