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>
324 lines
15 KiB
Plaintext
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: </MudText>
|
|
<MudLink Href="@(@$"content_rating:""{_sortedContentRatings.Head()}""".GetRelativeSearchQuery())">@_sortedContentRatings.Head()</MudLink>
|
|
@foreach (string contentRating in _sortedContentRatings.Skip(1))
|
|
{
|
|
<MudText>, </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: </MudText>
|
|
<MudLink Href="@(@$"language:""{_sortedLanguages.Head().EnglishName.ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedLanguages.Head().EnglishName</MudLink>
|
|
@foreach (CultureInfo language in _sortedLanguages.Skip(1))
|
|
{
|
|
<MudText>, </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: </MudText>
|
|
<MudLink Href="@(@$"studio:""{_sortedStudios.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedStudios.Head()</MudLink>
|
|
@foreach (string studio in _sortedStudios.Skip(1))
|
|
{
|
|
<MudText>, </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: </MudText>
|
|
<MudLink Href="@(@$"network:""{_sortedNetworks.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedNetworks.Head()</MudLink>
|
|
@foreach (string network in _sortedNetworks.Skip(1))
|
|
{
|
|
<MudText>, </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: </MudText>
|
|
<MudLink Href="@(@$"genre:""{_sortedGenres.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedGenres.Head()</MudLink>
|
|
@foreach (string genre in _sortedGenres.Skip(1))
|
|
{
|
|
<MudText>, </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: </MudText>
|
|
<MudLink Href="@(@$"tag:""{_sortedTags.Head().ToLowerInvariant()}""".GetRelativeSearchQuery())">@_sortedTags.Head()</MudLink>
|
|
@foreach (string tag in _sortedTags.Skip(1))
|
|
{
|
|
<MudText>, </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();
|
|
}
|
|
}
|
|
|
|
}
|