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>
137 lines
6.7 KiB
C#
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);
|
|
}
|