1599 lines
70 KiB
C#
1599 lines
70 KiB
C#
using System.Globalization;
|
||
|
||
namespace Music;
|
||
|
||
/// <summary>
|
||
/// «Миксы недели»: каждому дню недели детерминированно назначаются два
|
||
/// артиста из истории прослушиваний профиля + до двух похожих (доминирующие
|
||
/// чужие артисты из SoundCloud related-пулов) — это основа микса. Поверх —
|
||
/// Поверх — additive discovery-слой: Music-Map, SoundCloud-жанры и
|
||
/// Spotify related artists делят до 8 слотов с адаптивными квотами;
|
||
/// открытия вплетаются в микс равномерно. Seed =
|
||
/// profile + ISO-неделя, поэтому внутри дня и недели состав стабилен, а на
|
||
/// следующей неделе раскладка меняется. Сводки по дням — чистый SQL без
|
||
/// внешних HTTP; треки грузятся лениво при открытии микса.
|
||
/// </summary>
|
||
public static class MusicDailyMixService
|
||
{
|
||
static readonly TimeSpan mixTimeout = TimeSpan.FromSeconds(8);
|
||
static readonly TimeSpan mixCacheTtl = TimeSpan.FromHours(12);
|
||
static readonly TimeSpan shortMixCacheTtl = TimeSpan.FromMinutes(30);
|
||
const int historyDepth = 150;
|
||
const int ownArtistsPerDay = 2;
|
||
const int minHistoryArtists = 3;
|
||
const int maxAssignableArtists = 28;
|
||
// квота артистов из плейлистов пользователя (импорт Spotify/SC или свои):
|
||
// «декларированный вкус» участвует в неделе даже при богатой истории,
|
||
// а пользователю без истории плейлист сам открывает полку
|
||
const int maxPlaylistArtists = 8;
|
||
const int mixTrackLimit = 24;
|
||
const int minMixTracks = 8;
|
||
const int minSimilarPoolTracks = 3;
|
||
// рецепт разнообразия (просьба пользователя 2026-07-16: «чтобы клиент
|
||
// получал что новенькое»): 2 своих + до 2 похожих из SC related + до 2
|
||
// внешних с Music-Map = до 6 артистов на микс; round-robin выравнивает
|
||
// по ~4 трека на артиста, свои артисты идут первыми пулами
|
||
const int maxSimilarArtists = 2;
|
||
const int maxExternalArtists = 2;
|
||
const int maxRefillExternalArtists = 6;
|
||
const int externalPickWindow = 12;
|
||
// Music-Map + жанр сохраняют свои прежние 5 слотов. Spotify related
|
||
// получает до 4 слотов, но общий discovery-слой ограничен 8:
|
||
// источники адаптивно забирают незанятые места, но не вытесняют основу.
|
||
const int coreDiscoveryTrackBudget = 5;
|
||
const int discoveryTrackBudget = 8;
|
||
const int spotifyTrackQuota = 4;
|
||
const int desiredMixTrackCount = 20;
|
||
const int maxMainTracksPerArtist = 4;
|
||
const int maxDiscoveryTracksPerArtist = 2;
|
||
const int externalPoolCandidateLimit = 10;
|
||
const int genreTrackQuota = 4;
|
||
const int genrePoolFetchLimit = 30;
|
||
const int spotifyFallbackTarget = 16;
|
||
const int spotifyRelatedArtistLimit = 2;
|
||
static readonly TimeSpan genreCacheTtl = TimeSpan.FromHours(6);
|
||
static readonly TimeSpan artistGenresCacheTtl = TimeSpan.FromDays(14);
|
||
static readonly TimeSpan spotifyDiscoveryCacheTtl = TimeSpan.FromDays(7);
|
||
// кэш собранных кандидатов недели (история + парс payload всех плейлистов —
|
||
// самая дорогая часть /music/home). Сбор НЕ зависит от недели и salt (они
|
||
// входят только в шаффл), поэтому ключ — профиль; сброс миксов и смена
|
||
// недели получают новую раскладку мгновенно из кэшированного сбора.
|
||
// Пустой/ошибочный сбор живёт 45s (ревью Codex: новый пользователь после
|
||
// импорта плейлиста не должен ждать TTL, чтобы полка появилась)
|
||
static readonly TimeSpan seedCacheTtl = TimeSpan.FromMinutes(12);
|
||
static readonly TimeSpan seedCacheEmptyTtl = TimeSpan.FromSeconds(45);
|
||
const int seedArtistsCacheCapacity = 256;
|
||
static readonly System.Collections.Concurrent.ConcurrentDictionary<string, (DateTime expiresAt, List<HistoryArtist> artists)> seedArtistsCache = new(StringComparer.Ordinal);
|
||
// кап жёсткий: чтение lock-free, trim + вставка под общим lock — иначе
|
||
// параллельные сборы одновременно пройдут проверку Count и вместе её пробьют
|
||
static readonly object seedArtistsCacheWriteLock = new();
|
||
// v19-target-20: расширенный discovery-слой не раздувает итог выше целевых 20
|
||
// v18-spotify-peer: Spotify related участвует во всех миксах с адаптивной квотой 2–4
|
||
// v17-spotify-buckets: фиты Spotify считаются по primary artist и не обходят cap
|
||
// v16-spotify-fallback: короткие миксы добираются чистой Spotify
|
||
// metadata сидов, затем максимум двух related artists, только до 16
|
||
// v15-short-ttl: миксы короче 16 треков живут в кэше 30m, а не 12h
|
||
// v14-secondary-genre: один дополнительный жанр добирает только
|
||
// короткие (<16) миксы; обычные миксы и HTTP-бюджет не меняются
|
||
// v13-metadata: неразрушающая нормализация + подтверждение Music-Map
|
||
// external через YouTube author-match либо SoundCloud ISRC
|
||
// v12: основной artist-cap снижен до 4 треков, чтобы якорь не доминировал
|
||
// в 20-трековом миксе; v11: короткие миксы после artist-cap добираются дополнительными
|
||
// Music-Map/genre артистами до ~20 треков без снятия лимитов на артиста;
|
||
// v10: финальный artist-cap в миксе, чтобы один исполнитель/related-пул
|
||
// не забивал весь день и у discovery/genre оставалось место;
|
||
// v9: косметика title strip для дуэтов "Artist & Other - Title";
|
||
// v8: artist-pool теперь гейтит кандидатов по запрошенному артисту и
|
||
// канонизирует uploader/channel-имена; v7 — title-prefix нормализация сидов
|
||
// применяется и к истории (YouTube/SC channel/uploader -> реальный Artist - Title)
|
||
// v20: пересобираем миксы с album_id из Spotify, чтобы playback мог
|
||
// быстро получить точную длительность без обращения к MusicBrainz.
|
||
const string cacheVersion = "dailymix-v20-spotify-album-id";
|
||
|
||
static readonly string[] dayTitles =
|
||
{
|
||
"Понедельник", "Вторник", "Среда", "Четверг", "Пятница", "Суббота", "Воскресенье"
|
||
};
|
||
|
||
sealed class HistoryArtist
|
||
{
|
||
public string Name;
|
||
public string NormalizedName;
|
||
public MusicTrack RecentTrack;
|
||
}
|
||
|
||
sealed class PlaylistArtistCandidate
|
||
{
|
||
public string Name;
|
||
public string NormalizedName;
|
||
public MusicTrack RecentTrack;
|
||
public int Count;
|
||
public int FirstIndex;
|
||
public int NoisyCount;
|
||
}
|
||
|
||
sealed class GenrePoolPlan
|
||
{
|
||
public List<List<MusicTrack>> PrimaryPools { get; init; } = new();
|
||
public string SecondaryGenre { get; init; }
|
||
}
|
||
|
||
sealed class SpotifyDiscoveryPlan
|
||
{
|
||
public List<SpotifySupport.ArtistDiscoveryResult> SeedDiscoveries { get; init; } = new();
|
||
public List<List<MusicTrack>> RelatedPools { get; init; } = new();
|
||
}
|
||
|
||
// кэшируемый payload микса; null из фабрики (слабый результат) не кэшируется
|
||
sealed class DailyMixPayload
|
||
{
|
||
public List<string> artists { get; set; } = new();
|
||
public List<MusicTrack> tracks { get; set; } = new();
|
||
}
|
||
|
||
public static async Task<List<MusicDailyMixSummary>> GetSummariesAsync(string profileId, int salt = 0, CancellationToken cancellationToken = default)
|
||
{
|
||
var (assignment, historyArtists) = await BuildWeekAssignmentAsync(profileId, salt, cancellationToken);
|
||
if (assignment == null)
|
||
return new List<MusicDailyMixSummary>();
|
||
|
||
// при бедной истории показываем столько дней, сколько закрывается
|
||
// РАЗНЫМИ парами артистов, — иначе полка выглядит натянуто.
|
||
// Полное покрытие (14+ артистов) — вся неделя с переносом через
|
||
// Вс→Пн; частичное — остаток текущей недели от сегодня БЕЗ переноса:
|
||
// шаг пар по модулю n на границе недели коллизится (7 простое),
|
||
// а «полка живёт до воскресенья» честно матчится с «обновится в
|
||
// понедельник»
|
||
int coverage = Math.Min(7, historyArtists.Count / ownArtistsPerDay);
|
||
int today = IsoDayOfWeek(DateTime.UtcNow);
|
||
var result = new List<MusicDailyMixSummary>(coverage);
|
||
|
||
for (int offset = 0; offset < coverage; offset++)
|
||
{
|
||
int day = coverage >= 7
|
||
? (today - 1 + offset) % 7 + 1
|
||
: today + offset;
|
||
|
||
if (day > 7)
|
||
break;
|
||
|
||
result.Add(new MusicDailyMixSummary
|
||
{
|
||
day = day,
|
||
title = dayTitles[day - 1],
|
||
today = day == today,
|
||
artists = assignment[day - 1].Select(i => i.Name).ToList()
|
||
});
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
public static async Task<MusicDailyMixResponse> GetMixAsync(string profileId, int day, int salt = 0, CancellationToken cancellationToken = default)
|
||
{
|
||
if (day is < 1 or > 7)
|
||
return Unavailable(day, "Некорректный день недели.");
|
||
|
||
var (assignment, historyArtists) = await BuildWeekAssignmentAsync(profileId, salt, cancellationToken);
|
||
if (assignment == null)
|
||
return Unavailable(day, "Слишком мало истории — послушай больше музыки, миксы соберутся сами.");
|
||
|
||
var seeds = assignment[day - 1];
|
||
string cacheKey = $"{cacheVersion}|{NormalizeProfileKey(profileId)}|{SeedKey(salt)}|{day}|{string.Join(",", seeds.Select(i => i.NormalizedName))}";
|
||
|
||
DailyMixPayload payload = null;
|
||
|
||
try
|
||
{
|
||
payload = await MusicMetadataCacheService.GetOrCreateAsync(
|
||
"dailymix",
|
||
"daily_mix",
|
||
cacheKey,
|
||
mixCacheTtl,
|
||
() => BuildMixAsync(profileId, day, salt, seeds, historyArtists, cancellationToken),
|
||
cancellationToken,
|
||
created => created.tracks.Count < 16 ? shortMixCacheTtl : mixCacheTtl);
|
||
}
|
||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||
{
|
||
throw;
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
|
||
if (payload == null || payload.tracks.Count < minMixTracks)
|
||
return Unavailable(day, "Микс не собрался — попробуй чуть позже.");
|
||
|
||
return new MusicDailyMixResponse
|
||
{
|
||
available = true,
|
||
day = day,
|
||
title = dayTitles[day - 1],
|
||
artists = payload.artists,
|
||
tracks = payload.tracks
|
||
};
|
||
}
|
||
|
||
// раскладка артистов истории по 7 дням; null = истории мало, полку не показываем.
|
||
// Детерминирована в пределах ISO-недели: seed-перемешивание + шаг по 2 артиста
|
||
// на день, с wrap'ом при коротком списке. Сколько дней из раскладки реально
|
||
// показывать (уникальные пары), решает GetSummariesAsync по artistCount
|
||
static async Task<(List<HistoryArtist>[] assignment, List<HistoryArtist> artists)> BuildWeekAssignmentAsync(string profileId, int salt, CancellationToken cancellationToken)
|
||
{
|
||
var artists = await CollectSeedArtistsCachedAsync(profileId, cancellationToken);
|
||
if (artists.Count < minHistoryArtists)
|
||
return (null, artists);
|
||
|
||
// вход шаффла сортируем по стабильному ключу: без этого любое
|
||
// прослушивание меняло recency-порядок истории и пересобирало ВСЮ
|
||
// раскладку недели (живой промах: миксы уехали после теста на
|
||
// телефоне). Теперь раскладка сдвигается только при смене САМОГО
|
||
// состава top-28 (дебют/выпадение артиста), а не порядка
|
||
artists.Sort((a, b) => string.CompareOrdinal(a.NormalizedName, b.NormalizedName));
|
||
|
||
ShuffleDeterministic(artists, Fnv1aHash($"{NormalizeProfileKey(profileId)}|{SeedKey(salt)}"));
|
||
|
||
var assignment = new List<HistoryArtist>[7];
|
||
|
||
for (int day = 1; day <= 7; day++)
|
||
{
|
||
var picks = new List<HistoryArtist>(ownArtistsPerDay);
|
||
|
||
for (int slot = 0; slot < ownArtistsPerDay; slot++)
|
||
{
|
||
var candidate = artists[((day - 1) * ownArtistsPerDay + slot) % artists.Count];
|
||
if (!picks.Contains(candidate))
|
||
picks.Add(candidate);
|
||
}
|
||
|
||
assignment[day - 1] = picks;
|
||
}
|
||
|
||
return (assignment, artists);
|
||
}
|
||
|
||
static async Task<List<HistoryArtist>> CollectSeedArtistsCachedAsync(string profileId, CancellationToken cancellationToken)
|
||
{
|
||
string key = NormalizeProfileKey(profileId);
|
||
|
||
// возвращаем КОПИЮ списка: BuildWeekAssignmentAsync сортирует и
|
||
// шаффлит его на месте — общий инстанс между запросами дал бы гонку
|
||
if (seedArtistsCache.TryGetValue(key, out var cached) && cached.expiresAt > DateTime.UtcNow)
|
||
return new List<HistoryArtist>(cached.artists);
|
||
|
||
var artists = await CollectSeedArtistsAsync(profileId, cancellationToken);
|
||
|
||
var ttl = artists.Count >= minHistoryArtists ? seedCacheTtl : seedCacheEmptyTtl;
|
||
|
||
lock (seedArtistsCacheWriteLock)
|
||
{
|
||
if (!seedArtistsCache.ContainsKey(key) && seedArtistsCache.Count >= seedArtistsCacheCapacity)
|
||
{
|
||
// сначала протухшие; если их нет — вытесняем с ближайшим истечением,
|
||
// иначе при равномерном трафике свежие профили переполнят кап
|
||
foreach (var stale in seedArtistsCache.Where(i => i.Value.expiresAt <= DateTime.UtcNow).ToList())
|
||
seedArtistsCache.TryRemove(stale.Key, out _);
|
||
|
||
while (seedArtistsCache.Count >= seedArtistsCacheCapacity)
|
||
{
|
||
var oldest = seedArtistsCache.OrderBy(i => i.Value.expiresAt).FirstOrDefault();
|
||
if (oldest.Key == null || !seedArtistsCache.TryRemove(oldest.Key, out _))
|
||
break;
|
||
}
|
||
}
|
||
|
||
seedArtistsCache[key] = (DateTime.UtcNow.Add(ttl), new List<HistoryArtist>(artists));
|
||
}
|
||
|
||
return artists;
|
||
}
|
||
|
||
// кандидаты недели: история прослушиваний (реальное поведение, до 28) +
|
||
// артисты из плейлистов пользователя (декларированный вкус, квота 8,
|
||
// гарантирована — история при переполнении ужимается с наименее свежего
|
||
// края). Пользователь без истории, но с плейлистом ≥3 артистов получает
|
||
// полку целиком из плейлиста
|
||
static async Task<List<HistoryArtist>> CollectSeedArtistsAsync(string profileId, CancellationToken cancellationToken)
|
||
{
|
||
var artists = await CollectHistoryArtistsAsync(profileId, cancellationToken);
|
||
var playlistArtists = new List<HistoryArtist>();
|
||
|
||
try
|
||
{
|
||
var playlistTracks = await MusicUserPlaylistService.GetAllTracksAsync(profileId, cancellationToken);
|
||
playlistArtists = RankPlaylistArtists(playlistTracks, artists)
|
||
.Take(maxPlaylistArtists)
|
||
.ToList();
|
||
}
|
||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||
{
|
||
throw;
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
|
||
if (playlistArtists.Count == 0)
|
||
return artists;
|
||
|
||
int historyKeep = Math.Min(artists.Count, maxAssignableArtists - playlistArtists.Count);
|
||
return artists.Take(historyKeep).Concat(playlistArtists).ToList();
|
||
}
|
||
|
||
static List<HistoryArtist> RankPlaylistArtists(List<MusicTrack> playlistTracks, List<HistoryArtist> historyArtists)
|
||
{
|
||
var groups = new List<PlaylistArtistCandidate>();
|
||
var byNormalized = new Dictionary<string, PlaylistArtistCandidate>(StringComparer.OrdinalIgnoreCase);
|
||
var history = historyArtists ?? new List<HistoryArtist>();
|
||
int index = 0;
|
||
|
||
foreach (var rawTrack in playlistTracks ?? new List<MusicTrack>())
|
||
{
|
||
var track = NormalizeSeedTrack(rawTrack);
|
||
string name = MusicRadioService.CleanupArtistName(track?.artist_name);
|
||
if (string.IsNullOrWhiteSpace(name))
|
||
continue;
|
||
|
||
string normalized = MusicRadioService.NormalizeText(name);
|
||
if (string.IsNullOrWhiteSpace(normalized))
|
||
continue;
|
||
|
||
if (history.Any(existing => MusicMapSupport.IsAliasOf(name, existing.Name)))
|
||
continue;
|
||
|
||
if (!byNormalized.TryGetValue(normalized, out var group))
|
||
{
|
||
group = groups.FirstOrDefault(existing => MusicMapSupport.IsAliasOf(name, existing.Name));
|
||
|
||
if (group == null)
|
||
{
|
||
group = new PlaylistArtistCandidate
|
||
{
|
||
Name = name,
|
||
NormalizedName = normalized,
|
||
RecentTrack = track,
|
||
FirstIndex = index
|
||
};
|
||
groups.Add(group);
|
||
}
|
||
|
||
byNormalized[normalized] = group;
|
||
}
|
||
|
||
group.Count++;
|
||
if (IsNoisyDiscoveryTrack(track))
|
||
group.NoisyCount++;
|
||
|
||
// Для related-пула лучше держать первый чистый трек артиста: он
|
||
// идёт сидом в SoundCloud related. Если первый был slowed/reverb, а
|
||
// позже встретился обычный трек — заменяем только сид, не порядок.
|
||
if (IsNoisyDiscoveryTrack(group.RecentTrack) && !IsNoisyDiscoveryTrack(track))
|
||
group.RecentTrack = track;
|
||
|
||
index++;
|
||
}
|
||
|
||
return groups
|
||
.OrderByDescending(group => PlaylistArtistScore(group))
|
||
.ThenBy(group => group.FirstIndex)
|
||
.Select(group => new HistoryArtist
|
||
{
|
||
Name = group.Name,
|
||
NormalizedName = group.NormalizedName,
|
||
RecentTrack = group.RecentTrack
|
||
})
|
||
.ToList();
|
||
}
|
||
|
||
static int PlaylistArtistScore(PlaylistArtistCandidate group)
|
||
{
|
||
// Повторы в плейлистах — главный сигнал намерения. Одноразовые SC-
|
||
// uploader'ы остаются fallback'ом для холодного старта, но не перебивают
|
||
// артистов, реально часто встречающихся в сохранённой музыке.
|
||
return group.Count * 100 - group.NoisyCount * 30;
|
||
}
|
||
|
||
// канонизация сида рекомендаций (история И плейлисты): из uploader-имён
|
||
// вида «Artist - Title» вытаскивается реальный артист
|
||
static MusicTrack NormalizeSeedTrack(MusicTrack track)
|
||
{
|
||
if (track == null)
|
||
return null;
|
||
|
||
var result = CloneTrack(track);
|
||
|
||
if ((IsProviderUploadTrack(result) || string.IsNullOrWhiteSpace(result.artist_name))
|
||
&& TrySplitLeadingArtistTitle(result.title, out var artist, out var title))
|
||
{
|
||
result.artist_name = artist;
|
||
result.artists = new List<string> { artist };
|
||
result.title = title;
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
static bool TrySplitLeadingArtistTitle(string rawTitle, out string artist, out string title)
|
||
{
|
||
artist = null;
|
||
title = null;
|
||
|
||
var match = System.Text.RegularExpressions.Regex.Match(rawTitle ?? string.Empty, @"^\s*(?<artist>.{2,80}?)\s+[-–—]{1,2}\s+(?<title>.{2,180})\s*$");
|
||
if (!match.Success)
|
||
return false;
|
||
|
||
artist = MusicRadioService.CleanupArtistName(match.Groups["artist"].Value.Trim());
|
||
title = match.Groups["title"].Value.Trim();
|
||
|
||
if (string.IsNullOrWhiteSpace(artist) || string.IsNullOrWhiteSpace(title))
|
||
return false;
|
||
|
||
return MusicRadioService.NormalizeText(artist).Length >= 2
|
||
&& MusicRadioService.NormalizeText(title).Length >= 2;
|
||
}
|
||
|
||
static bool IsProviderUploadTrack(MusicTrack track)
|
||
{
|
||
if (track == null)
|
||
return false;
|
||
|
||
if ((track.id ?? string.Empty).StartsWith("soundcloud:", StringComparison.OrdinalIgnoreCase))
|
||
return true;
|
||
|
||
if ((track.id ?? string.Empty).StartsWith("youtube:", StringComparison.OrdinalIgnoreCase))
|
||
return true;
|
||
|
||
return (track.provider_refs ?? new List<MusicProviderRef>())
|
||
.Any(i => (i.provider ?? string.Empty).Contains("soundcloud", StringComparison.OrdinalIgnoreCase));
|
||
}
|
||
|
||
static bool IsNoisyDiscoveryTrack(MusicTrack track)
|
||
{
|
||
string value = MusicRadioService.NormalizeText(track?.title);
|
||
if (string.IsNullOrWhiteSpace(value))
|
||
return true;
|
||
|
||
string padded = $" {value} ";
|
||
string[] noisy =
|
||
{
|
||
" slowed ", " reverb ", " sped up ", " nightcore ", " remix ",
|
||
" cover ", " karaoke ", " type beat ", " freestyle "
|
||
};
|
||
|
||
return noisy.Any(word => padded.Contains(word, StringComparison.OrdinalIgnoreCase));
|
||
}
|
||
|
||
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 async Task<List<HistoryArtist>> CollectHistoryArtistsAsync(string profileId, CancellationToken cancellationToken)
|
||
{
|
||
var result = new List<HistoryArtist>();
|
||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
|
||
List<MusicRecentlyPlayedItem> history;
|
||
try
|
||
{
|
||
history = await MusicPlaybackHistoryService.GetRecentAsync(profileId, historyDepth, cancellationToken);
|
||
}
|
||
catch
|
||
{
|
||
return result;
|
||
}
|
||
|
||
// история отсортирована по свежести — первый встреченный трек артиста
|
||
// и есть его самый свежий (идёт сид-треком в related-пул)
|
||
foreach (var item in history)
|
||
{
|
||
var track = NormalizeSeedTrack(item?.track);
|
||
if (track == null)
|
||
continue;
|
||
|
||
string name = MusicRadioService.CleanupArtistName(track.artist_name);
|
||
if (string.IsNullOrWhiteSpace(name))
|
||
continue;
|
||
|
||
string normalized = MusicRadioService.NormalizeText(name);
|
||
if (string.IsNullOrWhiteSpace(normalized) || !seen.Add(normalized))
|
||
continue;
|
||
|
||
// кластер-дедуп: варианты одного артиста («Cкриптoнит»/«Скриптонит»,
|
||
// «Basta, GUF»/«Basta & Guf», «Miyagi & Эндшпиль»/«MiyaGi & Эндшпиль»)
|
||
// не должны занимать несколько слотов недели — живой промах: день
|
||
// из двух алиасов одного кластера вырождается в моно-артист микс
|
||
if (result.Any(existing => MusicMapSupport.IsAliasOf(name, existing.Name)))
|
||
continue;
|
||
|
||
result.Add(new HistoryArtist
|
||
{
|
||
Name = name,
|
||
NormalizedName = normalized,
|
||
RecentTrack = track
|
||
});
|
||
|
||
if (result.Count >= maxAssignableArtists)
|
||
break;
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
static async Task<DailyMixPayload> BuildMixAsync(string profileId, int day, int salt, List<HistoryArtist> seeds, List<HistoryArtist> historyArtists, CancellationToken cancellationToken)
|
||
{
|
||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||
timeoutCts.CancelAfter(mixTimeout);
|
||
|
||
var artistPoolTasks = seeds
|
||
.Select(seed => MusicRadioService.LoadArtistPoolAsync(seed.Name, timeoutCts.Token))
|
||
.ToList();
|
||
|
||
var relatedTasks = SoundCloudSupport.IsDiscoveryEnabled
|
||
? seeds.Select(seed => MusicRadioService.LoadRelatedPoolAsync(seed.RecentTrack, timeoutCts.Token)).ToList()
|
||
: new List<Task<List<MusicTrack>>>();
|
||
|
||
try
|
||
{
|
||
await Task.WhenAll(artistPoolTasks.Concat(relatedTasks));
|
||
}
|
||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||
{
|
||
throw;
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
|
||
static List<MusicTrack> PoolOf(Task<List<MusicTrack>> task) => task.IsCompletedSuccessfully
|
||
? task.Result ?? new List<MusicTrack>()
|
||
: new List<MusicTrack>();
|
||
|
||
var pools = artistPoolTasks.Select(PoolOf).Where(pool => pool.Count > 0).ToList();
|
||
var mixArtists = seeds.Select(i => i.Name).ToList();
|
||
|
||
// похожие — доминирующие чужие артисты из related-пулов сид-треков:
|
||
// их треки уже на руках, дополнительных HTTP не нужно
|
||
var relatedTracks = relatedTasks.SelectMany(PoolOf).ToList();
|
||
var similars = PickSimilarArtistPools(relatedTracks, seeds, maxSimilarArtists);
|
||
|
||
foreach (var similar in similars)
|
||
{
|
||
pools.Add(similar.pool);
|
||
mixArtists.Add(similar.name);
|
||
}
|
||
|
||
if (pools.Count == 0)
|
||
return null;
|
||
|
||
// сэмплирование внутри дня тоже детерминировано: пул может обновиться
|
||
// (6h TTL radio-кэша), а микс дня не должен скакать между заходами
|
||
uint sampleSeed = Fnv1aHash($"{NormalizeProfileKey(profileId)}|{SeedKey(salt)}|{day}|tracks");
|
||
|
||
// Music-Map — только additive-слой. Запускаем его после проверки, что
|
||
// базовый микс уже есть: так при пустых artist/related-пулах не остаются
|
||
// фоновые HTTP-задачи, а общий 8-секундный бюджет всё равно сохраняется.
|
||
var musicMapTasks = seeds
|
||
.Select(seed => MusicMapSupport.GetSimilarArtistsAsync(seed.Name, timeoutCts.Token))
|
||
.ToList();
|
||
|
||
// жанровые пулы — тоже additive-слой, стартуют параллельно с картами
|
||
// (жанры артистов кэшируются 14d, пул жанра — 6h)
|
||
var genrePoolsTask = SoundCloudSupport.IsDiscoveryEnabled
|
||
? LoadGenrePoolsAsync(seeds, timeoutCts.Token)
|
||
: Task.FromResult(new GenrePoolPlan());
|
||
|
||
// Spotify стартует параллельно с другим discovery. На холодном кэше
|
||
// это не добавляет последовательную задержку к сборке микса.
|
||
var spotifyDiscoveryTask = ModInit.conf?.spotify_discovery_enabled == true
|
||
? LoadSpotifyDiscoveryPlanAsync(seeds, mixArtists, sampleSeed, timeoutCts.Token)
|
||
: Task.FromResult(new SpotifyDiscoveryPlan());
|
||
|
||
for (int i = 0; i < pools.Count; i++)
|
||
{
|
||
var pool = pools[i].ToList();
|
||
ShuffleDeterministic(pool, sampleSeed + (uint)i);
|
||
pools[i] = pool;
|
||
}
|
||
|
||
var seedTracks = seeds.Select(i => i.RecentTrack).ToList();
|
||
var seedNoisyWords = MusicRadioService.CollectSeedNoisyWords(seedTracks);
|
||
bool requireCyrillic = seedTracks.Count > 0
|
||
&& seedTracks.All(seed => MusicRadioService.ContainsCyrillic(seed?.title) || MusicRadioService.ContainsCyrillic(seed?.artist_name));
|
||
|
||
// дедуп-сеты общие для слоя открытий и основного заполнения
|
||
var excludedIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
var excludedKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
var excludedTitles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
var outputIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
var outputKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
var outputTitles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
|
||
// ЕДИНЫЙ СЛОЙ ОТКРЫТИЙ (общий бюджет discoveryTrackBudget, ревью
|
||
// Codex): сперва Music-Map externals (похожесть на твоих), остаток
|
||
// добирает жанр дня; Music-Map пуст — жанр берёт весь бюджет.
|
||
// Строго additive, в слотах карточки НЕ анонсируется — сюрприз.
|
||
// Для «специй» шумовые слова не прощаются, даже если были в сидах:
|
||
// discovery должен удивлять артистом, а не slowed/remix-версией
|
||
var discoveryOutput = new List<MusicTrack>();
|
||
var strictNoisyWords = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
var discoveryArtistCounts = new Dictionary<string, int>(StringComparer.Ordinal);
|
||
var externalArtists = new List<string>();
|
||
List<List<MusicTrack>> genrePools = null;
|
||
|
||
try
|
||
{
|
||
externalArtists = await PickExternalArtistsAsync(musicMapTasks, seeds, historyArtists, similars.Select(i => i.name), sampleSeed);
|
||
|
||
var externalPools = await LoadExternalArtistPoolsAsync(externalArtists, timeoutCts.Token);
|
||
|
||
if (externalPools.Count > 0)
|
||
MusicRadioService.FillRoundRobin(
|
||
externalPools, discoveryOutput, coreDiscoveryTrackBudget,
|
||
strictNoisyWords, requireCyrillic,
|
||
excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles,
|
||
discoveryArtistCounts, maxDiscoveryTracksPerArtist);
|
||
|
||
int remaining = Math.Min(coreDiscoveryTrackBudget - discoveryOutput.Count, genreTrackQuota);
|
||
|
||
if (remaining > 0)
|
||
{
|
||
var knownNames = historyArtists.Select(i => i.Name)
|
||
.Concat(mixArtists)
|
||
.Concat(externalArtists)
|
||
.ToList();
|
||
|
||
genrePools = await PrepareGenrePoolsAsync(genrePoolsTask, knownNames, sampleSeed, timeoutCts.Token);
|
||
|
||
if (genrePools.Count > 0)
|
||
FillDiscoveryFromGenrePools(
|
||
genrePools, discoveryOutput, discoveryOutput.Count + remaining,
|
||
strictNoisyWords, requireCyrillic,
|
||
excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles,
|
||
discoveryArtistCounts);
|
||
}
|
||
|
||
var spotifyPlan = await spotifyDiscoveryTask;
|
||
int spotifyLimit = Math.Min(
|
||
discoveryTrackBudget,
|
||
discoveryOutput.Count + spotifyTrackQuota);
|
||
|
||
FillSpotifyRelatedPools(
|
||
spotifyPlan.RelatedPools,
|
||
discoveryOutput,
|
||
spotifyLimit,
|
||
strictNoisyWords,
|
||
requireCyrillic,
|
||
discoveryArtistCounts,
|
||
excludedIds,
|
||
excludedKeys,
|
||
excludedTitles,
|
||
outputIds,
|
||
outputKeys,
|
||
outputTitles);
|
||
}
|
||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||
{
|
||
throw;
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
|
||
var output = new List<MusicTrack>();
|
||
var mainArtistCounts = new Dictionary<string, int>(StringComparer.Ordinal);
|
||
MusicRadioService.FillRoundRobin(
|
||
pools,
|
||
output,
|
||
Math.Min(
|
||
mixTrackLimit - discoveryOutput.Count,
|
||
desiredMixTrackCount - discoveryOutput.Count),
|
||
seedNoisyWords,
|
||
requireCyrillic,
|
||
excludedIds,
|
||
excludedKeys,
|
||
excludedTitles,
|
||
outputIds,
|
||
outputKeys,
|
||
outputTitles,
|
||
mainArtistCounts,
|
||
maxMainTracksPerArtist);
|
||
|
||
int desiredDiscoveryLimit = Math.Min(
|
||
mixTrackLimit - output.Count,
|
||
desiredMixTrackCount - output.Count);
|
||
|
||
if (desiredDiscoveryLimit > discoveryOutput.Count)
|
||
{
|
||
try
|
||
{
|
||
await RefillShortMixAsync(
|
||
musicMapTasks,
|
||
genrePoolsTask,
|
||
seeds,
|
||
historyArtists,
|
||
mixArtists,
|
||
similars.Select(i => i.name),
|
||
externalArtists,
|
||
sampleSeed,
|
||
strictNoisyWords,
|
||
requireCyrillic,
|
||
discoveryOutput,
|
||
desiredDiscoveryLimit,
|
||
output.Count,
|
||
discoveryArtistCounts,
|
||
excludedIds,
|
||
excludedKeys,
|
||
excludedTitles,
|
||
outputIds,
|
||
outputKeys,
|
||
outputTitles,
|
||
genrePools,
|
||
timeoutCts.Token);
|
||
}
|
||
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
|
||
{
|
||
// The discovery budget expired; keep the tracks already collected.
|
||
}
|
||
}
|
||
|
||
if (ModInit.conf?.spotify_discovery_enabled == true
|
||
&& output.Count + discoveryOutput.Count < spotifyFallbackTarget
|
||
&& desiredDiscoveryLimit > discoveryOutput.Count)
|
||
{
|
||
await FillSpotifyFallbackAsync(
|
||
spotifyDiscoveryTask,
|
||
sampleSeed,
|
||
seedNoisyWords,
|
||
strictNoisyWords,
|
||
requireCyrillic,
|
||
discoveryOutput,
|
||
desiredDiscoveryLimit,
|
||
output.Count,
|
||
mainArtistCounts,
|
||
discoveryArtistCounts,
|
||
excludedIds,
|
||
excludedKeys,
|
||
excludedTitles,
|
||
outputIds,
|
||
outputKeys,
|
||
outputTitles,
|
||
timeoutCts.Token,
|
||
cancellationToken);
|
||
}
|
||
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
|
||
// открытия вплетаются равномерно, а не хвостом:
|
||
// при 19+5 — позиции 4, 9, 14, 19, 24
|
||
if (discoveryOutput.Count > 0)
|
||
{
|
||
int step = (output.Count + discoveryOutput.Count) / (discoveryOutput.Count + 1);
|
||
|
||
for (int i = 0; i < discoveryOutput.Count; i++)
|
||
output.Insert(Math.Min(output.Count, (i + 1) * step + i), discoveryOutput[i]);
|
||
}
|
||
|
||
// auto_radio — маркер автопродолжения очереди; для микса дня это
|
||
// обычные треки плейлиста, флаг с общего FillRoundRobin снимаем
|
||
foreach (var track in output)
|
||
track.auto_radio = false;
|
||
|
||
return output.Count >= minMixTracks
|
||
? new DailyMixPayload { artists = mixArtists, tracks = output }
|
||
: null;
|
||
}
|
||
|
||
// Artist-cap защищает от доминирования одного исполнителя, но у бедного дня
|
||
// после этого может остаться 10-15 треков. Добор включается только тогда:
|
||
// берём ещё внешних Music-Map артистов и, если надо, жанровые пулы. Лимиты на
|
||
// артиста остаются теми же, поэтому микс растёт шириной, а не повтором.
|
||
static async Task RefillShortMixAsync(
|
||
List<Task<List<string>>> musicMapTasks,
|
||
Task<GenrePoolPlan> genrePoolsTask,
|
||
List<HistoryArtist> seeds,
|
||
List<HistoryArtist> historyArtists,
|
||
List<string> mixArtists,
|
||
IEnumerable<string> similarArtists,
|
||
List<string> selectedExternalArtists,
|
||
uint sampleSeed,
|
||
HashSet<string> strictNoisyWords,
|
||
bool requireCyrillic,
|
||
List<MusicTrack> discoveryOutput,
|
||
int desiredDiscoveryLimit,
|
||
int mainTrackCount,
|
||
Dictionary<string, int> discoveryArtistCounts,
|
||
HashSet<string> excludedIds,
|
||
HashSet<string> excludedKeys,
|
||
HashSet<string> excludedTitles,
|
||
HashSet<string> outputIds,
|
||
HashSet<string> outputKeys,
|
||
HashSet<string> outputTitles,
|
||
List<List<MusicTrack>> preparedGenrePools,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
if (desiredDiscoveryLimit <= discoveryOutput.Count)
|
||
return;
|
||
|
||
try
|
||
{
|
||
var extraExternalArtists = await PickExternalArtistsAsync(
|
||
musicMapTasks,
|
||
seeds,
|
||
historyArtists,
|
||
(similarArtists ?? Enumerable.Empty<string>()).Concat(selectedExternalArtists),
|
||
sampleSeed + 0x51F15EED,
|
||
maxRefillExternalArtists);
|
||
|
||
if (extraExternalArtists.Count > 0)
|
||
{
|
||
selectedExternalArtists.AddRange(extraExternalArtists);
|
||
|
||
var extraPools = await LoadExternalArtistPoolsAsync(extraExternalArtists, cancellationToken);
|
||
|
||
if (extraPools.Count > 0)
|
||
MusicRadioService.FillRoundRobin(
|
||
extraPools, discoveryOutput, desiredDiscoveryLimit,
|
||
strictNoisyWords, requireCyrillic,
|
||
excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles,
|
||
discoveryArtistCounts, maxDiscoveryTracksPerArtist);
|
||
}
|
||
}
|
||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||
{
|
||
throw;
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
|
||
if (desiredDiscoveryLimit <= discoveryOutput.Count)
|
||
return;
|
||
|
||
try
|
||
{
|
||
var genrePools = preparedGenrePools;
|
||
if (genrePools == null)
|
||
{
|
||
var knownNames = historyArtists.Select(i => i.Name)
|
||
.Concat(mixArtists)
|
||
.Concat(selectedExternalArtists)
|
||
.ToList();
|
||
|
||
genrePools = await PrepareGenrePoolsAsync(genrePoolsTask, knownNames, sampleSeed, cancellationToken);
|
||
}
|
||
|
||
if (genrePools.Count > 0)
|
||
FillDiscoveryFromGenrePools(
|
||
genrePools, discoveryOutput, desiredDiscoveryLimit,
|
||
strictNoisyWords, requireCyrillic,
|
||
excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles,
|
||
discoveryArtistCounts);
|
||
|
||
if (mainTrackCount + discoveryOutput.Count >= 16
|
||
|| desiredDiscoveryLimit <= discoveryOutput.Count)
|
||
return;
|
||
|
||
var secondaryGenrePools = await PrepareGenrePoolsAsync(
|
||
genrePoolsTask,
|
||
historyArtists.Select(i => i.Name)
|
||
.Concat(mixArtists)
|
||
.Concat(selectedExternalArtists),
|
||
sampleSeed + 0x6A09E667,
|
||
cancellationToken,
|
||
secondary: true);
|
||
|
||
if (secondaryGenrePools.Count > 0)
|
||
FillDiscoveryFromGenrePools(
|
||
secondaryGenrePools, discoveryOutput, desiredDiscoveryLimit,
|
||
strictNoisyWords, requireCyrillic,
|
||
excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles,
|
||
discoveryArtistCounts);
|
||
}
|
||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||
{
|
||
throw;
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
}
|
||
|
||
// общий для main-заполнения и refill: пулы внешних Music-Map артистов.
|
||
// Топ выдачи поиска = популярные треки артиста — правильное знакомство
|
||
// с незнакомым именем. Окно шире лимита на артиста: если первые результаты
|
||
// шумные/дубли, FillRoundRobin дойдёт до нормального трека, а artist-cap
|
||
// не даст внешнему артисту доминировать
|
||
static async Task<List<List<MusicTrack>>> LoadExternalArtistPoolsAsync(List<string> artists, CancellationToken cancellationToken)
|
||
{
|
||
var poolTasks = artists
|
||
.Select(artist => MusicRadioService.LoadArtistPoolAsync(artist, cancellationToken, requireConfirmedArtist: true))
|
||
.ToList();
|
||
|
||
try
|
||
{
|
||
await Task.WhenAll(poolTasks);
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
|
||
return poolTasks
|
||
.Where(task => task.IsCompletedSuccessfully)
|
||
.Select(task => task.Result ?? new List<MusicTrack>())
|
||
.Where(pool => pool.Count > 0)
|
||
.Select(pool => pool.Take(externalPoolCandidateLimit).ToList())
|
||
.ToList();
|
||
}
|
||
|
||
static async Task FillSpotifyFallbackAsync(
|
||
Task<SpotifyDiscoveryPlan> spotifyDiscoveryTask,
|
||
uint sampleSeed,
|
||
HashSet<string> seedNoisyWords,
|
||
HashSet<string> strictNoisyWords,
|
||
bool requireCyrillic,
|
||
List<MusicTrack> discoveryOutput,
|
||
int desiredDiscoveryLimit,
|
||
int mainTrackCount,
|
||
Dictionary<string, int> mainArtistCounts,
|
||
Dictionary<string, int> discoveryArtistCounts,
|
||
HashSet<string> excludedIds,
|
||
HashSet<string> excludedKeys,
|
||
HashSet<string> excludedTitles,
|
||
HashSet<string> outputIds,
|
||
HashSet<string> outputKeys,
|
||
HashSet<string> outputTitles,
|
||
CancellationToken budgetToken,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
int missing = spotifyFallbackTarget - mainTrackCount - discoveryOutput.Count;
|
||
if (missing <= 0)
|
||
return;
|
||
|
||
int target = Math.Min(desiredDiscoveryLimit, discoveryOutput.Count + missing);
|
||
|
||
try
|
||
{
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
// A completed plan is still useful after expiry, but no new discovery
|
||
// request or extra timeout is allowed for the fallback.
|
||
var plan = spotifyDiscoveryTask.IsCompletedSuccessfully
|
||
? spotifyDiscoveryTask.Result
|
||
: await spotifyDiscoveryTask.WaitAsync(budgetToken);
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
if (plan == null)
|
||
return;
|
||
|
||
var seedDiscoveries = plan.SeedDiscoveries;
|
||
|
||
var seedPools = seedDiscoveries
|
||
.Select(item => item.top_tracks
|
||
.Select(MusicRadioService.NormalizeCandidateMetadata)
|
||
.Where(track => track != null)
|
||
.ToList())
|
||
.Where(pool => pool.Count > 0)
|
||
.ToList();
|
||
|
||
for (int i = 0; i < seedPools.Count; i++)
|
||
ShuffleDeterministic(seedPools[i], sampleSeed + 0x3C6EF372 + (uint)i);
|
||
|
||
if (seedPools.Count > 0)
|
||
{
|
||
MusicRadioService.FillRoundRobin(
|
||
seedPools, discoveryOutput, target,
|
||
seedNoisyWords, requireCyrillic,
|
||
excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles,
|
||
mainArtistCounts, maxMainTracksPerArtist);
|
||
|
||
if (mainTrackCount + discoveryOutput.Count < spotifyFallbackTarget && requireCyrillic)
|
||
MusicRadioService.FillRoundRobin(
|
||
seedPools, discoveryOutput, target,
|
||
seedNoisyWords, false,
|
||
excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles,
|
||
mainArtistCounts, maxMainTracksPerArtist);
|
||
}
|
||
|
||
if (mainTrackCount + discoveryOutput.Count >= spotifyFallbackTarget
|
||
|| discoveryOutput.Count >= target)
|
||
return;
|
||
|
||
FillSpotifyRelatedPools(
|
||
plan.RelatedPools,
|
||
discoveryOutput,
|
||
target,
|
||
strictNoisyWords,
|
||
requireCyrillic,
|
||
discoveryArtistCounts,
|
||
excludedIds,
|
||
excludedKeys,
|
||
excludedTitles,
|
||
outputIds,
|
||
outputKeys,
|
||
outputTitles);
|
||
}
|
||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||
{
|
||
throw;
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
}
|
||
|
||
static async Task<SpotifyDiscoveryPlan> LoadSpotifyDiscoveryPlanAsync(
|
||
List<HistoryArtist> seeds,
|
||
IEnumerable<string> mixArtists,
|
||
uint sampleSeed,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var seedTasks = seeds.Select(seed => LoadSpotifyArtistDiscoveryByNameAsync(seed.Name, cancellationToken)).ToList();
|
||
|
||
try
|
||
{
|
||
await Task.WhenAll(seedTasks);
|
||
}
|
||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||
{
|
||
throw;
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
|
||
var seedDiscoveries = seedTasks
|
||
.Where(task => task.IsCompletedSuccessfully && task.Result != null)
|
||
.Select(task => task.Result)
|
||
.ToList();
|
||
|
||
if (seedDiscoveries.Count == 0)
|
||
return new SpotifyDiscoveryPlan();
|
||
|
||
var excludedNames = seeds.Select(seed => seed.Name)
|
||
.Concat(mixArtists ?? Enumerable.Empty<string>())
|
||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||
.ToList();
|
||
|
||
var relatedCandidates = seedDiscoveries
|
||
.SelectMany(discovery => discovery.related_artists.Select((artist, rank) => new { artist, rank }))
|
||
.Where(item => item.artist != null
|
||
&& !string.IsNullOrWhiteSpace(item.artist.id)
|
||
&& !string.IsNullOrWhiteSpace(item.artist.name)
|
||
&& !excludedNames.Any(name => MusicMapSupport.IsAliasOf(item.artist.name, name)))
|
||
.GroupBy(item => MusicRadioService.NormalizeText(item.artist.name), StringComparer.Ordinal)
|
||
.Where(group => !string.IsNullOrWhiteSpace(group.Key))
|
||
.Select(group => new
|
||
{
|
||
artist = group.First().artist,
|
||
sources = group.Count(),
|
||
rank = group.Min(item => item.rank)
|
||
})
|
||
.OrderByDescending(item => item.sources)
|
||
.ThenBy(item => item.rank)
|
||
.Take(8)
|
||
.ToList();
|
||
|
||
// Совпадение related от обоих сидов важнее позиции в одном списке.
|
||
// Внутри одинаковой силы порядок зависит от seed дня.
|
||
ShuffleDeterministic(relatedCandidates, sampleSeed + 0xBB67AE85);
|
||
var selectedRelated = relatedCandidates
|
||
.OrderByDescending(item => item.sources)
|
||
.Take(spotifyRelatedArtistLimit)
|
||
.Select(item => item.artist)
|
||
.ToList();
|
||
|
||
var relatedTasks = selectedRelated
|
||
.Select(artist => LoadSpotifyArtistDiscoveryByIdAsync(artist.id, cancellationToken))
|
||
.ToList();
|
||
|
||
try
|
||
{
|
||
await Task.WhenAll(relatedTasks);
|
||
}
|
||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||
{
|
||
throw;
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
|
||
var relatedPools = relatedTasks
|
||
.Where(task => task.IsCompletedSuccessfully && task.Result != null)
|
||
.Select(task => task.Result.top_tracks
|
||
.Select(MusicRadioService.NormalizeCandidateMetadata)
|
||
.Where(track => track != null)
|
||
.ToList())
|
||
.Where(pool => pool.Count > 0)
|
||
.ToList();
|
||
|
||
for (int i = 0; i < relatedPools.Count; i++)
|
||
ShuffleDeterministic(relatedPools[i], sampleSeed + 0xA54FF53A + (uint)i);
|
||
|
||
return new SpotifyDiscoveryPlan
|
||
{
|
||
SeedDiscoveries = seedDiscoveries,
|
||
RelatedPools = relatedPools
|
||
};
|
||
}
|
||
|
||
static void FillSpotifyRelatedPools(
|
||
List<List<MusicTrack>> relatedPools,
|
||
List<MusicTrack> discoveryOutput,
|
||
int limit,
|
||
HashSet<string> strictNoisyWords,
|
||
bool requireCyrillic,
|
||
Dictionary<string, int> discoveryArtistCounts,
|
||
HashSet<string> excludedIds,
|
||
HashSet<string> excludedKeys,
|
||
HashSet<string> excludedTitles,
|
||
HashSet<string> outputIds,
|
||
HashSet<string> outputKeys,
|
||
HashSet<string> outputTitles)
|
||
{
|
||
if (relatedPools == null || relatedPools.Count == 0 || discoveryOutput.Count >= limit)
|
||
return;
|
||
|
||
MusicRadioService.FillRoundRobin(
|
||
relatedPools, discoveryOutput, limit,
|
||
strictNoisyWords, requireCyrillic,
|
||
excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles,
|
||
discoveryArtistCounts, maxDiscoveryTracksPerArtist);
|
||
|
||
if (discoveryOutput.Count < limit && requireCyrillic)
|
||
MusicRadioService.FillRoundRobin(
|
||
relatedPools, discoveryOutput, limit,
|
||
strictNoisyWords, false,
|
||
excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles,
|
||
discoveryArtistCounts, maxDiscoveryTracksPerArtist);
|
||
}
|
||
|
||
static Task<SpotifySupport.ArtistDiscoveryResult> LoadSpotifyArtistDiscoveryByNameAsync(string artist, CancellationToken cancellationToken)
|
||
{
|
||
return MusicMetadataCacheService.GetOrCreateAsync(
|
||
SpotifySupport.ProviderId,
|
||
"weekly_artist_discovery",
|
||
$"artist-name-v2|{MusicRadioService.NormalizeText(artist)}",
|
||
spotifyDiscoveryCacheTtl,
|
||
() => SpotifySupport.GetArtistDiscoveryByNameAsync(artist, cancellationToken: cancellationToken),
|
||
cancellationToken);
|
||
}
|
||
|
||
static Task<SpotifySupport.ArtistDiscoveryResult> LoadSpotifyArtistDiscoveryByIdAsync(string artistId, CancellationToken cancellationToken)
|
||
{
|
||
return MusicMetadataCacheService.GetOrCreateAsync(
|
||
SpotifySupport.ProviderId,
|
||
"weekly_artist_discovery",
|
||
$"artist-id-v2|{artistId}",
|
||
spotifyDiscoveryCacheTtl,
|
||
() => SpotifySupport.GetArtistDiscoveryAsync(artistId, cancellationToken: cancellationToken),
|
||
cancellationToken);
|
||
}
|
||
|
||
// общий для main-заполнения и refill: жанровый слой в два прохода —
|
||
// сперва в родном скрипт-режиме микса; если кириллический фильтр съел всё
|
||
// (глобальный жанровый топ чаще латиница) — второй проход без него:
|
||
// жанровый слой и задуман как сознательный выход за рамки привычного
|
||
static void FillDiscoveryFromGenrePools(
|
||
List<List<MusicTrack>> genrePools,
|
||
List<MusicTrack> discoveryOutput,
|
||
int limit,
|
||
HashSet<string> strictNoisyWords,
|
||
bool requireCyrillic,
|
||
HashSet<string> excludedIds,
|
||
HashSet<string> excludedKeys,
|
||
HashSet<string> excludedTitles,
|
||
HashSet<string> outputIds,
|
||
HashSet<string> outputKeys,
|
||
HashSet<string> outputTitles,
|
||
Dictionary<string, int> discoveryArtistCounts)
|
||
{
|
||
int beforeGenre = discoveryOutput.Count;
|
||
|
||
MusicRadioService.FillRoundRobin(
|
||
genrePools, discoveryOutput, limit,
|
||
strictNoisyWords, requireCyrillic,
|
||
excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles,
|
||
discoveryArtistCounts, maxDiscoveryTracksPerArtist);
|
||
|
||
if (discoveryOutput.Count == beforeGenre && requireCyrillic)
|
||
MusicRadioService.FillRoundRobin(
|
||
genrePools, discoveryOutput, limit,
|
||
strictNoisyWords, false,
|
||
excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles,
|
||
discoveryArtistCounts, maxDiscoveryTracksPerArtist);
|
||
}
|
||
|
||
static async Task<List<List<MusicTrack>>> PrepareGenrePoolsAsync(
|
||
Task<GenrePoolPlan> genrePoolsTask,
|
||
IEnumerable<string> knownNames,
|
||
uint sampleSeed,
|
||
CancellationToken cancellationToken,
|
||
bool secondary = false)
|
||
{
|
||
List<List<MusicTrack>> source;
|
||
|
||
try
|
||
{
|
||
var plan = await genrePoolsTask ?? new GenrePoolPlan();
|
||
|
||
if (!secondary)
|
||
{
|
||
source = plan.PrimaryPools;
|
||
}
|
||
else if (!string.IsNullOrWhiteSpace(plan.SecondaryGenre))
|
||
{
|
||
var secondaryPool = await LoadGenrePoolAsync(plan.SecondaryGenre, cancellationToken);
|
||
source = secondaryPool.Count > 0
|
||
? new List<List<MusicTrack>> { secondaryPool }
|
||
: new List<List<MusicTrack>>();
|
||
}
|
||
else
|
||
{
|
||
source = new List<List<MusicTrack>>();
|
||
}
|
||
}
|
||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||
{
|
||
throw;
|
||
}
|
||
catch
|
||
{
|
||
return new List<List<MusicTrack>>();
|
||
}
|
||
|
||
var known = (knownNames ?? Enumerable.Empty<string>())
|
||
.Where(i => !string.IsNullOrWhiteSpace(i))
|
||
.ToList();
|
||
|
||
var genrePools = source
|
||
.Select(genreTracks => genreTracks
|
||
.Select(MusicRadioService.NormalizeCandidateMetadata)
|
||
.Where(track => !string.IsNullOrWhiteSpace(track?.artist_name)
|
||
&& !known.Any(name => MusicMapSupport.IsAliasOf(track.artist_name, name)))
|
||
.ToList())
|
||
.Where(pool => pool.Count > 0)
|
||
.ToList();
|
||
|
||
for (int i = 0; i < genrePools.Count; i++)
|
||
ShuffleDeterministic(genrePools[i], sampleSeed + 777 + (uint)i);
|
||
|
||
return genrePools;
|
||
}
|
||
|
||
// жанр дня: доминирующие genre-теги из SC-поиска по обоим сид-артистам
|
||
// (кэш 14d на артиста) -> канонический жанр-фасет SC -> популярные треки
|
||
// жанра (кэш 6h). Жанр берётся только ПРИ УВЕРЕННОСТИ (ревью Codex):
|
||
// явный победитель — один пул; ничья двух сильных жанров (артисты дня
|
||
// из разных миров) — слой делится между обоими; слабый сигнал — слоя нет
|
||
static async Task<GenrePoolPlan> LoadGenrePoolsAsync(List<HistoryArtist> seeds, CancellationToken cancellationToken)
|
||
{
|
||
var genreTasks = seeds.Select(seed => MusicMetadataCacheService.GetOrCreateAsync(
|
||
SoundCloudSupport.DiscoveryProviderId,
|
||
"artist_genres",
|
||
$"genres-v1|{MusicRadioService.NormalizeText(seed.Name)}",
|
||
artistGenresCacheTtl,
|
||
() => SoundCloudSupport.SearchDominantGenresAsync(seed.Name, cancellationToken),
|
||
cancellationToken)).ToList();
|
||
|
||
try
|
||
{
|
||
await Task.WhenAll(genreTasks);
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
|
||
// голоса по убыванию ранга: топ-жанр артиста весит больше
|
||
var votes = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||
|
||
foreach (var task in genreTasks.Where(t => t.IsCompletedSuccessfully))
|
||
{
|
||
var genres = task.Result ?? new List<string>();
|
||
|
||
for (int rank = 0; rank < genres.Count; rank++)
|
||
{
|
||
string canonical = SoundCloudSupport.MapToCanonicalGenre(genres[rank]);
|
||
if (string.IsNullOrWhiteSpace(canonical))
|
||
continue;
|
||
|
||
votes[canonical] = votes.TryGetValue(canonical, out int current)
|
||
? current + (3 - rank)
|
||
: 3 - rank;
|
||
}
|
||
}
|
||
|
||
var ranked = votes.OrderByDescending(i => i.Value).ToList();
|
||
|
||
// слабый сигнал: жанр не был топ-тегом ни одного артиста (score < 3)
|
||
if (ranked.Count == 0 || ranked[0].Value < 3)
|
||
return new GenrePoolPlan();
|
||
|
||
var chosenGenres = new List<string> { ranked[0].Key };
|
||
|
||
// ничья двух сильных жанров — артисты дня из разных миров:
|
||
// делим жанровый слой между обоими вместо случайного победителя
|
||
if (ranked.Count > 1 && ranked[1].Value >= 3 && ranked[1].Value * 2 > ranked[0].Value)
|
||
chosenGenres.Add(ranked[1].Key);
|
||
|
||
// Пул не грузим заранее: он нужен только если после обычного
|
||
// добора микс остался короче 16 треков. Берём ровно один следующий
|
||
// жанр и только при реальном сигнале от сидов (score >= 2).
|
||
string secondaryGenre = ranked
|
||
.Where(item => item.Value >= 2 && !chosenGenres.Contains(item.Key, StringComparer.OrdinalIgnoreCase))
|
||
.Select(item => item.Key)
|
||
.FirstOrDefault();
|
||
|
||
var poolTasks = chosenGenres.Select(genre => LoadGenrePoolAsync(genre, cancellationToken)).ToList();
|
||
|
||
try
|
||
{
|
||
await Task.WhenAll(poolTasks);
|
||
}
|
||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||
{
|
||
throw;
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
|
||
return new GenrePoolPlan
|
||
{
|
||
PrimaryPools = poolTasks
|
||
.Where(task => task.IsCompletedSuccessfully)
|
||
.Select(task => task.Result ?? new List<MusicTrack>())
|
||
.Where(pool => pool.Count > 0)
|
||
.ToList(),
|
||
SecondaryGenre = secondaryGenre
|
||
};
|
||
}
|
||
|
||
static Task<List<MusicTrack>> LoadGenrePoolAsync(string genre, CancellationToken cancellationToken)
|
||
{
|
||
return MusicMetadataCacheService.GetOrCreateAsync(
|
||
SoundCloudSupport.DiscoveryProviderId,
|
||
"genre_tracks",
|
||
$"genrepool-v1|{genre}",
|
||
genreCacheTtl,
|
||
() => SoundCloudSupport.SearchTracksByGenreAsync(genre, genrePoolFetchLimit, cancellationToken),
|
||
cancellationToken);
|
||
}
|
||
|
||
// внешние похожие артисты с Music-Map: пересечение карт обоих сид-артистов
|
||
// (похож на обоих = сильный сигнал вайба дня), при пустом пересечении —
|
||
// union в порядке близости; алиасы сидов, вся top-28 история и уже
|
||
// выбранные SC-similar исключены; seeded-выбор из ближайшего окна —
|
||
// стабилен в пределах недели, разный между неделями
|
||
static async Task<List<string>> PickExternalArtistsAsync(
|
||
List<Task<List<string>>> musicMapTasks,
|
||
List<HistoryArtist> seeds,
|
||
List<HistoryArtist> historyArtists,
|
||
IEnumerable<string> similarArtists,
|
||
uint seed,
|
||
int maxArtists = maxExternalArtists)
|
||
{
|
||
try
|
||
{
|
||
await Task.WhenAll(musicMapTasks);
|
||
}
|
||
catch
|
||
{
|
||
}
|
||
|
||
var maps = musicMapTasks
|
||
.Where(task => task.IsCompletedSuccessfully)
|
||
.Select(task => task.Result ?? new List<string>())
|
||
.Where(map => map.Count > 0)
|
||
.ToList();
|
||
|
||
if (maps.Count == 0)
|
||
return new List<string>();
|
||
|
||
var aliasReferences = seeds.Select(i => i.Name).ToList();
|
||
var excludeNames = historyArtists.Select(i => i.Name)
|
||
.Concat(similarArtists ?? Enumerable.Empty<string>())
|
||
.Where(i => !string.IsNullOrWhiteSpace(i))
|
||
.ToList();
|
||
|
||
var filtered = maps
|
||
.Select(map => MusicMapSupport.FilterCandidates(map, aliasReferences, excludeNames))
|
||
.Where(map => map.Count > 0)
|
||
.ToList();
|
||
|
||
if (filtered.Count == 0)
|
||
return new List<string>();
|
||
|
||
List<string> candidates;
|
||
|
||
if (filtered.Count > 1)
|
||
{
|
||
var otherKeys = filtered.Skip(1)
|
||
.Select(map => map.Select(MusicMapSupport.NormalizeName).ToHashSet(StringComparer.Ordinal))
|
||
.ToList();
|
||
|
||
candidates = filtered[0]
|
||
.Where(name => otherKeys.All(keys => keys.Contains(MusicMapSupport.NormalizeName(name))))
|
||
.ToList();
|
||
|
||
if (candidates.Count == 0)
|
||
{
|
||
candidates = new List<string>();
|
||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||
|
||
foreach (var name in filtered.SelectMany(map => map))
|
||
{
|
||
if (seen.Add(MusicMapSupport.NormalizeName(name)))
|
||
candidates.Add(name);
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
candidates = filtered[0];
|
||
}
|
||
|
||
if (candidates.Count == 0)
|
||
return new List<string>();
|
||
|
||
// несколько seeded-выборов из ближайшего окна без повторов/алиасов
|
||
var window = candidates.Take(externalPickWindow).ToList();
|
||
var picks = new List<string>();
|
||
uint state = seed ^ 0x9E3779B9;
|
||
|
||
while (picks.Count < maxArtists && window.Count > 0)
|
||
{
|
||
state = NextXorshift(state);
|
||
string pick = window[(int)(state % (uint)window.Count)];
|
||
window.Remove(pick);
|
||
|
||
if (!picks.Any(chosen => MusicMapSupport.IsAliasOf(pick, chosen)))
|
||
picks.Add(pick);
|
||
}
|
||
|
||
return picks;
|
||
}
|
||
|
||
static uint NextXorshift(uint seed)
|
||
{
|
||
uint state = seed == 0 ? 2463534242 : seed;
|
||
state ^= state << 13;
|
||
state ^= state >> 17;
|
||
state ^= state << 5;
|
||
return state;
|
||
}
|
||
|
||
static List<(string name, List<MusicTrack> pool)> PickSimilarArtistPools(List<MusicTrack> relatedTracks, List<HistoryArtist> seeds, int maxArtists)
|
||
{
|
||
var seedNames = new HashSet<string>(seeds.Select(i => i.NormalizedName), StringComparer.OrdinalIgnoreCase);
|
||
var groups = new Dictionary<string, (string name, List<MusicTrack> pool)>(StringComparer.OrdinalIgnoreCase);
|
||
|
||
foreach (var raw in relatedTracks)
|
||
{
|
||
var track = MusicRadioService.NormalizeCandidateMetadata(raw);
|
||
string name = MusicRadioService.CleanupArtistName(track?.artist_name);
|
||
if (string.IsNullOrWhiteSpace(name))
|
||
continue;
|
||
|
||
string normalized = MusicRadioService.NormalizeText(name);
|
||
if (string.IsNullOrWhiteSpace(normalized) || seedNames.Contains(normalized))
|
||
continue;
|
||
|
||
// кросс-скрипт/опечаточные дубли сида NormalizeText не ловит
|
||
// (живой кейс: «Cкриптoнит» с латинскими C/o в истории vs
|
||
// «Скриптонит» из related) — добиваем транслит+fuzzy Music-Map
|
||
if (seeds.Any(seed => MusicMapSupport.IsAliasOf(name, seed.Name)))
|
||
continue;
|
||
|
||
if (!groups.TryGetValue(normalized, out var group))
|
||
groups[normalized] = group = (name, new List<MusicTrack>());
|
||
|
||
group.pool.Add(track);
|
||
}
|
||
|
||
var result = new List<(string name, List<MusicTrack> pool)>();
|
||
|
||
foreach (var group in groups.Values.OrderByDescending(i => i.pool.Count))
|
||
{
|
||
// группы отсортированы по убыванию — дальше только мельче
|
||
if (group.pool.Count < minSimilarPoolTracks)
|
||
break;
|
||
|
||
if (result.Any(chosen => MusicMapSupport.IsAliasOf(group.name, chosen.name)))
|
||
continue;
|
||
|
||
result.Add(group);
|
||
|
||
if (result.Count >= maxArtists)
|
||
break;
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
static MusicDailyMixResponse Unavailable(int day, string message) => new()
|
||
{
|
||
available = false,
|
||
day = day,
|
||
title = day is >= 1 and <= 7 ? dayTitles[day - 1] : null,
|
||
message = message
|
||
};
|
||
|
||
static string NormalizeProfileKey(string profileId)
|
||
=> (profileId ?? string.Empty).Trim().ToLowerInvariant();
|
||
|
||
// Неделя СОЗНАТЕЛЬНО считается по UTC, а не по локальному времени
|
||
// (для UTC+3 полка обновляется ~03:00 ночи понедельника — приемлемо):
|
||
// граница недели не зависит от таймзоны контейнера и переводов часов,
|
||
// а «локальный понедельник» потребовал бы таймзону профиля — новое
|
||
// состояние ради сдвига на пару часов. Это решение, не недосмотр
|
||
static string WeekKey()
|
||
{
|
||
var now = DateTime.UtcNow;
|
||
return $"{ISOWeek.GetYear(now)}-w{ISOWeek.GetWeekOfYear(now):D2}";
|
||
}
|
||
|
||
// salt — «сброс миксов недели» по кнопке: клиент хранит счётчик в своём
|
||
// Storage (скоуп совпадает с per-browser профилем) и шлёт параметром;
|
||
// salt=0 сохраняет исходный формат seed'а — дефолтная раскладка не
|
||
// меняется от самого факта появления параметра
|
||
static string SeedKey(int salt)
|
||
=> salt == 0 ? WeekKey() : $"{WeekKey()}|{salt}";
|
||
|
||
static int IsoDayOfWeek(DateTime date)
|
||
=> ((int)date.DayOfWeek + 6) % 7 + 1;
|
||
|
||
// string.GetHashCode рандомизирован per-process — для стабильного seed
|
||
// между рестартами контейнера нужен собственный хэш
|
||
static uint Fnv1aHash(string value)
|
||
{
|
||
uint hash = 2166136261;
|
||
|
||
foreach (char c in value ?? string.Empty)
|
||
{
|
||
hash ^= c;
|
||
hash *= 16777619;
|
||
}
|
||
|
||
return hash;
|
||
}
|
||
|
||
static void ShuffleDeterministic<T>(List<T> list, uint seed)
|
||
{
|
||
uint state = seed == 0 ? 2463534242 : seed;
|
||
|
||
uint next()
|
||
{
|
||
// xorshift32 — дешёвый детерминированный PRNG, качества хватает
|
||
state ^= state << 13;
|
||
state ^= state >> 17;
|
||
state ^= state << 5;
|
||
return state;
|
||
}
|
||
|
||
for (int i = list.Count - 1; i > 0; i--)
|
||
{
|
||
int j = (int)(next() % (uint)(i + 1));
|
||
(list[i], list[j]) = (list[j], list[i]);
|
||
}
|
||
}
|
||
}
|