mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-11 00:18:38 -05:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
87467be61b
|
|||
|
713ecd4ec8
|
|||
|
0ff1e3a428
|
|||
|
cef18b9482
|
|||
|
1bfe30b216
|
@@ -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;
|
||||
|
||||
if (_lyricsOrchestrator != null)
|
||||
{
|
||||
lyrics = await _lyricsOrchestrator.GetLyricsAsync(
|
||||
trackName: searchTitle,
|
||||
artistNames: searchArtists.ToArray(),
|
||||
albumName: searchAlbum,
|
||||
durationSeconds: song.Duration ?? 0,
|
||||
spotifyTrackId: spotifyTrackId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 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
|
||||
// Spotify lyrics only work for tracks from injected playlists that have been matched
|
||||
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled && !string.IsNullOrEmpty(spotifyTrackId))
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to LRCLIB if no Spotify lyrics
|
||||
if (lyrics == null)
|
||||
// Fall back to LyricsPlus
|
||||
if (lyrics == null && _lyricsPlusService != null)
|
||||
{
|
||||
_logger.LogInformation("Searching LRCLIB for lyrics: {Artists} - {Title}",
|
||||
string.Join(", ", searchArtists),
|
||||
searchTitle);
|
||||
var lrclibService = HttpContext.RequestServices.GetService<LrclibService>();
|
||||
if (lrclibService != 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(
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -575,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>());
|
||||
|
||||
@@ -211,9 +211,20 @@ 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;
|
||||
}
|
||||
|
||||
228
allstarr/Services/Lyrics/LyricsOrchestrator.cs
Normal file
228
allstarr/Services/Lyrics/LyricsOrchestrator.cs
Normal 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
|
||||
}
|
||||
254
allstarr/Services/Lyrics/LyricsPlusService.cs
Normal file
254
allstarr/Services/Lyrics/LyricsPlusService.cs
Normal 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.0.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;
|
||||
}
|
||||
}
|
||||
@@ -185,9 +185,20 @@ public class QobuzMetadataService : IMusicMetadataService
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -86,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);
|
||||
@@ -132,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>();
|
||||
@@ -169,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)
|
||||
{
|
||||
@@ -184,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>();
|
||||
@@ -291,9 +291,20 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
|
||||
// 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
|
||||
|
||||
Reference in New Issue
Block a user