Исправление: том ./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
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,197 @@
using Microsoft.Data.Sqlite;
namespace Music;
public static class MusicPlaybackHistoryService
{
public static async Task SaveAsync(string profileId, MusicTrack track, CancellationToken cancellationToken = default)
{
profileId = NormalizeProfileId(profileId);
if (track == null || string.IsNullOrWhiteSpace(track.id) || string.IsNullOrWhiteSpace(profileId))
return;
var semaphore = new SemaphorManager(MusicContext.semaphoreKey, TimeSpan.FromSeconds(20));
try
{
if (!await semaphore.WaitAsync())
return;
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = """
INSERT INTO playback_history (profile_id, track_id, payload, updated)
VALUES ($profile_id, $track_id, $payload, $updated)
ON CONFLICT(profile_id, track_id) DO UPDATE SET
payload = excluded.payload,
updated = excluded.updated;
""";
command.Parameters.AddWithValue("$profile_id", profileId);
command.Parameters.AddWithValue("$track_id", track.id);
command.Parameters.AddWithValue("$payload", MusicJson.Serialize(track));
command.Parameters.AddWithValue("$updated", DateTime.UtcNow);
await command.ExecuteNonQueryAsync(cancellationToken);
}
finally
{
semaphore.Release();
}
}
public static async Task<List<MusicRecentlyPlayedItem>> GetRecentAsync(string profileId, int limit = 12, CancellationToken cancellationToken = default)
{
profileId = NormalizeProfileId(profileId);
var items = new List<MusicRecentlyPlayedItem>();
if (string.IsNullOrWhiteSpace(profileId))
return items;
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = """
SELECT payload, updated
FROM playback_history
WHERE profile_id = $profile_id
ORDER BY updated DESC
LIMIT $limit;
""";
command.Parameters.AddWithValue("$profile_id", profileId);
command.Parameters.AddWithValue("$limit", Math.Max(1, limit));
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var payload = reader.IsDBNull(0) ? null : reader.GetString(0);
if (string.IsNullOrWhiteSpace(payload))
continue;
var track = MusicJson.Deserialize<MusicTrack>(payload);
if (track == null)
continue;
items.Add(new MusicRecentlyPlayedItem
{
track = track,
played_at = reader.IsDBNull(1) ? DateTime.UtcNow : reader.GetDateTime(1)
});
}
return items;
}
public static async Task<MusicTrack> GetTrackAsync(string profileId, string trackId, CancellationToken cancellationToken = default)
{
profileId = NormalizeProfileId(profileId);
trackId = string.IsNullOrWhiteSpace(trackId) ? null : trackId.Trim();
if (string.IsNullOrWhiteSpace(profileId) || string.IsNullOrWhiteSpace(trackId))
return null;
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = """
SELECT payload
FROM playback_history
WHERE profile_id = $profile_id
AND track_id = $track_id
LIMIT 1;
""";
command.Parameters.AddWithValue("$profile_id", profileId);
command.Parameters.AddWithValue("$track_id", trackId);
var payload = await command.ExecuteScalarAsync(cancellationToken) as string;
return string.IsNullOrWhiteSpace(payload) ? null : MusicJson.Deserialize<MusicTrack>(payload);
}
public static async Task<Dictionary<string, MusicTrack>> GetTracksAsync(string profileId, IEnumerable<string> trackIds, CancellationToken cancellationToken = default)
{
profileId = NormalizeProfileId(profileId);
if (string.IsNullOrWhiteSpace(profileId) || trackIds == null)
return new Dictionary<string, MusicTrack>(StringComparer.Ordinal);
var normalizedIds = trackIds
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id.Trim())
.Distinct(StringComparer.Ordinal)
.ToList();
if (normalizedIds.Count == 0)
return new Dictionary<string, MusicTrack>(StringComparer.Ordinal);
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
var placeholders = new List<string>(normalizedIds.Count);
for (int i = 0; i < normalizedIds.Count; i++)
{
string parameterName = $"$track_id_{i}";
placeholders.Add(parameterName);
command.Parameters.AddWithValue(parameterName, normalizedIds[i]);
}
command.CommandText = $"""
SELECT track_id, payload
FROM playback_history
WHERE profile_id = $profile_id
AND track_id IN ({string.Join(", ", placeholders)});
""";
command.Parameters.AddWithValue("$profile_id", profileId);
var result = new Dictionary<string, MusicTrack>(normalizedIds.Count, StringComparer.Ordinal);
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var trackId = reader.IsDBNull(0) ? null : reader.GetString(0);
var payload = reader.IsDBNull(1) ? null : reader.GetString(1);
if (string.IsNullOrWhiteSpace(trackId) || string.IsNullOrWhiteSpace(payload))
continue;
var track = MusicJson.Deserialize<MusicTrack>(payload);
if (track != null)
result[trackId] = track;
}
return result;
}
public static async Task<bool> RemoveAsync(string profileId, string trackId, CancellationToken cancellationToken = default)
{
profileId = NormalizeProfileId(profileId);
trackId = string.IsNullOrWhiteSpace(trackId) ? null : trackId.Trim();
if (string.IsNullOrWhiteSpace(profileId) || string.IsNullOrWhiteSpace(trackId))
return false;
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = """
DELETE FROM playback_history
WHERE profile_id = $profile_id
AND track_id = $track_id;
""";
command.Parameters.AddWithValue("$profile_id", profileId);
command.Parameters.AddWithValue("$track_id", trackId);
return await command.ExecuteNonQueryAsync(cancellationToken) > 0;
}
static string NormalizeProfileId(string profileId)
=> string.IsNullOrWhiteSpace(profileId) ? null : profileId.Trim().ToLowerInvariant();
}
@@ -0,0 +1,21 @@
namespace Music;
public static class MusicPlaybackModeService
{
public const string Audio = "audio";
public const string Video = "video";
public static string Normalize(string mode)
{
mode = mode?.Trim().ToLowerInvariant();
return mode switch
{
Video => Video,
_ => Audio
};
}
public static bool IsVideo(string mode)
=> Normalize(mode) == Video;
}
@@ -0,0 +1,215 @@
using System.Text;
namespace Music;
public static class MusicPlaybackService
{
public static async Task<MusicTrack> ResolveRequestTrackAsync(string id, string provider, string title, string artistName, string albumId, string albumTitle, int? durationMs, string date, string isrc)
{
string normalizedIsrc = MusicIsrc.Normalize(isrc);
MusicTrack track = null;
// Spotify search отдаёт точный track/album id, но без duration/isrc.
// Достаём трек из его родного queryAlbum (кэшируется каталогом), вместо
// заведомо бесполезного поиска spotify:track:* через MusicBrainz.
if (!string.IsNullOrWhiteSpace(id)
&& id.StartsWith("spotify:track:", StringComparison.OrdinalIgnoreCase)
&& !durationMs.HasValue
&& !string.IsNullOrWhiteSpace(albumId)
&& albumId.StartsWith("spotify:album:", StringComparison.OrdinalIgnoreCase))
{
var albumTask = MusicCatalogService.GetAlbumAsync(albumId, SpotifySupport.ProviderId);
if (await Task.WhenAny(albumTask, Task.Delay(2500)) == albumTask)
{
var album = await albumTask;
track = album?.tracks?.FirstOrDefault(i => string.Equals(i?.id, id, StringComparison.OrdinalIgnoreCase));
}
}
else
{
track = await MusicCatalogService.GetTrackAsync(id, provider);
}
if (track != null)
{
track = CopyTrack(track);
track.isrc = MusicIsrc.Normalize(track.isrc) ?? normalizedIsrc;
return track;
}
if (string.IsNullOrWhiteSpace(title))
return null;
return new MusicTrack
{
id = string.IsNullOrWhiteSpace(id) ? BuildInlineTrackId(title, artistName, albumTitle, durationMs, normalizedIsrc) : id,
title = title,
artist_name = artistName,
album_id = albumId,
album_title = albumTitle,
isrc = normalizedIsrc,
duration_ms = durationMs,
date = date
};
}
static MusicTrack CopyTrack(MusicTrack track)
{
return new MusicTrack
{
id = track.id,
title = track.title,
artist_id = track.artist_id,
artist_name = track.artist_name,
artists = track.artists?.ToList() ?? new List<string>(),
album_id = track.album_id,
album_title = track.album_title,
isrc = track.isrc,
duration_ms = track.duration_ms,
track_number = track.track_number,
disc_number = track.disc_number,
date = track.date,
search_score = track.search_score,
images = track.images?.ToList() ?? new List<MusicImage>(),
provider_refs = track.provider_refs?.ToList() ?? new List<MusicProviderRef>(),
auto_radio = track.auto_radio
};
}
public static async Task<MusicPlayResponse> ResolveTrackAsync(MusicTrack track, string audioProvider, string streamMode, string playbackMode, string profileId, CancellationToken cancellationToken = default, bool refreshSources = false)
{
var result = await MusicResolver.ResolveTrackAsync(track, audioProvider, playbackMode, profileId, cancellationToken, refreshSources);
if (result?.sources != null && result.sources.Count > 0)
result.sources = MusicStreamModeService.Order(result.sources, streamMode);
return result;
}
public static async Task<MusicPlayResponse> ResolvePreferredTrackAsync(MusicTrack track, string audioProvider, string streamMode, string playbackMode, string profileId, CancellationToken cancellationToken = default)
{
var result = await MusicResolver.ResolvePreferredTrackAsync(track, audioProvider, playbackMode, profileId, cancellationToken);
if (result?.sources != null && result.sources.Count > 0)
result.sources = MusicStreamModeService.Order(result.sources, streamMode);
return result;
}
public static void PrepareStreamSources(string host, MusicTrack track, string provider, string audioProvider, string streamMode, string playbackMode, MusicPlayResponse result)
{
if (string.IsNullOrWhiteSpace(host) || track == null || result?.sources == null || result.sources.Count == 0)
return;
string resolvedProvider = ResolveTrackProvider(track, provider);
var fallbackParams = BuildStreamFallbackParams(track, resolvedProvider, audioProvider, streamMode, playbackMode, null, null);
foreach (var source in result.sources)
{
if (source == null || string.IsNullOrWhiteSpace(source.url))
continue;
source.external_url = source.url;
string ticket = MusicStreamTicketService.Create(source);
if (!string.IsNullOrWhiteSpace(ticket))
{
var query = new List<string> { $"ticket={Uri.EscapeDataString(ticket)}" };
query.AddRange(fallbackParams);
if (!string.IsNullOrWhiteSpace(source.quality))
query.Add($"quality={Uri.EscapeDataString(source.quality)}");
source.url = $"{host}/music/stream?{string.Join("&", query)}";
}
source.headers = new Dictionary<string, string>();
}
}
public static MusicPlaybackSource SelectSource(List<MusicPlaybackSource> sources, string quality, string streamMode)
{
if (sources == null || sources.Count == 0)
return null;
if (!string.IsNullOrWhiteSpace(quality))
{
var exact = sources.FirstOrDefault(i => string.Equals(i.quality, quality, StringComparison.OrdinalIgnoreCase));
if (exact != null)
return exact;
}
return MusicStreamModeService.Order(sources, streamMode).FirstOrDefault();
}
public static string ResolveTrackProvider(MusicTrack track, string fallbackProvider)
{
if (!string.IsNullOrWhiteSpace(fallbackProvider))
return fallbackProvider;
if (track?.provider_refs == null || track.provider_refs.Count == 0)
return null;
foreach (var providerRef in track.provider_refs)
{
if (!string.IsNullOrWhiteSpace(providerRef?.provider))
return providerRef.provider.Trim();
}
return null;
}
public static List<string> BuildStreamFallbackParams(MusicTrack track, string provider, string audioProvider, string streamMode, string playbackMode, string uid, string accountEmail)
{
var url = new List<string>();
if (!string.IsNullOrWhiteSpace(track?.id))
url.Add($"id={Uri.EscapeDataString(track.id)}");
if (!string.IsNullOrWhiteSpace(provider))
url.Add($"provider={Uri.EscapeDataString(provider)}");
if (!string.IsNullOrWhiteSpace(audioProvider))
url.Add($"audio_provider={Uri.EscapeDataString(audioProvider)}");
if (!string.IsNullOrWhiteSpace(streamMode))
url.Add($"stream_mode={Uri.EscapeDataString(streamMode)}");
if (!string.IsNullOrWhiteSpace(playbackMode))
url.Add($"playback_mode={Uri.EscapeDataString(playbackMode)}");
if (!string.IsNullOrWhiteSpace(track?.title))
url.Add($"title={Uri.EscapeDataString(track.title)}");
if (!string.IsNullOrWhiteSpace(track?.artist_name))
url.Add($"artist_name={Uri.EscapeDataString(track.artist_name)}");
if (!string.IsNullOrWhiteSpace(track?.album_id))
url.Add($"album_id={Uri.EscapeDataString(track.album_id)}");
if (!string.IsNullOrWhiteSpace(track?.album_title))
url.Add($"album_title={Uri.EscapeDataString(track.album_title)}");
string isrc = MusicIsrc.Normalize(track?.isrc);
if (isrc != null)
url.Add($"isrc={Uri.EscapeDataString(isrc)}");
if (track?.duration_ms.HasValue == true)
url.Add($"duration_ms={track.duration_ms.Value}");
if (!string.IsNullOrWhiteSpace(track?.date))
url.Add($"date={Uri.EscapeDataString(track.date)}");
if (!string.IsNullOrWhiteSpace(uid))
url.Add($"uid={Uri.EscapeDataString(uid)}");
if (!string.IsNullOrWhiteSpace(accountEmail))
url.Add($"account_email={Uri.EscapeDataString(accountEmail)}");
return url;
}
static string BuildInlineTrackId(string title, string artistName, string albumTitle, int? durationMs, string isrc)
{
string key = $"{artistName}::{title}::{albumTitle}::{durationMs}::{isrc}";
using var md5 = System.Security.Cryptography.MD5.Create();
var bytes = md5.ComputeHash(Encoding.UTF8.GetBytes(key));
return "inline:" + Convert.ToHexString(bytes).ToLowerInvariant();
}
}
@@ -0,0 +1,327 @@
using System.Text;
namespace Music;
public sealed class MusicPlaylistRequest
{
public string ids { get; set; }
public string provider { get; set; }
public string audio_provider { get; set; }
public string stream_mode { get; set; }
public string playback_mode { get; set; }
public string playlist_strategy { get; set; }
public string profile_id { get; set; }
public string host { get; set; }
public string uid { get; set; }
public string account_email { get; set; }
public CancellationToken cancellation_token { get; set; }
}
public static class MusicPlaylistService
{
public static async Task<string> BuildAsync(MusicPlaylistRequest request)
{
request ??= new MusicPlaylistRequest();
if (string.IsNullOrWhiteSpace(request.ids))
return string.Empty;
// защитный кап: 500 = лимит окна снапшота очереди, длиннее клиент
// не шлёт; всё сверх — обрезаем, а не отказываем (устойчивость)
const int maxPlaylistIds = 500;
const int maxTrackIdLength = 256;
var trackIds = request.ids
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(id => !string.IsNullOrWhiteSpace(id) && id.Length <= maxTrackIdLength)
.Take(maxPlaylistIds)
.ToArray();
if (trackIds.Length == 0)
return string.Empty;
var tracks = await ResolvePlaylistTracksAsync(trackIds, request);
if (string.Equals(request.audio_provider, SefonSupport.ProviderId, StringComparison.OrdinalIgnoreCase))
return await BuildResolvedExternalPlaylistAsync(trackIds, tracks, request);
if (string.Equals(request.audio_provider, SoundCloudSupport.AudioProviderId, StringComparison.OrdinalIgnoreCase))
{
if (string.Equals(request.playlist_strategy, "resolved", StringComparison.OrdinalIgnoreCase))
return await BuildResolvedExternalPlaylistAsync(trackIds, tracks, request);
return await BuildHybridSoundCloudExternalPlaylistAsync(trackIds, tracks, request);
}
return BuildInternalPlaylist(trackIds, tracks, request);
}
static async Task<MusicTrack[]> ResolvePlaylistTracksAsync(string[] trackIds, MusicPlaylistRequest request)
{
const int metadataResolveParallelism = 6;
var tracks = new MusicTrack[trackIds.Length];
var historyTracks = await MusicPlaybackHistoryService.GetTracksAsync(request.profile_id, trackIds, request.cancellation_token);
for (int i = 0; i < trackIds.Length; i++)
{
if (historyTracks.TryGetValue(trackIds[i], out var historyTrack))
tracks[i] = historyTrack;
}
if (string.IsNullOrWhiteSpace(request.provider))
return tracks;
using var resolveGate = new SemaphoreSlim(metadataResolveParallelism);
var resolveTasks = new List<Task>();
async Task ResolveTrackAtAsync(int index)
{
await resolveGate.WaitAsync(request.cancellation_token);
try
{
tracks[index] = await MusicCatalogService.GetTrackAsync(trackIds[index], request.provider, request.cancellation_token);
}
finally
{
resolveGate.Release();
}
}
for (int i = 0; i < trackIds.Length; i++)
{
if (tracks[i] == null)
resolveTasks.Add(ResolveTrackAtAsync(i));
}
if (resolveTasks.Count > 0)
await Task.WhenAll(resolveTasks);
return tracks;
}
static string BuildInternalPlaylist(string[] trackIds, MusicTrack[] tracks, MusicPlaylistRequest request)
{
var playlist = new StringBuilder();
playlist.AppendLine("#EXTM3U");
for (int i = 0; i < trackIds.Length; i++)
{
var track = tracks[i];
string id = trackIds[i];
int position = i + 1;
string title = BuildTitle(track, position);
int seconds = GetSeconds(track);
playlist.AppendLine($"#EXTINF:{seconds},{EscapePlaylistTitle(title)}");
string playlistUrl = BuildPlaylistEntryUrl(track, id, request);
if (!string.IsNullOrWhiteSpace(playlistUrl))
playlist.AppendLine(playlistUrl);
}
return playlist.ToString();
}
static async Task<string> BuildHybridSoundCloudExternalPlaylistAsync(string[] trackIds, MusicTrack[] tracks, MusicPlaylistRequest request)
{
var playlist = new StringBuilder();
playlist.AppendLine("#EXTM3U");
const int leadChunkSize = 6;
int leadIndex = -1;
var leadResolvedEntries = new Dictionary<int, PlaylistResolvedEntry>();
for (int offset = 0; offset < trackIds.Length && leadIndex < 0; offset += leadChunkSize)
{
int chunkCount = Math.Min(leadChunkSize, trackIds.Length - offset);
var tasks = new Task<PlaylistResolvedEntry>[chunkCount];
for (int chunkOffset = 0; chunkOffset < chunkCount; chunkOffset++)
{
int index = offset + chunkOffset;
var track = tracks[index];
if (track == null)
continue;
tasks[chunkOffset] = ResolvePreferredPlaylistEntryAsync(index, track, request);
}
await Task.WhenAll(tasks.Where(task => task != null));
for (int chunkOffset = 0; chunkOffset < chunkCount; chunkOffset++)
{
int index = offset + chunkOffset;
var task = tasks[chunkOffset];
var entry = task?.Result;
if (entry == null || string.IsNullOrWhiteSpace(entry.Url))
continue;
leadResolvedEntries[index] = entry;
if (leadIndex < 0)
leadIndex = index;
}
}
if (leadIndex < 0)
return playlist.ToString();
for (int i = leadIndex; i < trackIds.Length; i++)
{
var track = tracks[i];
string trackId = trackIds[i];
if (track == null)
continue;
if (leadResolvedEntries.TryGetValue(i, out var resolvedEntry) && !string.IsNullOrWhiteSpace(resolvedEntry.Url))
{
playlist.AppendLine($"#EXTINF:{resolvedEntry.Seconds},{EscapePlaylistTitle(resolvedEntry.Title)}");
playlist.AppendLine(resolvedEntry.Url);
continue;
}
string playlistUrl = BuildPlaylistEntryUrl(track, trackId, request);
if (string.IsNullOrWhiteSpace(playlistUrl))
continue;
playlist.AppendLine($"#EXTINF:{GetSeconds(track)},{EscapePlaylistTitle(BuildTitle(track, i + 1))}");
playlist.AppendLine(playlistUrl);
}
return playlist.ToString();
}
static async Task<string> BuildResolvedExternalPlaylistAsync(string[] trackIds, MusicTrack[] tracks, MusicPlaylistRequest request)
{
var entries = new PlaylistResolvedEntry[trackIds.Length];
using var concurrency = new SemaphoreSlim(8);
var tasks = trackIds
.Select((trackId, index) => ResolveResolvedPlaylistEntryAsync(index, tracks[index], request, entries, concurrency))
.ToArray();
await Task.WhenAll(tasks);
var playlist = new StringBuilder();
playlist.AppendLine("#EXTM3U");
foreach (var entry in entries)
{
if (entry == null || string.IsNullOrWhiteSpace(entry.Url))
continue;
playlist.AppendLine($"#EXTINF:{entry.Seconds},{EscapePlaylistTitle(entry.Title)}");
playlist.AppendLine(entry.Url);
}
return playlist.ToString();
}
static async Task ResolveResolvedPlaylistEntryAsync(int index, MusicTrack track, MusicPlaylistRequest request, PlaylistResolvedEntry[] entries, SemaphoreSlim concurrency)
{
await concurrency.WaitAsync(request.cancellation_token);
try
{
if (track == null)
return;
var entry = await ResolvePreferredPlaylistEntryAsync(index, track, request);
if (entry != null)
entries[index] = entry;
}
catch
{
}
finally
{
concurrency.Release();
}
}
static async Task<PlaylistResolvedEntry> ResolvePreferredPlaylistEntryAsync(int index, MusicTrack track, MusicPlaylistRequest request)
{
if (track == null)
return null;
var result = await MusicPlaybackService.ResolvePreferredTrackAsync(track, request.audio_provider, request.stream_mode, request.playback_mode, request.profile_id, request.cancellation_token);
if (result?.available != true || result.sources == null || result.sources.Count == 0)
return null;
var source = MusicPlaybackService.SelectSource(result.sources, null, request.stream_mode);
if (source == null || string.IsNullOrWhiteSpace(source.url))
return null;
string url = source.url;
string resolvedAudioProvider = string.IsNullOrWhiteSpace(source.provider_id) ? request.audio_provider : source.provider_id;
bool canUseDirectExternalUrl =
(string.Equals(resolvedAudioProvider, SefonSupport.ProviderId, StringComparison.OrdinalIgnoreCase)
|| string.Equals(resolvedAudioProvider, SoundCloudSupport.AudioProviderId, StringComparison.OrdinalIgnoreCase))
&& (source.headers == null || source.headers.Count == 0);
if (!canUseDirectExternalUrl)
{
MusicPlaybackService.PrepareStreamSources(request.host, track, MusicPlaybackService.ResolveTrackProvider(track, request.provider), resolvedAudioProvider, request.stream_mode, request.playback_mode, result);
source = MusicPlaybackService.SelectSource(result.sources, null, request.stream_mode);
if (source == null || string.IsNullOrWhiteSpace(source.url))
return null;
url = source.url;
}
return new PlaylistResolvedEntry
{
Title = BuildTitle(track, index + 1),
Seconds = GetSeconds(track),
Url = url
};
}
static string BuildPlaylistEntryUrl(MusicTrack track, string trackId, MusicPlaylistRequest request)
{
trackId = string.IsNullOrWhiteSpace(trackId) ? track?.id : trackId.Trim();
if (string.IsNullOrWhiteSpace(trackId) || string.IsNullOrWhiteSpace(request.host))
return null;
string resolvedProvider = MusicPlaybackService.ResolveTrackProvider(track, request.provider);
var fallbackTrack = track ?? new MusicTrack { id = trackId };
if (string.IsNullOrWhiteSpace(fallbackTrack.id))
fallbackTrack.id = trackId;
var url = MusicPlaybackService.BuildStreamFallbackParams(
fallbackTrack,
resolvedProvider,
request.audio_provider,
request.stream_mode,
request.playback_mode,
request.uid,
request.account_email
);
return $"{request.host}/music/stream?{string.Join("&", url)}";
}
static string BuildTitle(MusicTrack track, int position)
{
return string.IsNullOrWhiteSpace(track?.artist_name)
? track?.title ?? $"Track {position}"
: $"{track.artist_name} - {track.title}";
}
static int GetSeconds(MusicTrack track)
{
return track?.duration_ms.HasValue == true
? (int)Math.Max(0, Math.Round(track.duration_ms.Value / 1000d))
: -1;
}
static string EscapePlaylistTitle(string title)
=> (title ?? string.Empty).Replace("\r", " ").Replace("\n", " ");
sealed class PlaylistResolvedEntry
{
public string Title { get; set; }
public int Seconds { get; set; }
public string Url { get; set; }
}
}
@@ -0,0 +1,753 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
namespace Music;
public static class MusicRadioService
{
static readonly TimeSpan searchCacheTtl = TimeSpan.FromHours(6);
static readonly TimeSpan radioTimeout = TimeSpan.FromSeconds(8);
const int maxSeedArtists = 5;
const int providerQueryLimit = 12;
const int maxPoolPerArtist = 24;
const string cacheVersion = "radio-v3-metadata";
public static async Task<MusicRadioResponse> GetAsync(string profileId, MusicRadioRequest request, int limit = 20, CancellationToken cancellationToken = default)
{
request ??= new MusicRadioRequest();
limit = Math.Clamp(limit, 5, 30);
var seeds = NormalizeTrackList(request.seeds);
var exclude = NormalizeTrackList(request.exclude);
if (seeds.Count == 0)
seeds = exclude.TakeLast(Math.Min(5, exclude.Count)).ToList();
var artists = ExtractSeedArtists(seeds);
if (artists.Count == 0)
return Unavailable("Недостаточно данных для радио.");
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(radioTimeout);
// сиды исключаем всегда, даже если клиент прислал пустой exclude —
// иначе радио может вернуть сам сид-трек под другим id
var excludedIds = new HashSet<string>(exclude.Concat(seeds).Select(i => i.id).Where(i => !string.IsNullOrWhiteSpace(i)), StringComparer.OrdinalIgnoreCase);
var excludedKeys = new HashSet<string>(exclude.Concat(seeds).Select(BuildTrackDedupeKey).Where(i => !string.IsNullOrWhiteSpace(i)), StringComparer.OrdinalIgnoreCase);
var excludedTitles = new HashSet<string>(exclude.Concat(seeds).Select(BuildTrackTitleKey).Where(i => !string.IsNullOrWhiteSpace(i)), StringComparer.OrdinalIgnoreCase);
try
{
foreach (var recent in await MusicPlaybackHistoryService.GetRecentAsync(profileId, 150, timeoutCts.Token))
{
if (recent == null || recent.played_at.ToUniversalTime() < DateTime.UtcNow.AddDays(-1))
continue;
if (!string.IsNullOrWhiteSpace(recent.track?.id))
excludedIds.Add(recent.track.id);
string key = BuildTrackDedupeKey(recent.track);
if (!string.IsNullOrWhiteSpace(key))
excludedKeys.Add(key);
string titleKey = BuildTrackTitleKey(recent.track);
if (!string.IsNullOrWhiteSpace(titleKey))
excludedTitles.Add(titleKey);
}
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
}
var output = new List<MusicTrack>();
var outputIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var outputKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var outputTitles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
// «шумные» слова допустимы в кандидате, только если были в самом сиде
// (слушаешь remix — получай remix, иначе это мусор)
var seedNoisyWords = CollectSeedNoisyWords(seeds);
// script-affinity включаем только когда ВЕСЬ сид-контекст кириллический:
// при Any смешанная очередь (Eminem + Miyagi) резала бы латинские
// кандидаты целиком, включая related-пул латинского сида
bool requireCyrillic = seeds.Count > 0
&& seeds.All(seed => ContainsCyrillic(seed?.title) || ContainsCyrillic(seed?.artist_name));
// слой 1: рекомендации SoundCloud ПО ТРЕКУ (related/station) — живое
// радио вместо «топ треков артиста»; artist-пулы остаются как backfill
var relatedTasks = SoundCloudSupport.IsDiscoveryEnabled
? seeds.Take(3).Select(seed => LoadRelatedPoolAsync(seed, timeoutCts.Token)).ToList()
: new List<Task<List<MusicTrack>>>();
try
{
await Task.WhenAll(relatedTasks);
}
catch
{
}
static List<List<MusicTrack>> CollectPools(IEnumerable<Task<List<MusicTrack>>> tasks) => tasks
.Where(task => task.IsCompletedSuccessfully)
.Select(task => task.Result ?? new List<MusicTrack>())
.Where(pool => pool.Count > 0)
.ToList();
FillRoundRobin(CollectPools(relatedTasks), output, limit, seedNoisyWords, requireCyrillic, excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles);
if (output.Count < limit)
{
// Backfill is intentionally lazy: if SoundCloud related/station gave
// enough tracks, do not spend requests on generic artist search.
var artistTasks = artists.Select(artist => LoadArtistPoolAsync(artist, timeoutCts.Token)).ToList();
try
{
await Task.WhenAll(artistTasks);
}
catch
{
}
FillRoundRobin(CollectPools(artistTasks), output, limit, seedNoisyWords, requireCyrillic, excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles);
}
return output.Count >= Math.Min(limit, 8)
? Available(output)
: Unavailable("Радио не нашло достаточно похожих треков.");
}
// round-robin по пулам: 1-й трек каждого пула, потом 2-е и т.д. — иначе
// первый пул целиком заполняет лимит и «радио» вырождается в один источник
// (internal: переиспользуется MusicDailyMixService для «Миксов недели»)
internal static void FillRoundRobin(
List<List<MusicTrack>> pools,
List<MusicTrack> output,
int limit,
HashSet<string> seedNoisyWords,
bool requireCyrillic,
HashSet<string> excludedIds,
HashSet<string> excludedKeys,
HashSet<string> excludedTitles,
HashSet<string> outputIds,
HashSet<string> outputKeys,
HashSet<string> outputTitles,
Dictionary<string, int> outputArtistCounts = null,
int maxTracksPerArtist = 0)
{
for (int position = 0; output.Count < limit; position++)
{
bool anyLeft = false;
foreach (var pool in pools)
{
if (position >= pool.Count)
continue;
anyLeft = true;
var candidate = NormalizeCandidateMetadata(pool[position]);
if (maxTracksPerArtist > 0
&& outputArtistCounts != null
&& !CanAddArtistCandidate(candidate, outputArtistCounts, maxTracksPerArtist))
{
continue;
}
if (!AcceptCandidate(candidate, seedNoisyWords, requireCyrillic, excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles))
continue;
candidate.auto_radio = true;
output.Add(candidate);
AddArtistCandidate(candidate, outputArtistCounts);
if (output.Count >= limit)
break;
}
if (!anyLeft)
break;
}
}
static bool CanAddArtistCandidate(MusicTrack track, Dictionary<string, int> counts, int maxTracksPerArtist)
{
string key = BuildArtistBucketKey(track);
if (string.IsNullOrWhiteSpace(key))
return true;
return !counts.TryGetValue(key, out int count) || count < maxTracksPerArtist;
}
static void AddArtistCandidate(MusicTrack track, Dictionary<string, int> counts)
{
if (counts == null)
return;
string key = BuildArtistBucketKey(track);
if (string.IsNullOrWhiteSpace(key))
return;
counts[key] = counts.TryGetValue(key, out int count) ? count + 1 : 1;
}
static string BuildArtistBucketKey(MusicTrack track)
{
// Структурированный список хранит primary artist первым. Иначе
// "Artist, Guest" и "Artist" попадают в разные bucket'ы и обходят artist-cap.
string artist = CleanupArtistName(track?.artists?.FirstOrDefault());
if (string.IsNullOrWhiteSpace(artist))
artist = CleanupArtistName(track?.artist_name);
return string.IsNullOrWhiteSpace(artist)
? string.Empty
: MusicMapSupport.NormalizeName(artist);
}
static MusicRadioResponse Available(List<MusicTrack> tracks) => new()
{
available = true,
tracks = tracks ?? new List<MusicTrack>()
};
static MusicRadioResponse Unavailable(string message) => new()
{
available = false,
message = message
};
static List<MusicTrack> NormalizeTrackList(IEnumerable<MusicTrack> tracks)
{
var result = new List<MusicTrack>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var track in tracks ?? Enumerable.Empty<MusicTrack>())
{
if (track == null)
continue;
string id = (track.id ?? string.Empty).Trim();
string key = !string.IsNullOrWhiteSpace(id)
? id
: BuildTrackDedupeKey(track);
if (string.IsNullOrWhiteSpace(key) || !seen.Add(key))
continue;
if (!string.IsNullOrWhiteSpace(id))
track.id = id;
result.Add(track);
}
return result;
}
static List<string> ExtractSeedArtists(IEnumerable<MusicTrack> seeds)
{
var artists = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var track in seeds ?? Enumerable.Empty<MusicTrack>())
{
foreach (var artist in EnumerateTrackArtists(track))
{
string value = CleanupArtistName(artist);
if (string.IsNullOrWhiteSpace(value) || !seen.Add(NormalizeText(value)))
continue;
artists.Add(value);
if (artists.Count >= maxSeedArtists)
return artists;
}
}
return artists;
}
static IEnumerable<string> EnumerateTrackArtists(MusicTrack track)
{
if (!string.IsNullOrWhiteSpace(track?.artist_name))
yield return track.artist_name;
foreach (var artist in track?.artists ?? new List<string>())
{
if (!string.IsNullOrWhiteSpace(artist))
yield return artist;
}
}
internal static string CleanupArtistName(string value)
{
value = (value ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
value = Regex.Replace(value, @"\s+(feat\.?|ft\.?|featuring|with|vs\.?)\s+.*$", "", RegexOptions.IgnoreCase);
value = Regex.Replace(value, @"\s+", " ").Trim();
return value;
}
internal static Task<List<MusicTrack>> LoadRelatedPoolAsync(MusicTrack seed, CancellationToken cancellationToken)
{
string artist = CleanupArtistName(seed?.artist_name);
string title = (seed?.title ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(artist) || string.IsNullOrWhiteSpace(title))
return Task.FromResult(new List<MusicTrack>());
string query = $"{artist} {title}";
return LoadCachedTracksAsync(
SoundCloudSupport.DiscoveryProviderId,
query,
token => SoundCloudSupport.FindRelatedTracksByQueryAsync(query, artist, title, maxPoolPerArtist, token),
cancellationToken,
cacheType: "radio_related",
cacheLimit: maxPoolPerArtist);
}
internal static async Task<List<MusicTrack>> LoadArtistPoolAsync(
string artist,
CancellationToken cancellationToken,
bool requireConfirmedArtist = false)
{
var tasks = new List<Task<List<MusicTrack>>>();
string cacheType = requireConfirmedArtist ? "radio_tracks_confirmed" : "radio_tracks";
if (YouTubeMusicSearchSupport.IsSearchEnabled)
tasks.Add(LoadCachedTracksAsync(
YouTubeMusicSearchSupport.ProviderId,
artist,
token => YouTubeMusicSearchSupport.SearchTracksByQueryAsync(
artist,
providerQueryLimit,
token,
artist,
requireConfirmedArtist),
cancellationToken,
cacheType));
if (SoundCloudSupport.IsDiscoveryEnabled)
tasks.Add(LoadCachedTracksAsync(
SoundCloudSupport.DiscoveryProviderId,
artist,
token => SoundCloudSupport.SearchTracksByQueryAsync(artist, providerQueryLimit, token),
cancellationToken,
cacheType));
if (tasks.Count == 0)
return new List<MusicTrack>();
try
{
await Task.WhenAll(tasks);
}
catch
{
}
var candidates = tasks
.Where(i => i.IsCompletedSuccessfully)
.SelectMany(i => i.Result ?? new List<MusicTrack>())
.Select(track => NormalizeArtistPoolCandidate(track, artist, !requireConfirmedArtist))
.Where(i => i != null)
.Take(maxPoolPerArtist)
.ToList();
if (requireConfirmedArtist && !candidates.Any(IsConfirmedExternalArtistEvidence))
return new List<MusicTrack>();
return candidates;
}
static bool IsConfirmedExternalArtistEvidence(MusicTrack track)
{
if ((track?.id ?? string.Empty).StartsWith("youtube:", StringComparison.OrdinalIgnoreCase))
return true;
return (track?.id ?? string.Empty).StartsWith("soundcloud:", StringComparison.OrdinalIgnoreCase)
&& MusicIsrc.Normalize(track.isrc) != null;
}
static MusicTrack NormalizeArtistPoolCandidate(MusicTrack track, string requestedArtist, bool allowTitlePrefix)
{
track = NormalizeCandidateMetadata(track);
string artist = CleanupArtistName(requestedArtist);
if (track == null || string.IsNullOrWhiteSpace(artist))
return track;
string requested = NormalizeText(artist);
string actual = NormalizeText(track.artist_name);
string title = NormalizeText(track.title);
if (string.IsNullOrWhiteSpace(requested))
return track;
if (!string.IsNullOrWhiteSpace(actual)
&& (actual == requested
|| actual.Contains(requested, StringComparison.OrdinalIgnoreCase)
|| (actual.Length >= 4 && requested.Contains(actual, StringComparison.OrdinalIgnoreCase))))
{
// SoundCloud/YouTube sometimes expose channel names like
// "032 stas mikhaylov"; if the target artist is clearly embedded,
// keep the candidate but display the canonical artist name.
if (actual != requested)
{
track.artist_name = artist;
track.artists = new List<string> { artist };
}
return track;
}
if (allowTitlePrefix
&& !string.IsNullOrWhiteSpace(title)
&& title.StartsWith(requested + " ", StringComparison.OrdinalIgnoreCase))
{
track.artist_name = artist;
track.artists = new List<string> { artist };
track.title = StripLeadingArtistFromTitle(track.title, artist);
return track;
}
return null;
}
static string StripLeadingArtistFromTitle(string title, string artist)
{
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artist))
return title;
string pattern = @"^\s*" + Regex.Escape(artist.Trim()) + @"\s*(?:[-–—:]|&)?\s*";
string stripped = Regex.Replace(title, pattern, "", RegexOptions.IgnoreCase).Trim();
return string.IsNullOrWhiteSpace(stripped) ? title : stripped;
}
static async Task<List<MusicTrack>> LoadCachedTracksAsync(string providerId, string query, Func<CancellationToken, Task<List<MusicTrack>>> factory, CancellationToken cancellationToken, string cacheType = "radio_tracks", int cacheLimit = providerQueryLimit)
{
try
{
string key = $"{cacheVersion}|{providerId}|{cacheLimit}|{NormalizeText(query)}";
return await MusicMetadataCacheService.GetOrCreateAsync(
providerId,
cacheType,
key,
searchCacheTtl,
() => factory(cancellationToken),
cancellationToken
) ?? new List<MusicTrack>();
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return new List<MusicTrack>();
}
}
// «шумные» слова в названии кандидата допустимы только если встречались
// в самих сидах: слушаешь remix/nightcore — радио вправе их предлагать
static readonly string[] conditionalNoisyWords =
{
"cover", "karaoke", "sped up", "slowed", "nightcore", "remix",
"8d audio", "bass boosted", "reverb", "edit", "mashup",
"type beat", "freestyle"
};
internal static HashSet<string> CollectSeedNoisyWords(IEnumerable<MusicTrack> seeds)
{
var allowed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var seed in seeds ?? Enumerable.Empty<MusicTrack>())
{
string padded = $" {NormalizeNoiseText(seed?.title)} ";
foreach (var word in conditionalNoisyWords)
{
if (padded.Contains($" {word} ", StringComparison.OrdinalIgnoreCase))
allowed.Add(word);
}
}
return allowed;
}
static bool AcceptCandidate(
MusicTrack track,
HashSet<string> seedNoisyWords,
bool requireCyrillic,
HashSet<string> excludedIds,
HashSet<string> excludedKeys,
HashSet<string> excludedTitles,
HashSet<string> outputIds,
HashSet<string> outputKeys,
HashSet<string> outputTitles)
{
if (track == null || string.IsNullOrWhiteSpace(track.id) || string.IsNullOrWhiteSpace(track.title))
return false;
string id = track.id.Trim();
string key = BuildTrackDedupeKey(track);
string titleKey = BuildTrackTitleKey(track);
if (!LooksLikeStandaloneSong(track, titleKey, seedNoisyWords, requireCyrillic))
return false;
if (excludedIds.Contains(id) || outputIds.Contains(id))
return false;
if (!string.IsNullOrWhiteSpace(key) && (excludedKeys.Contains(key) || outputKeys.Contains(key)))
return false;
if (!string.IsNullOrWhiteSpace(titleKey) && (excludedTitles.Contains(titleKey) || outputTitles.Contains(titleKey)))
return false;
outputIds.Add(id);
if (!string.IsNullOrWhiteSpace(key))
outputKeys.Add(key);
if (!string.IsNullOrWhiteSpace(titleKey))
outputTitles.Add(titleKey);
return true;
}
internal static MusicTrack NormalizeCandidateMetadata(MusicTrack track)
{
if (track == null)
return track;
// Provider/cache objects can be reused by radio and weekly mixes. Never
// rewrite those shared instances while preparing one response.
var result = CloneTrack(track);
result.title = CleanupCandidateTitle(result.title);
if (string.IsNullOrWhiteSpace(result.title)
|| !TrySplitArtistTitle(result.title, out var artist, out var title))
{
return result;
}
string currentArtist = CleanupArtistName(result.artist_name);
if (!string.IsNullOrWhiteSpace(currentArtist)
&& !IsGenericUploaderName(currentArtist)
&& !ArtistNamesMatch(currentArtist, artist))
{
// Structured provider metadata is more trustworthy than an
// unverified multi-dash title. Keeping it intact also preserves an
// exact provider id for playback instead of inventing a new pair.
return result;
}
result.artist_name = artist;
result.artists = new List<string> { artist };
result.title = CleanupCandidateTitle(title);
return result;
}
static bool TrySplitArtistTitle(string rawTitle, out string artist, out string title)
{
artist = null;
title = null;
var match = Regex.Match(
rawTitle ?? string.Empty,
@"^\s*(?<artist>.{2,80}?)\s+[-–—]\s+(?<title>.{2,180})\s*$");
if (!match.Success)
return false;
string artistPart = match.Groups["artist"].Value;
string titlePart = match.Groups["title"].Value;
artistPart = Regex.Replace(artistPart, @"^\s*[\d#._-]+\s*", "").Trim();
titlePart = Regex.Replace(titlePart, @"^\s*[\d#._-]+\s*", "").Trim();
if (string.IsNullOrWhiteSpace(artistPart) || string.IsNullOrWhiteSpace(titlePart))
return false;
if (NormalizeText(artistPart).Length < 2 || NormalizeText(titlePart).Length < 2)
return false;
if (artistPart.Length > 80 || titlePart.Length > 120)
return false;
artist = artistPart;
title = titlePart;
return true;
}
static bool ArtistNamesMatch(string left, string right)
{
string a = NormalizeText(CleanupArtistName(left));
string b = NormalizeText(CleanupArtistName(right));
return !string.IsNullOrWhiteSpace(a)
&& !string.IsNullOrWhiteSpace(b)
&& (a == b
|| (a.Length >= 4 && b.Contains(a, StringComparison.Ordinal))
|| (b.Length >= 4 && a.Contains(b, StringComparison.Ordinal)));
}
static bool IsGenericUploaderName(string value)
{
string normalized = NormalizeText(value);
return normalized is "soundcloud" or "youtube" or "unknown" or "various artists";
}
static string CleanupCandidateTitle(string value)
{
value = Regex.Replace(value ?? string.Empty, @"\s+", " ").Trim();
if (string.IsNullOrWhiteSpace(value))
return value;
bool filename = Regex.IsMatch(value, @"\.(aac|flac|m4a|mp3|ogg|opus|wav|webm)\s*$", RegexOptions.IgnoreCase);
if (!filename)
return value;
value = Regex.Replace(value, @"\.(aac|flac|m4a|mp3|ogg|opus|wav|webm)\s*$", string.Empty, RegexOptions.IgnoreCase);
value = Regex.Replace(value, @"_+", " ");
value = Regex.Replace(value, @"^\s*[-–—.]+\s*|\s*[-–—.]+\s*$", string.Empty);
return Regex.Replace(value, @"\s+", " ").Trim();
}
static MusicTrack CloneTrack(MusicTrack track)
{
return new MusicTrack
{
id = track.id,
title = track.title,
artist_id = track.artist_id,
artist_name = track.artist_name,
artists = track.artists?.ToList() ?? new List<string>(),
album_id = track.album_id,
album_title = track.album_title,
isrc = track.isrc,
duration_ms = track.duration_ms,
track_number = track.track_number,
disc_number = track.disc_number,
date = track.date,
search_score = track.search_score,
images = track.images?.ToList() ?? new List<MusicImage>(),
provider_refs = track.provider_refs?.ToList() ?? new List<MusicProviderRef>(),
auto_radio = track.auto_radio
};
}
static bool LooksLikeStandaloneSong(MusicTrack track, string titleKey, HashSet<string> seedNoisyWords, bool requireCyrillic)
{
int durationMs = track.duration_ms ?? 0;
if (durationMs > 0 && (durationMs < 45_000 || durationMs > 12 * 60_000))
return false;
if (string.IsNullOrWhiteSpace(titleKey))
return false;
string padded = $" {titleKey} ";
string noisePadded = $" {NormalizeNoiseText(track?.title)} ";
if (requireCyrillic && !ContainsCyrillic(track?.title) && !ContainsCyrillic(track?.artist_name))
return false;
string[] noisy =
{
" nonstop ",
" megamix ",
" full album ",
" playlist ",
" reaction ",
" podcast ",
" interview ",
" instrumental beat ",
" dj set ",
" live set ",
" preview ",
" prod "
};
if (noisy.Any(word => padded.Contains(word, StringComparison.OrdinalIgnoreCase) || noisePadded.Contains(word, StringComparison.OrdinalIgnoreCase)))
return false;
foreach (var word in conditionalNoisyWords)
{
if (seedNoisyWords != null && seedNoisyWords.Contains(word))
continue;
if (noisePadded.Contains($" {word} ", StringComparison.OrdinalIgnoreCase))
return false;
}
return true;
}
internal static bool ContainsCyrillic(string value)
=> !string.IsNullOrWhiteSpace(value) && value.Any(c => c is >= '\u0400' and <= '\u04FF');
static string BuildTrackDedupeKey(MusicTrack track)
{
if (track == null)
return string.Empty;
string artist = NormalizeText(track.artist_name);
string title = BuildTrackTitleKey(track);
return string.IsNullOrWhiteSpace(title)
? string.Empty
: $"{artist}|{title}";
}
static string BuildTrackTitleKey(MusicTrack track)
{
string rawTitle = track?.title ?? string.Empty;
rawTitle = Regex.Replace(rawTitle, @"^\s*.{1,80}\s+[-–—]\s+", "", RegexOptions.IgnoreCase);
string title = NormalizeText(rawTitle);
string artist = NormalizeText(track?.artist_name);
if (!string.IsNullOrWhiteSpace(artist) && title.StartsWith(artist + " ", StringComparison.OrdinalIgnoreCase))
title = title.Substring(artist.Length).Trim();
foreach (var artistName in track?.artists ?? new List<string>())
{
string normalizedArtist = NormalizeText(artistName);
if (!string.IsNullOrWhiteSpace(normalizedArtist) && title.StartsWith(normalizedArtist + " ", StringComparison.OrdinalIgnoreCase))
title = title.Substring(normalizedArtist.Length).Trim();
}
return title;
}
internal static string NormalizeText(string value)
{
value = RemoveDiacritics((value ?? string.Empty).ToLowerInvariant());
value = Regex.Replace(value, @"\([^)]*\)|\[[^\]]*\]", " ");
value = Regex.Replace(value, @"\b(feat|ft|featuring|official|audio|video|lyrics|lyric|clip|mv|hd|4k)\b", " ", RegexOptions.IgnoreCase);
value = Regex.Replace(value, @"[^a-z0-9а-яёіїєґ]+", " ");
return Regex.Replace(value, @"\s+", " ").Trim();
}
static string NormalizeNoiseText(string value)
{
value = RemoveDiacritics((value ?? string.Empty).ToLowerInvariant());
value = Regex.Replace(value, @"\b(feat|ft|featuring|official|audio|video|lyrics|lyric|clip|mv|hd|4k)\b", " ", RegexOptions.IgnoreCase);
value = Regex.Replace(value, @"[^a-z0-9а-яёіїєґ]+", " ");
return Regex.Replace(value, @"\s+", " ").Trim();
}
static string RemoveDiacritics(string value)
{
if (string.IsNullOrWhiteSpace(value))
return string.Empty;
var normalized = value.Normalize(NormalizationForm.FormD);
var builder = new StringBuilder(normalized.Length);
foreach (var c in normalized)
{
if (CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark)
builder.Append(c);
}
return builder.ToString().Normalize(NormalizationForm.FormC);
}
}
@@ -0,0 +1,493 @@
using System.Collections.Concurrent;
namespace Music;
public static class MusicResolver
{
// Кэш stream-sources (только youtubeaudio): манифест YouTube стоил ~1-1.5s
// на КАЖДЫЙ тёплый /music/play, хотя googlevideo-ссылки живут часами.
// TTL 30min — с запасом короче срока жизни ссылок. У Sefon/SoundCloud свои
// (короткие/сессионные) сроки жизни — их не кэшируем. Ошибки и пустые
// списки не кэшируются. Хранится и отдаётся ГЛУБОКИМИ КОПИЯМИ:
// PrepareStreamSources мутирует source.url под хост запроса — общий
// инстанс запёк бы хост первого клиента (урок отравления image-proxy).
// Инвалидация: refreshSources=true (пере-резолв из /music/stream после
// смерти upstream-ссылки в relay) пропускает чтение и перезаписывает свежим.
static readonly TimeSpan sourcesCacheTtl = TimeSpan.FromMinutes(30);
const int sourcesCacheCapacity = 512;
static readonly ConcurrentDictionary<string, (DateTime at, List<MusicPlaybackSource> sources)> sourcesCache = new(StringComparer.Ordinal);
// кап жёсткий: чтение lock-free, но trim + вставка под общим lock, иначе
// параллельные запросы одновременно пройдут проверку Count и вместе её пробьют
static readonly object sourcesCacheWriteLock = new();
public static async Task<MusicPlayResponse> ResolveTrackAsync(MusicTrack track, string provider = null, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default, bool refreshSources = false)
{
return await ResolveTrackCoreAsync(track, provider, playbackMode, profileId, preferSingleSource: false, refreshSources, cancellationToken);
}
public static async Task<MusicPlayResponse> ResolvePreferredTrackAsync(MusicTrack track, string provider = null, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
return await ResolveTrackCoreAsync(track, provider, playbackMode, profileId, preferSingleSource: true, refreshSources: false, cancellationToken);
}
static async Task<MusicPlayResponse> ResolveTrackCoreAsync(MusicTrack track, string provider, string playbackMode, string profileId, bool preferSingleSource, bool refreshSources, CancellationToken cancellationToken)
{
if (track == null)
{
return new MusicPlayResponse
{
available = false,
message = "Track not found."
};
}
var requestedProviders = GetProviderOrder(provider);
var requestedProviderIds = requestedProviders
.Select(i => i.Id)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
var providers = ExpandProviderOrder(track, requestedProviders);
if (providers.Count == 0)
{
return new MusicPlayResponse
{
available = false,
message = "Audio provider not found.",
track_id = track.id
};
}
MusicAudioMatch firstMatch = null;
bool hadTransientFailure = false;
foreach (var sourceProvider in providers)
{
var providerTrack = requestedProviderIds.Contains(sourceProvider.Id)
? track
: BuildFallbackSearchTrack(track);
var selectedMatch = await MusicSourceMatchService.GetAsync(track.id, sourceProvider.Id, playbackMode, cancellationToken);
selectedMatch = NormalizeSelectedMatch(providerTrack, sourceProvider, selectedMatch);
// A durable manual choice is authoritative even when an older
// negative lookup for this provider is still cached.
if (selectedMatch?.pinned != true
&& sourceProvider.CacheMissingMatches
&& await MusicSourceMatchService.IsMarkedMissingAsync(track.id, sourceProvider.Id, playbackMode, cancellationToken))
continue;
bool providerHadTransientFailure = false;
if (selectedMatch != null)
{
if (firstMatch == null)
firstMatch = selectedMatch;
List<MusicPlaybackSource> selectedSources;
try
{
selectedSources = await ResolveSourcesAsync(sourceProvider, selectedMatch, preferSingleSource, playbackMode, profileId, refreshSources, cancellationToken);
}
catch (MusicAudioTransientException ex)
{
providerHadTransientFailure = true;
hadTransientFailure = true;
Console.WriteLine($"[Music] {ex.Message}");
selectedSources = new List<MusicPlaybackSource>();
}
if (selectedSources.Count > 0)
{
return new MusicPlayResponse
{
available = true,
message = "ok",
track_id = track.id,
selected_match = selectedMatch,
sources = selectedSources.ToList()
};
}
}
IReadOnlyList<MusicAudioMatch> matches;
try
{
matches = await GetOrderedMatchesAsync(providerTrack, sourceProvider, selectedMatch, playbackMode, profileId, cancellationToken);
}
catch (MusicAudioTransientException ex)
{
providerHadTransientFailure = true;
hadTransientFailure = true;
Console.WriteLine($"[Music] {ex.Message}");
matches = Array.Empty<MusicAudioMatch>();
}
foreach (var match in matches)
{
if (selectedMatch != null && match.id == selectedMatch.id)
continue;
if (firstMatch == null)
firstMatch = match;
List<MusicPlaybackSource> sources;
try
{
sources = await ResolveSourcesAsync(sourceProvider, match, preferSingleSource, playbackMode, profileId, refreshSources, cancellationToken);
}
catch (MusicAudioTransientException ex)
{
providerHadTransientFailure = true;
hadTransientFailure = true;
Console.WriteLine($"[Music] {ex.Message}");
continue;
}
if (sources.Count == 0)
continue;
// временный сбой источников у пинованного матча не должен
// стирать ручной выбор — играем fallback без перезаписи
if (selectedMatch == null || !selectedMatch.pinned)
await MusicSourceMatchService.SaveAsync(track.id, match, playbackMode, cancellationToken);
return new MusicPlayResponse
{
available = true,
message = "ok",
track_id = track.id,
selected_match = match,
sources = sources.ToList()
};
}
if (sourceProvider.CacheMissingMatches && !providerHadTransientFailure)
await MusicSourceMatchService.MarkMissingAsync(track.id, sourceProvider.Id, playbackMode, cancellationToken);
}
return new MusicPlayResponse
{
available = false,
reason = hadTransientFailure ? "transient" : "not_found",
message = hadTransientFailure ? "Audio source is temporarily unavailable." : "No audio source resolved yet.",
track_id = track.id,
selected_match = firstMatch,
sources = new List<MusicPlaybackSource>()
};
}
static async Task<List<MusicPlaybackSource>> ResolveSourcesAsync(IMusicAudioProvider provider, MusicAudioMatch match, bool preferSingleSource, string playbackMode, string profileId, bool refreshSources, CancellationToken cancellationToken)
{
if (provider == null || match == null)
return new List<MusicPlaybackSource>();
if (preferSingleSource)
{
var preferred = await provider.TryGetPreferredStreamAsync(match, playbackMode, profileId, cancellationToken);
if (!string.IsNullOrWhiteSpace(preferred?.url))
{
var preferredSources = new List<MusicPlaybackSource> { preferred };
MusicProxyService.ApplyStreamProxy(provider.Id, preferredSources);
return preferredSources;
}
}
bool cacheable = string.Equals(provider.Id, "youtubeaudio", StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(match.id);
string cacheKey = cacheable
? BuildSourcesCacheKey(
provider.Id,
match.id,
playbackMode,
MusicProxyService.CurrentRouteKey(provider.Id, MusicProxyPurpose.Stream)
)
: null;
if (cacheable && !refreshSources
&& sourcesCache.TryGetValue(cacheKey, out var entry)
&& DateTime.UtcNow - entry.at < sourcesCacheTtl)
return CloneSources(entry.sources);
var sources = await provider.GetStreamsAsync(match, playbackMode, profileId, cancellationToken);
var result = sources?
.Where(source => !string.IsNullOrWhiteSpace(source?.url))
.ToList() ?? new List<MusicPlaybackSource>();
MusicProxyService.ApplyStreamProxy(provider.Id, result);
if (cacheable && result.Count > 0)
{
string resultCacheKey = BuildSourcesCacheKey(
provider.Id,
match.id,
playbackMode,
MusicProxyService.SourceRouteKey(result[0])
);
lock (sourcesCacheWriteLock)
{
if (!sourcesCache.ContainsKey(resultCacheKey) && sourcesCache.Count >= sourcesCacheCapacity)
{
// сначала протухшие; если их нет — вытесняем старейшие,
// иначе при равномерном трафике свежие записи переполнят кап
foreach (var stale in sourcesCache.Where(i => DateTime.UtcNow - i.Value.at >= sourcesCacheTtl).Select(i => i.Key).ToList())
sourcesCache.TryRemove(stale, out _);
while (sourcesCache.Count >= sourcesCacheCapacity)
{
var oldest = sourcesCache.OrderBy(i => i.Value.at).FirstOrDefault();
if (oldest.Key == null || !sourcesCache.TryRemove(oldest.Key, out _))
break;
}
}
sourcesCache[resultCacheKey] = (DateTime.UtcNow, CloneSources(result));
}
}
return result;
}
static string BuildSourcesCacheKey(string providerId, string matchId, string playbackMode, string routeKey)
=> $"{providerId}|{matchId}|{playbackMode ?? string.Empty}|route:{routeKey}|cfg:{MusicProxyService.ConfigurationVersion}";
static List<MusicPlaybackSource> CloneSources(List<MusicPlaybackSource> sources)
{
return sources.Select(source => new MusicPlaybackSource
{
provider_id = source.provider_id,
url = source.url,
external_url = source.external_url,
mime_type = source.mime_type,
bitrate = source.bitrate,
quality = source.quality,
headers = source.headers != null ? new Dictionary<string, string>(source.headers) : new(),
proxy_url = source.proxy_url,
proxy_username = source.proxy_username,
proxy_password = source.proxy_password,
proxy_scope = source.proxy_scope
}).ToList();
}
public static async Task<MusicMatchesResponse> GetMatchesAsync(MusicTrack track, string provider = null, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
{
if (track == null)
{
return new MusicMatchesResponse
{
available = false,
message = "Track not found."
};
}
var providers = GetProviderOrder(provider);
if (providers.Count == 0)
{
return new MusicMatchesResponse
{
available = false,
message = "Audio provider not found.",
track_id = track.id
};
}
foreach (var sourceProvider in providers)
{
var selectedMatch = await MusicSourceMatchService.GetAsync(track.id, sourceProvider.Id, playbackMode, cancellationToken);
selectedMatch = NormalizeSelectedMatch(track, sourceProvider, selectedMatch);
var matches = await GetOrderedMatchesAsync(track, sourceProvider, selectedMatch, playbackMode, profileId, cancellationToken);
if (matches.Count == 0)
continue;
return new MusicMatchesResponse
{
available = true,
message = "ok",
track_id = track.id,
selected_match = selectedMatch ?? matches.First(),
matches = matches.ToList()
};
}
return new MusicMatchesResponse
{
available = false,
message = "No audio matches resolved yet.",
track_id = track.id
};
}
// «Найти вручную»: матчи по сырому пользовательскому запросу вместо
// метаданных трека — последний рубеж для совсем битых метаданных
public static async Task<MusicMatchesResponse> GetMatchesByQueryAsync(MusicTrack track, string provider, string searchQuery, string playbackMode = null, CancellationToken cancellationToken = default)
{
if (track == null || string.IsNullOrWhiteSpace(searchQuery))
{
return new MusicMatchesResponse
{
available = false,
message = "Query is empty.",
track_id = track?.id
};
}
foreach (var sourceProvider in GetProviderOrder(provider))
{
var matches = await SearchMatchesByQueryAsync(sourceProvider, searchQuery, cancellationToken);
if (matches.Count == 0)
continue;
var selectedMatch = await MusicSourceMatchService.GetAsync(track.id, sourceProvider.Id, playbackMode, cancellationToken);
selectedMatch = NormalizeSelectedMatch(track, sourceProvider, selectedMatch);
return new MusicMatchesResponse
{
available = true,
message = "ok",
track_id = track.id,
selected_match = selectedMatch,
matches = matches.ToList()
};
}
return new MusicMatchesResponse
{
available = false,
message = "No matches for query.",
track_id = track.id
};
}
static async Task<IReadOnlyList<MusicAudioMatch>> SearchMatchesByQueryAsync(IMusicAudioProvider provider, string searchQuery, CancellationToken cancellationToken)
{
return provider == null || !provider.Enabled
? Array.Empty<MusicAudioMatch>()
: await provider.SearchMatchesByQueryAsync(searchQuery, cancellationToken);
}
public static async Task<bool> SelectMatchAsync(MusicTrack track, string provider, string matchId, string playbackMode = null, string profileId = null, string searchQuery = null, CancellationToken cancellationToken = default)
{
if (track == null || string.IsNullOrWhiteSpace(provider) || string.IsNullOrWhiteSpace(matchId))
return false;
var sourceProvider = MusicProviderRegistry.GetAudioProvider(provider);
if (sourceProvider == null || !sourceProvider.Enabled)
return false;
IReadOnlyList<MusicAudioMatch> matches;
if (!string.IsNullOrWhiteSpace(searchQuery))
{
// кандидат из ручного поиска валидируется тем же запросом
matches = await SearchMatchesByQueryAsync(sourceProvider, searchQuery, cancellationToken);
}
else
{
var selectedMatch = await MusicSourceMatchService.GetAsync(track.id, sourceProvider.Id, playbackMode, cancellationToken);
selectedMatch = NormalizeSelectedMatch(track, sourceProvider, selectedMatch);
matches = await GetOrderedMatchesAsync(track, sourceProvider, selectedMatch, playbackMode, profileId, cancellationToken);
}
var match = matches.FirstOrDefault(i => i.id == matchId && i.provider_id == provider);
if (match == null)
return false;
match.pinned = true;
return await MusicSourceMatchService.SaveAsync(track.id, match, playbackMode, cancellationToken);
}
static List<IMusicAudioProvider> GetProviderOrder(string provider)
{
if (!string.IsNullOrWhiteSpace(provider))
{
var selected = MusicProviderRegistry.GetAudioProvider(provider);
return selected == null ? new List<IMusicAudioProvider>() : new List<IMusicAudioProvider> { selected };
}
var ordered = new List<IMusicAudioProvider>();
var preferred = MusicProviderRegistry.GetAudioProvider();
if (preferred != null)
ordered.Add(preferred);
ordered.AddRange(MusicProviderRegistry.AudioProviders.Where(i => i.Enabled && ordered.All(x => x.Id != i.Id)));
return ordered;
}
static List<IMusicAudioProvider> ExpandProviderOrder(MusicTrack track, List<IMusicAudioProvider> providers)
{
if (track == null || providers == null || providers.Count == 0)
return providers;
var expanded = providers.ToList();
foreach (var provider in providers)
{
foreach (var fallbackId in provider.GetFallbackProviderIds(track) ?? Array.Empty<string>())
{
var fallback = MusicProviderRegistry.GetAudioProvider(fallbackId);
if (fallback == null || !fallback.Enabled || expanded.Any(i => string.Equals(i.Id, fallback.Id, StringComparison.OrdinalIgnoreCase)))
continue;
expanded.Add(fallback);
}
}
return expanded;
}
static MusicTrack BuildFallbackSearchTrack(MusicTrack track)
{
if (track == null)
return null;
return new MusicTrack
{
title = track.title,
artist_id = track.artist_id,
artist_name = track.artist_name,
artists = track.artists?.ToList() ?? new List<string>(),
album_id = track.album_id,
album_title = track.album_title,
isrc = track.isrc,
duration_ms = track.duration_ms,
track_number = track.track_number,
disc_number = track.disc_number,
date = track.date,
search_score = track.search_score,
images = track.images?.ToList() ?? new List<MusicImage>()
};
}
static async Task<IReadOnlyList<MusicAudioMatch>> GetOrderedMatchesAsync(MusicTrack track, IMusicAudioProvider provider, MusicAudioMatch selectedMatch, string playbackMode, string profileId, CancellationToken cancellationToken)
{
selectedMatch = NormalizeSelectedMatch(track, provider, selectedMatch);
var matches = (await provider.MatchTrackAsync(track, playbackMode, profileId, cancellationToken)).ToList();
if (selectedMatch == null)
return matches;
var ordered = new List<MusicAudioMatch> { selectedMatch };
ordered.AddRange(matches.Where(i => i.id != selectedMatch.id));
return ordered;
}
static MusicAudioMatch NormalizeSelectedMatch(MusicTrack track, IMusicAudioProvider provider, MusicAudioMatch selectedMatch)
{
if (selectedMatch == null)
return null;
bool relevant = provider?.IsRelevantMatch(track, selectedMatch) == true;
if (!relevant && provider?.ShouldValidatePinnedMatch(track, selectedMatch) == true)
return null;
// ручной выбор пользователя авторитетен: эвристики релевантности написаны
// против протухших АВТО-матчей, а здесь метаданные трека могут быть битыми
// (пользователь ровно поэтому и выбирал источник вручную)
if (selectedMatch.pinned)
return selectedMatch;
return relevant ? selectedMatch : null;
}
}
@@ -0,0 +1,258 @@
using Microsoft.Data.Sqlite;
namespace Music;
/// <summary>
/// Дневная статистика прослушиваний (track_stats_daily). Счётчики отдельно
/// от витрины: payload трека живёт в playback_history и джойнится при чтении.
/// Инкремент вызывается ТОЛЬКО из честного play-пути (count_play=true в mark) —
/// обновления payload (смена источника и т.п.) статистику не трогают.
/// </summary>
public static class MusicStatsService
{
// unlock «Твоего топа»: 10 разных треков обязательны (иначе «альбом из
// трёх песен»), плюс либо фаворит на повторе, либо просто наслушан объём —
// fallback для тех, кто слушает широко и не гоняет один трек по кругу
const int UnlockTrackPlays = 10;
const int UnlockDistinctTracks = 10;
const int UnlockTotalPlays = 100;
public static Task IncrementPlayAsync(string profileId, MusicTrack track, CancellationToken cancellationToken = default)
=> RecordPlayAsync(profileId, track, true, null, cancellationToken);
public static async Task<int> ClearAsync(string profileId, CancellationToken cancellationToken = default)
{
profileId = NormalizeProfileId(profileId);
if (string.IsNullOrWhiteSpace(profileId))
return 0;
var semaphore = new SemaphorManager(MusicContext.semaphoreKey, TimeSpan.FromSeconds(20));
var acquired = false;
try
{
if (!await semaphore.WaitAsync())
return 0;
acquired = true;
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = """
DELETE FROM track_stats_daily
WHERE profile_id = $profile_id;
""";
command.Parameters.AddWithValue("$profile_id", profileId);
return await command.ExecuteNonQueryAsync(cancellationToken);
}
finally
{
if (acquired)
semaphore.Release();
}
}
public static async Task RecordPlayAsync(string profileId, MusicTrack track, bool countPlay, long? playedMs = null, CancellationToken cancellationToken = default)
{
profileId = NormalizeProfileId(profileId);
if (track == null || string.IsNullOrWhiteSpace(track.id) || string.IsNullOrWhiteSpace(profileId))
return;
long durationMs = Math.Max(0, track.duration_ms ?? 0);
long listenedMs = Math.Max(0, playedMs ?? 0);
// Старые клиенты присылали только count_play=true; для совместимости
// оставляем прежнюю оценку полной длительностью. Новый клиент досылает
// реальные куски played_ms, поэтому накрутка не превращается в часы.
if (listenedMs <= 0 && countPlay)
listenedMs = durationMs;
if (durationMs > 0 && listenedMs > durationMs)
listenedMs = durationMs;
// sanity для треков без duration: один mark не должен записать сутки.
listenedMs = Math.Min(listenedMs, TimeSpan.FromHours(6).Ticks / TimeSpan.TicksPerMillisecond);
if (!countPlay && listenedMs <= 0)
return;
var now = DateTime.UtcNow;
var semaphore = new SemaphorManager(MusicContext.semaphoreKey, TimeSpan.FromSeconds(20));
var acquired = false;
try
{
if (!await semaphore.WaitAsync())
return;
acquired = true;
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
await connection.OpenAsync(cancellationToken);
await using var command = connection.CreateCommand();
command.CommandText = countPlay
? """
INSERT INTO track_stats_daily (profile_id, track_id, day, play_count, total_ms, last_played)
VALUES ($profile_id, $track_id, $day, 1, $total_ms, $last_played)
ON CONFLICT(profile_id, track_id, day) DO UPDATE SET
play_count = play_count + 1,
total_ms = total_ms + excluded.total_ms,
last_played = excluded.last_played;
"""
: """
UPDATE track_stats_daily
SET total_ms = total_ms + $total_ms,
last_played = $last_played
WHERE profile_id = $profile_id
AND track_id = $track_id
AND day = $day
AND play_count > 0;
""";
command.Parameters.AddWithValue("$profile_id", profileId);
command.Parameters.AddWithValue("$track_id", track.id);
command.Parameters.AddWithValue("$day", now.ToString("yyyy-MM-dd"));
command.Parameters.AddWithValue("$total_ms", listenedMs);
command.Parameters.AddWithValue("$last_played", now);
await command.ExecuteNonQueryAsync(cancellationToken);
}
finally
{
if (acquired)
semaphore.Release();
}
}
public static async Task<MusicStatsTopResult> GetTopAsync(string profileId, int limit = 30, CancellationToken cancellationToken = default)
{
profileId = NormalizeProfileId(profileId);
var result = new MusicStatsTopResult();
if (string.IsNullOrWhiteSpace(profileId))
return result;
limit = Math.Clamp(limit, 1, 100);
var rows = new List<(string trackId, long plays, long totalMs, DateTime lastPlayed)>();
long totalPlays = 0;
long totalMs = 0;
long distinctTracks = 0;
await using (var connection = new SqliteConnection(MusicContext.ConnectionString))
{
await connection.OpenAsync(cancellationToken);
await using (var totals = connection.CreateCommand())
{
totals.CommandText = """
SELECT COALESCE(SUM(play_count), 0), COALESCE(SUM(total_ms), 0), COUNT(DISTINCT track_id)
FROM track_stats_daily
WHERE profile_id = $profile_id;
""";
totals.Parameters.AddWithValue("$profile_id", profileId);
await using var totalsReader = await totals.ExecuteReaderAsync(cancellationToken);
if (await totalsReader.ReadAsync(cancellationToken))
{
totalPlays = totalsReader.GetInt64(0);
totalMs = totalsReader.GetInt64(1);
distinctTracks = totalsReader.GetInt64(2);
}
}
await using (var top = connection.CreateCommand())
{
top.CommandText = """
SELECT track_id, SUM(play_count) AS plays, SUM(total_ms) AS ms, MAX(last_played) AS last
FROM track_stats_daily
WHERE profile_id = $profile_id
GROUP BY track_id
ORDER BY plays DESC, last DESC
LIMIT $limit;
""";
top.Parameters.AddWithValue("$profile_id", profileId);
top.Parameters.AddWithValue("$limit", limit);
await using var reader = await top.ExecuteReaderAsync(cancellationToken);
while (await reader.ReadAsync(cancellationToken))
{
var trackId = reader.IsDBNull(0) ? null : reader.GetString(0);
if (string.IsNullOrWhiteSpace(trackId))
continue;
rows.Add((
trackId,
reader.GetInt64(1),
reader.GetInt64(2),
reader.IsDBNull(3) ? DateTime.MinValue : reader.GetDateTime(3)
));
}
}
}
result.total_plays = totalPlays;
result.total_ms = totalMs;
long maxTrackPlays = rows.Count > 0 ? rows[0].plays : 0;
result.unlocked = distinctTracks >= UnlockDistinctTracks
&& (maxTrackPlays >= UnlockTrackPlays || totalPlays >= UnlockTotalPlays);
if (!result.unlocked || rows.Count == 0)
return result;
// витрина: payload треков из playback_history (mark всегда пишет туда же)
var payloads = await MusicPlaybackHistoryService.GetTracksAsync(profileId, rows.Select(i => i.trackId), cancellationToken);
foreach (var row in rows)
{
if (!payloads.TryGetValue(row.trackId, out var track) || track == null)
continue;
result.tracks.Add(new MusicStatsTopTrack
{
track = track,
play_count = row.plays,
total_ms = row.totalMs,
last_played = row.lastPlayed == DateTime.MinValue ? null : row.lastPlayed
});
}
result.available = result.tracks.Count > 0;
return result;
}
static string NormalizeProfileId(string profileId)
=> string.IsNullOrWhiteSpace(profileId) ? null : profileId.Trim().ToLowerInvariant();
}
public class MusicStatsTopResult
{
public bool available { get; set; }
public bool unlocked { get; set; }
public long total_plays { get; set; }
public long total_ms { get; set; }
public List<MusicStatsTopTrack> tracks { get; set; } = new();
}
public class MusicStatsTopTrack
{
public MusicTrack track { get; set; }
public long play_count { get; set; }
public long total_ms { get; set; }
public DateTime? last_played { get; set; }
}
@@ -0,0 +1,78 @@
namespace Music;
public static class MusicStreamModeService
{
public const string Auto = "auto";
public const string Best = "best";
public const string Low = "low";
public const string PreferM4a = "m4a";
public const string PreferWebm = "webm";
public static string Normalize(string mode)
{
mode = mode?.Trim().ToLowerInvariant();
return mode switch
{
Best => Best,
Low => Low,
PreferM4a => PreferM4a,
PreferWebm => PreferWebm,
_ => Auto
};
}
public static List<MusicPlaybackSource> Order(IEnumerable<MusicPlaybackSource> sources, string mode)
{
if (sources == null)
return new List<MusicPlaybackSource>();
mode = Normalize(mode);
var list = sources.Where(i => i != null && !string.IsNullOrWhiteSpace(i.url)).ToList();
return mode switch
{
Best => list
.OrderByDescending(GetBitrate)
.ThenByDescending(IsMp4Like)
.ToList(),
Low => list
.OrderBy(GetBitrate)
.ThenByDescending(IsMp4Like)
.ToList(),
PreferM4a => list
.OrderByDescending(IsMp4Like)
.ThenByDescending(GetBitrate)
.ToList(),
PreferWebm => list
.OrderByDescending(IsWebmLike)
.ThenByDescending(GetBitrate)
.ToList(),
_ => list
.OrderByDescending(IsMp4Like)
.ThenByDescending(GetBitrate)
.ToList()
};
}
static int GetBitrate(MusicPlaybackSource source)
=> source?.bitrate ?? 0;
static bool IsMp4Like(MusicPlaybackSource source)
{
string mime = source?.mime_type?.ToLowerInvariant() ?? string.Empty;
string quality = source?.quality?.ToLowerInvariant() ?? string.Empty;
return mime.Contains("mp4") || quality.Contains("m4a") || quality.Contains("mp4");
}
static bool IsWebmLike(MusicPlaybackSource source)
{
string mime = source?.mime_type?.ToLowerInvariant() ?? string.Empty;
string quality = source?.quality?.ToLowerInvariant() ?? string.Empty;
return mime.Contains("webm") || quality.Contains("webm");
}
}
@@ -0,0 +1,282 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
namespace Music;
public static class MusicStreamRelayService
{
static readonly HttpClient musicStreamClient = CreateMusicStreamClient();
static readonly System.Collections.Concurrent.ConcurrentDictionary<string, ProxyClientEntry> proxyClients = new();
static readonly TimeSpan proxyClientIdleTtl = TimeSpan.FromMinutes(30);
const int maxProxyClients = 128;
static readonly Timer proxyClientCleanupTimer = new(
_ => CleanupProxyClients(),
null,
TimeSpan.FromMinutes(5),
TimeSpan.FromMinutes(5)
);
sealed class ProxyClientEntry
{
public ProxyClientEntry(HttpClient client)
{
Client = client;
LastUsedUtc = DateTime.UtcNow;
}
public readonly object Sync = new();
public HttpClient Client { get; }
public DateTime LastUsedUtc { get; set; }
public int ActiveRequests { get; set; }
public bool Retired { get; set; }
}
sealed class ClientLease : IDisposable
{
ProxyClientEntry entry;
public ClientLease(HttpClient client, ProxyClientEntry entry = null)
{
Client = client;
this.entry = entry;
}
public HttpClient Client { get; }
public void Dispose()
{
var current = Interlocked.Exchange(ref entry, null);
if (current == null)
return;
lock (current.Sync)
{
current.ActiveRequests = Math.Max(0, current.ActiveRequests - 1);
current.LastUsedUtc = DateTime.UtcNow;
}
}
}
// failOnUpstreamError: не транслировать клиенту мёртвую ссылку или ошибку
// прокси, а вернуть null — вызывающий пере-резолвит источник и маршрут.
public static async Task<ActionResult> RelayAsync(HttpContext httpContext, MusicPlaybackSource source, bool failOnUpstreamError = false)
{
if (httpContext == null || source == null || string.IsNullOrWhiteSpace(source.url))
return failOnUpstreamError ? null : new StatusCodeResult(404);
using var request = new HttpRequestMessage(HttpMethod.Get, source.url);
if (source.headers != null)
{
foreach (var header in source.headers)
{
if (string.IsNullOrWhiteSpace(header.Key) || string.IsNullOrWhiteSpace(header.Value))
continue;
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
}
if (httpContext.Request.Headers.TryGetValue("Range", out var range) && !string.IsNullOrWhiteSpace(range.ToString()))
request.Headers.TryAddWithoutValidation("Range", range.ToString());
using var clientLease = AcquireClient(source);
HttpClient client = clientLease.Client;
HttpResponseMessage response;
try
{
response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, httpContext.RequestAborted);
}
catch (OperationCanceledException) when (httpContext.RequestAborted.IsCancellationRequested)
{
return new EmptyResult();
}
catch (Exception ex) when (failOnUpstreamError)
{
ReportProxyFailure(source, ex);
return null;
}
catch (Exception ex)
{
ReportProxyFailure(source, ex);
throw;
}
using (response)
{
bool proxyFailure = MusicHttp.IsProxyFailureStatus(response.StatusCode);
if (proxyFailure)
MusicProxyService.ReportFailure(source.proxy_scope);
else
MusicProxyService.ReportSuccess(source.proxy_scope);
if (failOnUpstreamError && (IsDeadUpstreamStatus(response.StatusCode) || proxyFailure))
return null;
httpContext.Response.StatusCode = (int)response.StatusCode;
httpContext.Response.Headers.Remove("transfer-encoding");
CopyResponseHeader(httpContext, "Accept-Ranges", response.Headers, response.Content.Headers);
CopyResponseHeader(httpContext, "Content-Range", response.Headers, response.Content.Headers);
CopyResponseHeader(httpContext, "Content-Length", response.Headers, response.Content.Headers);
CopyResponseHeader(httpContext, "Content-Type", response.Headers, response.Content.Headers);
CopyResponseHeader(httpContext, "Cache-Control", response.Headers, response.Content.Headers);
CopyResponseHeader(httpContext, "Expires", response.Headers, response.Content.Headers);
CopyResponseHeader(httpContext, "Last-Modified", response.Headers, response.Content.Headers);
CopyResponseHeader(httpContext, "ETag", response.Headers, response.Content.Headers);
if (!string.IsNullOrWhiteSpace(source.mime_type))
httpContext.Response.ContentType = source.mime_type;
try
{
await using var stream = await response.Content.ReadAsStreamAsync(httpContext.RequestAborted);
await stream.CopyToAsync(httpContext.Response.Body, httpContext.RequestAborted);
}
catch (OperationCanceledException)
{
// клиент ушёл (перемотка/стоп/смена трека) — штатный путь
}
catch (IOException)
{
// upstream оборвал середину стрима: рвём соединение, чтобы клиент
// сделал range-retry, а не принял обрыв за конец файла
httpContext.Abort();
}
return new EmptyResult();
}
}
static bool IsDeadUpstreamStatus(HttpStatusCode statusCode)
=> statusCode is HttpStatusCode.Forbidden
or HttpStatusCode.NotFound
or HttpStatusCode.Gone
or HttpStatusCode.RequestedRangeNotSatisfiable;
static void ReportProxyFailure(MusicPlaybackSource source, Exception exception)
{
if (MusicHttp.IsProxyFailure(exception))
MusicProxyService.ReportFailure(source?.proxy_scope);
}
static ClientLease AcquireClient(MusicPlaybackSource source)
{
var proxy = CreateMusicStreamProxy(source);
if (proxy == null)
return new ClientLease(musicStreamClient);
// клиент на прокси кэшируется: без этого каждый range-запрос платит
// новый TLS-хендшейк через прокси. Lease не даёт очистке закрыть
// HttpClient, пока через него идёт активный stream/range-запрос.
string key = $"{source.proxy_url}|{source.proxy_username}|{source.proxy_password}";
while (true)
{
var entry = proxyClients.GetOrAdd(key, _ => new ProxyClientEntry(CreateProxyClient(proxy)));
lock (entry.Sync)
{
if (entry.Retired)
continue;
entry.ActiveRequests++;
entry.LastUsedUtc = DateTime.UtcNow;
return new ClientLease(entry.Client, entry);
}
}
}
static HttpClient CreateProxyClient(WebProxy proxy)
{
var handler = new HttpClientHandler
{
AllowAutoRedirect = true,
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli,
Proxy = proxy,
UseProxy = true
};
handler.ServerCertificateCustomValidationCallback += (_, _, _, _) => true;
return new HttpClient(handler)
{
Timeout = Timeout.InfiniteTimeSpan
};
}
static void CleanupProxyClients()
{
try
{
var now = DateTime.UtcNow;
foreach (var item in proxyClients.ToArray().OrderBy(i => i.Value.LastUsedUtc))
{
bool overLimit = proxyClients.Count > maxProxyClients;
bool expired = now - item.Value.LastUsedUtc >= proxyClientIdleTtl;
if (!overLimit && !expired)
continue;
HttpClient retiredClient = null;
lock (item.Value.Sync)
{
if (item.Value.Retired || item.Value.ActiveRequests > 0)
continue;
if (proxyClients.TryRemove(item.Key, out var removed) && ReferenceEquals(removed, item.Value))
{
item.Value.Retired = true;
retiredClient = item.Value.Client;
}
}
retiredClient?.Dispose();
}
}
catch
{
// Очистка кэша не должна влиять на активные стримы.
}
}
static HttpClient CreateMusicStreamClient()
{
var handler = new HttpClientHandler
{
AllowAutoRedirect = true,
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli
};
handler.ServerCertificateCustomValidationCallback += (_, _, _, _) => true;
return new HttpClient(handler)
{
Timeout = Timeout.InfiniteTimeSpan
};
}
static void CopyResponseHeader(HttpContext httpContext, string headerName, HttpResponseHeaders responseHeaders, HttpContentHeaders contentHeaders)
{
if (responseHeaders.TryGetValues(headerName, out var headerValues) || contentHeaders.TryGetValues(headerName, out headerValues))
httpContext.Response.Headers[headerName] = headerValues.ToArray();
}
static WebProxy CreateMusicStreamProxy(MusicPlaybackSource source)
{
if (source == null || string.IsNullOrWhiteSpace(source.proxy_url))
return null;
if (!Uri.TryCreate(source.proxy_url, UriKind.Absolute, out var proxyUri))
return null;
NetworkCredential credentials = null;
if (!string.IsNullOrWhiteSpace(source.proxy_username))
credentials = new NetworkCredential(source.proxy_username, source.proxy_password ?? string.Empty);
return new WebProxy(proxyUri, false, null, credentials);
}
}
@@ -0,0 +1,125 @@
using System.Collections.Concurrent;
namespace Music;
public static class MusicStreamTicketService
{
sealed class TicketEntry
{
public MusicPlaybackSource source;
// Interlocked по long: touch идёт из параллельных range-запросов без lock
public long lastAccessTicks;
public DateTime absoluteExpireUtc;
}
// пауза на локскрине может длиться долго, поэтому тикет продлевается на каждом
// обращении (sliding) вместо жёстких 30 минут; absolute cap ограничен временем
// жизни upstream-ссылок (googlevideo и т.п.)
static readonly TimeSpan slidingLifetime = TimeSpan.FromHours(2);
static readonly TimeSpan absoluteLifetime = TimeSpan.FromHours(6);
// кап жёсткий. Create() обязан вернуть живой тикет, поэтому вытеснение самых
// старых неактивных записей выполняется ДО вставки под тем же lock:
// MemoryCache.SizeLimit так не умеет — мог бы не сохранить запись, хотя билет
// уже ушёл клиенту (тогда /music/stream платил бы лишним re-resolve)
const int maxTickets = 2048;
static readonly object writeLock = new();
static readonly ConcurrentDictionary<string, TicketEntry> tickets = new(StringComparer.Ordinal);
public static string Create(MusicPlaybackSource source)
{
if (source == null || string.IsNullOrWhiteSpace(source.url))
return null;
var now = DateTime.UtcNow;
string ticket = Guid.NewGuid().ToString("N");
lock (writeLock)
{
if (tickets.Count >= maxTickets)
TrimNoLock(now);
tickets[ticket] = new TicketEntry
{
source = Clone(source),
lastAccessTicks = now.Ticks,
absoluteExpireUtc = now.Add(absoluteLifetime)
};
}
return ticket;
}
public static bool TryGet(string ticket, out MusicPlaybackSource source)
{
source = null;
if (string.IsNullOrWhiteSpace(ticket))
return false;
if (!tickets.TryGetValue(ticket, out var entry) || entry == null)
return false;
var now = DateTime.UtcNow;
if (now > entry.absoluteExpireUtc || now.Ticks - Interlocked.Read(ref entry.lastAccessTicks) > slidingLifetime.Ticks)
{
// remove-if-same: не задеваем запись, если под этим ключом что-то пересоздали
tickets.TryRemove(new KeyValuePair<string, TicketEntry>(ticket, entry));
return false;
}
// sliding touch
Interlocked.Exchange(ref entry.lastAccessTicks, now.Ticks);
source = Clone(entry.source);
return true;
}
static void TrimNoLock(DateTime now)
{
long slidingCutoffTicks = now.Ticks - slidingLifetime.Ticks;
foreach (var item in tickets)
{
if (now > item.Value.absoluteExpireUtc || Interlocked.Read(ref item.Value.lastAccessTicks) < slidingCutoffTicks)
tickets.TryRemove(item.Key, out _);
}
while (tickets.Count >= maxTickets)
{
string oldestKey = null;
long oldestTicks = long.MaxValue;
foreach (var item in tickets)
{
long ticks = Interlocked.Read(ref item.Value.lastAccessTicks);
if (ticks < oldestTicks)
{
oldestTicks = ticks;
oldestKey = item.Key;
}
}
if (oldestKey == null || !tickets.TryRemove(oldestKey, out _))
break;
}
}
static MusicPlaybackSource Clone(MusicPlaybackSource source)
{
return new MusicPlaybackSource
{
provider_id = source.provider_id,
url = source.url,
external_url = source.external_url,
mime_type = source.mime_type,
bitrate = source.bitrate,
quality = source.quality,
headers = source.headers?.ToDictionary(k => k.Key, v => v.Value) ?? new Dictionary<string, string>(),
proxy_url = source.proxy_url,
proxy_username = source.proxy_username,
proxy_password = source.proxy_password,
proxy_scope = source.proxy_scope
};
}
}