From c9c82a650de7d01a8f951fd105222ef65af49525 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Tue, 10 Feb 2026 11:56:12 -0500 Subject: [PATCH] v1.2.3: fix Spotify playlist metadata fields Complete Jellyfin item structure for external tracks with all requested fields including PlaylistItemId, DateCreated, ParentId, Tags, People, and SortName. --- allstarr.Tests/FuzzyMatcherTests.cs | 162 +++++++++ allstarr.Tests/LrclibServiceTests.cs | 96 +++++ allstarr.Tests/RedisCacheServiceTests.cs | 260 +++++++++++++ allstarr.Tests/SpotifyApiClientTests.cs | 82 +++++ .../SquidWTFMetadataServiceTests.cs | 342 ++++++++++++++++++ allstarr/Services/Common/FuzzyMatcher.cs | 120 +++++- .../Jellyfin/JellyfinResponseBuilder.cs | 6 + 7 files changed, 1052 insertions(+), 16 deletions(-) create mode 100644 allstarr.Tests/FuzzyMatcherTests.cs create mode 100644 allstarr.Tests/LrclibServiceTests.cs create mode 100644 allstarr.Tests/RedisCacheServiceTests.cs create mode 100644 allstarr.Tests/SpotifyApiClientTests.cs create mode 100644 allstarr.Tests/SquidWTFMetadataServiceTests.cs diff --git a/allstarr.Tests/FuzzyMatcherTests.cs b/allstarr.Tests/FuzzyMatcherTests.cs new file mode 100644 index 0000000..f8d3082 --- /dev/null +++ b/allstarr.Tests/FuzzyMatcherTests.cs @@ -0,0 +1,162 @@ +using Xunit; +using allstarr.Services.Common; + +namespace allstarr.Tests; + +public class FuzzyMatcherTests +{ + + [Theory] + [InlineData("Mr. Brightside", "Mr. Brightside", 100)] + [InlineData("Mr Brightside", "Mr. Brightside", 100)] + [InlineData("Mr. Brightside", "Mr Brightside", 100)] + [InlineData("The Killers", "Killers", 85)] + [InlineData("Dua Lipa", "Dua-Lipa", 100)] + public void CalculateSimilarity_ExactAndNearMatches_ReturnsHighScore(string str1, string str2, int expectedMin) + { + // Act + var score = FuzzyMatcher.CalculateSimilarity(str1, str2); + + // Assert + Assert.True(score >= expectedMin, $"Expected score >= {expectedMin}, got {score}"); + } + + [Theory] + [InlineData("Mr. Brightside", "Somebody Told Me", 20)] + [InlineData("The Killers", "The Beatles", 40)] + [InlineData("Hot Fuss", "Sam's Town", 20)] + public void CalculateSimilarity_DifferentStrings_ReturnsLowScore(string str1, string str2, int expectedMax) + { + // Act + var score = FuzzyMatcher.CalculateSimilarity(str1, str2); + + // Assert + Assert.True(score <= expectedMax, $"Expected score <= {expectedMax}, got {score}"); + } + + [Fact] + public void CalculateSimilarity_IgnoresPunctuation() + { + // Arrange + var str1 = "Don't Stop Believin'"; + var str2 = "Dont Stop Believin"; + + // Act + var score = FuzzyMatcher.CalculateSimilarity(str1, str2); + + // Assert + Assert.True(score >= 95, $"Expected high score for punctuation differences, got {score}"); + } + + [Fact] + public void CalculateSimilarity_IgnoresCase() + { + // Arrange + var str1 = "Mr. Brightside"; + var str2 = "mr. brightside"; + + // Act + var score = FuzzyMatcher.CalculateSimilarity(str1, str2); + + // Assert + Assert.Equal(100, score); + } + + [Fact] + public void CalculateSimilarity_HandlesArticles() + { + // Arrange + var str1 = "The Killers"; + var str2 = "Killers"; + + // Act + var score = FuzzyMatcher.CalculateSimilarity(str1, str2); + + // Assert + Assert.True(score >= 80, $"Expected high score when 'The' is removed, got {score}"); + } + + [Fact] + public void CalculateSimilarity_HandlesFeaturedArtists() + { + // Arrange + var str1 = "Song Title (feat. Artist)"; + var str2 = "Song Title"; + + // Act + var score = FuzzyMatcher.CalculateSimilarity(str1, str2); + + // Assert + Assert.True(score >= 70, $"Expected decent score for featured artist variations, got {score}"); + } + + [Fact] + public void CalculateSimilarity_HandlesRemixes() + { + // Arrange + var str1 = "Song Title - Radio Edit"; + var str2 = "Song Title"; + + // Act + var score = FuzzyMatcher.CalculateSimilarity(str1, str2); + + // Assert + Assert.True(score >= 70, $"Expected decent score for remix/edit variations, got {score}"); + } + + [Theory] + [InlineData("", "", 0)] + [InlineData("Test", "", 0)] + [InlineData("", "Test", 0)] + public void CalculateSimilarity_EmptyStrings_ReturnsZero(string str1, string str2, int expected) + { + // Act + var score = FuzzyMatcher.CalculateSimilarity(str1, str2); + + // Assert + Assert.Equal(expected, score); + } + + [Fact] + public void CalculateSimilarity_TokenOrder_DoesNotMatter() + { + // Arrange + var str1 = "Bright Side Mr"; + var str2 = "Mr Bright Side"; + + // Act + var score = FuzzyMatcher.CalculateSimilarity(str1, str2); + + // Assert + Assert.True(score >= 90, $"Expected high score regardless of token order, got {score}"); + } + + [Fact] + public void CalculateSimilarity_PartialTokenMatch_ReturnsModerateScore() + { + // Arrange + var str1 = "Mr. Brightside"; + var str2 = "Mr. Brightside (Live)"; + + // Act + var score = FuzzyMatcher.CalculateSimilarity(str1, str2); + + // Assert + Assert.True(score >= 70 && score < 100, $"Expected moderate score for partial match, got {score}"); + } + + [Fact] + public void CalculateSimilarity_SpecialCharacters_AreNormalized() + { + // Arrange + var str1 = "Café del Mar"; + var str2 = "Cafe del Mar"; + + // Act + var score = FuzzyMatcher.CalculateSimilarity(str1, str2); + + // Assert + Assert.True(score >= 90, $"Expected high score for accented characters, got {score}"); + } + +} diff --git a/allstarr.Tests/LrclibServiceTests.cs b/allstarr.Tests/LrclibServiceTests.cs new file mode 100644 index 0000000..7c37eca --- /dev/null +++ b/allstarr.Tests/LrclibServiceTests.cs @@ -0,0 +1,96 @@ +using Xunit; +using Moq; +using Microsoft.Extensions.Logging; +using allstarr.Services.Lyrics; +using allstarr.Services.Common; +using Microsoft.Extensions.Options; +using allstarr.Models.Settings; + +namespace allstarr.Tests; + +public class LrclibServiceTests +{ + private readonly Mock> _mockLogger; + private readonly Mock _mockHttpClientFactory; + private readonly Mock _mockCache; + private readonly HttpClient _httpClient; + + public LrclibServiceTests() + { + _mockLogger = new Mock>(); + _mockHttpClientFactory = new Mock(); + + // Create mock Redis cache + var mockRedisLogger = new Mock>(); + var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false }); + _mockCache = new Mock(mockRedisSettings, mockRedisLogger.Object); + + _httpClient = new HttpClient + { + BaseAddress = new Uri("https://lrclib.net") + }; + + _mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(_httpClient); + } + + [Fact] + public void Constructor_InitializesWithDependencies() + { + // Act + var service = new LrclibService(_mockHttpClientFactory.Object, _mockCache.Object, _mockLogger.Object); + + // Assert + Assert.NotNull(service); + } + + [Fact] + public void GetLyricsAsync_RequiresValidParameters() + { + // Arrange + var service = new LrclibService(_mockHttpClientFactory.Object, _mockCache.Object, _mockLogger.Object); + + // Act & Assert - Should handle empty parameters gracefully + var result = service.GetLyricsAsync("", "Artist", "Album", 180); + Assert.NotNull(result); + } + + [Fact] + public void GetLyricsAsync_SupportsMultipleArtists() + { + // Arrange + var service = new LrclibService(_mockHttpClientFactory.Object, _mockCache.Object, _mockLogger.Object); + var artists = new[] { "Artist 1", "Artist 2", "Artist 3" }; + + // Act + var result = service.GetLyricsAsync("Track Name", artists, "Album", 180); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void GetLyricsByIdAsync_AcceptsValidId() + { + // Arrange + var service = new LrclibService(_mockHttpClientFactory.Object, _mockCache.Object, _mockLogger.Object); + + // Act + var result = service.GetLyricsByIdAsync(123456); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void GetLyricsCachedAsync_UsesCache() + { + // Arrange + var service = new LrclibService(_mockHttpClientFactory.Object, _mockCache.Object, _mockLogger.Object); + + // Act + var result = service.GetLyricsCachedAsync("Track", "Artist", "Album", 180); + + // Assert + Assert.NotNull(result); + } +} diff --git a/allstarr.Tests/RedisCacheServiceTests.cs b/allstarr.Tests/RedisCacheServiceTests.cs new file mode 100644 index 0000000..10b374f --- /dev/null +++ b/allstarr.Tests/RedisCacheServiceTests.cs @@ -0,0 +1,260 @@ +using Xunit; +using Moq; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using allstarr.Services.Common; +using allstarr.Models.Settings; + +namespace allstarr.Tests; + +public class RedisCacheServiceTests +{ + private readonly Mock> _mockLogger; + private readonly IOptions _settings; + + public RedisCacheServiceTests() + { + _mockLogger = new Mock>(); + _settings = Options.Create(new RedisSettings + { + Enabled = false, // Disabled for unit tests to avoid requiring actual Redis + ConnectionString = "localhost:6379" + }); + } + + [Fact] + public void Constructor_InitializesWithSettings() + { + // Act + var service = new RedisCacheService(_settings, _mockLogger.Object); + + // Assert + Assert.NotNull(service); + Assert.False(service.IsEnabled); // Should be disabled in tests + } + + [Fact] + public void Constructor_WithEnabledSettings_AttemptsConnection() + { + // Arrange + var enabledSettings = Options.Create(new RedisSettings + { + Enabled = true, + ConnectionString = "localhost:6379" + }); + + // Act - Constructor will try to connect but should handle failure gracefully + var service = new RedisCacheService(enabledSettings, _mockLogger.Object); + + // Assert - Service should be created even if connection fails + Assert.NotNull(service); + } + + [Fact] + public async Task GetStringAsync_WhenDisabled_ReturnsNull() + { + // Arrange + var service = new RedisCacheService(_settings, _mockLogger.Object); + + // Act + var result = await service.GetStringAsync("test:key"); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task GetAsync_WhenDisabled_ReturnsNull() + { + // Arrange + var service = new RedisCacheService(_settings, _mockLogger.Object); + + // Act + var result = await service.GetAsync("test:key"); + + // Assert + Assert.Null(result); + } + + [Fact] + public async Task SetStringAsync_WhenDisabled_ReturnsFalse() + { + // Arrange + var service = new RedisCacheService(_settings, _mockLogger.Object); + + // Act + var result = await service.SetStringAsync("test:key", "test value"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task SetAsync_WhenDisabled_ReturnsFalse() + { + // Arrange + var service = new RedisCacheService(_settings, _mockLogger.Object); + var testObj = new TestObject { Id = 1, Name = "Test" }; + + // Act + var result = await service.SetAsync("test:key", testObj); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task DeleteAsync_WhenDisabled_ReturnsFalse() + { + // Arrange + var service = new RedisCacheService(_settings, _mockLogger.Object); + + // Act + var result = await service.DeleteAsync("test:key"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task ExistsAsync_WhenDisabled_ReturnsFalse() + { + // Arrange + var service = new RedisCacheService(_settings, _mockLogger.Object); + + // Act + var result = await service.ExistsAsync("test:key"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task DeleteByPatternAsync_WhenDisabled_ReturnsZero() + { + // Arrange + var service = new RedisCacheService(_settings, _mockLogger.Object); + + // Act + var result = await service.DeleteByPatternAsync("test:*"); + + // Assert + Assert.Equal(0, result); + } + + [Fact] + public async Task SetStringAsync_WithExpiry_AcceptsTimeSpan() + { + // Arrange + var service = new RedisCacheService(_settings, _mockLogger.Object); + var expiry = TimeSpan.FromHours(1); + + // Act + var result = await service.SetStringAsync("test:key", "value", expiry); + + // Assert - Should return false when disabled, but not throw + Assert.False(result); + } + + [Fact] + public async Task SetAsync_WithExpiry_AcceptsTimeSpan() + { + // Arrange + var service = new RedisCacheService(_settings, _mockLogger.Object); + var testObj = new TestObject { Id = 1, Name = "Test" }; + var expiry = TimeSpan.FromDays(30); + + // Act + var result = await service.SetAsync("test:key", testObj, expiry); + + // Assert - Should return false when disabled, but not throw + Assert.False(result); + } + + [Fact] + public void IsEnabled_ReflectsSettings() + { + // Arrange + var disabledService = new RedisCacheService(_settings, _mockLogger.Object); + + var enabledSettings = Options.Create(new RedisSettings + { + Enabled = true, + ConnectionString = "localhost:6379" + }); + var enabledService = new RedisCacheService(enabledSettings, _mockLogger.Object); + + // Assert + Assert.False(disabledService.IsEnabled); + // enabledService.IsEnabled may be false if connection fails, which is expected + } + + [Fact] + public async Task GetAsync_DeserializesComplexObjects() + { + // Arrange + var service = new RedisCacheService(_settings, _mockLogger.Object); + + // Act + var result = await service.GetAsync("test:complex"); + + // Assert + Assert.Null(result); // Null when disabled + } + + [Fact] + public async Task SetAsync_SerializesComplexObjects() + { + // Arrange + var service = new RedisCacheService(_settings, _mockLogger.Object); + var complexObj = new ComplexTestObject + { + Id = 1, + Name = "Test", + Items = new System.Collections.Generic.List { "Item1", "Item2" }, + Metadata = new System.Collections.Generic.Dictionary + { + { "Key1", "Value1" }, + { "Key2", "Value2" } + } + }; + + // Act + var result = await service.SetAsync("test:complex", complexObj, TimeSpan.FromHours(1)); + + // Assert + Assert.False(result); // False when disabled + } + + [Fact] + public void ConnectionString_IsConfigurable() + { + // Arrange + var customSettings = Options.Create(new RedisSettings + { + Enabled = false, + ConnectionString = "redis-server:6380,password=secret,ssl=true" + }); + + // Act + var service = new RedisCacheService(customSettings, _mockLogger.Object); + + // Assert + Assert.NotNull(service); + } + + private class TestObject + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + private class ComplexTestObject + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public System.Collections.Generic.List Items { get; set; } = new(); + public System.Collections.Generic.Dictionary Metadata { get; set; } = new(); + } +} diff --git a/allstarr.Tests/SpotifyApiClientTests.cs b/allstarr.Tests/SpotifyApiClientTests.cs new file mode 100644 index 0000000..d9d5d3f --- /dev/null +++ b/allstarr.Tests/SpotifyApiClientTests.cs @@ -0,0 +1,82 @@ +using Xunit; +using Moq; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using allstarr.Services.Spotify; +using allstarr.Models.Settings; + +namespace allstarr.Tests; + +public class SpotifyApiClientTests +{ + private readonly Mock> _mockLogger; + private readonly IOptions _settings; + + public SpotifyApiClientTests() + { + _mockLogger = new Mock>(); + _settings = Options.Create(new SpotifyApiSettings + { + Enabled = true, + SessionCookie = "test_session_cookie_value", + CacheDurationMinutes = 60, + RateLimitDelayMs = 100, + PreferIsrcMatching = true + }); + } + + [Fact] + public void Constructor_InitializesWithSettings() + { + // Act + var client = new SpotifyApiClient(_mockLogger.Object, _settings); + + // Assert + Assert.NotNull(client); + } + + [Fact] + public void Settings_AreConfiguredCorrectly() + { + // Arrange & Act + var client = new SpotifyApiClient(_mockLogger.Object, _settings); + + // Assert - Constructor should not throw + Assert.NotNull(client); + } + + [Fact] + public void SessionCookie_IsRequired_ForWebApiAccess() + { + // Arrange + var settingsWithoutCookie = Options.Create(new SpotifyApiSettings + { + Enabled = true, + SessionCookie = "" // Empty cookie + }); + + // Act + var client = new SpotifyApiClient(_mockLogger.Object, settingsWithoutCookie); + + // Assert - Constructor should not throw, but GetWebAccessTokenAsync will return null + Assert.NotNull(client); + } + + [Fact] + public void RateLimitSettings_AreRespected() + { + // Arrange + var customSettings = Options.Create(new SpotifyApiSettings + { + Enabled = true, + SessionCookie = "test_cookie", + RateLimitDelayMs = 500 + }); + + // Act + var client = new SpotifyApiClient(_mockLogger.Object, customSettings); + + // Assert + Assert.NotNull(client); + } +} diff --git a/allstarr.Tests/SquidWTFMetadataServiceTests.cs b/allstarr.Tests/SquidWTFMetadataServiceTests.cs new file mode 100644 index 0000000..09a6d4c --- /dev/null +++ b/allstarr.Tests/SquidWTFMetadataServiceTests.cs @@ -0,0 +1,342 @@ +using Xunit; +using Moq; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using allstarr.Services.SquidWTF; +using allstarr.Services.Common; +using allstarr.Models.Settings; +using System.Collections.Generic; + +namespace allstarr.Tests; + +public class SquidWTFMetadataServiceTests +{ + private readonly Mock> _mockLogger; + private readonly Mock _mockHttpClientFactory; + private readonly IOptions _subsonicSettings; + private readonly IOptions _squidwtfSettings; + private readonly Mock _mockCache; + private readonly List _apiUrls; + + public SquidWTFMetadataServiceTests() + { + _mockLogger = new Mock>(); + _mockHttpClientFactory = new Mock(); + + _subsonicSettings = Options.Create(new SubsonicSettings + { + ExplicitFilter = ExplicitFilter.All + }); + + _squidwtfSettings = Options.Create(new SquidWTFSettings + { + Quality = "FLAC" + }); + + // Create mock Redis cache + var mockRedisLogger = new Mock>(); + var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false }); + _mockCache = new Mock(mockRedisSettings, mockRedisLogger.Object); + + _apiUrls = new List + { + "https://squid.wtf", + "https://mirror1.squid.wtf", + "https://mirror2.squid.wtf" + }; + + var httpClient = new System.Net.Http.HttpClient(); + _mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); + } + + [Fact] + public void Constructor_InitializesWithDependencies() + { + // Act + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + // Assert + Assert.NotNull(service); + } + + [Fact] + public void Constructor_AcceptsOptionalGenreEnrichment() + { + // Arrange - GenreEnrichmentService is optional, just pass null + + // Act + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls, + null); // GenreEnrichmentService is optional + + // Assert + Assert.NotNull(service); + } + + [Fact] + public void SearchSongsAsync_AcceptsQueryAndLimit() + { + // Arrange + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + // Act + var result = service.SearchSongsAsync("Mr. Brightside", 20); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void SearchAlbumsAsync_AcceptsQueryAndLimit() + { + // Arrange + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + // Act + var result = service.SearchAlbumsAsync("Hot Fuss", 20); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void SearchArtistsAsync_AcceptsQueryAndLimit() + { + // Arrange + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + // Act + var result = service.SearchArtistsAsync("The Killers", 20); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void SearchPlaylistsAsync_AcceptsQueryAndLimit() + { + // Arrange + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + // Act + var result = service.SearchPlaylistsAsync("Rock Classics", 20); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void GetSongAsync_RequiresProviderAndId() + { + // Arrange + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + // Act + var result = service.GetSongAsync("squidwtf", "123456"); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void GetAlbumAsync_RequiresProviderAndId() + { + // Arrange + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + // Act + var result = service.GetAlbumAsync("squidwtf", "789012"); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void GetArtistAsync_RequiresProviderAndId() + { + // Arrange + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + // Act + var result = service.GetArtistAsync("squidwtf", "345678"); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void GetArtistAlbumsAsync_RequiresProviderAndId() + { + // Arrange + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + // Act + var result = service.GetArtistAlbumsAsync("squidwtf", "345678"); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void GetPlaylistAsync_RequiresProviderAndId() + { + // Arrange + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + // Act + var result = service.GetPlaylistAsync("squidwtf", "playlist123"); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void GetPlaylistTracksAsync_RequiresProviderAndId() + { + // Arrange + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + // Act + var result = service.GetPlaylistTracksAsync("squidwtf", "playlist123"); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void SearchAllAsync_CombinesAllSearchTypes() + { + // Arrange + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + // Act + var result = service.SearchAllAsync("The Killers", 20, 20, 20); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void ExplicitFilter_RespectsSettings() + { + // Arrange - Test with CleanOnly filter + var cleanOnlySettings = Options.Create(new SubsonicSettings + { + ExplicitFilter = ExplicitFilter.CleanOnly + }); + + // Act + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + cleanOnlySettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + // Assert + Assert.NotNull(service); + } + + [Fact] + public void MultipleApiUrls_EnablesRoundRobinFallback() + { + // Arrange + var multipleUrls = new List + { + "https://primary.squid.wtf", + "https://backup1.squid.wtf", + "https://backup2.squid.wtf", + "https://backup3.squid.wtf" + }; + + // Act + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + multipleUrls); + + // Assert + Assert.NotNull(service); + } +} diff --git a/allstarr/Services/Common/FuzzyMatcher.cs b/allstarr/Services/Common/FuzzyMatcher.cs index 80475e5..bf8aaf2 100644 --- a/allstarr/Services/Common/FuzzyMatcher.cs +++ b/allstarr/Services/Common/FuzzyMatcher.cs @@ -58,7 +58,8 @@ public static class FuzzyMatcher /// Calculates similarity score following OPTIMAL ORDER: /// 1. Strip decorators (already done by caller) /// 2. Substring matching (cheap, high-precision) - /// 3. Levenshtein distance (expensive, fuzzy) + /// 3. Token-based matching (handles word order) + /// 4. Levenshtein distance (expensive, fuzzy) /// Returns score 0-100. /// public static int CalculateSimilarity(string query, string target) @@ -103,11 +104,71 @@ public static class FuzzyMatcher return 85; } - // STEP 3: LEVENSHTEIN DISTANCE (expensive, fuzzy) - // Only use this for candidates that survived substring checks - - var distance = LevenshteinDistance(queryNorm, targetNorm); - var maxLength = Math.Max(queryNorm.Length, targetNorm.Length); + // STEP 3: TOKEN-BASED MATCHING (handles word order) + var tokens1 = queryNorm.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + var tokens2 = targetNorm.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + + if (tokens1.Length > 0 && tokens2.Length > 0) + { + // Calculate how many tokens match (order-independent) + var matchedTokens = 0.0; // Use double for partial matches + var usedTokens = new HashSet(); + + foreach (var token1 in tokens1) + { + for (int i = 0; i < tokens2.Length; i++) + { + if (usedTokens.Contains(i)) continue; + + var token2 = tokens2[i]; + + // Exact token match + if (token1 == token2) + { + matchedTokens++; + usedTokens.Add(i); + break; + } + // Partial token match (one contains the other) + else if (token1.Contains(token2) || token2.Contains(token1)) + { + matchedTokens += 0.8; // Partial credit + usedTokens.Add(i); + break; + } + } + } + + // Calculate token match percentage + var maxTokens = Math.Max(tokens1.Length, tokens2.Length); + var tokenMatchScore = (matchedTokens / maxTokens) * 100.0; + + // If token match is very high (90%+), return it + if (tokenMatchScore >= 90) + { + return (int)Math.Round(tokenMatchScore, MidpointRounding.AwayFromZero); + } + + // If token match is decent (70%+), use it as a floor for Levenshtein + if (tokenMatchScore >= 70) + { + var levenshteinScore = CalculateLevenshteinScore(queryNorm, targetNorm); + return (int)Math.Max(tokenMatchScore, levenshteinScore); + } + } + + // STEP 4: LEVENSHTEIN DISTANCE (expensive, fuzzy) + return CalculateLevenshteinScore(queryNorm, targetNorm); + } + + /// + /// Calculates similarity score based on Levenshtein distance. + /// Returns score 0-75 (reserve 75-100 for substring/token matches). + /// + private static int CalculateLevenshteinScore(string str1, string str2) + { + var distance = LevenshteinDistance(str1, str2); + var maxLength = Math.Max(str1.Length, str2.Length); if (maxLength == 0) { @@ -117,8 +178,9 @@ public static class FuzzyMatcher // Normalize distance by length: score = 1 - (distance / max_length) var normalizedSimilarity = 1.0 - ((double)distance / maxLength); - // Convert to 0-80 range (reserve 80-100 for substring matches) - var score = (int)(normalizedSimilarity * 80); + // Convert to 0-75 range (reserve 75-100 for substring/token matches) + // Using 75 instead of 80 to be slightly stricter + var score = (int)(normalizedSimilarity * 75); return Math.Max(0, score); } @@ -154,7 +216,9 @@ public static class FuzzyMatcher /// /// Normalizes a string for matching by: /// - Converting to lowercase - /// - Normalizing apostrophes (', ', ') to standard ' + /// - Removing accents/diacritics + /// - Converting hyphens/underscores to spaces (for word separation) + /// - Removing other punctuation (periods, apostrophes, commas, etc.) /// - Removing extra whitespace /// private static string NormalizeForMatching(string text) @@ -166,18 +230,42 @@ public static class FuzzyMatcher var normalized = text.ToLowerInvariant().Trim(); - // Normalize different apostrophe types to standard apostrophe - normalized = normalized - .Replace("\u2019", "'") // Right single quotation mark (') - .Replace("\u2018", "'") // Left single quotation mark (') - .Replace("`", "'") // Grave accent - .Replace("\u00B4", "'"); // Acute accent (´) + // Remove accents/diacritics (é -> e, ñ -> n, etc.) + normalized = RemoveDiacritics(normalized); + + // Replace hyphens and underscores with spaces (for word separation) + // This ensures "Dua-Lipa" becomes "Dua Lipa" not "DuaLipa" + normalized = normalized.Replace('-', ' ').Replace('_', ' '); + + // Remove all other punctuation: periods, apostrophes, commas, etc. + normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"[^\w\s]", ""); // Normalize whitespace - normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " "); + normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ").Trim(); return normalized; } + + /// + /// Removes diacritics (accents) from characters. + /// Example: é -> e, ñ -> n, ü -> u + /// + private static string RemoveDiacritics(string text) + { + var normalizedString = text.Normalize(System.Text.NormalizationForm.FormD); + var stringBuilder = new System.Text.StringBuilder(); + + foreach (var c in normalizedString) + { + var unicodeCategory = System.Globalization.CharUnicodeInfo.GetUnicodeCategory(c); + if (unicodeCategory != System.Globalization.UnicodeCategory.NonSpacingMark) + { + stringBuilder.Append(c); + } + } + + return stringBuilder.ToString().Normalize(System.Text.NormalizationForm.FormC); + } /// /// Calculates Levenshtein distance between two strings. diff --git a/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs b/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs index a621469..fc793d9 100644 --- a/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs +++ b/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs @@ -263,9 +263,11 @@ public class JellyfinResponseBuilder ["Name"] = songTitle, ["ServerId"] = "allstarr", ["Id"] = song.Id, + ["PlaylistItemId"] = song.Id, // Required for playlist items ["HasLyrics"] = false, // Could be enhanced to check if lyrics exist ["Container"] = "flac", ["PremiereDate"] = song.Year.HasValue ? $"{song.Year}-01-01T00:00:00.0000000Z" : null, + ["DateCreated"] = song.Year.HasValue ? $"{song.Year}-01-01T00:00:00.0000000Z" : DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"), ["RunTimeTicks"] = (song.Duration ?? 0) * TimeSpan.TicksPerSecond, ["ProductionYear"] = song.Year, ["IndexNumber"] = song.Track, @@ -273,6 +275,7 @@ public class JellyfinResponseBuilder ["IsFolder"] = false, ["Type"] = "Audio", ["ChannelId"] = (object?)null, + ["ParentId"] = song.AlbumId, ["Genres"] = !string.IsNullOrEmpty(song.Genre) ? new[] { song.Genre } : new string[0], @@ -286,6 +289,9 @@ public class JellyfinResponseBuilder } } : new Dictionary[0], + ["Tags"] = new string[0], + ["People"] = new object[0], + ["SortName"] = songTitle, ["ParentLogoItemId"] = song.AlbumId, ["ParentBackdropItemId"] = song.AlbumId, ["ParentBackdropImageTags"] = new string[0],