rework television media (#26)

* rework television media

* refactor poster saving

* television and movie views are working again

* remove dead code

* use paper styling for all cards

* add show poster, plot to seasons page

* remove missing shows; cleanup interfaces

* fix split show display (same show in different folders/sources)

* add placeholder "add to schedule" button

* no more duplicate television shows, even with the same show split across sources

* stop releasing CLI for now

* use season number as season placeholder

* add television shows to collections

* add television seasons to collections

* add television episodes to collections

* add movies to collections

* remove movies, shows, seasons, episodes from collections

* fix page width and menus

* fix buffer size defaults

* fix chronological episode ordering

* allow deleting media collections

* don't get stuck building a playout with an empty collection

* schedule editing and playouts work again

* minor cleanup

* remove dead code

* fix bugs with viewing movies as they are loading

* add scanner tests; support nested movie folders

* update collections docs

* rearrange order of schedule items

* add show and season to schedule

* delete schedules that use legacy collections, reset all posters

* move cleanup to new migration

* load fallback metadata when nfo fails; don't require metadata in ui

* update readme and screenshots
This commit is contained in:
Jason Dove
2021-02-22 00:54:41 +00:00
committed by GitHub
parent 98cf922b3c
commit 871a031467
259 changed files with 13493 additions and 4771 deletions
@@ -1,6 +0,0 @@
using System.Collections.Generic;
namespace ErsatzTV.Application.MediaItems
{
public record AggregateMediaItemResults(int Count, List<AggregateMediaItemViewModel> DataPage);
}
@@ -1,9 +0,0 @@
namespace ErsatzTV.Application.MediaItems
{
public record AggregateMediaItemViewModel(
int MediaItemId,
string Title,
string Subtitle,
string SortTitle,
string Poster);
}
@@ -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>>;
}
@@ -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);
}
}
@@ -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>>;
}
@@ -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);
}
}
@@ -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;
}
@@ -1,9 +0,0 @@
namespace ErsatzTV.Application.MediaItems.Commands
{
public record RefreshMediaItemCollections : RefreshMediaItem
{
public RefreshMediaItemCollections(int mediaItemId) : base(mediaItemId)
{
}
}
}
@@ -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();
}
}
@@ -1,9 +0,0 @@
namespace ErsatzTV.Application.MediaItems.Commands
{
public record RefreshMediaItemMetadata : RefreshMediaItem
{
public RefreshMediaItemMetadata(int mediaItemId) : base(mediaItemId)
{
}
}
}
@@ -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();
}
}
@@ -1,9 +0,0 @@
namespace ErsatzTV.Application.MediaItems.Commands
{
public record RefreshMediaItemPoster : RefreshMediaItem
{
public RefreshMediaItemPoster(int mediaItemId) : base(mediaItemId)
{
}
}
}
@@ -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();
}
}
@@ -1,9 +0,0 @@
namespace ErsatzTV.Application.MediaItems.Commands
{
public record RefreshMediaItemStatistics : RefreshMediaItem
{
public RefreshMediaItemStatistics(int mediaItemId) : base(mediaItemId)
{
}
}
}
@@ -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);
}
}
+31 -11
View File
@@ -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
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
@@ -1,8 +0,0 @@
using ErsatzTV.Core.Domain;
using MediatR;
namespace ErsatzTV.Application.MediaItems.Queries
{
public record GetAggregateMediaItems
(MediaType MediaType, int PageNumber, int PageSize) : IRequest<AggregateMediaItemResults>;
}
@@ -1,43 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.AggregateModels;
using ErsatzTV.Core.Interfaces.Repositories;
using MediatR;
namespace ErsatzTV.Application.MediaItems.Queries
{
public class
GetAggregateMediaItemsHandler : IRequestHandler<GetAggregateMediaItems, AggregateMediaItemResults>
{
private readonly IMediaItemRepository _mediaItemRepository;
public GetAggregateMediaItemsHandler(IMediaItemRepository mediaItemRepository) =>
_mediaItemRepository = mediaItemRepository;
public async Task<AggregateMediaItemResults> Handle(
GetAggregateMediaItems request,
CancellationToken cancellationToken)
{
int count = await _mediaItemRepository.GetCountByType(request.MediaType);
IEnumerable<MediaItemSummary> allItems = await _mediaItemRepository.GetPageByType(
request.MediaType,
request.PageNumber,
request.PageSize);
var results = allItems
.Map(
s => new AggregateMediaItemViewModel(
s.MediaItemId,
s.Title,
s.Subtitle,
s.SortTitle,
s.Poster))
.ToList();
return new AggregateMediaItemResults(count, results);
}
}
}