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> SearchAsync(MusicTrack track, CancellationToken cancellationToken = default) { var queries = BuildTrackQueries(track).Take(MaxSearchQueries).ToList(); if (queries.Count == 0) return new List(); var merged = new Dictionary(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> SearchTracksByQueryAsync(string query, int limit = 10, CancellationToken cancellationToken = default) { if (string.IsNullOrWhiteSpace(query)) return new List(); var matches = await SearchQuerySafeAsync(query.Trim(), cancellationToken); if (matches.Count == 0) return new List(); 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> SearchQueryAsync(string query, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(query)) return new List(); 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(); } string html = await response.Content.ReadAsStringAsync(cancellationToken); if (string.IsNullOrWhiteSpace(html)) return new List(); 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(); var matches = new List(); 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() : new List { 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> SearchQuerySafeAsync(string query, CancellationToken cancellationToken) { try { return await SearchQueryAsync(query, cancellationToken); } catch { return new List(); } } static async Task<(List matches, Exception error)> SearchQueryResultAsync(string query, CancellationToken cancellationToken) { try { return (await SearchQueryAsync(query, cancellationToken), null); } catch (OperationCanceledException) { throw; } catch (Exception ex) { return (new List(), ex); } } public static IReadOnlyList BuildSources(MusicAudioMatch match) { var payload = string.IsNullOrWhiteSpace(match?.payload) ? null : MusicJson.Deserialize(match.payload); string sourceUrl = !string.IsNullOrWhiteSpace(payload?.stream_direct_url) ? payload.stream_direct_url : payload?.stream_url; if (string.IsNullOrWhiteSpace(sourceUrl)) return Array.Empty(); var headers = new Dictionary(); if (string.IsNullOrWhiteSpace(payload?.stream_direct_url)) { headers["Referer"] = string.IsNullOrWhiteSpace(payload.referer) ? $"{BaseUrl}/" : payload.referer; headers["Origin"] = BaseUrl; } return new List { new() { provider_id = ProviderId, url = sourceUrl, mime_type = "audio/mpeg", quality = "MP3", headers = headers } }; } public static List RankMatches(MusicTrack query, IEnumerable 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() : new List { 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 BuildTrackQueries(MusicTrack track) { var queries = new List(); 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 BuildNormalizedTitleVariants(string value) { var variants = new List(); 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 normalizedTitles) { return normalizedTitles.Any(i => i.Contains("intro") || i.Contains("outro") || i.Contains("interlude") || i.Contains("skit") || i.Contains("snippet")); } static List NormalizeArtistFragments(string value) { if (string.IsNullOrWhiteSpace(value)) return new List(); 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(); } }