mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Add Spotify direct API integration for lyrics, ISRC matching, and playlist ordering
Features: - SpotifyApiClient: Direct Spotify API client using sp_dc session cookie - SpotifyLyricsService: Fetch synced lyrics from Spotify's color-lyrics API - SpotifyPlaylistFetcher: Get playlists with correct track ordering and ISRC codes - SpotifyTrackMatchingService: ISRC-based exact track matching for external providers Improvements: - Lyrics endpoint now prioritizes: 1) Jellyfin embedded, 2) Spotify synced, 3) LRCLIB - Fixed playback progress reporting - removed incorrect body wrapping for Jellyfin API - Added SpotifyApiSettings configuration model Security: - Session cookie and client ID properly masked in startup logs - All credentials read from environment variables only
This commit is contained in:
37
.env.example
37
.env.example
@@ -149,3 +149,40 @@ SPOTIFY_IMPORT_PLAYLIST_LOCAL_TRACKS_POSITIONS=
|
||||
# Format: [["PlaylistName","JellyfinPlaylistId","first|last"],...]
|
||||
# Note: This format may not work in .env files due to Docker Compose limitations
|
||||
# SPOTIFY_IMPORT_PLAYLISTS=[["Discover Weekly","4383a46d8bcac3be2ef9385053ea18df","first"],["Release Radar","ba50e26c867ec9d57ab2f7bf24cfd6b0","last"]]
|
||||
|
||||
# ===== SPOTIFY DIRECT API (RECOMMENDED - ENABLES TRACK ORDERING & LYRICS) =====
|
||||
# This is the preferred method for Spotify playlist integration.
|
||||
# Provides: Correct track ordering, ISRC-based exact matching, synchronized lyrics
|
||||
# Does NOT require the Jellyfin Spotify Import plugin (can work standalone)
|
||||
|
||||
# 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.
|
||||
#
|
||||
# To get your sp_dc cookie:
|
||||
# 1. Open https://open.spotify.com in your browser and log in
|
||||
# 2. Open DevTools (F12) → Application → Cookies → https://open.spotify.com
|
||||
# 3. Find the cookie named "sp_dc" and copy its value
|
||||
# 4. Note: This cookie expires periodically (typically every few months)
|
||||
SPOTIFY_API_SESSION_COOKIE=
|
||||
|
||||
# Cache duration for playlist data in minutes (default: 60)
|
||||
# Release Radar updates weekly, Discover Weekly updates Mondays
|
||||
SPOTIFY_API_CACHE_DURATION_MINUTES=60
|
||||
|
||||
# Rate limit delay between API requests in milliseconds (default: 100)
|
||||
SPOTIFY_API_RATE_LIMIT_DELAY_MS=100
|
||||
|
||||
# Prefer ISRC matching over fuzzy title/artist matching (default: true)
|
||||
# ISRC provides exact track identification across different streaming services
|
||||
SPOTIFY_API_PREFER_ISRC_MATCHING=true
|
||||
|
||||
@@ -2,14 +2,17 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Lyrics;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Subsonic;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Services;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services.Local;
|
||||
using allstarr.Services.Jellyfin;
|
||||
using allstarr.Services.Subsonic;
|
||||
using allstarr.Services.Lyrics;
|
||||
using allstarr.Services.Spotify;
|
||||
using allstarr.Filters;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
@@ -24,6 +27,7 @@ public class JellyfinController : ControllerBase
|
||||
{
|
||||
private readonly JellyfinSettings _settings;
|
||||
private readonly SpotifyImportSettings _spotifySettings;
|
||||
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||
private readonly IMusicMetadataService _metadataService;
|
||||
private readonly ILocalLibraryService _localLibraryService;
|
||||
private readonly IDownloadService _downloadService;
|
||||
@@ -32,12 +36,15 @@ public class JellyfinController : ControllerBase
|
||||
private readonly JellyfinProxyService _proxyService;
|
||||
private readonly JellyfinSessionManager _sessionManager;
|
||||
private readonly PlaylistSyncService? _playlistSyncService;
|
||||
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
|
||||
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly ILogger<JellyfinController> _logger;
|
||||
|
||||
public JellyfinController(
|
||||
IOptions<JellyfinSettings> settings,
|
||||
IOptions<SpotifyImportSettings> spotifySettings,
|
||||
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
||||
IMusicMetadataService metadataService,
|
||||
ILocalLibraryService localLibraryService,
|
||||
IDownloadService downloadService,
|
||||
@@ -47,10 +54,13 @@ public class JellyfinController : ControllerBase
|
||||
JellyfinSessionManager sessionManager,
|
||||
RedisCacheService cache,
|
||||
ILogger<JellyfinController> logger,
|
||||
PlaylistSyncService? playlistSyncService = null)
|
||||
PlaylistSyncService? playlistSyncService = null,
|
||||
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
|
||||
SpotifyLyricsService? spotifyLyricsService = null)
|
||||
{
|
||||
_settings = settings.Value;
|
||||
_spotifySettings = spotifySettings.Value;
|
||||
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||
_metadataService = metadataService;
|
||||
_localLibraryService = localLibraryService;
|
||||
_downloadService = downloadService;
|
||||
@@ -59,6 +69,8 @@ public class JellyfinController : ControllerBase
|
||||
_proxyService = proxyService;
|
||||
_sessionManager = sessionManager;
|
||||
_playlistSyncService = playlistSyncService;
|
||||
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
|
||||
_spotifyLyricsService = spotifyLyricsService;
|
||||
_cache = cache;
|
||||
_logger = logger;
|
||||
|
||||
@@ -988,6 +1000,7 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
/// <summary>
|
||||
/// Gets lyrics for an item.
|
||||
/// Priority: 1. Jellyfin embedded lyrics, 2. Spotify synced lyrics, 3. LRCLIB
|
||||
/// </summary>
|
||||
[HttpGet("Audio/{itemId}/Lyrics")]
|
||||
[HttpGet("Items/{itemId}/Lyrics")]
|
||||
@@ -1010,19 +1023,21 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
if (jellyfinLyrics != null && statusCode == 200)
|
||||
{
|
||||
_logger.LogInformation("✓ Found embedded lyrics in Jellyfin for track {ItemId}", itemId);
|
||||
_logger.LogInformation("Found embedded lyrics in Jellyfin for track {ItemId}", itemId);
|
||||
return new JsonResult(JsonSerializer.Deserialize<object>(jellyfinLyrics.RootElement.GetRawText()));
|
||||
}
|
||||
|
||||
_logger.LogInformation("No embedded lyrics found in Jellyfin, falling back to LRCLIB search");
|
||||
_logger.LogInformation("No embedded lyrics found in Jellyfin, trying Spotify/LRCLIB");
|
||||
}
|
||||
|
||||
// For external tracks or when Jellyfin doesn't have lyrics, search LRCLIB
|
||||
// Get song metadata for lyrics search
|
||||
Song? song = null;
|
||||
string? spotifyTrackId = null;
|
||||
|
||||
if (isExternal)
|
||||
{
|
||||
song = await _metadataService.GetSongAsync(provider!, externalId!);
|
||||
// For Deezer tracks, we'll search Spotify by metadata
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -1038,6 +1053,15 @@ public class JellyfinController : ControllerBase
|
||||
Album = item.RootElement.TryGetProperty("Album", out var album) ? album.GetString() ?? "" : "",
|
||||
Duration = item.RootElement.TryGetProperty("RunTimeTicks", out var ticks) ? (int)(ticks.GetInt64() / 10000000) : 0
|
||||
};
|
||||
|
||||
// Check for Spotify ID in provider IDs
|
||||
if (item.RootElement.TryGetProperty("ProviderIds", out var providerIds))
|
||||
{
|
||||
if (providerIds.TryGetProperty("Spotify", out var spotifyId))
|
||||
{
|
||||
spotifyTrackId = spotifyId.GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1046,21 +1070,54 @@ public class JellyfinController : ControllerBase
|
||||
return NotFound(new { error = "Song not found" });
|
||||
}
|
||||
|
||||
// Try to get lyrics from LRCLIB
|
||||
_logger.LogInformation("Searching LRCLIB for lyrics: {Artists} - {Title}",
|
||||
song.Artists.Count > 0 ? string.Join(", ", song.Artists) : song.Artist,
|
||||
song.Title);
|
||||
var lyricsService = HttpContext.RequestServices.GetService<LrclibService>();
|
||||
if (lyricsService == null)
|
||||
LyricsInfo? lyrics = null;
|
||||
|
||||
// Try Spotify lyrics first (better synced lyrics quality)
|
||||
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled)
|
||||
{
|
||||
return NotFound(new { error = "Lyrics service not available" });
|
||||
_logger.LogInformation("Trying Spotify lyrics for: {Artist} - {Title}", song.Artist, song.Title);
|
||||
|
||||
SpotifyLyricsResult? spotifyLyrics = null;
|
||||
|
||||
// If we have a Spotify track ID, use it directly
|
||||
if (!string.IsNullOrEmpty(spotifyTrackId))
|
||||
{
|
||||
spotifyLyrics = await _spotifyLyricsService.GetLyricsByTrackIdAsync(spotifyTrackId);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Search by metadata
|
||||
spotifyLyrics = await _spotifyLyricsService.SearchAndGetLyricsAsync(
|
||||
song.Title,
|
||||
song.Artists.Count > 0 ? song.Artists[0] : song.Artist ?? "",
|
||||
song.Album,
|
||||
song.Duration.HasValue ? song.Duration.Value * 1000 : null);
|
||||
}
|
||||
|
||||
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("Found Spotify lyrics for {Artist} - {Title} ({LineCount} lines, type: {SyncType})",
|
||||
song.Artist, song.Title, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType);
|
||||
lyrics = _spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to LRCLIB if no Spotify lyrics
|
||||
if (lyrics == null)
|
||||
{
|
||||
_logger.LogInformation("Searching LRCLIB for lyrics: {Artists} - {Title}",
|
||||
song.Artists.Count > 0 ? string.Join(", ", song.Artists) : song.Artist,
|
||||
song.Title);
|
||||
var lrclibService = HttpContext.RequestServices.GetService<LrclibService>();
|
||||
if (lrclibService != null)
|
||||
{
|
||||
lyrics = await lrclibService.GetLyricsAsync(
|
||||
song.Title,
|
||||
song.Artists.Count > 0 ? song.Artists.ToArray() : new[] { song.Artist ?? "" },
|
||||
song.Album ?? "",
|
||||
song.Duration ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
var lyrics = await lyricsService.GetLyricsAsync(
|
||||
song.Title,
|
||||
song.Artists.Count > 0 ? song.Artists.ToArray() : new[] { song.Artist ?? "" },
|
||||
song.Album ?? "",
|
||||
song.Duration ?? 0);
|
||||
|
||||
if (lyrics == null)
|
||||
{
|
||||
@@ -2725,165 +2782,24 @@ public class JellyfinController : ControllerBase
|
||||
/// <summary>
|
||||
/// Gets tracks for a Spotify playlist by matching missing tracks against external providers
|
||||
/// and merging with existing local tracks from Jellyfin.
|
||||
///
|
||||
/// Supports two modes:
|
||||
/// 1. Direct Spotify API (new): Uses SpotifyPlaylistFetcher for ordered tracks with ISRC matching
|
||||
/// 2. Jellyfin Plugin (legacy): Uses MissingTrack data from Jellyfin Spotify Import plugin
|
||||
/// </summary>
|
||||
private async Task<IActionResult> GetSpotifyPlaylistTracksAsync(string spotifyPlaylistName, string playlistId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cacheKey = $"spotify:matched:{spotifyPlaylistName}";
|
||||
var cachedTracks = await _cache.GetAsync<List<Song>>(cacheKey);
|
||||
|
||||
if (cachedTracks != null)
|
||||
// Try ordered cache first (from direct Spotify API mode)
|
||||
if (_spotifyApiSettings.Enabled && _spotifyPlaylistFetcher != null)
|
||||
{
|
||||
_logger.LogDebug("Returning {Count} cached matched tracks for {Playlist}",
|
||||
cachedTracks.Count, spotifyPlaylistName);
|
||||
return _responseBuilder.CreateItemsResponse(cachedTracks);
|
||||
}
|
||||
|
||||
// Get existing Jellyfin playlist items (tracks the plugin already found)
|
||||
var (existingTracksResponse, _) = await _proxyService.GetJsonAsync(
|
||||
$"Playlists/{playlistId}/Items",
|
||||
null,
|
||||
Request.Headers);
|
||||
|
||||
var existingTracks = new List<Song>();
|
||||
var existingSpotifyIds = new HashSet<string>();
|
||||
|
||||
if (existingTracksResponse != null &&
|
||||
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var song = _modelMapper.ParseSong(item);
|
||||
existingTracks.Add(song);
|
||||
|
||||
// Track Spotify IDs to avoid duplicates
|
||||
if (item.TryGetProperty("ProviderIds", out var providerIds) &&
|
||||
providerIds.TryGetProperty("Spotify", out var spotifyId))
|
||||
{
|
||||
existingSpotifyIds.Add(spotifyId.GetString() ?? "");
|
||||
}
|
||||
}
|
||||
_logger.LogInformation("Found {Count} existing tracks in Jellyfin playlist", existingTracks.Count);
|
||||
}
|
||||
|
||||
var missingTracksKey = $"spotify:missing:{spotifyPlaylistName}";
|
||||
var missingTracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(missingTracksKey);
|
||||
|
||||
// Fallback to file cache if Redis is empty
|
||||
if (missingTracks == null || missingTracks.Count == 0)
|
||||
{
|
||||
missingTracks = await LoadMissingTracksFromFile(spotifyPlaylistName);
|
||||
|
||||
// If we loaded from file, restore to Redis with no expiration
|
||||
if (missingTracks != null && missingTracks.Count > 0)
|
||||
{
|
||||
await _cache.SetAsync(missingTracksKey, missingTracks, TimeSpan.FromDays(365));
|
||||
_logger.LogInformation("Restored {Count} missing tracks from file cache for {Playlist} (no expiration)",
|
||||
missingTracks.Count, spotifyPlaylistName);
|
||||
}
|
||||
var orderedResult = await GetSpotifyPlaylistTracksOrderedAsync(spotifyPlaylistName, playlistId);
|
||||
if (orderedResult != null) return orderedResult;
|
||||
}
|
||||
|
||||
if (missingTracks == null || missingTracks.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No missing tracks found for {Playlist}, returning {Count} existing tracks",
|
||||
spotifyPlaylistName, existingTracks.Count);
|
||||
return _responseBuilder.CreateItemsResponse(existingTracks);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Matching {Count} missing tracks for {Playlist}",
|
||||
missingTracks.Count, spotifyPlaylistName);
|
||||
|
||||
// Match missing tracks sequentially with rate limiting (excluding ones we already have locally)
|
||||
var matchedBySpotifyId = new Dictionary<string, Song>();
|
||||
var tracksToMatch = missingTracks
|
||||
.Where(track => !existingSpotifyIds.Contains(track.SpotifyId))
|
||||
.ToList();
|
||||
|
||||
foreach (var track in tracksToMatch)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Search with just title and artist for better matching
|
||||
var query = $"{track.Title} {track.PrimaryArtist}";
|
||||
var results = await _metadataService.SearchSongsAsync(query, limit: 5);
|
||||
|
||||
if (results.Count > 0)
|
||||
{
|
||||
// Fuzzy match to find best result
|
||||
// Check that ALL artists match (not just some)
|
||||
var bestMatch = results
|
||||
.Select(song => new
|
||||
{
|
||||
Song = song,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
|
||||
// Calculate artist score by checking ALL artists match
|
||||
ArtistScore = CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
x.Song,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
TotalScore = (x.TitleScore * 0.6) + (x.ArtistScore * 0.4) // Weight title more
|
||||
})
|
||||
.OrderByDescending(x => x.TotalScore)
|
||||
.FirstOrDefault();
|
||||
|
||||
// Only add if match is good enough (>60% combined score)
|
||||
if (bestMatch != null && bestMatch.TotalScore >= 60)
|
||||
{
|
||||
_logger.LogDebug("Matched '{Title}' by {Artist} -> '{MatchTitle}' by {MatchArtist} (score: {Score:F1})",
|
||||
track.Title, track.PrimaryArtist,
|
||||
bestMatch.Song.Title, bestMatch.Song.Artist,
|
||||
bestMatch.TotalScore);
|
||||
matchedBySpotifyId[track.SpotifyId] = bestMatch.Song;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("No good match for '{Title}' by {Artist} (best score: {Score:F1})",
|
||||
track.Title, track.PrimaryArtist, bestMatch?.TotalScore ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limiting: small delay between searches to avoid overwhelming the service
|
||||
await Task.Delay(100); // 100ms delay = max 10 searches/second
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
||||
track.Title, track.PrimaryArtist);
|
||||
}
|
||||
}
|
||||
|
||||
// Build final track list based on playlist configuration
|
||||
// Local tracks position is configurable per-playlist
|
||||
var playlistConfig = _spotifySettings.GetPlaylistById(playlistId);
|
||||
var localTracksPosition = playlistConfig?.LocalTracksPosition ?? LocalTracksPosition.First;
|
||||
|
||||
var finalTracks = new List<Song>();
|
||||
if (localTracksPosition == LocalTracksPosition.First)
|
||||
{
|
||||
// Local tracks first, external tracks at the end
|
||||
finalTracks.AddRange(existingTracks);
|
||||
finalTracks.AddRange(matchedBySpotifyId.Values);
|
||||
}
|
||||
else
|
||||
{
|
||||
// External tracks first, local tracks at the end
|
||||
finalTracks.AddRange(matchedBySpotifyId.Values);
|
||||
finalTracks.AddRange(existingTracks);
|
||||
}
|
||||
|
||||
await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1));
|
||||
|
||||
_logger.LogInformation("Final playlist: {Total} tracks ({Existing} local + {Matched} external, LocalTracksPosition: {Position})",
|
||||
finalTracks.Count,
|
||||
existingTracks.Count,
|
||||
matchedBySpotifyId.Count,
|
||||
localTracksPosition);
|
||||
|
||||
return _responseBuilder.CreateItemsResponse(finalTracks);
|
||||
// Fall back to legacy unordered mode
|
||||
return await GetSpotifyPlaylistTracksLegacyAsync(spotifyPlaylistName, playlistId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -2891,6 +2807,286 @@ public class JellyfinController : ControllerBase
|
||||
return _responseBuilder.CreateError(500, "Failed to get Spotify playlist tracks");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// New mode: Gets playlist tracks with correct ordering using direct Spotify API data.
|
||||
/// </summary>
|
||||
private async Task<IActionResult?> GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName, string playlistId)
|
||||
{
|
||||
// Check for ordered matched tracks from SpotifyTrackMatchingService
|
||||
var orderedCacheKey = $"spotify:matched:ordered:{spotifyPlaylistName}";
|
||||
var orderedTracks = await _cache.GetAsync<List<MatchedTrack>>(orderedCacheKey);
|
||||
|
||||
if (orderedTracks == null || orderedTracks.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("No ordered matched tracks in cache for {Playlist}, checking if we can fetch",
|
||||
spotifyPlaylistName);
|
||||
return null; // Fall back to legacy mode
|
||||
}
|
||||
|
||||
_logger.LogDebug("Using {Count} ordered matched tracks for {Playlist}",
|
||||
orderedTracks.Count, spotifyPlaylistName);
|
||||
|
||||
// Get existing Jellyfin playlist items (tracks the Spotify Import plugin already found)
|
||||
var (existingTracksResponse, _) = await _proxyService.GetJsonAsync(
|
||||
$"Playlists/{playlistId}/Items",
|
||||
null,
|
||||
Request.Headers);
|
||||
|
||||
var existingTracks = new List<Song>();
|
||||
var existingSpotifyIds = new HashSet<string>();
|
||||
var existingPositions = new Dictionary<string, int>(); // SpotifyId -> position from Jellyfin
|
||||
|
||||
if (existingTracksResponse != null &&
|
||||
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
var position = 0;
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var song = _modelMapper.ParseSong(item);
|
||||
existingTracks.Add(song);
|
||||
|
||||
// Track Spotify IDs and their positions
|
||||
if (item.TryGetProperty("ProviderIds", out var providerIds) &&
|
||||
providerIds.TryGetProperty("Spotify", out var spotifyId))
|
||||
{
|
||||
var id = spotifyId.GetString() ?? "";
|
||||
existingSpotifyIds.Add(id);
|
||||
existingPositions[id] = position;
|
||||
}
|
||||
position++;
|
||||
}
|
||||
_logger.LogInformation("Found {Count} existing local tracks in Jellyfin playlist", existingTracks.Count);
|
||||
}
|
||||
|
||||
// Get the full playlist from Spotify to know the correct order
|
||||
var spotifyTracks = await _spotifyPlaylistFetcher!.GetPlaylistTracksAsync(spotifyPlaylistName);
|
||||
if (spotifyTracks.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("Could not get Spotify playlist tracks for {Playlist}", spotifyPlaylistName);
|
||||
return null; // Fall back to legacy
|
||||
}
|
||||
|
||||
// Build the final track list in correct Spotify order
|
||||
var finalTracks = new List<Song>();
|
||||
var localUsed = new HashSet<int>(); // Track which local tracks we've placed
|
||||
|
||||
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
|
||||
{
|
||||
// Check if this track exists locally
|
||||
if (existingSpotifyIds.Contains(spotifyTrack.SpotifyId))
|
||||
{
|
||||
// Use the local version
|
||||
if (existingPositions.TryGetValue(spotifyTrack.SpotifyId, out var localIndex) &&
|
||||
localIndex < existingTracks.Count)
|
||||
{
|
||||
finalTracks.Add(existingTracks[localIndex]);
|
||||
localUsed.Add(localIndex);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have a matched external track
|
||||
var matched = orderedTracks.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
|
||||
if (matched != null)
|
||||
{
|
||||
finalTracks.Add(matched.MatchedSong);
|
||||
}
|
||||
// If no match, the track is simply omitted (not available from any source)
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
var cacheKey = $"spotify:matched:{spotifyPlaylistName}";
|
||||
await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1));
|
||||
await SaveMatchedTracksToFile(spotifyPlaylistName, finalTracks);
|
||||
|
||||
_logger.LogInformation(
|
||||
"Final ordered playlist: {Total} tracks ({Local} local + {External} external) for {Playlist}",
|
||||
finalTracks.Count,
|
||||
localUsed.Count,
|
||||
finalTracks.Count - localUsed.Count,
|
||||
spotifyPlaylistName);
|
||||
|
||||
return _responseBuilder.CreateItemsResponse(finalTracks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legacy mode: Gets playlist tracks without ordering (from Jellyfin Spotify Import plugin).
|
||||
/// </summary>
|
||||
private async Task<IActionResult> GetSpotifyPlaylistTracksLegacyAsync(string spotifyPlaylistName, string playlistId)
|
||||
{
|
||||
var cacheKey = $"spotify:matched:{spotifyPlaylistName}";
|
||||
var cachedTracks = await _cache.GetAsync<List<Song>>(cacheKey);
|
||||
|
||||
if (cachedTracks != null && cachedTracks.Count > 0)
|
||||
{
|
||||
_logger.LogDebug("Returning {Count} cached matched tracks from Redis for {Playlist}",
|
||||
cachedTracks.Count, spotifyPlaylistName);
|
||||
return _responseBuilder.CreateItemsResponse(cachedTracks);
|
||||
}
|
||||
|
||||
// Try file cache if Redis is empty
|
||||
if (cachedTracks == null || cachedTracks.Count == 0)
|
||||
{
|
||||
cachedTracks = await LoadMatchedTracksFromFile(spotifyPlaylistName);
|
||||
if (cachedTracks != null && cachedTracks.Count > 0)
|
||||
{
|
||||
// Restore to Redis with 1 hour TTL
|
||||
await _cache.SetAsync(cacheKey, cachedTracks, TimeSpan.FromHours(1));
|
||||
_logger.LogInformation("Loaded {Count} matched tracks from file cache for {Playlist}",
|
||||
cachedTracks.Count, spotifyPlaylistName);
|
||||
return _responseBuilder.CreateItemsResponse(cachedTracks);
|
||||
}
|
||||
}
|
||||
|
||||
// Get existing Jellyfin playlist items (tracks the plugin already found)
|
||||
var (existingTracksResponse, _) = await _proxyService.GetJsonAsync(
|
||||
$"Playlists/{playlistId}/Items",
|
||||
null,
|
||||
Request.Headers);
|
||||
|
||||
var existingTracks = new List<Song>();
|
||||
var existingSpotifyIds = new HashSet<string>();
|
||||
|
||||
if (existingTracksResponse != null &&
|
||||
existingTracksResponse.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var song = _modelMapper.ParseSong(item);
|
||||
existingTracks.Add(song);
|
||||
|
||||
// Track Spotify IDs to avoid duplicates
|
||||
if (item.TryGetProperty("ProviderIds", out var providerIds) &&
|
||||
providerIds.TryGetProperty("Spotify", out var spotifyId))
|
||||
{
|
||||
existingSpotifyIds.Add(spotifyId.GetString() ?? "");
|
||||
}
|
||||
}
|
||||
_logger.LogInformation("Found {Count} existing tracks in Jellyfin playlist", existingTracks.Count);
|
||||
}
|
||||
|
||||
var missingTracksKey = $"spotify:missing:{spotifyPlaylistName}";
|
||||
var missingTracks = await _cache.GetAsync<List<MissingTrack>>(missingTracksKey);
|
||||
|
||||
// Fallback to file cache if Redis is empty
|
||||
if (missingTracks == null || missingTracks.Count == 0)
|
||||
{
|
||||
missingTracks = await LoadMissingTracksFromFile(spotifyPlaylistName);
|
||||
|
||||
// If we loaded from file, restore to Redis with no expiration
|
||||
if (missingTracks != null && missingTracks.Count > 0)
|
||||
{
|
||||
await _cache.SetAsync(missingTracksKey, missingTracks, TimeSpan.FromDays(365));
|
||||
_logger.LogInformation("Restored {Count} missing tracks from file cache for {Playlist} (no expiration)",
|
||||
missingTracks.Count, spotifyPlaylistName);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingTracks == null || missingTracks.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No missing tracks found for {Playlist}, returning {Count} existing tracks",
|
||||
spotifyPlaylistName, existingTracks.Count);
|
||||
return _responseBuilder.CreateItemsResponse(existingTracks);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Matching {Count} missing tracks for {Playlist}",
|
||||
missingTracks.Count, spotifyPlaylistName);
|
||||
|
||||
// Match missing tracks sequentially with rate limiting (excluding ones we already have locally)
|
||||
var matchedBySpotifyId = new Dictionary<string, Song>();
|
||||
var tracksToMatch = missingTracks
|
||||
.Where(track => !existingSpotifyIds.Contains(track.SpotifyId))
|
||||
.ToList();
|
||||
|
||||
foreach (var track in tracksToMatch)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Search with just title and artist for better matching
|
||||
var query = $"{track.Title} {track.PrimaryArtist}";
|
||||
var results = await _metadataService.SearchSongsAsync(query, limit: 5);
|
||||
|
||||
if (results.Count > 0)
|
||||
{
|
||||
// Fuzzy match to find best result
|
||||
// Check that ALL artists match (not just some)
|
||||
var bestMatch = results
|
||||
.Select(song => new
|
||||
{
|
||||
Song = song,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
|
||||
// Calculate artist score by checking ALL artists match
|
||||
ArtistScore = CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
x.Song,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
TotalScore = (x.TitleScore * 0.6) + (x.ArtistScore * 0.4) // Weight title more
|
||||
})
|
||||
.OrderByDescending(x => x.TotalScore)
|
||||
.FirstOrDefault();
|
||||
|
||||
// Only add if match is good enough (>60% combined score)
|
||||
if (bestMatch != null && bestMatch.TotalScore >= 60)
|
||||
{
|
||||
_logger.LogDebug("Matched '{Title}' by {Artist} -> '{MatchTitle}' by {MatchArtist} (score: {Score:F1})",
|
||||
track.Title, track.PrimaryArtist,
|
||||
bestMatch.Song.Title, bestMatch.Song.Artist,
|
||||
bestMatch.TotalScore);
|
||||
matchedBySpotifyId[track.SpotifyId] = bestMatch.Song;
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("No good match for '{Title}' by {Artist} (best score: {Score:F1})",
|
||||
track.Title, track.PrimaryArtist, bestMatch?.TotalScore ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
// Rate limiting: small delay between searches to avoid overwhelming the service
|
||||
await Task.Delay(100); // 100ms delay = max 10 searches/second
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
||||
track.Title, track.PrimaryArtist);
|
||||
}
|
||||
}
|
||||
|
||||
// Build final track list based on playlist configuration
|
||||
// Local tracks position is configurable per-playlist
|
||||
var playlistConfig = _spotifySettings.GetPlaylistById(playlistId);
|
||||
var localTracksPosition = playlistConfig?.LocalTracksPosition ?? LocalTracksPosition.First;
|
||||
|
||||
var finalTracks = new List<Song>();
|
||||
if (localTracksPosition == LocalTracksPosition.First)
|
||||
{
|
||||
// Local tracks first, external tracks at the end
|
||||
finalTracks.AddRange(existingTracks);
|
||||
finalTracks.AddRange(matchedBySpotifyId.Values);
|
||||
}
|
||||
else
|
||||
{
|
||||
// External tracks first, local tracks at the end
|
||||
finalTracks.AddRange(matchedBySpotifyId.Values);
|
||||
finalTracks.AddRange(existingTracks);
|
||||
}
|
||||
|
||||
await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1));
|
||||
|
||||
// Also save to file cache for persistence across restarts
|
||||
await SaveMatchedTracksToFile(spotifyPlaylistName, finalTracks);
|
||||
|
||||
_logger.LogInformation("Final playlist: {Total} tracks ({Existing} local + {Matched} external, LocalTracksPosition: {Position})",
|
||||
finalTracks.Count,
|
||||
existingTracks.Count,
|
||||
matchedBySpotifyId.Count,
|
||||
localTracksPosition);
|
||||
|
||||
return _responseBuilder.CreateItemsResponse(finalTracks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies an external track to the kept folder when favorited.
|
||||
@@ -2994,6 +3190,74 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads matched/combined tracks from file cache as fallback when Redis is empty.
|
||||
/// </summary>
|
||||
private async Task<List<Song>?> LoadMatchedTracksFromFile(string playlistName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_matched.json");
|
||||
|
||||
if (!System.IO.File.Exists(filePath))
|
||||
{
|
||||
_logger.LogDebug("No matched tracks file cache found for {Playlist} at {Path}", playlistName, filePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
var fileAge = DateTime.UtcNow - System.IO.File.GetLastWriteTimeUtc(filePath);
|
||||
|
||||
// Check if cache is too old (more than 24 hours)
|
||||
if (fileAge.TotalHours > 24)
|
||||
{
|
||||
_logger.LogInformation("Matched tracks file cache for {Playlist} is too old ({Age:F1}h), will rebuild",
|
||||
playlistName, fileAge.TotalHours);
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Matched tracks file cache for {Playlist} age: {Age:F1}h", playlistName, fileAge.TotalHours);
|
||||
|
||||
var json = await System.IO.File.ReadAllTextAsync(filePath);
|
||||
var tracks = JsonSerializer.Deserialize<List<Song>>(json);
|
||||
|
||||
_logger.LogInformation("Loaded {Count} matched tracks from file cache for {Playlist} (age: {Age:F1}h)",
|
||||
tracks?.Count ?? 0, playlistName, fileAge.TotalHours);
|
||||
|
||||
return tracks;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load matched tracks from file for {Playlist}", playlistName);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves matched/combined tracks to file cache for persistence across restarts.
|
||||
/// </summary>
|
||||
private async Task SaveMatchedTracksToFile(string playlistName, List<Song> tracks)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cacheDir = "/app/cache/spotify";
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
var filePath = Path.Combine(cacheDir, $"{safeName}_matched.json");
|
||||
|
||||
var json = JsonSerializer.Serialize(tracks, new JsonSerializerOptions { WriteIndented = true });
|
||||
await System.IO.File.WriteAllTextAsync(filePath, json);
|
||||
|
||||
_logger.LogInformation("Saved {Count} matched tracks to file cache for {Playlist}",
|
||||
tracks.Count, playlistName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to save matched tracks to file for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manual trigger endpoint to force fetch Spotify missing tracks.
|
||||
/// GET /spotify/sync?api_key=YOUR_KEY
|
||||
|
||||
66
allstarr/Models/Settings/SpotifyApiSettings.cs
Normal file
66
allstarr/Models/Settings/SpotifyApiSettings.cs
Normal file
@@ -0,0 +1,66 @@
|
||||
namespace allstarr.Models.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for direct Spotify API access.
|
||||
/// This enables fetching playlist data directly from Spotify rather than relying on the Jellyfin plugin.
|
||||
///
|
||||
/// Benefits over Jellyfin plugin approach:
|
||||
/// - Track ordering is preserved (critical for playlists like Release Radar)
|
||||
/// - ISRC codes available for exact matching
|
||||
/// - Real-time data without waiting for plugin sync
|
||||
/// - Full track metadata (duration, release date, etc.)
|
||||
/// </summary>
|
||||
public class SpotifyApiSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Enable direct Spotify API integration.
|
||||
/// When enabled, playlists will be fetched directly from Spotify instead of the Jellyfin plugin.
|
||||
/// </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.
|
||||
/// These playlists are not available via the official API.
|
||||
///
|
||||
/// To get this cookie:
|
||||
/// 1. Log into open.spotify.com in your browser
|
||||
/// 2. Open DevTools (F12) > Application > Cookies > https://open.spotify.com
|
||||
/// 3. Copy the value of the "sp_dc" cookie
|
||||
///
|
||||
/// Note: This cookie expires periodically and will need to be refreshed.
|
||||
/// </summary>
|
||||
public string SessionCookie { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Cache duration in minutes for playlist data.
|
||||
/// Playlists like Release Radar only update weekly, so caching is beneficial.
|
||||
/// Default: 60 minutes
|
||||
/// </summary>
|
||||
public int CacheDurationMinutes { get; set; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Rate limit delay between Spotify API requests in milliseconds.
|
||||
/// Default: 100ms (Spotify allows ~100 requests per minute)
|
||||
/// </summary>
|
||||
public int RateLimitDelayMs { get; set; } = 100;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to prefer ISRC matching over fuzzy title/artist matching when ISRC is available.
|
||||
/// ISRC provides exact track identification across services.
|
||||
/// Default: true
|
||||
/// </summary>
|
||||
public bool PreferIsrcMatching { get; set; } = true;
|
||||
}
|
||||
231
allstarr/Models/Spotify/SpotifyPlaylistTrack.cs
Normal file
231
allstarr/Models/Spotify/SpotifyPlaylistTrack.cs
Normal file
@@ -0,0 +1,231 @@
|
||||
using allstarr.Models.Domain;
|
||||
|
||||
namespace allstarr.Models.Spotify;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a track from a Spotify playlist with full metadata including position.
|
||||
/// This model preserves track ordering which is critical for playlists like Release Radar.
|
||||
/// </summary>
|
||||
public class SpotifyPlaylistTrack
|
||||
{
|
||||
/// <summary>
|
||||
/// Spotify track ID (e.g., "3a8mo25v74BMUOJ1IDUEBL")
|
||||
/// </summary>
|
||||
public string SpotifyId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Track's position in the playlist (0-based index).
|
||||
/// This is critical for maintaining correct playlist order.
|
||||
/// </summary>
|
||||
public int Position { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Track title
|
||||
/// </summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Album name
|
||||
/// </summary>
|
||||
public string Album { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Album Spotify ID
|
||||
/// </summary>
|
||||
public string AlbumId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// List of artist names
|
||||
/// </summary>
|
||||
public List<string> Artists { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// List of artist Spotify IDs
|
||||
/// </summary>
|
||||
public List<string> ArtistIds { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// ISRC (International Standard Recording Code) for exact track identification.
|
||||
/// This enables precise matching across different streaming services.
|
||||
/// </summary>
|
||||
public string? Isrc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Track duration in milliseconds
|
||||
/// </summary>
|
||||
public int DurationMs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the track contains explicit content
|
||||
/// </summary>
|
||||
public bool Explicit { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Track's popularity score (0-100)
|
||||
/// </summary>
|
||||
public int Popularity { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Preview URL for 30-second audio clip (may be null)
|
||||
/// </summary>
|
||||
public string? PreviewUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Album artwork URL (largest available)
|
||||
/// </summary>
|
||||
public string? AlbumArtUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Release date of the album (format varies: YYYY, YYYY-MM, or YYYY-MM-DD)
|
||||
/// </summary>
|
||||
public string? ReleaseDate { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// When this track was added to the playlist
|
||||
/// </summary>
|
||||
public DateTime? AddedAt { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Disc number within the album
|
||||
/// </summary>
|
||||
public int DiscNumber { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Track number within the disc
|
||||
/// </summary>
|
||||
public int TrackNumber { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Primary (first) artist name
|
||||
/// </summary>
|
||||
public string PrimaryArtist => Artists.FirstOrDefault() ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// All artists as a comma-separated string
|
||||
/// </summary>
|
||||
public string AllArtists => string.Join(", ", Artists);
|
||||
|
||||
/// <summary>
|
||||
/// Track duration as TimeSpan
|
||||
/// </summary>
|
||||
public TimeSpan Duration => TimeSpan.FromMilliseconds(DurationMs);
|
||||
|
||||
/// <summary>
|
||||
/// Converts to the legacy MissingTrack format for compatibility with existing matching logic.
|
||||
/// </summary>
|
||||
public MissingTrack ToMissingTrack() => new()
|
||||
{
|
||||
SpotifyId = SpotifyId,
|
||||
Title = Title,
|
||||
Album = Album,
|
||||
Artists = Artists
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a Spotify playlist with its tracks in order.
|
||||
/// </summary>
|
||||
public class SpotifyPlaylist
|
||||
{
|
||||
/// <summary>
|
||||
/// Spotify playlist ID
|
||||
/// </summary>
|
||||
public string SpotifyId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Playlist name
|
||||
/// </summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Playlist description
|
||||
/// </summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Playlist owner's display name
|
||||
/// </summary>
|
||||
public string? OwnerName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Playlist owner's Spotify ID
|
||||
/// </summary>
|
||||
public string? OwnerId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Total number of tracks in the playlist
|
||||
/// </summary>
|
||||
public int TotalTracks { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Playlist cover image URL
|
||||
/// </summary>
|
||||
public string? ImageUrl { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a collaborative playlist
|
||||
/// </summary>
|
||||
public bool Collaborative { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether this playlist is public
|
||||
/// </summary>
|
||||
public bool Public { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Tracks in the playlist, ordered by position
|
||||
/// </summary>
|
||||
public List<SpotifyPlaylistTrack> Tracks { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// When this data was fetched from Spotify
|
||||
/// </summary>
|
||||
public DateTime FetchedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot ID for change detection (Spotify's playlist version identifier)
|
||||
/// </summary>
|
||||
public string? SnapshotId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a Spotify track that has been matched to an external provider track.
|
||||
/// Preserves position for correct playlist ordering.
|
||||
/// </summary>
|
||||
public class MatchedTrack
|
||||
{
|
||||
/// <summary>
|
||||
/// Position in the original Spotify playlist (0-based)
|
||||
/// </summary>
|
||||
public int Position { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Original Spotify track ID
|
||||
/// </summary>
|
||||
public string SpotifyId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Original Spotify track title (for debugging/logging)
|
||||
/// </summary>
|
||||
public string SpotifyTitle { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Original Spotify artist (for debugging/logging)
|
||||
/// </summary>
|
||||
public string SpotifyArtist { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// ISRC used for matching (if available)
|
||||
/// </summary>
|
||||
public string? Isrc { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// How the match was made: "isrc" or "fuzzy"
|
||||
/// </summary>
|
||||
public string MatchType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The matched song from the external provider
|
||||
/// </summary>
|
||||
public Song MatchedSong { get; set; } = null!;
|
||||
}
|
||||
@@ -463,7 +463,67 @@ builder.Services.AddHostedService<StartupValidationOrchestrator>();
|
||||
// Register cache cleanup service (only runs when StorageMode is Cache)
|
||||
builder.Services.AddHostedService<CacheCleanupService>();
|
||||
|
||||
// Register Spotify missing tracks fetcher (only runs when SpotifyImport is enabled)
|
||||
// Register Spotify API client, lyrics service, and settings for direct API access
|
||||
// Configure from environment variables with SPOTIFY_API_ prefix
|
||||
builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options =>
|
||||
{
|
||||
builder.Configuration.GetSection("SpotifyApi").Bind(options);
|
||||
|
||||
// Override from environment variables
|
||||
var enabled = builder.Configuration.GetValue<string>("SpotifyApi:Enabled");
|
||||
if (!string.IsNullOrEmpty(enabled))
|
||||
{
|
||||
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))
|
||||
{
|
||||
options.SessionCookie = sessionCookie;
|
||||
}
|
||||
|
||||
var cacheDuration = builder.Configuration.GetValue<int?>("SpotifyApi:CacheDurationMinutes");
|
||||
if (cacheDuration.HasValue)
|
||||
{
|
||||
options.CacheDurationMinutes = cacheDuration.Value;
|
||||
}
|
||||
|
||||
var preferIsrc = builder.Configuration.GetValue<string>("SpotifyApi:PreferIsrcMatching");
|
||||
if (!string.IsNullOrEmpty(preferIsrc))
|
||||
{
|
||||
options.PreferIsrcMatching = preferIsrc.Equals("true", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// 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($" CacheDurationMinutes: {options.CacheDurationMinutes}");
|
||||
Console.WriteLine($" PreferIsrcMatching: {options.PreferIsrcMatching}");
|
||||
});
|
||||
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 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>());
|
||||
|
||||
// Register Spotify missing tracks fetcher (legacy - only runs when SpotifyImport is enabled and SpotifyApi is disabled)
|
||||
builder.Services.AddHostedService<allstarr.Services.Spotify.SpotifyMissingTracksFetcher>();
|
||||
|
||||
// Register Spotify track matching service (pre-matches tracks with rate limiting)
|
||||
|
||||
@@ -284,33 +284,11 @@ public class JellyfinProxyService
|
||||
}
|
||||
}
|
||||
|
||||
// Handle special case for playback endpoints - Jellyfin expects wrapped body
|
||||
// Handle special case for playback endpoints
|
||||
// NOTE: Jellyfin API expects PlaybackStartInfo/PlaybackProgressInfo/PlaybackStopInfo
|
||||
// DIRECTLY as the body, NOT wrapped in a field. Do NOT wrap the body.
|
||||
var bodyToSend = body;
|
||||
if (!string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
// Check if this is a playback progress endpoint
|
||||
if (endpoint.Contains("Sessions/Playing/Progress", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Wrap the body in playbackProgressInfo field
|
||||
bodyToSend = $"{{\"playbackProgressInfo\":{body}}}";
|
||||
_logger.LogDebug("Wrapped body for playback progress endpoint");
|
||||
}
|
||||
else if (endpoint.Contains("Sessions/Playing/Stopped", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Wrap the body in playbackStopInfo field
|
||||
bodyToSend = $"{{\"playbackStopInfo\":{body}}}";
|
||||
_logger.LogDebug("Wrapped body for playback stopped endpoint");
|
||||
}
|
||||
else if (endpoint.Contains("Sessions/Playing", StringComparison.OrdinalIgnoreCase) &&
|
||||
!endpoint.Contains("Progress", StringComparison.OrdinalIgnoreCase) &&
|
||||
!endpoint.Contains("Stopped", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Wrap the body in playbackStartInfo field for /Sessions/Playing
|
||||
bodyToSend = $"{{\"playbackStartInfo\":{body}}}";
|
||||
_logger.LogDebug("Wrapped body for playback start endpoint");
|
||||
}
|
||||
}
|
||||
else
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
bodyToSend = "{}";
|
||||
_logger.LogWarning("POST body was empty for {Url}, sending empty JSON object", url);
|
||||
|
||||
474
allstarr/Services/Lyrics/SpotifyLyricsService.cs
Normal file
474
allstarr/Services/Lyrics/SpotifyLyricsService.cs
Normal file
@@ -0,0 +1,474 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using allstarr.Models.Lyrics;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services.Spotify;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace allstarr.Services.Lyrics;
|
||||
|
||||
/// <summary>
|
||||
/// Service for fetching synchronized lyrics from Spotify's internal color-lyrics API.
|
||||
///
|
||||
/// Spotify's lyrics API provides:
|
||||
/// - Line-by-line synchronized lyrics with precise timestamps
|
||||
/// - Word-level timing for karaoke-style display (syllable sync)
|
||||
/// - Background color suggestions based on album art
|
||||
/// - Support for multiple languages and translations
|
||||
///
|
||||
/// This requires the sp_dc session cookie for authentication.
|
||||
/// </summary>
|
||||
public class SpotifyLyricsService
|
||||
{
|
||||
private readonly ILogger<SpotifyLyricsService> _logger;
|
||||
private readonly SpotifyApiSettings _settings;
|
||||
private readonly SpotifyApiClient _spotifyClient;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
private const string LyricsApiBase = "https://spclient.wg.spotify.com/color-lyrics/v2/track";
|
||||
|
||||
public SpotifyLyricsService(
|
||||
ILogger<SpotifyLyricsService> logger,
|
||||
IOptions<SpotifyApiSettings> settings,
|
||||
SpotifyApiClient spotifyClient,
|
||||
RedisCacheService cache,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_settings = settings.Value;
|
||||
_spotifyClient = spotifyClient;
|
||||
_cache = cache;
|
||||
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0");
|
||||
_httpClient.DefaultRequestHeaders.Add("App-Platform", "WebPlayer");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets synchronized lyrics for a Spotify track by its ID.
|
||||
/// </summary>
|
||||
/// <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>
|
||||
public async Task<SpotifyLyricsResult?> GetLyricsByTrackIdAsync(string spotifyTrackId)
|
||||
{
|
||||
if (!_settings.Enabled || string.IsNullOrEmpty(_settings.SessionCookie))
|
||||
{
|
||||
_logger.LogDebug("Spotify API not enabled or no session cookie configured");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Normalize track ID (remove URI prefix if present)
|
||||
spotifyTrackId = ExtractTrackId(spotifyTrackId);
|
||||
|
||||
// Check cache
|
||||
var cacheKey = $"spotify:lyrics:{spotifyTrackId}";
|
||||
var cached = await _cache.GetAsync<SpotifyLyricsResult>(cacheKey);
|
||||
if (cached != null)
|
||||
{
|
||||
_logger.LogDebug("Returning cached Spotify lyrics for track {TrackId}", spotifyTrackId);
|
||||
return cached;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Get access token
|
||||
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
|
||||
var url = $"{LyricsApiBase}/{spotifyTrackId}?format=json&vocalRemoval=false&market=from_token";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, 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)
|
||||
{
|
||||
_logger.LogWarning("Spotify lyrics API returned {StatusCode} for track {TrackId}",
|
||||
response.StatusCode, spotifyTrackId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var result = ParseLyricsResponse(json, spotifyTrackId);
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
// Cache for 30 days (lyrics don't change)
|
||||
await _cache.SetAsync(cacheKey, result, TimeSpan.FromDays(30));
|
||||
_logger.LogInformation("Cached Spotify lyrics for track {TrackId} ({LineCount} lines)",
|
||||
spotifyTrackId, result.Lines.Count);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching Spotify lyrics for track {TrackId}", spotifyTrackId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for a track on Spotify and returns its lyrics.
|
||||
/// Useful when you have track metadata but not a Spotify ID.
|
||||
/// </summary>
|
||||
public async Task<SpotifyLyricsResult?> SearchAndGetLyricsAsync(
|
||||
string trackName,
|
||||
string artistName,
|
||||
string? albumName = null,
|
||||
int? durationMs = null)
|
||||
{
|
||||
if (!_settings.Enabled || string.IsNullOrEmpty(_settings.SessionCookie))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var token = await _spotifyClient.GetWebAccessTokenAsync();
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
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>
|
||||
/// Converts Spotify lyrics to LRCLIB-compatible LyricsInfo format.
|
||||
/// </summary>
|
||||
public LyricsInfo? ToLyricsInfo(SpotifyLyricsResult spotifyLyrics)
|
||||
{
|
||||
if (spotifyLyrics.Lines.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build synced lyrics in LRC format
|
||||
var lrcLines = new List<string>();
|
||||
foreach (var line in spotifyLyrics.Lines)
|
||||
{
|
||||
var timestamp = TimeSpan.FromMilliseconds(line.StartTimeMs);
|
||||
var mm = (int)timestamp.TotalMinutes;
|
||||
var ss = timestamp.Seconds;
|
||||
var ms = timestamp.Milliseconds / 10; // LRC uses centiseconds
|
||||
|
||||
lrcLines.Add($"[{mm:D2}:{ss:D2}.{ms:D2}]{line.Words}");
|
||||
}
|
||||
|
||||
return new LyricsInfo
|
||||
{
|
||||
TrackName = spotifyLyrics.TrackName ?? "",
|
||||
ArtistName = spotifyLyrics.ArtistName ?? "",
|
||||
AlbumName = spotifyLyrics.AlbumName ?? "",
|
||||
Duration = (int)(spotifyLyrics.DurationMs / 1000),
|
||||
Instrumental = spotifyLyrics.Lines.Count == 0,
|
||||
SyncedLyrics = string.Join("\n", lrcLines),
|
||||
PlainLyrics = string.Join("\n", spotifyLyrics.Lines.Select(l => l.Words))
|
||||
};
|
||||
}
|
||||
|
||||
private SpotifyLyricsResult? ParseLyricsResponse(string json, string trackId)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var result = new SpotifyLyricsResult
|
||||
{
|
||||
SpotifyTrackId = trackId
|
||||
};
|
||||
|
||||
// Parse lyrics lines
|
||||
if (root.TryGetProperty("lyrics", out var lyrics))
|
||||
{
|
||||
// Check sync type
|
||||
if (lyrics.TryGetProperty("syncType", out var syncType))
|
||||
{
|
||||
result.SyncType = syncType.GetString() ?? "LINE_SYNCED";
|
||||
}
|
||||
|
||||
// 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,
|
||||
Words = line.TryGetProperty("words", out var words)
|
||||
? words.GetString() ?? "" : "",
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error parsing Spotify lyrics response");
|
||||
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)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input)) return input;
|
||||
|
||||
// Handle spotify:track:xxxxx format
|
||||
if (input.StartsWith("spotify:track:"))
|
||||
{
|
||||
return input.Substring("spotify:track:".Length);
|
||||
}
|
||||
|
||||
// Handle https://open.spotify.com/track/xxxxx format
|
||||
if (input.Contains("open.spotify.com/track/"))
|
||||
{
|
||||
var start = input.IndexOf("/track/") + "/track/".Length;
|
||||
var end = input.IndexOf('?', start);
|
||||
return end > 0 ? input.Substring(start, end - start) : input.Substring(start);
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result from Spotify's color-lyrics API.
|
||||
/// </summary>
|
||||
public class SpotifyLyricsResult
|
||||
{
|
||||
public string SpotifyTrackId { get; set; } = string.Empty;
|
||||
public string? TrackName { get; set; }
|
||||
public string? ArtistName { get; set; }
|
||||
public string? AlbumName { get; set; }
|
||||
public long DurationMs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Sync type: "LINE_SYNCED", "SYLLABLE_SYNCED", or "UNSYNCED"
|
||||
/// </summary>
|
||||
public string SyncType { get; set; } = "LINE_SYNCED";
|
||||
|
||||
/// <summary>
|
||||
/// Language code (e.g., "en", "es", "ja")
|
||||
/// </summary>
|
||||
public string? Language { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Lyrics provider (e.g., "MusixMatch", "Spotify")
|
||||
/// </summary>
|
||||
public string? Provider { get; set; }
|
||||
|
||||
public string? ProviderDisplayName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Lyrics lines in order
|
||||
/// </summary>
|
||||
public List<SpotifyLyricsLine> Lines { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Color suggestions based on album art
|
||||
/// </summary>
|
||||
public SpotifyLyricsColors? Colors { get; set; }
|
||||
}
|
||||
|
||||
public class SpotifyLyricsLine
|
||||
{
|
||||
/// <summary>
|
||||
/// Start time in milliseconds
|
||||
/// </summary>
|
||||
public long StartTimeMs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// End time in milliseconds
|
||||
/// </summary>
|
||||
public long EndTimeMs { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The lyrics text for this line
|
||||
/// </summary>
|
||||
public string Words { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Syllable-level timing for karaoke display (if available)
|
||||
/// </summary>
|
||||
public List<SpotifyLyricsSyllable> Syllables { get; set; } = new();
|
||||
}
|
||||
|
||||
public class SpotifyLyricsSyllable
|
||||
{
|
||||
public long StartTimeMs { get; set; }
|
||||
public string Text { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
public class SpotifyLyricsColors
|
||||
{
|
||||
/// <summary>
|
||||
/// Suggested background color (ARGB integer)
|
||||
/// </summary>
|
||||
public int? Background { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Suggested text color (ARGB integer)
|
||||
/// </summary>
|
||||
public int? Text { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Suggested highlight/active text color (ARGB integer)
|
||||
/// </summary>
|
||||
public int? HighlightText { get; set; }
|
||||
}
|
||||
538
allstarr/Services/Spotify/SpotifyApiClient.cs
Normal file
538
allstarr/Services/Spotify/SpotifyApiClient.cs
Normal file
@@ -0,0 +1,538 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Spotify;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace allstarr.Services.Spotify;
|
||||
|
||||
/// <summary>
|
||||
/// Client for accessing Spotify's APIs directly.
|
||||
///
|
||||
/// Supports two modes:
|
||||
/// 1. Official API - For public playlists and standard operations
|
||||
/// 2. Web API (with session cookie) - For editorial/personalized playlists like Release Radar, Discover Weekly
|
||||
///
|
||||
/// The session cookie (sp_dc) is required because Spotify's official API doesn't expose
|
||||
/// algorithmically generated "Made For You" playlists.
|
||||
/// </summary>
|
||||
public class SpotifyApiClient : IDisposable
|
||||
{
|
||||
private readonly ILogger<SpotifyApiClient> _logger;
|
||||
private readonly SpotifyApiSettings _settings;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly HttpClient _webApiClient;
|
||||
|
||||
// Spotify API endpoints
|
||||
private const string OfficialApiBase = "https://api.spotify.com/v1";
|
||||
private const string WebApiBase = "https://api-partner.spotify.com/pathfinder/v1";
|
||||
private const string TokenEndpoint = "https://open.spotify.com/get_access_token";
|
||||
|
||||
// Web API access token (obtained via session cookie)
|
||||
private string? _webAccessToken;
|
||||
private DateTime _webTokenExpiry = DateTime.MinValue;
|
||||
private readonly SemaphoreSlim _tokenLock = new(1, 1);
|
||||
|
||||
public SpotifyApiClient(
|
||||
ILogger<SpotifyApiClient> logger,
|
||||
IOptions<SpotifyApiSettings> settings)
|
||||
{
|
||||
_logger = logger;
|
||||
_settings = settings.Value;
|
||||
|
||||
// Client for official API
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
BaseAddress = new Uri(OfficialApiBase),
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
// Client for web API (requires session cookie)
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
UseCookies = true,
|
||||
CookieContainer = new CookieContainer()
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(_settings.SessionCookie))
|
||||
{
|
||||
handler.CookieContainer.Add(
|
||||
new Uri("https://open.spotify.com"),
|
||||
new Cookie("sp_dc", _settings.SessionCookie));
|
||||
}
|
||||
|
||||
_webApiClient = new HttpClient(handler)
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
// Common headers for web API
|
||||
_webApiClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
|
||||
_webApiClient.DefaultRequestHeaders.Add("Accept", "application/json");
|
||||
_webApiClient.DefaultRequestHeaders.Add("Accept-Language", "en");
|
||||
_webApiClient.DefaultRequestHeaders.Add("app-platform", "WebPlayer");
|
||||
_webApiClient.DefaultRequestHeaders.Add("spotify-app-version", "1.2.31.0");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets an access token using the session cookie.
|
||||
/// This token can be used for both the official API and web API.
|
||||
/// </summary>
|
||||
public async Task<string?> GetWebAccessTokenAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_settings.SessionCookie))
|
||||
{
|
||||
_logger.LogWarning("No Spotify session cookie configured");
|
||||
return null;
|
||||
}
|
||||
|
||||
await _tokenLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
// Return cached token if still valid
|
||||
if (!string.IsNullOrEmpty(_webAccessToken) && DateTime.UtcNow < _webTokenExpiry)
|
||||
{
|
||||
return _webAccessToken;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Fetching new Spotify web access token");
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, TokenEndpoint);
|
||||
request.Headers.Add("Cookie", $"sp_dc={_settings.SessionCookie}");
|
||||
|
||||
var response = await _webApiClient.SendAsync(request, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Failed to get Spotify access token: {StatusCode}", response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (root.TryGetProperty("accessToken", out var tokenElement))
|
||||
{
|
||||
_webAccessToken = tokenElement.GetString();
|
||||
|
||||
// Token typically expires in 1 hour, but we'll refresh early
|
||||
if (root.TryGetProperty("accessTokenExpirationTimestampMs", out var expiryElement))
|
||||
{
|
||||
var expiryMs = expiryElement.GetInt64();
|
||||
_webTokenExpiry = DateTimeOffset.FromUnixTimeMilliseconds(expiryMs).UtcDateTime;
|
||||
// Refresh 5 minutes early
|
||||
_webTokenExpiry = _webTokenExpiry.AddMinutes(-5);
|
||||
}
|
||||
else
|
||||
{
|
||||
_webTokenExpiry = DateTime.UtcNow.AddMinutes(55);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Obtained Spotify web access token, expires at {Expiry}", _webTokenExpiry);
|
||||
return _webAccessToken;
|
||||
}
|
||||
|
||||
_logger.LogError("No access token in Spotify response");
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting Spotify web access token");
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_tokenLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a playlist with all its tracks from Spotify.
|
||||
/// Uses the web API to access editorial playlists that aren't available via the official API.
|
||||
/// </summary>
|
||||
/// <param name="playlistId">Spotify playlist ID or URI</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Playlist with tracks in correct order, or null if not found</returns>
|
||||
public async Task<SpotifyPlaylist?> GetPlaylistAsync(string playlistId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Extract ID from URI if needed (spotify:playlist:xxxxx or https://open.spotify.com/playlist/xxxxx)
|
||||
playlistId = ExtractPlaylistId(playlistId);
|
||||
|
||||
var token = await GetWebAccessTokenAsync(cancellationToken);
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
_logger.LogError("Cannot fetch playlist without access token");
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Use the official API with the web token for playlist data
|
||||
var playlist = await FetchPlaylistMetadataAsync(playlistId, token, cancellationToken);
|
||||
if (playlist == null) return null;
|
||||
|
||||
// Fetch all tracks with pagination
|
||||
var tracks = await FetchAllPlaylistTracksAsync(playlistId, token, cancellationToken);
|
||||
playlist.Tracks = tracks;
|
||||
playlist.TotalTracks = tracks.Count;
|
||||
|
||||
_logger.LogInformation("Fetched playlist '{Name}' with {Count} tracks", playlist.Name, tracks.Count);
|
||||
return playlist;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching playlist {PlaylistId}", playlistId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<SpotifyPlaylist?> FetchPlaylistMetadataAsync(
|
||||
string playlistId,
|
||||
string token,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var url = $"{OfficialApiBase}/playlists/{playlistId}?fields=id,name,description,owner(display_name,id),images,collaborative,public,snapshot_id,tracks.total";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Failed to fetch playlist metadata: {StatusCode}", response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var playlist = new SpotifyPlaylist
|
||||
{
|
||||
SpotifyId = root.GetProperty("id").GetString() ?? playlistId,
|
||||
Name = root.GetProperty("name").GetString() ?? "Unknown Playlist",
|
||||
Description = root.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
||||
SnapshotId = root.TryGetProperty("snapshot_id", out var snap) ? snap.GetString() : null,
|
||||
Collaborative = root.TryGetProperty("collaborative", out var collab) && collab.GetBoolean(),
|
||||
Public = root.TryGetProperty("public", out var pub) && pub.ValueKind != JsonValueKind.Null && pub.GetBoolean(),
|
||||
FetchedAt = DateTime.UtcNow
|
||||
};
|
||||
|
||||
if (root.TryGetProperty("owner", out var owner))
|
||||
{
|
||||
playlist.OwnerName = owner.TryGetProperty("display_name", out var dn) ? dn.GetString() : null;
|
||||
playlist.OwnerId = owner.TryGetProperty("id", out var oid) ? oid.GetString() : null;
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("images", out var images) && images.GetArrayLength() > 0)
|
||||
{
|
||||
playlist.ImageUrl = images[0].GetProperty("url").GetString();
|
||||
}
|
||||
|
||||
if (root.TryGetProperty("tracks", out var tracks) && tracks.TryGetProperty("total", out var total))
|
||||
{
|
||||
playlist.TotalTracks = total.GetInt32();
|
||||
}
|
||||
|
||||
return playlist;
|
||||
}
|
||||
|
||||
private async Task<List<SpotifyPlaylistTrack>> FetchAllPlaylistTracksAsync(
|
||||
string playlistId,
|
||||
string token,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var allTracks = new List<SpotifyPlaylistTrack>();
|
||||
var offset = 0;
|
||||
const int limit = 100; // Spotify's max
|
||||
|
||||
while (true)
|
||||
{
|
||||
var tracks = await FetchPlaylistTracksPageAsync(playlistId, token, offset, limit, cancellationToken);
|
||||
if (tracks == null || tracks.Count == 0) break;
|
||||
|
||||
allTracks.AddRange(tracks);
|
||||
|
||||
if (tracks.Count < limit) break;
|
||||
|
||||
offset += limit;
|
||||
|
||||
// Rate limiting
|
||||
if (_settings.RateLimitDelayMs > 0)
|
||||
{
|
||||
await Task.Delay(_settings.RateLimitDelayMs, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return allTracks;
|
||||
}
|
||||
|
||||
private async Task<List<SpotifyPlaylistTrack>?> FetchPlaylistTracksPageAsync(
|
||||
string playlistId,
|
||||
string token,
|
||||
int offset,
|
||||
int limit,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// Request fields needed for matching and ordering
|
||||
var fields = "items(added_at,track(id,name,album(id,name,images,release_date),artists(id,name),duration_ms,explicit,popularity,preview_url,disc_number,track_number,external_ids))";
|
||||
var url = $"{OfficialApiBase}/playlists/{playlistId}/tracks?offset={offset}&limit={limit}&fields={fields}";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogError("Failed to fetch playlist tracks: {StatusCode}", response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("items", out var items))
|
||||
{
|
||||
return new List<SpotifyPlaylistTrack>();
|
||||
}
|
||||
|
||||
var tracks = new List<SpotifyPlaylistTrack>();
|
||||
var position = offset;
|
||||
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
// Skip null tracks (can happen with deleted/unavailable tracks)
|
||||
if (!item.TryGetProperty("track", out var trackElement) ||
|
||||
trackElement.ValueKind == JsonValueKind.Null)
|
||||
{
|
||||
position++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var track = ParseTrack(trackElement, position);
|
||||
|
||||
// Parse added_at timestamp
|
||||
if (item.TryGetProperty("added_at", out var addedAt) &&
|
||||
addedAt.ValueKind != JsonValueKind.Null)
|
||||
{
|
||||
var addedAtStr = addedAt.GetString();
|
||||
if (DateTime.TryParse(addedAtStr, out var addedAtDate))
|
||||
{
|
||||
track.AddedAt = addedAtDate;
|
||||
}
|
||||
}
|
||||
|
||||
tracks.Add(track);
|
||||
position++;
|
||||
}
|
||||
|
||||
return tracks;
|
||||
}
|
||||
|
||||
private SpotifyPlaylistTrack ParseTrack(JsonElement track, int position)
|
||||
{
|
||||
var result = new SpotifyPlaylistTrack
|
||||
{
|
||||
Position = position,
|
||||
SpotifyId = track.TryGetProperty("id", out var id) ? id.GetString() ?? "" : "",
|
||||
Title = track.TryGetProperty("name", out var name) ? name.GetString() ?? "" : "",
|
||||
DurationMs = track.TryGetProperty("duration_ms", out var dur) ? dur.GetInt32() : 0,
|
||||
Explicit = track.TryGetProperty("explicit", out var exp) && exp.GetBoolean(),
|
||||
Popularity = track.TryGetProperty("popularity", out var pop) ? pop.GetInt32() : 0,
|
||||
PreviewUrl = track.TryGetProperty("preview_url", out var prev) && prev.ValueKind != JsonValueKind.Null
|
||||
? prev.GetString() : null,
|
||||
DiscNumber = track.TryGetProperty("disc_number", out var disc) ? disc.GetInt32() : 1,
|
||||
TrackNumber = track.TryGetProperty("track_number", out var tn) ? tn.GetInt32() : 1
|
||||
};
|
||||
|
||||
// Parse album
|
||||
if (track.TryGetProperty("album", out var album))
|
||||
{
|
||||
result.Album = album.TryGetProperty("name", out var albumName)
|
||||
? albumName.GetString() ?? "" : "";
|
||||
result.AlbumId = album.TryGetProperty("id", out var albumId)
|
||||
? albumId.GetString() ?? "" : "";
|
||||
result.ReleaseDate = album.TryGetProperty("release_date", out var rd)
|
||||
? rd.GetString() : null;
|
||||
|
||||
if (album.TryGetProperty("images", out var images) && images.GetArrayLength() > 0)
|
||||
{
|
||||
result.AlbumArtUrl = images[0].GetProperty("url").GetString();
|
||||
}
|
||||
}
|
||||
|
||||
// Parse artists
|
||||
if (track.TryGetProperty("artists", out var artists))
|
||||
{
|
||||
foreach (var artist in artists.EnumerateArray())
|
||||
{
|
||||
if (artist.TryGetProperty("name", out var artistName))
|
||||
{
|
||||
result.Artists.Add(artistName.GetString() ?? "");
|
||||
}
|
||||
if (artist.TryGetProperty("id", out var artistId))
|
||||
{
|
||||
result.ArtistIds.Add(artistId.GetString() ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Parse ISRC from external_ids
|
||||
if (track.TryGetProperty("external_ids", out var externalIds) &&
|
||||
externalIds.TryGetProperty("isrc", out var isrc))
|
||||
{
|
||||
result.Isrc = isrc.GetString();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for a user's playlists by name.
|
||||
/// Useful for finding playlists like "Release Radar" or "Discover Weekly" by their names.
|
||||
/// </summary>
|
||||
public async Task<List<SpotifyPlaylist>> SearchUserPlaylistsAsync(
|
||||
string searchName,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = await GetWebAccessTokenAsync(cancellationToken);
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
return new List<SpotifyPlaylist>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var playlists = new List<SpotifyPlaylist>();
|
||||
var offset = 0;
|
||||
const int limit = 50;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var url = $"{OfficialApiBase}/me/playlists?offset={offset}&limit={limit}";
|
||||
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode) break;
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
if (!root.TryGetProperty("items", out var items) || items.GetArrayLength() == 0)
|
||||
break;
|
||||
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
var name = item.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
|
||||
|
||||
// Check if name matches (case-insensitive)
|
||||
if (name.Contains(searchName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
playlists.Add(new SpotifyPlaylist
|
||||
{
|
||||
SpotifyId = item.TryGetProperty("id", out var id) ? id.GetString() ?? "" : "",
|
||||
Name = name,
|
||||
Description = item.TryGetProperty("description", out var desc) ? desc.GetString() : null,
|
||||
TotalTracks = item.TryGetProperty("tracks", out var tracks) &&
|
||||
tracks.TryGetProperty("total", out var total)
|
||||
? total.GetInt32() : 0,
|
||||
SnapshotId = item.TryGetProperty("snapshot_id", out var snap) ? snap.GetString() : null
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (items.GetArrayLength() < limit) break;
|
||||
offset += limit;
|
||||
|
||||
if (_settings.RateLimitDelayMs > 0)
|
||||
{
|
||||
await Task.Delay(_settings.RateLimitDelayMs, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
return playlists;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error searching user playlists for '{SearchName}'", searchName);
|
||||
return new List<SpotifyPlaylist>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current user's profile to verify authentication is working.
|
||||
/// </summary>
|
||||
public async Task<(bool Success, string? UserId, string? DisplayName)> GetCurrentUserAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = await GetWebAccessTokenAsync(cancellationToken);
|
||||
if (string.IsNullOrEmpty(token))
|
||||
{
|
||||
return (false, null, null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, $"{OfficialApiBase}/me");
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
|
||||
var response = await _httpClient.SendAsync(request, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
return (false, null, null);
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var userId = root.TryGetProperty("id", out var id) ? id.GetString() : null;
|
||||
var displayName = root.TryGetProperty("display_name", out var dn) ? dn.GetString() : null;
|
||||
|
||||
return (true, userId, displayName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error getting current Spotify user");
|
||||
return (false, null, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExtractPlaylistId(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input)) return input;
|
||||
|
||||
// Handle spotify:playlist:xxxxx format
|
||||
if (input.StartsWith("spotify:playlist:"))
|
||||
{
|
||||
return input.Substring("spotify:playlist:".Length);
|
||||
}
|
||||
|
||||
// Handle https://open.spotify.com/playlist/xxxxx format
|
||||
if (input.Contains("open.spotify.com/playlist/"))
|
||||
{
|
||||
var start = input.IndexOf("/playlist/") + "/playlist/".Length;
|
||||
var end = input.IndexOf('?', start);
|
||||
return end > 0 ? input.Substring(start, end - start) : input.Substring(start);
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
_webApiClient.Dispose();
|
||||
_tokenLock.Dispose();
|
||||
}
|
||||
}
|
||||
317
allstarr/Services/Spotify/SpotifyPlaylistFetcher.cs
Normal file
317
allstarr/Services/Spotify/SpotifyPlaylistFetcher.cs
Normal file
@@ -0,0 +1,317 @@
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Common;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace allstarr.Services.Spotify;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that fetches playlist tracks directly from Spotify's API.
|
||||
///
|
||||
/// This replaces the Jellyfin Spotify Import plugin dependency with key advantages:
|
||||
/// - Track ordering is preserved (critical for playlists like Release Radar)
|
||||
/// - ISRC codes available for exact matching
|
||||
/// - Real-time data without waiting for plugin sync schedules
|
||||
/// - Full track metadata (duration, release date, etc.)
|
||||
/// </summary>
|
||||
public class SpotifyPlaylistFetcher : BackgroundService
|
||||
{
|
||||
private readonly ILogger<SpotifyPlaylistFetcher> _logger;
|
||||
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||
private readonly SpotifyImportSettings _spotifyImportSettings;
|
||||
private readonly SpotifyApiClient _spotifyClient;
|
||||
private readonly RedisCacheService _cache;
|
||||
|
||||
private const string CacheDirectory = "/app/cache/spotify";
|
||||
private const string CacheKeyPrefix = "spotify:playlist:";
|
||||
|
||||
// Track Spotify playlist IDs after discovery
|
||||
private readonly Dictionary<string, string> _playlistNameToSpotifyId = new();
|
||||
|
||||
public SpotifyPlaylistFetcher(
|
||||
ILogger<SpotifyPlaylistFetcher> logger,
|
||||
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
||||
IOptions<SpotifyImportSettings> spotifyImportSettings,
|
||||
SpotifyApiClient spotifyClient,
|
||||
RedisCacheService cache)
|
||||
{
|
||||
_logger = logger;
|
||||
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||
_spotifyImportSettings = spotifyImportSettings.Value;
|
||||
_spotifyClient = spotifyClient;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Spotify playlist tracks in order, using cache if available.
|
||||
/// </summary>
|
||||
/// <param name="playlistName">Playlist name (e.g., "Release Radar", "Discover Weekly")</param>
|
||||
/// <returns>List of tracks in playlist order, or empty list if not found</returns>
|
||||
public async Task<List<SpotifyPlaylistTrack>> GetPlaylistTracksAsync(string playlistName)
|
||||
{
|
||||
var cacheKey = $"{CacheKeyPrefix}{playlistName}";
|
||||
|
||||
// Try Redis cache first
|
||||
var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
|
||||
if (cached != null && cached.Tracks.Count > 0)
|
||||
{
|
||||
var age = DateTime.UtcNow - cached.FetchedAt;
|
||||
if (age.TotalMinutes < _spotifyApiSettings.CacheDurationMinutes)
|
||||
{
|
||||
_logger.LogDebug("Using cached playlist '{Name}' ({Count} tracks, age: {Age:F1}m)",
|
||||
playlistName, cached.Tracks.Count, age.TotalMinutes);
|
||||
return cached.Tracks;
|
||||
}
|
||||
}
|
||||
|
||||
// Try file cache
|
||||
var filePath = GetCacheFilePath(playlistName);
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(filePath);
|
||||
var filePlaylist = JsonSerializer.Deserialize<SpotifyPlaylist>(json);
|
||||
if (filePlaylist != null && filePlaylist.Tracks.Count > 0)
|
||||
{
|
||||
var age = DateTime.UtcNow - filePlaylist.FetchedAt;
|
||||
if (age.TotalMinutes < _spotifyApiSettings.CacheDurationMinutes)
|
||||
{
|
||||
_logger.LogDebug("Using file-cached playlist '{Name}' ({Count} tracks)",
|
||||
playlistName, filePlaylist.Tracks.Count);
|
||||
return filePlaylist.Tracks;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to read file cache for '{Name}'", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
// Need to fetch fresh - try to use cached Spotify playlist ID
|
||||
if (!_playlistNameToSpotifyId.TryGetValue(playlistName, out var spotifyId))
|
||||
{
|
||||
_logger.LogDebug("Spotify playlist ID not cached for '{Name}', searching...", playlistName);
|
||||
var playlists = await _spotifyClient.SearchUserPlaylistsAsync(playlistName);
|
||||
|
||||
var exactMatch = playlists.FirstOrDefault(p =>
|
||||
p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (exactMatch == null)
|
||||
{
|
||||
_logger.LogWarning("Could not find Spotify playlist named '{Name}'", playlistName);
|
||||
|
||||
// Return file cache even if expired, as a fallback
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(filePath);
|
||||
var fallback = JsonSerializer.Deserialize<SpotifyPlaylist>(json);
|
||||
if (fallback != null)
|
||||
{
|
||||
_logger.LogWarning("Using expired file cache as fallback for '{Name}'", playlistName);
|
||||
return fallback.Tracks;
|
||||
}
|
||||
}
|
||||
|
||||
return new List<SpotifyPlaylistTrack>();
|
||||
}
|
||||
|
||||
spotifyId = exactMatch.SpotifyId;
|
||||
_playlistNameToSpotifyId[playlistName] = spotifyId;
|
||||
_logger.LogInformation("Found Spotify playlist '{Name}' with ID: {Id}", playlistName, spotifyId);
|
||||
}
|
||||
|
||||
// Fetch the full playlist
|
||||
var playlist = await _spotifyClient.GetPlaylistAsync(spotifyId);
|
||||
if (playlist == null || playlist.Tracks.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("Failed to fetch playlist '{Name}' from Spotify", playlistName);
|
||||
return cached?.Tracks ?? new List<SpotifyPlaylistTrack>();
|
||||
}
|
||||
|
||||
// Update cache
|
||||
await _cache.SetAsync(cacheKey, playlist, TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2));
|
||||
await SaveToFileCacheAsync(playlistName, playlist);
|
||||
|
||||
_logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks in order",
|
||||
playlistName, playlist.Tracks.Count);
|
||||
|
||||
return playlist.Tracks;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets missing tracks for a playlist (tracks not found in Jellyfin library).
|
||||
/// This provides compatibility with the existing SpotifyMissingTracksFetcher interface.
|
||||
/// </summary>
|
||||
/// <param name="playlistName">Playlist name</param>
|
||||
/// <param name="jellyfinTrackIds">Set of Spotify IDs that exist in Jellyfin library</param>
|
||||
/// <returns>List of missing tracks with position preserved</returns>
|
||||
public async Task<List<SpotifyPlaylistTrack>> GetMissingTracksAsync(
|
||||
string playlistName,
|
||||
HashSet<string> jellyfinTrackIds)
|
||||
{
|
||||
var allTracks = await GetPlaylistTracksAsync(playlistName);
|
||||
|
||||
// Filter to only tracks not in Jellyfin, preserving order
|
||||
return allTracks
|
||||
.Where(t => !jellyfinTrackIds.Contains(t.SpotifyId))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manual trigger to refresh a specific playlist.
|
||||
/// </summary>
|
||||
public async Task RefreshPlaylistAsync(string playlistName)
|
||||
{
|
||||
_logger.LogInformation("Manual refresh triggered for playlist '{Name}'", playlistName);
|
||||
|
||||
// Clear cache to force refresh
|
||||
var cacheKey = $"{CacheKeyPrefix}{playlistName}";
|
||||
await _cache.DeleteAsync(cacheKey);
|
||||
|
||||
// Re-fetch
|
||||
await GetPlaylistTracksAsync(playlistName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Manual trigger to refresh all configured playlists.
|
||||
/// </summary>
|
||||
public async Task TriggerFetchAsync()
|
||||
{
|
||||
_logger.LogInformation("Manual fetch triggered for all playlists");
|
||||
|
||||
foreach (var config in _spotifyImportSettings.Playlists)
|
||||
{
|
||||
await RefreshPlaylistAsync(config.Name);
|
||||
}
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("========================================");
|
||||
_logger.LogInformation("SpotifyPlaylistFetcher: Starting up...");
|
||||
|
||||
// Ensure cache directory exists
|
||||
Directory.CreateDirectory(CacheDirectory);
|
||||
|
||||
if (!_spotifyApiSettings.Enabled)
|
||||
{
|
||||
_logger.LogInformation("Spotify API integration is DISABLED");
|
||||
_logger.LogInformation("========================================");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
|
||||
{
|
||||
_logger.LogWarning("Spotify session cookie not configured - cannot access editorial playlists");
|
||||
_logger.LogInformation("========================================");
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify authentication
|
||||
var (success, userId, displayName) = await _spotifyClient.GetCurrentUserAsync(stoppingToken);
|
||||
if (!success)
|
||||
{
|
||||
_logger.LogError("Failed to authenticate with Spotify - check session cookie");
|
||||
_logger.LogInformation("========================================");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Spotify API ENABLED");
|
||||
_logger.LogInformation("Authenticated as: {DisplayName} ({UserId})", displayName, userId);
|
||||
_logger.LogInformation("Cache duration: {Minutes} minutes", _spotifyApiSettings.CacheDurationMinutes);
|
||||
_logger.LogInformation("ISRC matching: {Enabled}", _spotifyApiSettings.PreferIsrcMatching ? "enabled" : "disabled");
|
||||
_logger.LogInformation("Configured Playlists: {Count}", _spotifyImportSettings.Playlists.Count);
|
||||
|
||||
foreach (var playlist in _spotifyImportSettings.Playlists)
|
||||
{
|
||||
_logger.LogInformation(" - {Name}", playlist.Name);
|
||||
}
|
||||
|
||||
_logger.LogInformation("========================================");
|
||||
|
||||
// Initial fetch of all playlists
|
||||
await FetchAllPlaylistsAsync(stoppingToken);
|
||||
|
||||
// Periodic refresh loop
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes), stoppingToken);
|
||||
|
||||
try
|
||||
{
|
||||
await FetchAllPlaylistsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during periodic playlist refresh");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task FetchAllPlaylistsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("=== FETCHING SPOTIFY PLAYLISTS ===");
|
||||
|
||||
foreach (var config in _spotifyImportSettings.Playlists)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
var tracks = await GetPlaylistTracksAsync(config.Name);
|
||||
_logger.LogInformation(" {Name}: {Count} tracks", config.Name, tracks.Count);
|
||||
|
||||
// Log sample of track order for debugging
|
||||
if (tracks.Count > 0)
|
||||
{
|
||||
_logger.LogDebug(" First track: #{Position} {Title} - {Artist}",
|
||||
tracks[0].Position, tracks[0].Title, tracks[0].PrimaryArtist);
|
||||
|
||||
if (tracks.Count > 1)
|
||||
{
|
||||
var last = tracks[^1];
|
||||
_logger.LogDebug(" Last track: #{Position} {Title} - {Artist}",
|
||||
last.Position, last.Title, last.PrimaryArtist);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching playlist '{Name}'", config.Name);
|
||||
}
|
||||
|
||||
// Rate limiting between playlists
|
||||
await Task.Delay(_spotifyApiSettings.RateLimitDelayMs, cancellationToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("=== FINISHED FETCHING SPOTIFY PLAYLISTS ===");
|
||||
}
|
||||
|
||||
private string GetCacheFilePath(string playlistName)
|
||||
{
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
return Path.Combine(CacheDirectory, $"{safeName}_spotify.json");
|
||||
}
|
||||
|
||||
private async Task SaveToFileCacheAsync(string playlistName, SpotifyPlaylist playlist)
|
||||
{
|
||||
try
|
||||
{
|
||||
var filePath = GetCacheFilePath(playlistName);
|
||||
var json = JsonSerializer.Serialize(playlist, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||
});
|
||||
await File.WriteAllTextAsync(filePath, json);
|
||||
_logger.LogDebug("Saved playlist '{Name}' to file cache", playlistName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to save file cache for '{Name}'", playlistName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,12 +8,18 @@ using System.Text.Json;
|
||||
namespace allstarr.Services.Spotify;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that pre-matches Spotify missing tracks with external providers.
|
||||
/// Runs after SpotifyMissingTracksFetcher completes to avoid rate limiting during playlist loading.
|
||||
/// Background service that pre-matches Spotify tracks with external providers.
|
||||
///
|
||||
/// Supports two modes:
|
||||
/// 1. Legacy mode: Uses MissingTrack from Jellyfin plugin (no ISRC, no ordering)
|
||||
/// 2. Direct API mode: Uses SpotifyPlaylistTrack (with ISRC and ordering)
|
||||
///
|
||||
/// When ISRC is available, exact matching is preferred. Falls back to fuzzy matching.
|
||||
/// </summary>
|
||||
public class SpotifyTrackMatchingService : BackgroundService
|
||||
{
|
||||
private readonly IOptions<SpotifyImportSettings> _spotifySettings;
|
||||
private readonly SpotifyImportSettings _spotifySettings;
|
||||
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly ILogger<SpotifyTrackMatchingService> _logger;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
@@ -21,11 +27,13 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
public SpotifyTrackMatchingService(
|
||||
IOptions<SpotifyImportSettings> spotifySettings,
|
||||
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
||||
RedisCacheService cache,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<SpotifyTrackMatchingService> logger)
|
||||
{
|
||||
_spotifySettings = spotifySettings;
|
||||
_spotifySettings = spotifySettings.Value;
|
||||
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||
_cache = cache;
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
@@ -35,11 +43,15 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
{
|
||||
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
|
||||
|
||||
if (!_spotifySettings.Value.Enabled)
|
||||
if (!_spotifySettings.Enabled)
|
||||
{
|
||||
_logger.LogInformation("Spotify playlist injection is DISABLED, matching service will not run");
|
||||
return;
|
||||
}
|
||||
|
||||
var matchMode = _spotifyApiSettings.Enabled && _spotifyApiSettings.PreferIsrcMatching
|
||||
? "ISRC-preferred" : "fuzzy";
|
||||
_logger.LogInformation("Matching mode: {Mode}", matchMode);
|
||||
|
||||
// Wait a bit for the fetcher to run first
|
||||
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
|
||||
@@ -85,7 +97,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
{
|
||||
_logger.LogInformation("=== STARTING TRACK MATCHING ===");
|
||||
|
||||
var playlists = _spotifySettings.Value.Playlists;
|
||||
var playlists = _spotifySettings.Playlists;
|
||||
if (playlists.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No playlists configured for matching");
|
||||
@@ -94,6 +106,13 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
|
||||
|
||||
// Check if we should use the new SpotifyPlaylistFetcher
|
||||
SpotifyPlaylistFetcher? playlistFetcher = null;
|
||||
if (_spotifyApiSettings.Enabled)
|
||||
{
|
||||
playlistFetcher = scope.ServiceProvider.GetService<SpotifyPlaylistFetcher>();
|
||||
}
|
||||
|
||||
foreach (var playlist in playlists)
|
||||
{
|
||||
@@ -101,7 +120,18 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
try
|
||||
{
|
||||
await MatchPlaylistTracksAsync(playlist.Name, metadataService, cancellationToken);
|
||||
if (playlistFetcher != null)
|
||||
{
|
||||
// Use new direct API mode with ISRC support
|
||||
await MatchPlaylistTracksWithIsrcAsync(
|
||||
playlist.Name, playlistFetcher, metadataService, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fall back to legacy mode
|
||||
await MatchPlaylistTracksLegacyAsync(
|
||||
playlist.Name, metadataService, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -111,8 +141,211 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
_logger.LogInformation("=== FINISHED TRACK MATCHING ===");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// New matching mode that uses ISRC when available for exact matches.
|
||||
/// Preserves track position for correct playlist ordering.
|
||||
/// </summary>
|
||||
private async Task MatchPlaylistTracksWithIsrcAsync(
|
||||
string playlistName,
|
||||
SpotifyPlaylistFetcher playlistFetcher,
|
||||
IMusicMetadataService metadataService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var matchedTracksKey = $"spotify:matched:ordered:{playlistName}";
|
||||
|
||||
// Get playlist tracks with full metadata including ISRC and position
|
||||
var spotifyTracks = await playlistFetcher.GetPlaylistTracksAsync(playlistName);
|
||||
if (spotifyTracks.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No tracks found for {Playlist}, skipping matching", playlistName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check cache - use snapshot/timestamp to detect changes
|
||||
var existingMatched = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||
if (existingMatched != null && existingMatched.Count == spotifyTracks.Count)
|
||||
{
|
||||
_logger.LogInformation("Playlist {Playlist} already has {Count} matched tracks cached, skipping",
|
||||
playlistName, existingMatched.Count);
|
||||
return;
|
||||
}
|
||||
|
||||
private async Task MatchPlaylistTracksAsync(
|
||||
_logger.LogInformation("Matching {Count} tracks for {Playlist} (ISRC: {IsrcEnabled})",
|
||||
spotifyTracks.Count, playlistName, _spotifyApiSettings.PreferIsrcMatching);
|
||||
|
||||
var matchedTracks = new List<MatchedTrack>();
|
||||
var isrcMatches = 0;
|
||||
var fuzzyMatches = 0;
|
||||
var noMatch = 0;
|
||||
|
||||
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
Song? matchedSong = null;
|
||||
var matchType = "none";
|
||||
|
||||
// Try ISRC match first if available and enabled
|
||||
if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc))
|
||||
{
|
||||
matchedSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService);
|
||||
if (matchedSong != null)
|
||||
{
|
||||
matchType = "isrc";
|
||||
isrcMatches++;
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to fuzzy matching
|
||||
if (matchedSong == null)
|
||||
{
|
||||
matchedSong = await TryMatchByFuzzyAsync(
|
||||
spotifyTrack.Title,
|
||||
spotifyTrack.Artists,
|
||||
metadataService);
|
||||
|
||||
if (matchedSong != null)
|
||||
{
|
||||
matchType = "fuzzy";
|
||||
fuzzyMatches++;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedSong != null)
|
||||
{
|
||||
matchedTracks.Add(new MatchedTrack
|
||||
{
|
||||
Position = spotifyTrack.Position,
|
||||
SpotifyId = spotifyTrack.SpotifyId,
|
||||
SpotifyTitle = spotifyTrack.Title,
|
||||
SpotifyArtist = spotifyTrack.PrimaryArtist,
|
||||
Isrc = spotifyTrack.Isrc,
|
||||
MatchType = matchType,
|
||||
MatchedSong = matchedSong
|
||||
});
|
||||
|
||||
_logger.LogDebug(" #{Position} {Title} - {Artist} → {MatchType} match: {MatchedTitle}",
|
||||
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist,
|
||||
matchType, matchedSong.Title);
|
||||
}
|
||||
else
|
||||
{
|
||||
noMatch++;
|
||||
_logger.LogDebug(" #{Position} {Title} - {Artist} → no match",
|
||||
spotifyTrack.Position, spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
|
||||
spotifyTrack.Title, spotifyTrack.PrimaryArtist);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedTracks.Count > 0)
|
||||
{
|
||||
// Cache matched tracks with position data
|
||||
await _cache.SetAsync(matchedTracksKey, matchedTracks, TimeSpan.FromHours(1));
|
||||
|
||||
// Also update legacy cache for backward compatibility
|
||||
var legacyKey = $"spotify:matched:{playlistName}";
|
||||
var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList();
|
||||
await _cache.SetAsync(legacyKey, legacySongs, TimeSpan.FromHours(1));
|
||||
|
||||
_logger.LogInformation(
|
||||
"✓ Cached {Matched}/{Total} tracks for {Playlist} (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch})",
|
||||
matchedTracks.Count, spotifyTracks.Count, playlistName, isrcMatches, fuzzyMatches, noMatch);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("No tracks matched for {Playlist}", playlistName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to match a track by ISRC using provider search.
|
||||
/// </summary>
|
||||
private async Task<Song?> TryMatchByIsrcAsync(string isrc, IMusicMetadataService metadataService)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Search by ISRC directly - most providers support this
|
||||
var results = await metadataService.SearchSongsAsync($"isrc:{isrc}", limit: 1);
|
||||
if (results.Count > 0 && results[0].Isrc == isrc)
|
||||
{
|
||||
return results[0];
|
||||
}
|
||||
|
||||
// Some providers may not support isrc: prefix, try without
|
||||
results = await metadataService.SearchSongsAsync(isrc, limit: 5);
|
||||
var exactMatch = results.FirstOrDefault(r =>
|
||||
!string.IsNullOrEmpty(r.Isrc) &&
|
||||
r.Isrc.Equals(isrc, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return exactMatch;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to match a track by title and artist using fuzzy matching.
|
||||
/// </summary>
|
||||
private async Task<Song?> TryMatchByFuzzyAsync(
|
||||
string title,
|
||||
List<string> artists,
|
||||
IMusicMetadataService metadataService)
|
||||
{
|
||||
try
|
||||
{
|
||||
var primaryArtist = artists.FirstOrDefault() ?? "";
|
||||
var query = $"{title} {primaryArtist}";
|
||||
var results = await metadataService.SearchSongsAsync(query, limit: 5);
|
||||
|
||||
if (results.Count == 0) return null;
|
||||
|
||||
var bestMatch = results
|
||||
.Select(song => new
|
||||
{
|
||||
Song = song,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarity(title, song.Title),
|
||||
ArtistScore = CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
x.Song,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
TotalScore = (x.TitleScore * 0.6) + (x.ArtistScore * 0.4)
|
||||
})
|
||||
.OrderByDescending(x => x.TotalScore)
|
||||
.FirstOrDefault();
|
||||
|
||||
if (bestMatch != null && bestMatch.TotalScore >= 60)
|
||||
{
|
||||
return bestMatch.Song;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Legacy matching mode using MissingTrack from Jellyfin plugin.
|
||||
/// </summary>
|
||||
private async Task MatchPlaylistTracksLegacyAsync(
|
||||
string playlistName,
|
||||
IMusicMetadataService metadataService,
|
||||
CancellationToken cancellationToken)
|
||||
|
||||
@@ -60,5 +60,14 @@
|
||||
"Enabled": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"SpotifyApi": {
|
||||
"Enabled": false,
|
||||
"ClientId": "",
|
||||
"ClientSecret": "",
|
||||
"SessionCookie": "",
|
||||
"CacheDurationMinutes": 60,
|
||||
"RateLimitDelayMs": 100,
|
||||
"PreferIsrcMatching": true
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user