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/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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user