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
This commit is contained in:
@@ -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,102 @@
|
||||
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 maybeUserId.Match<Either<BaseError, Unit>>(
|
||||
userId =>
|
||||
{
|
||||
_logger.LogDebug("Jellyfin admin user id is {UserId}", userId);
|
||||
_memoryCache.Set($"jellyfin_admin_user_id.{parameters.JellyfinMediaSource.Id}", userId);
|
||||
return Unit.Default;
|
||||
},
|
||||
error => error);
|
||||
}
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate(SynchronizeJellyfinAdminUserId request) =>
|
||||
MediaSourceMustExist(request)
|
||||
.BindT(MediaSourceMustHaveActiveConnection)
|
||||
.BindT(MediaSourceMustHaveApiKey);
|
||||
|
||||
private Task<Validation<BaseError, JellyfinMediaSource>> MediaSourceMustExist(
|
||||
SynchronizeJellyfinAdminUserId request) =>
|
||||
_mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId)
|
||||
.Map(o => o.ToValidation<BaseError>("Jellyfin media source does not exist."));
|
||||
|
||||
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
|
||||
JellyfinMediaSource jellyfinMediaSource)
|
||||
{
|
||||
Option<JellyfinConnection> maybeConnection = jellyfinMediaSource.Connections.HeadOrNone();
|
||||
return maybeConnection.Map(connection => new ConnectionParameters(jellyfinMediaSource, connection))
|
||||
.ToValidation<BaseError>("Jellyfin media source requires an active connection");
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey(
|
||||
ConnectionParameters connectionParameters)
|
||||
{
|
||||
JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets();
|
||||
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
|
||||
.Filter(match => match)
|
||||
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
|
||||
.ToValidation<BaseError>("Jellyfin media source requires an api key");
|
||||
}
|
||||
|
||||
private record ConnectionParameters(
|
||||
JellyfinMediaSource JellyfinMediaSource,
|
||||
JellyfinConnection ActiveConnection)
|
||||
{
|
||||
public string ApiKey { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
{
|
||||
public record SynchronizeJellyfinLibraries(int JellyfinMediaSourceId) : MediatR.IRequest<Either<BaseError, Unit>>,
|
||||
IJellyfinBackgroundServiceRequest;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
{
|
||||
public class
|
||||
SynchronizeJellyfinLibrariesHandler : MediatR.IRequestHandler<SynchronizeJellyfinLibraries,
|
||||
Either<BaseError, Unit>>
|
||||
|
||||
{
|
||||
private readonly IJellyfinApiClient _jellyfinApiClient;
|
||||
private readonly IJellyfinSecretStore _jellyfinSecretStore;
|
||||
private readonly ILogger<SynchronizeJellyfinLibrariesHandler> _logger;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
|
||||
public SynchronizeJellyfinLibrariesHandler(
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IJellyfinSecretStore jellyfinSecretStore,
|
||||
IJellyfinApiClient jellyfinApiClient,
|
||||
ILogger<SynchronizeJellyfinLibrariesHandler> logger)
|
||||
{
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_jellyfinSecretStore = jellyfinSecretStore;
|
||||
_jellyfinApiClient = jellyfinApiClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
SynchronizeJellyfinLibraries request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(SynchronizeLibraries)
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> Validate(SynchronizeJellyfinLibraries request) =>
|
||||
MediaSourceMustExist(request)
|
||||
.BindT(MediaSourceMustHaveActiveConnection)
|
||||
.BindT(MediaSourceMustHaveApiKey);
|
||||
|
||||
private Task<Validation<BaseError, JellyfinMediaSource>> MediaSourceMustExist(
|
||||
SynchronizeJellyfinLibraries request) =>
|
||||
_mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId)
|
||||
.Map(o => o.ToValidation<BaseError>("Jellyfin media source does not exist."));
|
||||
|
||||
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
|
||||
JellyfinMediaSource jellyfinMediaSource)
|
||||
{
|
||||
Option<JellyfinConnection> maybeConnection = jellyfinMediaSource.Connections.HeadOrNone();
|
||||
return maybeConnection.Map(connection => new ConnectionParameters(jellyfinMediaSource, connection))
|
||||
.ToValidation<BaseError>("Jellyfin media source requires an active connection");
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey(
|
||||
ConnectionParameters connectionParameters)
|
||||
{
|
||||
JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets();
|
||||
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
|
||||
.Filter(match => match)
|
||||
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
|
||||
.ToValidation<BaseError>("Jellyfin media source requires an api key");
|
||||
}
|
||||
|
||||
private async Task<Unit> SynchronizeLibraries(ConnectionParameters connectionParameters)
|
||||
{
|
||||
Either<BaseError, List<JellyfinLibrary>> maybeLibraries = await _jellyfinApiClient.GetLibraries(
|
||||
connectionParameters.ActiveConnection.Address,
|
||||
connectionParameters.ApiKey);
|
||||
|
||||
await maybeLibraries.Match(
|
||||
libraries =>
|
||||
{
|
||||
var existing = connectionParameters.JellyfinMediaSource.Libraries.OfType<JellyfinLibrary>()
|
||||
.ToList();
|
||||
var toAdd = libraries.Filter(library => existing.All(l => l.ItemId != library.ItemId)).ToList();
|
||||
var toRemove = existing.Filter(library => libraries.All(l => l.ItemId != library.ItemId)).ToList();
|
||||
return _mediaSourceRepository.UpdateLibraries(
|
||||
connectionParameters.JellyfinMediaSource.Id,
|
||||
toAdd,
|
||||
toRemove);
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to synchronize libraries from jellyfin server {JellyfinServer}: {Error}",
|
||||
connectionParameters.JellyfinMediaSource.ServerName,
|
||||
error.Value);
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private record ConnectionParameters(
|
||||
JellyfinMediaSource JellyfinMediaSource,
|
||||
JellyfinConnection ActiveConnection)
|
||||
{
|
||||
public string ApiKey { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
{
|
||||
public interface ISynchronizeJellyfinLibraryById : IRequest<Either<BaseError, string>>,
|
||||
IJellyfinBackgroundServiceRequest
|
||||
{
|
||||
int JellyfinLibraryId { get; }
|
||||
bool ForceScan { get; }
|
||||
}
|
||||
|
||||
public record SynchronizeJellyfinLibraryByIdIfNeeded(int JellyfinLibraryId) : ISynchronizeJellyfinLibraryById
|
||||
{
|
||||
public bool ForceScan => false;
|
||||
}
|
||||
|
||||
public record ForceSynchronizeJellyfinLibraryById(int JellyfinLibraryId) : ISynchronizeJellyfinLibraryById
|
||||
{
|
||||
public bool ForceScan => true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Locking;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using static LanguageExt.Prelude;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
{
|
||||
public class SynchronizeJellyfinLibraryByIdHandler :
|
||||
IRequestHandler<ForceSynchronizeJellyfinLibraryById, Either<BaseError, string>>,
|
||||
IRequestHandler<SynchronizeJellyfinLibraryByIdIfNeeded, Either<BaseError, string>>
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly IEntityLocker _entityLocker;
|
||||
private readonly IJellyfinMovieLibraryScanner _jellyfinMovieLibraryScanner;
|
||||
|
||||
private readonly IJellyfinSecretStore _jellyfinSecretStore;
|
||||
private readonly IJellyfinTelevisionLibraryScanner _jellyfinTelevisionLibraryScanner;
|
||||
private readonly ILibraryRepository _libraryRepository;
|
||||
private readonly ILogger<SynchronizeJellyfinLibraryByIdHandler> _logger;
|
||||
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
|
||||
public SynchronizeJellyfinLibraryByIdHandler(
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IJellyfinSecretStore jellyfinSecretStore,
|
||||
IJellyfinMovieLibraryScanner jellyfinMovieLibraryScanner,
|
||||
IJellyfinTelevisionLibraryScanner jellyfinTelevisionLibraryScanner,
|
||||
ILibraryRepository libraryRepository,
|
||||
IEntityLocker entityLocker,
|
||||
IConfigElementRepository configElementRepository,
|
||||
ILogger<SynchronizeJellyfinLibraryByIdHandler> logger)
|
||||
{
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_jellyfinSecretStore = jellyfinSecretStore;
|
||||
_jellyfinMovieLibraryScanner = jellyfinMovieLibraryScanner;
|
||||
_jellyfinTelevisionLibraryScanner = jellyfinTelevisionLibraryScanner;
|
||||
_libraryRepository = libraryRepository;
|
||||
_entityLocker = entityLocker;
|
||||
_configElementRepository = configElementRepository;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public Task<Either<BaseError, string>> Handle(
|
||||
ForceSynchronizeJellyfinLibraryById request,
|
||||
CancellationToken cancellationToken) => Handle(request);
|
||||
|
||||
public Task<Either<BaseError, string>> Handle(
|
||||
SynchronizeJellyfinLibraryByIdIfNeeded request,
|
||||
CancellationToken cancellationToken) => Handle(request);
|
||||
|
||||
private Task<Either<BaseError, string>>
|
||||
Handle(ISynchronizeJellyfinLibraryById request) =>
|
||||
Validate(request)
|
||||
.MapT(parameters => Synchronize(parameters).Map(_ => parameters.Library.Name))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private async Task<Unit> Synchronize(RequestParameters parameters)
|
||||
{
|
||||
var lastScan = new DateTimeOffset(parameters.Library.LastScan ?? DateTime.MinValue, TimeSpan.Zero);
|
||||
if (parameters.ForceScan || lastScan < DateTimeOffset.Now - TimeSpan.FromHours(6))
|
||||
{
|
||||
switch (parameters.Library.MediaKind)
|
||||
{
|
||||
case LibraryMediaKind.Movies:
|
||||
await _jellyfinMovieLibraryScanner.ScanLibrary(
|
||||
parameters.ConnectionParameters.ActiveConnection.Address,
|
||||
parameters.ConnectionParameters.ApiKey,
|
||||
parameters.Library,
|
||||
parameters.FFprobePath);
|
||||
break;
|
||||
case LibraryMediaKind.Shows:
|
||||
await _jellyfinTelevisionLibraryScanner.ScanLibrary(
|
||||
parameters.ConnectionParameters.ActiveConnection.Address,
|
||||
parameters.ConnectionParameters.ApiKey,
|
||||
parameters.Library,
|
||||
parameters.FFprobePath);
|
||||
break;
|
||||
}
|
||||
|
||||
parameters.Library.LastScan = DateTime.UtcNow;
|
||||
await _libraryRepository.UpdateLastScan(parameters.Library);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Skipping unforced scan of jellyfin media library {Name}",
|
||||
parameters.Library.Name);
|
||||
}
|
||||
|
||||
_entityLocker.UnlockLibrary(parameters.Library.Id);
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, RequestParameters>> Validate(
|
||||
ISynchronizeJellyfinLibraryById request) =>
|
||||
(await ValidateConnection(request), await JellyfinLibraryMustExist(request), await ValidateFFprobePath())
|
||||
.Apply(
|
||||
(connectionParameters, jellyfinLibrary, ffprobePath) => new RequestParameters(
|
||||
connectionParameters,
|
||||
jellyfinLibrary,
|
||||
request.ForceScan,
|
||||
ffprobePath
|
||||
));
|
||||
|
||||
private Task<Validation<BaseError, ConnectionParameters>> ValidateConnection(
|
||||
ISynchronizeJellyfinLibraryById request) =>
|
||||
JellyfinMediaSourceMustExist(request)
|
||||
.BindT(MediaSourceMustHaveActiveConnection)
|
||||
.BindT(MediaSourceMustHaveApiKey);
|
||||
|
||||
private Task<Validation<BaseError, JellyfinMediaSource>> JellyfinMediaSourceMustExist(
|
||||
ISynchronizeJellyfinLibraryById request) =>
|
||||
_mediaSourceRepository.GetJellyfinByLibraryId(request.JellyfinLibraryId)
|
||||
.Map(
|
||||
v => v.ToValidation<BaseError>(
|
||||
$"Jellyfin media source for library {request.JellyfinLibraryId} does not exist."));
|
||||
|
||||
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
|
||||
JellyfinMediaSource jellyfinMediaSource)
|
||||
{
|
||||
Option<JellyfinConnection> maybeConnection = jellyfinMediaSource.Connections.HeadOrNone();
|
||||
return maybeConnection.Map(connection => new ConnectionParameters(jellyfinMediaSource, connection))
|
||||
.ToValidation<BaseError>("Jellyfin media source requires an active connection");
|
||||
}
|
||||
|
||||
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey(
|
||||
ConnectionParameters connectionParameters)
|
||||
{
|
||||
JellyfinSecrets secrets = await _jellyfinSecretStore.ReadSecrets();
|
||||
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
|
||||
.Filter(match => match)
|
||||
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
|
||||
.ToValidation<BaseError>("Jellyfin media source requires an api key");
|
||||
}
|
||||
|
||||
private Task<Validation<BaseError, JellyfinLibrary>> JellyfinLibraryMustExist(
|
||||
ISynchronizeJellyfinLibraryById request) =>
|
||||
_mediaSourceRepository.GetJellyfinLibrary(request.JellyfinLibraryId)
|
||||
.Map(v => v.ToValidation<BaseError>($"Jellyfin library {request.JellyfinLibraryId} does not exist."));
|
||||
|
||||
private Task<Validation<BaseError, string>> ValidateFFprobePath() =>
|
||||
_configElementRepository.GetValue<string>(ConfigElementKey.FFprobePath)
|
||||
.FilterT(File.Exists)
|
||||
.Map(
|
||||
ffprobePath =>
|
||||
ffprobePath.ToValidation<BaseError>("FFprobe path does not exist on the file system"));
|
||||
|
||||
private record RequestParameters(
|
||||
ConnectionParameters ConnectionParameters,
|
||||
JellyfinLibrary Library,
|
||||
bool ForceScan,
|
||||
string FFprobePath);
|
||||
|
||||
private record ConnectionParameters(
|
||||
JellyfinMediaSource JellyfinMediaSource,
|
||||
JellyfinConnection ActiveConnection)
|
||||
{
|
||||
public string ApiKey { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
{
|
||||
public record SynchronizeJellyfinMediaSources : IRequest<Either<BaseError, List<JellyfinMediaSource>>>,
|
||||
IJellyfinBackgroundServiceRequest;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
{
|
||||
public class SynchronizeJellyfinMediaSourcesHandler : IRequestHandler<SynchronizeJellyfinMediaSources,
|
||||
Either<BaseError, List<JellyfinMediaSource>>>
|
||||
{
|
||||
private readonly ChannelWriter<IJellyfinBackgroundServiceRequest> _channel;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
|
||||
public SynchronizeJellyfinMediaSourcesHandler(
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
ChannelWriter<IJellyfinBackgroundServiceRequest> channel)
|
||||
{
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_channel = channel;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<JellyfinMediaSource>>> Handle(
|
||||
SynchronizeJellyfinMediaSources request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<JellyfinMediaSource> mediaSources = await _mediaSourceRepository.GetAllJellyfin();
|
||||
foreach (JellyfinMediaSource mediaSource in mediaSources)
|
||||
{
|
||||
await _channel.WriteAsync(new SynchronizeJellyfinAdminUserId(mediaSource.Id), cancellationToken);
|
||||
await _channel.WriteAsync(new SynchronizeJellyfinLibraries(mediaSource.Id), cancellationToken);
|
||||
}
|
||||
|
||||
return mediaSources;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
{
|
||||
public record UpdateJellyfinLibraryPreferences
|
||||
(List<JellyfinLibraryPreference> Preferences) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
|
||||
public record JellyfinLibraryPreference(int Id, bool ShouldSyncItems);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
{
|
||||
public class
|
||||
UpdateJellyfinLibraryPreferencesHandler : MediatR.IRequestHandler<UpdateJellyfinLibraryPreferences,
|
||||
Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public UpdateJellyfinLibraryPreferencesHandler(
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
ISearchIndex searchIndex)
|
||||
{
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_searchIndex = searchIndex;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateJellyfinLibraryPreferences request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var toDisable = request.Preferences.Filter(p => p.ShouldSyncItems == false).Map(p => p.Id).ToList();
|
||||
List<int> ids = await _mediaSourceRepository.DisableJellyfinLibrarySync(toDisable);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
|
||||
IEnumerable<int> toEnable = request.Preferences.Filter(p => p.ShouldSyncItems).Map(p => p.Id);
|
||||
await _mediaSourceRepository.EnableJellyfinLibrarySync(toEnable);
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using System.Collections.Generic;
|
||||
using ErsatzTV.Core;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
{
|
||||
public record UpdateJellyfinPathReplacements(
|
||||
int JellyfinMediaSourceId,
|
||||
List<JellyfinPathReplacementItem> PathReplacements) : MediatR.IRequest<Either<BaseError, Unit>>;
|
||||
|
||||
public record JellyfinPathReplacementItem(int Id, string JellyfinPath, string LocalPath);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Commands
|
||||
{
|
||||
public class UpdateJellyfinPathReplacementsHandler : MediatR.IRequestHandler<UpdateJellyfinPathReplacements,
|
||||
Either<BaseError, Unit>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
|
||||
public UpdateJellyfinPathReplacementsHandler(IMediaSourceRepository mediaSourceRepository) =>
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
|
||||
public Task<Either<BaseError, Unit>> Handle(
|
||||
UpdateJellyfinPathReplacements request,
|
||||
CancellationToken cancellationToken) =>
|
||||
Validate(request)
|
||||
.MapT(pms => MergePathReplacements(request, pms))
|
||||
.Bind(v => v.ToEitherAsync());
|
||||
|
||||
private Task<Unit> MergePathReplacements(
|
||||
UpdateJellyfinPathReplacements request,
|
||||
JellyfinMediaSource jellyfinMediaSource)
|
||||
{
|
||||
jellyfinMediaSource.PathReplacements ??= new List<JellyfinPathReplacement>();
|
||||
|
||||
var incoming = request.PathReplacements.Map(Project).ToList();
|
||||
|
||||
var toAdd = incoming.Filter(r => r.Id < 1).ToList();
|
||||
var toRemove = jellyfinMediaSource.PathReplacements.Filter(r => incoming.All(pr => pr.Id != r.Id)).ToList();
|
||||
var toUpdate = incoming.Except(toAdd).ToList();
|
||||
|
||||
return _mediaSourceRepository.UpdatePathReplacements(jellyfinMediaSource.Id, toAdd, toUpdate, toRemove);
|
||||
}
|
||||
|
||||
private static JellyfinPathReplacement Project(JellyfinPathReplacementItem vm) =>
|
||||
new() { Id = vm.Id, JellyfinPath = vm.JellyfinPath, LocalPath = vm.LocalPath };
|
||||
|
||||
private Task<Validation<BaseError, JellyfinMediaSource>> Validate(UpdateJellyfinPathReplacements request) =>
|
||||
JellyfinMediaSourceMustExist(request);
|
||||
|
||||
private Task<Validation<BaseError, JellyfinMediaSource>> JellyfinMediaSourceMustExist(
|
||||
UpdateJellyfinPathReplacements request) =>
|
||||
_mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId)
|
||||
.Map(
|
||||
v => v.ToValidation<BaseError>(
|
||||
$"Jellyfin media source {request.JellyfinMediaSourceId} does not exist."));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using ErsatzTV.Application.Libraries;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin
|
||||
{
|
||||
public record JellyfinLibraryViewModel(int Id, string Name, LibraryMediaKind MediaKind, bool ShouldSyncItems)
|
||||
: LibraryViewModel("Jellyfin", Id, Name, MediaKind);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using ErsatzTV.Application.MediaSources;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin
|
||||
{
|
||||
public record JellyfinMediaSourceViewModel(int Id, string Name, string Address) : MediaSourceViewModel(Id, Name);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Application.Jellyfin
|
||||
{
|
||||
public record JellyfinPathReplacementViewModel(int Id, string JellyfinPath, string LocalPath);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin
|
||||
{
|
||||
internal static class Mapper
|
||||
{
|
||||
internal static JellyfinMediaSourceViewModel ProjectToViewModel(JellyfinMediaSource jellyfinMediaSource) =>
|
||||
new(
|
||||
jellyfinMediaSource.Id,
|
||||
jellyfinMediaSource.ServerName,
|
||||
jellyfinMediaSource.Connections.HeadOrNone().Match(c => c.Address, string.Empty));
|
||||
|
||||
internal static JellyfinLibraryViewModel ProjectToViewModel(JellyfinLibrary library) =>
|
||||
new(library.Id, library.Name, library.MediaKind, library.ShouldSyncItems);
|
||||
|
||||
internal static JellyfinPathReplacementViewModel ProjectToViewModel(JellyfinPathReplacement pathReplacement) =>
|
||||
new(pathReplacement.Id, pathReplacement.JellyfinPath, pathReplacement.LocalPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Queries
|
||||
{
|
||||
public record GetAllJellyfinMediaSources : IRequest<List<JellyfinMediaSourceViewModel>>;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using static ErsatzTV.Application.Jellyfin.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Queries
|
||||
{
|
||||
public class
|
||||
GetAllJellyfinMediaSourcesHandler : IRequestHandler<GetAllJellyfinMediaSources,
|
||||
List<JellyfinMediaSourceViewModel>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
|
||||
public GetAllJellyfinMediaSourcesHandler(IMediaSourceRepository mediaSourceRepository) =>
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
|
||||
public Task<List<JellyfinMediaSourceViewModel>> Handle(
|
||||
GetAllJellyfinMediaSources request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetAllJellyfin().Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using System.Collections.Generic;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Queries
|
||||
{
|
||||
public record GetJellyfinLibrariesBySourceId(int JellyfinMediaSourceId) : IRequest<List<JellyfinLibraryViewModel>>;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using static ErsatzTV.Application.Jellyfin.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Queries
|
||||
{
|
||||
public class
|
||||
GetJellyfinLibrariesBySourceIdHandler : IRequestHandler<GetJellyfinLibrariesBySourceId,
|
||||
List<JellyfinLibraryViewModel>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
|
||||
public GetJellyfinLibrariesBySourceIdHandler(IMediaSourceRepository mediaSourceRepository) =>
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
|
||||
public Task<List<JellyfinLibraryViewModel>> Handle(
|
||||
GetJellyfinLibrariesBySourceId request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetJellyfinLibraries(request.JellyfinMediaSourceId)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Queries
|
||||
{
|
||||
public record GetJellyfinMediaSourceById
|
||||
(int JellyfinMediaSourceId) : IRequest<Option<JellyfinMediaSourceViewModel>>;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using static ErsatzTV.Application.Jellyfin.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Queries
|
||||
{
|
||||
public class
|
||||
GetJellyfinMediaSourceByIdHandler : IRequestHandler<GetJellyfinMediaSourceById,
|
||||
Option<JellyfinMediaSourceViewModel>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
|
||||
public GetJellyfinMediaSourceByIdHandler(IMediaSourceRepository mediaSourceRepository) =>
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
|
||||
public Task<Option<JellyfinMediaSourceViewModel>> Handle(
|
||||
GetJellyfinMediaSourceById request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetJellyfin(request.JellyfinMediaSourceId).MapT(ProjectToViewModel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using System.Collections.Generic;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Queries
|
||||
{
|
||||
public record GetJellyfinPathReplacementsBySourceId
|
||||
(int JellyfinMediaSourceId) : IRequest<List<JellyfinPathReplacementViewModel>>;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using LanguageExt;
|
||||
using MediatR;
|
||||
using static ErsatzTV.Application.Jellyfin.Mapper;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Queries
|
||||
{
|
||||
public class GetJellyfinPathReplacementsBySourceIdHandler : IRequestHandler<GetJellyfinPathReplacementsBySourceId,
|
||||
List<JellyfinPathReplacementViewModel>>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
|
||||
public GetJellyfinPathReplacementsBySourceIdHandler(IMediaSourceRepository mediaSourceRepository) =>
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
|
||||
public Task<List<JellyfinPathReplacementViewModel>> Handle(
|
||||
GetJellyfinPathReplacementsBySourceId request,
|
||||
CancellationToken cancellationToken) =>
|
||||
_mediaSourceRepository.GetJellyfinPathReplacements(request.JellyfinMediaSourceId)
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Queries
|
||||
{
|
||||
public record GetJellyfinSecrets : IRequest<JellyfinSecrets>;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using MediatR;
|
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Queries
|
||||
{
|
||||
public class GetJellyfinSecretsHandler : IRequestHandler<GetJellyfinSecrets, JellyfinSecrets>
|
||||
{
|
||||
private readonly IJellyfinSecretStore _jellyfinSecretStore;
|
||||
|
||||
public GetJellyfinSecretsHandler(IJellyfinSecretStore jellyfinSecretStore) =>
|
||||
_jellyfinSecretStore = jellyfinSecretStore;
|
||||
|
||||
public Task<JellyfinSecrets> Handle(GetJellyfinSecrets request, CancellationToken cancellationToken) =>
|
||||
_jellyfinSecretStore.ReadSecrets();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using ErsatzTV.Application.Jellyfin;
|
||||
using ErsatzTV.Core.Domain;
|
||||
|
||||
namespace ErsatzTV.Application.Libraries
|
||||
@@ -10,6 +11,7 @@ namespace ErsatzTV.Application.Libraries
|
||||
{
|
||||
LocalLibrary l => ProjectToViewModel(l),
|
||||
PlexLibrary p => new PlexLibraryViewModel(p.Id, p.Name, p.MediaKind),
|
||||
JellyfinLibrary j => new JellyfinLibraryViewModel(j.Id, j.Name, j.MediaKind, j.ShouldSyncItems),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(library))
|
||||
};
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ namespace ErsatzTV.Application.Libraries.Queries
|
||||
.Map(
|
||||
list => list.Filter(ShouldIncludeLibrary)
|
||||
.OrderBy(l => l.MediaSource is LocalMediaSource ? 0 : 1)
|
||||
.ThenBy(l => l.GetType().Name)
|
||||
.ThenBy(l => l.MediaKind)
|
||||
.Map(ProjectToViewModel).ToList());
|
||||
|
||||
@@ -29,6 +30,7 @@ namespace ErsatzTV.Application.Libraries.Queries
|
||||
{
|
||||
LocalLibrary => true,
|
||||
PlexLibrary plex => plex.ShouldSyncItems,
|
||||
JellyfinLibrary jellyfin => jellyfin.ShouldSyncItems,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
using System;
|
||||
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,
|
||||
@@ -23,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,
|
||||
@@ -41,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(
|
||||
@@ -58,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(
|
||||
@@ -66,27 +74,41 @@ 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) =>
|
||||
new(actor.Id, actor.Name, actor.Role, actor.Artwork?.Path);
|
||||
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))
|
||||
@@ -96,12 +118,37 @@ namespace ErsatzTV.Application.MediaCards
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -10,21 +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),
|
||||
metadata.Actors.OrderBy(a => a.Order).ThenBy(a => a.Id).Map(MediaCards.Mapper.ProjectToViewModel)
|
||||
.ToList());
|
||||
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)
|
||||
@@ -43,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +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<ActorCardViewModel> Actors);
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
@@ -14,13 +15,18 @@ namespace ErsatzTV.Application.Search.Queries
|
||||
{
|
||||
public class QuerySearchIndexMoviesHandler : IRequestHandler<QuerySearchIndexMovies, MovieCardResultsViewModel>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
|
||||
public QuerySearchIndexMoviesHandler(ISearchIndex searchIndex, IMovieRepository movieRepository)
|
||||
public QuerySearchIndexMoviesHandler(
|
||||
ISearchIndex searchIndex,
|
||||
IMovieRepository movieRepository,
|
||||
IMediaSourceRepository mediaSourceRepository)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_movieRepository = movieRepository;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
}
|
||||
|
||||
public async Task<MovieCardResultsViewModel> Handle(
|
||||
@@ -32,9 +38,12 @@ namespace ErsatzTV.Application.Search.Queries
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
request.PageSize);
|
||||
|
||||
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
|
||||
.Map(list => list.HeadOrNone());
|
||||
|
||||
List<MovieCardViewModel> items = await _movieRepository
|
||||
.GetMoviesForCards(searchResult.Items.Map(i => i.Id).ToList())
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
.Map(list => list.Map(m => ProjectToViewModel(m, maybeJellyfin)).ToList());
|
||||
|
||||
return new MovieCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Application.MediaCards;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Search;
|
||||
@@ -15,13 +16,18 @@ namespace ErsatzTV.Application.Search.Queries
|
||||
public class
|
||||
QuerySearchIndexShowsHandler : IRequestHandler<QuerySearchIndexShows, TelevisionShowCardResultsViewModel>
|
||||
{
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ITelevisionRepository _televisionRepository;
|
||||
|
||||
public QuerySearchIndexShowsHandler(ISearchIndex searchIndex, ITelevisionRepository televisionRepository)
|
||||
public QuerySearchIndexShowsHandler(
|
||||
ISearchIndex searchIndex,
|
||||
ITelevisionRepository televisionRepository,
|
||||
IMediaSourceRepository mediaSourceRepository)
|
||||
{
|
||||
_searchIndex = searchIndex;
|
||||
_televisionRepository = televisionRepository;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
}
|
||||
|
||||
public async Task<TelevisionShowCardResultsViewModel> Handle(
|
||||
@@ -33,9 +39,12 @@ namespace ErsatzTV.Application.Search.Queries
|
||||
(request.PageNumber - 1) * request.PageSize,
|
||||
request.PageSize);
|
||||
|
||||
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
|
||||
.Map(list => list.HeadOrNone());
|
||||
|
||||
List<TelevisionShowCardViewModel> items = await _televisionRepository
|
||||
.GetShowsForCards(searchResult.Items.Map(i => i.Id).ToList())
|
||||
.Map(list => list.Map(ProjectToViewModel).ToList());
|
||||
.Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin)).ToList());
|
||||
|
||||
return new TelevisionShowCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
|
||||
}
|
||||
|
||||
+11
-1
@@ -5,6 +5,7 @@ using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Errors;
|
||||
using ErsatzTV.Core.FFmpeg;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Plex;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
@@ -18,6 +19,7 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
{
|
||||
private readonly IConfigElementRepository _configElementRepository;
|
||||
private readonly FFmpegProcessService _ffmpegProcessService;
|
||||
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly IPlayoutRepository _playoutRepository;
|
||||
private readonly IPlexPathReplacementService _plexPathReplacementService;
|
||||
@@ -28,7 +30,8 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
IPlayoutRepository playoutRepository,
|
||||
FFmpegProcessService ffmpegProcessService,
|
||||
ILocalFileSystem localFileSystem,
|
||||
IPlexPathReplacementService plexPathReplacementService)
|
||||
IPlexPathReplacementService plexPathReplacementService,
|
||||
IJellyfinPathReplacementService jellyfinPathReplacementService)
|
||||
: base(channelRepository, configElementRepository)
|
||||
{
|
||||
_configElementRepository = configElementRepository;
|
||||
@@ -36,6 +39,7 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
_ffmpegProcessService = ffmpegProcessService;
|
||||
_localFileSystem = localFileSystem;
|
||||
_plexPathReplacementService = plexPathReplacementService;
|
||||
_jellyfinPathReplacementService = jellyfinPathReplacementService;
|
||||
}
|
||||
|
||||
protected override async Task<Either<BaseError, Process>> GetProcess(
|
||||
@@ -168,6 +172,12 @@ namespace ErsatzTV.Application.Streaming.Queries
|
||||
PlexEpisode plexEpisode => await _plexPathReplacementService.GetReplacementPlexPath(
|
||||
plexEpisode.LibraryPathId,
|
||||
path),
|
||||
JellyfinMovie jellyfinMovie => await _jellyfinPathReplacementService.GetReplacementJellyfinPath(
|
||||
jellyfinMovie.LibraryPathId,
|
||||
path),
|
||||
JellyfinEpisode jellyfinEpisode => await _jellyfinPathReplacementService.GetReplacementJellyfinPath(
|
||||
jellyfinEpisode.LibraryPathId,
|
||||
path),
|
||||
_ => path
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,14 +11,17 @@ 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())
|
||||
@@ -26,19 +29,22 @@ namespace ErsatzTV.Application.Television
|
||||
LanguagesForShow(languages),
|
||||
show.ShowMetadata.HeadOrNone()
|
||||
.Map(
|
||||
m => m.Actors.OrderBy(a => a.Order).ThenBy(a => a.Id).Map(MediaCards.Mapper.ProjectToViewModel)
|
||||
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(
|
||||
@@ -47,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));
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -23,5 +24,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Jellyfin
|
||||
{
|
||||
public class JellyfinItemEtag
|
||||
{
|
||||
public string ItemId { get; set; }
|
||||
public string Etag { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using LanguageExt;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Core.Jellyfin
|
||||
{
|
||||
public class JellyfinMovieLibraryScanner : IJellyfinMovieLibraryScanner
|
||||
{
|
||||
private readonly IJellyfinApiClient _jellyfinApiClient;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalStatisticsProvider _localStatisticsProvider;
|
||||
private readonly ILogger<JellyfinMovieLibraryScanner> _logger;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMovieRepository _movieRepository;
|
||||
private readonly IJellyfinPathReplacementService _pathReplacementService;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
|
||||
public JellyfinMovieLibraryScanner(
|
||||
IJellyfinApiClient jellyfinApiClient,
|
||||
ISearchIndex searchIndex,
|
||||
IMediator mediator,
|
||||
IMovieRepository movieRepository,
|
||||
ISearchRepository searchRepository,
|
||||
IJellyfinPathReplacementService pathReplacementService,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
ILogger<JellyfinMovieLibraryScanner> logger)
|
||||
{
|
||||
_jellyfinApiClient = jellyfinApiClient;
|
||||
_searchIndex = searchIndex;
|
||||
_mediator = mediator;
|
||||
_movieRepository = movieRepository;
|
||||
_searchRepository = searchRepository;
|
||||
_pathReplacementService = pathReplacementService;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_localFileSystem = localFileSystem;
|
||||
_localStatisticsProvider = localStatisticsProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string ffprobePath)
|
||||
{
|
||||
List<JellyfinItemEtag> existingMovies = await _movieRepository.GetExistingJellyfinMovies(library);
|
||||
|
||||
// TODO: maybe get quick list of item ids and etags from api to compare first
|
||||
// TODO: paging?
|
||||
|
||||
List<JellyfinPathReplacement> pathReplacements = await _mediaSourceRepository
|
||||
.GetJellyfinPathReplacements(library.MediaSourceId);
|
||||
|
||||
Either<BaseError, List<JellyfinMovie>> maybeMovies = await _jellyfinApiClient.GetMovieLibraryItems(
|
||||
address,
|
||||
apiKey,
|
||||
library.MediaSourceId,
|
||||
library.ItemId);
|
||||
|
||||
await maybeMovies.Match(
|
||||
async movies =>
|
||||
{
|
||||
var validMovies = new List<JellyfinMovie>();
|
||||
foreach (JellyfinMovie movie in movies.OrderBy(m => m.MovieMetadata.Head().Title))
|
||||
{
|
||||
string localPath = _pathReplacementService.GetReplacementJellyfinPath(
|
||||
pathReplacements,
|
||||
movie.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
false);
|
||||
|
||||
if (!_localFileSystem.FileExists(localPath))
|
||||
{
|
||||
_logger.LogWarning($"Skipping jellyfin movie that does not exist at {localPath}");
|
||||
}
|
||||
else
|
||||
{
|
||||
validMovies.Add(movie);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (JellyfinMovie incoming in validMovies)
|
||||
{
|
||||
decimal percentCompletion = (decimal) validMovies.IndexOf(incoming) / validMovies.Count;
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
|
||||
|
||||
Option<JellyfinItemEtag> maybeExisting =
|
||||
existingMovies.Find(ie => ie.ItemId == incoming.ItemId);
|
||||
|
||||
var updateStatistics = false;
|
||||
|
||||
await maybeExisting.Match(
|
||||
async existing =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (existing.Etag == incoming.Etag)
|
||||
{
|
||||
// _logger.LogDebug(
|
||||
// $"NOOP: Etag has not changed for movie {incoming.MovieMetadata.Head().Title}");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"UPDATE: Etag has changed for movie {Movie}",
|
||||
incoming.MovieMetadata.Head().Title);
|
||||
|
||||
updateStatistics = true;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
Option<JellyfinMovie> updated = await _movieRepository.UpdateJellyfin(incoming);
|
||||
if (updated.IsSome)
|
||||
{
|
||||
await _searchIndex.UpdateItems(
|
||||
_searchRepository,
|
||||
new List<MediaItem> { updated.ValueUnsafe() });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
updateStatistics = false;
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Error updating movie {Movie}",
|
||||
incoming.MovieMetadata.Head().Title);
|
||||
}
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// _logger.LogDebug(
|
||||
// $"INSERT: Item id is new for movie {incoming.MovieMetadata.Head().Title}");
|
||||
|
||||
updateStatistics = true;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
if (await _movieRepository.AddJellyfin(incoming))
|
||||
{
|
||||
await _searchIndex.AddItems(
|
||||
_searchRepository,
|
||||
new List<MediaItem> { incoming });
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
updateStatistics = false;
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Error adding movie {Movie}",
|
||||
incoming.MovieMetadata.Head().Title);
|
||||
}
|
||||
});
|
||||
|
||||
if (updateStatistics)
|
||||
{
|
||||
string localPath = _pathReplacementService.GetReplacementJellyfinPath(
|
||||
pathReplacements,
|
||||
incoming.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
false);
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath);
|
||||
|
||||
await refreshResult.Match(
|
||||
async _ =>
|
||||
{
|
||||
Option<MediaItem> updated = await _searchRepository.GetItemToIndex(incoming.Id);
|
||||
if (updated.IsSome)
|
||||
{
|
||||
await _searchIndex.UpdateItems(
|
||||
_searchRepository,
|
||||
new List<MediaItem> { updated.ValueUnsafe() });
|
||||
}
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
|
||||
"Statistics",
|
||||
localPath,
|
||||
error.Value);
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: figure out how to rebuild playlists
|
||||
}
|
||||
|
||||
var incomingMovieIds = validMovies.Map(s => s.ItemId).ToList();
|
||||
var movieIds = existingMovies
|
||||
.Filter(i => !incomingMovieIds.Contains(i.ItemId))
|
||||
.Map(m => m.ItemId)
|
||||
.ToList();
|
||||
List<int> ids = await _movieRepository.RemoveMissingJellyfinMovies(library, movieIds);
|
||||
await _searchIndex.RemoveItems(ids);
|
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, 0));
|
||||
_searchIndex.Commit();
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error synchronizing jellyfin library {Path}: {Error}",
|
||||
library.Name,
|
||||
error.Value);
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
_searchIndex.Commit();
|
||||
return Unit.Default;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Runtime;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ErsatzTV.Core.Jellyfin
|
||||
{
|
||||
public class JellyfinPathReplacementService : IJellyfinPathReplacementService
|
||||
{
|
||||
private readonly ILogger<JellyfinPathReplacementService> _logger;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IRuntimeInfo _runtimeInfo;
|
||||
|
||||
public JellyfinPathReplacementService(
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IRuntimeInfo runtimeInfo,
|
||||
ILogger<JellyfinPathReplacementService> logger)
|
||||
{
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_runtimeInfo = runtimeInfo;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string> GetReplacementJellyfinPath(int libraryPathId, string path)
|
||||
{
|
||||
List<JellyfinPathReplacement> replacements =
|
||||
await _mediaSourceRepository.GetJellyfinPathReplacementsByLibraryId(libraryPathId);
|
||||
|
||||
return GetReplacementJellyfinPath(replacements, path);
|
||||
}
|
||||
|
||||
public string GetReplacementJellyfinPath(
|
||||
List<JellyfinPathReplacement> pathReplacements,
|
||||
string path,
|
||||
bool log = true)
|
||||
{
|
||||
Option<JellyfinPathReplacement> maybeReplacement = pathReplacements
|
||||
.SingleOrDefault(
|
||||
r =>
|
||||
{
|
||||
string separatorChar = IsWindows(r.JellyfinMediaSource) ? @"\" : @"/";
|
||||
string prefix = r.JellyfinPath.EndsWith(separatorChar)
|
||||
? r.JellyfinPath
|
||||
: r.JellyfinPath + separatorChar;
|
||||
return path.StartsWith(prefix);
|
||||
});
|
||||
|
||||
return maybeReplacement.Match(
|
||||
replacement =>
|
||||
{
|
||||
string finalPath = path.Replace(replacement.JellyfinPath, replacement.LocalPath);
|
||||
if (IsWindows(replacement.JellyfinMediaSource) && !_runtimeInfo.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
finalPath = finalPath.Replace(@"\", @"/");
|
||||
}
|
||||
else if (!IsWindows(replacement.JellyfinMediaSource) &&
|
||||
_runtimeInfo.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
finalPath = finalPath.Replace(@"/", @"\");
|
||||
}
|
||||
|
||||
if (log)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Replacing jellyfin path {JellyfinPath} with {LocalPath} resulting in {FinalPath}",
|
||||
replacement.JellyfinPath,
|
||||
replacement.LocalPath,
|
||||
finalPath);
|
||||
}
|
||||
|
||||
return finalPath;
|
||||
},
|
||||
() => path);
|
||||
}
|
||||
|
||||
private static bool IsWindows(JellyfinMediaSource jellyfinMediaSource) =>
|
||||
jellyfinMediaSource.OperatingSystem.ToLowerInvariant().StartsWith("windows");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Core.Jellyfin
|
||||
{
|
||||
public class JellyfinSecrets
|
||||
{
|
||||
public string Address { get; set; }
|
||||
public string ApiKey { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Core.Jellyfin
|
||||
{
|
||||
public record JellyfinServerInformation(string ServerName, string OperatingSystem);
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Interfaces.Search;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using LanguageExt;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Unit = LanguageExt.Unit;
|
||||
|
||||
namespace ErsatzTV.Core.Jellyfin
|
||||
{
|
||||
public class JellyfinTelevisionLibraryScanner : IJellyfinTelevisionLibraryScanner
|
||||
{
|
||||
private readonly IJellyfinApiClient _jellyfinApiClient;
|
||||
private readonly ILocalFileSystem _localFileSystem;
|
||||
private readonly ILocalStatisticsProvider _localStatisticsProvider;
|
||||
private readonly ILogger<JellyfinTelevisionLibraryScanner> _logger;
|
||||
private readonly IMediaSourceRepository _mediaSourceRepository;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IJellyfinPathReplacementService _pathReplacementService;
|
||||
private readonly ISearchIndex _searchIndex;
|
||||
private readonly ISearchRepository _searchRepository;
|
||||
private readonly IJellyfinTelevisionRepository _televisionRepository;
|
||||
|
||||
public JellyfinTelevisionLibraryScanner(
|
||||
IJellyfinApiClient jellyfinApiClient,
|
||||
IMediaSourceRepository mediaSourceRepository,
|
||||
IJellyfinTelevisionRepository televisionRepository,
|
||||
ISearchIndex searchIndex,
|
||||
ISearchRepository searchRepository,
|
||||
IJellyfinPathReplacementService pathReplacementService,
|
||||
ILocalFileSystem localFileSystem,
|
||||
ILocalStatisticsProvider localStatisticsProvider,
|
||||
IMediator mediator,
|
||||
ILogger<JellyfinTelevisionLibraryScanner> logger)
|
||||
{
|
||||
_jellyfinApiClient = jellyfinApiClient;
|
||||
_mediaSourceRepository = mediaSourceRepository;
|
||||
_televisionRepository = televisionRepository;
|
||||
_searchIndex = searchIndex;
|
||||
_searchRepository = searchRepository;
|
||||
_pathReplacementService = pathReplacementService;
|
||||
_localFileSystem = localFileSystem;
|
||||
_localStatisticsProvider = localStatisticsProvider;
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, Unit>> ScanLibrary(
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string ffprobePath)
|
||||
{
|
||||
List<JellyfinItemEtag> existingShows = await _televisionRepository.GetExistingShows(library);
|
||||
|
||||
// TODO: maybe get quick list of item ids and etags from api to compare first
|
||||
// TODO: paging?
|
||||
|
||||
List<JellyfinPathReplacement> pathReplacements = await _mediaSourceRepository
|
||||
.GetJellyfinPathReplacements(library.MediaSourceId);
|
||||
|
||||
Either<BaseError, List<JellyfinShow>> maybeShows = await _jellyfinApiClient.GetShowLibraryItems(
|
||||
address,
|
||||
apiKey,
|
||||
library.MediaSourceId,
|
||||
library.ItemId);
|
||||
|
||||
await maybeShows.Match(
|
||||
async shows =>
|
||||
{
|
||||
await ProcessShows(address, apiKey, library, ffprobePath, pathReplacements, existingShows, shows);
|
||||
|
||||
var incomingShowIds = shows.Map(s => s.ItemId).ToList();
|
||||
var showIds = existingShows
|
||||
.Filter(i => !incomingShowIds.Contains(i.ItemId))
|
||||
.Map(m => m.ItemId)
|
||||
.ToList();
|
||||
List<int> missingShowIds = await _televisionRepository.RemoveMissingShows(library, showIds);
|
||||
await _searchIndex.RemoveItems(missingShowIds);
|
||||
|
||||
await _televisionRepository.DeleteEmptySeasons(library);
|
||||
List<int> emptyShowIds = await _televisionRepository.DeleteEmptyShows(library);
|
||||
await _searchIndex.RemoveItems(emptyShowIds);
|
||||
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, 0));
|
||||
_searchIndex.Commit();
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error synchronizing jellyfin library {Path}: {Error}",
|
||||
library.Name,
|
||||
error.Value);
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
private async Task ProcessShows(
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string ffprobePath,
|
||||
List<JellyfinPathReplacement> pathReplacements,
|
||||
List<JellyfinItemEtag> existingShows,
|
||||
List<JellyfinShow> shows)
|
||||
{
|
||||
foreach (JellyfinShow incoming in shows.OrderBy(s => s.ShowMetadata.Head().Title))
|
||||
{
|
||||
decimal percentCompletion = (decimal) shows.IndexOf(incoming) / shows.Count;
|
||||
await _mediator.Publish(new LibraryScanProgress(library.Id, percentCompletion));
|
||||
|
||||
var changed = false;
|
||||
|
||||
Option<JellyfinItemEtag> maybeExisting = existingShows.Find(ie => ie.ItemId == incoming.ItemId);
|
||||
await maybeExisting.Match(
|
||||
async existing =>
|
||||
{
|
||||
if (existing.Etag == incoming.Etag)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"UPDATE: Etag has changed for show {Show}",
|
||||
incoming.ShowMetadata.Head().Title);
|
||||
|
||||
changed = true;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
Option<JellyfinShow> updated = await _televisionRepository.Update(incoming);
|
||||
if (updated.IsSome)
|
||||
{
|
||||
await _searchIndex.UpdateItems(
|
||||
_searchRepository,
|
||||
new List<MediaItem> { updated.ValueUnsafe() });
|
||||
}
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
changed = true;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
// _logger.LogDebug("INSERT: Item id is new for show {Show}", incoming.ShowMetadata.Head().Title);
|
||||
|
||||
if (await _televisionRepository.AddShow(incoming))
|
||||
{
|
||||
await _searchIndex.AddItems(_searchRepository, new List<MediaItem> { incoming });
|
||||
}
|
||||
});
|
||||
|
||||
if (changed)
|
||||
{
|
||||
List<JellyfinItemEtag> existingSeasons =
|
||||
await _televisionRepository.GetExistingSeasons(library, incoming.ItemId);
|
||||
|
||||
Either<BaseError, List<JellyfinSeason>> maybeSeasons =
|
||||
await _jellyfinApiClient.GetSeasonLibraryItems(
|
||||
address,
|
||||
apiKey,
|
||||
library.MediaSourceId,
|
||||
incoming.ItemId);
|
||||
|
||||
await maybeSeasons.Match(
|
||||
async seasons =>
|
||||
{
|
||||
await ProcessSeasons(
|
||||
address,
|
||||
apiKey,
|
||||
library,
|
||||
ffprobePath,
|
||||
pathReplacements,
|
||||
incoming,
|
||||
existingSeasons,
|
||||
seasons);
|
||||
|
||||
await _searchIndex.UpdateItems(_searchRepository, new List<MediaItem> { incoming });
|
||||
|
||||
var incomingSeasonIds = seasons.Map(s => s.ItemId).ToList();
|
||||
var seasonIds = existingSeasons
|
||||
.Filter(i => !incomingSeasonIds.Contains(i.ItemId))
|
||||
.Map(m => m.ItemId)
|
||||
.ToList();
|
||||
await _televisionRepository.RemoveMissingSeasons(library, seasonIds);
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error synchronizing jellyfin library {Path}: {Error}",
|
||||
library.Name,
|
||||
error.Value);
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessSeasons(
|
||||
string address,
|
||||
string apiKey,
|
||||
JellyfinLibrary library,
|
||||
string ffprobePath,
|
||||
List<JellyfinPathReplacement> pathReplacements,
|
||||
JellyfinShow show,
|
||||
List<JellyfinItemEtag> existingSeasons,
|
||||
List<JellyfinSeason> seasons)
|
||||
{
|
||||
foreach (JellyfinSeason incoming in seasons)
|
||||
{
|
||||
var changed = false;
|
||||
|
||||
Option<JellyfinItemEtag> maybeExisting = existingSeasons.Find(ie => ie.ItemId == incoming.ItemId);
|
||||
await maybeExisting.Match(
|
||||
async existing =>
|
||||
{
|
||||
if (existing.Etag == incoming.Etag)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"UPDATE: Etag has changed for show {Show} season {Season}",
|
||||
show.ShowMetadata.Head().Title,
|
||||
incoming.SeasonMetadata.Head().Title);
|
||||
|
||||
changed = true;
|
||||
incoming.ShowId = show.Id;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
await _televisionRepository.Update(incoming);
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
changed = true;
|
||||
incoming.ShowId = show.Id;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
_logger.LogDebug(
|
||||
"INSERT: Item id is new for show {Show} season {Season}",
|
||||
show.ShowMetadata.Head().Title,
|
||||
incoming.SeasonMetadata.Head().Title);
|
||||
|
||||
await _televisionRepository.AddSeason(incoming);
|
||||
});
|
||||
|
||||
if (changed)
|
||||
{
|
||||
List<JellyfinItemEtag> existingEpisodes =
|
||||
await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId);
|
||||
|
||||
Either<BaseError, List<JellyfinEpisode>> maybeEpisodes =
|
||||
await _jellyfinApiClient.GetEpisodeLibraryItems(
|
||||
address,
|
||||
apiKey,
|
||||
library.MediaSourceId,
|
||||
incoming.ItemId);
|
||||
|
||||
await maybeEpisodes.Match(
|
||||
async episodes =>
|
||||
{
|
||||
var validEpisodes = new List<JellyfinEpisode>();
|
||||
foreach (JellyfinEpisode episode in episodes)
|
||||
{
|
||||
string localPath = _pathReplacementService.GetReplacementJellyfinPath(
|
||||
pathReplacements,
|
||||
episode.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
false);
|
||||
|
||||
if (!_localFileSystem.FileExists(localPath))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Skipping jellyfin episode that does not exist at {Path}",
|
||||
localPath);
|
||||
}
|
||||
else
|
||||
{
|
||||
validEpisodes.Add(episode);
|
||||
}
|
||||
}
|
||||
|
||||
await ProcessEpisodes(
|
||||
show.ShowMetadata.Head().Title,
|
||||
incoming.SeasonMetadata.Head().Title,
|
||||
library,
|
||||
ffprobePath,
|
||||
pathReplacements,
|
||||
incoming,
|
||||
existingEpisodes,
|
||||
validEpisodes);
|
||||
|
||||
var incomingEpisodeIds = episodes.Map(s => s.ItemId).ToList();
|
||||
var episodeIds = existingEpisodes
|
||||
.Filter(i => !incomingEpisodeIds.Contains(i.ItemId))
|
||||
.Map(m => m.ItemId)
|
||||
.ToList();
|
||||
await _televisionRepository.RemoveMissingEpisodes(library, episodeIds);
|
||||
},
|
||||
error =>
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Error synchronizing jellyfin library {Path}: {Error}",
|
||||
library.Name,
|
||||
error.Value);
|
||||
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessEpisodes(
|
||||
string showName,
|
||||
string seasonName,
|
||||
JellyfinLibrary library,
|
||||
string ffprobePath,
|
||||
List<JellyfinPathReplacement> pathReplacements,
|
||||
JellyfinSeason season,
|
||||
List<JellyfinItemEtag> existingEpisodes,
|
||||
List<JellyfinEpisode> episodes)
|
||||
{
|
||||
foreach (JellyfinEpisode incoming in episodes)
|
||||
{
|
||||
var updateStatistics = false;
|
||||
|
||||
Option<JellyfinItemEtag> maybeExisting = existingEpisodes.Find(ie => ie.ItemId == incoming.ItemId);
|
||||
await maybeExisting.Match(
|
||||
async existing =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (existing.Etag == incoming.Etag)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug(
|
||||
"UPDATE: Etag has changed for show {Show} season {Season} episode {Episode}",
|
||||
showName,
|
||||
seasonName,
|
||||
"EPISODE");
|
||||
|
||||
updateStatistics = true;
|
||||
incoming.SeasonId = season.Id;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
await _televisionRepository.Update(incoming);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
updateStatistics = false;
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Error updating episode {Path}",
|
||||
incoming.MediaVersions.Head().MediaFiles.Head().Path);
|
||||
}
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
updateStatistics = true;
|
||||
incoming.SeasonId = season.Id;
|
||||
incoming.LibraryPathId = library.Paths.Head().Id;
|
||||
|
||||
_logger.LogDebug(
|
||||
"INSERT: Item id is new for show {Show} season {Season} episode {Episode}",
|
||||
showName,
|
||||
seasonName,
|
||||
"EPISODE");
|
||||
|
||||
await _televisionRepository.AddEpisode(incoming);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
updateStatistics = false;
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Error adding episode {Path}",
|
||||
incoming.MediaVersions.Head().MediaFiles.Head().Path);
|
||||
}
|
||||
});
|
||||
|
||||
if (updateStatistics)
|
||||
{
|
||||
string localPath = _pathReplacementService.GetReplacementJellyfinPath(
|
||||
pathReplacements,
|
||||
incoming.MediaVersions.Head().MediaFiles.Head().Path,
|
||||
false);
|
||||
|
||||
_logger.LogDebug("Refreshing {Attribute} for {Path}", "Statistics", localPath);
|
||||
Either<BaseError, bool> refreshResult =
|
||||
await _localStatisticsProvider.RefreshStatistics(ffprobePath, incoming, localPath);
|
||||
|
||||
refreshResult.Match(
|
||||
_ => { },
|
||||
error => _logger.LogWarning(
|
||||
"Unable to refresh {Attribute} for media item {Path}. Error: {Error}",
|
||||
"Statistics",
|
||||
localPath,
|
||||
error.Value));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,12 +41,28 @@ namespace ErsatzTV.Core.Metadata
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
|
||||
};
|
||||
|
||||
Either<BaseError, FFprobe> maybeProbe = await GetProbeOutput(ffprobePath, filePath);
|
||||
return await RefreshStatistics(ffprobePath, mediaItem, filePath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to refresh statistics for media item {Id}", mediaItem.Id);
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, bool>> RefreshStatistics(
|
||||
string ffprobePath,
|
||||
MediaItem mediaItem,
|
||||
string mediaItemPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
Either<BaseError, FFprobe> maybeProbe = await GetProbeOutput(ffprobePath, mediaItemPath);
|
||||
return await maybeProbe.Match(
|
||||
async ffprobe =>
|
||||
{
|
||||
MediaVersion version = ProjectToMediaVersion(filePath, ffprobe);
|
||||
bool result = await ApplyVersionUpdate(mediaItem, version, filePath);
|
||||
MediaVersion version = ProjectToMediaVersion(mediaItemPath, ffprobe);
|
||||
bool result = await ApplyVersionUpdate(mediaItem, version, mediaItemPath);
|
||||
return Right<BaseError, bool>(result);
|
||||
},
|
||||
error => Task.FromResult(Left<BaseError, bool>(error)));
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class JellyfinLibraryConfiguration : IEntityTypeConfiguration<JellyfinLibrary>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<JellyfinLibrary> builder) =>
|
||||
builder.ToTable("JellyfinLibrary");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class JellyfinEpisodeConfiguration : IEntityTypeConfiguration<JellyfinEpisode>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<JellyfinEpisode> builder) => builder.ToTable("JellyfinEpisode");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class JellyfinMovieConfiguration : IEntityTypeConfiguration<JellyfinMovie>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<JellyfinMovie> builder) => builder.ToTable("JellyfinMovie");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class JellyfinSeasonConfiguration : IEntityTypeConfiguration<JellyfinSeason>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<JellyfinSeason> builder) => builder.ToTable("JellyfinSeason");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class JellyfinShowConfiguration : IEntityTypeConfiguration<JellyfinShow>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<JellyfinShow> builder) => builder.ToTable("JellyfinShow");
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class JellyfinConnectionConfiguration : IEntityTypeConfiguration<JellyfinConnection>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<JellyfinConnection> builder) =>
|
||||
builder.ToTable("JellyfinConnection");
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class JellyfinMediaSourceConfiguration : IEntityTypeConfiguration<JellyfinMediaSource>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<JellyfinMediaSource> builder)
|
||||
{
|
||||
builder.ToTable("JellyfinMediaSource");
|
||||
|
||||
builder.HasMany(s => s.Connections)
|
||||
.WithOne(c => c.JellyfinMediaSource)
|
||||
.HasForeignKey(c => c.JellyfinMediaSourceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasMany(s => s.PathReplacements)
|
||||
.WithOne(r => r.JellyfinMediaSource)
|
||||
.HasForeignKey(r => r.JellyfinMediaSourceId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
using ErsatzTV.Core.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations
|
||||
{
|
||||
public class JellyfinPathReplacementConfiguration : IEntityTypeConfiguration<JellyfinPathReplacement>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<JellyfinPathReplacement> builder) =>
|
||||
builder.ToTable("JellyfinPathReplacement");
|
||||
}
|
||||
}
|
||||
@@ -66,7 +66,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
await dbContext.Entry(clone).Reference(f => f.Resolution).LoadAsync();
|
||||
|
||||
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using LanguageExt;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
{
|
||||
public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
|
||||
{
|
||||
private readonly IDbConnection _dbConnection;
|
||||
private readonly IDbContextFactory<TvContext> _dbContextFactory;
|
||||
|
||||
public JellyfinTelevisionRepository(IDbConnection dbConnection, IDbContextFactory<TvContext> dbContextFactory)
|
||||
{
|
||||
_dbConnection = dbConnection;
|
||||
_dbContextFactory = dbContextFactory;
|
||||
}
|
||||
|
||||
public Task<List<JellyfinItemEtag>> GetExistingShows(JellyfinLibrary library) =>
|
||||
_dbConnection.QueryAsync<JellyfinItemEtag>(
|
||||
@"SELECT ItemId, Etag FROM JellyfinShow
|
||||
INNER JOIN Show S on JellyfinShow.Id = S.Id
|
||||
INNER JOIN MediaItem MI on S.Id = MI.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
|
||||
WHERE LP.LibraryId = @LibraryId",
|
||||
new { LibraryId = library.Id })
|
||||
.Map(result => result.ToList());
|
||||
|
||||
public Task<List<JellyfinItemEtag>> GetExistingSeasons(JellyfinLibrary library, string showItemId) =>
|
||||
_dbConnection.QueryAsync<JellyfinItemEtag>(
|
||||
@"SELECT JellyfinSeason.ItemId, JellyfinSeason.Etag FROM JellyfinSeason
|
||||
INNER JOIN Season S on JellyfinSeason.Id = S.Id
|
||||
INNER JOIN MediaItem MI on S.Id = MI.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
|
||||
INNER JOIN Show S2 on S.ShowId = S2.Id
|
||||
INNER JOIN JellyfinShow JS on S2.Id = JS.Id
|
||||
WHERE LP.LibraryId = @LibraryId AND JS.ItemId = @ShowItemId",
|
||||
new { LibraryId = library.Id, ShowItemId = showItemId })
|
||||
.Map(result => result.ToList());
|
||||
|
||||
public Task<List<JellyfinItemEtag>> GetExistingEpisodes(JellyfinLibrary library, string seasonItemId) =>
|
||||
_dbConnection.QueryAsync<JellyfinItemEtag>(
|
||||
@"SELECT JellyfinEpisode.ItemId, JellyfinEpisode.Etag FROM JellyfinEpisode
|
||||
INNER JOIN Episode E on JellyfinEpisode.Id = E.Id
|
||||
INNER JOIN MediaItem MI on E.Id = MI.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
|
||||
INNER JOIN Season S2 on E.SeasonId = S2.Id
|
||||
INNER JOIN JellyfinSeason JS on S2.Id = JS.Id
|
||||
WHERE LP.LibraryId = @LibraryId AND JS.ItemId = @SeasonItemId",
|
||||
new { LibraryId = library.Id, SeasonItemId = seasonItemId })
|
||||
.Map(result => result.ToList());
|
||||
|
||||
public async Task<bool> AddShow(JellyfinShow show)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await dbContext.AddAsync(show);
|
||||
if (await dbContext.SaveChangesAsync() <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await dbContext.Entry(show).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(show.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<Option<JellyfinShow>> Update(JellyfinShow show)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Option<JellyfinShow> maybeExisting = await dbContext.JellyfinShows
|
||||
.Include(m => m.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(m => m.ShowMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(m => m.ShowMetadata)
|
||||
.ThenInclude(mm => mm.Tags)
|
||||
.Include(m => m.ShowMetadata)
|
||||
.ThenInclude(mm => mm.Studios)
|
||||
.Include(m => m.ShowMetadata)
|
||||
.ThenInclude(mm => mm.Actors)
|
||||
.Include(m => m.ShowMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Filter(m => m.ItemId == show.ItemId)
|
||||
.OrderBy(m => m.ItemId)
|
||||
.SingleOrDefaultAsync();
|
||||
|
||||
if (maybeExisting.IsSome)
|
||||
{
|
||||
JellyfinShow existing = maybeExisting.ValueUnsafe();
|
||||
|
||||
// library path is used for search indexing later
|
||||
show.LibraryPath = existing.LibraryPath;
|
||||
show.Id = existing.Id;
|
||||
|
||||
existing.Etag = show.Etag;
|
||||
|
||||
// metadata
|
||||
ShowMetadata metadata = existing.ShowMetadata.Head();
|
||||
ShowMetadata incomingMetadata = show.ShowMetadata.Head();
|
||||
metadata.Title = incomingMetadata.Title;
|
||||
metadata.SortTitle = incomingMetadata.SortTitle;
|
||||
metadata.Plot = incomingMetadata.Plot;
|
||||
metadata.Year = incomingMetadata.Year;
|
||||
metadata.Tagline = incomingMetadata.Tagline;
|
||||
metadata.DateAdded = incomingMetadata.DateAdded;
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
|
||||
// genres
|
||||
foreach (Genre genre in metadata.Genres
|
||||
.Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Genres.Remove(genre);
|
||||
}
|
||||
|
||||
foreach (Genre genre in incomingMetadata.Genres
|
||||
.Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Genres.Add(genre);
|
||||
}
|
||||
|
||||
// tags
|
||||
foreach (Tag tag in metadata.Tags
|
||||
.Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Tags.Remove(tag);
|
||||
}
|
||||
|
||||
foreach (Tag tag in incomingMetadata.Tags
|
||||
.Filter(g => metadata.Tags.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Tags.Add(tag);
|
||||
}
|
||||
|
||||
// studios
|
||||
foreach (Studio studio in metadata.Studios
|
||||
.Filter(g => incomingMetadata.Studios.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Studios.Remove(studio);
|
||||
}
|
||||
|
||||
foreach (Studio studio in incomingMetadata.Studios
|
||||
.Filter(g => metadata.Studios.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Studios.Add(studio);
|
||||
}
|
||||
|
||||
// actors
|
||||
foreach (Actor actor in metadata.Actors
|
||||
.Filter(
|
||||
a => incomingMetadata.Actors.All(
|
||||
a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Actors.Remove(actor);
|
||||
}
|
||||
|
||||
foreach (Actor actor in incomingMetadata.Actors
|
||||
.Filter(a => metadata.Actors.All(a2 => a2.Name != a.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Actors.Add(actor);
|
||||
}
|
||||
|
||||
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
|
||||
|
||||
// poster
|
||||
Artwork incomingPoster =
|
||||
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster);
|
||||
if (incomingPoster != null)
|
||||
{
|
||||
Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster);
|
||||
if (poster == null)
|
||||
{
|
||||
poster = new Artwork { ArtworkKind = ArtworkKind.Poster };
|
||||
metadata.Artwork.Add(poster);
|
||||
}
|
||||
|
||||
poster.Path = incomingPoster.Path;
|
||||
poster.DateAdded = incomingPoster.DateAdded;
|
||||
poster.DateUpdated = incomingPoster.DateUpdated;
|
||||
}
|
||||
|
||||
// fan art
|
||||
Artwork incomingFanArt =
|
||||
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
|
||||
if (incomingFanArt != null)
|
||||
{
|
||||
Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
|
||||
if (fanArt == null)
|
||||
{
|
||||
fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt };
|
||||
metadata.Artwork.Add(fanArt);
|
||||
}
|
||||
|
||||
fanArt.Path = incomingFanArt.Path;
|
||||
fanArt.DateAdded = incomingFanArt.DateAdded;
|
||||
fanArt.DateUpdated = incomingFanArt.DateUpdated;
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return maybeExisting;
|
||||
}
|
||||
|
||||
public async Task<bool> AddSeason(JellyfinSeason season)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await dbContext.AddAsync(season);
|
||||
if (await dbContext.SaveChangesAsync() <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await dbContext.Entry(season).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(season.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<Unit> Update(JellyfinSeason season)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Option<JellyfinSeason> maybeExisting = await dbContext.JellyfinSeasons
|
||||
.Include(m => m.LibraryPath)
|
||||
.Include(m => m.SeasonMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Filter(m => m.ItemId == season.ItemId)
|
||||
.OrderBy(m => m.ItemId)
|
||||
.SingleOrDefaultAsync();
|
||||
|
||||
if (maybeExisting.IsSome)
|
||||
{
|
||||
JellyfinSeason existing = maybeExisting.ValueUnsafe();
|
||||
|
||||
// library path is used for search indexing later
|
||||
season.LibraryPath = existing.LibraryPath;
|
||||
season.Id = existing.Id;
|
||||
|
||||
existing.Etag = season.Etag;
|
||||
existing.SeasonNumber = season.SeasonNumber;
|
||||
|
||||
// metadata
|
||||
SeasonMetadata metadata = existing.SeasonMetadata.Head();
|
||||
SeasonMetadata incomingMetadata = season.SeasonMetadata.Head();
|
||||
metadata.Title = incomingMetadata.Title;
|
||||
metadata.SortTitle = incomingMetadata.SortTitle;
|
||||
metadata.Year = incomingMetadata.Year;
|
||||
metadata.DateAdded = incomingMetadata.DateAdded;
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
|
||||
|
||||
// poster
|
||||
Artwork incomingPoster =
|
||||
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster);
|
||||
if (incomingPoster != null)
|
||||
{
|
||||
Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster);
|
||||
if (poster == null)
|
||||
{
|
||||
poster = new Artwork { ArtworkKind = ArtworkKind.Poster };
|
||||
metadata.Artwork.Add(poster);
|
||||
}
|
||||
|
||||
poster.Path = incomingPoster.Path;
|
||||
poster.DateAdded = incomingPoster.DateAdded;
|
||||
poster.DateUpdated = incomingPoster.DateUpdated;
|
||||
}
|
||||
|
||||
// fan art
|
||||
Artwork incomingFanArt =
|
||||
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
|
||||
if (incomingFanArt != null)
|
||||
{
|
||||
Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
|
||||
if (fanArt == null)
|
||||
{
|
||||
fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt };
|
||||
metadata.Artwork.Add(fanArt);
|
||||
}
|
||||
|
||||
fanArt.Path = incomingFanArt.Path;
|
||||
fanArt.DateAdded = incomingFanArt.DateAdded;
|
||||
fanArt.DateUpdated = incomingFanArt.DateUpdated;
|
||||
}
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
public async Task<bool> AddEpisode(JellyfinEpisode episode)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await dbContext.AddAsync(episode);
|
||||
if (await dbContext.SaveChangesAsync() <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await dbContext.Entry(episode).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(episode.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<Unit> Update(JellyfinEpisode episode)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Option<JellyfinEpisode> maybeExisting = await dbContext.JellyfinEpisodes
|
||||
.Include(m => m.LibraryPath)
|
||||
.Include(m => m.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(m => m.MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(m => m.EpisodeMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Filter(m => m.ItemId == episode.ItemId)
|
||||
.OrderBy(m => m.ItemId)
|
||||
.SingleOrDefaultAsync();
|
||||
|
||||
if (maybeExisting.IsSome)
|
||||
{
|
||||
JellyfinEpisode existing = maybeExisting.ValueUnsafe();
|
||||
|
||||
// library path is used for search indexing later
|
||||
episode.LibraryPath = existing.LibraryPath;
|
||||
episode.Id = existing.Id;
|
||||
|
||||
existing.Etag = episode.Etag;
|
||||
existing.EpisodeNumber = episode.EpisodeNumber;
|
||||
|
||||
// metadata
|
||||
EpisodeMetadata metadata = existing.EpisodeMetadata.Head();
|
||||
EpisodeMetadata incomingMetadata = episode.EpisodeMetadata.Head();
|
||||
metadata.Title = incomingMetadata.Title;
|
||||
metadata.SortTitle = incomingMetadata.SortTitle;
|
||||
metadata.Plot = incomingMetadata.Plot;
|
||||
metadata.Year = incomingMetadata.Year;
|
||||
metadata.DateAdded = incomingMetadata.DateAdded;
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
|
||||
|
||||
// thumbnail
|
||||
Artwork incomingThumbnail =
|
||||
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail);
|
||||
if (incomingThumbnail != null)
|
||||
{
|
||||
Artwork thumbnail = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail);
|
||||
if (thumbnail == null)
|
||||
{
|
||||
thumbnail = new Artwork { ArtworkKind = ArtworkKind.Thumbnail };
|
||||
metadata.Artwork.Add(thumbnail);
|
||||
}
|
||||
|
||||
thumbnail.Path = incomingThumbnail.Path;
|
||||
thumbnail.DateAdded = incomingThumbnail.DateAdded;
|
||||
thumbnail.DateUpdated = incomingThumbnail.DateUpdated;
|
||||
}
|
||||
|
||||
// version
|
||||
MediaVersion version = existing.MediaVersions.Head();
|
||||
MediaVersion incomingVersion = episode.MediaVersions.Head();
|
||||
version.Name = incomingVersion.Name;
|
||||
version.DateAdded = incomingVersion.DateAdded;
|
||||
|
||||
// media file
|
||||
MediaFile file = version.MediaFiles.Head();
|
||||
MediaFile incomingFile = incomingVersion.MediaFiles.Head();
|
||||
file.Path = incomingFile.Path;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
public async Task<List<int>> RemoveMissingShows(JellyfinLibrary library, List<string> showIds)
|
||||
{
|
||||
List<int> ids = await _dbConnection.QueryAsync<int>(
|
||||
@"SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN JellyfinShow js ON js.Id = m.Id
|
||||
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
|
||||
WHERE lp.LibraryId = @LibraryId AND js.ItemId IN @ShowIds",
|
||||
new { LibraryId = library.Id, ShowIds = showIds }).Map(result => result.ToList());
|
||||
|
||||
await _dbConnection.ExecuteAsync(
|
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
(SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN JellyfinShow js ON js.Id = m.Id
|
||||
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
|
||||
WHERE lp.LibraryId = @LibraryId AND js.ItemId IN @ShowIds)",
|
||||
new { LibraryId = library.Id, ShowIds = showIds });
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
public Task<Unit> RemoveMissingSeasons(JellyfinLibrary library, List<string> seasonIds) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
(SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN JellyfinSeason js ON js.Id = m.Id
|
||||
INNER JOIN LibraryPath LP on m.LibraryPathId = LP.Id
|
||||
WHERE LP.LibraryId = @LibraryId AND js.ItemId IN @SeasonIds)",
|
||||
new { LibraryId = library.Id, SeasonIds = seasonIds }).ToUnit();
|
||||
|
||||
public Task<Unit> RemoveMissingEpisodes(JellyfinLibrary library, List<string> episodeIds) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
(SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN JellyfinEpisode je ON je.Id = m.Id
|
||||
INNER JOIN LibraryPath LP on m.LibraryPathId = LP.Id
|
||||
WHERE LP.LibraryId = @LibraryId AND je.ItemId IN @EpisodeIds)",
|
||||
new { LibraryId = library.Id, EpisodeIds = episodeIds }).ToUnit();
|
||||
|
||||
public async Task<Unit> DeleteEmptySeasons(JellyfinLibrary library)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
List<JellyfinSeason> seasons = await dbContext.JellyfinSeasons
|
||||
.Filter(s => s.LibraryPath.LibraryId == library.Id)
|
||||
.Filter(s => s.Episodes.Count == 0)
|
||||
.ToListAsync();
|
||||
dbContext.Seasons.RemoveRange(seasons);
|
||||
await dbContext.SaveChangesAsync();
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
public async Task<List<int>> DeleteEmptyShows(JellyfinLibrary library)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
List<JellyfinShow> shows = await dbContext.JellyfinShows
|
||||
.Filter(s => s.LibraryPath.LibraryId == library.Id)
|
||||
.Filter(s => s.Seasons.Count == 0)
|
||||
.ToListAsync();
|
||||
var ids = shows.Map(s => s.Id).ToList();
|
||||
dbContext.Shows.RemoveRange(shows);
|
||||
await dbContext.SaveChangesAsync();
|
||||
return ids;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,6 +233,33 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
public async Task<Unit> UpdateLibraries(
|
||||
int jellyfinMediaSourceId,
|
||||
List<JellyfinLibrary> toAdd,
|
||||
List<JellyfinLibrary> toDelete)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
|
||||
foreach (JellyfinLibrary add in toAdd)
|
||||
{
|
||||
add.MediaSourceId = jellyfinMediaSourceId;
|
||||
dbContext.Entry(add).State = EntityState.Added;
|
||||
foreach (LibraryPath path in add.Paths)
|
||||
{
|
||||
dbContext.Entry(path).State = EntityState.Added;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (JellyfinLibrary delete in toDelete)
|
||||
{
|
||||
dbContext.Entry(delete).State = EntityState.Deleted;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
public async Task<Unit> UpdatePathReplacements(
|
||||
int plexMediaSourceId,
|
||||
List<PlexPathReplacement> toAdd,
|
||||
@@ -386,5 +413,260 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
_dbConnection.ExecuteAsync(
|
||||
"UPDATE PlexLibrary SET ShouldSyncItems = 1 WHERE Id IN @ids",
|
||||
new { ids = libraryIds });
|
||||
|
||||
public async Task<Unit> UpsertJellyfin(string address, string serverName, string operatingSystem)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Option<JellyfinMediaSource> maybeExisting = dbContext.JellyfinMediaSources
|
||||
.Include(ms => ms.Connections)
|
||||
.OrderBy(ms => ms.Id)
|
||||
.HeadOrNone();
|
||||
|
||||
return await maybeExisting.Match(
|
||||
async jellyfinMediaSource =>
|
||||
{
|
||||
if (!jellyfinMediaSource.Connections.Any())
|
||||
{
|
||||
jellyfinMediaSource.Connections.Add(new JellyfinConnection { Address = address });
|
||||
}
|
||||
else if (jellyfinMediaSource.Connections.Head().Address != address)
|
||||
{
|
||||
jellyfinMediaSource.Connections.Head().Address = address;
|
||||
}
|
||||
|
||||
if (jellyfinMediaSource.ServerName != serverName)
|
||||
{
|
||||
jellyfinMediaSource.ServerName = serverName;
|
||||
}
|
||||
|
||||
if (jellyfinMediaSource.OperatingSystem != operatingSystem)
|
||||
{
|
||||
jellyfinMediaSource.OperatingSystem = operatingSystem;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return Unit.Default;
|
||||
},
|
||||
async () =>
|
||||
{
|
||||
var mediaSource = new JellyfinMediaSource
|
||||
{
|
||||
ServerName = serverName,
|
||||
OperatingSystem = operatingSystem,
|
||||
Connections = new List<JellyfinConnection>
|
||||
{
|
||||
new() { Address = address }
|
||||
},
|
||||
PathReplacements = new List<JellyfinPathReplacement>()
|
||||
};
|
||||
|
||||
await dbContext.AddAsync(mediaSource);
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return Unit.Default;
|
||||
});
|
||||
}
|
||||
|
||||
public Task<List<JellyfinMediaSource>> GetAllJellyfin()
|
||||
{
|
||||
using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
return context.JellyfinMediaSources
|
||||
.Include(p => p.Connections)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public Task<Option<JellyfinMediaSource>> GetJellyfin(int id)
|
||||
{
|
||||
using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
return context.JellyfinMediaSources
|
||||
.Include(p => p.Connections)
|
||||
.Include(p => p.Libraries)
|
||||
.Include(p => p.PathReplacements)
|
||||
.OrderBy(s => s.Id) // https://github.com/dotnet/efcore/issues/22579
|
||||
.SingleOrDefaultAsync(p => p.Id == id)
|
||||
.Map(Optional);
|
||||
}
|
||||
|
||||
public Task<List<JellyfinLibrary>> GetJellyfinLibraries(int jellyfinMediaSourceId)
|
||||
{
|
||||
using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
return context.JellyfinLibraries
|
||||
.Filter(l => l.MediaSourceId == jellyfinMediaSourceId)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public Task<Unit> EnableJellyfinLibrarySync(IEnumerable<int> libraryIds) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
"UPDATE JellyfinLibrary SET ShouldSyncItems = 1 WHERE Id IN @ids",
|
||||
new { ids = libraryIds }).Map(_ => Unit.Default);
|
||||
|
||||
public async Task<List<int>> DisableJellyfinLibrarySync(List<int> libraryIds)
|
||||
{
|
||||
await _dbConnection.ExecuteAsync(
|
||||
"UPDATE JellyfinLibrary SET ShouldSyncItems = 0 WHERE Id IN @ids",
|
||||
new { ids = libraryIds });
|
||||
|
||||
await _dbConnection.ExecuteAsync(
|
||||
"UPDATE Library SET LastScan = null WHERE Id IN @ids",
|
||||
new { ids = libraryIds });
|
||||
|
||||
List<int> movieIds = await _dbConnection.QueryAsync<int>(
|
||||
@"SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN JellyfinMovie pm ON pm.Id = m.Id
|
||||
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
|
||||
INNER JOIN Library l ON l.Id = lp.LibraryId
|
||||
WHERE l.Id IN @ids",
|
||||
new { ids = libraryIds }).Map(result => result.ToList());
|
||||
|
||||
await _dbConnection.ExecuteAsync(
|
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
(SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN JellyfinMovie pm ON pm.Id = m.Id
|
||||
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
|
||||
INNER JOIN Library l ON l.Id = lp.LibraryId
|
||||
WHERE l.Id IN @ids)",
|
||||
new { ids = libraryIds });
|
||||
|
||||
await _dbConnection.ExecuteAsync(
|
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
(SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN JellyfinEpisode pe ON pe.Id = m.Id
|
||||
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
|
||||
INNER JOIN Library l ON l.Id = lp.LibraryId
|
||||
WHERE l.Id IN @ids)",
|
||||
new { ids = libraryIds });
|
||||
|
||||
await _dbConnection.ExecuteAsync(
|
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
(SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN JellyfinSeason ps ON ps.Id = m.Id
|
||||
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
|
||||
INNER JOIN Library l ON l.Id = lp.LibraryId
|
||||
WHERE l.Id IN @ids)",
|
||||
new { ids = libraryIds });
|
||||
|
||||
List<int> showIds = await _dbConnection.QueryAsync<int>(
|
||||
@"SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN JellyfinShow ps ON ps.Id = m.Id
|
||||
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
|
||||
INNER JOIN Library l ON l.Id = lp.LibraryId
|
||||
WHERE l.Id IN @ids",
|
||||
new { ids = libraryIds }).Map(result => result.ToList());
|
||||
|
||||
await _dbConnection.ExecuteAsync(
|
||||
@"DELETE FROM MediaItem WHERE Id IN
|
||||
(SELECT m.Id FROM MediaItem m
|
||||
INNER JOIN JellyfinShow ps ON ps.Id = m.Id
|
||||
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
|
||||
INNER JOIN Library l ON l.Id = lp.LibraryId
|
||||
WHERE l.Id IN @ids)",
|
||||
new { ids = libraryIds });
|
||||
|
||||
return movieIds.Append(showIds).ToList();
|
||||
}
|
||||
|
||||
public Task<Option<JellyfinLibrary>> GetJellyfinLibrary(int jellyfinLibraryId)
|
||||
{
|
||||
using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
return context.JellyfinLibraries
|
||||
.Include(l => l.Paths)
|
||||
.OrderBy(l => l.Id) // https://github.com/dotnet/efcore/issues/22579
|
||||
.SingleOrDefaultAsync(l => l.Id == jellyfinLibraryId)
|
||||
.Map(Optional);
|
||||
}
|
||||
|
||||
public async Task<Option<JellyfinMediaSource>> GetJellyfinByLibraryId(int jellyfinLibraryId)
|
||||
{
|
||||
int? id = await _dbConnection.QuerySingleAsync<int?>(
|
||||
@"SELECT L.MediaSourceId FROM Library L
|
||||
INNER JOIN JellyfinLibrary PL on L.Id = PL.Id
|
||||
WHERE L.Id = @JellyfinLibraryId",
|
||||
new { JellyfinLibraryId = jellyfinLibraryId });
|
||||
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
return await context.JellyfinMediaSources
|
||||
.Include(p => p.Connections)
|
||||
.Include(p => p.Libraries)
|
||||
.OrderBy(p => p.Id)
|
||||
.SingleOrDefaultAsync(p => p.Id == id)
|
||||
.Map(Optional);
|
||||
}
|
||||
|
||||
public Task<List<JellyfinPathReplacement>> GetJellyfinPathReplacements(int jellyfinMediaSourceId)
|
||||
{
|
||||
using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
return context.JellyfinPathReplacements
|
||||
.Filter(r => r.JellyfinMediaSourceId == jellyfinMediaSourceId)
|
||||
.Include(jpr => jpr.JellyfinMediaSource)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public Task<List<JellyfinPathReplacement>> GetJellyfinPathReplacementsByLibraryId(int jellyfinLibraryPathId)
|
||||
{
|
||||
using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
return context.JellyfinPathReplacements
|
||||
.FromSqlRaw(
|
||||
@"select jpr.* from LibraryPath lp
|
||||
inner join JellyfinLibrary jl ON jl.Id = lp.LibraryId
|
||||
inner join Library l ON l.Id = jl.Id
|
||||
inner join JellyfinPathReplacement jpr on jpr.JellyfinMediaSourceId = l.MediaSourceId
|
||||
where lp.Id = {0}",
|
||||
jellyfinLibraryPathId)
|
||||
.Include(jpr => jpr.JellyfinMediaSource)
|
||||
.ToListAsync();
|
||||
}
|
||||
|
||||
public async Task<Unit> UpdatePathReplacements(
|
||||
int jellyfinMediaSourceId,
|
||||
List<JellyfinPathReplacement> toAdd,
|
||||
List<JellyfinPathReplacement> toUpdate,
|
||||
List<JellyfinPathReplacement> toDelete)
|
||||
{
|
||||
foreach (JellyfinPathReplacement add in toAdd)
|
||||
{
|
||||
await _dbConnection.ExecuteAsync(
|
||||
@"INSERT INTO JellyfinPathReplacement
|
||||
(JellyfinPath, LocalPath, JellyfinMediaSourceId)
|
||||
VALUES (@JellyfinPath, @LocalPath, @JellyfinMediaSourceId)",
|
||||
new { add.JellyfinPath, add.LocalPath, JellyfinMediaSourceId = jellyfinMediaSourceId });
|
||||
}
|
||||
|
||||
foreach (JellyfinPathReplacement update in toUpdate)
|
||||
{
|
||||
await _dbConnection.ExecuteAsync(
|
||||
@"UPDATE JellyfinPathReplacement
|
||||
SET JellyfinPath = @JellyfinPath, LocalPath = @LocalPath
|
||||
WHERE Id = @Id",
|
||||
new { update.JellyfinPath, update.LocalPath, update.Id });
|
||||
}
|
||||
|
||||
foreach (JellyfinPathReplacement delete in toDelete)
|
||||
{
|
||||
await _dbConnection.ExecuteAsync(
|
||||
@"DELETE FROM JellyfinPathReplacement WHERE Id = @Id",
|
||||
new { delete.Id });
|
||||
}
|
||||
|
||||
return Unit.Default;
|
||||
}
|
||||
|
||||
public async Task<List<int>> DeleteAllJellyfin()
|
||||
{
|
||||
await using TvContext context = _dbContextFactory.CreateDbContext();
|
||||
|
||||
List<JellyfinMediaSource> allMediaSources = await context.JellyfinMediaSources.ToListAsync();
|
||||
context.JellyfinMediaSources.RemoveRange(allMediaSources);
|
||||
|
||||
List<JellyfinLibrary> allJellyfinLibraries = await context.JellyfinLibraries.ToListAsync();
|
||||
context.JellyfinLibraries.RemoveRange(allJellyfinLibraries);
|
||||
|
||||
List<int> movieIds = await context.JellyfinMovies.Map(pm => pm.Id).ToListAsync();
|
||||
List<int> showIds = await context.JellyfinShows.Map(ps => ps.Id).ToListAsync();
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return movieIds.Append(showIds).ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,7 +222,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
_dbConnection.ExecuteAsync(
|
||||
@"UPDATE MovieMetadata SET DateUpdated = @DateUpdated WHERE Id = @Id",
|
||||
new { DateUpdated = dateUpdated, metadata.Id }).ToUnit();
|
||||
|
||||
|
||||
public Task<Unit> MarkAsUpdated(EpisodeMetadata metadata, DateTime dateUpdated) =>
|
||||
_dbConnection.ExecuteAsync(
|
||||
@"UPDATE EpisodeMetadata SET DateUpdated = @DateUpdated WHERE Id = @Id",
|
||||
|
||||
@@ -7,8 +7,10 @@ using Dapper;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Repositories;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Core.Metadata;
|
||||
using LanguageExt;
|
||||
using LanguageExt.UnsafeValueAccess;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
@@ -246,6 +248,207 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
|
||||
@"UPDATE MovieMetadata SET SortTitle = @SortTitle WHERE Id = @Id",
|
||||
new { movieMetadata.SortTitle, movieMetadata.Id }).Map(result => result > 0);
|
||||
|
||||
public Task<List<JellyfinItemEtag>> GetExistingJellyfinMovies(JellyfinLibrary library) =>
|
||||
_dbConnection.QueryAsync<JellyfinItemEtag>(
|
||||
@"SELECT ItemId, Etag FROM JellyfinMovie
|
||||
INNER JOIN Movie M on JellyfinMovie.Id = M.Id
|
||||
INNER JOIN MediaItem MI on M.Id = MI.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
|
||||
WHERE LP.LibraryId = @LibraryId",
|
||||
new { LibraryId = library.Id })
|
||||
.Map(result => result.ToList());
|
||||
|
||||
public async Task<List<int>> RemoveMissingJellyfinMovies(JellyfinLibrary library, List<string> movieIds)
|
||||
{
|
||||
List<int> ids = await _dbConnection.QueryAsync<int>(
|
||||
@"SELECT JellyfinMovie.Id FROM JellyfinMovie
|
||||
INNER JOIN Movie M on JellyfinMovie.Id = M.Id
|
||||
INNER JOIN MediaItem MI on M.Id = MI.Id
|
||||
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
|
||||
WHERE LP.LibraryId = @LibraryId AND ItemId IN @ItemIds",
|
||||
new { LibraryId = library.Id, ItemIds = movieIds }).Map(result => result.ToList());
|
||||
|
||||
await _dbConnection.ExecuteAsync(
|
||||
"DELETE FROM JellyfinMovie WHERE Id IN @Ids",
|
||||
new { Ids = ids });
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
public async Task<bool> AddJellyfin(JellyfinMovie movie)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
await dbContext.AddAsync(movie);
|
||||
if (await dbContext.SaveChangesAsync() <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await dbContext.Entry(movie).Reference(m => m.LibraryPath).LoadAsync();
|
||||
await dbContext.Entry(movie.LibraryPath).Reference(lp => lp.Library).LoadAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<Option<JellyfinMovie>> UpdateJellyfin(JellyfinMovie movie)
|
||||
{
|
||||
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
|
||||
Option<JellyfinMovie> maybeExisting = await dbContext.JellyfinMovies
|
||||
.Include(m => m.LibraryPath)
|
||||
.ThenInclude(lp => lp.Library)
|
||||
.Include(m => m.MediaVersions)
|
||||
.ThenInclude(mv => mv.MediaFiles)
|
||||
.Include(m => m.MediaVersions)
|
||||
.ThenInclude(mv => mv.Streams)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Genres)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Tags)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Studios)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Actors)
|
||||
.Include(m => m.MovieMetadata)
|
||||
.ThenInclude(mm => mm.Artwork)
|
||||
.Filter(m => m.ItemId == movie.ItemId)
|
||||
.OrderBy(m => m.ItemId)
|
||||
.SingleOrDefaultAsync();
|
||||
|
||||
if (maybeExisting.IsSome)
|
||||
{
|
||||
JellyfinMovie existing = maybeExisting.ValueUnsafe();
|
||||
|
||||
// library path is used for search indexing later
|
||||
movie.LibraryPath = existing.LibraryPath;
|
||||
movie.Id = existing.Id;
|
||||
|
||||
existing.Etag = movie.Etag;
|
||||
|
||||
// metadata
|
||||
MovieMetadata metadata = existing.MovieMetadata.Head();
|
||||
MovieMetadata incomingMetadata = movie.MovieMetadata.Head();
|
||||
metadata.Title = incomingMetadata.Title;
|
||||
metadata.SortTitle = incomingMetadata.SortTitle;
|
||||
metadata.Plot = incomingMetadata.Plot;
|
||||
metadata.Year = incomingMetadata.Year;
|
||||
metadata.Tagline = incomingMetadata.Tagline;
|
||||
metadata.DateAdded = incomingMetadata.DateAdded;
|
||||
metadata.DateUpdated = DateTime.UtcNow;
|
||||
|
||||
// genres
|
||||
foreach (Genre genre in metadata.Genres
|
||||
.Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Genres.Remove(genre);
|
||||
}
|
||||
|
||||
foreach (Genre genre in incomingMetadata.Genres
|
||||
.Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Genres.Add(genre);
|
||||
}
|
||||
|
||||
// tags
|
||||
foreach (Tag tag in metadata.Tags
|
||||
.Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Tags.Remove(tag);
|
||||
}
|
||||
|
||||
foreach (Tag tag in incomingMetadata.Tags
|
||||
.Filter(g => metadata.Tags.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Tags.Add(tag);
|
||||
}
|
||||
|
||||
// studios
|
||||
foreach (Studio studio in metadata.Studios
|
||||
.Filter(g => incomingMetadata.Studios.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Studios.Remove(studio);
|
||||
}
|
||||
|
||||
foreach (Studio studio in incomingMetadata.Studios
|
||||
.Filter(g => metadata.Studios.All(g2 => g2.Name != g.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Studios.Add(studio);
|
||||
}
|
||||
|
||||
// actors
|
||||
foreach (Actor actor in metadata.Actors
|
||||
.Filter(
|
||||
a => incomingMetadata.Actors.All(
|
||||
a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Actors.Remove(actor);
|
||||
}
|
||||
|
||||
foreach (Actor actor in incomingMetadata.Actors
|
||||
.Filter(a => metadata.Actors.All(a2 => a2.Name != a.Name))
|
||||
.ToList())
|
||||
{
|
||||
metadata.Actors.Add(actor);
|
||||
}
|
||||
|
||||
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
|
||||
|
||||
// poster
|
||||
Artwork incomingPoster =
|
||||
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster);
|
||||
if (incomingPoster != null)
|
||||
{
|
||||
Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster);
|
||||
if (poster == null)
|
||||
{
|
||||
poster = new Artwork { ArtworkKind = ArtworkKind.Poster };
|
||||
metadata.Artwork.Add(poster);
|
||||
}
|
||||
|
||||
poster.Path = incomingPoster.Path;
|
||||
poster.DateAdded = incomingPoster.DateAdded;
|
||||
poster.DateUpdated = incomingPoster.DateUpdated;
|
||||
}
|
||||
|
||||
// fan art
|
||||
Artwork incomingFanArt =
|
||||
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
|
||||
if (incomingFanArt != null)
|
||||
{
|
||||
Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
|
||||
if (fanArt == null)
|
||||
{
|
||||
fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt };
|
||||
metadata.Artwork.Add(fanArt);
|
||||
}
|
||||
|
||||
fanArt.Path = incomingFanArt.Path;
|
||||
fanArt.DateAdded = incomingFanArt.DateAdded;
|
||||
fanArt.DateUpdated = incomingFanArt.DateUpdated;
|
||||
}
|
||||
|
||||
// version
|
||||
MediaVersion version = existing.MediaVersions.Head();
|
||||
MediaVersion incomingVersion = movie.MediaVersions.Head();
|
||||
version.Name = incomingVersion.Name;
|
||||
version.DateAdded = incomingVersion.DateAdded;
|
||||
|
||||
// media file
|
||||
MediaFile file = version.MediaFiles.Head();
|
||||
MediaFile incomingFile = incomingVersion.MediaFiles.Head();
|
||||
file.Path = incomingFile.Path;
|
||||
}
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
|
||||
return maybeExisting;
|
||||
}
|
||||
|
||||
private static async Task<Either<BaseError, MediaItemScanResult<Movie>>> AddMovie(
|
||||
TvContext dbContext,
|
||||
int libraryPathId,
|
||||
|
||||
@@ -17,11 +17,14 @@ namespace ErsatzTV.Infrastructure.Data
|
||||
public DbSet<MediaSource> MediaSources { get; set; }
|
||||
public DbSet<LocalMediaSource> LocalMediaSources { get; set; }
|
||||
public DbSet<PlexMediaSource> PlexMediaSources { get; set; }
|
||||
public DbSet<JellyfinMediaSource> JellyfinMediaSources { get; set; }
|
||||
public DbSet<Library> Libraries { get; set; }
|
||||
public DbSet<LocalLibrary> LocalLibraries { get; set; }
|
||||
public DbSet<LibraryPath> LibraryPaths { get; set; }
|
||||
public DbSet<PlexLibrary> PlexLibraries { get; set; }
|
||||
public DbSet<JellyfinLibrary> JellyfinLibraries { get; set; }
|
||||
public DbSet<PlexPathReplacement> PlexPathReplacements { get; set; }
|
||||
public DbSet<JellyfinPathReplacement> JellyfinPathReplacements { get; set; }
|
||||
public DbSet<MediaItem> MediaItems { get; set; }
|
||||
public DbSet<MediaVersion> MediaVersions { get; set; }
|
||||
public DbSet<MediaFile> MediaFiles { get; set; }
|
||||
@@ -40,6 +43,10 @@ namespace ErsatzTV.Infrastructure.Data
|
||||
public DbSet<PlexShow> PlexShows { get; set; }
|
||||
public DbSet<PlexSeason> PlexSeasons { get; set; }
|
||||
public DbSet<PlexEpisode> PlexEpisodes { get; set; }
|
||||
public DbSet<JellyfinMovie> JellyfinMovies { get; set; }
|
||||
public DbSet<JellyfinShow> JellyfinShows { get; set; }
|
||||
public DbSet<JellyfinSeason> JellyfinSeasons { get; set; }
|
||||
public DbSet<JellyfinEpisode> JellyfinEpisodes { get; set; }
|
||||
public DbSet<Collection> Collections { get; set; }
|
||||
public DbSet<CollectionItem> CollectionItems { get; set; }
|
||||
public DbSet<ProgramSchedule> ProgramSchedules { get; set; }
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Infrastructure.Jellyfin.Models;
|
||||
using Refit;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Jellyfin
|
||||
{
|
||||
[Headers("Accept: application/json")]
|
||||
public interface IJellyfinApi
|
||||
{
|
||||
[Get("/System/Info")]
|
||||
public Task<JellyfinSystemInformationResponse> GetSystemInformation(
|
||||
[Header("X-Emby-Token")]
|
||||
string apiKey);
|
||||
|
||||
[Get("/Users")]
|
||||
public Task<List<JellyfinUserResponse>> GetUsers(
|
||||
[Header("X-Emby-Token")]
|
||||
string apiKey);
|
||||
|
||||
[Get("/Library/VirtualFolders")]
|
||||
public Task<List<JellyfinLibraryResponse>> GetLibraries(
|
||||
[Header("X-Emby-Token")]
|
||||
string apiKey);
|
||||
|
||||
[Get("/Items")]
|
||||
public Task<JellyfinLibraryItemsResponse> GetMovieLibraryItems(
|
||||
[Header("X-Emby-Token")]
|
||||
string apiKey,
|
||||
[Query]
|
||||
string userId,
|
||||
[Query]
|
||||
string parentId,
|
||||
[Query]
|
||||
string fields = "Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People",
|
||||
[Query]
|
||||
string includeItemTypes = "Movie");
|
||||
|
||||
[Get("/Items")]
|
||||
public Task<JellyfinLibraryItemsResponse> GetShowLibraryItems(
|
||||
[Header("X-Emby-Token")]
|
||||
string apiKey,
|
||||
[Query]
|
||||
string userId,
|
||||
[Query]
|
||||
string parentId,
|
||||
[Query]
|
||||
string fields = "Path,Genres,Tags,DateCreated,Etag,Overview,Taglines,Studios,People",
|
||||
[Query]
|
||||
string includeItemTypes = "Series");
|
||||
|
||||
[Get("/Items")]
|
||||
public Task<JellyfinLibraryItemsResponse> GetSeasonLibraryItems(
|
||||
[Header("X-Emby-Token")]
|
||||
string apiKey,
|
||||
[Query]
|
||||
string userId,
|
||||
[Query]
|
||||
string parentId,
|
||||
[Query]
|
||||
string fields = "Path,DateCreated,Etag,Taglines",
|
||||
[Query]
|
||||
string includeItemTypes = "Season");
|
||||
|
||||
[Get("/Items")]
|
||||
public Task<JellyfinLibraryItemsResponse> GetEpisodeLibraryItems(
|
||||
[Header("X-Emby-Token")]
|
||||
string apiKey,
|
||||
[Query]
|
||||
string userId,
|
||||
[Query]
|
||||
string parentId,
|
||||
[Query]
|
||||
string fields = "Path,DateCreated,Etag,Overview",
|
||||
[Query]
|
||||
string includeItemTypes = "Episode");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Domain;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Interfaces.Metadata;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using ErsatzTV.Infrastructure.Jellyfin.Models;
|
||||
using LanguageExt;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Refit;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Jellyfin
|
||||
{
|
||||
public class JellyfinApiClient : IJellyfinApiClient
|
||||
{
|
||||
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
|
||||
private readonly ILogger<JellyfinApiClient> _logger;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
|
||||
public JellyfinApiClient(
|
||||
IMemoryCache memoryCache,
|
||||
IFallbackMetadataProvider fallbackMetadataProvider,
|
||||
ILogger<JellyfinApiClient> logger)
|
||||
{
|
||||
_memoryCache = memoryCache;
|
||||
_fallbackMetadataProvider = fallbackMetadataProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, JellyfinServerInformation>> GetServerInformation(
|
||||
string address,
|
||||
string apiKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
|
||||
return await service.GetSystemInformation(apiKey)
|
||||
.Map(response => new JellyfinServerInformation(response.ServerName, response.OperatingSystem));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting jellyfin server name");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<JellyfinLibrary>>> GetLibraries(string address, string apiKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
|
||||
List<JellyfinLibraryResponse> libraries = await service.GetLibraries(apiKey);
|
||||
return libraries
|
||||
.Map(Project)
|
||||
.Somes()
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting jellyfin libraries");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, string>> GetAdminUserId(string address, string apiKey)
|
||||
{
|
||||
try
|
||||
{
|
||||
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
|
||||
List<JellyfinUserResponse> users = await service.GetUsers(apiKey);
|
||||
Option<string> maybeUserId = users
|
||||
.Filter(user => user.Policy.IsAdministrator)
|
||||
.Map(user => user.Id)
|
||||
.HeadOrNone();
|
||||
|
||||
return maybeUserId.ToEither(BaseError.New("Unable to locate jellyfin admin user"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting jellyfin admin user id");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<JellyfinMovie>>> GetMovieLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
int mediaSourceId,
|
||||
string libraryId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{mediaSourceId}", out string userId))
|
||||
{
|
||||
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
|
||||
JellyfinLibraryItemsResponse items = await service.GetMovieLibraryItems(apiKey, userId, libraryId);
|
||||
return items.Items
|
||||
.Map(ProjectToMovie)
|
||||
.Somes()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return BaseError.New("Jellyfin admin user id is not available");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting jellyfin movie library items");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<JellyfinShow>>> GetShowLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
int mediaSourceId,
|
||||
string libraryId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{mediaSourceId}", out string userId))
|
||||
{
|
||||
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
|
||||
JellyfinLibraryItemsResponse items = await service.GetShowLibraryItems(apiKey, userId, libraryId);
|
||||
return items.Items
|
||||
.Map(ProjectToShow)
|
||||
.Somes()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return BaseError.New("Jellyfin admin user id is not available");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting jellyfin show library items");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<JellyfinSeason>>> GetSeasonLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
int mediaSourceId,
|
||||
string showId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{mediaSourceId}", out string userId))
|
||||
{
|
||||
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
|
||||
JellyfinLibraryItemsResponse items = await service.GetSeasonLibraryItems(apiKey, userId, showId);
|
||||
return items.Items
|
||||
.Map(ProjectToSeason)
|
||||
.Somes()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return BaseError.New("Jellyfin admin user id is not available");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting jellyfin show library items");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<Either<BaseError, List<JellyfinEpisode>>> GetEpisodeLibraryItems(
|
||||
string address,
|
||||
string apiKey,
|
||||
int mediaSourceId,
|
||||
string seasonId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{mediaSourceId}", out string userId))
|
||||
{
|
||||
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
|
||||
JellyfinLibraryItemsResponse items = await service.GetEpisodeLibraryItems(apiKey, userId, seasonId);
|
||||
return items.Items
|
||||
.Map(ProjectToEpisode)
|
||||
.Somes()
|
||||
.ToList();
|
||||
}
|
||||
|
||||
return BaseError.New("Jellyfin admin user id is not available");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting jellyfin episode library items");
|
||||
return BaseError.New(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private static Option<JellyfinLibrary> Project(JellyfinLibraryResponse response) =>
|
||||
response.CollectionType.ToLowerInvariant() switch
|
||||
{
|
||||
"tvshows" => new JellyfinLibrary
|
||||
{
|
||||
ItemId = response.ItemId,
|
||||
Name = response.Name,
|
||||
MediaKind = LibraryMediaKind.Shows,
|
||||
ShouldSyncItems = false,
|
||||
Paths = new List<LibraryPath> { new() { Path = $"jellyfin://{response.ItemId}" } }
|
||||
},
|
||||
"movies" => new JellyfinLibrary
|
||||
{
|
||||
ItemId = response.ItemId,
|
||||
Name = response.Name,
|
||||
MediaKind = LibraryMediaKind.Movies,
|
||||
ShouldSyncItems = false,
|
||||
Paths = new List<LibraryPath> { new() { Path = $"jellyfin://{response.ItemId}" } }
|
||||
},
|
||||
// TODO: ??? for music libraries
|
||||
_ => None
|
||||
};
|
||||
|
||||
private Option<JellyfinMovie> ProjectToMovie(JellyfinLibraryItemResponse item)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (item.LocationType != "FileSystem")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
var version = new MediaVersion
|
||||
{
|
||||
Name = "Main",
|
||||
Duration = TimeSpan.FromTicks(item.RunTimeTicks),
|
||||
DateAdded = item.DateCreated.UtcDateTime,
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Path = item.Path
|
||||
}
|
||||
},
|
||||
Streams = new List<MediaStream>()
|
||||
};
|
||||
|
||||
MovieMetadata metadata = ProjectToMovieMetadata(item);
|
||||
|
||||
var movie = new JellyfinMovie
|
||||
{
|
||||
ItemId = item.Id,
|
||||
Etag = item.Etag,
|
||||
MediaVersions = new List<MediaVersion> { version },
|
||||
MovieMetadata = new List<MovieMetadata> { metadata }
|
||||
};
|
||||
|
||||
return movie;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error projecting Jellyfin movie");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
private MovieMetadata ProjectToMovieMetadata(JellyfinLibraryItemResponse item)
|
||||
{
|
||||
DateTime dateAdded = item.DateCreated.UtcDateTime;
|
||||
// DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(item.UpdatedAt).DateTime;
|
||||
|
||||
var metadata = new MovieMetadata
|
||||
{
|
||||
Title = item.Name,
|
||||
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name),
|
||||
Plot = item.Overview,
|
||||
Year = item.ProductionYear,
|
||||
Tagline = Optional(item.Taglines).Flatten().HeadOrNone().IfNone(string.Empty),
|
||||
DateAdded = dateAdded,
|
||||
Genres = Optional(item.Genres).Flatten().Map(g => new Genre { Name = g }).ToList(),
|
||||
Tags = Optional(item.Tags).Flatten().Map(t => new Tag { Name = t }).ToList(),
|
||||
Studios = Optional(item.Studios).Flatten().Map(s => new Studio { Name = s.Name }).ToList(),
|
||||
Actors = Optional(item.People).Flatten().Map(r => ProjectToModel(r, dateAdded)).ToList(),
|
||||
Artwork = new List<Artwork>()
|
||||
};
|
||||
|
||||
// set order on actors
|
||||
for (var i = 0; i < metadata.Actors.Count; i++)
|
||||
{
|
||||
metadata.Actors[i].Order = i;
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate))
|
||||
{
|
||||
metadata.ReleaseDate = releaseDate;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(item.ImageTags.Primary))
|
||||
{
|
||||
var poster = new Artwork
|
||||
{
|
||||
ArtworkKind = ArtworkKind.Poster,
|
||||
Path = $"jellyfin:///Items/{item.Id}/Images/Primary?tag={item.ImageTags.Primary}",
|
||||
DateAdded = dateAdded
|
||||
};
|
||||
metadata.Artwork.Add(poster);
|
||||
}
|
||||
|
||||
if (item.BackdropImageTags.Any())
|
||||
{
|
||||
var fanArt = new Artwork
|
||||
{
|
||||
ArtworkKind = ArtworkKind.FanArt,
|
||||
Path = $"jellyfin:///Items/{item.Id}/Images/Backdrop?tag={item.BackdropImageTags.Head()}",
|
||||
DateAdded = dateAdded
|
||||
};
|
||||
metadata.Artwork.Add(fanArt);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private Actor ProjectToModel(JellyfinPersonResponse person, DateTime dateAdded)
|
||||
{
|
||||
var actor = new Actor { Name = person.Name, Role = person.Role };
|
||||
if (!string.IsNullOrWhiteSpace(person.Id) && !string.IsNullOrWhiteSpace(person.PrimaryImageTag))
|
||||
{
|
||||
actor.Artwork = new Artwork
|
||||
{
|
||||
Path = $"jellyfin:///Items/{person.Id}/Images/Primary?tag={person.PrimaryImageTag}",
|
||||
ArtworkKind = ArtworkKind.Thumbnail,
|
||||
DateAdded = dateAdded
|
||||
};
|
||||
}
|
||||
|
||||
return actor;
|
||||
}
|
||||
|
||||
private Option<JellyfinShow> ProjectToShow(JellyfinLibraryItemResponse item)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (item.LocationType != "FileSystem")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
ShowMetadata metadata = ProjectToShowMetadata(item);
|
||||
|
||||
var show = new JellyfinShow
|
||||
{
|
||||
ItemId = item.Id,
|
||||
Etag = item.Etag,
|
||||
ShowMetadata = new List<ShowMetadata> { metadata }
|
||||
};
|
||||
|
||||
return show;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error projecting Jellyfin show");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
private ShowMetadata ProjectToShowMetadata(JellyfinLibraryItemResponse item)
|
||||
{
|
||||
DateTime dateAdded = item.DateCreated.UtcDateTime;
|
||||
// DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(item.UpdatedAt).DateTime;
|
||||
|
||||
var metadata = new ShowMetadata
|
||||
{
|
||||
Title = item.Name,
|
||||
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name),
|
||||
Plot = item.Overview,
|
||||
Year = item.ProductionYear,
|
||||
Tagline = Optional(item.Taglines).Flatten().HeadOrNone().IfNone(string.Empty),
|
||||
DateAdded = dateAdded,
|
||||
Genres = Optional(item.Genres).Flatten().Map(g => new Genre { Name = g }).ToList(),
|
||||
Tags = Optional(item.Tags).Flatten().Map(t => new Tag { Name = t }).ToList(),
|
||||
Studios = Optional(item.Studios).Flatten().Map(s => new Studio { Name = s.Name }).ToList(),
|
||||
Actors = Optional(item.People).Flatten().Map(r => ProjectToModel(r, dateAdded)).ToList(),
|
||||
Artwork = new List<Artwork>()
|
||||
};
|
||||
|
||||
// set order on actors
|
||||
for (var i = 0; i < metadata.Actors.Count; i++)
|
||||
{
|
||||
metadata.Actors[i].Order = i;
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate))
|
||||
{
|
||||
metadata.ReleaseDate = releaseDate;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(item.ImageTags.Primary))
|
||||
{
|
||||
var poster = new Artwork
|
||||
{
|
||||
ArtworkKind = ArtworkKind.Poster,
|
||||
Path = $"jellyfin:///Items/{item.Id}/Images/Primary?tag={item.ImageTags.Primary}",
|
||||
DateAdded = dateAdded
|
||||
};
|
||||
metadata.Artwork.Add(poster);
|
||||
}
|
||||
|
||||
if (item.BackdropImageTags.Any())
|
||||
{
|
||||
var fanArt = new Artwork
|
||||
{
|
||||
ArtworkKind = ArtworkKind.FanArt,
|
||||
Path = $"jellyfin:///Items/{item.Id}/Images/Backdrop?tag={item.BackdropImageTags.Head()}",
|
||||
DateAdded = dateAdded
|
||||
};
|
||||
metadata.Artwork.Add(fanArt);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
private Option<JellyfinSeason> ProjectToSeason(JellyfinLibraryItemResponse item)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (item.LocationType != "FileSystem")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
DateTime dateAdded = item.DateCreated.UtcDateTime;
|
||||
// DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(response.UpdatedAt).DateTime;
|
||||
|
||||
var metadata = new SeasonMetadata
|
||||
{
|
||||
Title = item.Name,
|
||||
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name),
|
||||
Year = item.ProductionYear,
|
||||
DateAdded = dateAdded,
|
||||
Artwork = new List<Artwork>()
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(item.ImageTags.Primary))
|
||||
{
|
||||
var poster = new Artwork
|
||||
{
|
||||
ArtworkKind = ArtworkKind.Poster,
|
||||
Path = $"jellyfin:///Items/{item.Id}/Images/Primary?tag={item.ImageTags.Primary}",
|
||||
DateAdded = dateAdded
|
||||
};
|
||||
metadata.Artwork.Add(poster);
|
||||
}
|
||||
|
||||
if (item.BackdropImageTags.Any())
|
||||
{
|
||||
var fanArt = new Artwork
|
||||
{
|
||||
ArtworkKind = ArtworkKind.FanArt,
|
||||
Path = $"jellyfin:///Items/{item.Id}/Images/Backdrop?tag={item.BackdropImageTags.Head()}",
|
||||
DateAdded = dateAdded
|
||||
};
|
||||
metadata.Artwork.Add(fanArt);
|
||||
}
|
||||
|
||||
var season = new JellyfinSeason
|
||||
{
|
||||
ItemId = item.Id,
|
||||
Etag = item.Etag,
|
||||
SeasonMetadata = new List<SeasonMetadata> { metadata }
|
||||
};
|
||||
|
||||
if (item.IndexNumber.HasValue)
|
||||
{
|
||||
season.SeasonNumber = item.IndexNumber.Value;
|
||||
}
|
||||
|
||||
return season;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error projecting Jellyfin show");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
private Option<JellyfinEpisode> ProjectToEpisode(JellyfinLibraryItemResponse item)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (item.LocationType != "FileSystem")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
var version = new MediaVersion
|
||||
{
|
||||
Name = "Main",
|
||||
Duration = TimeSpan.FromTicks(item.RunTimeTicks),
|
||||
DateAdded = item.DateCreated.UtcDateTime,
|
||||
MediaFiles = new List<MediaFile>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Path = item.Path
|
||||
}
|
||||
},
|
||||
Streams = new List<MediaStream>()
|
||||
};
|
||||
|
||||
EpisodeMetadata metadata = ProjectToEpisodeMetadata(item);
|
||||
|
||||
var episode = new JellyfinEpisode
|
||||
{
|
||||
ItemId = item.Id,
|
||||
Etag = item.Etag,
|
||||
MediaVersions = new List<MediaVersion> { version },
|
||||
EpisodeMetadata = new List<EpisodeMetadata> { metadata }
|
||||
};
|
||||
|
||||
if (item.IndexNumber.HasValue)
|
||||
{
|
||||
episode.EpisodeNumber = item.IndexNumber.Value;
|
||||
}
|
||||
|
||||
return episode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error projecting Jellyfin movie");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
private EpisodeMetadata ProjectToEpisodeMetadata(JellyfinLibraryItemResponse item)
|
||||
{
|
||||
DateTime dateAdded = item.DateCreated.UtcDateTime;
|
||||
// DateTime lastWriteTime = DateTimeOffset.FromUnixTimeSeconds(item.UpdatedAt).DateTime;
|
||||
|
||||
var metadata = new EpisodeMetadata
|
||||
{
|
||||
Title = item.Name,
|
||||
SortTitle = _fallbackMetadataProvider.GetSortTitle(item.Name),
|
||||
Plot = item.Overview,
|
||||
Year = item.ProductionYear,
|
||||
DateAdded = dateAdded,
|
||||
Genres = new List<Genre>(),
|
||||
Tags = new List<Tag>(),
|
||||
Studios = new List<Studio>(),
|
||||
Actors = new List<Actor>(),
|
||||
Artwork = new List<Artwork>()
|
||||
};
|
||||
|
||||
if (DateTime.TryParse(item.PremiereDate, out DateTime releaseDate))
|
||||
{
|
||||
metadata.ReleaseDate = releaseDate;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(item.ImageTags.Primary))
|
||||
{
|
||||
var thumbnail = new Artwork
|
||||
{
|
||||
ArtworkKind = ArtworkKind.Thumbnail,
|
||||
Path = $"jellyfin:///Items/{item.Id}/Images/Primary?tag={item.ImageTags.Primary}",
|
||||
DateAdded = dateAdded
|
||||
};
|
||||
metadata.Artwork.Add(thumbnail);
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using ErsatzTV.Core;
|
||||
using ErsatzTV.Core.Interfaces.Jellyfin;
|
||||
using ErsatzTV.Core.Jellyfin;
|
||||
using LanguageExt;
|
||||
using Newtonsoft.Json;
|
||||
using static LanguageExt.Prelude;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Jellyfin
|
||||
{
|
||||
public class JellyfinSecretStore : IJellyfinSecretStore
|
||||
{
|
||||
public Task<Unit> DeleteAll() => SaveSecrets(new JellyfinSecrets());
|
||||
|
||||
public Task<JellyfinSecrets> ReadSecrets() =>
|
||||
File.ReadAllTextAsync(FileSystemLayout.JellyfinSecretsPath)
|
||||
.Map(JsonConvert.DeserializeObject<JellyfinSecrets>)
|
||||
.Map(s => Optional(s).IfNone(new JellyfinSecrets()));
|
||||
|
||||
public Task<Unit> SaveSecrets(JellyfinSecrets jellyfinSecrets) =>
|
||||
Some(JsonConvert.SerializeObject(jellyfinSecrets)).Match(
|
||||
s => File.WriteAllTextAsync(FileSystemLayout.JellyfinSecretsPath, s).ToUnit(),
|
||||
Task.FromResult(Unit.Default));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Infrastructure.Jellyfin.Models
|
||||
{
|
||||
public class JellyfinImageTagsResponse
|
||||
{
|
||||
public string Primary { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Jellyfin.Models
|
||||
{
|
||||
public class JellyfinLibraryItemResponse
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Id { get; set; }
|
||||
public string Etag { get; set; }
|
||||
public string Path { get; set; }
|
||||
public DateTimeOffset DateCreated { get; set; }
|
||||
public long RunTimeTicks { get; set; }
|
||||
public List<string> Genres { get; set; }
|
||||
public List<string> Tags { get; set; }
|
||||
public int ProductionYear { get; set; }
|
||||
public string PremiereDate { get; set; }
|
||||
public List<JellyfinMediaStreamResponse> MediaStreams { get; set; }
|
||||
public string LocationType { get; set; }
|
||||
public string Overview { get; set; }
|
||||
public List<string> Taglines { get; set; }
|
||||
public List<JellyfinStudioResponse> Studios { get; set; }
|
||||
public List<JellyfinPersonResponse> People { get; set; }
|
||||
public JellyfinImageTagsResponse ImageTags { get; set; }
|
||||
public List<string> BackdropImageTags { get; set; }
|
||||
public int? IndexNumber { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ErsatzTV.Infrastructure.Jellyfin.Models
|
||||
{
|
||||
public class JellyfinLibraryItemsResponse
|
||||
{
|
||||
public List<JellyfinLibraryItemResponse> Items { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Infrastructure.Jellyfin.Models
|
||||
{
|
||||
public class JellyfinLibraryResponse
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string CollectionType { get; set; }
|
||||
public string ItemId { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ErsatzTV.Infrastructure.Jellyfin.Models
|
||||
{
|
||||
public class JellyfinMediaStreamResponse
|
||||
{
|
||||
public string Type { get; set; }
|
||||
public string Codec { get; set; }
|
||||
public string Language { get; set; }
|
||||
public bool? IsInterlaced { get; set; }
|
||||
public int? Height { get; set; }
|
||||
public int? Width { get; set; }
|
||||
public int Index { get; set; }
|
||||
public bool IsDefault { get; set; }
|
||||
public bool IsForced { get; set; }
|
||||
public string Profile { get; set; }
|
||||
public string AspectRatio { get; set; }
|
||||
public int? Channels { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ErsatzTV.Infrastructure.Jellyfin.Models
|
||||
{
|
||||
public class JellyfinPersonResponse
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Id { get; set; }
|
||||
public string Role { get; set; }
|
||||
public string Type { get; set; }
|
||||
public string PrimaryImageTag { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Infrastructure.Jellyfin.Models
|
||||
{
|
||||
public class JellyfinStudioResponse
|
||||
{
|
||||
public string Id { get; set; }
|
||||
public string Name { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ErsatzTV.Infrastructure.Jellyfin.Models
|
||||
{
|
||||
public class JellyfinSystemInformationResponse
|
||||
{
|
||||
public string ServerName { get; set; }
|
||||
public string OperatingSystem { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Infrastructure.Jellyfin.Models
|
||||
{
|
||||
public class JellyfinUserPolicyResponse
|
||||
{
|
||||
public bool IsAdministrator { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ErsatzTV.Infrastructure.Jellyfin.Models
|
||||
{
|
||||
public class JellyfinUserResponse
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Id { get; set; }
|
||||
public JellyfinUserPolicyResponse Policy { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ namespace ErsatzTV.Infrastructure.Locking
|
||||
public class EntityLocker : IEntityLocker
|
||||
{
|
||||
private readonly ConcurrentDictionary<int, byte> _lockedMediaSources;
|
||||
private bool _jellyfin;
|
||||
private bool _plex;
|
||||
|
||||
public EntityLocker() => _lockedMediaSources = new ConcurrentDictionary<int, byte>();
|
||||
@@ -15,6 +16,8 @@ namespace ErsatzTV.Infrastructure.Locking
|
||||
|
||||
public event EventHandler OnPlexChanged;
|
||||
|
||||
public event EventHandler OnJellyfinChanged;
|
||||
|
||||
public bool LockLibrary(int mediaSourceId)
|
||||
{
|
||||
if (!_lockedMediaSources.ContainsKey(mediaSourceId) && _lockedMediaSources.TryAdd(mediaSourceId, 0))
|
||||
@@ -65,5 +68,29 @@ namespace ErsatzTV.Infrastructure.Locking
|
||||
}
|
||||
|
||||
public bool IsPlexLocked() => _plex;
|
||||
|
||||
public bool LockJellyfin()
|
||||
{
|
||||
if (!_jellyfin)
|
||||
{
|
||||
_jellyfin = true;
|
||||
OnJellyfinChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool UnlockJellyfin()
|
||||
{
|
||||
if (_jellyfin)
|
||||
{
|
||||
_jellyfin = false;
|
||||
OnJellyfinChanged?.Invoke(this, EventArgs.Empty);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user