diff --git a/allstarr.Tests/ApplicationCacheContractTests.cs b/allstarr.Tests/ApplicationCacheContractTests.cs index 9f6576ca..e7604c10 100644 --- a/allstarr.Tests/ApplicationCacheContractTests.cs +++ b/allstarr.Tests/ApplicationCacheContractTests.cs @@ -29,8 +29,8 @@ public sealed class ApplicationCacheContractTests nameof(IApplicationCache.SetAsync), nameof(IApplicationCache.DeleteAsync), nameof(IApplicationCache.ExistsAsync), - nameof(IApplicationCache.GetKeysByPattern), nameof(IApplicationCache.DeleteByPatternAsync), + nameof(IApplicationCache.PurgeAllAsync), }, methodNames); } diff --git a/allstarr.Tests/BoundedHotApplicationCacheTests.cs b/allstarr.Tests/BoundedHotApplicationCacheTests.cs index e8947057..47122933 100644 --- a/allstarr.Tests/BoundedHotApplicationCacheTests.cs +++ b/allstarr.Tests/BoundedHotApplicationCacheTests.cs @@ -33,39 +33,39 @@ public sealed class BoundedHotApplicationCacheTests : IAsyncLifetime [Fact] public async Task SuccessfulWrite_IsServedAfterDatabaseRowIsRemoved() { - Assert.True(await _cache.SetStringAsync("odesli:hot:track:1", "cached")); + Assert.True(await _cache.SetStringAsync("odesli:translate:v2:hot-1:spotify", "cached")); await using (var context = await _factory.CreateDbContextAsync()) { await context.ApplicationCacheEntries.ExecuteDeleteAsync(); } - Assert.Equal("cached", await _cache.GetStringAsync("odesli:hot:track:1")); + Assert.Equal("cached", await _cache.GetStringAsync("odesli:translate:v2:hot-1:spotify")); } [Fact] public async Task Delete_RemovesHotAndDatabaseCopies() { - await _cache.SetStringAsync("odesli:hot:track:2", "cached"); + await _cache.SetStringAsync("odesli:translate:v2:hot-2:spotify", "cached"); - Assert.True(await _cache.DeleteAsync("odesli:hot:track:2")); - Assert.Null(await _cache.GetStringAsync("odesli:hot:track:2")); + Assert.True(await _cache.DeleteAsync("odesli:translate:v2:hot-2:spotify")); + Assert.Null(await _cache.GetStringAsync("odesli:translate:v2:hot-2:spotify")); } [Fact] public async Task PatternDelete_ClearsHotTierBeforeDeletingDatabaseRows() { - await _cache.SetStringAsync("odesli:playlist:one", "one"); - await _cache.SetStringAsync("odesli:track:one", "track"); + await _cache.SetStringAsync("odesli:translate:v2:playlist-one:spotify", "one"); + await _cache.SetStringAsync("odesli:translate:v2:track-one:spotify", "track"); - Assert.Equal(1, await _cache.DeleteByPatternAsync("odesli:playlist:*")); + Assert.Equal(1, await _cache.DeleteByPatternAsync("odesli:translate:v2:playlist-*:spotify")); await using (var context = await _factory.CreateDbContextAsync()) { await context.ApplicationCacheEntries - .Where(item => item.Key == "odesli:track:one") + .Where(item => item.Key == "odesli:translate:v2:track-one:spotify") .ExecuteDeleteAsync(); } - Assert.Null(await _cache.GetStringAsync("odesli:track:one")); + Assert.Null(await _cache.GetStringAsync("odesli:translate:v2:track-one:spotify")); } [Fact] @@ -73,8 +73,8 @@ public sealed class BoundedHotApplicationCacheTests : IAsyncLifetime { var value = new string('x', BoundedHotApplicationCache.MaximumEntryBytes + 1); - Assert.True(await _cache.SetStringAsync("odesli:large:metadata", value)); - Assert.Equal(value, await _cache.GetStringAsync("odesli:large:metadata")); + Assert.True(await _cache.SetStringAsync("odesli:translate:v2:large:spotify", value)); + Assert.Equal(value, await _cache.GetStringAsync("odesli:translate:v2:large:spotify")); } public async Task DisposeAsync() diff --git a/allstarr.Tests/CacheDiagnosticsTests.cs b/allstarr.Tests/CacheDiagnosticsTests.cs index ac5dcb2f..62f6313c 100644 --- a/allstarr.Tests/CacheDiagnosticsTests.cs +++ b/allstarr.Tests/CacheDiagnosticsTests.cs @@ -50,7 +50,7 @@ public sealed class CacheDiagnosticsTests : IAsyncLifetime [Fact] public async Task CategoryPolicySuppliesDefaultExpiry() { - const string key = "playlist:discovery:v1:fixture"; + const string key = "playlist:discovery:v2:global:shared:00000000000000000000000000000000:1:fixture:digest"; Assert.True(await _cache.SetStringAsync(key, "{}")); await using var context = new AllstarrDbContext(_database.Options); @@ -62,8 +62,8 @@ public sealed class CacheDiagnosticsTests : IAsyncLifetime [Fact] public async Task Snapshot_ReportsEveryTierAndScopedPurgesStayIsolated() { - Assert.True(await _cache.SetStringAsync("odesli:track:1", "metadata")); - Assert.True(await _cache.SetStringAsync("image:track:1", "media")); + Assert.True(await _cache.SetStringAsync("odesli:translate:v2:track-1:spotify", "metadata")); + Assert.True(await _cache.SetStringAsync("artwork:payload:v1:track-1", "media")); var snapshot = await _cache.GetDiagnosticsAsync(); Assert.Equal(1, snapshot.Database.EntryCount); @@ -98,19 +98,19 @@ public sealed class CacheDiagnosticsTests : IAsyncLifetime Assert.Equal(5, artworkCategory.PayloadBytes); Assert.Equal(1, await _cache.PurgeMediaAsync()); - Assert.Equal("metadata", await _cache.GetStringAsync("odesli:track:1")); - Assert.Null(await _cache.GetStringAsync("image:track:1")); + Assert.Equal("metadata", await _cache.GetStringAsync("odesli:translate:v2:track-1:spotify")); + Assert.Null(await _cache.GetStringAsync("artwork:payload:v1:track-1")); snapshot = await _cache.GetDiagnosticsAsync(); Assert.Equal(1, snapshot.Hot.Hits); Assert.Equal(1, snapshot.Media.Misses); - Assert.True(await _cache.SetStringAsync("image:track:2", "media")); + Assert.True(await _cache.SetStringAsync("artwork:payload:v1:track-2", "media")); Assert.Equal(1, await _cache.PurgeMetadataAsync()); - Assert.Null(await _cache.GetStringAsync("odesli:track:1")); - Assert.Equal("media", await _cache.GetStringAsync("image:track:2")); + Assert.Null(await _cache.GetStringAsync("odesli:translate:v2:track-1:spotify")); + Assert.Equal("media", await _cache.GetStringAsync("artwork:payload:v1:track-2")); Assert.Equal(1, await _cache.PurgeAllAsync()); - Assert.Null(await _cache.GetStringAsync("image:track:2")); + Assert.Null(await _cache.GetStringAsync("artwork:payload:v1:track-2")); } [Fact] @@ -140,11 +140,11 @@ public sealed class CacheDiagnosticsTests : IAsyncLifetime .ExtensionStorage.ActiveExtensions); Assert.IsType(await controller.Purge("arbitrary:*")); - Assert.True(await _cache.SetStringAsync("lyrics:fixture", "lyrics")); - Assert.True(await _cache.SetStringAsync("odesli:fixture", "provider")); + Assert.True(await _cache.SetStringAsync("lyrics:v2:fixture", "lyrics")); + Assert.True(await _cache.SetStringAsync("odesli:translate:v2:fixture:spotify", "provider")); Assert.IsType(await controller.PurgeCategory("lyrics")); - Assert.Null(await _cache.GetStringAsync("lyrics:fixture")); - Assert.Equal("provider", await _cache.GetStringAsync("odesli:fixture")); + Assert.Null(await _cache.GetStringAsync("lyrics:v2:fixture")); + Assert.Equal("provider", await _cache.GetStringAsync("odesli:translate:v2:fixture:spotify")); Assert.IsType(await controller.PurgeCategory("not-a-category")); Assert.IsType(await controller.PurgeCategory("0")); } @@ -153,11 +153,11 @@ public sealed class CacheDiagnosticsTests : IAsyncLifetime public async Task MaintenancePreviewAndRunCoverMetadataAndMediaTiers() { Assert.True(await _cache.SetStringAsync( - "search:expired", + "search:v2:expired", "metadata", TimeSpan.FromMinutes(1))); Assert.True(await _cache.SetStringAsync( - "image:expired", + "artwork:payload:v1:expired", "media", TimeSpan.FromMinutes(1))); _clock.UtcNow = _clock.UtcNow.AddMinutes(2); diff --git a/allstarr.Tests/CacheKeyBuilderTests.cs b/allstarr.Tests/CacheKeyBuilderTests.cs index e62dad2c..1b617e0a 100644 --- a/allstarr.Tests/CacheKeyBuilderTests.cs +++ b/allstarr.Tests/CacheKeyBuilderTests.cs @@ -19,9 +19,9 @@ public class CacheKeyBuilderTests true, "1635cd7d23144ba08251ebe22a56119e"); - Assert.Equal( - "search:data:musicalbum:500:0:efa26829c37196b030fa31d127e0715b:datecreated,sortname:descending:true:1635cd7d23144ba08251ebe22a56119e:", - key); + Assert.StartsWith("search:v2:", key, StringComparison.Ordinal); + Assert.DoesNotContain("data", key, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("1635cd7d", key, StringComparison.OrdinalIgnoreCase); } [Fact] @@ -52,25 +52,17 @@ public class CacheKeyBuilderTests "true"); Assert.NotEqual(normalKey, favoritesOnlyKey); - Assert.EndsWith(":false", normalKey); - Assert.EndsWith(":true", favoritesOnlyKey); - } - - [Fact] - public void SearchKey_OldOverload_ShouldRemainCompatible() - { - Assert.Equal("search:data:Audio:500:0", CacheKeyBuilder.BuildSearchKey("DATA", "Audio", 500, 0)); + Assert.StartsWith("search:v2:", normalKey, StringComparison.Ordinal); + Assert.StartsWith("search:v2:", favoritesOnlyKey, StringComparison.Ordinal); } [Fact] public void LyricsAndGenreKeys_ShouldMatchExpectedFormats() { - Assert.Equal("lyrics:Artist:Title:Album:240", CacheKeyBuilder.BuildLyricsKey("Artist", "Title", "Album", 240)); - Assert.Equal("lyricsplus:Artist:Title:Album:240", CacheKeyBuilder.BuildLyricsPlusKey("Artist", "Title", "Album", 240)); - Assert.Equal("lyrics:id:42", CacheKeyBuilder.BuildLyricsByIdKey(42)); - - Assert.Equal("genre:Track:Artist", CacheKeyBuilder.BuildGenreEnrichmentKey("Track", "Artist")); - Assert.Equal("genre:Track:Artist", CacheKeyBuilder.BuildGenreEnrichmentKey("Track:Artist")); + Assert.StartsWith("lyrics:v2:", CacheKeyBuilder.BuildLyricsKey("Artist", "Title", "Album", 240)); + Assert.StartsWith("lyricsplus:v2:", CacheKeyBuilder.BuildLyricsPlusKey("Artist", "Title", "Album", 240)); + Assert.Equal("lyrics:id:v2:42", CacheKeyBuilder.BuildLyricsByIdKey(42)); + Assert.StartsWith("genre:v2:", CacheKeyBuilder.BuildGenreEnrichmentKey("Track:Artist")); Assert.Equal( ApplicationCacheCategory.CanonicalMetadata, ApplicationCachePolicyRegistry.Classify(CacheKeyBuilder.BuildAlbumKey("qobuz", "42"))); @@ -86,11 +78,11 @@ public class CacheKeyBuilderTests [Fact] public void MusicBrainzAndOdesliKeys_ShouldMatchExpectedFormats() { - Assert.Equal("musicbrainz:isrc:USABC123", CacheKeyBuilder.BuildMusicBrainzIsrcKey("USABC123")); - Assert.Equal("musicbrainz:search:title:artist:5", CacheKeyBuilder.BuildMusicBrainzSearchKey("Title", "Artist", 5)); - Assert.Equal("musicbrainz:mbid:abc-def", CacheKeyBuilder.BuildMusicBrainzMbidKey("abc-def")); + Assert.Equal("musicbrainz:isrc:v1:usabc123", CacheKeyBuilder.BuildMusicBrainzIsrcKey("USABC123")); + Assert.StartsWith("musicbrainz:search:v1:", CacheKeyBuilder.BuildMusicBrainzSearchKey("Title", "Artist", 5)); + Assert.Equal("musicbrainz:mbid:v1:abc-def", CacheKeyBuilder.BuildMusicBrainzMbidKey("abc-def")); - Assert.Equal("odesli:tidal-to-spotify:123", CacheKeyBuilder.BuildOdesliTidalToSpotifyKey("123")); + Assert.StartsWith("odesli:tidal-to-spotify:v2:", CacheKeyBuilder.BuildOdesliTidalToSpotifyKey("123")); var urlKey = CacheKeyBuilder.BuildOdesliUrlToSpotifyKey("https://example.com/track?token=secret"); Assert.StartsWith("odesli:url-to-spotify:v2:", urlKey, StringComparison.Ordinal); Assert.DoesNotContain("example.com", urlKey, StringComparison.Ordinal); @@ -136,7 +128,8 @@ public class CacheKeyBuilderTests { var key = CacheKeyBuilder.BuildPlaybackMetadataNegativeKey("jellyfin", "track-1"); - Assert.Equal("negative:playback:metadata:jellyfin:track-1", key); + Assert.StartsWith("negative:playback:metadata:v1:jellyfin:", key); + Assert.DoesNotContain("track-1", key, StringComparison.Ordinal); Assert.Equal( ApplicationCacheCategory.NegativeResult, ApplicationCachePolicyRegistry.Classify(key)); @@ -152,6 +145,9 @@ public class CacheKeyBuilderTests "odesli:translate:v2:fixture:spotify", out var category)); Assert.Equal(ApplicationCacheCategory.ProviderResponse, category); + Assert.False(ApplicationCachePolicyRegistry.TryClassify( + "lyrics:Artist:Title:Album:240", + out _)); } [Fact] diff --git a/allstarr.Tests/ConfigControllerAuthorizationTests.cs b/allstarr.Tests/ConfigControllerAuthorizationTests.cs index 1eb7ecd6..78f8b996 100644 --- a/allstarr.Tests/ConfigControllerAuthorizationTests.cs +++ b/allstarr.Tests/ConfigControllerAuthorizationTests.cs @@ -145,7 +145,7 @@ public class ConfigControllerAuthorizationTests : IAsyncLifetime var envPath = Path.Combine(_root, ".env"); await File.WriteAllTextAsync(envPath, "CACHE_LYRICS_DAYS=14\n"); var cache = new TestMemoryApplicationCache(); - await cache.SetStringAsync("lyrics:fixture", "cached"); + await cache.SetStringAsync("lyrics:v2:fixture", "cached"); var controller = CreateController( CreateHttpContextWithSession(isAdmin: true), applicationCache: cache); @@ -162,7 +162,7 @@ public class ConfigControllerAuthorizationTests : IAsyncLifetime Assert.Equal(_tenantId, setting.TenantId); Assert.Equal("Cache:LyricsDays", setting.Key); Assert.Equal("45", setting.ValueJson); - Assert.False(await cache.ExistsAsync("lyrics:fixture")); + Assert.False(await cache.ExistsAsync("lyrics:v2:fixture")); var getResult = Assert.IsType(await controller.GetConfig()); using var config = JsonDocument.Parse(JsonSerializer.Serialize( diff --git a/allstarr.Tests/DatabaseApplicationCacheTests.cs b/allstarr.Tests/DatabaseApplicationCacheTests.cs index f28f74b6..9814a2f7 100644 --- a/allstarr.Tests/DatabaseApplicationCacheTests.cs +++ b/allstarr.Tests/DatabaseApplicationCacheTests.cs @@ -32,10 +32,10 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime [Fact] public async Task SetAndGet_OverwriteOneDisposableEntry() { - Assert.True(await _cache.SetStringAsync("playback:metadata:test:1", "first", TimeSpan.FromMinutes(5))); - Assert.True(await _cache.SetStringAsync("playback:metadata:test:1", "second", TimeSpan.FromMinutes(10))); + Assert.True(await _cache.SetStringAsync("playback:metadata:v1:test:1", "first", TimeSpan.FromMinutes(5))); + Assert.True(await _cache.SetStringAsync("playback:metadata:v1:test:1", "second", TimeSpan.FromMinutes(10))); - Assert.Equal("second", await _cache.GetStringAsync("playback:metadata:test:1")); + Assert.Equal("second", await _cache.GetStringAsync("playback:metadata:v1:test:1")); await using var database = await _factory.CreateDbContextAsync(); var entry = await database.ApplicationCacheEntries.SingleAsync(); @@ -46,11 +46,11 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime [Fact] public async Task ReadsFlushOneSampledAccessTimestamp() { - await _cache.SetStringAsync("search:touched", "value", TimeSpan.FromHours(1)); + await _cache.SetStringAsync("search:v2:touched", "value", TimeSpan.FromHours(1)); _clock.UtcNow = _clock.UtcNow.AddMinutes(5); - Assert.Equal("value", await _cache.GetStringAsync("search:touched")); - Assert.Equal("value", await _cache.GetStringAsync("search:touched")); + Assert.Equal("value", await _cache.GetStringAsync("search:v2:touched")); + Assert.Equal("value", await _cache.GetStringAsync("search:v2:touched")); Assert.Equal(1, await _cache.FlushAccessesAsync()); await using var database = await _factory.CreateDbContextAsync(); @@ -63,11 +63,11 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime [Fact] public async Task ExpiredEntry_IsAColdMissAndIsRemoved() { - await _cache.SetStringAsync("odesli:playlist-artwork:1", "asset", TimeSpan.FromMinutes(1)); + await _cache.SetStringAsync("odesli:translate:v2:expired:spotify", "asset", TimeSpan.FromMinutes(1)); _clock.UtcNow = _clock.UtcNow.AddMinutes(2); - Assert.Null(await _cache.GetStringAsync("odesli:playlist-artwork:1")); - Assert.False(await _cache.ExistsAsync("odesli:playlist-artwork:1")); + Assert.Null(await _cache.GetStringAsync("odesli:translate:v2:expired:spotify")); + Assert.False(await _cache.ExistsAsync("odesli:translate:v2:expired:spotify")); await using var database = await _factory.CreateDbContextAsync(); Assert.Empty(await database.ApplicationCacheEntries.ToListAsync()); @@ -76,31 +76,31 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime [Fact] public async Task CleanupExpired_IsBoundedAndLeavesLiveEntries() { - await _cache.SetStringAsync("odesli:expired:1", "one", TimeSpan.FromMinutes(1)); - await _cache.SetStringAsync("odesli:expired:2", "two", TimeSpan.FromMinutes(1)); - await _cache.SetStringAsync("odesli:live:1", "live", TimeSpan.FromHours(1)); + await _cache.SetStringAsync("odesli:translate:v2:expired-1:spotify", "one", TimeSpan.FromMinutes(1)); + await _cache.SetStringAsync("odesli:translate:v2:expired-2:spotify", "two", TimeSpan.FromMinutes(1)); + await _cache.SetStringAsync("odesli:translate:v2:live:spotify", "live", TimeSpan.FromHours(1)); _clock.UtcNow = _clock.UtcNow.AddMinutes(2); Assert.Equal(1, await _cache.CleanupExpiredAsync(batchSize: 1)); await using var database = await _factory.CreateDbContextAsync(); Assert.Equal(2, await database.ApplicationCacheEntries.CountAsync()); - Assert.True(await database.ApplicationCacheEntries.AnyAsync(item => item.Key == "odesli:live:1")); + Assert.True(await database.ApplicationCacheEntries.AnyAsync(item => item.Key == "odesli:translate:v2:live:spotify")); } [Fact] public async Task MaintenancePreview_ReportsAndCleanupRemovesOnlyDisposableEntries() { - await _cache.SetStringAsync("search:live", "live", TimeSpan.FromHours(1)); - await _cache.SetStringAsync("search:expired", "expired", TimeSpan.FromMinutes(1)); + await _cache.SetStringAsync("search:v2:live", "live", TimeSpan.FromHours(1)); + await _cache.SetStringAsync("search:v2:expired", "expired", TimeSpan.FromMinutes(1)); _clock.UtcNow = _clock.UtcNow.AddMinutes(2); await using (var database = await _factory.CreateDbContextAsync()) { database.ApplicationCacheEntries.Add(new ApplicationCacheEntryRecord { - Key = "legacy:no-owner", - Category = "Legacy", + Key = "lyrics:Artist:Title:Album:240", + Category = ApplicationCacheCategory.Lyrics.ToString(), Value = "orphan", PayloadBytes = 6, CreatedAt = _clock.UtcNow, @@ -136,7 +136,7 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime Assert.Equal(1, await _cache.CleanupExpiredAsync()); Assert.Equal(3, await _cache.CleanupInvalidOwnershipAsync()); - Assert.Equal("live", await _cache.GetStringAsync("search:live")); + Assert.Equal("live", await _cache.GetStringAsync("search:v2:live")); await using var remaining = await _factory.CreateDbContextAsync(); Assert.Single(await remaining.ApplicationCacheEntries.ToListAsync()); @@ -163,15 +163,12 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime [Fact] public async Task PatternOperations_UseRedisCompatibleWildcards() { - await _cache.SetStringAsync("odesli:playlist:one", "1"); - await _cache.SetStringAsync("odesli:playlist:two", "2"); - await _cache.SetStringAsync("odesli:track:one", "3"); + await _cache.SetStringAsync("odesli:translate:v2:playlist-one:spotify", "1"); + await _cache.SetStringAsync("odesli:translate:v2:playlist-two:spotify", "2"); + await _cache.SetStringAsync("odesli:translate:v2:track-one:spotify", "3"); - Assert.Equal( - ["odesli:playlist:one", "odesli:playlist:two"], - _cache.GetKeysByPattern("odesli:playlist:*").Order(StringComparer.Ordinal).ToArray()); - Assert.Equal(2, await _cache.DeleteByPatternAsync("odesli:playlist:*")); - Assert.Equal("3", await _cache.GetStringAsync("odesli:track:one")); + Assert.Equal(2, await _cache.DeleteByPatternAsync("odesli:translate:v2:playlist-*:spotify")); + Assert.Equal("3", await _cache.GetStringAsync("odesli:translate:v2:track-one:spotify")); } [Fact] @@ -179,19 +176,16 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime { var payload = new string('x', DatabaseApplicationCache.MaximumPayloadBytes + 1); - Assert.False(await _cache.SetStringAsync("odesli:too-large", payload)); + Assert.False(await _cache.SetStringAsync("odesli:translate:v2:too-large:spotify", payload)); await using var database = await _factory.CreateDbContextAsync(); Assert.Empty(await database.ApplicationCacheEntries.ToListAsync()); } - [Theory] - [InlineData("image:jellyfin:primary:track")] - [InlineData("playlist:image:release-radar")] - [InlineData("artwork:spotify:album")] - [InlineData("cover:qobuz:playlist")] - public async Task MediaPayloadKey_IsRejectedWithoutWriting(string key) + [Fact] + public async Task MediaPayloadKey_IsRejectedWithoutWriting() { + const string key = "artwork:payload:v1:fixture"; Assert.False(await _cache.SetStringAsync(key, "base64-or-binary-json")); await using var database = await _factory.CreateDbContextAsync(); @@ -211,7 +205,7 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime [Fact] public async Task DisabledCategory_RejectsAccessAndCleanupRemovesExistingEntries() { - Assert.True(await _cache.SetStringAsync("lyrics:disabled-fixture", "lyrics")); + Assert.True(await _cache.SetStringAsync("lyrics:v2:disabled-fixture", "lyrics")); var disabledSettings = new CacheSettings { @@ -226,9 +220,9 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime NullLogger.Instance, Options.Create(disabledSettings)); - Assert.Null(await disabledCache.GetStringAsync("lyrics:disabled-fixture")); - Assert.False(await disabledCache.ExistsAsync("lyrics:disabled-fixture")); - Assert.False(await disabledCache.SetStringAsync("lyrics:new", "blocked")); + Assert.Null(await disabledCache.GetStringAsync("lyrics:v2:disabled-fixture")); + Assert.False(await disabledCache.ExistsAsync("lyrics:v2:disabled-fixture")); + Assert.False(await disabledCache.SetStringAsync("lyrics:v2:new", "blocked")); Assert.Equal(1, await disabledCache.CleanupPolicyOverflowAsync()); await using var database = await _factory.CreateDbContextAsync(); @@ -252,13 +246,13 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime Options.Create(settings)); var payload = new string('x', 600 * 1024); - Assert.True(await cache.SetStringAsync("odesli:provider:first", payload, TimeSpan.FromHours(1))); + Assert.True(await cache.SetStringAsync("odesli:translate:v2:first:spotify", payload, TimeSpan.FromHours(1))); _clock.UtcNow = _clock.UtcNow.AddSeconds(1); - Assert.True(await cache.SetStringAsync("odesli:provider:second", payload, TimeSpan.FromHours(1))); + Assert.True(await cache.SetStringAsync("odesli:translate:v2:second:spotify", payload, TimeSpan.FromHours(1))); Assert.Equal(1, await cache.CleanupPolicyOverflowAsync()); - Assert.Null(await cache.GetStringAsync("odesli:provider:first")); - Assert.Equal(payload, await cache.GetStringAsync("odesli:provider:second")); + Assert.Null(await cache.GetStringAsync("odesli:translate:v2:first:spotify")); + Assert.Equal(payload, await cache.GetStringAsync("odesli:translate:v2:second:spotify")); } [Fact] @@ -277,12 +271,12 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime NullLogger.Instance, Options.Create(settings)); - Assert.True(await cache.SetStringAsync("search:first", "one", TimeSpan.FromHours(1))); + Assert.True(await cache.SetStringAsync("search:v2:first", "one", TimeSpan.FromHours(1))); _clock.UtcNow = _clock.UtcNow.AddSeconds(1); - Assert.True(await cache.SetStringAsync("search:second", "two", TimeSpan.FromHours(1))); + Assert.True(await cache.SetStringAsync("search:v2:second", "two", TimeSpan.FromHours(1))); _clock.UtcNow = _clock.UtcNow.AddSeconds(1); - Assert.True(await cache.SetStringAsync("search:third", "three", TimeSpan.FromHours(1))); - Assert.True(await cache.SetStringAsync("lyrics:fixture", "lyrics", TimeSpan.FromHours(1))); + Assert.True(await cache.SetStringAsync("search:v2:third", "three", TimeSpan.FromHours(1))); + Assert.True(await cache.SetStringAsync("lyrics:v2:fixture", "lyrics", TimeSpan.FromHours(1))); Assert.Equal(1, await cache.CleanupPolicyOverflowAsync()); @@ -290,10 +284,10 @@ public sealed class DatabaseApplicationCacheTests : IAsyncLifetime var entries = await database.ApplicationCacheEntries .OrderBy(item => item.Key) .ToListAsync(); - Assert.DoesNotContain(entries, item => item.Key == "search:first"); + Assert.DoesNotContain(entries, item => item.Key == "search:v2:first"); Assert.Equal(2, entries.Count(item => item.Category == nameof(ApplicationCacheCategory.SearchResults))); Assert.Contains(entries, item => - item.Key == "lyrics:fixture" && + item.Key == "lyrics:v2:fixture" && item.Category == nameof(ApplicationCacheCategory.Lyrics)); } diff --git a/allstarr.Tests/DisabledApplicationCache.cs b/allstarr.Tests/DisabledApplicationCache.cs index 2f42a62d..d6a596a3 100644 --- a/allstarr.Tests/DisabledApplicationCache.cs +++ b/allstarr.Tests/DisabledApplicationCache.cs @@ -20,7 +20,7 @@ internal sealed class DisabledApplicationCache : IApplicationCache public Task ExistsAsync(string key) => Task.FromResult(false); - public IEnumerable GetKeysByPattern(string pattern) => []; - public Task DeleteByPatternAsync(string pattern) => Task.FromResult(0); + + public Task PurgeAllAsync() => Task.FromResult(0); } diff --git a/allstarr.Tests/FileMediaApplicationCacheTests.cs b/allstarr.Tests/FileMediaApplicationCacheTests.cs index 6cdb6732..ef7eb491 100644 --- a/allstarr.Tests/FileMediaApplicationCacheTests.cs +++ b/allstarr.Tests/FileMediaApplicationCacheTests.cs @@ -23,9 +23,9 @@ public sealed class FileMediaApplicationCacheTests : IAsyncLifetime } [Theory] - [InlineData("image:jellyfin:primary:../track")] - [InlineData("playlist:image:release/radar")] - [InlineData("artwork:spotify:album")] + [InlineData("artwork:payload:v1:../track")] + [InlineData("artwork:payload:v1:release/radar")] + [InlineData("artwork:payload:v1:album")] public async Task MediaKey_RoundTripsThroughHashedPaths(string key) { Assert.True(await _cache.SetStringAsync(key, "\"image-bytes\"", TimeSpan.FromHours(1))); @@ -47,39 +47,39 @@ public sealed class FileMediaApplicationCacheTests : IAsyncLifetime [Fact] public async Task ExpiredEntry_IsRemovedOnRead() { - await _cache.SetStringAsync("image:expired", "value", TimeSpan.FromMinutes(1)); + await _cache.SetStringAsync("artwork:payload:v1:expired", "value", TimeSpan.FromMinutes(1)); _clock.UtcNow = _clock.UtcNow.AddMinutes(2); - Assert.Null(await _cache.GetStringAsync("image:expired")); + Assert.Null(await _cache.GetStringAsync("artwork:payload:v1:expired")); Assert.Empty(Directory.GetFiles(_root, "*", SearchOption.AllDirectories)); } [Fact] public async Task Quota_RemovesOldestEntry() { - await _cache.SetStringAsync("image:first", new string('a', 40)); + await _cache.SetStringAsync("artwork:payload:v1:first", new string('a', 40)); _clock.UtcNow = _clock.UtcNow.AddSeconds(1); - await _cache.SetStringAsync("image:second", new string('b', 40)); + await _cache.SetStringAsync("artwork:payload:v1:second", new string('b', 40)); - Assert.Null(await _cache.GetStringAsync("image:first")); - Assert.Equal(new string('b', 40), await _cache.GetStringAsync("image:second")); + Assert.Null(await _cache.GetStringAsync("artwork:payload:v1:first")); + Assert.Equal(new string('b', 40), await _cache.GetStringAsync("artwork:payload:v1:second")); } [Fact] public async Task PatternDelete_RemovesOnlyMatchingMediaKeys() { - await _cache.SetStringAsync("image:one", "one"); - await _cache.SetStringAsync("playlist:image:two", "two"); + await _cache.SetStringAsync("artwork:payload:v1:track-one", "one"); + await _cache.SetStringAsync("artwork:payload:v1:playlist-two", "two"); - Assert.Equal(1, await _cache.DeleteByPatternAsync("playlist:image:*")); - Assert.Equal("one", await _cache.GetStringAsync("image:one")); - Assert.Null(await _cache.GetStringAsync("playlist:image:two")); + Assert.Equal(1, await _cache.DeleteByPatternAsync("artwork:payload:v1:playlist-*")); + Assert.Equal("one", await _cache.GetStringAsync("artwork:payload:v1:track-one")); + Assert.Null(await _cache.GetStringAsync("artwork:payload:v1:playlist-two")); } [Fact] public async Task Cleanup_RemovesMalformedOrphanedAndTemporaryFiles() { - await _cache.SetStringAsync("image:malformed", "value"); + await _cache.SetStringAsync("artwork:payload:v1:malformed", "value"); var entryFiles = Directory.GetFiles(_root, "*", SearchOption.AllDirectories); var metadataPath = Assert.Single( entryFiles, diff --git a/allstarr.Tests/PostgresStorageIntegrationTests.cs b/allstarr.Tests/PostgresStorageIntegrationTests.cs index f43bc3e6..41a087f1 100644 --- a/allstarr.Tests/PostgresStorageIntegrationTests.cs +++ b/allstarr.Tests/PostgresStorageIntegrationTests.cs @@ -1,6 +1,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.Json; +using allstarr.Core.Capabilities; using allstarr.Core.Configuration; using allstarr.Core.Favorites; using allstarr.Core.Jobs; @@ -654,25 +655,145 @@ public sealed class PostgresStorageIntegrationTests var clock = new FixedClock(now); var tenantId = Guid.CreateVersion7(); var userId = Guid.CreateVersion7(); + var accountId = Guid.CreateVersion7(); + var recordingId = Guid.CreateVersion7(); + var providerIdentityId = Guid.CreateVersion7(); + var linkId = Guid.CreateVersion7(); + var snapshotId = Guid.CreateVersion7(); + var firstExternalId = Guid.CreateVersion7(); + var secondExternalId = Guid.CreateVersion7(); + const string hash = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; await using (var seed = await factory.CreateDbContextAsync()) { - seed.Tenants.Add(new TenantRecord - { - Id = tenantId, - Slug = "cache-loss", - Name = "Cache loss", - CreatedAt = now - }); - seed.Users.Add(new PlatformUserRecord - { - Id = userId, - TenantId = tenantId, - DisplayName = "Cache owner", - Status = PlatformUserStatus.Active, - CreatedAt = now, - UpdatedAt = now - }); + seed.AddRange( + new TenantRecord + { + Id = tenantId, + Slug = "cache-loss", + Name = "Cache loss", + CreatedAt = now + }, + new PlatformUserRecord + { + Id = userId, + TenantId = tenantId, + DisplayName = "Cache owner", + Status = PlatformUserStatus.Active, + CreatedAt = now, + UpdatedAt = now + }, + new ProviderAccountRecord + { + Id = accountId, + TenantId = tenantId, + OwnerUserId = userId, + ProviderId = "fixture", + DisplayName = "Fixture", + Scope = ProviderAccountScope.User, + Enabled = true, + CreatedAt = now, + UpdatedAt = now + }, + new CanonicalRecordingRecord + { + Id = recordingId, + TenantId = tenantId, + CreatedByUserId = userId, + CreatedAt = now, + UpdatedAt = now + }, + new ProviderTrackIdentityRecord + { + Id = providerIdentityId, + TenantId = tenantId, + CanonicalRecordingId = recordingId, + ProviderAccountId = accountId, + ProviderId = "fixture", + ResourceKind = ProviderResourceKind.Track, + Scope = ProviderIdentityScope.Account, + ExternalId = "track", + ExternalIdHash = hash, + Verification = ProviderIdentityVerification.Verified, + VerificationMethod = "fixture", + DecisionVersion = 1, + VerifiedAt = now, + CreatedAt = now, + UpdatedAt = now + }, + new PlaylistLinkRecord + { + Id = linkId, + TenantId = tenantId, + OwnerUserId = userId, + ProviderAccountId = accountId, + LibraryScopeId = "music", + SourceProviderId = "fixture", + SourcePlaylistId = "playlist", + SourcePlaylistIdHash = hash, + TargetProtocol = "jellyfin", + TargetBackendInstanceId = "home", + Mode = PlaylistLinkMode.Materialized, + MaterializationMode = PlaylistMaterializationMode.Reconcile, + RuleVersion = "rules-v1", + PolicyVersion = "policy-v1", + CreatedAt = now, + UpdatedAt = now + }, + External(firstExternalId, 1), + External(secondExternalId, 2), + new PlaylistSourceSnapshotRecord + { + Id = snapshotId, + TenantId = tenantId, + OwnerUserId = userId, + PlaylistLinkId = linkId, + ProviderAccountId = accountId, + SnapshotVersion = 1, + ProviderRevision = "revision", + Name = "Ordered", + PayloadSha256 = hash, + CorrelationId = "cache-loss", + RetrievedAt = now + }, + new PlaylistSourceEntryRecord + { + Id = Guid.CreateVersion7(), + TenantId = tenantId, + PlaylistSourceSnapshotId = snapshotId, + ExternalMetadataSnapshotId = firstExternalId, + SourcePosition = 0, + SourceEntryIdHash = hash + }, + new PlaylistSourceEntryRecord + { + Id = Guid.CreateVersion7(), + TenantId = tenantId, + PlaylistSourceSnapshotId = snapshotId, + ExternalMetadataSnapshotId = secondExternalId, + SourcePosition = 1, + SourceEntryIdHash = new string('b', 64) + }); await seed.SaveChangesAsync(); + + ExternalMetadataSnapshotRecord External(Guid id, int version) => new() + { + Id = id, + TenantId = tenantId, + OwnerUserId = userId, + ProviderAccountId = accountId, + LibraryScopeId = "music", + BackendInstanceId = "home", + BackendPrincipalId = "principal", + Protocol = "jellyfin", + ProviderId = "fixture", + ResourceKind = "track", + ExternalIdHash = new string((char)('a' + version), 64), + SnapshotVersion = 1, + ProviderRevision = $"revision-{version}", + PayloadSha256 = new string((char)('c' + version), 64), + CorrelationId = "cache-loss", + RetrievedAt = now + }; } var jobOptions = new DurableJobOptions(); @@ -703,10 +824,10 @@ public sealed class PostgresStorageIntegrationTests clock, NullLogger.Instance); Assert.True(await firstCache.SetStringAsync( - "search:cache-loss", + "search:v2:cache-loss", "disposable", TimeSpan.FromHours(1))); - Assert.Equal("disposable", await firstCache.GetStringAsync("search:cache-loss")); + Assert.Equal("disposable", await firstCache.GetStringAsync("search:v2:cache-loss")); await using (var purge = await factory.CreateDbContextAsync()) { @@ -717,9 +838,19 @@ public sealed class PostgresStorageIntegrationTests factory, clock, NullLogger.Instance); - Assert.Null(await restartedCache.GetStringAsync("search:cache-loss")); + Assert.Null(await restartedCache.GetStringAsync("search:v2:cache-loss")); await using var verification = await factory.CreateDbContextAsync(); Assert.True(await verification.Jobs.AnyAsync(item => item.Id == enqueued.JobId)); + Assert.True(await verification.CanonicalRecordings.AnyAsync(item => item.Id == recordingId)); + Assert.True(await verification.ProviderTrackIdentities.AnyAsync(item => + item.Id == providerIdentityId && + item.CanonicalRecordingId == recordingId)); + var positions = await verification.PlaylistSourceEntries + .Where(item => item.PlaylistSourceSnapshotId == snapshotId) + .OrderBy(item => item.SourcePosition) + .Select(item => item.SourcePosition) + .ToArrayAsync(); + Assert.Equal(new[] { 0, 1 }, positions); Assert.True(await verification.AuditEvents.AnyAsync(item => item.Category == "job-progress" && item.CorrelationId == claim!.CorrelationId)); diff --git a/allstarr.Tests/TestMemoryApplicationCache.cs b/allstarr.Tests/TestMemoryApplicationCache.cs index c46ff47d..20814341 100644 --- a/allstarr.Tests/TestMemoryApplicationCache.cs +++ b/allstarr.Tests/TestMemoryApplicationCache.cs @@ -52,5 +52,7 @@ internal sealed class TestMemoryApplicationCache : IApplicationCache return Task.FromResult(deleted); } + public Task PurgeAllAsync() => DeleteByPatternAsync("*"); + private sealed record Entry(string Value, DateTimeOffset? ExpiresAt); } diff --git a/allstarr/Controllers/ConfigController.cs b/allstarr/Controllers/ConfigController.cs index 646ddc15..86bcfe68 100644 --- a/allstarr/Controllers/ConfigController.cs +++ b/allstarr/Controllers/ConfigController.cs @@ -353,7 +353,7 @@ public class ConfigController : ControllerBase var cacheEntriesInvalidated = normalized.Any(item => !item.DurableKey.StartsWith("Scrobbling:", StringComparison.OrdinalIgnoreCase) && !item.DurableKey.StartsWith("WebUi:", StringComparison.OrdinalIgnoreCase)) - ? await _cache.DeleteByPatternAsync("*") + ? await _cache.PurgeAllAsync() : 0; return Ok(new @@ -392,21 +392,8 @@ public class ConfigController : ControllerBase { _logger.LogDebug("Cache clear requested from admin UI"); - var clearedCacheEntries = 0; - - // Clear all search cache keys (pattern-based deletion) - var searchKeysDeleted = - await _cache.DeleteByPatternAsync(CacheKeyBuilder.BuildSearchPattern()); - clearedCacheEntries += searchKeysDeleted; - - // Clear all media descriptor and content-addressed payload keys. - var imageKeysDeleted = - await _cache.DeleteByPatternAsync(CacheKeyBuilder.BuildMediaDescriptorPattern()) + - await _cache.DeleteByPatternAsync(CacheKeyBuilder.BuildMediaPayloadPattern()); - clearedCacheEntries += imageKeysDeleted; - - _logger.LogInformation("Cache cleared: {Entries} derived entries (including {SearchKeys} search keys, {ImageKeys} image keys)", - clearedCacheEntries, searchKeysDeleted, imageKeysDeleted); + var clearedCacheEntries = await _cache.PurgeAllAsync(); + _logger.LogInformation("Cache cleared: {Entries} disposable entries", clearedCacheEntries); return Ok(new { diff --git a/allstarr/Services/Common/ApplicationCachePolicyRegistry.cs b/allstarr/Services/Common/ApplicationCachePolicyRegistry.cs index 57abd188..b42c325f 100644 --- a/allstarr/Services/Common/ApplicationCachePolicyRegistry.cs +++ b/allstarr/Services/Common/ApplicationCachePolicyRegistry.cs @@ -60,38 +60,40 @@ public static class ApplicationCachePolicyRegistry ArgumentException.ThrowIfNullOrWhiteSpace(key); category = default; - if (StartsWithAny(key, "image:", "playlist:image:", "artwork:", "cover:")) + if (CacheKeyBuilder.IsMediaAssetPayloadKey(key)) category = ApplicationCacheCategory.Artwork; - if (StartsWithAny(key, "lyrics:", "lyricsplus:")) + if (StartsWithAny(key, "lyrics:v2:", "lyrics:id:v2:", "lyricsplus:v2:")) category = ApplicationCacheCategory.Lyrics; - if (StartsWithAny(key, "transcode:", "temporary-audio:")) - category = ApplicationCacheCategory.TemporaryAudio; - if (key.StartsWith("negative:", StringComparison.OrdinalIgnoreCase)) + if (key.StartsWith("negative:playback:metadata:v1:", StringComparison.Ordinal)) category = ApplicationCacheCategory.NegativeResult; - if (StartsWithAny(key, "playback:signal:", "cts:rotation:")) + if (key.StartsWith("playback:signal:dedupe:v1:", StringComparison.Ordinal)) category = ApplicationCacheCategory.Coordination; - if (key.StartsWith("admin:playlists:", StringComparison.OrdinalIgnoreCase)) - category = ApplicationCacheCategory.DerivedProjection; if (StartsWithAny( key, - "media:descriptor:", - "playlist:artwork-descriptor:", - "musicbrainz:", - "genre:", - "playback:metadata:")) + "media:descriptor:v3:", + "playlist:artwork-descriptor:v1:", + "metadata:album:v1:", + "metadata:artist:v1:", + "musicbrainz:isrc:v1:", + "musicbrainz:search:v1:", + "musicbrainz:mbid:v1:", + "genre:v2:", + "playback:metadata:v1:")) category = ApplicationCacheCategory.CanonicalMetadata; - if (key.Contains(":album:", StringComparison.OrdinalIgnoreCase) || - key.Contains(":artist:", StringComparison.OrdinalIgnoreCase)) - category = ApplicationCacheCategory.CanonicalMetadata; - if (key.StartsWith("search:", StringComparison.OrdinalIgnoreCase)) + if (key.StartsWith("search:v2:", StringComparison.Ordinal)) category = ApplicationCacheCategory.SearchResults; - if (key.StartsWith("playlist:discovery:", StringComparison.OrdinalIgnoreCase)) + if (key.StartsWith("playlist:discovery:v2:", StringComparison.Ordinal)) category = ApplicationCacheCategory.PlaylistDiscovery; - if (StartsWithAny(key, "odesli:", "jellyfin:item-type:")) + if (StartsWithAny( + key, + "odesli:tidal-to-spotify:v2:", + "odesli:url-to-spotify:v2:", + "odesli:translate:v2:", + "jellyfin:item-type:v1:")) category = ApplicationCacheCategory.ProviderResponse; return category != default || - key.StartsWith("search:", StringComparison.OrdinalIgnoreCase); + key.StartsWith("search:v2:", StringComparison.Ordinal); } public static ApplicationCacheCategoryPolicy Resolve(string key, CacheSettings? settings = null) => diff --git a/allstarr/Services/Common/BoundedHotApplicationCache.cs b/allstarr/Services/Common/BoundedHotApplicationCache.cs index 782856ed..b3f037ee 100644 --- a/allstarr/Services/Common/BoundedHotApplicationCache.cs +++ b/allstarr/Services/Common/BoundedHotApplicationCache.cs @@ -131,9 +131,6 @@ public sealed class BoundedHotApplicationCache : IApplicationCache, IDisposable return await _database.ExistsAsync(key); } - public IEnumerable GetKeysByPattern(string pattern) => - _database.GetKeysByPattern(pattern); - public async Task DeleteByPatternAsync(string pattern) { _memory.Clear(); @@ -141,6 +138,13 @@ public sealed class BoundedHotApplicationCache : IApplicationCache, IDisposable return await _database.DeleteByPatternAsync(pattern); } + public async Task PurgeAllAsync() + { + _memory.Clear(); + Interlocked.Add(ref _evictions, ClearResidents()); + return await _database.PurgeAllAsync(); + } + public async Task DeleteCategoryAsync(ApplicationCacheCategory category) { _memory.Clear(); diff --git a/allstarr/Services/Common/CacheKeyBuilder.cs b/allstarr/Services/Common/CacheKeyBuilder.cs index 937093a9..ac055fe3 100644 --- a/allstarr/Services/Common/CacheKeyBuilder.cs +++ b/allstarr/Services/Common/CacheKeyBuilder.cs @@ -1,3 +1,4 @@ +using System.Globalization; using System.Security.Cryptography; using System.Text; @@ -10,29 +11,25 @@ namespace allstarr.Services.Common; public static class CacheKeyBuilder { public static string BuildPlaybackMetadataKey(string provider, string itemId) => - $"playback:metadata:{Normalize(provider)}:{Normalize(itemId)}"; + $"playback:metadata:v1:{Normalize(provider)}:{Digest(itemId.Trim())}"; public static string BuildPlaybackMetadataNegativeKey(string provider, string itemId) => - $"negative:playback:metadata:{Normalize(provider)}:{Normalize(itemId)}"; + $"negative:playback:metadata:v1:{Normalize(provider)}:{Digest(itemId.Trim())}"; public static string BuildJellyfinItemTypeKey(string itemId) => - $"jellyfin:item-type:{Normalize(itemId)}"; + $"jellyfin:item-type:v1:{Digest(itemId.Trim())}"; public static string BuildPlaybackSignalDedupeKey( string signalType, string deviceId, - string itemId) - { - var identity = $"{Normalize(signalType)}\u001f{Normalize(deviceId)}\u001f{Normalize(itemId)}"; - var digest = SHA256.HashData(Encoding.UTF8.GetBytes(identity)); - return $"playback:signal:dedupe:{Convert.ToHexStringLower(digest)}"; - } + string itemId) => + $"playback:signal:dedupe:v1:{DigestIdentity(signalType, deviceId, itemId)}"; public static string BuildProviderPlaylistArtworkDescriptorKey( string provider, string playlistId, string? revision) => - $"playlist:artwork-descriptor:{Normalize(provider)}:{Normalize(playlistId)}:{Normalize(revision)}"; + $"playlist:artwork-descriptor:v1:{Normalize(provider)}:{Digest(playlistId.Trim())}:{Digest(revision?.Trim() ?? string.Empty)}"; public static string BuildProviderPlaylistDiscoveryKey( Guid? tenantId, @@ -44,10 +41,6 @@ public static class CacheKeyBuilder string? cursor, int limit) { - var request = string.Join('\u001f', - query?.Trim(), - cursor?.Trim(), - limit); return string.Join(':', "playlist", "discovery", @@ -55,9 +48,9 @@ public static class CacheKeyBuilder tenantId?.ToString("N") ?? "global", userId?.ToString("N") ?? "shared", accountId.ToString("N"), - accountRevision, + accountRevision.ToString(CultureInfo.InvariantCulture), Normalize(providerId), - Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(request)))); + DigestIdentity(query, cursor, limit)); } public static string BuildProviderPlaylistDiscoveryAccountPattern(Guid accountId) => @@ -69,7 +62,7 @@ public static class CacheKeyBuilder Encoding.UTF8.GetBytes(identity.ResourceId.Trim()))); var revision = Convert.ToHexStringLower(SHA256.HashData( Encoding.UTF8.GetBytes(identity.Revision?.Trim() ?? string.Empty))); - var dimensions = $"{identity.Width ?? 0}x{identity.Height ?? 0}"; + var dimensions = FormattableString.Invariant($"{identity.Width ?? 0}x{identity.Height ?? 0}"); return string.Join(':', "media", "descriptor", @@ -88,15 +81,10 @@ public static class CacheKeyBuilder $"media:descriptor:v3:*:*:{accountId:N}:*"; public static string BuildMediaAssetPayloadKey(string sha256) => - $"artwork:payload:v1:{sha256}"; + $"artwork:payload:v1:{Normalize(sha256)}"; #region Search Keys - public static string BuildSearchKey(string? searchTerm, string? itemTypes, int? limit, int? startIndex) - { - return $"search:{searchTerm?.ToLowerInvariant()}:{itemTypes}:{limit}:{startIndex}"; - } - public static string BuildSearchKey( string? searchTerm, string? itemTypes, @@ -109,20 +97,19 @@ public static class CacheKeyBuilder string? userId, string? isFavorite = null) { - var normalizedTerm = Normalize(searchTerm); - var normalizedItemTypes = Normalize(itemTypes); - var normalizedParentId = Normalize(parentId); - var normalizedSortBy = Normalize(sortBy); - var normalizedSortOrder = Normalize(sortOrder); - var normalizedUserId = Normalize(userId); - var normalizedIsFavorite = Normalize(isFavorite); - var normalizedRecursive = recursive.HasValue ? (recursive.Value ? "true" : "false") : string.Empty; - - return $"search:{normalizedTerm}:{normalizedItemTypes}:{limit}:{startIndex}:{normalizedParentId}:{normalizedSortBy}:{normalizedSortOrder}:{normalizedRecursive}:{normalizedUserId}:{normalizedIsFavorite}"; + return $"search:v2:{DigestIdentity( + searchTerm, + itemTypes, + limit, + startIndex, + parentId, + sortBy, + sortOrder, + recursive, + userId, + isFavorite)}"; } - public static string BuildSearchPattern() => "search:*"; - private static string Normalize(string? value) { return string.IsNullOrWhiteSpace(value) @@ -136,12 +123,12 @@ public static class CacheKeyBuilder public static string BuildAlbumKey(string provider, string externalId) { - return $"{provider}:album:{externalId}"; + return $"metadata:album:v1:{Normalize(provider)}:{Digest(externalId.Trim())}"; } public static string BuildArtistKey(string provider, string externalId) { - return $"{provider}:artist:{externalId}"; + return $"metadata:artist:v1:{Normalize(provider)}:{Digest(externalId.Trim())}"; } #endregion @@ -150,39 +137,36 @@ public static class CacheKeyBuilder public static string BuildLyricsKey(string artist, string title, string? album, int? durationSeconds) { - return $"lyrics:{artist}:{title}:{album}:{durationSeconds}"; + return $"lyrics:v2:{DigestIdentity(artist, title, album, durationSeconds)}"; } public static string BuildLyricsPlusKey(string artist, string title, string? album, int? durationSeconds) { - return $"lyricsplus:{artist}:{title}:{album}:{durationSeconds}"; + return $"lyricsplus:v2:{DigestIdentity(artist, title, album, durationSeconds)}"; } public static string BuildLyricsByIdKey(int id) { - return $"lyrics:id:{id}"; + return $"lyrics:id:v2:{id.ToString(CultureInfo.InvariantCulture)}"; } #endregion #region Image Keys - public static string BuildMediaDescriptorPattern() => "media:descriptor:*"; + public static bool IsMediaAssetDescriptorKey(string key) => + key.StartsWith("media:descriptor:v3:", StringComparison.Ordinal); - public static string BuildMediaPayloadPattern() => "artwork:payload:*"; + public static bool IsMediaAssetPayloadKey(string key) => + key.StartsWith("artwork:payload:v1:", StringComparison.Ordinal); #endregion #region Genre Keys - public static string BuildGenreEnrichmentKey(string title, string artist) - { - return $"genre:{title}:{artist}"; - } - public static string BuildGenreEnrichmentKey(string compositeCacheKey) { - return $"genre:{compositeCacheKey}"; + return $"genre:v2:{Digest(compositeCacheKey.Trim())}"; } #endregion @@ -191,17 +175,17 @@ public static class CacheKeyBuilder public static string BuildMusicBrainzIsrcKey(string isrc) { - return $"musicbrainz:isrc:{isrc}"; + return $"musicbrainz:isrc:v1:{Normalize(isrc)}"; } public static string BuildMusicBrainzSearchKey(string title, string artist, int limit) { - return $"musicbrainz:search:{title.ToLowerInvariant()}:{artist.ToLowerInvariant()}:{limit}"; + return $"musicbrainz:search:v1:{DigestIdentity(title, artist, limit)}"; } public static string BuildMusicBrainzMbidKey(string mbid) { - return $"musicbrainz:mbid:{mbid}"; + return $"musicbrainz:mbid:v1:{Normalize(mbid)}"; } #endregion @@ -210,7 +194,7 @@ public static class CacheKeyBuilder public static string BuildOdesliTidalToSpotifyKey(string tidalTrackId) { - return $"odesli:tidal-to-spotify:{tidalTrackId}"; + return $"odesli:tidal-to-spotify:v2:{Digest(tidalTrackId.Trim())}"; } public static string BuildOdesliUrlToSpotifyKey(string musicUrl) @@ -220,13 +204,20 @@ public static class CacheKeyBuilder public static string BuildOdesliTranslationKey(string sourceUrl, string targetPlatform) { - return $"odesli:translate:v2:{HashOdesliUrl(sourceUrl)}:{targetPlatform.ToLowerInvariant()}"; + return $"odesli:translate:v2:{HashOdesliUrl(sourceUrl)}:{Normalize(targetPlatform)}"; } private static string HashOdesliUrl(string value) => - Convert.ToHexStringLower( - System.Security.Cryptography.SHA256.HashData( - System.Text.Encoding.UTF8.GetBytes(value))); + Digest(value); + + private static string DigestIdentity(params object?[] values) => + Digest(string.Join('\u001f', values.Select(value => Normalize( + value is IFormattable formattable + ? formattable.ToString(null, CultureInfo.InvariantCulture) + : value?.ToString())))); + + private static string Digest(string value) => + Convert.ToHexStringLower(SHA256.HashData(Encoding.UTF8.GetBytes(value))); #endregion } diff --git a/allstarr/Services/Common/DatabaseApplicationCache.cs b/allstarr/Services/Common/DatabaseApplicationCache.cs index b7a94153..e165ded5 100644 --- a/allstarr/Services/Common/DatabaseApplicationCache.cs +++ b/allstarr/Services/Common/DatabaseApplicationCache.cs @@ -327,28 +327,6 @@ public sealed class DatabaseApplicationCache( } } - public IEnumerable GetKeysByPattern(string pattern) - { - try - { - var now = clock.UtcNow; - using var database = contextFactory.CreateDbContext(); - var likePattern = ToLikePattern(pattern); - return database.Set() - .AsNoTracking() - .Where(item => - (item.ExpiresAt == null || item.ExpiresAt > now) && - EF.Functions.Like(item.Key, likePattern, "\\")) - .Select(item => item.Key) - .ToArray(); - } - catch (Exception exception) - { - logger.LogWarning(exception, "Database cache key scan failed for pattern {Pattern}", pattern); - return Array.Empty(); - } - } - public async Task DeleteByPatternAsync(string pattern) { try @@ -368,6 +346,22 @@ public sealed class DatabaseApplicationCache( } } + public async Task PurgeAllAsync() + { + try + { + await using var database = await contextFactory.CreateDbContextAsync(); + var deleted = await database.Set().ExecuteDeleteAsync(); + Interlocked.Add(ref _evictions, deleted); + return deleted; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Database cache purge failed"); + return 0; + } + } + public async Task DeleteCategoryAsync(ApplicationCacheCategory category) { try @@ -594,7 +588,7 @@ public sealed class DatabaseApplicationCache( var values = await database.Set() .AsNoTracking() .Where(item => - item.Key.StartsWith("media:descriptor:") && + item.Key.StartsWith("media:descriptor:v3:") && (item.ExpiresAt == null || item.ExpiresAt > clock.UtcNow)) .OrderBy(item => item.Key) .Select(item => item.Value) @@ -787,7 +781,7 @@ public sealed class DatabaseApplicationCache( !Enum.IsDefined(category) || ApplicationCachePolicyRegistry.Resolve(category).StorageTier != ApplicationCacheStorageTier.Metadata || expectedCategory != category || - (item.Key.StartsWith("media:descriptor:", StringComparison.Ordinal) && + (CacheKeyBuilder.IsMediaAssetDescriptorKey(item.Key) && ReadArtworkPayloadKey(item.Value) == null); private static string? ReadArtworkPayloadKey(string value) @@ -813,7 +807,7 @@ public sealed class DatabaseApplicationCache( private static IEnumerable FindSupersededArtworkDescriptors( IEnumerable entries) => entries - .Where(item => item.Key.StartsWith("media:descriptor:v3:", StringComparison.Ordinal)) + .Where(item => CacheKeyBuilder.IsMediaAssetDescriptorKey(item.Key)) .GroupBy(item => item.Key[..item.Key.LastIndexOf(':')], StringComparer.Ordinal) .SelectMany(group => group .OrderByDescending(item => item.UpdatedAt) diff --git a/allstarr/Services/Common/FileMediaApplicationCache.cs b/allstarr/Services/Common/FileMediaApplicationCache.cs index 56abc959..8e4a0d97 100644 --- a/allstarr/Services/Common/FileMediaApplicationCache.cs +++ b/allstarr/Services/Common/FileMediaApplicationCache.cs @@ -313,29 +313,6 @@ public sealed class FileMediaApplicationCache : IApplicationCache, IDisposable public async Task ExistsAsync(string key) => await GetStringAsync(key) is not null; - public IEnumerable GetKeysByPattern(string pattern) - { - _gate.Wait(); - try - { - return ReadAllMetadata() - .Where(item => - (item.ExpiresAt is null || item.ExpiresAt > _clock.UtcNow) && - FileSystemName.MatchesSimpleExpression(pattern, item.Key, ignoreCase: true)) - .Select(item => item.Key) - .ToArray(); - } - catch (Exception exception) - { - _logger.LogWarning(exception, "Disk media cache key scan failed for pattern {Pattern}", pattern); - return Array.Empty(); - } - finally - { - _gate.Release(); - } - } - public async Task DeleteByPatternAsync(string pattern) { await _gate.WaitAsync(); @@ -366,6 +343,32 @@ public sealed class FileMediaApplicationCache : IApplicationCache, IDisposable } } + public async Task PurgeAllAsync() + { + await _gate.WaitAsync(); + try + { + if (!Directory.Exists(_options.RootPath)) return 0; + var deleted = Directory.EnumerateFiles( + _options.RootPath, + "*.json", + SearchOption.AllDirectories) + .Count(); + Directory.Delete(_options.RootPath, recursive: true); + Interlocked.Add(ref _evictions, deleted); + return deleted; + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Disk media cache purge failed"); + return 0; + } + finally + { + _gate.Release(); + } + } + public async Task DeleteCategoryAsync(ApplicationCacheCategory category) { await _gate.WaitAsync(); @@ -568,7 +571,7 @@ public sealed class FileMediaApplicationCache : IApplicationCache, IDisposable var take = Math.Clamp(maximumEntries, 1, _options.MaximumCleanupFiles); var entries = ReadAllMetadata() .Where(item => - item.Key.StartsWith("artwork:payload:", StringComparison.Ordinal) && + CacheKeyBuilder.IsMediaAssetPayloadKey(item.Key) && item.LastAccessAt <= _clock.UtcNow.AddMinutes(-5) && !referencedPayloadKeys.Contains(item.Key)) .OrderBy(item => item.LastAccessAt) @@ -849,16 +852,8 @@ public sealed class HybridApplicationCache( ? Target(key).ExistsAsync(key) : Task.FromResult(false); - public IEnumerable GetKeysByPattern(string pattern) => - metadata.GetKeysByPattern(pattern) - .Concat(media.GetKeysByPattern(pattern)) - .Where(IsCategoryEnabled) - .Distinct(StringComparer.Ordinal) - .ToArray(); - - public async Task DeleteByPatternAsync(string pattern) => - await metadata.DeleteByPatternAsync(pattern) + - await media.DeleteByPatternAsync(pattern); + public Task DeleteByPatternAsync(string pattern) => + metadata.DeleteByPatternAsync(pattern); public async Task GetDiagnosticsAsync( CancellationToken cancellationToken = default) @@ -900,9 +895,9 @@ public sealed class HybridApplicationCache( }; } - public Task PurgeMetadataAsync() => metadata.DeleteByPatternAsync("*"); + public Task PurgeMetadataAsync() => metadata.PurgeAllAsync(); - public Task PurgeMediaAsync() => media.DeleteByPatternAsync("*"); + public Task PurgeMediaAsync() => media.PurgeAllAsync(); public async Task PreviewMaintenanceAsync( CancellationToken cancellationToken = default) diff --git a/allstarr/Services/Common/IApplicationCache.cs b/allstarr/Services/Common/IApplicationCache.cs index 5138eff2..2fa82630 100644 --- a/allstarr/Services/Common/IApplicationCache.cs +++ b/allstarr/Services/Common/IApplicationCache.cs @@ -20,7 +20,7 @@ public interface IApplicationCache Task ExistsAsync(string key); - IEnumerable GetKeysByPattern(string pattern); - Task DeleteByPatternAsync(string pattern); + + Task PurgeAllAsync(); }