mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-10 16:08:39 -05:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
c9c82a650d
|
162
allstarr.Tests/FuzzyMatcherTests.cs
Normal file
162
allstarr.Tests/FuzzyMatcherTests.cs
Normal file
@@ -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}");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
96
allstarr.Tests/LrclibServiceTests.cs
Normal file
96
allstarr.Tests/LrclibServiceTests.cs
Normal file
@@ -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<ILogger<LrclibService>> _mockLogger;
|
||||||
|
private readonly Mock<IHttpClientFactory> _mockHttpClientFactory;
|
||||||
|
private readonly Mock<RedisCacheService> _mockCache;
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
|
||||||
|
public LrclibServiceTests()
|
||||||
|
{
|
||||||
|
_mockLogger = new Mock<ILogger<LrclibService>>();
|
||||||
|
_mockHttpClientFactory = new Mock<IHttpClientFactory>();
|
||||||
|
|
||||||
|
// Create mock Redis cache
|
||||||
|
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
|
||||||
|
var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false });
|
||||||
|
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object);
|
||||||
|
|
||||||
|
_httpClient = new HttpClient
|
||||||
|
{
|
||||||
|
BaseAddress = new Uri("https://lrclib.net")
|
||||||
|
};
|
||||||
|
|
||||||
|
_mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).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);
|
||||||
|
}
|
||||||
|
}
|
||||||
260
allstarr.Tests/RedisCacheServiceTests.cs
Normal file
260
allstarr.Tests/RedisCacheServiceTests.cs
Normal file
@@ -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<ILogger<RedisCacheService>> _mockLogger;
|
||||||
|
private readonly IOptions<RedisSettings> _settings;
|
||||||
|
|
||||||
|
public RedisCacheServiceTests()
|
||||||
|
{
|
||||||
|
_mockLogger = new Mock<ILogger<RedisCacheService>>();
|
||||||
|
_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<TestObject>("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<ComplexTestObject>("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<string> { "Item1", "Item2" },
|
||||||
|
Metadata = new System.Collections.Generic.Dictionary<string, string>
|
||||||
|
{
|
||||||
|
{ "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<string> Items { get; set; } = new();
|
||||||
|
public System.Collections.Generic.Dictionary<string, string> Metadata { get; set; } = new();
|
||||||
|
}
|
||||||
|
}
|
||||||
82
allstarr.Tests/SpotifyApiClientTests.cs
Normal file
82
allstarr.Tests/SpotifyApiClientTests.cs
Normal file
@@ -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<ILogger<SpotifyApiClient>> _mockLogger;
|
||||||
|
private readonly IOptions<SpotifyApiSettings> _settings;
|
||||||
|
|
||||||
|
public SpotifyApiClientTests()
|
||||||
|
{
|
||||||
|
_mockLogger = new Mock<ILogger<SpotifyApiClient>>();
|
||||||
|
_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);
|
||||||
|
}
|
||||||
|
}
|
||||||
342
allstarr.Tests/SquidWTFMetadataServiceTests.cs
Normal file
342
allstarr.Tests/SquidWTFMetadataServiceTests.cs
Normal file
@@ -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<ILogger<SquidWTFMetadataService>> _mockLogger;
|
||||||
|
private readonly Mock<IHttpClientFactory> _mockHttpClientFactory;
|
||||||
|
private readonly IOptions<SubsonicSettings> _subsonicSettings;
|
||||||
|
private readonly IOptions<SquidWTFSettings> _squidwtfSettings;
|
||||||
|
private readonly Mock<RedisCacheService> _mockCache;
|
||||||
|
private readonly List<string> _apiUrls;
|
||||||
|
|
||||||
|
public SquidWTFMetadataServiceTests()
|
||||||
|
{
|
||||||
|
_mockLogger = new Mock<ILogger<SquidWTFMetadataService>>();
|
||||||
|
_mockHttpClientFactory = new Mock<IHttpClientFactory>();
|
||||||
|
|
||||||
|
_subsonicSettings = Options.Create(new SubsonicSettings
|
||||||
|
{
|
||||||
|
ExplicitFilter = ExplicitFilter.All
|
||||||
|
});
|
||||||
|
|
||||||
|
_squidwtfSettings = Options.Create(new SquidWTFSettings
|
||||||
|
{
|
||||||
|
Quality = "FLAC"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create mock Redis cache
|
||||||
|
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
|
||||||
|
var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false });
|
||||||
|
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object);
|
||||||
|
|
||||||
|
_apiUrls = new List<string>
|
||||||
|
{
|
||||||
|
"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<string>())).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<string>
|
||||||
|
{
|
||||||
|
"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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,7 +58,8 @@ public static class FuzzyMatcher
|
|||||||
/// Calculates similarity score following OPTIMAL ORDER:
|
/// Calculates similarity score following OPTIMAL ORDER:
|
||||||
/// 1. Strip decorators (already done by caller)
|
/// 1. Strip decorators (already done by caller)
|
||||||
/// 2. Substring matching (cheap, high-precision)
|
/// 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.
|
/// Returns score 0-100.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static int CalculateSimilarity(string query, string target)
|
public static int CalculateSimilarity(string query, string target)
|
||||||
@@ -103,11 +104,71 @@ public static class FuzzyMatcher
|
|||||||
return 85;
|
return 85;
|
||||||
}
|
}
|
||||||
|
|
||||||
// STEP 3: LEVENSHTEIN DISTANCE (expensive, fuzzy)
|
// STEP 3: TOKEN-BASED MATCHING (handles word order)
|
||||||
// Only use this for candidates that survived substring checks
|
var tokens1 = queryNorm.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
var tokens2 = targetNorm.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
|
||||||
var distance = LevenshteinDistance(queryNorm, targetNorm);
|
if (tokens1.Length > 0 && tokens2.Length > 0)
|
||||||
var maxLength = Math.Max(queryNorm.Length, targetNorm.Length);
|
{
|
||||||
|
// Calculate how many tokens match (order-independent)
|
||||||
|
var matchedTokens = 0.0; // Use double for partial matches
|
||||||
|
var usedTokens = new HashSet<int>();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates similarity score based on Levenshtein distance.
|
||||||
|
/// Returns score 0-75 (reserve 75-100 for substring/token matches).
|
||||||
|
/// </summary>
|
||||||
|
private static int CalculateLevenshteinScore(string str1, string str2)
|
||||||
|
{
|
||||||
|
var distance = LevenshteinDistance(str1, str2);
|
||||||
|
var maxLength = Math.Max(str1.Length, str2.Length);
|
||||||
|
|
||||||
if (maxLength == 0)
|
if (maxLength == 0)
|
||||||
{
|
{
|
||||||
@@ -117,8 +178,9 @@ public static class FuzzyMatcher
|
|||||||
// Normalize distance by length: score = 1 - (distance / max_length)
|
// Normalize distance by length: score = 1 - (distance / max_length)
|
||||||
var normalizedSimilarity = 1.0 - ((double)distance / maxLength);
|
var normalizedSimilarity = 1.0 - ((double)distance / maxLength);
|
||||||
|
|
||||||
// Convert to 0-80 range (reserve 80-100 for substring matches)
|
// Convert to 0-75 range (reserve 75-100 for substring/token matches)
|
||||||
var score = (int)(normalizedSimilarity * 80);
|
// Using 75 instead of 80 to be slightly stricter
|
||||||
|
var score = (int)(normalizedSimilarity * 75);
|
||||||
|
|
||||||
return Math.Max(0, score);
|
return Math.Max(0, score);
|
||||||
}
|
}
|
||||||
@@ -154,7 +216,9 @@ public static class FuzzyMatcher
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Normalizes a string for matching by:
|
/// Normalizes a string for matching by:
|
||||||
/// - Converting to lowercase
|
/// - 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
|
/// - Removing extra whitespace
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static string NormalizeForMatching(string text)
|
private static string NormalizeForMatching(string text)
|
||||||
@@ -166,19 +230,43 @@ public static class FuzzyMatcher
|
|||||||
|
|
||||||
var normalized = text.ToLowerInvariant().Trim();
|
var normalized = text.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
// Normalize different apostrophe types to standard apostrophe
|
// Remove accents/diacritics (é -> e, ñ -> n, etc.)
|
||||||
normalized = normalized
|
normalized = RemoveDiacritics(normalized);
|
||||||
.Replace("\u2019", "'") // Right single quotation mark (')
|
|
||||||
.Replace("\u2018", "'") // Left single quotation mark (')
|
// Replace hyphens and underscores with spaces (for word separation)
|
||||||
.Replace("`", "'") // Grave accent
|
// This ensures "Dua-Lipa" becomes "Dua Lipa" not "DuaLipa"
|
||||||
.Replace("\u00B4", "'"); // Acute accent (´)
|
normalized = normalized.Replace('-', ' ').Replace('_', ' ');
|
||||||
|
|
||||||
|
// Remove all other punctuation: periods, apostrophes, commas, etc.
|
||||||
|
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"[^\w\s]", "");
|
||||||
|
|
||||||
// Normalize whitespace
|
// Normalize whitespace
|
||||||
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ");
|
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ").Trim();
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes diacritics (accents) from characters.
|
||||||
|
/// Example: é -> e, ñ -> n, ü -> u
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Calculates Levenshtein distance between two strings.
|
/// Calculates Levenshtein distance between two strings.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -263,9 +263,11 @@ public class JellyfinResponseBuilder
|
|||||||
["Name"] = songTitle,
|
["Name"] = songTitle,
|
||||||
["ServerId"] = "allstarr",
|
["ServerId"] = "allstarr",
|
||||||
["Id"] = song.Id,
|
["Id"] = song.Id,
|
||||||
|
["PlaylistItemId"] = song.Id, // Required for playlist items
|
||||||
["HasLyrics"] = false, // Could be enhanced to check if lyrics exist
|
["HasLyrics"] = false, // Could be enhanced to check if lyrics exist
|
||||||
["Container"] = "flac",
|
["Container"] = "flac",
|
||||||
["PremiereDate"] = song.Year.HasValue ? $"{song.Year}-01-01T00:00:00.0000000Z" : null,
|
["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,
|
["RunTimeTicks"] = (song.Duration ?? 0) * TimeSpan.TicksPerSecond,
|
||||||
["ProductionYear"] = song.Year,
|
["ProductionYear"] = song.Year,
|
||||||
["IndexNumber"] = song.Track,
|
["IndexNumber"] = song.Track,
|
||||||
@@ -273,6 +275,7 @@ public class JellyfinResponseBuilder
|
|||||||
["IsFolder"] = false,
|
["IsFolder"] = false,
|
||||||
["Type"] = "Audio",
|
["Type"] = "Audio",
|
||||||
["ChannelId"] = (object?)null,
|
["ChannelId"] = (object?)null,
|
||||||
|
["ParentId"] = song.AlbumId,
|
||||||
["Genres"] = !string.IsNullOrEmpty(song.Genre)
|
["Genres"] = !string.IsNullOrEmpty(song.Genre)
|
||||||
? new[] { song.Genre }
|
? new[] { song.Genre }
|
||||||
: new string[0],
|
: new string[0],
|
||||||
@@ -286,6 +289,9 @@ public class JellyfinResponseBuilder
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
: new Dictionary<string, object?>[0],
|
: new Dictionary<string, object?>[0],
|
||||||
|
["Tags"] = new string[0],
|
||||||
|
["People"] = new object[0],
|
||||||
|
["SortName"] = songTitle,
|
||||||
["ParentLogoItemId"] = song.AlbumId,
|
["ParentLogoItemId"] = song.AlbumId,
|
||||||
["ParentBackdropItemId"] = song.AlbumId,
|
["ParentBackdropItemId"] = song.AlbumId,
|
||||||
["ParentBackdropImageTags"] = new string[0],
|
["ParentBackdropImageTags"] = new string[0],
|
||||||
|
|||||||
Reference in New Issue
Block a user