using ErsatzTV.Core.Api.LibraryBrowse; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Emby; using ErsatzTV.Core.Jellyfin; using ErsatzTV.Infrastructure.Data; using Flurl; using Microsoft.EntityFrameworkCore; namespace ErsatzTV.Application.LibraryBrowse; // Shared MediaItem -> LibraryBrowseItemResponseModel projection used by both the library-browse // search handler and the collection-items handler (#155). Keeping the per-kind hydration and the // rooted-artwork logic in one place avoids duplicating the Blazor-vs-SPA artwork rooting rules // (see the Artwork helper below and docs/api-conventions.md ยง4). internal static class LibraryBrowseItemMapper { // Hydrates an arbitrary set of media item ids (any kinds mixed) into response models. MediaItem // ids are globally unique across kinds, so passing the full id list to every per-kind query is // safe: each query only matches its own kind. Callers order/page the result themselves. public static async Task> HydrateMediaItemsByIds( TvContext dbContext, IReadOnlyList ids, CancellationToken cancellationToken) { if (ids.Count == 0) { return []; } var idList = ids.Distinct().ToList(); var results = new List(); results.AddRange(await GetMovies(dbContext, idList, cancellationToken)); results.AddRange(await GetShows(dbContext, idList, cancellationToken)); results.AddRange(await GetSeasons(dbContext, idList, cancellationToken)); results.AddRange(await GetArtists(dbContext, idList, cancellationToken)); results.AddRange(await GetEpisodes(dbContext, idList, cancellationToken)); results.AddRange(await GetMusicVideos(dbContext, idList, cancellationToken)); results.AddRange(await GetSongs(dbContext, idList, cancellationToken)); results.AddRange(await GetOtherVideos(dbContext, idList, cancellationToken)); results.AddRange(await GetImages(dbContext, idList, cancellationToken)); results.AddRange(await GetRemoteStreams(dbContext, idList, cancellationToken)); return results; } public static async Task> GetMovies( TvContext dbContext, List ids, CancellationToken cancellationToken) { if (ids.Count == 0) { return []; } return await dbContext.MovieMetadata .AsNoTracking() .Where(mm => ids.Contains(mm.MovieId)) .Include(mm => mm.Artwork) .Include(mm => mm.Movie) .ThenInclude(m => m.LibraryPath) .ThenInclude(lp => lp.Library) .Include(mm => mm.Movie) .ThenInclude(m => m.MediaVersions) .ToListAsync(cancellationToken) .Map(list => list .GroupBy(mm => mm.MovieId) .Select(g => g.OrderBy(mm => mm.Id).First()) .Map(mm => new LibraryBrowseItemResponseModel( mm.MovieId, LibraryBrowseMediaType.Movie, mm.Title ?? string.Empty, mm.Movie.LibraryPath.LibraryId, mm.Movie.LibraryPath.Library.Name, Artwork(mm, ArtworkKind.Poster), BestDuration(mm.Movie.MediaVersions), 1, null, CollectionType.Movie, null, null, null, null, mm.MovieId, null)).ToList()); } public static async Task> GetShows( TvContext dbContext, List ids, CancellationToken cancellationToken) { if (ids.Count == 0) { return []; } Dictionary counts = await dbContext.Episodes .AsNoTracking() .Where(e => ids.Contains(e.Season.ShowId)) .GroupBy(e => e.Season.ShowId) .Select(g => new { ShowId = g.Key, Count = g.Count() }) .ToDictionaryAsync(g => g.ShowId, g => g.Count, cancellationToken); return await dbContext.ShowMetadata .AsNoTracking() .Where(sm => ids.Contains(sm.ShowId)) .Include(sm => sm.Artwork) .Include(sm => sm.Show) .ThenInclude(s => s.LibraryPath) .ThenInclude(lp => lp.Library) .ToListAsync(cancellationToken) .Map(list => list .GroupBy(sm => sm.ShowId) .Select(g => g.OrderBy(sm => sm.Id).First()) .Map(sm => new LibraryBrowseItemResponseModel( sm.ShowId, LibraryBrowseMediaType.TelevisionShow, sm.Title ?? string.Empty, sm.Show.LibraryPath.LibraryId, sm.Show.LibraryPath.Library.Name, Artwork(sm, ArtworkKind.Poster), null, counts.TryGetValue(sm.ShowId, out int count) ? count : 0, null, CollectionType.TelevisionShow, null, null, null, null, sm.ShowId, null)).ToList()); } public static async Task> GetSeasons( TvContext dbContext, List ids, CancellationToken cancellationToken) { if (ids.Count == 0) { return []; } Dictionary counts = await dbContext.Episodes .AsNoTracking() .Where(e => ids.Contains(e.SeasonId)) .GroupBy(e => e.SeasonId) .Select(g => new { SeasonId = g.Key, Count = g.Count() }) .ToDictionaryAsync(g => g.SeasonId, g => g.Count, cancellationToken); return await dbContext.SeasonMetadata .AsNoTracking() .Where(sm => ids.Contains(sm.SeasonId)) .Include(sm => sm.Artwork) .Include(sm => sm.Season) .ThenInclude(s => s.Show) .ThenInclude(s => s.ShowMetadata) .ThenInclude(shm => shm.Artwork) .Include(sm => sm.Season) .ThenInclude(s => s.LibraryPath) .ThenInclude(lp => lp.Library) .ToListAsync(cancellationToken) .Map(list => list .GroupBy(sm => sm.SeasonId) .Select(g => g.OrderBy(sm => sm.Id).First()) .Map(sm => new LibraryBrowseItemResponseModel( sm.SeasonId, LibraryBrowseMediaType.TelevisionSeason, SeasonTitle(sm), sm.Season.LibraryPath.LibraryId, sm.Season.LibraryPath.Library.Name, SeasonArtwork(sm), null, counts.TryGetValue(sm.SeasonId, out int count) ? count : 0, null, CollectionType.TelevisionSeason, null, null, null, null, sm.SeasonId, null)).ToList()); } public static async Task> GetArtists( TvContext dbContext, List ids, CancellationToken cancellationToken) { if (ids.Count == 0) { return []; } Dictionary counts = await dbContext.MusicVideos .AsNoTracking() .Where(mv => ids.Contains(mv.ArtistId)) .GroupBy(mv => mv.ArtistId) .Select(g => new { ArtistId = g.Key, Count = g.Count() }) .ToDictionaryAsync(g => g.ArtistId, g => g.Count, cancellationToken); return await dbContext.ArtistMetadata .AsNoTracking() .Where(am => ids.Contains(am.ArtistId)) .Include(am => am.Artwork) .Include(am => am.Artist) .ThenInclude(a => a.LibraryPath) .ThenInclude(lp => lp.Library) .ToListAsync(cancellationToken) .Map(list => list .GroupBy(am => am.ArtistId) .Select(g => g.OrderBy(am => am.Id).First()) .Map(am => new LibraryBrowseItemResponseModel( am.ArtistId, LibraryBrowseMediaType.Artist, am.Title ?? string.Empty, am.Artist.LibraryPath.LibraryId, am.Artist.LibraryPath.Library.Name, Artwork(am, ArtworkKind.Thumbnail), null, counts.TryGetValue(am.ArtistId, out int count) ? count : 0, null, CollectionType.Artist, null, null, null, null, am.ArtistId, null)).ToList()); } public static async Task> GetEpisodes( TvContext dbContext, List ids, CancellationToken cancellationToken) { if (ids.Count == 0) { return []; } return await dbContext.EpisodeMetadata .AsNoTracking() .Where(em => ids.Contains(em.EpisodeId)) .Include(em => em.Artwork) .Include(em => em.Episode) .ThenInclude(e => e.LibraryPath) .ThenInclude(lp => lp.Library) .Include(em => em.Episode) .ThenInclude(e => e.MediaVersions) .Include(em => em.Episode) .ThenInclude(e => e.Season) .ThenInclude(s => s.Show) .ThenInclude(sh => sh.ShowMetadata) .ToListAsync(cancellationToken) .Map(list => list .GroupBy(em => em.EpisodeId) .Select(g => g.OrderBy(em => em.Id).First()) .Map(em => new LibraryBrowseItemResponseModel( em.EpisodeId, LibraryBrowseMediaType.Episode, em.Title ?? string.Empty, em.Episode.LibraryPath.LibraryId, em.Episode.LibraryPath.Library.Name, ArtworkWithFallback(em, ArtworkKind.Thumbnail, ArtworkKind.Poster), BestDuration(em.Episode.MediaVersions), 1, null, CollectionType.Episode, null, null, null, null, em.EpisodeId, null, EpisodeSubtitle(em), em.Episode.SeasonId)).ToList()); } public static async Task> GetMusicVideos( TvContext dbContext, List ids, CancellationToken cancellationToken) { if (ids.Count == 0) { return []; } return await dbContext.MusicVideoMetadata .AsNoTracking() .Where(mvm => ids.Contains(mvm.MusicVideoId)) .Include(mvm => mvm.Artwork) .Include(mvm => mvm.MusicVideo) .ThenInclude(mv => mv.LibraryPath) .ThenInclude(lp => lp.Library) .Include(mvm => mvm.MusicVideo) .ThenInclude(mv => mv.MediaVersions) .Include(mvm => mvm.MusicVideo) .ThenInclude(mv => mv.Artist) .ThenInclude(a => a.ArtistMetadata) .ToListAsync(cancellationToken) .Map(list => list .GroupBy(mvm => mvm.MusicVideoId) .Select(g => g.OrderBy(mvm => mvm.Id).First()) .Map(mvm => new LibraryBrowseItemResponseModel( mvm.MusicVideoId, LibraryBrowseMediaType.MusicVideo, mvm.Title ?? string.Empty, mvm.MusicVideo.LibraryPath.LibraryId, mvm.MusicVideo.LibraryPath.Library.Name, ArtworkWithFallback(mvm, ArtworkKind.Thumbnail, ArtworkKind.Poster), BestDuration(mvm.MusicVideo.MediaVersions), 1, null, CollectionType.MusicVideo, null, null, null, null, mvm.MusicVideoId, null, MusicVideoSubtitle(mvm))).ToList()); } public static async Task> GetSongs( TvContext dbContext, List ids, CancellationToken cancellationToken) { if (ids.Count == 0) { return []; } return await dbContext.SongMetadata .AsNoTracking() .Where(sm => ids.Contains(sm.SongId)) .Include(sm => sm.Artwork) .Include(sm => sm.Song) .ThenInclude(s => s.LibraryPath) .ThenInclude(lp => lp.Library) .Include(sm => sm.Song) .ThenInclude(s => s.MediaVersions) .ToListAsync(cancellationToken) .Map(list => list .GroupBy(sm => sm.SongId) .Select(g => g.OrderBy(sm => sm.Id).First()) .Map(sm => new LibraryBrowseItemResponseModel( sm.SongId, LibraryBrowseMediaType.Song, sm.Title ?? string.Empty, sm.Song.LibraryPath.LibraryId, sm.Song.LibraryPath.Library.Name, ArtworkWithFallback(sm, ArtworkKind.Thumbnail, ArtworkKind.Poster), BestDuration(sm.Song.MediaVersions), 1, null, CollectionType.Song, null, null, null, null, sm.SongId, null, SongSubtitle(sm))).ToList()); } public static async Task> GetOtherVideos( TvContext dbContext, List ids, CancellationToken cancellationToken) { if (ids.Count == 0) { return []; } return await dbContext.OtherVideoMetadata .AsNoTracking() .Where(ovm => ids.Contains(ovm.OtherVideoId)) .Include(ovm => ovm.Artwork) .Include(ovm => ovm.OtherVideo) .ThenInclude(ov => ov.LibraryPath) .ThenInclude(lp => lp.Library) .Include(ovm => ovm.OtherVideo) .ThenInclude(ov => ov.MediaVersions) .ToListAsync(cancellationToken) .Map(list => list .GroupBy(ovm => ovm.OtherVideoId) .Select(g => g.OrderBy(ovm => ovm.Id).First()) .Map(ovm => new LibraryBrowseItemResponseModel( ovm.OtherVideoId, LibraryBrowseMediaType.OtherVideo, ovm.Title ?? string.Empty, ovm.OtherVideo.LibraryPath.LibraryId, ovm.OtherVideo.LibraryPath.Library.Name, ArtworkWithFallback(ovm, ArtworkKind.Thumbnail, ArtworkKind.Poster), BestDuration(ovm.OtherVideo.MediaVersions), 1, null, CollectionType.OtherVideo, null, null, null, null, ovm.OtherVideoId, null, string.IsNullOrWhiteSpace(ovm.OriginalTitle) ? null : ovm.OriginalTitle)).ToList()); } public static async Task> GetImages( TvContext dbContext, List ids, CancellationToken cancellationToken) { if (ids.Count == 0) { return []; } return await dbContext.ImageMetadata .AsNoTracking() .Where(im => ids.Contains(im.ImageId)) .Include(im => im.Artwork) .Include(im => im.Image) .ThenInclude(i => i.LibraryPath) .ThenInclude(lp => lp.Library) .Include(im => im.Image) .ThenInclude(i => i.MediaVersions) .ToListAsync(cancellationToken) .Map(list => list .GroupBy(im => im.ImageId) .Select(g => g.OrderBy(im => im.Id).First()) .Map(im => new LibraryBrowseItemResponseModel( im.ImageId, LibraryBrowseMediaType.Image, im.Title ?? string.Empty, im.Image.LibraryPath.LibraryId, im.Image.LibraryPath.Library.Name, ArtworkWithFallback(im, ArtworkKind.Poster, ArtworkKind.Thumbnail), BestDuration(im.Image.MediaVersions), 1, null, CollectionType.Image, null, null, null, null, im.ImageId, null, string.IsNullOrWhiteSpace(im.OriginalTitle) ? null : im.OriginalTitle)).ToList()); } public static async Task> GetRemoteStreams( TvContext dbContext, List ids, CancellationToken cancellationToken) { if (ids.Count == 0) { return []; } return await dbContext.RemoteStreamMetadata .AsNoTracking() .Where(rsm => ids.Contains(rsm.RemoteStreamId)) .Include(rsm => rsm.Artwork) .Include(rsm => rsm.RemoteStream) .ThenInclude(rs => rs.LibraryPath) .ThenInclude(lp => lp.Library) .Include(rsm => rsm.RemoteStream) .ThenInclude(rs => rs.MediaVersions) .ToListAsync(cancellationToken) .Map(list => list .GroupBy(rsm => rsm.RemoteStreamId) .Select(g => g.OrderBy(rsm => rsm.Id).First()) .Map(rsm => new LibraryBrowseItemResponseModel( rsm.RemoteStreamId, LibraryBrowseMediaType.RemoteStream, rsm.Title ?? string.Empty, rsm.RemoteStream.LibraryPath.LibraryId, rsm.RemoteStream.LibraryPath.Library.Name, ArtworkWithFallback(rsm, ArtworkKind.Thumbnail, ArtworkKind.Poster), BestDuration(rsm.RemoteStream.MediaVersions), 1, null, CollectionType.RemoteStream, null, null, null, null, rsm.RemoteStreamId, null, string.IsNullOrWhiteSpace(rsm.OriginalTitle) ? null : rsm.OriginalTitle)).ToList()); } public static TimeSpan? BestDuration(IEnumerable versions) { TimeSpan duration = versions .Select(v => v.Duration) .Where(d => d > TimeSpan.Zero) .DefaultIfEmpty() .Max(); return duration > TimeSpan.Zero ? duration : null; } // Returns a rooted, directly-usable artwork URL for the SPA's . Blazor pages rely on // GetPosterUrl to prefix "artwork/posters/" and resolve relative to , but the SPA // renders the value raw from under /app/, so the API must root the URL itself (issue #180). public static string Artwork(Metadata metadata, ArtworkKind artworkKind) { string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind)) .Match(a => a.Path, string.Empty); if (string.IsNullOrWhiteSpace(artwork)) { return string.Empty; } // Absolute URLs are already usable as-is (matches Blazor's GetPosterUrl guard). if (artwork.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || artwork.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { return artwork; } string folder = artworkKind is ArtworkKind.Thumbnail ? "thumbnails" : "posters"; if (artwork.StartsWith("jellyfin://", StringComparison.OrdinalIgnoreCase)) { Url url = JellyfinUrl.RelativeProxyForArtwork(artwork); if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail) { url.SetQueryParam("fillHeight", 440); } return $"/artwork/{folder}/{url}"; } if (artwork.StartsWith("emby://", StringComparison.OrdinalIgnoreCase)) { Url url = EmbyUrl.RelativeProxyForArtwork(artwork); if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail) { url.SetQueryParam("maxHeight", 440); } return $"/artwork/{folder}/{url}"; } return $"/artwork/{folder}/{artwork}"; } private static string ArtworkWithFallback(Metadata metadata, ArtworkKind primary, ArtworkKind fallback) { string artwork = Artwork(metadata, primary); return string.IsNullOrWhiteSpace(artwork) ? Artwork(metadata, fallback) : artwork; } private static string SeasonTitle(SeasonMetadata metadata) { string showTitle = metadata.Season.Show.ShowMetadata.HeadOrNone() .Map(sm => sm.Title ?? string.Empty) .IfNone(string.Empty); string seasonTitle = metadata.Season.SeasonNumber == 0 ? "Specials" : $"Season {metadata.Season.SeasonNumber}"; return string.IsNullOrWhiteSpace(showTitle) ? seasonTitle : $"{showTitle} - {seasonTitle}"; } // Seasons often have no poster of their own; fall back to the parent show's poster (issue #180). private static string SeasonArtwork(SeasonMetadata metadata) { string artwork = Artwork(metadata, ArtworkKind.Poster); if (!string.IsNullOrWhiteSpace(artwork)) { return artwork; } return metadata.Season.Show.ShowMetadata.HeadOrNone() .Match(sm => Artwork(sm, ArtworkKind.Poster), string.Empty); } private static string EpisodeSubtitle(EpisodeMetadata metadata) { string showTitle = metadata.Episode.Season.Show.ShowMetadata.HeadOrNone() .Map(sm => sm.Title ?? string.Empty) .IfNone(string.Empty); int seasonNumber = metadata.Episode.Season.SeasonNumber; string suffix = $"S{seasonNumber}E{metadata.EpisodeNumber}"; return string.IsNullOrWhiteSpace(showTitle) ? suffix : $"{showTitle} - {suffix}"; } private static string MusicVideoSubtitle(MusicVideoMetadata metadata) { string artist = metadata.MusicVideo.Artist.ArtistMetadata.HeadOrNone() .Map(am => am.Title ?? string.Empty) .IfNone(string.Empty); string album = string.IsNullOrWhiteSpace(metadata.Album) ? string.Empty : metadata.Album; if (!string.IsNullOrWhiteSpace(artist) && !string.IsNullOrWhiteSpace(album)) { return $"{artist} - {album}"; } return string.IsNullOrWhiteSpace(artist) ? album : artist; } private static string SongSubtitle(SongMetadata metadata) { string artists = string.Join(", ", metadata.Artists ?? []); string album = string.IsNullOrWhiteSpace(metadata.Album) ? string.Empty : metadata.Album; if (!string.IsNullOrWhiteSpace(artists) && !string.IsNullOrWhiteSpace(album)) { return $"{artists} - {album}"; } return string.IsNullOrWhiteSpace(artists) ? album : artists; } }