diff --git a/CHANGELOG.md b/CHANGELOG.md index 571015387..0c61866bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### Added - Add warning during playout rebuild when schedule has been emptied +- Save Logs, Playout Detail, Schedule Detail table page sizes + +### Changed +- Show all log entries in log viewer, not just most recent 100 entries +- Use server-side paging and sorting for Logs table +- Use server-side paging for Playout Detail table +- Remove pager from Schedule Items editor (all schedule items will always be displayed) ### Fixed - Fix ui crash adding a channel without a watermark diff --git a/ErsatzTV.Application/Logs/PagedLogEntriesViewModel.cs b/ErsatzTV.Application/Logs/PagedLogEntriesViewModel.cs new file mode 100644 index 000000000..0bf8cb95d --- /dev/null +++ b/ErsatzTV.Application/Logs/PagedLogEntriesViewModel.cs @@ -0,0 +1,6 @@ +using System.Collections.Generic; + +namespace ErsatzTV.Application.Logs +{ + public record PagedLogEntriesViewModel(int TotalCount, List Page); +} diff --git a/ErsatzTV.Application/Logs/Queries/GetRecentLogEntries.cs b/ErsatzTV.Application/Logs/Queries/GetRecentLogEntries.cs index 0b92744d7..efd936b59 100644 --- a/ErsatzTV.Application/Logs/Queries/GetRecentLogEntries.cs +++ b/ErsatzTV.Application/Logs/Queries/GetRecentLogEntries.cs @@ -1,7 +1,14 @@ -using System.Collections.Generic; +using System; +using System.Linq.Expressions; +using ErsatzTV.Core.Domain; +using LanguageExt; using MediatR; namespace ErsatzTV.Application.Logs.Queries { - public record GetRecentLogEntries : IRequest>; + public record GetRecentLogEntries(int PageNum, int PageSize) : IRequest + { + public Expression> SortExpression { get; set; } + public Option SortDescending { get; set; } + } } diff --git a/ErsatzTV.Application/Logs/Queries/GetRecentLogEntriesHandler.cs b/ErsatzTV.Application/Logs/Queries/GetRecentLogEntriesHandler.cs index 6997a3de3..0df9f80da 100644 --- a/ErsatzTV.Application/Logs/Queries/GetRecentLogEntriesHandler.cs +++ b/ErsatzTV.Application/Logs/Queries/GetRecentLogEntriesHandler.cs @@ -2,20 +2,46 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using ErsatzTV.Core.Interfaces.Repositories; +using ErsatzTV.Core.Domain; +using ErsatzTV.Infrastructure.Data; using LanguageExt; using MediatR; +using Microsoft.EntityFrameworkCore; using static ErsatzTV.Application.Logs.Mapper; namespace ErsatzTV.Application.Logs.Queries { - public class GetRecentLogEntriesHandler : IRequestHandler> + public class GetRecentLogEntriesHandler : IRequestHandler { - private readonly ILogRepository _logRepository; + private readonly IDbContextFactory _dbContextFactory; - public GetRecentLogEntriesHandler(ILogRepository logRepository) => _logRepository = logRepository; + public GetRecentLogEntriesHandler(IDbContextFactory dbContextFactory) => + _dbContextFactory = dbContextFactory; - public Task> Handle(GetRecentLogEntries request, CancellationToken cancellationToken) => - _logRepository.GetRecentLogEntries().Map(list => list.Map(ProjectToViewModel).ToList()); + public async Task Handle( + GetRecentLogEntries request, + CancellationToken cancellationToken) + { + await using LogContext logContext = _dbContextFactory.CreateDbContext(); + int count = await logContext.LogEntries.CountAsync(cancellationToken); + + IOrderedQueryable ordered = logContext.LogEntries + .OrderByDescending(le => le.Id); + + foreach (bool descending in request.SortDescending) + { + ordered = descending + ? logContext.LogEntries.OrderByDescending(request.SortExpression).ThenByDescending(le => le.Id) + : logContext.LogEntries.OrderBy(request.SortExpression).ThenByDescending(le => le.Id); + } + + List page = await ordered + .Skip(request.PageNum * request.PageSize) + .Take(request.PageSize) + .ToListAsync(cancellationToken) + .Map(list => list.Map(ProjectToViewModel).ToList()); + + return new PagedLogEntriesViewModel(count, page); + } } } diff --git a/ErsatzTV.Application/Playouts/PagedPlayoutItemsViewModel.cs b/ErsatzTV.Application/Playouts/PagedPlayoutItemsViewModel.cs new file mode 100644 index 000000000..11260308f --- /dev/null +++ b/ErsatzTV.Application/Playouts/PagedPlayoutItemsViewModel.cs @@ -0,0 +1,6 @@ +using System.Collections.Generic; + +namespace ErsatzTV.Application.Playouts +{ + public record PagedPlayoutItemsViewModel(int TotalCount, List Page); +} diff --git a/ErsatzTV.Application/Playouts/Queries/GetPlayoutItemsById.cs b/ErsatzTV.Application/Playouts/Queries/GetPlayoutItemsById.cs index 169f655b0..832870431 100644 --- a/ErsatzTV.Application/Playouts/Queries/GetPlayoutItemsById.cs +++ b/ErsatzTV.Application/Playouts/Queries/GetPlayoutItemsById.cs @@ -3,5 +3,5 @@ using MediatR; namespace ErsatzTV.Application.Playouts.Queries { - public record GetPlayoutItemsById(int PlayoutId) : IRequest>; + public record GetPlayoutItemsById(int PlayoutId, int PageNum, int PageSize) : IRequest; } diff --git a/ErsatzTV.Application/Playouts/Queries/GetPlayoutItemsByIdHandler.cs b/ErsatzTV.Application/Playouts/Queries/GetPlayoutItemsByIdHandler.cs index e3c629624..84e35860d 100644 --- a/ErsatzTV.Application/Playouts/Queries/GetPlayoutItemsByIdHandler.cs +++ b/ErsatzTV.Application/Playouts/Queries/GetPlayoutItemsByIdHandler.cs @@ -11,27 +11,29 @@ using static ErsatzTV.Application.Playouts.Mapper; namespace ErsatzTV.Application.Playouts.Queries { - public class GetPlayoutItemsByIdHandler : IRequestHandler> + public class GetPlayoutItemsByIdHandler : IRequestHandler { private readonly IDbContextFactory _dbContextFactory; public GetPlayoutItemsByIdHandler(IDbContextFactory dbContextFactory) => _dbContextFactory = dbContextFactory; - public async Task> Handle( + public async Task Handle( GetPlayoutItemsById request, CancellationToken cancellationToken) { await using TvContext dbContext = _dbContextFactory.CreateDbContext(); - return await dbContext.PlayoutItems + + int totalCount = await dbContext.PlayoutItems + .CountAsync(i => i.PlayoutId == request.PlayoutId, cancellationToken); + + List page = await dbContext.PlayoutItems .Include(i => i.MediaItem) .ThenInclude(mi => (mi as Movie).MovieMetadata) - .ThenInclude(mm => mm.Artwork) .Include(i => i.MediaItem) .ThenInclude(mi => (mi as Movie).MediaVersions) .Include(i => i.MediaItem) .ThenInclude(mi => (mi as MusicVideo).MusicVideoMetadata) - .ThenInclude(mm => mm.Artwork) .Include(i => i.MediaItem) .ThenInclude(mi => (mi as MusicVideo).MediaVersions) .Include(i => i.MediaItem) @@ -39,7 +41,6 @@ namespace ErsatzTV.Application.Playouts.Queries .ThenInclude(mm => mm.ArtistMetadata) .Include(i => i.MediaItem) .ThenInclude(mi => (mi as Episode).EpisodeMetadata) - .ThenInclude(em => em.Artwork) .Include(i => i.MediaItem) .ThenInclude(mi => (mi as Episode).MediaVersions) .Include(i => i.MediaItem) @@ -49,8 +50,13 @@ namespace ErsatzTV.Application.Playouts.Queries .ThenInclude(mi => (mi as Episode).Season.Show) .ThenInclude(s => s.ShowMetadata) .Filter(i => i.PlayoutId == request.PlayoutId) + .OrderBy(i => i.Start) + .Skip(request.PageNum * request.PageSize) + .Take(request.PageSize) .ToListAsync(cancellationToken) .Map(list => list.Map(ProjectToViewModel).ToList()); + + return new PagedPlayoutItemsViewModel(totalCount, page); } } } diff --git a/ErsatzTV.Core/Domain/ConfigElementKey.cs b/ErsatzTV.Core/Domain/ConfigElementKey.cs index 32ee21dcc..1d42bc47f 100644 --- a/ErsatzTV.Core/Domain/ConfigElementKey.cs +++ b/ErsatzTV.Core/Domain/ConfigElementKey.cs @@ -18,7 +18,10 @@ public static ConfigElementKey ChannelsPageSize => new("pages.channels.page_size"); public static ConfigElementKey CollectionsPageSize => new("pages.collections.page_size"); public static ConfigElementKey SchedulesPageSize => new("pages.schedules.page_size"); + public static ConfigElementKey SchedulesDetailPageSize => new("pages.schedules.detail_page_size"); public static ConfigElementKey PlayoutsPageSize => new("pages.playouts.page_size"); + public static ConfigElementKey PlayoutsDetailPageSize => new("pages.playouts.detail_page_size"); + public static ConfigElementKey LogsPageSize => new("pages.logs.page_size"); public static ConfigElementKey LibraryRefreshInterval => new("scanner.library_refresh_interval"); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/ILogRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/ILogRepository.cs deleted file mode 100644 index a285f45f3..000000000 --- a/ErsatzTV.Core/Interfaces/Repositories/ILogRepository.cs +++ /dev/null @@ -1,11 +0,0 @@ -using System.Collections.Generic; -using System.Threading.Tasks; -using ErsatzTV.Core.Domain; - -namespace ErsatzTV.Core.Interfaces.Repositories -{ - public interface ILogRepository - { - Task> GetRecentLogEntries(); - } -} diff --git a/ErsatzTV.Infrastructure/Data/Repositories/LogRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/LogRepository.cs deleted file mode 100644 index 7d4864ce1..000000000 --- a/ErsatzTV.Infrastructure/Data/Repositories/LogRepository.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using ErsatzTV.Core.Domain; -using ErsatzTV.Core.Interfaces.Repositories; -using Microsoft.EntityFrameworkCore; - -namespace ErsatzTV.Infrastructure.Data.Repositories -{ - public class LogRepository : ILogRepository - { - private readonly LogContext _logContext; - - public LogRepository(LogContext logContext) => _logContext = logContext; - - public Task> GetRecentLogEntries() => - _logContext.LogEntries.OrderByDescending(e => e.Id).Take(100).ToListAsync(); - } -} diff --git a/ErsatzTV/Pages/Logs.razor b/ErsatzTV/Pages/Logs.razor index 932ef13cb..d1507ccc6 100644 --- a/ErsatzTV/Pages/Logs.razor +++ b/ErsatzTV/Pages/Logs.razor @@ -1,18 +1,24 @@ @page "/system/logs" @using ErsatzTV.Application.Logs @using ErsatzTV.Application.Logs.Queries +@using ErsatzTV.Application.Configuration.Queries +@using ErsatzTV.Application.Configuration.Commands @inject IMediator _mediator - + - + Timestamp - + Level @@ -30,8 +36,47 @@ @code { - private List _logEntries; + private MudTable _table; + private int _rowsPerPage; - protected override async Task OnInitializedAsync() => _logEntries = await _mediator.Send(new GetRecentLogEntries()); + protected override async Task OnParametersSetAsync() => _rowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.LogsPageSize)) + .Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10)); + + private async Task> ServerReload(TableState state) + { + await _mediator.Send(new SaveConfigElementByKey(ConfigElementKey.LogsPageSize, state.PageSize.ToString())); + + PagedLogEntriesViewModel data; + + switch (state.SortLabel?.ToLowerInvariant()) + { + case "timestamp": + data = await _mediator.Send(new GetRecentLogEntries(state.Page, state.PageSize) + { + SortExpression = le => le.Timestamp, + SortDescending = state.SortDirection == SortDirection.None + ? Option.None + : state.SortDirection == SortDirection.Descending + }); + break; + case "level": + data = await _mediator.Send(new GetRecentLogEntries(state.Page, state.PageSize) + { + SortExpression = le => le.Level, + SortDescending = state.SortDirection == SortDirection.None + ? Option.None + : state.SortDirection == SortDirection.Descending + }); + break; + default: + data = await _mediator.Send(new GetRecentLogEntries(state.Page, state.PageSize) + { + SortDescending = Option.None + }); + break; + } + + return new TableData { TotalItems = data.TotalCount, Items = data.Page }; + } } \ No newline at end of file diff --git a/ErsatzTV/Pages/Playouts.razor b/ErsatzTV/Pages/Playouts.razor index 3a2ccabed..7c186df41 100644 --- a/ErsatzTV/Pages/Playouts.razor +++ b/ErsatzTV/Pages/Playouts.razor @@ -63,9 +63,14 @@ Add Playout - @if (_selectedPlayoutItems != null) + @if (_selectedPlayoutId != null) { - + Playout Detail @@ -88,20 +93,23 @@ @code { private MudTable _table; + private MudTable _detailTable; private int _rowsPerPage; - private List _selectedPlayoutItems; + private int _detailRowsPerPage; private int? _selectedPlayoutId; protected override async Task OnParametersSetAsync() { _rowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.PlayoutsPageSize)) .Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10)); + _detailRowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.PlayoutsDetailPageSize)) + .Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10)); } private async Task PlayoutSelected(PlayoutNameViewModel playout) { _selectedPlayoutId = playout.PlayoutId; - _selectedPlayoutItems = await _mediator.Send(new GetPlayoutItemsById(playout.PlayoutId)); + await _detailTable.ReloadServerData(); } private async Task DeletePlayout(PlayoutNameViewModel playout) @@ -117,7 +125,7 @@ await _table.ReloadServerData(); if (_selectedPlayoutId == playout.PlayoutId) { - _selectedPlayoutItems = null; + _selectedPlayoutId = null; } } } @@ -147,4 +155,22 @@ }; } + private async Task> DetailServerReload(TableState state) + { + await _mediator.Send(new SaveConfigElementByKey(ConfigElementKey.PlayoutsDetailPageSize, state.PageSize.ToString())); + + if (_selectedPlayoutId.HasValue) + { + PagedPlayoutItemsViewModel data = + await _mediator.Send(new GetPlayoutItemsById(_selectedPlayoutId.Value, state.Page, state.PageSize)); + return new TableData + { + TotalItems = data.TotalCount, + Items = data.Page + }; + } + + return new TableData { TotalItems = 0 }; + } + } \ No newline at end of file diff --git a/ErsatzTV/Pages/ScheduleItemsEditor.razor b/ErsatzTV/Pages/ScheduleItemsEditor.razor index ee63d70aa..7f2ee67a1 100644 --- a/ErsatzTV/Pages/ScheduleItemsEditor.razor +++ b/ErsatzTV/Pages/ScheduleItemsEditor.razor @@ -71,9 +71,6 @@ - - - Add Schedule Item diff --git a/ErsatzTV/Pages/Schedules.razor b/ErsatzTV/Pages/Schedules.razor index c3132bf1f..3fed54b6d 100644 --- a/ErsatzTV/Pages/Schedules.razor +++ b/ErsatzTV/Pages/Schedules.razor @@ -62,9 +62,13 @@ Add Schedule - @if (_selectedScheduleItems != null) + @if (_selectedSchedule != null) { - + @_selectedSchedule.Name Items @@ -89,21 +93,23 @@ @code { private MudTable _table; + private MudTable _detailTable; private int _rowsPerPage; - private List _selectedScheduleItems; + private int _detailRowsPerPage; private ProgramScheduleViewModel _selectedSchedule; protected override async Task OnParametersSetAsync() { _rowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.SchedulesPageSize)) .Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10)); + _detailRowsPerPage = await _mediator.Send(new GetConfigElementByKey(ConfigElementKey.SchedulesDetailPageSize)) + .Map(maybeRows => maybeRows.Match(ce => int.TryParse(ce.Value, out int rows) ? rows : 10, () => 10)); } private async Task ScheduleSelected(ProgramScheduleViewModel schedule) { _selectedSchedule = schedule; - await _mediator.Send(new GetProgramScheduleItems(schedule.Id)) - .Map(results => _selectedScheduleItems = results.OrderBy(x => x.Name).ToList()); + await _detailTable.ReloadServerData(); } private async Task DeleteSchedule(ProgramScheduleViewModel programSchedule) @@ -117,6 +123,10 @@ { await _mediator.Send(new DeleteProgramSchedule(programSchedule.Id)); await _table.ReloadServerData(); + if (_selectedSchedule == programSchedule) + { + _selectedSchedule = null; + } } } @@ -135,4 +145,19 @@ }; } + private async Task> DetailServerReload(TableState state) + { + await _mediator.Send(new SaveConfigElementByKey(ConfigElementKey.SchedulesDetailPageSize, state.PageSize.ToString())); + + List scheduleItems = await _mediator.Send(new GetProgramScheduleItems(_selectedSchedule.Id)); + IOrderedEnumerable sorted = scheduleItems.OrderBy(s => s.Index); + + // TODO: properly page this data + return new TableData + { + TotalItems = scheduleItems.Count, + Items = sorted.Skip(state.Page * state.PageSize).Take(state.PageSize) + }; + } + } \ No newline at end of file diff --git a/ErsatzTV/Startup.cs b/ErsatzTV/Startup.cs index 88ca90d7c..01ab6af53 100644 --- a/ErsatzTV/Startup.cs +++ b/ErsatzTV/Startup.cs @@ -7,6 +7,7 @@ using Blazored.LocalStorage; using Dapper; using ErsatzTV.Application; using ErsatzTV.Application.Channels.Queries; +using ErsatzTV.Application.Logs.Queries; using ErsatzTV.Core; using ErsatzTV.Core.Emby; using ErsatzTV.Core.FFmpeg; @@ -144,14 +145,21 @@ namespace ErsatzTV o.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery); o.MigrationsAssembly("ErsatzTV.Infrastructure"); })); - + services.AddTransient(_ => new SqliteConnection(connectionString)); SqlMapper.AddTypeHandler(new DateTimeOffsetHandler()); SqlMapper.AddTypeHandler(new GuidHandler()); SqlMapper.AddTypeHandler(new TimeSpanHandler()); + var logConnectionString = $"Data Source={FileSystemLayout.LogDatabasePath}"; + services.AddDbContext( - options => options.UseSqlite($"Data Source={FileSystemLayout.LogDatabasePath}")); + options => options.UseSqlite(logConnectionString), + ServiceLifetime.Scoped, + ServiceLifetime.Singleton); + + services.AddDbContextFactory( + options => options.UseSqlite(logConnectionString)); services.AddMediatR(typeof(GetAllChannels).Assembly); @@ -196,7 +204,6 @@ namespace ErsatzTV services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -244,6 +251,8 @@ namespace ErsatzTV services.AddScoped(); services.AddScoped(); + // services.AddTransient(typeof(IRequestHandler<,>), typeof(GetRecentLogEntriesHandler<>)); + services.AddHostedService(); services.AddHostedService(); services.AddHostedService();