diff --git a/CHANGELOG.md b/CHANGELOG.md index 5342d8f8..1ae62896 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Remove duplicate items from smart collections before scheduling - i.e. shows no longer need to be filtered out if search results also include episodes - Certain multi-collection scenarios may still include duplicates across multiple collections +- Use autocomplete fields for collection searching in schedule items editor + - This greatly improves the editor performance ## [0.7.7-beta] - 2023-04-07 ### Added diff --git a/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsHandler.cs b/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsHandler.cs index 74529ed8..e2cedfc1 100644 --- a/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsHandler.cs +++ b/ErsatzTV.Application/Filler/Queries/GetAllFillerPresetsHandler.cs @@ -15,7 +15,7 @@ public class GetAllFillerPresetsHandler : IRequestHandler presets.Map(ProjectToViewModel).ToList()); } diff --git a/ErsatzTV.Application/MediaCollections/Queries/GetAllCollectionsHandler.cs b/ErsatzTV.Application/MediaCollections/Queries/GetAllCollectionsHandler.cs index aa19a2cc..0b9b80cc 100644 --- a/ErsatzTV.Application/MediaCollections/Queries/GetAllCollectionsHandler.cs +++ b/ErsatzTV.Application/MediaCollections/Queries/GetAllCollectionsHandler.cs @@ -15,7 +15,7 @@ public class GetAllCollectionsHandler : IRequestHandler list.Map(ProjectToViewModel).ToList()); diff --git a/ErsatzTV.Application/MediaCollections/Queries/GetAllMultiCollectionsHandler.cs b/ErsatzTV.Application/MediaCollections/Queries/GetAllMultiCollectionsHandler.cs index 2bf10f13..b848216d 100644 --- a/ErsatzTV.Application/MediaCollections/Queries/GetAllMultiCollectionsHandler.cs +++ b/ErsatzTV.Application/MediaCollections/Queries/GetAllMultiCollectionsHandler.cs @@ -15,7 +15,7 @@ public class GetAllMultiCollectionsHandler : IRequestHandler list.Map(ProjectToViewModel).ToList()); diff --git a/ErsatzTV.Application/MediaCollections/Queries/GetAllSmartCollectionsHandler.cs b/ErsatzTV.Application/MediaCollections/Queries/GetAllSmartCollectionsHandler.cs index 7872d1da..b5d4eb25 100644 --- a/ErsatzTV.Application/MediaCollections/Queries/GetAllSmartCollectionsHandler.cs +++ b/ErsatzTV.Application/MediaCollections/Queries/GetAllSmartCollectionsHandler.cs @@ -15,7 +15,7 @@ public class GetAllSmartCollectionsHandler : IRequestHandler list.Map(ProjectToViewModel).ToList()); diff --git a/ErsatzTV.Application/Search/Queries/SearchArtists.cs b/ErsatzTV.Application/Search/Queries/SearchArtists.cs new file mode 100644 index 00000000..0ff285c7 --- /dev/null +++ b/ErsatzTV.Application/Search/Queries/SearchArtists.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Application.MediaItems; + +namespace ErsatzTV.Application.Search; + +public record SearchArtists(string Query) : IRequest>; diff --git a/ErsatzTV.Application/Search/Queries/SearchArtistsHandler.cs b/ErsatzTV.Application/Search/Queries/SearchArtistsHandler.cs new file mode 100644 index 00000000..45042e80 --- /dev/null +++ b/ErsatzTV.Application/Search/Queries/SearchArtistsHandler.cs @@ -0,0 +1,45 @@ +using Dapper; +using ErsatzTV.Application.MediaItems; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.Search; + +public class SearchArtistsHandler : IRequestHandler> +{ + private readonly IDbContextFactory _dbContextFactory; + + public SearchArtistsHandler(IDbContextFactory dbContextFactory) => + _dbContextFactory = dbContextFactory; + + public async Task> Handle(SearchArtists request, CancellationToken cancellationToken) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + return await dbContext.Connection.QueryAsync( + @"SELECT Artist.Id, AM.Title FROM Artist + INNER JOIN ArtistMetadata AM on AM.ArtistId = Artist.Id + WHERE AM.Title LIKE @Title + ORDER BY AM.Title + LIMIT 10 + COLLATE NOCASE", + new { Title = $"%{request.Query}%" }) + .Map(list => list.Bind(a => ToNamedMediaItem(a)).ToList()); + } + + private static Option ToNamedMediaItem(Artist artist) + { + if (string.IsNullOrWhiteSpace(artist.Title)) + { + return Option.None; + } + + return new NamedMediaItemViewModel(artist.Id, artist.Title); + } + + public record Artist(int Id, string Title) + { + public Artist() : this(default, default) + { + } + } +} diff --git a/ErsatzTV.Application/Search/Queries/SearchCollections.cs b/ErsatzTV.Application/Search/Queries/SearchCollections.cs new file mode 100644 index 00000000..7f1b338a --- /dev/null +++ b/ErsatzTV.Application/Search/Queries/SearchCollections.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Application.MediaCollections; + +namespace ErsatzTV.Application.Search; + +public record SearchCollections(string Query) : IRequest>; diff --git a/ErsatzTV.Application/Search/Queries/SearchCollectionsHandler.cs b/ErsatzTV.Application/Search/Queries/SearchCollectionsHandler.cs new file mode 100644 index 00000000..d34888b5 --- /dev/null +++ b/ErsatzTV.Application/Search/Queries/SearchCollectionsHandler.cs @@ -0,0 +1,29 @@ +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using static ErsatzTV.Application.MediaCollections.Mapper; + +namespace ErsatzTV.Application.Search; + +public class SearchCollectionsHandler : IRequestHandler> +{ + private readonly IDbContextFactory _dbContextFactory; + + public SearchCollectionsHandler(IDbContextFactory dbContextFactory) => + _dbContextFactory = dbContextFactory; + + public async Task> Handle(SearchCollections request, CancellationToken cancellationToken) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + return await dbContext.Collections.FromSqlRaw( + @"SELECT * FROM Collection + WHERE Name LIKE {0} + ORDER BY Name + LIMIT 10 + COLLATE NOCASE", + $"%{request.Query}%") + .AsNoTracking() + .ToListAsync(cancellationToken) + .Map(list => list.Map(ProjectToViewModel).ToList()); + } +} diff --git a/ErsatzTV.Application/Search/Queries/SearchMultiCollections.cs b/ErsatzTV.Application/Search/Queries/SearchMultiCollections.cs new file mode 100644 index 00000000..05ab7bf3 --- /dev/null +++ b/ErsatzTV.Application/Search/Queries/SearchMultiCollections.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Application.MediaCollections; + +namespace ErsatzTV.Application.Search; + +public record SearchMultiCollections(string Query) : IRequest>; diff --git a/ErsatzTV.Application/Search/Queries/SearchMultiCollectionsHandler.cs b/ErsatzTV.Application/Search/Queries/SearchMultiCollectionsHandler.cs new file mode 100644 index 00000000..af900339 --- /dev/null +++ b/ErsatzTV.Application/Search/Queries/SearchMultiCollectionsHandler.cs @@ -0,0 +1,29 @@ +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using static ErsatzTV.Application.MediaCollections.Mapper; + +namespace ErsatzTV.Application.Search; + +public class SearchMultiCollectionsHandler : IRequestHandler> +{ + private readonly IDbContextFactory _dbContextFactory; + + public SearchMultiCollectionsHandler(IDbContextFactory dbContextFactory) => + _dbContextFactory = dbContextFactory; + + public async Task> Handle(SearchMultiCollections request, CancellationToken cancellationToken) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + return await dbContext.MultiCollections.FromSqlRaw( + @"SELECT * FROM MultiCollection + WHERE Name LIKE {0} + ORDER BY Name + LIMIT 10 + COLLATE NOCASE", + $"%{request.Query}%") + .AsNoTracking() + .ToListAsync(cancellationToken) + .Map(list => list.Map(ProjectToViewModel).ToList()); + } +} diff --git a/ErsatzTV.Application/Search/Queries/SearchSmartCollections.cs b/ErsatzTV.Application/Search/Queries/SearchSmartCollections.cs new file mode 100644 index 00000000..011b0cee --- /dev/null +++ b/ErsatzTV.Application/Search/Queries/SearchSmartCollections.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Application.MediaCollections; + +namespace ErsatzTV.Application.Search; + +public record SearchSmartCollections(string Query) : IRequest>; diff --git a/ErsatzTV.Application/Search/Queries/SearchSmartCollectionsHandler.cs b/ErsatzTV.Application/Search/Queries/SearchSmartCollectionsHandler.cs new file mode 100644 index 00000000..bdc6c322 --- /dev/null +++ b/ErsatzTV.Application/Search/Queries/SearchSmartCollectionsHandler.cs @@ -0,0 +1,29 @@ +using ErsatzTV.Application.MediaCollections; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; +using static ErsatzTV.Application.MediaCollections.Mapper; + +namespace ErsatzTV.Application.Search; + +public class SearchSmartCollectionsHandler : IRequestHandler> +{ + private readonly IDbContextFactory _dbContextFactory; + + public SearchSmartCollectionsHandler(IDbContextFactory dbContextFactory) => + _dbContextFactory = dbContextFactory; + + public async Task> Handle(SearchSmartCollections request, CancellationToken cancellationToken) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + return await dbContext.SmartCollections.FromSqlRaw( + @"SELECT * FROM SmartCollection + WHERE Name LIKE {0} + ORDER BY Name + LIMIT 10 + COLLATE NOCASE", + $"%{request.Query}%") + .AsNoTracking() + .ToListAsync(cancellationToken) + .Map(list => list.Map(ProjectToViewModel).ToList()); + } +} diff --git a/ErsatzTV.Application/Search/Queries/SearchTelevisionSeasons.cs b/ErsatzTV.Application/Search/Queries/SearchTelevisionSeasons.cs new file mode 100644 index 00000000..8e70ff83 --- /dev/null +++ b/ErsatzTV.Application/Search/Queries/SearchTelevisionSeasons.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Application.MediaItems; + +namespace ErsatzTV.Application.Search; + +public record SearchTelevisionSeasons(string Query) : IRequest>; diff --git a/ErsatzTV.Application/Search/Queries/SearchTelevisionSeasonsHandler.cs b/ErsatzTV.Application/Search/Queries/SearchTelevisionSeasonsHandler.cs new file mode 100644 index 00000000..e9289460 --- /dev/null +++ b/ErsatzTV.Application/Search/Queries/SearchTelevisionSeasonsHandler.cs @@ -0,0 +1,46 @@ +using Dapper; +using ErsatzTV.Application.MediaItems; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.Search; + +public class SearchTelevisionSeasonsHandler : IRequestHandler> +{ + private readonly IDbContextFactory _dbContextFactory; + + public SearchTelevisionSeasonsHandler(IDbContextFactory dbContextFactory) => + _dbContextFactory = dbContextFactory; + + public async Task> Handle(SearchTelevisionSeasons request, CancellationToken cancellationToken) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + return await dbContext.Connection.QueryAsync( + @"SELECT Season.Id, SM2.Title, Season.SeasonNumber FROM Season + INNER JOIN SeasonMetadata SM on Season.Id = SM.SeasonId + INNER JOIN ShowMetadata SM2 on SM2.ShowId = Season.ShowId + WHERE (SM2.Title || ' ' || SM.Title) LIKE @Title + ORDER BY SM2.Title, Season.SeasonNumber + LIMIT 20 + COLLATE NOCASE", + new { Title = $"%{request.Query}%" }) + .Map(list => list.Map(ToNamedMediaItem).ToList()); + } + + private static NamedMediaItemViewModel ToNamedMediaItem(TelevisionSeason season) => new( + season.Id, + $"{ShowTitle(season)} ({SeasonTitle(season)})"); + + private static string ShowTitle(TelevisionSeason season) => $"{season.Title ?? "???"}"; + + private static string SeasonTitle(TelevisionSeason season) => season.SeasonNumber == 0 + ? "Specials" + : $"Season {season.SeasonNumber}"; + + public record TelevisionSeason(int Id, string Title, int SeasonNumber) + { + public TelevisionSeason() : this(default, default, default) + { + } + } +} diff --git a/ErsatzTV.Application/Search/Queries/SearchTelevisionShows.cs b/ErsatzTV.Application/Search/Queries/SearchTelevisionShows.cs new file mode 100644 index 00000000..1c2ea411 --- /dev/null +++ b/ErsatzTV.Application/Search/Queries/SearchTelevisionShows.cs @@ -0,0 +1,5 @@ +using ErsatzTV.Application.MediaItems; + +namespace ErsatzTV.Application.Search; + +public record SearchTelevisionShows(string Query) : IRequest>; diff --git a/ErsatzTV.Application/Search/Queries/SearchTelevisionShowsHandler.cs b/ErsatzTV.Application/Search/Queries/SearchTelevisionShowsHandler.cs new file mode 100644 index 00000000..079ac7a5 --- /dev/null +++ b/ErsatzTV.Application/Search/Queries/SearchTelevisionShowsHandler.cs @@ -0,0 +1,39 @@ +using Dapper; +using ErsatzTV.Application.MediaItems; +using ErsatzTV.Infrastructure.Data; +using Microsoft.EntityFrameworkCore; + +namespace ErsatzTV.Application.Search; + +public class SearchTelevisionShowsHandler : IRequestHandler> +{ + private readonly IDbContextFactory _dbContextFactory; + + public SearchTelevisionShowsHandler(IDbContextFactory dbContextFactory) => + _dbContextFactory = dbContextFactory; + + public async Task> Handle(SearchTelevisionShows request, CancellationToken cancellationToken) + { + await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + return await dbContext.Connection.QueryAsync( + @"SELECT Show.Id, SM.Title, SM.Year FROM Show + INNER JOIN ShowMetadata SM on SM.ShowId = Show.Id + WHERE (SM.Title || ' ' || SM.Year) LIKE @Title + ORDER BY SM.Title, SM.Year + LIMIT 10 + COLLATE NOCASE", + new { Title = $"%{request.Query}%" }) + .Map(list => list.Map(ToNamedMediaItem).ToList()); + } + + private static NamedMediaItemViewModel ToNamedMediaItem(TelevisionShow show) => new( + show.Id, + $"{show.Title} ({(show.Year.HasValue ? show.Year.Value.ToString() : "???")})"); + + public record TelevisionShow(int Id, string Title, int? Year) + { + public TelevisionShow() : this(default, default, default) + { + } + } +} diff --git a/ErsatzTV/Pages/ScheduleItemsEditor.razor b/ErsatzTV/Pages/ScheduleItemsEditor.razor index 4b039f54..83ff23cc 100644 --- a/ErsatzTV/Pages/ScheduleItemsEditor.razor +++ b/ErsatzTV/Pages/ScheduleItemsEditor.razor @@ -2,17 +2,16 @@ @using ErsatzTV.Application.MediaCollections @using ErsatzTV.Application.MediaItems @using ErsatzTV.Application.ProgramSchedules -@using ErsatzTV.Application.Television @using ErsatzTV.Application.Watermarks @using ErsatzTV.Application.Filler @using System.Globalization @using ErsatzTV.Core.Domain.Filler -@using ErsatzTV.Application.Artists +@using ErsatzTV.Application.Search @implements IDisposable -@inject NavigationManager _navigationManager -@inject ILogger _logger -@inject ISnackbar _snackbar -@inject IMediator _mediator +@inject NavigationManager NavigationManager +@inject ILogger Logger +@inject ISnackbar Snackbar +@inject IMediator Mediator @@ -104,82 +103,86 @@ - @foreach (ProgramScheduleItemCollectionType collectionType in Enum.GetValues()) - { - @collectionType - } + Collection + Television Show + Television Season + Artist + Multi Collection + Smart Collection @if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.Collection) { - - @foreach (MediaCollectionViewModel collection in _mediaCollections) - { - @collection.Name - } - + + + + Only the first 10 items are shown + + + } @if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.MultiCollection) { - - @foreach (MultiCollectionViewModel collection in _multiCollections) - { - @collection.Name - } - + + + + Only the first 10 items are shown + + + } @if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.SmartCollection) { - - @foreach (SmartCollectionViewModel collection in _smartCollections) - { - @collection.Name - } - + + + + Only the first 10 items are shown + + + } @if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.TelevisionShow) { - - @foreach (NamedMediaItemViewModel show in _televisionShows) - { - @show.Name - } - + + + + Only the first 10 items are shown + + + } @if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.TelevisionSeason) { - - @foreach (NamedMediaItemViewModel season in _televisionSeasons) - { - @season.Name - } - + + + + Only the first 20 items are shown + + + } @if (_selectedItem.CollectionType == ProgramScheduleItemCollectionType.Artist) { - - @foreach (NamedMediaItemViewModel artist in _artists) - { - @artist.Name - } - + + + + Only the first 10 items are shown + + + } @switch (_selectedItem.CollectionType) @@ -338,12 +341,6 @@ public int Id { get; set; } private ProgramScheduleItemsEditViewModel _schedule; - private List _mediaCollections; - private List _multiCollections; - private List _smartCollections; - private List _televisionShows; - private List _televisionSeasons; - private List _artists; private List _fillerPresets; private List _watermarks; private List _availableCultures; @@ -361,34 +358,23 @@ private async Task LoadScheduleItems() { // TODO: fix performance - _mediaCollections = await _mediator.Send(new GetAllCollections(), _cts.Token) - .Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList()); - _multiCollections = await _mediator.Send(new GetAllMultiCollections(), _cts.Token) - .Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList()); - _smartCollections = await _mediator.Send(new GetAllSmartCollections(), _cts.Token) - .Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList()); - _televisionShows = await _mediator.Send(new GetAllTelevisionShows(), _cts.Token) + _fillerPresets = await Mediator.Send(new GetAllFillerPresets(), _cts.Token) .Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList()); - _televisionSeasons = await _mediator.Send(new GetAllTelevisionSeasons(), _cts.Token) + _watermarks = await Mediator.Send(new GetAllWatermarks(), _cts.Token) .Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList()); - _artists = await _mediator.Send(new GetAllArtists(), _cts.Token) - .Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList()); - _fillerPresets = await _mediator.Send(new GetAllFillerPresets(), _cts.Token) - .Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList()); - _watermarks = await _mediator.Send(new GetAllWatermarks(), _cts.Token) - .Map(list => list.OrderBy(vm => vm.Name, StringComparer.CurrentCultureIgnoreCase).ToList()); - _availableCultures = await _mediator.Send(new GetAllLanguageCodes(), _cts.Token); + _availableCultures = await Mediator.Send(new GetAllLanguageCodes(), _cts.Token); string name = string.Empty; var shuffleScheduleItems = false; - Option maybeSchedule = await _mediator.Send(new GetProgramScheduleById(Id), _cts.Token); + Option maybeSchedule = await Mediator.Send(new GetProgramScheduleById(Id), _cts.Token); foreach (ProgramScheduleViewModel schedule in maybeSchedule) { name = schedule.Name; shuffleScheduleItems = schedule.ShuffleScheduleItems; } - Option> maybeResults = await _mediator.Send(new GetProgramScheduleItems(Id), _cts.Token); + Option> maybeResults = + await Mediator.Send(new GetProgramScheduleItems(Id), _cts.Token); foreach (IEnumerable items in maybeResults) { _schedule = new ProgramScheduleItemsEditViewModel @@ -404,6 +390,66 @@ } } } + + private async Task> SearchCollections(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return new List(); + } + + return await Mediator.Send(new SearchCollections(value), _cts.Token); + } + + private async Task> SearchMultiCollections(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return new List(); + } + + return await Mediator.Send(new SearchMultiCollections(value), _cts.Token); + } + + private async Task> SearchSmartCollections(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return new List(); + } + + return await Mediator.Send(new SearchSmartCollections(value), _cts.Token); + } + + private async Task> SearchTelevisionShows(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return new List(); + } + + return await Mediator.Send(new SearchTelevisionShows(value), _cts.Token); + } + + private async Task> SearchTelevisionSeasons(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return new List(); + } + + return await Mediator.Send(new SearchTelevisionSeasons(value), _cts.Token); + } + + private async Task> SearchArtists(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return new List(); + } + + return await Mediator.Send(new SearchArtists(value), _cts.Token); + } private ProgramScheduleItemEditViewModel ProjectToEditViewModel(ProgramScheduleItemViewModel item) { @@ -512,15 +558,15 @@ item.PreferredSubtitleLanguageCode, item.SubtitleMode)).ToList(); - Seq errorMessages = await _mediator.Send(new ReplaceProgramScheduleItems(Id, items), _cts.Token).Map(e => e.LeftToSeq()); + Seq errorMessages = await Mediator.Send(new ReplaceProgramScheduleItems(Id, items), _cts.Token).Map(e => e.LeftToSeq()); errorMessages.HeadOrNone().Match( error => { - _snackbar.Add($"Unexpected error saving schedule: {error.Value}", Severity.Error); - _logger.LogError("Unexpected error saving schedule: {Error}", error.Value); + Snackbar.Add($"Unexpected error saving schedule: {error.Value}", Severity.Error); + Logger.LogError("Unexpected error saving schedule: {Error}", error.Value); }, - () => _navigationManager.NavigateTo("/schedules")); + () => NavigationManager.NavigateTo("/schedules")); } } \ No newline at end of file