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

663 lines
26 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.Text.Json;
using System.Text.RegularExpressions;
namespace Music;
// Транспорт и общая инфраструктура SoundCloud: client_id (конфиг или скрап с кэшем),
// построение URL/заголовков, resolve, постраничные коллекции api-v2, JSON-хелперы.
// Остальные части класса: .Catalog.cs (discovery), .Audio.cs (матчинг/стримы),
// .Import.cs (импорт плейлистов/лайков), .Auth.cs (OAuth credentials).
public static partial class SoundCloudSupport
{
public const string AuthProviderId = "soundcloud";
public const string AudioProviderId = "soundcloudaudio";
public const string DiscoveryProviderId = "soundcloudcharts";
public const string ApiBaseUrl = "https://api.soundcloud.com";
public const string ApiV2BaseUrl = "https://api-v2.soundcloud.com";
public const string OAuthTokenUrl = "https://secure.soundcloud.com/oauth/token";
public const string ChartsSectionId = "browse:soundcloud";
public const string SearchSectionId = "search:soundcloud";
public const string SearchTracksSectionId = "search:soundcloud:tracks";
public const string SearchAlbumsSectionId = "search:soundcloud:albums";
public const string SearchPlaylistsSectionId = "search:soundcloud:playlists";
public const string SearchArtistsSectionId = "search:soundcloud:artists";
public const string PlaylistIdPrefix = "soundcloud:playlist:";
public const string UserIdPrefix = "soundcloud:user:";
public const string UserTracksAlbumPrefix = "soundcloud:usertracks:";
public const string UserSectionPrefix = "soundcloud:usersection:";
static readonly HttpClient httpClient = MusicHttp.CreateClient("soundcloud");
static readonly Regex apiClientIdRegex = new("\"apiClient\".*?\"id\":\"([^\"]+)\"", RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled);
static readonly Regex publicClientIdRegex = new("(?:client_id|clientId)\\s*[:=]\\s*[\"']?([A-Za-z0-9_-]{20,})", RegexOptions.IgnoreCase | RegexOptions.Compiled);
static readonly Regex publicAssetRegex = new("https://a-v2\\.sndcdn\\.com/assets/[^\"'<>\\s]+\\.js", RegexOptions.IgnoreCase | RegexOptions.Compiled);
static readonly Regex titleArtistSeparatorRegex = new(@"^\s*(?<artist>.+?)\s+(?:-|–|—|:)\s+(?<title>.+?)\s*$", RegexOptions.Compiled);
static readonly TimeSpan chartsCacheTtl = TimeSpan.FromHours(1);
static readonly TimeSpan playlistCacheTtl = TimeSpan.FromMinutes(30);
static readonly TimeSpan trackCacheTtl = TimeSpan.FromMinutes(30);
static readonly TimeSpan preferredSourceCacheTtl = TimeSpan.FromMinutes(20);
static readonly TimeSpan publicClientIdCacheTtl = TimeSpan.FromHours(6);
static readonly SemaphoreSlim publicClientIdLock = new(1, 1);
static string publicClientId;
static DateTime publicClientIdExpiresAt;
const string chartsCacheVersion = "v9";
static SoundCloudSupport()
{
httpClient.Timeout = TimeSpan.FromSeconds(20);
}
public static HttpClient HttpClient => httpClient;
public static bool IsModuleEnabled => ModInit.conf?.soundcloud_enabled == true;
public static bool IsDiscoveryEnabled => IsModuleEnabled && ModInit.conf?.soundcloud_discovery_enabled == true;
public static bool IsAudioEnabled => IsModuleEnabled && ModInit.conf?.soundcloud_audio_enabled == true;
public static bool IsAuthEnabled => IsModuleEnabled && ModInit.conf?.soundcloud_auth_enabled == true && HasOAuthConfig();
public static bool HasClientId() => !string.IsNullOrWhiteSpace(ModInit.conf?.soundcloud_client_id);
public static bool HasClientCredentials() => HasClientId() && !string.IsNullOrWhiteSpace(ModInit.conf?.soundcloud_client_secret);
public static bool HasOAuthConfig() => HasClientCredentials() && !string.IsNullOrWhiteSpace(ModInit.conf?.soundcloud_redirect_uri);
public static string Country => NormalizeCountry(ModInit.conf?.soundcloud_country);
public static string NormalizeCountry(string country)
{
country = country?.Trim().ToUpperInvariant();
return country is "GB" or "UK" ? "UK" : "US";
}
public static string BuildApiUrl(string path, IDictionary<string, string> query = null)
{
var baseUrl = $"{ApiBaseUrl.TrimEnd('/')}/{path?.TrimStart('/')}";
if (query == null || query.Count == 0)
return baseUrl;
var items = query
.Where(i => !string.IsNullOrWhiteSpace(i.Key) && !string.IsNullOrWhiteSpace(i.Value))
.Select(i => $"{Uri.EscapeDataString(i.Key)}={Uri.EscapeDataString(i.Value)}")
.ToList();
return items.Count == 0 ? baseUrl : $"{baseUrl}?{string.Join("&", items)}";
}
public static string BuildApiV2Url(string path, IDictionary<string, string> query = null)
{
var baseUrl = $"{ApiV2BaseUrl.TrimEnd('/')}/{path?.TrimStart('/')}";
if (query == null || query.Count == 0)
return baseUrl;
var items = query
.Where(i => !string.IsNullOrWhiteSpace(i.Key) && !string.IsNullOrWhiteSpace(i.Value))
.Select(i => $"{Uri.EscapeDataString(i.Key)}={Uri.EscapeDataString(i.Value)}")
.ToList();
return items.Count == 0 ? baseUrl : $"{baseUrl}?{string.Join("&", items)}";
}
public static string BuildChartsUrl(bool trending, string genre = "all-music")
{
string route = trending ? "new" : "";
string url = $"https://soundcloud.com/charts/{route}".TrimEnd('/');
return $"{url}?country={Uri.EscapeDataString(Country)}&genre={Uri.EscapeDataString(string.IsNullOrWhiteSpace(genre) ? "all-music" : genre)}";
}
public static Dictionary<string, string> CreatePublicHeaders(string accessToken = null)
{
var headers = new Dictionary<string, string>
{
["Accept"] = "application/json; charset=utf-8",
["User-Agent"] = "LampacNextgenMusic/0.1 (https://github.com/lampac-nextgen/lampac)"
};
if (!string.IsNullOrWhiteSpace(accessToken))
headers["Authorization"] = $"OAuth {accessToken.Trim()}";
return headers;
}
public static async Task<string> GetClientIdAsync(CancellationToken cancellationToken = default)
{
if (!string.IsNullOrWhiteSpace(ModInit.conf?.soundcloud_client_id))
return ModInit.conf.soundcloud_client_id.Trim();
if (!string.IsNullOrWhiteSpace(publicClientId) && publicClientIdExpiresAt > DateTime.UtcNow)
return publicClientId;
await publicClientIdLock.WaitAsync(cancellationToken);
try
{
if (!string.IsNullOrWhiteSpace(publicClientId) && publicClientIdExpiresAt > DateTime.UtcNow)
return publicClientId;
string clientId = await FetchPublicClientIdAsync(cancellationToken);
if (!string.IsNullOrWhiteSpace(clientId))
{
publicClientId = clientId;
publicClientIdExpiresAt = DateTime.UtcNow.Add(publicClientIdCacheTtl);
}
return clientId;
}
finally
{
publicClientIdLock.Release();
}
}
static async Task<string> FetchPublicClientIdAsync(CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(HttpMethod.Get, "https://soundcloud.com/");
request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0");
request.Headers.TryAddWithoutValidation("Accept", "text/html,application/xhtml+xml");
using var response = await httpClient.SendAsync(request, cancellationToken);
string html = await response.Content.ReadAsStringAsync(cancellationToken);
string clientId = ExtractPublicClientId(html);
if (!string.IsNullOrWhiteSpace(clientId))
return clientId;
foreach (Match asset in publicAssetRegex.Matches(html ?? string.Empty).Take(12))
{
try
{
using var assetRequest = new HttpRequestMessage(HttpMethod.Get, asset.Value);
assetRequest.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0");
assetRequest.Headers.TryAddWithoutValidation("Accept", "application/javascript,*/*");
using var assetResponse = await httpClient.SendAsync(assetRequest, cancellationToken);
if (!assetResponse.IsSuccessStatusCode)
continue;
string script = await assetResponse.Content.ReadAsStringAsync(cancellationToken);
clientId = ExtractPublicClientId(script);
if (!string.IsNullOrWhiteSpace(clientId))
return clientId;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
// SoundCloud rotates asset bundles often; try the next one.
}
}
return null;
}
static string ExtractPublicClientId(string content)
{
if (string.IsNullOrWhiteSpace(content))
return null;
var legacy = apiClientIdRegex.Match(content);
if (legacy.Success)
return legacy.Groups[1].Value.Trim();
var current = publicClientIdRegex.Match(content);
return current.Success ? current.Groups[1].Value.Trim() : null;
}
static void InvalidatePublicClientIdOnUnauthorized(HttpStatusCode statusCode, string clientId)
{
if (statusCode != HttpStatusCode.Unauthorized || string.IsNullOrWhiteSpace(clientId))
return;
if (!string.Equals(publicClientId, clientId, StringComparison.Ordinal))
return;
publicClientId = null;
publicClientIdExpiresAt = DateTime.MinValue;
}
static async Task<JsonElement?> ResolveUrlElementAsync(string entityUrl, string clientId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(entityUrl) || string.IsNullOrWhiteSpace(clientId))
return null;
string url = BuildApiV2Url("resolve", new Dictionary<string, string>
{
["url"] = entityUrl,
["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);
return document.RootElement.Clone();
}
static async Task<JsonElement?> LoadPlaylistByIdAsync(string playlistId, string clientId, CancellationToken cancellationToken)
{
string url = BuildApiV2Url($"playlists/{playlistId}", new Dictionary<string, string>
{
["representation"] = "full",
["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);
return document.RootElement.Clone();
}
static async Task<SoundCloudTrackDetails> LoadTrackDetailsAsync(List<string> ids, string clientId, CancellationToken cancellationToken)
{
var result = new SoundCloudTrackDetails();
if (ids == null || ids.Count == 0 || string.IsNullOrWhiteSpace(clientId))
return result;
foreach (var chunk in ids.Distinct(StringComparer.OrdinalIgnoreCase).Chunk(50))
{
string url = BuildApiV2Url("tracks", new Dictionary<string, string>
{
["ids"] = string.Join(",", chunk),
["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);
result.complete = false;
continue;
}
string json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
if (document.RootElement.ValueKind != JsonValueKind.Array)
{
result.complete = false;
continue;
}
foreach (var track in document.RootElement.EnumerateArray())
{
string id = GetString(track, "id");
if (!string.IsNullOrWhiteSpace(id))
result.tracks[id] = track.Clone();
}
}
return result;
}
static async Task<JsonElement?> LoadTrackElementAsync(string trackId, string clientId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(trackId) || string.IsNullOrWhiteSpace(clientId))
return null;
string url = BuildApiV2Url($"tracks/{trackId}", 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);
return document.RootElement.Clone();
}
sealed class SoundCloudCollectionPage
{
public List<JsonElement> items { get; set; } = new();
public bool complete { get; set; } = true;
public bool hasMore { get; set; }
public string nextPage { get; set; }
public bool truncated { get; set; }
public static SoundCloudCollectionPage Empty() => new();
}
sealed class SoundCloudTrackDetails
{
public Dictionary<string, JsonElement> tracks { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public bool complete { get; set; } = true;
}
static async Task<SoundCloudCollectionPage> SafeLoadCollectionElementsAsync(
string path,
string clientId,
int limit,
CancellationToken cancellationToken,
IDictionary<string, string> query = null,
string pageUrl = null)
{
try
{
return await LoadCollectionElementsAsync(path, clientId, limit, cancellationToken, query, pageUrl);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return new SoundCloudCollectionPage { complete = false };
}
}
static async Task<SoundCloudCollectionPage> LoadCollectionElementsAsync(
string path,
string clientId,
int limit,
CancellationToken cancellationToken,
IDictionary<string, string> query = null,
string pageUrl = null)
{
var results = new List<JsonElement>();
var result = new SoundCloudCollectionPage { items = results };
if ((string.IsNullOrWhiteSpace(path) && string.IsNullOrWhiteSpace(pageUrl)) || string.IsNullOrWhiteSpace(clientId))
return result;
int target = Math.Max(1, limit);
var queryParams = query == null
? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, string>(query, StringComparer.OrdinalIgnoreCase);
string url;
if (!string.IsNullOrWhiteSpace(pageUrl))
{
url = EnsureSoundCloudCollectionUrl(pageUrl, clientId);
}
else
{
queryParams["client_id"] = clientId;
queryParams["limit"] = target.ToString();
queryParams["linked_partitioning"] = "1";
url = BuildApiV2Url(path, queryParams);
}
for (int page = 0; page < 8 && !string.IsNullOrWhiteSpace(url) && results.Count < target; page++)
{
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);
result.complete = false;
result.hasMore = true;
result.nextPage = CleanSoundCloudPageUrl(url);
return result;
}
string json = await response.Content.ReadAsStringAsync(cancellationToken);
using var document = JsonDocument.Parse(json);
if (document.RootElement.ValueKind == JsonValueKind.Array)
{
foreach (var item in document.RootElement.EnumerateArray())
{
if (results.Count >= target)
break;
results.Add(item.Clone());
}
result.hasMore = false;
result.nextPage = null;
break;
}
if (!document.RootElement.TryGetProperty("collection", out var collection) || collection.ValueKind != JsonValueKind.Array)
{
result.complete = false;
break;
}
int beforeCount = results.Count;
foreach (var item in collection.EnumerateArray())
{
if (results.Count >= target)
break;
results.Add(item.Clone());
}
string nextUrl = GetString(document.RootElement, "next_href");
result.hasMore = !string.IsNullOrWhiteSpace(nextUrl) && results.Count > beforeCount;
result.nextPage = result.hasMore ? CleanSoundCloudPageUrl(nextUrl) : null;
result.truncated = result.hasMore && results.Count >= target;
url = result.hasMore && results.Count < target
? EnsureSoundCloudCollectionUrl(nextUrl, clientId)
: null;
}
if (!string.IsNullOrWhiteSpace(url) && results.Count < target)
{
result.complete = false;
result.hasMore = true;
result.nextPage = CleanSoundCloudPageUrl(url);
}
if (result.hasMore && results.Count >= target)
result.truncated = true;
return result;
}
static async Task<List<JsonElement>> LoadUserTrackElementsAsync(string userId, string clientId, int limit, CancellationToken cancellationToken)
{
var page = await LoadCollectionElementsAsync($"users/{userId}/tracks", clientId, limit, cancellationToken);
return page.items;
}
static string EnsureSoundCloudCollectionUrl(string url, string clientId)
{
if (string.IsNullOrWhiteSpace(url))
return url;
if (!url.StartsWith(ApiV2BaseUrl, StringComparison.OrdinalIgnoreCase))
return null;
url = AddQueryParameterIfMissing(url, "client_id", clientId);
return AddQueryParameterIfMissing(url, "linked_partitioning", "1");
}
static string CleanSoundCloudPageUrl(string url)
{
if (string.IsNullOrWhiteSpace(url))
return null;
if (!url.StartsWith(ApiV2BaseUrl, StringComparison.OrdinalIgnoreCase))
return null;
int queryIndex = url.IndexOf('?');
if (queryIndex < 0)
return url;
string path = url.Substring(0, queryIndex);
var query = url.Substring(queryIndex + 1)
.Split('&', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(part => !part.StartsWith("client_id=", StringComparison.OrdinalIgnoreCase))
.ToList();
return query.Count == 0 ? path : $"{path}?{string.Join("&", query)}";
}
static string AddQueryParameterIfMissing(string url, string key, string value)
{
if (string.IsNullOrWhiteSpace(url) || string.IsNullOrWhiteSpace(key) || string.IsNullOrWhiteSpace(value))
return url;
if (Regex.IsMatch(url, $@"[?&]{Regex.Escape(key)}=", RegexOptions.IgnoreCase))
return url;
char separator = url.Contains('?') ? '&' : '?';
return $"{url}{separator}{Uri.EscapeDataString(key)}={Uri.EscapeDataString(value)}";
}
static async Task<List<JsonElement>> SearchTrackElementsAsync(string query, string clientId, int limit, CancellationToken cancellationToken)
{
return await SearchElementsAsync("search/tracks", query, clientId, limit, cancellationToken);
}
static async Task<List<JsonElement>> SearchElementsAsync(string path, string query, string clientId, int limit, CancellationToken cancellationToken)
{
var results = new List<JsonElement>();
if (string.IsNullOrWhiteSpace(path) || string.IsNullOrWhiteSpace(query) || string.IsNullOrWhiteSpace(clientId))
return results;
try
{
string url = BuildApiV2Url(path, new Dictionary<string, string>
{
["q"] = query.Trim(),
["client_id"] = clientId,
["limit"] = Math.Max(1, limit).ToString(),
["linked_partitioning"] = "1"
});
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;
foreach (var item in collection.EnumerateArray())
results.Add(item.Clone());
return results;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return null;
}
}
static (string artist, string title) SplitArtistTitleFromSoundCloudTitle(string value)
{
string normalized = NormalizeValue(value);
if (string.IsNullOrWhiteSpace(normalized))
return (null, null);
var match = titleArtistSeparatorRegex.Match(normalized);
if (!match.Success)
return (null, normalized);
string artist = NormalizeValue(match.Groups["artist"].Value);
string title = NormalizeValue(match.Groups["title"].Value);
if (string.IsNullOrWhiteSpace(artist) || string.IsNullOrWhiteSpace(title))
return (null, normalized);
return (artist, title);
}
static string ParseTrackApiId(string id)
{
if (string.IsNullOrWhiteSpace(id))
return null;
var match = Regex.Match(id.Trim(), @"soundcloud:tracks?:(\d+)$", RegexOptions.IgnoreCase);
return match.Success ? match.Groups[1].Value : null;
}
static string GetString(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var value))
return null;
return value.ValueKind switch
{
JsonValueKind.String => value.GetString(),
JsonValueKind.Number => value.ToString(),
JsonValueKind.True => bool.TrueString,
JsonValueKind.False => bool.FalseString,
_ => null
};
}
static int? GetInt(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var value))
return null;
if (value.ValueKind == JsonValueKind.Number)
{
if (value.TryGetInt32(out var intValue))
return intValue;
if (value.TryGetInt64(out var longValue) && longValue is <= int.MaxValue and >= int.MinValue)
return (int)longValue;
}
if (value.ValueKind == JsonValueKind.String && int.TryParse(value.GetString(), out var parsed))
return parsed;
return null;
}
static string GetTrackIsrc(JsonElement track)
{
if (!track.TryGetProperty("publisher_metadata", out var publisher) || publisher.ValueKind != JsonValueKind.Object)
return null;
return MusicIsrc.Normalize(GetString(publisher, "isrc"));
}
static int GetArrayLength(JsonElement element, string propertyName)
{
if (!element.TryGetProperty(propertyName, out var value) || value.ValueKind != JsonValueKind.Array)
return 0;
return value.GetArrayLength();
}
static string NormalizeValue(string value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}