mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-10 16:08:39 -05:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
0ff1e3a428
|
|||
|
cef18b9482
|
|||
|
1bfe30b216
|
|||
|
c9c82a650d
|
|||
|
d0a7dbcc96
|
|||
|
9c9a827a91
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -283,22 +283,23 @@ public class JellyfinController : ControllerBase
|
|||||||
// Just interleave local and external results based on which source has better overall match
|
// Just interleave local and external results based on which source has better overall match
|
||||||
|
|
||||||
// Calculate average match score for each source to determine which should come first
|
// Calculate average match score for each source to determine which should come first
|
||||||
|
// Give local tracks a +10 boost to prioritize them
|
||||||
var localSongsAvgScore = localSongs.Any()
|
var localSongsAvgScore = localSongs.Any()
|
||||||
? localSongs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
|
? localSongs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title) + 10.0)
|
||||||
: 0.0;
|
: 0.0;
|
||||||
var externalSongsAvgScore = externalResult.Songs.Any()
|
var externalSongsAvgScore = externalResult.Songs.Any()
|
||||||
? externalResult.Songs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
|
? externalResult.Songs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
|
||||||
: 0.0;
|
: 0.0;
|
||||||
|
|
||||||
var localAlbumsAvgScore = localAlbums.Any()
|
var localAlbumsAvgScore = localAlbums.Any()
|
||||||
? localAlbums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
|
? localAlbums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title) + 10.0)
|
||||||
: 0.0;
|
: 0.0;
|
||||||
var externalAlbumsAvgScore = externalResult.Albums.Any()
|
var externalAlbumsAvgScore = externalResult.Albums.Any()
|
||||||
? externalResult.Albums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
|
? externalResult.Albums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
|
||||||
: 0.0;
|
: 0.0;
|
||||||
|
|
||||||
var localArtistsAvgScore = localArtists.Any()
|
var localArtistsAvgScore = localArtists.Any()
|
||||||
? localArtists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
|
? localArtists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name) + 10.0)
|
||||||
: 0.0;
|
: 0.0;
|
||||||
var externalArtistsAvgScore = externalResult.Artists.Any()
|
var externalArtistsAvgScore = externalResult.Artists.Any()
|
||||||
? externalResult.Artists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
|
? externalResult.Artists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
|
||||||
@@ -3529,8 +3530,17 @@ public class JellyfinController : ControllerBase
|
|||||||
return null; // Fall back to legacy mode
|
return null; // Fall back to legacy mode
|
||||||
}
|
}
|
||||||
|
|
||||||
// Request MediaSources field to get bitrate info
|
// Pass through all requested fields from the original request
|
||||||
var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}&Fields=MediaSources";
|
var queryString = Request.QueryString.Value ?? "";
|
||||||
|
var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}";
|
||||||
|
|
||||||
|
// Append the original query string (which includes Fields parameter)
|
||||||
|
if (!string.IsNullOrEmpty(queryString))
|
||||||
|
{
|
||||||
|
// Remove the leading ? if present
|
||||||
|
queryString = queryString.TrimStart('?');
|
||||||
|
playlistItemsUrl = $"{playlistItemsUrl}&{queryString}";
|
||||||
|
}
|
||||||
|
|
||||||
_logger.LogInformation("🔍 Fetching existing tracks from Jellyfin playlist {PlaylistId} with UserId {UserId}",
|
_logger.LogInformation("🔍 Fetching existing tracks from Jellyfin playlist {PlaylistId} with UserId {UserId}",
|
||||||
playlistId, userId);
|
playlistId, userId);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ public class DeezerMetadataService : IMusicMetadataService
|
|||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly SubsonicSettings _settings;
|
private readonly SubsonicSettings _settings;
|
||||||
private readonly GenreEnrichmentService _genreEnrichment;
|
private readonly GenreEnrichmentService? _genreEnrichment;
|
||||||
private const string BaseUrl = "https://api.deezer.com";
|
private const string BaseUrl = "https://api.deezer.com";
|
||||||
|
|
||||||
public DeezerMetadataService(
|
public DeezerMetadataService(
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IOptions<SubsonicSettings> settings,
|
IOptions<SubsonicSettings> settings,
|
||||||
GenreEnrichmentService genreEnrichment)
|
GenreEnrichmentService? genreEnrichment = null)
|
||||||
{
|
{
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
@@ -210,9 +210,20 @@ public class DeezerMetadataService : IMusicMetadataService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Enrich with MusicBrainz genres if missing
|
// Enrich with MusicBrainz genres if missing
|
||||||
if (string.IsNullOrEmpty(song.Genre))
|
if (_genreEnrichment != null && string.IsNullOrEmpty(song.Genre))
|
||||||
{
|
{
|
||||||
await _genreEnrichment.EnrichSongGenreAsync(song);
|
// Fire-and-forget: don't block the response waiting for genre enrichment
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _genreEnrichment.EnrichSongGenreAsync(song);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Silently ignore genre enrichment failures
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return song;
|
return song;
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ public class QobuzMetadataService : IMusicMetadataService
|
|||||||
private readonly SubsonicSettings _settings;
|
private readonly SubsonicSettings _settings;
|
||||||
private readonly QobuzBundleService _bundleService;
|
private readonly QobuzBundleService _bundleService;
|
||||||
private readonly ILogger<QobuzMetadataService> _logger;
|
private readonly ILogger<QobuzMetadataService> _logger;
|
||||||
private readonly GenreEnrichmentService _genreEnrichment;
|
private readonly GenreEnrichmentService? _genreEnrichment;
|
||||||
private readonly string? _userAuthToken;
|
private readonly string? _userAuthToken;
|
||||||
private readonly string? _userId;
|
private readonly string? _userId;
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ public class QobuzMetadataService : IMusicMetadataService
|
|||||||
IOptions<QobuzSettings> qobuzSettings,
|
IOptions<QobuzSettings> qobuzSettings,
|
||||||
QobuzBundleService bundleService,
|
QobuzBundleService bundleService,
|
||||||
ILogger<QobuzMetadataService> logger,
|
ILogger<QobuzMetadataService> logger,
|
||||||
GenreEnrichmentService genreEnrichment)
|
GenreEnrichmentService? genreEnrichment = null)
|
||||||
{
|
{
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
@@ -184,9 +184,20 @@ public class QobuzMetadataService : IMusicMetadataService
|
|||||||
var song = ParseQobuzTrackFull(track);
|
var song = ParseQobuzTrackFull(track);
|
||||||
|
|
||||||
// Enrich with MusicBrainz genres if missing
|
// Enrich with MusicBrainz genres if missing
|
||||||
if (song != null && string.IsNullOrEmpty(song.Genre))
|
if (_genreEnrichment != null && song != null && string.IsNullOrEmpty(song.Genre))
|
||||||
{
|
{
|
||||||
await _genreEnrichment.EnrichSongGenreAsync(song);
|
// Fire-and-forget: don't block the response waiting for genre enrichment
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _genreEnrichment.EnrichSongGenreAsync(song);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to enrich genre for {Title}", song.Title);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return song;
|
return song;
|
||||||
|
|||||||
@@ -992,7 +992,8 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
headers["X-Emby-Authorization"] = $"MediaBrowser Token=\"{jellyfinSettings.ApiKey}\"";
|
headers["X-Emby-Authorization"] = $"MediaBrowser Token=\"{jellyfinSettings.ApiKey}\"";
|
||||||
}
|
}
|
||||||
|
|
||||||
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items?UserId={userId}&Fields=MediaSources";
|
// Request all fields that clients typically need (not just MediaSources)
|
||||||
|
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items?UserId={userId}&Fields=Genres,DateCreated,MediaSources,ParentId,People,Tags,SortName,ProviderIds";
|
||||||
var (existingTracksResponse, statusCode) = await proxyService.GetJsonAsync(playlistItemsUrl, null, headers);
|
var (existingTracksResponse, statusCode) = await proxyService.GetJsonAsync(playlistItemsUrl, null, headers);
|
||||||
|
|
||||||
if (statusCode != 200 || existingTracksResponse == null)
|
if (statusCode != 200 || existingTracksResponse == null)
|
||||||
@@ -1301,6 +1302,61 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
if (finalItems.Count > 0)
|
if (finalItems.Count > 0)
|
||||||
{
|
{
|
||||||
|
// Enrich external tracks with genres from MusicBrainz
|
||||||
|
if (externalUsedCount > 0)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var genreEnrichment = _serviceProvider.GetService<GenreEnrichmentService>();
|
||||||
|
if (genreEnrichment != null)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("🎨 Enriching {Count} external tracks with genres from MusicBrainz...", externalUsedCount);
|
||||||
|
|
||||||
|
// Extract external songs from matched tracks
|
||||||
|
var externalSongs = matchedTracks
|
||||||
|
.Where(t => t.MatchedSong != null && !t.MatchedSong.IsLocal)
|
||||||
|
.Select(t => t.MatchedSong!)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Enrich genres in parallel
|
||||||
|
await genreEnrichment.EnrichSongsGenresAsync(externalSongs);
|
||||||
|
|
||||||
|
// Update the genres in finalItems
|
||||||
|
foreach (var item in finalItems)
|
||||||
|
{
|
||||||
|
if (item.TryGetValue("Id", out var idObj) && idObj is string id && id.StartsWith("ext-"))
|
||||||
|
{
|
||||||
|
// Find the corresponding song
|
||||||
|
var song = externalSongs.FirstOrDefault(s => s.Id == id);
|
||||||
|
if (song != null && !string.IsNullOrEmpty(song.Genre))
|
||||||
|
{
|
||||||
|
// Update Genres array
|
||||||
|
item["Genres"] = new[] { song.Genre };
|
||||||
|
|
||||||
|
// Update GenreItems array
|
||||||
|
item["GenreItems"] = new[]
|
||||||
|
{
|
||||||
|
new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["Name"] = song.Genre,
|
||||||
|
["Id"] = $"genre-{song.Genre.ToLowerInvariant()}"
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_logger.LogDebug("✓ Enriched {Title} with genre: {Genre}", song.Title, song.Genre);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("✅ Genre enrichment complete for {Playlist}", playlistName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to enrich genres for {Playlist}, continuing without genres", playlistName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Save to Redis cache with same expiration as matched tracks (until next cron run)
|
// Save to Redis cache with same expiration as matched tracks (until next cron run)
|
||||||
var cacheKey = $"spotify:playlist:items:{playlistName}";
|
var cacheKey = $"spotify:playlist:items:{playlistName}";
|
||||||
await _cache.SetAsync(cacheKey, finalItems, cacheExpiration);
|
await _cache.SetAsync(cacheKey, finalItems, cacheExpiration);
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
private readonly ILogger<SquidWTFMetadataService> _logger;
|
private readonly ILogger<SquidWTFMetadataService> _logger;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
private readonly RoundRobinFallbackHelper _fallbackHelper;
|
private readonly RoundRobinFallbackHelper _fallbackHelper;
|
||||||
private readonly GenreEnrichmentService _genreEnrichment;
|
private readonly GenreEnrichmentService? _genreEnrichment;
|
||||||
|
|
||||||
public SquidWTFMetadataService(
|
public SquidWTFMetadataService(
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
@@ -65,7 +65,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
ILogger<SquidWTFMetadataService> logger,
|
ILogger<SquidWTFMetadataService> logger,
|
||||||
RedisCacheService cache,
|
RedisCacheService cache,
|
||||||
List<string> apiUrls,
|
List<string> apiUrls,
|
||||||
GenreEnrichmentService genreEnrichment)
|
GenreEnrichmentService? genreEnrichment = null)
|
||||||
{
|
{
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
@@ -86,19 +86,19 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
|
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
|
||||||
{
|
{
|
||||||
// Race all endpoints for fastest search results
|
// Use round-robin to distribute load across endpoints (allows parallel processing of multiple tracks)
|
||||||
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
// Use 's' parameter for track search as per hifi-api spec
|
// Use 's' parameter for track search as per hifi-api spec
|
||||||
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
|
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
|
||||||
var response = await _httpClient.GetAsync(url, ct);
|
var response = await _httpClient.GetAsync(url);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(ct);
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
// Check for error in response body
|
// Check for error in response body
|
||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
@@ -132,19 +132,19 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
|
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
|
||||||
{
|
{
|
||||||
// Race all endpoints for fastest search results
|
// Use round-robin to distribute load across endpoints (allows parallel processing)
|
||||||
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
// Note: hifi-api doesn't document album search, but 'al' parameter is commonly used
|
// Note: hifi-api doesn't document album search, but 'al' parameter is commonly used
|
||||||
var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
|
var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
|
||||||
var response = await _httpClient.GetAsync(url, ct);
|
var response = await _httpClient.GetAsync(url);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(ct);
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
|
|
||||||
var albums = new List<Album>();
|
var albums = new List<Album>();
|
||||||
@@ -169,14 +169,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
|
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
|
||||||
{
|
{
|
||||||
// Race all endpoints for fastest search results
|
// Use round-robin to distribute load across endpoints (allows parallel processing)
|
||||||
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
// Per hifi-api spec: use 'a' parameter for artist search
|
// Per hifi-api spec: use 'a' parameter for artist search
|
||||||
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
|
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
|
||||||
_logger.LogInformation("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
|
_logger.LogInformation("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(url, ct);
|
var response = await _httpClient.GetAsync(url);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
@@ -184,7 +184,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(ct);
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
|
|
||||||
var artists = new List<Artist>();
|
var artists = new List<Artist>();
|
||||||
@@ -290,9 +290,20 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
var song = ParseTidalTrackFull(track);
|
var song = ParseTidalTrackFull(track);
|
||||||
|
|
||||||
// Enrich with MusicBrainz genres if missing (SquidWTF/Tidal doesn't provide genres)
|
// Enrich with MusicBrainz genres if missing (SquidWTF/Tidal doesn't provide genres)
|
||||||
if (string.IsNullOrEmpty(song.Genre))
|
if (_genreEnrichment != null && string.IsNullOrEmpty(song.Genre))
|
||||||
{
|
{
|
||||||
await _genreEnrichment.EnrichSongGenreAsync(song);
|
// Fire-and-forget: don't block the response waiting for genre enrichment
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _genreEnrichment.EnrichSongGenreAsync(song);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to enrich genre for {Title}", song.Title);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// NOTE: Spotify ID conversion happens during download (in SquidWTFDownloadService)
|
// NOTE: Spotify ID conversion happens during download (in SquidWTFDownloadService)
|
||||||
|
|||||||
@@ -5,9 +5,9 @@
|
|||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<RootNamespace>allstarr</RootNamespace>
|
<RootNamespace>allstarr</RootNamespace>
|
||||||
<Version>1.0.0</Version>
|
<Version>1.2.2</Version>
|
||||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
<AssemblyVersion>1.2.2.0</AssemblyVersion>
|
||||||
<FileVersion>1.0.0.0</FileVersion>
|
<FileVersion>1.2.2.0</FileVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
Reference in New Issue
Block a user