Исправление: том ./mods:/lampac/mods в compose и копии 10 модулей с enable:true; конфиг без SkipModules
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
namespace Music;
|
||||
|
||||
public static class MusicAuthService
|
||||
{
|
||||
public static async Task<List<MusicAuthState>> GetStatesAsync(string profileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = new List<MusicAuthState>();
|
||||
|
||||
foreach (var provider in MusicProviderRegistry.AuthProviders.Where(i => i.Enabled))
|
||||
result.Add(await provider.GetStateAsync(profileId, cancellationToken));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static Task<bool> SaveAsync(string provider, string payload, string profileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var auth = MusicProviderRegistry.GetAuthProvider(provider);
|
||||
return auth == null ? Task.FromResult(false) : auth.SaveAsync(payload, profileId, cancellationToken);
|
||||
}
|
||||
|
||||
public static Task LogoutAsync(string provider, string profileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var auth = MusicProviderRegistry.GetAuthProvider(provider);
|
||||
return auth == null ? Task.CompletedTask : auth.LogoutAsync(profileId, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Music;
|
||||
|
||||
public static class MusicAuthStorageService
|
||||
{
|
||||
public static async Task<T> GetAsync<T>(string profileId, string providerId, CancellationToken cancellationToken = default) where T : class
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(providerId))
|
||||
return null;
|
||||
|
||||
using var db = MusicContext.Create();
|
||||
string normalizedProfileId = NormalizeProfileId(profileId);
|
||||
|
||||
var row = await db.auth_credentials
|
||||
.Where(i => i.provider_id == providerId && i.profile_id == normalizedProfileId)
|
||||
.OrderByDescending(i => i.updated)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (row == null && normalizedProfileId != string.Empty)
|
||||
{
|
||||
row = await db.auth_credentials
|
||||
.Where(i => i.provider_id == providerId && i.profile_id == string.Empty)
|
||||
.OrderByDescending(i => i.updated)
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
|
||||
return row == null ? null : MusicJson.Deserialize<T>(row.payload);
|
||||
}
|
||||
|
||||
public static async Task SaveAsync<T>(string profileId, string providerId, T payload, CancellationToken cancellationToken = default) where T : class
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(providerId) || payload == null)
|
||||
return;
|
||||
|
||||
using var db = MusicContext.Create();
|
||||
string normalizedProfileId = NormalizeProfileId(profileId);
|
||||
var existing = await db.auth_credentials
|
||||
.FirstOrDefaultAsync(i => i.profile_id == normalizedProfileId && i.provider_id == providerId, cancellationToken);
|
||||
|
||||
if (existing == null)
|
||||
{
|
||||
db.auth_credentials.Add(new MusicAuthCredentialSqlModel
|
||||
{
|
||||
profile_id = normalizedProfileId,
|
||||
provider_id = providerId,
|
||||
payload = MusicJson.Serialize(payload),
|
||||
updated = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.profile_id = normalizedProfileId;
|
||||
existing.payload = MusicJson.Serialize(payload);
|
||||
existing.updated = DateTime.UtcNow;
|
||||
db.auth_credentials.Update(existing);
|
||||
}
|
||||
|
||||
var semaphore = new SemaphorManager(MusicContext.semaphoreKey, TimeSpan.FromSeconds(20));
|
||||
|
||||
try
|
||||
{
|
||||
if (await semaphore.WaitAsync())
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task DeleteAsync(string profileId, string providerId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(providerId))
|
||||
return;
|
||||
|
||||
using var db = MusicContext.Create();
|
||||
string normalizedProfileId = NormalizeProfileId(profileId);
|
||||
var existing = await db.auth_credentials
|
||||
.Where(i => i.provider_id == providerId && (i.profile_id == normalizedProfileId || i.profile_id == string.Empty))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (existing.Count == 0)
|
||||
return;
|
||||
|
||||
db.auth_credentials.RemoveRange(existing);
|
||||
|
||||
var semaphore = new SemaphorManager(MusicContext.semaphoreKey, TimeSpan.FromSeconds(20));
|
||||
|
||||
try
|
||||
{
|
||||
if (await semaphore.WaitAsync())
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
static string NormalizeProfileId(string profileId)
|
||||
=> string.IsNullOrWhiteSpace(profileId) ? "global" : profileId.Trim().ToLowerInvariant();
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Shared.Models.Base;
|
||||
using Shared.Services.Utilities;
|
||||
|
||||
namespace Music;
|
||||
|
||||
public static class MusicProfileIdentity
|
||||
{
|
||||
public static string Resolve(RequestModel requestInfo, HttpRequest request)
|
||||
{
|
||||
string profileId = requestInfo?.user_uid;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(profileId) && request != null)
|
||||
{
|
||||
profileId = request.Query["uid"].FirstOrDefault() ?? request.Query["account_email"].FirstOrDefault();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(profileId) && request.HasFormContentType)
|
||||
{
|
||||
try
|
||||
{
|
||||
profileId = request.Form["uid"].FirstOrDefault() ?? request.Form["account_email"].FirstOrDefault();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(profileId))
|
||||
return profileId.Trim().ToLowerInvariant();
|
||||
|
||||
string fingerprint = $"{requestInfo?.IP}|{requestInfo?.UserAgent}".Trim('|');
|
||||
if (!string.IsNullOrWhiteSpace(fingerprint))
|
||||
return $"anon:{CrypTo.md5(fingerprint)}";
|
||||
|
||||
return "global";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using Shared.Services;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
|
||||
namespace Music;
|
||||
|
||||
public static class MusicHttp
|
||||
{
|
||||
public static HttpClient CreateClient(string providerId, MusicProxyPurpose purpose = MusicProxyPurpose.Api)
|
||||
=> new(new RoutingHandler(providerId, purpose));
|
||||
|
||||
public static HttpClient GetTransport(MusicProxyLease lease)
|
||||
{
|
||||
HttpClientHandler handler = null;
|
||||
|
||||
if (lease?.Enabled == true)
|
||||
{
|
||||
handler = new HttpClientHandler
|
||||
{
|
||||
AllowAutoRedirect = true,
|
||||
AutomaticDecompression = DecompressionMethods.Brotli | DecompressionMethods.GZip | DecompressionMethods.Deflate,
|
||||
Proxy = lease.Proxy,
|
||||
UseProxy = true,
|
||||
ServerCertificateCustomValidationCallback = Http.AlwaysAllowCertificate
|
||||
};
|
||||
}
|
||||
|
||||
return FriendlyHttp.MessageClient(
|
||||
"base",
|
||||
handler,
|
||||
out _,
|
||||
allowAutoRedirect: true,
|
||||
findNoRedirectClient: true);
|
||||
}
|
||||
|
||||
public static bool IsProxyFailureStatus(HttpStatusCode statusCode)
|
||||
=> statusCode is HttpStatusCode.ProxyAuthenticationRequired
|
||||
or HttpStatusCode.BadGateway
|
||||
or HttpStatusCode.ServiceUnavailable
|
||||
or HttpStatusCode.GatewayTimeout;
|
||||
|
||||
public static bool IsTransientFailureStatus(HttpStatusCode statusCode)
|
||||
=> statusCode is HttpStatusCode.Unauthorized
|
||||
or HttpStatusCode.Forbidden
|
||||
or HttpStatusCode.RequestTimeout
|
||||
or HttpStatusCode.TooManyRequests
|
||||
|| (int)statusCode >= 500;
|
||||
|
||||
public static bool IsProxyFailure(Exception exception)
|
||||
{
|
||||
for (var current = exception; current != null; current = current.InnerException)
|
||||
{
|
||||
if (current is HttpRequestException)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
sealed class RoutingHandler : HttpMessageHandler
|
||||
{
|
||||
readonly string providerId;
|
||||
readonly MusicProxyPurpose purpose;
|
||||
|
||||
public RoutingHandler(string providerId, MusicProxyPurpose purpose)
|
||||
{
|
||||
this.providerId = providerId;
|
||||
this.purpose = purpose;
|
||||
}
|
||||
|
||||
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var lease = MusicProxyService.Acquire(providerId, purpose);
|
||||
var transport = GetTransport(lease);
|
||||
using var routedRequest = await CloneRequestAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
var response = await transport.SendAsync(routedRequest, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (lease.Enabled && IsProxyFailureStatus(response.StatusCode))
|
||||
lease.Failure();
|
||||
else
|
||||
lease.Success();
|
||||
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (lease.Enabled && IsProxyFailure(ex))
|
||||
lease.Failure();
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<HttpRequestMessage> CloneRequestAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
var clone = new HttpRequestMessage(request.Method, request.RequestUri)
|
||||
{
|
||||
Version = request.Version,
|
||||
VersionPolicy = request.VersionPolicy
|
||||
};
|
||||
|
||||
foreach (var header in request.Headers)
|
||||
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
|
||||
foreach (var option in request.Options)
|
||||
clone.Options.Set(new HttpRequestOptionsKey<object>(option.Key), option.Value);
|
||||
|
||||
if (request.Content != null)
|
||||
{
|
||||
clone.Content = new ByteArrayContent(await request.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false));
|
||||
foreach (var header in request.Content.Headers)
|
||||
clone.Content.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using Shared.Services;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net;
|
||||
|
||||
namespace Music;
|
||||
|
||||
public enum MusicProxyPurpose
|
||||
{
|
||||
Api,
|
||||
Stream
|
||||
}
|
||||
|
||||
public sealed class MusicProxyLease
|
||||
{
|
||||
readonly ProxyManager manager;
|
||||
|
||||
internal MusicProxyLease(string scope, ProxyManager manager, WebProxy proxy, (string ip, string username, string password) data)
|
||||
{
|
||||
Scope = scope;
|
||||
this.manager = manager;
|
||||
Proxy = proxy;
|
||||
Data = data;
|
||||
}
|
||||
|
||||
public string Scope { get; }
|
||||
public WebProxy Proxy { get; }
|
||||
public (string ip, string username, string password) Data { get; }
|
||||
public bool Enabled => Proxy != null;
|
||||
public string RouteKey => MusicProxyService.BuildRouteKey(
|
||||
Scope,
|
||||
Data.ip ?? Proxy?.Address?.ToString(),
|
||||
Data.username,
|
||||
Data.password
|
||||
);
|
||||
|
||||
public void Success() => manager?.Success();
|
||||
public void Failure() => manager?.Refresh();
|
||||
|
||||
public void ApplyTo(MusicPlaybackSource source, bool overwrite = false)
|
||||
{
|
||||
if (source == null || !Enabled || (!overwrite && !string.IsNullOrWhiteSpace(source.proxy_url)))
|
||||
return;
|
||||
|
||||
source.proxy_url = Data.ip ?? Proxy.Address?.ToString();
|
||||
source.proxy_username = Data.username;
|
||||
source.proxy_password = Data.password;
|
||||
source.proxy_scope = Scope;
|
||||
}
|
||||
}
|
||||
|
||||
public static class MusicProxyService
|
||||
{
|
||||
sealed record ManagerEntry(ModuleConf Conf, ProxyManager Manager);
|
||||
|
||||
static readonly ConcurrentDictionary<string, ManagerEntry> managers = new(StringComparer.OrdinalIgnoreCase);
|
||||
static long configurationVersion;
|
||||
|
||||
public static long ConfigurationVersion => Interlocked.Read(ref configurationVersion);
|
||||
|
||||
public static void ConfigurationChanged()
|
||||
{
|
||||
Interlocked.Increment(ref configurationVersion);
|
||||
managers.Clear();
|
||||
}
|
||||
|
||||
public static MusicProxyLease Acquire(string providerId, MusicProxyPurpose purpose)
|
||||
{
|
||||
var conf = ModInit.conf;
|
||||
bool enabled = purpose == MusicProxyPurpose.Api
|
||||
? conf?.useproxy == true
|
||||
: conf?.useproxystream == true;
|
||||
|
||||
if (!enabled || conf == null)
|
||||
return new MusicProxyLease(null, null, null, default);
|
||||
|
||||
string provider = string.IsNullOrWhiteSpace(providerId) ? "shared" : providerId.Trim().ToLowerInvariant();
|
||||
string scope = $"Music:{provider}:{purpose.ToString().ToLowerInvariant()}";
|
||||
var entry = managers.AddOrUpdate(
|
||||
scope,
|
||||
_ => new ManagerEntry(conf, new ProxyManager(scope, conf)),
|
||||
(_, current) => ReferenceEquals(current.Conf, conf)
|
||||
? current
|
||||
: new ManagerEntry(conf, new ProxyManager(scope, conf)));
|
||||
|
||||
var selected = entry.Manager.BaseGet();
|
||||
return new MusicProxyLease(scope, entry.Manager, selected.proxy, selected.data);
|
||||
}
|
||||
|
||||
public static void ApplyStreamProxy(string providerId, IEnumerable<MusicPlaybackSource> sources)
|
||||
{
|
||||
if (sources == null)
|
||||
return;
|
||||
|
||||
MusicProxyLease lease = null;
|
||||
|
||||
foreach (var source in sources)
|
||||
{
|
||||
if (source == null || !string.IsNullOrWhiteSpace(source.proxy_url))
|
||||
continue;
|
||||
|
||||
lease ??= Acquire(providerId, MusicProxyPurpose.Stream);
|
||||
lease.ApplyTo(source);
|
||||
}
|
||||
}
|
||||
|
||||
public static string CurrentRouteKey(string providerId, MusicProxyPurpose purpose)
|
||||
=> Acquire(providerId, purpose).RouteKey;
|
||||
|
||||
public static string SourceRouteKey(MusicPlaybackSource source)
|
||||
=> source == null
|
||||
? "direct"
|
||||
: BuildRouteKey(source.proxy_scope, source.proxy_url, source.proxy_username, source.proxy_password);
|
||||
|
||||
internal static string BuildRouteKey(string scope, string proxyUrl, string username, string password)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(proxyUrl))
|
||||
return "direct";
|
||||
|
||||
return $"{scope ?? string.Empty}|{proxyUrl.Trim()}|{username ?? string.Empty}|{password ?? string.Empty}";
|
||||
}
|
||||
|
||||
public static void ReportSuccess(string scope)
|
||||
=> GetCurrentManager(scope)?.Success();
|
||||
|
||||
public static void ReportFailure(string scope)
|
||||
=> GetCurrentManager(scope)?.Refresh();
|
||||
|
||||
static ProxyManager GetCurrentManager(string scope)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scope))
|
||||
return null;
|
||||
|
||||
return managers.TryGetValue(scope, out var entry) ? entry.Manager : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,342 @@
|
||||
using System.Net.Http;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace Music;
|
||||
|
||||
public static class DiscogsArtistImageService
|
||||
{
|
||||
static readonly HttpClient httpClient = MusicHttp.CreateClient("discogs");
|
||||
static readonly SemaphoreSlim requestGate = new(1, 1);
|
||||
static readonly ConcurrentDictionary<string, Task<List<MusicImage>>> pendingLookups = new();
|
||||
static DateTime nextRequestAt = DateTime.MinValue;
|
||||
|
||||
static readonly TimeSpan cacheTtl = TimeSpan.FromDays(30);
|
||||
const string cacheVersion = "v6";
|
||||
|
||||
const string baseUrl = "https://api.discogs.com";
|
||||
const string userAgent = "LampacNextgenMusic/0.1 (https://github.com/lampac-nextgen/lampac)";
|
||||
|
||||
static DiscogsArtistImageService()
|
||||
{
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(20);
|
||||
}
|
||||
|
||||
public static async Task EnrichAsync(List<MusicArtist> artists, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (artists == null || artists.Count == 0)
|
||||
return;
|
||||
|
||||
var seen = new HashSet<string>();
|
||||
|
||||
foreach (var artist in artists)
|
||||
{
|
||||
if (artist == null)
|
||||
continue;
|
||||
|
||||
string normalized = NormalizeArtistName(artist.name);
|
||||
if (string.IsNullOrWhiteSpace(normalized) || !seen.Add(normalized))
|
||||
continue;
|
||||
|
||||
await EnrichAsync(artist, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task ApplyCachedAsync(List<MusicArtist> artists, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (artists == null || artists.Count == 0)
|
||||
return;
|
||||
|
||||
var seen = new HashSet<string>();
|
||||
|
||||
foreach (var artist in artists)
|
||||
{
|
||||
if (artist == null || !string.IsNullOrWhiteSpace(artist.images?.FirstOrDefault()?.url) || string.IsNullOrWhiteSpace(artist.name))
|
||||
continue;
|
||||
|
||||
string normalized = NormalizeArtistName(artist.name);
|
||||
if (string.IsNullOrWhiteSpace(normalized) || !seen.Add(normalized))
|
||||
continue;
|
||||
|
||||
var images = await GetCachedImagesAsync(artist.name, artist.country, cancellationToken);
|
||||
|
||||
if (images.Count > 0)
|
||||
artist.images = images;
|
||||
}
|
||||
}
|
||||
|
||||
public static void WarmupMissing(List<MusicArtist> artists)
|
||||
{
|
||||
if (artists == null || artists.Count == 0)
|
||||
return;
|
||||
|
||||
var queue = artists
|
||||
.Where(i => i != null && string.IsNullOrWhiteSpace(i.images?.FirstOrDefault()?.url) && !string.IsNullOrWhiteSpace(i.name))
|
||||
.GroupBy(i => BuildCacheKey(i.name, i.country))
|
||||
.Select(i => i.First())
|
||||
.Take(3)
|
||||
.ToList();
|
||||
|
||||
if (queue.Count == 0)
|
||||
return;
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
foreach (var artist in queue)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ResolveImagesAsync(artist, CancellationToken.None);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static async Task EnrichAsync(MusicArtist artist, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (artist == null || !string.IsNullOrWhiteSpace(artist.images?.FirstOrDefault()?.url) || string.IsNullOrWhiteSpace(artist.name))
|
||||
return;
|
||||
|
||||
var images = await ResolveImagesAsync(artist, cancellationToken);
|
||||
|
||||
if (images.Count > 0)
|
||||
artist.images = images;
|
||||
}
|
||||
|
||||
public static async Task<List<MusicImage>> ResolveImagesAsync(MusicArtist artist, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (artist == null || string.IsNullOrWhiteSpace(artist.name))
|
||||
return new List<MusicImage>();
|
||||
|
||||
string cacheKey = BuildCacheKey(artist.name, artist.country);
|
||||
var cached = await GetCachedImagesAsync(artist.name, artist.country, cancellationToken);
|
||||
if (cached.Count > 0)
|
||||
return cached;
|
||||
|
||||
var pending = pendingLookups.GetOrAdd(cacheKey, _ => LookupAndCacheAsync(cacheKey, artist.name, artist.country));
|
||||
|
||||
try
|
||||
{
|
||||
return await pending;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (pending.IsCompleted)
|
||||
pendingLookups.TryRemove(new KeyValuePair<string, Task<List<MusicImage>>>(cacheKey, pending));
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<List<MusicImage>> GetCachedImagesAsync(string artistName, string country = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(artistName))
|
||||
return new List<MusicImage>();
|
||||
|
||||
string cacheKey = BuildCacheKey(artistName, country);
|
||||
var exact = await MusicMetadataCacheService.GetAsync<List<MusicImage>>(
|
||||
"discogs",
|
||||
"artist-image",
|
||||
cacheKey,
|
||||
cancellationToken
|
||||
) ?? new List<MusicImage>();
|
||||
|
||||
if (exact.Count > 0 || string.IsNullOrWhiteSpace(country))
|
||||
return exact;
|
||||
|
||||
return await MusicMetadataCacheService.GetAsync<List<MusicImage>>(
|
||||
"discogs",
|
||||
"artist-image",
|
||||
BuildCacheKey(artistName, null),
|
||||
cancellationToken
|
||||
) ?? new List<MusicImage>();
|
||||
}
|
||||
|
||||
static async Task<List<MusicImage>> LookupAndCacheAsync(string cacheKey, string artistName, string country)
|
||||
{
|
||||
return await MusicMetadataCacheService.GetOrCreateAsync(
|
||||
"discogs",
|
||||
"artist-image",
|
||||
cacheKey,
|
||||
cacheTtl,
|
||||
() => LookupImagesAsync(artistName, country, CancellationToken.None),
|
||||
CancellationToken.None
|
||||
) ?? new List<MusicImage>();
|
||||
}
|
||||
|
||||
static async Task<List<MusicImage>> LookupImagesAsync(string artistName, string country, CancellationToken cancellationToken)
|
||||
{
|
||||
var searchJson = await GetJsonAsync($"database/search?q={HttpUtility.UrlEncode(artistName)}&type=artist&per_page=5", cancellationToken);
|
||||
|
||||
// null = сеть/429 — НЕ кэшируем (GetOrCreateAsync пропускает null),
|
||||
// чтобы сбой Discogs не превращался в «у артиста нет фото»
|
||||
if (searchJson == null)
|
||||
return null;
|
||||
|
||||
var best = SelectBestArtist(searchJson["results"] as JsonArray, artistName, country);
|
||||
if (best == null)
|
||||
return new List<MusicImage>();
|
||||
|
||||
var id = best["id"]?.GetValue<int?>();
|
||||
if (id == null || id <= 0)
|
||||
return new List<MusicImage>();
|
||||
|
||||
var artistJson = await GetJsonAsync($"artists/{id}", cancellationToken);
|
||||
if (artistJson == null)
|
||||
return null;
|
||||
|
||||
return ParseImages(artistJson["images"] as JsonArray);
|
||||
}
|
||||
|
||||
static JsonObject SelectBestArtist(JsonArray results, string artistName, string country)
|
||||
{
|
||||
if (results == null || results.Count == 0)
|
||||
return null;
|
||||
|
||||
string target = NormalizeArtistName(artistName);
|
||||
int bestScore = int.MinValue;
|
||||
JsonObject best = null;
|
||||
|
||||
foreach (var item in results.OfType<JsonObject>())
|
||||
{
|
||||
int score = ScoreArtist(item, target, country);
|
||||
if (score > bestScore)
|
||||
{
|
||||
bestScore = score;
|
||||
best = item;
|
||||
}
|
||||
}
|
||||
|
||||
return bestScore > 0 ? best : null;
|
||||
}
|
||||
|
||||
static int ScoreArtist(JsonObject item, string target, string country)
|
||||
{
|
||||
string rawTitle = item["title"]?.GetValue<string>() ?? string.Empty;
|
||||
string title = NormalizeArtistName(rawTitle);
|
||||
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(target))
|
||||
return 0;
|
||||
|
||||
if (title != target)
|
||||
return 0;
|
||||
|
||||
int score = 100;
|
||||
|
||||
if (string.Equals(rawTitle.Trim().ToLowerInvariant(), target, StringComparison.Ordinal))
|
||||
score += 15;
|
||||
|
||||
if (rawTitle.Contains("(")) score -= 10;
|
||||
if (Regex.IsMatch(rawTitle, @"\(\d+\)\s*$")) score -= 10;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(country) && rawTitle.Contains(country, StringComparison.OrdinalIgnoreCase))
|
||||
score += 5;
|
||||
|
||||
string thumb = item["thumb"]?.GetValue<string>() ?? string.Empty;
|
||||
string cover = item["cover_image"]?.GetValue<string>() ?? string.Empty;
|
||||
if (!string.IsNullOrWhiteSpace(thumb) || !string.IsNullOrWhiteSpace(cover))
|
||||
score += 3;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
static string NormalizeArtistName(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return string.Empty;
|
||||
|
||||
value = Regex.Replace(value, @"\s*\(\d+\)\s*$", string.Empty);
|
||||
value = Regex.Replace(value, @"[^0-9\p{L}\s]+", " ");
|
||||
value = Regex.Replace(value, @"\s+", " ").Trim();
|
||||
return value.ToLowerInvariant();
|
||||
}
|
||||
|
||||
static List<MusicImage> ParseImages(JsonArray items)
|
||||
{
|
||||
if (items == null || items.Count == 0)
|
||||
return new List<MusicImage>();
|
||||
|
||||
var primary = items
|
||||
.OfType<JsonObject>()
|
||||
.OrderByDescending(item => string.Equals(item["type"]?.GetValue<string>(), "primary", StringComparison.OrdinalIgnoreCase))
|
||||
.FirstOrDefault();
|
||||
|
||||
if (primary == null)
|
||||
return new List<MusicImage>();
|
||||
|
||||
string small = primary["uri150"]?.GetValue<string>();
|
||||
string full = primary["uri"]?.GetValue<string>();
|
||||
int width = primary["width"]?.GetValue<int?>() ?? 0;
|
||||
int height = primary["height"]?.GetValue<int?>() ?? 0;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(small) && string.IsNullOrWhiteSpace(full))
|
||||
return new List<MusicImage>();
|
||||
|
||||
var result = new List<MusicImage>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(small))
|
||||
{
|
||||
result.Add(new()
|
||||
{
|
||||
url = small,
|
||||
width = 150,
|
||||
height = 150
|
||||
});
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(full) && width > 0 && height > 0)
|
||||
{
|
||||
result.Add(new()
|
||||
{
|
||||
url = full,
|
||||
width = width,
|
||||
height = height
|
||||
});
|
||||
}
|
||||
|
||||
return result
|
||||
.GroupBy(i => i.url)
|
||||
.Select(i => i.First())
|
||||
.OrderBy(i => i.width)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
static async Task<JsonObject> GetJsonAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
await RespectRateLimitAsync(cancellationToken);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, $"{baseUrl}/{path}");
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", userAgent);
|
||||
request.Headers.TryAddWithoutValidation("Accept", "application/json");
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
return JsonNode.Parse(json) as JsonObject;
|
||||
}
|
||||
|
||||
static async Task RespectRateLimitAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await requestGate.WaitAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if (nextRequestAt > now)
|
||||
await Task.Delay(nextRequestAt - now, cancellationToken);
|
||||
|
||||
// анонимный лимит Discogs — 25 запросов/мин, т.е. не чаще ~2.4s
|
||||
nextRequestAt = DateTime.UtcNow.AddMilliseconds(2500);
|
||||
}
|
||||
finally
|
||||
{
|
||||
requestGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
static string BuildCacheKey(string artistName, string country)
|
||||
=> $"{cacheVersion}|{NormalizeArtistName(artistName)}|{(country ?? string.Empty).Trim().ToLowerInvariant()}";
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
using Shared;
|
||||
using Shared.Models.Base;
|
||||
|
||||
namespace Music;
|
||||
|
||||
public static class MusicImageProxyService
|
||||
{
|
||||
static readonly BaseSettings init = new()
|
||||
{
|
||||
plugin = "Music"
|
||||
};
|
||||
|
||||
// Create a shallow copy without listing every DTO property. The delegate is
|
||||
// bound once; new metadata fields are preserved automatically.
|
||||
static readonly Func<object, object> shallowCopy = typeof(object)
|
||||
.GetMethod("MemberwiseClone", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)
|
||||
.CreateDelegate<Func<object, object>>();
|
||||
|
||||
static T Copy<T>(T value) where T : class => (T)shallowCopy(value);
|
||||
|
||||
// Only rewritten image branches are copied. Reused branches stay read-only:
|
||||
// controllers serialize this result without modifying its DTOs or lists.
|
||||
public static T Apply<T>(BaseController controller, T response)
|
||||
{
|
||||
if (controller == null || response == null)
|
||||
return response;
|
||||
|
||||
return (T)Rewrite(controller, response);
|
||||
}
|
||||
|
||||
static object Rewrite(BaseController controller, object value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
MusicSearchResponse response => RewriteSearch(controller, response),
|
||||
MusicHomeResponse response => RewriteHome(controller, response),
|
||||
MusicStatsTopResult response => RewriteStats(controller, response),
|
||||
MusicArtist artist => RewriteArtist(controller, artist),
|
||||
MusicAlbum album => RewriteAlbum(controller, album),
|
||||
MusicTrack track => RewriteTrack(controller, track),
|
||||
MusicBrowseSection section => RewriteSection(controller, section),
|
||||
MusicUserPlaylistSummary playlist => RewritePlaylist(controller, playlist),
|
||||
MusicUserPlaylistImportResult response => RewriteImport(controller, response),
|
||||
MusicDailyMixResponse response => RewriteDaily(controller, response),
|
||||
MusicRadioResponse response => RewriteRadio(controller, response),
|
||||
MusicRecentlyPlayedItem item => RewriteHistory(controller, item),
|
||||
MusicStatsTopTrack item => RewriteStatsTrack(controller, item),
|
||||
MusicImage image => RewriteImage(controller, image),
|
||||
List<MusicTrack> tracks => RewriteList(controller, tracks),
|
||||
List<MusicUserPlaylistSummary> playlists => RewriteList(controller, playlists),
|
||||
MusicTrack[] tracks => RewriteArray(controller, tracks),
|
||||
MusicUserPlaylistSummary[] playlists => RewriteArray(controller, playlists),
|
||||
_ => value
|
||||
};
|
||||
}
|
||||
|
||||
static List<T> RewriteList<T>(BaseController controller, List<T> items) where T : class
|
||||
{
|
||||
if (items == null)
|
||||
return null;
|
||||
|
||||
List<T> copy = null;
|
||||
for (int i = 0; i < items.Count; i++)
|
||||
{
|
||||
var item = (T)Rewrite(controller, items[i]);
|
||||
if (ReferenceEquals(item, items[i]))
|
||||
continue;
|
||||
|
||||
copy ??= new List<T>(items);
|
||||
copy[i] = item;
|
||||
}
|
||||
|
||||
return copy ?? items;
|
||||
}
|
||||
|
||||
static T[] RewriteArray<T>(BaseController controller, T[] items) where T : class
|
||||
{
|
||||
T[] copy = null;
|
||||
for (int i = 0; i < items.Length; i++)
|
||||
{
|
||||
var item = (T)Rewrite(controller, items[i]);
|
||||
if (ReferenceEquals(item, items[i]))
|
||||
continue;
|
||||
|
||||
copy ??= (T[])items.Clone();
|
||||
copy[i] = item;
|
||||
}
|
||||
|
||||
return copy ?? items;
|
||||
}
|
||||
|
||||
static MusicSearchResponse RewriteSearch(BaseController controller, MusicSearchResponse value)
|
||||
{
|
||||
var artists = RewriteList(controller, value.artists);
|
||||
var albums = RewriteList(controller, value.albums);
|
||||
var tracks = RewriteList(controller, value.tracks);
|
||||
var search_sections = RewriteList(controller, value.search_sections);
|
||||
|
||||
if (ReferenceEquals(artists, value.artists)
|
||||
&& ReferenceEquals(albums, value.albums)
|
||||
&& ReferenceEquals(tracks, value.tracks)
|
||||
&& ReferenceEquals(search_sections, value.search_sections))
|
||||
return value;
|
||||
|
||||
var copy = Copy(value);
|
||||
copy.artists = artists;
|
||||
copy.albums = albums;
|
||||
copy.tracks = tracks;
|
||||
copy.search_sections = search_sections;
|
||||
return copy;
|
||||
}
|
||||
|
||||
static MusicHomeResponse RewriteHome(BaseController controller, MusicHomeResponse value)
|
||||
{
|
||||
var browse_sections = RewriteList(controller, value.browse_sections);
|
||||
var recently_played = RewriteList(controller, value.recently_played);
|
||||
var user_playlists = RewriteList(controller, value.user_playlists);
|
||||
|
||||
if (ReferenceEquals(browse_sections, value.browse_sections)
|
||||
&& ReferenceEquals(recently_played, value.recently_played)
|
||||
&& ReferenceEquals(user_playlists, value.user_playlists))
|
||||
return value;
|
||||
|
||||
var copy = Copy(value);
|
||||
copy.browse_sections = browse_sections;
|
||||
copy.recently_played = recently_played;
|
||||
copy.user_playlists = user_playlists;
|
||||
return copy;
|
||||
}
|
||||
|
||||
static MusicStatsTopResult RewriteStats(BaseController controller, MusicStatsTopResult value)
|
||||
{
|
||||
var tracks = RewriteList(controller, value.tracks);
|
||||
|
||||
if (ReferenceEquals(tracks, value.tracks))
|
||||
return value;
|
||||
|
||||
var copy = Copy(value);
|
||||
copy.tracks = tracks;
|
||||
return copy;
|
||||
}
|
||||
|
||||
static MusicArtist RewriteArtist(BaseController controller, MusicArtist value)
|
||||
{
|
||||
var images = RewriteList(controller, value.images);
|
||||
var albums = RewriteList(controller, value.albums);
|
||||
var sections = RewriteList(controller, value.sections);
|
||||
|
||||
if (ReferenceEquals(images, value.images)
|
||||
&& ReferenceEquals(albums, value.albums)
|
||||
&& ReferenceEquals(sections, value.sections))
|
||||
return value;
|
||||
|
||||
var copy = Copy(value);
|
||||
copy.images = images;
|
||||
copy.albums = albums;
|
||||
copy.sections = sections;
|
||||
return copy;
|
||||
}
|
||||
|
||||
static MusicAlbum RewriteAlbum(BaseController controller, MusicAlbum value)
|
||||
{
|
||||
var images = RewriteList(controller, value.images);
|
||||
var tracks = RewriteList(controller, value.tracks);
|
||||
|
||||
if (ReferenceEquals(images, value.images)
|
||||
&& ReferenceEquals(tracks, value.tracks))
|
||||
return value;
|
||||
|
||||
var copy = Copy(value);
|
||||
copy.images = images;
|
||||
copy.tracks = tracks;
|
||||
return copy;
|
||||
}
|
||||
|
||||
static MusicTrack RewriteTrack(BaseController controller, MusicTrack value)
|
||||
{
|
||||
var images = RewriteList(controller, value.images);
|
||||
|
||||
if (ReferenceEquals(images, value.images))
|
||||
return value;
|
||||
|
||||
var copy = Copy(value);
|
||||
copy.images = images;
|
||||
return copy;
|
||||
}
|
||||
|
||||
static MusicBrowseSection RewriteSection(BaseController controller, MusicBrowseSection value)
|
||||
{
|
||||
var albums = RewriteList(controller, value.albums);
|
||||
var artists = RewriteList(controller, value.artists);
|
||||
var tracks = RewriteList(controller, value.tracks);
|
||||
|
||||
if (ReferenceEquals(albums, value.albums)
|
||||
&& ReferenceEquals(artists, value.artists)
|
||||
&& ReferenceEquals(tracks, value.tracks))
|
||||
return value;
|
||||
|
||||
var copy = Copy(value);
|
||||
copy.albums = albums;
|
||||
copy.artists = artists;
|
||||
copy.tracks = tracks;
|
||||
return copy;
|
||||
}
|
||||
|
||||
static MusicUserPlaylistSummary RewritePlaylist(BaseController controller, MusicUserPlaylistSummary value)
|
||||
{
|
||||
var images = RewriteList(controller, value.images);
|
||||
|
||||
if (ReferenceEquals(images, value.images))
|
||||
return value;
|
||||
|
||||
var copy = Copy(value);
|
||||
copy.images = images;
|
||||
return copy;
|
||||
}
|
||||
|
||||
static MusicUserPlaylistImportResult RewriteImport(BaseController controller, MusicUserPlaylistImportResult value)
|
||||
{
|
||||
var tracks = RewriteList(controller, value.tracks);
|
||||
|
||||
if (ReferenceEquals(tracks, value.tracks))
|
||||
return value;
|
||||
|
||||
var copy = Copy(value);
|
||||
copy.tracks = tracks;
|
||||
return copy;
|
||||
}
|
||||
|
||||
static MusicDailyMixResponse RewriteDaily(BaseController controller, MusicDailyMixResponse value)
|
||||
{
|
||||
var tracks = RewriteList(controller, value.tracks);
|
||||
|
||||
if (ReferenceEquals(tracks, value.tracks))
|
||||
return value;
|
||||
|
||||
var copy = Copy(value);
|
||||
copy.tracks = tracks;
|
||||
return copy;
|
||||
}
|
||||
|
||||
static MusicRadioResponse RewriteRadio(BaseController controller, MusicRadioResponse value)
|
||||
{
|
||||
var tracks = RewriteList(controller, value.tracks);
|
||||
|
||||
if (ReferenceEquals(tracks, value.tracks))
|
||||
return value;
|
||||
|
||||
var copy = Copy(value);
|
||||
copy.tracks = tracks;
|
||||
return copy;
|
||||
}
|
||||
|
||||
static MusicRecentlyPlayedItem RewriteHistory(BaseController controller, MusicRecentlyPlayedItem value)
|
||||
{
|
||||
var track = (MusicTrack)Rewrite(controller, value.track);
|
||||
|
||||
if (ReferenceEquals(track, value.track))
|
||||
return value;
|
||||
|
||||
var copy = Copy(value);
|
||||
copy.track = track;
|
||||
return copy;
|
||||
}
|
||||
|
||||
static MusicStatsTopTrack RewriteStatsTrack(BaseController controller, MusicStatsTopTrack value)
|
||||
{
|
||||
var track = (MusicTrack)Rewrite(controller, value.track);
|
||||
|
||||
if (ReferenceEquals(track, value.track))
|
||||
return value;
|
||||
|
||||
var copy = Copy(value);
|
||||
copy.track = track;
|
||||
return copy;
|
||||
}
|
||||
|
||||
static MusicImage RewriteImage(BaseController controller, MusicImage image)
|
||||
{
|
||||
string url = image.url;
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return image;
|
||||
|
||||
// Normalize even when proxying is disabled (e.g. file:// TV clients).
|
||||
if (url.StartsWith("//", StringComparison.Ordinal))
|
||||
url = "https:" + url;
|
||||
|
||||
// Repair legacy cached proxy URLs without modifying the cached image.
|
||||
if (TryRewriteProxyHost(url, controller.host, out string rewritten))
|
||||
url = rewritten;
|
||||
else if (NeedProxy(url, controller.host))
|
||||
url = controller.HostImgProxy(init, url);
|
||||
|
||||
if (string.Equals(url, image.url, StringComparison.Ordinal))
|
||||
return image;
|
||||
|
||||
var copy = Copy(image);
|
||||
copy.url = url;
|
||||
return copy;
|
||||
}
|
||||
|
||||
static bool TryRewriteProxyHost(string url, string host, out string rewritten)
|
||||
{
|
||||
rewritten = null;
|
||||
|
||||
int index = url.IndexOf("/proxyimg", StringComparison.OrdinalIgnoreCase);
|
||||
if (index <= 0 || string.IsNullOrWhiteSpace(host))
|
||||
return false;
|
||||
|
||||
string prefix = url.Substring(0, index);
|
||||
if (string.Equals(prefix, host, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
// префикс должен быть полноценным origin (а не куском чужого пути)
|
||||
if (!Uri.TryCreate(prefix, UriKind.Absolute, out var prefixUri) ||
|
||||
!prefixUri.Scheme.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
rewritten = host + url.Substring(index);
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool NeedProxy(string url, string host)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return false;
|
||||
|
||||
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase) ||
|
||||
url.StartsWith("blob:", StringComparison.OrdinalIgnoreCase) ||
|
||||
url.StartsWith("file:", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
return false;
|
||||
|
||||
if (!uri.Scheme.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(host) &&
|
||||
Uri.TryCreate(host, UriKind.Absolute, out var hostUri) &&
|
||||
string.Equals(uri.Host, hostUri.Host, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
return !url.Contains("/proxyimg", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
using Shared.Services.Hybrid;
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Music;
|
||||
|
||||
// текст песен через lrclib.net (публичный API без ключей, как в Spotube);
|
||||
// лирика — volatile metadata-обогащение, кэш в HybridFileCache
|
||||
public static class MusicLyricsService
|
||||
{
|
||||
const string CacheKeyPrefix = "music:lyrics:v12";
|
||||
const string YouTubeNoSyncedCacheKeyPrefix = "music:lyrics:yt-nosynced:v1";
|
||||
static readonly TimeSpan SyncedTtl = TimeSpan.FromDays(30);
|
||||
static readonly TimeSpan ShortTtl = TimeSpan.FromHours(1);
|
||||
static readonly TimeSpan YouTubeNoSyncedTtl = TimeSpan.FromHours(6);
|
||||
static readonly HttpClient httpClient = CreateClient();
|
||||
|
||||
static HttpClient CreateClient()
|
||||
{
|
||||
var client = MusicHttp.CreateClient("lyrics");
|
||||
client.Timeout = TimeSpan.FromSeconds(12);
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "LampacNextgenMusic/0.1 (https://github.com/lampac-nextgen/lampac)");
|
||||
return client;
|
||||
}
|
||||
|
||||
public static async Task<MusicLyricsResponse> GetAsync(string title, string artistName, string albumTitle, int? durationMs, string youtubeId = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
title = title?.Trim();
|
||||
artistName = artistName?.Trim();
|
||||
youtubeId = NormalizeYouTubeId(youtubeId);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artistName))
|
||||
return new MusicLyricsResponse { available = false, message = "Track metadata is empty." };
|
||||
|
||||
string cacheKey = BuildCacheKey(title, artistName, durationMs, youtubeId);
|
||||
var cached = await HybridCache.Get().ReadCacheAsync<MusicLyricsResponse>(cacheKey, false, null, textJson: true);
|
||||
if (cached.succes && cached.value != null)
|
||||
return cached.value;
|
||||
|
||||
MusicLyricsResponse result;
|
||||
|
||||
try
|
||||
{
|
||||
result = await FetchAsync(title, artistName, albumTitle, durationMs, youtubeId, cancellationToken)
|
||||
?? new MusicLyricsResponse { available = false, message = "Lyrics not found." };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Music] lyrics fetch failed: {ex.Message}");
|
||||
// сетевые сбои не кэшируем — следующий запрос попробует снова
|
||||
return new MusicLyricsResponse { available = false, retry = true, message = "Lyrics provider unavailable." };
|
||||
}
|
||||
|
||||
// Долгий кэш заслуживает только synced. Plain-only мог появиться из-за
|
||||
// временного сбоя YouTube Music fallback, поэтому partial/retry не кэшируем.
|
||||
if (!result.retry)
|
||||
HybridCache.Get().Set(cacheKey, result, result.available && result.synced ? SyncedTtl : ShortTtl, textJson: true);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static async Task<MusicLyricsResponse> FetchAsync(string title, string artistName, string albumTitle, int? durationMs, string youtubeId, CancellationToken cancellationToken)
|
||||
{
|
||||
MusicLyricsResponse lrclib = null;
|
||||
bool lrclibFailed = false;
|
||||
|
||||
try
|
||||
{
|
||||
lrclib = await FetchLrcLibAsync(title, artistName, albumTitle, durationMs, cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
lrclibFailed = true;
|
||||
}
|
||||
|
||||
if (lrclib != null && lrclib.synced)
|
||||
return lrclib;
|
||||
|
||||
MusicLyricsResponse youtubeMusic = null;
|
||||
bool youtubeMusicFailed = false;
|
||||
bool probeSyncedOnly = lrclib != null;
|
||||
string youTubeNoSyncedKey = probeSyncedOnly ? BuildYouTubeNoSyncedCacheKey(title, artistName, durationMs, youtubeId) : null;
|
||||
|
||||
if (probeSyncedOnly)
|
||||
{
|
||||
var noSyncedCached = await HybridCache.Get().ReadCacheAsync<bool>(youTubeNoSyncedKey, false, null, textJson: true);
|
||||
if (noSyncedCached.succes && noSyncedCached.value)
|
||||
return lrclib;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
youtubeMusic = await YouTubeMusicLyricsFallback.GetAsync(title, artistName, durationMs, youtubeId, probeSyncedOnly, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
youtubeMusicFailed = true;
|
||||
Console.WriteLine($"[Music] youtube music lyrics fallback failed: {ex.Message}");
|
||||
}
|
||||
|
||||
if (youtubeMusic != null && youtubeMusic.synced)
|
||||
return youtubeMusic;
|
||||
|
||||
if (probeSyncedOnly && !youtubeMusicFailed)
|
||||
HybridCache.Get().Set(youTubeNoSyncedKey, true, YouTubeNoSyncedTtl, textJson: true);
|
||||
|
||||
if (lrclib != null)
|
||||
{
|
||||
if (youtubeMusicFailed)
|
||||
lrclib.retry = true;
|
||||
|
||||
return lrclib;
|
||||
}
|
||||
|
||||
if (youtubeMusic != null)
|
||||
return youtubeMusic;
|
||||
|
||||
// Если хоть один провайдер не успел/упал, не кэшируем «нет текста»:
|
||||
// следующий запрос может попасть в живой источник.
|
||||
if (lrclibFailed || youtubeMusicFailed)
|
||||
throw new TimeoutException("lyrics provider timed out");
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static async Task<MusicLyricsResponse> FetchLrcLibAsync(string title, string artistName, string albumTitle, int? durationMs, CancellationToken cancellationToken)
|
||||
{
|
||||
string getUrl = "https://lrclib.net/api/get?track_name=" + Uri.EscapeDataString(title)
|
||||
+ "&artist_name=" + Uri.EscapeDataString(artistName)
|
||||
+ (string.IsNullOrWhiteSpace(albumTitle) ? "" : "&album_name=" + Uri.EscapeDataString(albumTitle))
|
||||
+ (durationMs > 0 ? "&duration=" + (durationMs.Value / 1000) : "");
|
||||
|
||||
string searchUrl = "https://lrclib.net/api/search?track_name=" + Uri.EscapeDataString(title)
|
||||
+ "&artist_name=" + Uri.EscapeDataString(artistName);
|
||||
|
||||
// lrclib бывает медленным: оба запроса параллельно с общим бюджетом,
|
||||
// чтобы ответ гарантированно уложился в терпение клиента
|
||||
using var budget = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
budget.CancelAfter(TimeSpan.FromSeconds(9));
|
||||
|
||||
var getTask = RequestJsonAsync(getUrl, budget.Token);
|
||||
var searchTask = RequestJsonAsync(searchUrl, budget.Token);
|
||||
|
||||
JsonNode getNode = null, searchNode = null;
|
||||
bool getFailed = false, searchFailed = false;
|
||||
|
||||
try { getNode = await getTask; } catch { getFailed = true; }
|
||||
try { searchNode = await searchTask; } catch { searchFailed = true; }
|
||||
|
||||
var exact = BuildResponse(getNode as JsonObject);
|
||||
if (exact != null && exact.synced)
|
||||
return exact;
|
||||
|
||||
// приоритет: synced-точный → synced-из-поиска → plain-точный → plain-из-поиска
|
||||
MusicLyricsResponse searched = null;
|
||||
|
||||
if (searchNode is JsonArray items && items.Count > 0)
|
||||
{
|
||||
int targetSeconds = durationMs > 0 ? durationMs.Value / 1000 : 0;
|
||||
JsonObject best = null;
|
||||
JsonObject bestPlain = null;
|
||||
|
||||
foreach (var item in items.OfType<JsonObject>())
|
||||
{
|
||||
if (targetSeconds > 0)
|
||||
{
|
||||
double itemDuration = item["duration"]?.GetValue<double?>() ?? 0;
|
||||
if (itemDuration > 0 && Math.Abs(itemDuration - targetSeconds) > 10)
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(item["syncedLyrics"]?.GetValue<string>()))
|
||||
{
|
||||
best = item;
|
||||
break;
|
||||
}
|
||||
|
||||
bestPlain ??= string.IsNullOrWhiteSpace(item["plainLyrics"]?.GetValue<string>()) ? null : item;
|
||||
}
|
||||
|
||||
searched = BuildResponse(best ?? bestPlain);
|
||||
}
|
||||
|
||||
if (searched != null && searched.synced)
|
||||
return searched;
|
||||
|
||||
var result = exact ?? searched;
|
||||
|
||||
// ничего не нашли, но часть запросов упала по таймауту — это не «нет
|
||||
// текста», а сбой: бросаем, чтобы результат НЕ закэшировался
|
||||
if (result == null && (getFailed || searchFailed))
|
||||
throw new TimeoutException("lyrics provider timed out");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static async Task<JsonNode> RequestJsonAsync(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
using var response = await httpClient.GetAsync(url, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
string json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
return string.IsNullOrWhiteSpace(json) ? null : JsonNode.Parse(json);
|
||||
}
|
||||
|
||||
static MusicLyricsResponse BuildResponse(JsonObject item)
|
||||
{
|
||||
if (item == null)
|
||||
return null;
|
||||
|
||||
string syncedRaw = item["syncedLyrics"]?.GetValue<string>();
|
||||
string plain = item["plainLyrics"]?.GetValue<string>();
|
||||
|
||||
var lines = ParseSyncedLyrics(syncedRaw);
|
||||
|
||||
if (lines.Count == 0 && string.IsNullOrWhiteSpace(plain))
|
||||
return null;
|
||||
|
||||
return new MusicLyricsResponse
|
||||
{
|
||||
available = true,
|
||||
message = "ok",
|
||||
source = "lrclib",
|
||||
source_mode = lines.Count > 0 ? "synced" : "plain",
|
||||
synced = lines.Count > 0,
|
||||
lines = lines,
|
||||
plain = plain
|
||||
};
|
||||
}
|
||||
|
||||
static List<MusicLyricsLine> ParseSyncedLyrics(string lrc)
|
||||
{
|
||||
var lines = new List<MusicLyricsLine>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(lrc))
|
||||
return lines;
|
||||
|
||||
foreach (var raw in lrc.Split('\n'))
|
||||
{
|
||||
var match = Regex.Match(raw, @"^\s*\[(\d+):(\d+(?:\.\d+)?)\]\s*(.*)$");
|
||||
if (!match.Success)
|
||||
continue;
|
||||
|
||||
double minutes = double.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
|
||||
double seconds = double.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture);
|
||||
|
||||
lines.Add(new MusicLyricsLine
|
||||
{
|
||||
time_ms = (int)Math.Round((minutes * 60 + seconds) * 1000),
|
||||
text = match.Groups[3].Value.Trim()
|
||||
});
|
||||
}
|
||||
|
||||
return lines.OrderBy(i => i.time_ms).ToList();
|
||||
}
|
||||
|
||||
static string BuildCacheKey(string title, string artistName, int? durationMs, string youtubeId)
|
||||
{
|
||||
// длительность — в корзинах по 10s, чтобы близкие рипы попадали в один кэш
|
||||
int bucket = durationMs > 0 ? durationMs.Value / 10000 : 0;
|
||||
return string.Join(':', CacheKeyPrefix, artistName.ToLowerInvariant(), title.ToLowerInvariant(), bucket.ToString(), youtubeId ?? "auto");
|
||||
}
|
||||
|
||||
static string BuildYouTubeNoSyncedCacheKey(string title, string artistName, int? durationMs, string youtubeId)
|
||||
{
|
||||
int bucket = durationMs > 0 ? durationMs.Value / 10000 : 0;
|
||||
return string.Join(':', YouTubeNoSyncedCacheKeyPrefix, artistName.ToLowerInvariant(), title.ToLowerInvariant(), bucket.ToString(), youtubeId ?? "auto");
|
||||
}
|
||||
|
||||
static string NormalizeYouTubeId(string youtubeId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(youtubeId))
|
||||
return null;
|
||||
|
||||
youtubeId = youtubeId.Trim();
|
||||
if (youtubeId.StartsWith("youtube:", StringComparison.OrdinalIgnoreCase))
|
||||
youtubeId = youtubeId["youtube:".Length..].Trim();
|
||||
|
||||
return Regex.IsMatch(youtubeId, @"^[A-Za-z0-9_-]{6,32}$") ? youtubeId : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,773 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Music;
|
||||
|
||||
internal static class YouTubeMusicLyricsFallback
|
||||
{
|
||||
const string HomeUrl = "https://music.youtube.com/";
|
||||
const string InnertubeBaseUrl = "https://music.youtube.com/youtubei/v1/";
|
||||
const string SongsSearchParams = "EgWKAQIIAWoMEA4QChADEAQQCRAF";
|
||||
const int MaxCandidates = 4;
|
||||
|
||||
static readonly HttpClient httpClient = CreateClient();
|
||||
static readonly SemaphoreSlim configLock = new(1, 1);
|
||||
static YouTubeMusicClientConfig cachedConfig;
|
||||
static DateTimeOffset configExpiresAt;
|
||||
|
||||
static HttpClient CreateClient()
|
||||
{
|
||||
var client = MusicHttp.CreateClient("youtubemusic");
|
||||
client.Timeout = TimeSpan.FromSeconds(12);
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("Origin", HomeUrl.TrimEnd('/'));
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("Referer", HomeUrl);
|
||||
return client;
|
||||
}
|
||||
|
||||
public static async Task<MusicLyricsResponse> GetAsync(string title, string artistName, int? durationMs, string youtubeId = null, bool syncedOnly = false, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!YouTubeMusicSearchSupport.IsSearchEnabled || string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artistName))
|
||||
return null;
|
||||
|
||||
using var budget = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
budget.CancelAfter(TimeSpan.FromSeconds(12));
|
||||
|
||||
var config = await GetConfigAsync(budget.Token);
|
||||
|
||||
youtubeId = NormalizeVideoId(youtubeId);
|
||||
MusicLyricsResponse plainFallback = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(youtubeId))
|
||||
{
|
||||
// Прямой YouTube id может прийти с грязной карточки, поэтому доверяем
|
||||
// ему только если он отдаёт настоящие тайминги. Plain ищем через
|
||||
// канонический поиск по title/artist ниже.
|
||||
var directLyrics = await FetchLyricsAsync(config, youtubeId, durationMs, budget.Token, syncedOnly: true);
|
||||
if (directLyrics != null && directLyrics.synced)
|
||||
return directLyrics;
|
||||
}
|
||||
|
||||
var candidates = await SearchSongCandidatesAsync(config, title, artistName, durationMs, budget.Token);
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
var lyrics = await FetchLyricsAsync(config, candidate.VideoId, durationMs, budget.Token, syncedOnly);
|
||||
if (lyrics != null && lyrics.synced)
|
||||
return lyrics;
|
||||
|
||||
if (!syncedOnly)
|
||||
plainFallback ??= lyrics;
|
||||
}
|
||||
|
||||
return plainFallback;
|
||||
}
|
||||
|
||||
static MusicLyricsResponse BuildPlainResponse(string plain)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(plain))
|
||||
return null;
|
||||
|
||||
return new MusicLyricsResponse
|
||||
{
|
||||
available = true,
|
||||
message = "ok",
|
||||
source = "youtube_music",
|
||||
source_mode = "plain",
|
||||
synced = false,
|
||||
plain = plain.Trim()
|
||||
};
|
||||
}
|
||||
|
||||
static string NormalizeVideoId(string videoId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(videoId))
|
||||
return null;
|
||||
|
||||
videoId = videoId.Trim();
|
||||
if (videoId.StartsWith("youtube:", StringComparison.OrdinalIgnoreCase))
|
||||
videoId = videoId["youtube:".Length..].Trim();
|
||||
|
||||
return Regex.IsMatch(videoId, @"^[A-Za-z0-9_-]{6,32}$") ? videoId : null;
|
||||
}
|
||||
|
||||
static async Task<YouTubeMusicClientConfig> GetConfigAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (cachedConfig != null && DateTimeOffset.UtcNow < configExpiresAt)
|
||||
return cachedConfig;
|
||||
|
||||
await configLock.WaitAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
if (cachedConfig != null && DateTimeOffset.UtcNow < configExpiresAt)
|
||||
return cachedConfig;
|
||||
|
||||
string html = await httpClient.GetStringAsync(HomeUrl, cancellationToken);
|
||||
string apiKey = MatchConfigValue(html, "INNERTUBE_API_KEY");
|
||||
string clientVersion = MatchConfigValue(html, "INNERTUBE_CLIENT_VERSION");
|
||||
string visitorData = MatchConfigValue(html, "VISITOR_DATA");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(apiKey) || string.IsNullOrWhiteSpace(clientVersion))
|
||||
throw new InvalidOperationException("YouTube Music InnerTube config not found.");
|
||||
|
||||
cachedConfig = new YouTubeMusicClientConfig
|
||||
{
|
||||
ApiKey = apiKey,
|
||||
ClientVersion = clientVersion,
|
||||
VisitorData = visitorData
|
||||
};
|
||||
configExpiresAt = DateTimeOffset.UtcNow.AddHours(6);
|
||||
|
||||
return cachedConfig;
|
||||
}
|
||||
finally
|
||||
{
|
||||
configLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
static string MatchConfigValue(string html, string key)
|
||||
{
|
||||
var match = Regex.Match(html ?? string.Empty, $"\"{Regex.Escape(key)}\"\\s*:\\s*\"([^\"]+)\"");
|
||||
return match.Success ? Regex.Unescape(match.Groups[1].Value) : null;
|
||||
}
|
||||
|
||||
static async Task<List<YouTubeMusicSongCandidate>> SearchSongCandidatesAsync(YouTubeMusicClientConfig config, string title, string artistName, int? durationMs, CancellationToken cancellationToken)
|
||||
{
|
||||
int targetMs = durationMs.GetValueOrDefault();
|
||||
var result = new List<YouTubeMusicSongCandidate>();
|
||||
var seen = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var query in BuildCandidateQueries(title, artistName))
|
||||
{
|
||||
var root = await PostAsync(config, "search", new JsonObject
|
||||
{
|
||||
["context"] = BuildContext(config),
|
||||
["query"] = query.Query,
|
||||
["params"] = SongsSearchParams
|
||||
}, cancellationToken);
|
||||
|
||||
var candidates = new List<YouTubeMusicSongCandidate>();
|
||||
CollectSongCandidates(root, candidates);
|
||||
|
||||
foreach (var candidate in candidates.Where(i => IsRelevantCandidate(i, query.Title, query.Artist, targetMs, query.RequireArtist)))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidate.VideoId) || !seen.Add(candidate.VideoId))
|
||||
continue;
|
||||
|
||||
result.Add(candidate);
|
||||
if (result.Count >= MaxCandidates)
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static async Task<MusicLyricsResponse> FetchLyricsAsync(YouTubeMusicClientConfig config, string videoId, int? durationMs, CancellationToken cancellationToken, bool syncedOnly = false)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(videoId))
|
||||
return null;
|
||||
|
||||
var next = await PostAsync(config, "next", new JsonObject
|
||||
{
|
||||
["context"] = BuildContext(config),
|
||||
["videoId"] = videoId,
|
||||
["playlistId"] = "RDAMVM" + videoId,
|
||||
["enablePersistentPlaylistPanel"] = true,
|
||||
["isAudioOnly"] = true,
|
||||
["tunerSettingValue"] = "AUTOMIX_SETTING_NORMAL",
|
||||
["watchEndpointMusicSupportedConfigs"] = new JsonObject
|
||||
{
|
||||
["watchEndpointMusicConfig"] = new JsonObject
|
||||
{
|
||||
["hasPersistentPlaylistPanel"] = true,
|
||||
["musicVideoType"] = "MUSIC_VIDEO_TYPE_ATV"
|
||||
}
|
||||
}
|
||||
}, cancellationToken);
|
||||
|
||||
string browseId = FindBrowseIdByPageType(next, "MUSIC_PAGE_TYPE_TRACK_LYRICS");
|
||||
if (string.IsNullOrWhiteSpace(browseId))
|
||||
return null;
|
||||
|
||||
var timedBrowse = await PostAsync(config, "browse", new JsonObject
|
||||
{
|
||||
["context"] = BuildContext(config, mobile: true),
|
||||
["browseId"] = browseId
|
||||
}, cancellationToken);
|
||||
|
||||
var timedLines = FindTimedLyricsLines(timedBrowse);
|
||||
if (timedLines.Count > 0)
|
||||
{
|
||||
return new MusicLyricsResponse
|
||||
{
|
||||
available = true,
|
||||
message = "ok",
|
||||
source = "youtube_music",
|
||||
source_mode = "synced",
|
||||
synced = true,
|
||||
lines = timedLines,
|
||||
plain = string.Join("\n", timedLines.Select(i => i.text).Where(i => !string.IsNullOrWhiteSpace(i)))
|
||||
};
|
||||
}
|
||||
|
||||
if (syncedOnly)
|
||||
return null;
|
||||
|
||||
string staticPlain = FindStaticLyricsText(timedBrowse);
|
||||
if (!string.IsNullOrWhiteSpace(staticPlain))
|
||||
return BuildPlainResponse(staticPlain);
|
||||
|
||||
string mobilePlain = FindDescriptionShelfText(timedBrowse);
|
||||
if (!string.IsNullOrWhiteSpace(mobilePlain) && !mobilePlain.Contains("Lyrics not available", StringComparison.OrdinalIgnoreCase))
|
||||
return BuildPlainResponse(mobilePlain);
|
||||
|
||||
var browse = await PostAsync(config, "browse", new JsonObject
|
||||
{
|
||||
["context"] = BuildContext(config),
|
||||
["browseId"] = browseId
|
||||
}, cancellationToken);
|
||||
|
||||
string lyrics = FindDescriptionShelfText(browse);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(lyrics) || lyrics.Contains("Lyrics not available", StringComparison.OrdinalIgnoreCase))
|
||||
return null;
|
||||
|
||||
return BuildPlainResponse(lyrics);
|
||||
}
|
||||
|
||||
static async Task<JsonNode> PostAsync(YouTubeMusicClientConfig config, string endpoint, JsonObject body, CancellationToken cancellationToken)
|
||||
{
|
||||
string url = InnertubeBaseUrl + endpoint + "?key=" + Uri.EscapeDataString(config.ApiKey);
|
||||
|
||||
using var content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json");
|
||||
using var response = await httpClient.PostAsync(url, content, cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
if ((int)response.StatusCode == 401 || (int)response.StatusCode == 403)
|
||||
InvalidateConfig();
|
||||
|
||||
throw new HttpRequestException($"YouTube Music {endpoint} returned {(int)response.StatusCode}");
|
||||
}
|
||||
|
||||
string json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
return string.IsNullOrWhiteSpace(json) ? null : JsonNode.Parse(json);
|
||||
}
|
||||
|
||||
static void InvalidateConfig()
|
||||
{
|
||||
cachedConfig = null;
|
||||
configExpiresAt = DateTimeOffset.MinValue;
|
||||
}
|
||||
|
||||
static JsonObject BuildContext(YouTubeMusicClientConfig config, bool mobile = false)
|
||||
{
|
||||
var client = new JsonObject
|
||||
{
|
||||
["clientName"] = mobile ? "ANDROID_MUSIC" : "WEB_REMIX",
|
||||
["clientVersion"] = mobile ? "7.21.50" : config.ClientVersion,
|
||||
["hl"] = "ru",
|
||||
["gl"] = "US"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(config.VisitorData))
|
||||
client["visitorData"] = config.VisitorData;
|
||||
|
||||
return new JsonObject
|
||||
{
|
||||
["client"] = client
|
||||
};
|
||||
}
|
||||
|
||||
static void CollectSongCandidates(JsonNode node, List<YouTubeMusicSongCandidate> candidates)
|
||||
{
|
||||
if (node is JsonObject obj)
|
||||
{
|
||||
if (obj["musicResponsiveListItemRenderer"] is JsonObject renderer)
|
||||
{
|
||||
string videoId = FindWatchVideoId(renderer);
|
||||
if (!string.IsNullOrWhiteSpace(videoId))
|
||||
{
|
||||
var texts = ExtractFlexTexts(renderer);
|
||||
string title = texts.FirstOrDefault();
|
||||
string joined = string.Join(" ", texts.Where(i => !string.IsNullOrWhiteSpace(i)));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
candidates.Add(new YouTubeMusicSongCandidate
|
||||
{
|
||||
VideoId = videoId,
|
||||
Title = title,
|
||||
Description = joined,
|
||||
DurationMs = ParseDurationMs(joined)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var child in obj)
|
||||
CollectSongCandidates(child.Value, candidates);
|
||||
}
|
||||
else if (node is JsonArray array)
|
||||
{
|
||||
foreach (var child in array)
|
||||
CollectSongCandidates(child, candidates);
|
||||
}
|
||||
}
|
||||
|
||||
static string FindWatchVideoId(JsonNode node)
|
||||
{
|
||||
if (node is JsonObject obj)
|
||||
{
|
||||
if (obj["watchEndpoint"] is JsonObject endpoint)
|
||||
{
|
||||
string videoId = endpoint["videoId"]?.GetValue<string>();
|
||||
if (!string.IsNullOrWhiteSpace(videoId))
|
||||
return videoId;
|
||||
}
|
||||
|
||||
foreach (var child in obj)
|
||||
{
|
||||
string result = FindWatchVideoId(child.Value);
|
||||
if (!string.IsNullOrWhiteSpace(result))
|
||||
return result;
|
||||
}
|
||||
}
|
||||
else if (node is JsonArray array)
|
||||
{
|
||||
foreach (var child in array)
|
||||
{
|
||||
string result = FindWatchVideoId(child);
|
||||
if (!string.IsNullOrWhiteSpace(result))
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<string> ExtractFlexTexts(JsonObject renderer)
|
||||
{
|
||||
var result = new List<string>();
|
||||
|
||||
if (renderer["flexColumns"] is not JsonArray columns)
|
||||
return result;
|
||||
|
||||
foreach (var column in columns.OfType<JsonObject>())
|
||||
{
|
||||
var textNode = column["musicResponsiveListItemFlexColumnRenderer"]?["text"];
|
||||
string text = ExtractText(textNode);
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
result.Add(Regex.Replace(text.Trim(), @"\s+", " "));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static string ExtractText(JsonNode node)
|
||||
{
|
||||
if (node is not JsonObject obj)
|
||||
return null;
|
||||
|
||||
if (obj["simpleText"] != null)
|
||||
return obj["simpleText"]?.GetValue<string>();
|
||||
|
||||
if (obj["runs"] is JsonArray runs)
|
||||
return string.Concat(runs.OfType<JsonObject>().Select(i => i["text"]?.GetValue<string>() ?? string.Empty));
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static int? ParseDurationMs(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return null;
|
||||
|
||||
var matches = Regex.Matches(text, @"(?<!\d)(\d{1,2}):(\d{2})(?::(\d{2}))?(?!\d)");
|
||||
if (matches.Count == 0)
|
||||
return null;
|
||||
|
||||
var match = matches[^1];
|
||||
int first = int.Parse(match.Groups[1].Value, CultureInfo.InvariantCulture);
|
||||
int second = int.Parse(match.Groups[2].Value, CultureInfo.InvariantCulture);
|
||||
int third = match.Groups[3].Success ? int.Parse(match.Groups[3].Value, CultureInfo.InvariantCulture) : 0;
|
||||
|
||||
int seconds = match.Groups[3].Success
|
||||
? first * 3600 + second * 60 + third
|
||||
: first * 60 + second;
|
||||
|
||||
return seconds * 1000;
|
||||
}
|
||||
|
||||
static List<YouTubeMusicLyricsQuery> BuildCandidateQueries(string title, string artistName)
|
||||
{
|
||||
var result = new List<YouTubeMusicLyricsQuery>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
string cleanTitle = CleanSearchTitle(title);
|
||||
string artist = CleanSearchTitle(artistName);
|
||||
|
||||
void add(string query, string queryTitle, string queryArtist, bool requireArtist)
|
||||
{
|
||||
query = Regex.Replace(query ?? string.Empty, @"\s+", " ").Trim();
|
||||
queryTitle = Regex.Replace(queryTitle ?? string.Empty, @"\s+", " ").Trim();
|
||||
queryArtist = Regex.Replace(queryArtist ?? string.Empty, @"\s+", " ").Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query) || string.IsNullOrWhiteSpace(queryTitle))
|
||||
return;
|
||||
|
||||
string key = NormalizeText($"{query}|{queryTitle}|{queryArtist}|{requireArtist}");
|
||||
if (!seen.Add(key))
|
||||
return;
|
||||
|
||||
result.Add(new YouTubeMusicLyricsQuery
|
||||
{
|
||||
Query = query,
|
||||
Title = queryTitle,
|
||||
Artist = queryArtist,
|
||||
RequireArtist = requireArtist
|
||||
});
|
||||
}
|
||||
|
||||
add($"{artistName} {title}", title, artistName, requireArtist: true);
|
||||
|
||||
if (!string.Equals(cleanTitle, title, StringComparison.Ordinal))
|
||||
add($"{artist} {cleanTitle}", cleanTitle, artist, requireArtist: !string.IsNullOrWhiteSpace(artist));
|
||||
|
||||
var trailing = ExtractTrailingArtist(cleanTitle);
|
||||
if (!string.IsNullOrWhiteSpace(trailing.title) && !string.IsNullOrWhiteSpace(trailing.artist))
|
||||
add($"{trailing.artist} {trailing.title}", trailing.title, trailing.artist, requireArtist: true);
|
||||
|
||||
add(cleanTitle, cleanTitle, null, requireArtist: false);
|
||||
return result;
|
||||
}
|
||||
|
||||
static string CleanSearchTitle(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return string.Empty;
|
||||
|
||||
value = Regex.Replace(value, @"#\S+", " ");
|
||||
value = Regex.Replace(value, @"\b(official|audio|video|lyrics?|visualizer|remaster(?:ed)?|clip)\b", " ", RegexOptions.IgnoreCase);
|
||||
value = Regex.Replace(value, @"[\[\](){}]", " ");
|
||||
return Regex.Replace(value, @"\s+", " ").Trim();
|
||||
}
|
||||
|
||||
static (string title, string artist) ExtractTrailingArtist(string title)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
return (null, null);
|
||||
|
||||
var parts = title.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length < 2)
|
||||
return (null, null);
|
||||
|
||||
string last = parts[^1].Trim();
|
||||
string normalizedLast = NormalizeText(last);
|
||||
|
||||
if (normalizedLast.Length < 3)
|
||||
return (null, null);
|
||||
|
||||
if (!Regex.IsMatch(last, @"^\p{L}[\p{L}\p{Nd}._-]*$", RegexOptions.IgnoreCase))
|
||||
return (null, null);
|
||||
|
||||
if (Regex.IsMatch(normalizedLast, @"^(remix|mix|edit|audio|video|lyrics|music|official|cover|live)$", RegexOptions.IgnoreCase))
|
||||
return (null, null);
|
||||
|
||||
string baseTitle = string.Join(' ', parts.Take(parts.Length - 1)).Trim();
|
||||
return string.IsNullOrWhiteSpace(baseTitle) ? (null, null) : (baseTitle, last);
|
||||
}
|
||||
|
||||
static bool IsRelevantCandidate(YouTubeMusicSongCandidate candidate, string title, string artistName, int targetMs, bool requireArtist = true)
|
||||
{
|
||||
string wantedTitle = NormalizeTitle(title);
|
||||
string candidateTitle = NormalizeTitle(candidate.Title);
|
||||
string wantedArtist = NormalizeText(artistName);
|
||||
string candidateDescription = NormalizeText(candidate.Description);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(wantedTitle) || string.IsNullOrWhiteSpace(candidateTitle))
|
||||
return false;
|
||||
|
||||
bool titleMatches = candidateTitle == wantedTitle
|
||||
|| candidateTitle.Contains(wantedTitle, StringComparison.Ordinal)
|
||||
|| wantedTitle.Contains(candidateTitle, StringComparison.Ordinal)
|
||||
|| TitleTokenCoverage(wantedTitle, candidateTitle) >= 0.75;
|
||||
|
||||
if (!titleMatches)
|
||||
return false;
|
||||
|
||||
bool artistMatches = string.IsNullOrWhiteSpace(wantedArtist) || candidateDescription.Contains(wantedArtist, StringComparison.Ordinal);
|
||||
|
||||
if (!artistMatches)
|
||||
{
|
||||
var artistTokens = wantedArtist.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
artistMatches = artistTokens.Any(i => i.Length > 2 && candidateDescription.Contains(i, StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
if (requireArtist && !artistMatches)
|
||||
return false;
|
||||
|
||||
if (targetMs > 0 && candidate.DurationMs > 0 && Math.Abs(candidate.DurationMs.Value - targetMs) > 25000)
|
||||
return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
static string NormalizeTitle(string value)
|
||||
{
|
||||
value = Regex.Replace(value ?? string.Empty, @"\b(feat\.?|ft\.?|featuring)\b.*$", " ", RegexOptions.IgnoreCase);
|
||||
value = Regex.Replace(value, @"\((official|audio|video|lyrics?|visualizer|remaster(ed)?|clip)[^)]*\)", " ", RegexOptions.IgnoreCase);
|
||||
return NormalizeText(value);
|
||||
}
|
||||
|
||||
static double TitleTokenCoverage(string wantedTitle, string candidateTitle)
|
||||
{
|
||||
var wantedTokens = wantedTitle.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
if (wantedTokens.Length == 0)
|
||||
return 0;
|
||||
|
||||
var candidateTokens = candidateTitle.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
||||
int matched = wantedTokens.Count(wanted => candidateTokens.Any(candidate => TokensClose(wanted, candidate)));
|
||||
return matched / (double)wantedTokens.Length;
|
||||
}
|
||||
|
||||
static bool TokensClose(string left, string right)
|
||||
{
|
||||
if (left == right)
|
||||
return true;
|
||||
|
||||
int min = Math.Min(left.Length, right.Length);
|
||||
if (min < 4)
|
||||
return false;
|
||||
|
||||
return left.AsSpan(0, 4).SequenceEqual(right.AsSpan(0, 4));
|
||||
}
|
||||
|
||||
static string NormalizeText(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return string.Empty;
|
||||
|
||||
string decomposed = value.ToLowerInvariant().Normalize(NormalizationForm.FormD);
|
||||
var builder = new StringBuilder(decomposed.Length);
|
||||
|
||||
foreach (char c in decomposed)
|
||||
{
|
||||
var category = CharUnicodeInfo.GetUnicodeCategory(c);
|
||||
if (category != UnicodeCategory.NonSpacingMark)
|
||||
builder.Append(c);
|
||||
}
|
||||
|
||||
return Regex.Replace(builder.ToString().Normalize(NormalizationForm.FormC), @"[^\p{L}\p{Nd}]+", " ").Trim();
|
||||
}
|
||||
|
||||
static string FindBrowseIdByPageType(JsonNode node, string pageType)
|
||||
{
|
||||
if (node is JsonObject obj)
|
||||
{
|
||||
if (obj["browseEndpoint"] is JsonObject endpoint)
|
||||
{
|
||||
string browseId = endpoint["browseId"]?.GetValue<string>();
|
||||
if (!string.IsNullOrWhiteSpace(browseId) && ContainsPageType(endpoint, pageType))
|
||||
return browseId;
|
||||
}
|
||||
|
||||
foreach (var child in obj)
|
||||
{
|
||||
string result = FindBrowseIdByPageType(child.Value, pageType);
|
||||
if (!string.IsNullOrWhiteSpace(result))
|
||||
return result;
|
||||
}
|
||||
}
|
||||
else if (node is JsonArray array)
|
||||
{
|
||||
foreach (var child in array)
|
||||
{
|
||||
string result = FindBrowseIdByPageType(child, pageType);
|
||||
if (!string.IsNullOrWhiteSpace(result))
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool ContainsPageType(JsonNode node, string pageType)
|
||||
{
|
||||
if (node is JsonObject obj)
|
||||
{
|
||||
foreach (var item in obj)
|
||||
{
|
||||
if (item.Key == "pageType" && item.Value?.GetValue<string>() == pageType)
|
||||
return true;
|
||||
|
||||
if (ContainsPageType(item.Value, pageType))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
else if (node is JsonArray array)
|
||||
{
|
||||
foreach (var child in array)
|
||||
{
|
||||
if (ContainsPageType(child, pageType))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static string FindDescriptionShelfText(JsonNode node)
|
||||
{
|
||||
if (node is JsonObject obj)
|
||||
{
|
||||
if (obj["musicDescriptionShelfRenderer"] is JsonObject shelf)
|
||||
{
|
||||
string text = ExtractText(shelf["description"]);
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
return text;
|
||||
}
|
||||
|
||||
foreach (var child in obj)
|
||||
{
|
||||
string result = FindDescriptionShelfText(child.Value);
|
||||
if (!string.IsNullOrWhiteSpace(result))
|
||||
return result;
|
||||
}
|
||||
}
|
||||
else if (node is JsonArray array)
|
||||
{
|
||||
foreach (var child in array)
|
||||
{
|
||||
string result = FindDescriptionShelfText(child);
|
||||
if (!string.IsNullOrWhiteSpace(result))
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<MusicLyricsLine> FindTimedLyricsLines(JsonNode node)
|
||||
{
|
||||
if (node is JsonObject obj)
|
||||
{
|
||||
if (obj["timedLyricsData"] is JsonArray timed)
|
||||
{
|
||||
var lines = timed
|
||||
.OfType<JsonObject>()
|
||||
.Select(ParseTimedLyricsLine)
|
||||
.Where(i => i != null)
|
||||
.OrderBy(i => i.time_ms)
|
||||
.ToList();
|
||||
|
||||
if (lines.Count > 0)
|
||||
return lines;
|
||||
}
|
||||
|
||||
foreach (var child in obj)
|
||||
{
|
||||
var result = FindTimedLyricsLines(child.Value);
|
||||
if (result.Count > 0)
|
||||
return result;
|
||||
}
|
||||
}
|
||||
else if (node is JsonArray array)
|
||||
{
|
||||
foreach (var child in array)
|
||||
{
|
||||
var result = FindTimedLyricsLines(child);
|
||||
if (result.Count > 0)
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return new List<MusicLyricsLine>();
|
||||
}
|
||||
|
||||
static string FindStaticLyricsText(JsonNode node)
|
||||
{
|
||||
if (node is JsonObject obj)
|
||||
{
|
||||
if (obj["timedLyricsData"] is JsonArray timed)
|
||||
{
|
||||
var lines = timed
|
||||
.OfType<JsonObject>()
|
||||
.Select(i => i["lyricLine"]?.GetValue<string>()?.Trim())
|
||||
.Where(i => !string.IsNullOrWhiteSpace(i) && !string.Equals(i, "♪", StringComparison.Ordinal))
|
||||
.ToList();
|
||||
|
||||
if (lines.Count > 0)
|
||||
return string.Join("\n", lines);
|
||||
}
|
||||
|
||||
foreach (var child in obj)
|
||||
{
|
||||
string result = FindStaticLyricsText(child.Value);
|
||||
if (!string.IsNullOrWhiteSpace(result))
|
||||
return result;
|
||||
}
|
||||
}
|
||||
else if (node is JsonArray array)
|
||||
{
|
||||
foreach (var child in array)
|
||||
{
|
||||
string result = FindStaticLyricsText(child);
|
||||
if (!string.IsNullOrWhiteSpace(result))
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static MusicLyricsLine ParseTimedLyricsLine(JsonObject item)
|
||||
{
|
||||
string text = item["lyricLine"]?.GetValue<string>()?.Trim();
|
||||
string start = item["cueRange"]?["startTimeMilliseconds"]?.GetValue<string>();
|
||||
|
||||
if (!long.TryParse(start, NumberStyles.Integer, CultureInfo.InvariantCulture, out long timeMs))
|
||||
return null;
|
||||
|
||||
return new MusicLyricsLine
|
||||
{
|
||||
time_ms = (int)Math.Max(0, Math.Min(int.MaxValue, timeMs)),
|
||||
text = string.Equals(text, "♪", StringComparison.Ordinal) ? "" : text
|
||||
};
|
||||
}
|
||||
|
||||
class YouTubeMusicClientConfig
|
||||
{
|
||||
public string ApiKey { get; set; }
|
||||
public string ClientVersion { get; set; }
|
||||
public string VisitorData { get; set; }
|
||||
}
|
||||
|
||||
class YouTubeMusicSongCandidate
|
||||
{
|
||||
public string VideoId { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Description { get; set; }
|
||||
public int? DurationMs { get; set; }
|
||||
}
|
||||
|
||||
class YouTubeMusicLyricsQuery
|
||||
{
|
||||
public string Query { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Artist { get; set; }
|
||||
public bool RequireArtist { get; set; }
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Music;
|
||||
|
||||
public static class MusicPlaybackHistoryService
|
||||
{
|
||||
public static async Task SaveAsync(string profileId, MusicTrack track, CancellationToken cancellationToken = default)
|
||||
{
|
||||
profileId = NormalizeProfileId(profileId);
|
||||
|
||||
if (track == null || string.IsNullOrWhiteSpace(track.id) || string.IsNullOrWhiteSpace(profileId))
|
||||
return;
|
||||
|
||||
var semaphore = new SemaphorManager(MusicContext.semaphoreKey, TimeSpan.FromSeconds(20));
|
||||
|
||||
try
|
||||
{
|
||||
if (!await semaphore.WaitAsync())
|
||||
return;
|
||||
|
||||
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
INSERT INTO playback_history (profile_id, track_id, payload, updated)
|
||||
VALUES ($profile_id, $track_id, $payload, $updated)
|
||||
ON CONFLICT(profile_id, track_id) DO UPDATE SET
|
||||
payload = excluded.payload,
|
||||
updated = excluded.updated;
|
||||
""";
|
||||
|
||||
command.Parameters.AddWithValue("$profile_id", profileId);
|
||||
command.Parameters.AddWithValue("$track_id", track.id);
|
||||
command.Parameters.AddWithValue("$payload", MusicJson.Serialize(track));
|
||||
command.Parameters.AddWithValue("$updated", DateTime.UtcNow);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<List<MusicRecentlyPlayedItem>> GetRecentAsync(string profileId, int limit = 12, CancellationToken cancellationToken = default)
|
||||
{
|
||||
profileId = NormalizeProfileId(profileId);
|
||||
var items = new List<MusicRecentlyPlayedItem>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(profileId))
|
||||
return items;
|
||||
|
||||
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT payload, updated
|
||||
FROM playback_history
|
||||
WHERE profile_id = $profile_id
|
||||
ORDER BY updated DESC
|
||||
LIMIT $limit;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$profile_id", profileId);
|
||||
command.Parameters.AddWithValue("$limit", Math.Max(1, limit));
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
var payload = reader.IsDBNull(0) ? null : reader.GetString(0);
|
||||
if (string.IsNullOrWhiteSpace(payload))
|
||||
continue;
|
||||
|
||||
var track = MusicJson.Deserialize<MusicTrack>(payload);
|
||||
if (track == null)
|
||||
continue;
|
||||
|
||||
items.Add(new MusicRecentlyPlayedItem
|
||||
{
|
||||
track = track,
|
||||
played_at = reader.IsDBNull(1) ? DateTime.UtcNow : reader.GetDateTime(1)
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
public static async Task<MusicTrack> GetTrackAsync(string profileId, string trackId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
profileId = NormalizeProfileId(profileId);
|
||||
trackId = string.IsNullOrWhiteSpace(trackId) ? null : trackId.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(profileId) || string.IsNullOrWhiteSpace(trackId))
|
||||
return null;
|
||||
|
||||
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT payload
|
||||
FROM playback_history
|
||||
WHERE profile_id = $profile_id
|
||||
AND track_id = $track_id
|
||||
LIMIT 1;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$profile_id", profileId);
|
||||
command.Parameters.AddWithValue("$track_id", trackId);
|
||||
|
||||
var payload = await command.ExecuteScalarAsync(cancellationToken) as string;
|
||||
return string.IsNullOrWhiteSpace(payload) ? null : MusicJson.Deserialize<MusicTrack>(payload);
|
||||
}
|
||||
|
||||
public static async Task<Dictionary<string, MusicTrack>> GetTracksAsync(string profileId, IEnumerable<string> trackIds, CancellationToken cancellationToken = default)
|
||||
{
|
||||
profileId = NormalizeProfileId(profileId);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(profileId) || trackIds == null)
|
||||
return new Dictionary<string, MusicTrack>(StringComparer.Ordinal);
|
||||
|
||||
var normalizedIds = trackIds
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.Select(id => id.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (normalizedIds.Count == 0)
|
||||
return new Dictionary<string, MusicTrack>(StringComparer.Ordinal);
|
||||
|
||||
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
var placeholders = new List<string>(normalizedIds.Count);
|
||||
|
||||
for (int i = 0; i < normalizedIds.Count; i++)
|
||||
{
|
||||
string parameterName = $"$track_id_{i}";
|
||||
placeholders.Add(parameterName);
|
||||
command.Parameters.AddWithValue(parameterName, normalizedIds[i]);
|
||||
}
|
||||
|
||||
command.CommandText = $"""
|
||||
SELECT track_id, payload
|
||||
FROM playback_history
|
||||
WHERE profile_id = $profile_id
|
||||
AND track_id IN ({string.Join(", ", placeholders)});
|
||||
""";
|
||||
command.Parameters.AddWithValue("$profile_id", profileId);
|
||||
|
||||
var result = new Dictionary<string, MusicTrack>(normalizedIds.Count, StringComparer.Ordinal);
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
var trackId = reader.IsDBNull(0) ? null : reader.GetString(0);
|
||||
var payload = reader.IsDBNull(1) ? null : reader.GetString(1);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(trackId) || string.IsNullOrWhiteSpace(payload))
|
||||
continue;
|
||||
|
||||
var track = MusicJson.Deserialize<MusicTrack>(payload);
|
||||
if (track != null)
|
||||
result[trackId] = track;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static async Task<bool> RemoveAsync(string profileId, string trackId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
profileId = NormalizeProfileId(profileId);
|
||||
trackId = string.IsNullOrWhiteSpace(trackId) ? null : trackId.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(profileId) || string.IsNullOrWhiteSpace(trackId))
|
||||
return false;
|
||||
|
||||
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
DELETE FROM playback_history
|
||||
WHERE profile_id = $profile_id
|
||||
AND track_id = $track_id;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$profile_id", profileId);
|
||||
command.Parameters.AddWithValue("$track_id", trackId);
|
||||
|
||||
return await command.ExecuteNonQueryAsync(cancellationToken) > 0;
|
||||
}
|
||||
|
||||
static string NormalizeProfileId(string profileId)
|
||||
=> string.IsNullOrWhiteSpace(profileId) ? null : profileId.Trim().ToLowerInvariant();
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace Music;
|
||||
|
||||
public static class MusicPlaybackModeService
|
||||
{
|
||||
public const string Audio = "audio";
|
||||
public const string Video = "video";
|
||||
|
||||
public static string Normalize(string mode)
|
||||
{
|
||||
mode = mode?.Trim().ToLowerInvariant();
|
||||
|
||||
return mode switch
|
||||
{
|
||||
Video => Video,
|
||||
_ => Audio
|
||||
};
|
||||
}
|
||||
|
||||
public static bool IsVideo(string mode)
|
||||
=> Normalize(mode) == Video;
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Music;
|
||||
|
||||
public static class MusicPlaybackService
|
||||
{
|
||||
public static async Task<MusicTrack> ResolveRequestTrackAsync(string id, string provider, string title, string artistName, string albumId, string albumTitle, int? durationMs, string date, string isrc)
|
||||
{
|
||||
string normalizedIsrc = MusicIsrc.Normalize(isrc);
|
||||
MusicTrack track = null;
|
||||
|
||||
// Spotify search отдаёт точный track/album id, но без duration/isrc.
|
||||
// Достаём трек из его родного queryAlbum (кэшируется каталогом), вместо
|
||||
// заведомо бесполезного поиска spotify:track:* через MusicBrainz.
|
||||
if (!string.IsNullOrWhiteSpace(id)
|
||||
&& id.StartsWith("spotify:track:", StringComparison.OrdinalIgnoreCase)
|
||||
&& !durationMs.HasValue
|
||||
&& !string.IsNullOrWhiteSpace(albumId)
|
||||
&& albumId.StartsWith("spotify:album:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var albumTask = MusicCatalogService.GetAlbumAsync(albumId, SpotifySupport.ProviderId);
|
||||
if (await Task.WhenAny(albumTask, Task.Delay(2500)) == albumTask)
|
||||
{
|
||||
var album = await albumTask;
|
||||
track = album?.tracks?.FirstOrDefault(i => string.Equals(i?.id, id, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
track = await MusicCatalogService.GetTrackAsync(id, provider);
|
||||
}
|
||||
|
||||
if (track != null)
|
||||
{
|
||||
track = CopyTrack(track);
|
||||
track.isrc = MusicIsrc.Normalize(track.isrc) ?? normalizedIsrc;
|
||||
return track;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
return null;
|
||||
|
||||
return new MusicTrack
|
||||
{
|
||||
id = string.IsNullOrWhiteSpace(id) ? BuildInlineTrackId(title, artistName, albumTitle, durationMs, normalizedIsrc) : id,
|
||||
title = title,
|
||||
artist_name = artistName,
|
||||
album_id = albumId,
|
||||
album_title = albumTitle,
|
||||
isrc = normalizedIsrc,
|
||||
duration_ms = durationMs,
|
||||
date = date
|
||||
};
|
||||
}
|
||||
|
||||
static MusicTrack CopyTrack(MusicTrack track)
|
||||
{
|
||||
return new MusicTrack
|
||||
{
|
||||
id = track.id,
|
||||
title = track.title,
|
||||
artist_id = track.artist_id,
|
||||
artist_name = track.artist_name,
|
||||
artists = track.artists?.ToList() ?? new List<string>(),
|
||||
album_id = track.album_id,
|
||||
album_title = track.album_title,
|
||||
isrc = track.isrc,
|
||||
duration_ms = track.duration_ms,
|
||||
track_number = track.track_number,
|
||||
disc_number = track.disc_number,
|
||||
date = track.date,
|
||||
search_score = track.search_score,
|
||||
images = track.images?.ToList() ?? new List<MusicImage>(),
|
||||
provider_refs = track.provider_refs?.ToList() ?? new List<MusicProviderRef>(),
|
||||
auto_radio = track.auto_radio
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task<MusicPlayResponse> ResolveTrackAsync(MusicTrack track, string audioProvider, string streamMode, string playbackMode, string profileId, CancellationToken cancellationToken = default, bool refreshSources = false)
|
||||
{
|
||||
var result = await MusicResolver.ResolveTrackAsync(track, audioProvider, playbackMode, profileId, cancellationToken, refreshSources);
|
||||
if (result?.sources != null && result.sources.Count > 0)
|
||||
result.sources = MusicStreamModeService.Order(result.sources, streamMode);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static async Task<MusicPlayResponse> ResolvePreferredTrackAsync(MusicTrack track, string audioProvider, string streamMode, string playbackMode, string profileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var result = await MusicResolver.ResolvePreferredTrackAsync(track, audioProvider, playbackMode, profileId, cancellationToken);
|
||||
if (result?.sources != null && result.sources.Count > 0)
|
||||
result.sources = MusicStreamModeService.Order(result.sources, streamMode);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static void PrepareStreamSources(string host, MusicTrack track, string provider, string audioProvider, string streamMode, string playbackMode, MusicPlayResponse result)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(host) || track == null || result?.sources == null || result.sources.Count == 0)
|
||||
return;
|
||||
|
||||
string resolvedProvider = ResolveTrackProvider(track, provider);
|
||||
var fallbackParams = BuildStreamFallbackParams(track, resolvedProvider, audioProvider, streamMode, playbackMode, null, null);
|
||||
|
||||
foreach (var source in result.sources)
|
||||
{
|
||||
if (source == null || string.IsNullOrWhiteSpace(source.url))
|
||||
continue;
|
||||
|
||||
source.external_url = source.url;
|
||||
string ticket = MusicStreamTicketService.Create(source);
|
||||
if (!string.IsNullOrWhiteSpace(ticket))
|
||||
{
|
||||
var query = new List<string> { $"ticket={Uri.EscapeDataString(ticket)}" };
|
||||
query.AddRange(fallbackParams);
|
||||
if (!string.IsNullOrWhiteSpace(source.quality))
|
||||
query.Add($"quality={Uri.EscapeDataString(source.quality)}");
|
||||
|
||||
source.url = $"{host}/music/stream?{string.Join("&", query)}";
|
||||
}
|
||||
|
||||
source.headers = new Dictionary<string, string>();
|
||||
}
|
||||
}
|
||||
|
||||
public static MusicPlaybackSource SelectSource(List<MusicPlaybackSource> sources, string quality, string streamMode)
|
||||
{
|
||||
if (sources == null || sources.Count == 0)
|
||||
return null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(quality))
|
||||
{
|
||||
var exact = sources.FirstOrDefault(i => string.Equals(i.quality, quality, StringComparison.OrdinalIgnoreCase));
|
||||
if (exact != null)
|
||||
return exact;
|
||||
}
|
||||
|
||||
return MusicStreamModeService.Order(sources, streamMode).FirstOrDefault();
|
||||
}
|
||||
|
||||
public static string ResolveTrackProvider(MusicTrack track, string fallbackProvider)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(fallbackProvider))
|
||||
return fallbackProvider;
|
||||
|
||||
if (track?.provider_refs == null || track.provider_refs.Count == 0)
|
||||
return null;
|
||||
|
||||
foreach (var providerRef in track.provider_refs)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(providerRef?.provider))
|
||||
return providerRef.provider.Trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public static List<string> BuildStreamFallbackParams(MusicTrack track, string provider, string audioProvider, string streamMode, string playbackMode, string uid, string accountEmail)
|
||||
{
|
||||
var url = new List<string>();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(track?.id))
|
||||
url.Add($"id={Uri.EscapeDataString(track.id)}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(provider))
|
||||
url.Add($"provider={Uri.EscapeDataString(provider)}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(audioProvider))
|
||||
url.Add($"audio_provider={Uri.EscapeDataString(audioProvider)}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(streamMode))
|
||||
url.Add($"stream_mode={Uri.EscapeDataString(streamMode)}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(playbackMode))
|
||||
url.Add($"playback_mode={Uri.EscapeDataString(playbackMode)}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(track?.title))
|
||||
url.Add($"title={Uri.EscapeDataString(track.title)}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(track?.artist_name))
|
||||
url.Add($"artist_name={Uri.EscapeDataString(track.artist_name)}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(track?.album_id))
|
||||
url.Add($"album_id={Uri.EscapeDataString(track.album_id)}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(track?.album_title))
|
||||
url.Add($"album_title={Uri.EscapeDataString(track.album_title)}");
|
||||
|
||||
string isrc = MusicIsrc.Normalize(track?.isrc);
|
||||
if (isrc != null)
|
||||
url.Add($"isrc={Uri.EscapeDataString(isrc)}");
|
||||
|
||||
if (track?.duration_ms.HasValue == true)
|
||||
url.Add($"duration_ms={track.duration_ms.Value}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(track?.date))
|
||||
url.Add($"date={Uri.EscapeDataString(track.date)}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(uid))
|
||||
url.Add($"uid={Uri.EscapeDataString(uid)}");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(accountEmail))
|
||||
url.Add($"account_email={Uri.EscapeDataString(accountEmail)}");
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
static string BuildInlineTrackId(string title, string artistName, string albumTitle, int? durationMs, string isrc)
|
||||
{
|
||||
string key = $"{artistName}::{title}::{albumTitle}::{durationMs}::{isrc}";
|
||||
using var md5 = System.Security.Cryptography.MD5.Create();
|
||||
var bytes = md5.ComputeHash(Encoding.UTF8.GetBytes(key));
|
||||
return "inline:" + Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
using System.Text;
|
||||
|
||||
namespace Music;
|
||||
|
||||
public sealed class MusicPlaylistRequest
|
||||
{
|
||||
public string ids { get; set; }
|
||||
public string provider { get; set; }
|
||||
public string audio_provider { get; set; }
|
||||
public string stream_mode { get; set; }
|
||||
public string playback_mode { get; set; }
|
||||
public string playlist_strategy { get; set; }
|
||||
public string profile_id { get; set; }
|
||||
public string host { get; set; }
|
||||
public string uid { get; set; }
|
||||
public string account_email { get; set; }
|
||||
public CancellationToken cancellation_token { get; set; }
|
||||
}
|
||||
|
||||
public static class MusicPlaylistService
|
||||
{
|
||||
public static async Task<string> BuildAsync(MusicPlaylistRequest request)
|
||||
{
|
||||
request ??= new MusicPlaylistRequest();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.ids))
|
||||
return string.Empty;
|
||||
|
||||
// защитный кап: 500 = лимит окна снапшота очереди, длиннее клиент
|
||||
// не шлёт; всё сверх — обрезаем, а не отказываем (устойчивость)
|
||||
const int maxPlaylistIds = 500;
|
||||
const int maxTrackIdLength = 256;
|
||||
|
||||
var trackIds = request.ids
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id) && id.Length <= maxTrackIdLength)
|
||||
.Take(maxPlaylistIds)
|
||||
.ToArray();
|
||||
|
||||
if (trackIds.Length == 0)
|
||||
return string.Empty;
|
||||
|
||||
var tracks = await ResolvePlaylistTracksAsync(trackIds, request);
|
||||
|
||||
if (string.Equals(request.audio_provider, SefonSupport.ProviderId, StringComparison.OrdinalIgnoreCase))
|
||||
return await BuildResolvedExternalPlaylistAsync(trackIds, tracks, request);
|
||||
|
||||
if (string.Equals(request.audio_provider, SoundCloudSupport.AudioProviderId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (string.Equals(request.playlist_strategy, "resolved", StringComparison.OrdinalIgnoreCase))
|
||||
return await BuildResolvedExternalPlaylistAsync(trackIds, tracks, request);
|
||||
|
||||
return await BuildHybridSoundCloudExternalPlaylistAsync(trackIds, tracks, request);
|
||||
}
|
||||
|
||||
return BuildInternalPlaylist(trackIds, tracks, request);
|
||||
}
|
||||
|
||||
static async Task<MusicTrack[]> ResolvePlaylistTracksAsync(string[] trackIds, MusicPlaylistRequest request)
|
||||
{
|
||||
const int metadataResolveParallelism = 6;
|
||||
var tracks = new MusicTrack[trackIds.Length];
|
||||
var historyTracks = await MusicPlaybackHistoryService.GetTracksAsync(request.profile_id, trackIds, request.cancellation_token);
|
||||
|
||||
for (int i = 0; i < trackIds.Length; i++)
|
||||
{
|
||||
if (historyTracks.TryGetValue(trackIds[i], out var historyTrack))
|
||||
tracks[i] = historyTrack;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.provider))
|
||||
return tracks;
|
||||
|
||||
using var resolveGate = new SemaphoreSlim(metadataResolveParallelism);
|
||||
var resolveTasks = new List<Task>();
|
||||
|
||||
async Task ResolveTrackAtAsync(int index)
|
||||
{
|
||||
await resolveGate.WaitAsync(request.cancellation_token);
|
||||
|
||||
try
|
||||
{
|
||||
tracks[index] = await MusicCatalogService.GetTrackAsync(trackIds[index], request.provider, request.cancellation_token);
|
||||
}
|
||||
finally
|
||||
{
|
||||
resolveGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
for (int i = 0; i < trackIds.Length; i++)
|
||||
{
|
||||
if (tracks[i] == null)
|
||||
resolveTasks.Add(ResolveTrackAtAsync(i));
|
||||
}
|
||||
|
||||
if (resolveTasks.Count > 0)
|
||||
await Task.WhenAll(resolveTasks);
|
||||
|
||||
return tracks;
|
||||
}
|
||||
|
||||
static string BuildInternalPlaylist(string[] trackIds, MusicTrack[] tracks, MusicPlaylistRequest request)
|
||||
{
|
||||
var playlist = new StringBuilder();
|
||||
playlist.AppendLine("#EXTM3U");
|
||||
|
||||
for (int i = 0; i < trackIds.Length; i++)
|
||||
{
|
||||
var track = tracks[i];
|
||||
string id = trackIds[i];
|
||||
int position = i + 1;
|
||||
|
||||
string title = BuildTitle(track, position);
|
||||
int seconds = GetSeconds(track);
|
||||
|
||||
playlist.AppendLine($"#EXTINF:{seconds},{EscapePlaylistTitle(title)}");
|
||||
string playlistUrl = BuildPlaylistEntryUrl(track, id, request);
|
||||
if (!string.IsNullOrWhiteSpace(playlistUrl))
|
||||
playlist.AppendLine(playlistUrl);
|
||||
}
|
||||
|
||||
return playlist.ToString();
|
||||
}
|
||||
|
||||
static async Task<string> BuildHybridSoundCloudExternalPlaylistAsync(string[] trackIds, MusicTrack[] tracks, MusicPlaylistRequest request)
|
||||
{
|
||||
var playlist = new StringBuilder();
|
||||
playlist.AppendLine("#EXTM3U");
|
||||
|
||||
const int leadChunkSize = 6;
|
||||
int leadIndex = -1;
|
||||
var leadResolvedEntries = new Dictionary<int, PlaylistResolvedEntry>();
|
||||
|
||||
for (int offset = 0; offset < trackIds.Length && leadIndex < 0; offset += leadChunkSize)
|
||||
{
|
||||
int chunkCount = Math.Min(leadChunkSize, trackIds.Length - offset);
|
||||
var tasks = new Task<PlaylistResolvedEntry>[chunkCount];
|
||||
|
||||
for (int chunkOffset = 0; chunkOffset < chunkCount; chunkOffset++)
|
||||
{
|
||||
int index = offset + chunkOffset;
|
||||
var track = tracks[index];
|
||||
if (track == null)
|
||||
continue;
|
||||
|
||||
tasks[chunkOffset] = ResolvePreferredPlaylistEntryAsync(index, track, request);
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks.Where(task => task != null));
|
||||
|
||||
for (int chunkOffset = 0; chunkOffset < chunkCount; chunkOffset++)
|
||||
{
|
||||
int index = offset + chunkOffset;
|
||||
var task = tasks[chunkOffset];
|
||||
var entry = task?.Result;
|
||||
if (entry == null || string.IsNullOrWhiteSpace(entry.Url))
|
||||
continue;
|
||||
|
||||
leadResolvedEntries[index] = entry;
|
||||
if (leadIndex < 0)
|
||||
leadIndex = index;
|
||||
}
|
||||
}
|
||||
|
||||
if (leadIndex < 0)
|
||||
return playlist.ToString();
|
||||
|
||||
for (int i = leadIndex; i < trackIds.Length; i++)
|
||||
{
|
||||
var track = tracks[i];
|
||||
string trackId = trackIds[i];
|
||||
if (track == null)
|
||||
continue;
|
||||
|
||||
if (leadResolvedEntries.TryGetValue(i, out var resolvedEntry) && !string.IsNullOrWhiteSpace(resolvedEntry.Url))
|
||||
{
|
||||
playlist.AppendLine($"#EXTINF:{resolvedEntry.Seconds},{EscapePlaylistTitle(resolvedEntry.Title)}");
|
||||
playlist.AppendLine(resolvedEntry.Url);
|
||||
continue;
|
||||
}
|
||||
|
||||
string playlistUrl = BuildPlaylistEntryUrl(track, trackId, request);
|
||||
if (string.IsNullOrWhiteSpace(playlistUrl))
|
||||
continue;
|
||||
|
||||
playlist.AppendLine($"#EXTINF:{GetSeconds(track)},{EscapePlaylistTitle(BuildTitle(track, i + 1))}");
|
||||
playlist.AppendLine(playlistUrl);
|
||||
}
|
||||
|
||||
return playlist.ToString();
|
||||
}
|
||||
|
||||
static async Task<string> BuildResolvedExternalPlaylistAsync(string[] trackIds, MusicTrack[] tracks, MusicPlaylistRequest request)
|
||||
{
|
||||
var entries = new PlaylistResolvedEntry[trackIds.Length];
|
||||
using var concurrency = new SemaphoreSlim(8);
|
||||
|
||||
var tasks = trackIds
|
||||
.Select((trackId, index) => ResolveResolvedPlaylistEntryAsync(index, tracks[index], request, entries, concurrency))
|
||||
.ToArray();
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
|
||||
var playlist = new StringBuilder();
|
||||
playlist.AppendLine("#EXTM3U");
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (entry == null || string.IsNullOrWhiteSpace(entry.Url))
|
||||
continue;
|
||||
|
||||
playlist.AppendLine($"#EXTINF:{entry.Seconds},{EscapePlaylistTitle(entry.Title)}");
|
||||
playlist.AppendLine(entry.Url);
|
||||
}
|
||||
|
||||
return playlist.ToString();
|
||||
}
|
||||
|
||||
static async Task ResolveResolvedPlaylistEntryAsync(int index, MusicTrack track, MusicPlaylistRequest request, PlaylistResolvedEntry[] entries, SemaphoreSlim concurrency)
|
||||
{
|
||||
await concurrency.WaitAsync(request.cancellation_token);
|
||||
try
|
||||
{
|
||||
if (track == null)
|
||||
return;
|
||||
|
||||
var entry = await ResolvePreferredPlaylistEntryAsync(index, track, request);
|
||||
if (entry != null)
|
||||
entries[index] = entry;
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
finally
|
||||
{
|
||||
concurrency.Release();
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<PlaylistResolvedEntry> ResolvePreferredPlaylistEntryAsync(int index, MusicTrack track, MusicPlaylistRequest request)
|
||||
{
|
||||
if (track == null)
|
||||
return null;
|
||||
|
||||
var result = await MusicPlaybackService.ResolvePreferredTrackAsync(track, request.audio_provider, request.stream_mode, request.playback_mode, request.profile_id, request.cancellation_token);
|
||||
if (result?.available != true || result.sources == null || result.sources.Count == 0)
|
||||
return null;
|
||||
|
||||
var source = MusicPlaybackService.SelectSource(result.sources, null, request.stream_mode);
|
||||
if (source == null || string.IsNullOrWhiteSpace(source.url))
|
||||
return null;
|
||||
|
||||
string url = source.url;
|
||||
string resolvedAudioProvider = string.IsNullOrWhiteSpace(source.provider_id) ? request.audio_provider : source.provider_id;
|
||||
|
||||
bool canUseDirectExternalUrl =
|
||||
(string.Equals(resolvedAudioProvider, SefonSupport.ProviderId, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(resolvedAudioProvider, SoundCloudSupport.AudioProviderId, StringComparison.OrdinalIgnoreCase))
|
||||
&& (source.headers == null || source.headers.Count == 0);
|
||||
|
||||
if (!canUseDirectExternalUrl)
|
||||
{
|
||||
MusicPlaybackService.PrepareStreamSources(request.host, track, MusicPlaybackService.ResolveTrackProvider(track, request.provider), resolvedAudioProvider, request.stream_mode, request.playback_mode, result);
|
||||
source = MusicPlaybackService.SelectSource(result.sources, null, request.stream_mode);
|
||||
if (source == null || string.IsNullOrWhiteSpace(source.url))
|
||||
return null;
|
||||
|
||||
url = source.url;
|
||||
}
|
||||
|
||||
return new PlaylistResolvedEntry
|
||||
{
|
||||
Title = BuildTitle(track, index + 1),
|
||||
Seconds = GetSeconds(track),
|
||||
Url = url
|
||||
};
|
||||
}
|
||||
|
||||
static string BuildPlaylistEntryUrl(MusicTrack track, string trackId, MusicPlaylistRequest request)
|
||||
{
|
||||
trackId = string.IsNullOrWhiteSpace(trackId) ? track?.id : trackId.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trackId) || string.IsNullOrWhiteSpace(request.host))
|
||||
return null;
|
||||
|
||||
string resolvedProvider = MusicPlaybackService.ResolveTrackProvider(track, request.provider);
|
||||
var fallbackTrack = track ?? new MusicTrack { id = trackId };
|
||||
if (string.IsNullOrWhiteSpace(fallbackTrack.id))
|
||||
fallbackTrack.id = trackId;
|
||||
|
||||
var url = MusicPlaybackService.BuildStreamFallbackParams(
|
||||
fallbackTrack,
|
||||
resolvedProvider,
|
||||
request.audio_provider,
|
||||
request.stream_mode,
|
||||
request.playback_mode,
|
||||
request.uid,
|
||||
request.account_email
|
||||
);
|
||||
|
||||
return $"{request.host}/music/stream?{string.Join("&", url)}";
|
||||
}
|
||||
|
||||
static string BuildTitle(MusicTrack track, int position)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(track?.artist_name)
|
||||
? track?.title ?? $"Track {position}"
|
||||
: $"{track.artist_name} - {track.title}";
|
||||
}
|
||||
|
||||
static int GetSeconds(MusicTrack track)
|
||||
{
|
||||
return track?.duration_ms.HasValue == true
|
||||
? (int)Math.Max(0, Math.Round(track.duration_ms.Value / 1000d))
|
||||
: -1;
|
||||
}
|
||||
|
||||
static string EscapePlaylistTitle(string title)
|
||||
=> (title ?? string.Empty).Replace("\r", " ").Replace("\n", " ");
|
||||
|
||||
sealed class PlaylistResolvedEntry
|
||||
{
|
||||
public string Title { get; set; }
|
||||
public int Seconds { get; set; }
|
||||
public string Url { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,753 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Music;
|
||||
|
||||
public static class MusicRadioService
|
||||
{
|
||||
static readonly TimeSpan searchCacheTtl = TimeSpan.FromHours(6);
|
||||
static readonly TimeSpan radioTimeout = TimeSpan.FromSeconds(8);
|
||||
const int maxSeedArtists = 5;
|
||||
const int providerQueryLimit = 12;
|
||||
const int maxPoolPerArtist = 24;
|
||||
const string cacheVersion = "radio-v3-metadata";
|
||||
|
||||
public static async Task<MusicRadioResponse> GetAsync(string profileId, MusicRadioRequest request, int limit = 20, CancellationToken cancellationToken = default)
|
||||
{
|
||||
request ??= new MusicRadioRequest();
|
||||
limit = Math.Clamp(limit, 5, 30);
|
||||
|
||||
var seeds = NormalizeTrackList(request.seeds);
|
||||
var exclude = NormalizeTrackList(request.exclude);
|
||||
if (seeds.Count == 0)
|
||||
seeds = exclude.TakeLast(Math.Min(5, exclude.Count)).ToList();
|
||||
|
||||
var artists = ExtractSeedArtists(seeds);
|
||||
if (artists.Count == 0)
|
||||
return Unavailable("Недостаточно данных для радио.");
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(radioTimeout);
|
||||
|
||||
// сиды исключаем всегда, даже если клиент прислал пустой exclude —
|
||||
// иначе радио может вернуть сам сид-трек под другим id
|
||||
var excludedIds = new HashSet<string>(exclude.Concat(seeds).Select(i => i.id).Where(i => !string.IsNullOrWhiteSpace(i)), StringComparer.OrdinalIgnoreCase);
|
||||
var excludedKeys = new HashSet<string>(exclude.Concat(seeds).Select(BuildTrackDedupeKey).Where(i => !string.IsNullOrWhiteSpace(i)), StringComparer.OrdinalIgnoreCase);
|
||||
var excludedTitles = new HashSet<string>(exclude.Concat(seeds).Select(BuildTrackTitleKey).Where(i => !string.IsNullOrWhiteSpace(i)), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
try
|
||||
{
|
||||
foreach (var recent in await MusicPlaybackHistoryService.GetRecentAsync(profileId, 150, timeoutCts.Token))
|
||||
{
|
||||
if (recent == null || recent.played_at.ToUniversalTime() < DateTime.UtcNow.AddDays(-1))
|
||||
continue;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(recent.track?.id))
|
||||
excludedIds.Add(recent.track.id);
|
||||
|
||||
string key = BuildTrackDedupeKey(recent.track);
|
||||
if (!string.IsNullOrWhiteSpace(key))
|
||||
excludedKeys.Add(key);
|
||||
|
||||
string titleKey = BuildTrackTitleKey(recent.track);
|
||||
if (!string.IsNullOrWhiteSpace(titleKey))
|
||||
excludedTitles.Add(titleKey);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
|
||||
var output = new List<MusicTrack>();
|
||||
var outputIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var outputKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var outputTitles = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// «шумные» слова допустимы в кандидате, только если были в самом сиде
|
||||
// (слушаешь remix — получай remix, иначе это мусор)
|
||||
var seedNoisyWords = CollectSeedNoisyWords(seeds);
|
||||
// script-affinity включаем только когда ВЕСЬ сид-контекст кириллический:
|
||||
// при Any смешанная очередь (Eminem + Miyagi) резала бы латинские
|
||||
// кандидаты целиком, включая related-пул латинского сида
|
||||
bool requireCyrillic = seeds.Count > 0
|
||||
&& seeds.All(seed => ContainsCyrillic(seed?.title) || ContainsCyrillic(seed?.artist_name));
|
||||
|
||||
// слой 1: рекомендации SoundCloud ПО ТРЕКУ (related/station) — живое
|
||||
// радио вместо «топ треков артиста»; artist-пулы остаются как backfill
|
||||
var relatedTasks = SoundCloudSupport.IsDiscoveryEnabled
|
||||
? seeds.Take(3).Select(seed => LoadRelatedPoolAsync(seed, timeoutCts.Token)).ToList()
|
||||
: new List<Task<List<MusicTrack>>>();
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(relatedTasks);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
static List<List<MusicTrack>> CollectPools(IEnumerable<Task<List<MusicTrack>>> tasks) => tasks
|
||||
.Where(task => task.IsCompletedSuccessfully)
|
||||
.Select(task => task.Result ?? new List<MusicTrack>())
|
||||
.Where(pool => pool.Count > 0)
|
||||
.ToList();
|
||||
|
||||
FillRoundRobin(CollectPools(relatedTasks), output, limit, seedNoisyWords, requireCyrillic, excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles);
|
||||
|
||||
if (output.Count < limit)
|
||||
{
|
||||
// Backfill is intentionally lazy: if SoundCloud related/station gave
|
||||
// enough tracks, do not spend requests on generic artist search.
|
||||
var artistTasks = artists.Select(artist => LoadArtistPoolAsync(artist, timeoutCts.Token)).ToList();
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(artistTasks);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
FillRoundRobin(CollectPools(artistTasks), output, limit, seedNoisyWords, requireCyrillic, excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles);
|
||||
}
|
||||
|
||||
return output.Count >= Math.Min(limit, 8)
|
||||
? Available(output)
|
||||
: Unavailable("Радио не нашло достаточно похожих треков.");
|
||||
}
|
||||
|
||||
// round-robin по пулам: 1-й трек каждого пула, потом 2-е и т.д. — иначе
|
||||
// первый пул целиком заполняет лимит и «радио» вырождается в один источник
|
||||
// (internal: переиспользуется MusicDailyMixService для «Миксов недели»)
|
||||
internal static void FillRoundRobin(
|
||||
List<List<MusicTrack>> pools,
|
||||
List<MusicTrack> output,
|
||||
int limit,
|
||||
HashSet<string> seedNoisyWords,
|
||||
bool requireCyrillic,
|
||||
HashSet<string> excludedIds,
|
||||
HashSet<string> excludedKeys,
|
||||
HashSet<string> excludedTitles,
|
||||
HashSet<string> outputIds,
|
||||
HashSet<string> outputKeys,
|
||||
HashSet<string> outputTitles,
|
||||
Dictionary<string, int> outputArtistCounts = null,
|
||||
int maxTracksPerArtist = 0)
|
||||
{
|
||||
for (int position = 0; output.Count < limit; position++)
|
||||
{
|
||||
bool anyLeft = false;
|
||||
|
||||
foreach (var pool in pools)
|
||||
{
|
||||
if (position >= pool.Count)
|
||||
continue;
|
||||
|
||||
anyLeft = true;
|
||||
var candidate = NormalizeCandidateMetadata(pool[position]);
|
||||
|
||||
if (maxTracksPerArtist > 0
|
||||
&& outputArtistCounts != null
|
||||
&& !CanAddArtistCandidate(candidate, outputArtistCounts, maxTracksPerArtist))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!AcceptCandidate(candidate, seedNoisyWords, requireCyrillic, excludedIds, excludedKeys, excludedTitles, outputIds, outputKeys, outputTitles))
|
||||
continue;
|
||||
|
||||
candidate.auto_radio = true;
|
||||
output.Add(candidate);
|
||||
AddArtistCandidate(candidate, outputArtistCounts);
|
||||
|
||||
if (output.Count >= limit)
|
||||
break;
|
||||
}
|
||||
|
||||
if (!anyLeft)
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static bool CanAddArtistCandidate(MusicTrack track, Dictionary<string, int> counts, int maxTracksPerArtist)
|
||||
{
|
||||
string key = BuildArtistBucketKey(track);
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
return true;
|
||||
|
||||
return !counts.TryGetValue(key, out int count) || count < maxTracksPerArtist;
|
||||
}
|
||||
|
||||
static void AddArtistCandidate(MusicTrack track, Dictionary<string, int> counts)
|
||||
{
|
||||
if (counts == null)
|
||||
return;
|
||||
|
||||
string key = BuildArtistBucketKey(track);
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
return;
|
||||
|
||||
counts[key] = counts.TryGetValue(key, out int count) ? count + 1 : 1;
|
||||
}
|
||||
|
||||
static string BuildArtistBucketKey(MusicTrack track)
|
||||
{
|
||||
// Структурированный список хранит primary artist первым. Иначе
|
||||
// "Artist, Guest" и "Artist" попадают в разные bucket'ы и обходят artist-cap.
|
||||
string artist = CleanupArtistName(track?.artists?.FirstOrDefault());
|
||||
if (string.IsNullOrWhiteSpace(artist))
|
||||
artist = CleanupArtistName(track?.artist_name);
|
||||
return string.IsNullOrWhiteSpace(artist)
|
||||
? string.Empty
|
||||
: MusicMapSupport.NormalizeName(artist);
|
||||
}
|
||||
|
||||
static MusicRadioResponse Available(List<MusicTrack> tracks) => new()
|
||||
{
|
||||
available = true,
|
||||
tracks = tracks ?? new List<MusicTrack>()
|
||||
};
|
||||
|
||||
static MusicRadioResponse Unavailable(string message) => new()
|
||||
{
|
||||
available = false,
|
||||
message = message
|
||||
};
|
||||
|
||||
static List<MusicTrack> NormalizeTrackList(IEnumerable<MusicTrack> tracks)
|
||||
{
|
||||
var result = new List<MusicTrack>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var track in tracks ?? Enumerable.Empty<MusicTrack>())
|
||||
{
|
||||
if (track == null)
|
||||
continue;
|
||||
|
||||
string id = (track.id ?? string.Empty).Trim();
|
||||
string key = !string.IsNullOrWhiteSpace(id)
|
||||
? id
|
||||
: BuildTrackDedupeKey(track);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(key) || !seen.Add(key))
|
||||
continue;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(id))
|
||||
track.id = id;
|
||||
|
||||
result.Add(track);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static List<string> ExtractSeedArtists(IEnumerable<MusicTrack> seeds)
|
||||
{
|
||||
var artists = new List<string>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var track in seeds ?? Enumerable.Empty<MusicTrack>())
|
||||
{
|
||||
foreach (var artist in EnumerateTrackArtists(track))
|
||||
{
|
||||
string value = CleanupArtistName(artist);
|
||||
if (string.IsNullOrWhiteSpace(value) || !seen.Add(NormalizeText(value)))
|
||||
continue;
|
||||
|
||||
artists.Add(value);
|
||||
if (artists.Count >= maxSeedArtists)
|
||||
return artists;
|
||||
}
|
||||
}
|
||||
|
||||
return artists;
|
||||
}
|
||||
|
||||
static IEnumerable<string> EnumerateTrackArtists(MusicTrack track)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(track?.artist_name))
|
||||
yield return track.artist_name;
|
||||
|
||||
foreach (var artist in track?.artists ?? new List<string>())
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(artist))
|
||||
yield return artist;
|
||||
}
|
||||
}
|
||||
|
||||
internal static string CleanupArtistName(string value)
|
||||
{
|
||||
value = (value ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return string.Empty;
|
||||
|
||||
value = Regex.Replace(value, @"\s+(feat\.?|ft\.?|featuring|with|vs\.?)\s+.*$", "", RegexOptions.IgnoreCase);
|
||||
value = Regex.Replace(value, @"\s+", " ").Trim();
|
||||
return value;
|
||||
}
|
||||
|
||||
internal static Task<List<MusicTrack>> LoadRelatedPoolAsync(MusicTrack seed, CancellationToken cancellationToken)
|
||||
{
|
||||
string artist = CleanupArtistName(seed?.artist_name);
|
||||
string title = (seed?.title ?? string.Empty).Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artist) || string.IsNullOrWhiteSpace(title))
|
||||
return Task.FromResult(new List<MusicTrack>());
|
||||
|
||||
string query = $"{artist} {title}";
|
||||
|
||||
return LoadCachedTracksAsync(
|
||||
SoundCloudSupport.DiscoveryProviderId,
|
||||
query,
|
||||
token => SoundCloudSupport.FindRelatedTracksByQueryAsync(query, artist, title, maxPoolPerArtist, token),
|
||||
cancellationToken,
|
||||
cacheType: "radio_related",
|
||||
cacheLimit: maxPoolPerArtist);
|
||||
}
|
||||
|
||||
internal static async Task<List<MusicTrack>> LoadArtistPoolAsync(
|
||||
string artist,
|
||||
CancellationToken cancellationToken,
|
||||
bool requireConfirmedArtist = false)
|
||||
{
|
||||
var tasks = new List<Task<List<MusicTrack>>>();
|
||||
string cacheType = requireConfirmedArtist ? "radio_tracks_confirmed" : "radio_tracks";
|
||||
|
||||
if (YouTubeMusicSearchSupport.IsSearchEnabled)
|
||||
tasks.Add(LoadCachedTracksAsync(
|
||||
YouTubeMusicSearchSupport.ProviderId,
|
||||
artist,
|
||||
token => YouTubeMusicSearchSupport.SearchTracksByQueryAsync(
|
||||
artist,
|
||||
providerQueryLimit,
|
||||
token,
|
||||
artist,
|
||||
requireConfirmedArtist),
|
||||
cancellationToken,
|
||||
cacheType));
|
||||
|
||||
if (SoundCloudSupport.IsDiscoveryEnabled)
|
||||
tasks.Add(LoadCachedTracksAsync(
|
||||
SoundCloudSupport.DiscoveryProviderId,
|
||||
artist,
|
||||
token => SoundCloudSupport.SearchTracksByQueryAsync(artist, providerQueryLimit, token),
|
||||
cancellationToken,
|
||||
cacheType));
|
||||
|
||||
if (tasks.Count == 0)
|
||||
return new List<MusicTrack>();
|
||||
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
var candidates = tasks
|
||||
.Where(i => i.IsCompletedSuccessfully)
|
||||
.SelectMany(i => i.Result ?? new List<MusicTrack>())
|
||||
.Select(track => NormalizeArtistPoolCandidate(track, artist, !requireConfirmedArtist))
|
||||
.Where(i => i != null)
|
||||
.Take(maxPoolPerArtist)
|
||||
.ToList();
|
||||
|
||||
if (requireConfirmedArtist && !candidates.Any(IsConfirmedExternalArtistEvidence))
|
||||
return new List<MusicTrack>();
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
static bool IsConfirmedExternalArtistEvidence(MusicTrack track)
|
||||
{
|
||||
if ((track?.id ?? string.Empty).StartsWith("youtube:", StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
|
||||
return (track?.id ?? string.Empty).StartsWith("soundcloud:", StringComparison.OrdinalIgnoreCase)
|
||||
&& MusicIsrc.Normalize(track.isrc) != null;
|
||||
}
|
||||
|
||||
static MusicTrack NormalizeArtistPoolCandidate(MusicTrack track, string requestedArtist, bool allowTitlePrefix)
|
||||
{
|
||||
track = NormalizeCandidateMetadata(track);
|
||||
string artist = CleanupArtistName(requestedArtist);
|
||||
|
||||
if (track == null || string.IsNullOrWhiteSpace(artist))
|
||||
return track;
|
||||
|
||||
string requested = NormalizeText(artist);
|
||||
string actual = NormalizeText(track.artist_name);
|
||||
string title = NormalizeText(track.title);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(requested))
|
||||
return track;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(actual)
|
||||
&& (actual == requested
|
||||
|| actual.Contains(requested, StringComparison.OrdinalIgnoreCase)
|
||||
|| (actual.Length >= 4 && requested.Contains(actual, StringComparison.OrdinalIgnoreCase))))
|
||||
{
|
||||
// SoundCloud/YouTube sometimes expose channel names like
|
||||
// "032 stas mikhaylov"; if the target artist is clearly embedded,
|
||||
// keep the candidate but display the canonical artist name.
|
||||
if (actual != requested)
|
||||
{
|
||||
track.artist_name = artist;
|
||||
track.artists = new List<string> { artist };
|
||||
}
|
||||
|
||||
return track;
|
||||
}
|
||||
|
||||
if (allowTitlePrefix
|
||||
&& !string.IsNullOrWhiteSpace(title)
|
||||
&& title.StartsWith(requested + " ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
track.artist_name = artist;
|
||||
track.artists = new List<string> { artist };
|
||||
track.title = StripLeadingArtistFromTitle(track.title, artist);
|
||||
return track;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static string StripLeadingArtistFromTitle(string title, string artist)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(artist))
|
||||
return title;
|
||||
|
||||
string pattern = @"^\s*" + Regex.Escape(artist.Trim()) + @"\s*(?:[-–—:]|&)?\s*";
|
||||
string stripped = Regex.Replace(title, pattern, "", RegexOptions.IgnoreCase).Trim();
|
||||
return string.IsNullOrWhiteSpace(stripped) ? title : stripped;
|
||||
}
|
||||
|
||||
static async Task<List<MusicTrack>> LoadCachedTracksAsync(string providerId, string query, Func<CancellationToken, Task<List<MusicTrack>>> factory, CancellationToken cancellationToken, string cacheType = "radio_tracks", int cacheLimit = providerQueryLimit)
|
||||
{
|
||||
try
|
||||
{
|
||||
string key = $"{cacheVersion}|{providerId}|{cacheLimit}|{NormalizeText(query)}";
|
||||
return await MusicMetadataCacheService.GetOrCreateAsync(
|
||||
providerId,
|
||||
cacheType,
|
||||
key,
|
||||
searchCacheTtl,
|
||||
() => factory(cancellationToken),
|
||||
cancellationToken
|
||||
) ?? new List<MusicTrack>();
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<MusicTrack>();
|
||||
}
|
||||
}
|
||||
|
||||
// «шумные» слова в названии кандидата допустимы только если встречались
|
||||
// в самих сидах: слушаешь remix/nightcore — радио вправе их предлагать
|
||||
static readonly string[] conditionalNoisyWords =
|
||||
{
|
||||
"cover", "karaoke", "sped up", "slowed", "nightcore", "remix",
|
||||
"8d audio", "bass boosted", "reverb", "edit", "mashup",
|
||||
"type beat", "freestyle"
|
||||
};
|
||||
|
||||
internal static HashSet<string> CollectSeedNoisyWords(IEnumerable<MusicTrack> seeds)
|
||||
{
|
||||
var allowed = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var seed in seeds ?? Enumerable.Empty<MusicTrack>())
|
||||
{
|
||||
string padded = $" {NormalizeNoiseText(seed?.title)} ";
|
||||
|
||||
foreach (var word in conditionalNoisyWords)
|
||||
{
|
||||
if (padded.Contains($" {word} ", StringComparison.OrdinalIgnoreCase))
|
||||
allowed.Add(word);
|
||||
}
|
||||
}
|
||||
|
||||
return allowed;
|
||||
}
|
||||
|
||||
static bool AcceptCandidate(
|
||||
MusicTrack track,
|
||||
HashSet<string> seedNoisyWords,
|
||||
bool requireCyrillic,
|
||||
HashSet<string> excludedIds,
|
||||
HashSet<string> excludedKeys,
|
||||
HashSet<string> excludedTitles,
|
||||
HashSet<string> outputIds,
|
||||
HashSet<string> outputKeys,
|
||||
HashSet<string> outputTitles)
|
||||
{
|
||||
if (track == null || string.IsNullOrWhiteSpace(track.id) || string.IsNullOrWhiteSpace(track.title))
|
||||
return false;
|
||||
|
||||
string id = track.id.Trim();
|
||||
string key = BuildTrackDedupeKey(track);
|
||||
string titleKey = BuildTrackTitleKey(track);
|
||||
|
||||
if (!LooksLikeStandaloneSong(track, titleKey, seedNoisyWords, requireCyrillic))
|
||||
return false;
|
||||
|
||||
if (excludedIds.Contains(id) || outputIds.Contains(id))
|
||||
return false;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(key) && (excludedKeys.Contains(key) || outputKeys.Contains(key)))
|
||||
return false;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(titleKey) && (excludedTitles.Contains(titleKey) || outputTitles.Contains(titleKey)))
|
||||
return false;
|
||||
|
||||
outputIds.Add(id);
|
||||
if (!string.IsNullOrWhiteSpace(key))
|
||||
outputKeys.Add(key);
|
||||
if (!string.IsNullOrWhiteSpace(titleKey))
|
||||
outputTitles.Add(titleKey);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static MusicTrack NormalizeCandidateMetadata(MusicTrack track)
|
||||
{
|
||||
if (track == null)
|
||||
return track;
|
||||
|
||||
// Provider/cache objects can be reused by radio and weekly mixes. Never
|
||||
// rewrite those shared instances while preparing one response.
|
||||
var result = CloneTrack(track);
|
||||
result.title = CleanupCandidateTitle(result.title);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(result.title)
|
||||
|| !TrySplitArtistTitle(result.title, out var artist, out var title))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
string currentArtist = CleanupArtistName(result.artist_name);
|
||||
if (!string.IsNullOrWhiteSpace(currentArtist)
|
||||
&& !IsGenericUploaderName(currentArtist)
|
||||
&& !ArtistNamesMatch(currentArtist, artist))
|
||||
{
|
||||
// Structured provider metadata is more trustworthy than an
|
||||
// unverified multi-dash title. Keeping it intact also preserves an
|
||||
// exact provider id for playback instead of inventing a new pair.
|
||||
return result;
|
||||
}
|
||||
|
||||
result.artist_name = artist;
|
||||
result.artists = new List<string> { artist };
|
||||
result.title = CleanupCandidateTitle(title);
|
||||
return result;
|
||||
}
|
||||
|
||||
static bool TrySplitArtistTitle(string rawTitle, out string artist, out string title)
|
||||
{
|
||||
artist = null;
|
||||
title = null;
|
||||
|
||||
var match = Regex.Match(
|
||||
rawTitle ?? string.Empty,
|
||||
@"^\s*(?<artist>.{2,80}?)\s+[-–—]\s+(?<title>.{2,180})\s*$");
|
||||
if (!match.Success)
|
||||
return false;
|
||||
|
||||
string artistPart = match.Groups["artist"].Value;
|
||||
string titlePart = match.Groups["title"].Value;
|
||||
|
||||
artistPart = Regex.Replace(artistPart, @"^\s*[\d#._-]+\s*", "").Trim();
|
||||
titlePart = Regex.Replace(titlePart, @"^\s*[\d#._-]+\s*", "").Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(artistPart) || string.IsNullOrWhiteSpace(titlePart))
|
||||
return false;
|
||||
|
||||
if (NormalizeText(artistPart).Length < 2 || NormalizeText(titlePart).Length < 2)
|
||||
return false;
|
||||
|
||||
if (artistPart.Length > 80 || titlePart.Length > 120)
|
||||
return false;
|
||||
|
||||
artist = artistPart;
|
||||
title = titlePart;
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool ArtistNamesMatch(string left, string right)
|
||||
{
|
||||
string a = NormalizeText(CleanupArtistName(left));
|
||||
string b = NormalizeText(CleanupArtistName(right));
|
||||
|
||||
return !string.IsNullOrWhiteSpace(a)
|
||||
&& !string.IsNullOrWhiteSpace(b)
|
||||
&& (a == b
|
||||
|| (a.Length >= 4 && b.Contains(a, StringComparison.Ordinal))
|
||||
|| (b.Length >= 4 && a.Contains(b, StringComparison.Ordinal)));
|
||||
}
|
||||
|
||||
static bool IsGenericUploaderName(string value)
|
||||
{
|
||||
string normalized = NormalizeText(value);
|
||||
return normalized is "soundcloud" or "youtube" or "unknown" or "various artists";
|
||||
}
|
||||
|
||||
static string CleanupCandidateTitle(string value)
|
||||
{
|
||||
value = Regex.Replace(value ?? string.Empty, @"\s+", " ").Trim();
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return value;
|
||||
|
||||
bool filename = Regex.IsMatch(value, @"\.(aac|flac|m4a|mp3|ogg|opus|wav|webm)\s*$", RegexOptions.IgnoreCase);
|
||||
if (!filename)
|
||||
return value;
|
||||
|
||||
value = Regex.Replace(value, @"\.(aac|flac|m4a|mp3|ogg|opus|wav|webm)\s*$", string.Empty, RegexOptions.IgnoreCase);
|
||||
value = Regex.Replace(value, @"_+", " ");
|
||||
value = Regex.Replace(value, @"^\s*[-–—.]+\s*|\s*[-–—.]+\s*$", string.Empty);
|
||||
return Regex.Replace(value, @"\s+", " ").Trim();
|
||||
}
|
||||
|
||||
static MusicTrack CloneTrack(MusicTrack track)
|
||||
{
|
||||
return new MusicTrack
|
||||
{
|
||||
id = track.id,
|
||||
title = track.title,
|
||||
artist_id = track.artist_id,
|
||||
artist_name = track.artist_name,
|
||||
artists = track.artists?.ToList() ?? new List<string>(),
|
||||
album_id = track.album_id,
|
||||
album_title = track.album_title,
|
||||
isrc = track.isrc,
|
||||
duration_ms = track.duration_ms,
|
||||
track_number = track.track_number,
|
||||
disc_number = track.disc_number,
|
||||
date = track.date,
|
||||
search_score = track.search_score,
|
||||
images = track.images?.ToList() ?? new List<MusicImage>(),
|
||||
provider_refs = track.provider_refs?.ToList() ?? new List<MusicProviderRef>(),
|
||||
auto_radio = track.auto_radio
|
||||
};
|
||||
}
|
||||
|
||||
static bool LooksLikeStandaloneSong(MusicTrack track, string titleKey, HashSet<string> seedNoisyWords, bool requireCyrillic)
|
||||
{
|
||||
int durationMs = track.duration_ms ?? 0;
|
||||
if (durationMs > 0 && (durationMs < 45_000 || durationMs > 12 * 60_000))
|
||||
return false;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(titleKey))
|
||||
return false;
|
||||
|
||||
string padded = $" {titleKey} ";
|
||||
string noisePadded = $" {NormalizeNoiseText(track?.title)} ";
|
||||
|
||||
if (requireCyrillic && !ContainsCyrillic(track?.title) && !ContainsCyrillic(track?.artist_name))
|
||||
return false;
|
||||
|
||||
string[] noisy =
|
||||
{
|
||||
" nonstop ",
|
||||
" megamix ",
|
||||
" full album ",
|
||||
" playlist ",
|
||||
" reaction ",
|
||||
" podcast ",
|
||||
" interview ",
|
||||
" instrumental beat ",
|
||||
" dj set ",
|
||||
" live set ",
|
||||
" preview ",
|
||||
" prod "
|
||||
};
|
||||
|
||||
if (noisy.Any(word => padded.Contains(word, StringComparison.OrdinalIgnoreCase) || noisePadded.Contains(word, StringComparison.OrdinalIgnoreCase)))
|
||||
return false;
|
||||
|
||||
foreach (var word in conditionalNoisyWords)
|
||||
{
|
||||
if (seedNoisyWords != null && seedNoisyWords.Contains(word))
|
||||
continue;
|
||||
|
||||
if (noisePadded.Contains($" {word} ", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool ContainsCyrillic(string value)
|
||||
=> !string.IsNullOrWhiteSpace(value) && value.Any(c => c is >= '\u0400' and <= '\u04FF');
|
||||
|
||||
static string BuildTrackDedupeKey(MusicTrack track)
|
||||
{
|
||||
if (track == null)
|
||||
return string.Empty;
|
||||
|
||||
string artist = NormalizeText(track.artist_name);
|
||||
string title = BuildTrackTitleKey(track);
|
||||
|
||||
return string.IsNullOrWhiteSpace(title)
|
||||
? string.Empty
|
||||
: $"{artist}|{title}";
|
||||
}
|
||||
|
||||
static string BuildTrackTitleKey(MusicTrack track)
|
||||
{
|
||||
string rawTitle = track?.title ?? string.Empty;
|
||||
rawTitle = Regex.Replace(rawTitle, @"^\s*.{1,80}\s+[-–—]\s+", "", RegexOptions.IgnoreCase);
|
||||
|
||||
string title = NormalizeText(rawTitle);
|
||||
string artist = NormalizeText(track?.artist_name);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(artist) && title.StartsWith(artist + " ", StringComparison.OrdinalIgnoreCase))
|
||||
title = title.Substring(artist.Length).Trim();
|
||||
|
||||
foreach (var artistName in track?.artists ?? new List<string>())
|
||||
{
|
||||
string normalizedArtist = NormalizeText(artistName);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedArtist) && title.StartsWith(normalizedArtist + " ", StringComparison.OrdinalIgnoreCase))
|
||||
title = title.Substring(normalizedArtist.Length).Trim();
|
||||
}
|
||||
|
||||
return title;
|
||||
}
|
||||
|
||||
internal static string NormalizeText(string value)
|
||||
{
|
||||
value = RemoveDiacritics((value ?? string.Empty).ToLowerInvariant());
|
||||
value = Regex.Replace(value, @"\([^)]*\)|\[[^\]]*\]", " ");
|
||||
value = Regex.Replace(value, @"\b(feat|ft|featuring|official|audio|video|lyrics|lyric|clip|mv|hd|4k)\b", " ", RegexOptions.IgnoreCase);
|
||||
value = Regex.Replace(value, @"[^a-z0-9а-яёіїєґ]+", " ");
|
||||
return Regex.Replace(value, @"\s+", " ").Trim();
|
||||
}
|
||||
|
||||
static string NormalizeNoiseText(string value)
|
||||
{
|
||||
value = RemoveDiacritics((value ?? string.Empty).ToLowerInvariant());
|
||||
value = Regex.Replace(value, @"\b(feat|ft|featuring|official|audio|video|lyrics|lyric|clip|mv|hd|4k)\b", " ", RegexOptions.IgnoreCase);
|
||||
value = Regex.Replace(value, @"[^a-z0-9а-яёіїєґ]+", " ");
|
||||
return Regex.Replace(value, @"\s+", " ").Trim();
|
||||
}
|
||||
|
||||
static string RemoveDiacritics(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return string.Empty;
|
||||
|
||||
var normalized = value.Normalize(NormalizationForm.FormD);
|
||||
var builder = new StringBuilder(normalized.Length);
|
||||
|
||||
foreach (var c in normalized)
|
||||
{
|
||||
if (CharUnicodeInfo.GetUnicodeCategory(c) != UnicodeCategory.NonSpacingMark)
|
||||
builder.Append(c);
|
||||
}
|
||||
|
||||
return builder.ToString().Normalize(NormalizationForm.FormC);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Music;
|
||||
|
||||
/// <summary>
|
||||
/// Дневная статистика прослушиваний (track_stats_daily). Счётчики отдельно
|
||||
/// от витрины: payload трека живёт в playback_history и джойнится при чтении.
|
||||
/// Инкремент вызывается ТОЛЬКО из честного play-пути (count_play=true в mark) —
|
||||
/// обновления payload (смена источника и т.п.) статистику не трогают.
|
||||
/// </summary>
|
||||
public static class MusicStatsService
|
||||
{
|
||||
// unlock «Твоего топа»: 10 разных треков обязательны (иначе «альбом из
|
||||
// трёх песен»), плюс либо фаворит на повторе, либо просто наслушан объём —
|
||||
// fallback для тех, кто слушает широко и не гоняет один трек по кругу
|
||||
const int UnlockTrackPlays = 10;
|
||||
const int UnlockDistinctTracks = 10;
|
||||
const int UnlockTotalPlays = 100;
|
||||
|
||||
public static Task IncrementPlayAsync(string profileId, MusicTrack track, CancellationToken cancellationToken = default)
|
||||
=> RecordPlayAsync(profileId, track, true, null, cancellationToken);
|
||||
|
||||
public static async Task<int> ClearAsync(string profileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
profileId = NormalizeProfileId(profileId);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(profileId))
|
||||
return 0;
|
||||
|
||||
var semaphore = new SemaphorManager(MusicContext.semaphoreKey, TimeSpan.FromSeconds(20));
|
||||
var acquired = false;
|
||||
|
||||
try
|
||||
{
|
||||
if (!await semaphore.WaitAsync())
|
||||
return 0;
|
||||
|
||||
acquired = true;
|
||||
|
||||
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
DELETE FROM track_stats_daily
|
||||
WHERE profile_id = $profile_id;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$profile_id", profileId);
|
||||
|
||||
return await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (acquired)
|
||||
semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task RecordPlayAsync(string profileId, MusicTrack track, bool countPlay, long? playedMs = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
profileId = NormalizeProfileId(profileId);
|
||||
|
||||
if (track == null || string.IsNullOrWhiteSpace(track.id) || string.IsNullOrWhiteSpace(profileId))
|
||||
return;
|
||||
|
||||
long durationMs = Math.Max(0, track.duration_ms ?? 0);
|
||||
long listenedMs = Math.Max(0, playedMs ?? 0);
|
||||
|
||||
// Старые клиенты присылали только count_play=true; для совместимости
|
||||
// оставляем прежнюю оценку полной длительностью. Новый клиент досылает
|
||||
// реальные куски played_ms, поэтому накрутка не превращается в часы.
|
||||
if (listenedMs <= 0 && countPlay)
|
||||
listenedMs = durationMs;
|
||||
|
||||
if (durationMs > 0 && listenedMs > durationMs)
|
||||
listenedMs = durationMs;
|
||||
|
||||
// sanity для треков без duration: один mark не должен записать сутки.
|
||||
listenedMs = Math.Min(listenedMs, TimeSpan.FromHours(6).Ticks / TimeSpan.TicksPerMillisecond);
|
||||
|
||||
if (!countPlay && listenedMs <= 0)
|
||||
return;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
var semaphore = new SemaphorManager(MusicContext.semaphoreKey, TimeSpan.FromSeconds(20));
|
||||
var acquired = false;
|
||||
|
||||
try
|
||||
{
|
||||
if (!await semaphore.WaitAsync())
|
||||
return;
|
||||
|
||||
acquired = true;
|
||||
|
||||
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = countPlay
|
||||
? """
|
||||
INSERT INTO track_stats_daily (profile_id, track_id, day, play_count, total_ms, last_played)
|
||||
VALUES ($profile_id, $track_id, $day, 1, $total_ms, $last_played)
|
||||
ON CONFLICT(profile_id, track_id, day) DO UPDATE SET
|
||||
play_count = play_count + 1,
|
||||
total_ms = total_ms + excluded.total_ms,
|
||||
last_played = excluded.last_played;
|
||||
"""
|
||||
: """
|
||||
UPDATE track_stats_daily
|
||||
SET total_ms = total_ms + $total_ms,
|
||||
last_played = $last_played
|
||||
WHERE profile_id = $profile_id
|
||||
AND track_id = $track_id
|
||||
AND day = $day
|
||||
AND play_count > 0;
|
||||
""";
|
||||
|
||||
command.Parameters.AddWithValue("$profile_id", profileId);
|
||||
command.Parameters.AddWithValue("$track_id", track.id);
|
||||
command.Parameters.AddWithValue("$day", now.ToString("yyyy-MM-dd"));
|
||||
command.Parameters.AddWithValue("$total_ms", listenedMs);
|
||||
command.Parameters.AddWithValue("$last_played", now);
|
||||
|
||||
await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (acquired)
|
||||
semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<MusicStatsTopResult> GetTopAsync(string profileId, int limit = 30, CancellationToken cancellationToken = default)
|
||||
{
|
||||
profileId = NormalizeProfileId(profileId);
|
||||
var result = new MusicStatsTopResult();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(profileId))
|
||||
return result;
|
||||
|
||||
limit = Math.Clamp(limit, 1, 100);
|
||||
|
||||
var rows = new List<(string trackId, long plays, long totalMs, DateTime lastPlayed)>();
|
||||
long totalPlays = 0;
|
||||
long totalMs = 0;
|
||||
long distinctTracks = 0;
|
||||
|
||||
await using (var connection = new SqliteConnection(MusicContext.ConnectionString))
|
||||
{
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using (var totals = connection.CreateCommand())
|
||||
{
|
||||
totals.CommandText = """
|
||||
SELECT COALESCE(SUM(play_count), 0), COALESCE(SUM(total_ms), 0), COUNT(DISTINCT track_id)
|
||||
FROM track_stats_daily
|
||||
WHERE profile_id = $profile_id;
|
||||
""";
|
||||
totals.Parameters.AddWithValue("$profile_id", profileId);
|
||||
|
||||
await using var totalsReader = await totals.ExecuteReaderAsync(cancellationToken);
|
||||
if (await totalsReader.ReadAsync(cancellationToken))
|
||||
{
|
||||
totalPlays = totalsReader.GetInt64(0);
|
||||
totalMs = totalsReader.GetInt64(1);
|
||||
distinctTracks = totalsReader.GetInt64(2);
|
||||
}
|
||||
}
|
||||
|
||||
await using (var top = connection.CreateCommand())
|
||||
{
|
||||
top.CommandText = """
|
||||
SELECT track_id, SUM(play_count) AS plays, SUM(total_ms) AS ms, MAX(last_played) AS last
|
||||
FROM track_stats_daily
|
||||
WHERE profile_id = $profile_id
|
||||
GROUP BY track_id
|
||||
ORDER BY plays DESC, last DESC
|
||||
LIMIT $limit;
|
||||
""";
|
||||
top.Parameters.AddWithValue("$profile_id", profileId);
|
||||
top.Parameters.AddWithValue("$limit", limit);
|
||||
|
||||
await using var reader = await top.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
var trackId = reader.IsDBNull(0) ? null : reader.GetString(0);
|
||||
if (string.IsNullOrWhiteSpace(trackId))
|
||||
continue;
|
||||
|
||||
rows.Add((
|
||||
trackId,
|
||||
reader.GetInt64(1),
|
||||
reader.GetInt64(2),
|
||||
reader.IsDBNull(3) ? DateTime.MinValue : reader.GetDateTime(3)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.total_plays = totalPlays;
|
||||
result.total_ms = totalMs;
|
||||
|
||||
long maxTrackPlays = rows.Count > 0 ? rows[0].plays : 0;
|
||||
result.unlocked = distinctTracks >= UnlockDistinctTracks
|
||||
&& (maxTrackPlays >= UnlockTrackPlays || totalPlays >= UnlockTotalPlays);
|
||||
|
||||
if (!result.unlocked || rows.Count == 0)
|
||||
return result;
|
||||
|
||||
// витрина: payload треков из playback_history (mark всегда пишет туда же)
|
||||
var payloads = await MusicPlaybackHistoryService.GetTracksAsync(profileId, rows.Select(i => i.trackId), cancellationToken);
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (!payloads.TryGetValue(row.trackId, out var track) || track == null)
|
||||
continue;
|
||||
|
||||
result.tracks.Add(new MusicStatsTopTrack
|
||||
{
|
||||
track = track,
|
||||
play_count = row.plays,
|
||||
total_ms = row.totalMs,
|
||||
last_played = row.lastPlayed == DateTime.MinValue ? null : row.lastPlayed
|
||||
});
|
||||
}
|
||||
|
||||
result.available = result.tracks.Count > 0;
|
||||
return result;
|
||||
}
|
||||
|
||||
static string NormalizeProfileId(string profileId)
|
||||
=> string.IsNullOrWhiteSpace(profileId) ? null : profileId.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
public class MusicStatsTopResult
|
||||
{
|
||||
public bool available { get; set; }
|
||||
|
||||
public bool unlocked { get; set; }
|
||||
|
||||
public long total_plays { get; set; }
|
||||
|
||||
public long total_ms { get; set; }
|
||||
|
||||
public List<MusicStatsTopTrack> tracks { get; set; } = new();
|
||||
}
|
||||
|
||||
public class MusicStatsTopTrack
|
||||
{
|
||||
public MusicTrack track { get; set; }
|
||||
|
||||
public long play_count { get; set; }
|
||||
|
||||
public long total_ms { get; set; }
|
||||
|
||||
public DateTime? last_played { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
namespace Music;
|
||||
|
||||
public static class MusicStreamModeService
|
||||
{
|
||||
public const string Auto = "auto";
|
||||
public const string Best = "best";
|
||||
public const string Low = "low";
|
||||
public const string PreferM4a = "m4a";
|
||||
public const string PreferWebm = "webm";
|
||||
|
||||
public static string Normalize(string mode)
|
||||
{
|
||||
mode = mode?.Trim().ToLowerInvariant();
|
||||
|
||||
return mode switch
|
||||
{
|
||||
Best => Best,
|
||||
Low => Low,
|
||||
PreferM4a => PreferM4a,
|
||||
PreferWebm => PreferWebm,
|
||||
_ => Auto
|
||||
};
|
||||
}
|
||||
|
||||
public static List<MusicPlaybackSource> Order(IEnumerable<MusicPlaybackSource> sources, string mode)
|
||||
{
|
||||
if (sources == null)
|
||||
return new List<MusicPlaybackSource>();
|
||||
|
||||
mode = Normalize(mode);
|
||||
var list = sources.Where(i => i != null && !string.IsNullOrWhiteSpace(i.url)).ToList();
|
||||
|
||||
return mode switch
|
||||
{
|
||||
Best => list
|
||||
.OrderByDescending(GetBitrate)
|
||||
.ThenByDescending(IsMp4Like)
|
||||
.ToList(),
|
||||
|
||||
Low => list
|
||||
.OrderBy(GetBitrate)
|
||||
.ThenByDescending(IsMp4Like)
|
||||
.ToList(),
|
||||
|
||||
PreferM4a => list
|
||||
.OrderByDescending(IsMp4Like)
|
||||
.ThenByDescending(GetBitrate)
|
||||
.ToList(),
|
||||
|
||||
PreferWebm => list
|
||||
.OrderByDescending(IsWebmLike)
|
||||
.ThenByDescending(GetBitrate)
|
||||
.ToList(),
|
||||
|
||||
_ => list
|
||||
.OrderByDescending(IsMp4Like)
|
||||
.ThenByDescending(GetBitrate)
|
||||
.ToList()
|
||||
};
|
||||
}
|
||||
|
||||
static int GetBitrate(MusicPlaybackSource source)
|
||||
=> source?.bitrate ?? 0;
|
||||
|
||||
static bool IsMp4Like(MusicPlaybackSource source)
|
||||
{
|
||||
string mime = source?.mime_type?.ToLowerInvariant() ?? string.Empty;
|
||||
string quality = source?.quality?.ToLowerInvariant() ?? string.Empty;
|
||||
return mime.Contains("mp4") || quality.Contains("m4a") || quality.Contains("mp4");
|
||||
}
|
||||
|
||||
static bool IsWebmLike(MusicPlaybackSource source)
|
||||
{
|
||||
string mime = source?.mime_type?.ToLowerInvariant() ?? string.Empty;
|
||||
string quality = source?.quality?.ToLowerInvariant() ?? string.Empty;
|
||||
return mime.Contains("webm") || quality.Contains("webm");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
namespace Music;
|
||||
|
||||
public static class MusicStreamRelayService
|
||||
{
|
||||
static readonly HttpClient musicStreamClient = CreateMusicStreamClient();
|
||||
static readonly System.Collections.Concurrent.ConcurrentDictionary<string, ProxyClientEntry> proxyClients = new();
|
||||
static readonly TimeSpan proxyClientIdleTtl = TimeSpan.FromMinutes(30);
|
||||
const int maxProxyClients = 128;
|
||||
static readonly Timer proxyClientCleanupTimer = new(
|
||||
_ => CleanupProxyClients(),
|
||||
null,
|
||||
TimeSpan.FromMinutes(5),
|
||||
TimeSpan.FromMinutes(5)
|
||||
);
|
||||
|
||||
sealed class ProxyClientEntry
|
||||
{
|
||||
public ProxyClientEntry(HttpClient client)
|
||||
{
|
||||
Client = client;
|
||||
LastUsedUtc = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public readonly object Sync = new();
|
||||
public HttpClient Client { get; }
|
||||
public DateTime LastUsedUtc { get; set; }
|
||||
public int ActiveRequests { get; set; }
|
||||
public bool Retired { get; set; }
|
||||
}
|
||||
|
||||
sealed class ClientLease : IDisposable
|
||||
{
|
||||
ProxyClientEntry entry;
|
||||
|
||||
public ClientLease(HttpClient client, ProxyClientEntry entry = null)
|
||||
{
|
||||
Client = client;
|
||||
this.entry = entry;
|
||||
}
|
||||
|
||||
public HttpClient Client { get; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
var current = Interlocked.Exchange(ref entry, null);
|
||||
if (current == null)
|
||||
return;
|
||||
|
||||
lock (current.Sync)
|
||||
{
|
||||
current.ActiveRequests = Math.Max(0, current.ActiveRequests - 1);
|
||||
current.LastUsedUtc = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// failOnUpstreamError: не транслировать клиенту мёртвую ссылку или ошибку
|
||||
// прокси, а вернуть null — вызывающий пере-резолвит источник и маршрут.
|
||||
public static async Task<ActionResult> RelayAsync(HttpContext httpContext, MusicPlaybackSource source, bool failOnUpstreamError = false)
|
||||
{
|
||||
if (httpContext == null || source == null || string.IsNullOrWhiteSpace(source.url))
|
||||
return failOnUpstreamError ? null : new StatusCodeResult(404);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, source.url);
|
||||
|
||||
if (source.headers != null)
|
||||
{
|
||||
foreach (var header in source.headers)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(header.Key) || string.IsNullOrWhiteSpace(header.Value))
|
||||
continue;
|
||||
|
||||
request.Headers.TryAddWithoutValidation(header.Key, header.Value);
|
||||
}
|
||||
}
|
||||
|
||||
if (httpContext.Request.Headers.TryGetValue("Range", out var range) && !string.IsNullOrWhiteSpace(range.ToString()))
|
||||
request.Headers.TryAddWithoutValidation("Range", range.ToString());
|
||||
|
||||
using var clientLease = AcquireClient(source);
|
||||
HttpClient client = clientLease.Client;
|
||||
HttpResponseMessage response;
|
||||
|
||||
try
|
||||
{
|
||||
response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, httpContext.RequestAborted);
|
||||
}
|
||||
catch (OperationCanceledException) when (httpContext.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
return new EmptyResult();
|
||||
}
|
||||
catch (Exception ex) when (failOnUpstreamError)
|
||||
{
|
||||
ReportProxyFailure(source, ex);
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ReportProxyFailure(source, ex);
|
||||
throw;
|
||||
}
|
||||
|
||||
using (response)
|
||||
{
|
||||
bool proxyFailure = MusicHttp.IsProxyFailureStatus(response.StatusCode);
|
||||
if (proxyFailure)
|
||||
MusicProxyService.ReportFailure(source.proxy_scope);
|
||||
else
|
||||
MusicProxyService.ReportSuccess(source.proxy_scope);
|
||||
|
||||
if (failOnUpstreamError && (IsDeadUpstreamStatus(response.StatusCode) || proxyFailure))
|
||||
return null;
|
||||
|
||||
httpContext.Response.StatusCode = (int)response.StatusCode;
|
||||
httpContext.Response.Headers.Remove("transfer-encoding");
|
||||
|
||||
CopyResponseHeader(httpContext, "Accept-Ranges", response.Headers, response.Content.Headers);
|
||||
CopyResponseHeader(httpContext, "Content-Range", response.Headers, response.Content.Headers);
|
||||
CopyResponseHeader(httpContext, "Content-Length", response.Headers, response.Content.Headers);
|
||||
CopyResponseHeader(httpContext, "Content-Type", response.Headers, response.Content.Headers);
|
||||
CopyResponseHeader(httpContext, "Cache-Control", response.Headers, response.Content.Headers);
|
||||
CopyResponseHeader(httpContext, "Expires", response.Headers, response.Content.Headers);
|
||||
CopyResponseHeader(httpContext, "Last-Modified", response.Headers, response.Content.Headers);
|
||||
CopyResponseHeader(httpContext, "ETag", response.Headers, response.Content.Headers);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(source.mime_type))
|
||||
httpContext.Response.ContentType = source.mime_type;
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(httpContext.RequestAborted);
|
||||
await stream.CopyToAsync(httpContext.Response.Body, httpContext.RequestAborted);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// клиент ушёл (перемотка/стоп/смена трека) — штатный путь
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// upstream оборвал середину стрима: рвём соединение, чтобы клиент
|
||||
// сделал range-retry, а не принял обрыв за конец файла
|
||||
httpContext.Abort();
|
||||
}
|
||||
|
||||
return new EmptyResult();
|
||||
}
|
||||
}
|
||||
|
||||
static bool IsDeadUpstreamStatus(HttpStatusCode statusCode)
|
||||
=> statusCode is HttpStatusCode.Forbidden
|
||||
or HttpStatusCode.NotFound
|
||||
or HttpStatusCode.Gone
|
||||
or HttpStatusCode.RequestedRangeNotSatisfiable;
|
||||
|
||||
static void ReportProxyFailure(MusicPlaybackSource source, Exception exception)
|
||||
{
|
||||
if (MusicHttp.IsProxyFailure(exception))
|
||||
MusicProxyService.ReportFailure(source?.proxy_scope);
|
||||
}
|
||||
|
||||
static ClientLease AcquireClient(MusicPlaybackSource source)
|
||||
{
|
||||
var proxy = CreateMusicStreamProxy(source);
|
||||
if (proxy == null)
|
||||
return new ClientLease(musicStreamClient);
|
||||
|
||||
// клиент на прокси кэшируется: без этого каждый range-запрос платит
|
||||
// новый TLS-хендшейк через прокси. Lease не даёт очистке закрыть
|
||||
// HttpClient, пока через него идёт активный stream/range-запрос.
|
||||
string key = $"{source.proxy_url}|{source.proxy_username}|{source.proxy_password}";
|
||||
|
||||
while (true)
|
||||
{
|
||||
var entry = proxyClients.GetOrAdd(key, _ => new ProxyClientEntry(CreateProxyClient(proxy)));
|
||||
|
||||
lock (entry.Sync)
|
||||
{
|
||||
if (entry.Retired)
|
||||
continue;
|
||||
|
||||
entry.ActiveRequests++;
|
||||
entry.LastUsedUtc = DateTime.UtcNow;
|
||||
return new ClientLease(entry.Client, entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static HttpClient CreateProxyClient(WebProxy proxy)
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
AllowAutoRedirect = true,
|
||||
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli,
|
||||
Proxy = proxy,
|
||||
UseProxy = true
|
||||
};
|
||||
|
||||
handler.ServerCertificateCustomValidationCallback += (_, _, _, _) => true;
|
||||
|
||||
return new HttpClient(handler)
|
||||
{
|
||||
Timeout = Timeout.InfiniteTimeSpan
|
||||
};
|
||||
}
|
||||
|
||||
static void CleanupProxyClients()
|
||||
{
|
||||
try
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var item in proxyClients.ToArray().OrderBy(i => i.Value.LastUsedUtc))
|
||||
{
|
||||
bool overLimit = proxyClients.Count > maxProxyClients;
|
||||
bool expired = now - item.Value.LastUsedUtc >= proxyClientIdleTtl;
|
||||
if (!overLimit && !expired)
|
||||
continue;
|
||||
|
||||
HttpClient retiredClient = null;
|
||||
lock (item.Value.Sync)
|
||||
{
|
||||
if (item.Value.Retired || item.Value.ActiveRequests > 0)
|
||||
continue;
|
||||
|
||||
if (proxyClients.TryRemove(item.Key, out var removed) && ReferenceEquals(removed, item.Value))
|
||||
{
|
||||
item.Value.Retired = true;
|
||||
retiredClient = item.Value.Client;
|
||||
}
|
||||
}
|
||||
|
||||
retiredClient?.Dispose();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Очистка кэша не должна влиять на активные стримы.
|
||||
}
|
||||
}
|
||||
|
||||
static HttpClient CreateMusicStreamClient()
|
||||
{
|
||||
var handler = new HttpClientHandler
|
||||
{
|
||||
AllowAutoRedirect = true,
|
||||
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli
|
||||
};
|
||||
|
||||
handler.ServerCertificateCustomValidationCallback += (_, _, _, _) => true;
|
||||
|
||||
return new HttpClient(handler)
|
||||
{
|
||||
Timeout = Timeout.InfiniteTimeSpan
|
||||
};
|
||||
}
|
||||
|
||||
static void CopyResponseHeader(HttpContext httpContext, string headerName, HttpResponseHeaders responseHeaders, HttpContentHeaders contentHeaders)
|
||||
{
|
||||
if (responseHeaders.TryGetValues(headerName, out var headerValues) || contentHeaders.TryGetValues(headerName, out headerValues))
|
||||
httpContext.Response.Headers[headerName] = headerValues.ToArray();
|
||||
}
|
||||
|
||||
static WebProxy CreateMusicStreamProxy(MusicPlaybackSource source)
|
||||
{
|
||||
if (source == null || string.IsNullOrWhiteSpace(source.proxy_url))
|
||||
return null;
|
||||
|
||||
if (!Uri.TryCreate(source.proxy_url, UriKind.Absolute, out var proxyUri))
|
||||
return null;
|
||||
|
||||
NetworkCredential credentials = null;
|
||||
if (!string.IsNullOrWhiteSpace(source.proxy_username))
|
||||
credentials = new NetworkCredential(source.proxy_username, source.proxy_password ?? string.Empty);
|
||||
|
||||
return new WebProxy(proxyUri, false, null, credentials);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace Music;
|
||||
|
||||
public static class MusicStreamTicketService
|
||||
{
|
||||
sealed class TicketEntry
|
||||
{
|
||||
public MusicPlaybackSource source;
|
||||
// Interlocked по long: touch идёт из параллельных range-запросов без lock
|
||||
public long lastAccessTicks;
|
||||
public DateTime absoluteExpireUtc;
|
||||
}
|
||||
|
||||
// пауза на локскрине может длиться долго, поэтому тикет продлевается на каждом
|
||||
// обращении (sliding) вместо жёстких 30 минут; absolute cap ограничен временем
|
||||
// жизни upstream-ссылок (googlevideo и т.п.)
|
||||
static readonly TimeSpan slidingLifetime = TimeSpan.FromHours(2);
|
||||
static readonly TimeSpan absoluteLifetime = TimeSpan.FromHours(6);
|
||||
|
||||
// кап жёсткий. Create() обязан вернуть живой тикет, поэтому вытеснение самых
|
||||
// старых неактивных записей выполняется ДО вставки под тем же lock:
|
||||
// MemoryCache.SizeLimit так не умеет — мог бы не сохранить запись, хотя билет
|
||||
// уже ушёл клиенту (тогда /music/stream платил бы лишним re-resolve)
|
||||
const int maxTickets = 2048;
|
||||
static readonly object writeLock = new();
|
||||
static readonly ConcurrentDictionary<string, TicketEntry> tickets = new(StringComparer.Ordinal);
|
||||
|
||||
public static string Create(MusicPlaybackSource source)
|
||||
{
|
||||
if (source == null || string.IsNullOrWhiteSpace(source.url))
|
||||
return null;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
string ticket = Guid.NewGuid().ToString("N");
|
||||
|
||||
lock (writeLock)
|
||||
{
|
||||
if (tickets.Count >= maxTickets)
|
||||
TrimNoLock(now);
|
||||
|
||||
tickets[ticket] = new TicketEntry
|
||||
{
|
||||
source = Clone(source),
|
||||
lastAccessTicks = now.Ticks,
|
||||
absoluteExpireUtc = now.Add(absoluteLifetime)
|
||||
};
|
||||
}
|
||||
|
||||
return ticket;
|
||||
}
|
||||
|
||||
public static bool TryGet(string ticket, out MusicPlaybackSource source)
|
||||
{
|
||||
source = null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(ticket))
|
||||
return false;
|
||||
|
||||
if (!tickets.TryGetValue(ticket, out var entry) || entry == null)
|
||||
return false;
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
if (now > entry.absoluteExpireUtc || now.Ticks - Interlocked.Read(ref entry.lastAccessTicks) > slidingLifetime.Ticks)
|
||||
{
|
||||
// remove-if-same: не задеваем запись, если под этим ключом что-то пересоздали
|
||||
tickets.TryRemove(new KeyValuePair<string, TicketEntry>(ticket, entry));
|
||||
return false;
|
||||
}
|
||||
|
||||
// sliding touch
|
||||
Interlocked.Exchange(ref entry.lastAccessTicks, now.Ticks);
|
||||
|
||||
source = Clone(entry.source);
|
||||
return true;
|
||||
}
|
||||
|
||||
static void TrimNoLock(DateTime now)
|
||||
{
|
||||
long slidingCutoffTicks = now.Ticks - slidingLifetime.Ticks;
|
||||
|
||||
foreach (var item in tickets)
|
||||
{
|
||||
if (now > item.Value.absoluteExpireUtc || Interlocked.Read(ref item.Value.lastAccessTicks) < slidingCutoffTicks)
|
||||
tickets.TryRemove(item.Key, out _);
|
||||
}
|
||||
|
||||
while (tickets.Count >= maxTickets)
|
||||
{
|
||||
string oldestKey = null;
|
||||
long oldestTicks = long.MaxValue;
|
||||
|
||||
foreach (var item in tickets)
|
||||
{
|
||||
long ticks = Interlocked.Read(ref item.Value.lastAccessTicks);
|
||||
if (ticks < oldestTicks)
|
||||
{
|
||||
oldestTicks = ticks;
|
||||
oldestKey = item.Key;
|
||||
}
|
||||
}
|
||||
|
||||
if (oldestKey == null || !tickets.TryRemove(oldestKey, out _))
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
static MusicPlaybackSource Clone(MusicPlaybackSource source)
|
||||
{
|
||||
return 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?.ToDictionary(k => k.Key, v => v.Value) ?? new Dictionary<string, string>(),
|
||||
proxy_url = source.proxy_url,
|
||||
proxy_username = source.proxy_username,
|
||||
proxy_password = source.proxy_password,
|
||||
proxy_scope = source.proxy_scope
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace Music;
|
||||
|
||||
// Общие хелперы импортёров плейлистов (SoundCloud / Spotify / Apple Music).
|
||||
//
|
||||
// Контракт импорта: результат либо ПОЛНЫЙ, либо available=false с message —
|
||||
// частично скачанный список сохранять нельзя (sync перезаписывает payload
|
||||
// целиком, усечёнка молча потеряла бы треки пользователя). Единственное
|
||||
// допустимое усечение — явный кап MaxImportTracks с truncated=true.
|
||||
public static class MusicPlaylistImportHelpers
|
||||
{
|
||||
public static MusicUserPlaylistImportResult ImportUnavailable(string message)
|
||||
=> new()
|
||||
{
|
||||
available = false,
|
||||
message = message,
|
||||
tracks = new List<MusicTrack>()
|
||||
};
|
||||
|
||||
// дедуп по id с перенумерацией track_number по итоговому порядку
|
||||
public static List<MusicTrack> DeduplicateTracks(IEnumerable<MusicTrack> tracks)
|
||||
{
|
||||
var result = new List<MusicTrack>();
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var track in tracks ?? Enumerable.Empty<MusicTrack>())
|
||||
{
|
||||
if (track == null || string.IsNullOrWhiteSpace(track.id) || !seen.Add(track.id))
|
||||
continue;
|
||||
|
||||
track.track_number = result.Count + 1;
|
||||
result.Add(track);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static JsonElement? GetProperty(JsonElement element, params string[] path)
|
||||
{
|
||||
var current = element;
|
||||
foreach (var name in path)
|
||||
{
|
||||
if (current.ValueKind != JsonValueKind.Object || !current.TryGetProperty(name, out var next) || next.ValueKind == JsonValueKind.Null)
|
||||
return null;
|
||||
|
||||
current = next;
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
public static JsonElement? GetProperty(JsonElement? element, params string[] path)
|
||||
=> element == null ? null : GetProperty(element.Value, path);
|
||||
|
||||
public static string GetString(JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(propertyName, out var property))
|
||||
return null;
|
||||
|
||||
return property.ValueKind == JsonValueKind.String ? property.GetString() : null;
|
||||
}
|
||||
|
||||
public static int? GetInt(JsonElement element, string propertyName)
|
||||
{
|
||||
if (element.ValueKind != JsonValueKind.Object || !element.TryGetProperty(propertyName, out var property))
|
||||
return null;
|
||||
|
||||
if (property.ValueKind == JsonValueKind.Number && property.TryGetInt32(out int value))
|
||||
return value;
|
||||
|
||||
if (property.ValueKind == JsonValueKind.String && int.TryParse(property.GetString(), out int parsed))
|
||||
return parsed;
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,637 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Music;
|
||||
|
||||
// пользовательские плейлисты — durable state (намерение пользователя),
|
||||
// скоуп per-profile как у playback_history; payload = JSON-массив MusicTrack,
|
||||
// плейлист самодостаточен и не требует метадата-лукапов при открытии
|
||||
public static class MusicUserPlaylistService
|
||||
{
|
||||
public static async Task<List<MusicUserPlaylistSummary>> ListAsync(string profileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
profileId = NormalizeProfile(profileId);
|
||||
var result = new List<MusicUserPlaylistSummary>();
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT playlist_id, title, payload, source
|
||||
FROM user_playlists
|
||||
WHERE profile_id = $profile_id
|
||||
ORDER BY updated DESC;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$profile_id", profileId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
var tracks = ParseTracks(reader.IsDBNull(2) ? null : reader.GetString(2));
|
||||
result.Add(BuildSummary(
|
||||
reader.GetString(0),
|
||||
reader.GetString(1),
|
||||
tracks,
|
||||
ParseSource(reader.IsDBNull(3) ? null : reader.GetString(3))
|
||||
));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Music] playlists list failed: {ex.Message}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static async Task<MusicUserPlaylistSummary> GetSummaryAsync(string profileId, string playlistId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
playlistId = playlistId?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(playlistId))
|
||||
return null;
|
||||
|
||||
profileId = NormalizeProfile(profileId);
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT playlist_id, title, payload, source
|
||||
FROM user_playlists
|
||||
WHERE profile_id = $profile_id AND playlist_id = $playlist_id
|
||||
LIMIT 1;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$profile_id", profileId);
|
||||
command.Parameters.AddWithValue("$playlist_id", playlistId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
if (!await reader.ReadAsync(cancellationToken))
|
||||
return null;
|
||||
|
||||
var tracks = ParseTracks(reader.IsDBNull(2) ? null : reader.GetString(2));
|
||||
return BuildSummary(
|
||||
reader.GetString(0),
|
||||
reader.GetString(1),
|
||||
tracks,
|
||||
ParseSource(reader.IsDBNull(3) ? null : reader.GetString(3))
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Music] playlist summary failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<List<MusicTrack>> GetTracksAsync(string profileId, string playlistId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
string payload = await ReadPayloadAsync(NormalizeProfile(profileId), playlistId?.Trim(), cancellationToken);
|
||||
return ParseTracks(payload);
|
||||
}
|
||||
|
||||
// треки ВСЕХ плейлистов профиля (свежие плейлисты первыми) — источник
|
||||
// артистов для «Миксов недели»: у пользователя без истории, но с
|
||||
// импортированным плейлистом, миксы собираются из его артистов
|
||||
public static async Task<List<MusicTrack>> GetAllTracksAsync(string profileId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
profileId = NormalizeProfile(profileId);
|
||||
var result = new List<MusicTrack>();
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT payload
|
||||
FROM user_playlists
|
||||
WHERE profile_id = $profile_id
|
||||
ORDER BY updated DESC;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$profile_id", profileId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
result.AddRange(ParseTracks(reader.IsDBNull(0) ? null : reader.GetString(0)));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Music] playlists all tracks failed: {ex.Message}");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static async Task<string> CreateAsync(string profileId, string title, CancellationToken cancellationToken = default)
|
||||
{
|
||||
title = title?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
return null;
|
||||
|
||||
profileId = NormalizeProfile(profileId);
|
||||
string playlistId = Guid.NewGuid().ToString("N");
|
||||
|
||||
bool saved = await WriteAsync(async connection =>
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
INSERT INTO user_playlists (profile_id, playlist_id, title, payload, updated)
|
||||
VALUES ($profile_id, $playlist_id, $title, '[]', $updated);
|
||||
""";
|
||||
command.Parameters.AddWithValue("$profile_id", profileId);
|
||||
command.Parameters.AddWithValue("$playlist_id", playlistId);
|
||||
command.Parameters.AddWithValue("$title", title);
|
||||
command.Parameters.AddWithValue("$updated", DateTime.UtcNow);
|
||||
return await command.ExecuteNonQueryAsync(cancellationToken) > 0;
|
||||
}, cancellationToken);
|
||||
|
||||
return saved ? playlistId : null;
|
||||
}
|
||||
|
||||
// импорт по ссылке: сервис определяется хостом (Spotify / Apple Music / SoundCloud),
|
||||
// SoundCloud остаётся веткой по умолчанию (короткие on.soundcloud.com ссылки)
|
||||
static Task<MusicUserPlaylistImportResult> ImportByUrlAsync(string url, CancellationToken cancellationToken)
|
||||
{
|
||||
if (SpotifySupport.CanHandleUrl(url))
|
||||
return SpotifySupport.ImportPlaylistAsync(url, cancellationToken);
|
||||
|
||||
if (AppleMusicSupport.CanHandleUrl(url))
|
||||
return AppleMusicSupport.ImportPlaylistAsync(url, cancellationToken);
|
||||
|
||||
return SoundCloudSupport.ImportPlaylistAsync(url, cancellationToken);
|
||||
}
|
||||
|
||||
static Task<MusicUserPlaylistImportResult> ImportBySourceAsync(MusicUserPlaylistSource source, CancellationToken cancellationToken)
|
||||
{
|
||||
if (IsSpotifySource(source))
|
||||
return SpotifySupport.ImportPlaylistAsync(source, cancellationToken);
|
||||
|
||||
if (IsAppleMusicSource(source))
|
||||
return AppleMusicSupport.ImportPlaylistAsync(source, cancellationToken);
|
||||
|
||||
return SoundCloudSupport.ImportPlaylistAsync(source, cancellationToken);
|
||||
}
|
||||
|
||||
public static async Task<MusicUserPlaylistImportResult> ImportAsync(string profileId, string url, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var imported = await ImportByUrlAsync(url, cancellationToken);
|
||||
if (imported?.available != true || imported.tracks == null || imported.tracks.Count == 0 || imported.source == null)
|
||||
return imported ?? new MusicUserPlaylistImportResult { available = false, message = "Импорт не удался." };
|
||||
|
||||
profileId = NormalizeProfile(profileId);
|
||||
imported.title = string.IsNullOrWhiteSpace(imported.title) ? "Импорт" : imported.title.Trim();
|
||||
imported.track_count = imported.tracks.Count;
|
||||
|
||||
string savedPlaylistId = null;
|
||||
bool saved = await WriteAsync(async connection =>
|
||||
{
|
||||
savedPlaylistId = await FindSourcePlaylistIdAsync(connection, profileId, imported.source, cancellationToken)
|
||||
?? Guid.NewGuid().ToString("N");
|
||||
|
||||
return await UpsertPlaylistAsync(connection, profileId, savedPlaylistId, imported.title, imported.tracks, imported.source, cancellationToken) > 0;
|
||||
}, cancellationToken);
|
||||
|
||||
if (!saved)
|
||||
{
|
||||
imported.available = false;
|
||||
imported.message = "Не удалось сохранить импортированный плейлист.";
|
||||
return imported;
|
||||
}
|
||||
|
||||
imported.playlist_id = savedPlaylistId;
|
||||
return imported;
|
||||
}
|
||||
|
||||
public static async Task<MusicUserPlaylistImportResult> SyncAsync(string profileId, string playlistId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
playlistId = playlistId?.Trim();
|
||||
profileId = NormalizeProfile(profileId);
|
||||
if (string.IsNullOrWhiteSpace(playlistId))
|
||||
return new MusicUserPlaylistImportResult { available = false, message = "Плейлист не выбран." };
|
||||
|
||||
var source = await GetSourceAsync(profileId, playlistId, cancellationToken);
|
||||
if (source == null || !IsSyncableSource(source))
|
||||
return new MusicUserPlaylistImportResult { available = false, playlist_id = playlistId, message = "У плейлиста нет источника для обновления." };
|
||||
|
||||
var imported = await ImportBySourceAsync(source, cancellationToken);
|
||||
if (imported?.available != true || imported.tracks == null || imported.tracks.Count == 0)
|
||||
return imported ?? new MusicUserPlaylistImportResult { available = false, playlist_id = playlistId, message = "Обновление не удалось." };
|
||||
|
||||
imported.playlist_id = playlistId;
|
||||
imported.title = string.IsNullOrWhiteSpace(imported.title) ? source.title ?? "Импорт" : imported.title.Trim();
|
||||
imported.track_count = imported.tracks.Count;
|
||||
imported.source ??= source;
|
||||
|
||||
bool saved = await WriteAsync(async connection =>
|
||||
{
|
||||
// sync — слияние, а не зеркало: локальный порядок и ручные правки
|
||||
// сохраняются, новые треки источника добавляются В НАЧАЛО
|
||||
string currentPayload = await ReadPayloadAsync(connection, profileId, playlistId, cancellationToken);
|
||||
if (currentPayload == null)
|
||||
return false; // Deleted while the source was being fetched.
|
||||
var merged = MergeSyncedTracks(ParseTracks(currentPayload), imported.tracks);
|
||||
|
||||
imported.tracks = merged;
|
||||
imported.track_count = merged.Count;
|
||||
|
||||
return await UpsertPlaylistAsync(connection, profileId, playlistId, imported.title, merged, imported.source, cancellationToken, updateOnly: true) > 0;
|
||||
}, cancellationToken);
|
||||
|
||||
if (!saved)
|
||||
{
|
||||
imported.available = false;
|
||||
imported.message = "Не удалось обновить плейлист.";
|
||||
}
|
||||
|
||||
return imported;
|
||||
}
|
||||
|
||||
static List<MusicTrack> MergeSyncedTracks(List<MusicTrack> local, List<MusicTrack> source)
|
||||
{
|
||||
source ??= new List<MusicTrack>();
|
||||
|
||||
if (local == null || local.Count == 0)
|
||||
return source;
|
||||
|
||||
var localIds = new HashSet<string>(
|
||||
local.Where(i => !string.IsNullOrWhiteSpace(i?.id)).Select(i => i.id),
|
||||
StringComparer.Ordinal);
|
||||
|
||||
// новинки источника — наверх, в порядке источника
|
||||
var fresh = source
|
||||
.Where(i => !string.IsNullOrWhiteSpace(i?.id) && !localIds.Contains(i.id))
|
||||
.ToList();
|
||||
|
||||
// существующие треки остаются в локальном порядке, но метаданные
|
||||
// обновляются свежими из источника (обложки, длительность и т.п.);
|
||||
// пропавшие из источника и добавленные вручную — не удаляются
|
||||
var sourceById = new Dictionary<string, MusicTrack>(StringComparer.Ordinal);
|
||||
foreach (var track in source)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(track?.id))
|
||||
sourceById.TryAdd(track.id, track);
|
||||
}
|
||||
|
||||
var merged = new List<MusicTrack>(fresh.Count + local.Count);
|
||||
merged.AddRange(fresh);
|
||||
|
||||
foreach (var track in local)
|
||||
{
|
||||
if (track == null)
|
||||
continue;
|
||||
|
||||
merged.Add(!string.IsNullOrWhiteSpace(track.id) && sourceById.TryGetValue(track.id, out var updated)
|
||||
? updated
|
||||
: track);
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
public static Task<bool> DeleteAsync(string profileId, string playlistId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
playlistId = playlistId?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(playlistId))
|
||||
return Task.FromResult(false);
|
||||
|
||||
profileId = NormalizeProfile(profileId);
|
||||
return WriteAsync(async connection =>
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
DELETE FROM user_playlists
|
||||
WHERE profile_id = $profile_id AND playlist_id = $playlist_id;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$profile_id", profileId);
|
||||
command.Parameters.AddWithValue("$playlist_id", playlistId);
|
||||
return await command.ExecuteNonQueryAsync(cancellationToken) > 0;
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public static async Task<bool> AddTrackAsync(string profileId, string playlistId, MusicTrack track, CancellationToken cancellationToken = default)
|
||||
{
|
||||
playlistId = playlistId?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(playlistId) || track == null || string.IsNullOrWhiteSpace(track.id) || string.IsNullOrWhiteSpace(track.title))
|
||||
return false;
|
||||
|
||||
profileId = NormalizeProfile(profileId);
|
||||
|
||||
return await WriteAsync(async connection =>
|
||||
{
|
||||
string payload = await ReadPayloadAsync(connection, profileId, playlistId, cancellationToken);
|
||||
if (payload == null)
|
||||
return false;
|
||||
|
||||
var tracks = ParseTracks(payload);
|
||||
if (tracks.Any(i => i != null && string.Equals(i.id, track.id, StringComparison.Ordinal)))
|
||||
return true;
|
||||
|
||||
tracks.Add(track);
|
||||
return await UpdatePayloadAsync(connection, profileId, playlistId, tracks, cancellationToken) > 0;
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public static async Task<bool> RemoveTrackAsync(string profileId, string playlistId, string trackId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
playlistId = playlistId?.Trim();
|
||||
trackId = trackId?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(playlistId) || string.IsNullOrWhiteSpace(trackId))
|
||||
return false;
|
||||
|
||||
profileId = NormalizeProfile(profileId);
|
||||
|
||||
return await WriteAsync(async connection =>
|
||||
{
|
||||
string payload = await ReadPayloadAsync(connection, profileId, playlistId, cancellationToken);
|
||||
if (payload == null)
|
||||
return false;
|
||||
|
||||
var tracks = ParseTracks(payload);
|
||||
var next = tracks.Where(i => i != null && !string.Equals(i.id, trackId, StringComparison.Ordinal)).ToList();
|
||||
|
||||
if (next.Count == tracks.Count)
|
||||
return false;
|
||||
|
||||
return await UpdatePayloadAsync(connection, profileId, playlistId, next, cancellationToken) > 0;
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
public static async Task<bool> MoveTrackAsync(string profileId, string playlistId, string trackId, int position, CancellationToken cancellationToken = default)
|
||||
{
|
||||
playlistId = playlistId?.Trim();
|
||||
trackId = trackId?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(playlistId) || string.IsNullOrWhiteSpace(trackId))
|
||||
return false;
|
||||
|
||||
profileId = NormalizeProfile(profileId);
|
||||
|
||||
return await WriteAsync(async connection =>
|
||||
{
|
||||
string payload = await ReadPayloadAsync(connection, profileId, playlistId, cancellationToken);
|
||||
if (payload == null)
|
||||
return false;
|
||||
|
||||
var tracks = ParseTracks(payload);
|
||||
int index = tracks.FindIndex(i => i != null && string.Equals(i.id, trackId, StringComparison.Ordinal));
|
||||
if (index < 0)
|
||||
return false;
|
||||
|
||||
int target = Math.Clamp(position, 0, tracks.Count - 1);
|
||||
if (target == index)
|
||||
return true;
|
||||
|
||||
var track = tracks[index];
|
||||
tracks.RemoveAt(index);
|
||||
tracks.Insert(target, track);
|
||||
|
||||
return await UpdatePayloadAsync(connection, profileId, playlistId, tracks, cancellationToken) > 0;
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
static async Task<int> UpdatePayloadAsync(SqliteConnection connection, string profileId, string playlistId, List<MusicTrack> tracks, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
UPDATE user_playlists
|
||||
SET payload = $payload, updated = $updated
|
||||
WHERE profile_id = $profile_id AND playlist_id = $playlist_id;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$profile_id", profileId);
|
||||
command.Parameters.AddWithValue("$playlist_id", playlistId);
|
||||
command.Parameters.AddWithValue("$payload", MusicJson.Serialize(tracks));
|
||||
command.Parameters.AddWithValue("$updated", DateTime.UtcNow);
|
||||
return await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
static async Task<int> UpsertPlaylistAsync(SqliteConnection connection, string profileId, string playlistId, string title, List<MusicTrack> tracks, MusicUserPlaylistSource source, CancellationToken cancellationToken, bool updateOnly = false)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = updateOnly ? """
|
||||
UPDATE user_playlists
|
||||
SET title = $title, payload = $payload, source = $source, updated = $updated
|
||||
WHERE profile_id = $profile_id AND playlist_id = $playlist_id;
|
||||
""" : """
|
||||
INSERT INTO user_playlists (profile_id, playlist_id, title, payload, source, updated)
|
||||
VALUES ($profile_id, $playlist_id, $title, $payload, $source, $updated)
|
||||
ON CONFLICT(profile_id, playlist_id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
payload = excluded.payload,
|
||||
source = excluded.source,
|
||||
updated = excluded.updated;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$profile_id", profileId);
|
||||
command.Parameters.AddWithValue("$playlist_id", playlistId);
|
||||
command.Parameters.AddWithValue("$title", title);
|
||||
command.Parameters.AddWithValue("$payload", MusicJson.Serialize(tracks ?? new List<MusicTrack>()));
|
||||
command.Parameters.AddWithValue("$source", SerializeSource(source));
|
||||
command.Parameters.AddWithValue("$updated", DateTime.UtcNow);
|
||||
return await command.ExecuteNonQueryAsync(cancellationToken);
|
||||
}
|
||||
|
||||
static async Task<string> ReadPayloadAsync(string profileId, string playlistId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(playlistId))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT payload
|
||||
FROM user_playlists
|
||||
WHERE profile_id = $profile_id AND playlist_id = $playlist_id
|
||||
LIMIT 1;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$profile_id", profileId);
|
||||
command.Parameters.AddWithValue("$playlist_id", playlistId);
|
||||
|
||||
return await command.ExecuteScalarAsync(cancellationToken) as string;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Music] playlist read failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<string> ReadPayloadAsync(SqliteConnection connection, string profileId, string playlistId, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT payload
|
||||
FROM user_playlists
|
||||
WHERE profile_id = $profile_id AND playlist_id = $playlist_id
|
||||
LIMIT 1;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$profile_id", profileId);
|
||||
command.Parameters.AddWithValue("$playlist_id", playlistId);
|
||||
|
||||
return await command.ExecuteScalarAsync(cancellationToken) as string;
|
||||
}
|
||||
|
||||
static async Task<MusicUserPlaylistSource> GetSourceAsync(string profileId, string playlistId, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT source
|
||||
FROM user_playlists
|
||||
WHERE profile_id = $profile_id AND playlist_id = $playlist_id
|
||||
LIMIT 1;
|
||||
""";
|
||||
command.Parameters.AddWithValue("$profile_id", profileId);
|
||||
command.Parameters.AddWithValue("$playlist_id", playlistId);
|
||||
|
||||
return ParseSource(await command.ExecuteScalarAsync(cancellationToken) as string);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Music] playlist source read failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<string> FindSourcePlaylistIdAsync(SqliteConnection connection, string profileId, MusicUserPlaylistSource source, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = """
|
||||
SELECT playlist_id, source
|
||||
FROM user_playlists
|
||||
WHERE profile_id = $profile_id AND source <> '';
|
||||
""";
|
||||
command.Parameters.AddWithValue("$profile_id", profileId);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken);
|
||||
while (await reader.ReadAsync(cancellationToken))
|
||||
{
|
||||
var existing = ParseSource(reader.IsDBNull(1) ? null : reader.GetString(1));
|
||||
if (IsSameSource(existing, source))
|
||||
return reader.GetString(0);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static async Task<bool> WriteAsync(Func<SqliteConnection, Task<bool>> operation, CancellationToken cancellationToken)
|
||||
{
|
||||
var semaphore = new SemaphorManager(MusicContext.semaphoreKey, TimeSpan.FromSeconds(20));
|
||||
bool acquired = false;
|
||||
|
||||
try
|
||||
{
|
||||
if (!await semaphore.WaitAsync())
|
||||
{
|
||||
Console.WriteLine("[Music] playlist write skipped: db semaphore timeout");
|
||||
return false;
|
||||
}
|
||||
acquired = true;
|
||||
|
||||
await using var connection = new SqliteConnection(MusicContext.ConnectionString);
|
||||
await connection.OpenAsync(cancellationToken);
|
||||
|
||||
return await operation(connection);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Music] playlist write failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (acquired)
|
||||
semaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
static List<MusicTrack> ParseTracks(string payload)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payload))
|
||||
return new List<MusicTrack>();
|
||||
|
||||
try
|
||||
{
|
||||
return MusicJson.Deserialize<List<MusicTrack>>(payload) ?? new List<MusicTrack>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new List<MusicTrack>();
|
||||
}
|
||||
}
|
||||
|
||||
static MusicUserPlaylistSummary BuildSummary(string playlistId, string title, List<MusicTrack> tracks, MusicUserPlaylistSource source)
|
||||
{
|
||||
tracks ??= new List<MusicTrack>();
|
||||
|
||||
return new MusicUserPlaylistSummary
|
||||
{
|
||||
id = playlistId,
|
||||
title = title,
|
||||
track_count = tracks.Count,
|
||||
images = tracks.FirstOrDefault(i => i?.images != null && i.images.Count > 0)?.images ?? new List<MusicImage>(),
|
||||
source = source
|
||||
};
|
||||
}
|
||||
|
||||
static MusicUserPlaylistSource ParseSource(string payload)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(payload))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return MusicJson.Deserialize<MusicUserPlaylistSource>(payload);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static string SerializeSource(MusicUserPlaylistSource source)
|
||||
=> source == null || string.IsNullOrWhiteSpace(source.type) ? string.Empty : MusicJson.Serialize(source);
|
||||
|
||||
static bool IsSoundCloudSource(MusicUserPlaylistSource source)
|
||||
=> source != null && !string.IsNullOrWhiteSpace(source.type) && source.type.StartsWith("soundcloud_", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
static bool IsSpotifySource(MusicUserPlaylistSource source)
|
||||
=> source != null && !string.IsNullOrWhiteSpace(source.type) && source.type.StartsWith("spotify_", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
static bool IsAppleMusicSource(MusicUserPlaylistSource source)
|
||||
=> source != null && !string.IsNullOrWhiteSpace(source.type) && source.type.StartsWith("applemusic_", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
static bool IsSyncableSource(MusicUserPlaylistSource source)
|
||||
=> IsSoundCloudSource(source) || IsSpotifySource(source) || IsAppleMusicSource(source);
|
||||
|
||||
static bool IsSameSource(MusicUserPlaylistSource left, MusicUserPlaylistSource right)
|
||||
{
|
||||
if (left == null || right == null)
|
||||
return false;
|
||||
|
||||
if (!string.Equals(left.type, right.type, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(left.user_id) || !string.IsNullOrWhiteSpace(right.user_id))
|
||||
return string.Equals(left.user_id, right.user_id, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(left.playlist_id) || !string.IsNullOrWhiteSpace(right.playlist_id))
|
||||
return string.Equals(left.playlist_id, right.playlist_id, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
return string.Equals(left.url, right.url, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
static string NormalizeProfile(string profileId)
|
||||
=> string.IsNullOrWhiteSpace(profileId) ? "global" : profileId.Trim().ToLowerInvariant();
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Music;
|
||||
|
||||
public static class MusicJson
|
||||
{
|
||||
static readonly JsonSerializerOptions options = new()
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public static string Serialize<T>(T value) => JsonSerializer.Serialize(value, options);
|
||||
|
||||
public static T Deserialize<T>(string value) => JsonSerializer.Deserialize<T>(value, options);
|
||||
}
|
||||
Reference in New Issue
Block a user