Compare commits

...

9 Commits

Author SHA1 Message Date
ad5fea7d8e Fix LRCLib and SquidWTF error handling
Some checks failed
CI / build-and-test (push) Has been cancelled
- Handle nullable duration in LRCLib API responses
- Validate input parameters before making LRCLib requests
- Change SquidWTF artist warning to debug level (expected behavior)
- Prevent JSON deserialization errors when duration is null
- Prevent 400 Bad Request errors from empty track names
2026-02-06 02:07:45 -05:00
8a3abdcbf7 Fix LyricsStartupValidator build errors
- Remove duplicate _httpClient field (use inherited one)
- Replace ValidationResult.Warning with ValidationResult.Failure
- Use PARTIAL status for partial failures
2026-02-06 01:54:32 -05:00
f103dac6c8 Add comprehensive lyrics startup validation with '22' test
- Revert album endpoint back to ?id= (correct parameter)
- Update SquidWTF validator to test '22' by Taylor Swift
- Create LyricsStartupValidator testing all lyrics services:
  * LRCLib API
  * Spotify Lyrics Sidecar (docker container)
  * Spotify API configuration
- Test song: '22' by Taylor Swift (Spotify ID: 3yII7UwgLF6K5zW3xad3MP)
- Register lyrics validator in startup orchestrator
2026-02-06 01:48:12 -05:00
7abc26c069 Fix album detail endpoint to use correct parameter
- Change album endpoint from ?id= to ?f= to match API spec
- Album search parsing is correct (data.albums.items)
- Album detail parsing is correct (data with items array)
2026-02-06 01:45:10 -05:00
a2e9021100 Fix artist detail parsing to handle missing artist property
- Add TryGetProperty check for artist field in albums response
- Log response keys when artist data not found for debugging
- Improves error handling when API returns albums without artist field
2026-02-06 01:39:43 -05:00
5f22fb0a3b Integrate Spotify lyrics sidecar service
- Add spotify-lyrics-api sidecar container to docker-compose
- Replace direct Spotify API lyrics code with sidecar API calls
- Update SpotifyLyricsService to use sidecar exclusively
- Add LyricsApiUrl setting to SpotifyApiSettings
- Update prefetch to try Spotify lyrics first, then LRCLib
- Remove unused direct API authentication and parsing code
2026-02-06 01:24:49 -05:00
a3d1d81810 Add Spotify lyrics sidecar service and integrate with prefetch
- Add spotify-lyrics-api container to docker-compose
- Update SpotifyLyricsService to use sidecar API
- Prefetch now tries Spotify lyrics first (using track ID), then LRCLib
- Add SPOTIFY_LYRICS_API_URL setting
- Sidecar handles sp_dc cookie authentication automatically
2026-02-06 01:21:30 -05:00
2dd7020a61 Add logging to Spotify lyrics search for better debugging
Show when Spotify search is skipped, fails, or finds no matches
2026-02-06 00:59:23 -05:00
e36e685bee Fix lyrics cache key mismatch between prefetch and lookup
Use same artist format (comma-separated) in prefetch as LrclibService
to ensure cached lyrics are found during playback
2026-02-06 00:58:13 -05:00
10 changed files with 395 additions and 224 deletions

View File

@@ -187,3 +187,9 @@ SPOTIFY_API_RATE_LIMIT_DELAY_MS=100
# Prefer ISRC matching over fuzzy title/artist matching (default: true) # Prefer ISRC matching over fuzzy title/artist matching (default: true)
# ISRC provides exact track identification across different streaming services # ISRC provides exact track identification across different streaming services
SPOTIFY_API_PREFER_ISRC_MATCHING=true SPOTIFY_API_PREFER_ISRC_MATCHING=true
# Spotify Lyrics API URL (default: http://spotify-lyrics:8080)
# Uses the spotify-lyrics-api sidecar service for fetching synchronized lyrics
# This service is automatically started in docker-compose
# Leave as default unless running a custom deployment
SPOTIFY_LYRICS_API_URL=http://spotify-lyrics:8080

View File

@@ -69,4 +69,11 @@ public class SpotifyApiSettings
/// Used to track cookie age and warn when it's approaching expiration (~1 year). /// Used to track cookie age and warn when it's approaching expiration (~1 year).
/// </summary> /// </summary>
public string? SessionCookieSetDate { get; set; } public string? SessionCookieSetDate { get; set; }
/// <summary>
/// URL of the Spotify Lyrics API sidecar service.
/// Default: http://spotify-lyrics:8080 (docker-compose service name)
/// This service wraps Spotify's color-lyrics API for easier access.
/// </summary>
public string LyricsApiUrl { get; set; } = "http://spotify-lyrics:8080";
} }

View File

@@ -482,6 +482,7 @@ builder.Services.AddSingleton<IStartupValidator>(sp =>
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(), sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
sp.GetRequiredService<IHttpClientFactory>().CreateClient(), sp.GetRequiredService<IHttpClientFactory>().CreateClient(),
squidWtfApiUrls)); squidWtfApiUrls));
builder.Services.AddSingleton<IStartupValidator, LyricsStartupValidator>();
// Register orchestrator as hosted service // Register orchestrator as hosted service
builder.Services.AddHostedService<StartupValidationOrchestrator>(); builder.Services.AddHostedService<StartupValidationOrchestrator>();

View File

@@ -30,6 +30,14 @@ public class LrclibService
public async Task<LyricsInfo?> GetLyricsAsync(string trackName, string[] artistNames, string albumName, int 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 lyrics search: trackName={TrackName}, artistCount={ArtistCount}",
trackName, artistNames?.Length ?? 0);
return null;
}
var artistName = string.Join(", ", artistNames); var artistName = string.Join(", ", artistNames);
var cacheKey = $"lyrics:{artistName}:{trackName}:{albumName}:{durationSeconds}"; var cacheKey = $"lyrics:{artistName}:{trackName}:{albumName}:{durationSeconds}";
@@ -112,7 +120,7 @@ public class LrclibService
var artistCountBonus = resultArtistCount == expectedArtistCount ? 50.0 : 0.0; var artistCountBonus = resultArtistCount == expectedArtistCount ? 50.0 : 0.0;
// Duration match (within 5 seconds is good) // Duration match (within 5 seconds is good)
var durationDiff = Math.Abs(result.Duration - durationSeconds); var durationDiff = result.Duration.HasValue ? Math.Abs(result.Duration.Value - durationSeconds) : 999;
var durationScore = durationDiff <= 5 ? 100.0 : Math.Max(0, 100 - (durationDiff * 2)); var durationScore = durationDiff <= 5 ? 100.0 : Math.Max(0, 100 - (durationDiff * 2));
// Bonus for having synced lyrics (prefer synced over plain) // Bonus for having synced lyrics (prefer synced over plain)
@@ -143,7 +151,7 @@ public class LrclibService
TrackName = bestMatch.TrackName ?? trackName, TrackName = bestMatch.TrackName ?? trackName,
ArtistName = bestMatch.ArtistName ?? artistName, ArtistName = bestMatch.ArtistName ?? artistName,
AlbumName = bestMatch.AlbumName ?? albumName, AlbumName = bestMatch.AlbumName ?? albumName,
Duration = (int)Math.Round(bestMatch.Duration), Duration = bestMatch.Duration.HasValue ? (int)Math.Round(bestMatch.Duration.Value) : durationSeconds,
Instrumental = bestMatch.Instrumental, Instrumental = bestMatch.Instrumental,
PlainLyrics = bestMatch.PlainLyrics, PlainLyrics = bestMatch.PlainLyrics,
SyncedLyrics = bestMatch.SyncedLyrics SyncedLyrics = bestMatch.SyncedLyrics
@@ -192,7 +200,7 @@ public class LrclibService
TrackName = lyrics.TrackName ?? trackName, TrackName = lyrics.TrackName ?? trackName,
ArtistName = lyrics.ArtistName ?? artistName, ArtistName = lyrics.ArtistName ?? artistName,
AlbumName = lyrics.AlbumName ?? albumName, AlbumName = lyrics.AlbumName ?? albumName,
Duration = (int)Math.Round(lyrics.Duration), Duration = lyrics.Duration.HasValue ? (int)Math.Round(lyrics.Duration.Value) : durationSeconds,
Instrumental = lyrics.Instrumental, Instrumental = lyrics.Instrumental,
PlainLyrics = lyrics.PlainLyrics, PlainLyrics = lyrics.PlainLyrics,
SyncedLyrics = lyrics.SyncedLyrics SyncedLyrics = lyrics.SyncedLyrics
@@ -334,7 +342,7 @@ public class LrclibService
TrackName = lyrics.TrackName ?? trackName, TrackName = lyrics.TrackName ?? trackName,
ArtistName = lyrics.ArtistName ?? artistName, ArtistName = lyrics.ArtistName ?? artistName,
AlbumName = lyrics.AlbumName ?? albumName, AlbumName = lyrics.AlbumName ?? albumName,
Duration = (int)Math.Round(lyrics.Duration), Duration = lyrics.Duration.HasValue ? (int)Math.Round(lyrics.Duration.Value) : durationSeconds,
Instrumental = lyrics.Instrumental, Instrumental = lyrics.Instrumental,
PlainLyrics = lyrics.PlainLyrics, PlainLyrics = lyrics.PlainLyrics,
SyncedLyrics = lyrics.SyncedLyrics SyncedLyrics = lyrics.SyncedLyrics
@@ -390,7 +398,7 @@ public class LrclibService
TrackName = lyrics.TrackName ?? string.Empty, TrackName = lyrics.TrackName ?? string.Empty,
ArtistName = lyrics.ArtistName ?? string.Empty, ArtistName = lyrics.ArtistName ?? string.Empty,
AlbumName = lyrics.AlbumName ?? string.Empty, AlbumName = lyrics.AlbumName ?? string.Empty,
Duration = (int)Math.Round(lyrics.Duration), Duration = lyrics.Duration.HasValue ? (int)Math.Round(lyrics.Duration.Value) : 0,
Instrumental = lyrics.Instrumental, Instrumental = lyrics.Instrumental,
PlainLyrics = lyrics.PlainLyrics, PlainLyrics = lyrics.PlainLyrics,
SyncedLyrics = lyrics.SyncedLyrics SyncedLyrics = lyrics.SyncedLyrics
@@ -419,7 +427,7 @@ public class LrclibService
public string? TrackName { get; set; } public string? TrackName { get; set; }
public string? ArtistName { get; set; } public string? ArtistName { get; set; }
public string? AlbumName { get; set; } public string? AlbumName { get; set; }
public double Duration { get; set; } public double? Duration { get; set; }
public bool Instrumental { get; set; } public bool Instrumental { get; set; }
public string? PlainLyrics { get; set; } public string? PlainLyrics { get; set; }
public string? SyncedLyrics { get; set; } public string? SyncedLyrics { get; set; }

View File

@@ -135,7 +135,9 @@ public class LyricsPrefetchService : BackgroundService
try try
{ {
// Check if lyrics are already cached // Check if lyrics are already cached
var cacheKey = $"lyrics:{track.PrimaryArtist}:{track.Title}:{track.Album}:{track.DurationMs / 1000}"; // Use same cache key format as LrclibService: join all artists with ", "
var artistName = string.Join(", ", track.Artists);
var cacheKey = $"lyrics:{artistName}:{track.Title}:{track.Album}:{track.DurationMs / 1000}";
var existingLyrics = await _cache.GetStringAsync(cacheKey); var existingLyrics = await _cache.GetStringAsync(cacheKey);
if (!string.IsNullOrEmpty(existingLyrics)) if (!string.IsNullOrEmpty(existingLyrics))
@@ -154,16 +156,27 @@ public class LyricsPrefetchService : BackgroundService
track.PrimaryArtist, track.Title); track.PrimaryArtist, track.Title);
// Remove any previously cached LRCLib lyrics for this track // Remove any previously cached LRCLib lyrics for this track
await RemoveCachedLyricsAsync(track.PrimaryArtist, track.Title, track.Album, track.DurationMs / 1000); var artistNameForRemoval = string.Join(", ", track.Artists);
await RemoveCachedLyricsAsync(artistNameForRemoval, track.Title, track.Album, track.DurationMs / 1000);
continue; continue;
} }
// Fetch lyrics from LRCLib // Try Spotify lyrics first if we have a Spotify ID
var lyrics = await _lrclibService.GetLyricsAsync( LyricsInfo? lyrics = null;
track.Title, if (!string.IsNullOrEmpty(track.SpotifyId))
track.Artists.ToArray(), {
track.Album, lyrics = await TryGetSpotifyLyricsAsync(track.SpotifyId, track.Title, track.PrimaryArtist);
track.DurationMs / 1000); }
// Fall back to LRCLib if no Spotify lyrics
if (lyrics == null)
{
lyrics = await _lrclibService.GetLyricsAsync(
track.Title,
track.Artists.ToArray(),
track.Album,
track.DurationMs / 1000);
}
if (lyrics != null) if (lyrics != null)
{ {
@@ -172,7 +185,8 @@ public class LyricsPrefetchService : BackgroundService
track.PrimaryArtist, track.Title, !string.IsNullOrEmpty(lyrics.SyncedLyrics)); track.PrimaryArtist, track.Title, !string.IsNullOrEmpty(lyrics.SyncedLyrics));
// Save to file cache // Save to file cache
await SaveLyricsToFileAsync(track.PrimaryArtist, track.Title, track.Album, track.DurationMs / 1000, lyrics); var artistNameForSave = string.Join(", ", track.Artists);
await SaveLyricsToFileAsync(artistNameForSave, track.Title, track.Album, track.DurationMs / 1000, lyrics);
} }
else else
{ {
@@ -301,6 +315,40 @@ public class LyricsPrefetchService : BackgroundService
} }
} }
/// <summary>
/// Tries to get lyrics from Spotify using the track's Spotify ID.
/// Returns null if Spotify API is not enabled or lyrics not found.
/// </summary>
private async Task<LyricsInfo?> TryGetSpotifyLyricsAsync(string spotifyTrackId, string trackTitle, string artistName)
{
try
{
using var scope = _serviceProvider.CreateScope();
var spotifyLyricsService = scope.ServiceProvider.GetService<SpotifyLyricsService>();
if (spotifyLyricsService == null)
{
return null;
}
var spotifyLyrics = await spotifyLyricsService.GetLyricsByTrackIdAsync(spotifyTrackId);
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
{
_logger.LogInformation("✓ Found Spotify lyrics for {Artist} - {Track} ({LineCount} lines)",
artistName, trackTitle, spotifyLyrics.Lines.Count);
return spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
}
return null;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error fetching Spotify lyrics for track {SpotifyId}", spotifyTrackId);
return null;
}
}
/// <summary> /// <summary>
/// Checks if a track has embedded lyrics in Jellyfin by querying the Jellyfin API. /// Checks if a track has embedded lyrics in Jellyfin by querying the Jellyfin API.
/// This prevents downloading lyrics from LRCLib when the local file already has them. /// This prevents downloading lyrics from LRCLib when the local file already has them.

View File

@@ -0,0 +1,189 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Services.Validation;
namespace allstarr.Services.Lyrics;
/// <summary>
/// Validates lyrics services (LRCLib, Spotify Lyrics Sidecar, Spotify API) at startup
/// Tests with "22" by Taylor Swift (Spotify ID: 3yII7UwgLF6K5zW3xad3MP)
/// </summary>
public class LyricsStartupValidator : BaseStartupValidator
{
private readonly SpotifyApiSettings _spotifySettings;
// Test song: "22" by Taylor Swift
private const string TestSongTitle = "22";
private const string TestArtist = "Taylor Swift";
private const string TestAlbum = "Red";
private const int TestDuration = 232; // seconds
private const string TestSpotifyId = "3yII7UwgLF6K5zW3xad3MP";
public override string ServiceName => "Lyrics Services";
public LyricsStartupValidator(
IOptions<SpotifyApiSettings> spotifySettings,
IHttpClientFactory httpClientFactory)
: base(httpClientFactory.CreateClient())
{
_spotifySettings = spotifySettings.Value;
_httpClient.Timeout = TimeSpan.FromSeconds(10);
}
public override async Task<ValidationResult> ValidateAsync(CancellationToken cancellationToken)
{
Console.WriteLine();
WriteStatus("Lyrics Test Song", $"{TestSongTitle} by {TestArtist}", ConsoleColor.Cyan);
WriteDetail($"Spotify ID: {TestSpotifyId}");
var allSuccess = true;
// Test 1: LRCLib
allSuccess &= await TestLrclibAsync(cancellationToken);
// Test 2: Spotify Lyrics Sidecar
allSuccess &= await TestSpotifyLyricsSidecarAsync(cancellationToken);
// Test 3: Spotify API (if enabled)
if (_spotifySettings.Enabled)
{
allSuccess &= await TestSpotifyApiAsync(cancellationToken);
}
else
{
WriteStatus("Spotify API", "DISABLED", ConsoleColor.Yellow);
WriteDetail("Enable SpotifyApi__Enabled to test Spotify API lyrics");
}
return allSuccess
? ValidationResult.Success("Lyrics services validation completed")
: ValidationResult.Failure("PARTIAL", "Some lyrics services had issues", ConsoleColor.Yellow);
}
private async Task<bool> TestLrclibAsync(CancellationToken cancellationToken)
{
try
{
var url = $"https://lrclib.net/api/get?artist_name={Uri.EscapeDataString(TestArtist)}&track_name={Uri.EscapeDataString(TestSongTitle)}&album_name={Uri.EscapeDataString(TestAlbum)}&duration={TestDuration}";
var response = await _httpClient.GetAsync(url, cancellationToken);
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var doc = JsonDocument.Parse(json);
var hasSyncedLyrics = doc.RootElement.TryGetProperty("syncedLyrics", out var synced) &&
!string.IsNullOrEmpty(synced.GetString());
var hasPlainLyrics = doc.RootElement.TryGetProperty("plainLyrics", out var plain) &&
!string.IsNullOrEmpty(plain.GetString());
WriteStatus("LRCLib", "WORKING", ConsoleColor.Green);
WriteDetail($"✓ Synced: {hasSyncedLyrics}, Plain: {hasPlainLyrics}");
return true;
}
else if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
WriteStatus("LRCLib", "NO LYRICS FOUND", ConsoleColor.Yellow);
WriteDetail("Service is working but no lyrics available for test song");
return true; // Service is working, just no lyrics
}
else
{
WriteStatus("LRCLib", $"HTTP {(int)response.StatusCode}", ConsoleColor.Red);
return false;
}
}
catch (Exception ex)
{
WriteStatus("LRCLib", "ERROR", ConsoleColor.Red);
WriteDetail($"Failed to connect: {ex.Message}");
return false;
}
}
private async Task<bool> TestSpotifyLyricsSidecarAsync(CancellationToken cancellationToken)
{
try
{
if (string.IsNullOrEmpty(_spotifySettings.LyricsApiUrl))
{
WriteStatus("Spotify Lyrics Sidecar", "NOT CONFIGURED", ConsoleColor.Yellow);
WriteDetail("Set SpotifyApi__LyricsApiUrl to enable");
return true; // Not an error, just not configured
}
var url = $"{_spotifySettings.LyricsApiUrl}/?trackid={TestSpotifyId}&format=id3";
var response = await _httpClient.GetAsync(url, cancellationToken);
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var doc = JsonDocument.Parse(json);
var hasError = doc.RootElement.TryGetProperty("error", out var error) && error.GetBoolean();
if (hasError)
{
var message = doc.RootElement.TryGetProperty("message", out var msg)
? msg.GetString()
: "Unknown error";
WriteStatus("Spotify Lyrics Sidecar", "API ERROR", ConsoleColor.Yellow);
WriteDetail($"⚠ {message}");
WriteDetail("Check if sp_dc cookie is valid");
return false;
}
var syncType = doc.RootElement.TryGetProperty("syncType", out var st)
? st.GetString()
: "UNKNOWN";
var lineCount = doc.RootElement.TryGetProperty("lines", out var lines)
? lines.GetArrayLength()
: 0;
WriteStatus("Spotify Lyrics Sidecar", "WORKING", ConsoleColor.Green);
WriteDetail($"✓ Type: {syncType}, Lines: {lineCount}");
return true;
}
else
{
WriteStatus("Spotify Lyrics Sidecar", $"HTTP {(int)response.StatusCode}", ConsoleColor.Red);
WriteDetail("Check if spotify-lyrics container is running");
return false;
}
}
catch (Exception ex)
{
WriteStatus("Spotify Lyrics Sidecar", "ERROR", ConsoleColor.Red);
WriteDetail($"Failed to connect: {ex.Message}");
WriteDetail("Ensure spotify-lyrics container is running in docker-compose");
return false;
}
}
private async Task<bool> TestSpotifyApiAsync(CancellationToken cancellationToken)
{
try
{
if (string.IsNullOrEmpty(_spotifySettings.ClientId))
{
WriteStatus("Spotify API", "NOT CONFIGURED", ConsoleColor.Yellow);
WriteDetail("Set SpotifyApi__ClientId to enable");
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");
return true;
}
catch (Exception ex)
{
WriteStatus("Spotify API", "ERROR", ConsoleColor.Red);
WriteDetail($"Validation failed: {ex.Message}");
return false;
}
}
}

View File

@@ -24,31 +24,25 @@ public class SpotifyLyricsService
{ {
private readonly ILogger<SpotifyLyricsService> _logger; private readonly ILogger<SpotifyLyricsService> _logger;
private readonly SpotifyApiSettings _settings; private readonly SpotifyApiSettings _settings;
private readonly SpotifyApiClient _spotifyClient;
private readonly RedisCacheService _cache; private readonly RedisCacheService _cache;
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private const string LyricsApiBase = "https://spclient.wg.spotify.com/color-lyrics/v2/track";
public SpotifyLyricsService( public SpotifyLyricsService(
ILogger<SpotifyLyricsService> logger, ILogger<SpotifyLyricsService> logger,
IOptions<SpotifyApiSettings> settings, IOptions<SpotifyApiSettings> settings,
SpotifyApiClient spotifyClient,
RedisCacheService cache, RedisCacheService cache,
IHttpClientFactory httpClientFactory) IHttpClientFactory httpClientFactory)
{ {
_logger = logger; _logger = logger;
_settings = settings.Value; _settings = settings.Value;
_spotifyClient = spotifyClient;
_cache = cache; _cache = cache;
_httpClient = httpClientFactory.CreateClient(); _httpClient = httpClientFactory.CreateClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0"); _httpClient.Timeout = TimeSpan.FromSeconds(10);
_httpClient.DefaultRequestHeaders.Add("App-Platform", "WebPlayer");
} }
/// <summary> /// <summary>
/// Gets synchronized lyrics for a Spotify track by its ID. /// Gets synchronized lyrics for a Spotify track by its ID using the sidecar API.
/// </summary> /// </summary>
/// <param name="spotifyTrackId">Spotify track ID (e.g., "3a8mo25v74BMUOJ1IDUEBL")</param> /// <param name="spotifyTrackId">Spotify track ID (e.g., "3a8mo25v74BMUOJ1IDUEBL")</param>
/// <returns>Lyrics info with synced lyrics in LRC format, or null if not available</returns> /// <returns>Lyrics info with synced lyrics in LRC format, or null if not available</returns>
@@ -60,6 +54,12 @@ public class SpotifyLyricsService
return null; return null;
} }
if (string.IsNullOrEmpty(_settings.LyricsApiUrl))
{
_logger.LogWarning("Spotify lyrics API URL not configured");
return null;
}
// Normalize track ID (remove URI prefix if present) // Normalize track ID (remove URI prefix if present)
spotifyTrackId = ExtractTrackId(spotifyTrackId); spotifyTrackId = ExtractTrackId(spotifyTrackId);
@@ -74,44 +74,27 @@ public class SpotifyLyricsService
try try
{ {
// Get access token var url = $"{_settings.LyricsApiUrl}/?trackid={spotifyTrackId}&format=id3";
var token = await _spotifyClient.GetWebAccessTokenAsync();
if (string.IsNullOrEmpty(token))
{
_logger.LogWarning("Could not get Spotify access token for lyrics");
return null;
}
// Request lyrics from Spotify's color-lyrics API _logger.LogDebug("Fetching lyrics from sidecar API: {Url}", url);
var url = $"{LyricsApiBase}/{spotifyTrackId}?format=json&vocalRemoval=false&market=from_token";
var request = new HttpRequestMessage(HttpMethod.Get, url); var response = await _httpClient.GetAsync(url);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Headers.Add("Accept", "application/json");
var response = await _httpClient.SendAsync(request);
if (response.StatusCode == HttpStatusCode.NotFound)
{
_logger.LogDebug("No lyrics found on Spotify for track {TrackId}", spotifyTrackId);
return null;
}
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
_logger.LogWarning("Spotify lyrics API returned {StatusCode} for track {TrackId}", _logger.LogDebug("Sidecar API returned {StatusCode} for track {TrackId}",
response.StatusCode, spotifyTrackId); response.StatusCode, spotifyTrackId);
return null; return null;
} }
var json = await response.Content.ReadAsStringAsync(); var json = await response.Content.ReadAsStringAsync();
var result = ParseLyricsResponse(json, spotifyTrackId); var result = ParseSidecarResponse(json, spotifyTrackId);
if (result != null) if (result != null)
{ {
// Cache for 30 days (lyrics don't change) // Cache for 30 days (lyrics don't change)
await _cache.SetAsync(cacheKey, result, TimeSpan.FromDays(30)); await _cache.SetAsync(cacheKey, result, TimeSpan.FromDays(30));
_logger.LogInformation("Cached Spotify lyrics for track {TrackId} ({LineCount} lines)", _logger.LogInformation("Got Spotify lyrics from sidecar for track {TrackId} ({LineCount} lines)",
spotifyTrackId, result.Lines.Count); spotifyTrackId, result.Lines.Count);
} }
@@ -119,14 +102,15 @@ public class SpotifyLyricsService
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error fetching Spotify lyrics for track {TrackId}", spotifyTrackId); _logger.LogWarning(ex, "Error fetching lyrics from sidecar API for track {TrackId}", spotifyTrackId);
return null; return null;
} }
} }
/// <summary> /// <summary>
/// Searches for a track on Spotify and returns its lyrics. /// Searches for a track on Spotify and returns its lyrics using the sidecar API.
/// Useful when you have track metadata but not a Spotify ID. /// Useful when you have track metadata but not a Spotify ID.
/// Note: This requires the sidecar to handle search, or we skip it.
/// </summary> /// </summary>
public async Task<SpotifyLyricsResult?> SearchAndGetLyricsAsync( public async Task<SpotifyLyricsResult?> SearchAndGetLyricsAsync(
string trackName, string trackName,
@@ -136,85 +120,15 @@ public class SpotifyLyricsService
{ {
if (!_settings.Enabled || string.IsNullOrEmpty(_settings.SessionCookie)) if (!_settings.Enabled || string.IsNullOrEmpty(_settings.SessionCookie))
{ {
_logger.LogDebug("Spotify lyrics search skipped: API not enabled or no session cookie");
return null; return null;
} }
try // The sidecar API only supports track ID, not search
{ // So we skip Spotify lyrics for search-based requests
var token = await _spotifyClient.GetWebAccessTokenAsync(); // LRCLib will be used as fallback
if (string.IsNullOrEmpty(token)) _logger.LogDebug("Spotify lyrics search by metadata not supported with sidecar API, skipping");
{ return null;
return null;
}
// Search for the track
var query = $"track:{trackName} artist:{artistName}";
if (!string.IsNullOrEmpty(albumName))
{
query += $" album:{albumName}";
}
var searchUrl = $"https://api.spotify.com/v1/search?q={Uri.EscapeDataString(query)}&type=track&limit=5";
var request = new HttpRequestMessage(HttpMethod.Get, searchUrl);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
var response = await _httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
return null;
}
var json = await response.Content.ReadAsStringAsync();
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
if (!root.TryGetProperty("tracks", out var tracks) ||
!tracks.TryGetProperty("items", out var items) ||
items.GetArrayLength() == 0)
{
return null;
}
// Find best match considering duration if provided
string? bestMatchId = null;
var bestScore = 0;
foreach (var item in items.EnumerateArray())
{
var id = item.TryGetProperty("id", out var idProp) ? idProp.GetString() : null;
if (string.IsNullOrEmpty(id)) continue;
var score = 100; // Base score
// Check duration match
if (durationMs.HasValue && item.TryGetProperty("duration_ms", out var durProp))
{
var trackDuration = durProp.GetInt32();
var durationDiff = Math.Abs(trackDuration - durationMs.Value);
if (durationDiff < 2000) score += 50; // Within 2 seconds
else if (durationDiff < 5000) score += 25; // Within 5 seconds
}
if (score > bestScore)
{
bestScore = score;
bestMatchId = id;
}
}
if (!string.IsNullOrEmpty(bestMatchId))
{
return await GetLyricsByTrackIdAsync(bestMatchId);
}
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error searching Spotify for lyrics: {Track} - {Artist}", trackName, artistName);
return null;
}
} }
/// <summary> /// <summary>
@@ -251,91 +165,51 @@ public class SpotifyLyricsService
}; };
} }
private SpotifyLyricsResult? ParseLyricsResponse(string json, string trackId) /// <summary>
/// Parses the response from the sidecar spotify-lyrics-api service.
/// Format: {"error": false, "syncType": "LINE_SYNCED", "lines": [...]}
/// </summary>
private SpotifyLyricsResult? ParseSidecarResponse(string json, string trackId)
{ {
try try
{ {
using var doc = JsonDocument.Parse(json); using var doc = JsonDocument.Parse(json);
var root = doc.RootElement; var root = doc.RootElement;
// Check for error
if (root.TryGetProperty("error", out var error) && error.GetBoolean())
{
_logger.LogDebug("Sidecar API returned error for track {TrackId}", trackId);
return null;
}
var result = new SpotifyLyricsResult var result = new SpotifyLyricsResult
{ {
SpotifyTrackId = trackId SpotifyTrackId = trackId
}; };
// Parse lyrics lines // Get sync type
if (root.TryGetProperty("lyrics", out var lyrics)) if (root.TryGetProperty("syncType", out var syncType))
{ {
// Check sync type result.SyncType = syncType.GetString() ?? "LINE_SYNCED";
if (lyrics.TryGetProperty("syncType", out var syncType)) }
// Parse lines
if (root.TryGetProperty("lines", out var lines))
{
foreach (var line in lines.EnumerateArray())
{ {
result.SyncType = syncType.GetString() ?? "LINE_SYNCED"; var lyricsLine = new SpotifyLyricsLine
}
// Parse lines
if (lyrics.TryGetProperty("lines", out var lines))
{
foreach (var line in lines.EnumerateArray())
{ {
var lyricsLine = new SpotifyLyricsLine StartTimeMs = line.TryGetProperty("startTimeMs", out var start)
{ ? long.Parse(start.GetString() ?? "0") : 0,
StartTimeMs = line.TryGetProperty("startTimeMs", out var start) Words = line.TryGetProperty("words", out var words)
? long.Parse(start.GetString() ?? "0") : 0, ? words.GetString() ?? "" : "",
Words = line.TryGetProperty("words", out var words) EndTimeMs = line.TryGetProperty("endTimeMs", out var end)
? words.GetString() ?? "" : "", ? long.Parse(end.GetString() ?? "0") : 0
EndTimeMs = line.TryGetProperty("endTimeMs", out var end)
? long.Parse(end.GetString() ?? "0") : 0
};
// Parse syllables if available (for word-level sync)
if (line.TryGetProperty("syllables", out var syllables))
{
foreach (var syllable in syllables.EnumerateArray())
{
lyricsLine.Syllables.Add(new SpotifyLyricsSyllable
{
StartTimeMs = syllable.TryGetProperty("startTimeMs", out var sStart)
? long.Parse(sStart.GetString() ?? "0") : 0,
Text = syllable.TryGetProperty("charsIndex", out var text)
? text.GetString() ?? "" : ""
});
}
}
result.Lines.Add(lyricsLine);
}
}
// Parse color information
if (lyrics.TryGetProperty("colors", out var colors))
{
result.Colors = new SpotifyLyricsColors
{
Background = colors.TryGetProperty("background", out var bg)
? ParseColorValue(bg) : null,
Text = colors.TryGetProperty("text", out var txt)
? ParseColorValue(txt) : null,
HighlightText = colors.TryGetProperty("highlightText", out var ht)
? ParseColorValue(ht) : null
}; };
}
result.Lines.Add(lyricsLine);
// Language
if (lyrics.TryGetProperty("language", out var lang))
{
result.Language = lang.GetString();
}
// Provider info
if (lyrics.TryGetProperty("provider", out var provider))
{
result.Provider = provider.GetString();
}
// Display info
if (lyrics.TryGetProperty("providerDisplayName", out var providerDisplay))
{
result.ProviderDisplayName = providerDisplay.GetString();
} }
} }
@@ -343,28 +217,11 @@ public class SpotifyLyricsService
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error parsing Spotify lyrics response"); _logger.LogError(ex, "Error parsing sidecar API response");
return null; return null;
} }
} }
private static int? ParseColorValue(JsonElement element)
{
if (element.ValueKind == JsonValueKind.Number)
{
return element.GetInt32();
}
if (element.ValueKind == JsonValueKind.String)
{
var str = element.GetString();
if (!string.IsNullOrEmpty(str) && int.TryParse(str, out var val))
{
return val;
}
}
return null;
}
private static string ExtractTrackId(string input) private static string ExtractTrackId(string input)
{ {
if (string.IsNullOrEmpty(input)) return input; if (string.IsNullOrEmpty(input)) return input;

View File

@@ -360,21 +360,25 @@ public class SquidWTFMetadataService : IMusicMetadataService
JsonElement? artistSource = null; JsonElement? artistSource = null;
int albumCount = 0; int albumCount = 0;
// Think this can maybe switch to something using ParseTidalAlbum // Try to get artist from albums.items[0].artist
if (result.RootElement.TryGetProperty("albums", out var albums) && if (result.RootElement.TryGetProperty("albums", out var albums) &&
albums.TryGetProperty("items", out var albumItems) && albums.TryGetProperty("items", out var albumItems) &&
albumItems.GetArrayLength() > 0) albumItems.GetArrayLength() > 0)
{ {
albumCount = albumItems.GetArrayLength(); albumCount = albumItems.GetArrayLength();
artistSource = albumItems[0].GetProperty("artist"); if (albumItems[0].TryGetProperty("artist", out var artistEl))
_logger.LogInformation("Found artist from albums, albumCount={AlbumCount}", albumCount); {
artistSource = artistEl;
_logger.LogInformation("Found artist from albums, albumCount={AlbumCount}", albumCount);
}
} }
// Think this can maybe switch to something using ParseTidalTrack // Fallback: try to get artist from tracks[0].artists[0]
else if (result.RootElement.TryGetProperty("tracks", out var tracks) && if (artistSource == null &&
tracks.GetArrayLength() > 0 && result.RootElement.TryGetProperty("tracks", out var tracks) &&
tracks[0].TryGetProperty("artists", out var artists) && tracks.GetArrayLength() > 0 &&
artists.GetArrayLength() > 0) tracks[0].TryGetProperty("artists", out var artists) &&
artists.GetArrayLength() > 0)
{ {
artistSource = artists[0]; artistSource = artists[0];
_logger.LogInformation("Found artist from tracks"); _logger.LogInformation("Found artist from tracks");
@@ -382,7 +386,8 @@ public class SquidWTFMetadataService : IMusicMetadataService
if (artistSource == null) if (artistSource == null)
{ {
_logger.LogWarning("Could not find artist data in response"); _logger.LogDebug("Could not find artist data in response. Response keys: {Keys}",
string.Join(", ", result.RootElement.EnumerateObject().Select(p => p.Name)));
return null; return null;
} }

View File

@@ -107,8 +107,8 @@ public class SquidWTFStartupValidator : BaseStartupValidator
{ {
try try
{ {
// Test search with a simple query // Test search with "22" by Taylor Swift
var searchUrl = $"{baseUrl}/search/?s=Taylor%20Swift"; var searchUrl = $"{baseUrl}/search/?s=22%20Taylor%20Swift";
var searchResponse = await _httpClient.GetAsync(searchUrl, cancellationToken); var searchResponse = await _httpClient.GetAsync(searchUrl, cancellationToken);
if (searchResponse.IsSuccessStatusCode) if (searchResponse.IsSuccessStatusCode)
@@ -121,7 +121,36 @@ public class SquidWTFStartupValidator : BaseStartupValidator
{ {
var itemCount = items.GetArrayLength(); var itemCount = items.GetArrayLength();
WriteStatus("Search Functionality", "WORKING", ConsoleColor.Green); WriteStatus("Search Functionality", "WORKING", ConsoleColor.Green);
WriteDetail($"Test search returned {itemCount} results"); WriteDetail($"Test search for '22' by Taylor Swift returned {itemCount} results");
// Check if we found the actual song
bool foundTaylorSwift22 = false;
foreach (var item in items.EnumerateArray())
{
if (item.TryGetProperty("title", out var title) &&
item.TryGetProperty("artists", out var artists) &&
artists.GetArrayLength() > 0)
{
var titleStr = title.GetString() ?? "";
var artistName = artists[0].TryGetProperty("name", out var name)
? name.GetString() ?? ""
: "";
if (titleStr.Contains("22", StringComparison.OrdinalIgnoreCase) &&
artistName.Contains("Taylor Swift", StringComparison.OrdinalIgnoreCase))
{
foundTaylorSwift22 = true;
var trackId = item.TryGetProperty("id", out var id) ? id.GetInt64() : 0;
WriteDetail($"✓ Found: '{titleStr}' by {artistName} (ID: {trackId})");
break;
}
}
}
if (!foundTaylorSwift22)
{
WriteDetail("⚠ Could not find exact match for '22' by Taylor Swift in results");
}
} }
else else
{ {

View File

@@ -17,6 +17,23 @@ services:
networks: networks:
- allstarr-network - allstarr-network
spotify-lyrics:
image: akashrchandran/spotify-lyrics-api:latest
container_name: allstarr-spotify-lyrics
restart: unless-stopped
# Only accessible internally - no external port exposure
expose:
- "8080"
environment:
- SP_DC=${SPOTIFY_API_SESSION_COOKIE:-}
networks:
- allstarr-network
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://localhost:8080/"]
interval: 30s
timeout: 5s
retries: 3
allstarr: allstarr:
# Use pre-built image from GitHub Container Registry # Use pre-built image from GitHub Container Registry
# For latest stable: ghcr.io/sopat712/allstarr:latest # For latest stable: ghcr.io/sopat712/allstarr:latest
@@ -40,6 +57,8 @@ services:
depends_on: depends_on:
redis: redis:
condition: service_healthy condition: service_healthy
spotify-lyrics:
condition: service_started
healthcheck: healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"] test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s interval: 30s
@@ -98,6 +117,8 @@ services:
- SpotifyApi__CacheDurationMinutes=${SPOTIFY_API_CACHE_DURATION_MINUTES:-60} - SpotifyApi__CacheDurationMinutes=${SPOTIFY_API_CACHE_DURATION_MINUTES:-60}
- SpotifyApi__RateLimitDelayMs=${SPOTIFY_API_RATE_LIMIT_DELAY_MS:-100} - SpotifyApi__RateLimitDelayMs=${SPOTIFY_API_RATE_LIMIT_DELAY_MS:-100}
- SpotifyApi__PreferIsrcMatching=${SPOTIFY_API_PREFER_ISRC_MATCHING:-true} - SpotifyApi__PreferIsrcMatching=${SPOTIFY_API_PREFER_ISRC_MATCHING:-true}
# Spotify Lyrics API sidecar service URL (internal)
- SpotifyApi__LyricsApiUrl=${SPOTIFY_LYRICS_API_URL:-http://spotify-lyrics:8080}
# ===== SHARED ===== # ===== SHARED =====
- Library__DownloadPath=/app/downloads - Library__DownloadPath=/app/downloads