Files
ersatztv/ErsatzTV/Pages/RerunCollectionEditor.razor
T
timothyandClaude Fable 5 6dac2d724c
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 7m43s
Build ErsatzTV Image / Docs update reminder (pull_request) Successful in 12s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 9m52s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
fix(#193): review follow-ups — drop Playlist option, non-tracking existence check
Adversarial review follow-ups on the #193 rerun selection validation:
- RerunCollectionEditor.razor: remove the CollectionType.Playlist
  dropdown option — RerunCollection has no PlaylistId column, so the
  option previously persisted a broken row and now fails validation.
- RerunCollectionSelectionValidation: replace FindAsync with a
  non-tracking AnyAsync(EF.Property<int>("Id") == id) existence check
  so nothing is materialized/tracked into the save-path context.
- Tests: pin the deliberate 422-not-404 choice
  (ShouldNotBeOfType<NotFoundError>) and cover the unsupported-type
  default arm (Playlist -> "Unsupported collection type"), which was
  reachable via the Blazor editor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 19:53:29 +02:00

348 lines
16 KiB
Plaintext

@page "/media/rerun-collections/{Id:int}/edit"
@page "/media/rerun-collections/add"
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaItems
@using ErsatzTV.Application.Search
@implements IDisposable
@inject NavigationManager NavigationManager
@inject ISnackbar Snackbar
@inject IMediator Mediator
@inject ILogger<RerunCollectionEditor> Logger
<MudForm @ref="_form" @bind-IsValid="@_success" Style="max-height: 100%">
<MudPaper Square="true" Style="display: flex; height: 64px; min-height: 64px; width: 100%; z-index: 100; align-items: center">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => HandleSubmitAsync())" Class="ml-6" StartIcon="@Icons.Material.Filled.Save">
@(IsEdit ? "Save Rerun Collection" : "Add Rerun Collection")
</MudButton>
</MudPaper>
<div class="d-flex flex-column" style="height: 100vh; overflow-x: auto">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
<MudText Typo="Typo.h5" Class="mb-2">Rerun Collection</MudText>
<MudDivider Class="mb-6"/>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Name</MudText>
</div>
<MudTextField @bind-Value="_model.Name" For="@(() => _model.Name)" Required="true" RequiredError="Rerun collection name is required!"/>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Collection Type</MudText>
</div>
<MudSelect @bind-Value="_model.CollectionType" For="@(() => _model.CollectionType)">
<MudSelectItem Value="CollectionType.Collection">Collection</MudSelectItem>
<MudSelectItem Value="CollectionType.TelevisionShow">Television Show</MudSelectItem>
<MudSelectItem Value="CollectionType.TelevisionSeason">Television Season</MudSelectItem>
<MudSelectItem Value="CollectionType.Artist">Artist</MudSelectItem>
<MudSelectItem Value="CollectionType.MultiCollection">Multi Collection</MudSelectItem>
<MudSelectItem Value="CollectionType.SmartCollection">Smart Collection</MudSelectItem>
</MudSelect>
</MudStack>
@if (_model.CollectionType == CollectionType.Collection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Collection</MudText>
</div>
<MudAutocomplete T="MediaCollectionViewModel"
@bind-Value="_model.Collection" SearchFunc="@SearchCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_model.CollectionType == CollectionType.MultiCollection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Multi Collection</MudText>
</div>
<MudAutocomplete T="MultiCollectionViewModel"
@bind-Value="_model.MultiCollection" SearchFunc="@SearchMultiCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_model.CollectionType == CollectionType.SmartCollection)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Smart Collection</MudText>
</div>
<MudAutocomplete T="SmartCollectionViewModel"
@bind-Value="_model.SmartCollection" SearchFunc="@SearchSmartCollections"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_model.CollectionType == CollectionType.TelevisionShow)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Television Show</MudText>
</div>
<MudAutocomplete T="NamedMediaItemViewModel"
@bind-Value="_model.MediaItem" SearchFunc="@SearchTelevisionShows"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search...">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_model.CollectionType == CollectionType.TelevisionSeason)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Television Season</MudText>
</div>
<MudAutocomplete T="NamedMediaItemViewModel"
@bind-Value="_model.MediaItem" SearchFunc="@SearchTelevisionSeasons"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search..."
MaxItems="20">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 20 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
@if (_model.CollectionType == CollectionType.Artist)
{
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Artist</MudText>
</div>
<MudAutocomplete T="NamedMediaItemViewModel"
@bind-Value="_model.MediaItem" SearchFunc="@SearchArtists"
ToStringFunc="@(c => c?.Name)" Placeholder="Type to search..."
MaxItems="10">
<MoreItemsTemplate>
<MudText Align="Align.Center" Class="px-4 py-1">
Only the first 10 items are shown
</MudText>
</MoreItemsTemplate>
</MudAutocomplete>
</MudStack>
}
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>First Run Playback Order</MudText>
</div>
<MudSelect @bind-Value="@_model.FirstRunPlaybackOrder" For="@(() => _model.FirstRunPlaybackOrder)">
@switch (_model.CollectionType)
{
case CollectionType.MultiCollection:
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
break;
case CollectionType.Collection:
case CollectionType.SmartCollection:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
break;
case CollectionType.TelevisionShow:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.SeasonEpisode">Season, Episode</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
break;
default:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
break;
}
</MudSelect>
</MudStack>
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="form-field-stack gap-md-8 mb-5">
<div class="d-flex">
<MudText>Rerun Playback Order</MudText>
</div>
<MudSelect @bind-Value="@_model.RerunPlaybackOrder" For="@(() => _model.RerunPlaybackOrder)">
@switch (_model.CollectionType)
{
case CollectionType.MultiCollection:
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
break;
case CollectionType.Collection:
case CollectionType.SmartCollection:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
break;
case CollectionType.TelevisionShow:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.SeasonEpisode">Season, Episode</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
break;
default:
<MudSelectItem Value="PlaybackOrder.Chronological">Chronological</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Random">Random</MudSelectItem>
<MudSelectItem Value="PlaybackOrder.Shuffle">Shuffle</MudSelectItem>
break;
}
</MudSelect>
</MudStack>
</MudContainer>
</div>
</MudForm>
@code {
private CancellationTokenSource _cts;
[Parameter]
public int Id { get; set; }
private readonly RerunCollectionEditViewModel _model = new();
private MudForm _form;
private bool _success;
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
if (IsEdit)
{
Option<RerunCollectionViewModel> maybeCollection = await Mediator.Send(new GetRerunCollectionById(Id), token);
maybeCollection.IfSome(collection =>
{
_model.Id = collection.Id;
_model.Name = collection.Name;
_model.CollectionType = collection.CollectionType;
_model.Collection = collection.Collection;
_model.MultiCollection = collection.MultiCollection;
_model.SmartCollection = collection.SmartCollection;
_model.MediaItem = collection.MediaItem;
_model.FirstRunPlaybackOrder = collection.FirstRunPlaybackOrder;
_model.RerunPlaybackOrder = collection.RerunPlaybackOrder;
});
}
else
{
_model.Name = "New Rerun Collection";
}
}
catch (OperationCanceledException)
{
// do nothing
}
}
private bool IsEdit => Id != 0;
private async Task<IEnumerable<MediaCollectionViewModel>> SearchCollections(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<MediaCollectionViewModel>();
}
return await Mediator.Send(new SearchCollections(value), _cts.Token);
}
private async Task<IEnumerable<MultiCollectionViewModel>> SearchMultiCollections(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<MultiCollectionViewModel>();
}
return await Mediator.Send(new SearchMultiCollections(value), _cts.Token);
}
private async Task<IEnumerable<SmartCollectionViewModel>> SearchSmartCollections(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<SmartCollectionViewModel>();
}
return await Mediator.Send(new SearchSmartCollections(value), _cts.Token);
}
private async Task<IEnumerable<NamedMediaItemViewModel>> SearchTelevisionShows(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<NamedMediaItemViewModel>();
}
return await Mediator.Send(new SearchTelevisionShows(value), _cts.Token);
}
private async Task<IEnumerable<NamedMediaItemViewModel>> SearchTelevisionSeasons(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<NamedMediaItemViewModel>();
}
return await Mediator.Send(new SearchTelevisionSeasons(value), _cts.Token);
}
private async Task<IEnumerable<NamedMediaItemViewModel>> SearchArtists(string value, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(value))
{
return new List<NamedMediaItemViewModel>();
}
return await Mediator.Send(new SearchArtists(value), _cts.Token);
}
private async Task HandleSubmitAsync()
{
await _form.Validate();
if (_success)
{
Seq<BaseError> errorMessage = IsEdit
? (await Mediator.Send(_model.ToUpdate(), _cts.Token)).LeftToSeq()
: (await Mediator.Send(_model.ToCreate(), _cts.Token)).LeftToSeq();
errorMessage.HeadOrNone().Match(
error =>
{
Snackbar.Add(error.Value, Severity.Error);
Logger.LogError("Error saving collection: {Error}", error.Value);
},
() => NavigationManager.NavigateTo("media/rerun-collections"));
}
}
}