Исправление: том ./mods:/lampac/mods в compose и копии 10 модулей с enable:true; конфиг без SkipModules

This commit is contained in:
2026-10-02 12:11:49 +03:00
parent 8dcbc6bc52
commit 7b8e28b3bf
151 changed files with 68625 additions and 44 deletions
@@ -0,0 +1,108 @@
namespace Music;
public class SoundCloudCredentials
{
public string access_token { get; set; }
public string refresh_token { get; set; }
public string token_type { get; set; }
public string scope { get; set; }
public string username { get; set; }
public string user_urn { get; set; }
public long? expires_at_unix { get; set; }
}
public class SoundCloudTokenResponse
{
public string access_token { get; set; }
public string refresh_token { get; set; }
public string token_type { get; set; }
public string scope { get; set; }
public int? expires_in { get; set; }
}
public class SoundCloudCollectionDto<T>
{
public List<T> collection { get; set; } = new();
public string next_href { get; set; }
public string query_urn { get; set; }
}
public class SoundCloudNestedCollectionDto<T>
{
public List<T> collection { get; set; } = new();
}
public class SoundCloudChartSelectionDto
{
public string urn { get; set; }
public string title { get; set; }
public SoundCloudNestedCollectionDto<SoundCloudPlaylistDto> items { get; set; }
}
public class SoundCloudUserDto
{
public string urn { get; set; }
public string username { get; set; }
public string avatar_url { get; set; }
public string permalink_url { get; set; }
}
public class SoundCloudTrackDto
{
public string urn { get; set; }
public string title { get; set; }
public string artwork_url { get; set; }
public string permalink_url { get; set; }
public string stream_url { get; set; }
public string access { get; set; }
public int? duration { get; set; }
public int? full_duration { get; set; }
public string published_at { get; set; }
public SoundCloudUserDto user { get; set; }
public SoundCloudMediaDto media { get; set; }
}
public class SoundCloudPlaylistDto
{
public string urn { get; set; }
public string title { get; set; }
public string artwork_url { get; set; }
public string permalink_url { get; set; }
public int? track_count { get; set; }
public string published_at { get; set; }
public string display_date { get; set; }
public int? duration { get; set; }
public string genre { get; set; }
public SoundCloudUserDto user { get; set; }
public List<SoundCloudTrackDto> tracks { get; set; } = new();
}
public class SoundCloudMediaDto
{
public List<SoundCloudTranscodingDto> transcodings { get; set; } = new();
}
public class SoundCloudTranscodingDto
{
public string url { get; set; }
public string preset { get; set; }
public bool? snipped { get; set; }
public SoundCloudFormatDto format { get; set; }
}
public class SoundCloudFormatDto
{
public string protocol { get; set; }
public string mime_type { get; set; }
}
public class SoundCloudMatchPayload
{
public string track_id { get; set; }
public string urn { get; set; }
public string permalink_url { get; set; }
public string title { get; set; }
public string artist_name { get; set; }
public int? duration_ms { get; set; }
public string track_authorization { get; set; }
}
@@ -0,0 +1,748 @@
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Music;
// Audio capability: поиск и ранжирование матчей под трек, скоринг,
// выбор transcoding и построение stream-источников.
public static partial class SoundCloudSupport
{
public static async Task<IReadOnlyList<MusicAudioMatch>> SearchAudioAsync(MusicTrack track, CancellationToken cancellationToken = default)
{
if (track == null)
return Array.Empty<MusicAudioMatch>();
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return Array.Empty<MusicAudioMatch>();
var results = new List<JsonElement>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
string exactTrackId = ParseTrackApiId(track.id);
if (!string.IsNullOrWhiteSpace(exactTrackId))
{
var exact = await LoadTrackElementAsync(exactTrackId, clientId, cancellationToken);
if (exact.HasValue && IsPlayableAudioTrack(exact.Value))
{
var exactMatch = MapAudioMatch(exact.Value);
if (exactMatch != null)
return new[] { exactMatch };
}
return Array.Empty<MusicAudioMatch>();
}
foreach (string query in BuildAudioQueries(track))
{
var elements = await SearchTrackElementsAsync(query, clientId, 10, cancellationToken);
if (elements == null)
continue;
foreach (var item in elements)
{
string id = GetString(item, "id");
if (string.IsNullOrWhiteSpace(id) || !seen.Add(id) || !IsPlayableAudioTrack(item))
continue;
results.Add(item);
if (results.Count >= 16)
break;
}
if (results.Count >= 16)
break;
}
return RankAudioMatches(track, results);
}
public static async Task<IReadOnlyList<MusicPlaybackSource>> BuildAudioSourcesAsync(MusicAudioMatch match, CancellationToken cancellationToken = default)
{
if (match == null)
return Array.Empty<MusicPlaybackSource>();
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return Array.Empty<MusicPlaybackSource>();
var payload = ParseMatchPayload(match);
string trackId = NormalizeValue(payload?.track_id) ?? ParseTrackApiId(match.id);
if (string.IsNullOrWhiteSpace(trackId))
return Array.Empty<MusicPlaybackSource>();
var track = await LoadTrackElementAsync(trackId, clientId, cancellationToken);
if (!track.HasValue || !track.Value.TryGetProperty("media", out var media) || media.ValueKind != JsonValueKind.Object)
return Array.Empty<MusicPlaybackSource>();
if (!media.TryGetProperty("transcodings", out var transcodings) || transcodings.ValueKind != JsonValueKind.Array)
return Array.Empty<MusicPlaybackSource>();
var sources = new List<MusicPlaybackSource>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var transcoding in OrderPlayableTranscodings(transcodings))
{
string streamUrl = await ResolveTranscodingUrlAsync(transcoding, clientId, payload?.track_authorization, cancellationToken);
if (string.IsNullOrWhiteSpace(streamUrl) || IsPreviewStreamUrl(streamUrl) || !seen.Add(streamUrl))
continue;
string mime = GetFormatMime(transcoding);
string protocol = GetFormatProtocol(transcoding);
sources.Add(new MusicPlaybackSource
{
provider_id = AudioProviderId,
url = streamUrl,
mime_type = NormalizeSourceMime(protocol, mime, streamUrl),
quality = BuildQualityLabel(protocol, mime),
bitrate = EstimateBitrate(streamUrl, mime)
});
}
return sources;
}
public static async Task<MusicPlaybackSource> BuildPreferredAudioSourceAsync(MusicAudioMatch match, CancellationToken cancellationToken = default)
{
if (match == null)
return null;
string cacheKey = BuildPreferredSourceCacheKey(match);
if (!string.IsNullOrWhiteSpace(cacheKey))
{
var cached = await MusicMetadataCacheService.GetAsync<MusicPlaybackSource>(AudioProviderId, "preferred_source", cacheKey, cancellationToken);
if (cached != null && !string.IsNullOrWhiteSpace(cached.url))
return cached;
}
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return null;
var payload = ParseMatchPayload(match);
string trackId = NormalizeValue(payload?.track_id) ?? ParseTrackApiId(match.id);
if (string.IsNullOrWhiteSpace(trackId))
return null;
var track = await LoadTrackElementAsync(trackId, clientId, cancellationToken);
if (!track.HasValue || !track.Value.TryGetProperty("media", out var media) || media.ValueKind != JsonValueKind.Object)
return null;
if (!media.TryGetProperty("transcodings", out var transcodings) || transcodings.ValueKind != JsonValueKind.Array)
return null;
foreach (var transcoding in OrderPlayableTranscodings(transcodings))
{
string streamUrl = await ResolveTranscodingUrlAsync(transcoding, clientId, payload?.track_authorization, cancellationToken);
if (string.IsNullOrWhiteSpace(streamUrl) || IsPreviewStreamUrl(streamUrl))
continue;
string mime = GetFormatMime(transcoding);
string protocol = GetFormatProtocol(transcoding);
var source = new MusicPlaybackSource
{
provider_id = AudioProviderId,
url = streamUrl,
mime_type = NormalizeSourceMime(protocol, mime, streamUrl),
quality = BuildQualityLabel(protocol, mime),
bitrate = EstimateBitrate(streamUrl, mime)
};
if (!string.IsNullOrWhiteSpace(cacheKey))
await MusicMetadataCacheService.SaveAsync(AudioProviderId, "preferred_source", cacheKey, source, preferredSourceCacheTtl, cancellationToken);
return source;
}
return null;
}
public static async Task<MusicAudioMatch> GetExactAudioMatchAsync(MusicTrack track, CancellationToken cancellationToken = default)
{
if (track == null)
return null;
string exactTrackId = ParseTrackApiId(track.id);
if (string.IsNullOrWhiteSpace(exactTrackId))
return null;
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return null;
var exact = await LoadTrackElementAsync(exactTrackId, clientId, cancellationToken);
if (!exact.HasValue || !IsPlayableAudioTrack(exact.Value))
return null;
return MapAudioMatch(exact.Value);
}
static bool IsPreviewStreamUrl(string streamUrl)
{
streamUrl = NormalizeValue(streamUrl);
if (string.IsNullOrWhiteSpace(streamUrl))
return false;
if (streamUrl.Contains("cf-preview-media.sndcdn.com", StringComparison.OrdinalIgnoreCase))
return true;
if (streamUrl.Contains("/preview/", StringComparison.OrdinalIgnoreCase))
return true;
if (streamUrl.Contains("cf-hls-media.sndcdn.com/playlist/0/30/", StringComparison.OrdinalIgnoreCase))
return true;
if (streamUrl.Contains("cf-hls-media.sndcdn.com/media/0/30/", StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
public static bool IsRelevantAudioMatch(MusicTrack track, MusicAudioMatch match)
{
if (track == null || match == null)
return false;
var payload = ParseMatchPayload(match);
string expectedTrackId = ParseTrackApiId(track.id);
string actualTrackId = NormalizeValue(payload?.track_id) ?? ParseTrackApiId(match.id);
if (!string.IsNullOrWhiteSpace(expectedTrackId))
return string.Equals(expectedTrackId, actualTrackId, StringComparison.OrdinalIgnoreCase);
return ScoreAudioMatch(track, match) >= 40;
}
public static bool HasExactTrackId(MusicTrack track)
=> !string.IsNullOrWhiteSpace(ParseTrackApiId(track?.id));
static List<string> BuildAudioQueries(MusicTrack track)
{
var queries = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
void Add(string value)
{
string normalized = NormalizeValue(value);
if (!string.IsNullOrWhiteSpace(normalized) && seen.Add(normalized))
queries.Add(normalized);
}
string title = NormalizeValue(track?.title);
string primaryArtist = NormalizeValue(track?.artist_name);
string allArtists = track?.artists == null ? null : string.Join(", ", track.artists.Where(a => !string.IsNullOrWhiteSpace(a)).Distinct(StringComparer.OrdinalIgnoreCase));
Add($"{primaryArtist} {title}");
Add($"{allArtists} {title}");
Add(title);
return queries;
}
static IReadOnlyList<MusicAudioMatch> RankAudioMatches(MusicTrack expectedTrack, IEnumerable<JsonElement> candidates)
{
var ranked = new List<(MusicAudioMatch match, int score)>();
foreach (var candidate in candidates)
{
var match = MapAudioMatch(candidate);
if (match == null)
continue;
int score = ScoreAudioMatch(expectedTrack, match);
if (score < 40)
continue;
match.duration_ms ??= GetInt(candidate, "full_duration") ?? GetInt(candidate, "duration");
ranked.Add((match, score));
}
return ranked
.OrderByDescending(x => x.score)
.ThenBy(x => Math.Abs((x.match.duration_ms ?? 0) - (expectedTrack?.duration_ms ?? 0)))
.ThenBy(x => x.match.title, StringComparer.OrdinalIgnoreCase)
.Select(x => x.match)
.ToList();
}
static MusicAudioMatch MapAudioMatch(JsonElement track)
{
string trackId = GetString(track, "id");
string rawTitle = NormalizeValue(GetString(track, "title"));
var titleParts = SplitArtistTitleFromSoundCloudTitle(rawTitle);
string title = NormalizeValue(titleParts.title) ?? rawTitle;
if (string.IsNullOrWhiteSpace(trackId) || string.IsNullOrWhiteSpace(title))
return null;
string artistName = null;
string permalinkUrl = GetString(track, "permalink_url");
string urn = GetString(track, "urn");
var artists = ExtractCandidateArtists(track);
artistName = artists.FirstOrDefault();
var payload = new SoundCloudMatchPayload
{
track_id = trackId,
urn = urn,
permalink_url = permalinkUrl,
title = title,
artist_name = artistName,
duration_ms = GetInt(track, "full_duration") ?? GetInt(track, "duration"),
track_authorization = NormalizeValue(GetString(track, "track_authorization"))
};
return new MusicAudioMatch
{
provider_id = AudioProviderId,
id = !string.IsNullOrWhiteSpace(urn) ? urn : $"soundcloud:tracks:{trackId}",
title = title,
artists = artists,
isrc = GetTrackIsrc(track),
duration_ms = payload.duration_ms,
payload = MusicJson.Serialize(payload)
};
}
static int ScoreAudioMatch(MusicTrack expectedTrack, MusicAudioMatch candidate)
{
if (expectedTrack == null || candidate == null)
return 0;
string expectedTitle = NormalizeSearchText(expectedTrack.title);
string candidateTitle = NormalizeSearchText(candidate.title);
string expectedArtist = NormalizeSearchText(expectedTrack.artist_name);
var expectedArtists = BuildArtistFragments(new[] { expectedTrack.artist_name }.Concat(expectedTrack.artists ?? Enumerable.Empty<string>()));
var candidateArtists = BuildArtistFragments(candidate.artists);
string candidateArtist = candidateArtists.FirstOrDefault() ?? NormalizeSearchText(candidate.artists.FirstOrDefault());
string expectedIsrc = MusicIsrc.Normalize(expectedTrack.isrc);
string candidateIsrc = MusicIsrc.Normalize(candidate.isrc);
int score = 0;
bool artistMatched = false;
bool durationMatched = false;
if (expectedIsrc != null && candidateIsrc != null)
{
if (!string.Equals(expectedIsrc, candidateIsrc, StringComparison.Ordinal))
return int.MinValue;
score += 120;
}
if (!string.IsNullOrWhiteSpace(expectedTitle) && !string.IsNullOrWhiteSpace(candidateTitle))
{
if (string.Equals(expectedTitle, candidateTitle, StringComparison.Ordinal))
score += 60;
else if (candidateTitle.Contains(expectedTitle, StringComparison.Ordinal) || expectedTitle.Contains(candidateTitle, StringComparison.Ordinal))
score += 35;
else
score += CountSharedTokens(expectedTitle, candidateTitle) * 8;
}
if (expectedArtists.Count > 0 && candidateArtists.Count > 0)
{
if (candidateArtists.Any(a => expectedArtists.Contains(a)))
{
score += 30;
artistMatched = true;
}
else if (candidateArtists.Any(a => expectedArtists.Any(e => a.Contains(e, StringComparison.Ordinal) || e.Contains(a, StringComparison.Ordinal))))
{
score += 16;
artistMatched = true;
}
else
{
int sharedArtistTokens = expectedArtists.Max(e => candidateArtists.Max(a => CountSharedTokens(e, a)));
score += sharedArtistTokens * 6;
artistMatched = sharedArtistTokens > 0;
}
}
else if (!string.IsNullOrWhiteSpace(expectedArtist) && !string.IsNullOrWhiteSpace(candidateArtist))
{
if (string.Equals(expectedArtist, candidateArtist, StringComparison.Ordinal))
{
score += 30;
artistMatched = true;
}
else if (candidateArtist.Contains(expectedArtist, StringComparison.Ordinal) || expectedArtist.Contains(candidateArtist, StringComparison.Ordinal))
{
score += 16;
artistMatched = true;
}
else
{
int sharedArtistTokens = CountSharedTokens(expectedArtist, candidateArtist);
score += sharedArtistTokens * 6;
artistMatched = sharedArtistTokens > 0;
}
}
if (!artistMatched && expectedArtists.Any(a => candidateTitle.Contains(a, StringComparison.Ordinal)))
{
score += 16;
artistMatched = true;
}
if (expectedArtists.Count > 1 && candidateArtists.Count > 0)
{
foreach (string artist in expectedArtists.Skip(1).Take(3))
{
if (candidateArtists.Any(a => a == artist || a.Contains(artist, StringComparison.Ordinal)))
score += 4;
}
}
int expectedDuration = expectedTrack.duration_ms ?? 0;
int candidateDuration = candidate.duration_ms ?? 0;
if (expectedDuration > 0 && candidateDuration > 0)
{
double durationRatio = candidateDuration / (double)expectedDuration;
if (durationRatio >= 2d || durationRatio <= 0.5d)
return int.MinValue;
int delta = Math.Abs(expectedDuration - candidateDuration);
if (delta <= 2_000)
{
score += 18;
durationMatched = true;
}
else if (delta <= 5_000)
{
score += 12;
durationMatched = true;
}
else if (delta <= 10_000)
{
score += 6;
durationMatched = true;
}
else if (durationRatio >= 1.5d || durationRatio <= 0.67d) score -= 54;
else if (durationRatio >= 1.2d || durationRatio <= 0.8d) score -= 24;
else if (delta >= 30_000) score -= 12;
}
if (expectedArtists.Count > 0 && !artistMatched && !durationMatched)
score -= 36;
score -= ComputeDescriptorPenalty(expectedTitle, candidateTitle);
return score;
}
static int ComputeDescriptorPenalty(string expectedText, string candidateText)
{
int penalty = 0;
foreach (var descriptor in hardDescriptorPenalties)
{
bool expectedHas = HasDescriptor(expectedText, descriptor.term);
bool candidateHas = HasDescriptor(candidateText, descriptor.term);
if (!expectedHas && candidateHas)
penalty += descriptor.penalty;
}
return penalty;
}
static readonly (string term, int penalty)[] hardDescriptorPenalties =
[
("fast", 42),
("sped", 42),
("sped up", 42),
("slowed", 42),
("slowed reverb", 54),
("nightcore", 42),
("mp3 wav", 54),
("mp3", 28),
("wav", 28),
("reverb", 18),
("cover", 28),
("karaoke", 28),
("instrumental", 28),
("snippet", 36),
("looped", 28),
("8d", 24),
("bass boosted", 24),
("live", 18),
("remix", 18),
("remastered", 12),
("1 hour", 72),
("hour version", 72),
("long version", 42),
("extended", 12),
("tiktok", 18)
];
static bool HasDescriptor(string text, string descriptor)
{
string normalizedText = NormalizeSearchText(text);
string normalizedDescriptor = NormalizeSearchText(descriptor);
if (string.IsNullOrWhiteSpace(normalizedText) || string.IsNullOrWhiteSpace(normalizedDescriptor))
return false;
if (normalizedDescriptor.Contains(' ', StringComparison.Ordinal))
return normalizedText.Contains(normalizedDescriptor, StringComparison.Ordinal);
var tokens = normalizedText.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return tokens.Contains(normalizedDescriptor, StringComparer.Ordinal);
}
static List<string> ExtractCandidateArtists(JsonElement track)
{
var artists = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
void AddMany(string value)
{
foreach (string artist in SplitArtistNames(value))
{
if (!string.IsNullOrWhiteSpace(artist) && seen.Add(artist))
artists.Add(artist);
}
}
if (track.TryGetProperty("publisher_metadata", out var publisher) && publisher.ValueKind == JsonValueKind.Object)
AddMany(GetString(publisher, "artist"));
var titleParts = SplitArtistTitleFromSoundCloudTitle(GetString(track, "title"));
AddMany(titleParts.artist);
if (track.TryGetProperty("user", out var user) && user.ValueKind == JsonValueKind.Object)
AddMany(GetString(user, "username"));
return artists;
}
static List<string> SplitArtistNames(string value)
{
if (string.IsNullOrWhiteSpace(value))
return new List<string>();
return value
.Split(new[] { ",", "&", " x ", " feat. ", " feat ", " ft. ", " ft ", " and " }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(i => NormalizeValue(i))
.Where(i => !string.IsNullOrWhiteSpace(i))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
static List<string> BuildArtistFragments(IEnumerable<string> artists)
{
var fragments = new List<string>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (string artist in artists ?? Enumerable.Empty<string>())
{
foreach (string part in SplitArtistNames(artist))
{
string normalized = NormalizeSearchText(part);
if (!string.IsNullOrWhiteSpace(normalized) && seen.Add(normalized))
fragments.Add(normalized);
}
}
return fragments;
}
static string NormalizeSearchText(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
var chars = value
.Trim()
.ToLowerInvariant()
.Select(c => char.IsLetterOrDigit(c) ? c : ' ')
.ToArray();
return string.Join(' ', new string(chars).Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries));
}
static int CountSharedTokens(string left, string right)
{
if (string.IsNullOrWhiteSpace(left) || string.IsNullOrWhiteSpace(right))
return 0;
var leftTokens = left.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
var rightSet = right.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToHashSet(StringComparer.Ordinal);
int shared = 0;
foreach (string token in leftTokens)
{
if (rightSet.Contains(token))
shared++;
}
return shared;
}
static bool IsPlayableAudioTrack(JsonElement track)
{
if (!track.TryGetProperty("media", out var media) || media.ValueKind != JsonValueKind.Object)
return false;
if (!media.TryGetProperty("transcodings", out var transcodings) || transcodings.ValueKind != JsonValueKind.Array)
return false;
return OrderPlayableTranscodings(transcodings).Any();
}
static IEnumerable<JsonElement> OrderPlayableTranscodings(JsonElement transcodings)
{
if (transcodings.ValueKind != JsonValueKind.Array)
return Array.Empty<JsonElement>();
return transcodings.EnumerateArray()
.Where(IsPlainPlayableTranscoding)
.Select(t => t.Clone())
.OrderByDescending(GetTranscodingPriority)
.ToList();
}
static bool IsPlainPlayableTranscoding(JsonElement transcoding)
{
string protocol = GetFormatProtocol(transcoding);
string mime = GetFormatMime(transcoding);
if (string.IsNullOrWhiteSpace(protocol) || protocol.Contains("encrypted", StringComparison.OrdinalIgnoreCase))
return false;
return protocol.Equals("progressive", StringComparison.OrdinalIgnoreCase)
|| protocol.Equals("hls", StringComparison.OrdinalIgnoreCase)
|| mime.Contains("mpeg", StringComparison.OrdinalIgnoreCase)
|| mime.Contains("mp4", StringComparison.OrdinalIgnoreCase);
}
static int GetTranscodingPriority(JsonElement transcoding)
{
string protocol = GetFormatProtocol(transcoding);
string mime = GetFormatMime(transcoding);
if (protocol.Equals("progressive", StringComparison.OrdinalIgnoreCase) && mime.Contains("mpeg", StringComparison.OrdinalIgnoreCase))
return 400;
if (protocol.Equals("progressive", StringComparison.OrdinalIgnoreCase))
return 300;
if (protocol.Equals("hls", StringComparison.OrdinalIgnoreCase) && mime.Contains("mpeg", StringComparison.OrdinalIgnoreCase))
return 200;
if (protocol.Equals("hls", StringComparison.OrdinalIgnoreCase))
return 100;
return 0;
}
static string GetFormatProtocol(JsonElement transcoding)
{
if (transcoding.TryGetProperty("format", out var format) && format.ValueKind == JsonValueKind.Object)
return NormalizeValue(GetString(format, "protocol")) ?? string.Empty;
return string.Empty;
}
static string GetFormatMime(JsonElement transcoding)
{
if (transcoding.TryGetProperty("format", out var format) && format.ValueKind == JsonValueKind.Object)
return NormalizeValue(GetString(format, "mime_type")) ?? "audio/mpeg";
return "audio/mpeg";
}
static async Task<string> ResolveTranscodingUrlAsync(JsonElement transcoding, string clientId, string trackAuthorization, CancellationToken cancellationToken)
{
string transcodingUrl = NormalizeValue(GetString(transcoding, "url"));
if (string.IsNullOrWhiteSpace(transcodingUrl))
return null;
var query = new List<string>
{
$"client_id={Uri.EscapeDataString(clientId)}"
};
if (!string.IsNullOrWhiteSpace(trackAuthorization))
query.Add($"track_authorization={Uri.EscapeDataString(trackAuthorization)}");
string url = transcodingUrl.Contains('?')
? $"{transcodingUrl}&{string.Join("&", query)}"
: $"{transcodingUrl}?{string.Join("&", query)}";
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("licenseAuthToken", out _))
return null;
return NormalizeValue(GetString(document.RootElement, "url"));
}
static SoundCloudMatchPayload ParseMatchPayload(MusicAudioMatch match)
{
if (string.IsNullOrWhiteSpace(match?.payload))
return null;
try
{
return MusicJson.Deserialize<SoundCloudMatchPayload>(match.payload);
}
catch
{
return null;
}
}
static string BuildQualityLabel(string protocol, string mime)
{
if (protocol.Equals("progressive", StringComparison.OrdinalIgnoreCase) && mime.Contains("mpeg", StringComparison.OrdinalIgnoreCase))
return "MP3";
if (protocol.Equals("hls", StringComparison.OrdinalIgnoreCase) && mime.Contains("mpeg", StringComparison.OrdinalIgnoreCase))
return "HLS MP3";
if (protocol.Equals("hls", StringComparison.OrdinalIgnoreCase) && mime.Contains("mp4", StringComparison.OrdinalIgnoreCase))
return "HLS AAC";
if (protocol.Equals("progressive", StringComparison.OrdinalIgnoreCase))
return "Direct";
return "SoundCloud";
}
static string NormalizeSourceMime(string protocol, string mime, string streamUrl)
{
if (!string.IsNullOrWhiteSpace(protocol) && protocol.Contains("hls", StringComparison.OrdinalIgnoreCase))
return "application/vnd.apple.mpegurl";
if (!string.IsNullOrWhiteSpace(mime))
return mime;
return streamUrl.Contains(".m3u8", StringComparison.OrdinalIgnoreCase) ? "application/vnd.apple.mpegurl" : "audio/mpeg";
}
static int? EstimateBitrate(string streamUrl, string mime)
{
if (!string.IsNullOrWhiteSpace(streamUrl))
{
var match = Regex.Match(streamUrl, @"[._-](\d{2,3})k(?:\.|/)", RegexOptions.IgnoreCase);
if (match.Success && int.TryParse(match.Groups[1].Value, out int bitrate))
return bitrate;
}
return mime.Contains("mpeg", StringComparison.OrdinalIgnoreCase) ? 128 : null;
}
static string BuildPreferredSourceCacheKey(MusicAudioMatch match)
{
var payload = ParseMatchPayload(match);
string trackId = NormalizeValue(payload?.track_id) ?? ParseTrackApiId(match.id);
return string.IsNullOrWhiteSpace(trackId) ? null : $"preferred:{trackId}";
}
}
@@ -0,0 +1,87 @@
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace Music;
// OAuth credentials: парсинг payload, нормализация и статус токена.
public static partial class SoundCloudSupport
{
public static Dictionary<string, string> ParseSavePayload(string payload)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (string.IsNullOrWhiteSpace(payload))
return result;
foreach (var part in payload.Split('&', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
int sep = part.IndexOf('=');
if (sep <= 0)
continue;
string key = WebUtility.UrlDecode(part.Substring(0, sep));
string value = WebUtility.UrlDecode(part[(sep + 1)..]);
if (!string.IsNullOrWhiteSpace(key))
result[key] = value;
}
return result;
}
public static SoundCloudCredentials NormalizeCredentials(SoundCloudCredentials credentials)
{
if (credentials == null)
return null;
credentials.access_token = NormalizeValue(credentials.access_token);
credentials.refresh_token = NormalizeValue(credentials.refresh_token);
credentials.token_type = NormalizeValue(credentials.token_type);
credentials.scope = NormalizeValue(credentials.scope);
credentials.username = NormalizeValue(credentials.username);
credentials.user_urn = NormalizeValue(credentials.user_urn);
return credentials;
}
public static bool HasAccessToken(SoundCloudCredentials credentials)
=> !string.IsNullOrWhiteSpace(NormalizeCredentials(credentials)?.access_token);
public static bool IsTokenExpired(SoundCloudCredentials credentials)
{
var normalized = NormalizeCredentials(credentials);
if (normalized?.expires_at_unix == null || normalized.expires_at_unix <= 0)
return false;
return normalized.expires_at_unix <= DateTimeOffset.UtcNow.ToUnixTimeSeconds();
}
public static string BuildStateMessage(SoundCloudCredentials credentials)
{
if (!HasOAuthConfig())
return "SoundCloud OAuth is not configured.";
if (!HasAccessToken(credentials))
return "SoundCloud is not connected.";
return IsTokenExpired(credentials)
? "SoundCloud token is saved, but expired."
: "SoundCloud is connected.";
}
public static string BuildClientCredentialsPayload()
{
if (!HasClientCredentials())
return string.Empty;
var parts = new List<string>
{
"grant_type=client_credentials",
$"client_id={Uri.EscapeDataString(ModInit.conf.soundcloud_client_id)}",
$"client_secret={Uri.EscapeDataString(ModInit.conf.soundcloud_client_secret)}"
};
return string.Join("&", parts);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,276 @@
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Text.RegularExpressions;
using static Music.MusicPlaylistImportHelpers;
namespace Music;
// Импорт пользовательских плейлистов и лайков по ссылке
// (профиль → лайки, плейлист → треки) для MusicUserPlaylistService.
public static partial class SoundCloudSupport
{
static async Task<MusicUserPlaylistImportResult> ImportResolvedPlaylistAsync(JsonElement playlist, string clientId, CancellationToken cancellationToken)
{
int? trackCount = GetInt(playlist, "track_count");
int loadedTracks = GetArrayLength(playlist, "tracks");
string playlistId = GetString(playlist, "id");
bool knownIncompleteTrackList = trackCount.HasValue && loadedTracks < trackCount.Value;
if (!string.IsNullOrWhiteSpace(playlistId) && (!trackCount.HasValue || loadedTracks < trackCount.Value))
{
var hydrated = await LoadPlaylistByIdAsync(playlistId, clientId, cancellationToken);
if (hydrated.HasValue)
playlist = hydrated.Value;
else if (knownIncompleteTrackList)
return ImportUnavailable("SoundCloud плейлист не удалось полностью загрузить. Старый плейлист не обновлён, попробуй позже.");
}
var hydrationIds = CollectTrackIdsForHydration(playlist);
var trackDetails = await LoadTrackDetailsAsync(hydrationIds, clientId, cancellationToken);
if (hydrationIds.Count > 0 && !trackDetails.complete)
return ImportUnavailable("SoundCloud плейлист не удалось полностью загрузить. Старый плейлист не обновлён, попробуй позже.");
var album = MapResolvedPlaylist(playlist, trackDetails.tracks);
if (album == null)
return ImportUnavailable("SoundCloud плейлист не удалось прочитать.");
var tracks = DeduplicateTracks(album.tracks);
if (tracks.Count == 0)
return ImportUnavailable("В SoundCloud плейлисте не найдено треков.");
string sourceUrl = album.provider_refs?.FirstOrDefault(i => i?.provider == DiscoveryProviderId)?.external_id
?? GetString(playlist, "permalink_url");
return new MusicUserPlaylistImportResult
{
available = true,
title = string.IsNullOrWhiteSpace(album.title) ? "SoundCloud Playlist" : album.title,
track_count = tracks.Count,
tracks = tracks,
source = new MusicUserPlaylistSource
{
type = "soundcloud_playlist",
url = sourceUrl,
playlist_id = playlistId,
title = album.title
}
};
}
static async Task<MusicUserPlaylistImportResult> ImportUserLikesAsync(JsonElement user, string clientId, CancellationToken cancellationToken)
{
const int likesImportLimit = 200;
string userId = GetString(user, "id");
string userUrl = GetString(user, "permalink_url");
string username = NormalizeValue(GetString(user, "username")) ?? "SoundCloud";
if (string.IsNullOrWhiteSpace(userId))
return ImportUnavailable("SoundCloud профиль не удалось прочитать.");
var album = new MusicAlbum
{
id = $"soundcloud:likes:{userId}",
title = "Мои лайки (SoundCloud)",
artist_name = username,
images = BuildImages(GetString(user, "avatar_url"))
};
var likes = await LoadLikedTrackElementsAsync(userId, clientId, likesImportLimit, cancellationToken);
if (!likes.complete)
return ImportUnavailable("SoundCloud лайки не удалось полностью загрузить. Старый плейлист не обновлён, попробуй позже.");
string fallbackArtwork = SelectFirstImageUrl(album.images);
int position = 1;
foreach (var item in likes.items)
{
var trackElement = ExtractLikedTrack(item);
if (!trackElement.HasValue)
continue;
var mapped = MapPlaylistTrack(trackElement.Value, album, position++, fallbackArtwork);
if (mapped != null)
album.tracks.Add(mapped);
}
var tracks = DeduplicateTracks(album.tracks);
if (tracks.Count == 0)
return ImportUnavailable("Лайки SoundCloud не найдены или скрыты.");
return new MusicUserPlaylistImportResult
{
available = true,
message = likes.truncated
? $"Импортированы последние {tracks.Count} треков SoundCloud. У профиля есть ещё лайки, они не вошли в лимит {likesImportLimit}."
: null,
title = album.title,
track_count = tracks.Count,
truncated = likes.truncated,
tracks = tracks,
source = new MusicUserPlaylistSource
{
type = "soundcloud_likes",
url = userUrl,
user_id = userId,
title = username
}
};
}
static async Task<SoundCloudCollectionPage> LoadLikedTrackElementsAsync(string userId, string clientId, int limit, CancellationToken cancellationToken)
{
SoundCloudCollectionPage incomplete = null;
foreach (var path in new[] { $"users/{userId}/likes", $"users/{userId}/track_likes", $"users/{userId}/likes/tracks" })
{
var page = await SafeLoadCollectionElementsAsync(path, clientId, limit, cancellationToken);
var tracks = page.items.Where(item => ExtractLikedTrack(item).HasValue).ToList();
if (tracks.Count > 0)
{
return new SoundCloudCollectionPage
{
items = tracks,
complete = page.complete,
hasMore = page.hasMore,
nextPage = page.nextPage,
truncated = page.truncated
};
}
if (!page.complete)
incomplete ??= page;
}
return incomplete == null
? SoundCloudCollectionPage.Empty()
: new SoundCloudCollectionPage
{
complete = false,
hasMore = incomplete.hasMore,
nextPage = incomplete.nextPage
};
}
static JsonElement? ExtractLikedTrack(JsonElement item)
{
if (item.ValueKind != JsonValueKind.Object)
return null;
if (item.TryGetProperty("track", out var track) && track.ValueKind == JsonValueKind.Object)
return track;
string kind = NormalizeValue(GetString(item, "kind"));
if (string.Equals(kind, "track", StringComparison.OrdinalIgnoreCase) || item.TryGetProperty("title", out _))
return item;
return null;
}
static bool LooksLikePlaylist(JsonElement element)
=> element.ValueKind == JsonValueKind.Object
&& element.TryGetProperty("tracks", out var tracks)
&& tracks.ValueKind == JsonValueKind.Array
&& !string.IsNullOrWhiteSpace(GetString(element, "permalink_url"));
static bool LooksLikeUser(JsonElement element)
=> element.ValueKind == JsonValueKind.Object
&& !string.IsNullOrWhiteSpace(GetString(element, "username"))
&& !string.IsNullOrWhiteSpace(GetString(element, "permalink_url"));
static string NormalizeSoundCloudUrl(string value)
{
value = NormalizeValue(value);
if (string.IsNullOrWhiteSpace(value))
return null;
if (!value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !value.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
value = "https://" + value.TrimStart('/');
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
return null;
string host = uri.Host.Trim().ToLowerInvariant();
if (host == "m.soundcloud.com" || host == "www.soundcloud.com")
host = "soundcloud.com";
if (host != "soundcloud.com")
return null;
string path = uri.AbsolutePath.TrimEnd('/');
if (string.IsNullOrWhiteSpace(path) || path == "/")
return null;
return $"https://soundcloud.com{path}";
}
static async Task<string> ExpandSoundCloudShortUrlAsync(string value, CancellationToken cancellationToken)
{
value = NormalizeValue(value);
if (string.IsNullOrWhiteSpace(value))
return null;
if (!value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !value.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
value = "https://" + value.TrimStart('/');
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
return null;
string host = uri.Host.Trim().ToLowerInvariant();
if (host != "on.soundcloud.com")
return null;
try
{
using var request = new HttpRequestMessage(HttpMethod.Head, uri);
request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0");
request.Headers.TryAddWithoutValidation("Accept", "text/html,application/xhtml+xml");
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
string finalUrl = response.RequestMessage?.RequestUri?.ToString();
return NormalizeSoundCloudUrl(finalUrl);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return null;
}
}
public static async Task<MusicUserPlaylistImportResult> ImportPlaylistAsync(string inputUrl, CancellationToken cancellationToken = default)
{
string soundCloudUrl = NormalizeSoundCloudUrl(inputUrl)
?? await ExpandSoundCloudShortUrlAsync(inputUrl, cancellationToken);
if (string.IsNullOrWhiteSpace(soundCloudUrl))
return ImportUnavailable("Вставь ссылку на SoundCloud профиль или плейлист.");
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return ImportUnavailable("Не удалось получить SoundCloud client_id.");
var resolved = await ResolveUrlElementAsync(soundCloudUrl, clientId, cancellationToken);
if (!resolved.HasValue)
return ImportUnavailable("SoundCloud ссылку не удалось открыть.");
var element = resolved.Value;
string kind = NormalizeValue(GetString(element, "kind"));
if (string.Equals(kind, "user", StringComparison.OrdinalIgnoreCase) || LooksLikeUser(element))
return await ImportUserLikesAsync(element, clientId, cancellationToken);
if (string.Equals(kind, "playlist", StringComparison.OrdinalIgnoreCase) || LooksLikePlaylist(element))
return await ImportResolvedPlaylistAsync(element, clientId, cancellationToken);
return ImportUnavailable("Поддерживаются только SoundCloud профиль и плейлист.");
}
public static Task<MusicUserPlaylistImportResult> ImportPlaylistAsync(MusicUserPlaylistSource source, CancellationToken cancellationToken = default)
{
if (source == null || string.IsNullOrWhiteSpace(source.url))
return Task.FromResult(ImportUnavailable("У плейлиста нет SoundCloud источника."));
return ImportPlaylistAsync(source.url, cancellationToken);
}
}
@@ -0,0 +1,662 @@
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();
}