using System.Globalization;
namespace Music;
///
/// «Миксы недели»: каждому дню недели детерминированно назначаются два
/// артиста из истории прослушиваний профиля + до двух похожих (доминирующие
/// чужие артисты из SoundCloud related-пулов) — это основа микса. Поверх —
/// Поверх — additive discovery-слой: Music-Map, SoundCloud-жанры и
/// Spotify related artists делят до 8 слотов с адаптивными квотами;
/// открытия вплетаются в микс равномерно. Seed =
/// profile + ISO-неделя, поэтому внутри дня и недели состав стабилен, а на
/// следующей неделе раскладка меняется. Сводки по дням — чистый SQL без
/// внешних HTTP; треки грузятся лениво при открытии микса.
///
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 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> PrimaryPools { get; init; } = new();
public string SecondaryGenre { get; init; }
}
sealed class SpotifyDiscoveryPlan
{
public List SeedDiscoveries { get; init; } = new();
public List> RelatedPools { get; init; } = new();
}
// кэшируемый payload микса; null из фабрики (слабый результат) не кэшируется
sealed class DailyMixPayload
{
public List artists { get; set; } = new();
public List tracks { get; set; } = new();
}
public static async Task> GetSummariesAsync(string profileId, int salt = 0, CancellationToken cancellationToken = default)
{
var (assignment, historyArtists) = await BuildWeekAssignmentAsync(profileId, salt, cancellationToken);
if (assignment == null)
return new List();
// при бедной истории показываем столько дней, сколько закрывается
// РАЗНЫМИ парами артистов, — иначе полка выглядит натянуто.
// Полное покрытие (14+ артистов) — вся неделя с переносом через
// Вс→Пн; частичное — остаток текущей недели от сегодня БЕЗ переноса:
// шаг пар по модулю n на границе недели коллизится (7 простое),
// а «полка живёт до воскресенья» честно матчится с «обновится в
// понедельник»
int coverage = Math.Min(7, historyArtists.Count / ownArtistsPerDay);
int today = IsoDayOfWeek(DateTime.UtcNow);
var result = new List(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 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[] assignment, List 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[7];
for (int day = 1; day <= 7; day++)
{
var picks = new List(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> CollectSeedArtistsCachedAsync(string profileId, CancellationToken cancellationToken)
{
string key = NormalizeProfileKey(profileId);
// возвращаем КОПИЮ списка: BuildWeekAssignmentAsync сортирует и
// шаффлит его на месте — общий инстанс между запросами дал бы гонку
if (seedArtistsCache.TryGetValue(key, out var cached) && cached.expiresAt > DateTime.UtcNow)
return new List(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(artists));
}
return artists;
}
// кандидаты недели: история прослушиваний (реальное поведение, до 28) +
// артисты из плейлистов пользователя (декларированный вкус, квота 8,
// гарантирована — история при переполнении ужимается с наименее свежего
// края). Пользователь без истории, но с плейлистом ≥3 артистов получает
// полку целиком из плейлиста
static async Task> CollectSeedArtistsAsync(string profileId, CancellationToken cancellationToken)
{
var artists = await CollectHistoryArtistsAsync(profileId, cancellationToken);
var playlistArtists = new List();
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 RankPlaylistArtists(List playlistTracks, List historyArtists)
{
var groups = new List();
var byNormalized = new Dictionary(StringComparer.OrdinalIgnoreCase);
var history = historyArtists ?? new List();
int index = 0;
foreach (var rawTrack in playlistTracks ?? new List())
{
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 { 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*(?.{2,80}?)\s+[-–—]{1,2}\s+(?.{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())
.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(),
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(),
provider_refs = track.provider_refs?.ToList() ?? new List(),
auto_radio = track.auto_radio
};
}
static async Task> CollectHistoryArtistsAsync(string profileId, CancellationToken cancellationToken)
{
var result = new List();
var seen = new HashSet(StringComparer.OrdinalIgnoreCase);
List 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 BuildMixAsync(string profileId, int day, int salt, List seeds, List 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>>();
try
{
await Task.WhenAll(artistPoolTasks.Concat(relatedTasks));
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
}
static List PoolOf(Task> task) => task.IsCompletedSuccessfully
? task.Result ?? new List()
: new List();
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(StringComparer.OrdinalIgnoreCase);
var excludedKeys = new HashSet(StringComparer.OrdinalIgnoreCase);
var excludedTitles = new HashSet(StringComparer.OrdinalIgnoreCase);
var outputIds = new HashSet(StringComparer.OrdinalIgnoreCase);
var outputKeys = new HashSet(StringComparer.OrdinalIgnoreCase);
var outputTitles = new HashSet(StringComparer.OrdinalIgnoreCase);
// ЕДИНЫЙ СЛОЙ ОТКРЫТИЙ (общий бюджет discoveryTrackBudget, ревью
// Codex): сперва Music-Map externals (похожесть на твоих), остаток
// добирает жанр дня; Music-Map пуст — жанр берёт весь бюджет.
// Строго additive, в слотах карточки НЕ анонсируется — сюрприз.
// Для «специй» шумовые слова не прощаются, даже если были в сидах:
// discovery должен удивлять артистом, а не slowed/remix-версией
var discoveryOutput = new List();
var strictNoisyWords = new HashSet(StringComparer.OrdinalIgnoreCase);
var discoveryArtistCounts = new Dictionary(StringComparer.Ordinal);
var externalArtists = new List();
List> 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();
var mainArtistCounts = new Dictionary(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>> musicMapTasks,
Task genrePoolsTask,
List seeds,
List historyArtists,
List mixArtists,
IEnumerable similarArtists,
List selectedExternalArtists,
uint sampleSeed,
HashSet strictNoisyWords,
bool requireCyrillic,
List discoveryOutput,
int desiredDiscoveryLimit,
int mainTrackCount,
Dictionary discoveryArtistCounts,
HashSet excludedIds,
HashSet excludedKeys,
HashSet excludedTitles,
HashSet outputIds,
HashSet outputKeys,
HashSet outputTitles,
List> preparedGenrePools,
CancellationToken cancellationToken)
{
if (desiredDiscoveryLimit <= discoveryOutput.Count)
return;
try
{
var extraExternalArtists = await PickExternalArtistsAsync(
musicMapTasks,
seeds,
historyArtists,
(similarArtists ?? Enumerable.Empty()).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>> LoadExternalArtistPoolsAsync(List 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())
.Where(pool => pool.Count > 0)
.Select(pool => pool.Take(externalPoolCandidateLimit).ToList())
.ToList();
}
static async Task FillSpotifyFallbackAsync(
Task spotifyDiscoveryTask,
uint sampleSeed,
HashSet seedNoisyWords,
HashSet strictNoisyWords,
bool requireCyrillic,
List discoveryOutput,
int desiredDiscoveryLimit,
int mainTrackCount,
Dictionary mainArtistCounts,
Dictionary discoveryArtistCounts,
HashSet excludedIds,
HashSet excludedKeys,
HashSet excludedTitles,
HashSet outputIds,
HashSet outputKeys,
HashSet 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 LoadSpotifyDiscoveryPlanAsync(
List seeds,
IEnumerable 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())
.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> relatedPools,
List discoveryOutput,
int limit,
HashSet strictNoisyWords,
bool requireCyrillic,
Dictionary discoveryArtistCounts,
HashSet excludedIds,
HashSet excludedKeys,
HashSet excludedTitles,
HashSet outputIds,
HashSet outputKeys,
HashSet 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 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 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> genrePools,
List discoveryOutput,
int limit,
HashSet strictNoisyWords,
bool requireCyrillic,
HashSet excludedIds,
HashSet excludedKeys,
HashSet excludedTitles,
HashSet outputIds,
HashSet outputKeys,
HashSet outputTitles,
Dictionary 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>> PrepareGenrePoolsAsync(
Task genrePoolsTask,
IEnumerable knownNames,
uint sampleSeed,
CancellationToken cancellationToken,
bool secondary = false)
{
List> 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> { secondaryPool }
: new List>();
}
else
{
source = new List>();
}
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return new List>();
}
var known = (knownNames ?? Enumerable.Empty())
.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 LoadGenrePoolsAsync(List 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(StringComparer.OrdinalIgnoreCase);
foreach (var task in genreTasks.Where(t => t.IsCompletedSuccessfully))
{
var genres = task.Result ?? new List();
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 { 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())
.Where(pool => pool.Count > 0)
.ToList(),
SecondaryGenre = secondaryGenre
};
}
static Task> 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> PickExternalArtistsAsync(
List>> musicMapTasks,
List seeds,
List historyArtists,
IEnumerable 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())
.Where(map => map.Count > 0)
.ToList();
if (maps.Count == 0)
return new List();
var aliasReferences = seeds.Select(i => i.Name).ToList();
var excludeNames = historyArtists.Select(i => i.Name)
.Concat(similarArtists ?? Enumerable.Empty())
.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();
List 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();
var seen = new HashSet(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();
// несколько seeded-выборов из ближайшего окна без повторов/алиасов
var window = candidates.Take(externalPickWindow).ToList();
var picks = new List();
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 pool)> PickSimilarArtistPools(List relatedTracks, List seeds, int maxArtists)
{
var seedNames = new HashSet(seeds.Select(i => i.NormalizedName), StringComparer.OrdinalIgnoreCase);
var groups = new Dictionary 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());
group.pool.Add(track);
}
var result = new List<(string name, List 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(List 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]);
}
}
}