Исправление: том ./mods:/lampac/mods в compose и копии 10 модулей с enable:true; конфиг без SkipModules

This commit is contained in:
2026-10-02 12:11:49 +03:00
parent 8dcbc6bc52
commit 7b8e28b3bf
151 changed files with 68625 additions and 44 deletions
@@ -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;
}
}
+121
View File
@@ -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();
}
+17
View File
@@ -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);
}