Browse Source

rework television media

pull/26/head
Jason Dove 6 years ago
parent
commit
b0d27751c7
  1. 8
      ErsatzTV.Application/MediaItems/Commands/CreateMediaItem.cs
  2. 101
      ErsatzTV.Application/MediaItems/Commands/CreateMediaItemHandler.cs
  3. 9
      ErsatzTV.Application/MediaItems/Commands/DeleteMediaItem.cs
  4. 31
      ErsatzTV.Application/MediaItems/Commands/DeleteMediaItemHandler.cs
  5. 8
      ErsatzTV.Application/MediaItems/Commands/RefreshMediaItem.cs
  6. 9
      ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemCollections.cs
  7. 46
      ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemCollectionsHandler.cs
  8. 9
      ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemMetadata.cs
  9. 53
      ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemMetadataHandler.cs
  10. 9
      ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemPoster.cs
  11. 45
      ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemPosterHandler.cs
  12. 9
      ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemStatistics.cs
  13. 65
      ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemStatisticsHandler.cs
  14. 42
      ErsatzTV.Application/MediaItems/Mapper.cs
  15. 4
      ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs
  16. 26
      ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSourceHandler.cs
  17. 21
      ErsatzTV.Application/Playouts/Mapper.cs
  18. 143
      ErsatzTV.CommandLine/Commands/MediaCollections/MediaCollectionMediaItemsCommand.cs
  19. 42
      ErsatzTV.CommandLine/Commands/MediaItemCommandBase.cs
  20. 99
      ErsatzTV.CommandLine/Commands/MediaItemsCommand.cs
  21. 118
      ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsServiceTests.cs
  22. 10
      ErsatzTV.Core.Tests/Metadata/FallbackMetadataProviderTests.cs
  23. 1002
      ErsatzTV.Core.Tests/Metadata/LocalMediaSourcePlannerTests.cs
  24. 6
      ErsatzTV.Core.Tests/Scheduling/ChronologicalContentTests.cs
  25. 5
      ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs
  26. 6
      ErsatzTV.Core.Tests/Scheduling/RandomizedContentTests.cs
  27. 6
      ErsatzTV.Core.Tests/Scheduling/ShuffledContentTests.cs
  28. 12
      ErsatzTV.Core/Domain/MediaItem.cs
  29. 12
      ErsatzTV.Core/Domain/MediaItemMetadata.cs
  30. 18
      ErsatzTV.Core/Domain/MediaItemStatistics.cs
  31. 8
      ErsatzTV.Core/Domain/MovieMediaItem.cs
  32. 17
      ErsatzTV.Core/Domain/MovieMetadata.cs
  33. 9
      ErsatzTV.Core/Domain/TelevisionEpisodeMediaItem.cs
  34. 15
      ErsatzTV.Core/Domain/TelevisionEpisodeMetadata.cs
  35. 18
      ErsatzTV.Core/Domain/TelevisionSeason.cs
  36. 18
      ErsatzTV.Core/Domain/TelevisionShow.cs
  37. 17
      ErsatzTV.Core/Domain/TelevisionShowMetadata.cs
  38. 64
      ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs
  39. 2
      ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs
  40. 11
      ErsatzTV.Core/Interfaces/Domain/IHasAPoster.cs
  41. 8
      ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs
  42. 7
      ErsatzTV.Core/Interfaces/Metadata/ILocalMediaScanner.cs
  43. 14
      ErsatzTV.Core/Interfaces/Metadata/ILocalMediaSourcePlanner.cs
  44. 7
      ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs
  45. 3
      ErsatzTV.Core/Interfaces/Metadata/ILocalPosterProvider.cs
  46. 11
      ErsatzTV.Core/Interfaces/Metadata/IMovieFolderScanner.cs
  47. 2
      ErsatzTV.Core/Interfaces/Metadata/ISmartCollectionBuilder.cs
  48. 11
      ErsatzTV.Core/Interfaces/Metadata/ITelevisionFolderScanner.cs
  49. 2
      ErsatzTV.Core/Interfaces/Repositories/IMediaItemRepository.cs
  50. 11
      ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs
  51. 27
      ErsatzTV.Core/Interfaces/Repositories/ITelevisionRepository.cs
  52. 60
      ErsatzTV.Core/Iptv/ChannelGuide.cs
  53. 20
      ErsatzTV.Core/LanguageExtensions.cs
  54. 4
      ErsatzTV.Core/Metadata/ActionPlan.cs
  55. 72
      ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs
  56. 53
      ErsatzTV.Core/Metadata/LocalFileSystem.cs
  57. 75
      ErsatzTV.Core/Metadata/LocalFolderScanner.cs
  58. 289
      ErsatzTV.Core/Metadata/LocalMediaScanner.cs
  59. 11
      ErsatzTV.Core/Metadata/LocalMediaSourcePlan.cs
  60. 179
      ErsatzTV.Core/Metadata/LocalMediaSourcePlanner.cs
  61. 233
      ErsatzTV.Core/Metadata/LocalMetadataProvider.cs
  62. 40
      ErsatzTV.Core/Metadata/LocalPosterProvider.cs
  63. 40
      ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs
  64. 139
      ErsatzTV.Core/Metadata/MovieFolderScanner.cs
  65. 14
      ErsatzTV.Core/Metadata/ScanningAction.cs
  66. 8
      ErsatzTV.Core/Metadata/ScanningMode.cs
  67. 12
      ErsatzTV.Core/Metadata/SmartCollectionBuilder.cs
  68. 296
      ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs
  69. 71
      ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs
  70. 20
      ErsatzTV.Core/Scheduling/PlayoutBuilder.cs
  71. 2
      ErsatzTV.Infrastructure/Data/Configurations/GenericIntegerIdConfiguration.cs
  72. 2
      ErsatzTV.Infrastructure/Data/Configurations/MediaCollectionSummaryConfiguration.cs
  73. 2
      ErsatzTV.Infrastructure/Data/Configurations/MediaItemConfiguration.cs
  74. 2
      ErsatzTV.Infrastructure/Data/Configurations/MediaItemSummaryConfiguration.cs
  75. 19
      ErsatzTV.Infrastructure/Data/Configurations/MovieMediaItemConfiguration.cs
  76. 12
      ErsatzTV.Infrastructure/Data/Configurations/MovieMetadataConfiguration.cs
  77. 19
      ErsatzTV.Infrastructure/Data/Configurations/TelevisionEpisodeMediaItemConfiguration.cs
  78. 12
      ErsatzTV.Infrastructure/Data/Configurations/TelevisionEpisodeMetadataConfiguration.cs
  79. 19
      ErsatzTV.Infrastructure/Data/Configurations/TelevisionSeasonConfiguration.cs
  80. 22
      ErsatzTV.Infrastructure/Data/Configurations/TelevisionShowConfiguration.cs
  81. 12
      ErsatzTV.Infrastructure/Data/Configurations/TelevisionShowMetadataConfiguration.cs
  82. 17
      ErsatzTV.Infrastructure/Data/Repositories/MediaCollectionRepository.cs
  83. 67
      ErsatzTV.Infrastructure/Data/Repositories/MediaItemRepository.cs
  84. 44
      ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs
  85. 5
      ErsatzTV.Infrastructure/Data/Repositories/PlayoutRepository.cs
  86. 142
      ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs
  87. 4
      ErsatzTV.Infrastructure/Data/TvContext.cs
  88. 1182
      ErsatzTV.Infrastructure/Migrations/20210218214650_Statistics.Designer.cs
  89. 428
      ErsatzTV.Infrastructure/Migrations/20210218214650_Statistics.cs
  90. 366
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  91. 3
      ErsatzTV.sln.DotSettings
  92. 18
      ErsatzTV/Controllers/Api/MediaItemsController.cs
  93. 5
      ErsatzTV/Pages/LocalMediaSourceEditor.razor
  94. 3
      ErsatzTV/Services/SchedulerService.cs
  95. 23
      ErsatzTV/Services/WorkerService.cs
  96. 3
      ErsatzTV/Shared/LocalMediaSources.razor
  97. 1
      ErsatzTV/Shared/MainLayout.razor
  98. 11
      ErsatzTV/Shared/MediaCard.razor
  99. 10
      ErsatzTV/Startup.cs
  100. 4
      generated/ErsatzTV.Api.Sdk/.openapi-generator/FILES
  101. Some files were not shown because too many files have changed in this diff Show More

8
ErsatzTV.Application/MediaItems/Commands/CreateMediaItem.cs

@ -1,8 +0,0 @@ @@ -1,8 +0,0 @@
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaItems.Commands
{
public record CreateMediaItem(int MediaSourceId, string Path) : IRequest<Either<BaseError, MediaItemViewModel>>;
}

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

@ -1,101 +0,0 @@ @@ -1,101 +0,0 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
using static LanguageExt.Prelude;
using static ErsatzTV.Application.MediaItems.Mapper;
namespace ErsatzTV.Application.MediaItems.Commands
{
public class CreateMediaItemHandler : IRequestHandler<CreateMediaItem, Either<BaseError, MediaItemViewModel>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILocalPosterProvider _localPosterProvider;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly IMediaItemRepository _mediaItemRepository;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly ISmartCollectionBuilder _smartCollectionBuilder;
public CreateMediaItemHandler(
IMediaItemRepository mediaItemRepository,
IMediaSourceRepository mediaSourceRepository,
IConfigElementRepository configElementRepository,
ISmartCollectionBuilder smartCollectionBuilder,
ILocalMetadataProvider localMetadataProvider,
ILocalStatisticsProvider localStatisticsProvider,
ILocalPosterProvider localPosterProvider)
{
_mediaItemRepository = mediaItemRepository;
_mediaSourceRepository = mediaSourceRepository;
_configElementRepository = configElementRepository;
_smartCollectionBuilder = smartCollectionBuilder;
_localMetadataProvider = localMetadataProvider;
_localStatisticsProvider = localStatisticsProvider;
_localPosterProvider = localPosterProvider;
}
public Task<Either<BaseError, MediaItemViewModel>> Handle(
CreateMediaItem request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(PersistMediaItem)
.Bind(v => v.ToEitherAsync());
private async Task<MediaItemViewModel> PersistMediaItem(RequestParameters parameters)
{
await _mediaItemRepository.Add(parameters.MediaItem);
await _localStatisticsProvider.RefreshStatistics(parameters.FFprobePath, parameters.MediaItem);
// TODO: reimplement this
// await _localMetadataProvider.RefreshMetadata(parameters.MediaItem);
// await _localPosterProvider.RefreshPoster(parameters.MediaItem);
// await _smartCollectionBuilder.RefreshSmartCollections(parameters.MediaItem);
return ProjectToViewModel(parameters.MediaItem);
}
private async Task<Validation<BaseError, RequestParameters>> Validate(CreateMediaItem request) =>
(await ValidateMediaSource(request), PathMustExist(request), await ValidateFFprobePath())
.Apply(
(mediaSourceId, path, ffprobePath) => new RequestParameters(
ffprobePath,
new MediaItem
{
MediaSourceId = mediaSourceId,
Path = path
}));
private async Task<Validation<BaseError, int>> ValidateMediaSource(CreateMediaItem createMediaItem) =>
(await MediaSourceMustExist(createMediaItem)).Bind(MediaSourceMustBeLocal);
private async Task<Validation<BaseError, MediaSource>> MediaSourceMustExist(CreateMediaItem createMediaItem) =>
(await _mediaSourceRepository.Get(createMediaItem.MediaSourceId))
.ToValidation<BaseError>($"[MediaSource] {createMediaItem.MediaSourceId} does not exist.");
private Validation<BaseError, int> MediaSourceMustBeLocal(MediaSource mediaSource) =>
Some(mediaSource)
.Filter(ms => ms is LocalMediaSource)
.ToValidation<BaseError>($"[MediaSource] {mediaSource.Id} must be a local media source")
.Map(ms => ms.Id);
private Validation<BaseError, string> PathMustExist(CreateMediaItem createMediaItem) =>
Some(createMediaItem.Path)
.Filter(File.Exists)
.ToValidation<BaseError>("[Path] does not exist on the file system");
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
.FilterT(File.Exists)
.Map(
ffprobePath =>
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
private record RequestParameters(string FFprobePath, MediaItem MediaItem);
}
}

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

@ -1,9 +0,0 @@ @@ -1,9 +0,0 @@
using System.Threading.Tasks;
using ErsatzTV.Core;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaItems.Commands
{
public record DeleteMediaItem(int MediaItemId) : IRequest<Either<BaseError, Task>>;
}

31
ErsatzTV.Application/MediaItems/Commands/DeleteMediaItemHandler.cs

@ -1,31 +0,0 @@ @@ -1,31 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaItems.Commands
{
public class DeleteMediaItemHandler : IRequestHandler<DeleteMediaItem, Either<BaseError, Task>>
{
private readonly IMediaItemRepository _mediaItemRepository;
public DeleteMediaItemHandler(IMediaItemRepository mediaItemRepository) =>
_mediaItemRepository = mediaItemRepository;
public async Task<Either<BaseError, Task>> Handle(
DeleteMediaItem request,
CancellationToken cancellationToken) =>
(await MediaItemMustExist(request))
.Map(DoDeletion)
.ToEither<Task>();
private Task DoDeletion(int mediaItemId) => _mediaItemRepository.Delete(mediaItemId);
private async Task<Validation<BaseError, int>> MediaItemMustExist(DeleteMediaItem deleteMediaItem) =>
(await _mediaItemRepository.Get(deleteMediaItem.MediaItemId))
.ToValidation<BaseError>($"MediaItem {deleteMediaItem.MediaItemId} does not exist.")
.Map(c => c.Id);
}
}

8
ErsatzTV.Application/MediaItems/Commands/RefreshMediaItem.cs

@ -1,8 +0,0 @@ @@ -1,8 +0,0 @@
using ErsatzTV.Core;
using LanguageExt;
namespace ErsatzTV.Application.MediaItems.Commands
{
public record RefreshMediaItem(int MediaItemId) : MediatR.IRequest<Either<BaseError, Unit>>,
IBackgroundServiceRequest;
}

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

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

46
ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemCollectionsHandler.cs

@ -1,46 +0,0 @@ @@ -1,46 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
namespace ErsatzTV.Application.MediaItems.Commands
{
public class
RefreshMediaItemCollectionsHandler : MediatR.IRequestHandler<RefreshMediaItemCollections,
Either<BaseError, Unit>>
{
private readonly IMediaItemRepository _mediaItemRepository;
private readonly ISmartCollectionBuilder _smartCollectionBuilder;
public RefreshMediaItemCollectionsHandler(
IMediaItemRepository mediaItemRepository,
ISmartCollectionBuilder smartCollectionBuilder)
{
_mediaItemRepository = mediaItemRepository;
_smartCollectionBuilder = smartCollectionBuilder;
}
public Task<Either<BaseError, Unit>> Handle(
RefreshMediaItemCollections request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(RefreshCollections)
.Bind(v => v.ToEitherAsync());
private Task<Validation<BaseError, MediaItem>> Validate(RefreshMediaItemCollections request) =>
MediaItemMustExist(request);
private Task<Validation<BaseError, MediaItem>> MediaItemMustExist(
RefreshMediaItemCollections refreshMediaItemCollections) =>
_mediaItemRepository.Get(refreshMediaItemCollections.MediaItemId)
.Map(
maybeItem => maybeItem.ToValidation<BaseError>(
$"[MediaItem] {refreshMediaItemCollections.MediaItemId} does not exist."));
private Task<Unit> RefreshCollections(MediaItem mediaItem) =>
_smartCollectionBuilder.RefreshSmartCollections(mediaItem).ToUnit();
}
}

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

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

53
ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemMetadataHandler.cs

@ -1,53 +0,0 @@ @@ -1,53 +0,0 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaItems.Commands
{
public class
RefreshMediaItemMetadataHandler : MediatR.IRequestHandler<RefreshMediaItemMetadata, Either<BaseError, Unit>>
{
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly IMediaItemRepository _mediaItemRepository;
public RefreshMediaItemMetadataHandler(
IMediaItemRepository mediaItemRepository,
ILocalMetadataProvider localMetadataProvider)
{
_mediaItemRepository = mediaItemRepository;
_localMetadataProvider = localMetadataProvider;
}
public Task<Either<BaseError, Unit>> Handle(
RefreshMediaItemMetadata request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(RefreshMetadata)
.Bind(v => v.ToEitherAsync());
private Task<Validation<BaseError, MediaItem>> Validate(RefreshMediaItemMetadata request) =>
MediaItemMustExist(request).BindT(PathMustExist);
private Task<Validation<BaseError, MediaItem>> MediaItemMustExist(
RefreshMediaItemMetadata refreshMediaItemMetadata) =>
_mediaItemRepository.Get(refreshMediaItemMetadata.MediaItemId)
.Map(
maybeItem => maybeItem.ToValidation<BaseError>(
$"[MediaItem] {refreshMediaItemMetadata.MediaItemId} does not exist."));
private Validation<BaseError, MediaItem> PathMustExist(MediaItem mediaItem) =>
Some(mediaItem)
.Filter(item => File.Exists(item.Path))
.ToValidation<BaseError>($"[Path] '{mediaItem.Path}' does not exist on the file system");
private Task<Unit> RefreshMetadata(MediaItem mediaItem) => Task.CompletedTask.ToUnit();
// TODO: reimplement this
// _localMetadataProvider.RefreshMetadata(mediaItem).ToUnit();
}
}

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

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

45
ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemPosterHandler.cs

@ -1,45 +0,0 @@ @@ -1,45 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
namespace ErsatzTV.Application.MediaItems.Commands
{
public class
RefreshMediaItemPosterHandler : MediatR.IRequestHandler<RefreshMediaItemPoster,
Either<BaseError, Unit>>
{
private readonly ILocalPosterProvider _localPosterProvider;
private readonly IMediaItemRepository _mediaItemRepository;
public RefreshMediaItemPosterHandler(
IMediaItemRepository mediaItemRepository,
ILocalPosterProvider localPosterProvider)
{
_mediaItemRepository = mediaItemRepository;
_localPosterProvider = localPosterProvider;
}
public Task<Either<BaseError, Unit>> Handle(
RefreshMediaItemPoster request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(RefreshPoster)
.Bind(v => v.ToEitherAsync());
private Task<Validation<BaseError, MediaItem>> Validate(RefreshMediaItemPoster request) =>
MediaItemMustExist(request);
private Task<Validation<BaseError, MediaItem>> MediaItemMustExist(RefreshMediaItemPoster request) =>
_mediaItemRepository.Get(request.MediaItemId)
.Map(
maybeItem => maybeItem.ToValidation<BaseError>(
$"[MediaItem] {request.MediaItemId} does not exist."));
private Task<Unit> RefreshPoster(MediaItem mediaItem) =>
_localPosterProvider.RefreshPoster(mediaItem).ToUnit();
}
}

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

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

65
ErsatzTV.Application/MediaItems/Commands/RefreshMediaItemStatisticsHandler.cs

@ -1,65 +0,0 @@ @@ -1,65 +0,0 @@
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaItems.Commands
{
public class
RefreshMediaItemStatisticsHandler : MediatR.IRequestHandler<RefreshMediaItemStatistics, Either<BaseError, Unit>>
{
private readonly IConfigElementRepository _configElementRepository;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly IMediaItemRepository _mediaItemRepository;
public RefreshMediaItemStatisticsHandler(
IMediaItemRepository mediaItemRepository,
IConfigElementRepository configElementRepository,
ILocalStatisticsProvider localStatisticsProvider)
{
_mediaItemRepository = mediaItemRepository;
_configElementRepository = configElementRepository;
_localStatisticsProvider = localStatisticsProvider;
}
public Task<Either<BaseError, Unit>> Handle(
RefreshMediaItemStatistics request,
CancellationToken cancellationToken) =>
Validate(request)
.MapT(RefreshStatistics)
.Bind(v => v.ToEitherAsync());
private async Task<Validation<BaseError, RefreshParameters>> Validate(RefreshMediaItemStatistics request) =>
(await MediaItemMustExist(request).BindT(PathMustExist), await ValidateFFprobePath())
.Apply((mediaItem, ffprobePath) => new RefreshParameters(mediaItem, ffprobePath));
private Task<Validation<BaseError, MediaItem>> MediaItemMustExist(
RefreshMediaItemStatistics refreshMediaItemStatistics) =>
_mediaItemRepository.Get(refreshMediaItemStatistics.MediaItemId)
.Map(
maybeItem => maybeItem.ToValidation<BaseError>(
$"[MediaItem] {refreshMediaItemStatistics.MediaItemId} does not exist."));
private Validation<BaseError, MediaItem> PathMustExist(MediaItem mediaItem) =>
Some(mediaItem)
.Filter(item => File.Exists(item.Path))
.ToValidation<BaseError>($"[Path] '{mediaItem.Path}' does not exist on the file system");
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
.FilterT(File.Exists)
.Map(
ffprobePath =>
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
private Task<Unit> RefreshStatistics(RefreshParameters parameters) =>
_localStatisticsProvider.RefreshStatistics(parameters.FFprobePath, parameters.MediaItem).ToUnit();
private record RefreshParameters(MediaItem MediaItem, string FFprobePath);
}
}

42
ErsatzTV.Application/MediaItems/Mapper.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using ErsatzTV.Core.Domain;
using static LanguageExt.Prelude;
using System;
using System.IO;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.MediaItems
{
@ -12,25 +13,44 @@ namespace ErsatzTV.Application.MediaItems @@ -12,25 +13,44 @@ namespace ErsatzTV.Application.MediaItems
mediaItem.Path);
internal static MediaItemSearchResultViewModel ProjectToSearchViewModel(MediaItem mediaItem) =>
mediaItem switch
{
TelevisionEpisodeMediaItem e => ProjectToSearchViewModel(e),
MovieMediaItem m => ProjectToSearchViewModel(m),
_ => throw new ArgumentOutOfRangeException()
};
private static MediaItemSearchResultViewModel ProjectToSearchViewModel(TelevisionEpisodeMediaItem mediaItem) =>
new(
mediaItem.Id,
GetSourceName(mediaItem.Source),
mediaItem.Metadata.MediaType.ToString(),
"TV Show",
GetDisplayTitle(mediaItem),
GetDisplayDuration(mediaItem));
private static MediaItemSearchResultViewModel ProjectToSearchViewModel(MovieMediaItem mediaItem) =>
new(
mediaItem.Id,
GetSourceName(mediaItem.Source),
"Movie",
GetDisplayTitle(mediaItem),
GetDisplayDuration(mediaItem));
private static string GetDisplayTitle(this MediaItem mediaItem) =>
mediaItem.Metadata.MediaType == MediaType.TvShow &&
Optional(mediaItem.Metadata.SeasonNumber).IsSome &&
Optional(mediaItem.Metadata.EpisodeNumber).IsSome
? $"{mediaItem.Metadata.Title} s{mediaItem.Metadata.SeasonNumber:00}e{mediaItem.Metadata.EpisodeNumber:00}"
: mediaItem.Metadata.Title;
private static string GetDisplayTitle(MediaItem mediaItem) =>
mediaItem switch
{
TelevisionEpisodeMediaItem e => e.Metadata != null
? $"{e.Metadata.Title} - s{e.Metadata.Season:00}e{e.Metadata.Episode:00}"
: Path.GetFileName(e.Path),
MovieMediaItem m => m.Metadata?.Title ?? Path.GetFileName(m.Path),
_ => string.Empty
};
private static string GetDisplayDuration(MediaItem mediaItem) =>
string.Format(
mediaItem.Metadata.Duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}",
mediaItem.Metadata.Duration);
mediaItem.Statistics.Duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}",
mediaItem.Statistics.Duration);
private static string GetSourceName(MediaSource source) =>
source switch

4
ErsatzTV.Application/MediaSources/Commands/ScanLocalMediaSource.cs

@ -1,11 +1,9 @@ @@ -1,11 +1,9 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Metadata;
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.MediaSources.Commands
{
public record ScanLocalMediaSource(int MediaSourceId, ScanningMode ScanningMode) :
IRequest<Either<BaseError, string>>,
public record ScanLocalMediaSource(int MediaSourceId) : IRequest<Either<BaseError, string>>,
IBackgroundServiceRequest;
}

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

@ -16,33 +16,41 @@ namespace ErsatzTV.Application.MediaSources.Commands @@ -16,33 +16,41 @@ namespace ErsatzTV.Application.MediaSources.Commands
{
private readonly IConfigElementRepository _configElementRepository;
private readonly IEntityLocker _entityLocker;
private readonly ILocalMediaScanner _localMediaScanner;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMovieFolderScanner _movieFolderScanner;
private readonly ITelevisionFolderScanner _televisionFolderScanner;
public ScanLocalMediaSourceHandler(
IMediaSourceRepository mediaSourceRepository,
IConfigElementRepository configElementRepository,
ILocalMediaScanner localMediaScanner,
IMovieFolderScanner movieFolderScanner,
ITelevisionFolderScanner televisionFolderScanner,
IEntityLocker entityLocker)
{
_mediaSourceRepository = mediaSourceRepository;
_configElementRepository = configElementRepository;
_localMediaScanner = localMediaScanner;
_movieFolderScanner = movieFolderScanner;
_televisionFolderScanner = televisionFolderScanner;
_entityLocker = entityLocker;
}
public Task<Either<BaseError, string>>
Handle(ScanLocalMediaSource request, CancellationToken cancellationToken) =>
Validate(request)
.MapT(parameters => PerformScan(request, parameters).Map(_ => parameters.LocalMediaSource.Folder))
.MapT(parameters => PerformScan(parameters).Map(_ => parameters.LocalMediaSource.Folder))
.Bind(v => v.ToEitherAsync());
private async Task<Unit> PerformScan(ScanLocalMediaSource request, RequestParameters parameters)
private async Task<Unit> PerformScan(RequestParameters parameters)
{
await _localMediaScanner.ScanLocalMediaSource(
parameters.LocalMediaSource,
parameters.FFprobePath,
request.ScanningMode);
switch (parameters.LocalMediaSource.MediaType)
{
case MediaType.Movie:
await _movieFolderScanner.ScanFolder(parameters.LocalMediaSource, parameters.FFprobePath);
break;
case MediaType.TvShow:
await _televisionFolderScanner.ScanFolder(parameters.LocalMediaSource, parameters.FFprobePath);
break;
}
_entityLocker.UnlockMediaSource(parameters.LocalMediaSource.Id);

21
ErsatzTV.Application/Playouts/Mapper.cs

@ -1,5 +1,5 @@ @@ -1,5 +1,5 @@
using ErsatzTV.Core.Domain;
using static LanguageExt.Prelude;
using System.IO;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Playouts
{
@ -22,15 +22,18 @@ namespace ErsatzTV.Application.Playouts @@ -22,15 +22,18 @@ namespace ErsatzTV.Application.Playouts
new(programSchedule.Id, programSchedule.Name);
private static string GetDisplayTitle(MediaItem mediaItem) =>
mediaItem.Metadata.MediaType == MediaType.TvShow &&
Optional(mediaItem.Metadata.SeasonNumber).IsSome &&
Optional(mediaItem.Metadata.EpisodeNumber).IsSome
? $"{mediaItem.Metadata.Title} s{mediaItem.Metadata.SeasonNumber:00}e{mediaItem.Metadata.EpisodeNumber:00}"
: mediaItem.Metadata.Title;
mediaItem switch
{
TelevisionEpisodeMediaItem e => e.Metadata != null
? $"{e.Metadata.Title} - s{e.Metadata.Season:00}e{e.Metadata.Episode:00}"
: Path.GetFileName(e.Path),
MovieMediaItem m => m.Metadata?.Title ?? Path.GetFileName(m.Path),
_ => string.Empty
};
private static string GetDisplayDuration(MediaItem mediaItem) =>
string.Format(
mediaItem.Metadata.Duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}",
mediaItem.Metadata.Duration);
mediaItem.Statistics.Duration.TotalHours >= 1 ? @"{0:h\:mm\:ss}" : @"{0:mm\:ss}",
mediaItem.Statistics.Duration);
}
}

143
ErsatzTV.CommandLine/Commands/MediaCollections/MediaCollectionMediaItemsCommand.cs

@ -1,143 +0,0 @@ @@ -1,143 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using LanguageExt.Common;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.CommandLine.Commands.MediaCollections
{
[Command("collection add-items", Description = "Ensure media collection exists and contains requested media items")]
public class MediaCollectionMediaItemsCommand : MediaItemCommandBase
{
private readonly ILogger<MediaCollectionMediaItemsCommand> _logger;
private readonly string _serverUrl;
public MediaCollectionMediaItemsCommand(
IConfiguration configuration,
ILogger<MediaCollectionMediaItemsCommand> logger)
{
_logger = logger;
_serverUrl = configuration["ServerUrl"];
}
[CommandParameter(0, Name = "collection-name", Description = "The name of the media collection")]
public string Name { get; set; }
public override async ValueTask ExecuteAsync(IConsole console)
{
try
{
CancellationToken cancellationToken = console.GetCancellationToken();
Either<Error, List<string>> maybeFileNames = await GetFileNames();
await maybeFileNames.Match(
allFiles => SynchronizeMediaItemsToCollection(cancellationToken, allFiles),
error =>
{
_logger.LogError("{Error}", error.Message);
return Task.CompletedTask;
});
}
catch (Exception ex)
{
_logger.LogError("Unable to synchronize media items to media collection: {Error}", ex.Message);
}
}
private async Task SynchronizeMediaItemsToCollection(CancellationToken cancellationToken, List<string> allFiles)
{
Either<Error, Unit> result = await GetMediaSourceIdAsync(cancellationToken)
.BindAsync(mediaSourceId => SynchronizeMediaItemsAsync(mediaSourceId, allFiles, cancellationToken))
.BindAsync(mediaItemIds => SynchronizeMediaItemsToCollectionAsync(mediaItemIds, cancellationToken));
result.Match(
_ => _logger.LogInformation(
"Successfully synchronized {Count} media items to media collection {MediaCollection}",
allFiles.Count,
Name),
error => _logger.LogError(
"Unable to synchronize media items to media collection: {Error}",
error.Message));
}
private async Task<Either<Error, int>> GetMediaSourceIdAsync(CancellationToken cancellationToken)
{
var mediaSourcesApi = new MediaSourcesApi(_serverUrl);
List<MediaSourceViewModel> allMediaSources =
await mediaSourcesApi.ApiMediaSourcesGetAsync(cancellationToken);
Option<MediaSourceViewModel> maybeLocalMediaSource =
allMediaSources.SingleOrDefault(cs => cs.SourceType == MediaSourceType.Local);
return maybeLocalMediaSource.Match<Either<Error, int>>(
mediaSource => mediaSource.Id,
() => Error.New("Unable to find local media source"));
}
private async Task<Either<Error, List<int>>> SynchronizeMediaItemsAsync(
int mediaSourceId,
ICollection<string> fileNames,
CancellationToken cancellationToken)
{
var mediaItemsApi = new MediaItemsApi(_serverUrl);
List<MediaItemViewModel> allMediaItems = await mediaItemsApi.ApiMediaItemsGetAsync(cancellationToken);
var missingMediaItems = fileNames.Where(f => allMediaItems.All(c => c.Path != f))
.Map(f => new CreateMediaItem(mediaSourceId, f))
.ToList();
var addedIds = new List<int>();
foreach (CreateMediaItem mediaItem in missingMediaItems)
{
_logger.LogInformation("Adding media item {Path}", mediaItem.Path);
addedIds.Add(await mediaItemsApi.ApiMediaItemsPostAsync(mediaItem, cancellationToken).Map(vm => vm.Id));
}
IEnumerable<int> knownIds = allMediaItems.Where(c => fileNames.Contains(c.Path)).Map(c => c.Id);
return knownIds.Concat(addedIds).ToList();
}
private async Task<Either<Error, Unit>> SynchronizeMediaItemsToCollectionAsync(
List<int> mediaItemIds,
CancellationToken cancellationToken) =>
await EnsureMediaCollectionExistsAsync(cancellationToken)
.BindAsync(
mediaSourceId => SynchronizeMediaCollectionAsync(mediaSourceId, mediaItemIds, cancellationToken));
private async Task<Either<Error, int>> EnsureMediaCollectionExistsAsync(CancellationToken cancellationToken)
{
var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl);
Option<MediaCollectionViewModel> maybeExisting = await mediaCollectionsApi
.ApiMediaCollectionsGetAsync(cancellationToken)
.Map(list => list.SingleOrDefault(mc => mc.Name == Name));
return await maybeExisting.Match(
existing => Task.FromResult(existing.Id),
async () =>
{
var data = new CreateSimpleMediaCollection(Name);
return await mediaCollectionsApi.ApiMediaCollectionsPostAsync(data, cancellationToken)
.Map(vm => vm.Id);
});
}
private async Task<Either<Error, Unit>> SynchronizeMediaCollectionAsync(
int mediaCollectionId,
List<int> mediaItemIds,
CancellationToken cancellationToken)
{
var mediaCollectionsApi = new MediaCollectionsApi(_serverUrl);
await mediaCollectionsApi.ApiMediaCollectionsIdItemsPutAsync(
mediaCollectionId,
mediaItemIds,
cancellationToken);
return unit;
}
}
}

42
ErsatzTV.CommandLine/Commands/MediaItemCommandBase.cs

@ -1,42 +0,0 @@ @@ -1,42 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using LanguageExt;
using LanguageExt.Common;
namespace ErsatzTV.CommandLine.Commands
{
public abstract class MediaItemCommandBase : ICommand
{
[CommandOption("folder", 'f', Description = "Folder to search for media items")]
public string Folder { get; set; }
[CommandOption("pattern", 'p', Description = "File search pattern")]
public string SearchPattern { get; set; }
public abstract ValueTask ExecuteAsync(IConsole console);
protected async Task<Either<Error, List<string>>> GetFileNames()
{
if (Console.IsInputRedirected)
{
await using Stream standardInput = Console.OpenStandardInput();
using var streamReader = new StreamReader(standardInput);
string input = await streamReader.ReadToEndAsync();
return input.Trim().Split("\n").Map(s => s.Trim()).ToList();
}
if (string.IsNullOrWhiteSpace(Folder) || string.IsNullOrWhiteSpace(SearchPattern))
{
return Error.New(
"--folder and --pattern are required when file names are not passed on standard input");
}
return Directory.GetFiles(Folder, SearchPattern, SearchOption.AllDirectories).ToList();
}
}
}

99
ErsatzTV.CommandLine/Commands/MediaItemsCommand.cs

@ -1,99 +0,0 @@ @@ -1,99 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CliFx;
using CliFx.Attributes;
using ErsatzTV.Api.Sdk.Api;
using ErsatzTV.Api.Sdk.Model;
using LanguageExt;
using LanguageExt.Common;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
namespace ErsatzTV.CommandLine.Commands
{
[Command("items", Description = "Ensure media items exist")]
public class MediaItemsCommand : MediaItemCommandBase
{
private readonly ILogger<MediaItemsCommand> _logger;
private readonly string _serverUrl;
public MediaItemsCommand(IConfiguration configuration, ILogger<MediaItemsCommand> logger)
{
_logger = logger;
_serverUrl = configuration["ServerUrl"];
}
public override async ValueTask ExecuteAsync(IConsole console)
{
try
{
CancellationToken cancellationToken = console.GetCancellationToken();
Either<Error, List<string>> maybeFileNames = await GetFileNames();
await maybeFileNames.Match(
allFiles => SynchronizeMediaItems(cancellationToken, allFiles),
error =>
{
_logger.LogError("{Error}", error.Message);
return Task.CompletedTask;
});
}
catch (Exception ex)
{
_logger.LogError("Unable to synchronize media items: {Error}", ex.Message);
}
}
private async Task SynchronizeMediaItems(CancellationToken cancellationToken, List<string> allFiles)
{
Either<Error, Unit> result = await GetMediaSourceId(cancellationToken)
.BindAsync(
contentSourceId => PostMediaItems(
contentSourceId,
allFiles,
cancellationToken));
result.Match(
_ => _logger.LogInformation(
"Successfully synchronized {Count} media items",
allFiles.Count),
error => _logger.LogError("Unable to synchronize media items: {Error}", error.Message));
}
private async Task<Either<Error, int>> GetMediaSourceId(CancellationToken cancellationToken)
{
var mediaSourcesApi = new MediaSourcesApi(_serverUrl);
List<MediaSourceViewModel> allMediaSources =
await mediaSourcesApi.ApiMediaSourcesGetAsync(cancellationToken);
Option<MediaSourceViewModel> maybeLocalMediaSource =
allMediaSources.SingleOrDefault(cs => cs.SourceType == MediaSourceType.Local);
return maybeLocalMediaSource.Match<Either<Error, int>>(
mediaSource => mediaSource.Id,
() => Error.New("Unable to find local media source"));
}
private async Task<Either<Error, Unit>> PostMediaItems(
int mediaSourceId,
ICollection<string> fileNames,
CancellationToken cancellationToken)
{
var mediaItemsApi = new MediaItemsApi(_serverUrl);
List<MediaItemViewModel> allContent = await mediaItemsApi.ApiMediaItemsGetAsync(cancellationToken);
var missingMediaItems = fileNames.Where(f => allContent.All(c => c.Path != f))
.Map(f => new CreateMediaItem(mediaSourceId, f))
.ToList();
foreach (CreateMediaItem mediaItem in missingMediaItems)
{
_logger.LogInformation("Adding media item {Path}", mediaItem.Path);
await mediaItemsApi.ApiMediaItemsPostAsync(mediaItem, cancellationToken);
}
return unit;
}
}
}

118
ErsatzTV.Core.Tests/FFmpeg/FFmpegPlaybackSettingsServiceTests.cs

@ -20,7 +20,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -20,7 +20,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
{
MediaItem = new MediaItem
{
Metadata = new MediaMetadata()
Statistics = new MediaItemStatistics()
}
};
@ -175,9 +175,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -175,9 +175,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg
};
PlayoutItem playoutItem = EmptyPlayoutItem();
playoutItem.MediaItem.Metadata.Width = 1920;
playoutItem.MediaItem.Metadata.Height = 1080;
playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic
playoutItem.MediaItem.Statistics.Width = 1920;
playoutItem.MediaItem.Statistics.Height = 1080;
playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
@ -198,9 +198,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -198,9 +198,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg
};
PlayoutItem playoutItem = EmptyPlayoutItem();
playoutItem.MediaItem.Metadata.Width = 1918;
playoutItem.MediaItem.Metadata.Height = 1080;
playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic
playoutItem.MediaItem.Statistics.Width = 1918;
playoutItem.MediaItem.Statistics.Height = 1080;
playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
@ -221,9 +221,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -221,9 +221,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg
};
PlayoutItem playoutItem = EmptyPlayoutItem();
playoutItem.MediaItem.Metadata.Width = 1920;
playoutItem.MediaItem.Metadata.Height = 1080;
playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic
playoutItem.MediaItem.Statistics.Width = 1920;
playoutItem.MediaItem.Statistics.Height = 1080;
playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
@ -245,9 +245,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -245,9 +245,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg
};
PlayoutItem playoutItem = EmptyPlayoutItem();
playoutItem.MediaItem.Metadata.Width = 1918;
playoutItem.MediaItem.Metadata.Height = 1080;
playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic
playoutItem.MediaItem.Statistics.Width = 1918;
playoutItem.MediaItem.Statistics.Height = 1080;
playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
@ -269,9 +269,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -269,9 +269,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg
};
PlayoutItem playoutItem = EmptyPlayoutItem();
playoutItem.MediaItem.Metadata.Width = 1918;
playoutItem.MediaItem.Metadata.Height = 1080;
playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic
playoutItem.MediaItem.Statistics.Width = 1918;
playoutItem.MediaItem.Statistics.Height = 1080;
playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming,
@ -295,9 +295,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -295,9 +295,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg
};
PlayoutItem playoutItem = EmptyPlayoutItem();
playoutItem.MediaItem.Metadata.Width = 1918;
playoutItem.MediaItem.Metadata.Height = 1080;
playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic
playoutItem.MediaItem.Statistics.Width = 1918;
playoutItem.MediaItem.Statistics.Height = 1080;
playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
@ -323,10 +323,10 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -323,10 +323,10 @@ namespace ErsatzTV.Core.Tests.FFmpeg
};
PlayoutItem playoutItem = EmptyPlayoutItem();
playoutItem.MediaItem.Metadata.Width = 1920;
playoutItem.MediaItem.Metadata.Height = 1080;
playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic
playoutItem.MediaItem.Metadata.VideoCodec = "mpeg2video";
playoutItem.MediaItem.Statistics.Width = 1920;
playoutItem.MediaItem.Statistics.Height = 1080;
playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic
playoutItem.MediaItem.Statistics.VideoCodec = "mpeg2video";
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
@ -352,10 +352,10 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -352,10 +352,10 @@ namespace ErsatzTV.Core.Tests.FFmpeg
};
PlayoutItem playoutItem = EmptyPlayoutItem();
playoutItem.MediaItem.Metadata.Width = 1920;
playoutItem.MediaItem.Metadata.Height = 1080;
playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic
playoutItem.MediaItem.Metadata.VideoCodec = "mpeg2video";
playoutItem.MediaItem.Statistics.Width = 1920;
playoutItem.MediaItem.Statistics.Height = 1080;
playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic
playoutItem.MediaItem.Statistics.VideoCodec = "mpeg2video";
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming,
@ -380,10 +380,10 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -380,10 +380,10 @@ namespace ErsatzTV.Core.Tests.FFmpeg
};
PlayoutItem playoutItem = EmptyPlayoutItem();
playoutItem.MediaItem.Metadata.Width = 1920;
playoutItem.MediaItem.Metadata.Height = 1080;
playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic
playoutItem.MediaItem.Metadata.VideoCodec = "libx264";
playoutItem.MediaItem.Statistics.Width = 1920;
playoutItem.MediaItem.Statistics.Height = 1080;
playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic
playoutItem.MediaItem.Statistics.VideoCodec = "libx264";
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
@ -409,10 +409,10 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -409,10 +409,10 @@ namespace ErsatzTV.Core.Tests.FFmpeg
};
PlayoutItem playoutItem = EmptyPlayoutItem();
playoutItem.MediaItem.Metadata.Width = 1920;
playoutItem.MediaItem.Metadata.Height = 1080;
playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic
playoutItem.MediaItem.Metadata.VideoCodec = "mpeg2video";
playoutItem.MediaItem.Statistics.Width = 1920;
playoutItem.MediaItem.Statistics.Height = 1080;
playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic
playoutItem.MediaItem.Statistics.VideoCodec = "mpeg2video";
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
@ -437,9 +437,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -437,9 +437,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg
};
PlayoutItem playoutItem = EmptyPlayoutItem();
playoutItem.MediaItem.Metadata.Width = 1918;
playoutItem.MediaItem.Metadata.Height = 1080;
playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic
playoutItem.MediaItem.Statistics.Width = 1918;
playoutItem.MediaItem.Statistics.Height = 1080;
playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
@ -464,10 +464,10 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -464,10 +464,10 @@ namespace ErsatzTV.Core.Tests.FFmpeg
};
PlayoutItem playoutItem = EmptyPlayoutItem();
playoutItem.MediaItem.Metadata.Width = 1920;
playoutItem.MediaItem.Metadata.Height = 1080;
playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic
playoutItem.MediaItem.Metadata.VideoCodec = "mpeg2video";
playoutItem.MediaItem.Statistics.Width = 1920;
playoutItem.MediaItem.Statistics.Height = 1080;
playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic
playoutItem.MediaItem.Statistics.VideoCodec = "mpeg2video";
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
@ -492,9 +492,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -492,9 +492,9 @@ namespace ErsatzTV.Core.Tests.FFmpeg
};
PlayoutItem playoutItem = EmptyPlayoutItem();
playoutItem.MediaItem.Metadata.Width = 1918;
playoutItem.MediaItem.Metadata.Height = 1080;
playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic
playoutItem.MediaItem.Statistics.Width = 1918;
playoutItem.MediaItem.Statistics.Height = 1080;
playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
@ -520,10 +520,10 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -520,10 +520,10 @@ namespace ErsatzTV.Core.Tests.FFmpeg
};
PlayoutItem playoutItem = EmptyPlayoutItem();
playoutItem.MediaItem.Metadata.Width = 1920;
playoutItem.MediaItem.Metadata.Height = 1080;
playoutItem.MediaItem.Metadata.SampleAspectRatio = "1:1"; // not anamorphic
playoutItem.MediaItem.Metadata.VideoCodec = "mpeg2video";
playoutItem.MediaItem.Statistics.Width = 1920;
playoutItem.MediaItem.Statistics.Height = 1080;
playoutItem.MediaItem.Statistics.SampleAspectRatio = "1:1"; // not anamorphic
playoutItem.MediaItem.Statistics.VideoCodec = "mpeg2video";
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
@ -546,7 +546,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -546,7 +546,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
};
PlayoutItem playoutItem = EmptyPlayoutItem();
playoutItem.MediaItem.Metadata.AudioCodec = "aac";
playoutItem.MediaItem.Statistics.AudioCodec = "aac";
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
@ -567,7 +567,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -567,7 +567,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
};
PlayoutItem playoutItem = EmptyPlayoutItem();
playoutItem.MediaItem.Metadata.AudioCodec = "ac3";
playoutItem.MediaItem.Statistics.AudioCodec = "ac3";
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
@ -588,7 +588,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -588,7 +588,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
};
PlayoutItem playoutItem = EmptyPlayoutItem();
playoutItem.MediaItem.Metadata.AudioCodec = "ac3";
playoutItem.MediaItem.Statistics.AudioCodec = "ac3";
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
@ -609,7 +609,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -609,7 +609,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
};
PlayoutItem playoutItem = EmptyPlayoutItem();
playoutItem.MediaItem.Metadata.AudioCodec = "ac3";
playoutItem.MediaItem.Statistics.AudioCodec = "ac3";
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.HttpLiveStreaming,
@ -630,7 +630,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -630,7 +630,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
};
PlayoutItem playoutItem = EmptyPlayoutItem();
playoutItem.MediaItem.Metadata.AudioCodec = "ac3";
playoutItem.MediaItem.Statistics.AudioCodec = "ac3";
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
@ -651,7 +651,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -651,7 +651,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
};
PlayoutItem playoutItem = EmptyPlayoutItem();
playoutItem.MediaItem.Metadata.AudioCodec = "ac3";
playoutItem.MediaItem.Statistics.AudioCodec = "ac3";
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
@ -674,7 +674,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -674,7 +674,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
};
PlayoutItem playoutItem = EmptyPlayoutItem();
playoutItem.MediaItem.Metadata.AudioCodec = "ac3";
playoutItem.MediaItem.Statistics.AudioCodec = "ac3";
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
@ -697,7 +697,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -697,7 +697,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
};
PlayoutItem playoutItem = EmptyPlayoutItem();
playoutItem.MediaItem.Metadata.AudioCodec = "ac3";
playoutItem.MediaItem.Statistics.AudioCodec = "ac3";
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
@ -719,7 +719,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -719,7 +719,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
};
PlayoutItem playoutItem = EmptyPlayoutItem();
playoutItem.MediaItem.Metadata.AudioCodec = "ac3";
playoutItem.MediaItem.Statistics.AudioCodec = "ac3";
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,
@ -741,7 +741,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg @@ -741,7 +741,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
};
PlayoutItem playoutItem = EmptyPlayoutItem();
playoutItem.MediaItem.Metadata.AudioCodec = "ac3";
playoutItem.MediaItem.Statistics.AudioCodec = "ac3";
FFmpegPlaybackSettings actual = _calculator.CalculateSettings(
StreamingMode.TransportStream,

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

@ -37,13 +37,13 @@ namespace ErsatzTV.Core.Tests.Metadata @@ -37,13 +37,13 @@ namespace ErsatzTV.Core.Tests.Metadata
2)]
public void GetFallbackMetadata_ShouldHandleVariousFormats(string path, string title, int season, int episode)
{
MediaMetadata metadata = FallbackMetadataProvider.GetFallbackMetadata(
new MediaItem { Path = path, Source = new LocalMediaSource { MediaType = MediaType.TvShow } });
TelevisionEpisodeMetadata metadata = FallbackMetadataProvider.GetFallbackMetadata(
new TelevisionEpisodeMediaItem
{ Path = path, Source = new LocalMediaSource { MediaType = MediaType.TvShow } });
metadata.MediaType.Should().Be(MediaType.TvShow);
metadata.Title.Should().Be(title);
metadata.SeasonNumber.Should().Be(season);
metadata.EpisodeNumber.Should().Be(episode);
metadata.Season.Should().Be(season);
metadata.Episode.Should().Be(episode);
}
}
}

1002
ErsatzTV.Core.Tests/Metadata/LocalMediaSourcePlannerTests.cs

File diff suppressed because it is too large Load Diff

6
ErsatzTV.Core.Tests/Scheduling/ChronologicalContentTests.cs

@ -62,12 +62,12 @@ namespace ErsatzTV.Core.Tests.Scheduling @@ -62,12 +62,12 @@ namespace ErsatzTV.Core.Tests.Scheduling
private static List<MediaItem> Episodes(int count) =>
Range(1, count).Map(
i => new MediaItem
i => (MediaItem) new TelevisionEpisodeMediaItem
{
Id = i,
Metadata = new MediaMetadata
Metadata = new TelevisionEpisodeMetadata
{
MediaType = MediaType.TvShow, Aired = new DateTime(2020, 1, i)
Aired = new DateTime(2020, 1, i)
}
})
.Reverse()

5
ErsatzTV.Core.Tests/Scheduling/PlayoutBuilderTests.cs

@ -604,10 +604,11 @@ namespace ErsatzTV.Core.Tests.Scheduling @@ -604,10 +604,11 @@ namespace ErsatzTV.Core.Tests.Scheduling
};
private static MediaItem TestMovie(int id, TimeSpan duration, DateTime aired) =>
new()
new MovieMediaItem
{
Id = id,
Metadata = new MediaMetadata { Duration = duration, MediaType = MediaType.Movie, Aired = aired }
Metadata = new MovieMetadata { Premiered = aired },
Statistics = new MediaItemStatistics { Duration = duration }
};
private TestData TestDataFloodForItems(List<MediaItem> mediaItems, PlaybackOrder playbackOrder)

6
ErsatzTV.Core.Tests/Scheduling/RandomizedContentTests.cs

@ -80,12 +80,12 @@ namespace ErsatzTV.Core.Tests.Scheduling @@ -80,12 +80,12 @@ namespace ErsatzTV.Core.Tests.Scheduling
private static List<MediaItem> Episodes(int count) =>
Range(1, count).Map(
i => new MediaItem
i => (MediaItem) new TelevisionEpisodeMediaItem
{
Id = i,
Metadata = new MediaMetadata
Metadata = new TelevisionEpisodeMetadata
{
MediaType = MediaType.TvShow, Aired = new DateTime(2020, 1, i)
Aired = new DateTime(2020, 1, i)
}
})
.Reverse()

6
ErsatzTV.Core.Tests/Scheduling/ShuffledContentTests.cs

@ -70,12 +70,12 @@ namespace ErsatzTV.Core.Tests.Scheduling @@ -70,12 +70,12 @@ namespace ErsatzTV.Core.Tests.Scheduling
private static List<MediaItem> Episodes(int count) =>
Range(1, count).Map(
i => new MediaItem
i => (MediaItem) new TelevisionEpisodeMediaItem
{
Id = i,
Metadata = new MediaMetadata
Metadata = new TelevisionEpisodeMetadata
{
MediaType = MediaType.TvShow, Aired = new DateTime(2020, 1, i)
Aired = new DateTime(2020, 1, i)
}
})
.Reverse()

12
ErsatzTV.Core/Domain/MediaItem.cs

@ -1,18 +1,22 @@ @@ -1,18 +1,22 @@
using System;
using System.Collections.Generic;
using ErsatzTV.Core.Interfaces.Domain;
namespace ErsatzTV.Core.Domain
{
public class MediaItem
public class MediaItem : IHasAPoster
{
public int Id { get; set; }
public int MediaSourceId { get; set; }
public MediaSource Source { get; set; }
// public MediaMetadata Metadata { get; set; }
public MediaItemStatistics Statistics { get; set; }
public DateTime? LastWriteTime { get; set; }
public IList<SimpleMediaCollection> SimpleMediaCollections { get; set; }
public string Path { get; set; }
public string Poster { get; set; }
public DateTime? PosterLastWriteTime { get; set; }
public MediaMetadata Metadata { get; set; }
public DateTime? LastWriteTime { get; set; }
public IList<SimpleMediaCollection> SimpleMediaCollections { get; set; }
}
}

12
ErsatzTV.Core/Domain/MediaItemMetadata.cs

@ -0,0 +1,12 @@ @@ -0,0 +1,12 @@
using System;
namespace ErsatzTV.Core.Domain
{
public class MediaItemMetadata
{
public MetadataSource Source { get; set; }
public DateTime? LastWriteTime { get; set; }
public string Title { get; set; }
public string SortTitle { get; set; }
}
}

18
ErsatzTV.Core/Domain/MediaItemStatistics.cs

@ -0,0 +1,18 @@ @@ -0,0 +1,18 @@
using System;
using ErsatzTV.Core.Interfaces.FFmpeg;
namespace ErsatzTV.Core.Domain
{
public record MediaItemStatistics : IDisplaySize
{
public DateTime? LastWriteTime { get; set; }
public TimeSpan Duration { get; set; }
public string SampleAspectRatio { get; set; }
public string DisplayAspectRatio { get; set; }
public string VideoCodec { get; set; }
public string AudioCodec { get; set; }
public VideoScanType VideoScanType { get; set; }
public int Width { get; set; }
public int Height { get; set; }
}
}

8
ErsatzTV.Core/Domain/MovieMediaItem.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Domain
{
public class MovieMediaItem : MediaItem
{
public int MetadataId { get; set; }
public MovieMetadata Metadata { get; set; }
}
}

17
ErsatzTV.Core/Domain/MovieMetadata.cs

@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
using System;
namespace ErsatzTV.Core.Domain
{
public class MovieMetadata : MediaItemMetadata
{
public int Id { get; set; }
public int MovieId { get; set; }
public MovieMediaItem Movie { get; set; }
public int? Year { get; set; }
public DateTime? Premiered { get; set; }
public string Plot { get; set; }
public string Outline { get; set; }
public string Tagline { get; set; }
public string ContentRating { get; set; }
}
}

9
ErsatzTV.Core/Domain/TelevisionEpisodeMediaItem.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Domain
{
public class TelevisionEpisodeMediaItem : MediaItem
{
public int SeasonId { get; set; }
public TelevisionSeason Season { get; set; }
public TelevisionEpisodeMetadata Metadata { get; set; }
}
}

15
ErsatzTV.Core/Domain/TelevisionEpisodeMetadata.cs

@ -0,0 +1,15 @@ @@ -0,0 +1,15 @@
using System;
namespace ErsatzTV.Core.Domain
{
public class TelevisionEpisodeMetadata : MediaItemMetadata
{
public int Id { get; set; }
public int TelevisionEpisodeId { get; set; }
public TelevisionEpisodeMediaItem TelevisionEpisode { get; set; }
public int Season { get; set; }
public int Episode { get; set; }
public string Plot { get; set; }
public DateTime? Aired { get; set; }
}
}

18
ErsatzTV.Core/Domain/TelevisionSeason.cs

@ -0,0 +1,18 @@ @@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
using ErsatzTV.Core.Interfaces.Domain;
namespace ErsatzTV.Core.Domain
{
public class TelevisionSeason : IHasAPoster
{
public int Id { get; set; }
public int TelevisionShowId { get; set; }
public TelevisionShow TelevisionShow { get; set; }
public int Number { get; set; }
public List<TelevisionEpisodeMediaItem> Episodes { get; set; }
public string Path { get; set; }
public string Poster { get; set; }
public DateTime? PosterLastWriteTime { get; set; }
}
}

18
ErsatzTV.Core/Domain/TelevisionShow.cs

@ -0,0 +1,18 @@ @@ -0,0 +1,18 @@
using System;
using System.Collections.Generic;
using ErsatzTV.Core.Interfaces.Domain;
namespace ErsatzTV.Core.Domain
{
public class TelevisionShow : IHasAPoster
{
public int Id { get; set; }
public int MediaSourceId { get; set; }
public MediaSource Source { get; set; }
public TelevisionShowMetadata Metadata { get; set; }
public List<TelevisionSeason> Seasons { get; set; }
public string Path { get; set; }
public string Poster { get; set; }
public DateTime? PosterLastWriteTime { get; set; }
}
}

17
ErsatzTV.Core/Domain/TelevisionShowMetadata.cs

@ -0,0 +1,17 @@ @@ -0,0 +1,17 @@
using System;
namespace ErsatzTV.Core.Domain
{
public class TelevisionShowMetadata
{
public int Id { get; set; }
public int TelevisionShowId { get; set; }
public TelevisionShow TelevisionShow { get; set; }
public MetadataSource Source { get; set; }
public DateTime? LastWriteTime { get; set; }
public string Title { get; set; }
public string SortTitle { get; set; }
public int? Year { get; set; }
public string Plot { get; set; }
}
}

64
ErsatzTV.Core/FFmpeg/FFmpegPlaybackSettingsCalculator.cs

@ -66,24 +66,24 @@ namespace ErsatzTV.Core.FFmpeg @@ -66,24 +66,24 @@ namespace ErsatzTV.Core.FFmpeg
result.Deinterlace = false;
break;
case StreamingMode.TransportStream:
if (NeedToScale(ffmpegProfile, playoutItem.MediaItem.Metadata))
if (NeedToScale(ffmpegProfile, playoutItem.MediaItem.Statistics))
{
IDisplaySize scaledSize = CalculateScaledSize(ffmpegProfile, playoutItem.MediaItem.Metadata);
if (!scaledSize.IsSameSizeAs(playoutItem.MediaItem.Metadata))
IDisplaySize scaledSize = CalculateScaledSize(ffmpegProfile, playoutItem.MediaItem.Statistics);
if (!scaledSize.IsSameSizeAs(playoutItem.MediaItem.Statistics))
{
result.ScaledSize = Some(
CalculateScaledSize(ffmpegProfile, playoutItem.MediaItem.Metadata));
CalculateScaledSize(ffmpegProfile, playoutItem.MediaItem.Statistics));
}
}
IDisplaySize sizeAfterScaling = result.ScaledSize.IfNone(playoutItem.MediaItem.Metadata);
IDisplaySize sizeAfterScaling = result.ScaledSize.IfNone(playoutItem.MediaItem.Statistics);
if (!sizeAfterScaling.IsSameSizeAs(ffmpegProfile.Resolution))
{
result.PadToDesiredResolution = true;
}
if (result.ScaledSize.IsSome || result.PadToDesiredResolution ||
NeedToNormalizeVideoCodec(ffmpegProfile, playoutItem.MediaItem.Metadata))
NeedToNormalizeVideoCodec(ffmpegProfile, playoutItem.MediaItem.Statistics))
{
result.VideoCodec = ffmpegProfile.VideoCodec;
result.VideoBitrate = ffmpegProfile.VideoBitrate;
@ -94,7 +94,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -94,7 +94,7 @@ namespace ErsatzTV.Core.FFmpeg
result.VideoCodec = "copy";
}
if (NeedToNormalizeAudioCodec(ffmpegProfile, playoutItem.MediaItem.Metadata))
if (NeedToNormalizeAudioCodec(ffmpegProfile, playoutItem.MediaItem.Statistics))
{
result.AudioCodec = ffmpegProfile.AudioCodec;
result.AudioBitrate = ffmpegProfile.AudioBitrate;
@ -104,7 +104,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -104,7 +104,7 @@ namespace ErsatzTV.Core.FFmpeg
{
result.AudioChannels = ffmpegProfile.AudioChannels;
result.AudioSampleRate = ffmpegProfile.AudioSampleRate;
result.AudioDuration = playoutItem.MediaItem.Metadata.Duration;
result.AudioDuration = playoutItem.MediaItem.Statistics.Duration;
}
}
else
@ -112,7 +112,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -112,7 +112,7 @@ namespace ErsatzTV.Core.FFmpeg
result.AudioCodec = "copy";
}
if (playoutItem.MediaItem.Metadata.VideoScanType == VideoScanType.Interlaced)
if (playoutItem.MediaItem.Statistics.VideoScanType == VideoScanType.Interlaced)
{
result.Deinterlace = true;
}
@ -132,16 +132,16 @@ namespace ErsatzTV.Core.FFmpeg @@ -132,16 +132,16 @@ namespace ErsatzTV.Core.FFmpeg
AudioCodec = ffmpegProfile.AudioCodec
};
private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaMetadata mediaMetadata) =>
private static bool NeedToScale(FFmpegProfile ffmpegProfile, MediaItemStatistics statistics) =>
ffmpegProfile.NormalizeResolution &&
IsIncorrectSize(ffmpegProfile.Resolution, mediaMetadata) ||
IsTooLarge(ffmpegProfile.Resolution, mediaMetadata) ||
IsOddSize(mediaMetadata);
IsIncorrectSize(ffmpegProfile.Resolution, statistics) ||
IsTooLarge(ffmpegProfile.Resolution, statistics) ||
IsOddSize(statistics);
private static bool IsIncorrectSize(IDisplaySize desiredResolution, MediaMetadata mediaMetadata) =>
IsAnamorphic(mediaMetadata) ||
mediaMetadata.Width != desiredResolution.Width ||
mediaMetadata.Height != desiredResolution.Height;
private static bool IsIncorrectSize(IDisplaySize desiredResolution, MediaItemStatistics statistics) =>
IsAnamorphic(statistics) ||
statistics.Width != desiredResolution.Width ||
statistics.Height != desiredResolution.Height;
private static bool IsTooLarge(IDisplaySize desiredResolution, IDisplaySize mediaSize) =>
mediaSize.Height > desiredResolution.Height ||
@ -150,17 +150,17 @@ namespace ErsatzTV.Core.FFmpeg @@ -150,17 +150,17 @@ namespace ErsatzTV.Core.FFmpeg
private static bool IsOddSize(IDisplaySize displaySize) =>
displaySize.Height % 2 == 1 || displaySize.Width % 2 == 1;
private static bool NeedToNormalizeVideoCodec(FFmpegProfile ffmpegProfile, MediaMetadata mediaMetadata) =>
ffmpegProfile.NormalizeVideoCodec && ffmpegProfile.VideoCodec != mediaMetadata.VideoCodec;
private static bool NeedToNormalizeVideoCodec(FFmpegProfile ffmpegProfile, MediaItemStatistics statistics) =>
ffmpegProfile.NormalizeVideoCodec && ffmpegProfile.VideoCodec != statistics.VideoCodec;
private static bool NeedToNormalizeAudioCodec(FFmpegProfile ffmpegProfile, MediaMetadata mediaMetadata) =>
ffmpegProfile.NormalizeAudioCodec && ffmpegProfile.AudioCodec != mediaMetadata.AudioCodec;
private static bool NeedToNormalizeAudioCodec(FFmpegProfile ffmpegProfile, MediaItemStatistics statistics) =>
ffmpegProfile.NormalizeAudioCodec && ffmpegProfile.AudioCodec != statistics.AudioCodec;
private static IDisplaySize CalculateScaledSize(FFmpegProfile ffmpegProfile, MediaMetadata mediaMetadata)
private static IDisplaySize CalculateScaledSize(FFmpegProfile ffmpegProfile, MediaItemStatistics statistics)
{
IDisplaySize sarSize = SARSize(mediaMetadata);
int p = mediaMetadata.Width * sarSize.Width;
int q = mediaMetadata.Height * sarSize.Height;
IDisplaySize sarSize = SARSize(statistics);
int p = statistics.Width * sarSize.Width;
int q = statistics.Height * sarSize.Height;
int g = Gcd(q, p);
p = p / g;
q = q / g;
@ -194,29 +194,29 @@ namespace ErsatzTV.Core.FFmpeg @@ -194,29 +194,29 @@ namespace ErsatzTV.Core.FFmpeg
return a | b;
}
private static bool IsAnamorphic(MediaMetadata mediaMetadata)
private static bool IsAnamorphic(MediaItemStatistics statistics)
{
if (mediaMetadata.SampleAspectRatio == "1:1")
if (statistics.SampleAspectRatio == "1:1")
{
return false;
}
if (mediaMetadata.SampleAspectRatio != "0:1")
if (statistics.SampleAspectRatio != "0:1")
{
return true;
}
if (mediaMetadata.DisplayAspectRatio == "0:1")
if (statistics.DisplayAspectRatio == "0:1")
{
return false;
}
return mediaMetadata.DisplayAspectRatio != $"{mediaMetadata.Width}:{mediaMetadata.Height}";
return statistics.DisplayAspectRatio != $"{statistics.Width}:{statistics.Height}";
}
private static IDisplaySize SARSize(MediaMetadata mediaMetadata)
private static IDisplaySize SARSize(MediaItemStatistics statistics)
{
string[] split = mediaMetadata.SampleAspectRatio.Split(":");
string[] split = statistics.SampleAspectRatio.Split(":");
return new DisplaySize(int.Parse(split[0]), int.Parse(split[1]));
}
}

2
ErsatzTV.Core/FFmpeg/FFmpegProcessService.cs

@ -76,7 +76,7 @@ namespace ErsatzTV.Core.FFmpeg @@ -76,7 +76,7 @@ namespace ErsatzTV.Core.FFmpeg
return builder.WithPlaybackArgs(playbackSettings)
.WithMetadata(channel)
.WithFormat("mpegts")
.WithDuration(item.Start + item.MediaItem.Metadata.Duration - now)
.WithDuration(item.Start + item.MediaItem.Statistics.Duration - now)
.WithPipe()
.Build();
}

11
ErsatzTV.Core/Interfaces/Domain/IHasAPoster.cs

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
using System;
namespace ErsatzTV.Core.Interfaces.Domain
{
public interface IHasAPoster
{
public string Path { get; set; }
public string Poster { get; set; }
public DateTime? PosterLastWriteTime { get; set; }
}
}

8
ErsatzTV.Core/Interfaces/Metadata/ILocalFileSystem.cs

@ -1,6 +1,6 @@ @@ -1,6 +1,6 @@
using System;
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata
{
@ -8,8 +8,8 @@ namespace ErsatzTV.Core.Interfaces.Metadata @@ -8,8 +8,8 @@ namespace ErsatzTV.Core.Interfaces.Metadata
{
public DateTime GetLastWriteTime(string path);
public bool IsMediaSourceAccessible(LocalMediaSource localMediaSource);
public Seq<string> FindRelevantVideos(LocalMediaSource localMediaSource);
public bool ShouldRefreshMetadata(LocalMediaSource localMediaSource, MediaItem mediaItem);
public bool ShouldRefreshPoster(MediaItem mediaItem);
public IEnumerable<string> ListSubdirectories(string folder);
public IEnumerable<string> ListFiles(string folder);
public bool FileExists(string path);
}
}

7
ErsatzTV.Core/Interfaces/Metadata/ILocalMediaScanner.cs

@ -1,15 +1,12 @@ @@ -1,15 +1,12 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ILocalMediaScanner
{
Task<Unit> ScanLocalMediaSource(
LocalMediaSource localMediaSource,
string ffprobePath,
ScanningMode scanningMode);
Task<Unit> ScanMovies(LocalMediaSource localMediaSource, string ffprobePath);
Task<Unit> ScanTelevision(LocalMediaSource localMediaSource, string ffprobePath);
}
}

14
ErsatzTV.Core/Interfaces/Metadata/ILocalMediaSourcePlanner.cs

@ -1,14 +0,0 @@ @@ -1,14 +0,0 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Metadata;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ILocalMediaSourcePlanner
{
public Seq<LocalMediaSourcePlan> DetermineActions(
MediaType mediaType,
Seq<MediaItem> mediaItems,
Seq<string> files);
}
}

7
ErsatzTV.Core/Interfaces/Metadata/ILocalMetadataProvider.cs

@ -1,11 +1,14 @@ @@ -1,11 +1,14 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ILocalMetadataProvider
{
Task RefreshSidecarMetadata(MediaItem mediaItem, string path);
Task RefreshFallbackMetadata(MediaItem mediaItem);
Task<Unit> RefreshSidecarMetadata(MediaItem mediaItem, string path);
Task<Unit> RefreshSidecarMetadata(TelevisionShow televisionShow, string path);
Task<Unit> RefreshFallbackMetadata(MediaItem mediaItem);
Task<Unit> RefreshFallbackMetadata(TelevisionShow televisionShow);
}
}

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

@ -5,6 +5,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata @@ -5,6 +5,7 @@ namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ILocalPosterProvider
{
Task RefreshPoster(MediaItem mediaItem);
public Task RefreshPoster(MediaItem mediaItem);
public Task SavePosterToDisk(MediaItem mediaItem, string posterPath);
}
}

11
ErsatzTV.Core/Interfaces/Metadata/IMovieFolderScanner.cs

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface IMovieFolderScanner
{
Task<Unit> ScanFolder(LocalMediaSource localMediaSource, string ffprobePath);
}
}

2
ErsatzTV.Core/Interfaces/Metadata/ISmartCollectionBuilder.cs

@ -5,6 +5,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata @@ -5,6 +5,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ISmartCollectionBuilder
{
Task<bool> RefreshSmartCollections(MediaItem mediaItem);
Task<bool> RefreshSmartCollections(TelevisionEpisodeMediaItem mediaItem);
}
}

11
ErsatzTV.Core/Interfaces/Metadata/ITelevisionFolderScanner.cs

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Metadata
{
public interface ITelevisionFolderScanner
{
Task<Unit> ScanFolder(LocalMediaSource localMediaSource, string ffprobePath);
}
}

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

@ -16,6 +16,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -16,6 +16,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
public Task<int> GetCountByType(MediaType mediaType);
public Task<List<MediaItem>> GetAllByMediaSourceId(int mediaSourceId);
public Task<bool> Update(MediaItem mediaItem);
public Task Delete(int mediaItemId);
public Task<Unit> Delete(int mediaItemId);
}
}

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

@ -0,0 +1,11 @@ @@ -0,0 +1,11 @@
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface IMovieRepository
{
public Task<Either<BaseError, MovieMediaItem>> GetOrAdd(int mediaSourceId, string path);
}
}

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

@ -0,0 +1,27 @@ @@ -0,0 +1,27 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Interfaces.Repositories
{
public interface ITelevisionRepository
{
public Task<Unit> Add(TelevisionShow show);
public Task<List<TelevisionShow>> GetAllByMediaSourceId(int mediaSourceId);
public Task<bool> Update(TelevisionShow show);
public Task<bool> Update(TelevisionEpisodeMediaItem episode);
public Task<Either<BaseError, TelevisionShow>> GetOrAddShow(int mediaSourceId, string path);
public Task<Either<BaseError, TelevisionSeason>> GetOrAddSeason(
TelevisionShow show,
string path,
int seasonNumber);
public Task<Either<BaseError, TelevisionEpisodeMediaItem>> GetOrAddEpisode(
TelevisionSeason season,
int mediaSourceId,
string path);
}
}

60
ErsatzTV.Core/Iptv/ChannelGuide.cs

@ -57,11 +57,26 @@ namespace ErsatzTV.Core.Iptv @@ -57,11 +57,26 @@ namespace ErsatzTV.Core.Iptv
{
string start = playoutItem.Start.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
string stop = playoutItem.Finish.ToString("yyyyMMddHHmmss zzz").Replace(":", string.Empty);
MediaMetadata metadata = Optional(playoutItem.MediaItem.Metadata).IfNone(
new MediaMetadata
{
Title = Path.GetFileName(playoutItem.MediaItem.Path)
});
string title = playoutItem.MediaItem switch
{
MovieMediaItem m => m.Metadata?.Title ?? m.Path,
TelevisionEpisodeMediaItem e => e.Metadata?.Title ?? e.Path,
_ => "[unknown]"
};
string description = playoutItem.MediaItem switch
{
MovieMediaItem m => m.Metadata?.Plot,
TelevisionEpisodeMediaItem e => e.Metadata?.Plot,
_ => string.Empty
};
string contentRating = playoutItem.MediaItem switch
{
MovieMediaItem m => m.Metadata?.ContentRating,
_ => string.Empty
};
xml.WriteStartElement("programme");
xml.WriteAttributeString("start", start);
@ -70,7 +85,7 @@ namespace ErsatzTV.Core.Iptv @@ -70,7 +85,7 @@ namespace ErsatzTV.Core.Iptv
xml.WriteStartElement("title");
xml.WriteAttributeString("lang", "en");
xml.WriteString(metadata.Title);
xml.WriteString(title);
xml.WriteEndElement(); // title
xml.WriteStartElement("previously-shown");
@ -80,28 +95,35 @@ namespace ErsatzTV.Core.Iptv @@ -80,28 +95,35 @@ namespace ErsatzTV.Core.Iptv
xml.WriteAttributeString("lang", "en");
xml.WriteEndElement(); // sub-title
int season = Optional(metadata.SeasonNumber).IfNone(0);
int episode = Optional(metadata.EpisodeNumber).IfNone(0);
if (season > 0 && episode > 0)
if (playoutItem.MediaItem is TelevisionEpisodeMediaItem episode)
{
xml.WriteStartElement("episode-num");
xml.WriteAttributeString("system", "xmltv_ns");
xml.WriteString($"{season - 1}.{episode - 1}.0/1");
xml.WriteEndElement(); // episode-num
int s = Optional(episode.Metadata?.Season).IfNone(0);
int e = Optional(episode.Metadata?.Episode).IfNone(0);
if (s > 0 && e > 0)
{
xml.WriteStartElement("episode-num");
xml.WriteAttributeString("system", "xmltv_ns");
xml.WriteString($"{s - 1}.{e - 1}.0/1");
xml.WriteEndElement(); // episode-num
}
}
// sb.AppendLine("<icon src=\"\"/>");
xml.WriteStartElement("desc");
xml.WriteAttributeString("lang", "en");
xml.WriteString(metadata.Description);
xml.WriteEndElement(); // desc
if (!string.IsNullOrWhiteSpace(metadata.ContentRating))
if (!string.IsNullOrWhiteSpace(description))
{
xml.WriteStartElement("desc");
xml.WriteAttributeString("lang", "en");
xml.WriteString(description);
xml.WriteEndElement(); // desc
}
if (!string.IsNullOrWhiteSpace(contentRating))
{
xml.WriteStartElement("rating");
xml.WriteAttributeString("system", "MPAA");
xml.WriteStartElement("value");
xml.WriteString(metadata.ContentRating);
xml.WriteString(contentRating);
xml.WriteEndElement(); // value
xml.WriteEndElement(); // rating
}

20
ErsatzTV.Core/LanguageExtensions.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using System.Threading.Tasks;
using LanguageExt;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core
{
@ -12,5 +13,24 @@ namespace ErsatzTV.Core @@ -12,5 +13,24 @@ namespace ErsatzTV.Core
validation.ToEither()
.MapLeft(errors => errors.Join())
.MapAsync<BaseError, Task<TR>, TR>(e => e);
public static Task<T> LogFailure<T>(
this TryAsync<T> tryAsync,
T defaultValue,
ILogger logger,
string message,
params object[] args) =>
tryAsync.IfFail(
ex =>
{
logger.LogError(ex, message, args);
return defaultValue;
});
public static Task<Unit> LogFailure(
this TryAsync<Unit> tryAsync,
ILogger logger,
string message,
params object[] args) => LogFailure(tryAsync, Unit.Default, logger, message, args);
}
}

4
ErsatzTV.Core/Metadata/ActionPlan.cs

@ -1,4 +0,0 @@ @@ -1,4 +0,0 @@
namespace ErsatzTV.Core.Metadata
{
public record ActionPlan(string TargetPath, ScanningAction TargetAction);
}

72
ErsatzTV.Core/Metadata/FallbackMetadataProvider.cs

@ -7,30 +7,52 @@ namespace ErsatzTV.Core.Metadata @@ -7,30 +7,52 @@ namespace ErsatzTV.Core.Metadata
{
public static class FallbackMetadataProvider
{
public static MediaMetadata GetFallbackMetadata(MediaItem mediaItem)
public static TelevisionShowMetadata GetFallbackMetadata(TelevisionShow televisionShow)
{
string fileName = Path.GetFileName(televisionShow.Path);
var metadata = new TelevisionShowMetadata
{ Source = MetadataSource.Fallback, Title = fileName ?? televisionShow.Path };
return GetTelevisionShowMetadata(fileName, metadata);
}
public static TelevisionEpisodeMetadata GetFallbackMetadata(TelevisionEpisodeMediaItem mediaItem)
{
string fileName = Path.GetFileName(mediaItem.Path);
var metadata = new MediaMetadata { Source = MetadataSource.Fallback, Title = fileName ?? mediaItem.Path };
var metadata = new TelevisionEpisodeMetadata
{ Source = MetadataSource.Fallback, Title = fileName ?? mediaItem.Path };
if (fileName != null)
{
if (!(mediaItem.Source is LocalMediaSource localMediaSource))
if (!(mediaItem.Source is LocalMediaSource))
{
return metadata;
}
return localMediaSource.MediaType switch
return GetEpisodeMetadata(fileName, metadata);
}
return metadata;
}
public static MovieMetadata GetFallbackMetadata(MovieMediaItem mediaItem)
{
string fileName = Path.GetFileName(mediaItem.Path);
var metadata = new MovieMetadata { Source = MetadataSource.Fallback, Title = fileName ?? mediaItem.Path };
if (fileName != null)
{
if (!(mediaItem.Source is LocalMediaSource))
{
MediaType.TvShow => GetTvShowMetadata(fileName, metadata),
MediaType.Movie => GetMovieMetadata(fileName, metadata),
_ => metadata
};
return metadata;
}
return GetMovieMetadata(fileName, metadata);
}
return metadata;
}
private static MediaMetadata GetTvShowMetadata(string fileName, MediaMetadata metadata)
private static TelevisionEpisodeMetadata GetEpisodeMetadata(string fileName, TelevisionEpisodeMetadata metadata)
{
try
{
@ -38,10 +60,9 @@ namespace ErsatzTV.Core.Metadata @@ -38,10 +60,9 @@ namespace ErsatzTV.Core.Metadata
Match match = Regex.Match(fileName, PATTERN);
if (match.Success)
{
metadata.MediaType = MediaType.TvShow;
metadata.Title = match.Groups[1].Value;
metadata.SeasonNumber = int.Parse(match.Groups[2].Value);
metadata.EpisodeNumber = int.Parse(match.Groups[3].Value);
metadata.Season = int.Parse(match.Groups[2].Value);
metadata.Episode = int.Parse(match.Groups[3].Value);
}
}
catch (Exception)
@ -52,7 +73,7 @@ namespace ErsatzTV.Core.Metadata @@ -52,7 +73,7 @@ namespace ErsatzTV.Core.Metadata
return metadata;
}
private static MediaMetadata GetMovieMetadata(string fileName, MediaMetadata metadata)
private static MovieMetadata GetMovieMetadata(string fileName, MovieMetadata metadata)
{
try
{
@ -60,9 +81,30 @@ namespace ErsatzTV.Core.Metadata @@ -60,9 +81,30 @@ namespace ErsatzTV.Core.Metadata
Match match = Regex.Match(fileName, PATTERN);
if (match.Success)
{
metadata.MediaType = MediaType.Movie;
metadata.Title = match.Groups[1].Value;
metadata.Aired = new DateTime(int.Parse(match.Groups[2].Value), 1, 1);
metadata.Year = int.Parse(match.Groups[2].Value);
}
}
catch (Exception)
{
// ignored
}
return metadata;
}
private static TelevisionShowMetadata GetTelevisionShowMetadata(
string fileName,
TelevisionShowMetadata metadata)
{
try
{
const string PATTERN = @"^(.*?)[\s.]+?[.\(](\d{4})[.\)].*$";
Match match = Regex.Match(fileName, PATTERN);
if (match.Success)
{
metadata.Title = match.Groups[1].Value;
metadata.Year = int.Parse(match.Groups[2].Value);
}
}
catch (Exception)

53
ErsatzTV.Core/Metadata/LocalFileSystem.cs

@ -1,9 +1,8 @@ @@ -1,9 +1,8 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Metadata
@ -16,52 +15,12 @@ namespace ErsatzTV.Core.Metadata @@ -16,52 +15,12 @@ namespace ErsatzTV.Core.Metadata
public bool IsMediaSourceAccessible(LocalMediaSource localMediaSource) =>
Directory.Exists(localMediaSource.Folder);
public Seq<string> FindRelevantVideos(LocalMediaSource localMediaSource)
{
Seq<string> allDirectories = Directory
.GetDirectories(localMediaSource.Folder, "*", SearchOption.AllDirectories)
.ToSeq()
.Add(localMediaSource.Folder);
public IEnumerable<string> ListSubdirectories(string folder) =>
Try(Directory.EnumerateDirectories(folder)).IfFail(new List<string>());
// remove any directories with an .etvignore file locally, or in any parent directory
Seq<string> excluded = allDirectories.Filter(ShouldExcludeDirectory);
Seq<string> relevantDirectories = allDirectories
.Filter(d => !excluded.Any(d.StartsWith))
.Filter(d => localMediaSource.MediaType == MediaType.Other || !IsExtrasFolder(d));
public IEnumerable<string> ListFiles(string folder) =>
Try(Directory.EnumerateFiles(folder, "*", SearchOption.TopDirectoryOnly)).IfFail(new List<string>());
return relevantDirectories
.Collect(d => Directory.GetFiles(d, "*", SearchOption.TopDirectoryOnly))
.Filter(file => KnownExtensions.Contains(Path.GetExtension(file)))
.OrderBy(identity)
.ToSeq();
}
public bool ShouldRefreshMetadata(LocalMediaSource localMediaSource, MediaItem mediaItem)
{
DateTime lastWrite = File.GetLastWriteTimeUtc(mediaItem.Path);
bool modified = lastWrite > mediaItem.LastWriteTime.IfNone(DateTime.MinValue);
return modified // media item has been modified
|| mediaItem.Metadata == null // media item has no metadata
|| mediaItem.Metadata.MediaType != localMediaSource.MediaType; // media item is typed incorrectly
}
public bool ShouldRefreshPoster(MediaItem mediaItem) =>
string.IsNullOrWhiteSpace(mediaItem.Poster);
private static bool ShouldExcludeDirectory(string path) => File.Exists(Path.Combine(path, ".etvignore"));
// see https://support.emby.media/support/solutions/articles/44001159102-movie-naming
private static bool IsExtrasFolder(string path) =>
ExtraFolderNames.Contains(Path.GetFileName(path)?.ToLowerInvariant());
// @formatter:off
private static readonly Seq<string> KnownExtensions = Seq(
".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4",
".m4p", ".m4v", ".avi", ".wmv", ".mov", ".mkv", ".ts");
private static readonly Seq<string> ExtraFolderNames = Seq(
"extras", "specials", "shorts", "scenes", "featurettes",
"behind the scenes", "deleted scenes", "interviews", "trailers");
// @formatter:on
public bool FileExists(string path) => File.Exists(path);
}
}

75
ErsatzTV.Core/Metadata/LocalFolderScanner.cs

@ -0,0 +1,75 @@ @@ -0,0 +1,75 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using LanguageExt;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Metadata
{
public abstract class LocalFolderScanner
{
protected static readonly List<string> VideoFileExtensions = new()
{
".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4",
".m4p", ".m4v", ".avi", ".wmv", ".mov", ".mkv", ".ts"
};
protected static readonly List<string> ImageFileExtensions = new()
{
"jpg", "jpeg", "png", "gif", "tbn"
};
protected static readonly List<string> ExtraFiles = new()
{
"behindthescenes", "deleted", "featurette",
"interview", "scene", "short", "trailer", "other"
};
protected static readonly List<string> ExtraDirectories = new List<string>
{
"behind the scenes", "deleted scenes", "featurettes",
"interviews", "scenes", "shorts", "trailers", "other",
"extras", "specials"
}
.Map(s => $"{Path.DirectorySeparatorChar}{s}{Path.DirectorySeparatorChar}")
.ToList();
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILogger _logger;
protected LocalFolderScanner(
ILocalFileSystem localFileSystem,
ILocalStatisticsProvider localStatisticsProvider,
ILogger logger)
{
_localFileSystem = localFileSystem;
_localStatisticsProvider = localStatisticsProvider;
_logger = logger;
}
protected async Task<Either<BaseError, T>> UpdateStatistics<T>(T mediaItem, string ffprobePath)
where T : MediaItem
{
try
{
if (mediaItem.Statistics is null ||
mediaItem.LastWriteTime < _localFileSystem.GetLastWriteTime(mediaItem.Path))
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", mediaItem.Path);
await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem);
}
return mediaItem;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
}
}

289
ErsatzTV.Core/Metadata/LocalMediaScanner.cs

@ -1,289 +0,0 @@ @@ -1,289 +0,0 @@
using System;
using System.Collections.Generic;
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 ErsatzTV.Core.Interfaces.Scheduling;
using LanguageExt;
using Microsoft.Extensions.Logging;
using static LanguageExt.Prelude;
using Seq = LanguageExt.Seq;
namespace ErsatzTV.Core.Metadata
{
public class LocalMediaScanner : ILocalMediaScanner
{
private readonly IImageCache _imageCache;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMediaSourcePlanner _localMediaSourcePlanner;
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILocalStatisticsProvider _localStatisticsProvider;
private readonly ILogger<LocalMediaScanner> _logger;
private readonly IMediaItemRepository _mediaItemRepository;
private readonly IPlayoutBuilder _playoutBuilder;
private readonly IPlayoutRepository _playoutRepository;
private readonly ISmartCollectionBuilder _smartCollectionBuilder;
public LocalMediaScanner(
IMediaItemRepository mediaItemRepository,
IPlayoutRepository playoutRepository,
ILocalStatisticsProvider localStatisticsProvider,
ILocalMetadataProvider localMetadataProvider,
ISmartCollectionBuilder smartCollectionBuilder,
IPlayoutBuilder playoutBuilder,
ILocalMediaSourcePlanner localMediaSourcePlanner,
ILocalFileSystem localFileSystem,
IImageCache imageCache,
ILogger<LocalMediaScanner> logger)
{
_mediaItemRepository = mediaItemRepository;
_playoutRepository = playoutRepository;
_localStatisticsProvider = localStatisticsProvider;
_localMetadataProvider = localMetadataProvider;
_smartCollectionBuilder = smartCollectionBuilder;
_playoutBuilder = playoutBuilder;
_localMediaSourcePlanner = localMediaSourcePlanner;
_localFileSystem = localFileSystem;
_imageCache = imageCache;
_logger = logger;
}
public async Task<Unit> ScanLocalMediaSource(
LocalMediaSource localMediaSource,
string ffprobePath,
ScanningMode scanningMode)
{
if (!_localFileSystem.IsMediaSourceAccessible(localMediaSource))
{
_logger.LogWarning(
"Media source folder {Folder} does not exist or is inaccessible; skipping scan",
localMediaSource.Folder);
return unit;
}
List<MediaItem> knownMediaItems = await _mediaItemRepository.GetAllByMediaSourceId(localMediaSource.Id);
var modifiedPlayoutIds = new List<int>();
Seq<LocalMediaSourcePlan> actions = _localMediaSourcePlanner.DetermineActions(
localMediaSource.MediaType,
knownMediaItems.ToSeq(),
FindAllFiles(localMediaSource));
foreach (LocalMediaSourcePlan action in actions)
{
Option<ActionPlan> maybeAddPlan =
action.ActionPlans.SingleOrDefault(plan => plan.TargetAction == ScanningAction.Add);
await maybeAddPlan.IfSomeAsync(
async plan =>
{
Option<MediaItem> maybeMediaItem = await AddMediaItem(localMediaSource, plan.TargetPath);
// any actions other than "add" need to operate on a media item
maybeMediaItem.IfSome(mediaItem => action.Source = mediaItem);
});
foreach (ActionPlan plan in action.ActionPlans.OrderBy(plan => (int) plan.TargetAction))
{
string sourcePath = action.Source.Match(
mediaItem => mediaItem.Path,
path => path);
_logger.LogDebug(
"{Source}: {Action} with {File}",
Path.GetFileName(sourcePath),
plan.TargetAction,
Path.GetRelativePath(Path.GetDirectoryName(sourcePath) ?? string.Empty, plan.TargetPath));
await action.Source.Match(
async mediaItem =>
{
var changed = false;
switch (plan.TargetAction)
{
case ScanningAction.Remove:
await RemoveMissingItem(mediaItem);
break;
case ScanningAction.Poster:
await SavePosterForItem(mediaItem, plan.TargetPath);
break;
case ScanningAction.FallbackMetadata:
await RefreshFallbackMetadataForItem(mediaItem);
break;
case ScanningAction.SidecarMetadata:
await RefreshSidecarMetadataForItem(mediaItem, plan.TargetPath);
break;
case ScanningAction.Statistics:
changed = await RefreshStatisticsForItem(mediaItem, ffprobePath);
break;
case ScanningAction.Collections:
changed = await RefreshCollectionsForItem(mediaItem);
break;
}
if (changed)
{
List<int> ids =
await _playoutRepository.GetPlayoutIdsForMediaItems(Seq.create(mediaItem));
modifiedPlayoutIds.AddRange(ids);
}
},
path =>
{
_logger.LogError("This is a bug, something went wrong processing {Path}", path);
return Task.CompletedTask;
});
}
}
foreach (int playoutId in modifiedPlayoutIds.Distinct())
{
Option<Playout> maybePlayout = await _playoutRepository.GetFull(playoutId);
await maybePlayout.Match(
async playout =>
{
Playout result = await _playoutBuilder.BuildPlayoutItems(playout, true);
await _playoutRepository.Update(result);
},
Task.CompletedTask);
}
return unit;
}
private Seq<string> FindAllFiles(LocalMediaSource localMediaSource)
{
Seq<string> allDirectories = Directory
.GetDirectories(localMediaSource.Folder, "*", SearchOption.AllDirectories)
.ToSeq()
.Add(localMediaSource.Folder);
// remove any directories with an .etvignore file locally, or in any parent directory
Seq<string> excluded = allDirectories.Filter(path => File.Exists(Path.Combine(path, ".etvignore")));
Seq<string> relevantDirectories = allDirectories
.Filter(d => !excluded.Any(d.StartsWith));
// .Filter(d => localMediaSource.MediaType == MediaType.Other || !IsExtrasFolder(d));
return relevantDirectories
.Collect(d => Directory.GetFiles(d, "*", SearchOption.TopDirectoryOnly))
.OrderBy(identity)
.ToSeq();
}
private async Task<Option<MediaItem>> AddMediaItem(MediaSource mediaSource, string path)
{
try
{
var mediaItem = new MediaItem
{
MediaSourceId = mediaSource.Id,
Path = path,
LastWriteTime = File.GetLastWriteTimeUtc(path)
};
await _mediaItemRepository.Add(mediaItem);
return mediaItem;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to add media item for {Path}", path);
return None;
}
}
private async Task RemoveMissingItem(MediaItem mediaItem)
{
try
{
await _mediaItemRepository.Delete(mediaItem.Id);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to remove missing local media item {MediaItem}", mediaItem.Path);
}
}
private async Task SavePosterForItem(MediaItem mediaItem, string posterPath)
{
try
{
byte[] originalBytes = await File.ReadAllBytesAsync(posterPath);
Either<BaseError, string> maybeHash = await _imageCache.ResizeAndSaveImage(originalBytes, 220, null);
await maybeHash.Match(
hash =>
{
mediaItem.Poster = hash;
mediaItem.PosterLastWriteTime = File.GetLastWriteTimeUtc(posterPath);
return _mediaItemRepository.Update(mediaItem);
},
error =>
{
_logger.LogWarning(
"Unable to save poster to disk from {Path}: {Error}",
posterPath,
error.Value);
return Task.CompletedTask;
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh poster for media item {MediaItem}", mediaItem.Path);
}
}
private async Task<bool> RefreshStatisticsForItem(MediaItem mediaItem, string ffprobePath)
{
try
{
return await _localStatisticsProvider.RefreshStatistics(ffprobePath, mediaItem);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh statistics for media item {MediaItem}", mediaItem.Path);
return false;
}
}
private async Task<bool> RefreshCollectionsForItem(MediaItem mediaItem)
{
try
{
return await _smartCollectionBuilder.RefreshSmartCollections(mediaItem);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh collections for media item {MediaItem}", mediaItem.Path);
return false;
}
}
private async Task RefreshSidecarMetadataForItem(MediaItem mediaItem, string path)
{
try
{
await _localMetadataProvider.RefreshSidecarMetadata(mediaItem, path);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh nfo metadata for media item {MediaItem}", mediaItem.Path);
}
}
private async Task RefreshFallbackMetadataForItem(MediaItem mediaItem)
{
try
{
await _localMetadataProvider.RefreshFallbackMetadata(mediaItem);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to refresh fallback metadata for media item {MediaItem}", mediaItem.Path);
}
}
}
}

11
ErsatzTV.Core/Metadata/LocalMediaSourcePlan.cs

@ -1,11 +0,0 @@ @@ -1,11 +0,0 @@
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using LanguageExt;
namespace ErsatzTV.Core.Metadata
{
public record LocalMediaSourcePlan(Either<string, MediaItem> Source, List<ActionPlan> ActionPlans)
{
public Either<string, MediaItem> Source { get; set; } = Source;
}
}

179
ErsatzTV.Core/Metadata/LocalMediaSourcePlanner.cs

@ -1,179 +0,0 @@ @@ -1,179 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Metadata
{
// TODO: this needs a better name
public class LocalMediaSourcePlanner : ILocalMediaSourcePlanner
{
private static readonly Seq<string> ImageFileExtensions = Seq("jpg", "jpeg", "png", "gif", "tbn");
private readonly ILocalFileSystem _localFileSystem;
public LocalMediaSourcePlanner(ILocalFileSystem localFileSystem) => _localFileSystem = localFileSystem;
public Seq<LocalMediaSourcePlan> DetermineActions(
MediaType mediaType,
Seq<MediaItem> mediaItems,
Seq<string> files)
{
var results = new IntermediateResults();
Seq<string> videoFiles = files.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f)))
.Filter(f => !IsExtra(f));
(Seq<string> newFiles, Seq<MediaItem> existingMediaItems) = videoFiles.Map(
s => mediaItems.Find(i => i.Path == s).ToEither(s))
.Partition();
// new files
foreach (string file in newFiles)
{
results.Add(file, new ActionPlan(file, ScanningAction.Add));
results.Add(file, new ActionPlan(file, ScanningAction.Statistics));
Option<string> maybeNfoFile = LocateNfoFile(mediaType, files, file);
maybeNfoFile.BiIter(
nfoFile =>
{
results.Add(file, new ActionPlan(nfoFile, ScanningAction.SidecarMetadata));
results.Add(file, new ActionPlan(nfoFile, ScanningAction.Collections));
},
() =>
{
results.Add(file, new ActionPlan(file, ScanningAction.FallbackMetadata));
results.Add(file, new ActionPlan(file, ScanningAction.Collections));
});
Option<string> maybePoster = LocatePoster(mediaType, files, file);
maybePoster.IfSome(
posterFile => results.Add(file, new ActionPlan(posterFile, ScanningAction.Poster)));
}
// existing media items
foreach (MediaItem mediaItem in existingMediaItems)
{
if ((mediaItem.LastWriteTime ?? DateTime.MinValue) < _localFileSystem.GetLastWriteTime(mediaItem.Path))
{
results.Add(mediaItem, new ActionPlan(mediaItem.Path, ScanningAction.Statistics));
}
Option<string> maybeNfoFile = LocateNfoFile(mediaType, files, mediaItem.Path);
maybeNfoFile.IfSome(
nfoFile =>
{
if (mediaItem.Metadata == null || mediaItem.Metadata.Source == MetadataSource.Fallback ||
(mediaItem.Metadata.LastWriteTime ?? DateTime.MinValue) <
_localFileSystem.GetLastWriteTime(nfoFile))
{
results.Add(mediaItem, new ActionPlan(nfoFile, ScanningAction.SidecarMetadata));
results.Add(mediaItem, new ActionPlan(nfoFile, ScanningAction.Collections));
}
});
Option<string> maybePoster = LocatePoster(mediaType, files, mediaItem.Path);
maybePoster.IfSome(
posterFile =>
{
if (string.IsNullOrWhiteSpace(mediaItem.Poster) ||
(mediaItem.PosterLastWriteTime ?? DateTime.MinValue) <
_localFileSystem.GetLastWriteTime(posterFile))
{
results.Add(mediaItem, new ActionPlan(posterFile, ScanningAction.Poster));
}
});
}
// missing media items
foreach (MediaItem mediaItem in mediaItems.Where(i => !files.Contains(i.Path)))
{
results.Add(mediaItem, new ActionPlan(mediaItem.Path, ScanningAction.Remove));
}
return results.Summarize();
}
private static bool IsExtra(string path)
{
string folder = Path.GetFileName(Path.GetDirectoryName(path) ?? string.Empty);
string file = Path.GetFileNameWithoutExtension(path);
return ExtraDirectories.Contains(folder, StringComparer.OrdinalIgnoreCase)
|| ExtraFiles.Any(f => file.EndsWith(f, StringComparison.OrdinalIgnoreCase));
}
private static Option<string> LocateNfoFile(MediaType mediaType, Seq<string> files, string file)
{
switch (mediaType)
{
case MediaType.Movie:
string movieAsNfo = Path.ChangeExtension(file, "nfo");
string movieNfo = Path.Combine(Path.GetDirectoryName(file) ?? string.Empty, "movie.nfo");
return Seq(movieAsNfo, movieNfo)
.Filter(s => files.Contains(s))
.HeadOrNone();
case MediaType.TvShow:
string episodeAsNfo = Path.ChangeExtension(file, "nfo");
return Optional(episodeAsNfo)
.Filter(s => files.Contains(s))
.HeadOrNone();
}
return None;
}
private static Option<string> LocatePoster(MediaType mediaType, Seq<string> files, string file)
{
string folder = Path.GetDirectoryName(file) ?? string.Empty;
switch (mediaType)
{
case MediaType.Movie:
IEnumerable<string> possibleMoviePosters = ImageFileExtensions.Collect(
ext => new[] { $"poster.{ext}", Path.GetFileNameWithoutExtension(file) + $"-poster.{ext}" })
.Map(f => Path.Combine(folder, f));
return possibleMoviePosters.Filter(p => files.Contains(p)).HeadOrNone();
case MediaType.TvShow:
string parentFolder = Directory.GetParent(folder)?.FullName ?? string.Empty;
IEnumerable<string> possibleTvPosters = ImageFileExtensions
.Collect(ext => new[] { $"poster.{ext}" })
.Map(f => Path.Combine(parentFolder, f));
return possibleTvPosters.Filter(p => files.Contains(p)).HeadOrNone();
}
return None;
}
private class IntermediateResults
{
private readonly List<Tuple<Either<string, MediaItem>, ActionPlan>> _rawResults = new();
public void Add(Either<string, MediaItem> source, ActionPlan plan) =>
_rawResults.Add(Tuple(source, plan));
public Seq<LocalMediaSourcePlan> Summarize() =>
_rawResults
.GroupBy(t => t.Item1)
.Select(g => new LocalMediaSourcePlan(g.Key, g.Select(g2 => g2.Item2).ToList()))
.ToSeq();
}
// @formatter:off
private static readonly Seq<string> VideoFileExtensions = Seq(
".mpg", ".mp2", ".mpeg", ".mpe", ".mpv", ".ogg", ".mp4",
".m4p", ".m4v", ".avi", ".wmv", ".mov", ".mkv", ".ts");
private static readonly Seq<string> ExtraDirectories = Seq(
"behind the scenes", "deleted scenes", "featurettes",
"interviews", "scenes", "shorts", "trailers", "other",
"extras", "specials");
private static readonly Seq<string> ExtraFiles = Seq(
"behindthescenes", "deleted", "featurette",
"interview", "scene", "short", "trailer", "other");
// @formatter:on
}
}

233
ErsatzTV.Core/Metadata/LocalMetadataProvider.cs

@ -14,52 +14,116 @@ namespace ErsatzTV.Core.Metadata @@ -14,52 +14,116 @@ namespace ErsatzTV.Core.Metadata
public class LocalMetadataProvider : ILocalMetadataProvider
{
private static readonly XmlSerializer MovieSerializer = new(typeof(MovieNfo));
private static readonly XmlSerializer TvShowSerializer = new(typeof(TvShowEpisodeNfo));
private static readonly XmlSerializer EpisodeSerializer = new(typeof(TvShowEpisodeNfo));
private static readonly XmlSerializer TvShowSerializer = new(typeof(TvShowNfo));
private readonly ILogger<LocalMetadataProvider> _logger;
private readonly IMediaItemRepository _mediaItemRepository;
private readonly ITelevisionRepository _televisionRepository;
public LocalMetadataProvider(IMediaItemRepository mediaItemRepository, ILogger<LocalMetadataProvider> logger)
public LocalMetadataProvider(
IMediaItemRepository mediaItemRepository,
ITelevisionRepository televisionRepository,
ILogger<LocalMetadataProvider> logger)
{
_mediaItemRepository = mediaItemRepository;
_televisionRepository = televisionRepository;
_logger = logger;
}
public async Task RefreshSidecarMetadata(MediaItem mediaItem, string path)
{
Option<MediaMetadata> maybeMetadata = await LoadMetadata(mediaItem, path);
await maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(mediaItem, metadata));
}
public Task<Unit> RefreshSidecarMetadata(MediaItem mediaItem, string path) =>
mediaItem switch
{
TelevisionEpisodeMediaItem e => LoadMetadata(e, path)
.Bind(maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(e, metadata))),
MovieMediaItem m => LoadMetadata(m, path)
.Bind(maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(m, metadata))),
_ => Task.FromResult(Unit.Default)
};
public Task<Unit> RefreshSidecarMetadata(TelevisionShow televisionShow, string path) =>
LoadMetadata(televisionShow, path).Bind(
maybeMetadata => maybeMetadata.IfSomeAsync(metadata => ApplyMetadataUpdate(televisionShow, metadata)));
public Task<Unit> RefreshFallbackMetadata(MediaItem mediaItem) =>
mediaItem switch
{
TelevisionEpisodeMediaItem e => ApplyMetadataUpdate(e, FallbackMetadataProvider.GetFallbackMetadata(e))
.ToUnit(),
MovieMediaItem m => ApplyMetadataUpdate(m, FallbackMetadataProvider.GetFallbackMetadata(m)).ToUnit(),
_ => Task.FromResult(Unit.Default)
};
public Task RefreshFallbackMetadata(MediaItem mediaItem) =>
ApplyMetadataUpdate(mediaItem, FallbackMetadataProvider.GetFallbackMetadata(mediaItem));
public Task<Unit> RefreshFallbackMetadata(TelevisionShow televisionShow) =>
ApplyMetadataUpdate(televisionShow, FallbackMetadataProvider.GetFallbackMetadata(televisionShow)).ToUnit();
private async Task ApplyMetadataUpdate(MediaItem mediaItem, MediaMetadata metadata)
private async Task ApplyMetadataUpdate(TelevisionEpisodeMediaItem mediaItem, TelevisionEpisodeMetadata metadata)
{
if (mediaItem.Metadata == null)
{
mediaItem.Metadata = new MediaMetadata();
mediaItem.Metadata = new TelevisionEpisodeMetadata { TelevisionEpisodeId = mediaItem.Id };
}
mediaItem.Metadata.Source = metadata.Source;
mediaItem.Metadata.LastWriteTime = metadata.LastWriteTime;
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;
mediaItem.Metadata.Season = metadata.Season;
mediaItem.Metadata.Episode = metadata.Episode;
mediaItem.Metadata.Plot = metadata.Plot;
mediaItem.Metadata.Aired = metadata.Aired;
await _televisionRepository.Update(mediaItem);
}
private async Task ApplyMetadataUpdate(MovieMediaItem mediaItem, MovieMetadata metadata)
{
if (mediaItem.Metadata == null)
{
mediaItem.Metadata = new MovieMetadata();
}
mediaItem.Metadata.Source = metadata.Source;
mediaItem.Metadata.LastWriteTime = metadata.LastWriteTime;
mediaItem.Metadata.Title = metadata.Title;
mediaItem.Metadata.SortTitle =
(metadata.Title ?? string.Empty).ToLowerInvariant().StartsWith("the ")
? metadata.Title?.Substring(4)
: metadata.Title;
mediaItem.Metadata.Year = metadata.Year;
mediaItem.Metadata.Premiered = metadata.Premiered;
mediaItem.Metadata.Plot = metadata.Plot;
mediaItem.Metadata.Outline = metadata.Outline;
mediaItem.Metadata.Tagline = metadata.Tagline;
mediaItem.Metadata.ContentRating = metadata.ContentRating;
await _mediaItemRepository.Update(mediaItem);
}
private async Task<Option<MediaMetadata>> LoadMetadata(MediaItem mediaItem, string nfoFileName)
private async Task ApplyMetadataUpdate(TelevisionShow televisionShow, TelevisionShowMetadata metadata)
{
if (televisionShow.Metadata == null)
{
televisionShow.Metadata = new TelevisionShowMetadata();
}
televisionShow.Metadata.Source = metadata.Source;
televisionShow.Metadata.LastWriteTime = metadata.LastWriteTime;
televisionShow.Metadata.Title = metadata.Title;
televisionShow.Metadata.Plot = metadata.Plot;
televisionShow.Metadata.Year = metadata.Year;
televisionShow.Metadata.SortTitle =
(metadata.Title ?? string.Empty).ToLowerInvariant().StartsWith("the ")
? metadata.Title?.Substring(4)
: metadata.Title;
await _televisionRepository.Update(televisionShow);
}
private async Task<Option<MovieMetadata>> LoadMetadata(MovieMediaItem mediaItem, string nfoFileName)
{
if (nfoFileName == null || !File.Exists(nfoFileName))
{
@ -67,64 +131,121 @@ namespace ErsatzTV.Core.Metadata @@ -67,64 +131,121 @@ namespace ErsatzTV.Core.Metadata
return None;
}
if (!(mediaItem.Source is LocalMediaSource localMediaSource))
if (!(mediaItem.Source is LocalMediaSource))
{
_logger.LogDebug("Media source {Name} is not a local media source", mediaItem.Source.Name);
return None;
}
return localMediaSource.MediaType switch
return await LoadMovieMetadata(nfoFileName);
}
private async Task<Option<TelevisionEpisodeMetadata>> LoadMetadata(
TelevisionEpisodeMediaItem mediaItem,
string nfoFileName)
{
if (nfoFileName == null || !File.Exists(nfoFileName))
{
MediaType.Movie => await LoadMovieMetadata(nfoFileName),
MediaType.TvShow => await LoadTvShowMetadata(nfoFileName),
_ => None
};
_logger.LogDebug("NFO file does not exist at {Path}", nfoFileName);
return None;
}
if (!(mediaItem.Source is LocalMediaSource))
{
_logger.LogDebug("Media source {Name} is not a local media source", mediaItem.Source.Name);
return None;
}
return await LoadEpisodeMetadata(nfoFileName);
}
private async Task<Option<MediaMetadata>> LoadTvShowMetadata(string nfoFileName)
private async Task<Option<TelevisionShowMetadata>> LoadMetadata(
TelevisionShow televisionShow,
string nfoFileName)
{
if (nfoFileName == null || !File.Exists(nfoFileName))
{
_logger.LogDebug("NFO file does not exist at {Path}", nfoFileName);
return None;
}
if (!(televisionShow.Source is LocalMediaSource))
{
_logger.LogDebug("Media source {Name} is not a local media source", televisionShow.Source.Name);
return None;
}
return await LoadTelevisionShowMetadata(nfoFileName);
}
private async Task<Option<TelevisionShowMetadata>> LoadTelevisionShowMetadata(string nfoFileName)
{
try
{
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
Option<TvShowEpisodeNfo> maybeNfo = TvShowSerializer.Deserialize(fileStream) as TvShowEpisodeNfo;
return maybeNfo.Match<Option<MediaMetadata>>(
nfo => new MediaMetadata
Option<TvShowNfo> maybeNfo = TvShowSerializer.Deserialize(fileStream) as TvShowNfo;
return maybeNfo.Match<Option<TelevisionShowMetadata>>(
nfo => new TelevisionShowMetadata
{
Source = MetadataSource.Sidecar,
LastWriteTime = File.GetLastWriteTimeUtc(nfoFileName),
Title = nfo.Title,
Plot = nfo.Plot,
Year = nfo.Year
},
None);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to read TV show nfo metadata from {Path}", nfoFileName);
return None;
}
}
private async Task<Option<TelevisionEpisodeMetadata>> LoadEpisodeMetadata(string nfoFileName)
{
try
{
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
Option<TvShowEpisodeNfo> maybeNfo = EpisodeSerializer.Deserialize(fileStream) as TvShowEpisodeNfo;
return maybeNfo.Match<Option<TelevisionEpisodeMetadata>>(
nfo => new TelevisionEpisodeMetadata
{
Source = MetadataSource.Sidecar,
LastWriteTime = File.GetLastWriteTimeUtc(nfoFileName),
MediaType = MediaType.TvShow,
Title = nfo.ShowTitle,
Subtitle = nfo.Title,
Description = nfo.Outline,
EpisodeNumber = nfo.Episode,
SeasonNumber = nfo.Season,
Aired = GetAired(nfo.Aired)
Aired = GetAired(nfo.Aired),
Episode = nfo.Episode,
Season = nfo.Season,
Plot = nfo.Plot
},
None);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to read TV nfo metadata from {Path}", nfoFileName);
_logger.LogDebug(ex, "Failed to read TV episode nfo metadata from {Path}", nfoFileName);
return None;
}
}
private async Task<Option<MediaMetadata>> LoadMovieMetadata(string nfoFileName)
private async Task<Option<MovieMetadata>> LoadMovieMetadata(string nfoFileName)
{
try
{
await using FileStream fileStream = File.Open(nfoFileName, FileMode.Open, FileAccess.Read);
Option<MovieNfo> maybeNfo = MovieSerializer.Deserialize(fileStream) as MovieNfo;
return maybeNfo.Match<Option<MediaMetadata>>(
nfo => new MediaMetadata
return maybeNfo.Match<Option<MovieMetadata>>(
nfo => new MovieMetadata
{
Source = MetadataSource.Sidecar,
LastWriteTime = File.GetLastWriteTimeUtc(nfoFileName),
MediaType = MediaType.Movie,
Title = nfo.Title,
Description = nfo.Outline,
ContentRating = nfo.ContentRating,
Aired = GetAired(nfo.Premiered)
Year = nfo.Year,
Premiered = nfo.Premiered,
Plot = nfo.Plot,
Outline = nfo.Outline,
Tagline = nfo.Tagline,
ContentRating = nfo.ContentRating
},
None);
}
@ -159,11 +280,33 @@ namespace ErsatzTV.Core.Metadata @@ -159,11 +280,33 @@ namespace ErsatzTV.Core.Metadata
[XmlElement("outline")]
public string Outline { get; set; }
[XmlElement("year")]
public int Year { get; set; }
[XmlElement("mpaa")]
public string ContentRating { get; set; }
[XmlElement("premiered")]
public string Premiered { get; set; }
public DateTime Premiered { get; set; }
[XmlElement("plot")]
public string Plot { get; set; }
[XmlElement("tagline")]
public string Tagline { get; set; }
}
[XmlRoot("tvshow")]
public class TvShowNfo
{
[XmlElement("title")]
public string Title { get; set; }
[XmlElement("year")]
public int Year { get; set; }
[XmlElement("plot")]
public string Plot { get; set; }
}
[XmlRoot("episodedetails")]
@ -175,9 +318,6 @@ namespace ErsatzTV.Core.Metadata @@ -175,9 +318,6 @@ namespace ErsatzTV.Core.Metadata
[XmlElement("title")]
public string Title { get; set; }
[XmlElement("outline")]
public string Outline { get; set; }
[XmlElement("episode")]
public int Episode { get; set; }
@ -189,6 +329,9 @@ namespace ErsatzTV.Core.Metadata @@ -189,6 +329,9 @@ namespace ErsatzTV.Core.Metadata
[XmlElement("aired")]
public string Aired { get; set; }
[XmlElement("plot")]
public string Plot { get; set; }
}
}
}

40
ErsatzTV.Core/Metadata/LocalPosterProvider.cs

@ -32,10 +32,10 @@ namespace ErsatzTV.Core.Metadata @@ -32,10 +32,10 @@ namespace ErsatzTV.Core.Metadata
public async Task RefreshPoster(MediaItem mediaItem)
{
Option<string> maybePosterPath = mediaItem.Metadata.MediaType switch
Option<string> maybePosterPath = mediaItem switch
{
MediaType.Movie => RefreshMoviePoster(mediaItem),
MediaType.TvShow => RefreshTelevisionPoster(mediaItem),
MovieMediaItem m => RefreshMoviePoster(m),
TelevisionEpisodeMediaItem e => RefreshTelevisionPoster(e),
_ => None
};
@ -44,6 +44,23 @@ namespace ErsatzTV.Core.Metadata @@ -44,6 +44,23 @@ namespace ErsatzTV.Core.Metadata
Task.CompletedTask);
}
public 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;
});
}
private static Option<string> RefreshMoviePoster(MediaItem mediaItem)
{
string folder = Path.GetDirectoryName(mediaItem.Path);
@ -72,22 +89,5 @@ namespace ErsatzTV.Core.Metadata @@ -72,22 +89,5 @@ namespace ErsatzTV.Core.Metadata
return None;
}
public 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;
});
}
}
}

40
ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs

@ -31,8 +31,8 @@ namespace ErsatzTV.Core.Metadata @@ -31,8 +31,8 @@ namespace ErsatzTV.Core.Metadata
try
{
FFprobe ffprobe = await GetProbeOutput(ffprobePath, mediaItem);
MediaMetadata metadata = ProjectToMediaMetadata(ffprobe);
return await ApplyStatisticsUpdate(mediaItem, metadata);
MediaItemStatistics statistics = ProjectToMediaItemStatistics(ffprobe);
return await ApplyStatisticsUpdate(mediaItem, statistics);
}
catch (Exception ex)
{
@ -43,23 +43,23 @@ namespace ErsatzTV.Core.Metadata @@ -43,23 +43,23 @@ namespace ErsatzTV.Core.Metadata
private async Task<bool> ApplyStatisticsUpdate(
MediaItem mediaItem,
MediaMetadata metadata)
MediaItemStatistics statistics)
{
if (mediaItem.Metadata == null)
if (mediaItem.Statistics == null)
{
mediaItem.Metadata = new MediaMetadata();
mediaItem.Statistics = new MediaItemStatistics();
}
bool durationChange = mediaItem.Metadata.Duration != metadata.Duration;
bool durationChange = mediaItem.Statistics.Duration != statistics.Duration;
mediaItem.Metadata.Duration = metadata.Duration;
mediaItem.Metadata.AudioCodec = metadata.AudioCodec;
mediaItem.Metadata.SampleAspectRatio = metadata.SampleAspectRatio;
mediaItem.Metadata.DisplayAspectRatio = metadata.DisplayAspectRatio;
mediaItem.Metadata.Width = metadata.Width;
mediaItem.Metadata.Height = metadata.Height;
mediaItem.Metadata.VideoCodec = metadata.VideoCodec;
mediaItem.Metadata.VideoScanType = metadata.VideoScanType;
mediaItem.Statistics.Duration = statistics.Duration;
mediaItem.Statistics.AudioCodec = statistics.AudioCodec;
mediaItem.Statistics.SampleAspectRatio = statistics.SampleAspectRatio;
mediaItem.Statistics.DisplayAspectRatio = statistics.DisplayAspectRatio;
mediaItem.Statistics.Width = statistics.Width;
mediaItem.Statistics.Height = statistics.Height;
mediaItem.Statistics.VideoCodec = statistics.VideoCodec;
mediaItem.Statistics.VideoScanType = statistics.VideoScanType;
return await _mediaItemRepository.Update(mediaItem) && durationChange;
}
@ -97,7 +97,7 @@ namespace ErsatzTV.Core.Metadata @@ -97,7 +97,7 @@ namespace ErsatzTV.Core.Metadata
});
}
private MediaMetadata ProjectToMediaMetadata(FFprobe probeOutput) =>
private MediaItemStatistics ProjectToMediaItemStatistics(FFprobe probeOutput) =>
Optional(probeOutput)
.Filter(json => json?.format != null && json.streams != null)
.ToValidation<BaseError>("Unable to parse ffprobe output")
@ -107,12 +107,12 @@ namespace ErsatzTV.Core.Metadata @@ -107,12 +107,12 @@ namespace ErsatzTV.Core.Metadata
{
var duration = TimeSpan.FromSeconds(double.Parse(json.format.duration));
var metadata = new MediaMetadata { Duration = duration };
var statistics = new MediaItemStatistics { Duration = duration };
FFprobeStream audioStream = json.streams.FirstOrDefault(s => s.codec_type == "audio");
if (audioStream != null)
{
metadata = metadata with
statistics = statistics with
{
AudioCodec = audioStream.codec_name
};
@ -121,7 +121,7 @@ namespace ErsatzTV.Core.Metadata @@ -121,7 +121,7 @@ namespace ErsatzTV.Core.Metadata
FFprobeStream videoStream = json.streams.FirstOrDefault(s => s.codec_type == "video");
if (videoStream != null)
{
metadata = metadata with
statistics = statistics with
{
SampleAspectRatio = videoStream.sample_aspect_ratio,
DisplayAspectRatio = videoStream.display_aspect_ratio,
@ -132,9 +132,9 @@ namespace ErsatzTV.Core.Metadata @@ -132,9 +132,9 @@ namespace ErsatzTV.Core.Metadata
};
}
return metadata;
return statistics;
},
_ => new MediaMetadata());
_ => new MediaItemStatistics());
private VideoScanType ScanTypeFromFieldOrder(string fieldOrder) =>
fieldOrder?.ToLowerInvariant() switch

139
ErsatzTV.Core/Metadata/MovieFolderScanner.cs

@ -0,0 +1,139 @@ @@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Metadata
{
public class MovieFolderScanner : LocalFolderScanner, IMovieFolderScanner
{
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILocalPosterProvider _localPosterProvider;
private readonly ILogger<MovieFolderScanner> _logger;
private readonly IMovieRepository _movieRepository;
public MovieFolderScanner(
ILocalFileSystem localFileSystem,
IMovieRepository movieRepository,
ILocalStatisticsProvider localStatisticsProvider,
ILocalMetadataProvider localMetadataProvider,
ILocalPosterProvider localPosterProvider,
ILogger<MovieFolderScanner> logger)
: base(localFileSystem, localStatisticsProvider, logger)
{
_localFileSystem = localFileSystem;
_movieRepository = movieRepository;
_localMetadataProvider = localMetadataProvider;
_localPosterProvider = localPosterProvider;
_logger = logger;
}
public async Task<Unit> ScanFolder(LocalMediaSource localMediaSource, string ffprobePath)
{
if (!_localFileSystem.IsMediaSourceAccessible(localMediaSource))
{
_logger.LogWarning(
"Media source is not accessible or missing; skipping scan of {Folder}",
localMediaSource.Folder);
return Unit.Default;
}
foreach (string movieFolder in _localFileSystem.ListSubdirectories(localMediaSource.Folder))
{
foreach (string file in _localFileSystem.ListFiles(movieFolder)
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f))))
{
// TODO: figure out how to rebuild playlists
Either<BaseError, MovieMediaItem> maybeMovie = await _movieRepository
.GetOrAdd(localMediaSource.Id, file)
.BindT(movie => UpdateStatistics(movie, ffprobePath).MapT(_ => movie))
.BindT(UpdateMetadata)
.BindT(UpdatePoster);
maybeMovie.IfLeft(
error => _logger.LogWarning("Error processing movie at {Path}: {Error}", file, error.Value));
}
}
return Unit.Default;
}
private async Task<Either<BaseError, MovieMediaItem>> UpdateMetadata(MovieMediaItem movie)
{
try
{
await LocateNfoFile(movie).Match(
async nfoFile =>
{
if (movie.Metadata == null || movie.Metadata.Source == MetadataSource.Fallback ||
movie.Metadata.LastWriteTime < _localFileSystem.GetLastWriteTime(nfoFile))
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
await _localMetadataProvider.RefreshSidecarMetadata(movie, nfoFile);
}
},
async () =>
{
if (movie.Metadata == null)
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", movie.Path);
await _localMetadataProvider.RefreshFallbackMetadata(movie);
}
});
return movie;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
private async Task<Either<BaseError, MovieMediaItem>> UpdatePoster(MovieMediaItem movie)
{
try
{
await LocatePoster(movie).IfSomeAsync(
async posterFile =>
{
if (string.IsNullOrWhiteSpace(movie.Poster) ||
movie.PosterLastWriteTime < _localFileSystem.GetLastWriteTime(posterFile))
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Poster", posterFile);
await _localPosterProvider.SavePosterToDisk(movie, posterFile);
}
});
return movie;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
private Option<string> LocateNfoFile(MovieMediaItem movie)
{
string movieAsNfo = Path.ChangeExtension(movie.Path, "nfo");
string movieNfo = Path.Combine(Path.GetDirectoryName(movie.Path) ?? string.Empty, "movie.nfo");
return Seq.create(movieAsNfo, movieNfo)
.Filter(s => _localFileSystem.FileExists(s))
.HeadOrNone();
}
private Option<string> LocatePoster(MovieMediaItem movie)
{
string folder = Path.GetDirectoryName(movie.Path) ?? string.Empty;
IEnumerable<string> possibleMoviePosters = ImageFileExtensions.Collect(
ext => new[] { $"poster.{ext}", Path.GetFileNameWithoutExtension(movie.Path) + $"-poster.{ext}" })
.Map(f => Path.Combine(folder, f));
return possibleMoviePosters.Filter(p => _localFileSystem.FileExists(p)).HeadOrNone();
}
}
}

14
ErsatzTV.Core/Metadata/ScanningAction.cs

@ -1,14 +0,0 @@ @@ -1,14 +0,0 @@
namespace ErsatzTV.Core.Metadata
{
public enum ScanningAction
{
None = 0,
Add = 1,
Remove = 2,
Statistics = 3,
SidecarMetadata = 4,
FallbackMetadata = 5,
Collections = 6,
Poster = 7
}
}

8
ErsatzTV.Core/Metadata/ScanningMode.cs

@ -1,8 +0,0 @@ @@ -1,8 +0,0 @@
namespace ErsatzTV.Core.Metadata
{
public enum ScanningMode
{
Default = 0,
RescanAll = 1
}
}

12
ErsatzTV.Core/Metadata/SmartCollectionBuilder.cs

@ -16,7 +16,7 @@ namespace ErsatzTV.Core.Metadata @@ -16,7 +16,7 @@ namespace ErsatzTV.Core.Metadata
public SmartCollectionBuilder(IMediaCollectionRepository mediaCollectionRepository) =>
_mediaCollectionRepository = mediaCollectionRepository;
public async Task<bool> RefreshSmartCollections(MediaItem mediaItem)
public async Task<bool> RefreshSmartCollections(TelevisionEpisodeMediaItem mediaItem)
{
var results = new List<bool>();
@ -28,11 +28,9 @@ namespace ErsatzTV.Core.Metadata @@ -28,11 +28,9 @@ namespace ErsatzTV.Core.Metadata
return results.Any(identity);
}
private IEnumerable<TelevisionMediaCollection> GetTelevisionCollections(MediaItem mediaItem)
private IEnumerable<TelevisionMediaCollection> GetTelevisionCollections(TelevisionEpisodeMediaItem mediaItem)
{
IList<MediaItem> televisionMediaItems = new[] { mediaItem }
.Where(c => c.Metadata.MediaType == MediaType.TvShow)
.ToList();
var televisionMediaItems = new List<TelevisionEpisodeMediaItem> { mediaItem };
IEnumerable<TelevisionMediaCollection> televisionShowCollections = televisionMediaItems
.Map(c => c.Metadata.Title)
@ -45,11 +43,11 @@ namespace ErsatzTV.Core.Metadata @@ -45,11 +43,11 @@ namespace ErsatzTV.Core.Metadata
});
IEnumerable<TelevisionMediaCollection> televisionShowSeasonCollections = televisionMediaItems
.Map(c => new { c.Metadata.Title, c.Metadata.SeasonNumber }).Distinct()
.Map(c => new { c.Metadata.Title, c.Metadata.Season }).Distinct()
.Map(
ts =>
{
return Optional(ts.SeasonNumber).Map(
return Optional(ts.Season).Map(
sn => new TelevisionMediaCollection
{
Name = $"{ts.Title} - Season {sn:00}",

296
ErsatzTV.Core/Metadata/TelevisionFolderScanner.cs

@ -0,0 +1,296 @@ @@ -0,0 +1,296 @@
using System;
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 TelevisionFolderScanner : LocalFolderScanner, ITelevisionFolderScanner
{
private readonly IImageCache _imageCache;
private readonly ILocalFileSystem _localFileSystem;
private readonly ILocalMetadataProvider _localMetadataProvider;
private readonly ILogger<TelevisionFolderScanner> _logger;
private readonly ITelevisionRepository _televisionRepository;
public TelevisionFolderScanner(
ILocalFileSystem localFileSystem,
ITelevisionRepository televisionRepository,
ILocalStatisticsProvider localStatisticsProvider,
ILocalMetadataProvider localMetadataProvider,
IImageCache imageCache,
ILogger<TelevisionFolderScanner> logger) : base(localFileSystem, localStatisticsProvider, logger)
{
_localFileSystem = localFileSystem;
_televisionRepository = televisionRepository;
_localMetadataProvider = localMetadataProvider;
_imageCache = imageCache;
_logger = logger;
}
public async Task<Unit> ScanFolder(LocalMediaSource localMediaSource, string ffprobePath)
{
if (!_localFileSystem.IsMediaSourceAccessible(localMediaSource))
{
_logger.LogWarning(
"Media source is not accessible or missing; skipping scan of {Folder}",
localMediaSource.Folder);
return Unit.Default;
}
foreach (string showFolder in _localFileSystem.ListSubdirectories(localMediaSource.Folder)
.Filter(ShouldIncludeFolder))
{
Either<BaseError, TelevisionShow> maybeShow = await _televisionRepository
.GetOrAddShow(localMediaSource.Id, showFolder)
.BindT(UpdateMetadata)
.BindT(UpdatePoster);
await maybeShow.Match(
show => ScanSeasons(localMediaSource, ffprobePath, show),
_ => Task.FromResult(Unit.Default));
}
return Unit.Default;
}
private async Task<Unit> ScanSeasons(LocalMediaSource localMediaSource, string ffprobePath, TelevisionShow show)
{
foreach (string seasonFolder in _localFileSystem.ListSubdirectories(show.Path).Filter(ShouldIncludeFolder))
{
Option<int> maybeSeasonNumber = SeasonNumberForFolder(seasonFolder);
await maybeSeasonNumber.IfSomeAsync(
async seasonNumber =>
{
Either<BaseError, TelevisionSeason> maybeSeason = await _televisionRepository
.GetOrAddSeason(show, seasonFolder, seasonNumber);
await maybeSeason.Match(
season => ScanEpisodes(localMediaSource, ffprobePath, season),
_ => Task.FromResult(Unit.Default));
});
}
return Unit.Default;
}
private async Task<Unit> ScanEpisodes(
LocalMediaSource localMediaSource,
string ffprobePath,
TelevisionSeason season)
{
foreach (string file in _localFileSystem.ListFiles(season.Path)
.Filter(f => VideoFileExtensions.Contains(Path.GetExtension(f))))
{
// TODO: figure out how to rebuild playlists
Either<BaseError, TelevisionEpisodeMediaItem> maybeEpisode = await _televisionRepository
.GetOrAddEpisode(season, localMediaSource.Id, file)
.BindT(episode => UpdateStatistics(episode, ffprobePath).MapT(_ => episode))
.BindT(UpdateMetadata)
.BindT(UpdateThumbnail);
maybeEpisode.IfLeft(
error => _logger.LogWarning("Error processing episode at {Path}: {Error}", file, error.Value));
}
return Unit.Default;
}
private async Task<Either<BaseError, TelevisionShow>> UpdateMetadata(TelevisionShow show)
{
try
{
await LocateNfoFile(show).Match(
async nfoFile =>
{
if (show.Metadata == null || show.Metadata.Source == MetadataSource.Fallback ||
show.Metadata.LastWriteTime < _localFileSystem.GetLastWriteTime(nfoFile))
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
await _localMetadataProvider.RefreshSidecarMetadata(show, nfoFile);
}
},
async () =>
{
if (show.Metadata == null)
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", show.Path);
await _localMetadataProvider.RefreshFallbackMetadata(show);
}
});
return show;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
private async Task<Either<BaseError, TelevisionEpisodeMediaItem>> UpdateMetadata(
TelevisionEpisodeMediaItem episode)
{
try
{
await LocateNfoFile(episode).Match(
async nfoFile =>
{
if (episode.Metadata == null || episode.Metadata.Source == MetadataSource.Fallback ||
episode.Metadata.LastWriteTime < _localFileSystem.GetLastWriteTime(nfoFile))
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Sidecar Metadata", nfoFile);
await _localMetadataProvider.RefreshSidecarMetadata(episode, nfoFile);
}
},
async () =>
{
if (episode.Metadata == null)
{
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Fallback Metadata", episode.Path);
await _localMetadataProvider.RefreshFallbackMetadata(episode);
}
});
return episode;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
private async Task<Either<BaseError, TelevisionShow>> UpdatePoster(TelevisionShow show)
{
try
{
await LocatePoster(show).IfSomeAsync(
async posterFile =>
{
if (string.IsNullOrWhiteSpace(show.Poster) ||
show.PosterLastWriteTime < _localFileSystem.GetLastWriteTime(posterFile))
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Poster", posterFile);
await SavePosterToDisk(show, posterFile);
}
});
return show;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
private async Task<Either<BaseError, TelevisionEpisodeMediaItem>> UpdateThumbnail(
TelevisionEpisodeMediaItem episode)
{
try
{
await LocateThumbnail(episode).IfSomeAsync(
async posterFile =>
{
if (string.IsNullOrWhiteSpace(episode.Poster) ||
episode.PosterLastWriteTime < _localFileSystem.GetLastWriteTime(posterFile))
{
_logger.LogDebug("Refreshing {Attribute} from {Path}", "Thumbnail", posterFile);
await SavePosterToDisk(episode, posterFile);
}
});
return episode;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
private Option<string> LocateNfoFile(TelevisionShow show) =>
Optional(Path.Combine(show.Path, "tvshow.nfo"))
.Filter(s => _localFileSystem.FileExists(s));
private Option<string> LocateNfoFile(TelevisionEpisodeMediaItem episode) =>
Optional(Path.ChangeExtension(episode.Path, "nfo"))
.Filter(s => _localFileSystem.FileExists(s));
private Option<string> LocatePoster(TelevisionShow show) =>
ImageFileExtensions
.Collect(ext => new[] { $"poster.{ext}" })
.Map(f => Path.Combine(show.Path, f))
.Filter(s => _localFileSystem.FileExists(s))
.HeadOrNone();
private Option<string> LocateThumbnail(TelevisionEpisodeMediaItem episode)
{
string folder = Path.GetDirectoryName(episode.Path) ?? string.Empty;
return ImageFileExtensions
.Map(ext => Path.GetFileNameWithoutExtension(episode.Path) + $"-thumb.{ext}")
.Map(f => Path.Combine(folder, f))
.Filter(f => _localFileSystem.FileExists(f))
.HeadOrNone();
}
// TODO: refactor this to work with both scanners, everything that needs a poster
// also, return either<baseerror, unit> ???
private async Task SavePosterToDisk(TelevisionShow show, string posterPath)
{
byte[] originalBytes = await File.ReadAllBytesAsync(posterPath);
Either<BaseError, string> maybeHash = await _imageCache.ResizeAndSaveImage(originalBytes, 220, null);
await maybeHash.Match(
hash =>
{
show.Poster = hash;
return _televisionRepository.Update(show);
},
error =>
{
_logger.LogWarning("Unable to save poster to disk from {Path}: {Error}", posterPath, error.Value);
return Task.CompletedTask;
});
}
private async Task SavePosterToDisk(TelevisionEpisodeMediaItem episode, string posterPath)
{
byte[] originalBytes = await File.ReadAllBytesAsync(posterPath);
Either<BaseError, string> maybeHash = await _imageCache.ResizeAndSaveImage(originalBytes, 220, null);
await maybeHash.Match(
hash =>
{
episode.Poster = hash;
return _televisionRepository.Update(episode);
},
error =>
{
_logger.LogWarning("Unable to save poster to disk from {Path}: {Error}", posterPath, error.Value);
return Task.CompletedTask;
});
}
private bool ShouldIncludeFolder(string folder) =>
!Path.GetFileName(folder).StartsWith('.') &&
!_localFileSystem.FileExists(Path.Combine(folder, ".etvignore"));
private static Option<int> SeasonNumberForFolder(string folder)
{
if (int.TryParse(folder.Split(" ").Last(), out int seasonNumber))
{
return seasonNumber;
}
if (folder.EndsWith("specials", StringComparison.OrdinalIgnoreCase))
{
return 0;
}
return None;
}
}
}

71
ErsatzTV.Core/Scheduling/ChronologicalMediaCollectionEnumerator.cs

@ -16,10 +16,7 @@ namespace ErsatzTV.Core.Scheduling @@ -16,10 +16,7 @@ namespace ErsatzTV.Core.Scheduling
IEnumerable<MediaItem> mediaItems,
MediaCollectionEnumeratorState state)
{
_sortedMediaItems = mediaItems.OrderBy(c => c.Metadata.Aired ?? DateTime.MaxValue)
.ThenBy(c => c.Metadata.SeasonNumber)
.ThenBy(c => c.Metadata.EpisodeNumber)
.ToList();
_sortedMediaItems = mediaItems.OrderBy(identity, new ChronologicalComparer()).ToList();
State = new MediaCollectionEnumeratorState { Seed = state.Seed };
while (State.Index < state.Index)
@ -33,5 +30,71 @@ namespace ErsatzTV.Core.Scheduling @@ -33,5 +30,71 @@ namespace ErsatzTV.Core.Scheduling
public Option<MediaItem> Current => _sortedMediaItems.Any() ? _sortedMediaItems[State.Index] : None;
public void MoveNext() => State.Index = (State.Index + 1) % _sortedMediaItems.Count;
private class ChronologicalComparer : IComparer<MediaItem>
{
public int Compare(MediaItem x, MediaItem y)
{
if (x == null || y == null)
{
return 0;
}
DateTime date1 = x switch
{
TelevisionEpisodeMediaItem e => e.Metadata?.Aired ?? DateTime.MaxValue,
MovieMediaItem m => m.Metadata?.Premiered ?? DateTime.MaxValue,
_ => DateTime.MaxValue
};
DateTime date2 = y switch
{
TelevisionEpisodeMediaItem e => e.Metadata?.Aired ?? DateTime.MaxValue,
MovieMediaItem m => m.Metadata?.Premiered ?? DateTime.MaxValue,
_ => DateTime.MaxValue
};
if (date1 != date2)
{
return date1.CompareTo(date2);
}
int season1 = x switch
{
TelevisionEpisodeMediaItem e => e.Metadata?.Season ?? int.MaxValue,
_ => int.MaxValue
};
int season2 = y switch
{
TelevisionEpisodeMediaItem e => e.Metadata?.Season ?? int.MaxValue,
_ => int.MaxValue
};
if (season1 != season2)
{
return season1.CompareTo(season2);
}
int episode1 = x switch
{
TelevisionEpisodeMediaItem e => e.Metadata?.Episode ?? int.MaxValue,
_ => int.MaxValue
};
int episode2 = y switch
{
TelevisionEpisodeMediaItem e => e.Metadata?.Episode ?? int.MaxValue,
_ => int.MaxValue
};
if (episode1 != episode2)
{
return episode1.CompareTo(episode2);
}
return x.Id.CompareTo(y.Id);
}
}
}
}

20
ErsatzTV.Core/Scheduling/PlayoutBuilder.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
@ -117,10 +118,10 @@ namespace ErsatzTV.Core.Scheduling @@ -117,10 +118,10 @@ namespace ErsatzTV.Core.Scheduling
{
MediaItemId = mediaItem.Id,
Start = itemStartTime,
Finish = itemStartTime + mediaItem.Metadata.Duration
Finish = itemStartTime + mediaItem.Statistics.Duration
};
currentTime = itemStartTime + mediaItem.Metadata.Duration;
currentTime = itemStartTime + mediaItem.Statistics.Duration;
enumerator.MoveNext();
playout.Items.Add(playoutItem);
@ -166,7 +167,7 @@ namespace ErsatzTV.Core.Scheduling @@ -166,7 +167,7 @@ namespace ErsatzTV.Core.Scheduling
// is after, we need to move on to the next schedule item
// eventually, spots probably have to fit in this gap
bool willNotFinishInTime = currentTime <= peekScheduleItemStart &&
currentTime + peekMediaItem.Metadata.Duration >
currentTime + peekMediaItem.Statistics.Duration >
peekScheduleItemStart;
if (willNotFinishInTime)
{
@ -189,7 +190,7 @@ namespace ErsatzTV.Core.Scheduling @@ -189,7 +190,7 @@ namespace ErsatzTV.Core.Scheduling
bool willNotFinishInTime =
currentTime <= durationFinish.IfNone(DateTime.MinValue) &&
currentTime + peekMediaItem.Metadata.Duration >
currentTime + peekMediaItem.Statistics.Duration >
durationFinish.IfNone(DateTime.MinValue);
if (willNotFinishInTime)
{
@ -348,8 +349,13 @@ namespace ErsatzTV.Core.Scheduling @@ -348,8 +349,13 @@ namespace ErsatzTV.Core.Scheduling
}
private static string DisplayTitle(MediaItem mediaItem) =>
mediaItem.Metadata.MediaType == MediaType.TvShow
? $"{mediaItem.Metadata.Title} - s{mediaItem.Metadata.SeasonNumber:00}e{mediaItem.Metadata.EpisodeNumber:00}"
: mediaItem.Metadata.Title;
mediaItem switch
{
TelevisionEpisodeMediaItem e => e.Metadata != null
? $"{e.Metadata.Title} - s{e.Metadata.Season:00}e{e.Metadata.Episode:00}"
: Path.GetFileName(e.Path),
MovieMediaItem m => m.Metadata?.Title ?? Path.GetFileName(m.Path),
_ => string.Empty
};
}
}

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().ToView(null);
builder.HasNoKey().ToView("No table or view exists for GenericIntegerId");
}
}

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().ToView(null);
builder.HasNoKey().ToView("No table or view exists for MediaCollectionSummary");
}
}

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

@ -7,6 +7,6 @@ namespace ErsatzTV.Infrastructure.Data.Configurations @@ -7,6 +7,6 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
public class MediaItemConfiguration : IEntityTypeConfiguration<MediaItem>
{
public void Configure(EntityTypeBuilder<MediaItem> builder) =>
builder.OwnsOne(c => c.Metadata).WithOwner();
builder.OwnsOne(c => c.Statistics).WithOwner();
}
}

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

@ -7,6 +7,6 @@ namespace ErsatzTV.Infrastructure.Data.Configurations @@ -7,6 +7,6 @@ namespace ErsatzTV.Infrastructure.Data.Configurations
public class MediaItemSummaryConfiguration : IEntityTypeConfiguration<MediaItemSummary>
{
public void Configure(EntityTypeBuilder<MediaItemSummary> builder) =>
builder.HasNoKey().ToView(null);
builder.HasNoKey().ToView("No table or view exists for MediaItemSummary");
}
}

19
ErsatzTV.Infrastructure/Data/Configurations/MovieMediaItemConfiguration.cs

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class MovieMediaItemConfiguration : IEntityTypeConfiguration<MovieMediaItem>
{
public void Configure(EntityTypeBuilder<MovieMediaItem> builder)
{
builder.ToTable("Movies");
builder.HasOne(i => i.Metadata)
.WithOne(m => m.Movie)
.HasForeignKey<MovieMetadata>(m => m.MovieId)
.OnDelete(DeleteBehavior.Cascade);
}
}
}

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

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

19
ErsatzTV.Infrastructure/Data/Configurations/TelevisionEpisodeMediaItemConfiguration.cs

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class TelevisionEpisodeMediaItemConfiguration : IEntityTypeConfiguration<TelevisionEpisodeMediaItem>
{
public void Configure(EntityTypeBuilder<TelevisionEpisodeMediaItem> builder)
{
builder.ToTable("TelevisionEpisodes");
builder.HasOne(i => i.Metadata)
.WithOne(m => m.TelevisionEpisode)
.HasForeignKey<TelevisionEpisodeMetadata>(m => m.TelevisionEpisodeId)
.OnDelete(DeleteBehavior.Cascade);
}
}
}

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

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

19
ErsatzTV.Infrastructure/Data/Configurations/TelevisionSeasonConfiguration.cs

@ -0,0 +1,19 @@ @@ -0,0 +1,19 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class TelevisionSeasonConfiguration : IEntityTypeConfiguration<TelevisionSeason>
{
public void Configure(EntityTypeBuilder<TelevisionSeason> builder)
{
builder.ToTable("TelevisionSeasons");
builder.HasMany(season => season.Episodes)
.WithOne(episode => episode.Season)
.HasForeignKey(episode => episode.SeasonId)
.OnDelete(DeleteBehavior.Cascade);
}
}
}

22
ErsatzTV.Infrastructure/Data/Configurations/TelevisionShowConfiguration.cs

@ -0,0 +1,22 @@ @@ -0,0 +1,22 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations
{
public class TelevisionShowConfiguration : IEntityTypeConfiguration<TelevisionShow>
{
public void Configure(EntityTypeBuilder<TelevisionShow> builder)
{
builder.ToTable("TelevisionShows");
builder.HasOne(show => show.Metadata)
.WithOne(metadata => metadata.TelevisionShow)
.HasForeignKey<TelevisionShowMetadata>(metadata => metadata.TelevisionShowId)
.OnDelete(DeleteBehavior.Cascade);
builder.HasMany(show => show.Seasons)
.WithOne(season => season.TelevisionShow);
}
}
}

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

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

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

@ -55,11 +55,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -55,11 +55,11 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
WHERE mc.Name LIKE {0}
GROUP BY mc.Id, mc.Name
UNION ALL
SELECT mc.Id, mc.Name, Count(mi.Id) AS ItemCount, false AS IsSimple
SELECT mc.Id, mc.Name, Count(tem.Id) AS ItemCount, false AS IsSimple
FROM MediaCollections mc
INNER JOIN TelevisionMediaCollections tmc ON tmc.Id = mc.Id
LEFT OUTER JOIN MediaItems mi ON (tmc.SeasonNumber IS NULL OR mi.Metadata_SeasonNumber = tmc.SeasonNumber)
AND mi.Metadata_Title = tmc.ShowTitle
LEFT OUTER JOIN TelevisionEpisodeMetadata tem ON (tmc.SeasonNumber IS NULL OR tem.Season = tmc.SeasonNumber)
AND tem.Title = tmc.ShowTitle
WHERE mc.Name LIKE {0}
GROUP BY mc.Id, mc.Name",
$"%{searchString}%").ToListAsync();
@ -145,10 +145,13 @@ WHERE NOT EXISTS @@ -145,10 +145,13 @@ WHERE NOT EXISTS
return collection.Items.ToList();
}
// TODO: reimplement this
private Task<List<MediaItem>> TelevisionItems(TelevisionMediaCollection collection) =>
_dbContext.MediaItems
.Filter(c => c.Metadata.Title == collection.ShowTitle)
.Filter(c => collection.SeasonNumber == null || c.Metadata.SeasonNumber == collection.SeasonNumber)
.ToListAsync();
new List<MediaItem>().AsTask();
// _dbContext.MediaItems
// .Filter(c => c.Metadata.Title == collection.ShowTitle)
// .Filter(c => collection.SeasonNumber == null || c.Metadata.SeasonNumber == collection.SeasonNumber)
// .ToListAsync();
}
}

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

@ -33,14 +33,23 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -33,14 +33,23 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
public Task<List<MediaItem>> Search(string searchString)
{
IQueryable<MediaItem> data = from c in _dbContext.MediaItems.Include(c => c.Source) select c;
IQueryable<TelevisionEpisodeMediaItem> episodeData =
from c in _dbContext.TelevisionEpisodeMediaItems.Include(c => c.Source) select c;
if (!string.IsNullOrEmpty(searchString))
{
data = data.Where(c => EF.Functions.Like(c.Metadata.Title, $"%{searchString}%"));
episodeData = episodeData.Where(c => EF.Functions.Like(c.Metadata.Title, $"%{searchString}%"));
}
return data.ToListAsync();
IQueryable<MovieMediaItem> movieData =
from c in _dbContext.MovieMediaItems.Include(c => c.Source) select c;
if (!string.IsNullOrEmpty(searchString))
{
movieData = movieData.Where(c => EF.Functions.Like(c.Metadata.Title, $"%{searchString}%"));
}
return episodeData.OfType<MediaItem>().Concat(movieData.OfType<MediaItem>()).ToListAsync();
}
@ -49,13 +58,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -49,13 +58,15 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
{
MediaType.Movie => _dbContext.MediaItemSummaries.FromSqlRaw(
@"SELECT
Id AS MediaItemId,
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
m.Id AS MediaItemId,
mm.Title AS Title,
mm.SortTitle AS SortTitle,
mm.Year AS Subtitle,
mi.Poster AS Poster
FROM Movies m
INNER JOIN MediaItems mi on m.Id = mi.Id
INNER JOIN MovieMetadata mm on mm.MovieId = m.Id
ORDER BY SortTitle
LIMIT {0} OFFSET {1}",
pageSize,
(pageNumber - 1) * pageSize)
@ -63,14 +74,15 @@ LIMIT {0} OFFSET {1}", @@ -63,14 +74,15 @@ LIMIT {0} OFFSET {1}",
.ToListAsync(),
MediaType.TvShow => _dbContext.MediaItemSummaries.FromSqlRaw(
@"SELECT
min(Id) AS MediaItemId,
Metadata_Title AS Title,
Metadata_SortTitle AS SortTitle,
count(*) || ' Episodes' AS Subtitle,
max(Poster) AS Poster
FROM MediaItems WHERE Metadata_MediaType=1
GROUP BY Metadata_Title, Metadata_SortTitle
ORDER BY Metadata_SortTitle
min(ts.Id) AS MediaItemId,
tsm.Title AS Title,
tsm.SortTitle AS SortTitle,
tsm.Year AS Subtitle,
max(ts.Poster) AS Poster
FROM TelevisionShows ts
INNER JOIN TelevisionShowMetadata tsm on tsm.TelevisionShowId = ts.Id
GROUP BY tsm.Title, tsm.SortTitle, tsm.Year
ORDER BY tsm.SortTitle
LIMIT {0} OFFSET {1}",
pageSize,
(pageNumber - 1) * pageSize)
@ -82,11 +94,9 @@ LIMIT {0} OFFSET {1}", @@ -82,11 +94,9 @@ LIMIT {0} OFFSET {1}",
public Task<int> GetCountByType(MediaType mediaType) =>
mediaType switch
{
MediaType.Movie => _dbContext.MediaItems
.Filter(i => i.Metadata.MediaType == mediaType)
MediaType.Movie => _dbContext.MovieMediaItems
.CountAsync(),
MediaType.TvShow => _dbContext.MediaItems
.Filter(i => i.Metadata.MediaType == mediaType)
MediaType.TvShow => _dbContext.TelevisionShows
.GroupBy(i => new { i.Metadata.Title, i.Metadata.SortTitle })
.CountAsync(),
_ => Task.FromResult(0)
@ -103,11 +113,12 @@ LIMIT {0} OFFSET {1}", @@ -103,11 +113,12 @@ LIMIT {0} OFFSET {1}",
return await _dbContext.SaveChangesAsync() > 0;
}
public async Task Delete(int mediaItemId)
{
MediaItem mediaItem = await _dbContext.MediaItems.FindAsync(mediaItemId);
_dbContext.MediaItems.Remove(mediaItem);
await _dbContext.SaveChangesAsync();
}
public Task<Unit> Delete(int mediaItemId) =>
_dbContext.MediaItems.FindAsync(mediaItemId).AsTask().Bind(
mediaItem =>
{
_dbContext.MediaItems.Remove(mediaItem);
return _dbContext.SaveChangesAsync();
}).ToUnit();
}
}

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

@ -0,0 +1,44 @@ @@ -0,0 +1,44 @@
using System;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Infrastructure.Data.Repositories
{
public class MovieRepository : IMovieRepository
{
private readonly TvContext _dbContext;
public MovieRepository(TvContext dbContext) => _dbContext = dbContext;
public async Task<Either<BaseError, MovieMediaItem>> GetOrAdd(int mediaSourceId, string path)
{
Option<MovieMediaItem> maybeExisting = await _dbContext.MovieMediaItems
.Include(i => i.Metadata)
.SingleOrDefaultAsync(i => i.Path == path);
return await maybeExisting.Match(
mediaItem => Right<BaseError, MovieMediaItem>(mediaItem).AsTask(),
async () => await AddMovie(mediaSourceId, path));
}
private async Task<Either<BaseError, MovieMediaItem>> AddMovie(int mediaSourceId, string path)
{
try
{
var movie = new MovieMediaItem { MediaSourceId = mediaSourceId, Path = path };
await _dbContext.MovieMediaItems.AddAsync(movie);
await _dbContext.SaveChangesAsync();
return movie;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
}
}

5
ErsatzTV.Infrastructure/Data/Repositories/PlayoutRepository.cs

@ -52,10 +52,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -52,10 +52,13 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
.SingleOrDefaultAsync();
}
// TODO: does this actually work?
public Task<List<PlayoutItem>> GetPlayoutItems(int playoutId) =>
_dbContext.PlayoutItems
.Include(i => i.MediaItem)
.ThenInclude(mi => mi.Metadata)
.ThenInclude(m => (m as MovieMediaItem).Metadata)
.Include(i => i.MediaItem)
.ThenInclude(m => (m as TelevisionEpisodeMediaItem).Metadata)
.Filter(i => i.PlayoutId == playoutId)
.ToListAsync();

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

@ -0,0 +1,142 @@ @@ -0,0 +1,142 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
namespace ErsatzTV.Infrastructure.Data.Repositories
{
public class TelevisionRepository : ITelevisionRepository
{
private readonly TvContext _dbContext;
public TelevisionRepository(TvContext dbContext) => _dbContext = dbContext;
public Task<Unit> Add(TelevisionShow show) =>
_dbContext.TelevisionShows.AddAsync(show).AsTask()
.Bind(_ => _dbContext.SaveChangesAsync())
.ToUnit();
public Task<List<TelevisionShow>> GetAllByMediaSourceId(int mediaSourceId) =>
_dbContext.TelevisionShows
.Include(s => s.Metadata)
.Include(s => s.Seasons)
.ThenInclude(s => s.Episodes)
.ToListAsync();
public async Task<bool> Update(TelevisionShow show)
{
_dbContext.TelevisionShows.Update(show);
return await _dbContext.SaveChangesAsync() > 0;
}
public async Task<bool> Update(TelevisionEpisodeMediaItem episode)
{
_dbContext.TelevisionEpisodeMediaItems.Update(episode);
return await _dbContext.SaveChangesAsync() > 0;
}
public async Task<Either<BaseError, TelevisionShow>> GetOrAddShow(int mediaSourceId, string path)
{
Option<TelevisionShow> maybeExisting = await _dbContext.TelevisionShows
.Include(i => i.Metadata)
.SingleOrDefaultAsync(i => i.Path == path);
return await maybeExisting.Match(
show => Right<BaseError, TelevisionShow>(show).AsTask(),
() => AddShow(mediaSourceId, path));
}
public async Task<Either<BaseError, TelevisionSeason>> GetOrAddSeason(
TelevisionShow show,
string path,
int seasonNumber)
{
Option<TelevisionSeason> maybeExisting = await _dbContext.TelevisionSeasons
.SingleOrDefaultAsync(i => i.Path == path);
return await maybeExisting.Match(
season => Right<BaseError, TelevisionSeason>(season).AsTask(),
() => AddSeason(show, path, seasonNumber));
}
public async Task<Either<BaseError, TelevisionEpisodeMediaItem>> GetOrAddEpisode(
TelevisionSeason season,
int mediaSourceId,
string path)
{
Option<TelevisionEpisodeMediaItem> maybeExisting = await _dbContext.TelevisionEpisodeMediaItems
.Include(i => i.Metadata)
.SingleOrDefaultAsync(i => i.Path == path);
return await maybeExisting.Match(
episode => Right<BaseError, TelevisionEpisodeMediaItem>(episode).AsTask(),
() => AddEpisode(season, mediaSourceId, path));
}
private async Task<Either<BaseError, TelevisionShow>> AddShow(int mediaSourceId, string path)
{
try
{
var show = new TelevisionShow
{ MediaSourceId = mediaSourceId, Path = path, Seasons = new List<TelevisionSeason>() };
await _dbContext.TelevisionShows.AddAsync(show);
await _dbContext.SaveChangesAsync();
return show;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
private async Task<Either<BaseError, TelevisionSeason>> AddSeason(
TelevisionShow show,
string path,
int seasonNumber)
{
try
{
var season = new TelevisionSeason
{
TelevisionShowId = show.Id, Path = path, Number = seasonNumber,
Episodes = new List<TelevisionEpisodeMediaItem>()
};
await _dbContext.TelevisionSeasons.AddAsync(season);
await _dbContext.SaveChangesAsync();
return season;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
private async Task<Either<BaseError, TelevisionEpisodeMediaItem>> AddEpisode(
TelevisionSeason season,
int mediaSourceId,
string path)
{
try
{
var episode = new TelevisionEpisodeMediaItem
{
MediaSourceId = mediaSourceId,
SeasonId = season.Id,
Path = path
};
await _dbContext.TelevisionEpisodeMediaItems.AddAsync(episode);
await _dbContext.SaveChangesAsync();
return episode;
}
catch (Exception ex)
{
return BaseError.New(ex.Message);
}
}
}
}

4
ErsatzTV.Infrastructure/Data/TvContext.cs

@ -19,6 +19,8 @@ namespace ErsatzTV.Infrastructure.Data @@ -19,6 +19,8 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<LocalMediaSource> LocalMediaSources { get; set; }
public DbSet<PlexMediaSource> PlexMediaSources { get; set; }
public DbSet<MediaItem> MediaItems { get; set; }
public DbSet<MovieMediaItem> MovieMediaItems { get; set; }
public DbSet<TelevisionEpisodeMediaItem> TelevisionEpisodeMediaItems { get; set; }
public DbSet<MediaCollection> MediaCollections { get; set; }
public DbSet<SimpleMediaCollection> SimpleMediaCollections { get; set; }
public DbSet<TelevisionMediaCollection> TelevisionMediaCollections { get; set; }
@ -28,6 +30,8 @@ namespace ErsatzTV.Infrastructure.Data @@ -28,6 +30,8 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<PlayoutProgramScheduleAnchor> PlayoutProgramScheduleItemAnchors { get; set; }
public DbSet<FFmpegProfile> FFmpegProfiles { get; set; }
public DbSet<Resolution> Resolutions { get; set; }
public DbSet<TelevisionShow> TelevisionShows { get; set; }
public DbSet<TelevisionSeason> TelevisionSeasons { get; set; }
// support raw sql queries
public DbSet<MediaCollectionSummary> MediaCollectionSummaries { get; set; }

1182
ErsatzTV.Infrastructure/Migrations/20210218214650_Statistics.Designer.cs generated

File diff suppressed because it is too large Load Diff

428
ErsatzTV.Infrastructure/Migrations/20210218214650_Statistics.cs

@ -0,0 +1,428 @@ @@ -0,0 +1,428 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Statistics : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
"Metadata_Aired",
"MediaItems");
migrationBuilder.DropColumn(
"Metadata_ContentRating",
"MediaItems");
migrationBuilder.DropColumn(
"Metadata_Description",
"MediaItems");
migrationBuilder.DropColumn(
"Metadata_EpisodeNumber",
"MediaItems");
migrationBuilder.DropColumn(
"Metadata_MediaType",
"MediaItems");
migrationBuilder.DropColumn(
"Metadata_SeasonNumber",
"MediaItems");
migrationBuilder.DropColumn(
"Metadata_SortTitle",
"MediaItems");
migrationBuilder.DropColumn(
"Metadata_Source",
"MediaItems");
migrationBuilder.DropColumn(
"Metadata_Subtitle",
"MediaItems");
migrationBuilder.DropColumn(
"Metadata_Title",
"MediaItems");
migrationBuilder.RenameColumn(
"Metadata_Width",
"MediaItems",
"Statistics_Width");
migrationBuilder.RenameColumn(
"Metadata_VideoScanType",
"MediaItems",
"Statistics_VideoScanType");
migrationBuilder.RenameColumn(
"Metadata_VideoCodec",
"MediaItems",
"Statistics_VideoCodec");
migrationBuilder.RenameColumn(
"Metadata_SampleAspectRatio",
"MediaItems",
"Statistics_SampleAspectRatio");
migrationBuilder.RenameColumn(
"Metadata_LastWriteTime",
"MediaItems",
"Statistics_LastWriteTime");
migrationBuilder.RenameColumn(
"Metadata_Height",
"MediaItems",
"Statistics_Height");
migrationBuilder.RenameColumn(
"Metadata_Duration",
"MediaItems",
"Statistics_Duration");
migrationBuilder.RenameColumn(
"Metadata_DisplayAspectRatio",
"MediaItems",
"Statistics_DisplayAspectRatio");
migrationBuilder.RenameColumn(
"Metadata_AudioCodec",
"MediaItems",
"Statistics_AudioCodec");
migrationBuilder.CreateTable(
"Movies",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
MetadataId = table.Column<int>("INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Movies", x => x.Id);
table.ForeignKey(
"FK_Movies_MediaItems_Id",
x => x.Id,
"MediaItems",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"TelevisionShows",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
MediaSourceId = table.Column<int>("INTEGER", nullable: false),
Path = table.Column<string>("TEXT", nullable: true),
Poster = table.Column<string>("TEXT", nullable: true),
PosterLastWriteTime = table.Column<DateTime>("TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TelevisionShows", x => x.Id);
table.ForeignKey(
"FK_TelevisionShows_MediaSources_MediaSourceId",
x => x.MediaSourceId,
"MediaSources",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"MovieMetadata",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
MovieId = table.Column<int>("INTEGER", nullable: false),
Year = table.Column<int>("INTEGER", nullable: true),
Premiered = table.Column<DateTime>("TEXT", nullable: true),
Plot = table.Column<string>("TEXT", nullable: true),
Outline = table.Column<string>("TEXT", nullable: true),
Tagline = table.Column<string>("TEXT", nullable: true),
ContentRating = table.Column<string>("TEXT", nullable: true),
Source = table.Column<int>("INTEGER", nullable: false),
LastWriteTime = table.Column<DateTime>("TEXT", nullable: true),
Title = table.Column<string>("TEXT", nullable: true),
SortTitle = table.Column<string>("TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_MovieMetadata", x => x.Id);
table.ForeignKey(
"FK_MovieMetadata_Movies_MovieId",
x => x.MovieId,
"Movies",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"TelevisionSeasons",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
TelevisionShowId = table.Column<int>("INTEGER", nullable: false),
Number = table.Column<int>("INTEGER", nullable: false),
Path = table.Column<string>("TEXT", nullable: true),
Poster = table.Column<string>("TEXT", nullable: true),
PosterLastWriteTime = table.Column<DateTime>("TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TelevisionSeasons", x => x.Id);
table.ForeignKey(
"FK_TelevisionSeasons_TelevisionShows_TelevisionShowId",
x => x.TelevisionShowId,
"TelevisionShows",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"TelevisionShowMetadata",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
TelevisionShowId = table.Column<int>("INTEGER", nullable: false),
Source = table.Column<int>("INTEGER", nullable: false),
LastWriteTime = table.Column<DateTime>("TEXT", nullable: true),
Title = table.Column<string>("TEXT", nullable: true),
SortTitle = table.Column<string>("TEXT", nullable: true),
Year = table.Column<int>("INTEGER", nullable: true),
Plot = table.Column<string>("TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TelevisionShowMetadata", x => x.Id);
table.ForeignKey(
"FK_TelevisionShowMetadata_TelevisionShows_TelevisionShowId",
x => x.TelevisionShowId,
"TelevisionShows",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"TelevisionEpisodes",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
SeasonId = table.Column<int>("INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TelevisionEpisodes", x => x.Id);
table.ForeignKey(
"FK_TelevisionEpisodes_MediaItems_Id",
x => x.Id,
"MediaItems",
"Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
"FK_TelevisionEpisodes_TelevisionSeasons_SeasonId",
x => x.SeasonId,
"TelevisionSeasons",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
"TelevisionEpisodeMetadata",
table => new
{
Id = table.Column<int>("INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
TelevisionEpisodeId = table.Column<int>("INTEGER", nullable: false),
Season = table.Column<int>("INTEGER", nullable: false),
Episode = table.Column<int>("INTEGER", nullable: false),
Plot = table.Column<string>("TEXT", nullable: true),
Aired = table.Column<DateTime>("TEXT", nullable: true),
Source = table.Column<int>("INTEGER", nullable: false),
LastWriteTime = table.Column<DateTime>("TEXT", nullable: true),
Title = table.Column<string>("TEXT", nullable: true),
SortTitle = table.Column<string>("TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_TelevisionEpisodeMetadata", x => x.Id);
table.ForeignKey(
"FK_TelevisionEpisodeMetadata_TelevisionEpisodes_TelevisionEpisodeId",
x => x.TelevisionEpisodeId,
"TelevisionEpisodes",
"Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
"IX_MovieMetadata_MovieId",
"MovieMetadata",
"MovieId",
unique: true);
migrationBuilder.CreateIndex(
"IX_TelevisionEpisodeMetadata_TelevisionEpisodeId",
"TelevisionEpisodeMetadata",
"TelevisionEpisodeId",
unique: true);
migrationBuilder.CreateIndex(
"IX_TelevisionEpisodes_SeasonId",
"TelevisionEpisodes",
"SeasonId");
migrationBuilder.CreateIndex(
"IX_TelevisionSeasons_TelevisionShowId",
"TelevisionSeasons",
"TelevisionShowId");
migrationBuilder.CreateIndex(
"IX_TelevisionShowMetadata_TelevisionShowId",
"TelevisionShowMetadata",
"TelevisionShowId",
unique: true);
migrationBuilder.CreateIndex(
"IX_TelevisionShows_MediaSourceId",
"TelevisionShows",
"MediaSourceId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
"MovieMetadata");
migrationBuilder.DropTable(
"TelevisionEpisodeMetadata");
migrationBuilder.DropTable(
"TelevisionShowMetadata");
migrationBuilder.DropTable(
"Movies");
migrationBuilder.DropTable(
"TelevisionEpisodes");
migrationBuilder.DropTable(
"TelevisionSeasons");
migrationBuilder.DropTable(
"TelevisionShows");
migrationBuilder.RenameColumn(
"Statistics_Width",
"MediaItems",
"Metadata_Width");
migrationBuilder.RenameColumn(
"Statistics_VideoScanType",
"MediaItems",
"Metadata_VideoScanType");
migrationBuilder.RenameColumn(
"Statistics_VideoCodec",
"MediaItems",
"Metadata_VideoCodec");
migrationBuilder.RenameColumn(
"Statistics_SampleAspectRatio",
"MediaItems",
"Metadata_SampleAspectRatio");
migrationBuilder.RenameColumn(
"Statistics_LastWriteTime",
"MediaItems",
"Metadata_LastWriteTime");
migrationBuilder.RenameColumn(
"Statistics_Height",
"MediaItems",
"Metadata_Height");
migrationBuilder.RenameColumn(
"Statistics_Duration",
"MediaItems",
"Metadata_Duration");
migrationBuilder.RenameColumn(
"Statistics_DisplayAspectRatio",
"MediaItems",
"Metadata_DisplayAspectRatio");
migrationBuilder.RenameColumn(
"Statistics_AudioCodec",
"MediaItems",
"Metadata_AudioCodec");
migrationBuilder.AddColumn<DateTime>(
"Metadata_Aired",
"MediaItems",
"TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
"Metadata_ContentRating",
"MediaItems",
"TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
"Metadata_Description",
"MediaItems",
"TEXT",
nullable: true);
migrationBuilder.AddColumn<int>(
"Metadata_EpisodeNumber",
"MediaItems",
"INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
"Metadata_MediaType",
"MediaItems",
"INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
"Metadata_SeasonNumber",
"MediaItems",
"INTEGER",
nullable: true);
migrationBuilder.AddColumn<string>(
"Metadata_SortTitle",
"MediaItems",
"TEXT",
nullable: true);
migrationBuilder.AddColumn<int>(
"Metadata_Source",
"MediaItems",
"INTEGER",
nullable: true);
migrationBuilder.AddColumn<string>(
"Metadata_Subtitle",
"MediaItems",
"TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
"Metadata_Title",
"MediaItems",
"TEXT",
nullable: true);
}
}
}

366
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -22,6 +22,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -22,6 +22,8 @@ namespace ErsatzTV.Infrastructure.Migrations
{
b.Property<int>("Id")
.HasColumnType("INTEGER");
b.ToView("No table or view exists for GenericIntegerId");
});
modelBuilder.Entity(
@ -39,6 +41,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -39,6 +41,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Name")
.HasColumnType("TEXT");
b.ToView("No table or view exists for MediaCollectionSummary");
});
modelBuilder.Entity(
@ -59,6 +63,8 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -59,6 +63,8 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<string>("Title")
.HasColumnType("TEXT");
b.ToView("No table or view exists for MediaItemSummary");
});
modelBuilder.Entity(
@ -256,6 +262,55 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -256,6 +262,55 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("MediaSources");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MovieMetadata",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("ContentRating")
.HasColumnType("TEXT");
b.Property<DateTime?>("LastWriteTime")
.HasColumnType("TEXT");
b.Property<int>("MovieId")
.HasColumnType("INTEGER");
b.Property<string>("Outline")
.HasColumnType("TEXT");
b.Property<string>("Plot")
.HasColumnType("TEXT");
b.Property<DateTime?>("Premiered")
.HasColumnType("TEXT");
b.Property<string>("SortTitle")
.HasColumnType("TEXT");
b.Property<int>("Source")
.HasColumnType("INTEGER");
b.Property<string>("Tagline")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MovieId")
.IsUnique();
b.ToTable("MovieMetadata");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.Playout",
b =>
@ -457,6 +512,143 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -457,6 +512,143 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("Resolutions");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.TelevisionEpisodeMetadata",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("Aired")
.HasColumnType("TEXT");
b.Property<int>("Episode")
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastWriteTime")
.HasColumnType("TEXT");
b.Property<string>("Plot")
.HasColumnType("TEXT");
b.Property<int>("Season")
.HasColumnType("INTEGER");
b.Property<string>("SortTitle")
.HasColumnType("TEXT");
b.Property<int>("Source")
.HasColumnType("INTEGER");
b.Property<int>("TelevisionEpisodeId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TelevisionEpisodeId")
.IsUnique();
b.ToTable("TelevisionEpisodeMetadata");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.TelevisionSeason",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<string>("Path")
.HasColumnType("TEXT");
b.Property<string>("Poster")
.HasColumnType("TEXT");
b.Property<DateTime?>("PosterLastWriteTime")
.HasColumnType("TEXT");
b.Property<int>("TelevisionShowId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("TelevisionShowId");
b.ToTable("TelevisionSeasons");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.TelevisionShow",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("MediaSourceId")
.HasColumnType("INTEGER");
b.Property<string>("Path")
.HasColumnType("TEXT");
b.Property<string>("Poster")
.HasColumnType("TEXT");
b.Property<DateTime?>("PosterLastWriteTime")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MediaSourceId");
b.ToTable("TelevisionShows");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.TelevisionShowMetadata",
b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTime?>("LastWriteTime")
.HasColumnType("TEXT");
b.Property<string>("Plot")
.HasColumnType("TEXT");
b.Property<string>("SortTitle")
.HasColumnType("TEXT");
b.Property<int>("Source")
.HasColumnType("INTEGER");
b.Property<int>("TelevisionShowId")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<int?>("Year")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("TelevisionShowId")
.IsUnique();
b.ToTable("TelevisionShowMetadata");
});
modelBuilder.Entity(
"MediaItemSimpleMediaCollection",
b =>
@ -501,6 +693,32 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -501,6 +693,32 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("TelevisionMediaCollections");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MovieMediaItem",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
b.Property<int>("MetadataId")
.HasColumnType("INTEGER");
b.ToTable("Movies");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem",
b =>
{
b.HasBaseType("ErsatzTV.Core.Domain.MediaItem");
b.Property<int>("SeasonId")
.HasColumnType("INTEGER");
b.HasIndex("SeasonId");
b.ToTable("TelevisionEpisodes");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.LocalMediaSource",
b =>
@ -613,61 +831,31 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -613,61 +831,31 @@ namespace ErsatzTV.Infrastructure.Migrations
.IsRequired();
b.OwnsOne(
"ErsatzTV.Core.Domain.MediaMetadata",
"Metadata",
"ErsatzTV.Core.Domain.MediaItemStatistics",
"Statistics",
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<DateTime?>("LastWriteTime")
.HasColumnType("TEXT");
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<int>("Source")
.HasColumnType("INTEGER");
b1.Property<string>("Subtitle")
.HasColumnType("TEXT");
b1.Property<string>("Title")
.HasColumnType("TEXT");
b1.Property<string>("VideoCodec")
.HasColumnType("TEXT");
@ -685,9 +873,22 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -685,9 +873,22 @@ namespace ErsatzTV.Infrastructure.Migrations
.HasForeignKey("MediaItemId");
});
b.Navigation("Metadata");
b.Navigation("Source");
b.Navigation("Statistics");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MovieMetadata",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MovieMediaItem", "Movie")
.WithOne("Metadata")
.HasForeignKey("ErsatzTV.Core.Domain.MovieMetadata", "MovieId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Movie");
});
modelBuilder.Entity(
@ -870,6 +1071,58 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -870,6 +1071,58 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("ProgramSchedule");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.TelevisionEpisodeMetadata",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", "TelevisionEpisode")
.WithOne("Metadata")
.HasForeignKey("ErsatzTV.Core.Domain.TelevisionEpisodeMetadata", "TelevisionEpisodeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("TelevisionEpisode");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.TelevisionSeason",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.TelevisionShow", "TelevisionShow")
.WithMany("Seasons")
.HasForeignKey("TelevisionShowId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("TelevisionShow");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.TelevisionShow",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaSource", "Source")
.WithMany()
.HasForeignKey("MediaSourceId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Source");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.TelevisionShowMetadata",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.TelevisionShow", "TelevisionShow")
.WithOne("Metadata")
.HasForeignKey("ErsatzTV.Core.Domain.TelevisionShowMetadata", "TelevisionShowId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("TelevisionShow");
});
modelBuilder.Entity(
"MediaItemSimpleMediaCollection",
b =>
@ -909,6 +1162,36 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -909,6 +1162,36 @@ namespace ErsatzTV.Infrastructure.Migrations
.IsRequired();
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.MovieMediaItem",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.MovieMediaItem", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem",
b =>
{
b.HasOne("ErsatzTV.Core.Domain.MediaItem", null)
.WithOne()
.HasForeignKey("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", "Id")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ErsatzTV.Core.Domain.TelevisionSeason", "Season")
.WithMany("Episodes")
.HasForeignKey("SeasonId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Season");
});
modelBuilder.Entity(
"ErsatzTV.Core.Domain.LocalMediaSource",
b =>
@ -995,6 +1278,21 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -995,6 +1278,21 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Navigation("Playouts");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionSeason", b => { b.Navigation("Episodes"); });
modelBuilder.Entity(
"ErsatzTV.Core.Domain.TelevisionShow",
b =>
{
b.Navigation("Metadata");
b.Navigation("Seasons");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.MovieMediaItem", b => { b.Navigation("Metadata"); });
modelBuilder.Entity("ErsatzTV.Core.Domain.TelevisionEpisodeMediaItem", b => { b.Navigation("Metadata"); });
modelBuilder.Entity(
"ErsatzTV.Core.Domain.PlexMediaSource",
b =>

3
ErsatzTV.sln.DotSettings

@ -4,6 +4,7 @@ @@ -4,6 +4,7 @@
<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/=behindthescenes/@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>
@ -13,6 +14,7 @@ @@ -13,6 +14,7 @@
<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/=featurette/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=featurettes/@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>
@ -35,4 +37,5 @@ @@ -35,4 +37,5 @@
<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/=tvshow/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=yadif/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

18
ErsatzTV/Controllers/Api/MediaItemsController.cs

@ -1,8 +1,6 @@ @@ -1,8 +1,6 @@
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaItems;
using ErsatzTV.Application.MediaItems.Commands;
using ErsatzTV.Application.MediaItems.Queries;
using ErsatzTV.Extensions;
using MediatR;
@ -19,14 +17,6 @@ namespace ErsatzTV.Controllers.Api @@ -19,14 +17,6 @@ namespace ErsatzTV.Controllers.Api
public MediaItemsController(IMediator mediator) => _mediator = mediator;
[HttpPost]
[ProducesResponseType(typeof(MediaItemViewModel), 200)]
[ProducesResponseType(400)]
public Task<IActionResult> Add(
[Required] [FromBody]
CreateMediaItem createMediaItem) =>
_mediator.Send(createMediaItem).ToActionResult();
[HttpGet("{mediaItemId}")]
[ProducesResponseType(typeof(MediaItemViewModel), 200)]
[ProducesResponseType(404)]
@ -37,13 +27,5 @@ namespace ErsatzTV.Controllers.Api @@ -37,13 +27,5 @@ namespace ErsatzTV.Controllers.Api
[ProducesResponseType(typeof(IEnumerable<MediaItemViewModel>), 200)]
public Task<IActionResult> GetAll() =>
_mediator.Send(new GetAllMediaItems()).ToActionResult();
[HttpDelete]
[ProducesResponseType(200)]
[ProducesResponseType(400)]
public Task<IActionResult> Delete(
[Required] [FromBody]
DeleteMediaItem deleteMediaItem) =>
_mediator.Send(deleteMediaItem).ToActionResult();
}
}

5
ErsatzTV/Pages/LocalMediaSourceEditor.razor

@ -1,7 +1,6 @@ @@ -1,7 +1,6 @@
@page "/media/sources/local/add"
@using ErsatzTV.Application.MediaSources.Commands
@using ErsatzTV.Application.MediaSources
@using ErsatzTV.Core.Metadata
@inject NavigationManager NavigationManager
@inject ILogger<LocalMediaSourceEditor> Logger
@inject ISnackbar Snackbar
@ -17,7 +16,7 @@ @@ -17,7 +16,7 @@
<MudCard>
<MudCardContent>
<MudSelect Class="mt-3" Label="Media Type" @bind-Value="_model.MediaType" For="@(() => _model.MediaType)">
@foreach (MediaType mediaType in Enum.GetValues<MediaType>())
@foreach (MediaType mediaType in new[] { MediaType.TvShow, MediaType.Movie })
{
<MudSelectItem Value="@mediaType">@mediaType</MudSelectItem>
}
@ -88,7 +87,7 @@ @@ -88,7 +87,7 @@
{
if (Locker.LockMediaSource(vm.Id))
{
await Channel.WriteAsync(new ScanLocalMediaSource(vm.Id, ScanningMode.Default));
await Channel.WriteAsync(new ScanLocalMediaSource(vm.Id));
NavigationManager.NavigateTo("/media/sources");
}
});

3
ErsatzTV/Services/SchedulerService.cs

@ -7,7 +7,6 @@ using ErsatzTV.Application; @@ -7,7 +7,6 @@ using ErsatzTV.Application;
using ErsatzTV.Application.MediaSources.Commands;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Metadata;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
@ -83,7 +82,7 @@ namespace ErsatzTV.Services @@ -83,7 +82,7 @@ namespace ErsatzTV.Services
if (_entityLocker.LockMediaSource(mediaSourceId))
{
await _channel.WriteAsync(
new ScanLocalMediaSource(mediaSourceId, ScanningMode.Default),
new ScanLocalMediaSource(mediaSourceId),
cancellationToken);
}
}

23
ErsatzTV/Services/WorkerService.cs

@ -3,7 +3,6 @@ using System.Threading; @@ -3,7 +3,6 @@ using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using ErsatzTV.Application;
using ErsatzTV.Application.MediaItems.Commands;
using ErsatzTV.Application.MediaSources.Commands;
using ErsatzTV.Application.Playouts.Commands;
using ErsatzTV.Core;
@ -56,28 +55,6 @@ namespace ErsatzTV.Services @@ -56,28 +55,6 @@ namespace ErsatzTV.Services
buildPlayout.PlayoutId,
error.Value));
break;
case RefreshMediaItem refreshMediaItem:
string type = refreshMediaItem switch
{
// RefreshMediaItemMetadata => "metadata",
RefreshMediaItemStatistics => "statistics",
RefreshMediaItemCollections => "collections",
RefreshMediaItemPoster => "poster",
_ => ""
};
// TODO: different request types for different media source types?
Either<BaseError, Unit> refreshMediaItemResult =
await mediator.Send(refreshMediaItem, cancellationToken);
refreshMediaItemResult.Match(
_ => _logger.LogDebug(
$"Refreshed {type} for media item {{MediaItemId}}",
refreshMediaItem.MediaItemId),
error => _logger.LogWarning(
$"Unable to refresh {type} for media item {{MediaItemId}}: {{Error}}",
refreshMediaItem.MediaItemId,
error.Value));
break;
case ScanLocalMediaSource scanLocalMediaSource:
Either<BaseError, string> scanResult = await mediator.Send(
scanLocalMediaSource,

3
ErsatzTV/Shared/LocalMediaSources.razor

@ -1,7 +1,6 @@ @@ -1,7 +1,6 @@
@using ErsatzTV.Application.MediaSources
@using ErsatzTV.Application.MediaSources.Commands
@using ErsatzTV.Application.MediaSources.Queries
@using ErsatzTV.Core.Metadata
@implements IDisposable
@inject IDialogService Dialog
@inject IMediator Mediator
@ -90,7 +89,7 @@ @@ -90,7 +89,7 @@
{
if (Locker.LockMediaSource(mediaSource.Id))
{
await Channel.WriteAsync(new ScanLocalMediaSource(mediaSource.Id, ScanningMode.RescanAll));
await Channel.WriteAsync(new ScanLocalMediaSource(mediaSource.Id));
StateHasChanged();
}
}

1
ErsatzTV/Shared/MainLayout.razor

@ -30,7 +30,6 @@ @@ -30,7 +30,6 @@
<MudNavLink Href="/media/sources">Media Sources</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>

11
ErsatzTV/Shared/MediaCard.razor

@ -1,5 +1,4 @@ @@ -1,5 +1,4 @@
@using ErsatzTV.Application.MediaItems
@using ErsatzTV.Application.MediaItems.Commands
@using Unit = LanguageExt.Unit
@inject IMediator Mediator
@ -11,7 +10,6 @@ @@ -11,7 +10,6 @@
@Placeholder(Data.SortTitle)
</MudText>
}
<MudIconButton Icon="@Icons.Material.Filled.Refresh" Color="Color.Primary" OnClick="@(() => RefreshMetadata())" Class="media-card-menu"></MudIconButton>
</MudPaper>
<MudText Align="Align.Center" Class="media-card-title" UserAttributes="@(new Dictionary<string, object> { { "title", Data.Title } })">
@Data.Title
@ -39,13 +37,4 @@ @@ -39,13 +37,4 @@
? "position: relative"
: $"position: relative; background-image: url(/posters/{Data.Poster}); background-size: cover";
private async Task RefreshMetadata()
{
// TODO: how should we refresh an entire television show?
await Mediator.Send(new RefreshMediaItemMetadata(Data.MediaItemId));
await Mediator.Send(new RefreshMediaItemCollections(Data.MediaItemId));
await Mediator.Send(new RefreshMediaItemPoster(Data.MediaItemId));
await DataRefreshed.InvokeAsync();
}
}

10
ErsatzTV/Startup.cs

@ -171,16 +171,22 @@ namespace ErsatzTV @@ -171,16 +171,22 @@ namespace ErsatzTV
services.AddScoped<IProgramScheduleRepository, ProgramScheduleRepository>();
services.AddScoped<IPlayoutRepository, PlayoutRepository>();
services.AddScoped<ILogRepository, LogRepository>();
services.AddScoped<ITelevisionRepository, TelevisionRepository>();
services.AddScoped<IMovieRepository, MovieRepository>();
services.AddScoped<IFFmpegLocator, FFmpegLocator>();
services.AddScoped<ISmartCollectionBuilder, SmartCollectionBuilder>();
services.AddScoped<ILocalMetadataProvider, LocalMetadataProvider>();
services.AddScoped<ILocalStatisticsProvider, LocalStatisticsProvider>();
services.AddScoped<ILocalPosterProvider, LocalPosterProvider>();
services.AddScoped<ILocalMediaScanner, LocalMediaScanner>();
// services.AddScoped<ILocalMediaScanner, LocalMediaScanner>();
services.AddScoped<IPlayoutBuilder, PlayoutBuilder>();
services.AddScoped<IImageCache, ImageCache>();
services.AddScoped<ILocalFileSystem, LocalFileSystem>();
services.AddScoped<ILocalMediaSourcePlanner, LocalMediaSourcePlanner>();
// services.AddScoped<ILocalMediaSourcePlanner, LocalMediaSourcePlanner>();
// services.AddScoped<ILocalMediaSourceMovieScanner, LocalMediaSourceMovieScanner>();
// services.AddScoped<ILocalMediaSourceTelevisionScanner, LocalMediaSourceTelevisionScanner>();
services.AddScoped<IMovieFolderScanner, MovieFolderScanner>();
services.AddScoped<ITelevisionFolderScanner, TelevisionFolderScanner>();
services.AddHostedService<PlexService>();
services.AddHostedService<FFmpegLocatorService>();

4
generated/ErsatzTV.Api.Sdk/.openapi-generator/FILES

@ -11,13 +11,11 @@ src/ErsatzTV.Api.Sdk.Test/Model/AddProgramScheduleItemTests.cs @@ -11,13 +11,11 @@ src/ErsatzTV.Api.Sdk.Test/Model/AddProgramScheduleItemTests.cs
src/ErsatzTV.Api.Sdk.Test/Model/ChannelViewModelTests.cs
src/ErsatzTV.Api.Sdk.Test/Model/CreateChannelTests.cs
src/ErsatzTV.Api.Sdk.Test/Model/CreateFFmpegProfileTests.cs
src/ErsatzTV.Api.Sdk.Test/Model/CreateMediaItemTests.cs
src/ErsatzTV.Api.Sdk.Test/Model/CreatePlayoutTests.cs
src/ErsatzTV.Api.Sdk.Test/Model/CreateProgramScheduleTests.cs
src/ErsatzTV.Api.Sdk.Test/Model/CreateSimpleMediaCollectionTests.cs
src/ErsatzTV.Api.Sdk.Test/Model/DeleteChannelTests.cs
src/ErsatzTV.Api.Sdk.Test/Model/DeleteFFmpegProfileTests.cs
src/ErsatzTV.Api.Sdk.Test/Model/DeleteMediaItemTests.cs
src/ErsatzTV.Api.Sdk.Test/Model/DeletePlayoutTests.cs
src/ErsatzTV.Api.Sdk.Test/Model/DeleteProgramScheduleTests.cs
src/ErsatzTV.Api.Sdk.Test/Model/FFmpegProfileViewModelTests.cs
@ -71,13 +69,11 @@ src/ErsatzTV.Api.Sdk/Model/AddProgramScheduleItem.cs @@ -71,13 +69,11 @@ src/ErsatzTV.Api.Sdk/Model/AddProgramScheduleItem.cs
src/ErsatzTV.Api.Sdk/Model/ChannelViewModel.cs
src/ErsatzTV.Api.Sdk/Model/CreateChannel.cs
src/ErsatzTV.Api.Sdk/Model/CreateFFmpegProfile.cs
src/ErsatzTV.Api.Sdk/Model/CreateMediaItem.cs
src/ErsatzTV.Api.Sdk/Model/CreatePlayout.cs
src/ErsatzTV.Api.Sdk/Model/CreateProgramSchedule.cs
src/ErsatzTV.Api.Sdk/Model/CreateSimpleMediaCollection.cs
src/ErsatzTV.Api.Sdk/Model/DeleteChannel.cs
src/ErsatzTV.Api.Sdk/Model/DeleteFFmpegProfile.cs
src/ErsatzTV.Api.Sdk/Model/DeleteMediaItem.cs
src/ErsatzTV.Api.Sdk/Model/DeletePlayout.cs
src/ErsatzTV.Api.Sdk/Model/DeleteProgramSchedule.cs
src/ErsatzTV.Api.Sdk/Model/FFmpegProfileViewModel.cs

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save