Browse Source

jellyfin and emby collection sync (#752)

* sync jellyfin and emby collections

* update changelog
pull/753/head
Jason Dove 3 years ago committed by GitHub
parent
commit
d67251bfa0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 5
      CHANGELOG.md
  2. 6
      ErsatzTV.Application/Emby/Commands/SynchronizeEmbyCollections.cs
  3. 73
      ErsatzTV.Application/Emby/Commands/SynchronizeEmbyCollectionsHandler.cs
  4. 31
      ErsatzTV.Application/Emby/Commands/SynchronizeEmbyLibraryByIdHandler.cs
  5. 6
      ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinCollections.cs
  6. 77
      ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinCollectionsHandler.cs
  7. 31
      ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinLibraryByIdHandler.cs
  8. 3
      ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs
  9. 9
      ErsatzTV.Core/Domain/Collection/EmbyCollection.cs
  10. 9
      ErsatzTV.Core/Domain/Collection/JellyfinCollection.cs
  11. 1
      ErsatzTV.Core/Domain/Metadata/Tag.cs
  12. 121
      ErsatzTV.Core/Emby/EmbyCollectionScanner.cs
  13. 1
      ErsatzTV.Core/Emby/EmbyMovieLibraryScanner.cs
  14. 13
      ErsatzTV.Core/Emby/EmbyTelevisionLibraryScanner.cs
  15. 13
      ErsatzTV.Core/Interfaces/Emby/IEmbyApiClient.cs
  16. 8
      ErsatzTV.Core/Interfaces/Emby/IEmbyCollectionScanner.cs
  17. 11
      ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinApiClient.cs
  18. 9
      ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinCollectionScanner.cs
  19. 13
      ErsatzTV.Core/Interfaces/Repositories/IEmbyCollectionRepository.cs
  20. 13
      ErsatzTV.Core/Interfaces/Repositories/IJellyfinCollectionRepository.cs
  21. 122
      ErsatzTV.Core/Jellyfin/JellyfinCollectionScanner.cs
  22. 10
      ErsatzTV.Infrastructure/Data/Configurations/Collection/EmbyCollectionConfiguration.cs
  23. 10
      ErsatzTV.Infrastructure/Data/Configurations/Collection/JellyfinCollectionConfiguration.cs
  24. 148
      ErsatzTV.Infrastructure/Data/Repositories/EmbyCollectionRepository.cs
  25. 1
      ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs
  26. 148
      ErsatzTV.Infrastructure/Data/Repositories/JellyfinCollectionRepository.cs
  27. 1
      ErsatzTV.Infrastructure/Data/Repositories/JellyfinTelevisionRepository.cs
  28. 4
      ErsatzTV.Infrastructure/Data/Repositories/MetadataRepository.cs
  29. 6
      ErsatzTV.Infrastructure/Data/Repositories/MovieRepository.cs
  30. 4
      ErsatzTV.Infrastructure/Data/Repositories/MusicVideoRepository.cs
  31. 4
      ErsatzTV.Infrastructure/Data/Repositories/OtherVideoRepository.cs
  32. 4
      ErsatzTV.Infrastructure/Data/Repositories/SongRepository.cs
  33. 12
      ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs
  34. 2
      ErsatzTV.Infrastructure/Data/TvContext.cs
  35. 105
      ErsatzTV.Infrastructure/Emby/EmbyApiClient.cs
  36. 28
      ErsatzTV.Infrastructure/Emby/IEmbyApi.cs
  37. 1
      ErsatzTV.Infrastructure/Emby/Models/EmbyLibraryItemResponse.cs
  38. 30
      ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs
  39. 113
      ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs
  40. 1
      ErsatzTV.Infrastructure/Jellyfin/Models/JellyfinLibraryItemResponse.cs
  41. 4102
      ErsatzTV.Infrastructure/Migrations/20220422151729_Add_JellyfinCollection.Designer.cs
  42. 43
      ErsatzTV.Infrastructure/Migrations/20220422151729_Add_JellyfinCollection.cs
  43. 4122
      ErsatzTV.Infrastructure/Migrations/20220422170757_Add_EmbyCollection.Designer.cs
  44. 33
      ErsatzTV.Infrastructure/Migrations/20220422170757_Add_EmbyCollection.cs
  45. 43
      ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs
  46. 12
      ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs
  47. 19
      ErsatzTV/Services/EmbyService.cs
  48. 21
      ErsatzTV/Services/JellyfinService.cs
  49. 4
      ErsatzTV/Startup.cs

5
CHANGELOG.md

@ -12,8 +12,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). @@ -12,8 +12,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Add support for burning in embedded text subtitles
- Add support for burning in external text subtitles
- **This requires a one-time full library scan, which may take a long time with large libraries.**
- Sync Plex collections as tags on movies, shows, seasons and episodes
- This allows smart collections that use queries like `tag:"Plex Collection Name"`
- Sync Plex, Jellyfin and Emby collections as tags on movies, shows, seasons and episodes
- This allows smart collections that use queries like `tag:"Collection Name"`
- Note that Emby has an outstanding collections bug that prevents updates when removing items from a collection
- Sync Plex labels as tags on movies and shows
- This allows smart collections that use queries like `tag:"Plex Label Name"`
- Add `Deep Scan` button for Plex libraries

6
ErsatzTV.Application/Emby/Commands/SynchronizeEmbyCollections.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Emby;
public record SynchronizeEmbyCollections(int EmbyMediaSourceId) : IRequest<Either<BaseError, Unit>>,
IEmbyBackgroundServiceRequest;

73
ErsatzTV.Application/Emby/Commands/SynchronizeEmbyCollectionsHandler.cs

@ -0,0 +1,73 @@ @@ -0,0 +1,73 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Repositories;
namespace ErsatzTV.Application.Emby;
public class SynchronizeEmbyCollectionsHandler : IRequestHandler<SynchronizeEmbyCollections, Either<BaseError, Unit>>
{
private readonly IEmbySecretStore _embySecretStore;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IEmbyCollectionScanner _scanner;
public SynchronizeEmbyCollectionsHandler(
IMediaSourceRepository mediaSourceRepository,
IEmbySecretStore embySecretStore,
IEmbyCollectionScanner scanner)
{
_mediaSourceRepository = mediaSourceRepository;
_embySecretStore = embySecretStore;
_scanner = scanner;
}
public async Task<Either<BaseError, Unit>> Handle(
SynchronizeEmbyCollections request,
CancellationToken cancellationToken)
{
Validation<BaseError, ConnectionParameters> validation = await Validate(request);
return await validation.Match(
SynchronizeCollections,
error => Task.FromResult<Either<BaseError, Unit>>(error.Join()));
}
private Task<Validation<BaseError, ConnectionParameters>> Validate(SynchronizeEmbyCollections request) =>
MediaSourceMustExist(request)
.BindT(MediaSourceMustHaveActiveConnection)
.BindT(MediaSourceMustHaveApiKey);
private Task<Validation<BaseError, EmbyMediaSource>> MediaSourceMustExist(
SynchronizeEmbyCollections request) =>
_mediaSourceRepository.GetEmby(request.EmbyMediaSourceId)
.Map(o => o.ToValidation<BaseError>("Emby media source does not exist."));
private Validation<BaseError, ConnectionParameters> MediaSourceMustHaveActiveConnection(
EmbyMediaSource embyMediaSource)
{
Option<EmbyConnection> maybeConnection = embyMediaSource.Connections.HeadOrNone();
return maybeConnection.Map(connection => new ConnectionParameters(connection))
.ToValidation<BaseError>("Emby media source requires an active connection");
}
private async Task<Validation<BaseError, ConnectionParameters>> MediaSourceMustHaveApiKey(
ConnectionParameters connectionParameters)
{
EmbySecrets secrets = await _embySecretStore.ReadSecrets();
return Optional(secrets.Address == connectionParameters.ActiveConnection.Address)
.Where(match => match)
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
.ToValidation<BaseError>("Emby media source requires an api key");
}
private async Task<Either<BaseError, Unit>> SynchronizeCollections(ConnectionParameters connectionParameters) =>
await _scanner.ScanCollections(
connectionParameters.ActiveConnection.Address,
connectionParameters.ApiKey);
private record ConnectionParameters(EmbyConnection ActiveConnection)
{
public string ApiKey { get; set; }
}
}

31
ErsatzTV.Application/Emby/Commands/SynchronizeEmbyLibraryByIdHandler.cs

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using ErsatzTV.Core;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Emby;
@ -17,6 +18,7 @@ public class SynchronizeEmbyLibraryByIdHandler : @@ -17,6 +18,7 @@ public class SynchronizeEmbyLibraryByIdHandler :
private readonly IEmbySecretStore _embySecretStore;
private readonly IEmbyTelevisionLibraryScanner _embyTelevisionLibraryScanner;
private readonly ChannelWriter<IEmbyBackgroundServiceRequest> _embyWorkerChannel;
private readonly IEntityLocker _entityLocker;
private readonly ILibraryRepository _libraryRepository;
private readonly ILogger<SynchronizeEmbyLibraryByIdHandler> _logger;
@ -31,6 +33,7 @@ public class SynchronizeEmbyLibraryByIdHandler : @@ -31,6 +33,7 @@ public class SynchronizeEmbyLibraryByIdHandler :
ILibraryRepository libraryRepository,
IEntityLocker entityLocker,
IConfigElementRepository configElementRepository,
ChannelWriter<IEmbyBackgroundServiceRequest> embyWorkerChannel,
ILogger<SynchronizeEmbyLibraryByIdHandler> logger)
{
_mediaSourceRepository = mediaSourceRepository;
@ -40,6 +43,7 @@ public class SynchronizeEmbyLibraryByIdHandler : @@ -40,6 +43,7 @@ public class SynchronizeEmbyLibraryByIdHandler :
_libraryRepository = libraryRepository;
_entityLocker = entityLocker;
_configElementRepository = configElementRepository;
_embyWorkerChannel = embyWorkerChannel;
_logger = logger;
}
@ -65,28 +69,33 @@ public class SynchronizeEmbyLibraryByIdHandler : @@ -65,28 +69,33 @@ public class SynchronizeEmbyLibraryByIdHandler :
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
{
switch (parameters.Library.MediaKind)
Either<BaseError, Unit> result = parameters.Library.MediaKind switch
{
case LibraryMediaKind.Movies:
LibraryMediaKind.Movies =>
await _embyMovieLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection.Address,
parameters.ConnectionParameters.ApiKey,
parameters.Library,
parameters.FFmpegPath,
parameters.FFprobePath);
break;
case LibraryMediaKind.Shows:
parameters.FFprobePath),
LibraryMediaKind.Shows =>
await _embyTelevisionLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection.Address,
parameters.ConnectionParameters.ApiKey,
parameters.Library,
parameters.FFmpegPath,
parameters.FFprobePath);
break;
}
parameters.FFprobePath),
_ => BaseError.New("Unsupported library media kind")
};
if (result.IsRight)
{
parameters.Library.LastScan = DateTime.UtcNow;
await _libraryRepository.UpdateLastScan(parameters.Library);
parameters.Library.LastScan = DateTime.UtcNow;
await _libraryRepository.UpdateLastScan(parameters.Library);
await _embyWorkerChannel.WriteAsync(
new SynchronizeEmbyCollections(parameters.Library.MediaSourceId));
}
}
else
{

6
ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinCollections.cs

@ -0,0 +1,6 @@ @@ -0,0 +1,6 @@
using ErsatzTV.Core;
namespace ErsatzTV.Application.Jellyfin;
public record SynchronizeJellyfinCollections(int JellyfinMediaSourceId) : IRequest<Either<BaseError, Unit>>,
IJellyfinBackgroundServiceRequest;

77
ErsatzTV.Application/Jellyfin/Commands/SynchronizeJellyfinCollectionsHandler.cs

@ -0,0 +1,77 @@ @@ -0,0 +1,77 @@
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Jellyfin;
namespace ErsatzTV.Application.Jellyfin;
public class SynchronizeJellyfinCollectionsHandler :
IRequestHandler<SynchronizeJellyfinCollections, Either<BaseError, Unit>>
{
private readonly IJellyfinSecretStore _jellyfinSecretStore;
private readonly IMediaSourceRepository _mediaSourceRepository;
private readonly IJellyfinCollectionScanner _scanner;
public SynchronizeJellyfinCollectionsHandler(
IMediaSourceRepository mediaSourceRepository,
IJellyfinSecretStore jellyfinSecretStore,
IJellyfinCollectionScanner scanner)
{
_mediaSourceRepository = mediaSourceRepository;
_jellyfinSecretStore = jellyfinSecretStore;
_scanner = scanner;
}
public async Task<Either<BaseError, Unit>> Handle(
SynchronizeJellyfinCollections request,
CancellationToken cancellationToken)
{
Validation<BaseError, ConnectionParameters> validation = await Validate(request);
return await validation.Match(
SynchronizeCollections,
error => Task.FromResult<Either<BaseError, Unit>>(error.Join()));
}
private Task<Validation<BaseError, ConnectionParameters>> Validate(SynchronizeJellyfinCollections request) =>
MediaSourceMustExist(request)
.BindT(MediaSourceMustHaveActiveConnection)
.BindT(MediaSourceMustHaveApiKey);
private Task<Validation<BaseError, JellyfinMediaSource>> MediaSourceMustExist(
SynchronizeJellyfinCollections 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)
.Where(match => match)
.Map(_ => connectionParameters with { ApiKey = secrets.ApiKey })
.ToValidation<BaseError>("Jellyfin media source requires an api key");
}
private async Task<Either<BaseError, Unit>> SynchronizeCollections(ConnectionParameters connectionParameters) =>
await _scanner.ScanCollections(
connectionParameters.ActiveConnection.Address,
connectionParameters.ApiKey,
connectionParameters.JellyfinMediaSource.Id);
private record ConnectionParameters(
JellyfinMediaSource JellyfinMediaSource,
JellyfinConnection ActiveConnection)
{
public string ApiKey { get; set; }
}
}

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

@ -1,4 +1,5 @@ @@ -1,4 +1,5 @@
using ErsatzTV.Core;
using System.Threading.Channels;
using ErsatzTV.Core;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Locking;
@ -18,6 +19,7 @@ public class SynchronizeJellyfinLibraryByIdHandler : @@ -18,6 +19,7 @@ public class SynchronizeJellyfinLibraryByIdHandler :
private readonly IJellyfinSecretStore _jellyfinSecretStore;
private readonly IJellyfinTelevisionLibraryScanner _jellyfinTelevisionLibraryScanner;
private readonly ChannelWriter<IJellyfinBackgroundServiceRequest> _jellyfinWorkerChannel;
private readonly ILibraryRepository _libraryRepository;
private readonly ILogger<SynchronizeJellyfinLibraryByIdHandler> _logger;
@ -31,6 +33,7 @@ public class SynchronizeJellyfinLibraryByIdHandler : @@ -31,6 +33,7 @@ public class SynchronizeJellyfinLibraryByIdHandler :
ILibraryRepository libraryRepository,
IEntityLocker entityLocker,
IConfigElementRepository configElementRepository,
ChannelWriter<IJellyfinBackgroundServiceRequest> jellyfinWorkerChannel,
ILogger<SynchronizeJellyfinLibraryByIdHandler> logger)
{
_mediaSourceRepository = mediaSourceRepository;
@ -40,6 +43,7 @@ public class SynchronizeJellyfinLibraryByIdHandler : @@ -40,6 +43,7 @@ public class SynchronizeJellyfinLibraryByIdHandler :
_libraryRepository = libraryRepository;
_entityLocker = entityLocker;
_configElementRepository = configElementRepository;
_jellyfinWorkerChannel = jellyfinWorkerChannel;
_logger = logger;
}
@ -65,28 +69,33 @@ public class SynchronizeJellyfinLibraryByIdHandler : @@ -65,28 +69,33 @@ public class SynchronizeJellyfinLibraryByIdHandler :
DateTimeOffset nextScan = lastScan + TimeSpan.FromHours(parameters.LibraryRefreshInterval);
if (parameters.ForceScan || nextScan < DateTimeOffset.Now)
{
switch (parameters.Library.MediaKind)
Either<BaseError, Unit> result = parameters.Library.MediaKind switch
{
case LibraryMediaKind.Movies:
LibraryMediaKind.Movies =>
await _jellyfinMovieLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection.Address,
parameters.ConnectionParameters.ApiKey,
parameters.Library,
parameters.FFmpegPath,
parameters.FFprobePath);
break;
case LibraryMediaKind.Shows:
parameters.FFprobePath),
LibraryMediaKind.Shows =>
await _jellyfinTelevisionLibraryScanner.ScanLibrary(
parameters.ConnectionParameters.ActiveConnection.Address,
parameters.ConnectionParameters.ApiKey,
parameters.Library,
parameters.FFmpegPath,
parameters.FFprobePath);
break;
}
parameters.FFprobePath),
_ => BaseError.New("Unsupported library media kind")
};
if (result.IsRight)
{
parameters.Library.LastScan = DateTime.UtcNow;
await _libraryRepository.UpdateLastScan(parameters.Library);
parameters.Library.LastScan = DateTime.UtcNow;
await _libraryRepository.UpdateLastScan(parameters.Library);
await _jellyfinWorkerChannel.WriteAsync(
new SynchronizeJellyfinCollections(parameters.Library.MediaSourceId));
}
}
else
{

3
ErsatzTV.Core.Tests/FFmpeg/TranscodingTests.cs

@ -189,7 +189,8 @@ public class TranscodingTests @@ -189,7 +189,8 @@ public class TranscodingTests
[ValueSource(typeof(TestData), nameof(TestData.VideoFormats))]
FFmpegProfileVideoFormat profileVideoFormat,
// [ValueSource(typeof(TestData), nameof(TestData.NoAcceleration))] HardwareAccelerationKind profileAcceleration)
[ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))] HardwareAccelerationKind profileAcceleration)
[ValueSource(typeof(TestData), nameof(TestData.NvidiaAcceleration))]
HardwareAccelerationKind profileAcceleration)
// [ValueSource(typeof(TestData), nameof(TestData.VaapiAcceleration))] HardwareAccelerationKind profileAcceleration)
// [ValueSource(typeof(TestData), nameof(TestData.QsvAcceleration))] HardwareAccelerationKind profileAcceleration)
// [ValueSource(typeof(TestData), nameof(TestData.VideoToolboxAcceleration))] HardwareAccelerationKind profileAcceleration)

9
ErsatzTV.Core/Domain/Collection/EmbyCollection.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Domain;
public class EmbyCollection
{
public int Id { get; set; }
public string ItemId { get; set; }
public string Etag { get; set; }
public string Name { get; set; }
}

9
ErsatzTV.Core/Domain/Collection/JellyfinCollection.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Domain;
public class JellyfinCollection
{
public int Id { get; set; }
public string ItemId { get; set; }
public string Etag { get; set; }
public string Name { get; set; }
}

1
ErsatzTV.Core/Domain/Metadata/Tag.cs

@ -4,4 +4,5 @@ public class Tag @@ -4,4 +4,5 @@ public class Tag
{
public int Id { get; set; }
public string Name { get; set; }
public string ExternalCollectionId { get; set; }
}

121
ErsatzTV.Core/Emby/EmbyCollectionScanner.cs

@ -0,0 +1,121 @@ @@ -0,0 +1,121 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Emby;
public class EmbyCollectionScanner : IEmbyCollectionScanner
{
private readonly IEmbyApiClient _embyApiClient;
private readonly IEmbyCollectionRepository _embyCollectionRepository;
private readonly ILogger<EmbyCollectionScanner> _logger;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
public EmbyCollectionScanner(
IEmbyCollectionRepository embyCollectionRepository,
IEmbyApiClient embyApiClient,
ISearchRepository searchRepository,
ISearchIndex searchIndex,
ILogger<EmbyCollectionScanner> logger)
{
_embyCollectionRepository = embyCollectionRepository;
_embyApiClient = embyApiClient;
_searchRepository = searchRepository;
_searchIndex = searchIndex;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanCollections(string address, string apiKey)
{
// get all collections from db (item id, etag)
List<EmbyCollection> existingCollections = await _embyCollectionRepository.GetCollections();
// get all collections from emby
Either<BaseError, List<EmbyCollection>> maybeIncomingCollections =
await _embyApiClient.GetCollectionLibraryItems(address, apiKey);
foreach (BaseError error in maybeIncomingCollections.LeftToSeq())
{
_logger.LogWarning("Failed to get collections from Emby: {Error}", error.ToString());
return error;
}
foreach (List<EmbyCollection> incomingCollections in maybeIncomingCollections.RightToSeq())
{
// loop over collections
foreach (EmbyCollection collection in incomingCollections)
{
Option<EmbyCollection> maybeExisting = existingCollections.Find(c => c.ItemId == collection.ItemId);
// skip if unchanged (etag)
if (await maybeExisting.Map(e => e.Etag ?? string.Empty).IfNoneAsync(string.Empty) == collection.Etag)
{
_logger.LogDebug("Emby collection {Name} is unchanged", collection.Name);
continue;
}
// add if new
if (maybeExisting.IsNone)
{
_logger.LogDebug("Emby collection {Name} is new", collection.Name);
await _embyCollectionRepository.AddCollection(collection);
}
else
{
_logger.LogDebug("Emby collection {Name} has been updated", collection.Name);
}
await SyncCollectionItems(address, apiKey, collection);
// save collection etag
await _embyCollectionRepository.SetEtag(collection);
}
// remove missing collections (and remove any lingering tags from those collections)
foreach (EmbyCollection collection in existingCollections
.Filter(e => incomingCollections.All(i => i.ItemId != e.ItemId)))
{
await _embyCollectionRepository.RemoveCollection(collection);
}
}
return Unit.Default;
}
private async Task SyncCollectionItems(
string address,
string apiKey,
EmbyCollection collection)
{
// get collection items from JF
Either<BaseError, List<MediaItem>> maybeItems =
await _embyApiClient.GetCollectionItems(address, apiKey, collection.ItemId);
foreach (BaseError error in maybeItems.LeftToSeq())
{
_logger.LogWarning("Failed to get collection items from Emby: {Error}", error.ToString());
return;
}
List<int> removedIds = await _embyCollectionRepository.RemoveAllTags(collection);
var embyItems = maybeItems.RightToSeq().Flatten().ToList();
_logger.LogDebug("Emby collection {Name} contains {Count} items", collection.Name, embyItems.Count);
// sync tags on items
var addedIds = new List<int>();
foreach (MediaItem item in embyItems)
{
addedIds.Add(await _embyCollectionRepository.AddTag(item, collection));
}
var changedIds = removedIds.Except(addedIds).ToList();
changedIds.AddRange(addedIds.Except(removedIds));
await _searchIndex.RebuildItems(_searchRepository, changedIds);
_searchIndex.Commit();
}
}

1
ErsatzTV.Core/Emby/EmbyMovieLibraryScanner.cs

@ -68,7 +68,6 @@ public class EmbyMovieLibraryScanner : IEmbyMovieLibraryScanner @@ -68,7 +68,6 @@ public class EmbyMovieLibraryScanner : IEmbyMovieLibraryScanner
Either<BaseError, List<EmbyMovie>> maybeMovies = await _embyApiClient.GetMovieLibraryItems(
address,
apiKey,
library.MediaSourceId,
library.ItemId);
await maybeMovies.Match(

13
ErsatzTV.Core/Emby/EmbyTelevisionLibraryScanner.cs

@ -68,7 +68,6 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner @@ -68,7 +68,6 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
Either<BaseError, List<EmbyShow>> maybeShows = await _embyApiClient.GetShowLibraryItems(
address,
apiKey,
library.MediaSourceId,
library.ItemId);
await maybeShows.Match(
@ -167,11 +166,7 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner @@ -167,11 +166,7 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
await _televisionRepository.GetExistingSeasons(library, incoming.ItemId);
Either<BaseError, List<EmbySeason>> maybeSeasons =
await _embyApiClient.GetSeasonLibraryItems(
address,
apiKey,
library.MediaSourceId,
incoming.ItemId);
await _embyApiClient.GetSeasonLibraryItems(address, apiKey, incoming.ItemId);
await maybeSeasons.Match(
async seasons =>
@ -268,11 +263,7 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner @@ -268,11 +263,7 @@ public class EmbyTelevisionLibraryScanner : IEmbyTelevisionLibraryScanner
await _televisionRepository.GetExistingEpisodes(library, incoming.ItemId);
Either<BaseError, List<EmbyEpisode>> maybeEpisodes =
await _embyApiClient.GetEpisodeLibraryItems(
address,
apiKey,
library.MediaSourceId,
incoming.ItemId);
await _embyApiClient.GetEpisodeLibraryItems(address, apiKey, incoming.ItemId);
await maybeEpisodes.Match(
async episodes =>

13
ErsatzTV.Core/Interfaces/Emby/IEmbyApiClient.cs

@ -11,24 +11,29 @@ public interface IEmbyApiClient @@ -11,24 +11,29 @@ public interface IEmbyApiClient
Task<Either<BaseError, List<EmbyMovie>>> GetMovieLibraryItems(
string address,
string apiKey,
int mediaSourceId,
string libraryId);
Task<Either<BaseError, List<EmbyShow>>> GetShowLibraryItems(
string address,
string apiKey,
int mediaSourceId,
string libraryId);
Task<Either<BaseError, List<EmbySeason>>> GetSeasonLibraryItems(
string address,
string apiKey,
int mediaSourceId,
string showId);
Task<Either<BaseError, List<EmbyEpisode>>> GetEpisodeLibraryItems(
string address,
string apiKey,
int mediaSourceId,
string seasonId);
Task<Either<BaseError, List<EmbyCollection>>> GetCollectionLibraryItems(
string address,
string apiKey);
Task<Either<BaseError, List<MediaItem>>> GetCollectionItems(
string address,
string apiKey,
string collectionId);
}

8
ErsatzTV.Core/Interfaces/Emby/IEmbyCollectionScanner.cs

@ -0,0 +1,8 @@ @@ -0,0 +1,8 @@
namespace ErsatzTV.Core.Interfaces.Emby;
public interface IEmbyCollectionScanner
{
Task<Either<BaseError, Unit>> ScanCollections(
string address,
string apiKey);
}

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

@ -32,4 +32,15 @@ public interface IJellyfinApiClient @@ -32,4 +32,15 @@ public interface IJellyfinApiClient
string apiKey,
int mediaSourceId,
string seasonId);
Task<Either<BaseError, List<JellyfinCollection>>> GetCollectionLibraryItems(
string address,
string apiKey,
int mediaSourceId);
Task<Either<BaseError, List<MediaItem>>> GetCollectionItems(
string address,
string apiKey,
int mediaSourceId,
string collectionId);
}

9
ErsatzTV.Core/Interfaces/Jellyfin/IJellyfinCollectionScanner.cs

@ -0,0 +1,9 @@ @@ -0,0 +1,9 @@
namespace ErsatzTV.Core.Interfaces.Jellyfin;
public interface IJellyfinCollectionScanner
{
Task<Either<BaseError, Unit>> ScanCollections(
string address,
string apiKey,
int mediaSourceId);
}

13
ErsatzTV.Core/Interfaces/Repositories/IEmbyCollectionRepository.cs

@ -0,0 +1,13 @@ @@ -0,0 +1,13 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IEmbyCollectionRepository
{
Task<List<EmbyCollection>> GetCollections();
Task<bool> AddCollection(EmbyCollection collection);
Task<bool> RemoveCollection(EmbyCollection collection);
Task<List<int>> RemoveAllTags(EmbyCollection collection);
Task<int> AddTag(MediaItem item, EmbyCollection collection);
Task<bool> SetEtag(EmbyCollection collection);
}

13
ErsatzTV.Core/Interfaces/Repositories/IJellyfinCollectionRepository.cs

@ -0,0 +1,13 @@ @@ -0,0 +1,13 @@
using ErsatzTV.Core.Domain;
namespace ErsatzTV.Core.Interfaces.Repositories;
public interface IJellyfinCollectionRepository
{
Task<List<JellyfinCollection>> GetCollections();
Task<bool> AddCollection(JellyfinCollection collection);
Task<bool> RemoveCollection(JellyfinCollection collection);
Task<List<int>> RemoveAllTags(JellyfinCollection collection);
Task<int> AddTag(MediaItem item, JellyfinCollection collection);
Task<bool> SetEtag(JellyfinCollection collection);
}

122
ErsatzTV.Core/Jellyfin/JellyfinCollectionScanner.cs

@ -0,0 +1,122 @@ @@ -0,0 +1,122 @@
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Jellyfin;
using ErsatzTV.Core.Interfaces.Repositories;
using ErsatzTV.Core.Interfaces.Search;
using Microsoft.Extensions.Logging;
namespace ErsatzTV.Core.Jellyfin;
public class JellyfinCollectionScanner : IJellyfinCollectionScanner
{
private readonly IJellyfinApiClient _jellyfinApiClient;
private readonly IJellyfinCollectionRepository _jellyfinCollectionRepository;
private readonly ILogger<JellyfinCollectionScanner> _logger;
private readonly ISearchIndex _searchIndex;
private readonly ISearchRepository _searchRepository;
public JellyfinCollectionScanner(
IJellyfinCollectionRepository jellyfinCollectionRepository,
IJellyfinApiClient jellyfinApiClient,
ISearchRepository searchRepository,
ISearchIndex searchIndex,
ILogger<JellyfinCollectionScanner> logger)
{
_jellyfinCollectionRepository = jellyfinCollectionRepository;
_jellyfinApiClient = jellyfinApiClient;
_searchRepository = searchRepository;
_searchIndex = searchIndex;
_logger = logger;
}
public async Task<Either<BaseError, Unit>> ScanCollections(string address, string apiKey, int mediaSourceId)
{
// get all collections from db (item id, etag)
List<JellyfinCollection> existingCollections = await _jellyfinCollectionRepository.GetCollections();
// get all collections from jellyfin
Either<BaseError, List<JellyfinCollection>> maybeIncomingCollections =
await _jellyfinApiClient.GetCollectionLibraryItems(address, apiKey, mediaSourceId);
foreach (BaseError error in maybeIncomingCollections.LeftToSeq())
{
_logger.LogWarning("Failed to get collections from Jellyfin: {Error}", error.ToString());
return error;
}
foreach (List<JellyfinCollection> incomingCollections in maybeIncomingCollections.RightToSeq())
{
// loop over collections
foreach (JellyfinCollection collection in incomingCollections)
{
Option<JellyfinCollection> maybeExisting = existingCollections.Find(c => c.ItemId == collection.ItemId);
// skip if unchanged (etag)
if (await maybeExisting.Map(e => e.Etag ?? string.Empty).IfNoneAsync(string.Empty) == collection.Etag)
{
_logger.LogDebug("Jellyfin collection {Name} is unchanged", collection.Name);
continue;
}
// add if new
if (maybeExisting.IsNone)
{
_logger.LogDebug("Jellyfin collection {Name} is new", collection.Name);
await _jellyfinCollectionRepository.AddCollection(collection);
}
else
{
_logger.LogDebug("Jellyfin collection {Name} has been updated", collection.Name);
}
await SyncCollectionItems(address, apiKey, mediaSourceId, collection);
// save collection etag
await _jellyfinCollectionRepository.SetEtag(collection);
}
// remove missing collections (and remove any lingering tags from those collections)
foreach (JellyfinCollection collection in existingCollections
.Filter(e => incomingCollections.All(i => i.ItemId != e.ItemId)))
{
await _jellyfinCollectionRepository.RemoveCollection(collection);
}
}
return Unit.Default;
}
private async Task SyncCollectionItems(
string address,
string apiKey,
int mediaSourceId,
JellyfinCollection collection)
{
// get collection items from JF
Either<BaseError, List<MediaItem>> maybeItems =
await _jellyfinApiClient.GetCollectionItems(address, apiKey, mediaSourceId, collection.ItemId);
foreach (BaseError error in maybeItems.LeftToSeq())
{
_logger.LogWarning("Failed to get collection items from Jellyfin: {Error}", error.ToString());
return;
}
List<int> removedIds = await _jellyfinCollectionRepository.RemoveAllTags(collection);
var jellyfinItems = maybeItems.RightToSeq().Flatten().ToList();
_logger.LogDebug("Jellyfin collection {Name} contains {Count} items", collection.Name, jellyfinItems.Count);
// sync tags on items
var addedIds = new List<int>();
foreach (MediaItem item in jellyfinItems)
{
addedIds.Add(await _jellyfinCollectionRepository.AddTag(item, collection));
}
var changedIds = removedIds.Except(addedIds).ToList();
changedIds.AddRange(addedIds.Except(removedIds));
await _searchIndex.RebuildItems(_searchRepository, changedIds);
_searchIndex.Commit();
}
}

10
ErsatzTV.Infrastructure/Data/Configurations/Collection/EmbyCollectionConfiguration.cs

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations;
public class EmbyCollectionConfiguration : IEntityTypeConfiguration<EmbyCollection>
{
public void Configure(EntityTypeBuilder<EmbyCollection> builder) => builder.ToTable("EmbyCollection");
}

10
ErsatzTV.Infrastructure/Data/Configurations/Collection/JellyfinCollectionConfiguration.cs

@ -0,0 +1,10 @@ @@ -0,0 +1,10 @@
using ErsatzTV.Core.Domain;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ErsatzTV.Infrastructure.Data.Configurations;
public class JellyfinCollectionConfiguration : IEntityTypeConfiguration<JellyfinCollection>
{
public void Configure(EntityTypeBuilder<JellyfinCollection> builder) => builder.ToTable("JellyfinCollection");
}

148
ErsatzTV.Infrastructure/Data/Repositories/EmbyCollectionRepository.cs

@ -0,0 +1,148 @@ @@ -0,0 +1,148 @@
using Dapper;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories;
public class EmbyCollectionRepository : IEmbyCollectionRepository
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public EmbyCollectionRepository(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<List<EmbyCollection>> GetCollections()
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.EmbyCollections.ToListAsync();
}
public async Task<bool> AddCollection(EmbyCollection collection)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await dbContext.AddAsync(collection);
return await dbContext.SaveChangesAsync() > 0;
}
public async Task<bool> RemoveCollection(EmbyCollection collection)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
dbContext.Remove(collection);
// remove all tags that reference this collection
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM Tag WHERE Name = @Name AND ExternalCollectionId = @ItemId",
new { collection.Name, collection.ItemId });
return await dbContext.SaveChangesAsync() > 0;
}
public async Task<List<int>> RemoveAllTags(EmbyCollection collection)
{
var result = new List<int>();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
// movies
result.AddRange(
await dbContext.Connection.QueryAsync<int>(
@"SELECT JM.Id FROM Tag T
INNER JOIN MovieMetadata MM on T.MovieMetadataId = MM.Id
INNER JOIN EmbyMovie JM on JM.Id = MM.MovieId
WHERE T.ExternalCollectionId = @ItemId",
new { collection.ItemId }));
// shows
result.AddRange(
await dbContext.Connection.QueryAsync<int>(
@"SELECT JS.Id FROM Tag T
INNER JOIN ShowMetadata SM on T.ShowMetadataId = SM.Id
INNER JOIN EmbyShow JS on JS.Id = SM.ShowId
WHERE T.ExternalCollectionId = @ItemId",
new { collection.ItemId }));
// seasons
result.AddRange(
await dbContext.Connection.QueryAsync<int>(
@"SELECT JS.Id FROM Tag T
INNER JOIN SeasonMetadata SM on T.SeasonMetadataId = SM.Id
INNER JOIN EmbySeason JS on JS.Id = SM.SeasonId
WHERE T.ExternalCollectionId = @ItemId",
new { collection.ItemId }));
// episodes
result.AddRange(
await dbContext.Connection.QueryAsync<int>(
@"SELECT JE.Id FROM Tag T
INNER JOIN EpisodeMetadata EM on T.EpisodeMetadataId = EM.Id
INNER JOIN EmbyEpisode JE on JE.Id = EM.EpisodeId
WHERE T.ExternalCollectionId = @ItemId",
new { collection.ItemId }));
// delete all tags
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM Tag WHERE Name = @Name AND ExternalCollectionId = @ItemId",
new { collection.Name, collection.ItemId });
return result;
}
public async Task<int> AddTag(MediaItem item, EmbyCollection collection)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
switch (item)
{
case EmbyMovie movie:
int movieId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT Id FROM EmbyMovie WHERE ItemId = @ItemId",
new { movie.ItemId });
await dbContext.Connection.ExecuteAsync(
@"INSERT INTO Tag (Name, ExternalCollectionId, MovieMetadataId)
SELECT @Name, @ItemId, Id FROM
(SELECT Id FROM MovieMetadata WHERE MovieId = @MovieId)",
new { collection.Name, collection.ItemId, MovieId = movieId });
return movieId;
case EmbyShow show:
int showId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT Id FROM EmbyShow WHERE ItemId = @ItemId",
new { show.ItemId });
await dbContext.Connection.ExecuteAsync(
@"INSERT INTO Tag (Name, ExternalCollectionId, ShowMetadataId)
SELECT @Name, @ItemId, Id FROM
(SELECT Id FROM ShowMetadata WHERE ShowId = @ShowId)",
new { collection.Name, collection.ItemId, ShowId = showId });
return showId;
case EmbySeason season:
int seasonId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT Id FROM EmbySeason WHERE ItemId = @ItemId",
new { season.ItemId });
await dbContext.Connection.ExecuteAsync(
@"INSERT INTO Tag (Name, ExternalCollectionId, SeasonMetadataId)
SELECT @Name, @ItemId, Id FROM
(SELECT Id FROM SeasonMetadata WHERE SeasonId = @SeasonId)",
new { collection.Name, collection.ItemId, SeasonId = seasonId });
return seasonId;
case EmbyEpisode episode:
int episodeId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT Id FROM EmbyEpisode WHERE ItemId = @ItemId",
new { episode.ItemId });
await dbContext.Connection.ExecuteAsync(
@"INSERT INTO Tag (Name, ExternalCollectionId, EpisodeMetadataId)
SELECT @Name, @ItemId, Id FROM
(SELECT Id FROM EpisodeMetadata WHERE EpisodeId = @EpisodeId)",
new { collection.Name, collection.ItemId, EpisodeId = episodeId });
return episodeId;
default:
return 0;
}
}
public async Task<bool> SetEtag(EmbyCollection collection)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
@"UPDATE EmbyCollection SET Etag = @Etag WHERE ItemId = @ItemId",
new { collection.Etag, collection.ItemId }) > 0;
}
}

1
ErsatzTV.Infrastructure/Data/Repositories/EmbyTelevisionRepository.cs

@ -136,6 +136,7 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository @@ -136,6 +136,7 @@ public class EmbyTelevisionRepository : IEmbyTelevisionRepository
// tags
foreach (Tag tag in metadata.Tags
.Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name))
.Filter(g => g.ExternalCollectionId is null)
.ToList())
{
metadata.Tags.Remove(tag);

148
ErsatzTV.Infrastructure/Data/Repositories/JellyfinCollectionRepository.cs

@ -0,0 +1,148 @@ @@ -0,0 +1,148 @@
using Dapper;
using ErsatzTV.Core.Domain;
using ErsatzTV.Core.Interfaces.Repositories;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Infrastructure.Data.Repositories;
public class JellyfinCollectionRepository : IJellyfinCollectionRepository
{
private readonly IDbContextFactory<TvContext> _dbContextFactory;
public JellyfinCollectionRepository(IDbContextFactory<TvContext> dbContextFactory) =>
_dbContextFactory = dbContextFactory;
public async Task<List<JellyfinCollection>> GetCollections()
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.JellyfinCollections.ToListAsync();
}
public async Task<bool> AddCollection(JellyfinCollection collection)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
await dbContext.AddAsync(collection);
return await dbContext.SaveChangesAsync() > 0;
}
public async Task<bool> RemoveCollection(JellyfinCollection collection)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
dbContext.Remove(collection);
// remove all tags that reference this collection
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM Tag WHERE Name = @Name AND ExternalCollectionId = @ItemId",
new { collection.Name, collection.ItemId });
return await dbContext.SaveChangesAsync() > 0;
}
public async Task<List<int>> RemoveAllTags(JellyfinCollection collection)
{
var result = new List<int>();
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
// movies
result.AddRange(
await dbContext.Connection.QueryAsync<int>(
@"SELECT JM.Id FROM Tag T
INNER JOIN MovieMetadata MM on T.MovieMetadataId = MM.Id
INNER JOIN JellyfinMovie JM on JM.Id = MM.MovieId
WHERE T.ExternalCollectionId = @ItemId",
new { collection.ItemId }));
// shows
result.AddRange(
await dbContext.Connection.QueryAsync<int>(
@"SELECT JS.Id FROM Tag T
INNER JOIN ShowMetadata SM on T.ShowMetadataId = SM.Id
INNER JOIN JellyfinShow JS on JS.Id = SM.ShowId
WHERE T.ExternalCollectionId = @ItemId",
new { collection.ItemId }));
// seasons
result.AddRange(
await dbContext.Connection.QueryAsync<int>(
@"SELECT JS.Id FROM Tag T
INNER JOIN SeasonMetadata SM on T.SeasonMetadataId = SM.Id
INNER JOIN JellyfinSeason JS on JS.Id = SM.SeasonId
WHERE T.ExternalCollectionId = @ItemId",
new { collection.ItemId }));
// episodes
result.AddRange(
await dbContext.Connection.QueryAsync<int>(
@"SELECT JE.Id FROM Tag T
INNER JOIN EpisodeMetadata EM on T.EpisodeMetadataId = EM.Id
INNER JOIN JellyfinEpisode JE on JE.Id = EM.EpisodeId
WHERE T.ExternalCollectionId = @ItemId",
new { collection.ItemId }));
// delete all tags
await dbContext.Connection.ExecuteAsync(
@"DELETE FROM Tag WHERE Name = @Name AND ExternalCollectionId = @ItemId",
new { collection.Name, collection.ItemId });
return result;
}
public async Task<int> AddTag(MediaItem item, JellyfinCollection collection)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
switch (item)
{
case JellyfinMovie movie:
int movieId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT Id FROM JellyfinMovie WHERE ItemId = @ItemId",
new { movie.ItemId });
await dbContext.Connection.ExecuteAsync(
@"INSERT INTO Tag (Name, ExternalCollectionId, MovieMetadataId)
SELECT @Name, @ItemId, Id FROM
(SELECT Id FROM MovieMetadata WHERE MovieId = @MovieId)",
new { collection.Name, collection.ItemId, MovieId = movieId });
return movieId;
case JellyfinShow show:
int showId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT Id FROM JellyfinShow WHERE ItemId = @ItemId",
new { show.ItemId });
await dbContext.Connection.ExecuteAsync(
@"INSERT INTO Tag (Name, ExternalCollectionId, ShowMetadataId)
SELECT @Name, @ItemId, Id FROM
(SELECT Id FROM ShowMetadata WHERE ShowId = @ShowId)",
new { collection.Name, collection.ItemId, ShowId = showId });
return showId;
case JellyfinSeason season:
int seasonId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT Id FROM JellyfinSeason WHERE ItemId = @ItemId",
new { season.ItemId });
await dbContext.Connection.ExecuteAsync(
@"INSERT INTO Tag (Name, ExternalCollectionId, SeasonMetadataId)
SELECT @Name, @ItemId, Id FROM
(SELECT Id FROM SeasonMetadata WHERE SeasonId = @SeasonId)",
new { collection.Name, collection.ItemId, SeasonId = seasonId });
return seasonId;
case JellyfinEpisode episode:
int episodeId = await dbContext.Connection.ExecuteScalarAsync<int>(
@"SELECT Id FROM JellyfinEpisode WHERE ItemId = @ItemId",
new { episode.ItemId });
await dbContext.Connection.ExecuteAsync(
@"INSERT INTO Tag (Name, ExternalCollectionId, EpisodeMetadataId)
SELECT @Name, @ItemId, Id FROM
(SELECT Id FROM EpisodeMetadata WHERE EpisodeId = @EpisodeId)",
new { collection.Name, collection.ItemId, EpisodeId = episodeId });
return episodeId;
default:
return 0;
}
}
public async Task<bool> SetEtag(JellyfinCollection collection)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
@"UPDATE JellyfinCollection SET Etag = @Etag WHERE ItemId = @ItemId",
new { collection.Etag, collection.ItemId }) > 0;
}
}

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

@ -136,6 +136,7 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository @@ -136,6 +136,7 @@ public class JellyfinTelevisionRepository : IJellyfinTelevisionRepository
// tags
foreach (Tag tag in metadata.Tags
.Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name))
.Filter(g => g.ExternalCollectionId is null)
.ToList())
{
metadata.Tags.Remove(tag);

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

@ -552,7 +552,9 @@ public class MetadataRepository : IMetadataRepository @@ -552,7 +552,9 @@ public class MetadataRepository : IMetadataRepository
public async Task<bool> RemoveTag(Tag tag)
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync("DELETE FROM Tag WHERE Id = @TagId", new { TagId = tag.Id })
return await dbContext.Connection.ExecuteAsync(
"DELETE FROM Tag WHERE Id = @TagId AND ExternalCollectionId = @ExternalCollectionId",
new { TagId = tag.Id, tag.ExternalCollectionId })
.Map(result => result > 0);
}

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

@ -202,8 +202,8 @@ public class MovieRepository : IMovieRepository @@ -202,8 +202,8 @@ public class MovieRepository : IMovieRepository
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"INSERT INTO Tag (Name, MovieMetadataId) VALUES (@Name, @MetadataId)",
new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0);
"INSERT INTO Tag (Name, MovieMetadataId, ExternalCollectionId) VALUES (@Name, @MetadataId, @ExternalCollectionId)",
new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId }).Map(result => result > 0);
}
public async Task<bool> AddStudio(MovieMetadata metadata, Studio studio)
@ -399,6 +399,7 @@ public class MovieRepository : IMovieRepository @@ -399,6 +399,7 @@ public class MovieRepository : IMovieRepository
// tags
foreach (Tag tag in metadata.Tags
.Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name))
.Filter(g => g.ExternalCollectionId is null)
.ToList())
{
metadata.Tags.Remove(tag);
@ -660,6 +661,7 @@ public class MovieRepository : IMovieRepository @@ -660,6 +661,7 @@ public class MovieRepository : IMovieRepository
// tags
foreach (Tag tag in metadata.Tags
.Filter(g => incomingMetadata.Tags.All(g2 => g2.Name != g.Name))
.Filter(g => g.ExternalCollectionId is null)
.ToList())
{
metadata.Tags.Remove(tag);

4
ErsatzTV.Infrastructure/Data/Repositories/MusicVideoRepository.cs

@ -112,8 +112,8 @@ public class MusicVideoRepository : IMusicVideoRepository @@ -112,8 +112,8 @@ public class MusicVideoRepository : IMusicVideoRepository
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"INSERT INTO Tag (Name, MusicVideoMetadataId) VALUES (@Name, @MetadataId)",
new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0);
"INSERT INTO Tag (Name, MusicVideoMetadataId, ExternalCollectionId) VALUES (@Name, @MetadataId, @ExternalCollectionId)",
new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId }).Map(result => result > 0);
}
public async Task<bool> AddStudio(MusicVideoMetadata metadata, Studio studio)

4
ErsatzTV.Infrastructure/Data/Repositories/OtherVideoRepository.cs

@ -86,8 +86,8 @@ public class OtherVideoRepository : IOtherVideoRepository @@ -86,8 +86,8 @@ public class OtherVideoRepository : IOtherVideoRepository
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"INSERT INTO Tag (Name, OtherVideoMetadataId) VALUES (@Name, @MetadataId)",
new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0);
"INSERT INTO Tag (Name, OtherVideoMetadataId, ExternalCollectionId) VALUES (@Name, @MetadataId, @ExternalCollectionId)",
new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId }).Map(result => result > 0);
}
public async Task<List<OtherVideoMetadata>> GetOtherVideosForCards(List<int> ids)

4
ErsatzTV.Infrastructure/Data/Repositories/SongRepository.cs

@ -99,8 +99,8 @@ public class SongRepository : ISongRepository @@ -99,8 +99,8 @@ public class SongRepository : ISongRepository
{
await using TvContext dbContext = await _dbContextFactory.CreateDbContextAsync();
return await dbContext.Connection.ExecuteAsync(
"INSERT INTO Tag (Name, SongMetadataId) VALUES (@Name, @MetadataId)",
new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0);
"INSERT INTO Tag (Name, SongMetadataId, ExternalCollectionId) VALUES (@Name, @MetadataId, @ExternalCollectionId)",
new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId }).Map(result => result > 0);
}
public async Task<List<SongMetadata>> GetSongsForCards(List<int> ids)

12
ErsatzTV.Infrastructure/Data/Repositories/TelevisionRepository.cs

@ -700,16 +700,16 @@ public class TelevisionRepository : ITelevisionRepository @@ -700,16 +700,16 @@ public class TelevisionRepository : ITelevisionRepository
{
case ShowMetadata:
return await dbContext.Connection.ExecuteAsync(
"INSERT INTO Tag (Name, ShowMetadataId) VALUES (@Name, @MetadataId)",
new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0);
"INSERT INTO Tag (Name, ShowMetadataId, ExternalCollectionId) VALUES (@Name, @MetadataId, @ExternalCollectionId)",
new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId }).Map(result => result > 0);
case SeasonMetadata:
return await dbContext.Connection.ExecuteAsync(
"INSERT INTO Tag (Name, SeasonMetadataId) VALUES (@Name, @MetadataId)",
new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0);
"INSERT INTO Tag (Name, SeasonMetadataId, ExternalCollectionId) VALUES (@Name, @MetadataId, @ExternalCollectionId)",
new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId }).Map(result => result > 0);
case EpisodeMetadata:
return await dbContext.Connection.ExecuteAsync(
"INSERT INTO Tag (Name, EpisodeMetadataId) VALUES (@Name, @MetadataId)",
new { tag.Name, MetadataId = metadata.Id }).Map(result => result > 0);
"INSERT INTO Tag (Name, EpisodeMetadataId, ExternalCollectionId) VALUES (@Name, @MetadataId, @ExternalCollectionId)",
new { tag.Name, MetadataId = metadata.Id, tag.ExternalCollectionId }).Map(result => result > 0);
default:
return false;
}

2
ErsatzTV.Infrastructure/Data/TvContext.cs

@ -63,10 +63,12 @@ public class TvContext : DbContext @@ -63,10 +63,12 @@ public class TvContext : DbContext
public DbSet<JellyfinShow> JellyfinShows { get; set; }
public DbSet<JellyfinSeason> JellyfinSeasons { get; set; }
public DbSet<JellyfinEpisode> JellyfinEpisodes { get; set; }
public DbSet<JellyfinCollection> JellyfinCollections { get; set; }
public DbSet<EmbyMovie> EmbyMovies { get; set; }
public DbSet<EmbyShow> EmbyShows { get; set; }
public DbSet<EmbySeason> EmbySeasons { get; set; }
public DbSet<EmbyEpisode> EmbyEpisodes { get; set; }
public DbSet<EmbyCollection> EmbyCollections { get; set; }
public DbSet<Collection> Collections { get; set; }
public DbSet<CollectionItem> CollectionItems { get; set; }
public DbSet<MultiCollection> MultiCollections { get; set; }

105
ErsatzTV.Infrastructure/Emby/EmbyApiClient.cs

@ -4,6 +4,7 @@ using ErsatzTV.Core.Emby; @@ -4,6 +4,7 @@ using ErsatzTV.Core.Emby;
using ErsatzTV.Core.Interfaces.Emby;
using ErsatzTV.Core.Interfaces.Metadata;
using ErsatzTV.Infrastructure.Emby.Models;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Refit;
@ -13,10 +14,15 @@ public class EmbyApiClient : IEmbyApiClient @@ -13,10 +14,15 @@ public class EmbyApiClient : IEmbyApiClient
{
private readonly IFallbackMetadataProvider _fallbackMetadataProvider;
private readonly ILogger<EmbyApiClient> _logger;
private readonly IMemoryCache _memoryCache;
public EmbyApiClient(IFallbackMetadataProvider fallbackMetadataProvider, ILogger<EmbyApiClient> logger)
public EmbyApiClient(
IFallbackMetadataProvider fallbackMetadataProvider,
IMemoryCache memoryCache,
ILogger<EmbyApiClient> logger)
{
_fallbackMetadataProvider = fallbackMetadataProvider;
_memoryCache = memoryCache;
_logger = logger;
}
@ -65,7 +71,6 @@ public class EmbyApiClient : IEmbyApiClient @@ -65,7 +71,6 @@ public class EmbyApiClient : IEmbyApiClient
public async Task<Either<BaseError, List<EmbyMovie>>> GetMovieLibraryItems(
string address,
string apiKey,
int mediaSourceId,
string libraryId)
{
try
@ -87,7 +92,6 @@ public class EmbyApiClient : IEmbyApiClient @@ -87,7 +92,6 @@ public class EmbyApiClient : IEmbyApiClient
public async Task<Either<BaseError, List<EmbyShow>>> GetShowLibraryItems(
string address,
string apiKey,
int mediaSourceId,
string libraryId)
{
try
@ -109,7 +113,6 @@ public class EmbyApiClient : IEmbyApiClient @@ -109,7 +113,6 @@ public class EmbyApiClient : IEmbyApiClient
public async Task<Either<BaseError, List<EmbySeason>>> GetSeasonLibraryItems(
string address,
string apiKey,
int mediaSourceId,
string showId)
{
try
@ -131,7 +134,6 @@ public class EmbyApiClient : IEmbyApiClient @@ -131,7 +134,6 @@ public class EmbyApiClient : IEmbyApiClient
public async Task<Either<BaseError, List<EmbyEpisode>>> GetEpisodeLibraryItems(
string address,
string apiKey,
int mediaSourceId,
string seasonId)
{
try
@ -150,7 +152,91 @@ public class EmbyApiClient : IEmbyApiClient @@ -150,7 +152,91 @@ public class EmbyApiClient : IEmbyApiClient
}
}
private static Option<EmbyLibrary> Project(EmbyLibraryResponse response) =>
public async Task<Either<BaseError, List<EmbyCollection>>> GetCollectionLibraryItems(string address, string apiKey)
{
try
{
// TODO: should we enumerate collection libraries here?
if (_memoryCache.TryGetValue("emby_collections_library_item_id", out string itemId))
{
IEmbyApi service = RestService.For<IEmbyApi>(address);
EmbyLibraryItemsResponse items = await service.GetCollectionLibraryItems(apiKey, itemId);
return items.Items
.Map(ProjectToCollection)
.Somes()
.ToList();
}
return BaseError.New("Emby collection item id is not available");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting Emby collection library items");
return BaseError.New(ex.Message);
}
}
public async Task<Either<BaseError, List<MediaItem>>> GetCollectionItems(
string address,
string apiKey,
string collectionId)
{
try
{
IEmbyApi service = RestService.For<IEmbyApi>(address);
EmbyLibraryItemsResponse items = await service.GetCollectionItems(apiKey, collectionId);
return items.Items
.Map(ProjectToCollectionMediaItem)
.Somes()
.ToList();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting Emby collection items");
return BaseError.New(ex.Message);
}
}
private Option<EmbyCollection> ProjectToCollection(EmbyLibraryItemResponse item)
{
try
{
return new EmbyCollection
{
ItemId = item.Id,
Etag = item.Etag,
Name = item.Name
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error projecting Emby collection");
return None;
}
}
private Option<MediaItem> ProjectToCollectionMediaItem(EmbyLibraryItemResponse item)
{
try
{
return item.Type switch
{
"Movie" => new EmbyMovie { ItemId = item.Id },
"Series" => new EmbyShow { ItemId = item.Id },
"Season" => new EmbySeason { ItemId = item.Id },
"Episode" => new EmbyEpisode { ItemId = item.Id },
_ => None
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error projecting Emby collection media item");
return None;
}
}
private Option<EmbyLibrary> Project(EmbyLibraryResponse response) =>
response.CollectionType?.ToLowerInvariant() switch
{
"tvshows" => new EmbyLibrary
@ -170,9 +256,16 @@ public class EmbyApiClient : IEmbyApiClient @@ -170,9 +256,16 @@ public class EmbyApiClient : IEmbyApiClient
Paths = new List<LibraryPath> { new() { Path = $"emby://{response.ItemId}" } }
},
// TODO: ??? for music libraries
"boxsets" => CacheCollectionLibraryId(response.ItemId),
_ => None
};
private Option<EmbyLibrary> CacheCollectionLibraryId(string itemId)
{
_memoryCache.Set("emby_collections_library_item_id", itemId);
return None;
}
private Option<EmbyMovie> ProjectToMovie(EmbyLibraryItemResponse item)
{
try

28
ErsatzTV.Infrastructure/Emby/IEmbyApi.cs

@ -75,4 +75,32 @@ public interface IEmbyApi @@ -75,4 +75,32 @@ public interface IEmbyApi
string excludeLocationTypes = "Virtual",
[Query]
bool recursive = true);
[Get("/Items")]
public Task<EmbyLibraryItemsResponse> GetCollectionLibraryItems(
[Header("X-Emby-Token")]
string apiKey,
[Query]
string parentId,
[Query]
string fields = "Etag",
[Query]
string includeItemTypes = "BoxSet",
[Query]
bool recursive = true);
[Get("/Items")]
public Task<EmbyLibraryItemsResponse> GetCollectionItems(
[Header("X-Emby-Token")]
string apiKey,
[Query]
string parentId,
[Query]
string fields = "Etag",
[Query]
string includeItemTypes = "Movie,Series,Season,Episode",
[Query]
string excludeLocationTypes = "Virtual",
[Query]
bool recursive = true);
}

1
ErsatzTV.Infrastructure/Emby/Models/EmbyLibraryItemResponse.cs

@ -24,4 +24,5 @@ public class EmbyLibraryItemResponse @@ -24,4 +24,5 @@ public class EmbyLibraryItemResponse
public EmbyImageTagsResponse ImageTags { get; set; }
public List<string> BackdropImageTags { get; set; }
public int? IndexNumber { get; set; }
public string Type { get; set; }
}

30
ErsatzTV.Infrastructure/Jellyfin/IJellyfinApi.cs

@ -83,4 +83,34 @@ public interface IJellyfinApi @@ -83,4 +83,34 @@ public interface IJellyfinApi
string includeItemTypes = "Episode",
[Query]
bool recursive = true);
[Get("/Items")]
public Task<JellyfinLibraryItemsResponse> GetCollectionLibraryItems(
[Header("X-Emby-Token")]
string apiKey,
[Query]
string userId,
[Query]
string parentId,
[Query]
string fields = "Etag",
[Query]
string includeItemTypes = "BoxSet",
[Query]
bool recursive = true);
[Get("/Items")]
public Task<JellyfinLibraryItemsResponse> GetCollectionItems(
[Header("X-Emby-Token")]
string apiKey,
[Query]
string userId,
[Query]
string parentId,
[Query]
string fields = "Etag",
[Query]
string includeItemTypes = "Movie,Series,Season,Episode",
[Query]
bool recursive = true);
}

113
ErsatzTV.Infrastructure/Jellyfin/JellyfinApiClient.cs

@ -196,6 +196,101 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -196,6 +196,101 @@ public class JellyfinApiClient : IJellyfinApiClient
}
}
public async Task<Either<BaseError, List<JellyfinCollection>>> GetCollectionLibraryItems(
string address,
string apiKey,
int mediaSourceId)
{
try
{
if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{mediaSourceId}", out string userId))
{
// TODO: should we enumerate collection libraries here?
if (_memoryCache.TryGetValue("jellyfin_collections_library_item_id", out string itemId))
{
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
JellyfinLibraryItemsResponse items =
await service.GetCollectionLibraryItems(apiKey, userId, itemId);
return items.Items
.Map(ProjectToCollection)
.Somes()
.ToList();
}
return BaseError.New("Jellyfin collection item id is not available");
}
return BaseError.New("Jellyfin admin user id is not available");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting jellyfin collection library items");
return BaseError.New(ex.Message);
}
}
public async Task<Either<BaseError, List<MediaItem>>> GetCollectionItems(
string address,
string apiKey,
int mediaSourceId,
string collectionId)
{
try
{
if (_memoryCache.TryGetValue($"jellyfin_admin_user_id.{mediaSourceId}", out string userId))
{
IJellyfinApi service = RestService.For<IJellyfinApi>(address);
JellyfinLibraryItemsResponse items = await service.GetCollectionItems(
apiKey,
userId,
collectionId);
return items.Items
.Map(ProjectToCollectionMediaItem)
.Somes()
.ToList();
}
return BaseError.New("Jellyfin admin user id is not available");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting jellyfin collection items");
return BaseError.New(ex.Message);
}
}
private Option<MediaItem> ProjectToCollectionMediaItem(JellyfinLibraryItemResponse item)
{
try
{
if (item.LocationType != "FileSystem")
{
return None;
}
if (Path.GetExtension(item.Path)?.ToLowerInvariant() == ".strm")
{
_logger.LogWarning("STRM files are not supported; skipping {Path}", item.Path);
return None;
}
return item.Type switch
{
"Movie" => new JellyfinMovie { ItemId = item.Id },
"Series" => new JellyfinShow { ItemId = item.Id },
"Season" => new JellyfinSeason { ItemId = item.Id },
"Episode" => new JellyfinEpisode { ItemId = item.Id },
_ => None
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error projecting Jellyfin collection media item");
return None;
}
}
private Option<JellyfinLibrary> Project(JellyfinLibraryResponse response) =>
response.CollectionType?.ToLowerInvariant() switch
{
@ -534,6 +629,24 @@ public class JellyfinApiClient : IJellyfinApiClient @@ -534,6 +629,24 @@ public class JellyfinApiClient : IJellyfinApiClient
}
}
private Option<JellyfinCollection> ProjectToCollection(JellyfinLibraryItemResponse item)
{
try
{
return new JellyfinCollection
{
ItemId = item.Id,
Etag = item.Etag,
Name = item.Name
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error projecting Jellyfin collection");
return None;
}
}
private Option<JellyfinEpisode> ProjectToEpisode(JellyfinLibraryItemResponse item)
{
try

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

@ -23,4 +23,5 @@ public class JellyfinLibraryItemResponse @@ -23,4 +23,5 @@ public class JellyfinLibraryItemResponse
public JellyfinImageTagsResponse ImageTags { get; set; }
public List<string> BackdropImageTags { get; set; }
public int? IndexNumber { get; set; }
public string Type { get; set; }
}

4102
ErsatzTV.Infrastructure/Migrations/20220422151729_Add_JellyfinCollection.Designer.cs generated

File diff suppressed because it is too large Load Diff

43
ErsatzTV.Infrastructure/Migrations/20220422151729_Add_JellyfinCollection.cs

@ -0,0 +1,43 @@ @@ -0,0 +1,43 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_JellyfinCollection : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ExternalCollectionId",
table: "Tag",
type: "TEXT",
nullable: true);
migrationBuilder.CreateTable(
name: "JellyfinCollection",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ItemId = table.Column<string>(type: "TEXT", nullable: true),
Etag = table.Column<string>(type: "TEXT", nullable: true),
Name = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_JellyfinCollection", x => x.Id);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "JellyfinCollection");
migrationBuilder.DropColumn(
name: "ExternalCollectionId",
table: "Tag");
}
}
}

4122
ErsatzTV.Infrastructure/Migrations/20220422170757_Add_EmbyCollection.Designer.cs generated

File diff suppressed because it is too large Load Diff

33
ErsatzTV.Infrastructure/Migrations/20220422170757_Add_EmbyCollection.cs

@ -0,0 +1,33 @@ @@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ErsatzTV.Infrastructure.Migrations
{
public partial class Add_EmbyCollection : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "EmbyCollection",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
ItemId = table.Column<string>(type: "TEXT", nullable: true),
Etag = table.Column<string>(type: "TEXT", nullable: true),
Name = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_EmbyCollection", x => x.Id);
});
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "EmbyCollection");
}
}
}

43
ErsatzTV.Infrastructure/Migrations/TvContextModelSnapshot.cs

@ -397,6 +397,26 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -397,6 +397,26 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("Director", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyCollection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Etag")
.HasColumnType("TEXT");
b.Property<string>("ItemId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("EmbyCollection", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.EmbyConnection", b =>
{
b.Property<int>("Id")
@ -664,6 +684,26 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -664,6 +684,26 @@ namespace ErsatzTV.Infrastructure.Migrations
b.ToTable("Genre");
});
modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinCollection", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Etag")
.HasColumnType("TEXT");
b.Property<string>("ItemId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("JellyfinCollection", (string)null);
});
modelBuilder.Entity("ErsatzTV.Core.Domain.JellyfinConnection", b =>
{
b.Property<int>("Id")
@ -1915,6 +1955,9 @@ namespace ErsatzTV.Infrastructure.Migrations @@ -1915,6 +1955,9 @@ namespace ErsatzTV.Infrastructure.Migrations
b.Property<int?>("EpisodeMetadataId")
.HasColumnType("INTEGER");
b.Property<string>("ExternalCollectionId")
.HasColumnType("TEXT");
b.Property<int?>("MovieMetadataId")
.HasColumnType("INTEGER");

12
ErsatzTV.Infrastructure/Plex/PlexServerApiClient.cs

@ -399,12 +399,12 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -399,12 +399,12 @@ public class PlexServerApiClient : IPlexServerApiClient
foreach (PlexCollectionResponse collection in Optional(response.Collection).Flatten())
{
metadata.Tags.Add(new Tag { Name = collection.Tag });
metadata.Tags.Add(new Tag { Name = collection.Tag, ExternalCollectionId = collection.Id.ToString() });
}
foreach (PlexLabelResponse label in Optional(response.Label).Flatten())
{
metadata.Tags.Add(new Tag { Name = label.Tag });
metadata.Tags.Add(new Tag { Name = label.Tag, ExternalCollectionId = label.Id.ToString() });
}
if (!string.IsNullOrWhiteSpace(response.Studio))
@ -586,12 +586,12 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -586,12 +586,12 @@ public class PlexServerApiClient : IPlexServerApiClient
foreach (PlexCollectionResponse collection in Optional(response.Collection).Flatten())
{
metadata.Tags.Add(new Tag { Name = collection.Tag });
metadata.Tags.Add(new Tag { Name = collection.Tag, ExternalCollectionId = collection.Id.ToString() });
}
foreach (PlexLabelResponse label in Optional(response.Label).Flatten())
{
metadata.Tags.Add(new Tag { Name = label.Tag });
metadata.Tags.Add(new Tag { Name = label.Tag, ExternalCollectionId = label.Id.ToString() });
}
if (DateTime.TryParse(response.OriginallyAvailableAt, out DateTime releaseDate))
@ -663,7 +663,7 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -663,7 +663,7 @@ public class PlexServerApiClient : IPlexServerApiClient
foreach (PlexCollectionResponse collection in Optional(response.Collection).Flatten())
{
metadata.Tags.Add(new Tag { Name = collection.Tag });
metadata.Tags.Add(new Tag { Name = collection.Tag, ExternalCollectionId = collection.Id.ToString() });
}
if (!string.IsNullOrWhiteSpace(response.Thumb))
@ -799,7 +799,7 @@ public class PlexServerApiClient : IPlexServerApiClient @@ -799,7 +799,7 @@ public class PlexServerApiClient : IPlexServerApiClient
foreach (PlexCollectionResponse collection in Optional(response.Collection).Flatten())
{
metadata.Tags.Add(new Tag { Name = collection.Tag });
metadata.Tags.Add(new Tag { Name = collection.Tag, ExternalCollectionId = collection.Id.ToString() });
}
if (!string.IsNullOrWhiteSpace(response.Thumb))

19
ErsatzTV/Services/EmbyService.cs

@ -61,6 +61,9 @@ public class EmbyService : BackgroundService @@ -61,6 +61,9 @@ public class EmbyService : BackgroundService
break;
default:
throw new NotSupportedException($"Unsupported request type: {request.GetType().Name}");
case SynchronizeEmbyCollections synchronizeEmbyCollections:
requestTask = SynchronizeEmbyCollections(synchronizeEmbyCollections, cancellationToken);
break;
}
await requestTask;
@ -165,4 +168,20 @@ public class EmbyService : BackgroundService @@ -165,4 +168,20 @@ public class EmbyService : BackgroundService
request.EmbyLibraryId,
error.Value));
}
private async Task SynchronizeEmbyCollections(
SynchronizeEmbyCollections request,
CancellationToken cancellationToken)
{
using IServiceScope scope = _serviceScopeFactory.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
Either<BaseError, Unit> result = await mediator.Send(request, cancellationToken);
result.BiIter(
_ => _logger.LogDebug("Done synchronizing emby collections"),
error => _logger.LogWarning(
"Unable to synchronize emby collections for source {MediaSourceId}: {Error}",
request.EmbyMediaSourceId,
error.Value));
}
}

21
ErsatzTV/Services/JellyfinService.cs

@ -59,6 +59,11 @@ public class JellyfinService : BackgroundService @@ -59,6 +59,11 @@ public class JellyfinService : BackgroundService
case ISynchronizeJellyfinLibraryById synchronizeJellyfinLibraryById:
requestTask = SynchronizeJellyfinLibrary(synchronizeJellyfinLibraryById, cancellationToken);
break;
case SynchronizeJellyfinCollections synchronizeJellyfinCollections:
requestTask = SynchronizeJellyfinCollections(
synchronizeJellyfinCollections,
cancellationToken);
break;
default:
throw new NotSupportedException($"Unsupported request type: {request.GetType().Name}");
}
@ -165,4 +170,20 @@ public class JellyfinService : BackgroundService @@ -165,4 +170,20 @@ public class JellyfinService : BackgroundService
request.JellyfinLibraryId,
error.Value));
}
private async Task SynchronizeJellyfinCollections(
SynchronizeJellyfinCollections request,
CancellationToken cancellationToken)
{
using IServiceScope scope = _serviceScopeFactory.CreateScope();
IMediator mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
Either<BaseError, Unit> result = await mediator.Send(request, cancellationToken);
result.BiIter(
_ => _logger.LogDebug("Done synchronizing jellyfin collections"),
error => _logger.LogWarning(
"Unable to synchronize jellyfin collections for source {MediaSourceId}: {Error}",
request.JellyfinMediaSourceId,
error.Value));
}
}

4
ErsatzTV/Startup.cs

@ -374,14 +374,18 @@ public class Startup @@ -374,14 +374,18 @@ public class Startup
services.AddScoped<IPlexServerApiClient, PlexServerApiClient>();
services.AddScoped<IJellyfinMovieLibraryScanner, JellyfinMovieLibraryScanner>();
services.AddScoped<IJellyfinTelevisionLibraryScanner, JellyfinTelevisionLibraryScanner>();
services.AddScoped<IJellyfinCollectionScanner, JellyfinCollectionScanner>();
services.AddScoped<IJellyfinApiClient, JellyfinApiClient>();
services.AddScoped<IJellyfinPathReplacementService, JellyfinPathReplacementService>();
services.AddScoped<IJellyfinTelevisionRepository, JellyfinTelevisionRepository>();
services.AddScoped<IJellyfinCollectionRepository, JellyfinCollectionRepository>();
services.AddScoped<IEmbyApiClient, EmbyApiClient>();
services.AddScoped<IEmbyMovieLibraryScanner, EmbyMovieLibraryScanner>();
services.AddScoped<IEmbyTelevisionLibraryScanner, EmbyTelevisionLibraryScanner>();
services.AddScoped<IEmbyCollectionScanner, EmbyCollectionScanner>();
services.AddScoped<IEmbyPathReplacementService, EmbyPathReplacementService>();
services.AddScoped<IEmbyTelevisionRepository, EmbyTelevisionRepository>();
services.AddScoped<IEmbyCollectionRepository, EmbyCollectionRepository>();
services.AddScoped<IRuntimeInfo, RuntimeInfo>();
services.AddScoped<IPlexPathReplacementService, PlexPathReplacementService>();
services.AddScoped<IFFmpegStreamSelector, FFmpegStreamSelector>();

Loading…
Cancel
Save