Files
ersatztv/ErsatzTV/Pages/TelevisionSeasonList.razor
T
timothyandClaude Opus 4.8 628c9d7228 feat(235): F9 API parity — library deep-scan, external-collections scan, scan-show outcome enum (#235 slice B)
Closes the two F9 Libraries.razor parity gaps and normalizes scan-show error
mapping to ProblemDetails.

TASK 1 — library-wide deep scan:
- QueueLibraryScanByLibraryId gains optional `bool DeepScan = false`; handler
  threads it into ForceSynchronize{Plex,Jellyfin,Emby}LibraryById.
- POST /api/libraries/{id}/scan?deep=false binds it via [FromQuery].

TASK 2 — external-collections scan (new endpoints):
- POST /api/media-sources/{plex|jellyfin|emby}/{id}/scan-collections?deep=false
  acquires the per-source collections lock (§3b: lock IS the running scan → 409),
  enqueues Synchronize{X}Collections(id, ForceScan:true, deep) to the scanner
  channel, returns 202; compensating-unlock on enqueue throw.

TASK 3 — scan-show normalization:
- New QueueShowScanResult enum; handler returns it instead of bool.
- POST /api/libraries/{id}/scan-show now maps 202/404/409/422 (all errors
  ProblemDetails) instead of 200/404/400-anonymous-object.
- Updated the lone Blazor caller (TelevisionSeasonList.razor).

Tests: LibrariesController (scan deep=true, scan-show enum→status), the three
media-source controllers (scan-collections route/404/409/202/compensating-unlock),
and handler tests for both changed handlers (deep threading + show-scan outcomes).
Docs: api-conventions §3b exemplar + blazor-route-parity §5 F9 gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 18:02:17 +02:00

324 lines
15 KiB
Plaintext

@page "/media/tv/shows/{ShowId:int}"
@using System.Globalization
@using ErsatzTV.Application.Libraries
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.ProgramSchedules
@using ErsatzTV.Application.Television
@using ErsatzTV.Extensions
@implements IDisposable
@inject IMediator Mediator
@inject ILogger<TelevisionSeasonList> Logger
@inject ISnackbar Snackbar
@inject IDialogService Dialog
@inject NavigationManager NavigationManager
<MudContainer MaxWidth="MaxWidth.False" Style="padding: 0" Class="fanart-container">
<div class="fanart-tint"></div>
@if (!string.IsNullOrWhiteSpace(_show?.FanArt))
{
if (_show.FanArt.StartsWith("http://") || _show.FanArt.StartsWith("https://"))
{
<img src="@_show.FanArt" alt="fan art"/>
}
else
{
<img src="@($"artwork/fanart/{_show.FanArt}")" alt="fan art"/>
}
}
</MudContainer>
<MudContainer MaxWidth="MaxWidth.Large" Style="margin-top: 100px" Class="z-10">
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Spacing="6">
<MudImage Src="@GetPosterUrl(_show?.Poster)" Elevation="2" Class="rounded-lg z-10" Style="max-height: 325px; margin-left: auto; margin-right: auto"/>
<MudStack Row="false" Style="flex: 1" Class="z-10">
<MudStack Row="false">
<MudHidden Invert="true" Breakpoint="Breakpoint.SmAndDown">
<MudText Typo="Typo.h4" Class="media-item-title">@_show?.Title</MudText>
</MudHidden>
<MudHidden Invert="true" Breakpoint="Breakpoint.MdAndUp">
<MudText Typo="Typo.h2" Class="media-item-title">@_show?.Title</MudText>
</MudHidden>
<MudText Typo="Typo.subtitle1" Class="media-item-subtitle mb-6 mud-text-secondary">@_show?.Year</MudText>
</MudStack>
@if (!string.IsNullOrWhiteSpace(_show?.Plot))
{
<MudCard Elevation="2" Class="mb-6">
<MudCardContent Class="mx-3 my-3" Style="height: 100%">
<MudText Style="flex-grow: 1">@_show.Plot</MudText>
</MudCardContent>
</MudCard>
}
<MudStack Row="true" Breakpoint="Breakpoint.SmAndDown" Class="mb-6">
<MudMenu>
<ActivatorContent>
<MudButton StartIcon="@Icons.Material.Filled.Add" Variant="Variant.Filled" Color="Color.Primary">Add To</MudButton>
</ActivatorContent>
<ChildContent>
<MudMenuItem Label="Collection" Icon="@Icons.Material.Filled.Add" OnClick="@AddToCollection"/>
<MudMenuItem Label="Playlist" Icon="@Icons.Material.Filled.PlaylistAdd" OnClick="@AddToPlaylist"/>
<MudMenuItem Label="Schedule" Icon="@Icons.Material.Filled.Schedule" OnClick="@AddToSchedule"/>
</ChildContent>
</MudMenu>
@if (_show?.MediaSourceKind is MediaSourceKind.Plex or MediaSourceKind.Jellyfin or MediaSourceKind.Emby)
{
<MudMenu>
<ActivatorContent>
<MudButton StartIcon="@Icons.Material.Filled.Search" Variant="Variant.Filled" Color="Color.Primary">Scan</MudButton>
</ActivatorContent>
<ChildContent>
<MudMenuItem Label="Quick Scan" Icon="@Icons.Material.Filled.Search" OnClick="@(_ => ScanShow(false))"/>
<MudMenuItem Label="Deep Scan" Icon="@Icons.Material.Filled.ManageSearch" OnClick="@(_ => ScanShow(true))"/>
</ChildContent>
</MudMenu>
}
</MudStack>
</MudStack>
</MudStack>
<MudCard Class="mt-6 mb-6">
<MudCardContent>
@if (_sortedContentRatings.Any())
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Content Ratings:&nbsp;</MudText>
<MudLink Href="@(@$"content_rating:""{_sortedContentRatings.Head()}""".GetRelativeSearchQuery())">@_sortedContentRatings.Head()</MudLink>
@foreach (string contentRating in _sortedContentRatings.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@(@$"content_rating:""{contentRating}""".GetRelativeSearchQuery())">@contentRating</MudLink>
}
</div>
}
@if (_sortedLanguages.Any())
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Languages:&nbsp;</MudText>
<MudLink Href="@(@$"language:""{_sortedLanguages.Head().EnglishName.ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedLanguages.Head().EnglishName</MudLink>
@foreach (CultureInfo language in _sortedLanguages.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@(@$"language:""{language.EnglishName.ToLowerInvariant()}""".GetRelativeSearchQuery())">@language.EnglishName</MudLink>
}
</div>
}
@if (_sortedStudios.Any())
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Studios:&nbsp;</MudText>
<MudLink Href="@(@$"studio:""{_sortedStudios.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedStudios.Head()</MudLink>
@foreach (string studio in _sortedStudios.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@(@$"studio:""{studio.ToLowerInvariant()}""".GetRelativeSearchQuery())">@studio</MudLink>
}
</div>
}
@if (_sortedNetworks.Any())
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Networks:&nbsp;</MudText>
<MudLink Href="@(@$"network:""{_sortedNetworks.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedNetworks.Head()</MudLink>
@foreach (string network in _sortedNetworks.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@(@$"network:""{network.ToLowerInvariant()}""".GetRelativeSearchQuery())">@network</MudLink>
}
</div>
}
@if (_sortedGenres.Any())
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Genres:&nbsp;</MudText>
<MudLink Href="@(@$"genre:""{_sortedGenres.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedGenres.Head()</MudLink>
@foreach (string genre in _sortedGenres.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@(@$"genre:""{genre.ToLowerInvariant()}""".GetRelativeSearchQuery())">@genre</MudLink>
}
</div>
}
@if (_sortedTags.Any())
{
<div style="display: flex; flex-direction: row; flex-wrap: wrap">
<MudText GutterBottom="true">Tags:&nbsp;</MudText>
<MudLink Href="@(@$"tag:""{_sortedTags.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedTags.Head()</MudLink>
@foreach (string tag in _sortedTags.Skip(1))
{
<MudText>,&nbsp;</MudText>
<MudLink Href="@(@$"tag:""{tag.ToLowerInvariant()}""".GetRelativeSearchQuery())">@tag</MudLink>
}
</div>
}
</MudCardContent>
</MudCard>
</MudContainer>
<MudContainer MaxWidth="MaxWidth.Large">
<MudText Class="mb-4">Seasons</MudText>
<MudStack Row="true" Wrap="Wrap.Wrap">
@foreach (TelevisionSeasonCardViewModel card in _data.Cards)
{
<MediaCard Data="@card" Placeholder="@card.Placeholder"
Href="@($"media/tv/seasons/{card.TelevisionSeasonId}")"
AddToCollectionClicked="@AddSeasonToCollection"/>
}
</MudStack>
@if ((_show?.Actors?.Count ?? 0) > 0)
{
<MudText Class="mb-4">Actors</MudText>
<MudStack Row="true" Wrap="Wrap.Wrap">
@foreach (ActorCardViewModel actor in _show.Actors)
{
<MediaCard Data="@actor"
Href="@(@$"actor:""{actor.Name.ToLowerInvariant()}""".GetRelativeSearchQuery())"
ArtworkKind="ArtworkKind.Thumbnail"/>
}
</MudStack>
}
</MudContainer>
@code {
private CancellationTokenSource _cts;
[Parameter]
public int ShowId { get; set; }
private TelevisionShowViewModel _show;
private List<string> _sortedContentRatings = [];
private List<CultureInfo> _sortedLanguages = [];
private List<string> _sortedStudios = [];
private List<string> _sortedNetworks = [];
private List<string> _sortedGenres = [];
private List<string> _sortedTags = [];
private static int PageSize => 100;
private const int PageNumber = 1;
private TelevisionSeasonCardResultsViewModel _data = new(0, [], null);
public void Dispose()
{
_cts?.Cancel();
_cts?.Dispose();
}
protected override async Task OnParametersSetAsync()
{
_cts?.Cancel();
_cts?.Dispose();
_cts = new CancellationTokenSource();
var token = _cts.Token;
try
{
Option<TelevisionShowViewModel> maybeShow = await Mediator.Send(new GetTelevisionShowById(ShowId), token);
foreach (TelevisionShowViewModel show in maybeShow)
{
_show = show;
_sortedContentRatings = _show.ContentRatings.OrderBy(cr => cr).ToList();
_sortedLanguages = _show.Languages.OrderBy(ci => ci.EnglishName).ToList();
_sortedStudios = _show.Studios.OrderBy(s => s).ToList();
_sortedGenres = _show.Genres.OrderBy(g => g).ToList();
_sortedTags = _show.Tags.OrderBy(t => t).ToList();
_sortedNetworks = _show.Networks.OrderBy(n => n).ToList();
}
_data = await Mediator.Send(new GetTelevisionSeasonCards(ShowId, PageNumber, PageSize), token);
}
catch (OperationCanceledException)
{
// do nothing
}
}
private async Task AddToCollection()
{
var parameters = new DialogParameters { { "EntityType", "show" }, { "EntityName", _show.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
await Mediator.Send(new AddShowToCollection(collection.Id, ShowId), _cts.Token);
NavigationManager.NavigateTo($"media/collections/{collection.Id}");
}
}
private async Task AddToPlaylist()
{
var parameters = new DialogParameters { { "EntityType", "show" }, { "EntityName", _show.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToPlaylistDialog>("Add To Playlist", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: PlaylistViewModel playlist })
{
await Mediator.Send(new AddShowToPlaylist(playlist.Id, ShowId), _cts.Token);
NavigationManager.NavigateTo($"media/playlists/{playlist.Id}");
}
}
private async Task AddToSchedule()
{
var parameters = new DialogParameters { { "EntityType", "show" }, { "EntityName", _show.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToScheduleDialog>("Add To Schedule", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: ProgramScheduleViewModel schedule })
{
await Mediator.Send(AddProgramScheduleItem.ForMediaItem(schedule.Id, CollectionType.TelevisionShow, ShowId), _cts.Token);
NavigationManager.NavigateTo($"schedules/{schedule.Id}/items");
}
}
private async Task AddSeasonToCollection(MediaCardViewModel card)
{
if (card is TelevisionSeasonCardViewModel season)
{
var parameters = new DialogParameters { { "EntityType", "season" }, { "EntityName", season.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = await Dialog.ShowAsync<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (result is { Canceled: false, Data: MediaCollectionViewModel collection })
{
var request = new AddSeasonToCollection(collection.Id, season.TelevisionSeasonId);
Either<BaseError, Unit> addResult = await Mediator.Send(request, _cts.Token);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding season to collection: {error.Value}", Severity.Error);
Logger.LogError("Unexpected error adding season to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {season.Title} to collection {collection.Name}", Severity.Success));
}
}
}
private static string GetPosterUrl(string poster)
{
if (string.IsNullOrWhiteSpace(poster))
{
return poster;
}
return poster.StartsWith("http://") || poster.StartsWith("https://") ? poster : $"artwork/posters/{poster}";
}
private async Task ScanShow(bool deepScan)
{
QueueShowScanResult result =
await Mediator.Send(new QueueShowScanByLibraryId(_show.LibraryId, _show.Id, _show.Title, deepScan));
if (result != QueueShowScanResult.Queued)
{
Snackbar.Add($"Unable to scan show {_show.Title}", Severity.Error);
}
else
{
Snackbar.Add($"Done scanning show {_show.Title}", Severity.Success);
StateHasChanged();
}
}
}