Browse Source

Interface improvements (#34)

* fix plex library synchronization

* add basic plex movie synchronization

* proxy plex movie thumbnails (posters)

* add plex path replacements

* use transcoded plex artwork

* remove unsynchronized plex movies on save; queue plex library scan on save

* log plex path replacements

* prefer buttons instead of menus

* lock plex libraries before sync

* add movie to collection from paged view

* fix plex import memory use; quick add seasons/shows

* quick add episode to collection

* add favicon

* add search page

* disable plex for now
pull/35/head
Jason Dove 5 years ago committed by GitHub
parent
commit
f281d9fca5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      ErsatzTV.Application/Libraries/LibraryViewModel.cs
  2. 3
      ErsatzTV.Application/Libraries/LocalLibraryViewModel.cs
  3. 11
      ErsatzTV.Application/Libraries/Mapper.cs
  4. 7
      ErsatzTV.Application/Libraries/PlexLibraryViewModel.cs
  5. 2
      ErsatzTV.Application/Libraries/Queries/GetAllLibraries.cs
  6. 31
      ErsatzTV.Application/Libraries/Queries/GetAllLibrariesHandler.cs
  7. 22
      ErsatzTV.Application/Libraries/Queries/GetLocalLibrariesHandler.cs
  8. 6
      ErsatzTV.Application/MediaCards/Mapper.cs
  9. 4
      ErsatzTV.Application/MediaCards/Queries/GetMovieCardsHandler.cs
  10. 8
      ErsatzTV.Application/MediaCards/Queries/GetSearchCards.cs
  11. 25
      ErsatzTV.Application/MediaCards/Queries/GetSearchCardsHandler.cs
  12. 8
      ErsatzTV.Application/MediaCards/SearchCardResultsViewModel.cs
  13. 4
      ErsatzTV.Application/Plex/Commands/SynchronizePlexLibrariesHandler.cs
  14. 5
      ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryById.cs
  15. 12
      ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs
  16. 3
      ErsatzTV.Application/Plex/Commands/UpdatePlexLibraryPreferencesHandler.cs
  17. 13
      ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacements.cs
  18. 65
      ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacementsHandler.cs
  19. 3
      ErsatzTV.Application/Plex/Mapper.cs
  20. 6
      ErsatzTV.Application/Plex/PlexConnectionParametersViewModel.cs
  21. 4
      ErsatzTV.Application/Plex/PlexPathReplacementViewModel.cs
  22. 9
      ErsatzTV.Application/Plex/Queries/GetPlexConnectionParameters.cs
  23. 94
      ErsatzTV.Application/Plex/Queries/GetPlexConnectionParametersHandler.cs
  24. 8
      ErsatzTV.Application/Plex/Queries/GetPlexPathReplacementsBySourceId.cs
  25. 26
      ErsatzTV.Application/Plex/Queries/GetPlexPathReplacementsBySourceIdHandler.cs
  26. 62
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  27. 7
      ErsatzTV.Core.Tests/Metadata/FallbackMetadataProviderTests.cs
  28. 8
      ErsatzTV.Core/Domain/MediaItem/PlexMediaFile.cs
  29. 12
      ErsatzTV.Core/Domain/MediaItem/PlexMediaItemPart.cs
  30. 1
      ErsatzTV.Core/Domain/MediaItem/PlexMovie.cs
  31. 1
      ErsatzTV.Core/Domain/MediaSource/PlexMediaSource.cs
  32. 11
      ErsatzTV.Core/Domain/MediaSource/PlexPathReplacement.cs
  33. 19
      ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs
  34. 6
      ErsatzTV.Core/Interfaces/Metadata/IFallbackMetadataProvider.cs
  35. 2
      ErsatzTV.Core/Interfaces/Repositories/ILibraryRepository.cs
  36. 5
      ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs
  37. 11
      ErsatzTV.Core/Interfaces/Repositories/ISearchRepository.cs
  38. 2
      ErsatzTV.Core/Iptv/ChannelGuide.cs
  39. 32
      ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs
  40. 42
      ErsatzTV.Core/Metadata/LocalMetadataProvider.cs
  41. 20
      ErsatzTV.Core/Plex/PlexMediaEntry.cs
  42. 19
      ErsatzTV.Core/Plex/PlexMetadataEntry.cs
  43. 11
      ErsatzTV.Core/Plex/PlexPartEntry.cs
  44. 11
      ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexMediaFileConfiguration.cs
  45. 2
      ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexMovieConfiguration.cs
  46. 5
      ErsatzTV.Infrastructure/Data/Configurations/MediaSource/PlexMediaSourceConfiguration.cs
  47. 11
      ErsatzTV.Infrastructure/Data/Configurations/MediaSource/PlexPathReplacementConfiguration.cs
  48. 4
      ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs
  49. 58
      ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs
  50. 19
      ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs
  51. 48
      ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs
  52. 3
      ErsatzTV.Infrastructure/Data/TvContext.cs
  53. 1456
      ErsatzTV.Infrastructure/Migrations/20210228210815_Add_PlexMediaFile.Designer.cs
  54. 84
      ErsatzTV.Infrastructure/Migrations/20210228210815_Add_PlexMediaFile.cs
  55. 1491
      ErsatzTV.Infrastructure/Migrations/20210301001541_Add_PlexPathReplacement.Designer.cs
  56. 40
      ErsatzTV.Infrastructure/Migrations/20210301001541_Add_PlexPathReplacement.cs
  57. 438
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  58. 1
      ErsatzTV.Infrastructure/Plex/Models/PlexLibraryResponse.cs
  59. 1
      ErsatzTV.Infrastructure/Plex/Models/PlexPartResponse.cs
  60. 127
      ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs
  61. 40
      ErsatzTV/Controllers/ArtworkController.cs
  62. 20
      ErsatzTV/Pages/Libraries.razor
  63. 34
      ErsatzTV/Pages/MovieList.razor
  64. 31
      ErsatzTV/Pages/Playouts.razor
  65. 20
      ErsatzTV/Pages/PlexLibrariesEditor.razor
  66. 73
      ErsatzTV/Pages/PlexMediaSources.razor
  67. 135
      ErsatzTV/Pages/PlexPathReplacementsEditor.razor
  68. 30
      ErsatzTV/Pages/Schedules.razor
  69. 110
      ErsatzTV/Pages/Search.razor
  70. 29
      ErsatzTV/Pages/TelevisionEpisodeList.razor
  71. 29
      ErsatzTV/Pages/TelevisionSeasonList.razor
  72. 34
      ErsatzTV/Pages/TelevisionShowList.razor
  73. 1
      ErsatzTV/Pages/_Host.cshtml
  74. 39
      ErsatzTV/Shared/MainLayout.razor
  75. 10
      ErsatzTV/Shared/MediaCard.razor
  76. 6
      ErsatzTV/Shared/_Favicons.cshtml
  77. 3
      ErsatzTV/Startup.cs
  78. 14
      ErsatzTV/Validators/PlexPathReplacementEditViewModelValidator.cs
  79. 9
      ErsatzTV/ViewModels/PlexPathReplacementEditViewModel.cs
  80. BIN
      ErsatzTV/wwwroot/android-chrome-192x192.png
  81. BIN
      ErsatzTV/wwwroot/android-chrome-512x512.png
  82. BIN
      ErsatzTV/wwwroot/apple-touch-icon.png
  83. 9
      ErsatzTV/wwwroot/browserconfig.xml
  84. 47
      ErsatzTV/wwwroot/css/site.css
  85. BIN
      ErsatzTV/wwwroot/favicon-16x16.png
  86. BIN
      ErsatzTV/wwwroot/favicon-32x32.png
  87. BIN
      ErsatzTV/wwwroot/favicon.ico
  88. BIN
      ErsatzTV/wwwroot/mstile-150x150.png
  89. 19
      ErsatzTV/wwwroot/site.webmanifest

6
ErsatzTV.Application/Libraries/LibraryViewModel.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Libraries
{
public record LibraryViewModel(string LibraryKind, int Id, string Name, LibraryMediaKind MediaKind);
}

3
ErsatzTV.Application/Libraries/LocalLibraryViewModel.cs

@ -2,5 +2,6 @@ @@ -2,5 +2,6 @@
namespace ErsatzTV.Application.Libraries
{
public record LocalLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind);
public record LocalLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind)
: LibraryViewModel("Local", Id, Name, MediaKind);
}

11
ErsatzTV.Application/Libraries/Mapper.cs

@ -1,9 +1,18 @@ @@ -1,9 +1,18 @@
using ErsatzTV.Core.Domain;
using System;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Libraries
{
internal static class Mapper
{
public static LibraryViewModel ProjectToViewModel(Library library) =>
library switch
{
LocalLibrary l => ProjectToViewModel(l),
PlexLibrary p => new PlexLibraryViewModel(p.Id, p.Name, p.MediaKind),
_ => throw new ArgumentOutOfRangeException(nameof(library))
};
public static LocalLibraryViewModel ProjectToViewModel(LocalLibrary library) =>
new(library.Id, library.Name, library.MediaKind);

7
ErsatzTV.Application/Libraries/PlexLibraryViewModel.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Libraries
{
public record PlexLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind)
: LibraryViewModel("Plex", Id, Name, MediaKind);
}

2
ErsatzTV.Application/Libraries/Queries/GetLocalLibraries.cs → ErsatzTV.Application/Libraries/Queries/GetAllLibraries.cs

@ -3,5 +3,5 @@ using MediatR; @@ -3,5 +3,5 @@ using MediatR;
namespace ErsatzTV.Application.Libraries.Queries
{
public record GetLocalLibraries : IRequest<List<LocalLibraryViewModel>>;
public record GetAllLibraries : IRequest<List<LibraryViewModel>>;
}

31
ErsatzTV.Application/Libraries/Queries/GetAllLibrariesHandler.cs

@ -0,0 +1,31 @@ @@ -0,0 +1,31 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.Libraries.Mapper;
namespace ErsatzTV.Application.Libraries.Queries
{
public class GetAllLibrariesHandler : IRequestHandler<GetAllLibraries, List<LibraryViewModel>>
{
private readonly ILibraryRepository _libraryRepository;
public GetAllLibrariesHandler(ILibraryRepository libraryRepository) => _libraryRepository = libraryRepository;
public Task<List<LibraryViewModel>> Handle(GetAllLibraries request, CancellationToken cancellationToken) =>
_libraryRepository.GetAll()
.Map(list => list.Filter(ShouldIncludeLibrary).Map(ProjectToViewModel).ToList());
private static bool ShouldIncludeLibrary(Library library) =>
library switch
{
LocalLibrary => true,
PlexLibrary plex => plex.ShouldSyncItems,
_ => false
};
}
}

22
ErsatzTV.Application/Libraries/Queries/GetLocalLibrariesHandler.cs

@ -1,22 +0,0 @@ @@ -1,22 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.Libraries.Mapper;
namespace ErsatzTV.Application.Libraries.Queries
{
public class GetLocalLibrariesHandler : IRequestHandler<GetLocalLibraries, List<LocalLibraryViewModel>>
{
private readonly ILibraryRepository _libraryRepository;
public GetLocalLibrariesHandler(ILibraryRepository libraryRepository) => _libraryRepository = libraryRepository;
public Task<List<LocalLibraryViewModel>>
Handle(GetLocalLibraries request, CancellationToken cancellationToken) =>
_libraryRepository.GetAllLocal().Map(list => list.Map(ProjectToViewModel).ToList());
}
}

6
ErsatzTV.Application/MediaCards/Mapper.cs

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using ErsatzTV.Core.Domain;
using static LanguageExt.Prelude;
@ -56,6 +57,11 @@ namespace ErsatzTV.Application.MediaCards @@ -56,6 +57,11 @@ namespace ErsatzTV.Application.MediaCards
collection.MediaItems.OfType<Episode>().Map(e => ProjectToViewModel(e.EpisodeMetadata.Head()))
.ToList());
internal static SearchCardResultsViewModel ProjectToSearchResults(List<MediaItem> items) =>
new(
items.OfType<Movie>().Map(m => ProjectToViewModel(m.MovieMetadata.Head())).ToList(),
items.OfType<Show>().Map(s => ProjectToViewModel(s.ShowMetadata.Head())).ToList());
private static string GetSeasonName(int number) =>
number == 0 ? "Specials" : $"Season {number}";

4
ErsatzTV.Application/MediaCards/Queries/GetMovieCardsHandler.cs

@ -16,9 +16,7 @@ namespace ErsatzTV.Application.MediaCards.Queries @@ -16,9 +16,7 @@ namespace ErsatzTV.Application.MediaCards.Queries
public GetMovieCardsHandler(IMovieRepository movieRepository) => _movieRepository = movieRepository;
public async Task<MovieCardResultsViewModel> Handle(
GetMovieCards request,
CancellationToken cancellationToken)
public async Task<MovieCardResultsViewModel> Handle(GetMovieCards request, CancellationToken cancellationToken)
{
int count = await _movieRepository.GetMovieCount();

8
ErsatzTV.Application/MediaCards/Queries/GetSearchCards.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaCards.Queries
{
public record GetSearchCards(string Query) : IRequest<Either<BaseError, SearchCardResultsViewModel>>;
}

25
ErsatzTV.Application/MediaCards/Queries/GetSearchCardsHandler.cs

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.MediaCards.Mapper;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCards.Queries
{
public class GetSearchCardsHandler : IRequestHandler<GetSearchCards, Either<BaseError, SearchCardResultsViewModel>>
{
private readonly ISearchRepository _searchRepository;
public GetSearchCardsHandler(ISearchRepository searchRepository) => _searchRepository = searchRepository;
public Task<Either<BaseError, SearchCardResultsViewModel>> Handle(
GetSearchCards request,
CancellationToken cancellationToken) =>
Try(_searchRepository.SearchMediaItems(request.Query)).Sequence()
.Map(ProjectToSearchResults)
.Map(t => t.ToEither(ex => BaseError.New($"Failed to search: {ex.Message}")));
}
}

8
ErsatzTV.Application/MediaCards/SearchCardResultsViewModel.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.MediaCards
{
public record SearchCardResultsViewModel(
List<MovieCardViewModel> MovieCards,
List<TelevisionShowCardViewModel> ShowCards);
}

4
ErsatzTV.Application/Plex/Commands/SynchronizePlexLibrariesHandler.cs

@ -78,8 +78,8 @@ namespace ErsatzTV.Application.Plex.Commands @@ -78,8 +78,8 @@ namespace ErsatzTV.Application.Plex.Commands
var existing = connectionParameters.PlexMediaSource.Libraries.OfType<PlexLibrary>().ToList();
var toAdd = libraries.Filter(library => existing.All(l => l.Key != library.Key)).ToList();
var toRemove = existing.Filter(library => libraries.All(l => l.Key != library.Key)).ToList();
existing.AddRange(toAdd);
toRemove.ForEach(c => existing.Remove(c));
connectionParameters.PlexMediaSource.Libraries.AddRange(toAdd);
toRemove.ForEach(c => connectionParameters.PlexMediaSource.Libraries.Remove(c));
return _mediaSourceRepository.Update(connectionParameters.PlexMediaSource);
},

5
ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryById.cs

@ -6,19 +6,18 @@ namespace ErsatzTV.Application.Plex.Commands @@ -6,19 +6,18 @@ namespace ErsatzTV.Application.Plex.Commands
{
public interface ISynchronizePlexLibraryById : IRequest<Either<BaseError, string>>, IBackgroundServiceRequest
{
int PlexMediaSourceId { get; }
int PlexLibraryId { get; }
bool ForceScan { get; }
}
public record SynchronizePlexLibraryByIdIfNeeded
(int PlexMediaSourceId, int PlexLibraryId) : ISynchronizePlexLibraryById
(int PlexLibraryId) : ISynchronizePlexLibraryById
{
public bool ForceScan => false;
}
public record ForceSynchronizePlexLibraryById
(int PlexMediaSourceId, int PlexLibraryId) : ISynchronizePlexLibraryById
(int PlexLibraryId) : ISynchronizePlexLibraryById
{
public bool ForceScan => true;
}

12
ErsatzTV.Application/Plex/Commands/SynchronizePlexLibraryByIdHandler.cs

@ -82,7 +82,7 @@ namespace ErsatzTV.Application.Plex.Commands @@ -82,7 +82,7 @@ namespace ErsatzTV.Application.Plex.Commands
parameters.Library.Name);
}
// _entityLocker.UnlockMediaSource(parameters.MediaSource.Id);
_entityLocker.UnlockLibrary(parameters.Library.Id);
return Unit.Default;
}
@ -103,8 +103,10 @@ namespace ErsatzTV.Application.Plex.Commands @@ -103,8 +103,10 @@ namespace ErsatzTV.Application.Plex.Commands
private Task<Validation<BaseError, PlexMediaSource>> PlexMediaSourceMustExist(
ISynchronizePlexLibraryById request) =>
_mediaSourceRepository.GetPlex(request.PlexMediaSourceId)
.Map(v => v.ToValidation<BaseError>($"Plex media source {request.PlexMediaSourceId} does not exist."));
_mediaSourceRepository.GetPlexByLibraryId(request.PlexLibraryId)
.Map(
v => v.ToValidation<BaseError>(
$"Plex media source for library {request.PlexLibraryId} does not exist."));
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
PlexMediaSource plexMediaSource)
@ -134,9 +136,7 @@ namespace ErsatzTV.Application.Plex.Commands @@ -134,9 +136,7 @@ namespace ErsatzTV.Application.Plex.Commands
PlexLibrary Library,
bool ForceScan);
private record ConnectionParameters(
PlexMediaSource PlexMediaSource,
PlexConnection ActiveConnection)
private record ConnectionParameters(PlexMediaSource PlexMediaSource, PlexConnection ActiveConnection)
{
public PlexServerAuthToken PlexServerAuthToken { get; set; }
}

3
ErsatzTV.Application/Plex/Commands/UpdatePlexLibraryPreferencesHandler.cs

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
@ -20,7 +21,7 @@ namespace ErsatzTV.Application.Plex.Commands @@ -20,7 +21,7 @@ namespace ErsatzTV.Application.Plex.Commands
UpdatePlexLibraryPreferences request,
CancellationToken cancellationToken)
{
IEnumerable<int> toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id);
var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList();
await _mediaSourceRepository.DisablePlexLibrarySync(toDisable);
IEnumerable<int> toEnable = request.Preferences.Filter(p => p.ShouldSyncItems).Map(p => p.Id);

13
ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacements.cs

@ -0,0 +1,13 @@ @@ -0,0 +1,13 @@
using System.Collections.Generic;
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.Plex.Commands
{
public record UpdatePlexPathReplacements
(
int PlexMediaSourceId,
List<PlexPathReplacementItem> PathReplacements) : MediatR.IRequest<Either<BaseError, Unit>>;
public record PlexPathReplacementItem(int Id, string PlexPath, string LocalPath);
}

65
ErsatzTV.Application/Plex/Commands/UpdatePlexPathReplacementsHandler.cs

@ -0,0 +1,65 @@ @@ -0,0 +1,65 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.Plex.Commands
{
public class
UpdatePlexPathReplacementsHandler : MediatR.IRequestHandler<UpdatePlexPathReplacements, Either<BaseError, Unit>>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
public UpdatePlexPathReplacementsHandler(IMediaSourceRepository mediaSourceRepository) =>
_mediaSourceRepository = mediaSourceRepository;
public Task<Either<BaseError, Unit>> Handle(
UpdatePlexPathReplacements request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(pms => MergePathReplacements(request, pms))
.Bind(v => v.ToEitherAsync());
private Task<Unit> MergePathReplacements(UpdatePlexPathReplacements request, PlexMediaSource plexMediaSource)
{
plexMediaSource.PathReplacements ??= new List<PlexPathReplacement>();
var incoming = request.PathReplacements.Map(Project).ToList();
var toAdd = incoming.Filter(r => r.Id < 1).ToList();
var toRemove = plexMediaSource.PathReplacements.Filter(r => incoming.All(pr => pr.Id != r.Id)).ToList();
var toUpdate = incoming.Except(toAdd).ToList();
plexMediaSource.PathReplacements.AddRange(toAdd);
toRemove.ForEach(pr => plexMediaSource.PathReplacements.Remove(pr));
foreach (PlexPathReplacement pathReplacement in toUpdate)
{
Optional(plexMediaSource.PathReplacements.SingleOrDefault(pr => pr.Id == pathReplacement.Id))
.IfSome(
pr =>
{
pr.PlexPath = pathReplacement.PlexPath;
pr.LocalPath = pathReplacement.LocalPath;
});
}
return _mediaSourceRepository.Update(plexMediaSource).ToUnit();
}
private static PlexPathReplacement Project(PlexPathReplacementItem vm) =>
new() { Id = vm.Id, PlexPath = vm.PlexPath, LocalPath = vm.LocalPath };
private Task<Validation<BaseError, PlexMediaSource>> Validate(UpdatePlexPathReplacements request) =>
PlexMediaSourceMustExist(request);
private Task<Validation<BaseError, PlexMediaSource>> PlexMediaSourceMustExist(
UpdatePlexPathReplacements request) =>
_mediaSourceRepository.GetPlex(request.PlexMediaSourceId)
.Map(v => v.ToValidation<BaseError>($"Plex media source {request.PlexMediaSourceId} does not exist."));
}
}

3
ErsatzTV.Application/Plex/Mapper.cs

@ -14,5 +14,8 @@ namespace ErsatzTV.Application.Plex @@ -14,5 +14,8 @@ namespace ErsatzTV.Application.Plex
internal static PlexLibraryViewModel ProjectToViewModel(PlexLibrary library) =>
new(library.Id, library.Name, library.MediaKind, library.ShouldSyncItems);
internal static PlexPathReplacementViewModel ProjectToViewModel(PlexPathReplacement pathReplacement) =>
new(pathReplacement.Id, pathReplacement.PlexPath, pathReplacement.LocalPath);
}
}

6
ErsatzTV.Application/Plex/PlexConnectionParametersViewModel.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
using System;
namespace ErsatzTV.Application.Plex
{
public record PlexConnectionParametersViewModel(Uri Uri, string AuthToken);
}

4
ErsatzTV.Application/Plex/PlexPathReplacementViewModel.cs

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
namespace ErsatzTV.Application.Plex
{
public record PlexPathReplacementViewModel(int Id, string PlexPath, string LocalPath);
}

9
ErsatzTV.Application/Plex/Queries/GetPlexConnectionParameters.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Plex.Queries
{
public record GetPlexConnectionParameters
(int PlexMediaSourceId) : IRequest<Either<BaseError, PlexConnectionParametersViewModel>>;
}

94
ErsatzTV.Application/Plex/Queries/GetPlexConnectionParametersHandler.cs

@ -0,0 +1,94 @@ @@ -0,0 +1,94 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Plex;
using LanguageExt;
using MediatR;
using Microsoft.Extensions.Caching.Memory;
namespace ErsatzTV.Application.Plex.Queries
{
public class GetPlexConnectionParametersHandler : IRequestHandler<GetPlexConnectionParameters,
Either<BaseError, PlexConnectionParametersViewModel>>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMemoryCache _memoryCache;
private readonly IPlexSecretStore _plexSecretStore;
public GetPlexConnectionParametersHandler(
IMemoryCache memoryCache,
IMediaSourceRepository mediaSourceRepository,
IPlexSecretStore plexSecretStore)
{
_memoryCache = memoryCache;
_mediaSourceRepository = mediaSourceRepository;
_plexSecretStore = plexSecretStore;
}
public async Task<Either<BaseError, PlexConnectionParametersViewModel>> Handle(
GetPlexConnectionParameters request,
CancellationToken cancellationToken)
{
if (_memoryCache.TryGetValue(request, out PlexConnectionParametersViewModel parameters))
{
return parameters;
}
Either<BaseError, PlexConnectionParametersViewModel> maybeParameters =
await Validate(request)
.MapT(
cp => new PlexConnectionParametersViewModel(
new Uri(cp.ActiveConnection.Uri),
cp.PlexServerAuthToken.AuthToken))
.Map(v => v.ToEither<PlexConnectionParametersViewModel>());
return maybeParameters.Match(
p =>
{
_memoryCache.Set(request, p, TimeSpan.FromHours(1));
return maybeParameters;
},
error => error);
}
private Task<Validation<BaseError, ConnectionParameters>> Validate(GetPlexConnectionParameters request) =>
PlexMediaSourceMustExist(request)
.BindT(MediaSourceMustHaveActiveConnection)
.BindT(MediaSourceMustHaveToken);
private Task<Validation<BaseError, PlexMediaSource>> PlexMediaSourceMustExist(
GetPlexConnectionParameters request) =>
_mediaSourceRepository.GetPlex(request.PlexMediaSourceId)
.Map(
v => v.ToValidation<BaseError>(
$"Plex media source {request.PlexMediaSourceId} does not exist."));
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
PlexMediaSource plexMediaSource)
{
Option<PlexConnection> maybeConnection =
plexMediaSource.Connections.SingleOrDefault(c => c.IsActive);
return maybeConnection.Map(connection => new ConnectionParameters(plexMediaSource, connection))
.ToValidation<BaseError>("Plex media source requires an active connection");
}
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveToken(
ConnectionParameters connectionParameters)
{
Option<PlexServerAuthToken> maybeToken = await
_plexSecretStore.GetServerAuthToken(connectionParameters.PlexMediaSource.ClientIdentifier);
return maybeToken.Map(token => connectionParameters with { PlexServerAuthToken = token })
.ToValidation<BaseError>("Plex media source requires a token");
}
private record ConnectionParameters(PlexMediaSource PlexMediaSource, PlexConnection ActiveConnection)
{
public PlexServerAuthToken PlexServerAuthToken { get; set; }
}
}
}

8
ErsatzTV.Application/Plex/Queries/GetPlexPathReplacementsBySourceId.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.Plex.Queries
{
public record GetPlexPathReplacementsBySourceId
(int PlexMediaSourceId) : IRequest<List<PlexPathReplacementViewModel>>;
}

26
ErsatzTV.Application/Plex/Queries/GetPlexPathReplacementsBySourceIdHandler.cs

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static ErsatzTV.Application.Plex.Mapper;
namespace ErsatzTV.Application.Plex.Queries
{
public class GetPlexPathReplacementsBySourceIdHandler : IRequestHandler<GetPlexPathReplacementsBySourceId,
List<PlexPathReplacementViewModel>>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
public GetPlexPathReplacementsBySourceIdHandler(IMediaSourceRepository mediaSourceRepository) =>
_mediaSourceRepository = mediaSourceRepository;
public Task<List<PlexPathReplacementViewModel>> Handle(
GetPlexPathReplacementsBySourceId request,
CancellationToken cancellationToken) =>
_mediaSourceRepository.GetPlexPathReplacements(request.PlexMediaSourceId)
.Map(list => list.Map(ProjectToViewModel).ToList());
}
}

62
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -1,11 +1,15 @@ @@ -1,11 +1,15 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Application.Streaming.Queries
{
@ -13,17 +17,23 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -13,17 +17,23 @@ namespace ErsatzTV.Application.Streaming.Queries
GetPlayoutItemProcessByChannelNumberHandler : FFmpegProcessHandler<GetPlayoutItemProcessByChannelNumber>
{
private readonly FFmpegProcessService _ffmpegProcessService;
private readonly ILogger<GetPlayoutItemProcessByChannelNumberHandler> _logger;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IPlayoutRepository _playoutRepository;
public GetPlayoutItemProcessByChannelNumberHandler(
IChannelRepository channelRepository,
IConfigElementRepository configElementRepository,
IPlayoutRepository playoutRepository,
FFmpegProcessService ffmpegProcessService)
IMediaSourceRepository mediaSourceRepository,
FFmpegProcessService ffmpegProcessService,
ILogger<GetPlayoutItemProcessByChannelNumberHandler> logger)
: base(channelRepository, configElementRepository)
{
_playoutRepository = playoutRepository;
_mediaSourceRepository = mediaSourceRepository;
_ffmpegProcessService = ffmpegProcessService;
_logger = logger;
}
protected override async Task<Either<BaseError, Process>> GetProcess(
@ -33,9 +43,32 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -33,9 +43,32 @@ namespace ErsatzTV.Application.Streaming.Queries
{
DateTimeOffset now = DateTimeOffset.Now;
Option<PlayoutItem> maybePlayoutItem = await _playoutRepository.GetPlayoutItem(channel.Id, now);
return maybePlayoutItem.Match<Either<BaseError, Process>>(
playoutItem => _ffmpegProcessService.ForPlayoutItem(ffmpegPath, channel, playoutItem, now),
() =>
return await maybePlayoutItem.Match<Task<Either<BaseError, Process>>>(
async playoutItem =>
{
MediaVersion version = playoutItem.MediaItem switch
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(playoutItem))
};
MediaFile file = version.MediaFiles.Head();
string path = file.Path;
if (playoutItem.MediaItem is PlexMovie plexMovie)
{
path = await GetReplacementPlexPath(plexMovie.LibraryPathId, path);
}
return _ffmpegProcessService.ForPlayoutItem(
ffmpegPath,
channel,
version,
path,
playoutItem.StartOffset,
now);
},
async () =>
{
if (channel.FFmpegProfile.Transcode)
{
@ -46,5 +79,26 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -46,5 +79,26 @@ namespace ErsatzTV.Application.Streaming.Queries
$"Unable to locate playout item for channel {channel.Number}; offline image is unavailable because transcoding is disabled in ffmpeg profile '{channel.FFmpegProfile.Name}'");
});
}
private async Task<string> GetReplacementPlexPath(int libraryPathId, string path)
{
List<PlexPathReplacement> replacements =
await _mediaSourceRepository.GetPlexPathReplacementsByLibraryId(libraryPathId);
// TODO: this might barf mixing platforms (i.e. plex on linux, etv on windows)
Option<PlexPathReplacement> maybeReplacement = replacements
.SingleOrDefault(r => path.StartsWith(r.PlexPath + Path.DirectorySeparatorChar));
return maybeReplacement.Match(
replacement =>
{
string finalPath = path.Replace(replacement.PlexPath, replacement.LocalPath);
_logger.LogInformation(
"Replacing plex path {PlexPath} with {LocalPath} resulting in {FinalPath}",
replacement.PlexPath,
replacement.LocalPath,
finalPath);
return finalPath;
},
() => path);
}
}
}

7
ErsatzTV.Core.Tests/Metadata/FallbackMetadataProviderTests.cs

@ -10,6 +10,11 @@ namespace ErsatzTV.Core.Tests.Metadata @@ -10,6 +10,11 @@ namespace ErsatzTV.Core.Tests.Metadata
[TestFixture]
public class FallbackMetadataProviderTests
{
[SetUp]
public void SetUp() => _fallbackMetadataProvider = new FallbackMetadataProvider();
private FallbackMetadataProvider _fallbackMetadataProvider;
[Test]
[TestCase("Awesome Show - s01e02.mkv", "Awesome Show", 1, 2)]
[TestCase("Awesome Show - S01E02.mkv", "Awesome Show", 1, 2)]
@ -39,7 +44,7 @@ namespace ErsatzTV.Core.Tests.Metadata @@ -39,7 +44,7 @@ namespace ErsatzTV.Core.Tests.Metadata
2)]
public void GetFallbackMetadata_ShouldHandleVariousFormats(string path, string title, int season, int episode)
{
(EpisodeMetadata metadata, int episodeNumber) = FallbackMetadataProvider.GetFallbackMetadata(
(EpisodeMetadata metadata, int episodeNumber) = _fallbackMetadataProvider.GetFallbackMetadata(
new Episode
{
LibraryPath = new LibraryPath(),

8
ErsatzTV.Core/Domain/MediaItem/PlexMediaFile.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public class PlexMediaFile : MediaFile
{
public int PlexId { get; set; }
public string Key { get; set; }
}
}

12
ErsatzTV.Core/Domain/MediaItem/PlexMediaItemPart.cs

@ -1,12 +0,0 @@ @@ -1,12 +0,0 @@
namespace ErsatzTV.Core.Domain
{
public class PlexMediaItemPart
{
public int Id { get; set; }
public int PlexId { get; set; }
public string Key { get; set; }
public int Duration { get; set; }
public string File { get; set; }
public int Size { get; set; }
}
}

1
ErsatzTV.Core/Domain/MediaItem/PlexMovie.cs

@ -3,6 +3,5 @@ @@ -3,6 +3,5 @@
public class PlexMovie : Movie
{
public string Key { get; set; }
public PlexMediaItemPart Part { get; set; }
}
}

1
ErsatzTV.Core/Domain/MediaSource/PlexMediaSource.cs

@ -10,5 +10,6 @@ namespace ErsatzTV.Core.Domain @@ -10,5 +10,6 @@ namespace ErsatzTV.Core.Domain
// public bool IsOwned { get; set; }
public List<PlexConnection> Connections { get; set; }
public List<PlexPathReplacement> PathReplacements { get; set; }
}
}

11
ErsatzTV.Core/Domain/MediaSource/PlexPathReplacement.cs

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
namespace ErsatzTV.Core.Domain
{
public class PlexPathReplacement
{
public int Id { get; set; }
public string PlexPath { get; set; }
public string LocalPath { get; set; }
public int PlexMediaSourceId { get; set; }
public PlexMediaSource PlexMediaSource { get; set; }
}
}

19
ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs

@ -15,32 +15,25 @@ namespace ErsatzTV.Core.FFmpeg @@ -15,32 +15,25 @@ namespace ErsatzTV.Core.FFmpeg
public Process ForPlayoutItem(
string ffmpegPath,
Channel channel,
PlayoutItem item,
MediaVersion version,
string path,
DateTimeOffset start,
DateTimeOffset now)
{
MediaVersion version = item.MediaItem switch
{
Movie m => m.MediaVersions.Head(),
Episode e => e.MediaVersions.Head(),
_ => throw new ArgumentOutOfRangeException(nameof(item))
};
FFmpegPlaybackSettings playbackSettings = _playbackSettingsCalculator.CalculateSettings(
channel.StreamingMode,
channel.FFmpegProfile,
version,
item.StartOffset,
start,
now);
MediaFile file = version.MediaFiles.Head();
FFmpegProcessBuilder builder = new FFmpegProcessBuilder(ffmpegPath)
.WithThreads(playbackSettings.ThreadCount)
.WithQuiet()
.WithFormatFlags(playbackSettings.FormatFlags)
.WithRealtimeOutput(playbackSettings.RealtimeOutput)
.WithSeek(playbackSettings.StreamSeek)
.WithInput(file.Path);
.WithInput(path);
playbackSettings.ScaledSize.Match(
scaledSize =>
@ -86,7 +79,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -86,7 +79,7 @@ namespace ErsatzTV.Core.FFmpeg
return builder.WithPlaybackArgs(playbackSettings)
.WithMetadata(channel)
.WithFormat("mpegts")
.WithDuration(item.Start + version.Duration - now)
.WithDuration(start + version.Duration - now)
.WithPipe()
.Build();
}

6
ErsatzTV.Core/Interfaces/Metadata/IFallbackMetadataProvider.cs

@ -1,9 +1,13 @@ @@ -1,9 +1,13 @@
using ErsatzTV.Core.Domain;
using System;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface IFallbackMetadataProvider
{
ShowMetadata GetFallbackMetadataForShow(string showFolder);
Tuple<EpisodeMetadata, int> GetFallbackMetadata(Episode episode);
MovieMetadata GetFallbackMetadata(Movie movie);
string GetSortTitle(string title);
}
}

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

@ -10,7 +10,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -10,7 +10,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<LibraryPath> Add(LibraryPath libraryPath);
Task<Option<Library>> Get(int libraryId);
Task<Option<LocalLibrary>> GetLocal(int libraryId);
Task<List<LocalLibrary>> GetAllLocal();
Task<List<Library>> GetAll();
Task<Unit> UpdateLastScan(Library library);
Task<List<LibraryPath>> GetLocalPaths(int libraryId);
Task<Option<LibraryPath>> GetPath(int libraryPathId);

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

@ -12,15 +12,18 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -12,15 +12,18 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<List<MediaSource>> GetAll();
Task<List<PlexMediaSource>> GetAllPlex();
Task<List<PlexLibrary>> GetPlexLibraries(int plexMediaSourceId);
Task<List<PlexPathReplacement>> GetPlexPathReplacements(int plexMediaSourceId);
Task<Option<PlexLibrary>> GetPlexLibrary(int plexLibraryId);
Task<Option<MediaSource>> Get(int id);
Task<Option<PlexMediaSource>> GetPlex(int id);
Task<Option<PlexMediaSource>> GetPlexByLibraryId(int plexLibraryId);
Task<List<PlexPathReplacement>> GetPlexPathReplacementsByLibraryId(int plexLibraryPathId);
Task<int> CountMediaItems(int id);
Task Update(LocalMediaSource localMediaSource);
Task Update(PlexMediaSource plexMediaSource);
Task Update(PlexLibrary plexMediaSourceLibrary);
Task Delete(int id);
Task DisablePlexLibrarySync(IEnumerable<int> libraryIds);
Task DisablePlexLibrarySync(List<int> libraryIds);
Task EnablePlexLibrarySync(IEnumerable<int> libraryIds);
}
}

11
ErsatzTV.Core/Interfaces/Repositories/ISearchRepository.cs

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface ISearchRepository
{
public Task<List<MediaItem>> SearchMediaItems(string query);
}
}

2
ErsatzTV.Core/Iptv/ChannelGuide.cs

@ -108,7 +108,7 @@ namespace ErsatzTV.Core.Iptv @@ -108,7 +108,7 @@ namespace ErsatzTV.Core.Iptv
xml.WriteAttributeString("system", "xmltv_ns");
xml.WriteString($"{s - 1}.{e - 1}.0/1");
xml.WriteEndElement(); // episode-num
xml.WriteStartElement("episode-num");
xml.WriteAttributeString("system", "onscreen");
xml.WriteString($"S{s:00}E{e:00}");

32
ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs

@ -17,7 +17,7 @@ namespace ErsatzTV.Core.Metadata @@ -17,7 +17,7 @@ namespace ErsatzTV.Core.Metadata
return GetTelevisionShowMetadata(fileName, metadata);
}
public static Tuple<EpisodeMetadata, int> GetFallbackMetadata(Episode episode)
public Tuple<EpisodeMetadata, int> GetFallbackMetadata(Episode episode)
{
string path = episode.MediaVersions.Head().MediaFiles.Head().Path;
string fileName = Path.GetFileName(path);
@ -26,7 +26,7 @@ namespace ErsatzTV.Core.Metadata @@ -26,7 +26,7 @@ namespace ErsatzTV.Core.Metadata
return fileName != null ? GetEpisodeMetadata(fileName, metadata) : Tuple(metadata, 0);
}
public static MovieMetadata GetFallbackMetadata(Movie movie)
public MovieMetadata GetFallbackMetadata(Movie movie)
{
string path = movie.MediaVersions.Head().MediaFiles.Head().Path;
string fileName = Path.GetFileName(path);
@ -34,8 +34,28 @@ namespace ErsatzTV.Core.Metadata @@ -34,8 +34,28 @@ namespace ErsatzTV.Core.Metadata
return fileName != null ? GetMovieMetadata(fileName, metadata) : metadata;
}
public string GetSortTitle(string title)
{
if (string.IsNullOrWhiteSpace(title))
{
return title;
}
if (title.StartsWith("the ", StringComparison.OrdinalIgnoreCase))
{
return title.Substring(4);
}
if (title.StartsWith("Æ"))
{
return title.Replace("Æ", "E");
}
return title;
}
private static Tuple<EpisodeMetadata, int> GetEpisodeMetadata(string fileName, EpisodeMetadata metadata)
private Tuple<EpisodeMetadata, int> GetEpisodeMetadata(string fileName, EpisodeMetadata metadata)
{
try
{
@ -55,7 +75,7 @@ namespace ErsatzTV.Core.Metadata @@ -55,7 +75,7 @@ namespace ErsatzTV.Core.Metadata
return Tuple(metadata, 0);
}
private static MovieMetadata GetMovieMetadata(string fileName, MovieMetadata metadata)
private MovieMetadata GetMovieMetadata(string fileName, MovieMetadata metadata)
{
try
{
@ -76,9 +96,7 @@ namespace ErsatzTV.Core.Metadata @@ -76,9 +96,7 @@ namespace ErsatzTV.Core.Metadata
return metadata;
}
private static ShowMetadata GetTelevisionShowMetadata(
string fileName,
ShowMetadata metadata)
private ShowMetadata GetTelevisionShowMetadata(string fileName, ShowMetadata metadata)
{
try
{

42
ErsatzTV.Core/Metadata/LocalMetadataProvider.cs

@ -51,7 +51,7 @@ namespace ErsatzTV.Core.Metadata @@ -51,7 +51,7 @@ namespace ErsatzTV.Core.Metadata
() =>
{
ShowMetadata metadata = _fallbackMetadataProvider.GetFallbackMetadataForShow(showFolder);
metadata.SortTitle = GetSortTitle(metadata.Title);
metadata.SortTitle = _fallbackMetadataProvider.GetSortTitle(metadata.Title);
return metadata;
});
}
@ -73,9 +73,9 @@ namespace ErsatzTV.Core.Metadata @@ -73,9 +73,9 @@ namespace ErsatzTV.Core.Metadata
public Task<Unit> RefreshFallbackMetadata(MediaItem mediaItem) =>
mediaItem switch
{
Episode e => ApplyMetadataUpdate(e, FallbackMetadataProvider.GetFallbackMetadata(e))
Episode e => ApplyMetadataUpdate(e, _fallbackMetadataProvider.GetFallbackMetadata(e))
.ToUnit(),
Movie m => ApplyMetadataUpdate(m, FallbackMetadataProvider.GetFallbackMetadata(m)).ToUnit(),
Movie m => ApplyMetadataUpdate(m, _fallbackMetadataProvider.GetFallbackMetadata(m)).ToUnit(),
_ => Task.FromResult(Unit.Default)
};
@ -100,11 +100,11 @@ namespace ErsatzTV.Core.Metadata @@ -100,11 +100,11 @@ namespace ErsatzTV.Core.Metadata
existing.OriginalTitle = metadata.OriginalTitle;
existing.ReleaseDate = metadata.ReleaseDate;
existing.Year = metadata.Year;
existing.SortTitle = metadata.SortTitle ?? GetSortTitle(metadata.Title);
existing.SortTitle = metadata.SortTitle ?? _fallbackMetadataProvider.GetSortTitle(metadata.Title);
},
() =>
{
metadata.SortTitle ??= GetSortTitle(metadata.Title);
metadata.SortTitle ??= _fallbackMetadataProvider.GetSortTitle(metadata.Title);
episode.EpisodeMetadata = new List<EpisodeMetadata> { metadata };
});
@ -126,11 +126,11 @@ namespace ErsatzTV.Core.Metadata @@ -126,11 +126,11 @@ namespace ErsatzTV.Core.Metadata
existing.OriginalTitle = metadata.OriginalTitle;
existing.ReleaseDate = metadata.ReleaseDate;
existing.Year = metadata.Year;
existing.SortTitle = metadata.SortTitle ?? GetSortTitle(metadata.Title);
existing.SortTitle = metadata.SortTitle ?? _fallbackMetadataProvider.GetSortTitle(metadata.Title);
},
() =>
{
metadata.SortTitle ??= GetSortTitle(metadata.Title);
metadata.SortTitle ??= _fallbackMetadataProvider.GetSortTitle(metadata.Title);
movie.MovieMetadata = new List<MovieMetadata> { metadata };
});
@ -152,11 +152,11 @@ namespace ErsatzTV.Core.Metadata @@ -152,11 +152,11 @@ namespace ErsatzTV.Core.Metadata
existing.OriginalTitle = metadata.OriginalTitle;
existing.ReleaseDate = metadata.ReleaseDate;
existing.Year = metadata.Year;
existing.SortTitle = metadata.SortTitle ?? GetSortTitle(metadata.Title);
existing.SortTitle = metadata.SortTitle ?? _fallbackMetadataProvider.GetSortTitle(metadata.Title);
},
() =>
{
metadata.SortTitle ??= GetSortTitle(metadata.Title);
metadata.SortTitle ??= _fallbackMetadataProvider.GetSortTitle(metadata.Title);
show.ShowMetadata = new List<ShowMetadata> { metadata };
});
@ -247,7 +247,7 @@ namespace ErsatzTV.Core.Metadata @@ -247,7 +247,7 @@ namespace ErsatzTV.Core.Metadata
catch (Exception ex)
{
_logger.LogInformation(ex, "Failed to read TV episode nfo metadata from {Path}", nfoFileName);
return FallbackMetadataProvider.GetFallbackMetadata(episode);
return _fallbackMetadataProvider.GetFallbackMetadata(episode);
}
}
@ -274,7 +274,7 @@ namespace ErsatzTV.Core.Metadata @@ -274,7 +274,7 @@ namespace ErsatzTV.Core.Metadata
catch (Exception ex)
{
_logger.LogInformation(ex, "Failed to read Movie nfo metadata from {Path}", nfoFileName);
return FallbackMetadataProvider.GetFallbackMetadata(mediaItem);
return _fallbackMetadataProvider.GetFallbackMetadata(mediaItem);
}
}
@ -293,26 +293,6 @@ namespace ErsatzTV.Core.Metadata @@ -293,26 +293,6 @@ namespace ErsatzTV.Core.Metadata
return null;
}
private static string GetSortTitle(string title)
{
if (string.IsNullOrWhiteSpace(title))
{
return title;
}
if (title.StartsWith("the ", StringComparison.OrdinalIgnoreCase))
{
return title.Substring(4);
}
if (title.StartsWith("Æ"))
{
return title.Replace("Æ", "E");
}
return title;
}
[XmlRoot("movie")]
public class MovieNfo
{

20
ErsatzTV.Core/Plex/PlexMediaEntry.cs

@ -1,20 +0,0 @@ @@ -1,20 +0,0 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Plex
{
public class PlexMediaEntry
{
public int Id { get; set; }
public int Duration { get; set; }
public int Bitrate { get; set; }
public int Width { get; set; }
public int Height { get; set; }
public double AspectRatio { get; set; }
public int AudioChannels { get; set; }
public string AudioCodec { get; set; }
public string VideoCodec { get; set; }
public string Container { get; set; }
public string VideoFrameRate { get; set; }
public List<PlexPartEntry> Part { get; set; }
}
}

19
ErsatzTV.Core/Plex/PlexMetadataEntry.cs

@ -1,19 +0,0 @@ @@ -1,19 +0,0 @@
using System.Collections.Generic;
namespace ErsatzTV.Core.Plex
{
public class PlexMetadataEntry
{
public string Key { get; set; }
public string Title { get; set; }
public string Summary { get; set; }
public int Year { get; set; }
public string Tagline { get; set; }
public string Thumb { get; set; }
public string Art { get; set; }
public string OriginallyAvailableAt { get; set; }
public int AddedAt { get; set; }
public int UpdatedAt { get; set; }
public List<PlexMediaEntry> Media { get; set; }
}
}

11
ErsatzTV.Core/Plex/PlexPartEntry.cs

@ -1,11 +0,0 @@ @@ -1,11 +0,0 @@
namespace ErsatzTV.Core.Plex
{
public class PlexPartEntry
{
public int Id { get; set; }
public string Key { get; set; }
public int Duration { get; set; }
public string File { get; set; }
public int Size { get; set; }
}
}

11
ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexMediaFileConfiguration.cs

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class PlexMediaFileConfiguration : IEntityTypeConfiguration<PlexMediaFile>
{
public void Configure(EntityTypeBuilder<PlexMediaFile> builder) => builder.ToTable("PlexMediaFile");
}
}

2
ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexMovieMediaItemConfiguration.cs → ErsatzTV.Infrastructure/Data/Configurations/MediaItem/PlexMovieConfiguration.cs

@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; @@ -4,7 +4,7 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class PlexMovieMediaItemConfiguration : IEntityTypeConfiguration<PlexMovie>
public class PlexMovieConfiguration : IEntityTypeConfiguration<PlexMovie>
{
public void Configure(EntityTypeBuilder<PlexMovie> builder) => builder.ToTable("PlexMovie");
}

5
ErsatzTV.Infrastructure/Data/Configurations/MediaSource/PlexMediaSourceConfiguration.cs

@ -14,6 +14,11 @@ namespace ErsatzTV.Infrastructure.Data.Configurations @@ -14,6 +14,11 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
.WithOne(c => c.PlexMediaSource)
.HasForeignKey(c => c.PlexMediaSourceId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(s => s.PathReplacements)
.WithOne(r => r.PlexMediaSource)
.HasForeignKey(r => r.PlexMediaSourceId)
.OnDelete(DeleteBehavior.Cascade);
}
}
}

11
ErsatzTV.Infrastructure/Data/Configurations/MediaSource/PlexPathReplacementConfiguration.cs

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class PlexPathReplacementConfiguration : IEntityTypeConfiguration<PlexPathReplacement>
{
public void Configure(EntityTypeBuilder<PlexPathReplacement> builder) => builder.ToTable("PlexPathReplacement");
}
}

4
ErsatzTV.Infrastructure/Data/Repositories/LibraryRepository.cs

@ -49,10 +49,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -49,10 +49,10 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.Map(Optional);
}
public Task<List<LocalLibrary>> GetAllLocal()
public Task<List<Library>> GetAll()
{
using TvContext context = _dbContextFactory.CreateDbContext();
return context.LocalLibraries.ToListAsync();
return context.Libraries.ToListAsync();
}
public Task<Unit> UpdateLastScan(Library library) => _dbConnection.ExecuteAsync(

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

@ -68,10 +68,19 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -68,10 +68,19 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.ToListAsync();
}
public Task<List<PlexPathReplacement>> GetPlexPathReplacements(int plexMediaSourceId)
{
using TvContext context = _dbContextFactory.CreateDbContext();
return context.PlexPathReplacements
.Filter(r => r.PlexMediaSourceId == plexMediaSourceId)
.ToListAsync();
}
public Task<Option<PlexLibrary>> GetPlexLibrary(int plexLibraryId)
{
using TvContext context = _dbContextFactory.CreateDbContext();
return context.PlexLibraries
.Include(l => l.Paths)
.OrderBy(l => l.Id) // https://github.com/dotnet/efcore/issues/22579
.SingleOrDefaultAsync(l => l.Id == plexLibraryId)
.Map(Optional);
@ -92,11 +101,37 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -92,11 +101,37 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return context.PlexMediaSources
.Include(p => p.Connections)
.Include(p => p.Libraries)
.Include(p => p.PathReplacements)
.OrderBy(s => s.Id) // https://github.com/dotnet/efcore/issues/22579
.SingleOrDefaultAsync(p => p.Id == id)
.Map(Optional);
}
public Task<Option<PlexMediaSource>> GetPlexByLibraryId(int plexLibraryId)
{
using TvContext context = _dbContextFactory.CreateDbContext();
return context.PlexMediaSources
.Include(p => p.Connections)
.Include(p => p.Libraries)
.Where(p => p.Libraries.Any(l => l.Id == plexLibraryId))
.SingleOrDefaultAsync()
.Map(Optional);
}
public Task<List<PlexPathReplacement>> GetPlexPathReplacementsByLibraryId(int plexLibraryPathId)
{
using TvContext context = _dbContextFactory.CreateDbContext();
return context.PlexPathReplacements
.FromSqlRaw(
@"select ppr.* from LibraryPath lp
inner join PlexLibrary pl ON pl.Id = lp.LibraryId
inner join Library l ON l.Id = pl.Id
inner join PlexPathReplacement ppr on ppr.PlexMediaSourceId = l.MediaSourceId
where lp.Id = {0}",
plexLibraryPathId)
.ToListAsync();
}
public Task<int> CountMediaItems(int id)
{
using TvContext context = _dbContextFactory.CreateDbContext();
@ -133,14 +168,29 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -133,14 +168,29 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await context.SaveChangesAsync();
}
public Task DisablePlexLibrarySync(IEnumerable<int> libraryIds) =>
_dbConnection.ExecuteAsync(
"UPDATE PlexMediaSourceLibraries SET ShouldSyncItems = 0 WHERE Id in @ids",
public async Task DisablePlexLibrarySync(List<int> libraryIds)
{
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 });
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN PlexMovie pm ON pm.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids)",
new { ids = libraryIds });
}
public Task EnablePlexLibrarySync(IEnumerable<int> libraryIds) =>
_dbConnection.ExecuteAsync(
"UPDATE PlexMediaSourceLibraries SET ShouldSyncItems = 1 WHERE Id in @ids",
"UPDATE PlexLibrary SET ShouldSyncItems = 1 WHERE Id IN @ids",
new { ids = libraryIds });
}
}

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

@ -58,15 +58,18 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -58,15 +58,18 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public async Task<Either<BaseError, PlexMovie>> GetOrAdd(PlexLibrary library, PlexMovie item)
{
Option<PlexMovie> maybeExisting = await _dbContext.PlexMovieMediaItems
await using TvContext context = _dbContextFactory.CreateDbContext();
Option<PlexMovie> maybeExisting = await context.PlexMovies
.AsNoTracking()
.Include(i => i.MovieMetadata)
.Include(i => i.Part)
.Include(i => i.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.OrderBy(i => i.Key)
.SingleOrDefaultAsync(i => i.Key == item.Key);
return await maybeExisting.Match(
plexMovie => Right<BaseError, PlexMovie>(plexMovie).AsTask(),
async () => await AddPlexMovie(library, item));
async () => await AddPlexMovie(context, library, item));
}
public async Task<bool> Update(Movie movie)
@ -119,17 +122,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -119,17 +122,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
}
}
private async Task<Either<BaseError, PlexMovie>> AddPlexMovie(
PlexLibrary library,
PlexMovie item)
private async Task<Either<BaseError, PlexMovie>> AddPlexMovie(TvContext context, PlexLibrary library, PlexMovie item)
{
try
{
item.LibraryPathId = library.Paths.Head().Id;
await _dbContext.PlexMovieMediaItems.AddAsync(item);
await _dbContext.SaveChangesAsync();
await _dbContext.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
await context.PlexMovies.AddAsync(item);
await context.SaveChangesAsync();
await context.Entry(item).Reference(i => i.LibraryPath).LoadAsync();
return item;
}
catch (Exception ex)

48
ErsatzTV.Infrastructure/Data/Repositories/SearchRepository.cs

@ -0,0 +1,48 @@ @@ -0,0 +1,48 @@
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
using Dapper;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories
{
public class SearchRepository : ISearchRepository
{
private readonly IDbConnection _dbConnection;
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public SearchRepository(IDbContextFactory<TvContext> dbContextFactory, IDbConnection dbConnection)
{
_dbContextFactory = dbContextFactory;
_dbConnection = dbConnection;
}
public async Task<List<MediaItem>> SearchMediaItems(string query)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT M.Id FROM Movie M
INNER JOIN MovieMetadata MM on M.Id = MM.MovieId
WHERE MM.Title LIKE @Query
UNION
SELECT S.Id FROM Show S
INNER JOIN ShowMetadata SM on S.Id = SM.ShowId
WHERE SM.Title LIKE @Query",
new { Query = $"%{query}%" })
.Map(results => results.ToList());
await using TvContext context = _dbContextFactory.CreateDbContext();
return await context.MediaItems
.Filter(m => ids.Contains(m.Id))
.Include(m => (m as Movie).MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Include(m => (m as Show).ShowMetadata)
.ThenInclude(mm => mm.Artwork)
.OfType<MediaItem>()
.ToListAsync();
}
}
}

3
ErsatzTV.Infrastructure/Data/TvContext.cs

@ -21,6 +21,7 @@ namespace ErsatzTV.Infrastructure.Data @@ -21,6 +21,7 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<LocalLibrary> LocalLibraries { get; set; }
public DbSet<LibraryPath> LibraryPaths { get; set; }
public DbSet<PlexLibrary> PlexLibraries { get; set; }
public DbSet<PlexPathReplacement> PlexPathReplacements { get; set; }
public DbSet<MediaItem> MediaItems { get; set; }
public DbSet<MediaVersion> MediaVersions { get; set; }
public DbSet<MediaFile> MediaFiles { get; set; }
@ -31,7 +32,7 @@ namespace ErsatzTV.Infrastructure.Data @@ -31,7 +32,7 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<Season> Seasons { get; set; }
public DbSet<Episode> Episodes { get; set; }
public DbSet<EpisodeMetadata> EpisodeMetadata { get; set; }
public DbSet<PlexMovie> PlexMovieMediaItems { get; set; }
public DbSet<PlexMovie> PlexMovies { get; set; }
public DbSet<Collection> Collections { get; set; }
public DbSet<ProgramSchedule> ProgramSchedules { get; set; }
public DbSet<Playout> Playouts { get; set; }

1456
ErsatzTV.Infrastructure/Migrations/20210228210815_Add_PlexMediaFile.Designer.cs generated

File diff suppressed because it is too large Load Diff

84
ErsatzTV.Infrastructure/Migrations/20210228210815_Add_PlexMediaFile.cs

@ -0,0 +1,84 @@ @@ -0,0 +1,84 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_PlexMediaFile : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
"FK_PlexMovie_PlexMediaItemPart_PartId",
"PlexMovie");
migrationBuilder.DropTable(
"PlexMediaItemPart");
migrationBuilder.DropIndex(
"IX_PlexMovie_PartId",
"PlexMovie");
migrationBuilder.DropColumn(
"PartId",
"PlexMovie");
migrationBuilder.CreateTable(
"PlexMediaFile",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
PlexId = table.Column<int>("INTEGER", nullable: false),
Key = table.Column<string>("TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_PlexMediaFile", x => x.Id);
table.ForeignKey(
"FK_PlexMediaFile_MediaFile_Id",
x => x.Id,
"MediaFile",
"Id",
onDelete: ReferentialAction.Cascade);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
"PlexMediaFile");
migrationBuilder.AddColumn<int>(
"PartId",
"PlexMovie",
"INTEGER",
nullable: true);
migrationBuilder.CreateTable(
"PlexMediaItemPart",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Duration = table.Column<int>("INTEGER", nullable: false),
File = table.Column<string>("TEXT", nullable: true),
Key = table.Column<string>("TEXT", nullable: true),
PlexId = table.Column<int>("INTEGER", nullable: false),
Size = table.Column<int>("INTEGER", nullable: false)
},
constraints: table => { table.PrimaryKey("PK_PlexMediaItemPart", x => x.Id); });
migrationBuilder.CreateIndex(
"IX_PlexMovie_PartId",
"PlexMovie",
"PartId");
migrationBuilder.AddForeignKey(
"FK_PlexMovie_PlexMediaItemPart_PartId",
"PlexMovie",
"PartId",
"PlexMediaItemPart",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
}
}
}

1491
ErsatzTV.Infrastructure/Migrations/20210301001541_Add_PlexPathReplacement.Designer.cs generated

File diff suppressed because it is too large Load Diff

40
ErsatzTV.Infrastructure/Migrations/20210301001541_Add_PlexPathReplacement.cs

@ -0,0 +1,40 @@ @@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_PlexPathReplacement : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
"PlexPathReplacement",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
PlexPath = table.Column<string>("TEXT", nullable: true),
LocalPath = table.Column<string>("TEXT", nullable: true),
PlexMediaSourceId = table.Column<int>("INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PlexPathReplacement", x => x.Id);
table.ForeignKey(
"FK_PlexPathReplacement_PlexMediaSource_PlexMediaSourceId",
x => x.PlexMediaSourceId,
"PlexMediaSource",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
"IX_PlexPathReplacement_PlexMediaSourceId",
"PlexPathReplacement",
"PlexMediaSourceId");
}
protected override void Down(MigrationBuilder migrationBuilder) =>
migrationBuilder.DropTable(
"PlexPathReplacement");
}
}

438
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -1,14 +1,14 @@ @@ -1,14 +1,14 @@
// <auto-generated />
using System;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace ErsatzTV.Infrastructure.Migrations
{
[DbContext(typeof(TvContext))]
partial class TvContextModelSnapshot : ModelSnapshot
internal class TvContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
@ -16,7 +16,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -16,7 +16,9 @@ namespace ErsatzTV.Infrastructure.Migrations
modelBuilder
.HasAnnotation("ProductVersion", "5.0.3");
modelBuilder.Entity("ErsatzTV.Core.Domain.Artwork", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Artwork",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@ -64,7 +66,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -64,7 +66,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("Artwork");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Channel",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@ -95,7 +99,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -95,7 +99,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("Channel");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Collection",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@ -109,7 +115,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -109,7 +115,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("Collection");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.CollectionItem", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.CollectionItem",
b =>
{
b.Property<int>("CollectionId")
.HasColumnType("INTEGER");
@ -124,7 +132,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -124,7 +132,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("CollectionItem");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ConfigElement", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ConfigElement",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@ -144,7 +154,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -144,7 +154,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("ConfigElement");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.EpisodeMetadata", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.EpisodeMetadata",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@ -193,7 +205,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -193,7 +205,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("EpisodeMetadata");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.FFmpegProfile",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@ -257,7 +271,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -257,7 +271,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("FFmpegProfile");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Library",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@ -282,7 +298,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -282,7 +298,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("Library");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryPath", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.LibraryPath",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@ -301,7 +319,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -301,7 +319,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("LibraryPath");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaFile", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MediaFile",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@ -323,7 +343,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -323,7 +343,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("MediaFile");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MediaItem",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@ -339,7 +361,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -339,7 +361,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("MediaItem");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaSource", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MediaSource",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@ -350,7 +374,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -350,7 +374,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("MediaSource");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaVersion", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MediaVersion",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@ -404,7 +430,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -404,7 +430,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("MediaVersion");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MovieMetadata",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@ -453,7 +481,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -453,7 +481,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("MovieMetadata");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Playout",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@ -477,7 +507,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -477,7 +507,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("Playout");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlayoutItem",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@ -504,7 +536,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -504,7 +536,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("PlayoutItem");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@ -538,7 +572,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -538,7 +572,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("PlayoutProgramScheduleAnchor");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexConnection", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexConnection",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@ -560,33 +596,33 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -560,33 +596,33 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("PlexConnection");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaItemPart", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexPathReplacement",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Duration")
.HasColumnType("INTEGER");
b.Property<string>("File")
b.Property<string>("LocalPath")
.HasColumnType("TEXT");
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<int>("PlexId")
b.Property<int>("PlexMediaSourceId")
.HasColumnType("INTEGER");
b.Property<int>("Size")
.HasColumnType("INTEGER");
b.Property<string>("PlexPath")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("PlexMediaItemPart");
b.HasIndex("PlexMediaSourceId");
b.ToTable("PlexPathReplacement");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ProgramSchedule",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@ -606,7 +642,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -606,7 +642,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("ProgramSchedule");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ProgramScheduleItem",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@ -641,7 +679,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -641,7 +679,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("ProgramScheduleItem");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Resolution", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Resolution",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@ -661,7 +701,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -661,7 +701,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("Resolution");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.SeasonMetadata", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.SeasonMetadata",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@ -704,7 +746,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -704,7 +746,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("SeasonMetadata");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ShowMetadata", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ShowMetadata",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
@ -753,14 +797,18 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -753,14 +797,18 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("ShowMetadata");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.LocalLibrary", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.LocalLibrary",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.Library");
b.ToTable("LocalLibrary");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexLibrary", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexLibrary",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.Library");
@ -773,7 +821,24 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -773,7 +821,24 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("PlexLibrary");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Episode", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexMediaFile",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaFile");
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<int>("PlexId")
.HasColumnType("INTEGER");
b.ToTable("PlexMediaFile");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Episode",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
@ -788,14 +853,18 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -788,14 +853,18 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("Episode");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Movie", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Movie",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
b.ToTable("Movie");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Season",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
@ -810,21 +879,27 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -810,21 +879,27 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("Season");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Show", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Show",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
b.ToTable("Show");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.LocalMediaSource",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaSource");
b.ToTable("LocalMediaSource");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexMediaSource",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaSource");
@ -840,7 +915,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -840,7 +915,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("PlexMediaSource");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ProgramScheduleItemDuration",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
@ -853,14 +930,18 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -853,14 +930,18 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("ProgramScheduleDurationItem");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ProgramScheduleItemFlood",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.ToTable("ProgramScheduleFloodItem");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ProgramScheduleItemMultiple",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
@ -870,29 +951,30 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -870,29 +951,30 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("ProgramScheduleMultipleItem");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ProgramScheduleItemOne",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.ToTable("ProgramScheduleOneItem");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMovie", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexMovie",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.Movie");
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<int?>("PartId")
.HasColumnType("INTEGER");
b.HasIndex("PartId");
b.ToTable("PlexMovie");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Artwork", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Artwork",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.Channel", null)
.WithMany("Artwork")
@ -920,7 +1002,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -920,7 +1002,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Channel",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.FFmpegProfile", "FFmpegProfile")
.WithMany()
@ -931,7 +1015,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -931,7 +1015,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("FFmpegProfile");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.CollectionItem", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.CollectionItem",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.Collection", "Collection")
.WithMany("CollectionItems")
@ -950,7 +1036,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -950,7 +1036,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("MediaItem");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.EpisodeMetadata", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.EpisodeMetadata",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.Episode", "Episode")
.WithMany("EpisodeMetadata")
@ -961,7 +1049,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -961,7 +1049,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Episode");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.FFmpegProfile",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.Resolution", "Resolution")
.WithMany()
@ -972,7 +1062,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -972,7 +1062,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Resolution");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Library",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaSource", "MediaSource")
.WithMany("Libraries")
@ -983,7 +1075,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -983,7 +1075,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("MediaSource");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryPath", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.LibraryPath",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.Library", "Library")
.WithMany("Paths")
@ -994,7 +1088,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -994,7 +1088,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Library");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaFile", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MediaFile",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaVersion", "MediaVersion")
.WithMany("MediaFiles")
@ -1005,7 +1101,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1005,7 +1101,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("MediaVersion");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MediaItem",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.LibraryPath", "LibraryPath")
.WithMany("MediaItems")
@ -1016,7 +1114,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1016,7 +1114,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("LibraryPath");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaVersion", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MediaVersion",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.Episode", null)
.WithMany("MediaVersions")
@ -1029,7 +1129,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1029,7 +1129,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MovieMetadata",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.Movie", "Movie")
.WithMany("MovieMetadata")
@ -1040,7 +1142,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1040,7 +1142,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Movie");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Playout",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.Channel", "Channel")
.WithMany("Playouts")
@ -1054,7 +1158,10 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1054,7 +1158,10 @@ namespace ErsatzTV.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("ErsatzTV.Core.Domain.PlayoutAnchor", "Anchor", b1 =>
b.OwnsOne(
"ErsatzTV.Core.Domain.PlayoutAnchor",
"Anchor",
b1 =>
{
b1.Property<int>("PlayoutId")
.HasColumnType("INTEGER");
@ -1090,7 +1197,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1090,7 +1197,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("ProgramSchedule");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlayoutItem",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem")
.WithMany()
@ -1109,7 +1218,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1109,7 +1218,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Playout");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.Collection", "Collection")
.WithMany()
@ -1133,7 +1244,10 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1133,7 +1244,10 @@ namespace ErsatzTV.Infrastructure.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("ErsatzTV.Core.Domain.CollectionEnumeratorState", "EnumeratorState", b1 =>
b.OwnsOne(
"ErsatzTV.Core.Domain.CollectionEnumeratorState",
"EnumeratorState",
b1 =>
{
b1.Property<int>("PlayoutProgramScheduleAnchorId")
.HasColumnType("INTEGER");
@ -1163,7 +1277,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1163,7 +1277,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("ProgramSchedule");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexConnection", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexConnection",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", "PlexMediaSource")
.WithMany("Connections")
@ -1174,7 +1290,22 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1174,7 +1290,22 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("PlexMediaSource");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexPathReplacement",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", "PlexMediaSource")
.WithMany("PathReplacements")
.HasForeignKey("PlexMediaSourceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PlexMediaSource");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ProgramScheduleItem",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.Collection", "Collection")
.WithMany()
@ -1199,7 +1330,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1199,7 +1330,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("ProgramSchedule");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.SeasonMetadata", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.SeasonMetadata",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.Season", "Season")
.WithMany("SeasonMetadata")
@ -1210,7 +1343,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1210,7 +1343,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Season");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ShowMetadata", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ShowMetadata",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.Show", "Show")
.WithMany("ShowMetadata")
@ -1221,7 +1356,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1221,7 +1356,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Show");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.LocalLibrary", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.LocalLibrary",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.Library", null)
.WithOne()
@ -1230,7 +1367,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1230,7 +1367,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexLibrary", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexLibrary",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.Library", null)
.WithOne()
@ -1239,7 +1378,20 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1239,7 +1378,20 @@ namespace ErsatzTV.Infrastructure.Migrations
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Episode", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexMediaFile",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaFile", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.PlexMediaFile", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Episode",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
.WithOne()
@ -1256,7 +1408,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1256,7 +1408,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Season");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Movie", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Movie",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
.WithOne()
@ -1265,7 +1419,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1265,7 +1419,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Season",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
.WithOne()
@ -1282,7 +1438,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1282,7 +1438,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Show");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Show", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Show",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
.WithOne()
@ -1291,7 +1449,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1291,7 +1449,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.LocalMediaSource",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaSource", null)
.WithOne()
@ -1300,7 +1460,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1300,7 +1460,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexMediaSource",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaSource", null)
.WithOne()
@ -1309,7 +1471,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1309,7 +1471,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ProgramScheduleItemDuration",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
@ -1318,7 +1482,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1318,7 +1482,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ProgramScheduleItemFlood",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
@ -1327,7 +1493,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1327,7 +1493,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ProgramScheduleItemMultiple",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
@ -1336,7 +1504,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1336,7 +1504,9 @@ namespace ErsatzTV.Infrastructure.Migrations
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ProgramScheduleItemOne",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
@ -1345,123 +1515,107 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1345,123 +1515,107 @@ namespace ErsatzTV.Infrastructure.Migrations
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMovie", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexMovie",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.Movie", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.PlexMovie", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.PlexMediaItemPart", "Part")
.WithMany()
.HasForeignKey("PartId");
b.Navigation("Part");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Channel",
b =>
{
b.Navigation("Artwork");
b.Navigation("Playouts");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b =>
{
b.Navigation("CollectionItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Collection", b => { b.Navigation("CollectionItems"); });
modelBuilder.Entity("ErsatzTV.Core.Domain.EpisodeMetadata", b =>
{
b.Navigation("Artwork");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.EpisodeMetadata", b => { b.Navigation("Artwork"); });
modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b =>
{
b.Navigation("Paths");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Library", b => { b.Navigation("Paths"); });
modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryPath", b =>
{
b.Navigation("MediaItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.LibraryPath", b => { b.Navigation("MediaItems"); });
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b =>
{
b.Navigation("CollectionItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b => { b.Navigation("CollectionItems"); });
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaSource", b =>
{
b.Navigation("Libraries");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaSource", b => { b.Navigation("Libraries"); });
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaVersion", b =>
{
b.Navigation("MediaFiles");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaVersion", b => { b.Navigation("MediaFiles"); });
modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b =>
{
b.Navigation("Artwork");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMetadata", b => { b.Navigation("Artwork"); });
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Playout",
b =>
{
b.Navigation("Items");
b.Navigation("ProgramScheduleAnchors");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.ProgramSchedule",
b =>
{
b.Navigation("Items");
b.Navigation("Playouts");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.SeasonMetadata", b =>
{
b.Navigation("Artwork");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.SeasonMetadata", b => { b.Navigation("Artwork"); });
modelBuilder.Entity("ErsatzTV.Core.Domain.ShowMetadata", b =>
{
b.Navigation("Artwork");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ShowMetadata", b => { b.Navigation("Artwork"); });
modelBuilder.Entity("ErsatzTV.Core.Domain.Episode", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Episode",
b =>
{
b.Navigation("EpisodeMetadata");
b.Navigation("MediaVersions");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Movie", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Movie",
b =>
{
b.Navigation("MediaVersions");
b.Navigation("MovieMetadata");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Season", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Season",
b =>
{
b.Navigation("Episodes");
b.Navigation("SeasonMetadata");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Show", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Show",
b =>
{
b.Navigation("Seasons");
b.Navigation("ShowMetadata");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b =>
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexMediaSource",
b =>
{
b.Navigation("Connections");
b.Navigation("PathReplacements");
});
#pragma warning restore 612, 618
}

1
ErsatzTV.Infrastructure/Plex/Models/PlexLibraryResponse.cs

@ -6,5 +6,6 @@ @@ -6,5 +6,6 @@
public string Title { get; set; }
public string Type { get; set; }
public int Hidden { get; set; }
public string Uuid { get; set; }
}
}

1
ErsatzTV.Infrastructure/Plex/Models/PlexPartResponse.cs

@ -6,6 +6,5 @@ @@ -6,6 +6,5 @@
public string Key { get; set; }
public int Duration { get; set; }
public string File { get; set; }
public int Size { get; set; }
}
}

127
ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

@ -4,6 +4,7 @@ using System.Linq; @@ -4,6 +4,7 @@ using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Plex;
using ErsatzTV.Infrastructure.Plex.Models;
@ -15,6 +16,13 @@ namespace ErsatzTV.Infrastructure.Plex @@ -15,6 +16,13 @@ namespace ErsatzTV.Infrastructure.Plex
{
public class PlexServerApiClient : IPlexServerApiClient
{
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
public PlexServerApiClient(IFallbackMetadataProvider fallbackMetadataProvider)
{
_fallbackMetadataProvider = fallbackMetadataProvider;
}
public async Task<Either<BaseError, List<PlexLibrary>>> GetLibraries(
PlexConnection connection,
PlexServerAuthToken token)
@ -46,7 +54,7 @@ namespace ErsatzTV.Infrastructure.Plex @@ -46,7 +54,7 @@ namespace ErsatzTV.Infrastructure.Plex
IPlexServerApi service = RestService.For<IPlexServerApi>(connection.Uri);
return await service.GetLibrarySectionContents(library.Key, token.AuthToken)
.Map(r => r.MediaContainer.Metadata.Filter(m => m.Media.Count > 0 && m.Media[0].Part.Count > 0))
.Map(list => list.Map(ProjectToMovie).ToList());
.Map(list => list.Map(metadata => ProjectToMovie(metadata, library.MediaSourceId)).ToList());
}
catch (Exception ex)
{
@ -62,108 +70,87 @@ namespace ErsatzTV.Infrastructure.Plex @@ -62,108 +70,87 @@ namespace ErsatzTV.Infrastructure.Plex
Key = response.Key,
Name = response.Title,
MediaKind = LibraryMediaKind.Shows,
ShouldSyncItems = false
ShouldSyncItems = false,
Paths = new List<LibraryPath> { new() { Path = $"plex://{response.Uuid}" } }
},
"movie" => new PlexLibrary
{
Key = response.Key,
Name = response.Title,
MediaKind = LibraryMediaKind.Movies,
ShouldSyncItems = false
ShouldSyncItems = false,
Paths = new List<LibraryPath> { new() { Path = $"plex://{response.Uuid}" } }
},
// TODO: "artist" for music libraries
_ => None
};
private static PlexPartEntry Project(PlexPartResponse response) =>
new()
{
Id = response.Id,
Key = response.Key,
Duration = response.Duration,
File = response.File,
Size = response.Size
};
private static PlexMediaEntry Project(PlexMediaResponse response) =>
new()
{
Id = response.Id,
Duration = response.Duration,
Bitrate = response.Bitrate,
Width = response.Width,
Height = response.Height,
AspectRatio = response.AspectRatio,
AudioChannels = response.AudioChannels,
AudioCodec = response.AudioCodec,
VideoCodec = response.VideoCodec,
Container = response.Container,
VideoFrameRate = response.VideoFrameRate,
Part = response.Part.Map(Project).ToList()
};
private static PlexMetadataEntry Project(PlexMetadataResponse response) =>
new()
{
Key = response.Key,
Title = response.Title,
Summary = response.Summary,
Year = response.Year,
Tagline = response.Tagline,
Thumb = response.Thumb,
Art = response.Art,
AddedAt = response.AddedAt,
UpdatedAt = response.UpdatedAt,
Media = response.Media.Map(Project).ToList()
};
private static PlexMovie ProjectToMovie(PlexMetadataResponse response)
private PlexMovie ProjectToMovie(PlexMetadataResponse response, int mediaSourceId)
{
PlexMediaResponse media = response.Media.Head();
PlexPartResponse part = media.Part.Head();
DateTime dateAdded = DateTimeOffset.FromUnixTimeSeconds(response.AddedAt).DateTime;
DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime;
var metadata = new MovieMetadata
{
Title = response.Title,
SortTitle = _fallbackMetadataProvider.GetSortTitle(response.Title),
Plot = response.Summary,
ReleaseDate = new DateTime(response.Year, 1, 1), // TODO: actual release date?
ReleaseDate = DateTime.Parse(response.OriginallyAvailableAt),
Year = response.Year,
Tagline = response.Tagline,
DateAdded = DateTime.UtcNow, // TODO: actual date added?
DateAdded = dateAdded,
DateUpdated = lastWriteTime
};
// TODO: artwork
if (!string.IsNullOrWhiteSpace(response.Thumb))
{
var path = $"plex/{mediaSourceId}{response.Thumb}";
var artwork = new Artwork
{
ArtworkKind = ArtworkKind.Poster,
Path = path,
DateAdded = dateAdded,
DateUpdated = lastWriteTime
};
metadata.Artwork ??= new List<Artwork>();
metadata.Artwork.Add(artwork);
}
var version = new MediaVersion
{
Name = "Main",
Duration = TimeSpan.FromMilliseconds(media.Duration),
Width = media.Width,
Height = media.Height,
AudioCodec = media.AudioCodec,
VideoCodec = media.VideoCodec,
SampleAspectRatio = ConvertToSAR(media.AspectRatio),
MediaFiles = new List<MediaFile>
{
new PlexMediaFile
{
PlexId = part.Id,
Key = part.Key,
Path = part.File
}
}
};
var movie = new PlexMovie
{
Key = response.Key,
// DateUpdated = lastWriteTime,
MovieMetadata = new List<MovieMetadata> { metadata },
// TODO: versions
// Statistics = new MediaItemStatistics
// {
// Duration = TimeSpan.FromMilliseconds(media.Duration),
// Width = media.Width,
// Height = media.Height,
// // TODO: aspect ratio
// AudioCodec = media.AudioCodec,
// VideoCodec = media.VideoCodec,
// LastWriteTime = lastWriteTime
// },
// TODO: multi-part movies?
Part = new PlexMediaItemPart
{
PlexId = part.Id,
Key = part.Key,
File = part.File,
Duration = part.Duration,
Size = part.Size
}
MediaVersions = new List<MediaVersion> { version }
};
return movie;
}
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";
}
}

40
ErsatzTV/Controllers/ArtworkController.cs

@ -1,6 +1,11 @@ @@ -1,6 +1,11 @@
using System.Threading.Tasks;
using System;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using ErsatzTV.Application.Images;
using ErsatzTV.Application.Images.Queries;
using ErsatzTV.Application.Plex;
using ErsatzTV.Application.Plex.Queries;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using LanguageExt;
@ -13,9 +18,14 @@ namespace ErsatzTV.Controllers @@ -13,9 +18,14 @@ namespace ErsatzTV.Controllers
[ApiExplorerSettings(IgnoreApi = true)]
public class PostersController : ControllerBase
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMediator _mediator;
public PostersController(IMediator mediator) => _mediator = mediator;
public PostersController(IMediator mediator, IHttpClientFactory httpClientFactory)
{
_mediator = mediator;
_httpClientFactory = httpClientFactory;
}
[HttpGet("/artwork/posters/{fileName}")]
public async Task<IActionResult> GetPoster(string fileName)
@ -27,6 +37,32 @@ namespace ErsatzTV.Controllers @@ -27,6 +37,32 @@ namespace ErsatzTV.Controllers
Right: r => new FileContentResult(r.Contents, r.MimeType));
}
[HttpGet("/artwork/posters/plex/{plexMediaSourceId}/{*path}")]
public async Task<IActionResult> GetPlexPoster(int plexMediaSourceId, string path)
{
Either<BaseError, PlexConnectionParametersViewModel> connectionParameters =
await _mediator.Send(new GetPlexConnectionParameters(plexMediaSourceId));
return await connectionParameters.Match<Task<IActionResult>>(
Left: _ => new NotFoundResult().AsTask<IActionResult>(),
Right: async r =>
{
HttpClient client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Add("X-Plex-Token", r.AuthToken);
var transcodePath = $"photo/:/transcode?url=/{path}&height=440&width=304&minSize=1&upscale=0";
var fullPath = new Uri(r.Uri, transcodePath);
HttpResponseMessage response = await client.GetAsync(
fullPath,
HttpCompletionOption.ResponseHeadersRead);
Stream stream = await response.Content.ReadAsStreamAsync();
return new FileStreamResult(
stream,
response.Content.Headers.ContentType?.MediaType ?? "image/jpeg");
});
}
[HttpGet("/artwork/thumbnails/{fileName}")]
public async Task<IActionResult> GetThumbnail(string fileName)
{

20
ErsatzTV/Pages/Libraries.razor

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
@using ErsatzTV.Application.Libraries
@using ErsatzTV.Application.Libraries.Queries
@using ErsatzTV.Application.MediaSources.Commands
@using ErsatzTV.Application.Plex.Commands
@implements IDisposable
@inject IMediator Mediator
@inject IEntityLocker Locker
@ -25,7 +26,7 @@ @@ -25,7 +26,7 @@
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Library Kind">Local</MudTd>
<MudTd DataLabel="Library Kind">@context.LibraryKind</MudTd>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Media Kind">@context.MediaKind</MudTd>
<MudTd>
@ -61,7 +62,7 @@ @@ -61,7 +62,7 @@
</MudContainer>
@code {
private IList<LocalLibraryViewModel> _libraries;
private IList<LibraryViewModel> _libraries;
protected override void OnInitialized() =>
Locker.OnLibraryChanged += LockChanged;
@ -69,13 +70,22 @@ @@ -69,13 +70,22 @@
protected override async Task OnParametersSetAsync() => await LoadLibraries();
private async Task LoadLibraries() =>
_libraries = await Mediator.Send(new GetLocalLibraries());
_libraries = await Mediator.Send(new GetAllLibraries());
private async Task ScanLibrary(LocalLibraryViewModel library)
private async Task ScanLibrary(LibraryViewModel library)
{
if (Locker.LockLibrary(library.Id))
{
await Channel.WriteAsync(new ForceScanLocalLibrary(library.Id));
switch (library)
{
case LocalLibraryViewModel:
await Channel.WriteAsync(new ForceScanLocalLibrary(library.Id));
break;
case PlexLibraryViewModel:
await Channel.WriteAsync(new ForceSynchronizePlexLibraryById(library.Id));
break;
}
StateHasChanged();
}
}

34
ErsatzTV/Pages/MovieList.razor

@ -2,7 +2,13 @@ @@ -2,7 +2,13 @@
@page "/media/movies/page/{PageNumber:int}"
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCards.Queries
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using Unit = LanguageExt.Unit
@inject ILogger<MovieList> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
@inject IDialogService Dialog
@inject NavigationManager NavigationManager
<MudContainer MaxWidth="MaxWidth.Small" Class="mb-6" Style="max-width: 300px">
@ -24,7 +30,9 @@ @@ -24,7 +30,9 @@
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (MovieCardViewModel card in _data.Cards.Where(d => !string.IsNullOrWhiteSpace(d.Title)))
{
<MediaCard Data="@card" Link="@($"/media/movies/{card.MovieId}")"/>
<MediaCard Data="@card"
Link="@($"/media/movies/{card.MovieId}")"
AddToCollectionClicked="@AddToCollection"/>
}
</MudContainer>
@ -53,4 +61,28 @@ @@ -53,4 +61,28 @@
private void NextPage() => NavigationManager.NavigateTo($"/media/movies/page/{PageNumber + 1}");
private async Task AddToCollection(MediaCardViewModel card)
{
if (card is MovieCardViewModel movie)
{
var parameters = new DialogParameters { { "EntityType", "movie" }, { "EntityName", movie.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = Dialog.Show<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is MediaCollectionViewModel collection)
{
var request = new AddMovieToCollection(collection.Id, movie.MovieId);
Either<BaseError, Unit> addResult = await Mediator.Send(request);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding movie to collection: {error.Value}");
Logger.LogError("Unexpected error adding movie to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {movie.Title} to collection {collection.Name}", Severity.Success));
}
}
}
}

31
ErsatzTV/Pages/Playouts.razor

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
<col/>
<col/>
<col/>
<col style="width: 60px;"/>
<col style="width: 120px;"/>
</ColGroup>
<HeaderContent>
<MudTh>Id</MudTh>
@ -29,7 +29,18 @@ @@ -29,7 +29,18 @@
<MudTd DataLabel="Schedule">@context.ProgramSchedule.Name</MudTd>
@* <MudTd DataLabel="Playout Type">@context.ProgramSchedulePlayoutType</MudTd> *@
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Delete" OnClick="@(_ => DeletePlayout(context))"></MudIconButton>
<div style="align-items: center; display: flex;">
<MudTooltip Text="Rebuild Playout">
<MudIconButton Icon="@Icons.Material.Filled.Refresh"
OnClick="@(_ => RebuildPlayout(context))">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Delete Playout">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => DeletePlayout(context))">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
</MudTable>
@ -63,12 +74,16 @@ @@ -63,12 +74,16 @@
@code {
private List<PlayoutViewModel> _playouts;
private List<PlayoutItemViewModel> _selectedPlayoutItems;
private int? _selectedPlayoutId;
protected override Task OnParametersSetAsync() =>
LoadAllPlayouts();
private async Task PlayoutSelected(PlayoutViewModel playout) =>
private async Task PlayoutSelected(PlayoutViewModel playout)
{
_selectedPlayoutId = playout.Id;
_selectedPlayoutItems = await Mediator.Send(new GetPlayoutItemsById(playout.Id));
}
private async Task DeletePlayout(PlayoutViewModel playout)
{
@ -84,6 +99,16 @@ @@ -84,6 +99,16 @@
}
}
private async Task RebuildPlayout(PlayoutViewModel playout)
{
await Mediator.Send(new BuildPlayout(playout.Id, true));
await LoadAllPlayouts();
if (_selectedPlayoutId == playout.Id)
{
await PlayoutSelected(playout);
}
}
private async Task LoadAllPlayouts() =>
_playouts = await Mediator.Send(new GetAllPlayouts());

20
ErsatzTV/Pages/PlexLibrariesEditor.razor

@ -6,9 +6,11 @@ @@ -6,9 +6,11 @@
@inject NavigationManager NavigationManager
@inject ILogger<PlexLibrariesEditor> Logger
@inject ISnackbar Snackbar
@inject ChannelWriter<IBackgroundServiceRequest> Channel
@inject IEntityLocker Locker
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
<MudTable Hover="true" Items="_libraries" Dense="true" Class="mt-8">
<MudTable Hover="true" Items="_libraries" Dense="true">
<ToolBarContent>
<MudText Typo="Typo.h6"><b>@_source.Name</b> Libraries</MudText>
</ToolBarContent>
@ -86,13 +88,25 @@ @@ -86,13 +88,25 @@
Seq<BaseError> errorMessages = await Mediator.Send(request).Map(e => e.LeftToSeq());
errorMessages.HeadOrNone().Match(
await errorMessages.HeadOrNone().Match(
error =>
{
Snackbar.Add($"Unexpected error saving plex libraries: {error.Value}", Severity.Error);
Logger.LogError("Unexpected error saving plex libraries: {Error}", error.Value);
return Task.CompletedTask;
},
() => NavigationManager.NavigateTo("/media/sources/1"));
async () =>
{
foreach (int id in _libraries.Filter(l => l.ShouldSyncItems).Map(l => l.Id))
{
if (Locker.LockLibrary(id))
{
await Channel.WriteAsync(new SynchronizePlexLibraryByIdIfNeeded(id));
}
}
NavigationManager.NavigateTo("/media/plex");
});
}
}

73
ErsatzTV/Shared/PlexMediaSources.razor → ErsatzTV/Pages/PlexMediaSources.razor

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
@using ErsatzTV.Application.Plex
@page "/media/plex"
@using ErsatzTV.Application.Plex
@using ErsatzTV.Application.Plex.Commands
@using ErsatzTV.Application.Plex.Queries
@inject IDialogService Dialog
@ -7,38 +8,44 @@ @@ -7,38 +8,44 @@
@inject ILogger<PlexMediaSources> Logger
@inject IJSRuntime JsRuntime
<MudTable Hover="true" Dense="true" Items="_mediaSources">
<ToolBarContent>
<MudText Typo="Typo.h6">Plex Media Sources</MudText>
</ToolBarContent>
<ColGroup>
<col/>
<col/>
<col style="width: 60px;"/>
</ColGroup>
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Address</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Address">@context.Address</MudTd>
<MudTd>
<MudMenu Icon="@Icons.Material.Filled.MoreVert" Direction="Direction.Left" OffsetX="true">
<MudMenuItem Icon="@Icons.Material.Filled.Edit" Link="@($"/media/sources/plex/{context.Id}/libraries")">
Edit Libraries
</MudMenuItem>
@* <MudMenuItem Icon="@Filled.Edit" Link="@($"/media/collections/{context.Id}/items")"> *@
@* Edit Path Replacements *@
@* </MudMenuItem> *@
</MudMenu>
</MudTd>
</RowTemplate>
</MudTable>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddPlexMediaSource())" Class="mt-4">
Add Plex Media Source
</MudButton>
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
<MudTable Hover="true" Dense="true" Items="_mediaSources">
<ToolBarContent>
<MudText Typo="Typo.h6">Plex Media Sources</MudText>
</ToolBarContent>
<ColGroup>
<col/>
<col/>
<col style="width: 120px;"/>
</ColGroup>
<HeaderContent>
<MudTh>Name</MudTh>
<MudTh>Address</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Address">@context.Address</MudTd>
<MudTd>
<div style="align-items: center; display: flex;">
<MudTooltip Text="Edit Libraries">
<MudIconButton Icon="@Icons.Material.Filled.VideoLibrary"
Link="@($"/media/sources/plex/{context.Id}/libraries")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Edit Path Replacements">
<MudIconButton Icon="@Icons.Material.Filled.Folder"
Link="@($"/media/sources/plex/{context.Id}/paths")">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
</MudTable>
@* <MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => AddPlexMediaSource())" Class="mt-4"> *@
@* Add Plex Media Source *@
@* </MudButton> *@
</MudContainer>
@code {
private List<PlexMediaSourceViewModel> _mediaSources;

135
ErsatzTV/Pages/PlexPathReplacementsEditor.razor

@ -0,0 +1,135 @@ @@ -0,0 +1,135 @@
@page "/media/sources/plex/{Id:int}/paths"
@using ErsatzTV.Application.Plex
@using ErsatzTV.Application.Plex.Commands
@using ErsatzTV.Application.Plex.Queries
@inject NavigationManager NavigationManager
@inject ILogger<ScheduleItemsEditor> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
<MudTable Hover="true" Items="_pathReplacements.OrderBy(r => r.Id)" Dense="true" @bind-SelectedItem="_selectedItem">
<ToolBarContent>
<MudText Typo="Typo.h6"><b>@_source.Name</b> Path Replacements</MudText>
</ToolBarContent>
<ColGroup>
<col/>
<col/>
<col style="width: 60px;"/>
</ColGroup>
<HeaderContent>
<MudTh>Plex Path</MudTh>
<MudTh>Local Path</MudTh>
<MudTh/>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Plex Path">
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@context.PlexPath
</MudText>
</MudTd>
<MudTd DataLabel="Local Path">
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@context.LocalPath
</MudText>
</MudTd>
<MudTd>
<MudTooltip Text="Delete Path Replacement">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => RemovePathReplacement(context))">
</MudIconButton>
</MudTooltip>
</MudTd>
</RowTemplate>
</MudTable>
<MudButton Variant="Variant.Filled" Color="Color.Default" OnClick="@(_ => AddPathReplacement())" Class="mt-4">
Add Path Replacement
</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="@(_ => SaveChanges())" Class="mt-4 ml-4">
Save Changes
</MudButton>
@if (_selectedItem is not null)
{
<div style="max-width: 400px;">
<EditForm Model="_selectedItem">
<FluentValidator/>
<MudCard Class="mt-6">
<MudCardContent>
<MudTextField Label="Plex Path"
@bind-Value="@_selectedItem.PlexPath"
For="@(() => _selectedItem.PlexPath)"/>
<MudTextField Class="mt-3"
Label="Local Path"
@bind-Value="@_selectedItem.LocalPath"
For="@(() => _selectedItem.LocalPath)"/>
</MudCardContent>
</MudCard>
</EditForm>
</div>
}
</MudContainer>
@code {
[Parameter]
public int Id { get; set; }
private PlexMediaSourceViewModel _source;
private List<PlexPathReplacementEditViewModel> _pathReplacements;
private PlexPathReplacementEditViewModel _selectedItem;
protected override Task OnParametersSetAsync() => LoadData();
private async Task LoadData()
{
Option<PlexMediaSourceViewModel> maybeSource = await Mediator.Send(new GetPlexMediaSourceById(Id));
await maybeSource.Match(
async source =>
{
_source = source;
_pathReplacements = await Mediator.Send(new GetPlexPathReplacementsBySourceId(Id))
.Map(list => list.Map(ProjectToEditViewModel).ToList());
},
() =>
{
NavigationManager.NavigateTo("404");
return Task.CompletedTask;
});
}
private PlexPathReplacementEditViewModel ProjectToEditViewModel(PlexPathReplacementViewModel item) =>
new() { Id = item.Id, PlexPath = item.PlexPath, LocalPath = item.LocalPath };
private void AddPathReplacement()
{
var item = new PlexPathReplacementEditViewModel();
_pathReplacements.Add(item);
_selectedItem = item;
}
private void RemovePathReplacement(PlexPathReplacementEditViewModel item)
{
_selectedItem = null;
_pathReplacements.Remove(item);
}
private async Task SaveChanges()
{
var items = _pathReplacements
.Map(item => new PlexPathReplacementItem(item.Id, item.PlexPath, item.LocalPath))
.ToList();
Seq<BaseError> errorMessages = await Mediator.Send(new UpdatePlexPathReplacements(Id, items)).Map(e => e.LeftToSeq());
errorMessages.HeadOrNone().Match(
error =>
{
Snackbar.Add($"Unexpected error saving path replacements: {error.Value}", Severity.Error);
Logger.LogError("Unexpected error saving path replacements: {Error}", error.Value);
},
() => NavigationManager.NavigateTo("/media/plex"));
}
}

30
ErsatzTV/Pages/Schedules.razor

@ -14,7 +14,7 @@ @@ -14,7 +14,7 @@
<col/>
<col/>
<col/>
<col style="width: 60px;"/>
<col style="width: 180px;"/>
</ColGroup>
<HeaderContent>
<MudTh>Id</MudTh>
@ -27,17 +27,23 @@ @@ -27,17 +27,23 @@
<MudTd DataLabel="Name">@context.Name</MudTd>
<MudTd DataLabel="Name">@context.MediaCollectionPlaybackOrder</MudTd>
<MudTd>
<MudMenu Icon="@Icons.Material.Filled.MoreVert" Direction="Direction.Left" OffsetX="true">
<MudMenuItem Icon="@Icons.Material.Filled.Edit" Link="@($"/schedules/{context.Id}")">
Edit Properties
</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Edit" Link="@($"/schedules/{context.Id}/items")">
Edit Schedule Items
</MudMenuItem>
<MudMenuItem Icon="@Icons.Material.Filled.Delete" OnClick="@(_ => DeleteSchedule(context))">
Delete
</MudMenuItem>
</MudMenu>
<div style="align-items: center; display: flex;">
<MudTooltip Text="Edit Properties">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Link="@($"/schedules/{context.Id}")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Edit Schedule Items">
<MudIconButton Icon="@Icons.Material.Filled.FormatListNumbered"
Link="@($"/schedules/{context.Id}/items")">
</MudIconButton>
</MudTooltip>
<MudTooltip Text="Delete Schedule">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
OnClick="@(_ => DeleteSchedule(context))">
</MudIconButton>
</MudTooltip>
</div>
</MudTd>
</RowTemplate>
</MudTable>

110
ErsatzTV/Pages/Search.razor

@ -0,0 +1,110 @@ @@ -0,0 +1,110 @@
@page "/search"
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCards.Queries
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using Microsoft.AspNetCore.WebUtilities
@using Microsoft.Extensions.Primitives
@using Unit = LanguageExt.Unit
@inject NavigationManager NavigationManager
@inject IMediator Mediator
@inject ILogger<Search> Logger
@inject ISnackbar Snackbar
@inject IDialogService Dialog
<MudContainer MaxWidth="MaxWidth.ExtraLarge">
<div class="mb-6" style="display: flex; flex-direction: row;">
<MudText GutterBottom="true" Typo="Typo.h4">Search Results: "@_query"</MudText>
</div>
@if (_data?.MovieCards.Any() == true)
{
<MudText GutterBottom="true" Typo="Typo.h4">Movies</MudText>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (MovieCardViewModel card in _data.MovieCards)
{
<MediaCard Data="@card"
Link="@($"/media/movies/{card.MovieId}")"
AddToCollectionClicked="@AddToCollection"/>
}
</MudContainer>
}
@if (_data?.ShowCards.Any() == true)
{
<MudText GutterBottom="true" Typo="Typo.h4">Television Shows</MudText>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (TelevisionShowCardViewModel card in _data.ShowCards)
{
<MediaCard Data="@card"
Link="@($"/media/tv/shows/{card.TelevisionShowId}")"
AddToCollectionClicked="@AddToCollection"/>
}
</MudContainer>
}
</MudContainer>
@code {
private string _query;
private SearchCardResultsViewModel _data;
protected override async Task OnInitializedAsync()
{
string query = new Uri(NavigationManager.Uri).Query;
if (QueryHelpers.ParseQuery(query).TryGetValue("query", out StringValues value))
{
_query = value;
Either<BaseError, SearchCardResultsViewModel> maybeResults = await Mediator.Send(new GetSearchCards(_query));
maybeResults.IfRight(results => _data = results);
}
}
private async Task AddToCollection(MediaCardViewModel card)
{
if (card is MovieCardViewModel movie)
{
var parameters = new DialogParameters { { "EntityType", "movie" }, { "EntityName", movie.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = Dialog.Show<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is MediaCollectionViewModel collection)
{
var request = new AddMovieToCollection(collection.Id, movie.MovieId);
Either<BaseError, Unit> addResult = await Mediator.Send(request);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding movie to collection: {error.Value}");
Logger.LogError("Unexpected error adding movie to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {movie.Title} to collection {collection.Name}", Severity.Success));
}
}
if (card is TelevisionShowCardViewModel show)
{
var parameters = new DialogParameters { { "EntityType", "show" }, { "EntityName", show.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = Dialog.Show<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is MediaCollectionViewModel collection)
{
var request = new AddShowToCollection(collection.Id, show.TelevisionShowId);
Either<BaseError, Unit> addResult = await Mediator.Send(request);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding show to collection: {error.Value}");
Logger.LogError("Unexpected error adding show to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {show.Title} to collection {collection.Name}", Severity.Success));
}
}
}
}

29
ErsatzTV/Pages/TelevisionEpisodeList.razor

@ -7,7 +7,10 @@ @@ -7,7 +7,10 @@
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.ProgramSchedules
@using ErsatzTV.Application.ProgramSchedules.Commands
@using Unit=LanguageExt.Unit;
@inject IMediator Mediator
@inject ILogger<TelevisionEpisodeList> Logger
@inject ISnackbar Snackbar
@inject IDialogService Dialog
@inject NavigationManager NavigationManager
@ -53,7 +56,7 @@ @@ -53,7 +56,7 @@
<MediaCard Data="@card"
Placeholder="@card.Placeholder"
Link="@($"/media/tv/episodes/{card.EpisodeId}")"
DataRefreshed="@(() => RefreshData())"
AddToCollectionClicked="@AddEpisodeToCollection"
ContainerClass="media-card-episode-container mx-2"
CardClass="media-card-episode"
ArtworkKind="@ArtworkKind.Thumbnail"/>
@ -119,5 +122,29 @@ @@ -119,5 +122,29 @@
NavigationManager.NavigateTo($"/schedules/{schedule.Id}/items");
}
}
private async Task AddEpisodeToCollection(MediaCardViewModel card)
{
if (card is TelevisionEpisodeCardViewModel episode)
{
var parameters = new DialogParameters { { "EntityType", "episode" }, { "EntityName", episode.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = Dialog.Show<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is MediaCollectionViewModel collection)
{
var request = new AddEpisodeToCollection(collection.Id, episode.EpisodeId);
Either<BaseError, Unit> addResult = await Mediator.Send(request);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding episode to collection: {error.Value}");
Logger.LogError("Unexpected error adding episode to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {episode.Title} to collection {collection.Name}", Severity.Success));
}
}
}
}

29
ErsatzTV/Pages/TelevisionSeasonList.razor

@ -7,7 +7,10 @@ @@ -7,7 +7,10 @@
@using ErsatzTV.Application.MediaCollections.Commands
@using ErsatzTV.Application.ProgramSchedules
@using ErsatzTV.Application.ProgramSchedules.Commands
@using Unit=LanguageExt.Unit;
@inject IMediator Mediator
@inject ILogger<TelevisionSeasonList> Logger
@inject ISnackbar Snackbar
@inject IDialogService Dialog
@inject NavigationManager NavigationManager
@ -52,7 +55,7 @@ @@ -52,7 +55,7 @@
{
<MediaCard Data="@card" Placeholder="@card.Placeholder"
Link="@($"/media/tv/seasons/{card.TelevisionSeasonId}")"
DataRefreshed="@(() => RefreshData())"/>
AddToCollectionClicked="@AddSeasonToCollection"/>
}
</MudContainer>
@ -113,5 +116,29 @@ @@ -113,5 +116,29 @@
NavigationManager.NavigateTo($"/schedules/{schedule.Id}/items");
}
}
private async Task AddSeasonToCollection(MediaCardViewModel card)
{
if (card is TelevisionSeasonCardViewModel season)
{
var parameters = new DialogParameters { { "EntityType", "season" }, { "EntityName", season.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = Dialog.Show<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is MediaCollectionViewModel collection)
{
var request = new AddSeasonToCollection(collection.Id, season.TelevisionSeasonId);
Either<BaseError, Unit> addResult = await Mediator.Send(request);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding season to collection: {error.Value}");
Logger.LogError("Unexpected error adding season to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {season.Title} to collection {collection.Name}", Severity.Success));
}
}
}
}

34
ErsatzTV/Pages/TelevisionShowList.razor

@ -1,7 +1,13 @@ @@ -1,7 +1,13 @@
@page "/media/tv/shows"
@using ErsatzTV.Application.MediaCards
@using ErsatzTV.Application.MediaCards.Queries
@using ErsatzTV.Application.MediaCollections
@using ErsatzTV.Application.MediaCollections.Commands
@using Unit = LanguageExt.Unit
@inject ILogger<TelevisionShowList> Logger
@inject ISnackbar Snackbar
@inject IMediator Mediator
@inject IDialogService Dialog
<MudContainer MaxWidth="MaxWidth.Small" Class="mb-6" Style="max-width: 300px">
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
@ -22,7 +28,9 @@ @@ -22,7 +28,9 @@
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (TelevisionShowCardViewModel card in _data.Cards)
{
<MediaCard Data="@card" Link="@($"/media/tv/shows/{card.TelevisionShowId}")" DataRefreshed="@(() => RefreshData())"/>
<MediaCard Data="@card"
Link="@($"/media/tv/shows/{card.TelevisionShowId}")"
AddToCollectionClicked="@AddToCollection"/>
}
</MudContainer>
@ -49,4 +57,28 @@ @@ -49,4 +57,28 @@
await RefreshData();
}
private async Task AddToCollection(MediaCardViewModel card)
{
if (card is TelevisionShowCardViewModel show)
{
var parameters = new DialogParameters { { "EntityType", "show" }, { "EntityName", show.Title } };
var options = new DialogOptions { CloseButton = true, MaxWidth = MaxWidth.ExtraSmall };
IDialogReference dialog = Dialog.Show<AddToCollectionDialog>("Add To Collection", parameters, options);
DialogResult result = await dialog.Result;
if (!result.Cancelled && result.Data is MediaCollectionViewModel collection)
{
var request = new AddShowToCollection(collection.Id, show.TelevisionShowId);
Either<BaseError, Unit> addResult = await Mediator.Send(request);
addResult.Match(
Left: error =>
{
Snackbar.Add($"Unexpected error adding show to collection: {error.Value}");
Logger.LogError("Unexpected error adding show to collection: {Error}", error.Value);
},
Right: _ => Snackbar.Add($"Added {show.Title} to collection {collection.Name}", Severity.Success));
}
}
}
}

1
ErsatzTV/Pages/_Host.cshtml

@ -17,6 +17,7 @@ @@ -17,6 +17,7 @@
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet"/>
<link href="css/site.css" rel="stylesheet"/>
<link href="ErsatzTV.styles.css" rel="stylesheet"/>
@await Html.PartialAsync("../Shared/_Favicons")
</head>
<body>
<component type="typeof(App)" render-mode="ServerPrerendered"/>

39
ErsatzTV/Shared/MainLayout.razor

@ -1,5 +1,7 @@ @@ -1,5 +1,7 @@
@using System.Reflection
@using System.Web
@inherits LayoutComponentBase
@inject NavigationManager NavigationManager
<MudThemeProvider Theme="_ersatzTvTheme"/>
<MudDialogProvider DisableBackdropClick="true"/>
@ -7,8 +9,14 @@ @@ -7,8 +9,14 @@
<MudLayout>
<MudAppBar Elevation="1">
<MudIconButton Icon="@Icons.Material.Filled.Menu" Color="Color.Inherit" Edge="Edge.Start" OnClick="@(_ => DrawerToggle())"/>
<MudText Typo="Typo.h5" Class="ml-3">ErsatzTV</MudText>
<MudTextField T="string"
@ref="_textField"
Placeholder="Search"
Variant="Variant.Outlined"
Class="search-bar"
OnKeyDown="@OnSearchKeyDown"
Immediate="true">
</MudTextField>
<MudAppBarSpacer/>
<MudLink Style="@($"color:{Colors.Shades.White}")" Color="Color.Inherit" Href="/iptv/channels.m3u" Target="_blank" Underline="Underline.None">M3U</MudLink>
<MudLink Style="@($"color:{Colors.Shades.White}")" Color="Color.Inherit" Href="/iptv/xmltv.xml" Target="_blank" Class="mx-4" Underline="Underline.None">XMLTV</MudLink>
@ -21,11 +29,17 @@ @@ -21,11 +29,17 @@
<MudIconButton Icon="@Icons.Custom.Brands.GitHub" Color="Color.Inherit" Link="https://github.com/jasongdove/ErsatzTV" Target="_blank"/>
</MudTooltip>
</MudAppBar>
<MudDrawer @bind-Open="_drawerOpen" Elevation="2">
<MudDrawer Open="true" Elevation="2">
<MudDrawerHeader>
<MudText Typo="Typo.h6">ErsatzTV</MudText>
</MudDrawerHeader>
<MudNavMenu>
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Home">Home</MudNavLink>
<MudNavLink Href="/" Match="NavLinkMatch.All">Home</MudNavLink>
<MudNavLink Href="/channels">Channels</MudNavLink>
<MudNavLink Href="/ffmpeg">FFmpeg</MudNavLink>
@* <MudNavGroup Title="Media Sources" Expanded="true"> *@
@* <MudNavLink Href="/media/plex">Plex</MudNavLink> *@
@* </MudNavGroup> *@
<MudNavGroup Title="Media" Expanded="true">
<MudNavLink Href="/media/libraries">Libraries</MudNavLink>
<MudNavLink Href="/media/tv/shows">TV Shows</MudNavLink>
@ -52,10 +66,8 @@ @@ -52,10 +66,8 @@
@code {
private static readonly string InfoVersion = Assembly.GetEntryAssembly().GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? "unknown";
private bool _drawerOpen = true;
private void DrawerToggle() => _drawerOpen = !_drawerOpen;
private MudTextField<string> _textField;
private MudTheme _ersatzTvTheme
{
get
@ -74,4 +86,15 @@ @@ -74,4 +86,15 @@
}
}
private void OnSearchKeyDown(KeyboardEventArgs args)
{
if (args.Code == "Enter")
{
string query = HttpUtility.UrlEncode(_textField.Value);
_textField.Reset();
NavigationManager.NavigateTo($"/search?query={query}", true);
StateHasChanged();
}
}
}

10
ErsatzTV/Shared/MediaCard.razor

@ -17,6 +17,13 @@ @@ -17,6 +17,13 @@
<div class="media-card-overlay" style="">
<MudButton Link="@Link" Style="height: 100%; width: 100%">
</MudButton>
@if (AddToCollectionClicked.HasDelegate)
{
<MudIconButton Color="Color.Tertiary"
Icon="@Icons.Material.Filled.PlaylistAdd"
Style="bottom: 0; position: absolute; left: 0;"
OnClick="@(() => AddToCollectionClicked.InvokeAsync(Data))"/>
}
@if (DeleteClicked.HasDelegate)
{
<MudIconButton Color="Color.Tertiary"
@ -78,6 +85,9 @@ @@ -78,6 +85,9 @@
[Parameter]
public EventCallback<MediaCardViewModel> DeleteClicked { get; set; }
[Parameter]
public EventCallback<MediaCardViewModel> AddToCollectionClicked { get; set; }
private string GetPlaceholder(string sortTitle)
{
if (Placeholder != null)

6
ErsatzTV/Shared/_Favicons.cshtml

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
<link rel="manifest" href="/site.webmanifest">
<meta name="msapplication-TileColor" content="#da532c">
<meta name="theme-color" content="#ffffff">

3
ErsatzTV/Startup.cs

@ -178,7 +178,6 @@ namespace ErsatzTV @@ -178,7 +178,6 @@ namespace ErsatzTV
services.AddSingleton<FFmpegProcessService>();
services.AddSingleton<IPlexSecretStore, PlexSecretStore>();
services.AddSingleton<IPlexTvApiClient, PlexTvApiClient>(); // TODO: does this need to be singleton?
services.AddSingleton<IPlexServerApiClient, PlexServerApiClient>();
services.AddSingleton<IEntityLocker, EntityLocker>();
AddChannel<IBackgroundServiceRequest>(services);
AddChannel<IPlexBackgroundServiceRequest>(services);
@ -194,6 +193,7 @@ namespace ErsatzTV @@ -194,6 +193,7 @@ namespace ErsatzTV
services.AddScoped<IPlayoutRepository, PlayoutRepository>();
services.AddScoped<ILogRepository, LogRepository>();
services.AddScoped<ITelevisionRepository, TelevisionRepository>();
services.AddScoped<ISearchRepository, SearchRepository>();
services.AddScoped<IMovieRepository, MovieRepository>();
services.AddScoped<ILibraryRepository, LibraryRepository>();
services.AddScoped<IFFmpegLocator, FFmpegLocator>();
@ -206,6 +206,7 @@ namespace ErsatzTV @@ -206,6 +206,7 @@ namespace ErsatzTV
services.AddScoped<IMovieFolderScanner, MovieFolderScanner>();
services.AddScoped<ITelevisionFolderScanner, TelevisionFolderScanner>();
services.AddScoped<IPlexMovieLibraryScanner, PlexMovieLibraryScanner>();
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
// services.AddHostedService<PlexService>();
services.AddHostedService<FFmpegLocatorService>();

14
ErsatzTV/Validators/PlexPathReplacementEditViewModelValidator.cs

@ -0,0 +1,14 @@ @@ -0,0 +1,14 @@
using ErsatzTV.ViewModels;
using FluentValidation;
namespace ErsatzTV.Validators
{
public class PlexPathReplacementEditViewModelValidator : AbstractValidator<PlexPathReplacementEditViewModel>
{
public PlexPathReplacementEditViewModelValidator()
{
RuleFor(vm => vm.PlexPath).NotEmpty();
RuleFor(vm => vm.LocalPath).NotEmpty();
}
}
}

9
ErsatzTV/ViewModels/PlexPathReplacementEditViewModel.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
namespace ErsatzTV.ViewModels
{
public class PlexPathReplacementEditViewModel
{
public int Id { get; set; }
public string PlexPath { get; set; }
public string LocalPath { get; set; }
}
}

BIN
ErsatzTV/wwwroot/android-chrome-192x192.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

BIN
ErsatzTV/wwwroot/android-chrome-512x512.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

BIN
ErsatzTV/wwwroot/apple-touch-icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

9
ErsatzTV/wwwroot/browserconfig.xml

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

47
ErsatzTV/wwwroot/css/site.css

@ -4,9 +4,13 @@ @@ -4,9 +4,13 @@
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;
@ -18,9 +22,12 @@ @@ -18,9 +22,12 @@
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;
@ -29,7 +36,9 @@ @@ -29,7 +36,9 @@
width: 100%;
}
.media-card-poster-placeholder { font-weight: bold; }
.media-card-poster-placeholder {
font-weight: bold;
}
.media-card-menu {
bottom: 0;
@ -38,7 +47,9 @@ @@ -38,7 +47,9 @@
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);
@ -52,4 +63,26 @@ @@ -52,4 +63,26 @@
transition: opacity 0.2s;
}
.media-card-overlay:hover { opacity: 1; }
.media-card-overlay:hover {
opacity: 1;
}
.search-bar .mud-input {
height: 42px;
}
.search-bar {
background-color: rgba(255, 255, 255, .15);
margin-bottom: 5px;
height: 42px;
border-radius: 4px;
}
.search-bar div .mud-input-root {
color: #fafafa;
}
.search-bar .mud-input.mud-input-outlined .mud-input-outlined-border {
border: none;
border-radius: 4px;
}

BIN
ErsatzTV/wwwroot/favicon-16x16.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

BIN
ErsatzTV/wwwroot/favicon-32x32.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
ErsatzTV/wwwroot/favicon.ico

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
ErsatzTV/wwwroot/mstile-150x150.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

19
ErsatzTV/wwwroot/site.webmanifest

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
{
"name": "",
"short_name": "",
"icons": [
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}
Loading…
Cancel
Save