Cold independent review of 017ef988d. Three findings taken, one filed out.
The important one is a regression this branch INTRODUCED. `SongMetadata.Artists`
is a nullable EF primitive collection (JSON in one column, not a navigation)
that FallbackMetadataProvider leaves unassigned when a song's tags fail to read,
and `string.Join` throws ArgumentNullException on a null sequence. The rerun
list previously did not load SongMetadata at all, so the throw was unreachable
there; adding the include promoted it to a live 500 that would have failed the
whole page. Confirmed by reverting the guard: ArgumentNullException, parameter
'values'. The file header claiming every member was guarded was false.
The empty case is filtered too, so an artist-less song loses its bare " - ".
Second: `GetPlaylistItemsHandler` had no handler-level test at all (its
controller tests stub the mediator), so the RemoteStream include added last
round was discharged by inspection -- the same method that produced #671. It
now runs the same 13-type matrix via a shared SelectionSeedData; removing the
include fails that matrix.
Third: dropped the dead `(i as Season).SeasonMetadata` include leg -- the Season
projection reads Show.ShowMetadata and the scalar SeasonNumber, never
SeasonMetadata.
Filed #690 for the pre-existing, out-of-scope finding: the paged TotalCount
ignores the search query, so the SPA renders empty pages.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
144 lines
6.5 KiB
C#
144 lines
6.5 KiB
C#
using System.Globalization;
|
|
using ErsatzTV.Core.Domain;
|
|
|
|
namespace ErsatzTV.Application.MediaItems;
|
|
|
|
internal static class Mapper
|
|
{
|
|
// Every metadata navigation below is read through Optional(...).Flatten() rather than a bare
|
|
// dereference: these projections are reached from several handlers whose Include chains differ,
|
|
// and an un-included navigation must degrade to the "???" placeholder instead of throwing an
|
|
// NRE that surfaces as a 500 on a GET (issue #671).
|
|
internal static NamedMediaItemViewModel ProjectToViewModel(Show show) =>
|
|
new(
|
|
show.Id,
|
|
Optional(show.ShowMetadata).Flatten().HeadOrNone().Map(sm => $"{sm?.Title} ({sm?.Year})").IfNone("???"));
|
|
|
|
internal static NamedMediaItemViewModel ProjectToViewModel(Season season) =>
|
|
new(season.Id, $"{ShowTitle(season)} - {SeasonDescription(season)}");
|
|
|
|
internal static NamedMediaItemViewModel ProjectToViewModel(Artist artist) =>
|
|
new(artist.Id, Optional(artist.ArtistMetadata).Flatten().HeadOrNone().Match(am => am.Title, () => "???"));
|
|
|
|
internal static NamedMediaItemViewModel ProjectToViewModel(Movie movie) =>
|
|
new(movie.Id, MovieTitle(movie));
|
|
|
|
internal static NamedMediaItemViewModel ProjectToViewModel(Episode episode) =>
|
|
new(episode.Id, EpisodeTitle(episode));
|
|
|
|
internal static NamedMediaItemViewModel ProjectToViewModel(MusicVideo musicVideo) =>
|
|
new(musicVideo.Id, MusicVideoTitle(musicVideo));
|
|
|
|
internal static NamedMediaItemViewModel ProjectToViewModel(OtherVideo otherVideo) =>
|
|
new(
|
|
otherVideo.Id,
|
|
Optional(otherVideo.OtherVideoMetadata).Flatten().HeadOrNone().Match(ov => ov.Title, () => "???"));
|
|
|
|
internal static NamedMediaItemViewModel ProjectToViewModel(Song song) =>
|
|
new(song.Id, SongTitle(song));
|
|
|
|
internal static NamedMediaItemViewModel ProjectToViewModel(Image image) =>
|
|
new(image.Id, Optional(image.ImageMetadata).Flatten().HeadOrNone().Match(i => i.Title, () => "???"));
|
|
|
|
internal static RemoteStreamViewModel ProjectToViewModel(RemoteStream remoteStream) =>
|
|
new(remoteStream.Id, remoteStream.Url, remoteStream.Script);
|
|
|
|
/// <summary>
|
|
/// The named projection for a <see cref="RemoteStream" />. This cannot be an overload of
|
|
/// <see cref="ProjectToViewModel(RemoteStream)" /> — that one already exists and returns a
|
|
/// <see cref="RemoteStreamViewModel" />, and C# will not overload on return type alone. Its
|
|
/// absence is why every selection-flattening switch dropped <c>RemoteStream</c> through a
|
|
/// <c>_ => null</c> arm (issue #671).
|
|
/// </summary>
|
|
internal static NamedMediaItemViewModel ProjectToNamedViewModel(RemoteStream remoteStream) =>
|
|
new(
|
|
remoteStream.Id,
|
|
Optional(remoteStream.RemoteStreamMetadata).Flatten().HeadOrNone().Match(rsm => rsm.Title, () => "???"));
|
|
|
|
private static string MovieTitle(Movie movie)
|
|
{
|
|
var title = "???";
|
|
var year = "???";
|
|
|
|
foreach (MovieMetadata movieMetadata in Optional(movie.MovieMetadata).Flatten().HeadOrNone())
|
|
{
|
|
title = movieMetadata.Title;
|
|
foreach (int y in Optional(movieMetadata.Year))
|
|
{
|
|
year = y.ToString(CultureInfo.InvariantCulture);
|
|
}
|
|
}
|
|
|
|
return $"{title} ({year})";
|
|
}
|
|
|
|
private static string ShowTitle(Season season)
|
|
{
|
|
var title = "???";
|
|
var year = "???";
|
|
|
|
// Season.Show and Show.ShowMetadata are only populated when the caller eager-loaded them.
|
|
// An un-included navigation must degrade to the "???" placeholder these helpers already
|
|
// produce for missing metadata — never an NRE, which surfaced as a 500 (issue #671).
|
|
foreach (ShowMetadata show in Optional(season.Show?.ShowMetadata).Flatten().HeadOrNone())
|
|
{
|
|
title = show.Title;
|
|
foreach (int y in Optional(show.Year))
|
|
{
|
|
year = y.ToString(CultureInfo.InvariantCulture);
|
|
}
|
|
}
|
|
|
|
return $"{title} ({year})";
|
|
}
|
|
|
|
private static string SeasonDescription(Season season) =>
|
|
season.SeasonNumber == 0 ? "Specials" : $"Season {season.SeasonNumber}";
|
|
|
|
private static string EpisodeTitle(Episode e)
|
|
{
|
|
string showTitle = Optional(e.Season?.Show?.ShowMetadata).Flatten().HeadOrNone()
|
|
.Map(sm => $"{sm.Title} - ").IfNone(string.Empty);
|
|
var episodeNumbers = Optional(e.EpisodeMetadata).Flatten().Map(em => em.EpisodeNumber).ToList();
|
|
var episodeTitles = Optional(e.EpisodeMetadata).Flatten().Map(em => em.Title).ToList();
|
|
if (episodeNumbers.Count == 0 || episodeTitles.Count == 0)
|
|
{
|
|
return "[unknown episode]";
|
|
}
|
|
|
|
var numbersString = $"e{string.Join('e', episodeNumbers.Map(n => $"{n:00}"))}";
|
|
var titlesString = $"{string.Join('/', episodeTitles)}";
|
|
|
|
// "s00" conventionally means Specials, so an unloaded Season must not borrow it — that would
|
|
// fabricate plausible-looking real data. Render the season as explicitly unknown instead.
|
|
string seasonNumber = e.Season is null ? "??" : $"{e.Season.SeasonNumber:00}";
|
|
|
|
return $"{showTitle}s{seasonNumber}{numbersString} - {titlesString}";
|
|
}
|
|
|
|
private static string MusicVideoTitle(MusicVideo mv)
|
|
{
|
|
string artistName = Optional(mv.Artist?.ArtistMetadata).Flatten().HeadOrNone()
|
|
.Map(am => $"{am.Title} - ").IfNone(string.Empty);
|
|
return Optional(mv.MusicVideoMetadata).Flatten().HeadOrNone()
|
|
.Map(mvm => $"{artistName}{mvm.Title}")
|
|
.IfNone("[unknown music video]");
|
|
}
|
|
|
|
private static string SongTitle(Song s)
|
|
{
|
|
// Artists is a NULLABLE primitive collection, not a navigation: a song whose tags failed to read
|
|
// is persisted by FallbackMetadataProvider with Artists never assigned, and string.Join throws
|
|
// ArgumentNullException on a null sequence. Filtering the empty case too avoids prefixing an
|
|
// artist-less song with a bare " - ".
|
|
string songArtist = Optional(s.SongMetadata).Flatten().HeadOrNone()
|
|
.Map(sm => Optional(sm.Artists).Flatten().ToList())
|
|
.Filter(artists => artists.Count > 0)
|
|
.Map(artists => $"{string.Join(", ", artists)} - ")
|
|
.IfNone(string.Empty);
|
|
return Optional(s.SongMetadata).Flatten().HeadOrNone()
|
|
.Map(sm => $"{songArtist}{sm.Title ?? string.Empty}")
|
|
.IfNone("[unknown song]");
|
|
}
|
|
}
|