- `CustomFormatCache` is now a utility class for updating cache entries. - `CustomFormatCacheData` is now what `CustomFormatCache` used to be (data object used for serialization). - `CustomFormatCachePersister` is now specific to custom formats. Future cache types will have their own persister implementation.pull/231/head
parent
7f6a5a2ff6
commit
e99f4cb766
@ -1,35 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using Recyclarr.Config.Models;
|
|
||||||
|
|
||||||
namespace Recyclarr.Cli.Cache;
|
|
||||||
|
|
||||||
public class CachePersister(ILogger log, IServiceCache serviceCache) : ICachePersister
|
|
||||||
{
|
|
||||||
public CustomFormatCache Load(IServiceConfiguration config)
|
|
||||||
{
|
|
||||||
var cache = serviceCache.Load<CustomFormatCache>(config);
|
|
||||||
if (cache == null)
|
|
||||||
{
|
|
||||||
log.Debug("Custom format cache does not exist; proceeding without it");
|
|
||||||
return new CustomFormatCache();
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the version is higher OR lower, we invalidate the cache. It means there's an
|
|
||||||
// incompatibility that we do not support.
|
|
||||||
if (cache.Version != CustomFormatCache.LatestVersion)
|
|
||||||
{
|
|
||||||
log.Information("Cache version mismatch ({OldVersion} vs {LatestVersion}); ignoring cache data",
|
|
||||||
cache.Version, CustomFormatCache.LatestVersion);
|
|
||||||
throw new CacheException("Version mismatch");
|
|
||||||
}
|
|
||||||
|
|
||||||
return cache;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Save(IServiceConfiguration config, CustomFormatCache cache)
|
|
||||||
{
|
|
||||||
log.Debug("Saving Cache with {Mappings}", JsonSerializer.Serialize(cache.TrashIdMappings));
|
|
||||||
|
|
||||||
serviceCache.Save(cache, config);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,64 +0,0 @@
|
|||||||
using System.Diagnostics.CodeAnalysis;
|
|
||||||
using Recyclarr.Cli.Pipelines.CustomFormat;
|
|
||||||
using Recyclarr.TrashGuide.CustomFormat;
|
|
||||||
|
|
||||||
namespace Recyclarr.Cli.Cache;
|
|
||||||
|
|
||||||
[CacheObjectName("custom-format-cache")]
|
|
||||||
public record CustomFormatCache
|
|
||||||
{
|
|
||||||
public const int LatestVersion = 1;
|
|
||||||
|
|
||||||
public int Version { get; init; } = LatestVersion;
|
|
||||||
|
|
||||||
[SuppressMessage("ReSharper", "UnusedAutoPropertyAccessor.Global")]
|
|
||||||
public string? InstanceName { get; init; }
|
|
||||||
|
|
||||||
public IReadOnlyList<TrashIdMapping> TrashIdMappings { get; init; } = new List<TrashIdMapping>();
|
|
||||||
|
|
||||||
public CustomFormatCache Update(CustomFormatTransactionData transactions)
|
|
||||||
{
|
|
||||||
// Assume that RemoveStale() is called before this method, and that TrashIdMappings contains existing CFs
|
|
||||||
// in the remote service that we want to keep and update.
|
|
||||||
|
|
||||||
var existingCfs = transactions.UpdatedCustomFormats
|
|
||||||
.Concat(transactions.UnchangedCustomFormats)
|
|
||||||
.Concat(transactions.NewCustomFormats);
|
|
||||||
|
|
||||||
return this with
|
|
||||||
{
|
|
||||||
TrashIdMappings = TrashIdMappings
|
|
||||||
.DistinctBy(x => x.CustomFormatId)
|
|
||||||
.Where(x => transactions.DeletedCustomFormats.All(y => y.CustomFormatId != x.CustomFormatId))
|
|
||||||
.FullOuterJoin(existingCfs, JoinType.Hash,
|
|
||||||
l => l.CustomFormatId,
|
|
||||||
r => r.Id,
|
|
||||||
// Keep existing service CFs, even if they aren't in user config
|
|
||||||
l => l,
|
|
||||||
// Add a new mapping for CFs in user's config
|
|
||||||
r => new TrashIdMapping(r.TrashId, r.Name, r.Id),
|
|
||||||
// Update existing mappings for CFs in user's config
|
|
||||||
(l, r) => l with {TrashId = r.TrashId, CustomFormatName = r.Name})
|
|
||||||
.Where(x => x.CustomFormatId != 0)
|
|
||||||
.OrderBy(x => x.CustomFormatId)
|
|
||||||
.ToList()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public CustomFormatCache RemoveStale(IEnumerable<CustomFormatData> serviceCfs)
|
|
||||||
{
|
|
||||||
return this with
|
|
||||||
{
|
|
||||||
TrashIdMappings = TrashIdMappings
|
|
||||||
.Where(x => x.CustomFormatId != 0 && serviceCfs.Any(y => y.Id == x.CustomFormatId))
|
|
||||||
.ToList()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public int? FindId(CustomFormatData cf)
|
|
||||||
{
|
|
||||||
return TrashIdMappings.FirstOrDefault(c => c.TrashId == cf.TrashId)?.CustomFormatId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public record TrashIdMapping(string TrashId, string CustomFormatName, int CustomFormatId);
|
|
@ -1,7 +1,7 @@
|
|||||||
using System.IO.Abstractions;
|
using System.IO.Abstractions;
|
||||||
using Recyclarr.Config.Models;
|
using Recyclarr.Config.Models;
|
||||||
|
|
||||||
namespace Recyclarr.Cli.Console.Helpers;
|
namespace Recyclarr.Cli.Cache;
|
||||||
|
|
||||||
public interface ICacheStoragePath
|
public interface ICacheStoragePath
|
||||||
{
|
{
|
@ -0,0 +1,46 @@
|
|||||||
|
using Recyclarr.TrashGuide.CustomFormat;
|
||||||
|
|
||||||
|
namespace Recyclarr.Cli.Pipelines.CustomFormat.Cache;
|
||||||
|
|
||||||
|
public class CustomFormatCache(IEnumerable<TrashIdMapping> mappings)
|
||||||
|
{
|
||||||
|
private List<TrashIdMapping> _mappings = mappings.ToList(); // Deep clone with ToList()
|
||||||
|
|
||||||
|
public IReadOnlyList<TrashIdMapping> Mappings => _mappings;
|
||||||
|
|
||||||
|
public void Update(CustomFormatTransactionData transactions)
|
||||||
|
{
|
||||||
|
// Assume that RemoveStale() is called before this method, and that TrashIdMappings contains existing CFs
|
||||||
|
// in the remote service that we want to keep and update.
|
||||||
|
|
||||||
|
var existingCfs = transactions.UpdatedCustomFormats
|
||||||
|
.Concat(transactions.UnchangedCustomFormats)
|
||||||
|
.Concat(transactions.NewCustomFormats);
|
||||||
|
|
||||||
|
_mappings = _mappings
|
||||||
|
.DistinctBy(x => x.CustomFormatId)
|
||||||
|
.Where(x => transactions.DeletedCustomFormats.All(y => y.CustomFormatId != x.CustomFormatId))
|
||||||
|
.FullOuterJoin(existingCfs, JoinType.Hash,
|
||||||
|
l => l.CustomFormatId,
|
||||||
|
r => r.Id,
|
||||||
|
// Keep existing service CFs, even if they aren't in user config
|
||||||
|
l => l,
|
||||||
|
// Add a new mapping for CFs in user's config
|
||||||
|
r => new TrashIdMapping(r.TrashId, r.Name, r.Id),
|
||||||
|
// Update existing mappings for CFs in user's config
|
||||||
|
(l, r) => l with {TrashId = r.TrashId, CustomFormatName = r.Name})
|
||||||
|
.Where(x => x.CustomFormatId != 0)
|
||||||
|
.OrderBy(x => x.CustomFormatId)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveStale(IEnumerable<CustomFormatData> serviceCfs)
|
||||||
|
{
|
||||||
|
_mappings.RemoveAll(x => x.CustomFormatId == 0 || serviceCfs.All(y => y.Id != x.CustomFormatId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int? FindId(CustomFormatData cf)
|
||||||
|
{
|
||||||
|
return _mappings.Find(c => c.TrashId == cf.TrashId)?.CustomFormatId;
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,11 @@
|
|||||||
|
using Recyclarr.Cli.Cache;
|
||||||
|
|
||||||
|
namespace Recyclarr.Cli.Pipelines.CustomFormat.Cache;
|
||||||
|
|
||||||
|
public record TrashIdMapping(string TrashId, string CustomFormatName, int CustomFormatId);
|
||||||
|
|
||||||
|
[CacheObjectName("custom-format-cache")]
|
||||||
|
public record CustomFormatCacheData(
|
||||||
|
int Version,
|
||||||
|
string InstanceName,
|
||||||
|
IReadOnlyCollection<TrashIdMapping> TrashIdMappings);
|
@ -0,0 +1,38 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Recyclarr.Cli.Cache;
|
||||||
|
using Recyclarr.Config.Models;
|
||||||
|
|
||||||
|
namespace Recyclarr.Cli.Pipelines.CustomFormat.Cache;
|
||||||
|
|
||||||
|
public class CustomFormatCachePersister(ILogger log, IServiceCache serviceCache) : ICustomFormatCachePersister
|
||||||
|
{
|
||||||
|
public const int LatestVersion = 1;
|
||||||
|
|
||||||
|
public CustomFormatCache Load(IServiceConfiguration config)
|
||||||
|
{
|
||||||
|
var cacheData = serviceCache.Load<CustomFormatCacheData>(config);
|
||||||
|
if (cacheData == null)
|
||||||
|
{
|
||||||
|
log.Debug("Custom format cache does not exist; proceeding without it");
|
||||||
|
cacheData = new CustomFormatCacheData(LatestVersion, config.InstanceName, []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the version is higher OR lower, we invalidate the cache. It means there's an
|
||||||
|
// incompatibility that we do not support.
|
||||||
|
if (cacheData.Version != LatestVersion)
|
||||||
|
{
|
||||||
|
log.Information("Cache version mismatch ({OldVersion} vs {LatestVersion}); ignoring cache data",
|
||||||
|
cacheData.Version, LatestVersion);
|
||||||
|
throw new CacheException("Version mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CustomFormatCache(cacheData.TrashIdMappings);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save(IServiceConfiguration config, CustomFormatCache cache)
|
||||||
|
{
|
||||||
|
var data = new CustomFormatCacheData(LatestVersion, config.InstanceName, cache.Mappings);
|
||||||
|
log.Debug("Saving Custom Format Cache with {Mappings}", JsonSerializer.Serialize(data.TrashIdMappings));
|
||||||
|
serviceCache.Save(data, config);
|
||||||
|
}
|
||||||
|
}
|
@ -1,8 +1,8 @@
|
|||||||
using Recyclarr.Config.Models;
|
using Recyclarr.Config.Models;
|
||||||
|
|
||||||
namespace Recyclarr.Cli.Cache;
|
namespace Recyclarr.Cli.Pipelines.CustomFormat.Cache;
|
||||||
|
|
||||||
public interface ICachePersister
|
public interface ICustomFormatCachePersister
|
||||||
{
|
{
|
||||||
CustomFormatCache Load(IServiceConfiguration config);
|
CustomFormatCache Load(IServiceConfiguration config);
|
||||||
void Save(IServiceConfiguration config, CustomFormatCache cache);
|
void Save(IServiceConfiguration config, CustomFormatCache cache);
|
@ -1,85 +0,0 @@
|
|||||||
using System.Collections.ObjectModel;
|
|
||||||
using Recyclarr.Cli.Cache;
|
|
||||||
using Recyclarr.Config.Models;
|
|
||||||
|
|
||||||
namespace Recyclarr.Cli.Tests.Cache;
|
|
||||||
|
|
||||||
[TestFixture]
|
|
||||||
public class CachePersisterTest
|
|
||||||
{
|
|
||||||
private sealed class Context
|
|
||||||
{
|
|
||||||
public Context()
|
|
||||||
{
|
|
||||||
var log = Substitute.For<ILogger>();
|
|
||||||
ServiceCache = Substitute.For<IServiceCache>();
|
|
||||||
Persister = new CachePersister(log, ServiceCache);
|
|
||||||
}
|
|
||||||
|
|
||||||
public CachePersister Persister { get; }
|
|
||||||
public IServiceCache ServiceCache { get; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[TestCase(CustomFormatCache.LatestVersion - 1)]
|
|
||||||
[TestCase(CustomFormatCache.LatestVersion + 1)]
|
|
||||||
public void Throw_when_versions_mismatch(int versionToTest)
|
|
||||||
{
|
|
||||||
var ctx = new Context();
|
|
||||||
var config = Substitute.For<IServiceConfiguration>();
|
|
||||||
|
|
||||||
var testCfObj = new CustomFormatCache
|
|
||||||
{
|
|
||||||
Version = versionToTest,
|
|
||||||
TrashIdMappings = new Collection<TrashIdMapping> {new("", "", 5)}
|
|
||||||
};
|
|
||||||
|
|
||||||
ctx.ServiceCache.Load<CustomFormatCache>(config).Returns(testCfObj);
|
|
||||||
|
|
||||||
var act = () => ctx.Persister.Load(config);
|
|
||||||
|
|
||||||
act.Should().Throw<CacheException>();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Accept_loaded_cache_when_versions_match()
|
|
||||||
{
|
|
||||||
var ctx = new Context();
|
|
||||||
var config = Substitute.For<IServiceConfiguration>();
|
|
||||||
|
|
||||||
var testCfObj = new CustomFormatCache
|
|
||||||
{
|
|
||||||
Version = CustomFormatCache.LatestVersion,
|
|
||||||
TrashIdMappings = new Collection<TrashIdMapping> {new("", "", 5)}
|
|
||||||
};
|
|
||||||
ctx.ServiceCache.Load<CustomFormatCache>(config).Returns(testCfObj);
|
|
||||||
var result = ctx.Persister.Load(config);
|
|
||||||
result.Should().NotBeNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Cache_is_valid_after_successful_load()
|
|
||||||
{
|
|
||||||
var ctx = new Context();
|
|
||||||
var testCfObj = new CustomFormatCache();
|
|
||||||
var config = Substitute.For<IServiceConfiguration>();
|
|
||||||
|
|
||||||
ctx.ServiceCache.Load<CustomFormatCache>(config).Returns(testCfObj);
|
|
||||||
var result = ctx.Persister.Load(config);
|
|
||||||
result.Should().BeSameAs(testCfObj);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public void Save_works_with_valid_cf_cache()
|
|
||||||
{
|
|
||||||
var ctx = new Context();
|
|
||||||
var testCfObj = new CustomFormatCache();
|
|
||||||
var config = Substitute.For<IServiceConfiguration>();
|
|
||||||
|
|
||||||
ctx.ServiceCache.Load<CustomFormatCache>(config).Returns(testCfObj);
|
|
||||||
|
|
||||||
var result = ctx.Persister.Load(config);
|
|
||||||
ctx.Persister.Save(config, result);
|
|
||||||
|
|
||||||
ctx.ServiceCache.Received().Save(testCfObj, config);
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,7 +1,7 @@
|
|||||||
using Recyclarr.Cli.Console.Helpers;
|
using Recyclarr.Cli.Cache;
|
||||||
using Recyclarr.Config.Models;
|
using Recyclarr.Config.Models;
|
||||||
|
|
||||||
namespace Recyclarr.Cli.Tests.Console.Helpers;
|
namespace Recyclarr.Cli.Tests.Cache;
|
||||||
|
|
||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class CacheStoragePathTest
|
public class CacheStoragePathTest
|
@ -0,0 +1,70 @@
|
|||||||
|
using AutoFixture;
|
||||||
|
using Recyclarr.Cli.Cache;
|
||||||
|
using Recyclarr.Cli.Pipelines.CustomFormat.Cache;
|
||||||
|
using Recyclarr.Config.Models;
|
||||||
|
|
||||||
|
namespace Recyclarr.Cli.Tests.Pipelines.CustomFormat.Cache;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class CustomFormatCachePersisterTest
|
||||||
|
{
|
||||||
|
[TestCase(CustomFormatCachePersister.LatestVersion - 1)]
|
||||||
|
[TestCase(CustomFormatCachePersister.LatestVersion + 1)]
|
||||||
|
public void Throw_when_versions_mismatch(int versionToTest)
|
||||||
|
{
|
||||||
|
var fixture = NSubstituteFixture.Create();
|
||||||
|
var serviceCache = fixture.Freeze<IServiceCache>();
|
||||||
|
var sut = fixture.Create<CustomFormatCachePersister>();
|
||||||
|
|
||||||
|
var config = Substitute.For<IServiceConfiguration>();
|
||||||
|
|
||||||
|
var testCfObj = new CustomFormatCacheData(versionToTest, "",
|
||||||
|
[
|
||||||
|
new TrashIdMapping("", "", 5)
|
||||||
|
]);
|
||||||
|
|
||||||
|
serviceCache.Load<CustomFormatCacheData>(config).Returns(testCfObj);
|
||||||
|
|
||||||
|
var act = () => sut.Load(config);
|
||||||
|
|
||||||
|
act.Should().Throw<CacheException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Accept_loaded_cache_when_versions_match()
|
||||||
|
{
|
||||||
|
var fixture = NSubstituteFixture.Create();
|
||||||
|
var serviceCache = fixture.Freeze<IServiceCache>();
|
||||||
|
var sut = fixture.Create<CustomFormatCachePersister>();
|
||||||
|
|
||||||
|
var config = Substitute.For<IServiceConfiguration>();
|
||||||
|
|
||||||
|
var testCfObj = new CustomFormatCacheData(CustomFormatCachePersister.LatestVersion, "",
|
||||||
|
[
|
||||||
|
new TrashIdMapping("", "", 5)
|
||||||
|
]);
|
||||||
|
|
||||||
|
serviceCache.Load<CustomFormatCacheData>(config).Returns(testCfObj);
|
||||||
|
|
||||||
|
var result = sut.Load(config);
|
||||||
|
|
||||||
|
result.Should().NotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Cache_is_valid_after_successful_load()
|
||||||
|
{
|
||||||
|
var fixture = NSubstituteFixture.Create();
|
||||||
|
var serviceCache = fixture.Freeze<IServiceCache>();
|
||||||
|
var sut = fixture.Create<CustomFormatCachePersister>();
|
||||||
|
|
||||||
|
TrashIdMapping[] mappings = [new TrashIdMapping("abc", "name", 123)];
|
||||||
|
var config = Substitute.For<IServiceConfiguration>();
|
||||||
|
|
||||||
|
serviceCache.Load<CustomFormatCacheData>(config).Returns(new CustomFormatCacheData(1, "", mappings));
|
||||||
|
|
||||||
|
var result = sut.Load(config);
|
||||||
|
|
||||||
|
result.Mappings.Should().BeEquivalentTo(mappings);
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in new issue