diff --git a/CHANGELOG.md b/CHANGELOG.md index b0783440c..fb33836e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Added +- Add button to copy/clone schedule from schedules table + ### Fixed - Fix many QSV pipeline bugs - Fix MPEG2 video format with QSV and VAAPI acceleration diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/CopyProgramSchedule.cs b/ErsatzTV.Application/ProgramSchedules/Commands/CopyProgramSchedule.cs new file mode 100644 index 000000000..2c09f683a --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/Commands/CopyProgramSchedule.cs @@ -0,0 +1,6 @@ +using ErsatzTV.Core; + +namespace ErsatzTV.Application.ProgramSchedules; + +public record CopyProgramSchedule + (int ProgramScheduleId, string Name) : IRequest>; diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/CopyProgramScheduleHandler.cs b/ErsatzTV.Application/ProgramSchedules/Commands/CopyProgramScheduleHandler.cs new file mode 100644 index 000000000..48e9efe18 --- /dev/null +++ b/ErsatzTV.Application/ProgramSchedules/Commands/CopyProgramScheduleHandler.cs @@ -0,0 +1,113 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Domain; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using static ErsatzTV.Application.ProgramSchedules.Mapper; + +namespace ErsatzTV.Application.ProgramSchedules; + +public class + CopyProgramScheduleHandler : IRequestHandler> +{ + private readonly IDbContextFactory _dbContextFactory; + + public CopyProgramScheduleHandler(IDbContextFactory dbContextFactory) => + _dbContextFactory = dbContextFactory; + + public async Task> Handle( + CopyProgramSchedule request, + CancellationToken cancellationToken) + { + try + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + Validation validation = await Validate(dbContext, request); + return await validation.Apply(p => PerformCopy(dbContext, p, request, cancellationToken)); + } + catch (Exception ex) + { + return BaseError.New(ex.Message); + } + } + + private async Task PerformCopy( + TvContext dbContext, + ProgramSchedule schedule, + CopyProgramSchedule request, + CancellationToken cancellationToken) + { + var clone = new ProgramSchedule(); + await dbContext.AddAsync(clone, cancellationToken); + + clone.Name = request.Name; + clone.RandomStartPoint = schedule.RandomStartPoint; + clone.ShuffleScheduleItems = schedule.ShuffleScheduleItems; + clone.TreatCollectionsAsShows = schedule.TreatCollectionsAsShows; + clone.KeepMultiPartEpisodesTogether = schedule.KeepMultiPartEpisodesTogether; + + // no playouts, no alternates + clone.Playouts = new List(); + clone.ProgramScheduleAlternates = new List(); + + // clone all items + clone.Items = new List(); + foreach (ProgramScheduleItem item in schedule.Items) + { + PropertyValues itemValues = dbContext.Entry(item).CurrentValues.Clone(); + itemValues["Id"] = 0; + + ProgramScheduleItem itemClone = item switch + { + ProgramScheduleItemFlood => new ProgramScheduleItemFlood(), + ProgramScheduleItemDuration => new ProgramScheduleItemDuration(), + ProgramScheduleItemMultiple => new ProgramScheduleItemMultiple(), + _ => new ProgramScheduleItemOne() + }; + + await dbContext.AddAsync(itemClone, cancellationToken); + dbContext.Entry(itemClone).CurrentValues.SetValues(itemValues); + + itemClone.ProgramScheduleId = 0; + itemClone.ProgramSchedule = clone; + } + + await dbContext.SaveChangesAsync(cancellationToken); + + return ProjectToViewModel(clone); + } + + private static async Task> Validate( + TvContext dbContext, + CopyProgramSchedule request) => + (await ScheduleMustExist(dbContext, request), await ValidateName(dbContext, request)) + .Apply((programSchedule, _) => programSchedule); + + private static Task> ScheduleMustExist( + TvContext dbContext, + CopyProgramSchedule request) => + dbContext.ProgramSchedules + .AsNoTracking() + .Include(ps => ps.Items) + .SelectOneAsync(p => p.Id, p => p.Id == request.ProgramScheduleId) + .Map(o => o.ToValidation("Schedule does not exist.")); + + private static async Task> ValidateName( + TvContext dbContext, + CopyProgramSchedule request) + { + List allNames = await dbContext.ProgramSchedules + .Map(ps => ps.Name) + .ToListAsync(); + + Validation result1 = request.NotEmpty(c => c.Name) + .Bind(_ => request.NotLongerThan(50)(c => c.Name)); + + var result2 = Optional(request.Name) + .Where(name => !allNames.Contains(name)) + .ToValidation("Schedule name must be unique"); + + return (result1, result2).Apply((_, _) => request.Name); + } +} diff --git a/ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramScheduleHandler.cs b/ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramScheduleHandler.cs index 680dfc826..d26cce50c 100644 --- a/ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramScheduleHandler.cs +++ b/ErsatzTV.Application/ProgramSchedules/Commands/CreateProgramScheduleHandler.cs @@ -17,10 +17,10 @@ public class CreateProgramScheduleHandler : CreateProgramSchedule request, CancellationToken cancellationToken) { - await using TvContext dbContext = _dbContextFactory.CreateDbContext(); + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); Validation validation = await Validate(dbContext, request); - return await LanguageExtensions.Apply(validation, ps => PersistProgramSchedule(dbContext, ps)); + return await validation.Apply(ps => PersistProgramSchedule(dbContext, ps)); } private static async Task PersistProgramSchedule( diff --git a/ErsatzTV/Pages/Schedules.razor b/ErsatzTV/Pages/Schedules.razor index 6c855185d..4daf170c5 100644 --- a/ErsatzTV/Pages/Schedules.razor +++ b/ErsatzTV/Pages/Schedules.razor @@ -3,8 +3,9 @@ @using ErsatzTV.Application.Configuration @using NaturalSort.Extension @implements IDisposable -@inject IDialogService _dialog -@inject IMediator _mediator +@inject IDialogService Dialog +@inject IMediator Mediator +@inject NavigationManager NavigationManager - + @@ -42,6 +43,11 @@ Link="@($"schedules/{context.Id}/items")"> + + + + @@ -104,9 +110,9 @@ protected override async Task OnParametersSetAsync() { - _rowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.SchedulesPageSize), _cts.Token) + _rowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.SchedulesPageSize), _cts.Token) .Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10)); - _detailRowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.SchedulesDetailPageSize), _cts.Token) + _detailRowsPerPage = await Mediator.Send(new GetConfigElementByKey(ConfigElementKey.SchedulesDetailPageSize), _cts.Token) .Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10)); } @@ -124,11 +130,11 @@ var parameters = new DialogParameters { { "EntityType", "schedule" }, { "EntityName", programSchedule.Name } }; var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; - IDialogReference dialog = await _dialog.ShowAsync("Delete Schedule", parameters, options); + IDialogReference dialog = await Dialog.ShowAsync("Delete Schedule", parameters, options); DialogResult result = await dialog.Result; if (!result.Canceled) { - await _mediator.Send(new DeleteProgramSchedule(programSchedule.Id), _cts.Token); + await Mediator.Send(new DeleteProgramSchedule(programSchedule.Id), _cts.Token); if (_table != null) { await _table.ReloadServerData(); @@ -139,12 +145,25 @@ } } } + + private async Task CopySchedule(ProgramScheduleViewModel programSchedule) + { + var parameters = new DialogParameters { { "ProgramScheduleId", programSchedule.Id } }; + var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall }; + + IDialogReference dialog = await Dialog.ShowAsync("Copy Schedule", parameters, options); + DialogResult dialogResult = await dialog.Result; + if (!dialogResult.Canceled && dialogResult.Data is ProgramScheduleViewModel data) + { + NavigationManager.NavigateTo($"schedules/{data.Id}/items"); + } + } private async Task> ServerReload(TableState state) { - await _mediator.Send(new SaveConfigElementByKey(ConfigElementKey.SchedulesPageSize, state.PageSize.ToString()), _cts.Token); + await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.SchedulesPageSize, state.PageSize.ToString()), _cts.Token); - List schedules = await _mediator.Send(new GetAllProgramSchedules(), _cts.Token); + List schedules = await Mediator.Send(new GetAllProgramSchedules(), _cts.Token); IOrderedEnumerable sorted = schedules.OrderBy(s => s.Name, new NaturalSortComparer(StringComparison.CurrentCultureIgnoreCase)); // TODO: properly page this data @@ -157,9 +176,9 @@ private async Task> DetailServerReload(TableState state) { - await _mediator.Send(new SaveConfigElementByKey(ConfigElementKey.SchedulesDetailPageSize, state.PageSize.ToString()), _cts.Token); + await Mediator.Send(new SaveConfigElementByKey(ConfigElementKey.SchedulesDetailPageSize, state.PageSize.ToString()), _cts.Token); - List scheduleItems = await _mediator.Send(new GetProgramScheduleItems(_selectedSchedule.Id), _cts.Token); + List scheduleItems = await Mediator.Send(new GetProgramScheduleItems(_selectedSchedule.Id), _cts.Token); IOrderedEnumerable sorted = scheduleItems.OrderBy(s => s.Index); // TODO: properly page this data diff --git a/ErsatzTV/Shared/CopyScheduleDialog.razor b/ErsatzTV/Shared/CopyScheduleDialog.razor new file mode 100644 index 000000000..f85c6a1ae --- /dev/null +++ b/ErsatzTV/Shared/CopyScheduleDialog.razor @@ -0,0 +1,73 @@ +@using ErsatzTV.Application.ProgramSchedules +@implements IDisposable +@inject IMediator Mediator +@inject ISnackbar Snackbar +@inject ILogger Logger + + + + + + + Enter a name for the new Schedule + + + + + + + + Cancel + + Copy Schedule + + + + +@code { + private readonly CancellationTokenSource _cts = new(); + + [CascadingParameter] + MudDialogInstance MudDialog { get; set; } + + [Parameter] + public int ProgramScheduleId { get; set; } + + private record DummyModel; + + private readonly DummyModel _dummyModel = new(); + + private string _newName; + + public void Dispose() + { + _cts.Cancel(); + _cts.Dispose(); + } + + private bool CanSubmit() => !string.IsNullOrWhiteSpace(_newName); + + private async Task Submit() + { + if (!CanSubmit()) + { + return; + } + + Either maybeResult = + await Mediator.Send(new CopyProgramSchedule(ProgramScheduleId, _newName), _cts.Token); + + maybeResult.Match( + schedule => { MudDialog.Close(DialogResult.Ok(schedule)); }, + error => + { + Snackbar.Add(error.Value, Severity.Error); + Logger.LogError("Error copying Schedule: {Error}", error.Value); + MudDialog.Close(DialogResult.Cancel()); + }); + } + + private void Cancel(MouseEventArgs e) => MudDialog.Cancel(); +} \ No newline at end of file