Исправление: том ./mods:/lampac/mods в compose и копии 10 модулей с enable:true; конфиг без SkipModules
This commit is contained in:
@@ -0,0 +1,481 @@
|
||||
using System.Net.Http;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using static Music.MusicPlaylistImportHelpers;
|
||||
|
||||
namespace Music;
|
||||
|
||||
// Импорт публичных плейлистов/альбомов Apple Music БЕЗ ключей и логина:
|
||||
// анонимный developer-JWT из JS-бандла music.apple.com (живёт ~месяц)
|
||||
// + amp-api catalog — тот же путь, что у веб-плеера. Стримов нет (DRM):
|
||||
// треки приезжают метаданными, аудио резолвит обычный конвейер (YouTube-матчер).
|
||||
public static class AppleMusicSupport
|
||||
{
|
||||
public const string ProviderId = "applemusic";
|
||||
public const string PlaylistSourceType = "applemusic_playlist";
|
||||
public const string AlbumSourceType = "applemusic_album";
|
||||
|
||||
const string ApiBaseUrl = "https://amp-api.music.apple.com";
|
||||
const int PageLimit = 100;
|
||||
const int MaxImportTracks = 2000;
|
||||
const int MaxPages = 30;
|
||||
const string BrowserUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36";
|
||||
|
||||
static readonly HttpClient httpClient = MusicHttp.CreateClient("applemusic");
|
||||
// storefront (us/ru/ua/...), тип, id: pl.xxx / pl.u-xxx у плейлистов, цифры у альбомов
|
||||
static readonly Regex entityUrlRegex = new(@"^https?://music\.apple\.com/([a-z]{2})/(playlist|album)/(?:[^/]+/)?(pl\.[A-Za-z0-9\.\-]+|\d+)", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
static readonly Regex catalogAlbumIdRegex = new(@"^applemusic:album:([a-z]{2}):(\d+)$", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
static readonly Regex assetBundleRegex = new("src=\"(/assets/index~[^\"]+\\.js)\"", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
static readonly Regex developerTokenRegex = new("\"(eyJ0[A-Za-z0-9_\\-]+\\.[A-Za-z0-9_\\-]+\\.[A-Za-z0-9_\\-]+)\"", RegexOptions.Compiled);
|
||||
static readonly SemaphoreSlim developerTokenLock = new(1, 1);
|
||||
static string developerToken;
|
||||
static DateTime developerTokenExpiresAt;
|
||||
|
||||
static AppleMusicSupport()
|
||||
{
|
||||
httpClient.Timeout = TimeSpan.FromSeconds(20);
|
||||
}
|
||||
|
||||
public static bool CanHandleUrl(string url) => IsAppleMusicUrl(url);
|
||||
|
||||
public static bool IsCatalogAlbum(string provider, string id)
|
||||
=> string.Equals(provider, ProviderId, StringComparison.OrdinalIgnoreCase)
|
||||
|| catalogAlbumIdRegex.IsMatch(id ?? string.Empty);
|
||||
|
||||
public static Task<MusicAlbum> GetCatalogAlbumAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var match = catalogAlbumIdRegex.Match(id ?? string.Empty);
|
||||
return match.Success
|
||||
? GetCatalogAlbumAsync(match.Groups[1].Value, match.Groups[2].Value, cancellationToken)
|
||||
: Task.FromResult<MusicAlbum>(null);
|
||||
}
|
||||
|
||||
public static async Task<MusicAlbum> GetCatalogAlbumAsync(string storefront, string albumId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
return (await LoadCatalogAlbumAsync(storefront, albumId, cancellationToken)).album;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<MusicUserPlaylistImportResult> ImportPlaylistAsync(string inputUrl, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = ParseEntity(inputUrl);
|
||||
if (entity == null)
|
||||
{
|
||||
if (IsProfileUrl(inputUrl))
|
||||
return ImportUnavailable("Apple Music профиль не импортируется. Вставь ссылку на конкретный публичный плейлист или альбом.");
|
||||
|
||||
return ImportUnavailable("Вставь ссылку на Apple Music плейлист или альбом.");
|
||||
}
|
||||
|
||||
// прогрев кэша токена + дружелюбная ошибка, если Apple недоступен
|
||||
string token = await GetDeveloperTokenAsync(cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return ImportUnavailable("Не удалось получить Apple Music токен.");
|
||||
|
||||
try
|
||||
{
|
||||
return entity.Value.type == "album"
|
||||
? await ImportAlbumAsync(entity.Value.storefront, entity.Value.id, cancellationToken)
|
||||
: await ImportPlaylistByIdAsync(entity.Value.storefront, entity.Value.id, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[Music] apple music import failed: {ex.Message}");
|
||||
return ImportUnavailable("Apple Music не ответил, попробуй ещё раз.");
|
||||
}
|
||||
}
|
||||
|
||||
public static Task<MusicUserPlaylistImportResult> ImportPlaylistAsync(MusicUserPlaylistSource source, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (source == null || string.IsNullOrWhiteSpace(source.url))
|
||||
return Task.FromResult(ImportUnavailable("У плейлиста нет Apple Music источника."));
|
||||
|
||||
return ImportPlaylistAsync(source.url, cancellationToken);
|
||||
}
|
||||
|
||||
static (string storefront, string type, string id)? ParseEntity(string value)
|
||||
{
|
||||
value = value?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return null;
|
||||
|
||||
var match = entityUrlRegex.Match(value);
|
||||
if (!match.Success)
|
||||
return null;
|
||||
|
||||
string type = match.Groups[2].Value.ToLowerInvariant();
|
||||
string id = match.Groups[3].Value;
|
||||
|
||||
// альбомная ссылка с плейлистным id (и наоборот) — мусор, не берём
|
||||
bool playlistId = id.StartsWith("pl.", StringComparison.OrdinalIgnoreCase);
|
||||
if (type == "playlist" != playlistId)
|
||||
return null;
|
||||
|
||||
return (match.Groups[1].Value.ToLowerInvariant(), type, id);
|
||||
}
|
||||
|
||||
static bool IsAppleMusicUrl(string value)
|
||||
{
|
||||
if (!Uri.TryCreate(value?.Trim(), UriKind.Absolute, out var uri))
|
||||
return false;
|
||||
|
||||
return string.Equals(uri.Host, "music.apple.com", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
static bool IsProfileUrl(string value)
|
||||
{
|
||||
if (!Uri.TryCreate(value?.Trim(), UriKind.Absolute, out var uri))
|
||||
return false;
|
||||
|
||||
return string.Equals(uri.Host, "music.apple.com", StringComparison.OrdinalIgnoreCase)
|
||||
&& uri.AbsolutePath.StartsWith("/profile/", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
// Анонимный developer-JWT лежит в бандле /assets/index~*.js главной страницы;
|
||||
// Apple выпускает его на ~месяц — кэшируем сутки и сбрасываем на 401.
|
||||
static async Task<string> GetDeveloperTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(developerToken) && developerTokenExpiresAt > DateTime.UtcNow)
|
||||
return developerToken;
|
||||
|
||||
await developerTokenLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(developerToken) && developerTokenExpiresAt > DateTime.UtcNow)
|
||||
return developerToken;
|
||||
|
||||
string html = await FetchTextAsync("https://music.apple.com/us/browse", "text/html,application/xhtml+xml", cancellationToken);
|
||||
var bundleMatch = assetBundleRegex.Match(html ?? string.Empty);
|
||||
if (!bundleMatch.Success)
|
||||
return null;
|
||||
|
||||
string script = await FetchTextAsync("https://music.apple.com" + bundleMatch.Groups[1].Value, "application/javascript,*/*", cancellationToken);
|
||||
var tokenMatch = developerTokenRegex.Match(script ?? string.Empty);
|
||||
if (!tokenMatch.Success)
|
||||
return null;
|
||||
|
||||
developerToken = tokenMatch.Groups[1].Value;
|
||||
developerTokenExpiresAt = DateTime.UtcNow.AddHours(24);
|
||||
return developerToken;
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
developerTokenLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
static void InvalidateDeveloperToken()
|
||||
{
|
||||
developerToken = null;
|
||||
developerTokenExpiresAt = DateTime.MinValue;
|
||||
}
|
||||
|
||||
static async Task<string> FetchTextAsync(string url, string accept, CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent);
|
||||
request.Headers.TryAddWithoutValidation("Accept", accept);
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
return await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
}
|
||||
|
||||
// сам берёт токен из кэша; на 401 сбрасывает его и повторяет запрос
|
||||
// один раз со свежим — протухший developer-токен не роняет импорт/sync
|
||||
static async Task<JsonElement?> ApiAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
for (int attempt = 0; attempt < 2; attempt++)
|
||||
{
|
||||
string token = await GetDeveloperTokenAsync(cancellationToken);
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return null;
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, ApiBaseUrl + path);
|
||||
request.Headers.TryAddWithoutValidation("Authorization", $"Bearer {token}");
|
||||
request.Headers.TryAddWithoutValidation("Origin", "https://music.apple.com");
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", BrowserUserAgent);
|
||||
request.Headers.TryAddWithoutValidation("Accept", "application/json");
|
||||
|
||||
using var response = await httpClient.SendAsync(request, cancellationToken);
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
||||
{
|
||||
InvalidateDeveloperToken();
|
||||
Console.WriteLine("[Music] apple music developer token expired");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return null;
|
||||
|
||||
string json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
using var document = JsonDocument.Parse(json);
|
||||
return document.RootElement.Clone();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
static async Task<MusicUserPlaylistImportResult> ImportPlaylistByIdAsync(string storefront, string playlistId, CancellationToken cancellationToken)
|
||||
{
|
||||
// мета плейлиста ради имени; треки из relationships не берём —
|
||||
// единообразно листаем /tracks с нуля (одна лишняя страница, зато один путь)
|
||||
var meta = await ApiAsync($"/v1/catalog/{storefront}/playlists/{Uri.EscapeDataString(playlistId)}", cancellationToken);
|
||||
var playlist = FirstDataElement(meta);
|
||||
if (playlist == null)
|
||||
return ImportUnavailable("Apple Music плейлист не найден или приватный.");
|
||||
|
||||
string title = GetString(GetProperty(playlist.Value, "attributes") ?? default, "name");
|
||||
|
||||
var tracks = new List<MusicTrack>();
|
||||
for (int page = 0; page < MaxPages && tracks.Count < MaxImportTracks; page++)
|
||||
{
|
||||
var root = await ApiAsync($"/v1/catalog/{storefront}/playlists/{Uri.EscapeDataString(playlistId)}/tracks?limit={PageLimit}&offset={page * PageLimit}", cancellationToken);
|
||||
if (root == null)
|
||||
{
|
||||
// страница не отдалась — импорт атомарный, усечёнку не сохраняем
|
||||
return page == 0
|
||||
? ImportUnavailable("Apple Music плейлист не удалось прочитать.")
|
||||
: ImportUnavailable("Apple Music не отдал плейлист целиком, попробуй ещё раз.");
|
||||
}
|
||||
|
||||
int pageItems = MapTrackPage(root.Value, tracks, albumImagesFallback: null, albumTitleFallback: null, dateFallback: null);
|
||||
bool hasNext = GetProperty(root.Value, "next") != null;
|
||||
|
||||
if (pageItems == 0 || !hasNext)
|
||||
break;
|
||||
}
|
||||
|
||||
tracks = DeduplicateTracks(tracks);
|
||||
if (tracks.Count == 0)
|
||||
return ImportUnavailable("В Apple Music плейлисте не найдено треков.");
|
||||
|
||||
title = string.IsNullOrWhiteSpace(title) ? "Apple Music Playlist" : title.Trim();
|
||||
|
||||
return new MusicUserPlaylistImportResult
|
||||
{
|
||||
available = true,
|
||||
title = title,
|
||||
track_count = tracks.Count,
|
||||
truncated = tracks.Count >= MaxImportTracks,
|
||||
tracks = tracks,
|
||||
source = new MusicUserPlaylistSource
|
||||
{
|
||||
type = PlaylistSourceType,
|
||||
url = $"https://music.apple.com/{storefront}/playlist/_/{playlistId}",
|
||||
playlist_id = playlistId,
|
||||
title = title
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static async Task<MusicUserPlaylistImportResult> ImportAlbumAsync(string storefront, string albumId, CancellationToken cancellationToken)
|
||||
{
|
||||
var loaded = await LoadCatalogAlbumAsync(storefront, albumId, cancellationToken);
|
||||
var album = loaded.album;
|
||||
if (album == null)
|
||||
return ImportUnavailable(loaded.error ?? "Apple Music альбом не найден.");
|
||||
|
||||
string playlistTitle = string.IsNullOrWhiteSpace(album.artist_name) ? album.title : $"{album.artist_name} — {album.title}";
|
||||
|
||||
return new MusicUserPlaylistImportResult
|
||||
{
|
||||
available = true,
|
||||
title = playlistTitle,
|
||||
track_count = album.tracks.Count,
|
||||
tracks = album.tracks,
|
||||
source = new MusicUserPlaylistSource
|
||||
{
|
||||
type = AlbumSourceType,
|
||||
url = $"https://music.apple.com/{storefront}/album/_/{albumId}",
|
||||
playlist_id = albumId,
|
||||
title = playlistTitle
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static async Task<(MusicAlbum album, string error)> LoadCatalogAlbumAsync(string storefront, string albumId, CancellationToken cancellationToken)
|
||||
{
|
||||
storefront = storefront?.Trim().ToLowerInvariant();
|
||||
albumId = albumId?.Trim();
|
||||
if (!Regex.IsMatch(storefront ?? string.Empty, "^[a-z]{2}$") || !Regex.IsMatch(albumId ?? string.Empty, "^[0-9]+$"))
|
||||
return (null, "Некорректный Apple Music album ID.");
|
||||
|
||||
var meta = await ApiAsync($"/v1/catalog/{storefront}/albums/{Uri.EscapeDataString(albumId)}", cancellationToken);
|
||||
var albumElement = FirstDataElement(meta);
|
||||
if (albumElement == null)
|
||||
return (null, "Apple Music альбом не найден.");
|
||||
|
||||
var attributes = GetProperty(albumElement.Value, "attributes");
|
||||
string title = attributes != null ? GetString(attributes.Value, "name") : null;
|
||||
string artistName = attributes != null ? GetString(attributes.Value, "artistName") : null;
|
||||
string date = attributes != null ? GetString(attributes.Value, "releaseDate") : null;
|
||||
var albumImages = attributes != null ? MapArtwork(GetProperty(attributes.Value, "artwork")) : null;
|
||||
|
||||
var tracks = new List<MusicTrack>();
|
||||
int fetched = 0;
|
||||
|
||||
// первая порция треков приезжает в relationships, остальное — пагинацией;
|
||||
// offset считаем по сырому числу элементов (не по замапленным — часть фильтруется)
|
||||
var relationshipTracks = GetProperty(albumElement.Value, "relationships", "tracks");
|
||||
bool hasNext = false;
|
||||
if (relationshipTracks != null)
|
||||
{
|
||||
fetched += MapTrackPage(relationshipTracks.Value, tracks, albumImages, title, date);
|
||||
hasNext = GetProperty(relationshipTracks.Value, "next") != null;
|
||||
}
|
||||
|
||||
for (int page = 1; hasNext && page < MaxPages && tracks.Count < MaxImportTracks; page++)
|
||||
{
|
||||
var root = await ApiAsync($"/v1/catalog/{storefront}/albums/{Uri.EscapeDataString(albumId)}/tracks?limit={PageLimit}&offset={fetched}", cancellationToken);
|
||||
if (root == null)
|
||||
return (null, "Apple Music не отдал альбом целиком, попробуй ещё раз.");
|
||||
|
||||
int pageItems = MapTrackPage(root.Value, tracks, albumImages, title, date);
|
||||
fetched += pageItems;
|
||||
hasNext = GetProperty(root.Value, "next") != null && pageItems > 0;
|
||||
}
|
||||
|
||||
tracks = DeduplicateTracks(tracks);
|
||||
if (tracks.Count == 0)
|
||||
return (null, "В Apple Music альбоме не найдено треков.");
|
||||
|
||||
title = string.IsNullOrWhiteSpace(title) ? "Apple Music Album" : title.Trim();
|
||||
string canonicalId = $"applemusic:album:{storefront}:{albumId}";
|
||||
int? year = date?.Length >= 4 && int.TryParse(date[..4], out int parsedYear) ? parsedYear : null;
|
||||
|
||||
foreach (var track in tracks)
|
||||
track.album_id = canonicalId;
|
||||
|
||||
return (new MusicAlbum
|
||||
{
|
||||
title = title,
|
||||
id = canonicalId,
|
||||
artist_name = string.IsNullOrWhiteSpace(artistName) ? "Apple Music" : artistName.Trim(),
|
||||
date = date,
|
||||
year = year,
|
||||
type = "Album",
|
||||
images = albumImages ?? new List<MusicImage>(),
|
||||
tracks = tracks,
|
||||
provider_refs = new List<MusicProviderRef>
|
||||
{
|
||||
new() { provider = ProviderId, external_id = albumId }
|
||||
}
|
||||
}, null);
|
||||
}
|
||||
|
||||
// маппит data[] страницы (плейлист-треки или альбом-треки); возвращает СЫРОЕ
|
||||
// число элементов страницы — по нему считается offset (часть элементов фильтруется)
|
||||
static int MapTrackPage(JsonElement root, List<MusicTrack> tracks, List<MusicImage> albumImagesFallback, string albumTitleFallback, string dateFallback)
|
||||
{
|
||||
if (!root.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array)
|
||||
return 0;
|
||||
|
||||
int rawCount = 0;
|
||||
foreach (var item in data.EnumerateArray())
|
||||
{
|
||||
rawCount++;
|
||||
|
||||
// в плейлистах бывают music-videos — берём только песни
|
||||
string kind = GetString(item, "type");
|
||||
if (!string.IsNullOrWhiteSpace(kind) && !string.Equals(kind, "songs", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var mapped = MapTrackElement(item, albumImagesFallback, albumTitleFallback, dateFallback);
|
||||
if (mapped != null)
|
||||
tracks.Add(mapped);
|
||||
}
|
||||
|
||||
return rawCount;
|
||||
}
|
||||
|
||||
static MusicTrack MapTrackElement(JsonElement item, List<MusicImage> albumImagesFallback, string albumTitleFallback, string dateFallback)
|
||||
{
|
||||
var attributes = GetProperty(item, "attributes");
|
||||
if (attributes == null)
|
||||
return null;
|
||||
|
||||
string title = GetString(attributes.Value, "name")?.Trim();
|
||||
string trackId = GetString(item, "id");
|
||||
if (string.IsNullOrWhiteSpace(title) || string.IsNullOrWhiteSpace(trackId))
|
||||
return null;
|
||||
|
||||
string artistName = GetString(attributes.Value, "artistName")?.Trim();
|
||||
var images = MapArtwork(GetProperty(attributes.Value, "artwork"));
|
||||
if (images.Count == 0 && albumImagesFallback != null)
|
||||
images = albumImagesFallback.ToList();
|
||||
|
||||
return new MusicTrack
|
||||
{
|
||||
id = $"applemusic:track:{trackId}",
|
||||
title = title,
|
||||
artist_name = string.IsNullOrWhiteSpace(artistName) ? "Apple Music" : artistName,
|
||||
artists = string.IsNullOrWhiteSpace(artistName) ? new List<string>() : new List<string> { artistName },
|
||||
album_title = GetString(attributes.Value, "albumName")?.Trim() ?? albumTitleFallback,
|
||||
isrc = MusicIsrc.Normalize(GetString(attributes.Value, "isrc")),
|
||||
duration_ms = GetInt(attributes.Value, "durationInMillis"),
|
||||
track_number = GetInt(attributes.Value, "trackNumber"),
|
||||
disc_number = GetInt(attributes.Value, "discNumber"),
|
||||
date = GetString(attributes.Value, "releaseDate") ?? dateFallback,
|
||||
images = images,
|
||||
provider_refs = new List<MusicProviderRef>
|
||||
{
|
||||
new() { provider = ProviderId, external_id = trackId }
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// artwork.url — шаблон с {w}x{h}; просим два размера, крупный первым
|
||||
static List<MusicImage> MapArtwork(JsonElement? artwork)
|
||||
{
|
||||
var result = new List<MusicImage>();
|
||||
string template = artwork != null ? GetString(artwork.Value, "url") : null;
|
||||
if (string.IsNullOrWhiteSpace(template) || !template.Contains("{w}", StringComparison.OrdinalIgnoreCase))
|
||||
return result;
|
||||
|
||||
foreach (int size in new[] { 600, 300 })
|
||||
{
|
||||
result.Add(new MusicImage
|
||||
{
|
||||
url = template.Replace("{w}", size.ToString()).Replace("{h}", size.ToString()),
|
||||
width = size,
|
||||
height = size
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static JsonElement? FirstDataElement(JsonElement? root)
|
||||
{
|
||||
if (root == null || !root.Value.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Array || data.GetArrayLength() == 0)
|
||||
return null;
|
||||
|
||||
return data[0];
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user