Compare commits

...
Author SHA1 Message Date
Jason DoveandGitHub 3ec838da68 handle unauthorized jellyfin server (#186) 2021-05-15 15:47:43 -05:00
Jason Dove fc5bedc70b update docs for jellyfin [no docker] 2021-05-15 15:39:33 -05:00
Jason DoveandGitHub 4d86250630 add jellyfin media source (#185)
* wip

* start to add jellyfin tables to db

* code cleanup

* finish adding jellyfin media source

* sync jellyfin libraries

* display list of jellyfin libraries

* toggle jellyfin library sync

* edit jellyfin path replacements

* noop jellyfin scanners

* get jellyfin admin user id on startup

* implement jellyfin disconnect

* add jellyfin libraries to list; start to query jellyfin library items

* code cleanup

* start to project jellyfin movies

* save new jellyfin movies to db

* basic jellyfin movie update

* load jellyfin actor artwork

* load jellyfin movie poster and fan art

* more jellyfin artwork fixes, sync audio streams

* jellyfin playback sort of works

* skip jellyfin movies that are inaccessible

* use ffprobe for jellyfin movie statistics

* code cleanup

* store jellyfin operating system

* more jellyfin movie updates

* update jellyfin movie poster and fan art

* add jellyfin tv types

* sync jellyfin shows

* sync jellyfin seasons

* sync jellyfin episodes

* remove missing jellyfin television items

* delete empty jellyfin seasons and shows

* fix jellyfin updates

* fix indexing jellyfin movie and show languages
2021-05-15 13:14:17 -05:00
Jason DoveandGitHub 27e0a70d93 add configurable library refresh interval (#184)
* add configurable library refresh interval

* code cleanup
2021-05-14 06:43:44 -05:00
Jason DoveandGitHub 198e595bc6 add button to copy/clone ffmpeg profile (#183) 2021-05-01 07:45:33 -05:00
Jason DoveandGitHub b178b7402b upgrade to ffmpeg 4.4 (#182)
* bump docker images from ffmpeg 4.3 to 4.4 (#181)

* fix ffmpeg 4.4 compatibility
2021-04-28 18:28:07 -05:00
Jason Dove 1c51aed162 Revert "bump docker images from ffmpeg 4.3 to 4.4 (#181)"
This reverts commit ff6a4c5ea2.
2021-04-28 16:15:47 -05:00
Jason DoveandGitHub ff6a4c5ea2 bump docker images from ffmpeg 4.3 to 4.4 (#181) 2021-04-28 11:05:41 -05:00
Jason DoveandGitHub e515df93fd fix local movie scanner optimization (#180) 2021-04-27 20:13:19 -05:00
Jason DoveandGitHub fedc18f7db only show "movie" and "show" libraries from Plex (#179) 2021-04-25 04:24:38 -05:00
Jason DoveandGitHub 59d75fe08f revert library_name index change, add library_id index (#178) 2021-04-23 08:37:48 -05:00
Jason DoveandGitHub 49d9b1c714 add library search buttons (#177) 2021-04-22 21:31:19 -05:00
Jason Dove 2f066d5b62 update search docs [no docker] 2021-04-17 21:03:58 -05:00
Jason DoveandGitHub 63db2edb99 fix plex actor artwork for first few actors (#176) 2021-04-17 15:39:24 -05:00
Jason DoveandGitHub 5d01276ef3 fix plex actor artwork with newly added media items (#175) 2021-04-17 15:19:23 -05:00
Jason DoveandGitHub 050aaaa288 fix updating music videos (#174) 2021-04-17 10:24:16 -05:00
Jason DoveandGitHub 7c07c5f522 fix odd resolution padding; fix updating plex episode artwork (#173)
* fix padding odd resolutions

* fix updating plex episode artwork only as needed
2021-04-17 10:08:20 -05:00
Jason DoveandGitHub d8d21996b4 add actors to movies and shows (#172)
* add actor metadata

* show actors in ui

* get full movie/show metadata from plex

* store actor thumbnail url

* rework movie detail page

* metadata fixes

* rework show detail page

* rework artist page

* code cleanup
2021-04-17 09:36:57 -05:00
Jason DoveandGitHub e368d4a075 fix collections paging (#171) 2021-04-16 15:59:31 -05:00
Jason DoveandGitHub 466059e2aa fix add to collection typing lag (#170) 2021-04-15 20:35:07 -05:00
Jason DoveandGitHub e951ecb650 fix lag when typing in search bar (#169) 2021-04-15 19:54:19 -05:00
Jason DoveandGitHub 1d1f53da01 enter to submit all dialogs (#168) 2021-04-14 14:48:08 -05:00
Jason DoveandGitHub a854294cb6 allow enter key to submit add to collection dialog (#167) 2021-04-14 14:37:37 -05:00
Jason DoveandGitHub f89f3d2225 fix music videos in epg (#166) 2021-04-13 06:20:37 -05:00
223 changed files with 28470 additions and 661 deletions
@@ -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);
}
+19
View File
@@ -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();
}
}
+2
View File
@@ -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);
}
+69 -25
View File
@@ -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);
}
}
+31 -6
View File
@@ -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);
}
@@ -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
};
}
+48 -13
View File
@@ -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();
+2
View File
@@ -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; }
}
}
+12
View File
@@ -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))
+2 -6
View File
@@ -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);
+1
View File
@@ -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