From c6ea2c88df7c42acdcac9c1b93937caa52e1f29b Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Wed, 3 Mar 2021 21:22:15 -0600 Subject: [PATCH 1/3] remember selected collection (#36) --- ErsatzTV/Shared/AddToCollectionDialog.razor | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/ErsatzTV/Shared/AddToCollectionDialog.razor b/ErsatzTV/Shared/AddToCollectionDialog.razor index 042fea0f3..bd780a395 100644 --- a/ErsatzTV/Shared/AddToCollectionDialog.razor +++ b/ErsatzTV/Shared/AddToCollectionDialog.razor @@ -1,6 +1,8 @@ -@using ErsatzTV.Application.MediaCollections +@using Microsoft.Extensions.Caching.Memory +@using ErsatzTV.Application.MediaCollections @using ErsatzTV.Application.MediaCollections.Queries @inject IMediator Mediator +@inject IMemoryCache MemoryCache @@ -46,12 +48,22 @@ private MediaCollectionViewModel _selectedCollection; - protected override async Task OnParametersSetAsync() => + protected override async Task OnParametersSetAsync() + { _collections = await Mediator.Send(new GetAllCollections()); + if (MemoryCache.TryGetValue("AddToCollectionDialog.SelectedCollectionId", out int id)) + { + _selectedCollection = _collections.SingleOrDefault(c => c.Id == id); + } + } private string FormatText() => $"Select the collection to add the {EntityType} {EntityName}"; - private void Submit() => MudDialog.Close(DialogResult.Ok(_selectedCollection)); + private void Submit() + { + MemoryCache.Set("AddToCollectionDialog.SelectedCollectionId", _selectedCollection.Id); + MudDialog.Close(DialogResult.Ok(_selectedCollection)); + } private void Cancel() => MudDialog.Cancel(); From 363eb2c2765e8d3bda957f8066d246afc204ca6c Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Thu, 4 Mar 2021 18:50:26 -0600 Subject: [PATCH 2/3] Rebuild playouts with modified collections (#39) * rebuild playouts when items are removed from collections * rebuild playouts when items are added to collections * simplify logic --- .../Commands/AddEpisodeToCollectionHandler.cs | 23 +++++++++-- .../Commands/AddMovieToCollectionHandler.cs | 26 ++++++++++-- .../Commands/AddSeasonToCollectionHandler.cs | 26 ++++++++++-- .../Commands/AddShowToCollectionHandler.cs | 25 +++++++++-- .../RemoveItemsFromCollectionHandler.cs | 26 +++++++++--- .../Fakes/FakeMediaCollectionRepository.cs | 14 ++----- .../IMediaCollectionRepository.cs | 5 ++- ErsatzTV.Core/Iptv/ChannelGuide.cs | 11 +++-- .../Metadata/FallbackMetadataProvider.cs | 2 +- .../Repositories/MediaCollectionRepository.cs | 22 +++++++--- .../Repositories/MediaSourceRepository.cs | 2 +- .../Data/Repositories/MovieRepository.cs | 5 ++- .../Plex/PlexServerApiClient.cs | 8 ++-- ErsatzTV/Extensions/HostExtensions.cs | 5 ++- ErsatzTV/Pages/CollectionItems.razor | 1 + ErsatzTV/Pages/MovieList.razor | 1 + ErsatzTV/Pages/Search.razor | 1 + ErsatzTV/Pages/TelevisionEpisodeList.razor | 5 ++- ErsatzTV/Pages/TelevisionSeasonList.razor | 5 ++- ErsatzTV/Pages/TelevisionShowList.razor | 1 + ErsatzTV/Shared/MainLayout.razor | 2 +- ErsatzTV/Shared/MediaCard.razor | 2 +- ErsatzTV/wwwroot/browserconfig.xml | 5 ++- ErsatzTV/wwwroot/css/site.css | 41 ++++++------------- 24 files changed, 176 insertions(+), 88 deletions(-) diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddEpisodeToCollectionHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/AddEpisodeToCollectionHandler.cs index 00c9abaeb..8b41da6cc 100644 --- a/ErsatzTV.Application/MediaCollections/Commands/AddEpisodeToCollectionHandler.cs +++ b/ErsatzTV.Application/MediaCollections/Commands/AddEpisodeToCollectionHandler.cs @@ -1,5 +1,7 @@ using System.Threading; +using System.Threading.Channels; using System.Threading.Tasks; +using ErsatzTV.Application.Playouts.Commands; using ErsatzTV.Core; using ErsatzTV.Core.Interfaces.Repositories; using LanguageExt; @@ -9,15 +11,18 @@ namespace ErsatzTV.Application.MediaCollections.Commands public class AddEpisodeToCollectionHandler : MediatR.IRequestHandler> { + private readonly ChannelWriter _channel; private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly ITelevisionRepository _televisionRepository; public AddEpisodeToCollectionHandler( IMediaCollectionRepository mediaCollectionRepository, - ITelevisionRepository televisionRepository) + ITelevisionRepository televisionRepository, + ChannelWriter channel) { _mediaCollectionRepository = mediaCollectionRepository; _televisionRepository = televisionRepository; + _channel = channel; } public Task> Handle( @@ -27,8 +32,20 @@ namespace ErsatzTV.Application.MediaCollections.Commands .MapT(_ => ApplyAddTelevisionEpisodeRequest(request)) .Bind(v => v.ToEitherAsync()); - private Task ApplyAddTelevisionEpisodeRequest(AddEpisodeToCollection request) => - _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.EpisodeId); + private async Task ApplyAddTelevisionEpisodeRequest(AddEpisodeToCollection request) + { + if (await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.EpisodeId)) + { + // rebuild all playouts that use this collection + foreach (int playoutId in await _mediaCollectionRepository + .PlayoutIdsUsingCollection(request.CollectionId)) + { + await _channel.WriteAsync(new BuildPlayout(playoutId, true)); + } + } + + return Unit.Default; + } private async Task> Validate(AddEpisodeToCollection request) => (await CollectionMustExist(request), await ValidateEpisode(request)) diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddMovieToCollectionHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/AddMovieToCollectionHandler.cs index d0f3d521b..3f8d0225a 100644 --- a/ErsatzTV.Application/MediaCollections/Commands/AddMovieToCollectionHandler.cs +++ b/ErsatzTV.Application/MediaCollections/Commands/AddMovieToCollectionHandler.cs @@ -1,5 +1,7 @@ using System.Threading; +using System.Threading.Channels; using System.Threading.Tasks; +using ErsatzTV.Application.Playouts.Commands; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; @@ -7,17 +9,21 @@ using LanguageExt; namespace ErsatzTV.Application.MediaCollections.Commands { - public class AddMovieToCollectionHandler : MediatR.IRequestHandler> + public class + AddMovieToCollectionHandler : MediatR.IRequestHandler> { + private readonly ChannelWriter _channel; private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly IMovieRepository _movieRepository; public AddMovieToCollectionHandler( IMediaCollectionRepository mediaCollectionRepository, - IMovieRepository movieRepository) + IMovieRepository movieRepository, + ChannelWriter channel) { _mediaCollectionRepository = mediaCollectionRepository; _movieRepository = movieRepository; + _channel = channel; } public Task> Handle( @@ -27,8 +33,20 @@ namespace ErsatzTV.Application.MediaCollections.Commands .MapT(_ => ApplyAddMoviesRequest(request)) .Bind(v => v.ToEitherAsync()); - private Task ApplyAddMoviesRequest(AddMovieToCollection request) => - _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.MovieId); + private async Task ApplyAddMoviesRequest(AddMovieToCollection request) + { + if (await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.MovieId)) + { + // rebuild all playouts that use this collection + foreach (int playoutId in await _mediaCollectionRepository + .PlayoutIdsUsingCollection(request.CollectionId)) + { + await _channel.WriteAsync(new BuildPlayout(playoutId, true)); + } + } + + return Unit.Default; + } private async Task> Validate(AddMovieToCollection request) => (await CollectionMustExist(request), await ValidateMovies(request)) diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddSeasonToCollectionHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/AddSeasonToCollectionHandler.cs index 4812be04b..b677da943 100644 --- a/ErsatzTV.Application/MediaCollections/Commands/AddSeasonToCollectionHandler.cs +++ b/ErsatzTV.Application/MediaCollections/Commands/AddSeasonToCollectionHandler.cs @@ -1,5 +1,7 @@ using System.Threading; +using System.Threading.Channels; using System.Threading.Tasks; +using ErsatzTV.Application.Playouts.Commands; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; @@ -7,17 +9,21 @@ using LanguageExt; namespace ErsatzTV.Application.MediaCollections.Commands { - public class AddSeasonToCollectionHandler : MediatR.IRequestHandler> + public class + AddSeasonToCollectionHandler : MediatR.IRequestHandler> { + private readonly ChannelWriter _channel; private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly ITelevisionRepository _televisionRepository; public AddSeasonToCollectionHandler( IMediaCollectionRepository mediaCollectionRepository, - ITelevisionRepository televisionRepository) + ITelevisionRepository televisionRepository, + ChannelWriter channel) { _mediaCollectionRepository = mediaCollectionRepository; _televisionRepository = televisionRepository; + _channel = channel; } public Task> Handle( @@ -27,8 +33,20 @@ namespace ErsatzTV.Application.MediaCollections.Commands .MapT(_ => ApplyAddTelevisionSeasonRequest(request)) .Bind(v => v.ToEitherAsync()); - private async Task ApplyAddTelevisionSeasonRequest(AddSeasonToCollection request) => - await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.SeasonId); + private async Task ApplyAddTelevisionSeasonRequest(AddSeasonToCollection request) + { + if (await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.SeasonId)) + { + // rebuild all playouts that use this collection + foreach (int playoutId in await _mediaCollectionRepository + .PlayoutIdsUsingCollection(request.CollectionId)) + { + await _channel.WriteAsync(new BuildPlayout(playoutId, true)); + } + } + + return Unit.Default; + } private async Task> Validate(AddSeasonToCollection request) => (await CollectionMustExist(request), await ValidateSeason(request)) diff --git a/ErsatzTV.Application/MediaCollections/Commands/AddShowToCollectionHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/AddShowToCollectionHandler.cs index 9c7e780a9..de77bacfa 100644 --- a/ErsatzTV.Application/MediaCollections/Commands/AddShowToCollectionHandler.cs +++ b/ErsatzTV.Application/MediaCollections/Commands/AddShowToCollectionHandler.cs @@ -1,5 +1,7 @@ using System.Threading; +using System.Threading.Channels; using System.Threading.Tasks; +using ErsatzTV.Application.Playouts.Commands; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; @@ -9,15 +11,18 @@ namespace ErsatzTV.Application.MediaCollections.Commands { public class AddShowToCollectionHandler : MediatR.IRequestHandler> { + private readonly ChannelWriter _channel; private readonly IMediaCollectionRepository _mediaCollectionRepository; private readonly ITelevisionRepository _televisionRepository; public AddShowToCollectionHandler( IMediaCollectionRepository mediaCollectionRepository, - ITelevisionRepository televisionRepository) + ITelevisionRepository televisionRepository, + ChannelWriter channel) { _mediaCollectionRepository = mediaCollectionRepository; _televisionRepository = televisionRepository; + _channel = channel; } public Task> Handle( @@ -27,8 +32,22 @@ namespace ErsatzTV.Application.MediaCollections.Commands .MapT(_ => ApplyAddTelevisionShowRequest(request)) .Bind(v => v.ToEitherAsync()); - private Task ApplyAddTelevisionShowRequest(AddShowToCollection request) - => _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.ShowId); + private async Task ApplyAddTelevisionShowRequest(AddShowToCollection request) + { + var result = new Unit(); + + if (await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.ShowId)) + { + // rebuild all playouts that use this collection + foreach (int playoutId in await _mediaCollectionRepository + .PlayoutIdsUsingCollection(request.CollectionId)) + { + await _channel.WriteAsync(new BuildPlayout(playoutId, true)); + } + } + + return result; + } private async Task> Validate(AddShowToCollection request) => (await CollectionMustExist(request), await ValidateShow(request)) diff --git a/ErsatzTV.Application/MediaCollections/Commands/RemoveItemsFromCollectionHandler.cs b/ErsatzTV.Application/MediaCollections/Commands/RemoveItemsFromCollectionHandler.cs index f4341f2bd..cab1d2a9d 100644 --- a/ErsatzTV.Application/MediaCollections/Commands/RemoveItemsFromCollectionHandler.cs +++ b/ErsatzTV.Application/MediaCollections/Commands/RemoveItemsFromCollectionHandler.cs @@ -1,6 +1,8 @@ using System.Linq; using System.Threading; +using System.Threading.Channels; using System.Threading.Tasks; +using ErsatzTV.Application.Playouts.Commands; using ErsatzTV.Core; using ErsatzTV.Core.Domain; using ErsatzTV.Core.Interfaces.Repositories; @@ -11,20 +13,25 @@ namespace ErsatzTV.Application.MediaCollections.Commands public class RemoveItemsFromCollectionHandler : MediatR.IRequestHandler> { + private readonly ChannelWriter _channel; private readonly IMediaCollectionRepository _mediaCollectionRepository; public RemoveItemsFromCollectionHandler( - IMediaCollectionRepository mediaCollectionRepository) => + IMediaCollectionRepository mediaCollectionRepository, + ChannelWriter channel) + { _mediaCollectionRepository = mediaCollectionRepository; + _channel = channel; + } public Task> Handle( RemoveItemsFromCollection request, CancellationToken cancellationToken) => Validate(request) - .MapT(collection => ApplyAddTelevisionEpisodeRequest(request, collection)) + .MapT(collection => ApplyRemoveItemsRequest(request, collection)) .Bind(v => v.ToEitherAsync()); - private Task ApplyAddTelevisionEpisodeRequest( + private async Task ApplyRemoveItemsRequest( RemoveItemsFromCollection request, Collection collection) { @@ -34,9 +41,16 @@ namespace ErsatzTV.Application.MediaCollections.Commands itemsToRemove.ForEach(m => collection.MediaItems.Remove(m)); - return itemsToRemove.Any() - ? _mediaCollectionRepository.Update(collection).ToUnit() - : Task.FromResult(Unit.Default); + if (itemsToRemove.Any() && await _mediaCollectionRepository.Update(collection)) + { + // rebuild all playouts that use this collection + foreach (int playoutId in await _mediaCollectionRepository.PlayoutIdsUsingCollection(collection.Id)) + { + await _channel.WriteAsync(new BuildPlayout(playoutId, true)); + } + } + + return Unit.Default; } private Task> Validate( diff --git a/ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs b/ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs index 8a94581a2..2323eaa8b 100644 --- a/ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs +++ b/ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs @@ -14,23 +14,15 @@ namespace ErsatzTV.Core.Tests.Fakes private readonly Map> _data; public FakeMediaCollectionRepository(Map> data) => _data = data; - public Task Add(Collection collection) => throw new NotSupportedException(); - public Task AddMediaItem(int collectionId, int mediaItemId) => throw new NotSupportedException(); - + public Task AddMediaItem(int collectionId, int mediaItemId) => throw new NotSupportedException(); public Task> Get(int id) => throw new NotSupportedException(); - public Task> GetCollectionWithItems(int id) => throw new NotSupportedException(); - public Task> GetCollectionWithItemsUntracked(int id) => throw new NotSupportedException(); - public Task> GetAll() => throw new NotSupportedException(); - public Task>> GetItems(int id) => Some(_data[id].ToList()).AsTask(); - - public Task Update(Collection collection) => throw new NotSupportedException(); - + Task IMediaCollectionRepository.Update(Collection collection) => throw new NotSupportedException(); public Task Delete(int collectionId) => throw new NotSupportedException(); - public Task>> GetSimpleMediaCollectionItems(int id) => throw new NotSupportedException(); + public Task> PlayoutIdsUsingCollection(int collectionId) => throw new NotSupportedException(); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs index 248fa0764..60ddbc70e 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs @@ -8,13 +8,14 @@ namespace ErsatzTV.Core.Interfaces.Repositories public interface IMediaCollectionRepository { Task Add(Collection collection); - Task AddMediaItem(int collectionId, int mediaItemId); + Task AddMediaItem(int collectionId, int mediaItemId); Task> Get(int id); Task> GetCollectionWithItems(int id); Task> GetCollectionWithItemsUntracked(int id); Task> GetAll(); Task>> GetItems(int id); - Task Update(Collection collection); + Task Update(Collection collection); Task Delete(int collectionId); + Task> PlayoutIdsUsingCollection(int collectionId); } } diff --git a/ErsatzTV.Core/Iptv/ChannelGuide.cs b/ErsatzTV.Core/Iptv/ChannelGuide.cs index 11289b785..fbdb59e0a 100644 --- a/ErsatzTV.Core/Iptv/ChannelGuide.cs +++ b/ErsatzTV.Core/Iptv/ChannelGuide.cs @@ -62,15 +62,18 @@ namespace ErsatzTV.Core.Iptv string title = playoutItem.MediaItem switch { - Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty).IfNone("[unknown movie]"), - Episode e => e.EpisodeMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty).IfNone("[unknown episode]"), + Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Title ?? string.Empty) + .IfNone("[unknown movie]"), + Episode e => e.EpisodeMetadata.HeadOrNone().Map(em => em.Title ?? string.Empty) + .IfNone("[unknown episode]"), _ => "[unknown]" }; string description = playoutItem.MediaItem switch { Movie m => m.MovieMetadata.HeadOrNone().Map(mm => mm.Plot ?? string.Empty).IfNone(string.Empty), - Episode e => e.EpisodeMetadata.HeadOrNone().Map(em => em.Plot ?? string.Empty).IfNone(string.Empty), + Episode e => e.EpisodeMetadata.HeadOrNone().Map(em => em.Plot ?? string.Empty) + .IfNone(string.Empty), _ => string.Empty }; @@ -108,7 +111,7 @@ namespace ErsatzTV.Core.Iptv xml.WriteAttributeString("system", "onscreen"); xml.WriteString($"S{s:00}E{e:00}"); xml.WriteEndElement(); // episode-num - + xml.WriteStartElement("episode-num"); xml.WriteAttributeString("system", "xmltv_ns"); xml.WriteString($"{s - 1}.{e - 1}.0/1"); diff --git a/ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs b/ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs index aa911336d..15cdd7cf1 100644 --- a/ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs +++ b/ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs @@ -34,7 +34,7 @@ namespace ErsatzTV.Core.Metadata return fileName != null ? GetMovieMetadata(fileName, metadata) : metadata; } - + public string GetSortTitle(string title) { if (string.IsNullOrWhiteSpace(title)) diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs index f28f7fd46..0a0afeef0 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs @@ -30,8 +30,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories } - public async Task AddMediaItem(int collectionId, int mediaItemId) + public async Task AddMediaItem(int collectionId, int mediaItemId) { + var modified = false; + Option maybeCollection = await _dbContext.Collections .Include(c => c.MediaItems) .OrderBy(c => c.Id) @@ -52,12 +54,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories async mediaItem => { collection.MediaItems.Add(mediaItem); - await _dbContext.SaveChangesAsync(); + modified = await _dbContext.SaveChangesAsync() > 0; }); } }); - return Unit.Default; + return modified; } public Task> Get(int id) => @@ -121,10 +123,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories public Task>> GetItems(int id) => Get(id).MapT(GetItemsForCollection).Bind(x => x.Sequence()); - public Task Update(Collection collection) + public Task Update(Collection collection) { _dbContext.Collections.Update(collection); - return _dbContext.SaveChangesAsync(); + return _dbContext.SaveChangesAsync().Map(result => result > 0); } public async Task Delete(int collectionId) @@ -134,6 +136,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories await _dbContext.SaveChangesAsync(); } + public Task> PlayoutIdsUsingCollection(int collectionId) => + _dbConnection.QueryAsync( + @"SELECT DISTINCT p.Id + FROM Playout p + INNER JOIN ProgramSchedule PS on p.ProgramScheduleId = PS.Id + INNER JOIN ProgramScheduleItem PSI on p.Anchor_NextScheduleItemId = PSI.Id + WHERE PSI.CollectionId = @CollectionId", + new { CollectionId = collectionId }) + .Map(result => result.ToList()); + private async Task> GetItemsForCollection(Collection collection) { var result = new List(); diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs index fc21f44dd..bcdf3366c 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs @@ -173,7 +173,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories await _dbConnection.ExecuteAsync( "UPDATE PlexLibrary SET ShouldSyncItems = 0 WHERE Id IN @ids", new { ids = libraryIds }); - + await _dbConnection.ExecuteAsync( "UPDATE Library SET LastScan = null WHERE Id IN @ids", new { ids = libraryIds }); diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs index f6b007c8a..ad20a7293 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs @@ -122,7 +122,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories } } - private async Task> AddPlexMovie(TvContext context, PlexLibrary library, PlexMovie item) + private async Task> AddPlexMovie( + TvContext context, + PlexLibrary library, + PlexMovie item) { try { diff --git a/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs b/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs index 0447915bb..650d75a16 100644 --- a/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs +++ b/ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs @@ -18,10 +18,8 @@ namespace ErsatzTV.Infrastructure.Plex { private readonly IFallbackMetadataProvider _fallbackMetadataProvider; - public PlexServerApiClient(IFallbackMetadataProvider fallbackMetadataProvider) - { + public PlexServerApiClient(IFallbackMetadataProvider fallbackMetadataProvider) => _fallbackMetadataProvider = fallbackMetadataProvider; - } public async Task>> GetLibraries( PlexConnection connection, @@ -150,7 +148,7 @@ namespace ErsatzTV.Infrastructure.Plex } private static string ConvertToSAR(double aspectRatio) => "1:1"; - // TODO: fix this with more detailed stats pull from plex for each item - // Math.Abs(aspectRatio - 1) < 0.01 ? "1:1" : $"{(int) (aspectRatio * 100)}:100"; + // TODO: fix this with more detailed stats pull from plex for each item + // Math.Abs(aspectRatio - 1) < 0.01 ? "1:1" : $"{(int) (aspectRatio * 100)}:100"; } } diff --git a/ErsatzTV/Extensions/HostExtensions.cs b/ErsatzTV/Extensions/HostExtensions.cs index bce49bcbb..1bc1a6763 100644 --- a/ErsatzTV/Extensions/HostExtensions.cs +++ b/ErsatzTV/Extensions/HostExtensions.cs @@ -36,7 +36,10 @@ namespace ErsatzTV.Extensions .IfFail( ex => { - LogException(ex, "Error occured while migrating database; shutting down.", scope.ServiceProvider); + LogException( + ex, + "Error occured while migrating database; shutting down.", + scope.ServiceProvider); Environment.Exit(13); return unit; }); diff --git a/ErsatzTV/Pages/CollectionItems.razor b/ErsatzTV/Pages/CollectionItems.razor index 2e97f80d7..aca84e664 100644 --- a/ErsatzTV/Pages/CollectionItems.razor +++ b/ErsatzTV/Pages/CollectionItems.razor @@ -7,6 +7,7 @@ @inject ILogger Logger @inject ISnackbar Snackbar @inject IDialogService Dialog +@inject ChannelWriter Channel
@_data.Name diff --git a/ErsatzTV/Pages/MovieList.razor b/ErsatzTV/Pages/MovieList.razor index e312f82d5..c3cd5e405 100644 --- a/ErsatzTV/Pages/MovieList.razor +++ b/ErsatzTV/Pages/MovieList.razor @@ -10,6 +10,7 @@ @inject IMediator Mediator @inject IDialogService Dialog @inject NavigationManager NavigationManager +@inject ChannelWriter Channel diff --git a/ErsatzTV/Pages/Search.razor b/ErsatzTV/Pages/Search.razor index e18b378fe..f17c338d8 100644 --- a/ErsatzTV/Pages/Search.razor +++ b/ErsatzTV/Pages/Search.razor @@ -11,6 +11,7 @@ @inject ILogger Logger @inject ISnackbar Snackbar @inject IDialogService Dialog +@inject ChannelWriter Channel
diff --git a/ErsatzTV/Pages/TelevisionEpisodeList.razor b/ErsatzTV/Pages/TelevisionEpisodeList.razor index 430d83b55..f1f93102e 100644 --- a/ErsatzTV/Pages/TelevisionEpisodeList.razor +++ b/ErsatzTV/Pages/TelevisionEpisodeList.razor @@ -7,12 +7,13 @@ @using ErsatzTV.Application.MediaCollections.Commands @using ErsatzTV.Application.ProgramSchedules @using ErsatzTV.Application.ProgramSchedules.Commands -@using Unit=LanguageExt.Unit; +@using Unit = LanguageExt.Unit @inject IMediator Mediator @inject ILogger Logger @inject ISnackbar Snackbar @inject IDialogService Dialog @inject NavigationManager NavigationManager +@inject ChannelWriter Channel @@ -122,7 +123,7 @@ NavigationManager.NavigateTo($"/schedules/{schedule.Id}/items"); } } - + private async Task AddEpisodeToCollection(MediaCardViewModel card) { if (card is TelevisionEpisodeCardViewModel episode) diff --git a/ErsatzTV/Pages/TelevisionSeasonList.razor b/ErsatzTV/Pages/TelevisionSeasonList.razor index 64dfd2569..f6442b66e 100644 --- a/ErsatzTV/Pages/TelevisionSeasonList.razor +++ b/ErsatzTV/Pages/TelevisionSeasonList.razor @@ -7,12 +7,13 @@ @using ErsatzTV.Application.MediaCollections.Commands @using ErsatzTV.Application.ProgramSchedules @using ErsatzTV.Application.ProgramSchedules.Commands -@using Unit=LanguageExt.Unit; +@using Unit = LanguageExt.Unit @inject IMediator Mediator @inject ILogger Logger @inject ISnackbar Snackbar @inject IDialogService Dialog @inject NavigationManager NavigationManager +@inject ChannelWriter Channel @@ -116,7 +117,7 @@ NavigationManager.NavigateTo($"/schedules/{schedule.Id}/items"); } } - + private async Task AddSeasonToCollection(MediaCardViewModel card) { if (card is TelevisionSeasonCardViewModel season) diff --git a/ErsatzTV/Pages/TelevisionShowList.razor b/ErsatzTV/Pages/TelevisionShowList.razor index 2924ce570..a6845766a 100644 --- a/ErsatzTV/Pages/TelevisionShowList.razor +++ b/ErsatzTV/Pages/TelevisionShowList.razor @@ -8,6 +8,7 @@ @inject ISnackbar Snackbar @inject IMediator Mediator @inject IDialogService Dialog +@inject ChannelWriter Channel diff --git a/ErsatzTV/Shared/MainLayout.razor b/ErsatzTV/Shared/MainLayout.razor index 1ade69772..5dbe09036 100644 --- a/ErsatzTV/Shared/MainLayout.razor +++ b/ErsatzTV/Shared/MainLayout.razor @@ -67,7 +67,7 @@ private static readonly string InfoVersion = Assembly.GetEntryAssembly().GetCustomAttribute()?.InformationalVersion ?? "unknown"; private MudTextField _textField; - + private MudTheme _ersatzTvTheme { get diff --git a/ErsatzTV/Shared/MediaCard.razor b/ErsatzTV/Shared/MediaCard.razor index 6f78a7e2a..4ef1d8cb4 100644 --- a/ErsatzTV/Shared/MediaCard.razor +++ b/ErsatzTV/Shared/MediaCard.razor @@ -21,7 +21,7 @@ { } @if (DeleteClicked.HasDelegate) diff --git a/ErsatzTV/wwwroot/browserconfig.xml b/ErsatzTV/wwwroot/browserconfig.xml index b3930d0f0..c6f1bea66 100644 --- a/ErsatzTV/wwwroot/browserconfig.xml +++ b/ErsatzTV/wwwroot/browserconfig.xml @@ -1,9 +1,10 @@ + - + #da532c - + \ No newline at end of file diff --git a/ErsatzTV/wwwroot/css/site.css b/ErsatzTV/wwwroot/css/site.css index 9d0ae74bb..29d0d7136 100644 --- a/ErsatzTV/wwwroot/css/site.css +++ b/ErsatzTV/wwwroot/css/site.css @@ -4,13 +4,9 @@ flex-wrap: wrap; } -.media-card-container { - width: 152px; -} +.media-card-container { width: 152px; } -.media-card-episode-container { - width: 392px; -} +.media-card-episode-container { width: 392px; } .media-card { display: flex; @@ -22,12 +18,9 @@ width: 152px; } -.media-card-episode { - width: 392px; -} +.media-card-episode { width: 392px; } -.media-card:hover { /*filter: brightness(75%);*/ -} +.media-card:hover { /*filter: brightness(75%);*/ } .media-card-title { overflow: hidden; @@ -36,9 +29,7 @@ width: 100%; } -.media-card-poster-placeholder { - font-weight: bold; -} +.media-card-poster-placeholder { font-weight: bold; } .media-card-menu { bottom: 0; @@ -47,9 +38,7 @@ right: 0; } -.media-card:hover .media-card-menu { - display: block; -} +.media-card:hover .media-card-menu { display: block; } .media-card-overlay { background: rgba(0, 0, 0, 0.4); @@ -63,26 +52,20 @@ transition: opacity 0.2s; } -.media-card-overlay:hover { - opacity: 1; -} +.media-card-overlay:hover { opacity: 1; } -.search-bar .mud-input { - height: 42px; -} +.search-bar .mud-input { height: 42px; } .search-bar { background-color: rgba(255, 255, 255, .15); - margin-bottom: 5px; - height: 42px; border-radius: 4px; + height: 42px; + margin-bottom: 5px; } -.search-bar div .mud-input-root { - color: #fafafa; -} +.search-bar div .mud-input-root { color: #fafafa; } .search-bar .mud-input.mud-input-outlined .mud-input-outlined-border { border: none; border-radius: 4px; -} +} \ No newline at end of file From 51cdb372b97ded452da60f2f99a5200a3394042e Mon Sep 17 00:00:00 2001 From: Jason Dove Date: Sat, 6 Mar 2021 06:43:38 -0600 Subject: [PATCH 3/3] Remove missing media (#40) * remove movies that are no longer present on disk * remove missing episodes, empty seasons, empty shows --- .../Fakes/FakeTelevisionRepository.cs | 9 ++-- .../Metadata/MovieFolderScannerTests.cs | 52 +++++++++++++++++++ .../Repositories/IMovieRepository.cs | 2 + .../Repositories/ITelevisionRepository.cs | 5 +- ErsatzTV.Core/Metadata/MovieFolderScanner.cs | 10 ++++ .../Metadata/TelevisionFolderScanner.cs | 12 ++++- .../Data/Repositories/MovieRepository.cs | 32 ++++++++++++ .../Data/Repositories/TelevisionRepository.cs | 50 +++++++++++++++++- 8 files changed, 164 insertions(+), 8 deletions(-) diff --git a/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs b/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs index 6eda1708d..38782c108 100644 --- a/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs +++ b/ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs @@ -57,11 +57,12 @@ namespace ErsatzTV.Core.Tests.Fakes public Task> GetOrAddEpisode(Season season, LibraryPath libraryPath, string path) => throw new NotSupportedException(); - public Task DeleteEmptyShows() => throw new NotSupportedException(); + public Task> FindEpisodePaths(LibraryPath libraryPath) => throw new NotSupportedException(); - public Task> GetShowByPath(int mediaSourceId, string path) => throw new NotSupportedException(); + public Task DeleteByPath(LibraryPath libraryPath, string path) => throw new NotSupportedException(); - public Task DeleteMissingSources(int localMediaSourceId, List allFolders) => - throw new NotSupportedException(); + public Task DeleteEmptySeasons(LibraryPath libraryPath) => throw new NotSupportedException(); + + public Task DeleteEmptyShows(LibraryPath libraryPath) => throw new NotSupportedException(); } } diff --git a/ErsatzTV.Core.Tests/Metadata/MovieFolderScannerTests.cs b/ErsatzTV.Core.Tests/Metadata/MovieFolderScannerTests.cs index a27e6e84d..945baf759 100644 --- a/ErsatzTV.Core.Tests/Metadata/MovieFolderScannerTests.cs +++ b/ErsatzTV.Core.Tests/Metadata/MovieFolderScannerTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Runtime.InteropServices; using System.Threading.Tasks; using ErsatzTV.Core.Domain; @@ -44,6 +45,8 @@ namespace ErsatzTV.Core.Tests.Metadata _movieRepository.Setup(x => x.GetOrAdd(It.IsAny(), It.IsAny())) .Returns( (LibraryPath _, string path) => Right(new FakeMovieWithPath(path)).AsTask()); + _movieRepository.Setup(x => x.FindMoviePaths(It.IsAny())) + .Returns(new List().AsEnumerable().AsTask()); _localStatisticsProvider = new Mock(); _localMetadataProvider = new Mock(); @@ -359,6 +362,55 @@ namespace ErsatzTV.Core.Tests.Metadata Times.Once); } + [Test] + public async Task RenamedMovie_Should_Delete_Old_Movie() + { + string movieFolder = Path.Combine(FakeRoot, "Movie (2020)"); + string oldMoviePath = Path.Combine(movieFolder, "Movie (2020).avi"); + + _movieRepository.Setup(x => x.FindMoviePaths(It.IsAny())) + .Returns(new List { oldMoviePath }.AsEnumerable().AsTask()); + + string moviePath = Path.Combine(movieFolder, "Movie (2020).mkv"); + + MovieFolderScanner service = GetService( + new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now } + ); + var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot }; + + Either result = await service.ScanFolder(libraryPath, FFprobePath); + + result.IsRight.Should().BeTrue(); + + _movieRepository.Verify(x => x.DeleteByPath(It.IsAny(), It.IsAny()), Times.Once); + _movieRepository.Verify(x => x.DeleteByPath(libraryPath, oldMoviePath), Times.Once); + } + + [Test] + public async Task DeletedMovieAndFolder_Should_Delete_Old_Movie() + { + string movieFolder = Path.Combine(FakeRoot, "Movie (2020)"); + string oldMoviePath = Path.Combine(movieFolder, "Movie (2020).avi"); + + _movieRepository.Setup(x => x.FindMoviePaths(It.IsAny())) + .Returns(new List { oldMoviePath }.AsEnumerable().AsTask()); + + string moviePath = Path.Combine(movieFolder, "Movie (2020).mkv"); + + MovieFolderScanner service = GetService( + new FakeFileEntry(moviePath) { LastWriteTime = DateTime.Now } + ); + var libraryPath = new LibraryPath { Id = 1, Path = FakeRoot }; + + Either result = await service.ScanFolder(libraryPath, FFprobePath); + + result.IsRight.Should().BeTrue(); + + _movieRepository.Verify(x => x.DeleteByPath(It.IsAny(), It.IsAny()), Times.Once); + _movieRepository.Verify(x => x.DeleteByPath(libraryPath, oldMoviePath), Times.Once); + } + + private MovieFolderScanner GetService(params FakeFileEntry[] files) => new( new FakeLocalFileSystem(new List(files)), diff --git a/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs index e7bd50a7f..7b28ea6ac 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs @@ -13,5 +13,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories Task Update(Movie movie); Task GetMovieCount(); Task> GetPagedMovies(int pageNumber, int pageSize); + Task> FindMoviePaths(LibraryPath libraryPath); + Task DeleteByPath(LibraryPath libraryPath, string path); } } diff --git a/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs b/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs index 9ac405b84..dd3287cc1 100644 --- a/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs +++ b/ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs @@ -27,6 +27,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories Task> AddShow(int libraryPathId, string showFolder, ShowMetadata metadata); Task> GetOrAddSeason(Show show, int libraryPathId, int seasonNumber); Task> GetOrAddEpisode(Season season, LibraryPath libraryPath, string path); - Task DeleteEmptyShows(); + Task> FindEpisodePaths(LibraryPath libraryPath); + Task DeleteByPath(LibraryPath libraryPath, string path); + Task DeleteEmptySeasons(LibraryPath libraryPath); + Task DeleteEmptyShows(LibraryPath libraryPath); } } diff --git a/ErsatzTV.Core/Metadata/MovieFolderScanner.cs b/ErsatzTV.Core/Metadata/MovieFolderScanner.cs index 7a40a2ddc..c1754f6c5 100644 --- a/ErsatzTV.Core/Metadata/MovieFolderScanner.cs +++ b/ErsatzTV.Core/Metadata/MovieFolderScanner.cs @@ -71,6 +71,7 @@ namespace ErsatzTV.Core.Metadata continue; } + foreach (string file in allFiles.OrderBy(identity)) { // TODO: optimize dbcontext use here, do we need tracking? can we make partial updates with dapper? @@ -86,6 +87,15 @@ namespace ErsatzTV.Core.Metadata } } + foreach (string path in await _movieRepository.FindMoviePaths(libraryPath)) + { + if (!_localFileSystem.FileExists(path)) + { + _logger.LogInformation("Removing missing movie at {Path}", path); + await _movieRepository.DeleteByPath(libraryPath, path); + } + } + return Unit.Default; } diff --git a/ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs b/ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs index 1e7227a9b..050589ca6 100644 --- a/ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs +++ b/ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs @@ -63,7 +63,17 @@ namespace ErsatzTV.Core.Metadata _ => Task.FromResult(Unit.Default)); } - await _televisionRepository.DeleteEmptyShows(); + foreach (string path in await _televisionRepository.FindEpisodePaths(libraryPath)) + { + if (!_localFileSystem.FileExists(path)) + { + _logger.LogInformation("Removing missing episode at {Path}", path); + await _televisionRepository.DeleteByPath(libraryPath, path); + } + } + + await _televisionRepository.DeleteEmptySeasons(libraryPath); + await _televisionRepository.DeleteEmptyShows(libraryPath); return Unit.Default; } diff --git a/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs index ad20a7293..9a4469eb5 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs @@ -93,6 +93,38 @@ namespace ErsatzTV.Infrastructure.Data.Repositories .OrderBy(mm => mm.SortTitle) .ToListAsync(); + public Task> FindMoviePaths(LibraryPath libraryPath) => + _dbConnection.QueryAsync( + @"SELECT MF.Path + FROM MediaFile MF + INNER JOIN MediaVersion MV on MF.MediaVersionId = MV.Id + INNER JOIN Movie M on MV.MovieId = M.Id + INNER JOIN MediaItem MI on M.Id = MI.Id + WHERE MI.LibraryPathId = @LibraryPathId", + new { LibraryPathId = libraryPath.Id }); + + public async Task DeleteByPath(LibraryPath libraryPath, string path) + { + IEnumerable ids = await _dbConnection.QueryAsync( + @"SELECT M.Id + FROM Movie M + INNER JOIN MediaItem MI on M.Id = MI.Id + INNER JOIN MediaVersion MV on M.Id = MV.MovieId + INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId + WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path", + new { LibraryPathId = libraryPath.Id, Path = path }); + + foreach (int movieId in ids) + { + Movie movie = await _dbContext.Movies.FindAsync(movieId); + _dbContext.Movies.Remove(movie); + } + + await _dbContext.SaveChangesAsync(); + + return Unit.Default; + } + private async Task> AddMovie(int libraryPathId, string path) { try diff --git a/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs index 9dd94ed82..c46781116 100644 --- a/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs +++ b/ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs @@ -230,9 +230,55 @@ namespace ErsatzTV.Infrastructure.Data.Repositories () => AddEpisode(season, libraryPath.Id, path)); } - public Task DeleteEmptyShows() => + public Task> FindEpisodePaths(LibraryPath libraryPath) => + _dbConnection.QueryAsync( + @"SELECT MF.Path + FROM MediaFile MF + INNER JOIN MediaVersion MV on MF.MediaVersionId = MV.Id + INNER JOIN Episode E on MV.EpisodeId = E.Id + INNER JOIN MediaItem MI on E.Id = MI.Id + WHERE MI.LibraryPathId = @LibraryPathId", + new { LibraryPathId = libraryPath.Id }); + + public async Task DeleteByPath(LibraryPath libraryPath, string path) + { + IEnumerable ids = await _dbConnection.QueryAsync( + @"SELECT E.Id + FROM Episode E + INNER JOIN MediaItem MI on E.Id = MI.Id + INNER JOIN MediaVersion MV on E.Id = MV.EpisodeId + INNER JOIN MediaFile MF on MV.Id = MF.MediaVersionId + WHERE MI.LibraryPathId = @LibraryPathId AND MF.Path = @Path", + new { LibraryPathId = libraryPath.Id, Path = path }); + + foreach (int episodeId in ids) + { + Episode episode = await _dbContext.Episodes.FindAsync(episodeId); + _dbContext.Episodes.Remove(episode); + } + + await _dbContext.SaveChangesAsync(); + + return Unit.Default; + } + + public Task DeleteEmptySeasons(LibraryPath libraryPath) => + _dbContext.Seasons + .Filter(s => s.LibraryPathId == libraryPath.Id) + .Filter(s => s.Episodes.Count == 0) + .ToListAsync() + .Bind( + list => + { + _dbContext.Seasons.RemoveRange(list); + return _dbContext.SaveChangesAsync(); + }) + .ToUnit(); + + public Task DeleteEmptyShows(LibraryPath libraryPath) => _dbContext.Shows - .Where(s => s.Seasons.Count == 0) + .Filter(s => s.LibraryPathId == libraryPath.Id) + .Filter(s => s.Seasons.Count == 0) .ToListAsync() .Bind( list =>