Исправление: том ./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,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];
}
}