mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Compare commits
20 Commits
35c125d042
...
f741cc5297
| Author | SHA1 | Date | |
|---|---|---|---|
|
f741cc5297
|
|||
|
1a0e0216f5
|
|||
|
73bd3bf308
|
|||
|
43bf71c390
|
|||
|
2254616d32
|
|||
|
c0444becad
|
|||
|
b906a5fd6d
|
|||
|
e3bcc93597
|
|||
|
7e6bed51e1
|
|||
|
47b9427c20
|
|||
|
bb46db43b1
|
|||
|
3937e637c6
|
|||
|
2272e8d363
|
|||
|
6169d7a4ac
|
|||
|
da8cb29e08
|
|||
|
d88ed64e37
|
|||
|
210d18220b
|
|||
|
c44e48a425
|
|||
|
e44b46aee1
|
|||
|
a75df9328a
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -89,6 +89,9 @@ apis/api-calls/*.json
|
|||||||
!apis/api-calls/jellyfin-openapi-stable.json
|
!apis/api-calls/jellyfin-openapi-stable.json
|
||||||
apis/temp.json
|
apis/temp.json
|
||||||
|
|
||||||
|
# Temporary documentation files
|
||||||
|
apis/*.md
|
||||||
|
|
||||||
# Log files for debugging
|
# Log files for debugging
|
||||||
apis/api-calls/*.log
|
apis/api-calls/*.log
|
||||||
|
|
||||||
|
|||||||
@@ -603,66 +603,57 @@ public class AdminController : ControllerBase
|
|||||||
// Get Spotify tracks
|
// Get Spotify tracks
|
||||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName);
|
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName);
|
||||||
|
|
||||||
// Get the playlist config to find Jellyfin ID
|
|
||||||
var playlistConfig = _spotifyImportSettings.Playlists
|
|
||||||
.FirstOrDefault(p => p.Name.Equals(decodedName, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
var tracksWithStatus = new List<object>();
|
var tracksWithStatus = new List<object>();
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId))
|
// Use the pre-built playlist cache (same as GetPlaylists endpoint)
|
||||||
{
|
// This cache includes all matched tracks with proper provider IDs
|
||||||
// Get existing tracks from Jellyfin to determine local/external status
|
var playlistItemsCacheKey = $"spotify:playlist:items:{decodedName}";
|
||||||
var userId = _jellyfinSettings.UserId;
|
|
||||||
if (!string.IsNullOrEmpty(userId))
|
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
|
||||||
{
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var url = $"{_jellyfinSettings.Url}/Playlists/{playlistConfig.JellyfinId}/Items?UserId={userId}";
|
cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsCacheKey);
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
|
||||||
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
|
|
||||||
|
|
||||||
var response = await _jellyfinHttpClient.SendAsync(request);
|
|
||||||
if (response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
|
||||||
using var doc = JsonDocument.Parse(json);
|
|
||||||
|
|
||||||
// Build list of local tracks (match by name only - no Spotify IDs!)
|
|
||||||
var localTracks = new List<(string Title, string Artist)>();
|
|
||||||
if (doc.RootElement.TryGetProperty("Items", out var items))
|
|
||||||
{
|
|
||||||
foreach (var item in items.EnumerateArray())
|
|
||||||
{
|
|
||||||
var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
|
||||||
var artist = "";
|
|
||||||
|
|
||||||
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
|
||||||
{
|
|
||||||
artist = artistsEl[0].GetString() ?? "";
|
|
||||||
}
|
}
|
||||||
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
catch (Exception cacheEx)
|
||||||
{
|
{
|
||||||
artist = albumArtistEl.GetString() ?? "";
|
_logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", decodedName);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(title))
|
_logger.LogInformation("GetPlaylistTracks for {Playlist}: Cache found: {Found}, Count: {Count}",
|
||||||
|
decodedName, cachedPlaylistItems != null, cachedPlaylistItems?.Count ?? 0);
|
||||||
|
|
||||||
|
if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0)
|
||||||
{
|
{
|
||||||
localTracks.Add((title, artist));
|
// Build a map of Spotify ID -> cached item for quick lookup
|
||||||
|
var spotifyIdToItem = new Dictionary<string, Dictionary<string, object?>>();
|
||||||
|
|
||||||
|
foreach (var item in cachedPlaylistItems)
|
||||||
|
{
|
||||||
|
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
||||||
|
{
|
||||||
|
Dictionary<string, string>? providerIds = null;
|
||||||
|
|
||||||
|
if (providerIdsObj is Dictionary<string, string> dict)
|
||||||
|
{
|
||||||
|
providerIds = dict;
|
||||||
|
}
|
||||||
|
else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
providerIds = new Dictionary<string, string>();
|
||||||
|
foreach (var prop in jsonEl.EnumerateObject())
|
||||||
|
{
|
||||||
|
providerIds[prop.Name] = prop.Value.GetString() ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providerIds != null && providerIds.TryGetValue("Spotify", out var spotifyId) && !string.IsNullOrEmpty(spotifyId))
|
||||||
|
{
|
||||||
|
spotifyIdToItem[spotifyId] = item;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Found {Count} local tracks in Jellyfin playlist {Playlist}",
|
// Match each Spotify track to its cached item
|
||||||
localTracks.Count, decodedName);
|
|
||||||
|
|
||||||
// Get matched external tracks cache
|
|
||||||
var matchedTracksKey = $"spotify:matched:ordered:{decodedName}";
|
|
||||||
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
|
||||||
var matchedSpotifyIds = new HashSet<string>(
|
|
||||||
matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Match Spotify tracks to local tracks by name (fuzzy matching)
|
|
||||||
foreach (var track in spotifyTracks)
|
foreach (var track in spotifyTracks)
|
||||||
{
|
{
|
||||||
bool? isLocal = null;
|
bool? isLocal = null;
|
||||||
@@ -671,23 +662,92 @@ public class AdminController : ControllerBase
|
|||||||
string? manualMappingType = null;
|
string? manualMappingType = null;
|
||||||
string? manualMappingId = null;
|
string? manualMappingId = null;
|
||||||
|
|
||||||
// FIRST: Check for manual Jellyfin mapping
|
if (spotifyIdToItem.TryGetValue(track.SpotifyId, out var cachedItem))
|
||||||
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
|
|
||||||
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
|
||||||
{
|
{
|
||||||
// Manual Jellyfin mapping exists - this track is definitely local
|
// Track is in the cache - determine if it's local or external
|
||||||
isLocal = true;
|
if (cachedItem.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
||||||
isManualMapping = true;
|
{
|
||||||
manualMappingType = "jellyfin";
|
Dictionary<string, string>? providerIds = null;
|
||||||
manualMappingId = manualJellyfinId;
|
|
||||||
_logger.LogDebug("✓ Manual Jellyfin mapping found for {Title}: Jellyfin ID {Id}",
|
if (providerIdsObj is Dictionary<string, string> dict)
|
||||||
track.Title, manualJellyfinId);
|
{
|
||||||
|
providerIds = dict;
|
||||||
|
}
|
||||||
|
else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
|
||||||
|
{
|
||||||
|
providerIds = new Dictionary<string, string>();
|
||||||
|
foreach (var prop in jsonEl.EnumerateObject())
|
||||||
|
{
|
||||||
|
providerIds[prop.Name] = prop.Value.GetString() ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providerIds != null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Track {Title} has ProviderIds: {Keys}", track.Title, string.Join(", ", providerIds.Keys));
|
||||||
|
|
||||||
|
// Check for external provider keys (case-insensitive)
|
||||||
|
// External providers: squidwtf, deezer, qobuz, tidal (lowercase)
|
||||||
|
var providerKey = providerIds.Keys.FirstOrDefault(k =>
|
||||||
|
k.Equals("squidwtf", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
k.Equals("SquidWTF", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (providerKey != null)
|
||||||
|
{
|
||||||
|
isLocal = false;
|
||||||
|
externalProvider = "SquidWTF";
|
||||||
|
_logger.LogDebug("✓ Track {Title} identified as SquidWTF", track.Title);
|
||||||
|
}
|
||||||
|
else if ((providerKey = providerIds.Keys.FirstOrDefault(k => k.Equals("deezer", StringComparison.OrdinalIgnoreCase))) != null)
|
||||||
|
{
|
||||||
|
isLocal = false;
|
||||||
|
externalProvider = "Deezer";
|
||||||
|
_logger.LogDebug("✓ Track {Title} identified as Deezer", track.Title);
|
||||||
|
}
|
||||||
|
else if ((providerKey = providerIds.Keys.FirstOrDefault(k => k.Equals("qobuz", StringComparison.OrdinalIgnoreCase))) != null)
|
||||||
|
{
|
||||||
|
isLocal = false;
|
||||||
|
externalProvider = "Qobuz";
|
||||||
|
_logger.LogDebug("✓ Track {Title} identified as Qobuz", track.Title);
|
||||||
|
}
|
||||||
|
else if ((providerKey = providerIds.Keys.FirstOrDefault(k => k.Equals("tidal", StringComparison.OrdinalIgnoreCase))) != null)
|
||||||
|
{
|
||||||
|
isLocal = false;
|
||||||
|
externalProvider = "Tidal";
|
||||||
|
_logger.LogDebug("✓ Track {Title} identified as Tidal", track.Title);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// No external provider key found - it's a local track
|
||||||
|
// Local tracks have MusicBrainz, ISRC, Spotify IDs but no external provider
|
||||||
|
isLocal = true;
|
||||||
|
_logger.LogDebug("✓ Track {Title} identified as LOCAL (has ProviderIds but no external provider)", track.Title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Track {Title} has ProviderIds object but it's null after parsing", track.Title);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Track {Title} in cache but has NO ProviderIds - treating as missing", track.Title);
|
||||||
|
isLocal = null;
|
||||||
|
externalProvider = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a manual mapping
|
||||||
|
var manualJellyfinKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
|
||||||
|
var manualJellyfinId = await _cache.GetAsync<string>(manualJellyfinKey);
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||||
|
{
|
||||||
|
isManualMapping = true;
|
||||||
|
manualMappingType = "jellyfin";
|
||||||
|
manualMappingId = manualJellyfinId;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Check for external manual mapping
|
|
||||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||||
|
|
||||||
@@ -698,91 +758,25 @@ public class AdminController : ControllerBase
|
|||||||
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
using var extDoc = JsonDocument.Parse(externalMappingJson);
|
||||||
var extRoot = extDoc.RootElement;
|
var extRoot = extDoc.RootElement;
|
||||||
|
|
||||||
string? provider = null;
|
|
||||||
string? externalId = null;
|
|
||||||
|
|
||||||
if (extRoot.TryGetProperty("provider", out var providerEl))
|
|
||||||
{
|
|
||||||
provider = providerEl.GetString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (extRoot.TryGetProperty("id", out var idEl))
|
if (extRoot.TryGetProperty("id", out var idEl))
|
||||||
{
|
{
|
||||||
externalId = idEl.GetString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
|
|
||||||
{
|
|
||||||
// External manual mapping exists
|
|
||||||
isLocal = false;
|
|
||||||
externalProvider = provider;
|
|
||||||
isManualMapping = true;
|
isManualMapping = true;
|
||||||
manualMappingType = "external";
|
manualMappingType = "external";
|
||||||
manualMappingId = externalId;
|
manualMappingId = idEl.GetString();
|
||||||
_logger.LogDebug("✓ Manual external mapping found for {Title}: {Provider} {ExternalId}",
|
|
||||||
track.Title, provider, externalId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch { }
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (localTracks.Count > 0)
|
|
||||||
{
|
|
||||||
// SECOND: No manual mapping, try fuzzy matching with local tracks
|
|
||||||
var bestMatch = localTracks
|
|
||||||
.Select(local => new
|
|
||||||
{
|
|
||||||
Local = local,
|
|
||||||
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, local.Title),
|
|
||||||
ArtistScore = FuzzyMatcher.CalculateSimilarity(track.PrimaryArtist, local.Artist)
|
|
||||||
})
|
|
||||||
.Select(x => new
|
|
||||||
{
|
|
||||||
x.Local,
|
|
||||||
x.TitleScore,
|
|
||||||
x.ArtistScore,
|
|
||||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
|
||||||
})
|
|
||||||
.OrderByDescending(x => x.TotalScore)
|
|
||||||
.FirstOrDefault();
|
|
||||||
|
|
||||||
// Use 70% threshold (same as playback matching)
|
|
||||||
if (bestMatch != null && bestMatch.TotalScore >= 70)
|
|
||||||
{
|
|
||||||
isLocal = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not local, check if it's externally matched or missing
|
|
||||||
if (isLocal != true)
|
|
||||||
{
|
|
||||||
// Check if there's a manual external mapping
|
|
||||||
if (isManualMapping && manualMappingType == "external")
|
|
||||||
{
|
|
||||||
// Track has manual external mapping - it's available externally
|
|
||||||
isLocal = false;
|
|
||||||
// externalProvider already set above
|
|
||||||
}
|
|
||||||
else if (matchedSpotifyIds.Contains(track.SpotifyId))
|
|
||||||
{
|
|
||||||
// Track is externally matched (search succeeded)
|
|
||||||
isLocal = false;
|
|
||||||
externalProvider = "SquidWTF"; // Default to SquidWTF for external matches
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Track is missing (search failed)
|
// Track not in cache - it's missing
|
||||||
isLocal = null;
|
isLocal = null;
|
||||||
externalProvider = null;
|
externalProvider = null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Check lyrics status (only from our cache - lrclib/Spotify)
|
// Check lyrics status
|
||||||
// Note: For local tracks, Jellyfin may have embedded lyrics that we don't check here
|
|
||||||
// Those will be served directly by Jellyfin when requested
|
|
||||||
var cacheKey = $"lyrics:{track.PrimaryArtist}:{track.Title}:{track.Album}:{track.DurationMs / 1000}";
|
var cacheKey = $"lyrics:{track.PrimaryArtist}:{track.Title}:{track.Album}:{track.DurationMs / 1000}";
|
||||||
var existingLyrics = await _cache.GetStringAsync(cacheKey);
|
var existingLyrics = await _cache.GetStringAsync(cacheKey);
|
||||||
var hasLyrics = !string.IsNullOrEmpty(existingLyrics);
|
var hasLyrics = !string.IsNullOrEmpty(existingLyrics);
|
||||||
@@ -799,7 +793,7 @@ public class AdminController : ControllerBase
|
|||||||
albumArtUrl = track.AlbumArtUrl,
|
albumArtUrl = track.AlbumArtUrl,
|
||||||
isLocal = isLocal,
|
isLocal = isLocal,
|
||||||
externalProvider = externalProvider,
|
externalProvider = externalProvider,
|
||||||
searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null, // Set for both external and missing
|
searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null,
|
||||||
isManualMapping = isManualMapping,
|
isManualMapping = isManualMapping,
|
||||||
manualMappingType = manualMappingType,
|
manualMappingType = manualMappingType,
|
||||||
manualMappingId = manualMappingId,
|
manualMappingId = manualMappingId,
|
||||||
@@ -814,25 +808,16 @@ public class AdminController : ControllerBase
|
|||||||
tracks = tracksWithStatus
|
tracks = tracksWithStatus
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Failed to get local track status for {Playlist}", decodedName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we get here, we couldn't get local tracks from Jellyfin
|
// Fallback: Cache not available, use matched tracks cache
|
||||||
// Just return tracks with basic external/missing status based on cache
|
_logger.LogWarning("Playlist cache not available for {Playlist}, using fallback", decodedName);
|
||||||
|
|
||||||
var fallbackMatchedTracksKey = $"spotify:matched:ordered:{decodedName}";
|
var fallbackMatchedTracksKey = $"spotify:matched:ordered:{decodedName}";
|
||||||
var fallbackMatchedTracks = await _cache.GetAsync<List<MatchedTrack>>(fallbackMatchedTracksKey);
|
var fallbackMatchedTracks = await _cache.GetAsync<List<MatchedTrack>>(fallbackMatchedTracksKey);
|
||||||
var fallbackMatchedSpotifyIds = new HashSet<string>(
|
var fallbackMatchedSpotifyIds = new HashSet<string>(
|
||||||
fallbackMatchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
|
fallbackMatchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Clear and reuse tracksWithStatus for fallback
|
|
||||||
tracksWithStatus.Clear();
|
|
||||||
|
|
||||||
foreach (var track in spotifyTracks)
|
foreach (var track in spotifyTracks)
|
||||||
{
|
{
|
||||||
bool? isLocal = null;
|
bool? isLocal = null;
|
||||||
@@ -879,13 +864,11 @@ public class AdminController : ControllerBase
|
|||||||
}
|
}
|
||||||
else if (fallbackMatchedSpotifyIds.Contains(track.SpotifyId))
|
else if (fallbackMatchedSpotifyIds.Contains(track.SpotifyId))
|
||||||
{
|
{
|
||||||
// Track is externally matched (search succeeded)
|
|
||||||
isLocal = false;
|
isLocal = false;
|
||||||
externalProvider = "SquidWTF"; // Default to SquidWTF for external matches
|
externalProvider = "SquidWTF";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Track is missing (search failed)
|
|
||||||
isLocal = null;
|
isLocal = null;
|
||||||
externalProvider = null;
|
externalProvider = null;
|
||||||
}
|
}
|
||||||
@@ -903,7 +886,7 @@ public class AdminController : ControllerBase
|
|||||||
albumArtUrl = track.AlbumArtUrl,
|
albumArtUrl = track.AlbumArtUrl,
|
||||||
isLocal = isLocal,
|
isLocal = isLocal,
|
||||||
externalProvider = externalProvider,
|
externalProvider = externalProvider,
|
||||||
searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null // Set for both external and missing
|
searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ public class JellyfinController : ControllerBase
|
|||||||
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
|
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
|
||||||
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
||||||
private readonly LrclibService? _lrclibService;
|
private readonly LrclibService? _lrclibService;
|
||||||
|
private readonly OdesliService _odesliService;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly ILogger<JellyfinController> _logger;
|
private readonly ILogger<JellyfinController> _logger;
|
||||||
@@ -55,6 +56,7 @@ public class JellyfinController : ControllerBase
|
|||||||
JellyfinModelMapper modelMapper,
|
JellyfinModelMapper modelMapper,
|
||||||
JellyfinProxyService proxyService,
|
JellyfinProxyService proxyService,
|
||||||
JellyfinSessionManager sessionManager,
|
JellyfinSessionManager sessionManager,
|
||||||
|
OdesliService odesliService,
|
||||||
RedisCacheService cache,
|
RedisCacheService cache,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
ILogger<JellyfinController> logger,
|
ILogger<JellyfinController> logger,
|
||||||
@@ -79,6 +81,7 @@ public class JellyfinController : ControllerBase
|
|||||||
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
|
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
|
||||||
_spotifyLyricsService = spotifyLyricsService;
|
_spotifyLyricsService = spotifyLyricsService;
|
||||||
_lrclibService = lrclibService;
|
_lrclibService = lrclibService;
|
||||||
|
_odesliService = odesliService;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -276,63 +279,71 @@ public class JellyfinController : ControllerBase
|
|||||||
// Parse Jellyfin results into domain models
|
// Parse Jellyfin results into domain models
|
||||||
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
|
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
|
||||||
|
|
||||||
// Score and filter Jellyfin results by relevance
|
// Respect source ordering (SquidWTF/Tidal has better search ranking than our fuzzy matching)
|
||||||
var scoredLocalSongs = ScoreSearchResults(cleanQuery, localSongs, s => s.Title, s => s.Artist, s => s.Album, isExternal: false);
|
// Just interleave local and external results based on which source has better overall match
|
||||||
var scoredLocalAlbums = ScoreSearchResults(cleanQuery, localAlbums, a => a.Title, a => a.Artist, _ => null, isExternal: false);
|
|
||||||
var scoredLocalArtists = ScoreSearchResults(cleanQuery, localArtists, a => a.Name, _ => null, _ => null, isExternal: false);
|
|
||||||
|
|
||||||
// Score external results with a small boost
|
// Calculate average match score for each source to determine which should come first
|
||||||
var scoredExternalSongs = ScoreSearchResults(cleanQuery, externalResult.Songs, s => s.Title, s => s.Artist, s => s.Album, isExternal: true);
|
var localSongsAvgScore = localSongs.Any()
|
||||||
var scoredExternalAlbums = ScoreSearchResults(cleanQuery, externalResult.Albums, a => a.Title, a => a.Artist, _ => null, isExternal: true);
|
? localSongs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
|
||||||
var scoredExternalArtists = ScoreSearchResults(cleanQuery, externalResult.Artists, a => a.Name, _ => null, _ => null, isExternal: true);
|
: 0.0;
|
||||||
|
var externalSongsAvgScore = externalResult.Songs.Any()
|
||||||
|
? externalResult.Songs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
// Merge and sort by score (no filtering - just reorder by relevance)
|
var localAlbumsAvgScore = localAlbums.Any()
|
||||||
var allSongs = scoredLocalSongs.Concat(scoredExternalSongs)
|
? localAlbums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
|
||||||
.OrderByDescending(x => x.Score)
|
: 0.0;
|
||||||
.Select(x => x.Item)
|
var externalAlbumsAvgScore = externalResult.Albums.Any()
|
||||||
.ToList();
|
? externalResult.Albums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
var allAlbums = scoredLocalAlbums.Concat(scoredExternalAlbums)
|
var localArtistsAvgScore = localArtists.Any()
|
||||||
.OrderByDescending(x => x.Score)
|
? localArtists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
|
||||||
.Select(x => x.Item)
|
: 0.0;
|
||||||
.ToList();
|
var externalArtistsAvgScore = externalResult.Artists.Any()
|
||||||
|
? externalResult.Artists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
// NO deduplication - just merge and sort by relevance score
|
// Interleave results: put better-matching source first, preserve original ordering within each source
|
||||||
// Show ALL matches (local + external) sorted by best match first
|
var allSongs = localSongsAvgScore >= externalSongsAvgScore
|
||||||
var artistScores = scoredLocalArtists.Concat(scoredExternalArtists)
|
? localSongs.Concat(externalResult.Songs).ToList()
|
||||||
.OrderByDescending(x => x.Score)
|
: externalResult.Songs.Concat(localSongs).ToList();
|
||||||
.Select(x => x.Item)
|
|
||||||
.ToList();
|
var allAlbums = localAlbumsAvgScore >= externalAlbumsAvgScore
|
||||||
|
? localAlbums.Concat(externalResult.Albums).ToList()
|
||||||
|
: externalResult.Albums.Concat(localAlbums).ToList();
|
||||||
|
|
||||||
|
var allArtists = localArtistsAvgScore >= externalArtistsAvgScore
|
||||||
|
? localArtists.Concat(externalResult.Artists).ToList()
|
||||||
|
: externalResult.Artists.Concat(localArtists).ToList();
|
||||||
|
|
||||||
// Log results for debugging
|
// Log results for debugging
|
||||||
if (_logger.IsEnabled(LogLevel.Debug))
|
if (_logger.IsEnabled(LogLevel.Debug))
|
||||||
{
|
{
|
||||||
var localArtistNames = scoredLocalArtists.Select(x => $"{x.Item.Name} (local, score: {x.Score:F2})").ToList();
|
_logger.LogDebug("🎵 Songs: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
|
||||||
var externalArtistNames = scoredExternalArtists.Select(x => $"{x.Item.Name} ({x.Item.ExternalProvider}, score: {x.Score:F2})").ToList();
|
localSongsAvgScore, externalSongsAvgScore, localSongsAvgScore >= externalSongsAvgScore);
|
||||||
_logger.LogDebug("🎤 Artist results: Local={LocalArtists}, External={ExternalArtists}, Total={TotalCount}",
|
_logger.LogDebug("💿 Albums: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
|
||||||
string.Join(", ", localArtistNames),
|
localAlbumsAvgScore, externalAlbumsAvgScore, localAlbumsAvgScore >= externalAlbumsAvgScore);
|
||||||
string.Join(", ", externalArtistNames),
|
_logger.LogDebug("🎤 Artists: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
|
||||||
artistScores.Count);
|
localArtistsAvgScore, externalArtistsAvgScore, localArtistsAvgScore >= externalArtistsAvgScore);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to Jellyfin format
|
// Convert to Jellyfin format
|
||||||
var mergedSongs = allSongs.Select(s => _responseBuilder.ConvertSongToJellyfinItem(s)).ToList();
|
var mergedSongs = allSongs.Select(s => _responseBuilder.ConvertSongToJellyfinItem(s)).ToList();
|
||||||
var mergedAlbums = allAlbums.Select(a => _responseBuilder.ConvertAlbumToJellyfinItem(a)).ToList();
|
var mergedAlbums = allAlbums.Select(a => _responseBuilder.ConvertAlbumToJellyfinItem(a)).ToList();
|
||||||
var mergedArtists = artistScores.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
|
var mergedArtists = allArtists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
|
||||||
|
|
||||||
// Add playlists (score them too)
|
// Add playlists (preserve their order too)
|
||||||
if (playlistResult.Count > 0)
|
if (playlistResult.Count > 0)
|
||||||
{
|
{
|
||||||
var scoredPlaylists = playlistResult
|
var playlistItems = playlistResult
|
||||||
.Select(p => new { Playlist = p, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, p.Name) })
|
.Select(p => _responseBuilder.ConvertPlaylistToJellyfinItem(p))
|
||||||
.OrderByDescending(x => x.Score)
|
|
||||||
.Select(x => _responseBuilder.ConvertPlaylistToJellyfinItem(x.Playlist))
|
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
mergedAlbums.AddRange(scoredPlaylists);
|
mergedAlbums.AddRange(playlistItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("Scored and filtered results: Songs={Songs}, Albums={Albums}, Artists={Artists}",
|
_logger.LogInformation("Merged results (preserving source order): Songs={Songs}, Albums={Albums}, Artists={Artists}",
|
||||||
mergedSongs.Count, mergedAlbums.Count, mergedArtists.Count);
|
mergedSongs.Count, mergedAlbums.Count, mergedArtists.Count);
|
||||||
|
|
||||||
// Pre-fetch lyrics for top 3 songs in background (don't await)
|
// Pre-fetch lyrics for top 3 songs in background (don't await)
|
||||||
@@ -1165,9 +1176,15 @@ public class JellyfinController : ControllerBase
|
|||||||
{
|
{
|
||||||
song = await _metadataService.GetSongAsync(provider!, externalId!);
|
song = await _metadataService.GetSongAsync(provider!, externalId!);
|
||||||
|
|
||||||
// Try to find Spotify ID from matched tracks cache
|
// Use Spotify ID from song metadata if available (populated during GetSongAsync)
|
||||||
// External tracks from playlists should have been matched and cached
|
if (song != null && !string.IsNullOrEmpty(song.SpotifyId))
|
||||||
if (song != null)
|
{
|
||||||
|
spotifyTrackId = song.SpotifyId;
|
||||||
|
_logger.LogInformation("Using Spotify ID {SpotifyId} from song metadata for {Provider}/{ExternalId}",
|
||||||
|
spotifyTrackId, provider, externalId);
|
||||||
|
}
|
||||||
|
// Fallback: Try to find Spotify ID from matched tracks cache
|
||||||
|
else if (song != null)
|
||||||
{
|
{
|
||||||
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
|
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
|
||||||
if (!string.IsNullOrEmpty(spotifyTrackId))
|
if (!string.IsNullOrEmpty(spotifyTrackId))
|
||||||
@@ -1177,9 +1194,27 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// If no cached Spotify ID, try to convert via Odesli/song.link
|
// Last resort: Try to convert via Odesli/song.link
|
||||||
// This works for SquidWTF (Tidal), Deezer, Qobuz, etc.
|
if (provider == "squidwtf")
|
||||||
spotifyTrackId = await ConvertToSpotifyIdViaOdesliAsync(song, provider!, externalId!);
|
{
|
||||||
|
spotifyTrackId = await _odesliService.ConvertTidalToSpotifyIdAsync(externalId!, HttpContext.RequestAborted);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// For other providers, build the URL and convert
|
||||||
|
var sourceUrl = provider?.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"deezer" => $"https://www.deezer.com/track/{externalId}",
|
||||||
|
"qobuz" => $"https://www.qobuz.com/us-en/album/-/-/{externalId}",
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(sourceUrl))
|
||||||
|
{
|
||||||
|
spotifyTrackId = await _odesliService.ConvertUrlToSpotifyIdAsync(sourceUrl, HttpContext.RequestAborted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(spotifyTrackId))
|
if (!string.IsNullOrEmpty(spotifyTrackId))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Converted {Provider}/{ExternalId} to Spotify ID {SpotifyId} via Odesli",
|
_logger.LogInformation("Converted {Provider}/{ExternalId} to Spotify ID {SpotifyId} via Odesli",
|
||||||
@@ -1404,9 +1439,9 @@ public class JellyfinController : ControllerBase
|
|||||||
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
|
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
|
||||||
|
|
||||||
// If no cached Spotify ID, try Odesli conversion
|
// If no cached Spotify ID, try Odesli conversion
|
||||||
if (string.IsNullOrEmpty(spotifyTrackId))
|
if (string.IsNullOrEmpty(spotifyTrackId) && provider == "squidwtf")
|
||||||
{
|
{
|
||||||
spotifyTrackId = await ConvertToSpotifyIdViaOdesliAsync(song, provider, externalId);
|
spotifyTrackId = await _odesliService.ConvertTidalToSpotifyIdAsync(externalId, HttpContext.RequestAborted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1830,26 +1865,43 @@ public class JellyfinController : ControllerBase
|
|||||||
_logger.LogInformation("Authentication request received");
|
_logger.LogInformation("Authentication request received");
|
||||||
// DO NOT log request body or detailed headers - contains password
|
// DO NOT log request body or detailed headers - contains password
|
||||||
|
|
||||||
// Forward to Jellyfin server with client headers
|
// Forward to Jellyfin server with client headers - completely transparent proxy
|
||||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Users/AuthenticateByName", body, Request.Headers);
|
var (result, statusCode) = await _proxyService.PostJsonAsync("Users/AuthenticateByName", body, Request.Headers);
|
||||||
|
|
||||||
if (result == null)
|
// Pass through Jellyfin's response exactly as-is (transparent proxy)
|
||||||
|
if (result != null)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Authentication failed - status {StatusCode}", statusCode);
|
var responseJson = result.RootElement.GetRawText();
|
||||||
if (statusCode == 401)
|
|
||||||
{
|
|
||||||
return Unauthorized(new { error = "Invalid username or password" });
|
|
||||||
}
|
|
||||||
return StatusCode(statusCode, new { error = "Authentication failed" });
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// On successful auth, extract access token and post session capabilities in background
|
||||||
|
if (statusCode == 200)
|
||||||
|
{
|
||||||
_logger.LogInformation("Authentication successful");
|
_logger.LogInformation("Authentication successful");
|
||||||
|
|
||||||
// Post session capabilities immediately after authentication
|
// Extract access token from response for session capabilities
|
||||||
// This ensures Jellyfin creates a session that will show up in the dashboard
|
string? accessToken = null;
|
||||||
|
if (result.RootElement.TryGetProperty("AccessToken", out var tokenEl))
|
||||||
|
{
|
||||||
|
accessToken = tokenEl.GetString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post session capabilities in background if we have a token
|
||||||
|
if (!string.IsNullOrEmpty(accessToken))
|
||||||
|
{
|
||||||
|
// Capture token in closure - don't use Request.Headers (will be disposed)
|
||||||
|
var token = accessToken;
|
||||||
|
_ = Task.Run(async () =>
|
||||||
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_logger.LogInformation("🔧 Posting session capabilities after authentication");
|
_logger.LogDebug("🔧 Posting session capabilities after authentication");
|
||||||
|
|
||||||
|
// Build auth header with the new token
|
||||||
|
var authHeaders = new HeaderDictionary
|
||||||
|
{
|
||||||
|
["X-Emby-Token"] = token
|
||||||
|
};
|
||||||
|
|
||||||
var capabilities = new
|
var capabilities = new
|
||||||
{
|
{
|
||||||
PlayableMediaTypes = new[] { "Audio" },
|
PlayableMediaTypes = new[] { "Audio" },
|
||||||
@@ -1860,23 +1912,36 @@ public class JellyfinController : ControllerBase
|
|||||||
};
|
};
|
||||||
|
|
||||||
var capabilitiesJson = JsonSerializer.Serialize(capabilities);
|
var capabilitiesJson = JsonSerializer.Serialize(capabilities);
|
||||||
var (capResult, capStatus) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", capabilitiesJson, Request.Headers);
|
var (capResult, capStatus) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", capabilitiesJson, authHeaders);
|
||||||
|
|
||||||
if (capStatus == 204 || capStatus == 200)
|
if (capStatus == 204 || capStatus == 200)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("✓ Session capabilities posted after auth ({StatusCode})", capStatus);
|
_logger.LogDebug("✓ Session capabilities posted after auth ({StatusCode})", capStatus);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("⚠ Session capabilities returned {StatusCode} after auth", capStatus);
|
_logger.LogDebug("⚠ Session capabilities returned {StatusCode} after auth", capStatus);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to post session capabilities after auth, continuing anyway");
|
_logger.LogDebug(ex, "Failed to post session capabilities after auth");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Authentication failed - status {StatusCode}", statusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Content(result.RootElement.GetRawText(), "application/json");
|
// Return Jellyfin's exact response
|
||||||
|
return Content(responseJson, "application/json");
|
||||||
|
}
|
||||||
|
|
||||||
|
// No response body from Jellyfin - return status code only
|
||||||
|
_logger.LogWarning("Authentication request returned {StatusCode} with no response body", statusCode);
|
||||||
|
return StatusCode(statusCode);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -2221,35 +2286,37 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// CRITICAL: Create session for external tracks too!
|
// Create a ghost/fake item to report to Jellyfin so "Now Playing" shows up
|
||||||
// Even though Jellyfin doesn't know about the track, we need a session
|
// Generate a deterministic UUID from the external ID
|
||||||
// for the client to appear in the dashboard and receive remote control commands
|
var ghostUuid = GenerateUuidFromString(itemId);
|
||||||
if (!string.IsNullOrEmpty(deviceId))
|
|
||||||
{
|
|
||||||
_logger.LogInformation("🔧 SESSION: Creating session for external track playback");
|
|
||||||
var sessionCreated = await _sessionManager.EnsureSessionAsync(
|
|
||||||
deviceId,
|
|
||||||
client ?? "Unknown",
|
|
||||||
device ?? "Unknown",
|
|
||||||
version ?? "1.0",
|
|
||||||
Request.Headers);
|
|
||||||
|
|
||||||
if (sessionCreated)
|
// Build minimal playback start with just the ghost UUID
|
||||||
|
// Don't include the Item object - Jellyfin will just track the session without item details
|
||||||
|
var playbackStart = new
|
||||||
{
|
{
|
||||||
_logger.LogInformation("✓ SESSION: Session created for external track playback on device {DeviceId}", deviceId);
|
ItemId = ghostUuid,
|
||||||
|
PositionTicks = positionTicks ?? 0,
|
||||||
|
CanSeek = true,
|
||||||
|
IsPaused = false,
|
||||||
|
IsMuted = false,
|
||||||
|
PlayMethod = "DirectPlay"
|
||||||
|
};
|
||||||
|
|
||||||
|
var playbackJson = JsonSerializer.Serialize(playbackStart);
|
||||||
|
_logger.LogDebug("📤 Sending ghost playback start for external track: {Json}", playbackJson);
|
||||||
|
|
||||||
|
// Forward to Jellyfin with ghost UUID
|
||||||
|
var (ghostResult, ghostStatusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", playbackJson, Request.Headers);
|
||||||
|
|
||||||
|
if (ghostStatusCode == 204 || ghostStatusCode == 200)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("✓ Ghost playback start forwarded to Jellyfin for external track ({StatusCode})", ghostStatusCode);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("⚠️ SESSION: Failed to create session for external track playback");
|
_logger.LogWarning("⚠️ Ghost playback start returned status {StatusCode} for external track", ghostStatusCode);
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogWarning("⚠️ SESSION: No device ID found for external track playback");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For external tracks, we can't report playback to Jellyfin since it doesn't know about them
|
|
||||||
// But the session is now active and will appear in the dashboard
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2402,11 +2469,24 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
if (isExternal)
|
if (isExternal)
|
||||||
{
|
{
|
||||||
// For external tracks, update session activity to keep it alive
|
// For external tracks, report progress with ghost UUID to Jellyfin
|
||||||
// This ensures the session remains visible in Jellyfin dashboard
|
var ghostUuid = GenerateUuidFromString(itemId);
|
||||||
if (!string.IsNullOrEmpty(deviceId))
|
|
||||||
|
// Build progress report with ghost UUID
|
||||||
|
var progressReport = new
|
||||||
{
|
{
|
||||||
_sessionManager.UpdateActivity(deviceId);
|
ItemId = ghostUuid,
|
||||||
|
PositionTicks = positionTicks ?? 0,
|
||||||
|
IsPaused = false,
|
||||||
|
IsMuted = false,
|
||||||
|
CanSeek = true,
|
||||||
|
PlayMethod = "DirectPlay"
|
||||||
|
};
|
||||||
|
|
||||||
|
var progressJson = JsonSerializer.Serialize(progressReport);
|
||||||
|
|
||||||
|
// Forward to Jellyfin with ghost UUID
|
||||||
|
var (progressResult, progressStatusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Progress", progressJson, Request.Headers);
|
||||||
|
|
||||||
// Log progress occasionally for debugging (every ~30 seconds)
|
// Log progress occasionally for debugging (every ~30 seconds)
|
||||||
if (positionTicks.HasValue)
|
if (positionTicks.HasValue)
|
||||||
@@ -2414,13 +2494,11 @@ public class JellyfinController : ControllerBase
|
|||||||
var position = TimeSpan.FromTicks(positionTicks.Value);
|
var position = TimeSpan.FromTicks(positionTicks.Value);
|
||||||
if (position.Seconds % 30 == 0 && position.Milliseconds < 500)
|
if (position.Seconds % 30 == 0 && position.Milliseconds < 500)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("▶️ External track progress: {Position:mm\\:ss} ({Provider}/{ExternalId})",
|
_logger.LogDebug("▶️ External track progress: {Position:mm\\:ss} ({Provider}/{ExternalId}) - Status: {StatusCode}",
|
||||||
position, provider, externalId);
|
position, provider, externalId, progressStatusCode);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Just acknowledge (no reporting to Jellyfin for external tracks)
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2437,6 +2515,8 @@ public class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For local tracks, forward to Jellyfin
|
// For local tracks, forward to Jellyfin
|
||||||
|
_logger.LogDebug("📤 Sending playback progress body: {Body}", body);
|
||||||
|
|
||||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Progress", body, Request.Headers);
|
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Progress", body, Request.Headers);
|
||||||
|
|
||||||
if (statusCode != 204 && statusCode != 200)
|
if (statusCode != 204 && statusCode != 200)
|
||||||
@@ -2511,11 +2591,23 @@ public class JellyfinController : ControllerBase
|
|||||||
_logger.LogInformation("🎵 External track playback stopped: {Name} at {Position} ({Provider}/{ExternalId})",
|
_logger.LogInformation("🎵 External track playback stopped: {Name} at {Position} ({Provider}/{ExternalId})",
|
||||||
itemName ?? "Unknown", position, provider, externalId);
|
itemName ?? "Unknown", position, provider, externalId);
|
||||||
|
|
||||||
// Mark session as potentially ended after playback stops
|
// Report stop to Jellyfin with ghost UUID
|
||||||
// Wait 50 seconds for next song to start before cleaning up
|
var ghostUuid = GenerateUuidFromString(itemId);
|
||||||
if (!string.IsNullOrEmpty(deviceId))
|
|
||||||
|
var stopInfo = new
|
||||||
{
|
{
|
||||||
_sessionManager.MarkSessionPotentiallyEnded(deviceId, TimeSpan.FromSeconds(50));
|
ItemId = ghostUuid,
|
||||||
|
PositionTicks = positionTicks ?? 0
|
||||||
|
};
|
||||||
|
|
||||||
|
var stopJson = JsonSerializer.Serialize(stopInfo);
|
||||||
|
_logger.LogDebug("📤 Sending ghost playback stop for external track: {Json}", stopJson);
|
||||||
|
|
||||||
|
var (stopResult, stopStatusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, Request.Headers);
|
||||||
|
|
||||||
|
if (stopStatusCode == 204 || stopStatusCode == 200)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("✓ Ghost playback stop forwarded to Jellyfin ({StatusCode})", stopStatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
@@ -2527,6 +2619,24 @@ public class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
// For local tracks, forward to Jellyfin
|
// For local tracks, forward to Jellyfin
|
||||||
_logger.LogInformation("Forwarding playback stop to Jellyfin...");
|
_logger.LogInformation("Forwarding playback stop to Jellyfin...");
|
||||||
|
|
||||||
|
// Log the body being sent for debugging
|
||||||
|
_logger.LogInformation("📤 Sending playback stop body: {Body}", body);
|
||||||
|
|
||||||
|
// Validate that body is not empty
|
||||||
|
if (string.IsNullOrWhiteSpace(body) || body == "{}")
|
||||||
|
{
|
||||||
|
_logger.LogWarning("⚠️ Playback stop body is empty, building minimal valid payload");
|
||||||
|
// Build a minimal valid PlaybackStopInfo
|
||||||
|
var stopInfo = new
|
||||||
|
{
|
||||||
|
ItemId = itemId,
|
||||||
|
PositionTicks = positionTicks ?? 0
|
||||||
|
};
|
||||||
|
body = JsonSerializer.Serialize(stopInfo);
|
||||||
|
_logger.LogInformation("📤 Built playback stop body: {Body}", body);
|
||||||
|
}
|
||||||
|
|
||||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", body, Request.Headers);
|
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", body, Request.Headers);
|
||||||
|
|
||||||
if (statusCode == 204 || statusCode == 200)
|
if (statusCode == 204 || statusCode == 200)
|
||||||
@@ -3696,8 +3806,7 @@ public class JellyfinController : ControllerBase
|
|||||||
{
|
{
|
||||||
Song = song,
|
Song = song,
|
||||||
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
|
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
|
||||||
// Calculate artist score by checking ALL artists match
|
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
|
||||||
ArtistScore = CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
|
|
||||||
})
|
})
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
@@ -4388,6 +4497,24 @@ public class JellyfinController : ControllerBase
|
|||||||
return (deviceId, client, device, version);
|
return (deviceId, client, device, version);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Generates a deterministic UUID (v5) from a string.
|
||||||
|
/// This allows us to create consistent UUIDs for external track IDs.
|
||||||
|
/// </summary>
|
||||||
|
private string GenerateUuidFromString(string input)
|
||||||
|
{
|
||||||
|
// Use MD5 hash to generate a deterministic UUID
|
||||||
|
using var md5 = System.Security.Cryptography.MD5.Create();
|
||||||
|
var hash = md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input));
|
||||||
|
|
||||||
|
// Convert to UUID format (version 5, namespace-based)
|
||||||
|
hash[6] = (byte)((hash[6] & 0x0F) | 0x50); // Version 5
|
||||||
|
hash[8] = (byte)((hash[8] & 0x3F) | 0x80); // Variant
|
||||||
|
|
||||||
|
var guid = new Guid(hash);
|
||||||
|
return guid.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Finds the Spotify ID for an external track by searching through all playlist matched tracks caches.
|
/// Finds the Spotify ID for an external track by searching through all playlist matched tracks caches.
|
||||||
/// This allows us to get Spotify lyrics for external tracks that were matched from Spotify playlists.
|
/// This allows us to get Spotify lyrics for external tracks that were matched from Spotify playlists.
|
||||||
@@ -4432,122 +4559,6 @@ public class JellyfinController : ControllerBase
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Converts an external track URL (Tidal/Deezer/Qobuz) to a Spotify track ID using Odesli/song.link API.
|
|
||||||
/// This enables Spotify lyrics for external tracks that aren't in injected playlists.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<string?> ConvertToSpotifyIdViaOdesliAsync(Song song, string provider, string externalId)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Build the source URL based on provider
|
|
||||||
string? sourceUrl = null;
|
|
||||||
|
|
||||||
switch (provider.ToLowerInvariant())
|
|
||||||
{
|
|
||||||
case "squidwtf":
|
|
||||||
// SquidWTF uses Tidal IDs
|
|
||||||
sourceUrl = $"https://tidal.com/browse/track/{externalId}";
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "deezer":
|
|
||||||
sourceUrl = $"https://www.deezer.com/track/{externalId}";
|
|
||||||
break;
|
|
||||||
|
|
||||||
case "qobuz":
|
|
||||||
sourceUrl = $"https://www.qobuz.com/us-en/album/-/-/{externalId}";
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
_logger.LogDebug("Provider {Provider} not supported for Odesli conversion", provider);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check cache first (cache for 30 days since these mappings don't change)
|
|
||||||
var cacheKey = $"odesli:{provider}:{externalId}";
|
|
||||||
var cachedSpotifyId = await _cache.GetStringAsync(cacheKey);
|
|
||||||
if (!string.IsNullOrEmpty(cachedSpotifyId))
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Returning cached Odesli conversion: {Provider}/{ExternalId} → {SpotifyId}",
|
|
||||||
provider, externalId, cachedSpotifyId);
|
|
||||||
return cachedSpotifyId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// RATE LIMITING: Odesli allows 10 requests per minute
|
|
||||||
// Use a simple semaphore-based rate limiter
|
|
||||||
await OdesliRateLimiter.WaitAsync();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Call Odesli API
|
|
||||||
var odesliUrl = $"https://api.song.link/v1-alpha.1/links?url={Uri.EscapeDataString(sourceUrl)}&userCountry=US";
|
|
||||||
|
|
||||||
_logger.LogDebug("Calling Odesli API: {Url}", odesliUrl);
|
|
||||||
|
|
||||||
using var httpClient = new HttpClient();
|
|
||||||
httpClient.Timeout = TimeSpan.FromSeconds(5);
|
|
||||||
|
|
||||||
var response = await httpClient.GetAsync(odesliUrl);
|
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Odesli API returned {StatusCode} for {Provider}/{ExternalId}",
|
|
||||||
response.StatusCode, provider, externalId);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
|
||||||
using var doc = JsonDocument.Parse(json);
|
|
||||||
var root = doc.RootElement;
|
|
||||||
|
|
||||||
// Extract Spotify URL from linksByPlatform.spotify.url
|
|
||||||
if (root.TryGetProperty("linksByPlatform", out var platforms) &&
|
|
||||||
platforms.TryGetProperty("spotify", out var spotify) &&
|
|
||||||
spotify.TryGetProperty("url", out var spotifyUrlEl))
|
|
||||||
{
|
|
||||||
var spotifyUrl = spotifyUrlEl.GetString();
|
|
||||||
if (!string.IsNullOrEmpty(spotifyUrl))
|
|
||||||
{
|
|
||||||
// Extract Spotify ID from URL: https://open.spotify.com/track/{id}
|
|
||||||
var match = System.Text.RegularExpressions.Regex.Match(spotifyUrl, @"spotify\.com/track/([a-zA-Z0-9]+)");
|
|
||||||
if (match.Success)
|
|
||||||
{
|
|
||||||
var spotifyId = match.Groups[1].Value;
|
|
||||||
|
|
||||||
// Cache the result (30 days)
|
|
||||||
await _cache.SetStringAsync(cacheKey, spotifyId, TimeSpan.FromDays(30));
|
|
||||||
|
|
||||||
_logger.LogInformation("✓ Odesli converted {Provider}/{ExternalId} → Spotify ID {SpotifyId}",
|
|
||||||
provider, externalId, spotifyId);
|
|
||||||
|
|
||||||
return spotifyId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogDebug("No Spotify link found in Odesli response for {Provider}/{ExternalId}", provider, externalId);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
// Release rate limiter after 6 seconds (10 requests per 60 seconds = 1 request per 6 seconds)
|
|
||||||
_ = Task.Run(async () =>
|
|
||||||
{
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(6));
|
|
||||||
OdesliRateLimiter.Release();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogDebug(ex, "Error converting {Provider}/{ExternalId} via Odesli", provider, externalId);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Static rate limiter for Odesli API (10 requests per minute = 1 request per 6 seconds)
|
|
||||||
private static readonly SemaphoreSlim OdesliRateLimiter = new SemaphoreSlim(10, 10);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// force rebuild Sun Jan 25 13:22:47 EST 2026
|
// force rebuild Sun Jan 25 13:22:47 EST 2026
|
||||||
|
|||||||
@@ -44,6 +44,11 @@ public class Song
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public string? Isrc { get; set; }
|
public string? Isrc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Spotify track ID (for lyrics and matching)
|
||||||
|
/// </summary>
|
||||||
|
public string? SpotifyId { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Full release date (format: YYYY-MM-DD)
|
/// Full release date (format: YYYY-MM-DD)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -379,6 +379,7 @@ else
|
|||||||
|
|
||||||
// Business services - shared across backends
|
// Business services - shared across backends
|
||||||
builder.Services.AddSingleton<RedisCacheService>();
|
builder.Services.AddSingleton<RedisCacheService>();
|
||||||
|
builder.Services.AddSingleton<OdesliService>();
|
||||||
builder.Services.AddSingleton<ILocalLibraryService, LocalLibraryService>();
|
builder.Services.AddSingleton<ILocalLibraryService, LocalLibraryService>();
|
||||||
builder.Services.AddSingleton<LrclibService>();
|
builder.Services.AddSingleton<LrclibService>();
|
||||||
|
|
||||||
@@ -459,6 +460,7 @@ else if (musicService == MusicService.SquidWTF)
|
|||||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
||||||
sp,
|
sp,
|
||||||
sp.GetRequiredService<ILogger<SquidWTFDownloadService>>(),
|
sp.GetRequiredService<ILogger<SquidWTFDownloadService>>(),
|
||||||
|
sp.GetRequiredService<OdesliService>(),
|
||||||
squidWtfApiUrls));
|
squidWtfApiUrls));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -475,13 +477,18 @@ else
|
|||||||
builder.Services.AddSingleton<IStartupValidator, SubsonicStartupValidator>();
|
builder.Services.AddSingleton<IStartupValidator, SubsonicStartupValidator>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register endpoint benchmark service
|
||||||
|
builder.Services.AddSingleton<EndpointBenchmarkService>();
|
||||||
|
|
||||||
builder.Services.AddSingleton<IStartupValidator, DeezerStartupValidator>();
|
builder.Services.AddSingleton<IStartupValidator, DeezerStartupValidator>();
|
||||||
builder.Services.AddSingleton<IStartupValidator, QobuzStartupValidator>();
|
builder.Services.AddSingleton<IStartupValidator, QobuzStartupValidator>();
|
||||||
builder.Services.AddSingleton<IStartupValidator>(sp =>
|
builder.Services.AddSingleton<IStartupValidator>(sp =>
|
||||||
new SquidWTFStartupValidator(
|
new SquidWTFStartupValidator(
|
||||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
||||||
sp.GetRequiredService<IHttpClientFactory>().CreateClient(),
|
sp.GetRequiredService<IHttpClientFactory>().CreateClient(),
|
||||||
squidWtfApiUrls));
|
squidWtfApiUrls,
|
||||||
|
sp.GetRequiredService<EndpointBenchmarkService>(),
|
||||||
|
sp.GetRequiredService<ILogger<SquidWTFStartupValidator>>()));
|
||||||
builder.Services.AddSingleton<IStartupValidator, LyricsStartupValidator>();
|
builder.Services.AddSingleton<IStartupValidator, LyricsStartupValidator>();
|
||||||
|
|
||||||
// Register orchestrator as hosted service
|
// Register orchestrator as hosted service
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
protected readonly ConcurrentDictionary<string, DownloadInfo> ActiveDownloads = new();
|
protected readonly ConcurrentDictionary<string, DownloadInfo> ActiveDownloads = new();
|
||||||
protected readonly SemaphoreSlim DownloadLock = new(1, 1);
|
protected readonly SemaphoreSlim DownloadLock = new(1, 1);
|
||||||
|
|
||||||
|
// Rate limiting fields
|
||||||
|
private readonly SemaphoreSlim _requestLock = new(1, 1);
|
||||||
|
private DateTime _lastRequestTime = DateTime.MinValue;
|
||||||
|
private readonly int _minRequestIntervalMs = 200;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Lazy-loaded PlaylistSyncService to avoid circular dependency
|
/// Lazy-loaded PlaylistSyncService to avoid circular dependency
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -121,20 +126,13 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check local library
|
// Check local library (works for both cache and permanent storage)
|
||||||
var localPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
|
var localPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
|
||||||
if (localPath != null && IOFile.Exists(localPath))
|
if (localPath != null && IOFile.Exists(localPath))
|
||||||
{
|
{
|
||||||
return localPath;
|
return localPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check cache directory
|
|
||||||
var cachedPath = GetCachedFilePath(externalProvider, externalId);
|
|
||||||
if (cachedPath != null && IOFile.Exists(cachedPath))
|
|
||||||
{
|
|
||||||
return cachedPath;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,28 +201,20 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Check if already downloaded (skip for cache mode as we want to check cache folder)
|
// Check if already downloaded (works for both cache and permanent modes)
|
||||||
if (!isCache)
|
|
||||||
{
|
|
||||||
var existingPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
|
var existingPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
|
||||||
if (existingPath != null && IOFile.Exists(existingPath))
|
if (existingPath != null && IOFile.Exists(existingPath))
|
||||||
{
|
{
|
||||||
Logger.LogInformation("Song already downloaded: {Path}", existingPath);
|
Logger.LogInformation("Song already downloaded: {Path}", existingPath);
|
||||||
|
|
||||||
|
// For cache mode, update file access time for cache cleanup logic
|
||||||
|
if (isCache)
|
||||||
|
{
|
||||||
|
IOFile.SetLastAccessTime(existingPath, DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
return existingPath;
|
return existingPath;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
// For cache mode, check if file exists in cache directory
|
|
||||||
var cachedPath = GetCachedFilePath(externalProvider, externalId);
|
|
||||||
if (cachedPath != null && IOFile.Exists(cachedPath))
|
|
||||||
{
|
|
||||||
Logger.LogInformation("Song found in cache: {Path}", cachedPath);
|
|
||||||
// Update file access time for cache cleanup logic
|
|
||||||
IOFile.SetLastAccessTime(cachedPath, DateTime.UtcNow);
|
|
||||||
return cachedPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if download in progress
|
// Check if download in progress
|
||||||
if (ActiveDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
|
if (ActiveDownloads.TryGetValue(songId, out var activeDownload) && activeDownload.Status == DownloadStatus.InProgress)
|
||||||
@@ -577,29 +567,34 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Rate Limiting
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the cached file path for a given provider and external ID
|
/// Queues a request with rate limiting to prevent overwhelming the API.
|
||||||
/// Returns null if no cached file exists
|
/// Ensures minimum interval between requests.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
protected string? GetCachedFilePath(string provider, string externalId)
|
protected async Task<T> QueueRequestAsync<T>(Func<Task<T>> action)
|
||||||
{
|
{
|
||||||
|
await _requestLock.WaitAsync();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Search for cached files matching the pattern: {provider}_{externalId}.*
|
var now = DateTime.UtcNow;
|
||||||
var pattern = $"{provider}_{externalId}.*";
|
var timeSinceLastRequest = (now - _lastRequestTime).TotalMilliseconds;
|
||||||
var files = Directory.GetFiles(CachePath, pattern, SearchOption.AllDirectories);
|
|
||||||
|
|
||||||
if (files.Length > 0)
|
if (timeSinceLastRequest < _minRequestIntervalMs)
|
||||||
{
|
{
|
||||||
return files[0]; // Return first match
|
await Task.Delay((int)(_minRequestIntervalMs - timeSinceLastRequest));
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
_lastRequestTime = DateTime.UtcNow;
|
||||||
|
return await action();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
finally
|
||||||
{
|
{
|
||||||
Logger.LogWarning(ex, "Failed to search for cached file: {Provider}_{ExternalId}", provider, externalId);
|
_requestLock.Release();
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
135
allstarr/Services/Common/EndpointBenchmarkService.cs
Normal file
135
allstarr/Services/Common/EndpointBenchmarkService.cs
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace allstarr.Services.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Benchmarks API endpoints on startup and maintains performance metrics.
|
||||||
|
/// Used to prioritize faster endpoints in racing scenarios.
|
||||||
|
/// </summary>
|
||||||
|
public class EndpointBenchmarkService
|
||||||
|
{
|
||||||
|
private readonly ILogger<EndpointBenchmarkService> _logger;
|
||||||
|
private readonly Dictionary<string, EndpointMetrics> _metrics = new();
|
||||||
|
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||||
|
|
||||||
|
public EndpointBenchmarkService(ILogger<EndpointBenchmarkService> logger)
|
||||||
|
{
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Benchmarks a list of endpoints by making test requests.
|
||||||
|
/// Returns endpoints sorted by average response time (fastest first).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<string>> BenchmarkEndpointsAsync(
|
||||||
|
List<string> endpoints,
|
||||||
|
Func<string, CancellationToken, Task<bool>> testFunc,
|
||||||
|
int pingCount = 3,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("🏁 Benchmarking {Count} endpoints with {Pings} pings each...", endpoints.Count, pingCount);
|
||||||
|
|
||||||
|
var tasks = endpoints.Select(async endpoint =>
|
||||||
|
{
|
||||||
|
var sw = Stopwatch.StartNew();
|
||||||
|
var successCount = 0;
|
||||||
|
var totalMs = 0L;
|
||||||
|
|
||||||
|
for (int i = 0; i < pingCount; i++)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var pingStart = Stopwatch.GetTimestamp();
|
||||||
|
var success = await testFunc(endpoint, cancellationToken);
|
||||||
|
var pingMs = Stopwatch.GetElapsedTime(pingStart).TotalMilliseconds;
|
||||||
|
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
successCount++;
|
||||||
|
totalMs += (long)pingMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Benchmark ping failed for {Endpoint}", endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small delay between pings
|
||||||
|
if (i < pingCount - 1)
|
||||||
|
{
|
||||||
|
await Task.Delay(100, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sw.Stop();
|
||||||
|
|
||||||
|
var avgMs = successCount > 0 ? totalMs / successCount : long.MaxValue;
|
||||||
|
var metrics = new EndpointMetrics
|
||||||
|
{
|
||||||
|
Endpoint = endpoint,
|
||||||
|
AverageResponseMs = avgMs,
|
||||||
|
SuccessRate = (double)successCount / pingCount,
|
||||||
|
LastBenchmark = DateTime.UtcNow
|
||||||
|
};
|
||||||
|
|
||||||
|
await _lock.WaitAsync(cancellationToken);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_metrics[endpoint] = metrics;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lock.Release();
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation(" {Endpoint}: {AvgMs}ms avg, {SuccessRate:P0} success rate",
|
||||||
|
endpoint, avgMs, metrics.SuccessRate);
|
||||||
|
|
||||||
|
return metrics;
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
var results = await Task.WhenAll(tasks);
|
||||||
|
|
||||||
|
// Sort by: success rate first (must be > 0), then by average response time
|
||||||
|
var sorted = results
|
||||||
|
.Where(m => m.SuccessRate > 0)
|
||||||
|
.OrderByDescending(m => m.SuccessRate)
|
||||||
|
.ThenBy(m => m.AverageResponseMs)
|
||||||
|
.Select(m => m.Endpoint)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
_logger.LogInformation("✅ Benchmark complete. Fastest: {Fastest} ({Ms}ms)",
|
||||||
|
sorted.FirstOrDefault() ?? "none",
|
||||||
|
results.Where(m => m.SuccessRate > 0).MinBy(m => m.AverageResponseMs)?.AverageResponseMs ?? 0);
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the metrics for a specific endpoint.
|
||||||
|
/// </summary>
|
||||||
|
public EndpointMetrics? GetMetrics(string endpoint)
|
||||||
|
{
|
||||||
|
_metrics.TryGetValue(endpoint, out var metrics);
|
||||||
|
return metrics;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets all endpoint metrics sorted by performance.
|
||||||
|
/// </summary>
|
||||||
|
public List<EndpointMetrics> GetAllMetrics()
|
||||||
|
{
|
||||||
|
return _metrics.Values
|
||||||
|
.OrderByDescending(m => m.SuccessRate)
|
||||||
|
.ThenBy(m => m.AverageResponseMs)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class EndpointMetrics
|
||||||
|
{
|
||||||
|
public string Endpoint { get; set; } = string.Empty;
|
||||||
|
public long AverageResponseMs { get; set; }
|
||||||
|
public double SuccessRate { get; set; }
|
||||||
|
public DateTime LastBenchmark { get; set; }
|
||||||
|
}
|
||||||
@@ -221,4 +221,54 @@ public static class FuzzyMatcher
|
|||||||
|
|
||||||
return distance[sourceLength, targetLength];
|
return distance[sourceLength, targetLength];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Calculates artist match score between Spotify artists and local song artists.
|
||||||
|
/// Checks bidirectional matching and penalizes mismatches.
|
||||||
|
/// Penalizes if artist counts don't match or if any artist is missing.
|
||||||
|
/// Returns score 0-100.
|
||||||
|
/// </summary>
|
||||||
|
public static double CalculateArtistMatchScore(List<string> spotifyArtists, string songMainArtist, List<string> songContributors)
|
||||||
|
{
|
||||||
|
if (spotifyArtists.Count == 0 || string.IsNullOrEmpty(songMainArtist))
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
// Build list of all song artists (main + contributors)
|
||||||
|
var allSongArtists = new List<string> { songMainArtist };
|
||||||
|
allSongArtists.AddRange(songContributors);
|
||||||
|
|
||||||
|
// If artist counts differ significantly, penalize
|
||||||
|
var countDiff = Math.Abs(spotifyArtists.Count - allSongArtists.Count);
|
||||||
|
if (countDiff > 1) // Allow 1 artist difference (sometimes features are listed differently)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
// Check that each Spotify artist has a good match in song artists
|
||||||
|
var spotifyScores = new List<double>();
|
||||||
|
foreach (var spotifyArtist in spotifyArtists)
|
||||||
|
{
|
||||||
|
var bestMatch = allSongArtists.Max(songArtist =>
|
||||||
|
CalculateSimilarity(spotifyArtist, songArtist));
|
||||||
|
spotifyScores.Add(bestMatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that each song artist has a good match in Spotify artists
|
||||||
|
var songScores = new List<double>();
|
||||||
|
foreach (var songArtist in allSongArtists)
|
||||||
|
{
|
||||||
|
var bestMatch = spotifyArtists.Max(spotifyArtist =>
|
||||||
|
CalculateSimilarity(songArtist, spotifyArtist));
|
||||||
|
songScores.Add(bestMatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Average all scores - this ensures ALL artists must match well
|
||||||
|
var allScores = spotifyScores.Concat(songScores);
|
||||||
|
var avgScore = allScores.Average();
|
||||||
|
|
||||||
|
// Penalize if any individual artist match is poor (< 70)
|
||||||
|
var minScore = allScores.Min();
|
||||||
|
if (minScore < 70)
|
||||||
|
avgScore *= 0.7; // 30% penalty for poor individual match
|
||||||
|
|
||||||
|
return avgScore;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
143
allstarr/Services/Common/OdesliService.cs
Normal file
143
allstarr/Services/Common/OdesliService.cs
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace allstarr.Services.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service for converting music URLs between platforms using Odesli/song.link API
|
||||||
|
/// </summary>
|
||||||
|
public class OdesliService
|
||||||
|
{
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
private readonly ILogger<OdesliService> _logger;
|
||||||
|
private readonly RedisCacheService _cache;
|
||||||
|
|
||||||
|
public OdesliService(
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
ILogger<OdesliService> logger,
|
||||||
|
RedisCacheService cache)
|
||||||
|
{
|
||||||
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
|
_logger = logger;
|
||||||
|
_cache = cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts a Tidal track ID to a Spotify track ID using Odesli
|
||||||
|
/// Results are cached for 7 days
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string?> ConvertTidalToSpotifyIdAsync(string tidalTrackId, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Check cache first (7 day TTL - these mappings don't change)
|
||||||
|
var cacheKey = $"odesli:tidal-to-spotify:{tidalTrackId}";
|
||||||
|
var cached = await _cache.GetAsync<string>(cacheKey);
|
||||||
|
if (!string.IsNullOrEmpty(cached))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("✓ Using cached Spotify ID for Tidal track {TidalId}", tidalTrackId);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var tidalUrl = $"https://tidal.com/browse/track/{tidalTrackId}";
|
||||||
|
var odesliUrl = $"https://api.song.link/v1-alpha.1/links?url={Uri.EscapeDataString(tidalUrl)}&userCountry=US";
|
||||||
|
|
||||||
|
_logger.LogDebug("🔗 Converting Tidal track {TidalId} to Spotify ID via Odesli", tidalTrackId);
|
||||||
|
|
||||||
|
var odesliResponse = await _httpClient.GetAsync(odesliUrl, cancellationToken);
|
||||||
|
if (odesliResponse.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var odesliJson = await odesliResponse.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
var odesliDoc = JsonDocument.Parse(odesliJson);
|
||||||
|
|
||||||
|
// Extract Spotify track ID from the Spotify URL
|
||||||
|
if (odesliDoc.RootElement.TryGetProperty("linksByPlatform", out var platforms) &&
|
||||||
|
platforms.TryGetProperty("spotify", out var spotifyPlatform) &&
|
||||||
|
spotifyPlatform.TryGetProperty("url", out var spotifyUrlEl))
|
||||||
|
{
|
||||||
|
var spotifyUrl = spotifyUrlEl.GetString();
|
||||||
|
if (!string.IsNullOrEmpty(spotifyUrl))
|
||||||
|
{
|
||||||
|
// Extract ID from URL: https://open.spotify.com/track/{id}
|
||||||
|
var match = System.Text.RegularExpressions.Regex.Match(spotifyUrl, @"spotify\.com/track/([a-zA-Z0-9]+)");
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
var spotifyId = match.Groups[1].Value;
|
||||||
|
_logger.LogInformation("✓ Converted Tidal/{TidalId} → Spotify ID {SpotifyId}", tidalTrackId, spotifyId);
|
||||||
|
|
||||||
|
// Cache for 7 days
|
||||||
|
await _cache.SetAsync(cacheKey, spotifyId, TimeSpan.FromDays(7));
|
||||||
|
|
||||||
|
return spotifyId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to convert Tidal track to Spotify ID via Odesli");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Converts any music URL to a Spotify track ID using Odesli
|
||||||
|
/// Results are cached for 7 days
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string?> ConvertUrlToSpotifyIdAsync(string musicUrl, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
// Check cache first
|
||||||
|
var cacheKey = $"odesli:url-to-spotify:{musicUrl}";
|
||||||
|
var cached = await _cache.GetAsync<string>(cacheKey);
|
||||||
|
if (!string.IsNullOrEmpty(cached))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("✓ Using cached Spotify ID for URL {Url}", musicUrl);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var odesliUrl = $"https://api.song.link/v1-alpha.1/links?url={Uri.EscapeDataString(musicUrl)}&userCountry=US";
|
||||||
|
|
||||||
|
_logger.LogDebug("🔗 Converting URL to Spotify ID via Odesli: {Url}", musicUrl);
|
||||||
|
|
||||||
|
var odesliResponse = await _httpClient.GetAsync(odesliUrl, cancellationToken);
|
||||||
|
if (odesliResponse.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var odesliJson = await odesliResponse.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
var odesliDoc = JsonDocument.Parse(odesliJson);
|
||||||
|
|
||||||
|
// Extract Spotify track ID from the Spotify URL
|
||||||
|
if (odesliDoc.RootElement.TryGetProperty("linksByPlatform", out var platforms) &&
|
||||||
|
platforms.TryGetProperty("spotify", out var spotifyPlatform) &&
|
||||||
|
spotifyPlatform.TryGetProperty("url", out var spotifyUrlEl))
|
||||||
|
{
|
||||||
|
var spotifyUrl = spotifyUrlEl.GetString();
|
||||||
|
if (!string.IsNullOrEmpty(spotifyUrl))
|
||||||
|
{
|
||||||
|
// Extract ID from URL: https://open.spotify.com/track/{id}
|
||||||
|
var match = System.Text.RegularExpressions.Regex.Match(spotifyUrl, @"spotify\.com/track/([a-zA-Z0-9]+)");
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
var spotifyId = match.Groups[1].Value;
|
||||||
|
_logger.LogInformation("✓ Converted URL → Spotify ID {SpotifyId}", spotifyId);
|
||||||
|
|
||||||
|
// Cache for 7 days
|
||||||
|
await _cache.SetAsync(cacheKey, spotifyId, TimeSpan.FromDays(7));
|
||||||
|
|
||||||
|
return spotifyId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Failed to convert URL to Spotify ID via Odesli");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
192
allstarr/Services/Common/RoundRobinFallbackHelper.cs
Normal file
192
allstarr/Services/Common/RoundRobinFallbackHelper.cs
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
namespace allstarr.Services.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Helper for round-robin load balancing with fallback across multiple API endpoints.
|
||||||
|
/// Distributes load evenly while maintaining reliability through automatic failover.
|
||||||
|
/// </summary>
|
||||||
|
public class RoundRobinFallbackHelper
|
||||||
|
{
|
||||||
|
private readonly List<string> _apiUrls;
|
||||||
|
private int _currentUrlIndex = 0;
|
||||||
|
private readonly object _urlIndexLock = new object();
|
||||||
|
private readonly ILogger _logger;
|
||||||
|
private readonly string _serviceName;
|
||||||
|
|
||||||
|
public int EndpointCount => _apiUrls.Count;
|
||||||
|
|
||||||
|
public RoundRobinFallbackHelper(List<string> apiUrls, ILogger logger, string serviceName)
|
||||||
|
{
|
||||||
|
_apiUrls = apiUrls ?? throw new ArgumentNullException(nameof(apiUrls));
|
||||||
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
|
_serviceName = serviceName ?? "Service";
|
||||||
|
|
||||||
|
if (_apiUrls.Count == 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("API URLs list cannot be empty", nameof(apiUrls));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Updates the endpoint order based on benchmark results (fastest first).
|
||||||
|
/// </summary>
|
||||||
|
public void SetEndpointOrder(List<string> orderedEndpoints)
|
||||||
|
{
|
||||||
|
lock (_urlIndexLock)
|
||||||
|
{
|
||||||
|
// Reorder _apiUrls to match the benchmarked order
|
||||||
|
var reordered = orderedEndpoints.Where(e => _apiUrls.Contains(e)).ToList();
|
||||||
|
|
||||||
|
// Add any endpoints that weren't benchmarked (shouldn't happen, but be safe)
|
||||||
|
foreach (var url in _apiUrls.Where(u => !reordered.Contains(u)))
|
||||||
|
{
|
||||||
|
reordered.Add(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
_apiUrls.Clear();
|
||||||
|
_apiUrls.AddRange(reordered);
|
||||||
|
_currentUrlIndex = 0;
|
||||||
|
|
||||||
|
_logger.LogInformation("📊 {Service} endpoints reordered by benchmark: {Endpoints}",
|
||||||
|
_serviceName, string.Join(", ", _apiUrls.Take(3)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tries the request with the next provider in round-robin, then falls back to others on failure.
|
||||||
|
/// This distributes load evenly across all providers while maintaining reliability.
|
||||||
|
/// Throws exception if all endpoints fail.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action)
|
||||||
|
{
|
||||||
|
// Start with the next URL in round-robin to distribute load
|
||||||
|
var startIndex = 0;
|
||||||
|
lock (_urlIndexLock)
|
||||||
|
{
|
||||||
|
startIndex = _currentUrlIndex;
|
||||||
|
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try all URLs starting from the round-robin selected one
|
||||||
|
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
|
||||||
|
{
|
||||||
|
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
|
||||||
|
var baseUrl = _apiUrls[urlIndex];
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Trying {Service} endpoint {Endpoint} (attempt {Attempt}/{Total})",
|
||||||
|
_serviceName, baseUrl, attempt + 1, _apiUrls.Count);
|
||||||
|
return await action(baseUrl);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
|
||||||
|
_serviceName, baseUrl);
|
||||||
|
|
||||||
|
if (attempt == _apiUrls.Count - 1)
|
||||||
|
{
|
||||||
|
_logger.LogError("All {Count} {Service} endpoints failed", _apiUrls.Count, _serviceName);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Exception($"All {_serviceName} endpoints failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Races all endpoints in parallel and returns the first successful result.
|
||||||
|
/// Cancels remaining requests once one succeeds. Great for latency-sensitive operations.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<T> RaceAllEndpointsAsync<T>(Func<string, CancellationToken, Task<T>> action, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
if (_apiUrls.Count == 1)
|
||||||
|
{
|
||||||
|
// No point racing with one endpoint
|
||||||
|
return await action(_apiUrls[0], cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
using var raceCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
var tasks = new List<Task<(T result, string endpoint, bool success)>>();
|
||||||
|
|
||||||
|
// Start all requests in parallel
|
||||||
|
foreach (var baseUrl in _apiUrls)
|
||||||
|
{
|
||||||
|
var task = Task.Run(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Racing {Service} endpoint {Endpoint}", _serviceName, baseUrl);
|
||||||
|
var result = await action(baseUrl, raceCts.Token);
|
||||||
|
return (result, baseUrl, true);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "{Service} race failed for endpoint {Endpoint}", _serviceName, baseUrl);
|
||||||
|
return (default(T)!, baseUrl, false);
|
||||||
|
}
|
||||||
|
}, raceCts.Token);
|
||||||
|
|
||||||
|
tasks.Add(task);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for first successful completion
|
||||||
|
while (tasks.Count > 0)
|
||||||
|
{
|
||||||
|
var completedTask = await Task.WhenAny(tasks);
|
||||||
|
var (result, endpoint, success) = await completedTask;
|
||||||
|
|
||||||
|
if (success)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("🏁 {Service} race won by {Endpoint}, canceling others", _serviceName, endpoint);
|
||||||
|
raceCts.Cancel(); // Cancel all other requests
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.Remove(completedTask);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Exception($"All {_serviceName} endpoints failed in race");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tries the request with the next provider in round-robin, then falls back to others on failure.
|
||||||
|
/// Returns default value if all endpoints fail (does not throw).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue)
|
||||||
|
{
|
||||||
|
// Start with the next URL in round-robin to distribute load
|
||||||
|
var startIndex = 0;
|
||||||
|
lock (_urlIndexLock)
|
||||||
|
{
|
||||||
|
startIndex = _currentUrlIndex;
|
||||||
|
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try all URLs starting from the round-robin selected one
|
||||||
|
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
|
||||||
|
{
|
||||||
|
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
|
||||||
|
var baseUrl = _apiUrls[urlIndex];
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Trying {Service} endpoint {Endpoint} (attempt {Attempt}/{Total})",
|
||||||
|
_serviceName, baseUrl, attempt + 1, _apiUrls.Count);
|
||||||
|
return await action(baseUrl);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
|
||||||
|
_serviceName, baseUrl);
|
||||||
|
|
||||||
|
if (attempt == _apiUrls.Count - 1)
|
||||||
|
{
|
||||||
|
_logger.LogError("All {Count} {Service} endpoints failed, returning default value",
|
||||||
|
_apiUrls.Count, _serviceName);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,6 @@ namespace allstarr.Services.Deezer;
|
|||||||
public class DeezerDownloadService : BaseDownloadService
|
public class DeezerDownloadService : BaseDownloadService
|
||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly SemaphoreSlim _requestLock = new(1, 1);
|
|
||||||
|
|
||||||
private readonly string? _arl;
|
private readonly string? _arl;
|
||||||
private readonly string? _arlFallback;
|
private readonly string? _arlFallback;
|
||||||
@@ -33,9 +32,6 @@ public class DeezerDownloadService : BaseDownloadService
|
|||||||
private string? _apiToken;
|
private string? _apiToken;
|
||||||
private string? _licenseToken;
|
private string? _licenseToken;
|
||||||
|
|
||||||
private DateTime _lastRequestTime = DateTime.MinValue;
|
|
||||||
private readonly int _minRequestIntervalMs = 200;
|
|
||||||
|
|
||||||
private const string DeezerApiBase = "https://api.deezer.com";
|
private const string DeezerApiBase = "https://api.deezer.com";
|
||||||
|
|
||||||
// Deezer's standard Blowfish CBC encryption key for track decryption
|
// Deezer's standard Blowfish CBC encryption key for track decryption
|
||||||
@@ -111,7 +107,10 @@ public class DeezerDownloadService : BaseDownloadService
|
|||||||
|
|
||||||
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
|
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
|
||||||
var artistForPath = song.AlbumArtist ?? song.Artist;
|
var artistForPath = song.AlbumArtist ?? song.Artist;
|
||||||
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache ? CachePath : DownloadPath;
|
// Cache mode uses cache/Music folder (cleaned up after 24h), Permanent mode uses downloads folder
|
||||||
|
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
|
||||||
|
? Path.Combine("cache", "Music")
|
||||||
|
: "downloads";
|
||||||
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
|
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
|
||||||
|
|
||||||
// Create directories if they don't exist
|
// Create directories if they don't exist
|
||||||
@@ -494,27 +493,6 @@ public class DeezerDownloadService : BaseDownloadService
|
|||||||
await RetryWithBackoffAsync<bool>(action, maxRetries, initialDelayMs);
|
await RetryWithBackoffAsync<bool>(action, maxRetries, initialDelayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<T> QueueRequestAsync<T>(Func<Task<T>> action)
|
|
||||||
{
|
|
||||||
await _requestLock.WaitAsync();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var timeSinceLastRequest = (now - _lastRequestTime).TotalMilliseconds;
|
|
||||||
|
|
||||||
if (timeSinceLastRequest < _minRequestIntervalMs)
|
|
||||||
{
|
|
||||||
await Task.Delay((int)(_minRequestIntervalMs - timeSinceLastRequest));
|
|
||||||
}
|
|
||||||
|
|
||||||
_lastRequestTime = DateTime.UtcNow;
|
|
||||||
return await action();
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_requestLock.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
|||||||
@@ -374,6 +374,21 @@ public class JellyfinProxyService
|
|||||||
var errorContent = await response.Content.ReadAsStringAsync();
|
var errorContent = await response.Content.ReadAsStringAsync();
|
||||||
_logger.LogWarning("❌ SESSION: Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
|
_logger.LogWarning("❌ SESSION: Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
|
||||||
response.StatusCode, url, errorContent);
|
response.StatusCode, url, errorContent);
|
||||||
|
|
||||||
|
// Try to parse error response as JSON to pass through to client
|
||||||
|
if (!string.IsNullOrWhiteSpace(errorContent))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var errorDoc = JsonDocument.Parse(errorContent);
|
||||||
|
return (errorDoc, statusCode);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Not valid JSON, return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (null, statusCode);
|
return (null, statusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -298,20 +298,23 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
webSocket = new ClientWebSocket();
|
webSocket = new ClientWebSocket();
|
||||||
session.WebSocket = webSocket;
|
session.WebSocket = webSocket;
|
||||||
|
|
||||||
|
// Use stored session headers instead of parameter (parameter might be disposed)
|
||||||
|
var sessionHeaders = session.Headers;
|
||||||
|
|
||||||
// Log available headers for debugging
|
// Log available headers for debugging
|
||||||
_logger.LogDebug("🔍 WEBSOCKET: Available headers for {DeviceId}: {Headers}",
|
_logger.LogDebug("🔍 WEBSOCKET: Available headers for {DeviceId}: {Headers}",
|
||||||
deviceId, string.Join(", ", headers.Keys));
|
deviceId, string.Join(", ", sessionHeaders.Keys));
|
||||||
|
|
||||||
// Forward authentication headers from the CLIENT - this is critical for session to appear under the right user
|
// Forward authentication headers from the CLIENT - this is critical for session to appear under the right user
|
||||||
bool authFound = false;
|
bool authFound = false;
|
||||||
if (headers.TryGetValue("X-Emby-Authorization", out var embyAuth))
|
if (sessionHeaders.TryGetValue("X-Emby-Authorization", out var embyAuth))
|
||||||
{
|
{
|
||||||
webSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuth.ToString());
|
webSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuth.ToString());
|
||||||
_logger.LogDebug("🔑 WEBSOCKET: Using X-Emby-Authorization for {DeviceId}: {Auth}",
|
_logger.LogDebug("🔑 WEBSOCKET: Using X-Emby-Authorization for {DeviceId}: {Auth}",
|
||||||
deviceId, embyAuth.ToString().Length > 50 ? embyAuth.ToString()[..50] + "..." : embyAuth.ToString());
|
deviceId, embyAuth.ToString().Length > 50 ? embyAuth.ToString()[..50] + "..." : embyAuth.ToString());
|
||||||
authFound = true;
|
authFound = true;
|
||||||
}
|
}
|
||||||
else if (headers.TryGetValue("Authorization", out var auth))
|
else if (sessionHeaders.TryGetValue("Authorization", out var auth))
|
||||||
{
|
{
|
||||||
var authValue = auth.ToString();
|
var authValue = auth.ToString();
|
||||||
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))
|
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))
|
||||||
|
|||||||
@@ -110,7 +110,10 @@ public class QobuzDownloadService : BaseDownloadService
|
|||||||
|
|
||||||
// Build organized folder structure using AlbumArtist (fallback to Artist for singles)
|
// Build organized folder structure using AlbumArtist (fallback to Artist for singles)
|
||||||
var artistForPath = song.AlbumArtist ?? song.Artist;
|
var artistForPath = song.AlbumArtist ?? song.Artist;
|
||||||
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache ? CachePath : DownloadPath;
|
// Cache mode uses cache/Music folder (cleaned up after 24h), Permanent mode uses downloads folder
|
||||||
|
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
|
||||||
|
? Path.Combine("cache", "Music")
|
||||||
|
: "downloads";
|
||||||
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
|
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
|
||||||
|
|
||||||
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
var albumFolder = Path.GetDirectoryName(outputPath)!;
|
||||||
|
|||||||
@@ -554,7 +554,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
Song = song,
|
Song = song,
|
||||||
// Use aggressive matching which follows optimal order internally
|
// Use aggressive matching which follows optimal order internally
|
||||||
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
||||||
ArtistScore = CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||||
})
|
})
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
@@ -640,7 +640,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
Song = song,
|
Song = song,
|
||||||
// Use aggressive matching which follows optimal order internally
|
// Use aggressive matching which follows optimal order internally
|
||||||
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
||||||
ArtistScore = CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||||
})
|
})
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
@@ -744,7 +744,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
Song = song,
|
Song = song,
|
||||||
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
|
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
|
||||||
// Calculate artist score by checking ALL artists match
|
// Calculate artist score by checking ALL artists match
|
||||||
ArtistScore = CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
|
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
|
||||||
})
|
})
|
||||||
.Select(x => new
|
.Select(x => new
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,21 +14,47 @@ using Microsoft.Extensions.Logging;
|
|||||||
namespace allstarr.Services.SquidWTF;
|
namespace allstarr.Services.SquidWTF;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Handles track downloading from tidal.squid.wtf (no encryption, no auth required)
|
/// Handles track downloading from tidal.squid.wtf (no encryption, no auth required).
|
||||||
/// Downloads are direct from Tidal's CDN via the squid.wtf proxy
|
///
|
||||||
|
/// Downloads are direct from Tidal's CDN via the squid.wtf proxy. The service:
|
||||||
|
/// 1. Fetches download info from hifi-api /track/ endpoint
|
||||||
|
/// 2. Decodes base64 manifest to get actual Tidal CDN URL
|
||||||
|
/// 3. Downloads directly from Tidal CDN (no decryption needed)
|
||||||
|
/// 4. Converts Tidal track ID to Spotify ID in parallel (for lyrics matching)
|
||||||
|
/// 5. Writes ID3/FLAC metadata tags and embeds cover art
|
||||||
|
///
|
||||||
|
/// Per hifi-api spec, the /track/ endpoint returns:
|
||||||
|
/// { "version": "2.0", "data": {
|
||||||
|
/// trackId, assetPresentation, audioMode, audioQuality,
|
||||||
|
/// manifestMimeType: "application/vnd.tidal.bts",
|
||||||
|
/// manifest: "base64-encoded-json",
|
||||||
|
/// albumReplayGain, trackReplayGain, bitDepth, sampleRate
|
||||||
|
/// }}
|
||||||
|
///
|
||||||
|
/// The manifest decodes to:
|
||||||
|
/// { "mimeType": "audio/flac", "codecs": "flac", "encryptionType": "NONE",
|
||||||
|
/// "urls": ["https://lgf.audio.tidal.com/mediatracks/..."] }
|
||||||
|
///
|
||||||
|
/// Quality Mapping:
|
||||||
|
/// - HI_RES → HI_RES_LOSSLESS (24-bit/192kHz FLAC)
|
||||||
|
/// - FLAC/LOSSLESS → LOSSLESS (16-bit/44.1kHz FLAC)
|
||||||
|
/// - HIGH → HIGH (320kbps AAC)
|
||||||
|
/// - LOW → LOW (96kbps AAC)
|
||||||
|
///
|
||||||
|
/// Features:
|
||||||
|
/// - Racing multiple endpoints for fastest download
|
||||||
|
/// - Automatic failover to backup endpoints
|
||||||
|
/// - Parallel Spotify ID conversion via Odesli
|
||||||
|
/// - Organized folder structure: Artist/Album/Track
|
||||||
|
/// - Unique filename resolution for duplicates
|
||||||
|
/// - Support for both cache and permanent storage modes
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SquidWTFDownloadService : BaseDownloadService
|
public class SquidWTFDownloadService : BaseDownloadService
|
||||||
{
|
{
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly SemaphoreSlim _requestLock = new(1, 1);
|
|
||||||
private readonly SquidWTFSettings _squidwtfSettings;
|
private readonly SquidWTFSettings _squidwtfSettings;
|
||||||
|
private readonly OdesliService _odesliService;
|
||||||
private DateTime _lastRequestTime = DateTime.MinValue;
|
private readonly RoundRobinFallbackHelper _fallbackHelper;
|
||||||
private readonly int _minRequestIntervalMs = 200;
|
|
||||||
|
|
||||||
private readonly List<string> _apiUrls;
|
|
||||||
private int _currentUrlIndex = 0;
|
|
||||||
private readonly object _urlIndexLock = new object();
|
|
||||||
|
|
||||||
protected override string ProviderName => "squidwtf";
|
protected override string ProviderName => "squidwtf";
|
||||||
|
|
||||||
@@ -41,59 +67,22 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
IOptions<SquidWTFSettings> SquidWTFSettings,
|
IOptions<SquidWTFSettings> SquidWTFSettings,
|
||||||
IServiceProvider serviceProvider,
|
IServiceProvider serviceProvider,
|
||||||
ILogger<SquidWTFDownloadService> logger,
|
ILogger<SquidWTFDownloadService> logger,
|
||||||
|
OdesliService odesliService,
|
||||||
List<string> apiUrls)
|
List<string> apiUrls)
|
||||||
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, serviceProvider, logger)
|
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, serviceProvider, logger)
|
||||||
{
|
{
|
||||||
_httpClient = httpClientFactory.CreateClient();
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
_squidwtfSettings = SquidWTFSettings.Value;
|
_squidwtfSettings = SquidWTFSettings.Value;
|
||||||
_apiUrls = apiUrls;
|
_odesliService = odesliService;
|
||||||
|
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tries the request with the next provider in round-robin, then falls back to others on failure.
|
|
||||||
/// This distributes load evenly across all providers while maintaining reliability.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action)
|
|
||||||
{
|
|
||||||
// Start with the next URL in round-robin to distribute load
|
|
||||||
var startIndex = 0;
|
|
||||||
lock (_urlIndexLock)
|
|
||||||
{
|
|
||||||
startIndex = _currentUrlIndex;
|
|
||||||
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try all URLs starting from the round-robin selected one
|
|
||||||
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
|
|
||||||
{
|
|
||||||
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
|
|
||||||
var baseUrl = _apiUrls[urlIndex];
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Logger.LogDebug("Trying endpoint {Endpoint} (attempt {Attempt}/{Total})",
|
|
||||||
baseUrl, attempt + 1, _apiUrls.Count);
|
|
||||||
return await action(baseUrl);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogWarning(ex, "Request failed with endpoint {Endpoint}, trying next...", baseUrl);
|
|
||||||
|
|
||||||
if (attempt == _apiUrls.Count - 1)
|
|
||||||
{
|
|
||||||
Logger.LogError("All {Count} SquidWTF endpoints failed", _apiUrls.Count);
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw new Exception("All SquidWTF endpoints failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
#region BaseDownloadService Implementation
|
#region BaseDownloadService Implementation
|
||||||
|
|
||||||
public override async Task<bool> IsAvailableAsync()
|
public override async Task<bool> IsAvailableAsync()
|
||||||
{
|
{
|
||||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
var response = await _httpClient.GetAsync(baseUrl);
|
var response = await _httpClient.GetAsync(baseUrl);
|
||||||
Console.WriteLine($"Response code from is available async: {response.IsSuccessStatusCode}");
|
Console.WriteLine($"Response code from is available async: {response.IsSuccessStatusCode}");
|
||||||
@@ -116,8 +105,11 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
{
|
{
|
||||||
var downloadInfo = await GetTrackDownloadInfoAsync(trackId, cancellationToken);
|
var downloadInfo = await GetTrackDownloadInfoAsync(trackId, cancellationToken);
|
||||||
|
|
||||||
Logger.LogInformation("Track token obtained: {Url}", downloadInfo.DownloadUrl);
|
Logger.LogInformation("Track download URL obtained from hifi-api: {Url}", downloadInfo.DownloadUrl);
|
||||||
Logger.LogInformation("Using format: {Format}", downloadInfo.MimeType);
|
Logger.LogInformation("Using format: {Format} (Quality: {Quality})", downloadInfo.MimeType, downloadInfo.AudioQuality);
|
||||||
|
|
||||||
|
// Start Spotify ID conversion in parallel with download (don't await yet)
|
||||||
|
var spotifyIdTask = _odesliService.ConvertTidalToSpotifyIdAsync(trackId, cancellationToken);
|
||||||
|
|
||||||
// Determine extension from MIME type
|
// Determine extension from MIME type
|
||||||
var extension = downloadInfo.MimeType?.ToLower() switch
|
var extension = downloadInfo.MimeType?.ToLower() switch
|
||||||
@@ -130,7 +122,10 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
|
|
||||||
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
|
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
|
||||||
var artistForPath = song.AlbumArtist ?? song.Artist;
|
var artistForPath = song.AlbumArtist ?? song.Artist;
|
||||||
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache ? CachePath : DownloadPath;
|
// Cache mode uses cache/Music folder (cleaned up after 24h), Permanent mode uses downloads folder
|
||||||
|
var basePath = SubsonicSettings.StorageMode == StorageMode.Cache
|
||||||
|
? Path.Combine("cache", "Music")
|
||||||
|
: "downloads";
|
||||||
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
|
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
|
||||||
|
|
||||||
// Create directories if they don't exist
|
// Create directories if they don't exist
|
||||||
@@ -140,15 +135,58 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
// Resolve unique path if file already exists
|
// Resolve unique path if file already exists
|
||||||
outputPath = PathHelper.ResolveUniquePath(outputPath);
|
outputPath = PathHelper.ResolveUniquePath(outputPath);
|
||||||
|
|
||||||
// Download from Tidal CDN (no authentication needed, token is in URL)
|
// Race all endpoints to download from the fastest one
|
||||||
var response = await QueueRequestAsync(async () =>
|
Logger.LogInformation("🏁 Racing {Count} endpoints for fastest download", _fallbackHelper.EndpointCount);
|
||||||
|
|
||||||
|
var response = await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
||||||
{
|
{
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl);
|
// Map quality settings to Tidal's quality levels per hifi-api spec
|
||||||
|
var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
|
||||||
|
{
|
||||||
|
"FLAC" => "LOSSLESS",
|
||||||
|
"HI_RES" => "HI_RES_LOSSLESS",
|
||||||
|
"LOSSLESS" => "LOSSLESS",
|
||||||
|
"HIGH" => "HIGH",
|
||||||
|
"LOW" => "LOW",
|
||||||
|
_ => "LOSSLESS"
|
||||||
|
};
|
||||||
|
|
||||||
|
var url = $"{baseUrl}/track/?id={trackId}&quality={quality}";
|
||||||
|
|
||||||
|
// Get download info from this endpoint
|
||||||
|
var infoResponse = await _httpClient.GetAsync(url, ct);
|
||||||
|
infoResponse.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
var json = await infoResponse.Content.ReadAsStringAsync(ct);
|
||||||
|
var doc = JsonDocument.Parse(json);
|
||||||
|
|
||||||
|
if (!doc.RootElement.TryGetProperty("data", out var data))
|
||||||
|
{
|
||||||
|
throw new Exception("Invalid response from API");
|
||||||
|
}
|
||||||
|
|
||||||
|
var manifestBase64 = data.GetProperty("manifest").GetString()
|
||||||
|
?? throw new Exception("No manifest in response");
|
||||||
|
|
||||||
|
// Decode base64 manifest to get actual CDN URL
|
||||||
|
var manifestJson = Encoding.UTF8.GetString(Convert.FromBase64String(manifestBase64));
|
||||||
|
var manifest = JsonDocument.Parse(manifestJson);
|
||||||
|
|
||||||
|
if (!manifest.RootElement.TryGetProperty("urls", out var urls) || urls.GetArrayLength() == 0)
|
||||||
|
{
|
||||||
|
throw new Exception("No download URLs in manifest");
|
||||||
|
}
|
||||||
|
|
||||||
|
var downloadUrl = urls[0].GetString()
|
||||||
|
?? throw new Exception("Download URL is null");
|
||||||
|
|
||||||
|
// Start the actual download from Tidal CDN (no encryption - squid.wtf handles everything)
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl);
|
||||||
request.Headers.Add("User-Agent", "Mozilla/5.0");
|
request.Headers.Add("User-Agent", "Mozilla/5.0");
|
||||||
request.Headers.Add("Accept", "*/*");
|
request.Headers.Add("Accept", "*/*");
|
||||||
|
|
||||||
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
|
||||||
});
|
}, cancellationToken);
|
||||||
|
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
@@ -161,6 +199,13 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
// Close file before writing metadata
|
// Close file before writing metadata
|
||||||
await outputFile.DisposeAsync();
|
await outputFile.DisposeAsync();
|
||||||
|
|
||||||
|
// Wait for Spotify ID conversion to complete and update song metadata
|
||||||
|
var spotifyId = await spotifyIdTask;
|
||||||
|
if (!string.IsNullOrEmpty(spotifyId))
|
||||||
|
{
|
||||||
|
song.SpotifyId = spotifyId;
|
||||||
|
}
|
||||||
|
|
||||||
// Write metadata and cover art
|
// Write metadata and cover art
|
||||||
await WriteMetadataAsync(outputPath, song, cancellationToken);
|
await WriteMetadataAsync(outputPath, song, cancellationToken);
|
||||||
|
|
||||||
@@ -171,13 +216,22 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
|
|
||||||
#region SquidWTF API Methods
|
#region SquidWTF API Methods
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets track download information from hifi-api /track/ endpoint.
|
||||||
|
/// Per hifi-api spec: GET /track/?id={trackId}&quality={quality}
|
||||||
|
/// Returns: { "version": "2.0", "data": { trackId, assetPresentation, audioMode, audioQuality,
|
||||||
|
/// manifestMimeType, manifestHash, manifest (base64), albumReplayGain, trackReplayGain, bitDepth, sampleRate } }
|
||||||
|
/// The manifest is base64-encoded JSON containing: { mimeType, codecs, encryptionType, urls: [downloadUrl] }
|
||||||
|
/// Quality options: HI_RES_LOSSLESS (24-bit/192kHz FLAC), LOSSLESS (16-bit/44.1kHz FLAC), HIGH (320kbps AAC), LOW (96kbps AAC)
|
||||||
|
/// </summary>
|
||||||
private async Task<DownloadResult> GetTrackDownloadInfoAsync(string trackId, CancellationToken cancellationToken)
|
private async Task<DownloadResult> GetTrackDownloadInfoAsync(string trackId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return await QueueRequestAsync(async () =>
|
return await QueueRequestAsync(async () =>
|
||||||
{
|
{
|
||||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
// Race all endpoints for fastest download info retrieval
|
||||||
|
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
||||||
{
|
{
|
||||||
// Map quality settings to Tidal's quality levels
|
// Map quality settings to Tidal's quality levels per hifi-api spec
|
||||||
var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
|
var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
|
||||||
{
|
{
|
||||||
"FLAC" => "LOSSLESS",
|
"FLAC" => "LOSSLESS",
|
||||||
@@ -190,12 +244,12 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
|
|
||||||
var url = $"{baseUrl}/track/?id={trackId}&quality={quality}";
|
var url = $"{baseUrl}/track/?id={trackId}&quality={quality}";
|
||||||
|
|
||||||
Console.WriteLine($"%%%%%%%%%%%%%%%%%%% URL For downloads??: {url}");
|
Logger.LogDebug("Fetching track download info from: {Url}", url);
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
var response = await _httpClient.GetAsync(url, ct);
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
var json = await response.Content.ReadAsStringAsync(ct);
|
||||||
var doc = JsonDocument.Parse(json);
|
var doc = JsonDocument.Parse(json);
|
||||||
|
|
||||||
if (!doc.RootElement.TryGetProperty("data", out var data))
|
if (!doc.RootElement.TryGetProperty("data", out var data))
|
||||||
@@ -237,35 +291,15 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
MimeType = mimeType ?? "audio/flac",
|
MimeType = mimeType ?? "audio/flac",
|
||||||
AudioQuality = audioQuality ?? "LOSSLESS"
|
AudioQuality = audioQuality ?? "LOSSLESS"
|
||||||
};
|
};
|
||||||
});
|
}, cancellationToken);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Utility Methods
|
#region Utility Methods
|
||||||
|
|
||||||
private async Task<T> QueueRequestAsync<T>(Func<Task<T>> action)
|
|
||||||
{
|
|
||||||
await _requestLock.WaitAsync();
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var now = DateTime.UtcNow;
|
|
||||||
var timeSinceLastRequest = (now - _lastRequestTime).TotalMilliseconds;
|
|
||||||
|
|
||||||
if (timeSinceLastRequest < _minRequestIntervalMs)
|
|
||||||
{
|
|
||||||
await Task.Delay((int)(_minRequestIntervalMs - timeSinceLastRequest));
|
|
||||||
}
|
|
||||||
|
|
||||||
_lastRequestTime = DateTime.UtcNow;
|
|
||||||
return await action();
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
_requestLock.Release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,41 @@ using System.Text.Json.Nodes;
|
|||||||
namespace allstarr.Services.SquidWTF;
|
namespace allstarr.Services.SquidWTF;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Metadata service implementation using the SquidWTF API (free, no key required)
|
/// Metadata service implementation using the SquidWTF API (free, no key required).
|
||||||
|
///
|
||||||
|
/// SquidWTF is a proxy to Tidal's API that provides free access to Tidal's music catalog.
|
||||||
|
/// This implementation follows the hifi-api specification documented at the forked repository.
|
||||||
|
///
|
||||||
|
/// API Endpoints (per hifi-api spec):
|
||||||
|
/// - GET /search/?s={query} - Search tracks (returns data.items array)
|
||||||
|
/// - GET /search/?a={query} - Search artists (returns data.artists.items array)
|
||||||
|
/// - GET /search/?al={query} - Search albums (returns data.albums.items array, undocumented)
|
||||||
|
/// - GET /search/?p={query} - Search playlists (returns data.playlists.items array, undocumented)
|
||||||
|
/// - GET /info/?id={trackId} - Get track metadata (returns data object with full track info)
|
||||||
|
/// - GET /track/?id={trackId}&quality={quality} - Get track download info (returns manifest)
|
||||||
|
/// - GET /album/?id={albumId} - Get album with tracks (undocumented, returns data.items array)
|
||||||
|
/// - GET /artist/?f={artistId} - Get artist with albums (undocumented, returns albums.items array)
|
||||||
|
/// - GET /playlist/?id={playlistId} - Get playlist with tracks (undocumented)
|
||||||
|
///
|
||||||
|
/// Quality Options:
|
||||||
|
/// - HI_RES_LOSSLESS: 24-bit/192kHz FLAC
|
||||||
|
/// - LOSSLESS: 16-bit/44.1kHz FLAC
|
||||||
|
/// - HIGH: 320kbps AAC
|
||||||
|
/// - LOW: 96kbps AAC
|
||||||
|
///
|
||||||
|
/// Response Structure:
|
||||||
|
/// All responses follow: { "version": "2.0", "data": { ... } }
|
||||||
|
/// Track objects include: id, title, duration, trackNumber, volumeNumber, explicit, bpm, isrc,
|
||||||
|
/// artist (singular), artists (array), album (object with id, title, cover UUID)
|
||||||
|
/// Cover art URLs: https://resources.tidal.com/images/{uuid-with-slashes}/{size}.jpg
|
||||||
|
///
|
||||||
|
/// Features:
|
||||||
|
/// - Round-robin load balancing across multiple mirror endpoints
|
||||||
|
/// - Automatic failover to backup endpoints on failure
|
||||||
|
/// - Racing endpoints for fastest response on latency-sensitive operations
|
||||||
|
/// - Redis caching for albums and artists (24-hour TTL)
|
||||||
|
/// - Explicit content filtering support
|
||||||
|
/// - Parallel Spotify ID conversion via Odesli for lyrics matching
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
||||||
public class SquidWTFMetadataService : IMusicMetadataService
|
public class SquidWTFMetadataService : IMusicMetadataService
|
||||||
@@ -21,9 +55,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
private readonly SubsonicSettings _settings;
|
private readonly SubsonicSettings _settings;
|
||||||
private readonly ILogger<SquidWTFMetadataService> _logger;
|
private readonly ILogger<SquidWTFMetadataService> _logger;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
private readonly List<string> _apiUrls;
|
private readonly RoundRobinFallbackHelper _fallbackHelper;
|
||||||
private int _currentUrlIndex = 0;
|
|
||||||
private readonly object _urlIndexLock = new object();
|
|
||||||
|
|
||||||
public SquidWTFMetadataService(
|
public SquidWTFMetadataService(
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
@@ -37,79 +69,30 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
_apiUrls = apiUrls;
|
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
|
||||||
|
|
||||||
// Set up default headers
|
// Set up default headers
|
||||||
_httpClient.DefaultRequestHeaders.Add("User-Agent",
|
_httpClient.DefaultRequestHeaders.Add("User-Agent",
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0");
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the next URL in round-robin fashion to distribute load across providers
|
|
||||||
/// </summary>
|
|
||||||
private string GetNextBaseUrl()
|
|
||||||
{
|
|
||||||
lock (_urlIndexLock)
|
|
||||||
{
|
|
||||||
var url = _apiUrls[_currentUrlIndex];
|
|
||||||
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tries the request with the next provider in round-robin, then falls back to others on failure.
|
|
||||||
/// This distributes load evenly across all providers while maintaining reliability.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue)
|
|
||||||
{
|
|
||||||
// Start with the next URL in round-robin to distribute load
|
|
||||||
var startIndex = 0;
|
|
||||||
lock (_urlIndexLock)
|
|
||||||
{
|
|
||||||
startIndex = _currentUrlIndex;
|
|
||||||
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try all URLs starting from the round-robin selected one
|
|
||||||
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
|
|
||||||
{
|
|
||||||
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
|
|
||||||
var baseUrl = _apiUrls[urlIndex];
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Trying endpoint {Endpoint} (attempt {Attempt}/{Total})",
|
|
||||||
baseUrl, attempt + 1, _apiUrls.Count);
|
|
||||||
return await action(baseUrl);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Request failed with endpoint {Endpoint}, trying next...", baseUrl);
|
|
||||||
|
|
||||||
if (attempt == _apiUrls.Count - 1)
|
|
||||||
{
|
|
||||||
_logger.LogError("All {Count} SquidWTF endpoints failed", _apiUrls.Count);
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
|
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
|
||||||
{
|
{
|
||||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
// Race all endpoints for fastest search results
|
||||||
|
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
||||||
{
|
{
|
||||||
|
// Use 's' parameter for track search as per hifi-api spec
|
||||||
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
|
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url, ct);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
var json = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
|
||||||
// Check for error in response body
|
// Check for error in response body
|
||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
@@ -120,6 +103,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var songs = new List<Song>();
|
var songs = new List<Song>();
|
||||||
|
// Per hifi-api spec: track search returns data.items array
|
||||||
if (result.RootElement.TryGetProperty("data", out var data) &&
|
if (result.RootElement.TryGetProperty("data", out var data) &&
|
||||||
data.TryGetProperty("items", out var items))
|
data.TryGetProperty("items", out var items))
|
||||||
{
|
{
|
||||||
@@ -129,30 +113,36 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
if (count >= limit) break;
|
if (count >= limit) break;
|
||||||
|
|
||||||
var song = ParseTidalTrack(track);
|
var song = ParseTidalTrack(track);
|
||||||
|
if (ShouldIncludeSong(song))
|
||||||
|
{
|
||||||
songs.Add(song);
|
songs.Add(song);
|
||||||
|
}
|
||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return songs;
|
return songs;
|
||||||
}, new List<Song>());
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
|
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
|
||||||
{
|
{
|
||||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
// Race all endpoints for fastest search results
|
||||||
|
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
||||||
{
|
{
|
||||||
|
// Note: hifi-api doesn't document album search, but 'al' parameter is commonly used
|
||||||
var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
|
var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url, ct);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
return new List<Album>();
|
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
var json = await response.Content.ReadAsStringAsync(ct);
|
||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
|
|
||||||
var albums = new List<Album>();
|
var albums = new List<Album>();
|
||||||
|
// Per hifi-api spec: album search returns data.albums.items array
|
||||||
if (result.RootElement.TryGetProperty("data", out var data) &&
|
if (result.RootElement.TryGetProperty("data", out var data) &&
|
||||||
data.TryGetProperty("albums", out var albumsObj) &&
|
data.TryGetProperty("albums", out var albumsObj) &&
|
||||||
albumsObj.TryGetProperty("items", out var items))
|
albumsObj.TryGetProperty("items", out var items))
|
||||||
@@ -168,28 +158,31 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
}
|
}
|
||||||
|
|
||||||
return albums;
|
return albums;
|
||||||
}, new List<Album>());
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
|
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
|
||||||
{
|
{
|
||||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
// Race all endpoints for fastest search results
|
||||||
|
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
|
||||||
{
|
{
|
||||||
|
// Per hifi-api spec: use 'a' parameter for artist search
|
||||||
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
|
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
|
||||||
_logger.LogInformation("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
|
_logger.LogInformation("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url, ct);
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("⚠️ SQUIDWTF: Artist search failed with status {StatusCode}", response.StatusCode);
|
_logger.LogWarning("⚠️ SQUIDWTF: Artist search failed with status {StatusCode}", response.StatusCode);
|
||||||
return new List<Artist>();
|
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
var json = await response.Content.ReadAsStringAsync(ct);
|
||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
|
|
||||||
var artists = new List<Artist>();
|
var artists = new List<Artist>();
|
||||||
|
// Per hifi-api spec: artist search returns data.artists.items array
|
||||||
if (result.RootElement.TryGetProperty("data", out var data) &&
|
if (result.RootElement.TryGetProperty("data", out var data) &&
|
||||||
data.TryGetProperty("artists", out var artistsObj) &&
|
data.TryGetProperty("artists", out var artistsObj) &&
|
||||||
artistsObj.TryGetProperty("items", out var items))
|
artistsObj.TryGetProperty("items", out var items))
|
||||||
@@ -208,13 +201,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
_logger.LogInformation("✓ SQUIDWTF: Artist search returned {Count} results", artists.Count);
|
_logger.LogInformation("✓ SQUIDWTF: Artist search returned {Count} results", artists.Count);
|
||||||
return artists;
|
return artists;
|
||||||
}, new List<Artist>());
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20)
|
public async Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20)
|
||||||
{
|
{
|
||||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
|
// Per hifi-api spec: use 'p' parameter for playlist search
|
||||||
var url = $"{baseUrl}/search/?p={Uri.EscapeDataString(query)}";
|
var url = $"{baseUrl}/search/?p={Uri.EscapeDataString(query)}";
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url);
|
||||||
if (!response.IsSuccessStatusCode) return new List<ExternalPlaylist>();
|
if (!response.IsSuccessStatusCode) return new List<ExternalPlaylist>();
|
||||||
@@ -223,15 +217,20 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
|
|
||||||
var playlists = new List<ExternalPlaylist>();
|
var playlists = new List<ExternalPlaylist>();
|
||||||
|
// Per hifi-api spec: playlist search returns data.playlists.items array
|
||||||
if (result.RootElement.TryGetProperty("data", out var data) &&
|
if (result.RootElement.TryGetProperty("data", out var data) &&
|
||||||
data.TryGetProperty("playlists", out var playlistObj) &&
|
data.TryGetProperty("playlists", out var playlistObj) &&
|
||||||
playlistObj.TryGetProperty("items", out var items))
|
playlistObj.TryGetProperty("items", out var items))
|
||||||
{
|
{
|
||||||
|
int count = 0;
|
||||||
foreach(var playlist in items.EnumerateArray())
|
foreach(var playlist in items.EnumerateArray())
|
||||||
{
|
{
|
||||||
|
if (count >= limit) break;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
playlists.Add(ParseTidalPlaylist(playlist));
|
playlists.Add(ParseTidalPlaylist(playlist));
|
||||||
|
count++;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -267,8 +266,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
{
|
{
|
||||||
if (externalProvider != "squidwtf") return null;
|
if (externalProvider != "squidwtf") return null;
|
||||||
|
|
||||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
|
// Per hifi-api spec: GET /info/?id={trackId} returns track metadata
|
||||||
var url = $"{baseUrl}/info/?id={externalId}";
|
var url = $"{baseUrl}/info/?id={externalId}";
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url);
|
||||||
@@ -277,10 +277,16 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
var json = await response.Content.ReadAsStringAsync();
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
|
|
||||||
|
// Per hifi-api spec: response is { "version": "2.0", "data": { track object } }
|
||||||
if (!result.RootElement.TryGetProperty("data", out var track))
|
if (!result.RootElement.TryGetProperty("data", out var track))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return ParseTidalTrackFull(track);
|
var song = ParseTidalTrackFull(track);
|
||||||
|
|
||||||
|
// NOTE: Spotify ID conversion happens during download (in SquidWTFDownloadService)
|
||||||
|
// This avoids redundant conversions and ensures it's done in parallel with the download
|
||||||
|
|
||||||
|
return song;
|
||||||
}, (Song?)null);
|
}, (Song?)null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,8 +299,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
var cached = await _cache.GetAsync<Album>(cacheKey);
|
var cached = await _cache.GetAsync<Album>(cacheKey);
|
||||||
if (cached != null) return cached;
|
if (cached != null) return cached;
|
||||||
|
|
||||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
|
// Note: hifi-api doesn't document album endpoint, but /album/?id={albumId} is commonly used
|
||||||
var url = $"{baseUrl}/album/?id={externalId}";
|
var url = $"{baseUrl}/album/?id={externalId}";
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url);
|
||||||
@@ -303,17 +310,18 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
var json = await response.Content.ReadAsStringAsync();
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
|
|
||||||
|
// Response structure: { "data": { album object with "items" array of tracks } }
|
||||||
if (!result.RootElement.TryGetProperty("data", out var albumElement))
|
if (!result.RootElement.TryGetProperty("data", out var albumElement))
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
var album = ParseTidalAlbum(albumElement);
|
var album = ParseTidalAlbum(albumElement);
|
||||||
|
|
||||||
// Get album tracks
|
// Get album tracks from items array
|
||||||
if (albumElement.TryGetProperty("items", out var tracks))
|
if (albumElement.TryGetProperty("items", out var tracks))
|
||||||
{
|
{
|
||||||
foreach (var trackWrapper in tracks.EnumerateArray())
|
foreach (var trackWrapper in tracks.EnumerateArray())
|
||||||
{
|
{
|
||||||
|
// Each item is wrapped: { "item": { track object } }
|
||||||
if (trackWrapper.TryGetProperty("item", out var track))
|
if (trackWrapper.TryGetProperty("item", out var track))
|
||||||
{
|
{
|
||||||
var song = ParseTidalTrack(track);
|
var song = ParseTidalTrack(track);
|
||||||
@@ -347,8 +355,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
|
|
||||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
|
// Note: hifi-api doesn't document artist endpoint, but /artist/?f={artistId} is commonly used
|
||||||
var url = $"{baseUrl}/artist/?f={externalId}";
|
var url = $"{baseUrl}/artist/?f={externalId}";
|
||||||
_logger.LogInformation("Fetching artist from {Url}", url);
|
_logger.LogInformation("Fetching artist from {Url}", url);
|
||||||
|
|
||||||
@@ -366,7 +375,8 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
JsonElement? artistSource = null;
|
JsonElement? artistSource = null;
|
||||||
int albumCount = 0;
|
int albumCount = 0;
|
||||||
|
|
||||||
// Try to get artist from albums.items[0].artist
|
// Response structure: { "albums": { "items": [ album objects ] }, "tracks": [ track objects ] }
|
||||||
|
// Extract artist info from albums.items[0].artist (most reliable source)
|
||||||
if (result.RootElement.TryGetProperty("albums", out var albums) &&
|
if (result.RootElement.TryGetProperty("albums", out var albums) &&
|
||||||
albums.TryGetProperty("items", out var albumItems) &&
|
albums.TryGetProperty("items", out var albumItems) &&
|
||||||
albumItems.GetArrayLength() > 0)
|
albumItems.GetArrayLength() > 0)
|
||||||
@@ -398,6 +408,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
}
|
}
|
||||||
|
|
||||||
var artistElement = artistSource.Value;
|
var artistElement = artistSource.Value;
|
||||||
|
// Normalize artist data to include album count
|
||||||
var normalizedArtist = new JsonObject
|
var normalizedArtist = new JsonObject
|
||||||
{
|
{
|
||||||
["id"] = artistElement.GetProperty("id").GetInt64(),
|
["id"] = artistElement.GetProperty("id").GetInt64(),
|
||||||
@@ -422,10 +433,11 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
{
|
{
|
||||||
if (externalProvider != "squidwtf") return new List<Album>();
|
if (externalProvider != "squidwtf") return new List<Album>();
|
||||||
|
|
||||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
_logger.LogInformation("GetArtistAlbumsAsync called for SquidWTF artist {ExternalId}", externalId);
|
_logger.LogInformation("GetArtistAlbumsAsync called for SquidWTF artist {ExternalId}", externalId);
|
||||||
|
|
||||||
|
// Note: hifi-api doesn't document artist endpoint, but /artist/?f={artistId} is commonly used
|
||||||
var url = $"{baseUrl}/artist/?f={externalId}";
|
var url = $"{baseUrl}/artist/?f={externalId}";
|
||||||
_logger.LogInformation("Fetching artist albums from URL: {Url}", url);
|
_logger.LogInformation("Fetching artist albums from URL: {Url}", url);
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url);
|
||||||
@@ -442,6 +454,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
var albums = new List<Album>();
|
var albums = new List<Album>();
|
||||||
|
|
||||||
|
// Response structure: { "albums": { "items": [ album objects ] } }
|
||||||
if (result.RootElement.TryGetProperty("albums", out var albumsObj) &&
|
if (result.RootElement.TryGetProperty("albums", out var albumsObj) &&
|
||||||
albumsObj.TryGetProperty("items", out var items))
|
albumsObj.TryGetProperty("items", out var items))
|
||||||
{
|
{
|
||||||
@@ -467,8 +480,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
{
|
{
|
||||||
if (externalProvider != "squidwtf") return null;
|
if (externalProvider != "squidwtf") return null;
|
||||||
|
|
||||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
|
// Note: hifi-api doesn't document playlist endpoint, but /playlist/?id={playlistId} is commonly used
|
||||||
var url = $"{baseUrl}/playlist/?id={externalId}";
|
var url = $"{baseUrl}/playlist/?id={externalId}";
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url);
|
||||||
if (!response.IsSuccessStatusCode) return null;
|
if (!response.IsSuccessStatusCode) return null;
|
||||||
@@ -476,8 +490,10 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
var json = await response.Content.ReadAsStringAsync();
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
var playlistElement = JsonDocument.Parse(json).RootElement;
|
var playlistElement = JsonDocument.Parse(json).RootElement;
|
||||||
|
|
||||||
|
// Check for error response
|
||||||
if (playlistElement.TryGetProperty("error", out _)) return null;
|
if (playlistElement.TryGetProperty("error", out _)) return null;
|
||||||
|
|
||||||
|
// Response structure: { "playlist": { playlist object }, "items": [ track wrappers ] }
|
||||||
return ParseTidalPlaylist(playlistElement);
|
return ParseTidalPlaylist(playlistElement);
|
||||||
}, (ExternalPlaylist?)null);
|
}, (ExternalPlaylist?)null);
|
||||||
}
|
}
|
||||||
@@ -486,8 +502,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
{
|
{
|
||||||
if (externalProvider != "squidwtf") return new List<Song>();
|
if (externalProvider != "squidwtf") return new List<Song>();
|
||||||
|
|
||||||
return await TryWithFallbackAsync(async (baseUrl) =>
|
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
|
// Note: hifi-api doesn't document playlist endpoint, but /playlist/?id={playlistId} is commonly used
|
||||||
var url = $"{baseUrl}/playlist/?id={externalId}";
|
var url = $"{baseUrl}/playlist/?id={externalId}";
|
||||||
var response = await _httpClient.GetAsync(url);
|
var response = await _httpClient.GetAsync(url);
|
||||||
if (!response.IsSuccessStatusCode) return new List<Song>();
|
if (!response.IsSuccessStatusCode) return new List<Song>();
|
||||||
@@ -495,11 +512,13 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
var json = await response.Content.ReadAsStringAsync();
|
var json = await response.Content.ReadAsStringAsync();
|
||||||
var playlistElement = JsonDocument.Parse(json).RootElement;
|
var playlistElement = JsonDocument.Parse(json).RootElement;
|
||||||
|
|
||||||
|
// Check for error response
|
||||||
if (playlistElement.TryGetProperty("error", out _)) return new List<Song>();
|
if (playlistElement.TryGetProperty("error", out _)) return new List<Song>();
|
||||||
|
|
||||||
JsonElement? playlist = null;
|
JsonElement? playlist = null;
|
||||||
JsonElement? tracks = null;
|
JsonElement? tracks = null;
|
||||||
|
|
||||||
|
// Response structure: { "playlist": { playlist object }, "items": [ track wrappers ] }
|
||||||
if (playlistElement.TryGetProperty("playlist", out var playlistEl))
|
if (playlistElement.TryGetProperty("playlist", out var playlistEl))
|
||||||
{
|
{
|
||||||
playlist = playlistEl;
|
playlist = playlistEl;
|
||||||
@@ -522,6 +541,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
int trackIndex = 1;
|
int trackIndex = 1;
|
||||||
foreach (var entry in tracks.Value.EnumerateArray())
|
foreach (var entry in tracks.Value.EnumerateArray())
|
||||||
{
|
{
|
||||||
|
// Each item is wrapped: { "item": { track object } }
|
||||||
if (!entry.TryGetProperty("item", out var track))
|
if (!entry.TryGetProperty("item", out var track))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
@@ -544,6 +564,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
|
|
||||||
// --- Parser functions start here ---
|
// --- Parser functions start here ---
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a Tidal track object from hifi-api search/album/playlist responses.
|
||||||
|
/// Per hifi-api spec, track objects contain: id, title, duration, trackNumber, volumeNumber,
|
||||||
|
/// explicit, artist (singular), artists (array), album (object with id, title, cover).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="track">JSON element containing track data</param>
|
||||||
|
/// <param name="fallbackTrackNumber">Optional track number to use if not present in JSON</param>
|
||||||
|
/// <returns>Parsed Song object</returns>
|
||||||
private Song ParseTidalTrack(JsonElement track, int? fallbackTrackNumber = null)
|
private Song ParseTidalTrack(JsonElement track, int? fallbackTrackNumber = null)
|
||||||
{
|
{
|
||||||
var externalId = track.GetProperty("id").GetInt64().ToString();
|
var externalId = track.GetProperty("id").GetInt64().ToString();
|
||||||
@@ -633,6 +661,13 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a full Tidal track object from hifi-api /info/ endpoint.
|
||||||
|
/// Per hifi-api spec, full track objects include additional metadata: bpm, isrc, key, keyScale,
|
||||||
|
/// streamStartDate (for year), copyright, replayGain, peak, audioQuality, audioModes.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="track">JSON element containing full track data</param>
|
||||||
|
/// <returns>Parsed Song object with extended metadata</returns>
|
||||||
private Song ParseTidalTrackFull(JsonElement track)
|
private Song ParseTidalTrackFull(JsonElement track)
|
||||||
{
|
{
|
||||||
var externalId = track.GetProperty("id").GetInt64().ToString();
|
var externalId = track.GetProperty("id").GetInt64().ToString();
|
||||||
@@ -754,6 +789,13 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a Tidal album object from hifi-api responses.
|
||||||
|
/// Per hifi-api spec, album objects contain: id, title, releaseDate, numberOfTracks,
|
||||||
|
/// cover (UUID), artist (object) or artists (array).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="album">JSON element containing album data</param>
|
||||||
|
/// <returns>Parsed Album object</returns>
|
||||||
private Album ParseTidalAlbum(JsonElement album)
|
private Album ParseTidalAlbum(JsonElement album)
|
||||||
{
|
{
|
||||||
var externalId = album.GetProperty("id").GetInt64().ToString();
|
var externalId = album.GetProperty("id").GetInt64().ToString();
|
||||||
@@ -807,8 +849,13 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Think of a way to implement album count when this function is called by search function
|
/// <summary>
|
||||||
// as the API endpoint in search does not include this data
|
/// Parses a Tidal artist object from hifi-api responses.
|
||||||
|
/// Per hifi-api spec, artist objects contain: id, name, picture (UUID).
|
||||||
|
/// Note: albums_count is not in the standard API response but is added by GetArtistAsync.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="artist">JSON element containing artist data</param>
|
||||||
|
/// <returns>Parsed Artist object</returns>
|
||||||
private Artist ParseTidalArtist(JsonElement artist)
|
private Artist ParseTidalArtist(JsonElement artist)
|
||||||
{
|
{
|
||||||
var externalId = artist.GetProperty("id").GetInt64().ToString();
|
var externalId = artist.GetProperty("id").GetInt64().ToString();
|
||||||
@@ -834,6 +881,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Parses a Tidal playlist from hifi-api /playlist/ endpoint response.
|
||||||
|
/// Per hifi-api spec (undocumented), response structure is:
|
||||||
|
/// { "playlist": { uuid, title, description, creator, created, numberOfTracks, duration, squareImage },
|
||||||
|
/// "items": [ { "item": { track object } } ] }
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="playlistElement">Root JSON element containing playlist and items</param>
|
||||||
|
/// <returns>Parsed ExternalPlaylist object</returns>
|
||||||
private ExternalPlaylist ParseTidalPlaylist(JsonElement playlistElement)
|
private ExternalPlaylist ParseTidalPlaylist(JsonElement playlistElement)
|
||||||
{
|
{
|
||||||
JsonElement? playlist = null;
|
JsonElement? playlist = null;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Text.Json;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using allstarr.Models.Settings;
|
using allstarr.Models.Settings;
|
||||||
using allstarr.Services.Validation;
|
using allstarr.Services.Validation;
|
||||||
|
using allstarr.Services.Common;
|
||||||
|
|
||||||
namespace allstarr.Services.SquidWTF;
|
namespace allstarr.Services.SquidWTF;
|
||||||
|
|
||||||
@@ -12,56 +13,26 @@ namespace allstarr.Services.SquidWTF;
|
|||||||
public class SquidWTFStartupValidator : BaseStartupValidator
|
public class SquidWTFStartupValidator : BaseStartupValidator
|
||||||
{
|
{
|
||||||
private readonly SquidWTFSettings _settings;
|
private readonly SquidWTFSettings _settings;
|
||||||
private readonly List<string> _apiUrls;
|
private readonly RoundRobinFallbackHelper _fallbackHelper;
|
||||||
private int _currentUrlIndex = 0;
|
private readonly EndpointBenchmarkService _benchmarkService;
|
||||||
private readonly object _urlIndexLock = new object();
|
private readonly ILogger<SquidWTFStartupValidator> _logger;
|
||||||
|
|
||||||
public override string ServiceName => "SquidWTF";
|
public override string ServiceName => "SquidWTF";
|
||||||
|
|
||||||
public SquidWTFStartupValidator(IOptions<SquidWTFSettings> settings, HttpClient httpClient, List<string> apiUrls)
|
public SquidWTFStartupValidator(
|
||||||
|
IOptions<SquidWTFSettings> settings,
|
||||||
|
HttpClient httpClient,
|
||||||
|
List<string> apiUrls,
|
||||||
|
EndpointBenchmarkService benchmarkService,
|
||||||
|
ILogger<SquidWTFStartupValidator> logger)
|
||||||
: base(httpClient)
|
: base(httpClient)
|
||||||
{
|
{
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
_apiUrls = apiUrls;
|
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
|
||||||
|
_benchmarkService = benchmarkService;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Tries the request with the next provider in round-robin, then falls back to others on failure.
|
|
||||||
/// This distributes load evenly across all providers while maintaining reliability.
|
|
||||||
/// </summary>
|
|
||||||
private async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue)
|
|
||||||
{
|
|
||||||
// Start with the next URL in round-robin to distribute load
|
|
||||||
var startIndex = 0;
|
|
||||||
lock (_urlIndexLock)
|
|
||||||
{
|
|
||||||
startIndex = _currentUrlIndex;
|
|
||||||
_currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try all URLs starting from the round-robin selected one
|
|
||||||
for (int attempt = 0; attempt < _apiUrls.Count; attempt++)
|
|
||||||
{
|
|
||||||
var urlIndex = (startIndex + attempt) % _apiUrls.Count;
|
|
||||||
var baseUrl = _apiUrls[urlIndex];
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
return await action(baseUrl);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
WriteDetail($"Endpoint {baseUrl} failed, trying next...");
|
|
||||||
|
|
||||||
if (attempt == _apiUrls.Count - 1)
|
|
||||||
{
|
|
||||||
WriteDetail($"All {_apiUrls.Count} endpoints failed");
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return defaultValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override async Task<ValidationResult> ValidateAsync(CancellationToken cancellationToken)
|
public override async Task<ValidationResult> ValidateAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -79,8 +50,49 @@ public class SquidWTFStartupValidator : BaseStartupValidator
|
|||||||
|
|
||||||
WriteStatus("SquidWTF Quality", quality, ConsoleColor.Cyan);
|
WriteStatus("SquidWTF Quality", quality, ConsoleColor.Cyan);
|
||||||
|
|
||||||
|
// Benchmark all endpoints to determine fastest
|
||||||
|
var apiUrls = _fallbackHelper.EndpointCount > 0
|
||||||
|
? Enumerable.Range(0, _fallbackHelper.EndpointCount).Select(_ => "").ToList() // Placeholder, we'll get actual URLs from fallback helper
|
||||||
|
: new List<string>();
|
||||||
|
|
||||||
|
// Get the actual API URLs by reflection (not ideal, but works for now)
|
||||||
|
var fallbackHelperType = _fallbackHelper.GetType();
|
||||||
|
var apiUrlsField = fallbackHelperType.GetField("_apiUrls", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||||
|
if (apiUrlsField != null)
|
||||||
|
{
|
||||||
|
apiUrls = (List<string>)apiUrlsField.GetValue(_fallbackHelper)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (apiUrls.Count > 1)
|
||||||
|
{
|
||||||
|
WriteStatus("Benchmarking Endpoints", $"{apiUrls.Count} endpoints", ConsoleColor.Cyan);
|
||||||
|
|
||||||
|
var orderedEndpoints = await _benchmarkService.BenchmarkEndpointsAsync(
|
||||||
|
apiUrls,
|
||||||
|
async (endpoint, ct) =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await _httpClient.GetAsync(endpoint, ct);
|
||||||
|
return response.IsSuccessStatusCode;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pingCount: 2,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (orderedEndpoints.Count > 0)
|
||||||
|
{
|
||||||
|
_fallbackHelper.SetEndpointOrder(orderedEndpoints);
|
||||||
|
WriteDetail($"Fastest endpoint: {orderedEndpoints.First()}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Test connectivity with fallback
|
// Test connectivity with fallback
|
||||||
var result = await TryWithFallbackAsync(async (baseUrl) =>
|
var result = await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||||
{
|
{
|
||||||
var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
|
var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -668,14 +668,13 @@
|
|||||||
<th>Spotify ID</th>
|
<th>Spotify ID</th>
|
||||||
<th>Tracks</th>
|
<th>Tracks</th>
|
||||||
<th>Completion</th>
|
<th>Completion</th>
|
||||||
<th>Lyrics</th>
|
|
||||||
<th>Cache Age</th>
|
<th>Cache Age</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="playlist-table-body">
|
<tbody id="playlist-table-body">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" class="loading">
|
<td colspan="6" class="loading">
|
||||||
<span class="spinner"></span> Loading playlists...
|
<span class="spinner"></span> Loading playlists...
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -1381,7 +1380,7 @@
|
|||||||
|
|
||||||
if (data.playlists.length === 0) {
|
if (data.playlists.length === 0) {
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Jellyfin Playlists tab.</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Jellyfin Playlists tab.</td></tr>';
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1450,9 +1449,6 @@
|
|||||||
<span style="font-size:0.85rem;color:${completionColor};font-weight:500;min-width:40px;">${completionPct}%</span>
|
<span style="font-size:0.85rem;color:${completionColor};font-weight:500;min-width:40px;">${completionPct}%</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
|
||||||
<span style="color:var(--text-secondary);font-size:0.85rem;">-</span>
|
|
||||||
</td>
|
|
||||||
<td class="cache-age">${p.cacheAge || '-'}</td>
|
<td class="cache-age">${p.cacheAge || '-'}</td>
|
||||||
<td>
|
<td>
|
||||||
<button onclick="clearPlaylistCache('${escapeJs(p.name)}')">Clear Cache & Rebuild</button>
|
<button onclick="clearPlaylistCache('${escapeJs(p.name)}')">Clear Cache & Rebuild</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user