Adds a paged collection-items endpoint reusing LibraryBrowseItemResponseModel
so the SPA lists a manual collection's full contents (all media kinds), replacing
the lossy Lucene name-based preview. Confirms POST /items already returns 422 for
bogus ids (guarded by ValidateMediaItems, fb3f2856); adds endpoint-level coverage.
fixes #155
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
621 lines
23 KiB
C#
621 lines
23 KiB
C#
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<List<LibraryBrowseItemResponseModel>> HydrateMediaItemsByIds(
|
|
TvContext dbContext,
|
|
IReadOnlyList<int> ids,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (ids.Count == 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
var idList = ids.Distinct().ToList();
|
|
|
|
var results = new List<LibraryBrowseItemResponseModel>();
|
|
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<List<LibraryBrowseItemResponseModel>> GetMovies(
|
|
TvContext dbContext,
|
|
List<int> 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<List<LibraryBrowseItemResponseModel>> GetShows(
|
|
TvContext dbContext,
|
|
List<int> ids,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (ids.Count == 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
Dictionary<int, int> 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<List<LibraryBrowseItemResponseModel>> GetSeasons(
|
|
TvContext dbContext,
|
|
List<int> ids,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (ids.Count == 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
Dictionary<int, int> 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<List<LibraryBrowseItemResponseModel>> GetArtists(
|
|
TvContext dbContext,
|
|
List<int> ids,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
if (ids.Count == 0)
|
|
{
|
|
return [];
|
|
}
|
|
|
|
Dictionary<int, int> 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<List<LibraryBrowseItemResponseModel>> GetEpisodes(
|
|
TvContext dbContext,
|
|
List<int> 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))).ToList());
|
|
}
|
|
|
|
public static async Task<List<LibraryBrowseItemResponseModel>> GetMusicVideos(
|
|
TvContext dbContext,
|
|
List<int> 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<List<LibraryBrowseItemResponseModel>> GetSongs(
|
|
TvContext dbContext,
|
|
List<int> 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<List<LibraryBrowseItemResponseModel>> GetOtherVideos(
|
|
TvContext dbContext,
|
|
List<int> 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<List<LibraryBrowseItemResponseModel>> GetImages(
|
|
TvContext dbContext,
|
|
List<int> 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<List<LibraryBrowseItemResponseModel>> GetRemoteStreams(
|
|
TvContext dbContext,
|
|
List<int> 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<MediaVersion> 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 <img src>. Blazor pages rely on
|
|
// GetPosterUrl to prefix "artwork/posters/" and resolve relative to <base href="/">, 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;
|
|
}
|
|
}
|