494 lines
21 KiB
C#
494 lines
21 KiB
C#
using System.Collections.Concurrent;
|
|
|
|
namespace Music;
|
|
|
|
public static class MusicResolver
|
|
{
|
|
// Кэш stream-sources (только youtubeaudio): манифест YouTube стоил ~1-1.5s
|
|
// на КАЖДЫЙ тёплый /music/play, хотя googlevideo-ссылки живут часами.
|
|
// TTL 30min — с запасом короче срока жизни ссылок. У Sefon/SoundCloud свои
|
|
// (короткие/сессионные) сроки жизни — их не кэшируем. Ошибки и пустые
|
|
// списки не кэшируются. Хранится и отдаётся ГЛУБОКИМИ КОПИЯМИ:
|
|
// PrepareStreamSources мутирует source.url под хост запроса — общий
|
|
// инстанс запёк бы хост первого клиента (урок отравления image-proxy).
|
|
// Инвалидация: refreshSources=true (пере-резолв из /music/stream после
|
|
// смерти upstream-ссылки в relay) пропускает чтение и перезаписывает свежим.
|
|
static readonly TimeSpan sourcesCacheTtl = TimeSpan.FromMinutes(30);
|
|
const int sourcesCacheCapacity = 512;
|
|
static readonly ConcurrentDictionary<string, (DateTime at, List<MusicPlaybackSource> sources)> sourcesCache = new(StringComparer.Ordinal);
|
|
// кап жёсткий: чтение lock-free, но trim + вставка под общим lock, иначе
|
|
// параллельные запросы одновременно пройдут проверку Count и вместе её пробьют
|
|
static readonly object sourcesCacheWriteLock = new();
|
|
|
|
public static async Task<MusicPlayResponse> ResolveTrackAsync(MusicTrack track, string provider = null, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default, bool refreshSources = false)
|
|
{
|
|
return await ResolveTrackCoreAsync(track, provider, playbackMode, profileId, preferSingleSource: false, refreshSources, cancellationToken);
|
|
}
|
|
|
|
public static async Task<MusicPlayResponse> ResolvePreferredTrackAsync(MusicTrack track, string provider = null, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
|
|
{
|
|
return await ResolveTrackCoreAsync(track, provider, playbackMode, profileId, preferSingleSource: true, refreshSources: false, cancellationToken);
|
|
}
|
|
|
|
static async Task<MusicPlayResponse> ResolveTrackCoreAsync(MusicTrack track, string provider, string playbackMode, string profileId, bool preferSingleSource, bool refreshSources, CancellationToken cancellationToken)
|
|
{
|
|
if (track == null)
|
|
{
|
|
return new MusicPlayResponse
|
|
{
|
|
available = false,
|
|
message = "Track not found."
|
|
};
|
|
}
|
|
|
|
var requestedProviders = GetProviderOrder(provider);
|
|
var requestedProviderIds = requestedProviders
|
|
.Select(i => i.Id)
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
var providers = ExpandProviderOrder(track, requestedProviders);
|
|
if (providers.Count == 0)
|
|
{
|
|
return new MusicPlayResponse
|
|
{
|
|
available = false,
|
|
message = "Audio provider not found.",
|
|
track_id = track.id
|
|
};
|
|
}
|
|
|
|
MusicAudioMatch firstMatch = null;
|
|
bool hadTransientFailure = false;
|
|
|
|
foreach (var sourceProvider in providers)
|
|
{
|
|
var providerTrack = requestedProviderIds.Contains(sourceProvider.Id)
|
|
? track
|
|
: BuildFallbackSearchTrack(track);
|
|
|
|
var selectedMatch = await MusicSourceMatchService.GetAsync(track.id, sourceProvider.Id, playbackMode, cancellationToken);
|
|
selectedMatch = NormalizeSelectedMatch(providerTrack, sourceProvider, selectedMatch);
|
|
|
|
// A durable manual choice is authoritative even when an older
|
|
// negative lookup for this provider is still cached.
|
|
if (selectedMatch?.pinned != true
|
|
&& sourceProvider.CacheMissingMatches
|
|
&& await MusicSourceMatchService.IsMarkedMissingAsync(track.id, sourceProvider.Id, playbackMode, cancellationToken))
|
|
continue;
|
|
|
|
bool providerHadTransientFailure = false;
|
|
|
|
if (selectedMatch != null)
|
|
{
|
|
if (firstMatch == null)
|
|
firstMatch = selectedMatch;
|
|
|
|
List<MusicPlaybackSource> selectedSources;
|
|
try
|
|
{
|
|
selectedSources = await ResolveSourcesAsync(sourceProvider, selectedMatch, preferSingleSource, playbackMode, profileId, refreshSources, cancellationToken);
|
|
}
|
|
catch (MusicAudioTransientException ex)
|
|
{
|
|
providerHadTransientFailure = true;
|
|
hadTransientFailure = true;
|
|
Console.WriteLine($"[Music] {ex.Message}");
|
|
selectedSources = new List<MusicPlaybackSource>();
|
|
}
|
|
|
|
if (selectedSources.Count > 0)
|
|
{
|
|
return new MusicPlayResponse
|
|
{
|
|
available = true,
|
|
message = "ok",
|
|
track_id = track.id,
|
|
selected_match = selectedMatch,
|
|
sources = selectedSources.ToList()
|
|
};
|
|
}
|
|
}
|
|
|
|
IReadOnlyList<MusicAudioMatch> matches;
|
|
try
|
|
{
|
|
matches = await GetOrderedMatchesAsync(providerTrack, sourceProvider, selectedMatch, playbackMode, profileId, cancellationToken);
|
|
}
|
|
catch (MusicAudioTransientException ex)
|
|
{
|
|
providerHadTransientFailure = true;
|
|
hadTransientFailure = true;
|
|
Console.WriteLine($"[Music] {ex.Message}");
|
|
matches = Array.Empty<MusicAudioMatch>();
|
|
}
|
|
|
|
foreach (var match in matches)
|
|
{
|
|
if (selectedMatch != null && match.id == selectedMatch.id)
|
|
continue;
|
|
|
|
if (firstMatch == null)
|
|
firstMatch = match;
|
|
|
|
List<MusicPlaybackSource> sources;
|
|
try
|
|
{
|
|
sources = await ResolveSourcesAsync(sourceProvider, match, preferSingleSource, playbackMode, profileId, refreshSources, cancellationToken);
|
|
}
|
|
catch (MusicAudioTransientException ex)
|
|
{
|
|
providerHadTransientFailure = true;
|
|
hadTransientFailure = true;
|
|
Console.WriteLine($"[Music] {ex.Message}");
|
|
continue;
|
|
}
|
|
|
|
if (sources.Count == 0)
|
|
continue;
|
|
|
|
// временный сбой источников у пинованного матча не должен
|
|
// стирать ручной выбор — играем fallback без перезаписи
|
|
if (selectedMatch == null || !selectedMatch.pinned)
|
|
await MusicSourceMatchService.SaveAsync(track.id, match, playbackMode, cancellationToken);
|
|
|
|
return new MusicPlayResponse
|
|
{
|
|
available = true,
|
|
message = "ok",
|
|
track_id = track.id,
|
|
selected_match = match,
|
|
sources = sources.ToList()
|
|
};
|
|
}
|
|
|
|
if (sourceProvider.CacheMissingMatches && !providerHadTransientFailure)
|
|
await MusicSourceMatchService.MarkMissingAsync(track.id, sourceProvider.Id, playbackMode, cancellationToken);
|
|
}
|
|
|
|
return new MusicPlayResponse
|
|
{
|
|
available = false,
|
|
reason = hadTransientFailure ? "transient" : "not_found",
|
|
message = hadTransientFailure ? "Audio source is temporarily unavailable." : "No audio source resolved yet.",
|
|
track_id = track.id,
|
|
selected_match = firstMatch,
|
|
sources = new List<MusicPlaybackSource>()
|
|
};
|
|
}
|
|
|
|
static async Task<List<MusicPlaybackSource>> ResolveSourcesAsync(IMusicAudioProvider provider, MusicAudioMatch match, bool preferSingleSource, string playbackMode, string profileId, bool refreshSources, CancellationToken cancellationToken)
|
|
{
|
|
if (provider == null || match == null)
|
|
return new List<MusicPlaybackSource>();
|
|
|
|
if (preferSingleSource)
|
|
{
|
|
var preferred = await provider.TryGetPreferredStreamAsync(match, playbackMode, profileId, cancellationToken);
|
|
if (!string.IsNullOrWhiteSpace(preferred?.url))
|
|
{
|
|
var preferredSources = new List<MusicPlaybackSource> { preferred };
|
|
MusicProxyService.ApplyStreamProxy(provider.Id, preferredSources);
|
|
return preferredSources;
|
|
}
|
|
}
|
|
|
|
bool cacheable = string.Equals(provider.Id, "youtubeaudio", StringComparison.OrdinalIgnoreCase)
|
|
&& !string.IsNullOrWhiteSpace(match.id);
|
|
string cacheKey = cacheable
|
|
? BuildSourcesCacheKey(
|
|
provider.Id,
|
|
match.id,
|
|
playbackMode,
|
|
MusicProxyService.CurrentRouteKey(provider.Id, MusicProxyPurpose.Stream)
|
|
)
|
|
: null;
|
|
|
|
if (cacheable && !refreshSources
|
|
&& sourcesCache.TryGetValue(cacheKey, out var entry)
|
|
&& DateTime.UtcNow - entry.at < sourcesCacheTtl)
|
|
return CloneSources(entry.sources);
|
|
|
|
var sources = await provider.GetStreamsAsync(match, playbackMode, profileId, cancellationToken);
|
|
var result = sources?
|
|
.Where(source => !string.IsNullOrWhiteSpace(source?.url))
|
|
.ToList() ?? new List<MusicPlaybackSource>();
|
|
|
|
MusicProxyService.ApplyStreamProxy(provider.Id, result);
|
|
|
|
if (cacheable && result.Count > 0)
|
|
{
|
|
string resultCacheKey = BuildSourcesCacheKey(
|
|
provider.Id,
|
|
match.id,
|
|
playbackMode,
|
|
MusicProxyService.SourceRouteKey(result[0])
|
|
);
|
|
|
|
lock (sourcesCacheWriteLock)
|
|
{
|
|
if (!sourcesCache.ContainsKey(resultCacheKey) && sourcesCache.Count >= sourcesCacheCapacity)
|
|
{
|
|
// сначала протухшие; если их нет — вытесняем старейшие,
|
|
// иначе при равномерном трафике свежие записи переполнят кап
|
|
foreach (var stale in sourcesCache.Where(i => DateTime.UtcNow - i.Value.at >= sourcesCacheTtl).Select(i => i.Key).ToList())
|
|
sourcesCache.TryRemove(stale, out _);
|
|
|
|
while (sourcesCache.Count >= sourcesCacheCapacity)
|
|
{
|
|
var oldest = sourcesCache.OrderBy(i => i.Value.at).FirstOrDefault();
|
|
if (oldest.Key == null || !sourcesCache.TryRemove(oldest.Key, out _))
|
|
break;
|
|
}
|
|
}
|
|
|
|
sourcesCache[resultCacheKey] = (DateTime.UtcNow, CloneSources(result));
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
static string BuildSourcesCacheKey(string providerId, string matchId, string playbackMode, string routeKey)
|
|
=> $"{providerId}|{matchId}|{playbackMode ?? string.Empty}|route:{routeKey}|cfg:{MusicProxyService.ConfigurationVersion}";
|
|
|
|
static List<MusicPlaybackSource> CloneSources(List<MusicPlaybackSource> sources)
|
|
{
|
|
return sources.Select(source => new MusicPlaybackSource
|
|
{
|
|
provider_id = source.provider_id,
|
|
url = source.url,
|
|
external_url = source.external_url,
|
|
mime_type = source.mime_type,
|
|
bitrate = source.bitrate,
|
|
quality = source.quality,
|
|
headers = source.headers != null ? new Dictionary<string, string>(source.headers) : new(),
|
|
proxy_url = source.proxy_url,
|
|
proxy_username = source.proxy_username,
|
|
proxy_password = source.proxy_password,
|
|
proxy_scope = source.proxy_scope
|
|
}).ToList();
|
|
}
|
|
|
|
public static async Task<MusicMatchesResponse> GetMatchesAsync(MusicTrack track, string provider = null, string playbackMode = null, string profileId = null, CancellationToken cancellationToken = default)
|
|
{
|
|
if (track == null)
|
|
{
|
|
return new MusicMatchesResponse
|
|
{
|
|
available = false,
|
|
message = "Track not found."
|
|
};
|
|
}
|
|
|
|
var providers = GetProviderOrder(provider);
|
|
if (providers.Count == 0)
|
|
{
|
|
return new MusicMatchesResponse
|
|
{
|
|
available = false,
|
|
message = "Audio provider not found.",
|
|
track_id = track.id
|
|
};
|
|
}
|
|
|
|
foreach (var sourceProvider in providers)
|
|
{
|
|
var selectedMatch = await MusicSourceMatchService.GetAsync(track.id, sourceProvider.Id, playbackMode, cancellationToken);
|
|
selectedMatch = NormalizeSelectedMatch(track, sourceProvider, selectedMatch);
|
|
var matches = await GetOrderedMatchesAsync(track, sourceProvider, selectedMatch, playbackMode, profileId, cancellationToken);
|
|
|
|
if (matches.Count == 0)
|
|
continue;
|
|
|
|
return new MusicMatchesResponse
|
|
{
|
|
available = true,
|
|
message = "ok",
|
|
track_id = track.id,
|
|
selected_match = selectedMatch ?? matches.First(),
|
|
matches = matches.ToList()
|
|
};
|
|
}
|
|
|
|
return new MusicMatchesResponse
|
|
{
|
|
available = false,
|
|
message = "No audio matches resolved yet.",
|
|
track_id = track.id
|
|
};
|
|
}
|
|
|
|
// «Найти вручную»: матчи по сырому пользовательскому запросу вместо
|
|
// метаданных трека — последний рубеж для совсем битых метаданных
|
|
public static async Task<MusicMatchesResponse> GetMatchesByQueryAsync(MusicTrack track, string provider, string searchQuery, string playbackMode = null, CancellationToken cancellationToken = default)
|
|
{
|
|
if (track == null || string.IsNullOrWhiteSpace(searchQuery))
|
|
{
|
|
return new MusicMatchesResponse
|
|
{
|
|
available = false,
|
|
message = "Query is empty.",
|
|
track_id = track?.id
|
|
};
|
|
}
|
|
|
|
foreach (var sourceProvider in GetProviderOrder(provider))
|
|
{
|
|
var matches = await SearchMatchesByQueryAsync(sourceProvider, searchQuery, cancellationToken);
|
|
if (matches.Count == 0)
|
|
continue;
|
|
|
|
var selectedMatch = await MusicSourceMatchService.GetAsync(track.id, sourceProvider.Id, playbackMode, cancellationToken);
|
|
selectedMatch = NormalizeSelectedMatch(track, sourceProvider, selectedMatch);
|
|
|
|
return new MusicMatchesResponse
|
|
{
|
|
available = true,
|
|
message = "ok",
|
|
track_id = track.id,
|
|
selected_match = selectedMatch,
|
|
matches = matches.ToList()
|
|
};
|
|
}
|
|
|
|
return new MusicMatchesResponse
|
|
{
|
|
available = false,
|
|
message = "No matches for query.",
|
|
track_id = track.id
|
|
};
|
|
}
|
|
|
|
static async Task<IReadOnlyList<MusicAudioMatch>> SearchMatchesByQueryAsync(IMusicAudioProvider provider, string searchQuery, CancellationToken cancellationToken)
|
|
{
|
|
return provider == null || !provider.Enabled
|
|
? Array.Empty<MusicAudioMatch>()
|
|
: await provider.SearchMatchesByQueryAsync(searchQuery, cancellationToken);
|
|
}
|
|
|
|
public static async Task<bool> SelectMatchAsync(MusicTrack track, string provider, string matchId, string playbackMode = null, string profileId = null, string searchQuery = null, CancellationToken cancellationToken = default)
|
|
{
|
|
if (track == null || string.IsNullOrWhiteSpace(provider) || string.IsNullOrWhiteSpace(matchId))
|
|
return false;
|
|
|
|
var sourceProvider = MusicProviderRegistry.GetAudioProvider(provider);
|
|
if (sourceProvider == null || !sourceProvider.Enabled)
|
|
return false;
|
|
|
|
IReadOnlyList<MusicAudioMatch> matches;
|
|
|
|
if (!string.IsNullOrWhiteSpace(searchQuery))
|
|
{
|
|
// кандидат из ручного поиска валидируется тем же запросом
|
|
matches = await SearchMatchesByQueryAsync(sourceProvider, searchQuery, cancellationToken);
|
|
}
|
|
else
|
|
{
|
|
var selectedMatch = await MusicSourceMatchService.GetAsync(track.id, sourceProvider.Id, playbackMode, cancellationToken);
|
|
selectedMatch = NormalizeSelectedMatch(track, sourceProvider, selectedMatch);
|
|
matches = await GetOrderedMatchesAsync(track, sourceProvider, selectedMatch, playbackMode, profileId, cancellationToken);
|
|
}
|
|
|
|
var match = matches.FirstOrDefault(i => i.id == matchId && i.provider_id == provider);
|
|
|
|
if (match == null)
|
|
return false;
|
|
|
|
match.pinned = true;
|
|
return await MusicSourceMatchService.SaveAsync(track.id, match, playbackMode, cancellationToken);
|
|
}
|
|
|
|
static List<IMusicAudioProvider> GetProviderOrder(string provider)
|
|
{
|
|
if (!string.IsNullOrWhiteSpace(provider))
|
|
{
|
|
var selected = MusicProviderRegistry.GetAudioProvider(provider);
|
|
return selected == null ? new List<IMusicAudioProvider>() : new List<IMusicAudioProvider> { selected };
|
|
}
|
|
|
|
var ordered = new List<IMusicAudioProvider>();
|
|
var preferred = MusicProviderRegistry.GetAudioProvider();
|
|
|
|
if (preferred != null)
|
|
ordered.Add(preferred);
|
|
|
|
ordered.AddRange(MusicProviderRegistry.AudioProviders.Where(i => i.Enabled && ordered.All(x => x.Id != i.Id)));
|
|
return ordered;
|
|
}
|
|
|
|
static List<IMusicAudioProvider> ExpandProviderOrder(MusicTrack track, List<IMusicAudioProvider> providers)
|
|
{
|
|
if (track == null || providers == null || providers.Count == 0)
|
|
return providers;
|
|
|
|
var expanded = providers.ToList();
|
|
foreach (var provider in providers)
|
|
{
|
|
foreach (var fallbackId in provider.GetFallbackProviderIds(track) ?? Array.Empty<string>())
|
|
{
|
|
var fallback = MusicProviderRegistry.GetAudioProvider(fallbackId);
|
|
if (fallback == null || !fallback.Enabled || expanded.Any(i => string.Equals(i.Id, fallback.Id, StringComparison.OrdinalIgnoreCase)))
|
|
continue;
|
|
|
|
expanded.Add(fallback);
|
|
}
|
|
}
|
|
|
|
return expanded;
|
|
}
|
|
|
|
static MusicTrack BuildFallbackSearchTrack(MusicTrack track)
|
|
{
|
|
if (track == null)
|
|
return null;
|
|
|
|
return new MusicTrack
|
|
{
|
|
title = track.title,
|
|
artist_id = track.artist_id,
|
|
artist_name = track.artist_name,
|
|
artists = track.artists?.ToList() ?? new List<string>(),
|
|
album_id = track.album_id,
|
|
album_title = track.album_title,
|
|
isrc = track.isrc,
|
|
duration_ms = track.duration_ms,
|
|
track_number = track.track_number,
|
|
disc_number = track.disc_number,
|
|
date = track.date,
|
|
search_score = track.search_score,
|
|
images = track.images?.ToList() ?? new List<MusicImage>()
|
|
};
|
|
}
|
|
|
|
static async Task<IReadOnlyList<MusicAudioMatch>> GetOrderedMatchesAsync(MusicTrack track, IMusicAudioProvider provider, MusicAudioMatch selectedMatch, string playbackMode, string profileId, CancellationToken cancellationToken)
|
|
{
|
|
selectedMatch = NormalizeSelectedMatch(track, provider, selectedMatch);
|
|
|
|
var matches = (await provider.MatchTrackAsync(track, playbackMode, profileId, cancellationToken)).ToList();
|
|
|
|
if (selectedMatch == null)
|
|
return matches;
|
|
|
|
var ordered = new List<MusicAudioMatch> { selectedMatch };
|
|
ordered.AddRange(matches.Where(i => i.id != selectedMatch.id));
|
|
return ordered;
|
|
}
|
|
|
|
static MusicAudioMatch NormalizeSelectedMatch(MusicTrack track, IMusicAudioProvider provider, MusicAudioMatch selectedMatch)
|
|
{
|
|
if (selectedMatch == null)
|
|
return null;
|
|
|
|
bool relevant = provider?.IsRelevantMatch(track, selectedMatch) == true;
|
|
if (!relevant && provider?.ShouldValidatePinnedMatch(track, selectedMatch) == true)
|
|
return null;
|
|
|
|
// ручной выбор пользователя авторитетен: эвристики релевантности написаны
|
|
// против протухших АВТО-матчей, а здесь метаданные трека могут быть битыми
|
|
// (пользователь ровно поэтому и выбирал источник вручную)
|
|
if (selectedMatch.pinned)
|
|
return selectedMatch;
|
|
|
|
return relevant ? selectedMatch : null;
|
|
}
|
|
}
|