Files
ersatztv/ErsatzTV.Application/MediaItems/Mapper.cs
T
timothyandClaude Opus 5 572737a29e fix(671): review round 2 -- guard Song.Artists, cover the second consumer
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>
2026-07-28 19:33:52 +02:00

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>_ =&gt; 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]");
}
}