mirror of https://github.com/ErsatzTV/ErsatzTV.git
				
				
			
				 49 changed files with 9538 additions and 68 deletions
			
			
		@ -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; | 
				
			||||
@ -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; } | 
				
			||||
    } | 
				
			||||
} | 
				
			||||
@ -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; | 
				
			||||
@ -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; } | 
				
			||||
    } | 
				
			||||
} | 
				
			||||
@ -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; } | 
				
			||||
} | 
				
			||||
@ -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; } | 
				
			||||
} | 
				
			||||
@ -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(); | 
				
			||||
    } | 
				
			||||
} | 
				
			||||
@ -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); | 
				
			||||
} | 
				
			||||
@ -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); | 
				
			||||
} | 
				
			||||
@ -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); | 
				
			||||
} | 
				
			||||
@ -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); | 
				
			||||
} | 
				
			||||
@ -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(); | 
				
			||||
    } | 
				
			||||
} | 
				
			||||
@ -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"); | 
				
			||||
} | 
				
			||||
@ -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"); | 
				
			||||
} | 
				
			||||
@ -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; | 
				
			||||
    } | 
				
			||||
} | 
				
			||||
@ -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; | 
				
			||||
    } | 
				
			||||
} | 
				
			||||
									
										
											File diff suppressed because it is too large
											Load Diff
										
									
								
							
						@ -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"); | 
				
			||||
        } | 
				
			||||
    } | 
				
			||||
} | 
				
			||||
									
										
											File diff suppressed because it is too large
											Load Diff
										
									
								
							
						@ -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"); | 
				
			||||
        } | 
				
			||||
    } | 
				
			||||
} | 
				
			||||
					Loading…
					
					
				
		Reference in new issue