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

This commit is contained in:
2026-10-02 12:11:49 +03:00
parent 8dcbc6bc52
commit 7b8e28b3bf
151 changed files with 68625 additions and 44 deletions
@@ -0,0 +1,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
};
}
}