Исправление: том ./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,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();
}
}