mirror of https://github.com/ErsatzTV/ErsatzTV.git
Browse Source
* properly scope jellyfin disconnect * add emby entities * add emby media source page * add emby media source editor * sync emby libraries * enable emby library sync toggle * add emby path replacements editor * add emby movie synchronization * fix emby artwork * sync emby television * code cleanup * add jellyfin/emby address placeholder * tweak jellyfin/emby address formpull/202/head
134 changed files with 7906 additions and 674 deletions
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
using ErsatzTV.Core; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Application.Emby.Commands |
||||
{ |
||||
public record DisconnectEmby : MediatR.IRequest<Either<BaseError, Unit>>; |
||||
} |
||||
@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
|
||||
using System.Collections.Generic; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Interfaces.Emby; |
||||
using ErsatzTV.Core.Interfaces.Locking; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using ErsatzTV.Core.Interfaces.Search; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Application.Emby.Commands |
||||
{ |
||||
public class DisconnectEmbyHandler : MediatR.IRequestHandler<DisconnectEmby, Either<BaseError, Unit>> |
||||
{ |
||||
private readonly IEmbySecretStore _embySecretStore; |
||||
private readonly IEntityLocker _entityLocker; |
||||
private readonly IMediaSourceRepository _mediaSourceRepository; |
||||
private readonly ISearchIndex _searchIndex; |
||||
|
||||
public DisconnectEmbyHandler( |
||||
IMediaSourceRepository mediaSourceRepository, |
||||
IEmbySecretStore embySecretStore, |
||||
IEntityLocker entityLocker, |
||||
ISearchIndex searchIndex) |
||||
{ |
||||
_mediaSourceRepository = mediaSourceRepository; |
||||
_embySecretStore = embySecretStore; |
||||
_entityLocker = entityLocker; |
||||
_searchIndex = searchIndex; |
||||
} |
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle( |
||||
DisconnectEmby request, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
List<int> ids = await _mediaSourceRepository.DeleteAllEmby(); |
||||
await _searchIndex.RemoveItems(ids); |
||||
await _embySecretStore.DeleteAll(); |
||||
_entityLocker.UnlockRemoteMediaSource<EmbyMediaSource>(); |
||||
|
||||
return Unit.Default; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Core; |
||||
using ErsatzTV.Core.Emby; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Application.Emby.Commands |
||||
{ |
||||
public record SaveEmbySecrets(EmbySecrets Secrets) : MediatR.IRequest<Either<BaseError, Unit>>; |
||||
} |
||||
@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
|
||||
using System.Threading; |
||||
using System.Threading.Channels; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core; |
||||
using ErsatzTV.Core.Emby; |
||||
using ErsatzTV.Core.Interfaces.Emby; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Application.Emby.Commands |
||||
{ |
||||
public class SaveEmbySecretsHandler : MediatR.IRequestHandler<SaveEmbySecrets, Either<BaseError, Unit>> |
||||
{ |
||||
private readonly ChannelWriter<IEmbyBackgroundServiceRequest> _channel; |
||||
private readonly IEmbyApiClient _embyApiClient; |
||||
private readonly IEmbySecretStore _embySecretStore; |
||||
private readonly IMediaSourceRepository _mediaSourceRepository; |
||||
|
||||
public SaveEmbySecretsHandler( |
||||
IEmbySecretStore embySecretStore, |
||||
IEmbyApiClient embyApiClient, |
||||
IMediaSourceRepository mediaSourceRepository, |
||||
ChannelWriter<IEmbyBackgroundServiceRequest> channel) |
||||
{ |
||||
_embySecretStore = embySecretStore; |
||||
_embyApiClient = embyApiClient; |
||||
_mediaSourceRepository = mediaSourceRepository; |
||||
_channel = channel; |
||||
} |
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(SaveEmbySecrets request, CancellationToken cancellationToken) => |
||||
Validate(request) |
||||
.MapT(PerformSave) |
||||
.Bind(v => v.ToEitherAsync()); |
||||
|
||||
private async Task<Validation<BaseError, Parameters>> Validate(SaveEmbySecrets request) |
||||
{ |
||||
Either<BaseError, EmbyServerInformation> maybeServerInformation = await _embyApiClient |
||||
.GetServerInformation(request.Secrets.Address, request.Secrets.ApiKey); |
||||
|
||||
return maybeServerInformation.Match( |
||||
info => Validation<BaseError, Parameters>.Success(new Parameters(request.Secrets, info)), |
||||
error => error); |
||||
} |
||||
|
||||
private async Task<Unit> PerformSave(Parameters parameters) |
||||
{ |
||||
await _embySecretStore.SaveSecrets(parameters.Secrets); |
||||
await _mediaSourceRepository.UpsertEmby( |
||||
parameters.Secrets.Address, |
||||
parameters.ServerInformation.ServerName, |
||||
parameters.ServerInformation.OperatingSystem); |
||||
await _channel.WriteAsync(new SynchronizeEmbyMediaSources()); |
||||
|
||||
return Unit.Default; |
||||
} |
||||
|
||||
private record Parameters(EmbySecrets Secrets, EmbyServerInformation ServerInformation); |
||||
} |
||||
} |
||||
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Core; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Application.Emby.Commands |
||||
{ |
||||
public record SynchronizeEmbyLibraries(int EmbyMediaSourceId) : MediatR.IRequest<Either<BaseError, Unit>>, |
||||
IEmbyBackgroundServiceRequest; |
||||
} |
||||
@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
|
||||
using System.Collections.Generic; |
||||
using System.Linq; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Emby; |
||||
using ErsatzTV.Core.Interfaces.Emby; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using LanguageExt; |
||||
using Microsoft.Extensions.Logging; |
||||
using static LanguageExt.Prelude; |
||||
|
||||
namespace ErsatzTV.Application.Emby.Commands |
||||
{ |
||||
public class |
||||
SynchronizeEmbyLibrariesHandler : MediatR.IRequestHandler<SynchronizeEmbyLibraries, Either<BaseError, Unit>> |
||||
{ |
||||
private readonly IEmbyApiClient _embyApiClient; |
||||
private readonly IEmbySecretStore _embySecretStore; |
||||
private readonly ILogger<SynchronizeEmbyLibrariesHandler> _logger; |
||||
private readonly IMediaSourceRepository _mediaSourceRepository; |
||||
|
||||
public SynchronizeEmbyLibrariesHandler( |
||||
IMediaSourceRepository mediaSourceRepository, |
||||
IEmbySecretStore embySecretStore, |
||||
IEmbyApiClient embyApiClient, |
||||
ILogger<SynchronizeEmbyLibrariesHandler> logger) |
||||
{ |
||||
_mediaSourceRepository = mediaSourceRepository; |
||||
_embySecretStore = embySecretStore; |
||||
_embyApiClient = embyApiClient; |
||||
_logger = logger; |
||||
} |
||||
|
||||
public Task<Either<BaseError, Unit>> Handle( |
||||
SynchronizeEmbyLibraries request, |
||||
CancellationToken cancellationToken) => |
||||
Validate(request) |
||||
.MapT(SynchronizeLibraries) |
||||
.Bind(v => v.ToEitherAsync()); |
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate(SynchronizeEmbyLibraries request) => |
||||
MediaSourceMustExist(request) |
||||
.BindT(MediaSourceMustHaveActiveConnection) |
||||
.BindT(MediaSourceMustHaveApiKey); |
||||
|
||||
private Task<Validation<BaseError, EmbyMediaSource>> MediaSourceMustExist( |
||||
SynchronizeEmbyLibraries request) => |
||||
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId) |
||||
.Map(o => o.ToValidation<BaseError>("Emby media source does not exist.")); |
||||
|
||||
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection( |
||||
EmbyMediaSource embyMediaSource) |
||||
{ |
||||
Option<EmbyConnection> maybeConnection = embyMediaSource.Connections.HeadOrNone(); |
||||
return maybeConnection.Map(connection => new ConnectionParameters(embyMediaSource, connection)) |
||||
.ToValidation<BaseError>("Emby media source requires an active connection"); |
||||
} |
||||
|
||||
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey( |
||||
ConnectionParameters connectionParameters) |
||||
{ |
||||
EmbySecrets secrets = await _embySecretStore.ReadSecrets(); |
||||
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address) |
||||
.Filter(match => match) |
||||
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey }) |
||||
.ToValidation<BaseError>("Emby media source requires an api key"); |
||||
} |
||||
|
||||
private async Task<Unit> SynchronizeLibraries(ConnectionParameters connectionParameters) |
||||
{ |
||||
Either<BaseError, List<EmbyLibrary>> maybeLibraries = await _embyApiClient.GetLibraries( |
||||
connectionParameters.ActiveConnection.Address, |
||||
connectionParameters.ApiKey); |
||||
|
||||
await maybeLibraries.Match( |
||||
libraries => |
||||
{ |
||||
var existing = connectionParameters.EmbyMediaSource.Libraries.OfType<EmbyLibrary>() |
||||
.ToList(); |
||||
var toAdd = libraries.Filter(library => existing.All(l => l.ItemId != library.ItemId)).ToList(); |
||||
var toRemove = existing.Filter(library => libraries.All(l => l.ItemId != library.ItemId)).ToList(); |
||||
return _mediaSourceRepository.UpdateLibraries( |
||||
connectionParameters.EmbyMediaSource.Id, |
||||
toAdd, |
||||
toRemove); |
||||
}, |
||||
error => |
||||
{ |
||||
_logger.LogWarning( |
||||
"Unable to synchronize libraries from emby server {EmbyServer}: {Error}", |
||||
connectionParameters.EmbyMediaSource.ServerName, |
||||
error.Value); |
||||
|
||||
return Task.CompletedTask; |
||||
}); |
||||
|
||||
return Unit.Default; |
||||
} |
||||
|
||||
private record ConnectionParameters( |
||||
EmbyMediaSource EmbyMediaSource, |
||||
EmbyConnection ActiveConnection) |
||||
{ |
||||
public string ApiKey { get; set; } |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
|
||||
using ErsatzTV.Core; |
||||
using LanguageExt; |
||||
using MediatR; |
||||
|
||||
namespace ErsatzTV.Application.Emby.Commands |
||||
{ |
||||
public interface ISynchronizeEmbyLibraryById : IRequest<Either<BaseError, string>>, |
||||
IEmbyBackgroundServiceRequest |
||||
{ |
||||
int EmbyLibraryId { get; } |
||||
bool ForceScan { get; } |
||||
} |
||||
|
||||
public record SynchronizeEmbyLibraryByIdIfNeeded(int EmbyLibraryId) : ISynchronizeEmbyLibraryById |
||||
{ |
||||
public bool ForceScan => false; |
||||
} |
||||
|
||||
public record ForceSynchronizeEmbyLibraryById(int EmbyLibraryId) : ISynchronizeEmbyLibraryById |
||||
{ |
||||
public bool ForceScan => true; |
||||
} |
||||
} |
||||
@ -0,0 +1,172 @@
@@ -0,0 +1,172 @@
|
||||
using System; |
||||
using System.IO; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Emby; |
||||
using ErsatzTV.Core.Interfaces.Emby; |
||||
using ErsatzTV.Core.Interfaces.Locking; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using LanguageExt; |
||||
using MediatR; |
||||
using Microsoft.Extensions.Logging; |
||||
using static LanguageExt.Prelude; |
||||
using Unit = LanguageExt.Unit; |
||||
|
||||
namespace ErsatzTV.Application.Emby.Commands |
||||
{ |
||||
public class SynchronizeEmbyLibraryByIdHandler : |
||||
IRequestHandler<ForceSynchronizeEmbyLibraryById, Either<BaseError, string>>, |
||||
IRequestHandler<SynchronizeEmbyLibraryByIdIfNeeded, Either<BaseError, string>> |
||||
{ |
||||
private readonly IConfigElementRepository _configElementRepository; |
||||
private readonly IEmbyMovieLibraryScanner _embyMovieLibraryScanner; |
||||
|
||||
private readonly IEmbySecretStore _embySecretStore; |
||||
private readonly IEmbyTelevisionLibraryScanner _embyTelevisionLibraryScanner; |
||||
private readonly IEntityLocker _entityLocker; |
||||
private readonly ILibraryRepository _libraryRepository; |
||||
private readonly ILogger<SynchronizeEmbyLibraryByIdHandler> _logger; |
||||
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository; |
||||
|
||||
public SynchronizeEmbyLibraryByIdHandler( |
||||
IMediaSourceRepository mediaSourceRepository, |
||||
IEmbySecretStore embySecretStore, |
||||
IEmbyMovieLibraryScanner embyMovieLibraryScanner, |
||||
IEmbyTelevisionLibraryScanner embyTelevisionLibraryScanner, |
||||
ILibraryRepository libraryRepository, |
||||
IEntityLocker entityLocker, |
||||
IConfigElementRepository configElementRepository, |
||||
ILogger<SynchronizeEmbyLibraryByIdHandler> logger) |
||||
{ |
||||
_mediaSourceRepository = mediaSourceRepository; |
||||
_embySecretStore = embySecretStore; |
||||
_embyMovieLibraryScanner = embyMovieLibraryScanner; |
||||
_embyTelevisionLibraryScanner = embyTelevisionLibraryScanner; |
||||
_libraryRepository = libraryRepository; |
||||
_entityLocker = entityLocker; |
||||
_configElementRepository = configElementRepository; |
||||
_logger = logger; |
||||
} |
||||
|
||||
public Task<Either<BaseError, string>> Handle( |
||||
ForceSynchronizeEmbyLibraryById request, |
||||
CancellationToken cancellationToken) => Handle(request); |
||||
|
||||
public Task<Either<BaseError, string>> Handle( |
||||
SynchronizeEmbyLibraryByIdIfNeeded request, |
||||
CancellationToken cancellationToken) => Handle(request); |
||||
|
||||
private Task<Either<BaseError, string>> |
||||
Handle(ISynchronizeEmbyLibraryById request) => |
||||
Validate(request) |
||||
.MapT(parameters => Synchronize(parameters).Map(_ => parameters.Library.Name)) |
||||
.Bind(v => v.ToEitherAsync()); |
||||
|
||||
private async Task<Unit> Synchronize(RequestParameters parameters) |
||||
{ |
||||
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero); |
||||
if (parameters.ForceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6)) |
||||
{ |
||||
switch (parameters.Library.MediaKind) |
||||
{ |
||||
case LibraryMediaKind.Movies: |
||||
await _embyMovieLibraryScanner.ScanLibrary( |
||||
parameters.ConnectionParameters.ActiveConnection.Address, |
||||
parameters.ConnectionParameters.ApiKey, |
||||
parameters.Library, |
||||
parameters.FFprobePath); |
||||
break; |
||||
case LibraryMediaKind.Shows: |
||||
await _embyTelevisionLibraryScanner.ScanLibrary( |
||||
parameters.ConnectionParameters.ActiveConnection.Address, |
||||
parameters.ConnectionParameters.ApiKey, |
||||
parameters.Library, |
||||
parameters.FFprobePath); |
||||
break; |
||||
} |
||||
|
||||
parameters.Library.LastScan = DateTime.UtcNow; |
||||
await _libraryRepository.UpdateLastScan(parameters.Library); |
||||
} |
||||
else |
||||
{ |
||||
_logger.LogDebug( |
||||
"Skipping unforced scan of emby media library {Name}", |
||||
parameters.Library.Name); |
||||
} |
||||
|
||||
_entityLocker.UnlockLibrary(parameters.Library.Id); |
||||
return Unit.Default; |
||||
} |
||||
|
||||
private async Task<Validation<BaseError, RequestParameters>> Validate( |
||||
ISynchronizeEmbyLibraryById request) => |
||||
(await ValidateConnection(request), await EmbyLibraryMustExist(request), await ValidateFFprobePath()) |
||||
.Apply( |
||||
(connectionParameters, embyLibrary, ffprobePath) => new RequestParameters( |
||||
connectionParameters, |
||||
embyLibrary, |
||||
request.ForceScan, |
||||
ffprobePath |
||||
)); |
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> ValidateConnection( |
||||
ISynchronizeEmbyLibraryById request) => |
||||
EmbyMediaSourceMustExist(request) |
||||
.BindT(MediaSourceMustHaveActiveConnection) |
||||
.BindT(MediaSourceMustHaveApiKey); |
||||
|
||||
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist( |
||||
ISynchronizeEmbyLibraryById request) => |
||||
_mediaSourceRepository.GetEmbyByLibraryId(request.EmbyLibraryId) |
||||
.Map( |
||||
v => v.ToValidation<BaseError>( |
||||
$"Emby media source for library {request.EmbyLibraryId} does not exist.")); |
||||
|
||||
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection( |
||||
EmbyMediaSource embyMediaSource) |
||||
{ |
||||
Option<EmbyConnection> maybeConnection = embyMediaSource.Connections.HeadOrNone(); |
||||
return maybeConnection.Map(connection => new ConnectionParameters(embyMediaSource, connection)) |
||||
.ToValidation<BaseError>("Emby media source requires an active connection"); |
||||
} |
||||
|
||||
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey( |
||||
ConnectionParameters connectionParameters) |
||||
{ |
||||
EmbySecrets secrets = await _embySecretStore.ReadSecrets(); |
||||
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address) |
||||
.Filter(match => match) |
||||
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey }) |
||||
.ToValidation<BaseError>("Emby media source requires an api key"); |
||||
} |
||||
|
||||
private Task<Validation<BaseError, EmbyLibrary>> EmbyLibraryMustExist( |
||||
ISynchronizeEmbyLibraryById request) => |
||||
_mediaSourceRepository.GetEmbyLibrary(request.EmbyLibraryId) |
||||
.Map(v => v.ToValidation<BaseError>($"Emby library {request.EmbyLibraryId} does not exist.")); |
||||
|
||||
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( |
||||
ConnectionParameters ConnectionParameters, |
||||
EmbyLibrary Library, |
||||
bool ForceScan, |
||||
string FFprobePath); |
||||
|
||||
private record ConnectionParameters( |
||||
EmbyMediaSource EmbyMediaSource, |
||||
EmbyConnection ActiveConnection) |
||||
{ |
||||
public string ApiKey { get; set; } |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
using System.Collections.Generic; |
||||
using ErsatzTV.Core; |
||||
using ErsatzTV.Core.Domain; |
||||
using LanguageExt; |
||||
using MediatR; |
||||
|
||||
namespace ErsatzTV.Application.Emby.Commands |
||||
{ |
||||
public record SynchronizeEmbyMediaSources : IRequest<Either<BaseError, List<EmbyMediaSource>>>, |
||||
IEmbyBackgroundServiceRequest; |
||||
} |
||||
@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
|
||||
using System.Collections.Generic; |
||||
using System.Threading; |
||||
using System.Threading.Channels; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using LanguageExt; |
||||
using MediatR; |
||||
|
||||
namespace ErsatzTV.Application.Emby.Commands |
||||
{ |
||||
public class SynchronizeEmbyMediaSourcesHandler : IRequestHandler<SynchronizeEmbyMediaSources, |
||||
Either<BaseError, List<EmbyMediaSource>>> |
||||
{ |
||||
private readonly ChannelWriter<IEmbyBackgroundServiceRequest> _channel; |
||||
private readonly IMediaSourceRepository _mediaSourceRepository; |
||||
|
||||
public SynchronizeEmbyMediaSourcesHandler( |
||||
IMediaSourceRepository mediaSourceRepository, |
||||
ChannelWriter<IEmbyBackgroundServiceRequest> channel) |
||||
{ |
||||
_mediaSourceRepository = mediaSourceRepository; |
||||
_channel = channel; |
||||
} |
||||
|
||||
public async Task<Either<BaseError, List<EmbyMediaSource>>> Handle( |
||||
SynchronizeEmbyMediaSources request, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
List<EmbyMediaSource> mediaSources = await _mediaSourceRepository.GetAllEmby(); |
||||
foreach (EmbyMediaSource mediaSource in mediaSources) |
||||
{ |
||||
// await _channel.WriteAsync(new SynchronizeEmbyAdminUserId(mediaSource.Id), cancellationToken);
|
||||
await _channel.WriteAsync(new SynchronizeEmbyLibraries(mediaSource.Id), cancellationToken); |
||||
} |
||||
|
||||
return mediaSources; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
using System.Collections.Generic; |
||||
using ErsatzTV.Core; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Application.Emby.Commands |
||||
{ |
||||
public record UpdateEmbyLibraryPreferences |
||||
(List<EmbyLibraryPreference> Preferences) : MediatR.IRequest<Either<BaseError, Unit>>; |
||||
|
||||
public record EmbyLibraryPreference(int Id, bool ShouldSyncItems); |
||||
} |
||||
@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
|
||||
using System.Collections.Generic; |
||||
using System.Linq; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using ErsatzTV.Core.Interfaces.Search; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Application.Emby.Commands |
||||
{ |
||||
public class |
||||
UpdateEmbyLibraryPreferencesHandler : MediatR.IRequestHandler<UpdateEmbyLibraryPreferences, |
||||
Either<BaseError, Unit>> |
||||
{ |
||||
private readonly IMediaSourceRepository _mediaSourceRepository; |
||||
private readonly ISearchIndex _searchIndex; |
||||
|
||||
public UpdateEmbyLibraryPreferencesHandler( |
||||
IMediaSourceRepository mediaSourceRepository, |
||||
ISearchIndex searchIndex) |
||||
{ |
||||
_mediaSourceRepository = mediaSourceRepository; |
||||
_searchIndex = searchIndex; |
||||
} |
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle( |
||||
UpdateEmbyLibraryPreferences request, |
||||
CancellationToken cancellationToken) |
||||
{ |
||||
var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList(); |
||||
List<int> ids = await _mediaSourceRepository.DisableEmbyLibrarySync(toDisable); |
||||
await _searchIndex.RemoveItems(ids); |
||||
|
||||
IEnumerable<int> toEnable = request.Preferences.Filter(p => p.ShouldSyncItems).Map(p => p.Id); |
||||
await _mediaSourceRepository.EnableEmbyLibrarySync(toEnable); |
||||
|
||||
return Unit.Default; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic; |
||||
using ErsatzTV.Core; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Application.Emby.Commands |
||||
{ |
||||
public record UpdateEmbyPathReplacements( |
||||
int EmbyMediaSourceId, |
||||
List<EmbyPathReplacementItem> PathReplacements) : MediatR.IRequest<Either<BaseError, Unit>>; |
||||
|
||||
public record EmbyPathReplacementItem(int Id, string EmbyPath, string LocalPath); |
||||
} |
||||
@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
|
||||
using System.Collections.Generic; |
||||
using System.Linq; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Application.Emby.Commands |
||||
{ |
||||
public class UpdateEmbyPathReplacementsHandler : MediatR.IRequestHandler<UpdateEmbyPathReplacements, |
||||
Either<BaseError, Unit>> |
||||
{ |
||||
private readonly IMediaSourceRepository _mediaSourceRepository; |
||||
|
||||
public UpdateEmbyPathReplacementsHandler(IMediaSourceRepository mediaSourceRepository) => |
||||
_mediaSourceRepository = mediaSourceRepository; |
||||
|
||||
public Task<Either<BaseError, Unit>> Handle( |
||||
UpdateEmbyPathReplacements request, |
||||
CancellationToken cancellationToken) => |
||||
Validate(request) |
||||
.MapT(pms => MergePathReplacements(request, pms)) |
||||
.Bind(v => v.ToEitherAsync()); |
||||
|
||||
private Task<Unit> MergePathReplacements( |
||||
UpdateEmbyPathReplacements request, |
||||
EmbyMediaSource embyMediaSource) |
||||
{ |
||||
embyMediaSource.PathReplacements ??= new List<EmbyPathReplacement>(); |
||||
|
||||
var incoming = request.PathReplacements.Map(Project).ToList(); |
||||
|
||||
var toAdd = incoming.Filter(r => r.Id < 1).ToList(); |
||||
var toRemove = embyMediaSource.PathReplacements.Filter(r => incoming.All(pr => pr.Id != r.Id)).ToList(); |
||||
var toUpdate = incoming.Except(toAdd).ToList(); |
||||
|
||||
return _mediaSourceRepository.UpdatePathReplacements(embyMediaSource.Id, toAdd, toUpdate, toRemove); |
||||
} |
||||
|
||||
private static EmbyPathReplacement Project(EmbyPathReplacementItem vm) => |
||||
new() { Id = vm.Id, EmbyPath = vm.EmbyPath, LocalPath = vm.LocalPath }; |
||||
|
||||
private Task<Validation<BaseError, EmbyMediaSource>> Validate(UpdateEmbyPathReplacements request) => |
||||
EmbyMediaSourceMustExist(request); |
||||
|
||||
private Task<Validation<BaseError, EmbyMediaSource>> EmbyMediaSourceMustExist( |
||||
UpdateEmbyPathReplacements request) => |
||||
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId) |
||||
.Map( |
||||
v => v.ToValidation<BaseError>( |
||||
$"Emby media source {request.EmbyMediaSourceId} does not exist.")); |
||||
} |
||||
} |
||||
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Application.Libraries; |
||||
using ErsatzTV.Core.Domain; |
||||
|
||||
namespace ErsatzTV.Application.Emby |
||||
{ |
||||
public record EmbyLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind, bool ShouldSyncItems) |
||||
: LibraryViewModel("Emby", Id, Name, MediaKind); |
||||
} |
||||
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
using ErsatzTV.Application.MediaSources; |
||||
|
||||
namespace ErsatzTV.Application.Emby |
||||
{ |
||||
public record EmbyMediaSourceViewModel(int Id, string Name, string Address) : RemoteMediaSourceViewModel( |
||||
Id, |
||||
Name, |
||||
Address); |
||||
} |
||||
@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Application.Emby |
||||
{ |
||||
public record EmbyPathReplacementViewModel(int Id, string EmbyPath, string LocalPath); |
||||
} |
||||
@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
|
||||
namespace ErsatzTV.Application.Emby |
||||
{ |
||||
internal static class Mapper |
||||
{ |
||||
internal static EmbyMediaSourceViewModel ProjectToViewModel(EmbyMediaSource embyMediaSource) => |
||||
new( |
||||
embyMediaSource.Id, |
||||
embyMediaSource.ServerName, |
||||
embyMediaSource.Connections.HeadOrNone().Match(c => c.Address, string.Empty)); |
||||
|
||||
internal static EmbyLibraryViewModel ProjectToViewModel(EmbyLibrary library) => |
||||
new(library.Id, library.Name, library.MediaKind, library.ShouldSyncItems); |
||||
|
||||
internal static EmbyPathReplacementViewModel ProjectToViewModel(EmbyPathReplacement pathReplacement) => |
||||
new(pathReplacement.Id, pathReplacement.EmbyPath, pathReplacement.LocalPath); |
||||
} |
||||
} |
||||
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
using System.Collections.Generic; |
||||
using MediatR; |
||||
|
||||
namespace ErsatzTV.Application.Emby.Queries |
||||
{ |
||||
public record GetAllEmbyMediaSources : IRequest<List<EmbyMediaSourceViewModel>>; |
||||
} |
||||
@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
|
||||
using System.Collections.Generic; |
||||
using System.Linq; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using LanguageExt; |
||||
using MediatR; |
||||
using static ErsatzTV.Application.Emby.Mapper; |
||||
|
||||
namespace ErsatzTV.Application.Emby.Queries |
||||
{ |
||||
public class GetAllEmbyMediaSourcesHandler : IRequestHandler<GetAllEmbyMediaSources, List<EmbyMediaSourceViewModel>> |
||||
{ |
||||
private readonly IMediaSourceRepository _mediaSourceRepository; |
||||
|
||||
public GetAllEmbyMediaSourcesHandler(IMediaSourceRepository mediaSourceRepository) => |
||||
_mediaSourceRepository = mediaSourceRepository; |
||||
|
||||
public Task<List<EmbyMediaSourceViewModel>> Handle( |
||||
GetAllEmbyMediaSources request, |
||||
CancellationToken cancellationToken) => |
||||
_mediaSourceRepository.GetAllEmby().Map(list => list.Map(ProjectToViewModel).ToList()); |
||||
} |
||||
} |
||||
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
using System.Collections.Generic; |
||||
using MediatR; |
||||
|
||||
namespace ErsatzTV.Application.Emby.Queries |
||||
{ |
||||
public record GetEmbyLibrariesBySourceId(int EmbyMediaSourceId) : IRequest<List<EmbyLibraryViewModel>>; |
||||
} |
||||
@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Generic; |
||||
using System.Linq; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using LanguageExt; |
||||
using MediatR; |
||||
using static ErsatzTV.Application.Emby.Mapper; |
||||
|
||||
namespace ErsatzTV.Application.Emby.Queries |
||||
{ |
||||
public class |
||||
GetEmbyLibrariesBySourceIdHandler : IRequestHandler<GetEmbyLibrariesBySourceId, List<EmbyLibraryViewModel>> |
||||
{ |
||||
private readonly IMediaSourceRepository _mediaSourceRepository; |
||||
|
||||
public GetEmbyLibrariesBySourceIdHandler(IMediaSourceRepository mediaSourceRepository) => |
||||
_mediaSourceRepository = mediaSourceRepository; |
||||
|
||||
public Task<List<EmbyLibraryViewModel>> Handle( |
||||
GetEmbyLibrariesBySourceId request, |
||||
CancellationToken cancellationToken) => |
||||
_mediaSourceRepository.GetEmbyLibraries(request.EmbyMediaSourceId) |
||||
.Map(list => list.Map(ProjectToViewModel).ToList()); |
||||
} |
||||
} |
||||
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
using LanguageExt; |
||||
using MediatR; |
||||
|
||||
namespace ErsatzTV.Application.Emby.Queries |
||||
{ |
||||
public record GetEmbyMediaSourceById(int EmbyMediaSourceId) : IRequest<Option<EmbyMediaSourceViewModel>>; |
||||
} |
||||
@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
|
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using LanguageExt; |
||||
using MediatR; |
||||
using static ErsatzTV.Application.Emby.Mapper; |
||||
|
||||
namespace ErsatzTV.Application.Emby.Queries |
||||
{ |
||||
public class |
||||
GetEmbyMediaSourceByIdHandler : IRequestHandler<GetEmbyMediaSourceById, Option<EmbyMediaSourceViewModel>> |
||||
{ |
||||
private readonly IMediaSourceRepository _mediaSourceRepository; |
||||
|
||||
public GetEmbyMediaSourceByIdHandler(IMediaSourceRepository mediaSourceRepository) => |
||||
_mediaSourceRepository = mediaSourceRepository; |
||||
|
||||
public Task<Option<EmbyMediaSourceViewModel>> Handle( |
||||
GetEmbyMediaSourceById request, |
||||
CancellationToken cancellationToken) => |
||||
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId).MapT(ProjectToViewModel); |
||||
} |
||||
} |
||||
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
using System.Collections.Generic; |
||||
using MediatR; |
||||
|
||||
namespace ErsatzTV.Application.Emby.Queries |
||||
{ |
||||
public record GetEmbyPathReplacementsBySourceId |
||||
(int EmbyMediaSourceId) : IRequest<List<EmbyPathReplacementViewModel>>; |
||||
} |
||||
@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Generic; |
||||
using System.Linq; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using LanguageExt; |
||||
using MediatR; |
||||
using static ErsatzTV.Application.Emby.Mapper; |
||||
|
||||
namespace ErsatzTV.Application.Emby.Queries |
||||
{ |
||||
public class GetEmbyPathReplacementsBySourceIdHandler : IRequestHandler<GetEmbyPathReplacementsBySourceId, |
||||
List<EmbyPathReplacementViewModel>> |
||||
{ |
||||
private readonly IMediaSourceRepository _mediaSourceRepository; |
||||
|
||||
public GetEmbyPathReplacementsBySourceIdHandler(IMediaSourceRepository mediaSourceRepository) => |
||||
_mediaSourceRepository = mediaSourceRepository; |
||||
|
||||
public Task<List<EmbyPathReplacementViewModel>> Handle( |
||||
GetEmbyPathReplacementsBySourceId request, |
||||
CancellationToken cancellationToken) => |
||||
_mediaSourceRepository.GetEmbyPathReplacements(request.EmbyMediaSourceId) |
||||
.Map(list => list.Map(ProjectToViewModel).ToList()); |
||||
} |
||||
} |
||||
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
using ErsatzTV.Core.Emby; |
||||
using MediatR; |
||||
|
||||
namespace ErsatzTV.Application.Emby.Queries |
||||
{ |
||||
public record GetEmbySecrets : IRequest<EmbySecrets>; |
||||
} |
||||
@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
|
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core.Emby; |
||||
using ErsatzTV.Core.Interfaces.Emby; |
||||
using MediatR; |
||||
|
||||
namespace ErsatzTV.Application.Emby.Queries |
||||
{ |
||||
public class GetEmbySecretsHandler : IRequestHandler<GetEmbySecrets, EmbySecrets> |
||||
{ |
||||
private readonly IEmbySecretStore _embySecretStore; |
||||
|
||||
public GetEmbySecretsHandler(IEmbySecretStore embySecretStore) => |
||||
_embySecretStore = embySecretStore; |
||||
|
||||
public Task<EmbySecrets> Handle(GetEmbySecrets request, CancellationToken cancellationToken) => |
||||
_embySecretStore.ReadSecrets(); |
||||
} |
||||
} |
||||
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Application |
||||
{ |
||||
public interface IEmbyBackgroundServiceRequest |
||||
{ |
||||
} |
||||
} |
||||
@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Application.MediaSources |
||||
{ |
||||
public record RemoteMediaSourceViewModel(int Id, string Name, string Address) : MediaSourceViewModel(Id, Name); |
||||
} |
||||
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain |
||||
{ |
||||
public class EmbyLibrary : Library |
||||
{ |
||||
public string ItemId { get; set; } |
||||
public bool ShouldSyncItems { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
using System.Diagnostics; |
||||
|
||||
namespace ErsatzTV.Core.Domain |
||||
{ |
||||
[DebuggerDisplay("{EpisodeMetadata[0].Title}")]
|
||||
public class EmbyEpisode : Episode |
||||
{ |
||||
public string ItemId { get; set; } |
||||
public string Etag { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain |
||||
{ |
||||
public class EmbyMovie : Movie |
||||
{ |
||||
public string ItemId { get; set; } |
||||
public string Etag { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain |
||||
{ |
||||
public class EmbySeason : Season |
||||
{ |
||||
public string ItemId { get; set; } |
||||
public string Etag { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain |
||||
{ |
||||
public class EmbyShow : Show |
||||
{ |
||||
public string ItemId { get; set; } |
||||
public string Etag { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
|
||||
namespace ErsatzTV.Core.Domain |
||||
{ |
||||
public class EmbyConnection |
||||
{ |
||||
public int Id { get; set; } |
||||
public string Address { get; set; } |
||||
public int EmbyMediaSourceId { get; set; } |
||||
public EmbyMediaSource EmbyMediaSource { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic; |
||||
|
||||
namespace ErsatzTV.Core.Domain |
||||
{ |
||||
public class EmbyMediaSource : MediaSource |
||||
{ |
||||
public string ServerName { get; set; } |
||||
public string OperatingSystem { get; set; } |
||||
public List<EmbyConnection> Connections { get; set; } |
||||
public List<EmbyPathReplacement> PathReplacements { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
namespace ErsatzTV.Core.Domain |
||||
{ |
||||
public class EmbyPathReplacement |
||||
{ |
||||
public int Id { get; set; } |
||||
public string EmbyPath { get; set; } |
||||
public string LocalPath { get; set; } |
||||
public int EmbyMediaSourceId { get; set; } |
||||
public EmbyMediaSource EmbyMediaSource { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Emby |
||||
{ |
||||
public class EmbyItemEtag |
||||
{ |
||||
public string ItemId { get; set; } |
||||
public string Etag { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,230 @@
@@ -0,0 +1,230 @@
|
||||
using System; |
||||
using System.Collections.Generic; |
||||
using System.Linq; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Interfaces.Emby; |
||||
using ErsatzTV.Core.Interfaces.Metadata; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using ErsatzTV.Core.Interfaces.Search; |
||||
using ErsatzTV.Core.Metadata; |
||||
using LanguageExt; |
||||
using LanguageExt.UnsafeValueAccess; |
||||
using MediatR; |
||||
using Microsoft.Extensions.Logging; |
||||
using Unit = LanguageExt.Unit; |
||||
|
||||
namespace ErsatzTV.Core.Emby |
||||
{ |
||||
public class EmbyMovieLibraryScanner : IEmbyMovieLibraryScanner |
||||
{ |
||||
private readonly IEmbyApiClient _embyApiClient; |
||||
private readonly ILocalFileSystem _localFileSystem; |
||||
private readonly ILocalStatisticsProvider _localStatisticsProvider; |
||||
private readonly ILogger<EmbyMovieLibraryScanner> _logger; |
||||
private readonly IMediaSourceRepository _mediaSourceRepository; |
||||
private readonly IMediator _mediator; |
||||
private readonly IMovieRepository _movieRepository; |
||||
private readonly IEmbyPathReplacementService _pathReplacementService; |
||||
private readonly ISearchIndex _searchIndex; |
||||
private readonly ISearchRepository _searchRepository; |
||||
|
||||
public EmbyMovieLibraryScanner( |
||||
IEmbyApiClient embyApiClient, |
||||
ISearchIndex searchIndex, |
||||
IMediator mediator, |
||||
IMovieRepository movieRepository, |
||||
ISearchRepository searchRepository, |
||||
IEmbyPathReplacementService pathReplacementService, |
||||
IMediaSourceRepository mediaSourceRepository, |
||||
ILocalFileSystem localFileSystem, |
||||
ILocalStatisticsProvider localStatisticsProvider, |
||||
ILogger<EmbyMovieLibraryScanner> logger) |
||||
{ |
||||
_embyApiClient = embyApiClient; |
||||
_searchIndex = searchIndex; |
||||
_mediator = mediator; |
||||
_movieRepository = movieRepository; |
||||
_searchRepository = searchRepository; |
||||
_pathReplacementService = pathReplacementService; |
||||
_mediaSourceRepository = mediaSourceRepository; |
||||
_localFileSystem = localFileSystem; |
||||
_localStatisticsProvider = localStatisticsProvider; |
||||
_logger = logger; |
||||
} |
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanLibrary( |
||||
string address, |
||||
string apiKey, |
||||
EmbyLibrary library, |
||||
string ffprobePath) |
||||
{ |
||||
List<EmbyItemEtag> existingMovies = await _movieRepository.GetExistingEmbyMovies(library); |
||||
|
||||
// TODO: maybe get quick list of item ids and etags from api to compare first
|
||||
// TODO: paging?
|
||||
|
||||
List<EmbyPathReplacement> pathReplacements = await _mediaSourceRepository |
||||
.GetEmbyPathReplacements(library.MediaSourceId); |
||||
|
||||
Either<BaseError, List<EmbyMovie>> maybeMovies = await _embyApiClient.GetMovieLibraryItems( |
||||
address, |
||||
apiKey, |
||||
library.MediaSourceId, |
||||
library.ItemId); |
||||
|
||||
await maybeMovies.Match( |
||||
async movies => |
||||
{ |
||||
var validMovies = new List<EmbyMovie>(); |
||||
foreach (EmbyMovie movie in movies.OrderBy(m => m.MovieMetadata.Head().Title)) |
||||
{ |
||||
string localPath = _pathReplacementService.GetReplacementEmbyPath( |
||||
pathReplacements, |
||||
movie.MediaVersions.Head().MediaFiles.Head().Path, |
||||
false); |
||||
|
||||
if (!_localFileSystem.FileExists(localPath)) |
||||
{ |
||||
_logger.LogWarning("Skipping emby movie that does not exist at {Path}", localPath); |
||||
} |
||||
else |
||||
{ |
||||
validMovies.Add(movie); |
||||
} |
||||
} |
||||
|
||||
foreach (EmbyMovie incoming in validMovies) |
||||
{ |
||||
decimal percentCompletion = (decimal) validMovies.IndexOf(incoming) / validMovies.Count; |
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion)); |
||||
|
||||
Option<EmbyItemEtag> maybeExisting = |
||||
existingMovies.Find(ie => ie.ItemId == incoming.ItemId); |
||||
|
||||
var updateStatistics = false; |
||||
|
||||
await maybeExisting.Match( |
||||
async existing => |
||||
{ |
||||
try |
||||
{ |
||||
if (existing.Etag == incoming.Etag) |
||||
{ |
||||
// _logger.LogDebug(
|
||||
// $"NOOP: Etag has not changed for movie {incoming.MovieMetadata.Head().Title}");
|
||||
return; |
||||
} |
||||
|
||||
_logger.LogDebug( |
||||
"UPDATE: Etag has changed for movie {Movie}", |
||||
incoming.MovieMetadata.Head().Title); |
||||
|
||||
updateStatistics = true; |
||||
incoming.LibraryPathId = library.Paths.Head().Id; |
||||
Option<EmbyMovie> updated = await _movieRepository.UpdateEmby(incoming); |
||||
if (updated.IsSome) |
||||
{ |
||||
await _searchIndex.UpdateItems( |
||||
_searchRepository, |
||||
new List<MediaItem> { updated.ValueUnsafe() }); |
||||
} |
||||
} |
||||
catch (Exception ex) |
||||
{ |
||||
updateStatistics = false; |
||||
_logger.LogError( |
||||
ex, |
||||
"Error updating movie {Movie}", |
||||
incoming.MovieMetadata.Head().Title); |
||||
} |
||||
}, |
||||
async () => |
||||
{ |
||||
try |
||||
{ |
||||
// _logger.LogDebug(
|
||||
// $"INSERT: Item id is new for movie {incoming.MovieMetadata.Head().Title}");
|
||||
|
||||
updateStatistics = true; |
||||
incoming.LibraryPathId = library.Paths.Head().Id; |
||||
if (await _movieRepository.AddEmby(incoming)) |
||||
{ |
||||
await _searchIndex.AddItems( |
||||
_searchRepository, |
||||
new List<MediaItem> { incoming }); |
||||
} |
||||
} |
||||
catch (Exception ex) |
||||
{ |
||||
updateStatistics = false; |
||||
_logger.LogError( |
||||
ex, |
||||
"Error adding movie {Movie}", |
||||
incoming.MovieMetadata.Head().Title); |
||||
} |
||||
}); |
||||
|
||||
if (updateStatistics) |
||||
{ |
||||
string localPath = _pathReplacementService.GetReplacementEmbyPath( |
||||
pathReplacements, |
||||
incoming.MediaVersions.Head().MediaFiles.Head().Path, |
||||
false); |
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath); |
||||
Either<BaseError, bool> refreshResult = |
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath); |
||||
|
||||
await refreshResult.Match( |
||||
async _ => |
||||
{ |
||||
Option<MediaItem> updated = await _searchRepository.GetItemToIndex(incoming.Id); |
||||
if (updated.IsSome) |
||||
{ |
||||
await _searchIndex.UpdateItems( |
||||
_searchRepository, |
||||
new List<MediaItem> { updated.ValueUnsafe() }); |
||||
} |
||||
}, |
||||
error => |
||||
{ |
||||
_logger.LogWarning( |
||||
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}", |
||||
"Statistics", |
||||
localPath, |
||||
error.Value); |
||||
|
||||
return Task.CompletedTask; |
||||
}); |
||||
} |
||||
|
||||
// TODO: figure out how to rebuild playlists
|
||||
} |
||||
|
||||
var incomingMovieIds = validMovies.Map(s => s.ItemId).ToList(); |
||||
var movieIds = existingMovies |
||||
.Filter(i => !incomingMovieIds.Contains(i.ItemId)) |
||||
.Map(m => m.ItemId) |
||||
.ToList(); |
||||
List<int> ids = await _movieRepository.RemoveMissingEmbyMovies(library, movieIds); |
||||
await _searchIndex.RemoveItems(ids); |
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, 0)); |
||||
_searchIndex.Commit(); |
||||
}, |
||||
error => |
||||
{ |
||||
_logger.LogWarning( |
||||
"Error synchronizing emby library {Path}: {Error}", |
||||
library.Name, |
||||
error.Value); |
||||
|
||||
return Task.CompletedTask; |
||||
}); |
||||
|
||||
_searchIndex.Commit(); |
||||
return Unit.Default; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
|
||||
using System.Collections.Generic; |
||||
using System.Linq; |
||||
using System.Runtime.InteropServices; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Interfaces.Emby; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using ErsatzTV.Core.Interfaces.Runtime; |
||||
using LanguageExt; |
||||
using Microsoft.Extensions.Logging; |
||||
|
||||
namespace ErsatzTV.Core.Emby |
||||
{ |
||||
public class EmbyPathReplacementService : IEmbyPathReplacementService |
||||
{ |
||||
private readonly ILogger<EmbyPathReplacementService> _logger; |
||||
private readonly IMediaSourceRepository _mediaSourceRepository; |
||||
private readonly IRuntimeInfo _runtimeInfo; |
||||
|
||||
public EmbyPathReplacementService( |
||||
IMediaSourceRepository mediaSourceRepository, |
||||
IRuntimeInfo runtimeInfo, |
||||
ILogger<EmbyPathReplacementService> logger) |
||||
{ |
||||
_mediaSourceRepository = mediaSourceRepository; |
||||
_runtimeInfo = runtimeInfo; |
||||
_logger = logger; |
||||
} |
||||
|
||||
public async Task<string> GetReplacementEmbyPath(int libraryPathId, string path) |
||||
{ |
||||
List<EmbyPathReplacement> replacements = |
||||
await _mediaSourceRepository.GetEmbyPathReplacementsByLibraryId(libraryPathId); |
||||
|
||||
return GetReplacementEmbyPath(replacements, path); |
||||
} |
||||
|
||||
public string GetReplacementEmbyPath( |
||||
List<EmbyPathReplacement> pathReplacements, |
||||
string path, |
||||
bool log = true) |
||||
{ |
||||
Option<EmbyPathReplacement> maybeReplacement = pathReplacements |
||||
.SingleOrDefault( |
||||
r => |
||||
{ |
||||
string separatorChar = IsWindows(r.EmbyMediaSource) ? @"\" : @"/"; |
||||
string prefix = r.EmbyPath.EndsWith(separatorChar) |
||||
? r.EmbyPath |
||||
: r.EmbyPath + separatorChar; |
||||
return path.StartsWith(prefix); |
||||
}); |
||||
|
||||
return maybeReplacement.Match( |
||||
replacement => |
||||
{ |
||||
string finalPath = path.Replace(replacement.EmbyPath, replacement.LocalPath); |
||||
if (IsWindows(replacement.EmbyMediaSource) && !_runtimeInfo.IsOSPlatform(OSPlatform.Windows)) |
||||
{ |
||||
finalPath = finalPath.Replace(@"\", @"/"); |
||||
} |
||||
else if (!IsWindows(replacement.EmbyMediaSource) && |
||||
_runtimeInfo.IsOSPlatform(OSPlatform.Windows)) |
||||
{ |
||||
finalPath = finalPath.Replace(@"/", @"\"); |
||||
} |
||||
|
||||
if (log) |
||||
{ |
||||
_logger.LogDebug( |
||||
"Replacing emby path {EmbyPath} with {LocalPath} resulting in {FinalPath}", |
||||
replacement.EmbyPath, |
||||
replacement.LocalPath, |
||||
finalPath); |
||||
} |
||||
|
||||
return finalPath; |
||||
}, |
||||
() => path); |
||||
} |
||||
|
||||
private static bool IsWindows(EmbyMediaSource embyMediaSource) => |
||||
embyMediaSource.OperatingSystem.ToLowerInvariant().StartsWith("windows"); |
||||
} |
||||
} |
||||
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Core.MediaSources; |
||||
|
||||
namespace ErsatzTV.Core.Emby |
||||
{ |
||||
public class EmbySecrets : RemoteMediaSourceSecrets |
||||
{ |
||||
} |
||||
} |
||||
@ -0,0 +1,4 @@
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Core.Emby |
||||
{ |
||||
public record EmbyServerInformation(string ServerName, string OperatingSystem); |
||||
} |
||||
@ -0,0 +1,416 @@
@@ -0,0 +1,416 @@
|
||||
using System; |
||||
using System.Collections.Generic; |
||||
using System.Linq; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Interfaces.Emby; |
||||
using ErsatzTV.Core.Interfaces.Metadata; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using ErsatzTV.Core.Interfaces.Search; |
||||
using ErsatzTV.Core.Metadata; |
||||
using LanguageExt; |
||||
using LanguageExt.UnsafeValueAccess; |
||||
using MediatR; |
||||
using Microsoft.Extensions.Logging; |
||||
using Unit = LanguageExt.Unit; |
||||
|
||||
namespace ErsatzTV.Core.Emby |
||||
{ |
||||
public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner |
||||
{ |
||||
private readonly IEmbyApiClient _embyApiClient; |
||||
private readonly ILocalFileSystem _localFileSystem; |
||||
private readonly ILocalStatisticsProvider _localStatisticsProvider; |
||||
private readonly ILogger<EmbyTelevisionLibraryScanner> _logger; |
||||
private readonly IMediaSourceRepository _mediaSourceRepository; |
||||
private readonly IMediator _mediator; |
||||
private readonly IEmbyPathReplacementService _pathReplacementService; |
||||
private readonly ISearchIndex _searchIndex; |
||||
private readonly ISearchRepository _searchRepository; |
||||
private readonly IEmbyTelevisionRepository _televisionRepository; |
||||
|
||||
public EmbyTelevisionLibraryScanner( |
||||
IEmbyApiClient embyApiClient, |
||||
IMediaSourceRepository mediaSourceRepository, |
||||
IEmbyTelevisionRepository televisionRepository, |
||||
ISearchIndex searchIndex, |
||||
ISearchRepository searchRepository, |
||||
IEmbyPathReplacementService pathReplacementService, |
||||
ILocalFileSystem localFileSystem, |
||||
ILocalStatisticsProvider localStatisticsProvider, |
||||
IMediator mediator, |
||||
ILogger<EmbyTelevisionLibraryScanner> logger) |
||||
{ |
||||
_embyApiClient = embyApiClient; |
||||
_mediaSourceRepository = mediaSourceRepository; |
||||
_televisionRepository = televisionRepository; |
||||
_searchIndex = searchIndex; |
||||
_searchRepository = searchRepository; |
||||
_pathReplacementService = pathReplacementService; |
||||
_localFileSystem = localFileSystem; |
||||
_localStatisticsProvider = localStatisticsProvider; |
||||
_mediator = mediator; |
||||
_logger = logger; |
||||
} |
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanLibrary( |
||||
string address, |
||||
string apiKey, |
||||
EmbyLibrary library, |
||||
string ffprobePath) |
||||
{ |
||||
List<EmbyItemEtag> existingShows = await _televisionRepository.GetExistingShows(library); |
||||
|
||||
// TODO: maybe get quick list of item ids and etags from api to compare first
|
||||
// TODO: paging?
|
||||
|
||||
List<EmbyPathReplacement> pathReplacements = await _mediaSourceRepository |
||||
.GetEmbyPathReplacements(library.MediaSourceId); |
||||
|
||||
Either<BaseError, List<EmbyShow>> maybeShows = await _embyApiClient.GetShowLibraryItems( |
||||
address, |
||||
apiKey, |
||||
library.MediaSourceId, |
||||
library.ItemId); |
||||
|
||||
await maybeShows.Match( |
||||
async shows => |
||||
{ |
||||
await ProcessShows(address, apiKey, library, ffprobePath, pathReplacements, existingShows, shows); |
||||
|
||||
var incomingShowIds = shows.Map(s => s.ItemId).ToList(); |
||||
var showIds = existingShows |
||||
.Filter(i => !incomingShowIds.Contains(i.ItemId)) |
||||
.Map(m => m.ItemId) |
||||
.ToList(); |
||||
List<int> missingShowIds = await _televisionRepository.RemoveMissingShows(library, showIds); |
||||
await _searchIndex.RemoveItems(missingShowIds); |
||||
|
||||
await _televisionRepository.DeleteEmptySeasons(library); |
||||
List<int> emptyShowIds = await _televisionRepository.DeleteEmptyShows(library); |
||||
await _searchIndex.RemoveItems(emptyShowIds); |
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, 0)); |
||||
_searchIndex.Commit(); |
||||
}, |
||||
error => |
||||
{ |
||||
_logger.LogWarning( |
||||
"Error synchronizing emby library {Path}: {Error}", |
||||
library.Name, |
||||
error.Value); |
||||
|
||||
return Task.CompletedTask; |
||||
}); |
||||
|
||||
return Unit.Default; |
||||
} |
||||
|
||||
private async Task ProcessShows( |
||||
string address, |
||||
string apiKey, |
||||
EmbyLibrary library, |
||||
string ffprobePath, |
||||
List<EmbyPathReplacement> pathReplacements, |
||||
List<EmbyItemEtag> existingShows, |
||||
List<EmbyShow> shows) |
||||
{ |
||||
foreach (EmbyShow incoming in shows.OrderBy(s => s.ShowMetadata.Head().Title)) |
||||
{ |
||||
decimal percentCompletion = (decimal) shows.IndexOf(incoming) / shows.Count; |
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion)); |
||||
|
||||
var changed = false; |
||||
|
||||
Option<EmbyItemEtag> maybeExisting = existingShows.Find(ie => ie.ItemId == incoming.ItemId); |
||||
await maybeExisting.Match( |
||||
async existing => |
||||
{ |
||||
if (existing.Etag == incoming.Etag) |
||||
{ |
||||
return; |
||||
} |
||||
|
||||
_logger.LogDebug( |
||||
"UPDATE: Etag has changed for show {Show}", |
||||
incoming.ShowMetadata.Head().Title); |
||||
|
||||
changed = true; |
||||
incoming.LibraryPathId = library.Paths.Head().Id; |
||||
|
||||
Option<EmbyShow> updated = await _televisionRepository.Update(incoming); |
||||
if (updated.IsSome) |
||||
{ |
||||
await _searchIndex.UpdateItems( |
||||
_searchRepository, |
||||
new List<MediaItem> { updated.ValueUnsafe() }); |
||||
} |
||||
}, |
||||
async () => |
||||
{ |
||||
changed = true; |
||||
incoming.LibraryPathId = library.Paths.Head().Id; |
||||
|
||||
// _logger.LogDebug("INSERT: Item id is new for show {Show}", incoming.ShowMetadata.Head().Title);
|
||||
|
||||
if (await _televisionRepository.AddShow(incoming)) |
||||
{ |
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming }); |
||||
} |
||||
}); |
||||
|
||||
if (changed) |
||||
{ |
||||
List<EmbyItemEtag> existingSeasons = |
||||
await _televisionRepository.GetExistingSeasons(library, incoming.ItemId); |
||||
|
||||
Either<BaseError, List<EmbySeason>> maybeSeasons = |
||||
await _embyApiClient.GetSeasonLibraryItems( |
||||
address, |
||||
apiKey, |
||||
library.MediaSourceId, |
||||
incoming.ItemId); |
||||
|
||||
await maybeSeasons.Match( |
||||
async seasons => |
||||
{ |
||||
await ProcessSeasons( |
||||
address, |
||||
apiKey, |
||||
library, |
||||
ffprobePath, |
||||
pathReplacements, |
||||
incoming, |
||||
existingSeasons, |
||||
seasons); |
||||
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { incoming }); |
||||
|
||||
var incomingSeasonIds = seasons.Map(s => s.ItemId).ToList(); |
||||
var seasonIds = existingSeasons |
||||
.Filter(i => !incomingSeasonIds.Contains(i.ItemId)) |
||||
.Map(m => m.ItemId) |
||||
.ToList(); |
||||
await _televisionRepository.RemoveMissingSeasons(library, seasonIds); |
||||
}, |
||||
error => |
||||
{ |
||||
_logger.LogWarning( |
||||
"Error synchronizing emby library {Path}: {Error}", |
||||
library.Name, |
||||
error.Value); |
||||
|
||||
return Task.CompletedTask; |
||||
}); |
||||
} |
||||
} |
||||
} |
||||
|
||||
private async Task ProcessSeasons( |
||||
string address, |
||||
string apiKey, |
||||
EmbyLibrary library, |
||||
string ffprobePath, |
||||
List<EmbyPathReplacement> pathReplacements, |
||||
EmbyShow show, |
||||
List<EmbyItemEtag> existingSeasons, |
||||
List<EmbySeason> seasons) |
||||
{ |
||||
foreach (EmbySeason incoming in seasons) |
||||
{ |
||||
var changed = false; |
||||
|
||||
Option<EmbyItemEtag> maybeExisting = existingSeasons.Find(ie => ie.ItemId == incoming.ItemId); |
||||
await maybeExisting.Match( |
||||
async existing => |
||||
{ |
||||
if (existing.Etag == incoming.Etag) |
||||
{ |
||||
return; |
||||
} |
||||
|
||||
_logger.LogDebug( |
||||
"UPDATE: Etag has changed for show {Show} season {Season}", |
||||
show.ShowMetadata.Head().Title, |
||||
incoming.SeasonMetadata.Head().Title); |
||||
|
||||
changed = true; |
||||
incoming.ShowId = show.Id; |
||||
incoming.LibraryPathId = library.Paths.Head().Id; |
||||
|
||||
await _televisionRepository.Update(incoming); |
||||
}, |
||||
async () => |
||||
{ |
||||
changed = true; |
||||
incoming.ShowId = show.Id; |
||||
incoming.LibraryPathId = library.Paths.Head().Id; |
||||
|
||||
_logger.LogDebug( |
||||
"INSERT: Item id is new for show {Show} season {Season}", |
||||
show.ShowMetadata.Head().Title, |
||||
incoming.SeasonMetadata.Head().Title); |
||||
|
||||
await _televisionRepository.AddSeason(incoming); |
||||
}); |
||||
|
||||
if (changed) |
||||
{ |
||||
List<EmbyItemEtag> existingEpisodes = |
||||
await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId); |
||||
|
||||
Either<BaseError, List<EmbyEpisode>> maybeEpisodes = |
||||
await _embyApiClient.GetEpisodeLibraryItems( |
||||
address, |
||||
apiKey, |
||||
library.MediaSourceId, |
||||
incoming.ItemId); |
||||
|
||||
await maybeEpisodes.Match( |
||||
async episodes => |
||||
{ |
||||
var validEpisodes = new List<EmbyEpisode>(); |
||||
foreach (EmbyEpisode episode in episodes) |
||||
{ |
||||
string localPath = _pathReplacementService.GetReplacementEmbyPath( |
||||
pathReplacements, |
||||
episode.MediaVersions.Head().MediaFiles.Head().Path, |
||||
false); |
||||
|
||||
if (!_localFileSystem.FileExists(localPath)) |
||||
{ |
||||
_logger.LogWarning( |
||||
"Skipping emby episode that does not exist at {Path}", |
||||
localPath); |
||||
} |
||||
else |
||||
{ |
||||
validEpisodes.Add(episode); |
||||
} |
||||
} |
||||
|
||||
await ProcessEpisodes( |
||||
show.ShowMetadata.Head().Title, |
||||
incoming.SeasonMetadata.Head().Title, |
||||
library, |
||||
ffprobePath, |
||||
pathReplacements, |
||||
incoming, |
||||
existingEpisodes, |
||||
validEpisodes); |
||||
|
||||
var incomingEpisodeIds = episodes.Map(s => s.ItemId).ToList(); |
||||
var episodeIds = existingEpisodes |
||||
.Filter(i => !incomingEpisodeIds.Contains(i.ItemId)) |
||||
.Map(m => m.ItemId) |
||||
.ToList(); |
||||
await _televisionRepository.RemoveMissingEpisodes(library, episodeIds); |
||||
}, |
||||
error => |
||||
{ |
||||
_logger.LogWarning( |
||||
"Error synchronizing emby library {Path}: {Error}", |
||||
library.Name, |
||||
error.Value); |
||||
|
||||
return Task.CompletedTask; |
||||
}); |
||||
} |
||||
} |
||||
} |
||||
|
||||
private async Task ProcessEpisodes( |
||||
string showName, |
||||
string seasonName, |
||||
EmbyLibrary library, |
||||
string ffprobePath, |
||||
List<EmbyPathReplacement> pathReplacements, |
||||
EmbySeason season, |
||||
List<EmbyItemEtag> existingEpisodes, |
||||
List<EmbyEpisode> episodes) |
||||
{ |
||||
foreach (EmbyEpisode incoming in episodes) |
||||
{ |
||||
var updateStatistics = false; |
||||
|
||||
Option<EmbyItemEtag> maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId); |
||||
await maybeExisting.Match( |
||||
async existing => |
||||
{ |
||||
try |
||||
{ |
||||
if (existing.Etag == incoming.Etag) |
||||
{ |
||||
return; |
||||
} |
||||
|
||||
_logger.LogDebug( |
||||
"UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}", |
||||
showName, |
||||
seasonName, |
||||
incoming.EpisodeNumber); |
||||
|
||||
updateStatistics = true; |
||||
incoming.SeasonId = season.Id; |
||||
incoming.LibraryPathId = library.Paths.Head().Id; |
||||
|
||||
await _televisionRepository.Update(incoming); |
||||
} |
||||
catch (Exception ex) |
||||
{ |
||||
updateStatistics = false; |
||||
_logger.LogError( |
||||
ex, |
||||
"Error updating episode {Path}", |
||||
incoming.MediaVersions.Head().MediaFiles.Head().Path); |
||||
} |
||||
}, |
||||
async () => |
||||
{ |
||||
try |
||||
{ |
||||
updateStatistics = true; |
||||
incoming.SeasonId = season.Id; |
||||
incoming.LibraryPathId = library.Paths.Head().Id; |
||||
|
||||
_logger.LogDebug( |
||||
"INSERT: Item id is new for show {Show} season {Season} episode {Episode}", |
||||
showName, |
||||
seasonName, |
||||
incoming.EpisodeNumber); |
||||
|
||||
await _televisionRepository.AddEpisode(incoming); |
||||
} |
||||
catch (Exception ex) |
||||
{ |
||||
updateStatistics = false; |
||||
_logger.LogError( |
||||
ex, |
||||
"Error adding episode {Path}", |
||||
incoming.MediaVersions.Head().MediaFiles.Head().Path); |
||||
} |
||||
}); |
||||
|
||||
if (updateStatistics) |
||||
{ |
||||
string localPath = _pathReplacementService.GetReplacementEmbyPath( |
||||
pathReplacements, |
||||
incoming.MediaVersions.Head().MediaFiles.Head().Path, |
||||
false); |
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath); |
||||
Either<BaseError, bool> refreshResult = |
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath); |
||||
|
||||
refreshResult.Match( |
||||
_ => { }, |
||||
error => _logger.LogWarning( |
||||
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}", |
||||
"Statistics", |
||||
localPath, |
||||
error.Value)); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
using Flurl; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Core.Emby |
||||
{ |
||||
public static class EmbyUrl |
||||
{ |
||||
public static Url ForArtwork(Option<EmbyMediaSource> maybeEmby, string artwork) |
||||
{ |
||||
string address = maybeEmby.Map(ms => ms.Connections.HeadOrNone().Map(c => c.Address)) |
||||
.Flatten() |
||||
.IfNone("emby://"); |
||||
|
||||
string[] split = artwork.Replace("emby://", string.Empty).Split('?'); |
||||
if (split.Length != 2) |
||||
{ |
||||
return artwork; |
||||
} |
||||
|
||||
string pathSegment = split[0]; |
||||
QueryParamCollection query = Url.ParseQueryParams(split[1]); |
||||
|
||||
Url x = Url.Parse(address) |
||||
.AppendPathSegment(pathSegment) |
||||
.SetQueryParams(query); |
||||
|
||||
return x; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
|
||||
using System.Collections.Generic; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Emby; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Emby |
||||
{ |
||||
public interface IEmbyApiClient |
||||
{ |
||||
Task<Either<BaseError, EmbyServerInformation>> GetServerInformation(string address, string apiKey); |
||||
Task<Either<BaseError, List<EmbyLibrary>>> GetLibraries(string address, string apiKey); |
||||
|
||||
Task<Either<BaseError, List<EmbyMovie>>> GetMovieLibraryItems( |
||||
string address, |
||||
string apiKey, |
||||
int mediaSourceId, |
||||
string libraryId); |
||||
|
||||
Task<Either<BaseError, List<EmbyShow>>> GetShowLibraryItems( |
||||
string address, |
||||
string apiKey, |
||||
int mediaSourceId, |
||||
string libraryId); |
||||
|
||||
Task<Either<BaseError, List<EmbySeason>>> GetSeasonLibraryItems( |
||||
string address, |
||||
string apiKey, |
||||
int mediaSourceId, |
||||
string showId); |
||||
|
||||
Task<Either<BaseError, List<EmbyEpisode>>> GetEpisodeLibraryItems( |
||||
string address, |
||||
string apiKey, |
||||
int mediaSourceId, |
||||
string seasonId); |
||||
} |
||||
} |
||||
@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
|
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core.Domain; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Emby |
||||
{ |
||||
public interface IEmbyMovieLibraryScanner |
||||
{ |
||||
Task<Either<BaseError, Unit>> ScanLibrary( |
||||
string address, |
||||
string apiKey, |
||||
EmbyLibrary library, |
||||
string ffprobePath); |
||||
} |
||||
} |
||||
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core.Domain; |
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Emby |
||||
{ |
||||
public interface IEmbyPathReplacementService |
||||
{ |
||||
Task<string> GetReplacementEmbyPath(int libraryPathId, string path); |
||||
string GetReplacementEmbyPath(List<EmbyPathReplacement> pathReplacements, string path, bool log = true); |
||||
} |
||||
} |
||||
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
using ErsatzTV.Core.Emby; |
||||
using ErsatzTV.Core.Interfaces.MediaSources; |
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Emby |
||||
{ |
||||
public interface IEmbySecretStore : IRemoteMediaSourceSecretStore<EmbySecrets> |
||||
{ |
||||
} |
||||
} |
||||
@ -0,0 +1,15 @@
@@ -0,0 +1,15 @@
|
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core.Domain; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Emby |
||||
{ |
||||
public interface IEmbyTelevisionLibraryScanner |
||||
{ |
||||
Task<Either<BaseError, Unit>> ScanLibrary( |
||||
string address, |
||||
string apiKey, |
||||
EmbyLibrary library, |
||||
string ffprobePath); |
||||
} |
||||
} |
||||
@ -1,13 +1,9 @@
@@ -1,13 +1,9 @@
|
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core.Interfaces.MediaSources; |
||||
using ErsatzTV.Core.Jellyfin; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Jellyfin |
||||
{ |
||||
public interface IJellyfinSecretStore |
||||
public interface IJellyfinSecretStore : IRemoteMediaSourceSecretStore<JellyfinSecrets> |
||||
{ |
||||
Task<Unit> DeleteAll(); |
||||
Task<JellyfinSecrets> ReadSecrets(); |
||||
Task<Unit> SaveSecrets(JellyfinSecrets jellyfinSecrets); |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
using System.Threading.Tasks; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Core.Interfaces.MediaSources |
||||
{ |
||||
public interface IRemoteMediaSourceSecretStore<TSecrets> |
||||
{ |
||||
Task<Unit> DeleteAll(); |
||||
Task<TSecrets> ReadSecrets(); |
||||
Task<Unit> SaveSecrets(TSecrets jellyfinSecrets); |
||||
} |
||||
} |
||||
@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Generic; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Emby; |
||||
using LanguageExt; |
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories |
||||
{ |
||||
public interface IEmbyTelevisionRepository |
||||
{ |
||||
Task<List<EmbyItemEtag>> GetExistingShows(EmbyLibrary library); |
||||
Task<List<EmbyItemEtag>> GetExistingSeasons(EmbyLibrary library, string showItemId); |
||||
Task<List<EmbyItemEtag>> GetExistingEpisodes(EmbyLibrary library, string seasonItemId); |
||||
Task<bool> AddShow(EmbyShow show); |
||||
Task<Option<EmbyShow>> Update(EmbyShow show); |
||||
Task<bool> AddSeason(EmbySeason season); |
||||
Task<Unit> Update(EmbySeason season); |
||||
Task<bool> AddEpisode(EmbyEpisode episode); |
||||
Task<Unit> Update(EmbyEpisode episode); |
||||
Task<List<int>> RemoveMissingShows(EmbyLibrary library, List<string> showIds); |
||||
Task<Unit> RemoveMissingSeasons(EmbyLibrary library, List<string> seasonIds); |
||||
Task<Unit> RemoveMissingEpisodes(EmbyLibrary library, List<string> episodeIds); |
||||
Task<Unit> DeleteEmptySeasons(EmbyLibrary library); |
||||
Task<List<int>> DeleteEmptyShows(EmbyLibrary library); |
||||
} |
||||
} |
||||
@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
|
||||
namespace ErsatzTV.Core.Jellyfin |
||||
using ErsatzTV.Core.MediaSources; |
||||
|
||||
namespace ErsatzTV.Core.Jellyfin |
||||
{ |
||||
public class JellyfinSecrets |
||||
public class JellyfinSecrets : RemoteMediaSourceSecrets |
||||
{ |
||||
public string Address { get; set; } |
||||
public string ApiKey { get; set; } |
||||
} |
||||
} |
||||
|
||||
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.MediaSources |
||||
{ |
||||
public class RemoteMediaSourceSecrets |
||||
{ |
||||
public string Address { get; set; } |
||||
public string ApiKey { get; set; } |
||||
} |
||||
} |
||||
@ -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 EmbyLibraryConfiguration : IEntityTypeConfiguration<EmbyLibrary> |
||||
{ |
||||
public void Configure(EntityTypeBuilder<EmbyLibrary> builder) => |
||||
builder.ToTable("EmbyLibrary"); |
||||
} |
||||
} |
||||
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations |
||||
{ |
||||
public class EmbyEpisodeConfiguration : IEntityTypeConfiguration<EmbyEpisode> |
||||
{ |
||||
public void Configure(EntityTypeBuilder<EmbyEpisode> builder) => builder.ToTable("EmbyEpisode"); |
||||
} |
||||
} |
||||
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations |
||||
{ |
||||
public class EmbyMovieConfiguration : IEntityTypeConfiguration<EmbyMovie> |
||||
{ |
||||
public void Configure(EntityTypeBuilder<EmbyMovie> builder) => builder.ToTable("EmbyMovie"); |
||||
} |
||||
} |
||||
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations |
||||
{ |
||||
public class EmbySeasonConfiguration : IEntityTypeConfiguration<EmbySeason> |
||||
{ |
||||
public void Configure(EntityTypeBuilder<EmbySeason> builder) => builder.ToTable("EmbySeason"); |
||||
} |
||||
} |
||||
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations |
||||
{ |
||||
public class EmbyShowConfiguration : IEntityTypeConfiguration<EmbyShow> |
||||
{ |
||||
public void Configure(EntityTypeBuilder<EmbyShow> builder) => builder.ToTable("EmbyShow"); |
||||
} |
||||
} |
||||
@ -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 EmbyConnectionConfiguration : IEntityTypeConfiguration<EmbyConnection> |
||||
{ |
||||
public void Configure(EntityTypeBuilder<EmbyConnection> builder) => |
||||
builder.ToTable("EmbyConnection"); |
||||
} |
||||
} |
||||
@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations |
||||
{ |
||||
public class EmbyMediaSourceConfiguration : IEntityTypeConfiguration<EmbyMediaSource> |
||||
{ |
||||
public void Configure(EntityTypeBuilder<EmbyMediaSource> builder) |
||||
{ |
||||
builder.ToTable("EmbyMediaSource"); |
||||
|
||||
builder.HasMany(s => s.Connections) |
||||
.WithOne(c => c.EmbyMediaSource) |
||||
.HasForeignKey(c => c.EmbyMediaSourceId) |
||||
.OnDelete(DeleteBehavior.Cascade); |
||||
|
||||
builder.HasMany(s => s.PathReplacements) |
||||
.WithOne(r => r.EmbyMediaSource) |
||||
.HasForeignKey(r => r.EmbyMediaSourceId) |
||||
.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 EmbyPathReplacementConfiguration : IEntityTypeConfiguration<EmbyPathReplacement> |
||||
{ |
||||
public void Configure(EntityTypeBuilder<EmbyPathReplacement> builder) => |
||||
builder.ToTable("EmbyPathReplacement"); |
||||
} |
||||
} |
||||
@ -0,0 +1,454 @@
@@ -0,0 +1,454 @@
|
||||
using System; |
||||
using System.Collections.Generic; |
||||
using System.Data; |
||||
using System.Linq; |
||||
using System.Threading.Tasks; |
||||
using Dapper; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Emby; |
||||
using ErsatzTV.Core.Interfaces.Repositories; |
||||
using LanguageExt; |
||||
using LanguageExt.UnsafeValueAccess; |
||||
using Microsoft.EntityFrameworkCore; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Repositories |
||||
{ |
||||
public class EmbyTelevisionRepository : IEmbyTelevisionRepository |
||||
{ |
||||
private readonly IDbConnection _dbConnection; |
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory; |
||||
|
||||
public EmbyTelevisionRepository(IDbConnection dbConnection, IDbContextFactory<TvContext> dbContextFactory) |
||||
{ |
||||
_dbConnection = dbConnection; |
||||
_dbContextFactory = dbContextFactory; |
||||
} |
||||
|
||||
public Task<List<EmbyItemEtag>> GetExistingShows(EmbyLibrary library) => |
||||
_dbConnection.QueryAsync<EmbyItemEtag>( |
||||
@"SELECT ItemId, Etag FROM EmbyShow
|
||||
INNER JOIN Show S on EmbyShow.Id = S.Id |
||||
INNER JOIN MediaItem MI on S.Id = MI.Id |
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id |
||||
WHERE LP.LibraryId = @LibraryId",
|
||||
new { LibraryId = library.Id }) |
||||
.Map(result => result.ToList()); |
||||
|
||||
public Task<List<EmbyItemEtag>> GetExistingSeasons(EmbyLibrary library, string showItemId) => |
||||
_dbConnection.QueryAsync<EmbyItemEtag>( |
||||
@"SELECT EmbySeason.ItemId, EmbySeason.Etag FROM EmbySeason
|
||||
INNER JOIN Season S on EmbySeason.Id = S.Id |
||||
INNER JOIN MediaItem MI on S.Id = MI.Id |
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id |
||||
INNER JOIN Show S2 on S.ShowId = S2.Id |
||||
INNER JOIN EmbyShow JS on S2.Id = JS.Id |
||||
WHERE LP.LibraryId = @LibraryId AND JS.ItemId = @ShowItemId",
|
||||
new { LibraryId = library.Id, ShowItemId = showItemId }) |
||||
.Map(result => result.ToList()); |
||||
|
||||
public Task<List<EmbyItemEtag>> GetExistingEpisodes(EmbyLibrary library, string seasonItemId) => |
||||
_dbConnection.QueryAsync<EmbyItemEtag>( |
||||
@"SELECT EmbyEpisode.ItemId, EmbyEpisode.Etag FROM EmbyEpisode
|
||||
INNER JOIN Episode E on EmbyEpisode.Id = E.Id |
||||
INNER JOIN MediaItem MI on E.Id = MI.Id |
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id |
||||
INNER JOIN Season S2 on E.SeasonId = S2.Id |
||||
INNER JOIN EmbySeason JS on S2.Id = JS.Id |
||||
WHERE LP.LibraryId = @LibraryId AND JS.ItemId = @SeasonItemId",
|
||||
new { LibraryId = library.Id, SeasonItemId = seasonItemId }) |
||||
.Map(result => result.ToList()); |
||||
|
||||
public async Task<bool> AddShow(EmbyShow show) |
||||
{ |
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); |
||||
await dbContext.AddAsync(show); |
||||
if (await dbContext.SaveChangesAsync() <= 0) |
||||
{ |
||||
return false; |
||||
} |
||||
|
||||
await dbContext.Entry(show).Reference(m => m.LibraryPath).LoadAsync(); |
||||
await dbContext.Entry(show.LibraryPath).Reference(lp => lp.Library).LoadAsync(); |
||||
return true; |
||||
} |
||||
|
||||
public async Task<Option<EmbyShow>> Update(EmbyShow show) |
||||
{ |
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); |
||||
Option<EmbyShow> maybeExisting = await dbContext.EmbyShows |
||||
.Include(m => m.LibraryPath) |
||||
.ThenInclude(lp => lp.Library) |
||||
.Include(m => m.ShowMetadata) |
||||
.ThenInclude(mm => mm.Genres) |
||||
.Include(m => m.ShowMetadata) |
||||
.ThenInclude(mm => mm.Tags) |
||||
.Include(m => m.ShowMetadata) |
||||
.ThenInclude(mm => mm.Studios) |
||||
.Include(m => m.ShowMetadata) |
||||
.ThenInclude(mm => mm.Actors) |
||||
.Include(m => m.ShowMetadata) |
||||
.ThenInclude(mm => mm.Artwork) |
||||
.Filter(m => m.ItemId == show.ItemId) |
||||
.OrderBy(m => m.ItemId) |
||||
.SingleOrDefaultAsync(); |
||||
|
||||
if (maybeExisting.IsSome) |
||||
{ |
||||
EmbyShow existing = maybeExisting.ValueUnsafe(); |
||||
|
||||
// library path is used for search indexing later
|
||||
show.LibraryPath = existing.LibraryPath; |
||||
show.Id = existing.Id; |
||||
|
||||
existing.Etag = show.Etag; |
||||
|
||||
// metadata
|
||||
ShowMetadata metadata = existing.ShowMetadata.Head(); |
||||
ShowMetadata incomingMetadata = show.ShowMetadata.Head(); |
||||
metadata.Title = incomingMetadata.Title; |
||||
metadata.SortTitle = incomingMetadata.SortTitle; |
||||
metadata.Plot = incomingMetadata.Plot; |
||||
metadata.Year = incomingMetadata.Year; |
||||
metadata.Tagline = incomingMetadata.Tagline; |
||||
metadata.DateAdded = incomingMetadata.DateAdded; |
||||
metadata.DateUpdated = DateTime.UtcNow; |
||||
|
||||
// genres
|
||||
foreach (Genre genre in metadata.Genres |
||||
.Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name)) |
||||
.ToList()) |
||||
{ |
||||
metadata.Genres.Remove(genre); |
||||
} |
||||
|
||||
foreach (Genre genre in incomingMetadata.Genres |
||||
.Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name)) |
||||
.ToList()) |
||||
{ |
||||
metadata.Genres.Add(genre); |
||||
} |
||||
|
||||
// tags
|
||||
foreach (Tag tag in metadata.Tags |
||||
.Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name)) |
||||
.ToList()) |
||||
{ |
||||
metadata.Tags.Remove(tag); |
||||
} |
||||
|
||||
foreach (Tag tag in incomingMetadata.Tags |
||||
.Filter(g => metadata.Tags.All(g2 => g2.Name != g.Name)) |
||||
.ToList()) |
||||
{ |
||||
metadata.Tags.Add(tag); |
||||
} |
||||
|
||||
// studios
|
||||
foreach (Studio studio in metadata.Studios |
||||
.Filter(g => incomingMetadata.Studios.All(g2 => g2.Name != g.Name)) |
||||
.ToList()) |
||||
{ |
||||
metadata.Studios.Remove(studio); |
||||
} |
||||
|
||||
foreach (Studio studio in incomingMetadata.Studios |
||||
.Filter(g => metadata.Studios.All(g2 => g2.Name != g.Name)) |
||||
.ToList()) |
||||
{ |
||||
metadata.Studios.Add(studio); |
||||
} |
||||
|
||||
// actors
|
||||
foreach (Actor actor in metadata.Actors |
||||
.Filter( |
||||
a => incomingMetadata.Actors.All( |
||||
a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null)) |
||||
.ToList()) |
||||
{ |
||||
metadata.Actors.Remove(actor); |
||||
} |
||||
|
||||
foreach (Actor actor in incomingMetadata.Actors |
||||
.Filter(a => metadata.Actors.All(a2 => a2.Name != a.Name)) |
||||
.ToList()) |
||||
{ |
||||
metadata.Actors.Add(actor); |
||||
} |
||||
|
||||
metadata.ReleaseDate = incomingMetadata.ReleaseDate; |
||||
|
||||
// poster
|
||||
Artwork incomingPoster = |
||||
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); |
||||
if (incomingPoster != null) |
||||
{ |
||||
Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); |
||||
if (poster == null) |
||||
{ |
||||
poster = new Artwork { ArtworkKind = ArtworkKind.Poster }; |
||||
metadata.Artwork.Add(poster); |
||||
} |
||||
|
||||
poster.Path = incomingPoster.Path; |
||||
poster.DateAdded = incomingPoster.DateAdded; |
||||
poster.DateUpdated = incomingPoster.DateUpdated; |
||||
} |
||||
|
||||
// fan art
|
||||
Artwork incomingFanArt = |
||||
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); |
||||
if (incomingFanArt != null) |
||||
{ |
||||
Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); |
||||
if (fanArt == null) |
||||
{ |
||||
fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt }; |
||||
metadata.Artwork.Add(fanArt); |
||||
} |
||||
|
||||
fanArt.Path = incomingFanArt.Path; |
||||
fanArt.DateAdded = incomingFanArt.DateAdded; |
||||
fanArt.DateUpdated = incomingFanArt.DateUpdated; |
||||
} |
||||
} |
||||
|
||||
await dbContext.SaveChangesAsync(); |
||||
|
||||
return maybeExisting; |
||||
} |
||||
|
||||
public async Task<bool> AddSeason(EmbySeason season) |
||||
{ |
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); |
||||
await dbContext.AddAsync(season); |
||||
if (await dbContext.SaveChangesAsync() <= 0) |
||||
{ |
||||
return false; |
||||
} |
||||
|
||||
await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync(); |
||||
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync(); |
||||
return true; |
||||
} |
||||
|
||||
public async Task<Unit> Update(EmbySeason season) |
||||
{ |
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); |
||||
Option<EmbySeason> maybeExisting = await dbContext.EmbySeasons |
||||
.Include(m => m.LibraryPath) |
||||
.Include(m => m.SeasonMetadata) |
||||
.ThenInclude(mm => mm.Artwork) |
||||
.Filter(m => m.ItemId == season.ItemId) |
||||
.OrderBy(m => m.ItemId) |
||||
.SingleOrDefaultAsync(); |
||||
|
||||
if (maybeExisting.IsSome) |
||||
{ |
||||
EmbySeason existing = maybeExisting.ValueUnsafe(); |
||||
|
||||
// library path is used for search indexing later
|
||||
season.LibraryPath = existing.LibraryPath; |
||||
season.Id = existing.Id; |
||||
|
||||
existing.Etag = season.Etag; |
||||
existing.SeasonNumber = season.SeasonNumber; |
||||
|
||||
// metadata
|
||||
SeasonMetadata metadata = existing.SeasonMetadata.Head(); |
||||
SeasonMetadata incomingMetadata = season.SeasonMetadata.Head(); |
||||
metadata.Title = incomingMetadata.Title; |
||||
metadata.SortTitle = incomingMetadata.SortTitle; |
||||
metadata.Year = incomingMetadata.Year; |
||||
metadata.DateAdded = incomingMetadata.DateAdded; |
||||
metadata.DateUpdated = DateTime.UtcNow; |
||||
metadata.ReleaseDate = incomingMetadata.ReleaseDate; |
||||
|
||||
// poster
|
||||
Artwork incomingPoster = |
||||
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); |
||||
if (incomingPoster != null) |
||||
{ |
||||
Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster); |
||||
if (poster == null) |
||||
{ |
||||
poster = new Artwork { ArtworkKind = ArtworkKind.Poster }; |
||||
metadata.Artwork.Add(poster); |
||||
} |
||||
|
||||
poster.Path = incomingPoster.Path; |
||||
poster.DateAdded = incomingPoster.DateAdded; |
||||
poster.DateUpdated = incomingPoster.DateUpdated; |
||||
} |
||||
|
||||
// fan art
|
||||
Artwork incomingFanArt = |
||||
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); |
||||
if (incomingFanArt != null) |
||||
{ |
||||
Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt); |
||||
if (fanArt == null) |
||||
{ |
||||
fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt }; |
||||
metadata.Artwork.Add(fanArt); |
||||
} |
||||
|
||||
fanArt.Path = incomingFanArt.Path; |
||||
fanArt.DateAdded = incomingFanArt.DateAdded; |
||||
fanArt.DateUpdated = incomingFanArt.DateUpdated; |
||||
} |
||||
} |
||||
|
||||
await dbContext.SaveChangesAsync(); |
||||
|
||||
return Unit.Default; |
||||
} |
||||
|
||||
public async Task<bool> AddEpisode(EmbyEpisode episode) |
||||
{ |
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); |
||||
await dbContext.AddAsync(episode); |
||||
if (await dbContext.SaveChangesAsync() <= 0) |
||||
{ |
||||
return false; |
||||
} |
||||
|
||||
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync(); |
||||
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync(); |
||||
return true; |
||||
} |
||||
|
||||
public async Task<Unit> Update(EmbyEpisode episode) |
||||
{ |
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); |
||||
Option<EmbyEpisode> maybeExisting = await dbContext.EmbyEpisodes |
||||
.Include(m => m.LibraryPath) |
||||
.Include(m => m.MediaVersions) |
||||
.ThenInclude(mv => mv.MediaFiles) |
||||
.Include(m => m.MediaVersions) |
||||
.ThenInclude(mv => mv.Streams) |
||||
.Include(m => m.EpisodeMetadata) |
||||
.ThenInclude(mm => mm.Artwork) |
||||
.Filter(m => m.ItemId == episode.ItemId) |
||||
.OrderBy(m => m.ItemId) |
||||
.SingleOrDefaultAsync(); |
||||
|
||||
if (maybeExisting.IsSome) |
||||
{ |
||||
EmbyEpisode existing = maybeExisting.ValueUnsafe(); |
||||
|
||||
// library path is used for search indexing later
|
||||
episode.LibraryPath = existing.LibraryPath; |
||||
episode.Id = existing.Id; |
||||
|
||||
existing.Etag = episode.Etag; |
||||
existing.EpisodeNumber = episode.EpisodeNumber; |
||||
|
||||
// metadata
|
||||
EpisodeMetadata metadata = existing.EpisodeMetadata.Head(); |
||||
EpisodeMetadata incomingMetadata = episode.EpisodeMetadata.Head(); |
||||
metadata.Title = incomingMetadata.Title; |
||||
metadata.SortTitle = incomingMetadata.SortTitle; |
||||
metadata.Plot = incomingMetadata.Plot; |
||||
metadata.Year = incomingMetadata.Year; |
||||
metadata.DateAdded = incomingMetadata.DateAdded; |
||||
metadata.DateUpdated = DateTime.UtcNow; |
||||
metadata.ReleaseDate = incomingMetadata.ReleaseDate; |
||||
|
||||
// thumbnail
|
||||
Artwork incomingThumbnail = |
||||
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail); |
||||
if (incomingThumbnail != null) |
||||
{ |
||||
Artwork thumbnail = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail); |
||||
if (thumbnail == null) |
||||
{ |
||||
thumbnail = new Artwork { ArtworkKind = ArtworkKind.Thumbnail }; |
||||
metadata.Artwork.Add(thumbnail); |
||||
} |
||||
|
||||
thumbnail.Path = incomingThumbnail.Path; |
||||
thumbnail.DateAdded = incomingThumbnail.DateAdded; |
||||
thumbnail.DateUpdated = incomingThumbnail.DateUpdated; |
||||
} |
||||
|
||||
// version
|
||||
MediaVersion version = existing.MediaVersions.Head(); |
||||
MediaVersion incomingVersion = episode.MediaVersions.Head(); |
||||
version.Name = incomingVersion.Name; |
||||
version.DateAdded = incomingVersion.DateAdded; |
||||
|
||||
// media file
|
||||
MediaFile file = version.MediaFiles.Head(); |
||||
MediaFile incomingFile = incomingVersion.MediaFiles.Head(); |
||||
file.Path = incomingFile.Path; |
||||
} |
||||
|
||||
await dbContext.SaveChangesAsync(); |
||||
|
||||
return Unit.Default; |
||||
} |
||||
|
||||
public async Task<List<int>> RemoveMissingShows(EmbyLibrary library, List<string> showIds) |
||||
{ |
||||
List<int> ids = await _dbConnection.QueryAsync<int>( |
||||
@"SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN EmbyShow js ON js.Id = m.Id |
||||
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId |
||||
WHERE lp.LibraryId = @LibraryId AND js.ItemId IN @ShowIds",
|
||||
new { LibraryId = library.Id, ShowIds = showIds }).Map(result => result.ToList()); |
||||
|
||||
await _dbConnection.ExecuteAsync( |
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
(SELECT m.Id FROM MediaItem m |
||||
INNER JOIN EmbyShow js ON js.Id = m.Id |
||||
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId |
||||
WHERE lp.LibraryId = @LibraryId AND js.ItemId IN @ShowIds)",
|
||||
new { LibraryId = library.Id, ShowIds = showIds }); |
||||
|
||||
return ids; |
||||
} |
||||
|
||||
public Task<Unit> RemoveMissingSeasons(EmbyLibrary library, List<string> seasonIds) => |
||||
_dbConnection.ExecuteAsync( |
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
(SELECT m.Id FROM MediaItem m |
||||
INNER JOIN EmbySeason js ON js.Id = m.Id |
||||
INNER JOIN LibraryPath LP on m.LibraryPathId = LP.Id |
||||
WHERE LP.LibraryId = @LibraryId AND js.ItemId IN @SeasonIds)",
|
||||
new { LibraryId = library.Id, SeasonIds = seasonIds }).ToUnit(); |
||||
|
||||
public Task<Unit> RemoveMissingEpisodes(EmbyLibrary library, List<string> episodeIds) => |
||||
_dbConnection.ExecuteAsync( |
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
(SELECT m.Id FROM MediaItem m |
||||
INNER JOIN EmbyEpisode je ON je.Id = m.Id |
||||
INNER JOIN LibraryPath LP on m.LibraryPathId = LP.Id |
||||
WHERE LP.LibraryId = @LibraryId AND je.ItemId IN @EpisodeIds)",
|
||||
new { LibraryId = library.Id, EpisodeIds = episodeIds }).ToUnit(); |
||||
|
||||
public async Task<Unit> DeleteEmptySeasons(EmbyLibrary library) |
||||
{ |
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); |
||||
List<EmbySeason> seasons = await dbContext.EmbySeasons |
||||
.Filter(s => s.LibraryPath.LibraryId == library.Id) |
||||
.Filter(s => s.Episodes.Count == 0) |
||||
.ToListAsync(); |
||||
dbContext.Seasons.RemoveRange(seasons); |
||||
await dbContext.SaveChangesAsync(); |
||||
return Unit.Default; |
||||
} |
||||
|
||||
public async Task<List<int>> DeleteEmptyShows(EmbyLibrary library) |
||||
{ |
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext(); |
||||
List<EmbyShow> shows = await dbContext.EmbyShows |
||||
.Filter(s => s.LibraryPath.LibraryId == library.Id) |
||||
.Filter(s => s.Seasons.Count == 0) |
||||
.ToListAsync(); |
||||
var ids = shows.Map(s => s.Id).ToList(); |
||||
dbContext.Shows.RemoveRange(shows); |
||||
await dbContext.SaveChangesAsync(); |
||||
return ids; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,521 @@
@@ -0,0 +1,521 @@
|
||||
using System; |
||||
using System.Collections.Generic; |
||||
using System.Linq; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Emby; |
||||
using ErsatzTV.Core.Interfaces.Emby; |
||||
using ErsatzTV.Core.Interfaces.Metadata; |
||||
using ErsatzTV.Infrastructure.Emby.Models; |
||||
using LanguageExt; |
||||
using Microsoft.Extensions.Logging; |
||||
using Refit; |
||||
using static LanguageExt.Prelude; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Emby |
||||
{ |
||||
public class EmbyApiClient : IEmbyApiClient |
||||
{ |
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider; |
||||
private readonly ILogger<EmbyApiClient> _logger; |
||||
|
||||
public EmbyApiClient(IFallbackMetadataProvider fallbackMetadataProvider, ILogger<EmbyApiClient> logger) |
||||
{ |
||||
_fallbackMetadataProvider = fallbackMetadataProvider; |
||||
_logger = logger; |
||||
} |
||||
|
||||
public async Task<Either<BaseError, EmbyServerInformation>> GetServerInformation( |
||||
string address, |
||||
string apiKey) |
||||
{ |
||||
try |
||||
{ |
||||
IEmbyApi service = RestService.For<IEmbyApi>(address); |
||||
var cts = new CancellationTokenSource(); |
||||
cts.CancelAfter(TimeSpan.FromSeconds(5)); |
||||
return await service.GetSystemInformation(apiKey, cts.Token) |
||||
.Map(response => new EmbyServerInformation(response.ServerName, response.OperatingSystem)); |
||||
} |
||||
catch (OperationCanceledException ex) |
||||
{ |
||||
_logger.LogError(ex, "Timeout getting emby server name"); |
||||
return BaseError.New("Emby did not respond in time"); |
||||
} |
||||
catch (Exception ex) |
||||
{ |
||||
_logger.LogError(ex, "Error getting emby server name"); |
||||
return BaseError.New(ex.Message); |
||||
} |
||||
} |
||||
|
||||
public async Task<Either<BaseError, List<EmbyLibrary>>> GetLibraries(string address, string apiKey) |
||||
{ |
||||
try |
||||
{ |
||||
IEmbyApi service = RestService.For<IEmbyApi>(address); |
||||
List<EmbyLibraryResponse> libraries = await service.GetLibraries(apiKey); |
||||
return libraries |
||||
.Map(Project) |
||||
.Somes() |
||||
.ToList(); |
||||
} |
||||
catch (Exception ex) |
||||
{ |
||||
_logger.LogError(ex, "Error getting emby libraries"); |
||||
return BaseError.New(ex.Message); |
||||
} |
||||
} |
||||
|
||||
public async Task<Either<BaseError, List<EmbyMovie>>> GetMovieLibraryItems( |
||||
string address, |
||||
string apiKey, |
||||
int mediaSourceId, |
||||
string libraryId) |
||||
{ |
||||
try |
||||
{ |
||||
IEmbyApi service = RestService.For<IEmbyApi>(address); |
||||
EmbyLibraryItemsResponse items = await service.GetMovieLibraryItems(apiKey, libraryId); |
||||
return items.Items |
||||
.Map(ProjectToMovie) |
||||
.Somes() |
||||
.ToList(); |
||||
} |
||||
catch (Exception ex) |
||||
{ |
||||
_logger.LogError(ex, "Error getting emby movie library items"); |
||||
return BaseError.New(ex.Message); |
||||
} |
||||
} |
||||
|
||||
public async Task<Either<BaseError, List<EmbyShow>>> GetShowLibraryItems( |
||||
string address, |
||||
string apiKey, |
||||
int mediaSourceId, |
||||
string libraryId) |
||||
{ |
||||
try |
||||
{ |
||||
IEmbyApi service = RestService.For<IEmbyApi>(address); |
||||
EmbyLibraryItemsResponse items = await service.GetShowLibraryItems(apiKey, libraryId); |
||||
return items.Items |
||||
.Map(ProjectToShow) |
||||
.Somes() |
||||
.ToList(); |
||||
} |
||||
catch (Exception ex) |
||||
{ |
||||
_logger.LogError(ex, "Error getting emby show library items"); |
||||
return BaseError.New(ex.Message); |
||||
} |
||||
} |
||||
|
||||
public async Task<Either<BaseError, List<EmbySeason>>> GetSeasonLibraryItems( |
||||
string address, |
||||
string apiKey, |
||||
int mediaSourceId, |
||||
string showId) |
||||
{ |
||||
try |
||||
{ |
||||
IEmbyApi service = RestService.For<IEmbyApi>(address); |
||||
EmbyLibraryItemsResponse items = await service.GetSeasonLibraryItems(apiKey, showId); |
||||
return items.Items |
||||
.Map(ProjectToSeason) |
||||
.Somes() |
||||
.ToList(); |
||||
} |
||||
catch (Exception ex) |
||||
{ |
||||
_logger.LogError(ex, "Error getting emby show library items"); |
||||
return BaseError.New(ex.Message); |
||||
} |
||||
} |
||||
|
||||
public async Task<Either<BaseError, List<EmbyEpisode>>> GetEpisodeLibraryItems( |
||||
string address, |
||||
string apiKey, |
||||
int mediaSourceId, |
||||
string seasonId) |
||||
{ |
||||
try |
||||
{ |
||||
IEmbyApi service = RestService.For<IEmbyApi>(address); |
||||
EmbyLibraryItemsResponse items = await service.GetEpisodeLibraryItems(apiKey, seasonId); |
||||
return items.Items |
||||
.Map(ProjectToEpisode) |
||||
.Somes() |
||||
.ToList(); |
||||
} |
||||
catch (Exception ex) |
||||
{ |
||||
_logger.LogError(ex, "Error getting emby episode library items"); |
||||
return BaseError.New(ex.Message); |
||||
} |
||||
} |
||||
|
||||
private static Option<EmbyLibrary> Project(EmbyLibraryResponse response) => |
||||
response.CollectionType?.ToLowerInvariant() switch |
||||
{ |
||||
"tvshows" => new EmbyLibrary |
||||
{ |
||||
ItemId = response.ItemId, |
||||
Name = response.Name, |
||||
MediaKind = LibraryMediaKind.Shows, |
||||
ShouldSyncItems = false, |
||||
Paths = new List<LibraryPath> { new() { Path = $"emby://{response.ItemId}" } } |
||||
}, |
||||
"movies" => new EmbyLibrary |
||||
{ |
||||
ItemId = response.ItemId, |
||||
Name = response.Name, |
||||
MediaKind = LibraryMediaKind.Movies, |
||||
ShouldSyncItems = false, |
||||
Paths = new List<LibraryPath> { new() { Path = $"emby://{response.ItemId}" } } |
||||
}, |
||||
// TODO: ??? for music libraries
|
||||
_ => None |
||||
}; |
||||
|
||||
private Option<EmbyMovie> ProjectToMovie(EmbyLibraryItemResponse item) |
||||
{ |
||||
try |
||||
{ |
||||
if (item.MediaSources.Any(ms => ms.Protocol != "File")) |
||||
{ |
||||
return None; |
||||
} |
||||
|
||||
var version = new MediaVersion |
||||
{ |
||||
Name = "Main", |
||||
Duration = TimeSpan.FromTicks(item.RunTimeTicks), |
||||
DateAdded = item.DateCreated.UtcDateTime, |
||||
MediaFiles = new List<MediaFile> |
||||
{ |
||||
new() |
||||
{ |
||||
Path = item.Path |
||||
} |
||||
}, |
||||
Streams = new List<MediaStream>() |
||||
}; |
||||
|
||||
MovieMetadata metadata = ProjectToMovieMetadata(item); |
||||
|
||||
var movie = new EmbyMovie |
||||
{ |
||||
ItemId = item.Id, |
||||
Etag = item.Etag, |
||||
MediaVersions = new List<MediaVersion> { version }, |
||||
MovieMetadata = new List<MovieMetadata> { metadata } |
||||
}; |
||||
|
||||
return movie; |
||||
} |
||||
catch (Exception ex) |
||||
{ |
||||
_logger.LogWarning(ex, "Error projecting Emby movie"); |
||||
return None; |
||||
} |
||||
} |
||||
|
||||
private MovieMetadata ProjectToMovieMetadata(EmbyLibraryItemResponse item) |
||||
{ |
||||
DateTime dateAdded = item.DateCreated.UtcDateTime; |
||||
// DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(item.UpdatedAt).DateTime;
|
||||
|
||||
var metadata = new MovieMetadata |
||||
{ |
||||
Title = item.Name, |
||||
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name), |
||||
Plot = item.Overview, |
||||
Year = item.ProductionYear, |
||||
Tagline = Optional(item.Taglines).Flatten().HeadOrNone().IfNone(string.Empty), |
||||
DateAdded = dateAdded, |
||||
Genres = Optional(item.Genres).Flatten().Map(g => new Genre { Name = g }).ToList(), |
||||
Tags = Optional(item.Tags).Flatten().Map(t => new Tag { Name = t }).ToList(), |
||||
Studios = Optional(item.Studios).Flatten().Map(s => new Studio { Name = s.Name }).ToList(), |
||||
Actors = Optional(item.People).Flatten().Map(r => ProjectToModel(r, dateAdded)).ToList(), |
||||
Artwork = new List<Artwork>() |
||||
}; |
||||
|
||||
// set order on actors
|
||||
for (var i = 0; i < metadata.Actors.Count; i++) |
||||
{ |
||||
metadata.Actors[i].Order = i; |
||||
} |
||||
|
||||
if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate)) |
||||
{ |
||||
metadata.ReleaseDate = releaseDate; |
||||
} |
||||
|
||||
if (!string.IsNullOrWhiteSpace(item.ImageTags.Primary)) |
||||
{ |
||||
var poster = new Artwork |
||||
{ |
||||
ArtworkKind = ArtworkKind.Poster, |
||||
Path = $"emby://Items/{item.Id}/Images/Primary?tag={item.ImageTags.Primary}", |
||||
DateAdded = dateAdded |
||||
}; |
||||
metadata.Artwork.Add(poster); |
||||
} |
||||
|
||||
if (item.BackdropImageTags.Any()) |
||||
{ |
||||
var fanArt = new Artwork |
||||
{ |
||||
ArtworkKind = ArtworkKind.FanArt, |
||||
Path = $"emby://Items/{item.Id}/Images/Backdrop?tag={item.BackdropImageTags.Head()}", |
||||
DateAdded = dateAdded |
||||
}; |
||||
metadata.Artwork.Add(fanArt); |
||||
} |
||||
|
||||
return metadata; |
||||
} |
||||
|
||||
private Actor ProjectToModel(EmbyPersonResponse person, DateTime dateAdded) |
||||
{ |
||||
var actor = new Actor { Name = person.Name, Role = person.Role }; |
||||
if (!string.IsNullOrWhiteSpace(person.Id) && !string.IsNullOrWhiteSpace(person.PrimaryImageTag)) |
||||
{ |
||||
actor.Artwork = new Artwork |
||||
{ |
||||
Path = $"emby://Items/{person.Id}/Images/Primary?tag={person.PrimaryImageTag}", |
||||
ArtworkKind = ArtworkKind.Thumbnail, |
||||
DateAdded = dateAdded |
||||
}; |
||||
} |
||||
|
||||
return actor; |
||||
} |
||||
|
||||
private Option<EmbyShow> ProjectToShow(EmbyLibraryItemResponse item) |
||||
{ |
||||
try |
||||
{ |
||||
ShowMetadata metadata = ProjectToShowMetadata(item); |
||||
|
||||
var show = new EmbyShow |
||||
{ |
||||
ItemId = item.Id, |
||||
Etag = item.Etag, |
||||
ShowMetadata = new List<ShowMetadata> { metadata } |
||||
}; |
||||
|
||||
return show; |
||||
} |
||||
catch (Exception ex) |
||||
{ |
||||
_logger.LogWarning(ex, "Error projecting Emby show"); |
||||
return None; |
||||
} |
||||
} |
||||
|
||||
private ShowMetadata ProjectToShowMetadata(EmbyLibraryItemResponse item) |
||||
{ |
||||
DateTime dateAdded = item.DateCreated.UtcDateTime; |
||||
// DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(item.UpdatedAt).DateTime;
|
||||
|
||||
var metadata = new ShowMetadata |
||||
{ |
||||
Title = item.Name, |
||||
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name), |
||||
Plot = item.Overview, |
||||
Year = item.ProductionYear, |
||||
Tagline = Optional(item.Taglines).Flatten().HeadOrNone().IfNone(string.Empty), |
||||
DateAdded = dateAdded, |
||||
Genres = Optional(item.Genres).Flatten().Map(g => new Genre { Name = g }).ToList(), |
||||
Tags = Optional(item.Tags).Flatten().Map(t => new Tag { Name = t }).ToList(), |
||||
Studios = Optional(item.Studios).Flatten().Map(s => new Studio { Name = s.Name }).ToList(), |
||||
Actors = Optional(item.People).Flatten().Map(r => ProjectToModel(r, dateAdded)).ToList(), |
||||
Artwork = new List<Artwork>() |
||||
}; |
||||
|
||||
// set order on actors
|
||||
for (var i = 0; i < metadata.Actors.Count; i++) |
||||
{ |
||||
metadata.Actors[i].Order = i; |
||||
} |
||||
|
||||
if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate)) |
||||
{ |
||||
metadata.ReleaseDate = releaseDate; |
||||
} |
||||
|
||||
if (!string.IsNullOrWhiteSpace(item.ImageTags.Primary)) |
||||
{ |
||||
var poster = new Artwork |
||||
{ |
||||
ArtworkKind = ArtworkKind.Poster, |
||||
Path = $"emby://Items/{item.Id}/Images/Primary?tag={item.ImageTags.Primary}", |
||||
DateAdded = dateAdded |
||||
}; |
||||
metadata.Artwork.Add(poster); |
||||
} |
||||
|
||||
if (item.BackdropImageTags.Any()) |
||||
{ |
||||
var fanArt = new Artwork |
||||
{ |
||||
ArtworkKind = ArtworkKind.FanArt, |
||||
Path = $"emby://Items/{item.Id}/Images/Backdrop?tag={item.BackdropImageTags.Head()}", |
||||
DateAdded = dateAdded |
||||
}; |
||||
metadata.Artwork.Add(fanArt); |
||||
} |
||||
|
||||
return metadata; |
||||
} |
||||
|
||||
private Option<EmbySeason> ProjectToSeason(EmbyLibraryItemResponse item) |
||||
{ |
||||
try |
||||
{ |
||||
DateTime dateAdded = item.DateCreated.UtcDateTime; |
||||
// DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime;
|
||||
|
||||
var metadata = new SeasonMetadata |
||||
{ |
||||
Title = item.Name, |
||||
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name), |
||||
Year = item.ProductionYear, |
||||
DateAdded = dateAdded, |
||||
Artwork = new List<Artwork>() |
||||
}; |
||||
|
||||
if (!string.IsNullOrWhiteSpace(item.ImageTags.Primary)) |
||||
{ |
||||
var poster = new Artwork |
||||
{ |
||||
ArtworkKind = ArtworkKind.Poster, |
||||
Path = $"emby://Items/{item.Id}/Images/Primary?tag={item.ImageTags.Primary}", |
||||
DateAdded = dateAdded |
||||
}; |
||||
metadata.Artwork.Add(poster); |
||||
} |
||||
|
||||
if (item.BackdropImageTags.Any()) |
||||
{ |
||||
var fanArt = new Artwork |
||||
{ |
||||
ArtworkKind = ArtworkKind.FanArt, |
||||
Path = $"emby://Items/{item.Id}/Images/Backdrop?tag={item.BackdropImageTags.Head()}", |
||||
DateAdded = dateAdded |
||||
}; |
||||
metadata.Artwork.Add(fanArt); |
||||
} |
||||
|
||||
var season = new EmbySeason |
||||
{ |
||||
ItemId = item.Id, |
||||
Etag = item.Etag, |
||||
SeasonMetadata = new List<SeasonMetadata> { metadata } |
||||
}; |
||||
|
||||
if (item.IndexNumber.HasValue) |
||||
{ |
||||
season.SeasonNumber = item.IndexNumber.Value; |
||||
} |
||||
|
||||
return season; |
||||
} |
||||
catch (Exception ex) |
||||
{ |
||||
_logger.LogWarning(ex, "Error projecting Emby show"); |
||||
return None; |
||||
} |
||||
} |
||||
|
||||
private Option<EmbyEpisode> ProjectToEpisode(EmbyLibraryItemResponse item) |
||||
{ |
||||
try |
||||
{ |
||||
if (item.LocationType == "Virtual") |
||||
{ |
||||
return None; |
||||
} |
||||
|
||||
var version = new MediaVersion |
||||
{ |
||||
Name = "Main", |
||||
Duration = TimeSpan.FromTicks(item.RunTimeTicks), |
||||
DateAdded = item.DateCreated.UtcDateTime, |
||||
MediaFiles = new List<MediaFile> |
||||
{ |
||||
new() |
||||
{ |
||||
Path = item.Path |
||||
} |
||||
}, |
||||
Streams = new List<MediaStream>() |
||||
}; |
||||
|
||||
EpisodeMetadata metadata = ProjectToEpisodeMetadata(item); |
||||
|
||||
var episode = new EmbyEpisode |
||||
{ |
||||
ItemId = item.Id, |
||||
Etag = item.Etag, |
||||
MediaVersions = new List<MediaVersion> { version }, |
||||
EpisodeMetadata = new List<EpisodeMetadata> { metadata } |
||||
}; |
||||
|
||||
if (item.IndexNumber.HasValue) |
||||
{ |
||||
episode.EpisodeNumber = item.IndexNumber.Value; |
||||
} |
||||
|
||||
return episode; |
||||
} |
||||
catch (Exception ex) |
||||
{ |
||||
_logger.LogWarning(ex, "Error projecting Emby movie"); |
||||
return None; |
||||
} |
||||
} |
||||
|
||||
private EpisodeMetadata ProjectToEpisodeMetadata(EmbyLibraryItemResponse item) |
||||
{ |
||||
DateTime dateAdded = item.DateCreated.UtcDateTime; |
||||
// DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(item.UpdatedAt).DateTime;
|
||||
|
||||
var metadata = new EpisodeMetadata |
||||
{ |
||||
Title = item.Name, |
||||
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name), |
||||
Plot = item.Overview, |
||||
Year = item.ProductionYear, |
||||
DateAdded = dateAdded, |
||||
Genres = new List<Genre>(), |
||||
Tags = new List<Tag>(), |
||||
Studios = new List<Studio>(), |
||||
Actors = new List<Actor>(), |
||||
Artwork = new List<Artwork>() |
||||
}; |
||||
|
||||
if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate)) |
||||
{ |
||||
metadata.ReleaseDate = releaseDate; |
||||
} |
||||
|
||||
if (!string.IsNullOrWhiteSpace(item.ImageTags.Primary)) |
||||
{ |
||||
var thumbnail = new Artwork |
||||
{ |
||||
ArtworkKind = ArtworkKind.Thumbnail, |
||||
Path = $"emby://Items/{item.Id}/Images/Primary?tag={item.ImageTags.Primary}", |
||||
DateAdded = dateAdded |
||||
}; |
||||
metadata.Artwork.Add(thumbnail); |
||||
} |
||||
|
||||
return metadata; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
|
||||
using System.IO; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core; |
||||
using ErsatzTV.Core.Emby; |
||||
using ErsatzTV.Core.Interfaces.Emby; |
||||
using LanguageExt; |
||||
using Newtonsoft.Json; |
||||
using static LanguageExt.Prelude; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Emby |
||||
{ |
||||
public class EmbySecretStore : IEmbySecretStore |
||||
{ |
||||
public Task<Unit> DeleteAll() => SaveSecrets(new EmbySecrets()); |
||||
|
||||
public Task<EmbySecrets> ReadSecrets() => |
||||
File.ReadAllTextAsync(FileSystemLayout.EmbySecretsPath) |
||||
.Map(JsonConvert.DeserializeObject<EmbySecrets>) |
||||
.Map(s => Optional(s).IfNone(new EmbySecrets())); |
||||
|
||||
public Task<Unit> SaveSecrets(EmbySecrets embySecrets) => |
||||
Some(JsonConvert.SerializeObject(embySecrets)).Match( |
||||
s => File.WriteAllTextAsync(FileSystemLayout.EmbySecretsPath, s).ToUnit(), |
||||
Task.FromResult(Unit.Default)); |
||||
} |
||||
} |
||||
@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
|
||||
using System.Collections.Generic; |
||||
using System.Threading; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Infrastructure.Emby.Models; |
||||
using Refit; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Emby |
||||
{ |
||||
[Headers("Accept: application/json")] |
||||
public interface IEmbyApi |
||||
{ |
||||
[Get("/System/Info")] |
||||
public Task<EmbySystemInformationResponse> GetSystemInformation( |
||||
[Header("X-Emby-Token")] |
||||
string apiKey, |
||||
CancellationToken cancellationToken); |
||||
|
||||
[Get("/Library/VirtualFolders")] |
||||
public Task<List<EmbyLibraryResponse>> GetLibraries( |
||||
[Header("X-Emby-Token")] |
||||
string apiKey); |
||||
|
||||
[Get("/Items")] |
||||
public Task<EmbyLibraryItemsResponse> GetMovieLibraryItems( |
||||
[Header("X-Emby-Token")] |
||||
string apiKey, |
||||
[Query] |
||||
string parentId, |
||||
[Query] |
||||
string fields = |
||||
"Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,ProductionYear,PremiereDate,MediaSources", |
||||
[Query] |
||||
string includeItemTypes = "Movie"); |
||||
|
||||
[Get("/Items")] |
||||
public Task<EmbyLibraryItemsResponse> GetShowLibraryItems( |
||||
[Header("X-Emby-Token")] |
||||
string apiKey, |
||||
[Query] |
||||
string parentId, |
||||
[Query] |
||||
string fields = |
||||
"Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People,ProductionYear,PremiereDate,MediaSources", |
||||
[Query] |
||||
string includeItemTypes = "Series"); |
||||
|
||||
[Get("/Items")] |
||||
public Task<EmbyLibraryItemsResponse> GetSeasonLibraryItems( |
||||
[Header("X-Emby-Token")] |
||||
string apiKey, |
||||
[Query] |
||||
string parentId, |
||||
[Query] |
||||
string fields = "Path,DateCreated,Etag,Taglines", |
||||
[Query] |
||||
string includeItemTypes = "Season"); |
||||
|
||||
[Get("/Items")] |
||||
public Task<EmbyLibraryItemsResponse> GetEpisodeLibraryItems( |
||||
[Header("X-Emby-Token")] |
||||
string apiKey, |
||||
[Query] |
||||
string parentId, |
||||
[Query] |
||||
string fields = "Path,DateCreated,Etag,Overview,ProductionYear,PremiereDate,MediaSources,LocationType", |
||||
[Query] |
||||
string includeItemTypes = "Episode"); |
||||
} |
||||
} |
||||
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Infrastructure.Emby.Models |
||||
{ |
||||
public class EmbyImageTagsResponse |
||||
{ |
||||
public string Primary { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
|
||||
using System; |
||||
using System.Collections.Generic; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Emby.Models |
||||
{ |
||||
public class EmbyLibraryItemResponse |
||||
{ |
||||
public string Name { get; set; } |
||||
public string Id { get; set; } |
||||
public string Etag { get; set; } |
||||
public string Path { get; set; } |
||||
public DateTimeOffset DateCreated { get; set; } |
||||
public long RunTimeTicks { get; set; } |
||||
public List<string> Genres { get; set; } |
||||
public List<string> Tags { get; set; } |
||||
public int ProductionYear { get; set; } |
||||
public string PremiereDate { get; set; } |
||||
public List<EmbyMediaStreamResponse> MediaStreams { get; set; } |
||||
public List<EmbyMediaSourceResponse> MediaSources { get; set; } |
||||
public string LocationType { get; set; } |
||||
public string Overview { get; set; } |
||||
public List<string> Taglines { get; set; } |
||||
public List<EmbyStudioResponse> Studios { get; set; } |
||||
public List<EmbyPersonResponse> People { get; set; } |
||||
public EmbyImageTagsResponse ImageTags { get; set; } |
||||
public List<string> BackdropImageTags { get; set; } |
||||
public int? IndexNumber { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
using System.Collections.Generic; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Emby.Models |
||||
{ |
||||
public class EmbyLibraryItemsResponse |
||||
{ |
||||
public List<EmbyLibraryItemResponse> Items { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Infrastructure.Emby.Models |
||||
{ |
||||
public class EmbyLibraryResponse |
||||
{ |
||||
public string Name { get; set; } |
||||
public string CollectionType { get; set; } |
||||
public string ItemId { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Infrastructure.Emby.Models |
||||
{ |
||||
public class EmbyMediaSourceResponse |
||||
{ |
||||
public string Id { get; set; } |
||||
public string Protocol { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
|
||||
namespace ErsatzTV.Infrastructure.Emby.Models |
||||
{ |
||||
public class EmbyMediaStreamResponse |
||||
{ |
||||
public string Type { get; set; } |
||||
public string Codec { get; set; } |
||||
public string Language { get; set; } |
||||
public bool? IsInterlaced { get; set; } |
||||
public int? Height { get; set; } |
||||
public int? Width { get; set; } |
||||
public int Index { get; set; } |
||||
public bool IsDefault { get; set; } |
||||
public bool IsForced { get; set; } |
||||
public string Profile { get; set; } |
||||
public string AspectRatio { get; set; } |
||||
public int? Channels { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
namespace ErsatzTV.Infrastructure.Emby.Models |
||||
{ |
||||
public class EmbyPersonResponse |
||||
{ |
||||
public string Name { get; set; } |
||||
public string Id { get; set; } |
||||
public string Role { get; set; } |
||||
public string Type { get; set; } |
||||
public string PrimaryImageTag { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Infrastructure.Emby.Models |
||||
{ |
||||
public class EmbyStudioResponse |
||||
{ |
||||
public string Name { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Infrastructure.Emby.Models |
||||
{ |
||||
public class EmbySystemInformationResponse |
||||
{ |
||||
public string ServerName { get; set; } |
||||
public string OperatingSystem { get; set; } |
||||
} |
||||
} |
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue