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