Compare commits

..

20 Commits

Author SHA1 Message Date
f741cc5297 fix: simplify ghost playback start to avoid Jellyfin validation errors
Some checks failed
CI / build-and-test (push) Has been cancelled
- Remove complex Item object from playback start (was causing 400 errors)
- Send minimal playback info with just ghost UUID and playback state
- Progress reports already working (204 responses)
- Jellyfin will track session without full item details
2026-02-07 13:30:47 -05:00
1a0e0216f5 feat: implement ghost item reporting for external track WebSocket sessions
- Generate deterministic UUIDs from external track IDs using MD5 hashing
- Create fake BaseItemDto objects with track metadata for external tracks
- Forward playback reports (start/progress/stop) to Jellyfin with ghost items
- Enables 'Now Playing' info in Jellyfin dashboard for external tracks
- Remove redundant JellyfinSessionManager WebSocket creation (client handles via proxy)
- Fix indentation issues in SquidWTF services (tabs to spaces)
- Add apis/*.md to .gitignore for temporary docs
- Fix null reference warning in provider switch expression
2026-02-07 13:27:25 -05:00
73bd3bf308 feat: add endpoint benchmarking on startup
- New EndpointBenchmarkService pings all endpoints on startup
- Measures average response time and success rate
- Reorders endpoints by performance (fastest first)
- RoundRobinFallbackHelper now uses benchmarked order
- Racing still happens, but starts with fastest endpoints
- Reduces latency by prioritizing known-fast servers
- Logs benchmark results for visibility
2026-02-07 12:51:48 -05:00
43bf71c390 fix: race endpoints for download metadata fetching
- GetTrackDownloadInfoAsync now uses RaceAllEndpointsAsync instead of TryWithFallbackAsync
- Prevents sequential fallback through all 10 endpoints on cancellation
- Eliminates cascade of 'task was canceled' warnings
- Consistent racing strategy across all download operations
2026-02-07 12:49:43 -05:00
2254616d32 feat: preserve source search ordering instead of re-scoring
- Respect SquidWTF/Tidal's native search ranking (better than fuzzy matching)
- Interleave local and external results based on average match quality
- Put better-matching source first, preserve original order within each source
- Remove unnecessary re-scoring that was disrupting optimal search results
- Simplifies search logic and improves result relevance
2026-02-07 12:43:15 -05:00
c0444becad feat: add endpoint racing for downloads and searches
- Race all proxy endpoints in parallel for downloads (SquidWTF)
- Use fastest responding server, cancel slower ones
- Apply same racing strategy to search operations
- Reduces download wait times from 5-10s to sub-second
- Reduces search latency from ~1s to ~300-500ms
- Add RaceAllEndpointsAsync method to RoundRobinFallbackHelper
2026-02-07 12:36:50 -05:00
b906a5fd6d Refactor: Extract duplicate code into reusable helpers
- Created RoundRobinFallbackHelper for SquidWTF services (eliminates 3 duplicates)
- Moved QueueRequestAsync to BaseDownloadService (eliminates 2 duplicates)
- Moved CalculateArtistMatchScore to FuzzyMatcher (eliminates 2 duplicates)
- Updated all SquidWTF services to use RoundRobinFallbackHelper
- Updated DeezerDownloadService and SquidWTFDownloadService to use base class rate limiting
- Updated SpotifyTrackMatchingService and JellyfinController to use FuzzyMatcher helper
- All 225 tests passing
2026-02-07 12:27:10 -05:00
e3bcc93597 Add Odesli service for Tidal to Spotify ID conversion
- Created OdesliService to convert Tidal track IDs to Spotify IDs
- Integrated Odesli API calls into SquidWTF download workflow
- Updated SquidWTFDownloadService to use OdesliService for track metadata enrichment
- Fixed dependency injection in Program.cs for OdesliService
- All 225 tests passing
2026-02-07 12:19:41 -05:00
7e6bed51e1 refactor: extract Spotify ID from URL instead of entityUniqueId
- Use linksByPlatform.spotify.url from Odesli response
- Extract track ID from Spotify URL using regex
- More reliable than parsing entityUniqueId format
- Matches the approach used in ConvertToSpotifyIdViaOdesliAsync
2026-02-07 12:06:48 -05:00
47b9427c20 fix: extract Spotify track ID from Odesli entityUniqueId format
- Odesli returns entityUniqueId as 'SPOTIFY_SONG::trackid'
- Now extracts just the track ID part after '::'
- Fixes Spotify lyrics not working due to invalid ID format
- Spotify lyrics service expects clean track IDs like '0PgYPBGqF6Wm5KFHQ81nq5'
2026-02-07 12:05:55 -05:00
bb46db43b1 fix: use persistent cache/Music folder instead of /tmp
- Cache mode now uses cache/Music/ (survives restarts, cleaned after 24h)
- Permanent mode uses downloads/ (keeps forever)
- Fixed all three download services: SquidWTF, Deezer, Qobuz
- Files no longer stored in /tmp/allstarr-cache/ which gets wiped on restart
- Both folders are in project root alongside cache/ and downloads/ directories
2026-02-07 12:02:48 -05:00
3937e637c6 feat: convert Tidal tracks to Spotify ID immediately for lyrics
- Added SpotifyId field to Song model
- SquidWTFMetadataService now calls Odesli API when fetching track metadata
- Spotify ID is populated immediately when track is loaded, not during lyrics fetch
- GetLyrics now checks song.SpotifyId first before falling back to cache/Odesli
- Enables Spotify lyrics for all SquidWTF (Tidal) tracks automatically
- Reduces latency - conversion happens once during track load, not every lyrics request
2026-02-07 11:57:24 -05:00
2272e8d363 fix: use stored session headers for WebSocket auth
- Changed MaintainWebSocketForSessionAsync to use session.Headers instead of parameter
- Parameter headers might be disposed after HTTP request completes
- Session headers are cloned and stored safely in memory
- WebSocket now properly authenticates as the client instead of falling back to server API key
- Sessions will now appear under correct user in Jellyfin dashboard
2026-02-07 11:52:17 -05:00
6169d7a4ac fix: session capabilities using disposed HTTP context
- Extract AccessToken from auth response before background task
- Create new HeaderDictionary with token instead of using Request.Headers
- Prevents ObjectDisposedException when HTTP context is disposed
- Session capabilities now work correctly for all clients

Note: WebSocket support for external tracks already implemented via
JellyfinSessionManager.EnsureSessionAsync and WebSocketProxyMiddleware
2026-02-07 11:49:43 -05:00
da8cb29e08 refactor: make authentication truly transparent proxy
- Pass through ALL Jellyfin responses (success and error) without modification
- Move session capabilities posting to background task (don't block auth response)
- Remove generic error fallbacks - always return Jellyfin's actual response
- Simplify logic: if Jellyfin returns a response, pass it through; if not, return status code only
2026-02-07 11:45:16 -05:00
d88ed64e37 fix: pass through Jellyfin error responses to client
- Modified PostJsonAsync to return error response body as JSON when available
- Updated AuthenticateByName to pass through Jellyfin's actual error response
- Clients now see Jellyfin's real error messages instead of generic ones
2026-02-07 11:42:11 -05:00
210d18220b fix: use case-insensitive provider key matching 2026-02-07 11:22:00 -05:00
c44e48a425 add debug logging to track provider identification 2026-02-07 11:21:03 -05:00
e44b46aee1 remove lyrics column from playlist table 2026-02-07 11:17:48 -05:00
a75df9328a fix: use playlist cache in view tracks endpoint 2026-02-07 11:14:36 -05:00
19 changed files with 1351 additions and 731 deletions

3
.gitignore vendored
View File

@@ -89,6 +89,9 @@ apis/api-calls/*.json
!apis/api-calls/jellyfin-openapi-stable.json
apis/temp.json
# Temporary documentation files
apis/*.md
# Log files for debugging
apis/api-calls/*.log

View File

@@ -603,236 +603,221 @@ public class AdminController : ControllerBase
// Get Spotify tracks
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>();
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
var playlistItemsCacheKey = $"spotify:playlist:items:{decodedName}";
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
try
{
// Get existing tracks from Jellyfin to determine local/external status
var userId = _jellyfinSettings.UserId;
if (!string.IsNullOrEmpty(userId))
cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsCacheKey);
}
catch (Exception cacheEx)
{
_logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", decodedName);
}
_logger.LogInformation("GetPlaylistTracks for {Playlist}: Cache found: {Found}, Count: {Count}",
decodedName, cachedPlaylistItems != null, cachedPlaylistItems?.Count ?? 0);
if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0)
{
// Build a map of Spotify ID -> cached item for quick lookup
var spotifyIdToItem = new Dictionary<string, Dictionary<string, object?>>();
foreach (var item in cachedPlaylistItems)
{
try
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
{
var url = $"{_jellyfinSettings.Url}/Playlists/{playlistConfig.JellyfinId}/Items?UserId={userId}";
var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader());
Dictionary<string, string>? providerIds = null;
var response = await _jellyfinHttpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
if (providerIdsObj is Dictionary<string, string> dict)
{
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))
providerIds = dict;
}
else if (providerIdsObj is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
{
providerIds = new Dictionary<string, string>();
foreach (var prop in jsonEl.EnumerateObject())
{
foreach (var item in items.EnumerateArray())
providerIds[prop.Name] = prop.Value.GetString() ?? "";
}
}
if (providerIds != null && providerIds.TryGetValue("Spotify", out var spotifyId) && !string.IsNullOrEmpty(spotifyId))
{
spotifyIdToItem[spotifyId] = item;
}
}
}
// Match each Spotify track to its cached item
foreach (var track in spotifyTracks)
{
bool? isLocal = null;
string? externalProvider = null;
bool isManualMapping = false;
string? manualMappingType = null;
string? manualMappingId = null;
if (spotifyIdToItem.TryGetValue(track.SpotifyId, out var cachedItem))
{
// Track is in the cache - determine if it's local or external
if (cachedItem.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())
{
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))
{
artist = albumArtistEl.GetString() ?? "";
}
if (!string.IsNullOrEmpty(title))
{
localTracks.Add((title, artist));
}
providerIds[prop.Name] = prop.Value.GetString() ?? "";
}
}
_logger.LogInformation("Found {Count} local tracks in Jellyfin playlist {Playlist}",
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)
if (providerIds != null)
{
bool? isLocal = null;
string? externalProvider = null;
bool isManualMapping = false;
string? manualMappingType = null;
string? manualMappingId = null;
_logger.LogDebug("Track {Title} has ProviderIds: {Keys}", track.Title, string.Join(", ", providerIds.Keys));
// FIRST: Check for manual Jellyfin mapping
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
// 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 (!string.IsNullOrEmpty(manualJellyfinId))
if (providerKey != null)
{
// Manual Jellyfin mapping exists - this track is definitely local
isLocal = true;
isManualMapping = true;
manualMappingType = "jellyfin";
manualMappingId = manualJellyfinId;
_logger.LogDebug("✓ Manual Jellyfin mapping found for {Title}: Jellyfin ID {Id}",
track.Title, manualJellyfinId);
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
{
// Check for external manual mapping
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
if (!string.IsNullOrEmpty(externalMappingJson))
{
try
{
using var extDoc = JsonDocument.Parse(externalMappingJson);
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))
{
externalId = idEl.GetString();
}
if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId))
{
// External manual mapping exists
isLocal = false;
externalProvider = provider;
isManualMapping = true;
manualMappingType = "external";
manualMappingId = externalId;
_logger.LogDebug("✓ Manual external mapping found for {Title}: {Provider} {ExternalId}",
track.Title, provider, externalId);
}
}
catch (Exception ex)
{
_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;
}
}
// 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);
}
// 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
{
// Track is missing (search failed)
isLocal = null;
externalProvider = null;
}
}
// Check lyrics status (only from our cache - lrclib/Spotify)
// 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 existingLyrics = await _cache.GetStringAsync(cacheKey);
var hasLyrics = !string.IsNullOrEmpty(existingLyrics);
tracksWithStatus.Add(new
{
position = track.Position,
title = track.Title,
artists = track.Artists,
album = track.Album,
isrc = track.Isrc,
spotifyId = track.SpotifyId,
durationMs = track.DurationMs,
albumArtUrl = track.AlbumArtUrl,
isLocal = isLocal,
externalProvider = externalProvider,
searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null, // Set for both external and missing
isManualMapping = isManualMapping,
manualMappingType = manualMappingType,
manualMappingId = manualMappingId,
hasLyrics = hasLyrics
});
}
return Ok(new
else
{
name = decodedName,
trackCount = spotifyTracks.Count,
tracks = tracksWithStatus
});
_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
{
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
if (!string.IsNullOrEmpty(externalMappingJson))
{
try
{
using var extDoc = JsonDocument.Parse(externalMappingJson);
var extRoot = extDoc.RootElement;
if (extRoot.TryGetProperty("id", out var idEl))
{
isManualMapping = true;
manualMappingType = "external";
manualMappingId = idEl.GetString();
}
}
catch { }
}
}
}
catch (Exception ex)
else
{
_logger.LogWarning(ex, "Failed to get local track status for {Playlist}", decodedName);
// Track not in cache - it's missing
isLocal = null;
externalProvider = null;
}
// Check lyrics status
var cacheKey = $"lyrics:{track.PrimaryArtist}:{track.Title}:{track.Album}:{track.DurationMs / 1000}";
var existingLyrics = await _cache.GetStringAsync(cacheKey);
var hasLyrics = !string.IsNullOrEmpty(existingLyrics);
tracksWithStatus.Add(new
{
position = track.Position,
title = track.Title,
artists = track.Artists,
album = track.Album,
isrc = track.Isrc,
spotifyId = track.SpotifyId,
durationMs = track.DurationMs,
albumArtUrl = track.AlbumArtUrl,
isLocal = isLocal,
externalProvider = externalProvider,
searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null,
isManualMapping = isManualMapping,
manualMappingType = manualMappingType,
manualMappingId = manualMappingId,
hasLyrics = hasLyrics
});
}
return Ok(new
{
name = decodedName,
trackCount = spotifyTracks.Count,
tracks = tracksWithStatus
});
}
// If we get here, we couldn't get local tracks from Jellyfin
// Just return tracks with basic external/missing status based on cache
// Fallback: Cache not available, use matched tracks cache
_logger.LogWarning("Playlist cache not available for {Playlist}, using fallback", decodedName);
var fallbackMatchedTracksKey = $"spotify:matched:ordered:{decodedName}";
var fallbackMatchedTracks = await _cache.GetAsync<List<MatchedTrack>>(fallbackMatchedTracksKey);
var fallbackMatchedSpotifyIds = new HashSet<string>(
fallbackMatchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
);
// Clear and reuse tracksWithStatus for fallback
tracksWithStatus.Clear();
foreach (var track in spotifyTracks)
{
bool? isLocal = null;
@@ -879,13 +864,11 @@ public class AdminController : ControllerBase
}
else if (fallbackMatchedSpotifyIds.Contains(track.SpotifyId))
{
// Track is externally matched (search succeeded)
isLocal = false;
externalProvider = "SquidWTF"; // Default to SquidWTF for external matches
externalProvider = "SquidWTF";
}
else
{
// Track is missing (search failed)
isLocal = null;
externalProvider = null;
}
@@ -903,7 +886,7 @@ public class AdminController : ControllerBase
albumArtUrl = track.AlbumArtUrl,
isLocal = isLocal,
externalProvider = externalProvider,
searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null // Set for both external and missing
searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null
});
}

View File

@@ -40,6 +40,7 @@ public class JellyfinController : ControllerBase
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
private readonly SpotifyLyricsService? _spotifyLyricsService;
private readonly LrclibService? _lrclibService;
private readonly OdesliService _odesliService;
private readonly RedisCacheService _cache;
private readonly IConfiguration _configuration;
private readonly ILogger<JellyfinController> _logger;
@@ -55,6 +56,7 @@ public class JellyfinController : ControllerBase
JellyfinModelMapper modelMapper,
JellyfinProxyService proxyService,
JellyfinSessionManager sessionManager,
OdesliService odesliService,
RedisCacheService cache,
IConfiguration configuration,
ILogger<JellyfinController> logger,
@@ -79,6 +81,7 @@ public class JellyfinController : ControllerBase
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
_spotifyLyricsService = spotifyLyricsService;
_lrclibService = lrclibService;
_odesliService = odesliService;
_cache = cache;
_configuration = configuration;
_logger = logger;
@@ -276,63 +279,71 @@ public class JellyfinController : ControllerBase
// Parse Jellyfin results into domain models
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult);
// Score and filter Jellyfin results by relevance
var scoredLocalSongs = ScoreSearchResults(cleanQuery, localSongs, s => s.Title, s => s.Artist, s => s.Album, isExternal: false);
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);
// Respect source ordering (SquidWTF/Tidal has better search ranking than our fuzzy matching)
// Just interleave local and external results based on which source has better overall match
// Score external results with a small boost
var scoredExternalSongs = ScoreSearchResults(cleanQuery, externalResult.Songs, s => s.Title, s => s.Artist, s => s.Album, isExternal: true);
var scoredExternalAlbums = ScoreSearchResults(cleanQuery, externalResult.Albums, a => a.Title, a => a.Artist, _ => null, isExternal: true);
var scoredExternalArtists = ScoreSearchResults(cleanQuery, externalResult.Artists, a => a.Name, _ => null, _ => null, isExternal: true);
// Calculate average match score for each source to determine which should come first
var localSongsAvgScore = localSongs.Any()
? localSongs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
: 0.0;
var externalSongsAvgScore = externalResult.Songs.Any()
? externalResult.Songs.Average(s => FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title))
: 0.0;
// Merge and sort by score (no filtering - just reorder by relevance)
var allSongs = scoredLocalSongs.Concat(scoredExternalSongs)
.OrderByDescending(x => x.Score)
.Select(x => x.Item)
.ToList();
var localAlbumsAvgScore = localAlbums.Any()
? localAlbums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
: 0.0;
var externalAlbumsAvgScore = externalResult.Albums.Any()
? externalResult.Albums.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title))
: 0.0;
var allAlbums = scoredLocalAlbums.Concat(scoredExternalAlbums)
.OrderByDescending(x => x.Score)
.Select(x => x.Item)
.ToList();
var localArtistsAvgScore = localArtists.Any()
? localArtists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
: 0.0;
var externalArtistsAvgScore = externalResult.Artists.Any()
? externalResult.Artists.Average(a => FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name))
: 0.0;
// NO deduplication - just merge and sort by relevance score
// Show ALL matches (local + external) sorted by best match first
var artistScores = scoredLocalArtists.Concat(scoredExternalArtists)
.OrderByDescending(x => x.Score)
.Select(x => x.Item)
.ToList();
// Interleave results: put better-matching source first, preserve original ordering within each source
var allSongs = localSongsAvgScore >= externalSongsAvgScore
? localSongs.Concat(externalResult.Songs).ToList()
: externalResult.Songs.Concat(localSongs).ToList();
var allAlbums = localAlbumsAvgScore >= externalAlbumsAvgScore
? localAlbums.Concat(externalResult.Albums).ToList()
: externalResult.Albums.Concat(localAlbums).ToList();
var allArtists = localArtistsAvgScore >= externalArtistsAvgScore
? localArtists.Concat(externalResult.Artists).ToList()
: externalResult.Artists.Concat(localArtists).ToList();
// Log results for debugging
if (_logger.IsEnabled(LogLevel.Debug))
{
var localArtistNames = scoredLocalArtists.Select(x => $"{x.Item.Name} (local, score: {x.Score:F2})").ToList();
var externalArtistNames = scoredExternalArtists.Select(x => $"{x.Item.Name} ({x.Item.ExternalProvider}, score: {x.Score:F2})").ToList();
_logger.LogDebug("🎤 Artist results: Local={LocalArtists}, External={ExternalArtists}, Total={TotalCount}",
string.Join(", ", localArtistNames),
string.Join(", ", externalArtistNames),
artistScores.Count);
_logger.LogDebug("🎵 Songs: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
localSongsAvgScore, externalSongsAvgScore, localSongsAvgScore >= externalSongsAvgScore);
_logger.LogDebug("💿 Albums: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
localAlbumsAvgScore, externalAlbumsAvgScore, localAlbumsAvgScore >= externalAlbumsAvgScore);
_logger.LogDebug("🎤 Artists: Local avg score={LocalScore:F2}, External avg score={ExtScore:F2}, Local first={LocalFirst}",
localArtistsAvgScore, externalArtistsAvgScore, localArtistsAvgScore >= externalArtistsAvgScore);
}
// Convert to Jellyfin format
var mergedSongs = allSongs.Select(s => _responseBuilder.ConvertSongToJellyfinItem(s)).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)
{
var scoredPlaylists = playlistResult
.Select(p => new { Playlist = p, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, p.Name) })
.OrderByDescending(x => x.Score)
.Select(x => _responseBuilder.ConvertPlaylistToJellyfinItem(x.Playlist))
var playlistItems = playlistResult
.Select(p => _responseBuilder.ConvertPlaylistToJellyfinItem(p))
.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);
// 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!);
// Try to find Spotify ID from matched tracks cache
// External tracks from playlists should have been matched and cached
if (song != null)
// Use Spotify ID from song metadata if available (populated during GetSongAsync)
if (song != null && !string.IsNullOrEmpty(song.SpotifyId))
{
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);
if (!string.IsNullOrEmpty(spotifyTrackId))
@@ -1177,9 +1194,27 @@ public class JellyfinController : ControllerBase
}
else
{
// If no cached Spotify ID, try to convert via Odesli/song.link
// This works for SquidWTF (Tidal), Deezer, Qobuz, etc.
spotifyTrackId = await ConvertToSpotifyIdViaOdesliAsync(song, provider!, externalId!);
// Last resort: Try to convert via Odesli/song.link
if (provider == "squidwtf")
{
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))
{
_logger.LogInformation("Converted {Provider}/{ExternalId} to Spotify ID {SpotifyId} via Odesli",
@@ -1404,9 +1439,9 @@ public class JellyfinController : ControllerBase
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
// 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,53 +1865,83 @@ public class JellyfinController : ControllerBase
_logger.LogInformation("Authentication request received");
// 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);
if (result == null)
// Pass through Jellyfin's response exactly as-is (transparent proxy)
if (result != null)
{
_logger.LogWarning("Authentication failed - status {StatusCode}", statusCode);
if (statusCode == 401)
var responseJson = result.RootElement.GetRawText();
// On successful auth, extract access token and post session capabilities in background
if (statusCode == 200)
{
return Unauthorized(new { error = "Invalid username or password" });
}
return StatusCode(statusCode, new { error = "Authentication failed" });
}
_logger.LogInformation("Authentication successful");
_logger.LogInformation("Authentication successful");
// Extract access token from response for session capabilities
string? accessToken = null;
if (result.RootElement.TryGetProperty("AccessToken", out var tokenEl))
{
accessToken = tokenEl.GetString();
}
// Post session capabilities immediately after authentication
// This ensures Jellyfin creates a session that will show up in the dashboard
try
{
_logger.LogInformation("🔧 Posting session capabilities after authentication");
var capabilities = new
{
PlayableMediaTypes = new[] { "Audio" },
SupportedCommands = Array.Empty<string>(),
SupportsMediaControl = false,
SupportsPersistentIdentifier = true,
SupportsSync = false
};
// 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
{
_logger.LogDebug("🔧 Posting session capabilities after authentication");
var capabilitiesJson = JsonSerializer.Serialize(capabilities);
var (capResult, capStatus) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", capabilitiesJson, Request.Headers);
// Build auth header with the new token
var authHeaders = new HeaderDictionary
{
["X-Emby-Token"] = token
};
if (capStatus == 204 || capStatus == 200)
{
_logger.LogInformation("✓ Session capabilities posted after auth ({StatusCode})", capStatus);
var capabilities = new
{
PlayableMediaTypes = new[] { "Audio" },
SupportedCommands = Array.Empty<string>(),
SupportsMediaControl = false,
SupportsPersistentIdentifier = true,
SupportsSync = false
};
var capabilitiesJson = JsonSerializer.Serialize(capabilities);
var (capResult, capStatus) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", capabilitiesJson, authHeaders);
if (capStatus == 204 || capStatus == 200)
{
_logger.LogDebug("✓ Session capabilities posted after auth ({StatusCode})", capStatus);
}
else
{
_logger.LogDebug("⚠ Session capabilities returned {StatusCode} after auth", capStatus);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to post session capabilities after auth");
}
});
}
}
else
{
_logger.LogWarning("⚠ Session capabilities returned {StatusCode} after auth", capStatus);
_logger.LogWarning("Authentication failed - status {StatusCode}", statusCode);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to post session capabilities after auth, continuing anyway");
// Return Jellyfin's exact response
return Content(responseJson, "application/json");
}
return Content(result.RootElement.GetRawText(), "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)
{
@@ -2221,35 +2286,37 @@ public class JellyfinController : ControllerBase
}
});
// CRITICAL: Create session for external tracks too!
// Even though Jellyfin doesn't know about the track, we need a session
// for the client to appear in the dashboard and receive remote control commands
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);
// Create a ghost/fake item to report to Jellyfin so "Now Playing" shows up
// Generate a deterministic UUID from the external ID
var ghostUuid = GenerateUuidFromString(itemId);
if (sessionCreated)
{
_logger.LogInformation("✓ SESSION: Session created for external track playback on device {DeviceId}", deviceId);
}
else
{
_logger.LogWarning("⚠️ SESSION: Failed to create session for external track playback");
}
// 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
{
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
{
_logger.LogWarning("⚠️ SESSION: No device ID found for external track playback");
_logger.LogWarning("⚠️ Ghost playback start returned status {StatusCode} for external track", ghostStatusCode);
}
// 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();
}
@@ -2402,25 +2469,36 @@ public class JellyfinController : ControllerBase
if (isExternal)
{
// For external tracks, update session activity to keep it alive
// This ensures the session remains visible in Jellyfin dashboard
if (!string.IsNullOrEmpty(deviceId))
{
_sessionManager.UpdateActivity(deviceId);
// For external tracks, report progress with ghost UUID to Jellyfin
var ghostUuid = GenerateUuidFromString(itemId);
// Log progress occasionally for debugging (every ~30 seconds)
if (positionTicks.HasValue)
// Build progress report with ghost UUID
var progressReport = new
{
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)
if (positionTicks.HasValue)
{
var position = TimeSpan.FromTicks(positionTicks.Value);
if (position.Seconds % 30 == 0 && position.Milliseconds < 500)
{
var position = TimeSpan.FromTicks(positionTicks.Value);
if (position.Seconds % 30 == 0 && position.Milliseconds < 500)
{
_logger.LogDebug("▶️ External track progress: {Position:mm\\:ss} ({Provider}/{ExternalId})",
position, provider, externalId);
}
_logger.LogDebug("▶️ External track progress: {Position:mm\\:ss} ({Provider}/{ExternalId}) - Status: {StatusCode}",
position, provider, externalId, progressStatusCode);
}
}
// Just acknowledge (no reporting to Jellyfin for external tracks)
return NoContent();
}
@@ -2437,6 +2515,8 @@ public class JellyfinController : ControllerBase
}
// 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);
if (statusCode != 204 && statusCode != 200)
@@ -2511,11 +2591,23 @@ public class JellyfinController : ControllerBase
_logger.LogInformation("🎵 External track playback stopped: {Name} at {Position} ({Provider}/{ExternalId})",
itemName ?? "Unknown", position, provider, externalId);
// Mark session as potentially ended after playback stops
// Wait 50 seconds for next song to start before cleaning up
if (!string.IsNullOrEmpty(deviceId))
// Report stop to Jellyfin with ghost UUID
var ghostUuid = GenerateUuidFromString(itemId);
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();
@@ -2527,6 +2619,24 @@ public class JellyfinController : ControllerBase
// For local tracks, forward 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);
if (statusCode == 204 || statusCode == 200)
@@ -3696,8 +3806,7 @@ public class JellyfinController : ControllerBase
{
Song = song,
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
// Calculate artist score by checking ALL artists match
ArtistScore = CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
})
.Select(x => new
{
@@ -4388,6 +4497,24 @@ public class JellyfinController : ControllerBase
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>
/// 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.
@@ -4432,122 +4559,6 @@ public class JellyfinController : ControllerBase
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

View File

@@ -44,6 +44,11 @@ public class Song
/// </summary>
public string? Isrc { get; set; }
/// <summary>
/// Spotify track ID (for lyrics and matching)
/// </summary>
public string? SpotifyId { get; set; }
/// <summary>
/// Full release date (format: YYYY-MM-DD)
/// </summary>

View File

@@ -379,6 +379,7 @@ else
// Business services - shared across backends
builder.Services.AddSingleton<RedisCacheService>();
builder.Services.AddSingleton<OdesliService>();
builder.Services.AddSingleton<ILocalLibraryService, LocalLibraryService>();
builder.Services.AddSingleton<LrclibService>();
@@ -459,6 +460,7 @@ else if (musicService == MusicService.SquidWTF)
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
sp,
sp.GetRequiredService<ILogger<SquidWTFDownloadService>>(),
sp.GetRequiredService<OdesliService>(),
squidWtfApiUrls));
}
@@ -475,13 +477,18 @@ else
builder.Services.AddSingleton<IStartupValidator, SubsonicStartupValidator>();
}
// Register endpoint benchmark service
builder.Services.AddSingleton<EndpointBenchmarkService>();
builder.Services.AddSingleton<IStartupValidator, DeezerStartupValidator>();
builder.Services.AddSingleton<IStartupValidator, QobuzStartupValidator>();
builder.Services.AddSingleton<IStartupValidator>(sp =>
new SquidWTFStartupValidator(
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
sp.GetRequiredService<IHttpClientFactory>().CreateClient(),
squidWtfApiUrls));
squidWtfApiUrls,
sp.GetRequiredService<EndpointBenchmarkService>(),
sp.GetRequiredService<ILogger<SquidWTFStartupValidator>>()));
builder.Services.AddSingleton<IStartupValidator, LyricsStartupValidator>();
// Register orchestrator as hosted service

View File

@@ -31,6 +31,11 @@ public abstract class BaseDownloadService : IDownloadService
protected readonly ConcurrentDictionary<string, DownloadInfo> ActiveDownloads = new();
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>
/// Lazy-loaded PlaylistSyncService to avoid circular dependency
/// </summary>
@@ -121,20 +126,13 @@ public abstract class BaseDownloadService : IDownloadService
return null;
}
// Check local library
// Check local library (works for both cache and permanent storage)
var localPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
if (localPath != null && IOFile.Exists(localPath))
{
return localPath;
}
// Check cache directory
var cachedPath = GetCachedFilePath(externalProvider, externalId);
if (cachedPath != null && IOFile.Exists(cachedPath))
{
return cachedPath;
}
return null;
}
@@ -203,27 +201,19 @@ public abstract class BaseDownloadService : IDownloadService
try
{
// Check if already downloaded (skip for cache mode as we want to check cache folder)
if (!isCache)
// Check if already downloaded (works for both cache and permanent modes)
var existingPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
if (existingPath != null && IOFile.Exists(existingPath))
{
var existingPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
if (existingPath != null && IOFile.Exists(existingPath))
Logger.LogInformation("Song already downloaded: {Path}", existingPath);
// For cache mode, update file access time for cache cleanup logic
if (isCache)
{
Logger.LogInformation("Song already downloaded: {Path}", 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;
IOFile.SetLastAccessTime(existingPath, DateTime.UtcNow);
}
return existingPath;
}
// Check if download in progress
@@ -577,29 +567,34 @@ public abstract class BaseDownloadService : IDownloadService
}
}
#endregion
#region Rate Limiting
/// <summary>
/// Gets the cached file path for a given provider and external ID
/// Returns null if no cached file exists
/// Queues a request with rate limiting to prevent overwhelming the API.
/// Ensures minimum interval between requests.
/// </summary>
protected string? GetCachedFilePath(string provider, string externalId)
protected async Task<T> QueueRequestAsync<T>(Func<Task<T>> action)
{
await _requestLock.WaitAsync();
try
{
// Search for cached files matching the pattern: {provider}_{externalId}.*
var pattern = $"{provider}_{externalId}.*";
var files = Directory.GetFiles(CachePath, pattern, SearchOption.AllDirectories);
var now = DateTime.UtcNow;
var timeSinceLastRequest = (now - _lastRequestTime).TotalMilliseconds;
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);
return null;
_requestLock.Release();
}
}

View 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; }
}

View File

@@ -221,4 +221,54 @@ public static class FuzzyMatcher
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;
}
}

View 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;
}
}

View 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;
}
}

View File

@@ -24,7 +24,6 @@ namespace allstarr.Services.Deezer;
public class DeezerDownloadService : BaseDownloadService
{
private readonly HttpClient _httpClient;
private readonly SemaphoreSlim _requestLock = new(1, 1);
private readonly string? _arl;
private readonly string? _arlFallback;
@@ -33,9 +32,6 @@ public class DeezerDownloadService : BaseDownloadService
private string? _apiToken;
private string? _licenseToken;
private DateTime _lastRequestTime = DateTime.MinValue;
private readonly int _minRequestIntervalMs = 200;
private const string DeezerApiBase = "https://api.deezer.com";
// 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)
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);
// Create directories if they don't exist
@@ -494,27 +493,6 @@ public class DeezerDownloadService : BaseDownloadService
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

View File

@@ -374,6 +374,21 @@ public class JellyfinProxyService
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogWarning("❌ SESSION: Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
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);
}

View File

@@ -298,20 +298,23 @@ public class JellyfinSessionManager : IDisposable
webSocket = new ClientWebSocket();
session.WebSocket = webSocket;
// Use stored session headers instead of parameter (parameter might be disposed)
var sessionHeaders = session.Headers;
// Log available headers for debugging
_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
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());
_logger.LogDebug("🔑 WEBSOCKET: Using X-Emby-Authorization for {DeviceId}: {Auth}",
deviceId, embyAuth.ToString().Length > 50 ? embyAuth.ToString()[..50] + "..." : embyAuth.ToString());
authFound = true;
}
else if (headers.TryGetValue("Authorization", out var auth))
else if (sessionHeaders.TryGetValue("Authorization", out var auth))
{
var authValue = auth.ToString();
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))

View File

@@ -110,7 +110,10 @@ public class QobuzDownloadService : BaseDownloadService
// Build organized folder structure using AlbumArtist (fallback to Artist for singles)
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 albumFolder = Path.GetDirectoryName(outputPath)!;

View File

@@ -554,7 +554,7 @@ public class SpotifyTrackMatchingService : BackgroundService
Song = song,
// Use aggressive matching which follows optimal order internally
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
ArtistScore = CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
})
.Select(x => new
{
@@ -640,7 +640,7 @@ public class SpotifyTrackMatchingService : BackgroundService
Song = song,
// Use aggressive matching which follows optimal order internally
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
ArtistScore = CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
})
.Select(x => new
{
@@ -744,7 +744,7 @@ public class SpotifyTrackMatchingService : BackgroundService
Song = song,
TitleScore = FuzzyMatcher.CalculateSimilarity(track.Title, song.Title),
// Calculate artist score by checking ALL artists match
ArtistScore = CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(track.Artists, song.Artist, song.Contributors)
})
.Select(x => new
{

View File

@@ -14,21 +14,47 @@ using Microsoft.Extensions.Logging;
namespace allstarr.Services.SquidWTF;
/// <summary>
/// Handles track downloading from tidal.squid.wtf (no encryption, no auth required)
/// Downloads are direct from Tidal's CDN via the squid.wtf proxy
/// Handles track downloading from tidal.squid.wtf (no encryption, no auth required).
///
/// 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>
public class SquidWTFDownloadService : BaseDownloadService
{
private readonly HttpClient _httpClient;
private readonly SemaphoreSlim _requestLock = new(1, 1);
private readonly SquidWTFSettings _squidwtfSettings;
private DateTime _lastRequestTime = DateTime.MinValue;
private readonly int _minRequestIntervalMs = 200;
private readonly List<string> _apiUrls;
private int _currentUrlIndex = 0;
private readonly object _urlIndexLock = new object();
private readonly OdesliService _odesliService;
private readonly RoundRobinFallbackHelper _fallbackHelper;
protected override string ProviderName => "squidwtf";
@@ -41,59 +67,22 @@ public class SquidWTFDownloadService : BaseDownloadService
IOptions<SquidWTFSettings> SquidWTFSettings,
IServiceProvider serviceProvider,
ILogger<SquidWTFDownloadService> logger,
OdesliService odesliService,
List<string> apiUrls)
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, serviceProvider, logger)
{
_httpClient = httpClientFactory.CreateClient();
_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
public override async Task<bool> IsAvailableAsync()
{
return await TryWithFallbackAsync(async (baseUrl) =>
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var response = await _httpClient.GetAsync(baseUrl);
Console.WriteLine($"Response code from is available async: {response.IsSuccessStatusCode}");
@@ -116,8 +105,11 @@ public class SquidWTFDownloadService : BaseDownloadService
{
var downloadInfo = await GetTrackDownloadInfoAsync(trackId, cancellationToken);
Logger.LogInformation("Track token obtained: {Url}", downloadInfo.DownloadUrl);
Logger.LogInformation("Using format: {Format}", downloadInfo.MimeType);
Logger.LogInformation("Track download URL obtained from hifi-api: {Url}", downloadInfo.DownloadUrl);
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
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)
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);
// Create directories if they don't exist
@@ -140,15 +135,58 @@ public class SquidWTFDownloadService : BaseDownloadService
// Resolve unique path if file already exists
outputPath = PathHelper.ResolveUniquePath(outputPath);
// Download from Tidal CDN (no authentication needed, token is in URL)
var response = await QueueRequestAsync(async () =>
// Race all endpoints to download from the fastest one
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("Accept", "*/*");
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
});
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct);
}, cancellationToken);
response.EnsureSuccessStatusCode();
@@ -161,6 +199,13 @@ public class SquidWTFDownloadService : BaseDownloadService
// Close file before writing metadata
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
await WriteMetadataAsync(outputPath, song, cancellationToken);
@@ -171,13 +216,22 @@ public class SquidWTFDownloadService : BaseDownloadService
#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)
{
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
{
"FLAC" => "LOSSLESS",
@@ -190,12 +244,12 @@ public class SquidWTFDownloadService : BaseDownloadService
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();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var json = await response.Content.ReadAsStringAsync(ct);
var doc = JsonDocument.Parse(json);
if (!doc.RootElement.TryGetProperty("data", out var data))
@@ -237,35 +291,15 @@ public class SquidWTFDownloadService : BaseDownloadService
MimeType = mimeType ?? "audio/flac",
AudioQuality = audioQuality ?? "LOSSLESS"
};
});
}, cancellationToken);
});
}
#endregion
#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

View File

@@ -12,7 +12,41 @@ using System.Text.Json.Nodes;
namespace allstarr.Services.SquidWTF;
/// <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>
public class SquidWTFMetadataService : IMusicMetadataService
@@ -21,9 +55,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
private readonly SubsonicSettings _settings;
private readonly ILogger<SquidWTFMetadataService> _logger;
private readonly RedisCacheService _cache;
private readonly List<string> _apiUrls;
private int _currentUrlIndex = 0;
private readonly object _urlIndexLock = new object();
private readonly RoundRobinFallbackHelper _fallbackHelper;
public SquidWTFMetadataService(
IHttpClientFactory httpClientFactory,
@@ -37,79 +69,30 @@ public class SquidWTFMetadataService : IMusicMetadataService
_settings = settings.Value;
_logger = logger;
_cache = cache;
_apiUrls = apiUrls;
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
// Set up default headers
_httpClient.DefaultRequestHeaders.Add("User-Agent",
"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)
{
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 response = await _httpClient.GetAsync(url);
var response = await _httpClient.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
{
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
var result = JsonDocument.Parse(json);
@@ -120,6 +103,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
}
var songs = new List<Song>();
// Per hifi-api spec: track search returns data.items array
if (result.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("items", out var items))
{
@@ -129,30 +113,36 @@ public class SquidWTFMetadataService : IMusicMetadataService
if (count >= limit) break;
var song = ParseTidalTrack(track);
songs.Add(song);
if (ShouldIncludeSong(song))
{
songs.Add(song);
}
count++;
}
}
return songs;
}, new List<Song>());
});
}
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 response = await _httpClient.GetAsync(url);
var response = await _httpClient.GetAsync(url, ct);
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 albums = new List<Album>();
// Per hifi-api spec: album search returns data.albums.items array
if (result.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("albums", out var albumsObj) &&
albumsObj.TryGetProperty("items", out var items))
@@ -168,28 +158,31 @@ public class SquidWTFMetadataService : IMusicMetadataService
}
return albums;
}, new List<Album>());
});
}
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)}";
_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)
{
_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 artists = new List<Artist>();
// Per hifi-api spec: artist search returns data.artists.items array
if (result.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("artists", out var artistsObj) &&
artistsObj.TryGetProperty("items", out var items))
@@ -208,13 +201,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
_logger.LogInformation("✓ SQUIDWTF: Artist search returned {Count} results", artists.Count);
return artists;
}, new List<Artist>());
});
}
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 response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return new List<ExternalPlaylist>();
@@ -223,15 +217,20 @@ public class SquidWTFMetadataService : IMusicMetadataService
var result = JsonDocument.Parse(json);
var playlists = new List<ExternalPlaylist>();
// Per hifi-api spec: playlist search returns data.playlists.items array
if (result.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("playlists", out var playlistObj) &&
playlistObj.TryGetProperty("items", out var items))
{
int count = 0;
foreach(var playlist in items.EnumerateArray())
{
if (count >= limit) break;
try
{
playlists.Add(ParseTidalPlaylist(playlist));
count++;
}
catch (Exception ex)
{
@@ -267,8 +266,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
{
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 response = await _httpClient.GetAsync(url);
@@ -277,10 +277,16 @@ public class SquidWTFMetadataService : IMusicMetadataService
var json = await response.Content.ReadAsStringAsync();
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))
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);
}
@@ -293,8 +299,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
var cached = await _cache.GetAsync<Album>(cacheKey);
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 response = await _httpClient.GetAsync(url);
@@ -303,17 +310,18 @@ public class SquidWTFMetadataService : IMusicMetadataService
var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json);
// Response structure: { "data": { album object with "items" array of tracks } }
if (!result.RootElement.TryGetProperty("data", out var albumElement))
return null;
var album = ParseTidalAlbum(albumElement);
// Get album tracks
// Get album tracks from items array
if (albumElement.TryGetProperty("items", out var tracks))
{
foreach (var trackWrapper in tracks.EnumerateArray())
{
// Each item is wrapped: { "item": { track object } }
if (trackWrapper.TryGetProperty("item", out var track))
{
var song = ParseTidalTrack(track);
@@ -347,8 +355,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
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}";
_logger.LogInformation("Fetching artist from {Url}", url);
@@ -366,7 +375,8 @@ public class SquidWTFMetadataService : IMusicMetadataService
JsonElement? artistSource = null;
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) &&
albums.TryGetProperty("items", out var albumItems) &&
albumItems.GetArrayLength() > 0)
@@ -398,6 +408,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
}
var artistElement = artistSource.Value;
// Normalize artist data to include album count
var normalizedArtist = new JsonObject
{
["id"] = artistElement.GetProperty("id").GetInt64(),
@@ -422,10 +433,11 @@ public class SquidWTFMetadataService : IMusicMetadataService
{
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);
// Note: hifi-api doesn't document artist endpoint, but /artist/?f={artistId} is commonly used
var url = $"{baseUrl}/artist/?f={externalId}";
_logger.LogInformation("Fetching artist albums from URL: {Url}", url);
var response = await _httpClient.GetAsync(url);
@@ -442,6 +454,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
var albums = new List<Album>();
// Response structure: { "albums": { "items": [ album objects ] } }
if (result.RootElement.TryGetProperty("albums", out var albumsObj) &&
albumsObj.TryGetProperty("items", out var items))
{
@@ -467,8 +480,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
{
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 response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return null;
@@ -476,8 +490,10 @@ public class SquidWTFMetadataService : IMusicMetadataService
var json = await response.Content.ReadAsStringAsync();
var playlistElement = JsonDocument.Parse(json).RootElement;
// Check for error response
if (playlistElement.TryGetProperty("error", out _)) return null;
// Response structure: { "playlist": { playlist object }, "items": [ track wrappers ] }
return ParseTidalPlaylist(playlistElement);
}, (ExternalPlaylist?)null);
}
@@ -486,8 +502,9 @@ public class SquidWTFMetadataService : IMusicMetadataService
{
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 response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return new List<Song>();
@@ -495,11 +512,13 @@ public class SquidWTFMetadataService : IMusicMetadataService
var json = await response.Content.ReadAsStringAsync();
var playlistElement = JsonDocument.Parse(json).RootElement;
// Check for error response
if (playlistElement.TryGetProperty("error", out _)) return new List<Song>();
JsonElement? playlist = null;
JsonElement? tracks = null;
// Response structure: { "playlist": { playlist object }, "items": [ track wrappers ] }
if (playlistElement.TryGetProperty("playlist", out var playlistEl))
{
playlist = playlistEl;
@@ -522,6 +541,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
int trackIndex = 1;
foreach (var entry in tracks.Value.EnumerateArray())
{
// Each item is wrapped: { "item": { track object } }
if (!entry.TryGetProperty("item", out var track))
continue;
@@ -544,6 +564,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
// --- 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)
{
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)
{
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)
{
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
// as the API endpoint in search does not include this data
/// <summary>
/// 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)
{
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)
{
JsonElement? playlist = null;

View File

@@ -3,6 +3,7 @@ using System.Text.Json;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
using allstarr.Services.Validation;
using allstarr.Services.Common;
namespace allstarr.Services.SquidWTF;
@@ -12,56 +13,26 @@ namespace allstarr.Services.SquidWTF;
public class SquidWTFStartupValidator : BaseStartupValidator
{
private readonly SquidWTFSettings _settings;
private readonly List<string> _apiUrls;
private int _currentUrlIndex = 0;
private readonly object _urlIndexLock = new object();
private readonly RoundRobinFallbackHelper _fallbackHelper;
private readonly EndpointBenchmarkService _benchmarkService;
private readonly ILogger<SquidWTFStartupValidator> _logger;
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)
{
_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)
{
@@ -79,8 +50,49 @@ public class SquidWTFStartupValidator : BaseStartupValidator
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
var result = await TryWithFallbackAsync(async (baseUrl) =>
var result = await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
var response = await _httpClient.GetAsync(baseUrl, cancellationToken);

View File

@@ -668,14 +668,13 @@
<th>Spotify ID</th>
<th>Tracks</th>
<th>Completion</th>
<th>Lyrics</th>
<th>Cache Age</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="playlist-table-body">
<tr>
<td colspan="7" class="loading">
<td colspan="6" class="loading">
<span class="spinner"></span> Loading playlists...
</td>
</tr>
@@ -1381,7 +1380,7 @@
if (data.playlists.length === 0) {
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;
}
@@ -1450,9 +1449,6 @@
<span style="font-size:0.85rem;color:${completionColor};font-weight:500;min-width:40px;">${completionPct}%</span>
</div>
</td>
<td>
<span style="color:var(--text-secondary);font-size:0.85rem;">-</span>
</td>
<td class="cache-age">${p.cacheAge || '-'}</td>
<td>
<button onclick="clearPlaylistCache('${escapeJs(p.name)}')">Clear Cache & Rebuild</button>