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"],...]
|
# Format: [["PlaylistName","JellyfinPlaylistId","first|last"],...]
|
||||||
# Note: This format may not work in .env files due to Docker Compose limitations
|
# 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_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 Microsoft.Extensions.Options;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using allstarr.Models.Domain;
|
using allstarr.Models.Domain;
|
||||||
|
using allstarr.Models.Lyrics;
|
||||||
using allstarr.Models.Settings;
|
using allstarr.Models.Settings;
|
||||||
using allstarr.Models.Subsonic;
|
using allstarr.Models.Subsonic;
|
||||||
|
using allstarr.Models.Spotify;
|
||||||
using allstarr.Services;
|
using allstarr.Services;
|
||||||
using allstarr.Services.Common;
|
using allstarr.Services.Common;
|
||||||
using allstarr.Services.Local;
|
using allstarr.Services.Local;
|
||||||
using allstarr.Services.Jellyfin;
|
using allstarr.Services.Jellyfin;
|
||||||
using allstarr.Services.Subsonic;
|
using allstarr.Services.Subsonic;
|
||||||
using allstarr.Services.Lyrics;
|
using allstarr.Services.Lyrics;
|
||||||
|
using allstarr.Services.Spotify;
|
||||||
using allstarr.Filters;
|
using allstarr.Filters;
|
||||||
|
|
||||||
namespace allstarr.Controllers;
|
namespace allstarr.Controllers;
|
||||||
@@ -24,6 +27,7 @@ public class JellyfinController : ControllerBase
|
|||||||
{
|
{
|
||||||
private readonly JellyfinSettings _settings;
|
private readonly JellyfinSettings _settings;
|
||||||
private readonly SpotifyImportSettings _spotifySettings;
|
private readonly SpotifyImportSettings _spotifySettings;
|
||||||
|
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||||
private readonly IMusicMetadataService _metadataService;
|
private readonly IMusicMetadataService _metadataService;
|
||||||
private readonly ILocalLibraryService _localLibraryService;
|
private readonly ILocalLibraryService _localLibraryService;
|
||||||
private readonly IDownloadService _downloadService;
|
private readonly IDownloadService _downloadService;
|
||||||
@@ -32,12 +36,15 @@ public class JellyfinController : ControllerBase
|
|||||||
private readonly JellyfinProxyService _proxyService;
|
private readonly JellyfinProxyService _proxyService;
|
||||||
private readonly JellyfinSessionManager _sessionManager;
|
private readonly JellyfinSessionManager _sessionManager;
|
||||||
private readonly PlaylistSyncService? _playlistSyncService;
|
private readonly PlaylistSyncService? _playlistSyncService;
|
||||||
|
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
|
||||||
|
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
private readonly ILogger<JellyfinController> _logger;
|
private readonly ILogger<JellyfinController> _logger;
|
||||||
|
|
||||||
public JellyfinController(
|
public JellyfinController(
|
||||||
IOptions<JellyfinSettings> settings,
|
IOptions<JellyfinSettings> settings,
|
||||||
IOptions<SpotifyImportSettings> spotifySettings,
|
IOptions<SpotifyImportSettings> spotifySettings,
|
||||||
|
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
||||||
IMusicMetadataService metadataService,
|
IMusicMetadataService metadataService,
|
||||||
ILocalLibraryService localLibraryService,
|
ILocalLibraryService localLibraryService,
|
||||||
IDownloadService downloadService,
|
IDownloadService downloadService,
|
||||||
@@ -47,10 +54,13 @@ public class JellyfinController : ControllerBase
|
|||||||
JellyfinSessionManager sessionManager,
|
JellyfinSessionManager sessionManager,
|
||||||
RedisCacheService cache,
|
RedisCacheService cache,
|
||||||
ILogger<JellyfinController> logger,
|
ILogger<JellyfinController> logger,
|
||||||
PlaylistSyncService? playlistSyncService = null)
|
PlaylistSyncService? playlistSyncService = null,
|
||||||
|
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
|
||||||
|
SpotifyLyricsService? spotifyLyricsService = null)
|
||||||
{
|
{
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
_spotifySettings = spotifySettings.Value;
|
_spotifySettings = spotifySettings.Value;
|
||||||
|
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||||
_metadataService = metadataService;
|
_metadataService = metadataService;
|
||||||
_localLibraryService = localLibraryService;
|
_localLibraryService = localLibraryService;
|
||||||
_downloadService = downloadService;
|
_downloadService = downloadService;
|
||||||
@@ -59,6 +69,8 @@ public class JellyfinController : ControllerBase
|
|||||||
_proxyService = proxyService;
|
_proxyService = proxyService;
|
||||||
_sessionManager = sessionManager;
|
_sessionManager = sessionManager;
|
||||||
_playlistSyncService = playlistSyncService;
|
_playlistSyncService = playlistSyncService;
|
||||||
|
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
|
||||||
|
_spotifyLyricsService = spotifyLyricsService;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
|
||||||
@@ -988,6 +1000,7 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets lyrics for an item.
|
/// Gets lyrics for an item.
|
||||||
|
/// Priority: 1. Jellyfin embedded lyrics, 2. Spotify synced lyrics, 3. LRCLIB
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("Audio/{itemId}/Lyrics")]
|
[HttpGet("Audio/{itemId}/Lyrics")]
|
||||||
[HttpGet("Items/{itemId}/Lyrics")]
|
[HttpGet("Items/{itemId}/Lyrics")]
|
||||||
@@ -1010,19 +1023,21 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
if (jellyfinLyrics != null && statusCode == 200)
|
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()));
|
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;
|
Song? song = null;
|
||||||
|
string? spotifyTrackId = null;
|
||||||
|
|
||||||
if (isExternal)
|
if (isExternal)
|
||||||
{
|
{
|
||||||
song = await _metadataService.GetSongAsync(provider!, externalId!);
|
song = await _metadataService.GetSongAsync(provider!, externalId!);
|
||||||
|
// For Deezer tracks, we'll search Spotify by metadata
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -1038,6 +1053,15 @@ public class JellyfinController : ControllerBase
|
|||||||
Album = item.RootElement.TryGetProperty("Album", out var album) ? album.GetString() ?? "" : "",
|
Album = item.RootElement.TryGetProperty("Album", out var album) ? album.GetString() ?? "" : "",
|
||||||
Duration = item.RootElement.TryGetProperty("RunTimeTicks", out var ticks) ? (int)(ticks.GetInt64() / 10000000) : 0
|
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" });
|
return NotFound(new { error = "Song not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to get lyrics from LRCLIB
|
LyricsInfo? lyrics = null;
|
||||||
_logger.LogInformation("Searching LRCLIB for lyrics: {Artists} - {Title}",
|
|
||||||
song.Artists.Count > 0 ? string.Join(", ", song.Artists) : song.Artist,
|
// Try Spotify lyrics first (better synced lyrics quality)
|
||||||
song.Title);
|
if (_spotifyLyricsService != null && _spotifyApiSettings.Enabled)
|
||||||
var lyricsService = HttpContext.RequestServices.GetService<LrclibService>();
|
|
||||||
if (lyricsService == null)
|
|
||||||
{
|
{
|
||||||
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)
|
if (lyrics == null)
|
||||||
{
|
{
|
||||||
@@ -2725,165 +2782,24 @@ public class JellyfinController : ControllerBase
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets tracks for a Spotify playlist by matching missing tracks against external providers
|
/// Gets tracks for a Spotify playlist by matching missing tracks against external providers
|
||||||
/// and merging with existing local tracks from Jellyfin.
|
/// 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>
|
/// </summary>
|
||||||
private async Task<IActionResult> GetSpotifyPlaylistTracksAsync(string spotifyPlaylistName, string playlistId)
|
private async Task<IActionResult> GetSpotifyPlaylistTracksAsync(string spotifyPlaylistName, string playlistId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var cacheKey = $"spotify:matched:{spotifyPlaylistName}";
|
// Try ordered cache first (from direct Spotify API mode)
|
||||||
var cachedTracks = await _cache.GetAsync<List<Song>>(cacheKey);
|
if (_spotifyApiSettings.Enabled && _spotifyPlaylistFetcher != null)
|
||||||
|
|
||||||
if (cachedTracks != null)
|
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Returning {Count} cached matched tracks for {Playlist}",
|
var orderedResult = await GetSpotifyPlaylistTracksOrderedAsync(spotifyPlaylistName, playlistId);
|
||||||
cachedTracks.Count, spotifyPlaylistName);
|
if (orderedResult != null) return orderedResult;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (missingTracks == null || missingTracks.Count == 0)
|
// Fall back to legacy unordered mode
|
||||||
{
|
return await GetSpotifyPlaylistTracksLegacyAsync(spotifyPlaylistName, playlistId);
|
||||||
_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);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -2891,6 +2807,286 @@ public class JellyfinController : ControllerBase
|
|||||||
return _responseBuilder.CreateError(500, "Failed to get Spotify playlist tracks");
|
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>
|
/// <summary>
|
||||||
/// Copies an external track to the kept folder when favorited.
|
/// 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>
|
/// <summary>
|
||||||
/// Manual trigger endpoint to force fetch Spotify missing tracks.
|
/// Manual trigger endpoint to force fetch Spotify missing tracks.
|
||||||
/// GET /spotify/sync?api_key=YOUR_KEY
|
/// 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)
|
// Register cache cleanup service (only runs when StorageMode is Cache)
|
||||||
builder.Services.AddHostedService<CacheCleanupService>();
|
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>();
|
builder.Services.AddHostedService<allstarr.Services.Spotify.SpotifyMissingTracksFetcher>();
|
||||||
|
|
||||||
// Register Spotify track matching service (pre-matches tracks with rate limiting)
|
// 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;
|
var bodyToSend = body;
|
||||||
if (!string.IsNullOrWhiteSpace(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
|
|
||||||
{
|
{
|
||||||
bodyToSend = "{}";
|
bodyToSend = "{}";
|
||||||
_logger.LogWarning("POST body was empty for {Url}, sending empty JSON object", url);
|
_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;
|
namespace allstarr.Services.Spotify;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Background service that pre-matches Spotify missing tracks with external providers.
|
/// Background service that pre-matches Spotify tracks with external providers.
|
||||||
/// Runs after SpotifyMissingTracksFetcher completes to avoid rate limiting during playlist loading.
|
///
|
||||||
|
/// 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>
|
/// </summary>
|
||||||
public class SpotifyTrackMatchingService : BackgroundService
|
public class SpotifyTrackMatchingService : BackgroundService
|
||||||
{
|
{
|
||||||
private readonly IOptions<SpotifyImportSettings> _spotifySettings;
|
private readonly SpotifyImportSettings _spotifySettings;
|
||||||
|
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
private readonly ILogger<SpotifyTrackMatchingService> _logger;
|
private readonly ILogger<SpotifyTrackMatchingService> _logger;
|
||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
@@ -21,11 +27,13 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
public SpotifyTrackMatchingService(
|
public SpotifyTrackMatchingService(
|
||||||
IOptions<SpotifyImportSettings> spotifySettings,
|
IOptions<SpotifyImportSettings> spotifySettings,
|
||||||
|
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
||||||
RedisCacheService cache,
|
RedisCacheService cache,
|
||||||
IServiceProvider serviceProvider,
|
IServiceProvider serviceProvider,
|
||||||
ILogger<SpotifyTrackMatchingService> logger)
|
ILogger<SpotifyTrackMatchingService> logger)
|
||||||
{
|
{
|
||||||
_spotifySettings = spotifySettings;
|
_spotifySettings = spotifySettings.Value;
|
||||||
|
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -35,11 +43,15 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
{
|
{
|
||||||
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
|
_logger.LogInformation("SpotifyTrackMatchingService: Starting up...");
|
||||||
|
|
||||||
if (!_spotifySettings.Value.Enabled)
|
if (!_spotifySettings.Enabled)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Spotify playlist injection is DISABLED, matching service will not run");
|
_logger.LogInformation("Spotify playlist injection is DISABLED, matching service will not run");
|
||||||
return;
|
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
|
// Wait a bit for the fetcher to run first
|
||||||
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
|
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
|
||||||
@@ -85,7 +97,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
{
|
{
|
||||||
_logger.LogInformation("=== STARTING TRACK MATCHING ===");
|
_logger.LogInformation("=== STARTING TRACK MATCHING ===");
|
||||||
|
|
||||||
var playlists = _spotifySettings.Value.Playlists;
|
var playlists = _spotifySettings.Playlists;
|
||||||
if (playlists.Count == 0)
|
if (playlists.Count == 0)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("No playlists configured for matching");
|
_logger.LogInformation("No playlists configured for matching");
|
||||||
@@ -94,6 +106,13 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
using var scope = _serviceProvider.CreateScope();
|
using var scope = _serviceProvider.CreateScope();
|
||||||
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
|
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)
|
foreach (var playlist in playlists)
|
||||||
{
|
{
|
||||||
@@ -101,7 +120,18 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
try
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -111,8 +141,211 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
_logger.LogInformation("=== FINISHED TRACK MATCHING ===");
|
_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,
|
string playlistName,
|
||||||
IMusicMetadataService metadataService,
|
IMusicMetadataService metadataService,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -60,5 +60,14 @@
|
|||||||
"Enabled": true
|
"Enabled": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
"SpotifyApi": {
|
||||||
|
"Enabled": false,
|
||||||
|
"ClientId": "",
|
||||||
|
"ClientSecret": "",
|
||||||
|
"SessionCookie": "",
|
||||||
|
"CacheDurationMinutes": 60,
|
||||||
|
"RateLimitDelayMs": 100,
|
||||||
|
"PreferIsrcMatching": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user