Полностью переписанное api
All checks were successful
Release / pack-and-publish (release) Successful in 36s

This commit is contained in:
FrigaT
2026-04-19 17:00:05 +03:00
parent 5541d0ad27
commit 36e28ce3fe
111 changed files with 1552 additions and 3358 deletions

View File

@@ -1,80 +1,61 @@
using System.Net;
using YandexMusic.API.Common.Providers;
using YandexMusic.API.Models.Account;
using YandexMusic.API.Requests.Common;
namespace YandexMusic.API.Common;
/// <summary>
/// Хранилище данных пользователя
/// Хранилище данных авторизации. Не содержит HTTP-зависимостей.
/// </summary>
public class AuthStorage
{
/// <summary>
/// Http-контекст
/// </summary>
public HttpContext Context { get; }
/// <summary>
/// Флаг авторизации
/// Флаг, указывающий, авторизован ли пользователь.
/// </summary>
public bool IsAuthorized { get; internal set; }
/// <summary>
/// Идентификатор устройства
/// Идентификатор устройства (используется в заголовках).
/// </summary>
public string DeviceId { get; set; } = "csharp";
/// <summary>
/// Токен авторизации
/// OAuth-токен для доступа к API.
/// </summary>
public string Token { get; internal set; }
public string Token { get; internal set; } = string.Empty;
/// <summary>
/// Аккаунт
/// Информация об аккаунте пользователя.
/// </summary>
public YAccount User { get; set; }
public YAccount User { get; internal set; } = new();
/// <summary>
/// Провайдер запросов
/// Временный токен доступа (используется в некоторых сценариях авторизации).
/// </summary>
public IRequestProvider Provider { get; }
public YAccessToken AccessToken { get; internal set; } = new();
/// <summary>
/// Токен доступа
/// Внутренние данные авторизации (CSRF, track_id и т.д.).
/// </summary>
public YAccessToken AccessToken { get; set; }
internal YAuthToken AuthToken { get; set; }
internal YAuthToken AuthToken { get; set; } = new();
/// <summary>
/// Конструктор
/// Устанавливает флаг авторизации и сохраняет информацию об аккаунте.
/// </summary>
public AuthStorage(IRequestProvider provider)
internal void SetAuthorized(YAccount user, string token)
{
User = user ?? throw new ArgumentNullException(nameof(user));
Token = token ?? throw new ArgumentNullException(nameof(token));
IsAuthorized = true;
}
/// <summary>
/// Сбрасывает состояние авторизации.
/// </summary>
internal void ResetAuthorization()
{
User = new YAccount();
Context = new HttpContext();
Provider = provider;
Token = string.Empty;
AccessToken = new YAccessToken();
AuthToken = new YAuthToken();
IsAuthorized = false;
}
/// <summary>
/// Конструктор
/// </summary>
public AuthStorage()
{
User = new YAccount();
Context = new HttpContext();
Provider = new DefaultRequestProvider(this);
}
/// <summary>
/// Установка прокси для пользователия
/// </summary>
/// <param name="proxy">Прокси</param>
public void SetProxy(IWebProxy proxy)
{
Context.WebProxy = proxy;
}
}

View File

@@ -1,43 +0,0 @@
using System.Net;
namespace YandexMusic.API.Common;
/// <summary>
/// Загрузчик файлов по ссылке
/// </summary>
public class DataDownloader
{
private AuthStorage authStorage;
private async Task<HttpContent> GetResponseContent(string url, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead)
{
HttpRequestMessage message = new(new HttpMethod(WebRequestMethods.Http.Get), url);
HttpResponseMessage response = await authStorage.Provider.GetWebResponseAsync(message, httpCompletionOption);
return response.Content;
}
public async Task<Stream> AsStream(string url, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead)
{
HttpContent content = await GetResponseContent(url, httpCompletionOption);
return await content.ReadAsStreamAsync();
}
public async Task<byte[]> AsBytes(string url)
{
HttpContent content = await GetResponseContent(url);
return await content.ReadAsByteArrayAsync();
}
public async Task ToFile(string url, string fileName)
{
using Stream stream = await AsStream(url);
using FileStream fs = File.Create(fileName);
await stream.CopyToAsync(fs);
}
public DataDownloader(AuthStorage storage)
{
authStorage = storage;
}
}

View File

@@ -1,61 +0,0 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using YandexMusic.API.Converters;
using YandexMusic.API.Models.Common;
namespace YandexMusic.API.Common.Providers;
/// <summary>Базовый провайдер HTTP-запросов с общей логикой десериализации.</summary>
public abstract class CommonRequestProvider : IRequestProvider
{
/// <summary>Хранилище данных авторизации.</summary>
protected readonly AuthStorage storage;
/// <summary>Инициализирует новый экземпляр провайдера.</summary>
/// <param name="authStorage">Хранилище авторизации.</param>
protected CommonRequestProvider(AuthStorage authStorage)
{
storage = authStorage;
}
/// <summary>Выполняет HTTP-запрос и возвращает ответ.</summary>
public abstract Task<HttpResponseMessage> GetWebResponseAsync(
HttpRequestMessage message,
HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead);
/// <summary>Преобразует HTTP-ответ в объект типа T.</summary>
public virtual async Task<T> GetDataFromResponseAsync<T>(
YandexMusicApi api,
HttpResponseMessage response)
{
var json = await response.Content.ReadAsStringAsync();
JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
Converters = {
new JsonStringEnumConverter(JsonNamingPolicy.KebabCaseLower),
new IntToStringConverter(),
new StringToIntConverter(),
new YExecutionContextConverter(api, storage),
}
};
if (!response.IsSuccessStatusCode)
{
var error = JsonSerializer.Deserialize<YErrorResponse>(json, JsonOptions);
throw error ?? new Exception("Ошибка десериализации ответа с ошибкой.");
}
try
{
// Если нужен контекст выполнения, он добавляется через кастомный конвертер
return JsonSerializer.Deserialize<T>(json, JsonOptions)
?? throw new JsonException("Десериализация вернула null");
}
catch (Exception ex)
{
throw new Exception($"Ошибка десериализации: {ex.Message}", ex);
}
}
}

View File

@@ -1,45 +0,0 @@
using System.Net;
namespace YandexMusic.API.Common.Providers;
/// <summary>Стандартный провайдер HTTP-запросов с использованием HttpClient.</summary>
public class DefaultRequestProvider : CommonRequestProvider
{
/// <summary>Инициализирует новый экземпляр провайдера.</summary>
/// <param name="authStorage">Хранилище авторизации.</param>
public DefaultRequestProvider(AuthStorage authStorage) : base(authStorage) { }
/// <summary>Выполняет HTTP-запрос и возвращает ответ.</summary>
/// <param name="message">HTTP-запрос.</param>
/// <param name="completionOption">Опция завершения запроса.</param>
/// <returns>HTTP-ответ.</returns>
public override async Task<HttpResponseMessage> GetWebResponseAsync(
HttpRequestMessage message,
HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead)
{
using var handler = new SocketsHttpHandler
{
Proxy = storage.Context.WebProxy,
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
UseCookies = true,
CookieContainer = storage.Context.Cookies,
AllowAutoRedirect = true,
MaxAutomaticRedirections = 10
};
using var client = new HttpClient(handler);
try
{
return await client.SendAsync(message, completionOption);
}
catch (HttpRequestException ex)
{
// Пытаемся извлечь тело ошибки, если оно доступно
if (ex.InnerException == null)
throw;
throw new Exception($"Ошибка HTTP-запроса: {ex.Message}", ex);
}
}
}

View File

@@ -1,25 +0,0 @@
namespace YandexMusic.API.Common.Providers
{
/// <summary>
/// Интерфейс для провайдеров обработки запросов
/// </summary>
public interface IRequestProvider
{
/// <summary>
/// Функция получения ответа
/// </summary>
/// <param name="message">Запрос</param>
/// <param name="completionOption">Опция завершения запроса</param>
/// <returns></returns>
Task<HttpResponseMessage> GetWebResponseAsync(HttpRequestMessage message, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead);
/// <summary>
/// Функция формирования ответа
/// </summary>
/// <typeparam name="T">Тип объекта с ответом</typeparam>
/// <param name="api">API</param>
/// <param name="response">Ответ</param>
/// <returns></returns>
Task<T> GetDataFromResponseAsync<T>(YandexMusicApi api, HttpResponseMessage response);
}
}

View File

@@ -0,0 +1,47 @@
using System.Net;
namespace YandexMusic.API.Common;
/// <summary>
/// Фабрика для создания стандартного HttpClient с поддержкой кук, прокси и автоматической декомпрессией.
/// </summary>
public static class YandexMusicHttpClientFactory
{
/// <summary>
/// Создаёт стандартный HttpClient с автоматическим управлением куками.
/// </summary>
/// <param name="proxy">Прокси-сервер (опционально).</param>
/// <param name="timeout">Таймаут запросов (по умолчанию 30 секунд).</param>
/// <param name="userAgent">User-Agent (по умолчанию как у браузера Chrome).</param>
/// <returns>Настроенный HttpClient.</returns>
public static HttpClient CreateDefault(
CookieContainer? cookieContainer = null,
IWebProxy? proxy = null,
TimeSpan? timeout = null,
string? userAgent = null)
{
var handler = new HttpClientHandler
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
UseCookies = true,
CookieContainer = cookieContainer ?? new CookieContainer(),
AllowAutoRedirect = true,
MaxAutomaticRedirections = 10,
Proxy = proxy,
UseProxy = proxy != null
};
var client = new HttpClient(handler, disposeHandler: true)
{
Timeout = timeout ?? TimeSpan.FromSeconds(30)
};
// Стандартный User-Agent, похожий на браузерный
client.DefaultRequestHeaders.Add("User-Agent",
userAgent ?? "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
client.DefaultRequestHeaders.Add("Accept", "*/*");
client.DefaultRequestHeaders.Add("Accept-Language", "ru-RU,ru;q=0.9,en;q=0.8");
return client;
}
}

View File

@@ -1,4 +1,5 @@
using System.Net.WebSockets;
using System.Net;
using System.Net.WebSockets;
using System.Text.Json;
using System.Text.Json.Serialization;
using YandexMusic.API.Models.Track;
@@ -12,6 +13,7 @@ public class YnisonPlayer : IDisposable
{
private readonly JsonSerializerOptions _jsonOptions;
private readonly AuthStorage _storage;
private readonly IWebProxy? _proxy;
private YnisonWebSocket? _redirector;
private YnisonWebSocket? _state;
@@ -33,40 +35,36 @@ public class YnisonPlayer : IDisposable
/// <summary>Аргументы события получения состояния.</summary>
public class ReceiveEventArgs : EventArgs
{
/// <summary>Состояние плеера.</summary>
public YYnisonState State { get; init; } = null!;
}
/// <summary>Аргументы события закрытия соединения.</summary>
public class CloseEventArgs : EventArgs
{
/// <summary>Статус закрытия.</summary>
public WebSocketCloseStatus? Status { get; init; }
/// <summary>Описание причины закрытия.</summary>
public string? Description { get; init; }
}
internal YnisonPlayer(YandexMusicApi api, AuthStorage authStorage)
internal YnisonPlayer(YandexMusicApi api, AuthStorage authStorage, IWebProxy? proxy = null)
{
API = api;
_storage = authStorage;
_proxy = proxy;
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter(new UpperSnakeCaseNamingPolicy(), false) }
};
_redirector = new YnisonWebSocket();
_state = new YnisonWebSocket();
_redirector = new YnisonWebSocket(_proxy);
_state = new YnisonWebSocket(_proxy);
}
private string SerializeJson(object data) => JsonSerializer.Serialize(data, _jsonOptions);
private T Deserialize<T>(YYnisonMessageType messageType, string data)
{
return JsonSerializer.Deserialize<T>(data, _jsonOptions)
=> JsonSerializer.Deserialize<T>(data, _jsonOptions)
?? throw new JsonException("Десериализация вернула null");
}
private T DeserializeMessage<T>(YYnisonMessageType messageType, string data)
{
@@ -120,8 +118,8 @@ public class YnisonPlayer : IDisposable
if (index < 0 || index >= State.PlayerState.PlayerQueue.PlayableList.Count)
return null;
var item = State.PlayerState.PlayerQueue.PlayableList[index];
var response = await API.Track.GetAsync(_storage, item.PlayableId);
return response?.Result?.FirstOrDefault();
var response = await API.Track.GetAsync(item.PlayableId);
return response;
}
private async Task UpdateStateAsync()

View File

@@ -1,4 +1,5 @@
using System.Net.WebSockets;
using System.Net;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
@@ -7,14 +8,15 @@ namespace YandexMusic.API.Common.Ynison;
/// <summary>WebSocket-клиент для взаимодействия с протоколом Ynison.</summary>
public class YnisonWebSocket : IDisposable
{
private readonly ClientWebSocket _socketClient = new();
private ClientWebSocket? _socketClient;
private CancellationTokenSource? _cancellationTokenSource;
private CancellationToken _cancellationToken;
private readonly StringBuilder _data = new();
private const int BufferSize = 4096;
private readonly IWebProxy? _proxy;
/// <summary>Флаг, указывает, открыто ли соединение.</summary>
public bool IsConnected => _socketClient.State == WebSocketState.Open;
public bool IsConnected => _socketClient?.State == WebSocketState.Open;
/// <summary>Событие получения сообщения.</summary>
public event EventHandler<ReceiveEventArgs>? OnReceive;
@@ -25,19 +27,25 @@ public class YnisonWebSocket : IDisposable
/// <summary>Аргументы события получения данных.</summary>
public class ReceiveEventArgs : EventArgs
{
/// <summary>Полученные данные (JSON-строка).</summary>
public string Data { get; init; } = null!;
}
/// <summary>Аргументы события закрытия соединения.</summary>
public class CloseEventArgs : EventArgs
{
/// <summary>Статус закрытия.</summary>
public WebSocketCloseStatus? Status { get; init; }
/// <summary>Описание причины закрытия.</summary>
public string? Description { get; init; }
}
/// <summary>
/// Инициализирует новый экземпляр WebSocket-клиента.
/// </summary>
/// <param name="proxy">Прокси-сервер (опционально).</param>
public YnisonWebSocket(IWebProxy? proxy = null)
{
_proxy = proxy;
}
private static string GetProtocolData(string deviceId, string? redirectTicket)
{
var deviceInfo = new Dictionary<string, object>
@@ -57,6 +65,9 @@ public class YnisonWebSocket : IDisposable
private async Task<string> ReadSocketContentAsync()
{
if (_socketClient == null)
throw new InvalidOperationException("WebSocket не инициализирован");
var buffer = new byte[BufferSize];
WebSocketReceiveResult result;
do
@@ -68,17 +79,19 @@ public class YnisonWebSocket : IDisposable
}
/// <summary>Подключается к WebSocket.</summary>
/// <param name="storage">Хранилище авторизации.</param>
/// <param name="storage">Хранилище авторизации (для токена и deviceId).</param>
/// <param name="url">URL WebSocket.</param>
/// <param name="redirectTicket">Тикет перенаправления (опционально).</param>
public async Task ConnectAsync(AuthStorage storage, string url, string? redirectTicket = null)
{
_socketClient = new ClientWebSocket();
_socketClient.Options.AddSubProtocol("Bearer");
var protocolData = GetProtocolData(storage.DeviceId, redirectTicket);
_socketClient.Options.SetRequestHeader("Sec-WebSocket-Protocol", $"Bearer, v2, {protocolData}");
_socketClient.Options.SetRequestHeader("Origin", "https://music.yandex.ru");
_socketClient.Options.SetRequestHeader("Authorization", $"OAuth {storage.Token}");
_socketClient.Options.Proxy = storage.Context.WebProxy;
if (_proxy != null)
_socketClient.Options.Proxy = _proxy;
_cancellationTokenSource = new CancellationTokenSource();
_cancellationToken = _cancellationTokenSource.Token;
@@ -89,7 +102,7 @@ public class YnisonWebSocket : IDisposable
/// <summary>Начинает асинхронный приём сообщений.</summary>
public async Task BeginReceiveAsync()
{
if (_socketClient.State != WebSocketState.Open)
if (_socketClient == null || _socketClient.State != WebSocketState.Open)
return;
try
@@ -116,9 +129,11 @@ public class YnisonWebSocket : IDisposable
}
/// <summary>Отправляет JSON-сообщение.</summary>
/// <param name="json">JSON-строка.</param>
public async ValueTask SendAsync(string json)
{
if (_socketClient == null)
throw new InvalidOperationException("WebSocket не инициализирован");
var bytes = Encoding.UTF8.GetBytes(json);
await _socketClient.SendAsync(new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, _cancellationToken);
}
@@ -127,16 +142,14 @@ public class YnisonWebSocket : IDisposable
public async Task StopReceiveAsync()
{
if (_cancellationTokenSource != null && !_cancellationTokenSource.IsCancellationRequested)
{
await _cancellationTokenSource.CancelAsync();
}
}
/// <summary>Освобождает ресурсы.</summary>
public void Dispose()
{
_cancellationTokenSource?.Dispose();
_socketClient.Dispose();
_socketClient?.Dispose();
GC.SuppressFinalize(this);
}
}