Исправление: том ./mods:/lampac/mods в compose и копии 10 модулей с enable:true; конфиг без SkipModules
This commit is contained in:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user