Files
lampac/mods/Music/Providers/SoundCloud/SoundCloudSupport.Catalog.cs
T

1379 lines
53 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Net;
using System.Net.Http;
using System.Linq;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Music;
// Discovery/каталог: чарты, страницы артистов и секций, поиск по запросу,
// маппинг JSON api-v2 в Music*-контракты, кодеки id.
public static partial class SoundCloudSupport
{
public static async Task<List<MusicAlbum>> GetChartAlbumsAsync(CancellationToken cancellationToken = default)
{
return await MusicMetadataCacheService.GetOrCreateAsync(
DiscoveryProviderId,
"browse",
$"charts:{chartsCacheVersion}:{Country.ToLowerInvariant()}",
chartsCacheTtl,
() => LoadChartAlbumsAsync(cancellationToken),
cancellationToken
) ?? new List<MusicAlbum>();
}
public static async Task<MusicAlbum> GetChartAlbumAsync(string id, CancellationToken cancellationToken = default)
{
string playlistUrl = DecodePlaylistUrl(id);
if (string.IsNullOrWhiteSpace(playlistUrl))
return null;
var album = await MusicMetadataCacheService.GetOrCreateAsync(
DiscoveryProviderId,
"album",
$"playlist:{chartsCacheVersion}:{playlistUrl}",
playlistCacheTtl,
() => LoadChartAlbumAsync(playlistUrl, cancellationToken),
cancellationToken
);
if (album?.tracks?.Count > 0)
await CacheDiscoveredTracksAsync(album.tracks, cancellationToken);
return album;
}
public static async Task<MusicArtist> GetUserArtistAsync(string id, CancellationToken cancellationToken = default)
{
string userUrl = DecodeUserUrl(id);
if (string.IsNullOrWhiteSpace(userUrl))
return null;
return await MusicMetadataCacheService.GetOrCreateAsync(
DiscoveryProviderId,
"artist",
$"user:{chartsCacheVersion}:{userUrl}",
playlistCacheTtl,
() => LoadUserArtistAsync(userUrl, cancellationToken),
cancellationToken
);
}
public static async Task<MusicBrowseSection> GetUserSectionAsync(string id, string page = null, int limit = 20, CancellationToken cancellationToken = default)
{
var decoded = DecodeUserSectionId(id);
if (string.IsNullOrWhiteSpace(decoded.userUrl) || string.IsNullOrWhiteSpace(decoded.section))
return null;
string pageKey = string.IsNullOrWhiteSpace(page) ? "first" : page.Trim();
return await MusicMetadataCacheService.GetOrCreateAsync(
DiscoveryProviderId,
"artistsection",
$"usersection:{chartsCacheVersion}:{decoded.section}:{Math.Max(1, limit)}:{decoded.userUrl}:{pageKey}",
playlistCacheTtl,
() => LoadUserSectionAsync(decoded.userUrl, decoded.section, page, limit, cancellationToken),
cancellationToken
);
}
public static async Task<MusicAlbum> GetUserTracksAlbumAsync(string id, CancellationToken cancellationToken = default)
{
string userUrl = DecodeUserTracksUrl(id);
if (string.IsNullOrWhiteSpace(userUrl))
return null;
return await MusicMetadataCacheService.GetOrCreateAsync(
DiscoveryProviderId,
"album",
$"usertracks:{chartsCacheVersion}:{userUrl}",
playlistCacheTtl,
() => LoadUserTracksAlbumAsync(userUrl, cancellationToken),
cancellationToken
);
}
public static async Task<MusicTrack> GetTrackAsync(string id, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(id))
return null;
var cached = await MusicMetadataCacheService.GetAsync<MusicTrack>(
DiscoveryProviderId,
"track",
BuildTrackCacheKey(id),
cancellationToken
);
if (cached != null)
return cached;
string apiTrackId = ParseTrackApiId(id);
if (string.IsNullOrWhiteSpace(apiTrackId))
return null;
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return null;
string url = BuildApiV2Url($"tracks/{apiTrackId}", new Dictionary<string, string>
{
["client_id"] = clientId
});
using var request = new HttpRequestMessage(HttpMethod.Get, url);
foreach (var header in CreatePublicHeaders())
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
InvalidatePublicClientIdOnUnauthorized(response.StatusCode, clientId);
return null;
}
string json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
var track = MapStandaloneTrack(document.RootElement);
if (track == null)
return null;
await MusicMetadataCacheService.SaveAsync(
DiscoveryProviderId,
"track",
BuildTrackCacheKey(track.id),
track,
trackCacheTtl,
cancellationToken);
return track;
}
public static string BuildPlaylistAlbumId(string playlistUrl)
{
if (string.IsNullOrWhiteSpace(playlistUrl))
return null;
return PlaylistIdPrefix + Uri.EscapeDataString(playlistUrl.Trim());
}
public static string DecodePlaylistUrl(string id)
{
if (string.IsNullOrWhiteSpace(id) || !id.StartsWith(PlaylistIdPrefix, StringComparison.OrdinalIgnoreCase))
return null;
string encoded = id.Substring(PlaylistIdPrefix.Length);
return string.IsNullOrWhiteSpace(encoded) ? null : Uri.UnescapeDataString(encoded);
}
public static string BuildUserArtistId(string userUrl)
{
if (string.IsNullOrWhiteSpace(userUrl))
return null;
return UserIdPrefix + Uri.EscapeDataString(userUrl.Trim());
}
public static string DecodeUserUrl(string id)
{
if (string.IsNullOrWhiteSpace(id) || !id.StartsWith(UserIdPrefix, StringComparison.OrdinalIgnoreCase))
return null;
string encoded = id.Substring(UserIdPrefix.Length);
return string.IsNullOrWhiteSpace(encoded) ? null : Uri.UnescapeDataString(encoded);
}
public static string BuildUserTracksAlbumId(string userUrl)
{
if (string.IsNullOrWhiteSpace(userUrl))
return null;
return UserTracksAlbumPrefix + Uri.EscapeDataString(userUrl.Trim());
}
static string BuildUserSectionId(string userUrl, string section)
{
if (string.IsNullOrWhiteSpace(userUrl) || string.IsNullOrWhiteSpace(section))
return null;
return $"{UserSectionPrefix}{Uri.EscapeDataString(userUrl.Trim())}:{section.Trim().ToLowerInvariant()}";
}
static (string userUrl, string section) DecodeUserSectionId(string id)
{
if (string.IsNullOrWhiteSpace(id) || !id.StartsWith(UserSectionPrefix, StringComparison.OrdinalIgnoreCase))
return (null, null);
string value = id.Substring(UserSectionPrefix.Length);
int separator = value.LastIndexOf(':');
if (separator <= 0 || separator >= value.Length - 1)
return (null, null);
string encodedUserUrl = value.Substring(0, separator);
string section = value.Substring(separator + 1);
return (
string.IsNullOrWhiteSpace(encodedUserUrl) ? null : Uri.UnescapeDataString(encodedUserUrl),
NormalizeUserSectionKey(section)
);
}
static string NormalizeUserSectionKey(string section)
{
string value = NormalizeValue(section)?.ToLowerInvariant();
return value is "toptracks" or "albums" or "tracks" or "fansalsolike" ? value : null;
}
public static string DecodeUserTracksUrl(string id)
{
if (string.IsNullOrWhiteSpace(id) || !id.StartsWith(UserTracksAlbumPrefix, StringComparison.OrdinalIgnoreCase))
return null;
string encoded = id.Substring(UserTracksAlbumPrefix.Length);
return string.IsNullOrWhiteSpace(encoded) ? null : Uri.UnescapeDataString(encoded);
}
public static bool IsUserArtist(string provider, string id)
{
return string.Equals(provider, DiscoveryProviderId, StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(DecodeUserUrl(id));
}
public static bool IsUserTracksAlbum(string provider, string id)
{
return string.Equals(provider, DiscoveryProviderId, StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(DecodeUserTracksUrl(id));
}
public static bool IsUserSection(string provider, string id)
{
var decoded = DecodeUserSectionId(id);
return string.Equals(provider, DiscoveryProviderId, StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(decoded.userUrl)
&& !string.IsNullOrWhiteSpace(decoded.section);
}
static async Task<List<MusicAlbum>> LoadChartAlbumsAsync(CancellationToken cancellationToken)
{
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return new List<MusicAlbum>();
string url = BuildApiV2Url("charts/selections", new Dictionary<string, string>
{
["client_id"] = clientId
});
using var request = new HttpRequestMessage(HttpMethod.Get, url);
foreach (var header in CreatePublicHeaders())
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
InvalidatePublicClientIdOnUnauthorized(response.StatusCode, clientId);
return new List<MusicAlbum>();
}
string json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
if (!document.RootElement.TryGetProperty("collection", out var selections) || selections.ValueKind != JsonValueKind.Array)
return new List<MusicAlbum>();
JsonElement? selection = PickSelection(selections);
if (selection == null || !TryGetNestedCollection(selection.Value, "items", out var playlists))
return new List<MusicAlbum>();
var results = new List<MusicAlbum>();
int index = 0;
foreach (var playlist in playlists.EnumerateArray())
{
if (!ShouldExposePlaylist(playlist))
continue;
var album = MapChartPlaylist(selection.Value, playlist, index++);
if (album != null)
results.Add(album);
}
return results;
}
static async Task<MusicAlbum> LoadChartAlbumAsync(string playlistUrl, CancellationToken cancellationToken)
{
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return null;
var resolved = await ResolveUrlElementAsync(playlistUrl, clientId, cancellationToken);
if (!resolved.HasValue)
return null;
var playlist = resolved.Value;
int? trackCount = GetInt(playlist, "track_count");
int loadedTracks = GetArrayLength(playlist, "tracks");
string playlistId = GetString(playlist, "id");
if (!string.IsNullOrWhiteSpace(playlistId) && (!trackCount.HasValue || loadedTracks < trackCount.Value))
{
var hydrated = await LoadPlaylistByIdAsync(playlistId, clientId, cancellationToken);
if (hydrated.HasValue)
playlist = hydrated.Value;
}
var trackDetails = await LoadTrackDetailsAsync(CollectTrackIdsForHydration(playlist), clientId, cancellationToken);
return MapResolvedPlaylist(playlist, trackDetails.tracks);
}
static async Task<MusicArtist> LoadUserArtistAsync(string userUrl, CancellationToken cancellationToken)
{
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return null;
var resolved = await ResolveUrlElementAsync(userUrl, clientId, cancellationToken);
if (!resolved.HasValue)
return null;
var user = resolved.Value;
var artist = MapUser(user, includeTracksAlbum: false);
if (artist == null)
return null;
string userId = GetString(user, "id");
if (string.IsNullOrWhiteSpace(userId))
return artist;
var topTracksTask = SafeLoadCollectionElementsAsync($"users/{userId}/toptracks", clientId, 30, cancellationToken);
var albumsTask = SafeLoadCollectionElementsAsync($"users/{userId}/albums", clientId, 20, cancellationToken);
var allTracksTask = SafeLoadCollectionElementsAsync($"users/{userId}/tracks", clientId, 20, cancellationToken);
var relatedArtistsTask = SafeLoadCollectionElementsAsync(
$"users/{userId}/relatedartists",
clientId,
12,
cancellationToken,
new Dictionary<string, string>
{
["creators_only"] = "false",
["page_size"] = "12"
});
await Task.WhenAll(topTracksTask, albumsTask, allTracksTask, relatedArtistsTask);
var topTracks = await topTracksTask;
var albums = await albumsTask;
var allTracks = await allTracksTask;
var relatedArtists = await relatedArtistsTask;
AddSectionIfNotEmpty(artist, BuildTrackSection(userUrl, "toptracks", "Top tracks", topTracks.items, topTracks.hasMore, topTracks.nextPage));
var albumsSection = BuildAlbumSection(userUrl, "albums", "Albums", albums.items, albums.hasMore, albums.nextPage);
AddSectionIfNotEmpty(artist, albumsSection);
if (albumsSection?.albums?.Count > 0)
artist.albums = albumsSection.albums;
AddSectionIfNotEmpty(artist, BuildTrackSection(userUrl, "tracks", "All tracks", allTracks.items, allTracks.hasMore, allTracks.nextPage));
AddSectionIfNotEmpty(artist, BuildArtistSection(userUrl, "fansalsolike", "Fans also like", relatedArtists.items, relatedArtists.hasMore, relatedArtists.nextPage));
await CacheDiscoveredTracksAsync(
artist.sections
?.Where(section => section?.tracks != null)
.SelectMany(section => section.tracks)
?? Enumerable.Empty<MusicTrack>(),
cancellationToken);
return artist;
}
static async Task<MusicBrowseSection> LoadUserSectionAsync(string userUrl, string section, string page, int limit, CancellationToken cancellationToken)
{
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return null;
section = NormalizeUserSectionKey(section);
if (string.IsNullOrWhiteSpace(section))
return null;
int safeLimit = Math.Max(1, Math.Min(limit <= 0 ? 20 : limit, 50));
SoundCloudCollectionPage pageResult;
if (!string.IsNullOrWhiteSpace(page))
{
pageResult = await SafeLoadCollectionElementsAsync(
null,
clientId,
safeLimit,
cancellationToken,
pageUrl: page);
}
else
{
var resolved = await ResolveUrlElementAsync(userUrl, clientId, cancellationToken);
if (!resolved.HasValue)
return null;
string userId = GetString(resolved.Value, "id");
if (string.IsNullOrWhiteSpace(userId))
return null;
pageResult = await SafeLoadUserSectionPageAsync(userId, section, clientId, safeLimit, cancellationToken);
}
return section switch
{
"toptracks" => BuildTrackSection(userUrl, section, "Top tracks", pageResult.items, pageResult.hasMore, pageResult.nextPage),
"albums" => BuildAlbumSection(userUrl, section, "Albums", pageResult.items, pageResult.hasMore, pageResult.nextPage),
"tracks" => BuildTrackSection(userUrl, section, "All tracks", pageResult.items, pageResult.hasMore, pageResult.nextPage),
"fansalsolike" => BuildArtistSection(userUrl, section, "Fans also like", pageResult.items, pageResult.hasMore, pageResult.nextPage),
_ => null
};
}
static Task<SoundCloudCollectionPage> SafeLoadUserSectionPageAsync(
string userId,
string section,
string clientId,
int limit,
CancellationToken cancellationToken)
{
return section switch
{
"toptracks" => SafeLoadCollectionElementsAsync($"users/{userId}/toptracks", clientId, limit, cancellationToken),
"albums" => SafeLoadCollectionElementsAsync($"users/{userId}/albums", clientId, limit, cancellationToken),
"tracks" => SafeLoadCollectionElementsAsync($"users/{userId}/tracks", clientId, limit, cancellationToken),
"fansalsolike" => SafeLoadCollectionElementsAsync(
$"users/{userId}/relatedartists",
clientId,
limit,
cancellationToken,
new Dictionary<string, string>
{
["creators_only"] = "false",
["page_size"] = limit.ToString()
}),
_ => Task.FromResult(SoundCloudCollectionPage.Empty())
};
}
static async Task<MusicAlbum> LoadUserTracksAlbumAsync(string userUrl, CancellationToken cancellationToken)
{
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return null;
var resolved = await ResolveUrlElementAsync(userUrl, clientId, cancellationToken);
if (!resolved.HasValue)
return null;
var user = resolved.Value;
string userId = GetString(user, "id");
string username = GetString(user, "username") ?? "SoundCloud";
var album = BuildUserTracksAlbum(userUrl, username, BuildImages(GetString(user, "avatar_url")));
if (string.IsNullOrWhiteSpace(userId))
return album;
foreach (var track in await LoadUserTrackElementsAsync(userId, clientId, 50, cancellationToken))
{
var mapped = MapPlaylistTrack(track, album, album.tracks.Count + 1, SelectFirstImageUrl(album.images));
if (mapped != null)
album.tracks.Add(mapped);
}
return album;
}
static Task CacheDiscoveredTracksAsync(IEnumerable<MusicTrack> tracks, CancellationToken cancellationToken)
{
if (tracks == null)
return Task.CompletedTask;
var writes = new List<Task>();
foreach (var track in tracks)
{
if (track == null || string.IsNullOrWhiteSpace(track.id))
continue;
writes.Add(MusicMetadataCacheService.SaveAsync(
DiscoveryProviderId,
"track",
BuildTrackCacheKey(track.id),
track,
trackCacheTtl,
cancellationToken));
}
return writes.Count == 0 ? Task.CompletedTask : Task.WhenAll(writes);
}
static JsonElement? PickSelection(JsonElement selections)
{
if (selections.ValueKind != JsonValueKind.Array || selections.GetArrayLength() == 0)
return null;
string country = Country;
foreach (var selection in selections.EnumerateArray())
{
string title = GetString(selection, "title");
if (!string.IsNullOrWhiteSpace(title) && title.EndsWith($" {country}", StringComparison.OrdinalIgnoreCase))
return selection;
}
foreach (var selection in selections.EnumerateArray())
{
string title = GetString(selection, "title");
if (!string.IsNullOrWhiteSpace(title) && title.Contains("US", StringComparison.OrdinalIgnoreCase))
return selection;
}
foreach (var selection in selections.EnumerateArray())
return selection;
return null;
}
static bool TryGetNestedCollection(JsonElement element, string propertyName, out JsonElement collection)
{
collection = default;
if (!element.TryGetProperty(propertyName, out var nested) || nested.ValueKind != JsonValueKind.Object)
return false;
if (!nested.TryGetProperty("collection", out collection) || collection.ValueKind != JsonValueKind.Array)
return false;
return true;
}
static bool ShouldExposePlaylist(JsonElement playlist)
{
string url = GetString(playlist, "permalink_url");
string title = GetString(playlist, "title");
if (string.IsNullOrWhiteSpace(url) || string.IsNullOrWhiteSpace(title))
return false;
return !string.Equals(title.Trim(), "Artist Pro", StringComparison.OrdinalIgnoreCase);
}
static MusicAlbum MapChartPlaylist(JsonElement selection, JsonElement playlist, int index)
{
string playlistUrl = GetString(playlist, "permalink_url");
if (string.IsNullOrWhiteSpace(playlistUrl))
return null;
string title = NormalizePlaylistTitle(GetString(playlist, "title"));
string artwork = UpgradeArtwork(GetString(playlist, "artwork_url"));
string description = GetString(selection, "title");
return new MusicAlbum
{
id = BuildPlaylistAlbumId(playlistUrl),
title = title,
artist_name = "SoundCloud",
date = GetString(playlist, "display_date") ?? GetString(playlist, "published_at"),
type = "Playlist",
description = description,
images = string.IsNullOrWhiteSpace(artwork)
? new List<MusicImage>()
: new List<MusicImage> { new() { url = artwork, width = 500, height = 500 } },
provider_refs = new List<MusicProviderRef>
{
new() { provider = DiscoveryProviderId, external_id = playlistUrl }
}
};
}
static MusicAlbum MapResolvedPlaylist(JsonElement playlist, Dictionary<string, JsonElement> hydratedTracks = null)
{
string playlistUrl = GetString(playlist, "permalink_url");
if (string.IsNullOrWhiteSpace(playlistUrl))
return null;
string title = NormalizePlaylistTitle(GetString(playlist, "title"));
string artwork = UpgradeArtwork(GetString(playlist, "artwork_url"));
string date = GetString(playlist, "display_date") ?? GetString(playlist, "published_at");
string artistName = "SoundCloud";
string avatar = null;
if (playlist.TryGetProperty("user", out var user) && user.ValueKind == JsonValueKind.Object)
{
artistName = NormalizeValue(GetString(user, "username")) ?? artistName;
avatar = GetString(user, "avatar_url");
}
var album = new MusicAlbum
{
id = BuildPlaylistAlbumId(playlistUrl),
title = title,
artist_name = artistName,
date = date,
type = IsSoundCloudAlbumSet(playlist) ? "Album" : "Playlist",
description = GetString(playlist, "genre") ?? (IsSoundCloudAlbumSet(playlist) ? "Album" : "Playlist"),
images = BuildImages(artwork, avatar),
provider_refs = new List<MusicProviderRef>
{
new() { provider = DiscoveryProviderId, external_id = playlistUrl }
},
tracks = new List<MusicTrack>()
};
if (playlist.TryGetProperty("tracks", out var tracks) && tracks.ValueKind == JsonValueKind.Array)
{
int position = 1;
foreach (var track in tracks.EnumerateArray())
{
var mapped = MapPlaylistTrack(track, album, position++, artwork, hydratedTracks);
if (mapped != null)
album.tracks.Add(mapped);
}
}
return album;
}
static MusicAlbum MapSearchPlaylist(JsonElement playlist, string type)
{
string playlistUrl = GetString(playlist, "permalink_url");
string title = NormalizeValue(GetString(playlist, "title"));
if (string.IsNullOrWhiteSpace(playlistUrl) || string.IsNullOrWhiteSpace(title))
return null;
string artistName = null;
string avatar = null;
if (playlist.TryGetProperty("user", out var user) && user.ValueKind == JsonValueKind.Object)
{
artistName = NormalizeValue(GetString(user, "username"));
avatar = GetString(user, "avatar_url");
}
return new MusicAlbum
{
id = BuildPlaylistAlbumId(playlistUrl),
title = title,
artist_name = string.IsNullOrWhiteSpace(artistName) ? "SoundCloud" : artistName,
date = GetString(playlist, "display_date") ?? GetString(playlist, "published_at"),
type = string.IsNullOrWhiteSpace(type) ? "Playlist" : type,
description = GetString(playlist, "genre") ?? type ?? "SoundCloud",
images = BuildImages(GetString(playlist, "artwork_url"), avatar),
provider_refs = new List<MusicProviderRef>
{
new() { provider = DiscoveryProviderId, external_id = playlistUrl }
}
};
}
static bool IsSoundCloudAlbumSet(JsonElement playlist)
{
string setType = NormalizeValue(GetString(playlist, "set_type"));
string playlistType = NormalizeValue(GetString(playlist, "playlist_type"));
string kind = NormalizeValue(GetString(playlist, "kind"));
return string.Equals(setType, "album", StringComparison.OrdinalIgnoreCase)
|| string.Equals(playlistType, "album", StringComparison.OrdinalIgnoreCase)
|| string.Equals(kind, "album", StringComparison.OrdinalIgnoreCase);
}
static void AddSectionIfNotEmpty(MusicArtist artist, MusicBrowseSection section)
{
if (artist == null || section == null)
return;
bool hasEntries = section.tracks?.Count > 0
|| section.albums?.Count > 0
|| section.artists?.Count > 0;
if (hasEntries)
artist.sections.Add(section);
}
static MusicBrowseSection BuildTrackSection(string userUrl, string key, string title, List<JsonElement> items, bool hasMore, string nextPage)
{
var section = new MusicBrowseSection
{
id = BuildUserSectionId(userUrl, key),
title = title,
type = "tracks",
source_provider = DiscoveryProviderId,
has_more = hasMore,
next_page = nextPage,
tracks = new List<MusicTrack>()
};
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in items ?? new List<JsonElement>())
{
var track = MapStandaloneTrack(item);
if (track == null || string.IsNullOrWhiteSpace(track.id) || !seen.Add(track.id))
continue;
section.tracks.Add(track);
}
return section;
}
static MusicBrowseSection BuildAlbumSection(string userUrl, string key, string title, List<JsonElement> items, bool hasMore, string nextPage)
{
var section = new MusicBrowseSection
{
id = BuildUserSectionId(userUrl, key),
title = title,
type = "albums",
source_provider = DiscoveryProviderId,
has_more = hasMore,
next_page = nextPage,
albums = new List<MusicAlbum>()
};
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in items ?? new List<JsonElement>())
{
var album = MapSearchPlaylist(item, "Album");
if (album == null || string.IsNullOrWhiteSpace(album.id) || !seen.Add(album.id))
continue;
section.albums.Add(album);
}
return section;
}
static MusicBrowseSection BuildArtistSection(string userUrl, string key, string title, List<JsonElement> items, bool hasMore, string nextPage)
{
var section = new MusicBrowseSection
{
id = BuildUserSectionId(userUrl, key),
title = title,
type = "artists",
source_provider = DiscoveryProviderId,
has_more = hasMore,
next_page = nextPage,
artists = new List<MusicArtist>()
};
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in items ?? new List<JsonElement>())
{
var artist = MapUser(item, includeTracksAlbum: false);
if (artist == null || string.IsNullOrWhiteSpace(artist.id) || !seen.Add(artist.id))
continue;
section.artists.Add(artist);
}
return section;
}
static MusicArtist MapUser(JsonElement user, bool includeTracksAlbum)
{
string userUrl = GetString(user, "permalink_url");
string username = NormalizeValue(GetString(user, "username"));
if (string.IsNullOrWhiteSpace(userUrl) || string.IsNullOrWhiteSpace(username))
return null;
var images = BuildImages(GetString(user, "avatar_url"));
var artist = new MusicArtist
{
id = BuildUserArtistId(userUrl),
name = username,
sort_name = username,
description = "SoundCloud",
images = images,
provider_refs = new List<MusicProviderRef>
{
new() { provider = DiscoveryProviderId, external_id = userUrl }
}
};
if (includeTracksAlbum)
artist.albums.Add(BuildUserTracksAlbum(userUrl, username, images));
return artist;
}
static MusicAlbum BuildUserTracksAlbum(string userUrl, string username, List<MusicImage> images)
{
return new MusicAlbum
{
id = BuildUserTracksAlbumId(userUrl),
title = "Треки пользователя",
artist_id = BuildUserArtistId(userUrl),
artist_name = string.IsNullOrWhiteSpace(username) ? "SoundCloud" : username,
type = "Playlist",
description = "SoundCloud",
images = images ?? new List<MusicImage>(),
provider_refs = new List<MusicProviderRef>
{
new() { provider = DiscoveryProviderId, external_id = userUrl }
}
};
}
static MusicTrack MapPlaylistTrack(JsonElement track, MusicAlbum album, int position, string fallbackArtwork, Dictionary<string, JsonElement> hydratedTracks = null)
{
string trackId = GetString(track, "id");
if ((string.IsNullOrWhiteSpace(GetString(track, "title")) || string.IsNullOrWhiteSpace(GetString(track, "artwork_url")))
&& !string.IsNullOrWhiteSpace(trackId)
&& hydratedTracks?.TryGetValue(trackId, out var hydratedTrack) == true)
{
track = hydratedTrack;
}
string rawTitle = GetString(track, "title");
var titleParts = SplitArtistTitleFromSoundCloudTitle(rawTitle);
string title = titleParts.title ?? NormalizeValue(rawTitle);
if (string.IsNullOrWhiteSpace(title))
return null;
var artists = ExtractCandidateArtists(track);
string artistName = artists.FirstOrDefault();
string artwork = UpgradeArtwork(GetString(track, "artwork_url")) ?? fallbackArtwork;
string permalinkUrl = GetString(track, "permalink_url");
string urn = GetString(track, "urn");
string externalId = !string.IsNullOrWhiteSpace(permalinkUrl)
? permalinkUrl
: (!string.IsNullOrWhiteSpace(urn) ? urn : title);
return new MusicTrack
{
id = !string.IsNullOrWhiteSpace(urn) ? urn : $"soundcloud:track:{Uri.EscapeDataString(externalId)}",
title = title.Trim(),
artist_name = string.IsNullOrWhiteSpace(artistName) ? "SoundCloud" : artistName,
artists = string.IsNullOrWhiteSpace(artistName) ? new List<string>() : new List<string> { artistName },
album_id = album?.id,
album_title = album?.title,
isrc = GetTrackIsrc(track),
duration_ms = GetInt(track, "full_duration") ?? GetInt(track, "duration"),
track_number = position,
date = GetString(track, "published_at"),
images = string.IsNullOrWhiteSpace(artwork)
? new List<MusicImage>()
: new List<MusicImage> { new() { url = artwork, width = 500, height = 500 } },
provider_refs = new List<MusicProviderRef>
{
new() { provider = DiscoveryProviderId, external_id = externalId }
}
};
}
static List<string> CollectTrackIdsForHydration(JsonElement playlist)
{
var ids = new List<string>();
if (!playlist.TryGetProperty("tracks", out var tracks) || tracks.ValueKind != JsonValueKind.Array)
return ids;
foreach (var track in tracks.EnumerateArray())
{
string title = GetString(track, "title");
string id = GetString(track, "id");
if (string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(id))
ids.Add(id);
}
return ids;
}
/// <summary>
/// Рекомендации SoundCloud по конкретному треку: top-1 поиска как сид →
/// /tracks/{id}/related («Related tracks» сайта, коллаборативная
/// фильтрация), при недоборе — track-station того же сида. Используется
/// радио как основной слой похожести (вместо «топ треков артиста»).
/// </summary>
public static Task<List<MusicTrack>> FindRelatedTracksByQueryAsync(string query, int limit = 12, CancellationToken cancellationToken = default)
=> FindRelatedTracksByQueryAsync(query, null, null, limit, cancellationToken);
public static async Task<List<MusicTrack>> FindRelatedTracksByQueryAsync(string query, string expectedArtist, string expectedTitle, int limit = 12, CancellationToken cancellationToken = default)
{
var tracks = new List<MusicTrack>();
if (string.IsNullOrWhiteSpace(query))
return tracks;
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return null;
var seedElements = await SearchTrackElementsAsync(query, clientId, 1, cancellationToken);
if (seedElements == null)
return null;
if (seedElements.Count == 0)
return tracks;
if (!IsRelevantRelatedSeed(seedElements[0], expectedArtist, expectedTitle))
return tracks;
if (!seedElements[0].TryGetProperty("id", out var idProperty) || idProperty.ValueKind != JsonValueKind.Number)
return tracks;
long seedId = idProperty.GetInt64();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
void AddAll(IEnumerable<JsonElement> items)
{
foreach (var item in items ?? Enumerable.Empty<JsonElement>())
{
var element = item;
// station-элементы бывают обёрнуты в { "track": {...} }
if (element.ValueKind == JsonValueKind.Object
&& element.TryGetProperty("track", out var inner)
&& inner.ValueKind == JsonValueKind.Object)
element = inner;
var track = MapStandaloneTrack(element);
if (track == null || string.IsNullOrWhiteSpace(track.id) || !seen.Add(track.id))
continue;
tracks.Add(track);
if (tracks.Count >= limit)
return;
}
}
var related = await SafeLoadCollectionElementsAsync($"tracks/{seedId}/related", clientId, limit, cancellationToken);
AddAll(related.items);
if (tracks.Count < limit)
{
var station = await SafeLoadCollectionElementsAsync($"stations/soundcloud:track-stations:{seedId}/tracks", clientId, limit, cancellationToken);
AddAll(station.items);
if (tracks.Count == 0 && !related.complete && !station.complete)
return null;
}
return tracks;
}
static bool IsRelevantRelatedSeed(JsonElement seedElement, string expectedArtist, string expectedTitle)
{
if (string.IsNullOrWhiteSpace(expectedTitle))
return true;
var seed = MapStandaloneTrack(seedElement);
if (seed == null)
return false;
string expectedTitleKey = NormalizeRelatedSeedText(expectedTitle);
string foundTitleKey = NormalizeRelatedSeedText(seed.title);
if (string.IsNullOrWhiteSpace(expectedTitleKey) || string.IsNullOrWhiteSpace(foundTitleKey))
return false;
int expectedTitleTokens = expectedTitleKey.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Length;
int sharedTitleTokens = CountSharedTokens(expectedTitleKey, foundTitleKey);
int requiredTitleTokens = expectedTitleTokens <= 1
? 1
: Math.Max(1, (int)Math.Ceiling(expectedTitleTokens * 0.6));
if (sharedTitleTokens < requiredTitleTokens)
return false;
string expectedArtistKey = NormalizeRelatedSeedText(expectedArtist);
if (string.IsNullOrWhiteSpace(expectedArtistKey))
return true;
string foundArtistKey = NormalizeRelatedSeedText(seed.artist_name);
string foundCombinedKey = NormalizeRelatedSeedText($"{seed.artist_name} {seed.title}");
if (CountSharedTokens(expectedArtistKey, foundArtistKey) > 0 || CountSharedTokens(expectedArtistKey, foundCombinedKey) > 0)
return true;
// SoundCloud uploads often use usernames instead of real artists. For
// non-generic titles, exact title coverage is enough to keep related.
return expectedTitleTokens > 1 && sharedTitleTokens >= expectedTitleTokens;
}
static string NormalizeRelatedSeedText(string value)
{
value = Regex.Replace(value ?? string.Empty, @"\([^)]*\)|\[[^\]]*\]", " ");
value = Regex.Replace(value, @"\b(feat|ft|featuring|official|audio|video|lyrics|lyric|clip|mv|hd|4k)\b", " ", RegexOptions.IgnoreCase);
return NormalizeSearchText(value);
}
public static async Task<List<MusicTrack>> SearchTracksByQueryAsync(string query, int limit = 10, CancellationToken cancellationToken = default)
{
var tracks = new List<MusicTrack>();
if (string.IsNullOrWhiteSpace(query))
return tracks;
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return null;
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var elements = await SearchTrackElementsAsync(query, clientId, limit, cancellationToken);
if (elements == null)
return null;
foreach (var item in elements)
{
var track = MapStandaloneTrack(item);
if (track == null || string.IsNullOrWhiteSpace(track.id) || !seen.Add(track.id))
continue;
tracks.Add(track);
}
return tracks;
}
// доминирующие жанры артиста по его трекам в поиске (сырые display-значения
// поля genre, по убыванию частоты) — для жанрового слоя «Миксов недели»
public static async Task<List<string>> SearchDominantGenresAsync(string query, CancellationToken cancellationToken = default)
{
var result = new List<string>();
if (string.IsNullOrWhiteSpace(query))
return result;
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return result;
var elements = await SearchTrackElementsAsync(query, clientId, 10, cancellationToken);
if (elements == null)
return result;
var counts = new Dictionary<string, (string display, int count)>(StringComparer.OrdinalIgnoreCase);
foreach (var item in elements)
{
string genre = GetString(item, "genre")?.Trim();
if (string.IsNullOrWhiteSpace(genre) || genre.Length > 40)
continue;
counts[genre] = counts.TryGetValue(genre, out var current)
? (current.display, current.count + 1)
: (genre, 1);
}
return counts.Values
.Where(i => i.count >= 2)
.OrderByDescending(i => i.count)
.Select(i => i.display)
.Take(3)
.ToList();
}
// свободный genre-тег трека -> канонический жанр-фасет SC (фильтр
// поиска регистрозависим и принимает только канон в lower;
// проверено живьём: «Hip Hop» — 0 результатов, «hip-hop & rap» — работает)
public static string MapToCanonicalGenre(string genre)
{
string value = (genre ?? string.Empty).ToLowerInvariant();
if (string.IsNullOrWhiteSpace(value))
return null;
if (value.Contains("hip") || value.Contains("rap") || value.Contains("trap") || value.Contains("phonk"))
return "hip-hop & rap";
if (value.Contains("edm") || value.Contains("dance"))
return "dance & edm";
if (value.Contains("r&b") || value.Contains("rnb") || value.Contains("soul"))
return "r&b & soul";
if (value.Contains("drum") && value.Contains("bass"))
return "drum & bass";
if (value.Contains("dubstep"))
return "dubstep";
if (value.Contains("house"))
return "house";
if (value.Contains("techno"))
return "techno";
if (value.Contains("trance"))
return "trance";
if (value.Contains("electro"))
return "electronic";
if (value.Contains("metal"))
return "metal";
if (value.Contains("indie"))
return "indie";
if (value.Contains("rock"))
return "rock";
if (value.Contains("jazz") || value.Contains("blues"))
return "jazz & blues";
if (value.Contains("folk") || value.Contains("singer"))
return "folk & singer-songwriter";
if (value.Contains("reggaeton"))
return "reggaeton";
if (value.Contains("reggae"))
return "reggae";
if (value.Contains("dancehall"))
return "dancehall";
if (value.Contains("latin"))
return "latin";
if (value.Contains("ambient"))
return "ambient";
if (value.Contains("classic"))
return "classical";
if (value.Contains("country"))
return "country";
if (value.Contains("pop"))
return "pop";
return null;
}
// популярные треки канонического жанра: штатный /charts SC закрыт
// (400/404, проверено живьём 2026-07-16), рабочий путь — search/tracks
// с filter.genre_or_tag; параметр q обязателен, даже пустой
public static async Task<List<MusicTrack>> SearchTracksByGenreAsync(string canonicalGenre, int limit = 30, CancellationToken cancellationToken = default)
{
var tracks = new List<MusicTrack>();
if (string.IsNullOrWhiteSpace(canonicalGenre))
return tracks;
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return null;
try
{
string url = BuildApiV2Url("search/tracks", new Dictionary<string, string>
{
["filter.genre_or_tag"] = canonicalGenre.Trim().ToLowerInvariant(),
["sort"] = "popular",
["client_id"] = clientId,
["limit"] = Math.Max(1, limit).ToString(),
["linked_partitioning"] = "1"
});
// BuildApiV2Url выбрасывает пустые значения, а без q эндпоинт
// отвечает 400 — дописываем пустой q вручную
url += "&q=";
using var request = new HttpRequestMessage(HttpMethod.Get, url);
foreach (var header in CreatePublicHeaders())
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
using var response = await httpClient.SendAsync(request, cancellationToken);
if (!response.IsSuccessStatusCode)
{
InvalidatePublicClientIdOnUnauthorized(response.StatusCode, clientId);
return null;
}
string json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
if (!document.RootElement.TryGetProperty("collection", out var collection) || collection.ValueKind != JsonValueKind.Array)
return null;
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var item in collection.EnumerateArray())
{
var track = MapStandaloneTrack(item);
if (track == null || string.IsNullOrWhiteSpace(track.id) || !seen.Add(track.id))
continue;
tracks.Add(track);
}
return tracks;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return null;
}
}
public static async Task<List<MusicAlbum>> SearchPlaylistsByQueryAsync(string query, int limit = 8, CancellationToken cancellationToken = default)
{
var playlists = new List<MusicAlbum>();
if (string.IsNullOrWhiteSpace(query))
return playlists;
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return null;
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var elements = await SearchElementsAsync("search/playlists", query, clientId, limit, cancellationToken);
if (elements == null)
return null;
foreach (var item in elements)
{
if (IsSoundCloudAlbumSet(item))
continue;
var playlist = MapSearchPlaylist(item, "Playlist");
if (playlist == null || string.IsNullOrWhiteSpace(playlist.id) || !seen.Add(playlist.id))
continue;
playlists.Add(playlist);
}
return playlists;
}
public static async Task<List<MusicAlbum>> SearchAlbumsByQueryAsync(string query, int limit = 8, CancellationToken cancellationToken = default)
{
var albums = new List<MusicAlbum>();
if (string.IsNullOrWhiteSpace(query))
return albums;
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return null;
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var albumElements = await SearchElementsAsync("search/albums", query, clientId, limit, cancellationToken);
if (albumElements == null)
return null;
foreach (var item in albumElements)
{
var album = MapSearchPlaylist(item, "Album");
if (album == null || string.IsNullOrWhiteSpace(album.id) || !seen.Add(album.id))
continue;
albums.Add(album);
}
if (albums.Count >= Math.Max(1, limit))
return albums;
var playlistElements = await SearchElementsAsync("search/playlists", query, clientId, limit * 2, cancellationToken);
if (playlistElements == null)
return albums.Count > 0 ? albums : null;
foreach (var item in playlistElements)
{
if (!IsSoundCloudAlbumSet(item))
continue;
var album = MapSearchPlaylist(item, "Album");
if (album == null || string.IsNullOrWhiteSpace(album.id) || !seen.Add(album.id))
continue;
albums.Add(album);
if (albums.Count >= Math.Max(1, limit))
break;
}
return albums;
}
public static async Task<List<MusicArtist>> SearchArtistsByQueryAsync(string query, int limit = 8, CancellationToken cancellationToken = default)
{
var artists = new List<MusicArtist>();
if (string.IsNullOrWhiteSpace(query))
return artists;
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return null;
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var elements = await SearchElementsAsync("search/users", query, clientId, limit, cancellationToken);
if (elements == null)
return null;
foreach (var item in elements)
{
var artist = MapUser(item, includeTracksAlbum: false);
if (artist == null || string.IsNullOrWhiteSpace(artist.id) || !seen.Add(artist.id))
continue;
artists.Add(artist);
}
return artists;
}
static string NormalizePlaylistTitle(string title)
{
string value = (title ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(value))
return "SoundCloud";
return value.Equals("All music genres", StringComparison.OrdinalIgnoreCase)
? "Top 50"
: value;
}
static string UpgradeArtwork(string url)
{
if (string.IsNullOrWhiteSpace(url))
return null;
return url
.Replace("-large.", "-t500x500.", StringComparison.OrdinalIgnoreCase)
.Replace("-t300x300.", "-t500x500.", StringComparison.OrdinalIgnoreCase);
}
static List<MusicImage> BuildImages(params string[] urls)
{
var images = new List<MusicImage>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var raw in urls ?? Array.Empty<string>())
{
string url = UpgradeArtwork(raw);
if (string.IsNullOrWhiteSpace(url) || !seen.Add(url))
continue;
images.Add(new MusicImage
{
url = url,
width = 500,
height = 500
});
}
return images;
}
static string SelectFirstImageUrl(List<MusicImage> images)
{
return images?.FirstOrDefault(i => !string.IsNullOrWhiteSpace(i?.url))?.url;
}
static string BuildTrackCacheKey(string id) => $"track:{chartsCacheVersion}:{id.Trim().ToLowerInvariant()}";
static MusicTrack MapStandaloneTrack(JsonElement track)
{
string rawTitle = GetString(track, "title");
var titleParts = SplitArtistTitleFromSoundCloudTitle(rawTitle);
string title = titleParts.title ?? NormalizeValue(rawTitle);
if (string.IsNullOrWhiteSpace(title))
return null;
var artists = ExtractCandidateArtists(track);
string artistName = artists.FirstOrDefault();
string artwork = UpgradeArtwork(GetString(track, "artwork_url"));
string permalinkUrl = GetString(track, "permalink_url");
string urn = GetString(track, "urn");
string externalId = !string.IsNullOrWhiteSpace(permalinkUrl)
? permalinkUrl
: (!string.IsNullOrWhiteSpace(urn) ? urn : title);
return new MusicTrack
{
id = !string.IsNullOrWhiteSpace(urn) ? urn : $"soundcloud:track:{Uri.EscapeDataString(externalId)}",
title = title.Trim(),
artist_name = string.IsNullOrWhiteSpace(artistName) ? "SoundCloud" : artistName,
artists = string.IsNullOrWhiteSpace(artistName) ? new List<string>() : new List<string> { artistName },
isrc = GetTrackIsrc(track),
duration_ms = GetInt(track, "full_duration") ?? GetInt(track, "duration"),
date = GetString(track, "published_at"),
images = string.IsNullOrWhiteSpace(artwork)
? new List<MusicImage>()
: new List<MusicImage> { new() { url = artwork, width = 500, height = 500 } },
provider_refs = new List<MusicProviderRef>
{
new() { provider = DiscoveryProviderId, external_id = externalId }
}
};
}
}