Browse Source

Merge branch 'main' into scanning-and-metadata

pull/23/head
Jason Dove 6 years ago
parent
commit
cfa84bd0f1
  1. 9
      ErsatzTV.Application/IMediaCard.cs
  2. 35
      ErsatzTV.Application/Images/Commands/SaveImageToDiskHandler.cs
  3. 6
      ErsatzTV.Application/MediaItems/AggregateMediaItemResults.cs
  4. 6
      ErsatzTV.Application/MediaItems/AggregateMediaItemViewModel.cs
  5. 6
      ErsatzTV.Application/MediaItems/Commands/CreateMediaItemHandler.cs
  6. 9
      ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemPoster.cs
  7. 5
      ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItems.cs
  8. 40
      ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItemsHandler.cs
  9. 14
      ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSourceHandler.cs
  10. 2
      ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSourceHandler.cs
  11. 2
      ErsatzTV.Application/MediaSources/Commands/SynchronizePlexLibrariesHandler.cs
  12. 6
      ErsatzTV.Application/MediaSources/Mapper.cs
  13. 6
      ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs
  14. 7
      ErsatzTV.Application/ProgramSchedules/Mapper.cs
  15. 4
      ErsatzTV.Core/AggregateModels/MediaItemSummary.cs
  16. 1
      ErsatzTV.Core/Domain/MediaItem.cs
  17. 1
      ErsatzTV.Core/Domain/MediaMetadata.cs
  18. 28
      ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs
  19. 11
      ErsatzTV.Core/Interfaces/Images/IImageCache.cs
  20. 10
      ErsatzTV.Core/Interfaces/Metadata/ILocalPosterProvider.cs
  21. 4
      ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs
  22. 4
      ErsatzTV.Core/Metadata/LocalMediaScanner.cs
  23. 4
      ErsatzTV.Core/Metadata/LocalMetadataProvider.cs
  24. 90
      ErsatzTV.Core/Metadata/LocalPosterProvider.cs
  25. 2
      ErsatzTV.Infrastructure/Data/Configurations/GenericIntegerIdConfiguration.cs
  26. 12
      ErsatzTV.Infrastructure/Data/Configurations/LogEntryConfiguration.cs
  27. 2
      ErsatzTV.Infrastructure/Data/Configurations/MediaCollectionSummaryConfiguration.cs
  28. 12
      ErsatzTV.Infrastructure/Data/Configurations/MediaItemSummaryConfiguration.cs
  29. 2
      ErsatzTV.Infrastructure/Data/LogContext.cs
  30. 6
      ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs
  31. 51
      ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs
  32. 6
      ErsatzTV.Infrastructure/Data/TvContext.cs
  33. 1
      ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj
  34. 64
      ErsatzTV.Infrastructure/Images/ImageCache.cs
  35. 879
      ErsatzTV.Infrastructure/Migrations/20210213155419_MetadataSortTitle.Designer.cs
  36. 30
      ErsatzTV.Infrastructure/Migrations/20210213155419_MetadataSortTitle.cs
  37. 893
      ErsatzTV.Infrastructure/Migrations/20210213221040_MediaItemPoster.Designer.cs
  38. 48
      ErsatzTV.Infrastructure/Migrations/20210213221040_MediaItemPoster.cs
  39. 25
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  40. 2
      ErsatzTV.Infrastructure/Plex/PlexTvApiClient.cs
  41. 13
      ErsatzTV.sln.DotSettings
  42. 28
      ErsatzTV/Controllers/PostersController.cs
  43. 13
      ErsatzTV/Pages/LocalMediaSourceEditor.razor
  44. 17
      ErsatzTV/Pages/MediaItems.razor
  45. 8
      ErsatzTV/Pages/MediaMovieItems.razor
  46. 8
      ErsatzTV/Pages/MediaOtherItems.razor
  47. 8
      ErsatzTV/Pages/MediaTvItems.razor
  48. 6
      ErsatzTV/Pages/ScheduleItemsEditor.razor
  49. 2
      ErsatzTV/Pages/Schedules.razor
  50. 5
      ErsatzTV/Services/PlexService.cs
  51. 1
      ErsatzTV/Services/WorkerService.cs
  52. 2
      ErsatzTV/Shared/LocalMediaSources.razor
  53. 8
      ErsatzTV/Shared/MainLayout.razor
  54. 34
      ErsatzTV/Shared/MediaCard.razor
  55. 66
      ErsatzTV/Shared/MediaItemTable.razor
  56. 55
      ErsatzTV/Shared/MediaItemsGrid.razor
  57. 4
      ErsatzTV/Startup.cs
  58. 1
      ErsatzTV/_Imports.razor
  59. 25
      ErsatzTV/wwwroot/css/site.css

9
ErsatzTV.Application/IMediaCard.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
namespace ErsatzTV.Application
{
public interface IMediaCard
{
string Title { get; }
string SortTitle { get; }
string Subtitle { get; }
}
}

35
ErsatzTV.Application/Images/Commands/SaveImageToDiskHandler.cs

@ -1,9 +1,7 @@ @@ -1,9 +1,7 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Threading;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Images;
using LanguageExt;
using MediatR;
@ -11,33 +9,12 @@ namespace ErsatzTV.Application.Images.Commands @@ -11,33 +9,12 @@ namespace ErsatzTV.Application.Images.Commands
{
public class SaveImageToDiskHandler : IRequestHandler<SaveImageToDisk, Either<BaseError, string>>
{
private static readonly SHA1CryptoServiceProvider Crypto;
private readonly IImageCache _imageCache;
static SaveImageToDiskHandler() => Crypto = new SHA1CryptoServiceProvider();
public SaveImageToDiskHandler(IImageCache imageCache) => _imageCache = imageCache;
public async Task<Either<BaseError, string>> Handle(
public Task<Either<BaseError, string>> Handle(
SaveImageToDisk request,
CancellationToken cancellationToken)
{
try
{
byte[] hash = Crypto.ComputeHash(request.Buffer);
string hex = BitConverter.ToString(hash).Replace("-", string.Empty);
string fileName = Path.Combine(FileSystemLayout.ImageCacheFolder, hex);
if (!Directory.Exists(FileSystemLayout.ImageCacheFolder))
{
Directory.CreateDirectory(FileSystemLayout.ImageCacheFolder);
}
await File.WriteAllBytesAsync(fileName, request.Buffer, cancellationToken);
return hex;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
CancellationToken cancellationToken) => _imageCache.SaveImage(request.Buffer);
}
}

6
ErsatzTV.Application/MediaItems/AggregateMediaItemResults.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.MediaItems
{
public record AggregateMediaItemResults(int Count, List<AggregateMediaItemViewModel> DataPage);
}

6
ErsatzTV.Application/MediaItems/AggregateMediaItemViewModel.cs

@ -1,4 +1,8 @@ @@ -1,4 +1,8 @@
namespace ErsatzTV.Application.MediaItems
{
public record AggregateMediaItemViewModel(string Source, string Title, int Count, string Duration);
public record AggregateMediaItemViewModel(
string Title,
string Subtitle,
string SortTitle,
string Poster);
}

6
ErsatzTV.Application/MediaItems/Commands/CreateMediaItemHandler.cs

@ -16,6 +16,7 @@ namespace ErsatzTV.Application.MediaItems.Commands @@ -16,6 +16,7 @@ namespace ErsatzTV.Application.MediaItems.Commands
{
private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILocalPosterProvider _localPosterProvider;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly IMediaItemRepository _mediaItemRepository;
private readonly IMediaSourceRepository _mediaSourceRepository;
@ -27,7 +28,8 @@ namespace ErsatzTV.Application.MediaItems.Commands @@ -27,7 +28,8 @@ namespace ErsatzTV.Application.MediaItems.Commands
IConfigElementRepository configElementRepository,
ISmartCollectionBuilder smartCollectionBuilder,
ILocalMetadataProvider localMetadataProvider,
ILocalStatisticsProvider localStatisticsProvider)
ILocalStatisticsProvider localStatisticsProvider,
ILocalPosterProvider localPosterProvider)
{
_mediaItemRepository = mediaItemRepository;
_mediaSourceRepository = mediaSourceRepository;
@ -35,6 +37,7 @@ namespace ErsatzTV.Application.MediaItems.Commands @@ -35,6 +37,7 @@ namespace ErsatzTV.Application.MediaItems.Commands
_smartCollectionBuilder = smartCollectionBuilder;
_localMetadataProvider = localMetadataProvider;
_localStatisticsProvider = localStatisticsProvider;
_localPosterProvider = localPosterProvider;
}
public Task<Either<BaseError, MediaItemViewModel>> Handle(
@ -50,6 +53,7 @@ namespace ErsatzTV.Application.MediaItems.Commands @@ -50,6 +53,7 @@ namespace ErsatzTV.Application.MediaItems.Commands
await _localStatisticsProvider.RefreshStatistics(parameters.FFprobePath, parameters.MediaItem);
await _localMetadataProvider.RefreshMetadata(parameters.MediaItem);
await _localPosterProvider.RefreshPoster(parameters.MediaItem);
await _smartCollectionBuilder.RefreshSmartCollections(parameters.MediaItem);
return ProjectToViewModel(parameters.MediaItem);

9
ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemPoster.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
namespace ErsatzTV.Application.MediaItems.Commands
{
public record RefreshMediaItemPoster : RefreshMediaItem
{
public RefreshMediaItemPoster(int mediaItemId) : base(mediaItemId)
{
}
}
}

5
ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItems.cs

@ -1,9 +1,8 @@ @@ -1,9 +1,8 @@
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Domain;
using MediatR;
namespace ErsatzTV.Application.MediaItems.Queries
{
public record GetAggregateMediaItems
(MediaType MediaType, string SearchString) : IRequest<List<AggregateMediaItemViewModel>>;
(MediaType MediaType, int PageNumber, int PageSize) : IRequest<AggregateMediaItemResults>;
}

40
ErsatzTV.Application/MediaItems/Queries/GetAggregateMediaItemsHandler.cs

@ -1,46 +1,42 @@ @@ -1,46 +1,42 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.AggregateModels;
using ErsatzTV.Core.Interfaces.Repositories;
using MediatR;
namespace ErsatzTV.Application.MediaItems.Queries
{
public class
GetAggregateMediaItemsHandler : IRequestHandler<GetAggregateMediaItems, List<AggregateMediaItemViewModel>>
GetAggregateMediaItemsHandler : IRequestHandler<GetAggregateMediaItems, AggregateMediaItemResults>
{
private readonly IMediaItemRepository _mediaItemRepository;
public GetAggregateMediaItemsHandler(IMediaItemRepository mediaItemRepository) =>
_mediaItemRepository = mediaItemRepository;
public async Task<List<AggregateMediaItemViewModel>> Handle(
public async Task<AggregateMediaItemResults> Handle(
GetAggregateMediaItems request,
CancellationToken cancellationToken)
{
IEnumerable<MediaItem> allItems = await _mediaItemRepository.GetAll(request.MediaType);
int count = await _mediaItemRepository.GetCountByType(request.MediaType);
if (!string.IsNullOrEmpty(request.SearchString))
{
allItems = allItems.Filter(
i => i.Metadata?.Title.Contains(request.SearchString, StringComparison.OrdinalIgnoreCase) ==
true);
}
IEnumerable<MediaItemSummary> allItems = await _mediaItemRepository.GetPageByType(
request.MediaType,
request.PageNumber,
request.PageSize);
return allItems.GroupBy(c => new { c.Source.Name, c.Metadata.Title }).Map(
group => new AggregateMediaItemViewModel(
group.Key.Name,
group.Key.Title,
group.Count(),
group.Count() == 1 ? DisplayDuration(group.Head()) : string.Empty))
var results = allItems
.Map(
s => new AggregateMediaItemViewModel(
s.Title,
s.Subtitle,
s.SortTitle,
s.Poster))
.ToList();
}
private static string DisplayDuration(MediaItem mediaItem) => string.Format(
mediaItem.Metadata?.Duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}",
mediaItem.Metadata?.Duration);
return new AggregateMediaItemResults(count, results);
}
}
}

14
ErsatzTV.Application/MediaSources/Commands/CreateLocalMediaSourceHandler.cs

@ -41,28 +41,28 @@ namespace ErsatzTV.Application.MediaSources.Commands @@ -41,28 +41,28 @@ namespace ErsatzTV.Application.MediaSources.Commands
Folder = folder
});
private async Task<Validation<BaseError, string>> ValidateName(CreateLocalMediaSource createCollection)
private async Task<Validation<BaseError, string>> ValidateName(CreateLocalMediaSource request)
{
List<string> allNames = await _mediaSourceRepository.GetAll()
.Map(list => list.Map(c => c.Name).ToList());
Validation<BaseError, string> result1 = createCollection.NotEmpty(c => c.Name)
.Bind(_ => createCollection.NotLongerThan(50)(c => c.Name));
Validation<BaseError, string> result1 = request.NotEmpty(c => c.Name)
.Bind(_ => request.NotLongerThan(50)(c => c.Name));
var result2 = Optional(createCollection.Name)
var result2 = Optional(request.Name)
.Filter(name => !allNames.Contains(name))
.ToValidation<BaseError>("Media source name must be unique");
return (result1, result2).Apply((_, _) => createCollection.Name);
return (result1, result2).Apply((_, _) => request.Name);
}
private async Task<Validation<BaseError, string>> ValidateFolder(CreateLocalMediaSource createCollection)
private async Task<Validation<BaseError, string>> ValidateFolder(CreateLocalMediaSource request)
{
List<string> allFolders = await _mediaSourceRepository.GetAll()
.Map(list => list.OfType<LocalMediaSource>().Map(c => c.Folder).ToList());
return Optional(createCollection.Folder)
return Optional(request.Folder)
.Filter(folder => allFolders.ForAll(f => !AreSubPaths(f, folder)))
.ToValidation<BaseError>("Folder must not belong to another media source");
}

2
ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSourceHandler.cs

@ -31,7 +31,7 @@ namespace ErsatzTV.Application.MediaSources.Commands @@ -31,7 +31,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
Validate(request)
.MapT(
p => _localMediaScanner.ScanLocalMediaSource(p.LocalMediaSource, p.FFprobePath)
.Map(_ => p.LocalMediaSource.Name))
.Map(_ => p.LocalMediaSource.Folder))
.Bind(v => v.ToEitherAsync());
private async Task<Validation<BaseError, RequestParameters>> Validate(ScanLocalMediaSource request) =>

2
ErsatzTV.Application/MediaSources/Commands/SynchronizePlexLibrariesHandler.cs

@ -86,7 +86,7 @@ namespace ErsatzTV.Application.MediaSources.Commands @@ -86,7 +86,7 @@ namespace ErsatzTV.Application.MediaSources.Commands
error =>
{
_logger.LogWarning(
"Unable to synchronize libraries from Plex server {PlexServer}: {Error}",
"Unable to synchronize libraries from plex server {PlexServer}: {Error}",
connectionParameters.PlexMediaSource.Name,
error.Value);

6
ErsatzTV.Application/MediaSources/Mapper.cs

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using System.Linq;
using System;
using System.Linq;
using ErsatzTV.Core.Domain;
using static LanguageExt.Prelude;
@ -10,7 +11,8 @@ namespace ErsatzTV.Application.MediaSources @@ -10,7 +11,8 @@ namespace ErsatzTV.Application.MediaSources
mediaSource switch
{
LocalMediaSource lms => new LocalMediaSourceViewModel(lms.Id, lms.Name, lms.Folder),
PlexMediaSource pms => ProjectToViewModel(pms)
PlexMediaSource pms => ProjectToViewModel(pms),
_ => throw new NotSupportedException($"Unsupported media source {mediaSource.GetType().Name}")
};
internal static PlexMediaSourceViewModel ProjectToViewModel(PlexMediaSource plexMediaSource) =>

6
ErsatzTV.Application/ProgramSchedules/Commands/ProgramScheduleItemCommandBase.cs

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using System.Threading.Tasks;
using System;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
@ -88,7 +89,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands @@ -88,7 +89,8 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
MediaCollectionId = item.MediaCollectionId,
PlayoutDuration = item.PlayoutDuration.GetValueOrDefault(),
OfflineTail = item.OfflineTail.GetValueOrDefault()
}
},
_ => throw new NotSupportedException($"Unsupported playout mode {item.PlayoutMode}")
};
}
}

7
ErsatzTV.Application/ProgramSchedules/Mapper.cs

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using ErsatzTV.Core.Domain;
using System;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.ProgramSchedules
{
@ -40,7 +41,9 @@ namespace ErsatzTV.Application.ProgramSchedules @@ -40,7 +41,9 @@ namespace ErsatzTV.Application.ProgramSchedules
one.Index,
one.StartType,
one.StartTime,
MediaCollections.Mapper.ProjectToViewModel(one.MediaCollection))
MediaCollections.Mapper.ProjectToViewModel(one.MediaCollection)),
_ => throw new NotSupportedException(
$"Unsupported program schedule item type {programScheduleItem.GetType().Name}")
};
}
}

4
ErsatzTV.Core/AggregateModels/MediaItemSummary.cs

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
namespace ErsatzTV.Core.AggregateModels
{
public record MediaItemSummary(string Title, string SortTitle, string Subtitle, string Poster);
}

1
ErsatzTV.Core/Domain/MediaItem.cs

@ -9,6 +9,7 @@ namespace ErsatzTV.Core.Domain @@ -9,6 +9,7 @@ namespace ErsatzTV.Core.Domain
public int MediaSourceId { get; set; }
public MediaSource Source { get; set; }
public string Path { get; set; }
public string Poster { get; set; }
public MediaMetadata Metadata { get; set; }
public DateTime? LastWriteTime { get; set; }
public IList<SimpleMediaCollection> SimpleMediaCollections { get; set; }

1
ErsatzTV.Core/Domain/MediaMetadata.cs

@ -12,6 +12,7 @@ namespace ErsatzTV.Core.Domain @@ -12,6 +12,7 @@ namespace ErsatzTV.Core.Domain
public string AudioCodec { get; set; }
public MediaType MediaType { get; set; }
public string Title { get; set; }
public string SortTitle { get; set; }
public string Subtitle { get; set; }
public string Description { get; set; }
public int? SeasonNumber { get; set; }

28
ErsatzTV.Core/FFmpeg/FFmpegProcessBuilder.cs

@ -285,7 +285,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -285,7 +285,7 @@ namespace ErsatzTV.Core.FFmpeg
bool hasVideoFilters = _videoFilters.Any();
if (hasVideoFilters)
{
(string filter, string finalLabel) = GenerateFilter(_videoFilters, StreamType.Video);
(string filter, string finalLabel) = GenerateVideoFilter(_videoFilters);
complexFilter.Append(filter);
videoLabel = finalLabel;
}
@ -297,7 +297,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -297,7 +297,7 @@ namespace ErsatzTV.Core.FFmpeg
complexFilter.Append(';');
}
(string filter, string finalLabel) = GenerateFilter(_audioFilters, StreamType.Audio);
(string filter, string finalLabel) = GenerateAudioFilter(_audioFilters);
complexFilter.Append(filter);
audioLabel = finalLabel;
}
@ -348,20 +348,16 @@ namespace ErsatzTV.Core.FFmpeg @@ -348,20 +348,16 @@ namespace ErsatzTV.Core.FFmpeg
};
}
private FilterResult GenerateFilter(Queue<string> filterQueue, StreamType streamType)
private FilterResult GenerateVideoFilter(Queue<string> filterQueue) =>
GenerateFilter(filterQueue, "null", 'v');
private FilterResult GenerateAudioFilter(Queue<string> filterQueue) =>
GenerateFilter(filterQueue, "anull", 'a');
private static FilterResult GenerateFilter(Queue<string> filterQueue, string nullFilter, char av)
{
var filter = new StringBuilder();
var index = 0;
string nullFilter = streamType switch
{
StreamType.Audio => "anull",
StreamType.Video => "null"
};
char av = streamType switch
{
StreamType.Audio => 'a',
StreamType.Video => 'v'
};
filter.Append($"[0:{av}]{nullFilter}[{av}{index}]");
while (filterQueue.TryDequeue(out string result))
{
@ -372,11 +368,5 @@ namespace ErsatzTV.Core.FFmpeg @@ -372,11 +368,5 @@ namespace ErsatzTV.Core.FFmpeg
}
private record FilterResult(string Filter, string FinalLabel);
private enum StreamType
{
Audio,
Video
}
}
}

11
ErsatzTV.Core/Interfaces/Images/IImageCache.cs

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
using System.Threading.Tasks;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Images
{
public interface IImageCache
{
Task<Either<BaseError, string>> ResizeAndSaveImage(byte[] imageBuffer, int? height, int? width);
Task<Either<BaseError, string>> SaveImage(byte[] imageBuffer);
}
}

10
ErsatzTV.Core/Interfaces/Metadata/ILocalPosterProvider.cs

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ILocalPosterProvider
{
Task RefreshPoster(MediaItem mediaItem);
}
}

4
ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.AggregateModels;
using ErsatzTV.Core.Domain;
using LanguageExt;
@ -11,7 +12,8 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -11,7 +12,8 @@ namespace ErsatzTV.Core.Interfaces.Repositories
public Task<Option<MediaItem>> Get(int id);
public Task<List<MediaItem>> GetAll();
public Task<List<MediaItem>> Search(string searchString);
public Task<List<MediaItem>> GetAll(MediaType mediaType);
public Task<List<MediaItemSummary>> GetPageByType(MediaType mediaType, int pageNumber, int pageSize);
public Task<int> GetCountByType(MediaType mediaType);
public Task<List<MediaItem>> GetAllByMediaSourceId(int mediaSourceId);
public Task Update(MediaItem mediaItem);
public Task Delete(int mediaItemId);

4
ErsatzTV.Core/Metadata/LocalMediaScanner.cs

@ -16,6 +16,7 @@ namespace ErsatzTV.Core.Metadata @@ -16,6 +16,7 @@ namespace ErsatzTV.Core.Metadata
public class LocalMediaScanner : ILocalMediaScanner
{
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILocalPosterProvider _localPosterProvider;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILogger<LocalMediaScanner> _logger;
private readonly IMediaItemRepository _mediaItemRepository;
@ -28,6 +29,7 @@ namespace ErsatzTV.Core.Metadata @@ -28,6 +29,7 @@ namespace ErsatzTV.Core.Metadata
IPlayoutRepository playoutRepository,
ILocalStatisticsProvider localStatisticsProvider,
ILocalMetadataProvider localMetadataProvider,
ILocalPosterProvider localPosterProvider,
ISmartCollectionBuilder smartCollectionBuilder,
IPlayoutBuilder playoutBuilder,
ILogger<LocalMediaScanner> logger)
@ -36,6 +38,7 @@ namespace ErsatzTV.Core.Metadata @@ -36,6 +38,7 @@ namespace ErsatzTV.Core.Metadata
_playoutRepository = playoutRepository;
_localStatisticsProvider = localStatisticsProvider;
_localMetadataProvider = localMetadataProvider;
_localPosterProvider = localPosterProvider;
_smartCollectionBuilder = smartCollectionBuilder;
_playoutBuilder = playoutBuilder;
_logger = logger;
@ -146,6 +149,7 @@ namespace ErsatzTV.Core.Metadata @@ -146,6 +149,7 @@ namespace ErsatzTV.Core.Metadata
{
await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem);
await _localMetadataProvider.RefreshMetadata(mediaItem);
await _localPosterProvider.RefreshPoster(mediaItem);
await _smartCollectionBuilder.RefreshSmartCollections(mediaItem);
}

4
ErsatzTV.Core/Metadata/LocalMetadataProvider.cs

@ -43,6 +43,10 @@ namespace ErsatzTV.Core.Metadata @@ -43,6 +43,10 @@ namespace ErsatzTV.Core.Metadata
mediaItem.Metadata.MediaType = metadata.MediaType;
mediaItem.Metadata.Title = metadata.Title;
mediaItem.Metadata.Subtitle = metadata.Subtitle;
mediaItem.Metadata.SortTitle =
(metadata.Title ?? string.Empty).ToLowerInvariant().StartsWith("the ")
? metadata.Title?.Substring(4)
: metadata.Title;
mediaItem.Metadata.Description = metadata.Description;
mediaItem.Metadata.EpisodeNumber = metadata.EpisodeNumber;
mediaItem.Metadata.SeasonNumber = metadata.SeasonNumber;

90
ErsatzTV.Core/Metadata/LocalPosterProvider.cs

@ -0,0 +1,90 @@ @@ -0,0 +1,90 @@
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Metadata
{
public class LocalPosterProvider : ILocalPosterProvider
{
private readonly IImageCache _imageCache;
private readonly ILogger<LocalPosterProvider> _logger;
private readonly IMediaItemRepository _mediaItemRepository;
public LocalPosterProvider(
IMediaItemRepository mediaItemRepository,
IImageCache imageCache,
ILogger<LocalPosterProvider> logger)
{
_mediaItemRepository = mediaItemRepository;
_imageCache = imageCache;
_logger = logger;
}
public async Task RefreshPoster(MediaItem mediaItem)
{
Option<string> maybePosterPath = mediaItem.Metadata.MediaType switch
{
MediaType.Movie => RefreshMoviePoster(mediaItem),
MediaType.TvShow => RefreshTelevisionPoster(mediaItem),
_ => None
};
await maybePosterPath.Match(
path => SavePosterToDisk(mediaItem, path),
Task.CompletedTask);
}
private static Option<string> RefreshMoviePoster(MediaItem mediaItem)
{
string folder = Path.GetDirectoryName(mediaItem.Path);
if (folder != null)
{
string[] possiblePaths =
{ "poster.jpg", Path.GetFileNameWithoutExtension(mediaItem.Path) + "-poster.jpg" };
Option<string> maybePoster =
possiblePaths.Map(p => Path.Combine(folder, p)).FirstOrDefault(File.Exists);
return maybePoster;
}
return None;
}
private Option<string> RefreshTelevisionPoster(MediaItem mediaItem)
{
string folder = Directory.GetParent(Path.GetDirectoryName(mediaItem.Path) ?? string.Empty)?.FullName;
if (folder != null)
{
string[] possiblePaths = { "poster.jpg" };
Option<string> maybePoster =
possiblePaths.Map(p => Path.Combine(folder, p)).FirstOrDefault(File.Exists);
return maybePoster;
}
return None;
}
private async Task SavePosterToDisk(MediaItem mediaItem, string posterPath)
{
byte[] originalBytes = await File.ReadAllBytesAsync(posterPath);
Either<BaseError, string> maybeHash = await _imageCache.ResizeAndSaveImage(originalBytes, 220, null);
await maybeHash.Match(
hash =>
{
mediaItem.Poster = hash;
return _mediaItemRepository.Update(mediaItem);
},
error =>
{
_logger.LogWarning("Unable to save poster to disk from {Path}: {Error}", posterPath, error.Value);
return Task.CompletedTask;
});
}
}
}

2
ErsatzTV.Infrastructure/Data/Configurations/GenericIntegerIdConfiguration.cs

@ -7,6 +7,6 @@ namespace ErsatzTV.Infrastructure.Data.Configurations @@ -7,6 +7,6 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
public class GenericIntegerIdConfiguration : IEntityTypeConfiguration<GenericIntegerId>
{
public void Configure(EntityTypeBuilder<GenericIntegerId> builder) =>
builder.HasNoKey();
builder.HasNoKey().ToView(null);
}
}

12
ErsatzTV.Infrastructure/Data/Configurations/LogEntryConfiguration.cs

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

2
ErsatzTV.Infrastructure/Data/Configurations/MediaCollectionSummaryConfiguration.cs

@ -7,6 +7,6 @@ namespace ErsatzTV.Infrastructure.Data.Configurations @@ -7,6 +7,6 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
public class MediaCollectionSummaryConfiguration : IEntityTypeConfiguration<MediaCollectionSummary>
{
public void Configure(EntityTypeBuilder<MediaCollectionSummary> builder) =>
builder.HasNoKey();
builder.HasNoKey().ToView(null);
}
}

12
ErsatzTV.Infrastructure/Data/Configurations/MediaItemSummaryConfiguration.cs

@ -0,0 +1,12 @@ @@ -0,0 +1,12 @@
using ErsatzTV.Core.AggregateModels;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class MediaItemSummaryConfiguration : IEntityTypeConfiguration<MediaItemSummary>
{
public void Configure(EntityTypeBuilder<MediaItemSummary> builder) =>
builder.HasNoKey().ToView(null);
}
}

2
ErsatzTV.Infrastructure/Data/LogContext.cs

@ -15,7 +15,7 @@ namespace ErsatzTV.Infrastructure.Data @@ -15,7 +15,7 @@ namespace ErsatzTV.Infrastructure.Data
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.ApplyConfigurationsFromAssembly(typeof(LogContext).Assembly);
builder.Entity<LogEntry>().ToTable("Logs");
}
}
}

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

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.AggregateModels;
@ -68,7 +69,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -68,7 +69,8 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
collection => collection switch
{
SimpleMediaCollection s => SimpleItems(s),
TelevisionMediaCollection t => TelevisionItems(t)
TelevisionMediaCollection t => TelevisionItems(t),
_ => throw new NotSupportedException($"Unsupported collection type {collection.GetType().Name}")
}).Bind(x => x.Sequence());
public Task<Option<List<MediaItem>>> GetSimpleMediaCollectionItems(int id) =>

51
ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.AggregateModels;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
@ -40,11 +41,51 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -40,11 +41,51 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
}
public Task<List<MediaItem>> GetAll(MediaType mediaType) =>
_dbContext.MediaItems
.Include(i => i.Source)
.Filter(i => i.Metadata.MediaType == mediaType)
.ToListAsync();
public Task<List<MediaItemSummary>> GetPageByType(MediaType mediaType, int pageNumber, int pageSize) =>
mediaType switch
{
MediaType.Movie => _dbContext.MediaItemSummaries.FromSqlRaw(
@"SELECT
Metadata_Title AS Title,
Metadata_SortTitle AS SortTitle,
substr(Metadata_Aired, 1, 4) AS Subtitle,
Poster
FROM MediaItems WHERE Metadata_MediaType=2
ORDER BY Metadata_SortTitle
LIMIT {0} OFFSET {1}",
pageSize,
(pageNumber - 1) * pageSize)
.AsNoTracking()
.ToListAsync(),
MediaType.TvShow => _dbContext.MediaItemSummaries.FromSqlRaw(
@"SELECT
Metadata_Title AS Title,
Metadata_SortTitle AS SortTitle,
count(*) || ' Episodes' AS Subtitle,
min(Poster) AS Poster
FROM MediaItems WHERE Metadata_MediaType=1
GROUP BY Metadata_Title, Metadata_SortTitle
ORDER BY Metadata_SortTitle
LIMIT {0} OFFSET {1}",
pageSize,
(pageNumber - 1) * pageSize)
.AsNoTracking()
.ToListAsync(),
_ => Task.FromResult(new List<MediaItemSummary>())
};
public Task<int> GetCountByType(MediaType mediaType) =>
mediaType switch
{
MediaType.Movie => _dbContext.MediaItems
.Filter(i => i.Metadata.MediaType == mediaType)
.CountAsync(),
MediaType.TvShow => _dbContext.MediaItems
.Filter(i => i.Metadata.MediaType == mediaType)
.GroupBy(i => new { i.Metadata.Title, i.Metadata.SortTitle })
.CountAsync(),
_ => Task.FromResult(0)
};
public Task<List<MediaItem>> GetAllByMediaSourceId(int mediaSourceId) =>
_dbContext.MediaItems

6
ErsatzTV.Infrastructure/Data/TvContext.cs

@ -32,6 +32,7 @@ namespace ErsatzTV.Infrastructure.Data @@ -32,6 +32,7 @@ namespace ErsatzTV.Infrastructure.Data
// support raw sql queries
public DbSet<MediaCollectionSummary> MediaCollectionSummaries { get; set; }
public DbSet<GenericIntegerId> GenericIntegerIds { get; set; }
public DbSet<MediaItemSummary> MediaItemSummaries { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) =>
optionsBuilder.UseLoggerFactory(_loggerFactory);
@ -39,6 +40,11 @@ namespace ErsatzTV.Infrastructure.Data @@ -39,6 +40,11 @@ namespace ErsatzTV.Infrastructure.Data
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.Ignore<MediaCollectionSummary>();
builder.Ignore<GenericIntegerId>();
builder.Ignore<MediaItemSummary>();
builder.ApplyConfigurationsFromAssembly(typeof(TvContext).Assembly);
}
}

1
ErsatzTV.Infrastructure/ErsatzTV.Infrastructure.csproj

@ -13,6 +13,7 @@ @@ -13,6 +13,7 @@
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.3" />
<PackageReference Include="Refit" Version="6.0.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="1.0.2" />
</ItemGroup>
<ItemGroup>

64
ErsatzTV.Infrastructure/Images/ImageCache.cs

@ -0,0 +1,64 @@ @@ -0,0 +1,64 @@
using System;
using System.IO;
using System.Security.Cryptography;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Images;
using LanguageExt;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.Processing;
namespace ErsatzTV.Infrastructure.Images
{
public class ImageCache : IImageCache
{
private static readonly SHA1CryptoServiceProvider Crypto;
static ImageCache() => Crypto = new SHA1CryptoServiceProvider();
public async Task<Either<BaseError, string>> ResizeAndSaveImage(byte[] imageBuffer, int? height, int? width)
{
await using var inStream = new MemoryStream(imageBuffer);
using var image = await Image.LoadAsync(inStream);
Size size = height.HasValue ? new Size { Height = height.Value } : new Size { Width = width.Value };
image.Mutate(
i => i.Resize(
new ResizeOptions
{
Mode = ResizeMode.Max,
Size = size
}));
await using var outStream = new MemoryStream();
await image.SaveAsync(outStream, new JpegEncoder { Quality = 90 });
return await SaveImage(outStream.ToArray());
}
public async Task<Either<BaseError, string>> SaveImage(byte[] imageBuffer)
{
try
{
byte[] hash = Crypto.ComputeHash(imageBuffer);
string hex = BitConverter.ToString(hash).Replace("-", string.Empty);
string fileName = Path.Combine(FileSystemLayout.ImageCacheFolder, hex);
if (!Directory.Exists(FileSystemLayout.ImageCacheFolder))
{
Directory.CreateDirectory(FileSystemLayout.ImageCacheFolder);
}
await File.WriteAllBytesAsync(fileName, imageBuffer);
return hex;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
}
}

879
ErsatzTV.Infrastructure/Migrations/20210213155419_MetadataSortTitle.Designer.cs generated

@ -0,0 +1,879 @@ @@ -0,0 +1,879 @@
// <auto-generated />
using System;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace ErsatzTV.Infrastructure.Migrations
{
[DbContext(typeof(TvContext))]
[Migration("20210213155419_MetadataSortTitle")]
partial class MetadataSortTitle
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.3");
modelBuilder.Entity("ErsatzTV.Core.AggregateModels.GenericIntegerId", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.ToTable("GenericIntegerIds");
});
modelBuilder.Entity("ErsatzTV.Core.AggregateModels.MediaCollectionSummary", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<bool>("IsSimple")
.HasColumnType("INTEGER");
b.Property<int>("ItemCount")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.ToTable("MediaCollectionSummaries");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("FFmpegProfileId")
.HasColumnType("INTEGER");
b.Property<string>("Logo")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<int>("StreamingMode")
.HasColumnType("INTEGER");
b.Property<Guid>("UniqueId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("FFmpegProfileId");
b.HasIndex("Number")
.IsUnique();
b.ToTable("Channels");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ConfigElement", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("ConfigElements");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AudioBitrate")
.HasColumnType("INTEGER");
b.Property<int>("AudioBufferSize")
.HasColumnType("INTEGER");
b.Property<int>("AudioChannels")
.HasColumnType("INTEGER");
b.Property<string>("AudioCodec")
.HasColumnType("TEXT");
b.Property<int>("AudioSampleRate")
.HasColumnType("INTEGER");
b.Property<int>("AudioVolume")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<bool>("NormalizeAudio")
.HasColumnType("INTEGER");
b.Property<bool>("NormalizeAudioCodec")
.HasColumnType("INTEGER");
b.Property<bool>("NormalizeResolution")
.HasColumnType("INTEGER");
b.Property<bool>("NormalizeVideoCodec")
.HasColumnType("INTEGER");
b.Property<int>("ResolutionId")
.HasColumnType("INTEGER");
b.Property<int>("ThreadCount")
.HasColumnType("INTEGER");
b.Property<bool>("Transcode")
.HasColumnType("INTEGER");
b.Property<int>("VideoBitrate")
.HasColumnType("INTEGER");
b.Property<int>("VideoBufferSize")
.HasColumnType("INTEGER");
b.Property<string>("VideoCodec")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ResolutionId");
b.ToTable("FFmpegProfiles");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaCollection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("MediaCollections");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastWriteTime")
.HasColumnType("TEXT");
b.Property<int>("MediaSourceId")
.HasColumnType("INTEGER");
b.Property<string>("Path")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MediaSourceId");
b.ToTable("MediaItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaSource", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("SourceType")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("MediaSources");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChannelId")
.HasColumnType("INTEGER");
b.Property<int>("ProgramScheduleId")
.HasColumnType("INTEGER");
b.Property<int>("ProgramSchedulePlayoutType")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChannelId");
b.HasIndex("ProgramScheduleId");
b.ToTable("Playouts");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Finish")
.HasColumnType("TEXT");
b.Property<int>("MediaItemId")
.HasColumnType("INTEGER");
b.Property<int>("PlayoutId")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Start")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MediaItemId");
b.HasIndex("PlayoutId");
b.ToTable("PlayoutItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b =>
{
b.Property<int>("PlayoutId")
.HasColumnType("INTEGER");
b.Property<int>("ProgramScheduleId")
.HasColumnType("INTEGER");
b.Property<int>("MediaCollectionId")
.HasColumnType("INTEGER");
b.HasKey("PlayoutId", "ProgramScheduleId", "MediaCollectionId");
b.HasIndex("MediaCollectionId");
b.HasIndex("ProgramScheduleId");
b.ToTable("PlayoutProgramScheduleItemAnchors");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceConnection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<int?>("PlexMediaSourceId")
.HasColumnType("INTEGER");
b.Property<string>("Uri")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlexMediaSourceId");
b.ToTable("PlexMediaSourceConnections");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceLibrary", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<int>("MediaType")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("PlexMediaSourceId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlexMediaSourceId");
b.ToTable("PlexMediaSourceLibraries");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("MediaCollectionPlaybackOrder")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("ProgramSchedules");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<int>("MediaCollectionId")
.HasColumnType("INTEGER");
b.Property<int>("ProgramScheduleId")
.HasColumnType("INTEGER");
b.Property<TimeSpan?>("StartTime")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MediaCollectionId");
b.HasIndex("ProgramScheduleId");
b.ToTable("ProgramScheduleItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Resolution", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Height")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Resolutions");
});
modelBuilder.Entity("MediaItemSimpleMediaCollection", b =>
{
b.Property<int>("ItemsId")
.HasColumnType("INTEGER");
b.Property<int>("SimpleMediaCollectionsId")
.HasColumnType("INTEGER");
b.HasKey("ItemsId", "SimpleMediaCollectionsId");
b.HasIndex("SimpleMediaCollectionsId");
b.ToTable("MediaItemSimpleMediaCollection");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.SimpleMediaCollection", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaCollection");
b.ToTable("SimpleMediaCollections");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionMediaCollection", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaCollection");
b.Property<int?>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<string>("ShowTitle")
.HasColumnType("TEXT");
b.HasIndex("ShowTitle", "SeasonNumber")
.IsUnique();
b.ToTable("TelevisionMediaCollections");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaSource");
b.Property<string>("Folder")
.HasColumnType("TEXT");
b.Property<int>("MediaType")
.HasColumnType("INTEGER");
b.ToTable("LocalMediaSources");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaSource");
b.Property<string>("ClientIdentifier")
.HasColumnType("TEXT");
b.Property<string>("ProductVersion")
.HasColumnType("TEXT");
b.ToTable("PlexMediaSources");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.Property<bool>("OfflineTail")
.HasColumnType("INTEGER");
b.Property<TimeSpan>("PlayoutDuration")
.HasColumnType("TEXT");
b.ToTable("ProgramScheduleDurationItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.ToTable("ProgramScheduleFloodItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.Property<int>("Count")
.HasColumnType("INTEGER");
b.ToTable("ProgramScheduleMultipleItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.ToTable("ProgramScheduleOneItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b =>
{
b.HasOne("ErsatzTV.Core.Domain.FFmpegProfile", "FFmpegProfile")
.WithMany()
.HasForeignKey("FFmpegProfileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("FFmpegProfile");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Resolution", "Resolution")
.WithMany()
.HasForeignKey("ResolutionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Resolution");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaSource", "Source")
.WithMany()
.HasForeignKey("MediaSourceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("ErsatzTV.Core.Domain.MediaMetadata", "Metadata", b1 =>
{
b1.Property<int>("MediaItemId")
.HasColumnType("INTEGER");
b1.Property<DateTime?>("Aired")
.HasColumnType("TEXT");
b1.Property<string>("AudioCodec")
.HasColumnType("TEXT");
b1.Property<string>("ContentRating")
.HasColumnType("TEXT");
b1.Property<string>("Description")
.HasColumnType("TEXT");
b1.Property<string>("DisplayAspectRatio")
.HasColumnType("TEXT");
b1.Property<TimeSpan>("Duration")
.HasColumnType("TEXT");
b1.Property<int?>("EpisodeNumber")
.HasColumnType("INTEGER");
b1.Property<int>("Height")
.HasColumnType("INTEGER");
b1.Property<int>("MediaType")
.HasColumnType("INTEGER");
b1.Property<string>("SampleAspectRatio")
.HasColumnType("TEXT");
b1.Property<int?>("SeasonNumber")
.HasColumnType("INTEGER");
b1.Property<string>("SortTitle")
.HasColumnType("TEXT");
b1.Property<string>("Subtitle")
.HasColumnType("TEXT");
b1.Property<string>("Title")
.HasColumnType("TEXT");
b1.Property<string>("VideoCodec")
.HasColumnType("TEXT");
b1.Property<int>("VideoScanType")
.HasColumnType("INTEGER");
b1.Property<int>("Width")
.HasColumnType("INTEGER");
b1.HasKey("MediaItemId");
b1.ToTable("MediaItems");
b1.WithOwner()
.HasForeignKey("MediaItemId");
});
b.Navigation("Metadata");
b.Navigation("Source");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Channel", "Channel")
.WithMany("Playouts")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule")
.WithMany("Playouts")
.HasForeignKey("ProgramScheduleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("ErsatzTV.Core.Domain.PlayoutAnchor", "Anchor", b1 =>
{
b1.Property<int>("PlayoutId")
.HasColumnType("INTEGER");
b1.Property<int>("NextScheduleItemId")
.HasColumnType("INTEGER");
b1.Property<DateTimeOffset>("NextStart")
.HasColumnType("TEXT");
b1.HasKey("PlayoutId");
b1.HasIndex("NextScheduleItemId");
b1.ToTable("Playouts");
b1.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", "NextScheduleItem")
.WithMany()
.HasForeignKey("NextScheduleItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b1.WithOwner()
.HasForeignKey("PlayoutId");
b1.Navigation("NextScheduleItem");
});
b.Navigation("Anchor");
b.Navigation("Channel");
b.Navigation("ProgramSchedule");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem")
.WithMany()
.HasForeignKey("MediaItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout")
.WithMany("Items")
.HasForeignKey("PlayoutId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MediaItem");
b.Navigation("Playout");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaCollection", "MediaCollection")
.WithMany()
.HasForeignKey("MediaCollectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout")
.WithMany("ProgramScheduleAnchors")
.HasForeignKey("PlayoutId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule")
.WithMany()
.HasForeignKey("ProgramScheduleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("ErsatzTV.Core.Domain.MediaCollectionEnumeratorState", "EnumeratorState", b1 =>
{
b1.Property<int>("PlayoutProgramScheduleAnchorPlayoutId")
.HasColumnType("INTEGER");
b1.Property<int>("PlayoutProgramScheduleAnchorProgramScheduleId")
.HasColumnType("INTEGER");
b1.Property<int>("PlayoutProgramScheduleAnchorMediaCollectionId")
.HasColumnType("INTEGER");
b1.Property<int>("Index")
.HasColumnType("INTEGER");
b1.Property<int>("Seed")
.HasColumnType("INTEGER");
b1.HasKey("PlayoutProgramScheduleAnchorPlayoutId", "PlayoutProgramScheduleAnchorProgramScheduleId", "PlayoutProgramScheduleAnchorMediaCollectionId");
b1.ToTable("PlayoutProgramScheduleItemAnchors");
b1.WithOwner()
.HasForeignKey("PlayoutProgramScheduleAnchorPlayoutId", "PlayoutProgramScheduleAnchorProgramScheduleId", "PlayoutProgramScheduleAnchorMediaCollectionId");
});
b.Navigation("EnumeratorState");
b.Navigation("MediaCollection");
b.Navigation("Playout");
b.Navigation("ProgramSchedule");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceConnection", b =>
{
b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", null)
.WithMany("Connections")
.HasForeignKey("PlexMediaSourceId");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceLibrary", b =>
{
b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", null)
.WithMany("Libraries")
.HasForeignKey("PlexMediaSourceId");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaCollection", "MediaCollection")
.WithMany()
.HasForeignKey("MediaCollectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule")
.WithMany("Items")
.HasForeignKey("ProgramScheduleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MediaCollection");
b.Navigation("ProgramSchedule");
});
modelBuilder.Entity("MediaItemSimpleMediaCollection", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
.WithMany()
.HasForeignKey("ItemsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.SimpleMediaCollection", null)
.WithMany()
.HasForeignKey("SimpleMediaCollectionsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.SimpleMediaCollection", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaCollection", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.SimpleMediaCollection", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionMediaCollection", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaCollection", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.TelevisionMediaCollection", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaSource", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.LocalMediaSource", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaSource", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.PlexMediaSource", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemOne", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b =>
{
b.Navigation("Playouts");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
{
b.Navigation("Items");
b.Navigation("ProgramScheduleAnchors");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b =>
{
b.Navigation("Items");
b.Navigation("Playouts");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b =>
{
b.Navigation("Connections");
b.Navigation("Libraries");
});
#pragma warning restore 612, 618
}
}
}

30
ErsatzTV.Infrastructure/Migrations/20210213155419_MetadataSortTitle.cs

@ -0,0 +1,30 @@ @@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class MetadataSortTitle : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
"Metadata_SortTitle",
"MediaItems",
"TEXT",
nullable: true);
migrationBuilder.Sql(
@"UPDATE MediaItems
SET Metadata_SortTitle = Metadata_Title");
migrationBuilder.Sql(
@"UPDATE MediaItems
SET Metadata_SortTitle = substr(Metadata_Title, 5)
WHERE Metadata_Title LIKE 'the %'");
}
protected override void Down(MigrationBuilder migrationBuilder) =>
migrationBuilder.DropColumn(
"Metadata_SortTitle",
"MediaItems");
}
}

893
ErsatzTV.Infrastructure/Migrations/20210213221040_MediaItemPoster.Designer.cs generated

@ -0,0 +1,893 @@ @@ -0,0 +1,893 @@
// <auto-generated />
using System;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace ErsatzTV.Infrastructure.Migrations
{
[DbContext(typeof(TvContext))]
[Migration("20210213221040_MediaItemPoster")]
partial class MediaItemPoster
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.3");
modelBuilder.Entity("ErsatzTV.Core.AggregateModels.GenericIntegerId", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
});
modelBuilder.Entity("ErsatzTV.Core.AggregateModels.MediaCollectionSummary", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.Property<bool>("IsSimple")
.HasColumnType("INTEGER");
b.Property<int>("ItemCount")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
});
modelBuilder.Entity("ErsatzTV.Core.AggregateModels.MediaItemSummary", b =>
{
b.Property<string>("Poster")
.HasColumnType("TEXT");
b.Property<string>("SortTitle")
.HasColumnType("TEXT");
b.Property<string>("Subtitle")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("FFmpegProfileId")
.HasColumnType("INTEGER");
b.Property<string>("Logo")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<int>("StreamingMode")
.HasColumnType("INTEGER");
b.Property<Guid>("UniqueId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("FFmpegProfileId");
b.HasIndex("Number")
.IsUnique();
b.ToTable("Channels");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ConfigElement", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Key")
.IsUnique();
b.ToTable("ConfigElements");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("AudioBitrate")
.HasColumnType("INTEGER");
b.Property<int>("AudioBufferSize")
.HasColumnType("INTEGER");
b.Property<int>("AudioChannels")
.HasColumnType("INTEGER");
b.Property<string>("AudioCodec")
.HasColumnType("TEXT");
b.Property<int>("AudioSampleRate")
.HasColumnType("INTEGER");
b.Property<int>("AudioVolume")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<bool>("NormalizeAudio")
.HasColumnType("INTEGER");
b.Property<bool>("NormalizeAudioCodec")
.HasColumnType("INTEGER");
b.Property<bool>("NormalizeResolution")
.HasColumnType("INTEGER");
b.Property<bool>("NormalizeVideoCodec")
.HasColumnType("INTEGER");
b.Property<int>("ResolutionId")
.HasColumnType("INTEGER");
b.Property<int>("ThreadCount")
.HasColumnType("INTEGER");
b.Property<bool>("Transcode")
.HasColumnType("INTEGER");
b.Property<int>("VideoBitrate")
.HasColumnType("INTEGER");
b.Property<int>("VideoBufferSize")
.HasColumnType("INTEGER");
b.Property<string>("VideoCodec")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ResolutionId");
b.ToTable("FFmpegProfiles");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaCollection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("MediaCollections");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastWriteTime")
.HasColumnType("TEXT");
b.Property<int>("MediaSourceId")
.HasColumnType("INTEGER");
b.Property<string>("Path")
.HasColumnType("TEXT");
b.Property<string>("Poster")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MediaSourceId");
b.ToTable("MediaItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaSource", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("SourceType")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("MediaSources");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChannelId")
.HasColumnType("INTEGER");
b.Property<int>("ProgramScheduleId")
.HasColumnType("INTEGER");
b.Property<int>("ProgramSchedulePlayoutType")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChannelId");
b.HasIndex("ProgramScheduleId");
b.ToTable("Playouts");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Finish")
.HasColumnType("TEXT");
b.Property<int>("MediaItemId")
.HasColumnType("INTEGER");
b.Property<int>("PlayoutId")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Start")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MediaItemId");
b.HasIndex("PlayoutId");
b.ToTable("PlayoutItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b =>
{
b.Property<int>("PlayoutId")
.HasColumnType("INTEGER");
b.Property<int>("ProgramScheduleId")
.HasColumnType("INTEGER");
b.Property<int>("MediaCollectionId")
.HasColumnType("INTEGER");
b.HasKey("PlayoutId", "ProgramScheduleId", "MediaCollectionId");
b.HasIndex("MediaCollectionId");
b.HasIndex("ProgramScheduleId");
b.ToTable("PlayoutProgramScheduleItemAnchors");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceConnection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("IsActive")
.HasColumnType("INTEGER");
b.Property<int?>("PlexMediaSourceId")
.HasColumnType("INTEGER");
b.Property<string>("Uri")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PlexMediaSourceId");
b.ToTable("PlexMediaSourceConnections");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceLibrary", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<int>("MediaType")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("PlexMediaSourceId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PlexMediaSourceId");
b.ToTable("PlexMediaSourceLibraries");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("MediaCollectionPlaybackOrder")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("ProgramSchedules");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<int>("MediaCollectionId")
.HasColumnType("INTEGER");
b.Property<int>("ProgramScheduleId")
.HasColumnType("INTEGER");
b.Property<TimeSpan?>("StartTime")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MediaCollectionId");
b.HasIndex("ProgramScheduleId");
b.ToTable("ProgramScheduleItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Resolution", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Height")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Resolutions");
});
modelBuilder.Entity("MediaItemSimpleMediaCollection", b =>
{
b.Property<int>("ItemsId")
.HasColumnType("INTEGER");
b.Property<int>("SimpleMediaCollectionsId")
.HasColumnType("INTEGER");
b.HasKey("ItemsId", "SimpleMediaCollectionsId");
b.HasIndex("SimpleMediaCollectionsId");
b.ToTable("MediaItemSimpleMediaCollection");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.SimpleMediaCollection", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaCollection");
b.ToTable("SimpleMediaCollections");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionMediaCollection", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaCollection");
b.Property<int?>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<string>("ShowTitle")
.HasColumnType("TEXT");
b.HasIndex("ShowTitle", "SeasonNumber")
.IsUnique();
b.ToTable("TelevisionMediaCollections");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaSource");
b.Property<string>("Folder")
.HasColumnType("TEXT");
b.Property<int>("MediaType")
.HasColumnType("INTEGER");
b.ToTable("LocalMediaSources");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaSource");
b.Property<string>("ClientIdentifier")
.HasColumnType("TEXT");
b.Property<string>("ProductVersion")
.HasColumnType("TEXT");
b.ToTable("PlexMediaSources");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.Property<bool>("OfflineTail")
.HasColumnType("INTEGER");
b.Property<TimeSpan>("PlayoutDuration")
.HasColumnType("TEXT");
b.ToTable("ProgramScheduleDurationItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.ToTable("ProgramScheduleFloodItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.Property<int>("Count")
.HasColumnType("INTEGER");
b.ToTable("ProgramScheduleMultipleItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.ProgramScheduleItem");
b.ToTable("ProgramScheduleOneItems");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b =>
{
b.HasOne("ErsatzTV.Core.Domain.FFmpegProfile", "FFmpegProfile")
.WithMany()
.HasForeignKey("FFmpegProfileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("FFmpegProfile");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.FFmpegProfile", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Resolution", "Resolution")
.WithMany()
.HasForeignKey("ResolutionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Resolution");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MediaItem", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaSource", "Source")
.WithMany()
.HasForeignKey("MediaSourceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("ErsatzTV.Core.Domain.MediaMetadata", "Metadata", b1 =>
{
b1.Property<int>("MediaItemId")
.HasColumnType("INTEGER");
b1.Property<DateTime?>("Aired")
.HasColumnType("TEXT");
b1.Property<string>("AudioCodec")
.HasColumnType("TEXT");
b1.Property<string>("ContentRating")
.HasColumnType("TEXT");
b1.Property<string>("Description")
.HasColumnType("TEXT");
b1.Property<string>("DisplayAspectRatio")
.HasColumnType("TEXT");
b1.Property<TimeSpan>("Duration")
.HasColumnType("TEXT");
b1.Property<int?>("EpisodeNumber")
.HasColumnType("INTEGER");
b1.Property<int>("Height")
.HasColumnType("INTEGER");
b1.Property<int>("MediaType")
.HasColumnType("INTEGER");
b1.Property<string>("SampleAspectRatio")
.HasColumnType("TEXT");
b1.Property<int?>("SeasonNumber")
.HasColumnType("INTEGER");
b1.Property<string>("SortTitle")
.HasColumnType("TEXT");
b1.Property<string>("Subtitle")
.HasColumnType("TEXT");
b1.Property<string>("Title")
.HasColumnType("TEXT");
b1.Property<string>("VideoCodec")
.HasColumnType("TEXT");
b1.Property<int>("VideoScanType")
.HasColumnType("INTEGER");
b1.Property<int>("Width")
.HasColumnType("INTEGER");
b1.HasKey("MediaItemId");
b1.ToTable("MediaItems");
b1.WithOwner()
.HasForeignKey("MediaItemId");
});
b.Navigation("Metadata");
b.Navigation("Source");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
{
b.HasOne("ErsatzTV.Core.Domain.Channel", "Channel")
.WithMany("Playouts")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule")
.WithMany("Playouts")
.HasForeignKey("ProgramScheduleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("ErsatzTV.Core.Domain.PlayoutAnchor", "Anchor", b1 =>
{
b1.Property<int>("PlayoutId")
.HasColumnType("INTEGER");
b1.Property<int>("NextScheduleItemId")
.HasColumnType("INTEGER");
b1.Property<DateTimeOffset>("NextStart")
.HasColumnType("TEXT");
b1.HasKey("PlayoutId");
b1.HasIndex("NextScheduleItemId");
b1.ToTable("Playouts");
b1.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", "NextScheduleItem")
.WithMany()
.HasForeignKey("NextScheduleItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b1.WithOwner()
.HasForeignKey("PlayoutId");
b1.Navigation("NextScheduleItem");
});
b.Navigation("Anchor");
b.Navigation("Channel");
b.Navigation("ProgramSchedule");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutItem", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", "MediaItem")
.WithMany()
.HasForeignKey("MediaItemId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout")
.WithMany("Items")
.HasForeignKey("PlayoutId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MediaItem");
b.Navigation("Playout");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlayoutProgramScheduleAnchor", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaCollection", "MediaCollection")
.WithMany()
.HasForeignKey("MediaCollectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.Playout", "Playout")
.WithMany("ProgramScheduleAnchors")
.HasForeignKey("PlayoutId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule")
.WithMany()
.HasForeignKey("ProgramScheduleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("ErsatzTV.Core.Domain.MediaCollectionEnumeratorState", "EnumeratorState", b1 =>
{
b1.Property<int>("PlayoutProgramScheduleAnchorPlayoutId")
.HasColumnType("INTEGER");
b1.Property<int>("PlayoutProgramScheduleAnchorProgramScheduleId")
.HasColumnType("INTEGER");
b1.Property<int>("PlayoutProgramScheduleAnchorMediaCollectionId")
.HasColumnType("INTEGER");
b1.Property<int>("Index")
.HasColumnType("INTEGER");
b1.Property<int>("Seed")
.HasColumnType("INTEGER");
b1.HasKey("PlayoutProgramScheduleAnchorPlayoutId", "PlayoutProgramScheduleAnchorProgramScheduleId", "PlayoutProgramScheduleAnchorMediaCollectionId");
b1.ToTable("PlayoutProgramScheduleItemAnchors");
b1.WithOwner()
.HasForeignKey("PlayoutProgramScheduleAnchorPlayoutId", "PlayoutProgramScheduleAnchorProgramScheduleId", "PlayoutProgramScheduleAnchorMediaCollectionId");
});
b.Navigation("EnumeratorState");
b.Navigation("MediaCollection");
b.Navigation("Playout");
b.Navigation("ProgramSchedule");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceConnection", b =>
{
b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", null)
.WithMany("Connections")
.HasForeignKey("PlexMediaSourceId");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSourceLibrary", b =>
{
b.HasOne("ErsatzTV.Core.Domain.PlexMediaSource", null)
.WithMany("Libraries")
.HasForeignKey("PlexMediaSourceId");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItem", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaCollection", "MediaCollection")
.WithMany()
.HasForeignKey("MediaCollectionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.ProgramSchedule", "ProgramSchedule")
.WithMany("Items")
.HasForeignKey("ProgramScheduleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("MediaCollection");
b.Navigation("ProgramSchedule");
});
modelBuilder.Entity("MediaItemSimpleMediaCollection", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
.WithMany()
.HasForeignKey("ItemsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.SimpleMediaCollection", null)
.WithMany()
.HasForeignKey("SimpleMediaCollectionsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.SimpleMediaCollection", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaCollection", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.SimpleMediaCollection", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionMediaCollection", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaCollection", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.TelevisionMediaCollection", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.LocalMediaSource", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaSource", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.LocalMediaSource", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaSource", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.PlexMediaSource", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemDuration", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemFlood", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemMultiple", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramScheduleItemOne", b =>
{
b.HasOne("ErsatzTV.Core.Domain.ProgramScheduleItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.ProgramScheduleItemOne", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Channel", b =>
{
b.Navigation("Playouts");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.Playout", b =>
{
b.Navigation("Items");
b.Navigation("ProgramScheduleAnchors");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.ProgramSchedule", b =>
{
b.Navigation("Items");
b.Navigation("Playouts");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.PlexMediaSource", b =>
{
b.Navigation("Connections");
b.Navigation("Libraries");
});
#pragma warning restore 612, 618
}
}
}

48
ErsatzTV.Infrastructure/Migrations/20210213221040_MediaItemPoster.cs

@ -0,0 +1,48 @@ @@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class MediaItemPoster : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
"GenericIntegerIds");
migrationBuilder.DropTable(
"MediaCollectionSummaries");
migrationBuilder.AddColumn<string>(
"Poster",
"MediaItems",
"TEXT",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
"Poster",
"MediaItems");
migrationBuilder.CreateTable(
"GenericIntegerIds",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
},
constraints: table => { });
migrationBuilder.CreateTable(
"MediaCollectionSummaries",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false),
IsSimple = table.Column<bool>("INTEGER", nullable: false),
ItemCount = table.Column<int>("INTEGER", nullable: false),
Name = table.Column<string>("TEXT", nullable: true)
},
constraints: table => { });
}
}
}

25
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -22,8 +22,6 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -22,8 +22,6 @@ namespace ErsatzTV.Infrastructure.Migrations
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.ToTable("GenericIntegerIds");
});
modelBuilder.Entity(
@ -41,8 +39,23 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -41,8 +39,23 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Name")
.HasColumnType("TEXT");
});
modelBuilder.Entity(
"ErsatzTV.Core.AggregateModels.MediaItemSummary",
b =>
{
b.Property<string>("Poster")
.HasColumnType("TEXT");
b.Property<string>("SortTitle")
.HasColumnType("TEXT");
b.Property<string>("Subtitle")
.HasColumnType("TEXT");
b.ToTable("MediaCollectionSummaries");
b.Property<string>("Title")
.HasColumnType("TEXT");
});
modelBuilder.Entity(
@ -205,6 +218,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -205,6 +218,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Path")
.HasColumnType("TEXT");
b.Property<string>("Poster")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MediaSourceId");
@ -631,6 +647,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -631,6 +647,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b1.Property<int?>("SeasonNumber")
.HasColumnType("INTEGER");
b1.Property<string>("SortTitle")
.HasColumnType("TEXT");
b1.Property<string>("Subtitle")
.HasColumnType("TEXT");

2
ErsatzTV.Infrastructure/Plex/PlexTvApiClient.cs

@ -105,7 +105,7 @@ namespace ErsatzTV.Infrastructure.Plex @@ -105,7 +105,7 @@ namespace ErsatzTV.Infrastructure.Plex
return true;
}
}
catch (Exception ex)
catch (Exception)
{
// ignored
}

13
ErsatzTV.sln.DotSettings

@ -2,19 +2,25 @@ @@ -2,19 +2,25 @@
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=DTO/@EntryIndexedValue">DTO</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=HDHR/@EntryIndexedValue">HDHR</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=SAR/@EntryIndexedValue">SAR</s:String>
<s:Boolean x:Key="/Default/UserDictionary/Words/=anull/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=apad/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=bufsize/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=cgop/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Deinterlace/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=deinterlaced/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=discardcorrupt/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=drawtext/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=ersatztv/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=etvignore/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=faststart/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=ffconcat/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=fflags/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=ffprobe/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=fontfile/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Fprobe/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=genpts/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=igndts/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Libavfilter/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=libx/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=maxrate/@EntryIndexedValue">True</s:Boolean>
@ -22,5 +28,10 @@ @@ -22,5 +28,10 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=mpegts/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=muxdelay/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=muxpreload/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=nostats/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Pixfmt/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=playout/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Playouts/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Playouts/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=probesize/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=setsar/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=yadif/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

28
ErsatzTV/Controllers/PostersController.cs

@ -0,0 +1,28 @@ @@ -0,0 +1,28 @@
using System.Threading.Tasks;
using ErsatzTV.Application.Images;
using ErsatzTV.Application.Images.Queries;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace ErsatzTV.Controllers
{
[ApiController]
[ApiExplorerSettings(IgnoreApi = true)]
public class PostersController : ControllerBase
{
private readonly IMediator _mediator;
public PostersController(IMediator mediator) => _mediator = mediator;
[HttpGet("/posters/{fileName}")]
public async Task<IActionResult> GetImage(string fileName)
{
Either<BaseError, ImageViewModel> imageContents = await _mediator.Send(new GetImageContents(fileName));
return imageContents.Match<IActionResult>(
Left: _ => new NotFoundResult(),
Right: r => new FileContentResult(r.Contents, r.MimeType));
}
}
}

13
ErsatzTV/Pages/LocalMediaSourceEditor.razor

@ -1,7 +1,6 @@ @@ -1,7 +1,6 @@
@page "/media/sources/local/add"
@using ErsatzTV.Application
@using ErsatzTV.Application.MediaSources
@using ErsatzTV.Application.MediaSources.Commands
@using ErsatzTV.Application.MediaSources
@inject NavigationManager NavigationManager
@inject ILogger<LocalMediaSourceEditor> Logger
@inject ISnackbar Snackbar
@ -68,7 +67,13 @@ @@ -68,7 +67,13 @@
_messageStore.Clear();
if (_editContext.Validate())
{
var command = new CreateLocalMediaSource(_model.Folder, _model.MediaType, _model.Folder);
var command = new CreateLocalMediaSource(
Convert.ToBase64String(Guid.NewGuid().ToByteArray())
.TrimEnd('=')
.Replace("/", "_")
.Replace("+", "-"),
_model.MediaType,
_model.Folder);
Either<BaseError, MediaSourceViewModel> result = await Mediator.Send(command);
await result.Match(
Left: error =>
@ -85,4 +90,6 @@ @@ -85,4 +90,6 @@
}
}
}

17
ErsatzTV/Pages/MediaItems.razor

@ -1,17 +0,0 @@ @@ -1,17 +0,0 @@
@page "/media/items"
<MudTabs Elevation="1">
<MudTabPanel Text="TV Shows">
<MediaItemTable MediaType="@MediaType.TvShow"/>
</MudTabPanel>
<MudTabPanel Text="Movies">
<MediaItemTable MediaType="@MediaType.Movie"/>
</MudTabPanel>
<MudTabPanel Text="Other">
<MediaItemTable MediaType="@MediaType.Other"/>
</MudTabPanel>
</MudTabs>
@code {
}

8
ErsatzTV/Pages/MediaMovieItems.razor

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
@page "/media/movies/items"
@inject IMediator Mediator
<MediaItemsGrid MediaType="@MediaType.Movie"/>
@code {
}

8
ErsatzTV/Pages/MediaOtherItems.razor

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
@page "/media/other/items"
@inject IMediator Mediator
<MediaItemsGrid MediaType="@MediaType.Other"/>
@code {
}

8
ErsatzTV/Pages/MediaTvItems.razor

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
@page "/media/tv/items"
@inject IMediator Mediator
<MediaItemsGrid MediaType="@MediaType.TvShow"/>
@code {
}

6
ErsatzTV/Pages/ScheduleItemsEditor.razor

@ -28,7 +28,7 @@ @@ -28,7 +28,7 @@
<RowTemplate>
<MudTd DataLabel="Start Time">
<MudText Typo="@(context == _selectedItem ? Typo.subtitle2 : Typo.body2)">
@(context.StartType == StartType.Fixed ? context.StartTime : "Dynamic")
@(context.StartType == StartType.Fixed ? context.StartTime?.ToString(@"hh\:mm") ?? string.Empty : "Dynamic")
</MudText>
</MudTd>
<MudTd DataLabel="Media Collection">
@ -73,7 +73,7 @@ @@ -73,7 +73,7 @@
<MudSelectItem Value="@startType">@startType</MudSelectItem>
}
</MudSelect>
<MudTextField Class="mt-3" Label="Start Time" @bind-Value="@_selectedItem.StartTime" For="@(() => _selectedItem.StartTime)" Disabled="@(_selectedItem.StartType == StartType.Dynamic)"/>
<MudTimePicker Class="mt-3" Label="Start Time" @bind-Time="@_selectedItem.StartTime" For="@(() => _selectedItem.StartTime)" Disabled="@(_selectedItem.StartType == StartType.Dynamic)"/>
<MudAutocomplete Class="mt-3" T="MediaCollectionViewModel" Label="Media Collection" @bind-value="_selectedItem.MediaCollection" SearchFunc="@SearchMediaCollections" ToStringFunc="@(c => c?.Name)"/>
<MudSelect Class="mt-3" Label="Playout Mode" @bind-Value="@_selectedItem.PlayoutMode" For="@(() => _selectedItem.PlayoutMode)">
@foreach (PlayoutMode playoutMode in Enum.GetValues<PlayoutMode>())
@ -82,7 +82,7 @@ @@ -82,7 +82,7 @@
}
</MudSelect>
<MudTextField Class="mt-3" Label="Multiple Count" @bind-Value="@_selectedItem.MultipleCount" For="@(() => _selectedItem.MultipleCount)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Multiple)"/>
<MudTextField Class="mt-3" Label="Playout Duration" @bind-Value="@_selectedItem.PlayoutDuration" For="@(() => _selectedItem.PlayoutDuration)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration)"/>
<MudTimePicker Class="mt-3" Label="Playout Duration" @bind-Time="@_selectedItem.PlayoutDuration" For="@(() => _selectedItem.PlayoutDuration)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration)"/>
<MudElement HtmlTag="div" Class="mt-3">
<MudSwitch Label="Offline Tail" @bind-Checked="@_selectedItem.OfflineTail" For="@(() => _selectedItem.OfflineTail)" Disabled="@(_selectedItem.PlayoutMode != PlayoutMode.Duration)"/>
</MudElement>

2
ErsatzTV/Pages/Schedules.razor

@ -57,7 +57,7 @@ @@ -57,7 +57,7 @@
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Start Time">
@(context.StartType == StartType.Fixed ? context.StartTime : "Dynamic")
@(context.StartType == StartType.Fixed ? context.StartTime?.ToString(@"hh\:mm") ?? string.Empty : "Dynamic")
</MudTd>
<MudTd DataLabel="Media Collection">@context.MediaCollection.Name</MudTd>
<MudTd DataLabel="Media Collection">@context.PlayoutMode</MudTd>

5
ErsatzTV/Services/PlexService.cs

@ -63,14 +63,15 @@ namespace ErsatzTV.Services @@ -63,14 +63,15 @@ namespace ErsatzTV.Services
TryCompletePlexPinFlow pinRequest => CompletePinFlow(pinRequest, cancellationToken),
SynchronizePlexMediaSources sourcesRequest => SynchronizeSources(
sourcesRequest,
cancellationToken)
cancellationToken),
_ => throw new NotSupportedException($"Unsupported request type: {request.GetType().Name}")
};
await requestTask;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to process poll for Plex auth token request");
_logger.LogWarning(ex, "Failed to process plex background service request");
}
}
}

1
ErsatzTV/Services/WorkerService.cs

@ -62,6 +62,7 @@ namespace ErsatzTV.Services @@ -62,6 +62,7 @@ namespace ErsatzTV.Services
RefreshMediaItemMetadata => "metadata",
RefreshMediaItemStatistics => "statistics",
RefreshMediaItemCollections => "collections",
RefreshMediaItemPoster => "poster",
_ => ""
};

2
ErsatzTV/Shared/LocalMediaSources.razor

@ -42,7 +42,7 @@ @@ -42,7 +42,7 @@
var parameters = new DialogParameters
{
{ "EntityType", "media source" },
{ "EntityName", mediaSource.Name },
{ "EntityName", mediaSource.Folder },
{ "DetailText", $"This media source contains {count} media items." },
{ "DetailHighlight", count.ToString() }
};

8
ErsatzTV/Shared/MainLayout.razor

@ -28,13 +28,15 @@ @@ -28,13 +28,15 @@
<MudNavLink Href="/ffmpeg">FFmpeg</MudNavLink>
<MudNavGroup Title="Media" Expanded="true">
<MudNavLink Href="/media/sources">Media Sources</MudNavLink>
<MudNavLink Href="/media/items">Media Items</MudNavLink>
<MudNavLink Href="/media/tv/items">TV Shows</MudNavLink>
<MudNavLink Href="/media/movies/items">Movies</MudNavLink>
<MudNavLink Href="/media/other/items">Other Items</MudNavLink>
<MudNavLink Href="/media/collections">Media Collections</MudNavLink>
</MudNavGroup>
<MudNavLink Href="/schedules">Schedules</MudNavLink>
<MudNavLink Href="/playouts">Playouts</MudNavLink>
<MudNavLink Href="/system/logs">Logs</MudNavLink>
<MudDivider Class="my-6" DividerType="DividerType.Middle" />
<MudDivider Class="my-6" DividerType="DividerType.Middle"/>
<MudContainer Style="text-align: right" Class="mr-6">
<MudText Typo="Typo.body2">ErsatzTV Version</MudText>
<MudText Typo="Typo.body2" Color="Color.Info">@InfoVersion</MudText>
@ -42,7 +44,7 @@ @@ -42,7 +44,7 @@
</MudNavMenu>
</MudDrawer>
<MudMainContent>
<MudContainer MaxWidth="MaxWidth.Large" Class="pt-8">
<MudContainer MaxWidth="MaxWidth.ExtraLarge" Class="pt-8">
@Body
</MudContainer>
</MudMainContent>

34
ErsatzTV/Shared/MediaCard.razor

@ -0,0 +1,34 @@ @@ -0,0 +1,34 @@
@using ErsatzTV.Application.MediaItems
<div class="media-card-container mx-3 pb-3">
<MudPaper Class="media-card">
@if (!string.IsNullOrWhiteSpace(Data.Poster))
{
<img src="@($"/posters/{Data.Poster}")" style="max-height: 220px"/>
}
else
{
<MudText Align="Align.Center" Typo="Typo.h1" Class="media-card-poster-placeholder mud-text-disabled">
@Placeholder(Data.SortTitle)
</MudText>
}
</MudPaper>
<MudText Align="Align.Center" Class="media-card-title" UserAttributes="@(new Dictionary<string, object> { { "title", Data.Title } })">
@Data.Title
</MudText>
<MudText Typo="Typo.body2" Align="Align.Center" Class="mud-text-secondary">
@Data.Subtitle
</MudText>
</div>
@code {
[Parameter]
public AggregateMediaItemViewModel Data { get; set; }
private string Placeholder(string sortTitle)
{
string first = sortTitle.Substring(0, 1).ToUpperInvariant();
return int.TryParse(first, out _) ? "#" : first;
}
}

66
ErsatzTV/Shared/MediaItemTable.razor

@ -1,66 +0,0 @@ @@ -1,66 +0,0 @@
@using ErsatzTV.Application.MediaItems
@using ErsatzTV.Application.MediaItems.Queries
@inject IMediator Mediator
<MudTable @ref="_table" Hover="true" ServerData="@(new Func<TableState, Task<TableData<AggregateMediaItemViewModel>>>(ServerReload))">
<ToolBarContent>
<MudText Typo="Typo.h6">Media Items</MudText>
<MudToolBarSpacer/>
<MudTextField T="string" ValueChanged="@OnSearch" Placeholder="Search" Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Search" IconSize="Size.Medium" Class="mt-0">
</MudTextField>
</ToolBarContent>
<HeaderContent>
<MudTh>Source</MudTh>
<MudTh>Title</MudTh>
<MudTh>Count</MudTh>
<MudTh>Duration</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Source">@context.Source</MudTd>
<MudTd DataLabel="Title">@context.Title</MudTd>
<MudTd DataLabel="Count">@context.Count</MudTd>
<MudTd DataLabel="Duration">@context.Duration</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager/>
</PagerContent>
</MudTable>
@code {
[Parameter]
public MediaType MediaType { get; set; }
private IEnumerable<AggregateMediaItemViewModel> _pagedData;
private MudTable<AggregateMediaItemViewModel> _table;
private int _totalItems;
private string _searchString;
private async Task<TableData<AggregateMediaItemViewModel>> ServerReload(TableState state)
{
List<AggregateMediaItemViewModel> aggregateData =
await Mediator.Send(new GetAggregateMediaItems(MediaType, _searchString));
_totalItems = aggregateData.Count;
_pagedData = aggregateData.Skip(state.Page * state.PageSize).Take(state.PageSize);
return new TableData<AggregateMediaItemViewModel> { TotalItems = _totalItems, Items = _pagedData };
}
private async Task OnSearch(string text)
{
_searchString = text;
await _table.ReloadServerData();
}
private class MediaItemAggregate
{
public string Source { get; set; }
public string Title { get; set; }
public int Count { get; set; }
public string Duration { get; set; }
}
}

55
ErsatzTV/Shared/MediaItemsGrid.razor

@ -0,0 +1,55 @@ @@ -0,0 +1,55 @@
@using ErsatzTV.Application.MediaItems
@using ErsatzTV.Application.MediaItems.Queries
@inject IMediator Mediator
<MudContainer MaxWidth="MaxWidth.Small" Class="mb-6" Style="max-width: 300px">
<MudPaper Style="align-items: center; display: flex; justify-content: center;">
<MudIconButton Icon="@Icons.Material.Outlined.ChevronLeft"
OnClick="@(() => PrevPage())"
Disabled="@(_pageNumber <= 1)">
</MudIconButton>
<MudText Style="flex-grow: 1"
Align="Align.Center">
@Math.Min((_pageNumber - 1) * PageSize + 1, _data.Count)-@Math.Min(_data.Count, _pageNumber * PageSize) of @_data.Count
</MudText>
<MudIconButton Icon="@Icons.Material.Outlined.ChevronRight"
OnClick="@(() => NextPage())" Disabled="@(_pageNumber * PageSize >= _data.Count)">
</MudIconButton>
</MudPaper>
</MudContainer>
<MudContainer MaxWidth="MaxWidth.False" Class="media-card-grid">
@foreach (AggregateMediaItemViewModel item in _data.DataPage)
{
<MediaCard Data="@item"/>
}
</MudContainer>
@code {
[Parameter]
public MediaType MediaType { get; set; }
private int PageSize => 100;
private int _pageNumber = 1;
private AggregateMediaItemResults _data;
protected override Task OnParametersSetAsync() => RefreshData();
private async Task RefreshData() =>
_data = await Mediator.Send(new GetAggregateMediaItems(MediaType, _pageNumber, PageSize));
private async Task PrevPage()
{
_pageNumber -= 1;
await RefreshData();
}
private async Task NextPage()
{
_pageNumber += 1;
await RefreshData();
}
}

4
ErsatzTV/Startup.cs

@ -6,6 +6,7 @@ using ErsatzTV.Application.Channels.Queries; @@ -6,6 +6,7 @@ using ErsatzTV.Application.Channels.Queries;
using ErsatzTV.Core;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.FFmpeg;
using ErsatzTV.Core.Interfaces.Images;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
@ -15,6 +16,7 @@ using ErsatzTV.Core.Scheduling; @@ -15,6 +16,7 @@ using ErsatzTV.Core.Scheduling;
using ErsatzTV.Formatters;
using ErsatzTV.Infrastructure.Data;
using ErsatzTV.Infrastructure.Data.Repositories;
using ErsatzTV.Infrastructure.Images;
using ErsatzTV.Infrastructure.Plex;
using ErsatzTV.Serialization;
using ErsatzTV.Services;
@ -164,8 +166,10 @@ namespace ErsatzTV @@ -164,8 +166,10 @@ namespace ErsatzTV
services.AddScoped<ISmartCollectionBuilder, SmartCollectionBuilder>();
services.AddScoped<ILocalMetadataProvider, LocalMetadataProvider>();
services.AddScoped<ILocalStatisticsProvider, LocalStatisticsProvider>();
services.AddScoped<ILocalPosterProvider, LocalPosterProvider>();
services.AddScoped<ILocalMediaScanner, LocalMediaScanner>();
services.AddScoped<IPlayoutBuilder, PlayoutBuilder>();
services.AddScoped<IImageCache, ImageCache>();
services.AddHostedService<PlexService>();
services.AddHostedService<FFmpegLocatorService>();

1
ErsatzTV/_Imports.razor

@ -17,6 +17,7 @@ @@ -17,6 +17,7 @@
@using MudBlazor
@using MudBlazor.Dialog
@using ErsatzTV
@using ErsatzTV.Application
@using ErsatzTV.Core
@using ErsatzTV.Core.Domain
@using ErsatzTV.Infrastructure.Data

25
ErsatzTV/wwwroot/css/site.css

@ -1 +1,24 @@ @@ -1 +1,24 @@

.media-card-grid {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.media-card-container { width: 152px; }
.media-card {
display: flex;
flex-direction: column;
height: 220px;
justify-content: center;
width: 152px;
}
.media-card-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
width: 100%;
}
.media-card-poster-placeholder { font-weight: bold; }
Loading…
Cancel
Save