Исправление: том ./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,276 @@
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Text.RegularExpressions;
using static Music.MusicPlaylistImportHelpers;
namespace Music;
// Импорт пользовательских плейлистов и лайков по ссылке
// (профиль → лайки, плейлист → треки) для MusicUserPlaylistService.
public static partial class SoundCloudSupport
{
static async Task<MusicUserPlaylistImportResult> ImportResolvedPlaylistAsync(JsonElement playlist, string clientId, CancellationToken cancellationToken)
{
int? trackCount = GetInt(playlist, "track_count");
int loadedTracks = GetArrayLength(playlist, "tracks");
string playlistId = GetString(playlist, "id");
bool knownIncompleteTrackList = trackCount.HasValue && loadedTracks < trackCount.Value;
if (!string.IsNullOrWhiteSpace(playlistId) && (!trackCount.HasValue || loadedTracks < trackCount.Value))
{
var hydrated = await LoadPlaylistByIdAsync(playlistId, clientId, cancellationToken);
if (hydrated.HasValue)
playlist = hydrated.Value;
else if (knownIncompleteTrackList)
return ImportUnavailable("SoundCloud плейлист не удалось полностью загрузить. Старый плейлист не обновлён, попробуй позже.");
}
var hydrationIds = CollectTrackIdsForHydration(playlist);
var trackDetails = await LoadTrackDetailsAsync(hydrationIds, clientId, cancellationToken);
if (hydrationIds.Count > 0 && !trackDetails.complete)
return ImportUnavailable("SoundCloud плейлист не удалось полностью загрузить. Старый плейлист не обновлён, попробуй позже.");
var album = MapResolvedPlaylist(playlist, trackDetails.tracks);
if (album == null)
return ImportUnavailable("SoundCloud плейлист не удалось прочитать.");
var tracks = DeduplicateTracks(album.tracks);
if (tracks.Count == 0)
return ImportUnavailable("В SoundCloud плейлисте не найдено треков.");
string sourceUrl = album.provider_refs?.FirstOrDefault(i => i?.provider == DiscoveryProviderId)?.external_id
?? GetString(playlist, "permalink_url");
return new MusicUserPlaylistImportResult
{
available = true,
title = string.IsNullOrWhiteSpace(album.title) ? "SoundCloud Playlist" : album.title,
track_count = tracks.Count,
tracks = tracks,
source = new MusicUserPlaylistSource
{
type = "soundcloud_playlist",
url = sourceUrl,
playlist_id = playlistId,
title = album.title
}
};
}
static async Task<MusicUserPlaylistImportResult> ImportUserLikesAsync(JsonElement user, string clientId, CancellationToken cancellationToken)
{
const int likesImportLimit = 200;
string userId = GetString(user, "id");
string userUrl = GetString(user, "permalink_url");
string username = NormalizeValue(GetString(user, "username")) ?? "SoundCloud";
if (string.IsNullOrWhiteSpace(userId))
return ImportUnavailable("SoundCloud профиль не удалось прочитать.");
var album = new MusicAlbum
{
id = $"soundcloud:likes:{userId}",
title = "Мои лайки (SoundCloud)",
artist_name = username,
images = BuildImages(GetString(user, "avatar_url"))
};
var likes = await LoadLikedTrackElementsAsync(userId, clientId, likesImportLimit, cancellationToken);
if (!likes.complete)
return ImportUnavailable("SoundCloud лайки не удалось полностью загрузить. Старый плейлист не обновлён, попробуй позже.");
string fallbackArtwork = SelectFirstImageUrl(album.images);
int position = 1;
foreach (var item in likes.items)
{
var trackElement = ExtractLikedTrack(item);
if (!trackElement.HasValue)
continue;
var mapped = MapPlaylistTrack(trackElement.Value, album, position++, fallbackArtwork);
if (mapped != null)
album.tracks.Add(mapped);
}
var tracks = DeduplicateTracks(album.tracks);
if (tracks.Count == 0)
return ImportUnavailable("Лайки SoundCloud не найдены или скрыты.");
return new MusicUserPlaylistImportResult
{
available = true,
message = likes.truncated
? $"Импортированы последние {tracks.Count} треков SoundCloud. У профиля есть ещё лайки, они не вошли в лимит {likesImportLimit}."
: null,
title = album.title,
track_count = tracks.Count,
truncated = likes.truncated,
tracks = tracks,
source = new MusicUserPlaylistSource
{
type = "soundcloud_likes",
url = userUrl,
user_id = userId,
title = username
}
};
}
static async Task<SoundCloudCollectionPage> LoadLikedTrackElementsAsync(string userId, string clientId, int limit, CancellationToken cancellationToken)
{
SoundCloudCollectionPage incomplete = null;
foreach (var path in new[] { $"users/{userId}/likes", $"users/{userId}/track_likes", $"users/{userId}/likes/tracks" })
{
var page = await SafeLoadCollectionElementsAsync(path, clientId, limit, cancellationToken);
var tracks = page.items.Where(item => ExtractLikedTrack(item).HasValue).ToList();
if (tracks.Count > 0)
{
return new SoundCloudCollectionPage
{
items = tracks,
complete = page.complete,
hasMore = page.hasMore,
nextPage = page.nextPage,
truncated = page.truncated
};
}
if (!page.complete)
incomplete ??= page;
}
return incomplete == null
? SoundCloudCollectionPage.Empty()
: new SoundCloudCollectionPage
{
complete = false,
hasMore = incomplete.hasMore,
nextPage = incomplete.nextPage
};
}
static JsonElement? ExtractLikedTrack(JsonElement item)
{
if (item.ValueKind != JsonValueKind.Object)
return null;
if (item.TryGetProperty("track", out var track) && track.ValueKind == JsonValueKind.Object)
return track;
string kind = NormalizeValue(GetString(item, "kind"));
if (string.Equals(kind, "track", StringComparison.OrdinalIgnoreCase) || item.TryGetProperty("title", out _))
return item;
return null;
}
static bool LooksLikePlaylist(JsonElement element)
=> element.ValueKind == JsonValueKind.Object
&& element.TryGetProperty("tracks", out var tracks)
&& tracks.ValueKind == JsonValueKind.Array
&& !string.IsNullOrWhiteSpace(GetString(element, "permalink_url"));
static bool LooksLikeUser(JsonElement element)
=> element.ValueKind == JsonValueKind.Object
&& !string.IsNullOrWhiteSpace(GetString(element, "username"))
&& !string.IsNullOrWhiteSpace(GetString(element, "permalink_url"));
static string NormalizeSoundCloudUrl(string value)
{
value = NormalizeValue(value);
if (string.IsNullOrWhiteSpace(value))
return null;
if (!value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !value.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
value = "https://" + value.TrimStart('/');
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
return null;
string host = uri.Host.Trim().ToLowerInvariant();
if (host == "m.soundcloud.com" || host == "www.soundcloud.com")
host = "soundcloud.com";
if (host != "soundcloud.com")
return null;
string path = uri.AbsolutePath.TrimEnd('/');
if (string.IsNullOrWhiteSpace(path) || path == "/")
return null;
return $"https://soundcloud.com{path}";
}
static async Task<string> ExpandSoundCloudShortUrlAsync(string value, CancellationToken cancellationToken)
{
value = NormalizeValue(value);
if (string.IsNullOrWhiteSpace(value))
return null;
if (!value.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !value.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
value = "https://" + value.TrimStart('/');
if (!Uri.TryCreate(value, UriKind.Absolute, out var uri))
return null;
string host = uri.Host.Trim().ToLowerInvariant();
if (host != "on.soundcloud.com")
return null;
try
{
using var request = new HttpRequestMessage(HttpMethod.Head, uri);
request.Headers.TryAddWithoutValidation("User-Agent", "Mozilla/5.0");
request.Headers.TryAddWithoutValidation("Accept", "text/html,application/xhtml+xml");
using var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
string finalUrl = response.RequestMessage?.RequestUri?.ToString();
return NormalizeSoundCloudUrl(finalUrl);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch
{
return null;
}
}
public static async Task<MusicUserPlaylistImportResult> ImportPlaylistAsync(string inputUrl, CancellationToken cancellationToken = default)
{
string soundCloudUrl = NormalizeSoundCloudUrl(inputUrl)
?? await ExpandSoundCloudShortUrlAsync(inputUrl, cancellationToken);
if (string.IsNullOrWhiteSpace(soundCloudUrl))
return ImportUnavailable("Вставь ссылку на SoundCloud профиль или плейлист.");
string clientId = await GetClientIdAsync(cancellationToken);
if (string.IsNullOrWhiteSpace(clientId))
return ImportUnavailable("Не удалось получить SoundCloud client_id.");
var resolved = await ResolveUrlElementAsync(soundCloudUrl, clientId, cancellationToken);
if (!resolved.HasValue)
return ImportUnavailable("SoundCloud ссылку не удалось открыть.");
var element = resolved.Value;
string kind = NormalizeValue(GetString(element, "kind"));
if (string.Equals(kind, "user", StringComparison.OrdinalIgnoreCase) || LooksLikeUser(element))
return await ImportUserLikesAsync(element, clientId, cancellationToken);
if (string.Equals(kind, "playlist", StringComparison.OrdinalIgnoreCase) || LooksLikePlaylist(element))
return await ImportResolvedPlaylistAsync(element, clientId, cancellationToken);
return ImportUnavailable("Поддерживаются только SoundCloud профиль и плейлист.");
}
public static Task<MusicUserPlaylistImportResult> ImportPlaylistAsync(MusicUserPlaylistSource source, CancellationToken cancellationToken = default)
{
if (source == null || string.IsNullOrWhiteSpace(source.url))
return Task.FromResult(ImportUnavailable("У плейлиста нет SoundCloud источника."));
return ImportPlaylistAsync(source.url, cancellationToken);
}
}