Files
lampac/mods/Music/Services/Playback/MusicPlaylistService.cs
T

328 lines
12 KiB
C#

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; }
}
}