diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e141cebd..61ae5aca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Add channel troubleshooting button to channels list - This will open the playback troubleshooting tool in "channel" mode - This mode requires entering a date and time, and will play up to 30 seconds of *one item from that channel's playout* starting at the entered date and time +- Block schedules: add copy template button to templates table ### Fixed - Fix HLS Direct playback with Jellyfin 10.11 @@ -84,6 +85,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Use smaller batch size for search index updates (100, down from 1000) - This should help newly scanned items appear in the UI more quickly - Replace favicon and logo in background image used for error streams +- Block schedules: + - Auto scroll day view to block item time when adding and removing block items from template + - Allow keyboard selection of + - Block groups in block list + - Template groups in template list + - Block groups and blocks in template editor + - Replace template tree view with searchable table (like blocks) ## [25.8.0] - 2025-10-26 ### Added diff --git a/ErsatzTV.Application/Scheduling/Commands/CopyTemplate.cs b/ErsatzTV.Application/Scheduling/Commands/CopyTemplate.cs new file mode 100644 index 000000000..9b971ba56 --- /dev/null +++ b/ErsatzTV.Application/Scheduling/Commands/CopyTemplate.cs @@ -0,0 +1,6 @@ +using ErsatzTV.Core; + +namespace ErsatzTV.Application.Scheduling; + +public record CopyTemplate(int TemplateId, int NewTemplateGroupId, string NewTemplateName) + : IRequest>; diff --git a/ErsatzTV.Application/Scheduling/Commands/CopyTemplateHandler.cs b/ErsatzTV.Application/Scheduling/Commands/CopyTemplateHandler.cs new file mode 100644 index 000000000..645266e0a --- /dev/null +++ b/ErsatzTV.Application/Scheduling/Commands/CopyTemplateHandler.cs @@ -0,0 +1,100 @@ +using ErsatzTV.Core; +using ErsatzTV.Core.Domain.Scheduling; +using ErsatzTV.Infrastructure.Data; +using ErsatzTV.Infrastructure.Extensions; +using Microsoft.EntityFrameworkCore; +using static ErsatzTV.Application.Scheduling.Mapper; + +namespace ErsatzTV.Application.Scheduling; + +public class CopyTemplateHandler(IDbContextFactory dbContextFactory) + : IRequestHandler> +{ + public async Task> Handle( + CopyTemplate request, + CancellationToken cancellationToken) + { + try + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + Validation validation = await Validate(dbContext, request, cancellationToken); + return await validation.Apply(p => PerformCopy(dbContext, p, request, cancellationToken)); + } + catch (Exception ex) + { + Console.WriteLine(ex.ToString()); + return BaseError.New(ex.Message); + } + } + + private static async Task PerformCopy( + TvContext dbContext, + Template template, + CopyTemplate request, + CancellationToken cancellationToken) + { + DetachEntity(dbContext, template); + template.Name = request.NewTemplateName; + template.TemplateGroup = null; + template.TemplateGroupId = request.NewTemplateGroupId; + + foreach (TemplateItem item in template.Items) + { + DetachEntity(dbContext, item); + item.TemplateId = 0; + item.Template = template; + } + + await dbContext.Templates.AddAsync(template, cancellationToken); + await dbContext.TemplateItems.AddRangeAsync(template.Items, cancellationToken); + + await dbContext.SaveChangesAsync(cancellationToken); + + await dbContext.Entry(template).Reference(b => b.TemplateGroup).LoadAsync(cancellationToken); + + return ProjectToViewModel(template); + } + + private static async Task> Validate( + TvContext dbContext, + CopyTemplate request, + CancellationToken cancellationToken) => + (await TemplateMustExist(dbContext, request, cancellationToken), await ValidateName(dbContext, request)) + .Apply((template, _) => template); + + private static Task> TemplateMustExist( + TvContext dbContext, + CopyTemplate request, + CancellationToken cancellationToken) => + dbContext.Templates + .AsNoTracking() + .Include(t => t.Items) + .SelectOneAsync(p => p.Id, p => p.Id == request.TemplateId, cancellationToken) + .Map(o => o.ToValidation("Template does not exist.")); + + private static async Task> ValidateName(TvContext dbContext, CopyTemplate request) + { + List allNames = await dbContext.Templates + .Where(b => b.TemplateGroupId == request.NewTemplateGroupId) + .Map(ps => ps.Name) + .ToListAsync(); + + Validation result1 = request.NotEmpty(c => c.NewTemplateName) + .Bind(_ => request.NotLongerThan(50)(c => c.NewTemplateName)); + + var result2 = Optional(request.NewTemplateName) + .Where(name => !allNames.Contains(name)) + .ToValidation("Template name must be unique within the template group."); + + return (result1, result2).Apply((_, _) => request.NewTemplateName); + } + + private static void DetachEntity(TvContext db, T entity) where T : class + { + db.Entry(entity).State = EntityState.Detached; + if (entity.GetType().GetProperty("Id") is not null) + { + entity.GetType().GetProperty("Id")!.SetValue(entity, 0); + } + } +} diff --git a/ErsatzTV.Application/Scheduling/Queries/GetAllTemplates.cs b/ErsatzTV.Application/Scheduling/Queries/GetAllTemplates.cs new file mode 100644 index 000000000..695cb40cd --- /dev/null +++ b/ErsatzTV.Application/Scheduling/Queries/GetAllTemplates.cs @@ -0,0 +1,3 @@ +namespace ErsatzTV.Application.Scheduling; + +public record GetAllTemplates : IRequest>; diff --git a/ErsatzTV.Application/Scheduling/Queries/GetAllTemplatesHandler.cs b/ErsatzTV.Application/Scheduling/Queries/GetAllTemplatesHandler.cs new file mode 100644 index 000000000..f7220761f --- /dev/null +++ b/ErsatzTV.Application/Scheduling/Queries/GetAllTemplatesHandler.cs @@ -0,0 +1,54 @@ +using ErsatzTV.Core.Domain.Scheduling; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.Scheduling; + +public class GetAllTemplatesHandler(IDbContextFactory dbContextFactory) + : IRequestHandler> +{ + public async Task> Handle(GetAllTemplates request, CancellationToken cancellationToken) + { + await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + List