mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-10 07:58:39 -05:00
642 lines
22 KiB
C#
642 lines
22 KiB
C#
using octo_fiesta.Models;
|
|
using System.Text.Json;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
namespace octo_fiesta.Services.Qobuz;
|
|
|
|
/// <summary>
|
|
/// Metadata service implementation using the Qobuz API
|
|
/// Uses user authentication token instead of email/password
|
|
/// </summary>
|
|
public class QobuzMetadataService : IMusicMetadataService
|
|
{
|
|
private readonly HttpClient _httpClient;
|
|
private readonly SubsonicSettings _settings;
|
|
private readonly QobuzBundleService _bundleService;
|
|
private readonly ILogger<QobuzMetadataService> _logger;
|
|
private readonly string? _userAuthToken;
|
|
private readonly string? _userId;
|
|
|
|
private const string BaseUrl = "https://www.qobuz.com/api.json/0.2/";
|
|
|
|
public QobuzMetadataService(
|
|
IHttpClientFactory httpClientFactory,
|
|
IOptions<SubsonicSettings> settings,
|
|
IOptions<QobuzSettings> qobuzSettings,
|
|
QobuzBundleService bundleService,
|
|
ILogger<QobuzMetadataService> logger)
|
|
{
|
|
_httpClient = httpClientFactory.CreateClient();
|
|
_settings = settings.Value;
|
|
_bundleService = bundleService;
|
|
_logger = logger;
|
|
|
|
var qobuzConfig = qobuzSettings.Value;
|
|
_userAuthToken = qobuzConfig.UserAuthToken;
|
|
_userId = qobuzConfig.UserId;
|
|
|
|
// Set up default headers
|
|
_httpClient.DefaultRequestHeaders.Add("User-Agent",
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0");
|
|
}
|
|
|
|
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
|
|
{
|
|
try
|
|
{
|
|
var appId = await _bundleService.GetAppIdAsync();
|
|
var url = $"{BaseUrl}track/search?query={Uri.EscapeDataString(query)}&limit={limit}&app_id={appId}";
|
|
|
|
var response = await GetWithAuthAsync(url);
|
|
if (!response.IsSuccessStatusCode) return new List<Song>();
|
|
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
var result = JsonDocument.Parse(json);
|
|
|
|
var songs = new List<Song>();
|
|
if (result.RootElement.TryGetProperty("tracks", out var tracks) &&
|
|
tracks.TryGetProperty("items", out var items))
|
|
{
|
|
foreach (var track in items.EnumerateArray())
|
|
{
|
|
var song = ParseQobuzTrack(track);
|
|
if (ShouldIncludeSong(song))
|
|
{
|
|
songs.Add(song);
|
|
}
|
|
}
|
|
}
|
|
|
|
return songs;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to search songs for query: {Query}", query);
|
|
return new List<Song>();
|
|
}
|
|
}
|
|
|
|
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
|
|
{
|
|
try
|
|
{
|
|
var appId = await _bundleService.GetAppIdAsync();
|
|
var url = $"{BaseUrl}album/search?query={Uri.EscapeDataString(query)}&limit={limit}&app_id={appId}";
|
|
|
|
var response = await GetWithAuthAsync(url);
|
|
if (!response.IsSuccessStatusCode) return new List<Album>();
|
|
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
var result = JsonDocument.Parse(json);
|
|
|
|
var albums = new List<Album>();
|
|
if (result.RootElement.TryGetProperty("albums", out var albumsData) &&
|
|
albumsData.TryGetProperty("items", out var items))
|
|
{
|
|
foreach (var album in items.EnumerateArray())
|
|
{
|
|
albums.Add(ParseQobuzAlbum(album));
|
|
}
|
|
}
|
|
|
|
return albums;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to search albums for query: {Query}", query);
|
|
return new List<Album>();
|
|
}
|
|
}
|
|
|
|
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
|
|
{
|
|
try
|
|
{
|
|
var appId = await _bundleService.GetAppIdAsync();
|
|
var url = $"{BaseUrl}artist/search?query={Uri.EscapeDataString(query)}&limit={limit}&app_id={appId}";
|
|
|
|
var response = await GetWithAuthAsync(url);
|
|
if (!response.IsSuccessStatusCode) return new List<Artist>();
|
|
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
var result = JsonDocument.Parse(json);
|
|
|
|
var artists = new List<Artist>();
|
|
if (result.RootElement.TryGetProperty("artists", out var artistsData) &&
|
|
artistsData.TryGetProperty("items", out var items))
|
|
{
|
|
foreach (var artist in items.EnumerateArray())
|
|
{
|
|
artists.Add(ParseQobuzArtist(artist));
|
|
}
|
|
}
|
|
|
|
return artists;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to search artists for query: {Query}", query);
|
|
return new List<Artist>();
|
|
}
|
|
}
|
|
|
|
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20)
|
|
{
|
|
var songsTask = SearchSongsAsync(query, songLimit);
|
|
var albumsTask = SearchAlbumsAsync(query, albumLimit);
|
|
var artistsTask = SearchArtistsAsync(query, artistLimit);
|
|
|
|
await Task.WhenAll(songsTask, albumsTask, artistsTask);
|
|
|
|
return new SearchResult
|
|
{
|
|
Songs = await songsTask,
|
|
Albums = await albumsTask,
|
|
Artists = await artistsTask
|
|
};
|
|
}
|
|
|
|
public async Task<Song?> GetSongAsync(string externalProvider, string externalId)
|
|
{
|
|
if (externalProvider != "qobuz") return null;
|
|
|
|
try
|
|
{
|
|
var appId = await _bundleService.GetAppIdAsync();
|
|
var url = $"{BaseUrl}track/get?track_id={externalId}&app_id={appId}";
|
|
|
|
var response = await GetWithAuthAsync(url);
|
|
if (!response.IsSuccessStatusCode) return null;
|
|
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
var track = JsonDocument.Parse(json).RootElement;
|
|
|
|
if (track.TryGetProperty("error", out _)) return null;
|
|
|
|
return ParseQobuzTrackFull(track);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to get song {ExternalId}", externalId);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public async Task<Album?> GetAlbumAsync(string externalProvider, string externalId)
|
|
{
|
|
if (externalProvider != "qobuz") return null;
|
|
|
|
try
|
|
{
|
|
var appId = await _bundleService.GetAppIdAsync();
|
|
var url = $"{BaseUrl}album/get?album_id={externalId}&app_id={appId}";
|
|
|
|
var response = await GetWithAuthAsync(url);
|
|
if (!response.IsSuccessStatusCode) return null;
|
|
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
var albumElement = JsonDocument.Parse(json).RootElement;
|
|
|
|
if (albumElement.TryGetProperty("error", out _)) return null;
|
|
|
|
var album = ParseQobuzAlbum(albumElement);
|
|
|
|
// Get album tracks
|
|
if (albumElement.TryGetProperty("tracks", out var tracks) &&
|
|
tracks.TryGetProperty("items", out var tracksData))
|
|
{
|
|
foreach (var track in tracksData.EnumerateArray())
|
|
{
|
|
var song = ParseQobuzTrack(track);
|
|
if (ShouldIncludeSong(song))
|
|
{
|
|
album.Songs.Add(song);
|
|
}
|
|
}
|
|
}
|
|
|
|
return album;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to get album {ExternalId}", externalId);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public async Task<Artist?> GetArtistAsync(string externalProvider, string externalId)
|
|
{
|
|
if (externalProvider != "qobuz") return null;
|
|
|
|
try
|
|
{
|
|
var appId = await _bundleService.GetAppIdAsync();
|
|
var url = $"{BaseUrl}artist/get?artist_id={externalId}&app_id={appId}";
|
|
|
|
var response = await GetWithAuthAsync(url);
|
|
if (!response.IsSuccessStatusCode) return null;
|
|
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
var artist = JsonDocument.Parse(json).RootElement;
|
|
|
|
if (artist.TryGetProperty("error", out _)) return null;
|
|
|
|
return ParseQobuzArtist(artist);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to get artist {ExternalId}", externalId);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public async Task<List<Album>> GetArtistAlbumsAsync(string externalProvider, string externalId)
|
|
{
|
|
if (externalProvider != "qobuz") return new List<Album>();
|
|
|
|
try
|
|
{
|
|
var albums = new List<Album>();
|
|
var appId = await _bundleService.GetAppIdAsync();
|
|
int offset = 0;
|
|
const int limit = 500;
|
|
|
|
// Qobuz requires pagination for artist albums
|
|
while (true)
|
|
{
|
|
var url = $"{BaseUrl}artist/get?artist_id={externalId}&app_id={appId}&limit={limit}&offset={offset}&extra=albums";
|
|
|
|
var response = await GetWithAuthAsync(url);
|
|
if (!response.IsSuccessStatusCode) break;
|
|
|
|
var json = await response.Content.ReadAsStringAsync();
|
|
var result = JsonDocument.Parse(json);
|
|
|
|
if (!result.RootElement.TryGetProperty("albums", out var albumsData) ||
|
|
!albumsData.TryGetProperty("items", out var items))
|
|
{
|
|
break;
|
|
}
|
|
|
|
var itemsArray = items.EnumerateArray().ToList();
|
|
if (itemsArray.Count == 0) break;
|
|
|
|
foreach (var album in itemsArray)
|
|
{
|
|
albums.Add(ParseQobuzAlbum(album));
|
|
}
|
|
|
|
// If we got less than the limit, we've reached the end
|
|
if (itemsArray.Count < limit) break;
|
|
|
|
offset += limit;
|
|
}
|
|
|
|
return albums;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to get artist albums for {ExternalId}", externalId);
|
|
return new List<Album>();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Safely gets an ID value as a string, handling both number and string types from JSON
|
|
/// </summary>
|
|
private string GetIdAsString(JsonElement element)
|
|
{
|
|
return element.ValueKind switch
|
|
{
|
|
JsonValueKind.Number => element.GetInt64().ToString(),
|
|
JsonValueKind.String => element.GetString() ?? "",
|
|
_ => ""
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Makes an HTTP GET request with Qobuz authentication headers
|
|
/// </summary>
|
|
private async Task<HttpResponseMessage> GetWithAuthAsync(string url)
|
|
{
|
|
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
|
|
|
var appId = await _bundleService.GetAppIdAsync();
|
|
request.Headers.Add("X-App-Id", appId);
|
|
|
|
if (!string.IsNullOrEmpty(_userAuthToken))
|
|
{
|
|
request.Headers.Add("X-User-Auth-Token", _userAuthToken);
|
|
}
|
|
|
|
return await _httpClient.SendAsync(request);
|
|
}
|
|
|
|
private Song ParseQobuzTrack(JsonElement track)
|
|
{
|
|
var externalId = GetIdAsString(track.GetProperty("id"));
|
|
|
|
var title = track.GetProperty("title").GetString() ?? "";
|
|
|
|
// Add version to title if present (e.g., "Remastered", "Live")
|
|
if (track.TryGetProperty("version", out var version))
|
|
{
|
|
var versionStr = version.GetString();
|
|
if (!string.IsNullOrEmpty(versionStr))
|
|
{
|
|
title = $"{title} ({versionStr})";
|
|
}
|
|
}
|
|
|
|
// For classical music, prepend work name
|
|
if (track.TryGetProperty("work", out var work))
|
|
{
|
|
var workStr = work.GetString();
|
|
if (!string.IsNullOrEmpty(workStr))
|
|
{
|
|
title = $"{workStr}: {title}";
|
|
}
|
|
}
|
|
|
|
var performerName = track.TryGetProperty("performer", out var performer)
|
|
? performer.GetProperty("name").GetString() ?? ""
|
|
: "";
|
|
|
|
var albumTitle = track.TryGetProperty("album", out var album)
|
|
? album.GetProperty("title").GetString() ?? ""
|
|
: "";
|
|
|
|
var albumId = track.TryGetProperty("album", out var albumForId)
|
|
? $"ext-qobuz-album-{GetIdAsString(albumForId.GetProperty("id"))}"
|
|
: null;
|
|
|
|
// Get album artist
|
|
var albumArtist = track.TryGetProperty("album", out var albumForArtist) &&
|
|
albumForArtist.TryGetProperty("artist", out var albumArtistEl)
|
|
? albumArtistEl.GetProperty("name").GetString()
|
|
: performerName;
|
|
|
|
return new Song
|
|
{
|
|
Id = $"ext-qobuz-song-{externalId}",
|
|
Title = title,
|
|
Artist = performerName,
|
|
ArtistId = track.TryGetProperty("performer", out var performerForId)
|
|
? $"ext-qobuz-artist-{GetIdAsString(performerForId.GetProperty("id"))}"
|
|
: null,
|
|
Album = albumTitle,
|
|
AlbumId = albumId,
|
|
AlbumArtist = albumArtist,
|
|
Duration = track.TryGetProperty("duration", out var duration)
|
|
? duration.GetInt32()
|
|
: null,
|
|
Track = track.TryGetProperty("track_number", out var trackNum)
|
|
? trackNum.GetInt32()
|
|
: null,
|
|
DiscNumber = track.TryGetProperty("media_number", out var mediaNum)
|
|
? mediaNum.GetInt32()
|
|
: null,
|
|
CoverArtUrl = GetCoverArtUrl(track),
|
|
IsLocal = false,
|
|
ExternalProvider = "qobuz",
|
|
ExternalId = externalId
|
|
};
|
|
}
|
|
|
|
private Song ParseQobuzTrackFull(JsonElement track)
|
|
{
|
|
var song = ParseQobuzTrack(track);
|
|
|
|
// Add additional metadata for full track
|
|
if (track.TryGetProperty("composer", out var composer) &&
|
|
composer.TryGetProperty("name", out var composerName))
|
|
{
|
|
song.Contributors = new List<string> { composerName.GetString() ?? "" };
|
|
}
|
|
|
|
if (track.TryGetProperty("isrc", out var isrc))
|
|
{
|
|
song.Isrc = isrc.GetString();
|
|
}
|
|
|
|
if (track.TryGetProperty("copyright", out var copyright))
|
|
{
|
|
song.Copyright = FormatCopyright(copyright.GetString() ?? "");
|
|
}
|
|
|
|
// Get release date from album
|
|
if (track.TryGetProperty("album", out var album))
|
|
{
|
|
if (album.TryGetProperty("release_date_original", out var releaseDate))
|
|
{
|
|
var dateStr = releaseDate.GetString();
|
|
song.ReleaseDate = dateStr;
|
|
|
|
if (!string.IsNullOrEmpty(dateStr) && dateStr.Length >= 4)
|
|
{
|
|
if (int.TryParse(dateStr.Substring(0, 4), out var year))
|
|
{
|
|
song.Year = year;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (album.TryGetProperty("tracks_count", out var tracksCount))
|
|
{
|
|
song.TotalTracks = tracksCount.GetInt32();
|
|
}
|
|
|
|
if (album.TryGetProperty("genres_list", out var genres))
|
|
{
|
|
song.Genre = FormatGenres(genres);
|
|
}
|
|
|
|
// Get large cover art
|
|
song.CoverArtUrlLarge = GetLargeCoverArtUrl(album);
|
|
}
|
|
|
|
return song;
|
|
}
|
|
|
|
private Album ParseQobuzAlbum(JsonElement album)
|
|
{
|
|
var externalId = GetIdAsString(album.GetProperty("id"));
|
|
|
|
var title = album.GetProperty("title").GetString() ?? "";
|
|
|
|
// Add version to title if present
|
|
if (album.TryGetProperty("version", out var version))
|
|
{
|
|
var versionStr = version.GetString();
|
|
if (!string.IsNullOrEmpty(versionStr))
|
|
{
|
|
title = $"{title} ({versionStr})";
|
|
}
|
|
}
|
|
|
|
var artistName = album.TryGetProperty("artist", out var artist)
|
|
? artist.GetProperty("name").GetString() ?? ""
|
|
: "";
|
|
|
|
int? year = null;
|
|
if (album.TryGetProperty("release_date_original", out var releaseDate))
|
|
{
|
|
var dateStr = releaseDate.GetString();
|
|
if (!string.IsNullOrEmpty(dateStr) && dateStr.Length >= 4)
|
|
{
|
|
if (int.TryParse(dateStr.Substring(0, 4), out var y))
|
|
{
|
|
year = y;
|
|
}
|
|
}
|
|
}
|
|
|
|
return new Album
|
|
{
|
|
Id = $"ext-qobuz-album-{externalId}",
|
|
Title = title,
|
|
Artist = artistName,
|
|
ArtistId = album.TryGetProperty("artist", out var artistForId)
|
|
? $"ext-qobuz-artist-{GetIdAsString(artistForId.GetProperty("id"))}"
|
|
: null,
|
|
Year = year,
|
|
SongCount = album.TryGetProperty("tracks_count", out var tracksCount)
|
|
? tracksCount.GetInt32()
|
|
: null,
|
|
CoverArtUrl = GetCoverArtUrl(album),
|
|
Genre = album.TryGetProperty("genres_list", out var genres)
|
|
? FormatGenres(genres)
|
|
: null,
|
|
IsLocal = false,
|
|
ExternalProvider = "qobuz",
|
|
ExternalId = externalId
|
|
};
|
|
}
|
|
|
|
private Artist ParseQobuzArtist(JsonElement artist)
|
|
{
|
|
var externalId = GetIdAsString(artist.GetProperty("id"));
|
|
|
|
return new Artist
|
|
{
|
|
Id = $"ext-qobuz-artist-{externalId}",
|
|
Name = artist.GetProperty("name").GetString() ?? "",
|
|
ImageUrl = GetArtistImageUrl(artist),
|
|
AlbumCount = artist.TryGetProperty("albums_count", out var albumsCount)
|
|
? albumsCount.GetInt32()
|
|
: null,
|
|
IsLocal = false,
|
|
ExternalProvider = "qobuz",
|
|
ExternalId = externalId
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Extracts cover art URL from track or album element
|
|
/// </summary>
|
|
private string? GetCoverArtUrl(JsonElement element)
|
|
{
|
|
// For tracks, get album image
|
|
if (element.TryGetProperty("album", out var album))
|
|
{
|
|
element = album;
|
|
}
|
|
|
|
if (element.TryGetProperty("image", out var image))
|
|
{
|
|
// Prefer thumbnail (230x230), fallback to small
|
|
if (image.TryGetProperty("thumbnail", out var thumbnail))
|
|
{
|
|
return thumbnail.GetString();
|
|
}
|
|
if (image.TryGetProperty("small", out var small))
|
|
{
|
|
return small.GetString();
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets large cover art URL (600x600 or original)
|
|
/// </summary>
|
|
private string? GetLargeCoverArtUrl(JsonElement album)
|
|
{
|
|
if (album.TryGetProperty("image", out var image) &&
|
|
image.TryGetProperty("large", out var large))
|
|
{
|
|
var url = large.GetString();
|
|
// Replace _600.jpg with _org.jpg for original quality
|
|
return url?.Replace("_600.jpg", "_org.jpg");
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets artist image URL
|
|
/// </summary>
|
|
private string? GetArtistImageUrl(JsonElement artist)
|
|
{
|
|
if (artist.TryGetProperty("image", out var image) &&
|
|
image.TryGetProperty("large", out var large))
|
|
{
|
|
return large.GetString();
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Formats Qobuz genre list into a readable string
|
|
/// Example: ["Pop/Rock", "Pop/Rock→Rock"] becomes "Pop, Rock"
|
|
/// </summary>
|
|
private string FormatGenres(JsonElement genresList)
|
|
{
|
|
var genres = new List<string>();
|
|
|
|
foreach (var genre in genresList.EnumerateArray())
|
|
{
|
|
var genreStr = genre.GetString();
|
|
if (!string.IsNullOrEmpty(genreStr))
|
|
{
|
|
// Extract individual genres from paths like "Pop/Rock→Rock→Alternative"
|
|
var parts = genreStr.Split(new[] { '/', '→' }, StringSplitOptions.RemoveEmptyEntries);
|
|
foreach (var part in parts)
|
|
{
|
|
var trimmed = part.Trim();
|
|
if (!genres.Contains(trimmed))
|
|
{
|
|
genres.Add(trimmed);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return string.Join(", ", genres);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Formats copyright string
|
|
/// Replaces (P) with ℗ and (C) with ©
|
|
/// </summary>
|
|
private string FormatCopyright(string copyright)
|
|
{
|
|
return copyright
|
|
.Replace("(P)", "℗")
|
|
.Replace("(C)", "©");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines whether a song should be included based on the explicit content filter setting
|
|
/// Note: Qobuz doesn't have the same explicit content tagging as Deezer, so this is a no-op for now
|
|
/// </summary>
|
|
private bool ShouldIncludeSong(Song song)
|
|
{
|
|
// Qobuz API doesn't expose explicit content flags in the same way as Deezer
|
|
// We could implement this in the future if needed
|
|
return true;
|
|
}
|
|
}
|