mirror of https://github.com/ErsatzTV/ErsatzTV.git
Browse Source
* 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 languagespull/186/head
132 changed files with 17318 additions and 106 deletions
@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
|
||||
namespace ErsatzTV.Application |
||||
{ |
||||
public interface IJellyfinBackgroundServiceRequest |
||||
{ |
||||
} |
||||
} |
||||
@ -0,0 +1,7 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Application.Jellyfin |
||||
{ |
||||
public record JellyfinPathReplacementViewModel(int Id, string JellyfinPath, string LocalPath); |
||||
} |
||||
@ -0,0 +1,19 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -0,0 +1,7 @@
|
||||
using ErsatzTV.Core.Jellyfin; |
||||
using MediatR; |
||||
|
||||
namespace ErsatzTV.Application.Jellyfin.Queries |
||||
{ |
||||
public record GetJellyfinSecrets : IRequest<JellyfinSecrets>; |
||||
} |
||||
@ -0,0 +1,19 @@
@@ -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(); |
||||
} |
||||
} |
||||
@ -0,0 +1,8 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -0,0 +1,11 @@
|
||||
namespace ErsatzTV.Core.Domain |
||||
{ |
||||
public class JellyfinPathReplacement |
||||
{ |
||||
public int Id { get; set; } |
||||
public string JellyfinPath { get; set; } |
||||
public string LocalPath { get; set; } |
||||
public int JellyfinMediaSourceId { get; set; } |
||||
public JellyfinMediaSource JellyfinMediaSource { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,39 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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); |
||||
} |
||||
} |
||||
@ -0,0 +1,26 @@
@@ -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); |
||||
} |
||||
} |
||||
@ -0,0 +1,8 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -0,0 +1,4 @@
|
||||
namespace ErsatzTV.Core.Jellyfin |
||||
{ |
||||
public record JellyfinServerInformation(string ServerName, string OperatingSystem); |
||||
} |
||||
@ -0,0 +1,416 @@
@@ -0,0 +1,416 @@
|
||||
using System; |
||||
using System.Collections.Generic; |
||||
using System.Linq; |
||||
using System.Threading.Tasks; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.Interfaces.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)); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations |
||||
{ |
||||
public class JellyfinLibraryConfiguration : IEntityTypeConfiguration<JellyfinLibrary> |
||||
{ |
||||
public void Configure(EntityTypeBuilder<JellyfinLibrary> builder) => |
||||
builder.ToTable("JellyfinLibrary"); |
||||
} |
||||
} |
||||
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations |
||||
{ |
||||
public class JellyfinEpisodeConfiguration : IEntityTypeConfiguration<JellyfinEpisode> |
||||
{ |
||||
public void Configure(EntityTypeBuilder<JellyfinEpisode> builder) => builder.ToTable("JellyfinEpisode"); |
||||
} |
||||
} |
||||
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations |
||||
{ |
||||
public class JellyfinMovieConfiguration : IEntityTypeConfiguration<JellyfinMovie> |
||||
{ |
||||
public void Configure(EntityTypeBuilder<JellyfinMovie> builder) => builder.ToTable("JellyfinMovie"); |
||||
} |
||||
} |
||||
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations |
||||
{ |
||||
public class JellyfinSeasonConfiguration : IEntityTypeConfiguration<JellyfinSeason> |
||||
{ |
||||
public void Configure(EntityTypeBuilder<JellyfinSeason> builder) => builder.ToTable("JellyfinSeason"); |
||||
} |
||||
} |
||||
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations |
||||
{ |
||||
public class JellyfinShowConfiguration : IEntityTypeConfiguration<JellyfinShow> |
||||
{ |
||||
public void Configure(EntityTypeBuilder<JellyfinShow> builder) => builder.ToTable("JellyfinShow"); |
||||
} |
||||
} |
||||
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations |
||||
{ |
||||
public class JellyfinConnectionConfiguration : IEntityTypeConfiguration<JellyfinConnection> |
||||
{ |
||||
public void Configure(EntityTypeBuilder<JellyfinConnection> builder) => |
||||
builder.ToTable("JellyfinConnection"); |
||||
} |
||||
} |
||||
@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations |
||||
{ |
||||
public class 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); |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
using ErsatzTV.Core.Domain; |
||||
using Microsoft.EntityFrameworkCore; |
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders; |
||||
|
||||
namespace ErsatzTV.Infrastructure.Data.Configurations |
||||
{ |
||||
public class JellyfinPathReplacementConfiguration : IEntityTypeConfiguration<JellyfinPathReplacement> |
||||
{ |
||||
public void Configure(EntityTypeBuilder<JellyfinPathReplacement> builder) => |
||||
builder.ToTable("JellyfinPathReplacement"); |
||||
} |
||||
} |
||||
@ -0,0 +1,454 @@
@@ -0,0 +1,454 @@
|
||||
using System; |
||||
using System.Collections.Generic; |
||||
using System.Data; |
||||
using System.Linq; |
||||
using System.Threading.Tasks; |
||||
using Dapper; |
||||
using ErsatzTV.Core.Domain; |
||||
using ErsatzTV.Core.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; |
||||
} |
||||
} |
||||
} |
||||
@ -0,0 +1,78 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Infrastructure.Jellyfin.Models |
||||
{ |
||||
public class JellyfinImageTagsResponse |
||||
{ |
||||
public string Primary { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,28 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -0,0 +1,7 @@
|
||||
namespace ErsatzTV.Infrastructure.Jellyfin.Models |
||||
{ |
||||
public class JellyfinUserPolicyResponse |
||||
{ |
||||
public bool IsAdministrator { get; set; } |
||||
} |
||||
} |
||||
@ -0,0 +1,9 @@
@@ -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; } |
||||
} |
||||
} |
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue