Browse Source

Merge branch 'main' into hardware-transcoding

pull/41/head
Jason Dove 5 years ago
parent
commit
15877a4e53
  1. 23
      ErsatzTV.Application/MediaCollections/Commands/AddEpisodeToCollectionHandler.cs
  2. 26
      ErsatzTV.Application/MediaCollections/Commands/AddMovieToCollectionHandler.cs
  3. 26
      ErsatzTV.Application/MediaCollections/Commands/AddSeasonToCollectionHandler.cs
  4. 25
      ErsatzTV.Application/MediaCollections/Commands/AddShowToCollectionHandler.cs
  5. 26
      ErsatzTV.Application/MediaCollections/Commands/RemoveItemsFromCollectionHandler.cs
  6. 14
      ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs
  7. 9
      ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs
  8. 52
      ErsatzTV.Core.Tests/Metadata/MovieFolderScannerTests.cs
  9. 5
      ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs
  10. 2
      ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs
  11. 5
      ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs
  12. 11
      ErsatzTV.Core/Iptv/ChannelGuide.cs
  13. 2
      ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs
  14. 10
      ErsatzTV.Core/Metadata/MovieFolderScanner.cs
  15. 12
      ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs
  16. 22
      ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs
  17. 2
      ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs
  18. 37
      ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs
  19. 50
      ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs
  20. 8
      ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs
  21. 5
      ErsatzTV/Extensions/HostExtensions.cs
  22. 1
      ErsatzTV/Pages/CollectionItems.razor
  23. 1
      ErsatzTV/Pages/MovieList.razor
  24. 1
      ErsatzTV/Pages/Search.razor
  25. 5
      ErsatzTV/Pages/TelevisionEpisodeList.razor
  26. 5
      ErsatzTV/Pages/TelevisionSeasonList.razor
  27. 1
      ErsatzTV/Pages/TelevisionShowList.razor
  28. 18
      ErsatzTV/Shared/AddToCollectionDialog.razor
  29. 2
      ErsatzTV/Shared/MainLayout.razor
  30. 2
      ErsatzTV/Shared/MediaCard.razor
  31. 5
      ErsatzTV/wwwroot/browserconfig.xml
  32. 41
      ErsatzTV/wwwroot/css/site.css

23
ErsatzTV.Application/MediaCollections/Commands/AddEpisodeToCollectionHandler.cs

@ -1,5 +1,7 @@ @@ -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 @@ -9,15 +11,18 @@ namespace ErsatzTV.Application.MediaCollections.Commands
public class
AddEpisodeToCollectionHandler : MediatR.IRequestHandler<AddEpisodeToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly ITelevisionRepository _televisionRepository;
public AddEpisodeToCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository)
ITelevisionRepository televisionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
@ -27,8 +32,20 @@ namespace ErsatzTV.Application.MediaCollections.Commands @@ -27,8 +32,20 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.MapT(_ => ApplyAddTelevisionEpisodeRequest(request))
.Bind(v => v.ToEitherAsync());
private Task<Unit> ApplyAddTelevisionEpisodeRequest(AddEpisodeToCollection request) =>
_mediaCollectionRepository.AddMediaItem(request.CollectionId, request.EpisodeId);
private async Task<Unit> 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<Validation<BaseError, Unit>> Validate(AddEpisodeToCollection request) =>
(await CollectionMustExist(request), await ValidateEpisode(request))

26
ErsatzTV.Application/MediaCollections/Commands/AddMovieToCollectionHandler.cs

@ -1,5 +1,7 @@ @@ -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; @@ -7,17 +9,21 @@ using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class AddMovieToCollectionHandler : MediatR.IRequestHandler<AddMovieToCollection, Either<BaseError, Unit>>
public class
AddMovieToCollectionHandler : MediatR.IRequestHandler<AddMovieToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly IMovieRepository _movieRepository;
public AddMovieToCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
IMovieRepository movieRepository)
IMovieRepository movieRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_movieRepository = movieRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
@ -27,8 +33,20 @@ namespace ErsatzTV.Application.MediaCollections.Commands @@ -27,8 +33,20 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.MapT(_ => ApplyAddMoviesRequest(request))
.Bind(v => v.ToEitherAsync());
private Task<Unit> ApplyAddMoviesRequest(AddMovieToCollection request) =>
_mediaCollectionRepository.AddMediaItem(request.CollectionId, request.MovieId);
private async Task<Unit> 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<Validation<BaseError, Unit>> Validate(AddMovieToCollection request) =>
(await CollectionMustExist(request), await ValidateMovies(request))

26
ErsatzTV.Application/MediaCollections/Commands/AddSeasonToCollectionHandler.cs

@ -1,5 +1,7 @@ @@ -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; @@ -7,17 +9,21 @@ using LanguageExt;
namespace ErsatzTV.Application.MediaCollections.Commands
{
public class AddSeasonToCollectionHandler : MediatR.IRequestHandler<AddSeasonToCollection, Either<BaseError, Unit>>
public class
AddSeasonToCollectionHandler : MediatR.IRequestHandler<AddSeasonToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly ITelevisionRepository _televisionRepository;
public AddSeasonToCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository)
ITelevisionRepository televisionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
@ -27,8 +33,20 @@ namespace ErsatzTV.Application.MediaCollections.Commands @@ -27,8 +33,20 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.MapT(_ => ApplyAddTelevisionSeasonRequest(request))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> ApplyAddTelevisionSeasonRequest(AddSeasonToCollection request) =>
await _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.SeasonId);
private async Task<Unit> 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<Validation<BaseError, Unit>> Validate(AddSeasonToCollection request) =>
(await CollectionMustExist(request), await ValidateSeason(request))

25
ErsatzTV.Application/MediaCollections/Commands/AddShowToCollectionHandler.cs

@ -1,5 +1,7 @@ @@ -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 @@ -9,15 +11,18 @@ namespace ErsatzTV.Application.MediaCollections.Commands
{
public class AddShowToCollectionHandler : MediatR.IRequestHandler<AddShowToCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
private readonly ITelevisionRepository _televisionRepository;
public AddShowToCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository,
ITelevisionRepository televisionRepository)
ITelevisionRepository televisionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_televisionRepository = televisionRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
@ -27,8 +32,22 @@ namespace ErsatzTV.Application.MediaCollections.Commands @@ -27,8 +32,22 @@ namespace ErsatzTV.Application.MediaCollections.Commands
.MapT(_ => ApplyAddTelevisionShowRequest(request))
.Bind(v => v.ToEitherAsync());
private Task<Unit> ApplyAddTelevisionShowRequest(AddShowToCollection request)
=> _mediaCollectionRepository.AddMediaItem(request.CollectionId, request.ShowId);
private async Task<Unit> 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<Validation<BaseError, Unit>> Validate(AddShowToCollection request) =>
(await CollectionMustExist(request), await ValidateShow(request))

26
ErsatzTV.Application/MediaCollections/Commands/RemoveItemsFromCollectionHandler.cs

@ -1,6 +1,8 @@ @@ -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 @@ -11,20 +13,25 @@ namespace ErsatzTV.Application.MediaCollections.Commands
public class
RemoveItemsFromCollectionHandler : MediatR.IRequestHandler<RemoveItemsFromCollection, Either<BaseError, Unit>>
{
private readonly ChannelWriter<IBackgroundServiceRequest> _channel;
private readonly IMediaCollectionRepository _mediaCollectionRepository;
public RemoveItemsFromCollectionHandler(
IMediaCollectionRepository mediaCollectionRepository) =>
IMediaCollectionRepository mediaCollectionRepository,
ChannelWriter<IBackgroundServiceRequest> channel)
{
_mediaCollectionRepository = mediaCollectionRepository;
_channel = channel;
}
public Task<Either<BaseError, Unit>> Handle(
RemoveItemsFromCollection request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(collection => ApplyAddTelevisionEpisodeRequest(request, collection))
.MapT(collection => ApplyRemoveItemsRequest(request, collection))
.Bind(v => v.ToEitherAsync());
private Task<Unit> ApplyAddTelevisionEpisodeRequest(
private async Task<Unit> ApplyRemoveItemsRequest(
RemoveItemsFromCollection request,
Collection collection)
{
@ -34,9 +41,16 @@ namespace ErsatzTV.Application.MediaCollections.Commands @@ -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<Validation<BaseError, Collection>> Validate(

14
ErsatzTV.Core.Tests/Fakes/FakeMediaCollectionRepository.cs

@ -14,23 +14,15 @@ namespace ErsatzTV.Core.Tests.Fakes @@ -14,23 +14,15 @@ namespace ErsatzTV.Core.Tests.Fakes
private readonly Map<int, List<MediaItem>> _data;
public FakeMediaCollectionRepository(Map<int, List<MediaItem>> data) => _data = data;
public Task<Collection> Add(Collection collection) => throw new NotSupportedException();
public Task<Unit> AddMediaItem(int collectionId, int mediaItemId) => throw new NotSupportedException();
public Task<bool> AddMediaItem(int collectionId, int mediaItemId) => throw new NotSupportedException();
public Task<Option<Collection>> Get(int id) => throw new NotSupportedException();
public Task<Option<Collection>> GetCollectionWithItems(int id) => throw new NotSupportedException();
public Task<Option<Collection>> GetCollectionWithItemsUntracked(int id) => throw new NotSupportedException();
public Task<List<Collection>> GetAll() => throw new NotSupportedException();
public Task<Option<List<MediaItem>>> GetItems(int id) => Some(_data[id].ToList()).AsTask();
public Task Update(Collection collection) => throw new NotSupportedException();
Task<bool> IMediaCollectionRepository.Update(Collection collection) => throw new NotSupportedException();
public Task Delete(int collectionId) => throw new NotSupportedException();
public Task<Option<List<MediaItem>>> GetSimpleMediaCollectionItems(int id) => throw new NotSupportedException();
public Task<List<int>> PlayoutIdsUsingCollection(int collectionId) => throw new NotSupportedException();
}
}

9
ErsatzTV.Core.Tests/Fakes/FakeTelevisionRepository.cs

@ -57,11 +57,12 @@ namespace ErsatzTV.Core.Tests.Fakes @@ -57,11 +57,12 @@ namespace ErsatzTV.Core.Tests.Fakes
public Task<Either<BaseError, Episode>> GetOrAddEpisode(Season season, LibraryPath libraryPath, string path) =>
throw new NotSupportedException();
public Task<Unit> DeleteEmptyShows() => throw new NotSupportedException();
public Task<IEnumerable<string>> FindEpisodePaths(LibraryPath libraryPath) => throw new NotSupportedException();
public Task<Option<Show>> GetShowByPath(int mediaSourceId, string path) => throw new NotSupportedException();
public Task<Unit> DeleteByPath(LibraryPath libraryPath, string path) => throw new NotSupportedException();
public Task<Unit> DeleteMissingSources(int localMediaSourceId, List<string> allFolders) =>
throw new NotSupportedException();
public Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath) => throw new NotSupportedException();
public Task<Unit> DeleteEmptyShows(LibraryPath libraryPath) => throw new NotSupportedException();
}
}

52
ErsatzTV.Core.Tests/Metadata/MovieFolderScannerTests.cs

@ -1,6 +1,7 @@ @@ -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 @@ -44,6 +45,8 @@ namespace ErsatzTV.Core.Tests.Metadata
_movieRepository.Setup(x => x.GetOrAdd(It.IsAny<LibraryPath>(), It.IsAny<string>()))
.Returns(
(LibraryPath _, string path) => Right<BaseError, Movie>(new FakeMovieWithPath(path)).AsTask());
_movieRepository.Setup(x => x.FindMoviePaths(It.IsAny<LibraryPath>()))
.Returns(new List<string>().AsEnumerable().AsTask());
_localStatisticsProvider = new Mock<ILocalStatisticsProvider>();
_localMetadataProvider = new Mock<ILocalMetadataProvider>();
@ -359,6 +362,55 @@ namespace ErsatzTV.Core.Tests.Metadata @@ -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<LibraryPath>()))
.Returns(new List<string> { 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<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
result.IsRight.Should().BeTrue();
_movieRepository.Verify(x => x.DeleteByPath(It.IsAny<LibraryPath>(), It.IsAny<string>()), 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<LibraryPath>()))
.Returns(new List<string> { 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<BaseError, Unit> result = await service.ScanFolder(libraryPath, FFprobePath);
result.IsRight.Should().BeTrue();
_movieRepository.Verify(x => x.DeleteByPath(It.IsAny<LibraryPath>(), It.IsAny<string>()), Times.Once);
_movieRepository.Verify(x => x.DeleteByPath(libraryPath, oldMoviePath), Times.Once);
}
private MovieFolderScanner GetService(params FakeFileEntry[] files) =>
new(
new FakeLocalFileSystem(new List<FakeFileEntry>(files)),

5
ErsatzTV.Core/Interfaces/Repositories/IMediaCollectionRepository.cs

@ -8,13 +8,14 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -8,13 +8,14 @@ namespace ErsatzTV.Core.Interfaces.Repositories
public interface IMediaCollectionRepository
{
Task<Collection> Add(Collection collection);
Task<Unit> AddMediaItem(int collectionId, int mediaItemId);
Task<bool> AddMediaItem(int collectionId, int mediaItemId);
Task<Option<Collection>> Get(int id);
Task<Option<Collection>> GetCollectionWithItems(int id);
Task<Option<Collection>> GetCollectionWithItemsUntracked(int id);
Task<List<Collection>> GetAll();
Task<Option<List<MediaItem>>> GetItems(int id);
Task Update(Collection collection);
Task<bool> Update(Collection collection);
Task Delete(int collectionId);
Task<List<int>> PlayoutIdsUsingCollection(int collectionId);
}
}

2
ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs

@ -13,5 +13,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -13,5 +13,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> Update(Movie movie);
Task<int> GetMovieCount();
Task<List<MovieMetadata>> GetPagedMovies(int pageNumber, int pageSize);
Task<IEnumerable<string>> FindMoviePaths(LibraryPath libraryPath);
Task<Unit> DeleteByPath(LibraryPath libraryPath, string path);
}
}

5
ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs

@ -27,6 +27,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -27,6 +27,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<Either<BaseError, Show>> AddShow(int libraryPathId, string showFolder, ShowMetadata metadata);
Task<Either<BaseError, Season>> GetOrAddSeason(Show show, int libraryPathId, int seasonNumber);
Task<Either<BaseError, Episode>> GetOrAddEpisode(Season season, LibraryPath libraryPath, string path);
Task<Unit> DeleteEmptyShows();
Task<IEnumerable<string>> FindEpisodePaths(LibraryPath libraryPath);
Task<Unit> DeleteByPath(LibraryPath libraryPath, string path);
Task<Unit> DeleteEmptySeasons(LibraryPath libraryPath);
Task<Unit> DeleteEmptyShows(LibraryPath libraryPath);
}
}

11
ErsatzTV.Core/Iptv/ChannelGuide.cs

@ -62,15 +62,18 @@ namespace ErsatzTV.Core.Iptv @@ -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 @@ -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");

2
ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs

@ -34,7 +34,7 @@ namespace ErsatzTV.Core.Metadata @@ -34,7 +34,7 @@ namespace ErsatzTV.Core.Metadata
return fileName != null ? GetMovieMetadata(fileName, metadata) : metadata;
}
public string GetSortTitle(string title)
{
if (string.IsNullOrWhiteSpace(title))

10
ErsatzTV.Core/Metadata/MovieFolderScanner.cs

@ -71,6 +71,7 @@ namespace ErsatzTV.Core.Metadata @@ -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 @@ -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;
}

12
ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs

@ -63,7 +63,17 @@ namespace ErsatzTV.Core.Metadata @@ -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;
}

22
ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs

@ -30,8 +30,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -30,8 +30,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
}
public async Task<Unit> AddMediaItem(int collectionId, int mediaItemId)
public async Task<bool> AddMediaItem(int collectionId, int mediaItemId)
{
var modified = false;
Option<Collection> maybeCollection = await _dbContext.Collections
.Include(c => c.MediaItems)
.OrderBy(c => c.Id)
@ -52,12 +54,12 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -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<Option<Collection>> Get(int id) =>
@ -121,10 +123,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -121,10 +123,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public Task<Option<List<MediaItem>>> GetItems(int id) =>
Get(id).MapT(GetItemsForCollection).Bind(x => x.Sequence());
public Task Update(Collection collection)
public Task<bool> 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 @@ -134,6 +136,16 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await _dbContext.SaveChangesAsync();
}
public Task<List<int>> PlayoutIdsUsingCollection(int collectionId) =>
_dbConnection.QueryAsync<int>(
@"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<List<MediaItem>> GetItemsForCollection(Collection collection)
{
var result = new List<MediaItem>();

2
ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs

@ -173,7 +173,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -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 });

37
ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs

@ -93,6 +93,38 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -93,6 +93,38 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.OrderBy(mm => mm.SortTitle)
.ToListAsync();
public Task<IEnumerable<string>> FindMoviePaths(LibraryPath libraryPath) =>
_dbConnection.QueryAsync<string>(
@"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<Unit> DeleteByPath(LibraryPath libraryPath, string path)
{
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
@"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<Either<BaseError, Movie>> AddMovie(int libraryPathId, string path)
{
try
@ -122,7 +154,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -122,7 +154,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
}
}
private async Task<Either<BaseError, PlexMovie>> AddPlexMovie(TvContext context, PlexLibrary library, PlexMovie item)
private async Task<Either<BaseError, PlexMovie>> AddPlexMovie(
TvContext context,
PlexLibrary library,
PlexMovie item)
{
try
{

50
ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs

@ -230,9 +230,55 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -230,9 +230,55 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
() => AddEpisode(season, libraryPath.Id, path));
}
public Task<Unit> DeleteEmptyShows() =>
public Task<IEnumerable<string>> FindEpisodePaths(LibraryPath libraryPath) =>
_dbConnection.QueryAsync<string>(
@"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<Unit> DeleteByPath(LibraryPath libraryPath, string path)
{
IEnumerable<int> ids = await _dbConnection.QueryAsync<int>(
@"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<Unit> 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<Unit> 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 =>

8
ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

@ -18,10 +18,8 @@ namespace ErsatzTV.Infrastructure.Plex @@ -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<Either<BaseError, List<PlexLibrary>>> GetLibraries(
PlexConnection connection,
@ -150,7 +148,7 @@ namespace ErsatzTV.Infrastructure.Plex @@ -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";
}
}

5
ErsatzTV/Extensions/HostExtensions.cs

@ -36,7 +36,10 @@ namespace ErsatzTV.Extensions @@ -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;
});

1
ErsatzTV/Pages/CollectionItems.razor

@ -7,6 +7,7 @@ @@ -7,6 +7,7 @@
@inject ILogger<CollectionItems> Logger
@inject ISnackbar Snackbar
@inject IDialogService Dialog
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<div class="mb-6" style="display: flex; flex-direction: row;">
<MudText GutterBottom="true" Typo="Typo.h2">@_data.Name</MudText>

1
ErsatzTV/Pages/MovieList.razor

@ -10,6 +10,7 @@ @@ -10,6 +10,7 @@
@inject IMediator Mediator
@inject IDialogService Dialog
@inject NavigationManager NavigationManager
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<MudContainer MaxWidth="MaxWidth.Small" Class="mb-6" Style="max-width: 300px">
<MudPaper Style="align-items: center; display: flex; justify-content: center;">

1
ErsatzTV/Pages/Search.razor

@ -11,6 +11,7 @@ @@ -11,6 +11,7 @@
@inject ILogger<Search> Logger
@inject ISnackbar Snackbar
@inject IDialogService Dialog
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
<div class="mb-6" style="display: flex; flex-direction: row;">

5
ErsatzTV/Pages/TelevisionEpisodeList.razor

@ -7,12 +7,13 @@ @@ -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<TelevisionEpisodeList> Logger
@inject ISnackbar Snackbar
@inject IDialogService Dialog
@inject NavigationManager NavigationManager
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<MudContainer MaxWidth="MaxWidth.Large">
<MudBreadcrumbs Items="_breadcrumbs" Class="mb-6"></MudBreadcrumbs>
@ -122,7 +123,7 @@ @@ -122,7 +123,7 @@
NavigationManager.NavigateTo($"/schedules/{schedule.Id}/items");
}
}
private async Task AddEpisodeToCollection(MediaCardViewModel card)
{
if (card is TelevisionEpisodeCardViewModel episode)

5
ErsatzTV/Pages/TelevisionSeasonList.razor

@ -7,12 +7,13 @@ @@ -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<TelevisionSeasonList> Logger
@inject ISnackbar Snackbar
@inject IDialogService Dialog
@inject NavigationManager NavigationManager
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<MudContainer MaxWidth="MaxWidth.Large">
<MudBreadcrumbs Items="_breadcrumbs" Class="mb-6"></MudBreadcrumbs>
@ -116,7 +117,7 @@ @@ -116,7 +117,7 @@
NavigationManager.NavigateTo($"/schedules/{schedule.Id}/items");
}
}
private async Task AddSeasonToCollection(MediaCardViewModel card)
{
if (card is TelevisionSeasonCardViewModel season)

1
ErsatzTV/Pages/TelevisionShowList.razor

@ -8,6 +8,7 @@ @@ -8,6 +8,7 @@
@inject ISnackbar Snackbar
@inject IMediator Mediator
@inject IDialogService Dialog
@inject ChannelWriter<IBackgroundServiceRequest> Channel
<MudContainer MaxWidth="MaxWidth.Small" Class="mb-6" Style="max-width: 300px">
<MudPaper Style="align-items: center; display: flex; justify-content: center;">

18
ErsatzTV/Shared/AddToCollectionDialog.razor

@ -1,6 +1,8 @@ @@ -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
<MudDialog>
<DialogContent>
@ -46,12 +48,22 @@ @@ -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();

2
ErsatzTV/Shared/MainLayout.razor

@ -67,7 +67,7 @@ @@ -67,7 +67,7 @@
private static readonly string InfoVersion = Assembly.GetEntryAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "unknown";
private MudTextField<string> _textField;
private MudTheme _ersatzTvTheme
{
get

2
ErsatzTV/Shared/MediaCard.razor

@ -21,7 +21,7 @@ @@ -21,7 +21,7 @@
{
<MudIconButton Color="Color.Tertiary"
Icon="@Icons.Material.Filled.PlaylistAdd"
Style="bottom: 0; position: absolute; left: 0;"
Style="bottom: 0; left: 0; position: absolute;"
OnClick="@(() => AddToCollectionClicked.InvokeAsync(Data))"/>
}
@if (DeleteClicked.HasDelegate)

5
ErsatzTV/wwwroot/browserconfig.xml

@ -1,9 +1,10 @@ @@ -1,9 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig>
<msapplication>
<tile>
<square150x150logo src="/mstile-150x150.png"/>
<square150x150logo src="/mstile-150x150.png" />
<TileColor>#da532c</TileColor>
</tile>
</msapplication>
</browserconfig>
</browserconfig>

41
ErsatzTV/wwwroot/css/site.css

@ -4,13 +4,9 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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 @@ @@ -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;
}
}
Loading…
Cancel
Save