v1.2.3: fix Spotify playlist metadata fields

Complete Jellyfin item structure for external tracks with all requested fields including PlaylistItemId, DateCreated, ParentId, Tags, People, and SortName.
This commit is contained in:
2026-02-10 11:56:12 -05:00
parent d0a7dbcc96
commit c9c82a650d
7 changed files with 1052 additions and 16 deletions

View 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}");
}
}

View 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);
}
}

View 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();
}
}

View 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);
}
}

View 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);
}
}