Исправление: том ./mods:/lampac/mods в compose и копии 10 модулей с enable:true; конфиг без SkipModules
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
namespace Music;
|
||||
|
||||
public class SefonAudioProvider : IMusicAudioProvider
|
||||
{
|
||||
public string Id => SefonSupport.ProviderId;
|
||||
public string Name => "Sefon";
|
||||
public bool Enabled => ModInit.conf?.sefon_audio_enabled == true;
|
||||
public bool RequiresAuth => false;
|
||||
public bool CacheMissingMatches => true;
|
||||
|
||||
public async Task<IReadOnlyList<MusicAudioMatch>> MatchTrackAsync(MusicTrack track, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (track == null || MusicPlaybackModeService.IsVideo(playbackMode))
|
||||
return Array.Empty<MusicAudioMatch>();
|
||||
|
||||
try
|
||||
{
|
||||
return await SefonSupport.SearchAsync(track, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new MusicAudioTransientException(Id, "match", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MusicPlaybackSource>> GetStreamsAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (match == null || MusicPlaybackModeService.IsVideo(playbackMode))
|
||||
return Task.FromResult<IReadOnlyList<MusicPlaybackSource>>(Array.Empty<MusicPlaybackSource>());
|
||||
|
||||
return Task.FromResult(SefonSupport.BuildSources(match));
|
||||
}
|
||||
|
||||
public Task<MusicPlaybackSource> TryGetPreferredStreamAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<MusicPlaybackSource>(null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MusicAudioMatch>> SearchMatchesByQueryAsync(string query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<MusicAudioMatch>>(Array.Empty<MusicAudioMatch>());
|
||||
}
|
||||
|
||||
public bool IsRelevantMatch(MusicTrack track, MusicAudioMatch match)
|
||||
{
|
||||
return SefonSupport.IsRelevantMatch(track, match);
|
||||
}
|
||||
|
||||
public bool ShouldValidatePinnedMatch(MusicTrack track, MusicAudioMatch match)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> GetFallbackProviderIds(MusicTrack track)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text.RegularExpressions;
|
||||
using Shared.Services.HTML;
|
||||
|
||||
namespace Music;
|
||||
|
||||
internal sealed class SefonMatchPayload
|
||||
{
|
||||
public string stream_url { get; set; }
|
||||
public string stream_direct_url { get; set; }
|
||||
public string track_url { get; set; }
|
||||
public string referer { get; set; }
|
||||
}
|
||||
|
||||
internal static class SefonSupport
|
||||
{
|
||||
public const string ProviderId = "sefonaudio";
|
||||
public const string BaseUrl = "https://sefon.org";
|
||||
const int MaxSearchQueries = 3;
|
||||
|
||||
static readonly HttpClient httpClient = MusicHttp.CreateClient("sefon");
|
||||
|
||||
static readonly string[] SoftPenaltyWords =
|
||||
{
|
||||
"nightcore", "remix", "cover", "karaoke", "instrumental", "sped up", "slowed", "speed up",
|
||||
"bass boosted", "deep", "car and bass", "edit", "mashup", "bootleg", "live", "demo"
|
||||
};
|
||||
|
||||
static readonly string[] HardPenaltyWords =
|
||||
{
|
||||
"рингтон", "ringtone", "припев", "pripev", "припiв", "snippet", "тизер", "teaser",
|
||||
"минус", "acapella", "акапелла"
|
||||
};
|
||||
|
||||
static readonly string[] AlbumNoiseWords =
|
||||
{
|
||||
"www.", ".ru", ".com", "muzmix", "mfm", "freshmp3music", "not entered tracks"
|
||||
};
|
||||
|
||||
static SefonSupport()
|
||||
{
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(3);
|
||||
httpClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "Mozilla/5.0");
|
||||
httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Accept-Language", "ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7");
|
||||
httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Referer", $"{BaseUrl}/");
|
||||
httpClient.DefaultRequestHeaders.TryAddWithoutValidation("Origin", BaseUrl);
|
||||
}
|
||||
|
||||
public static string BuildTrackQuery(MusicTrack track)
|
||||
{
|
||||
return BuildTrackQueries(track).FirstOrDefault() ?? string.Empty;
|
||||
}
|
||||
|
||||
public static async Task<List<MusicAudioMatch>> SearchAsync(MusicTrack track, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var queries = BuildTrackQueries(track).Take(MaxSearchQueries).ToList();
|
||||
if (queries.Count == 0)
|
||||
return new List<MusicAudioMatch>();
|
||||
|
||||
var merged = new Dictionary<string, MusicAudioMatch>(StringComparer.OrdinalIgnoreCase);
|
||||
var tasks = queries
|
||||
.Select(query => SearchQueryResultAsync(query, cancellationToken))
|
||||
.ToList();
|
||||
|
||||
var batches = await Task.WhenAll(tasks);
|
||||
foreach (var batch in batches)
|
||||
{
|
||||
foreach (var match in batch.matches)
|
||||
{
|
||||
if (match == null || string.IsNullOrWhiteSpace(match.id))
|
||||
continue;
|
||||
|
||||
merged[match.id] = match;
|
||||
}
|
||||
}
|
||||
|
||||
var ranked = RankMatches(track, merged.Values)
|
||||
.Where(match => IsRelevantMatch(track, match))
|
||||
.Take(5)
|
||||
.ToList();
|
||||
|
||||
if (ranked.Count == 0)
|
||||
{
|
||||
var failure = batches.FirstOrDefault(i => i.error != null).error;
|
||||
if (failure != null)
|
||||
throw failure;
|
||||
}
|
||||
|
||||
return ranked;
|
||||
}
|
||||
|
||||
public static async Task<List<MusicTrack>> SearchTracksByQueryAsync(string query, int limit = 10, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return new List<MusicTrack>();
|
||||
|
||||
var matches = await SearchQuerySafeAsync(query.Trim(), cancellationToken);
|
||||
if (matches.Count == 0)
|
||||
return new List<MusicTrack>();
|
||||
|
||||
return matches
|
||||
.Select(match => new { match, score = ScoreLooseQueryMatch(query, match) })
|
||||
.Where(item => item.score > 0)
|
||||
.OrderByDescending(item => item.score)
|
||||
.ThenBy(item => item.match.title)
|
||||
.Take(Math.Max(1, limit))
|
||||
.Select(item => MapTrack(item.match))
|
||||
.Where(track => track != null)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static bool IsRelevantMatch(MusicTrack query, MusicAudioMatch match)
|
||||
{
|
||||
if (query == null || match == null)
|
||||
return false;
|
||||
|
||||
if (ScoreMatch(query, match) < 10)
|
||||
return false;
|
||||
|
||||
if (!HasArtistOverlap(query, match))
|
||||
return false;
|
||||
|
||||
if (query.duration_ms.HasValue && match.duration_ms.HasValue)
|
||||
{
|
||||
int diff = Math.Abs(query.duration_ms.Value - match.duration_ms.Value);
|
||||
if (diff > 90000)
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static async Task<List<MusicAudioMatch>> SearchQueryAsync(string query, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return new List<MusicAudioMatch>();
|
||||
|
||||
string url = $"{BaseUrl}/song/{Uri.EscapeDataString(query)}";
|
||||
using var response = await httpClient.GetAsync(url, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
if (MusicHttp.IsTransientFailureStatus(response.StatusCode))
|
||||
throw new HttpRequestException($"Sefon returned {(int)response.StatusCode}.", null, response.StatusCode);
|
||||
|
||||
return new List<MusicAudioMatch>();
|
||||
}
|
||||
|
||||
string html = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
return new List<MusicAudioMatch>();
|
||||
|
||||
var nodes = HtmlParse.Nodes(html, "//div[contains(concat(' ', normalize-space(@class), ' '), ' muzmo-track ') and contains(concat(' ', normalize-space(@class), ' '), ' track-item ')]");
|
||||
if (nodes.Count == 0)
|
||||
return new List<MusicAudioMatch>();
|
||||
|
||||
var matches = new List<MusicAudioMatch>();
|
||||
|
||||
foreach (var node in nodes)
|
||||
{
|
||||
string streamPath = node.SelectText(string.Empty, "data-file");
|
||||
string title = WebUtility.HtmlDecode(node.SelectText(string.Empty, "data-track-title") ?? string.Empty).Trim();
|
||||
string artist = WebUtility.HtmlDecode(node.SelectText(string.Empty, "data-artist") ?? string.Empty).Trim();
|
||||
string trackId = node.SelectText(string.Empty, "data-tid");
|
||||
string trackUrl = node.SelectText(".//a[contains(concat(' ', normalize-space(@class), ' '), ' muzmo-track__title ')]", "href");
|
||||
string albumTitle = CleanAlbumTitle(node.SelectText(".//span[contains(concat(' ', normalize-space(@class), ' '), ' short-track__info-album ')]"));
|
||||
int? durationMs = ParseDurationMs(node.SelectText(".//span[contains(concat(' ', normalize-space(@class), ' '), ' short-track__time ')]"));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(streamPath) || string.IsNullOrWhiteSpace(title))
|
||||
continue;
|
||||
|
||||
string streamUrl = BuildAbsoluteUrl(streamPath);
|
||||
if (string.IsNullOrWhiteSpace(streamUrl))
|
||||
continue;
|
||||
|
||||
string directStreamUrl = TryDecodeDirectStreamUrl(streamUrl);
|
||||
|
||||
matches.Add(new MusicAudioMatch
|
||||
{
|
||||
provider_id = ProviderId,
|
||||
id = !string.IsNullOrWhiteSpace(trackId) ? trackId : streamUrl,
|
||||
title = NormalizeMatchTitle(title, artist),
|
||||
artists = string.IsNullOrWhiteSpace(artist) ? new List<string>() : new List<string> { artist },
|
||||
album_title = albumTitle,
|
||||
duration_ms = durationMs,
|
||||
payload = MusicJson.Serialize(new SefonMatchPayload
|
||||
{
|
||||
stream_url = streamUrl,
|
||||
stream_direct_url = directStreamUrl,
|
||||
track_url = BuildAbsoluteUrl(trackUrl),
|
||||
referer = $"{BaseUrl}/"
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
static async Task<List<MusicAudioMatch>> SearchQuerySafeAsync(string query, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await SearchQueryAsync(query, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<MusicAudioMatch>();
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<(List<MusicAudioMatch> matches, Exception error)> SearchQueryResultAsync(string query, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (await SearchQueryAsync(query, cancellationToken), null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return (new List<MusicAudioMatch>(), ex);
|
||||
}
|
||||
}
|
||||
|
||||
public static IReadOnlyList<MusicPlaybackSource> BuildSources(MusicAudioMatch match)
|
||||
{
|
||||
var payload = string.IsNullOrWhiteSpace(match?.payload)
|
||||
? null
|
||||
: MusicJson.Deserialize<SefonMatchPayload>(match.payload);
|
||||
|
||||
string sourceUrl = !string.IsNullOrWhiteSpace(payload?.stream_direct_url)
|
||||
? payload.stream_direct_url
|
||||
: payload?.stream_url;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sourceUrl))
|
||||
return Array.Empty<MusicPlaybackSource>();
|
||||
|
||||
var headers = new Dictionary<string, string>();
|
||||
if (string.IsNullOrWhiteSpace(payload?.stream_direct_url))
|
||||
{
|
||||
headers["Referer"] = string.IsNullOrWhiteSpace(payload.referer) ? $"{BaseUrl}/" : payload.referer;
|
||||
headers["Origin"] = BaseUrl;
|
||||
}
|
||||
|
||||
return new List<MusicPlaybackSource>
|
||||
{
|
||||
new()
|
||||
{
|
||||
provider_id = ProviderId,
|
||||
url = sourceUrl,
|
||||
mime_type = "audio/mpeg",
|
||||
quality = "MP3",
|
||||
headers = headers
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static List<MusicAudioMatch> RankMatches(MusicTrack query, IEnumerable<MusicAudioMatch> matches)
|
||||
{
|
||||
return matches
|
||||
.Select(match => new { match, score = ScoreMatch(query, match) })
|
||||
.Where(i => i.score > -18)
|
||||
.OrderByDescending(i => i.score)
|
||||
.ThenBy(i => i.match.title)
|
||||
.Select(i => i.match)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
static int ScoreMatch(MusicTrack query, MusicAudioMatch match)
|
||||
{
|
||||
string title = NormalizeText(match?.title);
|
||||
string album = NormalizeText(match?.album_title);
|
||||
string artist = NormalizeText(match?.artists.FirstOrDefault());
|
||||
string lowered = $"{title} {artist} {album}";
|
||||
string queryAlbum = NormalizeText(query?.album_title);
|
||||
var queryTitles = BuildNormalizedTitleVariants(query?.title);
|
||||
var queryArtists = NormalizeArtistFragments(query?.artist_name);
|
||||
string queryPrimaryArtist = queryArtists.FirstOrDefault() ?? string.Empty;
|
||||
var matchArtists = NormalizeArtistFragments(match?.artists.FirstOrDefault());
|
||||
int score = 0;
|
||||
|
||||
if (queryTitles.Count > 0)
|
||||
{
|
||||
if (queryTitles.Any(i => title == i))
|
||||
score += 16;
|
||||
else if (queryTitles.Any(i => !string.IsNullOrWhiteSpace(i) && title.Contains(i)))
|
||||
score += 10;
|
||||
else if (queryTitles.Any(i => AllTokensPresent(i, title)))
|
||||
score += 5;
|
||||
else
|
||||
score -= 18;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(queryPrimaryArtist))
|
||||
{
|
||||
if (matchArtists.Contains(queryPrimaryArtist) || artist == queryPrimaryArtist)
|
||||
score += 12;
|
||||
else if (artist.Contains(queryPrimaryArtist))
|
||||
score += 7;
|
||||
else
|
||||
score -= 12;
|
||||
|
||||
if (matchArtists.FirstOrDefault() == queryPrimaryArtist)
|
||||
score += 4;
|
||||
}
|
||||
|
||||
foreach (var queryArtist in queryArtists.Skip(1).Take(3))
|
||||
{
|
||||
if (matchArtists.Contains(queryArtist) || artist.Contains(queryArtist))
|
||||
score += 3;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(queryAlbum) && !string.IsNullOrWhiteSpace(album))
|
||||
{
|
||||
if (album == queryAlbum)
|
||||
score += 3;
|
||||
else if (album.Contains(queryAlbum) || queryAlbum.Contains(album))
|
||||
score += 1;
|
||||
}
|
||||
|
||||
if (query?.duration_ms.HasValue == true && query.duration_ms.Value > 0 && match?.duration_ms.HasValue == true)
|
||||
{
|
||||
int diff = Math.Abs(query.duration_ms.Value - match.duration_ms.Value);
|
||||
if (diff <= 3000)
|
||||
score += 8;
|
||||
else if (diff <= 10000)
|
||||
score += 5;
|
||||
else if (diff <= 20000)
|
||||
score += 2;
|
||||
else if (diff > 120000)
|
||||
score -= 20;
|
||||
else if (diff > 60000)
|
||||
score -= 12;
|
||||
else if (diff > 30000)
|
||||
score -= 6;
|
||||
}
|
||||
else if (match?.duration_ms.HasValue == true && match.duration_ms.Value > 0 && !IsShortTrackQuery(queryTitles))
|
||||
{
|
||||
if (match.duration_ms.Value < 90000)
|
||||
score -= 14;
|
||||
else if (match.duration_ms.Value < 120000)
|
||||
score -= 8;
|
||||
}
|
||||
|
||||
if (HardPenaltyWords.Any(i => lowered.Contains(i)))
|
||||
score -= 18;
|
||||
|
||||
if (SoftPenaltyWords.Any(i => lowered.Contains(i)))
|
||||
score -= 6;
|
||||
|
||||
if (AlbumNoiseWords.Any(i => album.Contains(i)))
|
||||
score -= 4;
|
||||
|
||||
if (artist.Contains("www") || artist.Contains(".ru") || artist.Contains(".com"))
|
||||
score -= 4;
|
||||
|
||||
if (title.Length > 140)
|
||||
score -= 3;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
static int ScoreLooseQueryMatch(string query, MusicAudioMatch match)
|
||||
{
|
||||
string expected = NormalizeText(query);
|
||||
if (string.IsNullOrWhiteSpace(expected) || match == null)
|
||||
return 0;
|
||||
|
||||
string title = NormalizeText(match.title);
|
||||
string artist = NormalizeText(match.artists.FirstOrDefault());
|
||||
string album = NormalizeText(match.album_title);
|
||||
string joined = $"{artist} {title} {album}".Trim();
|
||||
int score = 0;
|
||||
|
||||
if (joined == expected)
|
||||
score += 80;
|
||||
else if (!string.IsNullOrWhiteSpace(title) && title == expected)
|
||||
score += 70;
|
||||
else if (!string.IsNullOrWhiteSpace(joined) && joined.Contains(expected))
|
||||
score += 50;
|
||||
else if (AllTokensPresent(expected, joined))
|
||||
score += 35;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(title) && title.Contains(expected))
|
||||
score += 10;
|
||||
|
||||
if (HardPenaltyWords.Any(i => title.Contains(i)) || HardPenaltyWords.Any(i => album.Contains(i)))
|
||||
score -= 40;
|
||||
|
||||
if (SoftPenaltyWords.Any(i => title.Contains(i)) || SoftPenaltyWords.Any(i => album.Contains(i)))
|
||||
score -= 18;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
static MusicTrack MapTrack(MusicAudioMatch match)
|
||||
{
|
||||
if (match == null || string.IsNullOrWhiteSpace(match.title))
|
||||
return null;
|
||||
|
||||
string artistName = match.artists?.FirstOrDefault() ?? string.Empty;
|
||||
|
||||
return new MusicTrack
|
||||
{
|
||||
id = $"sefon:search:{match.id}",
|
||||
title = match.title,
|
||||
artist_name = artistName,
|
||||
artists = string.IsNullOrWhiteSpace(artistName) ? new List<string>() : new List<string> { artistName },
|
||||
album_title = match.album_title,
|
||||
duration_ms = match.duration_ms
|
||||
};
|
||||
}
|
||||
|
||||
static bool HasArtistOverlap(MusicTrack query, MusicAudioMatch match)
|
||||
{
|
||||
var queryArtists = NormalizeArtistFragments(query?.artist_name);
|
||||
if (queryArtists.Count == 0)
|
||||
return true;
|
||||
|
||||
var matchArtists = NormalizeArtistFragments(match?.artists.FirstOrDefault());
|
||||
string normalizedArtist = NormalizeText(match?.artists.FirstOrDefault());
|
||||
|
||||
foreach (var queryArtist in queryArtists)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(queryArtist))
|
||||
continue;
|
||||
|
||||
if (matchArtists.Contains(queryArtist) || normalizedArtist.Contains(queryArtist))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static string BuildAbsoluteUrl(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return null;
|
||||
|
||||
value = WebUtility.HtmlDecode(value.Trim());
|
||||
if (value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || value.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
return value;
|
||||
|
||||
if (!value.StartsWith("/"))
|
||||
value = "/" + value;
|
||||
|
||||
return BaseUrl + value;
|
||||
}
|
||||
|
||||
static string TryDecodeDirectStreamUrl(string streamUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(streamUrl))
|
||||
return null;
|
||||
|
||||
const string marker = "/stream/mym/";
|
||||
int markerIndex = streamUrl.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
|
||||
if (markerIndex < 0)
|
||||
return null;
|
||||
|
||||
string encoded = streamUrl[(markerIndex + marker.Length)..].Trim();
|
||||
if (string.IsNullOrWhiteSpace(encoded))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
string padded = encoded.Replace('-', '+').Replace('_', '/');
|
||||
int mod = padded.Length % 4;
|
||||
if (mod > 0)
|
||||
padded = padded.PadRight(padded.Length + (4 - mod), '=');
|
||||
|
||||
string decoded = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(padded));
|
||||
if (Uri.TryCreate(decoded, UriKind.Absolute, out var uri) &&
|
||||
(string.Equals(uri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(uri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return uri.ToString();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static string CleanAlbumTitle(string value)
|
||||
{
|
||||
value = WebUtility.HtmlDecode(value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return null;
|
||||
|
||||
if (string.Equals(value, "Not entered tracks", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
return value.Trim().Trim('/');
|
||||
}
|
||||
|
||||
static string NormalizeMatchTitle(string title, string artist)
|
||||
{
|
||||
title = WebUtility.HtmlDecode(title ?? string.Empty).Trim();
|
||||
artist = WebUtility.HtmlDecode(artist ?? string.Empty).Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
return title;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artist))
|
||||
return title;
|
||||
|
||||
string normalizedTitle = NormalizeText(title);
|
||||
string normalizedArtist = NormalizeText(artist);
|
||||
if (string.IsNullOrWhiteSpace(normalizedTitle) || string.IsNullOrWhiteSpace(normalizedArtist))
|
||||
return title;
|
||||
|
||||
if (normalizedTitle == normalizedArtist)
|
||||
return title;
|
||||
|
||||
var prefixPattern = $"^{Regex.Escape(artist)}\\s*[-–—:]\\s*";
|
||||
return Regex.Replace(title, prefixPattern, string.Empty, RegexOptions.IgnoreCase).Trim();
|
||||
}
|
||||
|
||||
static int? ParseDurationMs(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return null;
|
||||
|
||||
var parts = value.Trim().Split(':', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length == 2 &&
|
||||
int.TryParse(parts[0], out int minutes) &&
|
||||
int.TryParse(parts[1], out int seconds))
|
||||
{
|
||||
return ((minutes * 60) + seconds) * 1000;
|
||||
}
|
||||
|
||||
if (parts.Length == 3 &&
|
||||
int.TryParse(parts[0], out int hours) &&
|
||||
int.TryParse(parts[1], out minutes) &&
|
||||
int.TryParse(parts[2], out seconds))
|
||||
{
|
||||
return ((hours * 3600) + (minutes * 60) + seconds) * 1000;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool AllTokensPresent(string query, string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query) || string.IsNullOrWhiteSpace(value))
|
||||
return false;
|
||||
|
||||
var tokens = query.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return tokens.Length > 0 && tokens.All(value.Contains);
|
||||
}
|
||||
|
||||
static List<string> BuildTrackQueries(MusicTrack track)
|
||||
{
|
||||
var queries = new List<string>();
|
||||
string title = CleanQueryValue(track?.title);
|
||||
string titlePrimary = CleanQueryValue(StripTitleDecorations(track?.title));
|
||||
string artistPrimary = CleanQueryValue(ExtractPrimaryArtist(track?.artist_name));
|
||||
string artistFull = CleanQueryValue(track?.artist_name);
|
||||
|
||||
void AddQuery(string artist, string trackTitle)
|
||||
{
|
||||
var parts = new[] { artist, trackTitle }
|
||||
.Where(i => !string.IsNullOrWhiteSpace(i))
|
||||
.ToArray();
|
||||
|
||||
if (parts.Length == 0)
|
||||
return;
|
||||
|
||||
string value = string.Join(" ", parts).Trim();
|
||||
if (!string.IsNullOrWhiteSpace(value) && queries.All(i => !string.Equals(i, value, StringComparison.OrdinalIgnoreCase)))
|
||||
queries.Add(value);
|
||||
}
|
||||
|
||||
AddQuery(artistPrimary, titlePrimary);
|
||||
AddQuery(artistFull, titlePrimary);
|
||||
|
||||
if (!string.Equals(titlePrimary, title, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
AddQuery(artistPrimary, title);
|
||||
AddQuery(artistFull, title);
|
||||
}
|
||||
|
||||
AddQuery(string.Empty, titlePrimary);
|
||||
return queries;
|
||||
}
|
||||
|
||||
static List<string> BuildNormalizedTitleVariants(string value)
|
||||
{
|
||||
var variants = new List<string>();
|
||||
|
||||
void Add(string next)
|
||||
{
|
||||
next = NormalizeText(next);
|
||||
if (!string.IsNullOrWhiteSpace(next) && variants.All(i => i != next))
|
||||
variants.Add(next);
|
||||
}
|
||||
|
||||
Add(value);
|
||||
Add(StripTitleDecorations(value));
|
||||
|
||||
return variants;
|
||||
}
|
||||
|
||||
static bool IsShortTrackQuery(List<string> normalizedTitles)
|
||||
{
|
||||
return normalizedTitles.Any(i =>
|
||||
i.Contains("intro")
|
||||
|| i.Contains("outro")
|
||||
|| i.Contains("interlude")
|
||||
|| i.Contains("skit")
|
||||
|| i.Contains("snippet"));
|
||||
}
|
||||
|
||||
static List<string> NormalizeArtistFragments(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return new List<string>();
|
||||
|
||||
string normalized = NormalizeText(value);
|
||||
normalized = Regex.Replace(normalized, @"\b(feat|ft|featuring|prod|with|and|x|vs|и)\b", "|");
|
||||
normalized = Regex.Replace(normalized, @"\s+-\s+", "|");
|
||||
normalized = normalized.Replace("&", "|").Replace(",", "|");
|
||||
|
||||
return normalized
|
||||
.Split('|', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(CleanQueryValue)
|
||||
.Where(i => !string.IsNullOrWhiteSpace(i))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
static string ExtractPrimaryArtist(string value)
|
||||
{
|
||||
return NormalizeArtistFragments(value).FirstOrDefault() ?? value;
|
||||
}
|
||||
|
||||
static string StripTitleDecorations(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return string.Empty;
|
||||
|
||||
value = WebUtility.HtmlDecode(value).Trim();
|
||||
value = Regex.Replace(value, @"\(([^)]*(feat|ft|live|remix|version|edit|explicit|clean|radio)[^)]*)\)", string.Empty, RegexOptions.IgnoreCase);
|
||||
value = Regex.Replace(value, @"\[([^\]]*(feat|ft|live|remix|version|edit|explicit|clean|radio)[^\]]*)\]", string.Empty, RegexOptions.IgnoreCase);
|
||||
return Regex.Replace(value, @"\s+", " ").Trim();
|
||||
}
|
||||
|
||||
static string CleanQueryValue(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return string.Empty;
|
||||
|
||||
value = WebUtility.HtmlDecode(value).Trim();
|
||||
value = Regex.Replace(value, @"\s+", " ");
|
||||
return value.Trim(' ', '-', '–', '—', ',', ';', ':');
|
||||
}
|
||||
|
||||
static string NormalizeText(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return string.Empty;
|
||||
|
||||
value = WebUtility.HtmlDecode(value).ToLowerInvariant();
|
||||
value = Regex.Replace(value, @"[\(\)\[\]\{\}\-_]+", " ");
|
||||
value = Regex.Replace(value, @"[^0-9a-zа-яёіїєґ\s]+", " ");
|
||||
return Regex.Replace(value, @"\s+", " ").Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
namespace Music;
|
||||
|
||||
public class SoundCloudAudioProvider : IMusicAudioProvider
|
||||
{
|
||||
public string Id => SoundCloudSupport.AudioProviderId;
|
||||
public string Name => "SoundCloud";
|
||||
public bool Enabled => SoundCloudSupport.IsAudioEnabled;
|
||||
public bool RequiresAuth => false;
|
||||
public bool CacheMissingMatches => false;
|
||||
|
||||
public async Task<IReadOnlyList<MusicAudioMatch>> MatchTrackAsync(MusicTrack track, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (track == null || MusicPlaybackModeService.IsVideo(playbackMode))
|
||||
return Array.Empty<MusicAudioMatch>();
|
||||
|
||||
try
|
||||
{
|
||||
return await SoundCloudSupport.SearchAudioAsync(track, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new MusicAudioTransientException(Id, "match", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MusicPlaybackSource>> GetStreamsAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (match == null || MusicPlaybackModeService.IsVideo(playbackMode))
|
||||
return Array.Empty<MusicPlaybackSource>();
|
||||
|
||||
try
|
||||
{
|
||||
return await SoundCloudSupport.BuildAudioSourcesAsync(match, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new MusicAudioTransientException(Id, "stream", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<MusicPlaybackSource> TryGetPreferredStreamAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (match == null || MusicPlaybackModeService.IsVideo(playbackMode))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return await SoundCloudSupport.BuildPreferredAudioSourceAsync(match, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MusicAudioMatch>> SearchMatchesByQueryAsync(string query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<MusicAudioMatch>>(Array.Empty<MusicAudioMatch>());
|
||||
}
|
||||
|
||||
public bool IsRelevantMatch(MusicTrack track, MusicAudioMatch match)
|
||||
{
|
||||
return SoundCloudSupport.IsRelevantAudioMatch(track, match);
|
||||
}
|
||||
|
||||
public bool ShouldValidatePinnedMatch(MusicTrack track, MusicAudioMatch match)
|
||||
{
|
||||
return SoundCloudSupport.HasExactTrackId(track);
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> GetFallbackProviderIds(MusicTrack track)
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
SefonSupport.ProviderId,
|
||||
YouTubeMusicSearchSupport.ProviderId
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
using System.Collections.Concurrent;
|
||||
using YoutubeExplode;
|
||||
using YoutubeExplode.Search;
|
||||
using YoutubeExplode.Videos.Streams;
|
||||
|
||||
namespace Music;
|
||||
|
||||
public class YouTubeAudioProvider : IMusicAudioProvider
|
||||
{
|
||||
// спекулятивный манифест: после первой пачки поиска манифест
|
||||
// промежуточного топ-кандидата тянется ПАРАЛЛЕЛЬНО со второй пачкой —
|
||||
// победитель ранжирования после неё почти никогда не меняется, и
|
||||
// GetStreamsAsync забирает готовый результат вместо ещё одного ~1.5s
|
||||
// запроса. Промах (победитель сменился/спекуляция упала) — обычный путь.
|
||||
static readonly ConcurrentDictionary<string, (DateTime at, Task<ResolvedManifest> task)> speculativeManifests = new();
|
||||
static readonly TimeSpan speculativeManifestTtl = TimeSpan.FromMinutes(2);
|
||||
|
||||
sealed record ResolvedManifest(StreamManifest Manifest, MusicProxyLease ProxyLease);
|
||||
|
||||
public string Id => "youtubeaudio";
|
||||
public string Name => "YouTube Audio";
|
||||
public bool Enabled => ModInit.conf?.youtube_audio_enabled == true;
|
||||
public bool RequiresAuth => false;
|
||||
public bool CacheMissingMatches => false;
|
||||
|
||||
public async Task<IReadOnlyList<MusicAudioMatch>> MatchTrackAsync(MusicTrack track, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (track == null)
|
||||
return Array.Empty<MusicAudioMatch>();
|
||||
|
||||
var queries = YouTubeAudioSupport.BuildTrackQueries(track, playbackMode);
|
||||
var directMatch = YouTubeAudioSupport.BuildDirectMatch(track);
|
||||
if (queries.Count == 0)
|
||||
return directMatch != null ? new[] { directMatch } : Array.Empty<MusicAudioMatch>();
|
||||
|
||||
try
|
||||
{
|
||||
var proxyLease = MusicProxyService.Acquire(Id, MusicProxyPurpose.Api);
|
||||
using var youtube = new YoutubeClient(MusicHttp.GetTransport(proxyLease));
|
||||
var results = new List<VideoSearchResult>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
// batch-parallel: пачки по 3 запроса параллельно (холодный резолв был
|
||||
// суммой 3-6 последовательных поисков по 1-2s = главная часть 5-16s
|
||||
// ожидания /music/play). Merge строго в порядке исходных queries —
|
||||
// вход ранжирования тот же, что у последовательного цикла; dedupe и
|
||||
// кап 36 на слиянии; набрали кап — следующая пачка не запускается
|
||||
bool speculated = false;
|
||||
|
||||
for (int batchStart = 0; batchStart < queries.Count && results.Count < 36; batchStart += 3)
|
||||
{
|
||||
var batchTasks = queries
|
||||
.Skip(batchStart)
|
||||
.Take(3)
|
||||
.Select(query => FetchQueryResultsAsync(youtube, query, cancellationToken))
|
||||
.ToList();
|
||||
|
||||
await Task.WhenAll(batchTasks);
|
||||
|
||||
foreach (var batchTask in batchTasks)
|
||||
{
|
||||
foreach (var video in batchTask.Result)
|
||||
{
|
||||
var videoId = video.Id.Value;
|
||||
if (!string.IsNullOrWhiteSpace(videoId) && seen.Add(videoId))
|
||||
results.Add(video);
|
||||
|
||||
if (results.Count >= 36)
|
||||
break;
|
||||
}
|
||||
|
||||
if (results.Count >= 36)
|
||||
break;
|
||||
}
|
||||
|
||||
// впереди ещё пачка — после первой пачки уже часто есть
|
||||
// очевидный победитель. Если он проходит строгий гейт
|
||||
// уверенности, не жжём вторую пачку YouTube-поисков вообще.
|
||||
// Манифест проверяем до раннего выхода: если топ-кандидат
|
||||
// оказался без стримов, сохраняем старую полную fallback-схему.
|
||||
if (directMatch == null && batchStart == 0 && batchStart + 3 < queries.Count && results.Count < 36)
|
||||
{
|
||||
var earlyRanked = YouTubeAudioSupport.RankMatches(track, YouTubeAudioSupport.ConvertSearchResults(results), playbackMode);
|
||||
var earlyTop = earlyRanked.FirstOrDefault();
|
||||
if (YouTubeAudioSupport.IsConfidentEarlyMatch(track, earlyTop))
|
||||
{
|
||||
try
|
||||
{
|
||||
var manifestTask = StartSpeculativeManifest(earlyTop.id);
|
||||
if (manifestTask != null)
|
||||
{
|
||||
await manifestTask.WaitAsync(cancellationToken);
|
||||
proxyLease.Success();
|
||||
return earlyRanked;
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// stream-less первый кандидат — не меняем поведение,
|
||||
// продолжаем полный поиск и даём ранжированию шанс.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// впереди ещё пачка — спекулятивно тянем манифест текущего
|
||||
// фаворита (directMatch, если есть — резолвер пробует его первым)
|
||||
if (!speculated && batchStart + 3 < queries.Count && results.Count < 36)
|
||||
{
|
||||
speculated = true;
|
||||
var prelim = YouTubeAudioSupport.RankMatches(track, YouTubeAudioSupport.ConvertSearchResults(results), playbackMode);
|
||||
StartSpeculativeManifest(directMatch?.id ?? prelim.FirstOrDefault()?.id);
|
||||
}
|
||||
}
|
||||
|
||||
var matches = YouTubeAudioSupport.ConvertSearchResults(results);
|
||||
var ranked = YouTubeAudioSupport.RankMatches(track, matches, playbackMode);
|
||||
proxyLease.Success();
|
||||
if (directMatch == null)
|
||||
return ranked;
|
||||
|
||||
var ordered = new List<MusicAudioMatch> { directMatch };
|
||||
ordered.AddRange(ranked.Where(i => !string.Equals(i.id, directMatch.id, StringComparison.Ordinal)));
|
||||
return ordered;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (MusicHttp.IsProxyFailure(ex))
|
||||
MusicProxyService.ReportFailure($"Music:{Id}:api");
|
||||
|
||||
if (directMatch != null)
|
||||
return new[] { directMatch };
|
||||
|
||||
throw new MusicAudioTransientException(Id, "match", ex);
|
||||
}
|
||||
}
|
||||
|
||||
// «Найти вручную» в Источниках: сырой пользовательский запрос — без
|
||||
// переписывания, ранжирования и гейта релевантности, решает человек
|
||||
public async Task<IReadOnlyList<MusicAudioMatch>> SearchMatchesByQueryAsync(string query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
query = query?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return Array.Empty<MusicAudioMatch>();
|
||||
|
||||
try
|
||||
{
|
||||
var proxyLease = MusicProxyService.Acquire(Id, MusicProxyPurpose.Api);
|
||||
using var youtube = new YoutubeClient(MusicHttp.GetTransport(proxyLease));
|
||||
var results = new List<VideoSearchResult>();
|
||||
|
||||
await foreach (var video in youtube.Search.GetVideosAsync(query, cancellationToken))
|
||||
{
|
||||
results.Add(video);
|
||||
if (results.Count >= 20)
|
||||
break;
|
||||
}
|
||||
|
||||
proxyLease.Success();
|
||||
return YouTubeAudioSupport.ConvertSearchResults(results);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (MusicHttp.IsProxyFailure(ex))
|
||||
MusicProxyService.ReportFailure($"Music:{Id}:api");
|
||||
|
||||
throw new MusicAudioTransientException(Id, "search", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<MusicPlaybackSource>> GetStreamsAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (match == null || string.IsNullOrWhiteSpace(match.id))
|
||||
return Array.Empty<MusicPlaybackSource>();
|
||||
|
||||
for (int attempt = 0; attempt < 2; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
var resolved = attempt == 0 ? await TryTakeSpeculativeManifestAsync(match.id) : null;
|
||||
resolved ??= await ResolveManifestAsync(match.id, cancellationToken);
|
||||
if (MusicPlaybackModeService.IsVideo(playbackMode))
|
||||
{
|
||||
var videoStreams = resolved.Manifest.GetMuxedStreams();
|
||||
var sources = YouTubeAudioSupport.ConvertVideoStreams(videoStreams);
|
||||
foreach (var source in sources)
|
||||
resolved.ProxyLease.ApplyTo(source, overwrite: true);
|
||||
return sources;
|
||||
}
|
||||
|
||||
var audioStreams = resolved.Manifest.GetAudioOnlyStreams();
|
||||
var audioSources = YouTubeAudioSupport.ConvertAudioStreams(audioStreams);
|
||||
foreach (var source in audioSources)
|
||||
resolved.ProxyLease.ApplyTo(source, overwrite: true);
|
||||
return audioSources;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (attempt == 0 && ShouldRetryManifestFailure(ex))
|
||||
{
|
||||
await Task.Delay(250, cancellationToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
Console.WriteLine($"[Music] youtube stream manifest failed for {match.id}: {ex.GetType().Name}: {ex.Message}");
|
||||
if (IsPermanentManifestFailure(ex))
|
||||
return Array.Empty<MusicPlaybackSource>();
|
||||
|
||||
throw new MusicAudioTransientException(Id, "stream", ex);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.Empty<MusicPlaybackSource>();
|
||||
}
|
||||
|
||||
public Task<MusicPlaybackSource> TryGetPreferredStreamAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<MusicPlaybackSource>(null);
|
||||
}
|
||||
|
||||
public bool IsRelevantMatch(MusicTrack track, MusicAudioMatch match)
|
||||
{
|
||||
return YouTubeAudioSupport.IsRelevantMatch(track, match);
|
||||
}
|
||||
|
||||
public bool ShouldValidatePinnedMatch(MusicTrack track, MusicAudioMatch match)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> GetFallbackProviderIds(MusicTrack track)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
static Task<ResolvedManifest> StartSpeculativeManifest(string videoId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(videoId))
|
||||
return null;
|
||||
|
||||
var proxyLease = MusicProxyService.Acquire("youtubeaudio", MusicProxyPurpose.Stream);
|
||||
string cacheKey = BuildManifestCacheKey(videoId, proxyLease);
|
||||
if (speculativeManifests.ContainsKey(cacheKey))
|
||||
return speculativeManifests.TryGetValue(cacheKey, out var existing) ? existing.task : null;
|
||||
|
||||
// уборка протухших записей (непотреблённые спекуляции — промахи ранжирования)
|
||||
foreach (var entry in speculativeManifests)
|
||||
{
|
||||
if (DateTime.UtcNow - entry.Value.at > speculativeManifestTtl)
|
||||
speculativeManifests.TryRemove(entry.Key, out _);
|
||||
}
|
||||
|
||||
// CancellationToken.None: спекуляция переживает отмену исходного запроса —
|
||||
// результат заберёт следующий GetStreams по тому же видео в пределах TTL
|
||||
var task = ResolveManifestAsync(videoId, CancellationToken.None, proxyLease);
|
||||
speculativeManifests[cacheKey] = (DateTime.UtcNow, task);
|
||||
_ = task.ContinueWith(t => { _ = t.Exception; }, TaskContinuationOptions.OnlyOnFaulted);
|
||||
return task;
|
||||
}
|
||||
|
||||
static async Task<ResolvedManifest> TryTakeSpeculativeManifestAsync(string videoId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(videoId))
|
||||
return null;
|
||||
|
||||
var proxyLease = MusicProxyService.Acquire("youtubeaudio", MusicProxyPurpose.Stream);
|
||||
string cacheKey = BuildManifestCacheKey(videoId, proxyLease);
|
||||
if (!speculativeManifests.TryRemove(cacheKey, out var entry))
|
||||
return null;
|
||||
|
||||
if (DateTime.UtcNow - entry.at > speculativeManifestTtl)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return await entry.task;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// упавшая спекуляция не считается попыткой — обычный путь
|
||||
// с его retry-семантикой отработает как раньше
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<List<VideoSearchResult>> FetchQueryResultsAsync(YoutubeClient youtube, string query, CancellationToken cancellationToken)
|
||||
{
|
||||
var list = new List<VideoSearchResult>();
|
||||
|
||||
await foreach (var video in youtube.Search.GetVideosAsync(query, cancellationToken))
|
||||
{
|
||||
list.Add(video);
|
||||
if (list.Count >= 16)
|
||||
break;
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
static async Task<ResolvedManifest> ResolveManifestAsync(string videoId, CancellationToken cancellationToken, MusicProxyLease proxyLease = null)
|
||||
{
|
||||
proxyLease ??= MusicProxyService.Acquire("youtubeaudio", MusicProxyPurpose.Stream);
|
||||
|
||||
try
|
||||
{
|
||||
using var youtube = new YoutubeClient(MusicHttp.GetTransport(proxyLease));
|
||||
var manifest = await youtube.Videos.Streams.GetManifestAsync(videoId, cancellationToken);
|
||||
proxyLease.Success();
|
||||
return new ResolvedManifest(manifest, proxyLease);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (proxyLease.Enabled && MusicHttp.IsProxyFailure(ex))
|
||||
proxyLease.Failure();
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
static string BuildManifestCacheKey(string videoId, MusicProxyLease proxyLease)
|
||||
{
|
||||
string route = proxyLease?.Enabled == true
|
||||
? $"{proxyLease.Scope}|{proxyLease.Data.ip}|{proxyLease.Data.username}"
|
||||
: "direct";
|
||||
|
||||
return $"{videoId}|{route}|cfg:{MusicProxyService.ConfigurationVersion}";
|
||||
}
|
||||
|
||||
static bool ShouldRetryManifestFailure(Exception ex)
|
||||
{
|
||||
return ex != null
|
||||
&& ex.GetType().Name == "YoutubeExplodeException"
|
||||
&& (ex.Message?.IndexOf("cipher manifest", StringComparison.OrdinalIgnoreCase) ?? -1) >= 0;
|
||||
}
|
||||
|
||||
static bool IsPermanentManifestFailure(Exception ex)
|
||||
{
|
||||
for (var current = ex; current != null; current = current.InnerException)
|
||||
{
|
||||
if (current.GetType().Name is "VideoUnavailableException" or "VideoUnplayableException")
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,708 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using YoutubeExplode.Search;
|
||||
using YoutubeExplode.Videos.Streams;
|
||||
|
||||
namespace Music;
|
||||
|
||||
internal static class YouTubeAudioSupport
|
||||
{
|
||||
static readonly string[] GenericTrackTitles =
|
||||
{
|
||||
"intro", "outro", "skit", "interlude", "theme", "song", "track"
|
||||
};
|
||||
|
||||
static readonly string[] PenaltyWords =
|
||||
{
|
||||
"live", "karaoke", "cover", "reaction", "remix", "sped up", "slowed", "nightcore", "8d", "instrumental", "fan made", "tribute", "parody", "concert", "festival", "demo", "teaser", "choreo", "choreography", "dance"
|
||||
};
|
||||
|
||||
static readonly string[] StrongVariantWords =
|
||||
{
|
||||
"instrumental", "karaoke", "drumless", "acapella", "a cappella"
|
||||
};
|
||||
|
||||
static readonly string[] LiveVariantWords =
|
||||
{
|
||||
"live", "concert", "festival", "live performance", "performance",
|
||||
"live session", "session", "sessions", "acoustic", "unplugged",
|
||||
"tiny desk", "live lounge", "kexp", "boiler room", "city hall",
|
||||
"royal albert hall", "madison square garden", "wembley"
|
||||
};
|
||||
|
||||
static readonly string[] BonusWords =
|
||||
{
|
||||
"official audio", "official video", "audio", "topic", "provided to youtube", "auto-generated by youtube", "visualizer", "lyrics"
|
||||
};
|
||||
|
||||
static readonly string[] EarlyExitPenaltyWords =
|
||||
{
|
||||
"live", "karaoke", "cover", "reaction", "remix", "sped up", "slowed", "nightcore", "8d", "instrumental", "fan made", "tribute", "parody", "concert", "festival", "demo", "teaser", "choreo", "choreography", "dance", "alternate", "version", "drumless", "acoustic", "unplugged", "remaster", "remastered", "edit", "sped", "reverb"
|
||||
};
|
||||
|
||||
public static IReadOnlyList<string> BuildTrackQueries(MusicTrack track, string playbackMode = null)
|
||||
{
|
||||
string title = StripFeatureSegment(track?.title)?.Trim();
|
||||
string album = track?.album_title?.Trim();
|
||||
string artist = track?.artist_name?.Trim();
|
||||
string normalizedTitle = NormalizeText(title);
|
||||
string normalizedAlbum = NormalizeText(album);
|
||||
bool preferVideo = MusicPlaybackModeService.IsVideo(playbackMode);
|
||||
string primaryHint = preferVideo ? "official music video" : "official audio";
|
||||
string secondaryHint = preferVideo ? "music video" : "audio";
|
||||
var queryArtists = BuildQueryArtists(artist, track?.artists);
|
||||
var queries = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
void add(string value)
|
||||
{
|
||||
value = value?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return;
|
||||
|
||||
var key = NormalizeText(value);
|
||||
if (string.IsNullOrWhiteSpace(key) || !seen.Add(key))
|
||||
return;
|
||||
|
||||
queries.Add(value);
|
||||
}
|
||||
|
||||
add(MusicIsrc.Normalize(track?.isrc));
|
||||
|
||||
if (queryArtists.Count == 0 || string.IsNullOrWhiteSpace(title))
|
||||
return queries;
|
||||
|
||||
foreach (var queryArtist in queryArtists)
|
||||
add($"{queryArtist} \"{title}\" {primaryHint}");
|
||||
|
||||
foreach (var queryArtist in queryArtists)
|
||||
add($"{queryArtist} {title} {primaryHint}");
|
||||
|
||||
foreach (var queryArtist in queryArtists)
|
||||
add($"{queryArtist} \"{title}\"");
|
||||
|
||||
foreach (var queryArtist in queryArtists)
|
||||
add($"{queryArtist} {title}");
|
||||
|
||||
if (ShouldAppendAlbumHint(title) && !string.IsNullOrWhiteSpace(album) && normalizedAlbum != normalizedTitle)
|
||||
add($"{artist} \"{title}\" {album}");
|
||||
|
||||
foreach (var queryArtist in queryArtists)
|
||||
add($"{queryArtist} {title} {secondaryHint}");
|
||||
|
||||
return queries;
|
||||
}
|
||||
|
||||
public static MusicAudioMatch BuildDirectMatch(MusicTrack track)
|
||||
{
|
||||
string videoId = ResolveVideoId(track);
|
||||
if (string.IsNullOrWhiteSpace(videoId))
|
||||
return null;
|
||||
|
||||
return new MusicAudioMatch
|
||||
{
|
||||
provider_id = "youtubeaudio",
|
||||
id = videoId,
|
||||
title = track?.title,
|
||||
artists = !string.IsNullOrWhiteSpace(track?.artist_name) ? new List<string> { track.artist_name } : track?.artists ?? new List<string>(),
|
||||
album_title = track?.album_title,
|
||||
isrc = MusicIsrc.Normalize(track?.isrc),
|
||||
duration_ms = track?.duration_ms,
|
||||
payload = MusicJson.Serialize(new Dictionary<string, string>
|
||||
{
|
||||
["video_id"] = videoId
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
public static List<MusicAudioMatch> RankMatches(MusicTrack query, IEnumerable<MusicAudioMatch> matches, string playbackMode = null)
|
||||
{
|
||||
string queryTitle = NormalizeTitleForMatch(query?.title);
|
||||
string queryArtist = NormalizeText(query?.artist_name);
|
||||
string queryAlbum = NormalizeText(query?.album_title);
|
||||
string queryPenaltyContext = NormalizeText($"{query?.title} {query?.album_title}");
|
||||
string queryArtistCompact = CompactText(queryArtist);
|
||||
bool preferVideo = MusicPlaybackModeService.IsVideo(playbackMode);
|
||||
|
||||
return matches
|
||||
.Select(match =>
|
||||
{
|
||||
int score = 0;
|
||||
string rawTitle = NormalizeText(match.title);
|
||||
string title = NormalizeTitleForMatch(match.title);
|
||||
string primaryTitle = NormalizePrimaryTitleForMatch(match.title);
|
||||
string album = NormalizeText(match.album_title);
|
||||
string fullTitle = NormalizeText($"{StripFeatureSegment(match.title)} {match.album_title}");
|
||||
string rawFullTitle = NormalizeText($"{match.title} {match.album_title}");
|
||||
var artists = match.artists.Select(NormalizeText).Where(i => !string.IsNullOrWhiteSpace(i)).ToList();
|
||||
var titleMatch = EvaluateTitleMatch(queryTitle, title, rawFullTitle);
|
||||
bool strongVariant = HasUnexpectedStrongVariant(queryPenaltyContext, rawFullTitle);
|
||||
bool primaryTitleContainsQuery = !string.IsNullOrWhiteSpace(queryTitle)
|
||||
&& !string.IsNullOrWhiteSpace(primaryTitle)
|
||||
&& primaryTitle.Contains(queryTitle, StringComparison.Ordinal);
|
||||
bool noisyTitleContainsQuery = !primaryTitleContainsQuery
|
||||
&& !string.IsNullOrWhiteSpace(queryTitle)
|
||||
&& !string.IsNullOrWhiteSpace(title)
|
||||
&& title.Contains(queryTitle, StringComparison.Ordinal);
|
||||
|
||||
if (titleMatch.exact)
|
||||
score += 14;
|
||||
else if (primaryTitleContainsQuery)
|
||||
score += 10;
|
||||
else if (titleMatch.allTokens)
|
||||
score += 8;
|
||||
else if (titleMatch.coverage >= 0.5d)
|
||||
score += 3;
|
||||
else if (!string.IsNullOrWhiteSpace(queryTitle))
|
||||
score -= 20;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(queryArtist) && !string.IsNullOrWhiteSpace(queryTitle) && rawFullTitle.Contains(queryArtist) && rawFullTitle.Contains(queryTitle))
|
||||
score += 4;
|
||||
|
||||
// Если совпадение нашлось только в скобках/хвосте, это часто
|
||||
// название альбома или подборки, а не самого трека.
|
||||
if (noisyTitleContainsQuery)
|
||||
score -= 10;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(queryArtist) && artists.Any(a => a == queryArtist))
|
||||
score += 5;
|
||||
else if (!string.IsNullOrWhiteSpace(queryArtist) && artists.Any(a => a.Contains(queryArtist) || queryArtist.Contains(a)))
|
||||
score += 3;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(queryArtistCompact) && artists.Any(a =>
|
||||
CompactText(a) == $"{queryArtistCompact}music"
|
||||
|| CompactText(a) == $"{queryArtistCompact}vevo"))
|
||||
{
|
||||
score += 3;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(queryAlbum) && album.Contains(queryAlbum))
|
||||
score += 1;
|
||||
|
||||
if (query.duration_ms.HasValue && match.duration_ms.HasValue)
|
||||
{
|
||||
int diff = Math.Abs(query.duration_ms.Value - match.duration_ms.Value);
|
||||
if (diff <= 3000)
|
||||
score += 4;
|
||||
else if (diff <= 10000)
|
||||
score += 2;
|
||||
else if (diff > 90000)
|
||||
score -= 12;
|
||||
else if (diff > 45000)
|
||||
score -= 6;
|
||||
|
||||
if (query.duration_ms.Value > 0 && match.duration_ms.Value > query.duration_ms.Value * 2)
|
||||
score -= 16;
|
||||
}
|
||||
else if (match.duration_ms.HasValue && match.duration_ms.Value > 0 && match.duration_ms.Value < 90000 && !LooksLikeShortTrackQuery(queryTitle))
|
||||
{
|
||||
score -= 8;
|
||||
}
|
||||
|
||||
string lowered = $"{rawTitle} {string.Join(" ", artists)} {album}";
|
||||
|
||||
if (BonusWords.Any(i => lowered.Contains(i)))
|
||||
score += 2;
|
||||
|
||||
if (artists.Any(a => a.Contains("topic")))
|
||||
score += 4;
|
||||
|
||||
if (preferVideo)
|
||||
{
|
||||
if (title.Contains("official video") || title.Contains("music video"))
|
||||
score += 8;
|
||||
|
||||
if (lowered.Contains("hd") || lowered.Contains("4k"))
|
||||
score += 2;
|
||||
|
||||
if (title.Contains("official audio") || title.Contains("provided to youtube"))
|
||||
score -= 10;
|
||||
|
||||
if (artists.Any(a => a.Contains("topic")))
|
||||
score -= 10;
|
||||
|
||||
if (lowered.Contains("lyrics") || lowered.Contains("visualizer"))
|
||||
score -= 4;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (title.Contains("official audio") || title.Contains("provided to youtube"))
|
||||
score += 4;
|
||||
|
||||
if (title.Contains("official video") || title.Contains("music video"))
|
||||
score += 1;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(queryArtist) && !string.IsNullOrWhiteSpace(queryTitle) && title.StartsWith($"{queryArtist} {queryTitle}"))
|
||||
score += 3;
|
||||
|
||||
foreach (var word in PenaltyWords)
|
||||
{
|
||||
string normalizedWord = NormalizeText(word);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedWord) && lowered.Contains(normalizedWord) && !queryPenaltyContext.Contains(normalizedWord))
|
||||
score -= 3;
|
||||
}
|
||||
|
||||
// Instrumental/karaoke/drumless версии почти всегда являются
|
||||
// другим треком для пользователя. Малый общий штраф не хватал:
|
||||
// точное title+artist+duration могло перебить нормальную версию.
|
||||
if (strongVariant)
|
||||
score -= 40;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(queryAlbum) && !string.IsNullOrWhiteSpace(album) && album == queryAlbum)
|
||||
score += 2;
|
||||
|
||||
return new { match, score, titleMatch, strongVariant };
|
||||
})
|
||||
.ToList() is var ranked
|
||||
? (ranked.Any(i => i.titleMatch.relevant)
|
||||
? ranked
|
||||
.Where(i => i.titleMatch.relevant)
|
||||
.Where(i => !i.strongVariant)
|
||||
.Where(i => i.score > -12)
|
||||
.OrderByDescending(i => i.score)
|
||||
.ThenBy(i => i.match.title)
|
||||
.Take(5)
|
||||
.Select(i => i.match)
|
||||
.ToList()
|
||||
: ranked
|
||||
.Where(i => !i.strongVariant)
|
||||
.Where(i => i.score > -8)
|
||||
.OrderByDescending(i => i.score)
|
||||
.ThenBy(i => i.match.title)
|
||||
.Take(5)
|
||||
.Select(i => i.match)
|
||||
.ToList())
|
||||
: new List<MusicAudioMatch>();
|
||||
}
|
||||
|
||||
public static bool IsRelevantMatch(MusicTrack query, MusicAudioMatch match)
|
||||
{
|
||||
if (query == null || match == null)
|
||||
return false;
|
||||
|
||||
string queryContext = NormalizeText($"{query.title} {query.album_title}");
|
||||
string candidateContext = NormalizeText($"{match.title} {match.album_title}");
|
||||
if (HasUnexpectedStrongVariant(queryContext, candidateContext))
|
||||
return false;
|
||||
|
||||
string queryTitle = NormalizeTitleForMatch(query.title);
|
||||
if (string.IsNullOrWhiteSpace(queryTitle))
|
||||
return true;
|
||||
|
||||
string primaryTitle = NormalizePrimaryTitleForMatch(match.title);
|
||||
return EvaluateTitleMatch(queryTitle, primaryTitle, primaryTitle).relevant;
|
||||
}
|
||||
|
||||
public static bool IsConfidentEarlyMatch(MusicTrack query, MusicAudioMatch match)
|
||||
{
|
||||
if (query == null || match == null)
|
||||
return false;
|
||||
|
||||
string queryTitle = NormalizeTitleForMatch(query.title);
|
||||
string candidatePrimaryTitle = NormalizePrimaryTitleForMatch(match.title);
|
||||
if (string.IsNullOrWhiteSpace(queryTitle) || string.IsNullOrWhiteSpace(candidatePrimaryTitle))
|
||||
return false;
|
||||
|
||||
if (LooksLikeShortTrackQuery(queryTitle))
|
||||
return false;
|
||||
|
||||
if (!ContainsTokenPhrase(candidatePrimaryTitle, queryTitle))
|
||||
return false;
|
||||
|
||||
if (!HasArtistMatch(query, match))
|
||||
return false;
|
||||
|
||||
if (!HasTightDurationMatch(query, match, 5000))
|
||||
return false;
|
||||
|
||||
if (HasUnexpectedEarlyExitNoise(query, match))
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static List<MusicAudioMatch> ConvertSearchResults(IEnumerable<VideoSearchResult> videos)
|
||||
{
|
||||
var matches = new List<MusicAudioMatch>();
|
||||
|
||||
foreach (var video in videos)
|
||||
{
|
||||
string id = video.Id.Value;
|
||||
string title = video.Title;
|
||||
string author = video.Author.ChannelTitle;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(id) || string.IsNullOrWhiteSpace(title))
|
||||
continue;
|
||||
|
||||
matches.Add(new MusicAudioMatch
|
||||
{
|
||||
provider_id = "youtubeaudio",
|
||||
id = id,
|
||||
title = title,
|
||||
artists = string.IsNullOrWhiteSpace(author) ? new List<string>() : new List<string> { author },
|
||||
album_title = null,
|
||||
duration_ms = video.Duration.HasValue ? (int)video.Duration.Value.TotalMilliseconds : null,
|
||||
payload = MusicJson.Serialize(new Dictionary<string, string>
|
||||
{
|
||||
["video_id"] = id,
|
||||
["author"] = author ?? string.Empty
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
public static IReadOnlyList<MusicPlaybackSource> ConvertAudioStreams(IEnumerable<IAudioStreamInfo> streams)
|
||||
{
|
||||
return streams
|
||||
.OrderByDescending(i => IsPreferredContainer(i))
|
||||
.ThenByDescending(i => i.Bitrate.BitsPerSecond)
|
||||
.Take(3)
|
||||
.Select(i => new MusicPlaybackSource
|
||||
{
|
||||
provider_id = "youtubeaudio",
|
||||
url = i.Url,
|
||||
mime_type = GetMimeType(i),
|
||||
bitrate = (int)Math.Max(1L, i.Bitrate.BitsPerSecond / 1000),
|
||||
quality = $"{GetContainerName(i).ToUpperInvariant()} {Math.Max(1L, i.Bitrate.BitsPerSecond / 1000)}kbps",
|
||||
headers = new Dictionary<string, string>
|
||||
{
|
||||
["Referer"] = "https://www.youtube.com/",
|
||||
["Origin"] = "https://www.youtube.com"
|
||||
}
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static IReadOnlyList<MusicPlaybackSource> ConvertVideoStreams(IEnumerable<MuxedStreamInfo> streams)
|
||||
{
|
||||
return streams
|
||||
.OrderByDescending(i => IsPreferredVideoContainer(i))
|
||||
.ThenByDescending(i => i.VideoQuality.MaxHeight)
|
||||
.ThenByDescending(i => i.Bitrate.BitsPerSecond)
|
||||
.Take(3)
|
||||
.Select(i => new MusicPlaybackSource
|
||||
{
|
||||
provider_id = "youtubeaudio",
|
||||
url = i.Url,
|
||||
mime_type = GetVideoMimeType(i),
|
||||
bitrate = (int)Math.Max(1L, i.Bitrate.BitsPerSecond / 1000),
|
||||
quality = BuildVideoQualityLabel(i),
|
||||
headers = new Dictionary<string, string>
|
||||
{
|
||||
["Referer"] = "https://www.youtube.com/",
|
||||
["Origin"] = "https://www.youtube.com"
|
||||
}
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
static bool IsPreferredContainer(IAudioStreamInfo stream)
|
||||
{
|
||||
string name = GetContainerName(stream);
|
||||
return name is "m4a" or "mp4";
|
||||
}
|
||||
|
||||
static bool IsPreferredVideoContainer(MuxedStreamInfo stream)
|
||||
{
|
||||
string name = GetContainerName(stream);
|
||||
return name is "mp4" or "m4v";
|
||||
}
|
||||
|
||||
static string GetContainerName(IStreamInfo stream)
|
||||
=> stream.Container.Name?.ToLowerInvariant() ?? stream.Container.ToString().ToLowerInvariant();
|
||||
|
||||
static string GetMimeType(IAudioStreamInfo stream)
|
||||
{
|
||||
string container = GetContainerName(stream);
|
||||
return container is "m4a" or "mp4" ? "audio/mp4" : "audio/webm";
|
||||
}
|
||||
|
||||
static string GetVideoMimeType(MuxedStreamInfo stream)
|
||||
{
|
||||
string container = GetContainerName(stream);
|
||||
return container is "mp4" or "m4v" ? "video/mp4" : "video/webm";
|
||||
}
|
||||
|
||||
static string BuildVideoQualityLabel(MuxedStreamInfo stream)
|
||||
{
|
||||
string container = GetContainerName(stream).ToUpperInvariant();
|
||||
string height = !string.IsNullOrWhiteSpace(stream.VideoQuality.Label) ? stream.VideoQuality.Label : $"{stream.VideoQuality.MaxHeight}p";
|
||||
long bitrate = Math.Max(1L, stream.Bitrate.BitsPerSecond / 1000);
|
||||
return $"{container} {height} {bitrate}kbps";
|
||||
}
|
||||
|
||||
static string NormalizeText(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return string.Empty;
|
||||
|
||||
value = Regex.Replace(value, @"[\(\)\[\]\{\}\-_]+", " ");
|
||||
value = value.Trim().ToLowerInvariant().Normalize(NormalizationForm.FormD);
|
||||
|
||||
var builder = new StringBuilder(value.Length);
|
||||
foreach (char ch in value)
|
||||
{
|
||||
if (CharUnicodeInfo.GetUnicodeCategory(ch) == UnicodeCategory.NonSpacingMark)
|
||||
continue;
|
||||
|
||||
if (char.IsLetterOrDigit(ch) || char.IsWhiteSpace(ch))
|
||||
builder.Append(ch);
|
||||
}
|
||||
|
||||
return Regex.Replace(builder.ToString(), @"\s+", " ").Trim();
|
||||
}
|
||||
|
||||
static string NormalizeTitleForMatch(string value) => NormalizeText(StripFeatureSegment(value));
|
||||
|
||||
static List<string> BuildQueryArtists(string primaryArtist, IEnumerable<string> artists)
|
||||
{
|
||||
var result = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
void add(string value)
|
||||
{
|
||||
value = value?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return;
|
||||
|
||||
var key = NormalizeText(value);
|
||||
if (string.IsNullOrWhiteSpace(key) || !seen.Add(key))
|
||||
return;
|
||||
|
||||
result.Add(value);
|
||||
}
|
||||
|
||||
add(primaryArtist);
|
||||
|
||||
foreach (var artist in artists ?? Array.Empty<string>())
|
||||
{
|
||||
add(artist);
|
||||
if (result.Count >= 3)
|
||||
break;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static string NormalizePrimaryTitleForMatch(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return string.Empty;
|
||||
|
||||
value = StripFeatureSegment(value);
|
||||
value = Regex.Replace(value, @"[\(\[\{][^)\]\}]+[\)\]\}]", " ");
|
||||
value = Regex.Replace(value, @"\s+[|/]\s+.*$", " ");
|
||||
return NormalizeText(value);
|
||||
}
|
||||
|
||||
static string CompactText(string value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? string.Empty : value.Replace(" ", string.Empty);
|
||||
|
||||
static bool LooksLikeShortTrackQuery(string normalizedTitle)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(normalizedTitle))
|
||||
return false;
|
||||
|
||||
return GenericTrackTitles.Contains(normalizedTitle)
|
||||
|| normalizedTitle.Contains("intro", StringComparison.Ordinal)
|
||||
|| normalizedTitle.Contains("outro", StringComparison.Ordinal)
|
||||
|| normalizedTitle.Contains("skit", StringComparison.Ordinal)
|
||||
|| normalizedTitle.Contains("interlude", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
static bool ContainsTokenPhrase(string value, string phrase)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value) || string.IsNullOrWhiteSpace(phrase))
|
||||
return false;
|
||||
|
||||
if (value == phrase)
|
||||
return true;
|
||||
|
||||
return value.StartsWith($"{phrase} ", StringComparison.Ordinal)
|
||||
|| value.EndsWith($" {phrase}", StringComparison.Ordinal)
|
||||
|| value.Contains($" {phrase} ", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
static bool HasArtistMatch(MusicTrack query, MusicAudioMatch match)
|
||||
{
|
||||
var expectedArtists = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(query?.artist_name))
|
||||
expectedArtists.Add(query.artist_name);
|
||||
|
||||
foreach (var artist in query?.artists ?? new List<string>())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(artist))
|
||||
expectedArtists.Add(artist);
|
||||
}
|
||||
|
||||
var normalizedExpected = expectedArtists
|
||||
.Select(NormalizeText)
|
||||
.Where(i => !string.IsNullOrWhiteSpace(i))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (normalizedExpected.Count == 0)
|
||||
return false;
|
||||
|
||||
var normalizedCandidateArtists = (match?.artists ?? new List<string>())
|
||||
.Select(NormalizeText)
|
||||
.Where(i => !string.IsNullOrWhiteSpace(i))
|
||||
.ToList();
|
||||
|
||||
string rawCandidateText = NormalizeText($"{match?.title} {string.Join(" ", match?.artists ?? new List<string>())}");
|
||||
|
||||
foreach (var expected in normalizedExpected)
|
||||
{
|
||||
string compactExpected = CompactText(expected);
|
||||
if (string.IsNullOrWhiteSpace(compactExpected))
|
||||
continue;
|
||||
|
||||
foreach (var candidate in normalizedCandidateArtists)
|
||||
{
|
||||
string compactCandidate = CompactText(candidate);
|
||||
if (candidate == expected
|
||||
|| candidate.Contains(expected, StringComparison.Ordinal)
|
||||
|| expected.Contains(candidate, StringComparison.Ordinal)
|
||||
|| compactCandidate == compactExpected
|
||||
|| compactCandidate == $"{compactExpected}vevo"
|
||||
|| compactCandidate == $"{compactExpected}music"
|
||||
|| compactCandidate == $"{compactExpected}topic")
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(rawCandidateText) && ContainsTokenPhrase(rawCandidateText, expected))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool HasTightDurationMatch(MusicTrack query, MusicAudioMatch match, int toleranceMs)
|
||||
{
|
||||
if (query?.duration_ms.HasValue != true || match?.duration_ms.HasValue != true)
|
||||
return false;
|
||||
|
||||
return Math.Abs(query.duration_ms.Value - match.duration_ms.Value) <= toleranceMs;
|
||||
}
|
||||
|
||||
static bool HasUnexpectedEarlyExitNoise(MusicTrack query, MusicAudioMatch match)
|
||||
{
|
||||
string queryContext = NormalizeText($"{query?.title} {query?.album_title}");
|
||||
string candidateContext = NormalizeText($"{match?.title} {match?.album_title} {string.Join(" ", match?.artists ?? new List<string>())}");
|
||||
if (string.IsNullOrWhiteSpace(candidateContext))
|
||||
return false;
|
||||
|
||||
foreach (var word in EarlyExitPenaltyWords)
|
||||
{
|
||||
string normalizedWord = NormalizeText(word);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedWord)
|
||||
&& ContainsTokenPhrase(candidateContext, normalizedWord)
|
||||
&& !ContainsTokenPhrase(queryContext, normalizedWord))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool HasUnexpectedStrongVariant(string queryContext, string candidateContext)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidateContext))
|
||||
return false;
|
||||
|
||||
foreach (var word in StrongVariantWords.Concat(LiveVariantWords))
|
||||
{
|
||||
string normalizedWord = NormalizeText(word);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedWord)
|
||||
&& ContainsTokenPhrase(candidateContext, normalizedWord)
|
||||
&& !ContainsTokenPhrase(queryContext, normalizedWord))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static string StripFeatureSegment(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return string.Empty;
|
||||
|
||||
string original = value.Trim();
|
||||
string stripped = Regex.Replace(
|
||||
original,
|
||||
@"\s*[\(\[\{]\s*(?:feat\.?|ft\.?|featuring)\s*[:\-]?\s+[^)\]\}]+[\)\]\}]\s*",
|
||||
" ",
|
||||
RegexOptions.IgnoreCase);
|
||||
|
||||
stripped = Regex.Replace(
|
||||
stripped,
|
||||
@"\s+(?:feat\.?|ft\.?|featuring)\s*[:\-]?\s+.+$",
|
||||
" ",
|
||||
RegexOptions.IgnoreCase);
|
||||
|
||||
stripped = Regex.Replace(stripped, @"\s+", " ").Trim();
|
||||
return string.IsNullOrWhiteSpace(stripped) ? original : stripped;
|
||||
}
|
||||
|
||||
static bool ShouldAppendAlbumHint(string title)
|
||||
{
|
||||
var normalized = NormalizeText(title);
|
||||
if (string.IsNullOrWhiteSpace(normalized))
|
||||
return false;
|
||||
|
||||
if (GenericTrackTitles.Contains(normalized))
|
||||
return true;
|
||||
|
||||
return normalized.Split(' ', StringSplitOptions.RemoveEmptyEntries).Length <= 2;
|
||||
}
|
||||
|
||||
static string ResolveVideoId(MusicTrack track)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(track?.id) && track.id.StartsWith("youtube:", StringComparison.OrdinalIgnoreCase))
|
||||
return track.id["youtube:".Length..].Trim();
|
||||
|
||||
if (track?.provider_refs == null)
|
||||
return null;
|
||||
|
||||
foreach (var providerRef in track.provider_refs)
|
||||
{
|
||||
if (!string.Equals(providerRef?.provider, "youtubeaudio", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(providerRef.external_id))
|
||||
return providerRef.external_id.Trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static (bool relevant, bool exact, bool allTokens, double coverage) EvaluateTitleMatch(string queryTitle, string title, string fullTitle)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(queryTitle))
|
||||
return (true, false, false, 0d);
|
||||
|
||||
bool exact = !string.IsNullOrWhiteSpace(title) && title == queryTitle;
|
||||
|
||||
var queryTokens = queryTitle
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (queryTokens.Length == 0)
|
||||
return (true, exact, false, 0d);
|
||||
|
||||
int matchedTokens = queryTokens.Count(token =>
|
||||
(!string.IsNullOrWhiteSpace(title) && title.Contains(token, StringComparison.Ordinal))
|
||||
|| (!string.IsNullOrWhiteSpace(fullTitle) && fullTitle.Contains(token, StringComparison.Ordinal)));
|
||||
|
||||
double coverage = matchedTokens / (double)queryTokens.Length;
|
||||
bool allTokens = matchedTokens == queryTokens.Length;
|
||||
bool relevant = exact
|
||||
|| allTokens
|
||||
|| (queryTokens.Length >= 3 && coverage >= 0.75d);
|
||||
return (relevant, exact, allTokens, coverage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace Music;
|
||||
|
||||
public class Z3FmAudioProvider : IMusicAudioProvider
|
||||
{
|
||||
public string Id => Z3FmSupport.AudioProviderId;
|
||||
public string Name => "Z3.FM";
|
||||
public bool Enabled => Z3FmSupport.IsAudioEnabled;
|
||||
public bool RequiresAuth => false;
|
||||
public bool CacheMissingMatches => false;
|
||||
|
||||
public Task<IReadOnlyList<MusicAudioMatch>> MatchTrackAsync(MusicTrack track, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!Enabled || track == null)
|
||||
return Task.FromResult<IReadOnlyList<MusicAudioMatch>>(Array.Empty<MusicAudioMatch>());
|
||||
|
||||
return Z3FmSupport.SearchAsync(track, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MusicPlaybackSource>> GetStreamsAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!Enabled || match == null)
|
||||
return Task.FromResult<IReadOnlyList<MusicPlaybackSource>>(Array.Empty<MusicPlaybackSource>());
|
||||
|
||||
return Z3FmSupport.BuildSourcesAsync(match, cancellationToken);
|
||||
}
|
||||
|
||||
public Task<MusicPlaybackSource> TryGetPreferredStreamAsync(MusicAudioMatch match, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<MusicPlaybackSource>(null);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<MusicAudioMatch>> SearchMatchesByQueryAsync(string query, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<MusicAudioMatch>>(Array.Empty<MusicAudioMatch>());
|
||||
}
|
||||
|
||||
public bool IsRelevantMatch(MusicTrack track, MusicAudioMatch match)
|
||||
{
|
||||
return Z3FmSupport.IsRelevantMatch(track, match);
|
||||
}
|
||||
|
||||
public bool ShouldValidatePinnedMatch(MusicTrack track, MusicAudioMatch match)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> GetFallbackProviderIds(MusicTrack track)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,626 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Playwright;
|
||||
using Shared.Models.Base;
|
||||
using Shared.PlaywrightCore;
|
||||
using Shared.Services.Utilities;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Music;
|
||||
|
||||
public static class Z3FmSupport
|
||||
{
|
||||
public const string AudioProviderId = "z3fmaudio";
|
||||
public const string BaseUrl = "https://z3.fm";
|
||||
public const string SearchPath = "/mp3/search";
|
||||
|
||||
static readonly MemoryCache cookieCache = new(new MemoryCacheOptions());
|
||||
static readonly TimeSpan cookieLifetime = TimeSpan.FromMinutes(20);
|
||||
static readonly Regex resultBlockRegex = new(
|
||||
"<div class=\"song song-wrap[^\"]*\">(?<block>.*?)<span[^>]*data-time=\"(?<seconds>\\d+)\"[^>]*data-sid=\"(?<sid>\\d+)\"[^>]*data-url=\"(?<download>/download/\\d+)\"[^>]*data-title=\"(?<fulltitle>[^\"]+)\"[^>]*class=\"song-play[^\"]*\"",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled
|
||||
);
|
||||
static readonly Regex artistRegex = new(
|
||||
"<div class=\"song-artist\">.*?<a[^>]*href=\"(?<href>/artist/\\d+)\"[^>]*>(?<inner>.*?)</a>",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled
|
||||
);
|
||||
static readonly Regex titleRegex = new(
|
||||
"<div class=\"song-name\">.*?<a[^>]*href=\"(?<href>/song/\\d+)\"[^>]*>(?<inner>.*?)</a>",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled
|
||||
);
|
||||
static readonly Regex songPageDownloadRegex = new(
|
||||
"data-sid=\"(?<sid>\\d+)\"\\s+data-url=\"(?<download>/download/\\d+)\"",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled
|
||||
);
|
||||
static readonly string[] noisyTerms =
|
||||
{
|
||||
"remix", "mix", "instrumental", "karaoke", "spedup", "slowed", "nightcore", "8d", "cover", "live", "edit", "extended"
|
||||
};
|
||||
|
||||
public static bool IsModuleEnabled => ModInit.conf?.z3fm_enabled == true;
|
||||
public static bool IsAudioEnabled => IsModuleEnabled && ModInit.conf?.z3fm_audio_enabled == true;
|
||||
public static bool IsProxyEnabled => IsModuleEnabled && ModInit.conf?.z3fm_proxy_enabled == true;
|
||||
|
||||
public static string BuildSearchUrl(string query)
|
||||
{
|
||||
query = string.IsNullOrWhiteSpace(query) ? string.Empty : query.Trim();
|
||||
return $"{BaseUrl.TrimEnd('/')}{SearchPath}?keywords={Uri.EscapeDataString(query)}";
|
||||
}
|
||||
|
||||
public static Dictionary<string, string> CreateBrowserHeaders()
|
||||
{
|
||||
return new Dictionary<string, string>
|
||||
{
|
||||
["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
["Accept-Language"] = "ru,en-US;q=0.9,en;q=0.8",
|
||||
["Cache-Control"] = "no-cache",
|
||||
["Pragma"] = "no-cache",
|
||||
["User-Agent"] = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7; rv:139.0) Gecko/20100101 Firefox/139.0"
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task<IReadOnlyList<MusicAudioMatch>> SearchAsync(MusicTrack track, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (track == null || string.IsNullOrWhiteSpace(track.title))
|
||||
return Array.Empty<MusicAudioMatch>();
|
||||
|
||||
var matches = new Dictionary<string, MusicAudioMatch>(StringComparer.Ordinal);
|
||||
foreach (var query in BuildQueryVariants(track))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var page = await FetchPageAsync(BuildSearchUrl(query), cancellationToken);
|
||||
if (page == null || string.IsNullOrWhiteSpace(page.Html))
|
||||
continue;
|
||||
|
||||
foreach (var item in ParseSearchResults(page.Html))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(page.CookieHeader))
|
||||
{
|
||||
cookieCache.Set(BuildCookieCacheKey(item.sid), new Z3FmCookieContext
|
||||
{
|
||||
CookieHeader = page.CookieHeader,
|
||||
Referer = string.IsNullOrWhiteSpace(item.trackUrl) ? page.Url : item.trackUrl
|
||||
}, cookieLifetime);
|
||||
}
|
||||
|
||||
if (matches.ContainsKey(item.sid))
|
||||
continue;
|
||||
|
||||
matches[item.sid] = new MusicAudioMatch
|
||||
{
|
||||
provider_id = AudioProviderId,
|
||||
id = item.sid,
|
||||
title = item.title,
|
||||
artists = string.IsNullOrWhiteSpace(item.artist) ? new List<string>() : new List<string> { item.artist },
|
||||
duration_ms = item.durationMs,
|
||||
payload = MusicJson.Serialize(new Z3FmMatchPayload
|
||||
{
|
||||
sid = item.sid,
|
||||
download_url = item.downloadUrl,
|
||||
track_url = item.trackUrl
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
if (matches.Count > 0)
|
||||
break;
|
||||
}
|
||||
|
||||
return RankMatches(track, matches.Values).Take(20).ToList();
|
||||
}
|
||||
|
||||
public static async Task<IReadOnlyList<MusicPlaybackSource>> BuildSourcesAsync(MusicAudioMatch match, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var payload = string.IsNullOrWhiteSpace(match?.payload)
|
||||
? null
|
||||
: MusicJson.Deserialize<Z3FmMatchPayload>(match.payload);
|
||||
|
||||
if (payload == null || string.IsNullOrWhiteSpace(payload.sid))
|
||||
return Array.Empty<MusicPlaybackSource>();
|
||||
|
||||
var context = await EnsureCookieContextAsync(payload, cancellationToken);
|
||||
string url = BuildAbsoluteUrl(payload.download_url);
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return Array.Empty<MusicPlaybackSource>();
|
||||
|
||||
var headers = new Dictionary<string, string>
|
||||
{
|
||||
["Referer"] = context?.Referer ?? BuildAbsoluteUrl(payload.track_url) ?? $"{BaseUrl}/",
|
||||
["Origin"] = BaseUrl,
|
||||
["User-Agent"] = CreateBrowserHeaders()["User-Agent"]
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(context?.CookieHeader))
|
||||
headers["Cookie"] = context.CookieHeader;
|
||||
|
||||
var source = new MusicPlaybackSource
|
||||
{
|
||||
provider_id = AudioProviderId,
|
||||
url = url,
|
||||
mime_type = "audio/mpeg",
|
||||
quality = "MP3",
|
||||
headers = headers
|
||||
};
|
||||
|
||||
ApplyConfiguredProxy(source);
|
||||
return new List<MusicPlaybackSource> { source };
|
||||
}
|
||||
|
||||
public static List<MusicAudioMatch> RankMatches(MusicTrack query, IEnumerable<MusicAudioMatch> matches)
|
||||
{
|
||||
return matches
|
||||
.Select(match => new { match, score = ScoreMatch(query, match) })
|
||||
.Where(i => i.score >= 4)
|
||||
.OrderByDescending(i => i.score)
|
||||
.ThenBy(i => i.match.title)
|
||||
.Select(i => i.match)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public static bool IsRelevantMatch(MusicTrack query, MusicAudioMatch match)
|
||||
{
|
||||
return ScoreMatch(query, match) >= 6;
|
||||
}
|
||||
|
||||
public static void ApplyConfiguredProxy(MusicPlaybackSource source)
|
||||
{
|
||||
if (source == null)
|
||||
return;
|
||||
|
||||
var configured = ParseConfiguredProxy();
|
||||
if (configured == null)
|
||||
return;
|
||||
|
||||
source.proxy_url = configured.Server;
|
||||
source.proxy_username = configured.Username;
|
||||
source.proxy_password = configured.Password;
|
||||
}
|
||||
|
||||
public static WebProxy GetHttpProxy()
|
||||
{
|
||||
var configured = ParseConfiguredProxy();
|
||||
if (configured == null)
|
||||
return null;
|
||||
|
||||
var credentials = string.IsNullOrWhiteSpace(configured.Username)
|
||||
? null
|
||||
: new NetworkCredential(configured.Username, configured.Password ?? string.Empty);
|
||||
|
||||
return new WebProxy(configured.Server, false, null, credentials);
|
||||
}
|
||||
|
||||
public static (string ip, string username, string password) GetBrowserProxyData()
|
||||
{
|
||||
var configured = ParseConfiguredProxy();
|
||||
if (configured == null)
|
||||
return default;
|
||||
|
||||
return (configured.Server, configured.Username, configured.Password);
|
||||
}
|
||||
|
||||
public static bool HasConfiguredProxy() => ParseConfiguredProxy() != null;
|
||||
|
||||
static async Task<Z3FmCookieContext> EnsureCookieContextAsync(Z3FmMatchPayload payload, CancellationToken cancellationToken)
|
||||
{
|
||||
if (payload == null || string.IsNullOrWhiteSpace(payload.sid))
|
||||
return null;
|
||||
|
||||
if (cookieCache.TryGetValue(BuildCookieCacheKey(payload.sid), out Z3FmCookieContext cached) && cached != null)
|
||||
return cached;
|
||||
|
||||
string trackUrl = BuildAbsoluteUrl(payload.track_url);
|
||||
if (string.IsNullOrWhiteSpace(trackUrl))
|
||||
return null;
|
||||
|
||||
var page = await FetchPageAsync(trackUrl, cancellationToken);
|
||||
if (page == null)
|
||||
return null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(payload.download_url))
|
||||
{
|
||||
var songPage = ParseSongPage(page.Html);
|
||||
if (songPage != null && songPage.sid == payload.sid && !string.IsNullOrWhiteSpace(songPage.downloadUrl))
|
||||
payload.download_url = songPage.downloadUrl;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(page.CookieHeader))
|
||||
return null;
|
||||
|
||||
var context = new Z3FmCookieContext
|
||||
{
|
||||
CookieHeader = page.CookieHeader,
|
||||
Referer = trackUrl
|
||||
};
|
||||
|
||||
cookieCache.Set(BuildCookieCacheKey(payload.sid), context, cookieLifetime);
|
||||
return context;
|
||||
}
|
||||
|
||||
static IEnumerable<string> BuildQueryVariants(MusicTrack track)
|
||||
{
|
||||
var variants = new List<string>();
|
||||
|
||||
void add(string value)
|
||||
{
|
||||
value = value?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return;
|
||||
|
||||
if (variants.All(existing => !string.Equals(existing, value, StringComparison.OrdinalIgnoreCase)))
|
||||
variants.Add(value);
|
||||
}
|
||||
|
||||
add($"{track.artist_name} {track.title}");
|
||||
|
||||
if (track.artists != null && track.artists.Count > 0)
|
||||
{
|
||||
string credit = string.Join(' ', track.artists.Where(i => !string.IsNullOrWhiteSpace(i)));
|
||||
add($"{credit} {track.title}");
|
||||
}
|
||||
|
||||
add(track.title);
|
||||
return variants;
|
||||
}
|
||||
|
||||
static async Task<Z3FmBrowserPage> FetchPageAsync(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var headers = CreateBrowserHeaders();
|
||||
var proxyData = GetBrowserProxyData();
|
||||
MusicProxyLease proxyLease = null;
|
||||
if (string.IsNullOrWhiteSpace(proxyData.ip))
|
||||
{
|
||||
proxyLease = MusicProxyService.Acquire(AudioProviderId, MusicProxyPurpose.Api);
|
||||
proxyData = proxyLease.Data;
|
||||
}
|
||||
|
||||
var init = new BaseSettings
|
||||
{
|
||||
plugin = "music.z3fm",
|
||||
priorityBrowser = "firefox"
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
using var browser = new PlaywrightBrowser(init.priorityBrowser);
|
||||
var page = await browser.NewPageAsync(init.plugin, headers, proxyData, keepopen: true, imitationHuman: false, deferredDispose: false).ConfigureAwait(false);
|
||||
if (page == null)
|
||||
return null;
|
||||
|
||||
var response = await page.GotoAsync(url, new PageGotoOptions
|
||||
{
|
||||
Timeout = 15_000,
|
||||
WaitUntil = WaitUntilState.DOMContentLoaded
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
string html = null;
|
||||
if (response != null)
|
||||
html = await response.TextAsync().ConfigureAwait(false);
|
||||
|
||||
html ??= await page.ContentAsync().ConfigureAwait(false);
|
||||
if (string.IsNullOrWhiteSpace(html) || IsBlockedHtml(html))
|
||||
{
|
||||
proxyLease?.Failure();
|
||||
return null;
|
||||
}
|
||||
|
||||
var cookies = await page.Context.CookiesAsync().ConfigureAwait(false);
|
||||
proxyLease?.Success();
|
||||
return new Z3FmBrowserPage
|
||||
{
|
||||
Url = url,
|
||||
Html = html,
|
||||
CookieHeader = BuildCookieHeader(cookies)
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
proxyLease?.Failure();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static IEnumerable<Z3FmParsedEntry> ParseSearchResults(string html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
yield break;
|
||||
|
||||
foreach (Match match in resultBlockRegex.Matches(html))
|
||||
{
|
||||
string block = match.Groups["block"].Value;
|
||||
string sid = match.Groups["sid"].Value;
|
||||
string downloadUrl = match.Groups["download"].Value;
|
||||
string fullTitle = HtmlDecode(match.Groups["fulltitle"].Value);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(sid) || string.IsNullOrWhiteSpace(downloadUrl))
|
||||
continue;
|
||||
|
||||
string title = ExtractTitle(block);
|
||||
string artist = ExtractArtist(block);
|
||||
string trackUrl = ExtractTrackUrl(block);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artist))
|
||||
(artist, title) = SplitFullTitle(fullTitle);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artist))
|
||||
continue;
|
||||
|
||||
int? durationMs = null;
|
||||
if (int.TryParse(match.Groups["seconds"].Value, out int seconds) && seconds > 0)
|
||||
durationMs = seconds * 1000;
|
||||
|
||||
yield return new Z3FmParsedEntry
|
||||
{
|
||||
sid = sid,
|
||||
artist = artist,
|
||||
title = title,
|
||||
downloadUrl = BuildAbsoluteUrl(downloadUrl),
|
||||
trackUrl = BuildAbsoluteUrl(trackUrl),
|
||||
durationMs = durationMs
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
static Z3FmParsedEntry ParseSongPage(string html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
return null;
|
||||
|
||||
var match = songPageDownloadRegex.Match(html);
|
||||
if (!match.Success)
|
||||
return null;
|
||||
|
||||
return new Z3FmParsedEntry
|
||||
{
|
||||
sid = match.Groups["sid"].Value,
|
||||
downloadUrl = BuildAbsoluteUrl(match.Groups["download"].Value)
|
||||
};
|
||||
}
|
||||
|
||||
static string ExtractArtist(string block)
|
||||
{
|
||||
var match = artistRegex.Match(block ?? string.Empty);
|
||||
if (!match.Success)
|
||||
return null;
|
||||
|
||||
return CleanInlineText(match.Groups["inner"].Value);
|
||||
}
|
||||
|
||||
static string ExtractTitle(string block)
|
||||
{
|
||||
var match = titleRegex.Match(block ?? string.Empty);
|
||||
if (!match.Success)
|
||||
return null;
|
||||
|
||||
return CleanInlineText(match.Groups["inner"].Value);
|
||||
}
|
||||
|
||||
static string ExtractTrackUrl(string block)
|
||||
{
|
||||
var match = titleRegex.Match(block ?? string.Empty);
|
||||
return match.Success ? match.Groups["href"].Value : null;
|
||||
}
|
||||
|
||||
static (string artist, string title) SplitFullTitle(string fullTitle)
|
||||
{
|
||||
fullTitle = CleanInlineText(fullTitle);
|
||||
if (string.IsNullOrWhiteSpace(fullTitle))
|
||||
return (null, null);
|
||||
|
||||
int delimiter = fullTitle.IndexOf(" - ", StringComparison.Ordinal);
|
||||
if (delimiter <= 0 || delimiter >= fullTitle.Length - 3)
|
||||
return (null, fullTitle);
|
||||
|
||||
return (fullTitle[..delimiter].Trim(), fullTitle[(delimiter + 3)..].Trim());
|
||||
}
|
||||
|
||||
static int ScoreMatch(MusicTrack query, MusicAudioMatch match)
|
||||
{
|
||||
string queryTitle = SearchNameTo.Convert(query?.title, string.Empty);
|
||||
string matchTitle = SearchNameTo.Convert(match?.title, string.Empty);
|
||||
string fullMatchTitle = SearchNameTo.Convert($"{match?.artists?.FirstOrDefault()} {match?.title}", string.Empty);
|
||||
string queryPrimaryArtist = BuildArtistFragments(query?.artist_name).FirstOrDefault() ?? string.Empty;
|
||||
var matchArtists = BuildArtistFragments(match?.artists?.FirstOrDefault());
|
||||
|
||||
int score = 0;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(queryTitle))
|
||||
{
|
||||
if (string.Equals(matchTitle, queryTitle, StringComparison.Ordinal))
|
||||
score += 18;
|
||||
else if (!string.IsNullOrWhiteSpace(matchTitle) && matchTitle.Contains(queryTitle, StringComparison.Ordinal))
|
||||
score += 10;
|
||||
else if (AllTokensPresent(queryTitle, matchTitle))
|
||||
score += 5;
|
||||
else
|
||||
score -= 14;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(queryPrimaryArtist))
|
||||
{
|
||||
if (matchArtists.Contains(queryPrimaryArtist) || string.Equals(SearchNameTo.Convert(match?.artists?.FirstOrDefault(), string.Empty), queryPrimaryArtist, StringComparison.Ordinal))
|
||||
score += 12;
|
||||
else if (matchArtists.Any(a => a.Contains(queryPrimaryArtist, StringComparison.Ordinal)))
|
||||
score += 6;
|
||||
else
|
||||
score -= 8;
|
||||
}
|
||||
|
||||
if (query?.duration_ms.HasValue == true && match?.duration_ms.HasValue == true)
|
||||
{
|
||||
int delta = Math.Abs(query.duration_ms.Value - match.duration_ms.Value);
|
||||
if (delta <= 2_000) score += 8;
|
||||
else if (delta <= 5_000) score += 6;
|
||||
else if (delta <= 10_000) score += 3;
|
||||
else if (delta <= 20_000) score += 1;
|
||||
else score -= 2;
|
||||
}
|
||||
|
||||
string queryNoiseBase = $"{SearchNameTo.Convert(query?.title, string.Empty)} {SearchNameTo.Convert(query?.artist_name, string.Empty)}";
|
||||
foreach (var term in noisyTerms)
|
||||
{
|
||||
if (!queryNoiseBase.Contains(term, StringComparison.Ordinal) && fullMatchTitle.Contains(term, StringComparison.Ordinal))
|
||||
score -= 6;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
static List<string> BuildArtistFragments(string artist)
|
||||
{
|
||||
artist = artist?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(artist))
|
||||
return new List<string>();
|
||||
|
||||
return artist
|
||||
.Split(new[] { ",", "&", " x ", " feat. ", " feat ", " ft. ", " ft ", " and " }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(i => SearchNameTo.Convert(i, string.Empty))
|
||||
.Where(i => !string.IsNullOrWhiteSpace(i))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
static bool AllTokensPresent(string query, string target)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query) || string.IsNullOrWhiteSpace(target))
|
||||
return false;
|
||||
|
||||
var tokens = query.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
return tokens.Length > 0 && tokens.All(token => target.Contains(token, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
static string CleanInlineText(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return null;
|
||||
|
||||
value = Regex.Replace(value, "<.*?>", string.Empty, RegexOptions.Singleline);
|
||||
value = HtmlDecode(value);
|
||||
value = value.Replace("\r", " ").Replace("\n", " ").Trim();
|
||||
return Regex.Replace(value, "\\s{2,}", " ").Trim();
|
||||
}
|
||||
|
||||
static string HtmlDecode(string value) => WebUtility.HtmlDecode(value ?? string.Empty);
|
||||
|
||||
static string BuildAbsoluteUrl(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return null;
|
||||
|
||||
value = value.Trim();
|
||||
if (value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) || value.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
|
||||
return value;
|
||||
|
||||
if (!value.StartsWith('/'))
|
||||
value = "/" + value;
|
||||
|
||||
return BaseUrl + value;
|
||||
}
|
||||
|
||||
static string BuildCookieHeader(IReadOnlyList<BrowserContextCookiesResult> cookies)
|
||||
{
|
||||
if (cookies == null || cookies.Count == 0)
|
||||
return null;
|
||||
|
||||
var parts = cookies
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.Name) && c.Value != null)
|
||||
.Select(c => $"{c.Name}={c.Value}")
|
||||
.ToArray();
|
||||
|
||||
return parts.Length == 0 ? null : string.Join("; ", parts);
|
||||
}
|
||||
|
||||
static bool IsBlockedHtml(string html)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(html))
|
||||
return true;
|
||||
|
||||
return html.Contains("Just a moment", StringComparison.OrdinalIgnoreCase)
|
||||
|| html.Contains("cf-challenge", StringComparison.OrdinalIgnoreCase)
|
||||
|| html.Contains("challenge-platform", StringComparison.OrdinalIgnoreCase)
|
||||
|| html.Contains("Attention Required", StringComparison.OrdinalIgnoreCase)
|
||||
|| html.Contains("Access denied", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
static string BuildCookieCacheKey(string sid) => $"z3fm:cookie:{sid}";
|
||||
|
||||
sealed class ProxyConfiguration
|
||||
{
|
||||
public string Server { get; init; }
|
||||
public string Username { get; init; }
|
||||
public string Password { get; init; }
|
||||
}
|
||||
|
||||
sealed class Z3FmBrowserPage
|
||||
{
|
||||
public string Url { get; init; }
|
||||
public string Html { get; init; }
|
||||
public string CookieHeader { get; init; }
|
||||
}
|
||||
|
||||
sealed class Z3FmCookieContext
|
||||
{
|
||||
public string CookieHeader { get; init; }
|
||||
public string Referer { get; init; }
|
||||
}
|
||||
|
||||
sealed class Z3FmParsedEntry
|
||||
{
|
||||
public string sid { get; init; }
|
||||
public string artist { get; init; }
|
||||
public string title { get; init; }
|
||||
public string trackUrl { get; set; }
|
||||
public string downloadUrl { get; set; }
|
||||
public int? durationMs { get; init; }
|
||||
}
|
||||
|
||||
sealed class Z3FmMatchPayload
|
||||
{
|
||||
public string sid { get; set; }
|
||||
public string track_url { get; set; }
|
||||
public string download_url { get; set; }
|
||||
}
|
||||
|
||||
static ProxyConfiguration ParseConfiguredProxy()
|
||||
{
|
||||
if (!IsProxyEnabled)
|
||||
return null;
|
||||
|
||||
string raw = ModInit.conf?.z3fm_proxy_url?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return null;
|
||||
|
||||
if (!raw.Contains("://", StringComparison.Ordinal))
|
||||
raw = "http://" + raw;
|
||||
|
||||
if (!Uri.TryCreate(raw, UriKind.Absolute, out var uri) || string.IsNullOrWhiteSpace(uri.Host))
|
||||
return null;
|
||||
|
||||
string username = ModInit.conf?.z3fm_proxy_username?.Trim();
|
||||
string password = ModInit.conf?.z3fm_proxy_password ?? string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username) && !string.IsNullOrWhiteSpace(uri.UserInfo))
|
||||
{
|
||||
var parts = uri.UserInfo.Split(':', 2, StringSplitOptions.None);
|
||||
username = Uri.UnescapeDataString(parts[0]);
|
||||
password = parts.Length > 1 ? Uri.UnescapeDataString(parts[1]) : string.Empty;
|
||||
}
|
||||
|
||||
var builder = new UriBuilder(uri)
|
||||
{
|
||||
UserName = string.Empty,
|
||||
Password = string.Empty
|
||||
};
|
||||
|
||||
return new ProxyConfiguration
|
||||
{
|
||||
Server = builder.Uri.GetLeftPart(UriPartial.Authority),
|
||||
Username = string.IsNullOrWhiteSpace(username) ? null : username,
|
||||
Password = string.IsNullOrWhiteSpace(username) ? null : password
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user