diff --git a/.gitignore b/.gitignore
index 7949c16..35d6d67 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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
diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs
index a1f4ee0..3301c00 100644
--- a/allstarr/Controllers/JellyfinController.cs
+++ b/allstarr/Controllers/JellyfinController.cs
@@ -1202,7 +1202,7 @@ public class JellyfinController : ControllerBase
else
{
// For other providers, build the URL and convert
- var sourceUrl = provider.ToLowerInvariant() switch
+ var sourceUrl = provider?.ToLowerInvariant() switch
{
"deezer" => $"https://www.deezer.com/track/{externalId}",
"qobuz" => $"https://www.qobuz.com/us-en/album/-/-/{externalId}",
@@ -2286,35 +2286,58 @@ 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))
+ // 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);
+
+ // Try to get metadata for the external track to populate the ghost item
+ var ghostItem = new
{
- _logger.LogInformation("🔧 SESSION: Creating session for external track playback");
- var sessionCreated = await _sessionManager.EnsureSessionAsync(
- deviceId,
- client ?? "Unknown",
- device ?? "Unknown",
- version ?? "1.0",
- Request.Headers);
-
- if (sessionCreated)
+ Name = itemName ?? "External Track",
+ Id = ghostUuid,
+ Type = "Audio",
+ MediaType = "Audio",
+ RunTimeTicks = 0L, // We don't know duration yet
+ Artists = new[] { provider }, // Use provider as artist for now
+ Album = "External",
+ AlbumArtist = provider,
+ IsFolder = false,
+ UserData = new
{
- _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");
+ PlaybackPositionTicks = positionTicks ?? 0,
+ PlayCount = 0,
+ IsFavorite = false,
+ Played = false
}
+ };
+
+ // Build playback start with ghost item
+ var playbackStart = new
+ {
+ ItemId = ghostUuid,
+ Item = ghostItem,
+ 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 item
+ 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();
}
@@ -2467,25 +2490,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))
+ // For external tracks, report progress with ghost UUID to Jellyfin
+ var ghostUuid = GenerateUuidFromString(itemId);
+
+ // Build progress report with ghost UUID
+ var progressReport = new
{
- _sessionManager.UpdateActivity(deviceId);
-
- // Log progress occasionally for debugging (every ~30 seconds)
- if (positionTicks.HasValue)
+ 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();
}
@@ -2502,6 +2536,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)
@@ -2576,11 +2612,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();
@@ -2592,6 +2640,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)
@@ -4452,6 +4518,24 @@ public class JellyfinController : ControllerBase
return (deviceId, client, device, version);
}
+ ///
+ /// Generates a deterministic UUID (v5) from a string.
+ /// This allows us to create consistent UUIDs for external track IDs.
+ ///
+ 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();
+ }
+
///
/// 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.
diff --git a/allstarr/Services/Common/BaseDownloadService.cs b/allstarr/Services/Common/BaseDownloadService.cs
index 94683c0..68012c1 100644
--- a/allstarr/Services/Common/BaseDownloadService.cs
+++ b/allstarr/Services/Common/BaseDownloadService.cs
@@ -126,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;
}
@@ -208,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
@@ -582,31 +567,6 @@ public abstract class BaseDownloadService : IDownloadService
}
}
- ///
- /// Gets the cached file path for a given provider and external ID
- /// Returns null if no cached file exists
- ///
- protected string? GetCachedFilePath(string provider, string externalId)
- {
- try
- {
- // Search for cached files matching the pattern: {provider}_{externalId}.*
- var pattern = $"{provider}_{externalId}.*";
- var files = Directory.GetFiles(CachePath, pattern, SearchOption.AllDirectories);
-
- if (files.Length > 0)
- {
- return files[0]; // Return first match
- }
-
- return null;
- }
- catch (Exception ex)
- {
- Logger.LogWarning(ex, "Failed to search for cached file: {Provider}_{ExternalId}", provider, externalId);
- return null;
- }
- }
#endregion
diff --git a/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs b/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs
index 700929f..1cdc3a4 100644
--- a/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs
+++ b/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs
@@ -14,8 +14,40 @@ using Microsoft.Extensions.Logging;
namespace allstarr.Services.SquidWTF;
///
-/// 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
///
public class SquidWTFDownloadService : BaseDownloadService
{
@@ -73,11 +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);
+ // 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
@@ -108,6 +140,7 @@ public class SquidWTFDownloadService : BaseDownloadService
var response = await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
{
+ // Map quality settings to Tidal's quality levels per hifi-api spec
var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
{
"FLAC" => "LOSSLESS",
@@ -135,6 +168,7 @@ public class SquidWTFDownloadService : BaseDownloadService
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);
@@ -146,7 +180,7 @@ public class SquidWTFDownloadService : BaseDownloadService
var downloadUrl = urls[0].GetString()
?? throw new Exception("Download URL is null");
- // Start the actual download from Tidal CDN
+ // 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", "*/*");
@@ -182,6 +216,14 @@ public class SquidWTFDownloadService : BaseDownloadService
#region SquidWTF API Methods
+ ///
+ /// 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)
+ ///
private async Task GetTrackDownloadInfoAsync(string trackId, CancellationToken cancellationToken)
{
return await QueueRequestAsync(async () =>
@@ -189,7 +231,7 @@ public class SquidWTFDownloadService : BaseDownloadService
// 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",
@@ -202,7 +244,7 @@ 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, ct);
response.EnsureSuccessStatusCode();
diff --git a/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs b/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs
index 08cbd74..c918c09 100644
--- a/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs
+++ b/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs
@@ -12,7 +12,41 @@ using System.Text.Json.Nodes;
namespace allstarr.Services.SquidWTF;
///
-/// 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
///
public class SquidWTFMetadataService : IMusicMetadataService
@@ -49,6 +83,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
// 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, ct);
@@ -68,6 +103,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
}
var songs = new List();
+ // Per hifi-api spec: track search returns data.items array
if (result.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("items", out var items))
{
@@ -77,7 +113,10 @@ public class SquidWTFMetadataService : IMusicMetadataService
if (count >= limit) break;
var song = ParseTidalTrack(track);
- songs.Add(song);
+ if (ShouldIncludeSong(song))
+ {
+ songs.Add(song);
+ }
count++;
}
}
@@ -90,6 +129,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
// 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, ct);
@@ -102,6 +142,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
var result = JsonDocument.Parse(json);
var albums = new List();
+ // 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))
@@ -125,6 +166,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
// 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);
@@ -140,6 +182,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
var result = JsonDocument.Parse(json);
var artists = new List();
+ // 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))
@@ -165,6 +208,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
{
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();
@@ -173,15 +217,20 @@ public class SquidWTFMetadataService : IMusicMetadataService
var result = JsonDocument.Parse(json);
var playlists = new List();
+ // 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)
{
@@ -219,6 +268,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
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);
@@ -227,6 +277,7 @@ 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;
@@ -250,6 +301,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
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);
@@ -258,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);
@@ -304,6 +357,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
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);
@@ -321,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)
@@ -353,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(),
@@ -381,6 +437,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
{
_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);
@@ -397,6 +454,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
var albums = new List();
+ // Response structure: { "albums": { "items": [ album objects ] } }
if (result.RootElement.TryGetProperty("albums", out var albumsObj) &&
albumsObj.TryGetProperty("items", out var items))
{
@@ -424,6 +482,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
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;
@@ -431,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);
}
@@ -443,6 +504,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
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();
@@ -450,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();
JsonElement? playlist = null;
JsonElement? tracks = null;
+ // Response structure: { "playlist": { playlist object }, "items": [ track wrappers ] }
if (playlistElement.TryGetProperty("playlist", out var playlistEl))
{
playlist = playlistEl;
@@ -477,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;
@@ -499,6 +564,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
// --- Parser functions start here ---
+ ///
+ /// 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).
+ ///
+ /// JSON element containing track data
+ /// Optional track number to use if not present in JSON
+ /// Parsed Song object
private Song ParseTidalTrack(JsonElement track, int? fallbackTrackNumber = null)
{
var externalId = track.GetProperty("id").GetInt64().ToString();
@@ -588,6 +661,13 @@ public class SquidWTFMetadataService : IMusicMetadataService
};
}
+ ///
+ /// 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.
+ ///
+ /// JSON element containing full track data
+ /// Parsed Song object with extended metadata
private Song ParseTidalTrackFull(JsonElement track)
{
var externalId = track.GetProperty("id").GetInt64().ToString();
@@ -709,6 +789,13 @@ public class SquidWTFMetadataService : IMusicMetadataService
};
}
+ ///
+ /// 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).
+ ///
+ /// JSON element containing album data
+ /// Parsed Album object
private Album ParseTidalAlbum(JsonElement album)
{
var externalId = album.GetProperty("id").GetInt64().ToString();
@@ -762,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
+ ///
+ /// 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.
+ ///
+ /// JSON element containing artist data
+ /// Parsed Artist object
private Artist ParseTidalArtist(JsonElement artist)
{
var externalId = artist.GetProperty("id").GetInt64().ToString();
@@ -789,6 +881,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
};
}
+ ///
+ /// 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 } } ] }
+ ///
+ /// Root JSON element containing playlist and items
+ /// Parsed ExternalPlaylist object
private ExternalPlaylist ParseTidalPlaylist(JsonElement playlistElement)
{
JsonElement? playlist = null;