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
This commit is contained in:
2026-02-07 13:27:25 -05:00
parent 73bd3bf308
commit 1a0e0216f5
5 changed files with 296 additions and 107 deletions

3
.gitignore vendored
View File

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

View File

@@ -1202,7 +1202,7 @@ public class JellyfinController : ControllerBase
else else
{ {
// For other providers, build the URL and convert // 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}", "deezer" => $"https://www.deezer.com/track/{externalId}",
"qobuz" => $"https://www.qobuz.com/us-en/album/-/-/{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! // Create a ghost/fake item to report to Jellyfin so "Now Playing" shows up
// Even though Jellyfin doesn't know about the track, we need a session // Generate a deterministic UUID from the external ID
// for the client to appear in the dashboard and receive remote control commands var ghostUuid = GenerateUuidFromString(itemId);
if (!string.IsNullOrEmpty(deviceId))
// 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"); Name = itemName ?? "External Track",
var sessionCreated = await _sessionManager.EnsureSessionAsync( Id = ghostUuid,
deviceId, Type = "Audio",
client ?? "Unknown", MediaType = "Audio",
device ?? "Unknown", RunTimeTicks = 0L, // We don't know duration yet
version ?? "1.0", Artists = new[] { provider }, // Use provider as artist for now
Request.Headers); Album = "External",
AlbumArtist = provider,
if (sessionCreated) IsFolder = false,
UserData = new
{ {
_logger.LogInformation("✓ SESSION: Session created for external track playback on device {DeviceId}", deviceId); PlaybackPositionTicks = positionTicks ?? 0,
} PlayCount = 0,
else IsFavorite = false,
{ Played = false
_logger.LogWarning("⚠️ SESSION: Failed to create session for external track playback");
} }
};
// 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 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(); return NoContent();
} }
@@ -2467,25 +2490,36 @@ public class JellyfinController : ControllerBase
if (isExternal) if (isExternal)
{ {
// For external tracks, update session activity to keep it alive // For external tracks, report progress with ghost UUID to Jellyfin
// This ensures the session remains visible in Jellyfin dashboard var ghostUuid = GenerateUuidFromString(itemId);
if (!string.IsNullOrEmpty(deviceId))
// Build progress report with ghost UUID
var progressReport = new
{ {
_sessionManager.UpdateActivity(deviceId); ItemId = ghostUuid,
PositionTicks = positionTicks ?? 0,
// Log progress occasionally for debugging (every ~30 seconds) IsPaused = false,
if (positionTicks.HasValue) 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); _logger.LogDebug("▶️ External track progress: {Position:mm\\:ss} ({Provider}/{ExternalId}) - Status: {StatusCode}",
if (position.Seconds % 30 == 0 && position.Milliseconds < 500) position, provider, externalId, progressStatusCode);
{
_logger.LogDebug("▶️ External track progress: {Position:mm\\:ss} ({Provider}/{ExternalId})",
position, provider, externalId);
}
} }
} }
// Just acknowledge (no reporting to Jellyfin for external tracks)
return NoContent(); return NoContent();
} }
@@ -2502,6 +2536,8 @@ public class JellyfinController : ControllerBase
} }
// For local tracks, forward to Jellyfin // For local tracks, forward to Jellyfin
_logger.LogDebug("📤 Sending playback progress body: {Body}", body);
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Progress", body, Request.Headers); var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Progress", body, Request.Headers);
if (statusCode != 204 && statusCode != 200) if (statusCode != 204 && statusCode != 200)
@@ -2576,11 +2612,23 @@ public class JellyfinController : ControllerBase
_logger.LogInformation("🎵 External track playback stopped: {Name} at {Position} ({Provider}/{ExternalId})", _logger.LogInformation("🎵 External track playback stopped: {Name} at {Position} ({Provider}/{ExternalId})",
itemName ?? "Unknown", position, provider, externalId); itemName ?? "Unknown", position, provider, externalId);
// Mark session as potentially ended after playback stops // Report stop to Jellyfin with ghost UUID
// Wait 50 seconds for next song to start before cleaning up var ghostUuid = GenerateUuidFromString(itemId);
if (!string.IsNullOrEmpty(deviceId))
var stopInfo = new
{ {
_sessionManager.MarkSessionPotentiallyEnded(deviceId, TimeSpan.FromSeconds(50)); ItemId = ghostUuid,
PositionTicks = positionTicks ?? 0
};
var stopJson = JsonSerializer.Serialize(stopInfo);
_logger.LogDebug("📤 Sending ghost playback stop for external track: {Json}", stopJson);
var (stopResult, stopStatusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, Request.Headers);
if (stopStatusCode == 204 || stopStatusCode == 200)
{
_logger.LogInformation("✓ Ghost playback stop forwarded to Jellyfin ({StatusCode})", stopStatusCode);
} }
return NoContent(); return NoContent();
@@ -2592,6 +2640,24 @@ public class JellyfinController : ControllerBase
// For local tracks, forward to Jellyfin // For local tracks, forward to Jellyfin
_logger.LogInformation("Forwarding playback stop to Jellyfin..."); _logger.LogInformation("Forwarding playback stop to Jellyfin...");
// Log the body being sent for debugging
_logger.LogInformation("📤 Sending playback stop body: {Body}", body);
// Validate that body is not empty
if (string.IsNullOrWhiteSpace(body) || body == "{}")
{
_logger.LogWarning("⚠️ Playback stop body is empty, building minimal valid payload");
// Build a minimal valid PlaybackStopInfo
var stopInfo = new
{
ItemId = itemId,
PositionTicks = positionTicks ?? 0
};
body = JsonSerializer.Serialize(stopInfo);
_logger.LogInformation("📤 Built playback stop body: {Body}", body);
}
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", body, Request.Headers); var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", body, Request.Headers);
if (statusCode == 204 || statusCode == 200) if (statusCode == 204 || statusCode == 200)
@@ -4452,6 +4518,24 @@ public class JellyfinController : ControllerBase
return (deviceId, client, device, version); return (deviceId, client, device, version);
} }
/// <summary>
/// Generates a deterministic UUID (v5) from a string.
/// This allows us to create consistent UUIDs for external track IDs.
/// </summary>
private string GenerateUuidFromString(string input)
{
// Use MD5 hash to generate a deterministic UUID
using var md5 = System.Security.Cryptography.MD5.Create();
var hash = md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input));
// Convert to UUID format (version 5, namespace-based)
hash[6] = (byte)((hash[6] & 0x0F) | 0x50); // Version 5
hash[8] = (byte)((hash[8] & 0x3F) | 0x80); // Variant
var guid = new Guid(hash);
return guid.ToString();
}
/// <summary> /// <summary>
/// Finds the Spotify ID for an external track by searching through all playlist matched tracks caches. /// Finds the Spotify ID for an external track by searching through all playlist matched tracks caches.
/// This allows us to get Spotify lyrics for external tracks that were matched from Spotify playlists. /// This allows us to get Spotify lyrics for external tracks that were matched from Spotify playlists.

View File

@@ -126,20 +126,13 @@ public abstract class BaseDownloadService : IDownloadService
return null; return null;
} }
// Check local library // Check local library (works for both cache and permanent storage)
var localPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId); var localPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
if (localPath != null && IOFile.Exists(localPath)) if (localPath != null && IOFile.Exists(localPath))
{ {
return localPath; return localPath;
} }
// Check cache directory
var cachedPath = GetCachedFilePath(externalProvider, externalId);
if (cachedPath != null && IOFile.Exists(cachedPath))
{
return cachedPath;
}
return null; return null;
} }
@@ -208,27 +201,19 @@ public abstract class BaseDownloadService : IDownloadService
try try
{ {
// Check if already downloaded (skip for cache mode as we want to check cache folder) // Check if already downloaded (works for both cache and permanent modes)
if (!isCache) var existingPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId);
if (existingPath != null && IOFile.Exists(existingPath))
{ {
var existingPath = await LocalLibraryService.GetLocalPathForExternalSongAsync(externalProvider, externalId); Logger.LogInformation("Song already downloaded: {Path}", existingPath);
if (existingPath != null && IOFile.Exists(existingPath))
// For cache mode, update file access time for cache cleanup logic
if (isCache)
{ {
Logger.LogInformation("Song already downloaded: {Path}", existingPath); IOFile.SetLastAccessTime(existingPath, DateTime.UtcNow);
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;
} }
return existingPath;
} }
// Check if download in progress // Check if download in progress
@@ -582,31 +567,6 @@ public abstract class BaseDownloadService : IDownloadService
} }
} }
/// <summary>
/// Gets the cached file path for a given provider and external ID
/// Returns null if no cached file exists
/// </summary>
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 #endregion

View File

@@ -14,8 +14,40 @@ using Microsoft.Extensions.Logging;
namespace allstarr.Services.SquidWTF; namespace allstarr.Services.SquidWTF;
/// <summary> /// <summary>
/// Handles track downloading from tidal.squid.wtf (no encryption, no auth required) /// Handles track downloading from tidal.squid.wtf (no encryption, no auth required).
/// Downloads are direct from Tidal's CDN via the squid.wtf proxy ///
/// Downloads are direct from Tidal's CDN via the squid.wtf proxy. The service:
/// 1. Fetches download info from hifi-api /track/ endpoint
/// 2. Decodes base64 manifest to get actual Tidal CDN URL
/// 3. Downloads directly from Tidal CDN (no decryption needed)
/// 4. Converts Tidal track ID to Spotify ID in parallel (for lyrics matching)
/// 5. Writes ID3/FLAC metadata tags and embeds cover art
///
/// Per hifi-api spec, the /track/ endpoint returns:
/// { "version": "2.0", "data": {
/// trackId, assetPresentation, audioMode, audioQuality,
/// manifestMimeType: "application/vnd.tidal.bts",
/// manifest: "base64-encoded-json",
/// albumReplayGain, trackReplayGain, bitDepth, sampleRate
/// }}
///
/// The manifest decodes to:
/// { "mimeType": "audio/flac", "codecs": "flac", "encryptionType": "NONE",
/// "urls": ["https://lgf.audio.tidal.com/mediatracks/..."] }
///
/// Quality Mapping:
/// - HI_RES → HI_RES_LOSSLESS (24-bit/192kHz FLAC)
/// - FLAC/LOSSLESS → LOSSLESS (16-bit/44.1kHz FLAC)
/// - HIGH → HIGH (320kbps AAC)
/// - LOW → LOW (96kbps AAC)
///
/// Features:
/// - Racing multiple endpoints for fastest download
/// - Automatic failover to backup endpoints
/// - Parallel Spotify ID conversion via Odesli
/// - Organized folder structure: Artist/Album/Track
/// - Unique filename resolution for duplicates
/// - Support for both cache and permanent storage modes
/// </summary> /// </summary>
public class SquidWTFDownloadService : BaseDownloadService public class SquidWTFDownloadService : BaseDownloadService
{ {
@@ -73,11 +105,11 @@ public class SquidWTFDownloadService : BaseDownloadService
{ {
var downloadInfo = await GetTrackDownloadInfoAsync(trackId, cancellationToken); var downloadInfo = await GetTrackDownloadInfoAsync(trackId, cancellationToken);
Logger.LogInformation("Track token obtained: {Url}", downloadInfo.DownloadUrl); Logger.LogInformation("Track download URL obtained from hifi-api: {Url}", downloadInfo.DownloadUrl);
Logger.LogInformation("Using format: {Format}", downloadInfo.MimeType); Logger.LogInformation("Using format: {Format} (Quality: {Quality})", downloadInfo.MimeType, downloadInfo.AudioQuality);
// Start Spotify ID conversion in parallel with download (don't await yet) // Start Spotify ID conversion in parallel with download (don't await yet)
var spotifyIdTask = _odesliService.ConvertTidalToSpotifyIdAsync(trackId, cancellationToken); var spotifyIdTask = _odesliService.ConvertTidalToSpotifyIdAsync(trackId, cancellationToken);
// Determine extension from MIME type // Determine extension from MIME type
var extension = downloadInfo.MimeType?.ToLower() switch var extension = downloadInfo.MimeType?.ToLower() switch
@@ -108,6 +140,7 @@ public class SquidWTFDownloadService : BaseDownloadService
var response = await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) => 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 var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
{ {
"FLAC" => "LOSSLESS", "FLAC" => "LOSSLESS",
@@ -135,6 +168,7 @@ public class SquidWTFDownloadService : BaseDownloadService
var manifestBase64 = data.GetProperty("manifest").GetString() var manifestBase64 = data.GetProperty("manifest").GetString()
?? throw new Exception("No manifest in response"); ?? throw new Exception("No manifest in response");
// Decode base64 manifest to get actual CDN URL
var manifestJson = Encoding.UTF8.GetString(Convert.FromBase64String(manifestBase64)); var manifestJson = Encoding.UTF8.GetString(Convert.FromBase64String(manifestBase64));
var manifest = JsonDocument.Parse(manifestJson); var manifest = JsonDocument.Parse(manifestJson);
@@ -146,7 +180,7 @@ public class SquidWTFDownloadService : BaseDownloadService
var downloadUrl = urls[0].GetString() var downloadUrl = urls[0].GetString()
?? throw new Exception("Download URL is null"); ?? 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); using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl);
request.Headers.Add("User-Agent", "Mozilla/5.0"); request.Headers.Add("User-Agent", "Mozilla/5.0");
request.Headers.Add("Accept", "*/*"); request.Headers.Add("Accept", "*/*");
@@ -182,6 +216,14 @@ public class SquidWTFDownloadService : BaseDownloadService
#region SquidWTF API Methods #region SquidWTF API Methods
/// <summary>
/// Gets track download information from hifi-api /track/ endpoint.
/// Per hifi-api spec: GET /track/?id={trackId}&quality={quality}
/// Returns: { "version": "2.0", "data": { trackId, assetPresentation, audioMode, audioQuality,
/// manifestMimeType, manifestHash, manifest (base64), albumReplayGain, trackReplayGain, bitDepth, sampleRate } }
/// The manifest is base64-encoded JSON containing: { mimeType, codecs, encryptionType, urls: [downloadUrl] }
/// Quality options: HI_RES_LOSSLESS (24-bit/192kHz FLAC), LOSSLESS (16-bit/44.1kHz FLAC), HIGH (320kbps AAC), LOW (96kbps AAC)
/// </summary>
private async Task<DownloadResult> GetTrackDownloadInfoAsync(string trackId, CancellationToken cancellationToken) private async Task<DownloadResult> GetTrackDownloadInfoAsync(string trackId, CancellationToken cancellationToken)
{ {
return await QueueRequestAsync(async () => return await QueueRequestAsync(async () =>
@@ -189,7 +231,7 @@ public class SquidWTFDownloadService : BaseDownloadService
// Race all endpoints for fastest download info retrieval // Race all endpoints for fastest download info retrieval
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) => return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
{ {
// Map quality settings to Tidal's quality levels // Map quality settings to Tidal's quality levels per hifi-api spec
var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
{ {
"FLAC" => "LOSSLESS", "FLAC" => "LOSSLESS",
@@ -202,7 +244,7 @@ public class SquidWTFDownloadService : BaseDownloadService
var url = $"{baseUrl}/track/?id={trackId}&quality={quality}"; var url = $"{baseUrl}/track/?id={trackId}&quality={quality}";
Console.WriteLine($"%%%%%%%%%%%%%%%%%%% URL For downloads??: {url}"); Logger.LogDebug("Fetching track download info from: {Url}", url);
var response = await _httpClient.GetAsync(url, ct); var response = await _httpClient.GetAsync(url, ct);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();

View File

@@ -12,7 +12,41 @@ using System.Text.Json.Nodes;
namespace allstarr.Services.SquidWTF; namespace allstarr.Services.SquidWTF;
/// <summary> /// <summary>
/// Metadata service implementation using the SquidWTF API (free, no key required) /// Metadata service implementation using the SquidWTF API (free, no key required).
///
/// SquidWTF is a proxy to Tidal's API that provides free access to Tidal's music catalog.
/// This implementation follows the hifi-api specification documented at the forked repository.
///
/// API Endpoints (per hifi-api spec):
/// - GET /search/?s={query} - Search tracks (returns data.items array)
/// - GET /search/?a={query} - Search artists (returns data.artists.items array)
/// - GET /search/?al={query} - Search albums (returns data.albums.items array, undocumented)
/// - GET /search/?p={query} - Search playlists (returns data.playlists.items array, undocumented)
/// - GET /info/?id={trackId} - Get track metadata (returns data object with full track info)
/// - GET /track/?id={trackId}&quality={quality} - Get track download info (returns manifest)
/// - GET /album/?id={albumId} - Get album with tracks (undocumented, returns data.items array)
/// - GET /artist/?f={artistId} - Get artist with albums (undocumented, returns albums.items array)
/// - GET /playlist/?id={playlistId} - Get playlist with tracks (undocumented)
///
/// Quality Options:
/// - HI_RES_LOSSLESS: 24-bit/192kHz FLAC
/// - LOSSLESS: 16-bit/44.1kHz FLAC
/// - HIGH: 320kbps AAC
/// - LOW: 96kbps AAC
///
/// Response Structure:
/// All responses follow: { "version": "2.0", "data": { ... } }
/// Track objects include: id, title, duration, trackNumber, volumeNumber, explicit, bpm, isrc,
/// artist (singular), artists (array), album (object with id, title, cover UUID)
/// Cover art URLs: https://resources.tidal.com/images/{uuid-with-slashes}/{size}.jpg
///
/// Features:
/// - Round-robin load balancing across multiple mirror endpoints
/// - Automatic failover to backup endpoints on failure
/// - Racing endpoints for fastest response on latency-sensitive operations
/// - Redis caching for albums and artists (24-hour TTL)
/// - Explicit content filtering support
/// - Parallel Spotify ID conversion via Odesli for lyrics matching
/// </summary> /// </summary>
public class SquidWTFMetadataService : IMusicMetadataService public class SquidWTFMetadataService : IMusicMetadataService
@@ -49,6 +83,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
// Race all endpoints for fastest search results // Race all endpoints for fastest search results
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) => return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
{ {
// Use 's' parameter for track search as per hifi-api spec
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}"; var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url, ct); var response = await _httpClient.GetAsync(url, ct);
@@ -68,6 +103,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
} }
var songs = new List<Song>(); var songs = new List<Song>();
// Per hifi-api spec: track search returns data.items array
if (result.RootElement.TryGetProperty("data", out var data) && if (result.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("items", out var items)) data.TryGetProperty("items", out var items))
{ {
@@ -77,7 +113,10 @@ public class SquidWTFMetadataService : IMusicMetadataService
if (count >= limit) break; if (count >= limit) break;
var song = ParseTidalTrack(track); var song = ParseTidalTrack(track);
songs.Add(song); if (ShouldIncludeSong(song))
{
songs.Add(song);
}
count++; count++;
} }
} }
@@ -90,6 +129,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
// Race all endpoints for fastest search results // Race all endpoints for fastest search results
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) => return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
{ {
// Note: hifi-api doesn't document album search, but 'al' parameter is commonly used
var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}"; var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url, ct); var response = await _httpClient.GetAsync(url, ct);
@@ -102,6 +142,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
var result = JsonDocument.Parse(json); var result = JsonDocument.Parse(json);
var albums = new List<Album>(); var albums = new List<Album>();
// Per hifi-api spec: album search returns data.albums.items array
if (result.RootElement.TryGetProperty("data", out var data) && if (result.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("albums", out var albumsObj) && data.TryGetProperty("albums", out var albumsObj) &&
albumsObj.TryGetProperty("items", out var items)) albumsObj.TryGetProperty("items", out var items))
@@ -125,6 +166,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
// Race all endpoints for fastest search results // Race all endpoints for fastest search results
return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) => return await _fallbackHelper.RaceAllEndpointsAsync(async (baseUrl, ct) =>
{ {
// Per hifi-api spec: use 'a' parameter for artist search
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}"; var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
_logger.LogInformation("🔍 SQUIDWTF: Searching artists with URL: {Url}", url); _logger.LogInformation("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
@@ -140,6 +182,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
var result = JsonDocument.Parse(json); var result = JsonDocument.Parse(json);
var artists = new List<Artist>(); var artists = new List<Artist>();
// Per hifi-api spec: artist search returns data.artists.items array
if (result.RootElement.TryGetProperty("data", out var data) && if (result.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("artists", out var artistsObj) && data.TryGetProperty("artists", out var artistsObj) &&
artistsObj.TryGetProperty("items", out var items)) artistsObj.TryGetProperty("items", out var items))
@@ -165,6 +208,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
{ {
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{ {
// Per hifi-api spec: use 'p' parameter for playlist search
var url = $"{baseUrl}/search/?p={Uri.EscapeDataString(query)}"; var url = $"{baseUrl}/search/?p={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url); var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return new List<ExternalPlaylist>(); if (!response.IsSuccessStatusCode) return new List<ExternalPlaylist>();
@@ -173,15 +217,20 @@ public class SquidWTFMetadataService : IMusicMetadataService
var result = JsonDocument.Parse(json); var result = JsonDocument.Parse(json);
var playlists = new List<ExternalPlaylist>(); var playlists = new List<ExternalPlaylist>();
// Per hifi-api spec: playlist search returns data.playlists.items array
if (result.RootElement.TryGetProperty("data", out var data) && if (result.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("playlists", out var playlistObj) && data.TryGetProperty("playlists", out var playlistObj) &&
playlistObj.TryGetProperty("items", out var items)) playlistObj.TryGetProperty("items", out var items))
{ {
int count = 0;
foreach(var playlist in items.EnumerateArray()) foreach(var playlist in items.EnumerateArray())
{ {
if (count >= limit) break;
try try
{ {
playlists.Add(ParseTidalPlaylist(playlist)); playlists.Add(ParseTidalPlaylist(playlist));
count++;
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -219,6 +268,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{ {
// Per hifi-api spec: GET /info/?id={trackId} returns track metadata
var url = $"{baseUrl}/info/?id={externalId}"; var url = $"{baseUrl}/info/?id={externalId}";
var response = await _httpClient.GetAsync(url); var response = await _httpClient.GetAsync(url);
@@ -227,6 +277,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
var json = await response.Content.ReadAsStringAsync(); var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json); var result = JsonDocument.Parse(json);
// Per hifi-api spec: response is { "version": "2.0", "data": { track object } }
if (!result.RootElement.TryGetProperty("data", out var track)) if (!result.RootElement.TryGetProperty("data", out var track))
return null; return null;
@@ -250,6 +301,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{ {
// Note: hifi-api doesn't document album endpoint, but /album/?id={albumId} is commonly used
var url = $"{baseUrl}/album/?id={externalId}"; var url = $"{baseUrl}/album/?id={externalId}";
var response = await _httpClient.GetAsync(url); var response = await _httpClient.GetAsync(url);
@@ -258,17 +310,18 @@ public class SquidWTFMetadataService : IMusicMetadataService
var json = await response.Content.ReadAsStringAsync(); var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json); var result = JsonDocument.Parse(json);
// Response structure: { "data": { album object with "items" array of tracks } }
if (!result.RootElement.TryGetProperty("data", out var albumElement)) if (!result.RootElement.TryGetProperty("data", out var albumElement))
return null; return null;
var album = ParseTidalAlbum(albumElement); var album = ParseTidalAlbum(albumElement);
// Get album tracks // Get album tracks from items array
if (albumElement.TryGetProperty("items", out var tracks)) if (albumElement.TryGetProperty("items", out var tracks))
{ {
foreach (var trackWrapper in tracks.EnumerateArray()) foreach (var trackWrapper in tracks.EnumerateArray())
{ {
// Each item is wrapped: { "item": { track object } }
if (trackWrapper.TryGetProperty("item", out var track)) if (trackWrapper.TryGetProperty("item", out var track))
{ {
var song = ParseTidalTrack(track); var song = ParseTidalTrack(track);
@@ -304,6 +357,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{ {
// Note: hifi-api doesn't document artist endpoint, but /artist/?f={artistId} is commonly used
var url = $"{baseUrl}/artist/?f={externalId}"; var url = $"{baseUrl}/artist/?f={externalId}";
_logger.LogInformation("Fetching artist from {Url}", url); _logger.LogInformation("Fetching artist from {Url}", url);
@@ -321,7 +375,8 @@ public class SquidWTFMetadataService : IMusicMetadataService
JsonElement? artistSource = null; JsonElement? artistSource = null;
int albumCount = 0; int albumCount = 0;
// Try to get artist from albums.items[0].artist // Response structure: { "albums": { "items": [ album objects ] }, "tracks": [ track objects ] }
// Extract artist info from albums.items[0].artist (most reliable source)
if (result.RootElement.TryGetProperty("albums", out var albums) && if (result.RootElement.TryGetProperty("albums", out var albums) &&
albums.TryGetProperty("items", out var albumItems) && albums.TryGetProperty("items", out var albumItems) &&
albumItems.GetArrayLength() > 0) albumItems.GetArrayLength() > 0)
@@ -353,6 +408,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
} }
var artistElement = artistSource.Value; var artistElement = artistSource.Value;
// Normalize artist data to include album count
var normalizedArtist = new JsonObject var normalizedArtist = new JsonObject
{ {
["id"] = artistElement.GetProperty("id").GetInt64(), ["id"] = artistElement.GetProperty("id").GetInt64(),
@@ -381,6 +437,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
{ {
_logger.LogInformation("GetArtistAlbumsAsync called for SquidWTF artist {ExternalId}", externalId); _logger.LogInformation("GetArtistAlbumsAsync called for SquidWTF artist {ExternalId}", externalId);
// Note: hifi-api doesn't document artist endpoint, but /artist/?f={artistId} is commonly used
var url = $"{baseUrl}/artist/?f={externalId}"; var url = $"{baseUrl}/artist/?f={externalId}";
_logger.LogInformation("Fetching artist albums from URL: {Url}", url); _logger.LogInformation("Fetching artist albums from URL: {Url}", url);
var response = await _httpClient.GetAsync(url); var response = await _httpClient.GetAsync(url);
@@ -397,6 +454,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
var albums = new List<Album>(); var albums = new List<Album>();
// Response structure: { "albums": { "items": [ album objects ] } }
if (result.RootElement.TryGetProperty("albums", out var albumsObj) && if (result.RootElement.TryGetProperty("albums", out var albumsObj) &&
albumsObj.TryGetProperty("items", out var items)) albumsObj.TryGetProperty("items", out var items))
{ {
@@ -424,6 +482,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{ {
// Note: hifi-api doesn't document playlist endpoint, but /playlist/?id={playlistId} is commonly used
var url = $"{baseUrl}/playlist/?id={externalId}"; var url = $"{baseUrl}/playlist/?id={externalId}";
var response = await _httpClient.GetAsync(url); var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return null; if (!response.IsSuccessStatusCode) return null;
@@ -431,8 +490,10 @@ public class SquidWTFMetadataService : IMusicMetadataService
var json = await response.Content.ReadAsStringAsync(); var json = await response.Content.ReadAsStringAsync();
var playlistElement = JsonDocument.Parse(json).RootElement; var playlistElement = JsonDocument.Parse(json).RootElement;
// Check for error response
if (playlistElement.TryGetProperty("error", out _)) return null; if (playlistElement.TryGetProperty("error", out _)) return null;
// Response structure: { "playlist": { playlist object }, "items": [ track wrappers ] }
return ParseTidalPlaylist(playlistElement); return ParseTidalPlaylist(playlistElement);
}, (ExternalPlaylist?)null); }, (ExternalPlaylist?)null);
} }
@@ -443,6 +504,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{ {
// Note: hifi-api doesn't document playlist endpoint, but /playlist/?id={playlistId} is commonly used
var url = $"{baseUrl}/playlist/?id={externalId}"; var url = $"{baseUrl}/playlist/?id={externalId}";
var response = await _httpClient.GetAsync(url); var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode) return new List<Song>(); if (!response.IsSuccessStatusCode) return new List<Song>();
@@ -450,11 +512,13 @@ public class SquidWTFMetadataService : IMusicMetadataService
var json = await response.Content.ReadAsStringAsync(); var json = await response.Content.ReadAsStringAsync();
var playlistElement = JsonDocument.Parse(json).RootElement; var playlistElement = JsonDocument.Parse(json).RootElement;
// Check for error response
if (playlistElement.TryGetProperty("error", out _)) return new List<Song>(); if (playlistElement.TryGetProperty("error", out _)) return new List<Song>();
JsonElement? playlist = null; JsonElement? playlist = null;
JsonElement? tracks = null; JsonElement? tracks = null;
// Response structure: { "playlist": { playlist object }, "items": [ track wrappers ] }
if (playlistElement.TryGetProperty("playlist", out var playlistEl)) if (playlistElement.TryGetProperty("playlist", out var playlistEl))
{ {
playlist = playlistEl; playlist = playlistEl;
@@ -477,6 +541,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
int trackIndex = 1; int trackIndex = 1;
foreach (var entry in tracks.Value.EnumerateArray()) foreach (var entry in tracks.Value.EnumerateArray())
{ {
// Each item is wrapped: { "item": { track object } }
if (!entry.TryGetProperty("item", out var track)) if (!entry.TryGetProperty("item", out var track))
continue; continue;
@@ -499,6 +564,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
// --- Parser functions start here --- // --- Parser functions start here ---
/// <summary>
/// Parses a Tidal track object from hifi-api search/album/playlist responses.
/// Per hifi-api spec, track objects contain: id, title, duration, trackNumber, volumeNumber,
/// explicit, artist (singular), artists (array), album (object with id, title, cover).
/// </summary>
/// <param name="track">JSON element containing track data</param>
/// <param name="fallbackTrackNumber">Optional track number to use if not present in JSON</param>
/// <returns>Parsed Song object</returns>
private Song ParseTidalTrack(JsonElement track, int? fallbackTrackNumber = null) private Song ParseTidalTrack(JsonElement track, int? fallbackTrackNumber = null)
{ {
var externalId = track.GetProperty("id").GetInt64().ToString(); var externalId = track.GetProperty("id").GetInt64().ToString();
@@ -588,6 +661,13 @@ public class SquidWTFMetadataService : IMusicMetadataService
}; };
} }
/// <summary>
/// Parses a full Tidal track object from hifi-api /info/ endpoint.
/// Per hifi-api spec, full track objects include additional metadata: bpm, isrc, key, keyScale,
/// streamStartDate (for year), copyright, replayGain, peak, audioQuality, audioModes.
/// </summary>
/// <param name="track">JSON element containing full track data</param>
/// <returns>Parsed Song object with extended metadata</returns>
private Song ParseTidalTrackFull(JsonElement track) private Song ParseTidalTrackFull(JsonElement track)
{ {
var externalId = track.GetProperty("id").GetInt64().ToString(); var externalId = track.GetProperty("id").GetInt64().ToString();
@@ -709,6 +789,13 @@ public class SquidWTFMetadataService : IMusicMetadataService
}; };
} }
/// <summary>
/// Parses a Tidal album object from hifi-api responses.
/// Per hifi-api spec, album objects contain: id, title, releaseDate, numberOfTracks,
/// cover (UUID), artist (object) or artists (array).
/// </summary>
/// <param name="album">JSON element containing album data</param>
/// <returns>Parsed Album object</returns>
private Album ParseTidalAlbum(JsonElement album) private Album ParseTidalAlbum(JsonElement album)
{ {
var externalId = album.GetProperty("id").GetInt64().ToString(); var externalId = album.GetProperty("id").GetInt64().ToString();
@@ -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 /// <summary>
// as the API endpoint in search does not include this data /// Parses a Tidal artist object from hifi-api responses.
/// Per hifi-api spec, artist objects contain: id, name, picture (UUID).
/// Note: albums_count is not in the standard API response but is added by GetArtistAsync.
/// </summary>
/// <param name="artist">JSON element containing artist data</param>
/// <returns>Parsed Artist object</returns>
private Artist ParseTidalArtist(JsonElement artist) private Artist ParseTidalArtist(JsonElement artist)
{ {
var externalId = artist.GetProperty("id").GetInt64().ToString(); var externalId = artist.GetProperty("id").GetInt64().ToString();
@@ -789,6 +881,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
}; };
} }
/// <summary>
/// Parses a Tidal playlist from hifi-api /playlist/ endpoint response.
/// Per hifi-api spec (undocumented), response structure is:
/// { "playlist": { uuid, title, description, creator, created, numberOfTracks, duration, squareImage },
/// "items": [ { "item": { track object } } ] }
/// </summary>
/// <param name="playlistElement">Root JSON element containing playlist and items</param>
/// <returns>Parsed ExternalPlaylist object</returns>
private ExternalPlaylist ParseTidalPlaylist(JsonElement playlistElement) private ExternalPlaylist ParseTidalPlaylist(JsonElement playlistElement)
{ {
JsonElement? playlist = null; JsonElement? playlist = null;