Исправление: том ./mods:/lampac/mods в compose и копии 10 модулей с enable:true; конфиг без SkipModules
This commit is contained in:
@@ -0,0 +1,797 @@
|
||||
namespace Music;
|
||||
|
||||
public static class MusicCatalogService
|
||||
{
|
||||
static readonly TimeSpan searchCacheTtl = TimeSpan.FromHours(6);
|
||||
static readonly TimeSpan artistCacheTtl = TimeSpan.FromDays(7);
|
||||
static readonly TimeSpan albumCacheTtl = TimeSpan.FromDays(7);
|
||||
static readonly TimeSpan trackCacheTtl = TimeSpan.FromDays(7);
|
||||
static readonly TimeSpan discoveryHomeProviderResponseBudget = TimeSpan.FromSeconds(2);
|
||||
static readonly TimeSpan discoveryHomeProviderWarmTimeout = TimeSpan.FromSeconds(10);
|
||||
static readonly TimeSpan discoverySectionProviderTimeout = TimeSpan.FromSeconds(8);
|
||||
static readonly TimeSpan searchSectionProviderTimeout = TimeSpan.FromSeconds(4);
|
||||
static readonly TimeSpan searchMetadataResponseBudget = TimeSpan.FromSeconds(2);
|
||||
static readonly TimeSpan searchMetadataTimeout = TimeSpan.FromSeconds(20);
|
||||
const int recentSectionLimit = 100;
|
||||
const int browseSectionLimit = 20;
|
||||
const int browseSectionFullLimit = 100;
|
||||
const int searchSectionLimit = 8;
|
||||
const int searchSectionFullLimit = 24;
|
||||
// v31: RU->latin fallback metadata-поиска для кириллических запросов
|
||||
// западных артистов ("эминем" -> "eminem"), без fuzzy-расширения.
|
||||
// v34: Spotify artist screen добирает полные discography/appears-on секции
|
||||
// отдельными artistsection-запросами вместо короткого overview.
|
||||
// v35: уточнённые Spotify release labels для singles/EP/appears-on.
|
||||
// v36: Spotify singles/EP отдаются как track-section, не album-section.
|
||||
// v37: track metadata сохраняет ISRC для точного audio matching.
|
||||
// v38: Spotify tracks сохраняют album_id для быстрого нативного
|
||||
// разрешения длительности без ошибочного fallback в MusicBrainz.
|
||||
const string metadataCacheVersion = "v38";
|
||||
static readonly object homeWarmLock = new();
|
||||
static readonly Dictionary<string, Task<List<MusicBrowseSection>>> homeWarmTasks = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public static async Task<MusicHomeResponse> GetHomeAsync(string profileId, int dailySalt = 0, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sections = new List<MusicSection>
|
||||
{
|
||||
new() { id = "search", title = "Search", endpoint = "/music/search", type = "search" },
|
||||
new() { id = "providers", title = "Providers", endpoint = "/music/providers", type = "info" }
|
||||
};
|
||||
|
||||
if (MusicProviderRegistry.AuthProviders.Any(i => i.Enabled))
|
||||
sections.Add(new MusicSection { id = "auth", title = "Auth", endpoint = "/music/auth/state", type = "info" });
|
||||
|
||||
var recentlyPlayedTask = MusicPlaybackHistoryService.GetRecentAsync(profileId, recentSectionLimit, cancellationToken);
|
||||
var userPlaylistsTask = MusicUserPlaylistService.ListAsync(profileId, cancellationToken);
|
||||
// сводки миксов недели — чистое чтение истории + детерминированная
|
||||
// раскладка, без внешних HTTP; треки грузятся лениво в /music/daily
|
||||
var dailyMixesTask = MusicDailyMixService.GetSummariesAsync(profileId, dailySalt, cancellationToken);
|
||||
var browseSectionsTask = GetBrowseSectionsAsync(cancellationToken);
|
||||
|
||||
await Task.WhenAll(recentlyPlayedTask, userPlaylistsTask, dailyMixesTask, browseSectionsTask);
|
||||
|
||||
var browseSectionsResult = await browseSectionsTask;
|
||||
|
||||
return new MusicHomeResponse
|
||||
{
|
||||
title = "Music",
|
||||
status = "ok",
|
||||
version = "0.1.0",
|
||||
sections = sections,
|
||||
metadata_providers = MusicProviderRegistry.DescribeMetadata(),
|
||||
audio_providers = MusicProviderRegistry.DescribeAudio(),
|
||||
auth_providers = MusicProviderRegistry.DescribeAuth(),
|
||||
recently_played = await recentlyPlayedTask,
|
||||
user_playlists = await userPlaylistsTask,
|
||||
daily_mixes = await dailyMixesTask,
|
||||
browse_sections = browseSectionsResult.sections,
|
||||
browse_sections_warming = browseSectionsResult.warming
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task<MusicBrowseSection> GetBrowseSectionAsync(string sectionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sectionId))
|
||||
return null;
|
||||
|
||||
foreach (var provider in MusicProviderRegistry.DiscoveryProviders.Where(i => i.Enabled))
|
||||
{
|
||||
var section = await SafeGetSectionAsync(provider, sectionId, browseSectionFullLimit, cancellationToken);
|
||||
if (section != null)
|
||||
return section;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static async Task<MusicSearchResponse> SearchAsync(string query, string provider = null, bool expanded = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var metadata = MusicProviderRegistry.GetMetadataProvider(provider);
|
||||
var metadataTask = SearchMetadataAsync(metadata, query, expanded, cancellationToken);
|
||||
ObserveBackgroundTask(metadataTask);
|
||||
var searchSectionsTask = GetSearchSectionsAsync(query, expanded, cancellationToken);
|
||||
var metadataBudgetTask = Task.Delay(searchMetadataResponseBudget, cancellationToken);
|
||||
|
||||
var searchSections = await searchSectionsTask;
|
||||
var hasSearchSections = searchSections.Count > 0;
|
||||
(MusicSearchResult result, bool pending) metadataState;
|
||||
if (hasSearchSections)
|
||||
metadataState = await WaitForSearchMetadataForResponseAsync(metadataTask, metadataBudgetTask, cancellationToken);
|
||||
else
|
||||
metadataState = (await metadataTask, false);
|
||||
|
||||
var result = metadataState.result;
|
||||
|
||||
if (result.artists != null && result.artists.Count > 0)
|
||||
await DiscogsArtistImageService.ApplyCachedAsync(result.artists, cancellationToken);
|
||||
|
||||
bool hasResults =
|
||||
(result.artists?.Count > 0)
|
||||
|| (result.albums?.Count > 0)
|
||||
|| (result.tracks?.Count > 0)
|
||||
|| hasSearchSections;
|
||||
|
||||
return new MusicSearchResponse
|
||||
{
|
||||
query = query,
|
||||
status = hasResults ? "ok" : "empty",
|
||||
metadata_provider = metadata?.Id,
|
||||
metadata_pending = metadataState.pending,
|
||||
audio_providers = MusicProviderRegistry.AudioProviders.Where(i => i.Enabled).Select(i => i.Id).ToList(),
|
||||
artists = result.artists,
|
||||
albums = result.albums,
|
||||
tracks = result.tracks,
|
||||
search_sections = searchSections
|
||||
};
|
||||
}
|
||||
|
||||
static async Task<(MusicSearchResult result, bool pending)> WaitForSearchMetadataForResponseAsync(Task<MusicSearchResult> metadataTask, Task budgetTask, CancellationToken cancellationToken)
|
||||
{
|
||||
if (metadataTask.IsCompleted)
|
||||
return (await metadataTask, false);
|
||||
|
||||
var completed = await Task.WhenAny(metadataTask, budgetTask);
|
||||
if (completed == metadataTask)
|
||||
return (await metadataTask, false);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
return (new MusicSearchResult(), true);
|
||||
}
|
||||
|
||||
static void ObserveBackgroundTask(Task task)
|
||||
{
|
||||
_ = task.ContinueWith(completed =>
|
||||
{
|
||||
_ = completed.Exception;
|
||||
}, TaskContinuationOptions.OnlyOnFaulted);
|
||||
}
|
||||
|
||||
static async Task<MusicSearchResult> SearchMetadataAsync(IMusicMetadataProvider metadata, string query, bool expanded, CancellationToken cancellationToken)
|
||||
{
|
||||
if (metadata == null || string.IsNullOrWhiteSpace(query))
|
||||
return new MusicSearchResult();
|
||||
|
||||
try
|
||||
{
|
||||
// MusicBrainz живёт на rate-limit и 20s HttpClient-таймауте:
|
||||
// ответ поиска ждёт metadata только короткое окно, но сама metadata-ветка
|
||||
// может догреться дольше и положить результат в кэш. Таймауты не кэшируются.
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(searchMetadataTimeout);
|
||||
|
||||
return await MusicMetadataCacheService.GetOrCreateAsync(
|
||||
metadata.Id,
|
||||
expanded ? "search_full" : "search",
|
||||
VersionedKey((expanded ? "full|" : "base|") + NormalizeSearchKey(query)),
|
||||
searchCacheTtl,
|
||||
() => metadata.SearchAsync(query, expanded, timeoutCts.Token),
|
||||
timeoutCts.Token
|
||||
) ?? new MusicSearchResult();
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new MusicSearchResult();
|
||||
}
|
||||
}
|
||||
|
||||
public static Task<MusicArtist> GetArtistAsync(string id, string provider = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (YouTubeMusicSearchSupport.IsChannelArtist(provider, id))
|
||||
return YouTubeMusicSearchSupport.GetChannelArtistAsync(id, cancellationToken);
|
||||
|
||||
if (SoundCloudSupport.IsUserArtist(provider, id))
|
||||
return SoundCloudSupport.GetUserArtistAsync(id, cancellationToken);
|
||||
|
||||
if (SpotifySupport.IsSearchEnabled && SpotifySupport.IsSpotifyArtist(provider, id))
|
||||
return MusicMetadataCacheService.GetOrCreateAsync(
|
||||
SpotifySupport.ProviderId, "artist", VersionedKey(id), artistCacheTtl,
|
||||
() => SpotifySupport.GetArtistAsync(id, cancellationToken), cancellationToken);
|
||||
|
||||
var metadata = MusicProviderRegistry.GetMetadataProvider(provider);
|
||||
bool isMusicBrainz = metadata is MusicBrainzMetadataProvider;
|
||||
return metadata == null
|
||||
? Task.FromResult<MusicArtist>(null)
|
||||
: MusicMetadataCacheService.GetOrCreateAsync(
|
||||
metadata.Id,
|
||||
"artist",
|
||||
// Bypass old week-long empty discographies without invalidating other caches.
|
||||
VersionedKey(isMusicBrainz ? $"artist-discography:v2:{id}" : id),
|
||||
artistCacheTtl,
|
||||
() => metadata.GetArtistAsync(id, cancellationToken),
|
||||
cancellationToken,
|
||||
ttlSelector: artist => isMusicBrainz
|
||||
&& (artist.albums?.Count ?? 0) == 0
|
||||
&& (artist.sections?.Count ?? 0) == 0
|
||||
? TimeSpan.FromMinutes(20)
|
||||
: artistCacheTtl
|
||||
);
|
||||
}
|
||||
|
||||
public static Task<MusicBrowseSection> GetArtistSectionAsync(string id, string provider = null, string page = null, int limit = 20, string artistName = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (SoundCloudSupport.IsUserSection(provider, id))
|
||||
return SoundCloudSupport.GetUserSectionAsync(id, page, limit, cancellationToken);
|
||||
|
||||
if (SpotifySupport.IsSearchEnabled && SpotifySupport.IsSpotifyArtistSection(provider, id))
|
||||
return MusicMetadataCacheService.GetOrCreateAsync(
|
||||
SpotifySupport.ProviderId,
|
||||
"artist_section",
|
||||
VersionedKey($"{id}|{page}|{limit}|{artistName}"),
|
||||
searchCacheTtl,
|
||||
() => SpotifySupport.GetArtistSectionAsync(id, page, limit, artistName, cancellationToken),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
return Task.FromResult<MusicBrowseSection>(null);
|
||||
}
|
||||
|
||||
public static Task<MusicAlbum> GetAlbumAsync(string id, string provider = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (YouTubeMusicSearchSupport.IsPlaylistAlbum(provider, id))
|
||||
return YouTubeMusicSearchSupport.GetPlaylistAlbumAsync(id, cancellationToken);
|
||||
|
||||
if (AppleMusicSupport.IsCatalogAlbum(provider, id))
|
||||
return MusicMetadataCacheService.GetOrCreateAsync(
|
||||
AppleMusicSupport.ProviderId, "album", VersionedKey(id), albumCacheTtl,
|
||||
() => AppleMusicSupport.GetCatalogAlbumAsync(id, cancellationToken), cancellationToken);
|
||||
|
||||
if (AppleMusicDiscoveryProvider.IsChartAlbum(provider, id))
|
||||
{
|
||||
var discovery = MusicProviderRegistry.DiscoveryProviders
|
||||
.FirstOrDefault(i => string.Equals(i.Id, AppleMusicDiscoveryProvider.ProviderId, StringComparison.OrdinalIgnoreCase)) as AppleMusicDiscoveryProvider;
|
||||
|
||||
return discovery?.Enabled == true
|
||||
? MusicMetadataCacheService.GetOrCreateAsync(
|
||||
AppleMusicDiscoveryProvider.ProviderId,
|
||||
"album",
|
||||
VersionedKey($"{AppleMusicDiscoveryProvider.CurrentCountry}|{id}"),
|
||||
albumCacheTtl,
|
||||
() => discovery.GetAlbumAsync(id, cancellationToken),
|
||||
cancellationToken)
|
||||
: Task.FromResult<MusicAlbum>(null);
|
||||
}
|
||||
|
||||
if (SpotifyDiscoveryProvider.IsPlaylistAlbum(provider, id))
|
||||
{
|
||||
var discovery = MusicProviderRegistry.DiscoveryProviders
|
||||
.FirstOrDefault(i => string.Equals(i.Id, provider, StringComparison.OrdinalIgnoreCase)) as SpotifyDiscoveryProvider;
|
||||
|
||||
return discovery?.Enabled == true
|
||||
? discovery.GetPlaylistAlbumAsync(id, cancellationToken)
|
||||
: Task.FromResult<MusicAlbum>(null);
|
||||
}
|
||||
|
||||
if (SoundCloudSupport.IsUserTracksAlbum(provider, id))
|
||||
return SoundCloudSupport.GetUserTracksAlbumAsync(id, cancellationToken);
|
||||
|
||||
if (SpotifySupport.IsSearchEnabled && SpotifySupport.IsSpotifyAlbum(provider, id))
|
||||
return MusicMetadataCacheService.GetOrCreateAsync(
|
||||
SpotifySupport.ProviderId, "album", VersionedKey(id), albumCacheTtl,
|
||||
() => SpotifySupport.GetAlbumAsync(id, cancellationToken), cancellationToken);
|
||||
|
||||
if (string.Equals(provider, SoundCloudSupport.DiscoveryProviderId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var discovery = MusicProviderRegistry.DiscoveryProviders
|
||||
.FirstOrDefault(i => string.Equals(i.Id, provider, StringComparison.OrdinalIgnoreCase)) as SoundCloudDiscoveryProvider;
|
||||
|
||||
return discovery?.Enabled == true
|
||||
? discovery.GetAlbumAsync(id, cancellationToken)
|
||||
: Task.FromResult<MusicAlbum>(null);
|
||||
}
|
||||
|
||||
var metadata = MusicProviderRegistry.GetMetadataProvider(provider);
|
||||
return metadata == null
|
||||
? Task.FromResult<MusicAlbum>(null)
|
||||
: MusicMetadataCacheService.GetOrCreateAsync(
|
||||
metadata.Id,
|
||||
"album",
|
||||
VersionedKey(id),
|
||||
albumCacheTtl,
|
||||
() => metadata.GetAlbumAsync(id, cancellationToken),
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
public static Task<MusicTrack> GetTrackAsync(string id, string provider = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.Equals(provider, SoundCloudSupport.DiscoveryProviderId, StringComparison.OrdinalIgnoreCase))
|
||||
return SoundCloudSupport.GetTrackAsync(id, cancellationToken);
|
||||
|
||||
if (ShouldSkipMetadataTrackLookup(id, provider))
|
||||
return Task.FromResult<MusicTrack>(null);
|
||||
|
||||
var metadata = MusicProviderRegistry.GetMetadataProvider(provider);
|
||||
return metadata == null
|
||||
? Task.FromResult<MusicTrack>(null)
|
||||
: MusicMetadataCacheService.GetOrCreateAsync(
|
||||
metadata.Id,
|
||||
"track",
|
||||
VersionedKey(id),
|
||||
trackCacheTtl,
|
||||
() => metadata.GetTrackAsync(id, cancellationToken),
|
||||
cancellationToken
|
||||
);
|
||||
}
|
||||
|
||||
static async Task<(List<MusicBrowseSection> sections, bool warming)> GetBrowseSectionsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var providers = MusicProviderRegistry.DiscoveryProviders.Where(i => i.Enabled).ToList();
|
||||
if (providers.Count == 0)
|
||||
return (new List<MusicBrowseSection>(), false);
|
||||
|
||||
var results = await Task.WhenAll(providers.Select(provider =>
|
||||
GetHomeSectionsForResponseAsync(provider, browseSectionLimit, cancellationToken)));
|
||||
|
||||
var sections = new List<MusicBrowseSection>();
|
||||
bool warming = false;
|
||||
foreach (var (providerSections, providerWarming) in results)
|
||||
{
|
||||
warming |= providerWarming;
|
||||
if (providerSections != null && providerSections.Count > 0)
|
||||
sections.AddRange(providerSections);
|
||||
}
|
||||
|
||||
return (sections, warming);
|
||||
}
|
||||
|
||||
static async Task<List<MusicBrowseSection>> GetSearchSectionsAsync(string query, bool expanded, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
return new List<MusicBrowseSection>();
|
||||
|
||||
var tasks = new List<Task<List<MusicBrowseSection>>>();
|
||||
|
||||
if (YouTubeMusicSearchSupport.IsSearchEnabled)
|
||||
tasks.Add(SafeGetSearchSectionsAsync(token => BuildYouTubeMusicSearchSectionsAsync(query, expanded, token), cancellationToken));
|
||||
|
||||
if (SoundCloudSupport.IsDiscoveryEnabled)
|
||||
tasks.Add(SafeGetSearchSectionsAsync(token => BuildSoundCloudSearchSectionsAsync(query, expanded, token), cancellationToken));
|
||||
|
||||
if (MusicProviderRegistry.AudioProviders.Any(i => i.Enabled && string.Equals(i.Id, SefonSupport.ProviderId, StringComparison.OrdinalIgnoreCase)))
|
||||
tasks.Add(SafeGetSearchSectionsAsync(async token => ToSearchSectionList(await BuildSefonSearchSectionAsync(query, expanded, token)), cancellationToken));
|
||||
|
||||
if (SpotifySupport.IsSearchEnabled)
|
||||
tasks.Add(SafeGetSearchSectionsAsync(token => BuildSpotifySearchSectionsAsync(query, expanded, token), cancellationToken));
|
||||
|
||||
if (tasks.Count == 0)
|
||||
return new List<MusicBrowseSection>();
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
return results
|
||||
.Where(i => i != null)
|
||||
.SelectMany(i => i)
|
||||
.Where(HasSearchSectionEntries)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
static async Task<List<MusicBrowseSection>> BuildYouTubeMusicSearchSectionsAsync(string query, bool expanded, CancellationToken cancellationToken)
|
||||
{
|
||||
int trackLimit = expanded ? searchSectionFullLimit : searchSectionLimit;
|
||||
int sideLimit = expanded ? 18 : 6;
|
||||
string searchKey = NormalizeSearchKey(query);
|
||||
|
||||
var tracksTask = MusicMetadataCacheService.GetOrCreateAsync(
|
||||
YouTubeMusicSearchSupport.ProviderId,
|
||||
"search_tracks",
|
||||
VersionedKey($"youtubemusic-tracks|{trackLimit}|{searchKey}"),
|
||||
searchCacheTtl,
|
||||
() => YouTubeMusicSearchSupport.SearchTracksByQueryAsync(query, trackLimit, cancellationToken),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var playlistsTask = MusicMetadataCacheService.GetOrCreateAsync(
|
||||
YouTubeMusicSearchSupport.ProviderId,
|
||||
"search_playlists",
|
||||
VersionedKey($"youtubemusic-playlists|{sideLimit}|{searchKey}"),
|
||||
searchCacheTtl,
|
||||
() => YouTubeMusicSearchSupport.SearchPlaylistsByQueryAsync(query, sideLimit, cancellationToken),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var artistsTask = MusicMetadataCacheService.GetOrCreateAsync(
|
||||
YouTubeMusicSearchSupport.ProviderId,
|
||||
"search_artists",
|
||||
VersionedKey($"youtubemusic-artists|{sideLimit}|{searchKey}"),
|
||||
searchCacheTtl,
|
||||
() => YouTubeMusicSearchSupport.SearchArtistsByQueryAsync(query, sideLimit, cancellationToken),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
await Task.WhenAll(tracksTask, playlistsTask, artistsTask);
|
||||
|
||||
var tracks = await tracksTask ?? new List<MusicTrack>();
|
||||
var playlists = await playlistsTask ?? new List<MusicAlbum>();
|
||||
var artists = await artistsTask ?? new List<MusicArtist>();
|
||||
var sections = new List<MusicBrowseSection>();
|
||||
|
||||
if (tracks.Count > 0)
|
||||
{
|
||||
sections.Add(new MusicBrowseSection
|
||||
{
|
||||
id = YouTubeMusicSearchSupport.TracksSectionId,
|
||||
title = "Треки",
|
||||
type = "tracks",
|
||||
source_provider = YouTubeMusicSearchSupport.ProviderId,
|
||||
has_more = !expanded && tracks.Count >= trackLimit,
|
||||
tracks = tracks
|
||||
});
|
||||
}
|
||||
|
||||
if (playlists.Count > 0)
|
||||
{
|
||||
sections.Add(new MusicBrowseSection
|
||||
{
|
||||
id = YouTubeMusicSearchSupport.PlaylistsSectionId,
|
||||
title = "Плейлисты / альбомы",
|
||||
type = "albums",
|
||||
source_provider = YouTubeMusicSearchSupport.ProviderId,
|
||||
has_more = !expanded && playlists.Count >= sideLimit,
|
||||
albums = playlists
|
||||
});
|
||||
}
|
||||
|
||||
if (artists.Count > 0)
|
||||
{
|
||||
sections.Add(new MusicBrowseSection
|
||||
{
|
||||
id = YouTubeMusicSearchSupport.ArtistsSectionId,
|
||||
title = "Исполнители / каналы",
|
||||
type = "artists",
|
||||
source_provider = YouTubeMusicSearchSupport.ProviderId,
|
||||
has_more = !expanded && artists.Count >= sideLimit,
|
||||
artists = artists
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
static async Task<List<MusicBrowseSection>> BuildSpotifySearchSectionsAsync(string query, bool expanded, CancellationToken cancellationToken)
|
||||
{
|
||||
int trackLimit = expanded ? searchSectionFullLimit : searchSectionLimit;
|
||||
int sideLimit = expanded ? 18 : 6;
|
||||
string searchKey = NormalizeSearchKey(query);
|
||||
|
||||
var artistsTask = MusicMetadataCacheService.GetOrCreateAsync(
|
||||
SpotifySupport.ProviderId, "search_artists",
|
||||
VersionedKey($"spotify-artists|{sideLimit}|{searchKey}"), searchCacheTtl,
|
||||
() => SpotifySupport.SearchArtistsAsync(query, sideLimit, cancellationToken), cancellationToken);
|
||||
|
||||
var albumsTask = MusicMetadataCacheService.GetOrCreateAsync(
|
||||
SpotifySupport.ProviderId, "search_albums",
|
||||
VersionedKey($"spotify-albums|{sideLimit}|{searchKey}"), searchCacheTtl,
|
||||
() => SpotifySupport.SearchAlbumsAsync(query, sideLimit, cancellationToken), cancellationToken);
|
||||
|
||||
var tracksTask = MusicMetadataCacheService.GetOrCreateAsync(
|
||||
SpotifySupport.ProviderId, "search_tracks",
|
||||
VersionedKey($"spotify-tracks|{trackLimit}|{searchKey}"), searchCacheTtl,
|
||||
() => SpotifySupport.SearchTracksAsync(query, trackLimit, cancellationToken), cancellationToken);
|
||||
|
||||
await Task.WhenAll(artistsTask, albumsTask, tracksTask);
|
||||
|
||||
var artists = await artistsTask ?? new List<MusicArtist>();
|
||||
var albums = await albumsTask ?? new List<MusicAlbum>();
|
||||
var tracks = await tracksTask ?? new List<MusicTrack>();
|
||||
var sections = new List<MusicBrowseSection>();
|
||||
|
||||
if (artists.Count > 0)
|
||||
sections.Add(new MusicBrowseSection
|
||||
{
|
||||
id = SpotifySupport.ArtistsSectionId,
|
||||
title = "Исполнители",
|
||||
type = "artists",
|
||||
source_provider = SpotifySupport.ProviderId,
|
||||
has_more = false,
|
||||
artists = artists
|
||||
});
|
||||
|
||||
if (albums.Count > 0)
|
||||
sections.Add(new MusicBrowseSection
|
||||
{
|
||||
id = SpotifySupport.AlbumsSectionId,
|
||||
title = "Альбомы",
|
||||
type = "albums",
|
||||
source_provider = SpotifySupport.ProviderId,
|
||||
has_more = false,
|
||||
albums = albums
|
||||
});
|
||||
|
||||
if (tracks.Count > 0)
|
||||
sections.Add(new MusicBrowseSection
|
||||
{
|
||||
id = SpotifySupport.TracksSectionId,
|
||||
title = "Треки",
|
||||
type = "tracks",
|
||||
source_provider = SpotifySupport.ProviderId,
|
||||
has_more = false,
|
||||
tracks = tracks
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
static bool ShouldSkipMetadataTrackLookup(string id, string provider)
|
||||
{
|
||||
// Spotify здесь является каталогом/источником discovery, но не
|
||||
// IMusicMetadataProvider. Без этого gate GetMetadataProvider("spotify")
|
||||
// откатывается на дефолтный MusicBrainz и до его сетевого таймаута
|
||||
// пытается найти там spotify:track:* перед каждым первым запуском.
|
||||
if (StartsWithAny(id, "inline:", "youtube:", "sefon:", "soundcloud:", "spotify:"))
|
||||
return true;
|
||||
|
||||
return !string.IsNullOrWhiteSpace(provider)
|
||||
&& MusicProviderRegistry.AudioProviders.Any(i => string.Equals(i.Id, provider, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
static bool StartsWithAny(string value, params string[] prefixes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return false;
|
||||
|
||||
return prefixes.Any(prefix => value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
static async Task<(List<MusicBrowseSection> sections, bool warming)> GetHomeSectionsForResponseAsync(IMusicDiscoveryProvider provider, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
var warmTask = GetOrStartHomeWarmTask(provider, limit);
|
||||
if (warmTask.IsCompleted)
|
||||
return (await warmTask, false);
|
||||
|
||||
var completed = await Task.WhenAny(warmTask, Task.Delay(discoveryHomeProviderResponseBudget, cancellationToken));
|
||||
if (completed == warmTask)
|
||||
return (await warmTask, false);
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
// warming=true: провайдер срезан по 2s-бюджету, прогрев продолжается в фоне —
|
||||
// клиент по этому флагу тихо дозапрашивает home
|
||||
return (new List<MusicBrowseSection>(), true);
|
||||
}
|
||||
|
||||
static Task<List<MusicBrowseSection>> GetOrStartHomeWarmTask(IMusicDiscoveryProvider provider, int limit)
|
||||
{
|
||||
string key = $"{provider.Id}:{limit}";
|
||||
|
||||
lock (homeWarmLock)
|
||||
{
|
||||
if (homeWarmTasks.TryGetValue(key, out var running) && !running.IsCompleted)
|
||||
return running;
|
||||
|
||||
var task = SafeGetHomeSectionsAsync(provider, limit);
|
||||
homeWarmTasks[key] = task;
|
||||
_ = task.ContinueWith(completed =>
|
||||
{
|
||||
lock (homeWarmLock)
|
||||
{
|
||||
if (homeWarmTasks.TryGetValue(key, out var current) && ReferenceEquals(current, completed))
|
||||
homeWarmTasks.Remove(key);
|
||||
}
|
||||
|
||||
if (completed.IsFaulted)
|
||||
_ = completed.Exception;
|
||||
}, CancellationToken.None, TaskContinuationOptions.ExecuteSynchronously, TaskScheduler.Default);
|
||||
|
||||
return task;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<List<MusicBrowseSection>> SafeGetHomeSectionsAsync(IMusicDiscoveryProvider provider, int limit)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timeoutCts = new CancellationTokenSource(discoveryHomeProviderWarmTimeout);
|
||||
return await provider.GetHomeSectionsAsync(limit, timeoutCts.Token) ?? new List<MusicBrowseSection>();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new List<MusicBrowseSection>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<MusicBrowseSection>();
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<MusicBrowseSection> SafeGetSectionAsync(IMusicDiscoveryProvider provider, string sectionId, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(discoverySectionProviderTimeout);
|
||||
return await provider.GetSectionAsync(sectionId, limit, timeoutCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<MusicBrowseSection> SafeGetSearchSectionAsync(Func<CancellationToken, Task<MusicBrowseSection>> factory, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(searchSectionProviderTimeout);
|
||||
return await factory(timeoutCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<List<MusicBrowseSection>> SafeGetSearchSectionsAsync(Func<CancellationToken, Task<List<MusicBrowseSection>>> factory, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(searchSectionProviderTimeout);
|
||||
return await factory(timeoutCts.Token) ?? new List<MusicBrowseSection>();
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return new List<MusicBrowseSection>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<MusicBrowseSection>();
|
||||
}
|
||||
}
|
||||
|
||||
static List<MusicBrowseSection> ToSearchSectionList(MusicBrowseSection section)
|
||||
{
|
||||
return section == null
|
||||
? new List<MusicBrowseSection>()
|
||||
: new List<MusicBrowseSection> { section };
|
||||
}
|
||||
|
||||
static async Task<List<MusicBrowseSection>> BuildSoundCloudSearchSectionsAsync(string query, bool expanded, CancellationToken cancellationToken)
|
||||
{
|
||||
int trackLimit = expanded ? searchSectionFullLimit : searchSectionLimit;
|
||||
int sideLimit = expanded ? 18 : 6;
|
||||
string searchKey = NormalizeSearchKey(query);
|
||||
|
||||
var tracksTask = MusicMetadataCacheService.GetOrCreateAsync(
|
||||
SoundCloudSupport.DiscoveryProviderId,
|
||||
"search_tracks",
|
||||
VersionedKey($"soundcloud-tracks|{trackLimit}|{searchKey}"),
|
||||
searchCacheTtl,
|
||||
() => SoundCloudSupport.SearchTracksByQueryAsync(query, trackLimit, cancellationToken),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var playlistsTask = MusicMetadataCacheService.GetOrCreateAsync(
|
||||
SoundCloudSupport.DiscoveryProviderId,
|
||||
"search_playlists",
|
||||
VersionedKey($"soundcloud-playlists|{sideLimit}|{searchKey}"),
|
||||
searchCacheTtl,
|
||||
() => SoundCloudSupport.SearchPlaylistsByQueryAsync(query, sideLimit, cancellationToken),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var albumsTask = MusicMetadataCacheService.GetOrCreateAsync(
|
||||
SoundCloudSupport.DiscoveryProviderId,
|
||||
"search_albums",
|
||||
VersionedKey($"soundcloud-albums|{sideLimit}|{searchKey}"),
|
||||
searchCacheTtl,
|
||||
() => SoundCloudSupport.SearchAlbumsByQueryAsync(query, sideLimit, cancellationToken),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
var artistsTask = MusicMetadataCacheService.GetOrCreateAsync(
|
||||
SoundCloudSupport.DiscoveryProviderId,
|
||||
"search_artists",
|
||||
VersionedKey($"soundcloud-artists|{sideLimit}|{searchKey}"),
|
||||
searchCacheTtl,
|
||||
() => SoundCloudSupport.SearchArtistsByQueryAsync(query, sideLimit, cancellationToken),
|
||||
cancellationToken
|
||||
);
|
||||
|
||||
await Task.WhenAll(tracksTask, albumsTask, playlistsTask, artistsTask);
|
||||
|
||||
var tracks = await tracksTask ?? new List<MusicTrack>();
|
||||
var albums = await albumsTask ?? new List<MusicAlbum>();
|
||||
var playlists = await playlistsTask ?? new List<MusicAlbum>();
|
||||
var artists = await artistsTask ?? new List<MusicArtist>();
|
||||
var sections = new List<MusicBrowseSection>();
|
||||
|
||||
if (tracks.Count > 0)
|
||||
{
|
||||
sections.Add(new MusicBrowseSection
|
||||
{
|
||||
id = SoundCloudSupport.SearchTracksSectionId,
|
||||
title = "Треки",
|
||||
type = "tracks",
|
||||
source_provider = SoundCloudSupport.DiscoveryProviderId,
|
||||
has_more = !expanded && tracks.Count >= trackLimit,
|
||||
tracks = tracks
|
||||
});
|
||||
}
|
||||
|
||||
if (albums.Count > 0)
|
||||
{
|
||||
sections.Add(new MusicBrowseSection
|
||||
{
|
||||
id = SoundCloudSupport.SearchAlbumsSectionId,
|
||||
title = "Альбомы",
|
||||
type = "albums",
|
||||
source_provider = SoundCloudSupport.DiscoveryProviderId,
|
||||
has_more = !expanded && albums.Count >= sideLimit,
|
||||
albums = albums
|
||||
});
|
||||
}
|
||||
|
||||
if (playlists.Count > 0)
|
||||
{
|
||||
sections.Add(new MusicBrowseSection
|
||||
{
|
||||
id = SoundCloudSupport.SearchPlaylistsSectionId,
|
||||
title = "Плейлисты",
|
||||
type = "albums",
|
||||
source_provider = SoundCloudSupport.DiscoveryProviderId,
|
||||
has_more = !expanded && playlists.Count >= sideLimit,
|
||||
albums = playlists
|
||||
});
|
||||
}
|
||||
|
||||
if (artists.Count > 0)
|
||||
{
|
||||
sections.Add(new MusicBrowseSection
|
||||
{
|
||||
id = SoundCloudSupport.SearchArtistsSectionId,
|
||||
title = "Исполнители",
|
||||
type = "artists",
|
||||
source_provider = SoundCloudSupport.DiscoveryProviderId,
|
||||
has_more = !expanded && artists.Count >= sideLimit,
|
||||
artists = artists
|
||||
});
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
static async Task<MusicBrowseSection> BuildSefonSearchSectionAsync(string query, bool expanded, CancellationToken cancellationToken)
|
||||
{
|
||||
int limit = expanded ? searchSectionFullLimit : searchSectionLimit;
|
||||
var tracks = await MusicMetadataCacheService.GetOrCreateAsync(
|
||||
SefonSupport.ProviderId,
|
||||
"search_tracks",
|
||||
VersionedKey($"sefon|{limit}|{NormalizeSearchKey(query)}"),
|
||||
searchCacheTtl,
|
||||
() => SefonSupport.SearchTracksByQueryAsync(query, limit, cancellationToken),
|
||||
cancellationToken
|
||||
) ?? new List<MusicTrack>();
|
||||
|
||||
return tracks.Count == 0
|
||||
? null
|
||||
: new MusicBrowseSection
|
||||
{
|
||||
id = "search:sefon",
|
||||
title = "Sefon",
|
||||
type = "tracks",
|
||||
source_provider = SefonSupport.ProviderId,
|
||||
has_more = !expanded && tracks.Count >= limit,
|
||||
tracks = tracks
|
||||
};
|
||||
}
|
||||
|
||||
static bool HasSearchSectionEntries(MusicBrowseSection section)
|
||||
{
|
||||
return section != null
|
||||
&& ((section.tracks?.Count > 0) || (section.albums?.Count > 0) || (section.artists?.Count > 0));
|
||||
}
|
||||
|
||||
static string NormalizeSearchKey(string query) => query.Trim().ToLowerInvariant();
|
||||
static string VersionedKey(string value) => $"{metadataCacheVersion}|{value}";
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
namespace Music;
|
||||
|
||||
public static class MusicProviderRegistry
|
||||
{
|
||||
static readonly List<IMusicMetadataProvider> metadataProviders = new()
|
||||
{
|
||||
new MusicBrainzMetadataProvider()
|
||||
};
|
||||
|
||||
static readonly List<IMusicDiscoveryProvider> discoveryProviders = new()
|
||||
{
|
||||
new AppleMusicDiscoveryProvider(),
|
||||
new SpotifyDiscoveryProvider(),
|
||||
new VkTopChartDiscoveryProvider(),
|
||||
new SoundCloudDiscoveryProvider()
|
||||
};
|
||||
|
||||
static readonly List<IMusicAudioProvider> audioProviders = new()
|
||||
{
|
||||
new YouTubeAudioProvider(),
|
||||
new SefonAudioProvider(),
|
||||
new SoundCloudAudioProvider()
|
||||
};
|
||||
|
||||
static readonly IMusicAudioProvider z3FmAudioProvider = new Z3FmAudioProvider();
|
||||
|
||||
static readonly List<IMusicAuthProvider> authProviders = new()
|
||||
{
|
||||
new SoundCloudAuthProvider()
|
||||
};
|
||||
|
||||
public static IReadOnlyList<IMusicMetadataProvider> MetadataProviders => metadataProviders;
|
||||
public static IReadOnlyList<IMusicDiscoveryProvider> DiscoveryProviders => discoveryProviders;
|
||||
public static IReadOnlyList<IMusicAudioProvider> AudioProviders => GetAudioProviders();
|
||||
public static IReadOnlyList<IMusicAuthProvider> AuthProviders => authProviders;
|
||||
|
||||
public static IMusicMetadataProvider GetMetadataProvider(string id = null)
|
||||
{
|
||||
string target = string.IsNullOrWhiteSpace(id) ? ModInit.conf.default_metadata_provider : id;
|
||||
return metadataProviders.FirstOrDefault(i => i.Id == target && i.Enabled) ?? metadataProviders.FirstOrDefault(i => i.Enabled);
|
||||
}
|
||||
|
||||
public static IMusicAudioProvider GetAudioProvider(string id = null)
|
||||
{
|
||||
string target = string.IsNullOrWhiteSpace(id) ? ModInit.conf.default_audio_provider : id;
|
||||
return AudioProviders.FirstOrDefault(i => i.Id == target && i.Enabled) ?? AudioProviders.FirstOrDefault(i => i.Enabled);
|
||||
}
|
||||
|
||||
public static IMusicDiscoveryProvider GetDiscoveryProvider(string id = null)
|
||||
{
|
||||
string target = string.IsNullOrWhiteSpace(id) ? null : id;
|
||||
return discoveryProviders.FirstOrDefault(i => i.Id == target && i.Enabled) ?? discoveryProviders.FirstOrDefault(i => i.Enabled);
|
||||
}
|
||||
|
||||
public static IMusicAuthProvider GetAuthProvider(string id = null)
|
||||
{
|
||||
string target = string.IsNullOrWhiteSpace(id) ? ModInit.conf.default_auth_provider : id;
|
||||
return authProviders.FirstOrDefault(i => i.Id == target && i.Enabled) ?? authProviders.FirstOrDefault(i => i.Enabled);
|
||||
}
|
||||
|
||||
public static List<MusicProviderDescriptor> DescribeMetadata() => metadataProviders.Select(i => new MusicProviderDescriptor
|
||||
{
|
||||
id = i.Id,
|
||||
name = i.Name,
|
||||
type = "metadata",
|
||||
enabled = i.Enabled,
|
||||
requires_auth = false,
|
||||
capabilities = new List<string> { "search", "artist", "album", "track", "browse" }
|
||||
}).ToList();
|
||||
|
||||
public static List<MusicProviderDescriptor> DescribeAudio() => AudioProviders.Select(i => new MusicProviderDescriptor
|
||||
{
|
||||
id = i.Id,
|
||||
name = i.Name,
|
||||
type = "audio",
|
||||
enabled = i.Enabled,
|
||||
requires_auth = i.RequiresAuth,
|
||||
capabilities = BuildAudioCapabilities(i)
|
||||
}).ToList();
|
||||
|
||||
public static List<MusicProviderDescriptor> DescribeAuth() => authProviders.Select(i => new MusicProviderDescriptor
|
||||
{
|
||||
id = i.Id,
|
||||
name = i.Name,
|
||||
type = "auth",
|
||||
enabled = i.Enabled,
|
||||
requires_auth = true,
|
||||
capabilities = new List<string> { "state", "save", "logout" }
|
||||
}).ToList();
|
||||
|
||||
static List<string> BuildAudioCapabilities(IMusicAudioProvider provider)
|
||||
{
|
||||
var capabilities = new List<string> { "match", "streams", "playback", "audio" };
|
||||
|
||||
if (string.Equals(provider?.Id, "youtubeaudio", StringComparison.OrdinalIgnoreCase))
|
||||
capabilities.Add("video");
|
||||
|
||||
if (provider?.RequiresAuth == true)
|
||||
capabilities.Add("auth");
|
||||
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
static IReadOnlyList<IMusicAudioProvider> GetAudioProviders()
|
||||
{
|
||||
if (z3FmAudioProvider.Enabled)
|
||||
return audioProviders.Concat(new[] { z3FmAudioProvider }).ToList();
|
||||
|
||||
return audioProviders;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user