Полностью переписанное 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,9 +0,0 @@
namespace YandexMusic.API.Requests.Common.Attributes;
public class YApiRequestAttribute : YBasePathRequestAttribute
{
public YApiRequestAttribute(string method, string url) : base(method, url)
{
basePath = "https://api.music.yandex.net";
}
}

View File

@@ -1,31 +0,0 @@
namespace YandexMusic.API.Requests.Common.Attributes;
/// <summary>
/// Атрибут запроса относительно базового адреса
/// </summary>
public class YBasePathRequestAttribute : YRequestAttribute
{
#region Поля
protected string basePath;
#endregion Поля
#region Свойства
public override string Url => GetFullUrl();
#endregion Свойства
#region Вспомогательные функции
private string GetFullUrl()
{
return $"{basePath.TrimEnd('/')}/{path.TrimStart('/')}";
}
#endregion Вспомогательные функции
public YBasePathRequestAttribute(string method, string url) : base(method, url)
{
}
}

View File

@@ -1,9 +0,0 @@
namespace YandexMusic.API.Requests.Common.Attributes;
public class YLoginRequestAttribute : YBasePathRequestAttribute
{
public YLoginRequestAttribute(string method, string url) : base(method, url)
{
basePath = "https://login.yandex.ru";
}
}

View File

@@ -1,9 +0,0 @@
namespace YandexMusic.API.Requests.Common.Attributes;
public class YMobileProxyRequestAttribute : YBasePathRequestAttribute
{
public YMobileProxyRequestAttribute(string method, string url) : base(method, url)
{
basePath = "https://mobileproxy.passport.yandex.net";
}
}

View File

@@ -1,9 +0,0 @@
namespace YandexMusic.API.Requests.Common.Attributes;
public class YOAuthMobileAttribute : YBasePathRequestAttribute
{
public YOAuthMobileAttribute(string method, string url) : base(method, url)
{
basePath = "https://oauth.mobile.yandex.net";
}
}

View File

@@ -1,9 +0,0 @@
namespace YandexMusic.API.Requests.Common.Attributes;
public class YOAuthRequestAttribute : YBasePathRequestAttribute
{
public YOAuthRequestAttribute(string method, string url) : base(method, url)
{
basePath = "https://oauth.yandex.ru";
}
}

View File

@@ -1,9 +0,0 @@
namespace YandexMusic.API.Requests.Common.Attributes;
public class YPassportRequestAttribute : YBasePathRequestAttribute
{
public YPassportRequestAttribute(string method, string url) : base(method, url)
{
basePath = "https://passport.yandex.ru";
}
}

View File

@@ -1,26 +0,0 @@
namespace YandexMusic.API.Requests.Common.Attributes;
/// <summary>
/// Атрибут запроса без привязки к базовому адресу
/// </summary>
public class YRequestAttribute : Attribute
{
#region Поля
protected string path;
#endregion Поля
#region Свойства
public string Method { get; }
public virtual string Url => path;
#endregion Свойства
public YRequestAttribute(string method, string url)
{
Method = method;
path = url;
}
}

View File

@@ -1,9 +0,0 @@
namespace YandexMusic.API.Requests.Common.Attributes;
public class YWebApiRequestAttribute : YBasePathRequestAttribute
{
public YWebApiRequestAttribute(string method, string url) : base(method, url)
{
basePath = "https://music.yandex.ru";
}
}

View File

@@ -1,25 +0,0 @@
using System.Net;
namespace YandexMusic.API.Requests.Common;
public class HttpContext
{
public CookieContainer Cookies;
public HttpContext()
{
Cookies = new CookieContainer();
}
public IWebProxy WebProxy { get; set; }
public long GetTimeInterval()
{
DateTime dt = TimeZoneInfo.ConvertTimeToUtc(DateTime.Now);
DateTime dt1970 = new(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc);
TimeSpan tsInterval = dt.Subtract(dt1970);
long iMilliseconds = Convert.ToInt64(tsInterval.TotalMilliseconds);
return iMilliseconds;
}
}

View File

@@ -0,0 +1,18 @@
using System.Net.Http.Headers;
using YandexMusic.API.Requests.Common;
namespace YandexMusic.API.Requests;
/// <summary>Базовый класс для запросов к Passport (passport.yandex.ru).</summary>
public abstract class YAuthRequestBuilder<TResponse, TParams> : YJsonRequestBuilder<TResponse, TParams>
{
protected override string BaseUrl => YConstants.Endpoints.PassportUrl;
protected YAuthRequestBuilder(YandexMusicApi api) : base(api) { }
protected override void SetCustomHeaders(HttpRequestHeaders headers)
{
base.SetCustomHeaders(headers);
headers.Add("X-Requested-With", "XMLHttpRequest");
}
}

View File

@@ -7,4 +7,10 @@ internal class YConstants
public const string XClientId = "c0ebe342af7d48fbbbfcf2d2eedb8f9e";
public const string XClientSecret = "ad0a908f0aa341a182a37ecd75bc319e";
internal static class Endpoints
{
public const string MusicUrl = "https://api.music.yandex.net";
public const string PassportUrl = "https://passport.yandex.ru/";
}
}

View File

@@ -0,0 +1,54 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using YandexMusic.API.Converters;
using YandexMusic.API.Models.Common;
namespace YandexMusic.API.Requests.Common;
/// <summary>
/// Строитель запросов с десериализацией JSON-ответа в TResponse.
/// </summary>
public abstract class YJsonRequestBuilder<TResponse, TParams> : YRequestBuilder<TParams>
{
protected YJsonRequestBuilder(YandexMusicApi api) : base(api) { }
protected virtual async Task<TResponse?> DeserializeAsync(HttpResponseMessage response)
{
var json = await response.Content.ReadAsStringAsync();
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
Converters = {
new JsonStringEnumConverter(JsonNamingPolicy.KebabCaseLower),
new IntToStringConverter(),
new StringToIntConverter(),
new YExecutionContextConverter(Api, Storage),
}
};
if (!response.IsSuccessStatusCode)
{
var error = JsonSerializer.Deserialize<YErrorResponse>(json, options);
throw error ?? new Exception($"Ошибка HTTP {response.StatusCode}: {json}");
}
try
{
return JsonSerializer.Deserialize<TResponse>(json, options);
}
catch (Exception ex)
{
throw new Exception($"Ошибка десериализации: {ex.Message}\nJSON: {json}", ex);
}
}
/// <summary>
/// Выполняет запрос и возвращает десериализованный объект типа TResponse.
/// </summary>
public async Task<TResponse?> ExecuteAsync(TParams parameters)
{
using var response = await ExecuteRawAsync(parameters);
return await DeserializeAsync(response);
}
}

View File

@@ -0,0 +1,55 @@
using System.Net.Http.Headers;
using System.Text.Json;
using System.Text.Json.Serialization;
using YandexMusic.API.Converters;
using YandexMusic.API.Models.Common;
using YandexMusic.API.Requests.Common;
namespace YandexMusic.API.Requests;
/// <summary>Базовый класс для запросов к API Яндекс Музыки (api.music.yandex.net).</summary>
public abstract class YMusicRequestBuilder<TResponse, TParams> : YJsonRequestBuilder<TResponse, TParams>
{
protected override string BaseUrl => YConstants.Endpoints.MusicUrl;
protected YMusicRequestBuilder(YandexMusicApi api) : base(api) { }
protected override void SetCustomHeaders(HttpRequestHeaders headers)
{
base.SetCustomHeaders(headers);
headers.Add("X-Yandex-Music-Client", Storage.DeviceId);
}
protected override async Task<TResponse?> DeserializeAsync(HttpResponseMessage response)
{
var json = await response.Content.ReadAsStringAsync();
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
Converters = {
new JsonStringEnumConverter(JsonNamingPolicy.KebabCaseLower),
new IntToStringConverter(),
new StringToIntConverter(),
new YExecutionContextConverter(Api, Storage),
}
};
if (!response.IsSuccessStatusCode)
{
var error = JsonSerializer.Deserialize<YErrorResponse>(json, options);
throw error ?? new Exception($"Ошибка HTTP {response.StatusCode}: {json}");
}
try
{
var uResponse = JsonSerializer.Deserialize<YResponse<TResponse>>(json, options);
if (uResponse == null) return default;
return uResponse.Result;
}
catch (Exception ex)
{
throw new Exception($"Ошибка десериализации: {ex.Message}\nJSON: {json}", ex);
}
}
}

View File

@@ -1,41 +0,0 @@
using YandexMusic.API.Common;
using YandexMusic.API.Common.Providers;
namespace YandexMusic.API.Requests.Common;
internal class YRequest<T>
{
private HttpRequestMessage msg;
private IRequestProvider provider;
protected YandexMusicApi api;
public YRequest(HttpRequestMessage message, YandexMusicApi yandex, AuthStorage auth)
{
msg = message;
api = yandex;
provider = auth.Provider;
}
public async Task<T> GetResponseAsync()
{
if (msg == null)
return default;
HttpResponseMessage response = await provider.GetWebResponseAsync(msg);
if (typeof(T) == typeof(HttpResponseMessage))
return (T)(object)response;
try
{
return await provider.GetDataFromResponseAsync<T>(api, response);
}
finally
{
response.Dispose();
}
}
}

View File

@@ -1,38 +1,39 @@
using System.Collections.Specialized;
using System.Net;
using System.Net.Http.Headers;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using System.Web;
using YandexMusic.API.Common;
using YandexMusic.API.Extensions;
using YandexMusic.API.Requests.Common.Attributes;
namespace YandexMusic.API.Requests.Common;
/// <summary>Базовый строитель HTTP-запросов к API Яндекс.Музыки.</summary>
/// <typeparam name="TResponse">Тип ответа.</typeparam>
/// <summary>Базовый строитель HTTP-запросов.</summary>
/// <typeparam name="TParams">Тип параметров запроса.</typeparam>
public abstract class YRequestBuilder<TResponse, TParams>
public abstract class YRequestBuilder<TParams>
{
private readonly YRequestAttribute _requestInfo;
private Dictionary<string, string> _substitutions = null!;
/// <summary>HTTP-метод (GET, POST и т.д.).</summary>
protected abstract string Method { get; }
/// <summary>Базовый URL (например, "https://api.music.yandex.net").</summary>
protected abstract string BaseUrl { get; }
/// <summary>Шаблон пути (может содержать плейсхолдеры вида {id}).</summary>
protected abstract string PathTemplate { get; }
private readonly JsonSerializerOptions _jsonOptions;
protected readonly YandexMusicApi api;
protected readonly AuthStorage storage;
protected string device;
/// <summary>Основной экземпляр API.</summary>
protected YandexMusicApi Api { get; }
protected YRequestBuilder(YandexMusicApi yandex, AuthStorage auth)
/// <summary>Хранилище авторизации (сокращение для Api.Storage).</summary>
protected AuthStorage Storage => Api.Storage;
protected YRequestBuilder(YandexMusicApi api)
{
_requestInfo = GetType().GetCustomAttribute<YRequestAttribute>()
?? throw new NotImplementedException($"Отсутствует атрибут {nameof(YRequestAttribute)}");
api = yandex;
storage = auth;
device = $"os=CSharp; os_version=; manufacturer=FrigaT; model=Yandex Music API; clid=; device_id={storage.DeviceId}; uuid=random";
Api = api;
_jsonOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
@@ -41,57 +42,91 @@ public abstract class YRequestBuilder<TResponse, TParams>
};
}
private Uri BuildUri(TParams tuple)
private string FullUrl => $"{BaseUrl.TrimEnd('/')}/{PathTemplate.TrimStart('/')}";
private Uri BuildUri(TParams parameters, Dictionary<string, string> substitutions)
{
var queryParams = GetQueryParams(tuple);
var queryParams = GetQueryParams(parameters);
var modifiedParams = HttpUtility.ParseQueryString(string.Empty);
foreach (string? key in queryParams)
if (key != null)
modifiedParams[key] = ReplaceSubs(queryParams[key]!);
var endpoint = ReplaceSubs(_requestInfo.Url);
modifiedParams[key] = ReplaceSubs(queryParams[key]!, substitutions);
var endpoint = ReplaceSubs(FullUrl, substitutions);
var builder = new UriBuilder(endpoint) { Query = modifiedParams.ToString() ?? string.Empty };
return builder.Uri;
}
private HttpRequestMessage CreateMessage(TParams tuple)
private HttpRequestMessage CreateMessage(TParams parameters, Dictionary<string, string> substitutions)
{
var msg = new HttpRequestMessage
{
RequestUri = BuildUri(tuple),
Method = new HttpMethod(_requestInfo.Method),
Content = GetContent(tuple)
RequestUri = BuildUri(parameters, substitutions),
Method = new HttpMethod(Method),
Content = GetContent(parameters)
};
msg.Headers.TryAddWithoutValidation(HttpRequestHeader.AcceptCharset.GetName(), Encoding.UTF8.WebName);
msg.Headers.TryAddWithoutValidation(HttpRequestHeader.AcceptEncoding.GetName(), "gzip");
if (!string.IsNullOrEmpty(storage.Token))
msg.Headers.TryAddWithoutValidation(HttpRequestHeader.Authorization.GetName(), $"OAuth {storage.Token}");
msg.Headers.TryAddWithoutValidation(GetHeaderName(HttpRequestHeader.AcceptCharset), Encoding.UTF8.WebName);
msg.Headers.TryAddWithoutValidation(GetHeaderName(HttpRequestHeader.AcceptEncoding), "gzip");
if (!string.IsNullOrEmpty(Storage.Token))
msg.Headers.TryAddWithoutValidation(GetHeaderName(HttpRequestHeader.Authorization), $"OAuth {Storage.Token}");
SetCustomHeaders(msg.Headers);
return msg;
}
protected string ReplaceSubs(string str)
// Вспомогательный метод: преобразование HttpRequestHeader в строку (как было в HttpRequestHeaderExtensions)
private static string GetHeaderName(HttpRequestHeader header)
{
var subs = str.GetMatches(@"\{.+?\}");
return SplitByCapitalLetter(header.ToString(), "-");
}
// Вспомогательный метод: разбиение строки по заглавным буквам (из StringExtensions)
private static string SplitByCapitalLetter(string str, string delimiter)
{
var matches = Regex.Matches(str, @"([A-Z]+)(?=([A-Z][a-z]|$)) | [A-Z][a-z].+?(?=([A-Z]|$))", RegexOptions.IgnorePatternWhitespace);
return string.Join(delimiter, matches.Cast<Match>().Select(m => m.Value));
}
// Вспомогательный метод: замена всех вхождений регулярного выражения (из StringExtensions)
private static string ReplaceRegex(string input, string pattern, string replacement, RegexOptions options = RegexOptions.IgnoreCase)
{
return string.IsNullOrEmpty(input) ? string.Empty : Regex.Replace(input, pattern, replacement, options);
}
// Вспомогательный метод: получение совпадений по регулярному выражению (из StringExtensions)
private static string[] GetMatches(string input, string pattern, RegexOptions options = RegexOptions.IgnoreCase)
{
if (string.IsNullOrEmpty(input) || !Regex.IsMatch(input, pattern, options))
return Array.Empty<string>();
return Regex.Matches(input, pattern, options)
.Cast<Match>()
.Select(m => m.Value)
.ToArray();
}
private string ReplaceSubs(string str, Dictionary<string, string> substitutions)
{
var subs = GetMatches(str, @"\{.+?\}");
foreach (var s in subs)
{
var key = s.ReplaceRegex(@"[\{\}]", string.Empty);
if (!_substitutions.TryGetValue(key, out var value))
var key = ReplaceRegex(s, @"[\{\}]", string.Empty);
if (!substitutions.TryGetValue(key, out var value))
throw new Exception($"Не найдена подстановка {s}");
str = str.Replace(s, value);
}
return str;
}
protected virtual Dictionary<string, string> GetSubstitutions(TParams tuple) => [];
protected virtual NameValueCollection GetQueryParams(TParams tuple) => [];
protected virtual HttpContent? GetContent(TParams tuple) => null;
protected virtual Dictionary<string, string> GetSubstitutions(TParams parameters) => [];
protected virtual NameValueCollection GetQueryParams(TParams parameters) => [];
protected virtual HttpContent? GetContent(TParams parameters) => null;
protected virtual void SetCustomHeaders(HttpRequestHeaders headers) { }
protected string SerializeJson(object data) => JsonSerializer.Serialize(data, _jsonOptions);
internal YRequest<TResponse> Build(TParams tuple)
/// <summary>Выполняет запрос и возвращает десериализованный ответ.</summary>
public async Task<HttpResponseMessage?> ExecuteRawAsync(TParams parameters)
{
_substitutions = GetSubstitutions(tuple);
var msg = CreateMessage(tuple);
return new YRequest<TResponse>(msg, api, storage);
var substitutions = GetSubstitutions(parameters);
using var msg = CreateMessage(parameters, substitutions);
return await Api.HttpClient.SendAsync(msg);
}
}