Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ec838da68 | ||
|
|
fc5bedc70b | ||
|
|
4d86250630 | ||
|
|
27e0a70d93 | ||
|
|
198e595bc6 | ||
|
|
b178b7402b | ||
|
|
1c51aed162 | ||
|
|
ff6a4c5ea2 | ||
|
|
e515df93fd | ||
|
|
fedc18f7db | ||
|
|
59d75fe08f | ||
|
|
49d9b1c714 | ||
|
|
2f066d5b62 | ||
|
|
63db2edb99 | ||
|
|
5d01276ef3 | ||
|
|
050aaaa288 | ||
|
|
7c07c5f522 | ||
|
|
d8d21996b4 | ||
|
|
e368d4a075 | ||
|
|
466059e2aa | ||
|
|
e951ecb650 | ||
|
|
1d1f53da01 | ||
|
|
a854294cb6 | ||
|
|
f89f3d2225 |
@@ -0,0 +1,7 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration.Commands
|
||||
{
|
||||
public record SaveConfigElementByKey(ConfigElementKey Key, string Value) : MediatR.IRequest<Unit>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration.Commands
|
||||
{
|
||||
public class SaveConfigElementByKeyHandler : MediatR.IRequestHandler<SaveConfigElementByKey, Unit>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public SaveConfigElementByKeyHandler(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public async Task<Unit> Handle(SaveConfigElementByKey request, CancellationToken cancellationToken)
|
||||
{
|
||||
Option<ConfigElement> maybeElement = await _configElementRepository.Get(request.Key);
|
||||
await maybeElement.Match(
|
||||
ce =>
|
||||
{
|
||||
ce.Value = request.Value;
|
||||
return _configElementRepository.Update(ce);
|
||||
},
|
||||
() =>
|
||||
{
|
||||
var ce = new ConfigElement { Key = request.Key.Key, Value = request.Value };
|
||||
return _configElementRepository.Add(ce);
|
||||
});
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration.Commands
|
||||
{
|
||||
public record UpdateLibraryRefreshInterval(int LibraryRefreshInterval) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration.Commands
|
||||
{
|
||||
public class
|
||||
UpdateLibraryRefreshIntervalHandler : MediatR.IRequestHandler<UpdateLibraryRefreshInterval,
|
||||
Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public UpdateLibraryRefreshIntervalHandler(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateLibraryRefreshInterval request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(_ => Upsert(ConfigElementKey.LibraryRefreshInterval, request.LibraryRefreshInterval.ToString()))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private Task<Validation<BaseError, Unit>> Validate(UpdateLibraryRefreshInterval request) =>
|
||||
Optional(request.LibraryRefreshInterval)
|
||||
.Filter(lri => lri > 0)
|
||||
.Map(_ => Unit.Default)
|
||||
.ToValidation<BaseError>("Tuner count must be greater than zero")
|
||||
.AsTask();
|
||||
|
||||
private Task<Unit> Upsert(ConfigElementKey key, string value) =>
|
||||
_configElementRepository.Get(key).Match(
|
||||
ce =>
|
||||
{
|
||||
ce.Value = value;
|
||||
return _configElementRepository.Update(ce);
|
||||
},
|
||||
() =>
|
||||
{
|
||||
var ce = new ConfigElement { Key = key.Key, Value = value };
|
||||
return _configElementRepository.Add(ce);
|
||||
}).ToUnit();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Application.Configuration
|
||||
{
|
||||
public record ConfigElementViewModel(string Key, string Value);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration
|
||||
{
|
||||
internal static class Mapper
|
||||
{
|
||||
internal static ConfigElementViewModel ProjectToViewModel(ConfigElement element) =>
|
||||
new(element.Key, element.Value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration.Queries
|
||||
{
|
||||
public record GetConfigElementByKey(ConfigElementKey Key) : IRequest<Option<ConfigElementViewModel>>;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using static ErsatzTV.Application.Configuration.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration.Queries
|
||||
{
|
||||
public class GetConfigElementByKeyHandler : IRequestHandler<GetConfigElementByKey, Option<ConfigElementViewModel>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public GetConfigElementByKeyHandler(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public Task<Option<ConfigElementViewModel>> Handle(
|
||||
GetConfigElementByKey request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_configElementRepository.Get(request.Key).MapT(ProjectToViewModel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration.Queries
|
||||
{
|
||||
public record GetLibraryRefreshInterval : IRequest<int>;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Configuration.Queries
|
||||
{
|
||||
public class GetLibraryRefreshIntervalHandler : IRequestHandler<GetLibraryRefreshInterval, int>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
|
||||
public GetLibraryRefreshIntervalHandler(IConfigElementRepository configElementRepository) =>
|
||||
_configElementRepository = configElementRepository;
|
||||
|
||||
public Task<int> Handle(GetLibraryRefreshInterval request, CancellationToken cancellationToken) =>
|
||||
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
|
||||
.Map(result => result.IfNone(6));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
{
|
||||
public record CopyFFmpegProfile
|
||||
(int FFmpegProfileId, string Name) : IRequest<Either<BaseError, FFmpegProfileViewModel>>;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using static ErsatzTV.Application.FFmpegProfiles.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
{
|
||||
public class
|
||||
CopyFFmpegProfileHandler : IRequestHandler<CopyFFmpegProfile, Either<BaseError, FFmpegProfileViewModel>>
|
||||
{
|
||||
private readonly IFFmpegProfileRepository _ffmpegProfileRepository;
|
||||
|
||||
public CopyFFmpegProfileHandler(IFFmpegProfileRepository ffmpegProfileRepository) =>
|
||||
_ffmpegProfileRepository = ffmpegProfileRepository;
|
||||
|
||||
public Task<Either<BaseError, FFmpegProfileViewModel>> Handle(
|
||||
CopyFFmpegProfile request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(PerformCopy)
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private Task<FFmpegProfileViewModel> PerformCopy(CopyFFmpegProfile request) =>
|
||||
_ffmpegProfileRepository.Copy(request.FFmpegProfileId, request.Name)
|
||||
.Map(ProjectToViewModel);
|
||||
|
||||
private Task<Validation<BaseError, CopyFFmpegProfile>> Validate(CopyFFmpegProfile request) =>
|
||||
ValidateName(request).AsTask().MapT(_ => request);
|
||||
|
||||
private Validation<BaseError, string> ValidateName(CopyFFmpegProfile request) =>
|
||||
request.NotEmpty(x => x.Name)
|
||||
.Bind(_ => request.NotLongerThan(50)(x => x.Name));
|
||||
}
|
||||
}
|
||||
@@ -86,8 +86,10 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private Task Upsert(ConfigElementKey key, string value) =>
|
||||
_configElementRepository.Get(key).Match(
|
||||
private async Task Upsert(ConfigElementKey key, string value)
|
||||
{
|
||||
Option<ConfigElement> maybeElement = await _configElementRepository.Get(key);
|
||||
await maybeElement.Match(
|
||||
ce =>
|
||||
{
|
||||
ce.Value = value;
|
||||
@@ -98,5 +100,6 @@ namespace ErsatzTV.Application.FFmpegProfiles.Commands
|
||||
var ce = new ConfigElement { Key = key.Key, Value = value };
|
||||
return _configElementRepository.Add(ce);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Application
|
||||
{
|
||||
public interface IJellyfinBackgroundServiceRequest
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
{
|
||||
public record DisconnectJellyfin : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
{
|
||||
public class DisconnectJellyfinHandler : MediatR.IRequestHandler<DisconnectJellyfin, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly IJellyfinSecretStore _jellyfinSecretStore;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public DisconnectJellyfinHandler(
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IJellyfinSecretStore jellyfinSecretStore,
|
||||
IEntityLocker entityLocker,
|
||||
ISearchIndex searchIndex)
|
||||
{
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_jellyfinSecretStore = jellyfinSecretStore;
|
||||
_entityLocker = entityLocker;
|
||||
_searchIndex = searchIndex;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
DisconnectJellyfin request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<int> ids = await _mediaSourceRepository.DeleteAllJellyfin();
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
await _jellyfinSecretStore.DeleteAll();
|
||||
_entityLocker.UnlockJellyfin();
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
{
|
||||
public record SaveJellyfinSecrets(JellyfinSecrets 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.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
{
|
||||
public class SaveJellyfinSecretsHandler : MediatR.IRequestHandler<SaveJellyfinSecrets, Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly ChannelWriter<IJellyfinBackgroundServiceRequest> _channel;
|
||||
private readonly IJellyfinApiClient _jellyfinApiClient;
|
||||
private readonly IJellyfinSecretStore _jellyfinSecretStore;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
|
||||
public SaveJellyfinSecretsHandler(
|
||||
IJellyfinSecretStore jellyfinSecretStore,
|
||||
IJellyfinApiClient jellyfinApiClient,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
ChannelWriter<IJellyfinBackgroundServiceRequest> channel)
|
||||
{
|
||||
_jellyfinSecretStore = jellyfinSecretStore;
|
||||
_jellyfinApiClient = jellyfinApiClient;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(SaveJellyfinSecrets request, CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(PerformSave)
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private async Task<Validation<BaseError, Parameters>> Validate(SaveJellyfinSecrets request)
|
||||
{
|
||||
Either<BaseError, JellyfinServerInformation> maybeServerInformation = await _jellyfinApiClient
|
||||
.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 _jellyfinSecretStore.SaveSecrets(parameters.Secrets);
|
||||
await _mediaSourceRepository.UpsertJellyfin(
|
||||
parameters.Secrets.Address,
|
||||
parameters.ServerInformation.ServerName,
|
||||
parameters.ServerInformation.OperatingSystem);
|
||||
await _channel.WriteAsync(new SynchronizeJellyfinMediaSources());
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private record Parameters(JellyfinSecrets Secrets, JellyfinServerInformation ServerInformation);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
{
|
||||
public record SynchronizeJellyfinAdminUserId(int JellyfinMediaSourceId) : MediatR.IRequest<Either<BaseError, Unit>>,
|
||||
IJellyfinBackgroundServiceRequest;
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
{
|
||||
public class
|
||||
SynchronizeJellyfinAdminUserIdHandler : MediatR.IRequestHandler<SynchronizeJellyfinAdminUserId,
|
||||
Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IJellyfinApiClient _jellyfinApiClient;
|
||||
private readonly IJellyfinSecretStore _jellyfinSecretStore;
|
||||
private readonly ILogger<SynchronizeJellyfinAdminUserIdHandler> _logger;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
|
||||
public SynchronizeJellyfinAdminUserIdHandler(
|
||||
IMemoryCache memoryCache,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IJellyfinSecretStore jellyfinSecretStore,
|
||||
IJellyfinApiClient jellyfinApiClient,
|
||||
ILogger<SynchronizeJellyfinAdminUserIdHandler> logger)
|
||||
{
|
||||
_memoryCache = memoryCache;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_jellyfinSecretStore = jellyfinSecretStore;
|
||||
_jellyfinApiClient = jellyfinApiClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
SynchronizeJellyfinAdminUserId request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.Map(v => v.ToEither<ConnectionParameters>())
|
||||
.BindT(PerformSync);
|
||||
|
||||
private async Task<Either<BaseError, Unit>> PerformSync(ConnectionParameters parameters)
|
||||
{
|
||||
if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{parameters.JellyfinMediaSource.Id}", out string _))
|
||||
{
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
Either<BaseError, string> maybeUserId = await _jellyfinApiClient.GetAdminUserId(
|
||||
parameters.ActiveConnection.Address,
|
||||
parameters.ApiKey);
|
||||
|
||||
return await maybeUserId.Match(
|
||||
userId =>
|
||||
{
|
||||
_logger.LogDebug("Jellyfin admin user id is {UserId}", userId);
|
||||
_memoryCache.Set($"jellyfin_admin_user_id.{parameters.JellyfinMediaSource.Id}", userId);
|
||||
return Task.FromResult<Either<BaseError, Unit>>(Unit.Default);
|
||||
},
|
||||
async error =>
|
||||
{
|
||||
// clear api key if unable to sync with jellyfin
|
||||
if (error.Value.Contains("Unauthorized"))
|
||||
{
|
||||
await _jellyfinSecretStore.SaveSecrets(
|
||||
new JellyfinSecrets { Address = parameters.ActiveConnection.Address, ApiKey = null });
|
||||
}
|
||||
|
||||
return Left<BaseError, Unit>(error);
|
||||
});
|
||||
}
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate(SynchronizeJellyfinAdminUserId request) =>
|
||||
MediaSourceMustExist(request)
|
||||
.BindT(MediaSourceMustHaveActiveConnection)
|
||||
.BindT(MediaSourceMustHaveApiKey);
|
||||
|
||||
private Task<Validation<BaseError, JellyfinMediaSource>> MediaSourceMustExist(
|
||||
SynchronizeJellyfinAdminUserId request) =>
|
||||
_mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId)
|
||||
.Map(o => o.ToValidation<BaseError>("Jellyfin media source does not exist."));
|
||||
|
||||
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
|
||||
JellyfinMediaSource jellyfinMediaSource)
|
||||
{
|
||||
Option<JellyfinConnection> maybeConnection = jellyfinMediaSource.Connections.HeadOrNone();
|
||||
return maybeConnection.Map(connection => new ConnectionParameters(jellyfinMediaSource, connection))
|
||||
.ToValidation<BaseError>("Jellyfin media source requires an active connection");
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey(
|
||||
ConnectionParameters connectionParameters)
|
||||
{
|
||||
JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets();
|
||||
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
|
||||
.Filter(match => match)
|
||||
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
|
||||
.ToValidation<BaseError>("Jellyfin media source requires an api key");
|
||||
}
|
||||
|
||||
private record ConnectionParameters(
|
||||
JellyfinMediaSource JellyfinMediaSource,
|
||||
JellyfinConnection ActiveConnection)
|
||||
{
|
||||
public string ApiKey { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
{
|
||||
public record SynchronizeJellyfinLibraries(int JellyfinMediaSourceId) : MediatR.IRequest<Either<BaseError, Unit>>,
|
||||
IJellyfinBackgroundServiceRequest;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
{
|
||||
public class
|
||||
SynchronizeJellyfinLibrariesHandler : MediatR.IRequestHandler<SynchronizeJellyfinLibraries,
|
||||
Either<BaseError, Unit>>
|
||||
|
||||
{
|
||||
private readonly IJellyfinApiClient _jellyfinApiClient;
|
||||
private readonly IJellyfinSecretStore _jellyfinSecretStore;
|
||||
private readonly ILogger<SynchronizeJellyfinLibrariesHandler> _logger;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
|
||||
public SynchronizeJellyfinLibrariesHandler(
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IJellyfinSecretStore jellyfinSecretStore,
|
||||
IJellyfinApiClient jellyfinApiClient,
|
||||
ILogger<SynchronizeJellyfinLibrariesHandler> logger)
|
||||
{
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_jellyfinSecretStore = jellyfinSecretStore;
|
||||
_jellyfinApiClient = jellyfinApiClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
SynchronizeJellyfinLibraries request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(SynchronizeLibraries)
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate(SynchronizeJellyfinLibraries request) =>
|
||||
MediaSourceMustExist(request)
|
||||
.BindT(MediaSourceMustHaveActiveConnection)
|
||||
.BindT(MediaSourceMustHaveApiKey);
|
||||
|
||||
private Task<Validation<BaseError, JellyfinMediaSource>> MediaSourceMustExist(
|
||||
SynchronizeJellyfinLibraries request) =>
|
||||
_mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId)
|
||||
.Map(o => o.ToValidation<BaseError>("Jellyfin media source does not exist."));
|
||||
|
||||
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
|
||||
JellyfinMediaSource jellyfinMediaSource)
|
||||
{
|
||||
Option<JellyfinConnection> maybeConnection = jellyfinMediaSource.Connections.HeadOrNone();
|
||||
return maybeConnection.Map(connection => new ConnectionParameters(jellyfinMediaSource, connection))
|
||||
.ToValidation<BaseError>("Jellyfin media source requires an active connection");
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey(
|
||||
ConnectionParameters connectionParameters)
|
||||
{
|
||||
JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets();
|
||||
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
|
||||
.Filter(match => match)
|
||||
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
|
||||
.ToValidation<BaseError>("Jellyfin media source requires an api key");
|
||||
}
|
||||
|
||||
private async Task<Unit> SynchronizeLibraries(ConnectionParameters connectionParameters)
|
||||
{
|
||||
Either<BaseError, List<JellyfinLibrary>> maybeLibraries = await _jellyfinApiClient.GetLibraries(
|
||||
connectionParameters.ActiveConnection.Address,
|
||||
connectionParameters.ApiKey);
|
||||
|
||||
await maybeLibraries.Match(
|
||||
libraries =>
|
||||
{
|
||||
var existing = connectionParameters.JellyfinMediaSource.Libraries.OfType<JellyfinLibrary>()
|
||||
.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.JellyfinMediaSource.Id,
|
||||
toAdd,
|
||||
toRemove);
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to synchronize libraries from jellyfin server {JellyfinServer}: {Error}",
|
||||
connectionParameters.JellyfinMediaSource.ServerName,
|
||||
error.Value);
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private record ConnectionParameters(
|
||||
JellyfinMediaSource JellyfinMediaSource,
|
||||
JellyfinConnection ActiveConnection)
|
||||
{
|
||||
public string ApiKey { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
{
|
||||
public interface ISynchronizeJellyfinLibraryById : IRequest<Either<BaseError, string>>,
|
||||
IJellyfinBackgroundServiceRequest
|
||||
{
|
||||
int JellyfinLibraryId { get; }
|
||||
bool ForceScan { get; }
|
||||
}
|
||||
|
||||
public record SynchronizeJellyfinLibraryByIdIfNeeded(int JellyfinLibraryId) : ISynchronizeJellyfinLibraryById
|
||||
{
|
||||
public bool ForceScan => false;
|
||||
}
|
||||
|
||||
public record ForceSynchronizeJellyfinLibraryById(int JellyfinLibraryId) : ISynchronizeJellyfinLibraryById
|
||||
{
|
||||
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.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
{
|
||||
public class SynchronizeJellyfinLibraryByIdHandler :
|
||||
IRequestHandler<ForceSynchronizeJellyfinLibraryById, Either<BaseError, string>>,
|
||||
IRequestHandler<SynchronizeJellyfinLibraryByIdIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly IJellyfinMovieLibraryScanner _jellyfinMovieLibraryScanner;
|
||||
|
||||
private readonly IJellyfinSecretStore _jellyfinSecretStore;
|
||||
private readonly IJellyfinTelevisionLibraryScanner _jellyfinTelevisionLibraryScanner;
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
private readonly ILogger<SynchronizeJellyfinLibraryByIdHandler> _logger;
|
||||
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
|
||||
public SynchronizeJellyfinLibraryByIdHandler(
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IJellyfinSecretStore jellyfinSecretStore,
|
||||
IJellyfinMovieLibraryScanner jellyfinMovieLibraryScanner,
|
||||
IJellyfinTelevisionLibraryScanner jellyfinTelevisionLibraryScanner,
|
||||
ILibraryRepository libraryRepository,
|
||||
IEntityLocker entityLocker,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILogger<SynchronizeJellyfinLibraryByIdHandler> logger)
|
||||
{
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_jellyfinSecretStore = jellyfinSecretStore;
|
||||
_jellyfinMovieLibraryScanner = jellyfinMovieLibraryScanner;
|
||||
_jellyfinTelevisionLibraryScanner = jellyfinTelevisionLibraryScanner;
|
||||
_libraryRepository = libraryRepository;
|
||||
_entityLocker = entityLocker;
|
||||
_configElementRepository = configElementRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, string>> Handle(
|
||||
ForceSynchronizeJellyfinLibraryById request,
|
||||
CancellationToken cancellationToken) => Handle(request);
|
||||
|
||||
public Task<Either<BaseError, string>> Handle(
|
||||
SynchronizeJellyfinLibraryByIdIfNeeded request,
|
||||
CancellationToken cancellationToken) => Handle(request);
|
||||
|
||||
private Task<Either<BaseError, string>>
|
||||
Handle(ISynchronizeJellyfinLibraryById 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 _jellyfinMovieLibraryScanner.ScanLibrary(
|
||||
parameters.ConnectionParameters.ActiveConnection.Address,
|
||||
parameters.ConnectionParameters.ApiKey,
|
||||
parameters.Library,
|
||||
parameters.FFprobePath);
|
||||
break;
|
||||
case LibraryMediaKind.Shows:
|
||||
await _jellyfinTelevisionLibraryScanner.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 jellyfin media library {Name}",
|
||||
parameters.Library.Name);
|
||||
}
|
||||
|
||||
_entityLocker.UnlockLibrary(parameters.Library.Id);
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, RequestParameters>> Validate(
|
||||
ISynchronizeJellyfinLibraryById request) =>
|
||||
(await ValidateConnection(request), await JellyfinLibraryMustExist(request), await ValidateFFprobePath())
|
||||
.Apply(
|
||||
(connectionParameters, jellyfinLibrary, ffprobePath) => new RequestParameters(
|
||||
connectionParameters,
|
||||
jellyfinLibrary,
|
||||
request.ForceScan,
|
||||
ffprobePath
|
||||
));
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> ValidateConnection(
|
||||
ISynchronizeJellyfinLibraryById request) =>
|
||||
JellyfinMediaSourceMustExist(request)
|
||||
.BindT(MediaSourceMustHaveActiveConnection)
|
||||
.BindT(MediaSourceMustHaveApiKey);
|
||||
|
||||
private Task<Validation<BaseError, JellyfinMediaSource>> JellyfinMediaSourceMustExist(
|
||||
ISynchronizeJellyfinLibraryById request) =>
|
||||
_mediaSourceRepository.GetJellyfinByLibraryId(request.JellyfinLibraryId)
|
||||
.Map(
|
||||
v => v.ToValidation<BaseError>(
|
||||
$"Jellyfin media source for library {request.JellyfinLibraryId} does not exist."));
|
||||
|
||||
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
|
||||
JellyfinMediaSource jellyfinMediaSource)
|
||||
{
|
||||
Option<JellyfinConnection> maybeConnection = jellyfinMediaSource.Connections.HeadOrNone();
|
||||
return maybeConnection.Map(connection => new ConnectionParameters(jellyfinMediaSource, connection))
|
||||
.ToValidation<BaseError>("Jellyfin media source requires an active connection");
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey(
|
||||
ConnectionParameters connectionParameters)
|
||||
{
|
||||
JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets();
|
||||
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
|
||||
.Filter(match => match)
|
||||
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
|
||||
.ToValidation<BaseError>("Jellyfin media source requires an api key");
|
||||
}
|
||||
|
||||
private Task<Validation<BaseError, JellyfinLibrary>> JellyfinLibraryMustExist(
|
||||
ISynchronizeJellyfinLibraryById request) =>
|
||||
_mediaSourceRepository.GetJellyfinLibrary(request.JellyfinLibraryId)
|
||||
.Map(v => v.ToValidation<BaseError>($"Jellyfin library {request.JellyfinLibraryId} 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,
|
||||
JellyfinLibrary Library,
|
||||
bool ForceScan,
|
||||
string FFprobePath);
|
||||
|
||||
private record ConnectionParameters(
|
||||
JellyfinMediaSource JellyfinMediaSource,
|
||||
JellyfinConnection 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.Jellyfin.Commands
|
||||
{
|
||||
public record SynchronizeJellyfinMediaSources : IRequest<Either<BaseError, List<JellyfinMediaSource>>>,
|
||||
IJellyfinBackgroundServiceRequest;
|
||||
}
|
||||
@@ -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.Jellyfin.Commands
|
||||
{
|
||||
public class SynchronizeJellyfinMediaSourcesHandler : IRequestHandler<SynchronizeJellyfinMediaSources,
|
||||
Either<BaseError, List<JellyfinMediaSource>>>
|
||||
{
|
||||
private readonly ChannelWriter<IJellyfinBackgroundServiceRequest> _channel;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
|
||||
public SynchronizeJellyfinMediaSourcesHandler(
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
ChannelWriter<IJellyfinBackgroundServiceRequest> channel)
|
||||
{
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<JellyfinMediaSource>>> Handle(
|
||||
SynchronizeJellyfinMediaSources request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<JellyfinMediaSource> mediaSources = await _mediaSourceRepository.GetAllJellyfin();
|
||||
foreach (JellyfinMediaSource mediaSource in mediaSources)
|
||||
{
|
||||
await _channel.WriteAsync(new SynchronizeJellyfinAdminUserId(mediaSource.Id), cancellationToken);
|
||||
await _channel.WriteAsync(new SynchronizeJellyfinLibraries(mediaSource.Id), cancellationToken);
|
||||
}
|
||||
|
||||
return mediaSources;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
{
|
||||
public record UpdateJellyfinLibraryPreferences
|
||||
(List<JellyfinLibraryPreference> Preferences) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
|
||||
public record JellyfinLibraryPreference(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.Jellyfin.Commands
|
||||
{
|
||||
public class
|
||||
UpdateJellyfinLibraryPreferencesHandler : MediatR.IRequestHandler<UpdateJellyfinLibraryPreferences,
|
||||
Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public UpdateJellyfinLibraryPreferencesHandler(
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
ISearchIndex searchIndex)
|
||||
{
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_searchIndex = searchIndex;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateJellyfinLibraryPreferences request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList();
|
||||
List<int> ids = await _mediaSourceRepository.DisableJellyfinLibrarySync(toDisable);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
|
||||
IEnumerable<int> toEnable = request.Preferences.Filter(p => p.ShouldSyncItems).Map(p => p.Id);
|
||||
await _mediaSourceRepository.EnableJellyfinLibrarySync(toEnable);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
{
|
||||
public record UpdateJellyfinPathReplacements(
|
||||
int JellyfinMediaSourceId,
|
||||
List<JellyfinPathReplacementItem> PathReplacements) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
|
||||
public record JellyfinPathReplacementItem(int Id, string JellyfinPath, 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.Jellyfin.Commands
|
||||
{
|
||||
public class UpdateJellyfinPathReplacementsHandler : MediatR.IRequestHandler<UpdateJellyfinPathReplacements,
|
||||
Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
|
||||
public UpdateJellyfinPathReplacementsHandler(IMediaSourceRepository mediaSourceRepository) =>
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateJellyfinPathReplacements request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(pms => MergePathReplacements(request, pms))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private Task<Unit> MergePathReplacements(
|
||||
UpdateJellyfinPathReplacements request,
|
||||
JellyfinMediaSource jellyfinMediaSource)
|
||||
{
|
||||
jellyfinMediaSource.PathReplacements ??= new List<JellyfinPathReplacement>();
|
||||
|
||||
var incoming = request.PathReplacements.Map(Project).ToList();
|
||||
|
||||
var toAdd = incoming.Filter(r => r.Id < 1).ToList();
|
||||
var toRemove = jellyfinMediaSource.PathReplacements.Filter(r => incoming.All(pr => pr.Id != r.Id)).ToList();
|
||||
var toUpdate = incoming.Except(toAdd).ToList();
|
||||
|
||||
return _mediaSourceRepository.UpdatePathReplacements(jellyfinMediaSource.Id, toAdd, toUpdate, toRemove);
|
||||
}
|
||||
|
||||
private static JellyfinPathReplacement Project(JellyfinPathReplacementItem vm) =>
|
||||
new() { Id = vm.Id, JellyfinPath = vm.JellyfinPath, LocalPath = vm.LocalPath };
|
||||
|
||||
private Task<Validation<BaseError, JellyfinMediaSource>> Validate(UpdateJellyfinPathReplacements request) =>
|
||||
JellyfinMediaSourceMustExist(request);
|
||||
|
||||
private Task<Validation<BaseError, JellyfinMediaSource>> JellyfinMediaSourceMustExist(
|
||||
UpdateJellyfinPathReplacements request) =>
|
||||
_mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId)
|
||||
.Map(
|
||||
v => v.ToValidation<BaseError>(
|
||||
$"Jellyfin media source {request.JellyfinMediaSourceId} does not exist."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin
|
||||
{
|
||||
public record JellyfinLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind, bool ShouldSyncItems)
|
||||
: LibraryViewModel("Jellyfin", Id, Name, MediaKind);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Application.MediaSources;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin
|
||||
{
|
||||
public record JellyfinMediaSourceViewModel(int Id, string Name, string Address) : MediaSourceViewModel(Id, Name);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Application.Jellyfin
|
||||
{
|
||||
public record JellyfinPathReplacementViewModel(int Id, string JellyfinPath, string LocalPath);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin
|
||||
{
|
||||
internal static class Mapper
|
||||
{
|
||||
internal static JellyfinMediaSourceViewModel ProjectToViewModel(JellyfinMediaSource jellyfinMediaSource) =>
|
||||
new(
|
||||
jellyfinMediaSource.Id,
|
||||
jellyfinMediaSource.ServerName,
|
||||
jellyfinMediaSource.Connections.HeadOrNone().Match(c => c.Address, string.Empty));
|
||||
|
||||
internal static JellyfinLibraryViewModel ProjectToViewModel(JellyfinLibrary library) =>
|
||||
new(library.Id, library.Name, library.MediaKind, library.ShouldSyncItems);
|
||||
|
||||
internal static JellyfinPathReplacementViewModel ProjectToViewModel(JellyfinPathReplacement pathReplacement) =>
|
||||
new(pathReplacement.Id, pathReplacement.JellyfinPath, pathReplacement.LocalPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Queries
|
||||
{
|
||||
public record GetAllJellyfinMediaSources : IRequest<List<JellyfinMediaSourceViewModel>>;
|
||||
}
|
||||
@@ -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.Jellyfin.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Queries
|
||||
{
|
||||
public class
|
||||
GetAllJellyfinMediaSourcesHandler : IRequestHandler<GetAllJellyfinMediaSources,
|
||||
List<JellyfinMediaSourceViewModel>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
|
||||
public GetAllJellyfinMediaSourcesHandler(IMediaSourceRepository mediaSourceRepository) =>
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
|
||||
public Task<List<JellyfinMediaSourceViewModel>> Handle(
|
||||
GetAllJellyfinMediaSources request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetAllJellyfin().Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Queries
|
||||
{
|
||||
public record GetJellyfinLibrariesBySourceId(int JellyfinMediaSourceId) : IRequest<List<JellyfinLibraryViewModel>>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
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.Jellyfin.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Queries
|
||||
{
|
||||
public class
|
||||
GetJellyfinLibrariesBySourceIdHandler : IRequestHandler<GetJellyfinLibrariesBySourceId,
|
||||
List<JellyfinLibraryViewModel>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
|
||||
public GetJellyfinLibrariesBySourceIdHandler(IMediaSourceRepository mediaSourceRepository) =>
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
|
||||
public Task<List<JellyfinLibraryViewModel>> Handle(
|
||||
GetJellyfinLibrariesBySourceId request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetJellyfinLibraries(request.JellyfinMediaSourceId)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Queries
|
||||
{
|
||||
public record GetJellyfinMediaSourceById
|
||||
(int JellyfinMediaSourceId) : IRequest<Option<JellyfinMediaSourceViewModel>>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using static ErsatzTV.Application.Jellyfin.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Queries
|
||||
{
|
||||
public class
|
||||
GetJellyfinMediaSourceByIdHandler : IRequestHandler<GetJellyfinMediaSourceById,
|
||||
Option<JellyfinMediaSourceViewModel>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
|
||||
public GetJellyfinMediaSourceByIdHandler(IMediaSourceRepository mediaSourceRepository) =>
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
|
||||
public Task<Option<JellyfinMediaSourceViewModel>> Handle(
|
||||
GetJellyfinMediaSourceById request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId).MapT(ProjectToViewModel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Collections.Generic;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Queries
|
||||
{
|
||||
public record GetJellyfinPathReplacementsBySourceId
|
||||
(int JellyfinMediaSourceId) : IRequest<List<JellyfinPathReplacementViewModel>>;
|
||||
}
|
||||
@@ -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.Jellyfin.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Queries
|
||||
{
|
||||
public class GetJellyfinPathReplacementsBySourceIdHandler : IRequestHandler<GetJellyfinPathReplacementsBySourceId,
|
||||
List<JellyfinPathReplacementViewModel>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
|
||||
public GetJellyfinPathReplacementsBySourceIdHandler(IMediaSourceRepository mediaSourceRepository) =>
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
|
||||
public Task<List<JellyfinPathReplacementViewModel>> Handle(
|
||||
GetJellyfinPathReplacementsBySourceId request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetJellyfinPathReplacements(request.JellyfinMediaSourceId)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Queries
|
||||
{
|
||||
public record GetJellyfinSecrets : IRequest<JellyfinSecrets>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Queries
|
||||
{
|
||||
public class GetJellyfinSecretsHandler : IRequestHandler<GetJellyfinSecrets, JellyfinSecrets>
|
||||
{
|
||||
private readonly IJellyfinSecretStore _jellyfinSecretStore;
|
||||
|
||||
public GetJellyfinSecretsHandler(IJellyfinSecretStore jellyfinSecretStore) =>
|
||||
_jellyfinSecretStore = jellyfinSecretStore;
|
||||
|
||||
public Task<JellyfinSecrets> Handle(GetJellyfinSecrets request, CancellationToken cancellationToken) =>
|
||||
_jellyfinSecretStore.ReadSecrets();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using ErsatzTV.Application.Jellyfin;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries
|
||||
@@ -10,6 +11,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),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(library))
|
||||
};
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ namespace ErsatzTV.Application.Libraries.Queries
|
||||
.Map(
|
||||
list => list.Filter(ShouldIncludeLibrary)
|
||||
.OrderBy(l => l.MediaSource is LocalMediaSource ? 0 : 1)
|
||||
.ThenBy(l => l.GetType().Name)
|
||||
.ThenBy(l => l.MediaKind)
|
||||
.Map(ProjectToViewModel).ToList());
|
||||
|
||||
@@ -29,6 +30,7 @@ namespace ErsatzTV.Application.Libraries.Queries
|
||||
{
|
||||
LocalLibrary => true,
|
||||
PlexLibrary plex => plex.ShouldSyncItems,
|
||||
JellyfinLibrary jellyfin => jellyfin.ShouldSyncItems,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace ErsatzTV.Application.MediaCards
|
||||
{
|
||||
public record ActorCardViewModel(int Id, string Name, string Role, string Thumb) :
|
||||
MediaCardViewModel(Id, Name, Role, Name, Thumb);
|
||||
}
|
||||
@@ -6,7 +6,5 @@
|
||||
Title,
|
||||
Subtitle,
|
||||
SortTitle,
|
||||
Poster)
|
||||
{
|
||||
}
|
||||
Poster);
|
||||
}
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.MediaCards
|
||||
{
|
||||
internal static class Mapper
|
||||
{
|
||||
internal static TelevisionShowCardViewModel ProjectToViewModel(ShowMetadata showMetadata) =>
|
||||
internal static TelevisionShowCardViewModel ProjectToViewModel(
|
||||
ShowMetadata showMetadata,
|
||||
Option<JellyfinMediaSource> maybeJellyfin) =>
|
||||
new(
|
||||
showMetadata.ShowId,
|
||||
showMetadata.Title,
|
||||
showMetadata.Year?.ToString(),
|
||||
showMetadata.SortTitle,
|
||||
GetPoster(showMetadata));
|
||||
GetPoster(showMetadata, maybeJellyfin));
|
||||
|
||||
internal static TelevisionSeasonCardViewModel ProjectToViewModel(Season season) =>
|
||||
internal static TelevisionSeasonCardViewModel ProjectToViewModel(
|
||||
Season season,
|
||||
Option<JellyfinMediaSource> maybeJellyfin) =>
|
||||
new(
|
||||
season.Show.ShowMetadata.HeadOrNone().Match(m => m.Title ?? string.Empty, () => string.Empty),
|
||||
season.Id,
|
||||
@@ -24,11 +28,12 @@ namespace ErsatzTV.Application.MediaCards
|
||||
GetSeasonName(season.SeasonNumber),
|
||||
string.Empty,
|
||||
GetSeasonName(season.SeasonNumber),
|
||||
season.SeasonMetadata.HeadOrNone().Map(GetPoster).IfNone(string.Empty),
|
||||
season.SeasonMetadata.HeadOrNone().Map(sm => GetPoster(sm, maybeJellyfin)).IfNone(string.Empty),
|
||||
season.SeasonNumber == 0 ? "S" : season.SeasonNumber.ToString());
|
||||
|
||||
internal static TelevisionEpisodeCardViewModel ProjectToViewModel(
|
||||
EpisodeMetadata episodeMetadata) =>
|
||||
EpisodeMetadata episodeMetadata,
|
||||
Option<JellyfinMediaSource> maybeJellyfin) =>
|
||||
new(
|
||||
episodeMetadata.EpisodeId,
|
||||
episodeMetadata.ReleaseDate ?? DateTime.MinValue,
|
||||
@@ -42,15 +47,17 @@ namespace ErsatzTV.Application.MediaCards
|
||||
episodeMetadata.Episode.EpisodeMetadata.HeadOrNone().Match(
|
||||
em => em.Plot ?? string.Empty,
|
||||
() => string.Empty),
|
||||
GetThumbnail(episodeMetadata));
|
||||
GetThumbnail(episodeMetadata, maybeJellyfin));
|
||||
|
||||
internal static MovieCardViewModel ProjectToViewModel(MovieMetadata movieMetadata) =>
|
||||
internal static MovieCardViewModel ProjectToViewModel(
|
||||
MovieMetadata movieMetadata,
|
||||
Option<JellyfinMediaSource> maybeJellyfin) =>
|
||||
new(
|
||||
movieMetadata.MovieId,
|
||||
movieMetadata.Title,
|
||||
movieMetadata.Year?.ToString(),
|
||||
movieMetadata.SortTitle,
|
||||
GetPoster(movieMetadata));
|
||||
GetPoster(movieMetadata, maybeJellyfin));
|
||||
|
||||
internal static MusicVideoCardViewModel ProjectToViewModel(MusicVideoMetadata musicVideoMetadata) =>
|
||||
new(
|
||||
@@ -59,7 +66,7 @@ namespace ErsatzTV.Application.MediaCards
|
||||
musicVideoMetadata.MusicVideo.Artist.ArtistMetadata.Head().Title,
|
||||
musicVideoMetadata.SortTitle,
|
||||
musicVideoMetadata.Plot,
|
||||
GetThumbnail(musicVideoMetadata));
|
||||
GetThumbnail(musicVideoMetadata, None));
|
||||
|
||||
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
|
||||
new(
|
||||
@@ -67,44 +74,81 @@ namespace ErsatzTV.Application.MediaCards
|
||||
artistMetadata.Title,
|
||||
artistMetadata.Disambiguation,
|
||||
artistMetadata.SortTitle,
|
||||
GetThumbnail(artistMetadata));
|
||||
GetThumbnail(artistMetadata, None));
|
||||
|
||||
internal static CollectionCardResultsViewModel
|
||||
ProjectToViewModel(Collection collection) =>
|
||||
ProjectToViewModel(Collection collection, Option<JellyfinMediaSource> maybeJellyfin) =>
|
||||
new(
|
||||
collection.Name,
|
||||
collection.MediaItems.OfType<Movie>().Map(
|
||||
m => ProjectToViewModel(m.MovieMetadata.Head()) with
|
||||
m => ProjectToViewModel(m.MovieMetadata.Head(), maybeJellyfin) with
|
||||
{
|
||||
CustomIndex = GetCustomIndex(collection, m.Id)
|
||||
}).ToList(),
|
||||
collection.MediaItems.OfType<Show>().Map(s => ProjectToViewModel(s.ShowMetadata.Head())).ToList(),
|
||||
collection.MediaItems.OfType<Season>().Map(ProjectToViewModel).ToList(),
|
||||
collection.MediaItems.OfType<Episode>().Map(e => ProjectToViewModel(e.EpisodeMetadata.Head()))
|
||||
collection.MediaItems.OfType<Show>().Map(s => ProjectToViewModel(s.ShowMetadata.Head(), maybeJellyfin))
|
||||
.ToList(),
|
||||
collection.MediaItems.OfType<Season>().Map(s => ProjectToViewModel(s, maybeJellyfin)).ToList(),
|
||||
collection.MediaItems.OfType<Episode>()
|
||||
.Map(e => ProjectToViewModel(e.EpisodeMetadata.Head(), maybeJellyfin))
|
||||
.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)
|
||||
{
|
||||
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";
|
||||
}
|
||||
|
||||
return new ActorCardViewModel(actor.Id, actor.Name, actor.Role, artwork);
|
||||
}
|
||||
|
||||
private static int GetCustomIndex(Collection collection, int mediaItemId) =>
|
||||
Optional(collection.CollectionItems.Find(ci => ci.MediaItemId == mediaItemId))
|
||||
.Map(ci => ci.CustomIndex ?? 0)
|
||||
.IfNone(0);
|
||||
|
||||
internal static SearchCardResultsViewModel ProjectToSearchResults(List<MediaItem> items) =>
|
||||
new(
|
||||
items.OfType<Movie>().Map(m => ProjectToViewModel(m.MovieMetadata.Head())).ToList(),
|
||||
items.OfType<Show>().Map(s => ProjectToViewModel(s.ShowMetadata.Head())).ToList());
|
||||
|
||||
private static string GetSeasonName(int number) =>
|
||||
number == 0 ? "Specials" : $"Season {number}";
|
||||
|
||||
private static string GetPoster(Metadata metadata) =>
|
||||
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster))
|
||||
private static string GetPoster(Metadata metadata, Option<JellyfinMediaSource> maybeJellyfin)
|
||||
{
|
||||
string poster = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster))
|
||||
.Match(a => a.Path, string.Empty);
|
||||
|
||||
private static string GetThumbnail(Metadata metadata) =>
|
||||
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail))
|
||||
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";
|
||||
}
|
||||
|
||||
return poster;
|
||||
}
|
||||
|
||||
private static string GetThumbnail(Metadata metadata, Option<JellyfinMediaSource> maybeJellyfin)
|
||||
{
|
||||
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
|
||||
}
|
||||
|
||||
return thumb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
@@ -12,15 +13,27 @@ namespace ErsatzTV.Application.MediaCards.Queries
|
||||
Either<BaseError, CollectionCardResultsViewModel>>
|
||||
{
|
||||
private readonly IMediaCollectionRepository _collectionRepository;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
|
||||
public GetCollectionCardsHandler(IMediaCollectionRepository collectionRepository) =>
|
||||
public GetCollectionCardsHandler(
|
||||
IMediaCollectionRepository collectionRepository,
|
||||
IMediaSourceRepository mediaSourceRepository)
|
||||
{
|
||||
_collectionRepository = collectionRepository;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, CollectionCardResultsViewModel>> Handle(
|
||||
public async Task<Either<BaseError, CollectionCardResultsViewModel>> Handle(
|
||||
GetCollectionCards request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_collectionRepository.GetCollectionWithItemsUntracked(request.Id)
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
|
||||
.Map(list => list.HeadOrNone());
|
||||
|
||||
return await _collectionRepository
|
||||
.GetCollectionWithItemsUntracked(request.Id)
|
||||
.Map(c => c.ToEither(BaseError.New("Unable to load collection")))
|
||||
.MapT(ProjectToViewModel);
|
||||
.MapT(c => ProjectToViewModel(c, maybeJellyfin));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
@@ -13,10 +14,16 @@ namespace ErsatzTV.Application.MediaCards.Queries
|
||||
GetTelevisionEpisodeCardsHandler : IRequestHandler<GetTelevisionEpisodeCards,
|
||||
TelevisionEpisodeCardResultsViewModel>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public GetTelevisionEpisodeCardsHandler(ITelevisionRepository televisionRepository) =>
|
||||
public GetTelevisionEpisodeCardsHandler(
|
||||
ITelevisionRepository televisionRepository,
|
||||
IMediaSourceRepository mediaSourceRepository)
|
||||
{
|
||||
_televisionRepository = televisionRepository;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
}
|
||||
|
||||
public async Task<TelevisionEpisodeCardResultsViewModel> Handle(
|
||||
GetTelevisionEpisodeCards request,
|
||||
@@ -24,9 +31,12 @@ namespace ErsatzTV.Application.MediaCards.Queries
|
||||
{
|
||||
int count = await _televisionRepository.GetEpisodeCount(request.TelevisionSeasonId);
|
||||
|
||||
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
|
||||
.Map(list => list.HeadOrNone());
|
||||
|
||||
List<TelevisionEpisodeCardViewModel> results = await _televisionRepository
|
||||
.GetPagedEpisodes(request.TelevisionSeasonId, request.PageNumber, request.PageSize)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
.Map(list => list.Map(e => ProjectToViewModel(e, maybeJellyfin)).ToList());
|
||||
|
||||
return new TelevisionEpisodeCardResultsViewModel(count, results);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
@@ -10,13 +11,19 @@ using static ErsatzTV.Application.MediaCards.Mapper;
|
||||
namespace ErsatzTV.Application.MediaCards.Queries
|
||||
{
|
||||
public class
|
||||
GetTelevisionSeasonCardsHandler : IRequestHandler<GetTelevisionSeasonCards, TelevisionSeasonCardResultsViewModel
|
||||
>
|
||||
GetTelevisionSeasonCardsHandler : IRequestHandler<GetTelevisionSeasonCards,
|
||||
TelevisionSeasonCardResultsViewModel>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public GetTelevisionSeasonCardsHandler(ITelevisionRepository televisionRepository) =>
|
||||
public GetTelevisionSeasonCardsHandler(
|
||||
ITelevisionRepository televisionRepository,
|
||||
IMediaSourceRepository mediaSourceRepository)
|
||||
{
|
||||
_televisionRepository = televisionRepository;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
}
|
||||
|
||||
public async Task<TelevisionSeasonCardResultsViewModel> Handle(
|
||||
GetTelevisionSeasonCards request,
|
||||
@@ -24,9 +31,12 @@ namespace ErsatzTV.Application.MediaCards.Queries
|
||||
{
|
||||
int count = await _televisionRepository.GetSeasonCount(request.TelevisionShowId);
|
||||
|
||||
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
|
||||
.Map(list => list.HeadOrNone());
|
||||
|
||||
List<TelevisionSeasonCardViewModel> results = await _televisionRepository
|
||||
.GetPagedSeasons(request.TelevisionShowId, request.PageNumber, request.PageSize)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
.Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin)).ToList());
|
||||
|
||||
return new TelevisionSeasonCardResultsViewModel(count, results);
|
||||
}
|
||||
|
||||
@@ -64,11 +64,13 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
|
||||
private async Task<Unit> PerformScan(RequestParameters parameters)
|
||||
{
|
||||
(LocalLibrary localLibrary, string ffprobePath, bool forceScan) = parameters;
|
||||
(LocalLibrary localLibrary, string ffprobePath, bool forceScan, int libraryRefreshInterval) = parameters;
|
||||
|
||||
var sw = new Stopwatch();
|
||||
sw.Start();
|
||||
|
||||
var scanned = false;
|
||||
|
||||
for (var i = 0; i < localLibrary.Paths.Count; i++)
|
||||
{
|
||||
LibraryPath libraryPath = localLibrary.Paths[i];
|
||||
@@ -77,8 +79,11 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
decimal progressMax = (decimal) (i + 1) / localLibrary.Paths.Count;
|
||||
|
||||
var lastScan = new DateTimeOffset(libraryPath.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
|
||||
if (forceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(libraryRefreshInterval);
|
||||
if (forceScan || nextScan < DateTimeOffset.Now)
|
||||
{
|
||||
scanned = true;
|
||||
|
||||
switch (localLibrary.MediaKind)
|
||||
{
|
||||
case LibraryMediaKind.Movies:
|
||||
@@ -115,10 +120,20 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
}
|
||||
|
||||
sw.Stop();
|
||||
_logger.LogDebug(
|
||||
"Scan of library {Name} completed in {Duration}",
|
||||
localLibrary.Name,
|
||||
TimeSpan.FromMilliseconds(sw.ElapsedMilliseconds));
|
||||
|
||||
if (scanned)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Scan of library {Name} completed in {Duration}",
|
||||
localLibrary.Name,
|
||||
TimeSpan.FromMilliseconds(sw.ElapsedMilliseconds));
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Skipping unforced scan of local media library {Name}",
|
||||
localLibrary.Name);
|
||||
}
|
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(localLibrary.Id, 0));
|
||||
|
||||
@@ -127,12 +142,13 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, RequestParameters>> Validate(IScanLocalLibrary request) =>
|
||||
(await LocalLibraryMustExist(request), await ValidateFFprobePath())
|
||||
(await LocalLibraryMustExist(request), await ValidateFFprobePath(), await ValidateLibraryRefreshInterval())
|
||||
.Apply(
|
||||
(library, ffprobePath) => new RequestParameters(
|
||||
(library, ffprobePath, libraryRefreshInterval) => new RequestParameters(
|
||||
library,
|
||||
ffprobePath,
|
||||
request.ForceScan));
|
||||
request.ForceScan,
|
||||
libraryRefreshInterval));
|
||||
|
||||
private Task<Validation<BaseError, LocalLibrary>> LocalLibraryMustExist(
|
||||
IScanLocalLibrary request) =>
|
||||
@@ -147,6 +163,15 @@ namespace ErsatzTV.Application.MediaSources.Commands
|
||||
ffprobePath =>
|
||||
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
|
||||
|
||||
private record RequestParameters(LocalLibrary LocalLibrary, string FFprobePath, bool ForceScan);
|
||||
private Task<Validation<BaseError, int>> ValidateLibraryRefreshInterval() =>
|
||||
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
|
||||
.FilterT(lri => lri > 0)
|
||||
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
|
||||
|
||||
private record RequestParameters(
|
||||
LocalLibrary LocalLibrary,
|
||||
string FFprobePath,
|
||||
bool ForceScan,
|
||||
int LibraryRefreshInterval);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,19 +10,24 @@ namespace ErsatzTV.Application.Movies
|
||||
{
|
||||
internal static class Mapper
|
||||
{
|
||||
internal static MovieViewModel ProjectToViewModel(Movie movie)
|
||||
internal static MovieViewModel ProjectToViewModel(Movie movie, Option<JellyfinMediaSource> maybeJellyfin)
|
||||
{
|
||||
MovieMetadata metadata = Optional(movie.MovieMetadata).Flatten().Head();
|
||||
return new MovieViewModel(
|
||||
metadata.Title,
|
||||
metadata.Year?.ToString(),
|
||||
metadata.Plot,
|
||||
Artwork(metadata, ArtworkKind.Poster),
|
||||
Artwork(metadata, ArtworkKind.FanArt),
|
||||
metadata.Genres.Map(g => g.Name).ToList(),
|
||||
metadata.Tags.Map(t => t.Name).ToList(),
|
||||
metadata.Studios.Map(s => s.Name).ToList(),
|
||||
LanguagesForMovie(movie));
|
||||
LanguagesForMovie(movie),
|
||||
metadata.Actors.OrderBy(a => a.Order).ThenBy(a => a.Id)
|
||||
.Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin))
|
||||
.ToList())
|
||||
{
|
||||
Poster = Artwork(metadata, ArtworkKind.Poster, maybeJellyfin),
|
||||
FanArt = Artwork(metadata, ArtworkKind.FanArt, maybeJellyfin)
|
||||
};
|
||||
}
|
||||
|
||||
private static List<CultureInfo> LanguagesForMovie(Movie movie)
|
||||
@@ -41,8 +46,28 @@ namespace ErsatzTV.Application.Movies
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string Artwork(Metadata metadata, ArtworkKind artworkKind) =>
|
||||
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
|
||||
private static string Artwork(
|
||||
Metadata metadata,
|
||||
ArtworkKind artworkKind,
|
||||
Option<JellyfinMediaSource> maybeJellyfin)
|
||||
{
|
||||
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);
|
||||
|
||||
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
|
||||
{
|
||||
artwork += "&fillHeight=440";
|
||||
}
|
||||
}
|
||||
|
||||
return artwork;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
|
||||
namespace ErsatzTV.Application.Movies
|
||||
{
|
||||
@@ -7,10 +8,13 @@ namespace ErsatzTV.Application.Movies
|
||||
string Title,
|
||||
string Year,
|
||||
string Plot,
|
||||
string Poster,
|
||||
string FanArt,
|
||||
List<string> Genres,
|
||||
List<string> Tags,
|
||||
List<string> Studios,
|
||||
List<CultureInfo> Languages);
|
||||
List<CultureInfo> Languages,
|
||||
List<ActorCardViewModel> Actors)
|
||||
{
|
||||
public string Poster { get; set; }
|
||||
public string FanArt { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
@@ -9,14 +10,24 @@ namespace ErsatzTV.Application.Movies.Queries
|
||||
{
|
||||
public class GetMovieByIdHandler : IRequestHandler<GetMovieById, Option<MovieViewModel>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
|
||||
public GetMovieByIdHandler(IMovieRepository movieRepository) =>
|
||||
public GetMovieByIdHandler(IMovieRepository movieRepository, IMediaSourceRepository mediaSourceRepository)
|
||||
{
|
||||
_movieRepository = movieRepository;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
}
|
||||
|
||||
public Task<Option<MovieViewModel>> Handle(
|
||||
public async Task<Option<MovieViewModel>> Handle(
|
||||
GetMovieById request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_movieRepository.GetMovie(request.Id).MapT(ProjectToViewModel);
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
|
||||
.Map(list => list.HeadOrNone());
|
||||
|
||||
Option<Movie> movie = await _movieRepository.GetMovie(request.Id);
|
||||
return movie.Map(m => ProjectToViewModel(m, maybeJellyfin));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,9 +66,9 @@ namespace ErsatzTV.Application.Playouts.Commands
|
||||
|
||||
private async Task<Validation<BaseError, Channel>> ChannelMustNotHavePlayouts(Channel channel) =>
|
||||
Optional(await _channelRepository.CountPlayouts(channel.Id))
|
||||
.Filter(count => count == 0)
|
||||
.Map(_ => channel)
|
||||
.ToValidation<BaseError>("Channel already has one playout.");
|
||||
.Filter(count => count == 0)
|
||||
.Map(_ => channel)
|
||||
.ToValidation<BaseError>("Channel already has one playout.");
|
||||
|
||||
private async Task<Validation<BaseError, ProgramSchedule>> ProgramScheduleMustExist(
|
||||
CreatePlayout createPlayout) =>
|
||||
|
||||
@@ -19,6 +19,7 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
SynchronizePlexLibraryByIdHandler : IRequestHandler<ForceSynchronizePlexLibraryById, Either<BaseError, string>>,
|
||||
IRequestHandler<SynchronizePlexLibraryByIdIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
private readonly ILogger<SynchronizePlexLibraryByIdHandler> _logger;
|
||||
@@ -29,6 +30,7 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
|
||||
public SynchronizePlexLibraryByIdHandler(
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IConfigElementRepository configElementRepository,
|
||||
IPlexSecretStore plexSecretStore,
|
||||
IPlexMovieLibraryScanner plexMovieLibraryScanner,
|
||||
IPlexTelevisionLibraryScanner plexTelevisionLibraryScanner,
|
||||
@@ -37,6 +39,7 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
ILogger<SynchronizePlexLibraryByIdHandler> logger)
|
||||
{
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_configElementRepository = configElementRepository;
|
||||
_plexSecretStore = plexSecretStore;
|
||||
_plexMovieLibraryScanner = plexMovieLibraryScanner;
|
||||
_plexTelevisionLibraryScanner = plexTelevisionLibraryScanner;
|
||||
@@ -62,7 +65,8 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
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))
|
||||
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
|
||||
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
|
||||
{
|
||||
switch (parameters.Library.MediaKind)
|
||||
{
|
||||
@@ -95,12 +99,14 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, RequestParameters>> Validate(ISynchronizePlexLibraryById request) =>
|
||||
(await ValidateConnection(request), await PlexLibraryMustExist(request))
|
||||
(await ValidateConnection(request), await PlexLibraryMustExist(request),
|
||||
await ValidateLibraryRefreshInterval())
|
||||
.Apply(
|
||||
(connectionParameters, plexLibrary) => new RequestParameters(
|
||||
(connectionParameters, plexLibrary, libraryRefreshInterval) => new RequestParameters(
|
||||
connectionParameters,
|
||||
plexLibrary,
|
||||
request.ForceScan
|
||||
request.ForceScan,
|
||||
libraryRefreshInterval
|
||||
));
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> ValidateConnection(
|
||||
@@ -139,10 +145,16 @@ namespace ErsatzTV.Application.Plex.Commands
|
||||
_mediaSourceRepository.GetPlexLibrary(request.PlexLibraryId)
|
||||
.Map(v => v.ToValidation<BaseError>($"Plex library {request.PlexLibraryId} does not exist."));
|
||||
|
||||
private Task<Validation<BaseError, int>> ValidateLibraryRefreshInterval() =>
|
||||
_configElementRepository.GetValue<int>(ConfigElementKey.LibraryRefreshInterval)
|
||||
.FilterT(lri => lri > 0)
|
||||
.Map(lri => lri.ToValidation<BaseError>("Library refresh interval is invalid"));
|
||||
|
||||
private record RequestParameters(
|
||||
ConnectionParameters ConnectionParameters,
|
||||
PlexLibrary Library,
|
||||
bool ForceScan);
|
||||
bool ForceScan,
|
||||
int LibraryRefreshInterval);
|
||||
|
||||
private record ConnectionParameters(PlexMediaSource PlexMediaSource, PlexConnection ActiveConnection)
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
@@ -14,13 +15,18 @@ namespace ErsatzTV.Application.Search.Queries
|
||||
{
|
||||
public class QuerySearchIndexMoviesHandler : IRequestHandler<QuerySearchIndexMovies, MovieCardResultsViewModel>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public QuerySearchIndexMoviesHandler(ISearchIndex searchIndex, IMovieRepository movieRepository)
|
||||
public QuerySearchIndexMoviesHandler(
|
||||
ISearchIndex searchIndex,
|
||||
IMovieRepository movieRepository,
|
||||
IMediaSourceRepository mediaSourceRepository)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_movieRepository = movieRepository;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
}
|
||||
|
||||
public async Task<MovieCardResultsViewModel> Handle(
|
||||
@@ -32,9 +38,12 @@ namespace ErsatzTV.Application.Search.Queries
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
request.PageSize);
|
||||
|
||||
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
|
||||
.Map(list => list.HeadOrNone());
|
||||
|
||||
List<MovieCardViewModel> items = await _movieRepository
|
||||
.GetMoviesForCards(searchResult.Items.Map(i => i.Id).ToList())
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
.Map(list => list.Map(m => ProjectToViewModel(m, maybeJellyfin)).ToList());
|
||||
|
||||
return new MovieCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
@@ -15,13 +16,18 @@ namespace ErsatzTV.Application.Search.Queries
|
||||
public class
|
||||
QuerySearchIndexShowsHandler : IRequestHandler<QuerySearchIndexShows, TelevisionShowCardResultsViewModel>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public QuerySearchIndexShowsHandler(ISearchIndex searchIndex, ITelevisionRepository televisionRepository)
|
||||
public QuerySearchIndexShowsHandler(
|
||||
ISearchIndex searchIndex,
|
||||
ITelevisionRepository televisionRepository,
|
||||
IMediaSourceRepository mediaSourceRepository)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_televisionRepository = televisionRepository;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
}
|
||||
|
||||
public async Task<TelevisionShowCardResultsViewModel> Handle(
|
||||
@@ -33,9 +39,12 @@ namespace ErsatzTV.Application.Search.Queries
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
request.PageSize);
|
||||
|
||||
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
|
||||
.Map(list => list.HeadOrNone());
|
||||
|
||||
List<TelevisionShowCardViewModel> items = await _televisionRepository
|
||||
.GetShowsForCards(searchResult.Items.Map(i => i.Id).ToList())
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
.Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin)).ToList());
|
||||
|
||||
return new TelevisionShowCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
|
||||
}
|
||||
|
||||
+11
-1
@@ -5,6 +5,7 @@ using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -18,6 +19,7 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly FFmpegProcessService _ffmpegProcessService;
|
||||
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly IPlayoutRepository _playoutRepository;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
@@ -28,7 +30,8 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
IPlayoutRepository playoutRepository,
|
||||
FFmpegProcessService ffmpegProcessService,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IPlexPathReplacementService plexPathReplacementService)
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
IJellyfinPathReplacementService jellyfinPathReplacementService)
|
||||
: base(channelRepository, configElementRepository)
|
||||
{
|
||||
_configElementRepository = configElementRepository;
|
||||
@@ -36,6 +39,7 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
_ffmpegProcessService = ffmpegProcessService;
|
||||
_localFileSystem = localFileSystem;
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
_jellyfinPathReplacementService = jellyfinPathReplacementService;
|
||||
}
|
||||
|
||||
protected override async Task<Either<BaseError, Process>> GetProcess(
|
||||
@@ -168,6 +172,12 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
PlexEpisode plexEpisode => await _plexPathReplacementService.GetReplacementPlexPath(
|
||||
plexEpisode.LibraryPathId,
|
||||
path),
|
||||
JellyfinMovie jellyfinMovie => await _jellyfinPathReplacementService.GetReplacementJellyfinPath(
|
||||
jellyfinMovie.LibraryPathId,
|
||||
path),
|
||||
JellyfinEpisode jellyfinEpisode => await _jellyfinPathReplacementService.GetReplacementJellyfinPath(
|
||||
jellyfinEpisode.LibraryPathId,
|
||||
path),
|
||||
_ => path
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
using static LanguageExt.Prelude;
|
||||
@@ -10,29 +11,40 @@ namespace ErsatzTV.Application.Television
|
||||
{
|
||||
internal static class Mapper
|
||||
{
|
||||
internal static TelevisionShowViewModel ProjectToViewModel(Show show, List<string> languages) =>
|
||||
internal static TelevisionShowViewModel ProjectToViewModel(
|
||||
Show show,
|
||||
List<string> languages,
|
||||
Option<JellyfinMediaSource> maybeJellyfin) =>
|
||||
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(GetPoster).IfNone(string.Empty),
|
||||
show.ShowMetadata.HeadOrNone().Map(GetFanArt).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 => 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())
|
||||
.IfNone(new List<string>()),
|
||||
LanguagesForShow(languages));
|
||||
LanguagesForShow(languages),
|
||||
show.ShowMetadata.HeadOrNone()
|
||||
.Map(
|
||||
m => m.Actors.OrderBy(a => a.Order).ThenBy(a => a.Id)
|
||||
.Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin))
|
||||
.ToList())
|
||||
.IfNone(new List<ActorCardViewModel>()));
|
||||
|
||||
internal static TelevisionSeasonViewModel ProjectToViewModel(Season season) =>
|
||||
internal static TelevisionSeasonViewModel ProjectToViewModel(
|
||||
Season season,
|
||||
Option<JellyfinMediaSource> maybeJellyfin) =>
|
||||
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(GetPoster).IfNone(string.Empty),
|
||||
season.Show.ShowMetadata.HeadOrNone().Map(GetFanArt).IfNone(string.Empty));
|
||||
season.SeasonMetadata.HeadOrNone().Map(m => GetPoster(m, maybeJellyfin)).IfNone(string.Empty),
|
||||
season.Show.ShowMetadata.HeadOrNone().Map(m => GetFanArt(m, maybeJellyfin)).IfNone(string.Empty));
|
||||
|
||||
internal static TelevisionEpisodeViewModel ProjectToViewModel(Episode episode) =>
|
||||
new(
|
||||
@@ -41,18 +53,41 @@ 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(GetThumbnail).IfNone(string.Empty));
|
||||
episode.EpisodeMetadata.HeadOrNone().Map(m => GetThumbnail(m, None)).IfNone(string.Empty));
|
||||
|
||||
private static string GetPoster(Metadata metadata) => GetArtwork(metadata, ArtworkKind.Poster);
|
||||
private static string GetPoster(Metadata metadata, Option<JellyfinMediaSource> maybeJellyfin) =>
|
||||
GetArtwork(metadata, ArtworkKind.Poster, maybeJellyfin);
|
||||
|
||||
private static string GetFanArt(Metadata metadata) => GetArtwork(metadata, ArtworkKind.FanArt);
|
||||
private static string GetFanArt(Metadata metadata, Option<JellyfinMediaSource> maybeJellyfin) =>
|
||||
GetArtwork(metadata, ArtworkKind.FanArt, maybeJellyfin);
|
||||
|
||||
private static string GetThumbnail(Metadata metadata) => GetArtwork(metadata, ArtworkKind.Thumbnail);
|
||||
private static string GetThumbnail(Metadata metadata, Option<JellyfinMediaSource> maybeJellyfin) =>
|
||||
GetArtwork(metadata, ArtworkKind.Thumbnail, maybeJellyfin);
|
||||
|
||||
private static string GetArtwork(Metadata metadata, ArtworkKind artworkKind) =>
|
||||
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
|
||||
private static string GetArtwork(
|
||||
Metadata metadata,
|
||||
ArtworkKind artworkKind,
|
||||
Option<JellyfinMediaSource> maybeJellyfin)
|
||||
{
|
||||
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);
|
||||
|
||||
if (artworkKind == ArtworkKind.Poster)
|
||||
{
|
||||
artwork += "&fillHeight=440";
|
||||
}
|
||||
}
|
||||
|
||||
return artwork;
|
||||
}
|
||||
|
||||
private static List<CultureInfo> LanguagesForShow(List<string> languages)
|
||||
{
|
||||
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
@@ -10,15 +11,26 @@ namespace ErsatzTV.Application.Television.Queries
|
||||
public class
|
||||
GetTelevisionSeasonByIdHandler : IRequestHandler<GetTelevisionSeasonById, Option<TelevisionSeasonViewModel>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public GetTelevisionSeasonByIdHandler(ITelevisionRepository televisionRepository) =>
|
||||
public GetTelevisionSeasonByIdHandler(
|
||||
ITelevisionRepository televisionRepository,
|
||||
IMediaSourceRepository mediaSourceRepository)
|
||||
{
|
||||
_televisionRepository = televisionRepository;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
}
|
||||
|
||||
public Task<Option<TelevisionSeasonViewModel>> Handle(
|
||||
public async Task<Option<TelevisionSeasonViewModel>> Handle(
|
||||
GetTelevisionSeasonById request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_televisionRepository.GetSeason(request.SeasonId)
|
||||
.MapT(ProjectToViewModel);
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
|
||||
.Map(list => list.HeadOrNone());
|
||||
|
||||
return await _televisionRepository.GetSeason(request.SeasonId)
|
||||
.MapT(s => ProjectToViewModel(s, maybeJellyfin));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,15 +11,18 @@ namespace ErsatzTV.Application.Television.Queries
|
||||
{
|
||||
public class GetTelevisionShowByIdHandler : IRequestHandler<GetTelevisionShowById, Option<TelevisionShowViewModel>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public GetTelevisionShowByIdHandler(
|
||||
ITelevisionRepository televisionRepository,
|
||||
ISearchRepository searchRepository)
|
||||
ISearchRepository searchRepository,
|
||||
IMediaSourceRepository mediaSourceRepository)
|
||||
{
|
||||
_televisionRepository = televisionRepository;
|
||||
_searchRepository = searchRepository;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
}
|
||||
|
||||
public async Task<Option<TelevisionShowViewModel>> Handle(
|
||||
@@ -30,8 +33,11 @@ namespace ErsatzTV.Application.Television.Queries
|
||||
return await maybeShow.Match<Task<Option<TelevisionShowViewModel>>>(
|
||||
async show =>
|
||||
{
|
||||
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
|
||||
.Map(list => list.HeadOrNone());
|
||||
|
||||
List<string> languages = await _searchRepository.GetLanguagesForShow(show);
|
||||
return ProjectToViewModel(show, languages);
|
||||
return ProjectToViewModel(show, languages, maybeJellyfin);
|
||||
},
|
||||
() => Task.FromResult(Option<TelevisionShowViewModel>.None));
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
|
||||
namespace ErsatzTV.Application.Television
|
||||
{
|
||||
@@ -13,5 +14,6 @@ namespace ErsatzTV.Application.Television
|
||||
List<string> Genres,
|
||||
List<string> Tags,
|
||||
List<string> Studios,
|
||||
List<CultureInfo> Languages);
|
||||
List<CultureInfo> Languages,
|
||||
List<ActorCardViewModel> Actors);
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Should_SetRealtime_ForHttpLiveStreaming()
|
||||
public void ShouldNot_SetRealtime_ForHttpLiveStreaming()
|
||||
{
|
||||
FFmpegProfile ffmpegProfile = TestProfile();
|
||||
|
||||
@@ -119,7 +119,7 @@ namespace ErsatzTV.Core.Tests.FFmpeg
|
||||
DateTimeOffset.Now,
|
||||
DateTimeOffset.Now);
|
||||
|
||||
actual.RealtimeOutput.Should().BeTrue();
|
||||
actual.RealtimeOutput.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -79,6 +79,9 @@ namespace ErsatzTV.Core.Tests.Fakes
|
||||
public Task<bool> AddTag(ShowMetadata metadata, Tag tag) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> AddStudio(ShowMetadata metadata, Studio studio) => throw new NotSupportedException();
|
||||
public Task<bool> AddActor(ShowMetadata metadata, Actor actor) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> AddActor(EpisodeMetadata metadata, Actor actor) => throw new NotSupportedException();
|
||||
|
||||
public Task<List<int>> RemoveMissingPlexShows(PlexLibrary library, List<string> showKeys) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
@@ -14,5 +14,7 @@
|
||||
public static ConfigElementKey FFmpegPreferredLanguageCode => new("ffmpeg.preferred_language_code");
|
||||
public static ConfigElementKey SearchIndexVersion => new("search_index.version");
|
||||
public static ConfigElementKey HDHRTunerCount => new("hdhr.tuner_count");
|
||||
public static ConfigElementKey CollectionsPageSize => new("pages.collections.page_size");
|
||||
public static ConfigElementKey LibraryRefreshInterval => new("scanner.library_refresh_interval");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class JellyfinLibrary : Library
|
||||
{
|
||||
public string ItemId { get; set; }
|
||||
public bool ShouldSyncItems { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class JellyfinEpisode : Episode
|
||||
{
|
||||
public string ItemId { get; set; }
|
||||
public string Etag { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class JellyfinMovie : Movie
|
||||
{
|
||||
public string ItemId { get; set; }
|
||||
public string Etag { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class JellyfinSeason : Season
|
||||
{
|
||||
public string ItemId { get; set; }
|
||||
public string Etag { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class JellyfinShow : Show
|
||||
{
|
||||
public string ItemId { get; set; }
|
||||
public string Etag { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class JellyfinConnection
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Address { get; set; }
|
||||
public int JellyfinMediaSourceId { get; set; }
|
||||
public JellyfinMediaSource JellyfinMediaSource { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class JellyfinMediaSource : MediaSource
|
||||
{
|
||||
public string ServerName { get; set; }
|
||||
public string OperatingSystem { get; set; }
|
||||
public List<JellyfinConnection> Connections { get; set; }
|
||||
public List<JellyfinPathReplacement> PathReplacements { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class JellyfinPathReplacement
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string JellyfinPath { get; set; }
|
||||
public string LocalPath { get; set; }
|
||||
public int JellyfinMediaSourceId { get; set; }
|
||||
public JellyfinMediaSource JellyfinMediaSource { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ErsatzTV.Core.Domain
|
||||
{
|
||||
public class Actor
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
public string Role { get; set; }
|
||||
public int? Order { get; set; }
|
||||
public int? ArtworkId { get; set; }
|
||||
public Artwork Artwork { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -18,5 +18,6 @@ namespace ErsatzTV.Core.Domain
|
||||
public List<Genre> Genres { get; set; }
|
||||
public List<Tag> Tags { get; set; }
|
||||
public List<Studio> Studios { get; set; }
|
||||
public List<Actor> Actors { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,6 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
public static class DisplaySizeExtensions
|
||||
{
|
||||
internal static IDisplaySize PadToEven(this IDisplaySize size) =>
|
||||
new DisplaySize(size.Width + size.Width % 2, size.Height + size.Height % 2);
|
||||
|
||||
internal static bool IsSameSizeAs(this IDisplaySize @this, IDisplaySize that) =>
|
||||
@this.Width == that.Width && @this.Height == that.Height;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ 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)
|
||||
@@ -26,6 +27,12 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithRealtime(bool realtime)
|
||||
{
|
||||
_realtime = realtime;
|
||||
return this;
|
||||
}
|
||||
|
||||
public FFmpegComplexFilterBuilder WithScaling(IDisplaySize scaleToSize)
|
||||
{
|
||||
_scaleToSize = Some(scaleToSize);
|
||||
@@ -95,6 +102,11 @@ 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 => true;
|
||||
public bool RealtimeOutput { get; set; }
|
||||
public Option<TimeSpan> StreamSeek { get; set; }
|
||||
public Option<IDisplaySize> ScaledSize { get; set; }
|
||||
public bool PadToDesiredResolution { get; set; }
|
||||
|
||||
@@ -68,8 +68,10 @@ 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))
|
||||
|
||||
@@ -90,11 +90,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
|
||||
public FFmpegProcessBuilder WithRealtimeOutput(bool realtimeOutput)
|
||||
{
|
||||
if (realtimeOutput)
|
||||
{
|
||||
_arguments.Add("-re");
|
||||
}
|
||||
|
||||
_complexFilterBuilder = _complexFilterBuilder.WithRealtime(realtimeOutput);
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -393,7 +389,7 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
{
|
||||
FileName = _ffmpegPath,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = false,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8
|
||||
|
||||
@@ -60,7 +60,6 @@ namespace ErsatzTV.Core.FFmpeg
|
||||
builder = builder.WithDeinterlace(playbackSettings.Deinterlace)
|
||||
.WithScaling(scaledSize);
|
||||
|
||||
scaledSize = scaledSize.PadToEven();
|
||||
if (NeedToPad(channel.FFmpegProfile.Resolution, scaledSize))
|
||||
{
|
||||
builder = builder.WithBlackBars(channel.FFmpegProfile.Resolution);
|
||||
|
||||
@@ -18,6 +18,7 @@ namespace ErsatzTV.Core
|
||||
public static readonly string LegacyImageCacheFolder = Path.Combine(AppDataFolder, "cache", "images");
|
||||
|
||||
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 FFmpegReportsFolder = Path.Combine(AppDataFolder, "ffmpeg-reports");
|
||||
public static readonly string SearchIndexFolder = Path.Combine(AppDataFolder, "search-index");
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Jellyfin
|
||||
{
|
||||
public interface IJellyfinApiClient
|
||||
{
|
||||
Task<Either<BaseError, JellyfinServerInformation>> GetServerInformation(string address, string apiKey);
|
||||
Task<Either<BaseError, List<JellyfinLibrary>>> GetLibraries(string address, string apiKey);
|
||||
Task<Either<BaseError, string>> GetAdminUserId(string address, string apiKey);
|
||||
|
||||
Task<Either<BaseError, List<JellyfinMovie>>> GetMovieLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
int mediaSourceId,
|
||||
string libraryId);
|
||||
|
||||
Task<Either<BaseError, List<JellyfinShow>>> GetShowLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
int mediaSourceId,
|
||||
string libraryId);
|
||||
|
||||
Task<Either<BaseError, List<JellyfinSeason>>> GetSeasonLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
int mediaSourceId,
|
||||
string showId);
|
||||
|
||||
Task<Either<BaseError, List<JellyfinEpisode>>> 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.Jellyfin
|
||||
{
|
||||
public interface IJellyfinMovieLibraryScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string ffprobePath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Jellyfin
|
||||
{
|
||||
public interface IJellyfinPathReplacementService
|
||||
{
|
||||
Task<string> GetReplacementJellyfinPath(int libraryPathId, string path);
|
||||
string GetReplacementJellyfinPath(List<JellyfinPathReplacement> pathReplacements, string path, bool log = true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Jellyfin
|
||||
{
|
||||
public interface IJellyfinSecretStore
|
||||
{
|
||||
Task<Unit> DeleteAll();
|
||||
Task<JellyfinSecrets> ReadSecrets();
|
||||
Task<Unit> SaveSecrets(JellyfinSecrets jellyfinSecrets);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Jellyfin
|
||||
{
|
||||
public interface IJellyfinTelevisionLibraryScanner
|
||||
{
|
||||
Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string ffprobePath);
|
||||
}
|
||||
}
|
||||
@@ -6,11 +6,14 @@ namespace ErsatzTV.Core.Interfaces.Locking
|
||||
{
|
||||
event EventHandler OnLibraryChanged;
|
||||
event EventHandler OnPlexChanged;
|
||||
event EventHandler OnJellyfinChanged;
|
||||
bool LockLibrary(int libraryId);
|
||||
bool UnlockLibrary(int libraryId);
|
||||
bool IsLibraryLocked(int libraryId);
|
||||
bool LockPlex();
|
||||
bool UnlockPlex();
|
||||
bool IsPlexLocked();
|
||||
bool LockJellyfin();
|
||||
bool UnlockJellyfin();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
|
||||
public interface ILocalStatisticsProvider
|
||||
{
|
||||
Task<Either<BaseError, bool>> RefreshStatistics(string ffprobePath, MediaItem mediaItem);
|
||||
Task<Either<BaseError, bool>> RefreshStatistics(string ffprobePath, MediaItem mediaItem, string mediaItemPath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,6 @@ namespace ErsatzTV.Core.Interfaces.Plex
|
||||
Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
PlexLibrary plexMediaSourceLibrary);
|
||||
PlexLibrary library);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,18 @@ namespace ErsatzTV.Core.Interfaces.Plex
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
|
||||
Task<Either<BaseError, MovieMetadata>> GetMovieMetadata(
|
||||
PlexLibrary library,
|
||||
string key,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
|
||||
Task<Either<BaseError, ShowMetadata>> GetShowMetadata(
|
||||
PlexLibrary library,
|
||||
string key,
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token);
|
||||
|
||||
Task<Either<BaseError, MediaVersion>> GetStatistics(
|
||||
string key,
|
||||
PlexConnection connection,
|
||||
|
||||
@@ -10,6 +10,6 @@ namespace ErsatzTV.Core.Interfaces.Plex
|
||||
Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
PlexConnection connection,
|
||||
PlexServerAuthToken token,
|
||||
PlexLibrary plexMediaSourceLibrary);
|
||||
PlexLibrary library);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<List<FFmpegProfile>> GetAll();
|
||||
Task Update(FFmpegProfile ffmpegProfile);
|
||||
Task Delete(int ffmpegProfileId);
|
||||
Task<FFmpegProfile> Copy(int ffmpegProfileId, string name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
{
|
||||
public interface IJellyfinTelevisionRepository
|
||||
{
|
||||
Task<List<JellyfinItemEtag>> GetExistingShows(JellyfinLibrary library);
|
||||
Task<List<JellyfinItemEtag>> GetExistingSeasons(JellyfinLibrary library, string showItemId);
|
||||
Task<List<JellyfinItemEtag>> GetExistingEpisodes(JellyfinLibrary library, string seasonItemId);
|
||||
Task<bool> AddShow(JellyfinShow show);
|
||||
Task<Option<JellyfinShow>> Update(JellyfinShow show);
|
||||
Task<bool> AddSeason(JellyfinSeason season);
|
||||
Task<Unit> Update(JellyfinSeason season);
|
||||
Task<bool> AddEpisode(JellyfinEpisode episode);
|
||||
Task<Unit> Update(JellyfinEpisode episode);
|
||||
Task<List<int>> RemoveMissingShows(JellyfinLibrary library, List<string> showIds);
|
||||
Task<Unit> RemoveMissingSeasons(JellyfinLibrary library, List<string> seasonIds);
|
||||
Task<Unit> RemoveMissingEpisodes(JellyfinLibrary library, List<string> episodeIds);
|
||||
Task<Unit> DeleteEmptySeasons(JellyfinLibrary library);
|
||||
Task<List<int>> DeleteEmptyShows(JellyfinLibrary library);
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,11 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
List<PlexLibrary> toAdd,
|
||||
List<PlexLibrary> toDelete);
|
||||
|
||||
Task<Unit> UpdateLibraries(
|
||||
int jellyfinMediaSourceId,
|
||||
List<JellyfinLibrary> toAdd,
|
||||
List<JellyfinLibrary> toDelete);
|
||||
|
||||
Task<Unit> UpdatePathReplacements(
|
||||
int plexMediaSourceId,
|
||||
List<PlexPathReplacement> toAdd,
|
||||
@@ -44,5 +49,24 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<List<int>> DeletePlex(PlexMediaSource plexMediaSource);
|
||||
Task<List<int>> DisablePlexLibrarySync(List<int> libraryIds);
|
||||
Task EnablePlexLibrarySync(IEnumerable<int> libraryIds);
|
||||
|
||||
Task<Unit> UpsertJellyfin(string address, string serverName, string operatingSystem);
|
||||
Task<List<JellyfinMediaSource>> GetAllJellyfin();
|
||||
Task<Option<JellyfinMediaSource>> GetJellyfin(int id);
|
||||
Task<List<JellyfinLibrary>> GetJellyfinLibraries(int jellyfinMediaSourceId);
|
||||
Task<Unit> EnableJellyfinLibrarySync(IEnumerable<int> libraryIds);
|
||||
Task<List<int>> DisableJellyfinLibrarySync(List<int> libraryIds);
|
||||
Task<Option<JellyfinLibrary>> GetJellyfinLibrary(int jellyfinLibraryId);
|
||||
Task<Option<JellyfinMediaSource>> GetJellyfinByLibraryId(int jellyfinLibraryId);
|
||||
Task<List<JellyfinPathReplacement>> GetJellyfinPathReplacements(int jellyfinMediaSourceId);
|
||||
Task<List<JellyfinPathReplacement>> GetJellyfinPathReplacementsByLibraryId(int jellyfinLibraryPathId);
|
||||
|
||||
Task<Unit> UpdatePathReplacements(
|
||||
int jellyfinMediaSourceId,
|
||||
List<JellyfinPathReplacement> toAdd,
|
||||
List<JellyfinPathReplacement> toUpdate,
|
||||
List<JellyfinPathReplacement> toDelete);
|
||||
|
||||
Task<List<int>> DeleteAllJellyfin();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<bool> RemoveStudio(Studio studio);
|
||||
Task<bool> RemoveStyle(Style style);
|
||||
Task<bool> RemoveMood(Mood mood);
|
||||
Task<bool> RemoveActor(Actor actor);
|
||||
Task<bool> Update(Domain.Metadata metadata);
|
||||
Task<bool> Add(Domain.Metadata metadata);
|
||||
Task<bool> UpdateLocalStatistics(int mediaVersionId, MediaVersion incoming, bool updateVersion = true);
|
||||
@@ -22,5 +23,6 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<Unit> MarkAsUpdated(ShowMetadata metadata, DateTime dateUpdated);
|
||||
Task<Unit> MarkAsUpdated(SeasonMetadata metadata, DateTime dateUpdated);
|
||||
Task<Unit> MarkAsUpdated(MovieMetadata metadata, DateTime dateUpdated);
|
||||
Task<Unit> MarkAsUpdated(EpisodeMetadata metadata, DateTime dateUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using LanguageExt;
|
||||
|
||||
@@ -20,7 +21,12 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
Task<bool> AddGenre(MovieMetadata metadata, Genre genre);
|
||||
Task<bool> AddTag(MovieMetadata metadata, Tag tag);
|
||||
Task<bool> AddStudio(MovieMetadata metadata, Studio studio);
|
||||
Task<bool> AddActor(MovieMetadata metadata, Actor actor);
|
||||
Task<List<int>> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys);
|
||||
Task<bool> UpdateSortTitle(MovieMetadata movieMetadata);
|
||||
Task<List<JellyfinItemEtag>> GetExistingJellyfinMovies(JellyfinLibrary library);
|
||||
Task<List<int>> RemoveMissingJellyfinMovies(JellyfinLibrary library, List<string> movieIds);
|
||||
Task<bool> AddJellyfin(JellyfinMovie movie);
|
||||
Task<Option<JellyfinMovie>> UpdateJellyfin(JellyfinMovie movie);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user