mirror of https://github.com/ErsatzTV/ErsatzTV.git
104 changed files with 3677 additions and 3278 deletions
@ -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>>; |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
@ -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>>; |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
@ -1,9 +0,0 @@
@@ -1,9 +0,0 @@
|
||||
namespace ErsatzTV.Application.MediaItems.Commands |
||||
{ |
||||
public record RefreshMediaItemCollections : RefreshMediaItem |
||||
{ |
||||
public RefreshMediaItemCollections(int mediaItemId) : base(mediaItemId) |
||||
{ |
||||
} |
||||
} |
||||
} |
||||
@ -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(); |
||||
} |
||||
} |
||||
@ -1,9 +0,0 @@
@@ -1,9 +0,0 @@
|
||||
namespace ErsatzTV.Application.MediaItems.Commands |
||||
{ |
||||
public record RefreshMediaItemMetadata : RefreshMediaItem |
||||
{ |
||||
public RefreshMediaItemMetadata(int mediaItemId) : base(mediaItemId) |
||||
{ |
||||
} |
||||
} |
||||
} |
||||
@ -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();
|
||||
} |
||||
} |
||||
@ -1,9 +0,0 @@
@@ -1,9 +0,0 @@
|
||||
namespace ErsatzTV.Application.MediaItems.Commands |
||||
{ |
||||
public record RefreshMediaItemPoster : RefreshMediaItem |
||||
{ |
||||
public RefreshMediaItemPoster(int mediaItemId) : base(mediaItemId) |
||||
{ |
||||
} |
||||
} |
||||
} |
||||
@ -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(); |
||||
} |
||||
} |
||||
@ -1,9 +0,0 @@
@@ -1,9 +0,0 @@
|
||||
namespace ErsatzTV.Application.MediaItems.Commands |
||||
{ |
||||
public record RefreshMediaItemStatistics : RefreshMediaItem |
||||
{ |
||||
public RefreshMediaItemStatistics(int mediaItemId) : base(mediaItemId) |
||||
{ |
||||
} |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
|
||||
@ -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; |
||||
} |
||||
} |
||||
} |
||||
@ -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(); |
||||
} |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -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; } |
||||
} |
||||
} |
||||
|
||||
@ -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; } |
||||
} |
||||
} |
||||
@ -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; } |
||||
} |
||||
} |
||||
@ -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; } |
||||
} |
||||
} |
||||
@ -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; } |
||||
} |
||||
} |
||||
@ -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; } |
||||
} |
||||
} |
||||
@ -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; } |
||||
} |
||||
} |
||||
@ -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; } |
||||
} |
||||
} |
||||
@ -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; } |
||||
} |
||||
} |
||||
@ -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; } |
||||
} |
||||
} |
||||
@ -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; } |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
|
||||
@ -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); |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
|
||||
@ -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); |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
@ -1,4 +0,0 @@
@@ -1,4 +0,0 @@
|
||||
namespace ErsatzTV.Core.Metadata |
||||
{ |
||||
public record ActionPlan(string TargetPath, ScanningAction TargetAction); |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
@ -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
|
||||
} |
||||
} |
||||
@ -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(); |
||||
} |
||||
} |
||||
} |
||||
@ -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 |
||||
} |
||||
} |
||||
@ -1,8 +0,0 @@
@@ -1,8 +0,0 @@
|
||||
namespace ErsatzTV.Core.Metadata |
||||
{ |
||||
public enum ScanningMode |
||||
{ |
||||
Default = 0, |
||||
RescanAll = 1 |
||||
} |
||||
} |
||||
@ -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; |
||||
} |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
} |
||||
@ -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"); |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
} |
||||
@ -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"); |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
} |
||||
@ -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"); |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -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); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
File diff suppressed because it is too large
Load Diff
@ -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); |
||||
} |
||||
} |
||||
} |
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue