From 1a0e0216f5646b573fa338b39eb3b31c05d62173 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Sat, 7 Feb 2026 13:27:25 -0500 Subject: [PATCH] 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 --- .gitignore | 3 + allstarr/Controllers/JellyfinController.cs | 164 +++++++++++++----- .../Services/Common/BaseDownloadService.cs | 62 ++----- .../SquidWTF/SquidWTFDownloadService.cs | 60 ++++++- .../SquidWTF/SquidWTFMetadataService.cs | 114 +++++++++++- 5 files changed, 296 insertions(+), 107 deletions(-) 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;