Compare commits

...
Author SHA1 Message Date
Jason DoveandGitHub 266bfbad23 add link to release notes (#203) 2021-05-23 10:02:26 -05:00
Jason DoveandGitHub 60a9640009 use ffmpeg 4.3 in docker (#202)
* Revert "fix ffmpeg 4.4 compatibility"

This reverts commit 1ca0df038c.

* use ffmpeg 4.3 in docker
2021-05-23 09:55:40 -05:00
Jason Dove 9291a6b6ed add emby docs 2021-05-23 03:50:00 -05:00
Jason Dove 9afec19888 cleanup 2021-05-23 03:27:20 -05:00
Jason DoveandGitHub 50529ee6ad add emby media source (#201)
* 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 form
2021-05-23 03:05:23 -05:00
Jason DoveandGitHub 0b105bf6e1 fix schedule item duration under one hour (#200) 2021-05-22 07:45:44 -05:00
Jason Dove 5356f7f293 update dependencies 2021-05-22 05:59:30 -05:00
Jason DoveandGitHub 1d35efa429 fix jellyfin artwork (#198) 2021-05-21 21:21:32 -05:00
Jason DoveandGitHub 04da4b2964 single-file app publishing (#197)
* attempt to fix single file app publishing

* update release workflow
2021-05-21 15:17:24 -05:00
195 changed files with 11002 additions and 1057 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ jobs:
#release_name_cli="ErsatzTV.CommandLine-$tag-${{ matrix.target }}"
# Build everything
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net5.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" /property:InformationalVersion="${tag:1}-${{ matrix.target }}"
dotnet publish ErsatzTV/ErsatzTV.csproj --framework net5.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name" /property:InformationalVersion="${tag:1}-${{ matrix.target }}" /property:PublishSingleFile=true --self-contained true
#dotnet publish ErsatzTV.CommandLine/ErsatzTV.CommandLine.csproj --framework net5.0 --runtime "${{ matrix.target }}" -c Release -o "$release_name_cli" /property:InformationalVersion="${tag:1}-${{ matrix.target }}"
# Pack files
@@ -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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
namespace ErsatzTV.Application.Emby
{
public record EmbyPathReplacementViewModel(int Id, string EmbyPath, string LocalPath);
}
+19
View File
@@ -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 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.Emby.Queries
{
public record GetAllEmbyMediaSources : IRequest<List<EmbyMediaSourceViewModel>>;
}
@@ -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 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.Emby.Queries
{
public record GetEmbyLibrariesBySourceId(int EmbyMediaSourceId) : IRequest<List<EmbyLibraryViewModel>>;
}
@@ -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 @@
using LanguageExt;
using MediatR;
namespace ErsatzTV.Application.Emby.Queries
{
public record GetEmbyMediaSourceById(int EmbyMediaSourceId) : IRequest<Option<EmbyMediaSourceViewModel>>;
}
@@ -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 @@
using System.Collections.Generic;
using MediatR;
namespace ErsatzTV.Application.Emby.Queries
{
public record GetEmbyPathReplacementsBySourceId
(int EmbyMediaSourceId) : IRequest<List<EmbyPathReplacementViewModel>>;
}
@@ -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 @@
using ErsatzTV.Core.Emby;
using MediatR;
namespace ErsatzTV.Application.Emby.Queries
{
public record GetEmbySecrets : IRequest<EmbySecrets>;
}
@@ -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();
}
}
@@ -16,7 +16,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="Winista.MimeDetect" Version="1.0.1" />
</ItemGroup>
@@ -0,0 +1,6 @@
namespace ErsatzTV.Application
{
public interface IEmbyBackgroundServiceRequest
{
}
}
@@ -2,6 +2,7 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Locking;
using ErsatzTV.Core.Interfaces.Repositories;
@@ -36,7 +37,7 @@ namespace ErsatzTV.Application.Jellyfin.Commands
List<int> ids = await _mediaSourceRepository.DeleteAllJellyfin();
await _searchIndex.RemoveItems(ids);
await _jellyfinSecretStore.DeleteAll();
_entityLocker.UnlockJellyfin();
_entityLocker.UnlockRemoteMediaSource<JellyfinMediaSource>();
return Unit.Default;
}
@@ -2,5 +2,8 @@
namespace ErsatzTV.Application.Jellyfin
{
public record JellyfinMediaSourceViewModel(int Id, string Name, string Address) : MediaSourceViewModel(Id, Name);
public record JellyfinMediaSourceViewModel(int Id, string Name, string Address) : RemoteMediaSourceViewModel(
Id,
Name,
Address);
}
+2
View File
@@ -1,4 +1,5 @@
using System;
using ErsatzTV.Application.Emby;
using ErsatzTV.Application.Jellyfin;
using ErsatzTV.Core.Domain;
@@ -12,6 +13,7 @@ namespace ErsatzTV.Application.Libraries
LocalLibrary l => ProjectToViewModel(l),
PlexLibrary p => new PlexLibraryViewModel(p.Id, p.Name, p.MediaKind),
JellyfinLibrary j => new JellyfinLibraryViewModel(j.Id, j.Name, j.MediaKind, j.ShouldSyncItems),
EmbyLibrary e => new EmbyLibraryViewModel(e.Id, e.Name, e.MediaKind, e.ShouldSyncItems),
_ => throw new ArgumentOutOfRangeException(nameof(library))
};
@@ -31,6 +31,7 @@ namespace ErsatzTV.Application.Libraries.Queries
LocalLibrary => true,
PlexLibrary plex => plex.ShouldSyncItems,
JellyfinLibrary jellyfin => jellyfin.ShouldSyncItems,
EmbyLibrary emby => emby.ShouldSyncItems,
_ => false
};
}
+60 -31
View File
@@ -1,6 +1,8 @@
using System;
using System.Linq;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Jellyfin;
using LanguageExt;
using static LanguageExt.Prelude;
@@ -10,17 +12,19 @@ namespace ErsatzTV.Application.MediaCards
{
internal static TelevisionShowCardViewModel ProjectToViewModel(
ShowMetadata showMetadata,
Option<JellyfinMediaSource> maybeJellyfin) =>
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby) =>
new(
showMetadata.ShowId,
showMetadata.Title,
showMetadata.Year?.ToString(),
showMetadata.SortTitle,
GetPoster(showMetadata, maybeJellyfin));
GetPoster(showMetadata, maybeJellyfin, maybeEmby));
internal static TelevisionSeasonCardViewModel ProjectToViewModel(
Season season,
Option<JellyfinMediaSource> maybeJellyfin) =>
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby) =>
new(
season.Show.ShowMetadata.HeadOrNone().Match(m => m.Title ?? string.Empty, () => string.Empty),
season.Id,
@@ -28,12 +32,14 @@ namespace ErsatzTV.Application.MediaCards
GetSeasonName(season.SeasonNumber),
string.Empty,
GetSeasonName(season.SeasonNumber),
season.SeasonMetadata.HeadOrNone().Map(sm => GetPoster(sm, maybeJellyfin)).IfNone(string.Empty),
season.SeasonMetadata.HeadOrNone().Map(sm => GetPoster(sm, maybeJellyfin, maybeEmby))
.IfNone(string.Empty),
season.SeasonNumber == 0 ? "S" : season.SeasonNumber.ToString());
internal static TelevisionEpisodeCardViewModel ProjectToViewModel(
EpisodeMetadata episodeMetadata,
Option<JellyfinMediaSource> maybeJellyfin) =>
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby) =>
new(
episodeMetadata.EpisodeId,
episodeMetadata.ReleaseDate ?? DateTime.MinValue,
@@ -47,17 +53,18 @@ namespace ErsatzTV.Application.MediaCards
episodeMetadata.Episode.EpisodeMetadata.HeadOrNone().Match(
em => em.Plot ?? string.Empty,
() => string.Empty),
GetThumbnail(episodeMetadata, maybeJellyfin));
GetThumbnail(episodeMetadata, maybeJellyfin, maybeEmby));
internal static MovieCardViewModel ProjectToViewModel(
MovieMetadata movieMetadata,
Option<JellyfinMediaSource> maybeJellyfin) =>
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby) =>
new(
movieMetadata.MovieId,
movieMetadata.Title,
movieMetadata.Year?.ToString(),
movieMetadata.SortTitle,
GetPoster(movieMetadata, maybeJellyfin));
GetPoster(movieMetadata, maybeJellyfin, maybeEmby));
internal static MusicVideoCardViewModel ProjectToViewModel(MusicVideoMetadata musicVideoMetadata) =>
new(
@@ -66,7 +73,7 @@ namespace ErsatzTV.Application.MediaCards
musicVideoMetadata.MusicVideo.Artist.ArtistMetadata.Head().Title,
musicVideoMetadata.SortTitle,
musicVideoMetadata.Plot,
GetThumbnail(musicVideoMetadata, None));
GetThumbnail(musicVideoMetadata, None, None));
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
new(
@@ -74,37 +81,48 @@ namespace ErsatzTV.Application.MediaCards
artistMetadata.Title,
artistMetadata.Disambiguation,
artistMetadata.SortTitle,
GetThumbnail(artistMetadata, None));
GetThumbnail(artistMetadata, None, None));
internal static CollectionCardResultsViewModel
ProjectToViewModel(Collection collection, Option<JellyfinMediaSource> maybeJellyfin) =>
ProjectToViewModel(
Collection collection,
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby) =>
new(
collection.Name,
collection.MediaItems.OfType<Movie>().Map(
m => ProjectToViewModel(m.MovieMetadata.Head(), maybeJellyfin) with
m => ProjectToViewModel(m.MovieMetadata.Head(), maybeJellyfin, maybeEmby) with
{
CustomIndex = GetCustomIndex(collection, m.Id)
}).ToList(),
collection.MediaItems.OfType<Show>().Map(s => ProjectToViewModel(s.ShowMetadata.Head(), maybeJellyfin))
collection.MediaItems.OfType<Show>()
.Map(s => ProjectToViewModel(s.ShowMetadata.Head(), maybeJellyfin, maybeEmby))
.ToList(),
collection.MediaItems.OfType<Season>().Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby))
.ToList(),
collection.MediaItems.OfType<Season>().Map(s => ProjectToViewModel(s, maybeJellyfin)).ToList(),
collection.MediaItems.OfType<Episode>()
.Map(e => ProjectToViewModel(e.EpisodeMetadata.Head(), maybeJellyfin))
.Map(e => ProjectToViewModel(e.EpisodeMetadata.Head(), maybeJellyfin, maybeEmby))
.ToList(),
collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(),
collection.MediaItems.OfType<MusicVideo>().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head()))
.ToList()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };
internal static ActorCardViewModel ProjectToViewModel(Actor actor, Option<JellyfinMediaSource> maybeJellyfin)
internal static ActorCardViewModel ProjectToViewModel(
Actor actor,
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby)
{
string artwork = actor.Artwork?.Path ?? string.Empty;
if (maybeJellyfin.IsSome && artwork.StartsWith("jellyfin://"))
{
string address = maybeJellyfin.Map(ms => ms.Connections.HeadOrNone().Map(c => c.Address))
.Flatten()
.IfNone("jellyfin://");
artwork = artwork.Replace("jellyfin://", address) + "&fillheight=440";
artwork = JellyfinUrl.ForArtwork(maybeJellyfin, artwork)
.SetQueryParam("fillHeight", 440);
}
else if (maybeEmby.IsSome && artwork.StartsWith("emby://"))
{
artwork = EmbyUrl.ForArtwork(maybeEmby, artwork)
.SetQueryParam("fillHeight", 440);
}
return new ActorCardViewModel(actor.Id, actor.Name, actor.Role, artwork);
@@ -118,34 +136,45 @@ namespace ErsatzTV.Application.MediaCards
private static string GetSeasonName(int number) =>
number == 0 ? "Specials" : $"Season {number}";
private static string GetPoster(Metadata metadata, Option<JellyfinMediaSource> maybeJellyfin)
private static string GetPoster(
Metadata metadata,
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby)
{
string poster = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster))
.Match(a => a.Path, string.Empty);
if (maybeJellyfin.IsSome && poster.StartsWith("jellyfin://"))
{
string address = maybeJellyfin.Map(ms => ms.Connections.HeadOrNone().Map(c => c.Address))
.Flatten()
.IfNone("jellyfin://");
poster = poster.Replace("jellyfin://", address) + "&fillHeight=440";
poster = JellyfinUrl.ForArtwork(maybeJellyfin, poster)
.SetQueryParam("fillHeight", 440);
}
else if (maybeEmby.IsSome && poster.StartsWith("emby://"))
{
poster = EmbyUrl.ForArtwork(maybeEmby, poster)
.SetQueryParam("fillHeight", 440);
}
return poster;
}
private static string GetThumbnail(Metadata metadata, Option<JellyfinMediaSource> maybeJellyfin)
private static string GetThumbnail(
Metadata metadata,
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby)
{
string thumb = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail))
.Match(a => a.Path, string.Empty);
if (maybeJellyfin.IsSome && thumb.StartsWith("jellyfin://"))
{
string address = maybeJellyfin.Map(ms => ms.Connections.HeadOrNone().Map(c => c.Address))
.Flatten()
.IfNone("jellyfin://");
thumb = thumb.Replace("jellyfin://", address) +
"&fillHeight=220"; // TODO: this height is optimized for episode
thumb = JellyfinUrl.ForArtwork(maybeJellyfin, thumb)
.SetQueryParam("fillHeight", 220);
}
else if (maybeEmby.IsSome && thumb.StartsWith("emby://"))
{
thumb = EmbyUrl.ForArtwork(maybeEmby, thumb)
.SetQueryParam("fillHeight", 220);
}
return thumb;
@@ -30,10 +30,13 @@ namespace ErsatzTV.Application.MediaCards.Queries
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone());
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
.Map(list => list.HeadOrNone());
return await _collectionRepository
.GetCollectionWithItemsUntracked(request.Id)
.Map(c => c.ToEither(BaseError.New("Unable to load collection")))
.MapT(c => ProjectToViewModel(c, maybeJellyfin));
.MapT(c => ProjectToViewModel(c, maybeJellyfin, maybeEmby));
}
}
}
@@ -34,9 +34,12 @@ namespace ErsatzTV.Application.MediaCards.Queries
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone());
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
.Map(list => list.HeadOrNone());
List<TelevisionEpisodeCardViewModel> results = await _televisionRepository
.GetPagedEpisodes(request.TelevisionSeasonId, request.PageNumber, request.PageSize)
.Map(list => list.Map(e => ProjectToViewModel(e, maybeJellyfin)).ToList());
.Map(list => list.Map(e => ProjectToViewModel(e, maybeJellyfin, maybeEmby)).ToList());
return new TelevisionEpisodeCardResultsViewModel(count, results);
}
@@ -34,9 +34,12 @@ namespace ErsatzTV.Application.MediaCards.Queries
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone());
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
.Map(list => list.HeadOrNone());
List<TelevisionSeasonCardViewModel> results = await _televisionRepository
.GetPagedSeasons(request.TelevisionShowId, request.PageNumber, request.PageSize)
.Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin)).ToList());
.Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby)).ToList());
return new TelevisionSeasonCardResultsViewModel(count, results);
}
@@ -17,7 +17,5 @@ namespace ErsatzTV.Application.MediaCards
Title,
$"Episode {Episode}",
$"Episode {Episode}",
Poster)
{
}
Poster);
}
@@ -14,7 +14,5 @@
Title,
Subtitle,
SortTitle,
Poster)
{
}
Poster);
}
@@ -6,7 +6,5 @@
Title,
Subtitle,
SortTitle,
Poster)
{
}
Poster);
}
@@ -0,0 +1,4 @@
namespace ErsatzTV.Application.MediaSources
{
public record RemoteMediaSourceViewModel(int Id, string Name, string Address) : MediaSourceViewModel(Id, Name);
}
+26 -11
View File
@@ -3,6 +3,9 @@ using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Jellyfin;
using Flurl;
using LanguageExt;
using static LanguageExt.Prelude;
@@ -10,7 +13,10 @@ namespace ErsatzTV.Application.Movies
{
internal static class Mapper
{
internal static MovieViewModel ProjectToViewModel(Movie movie, Option<JellyfinMediaSource> maybeJellyfin)
internal static MovieViewModel ProjectToViewModel(
Movie movie,
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby)
{
MovieMetadata metadata = Optional(movie.MovieMetadata).Flatten().Head();
return new MovieViewModel(
@@ -22,11 +28,11 @@ namespace ErsatzTV.Application.Movies
metadata.Studios.Map(s => s.Name).ToList(),
LanguagesForMovie(movie),
metadata.Actors.OrderBy(a => a.Order).ThenBy(a => a.Id)
.Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin))
.Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin, maybeEmby))
.ToList())
{
Poster = Artwork(metadata, ArtworkKind.Poster, maybeJellyfin),
FanArt = Artwork(metadata, ArtworkKind.FanArt, maybeJellyfin)
Poster = Artwork(metadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby),
FanArt = Artwork(metadata, ArtworkKind.FanArt, maybeJellyfin, maybeEmby)
};
}
@@ -49,22 +55,31 @@ namespace ErsatzTV.Application.Movies
private static string Artwork(
Metadata metadata,
ArtworkKind artworkKind,
Option<JellyfinMediaSource> maybeJellyfin)
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby)
{
string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
.Match(a => a.Path, string.Empty);
if (maybeJellyfin.IsSome && artwork.StartsWith("jellyfin://"))
{
string address = maybeJellyfin.Map(ms => ms.Connections.HeadOrNone().Map(c => c.Address))
.Flatten()
.IfNone("jellyfin://");
artwork = artwork.Replace("jellyfin://", address);
Url url = JellyfinUrl.ForArtwork(maybeJellyfin, artwork);
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
{
artwork += "&fillHeight=440";
url.SetQueryParam("fillHeight", 440);
}
artwork = url;
}
else if (maybeEmby.IsSome && artwork.StartsWith("emby://"))
{
Url url = EmbyUrl.ForArtwork(maybeEmby, artwork);
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
{
url.SetQueryParam("fillHeight", 440);
}
artwork = url;
}
return artwork;
@@ -26,8 +26,11 @@ namespace ErsatzTV.Application.Movies.Queries
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone());
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
.Map(list => list.HeadOrNone());
Option<Movie> movie = await _movieRepository.GetMovie(request.Id);
return movie.Map(m => ProjectToViewModel(m, maybeJellyfin));
return movie.Map(m => ProjectToViewModel(m, maybeJellyfin, maybeEmby));
}
}
}
@@ -132,11 +132,14 @@ namespace ErsatzTV.Application.ProgramSchedules.Commands
CollectionType = item.CollectionType,
CollectionId = item.CollectionId,
MediaItemId = item.MediaItemId,
PlayoutDuration = item.PlayoutDuration.GetValueOrDefault(),
PlayoutDuration = FixDuration(item.PlayoutDuration.GetValueOrDefault()),
OfflineTail = item.OfflineTail.GetValueOrDefault(),
CustomTitle = item.CustomTitle
},
_ => throw new NotSupportedException($"Unsupported playout mode {item.PlayoutMode}")
};
private static TimeSpan FixDuration(TimeSpan duration) =>
duration > TimeSpan.FromDays(1) ? duration.Subtract(TimeSpan.FromDays(1)) : duration;
}
}
@@ -41,9 +41,12 @@ namespace ErsatzTV.Application.Search.Queries
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone());
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
.Map(list => list.HeadOrNone());
List<MovieCardViewModel> items = await _movieRepository
.GetMoviesForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(m => ProjectToViewModel(m, maybeJellyfin)).ToList());
.Map(list => list.Map(m => ProjectToViewModel(m, maybeJellyfin, maybeEmby)).ToList());
return new MovieCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}
@@ -42,9 +42,12 @@ namespace ErsatzTV.Application.Search.Queries
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone());
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
.Map(list => list.HeadOrNone());
List<TelevisionShowCardViewModel> items = await _televisionRepository
.GetShowsForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin)).ToList());
.Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby)).ToList());
return new TelevisionShowCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}
+46 -21
View File
@@ -4,6 +4,9 @@ using System.Globalization;
using System.Linq;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Jellyfin;
using Flurl;
using LanguageExt;
using static LanguageExt.Prelude;
@@ -14,14 +17,15 @@ namespace ErsatzTV.Application.Television
internal static TelevisionShowViewModel ProjectToViewModel(
Show show,
List<string> languages,
Option<JellyfinMediaSource> maybeJellyfin) =>
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby) =>
new(
show.Id,
show.ShowMetadata.HeadOrNone().Map(m => m.Title ?? string.Empty).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(m => m.Year?.ToString() ?? string.Empty).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(m => m.Plot ?? string.Empty).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(m => GetPoster(m, maybeJellyfin)).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(m => GetFanArt(m, maybeJellyfin)).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(m => GetPoster(m, maybeJellyfin, maybeEmby)).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(m => GetFanArt(m, maybeJellyfin, maybeEmby)).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(m => m.Genres.Map(g => g.Name).ToList()).IfNone(new List<string>()),
show.ShowMetadata.HeadOrNone().Map(m => m.Tags.Map(g => g.Name).ToList()).IfNone(new List<string>()),
show.ShowMetadata.HeadOrNone().Map(m => m.Studios.Map(s => s.Name).ToList())
@@ -30,21 +34,24 @@ namespace ErsatzTV.Application.Television
show.ShowMetadata.HeadOrNone()
.Map(
m => m.Actors.OrderBy(a => a.Order).ThenBy(a => a.Id)
.Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin))
.Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin, maybeEmby))
.ToList())
.IfNone(new List<ActorCardViewModel>()));
internal static TelevisionSeasonViewModel ProjectToViewModel(
Season season,
Option<JellyfinMediaSource> maybeJellyfin) =>
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby) =>
new(
season.Id,
season.ShowId,
season.Show.ShowMetadata.HeadOrNone().Map(m => m.Title ?? string.Empty).IfNone(string.Empty),
season.Show.ShowMetadata.HeadOrNone().Map(m => m.Year?.ToString() ?? string.Empty).IfNone(string.Empty),
season.SeasonNumber == 0 ? "Specials" : $"Season {season.SeasonNumber}",
season.SeasonMetadata.HeadOrNone().Map(m => GetPoster(m, maybeJellyfin)).IfNone(string.Empty),
season.Show.ShowMetadata.HeadOrNone().Map(m => GetFanArt(m, maybeJellyfin)).IfNone(string.Empty));
season.SeasonMetadata.HeadOrNone().Map(m => GetPoster(m, maybeJellyfin, maybeEmby))
.IfNone(string.Empty),
season.Show.ShowMetadata.HeadOrNone().Map(m => GetFanArt(m, maybeJellyfin, maybeEmby))
.IfNone(string.Empty));
internal static TelevisionEpisodeViewModel ProjectToViewModel(Episode episode) =>
new(
@@ -53,36 +60,54 @@ namespace ErsatzTV.Application.Television
episode.EpisodeNumber,
episode.EpisodeMetadata.HeadOrNone().Map(m => m.Title ?? string.Empty).IfNone(string.Empty),
episode.EpisodeMetadata.HeadOrNone().Map(m => m.Plot ?? string.Empty).IfNone(string.Empty),
episode.EpisodeMetadata.HeadOrNone().Map(m => GetThumbnail(m, None)).IfNone(string.Empty));
episode.EpisodeMetadata.HeadOrNone().Map(m => GetThumbnail(m, None, None)).IfNone(string.Empty));
private static string GetPoster(Metadata metadata, Option<JellyfinMediaSource> maybeJellyfin) =>
GetArtwork(metadata, ArtworkKind.Poster, maybeJellyfin);
private static string GetPoster(
Metadata metadata,
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby) =>
GetArtwork(metadata, ArtworkKind.Poster, maybeJellyfin, maybeEmby);
private static string GetFanArt(Metadata metadata, Option<JellyfinMediaSource> maybeJellyfin) =>
GetArtwork(metadata, ArtworkKind.FanArt, maybeJellyfin);
private static string GetFanArt(
Metadata metadata,
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby) =>
GetArtwork(metadata, ArtworkKind.FanArt, maybeJellyfin, maybeEmby);
private static string GetThumbnail(Metadata metadata, Option<JellyfinMediaSource> maybeJellyfin) =>
GetArtwork(metadata, ArtworkKind.Thumbnail, maybeJellyfin);
private static string GetThumbnail(
Metadata metadata,
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby) =>
GetArtwork(metadata, ArtworkKind.Thumbnail, maybeJellyfin, maybeEmby);
private static string GetArtwork(
Metadata metadata,
ArtworkKind artworkKind,
Option<JellyfinMediaSource> maybeJellyfin)
Option<JellyfinMediaSource> maybeJellyfin,
Option<EmbyMediaSource> maybeEmby)
{
string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
.Match(a => a.Path, string.Empty);
if (maybeJellyfin.IsSome && artwork.StartsWith("jellyfin://"))
{
string address = maybeJellyfin.Map(ms => ms.Connections.HeadOrNone().Map(c => c.Address))
.Flatten()
.IfNone("jellyfin://");
artwork = artwork.Replace("jellyfin://", address);
Url url = JellyfinUrl.ForArtwork(maybeJellyfin, artwork);
if (artworkKind == ArtworkKind.Poster)
{
artwork += "&fillHeight=440";
url.SetQueryParam("fillHeight", 440);
}
artwork = url;
}
else if (maybeEmby.IsSome && artwork.StartsWith("emby://"))
{
Url url = EmbyUrl.ForArtwork(maybeEmby, artwork);
if (artworkKind == ArtworkKind.Poster)
{
url.SetQueryParam("fillHeight", 440);
}
artwork = url;
}
return artwork;
@@ -29,8 +29,11 @@ namespace ErsatzTV.Application.Television.Queries
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone());
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
.Map(list => list.HeadOrNone());
return await _televisionRepository.GetSeason(request.SeasonId)
.MapT(s => ProjectToViewModel(s, maybeJellyfin));
.MapT(s => ProjectToViewModel(s, maybeJellyfin, maybeEmby));
}
}
}
@@ -36,8 +36,11 @@ namespace ErsatzTV.Application.Television.Queries
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone());
Option<EmbyMediaSource> maybeEmby = await _mediaSourceRepository.GetAllEmby()
.Map(list => list.HeadOrNone());
List<string> languages = await _searchRepository.GetLanguagesForShow(show);
return ProjectToViewModel(show, languages, maybeJellyfin);
return ProjectToViewModel(show, languages, maybeJellyfin, maybeEmby);
},
() => Task.FromResult(Option<TelevisionShowViewModel>.None));
}
@@ -13,13 +13,13 @@
<PackageReference Include="FluentAssertions" Version="5.10.3" />
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="5.0.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.9.4" />
<PackageReference Include="Microsoft.VisualStudio.Threading.Analyzers" Version="16.9.60">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.16.1" />
<PackageReference Include="NUnit" Version="3.13.1" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="3.17.0" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="3.0.1" />
@@ -107,7 +107,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
}
[Test]
public void ShouldNot_SetRealtime_ForHttpLiveStreaming()
public void Should_SetRealtime_ForHttpLiveStreaming()
{
FFmpegProfile ffmpegProfile = TestProfile();
@@ -120,7 +120,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
DateTimeOffset.Now,
DateTimeOffset.Now);
actual.RealtimeOutput.Should().BeFalse();
actual.RealtimeOutput.Should().BeTrue();
}
[Test]
@@ -0,0 +1,121 @@
using System.Collections.Generic;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Jellyfin;
using FluentAssertions;
using Flurl;
using NUnit.Framework;
using static LanguageExt.Prelude;
namespace ErsatzTV.Core.Tests.Jellyfin
{
public class JellyfinUrlTests
{
[Test]
public void Should_Work_Without_Trailing_Slash()
{
var artwork = "jellyfin://Items/2/Images/3?tag=4";
var address = "https://some.jellyfin.server";
var mediaSource = new JellyfinMediaSource
{
Connections = new List<JellyfinConnection>
{
new() { Address = address }
}
};
Url url = JellyfinUrl.ForArtwork(Some(mediaSource), artwork);
url.ToString().Should().Be("https://some.jellyfin.server/Items/2/Images/3?tag=4");
}
[Test]
public void Should_Work_With_Trailing_Slash()
{
var artwork = "jellyfin://Items/2/Images/3?tag=4";
var address = "https://some.jellyfin.server/";
var mediaSource = new JellyfinMediaSource
{
Connections = new List<JellyfinConnection>
{
new() { Address = address }
}
};
Url url = JellyfinUrl.ForArtwork(Some(mediaSource), artwork);
url.ToString().Should().Be("https://some.jellyfin.server/Items/2/Images/3?tag=4");
}
[Test]
public void Should_Work_With_Port_Without_Trailing_Slash()
{
var artwork = "jellyfin://Items/2/Images/3?tag=4";
var address = "https://some.jellyfin.server:1000";
var mediaSource = new JellyfinMediaSource
{
Connections = new List<JellyfinConnection>
{
new() { Address = address }
}
};
Url url = JellyfinUrl.ForArtwork(Some(mediaSource), artwork);
url.ToString().Should().Be("https://some.jellyfin.server:1000/Items/2/Images/3?tag=4");
}
[Test]
public void Should_Work_With_Port_With_Trailing_Slash()
{
var artwork = "jellyfin://Items/2/Images/3?tag=4";
var address = "https://some.jellyfin.server:1000/";
var mediaSource = new JellyfinMediaSource
{
Connections = new List<JellyfinConnection>
{
new() { Address = address }
}
};
Url url = JellyfinUrl.ForArtwork(Some(mediaSource), artwork);
url.ToString().Should().Be("https://some.jellyfin.server:1000/Items/2/Images/3?tag=4");
}
[Test]
public void Should_Work_With_Path_Prefix_Without_Trailing_Slash()
{
var artwork = "jellyfin://Items/2/Images/3?tag=4";
var address = "https://some.jellyfin.server/jellyfin";
var mediaSource = new JellyfinMediaSource
{
Connections = new List<JellyfinConnection>
{
new() { Address = address }
}
};
Url url = JellyfinUrl.ForArtwork(Some(mediaSource), artwork);
url.ToString().Should().Be("https://some.jellyfin.server/jellyfin/Items/2/Images/3?tag=4");
}
[Test]
public void Should_Work_With_Path_Prefix_With_Trailing_Slash()
{
var artwork = "jellyfin://Items/2/Images/3?tag=4";
var address = "https://some.jellyfin.server/jellyfin/";
var mediaSource = new JellyfinMediaSource
{
Connections = new List<JellyfinConnection>
{
new() { Address = address }
}
};
Url url = JellyfinUrl.ForArtwork(Some(mediaSource), artwork);
url.ToString().Should().Be("https://some.jellyfin.server/jellyfin/Items/2/Images/3?tag=4");
}
}
}
@@ -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 @@
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 @@
namespace ErsatzTV.Core.Domain
{
public class EmbyMovie : Movie
{
public string ItemId { get; set; }
public string Etag { get; set; }
}
}
@@ -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 @@
namespace ErsatzTV.Core.Domain
{
public class EmbyShow : Show
{
public string ItemId { get; set; }
public string Etag { get; set; }
}
}
@@ -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 @@
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 @@
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; }
}
}
+8
View File
@@ -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 @@
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 @@
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");
}
}
+8
View File
@@ -0,0 +1,8 @@
using ErsatzTV.Core.MediaSources;
namespace ErsatzTV.Core.Emby
{
public class EmbySecrets : RemoteMediaSourceSecrets
{
}
}
@@ -0,0 +1,4 @@
namespace ErsatzTV.Core.Emby
{
public record EmbyServerInformation(string ServerName, string OperatingSystem);
}
@@ -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));
}
}
}
}
}
+31
View File
@@ -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;
}
}
}
+2 -1
View File
@@ -10,6 +10,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Flurl" Version="3.0.2" />
<PackageReference Include="LanguageExt.Core" Version="3.4.15" />
<PackageReference Include="MediatR" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="5.0.0" />
@@ -18,7 +19,7 @@
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="12.0.3" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="Serilog" Version="2.10.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="3.1.1" />
</ItemGroup>
@@ -18,7 +18,6 @@ namespace ErsatzTV.Core.FFmpeg
private string _inputCodec;
private bool _normalizeLoudness;
private Option<IDisplaySize> _padToSize = None;
private bool _realtime;
private Option<IDisplaySize> _scaleToSize = None;
public FFmpegComplexFilterBuilder WithHardwareAcceleration(HardwareAccelerationKind hardwareAccelerationKind)
@@ -27,12 +26,6 @@ namespace ErsatzTV.Core.FFmpeg
return this;
}
public FFmpegComplexFilterBuilder WithRealtime(bool realtime)
{
_realtime = realtime;
return this;
}
public FFmpegComplexFilterBuilder WithScaling(IDisplaySize scaleToSize)
{
_scaleToSize = Some(scaleToSize);
@@ -102,11 +95,6 @@ namespace ErsatzTV.Core.FFmpeg
_audioDuration.IfSome(
audioDuration => audioFilterQueue.Add($"apad=whole_dur={audioDuration.TotalMilliseconds}ms"));
if (_realtime)
{
videoFilterQueue.Add("realtime");
}
bool usesHardwareFilters = acceleration != HardwareAccelerationKind.None && !isHardwareDecode &&
(_deinterlace || _scaleToSize.IsSome);
if (usesHardwareFilters)
@@ -12,7 +12,7 @@ namespace ErsatzTV.Core.FFmpeg
public List<string> FormatFlags { get; set; }
public HardwareAccelerationKind HardwareAcceleration { get; set; }
public string VideoDecoder { get; set; }
public bool RealtimeOutput { get; set; }
public bool RealtimeOutput => true;
public Option<TimeSpan> StreamSeek { get; set; }
public Option<IDisplaySize> ScaledSize { get; set; }
public bool PadToDesiredResolution { get; set; }
@@ -68,10 +68,8 @@ namespace ErsatzTV.Core.FFmpeg
result.AudioCodec = "copy";
result.VideoCodec = "copy";
result.Deinterlace = false;
result.RealtimeOutput = false;
break;
case StreamingMode.TransportStream:
result.RealtimeOutput = true;
result.HardwareAcceleration = ffmpegProfile.HardwareAcceleration;
if (NeedToScale(ffmpegProfile, version))
+6 -2
View File
@@ -90,7 +90,11 @@ namespace ErsatzTV.Core.FFmpeg
public FFmpegProcessBuilder WithRealtimeOutput(bool realtimeOutput)
{
_complexFilterBuilder = _complexFilterBuilder.WithRealtime(realtimeOutput);
if (realtimeOutput)
{
_arguments.Add("-re");
}
return this;
}
@@ -389,7 +393,7 @@ namespace ErsatzTV.Core.FFmpeg
{
FileName = _ffmpegPath,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardError = false,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8
+1
View File
@@ -19,6 +19,7 @@ namespace ErsatzTV.Core
public static readonly string PlexSecretsPath = Path.Combine(AppDataFolder, "plex-secrets.json");
public static readonly string JellyfinSecretsPath = Path.Combine(AppDataFolder, "jellyfin-secrets.json");
public static readonly string EmbySecretsPath = Path.Combine(AppDataFolder, "emby-secrets.json");
public static readonly string FFmpegReportsFolder = Path.Combine(AppDataFolder, "ffmpeg-reports");
public static readonly string SearchIndexFolder = Path.Combine(AppDataFolder, "search-index");
@@ -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 @@
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 @@
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 @@
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.MediaSources;
namespace ErsatzTV.Core.Interfaces.Emby
{
public interface IEmbySecretStore : IRemoteMediaSourceSecretStore<EmbySecrets>
{
}
}
@@ -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 @@
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);
}
}
@@ -6,14 +6,14 @@ namespace ErsatzTV.Core.Interfaces.Locking
{
event EventHandler OnLibraryChanged;
event EventHandler OnPlexChanged;
event EventHandler OnJellyfinChanged;
event EventHandler<Type> OnRemoteMediaSourceChanged;
bool LockLibrary(int libraryId);
bool UnlockLibrary(int libraryId);
bool IsLibraryLocked(int libraryId);
bool LockPlex();
bool UnlockPlex();
bool IsPlexLocked();
bool LockJellyfin();
bool UnlockJellyfin();
bool LockRemoteMediaSource<TMediaSource>();
bool UnlockRemoteMediaSource<TMediaSource>();
}
}
@@ -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 @@
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);
}
}
@@ -37,6 +37,11 @@ namespace ErsatzTV.Core.Interfaces.Repositories
List<JellyfinLibrary> toAdd,
List<JellyfinLibrary> toDelete);
Task<Unit> UpdateLibraries(
int embyMediaSourceId,
List<EmbyLibrary> toAdd,
List<EmbyLibrary> toDelete);
Task<Unit> UpdatePathReplacements(
int plexMediaSourceId,
List<PlexPathReplacement> toAdd,
@@ -68,5 +73,24 @@ namespace ErsatzTV.Core.Interfaces.Repositories
List<JellyfinPathReplacement> toDelete);
Task<List<int>> DeleteAllJellyfin();
Task<Unit> UpsertEmby(string address, string serverName, string operatingSystem);
Task<List<EmbyMediaSource>> GetAllEmby();
Task<Option<EmbyMediaSource>> GetEmby(int id);
Task<Option<EmbyMediaSource>> GetEmbyByLibraryId(int embyLibraryId);
Task<Option<EmbyLibrary>> GetEmbyLibrary(int embyLibraryId);
Task<List<EmbyLibrary>> GetEmbyLibraries(int embyMediaSourceId);
Task<List<EmbyPathReplacement>> GetEmbyPathReplacements(int embyMediaSourceId);
Task<List<EmbyPathReplacement>> GetEmbyPathReplacementsByLibraryId(int embyLibraryPathId);
Task<Unit> UpdatePathReplacements(
int embyMediaSourceId,
List<EmbyPathReplacement> toAdd,
List<EmbyPathReplacement> toUpdate,
List<EmbyPathReplacement> toDelete);
Task<List<int>> DeleteAllEmby();
Task<Unit> EnableEmbyLibrarySync(IEnumerable<int> libraryIds);
Task<List<int>> DisableEmbyLibrarySync(List<int> libraryIds);
}
}
@@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata;
using LanguageExt;
@@ -28,5 +29,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<List<int>> RemoveMissingJellyfinMovies(JellyfinLibrary library, List<string> movieIds);
Task<bool> AddJellyfin(JellyfinMovie movie);
Task<Option<JellyfinMovie>> UpdateJellyfin(JellyfinMovie movie);
Task<List<EmbyItemEtag>> GetExistingEmbyMovies(EmbyLibrary library);
Task<List<int>> RemoveMissingEmbyMovies(EmbyLibrary library, List<string> movieIds);
Task<bool> AddEmby(EmbyMovie movie);
Task<Option<EmbyMovie>> UpdateEmby(EmbyMovie movie);
}
}
@@ -86,7 +86,7 @@ namespace ErsatzTV.Core.Jellyfin
if (!_localFileSystem.FileExists(localPath))
{
_logger.LogWarning($"Skipping jellyfin movie that does not exist at {localPath}");
_logger.LogWarning("Skipping jellyfin movie that does not exist at {Path}", localPath);
}
else
{
+4 -4
View File
@@ -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; }
}
}
@@ -348,7 +348,7 @@ namespace ErsatzTV.Core.Jellyfin
"UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}",
showName,
seasonName,
"EPISODE");
incoming.EpisodeNumber);
updateStatistics = true;
incoming.SeasonId = season.Id;
@@ -377,7 +377,7 @@ namespace ErsatzTV.Core.Jellyfin
"INSERT: Item id is new for show {Show} season {Season} episode {Episode}",
showName,
seasonName,
"EPISODE");
incoming.EpisodeNumber);
await _televisionRepository.AddEpisode(incoming);
}
+31
View File
@@ -0,0 +1,31 @@
using ErsatzTV.Core.Domain;
using Flurl;
using LanguageExt;
namespace ErsatzTV.Core.Jellyfin
{
public static class JellyfinUrl
{
public static Url ForArtwork(Option<JellyfinMediaSource> maybeJellyfin, string artwork)
{
string address = maybeJellyfin.Map(ms => ms.Connections.HeadOrNone().Map(c => c.Address))
.Flatten()
.IfNone("jellyfin://");
string[] split = artwork.Replace("jellyfin://", 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,8 @@
namespace ErsatzTV.Core.MediaSources
{
public class RemoteMediaSourceSecrets
{
public string Address { get; set; }
public string ApiKey { get; set; }
}
}
@@ -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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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;
}
}
}

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