Files
ersatztv/ErsatzTV.Application/MediaCollections/Mapper.cs
T
timothyandClaude Opus 5 8523088ceb fix(671): resolve rerun-collection selections through one shared include chain
The paged list handler eager-loaded nothing, so `ProjectToViewModel` read four
unloaded navigations and every row of every collection type projected a null
selection. Because the selected id and the display name are read off the SAME
navigation, this dropped the id too -- the harm is not an unlabelled badge but
an editor that round-trips a null and clears the user's stored selection.

The by-id handler loaded metadata for only four of the ten selectable media
types: Song/OtherVideo/Image/RemoteStream returned a null-ish selection and
Episode/MusicVideo threw an NRE that surfaced as a 500.

Fixed at the boundary rather than per call site:

- `RerunCollectionQueryExtensions.IncludeSelectionDetails()` is now the single
  include chain, called by both handlers, joining the existing
  `ProgramScheduleItemQueryExtensions.IncludeScheduleItemDetails()` precedent
  (#229). Artwork legs are deliberately omitted -- this projection reads only
  ids and titles.
- The media-item switch was duplicated verbatim for RerunCollection and
  PlaylistItem; both now call one `ProjectMediaItemToViewModel`, which handles
  `RemoteStream` (via a new `ProjectToNamedViewModel`, since the existing
  `ProjectToViewModel(RemoteStream)` returns an unrelated type) and never falls
  through to null -- an unknown subtype keeps its id and takes a conspicuous
  name, because throwing would fail a whole paged GET over one bad row.
- Every metadata navigation in `MediaItems.Mapper` is now read through
  `Optional(...).Flatten()`, so an un-included nav degrades to "???" instead of
  being a latent 500 for whichever caller loads least.

Tests enumerate all 13 supported CollectionTypes for both handlers, with the
matrix derived from `IsSupportedSelectionType` so a newly-supported type joins
it automatically, plus a completeness guard on the set. Each mechanism was
removed in turn and confirmed red first.

fixes #671

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:48:24 +02:00

137 lines
6.7 KiB
C#

using ErsatzTV.Application.Tree;
using ErsatzTV.Core.Api.SmartCollections;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaCollections;
internal static class Mapper
{
internal static MediaCollectionViewModel ProjectToViewModel(Collection collection) =>
new(
CollectionType.Collection,
collection.Id,
collection.Name,
collection.UseCustomPlaybackOrder,
MediaItemState.Normal,
collection.Version);
internal static MultiCollectionViewModel ProjectToViewModel(MultiCollection multiCollection) =>
new(
multiCollection.Id,
multiCollection.Name,
Optional(multiCollection.MultiCollectionItems).Flatten().Map(ProjectToViewModel).ToList(),
Optional(multiCollection.MultiCollectionSmartItems).Flatten().Map(ProjectToViewModel).ToList(),
multiCollection.Version);
internal static SmartCollectionViewModel ProjectToViewModel(SmartCollection collection) =>
new(collection.Id, collection.Name, collection.Query);
internal static SmartCollectionResponseModel ProjectToResponseModel(SmartCollection collection) =>
new(collection.Id, collection.Name, collection.Query);
internal static RerunCollectionViewModel ProjectToViewModel(RerunCollection collection) =>
new(
collection.Id,
collection.Name,
collection.CollectionType,
collection.Collection is not null ? ProjectToViewModel(collection.Collection) : null,
collection.MultiCollection is not null ? ProjectToViewModel(collection.MultiCollection) : null,
collection.SmartCollection is not null ? ProjectToViewModel(collection.SmartCollection) : null,
ProjectMediaItemToViewModel(collection.MediaItem),
collection.FirstRunPlaybackOrder,
collection.RerunPlaybackOrder,
collection.Version);
/// <summary>
/// Flattens the <see cref="MediaItem" /> half of a selection tagged union to a named view model.
/// Shared by <see cref="RerunCollection" /> and <see cref="PlaylistItem" />, which select from an
/// identical set of media types; one copy is what stops the two drifting apart again (issue #671
/// — the same rationale as <c>ProgramScheduleItemQueryExtensions.IncludeScheduleItemDetails</c>
/// on the query side).
/// A null <paramref name="mediaItem" /> is the legitimate "this selection is not a media item"
/// case (the selection is a Collection/MultiCollection/SmartCollection instead) and maps to null.
/// An unrecognized non-null subtype keeps its id and takes a deliberately conspicuous name rather
/// than falling through to null: the id is what the editor round-trips, so returning null there
/// silently clears the user's stored selection — while throwing would fail an entire paged GET
/// over one unreadable row.
/// </summary>
private static MediaItems.NamedMediaItemViewModel ProjectMediaItemToViewModel(MediaItem mediaItem) =>
mediaItem switch
{
null => null,
Show show => MediaItems.Mapper.ProjectToViewModel(show),
Season season => MediaItems.Mapper.ProjectToViewModel(season),
Artist artist => MediaItems.Mapper.ProjectToViewModel(artist),
Movie movie => MediaItems.Mapper.ProjectToViewModel(movie),
Episode episode => MediaItems.Mapper.ProjectToViewModel(episode),
MusicVideo musicVideo => MediaItems.Mapper.ProjectToViewModel(musicVideo),
OtherVideo otherVideo => MediaItems.Mapper.ProjectToViewModel(otherVideo),
Song song => MediaItems.Mapper.ProjectToViewModel(song),
Image image => MediaItems.Mapper.ProjectToViewModel(image),
RemoteStream remoteStream => MediaItems.Mapper.ProjectToNamedViewModel(remoteStream),
_ => new MediaItems.NamedMediaItemViewModel(
mediaItem.Id,
$"[unsupported media type: {mediaItem.GetType().Name}]")
};
internal static TraktListViewModel ProjectToViewModel(TraktList traktList) =>
new(
traktList.Id,
traktList.TraktId,
$"{traktList.User}/{traktList.List}",
traktList.Name,
traktList.ItemCount,
traktList.Items.Count(i => i.MediaItemId.HasValue),
traktList.AutoRefresh,
traktList.GeneratePlaylist);
private static MultiCollectionItemViewModel ProjectToViewModel(MultiCollectionItem multiCollectionItem) =>
new(
multiCollectionItem.MultiCollectionId,
ProjectToViewModel(multiCollectionItem.Collection),
multiCollectionItem.ScheduleAsGroup,
multiCollectionItem.PlaybackOrder,
multiCollectionItem.Weight);
private static MultiCollectionSmartItemViewModel ProjectToViewModel(
MultiCollectionSmartItem multiCollectionSmartItem) =>
new(
multiCollectionSmartItem.MultiCollectionId,
ProjectToViewModel(multiCollectionSmartItem.SmartCollection),
multiCollectionSmartItem.ScheduleAsGroup,
multiCollectionSmartItem.PlaybackOrder,
multiCollectionSmartItem.Weight);
internal static TreeViewModel ProjectToViewModel(List<PlaylistGroup> playlistGroups) =>
new(
playlistGroups.Map(bg => new TreeGroupViewModel(
bg.Id,
bg.Name,
bg.Playlists.Map(b => new TreeItemViewModel(b.Id, b.Name, b.IsSystem)).ToList(),
bg.IsSystem)).ToList());
internal static PlaylistGroupViewModel ProjectToViewModel(PlaylistGroup playlistGroup) =>
new(playlistGroup.Id, playlistGroup.Name, playlistGroup.Playlists.Count, playlistGroup.IsSystem);
internal static PlaylistViewModel ProjectToViewModel(Playlist playlist) =>
new(playlist.Id, playlist.PlaylistGroupId, playlist.Name, playlist.IsSystem, playlist.Version);
internal static PlaylistItemViewModel ProjectToViewModel(PlaylistItem playlistItem) =>
new(
playlistItem.Id,
playlistItem.Index,
playlistItem.CollectionType,
playlistItem.Collection is not null ? ProjectToViewModel(playlistItem.Collection) : null,
playlistItem.MultiCollection is not null
? ProjectToViewModel(playlistItem.MultiCollection)
: null,
playlistItem.SmartCollection is not null
? ProjectToViewModel(playlistItem.SmartCollection)
: null,
ProjectMediaItemToViewModel(playlistItem.MediaItem),
playlistItem.PlaybackOrder,
playlistItem.Count,
playlistItem.PlayAll,
playlistItem.IncludeInProgramGuide);
}