mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
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:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Generates a deterministic UUID (v5) from a string.
|
||||
/// This allows us to create consistent UUIDs for external track IDs.
|
||||
/// </summary>
|
||||
private string GenerateUuidFromString(string input)
|
||||
{
|
||||
// Use MD5 hash to generate a deterministic UUID
|
||||
using var md5 = System.Security.Cryptography.MD5.Create();
|
||||
var hash = md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input));
|
||||
|
||||
// Convert to UUID format (version 5, namespace-based)
|
||||
hash[6] = (byte)((hash[6] & 0x0F) | 0x50); // Version 5
|
||||
hash[8] = (byte)((hash[8] & 0x3F) | 0x80); // Variant
|
||||
|
||||
var guid = new Guid(hash);
|
||||
return guid.ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the Spotify ID for an external track by searching through all playlist matched tracks caches.
|
||||
/// This allows us to get Spotify lyrics for external tracks that were matched from Spotify playlists.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
|
||||
|
||||
@@ -14,8 +14,40 @@ using Microsoft.Extensions.Logging;
|
||||
namespace allstarr.Services.SquidWTF;
|
||||
|
||||
/// <summary>
|
||||
/// Handles track downloading from tidal.squid.wtf (no encryption, no auth required)
|
||||
/// Downloads are direct from Tidal's CDN via the squid.wtf proxy
|
||||
/// Handles track downloading from tidal.squid.wtf (no encryption, no auth required).
|
||||
///
|
||||
/// Downloads are direct from Tidal's CDN via the squid.wtf proxy. The service:
|
||||
/// 1. Fetches download info from hifi-api /track/ endpoint
|
||||
/// 2. Decodes base64 manifest to get actual Tidal CDN URL
|
||||
/// 3. Downloads directly from Tidal CDN (no decryption needed)
|
||||
/// 4. Converts Tidal track ID to Spotify ID in parallel (for lyrics matching)
|
||||
/// 5. Writes ID3/FLAC metadata tags and embeds cover art
|
||||
///
|
||||
/// Per hifi-api spec, the /track/ endpoint returns:
|
||||
/// { "version": "2.0", "data": {
|
||||
/// trackId, assetPresentation, audioMode, audioQuality,
|
||||
/// manifestMimeType: "application/vnd.tidal.bts",
|
||||
/// manifest: "base64-encoded-json",
|
||||
/// albumReplayGain, trackReplayGain, bitDepth, sampleRate
|
||||
/// }}
|
||||
///
|
||||
/// The manifest decodes to:
|
||||
/// { "mimeType": "audio/flac", "codecs": "flac", "encryptionType": "NONE",
|
||||
/// "urls": ["https://lgf.audio.tidal.com/mediatracks/..."] }
|
||||
///
|
||||
/// Quality Mapping:
|
||||
/// - HI_RES → HI_RES_LOSSLESS (24-bit/192kHz FLAC)
|
||||
/// - FLAC/LOSSLESS → LOSSLESS (16-bit/44.1kHz FLAC)
|
||||
/// - HIGH → HIGH (320kbps AAC)
|
||||
/// - LOW → LOW (96kbps AAC)
|
||||
///
|
||||
/// Features:
|
||||
/// - Racing multiple endpoints for fastest download
|
||||
/// - Automatic failover to backup endpoints
|
||||
/// - Parallel Spotify ID conversion via Odesli
|
||||
/// - Organized folder structure: Artist/Album/Track
|
||||
/// - Unique filename resolution for duplicates
|
||||
/// - Support for both cache and permanent storage modes
|
||||
/// </summary>
|
||||
public class SquidWTFDownloadService : BaseDownloadService
|
||||
{
|
||||
@@ -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
|
||||
|
||||
/// <summary>
|
||||
/// Gets track download information from hifi-api /track/ endpoint.
|
||||
/// Per hifi-api spec: GET /track/?id={trackId}&quality={quality}
|
||||
/// Returns: { "version": "2.0", "data": { trackId, assetPresentation, audioMode, audioQuality,
|
||||
/// manifestMimeType, manifestHash, manifest (base64), albumReplayGain, trackReplayGain, bitDepth, sampleRate } }
|
||||
/// The manifest is base64-encoded JSON containing: { mimeType, codecs, encryptionType, urls: [downloadUrl] }
|
||||
/// Quality options: HI_RES_LOSSLESS (24-bit/192kHz FLAC), LOSSLESS (16-bit/44.1kHz FLAC), HIGH (320kbps AAC), LOW (96kbps AAC)
|
||||
/// </summary>
|
||||
private async Task<DownloadResult> GetTrackDownloadInfoAsync(string trackId, CancellationToken cancellationToken)
|
||||
{
|
||||
return await QueueRequestAsync(async () =>
|
||||
@@ -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();
|
||||
|
||||
@@ -12,7 +12,41 @@ using System.Text.Json.Nodes;
|
||||
namespace allstarr.Services.SquidWTF;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata service implementation using the SquidWTF API (free, no key required)
|
||||
/// Metadata service implementation using the SquidWTF API (free, no key required).
|
||||
///
|
||||
/// SquidWTF is a proxy to Tidal's API that provides free access to Tidal's music catalog.
|
||||
/// This implementation follows the hifi-api specification documented at the forked repository.
|
||||
///
|
||||
/// API Endpoints (per hifi-api spec):
|
||||
/// - GET /search/?s={query} - Search tracks (returns data.items array)
|
||||
/// - GET /search/?a={query} - Search artists (returns data.artists.items array)
|
||||
/// - GET /search/?al={query} - Search albums (returns data.albums.items array, undocumented)
|
||||
/// - GET /search/?p={query} - Search playlists (returns data.playlists.items array, undocumented)
|
||||
/// - GET /info/?id={trackId} - Get track metadata (returns data object with full track info)
|
||||
/// - GET /track/?id={trackId}&quality={quality} - Get track download info (returns manifest)
|
||||
/// - GET /album/?id={albumId} - Get album with tracks (undocumented, returns data.items array)
|
||||
/// - GET /artist/?f={artistId} - Get artist with albums (undocumented, returns albums.items array)
|
||||
/// - GET /playlist/?id={playlistId} - Get playlist with tracks (undocumented)
|
||||
///
|
||||
/// Quality Options:
|
||||
/// - HI_RES_LOSSLESS: 24-bit/192kHz FLAC
|
||||
/// - LOSSLESS: 16-bit/44.1kHz FLAC
|
||||
/// - HIGH: 320kbps AAC
|
||||
/// - LOW: 96kbps AAC
|
||||
///
|
||||
/// Response Structure:
|
||||
/// All responses follow: { "version": "2.0", "data": { ... } }
|
||||
/// Track objects include: id, title, duration, trackNumber, volumeNumber, explicit, bpm, isrc,
|
||||
/// artist (singular), artists (array), album (object with id, title, cover UUID)
|
||||
/// Cover art URLs: https://resources.tidal.com/images/{uuid-with-slashes}/{size}.jpg
|
||||
///
|
||||
/// Features:
|
||||
/// - Round-robin load balancing across multiple mirror endpoints
|
||||
/// - Automatic failover to backup endpoints on failure
|
||||
/// - Racing endpoints for fastest response on latency-sensitive operations
|
||||
/// - Redis caching for albums and artists (24-hour TTL)
|
||||
/// - Explicit content filtering support
|
||||
/// - Parallel Spotify ID conversion via Odesli for lyrics matching
|
||||
/// </summary>
|
||||
|
||||
public class SquidWTFMetadataService : IMusicMetadataService
|
||||
@@ -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<Song>();
|
||||
// Per hifi-api spec: track search returns data.items array
|
||||
if (result.RootElement.TryGetProperty("data", out var data) &&
|
||||
data.TryGetProperty("items", out var items))
|
||||
{
|
||||
@@ -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<Album>();
|
||||
// Per hifi-api spec: album search returns data.albums.items array
|
||||
if (result.RootElement.TryGetProperty("data", out var data) &&
|
||||
data.TryGetProperty("albums", out var albumsObj) &&
|
||||
albumsObj.TryGetProperty("items", out var items))
|
||||
@@ -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<Artist>();
|
||||
// Per hifi-api spec: artist search returns data.artists.items array
|
||||
if (result.RootElement.TryGetProperty("data", out var data) &&
|
||||
data.TryGetProperty("artists", out var artistsObj) &&
|
||||
artistsObj.TryGetProperty("items", out var items))
|
||||
@@ -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<ExternalPlaylist>();
|
||||
@@ -173,15 +217,20 @@ public class SquidWTFMetadataService : IMusicMetadataService
|
||||
var result = JsonDocument.Parse(json);
|
||||
|
||||
var playlists = new List<ExternalPlaylist>();
|
||||
// Per hifi-api spec: playlist search returns data.playlists.items array
|
||||
if (result.RootElement.TryGetProperty("data", out var data) &&
|
||||
data.TryGetProperty("playlists", out var playlistObj) &&
|
||||
playlistObj.TryGetProperty("items", out var items))
|
||||
{
|
||||
int count = 0;
|
||||
foreach(var playlist in items.EnumerateArray())
|
||||
{
|
||||
if (count >= limit) break;
|
||||
|
||||
try
|
||||
{
|
||||
playlists.Add(ParseTidalPlaylist(playlist));
|
||||
count++;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -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<Album>();
|
||||
|
||||
// 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<Song>();
|
||||
@@ -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<Song>();
|
||||
|
||||
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 ---
|
||||
|
||||
/// <summary>
|
||||
/// Parses a Tidal track object from hifi-api search/album/playlist responses.
|
||||
/// Per hifi-api spec, track objects contain: id, title, duration, trackNumber, volumeNumber,
|
||||
/// explicit, artist (singular), artists (array), album (object with id, title, cover).
|
||||
/// </summary>
|
||||
/// <param name="track">JSON element containing track data</param>
|
||||
/// <param name="fallbackTrackNumber">Optional track number to use if not present in JSON</param>
|
||||
/// <returns>Parsed Song object</returns>
|
||||
private Song ParseTidalTrack(JsonElement track, int? fallbackTrackNumber = null)
|
||||
{
|
||||
var externalId = track.GetProperty("id").GetInt64().ToString();
|
||||
@@ -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)
|
||||
{
|
||||
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)
|
||||
{
|
||||
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
|
||||
/// <summary>
|
||||
/// Parses a Tidal artist object from hifi-api responses.
|
||||
/// Per hifi-api spec, artist objects contain: id, name, picture (UUID).
|
||||
/// Note: albums_count is not in the standard API response but is added by GetArtistAsync.
|
||||
/// </summary>
|
||||
/// <param name="artist">JSON element containing artist data</param>
|
||||
/// <returns>Parsed Artist object</returns>
|
||||
private Artist ParseTidalArtist(JsonElement artist)
|
||||
{
|
||||
var externalId = artist.GetProperty("id").GetInt64().ToString();
|
||||
@@ -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)
|
||||
{
|
||||
JsonElement? playlist = null;
|
||||
|
||||
Reference in New Issue
Block a user