Browse Source

add jellyfin media source (#185)

* wip

* start to add jellyfin tables to db

* code cleanup

* finish adding jellyfin media source

* sync jellyfin libraries

* display list of jellyfin libraries

* toggle jellyfin library sync

* edit jellyfin path replacements

* noop jellyfin scanners

* get jellyfin admin user id on startup

* implement jellyfin disconnect

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

* code cleanup

* start to project jellyfin movies

* save new jellyfin movies to db

* basic jellyfin movie update

* load jellyfin actor artwork

* load jellyfin movie poster and fan art

* more jellyfin artwork fixes, sync audio streams

* jellyfin playback sort of works

* skip jellyfin movies that are inaccessible

* use ffprobe for jellyfin movie statistics

* code cleanup

* store jellyfin operating system

* more jellyfin movie updates

* update jellyfin movie poster and fan art

* add jellyfin tv types

* sync jellyfin shows

* sync jellyfin seasons

* sync jellyfin episodes

* remove missing jellyfin television items

* delete empty jellyfin seasons and shows

* fix jellyfin updates

* fix indexing jellyfin movie and show languages
pull/186/head
Jason Dove 5 years ago committed by GitHub
parent
commit
4d86250630
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 6
      ErsatzTV.Application/IJellyfinBackgroundServiceRequest.cs
  2. 7
      ErsatzTV.Application/Jellyfin/Commands/DisconnectJellyfin.cs
  3. 44
      ErsatzTV.Application/Jellyfin/Commands/DisconnectJellyfinHandler.cs
  4. 8
      ErsatzTV.Application/Jellyfin/Commands/SaveJellyfinSecrets.cs
  5. 60
      ErsatzTV.Application/Jellyfin/Commands/SaveJellyfinSecretsHandler.cs
  6. 8
      ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinAdminUserId.cs
  7. 102
      ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinAdminUserIdHandler.cs
  8. 8
      ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinLibraries.cs
  9. 111
      ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinLibrariesHandler.cs
  10. 23
      ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinLibraryById.cs
  11. 172
      ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs
  12. 11
      ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinMediaSources.cs
  13. 41
      ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinMediaSourcesHandler.cs
  14. 11
      ErsatzTV.Application/Jellyfin/Commands/UpdateJellyfinLibraryPreferences.cs
  15. 41
      ErsatzTV.Application/Jellyfin/Commands/UpdateJellyfinLibraryPreferencesHandler.cs
  16. 12
      ErsatzTV.Application/Jellyfin/Commands/UpdateJellyfinPathReplacements.cs
  17. 55
      ErsatzTV.Application/Jellyfin/Commands/UpdateJellyfinPathReplacementsHandler.cs
  18. 8
      ErsatzTV.Application/Jellyfin/JellyfinLibraryViewModel.cs
  19. 6
      ErsatzTV.Application/Jellyfin/JellyfinMediaSourceViewModel.cs
  20. 4
      ErsatzTV.Application/Jellyfin/JellyfinPathReplacementViewModel.cs
  21. 19
      ErsatzTV.Application/Jellyfin/Mapper.cs
  22. 7
      ErsatzTV.Application/Jellyfin/Queries/GetAllJellyfinMediaSources.cs
  23. 26
      ErsatzTV.Application/Jellyfin/Queries/GetAllJellyfinMediaSourcesHandler.cs
  24. 7
      ErsatzTV.Application/Jellyfin/Queries/GetJellyfinLibrariesBySourceId.cs
  25. 27
      ErsatzTV.Application/Jellyfin/Queries/GetJellyfinLibrariesBySourceIdHandler.cs
  26. 8
      ErsatzTV.Application/Jellyfin/Queries/GetJellyfinMediaSourceById.cs
  27. 24
      ErsatzTV.Application/Jellyfin/Queries/GetJellyfinMediaSourceByIdHandler.cs
  28. 8
      ErsatzTV.Application/Jellyfin/Queries/GetJellyfinPathReplacementsBySourceId.cs
  29. 26
      ErsatzTV.Application/Jellyfin/Queries/GetJellyfinPathReplacementsBySourceIdHandler.cs
  30. 7
      ErsatzTV.Application/Jellyfin/Queries/GetJellyfinSecrets.cs
  31. 19
      ErsatzTV.Application/Jellyfin/Queries/GetJellyfinSecretsHandler.cs
  32. 2
      ErsatzTV.Application/Libraries/Mapper.cs
  33. 2
      ErsatzTV.Application/Libraries/Queries/GetAllLibrariesHandler.cs
  34. 89
      ErsatzTV.Application/MediaCards/Mapper.cs
  35. 23
      ErsatzTV.Application/MediaCards/Queries/GetCollectionCardsHandler.cs
  36. 14
      ErsatzTV.Application/MediaCards/Queries/GetTelevisionEpisodeCardsHandler.cs
  37. 18
      ErsatzTV.Application/MediaCards/Queries/GetTelevisionSeasonCardsHandler.cs
  38. 37
      ErsatzTV.Application/Movies/Mapper.cs
  39. 8
      ErsatzTV.Application/Movies/MovieViewModel.cs
  40. 19
      ErsatzTV.Application/Movies/Queries/GetMovieByIdHandler.cs
  41. 13
      ErsatzTV.Application/Search/Queries/QuerySearchIndexMoviesHandler.cs
  42. 13
      ErsatzTV.Application/Search/Queries/QuerySearchIndexShowsHandler.cs
  43. 12
      ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs
  44. 55
      ErsatzTV.Application/Television/Mapper.cs
  45. 22
      ErsatzTV.Application/Television/Queries/GetTelevisionSeasonByIdHandler.cs
  46. 10
      ErsatzTV.Application/Television/Queries/GetTelevisionShowByIdHandler.cs
  47. 8
      ErsatzTV.Core/Domain/Library/JellyfinLibrary.cs
  48. 8
      ErsatzTV.Core/Domain/MediaItem/JellyfinEpisode.cs
  49. 8
      ErsatzTV.Core/Domain/MediaItem/JellyfinMovie.cs
  50. 8
      ErsatzTV.Core/Domain/MediaItem/JellyfinSeason.cs
  51. 8
      ErsatzTV.Core/Domain/MediaItem/JellyfinShow.cs
  52. 10
      ErsatzTV.Core/Domain/MediaSource/JellyfinConnection.cs
  53. 12
      ErsatzTV.Core/Domain/MediaSource/JellyfinMediaSource.cs
  54. 11
      ErsatzTV.Core/Domain/MediaSource/JellyfinPathReplacement.cs
  55. 1
      ErsatzTV.Core/FileSystemLayout.cs
  56. 39
      ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinApiClient.cs
  57. 15
      ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinMovieLibraryScanner.cs
  58. 12
      ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinPathReplacementService.cs
  59. 13
      ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinSecretStore.cs
  60. 15
      ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinTelevisionLibraryScanner.cs
  61. 3
      ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs
  62. 1
      ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs
  63. 26
      ErsatzTV.Core/Interfaces/Repositories/IJellyfinTelevisionRepository.cs
  64. 24
      ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs
  65. 5
      ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs
  66. 8
      ErsatzTV.Core/Jellyfin/JellyfinItemEtag.cs
  67. 230
      ErsatzTV.Core/Jellyfin/JellyfinMovieLibraryScanner.cs
  68. 85
      ErsatzTV.Core/Jellyfin/JellyfinPathReplacementService.cs
  69. 8
      ErsatzTV.Core/Jellyfin/JellyfinSecrets.cs
  70. 4
      ErsatzTV.Core/Jellyfin/JellyfinServerInformation.cs
  71. 416
      ErsatzTV.Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs
  72. 22
      ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs
  73. 12
      ErsatzTV.Infrastructure/Data/Configurations/Library/JellyfinLibraryConfiguration.cs
  74. 11
      ErsatzTV.Infrastructure/Data/Configurations/MediaItem/JellyfinEpisodeConfiguration.cs
  75. 11
      ErsatzTV.Infrastructure/Data/Configurations/MediaItem/JellyfinMovieConfiguration.cs
  76. 11
      ErsatzTV.Infrastructure/Data/Configurations/MediaItem/JellyfinSeasonConfiguration.cs
  77. 11
      ErsatzTV.Infrastructure/Data/Configurations/MediaItem/JellyfinShowConfiguration.cs
  78. 12
      ErsatzTV.Infrastructure/Data/Configurations/MediaSource/JellyfinConnectionConfiguration.cs
  79. 24
      ErsatzTV.Infrastructure/Data/Configurations/MediaSource/JellyfinMediaSourceConfiguration.cs
  80. 12
      ErsatzTV.Infrastructure/Data/Configurations/MediaSource/JellyfinPathReplacementConfiguration.cs
  81. 2
      ErsatzTV.Infrastructure/Data/Repositories/FFmpegProfileRepository.cs
  82. 454
      ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs
  83. 282
      ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs
  84. 2
      ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs
  85. 203
      ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs
  86. 7
      ErsatzTV.Infrastructure/Data/TvContext.cs
  87. 78
      ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs
  88. 569
      ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs
  89. 26
      ErsatzTV.Infrastructure/Jellyfin/JellyfinSecretStore.cs
  90. 7
      ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinImageTagsResponse.cs
  91. 28
      ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinLibraryItemResponse.cs
  92. 9
      ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinLibraryItemsResponse.cs
  93. 9
      ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinLibraryResponse.cs
  94. 18
      ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinMediaStreamResponse.cs
  95. 11
      ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinPersonResponse.cs
  96. 8
      ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinStudioResponse.cs
  97. 8
      ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinSystemInformationResponse.cs
  98. 7
      ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinUserPolicyResponse.cs
  99. 9
      ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinUserResponse.cs
  100. 27
      ErsatzTV.Infrastructure/Locking/EntityLocker.cs
  101. Some files were not shown because too many files have changed in this diff Show More

6
ErsatzTV.Application/IJellyfinBackgroundServiceRequest.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
namespace ErsatzTV.Application
{
public interface IJellyfinBackgroundServiceRequest
{
}
}

7
ErsatzTV.Application/Jellyfin/Commands/DisconnectJellyfin.cs

@ -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>>;
}

44
ErsatzTV.Application/Jellyfin/Commands/DisconnectJellyfinHandler.cs

@ -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;
}
}
}

8
ErsatzTV.Application/Jellyfin/Commands/SaveJellyfinSecrets.cs

@ -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>>;
}

60
ErsatzTV.Application/Jellyfin/Commands/SaveJellyfinSecretsHandler.cs

@ -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);
}
}

8
ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinAdminUserId.cs

@ -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;
}

102
ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinAdminUserIdHandler.cs

@ -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; }
}
}
}

8
ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinLibraries.cs

@ -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;
}

111
ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinLibrariesHandler.cs

@ -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; }
}
}
}

23
ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinLibraryById.cs

@ -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;
}
}

172
ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs

@ -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; }
}
}
}

11
ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinMediaSources.cs

@ -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;
}

41
ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinMediaSourcesHandler.cs

@ -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;
}
}
}

11
ErsatzTV.Application/Jellyfin/Commands/UpdateJellyfinLibraryPreferences.cs

@ -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);
}

41
ErsatzTV.Application/Jellyfin/Commands/UpdateJellyfinLibraryPreferencesHandler.cs

@ -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;
}
}
}

12
ErsatzTV.Application/Jellyfin/Commands/UpdateJellyfinPathReplacements.cs

@ -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);
}

55
ErsatzTV.Application/Jellyfin/Commands/UpdateJellyfinPathReplacementsHandler.cs

@ -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."));
}
}

8
ErsatzTV.Application/Jellyfin/JellyfinLibraryViewModel.cs

@ -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);
}

6
ErsatzTV.Application/Jellyfin/JellyfinMediaSourceViewModel.cs

@ -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);
}

4
ErsatzTV.Application/Jellyfin/JellyfinPathReplacementViewModel.cs

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
namespace ErsatzTV.Application.Jellyfin
{
public record JellyfinPathReplacementViewModel(int Id, string JellyfinPath, string LocalPath);
}

19
ErsatzTV.Application/Jellyfin/Mapper.cs

@ -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);
}
}

7
ErsatzTV.Application/Jellyfin/Queries/GetAllJellyfinMediaSources.cs

@ -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>>;
}

26
ErsatzTV.Application/Jellyfin/Queries/GetAllJellyfinMediaSourcesHandler.cs

@ -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());
}
}

7
ErsatzTV.Application/Jellyfin/Queries/GetJellyfinLibrariesBySourceId.cs

@ -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>>;
}

27
ErsatzTV.Application/Jellyfin/Queries/GetJellyfinLibrariesBySourceIdHandler.cs

@ -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());
}
}

8
ErsatzTV.Application/Jellyfin/Queries/GetJellyfinMediaSourceById.cs

@ -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>>;
}

24
ErsatzTV.Application/Jellyfin/Queries/GetJellyfinMediaSourceByIdHandler.cs

@ -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);
}
}

8
ErsatzTV.Application/Jellyfin/Queries/GetJellyfinPathReplacementsBySourceId.cs

@ -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>>;
}

26
ErsatzTV.Application/Jellyfin/Queries/GetJellyfinPathReplacementsBySourceIdHandler.cs

@ -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());
}
}

7
ErsatzTV.Application/Jellyfin/Queries/GetJellyfinSecrets.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
using ErsatzTV.Core.Jellyfin;
using MediatR;
namespace ErsatzTV.Application.Jellyfin.Queries
{
public record GetJellyfinSecrets : IRequest<JellyfinSecrets>;
}

19
ErsatzTV.Application/Jellyfin/Queries/GetJellyfinSecretsHandler.cs

@ -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();
}
}

2
ErsatzTV.Application/Libraries/Mapper.cs

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using System;
using ErsatzTV.Application.Jellyfin;
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Application.Libraries
@ -10,6 +11,7 @@ namespace ErsatzTV.Application.Libraries @@ -10,6 +11,7 @@ namespace ErsatzTV.Application.Libraries
{
LocalLibrary l => ProjectToViewModel(l),
PlexLibrary p => new PlexLibraryViewModel(p.Id, p.Name, p.MediaKind),
JellyfinLibrary j => new JellyfinLibraryViewModel(j.Id, j.Name, j.MediaKind, j.ShouldSyncItems),
_ => throw new ArgumentOutOfRangeException(nameof(library))
};

2
ErsatzTV.Application/Libraries/Queries/GetAllLibrariesHandler.cs

@ -21,6 +21,7 @@ namespace ErsatzTV.Application.Libraries.Queries @@ -21,6 +21,7 @@ namespace ErsatzTV.Application.Libraries.Queries
.Map(
list => list.Filter(ShouldIncludeLibrary)
.OrderBy(l => l.MediaSource is LocalMediaSource ? 0 : 1)
.ThenBy(l => l.GetType().Name)
.ThenBy(l => l.MediaKind)
.Map(ProjectToViewModel).ToList());
@ -29,6 +30,7 @@ namespace ErsatzTV.Application.Libraries.Queries @@ -29,6 +30,7 @@ namespace ErsatzTV.Application.Libraries.Queries
{
LocalLibrary => true,
PlexLibrary plex => plex.ShouldSyncItems,
JellyfinLibrary jellyfin => jellyfin.ShouldSyncItems,
_ => false
};
}

89
ErsatzTV.Application/MediaCards/Mapper.cs

@ -1,21 +1,26 @@ @@ -1,21 +1,26 @@
using System;
using System.Linq;
using ErsatzTV.Core.Domain;
using LanguageExt;
using static LanguageExt.Prelude;
namespace ErsatzTV.Application.MediaCards
{
internal static class Mapper
{
internal static TelevisionShowCardViewModel ProjectToViewModel(ShowMetadata showMetadata) =>
internal static TelevisionShowCardViewModel ProjectToViewModel(
ShowMetadata showMetadata,
Option<JellyfinMediaSource> maybeJellyfin) =>
new(
showMetadata.ShowId,
showMetadata.Title,
showMetadata.Year?.ToString(),
showMetadata.SortTitle,
GetPoster(showMetadata));
GetPoster(showMetadata, maybeJellyfin));
internal static TelevisionSeasonCardViewModel ProjectToViewModel(Season season) =>
internal static TelevisionSeasonCardViewModel ProjectToViewModel(
Season season,
Option<JellyfinMediaSource> maybeJellyfin) =>
new(
season.Show.ShowMetadata.HeadOrNone().Match(m => m.Title ?? string.Empty, () => string.Empty),
season.Id,
@ -23,11 +28,12 @@ namespace ErsatzTV.Application.MediaCards @@ -23,11 +28,12 @@ namespace ErsatzTV.Application.MediaCards
GetSeasonName(season.SeasonNumber),
string.Empty,
GetSeasonName(season.SeasonNumber),
season.SeasonMetadata.HeadOrNone().Map(GetPoster).IfNone(string.Empty),
season.SeasonMetadata.HeadOrNone().Map(sm => GetPoster(sm, maybeJellyfin)).IfNone(string.Empty),
season.SeasonNumber == 0 ? "S" : season.SeasonNumber.ToString());
internal static TelevisionEpisodeCardViewModel ProjectToViewModel(
EpisodeMetadata episodeMetadata) =>
EpisodeMetadata episodeMetadata,
Option<JellyfinMediaSource> maybeJellyfin) =>
new(
episodeMetadata.EpisodeId,
episodeMetadata.ReleaseDate ?? DateTime.MinValue,
@ -41,15 +47,17 @@ namespace ErsatzTV.Application.MediaCards @@ -41,15 +47,17 @@ namespace ErsatzTV.Application.MediaCards
episodeMetadata.Episode.EpisodeMetadata.HeadOrNone().Match(
em => em.Plot ?? string.Empty,
() => string.Empty),
GetThumbnail(episodeMetadata));
GetThumbnail(episodeMetadata, maybeJellyfin));
internal static MovieCardViewModel ProjectToViewModel(MovieMetadata movieMetadata) =>
internal static MovieCardViewModel ProjectToViewModel(
MovieMetadata movieMetadata,
Option<JellyfinMediaSource> maybeJellyfin) =>
new(
movieMetadata.MovieId,
movieMetadata.Title,
movieMetadata.Year?.ToString(),
movieMetadata.SortTitle,
GetPoster(movieMetadata));
GetPoster(movieMetadata, maybeJellyfin));
internal static MusicVideoCardViewModel ProjectToViewModel(MusicVideoMetadata musicVideoMetadata) =>
new(
@ -58,7 +66,7 @@ namespace ErsatzTV.Application.MediaCards @@ -58,7 +66,7 @@ namespace ErsatzTV.Application.MediaCards
musicVideoMetadata.MusicVideo.Artist.ArtistMetadata.Head().Title,
musicVideoMetadata.SortTitle,
musicVideoMetadata.Plot,
GetThumbnail(musicVideoMetadata));
GetThumbnail(musicVideoMetadata, None));
internal static ArtistCardViewModel ProjectToViewModel(ArtistMetadata artistMetadata) =>
new(
@ -66,27 +74,41 @@ namespace ErsatzTV.Application.MediaCards @@ -66,27 +74,41 @@ namespace ErsatzTV.Application.MediaCards
artistMetadata.Title,
artistMetadata.Disambiguation,
artistMetadata.SortTitle,
GetThumbnail(artistMetadata));
GetThumbnail(artistMetadata, None));
internal static CollectionCardResultsViewModel
ProjectToViewModel(Collection collection) =>
ProjectToViewModel(Collection collection, Option<JellyfinMediaSource> maybeJellyfin) =>
new(
collection.Name,
collection.MediaItems.OfType<Movie>().Map(
m => ProjectToViewModel(m.MovieMetadata.Head()) with
m => ProjectToViewModel(m.MovieMetadata.Head(), maybeJellyfin) with
{
CustomIndex = GetCustomIndex(collection, m.Id)
}).ToList(),
collection.MediaItems.OfType<Show>().Map(s => ProjectToViewModel(s.ShowMetadata.Head())).ToList(),
collection.MediaItems.OfType<Season>().Map(ProjectToViewModel).ToList(),
collection.MediaItems.OfType<Episode>().Map(e => ProjectToViewModel(e.EpisodeMetadata.Head()))
collection.MediaItems.OfType<Show>().Map(s => ProjectToViewModel(s.ShowMetadata.Head(), maybeJellyfin))
.ToList(),
collection.MediaItems.OfType<Season>().Map(s => ProjectToViewModel(s, maybeJellyfin)).ToList(),
collection.MediaItems.OfType<Episode>()
.Map(e => ProjectToViewModel(e.EpisodeMetadata.Head(), maybeJellyfin))
.ToList(),
collection.MediaItems.OfType<Artist>().Map(a => ProjectToViewModel(a.ArtistMetadata.Head())).ToList(),
collection.MediaItems.OfType<MusicVideo>().Map(mv => ProjectToViewModel(mv.MusicVideoMetadata.Head()))
.ToList()) { UseCustomPlaybackOrder = collection.UseCustomPlaybackOrder };
internal static ActorCardViewModel ProjectToViewModel(Actor actor) =>
new(actor.Id, actor.Name, actor.Role, actor.Artwork?.Path);
internal static ActorCardViewModel ProjectToViewModel(Actor actor, Option<JellyfinMediaSource> maybeJellyfin)
{
string artwork = actor.Artwork?.Path ?? string.Empty;
if (maybeJellyfin.IsSome && artwork.StartsWith("jellyfin://"))
{
string address = maybeJellyfin.Map(ms => ms.Connections.HeadOrNone().Map(c => c.Address))
.Flatten()
.IfNone("jellyfin://");
artwork = artwork.Replace("jellyfin://", address) + "&fillheight=440";
}
return new ActorCardViewModel(actor.Id, actor.Name, actor.Role, artwork);
}
private static int GetCustomIndex(Collection collection, int mediaItemId) =>
Optional(collection.CollectionItems.Find(ci => ci.MediaItemId == mediaItemId))
@ -96,12 +118,37 @@ namespace ErsatzTV.Application.MediaCards @@ -96,12 +118,37 @@ namespace ErsatzTV.Application.MediaCards
private static string GetSeasonName(int number) =>
number == 0 ? "Specials" : $"Season {number}";
private static string GetPoster(Metadata metadata) =>
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster))
private static string GetPoster(Metadata metadata, Option<JellyfinMediaSource> maybeJellyfin)
{
string poster = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster))
.Match(a => a.Path, string.Empty);
private static string GetThumbnail(Metadata metadata) =>
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail))
if (maybeJellyfin.IsSome && poster.StartsWith("jellyfin://"))
{
string address = maybeJellyfin.Map(ms => ms.Connections.HeadOrNone().Map(c => c.Address))
.Flatten()
.IfNone("jellyfin://");
poster = poster.Replace("jellyfin://", address) + "&fillHeight=440";
}
return poster;
}
private static string GetThumbnail(Metadata metadata, Option<JellyfinMediaSource> maybeJellyfin)
{
string thumb = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Thumbnail))
.Match(a => a.Path, string.Empty);
if (maybeJellyfin.IsSome && thumb.StartsWith("jellyfin://"))
{
string address = maybeJellyfin.Map(ms => ms.Connections.HeadOrNone().Map(c => c.Address))
.Flatten()
.IfNone("jellyfin://");
thumb = thumb.Replace("jellyfin://", address) +
"&fillHeight=220"; // TODO: this height is optimized for episode
}
return thumb;
}
}
}

23
ErsatzTV.Application/MediaCards/Queries/GetCollectionCardsHandler.cs

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
@ -12,15 +13,27 @@ namespace ErsatzTV.Application.MediaCards.Queries @@ -12,15 +13,27 @@ namespace ErsatzTV.Application.MediaCards.Queries
Either<BaseError, CollectionCardResultsViewModel>>
{
private readonly IMediaCollectionRepository _collectionRepository;
private readonly IMediaSourceRepository _mediaSourceRepository;
public GetCollectionCardsHandler(IMediaCollectionRepository collectionRepository) =>
public GetCollectionCardsHandler(
IMediaCollectionRepository collectionRepository,
IMediaSourceRepository mediaSourceRepository)
{
_collectionRepository = collectionRepository;
_mediaSourceRepository = mediaSourceRepository;
}
public Task<Either<BaseError, CollectionCardResultsViewModel>> Handle(
public async Task<Either<BaseError, CollectionCardResultsViewModel>> Handle(
GetCollectionCards request,
CancellationToken cancellationToken) =>
_collectionRepository.GetCollectionWithItemsUntracked(request.Id)
CancellationToken cancellationToken)
{
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone());
return await _collectionRepository
.GetCollectionWithItemsUntracked(request.Id)
.Map(c => c.ToEither(BaseError.New("Unable to load collection")))
.MapT(ProjectToViewModel);
.MapT(c => ProjectToViewModel(c, maybeJellyfin));
}
}
}

14
ErsatzTV.Application/MediaCards/Queries/GetTelevisionEpisodeCardsHandler.cs

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
@ -13,10 +14,16 @@ namespace ErsatzTV.Application.MediaCards.Queries @@ -13,10 +14,16 @@ namespace ErsatzTV.Application.MediaCards.Queries
GetTelevisionEpisodeCardsHandler : IRequestHandler<GetTelevisionEpisodeCards,
TelevisionEpisodeCardResultsViewModel>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly ITelevisionRepository _televisionRepository;
public GetTelevisionEpisodeCardsHandler(ITelevisionRepository televisionRepository) =>
public GetTelevisionEpisodeCardsHandler(
ITelevisionRepository televisionRepository,
IMediaSourceRepository mediaSourceRepository)
{
_televisionRepository = televisionRepository;
_mediaSourceRepository = mediaSourceRepository;
}
public async Task<TelevisionEpisodeCardResultsViewModel> Handle(
GetTelevisionEpisodeCards request,
@ -24,9 +31,12 @@ namespace ErsatzTV.Application.MediaCards.Queries @@ -24,9 +31,12 @@ namespace ErsatzTV.Application.MediaCards.Queries
{
int count = await _televisionRepository.GetEpisodeCount(request.TelevisionSeasonId);
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone());
List<TelevisionEpisodeCardViewModel> results = await _televisionRepository
.GetPagedEpisodes(request.TelevisionSeasonId, request.PageNumber, request.PageSize)
.Map(list => list.Map(ProjectToViewModel).ToList());
.Map(list => list.Map(e => ProjectToViewModel(e, maybeJellyfin)).ToList());
return new TelevisionEpisodeCardResultsViewModel(count, results);
}

18
ErsatzTV.Application/MediaCards/Queries/GetTelevisionSeasonCardsHandler.cs

@ -2,6 +2,7 @@ @@ -2,6 +2,7 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
@ -10,13 +11,19 @@ using static ErsatzTV.Application.MediaCards.Mapper; @@ -10,13 +11,19 @@ using static ErsatzTV.Application.MediaCards.Mapper;
namespace ErsatzTV.Application.MediaCards.Queries
{
public class
GetTelevisionSeasonCardsHandler : IRequestHandler<GetTelevisionSeasonCards, TelevisionSeasonCardResultsViewModel
>
GetTelevisionSeasonCardsHandler : IRequestHandler<GetTelevisionSeasonCards,
TelevisionSeasonCardResultsViewModel>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly ITelevisionRepository _televisionRepository;
public GetTelevisionSeasonCardsHandler(ITelevisionRepository televisionRepository) =>
public GetTelevisionSeasonCardsHandler(
ITelevisionRepository televisionRepository,
IMediaSourceRepository mediaSourceRepository)
{
_televisionRepository = televisionRepository;
_mediaSourceRepository = mediaSourceRepository;
}
public async Task<TelevisionSeasonCardResultsViewModel> Handle(
GetTelevisionSeasonCards request,
@ -24,9 +31,12 @@ namespace ErsatzTV.Application.MediaCards.Queries @@ -24,9 +31,12 @@ namespace ErsatzTV.Application.MediaCards.Queries
{
int count = await _televisionRepository.GetSeasonCount(request.TelevisionShowId);
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone());
List<TelevisionSeasonCardViewModel> results = await _televisionRepository
.GetPagedSeasons(request.TelevisionShowId, request.PageNumber, request.PageSize)
.Map(list => list.Map(ProjectToViewModel).ToList());
.Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin)).ToList());
return new TelevisionSeasonCardResultsViewModel(count, results);
}

37
ErsatzTV.Application/Movies/Mapper.cs

@ -10,21 +10,24 @@ namespace ErsatzTV.Application.Movies @@ -10,21 +10,24 @@ namespace ErsatzTV.Application.Movies
{
internal static class Mapper
{
internal static MovieViewModel ProjectToViewModel(Movie movie)
internal static MovieViewModel ProjectToViewModel(Movie movie, Option<JellyfinMediaSource> maybeJellyfin)
{
MovieMetadata metadata = Optional(movie.MovieMetadata).Flatten().Head();
return new MovieViewModel(
metadata.Title,
metadata.Year?.ToString(),
metadata.Plot,
Artwork(metadata, ArtworkKind.Poster),
Artwork(metadata, ArtworkKind.FanArt),
metadata.Genres.Map(g => g.Name).ToList(),
metadata.Tags.Map(t => t.Name).ToList(),
metadata.Studios.Map(s => s.Name).ToList(),
LanguagesForMovie(movie),
metadata.Actors.OrderBy(a => a.Order).ThenBy(a => a.Id).Map(MediaCards.Mapper.ProjectToViewModel)
.ToList());
metadata.Actors.OrderBy(a => a.Order).ThenBy(a => a.Id)
.Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin))
.ToList())
{
Poster = Artwork(metadata, ArtworkKind.Poster, maybeJellyfin),
FanArt = Artwork(metadata, ArtworkKind.FanArt, maybeJellyfin)
};
}
private static List<CultureInfo> LanguagesForMovie(Movie movie)
@ -43,8 +46,28 @@ namespace ErsatzTV.Application.Movies @@ -43,8 +46,28 @@ namespace ErsatzTV.Application.Movies
.ToList();
}
private static string Artwork(Metadata metadata, ArtworkKind artworkKind) =>
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
private static string Artwork(
Metadata metadata,
ArtworkKind artworkKind,
Option<JellyfinMediaSource> maybeJellyfin)
{
string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
.Match(a => a.Path, string.Empty);
if (maybeJellyfin.IsSome && artwork.StartsWith("jellyfin://"))
{
string address = maybeJellyfin.Map(ms => ms.Connections.HeadOrNone().Map(c => c.Address))
.Flatten()
.IfNone("jellyfin://");
artwork = artwork.Replace("jellyfin://", address);
if (artworkKind is ArtworkKind.Poster or ArtworkKind.Thumbnail)
{
artwork += "&fillHeight=440";
}
}
return artwork;
}
}
}

8
ErsatzTV.Application/Movies/MovieViewModel.cs

@ -8,11 +8,13 @@ namespace ErsatzTV.Application.Movies @@ -8,11 +8,13 @@ namespace ErsatzTV.Application.Movies
string Title,
string Year,
string Plot,
string Poster,
string FanArt,
List<string> Genres,
List<string> Tags,
List<string> Studios,
List<CultureInfo> Languages,
List<ActorCardViewModel> Actors);
List<ActorCardViewModel> Actors)
{
public string Poster { get; set; }
public string FanArt { get; set; }
}
}

19
ErsatzTV.Application/Movies/Queries/GetMovieByIdHandler.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
@ -9,14 +10,24 @@ namespace ErsatzTV.Application.Movies.Queries @@ -9,14 +10,24 @@ namespace ErsatzTV.Application.Movies.Queries
{
public class GetMovieByIdHandler : IRequestHandler<GetMovieById, Option<MovieViewModel>>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMovieRepository _movieRepository;
public GetMovieByIdHandler(IMovieRepository movieRepository) =>
public GetMovieByIdHandler(IMovieRepository movieRepository, IMediaSourceRepository mediaSourceRepository)
{
_movieRepository = movieRepository;
_mediaSourceRepository = mediaSourceRepository;
}
public Task<Option<MovieViewModel>> Handle(
public async Task<Option<MovieViewModel>> Handle(
GetMovieById request,
CancellationToken cancellationToken) =>
_movieRepository.GetMovie(request.Id).MapT(ProjectToViewModel);
CancellationToken cancellationToken)
{
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone());
Option<Movie> movie = await _movieRepository.GetMovie(request.Id);
return movie.Map(m => ProjectToViewModel(m, maybeJellyfin));
}
}
}

13
ErsatzTV.Application/Search/Queries/QuerySearchIndexMoviesHandler.cs

@ -3,6 +3,7 @@ using System.Linq; @@ -3,6 +3,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
@ -14,13 +15,18 @@ namespace ErsatzTV.Application.Search.Queries @@ -14,13 +15,18 @@ namespace ErsatzTV.Application.Search.Queries
{
public class QuerySearchIndexMoviesHandler : IRequestHandler<QuerySearchIndexMovies, MovieCardResultsViewModel>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IMovieRepository _movieRepository;
private readonly ISearchIndex _searchIndex;
public QuerySearchIndexMoviesHandler(ISearchIndex searchIndex, IMovieRepository movieRepository)
public QuerySearchIndexMoviesHandler(
ISearchIndex searchIndex,
IMovieRepository movieRepository,
IMediaSourceRepository mediaSourceRepository)
{
_searchIndex = searchIndex;
_movieRepository = movieRepository;
_mediaSourceRepository = mediaSourceRepository;
}
public async Task<MovieCardResultsViewModel> Handle(
@ -32,9 +38,12 @@ namespace ErsatzTV.Application.Search.Queries @@ -32,9 +38,12 @@ namespace ErsatzTV.Application.Search.Queries
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone());
List<MovieCardViewModel> items = await _movieRepository
.GetMoviesForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(ProjectToViewModel).ToList());
.Map(list => list.Map(m => ProjectToViewModel(m, maybeJellyfin)).ToList());
return new MovieCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}

13
ErsatzTV.Application/Search/Queries/QuerySearchIndexShowsHandler.cs

@ -3,6 +3,7 @@ using System.Linq; @@ -3,6 +3,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Application.MediaCards;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using ErsatzTV.Core.Search;
@ -15,13 +16,18 @@ namespace ErsatzTV.Application.Search.Queries @@ -15,13 +16,18 @@ namespace ErsatzTV.Application.Search.Queries
public class
QuerySearchIndexShowsHandler : IRequestHandler<QuerySearchIndexShows, TelevisionShowCardResultsViewModel>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly ISearchIndex _searchIndex;
private readonly ITelevisionRepository _televisionRepository;
public QuerySearchIndexShowsHandler(ISearchIndex searchIndex, ITelevisionRepository televisionRepository)
public QuerySearchIndexShowsHandler(
ISearchIndex searchIndex,
ITelevisionRepository televisionRepository,
IMediaSourceRepository mediaSourceRepository)
{
_searchIndex = searchIndex;
_televisionRepository = televisionRepository;
_mediaSourceRepository = mediaSourceRepository;
}
public async Task<TelevisionShowCardResultsViewModel> Handle(
@ -33,9 +39,12 @@ namespace ErsatzTV.Application.Search.Queries @@ -33,9 +39,12 @@ namespace ErsatzTV.Application.Search.Queries
(request.PageNumber - 1) * request.PageSize,
request.PageSize);
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone());
List<TelevisionShowCardViewModel> items = await _televisionRepository
.GetShowsForCards(searchResult.Items.Map(i => i.Id).ToList())
.Map(list => list.Map(ProjectToViewModel).ToList());
.Map(list => list.Map(s => ProjectToViewModel(s, maybeJellyfin)).ToList());
return new TelevisionShowCardResultsViewModel(searchResult.TotalCount, items, searchResult.PageMap);
}

12
ErsatzTV.Application/Streaming/Queries/GetPlayoutItemProcessByChannelNumberHandler.cs

@ -5,6 +5,7 @@ using ErsatzTV.Core; @@ -5,6 +5,7 @@ using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Errors;
using ErsatzTV.Core.FFmpeg;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Core.Interfaces.Plex;
using ErsatzTV.Core.Interfaces.Repositories;
@ -18,6 +19,7 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -18,6 +19,7 @@ namespace ErsatzTV.Application.Streaming.Queries
{
private readonly IConfigElementRepository _configElementRepository;
private readonly FFmpegProcessService _ffmpegProcessService;
private readonly IJellyfinPathReplacementService _jellyfinPathReplacementService;
private readonly ILocalFileSystem _localFileSystem;
private readonly IPlayoutRepository _playoutRepository;
private readonly IPlexPathReplacementService _plexPathReplacementService;
@ -28,7 +30,8 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -28,7 +30,8 @@ namespace ErsatzTV.Application.Streaming.Queries
IPlayoutRepository playoutRepository,
FFmpegProcessService ffmpegProcessService,
ILocalFileSystem localFileSystem,
IPlexPathReplacementService plexPathReplacementService)
IPlexPathReplacementService plexPathReplacementService,
IJellyfinPathReplacementService jellyfinPathReplacementService)
: base(channelRepository, configElementRepository)
{
_configElementRepository = configElementRepository;
@ -36,6 +39,7 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -36,6 +39,7 @@ namespace ErsatzTV.Application.Streaming.Queries
_ffmpegProcessService = ffmpegProcessService;
_localFileSystem = localFileSystem;
_plexPathReplacementService = plexPathReplacementService;
_jellyfinPathReplacementService = jellyfinPathReplacementService;
}
protected override async Task<Either<BaseError, Process>> GetProcess(
@ -168,6 +172,12 @@ namespace ErsatzTV.Application.Streaming.Queries @@ -168,6 +172,12 @@ namespace ErsatzTV.Application.Streaming.Queries
PlexEpisode plexEpisode => await _plexPathReplacementService.GetReplacementPlexPath(
plexEpisode.LibraryPathId,
path),
JellyfinMovie jellyfinMovie => await _jellyfinPathReplacementService.GetReplacementJellyfinPath(
jellyfinMovie.LibraryPathId,
path),
JellyfinEpisode jellyfinEpisode => await _jellyfinPathReplacementService.GetReplacementJellyfinPath(
jellyfinEpisode.LibraryPathId,
path),
_ => path
};
}

55
ErsatzTV.Application/Television/Mapper.cs

@ -11,14 +11,17 @@ namespace ErsatzTV.Application.Television @@ -11,14 +11,17 @@ namespace ErsatzTV.Application.Television
{
internal static class Mapper
{
internal static TelevisionShowViewModel ProjectToViewModel(Show show, List<string> languages) =>
internal static TelevisionShowViewModel ProjectToViewModel(
Show show,
List<string> languages,
Option<JellyfinMediaSource> maybeJellyfin) =>
new(
show.Id,
show.ShowMetadata.HeadOrNone().Map(m => m.Title ?? string.Empty).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(m => m.Year?.ToString() ?? string.Empty).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(m => m.Plot ?? string.Empty).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(GetPoster).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(GetFanArt).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(m => GetPoster(m, maybeJellyfin)).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(m => GetFanArt(m, maybeJellyfin)).IfNone(string.Empty),
show.ShowMetadata.HeadOrNone().Map(m => m.Genres.Map(g => g.Name).ToList()).IfNone(new List<string>()),
show.ShowMetadata.HeadOrNone().Map(m => m.Tags.Map(g => g.Name).ToList()).IfNone(new List<string>()),
show.ShowMetadata.HeadOrNone().Map(m => m.Studios.Map(s => s.Name).ToList())
@ -26,19 +29,22 @@ namespace ErsatzTV.Application.Television @@ -26,19 +29,22 @@ namespace ErsatzTV.Application.Television
LanguagesForShow(languages),
show.ShowMetadata.HeadOrNone()
.Map(
m => m.Actors.OrderBy(a => a.Order).ThenBy(a => a.Id).Map(MediaCards.Mapper.ProjectToViewModel)
m => m.Actors.OrderBy(a => a.Order).ThenBy(a => a.Id)
.Map(a => MediaCards.Mapper.ProjectToViewModel(a, maybeJellyfin))
.ToList())
.IfNone(new List<ActorCardViewModel>()));
internal static TelevisionSeasonViewModel ProjectToViewModel(Season season) =>
internal static TelevisionSeasonViewModel ProjectToViewModel(
Season season,
Option<JellyfinMediaSource> maybeJellyfin) =>
new(
season.Id,
season.ShowId,
season.Show.ShowMetadata.HeadOrNone().Map(m => m.Title ?? string.Empty).IfNone(string.Empty),
season.Show.ShowMetadata.HeadOrNone().Map(m => m.Year?.ToString() ?? string.Empty).IfNone(string.Empty),
season.SeasonNumber == 0 ? "Specials" : $"Season {season.SeasonNumber}",
season.SeasonMetadata.HeadOrNone().Map(GetPoster).IfNone(string.Empty),
season.Show.ShowMetadata.HeadOrNone().Map(GetFanArt).IfNone(string.Empty));
season.SeasonMetadata.HeadOrNone().Map(m => GetPoster(m, maybeJellyfin)).IfNone(string.Empty),
season.Show.ShowMetadata.HeadOrNone().Map(m => GetFanArt(m, maybeJellyfin)).IfNone(string.Empty));
internal static TelevisionEpisodeViewModel ProjectToViewModel(Episode episode) =>
new(
@ -47,18 +53,41 @@ namespace ErsatzTV.Application.Television @@ -47,18 +53,41 @@ namespace ErsatzTV.Application.Television
episode.EpisodeNumber,
episode.EpisodeMetadata.HeadOrNone().Map(m => m.Title ?? string.Empty).IfNone(string.Empty),
episode.EpisodeMetadata.HeadOrNone().Map(m => m.Plot ?? string.Empty).IfNone(string.Empty),
episode.EpisodeMetadata.HeadOrNone().Map(GetThumbnail).IfNone(string.Empty));
episode.EpisodeMetadata.HeadOrNone().Map(m => GetThumbnail(m, None)).IfNone(string.Empty));
private static string GetPoster(Metadata metadata) => GetArtwork(metadata, ArtworkKind.Poster);
private static string GetPoster(Metadata metadata, Option<JellyfinMediaSource> maybeJellyfin) =>
GetArtwork(metadata, ArtworkKind.Poster, maybeJellyfin);
private static string GetFanArt(Metadata metadata) => GetArtwork(metadata, ArtworkKind.FanArt);
private static string GetFanArt(Metadata metadata, Option<JellyfinMediaSource> maybeJellyfin) =>
GetArtwork(metadata, ArtworkKind.FanArt, maybeJellyfin);
private static string GetThumbnail(Metadata metadata) => GetArtwork(metadata, ArtworkKind.Thumbnail);
private static string GetThumbnail(Metadata metadata, Option<JellyfinMediaSource> maybeJellyfin) =>
GetArtwork(metadata, ArtworkKind.Thumbnail, maybeJellyfin);
private static string GetArtwork(Metadata metadata, ArtworkKind artworkKind) =>
Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
private static string GetArtwork(
Metadata metadata,
ArtworkKind artworkKind,
Option<JellyfinMediaSource> maybeJellyfin)
{
string artwork = Optional(metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == artworkKind))
.Match(a => a.Path, string.Empty);
if (maybeJellyfin.IsSome && artwork.StartsWith("jellyfin://"))
{
string address = maybeJellyfin.Map(ms => ms.Connections.HeadOrNone().Map(c => c.Address))
.Flatten()
.IfNone("jellyfin://");
artwork = artwork.Replace("jellyfin://", address);
if (artworkKind == ArtworkKind.Poster)
{
artwork += "&fillHeight=440";
}
}
return artwork;
}
private static List<CultureInfo> LanguagesForShow(List<string> languages)
{
CultureInfo[] allCultures = CultureInfo.GetCultures(CultureTypes.NeutralCultures);

22
ErsatzTV.Application/Television/Queries/GetTelevisionSeasonByIdHandler.cs

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
using System.Threading;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using LanguageExt;
using MediatR;
@ -10,15 +11,26 @@ namespace ErsatzTV.Application.Television.Queries @@ -10,15 +11,26 @@ namespace ErsatzTV.Application.Television.Queries
public class
GetTelevisionSeasonByIdHandler : IRequestHandler<GetTelevisionSeasonById, Option<TelevisionSeasonViewModel>>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly ITelevisionRepository _televisionRepository;
public GetTelevisionSeasonByIdHandler(ITelevisionRepository televisionRepository) =>
public GetTelevisionSeasonByIdHandler(
ITelevisionRepository televisionRepository,
IMediaSourceRepository mediaSourceRepository)
{
_televisionRepository = televisionRepository;
_mediaSourceRepository = mediaSourceRepository;
}
public Task<Option<TelevisionSeasonViewModel>> Handle(
public async Task<Option<TelevisionSeasonViewModel>> Handle(
GetTelevisionSeasonById request,
CancellationToken cancellationToken) =>
_televisionRepository.GetSeason(request.SeasonId)
.MapT(ProjectToViewModel);
CancellationToken cancellationToken)
{
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone());
return await _televisionRepository.GetSeason(request.SeasonId)
.MapT(s => ProjectToViewModel(s, maybeJellyfin));
}
}
}

10
ErsatzTV.Application/Television/Queries/GetTelevisionShowByIdHandler.cs

@ -11,15 +11,18 @@ namespace ErsatzTV.Application.Television.Queries @@ -11,15 +11,18 @@ namespace ErsatzTV.Application.Television.Queries
{
public class GetTelevisionShowByIdHandler : IRequestHandler<GetTelevisionShowById, Option<TelevisionShowViewModel>>
{
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly ISearchRepository _searchRepository;
private readonly ITelevisionRepository _televisionRepository;
public GetTelevisionShowByIdHandler(
ITelevisionRepository televisionRepository,
ISearchRepository searchRepository)
ISearchRepository searchRepository,
IMediaSourceRepository mediaSourceRepository)
{
_televisionRepository = televisionRepository;
_searchRepository = searchRepository;
_mediaSourceRepository = mediaSourceRepository;
}
public async Task<Option<TelevisionShowViewModel>> Handle(
@ -30,8 +33,11 @@ namespace ErsatzTV.Application.Television.Queries @@ -30,8 +33,11 @@ namespace ErsatzTV.Application.Television.Queries
return await maybeShow.Match<Task<Option<TelevisionShowViewModel>>>(
async show =>
{
Option<JellyfinMediaSource> maybeJellyfin = await _mediaSourceRepository.GetAllJellyfin()
.Map(list => list.HeadOrNone());
List<string> languages = await _searchRepository.GetLanguagesForShow(show);
return ProjectToViewModel(show, languages);
return ProjectToViewModel(show, languages, maybeJellyfin);
},
() => Task.FromResult(Option<TelevisionShowViewModel>.None));
}

8
ErsatzTV.Core/Domain/Library/JellyfinLibrary.cs

@ -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; }
}
}

8
ErsatzTV.Core/Domain/MediaItem/JellyfinEpisode.cs

@ -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; }
}
}

8
ErsatzTV.Core/Domain/MediaItem/JellyfinMovie.cs

@ -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; }
}
}

8
ErsatzTV.Core/Domain/MediaItem/JellyfinSeason.cs

@ -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; }
}
}

8
ErsatzTV.Core/Domain/MediaItem/JellyfinShow.cs

@ -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; }
}
}

10
ErsatzTV.Core/Domain/MediaSource/JellyfinConnection.cs

@ -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; }
}
}

12
ErsatzTV.Core/Domain/MediaSource/JellyfinMediaSource.cs

@ -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; }
}
}

11
ErsatzTV.Core/Domain/MediaSource/JellyfinPathReplacement.cs

@ -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; }
}
}

1
ErsatzTV.Core/FileSystemLayout.cs

@ -18,6 +18,7 @@ namespace ErsatzTV.Core @@ -18,6 +18,7 @@ namespace ErsatzTV.Core
public static readonly string LegacyImageCacheFolder = Path.Combine(AppDataFolder, "cache", "images");
public static readonly string PlexSecretsPath = Path.Combine(AppDataFolder, "plex-secrets.json");
public static readonly string JellyfinSecretsPath = Path.Combine(AppDataFolder, "jellyfin-secrets.json");
public static readonly string FFmpegReportsFolder = Path.Combine(AppDataFolder, "ffmpeg-reports");
public static readonly string SearchIndexFolder = Path.Combine(AppDataFolder, "search-index");

39
ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinApiClient.cs

@ -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);
}
}

15
ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinMovieLibraryScanner.cs

@ -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);
}
}

12
ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinPathReplacementService.cs

@ -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);
}
}

13
ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinSecretStore.cs

@ -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);
}
}

15
ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinTelevisionLibraryScanner.cs

@ -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);
}
}

3
ErsatzTV.Core/Interfaces/Locking/IEntityLocker.cs

@ -6,11 +6,14 @@ namespace ErsatzTV.Core.Interfaces.Locking @@ -6,11 +6,14 @@ namespace ErsatzTV.Core.Interfaces.Locking
{
event EventHandler OnLibraryChanged;
event EventHandler OnPlexChanged;
event EventHandler OnJellyfinChanged;
bool LockLibrary(int libraryId);
bool UnlockLibrary(int libraryId);
bool IsLibraryLocked(int libraryId);
bool LockPlex();
bool UnlockPlex();
bool IsPlexLocked();
bool LockJellyfin();
bool UnlockJellyfin();
}
}

1
ErsatzTV.Core/Interfaces/Metadata/ILocalStatisticsProvider.cs

@ -7,5 +7,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata @@ -7,5 +7,6 @@ namespace ErsatzTV.Core.Interfaces.Metadata
public interface ILocalStatisticsProvider
{
Task<Either<BaseError, bool>> RefreshStatistics(string ffprobePath, MediaItem mediaItem);
Task<Either<BaseError, bool>> RefreshStatistics(string ffprobePath, MediaItem mediaItem, string mediaItemPath);
}
}

26
ErsatzTV.Core/Interfaces/Repositories/IJellyfinTelevisionRepository.cs

@ -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);
}
}

24
ErsatzTV.Core/Interfaces/Repositories/IMediaSourceRepository.cs

@ -32,6 +32,11 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -32,6 +32,11 @@ namespace ErsatzTV.Core.Interfaces.Repositories
List<PlexLibrary> toAdd,
List<PlexLibrary> toDelete);
Task<Unit> UpdateLibraries(
int jellyfinMediaSourceId,
List<JellyfinLibrary> toAdd,
List<JellyfinLibrary> toDelete);
Task<Unit> UpdatePathReplacements(
int plexMediaSourceId,
List<PlexPathReplacement> toAdd,
@ -44,5 +49,24 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -44,5 +49,24 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<List<int>> DeletePlex(PlexMediaSource plexMediaSource);
Task<List<int>> DisablePlexLibrarySync(List<int> libraryIds);
Task EnablePlexLibrarySync(IEnumerable<int> libraryIds);
Task<Unit> UpsertJellyfin(string address, string serverName, string operatingSystem);
Task<List<JellyfinMediaSource>> GetAllJellyfin();
Task<Option<JellyfinMediaSource>> GetJellyfin(int id);
Task<List<JellyfinLibrary>> GetJellyfinLibraries(int jellyfinMediaSourceId);
Task<Unit> EnableJellyfinLibrarySync(IEnumerable<int> libraryIds);
Task<List<int>> DisableJellyfinLibrarySync(List<int> libraryIds);
Task<Option<JellyfinLibrary>> GetJellyfinLibrary(int jellyfinLibraryId);
Task<Option<JellyfinMediaSource>> GetJellyfinByLibraryId(int jellyfinLibraryId);
Task<List<JellyfinPathReplacement>> GetJellyfinPathReplacements(int jellyfinMediaSourceId);
Task<List<JellyfinPathReplacement>> GetJellyfinPathReplacementsByLibraryId(int jellyfinLibraryPathId);
Task<Unit> UpdatePathReplacements(
int jellyfinMediaSourceId,
List<JellyfinPathReplacement> toAdd,
List<JellyfinPathReplacement> toUpdate,
List<JellyfinPathReplacement> toDelete);
Task<List<int>> DeleteAllJellyfin();
}
}

5
ErsatzTV.Core/Interfaces/Repositories/IMovieRepository.cs

@ -1,6 +1,7 @@ @@ -1,6 +1,7 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata;
using LanguageExt;
@ -23,5 +24,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories @@ -23,5 +24,9 @@ namespace ErsatzTV.Core.Interfaces.Repositories
Task<bool> AddActor(MovieMetadata metadata, Actor actor);
Task<List<int>> RemoveMissingPlexMovies(PlexLibrary library, List<string> movieKeys);
Task<bool> UpdateSortTitle(MovieMetadata movieMetadata);
Task<List<JellyfinItemEtag>> GetExistingJellyfinMovies(JellyfinLibrary library);
Task<List<int>> RemoveMissingJellyfinMovies(JellyfinLibrary library, List<string> movieIds);
Task<bool> AddJellyfin(JellyfinMovie movie);
Task<Option<JellyfinMovie>> UpdateJellyfin(JellyfinMovie movie);
}
}

8
ErsatzTV.Core/Jellyfin/JellyfinItemEtag.cs

@ -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; }
}
}

230
ErsatzTV.Core/Jellyfin/JellyfinMovieLibraryScanner.cs

@ -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;
}
}
}

85
ErsatzTV.Core/Jellyfin/JellyfinPathReplacementService.cs

@ -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");
}
}

8
ErsatzTV.Core/Jellyfin/JellyfinSecrets.cs

@ -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; }
}
}

4
ErsatzTV.Core/Jellyfin/JellyfinServerInformation.cs

@ -0,0 +1,4 @@ @@ -0,0 +1,4 @@
namespace ErsatzTV.Core.Jellyfin
{
public record JellyfinServerInformation(string ServerName, string OperatingSystem);
}

416
ErsatzTV.Core/Jellyfin/JellyfinTelevisionLibraryScanner.cs

@ -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));
}
}
}
}
}

22
ErsatzTV.Core/Metadata/LocalStatisticsProvider.cs

@ -41,12 +41,28 @@ namespace ErsatzTV.Core.Metadata @@ -41,12 +41,28 @@ namespace ErsatzTV.Core.Metadata
_ => throw new ArgumentOutOfRangeException(nameof(mediaItem))
};
Either<BaseError, FFprobe> maybeProbe = await GetProbeOutput(ffprobePath, filePath);
return await RefreshStatistics(ffprobePath, mediaItem, filePath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to refresh statistics for media item {Id}", mediaItem.Id);
return BaseError.New(ex.Message);
}
}
public async Task<Either<BaseError, bool>> RefreshStatistics(
string ffprobePath,
MediaItem mediaItem,
string mediaItemPath)
{
try
{
Either<BaseError, FFprobe> maybeProbe = await GetProbeOutput(ffprobePath, mediaItemPath);
return await maybeProbe.Match(
async ffprobe =>
{
MediaVersion version = ProjectToMediaVersion(filePath, ffprobe);
bool result = await ApplyVersionUpdate(mediaItem, version, filePath);
MediaVersion version = ProjectToMediaVersion(mediaItemPath, ffprobe);
bool result = await ApplyVersionUpdate(mediaItem, version, mediaItemPath);
return Right<BaseError, bool>(result);
},
error => Task.FromResult(Left<BaseError, bool>(error)));

12
ErsatzTV.Infrastructure/Data/Configurations/Library/JellyfinLibraryConfiguration.cs

@ -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");
}
}

11
ErsatzTV.Infrastructure/Data/Configurations/MediaItem/JellyfinEpisodeConfiguration.cs

@ -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");
}
}

11
ErsatzTV.Infrastructure/Data/Configurations/MediaItem/JellyfinMovieConfiguration.cs

@ -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");
}
}

11
ErsatzTV.Infrastructure/Data/Configurations/MediaItem/JellyfinSeasonConfiguration.cs

@ -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");
}
}

11
ErsatzTV.Infrastructure/Data/Configurations/MediaItem/JellyfinShowConfiguration.cs

@ -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");
}
}

12
ErsatzTV.Infrastructure/Data/Configurations/MediaSource/JellyfinConnectionConfiguration.cs

@ -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");
}
}

24
ErsatzTV.Infrastructure/Data/Configurations/MediaSource/JellyfinMediaSourceConfiguration.cs

@ -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);
}
}
}

12
ErsatzTV.Infrastructure/Data/Configurations/MediaSource/JellyfinPathReplacementConfiguration.cs

@ -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");
}
}

2
ErsatzTV.Infrastructure/Data/Repositories/FFmpegProfileRepository.cs

@ -66,7 +66,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -66,7 +66,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
await dbContext.SaveChangesAsync();
await dbContext.Entry(clone).Reference(f => f.Resolution).LoadAsync();
return clone;
}
}

454
ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs

@ -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;
}
}
}

282
ErsatzTV.Infrastructure/Data/Repositories/MediaSourceRepository.cs

@ -233,6 +233,33 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -233,6 +233,33 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
return Unit.Default;
}
public async Task<Unit> UpdateLibraries(
int jellyfinMediaSourceId,
List<JellyfinLibrary> toAdd,
List<JellyfinLibrary> toDelete)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
foreach (JellyfinLibrary add in toAdd)
{
add.MediaSourceId = jellyfinMediaSourceId;
dbContext.Entry(add).State = EntityState.Added;
foreach (LibraryPath path in add.Paths)
{
dbContext.Entry(path).State = EntityState.Added;
}
}
foreach (JellyfinLibrary delete in toDelete)
{
dbContext.Entry(delete).State = EntityState.Deleted;
}
await dbContext.SaveChangesAsync();
return Unit.Default;
}
public async Task<Unit> UpdatePathReplacements(
int plexMediaSourceId,
List<PlexPathReplacement> toAdd,
@ -386,5 +413,260 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -386,5 +413,260 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
_dbConnection.ExecuteAsync(
"UPDATE PlexLibrary SET ShouldSyncItems = 1 WHERE Id IN @ids",
new { ids = libraryIds });
public async Task<Unit> UpsertJellyfin(string address, string serverName, string operatingSystem)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<JellyfinMediaSource> maybeExisting = dbContext.JellyfinMediaSources
.Include(ms => ms.Connections)
.OrderBy(ms => ms.Id)
.HeadOrNone();
return await maybeExisting.Match(
async jellyfinMediaSource =>
{
if (!jellyfinMediaSource.Connections.Any())
{
jellyfinMediaSource.Connections.Add(new JellyfinConnection { Address = address });
}
else if (jellyfinMediaSource.Connections.Head().Address != address)
{
jellyfinMediaSource.Connections.Head().Address = address;
}
if (jellyfinMediaSource.ServerName != serverName)
{
jellyfinMediaSource.ServerName = serverName;
}
if (jellyfinMediaSource.OperatingSystem != operatingSystem)
{
jellyfinMediaSource.OperatingSystem = operatingSystem;
}
await dbContext.SaveChangesAsync();
return Unit.Default;
},
async () =>
{
var mediaSource = new JellyfinMediaSource
{
ServerName = serverName,
OperatingSystem = operatingSystem,
Connections = new List<JellyfinConnection>
{
new() { Address = address }
},
PathReplacements = new List<JellyfinPathReplacement>()
};
await dbContext.AddAsync(mediaSource);
await dbContext.SaveChangesAsync();
return Unit.Default;
});
}
public Task<List<JellyfinMediaSource>> GetAllJellyfin()
{
using TvContext context = _dbContextFactory.CreateDbContext();
return context.JellyfinMediaSources
.Include(p => p.Connections)
.ToListAsync();
}
public Task<Option<JellyfinMediaSource>> GetJellyfin(int id)
{
using TvContext context = _dbContextFactory.CreateDbContext();
return context.JellyfinMediaSources
.Include(p => p.Connections)
.Include(p => p.Libraries)
.Include(p => p.PathReplacements)
.OrderBy(s => s.Id) // https://github.com/dotnet/efcore/issues/22579
.SingleOrDefaultAsync(p => p.Id == id)
.Map(Optional);
}
public Task<List<JellyfinLibrary>> GetJellyfinLibraries(int jellyfinMediaSourceId)
{
using TvContext context = _dbContextFactory.CreateDbContext();
return context.JellyfinLibraries
.Filter(l => l.MediaSourceId == jellyfinMediaSourceId)
.ToListAsync();
}
public Task<Unit> EnableJellyfinLibrarySync(IEnumerable<int> libraryIds) =>
_dbConnection.ExecuteAsync(
"UPDATE JellyfinLibrary SET ShouldSyncItems = 1 WHERE Id IN @ids",
new { ids = libraryIds }).Map(_ => Unit.Default);
public async Task<List<int>> DisableJellyfinLibrarySync(List<int> libraryIds)
{
await _dbConnection.ExecuteAsync(
"UPDATE JellyfinLibrary SET ShouldSyncItems = 0 WHERE Id IN @ids",
new { ids = libraryIds });
await _dbConnection.ExecuteAsync(
"UPDATE Library SET LastScan = null WHERE Id IN @ids",
new { ids = libraryIds });
List<int> movieIds = await _dbConnection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN JellyfinMovie pm ON pm.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids",
new { ids = libraryIds }).Map(result => result.ToList());
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN JellyfinMovie pm ON pm.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids)",
new { ids = libraryIds });
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN JellyfinEpisode pe ON pe.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids)",
new { ids = libraryIds });
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN JellyfinSeason ps ON ps.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids)",
new { ids = libraryIds });
List<int> showIds = await _dbConnection.QueryAsync<int>(
@"SELECT m.Id FROM MediaItem m
INNER JOIN JellyfinShow ps ON ps.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids",
new { ids = libraryIds }).Map(result => result.ToList());
await _dbConnection.ExecuteAsync(
@"DELETE FROM MediaItem WHERE Id IN
(SELECT m.Id FROM MediaItem m
INNER JOIN JellyfinShow ps ON ps.Id = m.Id
INNER JOIN LibraryPath lp ON lp.Id = m.LibraryPathId
INNER JOIN Library l ON l.Id = lp.LibraryId
WHERE l.Id IN @ids)",
new { ids = libraryIds });
return movieIds.Append(showIds).ToList();
}
public Task<Option<JellyfinLibrary>> GetJellyfinLibrary(int jellyfinLibraryId)
{
using TvContext context = _dbContextFactory.CreateDbContext();
return context.JellyfinLibraries
.Include(l => l.Paths)
.OrderBy(l => l.Id) // https://github.com/dotnet/efcore/issues/22579
.SingleOrDefaultAsync(l => l.Id == jellyfinLibraryId)
.Map(Optional);
}
public async Task<Option<JellyfinMediaSource>> GetJellyfinByLibraryId(int jellyfinLibraryId)
{
int? id = await _dbConnection.QuerySingleAsync<int?>(
@"SELECT L.MediaSourceId FROM Library L
INNER JOIN JellyfinLibrary PL on L.Id = PL.Id
WHERE L.Id = @JellyfinLibraryId",
new { JellyfinLibraryId = jellyfinLibraryId });
await using TvContext context = _dbContextFactory.CreateDbContext();
return await context.JellyfinMediaSources
.Include(p => p.Connections)
.Include(p => p.Libraries)
.OrderBy(p => p.Id)
.SingleOrDefaultAsync(p => p.Id == id)
.Map(Optional);
}
public Task<List<JellyfinPathReplacement>> GetJellyfinPathReplacements(int jellyfinMediaSourceId)
{
using TvContext context = _dbContextFactory.CreateDbContext();
return context.JellyfinPathReplacements
.Filter(r => r.JellyfinMediaSourceId == jellyfinMediaSourceId)
.Include(jpr => jpr.JellyfinMediaSource)
.ToListAsync();
}
public Task<List<JellyfinPathReplacement>> GetJellyfinPathReplacementsByLibraryId(int jellyfinLibraryPathId)
{
using TvContext context = _dbContextFactory.CreateDbContext();
return context.JellyfinPathReplacements
.FromSqlRaw(
@"select jpr.* from LibraryPath lp
inner join JellyfinLibrary jl ON jl.Id = lp.LibraryId
inner join Library l ON l.Id = jl.Id
inner join JellyfinPathReplacement jpr on jpr.JellyfinMediaSourceId = l.MediaSourceId
where lp.Id = {0}",
jellyfinLibraryPathId)
.Include(jpr => jpr.JellyfinMediaSource)
.ToListAsync();
}
public async Task<Unit> UpdatePathReplacements(
int jellyfinMediaSourceId,
List<JellyfinPathReplacement> toAdd,
List<JellyfinPathReplacement> toUpdate,
List<JellyfinPathReplacement> toDelete)
{
foreach (JellyfinPathReplacement add in toAdd)
{
await _dbConnection.ExecuteAsync(
@"INSERT INTO JellyfinPathReplacement
(JellyfinPath, LocalPath, JellyfinMediaSourceId)
VALUES (@JellyfinPath, @LocalPath, @JellyfinMediaSourceId)",
new { add.JellyfinPath, add.LocalPath, JellyfinMediaSourceId = jellyfinMediaSourceId });
}
foreach (JellyfinPathReplacement update in toUpdate)
{
await _dbConnection.ExecuteAsync(
@"UPDATE JellyfinPathReplacement
SET JellyfinPath = @JellyfinPath, LocalPath = @LocalPath
WHERE Id = @Id",
new { update.JellyfinPath, update.LocalPath, update.Id });
}
foreach (JellyfinPathReplacement delete in toDelete)
{
await _dbConnection.ExecuteAsync(
@"DELETE FROM JellyfinPathReplacement WHERE Id = @Id",
new { delete.Id });
}
return Unit.Default;
}
public async Task<List<int>> DeleteAllJellyfin()
{
await using TvContext context = _dbContextFactory.CreateDbContext();
List<JellyfinMediaSource> allMediaSources = await context.JellyfinMediaSources.ToListAsync();
context.JellyfinMediaSources.RemoveRange(allMediaSources);
List<JellyfinLibrary> allJellyfinLibraries = await context.JellyfinLibraries.ToListAsync();
context.JellyfinLibraries.RemoveRange(allJellyfinLibraries);
List<int> movieIds = await context.JellyfinMovies.Map(pm => pm.Id).ToListAsync();
List<int> showIds = await context.JellyfinShows.Map(ps => ps.Id).ToListAsync();
await context.SaveChangesAsync();
return movieIds.Append(showIds).ToList();
}
}
}

2
ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs

@ -222,7 +222,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -222,7 +222,7 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
_dbConnection.ExecuteAsync(
@"UPDATE MovieMetadata SET DateUpdated = @DateUpdated WHERE Id = @Id",
new { DateUpdated = dateUpdated, metadata.Id }).ToUnit();
public Task<Unit> MarkAsUpdated(EpisodeMetadata metadata, DateTime dateUpdated) =>
_dbConnection.ExecuteAsync(
@"UPDATE EpisodeMetadata SET DateUpdated = @DateUpdated WHERE Id = @Id",

203
ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs

@ -7,8 +7,10 @@ using Dapper; @@ -7,8 +7,10 @@ using Dapper;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Jellyfin;
using ErsatzTV.Core.Metadata;
using LanguageExt;
using LanguageExt.UnsafeValueAccess;
using Microsoft.EntityFrameworkCore;
using static LanguageExt.Prelude;
@ -246,6 +248,207 @@ namespace ErsatzTV.Infrastructure.Data.Repositories @@ -246,6 +248,207 @@ namespace ErsatzTV.Infrastructure.Data.Repositories
@"UPDATE MovieMetadata SET SortTitle = @SortTitle WHERE Id = @Id",
new { movieMetadata.SortTitle, movieMetadata.Id }).Map(result => result > 0);
public Task<List<JellyfinItemEtag>> GetExistingJellyfinMovies(JellyfinLibrary library) =>
_dbConnection.QueryAsync<JellyfinItemEtag>(
@"SELECT ItemId, Etag FROM JellyfinMovie
INNER JOIN Movie M on JellyfinMovie.Id = M.Id
INNER JOIN MediaItem MI on M.Id = MI.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
WHERE LP.LibraryId = @LibraryId",
new { LibraryId = library.Id })
.Map(result => result.ToList());
public async Task<List<int>> RemoveMissingJellyfinMovies(JellyfinLibrary library, List<string> movieIds)
{
List<int> ids = await _dbConnection.QueryAsync<int>(
@"SELECT JellyfinMovie.Id FROM JellyfinMovie
INNER JOIN Movie M on JellyfinMovie.Id = M.Id
INNER JOIN MediaItem MI on M.Id = MI.Id
INNER JOIN LibraryPath LP on MI.LibraryPathId = LP.Id
WHERE LP.LibraryId = @LibraryId AND ItemId IN @ItemIds",
new { LibraryId = library.Id, ItemIds = movieIds }).Map(result => result.ToList());
await _dbConnection.ExecuteAsync(
"DELETE FROM JellyfinMovie WHERE Id IN @Ids",
new { Ids = ids });
return ids;
}
public async Task<bool> AddJellyfin(JellyfinMovie movie)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
await dbContext.AddAsync(movie);
if (await dbContext.SaveChangesAsync() <= 0)
{
return false;
}
await dbContext.Entry(movie).Reference(m => m.LibraryPath).LoadAsync();
await dbContext.Entry(movie.LibraryPath).Reference(lp => lp.Library).LoadAsync();
return true;
}
public async Task<Option<JellyfinMovie>> UpdateJellyfin(JellyfinMovie movie)
{
await using TvContext dbContext = _dbContextFactory.CreateDbContext();
Option<JellyfinMovie> maybeExisting = await dbContext.JellyfinMovies
.Include(m => m.LibraryPath)
.ThenInclude(lp => lp.Library)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.MediaFiles)
.Include(m => m.MediaVersions)
.ThenInclude(mv => mv.Streams)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Genres)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Tags)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Studios)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Actors)
.Include(m => m.MovieMetadata)
.ThenInclude(mm => mm.Artwork)
.Filter(m => m.ItemId == movie.ItemId)
.OrderBy(m => m.ItemId)
.SingleOrDefaultAsync();
if (maybeExisting.IsSome)
{
JellyfinMovie existing = maybeExisting.ValueUnsafe();
// library path is used for search indexing later
movie.LibraryPath = existing.LibraryPath;
movie.Id = existing.Id;
existing.Etag = movie.Etag;
// metadata
MovieMetadata metadata = existing.MovieMetadata.Head();
MovieMetadata incomingMetadata = movie.MovieMetadata.Head();
metadata.Title = incomingMetadata.Title;
metadata.SortTitle = incomingMetadata.SortTitle;
metadata.Plot = incomingMetadata.Plot;
metadata.Year = incomingMetadata.Year;
metadata.Tagline = incomingMetadata.Tagline;
metadata.DateAdded = incomingMetadata.DateAdded;
metadata.DateUpdated = DateTime.UtcNow;
// genres
foreach (Genre genre in metadata.Genres
.Filter(g => incomingMetadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
metadata.Genres.Remove(genre);
}
foreach (Genre genre in incomingMetadata.Genres
.Filter(g => metadata.Genres.All(g2 => g2.Name != g.Name))
.ToList())
{
metadata.Genres.Add(genre);
}
// tags
foreach (Tag tag in metadata.Tags
.Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name))
.ToList())
{
metadata.Tags.Remove(tag);
}
foreach (Tag tag in incomingMetadata.Tags
.Filter(g => metadata.Tags.All(g2 => g2.Name != g.Name))
.ToList())
{
metadata.Tags.Add(tag);
}
// studios
foreach (Studio studio in metadata.Studios
.Filter(g => incomingMetadata.Studios.All(g2 => g2.Name != g.Name))
.ToList())
{
metadata.Studios.Remove(studio);
}
foreach (Studio studio in incomingMetadata.Studios
.Filter(g => metadata.Studios.All(g2 => g2.Name != g.Name))
.ToList())
{
metadata.Studios.Add(studio);
}
// actors
foreach (Actor actor in metadata.Actors
.Filter(
a => incomingMetadata.Actors.All(
a2 => a2.Name != a.Name || a.Artwork == null && a2.Artwork != null))
.ToList())
{
metadata.Actors.Remove(actor);
}
foreach (Actor actor in incomingMetadata.Actors
.Filter(a => metadata.Actors.All(a2 => a2.Name != a.Name))
.ToList())
{
metadata.Actors.Add(actor);
}
metadata.ReleaseDate = incomingMetadata.ReleaseDate;
// poster
Artwork incomingPoster =
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster);
if (incomingPoster != null)
{
Artwork poster = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.Poster);
if (poster == null)
{
poster = new Artwork { ArtworkKind = ArtworkKind.Poster };
metadata.Artwork.Add(poster);
}
poster.Path = incomingPoster.Path;
poster.DateAdded = incomingPoster.DateAdded;
poster.DateUpdated = incomingPoster.DateUpdated;
}
// fan art
Artwork incomingFanArt =
incomingMetadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
if (incomingFanArt != null)
{
Artwork fanArt = metadata.Artwork.FirstOrDefault(a => a.ArtworkKind == ArtworkKind.FanArt);
if (fanArt == null)
{
fanArt = new Artwork { ArtworkKind = ArtworkKind.FanArt };
metadata.Artwork.Add(fanArt);
}
fanArt.Path = incomingFanArt.Path;
fanArt.DateAdded = incomingFanArt.DateAdded;
fanArt.DateUpdated = incomingFanArt.DateUpdated;
}
// version
MediaVersion version = existing.MediaVersions.Head();
MediaVersion incomingVersion = movie.MediaVersions.Head();
version.Name = incomingVersion.Name;
version.DateAdded = incomingVersion.DateAdded;
// media file
MediaFile file = version.MediaFiles.Head();
MediaFile incomingFile = incomingVersion.MediaFiles.Head();
file.Path = incomingFile.Path;
}
await dbContext.SaveChangesAsync();
return maybeExisting;
}
private static async Task<Either<BaseError, MediaItemScanResult<Movie>>> AddMovie(
TvContext dbContext,
int libraryPathId,

7
ErsatzTV.Infrastructure/Data/TvContext.cs

@ -17,11 +17,14 @@ namespace ErsatzTV.Infrastructure.Data @@ -17,11 +17,14 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<MediaSource> MediaSources { get; set; }
public DbSet<LocalMediaSource> LocalMediaSources { get; set; }
public DbSet<PlexMediaSource> PlexMediaSources { get; set; }
public DbSet<JellyfinMediaSource> JellyfinMediaSources { get; set; }
public DbSet<Library> Libraries { get; set; }
public DbSet<LocalLibrary> LocalLibraries { get; set; }
public DbSet<LibraryPath> LibraryPaths { get; set; }
public DbSet<PlexLibrary> PlexLibraries { get; set; }
public DbSet<JellyfinLibrary> JellyfinLibraries { get; set; }
public DbSet<PlexPathReplacement> PlexPathReplacements { get; set; }
public DbSet<JellyfinPathReplacement> JellyfinPathReplacements { get; set; }
public DbSet<MediaItem> MediaItems { get; set; }
public DbSet<MediaVersion> MediaVersions { get; set; }
public DbSet<MediaFile> MediaFiles { get; set; }
@ -40,6 +43,10 @@ namespace ErsatzTV.Infrastructure.Data @@ -40,6 +43,10 @@ namespace ErsatzTV.Infrastructure.Data
public DbSet<PlexShow> PlexShows { get; set; }
public DbSet<PlexSeason> PlexSeasons { get; set; }
public DbSet<PlexEpisode> PlexEpisodes { get; set; }
public DbSet<JellyfinMovie> JellyfinMovies { get; set; }
public DbSet<JellyfinShow> JellyfinShows { get; set; }
public DbSet<JellyfinSeason> JellyfinSeasons { get; set; }
public DbSet<JellyfinEpisode> JellyfinEpisodes { get; set; }
public DbSet<Collection> Collections { get; set; }
public DbSet<CollectionItem> CollectionItems { get; set; }
public DbSet<ProgramSchedule> ProgramSchedules { get; set; }

78
ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs

@ -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");
}
}

569
ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs

@ -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;
}
}
}

26
ErsatzTV.Infrastructure/Jellyfin/JellyfinSecretStore.cs

@ -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));
}
}

7
ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinImageTagsResponse.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
namespace ErsatzTV.Infrastructure.Jellyfin.Models
{
public class JellyfinImageTagsResponse
{
public string Primary { get; set; }
}
}

28
ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinLibraryItemResponse.cs

@ -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; }
}
}

9
ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinLibraryItemsResponse.cs

@ -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; }
}
}

9
ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinLibraryResponse.cs

@ -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; }
}
}

18
ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinMediaStreamResponse.cs

@ -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; }
}
}

11
ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinPersonResponse.cs

@ -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; }
}
}

8
ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinStudioResponse.cs

@ -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; }
}
}

8
ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinSystemInformationResponse.cs

@ -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; }
}
}

7
ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinUserPolicyResponse.cs

@ -0,0 +1,7 @@ @@ -0,0 +1,7 @@
namespace ErsatzTV.Infrastructure.Jellyfin.Models
{
public class JellyfinUserPolicyResponse
{
public bool IsAdministrator { get; set; }
}
}

9
ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinUserResponse.cs

@ -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; }
}
}

27
ErsatzTV.Infrastructure/Locking/EntityLocker.cs

@ -7,6 +7,7 @@ namespace ErsatzTV.Infrastructure.Locking @@ -7,6 +7,7 @@ namespace ErsatzTV.Infrastructure.Locking
public class EntityLocker : IEntityLocker
{
private readonly ConcurrentDictionary<int, byte> _lockedMediaSources;
private bool _jellyfin;
private bool _plex;
public EntityLocker() => _lockedMediaSources = new ConcurrentDictionary<int, byte>();
@ -15,6 +16,8 @@ namespace ErsatzTV.Infrastructure.Locking @@ -15,6 +16,8 @@ namespace ErsatzTV.Infrastructure.Locking
public event EventHandler OnPlexChanged;
public event EventHandler OnJellyfinChanged;
public bool LockLibrary(int mediaSourceId)
{
if (!_lockedMediaSources.ContainsKey(mediaSourceId) && _lockedMediaSources.TryAdd(mediaSourceId, 0))
@ -65,5 +68,29 @@ namespace ErsatzTV.Infrastructure.Locking @@ -65,5 +68,29 @@ namespace ErsatzTV.Infrastructure.Locking
}
public bool IsPlexLocked() => _plex;
public bool LockJellyfin()
{
if (!_jellyfin)
{
_jellyfin = true;
OnJellyfinChanged?.Invoke(this, EventArgs.Empty);
return true;
}
return false;
}
public bool UnlockJellyfin()
{
if (_jellyfin)
{
_jellyfin = false;
OnJellyfinChanged?.Invoke(this, EventArgs.Empty);
return true;
}
return false;
}
}
}

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

Loading…
Cancel
Save