17 Commits

Author SHA1 Message Date
0a9e528418 v1.3.0: Bump version to 1.3.0
Some checks are pending
Docker Build & Push / build-and-test (push) Waiting to run
Docker Build & Push / docker (push) Blocked by required conditions
2026-02-11 00:01:06 -05:00
f74728fc73 fix: use MBID lookup for MusicBrainz genre enrichment
Search API doesn't return genres even with inc=genres parameter.
Now doing search to get MBID, then lookup by MBID to get genres.
2026-02-10 23:52:14 -05:00
87467be61b feat: add LyricsPlus API with modular orchestrator architecture
Add multi-source lyrics support with clean, modular architecture for easier debugging and maintenance.

New Features:
- LyricsPlusService: Multi-source lyrics API (Apple Music, Spotify, Musixmatch)
- LyricsOrchestrator: Priority-based coordinator for all lyrics sources
- Modular service architecture with independent error handling
- Word-level and line-level timing support with LRC conversion

Architecture:
- Priority chain: Spotify → LyricsPlus → LRCLib
- Each service logs independently (→ Trying, ✓ Found,  Not found)
- Fallback continues even if one service fails
- Easy to add new sources or modify priority

Benefits:
- Easier debugging with clear service-level logs
- Better maintainability with separated concerns
- More reliable with graceful fallback handling
- Extensible for future lyrics sources
2026-02-10 23:02:17 -05:00
713ecd4ec8 v1.2.6: fix search result ordering to prioritize local tracks
Some checks failed
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-10 13:36:06 -05:00
0ff1e3a428 v1.2.5: fix genre enrichment blocking cover art loading 2026-02-10 12:56:43 -05:00
cef18b9482 v1.2.5: prioritize local tracks and optimize genre enrichment
Local tracks now appear first in search results with +10 score boost. Genre enrichment is non-blocking for faster cover art and playback.
2026-02-10 12:50:52 -05:00
1bfe30b216 v1.2.4: stop racing SquidWTF endpoints for better throughput
Use round-robin instead of racing to enable parallel processing of 12 tracks simultaneously (one per endpoint) instead of racing all endpoints for each track.
2026-02-10 12:14:38 -05:00
c9c82a650d 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.
2026-02-10 11:56:12 -05:00
d0a7dbcc96 v1.2.2: fix metadata loss in Spotify playlists
Spotify playlist tracks were missing genres, composers, and other metadata because the proxy only requested MediaSources field instead of passing through all client-requested fields.
2026-02-10 11:01:38 -05:00
9c9a827a91 v1.2.1: MusicBrainz genre enrichment + cleanup
## Features
- Implement automatic MusicBrainz genre enrichment for all external sources
  - Deezer: Enriches when genre missing
  - Qobuz: Enriches when genre missing
  - SquidWTF/Tidal: Always enriches (Tidal doesn't provide genres)
- Use ISRC codes for exact matching, fallback to title/artist search
- Cache results in Redis (30 days) + file cache for performance
- Respect MusicBrainz rate limits (1 req/sec)

## Cleanup
- Remove unused Spotify API ClientId and ClientSecret settings
- Simplify Spotify API configuration

## Fixes
- Make GenreEnrichmentService optional to fix test failures
- All 225 tests passing

This ensures all external tracks have genre metadata for better
organization and filtering in music clients.
2026-02-10 10:29:49 -05:00
96889738df v1.2.1: MusicBrainz genre enrichment + cleanup
## Features
- Implement automatic MusicBrainz genre enrichment for all external sources
  - Deezer: Enriches when genre missing
  - Qobuz: Enriches when genre missing
  - SquidWTF/Tidal: Always enriches (Tidal doesn't provide genres)
- Use ISRC codes for exact matching, fallback to title/artist search
- Cache results in Redis (30 days) + file cache for performance
- Respect MusicBrainz rate limits (1 req/sec)

## Cleanup
- Remove unused Spotify API ClientId and ClientSecret settings
- Simplify Spotify API configuration

This ensures all external tracks have genre metadata for better
organization and filtering in music clients.
2026-02-10 10:25:41 -05:00
f3c791496e v1.2.0: Spotify playlist improvements and admin UI fixes
Some checks failed
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
Enhanced Spotify playlist integration with GraphQL API, fixed track counts and folder filtering, improved session IP tracking with X-Forwarded-For support, and added per-playlist cron scheduling.
2026-02-09 18:17:15 -05:00
f68706f300 Release v1.1.1 - Download Structure Fix
Some checks failed
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
Fixed cache and permanent files to use unified downloads/ structure instead of separate paths.
2026-02-08 01:51:18 -05:00
9f362b4920 Release v1.1.0 - Configuration Simplification
Configuration Changes:
- Removed sync window logic from Spotify Import (no more SYNC_START_HOUR, SYNC_START_MINUTE, SYNC_WINDOW_HOURS)
- Simplified to: fetch on startup if cache missing, check every 5 minutes for stale cache
- Unified download folder structure: downloads/{permanent,cache,kept}/ instead of separate paths
- Removed Library:KeptPath config, now uses downloads/kept/

Documentation:
- Updated README with clearer Spotify Import configuration
- Updated .env.example to reflect simplified settings
- Removed MIGRATION.md from repository (local-only file)

Bug Fixes:
- Web UI now correctly displays kept tracks in Active Playlists tab
- Fixed path handling for favorited tracks
2026-02-08 01:33:09 -05:00
2b09484c0b Release v1.0.0 - Production Ready
Major Features:
- Spotify playlist injection with missing tracks search
- Transparent proxy authentication system
- WebSocket session management for external tracks
- Manual track mapping and favorites system
- Lyrics support (Spotify + LRCLib) with prefetching
- Admin dashboard with analytics and configuration
- Performance optimizations with health checks and endpoint racing
- Comprehensive caching and memory management

Performance Improvements:
- Quick health checks (3s timeout) before trying endpoints
- Health check results cached for 30 seconds
- 5 minute timeout for large artist responses
- Background Odesli conversion after streaming starts
- Parallel lyrics prefetching
- Endpoint benchmarking and racing
- 16 SquidWTF endpoints with load balancing

Reliability:
- Automatic endpoint fallback and failover
- Token expiration handling
- Concurrent request optimization
- Memory leak fixes
- Proper session cleanup

User Experience:
- Web UI for configuration and playlist management
- Real-time progress tracking
- API analytics dashboard
- Manual track mapping interface
- Playlist statistics and health monitoring
2026-02-08 00:43:47 -05:00
fa9739bfaa docs: update README
Some checks failed
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-01-31 11:16:00 -05:00
0ba51e2b30 fix: improve auth, search, and stability
Some checks failed
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-01-31 01:14:53 -05:00
24 changed files with 1880 additions and 170 deletions

View File

@@ -143,13 +143,6 @@ SPOTIFY_IMPORT_PLAYLISTS=[]
# Enable direct Spotify API access (default: false)
SPOTIFY_API_ENABLED=false
# Spotify Client ID from https://developer.spotify.com/dashboard
# Create an app in the Spotify Developer Dashboard to get this
SPOTIFY_API_CLIENT_ID=
# Spotify Client Secret (optional - only needed for certain OAuth flows)
SPOTIFY_API_CLIENT_SECRET=
# Spotify session cookie (sp_dc) - REQUIRED for editorial playlists
# Editorial playlists (Release Radar, Discover Weekly, etc.) require authentication
# via session cookie because they're not accessible through the official API.

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

View File

@@ -39,7 +39,9 @@ public class JellyfinController : ControllerBase
private readonly PlaylistSyncService? _playlistSyncService;
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
private readonly SpotifyLyricsService? _spotifyLyricsService;
private readonly LyricsPlusService? _lyricsPlusService;
private readonly LrclibService? _lrclibService;
private readonly LyricsOrchestrator? _lyricsOrchestrator;
private readonly OdesliService _odesliService;
private readonly RedisCacheService _cache;
private readonly IConfiguration _configuration;
@@ -64,7 +66,9 @@ public class JellyfinController : ControllerBase
PlaylistSyncService? playlistSyncService = null,
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
SpotifyLyricsService? spotifyLyricsService = null,
LrclibService? lrclibService = null)
LyricsPlusService? lyricsPlusService = null,
LrclibService? lrclibService = null,
LyricsOrchestrator? lyricsOrchestrator = null)
{
_settings = settings.Value;
_spotifySettings = spotifySettings.Value;
@@ -80,7 +84,9 @@ public class JellyfinController : ControllerBase
_playlistSyncService = playlistSyncService;
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
_spotifyLyricsService = spotifyLyricsService;
_lyricsPlusService = lyricsPlusService;
_lrclibService = lrclibService;
_lyricsOrchestrator = lyricsOrchestrator;
_odesliService = odesliService;
_cache = cache;
_configuration = configuration;
@@ -279,53 +285,50 @@ public class JellyfinController : ControllerBase
// Parse Jellyfin results into domain models
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
// Respect source ordering (SquidWTF/Tidal has better search ranking than our fuzzy matching)
// Just interleave local and external results based on which source has better overall match
// Sort all results by match score (local tracks get +10 boost)
// This ensures best matches appear first regardless of source
var allSongs = localSongs.Concat(externalResult.Songs)
.Select(s => new { Song = s, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title) + (s.IsLocal ? 10.0 : 0.0) })
.OrderByDescending(x => x.Score)
.Select(x => x.Song)
.ToList();
// Calculate average match score for each source to determine which should come first
var localSongsAvgScore = localSongs.Any()
? localSongs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
: 0.0;
var externalSongsAvgScore = externalResult.Songs.Any()
? externalResult.Songs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
: 0.0;
var allAlbums = localAlbums.Concat(externalResult.Albums)
.Select(a => new { Album = a, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title) + (a.IsLocal ? 10.0 : 0.0) })
.OrderByDescending(x => x.Score)
.Select(x => x.Album)
.ToList();
var localAlbumsAvgScore = localAlbums.Any()
? localAlbums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
: 0.0;
var externalAlbumsAvgScore = externalResult.Albums.Any()
? externalResult.Albums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
: 0.0;
var allArtists = localArtists.Concat(externalResult.Artists)
.Select(a => new { Artist = a, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name) + (a.IsLocal ? 10.0 : 0.0) })
.OrderByDescending(x => x.Score)
.Select(x => x.Artist)
.ToList();
var localArtistsAvgScore = localArtists.Any()
? localArtists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
: 0.0;
var externalArtistsAvgScore = externalResult.Artists.Any()
? externalResult.Artists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
: 0.0;
// Interleave results: put better-matching source first, preserve original ordering within each source
var allSongs = localSongsAvgScore >= externalSongsAvgScore
? localSongs.Concat(externalResult.Songs).ToList()
: externalResult.Songs.Concat(localSongs).ToList();
var allAlbums = localAlbumsAvgScore >= externalAlbumsAvgScore
? localAlbums.Concat(externalResult.Albums).ToList()
: externalResult.Albums.Concat(localAlbums).ToList();
var allArtists = localArtistsAvgScore >= externalArtistsAvgScore
? localArtists.Concat(externalResult.Artists).ToList()
: externalResult.Artists.Concat(localArtists).ToList();
// Log results for debugging
// Log top results for debugging
if (_logger.IsEnabled(LogLevel.Debug))
{
_logger.LogDebug("🎵 Songs: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
localSongsAvgScore, externalSongsAvgScore, localSongsAvgScore >= externalSongsAvgScore);
_logger.LogDebug("💿 Albums: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
localAlbumsAvgScore, externalAlbumsAvgScore, localAlbumsAvgScore >= externalAlbumsAvgScore);
_logger.LogDebug("🎤 Artists: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
localArtistsAvgScore, externalArtistsAvgScore, localArtistsAvgScore >= externalArtistsAvgScore);
if (allSongs.Any())
{
var topSong = allSongs.First();
var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topSong.Title) + (topSong.IsLocal ? 10.0 : 0.0);
_logger.LogDebug("🎵 Top song: '{Title}' (local={IsLocal}, score={Score:F2})",
topSong.Title, topSong.IsLocal, topScore);
}
if (allAlbums.Any())
{
var topAlbum = allAlbums.First();
var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topAlbum.Title) + (topAlbum.IsLocal ? 10.0 : 0.0);
_logger.LogDebug("💿 Top album: '{Title}' (local={IsLocal}, score={Score:F2})",
topAlbum.Title, topAlbum.IsLocal, topScore);
}
if (allArtists.Any())
{
var topArtist = allArtists.First();
var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topArtist.Name) + (topArtist.IsLocal ? 10.0 : 0.0);
_logger.LogDebug("🎤 Top artist: '{Name}' (local={IsLocal}, score={Score:F2})",
topArtist.Name, topArtist.IsLocal, topScore);
}
}
// Convert to Jellyfin format
@@ -343,7 +346,7 @@ public class JellyfinController : ControllerBase
mergedAlbums.AddRange(playlistItems);
}
_logger.LogInformation("Merged results (preserving source order): Songs={Songs}, Albums={Albums}, Artists={Artists}",
_logger.LogInformation("Merged and sorted results by score: Songs={Songs}, Albums={Albums}, Artists={Artists}",
mergedSongs.Count, mergedAlbums.Count, mergedArtists.Count);
// Pre-fetch lyrics for top 3 songs in background (don't await)
@@ -1274,50 +1277,53 @@ public class JellyfinController : ControllerBase
searchArtists.Add(searchArtist);
}
// Use orchestrator for clean, modular lyrics fetching
LyricsInfo? lyrics = null;
// Try Spotify lyrics ONLY if we have a valid Spotify track ID
// Spotify lyrics only work for tracks from injected playlists that have been matched
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
if (_lyricsOrchestrator != null)
{
// Validate that this is a real Spotify ID (not spotify:local or other invalid formats)
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
// Spotify track IDs are 22 characters, base62 encoded
if (cleanSpotifyId.Length == 22 && !cleanSpotifyId.Contains(":") && !cleanSpotifyId.Contains("local"))
{
_logger.LogInformation("Trying Spotify lyrics for track ID: {SpotifyId} ({Artist} - {Title})",
cleanSpotifyId, searchArtist, searchTitle);
var spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(cleanSpotifyId);
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
{
_logger.LogInformation("Found Spotify lyrics for {Artist} - {Title} ({LineCount} lines, type: {SyncType})",
searchArtist, searchTitle, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType);
lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
}
else
{
_logger.LogDebug("No Spotify lyrics found for track ID {SpotifyId}", cleanSpotifyId);
}
}
else
{
_logger.LogDebug("Invalid Spotify ID format: {SpotifyId}, skipping Spotify lyrics", spotifyTrackId);
}
lyrics = await _lyricsOrchestrator.GetLyricsAsync(
trackName: searchTitle,
artistNames: searchArtists.ToArray(),
albumName: searchAlbum,
durationSeconds: song.Duration ?? 0,
spotifyTrackId: spotifyTrackId);
}
// Fall back to LRCLIB if no Spotify lyrics
if (lyrics == null)
else
{
_logger.LogInformation("Searching LRCLIB for lyrics: {Artists} - {Title}",
string.Join(", ", searchArtists),
searchTitle);
var lrclibService = HttpContext.RequestServices.GetService<LrclibService>();
if (lrclibService != null)
// Fallback to manual fetching if orchestrator not available
_logger.LogWarning("LyricsOrchestrator not available, using fallback method");
// Try Spotify lyrics ONLY if we have a valid Spotify track ID
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
{
lyrics = await lrclibService.GetLyricsAsync(
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
if (cleanSpotifyId.Length == 22 && !cleanSpotifyId.Contains(":") && !cleanSpotifyId.Contains("local"))
{
var spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(cleanSpotifyId);
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
{
lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
}
}
}
// Fall back to LyricsPlus
if (lyrics == null && _lyricsPlusService != null)
{
lyrics = await _lyricsPlusService.GetLyricsAsync(
searchTitle,
searchArtists.ToArray(),
searchAlbum,
song.Duration ?? 0);
}
// Fall back to LRCLIB
if (lyrics == null && _lrclibService != null)
{
lyrics = await _lrclibService.GetLyricsAsync(
searchTitle,
searchArtists.ToArray(),
searchAlbum,
@@ -1498,6 +1504,21 @@ public class JellyfinController : ControllerBase
_logger.LogDebug("🎵 Prefetching lyrics for: {Artist} - {Title}", searchArtist, searchTitle);
// Use orchestrator for prefetching
if (_lyricsOrchestrator != null)
{
await _lyricsOrchestrator.PrefetchLyricsAsync(
trackName: searchTitle,
artistNames: searchArtists.ToArray(),
albumName: searchAlbum,
durationSeconds: song.Duration ?? 0,
spotifyTrackId: spotifyTrackId);
return;
}
// Fallback to manual prefetching if orchestrator not available
_logger.LogWarning("LyricsOrchestrator not available for prefetch, using fallback method");
// Try Spotify lyrics if we have a valid Spotify track ID
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
{
@@ -1516,6 +1537,22 @@ public class JellyfinController : ControllerBase
}
}
// Fall back to LyricsPlus
if (_lyricsPlusService != null)
{
var lyrics = await _lyricsPlusService.GetLyricsAsync(
searchTitle,
searchArtists.ToArray(),
searchAlbum,
song.Duration ?? 0);
if (lyrics != null)
{
_logger.LogDebug("✓ Prefetched LyricsPlus lyrics for {Artist} - {Title}", searchArtist, searchTitle);
return; // Success, lyrics are now cached
}
}
// Fall back to LRCLIB
if (_lrclibService != null)
{
@@ -3529,8 +3566,17 @@ public class JellyfinController : ControllerBase
return null; // Fall back to legacy mode
}
// Request MediaSources field to get bitrate info
var playlistItemsUrl = $"Playlists/{playlistId}/Items?UserId={userId}&Fields=MediaSources";
// Pass through all requested fields from the original request
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}",
playlistId, userId);

View File

@@ -139,7 +139,7 @@ public class WebSocketProxyMiddleware
}
// Set user agent
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0");
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.3.0");
await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted);
_logger.LogDebug("✓ WEBSOCKET: Connected to Jellyfin WebSocket");

View File

@@ -18,18 +18,6 @@ public class SpotifyApiSettings
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Spotify Client ID from https://developer.spotify.com/dashboard
/// Used for OAuth token refresh and API access.
/// </summary>
public string ClientId { get; set; } = string.Empty;
/// <summary>
/// Spotify Client Secret from https://developer.spotify.com/dashboard
/// Optional - only needed for certain OAuth flows.
/// </summary>
public string ClientSecret { get; set; } = string.Empty;
/// <summary>
/// Spotify session cookie (sp_dc).
/// Required for accessing editorial/personalized playlists like Release Radar and Discover Weekly.

View File

@@ -473,7 +473,8 @@ else if (musicService == MusicService.SquidWTF)
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
sp.GetRequiredService<ILogger<SquidWTFMetadataService>>(),
sp.GetRequiredService<RedisCacheService>(),
squidWtfApiUrls));
squidWtfApiUrls,
sp.GetRequiredService<GenreEnrichmentService>()));
builder.Services.AddSingleton<IDownloadService>(sp =>
new SquidWTFDownloadService(
sp.GetRequiredService<IHttpClientFactory>(),
@@ -537,18 +538,6 @@ builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options
options.Enabled = enabled.Equals("true", StringComparison.OrdinalIgnoreCase);
}
var clientId = builder.Configuration.GetValue<string>("SpotifyApi:ClientId");
if (!string.IsNullOrEmpty(clientId))
{
options.ClientId = clientId;
}
var clientSecret = builder.Configuration.GetValue<string>("SpotifyApi:ClientSecret");
if (!string.IsNullOrEmpty(clientSecret))
{
options.ClientSecret = clientSecret;
}
var sessionCookie = builder.Configuration.GetValue<string>("SpotifyApi:SessionCookie");
if (!string.IsNullOrEmpty(sessionCookie))
{
@@ -576,7 +565,6 @@ builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options
// Log configuration (mask sensitive values)
Console.WriteLine($"SpotifyApi Configuration:");
Console.WriteLine($" Enabled: {options.Enabled}");
Console.WriteLine($" ClientId: {(string.IsNullOrEmpty(options.ClientId) ? "(not set)" : options.ClientId[..8] + "...")}");
Console.WriteLine($" SessionCookie: {(string.IsNullOrEmpty(options.SessionCookie) ? "(not set)" : "***" + options.SessionCookie[^8..])}");
Console.WriteLine($" SessionCookieSetDate: {options.SessionCookieSetDate ?? "(not set)"}");
Console.WriteLine($" CacheDurationMinutes: {options.CacheDurationMinutes}");
@@ -587,6 +575,12 @@ builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyApiClient>();
// Register Spotify lyrics service (uses Spotify's color-lyrics API)
builder.Services.AddSingleton<allstarr.Services.Lyrics.SpotifyLyricsService>();
// Register LyricsPlus service (multi-source lyrics API)
builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsPlusService>();
// Register Lyrics Orchestrator (manages priority-based lyrics fetching)
builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsOrchestrator>();
// Register Spotify playlist fetcher (uses direct Spotify API when SpotifyApi is enabled)
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyPlaylistFetcher>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Spotify.SpotifyPlaylistFetcher>());

View File

@@ -58,7 +58,8 @@ public static class FuzzyMatcher
/// Calculates similarity score following OPTIMAL ORDER:
/// 1. Strip decorators (already done by caller)
/// 2. Substring matching (cheap, high-precision)
/// 3. Levenshtein distance (expensive, fuzzy)
/// 3. Token-based matching (handles word order)
/// 4. Levenshtein distance (expensive, fuzzy)
/// Returns score 0-100.
/// </summary>
public static int CalculateSimilarity(string query, string target)
@@ -103,11 +104,71 @@ public static class FuzzyMatcher
return 85;
}
// STEP 3: LEVENSHTEIN DISTANCE (expensive, fuzzy)
// Only use this for candidates that survived substring checks
// STEP 3: TOKEN-BASED MATCHING (handles word order)
var tokens1 = queryNorm.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
var tokens2 = targetNorm.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries);
var distance = LevenshteinDistance(queryNorm, targetNorm);
var maxLength = Math.Max(queryNorm.Length, targetNorm.Length);
if (tokens1.Length > 0 && tokens2.Length > 0)
{
// Calculate how many tokens match (order-independent)
var matchedTokens = 0.0; // Use double for partial matches
var usedTokens = new HashSet<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)
{
@@ -117,8 +178,9 @@ public static class FuzzyMatcher
// Normalize distance by length: score = 1 - (distance / max_length)
var normalizedSimilarity = 1.0 - ((double)distance / maxLength);
// Convert to 0-80 range (reserve 80-100 for substring matches)
var score = (int)(normalizedSimilarity * 80);
// Convert to 0-75 range (reserve 75-100 for substring/token matches)
// Using 75 instead of 80 to be slightly stricter
var score = (int)(normalizedSimilarity * 75);
return Math.Max(0, score);
}
@@ -154,7 +216,9 @@ public static class FuzzyMatcher
/// <summary>
/// Normalizes a string for matching by:
/// - Converting to lowercase
/// - Normalizing apostrophes (', ', ') to standard '
/// - Removing accents/diacritics
/// - Converting hyphens/underscores to spaces (for word separation)
/// - Removing other punctuation (periods, apostrophes, commas, etc.)
/// - Removing extra whitespace
/// </summary>
private static string NormalizeForMatching(string text)
@@ -166,19 +230,43 @@ public static class FuzzyMatcher
var normalized = text.ToLowerInvariant().Trim();
// Normalize different apostrophe types to standard apostrophe
normalized = normalized
.Replace("\u2019", "'") // Right single quotation mark (')
.Replace("\u2018", "'") // Left single quotation mark (')
.Replace("`", "'") // Grave accent
.Replace("\u00B4", "'"); // Acute accent (´)
// Remove accents/diacritics (é -> e, ñ -> n, etc.)
normalized = RemoveDiacritics(normalized);
// Replace hyphens and underscores with spaces (for word separation)
// This ensures "Dua-Lipa" becomes "Dua Lipa" not "DuaLipa"
normalized = normalized.Replace('-', ' ').Replace('_', ' ');
// Remove all other punctuation: periods, apostrophes, commas, etc.
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"[^\w\s]", "");
// Normalize whitespace
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ");
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ").Trim();
return normalized;
}
/// <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>
/// Calculates Levenshtein distance between two strings.
/// </summary>

View File

@@ -3,6 +3,7 @@ using allstarr.Models.Settings;
using allstarr.Models.Download;
using allstarr.Models.Search;
using allstarr.Models.Subsonic;
using allstarr.Services.Common;
using System.Text.Json;
using Microsoft.Extensions.Options;
@@ -15,12 +16,17 @@ public class DeezerMetadataService : IMusicMetadataService
{
private readonly HttpClient _httpClient;
private readonly SubsonicSettings _settings;
private readonly GenreEnrichmentService? _genreEnrichment;
private const string BaseUrl = "https://api.deezer.com";
public DeezerMetadataService(IHttpClientFactory httpClientFactory, IOptions<SubsonicSettings> settings)
public DeezerMetadataService(
IHttpClientFactory httpClientFactory,
IOptions<SubsonicSettings> settings,
GenreEnrichmentService? genreEnrichment = null)
{
_httpClient = httpClientFactory.CreateClient();
_settings = settings.Value;
_genreEnrichment = genreEnrichment;
}
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
@@ -203,6 +209,23 @@ public class DeezerMetadataService : IMusicMetadataService
}
}
// Enrich with MusicBrainz genres if missing
if (_genreEnrichment != null && string.IsNullOrEmpty(song.Genre))
{
// 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;
}

View File

@@ -263,9 +263,11 @@ public class JellyfinResponseBuilder
["Name"] = songTitle,
["ServerId"] = "allstarr",
["Id"] = song.Id,
["PlaylistItemId"] = song.Id, // Required for playlist items
["HasLyrics"] = false, // Could be enhanced to check if lyrics exist
["Container"] = "flac",
["PremiereDate"] = song.Year.HasValue ? $"{song.Year}-01-01T00:00:00.0000000Z" : null,
["DateCreated"] = song.Year.HasValue ? $"{song.Year}-01-01T00:00:00.0000000Z" : DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"),
["RunTimeTicks"] = (song.Duration ?? 0) * TimeSpan.TicksPerSecond,
["ProductionYear"] = song.Year,
["IndexNumber"] = song.Track,
@@ -273,6 +275,7 @@ public class JellyfinResponseBuilder
["IsFolder"] = false,
["Type"] = "Audio",
["ChannelId"] = (object?)null,
["ParentId"] = song.AlbumId,
["Genres"] = !string.IsNullOrEmpty(song.Genre)
? new[] { song.Genre }
: new string[0],
@@ -286,6 +289,9 @@ public class JellyfinResponseBuilder
}
}
: new Dictionary<string, object?>[0],
["Tags"] = new string[0],
["People"] = new object[0],
["SortName"] = songTitle,
["ParentLogoItemId"] = song.AlbumId,
["ParentBackdropItemId"] = song.AlbumId,
["ParentBackdropImageTags"] = new string[0],

View File

@@ -18,7 +18,7 @@ public class LrclibService
ILogger<LrclibService> logger)
{
_httpClient = httpClientFactory.CreateClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.0 (https://github.com/SoPat712/allstarr)");
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.3.0 (https://github.com/SoPat712/allstarr)");
_cache = cache;
_logger = logger;
}

View File

@@ -0,0 +1,228 @@
using allstarr.Models.Lyrics;
using allstarr.Models.Settings;
using Microsoft.Extensions.Options;
namespace allstarr.Services.Lyrics;
/// <summary>
/// Orchestrates lyrics fetching from multiple sources with priority-based fallback.
/// Priority order: Spotify → LyricsPlus → LRCLib
/// Note: Jellyfin local lyrics are handled by the controller before calling this orchestrator.
/// </summary>
public class LyricsOrchestrator
{
private readonly SpotifyLyricsService _spotifyLyrics;
private readonly LyricsPlusService _lyricsPlus;
private readonly LrclibService _lrclib;
private readonly SpotifyApiSettings _spotifySettings;
private readonly ILogger<LyricsOrchestrator> _logger;
public LyricsOrchestrator(
SpotifyLyricsService spotifyLyrics,
LyricsPlusService lyricsPlus,
LrclibService lrclib,
IOptions<SpotifyApiSettings> spotifySettings,
ILogger<LyricsOrchestrator> logger)
{
_spotifyLyrics = spotifyLyrics;
_lyricsPlus = lyricsPlus;
_lrclib = lrclib;
_spotifySettings = spotifySettings.Value;
_logger = logger;
}
/// <summary>
/// Fetches lyrics with automatic fallback through all available sources.
/// Note: Jellyfin local lyrics are handled by the controller before calling this.
/// </summary>
/// <param name="trackName">Track title</param>
/// <param name="artistNames">Artist names (can be multiple)</param>
/// <param name="albumName">Album name</param>
/// <param name="durationSeconds">Track duration in seconds</param>
/// <param name="spotifyTrackId">Spotify track ID (if available)</param>
/// <returns>Lyrics info or null if not found</returns>
public async Task<LyricsInfo?> GetLyricsAsync(
string trackName,
string[] artistNames,
string? albumName,
int durationSeconds,
string? spotifyTrackId = null)
{
var artistName = string.Join(", ", artistNames);
_logger.LogInformation("🎵 Fetching lyrics for: {Artist} - {Track}", artistName, trackName);
// 1. Try Spotify lyrics (if Spotify ID provided)
if (!string.IsNullOrEmpty(spotifyTrackId))
{
var spotifyLyrics = await TrySpotifyLyrics(spotifyTrackId, artistName, trackName);
if (spotifyLyrics != null)
{
return spotifyLyrics;
}
}
// 2. Try LyricsPlus
var lyricsPlusLyrics = await TryLyricsPlusLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
if (lyricsPlusLyrics != null)
{
return lyricsPlusLyrics;
}
// 3. Try LRCLib
var lrclibLyrics = await TryLrclibLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
if (lrclibLyrics != null)
{
return lrclibLyrics;
}
_logger.LogInformation("❌ No lyrics found for: {Artist} - {Track}", artistName, trackName);
return null;
}
/// <summary>
/// Prefetches lyrics in the background (for cache warming).
/// Skips Jellyfin local since we don't have an itemId.
/// </summary>
public async Task<bool> PrefetchLyricsAsync(
string trackName,
string[] artistNames,
string? albumName,
int durationSeconds,
string? spotifyTrackId = null)
{
var artistName = string.Join(", ", artistNames);
_logger.LogDebug("🎵 Prefetching lyrics for: {Artist} - {Track}", artistName, trackName);
// 1. Try Spotify lyrics (if Spotify ID provided)
if (!string.IsNullOrEmpty(spotifyTrackId))
{
var spotifyLyrics = await TrySpotifyLyrics(spotifyTrackId, artistName, trackName);
if (spotifyLyrics != null)
{
return true;
}
}
// 2. Try LyricsPlus
var lyricsPlusLyrics = await TryLyricsPlusLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
if (lyricsPlusLyrics != null)
{
return true;
}
// 3. Try LRCLib
var lrclibLyrics = await TryLrclibLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
if (lrclibLyrics != null)
{
return true;
}
_logger.LogDebug("No lyrics found for prefetch: {Artist} - {Track}", artistName, trackName);
return false;
}
#region Private Helper Methods
private async Task<LyricsInfo?> TrySpotifyLyrics(string spotifyTrackId, string artistName, string trackName)
{
if (!_spotifySettings.Enabled)
{
_logger.LogDebug("Spotify API not enabled, skipping Spotify lyrics");
return null;
}
try
{
// Validate Spotify ID format
var cleanSpotifyId = spotifyTrackId.Replace("spotify:track:", "").Trim();
if (cleanSpotifyId.Length != 22 || cleanSpotifyId.Contains(":") || cleanSpotifyId.Contains("local"))
{
_logger.LogDebug("Invalid Spotify ID format: {SpotifyId}, skipping", spotifyTrackId);
return null;
}
_logger.LogDebug("→ Trying Spotify lyrics for track ID: {SpotifyId}", cleanSpotifyId);
var spotifyLyrics = await _spotifyLyrics.GetLyricsByTrackIdAsync(cleanSpotifyId);
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
{
_logger.LogInformation("✓ Found Spotify lyrics for {Artist} - {Track} ({LineCount} lines, type: {SyncType})",
artistName, trackName, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType);
return _spotifyLyrics.ToLyricsInfo(spotifyLyrics);
}
_logger.LogDebug("No Spotify lyrics found for track ID {SpotifyId}", cleanSpotifyId);
return null;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error fetching Spotify lyrics for track ID {SpotifyId}", spotifyTrackId);
return null;
}
}
private async Task<LyricsInfo?> TryLyricsPlusLyrics(
string trackName,
string[] artistNames,
string? albumName,
int durationSeconds,
string artistName)
{
try
{
_logger.LogDebug("→ Trying LyricsPlus for: {Artist} - {Track}", artistName, trackName);
var lyrics = await _lyricsPlus.GetLyricsAsync(trackName, artistNames, albumName, durationSeconds);
if (lyrics != null)
{
_logger.LogInformation("✓ Found LyricsPlus lyrics for {Artist} - {Track}", artistName, trackName);
return lyrics;
}
_logger.LogDebug("No LyricsPlus lyrics found for {Artist} - {Track}", artistName, trackName);
return null;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error fetching LyricsPlus lyrics for {Artist} - {Track}", artistName, trackName);
return null;
}
}
private async Task<LyricsInfo?> TryLrclibLyrics(
string trackName,
string[] artistNames,
string? albumName,
int durationSeconds,
string artistName)
{
try
{
_logger.LogDebug("→ Trying LRCLib for: {Artist} - {Track}", artistName, trackName);
var lyrics = await _lrclib.GetLyricsAsync(trackName, artistNames, albumName ?? string.Empty, durationSeconds);
if (lyrics != null)
{
_logger.LogInformation("✓ Found LRCLib lyrics for {Artist} - {Track}", artistName, trackName);
return lyrics;
}
_logger.LogDebug("No LRCLib lyrics found for {Artist} - {Track}", artistName, trackName);
return null;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error fetching LRCLib lyrics for {Artist} - {Track}", artistName, trackName);
return null;
}
}
#endregion
}

View File

@@ -0,0 +1,254 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using allstarr.Models.Lyrics;
using allstarr.Services.Common;
namespace allstarr.Services.Lyrics;
/// <summary>
/// Service for fetching lyrics from LyricsPlus API (https://lyricsplus.prjktla.workers.dev)
/// Supports multiple sources: Apple Music, Spotify, Musixmatch, and more
/// </summary>
public class LyricsPlusService
{
private readonly HttpClient _httpClient;
private readonly RedisCacheService _cache;
private readonly ILogger<LyricsPlusService> _logger;
private const string BaseUrl = "https://lyricsplus.prjktla.workers.dev/v2/lyrics/get";
public LyricsPlusService(
IHttpClientFactory httpClientFactory,
RedisCacheService cache,
ILogger<LyricsPlusService> logger)
{
_httpClient = httpClientFactory.CreateClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.3.0 (https://github.com/SoPat712/allstarr)");
_cache = cache;
_logger = logger;
}
public async Task<LyricsInfo?> GetLyricsAsync(string trackName, string artistName, string? albumName, int durationSeconds)
{
return await GetLyricsAsync(trackName, new[] { artistName }, albumName, durationSeconds);
}
public async Task<LyricsInfo?> GetLyricsAsync(string trackName, string[] artistNames, string? albumName, int durationSeconds)
{
// Validate input parameters
if (string.IsNullOrWhiteSpace(trackName) || artistNames == null || artistNames.Length == 0)
{
_logger.LogDebug("Invalid parameters for LyricsPlus search: trackName={TrackName}, artistCount={ArtistCount}",
trackName, artistNames?.Length ?? 0);
return null;
}
var artistName = string.Join(", ", artistNames);
var cacheKey = $"lyricsplus:{artistName}:{trackName}:{albumName}:{durationSeconds}";
// Check cache
var cached = await _cache.GetStringAsync(cacheKey);
if (!string.IsNullOrEmpty(cached))
{
try
{
return JsonSerializer.Deserialize<LyricsInfo>(cached, JsonOptions);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to deserialize cached LyricsPlus lyrics");
}
}
try
{
// Build URL with query parameters
var url = $"{BaseUrl}?title={Uri.EscapeDataString(trackName)}&artist={Uri.EscapeDataString(artistName)}";
if (!string.IsNullOrEmpty(albumName))
{
url += $"&album={Uri.EscapeDataString(albumName)}";
}
if (durationSeconds > 0)
{
url += $"&duration={durationSeconds}";
}
// Add sources: apple, lyricsplus, musixmatch, spotify, musixmatch-word
url += "&source=apple,lyricsplus,musixmatch,spotify,musixmatch-word";
_logger.LogDebug("Fetching lyrics from LyricsPlus: {Url}", url);
var response = await _httpClient.GetAsync(url);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
_logger.LogDebug("Lyrics not found on LyricsPlus for {Artist} - {Track}", artistName, trackName);
return null;
}
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync();
var lyricsResponse = JsonSerializer.Deserialize<LyricsPlusResponse>(json, JsonOptions);
if (lyricsResponse == null || lyricsResponse.Lyrics == null || lyricsResponse.Lyrics.Count == 0)
{
_logger.LogDebug("Empty lyrics response from LyricsPlus for {Artist} - {Track}", artistName, trackName);
return null;
}
// Convert to LyricsInfo format
var result = ConvertToLyricsInfo(lyricsResponse, trackName, artistName, albumName, durationSeconds);
if (result != null)
{
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), TimeSpan.FromDays(30));
_logger.LogInformation("✓ Retrieved lyrics from LyricsPlus for {Artist} - {Track} (type: {Type}, source: {Source})",
artistName, trackName, lyricsResponse.Type, lyricsResponse.Metadata?.Source);
}
return result;
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "Failed to fetch lyrics from LyricsPlus for {Artist} - {Track}", artistName, trackName);
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching lyrics from LyricsPlus for {Artist} - {Track}", artistName, trackName);
return null;
}
}
private LyricsInfo? ConvertToLyricsInfo(LyricsPlusResponse response, string trackName, string artistName, string? albumName, int durationSeconds)
{
if (response.Lyrics == null || response.Lyrics.Count == 0)
{
return null;
}
string? syncedLyrics = null;
string? plainLyrics = null;
// Convert based on type
if (response.Type == "Word")
{
// Word-level timing - convert to line-level LRC
syncedLyrics = ConvertWordTimingToLrc(response.Lyrics);
plainLyrics = string.Join("\n", response.Lyrics.Select(l => l.Text));
}
else if (response.Type == "Line")
{
// Line-level timing - convert to LRC
syncedLyrics = ConvertLineTimingToLrc(response.Lyrics);
plainLyrics = string.Join("\n", response.Lyrics.Select(l => l.Text));
}
else
{
// Static or unknown type - just plain text
plainLyrics = string.Join("\n", response.Lyrics.Select(l => l.Text));
}
return new LyricsInfo
{
TrackName = trackName,
ArtistName = artistName,
AlbumName = albumName ?? string.Empty,
Duration = durationSeconds,
Instrumental = false,
PlainLyrics = plainLyrics,
SyncedLyrics = syncedLyrics
};
}
private string ConvertLineTimingToLrc(List<LyricsPlusLine> lines)
{
var lrcLines = new List<string>();
foreach (var line in lines)
{
if (line.Time.HasValue)
{
var timestamp = TimeSpan.FromMilliseconds(line.Time.Value);
var mm = (int)timestamp.TotalMinutes;
var ss = timestamp.Seconds;
var cs = timestamp.Milliseconds / 10; // Convert to centiseconds
lrcLines.Add($"[{mm:D2}:{ss:D2}.{cs:D2}]{line.Text}");
}
else
{
// No timing, just add the text
lrcLines.Add(line.Text);
}
}
return string.Join("\n", lrcLines);
}
private string ConvertWordTimingToLrc(List<LyricsPlusLine> lines)
{
// For word-level timing, we use the line start time
// (word-level detail is in syllabus array but we simplify to line-level for LRC)
return ConvertLineTimingToLrc(lines);
}
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
private class LyricsPlusResponse
{
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty; // "Word", "Line", or "Static"
[JsonPropertyName("metadata")]
public LyricsPlusMetadata? Metadata { get; set; }
[JsonPropertyName("lyrics")]
public List<LyricsPlusLine> Lyrics { get; set; } = new();
}
private class LyricsPlusMetadata
{
[JsonPropertyName("source")]
public string? Source { get; set; }
[JsonPropertyName("title")]
public string? Title { get; set; }
[JsonPropertyName("language")]
public string? Language { get; set; }
}
private class LyricsPlusLine
{
[JsonPropertyName("time")]
public long? Time { get; set; } // Milliseconds
[JsonPropertyName("duration")]
public long? Duration { get; set; }
[JsonPropertyName("text")]
public string Text { get; set; } = string.Empty;
[JsonPropertyName("syllabus")]
public List<LyricsPlusSyllable>? Syllabus { get; set; }
}
private class LyricsPlusSyllable
{
[JsonPropertyName("time")]
public long Time { get; set; }
[JsonPropertyName("duration")]
public long Duration { get; set; }
[JsonPropertyName("text")]
public string Text { get; set; } = string.Empty;
}
}

View File

@@ -167,16 +167,14 @@ public class LyricsStartupValidator : BaseStartupValidator
{
try
{
if (string.IsNullOrEmpty(_spotifySettings.ClientId))
if (!_spotifySettings.Enabled)
{
WriteStatus("Spotify API", "NOT CONFIGURED", ConsoleColor.Yellow);
WriteDetail("Set SpotifyApi__ClientId to enable");
WriteStatus("Spotify API", "DISABLED", ConsoleColor.Gray);
return true;
}
WriteStatus("Spotify API", "CONFIGURED", ConsoleColor.Green);
WriteDetail($"Client ID: {_spotifySettings.ClientId.Substring(0, Math.Min(8, _spotifySettings.ClientId.Length))}...");
WriteDetail("Note: Spotify API is used for track matching, not lyrics");
WriteDetail("Note: Spotify API is used for track matching and lyrics");
return true;
}
catch (Exception ex)

View File

@@ -25,7 +25,7 @@ public class MusicBrainzService
ILogger<MusicBrainzService> logger)
{
_httpClient = httpClientFactory.CreateClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.0 (https://github.com/SoPat712/allstarr)");
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.3.0 (https://github.com/SoPat712/allstarr)");
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_settings = settings.Value;
@@ -92,6 +92,7 @@ public class MusicBrainzService
/// <summary>
/// Searches for recordings by title and artist.
/// Note: Search API doesn't return genres, only MBIDs. Use LookupByMbidAsync to get genres.
/// </summary>
public async Task<List<MusicBrainzRecording>> SearchRecordingsAsync(string title, string artist, int limit = 5)
{
@@ -107,7 +108,8 @@ public class MusicBrainzService
// Build Lucene query
var query = $"recording:\"{title}\" AND artist:\"{artist}\"";
var encodedQuery = Uri.EscapeDataString(query);
var url = $"{_settings.BaseUrl}/recording?query={encodedQuery}&fmt=json&limit={limit}&inc=genres+tags";
// Note: Search API doesn't support inc=genres, only returns basic info + MBIDs
var url = $"{_settings.BaseUrl}/recording?query={encodedQuery}&fmt=json&limit={limit}";
_logger.LogDebug("MusicBrainz search: {Url}", url);
@@ -140,9 +142,56 @@ public class MusicBrainzService
}
}
/// <summary>
/// Looks up a recording by MBID to get full details including genres.
/// </summary>
public async Task<MusicBrainzRecording?> LookupByMbidAsync(string mbid)
{
if (!_settings.Enabled)
{
return null;
}
await RateLimitAsync();
try
{
var url = $"{_settings.BaseUrl}/recording/{mbid}?fmt=json&inc=artists+releases+release-groups+genres+tags";
_logger.LogDebug("MusicBrainz MBID lookup: {Url}", url);
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("MusicBrainz MBID lookup failed: {StatusCode}", response.StatusCode);
return null;
}
var json = await response.Content.ReadAsStringAsync();
var recording = JsonSerializer.Deserialize<MusicBrainzRecording>(json, JsonOptions);
if (recording == null)
{
_logger.LogDebug("No MusicBrainz recording found for MBID: {Mbid}", mbid);
return null;
}
var genres = recording.Genres?.Select(g => g.Name).Where(n => !string.IsNullOrEmpty(n)).ToList() ?? new List<string?>();
_logger.LogInformation("✓ Found MusicBrainz recording for MBID {Mbid}: {Title} by {Artist} (Genres: {Genres})",
mbid, recording.Title, recording.ArtistCredit?[0]?.Name ?? "Unknown", string.Join(", ", genres));
return recording;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error looking up MBID {Mbid} in MusicBrainz", mbid);
return null;
}
}
/// <summary>
/// Enriches a song with genre information from MusicBrainz.
/// First tries ISRC lookup, then falls back to title/artist search.
/// First tries ISRC lookup, then falls back to title/artist search + MBID lookup.
/// </summary>
public async Task<List<string>> GetGenresForSongAsync(string title, string artist, string? isrc = null)
{
@@ -153,17 +202,23 @@ public class MusicBrainzService
MusicBrainzRecording? recording = null;
// Try ISRC lookup first (most accurate)
// Try ISRC lookup first (most accurate and includes genres)
if (!string.IsNullOrEmpty(isrc))
{
recording = await LookupByIsrcAsync(isrc);
}
// Fall back to search if ISRC lookup failed or no ISRC provided
// Fall back to search + MBID lookup if ISRC lookup failed or no ISRC provided
if (recording == null)
{
var recordings = await SearchRecordingsAsync(title, artist, limit: 1);
recording = recordings.FirstOrDefault();
var searchResult = recordings.FirstOrDefault();
// If we found a recording from search, do a full lookup by MBID to get genres
if (searchResult != null && !string.IsNullOrEmpty(searchResult.Id))
{
recording = await LookupByMbidAsync(searchResult.Id);
}
}
if (recording == null)

View File

@@ -3,6 +3,7 @@ using allstarr.Models.Settings;
using allstarr.Models.Download;
using allstarr.Models.Search;
using allstarr.Models.Subsonic;
using allstarr.Services.Common;
using System.Text.Json;
using Microsoft.Extensions.Options;
@@ -18,6 +19,7 @@ public class QobuzMetadataService : IMusicMetadataService
private readonly SubsonicSettings _settings;
private readonly QobuzBundleService _bundleService;
private readonly ILogger<QobuzMetadataService> _logger;
private readonly GenreEnrichmentService? _genreEnrichment;
private readonly string? _userAuthToken;
private readonly string? _userId;
@@ -28,12 +30,14 @@ public class QobuzMetadataService : IMusicMetadataService
IOptions<SubsonicSettings> settings,
IOptions<QobuzSettings> qobuzSettings,
QobuzBundleService bundleService,
ILogger<QobuzMetadataService> logger)
ILogger<QobuzMetadataService> logger,
GenreEnrichmentService? genreEnrichment = null)
{
_httpClient = httpClientFactory.CreateClient();
_settings = settings.Value;
_bundleService = bundleService;
_logger = logger;
_genreEnrichment = genreEnrichment;
var qobuzConfig = qobuzSettings.Value;
_userAuthToken = qobuzConfig.UserAuthToken;
@@ -177,7 +181,26 @@ public class QobuzMetadataService : IMusicMetadataService
if (track.TryGetProperty("error", out _)) return null;
return ParseQobuzTrackFull(track);
var song = ParseQobuzTrackFull(track);
// Enrich with MusicBrainz genres if missing
if (_genreEnrichment != null && song != null && string.IsNullOrEmpty(song.Genre))
{
// 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;
}
catch (Exception ex)
{

View File

@@ -992,7 +992,8 @@ public class SpotifyTrackMatchingService : BackgroundService
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);
if (statusCode != 200 || existingTracksResponse == null)
@@ -1301,6 +1302,61 @@ public class SpotifyTrackMatchingService : BackgroundService
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)
var cacheKey = $"spotify:playlist:items:{playlistName}";
await _cache.SetAsync(cacheKey, finalItems, cacheExpiration);

View File

@@ -56,6 +56,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
private readonly ILogger<SquidWTFMetadataService> _logger;
private readonly RedisCacheService _cache;
private readonly RoundRobinFallbackHelper _fallbackHelper;
private readonly GenreEnrichmentService? _genreEnrichment;
public SquidWTFMetadataService(
IHttpClientFactory httpClientFactory,
@@ -63,13 +64,15 @@ public class SquidWTFMetadataService : IMusicMetadataService
IOptions<SquidWTFSettings> squidwtfSettings,
ILogger<SquidWTFMetadataService> logger,
RedisCacheService cache,
List<string> apiUrls)
List<string> apiUrls,
GenreEnrichmentService? genreEnrichment = null)
{
_httpClient = httpClientFactory.CreateClient();
_settings = settings.Value;
_logger = logger;
_cache = cache;
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
_genreEnrichment = genreEnrichment;
// Set up default headers
_httpClient.DefaultRequestHeaders.Add("User-Agent",
@@ -83,19 +86,19 @@ public class SquidWTFMetadataService : IMusicMetadataService
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
{
// Race all endpoints for fastest search results
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
// Use round-robin to distribute load across endpoints (allows parallel processing of multiple tracks)
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
// Use 's' parameter for track search as per hifi-api spec
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url, ct);
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
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
var result = JsonDocument.Parse(json);
@@ -129,19 +132,19 @@ public class SquidWTFMetadataService : IMusicMetadataService
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
{
// Race all endpoints for fastest search results
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
// Use round-robin to distribute load across endpoints (allows parallel processing)
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
// Note: hifi-api doesn't document album search, but 'al' parameter is commonly used
var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url, ct);
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
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 albums = new List<Album>();
@@ -166,14 +169,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
{
// Race all endpoints for fastest search results
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
// Use round-robin to distribute load across endpoints (allows parallel processing)
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
// Per hifi-api spec: use 'a' parameter for artist search
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
_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)
{
@@ -181,7 +184,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
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 artists = new List<Artist>();
@@ -286,6 +289,23 @@ public class SquidWTFMetadataService : IMusicMetadataService
var song = ParseTidalTrackFull(track);
// Enrich with MusicBrainz genres if missing (SquidWTF/Tidal doesn't provide genres)
if (_genreEnrichment != null && string.IsNullOrEmpty(song.Genre))
{
// 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)
// This avoids redundant conversions and ensures it's done in parallel with the download

View File

@@ -5,9 +5,9 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>allstarr</RootNamespace>
<Version>1.0.0</Version>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<Version>1.3.0</Version>
<AssemblyVersion>1.3.0.0</AssemblyVersion>
<FileVersion>1.3.0.0</FileVersion>
</PropertyGroup>
<ItemGroup>

View File

@@ -61,8 +61,6 @@
},
"SpotifyApi": {
"Enabled": false,
"ClientId": "",
"ClientSecret": "",
"SessionCookie": "",
"CacheDurationMinutes": 60,
"RateLimitDelayMs": 100,

View File

@@ -107,8 +107,6 @@ services:
# ===== SPOTIFY DIRECT API (for lyrics, ISRC matching, track ordering) =====
- SpotifyApi__Enabled=${SPOTIFY_API_ENABLED:-false}
- SpotifyApi__ClientId=${SPOTIFY_API_CLIENT_ID:-}
- SpotifyApi__ClientSecret=${SPOTIFY_API_CLIENT_SECRET:-}
- SpotifyApi__SessionCookie=${SPOTIFY_API_SESSION_COOKIE:-}
- SpotifyApi__SessionCookieSetDate=${SPOTIFY_API_SESSION_COOKIE_SET_DATE:-}
- SpotifyApi__CacheDurationMinutes=${SPOTIFY_API_CACHE_DURATION_MINUTES:-60}