Compare commits
25 Commits
ba9d97239e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c5dca1491 | ||
| 815283a776 | |||
| 526353d679 | |||
| eb1eba0162 | |||
|
|
34261d02a9 | ||
|
|
5f761d4fe8 | ||
|
|
b6f78da9c8 | ||
|
|
0bbaac5689 | ||
|
|
a7caf829d3 | ||
|
|
add7f08215 | ||
|
|
36e28ce3fe | ||
|
|
5541d0ad27 | ||
|
|
ea9f392896 | ||
|
|
6dcf39de56 | ||
|
|
50da85be57 | ||
|
|
699d38da74 | ||
|
|
1779ecaca9 | ||
|
|
8abc6c5074 | ||
|
|
b8f78a5856 | ||
|
|
21a0c5abe6 | ||
|
|
a40b36ef96 | ||
|
|
266aa2e181 | ||
|
|
3e5b9beaf1 | ||
|
|
6f01772bb7 | ||
|
|
dd338e3f57 |
51
.gitea/workflows/release-package.yaml
Normal file
51
.gitea/workflows/release-package.yaml
Normal file
@@ -0,0 +1,51 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
pack-and-publish:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Set version from tag
|
||||
id: version
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME#v}"
|
||||
echo "PACKAGE_VERSION=$TAG" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Replace ProjectReference with PackageReference
|
||||
run: |
|
||||
sed -i "s#<ProjectReference Include=\"..\/YandexMusic.API\/YandexMusic.API.csproj\" />#<PackageReference Include=\"YandexMusic.API\" Version=\"${{ steps.version.outputs.PACKAGE_VERSION }}\" />#" YandexMusic/YandexMusic.csproj
|
||||
|
||||
- name: Build and Pack projects
|
||||
run: |
|
||||
mkdir -p artifacts
|
||||
for proj in $PROJECTS; do
|
||||
echo "Restoring $proj..."
|
||||
dotnet restore $proj
|
||||
echo "Building $proj..."
|
||||
dotnet build $proj -c Release -p:Version=${{ steps.version.outputs.PACKAGE_VERSION }}
|
||||
echo "Packing $proj..."
|
||||
dotnet pack $proj -c Release --no-build -p:PackageVersion=${{ steps.version.outputs.PACKAGE_VERSION }} -o ./artifacts
|
||||
done
|
||||
env:
|
||||
PROJECTS: |
|
||||
YandexMusic.API
|
||||
YandexMusic
|
||||
|
||||
- name: Publish to NuGet
|
||||
env:
|
||||
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
|
||||
run: dotnet nuget push ./artifacts/*.nupkg --source https://git.frigat.duckdns.org/api/packages/FrigaT/nuget/index.json --api-key $NUGET_API_KEY --skip-duplicate
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -360,4 +360,5 @@ MigrationBackup/
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
FodyWeavers.xsd
|
||||
YaMusicCli/
|
||||
|
||||
607
ARCHITECTURE.md
Normal file
607
ARCHITECTURE.md
Normal file
@@ -0,0 +1,607 @@
|
||||
# 🏗️ Архитектура YandexMusic
|
||||
|
||||
Полное описание архитектуры, компонентов и взаимодействия между слоями решения YandexMusic.
|
||||
|
||||
## 📋 Содержание
|
||||
|
||||
- [Обзор архитектуры](#обзор-архитектуры)
|
||||
- [Структура слоёв](#структура-слоёв)
|
||||
- [Основные компоненты](#основные-компоненты)
|
||||
- [Потоки данных](#потоки-данных)
|
||||
- [Паттерны проектирования](#паттерны-проектирования)
|
||||
- [Модульная структура](#модульная-структура)
|
||||
- [Расширяемость](#расширяемость)
|
||||
|
||||
## 🔍 Обзор архитектуры
|
||||
|
||||
### Многослойная архитектура
|
||||
|
||||
Проект построен на классической трёхслойной архитектуре с элементами DDD:
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────┐
|
||||
│ CLI Layer (YaMusicCli) │ ← Точка входа
|
||||
├────────────────────────────────────────┤
|
||||
│ Client Layer (YandexMusic) │ ← Удобный интерфейс
|
||||
├────────────────────────────────────────┤
|
||||
│ API Layer (YandexMusic.API) │ ← Логика API
|
||||
├────────────────────────────────────────┤
|
||||
│ Data Layer (Models) │ ← Данные и сущности
|
||||
├────────────────────────────────────────┤
|
||||
│ Infrastructure Layer │ ← HTTP, Serialization
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Ключевые принципы
|
||||
|
||||
- **Separation of Concerns** - каждый слой отвечает за одно
|
||||
- **Dependency Injection** - слабая связанность через интерфейсы
|
||||
- **Single Responsibility** - каждый класс решает одну задачу
|
||||
- **Open/Closed Principle** - открыто для расширения, закрыто для модификации
|
||||
- **Asynchronous First** - всё асинхронное по умолчанию
|
||||
|
||||
## 📦 Структура слоёв
|
||||
|
||||
### 1. CLI Layer (YaMusicCli)
|
||||
|
||||
**Назначение:** Командная строка интерфейс для пользователей.
|
||||
|
||||
```
|
||||
YaMusicCli/
|
||||
├── Program.cs # Точка входа, парсинг аргументов
|
||||
└── Commands/ # Команды для CLI
|
||||
├── SearchCommand
|
||||
├── PlaylistCommand
|
||||
└── TrackCommand
|
||||
```
|
||||
|
||||
**Ответственность:**
|
||||
|
||||
- Парсинг команд пользователя
|
||||
- Вывод результатов в консоль
|
||||
- Обработка пользовательского ввода
|
||||
- Форматирование данных для вывода
|
||||
|
||||
**Зависимости:** YandexMusic (Client Layer)
|
||||
|
||||
### 2. Client Layer (YandexMusic)
|
||||
|
||||
**Назначение:** Удобный оборачиватель (wrapper) для работы с API.
|
||||
|
||||
```
|
||||
YandexMusic/
|
||||
└── YandexMusicClient.cs
|
||||
├── Initialization (конструктор)
|
||||
├── Authorization (авторизация)
|
||||
├── Properties (доступ к API и хранилищу)
|
||||
└── Cleanup (dispose)
|
||||
```
|
||||
|
||||
**Основной класс:**
|
||||
|
||||
```csharp
|
||||
public class YandexMusicClient : IDisposable
|
||||
{
|
||||
// Инициализация
|
||||
public YandexMusicClient(
|
||||
CookieContainer? cookieContainer = null,
|
||||
IWebProxy? proxy = null,
|
||||
TimeSpan? timeout = null,
|
||||
string? userAgent = null)
|
||||
|
||||
// Авторизация
|
||||
public async Task AuthorizeAsync(string login, string password)
|
||||
public async Task AuthorizeByTokenAsync(string token)
|
||||
|
||||
// Свойства доступа
|
||||
public YandexMusicApi Api { get; } // API Яндекс Музыки
|
||||
public AuthStorage AuthStorage { get; } // Хранилище данных
|
||||
public HttpClient HttpClient { get; } // HttpClient
|
||||
public YAccount Account { get; } // Информация об аккаунте
|
||||
public bool IsAuthorized { get; } // Статус авторизации
|
||||
public YnisonPlayer? Ynison { get; } // WebSocket плеер
|
||||
}
|
||||
```
|
||||
|
||||
**Ответственность:**
|
||||
|
||||
- Упрощение работы с API
|
||||
- Управление HttpClient и cookies
|
||||
- Обработка авторизации
|
||||
- Инициализация компонентов
|
||||
|
||||
**Зависимости:** YandexMusic.API (API Layer)
|
||||
|
||||
### 3. API Layer (YandexMusic.API)
|
||||
|
||||
**Назначение:** Низкоуровневой доступ к API Яндекс Музыки.
|
||||
|
||||
```
|
||||
YandexMusic.API/
|
||||
├── YandexMusicApi.cs # Главный класс - фасад
|
||||
├── API/ # API классы по функциям
|
||||
│ ├── YAlbumAPI
|
||||
│ ├── YArtistAPI
|
||||
│ ├── YTrackAPI
|
||||
│ ├── YPlaylistAPI
|
||||
│ ├── YRadioAPI
|
||||
│ ├── YSearchAPI
|
||||
│ └── ... (остальные API)
|
||||
├── Requests/ # Построители запросов
|
||||
│ ├── Common/
|
||||
│ ├── Album/
|
||||
│ ├── Artist/
|
||||
│ └── ... (по категориям)
|
||||
├── Models/ # Модели данных
|
||||
│ ├── Common/
|
||||
│ ├── Album/
|
||||
│ ├── Track/
|
||||
│ └── ... (по типам)
|
||||
└── Common/ # Вспомогательные компоненты
|
||||
├── AuthStorage
|
||||
├── Providers/
|
||||
├── Encryptor
|
||||
└── Ynison/
|
||||
```
|
||||
|
||||
**Главный класс:**
|
||||
|
||||
```csharp
|
||||
public class YandexMusicApi
|
||||
{
|
||||
// API Методы
|
||||
public YAlbumAPI Album { get; }
|
||||
public YArtistAPI Artist { get; }
|
||||
public YTrackAPI Track { get; }
|
||||
public YPlaylistAPI Playlist { get; }
|
||||
public YRadioAPI Radio { get; }
|
||||
public YSearchAPI Search { get; }
|
||||
// ... и так далее для всех веток API
|
||||
}
|
||||
```
|
||||
|
||||
**API Классы:**
|
||||
|
||||
Каждый API класс наследуется от `YCommonAPI`:
|
||||
|
||||
```csharp
|
||||
public abstract class YCommonAPI
|
||||
{
|
||||
protected readonly YandexMusicApi api;
|
||||
protected YCommonAPI(YandexMusicApi yandex) => api = yandex;
|
||||
}
|
||||
|
||||
// Пример конкретного API
|
||||
public class YTrackAPI : YCommonAPI
|
||||
{
|
||||
public YTrackAPI(YandexMusicApi yandex) : base(yandex) { }
|
||||
|
||||
public async Task<YTrack?> GetTrackAsync(string trackId) { }
|
||||
public async Task<IEnumerable<YTrack>> GetTracksAsync(IEnumerable<string> ids) { }
|
||||
// ... остальные методы
|
||||
}
|
||||
```
|
||||
|
||||
**Ответственность:**
|
||||
|
||||
- Построение HTTP запросов
|
||||
- Вызов провайдера запросов
|
||||
- Десериализация ответов
|
||||
- Обработка ошибок API
|
||||
|
||||
### 4. Data Layer (Models)
|
||||
|
||||
**Назначение:** Модели данных, отражающие структуру API Яндекс Музыки.
|
||||
|
||||
```
|
||||
Models/
|
||||
├── Common/ # Общие модели
|
||||
│ ├── YBaseModel # Базовая модель с контекстом
|
||||
│ ├── YResponse<T> # Ответ от API
|
||||
│ ├── YError # Ошибка
|
||||
│ └── ... (остальные)
|
||||
├── Album/ # Модели альбомов
|
||||
│ ├── YAlbum
|
||||
│ └── YAlbumMenuItem
|
||||
├── Track/ # Модели треков
|
||||
│ ├── YTrack
|
||||
│ ├── YTrackSupplement
|
||||
│ └── YTrackSimilar
|
||||
├── Playlist/ # Модели плейлистов
|
||||
│ ├── YPlaylist
|
||||
│ ├── YPlaylistChange
|
||||
│ └── YPlaylistMadeFor
|
||||
└── ... (остальные модели)
|
||||
```
|
||||
|
||||
**Базовая модель:**
|
||||
|
||||
```csharp
|
||||
public abstract class YBaseModel
|
||||
{
|
||||
[JsonIgnore]
|
||||
public YExecutionContext? Context { get; set; }
|
||||
}
|
||||
|
||||
// Пример модели
|
||||
public class YTrack : YBaseModel
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public string Id { get; set; }
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; }
|
||||
|
||||
[JsonPropertyName("duration_ms")]
|
||||
public int DurationMs { get; set; }
|
||||
|
||||
// ... остальные свойства
|
||||
}
|
||||
```
|
||||
|
||||
**Ответственность:**
|
||||
|
||||
- Представление данных API
|
||||
- System.Text.Json сериализация/десериализация
|
||||
- Хранение контекста выполнения (опционально)
|
||||
|
||||
### 5. Infrastructure Layer
|
||||
|
||||
**Назначение:** Низкоуровневая инфраструктура для работы с HTTP и сериализацией.
|
||||
|
||||
```
|
||||
Common/
|
||||
├── Providers/ # Провайдеры запросов
|
||||
│ ├── IRequestProvider # Интерфейс
|
||||
│ ├── DefaultRequestProvider
|
||||
│ ├── CommonRequestProvider
|
||||
│ └── MockRequestProvider
|
||||
├── AuthStorage # Управление авторизацией
|
||||
├── Encryptor # Шифрование данных
|
||||
├── DataDownloader # Загрузка файлов
|
||||
└── Ynison/ # WebSocket
|
||||
├── YnisonPlayer
|
||||
└── YnisonWebSocket
|
||||
```
|
||||
|
||||
**IRequestProvider:**
|
||||
|
||||
```csharp
|
||||
public interface IRequestProvider
|
||||
{
|
||||
// Выполняет HTTP запрос
|
||||
Task<HttpResponseMessage> GetWebResponseAsync(
|
||||
HttpRequestMessage message,
|
||||
HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead);
|
||||
|
||||
// Преобразует ответ в модель
|
||||
Task<T> GetDataFromResponseAsync<T>(
|
||||
YandexMusicApi api,
|
||||
HttpResponseMessage response);
|
||||
}
|
||||
```
|
||||
|
||||
## 🔄 Потоки данных
|
||||
|
||||
### Типичный поток запроса
|
||||
|
||||
```
|
||||
1. User Action
|
||||
↓
|
||||
2. YandexMusicClient Method
|
||||
↓
|
||||
3. YandexMusicApi.SomeAPI.SomeMethodAsync()
|
||||
↓
|
||||
4. YRequestBuilder<T> builds HttpRequestMessage
|
||||
↓
|
||||
5. IRequestProvider.GetWebResponseAsync()
|
||||
↓
|
||||
6. HttpClient sends request
|
||||
↓
|
||||
7. Server responds with HttpResponseMessage
|
||||
↓
|
||||
8. IRequestProvider.GetDataFromResponseAsync<T>()
|
||||
↓
|
||||
9. System.Text.Json deserializes to Model<T>
|
||||
↓
|
||||
10. Model<T> returned to caller
|
||||
```
|
||||
|
||||
### Пример: Получение трека
|
||||
|
||||
```csharp
|
||||
// 1. Пользователь вызывает метод
|
||||
var track = await client.Api.Track.GetTrackAsync("trackId123");
|
||||
|
||||
// 2. YTrackAPI.GetTrackAsync() создаёт запрос
|
||||
var builder = new YGetTracksBuilder(api);
|
||||
var message = builder.Build("trackId123");
|
||||
|
||||
// 3. Запрос отправляется через провайдер
|
||||
var response = await authStorage.Provider.GetWebResponseAsync(message);
|
||||
|
||||
// 4. Ответ преобразуется в модель
|
||||
var track = await provider.GetDataFromResponseAsync<YTrack>(api, response);
|
||||
|
||||
// 5. Модель возвращается пользователю
|
||||
return track;
|
||||
```
|
||||
|
||||
## 🎯 Паттерны проектирования
|
||||
|
||||
### 1. Facade Pattern (YandexMusicApi)
|
||||
|
||||
```csharp
|
||||
// Фасад предоставляет простой интерфейс к сложной подсистеме
|
||||
public class YandexMusicApi
|
||||
{
|
||||
public YAlbumAPI Album { get; } // Скрывает сложность
|
||||
public YArtistAPI Artist { get; } // инициализации
|
||||
public YTrackAPI Track { get; } // каждого компонента
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Builder Pattern (Request Builders)
|
||||
|
||||
```csharp
|
||||
// Построитель для конструирования запросов
|
||||
public class YSearchBuilder : YRequestBuilder<YSearch>
|
||||
{
|
||||
private string _query;
|
||||
private int _page = 0;
|
||||
|
||||
public YSearchBuilder Query(string q) { _query = q; return this; }
|
||||
public YSearchBuilder Page(int p) { _page = p; return this; }
|
||||
public override HttpRequestMessage Build() { /* ... */ }
|
||||
}
|
||||
|
||||
// Использование
|
||||
var search = new YSearchBuilder()
|
||||
.Query("Beatles")
|
||||
.Page(1)
|
||||
.Build();
|
||||
```
|
||||
|
||||
### 3. Strategy Pattern (IRequestProvider)
|
||||
|
||||
```csharp
|
||||
// Интерфейс позволяет выбрать стратегию обработки запросов
|
||||
public interface IRequestProvider
|
||||
{
|
||||
Task<HttpResponseMessage> GetWebResponseAsync(HttpRequestMessage message);
|
||||
}
|
||||
|
||||
// Разные реализации для разных сценариев
|
||||
public class DefaultRequestProvider : IRequestProvider { }
|
||||
public class MockRequestProvider : IRequestProvider { }
|
||||
public class CustomRequestProvider : IRequestProvider { }
|
||||
```
|
||||
|
||||
### 4. Dependency Injection
|
||||
|
||||
```csharp
|
||||
// Зависимости передаются через конструктор
|
||||
public class YTrackAPI : YCommonAPI
|
||||
{
|
||||
protected readonly YandexMusicApi api; // Injected
|
||||
|
||||
public YTrackAPI(YandexMusicApi yandex) => api = yandex;
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Template Method Pattern
|
||||
|
||||
```csharp
|
||||
// Базовый класс определяет структуру
|
||||
public abstract class YCommonAPI
|
||||
{
|
||||
protected readonly YandexMusicApi api;
|
||||
protected YCommonAPI(YandexMusicApi yandex) => api = yandex;
|
||||
}
|
||||
|
||||
// Подклассы реализуют специфичные методы
|
||||
public class YAlbumAPI : YCommonAPI
|
||||
{
|
||||
// Реализация методов для альбомов
|
||||
}
|
||||
```
|
||||
|
||||
## 🧩 Модульная структура
|
||||
|
||||
### Логическое разделение на модули
|
||||
|
||||
```
|
||||
Core Module (YandexMusic.API)
|
||||
├── Authentication Module
|
||||
│ ├── AuthStorage
|
||||
│ └── Authorization methods
|
||||
├── Request Module
|
||||
│ ├── IRequestProvider
|
||||
│ ├── Request builders
|
||||
│ └── HttpContext
|
||||
├── Model Module
|
||||
│ └── All data models
|
||||
└── API Module
|
||||
├── YAlbumAPI
|
||||
├── YTrackAPI
|
||||
├── YPlaylistAPI
|
||||
└── ... (each API endpoint)
|
||||
|
||||
Client Module (YandexMusic)
|
||||
├── YandexMusicClient
|
||||
└── Client configuration
|
||||
|
||||
CLI Module (YaMusicCli)
|
||||
├── Program.cs
|
||||
└── CLI commands
|
||||
```
|
||||
|
||||
### Зависимости между модулями
|
||||
|
||||
```
|
||||
CLI Module
|
||||
↓ depends on
|
||||
Client Module
|
||||
↓ depends on
|
||||
Core Module
|
||||
├── Authentication Module
|
||||
├── Request Module
|
||||
├── Model Module
|
||||
└── API Module
|
||||
```
|
||||
|
||||
## 🔌 Расширяемость
|
||||
|
||||
### Как добавить новый API метод
|
||||
|
||||
```csharp
|
||||
// 1. Создайте builder в Requests/YourCategory/
|
||||
public class YGetYourResourceBuilder : YRequestBuilder<YYourResource>
|
||||
{
|
||||
public override HttpRequestMessage Build()
|
||||
{
|
||||
// Реализуйте построение запроса
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Добавьте метод в соответствующий API класс
|
||||
public class YYourResourceAPI : YCommonAPI
|
||||
{
|
||||
public async Task<YYourResource?> GetYourResourceAsync(string id)
|
||||
{
|
||||
var builder = new YGetYourResourceBuilder(api);
|
||||
var request = builder.Build();
|
||||
return await new YRequest<YYourResource?>(request, api, api.AuthStorage).GetResponseAsync();
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Используйте новый метод
|
||||
var resource = await api.YourResource.GetYourResourceAsync("id123");
|
||||
```
|
||||
|
||||
### Как создать custom RequestProvider
|
||||
|
||||
```csharp
|
||||
public class MyCustomRequestProvider : IRequestProvider
|
||||
{
|
||||
private readonly AuthStorage _storage;
|
||||
|
||||
public MyCustomRequestProvider(AuthStorage storage)
|
||||
{
|
||||
_storage = storage;
|
||||
}
|
||||
|
||||
public async Task<HttpResponseMessage> GetWebResponseAsync(
|
||||
HttpRequestMessage message,
|
||||
HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead)
|
||||
{
|
||||
// Ваша логика обработки запроса
|
||||
using var handler = new HttpClientHandler();
|
||||
using var client = new HttpClient(handler);
|
||||
return await client.SendAsync(message, completionOption);
|
||||
}
|
||||
|
||||
public async Task<T> GetDataFromResponseAsync<T>(
|
||||
YandexMusicApi api,
|
||||
HttpResponseMessage response)
|
||||
{
|
||||
// Ваша логика десериализации
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
return JsonSerializer.Deserialize<T>(content) ?? default!;
|
||||
}
|
||||
}
|
||||
|
||||
// Использование
|
||||
var provider = new MyCustomRequestProvider(storage);
|
||||
var storage = new AuthStorage(provider);
|
||||
```
|
||||
|
||||
### Как создать extension method
|
||||
|
||||
```csharp
|
||||
public static class YPlaylistExtensions
|
||||
{
|
||||
public static async Task<IEnumerable<YTrack>> GetAllTracksAsync(
|
||||
this YPlaylist playlist,
|
||||
YandexMusicApi api)
|
||||
{
|
||||
// Расширение функциональности существующего класса
|
||||
var allTracks = new List<YTrack>();
|
||||
var currentPage = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var page = await api.Playlist.GetPlaylistAsync(
|
||||
playlist.Uid,
|
||||
page: currentPage);
|
||||
|
||||
if (page?.Tracks?.Count == 0)
|
||||
break;
|
||||
|
||||
allTracks.AddRange(page.Tracks ?? []);
|
||||
currentPage++;
|
||||
}
|
||||
|
||||
return allTracks;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 📊 Диаграмма взаимодействия компонентов
|
||||
|
||||
```
|
||||
┌──────────────────┐
|
||||
│ CLI Layer │
|
||||
│ (YaMusicCli) │
|
||||
└────────┬─────────┘
|
||||
│
|
||||
↓
|
||||
┌──────────────────────────┐
|
||||
│ Client Layer │
|
||||
│ (YandexMusicClient) │
|
||||
└────────┬─────────────────┘
|
||||
│
|
||||
↓
|
||||
┌────────────────────────────────────┐
|
||||
│ API Layer │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ YandexMusicApi (Facade) │ │
|
||||
│ └──┬──────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌──┴─────────┬─────────────────┐│
|
||||
│ ↓ ↓ ↓│
|
||||
│ YTrackAPI YPlaylistAPI YSearchAPI │
|
||||
│ │ │ │ │
|
||||
│ └──────┬─────┴────────┬────────┘ │
|
||||
└─────────┼──────────────┼───────────┘
|
||||
│ │
|
||||
↓ ↓
|
||||
┌──────────────────────────┐
|
||||
│ IRequestProvider │
|
||||
│ (Strategy Pattern) │
|
||||
└──────┬───────────────────┘
|
||||
│
|
||||
┌──┴───┬──────────┐
|
||||
↓ ↓ ↓
|
||||
Default Common Mock
|
||||
Provider Provider Provider
|
||||
│ │ │
|
||||
└──────┼────────┘
|
||||
↓
|
||||
┌──────────────────┐
|
||||
│ HttpClient │
|
||||
└──────┬───────────┘
|
||||
│
|
||||
↓
|
||||
┌──────────────────┐
|
||||
│ Network Layer │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
## 🔐 Безопасность архитектуры
|
||||
|
||||
1. **Инкапсуляция** - приватные поля и контролируемый доступ
|
||||
2. **Валидация** - все входные параметры валидируются
|
||||
3. **Null-safety** - полная поддержка nullable reference types
|
||||
4. **Async-safe** - асинхронные операции без blocking
|
||||
5. **Encryption** - встроенное шифрование для чувствительных данных
|
||||
410
CONTRIBUTING.md
Normal file
410
CONTRIBUTING.md
Normal file
@@ -0,0 +1,410 @@
|
||||
# 🤝 Руководство для участников
|
||||
|
||||
Спасибо за интерес к проекту YandexMusic! Этот документ содержит рекомендации для тех, кто хочет внести свой вклад в развитие проекта.
|
||||
|
||||
## 📋 Содержание
|
||||
|
||||
- [Кодекс поведения](#кодекс-поведения)
|
||||
- [Как начать](#как-начать)
|
||||
- [Процесс разработки](#процесс-разработки)
|
||||
- [Стандарты кода](#стандарты-кода)
|
||||
- [Commit сообщения](#commit-сообщения)
|
||||
- [Pull Requests](#pull-requests)
|
||||
- [Отчёты об ошибках](#отчёты-об-ошибках)
|
||||
- [Предложения по улучшениям](#предложения-по-улучшениям)
|
||||
|
||||
## 🤐 Кодекс поведения
|
||||
|
||||
- Будьте уважительны к другим участникам
|
||||
- Не допускайте дискриминацию, оскорбления и враждебного поведения
|
||||
- Критикуйте идеи, а не людей
|
||||
- Решайте конфликты конструктивно
|
||||
|
||||
## 🚀 Как начать
|
||||
|
||||
### 1. Подготовка окружения
|
||||
|
||||
```bash
|
||||
# Клонируем репозиторий
|
||||
git clone https://git.frigat.duckdns.org/FrigaT/YandexMusic.git
|
||||
cd YandexMusic
|
||||
|
||||
# Создаём ветку для своей работы
|
||||
git checkout -b feature/my-feature
|
||||
|
||||
# Восстанавливаем зависимости
|
||||
dotnet restore
|
||||
|
||||
# Собираем проект
|
||||
dotnet build
|
||||
```
|
||||
|
||||
### 2. Установка инструментов
|
||||
|
||||
- **Visual Studio 2026 Enterprise** (рекомендуется)
|
||||
- **Visual Studio Code** + C# extension (альтернатива)
|
||||
- **.NET 10 SDK**
|
||||
|
||||
### 3. Запуск тестов
|
||||
|
||||
```bash
|
||||
dotnet test
|
||||
```
|
||||
|
||||
## 🔄 Процесс разработки
|
||||
|
||||
### Workflow
|
||||
|
||||
```
|
||||
1. Выберите issue или создайте новый
|
||||
2. Создайте feature-ветку
|
||||
3. Внесите изменения
|
||||
4. Напишите/обновите тесты
|
||||
5. Убедитесь что код собирается и тесты проходят
|
||||
6. Создайте Pull Request
|
||||
7. Ждите review
|
||||
8. Исправьте замечания если нужно
|
||||
9. Merge в master
|
||||
```
|
||||
|
||||
### Версионирование веток
|
||||
|
||||
```
|
||||
master → Production версия
|
||||
├── feature/* → Новые функции
|
||||
├── bugfix/* → Исправления ошибок
|
||||
├── refactor/* → Рефакторинг кода
|
||||
└── docs/* → Обновления документации
|
||||
```
|
||||
|
||||
## 📐 Стандарты кода
|
||||
|
||||
### Общие правила
|
||||
|
||||
- **Язык:** C# 12
|
||||
- **Платформа:** .NET 10
|
||||
- **Стиль:** Microsoft C# Coding Conventions
|
||||
- **Документация:** Все публичные члены должны иметь XML документацию на русском
|
||||
|
||||
### Пример документированного кода
|
||||
|
||||
```csharp
|
||||
namespace YandexMusic.API.API;
|
||||
|
||||
/// <summary>API для работы с альбомами.</summary>
|
||||
public class YAlbumAPI : YCommonAPI
|
||||
{
|
||||
/// <summary>Инициализирует новый экземпляр.</summary>
|
||||
/// <param name="yandex">Экземпляр основного API.</param>
|
||||
public YAlbumAPI(YandexMusicApi yandex) : base(yandex) { }
|
||||
|
||||
/// <summary>Получает информацию об альбоме по идентификатору.</summary>
|
||||
/// <param name="albumId">Идентификатор альбома</param>
|
||||
/// <returns>Модель альбома или null если не найден</returns>
|
||||
/// <exception cref="ArgumentNullException">Если albumId null</exception>
|
||||
public async Task<YAlbum?> GetAlbumAsync(string albumId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(albumId);
|
||||
|
||||
// Реализация
|
||||
return await Task.FromResult<YAlbum?>(null);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Правила именования
|
||||
|
||||
```csharp
|
||||
// Классы: PascalCase
|
||||
public class YandexMusicApi { }
|
||||
|
||||
// Методы: PascalCase с Async суффиксом для асинхронных
|
||||
public async Task<YTrack?> GetTrackAsync(string trackId) { }
|
||||
|
||||
// Свойства: PascalCase
|
||||
public YandexMusicApi Api { get; }
|
||||
|
||||
// Приватные поля: _camelCase
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
// Параметры: camelCase
|
||||
public void DoSomething(string userName, int userId) { }
|
||||
|
||||
// Локальные переменные: camelCase
|
||||
var trackList = new List<YTrack>();
|
||||
```
|
||||
|
||||
### Code Style
|
||||
|
||||
Используйте `.editorconfig` для автоматического форматирования:
|
||||
|
||||
```ini
|
||||
# Отступы - 4 пробела
|
||||
indent_size = 4
|
||||
|
||||
# Новая строка для фигурных скобок
|
||||
csharp_new_line_before_open_brace = all
|
||||
|
||||
# Используйте var где возможно
|
||||
csharp_style_var_for_built_in_types = true
|
||||
|
||||
# Null-forgiving operator с осторожностью
|
||||
csharp_style_null_forgiving_operator = false
|
||||
```
|
||||
|
||||
### Nullable Reference Types
|
||||
|
||||
Всегда включен `<Nullable>enable</Nullable>`. Правила:
|
||||
|
||||
```csharp
|
||||
// ✅ Хорошо - явно указано может быть null
|
||||
public string? GetUserName() { }
|
||||
|
||||
// ✅ Хорошо - явно не null
|
||||
public string GetTitle() => "Title";
|
||||
|
||||
// ❌ Плохо - неявная nullable ссылка
|
||||
public object GetSomething() { }
|
||||
|
||||
// ✅ Хорошо - используйте null-coalescing
|
||||
var result = value ?? defaultValue;
|
||||
|
||||
// ✅ Хорошо - используйте null-conditional
|
||||
var count = list?.Count ?? 0;
|
||||
```
|
||||
|
||||
### Асинхронное программирование
|
||||
|
||||
```csharp
|
||||
// ✅ Хорошо - async/await
|
||||
public async Task<YTrack?> GetTrackAsync(string id)
|
||||
{
|
||||
return await api.Track.GetTrackAsync(id);
|
||||
}
|
||||
|
||||
// ❌ Плохо - Task.Result блокирует поток
|
||||
public YTrack? GetTrack(string id)
|
||||
{
|
||||
return api.Track.GetTrackAsync(id).Result;
|
||||
}
|
||||
|
||||
// ✅ Хорошо - ConfigureAwait(false) в библиотеках
|
||||
public async Task<YTrack?> GetTrackAsync(string id)
|
||||
{
|
||||
return await api.Track.GetTrackAsync(id).ConfigureAwait(false);
|
||||
}
|
||||
```
|
||||
|
||||
### Обработка ошибок
|
||||
|
||||
```csharp
|
||||
// ✅ Хорошо
|
||||
public async Task<YTrack?> GetTrackAsync(string trackId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(trackId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(trackId);
|
||||
|
||||
try
|
||||
{
|
||||
return await _provider.GetTrackAsync(trackId);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
Logger.LogError(ex, "Ошибка при получении трека");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ Плохо - молча игнорируем ошибки
|
||||
public async Task<YTrack?> GetTrackAsync(string trackId)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _provider.GetTrackAsync(trackId);
|
||||
}
|
||||
catch { }
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
### Структура файла
|
||||
|
||||
```csharp
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace YandexMusic.API.API;
|
||||
|
||||
/// <summary>API для работы с треками.</summary>
|
||||
public class YTrackAPI : YCommonAPI
|
||||
{
|
||||
/// <summary>Инициализирует новый экземпляр.</summary>
|
||||
public YTrackAPI(YandexMusicApi yandex) : base(yandex) { }
|
||||
|
||||
/// <summary>Получает трек.</summary>
|
||||
public async Task<YTrack?> GetTrackAsync(string trackId)
|
||||
{
|
||||
// реализация
|
||||
}
|
||||
|
||||
/// <summary>Получает несколько треков.</summary>
|
||||
public async Task<IEnumerable<YTrack>> GetTracksAsync(IEnumerable<string> trackIds)
|
||||
{
|
||||
// реализация
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 📝 Commit сообщения
|
||||
|
||||
### Формат
|
||||
|
||||
```
|
||||
<type>(<scope>): <subject>
|
||||
|
||||
<body>
|
||||
|
||||
<footer>
|
||||
```
|
||||
|
||||
### Type
|
||||
|
||||
- `feat:` - новая функция
|
||||
- `fix:` - исправление ошибки
|
||||
- `docs:` - обновление документации
|
||||
- `style:` - форматирование кода (не влияет на функциональность)
|
||||
- `refactor:` - переписывание кода (не меняет функциональность)
|
||||
- `perf:` - улучшение производительности
|
||||
- `test:` - добавление или обновление тестов
|
||||
- `ci:` - изменения в CI/CD
|
||||
- `chore:` - обновление зависимостей, версии и т.д.
|
||||
|
||||
### Примеры
|
||||
|
||||
```bash
|
||||
git commit -m "feat(track-api): add support for track recommendations"
|
||||
|
||||
git commit -m "fix(auth): handle refresh token expiration correctly"
|
||||
|
||||
git commit -m "docs(readme): update installation instructions"
|
||||
|
||||
git commit -m "refactor(models): simplify YTrack class structure"
|
||||
|
||||
git commit -m "test(playlist): add tests for playlist operations"
|
||||
```
|
||||
|
||||
## 🔀 Pull Requests
|
||||
|
||||
### Перед созданием PR
|
||||
|
||||
- [ ] Код собирается без ошибок: `dotnet build`
|
||||
- [ ] Все тесты проходят: `dotnet test`
|
||||
- [ ] Код отформатирован согласно стилю
|
||||
- [ ] Добавлена XML документация для всех публичных членов
|
||||
- [ ] Обновлена документация если нужно
|
||||
|
||||
### Шаблон PR
|
||||
|
||||
```markdown
|
||||
## Описание
|
||||
Краткое описание внесённых изменений.
|
||||
|
||||
## Тип изменения
|
||||
- [ ] Новая функция (non-breaking change)
|
||||
- [ ] Исправление ошибки (non-breaking change)
|
||||
- [ ] Breaking change (поясните почему)
|
||||
- [ ] Обновление документации
|
||||
|
||||
## Как это было протестировано?
|
||||
Опишите шаги для тестирования.
|
||||
|
||||
## Связанные Issues
|
||||
Closes #issue_number
|
||||
|
||||
## Чеклист
|
||||
- [ ] Код следует стилю проекта
|
||||
- [ ] Добавлена документация
|
||||
- [ ] Нет новых warnings
|
||||
- [ ] Тесты проходят
|
||||
- [ ] Изменения совместимы с существующим кодом
|
||||
```
|
||||
|
||||
## 🐛 Отчёты об ошибках
|
||||
|
||||
### Шаблон Issue
|
||||
|
||||
```markdown
|
||||
## Описание ошибки
|
||||
Четкое и краткое описание проблемы.
|
||||
|
||||
## Шаги воспроизведения
|
||||
1. Сделайте...
|
||||
2. Затем...
|
||||
3. Ошибка воспроизведится
|
||||
|
||||
## Ожидаемое поведение
|
||||
Что должно было происходить.
|
||||
|
||||
## Фактическое поведение
|
||||
Что происходит на самом деле.
|
||||
|
||||
## Окружение
|
||||
- OS: Windows 11
|
||||
- .NET Version: 10.0
|
||||
- Visual Studio: 2026 Enterprise
|
||||
- YandexMusic Version: 0.0.1
|
||||
|
||||
## Дополнительный контекст
|
||||
Скриптстек, логи, код примера.
|
||||
```
|
||||
|
||||
## 💡 Предложения по улучшениям
|
||||
|
||||
### Шаблон Feature Request
|
||||
|
||||
```markdown
|
||||
## Описание
|
||||
Четкое описание желаемой функции.
|
||||
|
||||
## Использование
|
||||
Как бы выглядел код с использованием этой функции?
|
||||
|
||||
## Альтернативы
|
||||
Есть ли другие способы решить эту проблему?
|
||||
|
||||
## Дополнительный контекст
|
||||
Почему это нужно добавить?
|
||||
```
|
||||
|
||||
## 🎯 Области для внесения вклада
|
||||
|
||||
### High Priority
|
||||
|
||||
- [ ] Документация API методов
|
||||
- [ ] Unit тесты для API классов
|
||||
- [ ] Обработка ошибок
|
||||
- [ ] Оптимизация производительности
|
||||
|
||||
### Medium Priority
|
||||
|
||||
- [ ] Примеры использования
|
||||
- [ ] Интеграционные тесты
|
||||
- [ ] Улучшение логирования
|
||||
- [ ] Расширение функциональности
|
||||
|
||||
### Low Priority
|
||||
|
||||
- [ ] Рефакторинг кода
|
||||
- [ ] Улучшение читаемости
|
||||
- [ ] Документация
|
||||
|
||||
## 📞 Контакты
|
||||
|
||||
Вопросы? Свяжитесь с разработчиком:
|
||||
|
||||
- **Issues:** [https://git.frigat.duckdns.org/FrigaT/YandexMusic/issues]
|
||||
|
||||
---
|
||||
|
||||
Спасибо за то, что делаете YandexMusic лучше! 🚀
|
||||
446
FAQ.md
Normal file
446
FAQ.md
Normal file
@@ -0,0 +1,446 @@
|
||||
# ❓ FAQ - Часто задаваемые вопросы
|
||||
|
||||
Ответы на самые частые вопросы по использованию YandexMusic.
|
||||
|
||||
## 📋 Содержание
|
||||
|
||||
- [Общие вопросы](#общие-вопросы)
|
||||
- [Установка и настройка](#установка-и-настройка)
|
||||
- [Использование](#использование)
|
||||
- [Авторизация](#авторизация)
|
||||
- [Ошибки и проблемы](#ошибки-и-проблемы)
|
||||
- [Производительность](#производительность)
|
||||
- [Разработка](#разработка)
|
||||
|
||||
## 📖 Общие вопросы
|
||||
|
||||
### Q: Что такое YandexMusic?
|
||||
|
||||
**A:** YandexMusic — это .NET 10 библиотека для работы с неофициальным API Яндекс Музыки. Она позволяет искать музыку, управлять плейлистами, получать информацию об альбомах и исполнителях, а также работать с WebSocket через протокол Ynison.
|
||||
|
||||
### Q: Это официальная библиотека?
|
||||
|
||||
**A:** Нет, это неофициальная библиотека. Используйте её на свой риск и соблюдайте Terms of Service Яндекс Музыки.
|
||||
|
||||
### Q: На каких платформах работает?
|
||||
|
||||
**A:** На любых платформах, поддерживающих .NET 10:
|
||||
- Windows (10/11)
|
||||
- Linux (Ubuntu, Debian, Red Hat, etc.)
|
||||
- macOS (Intel & Apple Silicon)
|
||||
|
||||
### Q: Нужна ли авторизация?
|
||||
|
||||
**A:** Нет, многие операции работают без авторизации (поиск, получение информации о треках). Но для некоторых операций (работа с лайками, плейлистами) требуется авторизация.
|
||||
|
||||
### Q: Где я могу сообщить об ошибке?
|
||||
|
||||
**A:** Создайте issue на GitHub:
|
||||
- https://git.frigat.duckdns.org/FrigaT/YandexMusic/issues
|
||||
|
||||
## 🚀 Установка и настройка
|
||||
|
||||
### Q: Какие требования?
|
||||
|
||||
**A:**
|
||||
- .NET 10 SDK или выше
|
||||
- C# 12 совместимый компилятор
|
||||
- Интернет соединение
|
||||
|
||||
### Q: Как установить библиотеку?
|
||||
|
||||
**A:** Несколько способов:
|
||||
|
||||
1. **Через NuGet:**
|
||||
```bash
|
||||
dotnet add package YandexMusic
|
||||
```
|
||||
|
||||
2. **Из GitHub:**
|
||||
```bash
|
||||
git clone https://git.frigat.duckdns.org/FrigaT/YandexMusic.git
|
||||
cd YandexMusic
|
||||
dotnet build
|
||||
```
|
||||
|
||||
3. **Добавить ссылку на проект:**
|
||||
```bash
|
||||
dotnet add reference ../YandexMusic/YandexMusic.csproj
|
||||
```
|
||||
|
||||
### Q: Как обновить библиотеку?
|
||||
|
||||
**A:** Через NuGet:
|
||||
```bash
|
||||
dotnet package update YandexMusic
|
||||
```
|
||||
|
||||
Или если вы клонировали репозиторий:
|
||||
```bash
|
||||
git pull origin master
|
||||
dotnet build
|
||||
```
|
||||
|
||||
### Q: Какие зависимости нужны?
|
||||
|
||||
**A:** Только встроенные в .NET 10:
|
||||
- System.Net.Http
|
||||
- System.Text.Json
|
||||
- System.Net.WebSockets
|
||||
|
||||
Нет дополнительных NuGet пакетов!
|
||||
|
||||
## 💻 Использование
|
||||
|
||||
### Q: Как начать использовать?
|
||||
|
||||
**A:** Самый простой способ:
|
||||
|
||||
```csharp
|
||||
using YandexMusic;
|
||||
|
||||
var client = new YandexMusicClient();
|
||||
var results = await client.Api.Search.SearchAsync("Beatles");
|
||||
```
|
||||
|
||||
Подробнее в [QUICKSTART.md](QUICKSTART.md).
|
||||
|
||||
### Q: Как получить информацию о треке?
|
||||
|
||||
**A:**
|
||||
```csharp
|
||||
var track = await client.Api.Track.GetTrackAsync("trackId");
|
||||
Console.WriteLine($"{track?.Title} - {track?.Artists?.FirstOrDefault()?.Title}");
|
||||
```
|
||||
|
||||
### Q: Как найти ID трека/альбома/плейлиста?
|
||||
|
||||
**A:**
|
||||
- В URL страницы на music.yandex.ru
|
||||
- Через API при поиске
|
||||
- На официальном сайте в адресной строке
|
||||
|
||||
Примеры:
|
||||
- Трек: `https://music.yandex.ru/album/123/track/456` → ID: `456`
|
||||
- Альбом: `https://music.yandex.ru/album/123` → ID: `123`
|
||||
- Плейлист: `https://music.yandex.ru/playlist/123` → ID: `123`
|
||||
|
||||
### Q: Почему я получаю null?
|
||||
|
||||
**A:** Несколько причин:
|
||||
|
||||
1. Неправильный ID
|
||||
2. Ресурс был удален
|
||||
3. Нет доступа (требуется авторизация)
|
||||
4. Сервер вернул ошибку
|
||||
|
||||
Всегда проверяйте результат:
|
||||
```csharp
|
||||
var track = await client.Api.Track.GetTrackAsync(id);
|
||||
if (track == null)
|
||||
{
|
||||
Console.WriteLine("Трек не найден или нет доступа");
|
||||
}
|
||||
```
|
||||
|
||||
### Q: Как работать с пагинацией?
|
||||
|
||||
**A:** Используйте параметр `page`:
|
||||
|
||||
```csharp
|
||||
var page1 = await client.Api.Search.SearchAsync("query", page: 0);
|
||||
var page2 = await client.Api.Search.SearchAsync("query", page: 1);
|
||||
```
|
||||
|
||||
### Q: Как получить большое количество данных?
|
||||
|
||||
**A:** Используйте цикл с пагинацией:
|
||||
|
||||
```csharp
|
||||
var allResults = new List<YTrack>();
|
||||
int page = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var search = await client.Api.Search.SearchAsync("query", page: page);
|
||||
if (search?.Tracks?.Results?.Count == 0) break;
|
||||
|
||||
allResults.AddRange(search.Tracks.Results ?? []);
|
||||
page++;
|
||||
}
|
||||
```
|
||||
|
||||
## 🔐 Авторизация
|
||||
|
||||
### Q: Как авторизироваться?
|
||||
|
||||
**A:** Два способа:
|
||||
|
||||
1. **Через логин и пароль:**
|
||||
```csharp
|
||||
await client.AuthorizeAsync("email@gmail.com", "password");
|
||||
```
|
||||
|
||||
2. **Через токен:**
|
||||
```csharp
|
||||
await client.AuthorizeByTokenAsync("your-oauth-token");
|
||||
```
|
||||
|
||||
### Q: Где получить OAuth токен?
|
||||
|
||||
**A:** Вы можете:
|
||||
1. Авторизироваться через логин/пароль (библиотека получит токен автоматически)
|
||||
2. Получить токен через официальное приложение Яндекса
|
||||
3. Использовать токен из DevTools браузера
|
||||
|
||||
### Q: Как проверить авторизирован ли я?
|
||||
|
||||
**A:**
|
||||
```csharp
|
||||
if (client.IsAuthorized)
|
||||
{
|
||||
Console.WriteLine($"Авторизирован: {client.Account.User?.DisplayName}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Console.WriteLine("Требуется авторизация");
|
||||
}
|
||||
```
|
||||
|
||||
### Q: Сохраняется ли авторизация?
|
||||
|
||||
**A:** По умолчанию нет. Вы должны авторизироваться при каждом запуске.
|
||||
|
||||
Если хотите сохранить токен:
|
||||
```csharp
|
||||
// После авторизации
|
||||
string token = client.AuthStorage.Token;
|
||||
|
||||
// Сохранить в файл, БД и т.д.
|
||||
// ...
|
||||
|
||||
// При следующем запуске
|
||||
var client = new YandexMusicClient();
|
||||
await client.AuthorizeByTokenAsync(savedToken);
|
||||
```
|
||||
|
||||
### Q: Безопасно ли хранить пароль?
|
||||
|
||||
**A:** **Нет!** Никогда не сохраняйте пароль в открытом виде. Лучше:
|
||||
1. Используйте OAuth токены
|
||||
2. Сохраняйте токены в безопасном хранилище
|
||||
3. Используйте переменные окружения
|
||||
|
||||
## ⚠️ Ошибки и проблемы
|
||||
|
||||
### Q: Получаю `HttpRequestException`
|
||||
|
||||
**A:** Проблемы с сетью или сервером:
|
||||
```csharp
|
||||
try
|
||||
{
|
||||
var track = await client.Api.Track.GetTrackAsync(id);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
Console.WriteLine($"Сетевая ошибка: {ex.Message}");
|
||||
}
|
||||
```
|
||||
|
||||
Проверьте:
|
||||
- Интернет соединение
|
||||
- Доступность сервера Яндекса
|
||||
- Firewall/прокси настройки
|
||||
|
||||
### Q: Получаю `JsonException`
|
||||
|
||||
**A:** Проблема с десериализацией JSON. Обновите библиотеку или сообщите об issue.
|
||||
|
||||
### Q: Получаю 401 Unauthorized
|
||||
|
||||
**A:** Проблема с авторизацией:
|
||||
```csharp
|
||||
// Проверьте токен
|
||||
if (!client.IsAuthorized)
|
||||
{
|
||||
await client.AuthorizeAsync("email", "password");
|
||||
}
|
||||
|
||||
// Или обновите токен
|
||||
await client.AuthorizeByTokenAsync(newToken);
|
||||
```
|
||||
|
||||
### Q: Получаю 429 Too Many Requests
|
||||
|
||||
**A:** Вы отправляете слишком много запросов. Добавьте задержку:
|
||||
```csharp
|
||||
for (int i = 0; i < ids.Count; i++)
|
||||
{
|
||||
var track = await client.Api.Track.GetTrackAsync(ids[i]);
|
||||
if (i < ids.Count - 1) await Task.Delay(100); // 100ms задержка
|
||||
}
|
||||
```
|
||||
|
||||
### Q: Результаты пусты/null
|
||||
|
||||
**A:** Несколько причин:
|
||||
|
||||
1. **Неправильный ID:**
|
||||
```csharp
|
||||
var track = await client.Api.Track.GetTrackAsync("invalid-id");
|
||||
// Будет null если ID неверный
|
||||
```
|
||||
|
||||
2. **Нет результатов поиска:**
|
||||
```csharp
|
||||
var results = await client.Api.Search.SearchAsync("абракадабра");
|
||||
// results?.Tracks?.Results будет пусто
|
||||
```
|
||||
|
||||
3. **Требуется авторизация:**
|
||||
```csharp
|
||||
var library = await client.Api.Library.GetLibraryAsync();
|
||||
// Вернёт null если не авторизирован
|
||||
```
|
||||
|
||||
### Q: Почему медленно работает?
|
||||
|
||||
**A:** Несколько причин:
|
||||
1. Медленное интернет соединение
|
||||
2. Сервер Яндекса перегружен
|
||||
3. Получаете большое количество данных
|
||||
4. Блокирующие операции вместо async
|
||||
|
||||
Используйте асинхронные операции:
|
||||
```csharp
|
||||
// ✅ Хорошо
|
||||
var track = await client.Api.Track.GetTrackAsync(id);
|
||||
|
||||
// ❌ Плохо
|
||||
var track = client.Api.Track.GetTrackAsync(id).Result;
|
||||
```
|
||||
|
||||
## 🚀 Производительность
|
||||
|
||||
### Q: Как оптимизировать производительность?
|
||||
|
||||
**A:** Несколько советов:
|
||||
|
||||
1. **Используйте пакетные операции:**
|
||||
```csharp
|
||||
// ✅ Хорошо - один запрос
|
||||
var tracks = await client.Api.Track.GetTracksAsync(["id1", "id2", "id3"]);
|
||||
|
||||
// ❌ Плохо - три запроса
|
||||
foreach (var id in ids)
|
||||
{
|
||||
var track = await client.Api.Track.GetTrackAsync(id);
|
||||
}
|
||||
```
|
||||
|
||||
2. **Кешируйте результаты:**
|
||||
```csharp
|
||||
private Dictionary<string, YTrack?> cache = new();
|
||||
|
||||
public async Task<YTrack?> GetCached(string id)
|
||||
{
|
||||
if (cache.TryGetValue(id, out var cached)) return cached;
|
||||
var track = await client.Api.Track.GetTrackAsync(id);
|
||||
cache[id] = track;
|
||||
return track;
|
||||
}
|
||||
```
|
||||
|
||||
3. **Запускайте запросы параллельно:**
|
||||
```csharp
|
||||
var (albums, artists) = await (
|
||||
Task.Run(() => client.Api.Album.GetAlbumsAsync(ids)),
|
||||
Task.Run(() => client.Api.Artist.GetArtistsAsync(ids))
|
||||
).AsAsync();
|
||||
```
|
||||
|
||||
4. **Добавляйте задержку между запросами:**
|
||||
```csharp
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var track = await client.Api.Track.GetTrackAsync(id);
|
||||
await Task.Delay(100); // 100ms между запросами
|
||||
}
|
||||
```
|
||||
|
||||
### Q: Есть ли какие-то лимиты?
|
||||
|
||||
**A:** Яндекс Музыка имеет лимиты:
|
||||
- Количество запросов в секунду
|
||||
- Размер результатов поиска
|
||||
- Размер файлов для загрузки
|
||||
|
||||
Уважайте эти лимиты и не создавайте нагрузку на сервер.
|
||||
|
||||
## 🔧 Разработка
|
||||
|
||||
### Q: Как стать разработчиком?
|
||||
|
||||
**A:** Читайте [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
### Q: Как создать pull request?
|
||||
|
||||
**A:**
|
||||
1. Форкните репозиторий
|
||||
2. Создайте ветку: `git checkout -b feature/my-feature`
|
||||
3. Сделайте изменения
|
||||
4. Коммитьте: `git commit -m "feat: описание"`
|
||||
5. Пушьте: `git push origin feature/my-feature`
|
||||
6. Создайте Pull Request
|
||||
|
||||
### Q: Как собрать проект?
|
||||
|
||||
**A:**
|
||||
```bash
|
||||
dotnet build
|
||||
```
|
||||
|
||||
### Q: Как запустить тесты?
|
||||
|
||||
**A:**
|
||||
```bash
|
||||
dotnet test
|
||||
```
|
||||
|
||||
### Q: Есть ли стиль кода?
|
||||
|
||||
**A:** Да, используется Microsoft C# Coding Conventions. Смотрите [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
### Q: Как добавить новый API метод?
|
||||
|
||||
**A:** Прочитайте [ARCHITECTURE.md](ARCHITECTURE.md) раздел "Как добавить новый API метод".
|
||||
|
||||
### Q: Как создать тест?
|
||||
|
||||
**A:** Создайте файл в папке Tests:
|
||||
```csharp
|
||||
using Xunit;
|
||||
using YandexMusic.API;
|
||||
|
||||
public class YTrackAPITests
|
||||
{
|
||||
[Fact]
|
||||
public async Task GetTrackAsync_WithValidId_ReturnsTrack()
|
||||
{
|
||||
// Arrange
|
||||
var api = new YandexMusicApi();
|
||||
string trackId = "valid-id";
|
||||
|
||||
// Act
|
||||
var result = await api.Track.GetTrackAsync(trackId);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🆘 Не нашли ответ?
|
||||
|
||||
Создайте вопрос через GitHub Issues:
|
||||
https://git.frigat.duckdns.org/FrigaT/YandexMusic/issues
|
||||
@@ -1,23 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<base href="/" />
|
||||
<ResourcePreloader />
|
||||
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["app.css"]" />
|
||||
<link rel="stylesheet" href="@Assets["PlaylistShared.styles.css"]" />
|
||||
<ImportMap />
|
||||
<link rel="icon" type="image/png" href="favicon.png" />
|
||||
<HeadOutlet />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<Routes />
|
||||
<ReconnectModal />
|
||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,23 +0,0 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<div class="page">
|
||||
<div class="sidebar">
|
||||
<NavMenu />
|
||||
</div>
|
||||
|
||||
<main>
|
||||
<div class="top-row px-4">
|
||||
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
|
||||
</div>
|
||||
|
||||
<article class="content px-4">
|
||||
@Body
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="blazor-error-ui" data-nosnippet>
|
||||
An unhandled error has occurred.
|
||||
<a href="." class="reload">Reload</a>
|
||||
<span class="dismiss">🗙</span>
|
||||
</div>
|
||||
@@ -1,98 +0,0 @@
|
||||
.page {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
background-color: #f7f7f7;
|
||||
border-bottom: 1px solid #d6d5d5;
|
||||
justify-content: flex-end;
|
||||
height: 3.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
white-space: nowrap;
|
||||
margin-left: 1.5rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.top-row ::deep a:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
@media (max-width: 640.98px) {
|
||||
.top-row {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.page {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
height: 100vh;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.top-row {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.top-row.auth ::deep a:first-child {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.top-row, article {
|
||||
padding-left: 2rem !important;
|
||||
padding-right: 1.5rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
#blazor-error-ui {
|
||||
color-scheme: light only;
|
||||
background: lightyellow;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
left: 0;
|
||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
||||
position: fixed;
|
||||
width: 100%;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
#blazor-error-ui .dismiss {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 0.5rem;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
<div class="top-row ps-3 navbar navbar-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="">PlaylistShared</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
|
||||
|
||||
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
|
||||
<nav class="nav flex-column">
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
||||
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Главная
|
||||
</NavLink>
|
||||
</div>
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="myplaylists">
|
||||
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Мои плейлисты
|
||||
</NavLink>
|
||||
</div>
|
||||
@if (user?.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="Logout">
|
||||
<span class="bi bi-box-arrow-right"></span> Выйти
|
||||
</NavLink>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="nav-item px-3">
|
||||
<NavLink class="nav-link" href="Login">
|
||||
<span class="bi bi-box-arrow-in-right"></span> Войти
|
||||
</NavLink>
|
||||
</div>
|
||||
}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private AuthenticationState? _authState;
|
||||
private ClaimsPrincipal? user => _authState?.User;
|
||||
[CascadingParameter] private Task<AuthenticationState>? AuthenticationStateTask { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (AuthenticationStateTask is not null)
|
||||
_authState = await AuthenticationStateTask;
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
.navbar-toggler {
|
||||
appearance: none;
|
||||
cursor: pointer;
|
||||
width: 3.5rem;
|
||||
height: 2.5rem;
|
||||
color: white;
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 1rem;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.navbar-toggler:checked {
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.top-row {
|
||||
min-height: 3.5rem;
|
||||
background-color: rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.bi {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin-right: 0.75rem;
|
||||
top: -1px;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
.bi-house-door-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-plus-square-fill-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.bi-list-nested-nav-menu {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
font-size: 0.9rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-item:first-of-type {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.nav-item:last-of-type {
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link {
|
||||
color: #d7d7d7;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 3rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-item ::deep a.active {
|
||||
background-color: rgba(255,255,255,0.37);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-item ::deep .nav-link:hover {
|
||||
background-color: rgba(255,255,255,0.1);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navbar-toggler:checked ~ .nav-scrollable {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 641px) {
|
||||
.navbar-toggler {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-scrollable {
|
||||
/* Never collapse the sidebar for wide screens */
|
||||
display: block;
|
||||
|
||||
/* Allow sidebar to scroll for tall menus */
|
||||
height: calc(100vh - 3.5rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
.bi-box-arrow-right, .bi-box-arrow-in-right {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M10 12.5a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v2a.5.5 0 0 0 1 0v-2A1.5 1.5 0 0 0 9.5 2h-8A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-2a.5.5 0 0 0-1 0v2z'/%3E%3Cpath fill-rule='evenodd' d='M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 0 0-.708.708L14.293 7.5H5.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z'/%3E%3C/svg%3E");
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
|
||||
|
||||
<dialog id="components-reconnect-modal" data-nosnippet>
|
||||
<div class="components-reconnect-container">
|
||||
<div class="components-rejoining-animation" aria-hidden="true">
|
||||
<div></div>
|
||||
<div></div>
|
||||
</div>
|
||||
<p class="components-reconnect-first-attempt-visible">
|
||||
Rejoining the server...
|
||||
</p>
|
||||
<p class="components-reconnect-repeated-attempt-visible">
|
||||
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
|
||||
</p>
|
||||
<p class="components-reconnect-failed-visible">
|
||||
Failed to rejoin.<br />Please retry or reload the page.
|
||||
</p>
|
||||
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
|
||||
Retry
|
||||
</button>
|
||||
<p class="components-pause-visible">
|
||||
The session has been paused by the server.
|
||||
</p>
|
||||
<p class="components-resume-failed-visible">
|
||||
Failed to resume the session.<br />Please retry or reload the page.
|
||||
</p>
|
||||
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
|
||||
Resume
|
||||
</button>
|
||||
</div>
|
||||
</dialog>
|
||||
@@ -1,157 +0,0 @@
|
||||
.components-reconnect-first-attempt-visible,
|
||||
.components-reconnect-repeated-attempt-visible,
|
||||
.components-reconnect-failed-visible,
|
||||
.components-pause-visible,
|
||||
.components-resume-failed-visible,
|
||||
.components-rejoining-animation {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
|
||||
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
|
||||
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
|
||||
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
|
||||
#components-reconnect-modal.components-reconnect-retrying,
|
||||
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
|
||||
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
|
||||
#components-reconnect-modal.components-reconnect-failed,
|
||||
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
#components-reconnect-modal {
|
||||
background-color: white;
|
||||
width: 20rem;
|
||||
margin: 20vh auto;
|
||||
padding: 2rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
|
||||
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
|
||||
&[open]
|
||||
|
||||
{
|
||||
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
#components-reconnect-modal::backdrop {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-slideUp {
|
||||
0% {
|
||||
transform: translateY(30px) scale(0.95);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-fadeInOpacity {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes components-reconnect-modal-fadeOutOpacity {
|
||||
0% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.components-reconnect-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
#components-reconnect-modal p {
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button {
|
||||
border: 0;
|
||||
background-color: #6b9ed2;
|
||||
color: white;
|
||||
padding: 4px 24px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button:hover {
|
||||
background-color: #3b6ea2;
|
||||
}
|
||||
|
||||
#components-reconnect-modal button:active {
|
||||
background-color: #6b9ed2;
|
||||
}
|
||||
|
||||
.components-rejoining-animation {
|
||||
position: relative;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
}
|
||||
|
||||
.components-rejoining-animation div {
|
||||
position: absolute;
|
||||
border: 3px solid #0087ff;
|
||||
opacity: 1;
|
||||
border-radius: 50%;
|
||||
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
|
||||
}
|
||||
|
||||
.components-rejoining-animation div:nth-child(2) {
|
||||
animation-delay: -0.5s;
|
||||
}
|
||||
|
||||
@keyframes components-rejoining-animation {
|
||||
0% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
4.9% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
5% {
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
top: 0px;
|
||||
left: 0px;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
// Set up event handlers
|
||||
const reconnectModal = document.getElementById("components-reconnect-modal");
|
||||
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
|
||||
|
||||
const retryButton = document.getElementById("components-reconnect-button");
|
||||
retryButton.addEventListener("click", retry);
|
||||
|
||||
const resumeButton = document.getElementById("components-resume-button");
|
||||
resumeButton.addEventListener("click", resume);
|
||||
|
||||
function handleReconnectStateChanged(event) {
|
||||
if (event.detail.state === "show") {
|
||||
reconnectModal.showModal();
|
||||
} else if (event.detail.state === "hide") {
|
||||
reconnectModal.close();
|
||||
} else if (event.detail.state === "failed") {
|
||||
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
} else if (event.detail.state === "rejected") {
|
||||
location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function retry() {
|
||||
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
|
||||
try {
|
||||
// Reconnect will asynchronously return:
|
||||
// - true to mean success
|
||||
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
|
||||
// - exception to mean we didn't reach the server (this can be sync or async)
|
||||
const successful = await Blazor.reconnect();
|
||||
if (!successful) {
|
||||
// We have been able to reach the server, but the circuit is no longer available.
|
||||
// We'll reload the page so the user can continue using the app as quickly as possible.
|
||||
const resumeSuccessful = await Blazor.resumeCircuit();
|
||||
if (!resumeSuccessful) {
|
||||
location.reload();
|
||||
} else {
|
||||
reconnectModal.close();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// We got an exception, server is currently unavailable
|
||||
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
||||
}
|
||||
}
|
||||
|
||||
async function resume() {
|
||||
try {
|
||||
const successful = await Blazor.resumeCircuit();
|
||||
if (!successful) {
|
||||
location.reload();
|
||||
}
|
||||
} catch {
|
||||
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function retryWhenDocumentBecomesVisible() {
|
||||
if (document.visibilityState === "visible") {
|
||||
await retry();
|
||||
}
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
@page "/createplaylist"
|
||||
@attribute [Authorize]
|
||||
@using System.Security.Claims
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject AppDbContext Db
|
||||
@inject IYandexMusicService YandexService
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<h3>Создание общего плейлиста</h3>
|
||||
|
||||
<EditForm Model="@model" OnValidSubmit="@HandleSubmit">
|
||||
<div class="mb-3">
|
||||
<label for="title">Название плейлиста</label>
|
||||
<InputText id="title" class="form-control" @bind-Value="model.Title" />
|
||||
<ValidationMessage For="@(() => model.Title)" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="description">Описание (необязательно)</label>
|
||||
<InputText id="description" class="form-control" @bind-Value="model.Description" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" disabled="@isLoading">@(isLoading ? "Создание..." : "Создать")</button>
|
||||
</EditForm>
|
||||
|
||||
@code {
|
||||
private CreateModel model = new();
|
||||
private bool isLoading;
|
||||
private string? userId;
|
||||
|
||||
public class CreateModel
|
||||
{
|
||||
[Required]
|
||||
public string Title { get; set; } = "";
|
||||
public string? Description { get; set; }
|
||||
}
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
userId = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
}
|
||||
|
||||
private async Task HandleSubmit()
|
||||
{
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
// пользователь не авторизован
|
||||
return;
|
||||
}
|
||||
|
||||
isLoading = true;
|
||||
try
|
||||
{
|
||||
// 1. Создаём плейлист в Яндекс Музыке
|
||||
var yandexId = await YandexService.CreatePlaylistAsync(userId, model.Title);
|
||||
// 2. Сохраняем в БД
|
||||
var playlist = new SharedPlaylist
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OwnerUserId = userId,
|
||||
YandexPlaylistId = yandexId,
|
||||
Title = model.Title,
|
||||
Description = model.Description,
|
||||
ShareSlug = Guid.NewGuid().ToString("N"),
|
||||
CreatedAt = DateTime.UtcNow
|
||||
};
|
||||
Db.SharedPlaylists.Add(playlist);
|
||||
await Db.SaveChangesAsync();
|
||||
Navigation.NavigateTo("/myplaylists");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// показать ошибку (можно добавить переменную errorMessage)
|
||||
}
|
||||
finally
|
||||
{
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
@page "/Login"
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@using Microsoft.AspNetCore.Authentication
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
|
||||
<h3>Вход через Яндекс</h3>
|
||||
<a class="btn btn-primary" href="/signin-yandex">Войти через Яндекс</a>
|
||||
@@ -1,12 +0,0 @@
|
||||
@page "/Logout"
|
||||
@using Microsoft.AspNetCore.Identity
|
||||
@inject SignInManager<ApplicationUser> SignInManager
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
@code {
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
await SignInManager.SignOutAsync();
|
||||
Navigation.NavigateTo("/", true);
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
@page "/myplaylists"
|
||||
@attribute [Authorize]
|
||||
@using System.Security.Claims
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject AppDbContext Db
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<h3>Мои плейлисты</h3>
|
||||
<a href="/createplaylist" class="btn btn-success mb-3">Создать новый плейлист</a>
|
||||
|
||||
@if (playlists == null)
|
||||
{
|
||||
<p>Загрузка...</p>
|
||||
}
|
||||
else if (!playlists.Any())
|
||||
{
|
||||
<p>У вас пока нет общих плейлистов. Создайте первый!</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Название</th><th>Дата создания</th><th>Ссылка</th><th>Настройки</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (var pl in playlists)
|
||||
{
|
||||
<tr>
|
||||
<td>@pl.Title</td>
|
||||
<td>@pl.CreatedAt.ToShortDateString()</td>
|
||||
<td><a href="/playlist/@pl.ShareSlug">открыть</a></td>
|
||||
<td><a href="/settings/@pl.Id">настройки</a></td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
}
|
||||
|
||||
@code {
|
||||
private List<SharedPlaylist>? playlists;
|
||||
private string? userId;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
userId = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
if (userId != null)
|
||||
{
|
||||
playlists = await Db.SharedPlaylists
|
||||
.Where(p => p.OwnerUserId == userId)
|
||||
.OrderByDescending(p => p.CreatedAt)
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
@page "/not-found"
|
||||
@layout MainLayout
|
||||
|
||||
<h3>Not Found</h3>
|
||||
<p>Sorry, the content you are looking for does not exist.</p>
|
||||
@@ -1,92 +0,0 @@
|
||||
@page "/playlist/{slug}"
|
||||
@using System.Security.Claims
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using PlaylistShared.Data.Entities
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject AppDbContext Db
|
||||
@inject IYandexMusicService YandexService
|
||||
@inject IJSRuntime Js
|
||||
@inject HttpClient Http
|
||||
|
||||
<h3>@playlist?.Title</h3>
|
||||
<p>Владелец: @playlist?.Owner?.UserName</p>
|
||||
<p>Описание: @playlist?.Description</p>
|
||||
|
||||
@if (canAdd)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<input type="text" id="trackId" placeholder="ID трека Яндекс.Музыки" class="form-control" />
|
||||
<input type="text" id="trackTitle" placeholder="Название трека" class="form-control mt-1" />
|
||||
<input type="text" id="artistName" placeholder="Исполнитель" class="form-control mt-1" />
|
||||
<button class="btn btn-primary mt-2" id="addTrackBtn">Добавить</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<ul id="trackList" class="list-group">
|
||||
@foreach (var track in tracks)
|
||||
{
|
||||
<li class="list-group-item d-flex justify-content-between align-items-center" data-track-id="@track.YandexTrackId">
|
||||
<div>
|
||||
<strong>@track.Title</strong> - @track.Artist
|
||||
</div>
|
||||
@if (canDelete(track))
|
||||
{
|
||||
<button class="btn btn-sm btn-danger deleteTrackBtn" data-track-id="@track.YandexTrackId">Удалить</button>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@code {
|
||||
[Parameter] public string slug { get; set; } = "";
|
||||
|
||||
private SharedPlaylist? playlist;
|
||||
private List<PlaylistTrack> tracks = new();
|
||||
private bool canAdd;
|
||||
private string? currentUserId;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
currentUserId = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
playlist = await Db.SharedPlaylists
|
||||
.Include(p => p.Owner)
|
||||
.Include(p => p.Tracks)
|
||||
.FirstOrDefaultAsync(p => p.ShareSlug == slug);
|
||||
if (playlist == null) return;
|
||||
|
||||
canAdd = playlist.Permissions.Add switch
|
||||
{
|
||||
AccessLevel.All => true,
|
||||
AccessLevel.Authorized => currentUserId != null,
|
||||
_ => false
|
||||
};
|
||||
|
||||
// Здесь можно при желании синхронизировать с Яндекс API, но пока используем локальный кеш
|
||||
// var yandexTracks = await YandexService.GetPlaylistTracksAsync(playlist.OwnerUserId, playlist.YandexPlaylistId);
|
||||
tracks = playlist.Tracks.OrderBy(t => t.AddedAt).ToList();
|
||||
}
|
||||
|
||||
private bool canDelete(PlaylistTrack track)
|
||||
{
|
||||
if (currentUserId == playlist?.OwnerUserId) return true;
|
||||
return playlist?.Permissions.Delete switch
|
||||
{
|
||||
DeleteAccessLevel.All => true,
|
||||
DeleteAccessLevel.Authorized => currentUserId != null,
|
||||
DeleteAccessLevel.AdderOnly => currentUserId != null && currentUserId == track.AddedByUserId,
|
||||
DeleteAccessLevel.OwnerOnly => false,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (firstRender && playlist != null)
|
||||
{
|
||||
await Js.InvokeVoidAsync("initPlaylistInteractions", playlist.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
@page "/settings/{id:guid}"
|
||||
@attribute [Authorize]
|
||||
@using System.Security.Claims
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.EntityFrameworkCore
|
||||
@using PlaylistShared.Data.Entities
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject AppDbContext Db
|
||||
@inject NavigationManager Navigation
|
||||
|
||||
<h3>Настройки плейлиста</h3>
|
||||
|
||||
@if (playlist == null)
|
||||
{
|
||||
<p>Загрузка...</p>
|
||||
}
|
||||
else if (!isOwner)
|
||||
{
|
||||
<p>Только владелец может изменять настройки.</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<EditForm Model="@playlist.Permissions" OnValidSubmit="@SaveSettings">
|
||||
<div class="mb-3">
|
||||
<label>Кто может просматривать</label>
|
||||
<InputSelect @bind-Value="playlist.Permissions.View" class="form-control">
|
||||
<option value="@AccessLevel.All">Все</option>
|
||||
<option value="@AccessLevel.Authorized">Авторизованные</option>
|
||||
<option value="@AccessLevel.None">Никто</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label>Кто может добавлять треки</label>
|
||||
<InputSelect @bind-Value="playlist.Permissions.Add" class="form-control">
|
||||
<option value="@AccessLevel.All">Все</option>
|
||||
<option value="@AccessLevel.Authorized">Авторизованные</option>
|
||||
<option value="@AccessLevel.None">Никто</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label>Кто может удалять треки</label>
|
||||
<InputSelect @bind-Value="playlist.Permissions.Delete" class="form-control">
|
||||
<option value="@DeleteAccessLevel.All">Все</option>
|
||||
<option value="@DeleteAccessLevel.Authorized">Авторизованные</option>
|
||||
<option value="@DeleteAccessLevel.AdderOnly">Тот, кто добавил</option>
|
||||
<option value="@DeleteAccessLevel.OwnerOnly">Только владелец</option>
|
||||
</InputSelect>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
||||
</EditForm>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public Guid id { get; set; }
|
||||
private SharedPlaylist? playlist;
|
||||
private bool isOwner;
|
||||
private string? currentUserId;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
||||
currentUserId = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
||||
|
||||
playlist = await Db.SharedPlaylists
|
||||
.Include(p => p.Owner)
|
||||
.FirstOrDefaultAsync(p => p.Id == id);
|
||||
if (playlist != null)
|
||||
{
|
||||
isOwner = currentUserId == playlist.OwnerUserId;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SaveSettings()
|
||||
{
|
||||
if (playlist != null)
|
||||
{
|
||||
Db.SharedPlaylists.Update(playlist);
|
||||
await Db.SaveChangesAsync();
|
||||
Navigation.NavigateTo("/myplaylists");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||
</Found>
|
||||
</Router>
|
||||
@@ -1,11 +0,0 @@
|
||||
@using System.Net.Http
|
||||
@using System.Net.Http.Json
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
||||
@using Microsoft.JSInterop
|
||||
@using PlaylistShared
|
||||
@using PlaylistShared.Components
|
||||
@using PlaylistShared.Components.Layout
|
||||
@@ -1,109 +0,0 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PlaylistShared.Models;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PlaylistShared.Controllers;
|
||||
|
||||
[Microsoft.AspNetCore.Mvc.Route("auth")]
|
||||
[ApiController]
|
||||
public class AuthController : ControllerBase
|
||||
{
|
||||
private readonly IConfiguration _config;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly AppDbContext _db;
|
||||
private readonly SignInManager<ApplicationUser> _signInManager;
|
||||
private readonly ILogger<AuthController> _logger;
|
||||
|
||||
public AuthController(
|
||||
IConfiguration config,
|
||||
IHttpClientFactory factory,
|
||||
AppDbContext db,
|
||||
SignInManager<ApplicationUser> signInManager,
|
||||
ILogger<AuthController> logger)
|
||||
{
|
||||
_config = config;
|
||||
_httpClient = factory.CreateClient();
|
||||
_db = db;
|
||||
_signInManager = signInManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet("login")]
|
||||
public IActionResult Login()
|
||||
{
|
||||
var clientId = _config["YandexOAuth:ClientId"];
|
||||
var redirectUri = Url.Action(nameof(Callback), "Auth", null, Request.Scheme);
|
||||
var authUrl = $"{_config["YandexOAuth:AuthorizationEndpoint"]}?response_type=code&client_id={clientId}&redirect_uri={Uri.EscapeDataString(redirectUri!)}";
|
||||
return Redirect(authUrl);
|
||||
}
|
||||
|
||||
[HttpGet("callback")]
|
||||
public async Task<IActionResult> Callback(string code)
|
||||
{
|
||||
if (string.IsNullOrEmpty(code))
|
||||
return BadRequest("Missing code");
|
||||
|
||||
var tokenResponse = await ExchangeCodeForTokenAsync(code);
|
||||
if (tokenResponse is null)
|
||||
return StatusCode(500, "Token exchange failed");
|
||||
|
||||
var userInfo = await GetUserInfoAsync(tokenResponse.AccessToken);
|
||||
if (userInfo is null)
|
||||
return StatusCode(500, "User info fetch failed");
|
||||
|
||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.YandexId == userInfo.Id);
|
||||
if (user is null)
|
||||
{
|
||||
user = new ApplicationUser
|
||||
{
|
||||
UserName = userInfo.Login,
|
||||
YandexId = userInfo.Id,
|
||||
Email = userInfo.DefaultEmail,
|
||||
AccessToken = tokenResponse.AccessToken,
|
||||
RefreshToken = tokenResponse.RefreshToken,
|
||||
AccessTokenExpiresAt = DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn)
|
||||
};
|
||||
_db.Users.Add(user);
|
||||
}
|
||||
else
|
||||
{
|
||||
user.AccessToken = tokenResponse.AccessToken;
|
||||
user.RefreshToken = tokenResponse.RefreshToken;
|
||||
user.AccessTokenExpiresAt = DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn);
|
||||
_db.Users.Update(user);
|
||||
}
|
||||
await _db.SaveChangesAsync();
|
||||
|
||||
await _signInManager.SignInAsync(user, isPersistent: false);
|
||||
return Redirect("/");
|
||||
}
|
||||
|
||||
private async Task<YandexTokenResponse?> ExchangeCodeForTokenAsync(string code)
|
||||
{
|
||||
var tokenUrl = _config["YandexOAuth:TokenEndpoint"];
|
||||
var redirectUri = Url.Action(nameof(Callback), "Auth", null, Request.Scheme);
|
||||
var content = new FormUrlEncodedContent(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("grant_type", "authorization_code"),
|
||||
new KeyValuePair<string, string>("code", code),
|
||||
new KeyValuePair<string, string>("client_id", _config["YandexOAuth:ClientId"]!),
|
||||
new KeyValuePair<string, string>("client_secret", _config["YandexOAuth:ClientSecret"]!),
|
||||
new KeyValuePair<string, string>("redirect_uri", redirectUri!)
|
||||
});
|
||||
|
||||
var response = await _httpClient.PostAsync(tokenUrl!, content);
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
return JsonSerializer.Deserialize<YandexTokenResponse>(json);
|
||||
}
|
||||
|
||||
private async Task<YandexUserInfo?> GetUserInfoAsync(string accessToken)
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("OAuth", accessToken);
|
||||
var response = await _httpClient.GetAsync(_config["YandexOAuth:UserInfoEndpoint"]!);
|
||||
if (!response.IsSuccessStatusCode) return null;
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
return JsonSerializer.Deserialize<YandexUserInfo>(json);
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
// Controllers/TrackController.cs
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace PlaylistShared.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Microsoft.AspNetCore.Mvc.Route("api/tracks")]
|
||||
[Authorize]
|
||||
public class TrackController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IYandexMusicService _yandex;
|
||||
|
||||
public TrackController(AppDbContext db, IYandexMusicService yandex)
|
||||
{
|
||||
_db = db;
|
||||
_yandex = yandex;
|
||||
}
|
||||
|
||||
[HttpPost("add")]
|
||||
public async Task<IActionResult> AddTrack(Guid playlistId, string trackId, string? trackTitle, string? artist)
|
||||
{
|
||||
var playlist = await _db.SharedPlaylists
|
||||
.Include(p => p.Owner)
|
||||
.FirstOrDefaultAsync(p => p.Id == playlistId);
|
||||
if (playlist == null) return NotFound();
|
||||
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
if (!CanAdd(playlist, userId)) return Forbid();
|
||||
|
||||
// Добавляем в Яндекс
|
||||
await _yandex.AddTrackToPlaylistAsync(playlist.OwnerUserId, playlist.YandexPlaylistId, trackId);
|
||||
|
||||
// Сохраняем информацию о треке в кеш
|
||||
var track = new PlaylistTrack
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
PlaylistId = playlist.Id,
|
||||
YandexTrackId = trackId,
|
||||
Title = trackTitle ?? "Unknown",
|
||||
Artist = artist,
|
||||
AddedByUserId = userId,
|
||||
AddedAt = DateTime.UtcNow
|
||||
};
|
||||
_db.PlaylistTracks.Add(track);
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
[HttpDelete("remove")]
|
||||
public async Task<IActionResult> RemoveTrack(Guid playlistId, string trackId)
|
||||
{
|
||||
var playlist = await _db.SharedPlaylists
|
||||
.Include(p => p.Owner)
|
||||
.FirstOrDefaultAsync(p => p.Id == playlistId);
|
||||
if (playlist == null) return NotFound();
|
||||
|
||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
var trackEntry = await _db.PlaylistTracks
|
||||
.FirstOrDefaultAsync(t => t.PlaylistId == playlistId && t.YandexTrackId == trackId);
|
||||
if (!CanDelete(playlist, userId, trackEntry?.AddedByUserId))
|
||||
return Forbid();
|
||||
|
||||
await _yandex.RemoveTrackFromPlaylistAsync(playlist.OwnerUserId, playlist.YandexPlaylistId, trackId);
|
||||
if (trackEntry != null) _db.PlaylistTracks.Remove(trackEntry);
|
||||
await _db.SaveChangesAsync();
|
||||
return Ok();
|
||||
}
|
||||
|
||||
private bool CanAdd(SharedPlaylist playlist, string? userId)
|
||||
{
|
||||
return playlist.Permissions.Add switch
|
||||
{
|
||||
AccessLevel.All => true,
|
||||
AccessLevel.Authorized => userId != null,
|
||||
AccessLevel.None => false,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private bool CanDelete(SharedPlaylist playlist, string? userId, string? adderUserId)
|
||||
{
|
||||
if (userId == playlist.OwnerUserId) return true; // владелец всегда может удалить
|
||||
return playlist.Permissions.Delete switch
|
||||
{
|
||||
DeleteAccessLevel.All => true,
|
||||
DeleteAccessLevel.Authorized => userId != null,
|
||||
DeleteAccessLevel.AdderOnly => userId != null && userId == adderUserId,
|
||||
DeleteAccessLevel.OwnerOnly => false,
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PlaylistShared.Data.Entities;
|
||||
|
||||
namespace PlaylistShared.Data.Contexts;
|
||||
|
||||
public class AppDbContext : IdentityDbContext<ApplicationUser>
|
||||
{
|
||||
public DbSet<SharedPlaylist> SharedPlaylists => Set<SharedPlaylist>();
|
||||
public DbSet<PlaylistTrack> PlaylistTracks => Set<PlaylistTrack>();
|
||||
|
||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.OnModelCreating(builder);
|
||||
|
||||
builder.Entity<SharedPlaylist>(entity =>
|
||||
{
|
||||
entity.HasIndex(p => p.ShareSlug).IsUnique();
|
||||
entity.OwnsOne(p => p.Permissions, per =>
|
||||
{
|
||||
per.Property(x => x.View).HasColumnName("PermView");
|
||||
per.Property(x => x.Add).HasColumnName("PermAdd");
|
||||
per.Property(x => x.Delete).HasColumnName("PermDelete");
|
||||
});
|
||||
});
|
||||
|
||||
builder.Entity<PlaylistTrack>(entity =>
|
||||
{
|
||||
entity.HasOne(pt => pt.Playlist)
|
||||
.WithMany(p => p.Tracks)
|
||||
.HasForeignKey(pt => pt.PlaylistId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace PlaylistShared.Data.Entities;
|
||||
|
||||
public class ApplicationUser : IdentityUser
|
||||
{
|
||||
public string? YandexId { get; set; }
|
||||
public string? AccessToken { get; set; }
|
||||
public string? RefreshToken { get; set; }
|
||||
public DateTime? AccessTokenExpiresAt { get; set; }
|
||||
public string? AvatarUrl { get; set; }
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace PlaylistShared.Data.Entities;
|
||||
|
||||
public enum AccessLevel { All, Authorized, None }
|
||||
public enum DeleteAccessLevel { All, Authorized, AdderOnly, OwnerOnly }
|
||||
|
||||
[Owned]
|
||||
public class PlaylistPermissions
|
||||
{
|
||||
public AccessLevel View { get; set; } = AccessLevel.All;
|
||||
public AccessLevel Add { get; set; } = AccessLevel.Authorized;
|
||||
public DeleteAccessLevel Delete { get; set; } = DeleteAccessLevel.OwnerOnly;
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace PlaylistShared.Data.Entities;
|
||||
|
||||
public class PlaylistTrack
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public Guid PlaylistId { get; set; }
|
||||
public SharedPlaylist Playlist { get; set; } = null!;
|
||||
|
||||
public string YandexTrackId { get; set; } = null!;
|
||||
public string Title { get; set; } = null!;
|
||||
public string? Artist { get; set; }
|
||||
public string? AlbumTitle { get; set; }
|
||||
public int DurationMs { get; set; }
|
||||
|
||||
public string? AddedByUserId { get; set; }
|
||||
|
||||
[ForeignKey(nameof(AddedByUserId))]
|
||||
public ApplicationUser? AddedByUser { get; set; }
|
||||
|
||||
public DateTime AddedAt { get; set; }
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using System.ComponentModel.DataAnnotations.Schema;
|
||||
|
||||
namespace PlaylistShared.Data.Entities;
|
||||
|
||||
public class SharedPlaylist
|
||||
{
|
||||
[Key]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
public string OwnerUserId { get; set; } = null!;
|
||||
[ForeignKey(nameof(OwnerUserId))]
|
||||
public ApplicationUser Owner { get; set; } = null!;
|
||||
|
||||
public string YandexPlaylistId { get; set; } = null!;
|
||||
public string Title { get; set; } = null!;
|
||||
public string? Description { get; set; }
|
||||
|
||||
[Required]
|
||||
public string ShareSlug { get; set; } = null!;
|
||||
|
||||
public PlaylistPermissions Permissions { get; set; } = new();
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? UpdatedAt { get; set; }
|
||||
|
||||
public List<PlaylistTrack> Tracks { get; set; } = new();
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
global using Microsoft.AspNetCore.Authorization;
|
||||
global using Microsoft.AspNetCore.Components;
|
||||
global using Microsoft.AspNetCore.Identity;
|
||||
global using Microsoft.EntityFrameworkCore;
|
||||
global using PlaylistShared.Data.Contexts;
|
||||
global using PlaylistShared.Data.Entities;
|
||||
global using PlaylistShared.Services;
|
||||
global using System.ComponentModel.DataAnnotations;
|
||||
global using System.Security.Claims;
|
||||
@@ -1,4 +0,0 @@
|
||||
namespace PlaylistShared.Models;
|
||||
|
||||
public record YandexTokenResponse(string AccessToken, string RefreshToken, int ExpiresIn);
|
||||
public record YandexUserInfo(string Id, string Login, string? DisplayName, string? DefaultEmail, string? AvatarUrl);
|
||||
@@ -1,26 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\YandexMusic\YandexMusic.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Data\Migrations\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,79 +0,0 @@
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using PlaylistShared.Data.Contexts;
|
||||
using PlaylistShared.Data.Entities;
|
||||
using PlaylistShared.Services;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddRazorPages();
|
||||
builder.Services.AddServerSideBlazor();
|
||||
|
||||
builder.Services.AddDbContext<AppDbContext>(options =>
|
||||
{
|
||||
options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection"));
|
||||
});
|
||||
|
||||
builder.Services.AddIdentity<ApplicationUser, IdentityRole>()
|
||||
.AddEntityFrameworkStores<AppDbContext>()
|
||||
.AddDefaultTokenProviders();
|
||||
|
||||
builder.Services.ConfigureApplicationCookie(options =>
|
||||
{
|
||||
options.LoginPath = "/Login";
|
||||
options.LogoutPath = "/Logout";
|
||||
options.AccessDeniedPath = "/AccessDenied";
|
||||
});
|
||||
|
||||
builder.Services.AddAuthentication()
|
||||
.AddOAuth("Yandex", options =>
|
||||
{
|
||||
options.ClientId = builder.Configuration["YandexOAuth:ClientId"];
|
||||
options.ClientSecret = builder.Configuration["YandexOAuth:ClientSecret"];
|
||||
options.AuthorizationEndpoint = builder.Configuration["YandexOAuth:AuthorizationEndpoint"];
|
||||
options.TokenEndpoint = builder.Configuration["YandexOAuth:TokenEndpoint"];
|
||||
options.UserInformationEndpoint = builder.Configuration["YandexOAuth:UserInfoEndpoint"];
|
||||
options.CallbackPath = "/signin-yandex";
|
||||
|
||||
options.ClaimActions.MapJsonKey("urn:yandex:avatar_url", "avatar_url");
|
||||
options.ClaimActions.MapJsonKey("urn:yandex:display_name", "display_name");
|
||||
|
||||
options.Events = new Microsoft.AspNetCore.Authentication.OAuth.OAuthEvents
|
||||
{
|
||||
OnCreatingTicket = async context =>
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, context.Options.UserInformationEndpoint);
|
||||
request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
|
||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", context.AccessToken);
|
||||
var response = await context.Backchannel.SendAsync(request);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var user = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
||||
context.RunClaimActions(user.RootElement);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddScoped<IYandexMusicService, YandexMusicService>();
|
||||
builder.Services.AddHttpClient();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
if (!app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseExceptionHandler("/Error");
|
||||
app.UseHsts();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseStaticFiles();
|
||||
app.UseRouting();
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapRazorPages();
|
||||
app.MapBlazorHub();
|
||||
app.MapFallbackToPage("/_Host");
|
||||
|
||||
app.Run();
|
||||
@@ -1,23 +0,0 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:5058",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:7272;http://localhost:5058",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
namespace PlaylistShared.Services;
|
||||
|
||||
public interface IYandexMusicService
|
||||
{
|
||||
Task<string> CreatePlaylistAsync(string userId, string title);
|
||||
Task AddTrackToPlaylistAsync(string userId, string yandexPlaylistId, string trackId);
|
||||
Task RemoveTrackFromPlaylistAsync(string userId, string yandexPlaylistId, string trackId);
|
||||
Task<List<YandexTrackInfo>> GetPlaylistTracksAsync(string userId, string yandexPlaylistId);
|
||||
Task<YandexPlaylistInfo> GetPlaylistInfoAsync(string userId, string yandexPlaylistId);
|
||||
Task<string?> RefreshUserTokenAsync(string userId);
|
||||
}
|
||||
|
||||
public record YandexTrackInfo(string Id, string Title, string? Artist, string? AlbumTitle, int DurationMs);
|
||||
public record YandexPlaylistInfo(string Kind, string Title, int TrackCount);
|
||||
@@ -1,173 +0,0 @@
|
||||
using PlaylistShared.Data.Contexts;
|
||||
using PlaylistShared.Data.Entities;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PlaylistShared.Services;
|
||||
|
||||
public class YandexMusicService : IYandexMusicService
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly HttpClient _http;
|
||||
private readonly IConfiguration _config;
|
||||
private readonly ILogger<YandexMusicService> _logger;
|
||||
|
||||
public YandexMusicService(AppDbContext db, IHttpClientFactory factory, IConfiguration config, ILogger<YandexMusicService> logger)
|
||||
{
|
||||
_db = db;
|
||||
_http = factory.CreateClient();
|
||||
_config = config;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private async Task<ApplicationUser> GetUserWithValidTokenAsync(string userId)
|
||||
{
|
||||
var user = await _db.Users.FindAsync(userId);
|
||||
if (user == null) throw new Exception("User not found");
|
||||
|
||||
if (user.AccessTokenExpiresAt <= DateTime.UtcNow.AddMinutes(5))
|
||||
{
|
||||
var newToken = await RefreshYandexTokenAsync(user.RefreshToken!);
|
||||
user.AccessToken = newToken.access_token;
|
||||
user.RefreshToken = newToken.refresh_token;
|
||||
user.AccessTokenExpiresAt = DateTime.UtcNow.AddSeconds(newToken.expires_in);
|
||||
await _db.SaveChangesAsync();
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
private async Task<(string access_token, string refresh_token, int expires_in)> RefreshYandexTokenAsync(string refreshToken)
|
||||
{
|
||||
var content = new FormUrlEncodedContent(new[]
|
||||
{
|
||||
new KeyValuePair<string, string>("grant_type", "refresh_token"),
|
||||
new KeyValuePair<string, string>("refresh_token", refreshToken),
|
||||
new KeyValuePair<string, string>("client_id", _config["YandexOAuth:ClientId"]),
|
||||
new KeyValuePair<string, string>("client_secret", _config["YandexOAuth:ClientSecret"])
|
||||
});
|
||||
var response = await _http.PostAsync(_config["YandexOAuth:TokenEndpoint"], content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
return (
|
||||
doc.RootElement.GetProperty("access_token").GetString()!,
|
||||
doc.RootElement.GetProperty("refresh_token").GetString()!,
|
||||
doc.RootElement.GetProperty("expires_in").GetInt32()
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<string> CreatePlaylistAsync(string userId, string title)
|
||||
{
|
||||
var user = await GetUserWithValidTokenAsync(userId);
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("OAuth", user.AccessToken);
|
||||
var payload = new { title, visibility = "public" };
|
||||
var json = JsonSerializer.Serialize(payload);
|
||||
var response = await _http.PostAsync("https://api.music.yandex.net/users/self/playlists/create",
|
||||
new StringContent(json, Encoding.UTF8, "application/json"));
|
||||
response.EnsureSuccessStatusCode();
|
||||
var resultJson = await response.Content.ReadAsStringAsync();
|
||||
var result = JsonSerializer.Deserialize<YandexApiResponse<YandexPlaylistData>>(resultJson);
|
||||
return result!.Result.Kind;
|
||||
}
|
||||
|
||||
public async Task AddTrackToPlaylistAsync(string userId, string yandexPlaylistId, string trackId)
|
||||
{
|
||||
var user = await GetUserWithValidTokenAsync(userId);
|
||||
var uid = user.YandexId; // или user.Id? В API Яндекса используется uid из аккаунта, но можно использовать "self"
|
||||
// Получаем альбом трека (опционально, можно передать без albumId)
|
||||
// Для простоты используем трек без albumId
|
||||
var diff = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
op = "insert",
|
||||
at = 0,
|
||||
tracks = new[] { new { id = trackId } }
|
||||
}
|
||||
};
|
||||
var json = JsonSerializer.Serialize(diff);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
var url = $"https://api.music.yandex.net/users/{uid}/playlists/{yandexPlaylistId}/change";
|
||||
var response = await _http.PostAsync(url, content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task RemoveTrackFromPlaylistAsync(string userId, string yandexPlaylistId, string trackId)
|
||||
{
|
||||
var user = await GetUserWithValidTokenAsync(userId);
|
||||
var uid = user.YandexId;
|
||||
// Сначала нужно получить позицию трека в плейлисте – упростим: удаляем по индексу (не надёжно)
|
||||
// Лучше получить плейлист, найти индекс трека, потом удалить
|
||||
var playlist = await GetPlaylistInfoAsync(userId, yandexPlaylistId);
|
||||
var tracks = await GetPlaylistTracksAsync(userId, yandexPlaylistId);
|
||||
var index = tracks.FindIndex(t => t.Id == trackId);
|
||||
if (index == -1) throw new Exception("Track not found in playlist");
|
||||
var diff = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
op = "delete",
|
||||
from = index,
|
||||
to = index + 1
|
||||
}
|
||||
};
|
||||
var json = JsonSerializer.Serialize(diff);
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
var url = $"https://api.music.yandex.net/users/{uid}/playlists/{yandexPlaylistId}/change";
|
||||
var response = await _http.PostAsync(url, content);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
public async Task<List<YandexTrackInfo>> GetPlaylistTracksAsync(string userId, string yandexPlaylistId)
|
||||
{
|
||||
var user = await GetUserWithValidTokenAsync(userId);
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("OAuth", user.AccessToken);
|
||||
var uid = user.YandexId;
|
||||
var url = $"https://api.music.yandex.net/users/{uid}/playlists/{yandexPlaylistId}";
|
||||
var response = await _http.GetAsync(url);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var data = JsonSerializer.Deserialize<YandexApiResponse<YandexPlaylistFull>>(json);
|
||||
return data!.Result.Tracks?.Select(t => new YandexTrackInfo(
|
||||
t.Id.ToString(),
|
||||
t.Title,
|
||||
t.Artists?.FirstOrDefault()?.Name,
|
||||
t.Albums?.FirstOrDefault()?.Title,
|
||||
t.DurationMs
|
||||
)).ToList() ?? new();
|
||||
}
|
||||
|
||||
public async Task<YandexPlaylistInfo> GetPlaylistInfoAsync(string userId, string yandexPlaylistId)
|
||||
{
|
||||
var user = await GetUserWithValidTokenAsync(userId);
|
||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("OAuth", user.AccessToken);
|
||||
var uid = user.YandexId;
|
||||
var url = $"https://api.music.yandex.net/users/{uid}/playlists/{yandexPlaylistId}";
|
||||
var response = await _http.GetAsync(url);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var data = JsonSerializer.Deserialize<YandexApiResponse<YandexPlaylistFull>>(json);
|
||||
return new YandexPlaylistInfo(data!.Result.Kind, data.Result.Title, data.Result.TrackCount);
|
||||
}
|
||||
|
||||
public async Task<string?> RefreshUserTokenAsync(string userId)
|
||||
{
|
||||
var user = await _db.Users.FindAsync(userId);
|
||||
if (user?.RefreshToken == null) return null;
|
||||
var newToken = await RefreshYandexTokenAsync(user.RefreshToken);
|
||||
user.AccessToken = newToken.access_token;
|
||||
user.RefreshToken = newToken.refresh_token;
|
||||
user.AccessTokenExpiresAt = DateTime.UtcNow.AddSeconds(newToken.expires_in);
|
||||
await _db.SaveChangesAsync();
|
||||
return newToken.access_token;
|
||||
}
|
||||
|
||||
// Вспомогательные record
|
||||
private record YandexApiResponse<T>(T Result);
|
||||
private record YandexPlaylistData(string Kind);
|
||||
private record YandexPlaylistFull(string Kind, string Title, int TrackCount, List<YandexTrack> Tracks);
|
||||
private record YandexTrack(string Id, string Title, int DurationMs, List<YandexArtist> Artists, List<YandexAlbum> Albums);
|
||||
private record YandexArtist(string Name);
|
||||
private record YandexAlbum(string Title);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"YandexOAuth": {
|
||||
"ClientId": "0916685f8a3641ca8fc382dbccf77236",
|
||||
"ClientSecret": "f7398893cd814f8b84b85aeb2a0a6698",
|
||||
"AuthorizationEndpoint": "https://oauth.yandex.ru/authorize",
|
||||
"TokenEndpoint": "https://oauth.yandex.ru/token",
|
||||
"UserInfoEndpoint": "https://login.yandex.ru/info"
|
||||
},
|
||||
"Jwt": {
|
||||
"Secret": "very_long_secret_key_at_least_32_chars",
|
||||
"Issuer": "PlaylistShared",
|
||||
"Audience": "PlaylistSharedAPI"
|
||||
},
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Data Source=playlist.db"
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
html, body {
|
||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
a, .btn-link {
|
||||
color: #006bb7;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
color: #fff;
|
||||
background-color: #1b6ec2;
|
||||
border-color: #1861ac;
|
||||
}
|
||||
|
||||
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
|
||||
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding-top: 1.1rem;
|
||||
}
|
||||
|
||||
h1:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.valid.modified:not([type=checkbox]) {
|
||||
outline: 1px solid #26b050;
|
||||
}
|
||||
|
||||
.invalid {
|
||||
outline: 1px solid #e50000;
|
||||
}
|
||||
|
||||
.validation-message {
|
||||
color: #e50000;
|
||||
}
|
||||
|
||||
.blazor-error-boundary {
|
||||
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
|
||||
padding: 1rem 1rem 1rem 3.7rem;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.blazor-error-boundary::after {
|
||||
content: "An error has occurred."
|
||||
}
|
||||
|
||||
.darker-border-checkbox.form-check-input {
|
||||
border-color: #929292;
|
||||
}
|
||||
|
||||
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
|
||||
color: var(--bs-secondary-color);
|
||||
text-align: end;
|
||||
}
|
||||
|
||||
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
|
||||
text-align: start;
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,26 +0,0 @@
|
||||
window.initPlaylistInteractions = (playlistId) => {
|
||||
const addBtn = document.getElementById('addTrackBtn');
|
||||
if (addBtn) {
|
||||
addBtn.addEventListener('click', async () => {
|
||||
const trackId = document.getElementById('trackId').value;
|
||||
const trackTitle = document.getElementById('trackTitle').value;
|
||||
const artist = document.getElementById('artistName').value;
|
||||
const response = await fetch(`/api/tracks/add?playlistId=${playlistId}&trackId=${trackId}&trackTitle=${encodeURIComponent(trackTitle)}&artist=${encodeURIComponent(artist)}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
if (response.ok) location.reload();
|
||||
else alert('Ошибка добавления трека');
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.deleteTrackBtn').forEach(btn => {
|
||||
btn.addEventListener('click', async () => {
|
||||
const trackId = btn.getAttribute('data-track-id');
|
||||
const response = await fetch(`/api/tracks/remove?playlistId=${playlistId}&trackId=${trackId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (response.ok) location.reload();
|
||||
else alert('Ошибка удаления трека');
|
||||
});
|
||||
});
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,597 +0,0 @@
|
||||
/*!
|
||||
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2024 The Bootstrap Authors
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
*/
|
||||
:root,
|
||||
[data-bs-theme=light] {
|
||||
--bs-blue: #0d6efd;
|
||||
--bs-indigo: #6610f2;
|
||||
--bs-purple: #6f42c1;
|
||||
--bs-pink: #d63384;
|
||||
--bs-red: #dc3545;
|
||||
--bs-orange: #fd7e14;
|
||||
--bs-yellow: #ffc107;
|
||||
--bs-green: #198754;
|
||||
--bs-teal: #20c997;
|
||||
--bs-cyan: #0dcaf0;
|
||||
--bs-black: #000;
|
||||
--bs-white: #fff;
|
||||
--bs-gray: #6c757d;
|
||||
--bs-gray-dark: #343a40;
|
||||
--bs-gray-100: #f8f9fa;
|
||||
--bs-gray-200: #e9ecef;
|
||||
--bs-gray-300: #dee2e6;
|
||||
--bs-gray-400: #ced4da;
|
||||
--bs-gray-500: #adb5bd;
|
||||
--bs-gray-600: #6c757d;
|
||||
--bs-gray-700: #495057;
|
||||
--bs-gray-800: #343a40;
|
||||
--bs-gray-900: #212529;
|
||||
--bs-primary: #0d6efd;
|
||||
--bs-secondary: #6c757d;
|
||||
--bs-success: #198754;
|
||||
--bs-info: #0dcaf0;
|
||||
--bs-warning: #ffc107;
|
||||
--bs-danger: #dc3545;
|
||||
--bs-light: #f8f9fa;
|
||||
--bs-dark: #212529;
|
||||
--bs-primary-rgb: 13, 110, 253;
|
||||
--bs-secondary-rgb: 108, 117, 125;
|
||||
--bs-success-rgb: 25, 135, 84;
|
||||
--bs-info-rgb: 13, 202, 240;
|
||||
--bs-warning-rgb: 255, 193, 7;
|
||||
--bs-danger-rgb: 220, 53, 69;
|
||||
--bs-light-rgb: 248, 249, 250;
|
||||
--bs-dark-rgb: 33, 37, 41;
|
||||
--bs-primary-text-emphasis: #052c65;
|
||||
--bs-secondary-text-emphasis: #2b2f32;
|
||||
--bs-success-text-emphasis: #0a3622;
|
||||
--bs-info-text-emphasis: #055160;
|
||||
--bs-warning-text-emphasis: #664d03;
|
||||
--bs-danger-text-emphasis: #58151c;
|
||||
--bs-light-text-emphasis: #495057;
|
||||
--bs-dark-text-emphasis: #495057;
|
||||
--bs-primary-bg-subtle: #cfe2ff;
|
||||
--bs-secondary-bg-subtle: #e2e3e5;
|
||||
--bs-success-bg-subtle: #d1e7dd;
|
||||
--bs-info-bg-subtle: #cff4fc;
|
||||
--bs-warning-bg-subtle: #fff3cd;
|
||||
--bs-danger-bg-subtle: #f8d7da;
|
||||
--bs-light-bg-subtle: #fcfcfd;
|
||||
--bs-dark-bg-subtle: #ced4da;
|
||||
--bs-primary-border-subtle: #9ec5fe;
|
||||
--bs-secondary-border-subtle: #c4c8cb;
|
||||
--bs-success-border-subtle: #a3cfbb;
|
||||
--bs-info-border-subtle: #9eeaf9;
|
||||
--bs-warning-border-subtle: #ffe69c;
|
||||
--bs-danger-border-subtle: #f1aeb5;
|
||||
--bs-light-border-subtle: #e9ecef;
|
||||
--bs-dark-border-subtle: #adb5bd;
|
||||
--bs-white-rgb: 255, 255, 255;
|
||||
--bs-black-rgb: 0, 0, 0;
|
||||
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
|
||||
--bs-body-font-family: var(--bs-font-sans-serif);
|
||||
--bs-body-font-size: 1rem;
|
||||
--bs-body-font-weight: 400;
|
||||
--bs-body-line-height: 1.5;
|
||||
--bs-body-color: #212529;
|
||||
--bs-body-color-rgb: 33, 37, 41;
|
||||
--bs-body-bg: #fff;
|
||||
--bs-body-bg-rgb: 255, 255, 255;
|
||||
--bs-emphasis-color: #000;
|
||||
--bs-emphasis-color-rgb: 0, 0, 0;
|
||||
--bs-secondary-color: rgba(33, 37, 41, 0.75);
|
||||
--bs-secondary-color-rgb: 33, 37, 41;
|
||||
--bs-secondary-bg: #e9ecef;
|
||||
--bs-secondary-bg-rgb: 233, 236, 239;
|
||||
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
|
||||
--bs-tertiary-color-rgb: 33, 37, 41;
|
||||
--bs-tertiary-bg: #f8f9fa;
|
||||
--bs-tertiary-bg-rgb: 248, 249, 250;
|
||||
--bs-heading-color: inherit;
|
||||
--bs-link-color: #0d6efd;
|
||||
--bs-link-color-rgb: 13, 110, 253;
|
||||
--bs-link-decoration: underline;
|
||||
--bs-link-hover-color: #0a58ca;
|
||||
--bs-link-hover-color-rgb: 10, 88, 202;
|
||||
--bs-code-color: #d63384;
|
||||
--bs-highlight-color: #212529;
|
||||
--bs-highlight-bg: #fff3cd;
|
||||
--bs-border-width: 1px;
|
||||
--bs-border-style: solid;
|
||||
--bs-border-color: #dee2e6;
|
||||
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
|
||||
--bs-border-radius: 0.375rem;
|
||||
--bs-border-radius-sm: 0.25rem;
|
||||
--bs-border-radius-lg: 0.5rem;
|
||||
--bs-border-radius-xl: 1rem;
|
||||
--bs-border-radius-xxl: 2rem;
|
||||
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
|
||||
--bs-border-radius-pill: 50rem;
|
||||
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
|
||||
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
|
||||
--bs-focus-ring-width: 0.25rem;
|
||||
--bs-focus-ring-opacity: 0.25;
|
||||
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
|
||||
--bs-form-valid-color: #198754;
|
||||
--bs-form-valid-border-color: #198754;
|
||||
--bs-form-invalid-color: #dc3545;
|
||||
--bs-form-invalid-border-color: #dc3545;
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] {
|
||||
color-scheme: dark;
|
||||
--bs-body-color: #dee2e6;
|
||||
--bs-body-color-rgb: 222, 226, 230;
|
||||
--bs-body-bg: #212529;
|
||||
--bs-body-bg-rgb: 33, 37, 41;
|
||||
--bs-emphasis-color: #fff;
|
||||
--bs-emphasis-color-rgb: 255, 255, 255;
|
||||
--bs-secondary-color: rgba(222, 226, 230, 0.75);
|
||||
--bs-secondary-color-rgb: 222, 226, 230;
|
||||
--bs-secondary-bg: #343a40;
|
||||
--bs-secondary-bg-rgb: 52, 58, 64;
|
||||
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
|
||||
--bs-tertiary-color-rgb: 222, 226, 230;
|
||||
--bs-tertiary-bg: #2b3035;
|
||||
--bs-tertiary-bg-rgb: 43, 48, 53;
|
||||
--bs-primary-text-emphasis: #6ea8fe;
|
||||
--bs-secondary-text-emphasis: #a7acb1;
|
||||
--bs-success-text-emphasis: #75b798;
|
||||
--bs-info-text-emphasis: #6edff6;
|
||||
--bs-warning-text-emphasis: #ffda6a;
|
||||
--bs-danger-text-emphasis: #ea868f;
|
||||
--bs-light-text-emphasis: #f8f9fa;
|
||||
--bs-dark-text-emphasis: #dee2e6;
|
||||
--bs-primary-bg-subtle: #031633;
|
||||
--bs-secondary-bg-subtle: #161719;
|
||||
--bs-success-bg-subtle: #051b11;
|
||||
--bs-info-bg-subtle: #032830;
|
||||
--bs-warning-bg-subtle: #332701;
|
||||
--bs-danger-bg-subtle: #2c0b0e;
|
||||
--bs-light-bg-subtle: #343a40;
|
||||
--bs-dark-bg-subtle: #1a1d20;
|
||||
--bs-primary-border-subtle: #084298;
|
||||
--bs-secondary-border-subtle: #41464b;
|
||||
--bs-success-border-subtle: #0f5132;
|
||||
--bs-info-border-subtle: #087990;
|
||||
--bs-warning-border-subtle: #997404;
|
||||
--bs-danger-border-subtle: #842029;
|
||||
--bs-light-border-subtle: #495057;
|
||||
--bs-dark-border-subtle: #343a40;
|
||||
--bs-heading-color: inherit;
|
||||
--bs-link-color: #6ea8fe;
|
||||
--bs-link-hover-color: #8bb9fe;
|
||||
--bs-link-color-rgb: 110, 168, 254;
|
||||
--bs-link-hover-color-rgb: 139, 185, 254;
|
||||
--bs-code-color: #e685b5;
|
||||
--bs-highlight-color: #dee2e6;
|
||||
--bs-highlight-bg: #664d03;
|
||||
--bs-border-color: #495057;
|
||||
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
|
||||
--bs-form-valid-color: #75b798;
|
||||
--bs-form-valid-border-color: #75b798;
|
||||
--bs-form-invalid-color: #ea868f;
|
||||
--bs-form-invalid-border-color: #ea868f;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:root {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--bs-body-font-family);
|
||||
font-size: var(--bs-body-font-size);
|
||||
font-weight: var(--bs-body-font-weight);
|
||||
line-height: var(--bs-body-line-height);
|
||||
color: var(--bs-body-color);
|
||||
text-align: var(--bs-body-text-align);
|
||||
background-color: var(--bs-body-bg);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 1rem 0;
|
||||
color: inherit;
|
||||
border: 0;
|
||||
border-top: var(--bs-border-width) solid;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
h6, h5, h4, h3, h2, h1 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: var(--bs-heading-color);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: calc(1.375rem + 1.5vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: calc(1.325rem + 0.9vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: calc(1.3rem + 0.6vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h3 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h4 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
abbr[title] {
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
cursor: help;
|
||||
-webkit-text-decoration-skip-ink: none;
|
||||
text-decoration-skip-ink: none;
|
||||
}
|
||||
|
||||
address {
|
||||
margin-bottom: 1rem;
|
||||
font-style: normal;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
padding-left: 2rem;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul,
|
||||
dl {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
ol ol,
|
||||
ul ul,
|
||||
ol ul,
|
||||
ul ol {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
mark {
|
||||
padding: 0.1875em;
|
||||
color: var(--bs-highlight-color);
|
||||
background-color: var(--bs-highlight-bg);
|
||||
}
|
||||
|
||||
sub,
|
||||
sup {
|
||||
position: relative;
|
||||
font-size: 0.75em;
|
||||
line-height: 0;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
|
||||
text-decoration: underline;
|
||||
}
|
||||
a:hover {
|
||||
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
|
||||
}
|
||||
|
||||
a:not([href]):not([class]), a:not([href]):not([class]):hover {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
pre,
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: var(--bs-font-monospace);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
pre {
|
||||
display: block;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
overflow: auto;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
pre code {
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.875em;
|
||||
color: var(--bs-code-color);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
a > code {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
kbd {
|
||||
padding: 0.1875rem 0.375rem;
|
||||
font-size: 0.875em;
|
||||
color: var(--bs-body-bg);
|
||||
background-color: var(--bs-body-color);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
kbd kbd {
|
||||
padding: 0;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
table {
|
||||
caption-side: bottom;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
caption {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
color: var(--bs-secondary-color);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: inherit;
|
||||
text-align: -webkit-match-parent;
|
||||
}
|
||||
|
||||
thead,
|
||||
tbody,
|
||||
tfoot,
|
||||
tr,
|
||||
td,
|
||||
th {
|
||||
border-color: inherit;
|
||||
border-style: solid;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
button:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
select,
|
||||
optgroup,
|
||||
textarea {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
[role=button] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select {
|
||||
word-wrap: normal;
|
||||
}
|
||||
select:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
button,
|
||||
[type=button],
|
||||
[type=reset],
|
||||
[type=submit] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
button:not(:disabled),
|
||||
[type=button]:not(:disabled),
|
||||
[type=reset]:not(:disabled),
|
||||
[type=submit]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
::-moz-focus-inner {
|
||||
padding: 0;
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
float: left;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
line-height: inherit;
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
legend {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
legend + * {
|
||||
clear: left;
|
||||
}
|
||||
|
||||
::-webkit-datetime-edit-fields-wrapper,
|
||||
::-webkit-datetime-edit-text,
|
||||
::-webkit-datetime-edit-minute,
|
||||
::-webkit-datetime-edit-hour-field,
|
||||
::-webkit-datetime-edit-day-field,
|
||||
::-webkit-datetime-edit-month-field,
|
||||
::-webkit-datetime-edit-year-field {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-inner-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
[type=search] {
|
||||
-webkit-appearance: textfield;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* rtl:raw:
|
||||
[type="tel"],
|
||||
[type="url"],
|
||||
[type="email"],
|
||||
[type="number"] {
|
||||
direction: ltr;
|
||||
}
|
||||
*/
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
::file-selector-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
output {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
iframe {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/*# sourceMappingURL=bootstrap-reboot.css.map */
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,594 +0,0 @@
|
||||
/*!
|
||||
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
|
||||
* Copyright 2011-2024 The Bootstrap Authors
|
||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
||||
*/
|
||||
:root,
|
||||
[data-bs-theme=light] {
|
||||
--bs-blue: #0d6efd;
|
||||
--bs-indigo: #6610f2;
|
||||
--bs-purple: #6f42c1;
|
||||
--bs-pink: #d63384;
|
||||
--bs-red: #dc3545;
|
||||
--bs-orange: #fd7e14;
|
||||
--bs-yellow: #ffc107;
|
||||
--bs-green: #198754;
|
||||
--bs-teal: #20c997;
|
||||
--bs-cyan: #0dcaf0;
|
||||
--bs-black: #000;
|
||||
--bs-white: #fff;
|
||||
--bs-gray: #6c757d;
|
||||
--bs-gray-dark: #343a40;
|
||||
--bs-gray-100: #f8f9fa;
|
||||
--bs-gray-200: #e9ecef;
|
||||
--bs-gray-300: #dee2e6;
|
||||
--bs-gray-400: #ced4da;
|
||||
--bs-gray-500: #adb5bd;
|
||||
--bs-gray-600: #6c757d;
|
||||
--bs-gray-700: #495057;
|
||||
--bs-gray-800: #343a40;
|
||||
--bs-gray-900: #212529;
|
||||
--bs-primary: #0d6efd;
|
||||
--bs-secondary: #6c757d;
|
||||
--bs-success: #198754;
|
||||
--bs-info: #0dcaf0;
|
||||
--bs-warning: #ffc107;
|
||||
--bs-danger: #dc3545;
|
||||
--bs-light: #f8f9fa;
|
||||
--bs-dark: #212529;
|
||||
--bs-primary-rgb: 13, 110, 253;
|
||||
--bs-secondary-rgb: 108, 117, 125;
|
||||
--bs-success-rgb: 25, 135, 84;
|
||||
--bs-info-rgb: 13, 202, 240;
|
||||
--bs-warning-rgb: 255, 193, 7;
|
||||
--bs-danger-rgb: 220, 53, 69;
|
||||
--bs-light-rgb: 248, 249, 250;
|
||||
--bs-dark-rgb: 33, 37, 41;
|
||||
--bs-primary-text-emphasis: #052c65;
|
||||
--bs-secondary-text-emphasis: #2b2f32;
|
||||
--bs-success-text-emphasis: #0a3622;
|
||||
--bs-info-text-emphasis: #055160;
|
||||
--bs-warning-text-emphasis: #664d03;
|
||||
--bs-danger-text-emphasis: #58151c;
|
||||
--bs-light-text-emphasis: #495057;
|
||||
--bs-dark-text-emphasis: #495057;
|
||||
--bs-primary-bg-subtle: #cfe2ff;
|
||||
--bs-secondary-bg-subtle: #e2e3e5;
|
||||
--bs-success-bg-subtle: #d1e7dd;
|
||||
--bs-info-bg-subtle: #cff4fc;
|
||||
--bs-warning-bg-subtle: #fff3cd;
|
||||
--bs-danger-bg-subtle: #f8d7da;
|
||||
--bs-light-bg-subtle: #fcfcfd;
|
||||
--bs-dark-bg-subtle: #ced4da;
|
||||
--bs-primary-border-subtle: #9ec5fe;
|
||||
--bs-secondary-border-subtle: #c4c8cb;
|
||||
--bs-success-border-subtle: #a3cfbb;
|
||||
--bs-info-border-subtle: #9eeaf9;
|
||||
--bs-warning-border-subtle: #ffe69c;
|
||||
--bs-danger-border-subtle: #f1aeb5;
|
||||
--bs-light-border-subtle: #e9ecef;
|
||||
--bs-dark-border-subtle: #adb5bd;
|
||||
--bs-white-rgb: 255, 255, 255;
|
||||
--bs-black-rgb: 0, 0, 0;
|
||||
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
|
||||
--bs-body-font-family: var(--bs-font-sans-serif);
|
||||
--bs-body-font-size: 1rem;
|
||||
--bs-body-font-weight: 400;
|
||||
--bs-body-line-height: 1.5;
|
||||
--bs-body-color: #212529;
|
||||
--bs-body-color-rgb: 33, 37, 41;
|
||||
--bs-body-bg: #fff;
|
||||
--bs-body-bg-rgb: 255, 255, 255;
|
||||
--bs-emphasis-color: #000;
|
||||
--bs-emphasis-color-rgb: 0, 0, 0;
|
||||
--bs-secondary-color: rgba(33, 37, 41, 0.75);
|
||||
--bs-secondary-color-rgb: 33, 37, 41;
|
||||
--bs-secondary-bg: #e9ecef;
|
||||
--bs-secondary-bg-rgb: 233, 236, 239;
|
||||
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
|
||||
--bs-tertiary-color-rgb: 33, 37, 41;
|
||||
--bs-tertiary-bg: #f8f9fa;
|
||||
--bs-tertiary-bg-rgb: 248, 249, 250;
|
||||
--bs-heading-color: inherit;
|
||||
--bs-link-color: #0d6efd;
|
||||
--bs-link-color-rgb: 13, 110, 253;
|
||||
--bs-link-decoration: underline;
|
||||
--bs-link-hover-color: #0a58ca;
|
||||
--bs-link-hover-color-rgb: 10, 88, 202;
|
||||
--bs-code-color: #d63384;
|
||||
--bs-highlight-color: #212529;
|
||||
--bs-highlight-bg: #fff3cd;
|
||||
--bs-border-width: 1px;
|
||||
--bs-border-style: solid;
|
||||
--bs-border-color: #dee2e6;
|
||||
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
|
||||
--bs-border-radius: 0.375rem;
|
||||
--bs-border-radius-sm: 0.25rem;
|
||||
--bs-border-radius-lg: 0.5rem;
|
||||
--bs-border-radius-xl: 1rem;
|
||||
--bs-border-radius-xxl: 2rem;
|
||||
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
|
||||
--bs-border-radius-pill: 50rem;
|
||||
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
|
||||
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
|
||||
--bs-focus-ring-width: 0.25rem;
|
||||
--bs-focus-ring-opacity: 0.25;
|
||||
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
|
||||
--bs-form-valid-color: #198754;
|
||||
--bs-form-valid-border-color: #198754;
|
||||
--bs-form-invalid-color: #dc3545;
|
||||
--bs-form-invalid-border-color: #dc3545;
|
||||
}
|
||||
|
||||
[data-bs-theme=dark] {
|
||||
color-scheme: dark;
|
||||
--bs-body-color: #dee2e6;
|
||||
--bs-body-color-rgb: 222, 226, 230;
|
||||
--bs-body-bg: #212529;
|
||||
--bs-body-bg-rgb: 33, 37, 41;
|
||||
--bs-emphasis-color: #fff;
|
||||
--bs-emphasis-color-rgb: 255, 255, 255;
|
||||
--bs-secondary-color: rgba(222, 226, 230, 0.75);
|
||||
--bs-secondary-color-rgb: 222, 226, 230;
|
||||
--bs-secondary-bg: #343a40;
|
||||
--bs-secondary-bg-rgb: 52, 58, 64;
|
||||
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
|
||||
--bs-tertiary-color-rgb: 222, 226, 230;
|
||||
--bs-tertiary-bg: #2b3035;
|
||||
--bs-tertiary-bg-rgb: 43, 48, 53;
|
||||
--bs-primary-text-emphasis: #6ea8fe;
|
||||
--bs-secondary-text-emphasis: #a7acb1;
|
||||
--bs-success-text-emphasis: #75b798;
|
||||
--bs-info-text-emphasis: #6edff6;
|
||||
--bs-warning-text-emphasis: #ffda6a;
|
||||
--bs-danger-text-emphasis: #ea868f;
|
||||
--bs-light-text-emphasis: #f8f9fa;
|
||||
--bs-dark-text-emphasis: #dee2e6;
|
||||
--bs-primary-bg-subtle: #031633;
|
||||
--bs-secondary-bg-subtle: #161719;
|
||||
--bs-success-bg-subtle: #051b11;
|
||||
--bs-info-bg-subtle: #032830;
|
||||
--bs-warning-bg-subtle: #332701;
|
||||
--bs-danger-bg-subtle: #2c0b0e;
|
||||
--bs-light-bg-subtle: #343a40;
|
||||
--bs-dark-bg-subtle: #1a1d20;
|
||||
--bs-primary-border-subtle: #084298;
|
||||
--bs-secondary-border-subtle: #41464b;
|
||||
--bs-success-border-subtle: #0f5132;
|
||||
--bs-info-border-subtle: #087990;
|
||||
--bs-warning-border-subtle: #997404;
|
||||
--bs-danger-border-subtle: #842029;
|
||||
--bs-light-border-subtle: #495057;
|
||||
--bs-dark-border-subtle: #343a40;
|
||||
--bs-heading-color: inherit;
|
||||
--bs-link-color: #6ea8fe;
|
||||
--bs-link-hover-color: #8bb9fe;
|
||||
--bs-link-color-rgb: 110, 168, 254;
|
||||
--bs-link-hover-color-rgb: 139, 185, 254;
|
||||
--bs-code-color: #e685b5;
|
||||
--bs-highlight-color: #dee2e6;
|
||||
--bs-highlight-bg: #664d03;
|
||||
--bs-border-color: #495057;
|
||||
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
|
||||
--bs-form-valid-color: #75b798;
|
||||
--bs-form-valid-border-color: #75b798;
|
||||
--bs-form-invalid-color: #ea868f;
|
||||
--bs-form-invalid-border-color: #ea868f;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
:root {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--bs-body-font-family);
|
||||
font-size: var(--bs-body-font-size);
|
||||
font-weight: var(--bs-body-font-weight);
|
||||
line-height: var(--bs-body-line-height);
|
||||
color: var(--bs-body-color);
|
||||
text-align: var(--bs-body-text-align);
|
||||
background-color: var(--bs-body-bg);
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 1rem 0;
|
||||
color: inherit;
|
||||
border: 0;
|
||||
border-top: var(--bs-border-width) solid;
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
h6, h5, h4, h3, h2, h1 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
color: var(--bs-heading-color);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: calc(1.375rem + 1.5vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: calc(1.325rem + 0.9vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h2 {
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: calc(1.3rem + 0.6vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h3 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
h4 {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
p {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
abbr[title] {
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
cursor: help;
|
||||
-webkit-text-decoration-skip-ink: none;
|
||||
text-decoration-skip-ink: none;
|
||||
}
|
||||
|
||||
address {
|
||||
margin-bottom: 1rem;
|
||||
font-style: normal;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
padding-right: 2rem;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul,
|
||||
dl {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
ol ol,
|
||||
ul ul,
|
||||
ol ul,
|
||||
ul ol {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
dt {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
dd {
|
||||
margin-bottom: 0.5rem;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
mark {
|
||||
padding: 0.1875em;
|
||||
color: var(--bs-highlight-color);
|
||||
background-color: var(--bs-highlight-bg);
|
||||
}
|
||||
|
||||
sub,
|
||||
sup {
|
||||
position: relative;
|
||||
font-size: 0.75em;
|
||||
line-height: 0;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
a {
|
||||
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
|
||||
text-decoration: underline;
|
||||
}
|
||||
a:hover {
|
||||
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
|
||||
}
|
||||
|
||||
a:not([href]):not([class]), a:not([href]):not([class]):hover {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
pre,
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: var(--bs-font-monospace);
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
pre {
|
||||
display: block;
|
||||
margin-top: 0;
|
||||
margin-bottom: 1rem;
|
||||
overflow: auto;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
pre code {
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
word-break: normal;
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 0.875em;
|
||||
color: var(--bs-code-color);
|
||||
word-wrap: break-word;
|
||||
}
|
||||
a > code {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
kbd {
|
||||
padding: 0.1875rem 0.375rem;
|
||||
font-size: 0.875em;
|
||||
color: var(--bs-body-bg);
|
||||
background-color: var(--bs-body-color);
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
kbd kbd {
|
||||
padding: 0;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
figure {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
img,
|
||||
svg {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
table {
|
||||
caption-side: bottom;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
caption {
|
||||
padding-top: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
color: var(--bs-secondary-color);
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: inherit;
|
||||
text-align: -webkit-match-parent;
|
||||
}
|
||||
|
||||
thead,
|
||||
tbody,
|
||||
tfoot,
|
||||
tr,
|
||||
td,
|
||||
th {
|
||||
border-color: inherit;
|
||||
border-style: solid;
|
||||
border-width: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
button {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
button:focus:not(:focus-visible) {
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
input,
|
||||
button,
|
||||
select,
|
||||
optgroup,
|
||||
textarea {
|
||||
margin: 0;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
line-height: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
[role=button] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
select {
|
||||
word-wrap: normal;
|
||||
}
|
||||
select:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
button,
|
||||
[type=button],
|
||||
[type=reset],
|
||||
[type=submit] {
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
button:not(:disabled),
|
||||
[type=button]:not(:disabled),
|
||||
[type=reset]:not(:disabled),
|
||||
[type=submit]:not(:disabled) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
::-moz-focus-inner {
|
||||
padding: 0;
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
legend {
|
||||
float: right;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: calc(1.275rem + 0.3vw);
|
||||
line-height: inherit;
|
||||
}
|
||||
@media (min-width: 1200px) {
|
||||
legend {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
}
|
||||
legend + * {
|
||||
clear: right;
|
||||
}
|
||||
|
||||
::-webkit-datetime-edit-fields-wrapper,
|
||||
::-webkit-datetime-edit-text,
|
||||
::-webkit-datetime-edit-minute,
|
||||
::-webkit-datetime-edit-hour-field,
|
||||
::-webkit-datetime-edit-day-field,
|
||||
::-webkit-datetime-edit-month-field,
|
||||
::-webkit-datetime-edit-year-field {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-inner-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
[type=search] {
|
||||
-webkit-appearance: textfield;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
[type="tel"],
|
||||
[type="url"],
|
||||
[type="email"],
|
||||
[type="number"] {
|
||||
direction: ltr;
|
||||
}
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
::-webkit-color-swatch-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
::file-selector-button {
|
||||
font: inherit;
|
||||
-webkit-appearance: button;
|
||||
}
|
||||
|
||||
output {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
iframe {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
12057
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap.css
vendored
12057
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap.css
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
536
QUICKSTART.md
Normal file
536
QUICKSTART.md
Normal file
@@ -0,0 +1,536 @@
|
||||
# ⚡ Быстрый старт - YandexMusic
|
||||
|
||||
5-минутное введение в использование YandexMusic для начинающих.
|
||||
|
||||
## 📋 Содержание
|
||||
|
||||
- [Установка](#установка)
|
||||
- [Ваш первый запрос](#ваш-первый-запрос)
|
||||
- [Авторизация](#авторизация)
|
||||
- [Основные операции](#основные-операции)
|
||||
- [WebSocket (Ynison)](#websocket-ynison)
|
||||
- [Обработка ошибок](#обработка-ошибок)
|
||||
- [Дальнейшее обучение](#дальнейшее-обучение)
|
||||
|
||||
## 📦 Установка
|
||||
|
||||
### 1. Требования
|
||||
|
||||
- .NET 10 SDK
|
||||
- C# 12 совместимый компилятор
|
||||
|
||||
### 2. Создание проекта
|
||||
|
||||
```bash
|
||||
# Создаём консольное приложение
|
||||
dotnet new console -n MyMusicApp
|
||||
cd MyMusicApp
|
||||
|
||||
# Добавляем ссылку на YandexMusic (если опубликовано в NuGet)
|
||||
dotnet add package YandexMusic
|
||||
|
||||
# ИЛИ добавляем ссылку на проект локально
|
||||
dotnet add reference ../YandexMusic/YandexMusic.csproj
|
||||
```
|
||||
|
||||
### 3. Восстановление зависимостей
|
||||
|
||||
```bash
|
||||
dotnet restore
|
||||
```
|
||||
|
||||
## ✨ Ваш первый запрос
|
||||
|
||||
### Простой поиск (без авторизации)
|
||||
|
||||
```csharp
|
||||
using YandexMusic;
|
||||
using YandexMusic.API;
|
||||
|
||||
// Создаём клиент
|
||||
var client = new YandexMusicClient();
|
||||
|
||||
// Ищем музыку
|
||||
var results = await client.Api.Search.SearchAsync("Ленинград");
|
||||
|
||||
// Выводим первые результаты
|
||||
if (results?.Tracks?.Results != null)
|
||||
{
|
||||
foreach (var track in results.Tracks.Results.Take(5))
|
||||
{
|
||||
Console.WriteLine($"🎵 {track.Title} - {string.Join(", ", track.Artists?.Select(a => a.Title) ?? [])}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Получение информации о треке
|
||||
|
||||
```csharp
|
||||
var track = await client.Api.Track.GetTrackAsync("trackId");
|
||||
|
||||
if (track != null)
|
||||
{
|
||||
Console.WriteLine($"Трек: {track.Title}");
|
||||
Console.WriteLine($"Исполнитель: {track.Artists?.FirstOrDefault()?.Title}");
|
||||
Console.WriteLine($"Альбом: {track.Album?.Title}");
|
||||
Console.WriteLine($"Длительность: {track.DurationMs / 1000} сек");
|
||||
}
|
||||
```
|
||||
|
||||
## 🔐 Авторизация
|
||||
|
||||
### Способ 1: Через логин и пароль
|
||||
|
||||
```csharp
|
||||
var client = new YandexMusicClient();
|
||||
|
||||
try
|
||||
{
|
||||
// Авторизуемся
|
||||
await client.AuthorizeAsync("your-email@gmail.com", "your-password");
|
||||
|
||||
Console.WriteLine($"✅ Авторизирован: {client.Account.User?.DisplayName}");
|
||||
Console.WriteLine($"💰 Подписка: {client.Account.Plus?.HasPlus}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"❌ Ошибка: {ex.Message}");
|
||||
}
|
||||
```
|
||||
|
||||
### Способ 2: Через токен
|
||||
|
||||
```csharp
|
||||
var client = new YandexMusicClient();
|
||||
|
||||
// Если у вас уже есть токен
|
||||
await client.AuthorizeByTokenAsync("your-oauth-token");
|
||||
|
||||
Console.WriteLine($"✅ Авторизирован через токен");
|
||||
```
|
||||
|
||||
## 📚 Основные операции
|
||||
|
||||
### 1. Поиск
|
||||
|
||||
```csharp
|
||||
// Поиск треков
|
||||
var search = await client.Api.Search.SearchAsync("Beatles");
|
||||
var tracks = search?.Tracks?.Results ?? [];
|
||||
foreach (var t in tracks.Take(10))
|
||||
{
|
||||
Console.WriteLine($" {t.Title}");
|
||||
}
|
||||
|
||||
// Поиск альбомов
|
||||
var albumSearch = await client.Api.Search.SearchAsync("Abbey Road", type: "album");
|
||||
|
||||
// Поиск исполнителей
|
||||
var artistSearch = await client.Api.Search.SearchAsync("John Lennon", type: "artist");
|
||||
```
|
||||
|
||||
### 2. Альбомы
|
||||
|
||||
```csharp
|
||||
// Получение альбома
|
||||
var album = await client.Api.Album.GetAlbumAsync("albumId");
|
||||
Console.WriteLine($"Альбом: {album?.Title} ({album?.Year})");
|
||||
Console.WriteLine($"Жанр: {album?.Genre}");
|
||||
|
||||
// Список треков в альбоме
|
||||
foreach (var track in album?.Tracks ?? [])
|
||||
{
|
||||
Console.WriteLine($" {track.Position}. {track.Title}");
|
||||
}
|
||||
|
||||
// Получение нескольких альбомов
|
||||
var albums = await client.Api.Album.GetAlbumsAsync(["albumId1", "albumId2"]);
|
||||
```
|
||||
|
||||
### 3. Исполнители
|
||||
|
||||
```csharp
|
||||
// Получение исполнителя
|
||||
var artist = await client.Api.Artist.GetArtistAsync("artistId");
|
||||
Console.WriteLine($"Исполнитель: {artist?.Title}");
|
||||
Console.WriteLine($"Жанр: {artist?.Genre}");
|
||||
|
||||
// Фото исполнителя
|
||||
if (artist?.Cover?.Pic != null)
|
||||
{
|
||||
Console.WriteLine($"Фото: {artist.Cover.Pic}");
|
||||
}
|
||||
|
||||
// Информация об исполнителе
|
||||
Console.WriteLine($"Альбомов: {artist?.Albums?.Count}");
|
||||
Console.WriteLine($"Треков: {artist?.Tracks?.Count}");
|
||||
```
|
||||
|
||||
### 4. Плейлисты
|
||||
|
||||
```csharp
|
||||
// Получение плейлиста
|
||||
var playlist = await client.Api.Playlist.GetPlaylistAsync("playlistId");
|
||||
Console.WriteLine($"Плейлист: {playlist?.Title}");
|
||||
Console.WriteLine($"Описание: {playlist?.Description}");
|
||||
Console.WriteLine($"Треков: {playlist?.Tracks?.Count}");
|
||||
|
||||
// Список треков
|
||||
foreach (var track in playlist?.Tracks ?? [])
|
||||
{
|
||||
Console.WriteLine($" {track.Title}");
|
||||
}
|
||||
|
||||
// Получение информации о плейлисте через UUID
|
||||
var playlistByUuid = await client.Api.Playlist.GetPlaylistByUuidAsync("uuid");
|
||||
```
|
||||
|
||||
### 5. Треки
|
||||
|
||||
```csharp
|
||||
// Получение одного трека
|
||||
var track = await client.Api.Track.GetTrackAsync("trackId");
|
||||
|
||||
// Получение нескольких треков
|
||||
var tracks = await client.Api.Track.GetTracksAsync(["id1", "id2", "id3"]);
|
||||
|
||||
// Дополнительная информация о треке
|
||||
var supplement = await client.Api.Track.GetTrackSupplementAsync("trackId");
|
||||
|
||||
// Похожие треки
|
||||
var similar = await client.Api.Track.GetTrackSimilarAsync("trackId");
|
||||
```
|
||||
|
||||
### 6. Радио
|
||||
|
||||
```csharp
|
||||
// Получение станций
|
||||
var stations = await client.Api.Radio.GetStationsAsync();
|
||||
|
||||
foreach (var station in stations?.Stations ?? [])
|
||||
{
|
||||
Console.WriteLine($"📻 {station.Title}");
|
||||
}
|
||||
|
||||
// Треки станции
|
||||
var stationTracks = await client.Api.Radio.GetStationTracksAsync("stationId");
|
||||
foreach (var track in stationTracks?.Sequence ?? [])
|
||||
{
|
||||
Console.WriteLine($" {track.Track?.Title}");
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Библиотека (Лайки)
|
||||
|
||||
```csharp
|
||||
// Получение лайков
|
||||
var library = await client.Api.Library.GetLibraryAsync();
|
||||
|
||||
Console.WriteLine($"Лайки: {library?.Library?.Count}");
|
||||
|
||||
// Добавление трека в лайки
|
||||
await client.Api.Library.LibraryAddAsync("trackId");
|
||||
|
||||
// Удаление из лайков
|
||||
await client.Api.Library.LibraryRemoveAsync("trackId");
|
||||
```
|
||||
|
||||
### 8. Главная страница (Landing)
|
||||
|
||||
```csharp
|
||||
// Получение рекомендаций
|
||||
var landing = await client.Api.Landing.GetLandingAsync("home");
|
||||
|
||||
foreach (var block in landing?.Blocks ?? [])
|
||||
{
|
||||
Console.WriteLine($"Блок: {block.Title}");
|
||||
|
||||
foreach (var entity in block.Entities ?? [])
|
||||
{
|
||||
Console.WriteLine($" {entity.Title}");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 🌐 WebSocket (Ynison)
|
||||
|
||||
### Подключение к Ynison
|
||||
|
||||
```csharp
|
||||
var client = new YandexMusicClient();
|
||||
|
||||
// Авторизуемся
|
||||
await client.AuthorizeAsync("email@gmail.com", "password");
|
||||
|
||||
// Получаем плеер
|
||||
var player = client.Ynison;
|
||||
|
||||
if (player != null)
|
||||
{
|
||||
// Подключаемся
|
||||
await player.ConnectAsync();
|
||||
Console.WriteLine("✅ Подключены к Ynison");
|
||||
|
||||
// Используем плеер...
|
||||
|
||||
// Отключаемся
|
||||
await player.DisconnectAsync();
|
||||
}
|
||||
```
|
||||
|
||||
## ⚠️ Обработка ошибок
|
||||
|
||||
### Базовая обработка
|
||||
|
||||
```csharp
|
||||
try
|
||||
{
|
||||
var track = await client.Api.Track.GetTrackAsync("trackId");
|
||||
if (track == null)
|
||||
{
|
||||
Console.WriteLine("Трек не найден");
|
||||
}
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
Console.WriteLine($"Ошибка сети: {ex.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Неожиданная ошибка: {ex.Message}");
|
||||
}
|
||||
```
|
||||
|
||||
### Проверка авторизации
|
||||
|
||||
```csharp
|
||||
if (!client.IsAuthorized)
|
||||
{
|
||||
Console.WriteLine("❌ Требуется авторизация");
|
||||
return;
|
||||
}
|
||||
|
||||
// Выполняем авторизованные операции
|
||||
var account = await client.Api.User.GetAccountAsync();
|
||||
```
|
||||
|
||||
### Валидация входных параметров
|
||||
|
||||
```csharp
|
||||
public async Task GetTrackInfo(string trackId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(trackId))
|
||||
{
|
||||
Console.WriteLine("❌ ID трека не может быть пустым");
|
||||
return;
|
||||
}
|
||||
|
||||
var track = await client.Api.Track.GetTrackAsync(trackId);
|
||||
if (track == null)
|
||||
{
|
||||
Console.WriteLine($"❌ Трек с ID {trackId} не найден");
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"✅ {track.Title}");
|
||||
}
|
||||
```
|
||||
|
||||
## 🔍 Полные примеры
|
||||
|
||||
### Пример 1: Поиск и загрузка информации
|
||||
|
||||
```csharp
|
||||
using YandexMusic;
|
||||
|
||||
// Создание клиента
|
||||
var client = new YandexMusicClient();
|
||||
|
||||
// Поиск
|
||||
Console.Write("Введите исполнителя: ");
|
||||
string query = Console.ReadLine() ?? "Beatles";
|
||||
|
||||
var search = await client.Api.Search.SearchAsync(query);
|
||||
var artists = search?.Artists?.Results ?? [];
|
||||
|
||||
if (artists.Count == 0)
|
||||
{
|
||||
Console.WriteLine("Исполнители не найдены");
|
||||
return;
|
||||
}
|
||||
|
||||
// Выбор первого результата
|
||||
var artist = artists.First();
|
||||
Console.WriteLine($"Найден: {artist.Title}");
|
||||
|
||||
// Получение полной информации
|
||||
var fullArtist = await client.Api.Artist.GetArtistAsync(artist.Id);
|
||||
Console.WriteLine($"Жанр: {fullArtist?.Genre}");
|
||||
Console.WriteLine($"Альбомов: {fullArtist?.Albums?.Count}");
|
||||
|
||||
// Список треков
|
||||
Console.WriteLine("\nПопулярные треки:");
|
||||
foreach (var track in fullArtist?.Tracks?.Take(5) ?? [])
|
||||
{
|
||||
Console.WriteLine($" 🎵 {track.Title}");
|
||||
}
|
||||
```
|
||||
|
||||
### Пример 2: Работа с плейлистом
|
||||
|
||||
```csharp
|
||||
using YandexMusic;
|
||||
|
||||
var client = new YandexMusicClient();
|
||||
|
||||
// ID плейлиста Яндекса
|
||||
string playlistId = "1130499373";
|
||||
|
||||
// Получение плейлиста
|
||||
var playlist = await client.Api.Playlist.GetPlaylistAsync(playlistId);
|
||||
|
||||
Console.WriteLine($"📋 {playlist?.Title}");
|
||||
Console.WriteLine($"Описание: {playlist?.Description}");
|
||||
Console.WriteLine($"Треков: {playlist?.Tracks?.Count}");
|
||||
|
||||
// Вывод треков с номерами
|
||||
Console.WriteLine("\nТреки:");
|
||||
int i = 1;
|
||||
foreach (var track in playlist?.Tracks ?? [])
|
||||
{
|
||||
var artists = string.Join(", ", track.Artists?.Select(a => a.Title) ?? []);
|
||||
Console.WriteLine($"{i}. {track.Title} - {artists}");
|
||||
i++;
|
||||
}
|
||||
|
||||
// Общая длительность
|
||||
var totalSeconds = (playlist?.Tracks ?? []).Sum(t => t.DurationMs ?? 0) / 1000;
|
||||
var hours = totalSeconds / 3600;
|
||||
var minutes = (totalSeconds % 3600) / 60;
|
||||
Console.WriteLine($"\nОбщая длительность: {hours}:{minutes:D2}");
|
||||
```
|
||||
|
||||
### Пример 3: Авторизация и библиотека
|
||||
|
||||
```csharp
|
||||
using YandexMusic;
|
||||
|
||||
var client = new YandexMusicClient();
|
||||
|
||||
Console.Write("Email: ");
|
||||
string email = Console.ReadLine() ?? "";
|
||||
|
||||
Console.Write("Password: ");
|
||||
string password = Console.ReadLine() ?? "";
|
||||
|
||||
try
|
||||
{
|
||||
await client.AuthorizeAsync(email, password);
|
||||
Console.WriteLine($"✅ Добро пожаловать, {client.Account.User?.DisplayName}!");
|
||||
|
||||
if (client.IsAuthorized)
|
||||
{
|
||||
// Получение лайков
|
||||
var library = await client.Api.Library.GetLibraryAsync();
|
||||
Console.WriteLine($"\n❤️ Ваши лайки: {library?.Library?.Count} треков");
|
||||
|
||||
// Список лайков
|
||||
if (library?.Library?.Count > 0)
|
||||
{
|
||||
Console.WriteLine("\nПервые 5 лайков:");
|
||||
var trackIds = library.Library.Select(l => l.TrackId).Take(5);
|
||||
var tracks = await client.Api.Track.GetTracksAsync(trackIds);
|
||||
|
||||
foreach (var track in tracks)
|
||||
{
|
||||
Console.WriteLine($" ❤️ {track?.Title}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"❌ Ошибка: {ex.Message}");
|
||||
}
|
||||
```
|
||||
|
||||
## 📖 Дальнейшее обучение
|
||||
|
||||
### Документация
|
||||
|
||||
- **[README.md](README.md)** - Полное описание проекта
|
||||
- **[ARCHITECTURE.md](ARCHITECTURE.md)** - Архитектура и паттерны
|
||||
- **[YandexMusic.API/README.md](YandexMusic.API/README.md)** - Низкоуровневый API
|
||||
- **[CONTRIBUTING.md](CONTRIBUTING.md)** - Как внести вклад
|
||||
|
||||
### Полезные ресурсы
|
||||
|
||||
- [.NET 10 Documentation](https://learn.microsoft.com/dotnet/)
|
||||
- [C# 12 Features](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-12)
|
||||
- [System.Text.Json Guide](https://learn.microsoft.com/en-us/dotnet/standard/serialization/system-text-json)
|
||||
|
||||
### Где найти ID
|
||||
|
||||
**Трека:** `https://music.yandex.ru/album/123456/track/789012`
|
||||
- Album ID: `123456`
|
||||
- Track ID: `789012`
|
||||
|
||||
**Плейлиста:** `https://music.yandex.ru/playlist/1130499373`
|
||||
- Playlist ID: `1130499373`
|
||||
|
||||
**Исполнителя:** `https://music.yandex.ru/artist/123456`
|
||||
- Artist ID: `123456`
|
||||
|
||||
## ⚡ Советы и трюки
|
||||
|
||||
### 1. Работа с async/await
|
||||
|
||||
```csharp
|
||||
// Получение нескольких ресурсов параллельно
|
||||
var (albums, artists, tracks) = await (
|
||||
client.Api.Album.GetAlbumsAsync(ids),
|
||||
client.Api.Artist.GetArtistsAsync(ids),
|
||||
client.Api.Track.GetTracksAsync(ids)
|
||||
).AsAsync();
|
||||
```
|
||||
|
||||
### 2. Обработка больших списков
|
||||
|
||||
```csharp
|
||||
// Пагинация для больших результатов
|
||||
var allPlaylists = new List<YPlaylist>();
|
||||
int page = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var playlists = await client.Api.Playlist.GetPlaylistsAsync(page: page);
|
||||
if (playlists?.Playlists?.Count == 0) break;
|
||||
|
||||
allPlaylists.AddRange(playlists.Playlists ?? []);
|
||||
page++;
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Кеширование результатов
|
||||
|
||||
```csharp
|
||||
private readonly Dictionary<string, YTrack?> _trackCache = new();
|
||||
|
||||
public async Task<YTrack?> GetTrackAsync(string id)
|
||||
{
|
||||
if (_trackCache.TryGetValue(id, out var cached))
|
||||
return cached;
|
||||
|
||||
var track = await client.Api.Track.GetTrackAsync(id);
|
||||
_trackCache[id] = track;
|
||||
return track;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**Следующий шаг:** Прочитайте [ARCHITECTURE.md](ARCHITECTURE.md) для понимания внутреннего устройства.
|
||||
|
||||
**Нужна помощь?** Посмотрите [README.md](README.md) или создайте issue.
|
||||
|
||||
Удачи в разработке! 🚀
|
||||
437
README.md
Normal file
437
README.md
Normal file
@@ -0,0 +1,437 @@
|
||||
# 🎵 YandexMusic - Complete Solution
|
||||
|
||||
Полнофункциональное решение для работы с неофициальным API Яндекс Музыки на базе .NET 10. Содержит библиотеку API, оборачиватель клиента и CLI приложение.
|
||||
|
||||
## 📋 Содержание
|
||||
|
||||
- [Описание](#описание)
|
||||
- [Структура решения](#структура-решения)
|
||||
- [Требования](#требования)
|
||||
- [Установка](#установка)
|
||||
- [Быстрый старт](#быстрый-старт)
|
||||
- [Проекты](#проекты)
|
||||
- [Примеры использования](#примеры-использования)
|
||||
- [Архитектура](#архитектура)
|
||||
- [Лицензия](#лицензия)
|
||||
|
||||
## 📖 Описание
|
||||
|
||||
**YandexMusic** — это комплексное решение для взаимодействия с API Яндекс Музыки на платформе .NET 10. Решение состоит из двух ключевых компонентов:
|
||||
|
||||
1. **YandexMusic.API** — низкоуровневая библиотека для прямого взаимодействия с API
|
||||
2. **YandexMusic** — удобный оборачиватель (wrapper) с клиентом `YandexMusicClient`
|
||||
|
||||
### 🎯 Основные возможности
|
||||
|
||||
- ✅ **Полная асинхронная архитектура** — Async/await на всех уровнях
|
||||
- ✅ **Типобезопасный код** — Nullable reference types, full type safety
|
||||
- ✅ **Модульная структура** — Легко расширяемые компоненты
|
||||
- ✅ **Современный стек** — .NET 10, C# 12, System.Text.Json
|
||||
- ✅ **Полная документация на русском** — XML docs для всех публичных членов
|
||||
- ✅ **WebSocket поддержка** — Протокол Ynison для real-time синхронизации
|
||||
- ✅ **Гибкая конфигурация** — Поддержка прокси, cookies, custom headers
|
||||
- ✅ **Отладка встроена** — Debug settings для логирования и анализа
|
||||
|
||||
## 🏗️ Структура решения
|
||||
|
||||
```
|
||||
YandexMusic/
|
||||
├── YandexMusic.API/ # Низкоуровневая библиотека API
|
||||
│ ├── API/ # Классы для разных веток API
|
||||
│ ├── Models/ # Модели данных
|
||||
│ ├── Requests/ # Построители запросов
|
||||
│ ├── Common/ # Вспомогательные компоненты
|
||||
│ ├── Extensions/ # Методы расширения
|
||||
│ ├── YandexMusicApi.cs # Главный класс API
|
||||
│ ├── YandexMusic.API.csproj
|
||||
│ └── README.md # Документация по API
|
||||
│
|
||||
├── YandexMusic/ # Оборачиватель и клиент
|
||||
│ ├── YandexMusicClient.cs # Основной класс клиента
|
||||
│ ├── YandexMusic.csproj
|
||||
│ └── YandexMusicClient.cs # Реализация клиента
|
||||
│
|
||||
└── README.md # Этот файл
|
||||
```
|
||||
|
||||
### Зависимости между проектами
|
||||
|
||||
```
|
||||
YandexMusic (Client wrapper)
|
||||
↓
|
||||
YandexMusic.API (Core library)
|
||||
```
|
||||
|
||||
## 📦 Требования
|
||||
|
||||
- **.NET 10** или выше
|
||||
- **C# 12** или выше
|
||||
- **Visual Studio 2026** (рекомендуется) или Visual Studio Code
|
||||
|
||||
### Системные требования
|
||||
|
||||
- Windows 10/11, Linux, macOS (любая ОС с .NET 10)
|
||||
- Минимум 512 MB RAM
|
||||
- Интернет соединение для работы с API
|
||||
|
||||
## 🚀 Установка
|
||||
|
||||
### Клонирование репозитория
|
||||
|
||||
```bash
|
||||
git clone https://git.frigat.duckdns.org/FrigaT/YandexMusic.git
|
||||
cd YandexMusic
|
||||
```
|
||||
|
||||
### Восстановление зависимостей
|
||||
|
||||
```bash
|
||||
dotnet restore
|
||||
```
|
||||
|
||||
### Сборка решения
|
||||
|
||||
```bash
|
||||
dotnet build
|
||||
```
|
||||
|
||||
### Запуск тестов (если есть)
|
||||
|
||||
```bash
|
||||
dotnet test
|
||||
```
|
||||
|
||||
## ⚡ Быстрый старт
|
||||
|
||||
### 1. Как клиент (рекомендуется для большинства случаев)
|
||||
|
||||
```csharp
|
||||
using YandexMusic;
|
||||
|
||||
// Создание клиента
|
||||
var client = new YandexMusicClient();
|
||||
|
||||
// Получение информации о треке
|
||||
var track = await client.Api.Track.GetTrackAsync("trackId123");
|
||||
Console.WriteLine($"Трек: {track?.Title}");
|
||||
|
||||
// Поиск музыки
|
||||
var results = await client.Api.Search.SearchAsync("Ленинград");
|
||||
Console.WriteLine($"Найдено результатов: {results?.Tracks?.Results?.Count}");
|
||||
|
||||
// Работа с плейлистами
|
||||
var playlists = await client.Api.Playlist.GetPlaylistAsync("playlistId");
|
||||
Console.WriteLine($"Плейлист: {playlists?.Title}");
|
||||
```
|
||||
|
||||
### 2. Низкоуровневой API (расширенная работа)
|
||||
|
||||
```csharp
|
||||
using YandexMusic.API;
|
||||
|
||||
// Создание API
|
||||
var api = new YandexMusicApi();
|
||||
|
||||
// Прямая работа с API
|
||||
var track = await api.Track.GetTrackAsync("trackId");
|
||||
```
|
||||
|
||||
## 📚 Проекты
|
||||
|
||||
### YandexMusic.API
|
||||
|
||||
Низкоуровневая библиотека, предоставляющая полный доступ к API Яндекс Музыки.
|
||||
|
||||
**Основные компоненты:**
|
||||
|
||||
- `YandexMusicApi` — главный класс, содержит все API классы
|
||||
- `AuthStorage` — управление авторизацией и cookies
|
||||
- `IRequestProvider` — интерфейс для обработки HTTP запросов
|
||||
- `YCommonAPI` — базовый класс для всех API веток
|
||||
|
||||
**API Классы:**
|
||||
|
||||
```csharp
|
||||
public class YandexMusicApi
|
||||
{
|
||||
public YAlbumAPI Album { get; } // Альбомы
|
||||
public YArtistAPI Artist { get; } // Исполнители
|
||||
public YLabelAPI Label { get; } // Лейблы
|
||||
public YLandingAPI Landing { get; } // Рекомендации
|
||||
public YLibraryAPI Library { get; } // Библиотека
|
||||
public YPlaylistAPI Playlist { get; } // Плейлисты
|
||||
public YPinsAPI Pins { get; } // Закреплённые
|
||||
public YRadioAPI Radio { get; } // Радио
|
||||
public YSearchAPI Search { get; } // Поиск
|
||||
public YTrackAPI Track { get; } // Треки
|
||||
public YQueueAPI Queue { get; } // Очередь
|
||||
public YUserAPI User { get; } // Пользователь
|
||||
public YUgcAPI UserGeneratedContent { get; } // UGC
|
||||
public YYnisonAPI Ynison { get; } // WebSocket
|
||||
}
|
||||
```
|
||||
|
||||
**Особенности:**
|
||||
|
||||
- 300+ моделей данных для полного покрытия API
|
||||
- Асинхронные методы для всех операций
|
||||
- Поддержка WebSocket (Ynison)
|
||||
- Встроенная обработка ошибок
|
||||
- System.Text.Json для сериализации
|
||||
|
||||
**Документация:** [YandexMusic.API/README.md](YandexMusic.API/README.md)
|
||||
|
||||
### YandexMusic
|
||||
|
||||
Удобный оборачиватель (wrapper) над низкоуровневой библиотекой с клиентом `YandexMusicClient`.
|
||||
|
||||
**Основной класс:**
|
||||
|
||||
```csharp
|
||||
public class YandexMusicClient : IDisposable
|
||||
{
|
||||
// Свойства
|
||||
public AuthStorage AuthStorage { get; }
|
||||
public YAccount Account { get; }
|
||||
public bool IsAuthorized { get; }
|
||||
public YnisonPlayer? Ynison { get; }
|
||||
public HttpClient HttpClient { get; }
|
||||
public YandexMusicApi Api { get; }
|
||||
|
||||
// Методы авторизации
|
||||
public Task AuthorizeAsync(string login, string password);
|
||||
public Task AuthorizeByTokenAsync(string token);
|
||||
}
|
||||
```
|
||||
|
||||
**Возможности:**
|
||||
|
||||
- Интеграция с собственным HttpClient
|
||||
- Управление cookies и прокси
|
||||
- Встроенная авторизация
|
||||
- WebSocket плеер Ynison
|
||||
- Удобное API через свойство `Api`
|
||||
|
||||
**Использование:**
|
||||
|
||||
```csharp
|
||||
// С пользовательскими настройками
|
||||
var client = new YandexMusicClient(
|
||||
cookieContainer: new CookieContainer(),
|
||||
proxy: new WebProxy("http://proxy:8080"),
|
||||
timeout: TimeSpan.FromSeconds(30),
|
||||
userAgent: "Custom Agent"
|
||||
);
|
||||
|
||||
// Авторизация
|
||||
await client.AuthorizeAsync("login@gmail.com", "password");
|
||||
|
||||
// Использование
|
||||
var playlists = await client.Api.Playlist.GetPlaylistAsync("123");
|
||||
```
|
||||
|
||||
## 💡 Примеры использования
|
||||
|
||||
### Пример 1: Поиск и получение информации
|
||||
|
||||
```csharp
|
||||
using YandexMusic;
|
||||
|
||||
var client = new YandexMusicClient();
|
||||
|
||||
// Поиск треков
|
||||
var search = await client.Api.Search.SearchAsync("The Beatles");
|
||||
var track = search?.Tracks?.Results?.FirstOrDefault();
|
||||
|
||||
if (track != null)
|
||||
{
|
||||
Console.WriteLine($"🎵 {track.Title}");
|
||||
Console.WriteLine($"👤 {string.Join(", ", track.Artists?.Select(a => a.Title) ?? [])}");
|
||||
Console.WriteLine($"⏱️ {track.DurationMs / 1000} сек");
|
||||
}
|
||||
```
|
||||
|
||||
### Пример 2: Работа с плейлистами
|
||||
|
||||
```csharp
|
||||
// Получение плейлиста
|
||||
var playlist = await client.Api.Playlist.GetPlaylistAsync("playlistId");
|
||||
Console.WriteLine($"Плейлист: {playlist?.Title}");
|
||||
Console.WriteLine($"Треков: {playlist?.Tracks?.Count}");
|
||||
|
||||
// Вывод треков
|
||||
foreach (var track in playlist?.Tracks ?? [])
|
||||
{
|
||||
Console.WriteLine($" - {track.Title}");
|
||||
}
|
||||
```
|
||||
|
||||
### Пример 3: Работа с альбомами
|
||||
|
||||
```csharp
|
||||
// Получение альбома
|
||||
var album = await client.Api.Album.GetAlbumAsync("albumId");
|
||||
|
||||
Console.WriteLine($"Альбом: {album?.Title}");
|
||||
Console.WriteLine($"Исполнитель: {album?.Artists?.FirstOrDefault()?.Title}");
|
||||
Console.WriteLine($"Год: {album?.Year}");
|
||||
Console.WriteLine($"Жанр: {album?.Genre}");
|
||||
|
||||
// Вывод треков в альбоме
|
||||
foreach (var track in album?.Tracks ?? [])
|
||||
{
|
||||
Console.WriteLine($" {track.Position}. {track.Title}");
|
||||
}
|
||||
```
|
||||
|
||||
### Пример 4: Авторизация
|
||||
|
||||
```csharp
|
||||
var client = new YandexMusicClient();
|
||||
|
||||
try
|
||||
{
|
||||
// Авторизация через логин и пароль
|
||||
await client.AuthorizeAsync("your-email@gmail.com", "your-password");
|
||||
|
||||
Console.WriteLine($"✅ Авторизирован: {client.Account.User?.DisplayName}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"❌ Ошибка авторизации: {ex.Message}");
|
||||
}
|
||||
```
|
||||
|
||||
### Пример 5: WebSocket Ynison
|
||||
|
||||
```csharp
|
||||
// Подключение к Ynison (если авторизирован)
|
||||
var player = client.Ynison;
|
||||
|
||||
if (player != null)
|
||||
{
|
||||
await player.ConnectAsync();
|
||||
Console.WriteLine("✅ Подключено к Ynison");
|
||||
|
||||
// Использование плеера...
|
||||
|
||||
await player.DisconnectAsync();
|
||||
}
|
||||
```
|
||||
|
||||
## 🏛️ Архитектура
|
||||
|
||||
### Слои
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ YandexMusic (Client Wrapper) │ ← Удобный клиент
|
||||
├─────────────────────────────────────┤
|
||||
│ YandexMusic.API (Core Library) │ ← Низкоуровневой API
|
||||
├─────────────────────────────────────┤
|
||||
│ HttpClient, System.Text.Json │ ← .NET Framework
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Ключевые компоненты YandexMusic.API
|
||||
|
||||
```
|
||||
YandexMusicApi (Main Entry Point)
|
||||
├── API Classes (YAlbumAPI, YTrackAPI, etc.)
|
||||
│ └── Requests (YGetAlbumBuilder, YSearchBuilder, etc.)
|
||||
│
|
||||
├── Models (YAlbum, YTrack, YPlaylist, etc.)
|
||||
│ └── Common Models (YBaseModel, YResponse, etc.)
|
||||
│
|
||||
├── AuthStorage (Authorization Management)
|
||||
│ └── IRequestProvider (HTTP Request Handling)
|
||||
│ ├── DefaultRequestProvider
|
||||
│ ├── CommonRequestProvider
|
||||
│ └── MockRequestProvider
|
||||
│
|
||||
└── Extensions & Utilities
|
||||
├── HttpRequestHeaderExtensions
|
||||
├── StringExtensions
|
||||
├── Encryptor (для шифрования)
|
||||
└── DataDownloader
|
||||
```
|
||||
|
||||
### Обработка запросов
|
||||
|
||||
```
|
||||
Request Builder (YRequestBuilder<T>)
|
||||
↓
|
||||
HttpRequestMessage
|
||||
↓
|
||||
IRequestProvider.GetWebResponseAsync()
|
||||
↓
|
||||
HttpResponseMessage
|
||||
↓
|
||||
System.Text.Json Deserialization
|
||||
↓
|
||||
Model<T>
|
||||
```
|
||||
|
||||
## 🔧 Конфигурация
|
||||
|
||||
### Настройка HttpClient
|
||||
|
||||
```csharp
|
||||
var client = new YandexMusicClient(
|
||||
cookieContainer: new CookieContainer(),
|
||||
proxy: new WebProxy("http://127.0.0.1:8080"),
|
||||
timeout: TimeSpan.FromSeconds(30),
|
||||
userAgent: "MyCustomAgent/1.0"
|
||||
);
|
||||
```
|
||||
|
||||
## 📊 Статистика проекта
|
||||
|
||||
| Метрика | Значение |
|
||||
|---------|----------|
|
||||
| Проектов | 3 |
|
||||
| Целевая платформа | .NET 10 |
|
||||
| Язык C# | 12 |
|
||||
| Основных API методов | 50+ |
|
||||
| Моделей данных | 300+ |
|
||||
| Документированных членов | 100% |
|
||||
| Асинхронных методов | 100% |
|
||||
|
||||
## 🔐 Безопасность
|
||||
|
||||
- ✅ Использование HTTPS для всех запросов
|
||||
- ✅ Поддержка прокси для безопасности
|
||||
- ✅ Встроенное шифрование для чувствительных данных
|
||||
- ✅ Управление cookies и сессиями
|
||||
- ✅ Валидация всех входных данных
|
||||
|
||||
**⚠️ Важно:** Это неофициальная библиотека. Используйте её на свой риск и соблюдайте Terms of Service Яндекс Музыки.
|
||||
|
||||
## 🚦 Статус проекта
|
||||
|
||||
- ✅ **Стабильный** — Основная функциональность работает
|
||||
- 🔄 **Активная разработка** — Регулярные обновления
|
||||
- 📝 **Документирован** — Полная документация на русском
|
||||
|
||||
## 📝 Лицензия
|
||||
|
||||
Это неофициальная библиотека для работы с API Яндекс Музыки.
|
||||
|
||||
**Дисклеймер:** Автор не несет ответственности за неправомерное использование данной библиотеки. Используйте её в соответствии с Terms of Service Яндекс Музыки.
|
||||
|
||||
## 👨💻 Автор
|
||||
|
||||
**FrigaT** - Разработчик
|
||||
|
||||
## 🤝 Поддержка
|
||||
|
||||
Для вопросов, багов и предложений:
|
||||
- 🐛 Issues: [https://git.frigat.duckdns.org/FrigaT/YandexMusic/issues]
|
||||
- 💬 Discussions: [https://git.frigat.duckdns.org/FrigaT/YandexMusic/discussions]
|
||||
|
||||
## 📚 Дополнительная информация
|
||||
|
||||
- [YandexMusic.API/README.md](YandexMusic.API/README.md) — Документация по низкоуровневому API
|
||||
- [Официальный сайт Яндекс Музыки](https://music.yandex.ru)
|
||||
- [.NET 10 Documentation](https://learn.microsoft.com/dotnet/)
|
||||
- [C# 12 Features](https://learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-12)
|
||||
@@ -1,6 +1,4 @@
|
||||
using YandexMusic.API.Common;
|
||||
using YandexMusic.API.Models.Album;
|
||||
using YandexMusic.API.Models.Common;
|
||||
using YandexMusic.API.Requests.Album;
|
||||
|
||||
namespace YandexMusic.API;
|
||||
@@ -8,21 +6,13 @@ namespace YandexMusic.API;
|
||||
/// <summary>API для работы с альбомами.</summary>
|
||||
public class YAlbumAPI : YCommonAPI
|
||||
{
|
||||
/// <summary>Инициализирует новый экземпляр API альбомов.</summary>
|
||||
/// <param name="yandex">Экземпляр основного API.</param>
|
||||
public YAlbumAPI(YandexMusicApi yandex) : base(yandex) { }
|
||||
public YAlbumAPI(YandexMusicApi api) : base(api) { }
|
||||
|
||||
/// <summary>Получает альбом по идентификатору.</summary>
|
||||
/// <param name="storage">Хранилище данных авторизации.</param>
|
||||
/// <param name="albumId">Идентификатор альбома.</param>
|
||||
/// <returns>Ответ API с моделью альбома.</returns>
|
||||
public Task<YResponse<YAlbum>> GetAsync(AuthStorage storage, string albumId)
|
||||
=> new YGetAlbumBuilder(api, storage).Build(albumId).GetResponseAsync();
|
||||
public Task<YAlbum?> GetAsync(string albumId)
|
||||
=> new YGetAlbumBuilder(Api).ExecuteAsync(albumId);
|
||||
|
||||
/// <summary>Получает несколько альбомов по списку идентификаторов.</summary>
|
||||
/// <param name="storage">Хранилище данных авторизации.</param>
|
||||
/// <param name="albumIds">Список идентификаторов альбомов.</param>
|
||||
/// <returns>Ответ API со списком альбомов.</returns>
|
||||
public Task<YResponse<List<YAlbum>>> GetAsync(AuthStorage storage, IEnumerable<string> albumIds)
|
||||
=> new YGetAlbumsBuilder(api, storage).Build(albumIds).GetResponseAsync();
|
||||
public Task<List<YAlbum>?> GetAsync(IEnumerable<string> albumIds)
|
||||
=> new YGetAlbumsBuilder(Api).ExecuteAsync(albumIds);
|
||||
}
|
||||
@@ -1,71 +1,27 @@
|
||||
using YandexMusic.API.Common;
|
||||
using YandexMusic.API.Models.Artist;
|
||||
using YandexMusic.API.Models.Common;
|
||||
using YandexMusic.API.Requests.Artist;
|
||||
|
||||
namespace YandexMusic.API;
|
||||
|
||||
/// <summary>
|
||||
/// API для взаимодействия с исполнителями
|
||||
/// </summary>
|
||||
/// <summary>API для работы с исполнителями.</summary>
|
||||
public class YArtistAPI : YCommonAPI
|
||||
{
|
||||
public YArtistAPI(YandexMusicApi yandex) : base(yandex)
|
||||
public YArtistAPI(YandexMusicApi api) : base(api) { }
|
||||
|
||||
public Task<YArtistBriefInfo?> GetAsync(string artistId)
|
||||
=> new YGetArtistBuilder(Api).ExecuteAsync(artistId);
|
||||
|
||||
public Task<List<YArtist>?> GetAsync(IEnumerable<string> artistIds)
|
||||
=> new YGetArtistsBuilder(Api).ExecuteAsync(artistIds);
|
||||
|
||||
public Task<YTracksPage?> GetTracksAsync(string artistId, int page = 0, int pageSize = 20)
|
||||
=> new YGetArtistTrackBuilder(Api).ExecuteAsync((artistId, page, pageSize));
|
||||
|
||||
public async Task<YTracksPage?> GetAllTracksAsync(string artistId)
|
||||
{
|
||||
var info = await GetAsync(artistId);
|
||||
if (info?.Artist?.Counts?.Tracks == null)
|
||||
return null;
|
||||
return await GetTracksAsync(artistId, pageSize: info.Artist.Counts.Tracks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получение исполнителя
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="artistId">Идентификатор</param>
|
||||
public Task<YResponse<YArtistBriefInfo>> GetAsync(AuthStorage storage, string artistId)
|
||||
{
|
||||
return new YGetArtistBuilder(api, storage)
|
||||
.Build(artistId)
|
||||
.GetResponseAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получение исполнителей
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="artistIds">Идентификаторы</param>
|
||||
public Task<YResponse<List<YArtist>>> GetAsync(AuthStorage storage, IEnumerable<string> artistIds)
|
||||
{
|
||||
return new YGetArtistsBuilder(api, storage)
|
||||
.Build(artistIds)
|
||||
.GetResponseAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получение треков исполнителя с пагинацией
|
||||
/// <remarks>
|
||||
/// Треки поставляются по <paramref name="pageSize"/> штук на страницу,
|
||||
/// для получения всех треков необходимо использовать метод <see cref="GetAllTracksAsync"/>
|
||||
/// </remarks>
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="artistId">Идентификатор исполнителя</param>
|
||||
/// <param name="page">Страница ответов</param>
|
||||
/// <param name="pageSize">Количество треков на странице ответов</param>
|
||||
public Task<YResponse<YTracksPage>> GetTracksAsync(AuthStorage storage, string artistId, int page = 0, int pageSize = 20)
|
||||
{
|
||||
return new YGetArtistTrackBuilder(api, storage)
|
||||
.Build((artistId, page, pageSize))
|
||||
.GetResponseAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Получение всех треков исполнителя
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="artistId">Идентификатор исполнителя</param>
|
||||
public async Task<YResponse<YTracksPage>> GetAllTracksAsync(AuthStorage storage, string artistId)
|
||||
{
|
||||
YResponse<YArtistBriefInfo> response = await GetAsync(storage, artistId);
|
||||
return await GetTracksAsync(storage, artistId, pageSize: response.Result.Artist.Counts.Tracks);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
111
YandexMusic.API/API/YAuthAPI.cs
Normal file
111
YandexMusic.API/API/YAuthAPI.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
using System.Security.Authentication;
|
||||
using YandexMusic.API.Models.Account;
|
||||
using YandexMusic.API.Requests.Account;
|
||||
|
||||
namespace YandexMusic.API;
|
||||
|
||||
/// <summary>API для работы с пользователем и авторизации.</summary>
|
||||
public class YAuthAPI : YCommonAPI
|
||||
{
|
||||
public YAuthAPI(YandexMusicApi api) : base(api) { }
|
||||
|
||||
/// <summary>Авторизация по готовому музыкальному токену (OAuth).</summary>
|
||||
public async Task AuthorizeAsync(string musicToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(musicToken))
|
||||
throw new ArgumentException("Токен не может быть пустым", nameof(musicToken));
|
||||
|
||||
Api.Storage.Token = musicToken;
|
||||
var authInfo = await new YGetAuthInfoBuilder(Api).ExecuteAsync(null!);
|
||||
if (authInfo?.Account?.Uid == null)
|
||||
throw new Exception("Пользователь не авторизован");
|
||||
|
||||
Api.Storage.SetAuthorized(authInfo.Account, musicToken);
|
||||
}
|
||||
|
||||
/// <summary>Авторизация по паспортному токену (полученному, например, после QR-входа).</summary>
|
||||
public async Task AuthorizeByPassportTokenAsync(string passportToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(passportToken))
|
||||
throw new ArgumentException("Паспортный токен не может быть пустым", nameof(passportToken));
|
||||
|
||||
var musicToken = await Api.Passport.GetMusicTokenByPassportTokenAsync(passportToken);
|
||||
if (musicToken?.AccessToken == null)
|
||||
throw new Exception("Не удалось обменять паспортный токен на музыкальный");
|
||||
|
||||
await AuthorizeAsync(musicToken.AccessToken);
|
||||
}
|
||||
|
||||
/// <summary>Получение информации о текущем аккаунте.</summary>
|
||||
public Task<YAccountResult?> GetUserAuthAsync()
|
||||
=> new YGetAuthInfoBuilder(Api).ExecuteAsync(null!);
|
||||
|
||||
/// <summary>Создание сессии авторизации (получение доступных методов входа).</summary>
|
||||
public async Task<YAuthTypes?> CreateAuthSessionAsync(string userName)
|
||||
{
|
||||
if (!await Api.Passport.GetCsrfTokenAsync()) // вместо InitSessionAsync
|
||||
throw new Exception("Не удалось инициализировать сессию");
|
||||
|
||||
var result = await new YGetAuthLoginUserBuilder(Api).ExecuteAsync((Api.Storage.AuthToken.CsfrToken, userName));
|
||||
if (result?.TrackId != null)
|
||||
Api.Storage.AuthToken.TrackId = result.TrackId;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Получение капчи.</summary>
|
||||
public Task<YAuthCaptcha?> GetCaptchaAsync()
|
||||
{
|
||||
if (Api.Storage.AuthToken == null)
|
||||
throw new AuthenticationException("Выполните CreateAuthSessionAsync перед использованием");
|
||||
return new YGetAuthCaptchaBuilder(Api).ExecuteAsync(null!);
|
||||
}
|
||||
|
||||
/// <summary>Авторизация по капче.</summary>
|
||||
public Task<YAuthBase?> AuthorizeByCaptchaAsync(string captchaValue)
|
||||
{
|
||||
if (Api.Storage.AuthToken == null)
|
||||
throw new AuthenticationException("Выполните CreateAuthSessionAsync перед использованием");
|
||||
return new YGetAuthLoginCaptchaBuilder(Api).ExecuteAsync(captchaValue);
|
||||
}
|
||||
|
||||
/// <summary>Отправить письмо для авторизации.</summary>
|
||||
public Task<YAuthLetter?> GetAuthLetterAsync()
|
||||
=> new YGetAuthLetterBuilder(Api).ExecuteAsync(null!);
|
||||
|
||||
/// <summary>Подтверждение входа по письму и получение музыкального токена.</summary>
|
||||
public async Task<bool> AuthorizeByLetterAsync()
|
||||
{
|
||||
var status = await new YGetAuthLoginLetterBuilder(Api).ExecuteAsync(null!);
|
||||
if (status?.Status != YAuthStatus.Ok || !status.MagicLinkConfirmed)
|
||||
throw new Exception("Письмо не подтверждено");
|
||||
|
||||
var musicToken = await Api.Passport.GetMusicTokenByCookiesAsync();
|
||||
if (musicToken?.AccessToken == null)
|
||||
throw new Exception("Не удалось получить музыкальный токен после подтверждения письма");
|
||||
|
||||
await AuthorizeAsync(musicToken.AccessToken);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Авторизация по паролю приложения Яндекс.</summary>
|
||||
public async Task<YAuthBase?> AuthorizeByAppPasswordAsync(string password)
|
||||
{
|
||||
if (Api.Storage.AuthToken == null)
|
||||
throw new AuthenticationException("Выполните CreateAuthSessionAsync перед использованием");
|
||||
|
||||
var result = await new YGetAuthAppPasswordBuilder(Api).ExecuteAsync(password);
|
||||
if (result?.Status != YAuthStatus.Ok)
|
||||
throw new AuthenticationException("Ошибка авторизации по паролю");
|
||||
|
||||
var musicToken = await Api.Passport.GetMusicTokenByCookiesAsync();
|
||||
if (musicToken?.AccessToken == null)
|
||||
throw new Exception("Не удалось получить музыкальный токен после ввода пароля");
|
||||
|
||||
await AuthorizeAsync(musicToken.AccessToken);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Получение информации о пользователе через логин.</summary>
|
||||
public Task<YLoginInfo?> GetLoginInfoAsync()
|
||||
=> new YGetLoginInfoBuilder(Api).ExecuteAsync(null!);
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
namespace YandexMusic.API;
|
||||
|
||||
/// <summary>Родительский класс для всех веток API.</summary>
|
||||
/// <summary>Базовый класс для всех веток API.</summary>
|
||||
public abstract class YCommonAPI
|
||||
{
|
||||
/// <summary>Основной экземпляр API.</summary>
|
||||
protected readonly YandexMusicApi api;
|
||||
protected YandexMusicApi Api { get; }
|
||||
|
||||
/// <summary>Инициализирует новый экземпляр.</summary>
|
||||
/// <param name="yandex">Экземпляр основного API.</param>
|
||||
protected YCommonAPI(YandexMusicApi yandex) => api = yandex;
|
||||
protected YCommonAPI(YandexMusicApi api) => Api = api;
|
||||
}
|
||||
@@ -1,39 +1,19 @@
|
||||
using YandexMusic.API.Common;
|
||||
using YandexMusic.API.Models.Common;
|
||||
using YandexMusic.API.Models.Label;
|
||||
using YandexMusic.API.Requests.Label;
|
||||
|
||||
namespace YandexMusic.API;
|
||||
|
||||
public partial class YLabelAPI : YCommonAPI
|
||||
/// <summary>API для работы с лейблами.</summary>
|
||||
public class YLabelAPI : YCommonAPI
|
||||
{
|
||||
public YLabelAPI(YandexMusicApi yandex) : base(yandex)
|
||||
{
|
||||
}
|
||||
public YLabelAPI(YandexMusicApi api) : base(api) { }
|
||||
|
||||
/// <summary>
|
||||
/// Постраничное получение альбомов лейбла
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="label">Лейбл</param>
|
||||
/// <param name="page">Страница</param>
|
||||
public Task<YResponse<YLabelAlbums>> GetAlbumsByLabelAsync(AuthStorage storage, YLabel label, int page)
|
||||
{
|
||||
return new YGetLabelAlbumsBuilder(api, storage)
|
||||
.Build((label, page))
|
||||
.GetResponseAsync();
|
||||
}
|
||||
/// <summary>Получает альбомы лейбла с пагинацией.</summary>
|
||||
public Task<YLabelAlbums?> GetAlbumsByLabelAsync(YLabel label, int page = 0)
|
||||
=> new YGetLabelAlbumsBuilder(Api).ExecuteAsync((label, page));
|
||||
|
||||
/// <summary>
|
||||
/// Постраничное получение артистов лейбла
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="label">Лейбл</param>
|
||||
/// <param name="page">Страница</param>
|
||||
public Task<YResponse<YLabelArtists>> GetArtistsByLabelAsync(AuthStorage storage, YLabel label, int page)
|
||||
{
|
||||
return new YGetLabelArtistsBuilder(api, storage)
|
||||
.Build((label, page))
|
||||
.GetResponseAsync();
|
||||
}
|
||||
/// <summary>Получает артистов лейбла с пагинацией.</summary>
|
||||
public Task<YLabelArtists?> GetArtistsByLabelAsync(YLabel label, int page = 0)
|
||||
=> new YGetLabelArtistsBuilder(Api).ExecuteAsync((label, page));
|
||||
}
|
||||
@@ -1,43 +1,21 @@
|
||||
using YandexMusic.API.Common;
|
||||
using YandexMusic.API.Models.Common;
|
||||
using YandexMusic.API.Models.Feed;
|
||||
using YandexMusic.API.Models.Feed;
|
||||
using YandexMusic.API.Models.Landing;
|
||||
using YandexMusic.API.Requests.Feed;
|
||||
using YandexMusic.API.Requests.Landing;
|
||||
|
||||
namespace YandexMusic.API;
|
||||
|
||||
/// <summary>API для взаимодействия с главной страницей (лендингом).</summary>
|
||||
/// <summary>API для работы с главной страницей (лендингом).</summary>
|
||||
public class YLandingAPI : YCommonAPI
|
||||
{
|
||||
/// <summary>Инициализирует новый экземпляр API лендинга.</summary>
|
||||
/// <param name="yandex">Экземпляр основного API.</param>
|
||||
public YLandingAPI(YandexMusicApi yandex) : base(yandex) { }
|
||||
public YLandingAPI(YandexMusicApi api) : base(api) { }
|
||||
|
||||
/// <summary>Получает персональные блоки лендинга.</summary>
|
||||
/// <param name="storage">Хранилище авторизации.</param>
|
||||
/// <param name="blocks">Типы запрашиваемых блоков.</param>
|
||||
/// <returns>Ответ API с лендингом.</returns>
|
||||
/// <exception cref="ArgumentNullException">Если массив blocks равен null.</exception>
|
||||
public Task<YResponse<YLanding>> GetAsync(AuthStorage storage, params YLandingBlockType[] blocks)
|
||||
{
|
||||
if (blocks == null)
|
||||
throw new ArgumentNullException(nameof(blocks), "Массив блоков не может быть null");
|
||||
public Task<YLanding?> GetAsync(params YLandingBlockType[] blocks)
|
||||
=> new YGetLandingBuilder(Api).ExecuteAsync(blocks);
|
||||
|
||||
return new YGetLandingBuilder(api, storage)
|
||||
.Build(blocks)
|
||||
.GetResponseAsync();
|
||||
}
|
||||
public Task<YFeed?> GetFeedAsync()
|
||||
=> new YGetFeedBuilder(Api).ExecuteAsync(null!);
|
||||
|
||||
/// <summary>Получает ленту событий (фид).</summary>
|
||||
/// <param name="storage">Хранилище авторизации.</param>
|
||||
/// <returns>Ответ API с лентой.</returns>
|
||||
public Task<YResponse<YFeed>> GetFeedAsync(AuthStorage storage)
|
||||
=> new YGetFeedBuilder(api, storage).Build(null!).GetResponseAsync();
|
||||
|
||||
/// <summary>Получает лендинг детского раздела.</summary>
|
||||
/// <param name="storage">Хранилище авторизации.</param>
|
||||
/// <returns>Ответ API с детским лендингом.</returns>
|
||||
public Task<YResponse<YChildrenLanding>> GetChildrenLandingAsync(AuthStorage storage)
|
||||
=> new YGetChildrenLandingBuilder(api, storage).Build(null!).GetResponseAsync();
|
||||
public Task<YChildrenLanding?> GetChildrenLandingAsync()
|
||||
=> new YGetChildrenLandingBuilder(Api).ExecuteAsync(null!);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
using YandexMusic.API.Common;
|
||||
using YandexMusic.API.Models.Album;
|
||||
using YandexMusic.API.Models.Artist;
|
||||
using YandexMusic.API.Models.Common;
|
||||
@@ -10,244 +9,82 @@ using YandexMusic.API.Requests.Library;
|
||||
|
||||
namespace YandexMusic.API;
|
||||
|
||||
/// <summary>
|
||||
/// API для взаимодействия с библиотекой
|
||||
/// </summary>
|
||||
public partial class YLibraryAPI : YCommonAPI
|
||||
/// <summary>API для работы с библиотекой (лайки, дизлайки, недавно прослушанное).</summary>
|
||||
public class YLibraryAPI : YCommonAPI
|
||||
{
|
||||
/// <summary>
|
||||
/// Получение секции библиотеки
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Тип объекта библиотеки</typeparam>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="section">Секция</param>
|
||||
/// <param name="type">Тип</param>
|
||||
/// <returns>Список объектов из секции</returns>
|
||||
private Task<YResponse<T>> GetLibrarySection<T>(AuthStorage storage, YLibrarySection section, YLibrarySectionType type = YLibrarySectionType.Likes)
|
||||
{
|
||||
return new YGetLibrarySectionBuilder<T>(api, storage)
|
||||
.Build((section, type))
|
||||
.GetResponseAsync();
|
||||
}
|
||||
|
||||
|
||||
|
||||
public YLibraryAPI(YandexMusicApi yandex) : base(yandex)
|
||||
{
|
||||
}
|
||||
public YLibraryAPI(YandexMusicApi api) : base(api) { }
|
||||
|
||||
#region Лайки
|
||||
|
||||
/// <summary>
|
||||
/// Получение лайкнутых треков
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<YLibraryTracks>> GetLikedTracksAsync(AuthStorage storage)
|
||||
{
|
||||
return GetLibrarySection<YLibraryTracks>(storage, YLibrarySection.Tracks);
|
||||
}
|
||||
public Task<YLibraryTracks?> GetLikedTracksAsync()
|
||||
=> new YGetLibrarySectionBuilder<YLibraryTracks>(Api).ExecuteAsync((YLibrarySection.Tracks, YLibrarySectionType.Likes));
|
||||
|
||||
/// <summary>
|
||||
/// Получение лайкнутых альбомов
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<List<YLibraryAlbum>>> GetLikedAlbumsAsync(AuthStorage storage)
|
||||
{
|
||||
return GetLibrarySection<List<YLibraryAlbum>>(storage, YLibrarySection.Albums);
|
||||
}
|
||||
public Task<List<YLibraryAlbum>?> GetLikedAlbumsAsync()
|
||||
=> new YGetLibrarySectionBuilder<List<YLibraryAlbum>>(Api).ExecuteAsync((YLibrarySection.Albums, YLibrarySectionType.Likes));
|
||||
|
||||
/// <summary>
|
||||
/// Получение лайкнутых исполнителей
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<List<YArtist>>> GetLikedArtistsAsync(AuthStorage storage)
|
||||
{
|
||||
return GetLibrarySection<List<YArtist>>(storage, YLibrarySection.Artists);
|
||||
}
|
||||
public Task<List<YArtist>?> GetLikedArtistsAsync()
|
||||
=> new YGetLibrarySectionBuilder<List<YArtist>>(Api).ExecuteAsync((YLibrarySection.Artists, YLibrarySectionType.Likes));
|
||||
|
||||
/// <summary>
|
||||
/// Получение лайкнутых плейлистов
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<List<YLibraryPlaylists>>> GetLikedPlaylistsAsync(AuthStorage storage)
|
||||
{
|
||||
return GetLibrarySection<List<YLibraryPlaylists>>(storage, YLibrarySection.Playlists);
|
||||
}
|
||||
public Task<List<YLibraryPlaylists>?> GetLikedPlaylistsAsync()
|
||||
=> new YGetLibrarySectionBuilder<List<YLibraryPlaylists>>(Api).ExecuteAsync((YLibrarySection.Playlists, YLibrarySectionType.Likes));
|
||||
|
||||
#endregion Лайки
|
||||
#endregion
|
||||
|
||||
#region Дизлайки
|
||||
|
||||
/// <summary>
|
||||
/// Получение дизлайкнутых треков
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<YLibraryTracks>> GetDislikedTracksAsync(AuthStorage storage)
|
||||
{
|
||||
return GetLibrarySection<YLibraryTracks>(storage, YLibrarySection.Tracks, YLibrarySectionType.Dislikes);
|
||||
}
|
||||
public Task<YLibraryTracks?> GetDislikedTracksAsync()
|
||||
=> new YGetLibrarySectionBuilder<YLibraryTracks>(Api).ExecuteAsync((YLibrarySection.Tracks, YLibrarySectionType.Dislikes));
|
||||
|
||||
/// <summary>
|
||||
/// Получение дизлайкнутых исполнителей
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<List<YArtist>>> GetDislikedArtistsAsync(AuthStorage storage)
|
||||
{
|
||||
return GetLibrarySection<List<YArtist>>(storage, YLibrarySection.Artists, YLibrarySectionType.Dislikes);
|
||||
}
|
||||
public Task<List<YArtist>?> GetDislikedArtistsAsync()
|
||||
=> new YGetLibrarySectionBuilder<List<YArtist>>(Api).ExecuteAsync((YLibrarySection.Artists, YLibrarySectionType.Dislikes));
|
||||
|
||||
#endregion Дизлайки
|
||||
#endregion
|
||||
|
||||
#region Добавление в списки лайков/дизлайков
|
||||
#region Добавление/удаление
|
||||
|
||||
/// <summary>
|
||||
/// Добавить трек в список лайкнутых
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="track">Трек</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<YPlaylist>> AddTrackLikeAsync(AuthStorage storage, YTrack track)
|
||||
{
|
||||
return new YLibraryAddBuilder<YPlaylist>(api, storage)
|
||||
.Build((track.GetKey().ToString(), YLibrarySection.Tracks, YLibrarySectionType.Likes))
|
||||
.GetResponseAsync();
|
||||
}
|
||||
public Task<int?> AddTrackLikeAsync(YTrack track)
|
||||
=> new YLibraryAddBuilder<YRevision>(Api).ExecuteAsync((track.Id, YLibrarySection.Tracks, YLibrarySectionType.Likes))
|
||||
.ContinueWith(t => t.Result?.Revision);
|
||||
|
||||
/// <summary>
|
||||
/// Удалить трек из списка лайкнутых
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="track">Трек</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<YRevision>> RemoveTrackLikeAsync(AuthStorage storage, YTrack track)
|
||||
{
|
||||
return new YLibraryRemoveBuilder<YRevision>(api, storage)
|
||||
.Build((track.GetKey().ToString(), YLibrarySection.Tracks, YLibrarySectionType.Likes))
|
||||
.GetResponseAsync();
|
||||
}
|
||||
public Task<int?> RemoveTrackLikeAsync(YTrack track)
|
||||
=> new YLibraryRemoveBuilder<YRevision>(Api).ExecuteAsync((track.Id, YLibrarySection.Tracks, YLibrarySectionType.Likes))
|
||||
.ContinueWith(t => t.Result?.Revision);
|
||||
|
||||
/// <summary>
|
||||
/// Добавить трек в список дизлайкнутых
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="track">Трек</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<YRevision>> AddTrackDislikeAsync(AuthStorage storage, YTrack track)
|
||||
{
|
||||
return new YLibraryAddBuilder<YRevision>(api, storage)
|
||||
.Build((track.GetKey().ToString(), YLibrarySection.Tracks, YLibrarySectionType.Dislikes))
|
||||
.GetResponseAsync();
|
||||
}
|
||||
public Task<int?> AddTrackDislikeAsync(YTrack track)
|
||||
=> new YLibraryAddBuilder<YRevision>(Api).ExecuteAsync((track.Id, YLibrarySection.Tracks, YLibrarySectionType.Dislikes))
|
||||
.ContinueWith(t => t.Result?.Revision);
|
||||
|
||||
/// <summary>
|
||||
/// Удалить трек из списка дизлайкнутых
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="track">Трек</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<YRevision>> RemoveTrackDislikeAsync(AuthStorage storage, YTrack track)
|
||||
{
|
||||
return new YLibraryRemoveBuilder<YRevision>(api, storage)
|
||||
.Build((track.GetKey().ToString(), YLibrarySection.Tracks, YLibrarySectionType.Dislikes))
|
||||
.GetResponseAsync();
|
||||
}
|
||||
public Task<int?> RemoveTrackDislikeAsync(YTrack track)
|
||||
=> new YLibraryRemoveBuilder<YRevision>(Api).ExecuteAsync((track.Id, YLibrarySection.Tracks, YLibrarySectionType.Dislikes))
|
||||
.ContinueWith(t => t.Result?.Revision);
|
||||
|
||||
/// <summary>
|
||||
/// Добавить альбом в список лайкнутых
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="album">Альбом</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<string>> AddAlbumLikeAsync(AuthStorage storage, YAlbum album)
|
||||
{
|
||||
return new YLibraryAddBuilder<string>(api, storage)
|
||||
.Build((album.Id, YLibrarySection.Albums, YLibrarySectionType.Likes))
|
||||
.GetResponseAsync();
|
||||
}
|
||||
public Task<string?> AddAlbumLikeAsync(YAlbum album)
|
||||
=> new YLibraryAddBuilder<string>(Api).ExecuteAsync((album.Id, YLibrarySection.Albums, YLibrarySectionType.Likes));
|
||||
|
||||
/// <summary>
|
||||
/// Удалить альбом из списка лайкнутых
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="album">Альбом</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<string>> RemoveAlbumLikeAsync(AuthStorage storage, YAlbum album)
|
||||
{
|
||||
return new YLibraryRemoveBuilder<string>(api, storage)
|
||||
.Build((album.Id, YLibrarySection.Albums, YLibrarySectionType.Likes))
|
||||
.GetResponseAsync();
|
||||
}
|
||||
public Task<string?> RemoveAlbumLikeAsync(YAlbum album)
|
||||
=> new YLibraryRemoveBuilder<string>(Api).ExecuteAsync((album.Id, YLibrarySection.Albums, YLibrarySectionType.Likes));
|
||||
|
||||
/// <summary>
|
||||
/// Добавить исполнителя в список лайкнутых
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="artist">Исполнитель</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<string>> AddArtistLikeAsync(AuthStorage storage, YArtist artist)
|
||||
{
|
||||
return new YLibraryAddBuilder<string>(api, storage)
|
||||
.Build((artist.Id, YLibrarySection.Artists, YLibrarySectionType.Likes))
|
||||
.GetResponseAsync();
|
||||
}
|
||||
public Task<string?> AddArtistLikeAsync(YArtist artist)
|
||||
=> new YLibraryAddBuilder<string>(Api).ExecuteAsync((artist.Id, YLibrarySection.Artists, YLibrarySectionType.Likes));
|
||||
|
||||
/// <summary>
|
||||
/// Удалить исполнителя из списка лайкнутых
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="artist">Исполнитель</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<string>> RemoveArtistLikeAsync(AuthStorage storage, YArtist artist)
|
||||
{
|
||||
return new YLibraryRemoveBuilder<string>(api, storage)
|
||||
.Build((artist.Id, YLibrarySection.Artists, YLibrarySectionType.Likes))
|
||||
.GetResponseAsync();
|
||||
}
|
||||
public Task<string?> RemoveArtistLikeAsync(YArtist artist)
|
||||
=> new YLibraryRemoveBuilder<string>(Api).ExecuteAsync((artist.Id, YLibrarySection.Artists, YLibrarySectionType.Likes));
|
||||
|
||||
/// <summary>
|
||||
/// Добавить плейлист в список лайкнутых
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="playlist">Плейлист</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<string>> AddPlaylistLikeAsync(AuthStorage storage, YPlaylist playlist)
|
||||
{
|
||||
return new YLibraryAddBuilder<string>(api, storage)
|
||||
.Build((playlist.GetKey().ToString(), YLibrarySection.Playlists, YLibrarySectionType.Likes))
|
||||
.GetResponseAsync();
|
||||
}
|
||||
public Task<string?> AddPlaylistLikeAsync(YPlaylist playlist)
|
||||
=> new YLibraryAddBuilder<string>(Api).ExecuteAsync((playlist.GetKey().ToString(), YLibrarySection.Playlists, YLibrarySectionType.Likes));
|
||||
|
||||
/// <summary>
|
||||
/// Удалить плейлист из списка лайкнутых
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="playlist">Плейлист</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<string>> RemovePlaylistLikeAsync(AuthStorage storage, YPlaylist playlist)
|
||||
{
|
||||
return new YLibraryRemoveBuilder<string>(api, storage)
|
||||
.Build((playlist.GetKey().ToString(), YLibrarySection.Playlists, YLibrarySectionType.Likes))
|
||||
.GetResponseAsync();
|
||||
}
|
||||
public Task<string?> RemovePlaylistLikeAsync(YPlaylist playlist)
|
||||
=> new YLibraryRemoveBuilder<string>(Api).ExecuteAsync((playlist.GetKey().ToString(), YLibrarySection.Playlists, YLibrarySectionType.Likes));
|
||||
|
||||
#endregion Добавление/удаление в списки лайков/дизлайков
|
||||
#endregion
|
||||
|
||||
#region Получение списка "Вы недавно слушали"
|
||||
|
||||
public Task<YResponse<YRecentlyListenedContext>> GetRecentlyListenedAsync(AuthStorage storage, IEnumerable<YPlayContextType> contextTypes, int trackCount, int contextCount)
|
||||
{
|
||||
return new YGetLibraryRecentlyListenedBuilder(api, storage)
|
||||
.Build((contextTypes, trackCount, contextCount))
|
||||
.GetResponseAsync();
|
||||
}
|
||||
|
||||
#endregion Получение списка "Вы недавно слушали"
|
||||
#region Недавно прослушанное
|
||||
|
||||
public Task<YRecentlyListenedContext?> GetRecentlyListenedAsync(
|
||||
IEnumerable<YPlayContextType> contextTypes,
|
||||
int trackCount = 50,
|
||||
int contextCount = 10)
|
||||
=> new YGetLibraryRecentlyListenedBuilder(Api).ExecuteAsync((contextTypes, trackCount, contextCount));
|
||||
|
||||
#endregion
|
||||
}
|
||||
230
YandexMusic.API/API/YPassportAPI.cs
Normal file
230
YandexMusic.API/API/YPassportAPI.cs
Normal file
@@ -0,0 +1,230 @@
|
||||
using System.Security.Authentication;
|
||||
using System.Text.RegularExpressions;
|
||||
using YandexMusic.API.Models.Account;
|
||||
using YandexMusic.API.Models.Passport;
|
||||
using YandexMusic.API.Requests.Account;
|
||||
using YandexMusic.API.Requests.Passport;
|
||||
|
||||
namespace YandexMusic.API;
|
||||
|
||||
/// <summary>API для работы с яндекс паспортом</summary>
|
||||
public class YPassportAPI : YCommonAPI
|
||||
{
|
||||
public YPassportAPI(YandexMusicApi api) : base(api) { }
|
||||
|
||||
public async Task<YAccessToken?> GetMusicTokenByCookiesAsync()
|
||||
{
|
||||
if (string.IsNullOrEmpty(Api.Storage.AuthToken.TrackId))
|
||||
{
|
||||
if (!await GetCsrfTokenAsync())
|
||||
throw new Exception("Не удалось инициализировать сессию");
|
||||
await CreateTrackAsync(); // ваш приватный метод создания track_id
|
||||
}
|
||||
return await new YGetAuthCookiesBuilder(Api).ExecuteAsync(null!);
|
||||
}
|
||||
|
||||
public async Task<YAccessToken?> GetMusicTokenByPassportTokenAsync(string passportToken)
|
||||
=> await GetAccessTokenAsync(passportToken);
|
||||
|
||||
public async Task<string?> GetAuthQRLinkAsync()
|
||||
{
|
||||
if (!await GetCsrfTokenAsync())
|
||||
throw new Exception("Не удалось инициализировать сессию");
|
||||
|
||||
await CreateTrackAsync();
|
||||
|
||||
return $"https://passport.yandex.ru/auth/magic/code/?track_id={Api.Storage.AuthToken.TrackId}";
|
||||
}
|
||||
|
||||
public async Task<YAuthQRStatus?> CheckQRStatusAsync()
|
||||
{
|
||||
if (Api.Storage.AuthToken == null)
|
||||
throw new Exception("Сессия не инициализирована");
|
||||
|
||||
var status = await new YGetQrStatus(Api).ExecuteAsync(null!);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(status?.TrackId))
|
||||
{
|
||||
Api.Storage.AuthToken.SessionTrackId = status.TrackId;
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
public async Task<YAuthQrSession?> AuthorizeByQRAsync()
|
||||
{
|
||||
if (Api.Storage.AuthToken == null)
|
||||
throw new Exception("Сессия не инициализирована");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Api.Storage.AuthToken.SessionTrackId))
|
||||
throw new Exception("Токен сессии не инициализирован");
|
||||
|
||||
var status = await new YGetAuthLoginQRBuilder(Api).ExecuteAsync(null!);
|
||||
if (status != null && status.DefaultUid != 0 && await LoginByCookiesAsync())
|
||||
return status;
|
||||
throw new AuthenticationException("Ошибка авторизации по QR");
|
||||
}
|
||||
|
||||
/// <summary>Многоступенчатая авторизация: начало (передача логина).</summary>
|
||||
public async Task<YMultistepStart?> MultistepStartAsync(string login)
|
||||
{
|
||||
if (Api.Storage.AuthToken == null)
|
||||
throw new Exception("Сессия не инициализирована. Вызовите GetAuthQRLinkAsync или CreateTrackAsync");
|
||||
return await new YMultistepStartBuilder(Api).ExecuteAsync(login);
|
||||
}
|
||||
|
||||
/// <summary>Многоступенчатая авторизация: ввод пароля.</summary>
|
||||
public async Task<YPassportUser?> MultistepPasswordAsync(string password)
|
||||
{
|
||||
if (Api.Storage.AuthToken == null)
|
||||
throw new Exception("Сессия не инициализирована");
|
||||
return await new YMultiStepPasswordBuilder(Api).ExecuteAsync(password);
|
||||
}
|
||||
|
||||
/// <summary>Авторизация с помощью RFC OTP.</summary>
|
||||
public async Task<YPassportUser?> RfcOtpPasswordAsync(string rfcOtp)
|
||||
{
|
||||
if (Api.Storage.AuthToken == null)
|
||||
throw new Exception("Сессия не инициализирована");
|
||||
return await new YRfcOtpBuilder(Api).ExecuteAsync(rfcOtp);
|
||||
}
|
||||
|
||||
/// <summary>Создание сессии пользователя.</summary>
|
||||
public async Task<YPassportSession?> CreateUserSessionAsync()
|
||||
{
|
||||
if (Api.Storage.AuthToken == null)
|
||||
throw new Exception("Сессия не инициализирована");
|
||||
return await new YGetSessionBuilder(Api).ExecuteAsync(null!);
|
||||
}
|
||||
|
||||
/// <summary>Проверка состояния сессии.</summary>
|
||||
public async Task<YPassportSessionStatus?> GetSessionStateAsync()
|
||||
{
|
||||
if (Api.Storage.AuthToken == null)
|
||||
throw new Exception("Сессия не инициализирована");
|
||||
return await new YCheckSessionBuilder(Api).ExecuteAsync(null!);
|
||||
}
|
||||
|
||||
/// <summary>Проверка номера телефона (валидация).</summary>
|
||||
public async Task<YValidatePhoneNumberResult?> ValidatePhoneNumberAsync(string phone)
|
||||
{
|
||||
if (Api.Storage.AuthToken == null)
|
||||
throw new Exception("Сессия не инициализирована");
|
||||
return await new YValidatePhoneNumberBuilder(Api).ExecuteAsync(phone);
|
||||
}
|
||||
|
||||
/// <summary>Проверка доступности номера.</summary>
|
||||
public async Task<YCheckAvailabilityResult?> CheckPhoneAvailabilityAsync(string phone)
|
||||
{
|
||||
if (Api.Storage.AuthToken == null)
|
||||
throw new Exception("Сессия не инициализирована");
|
||||
return await new YCheckPhoneAvailabilityBuilder(Api).ExecuteAsync(phone);
|
||||
}
|
||||
|
||||
/// <summary>Запрос на отправку push-уведомления.</summary>
|
||||
public async Task<YSendPushResult?> SuggestSendPushAsync(string phone)
|
||||
{
|
||||
if (Api.Storage.AuthToken == null)
|
||||
throw new Exception("Сессия не инициализирована");
|
||||
return await new YSendPushBuilder(Api).ExecuteAsync(phone);
|
||||
}
|
||||
|
||||
/// <summary>Проверка push-кода.</summary>
|
||||
public async Task CheckPushCodeAsync(string code)
|
||||
{
|
||||
if (Api.Storage.AuthToken == null)
|
||||
throw new Exception("Сессия не инициализирована");
|
||||
await new YCheckPushCodeBuilder(Api).ExecuteAsync(code);
|
||||
}
|
||||
|
||||
/// <summary>Проверка на "сквоттера" (захват номера).</summary>
|
||||
public async Task<YValidateSquatter?> ValidateSquatterAsync(string phone)
|
||||
{
|
||||
if (Api.Storage.AuthToken == null)
|
||||
throw new Exception("Сессия не инициализирована");
|
||||
return await new YValidateSquatterBuilder(Api).ExecuteAsync(phone);
|
||||
}
|
||||
|
||||
/// <summary>Получение списка аккаунтов по номеру телефона.</summary>
|
||||
public async Task<YSuggestByPhoneResult?> SuggestByPhoneAsync()
|
||||
{
|
||||
if (Api.Storage.AuthToken == null)
|
||||
throw new Exception("Сессия не инициализирована");
|
||||
return await new YSuggestByPhoneBuilder(Api).ExecuteAsync(null!);
|
||||
}
|
||||
|
||||
/// <summary>Вход по паролю (упрощённый, если не нужна многоступенчатость).</summary>
|
||||
public async Task<YPassportUser?> LoginByPasswordAsync(string password)
|
||||
{
|
||||
// Сначала запускаем мультистеп (если track_id уже есть)
|
||||
if (string.IsNullOrEmpty(Api.Storage.AuthToken.TrackId))
|
||||
throw new Exception("TrackId не найден. Вызовите MultistepStartAsync сначала.");
|
||||
return await MultistepPasswordAsync(password);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
private async Task CreateTrackAsync()
|
||||
{
|
||||
if (!await GetCsrfTokenAsync())
|
||||
throw new Exception("Невозможно инициализировать сессию входа.");
|
||||
|
||||
var track = await new YCreateTrackBuilder(Api).ExecuteAsync(null!);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(track?.TrackId) || string.IsNullOrWhiteSpace(track?.CsrfToken))
|
||||
throw new Exception("Не удалось создать трек паспорта.");
|
||||
|
||||
Api.Storage.AuthToken.TrackId = track.TrackId;
|
||||
Api.Storage.AuthToken.CsfrToken = track.CsrfToken;
|
||||
}
|
||||
|
||||
internal async Task<bool> GetCsrfTokenAsync()
|
||||
{
|
||||
using var response = await new YGetAuthMethodsBuilder(Api).ExecuteRawAsync(null!);
|
||||
if (response == null || !response.IsSuccessStatusCode)
|
||||
throw new HttpRequestException("Не удалось получить CSRF-токен");
|
||||
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
var csrfMatch = Regex.Match(content, @"window\.__CSRF__\s*=\s*""([^""]+)""");
|
||||
var processMatch = Regex.Match(content, @"'process_uuid'\s*:\s*'([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'");
|
||||
|
||||
if (!csrfMatch.Success || !processMatch.Success)
|
||||
return false;
|
||||
|
||||
Api.Storage.HeaderToken = new YAuthToken
|
||||
{
|
||||
CsfrToken = csrfMatch.Groups[1].Value,
|
||||
ProcessUuid = processMatch.Groups[1].Value
|
||||
};
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
internal async Task<YAccessToken?> GetAccessTokenAsync(string passportToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(passportToken))
|
||||
throw new Exception("Сессия не инициализована");
|
||||
|
||||
var token = await new YGetMusicTokenBuilder(Api).ExecuteAsync(passportToken);
|
||||
if (token?.AccessToken != null)
|
||||
Api.Storage.Token = token.AccessToken;
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
|
||||
internal async Task<bool> LoginByCookiesAsync()
|
||||
{
|
||||
if (Api.Storage.AuthToken == null)
|
||||
throw new AuthenticationException("Сессия входа не инициализирована");
|
||||
|
||||
var accessToken = await new YGetAuthCookiesBuilder(Api).ExecuteAsync(null!);
|
||||
if (accessToken == null || string.IsNullOrEmpty(accessToken.AccessToken))
|
||||
return false;
|
||||
|
||||
await GetAccessTokenAsync(accessToken.AccessToken);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,13 @@
|
||||
using YandexMusic.API.Common;
|
||||
using YandexMusic.API.Models.Common;
|
||||
using YandexMusic.API.Models.Pins;
|
||||
using YandexMusic.API.Requests.Pins;
|
||||
|
||||
namespace YandexMusic.API;
|
||||
|
||||
/// <summary>API для взаимодействия с закреплёнными объектами (пинами).</summary>
|
||||
/// <summary>API для работы с закреплёнными объектами (пинами).</summary>
|
||||
public class YPinsAPI : YCommonAPI
|
||||
{
|
||||
/// <summary>Инициализирует новый экземпляр API пинов.</summary>
|
||||
/// <param name="yandex">Экземпляр основного API.</param>
|
||||
public YPinsAPI(YandexMusicApi yandex) : base(yandex) { }
|
||||
public YPinsAPI(YandexMusicApi api) : base(api) { }
|
||||
|
||||
/// <summary>Получает список закреплённых объектов.</summary>
|
||||
/// <param name="storage">Хранилище авторизации.</param>
|
||||
/// <returns>Ответ API со списком пинов.</returns>
|
||||
public Task<YResponse<YPins>> GetAsync(AuthStorage storage)
|
||||
=> new YGetPinsBuilder(api, storage).Build(null!).GetResponseAsync();
|
||||
public Task<YPins?> GetAsync()
|
||||
=> new YGetPinsBuilder(Api).ExecuteAsync(null!);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
using YandexMusic.API.Common;
|
||||
using YandexMusic.API.Models.Common;
|
||||
using YandexMusic.API.Models.Landing;
|
||||
using YandexMusic.API.Models.Landing.Entity.Entities;
|
||||
using YandexMusic.API.Models.Playlist;
|
||||
@@ -8,142 +6,114 @@ using YandexMusic.API.Requests.Playlist;
|
||||
|
||||
namespace YandexMusic.API;
|
||||
|
||||
/// <summary>API для взаимодействия с плейлистами.</summary>
|
||||
/// <summary>API для работы с плейлистами.</summary>
|
||||
public class YPlaylistAPI : YCommonAPI
|
||||
{
|
||||
/// <summary>Инициализирует новый экземпляр API плейлистов.</summary>
|
||||
/// <param name="yandex">Экземпляр основного API.</param>
|
||||
public YPlaylistAPI(YandexMusicApi yandex) : base(yandex) { }
|
||||
public YPlaylistAPI(YandexMusicApi api) : base(api) { }
|
||||
|
||||
/// <summary>Получает список персональных плейлистов с главной страницы.</summary>
|
||||
/// <param name="storage">Хранилище авторизации.</param>
|
||||
/// <returns>Список ответов с плейлистами.</returns>
|
||||
public async Task<List<YResponse<YPlaylist>>> GetPersonalPlaylistsAsync(AuthStorage storage)
|
||||
public async Task<List<YPlaylist>> GetPersonalPlaylistsAsync()
|
||||
{
|
||||
var landing = await api.Landing.GetAsync(storage, YLandingBlockType.PersonalPlaylists);
|
||||
var block = landing.Result?.Blocks?.FirstOrDefault(b => b.Type == YLandingBlockType.PersonalPlaylists);
|
||||
var landing = await Api.Landing.GetAsync(YLandingBlockType.PersonalPlaylists);
|
||||
var block = landing?.Blocks?.FirstOrDefault(b => b.Type == YLandingBlockType.PersonalPlaylists);
|
||||
if (block?.Entities == null)
|
||||
return new List<YResponse<YPlaylist>>();
|
||||
return new List<YPlaylist>();
|
||||
|
||||
var tasks = block.Entities
|
||||
.OfType<YLandingEntityPersonalPlaylist>()
|
||||
.Select(e => api.Playlist.GetAsync(storage, e.Data?.Data));
|
||||
.Select(e => GetAsync(e.Data?.Data?.Owner?.Uid ?? Api.Storage.User.Uid, e.Data?.Data?.Kind ?? ""))
|
||||
.Where(t => t != null)
|
||||
.ToList();
|
||||
|
||||
return new List<YResponse<YPlaylist>>(await Task.WhenAll(tasks));
|
||||
var results = await Task.WhenAll(tasks);
|
||||
return results.Where(p => p != null).ToList()!;
|
||||
}
|
||||
|
||||
/// <summary>Получает избранные плейлисты.</summary>
|
||||
public Task<YResponse<List<YPlaylist>>> FavoritesAsync(AuthStorage storage)
|
||||
=> new YGetPlaylistFavoritesBuilder(api, storage).Build(null!).GetResponseAsync();
|
||||
public Task<YPlaylist?> GetAsync(string user, string kind)
|
||||
=> new YGetPlaylistBuilder(Api).ExecuteAsync((user, kind));
|
||||
|
||||
/// <summary>Получает плейлист дня.</summary>
|
||||
public Task<YResponse<YPlaylist>> OfTheDayAsync(AuthStorage storage)
|
||||
=> GetPersonalPlaylistAsync(storage, YGeneratedPlaylistType.PlaylistOfTheDay);
|
||||
public Task<YPlaylist?> GetAsync(string uuid)
|
||||
=> new YGetPlaylistByUuidBuilder(Api).ExecuteAsync(uuid);
|
||||
|
||||
/// <summary>Получает плейлист «Дежавю».</summary>
|
||||
public Task<YResponse<YPlaylist>> DejaVuAsync(AuthStorage storage)
|
||||
=> GetPersonalPlaylistAsync(storage, YGeneratedPlaylistType.NeverHeard);
|
||||
public Task<YPlaylist?> GetAsync(YPlaylist playlist)
|
||||
=> GetAsync(playlist.Owner.Uid, playlist.Kind);
|
||||
|
||||
/// <summary>Получает плейлист «Премьера».</summary>
|
||||
public Task<YResponse<YPlaylist>> PremiereAsync(AuthStorage storage)
|
||||
=> GetPersonalPlaylistAsync(storage, YGeneratedPlaylistType.RecentTracks);
|
||||
public Task<List<YPlaylist>?> GetAsync(IEnumerable<(string user, string kind)> ids)
|
||||
=> new YGetPlaylistsBuilder(Api).ExecuteAsync(ids);
|
||||
|
||||
/// <summary>Получает плейлист «Тайник».</summary>
|
||||
public Task<YResponse<YPlaylist>> MissedAsync(AuthStorage storage)
|
||||
=> GetPersonalPlaylistAsync(storage, YGeneratedPlaylistType.MissedLikes);
|
||||
public Task<List<YPlaylist>?> FavoritesAsync()
|
||||
=> new YGetPlaylistFavoritesBuilder(Api).ExecuteAsync(null!);
|
||||
|
||||
/// <summary>Получает плейлист «Кинопоиск».</summary>
|
||||
public Task<YResponse<YPlaylist>> KinopoiskAsync(AuthStorage storage)
|
||||
=> GetPersonalPlaylistAsync(storage, YGeneratedPlaylistType.Kinopoisk);
|
||||
public async Task<YPlaylist?> OfTheDayAsync()
|
||||
=> (await GetPersonalPlaylistsAsync()).FirstOrDefault(p => p?.GeneratedPlaylistType == YGeneratedPlaylistType.PlaylistOfTheDay.ToString());
|
||||
|
||||
private async Task<YResponse<YPlaylist>> GetPersonalPlaylistAsync(AuthStorage storage, YGeneratedPlaylistType type)
|
||||
{
|
||||
var list = await GetPersonalPlaylistsAsync(storage);
|
||||
return list.FirstOrDefault(e => string.Equals(e.Result?.GeneratedPlaylistType, type.ToString(), StringComparison.CurrentCultureIgnoreCase))
|
||||
?? throw new Exception($"Плейлист типа {type} не найден.");
|
||||
}
|
||||
public async Task<YPlaylist?> DejaVuAsync()
|
||||
=> (await GetPersonalPlaylistsAsync()).FirstOrDefault(p => p?.GeneratedPlaylistType == YGeneratedPlaylistType.NeverHeard.ToString());
|
||||
|
||||
/// <summary>Получает плейлист по идентификатору пользователя и типа.</summary>
|
||||
public Task<YResponse<YPlaylist>> GetAsync(AuthStorage storage, string user, string kind)
|
||||
=> new YGetPlaylistBuilder(api, storage).Build((user, kind)).GetResponseAsync();
|
||||
public async Task<YPlaylist?> PremiereAsync()
|
||||
=> (await GetPersonalPlaylistsAsync()).FirstOrDefault(p => p?.GeneratedPlaylistType == YGeneratedPlaylistType.RecentTracks.ToString());
|
||||
|
||||
/// <summary>Получает плейлист по UUID.</summary>
|
||||
public Task<YResponse<YPlaylist>> GetAsync(AuthStorage storage, string uuid)
|
||||
=> new YGetPlaylistByUuidBuilder(api, storage).Build(uuid).GetResponseAsync();
|
||||
public async Task<YPlaylist?> MissedAsync()
|
||||
=> (await GetPersonalPlaylistsAsync()).FirstOrDefault(p => p?.GeneratedPlaylistType == YGeneratedPlaylistType.MissedLikes.ToString());
|
||||
|
||||
/// <summary>Получает несколько плейлистов по списку пар (пользователь, тип).</summary>
|
||||
public Task<YResponse<List<YPlaylist>>> GetAsync(AuthStorage storage, IEnumerable<(string user, string kind)> ids)
|
||||
=> new YGetPlaylistsBuilder(api, storage).Build(ids).GetResponseAsync();
|
||||
public async Task<YPlaylist?> KinopoiskAsync()
|
||||
=> (await GetPersonalPlaylistsAsync()).FirstOrDefault(p => p?.GeneratedPlaylistType == YGeneratedPlaylistType.Kinopoisk.ToString());
|
||||
|
||||
/// <summary>Получает плейлист по объекту плейлиста (обновляет его треки).</summary>
|
||||
public Task<YResponse<YPlaylist>> GetAsync(AuthStorage storage, YPlaylist playlist)
|
||||
=> new YGetPlaylistBuilder(api, storage).Build((playlist.Owner.Uid, playlist.Kind)).GetResponseAsync();
|
||||
public Task<YPlaylist?> CreateAsync(string name)
|
||||
=> new YPlaylistCreateBuilder(Api).ExecuteAsync(name);
|
||||
|
||||
/// <summary>Создаёт новый плейлист с заданным именем.</summary>
|
||||
public Task<YResponse<YPlaylist>> CreateAsync(AuthStorage storage, string name)
|
||||
=> new YPlaylistCreateBuilder(api, storage).Build(name).GetResponseAsync();
|
||||
public Task<YPlaylist?> RenameAsync(string kind, string name)
|
||||
=> new YPlaylistRenameBuilder(Api).ExecuteAsync((kind, name));
|
||||
|
||||
/// <summary>Переименовывает плейлист.</summary>
|
||||
public Task<YResponse<YPlaylist>> RenameAsync(AuthStorage storage, string kind, string name)
|
||||
=> new YPlaylistRenameBuilder(api, storage).Build((kind, name)).GetResponseAsync();
|
||||
public Task<YPlaylist?> RenameAsync(YPlaylist playlist, string name)
|
||||
=> RenameAsync(playlist.Kind, name);
|
||||
|
||||
/// <summary>Переименовывает плейлист.</summary>
|
||||
public Task<YResponse<YPlaylist>> RenameAsync(AuthStorage storage, YPlaylist playlist, string name)
|
||||
=> RenameAsync(storage, playlist.Kind, name);
|
||||
|
||||
/// <summary>Удаляет плейлист.</summary>
|
||||
public async Task<bool> DeleteAsync(AuthStorage storage, string kind)
|
||||
public async Task<bool> DeleteAsync(string kind)
|
||||
{
|
||||
try
|
||||
{
|
||||
await new YPlaylistRemoveBuilder(api, storage).Build(kind).GetResponseAsync();
|
||||
await new YPlaylistRemoveBuilder(Api).ExecuteAsync(kind);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch
|
||||
{
|
||||
// Логирование ошибки можно добавить через ILogger
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Удаляет плейлист.</summary>
|
||||
public Task<bool> DeleteAsync(AuthStorage storage, YPlaylist playlist)
|
||||
=> DeleteAsync(storage, playlist.Kind);
|
||||
public Task<bool> DeleteAsync(YPlaylist playlist)
|
||||
=> DeleteAsync(playlist.Kind);
|
||||
|
||||
/// <summary>Добавляет треки в начало плейлиста.</summary>
|
||||
public async Task<YResponse<YPlaylist>> InsertTracksAsync(AuthStorage storage, YPlaylist playlist, IEnumerable<YTrack> tracks)
|
||||
public async Task<YPlaylist?> InsertTracksAsync(YPlaylist playlist, IEnumerable<YTrack> tracks)
|
||||
{
|
||||
var change = await ChangePlaylistAsync(storage, playlist, new List<YPlaylistChange>
|
||||
var change = await new YPlaylistChangeBuilder(Api).ExecuteAsync((playlist, new[]
|
||||
{
|
||||
new()
|
||||
new YPlaylistChange
|
||||
{
|
||||
Operation = YPlaylistChangeType.Insert,
|
||||
At = 0,
|
||||
Tracks = tracks.Select(t => t.GetKey())
|
||||
}
|
||||
});
|
||||
return await GetAsync(storage, change.Result);
|
||||
}));
|
||||
return change != null ? await GetAsync(change) : null;
|
||||
}
|
||||
|
||||
/// <summary>Удаляет треки из плейлиста.</summary>
|
||||
public Task<YResponse<YPlaylist>> DeleteTracksAsync(AuthStorage storage, YPlaylist playlist, IEnumerable<YTrack> tracks)
|
||||
public async Task<YPlaylist?> DeleteTracksAsync(YPlaylist playlist, IEnumerable<YTrack> tracks)
|
||||
{
|
||||
var distinctTracks = tracks.Distinct().ToList();
|
||||
var changes = distinctTracks
|
||||
var indices = distinctTracks
|
||||
.Select(t => playlist.Tracks?.FindIndex(ct => ct.Track?.GetKey() == t.GetKey()) ?? -1)
|
||||
.Where(i => i != -1)
|
||||
.Select(i => new YPlaylistChange
|
||||
{
|
||||
Operation = YPlaylistChangeType.Delete,
|
||||
From = i,
|
||||
To = i + 1,
|
||||
Tracks = new List<YTrackAlbumPair> { playlist.Tracks![i].Track!.GetKey() }
|
||||
})
|
||||
.ToList();
|
||||
|
||||
return ChangePlaylistAsync(storage, playlist, changes);
|
||||
}
|
||||
var changes = indices.Select(i => new YPlaylistChange
|
||||
{
|
||||
Operation = YPlaylistChangeType.Delete,
|
||||
From = i,
|
||||
To = i + 1,
|
||||
Tracks = new[] { playlist.Tracks![i].Track!.GetKey() }
|
||||
});
|
||||
|
||||
private Task<YResponse<YPlaylist>> ChangePlaylistAsync(AuthStorage storage, YPlaylist playlist, IEnumerable<YPlaylistChange> changes)
|
||||
=> new YPlaylistChangeBuilder(api, storage).Build((playlist, changes)).GetResponseAsync();
|
||||
var change = await new YPlaylistChangeBuilder(Api).ExecuteAsync((playlist, changes));
|
||||
return change != null ? await GetAsync(change) : null;
|
||||
}
|
||||
}
|
||||
@@ -1,72 +1,22 @@
|
||||
using YandexMusic.API.Common;
|
||||
using YandexMusic.API.Models.Common;
|
||||
using YandexMusic.API.Models.Queue;
|
||||
using YandexMusic.API.Requests.Queue;
|
||||
|
||||
namespace YandexMusic.API;
|
||||
|
||||
/// <summary>
|
||||
/// API для взаимодействия с очередями
|
||||
/// </summary>
|
||||
public partial class YQueueAPI : YCommonAPI
|
||||
/// <summary>API для работы с очередями воспроизведения.</summary>
|
||||
public class YQueueAPI : YCommonAPI
|
||||
{
|
||||
public YQueueAPI(YandexMusicApi yandex) : base(yandex)
|
||||
{
|
||||
}
|
||||
public YQueueAPI(YandexMusicApi api) : base(api) { }
|
||||
|
||||
/// <summary>
|
||||
/// Получение всех очередей треков с разных устройств для синхронизации между ними
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="device">Устройство</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<YQueueItemsContainer>> ListAsync(AuthStorage storage, string? device = null)
|
||||
{
|
||||
return new YQueuesListBuilder(api, storage)
|
||||
.Build(device)
|
||||
.GetResponseAsync();
|
||||
}
|
||||
public Task<YQueueItemsContainer?> ListAsync(string? device = null)
|
||||
=> new YQueuesListBuilder(Api, device).ExecuteAsync(null!);
|
||||
|
||||
/// <summary>
|
||||
/// Получение очереди
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="queueId">Идентификатор очереди</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<YQueue>> GetAsync(AuthStorage storage, string queueId)
|
||||
{
|
||||
return new YGetQueueBuilder(api, storage)
|
||||
.Build(queueId)
|
||||
.GetResponseAsync();
|
||||
}
|
||||
public Task<YQueue?> GetAsync(string queueId)
|
||||
=> new YGetQueueBuilder(Api).ExecuteAsync(queueId);
|
||||
|
||||
/// <summary>
|
||||
/// Создание новой очереди треков
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="queue">Очередь треков</param>
|
||||
/// <param name="device">Устройство</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<YNewQueue>> CreateAsync(AuthStorage storage, YQueue queue, string? device = null)
|
||||
{
|
||||
return new YQueueCreateBuilder(api, storage, device)
|
||||
.Build(queue)
|
||||
.GetResponseAsync();
|
||||
}
|
||||
public Task<YNewQueue?> CreateAsync(YQueue queue, string? device = null)
|
||||
=> new YQueueCreateBuilder(Api, device).ExecuteAsync(queue);
|
||||
|
||||
/// <summary>
|
||||
/// Установка текущего индекса проигрываемого трека в очереди треков
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="queueId">Идентификатор очереди</param>
|
||||
/// <param name="currentIndex">Текущий индекс</param>
|
||||
/// <param name="isInteractive">Флаг интерактивности</param>
|
||||
/// <param name="device">Устройство</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<YUpdatedQueue>> UpdatePositionAsync(AuthStorage storage, string queueId, int currentIndex, bool isInteractive, string device = null)
|
||||
{
|
||||
return new YQueueUpdatePositionBuilder(api, storage, device)
|
||||
.Build((queueId, currentIndex, isInteractive))
|
||||
.GetResponseAsync();
|
||||
}
|
||||
}
|
||||
public Task<YUpdatedQueue?> UpdatePositionAsync(string queueId, int currentIndex, bool isInteractive, string? device = null)
|
||||
=> new YQueueUpdatePositionBuilder(Api, device).ExecuteAsync((queueId, currentIndex, isInteractive));
|
||||
}
|
||||
@@ -1,113 +1,37 @@
|
||||
using YandexMusic.API.Common;
|
||||
using YandexMusic.API.Models.Common;
|
||||
using YandexMusic.API.Models.Radio;
|
||||
using YandexMusic.API.Models.Track;
|
||||
using YandexMusic.API.Requests.Radio;
|
||||
|
||||
namespace YandexMusic.API;
|
||||
|
||||
/// <summary>
|
||||
/// API для взаимодействия с радио
|
||||
/// </summary>
|
||||
public partial class YRadioAPI : YCommonAPI
|
||||
/// <summary>API для работы с радио.</summary>
|
||||
public class YRadioAPI : YCommonAPI
|
||||
{
|
||||
public YRadioAPI(YandexMusicApi yandex) : base(yandex)
|
||||
{
|
||||
}
|
||||
public YRadioAPI(YandexMusicApi api) : base(api) { }
|
||||
|
||||
/// <summary>
|
||||
/// Получение списка рекомендованных радиостанций
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<YStationsDashboard>> GetStationsDashboardAsync(AuthStorage storage)
|
||||
{
|
||||
return new YGetStationsDashboardBuilder(api, storage)
|
||||
.Build(null)
|
||||
.GetResponseAsync();
|
||||
}
|
||||
public Task<YStationsDashboard?> GetStationsDashboardAsync()
|
||||
=> new YGetStationsDashboardBuilder(Api).ExecuteAsync(null!);
|
||||
|
||||
/// <summary>
|
||||
/// Получение списка радиостанций
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<List<YStation>>> GetStationsAsync(AuthStorage storage)
|
||||
{
|
||||
return new YGetStationsBuilder(api, storage)
|
||||
.Build(null)
|
||||
.GetResponseAsync();
|
||||
}
|
||||
public Task<List<YStation>?> GetStationsAsync()
|
||||
=> new YGetStationsBuilder(Api).ExecuteAsync(null!);
|
||||
|
||||
/// <summary>
|
||||
/// Получение информации о радиостанции
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="type">Тип</param>
|
||||
/// <param name="tag">Тэг</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<List<YStation>>> GetStationAsync(AuthStorage storage, string type, string tag)
|
||||
{
|
||||
return new YGetStationBuilder(api, storage)
|
||||
.Build((type, tag))
|
||||
.GetResponseAsync();
|
||||
}
|
||||
public Task<List<YStation>?> GetStationAsync(string type, string tag)
|
||||
=> new YGetStationBuilder(Api).ExecuteAsync((type, tag));
|
||||
|
||||
/// <summary>
|
||||
/// Получение информации о радиостанции
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="id">Идентификатор станции</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<List<YStation>>> GetStationAsync(AuthStorage storage, YStationId id)
|
||||
{
|
||||
return GetStationAsync(storage, id.Type, id.Tag);
|
||||
}
|
||||
public Task<List<YStation>?> GetStationAsync(YStationId id)
|
||||
=> GetStationAsync(id.Type, id.Tag);
|
||||
|
||||
/// <summary>
|
||||
/// Получение последовательности треков радиостанции
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="station">Радиостанция</param>
|
||||
/// <param name="prevTrackId">Идентификатор предыдущего трека</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<YStationSequence>> GetStationTracksAsync(AuthStorage storage, YStation station, string prevTrackId = "")
|
||||
{
|
||||
return new YGetStationTracksBuilder(api, storage)
|
||||
.Build((station.Station, prevTrackId))
|
||||
.GetResponseAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Установка настроек подбора треков
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="station">Радиостанция</param>
|
||||
/// <param name="settings">Настройки</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<string>> SetStationSettings2Async(AuthStorage storage, YStation station, YStationSettings2 settings)
|
||||
{
|
||||
return new YSetSettings2Builder(api, storage)
|
||||
.Build((station.Station, settings))
|
||||
.GetResponseAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Отправка обратной связи на действия при прослушивании радио
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="station">Радиостанция</param>
|
||||
/// <param name="type">Тип обратной связи</param>
|
||||
/// <param name="track">Трек</param>
|
||||
/// <param name="batchId">Уникальный идентификатор партии треков. Возвращается при получении треков</param>
|
||||
/// <param name="totalPlayedSeconds">Сколько было проиграно секунд трека перед действием</param>
|
||||
/// <returns></returns>
|
||||
public Task<string> SendStationFeedBackAsync(AuthStorage storage, YStation station, YStationFeedbackType type, YTrack track = null, string batchId = "", double totalPlayedSeconds = 0)
|
||||
{
|
||||
return new YSetStationFeedbackBuilder(api, storage)
|
||||
.Build((type, station, track, batchId, totalPlayedSeconds))
|
||||
.GetResponseAsync();
|
||||
}
|
||||
public Task<YStationSequence?> GetStationTracksAsync(YStation station, string prevTrackId = "")
|
||||
=> new YGetStationTracksBuilder(Api).ExecuteAsync((station.Station, prevTrackId));
|
||||
|
||||
public Task<string?> SetStationSettings2Async(YStation station, YStationSettings2 settings)
|
||||
=> new YSetSettings2Builder(Api).ExecuteAsync((station.Station, settings));
|
||||
|
||||
public Task<string?> SendStationFeedbackAsync(
|
||||
YStation station,
|
||||
YStationFeedbackType type,
|
||||
YTrack? track = null,
|
||||
string batchId = "",
|
||||
double totalPlayedSeconds = 0)
|
||||
=> new YSetStationFeedbackBuilder(Api).ExecuteAsync((type, station, track, batchId, totalPlayedSeconds));
|
||||
}
|
||||
@@ -1,139 +1,38 @@
|
||||
using YandexMusic.API.Common;
|
||||
using YandexMusic.API.Models.Common;
|
||||
using YandexMusic.API.Models.Search;
|
||||
using YandexMusic.API.Requests.Search;
|
||||
|
||||
namespace YandexMusic.API;
|
||||
|
||||
/// <summary>
|
||||
/// API для поиска
|
||||
/// </summary>
|
||||
public partial class YSearchAPI : YCommonAPI
|
||||
/// <summary>API для поиска.</summary>
|
||||
public class YSearchAPI : YCommonAPI
|
||||
{
|
||||
public YSearchAPI(YandexMusicApi api) : base(api) { }
|
||||
|
||||
public YSearchAPI(YandexMusicApi yandex) : base(yandex)
|
||||
{
|
||||
}
|
||||
public Task<YSearch?> TrackAsync(string trackName, int page = 0, int pageSize = 20)
|
||||
=> SearchAsync(trackName, YSearchType.Track, page, pageSize);
|
||||
|
||||
/// <summary>
|
||||
/// Поиск по трекам
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="trackName">Имя трека</param>
|
||||
/// <param name="pageNumber">Номер страницы</param>
|
||||
/// <param name="pageSize">Размер страницы</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<YSearch>> TrackAsync(AuthStorage storage, string trackName, int pageNumber = 0, int pageSize = 20)
|
||||
{
|
||||
return SearchAsync(storage, trackName, YSearchType.Track, pageNumber, pageSize);
|
||||
}
|
||||
public Task<YSearch?> AlbumsAsync(string albumName, int page = 0, int pageSize = 20)
|
||||
=> SearchAsync(albumName, YSearchType.Album, page, pageSize);
|
||||
|
||||
/// <summary>
|
||||
/// Поиск по альбомам
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="albumName">Имя альбома</param>
|
||||
/// <param name="pageNumber">Номер страницы</param>
|
||||
/// <param name="pageSize">Размер страницы</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<YSearch>> AlbumsAsync(AuthStorage storage, string albumName, int pageNumber = 0, int pageSize = 20)
|
||||
{
|
||||
return SearchAsync(storage, albumName, YSearchType.Album, pageNumber, pageSize);
|
||||
}
|
||||
public Task<YSearch?> ArtistAsync(string artistName, int page = 0, int pageSize = 20)
|
||||
=> SearchAsync(artistName, YSearchType.Artist, page, pageSize);
|
||||
|
||||
/// <summary>
|
||||
/// Поиск по артисту
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="artistName">Имя артиста</param>
|
||||
/// <param name="pageNumber">Номер страницы</param>
|
||||
/// <param name="pageSize">Размер страницы</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<YSearch>> ArtistAsync(AuthStorage storage, string artistName, int pageNumber = 0, int pageSize = 20)
|
||||
{
|
||||
return SearchAsync(storage, artistName, YSearchType.Artist, pageNumber, pageSize);
|
||||
}
|
||||
public Task<YSearch?> PlaylistAsync(string playlistName, int page = 0, int pageSize = 20)
|
||||
=> SearchAsync(playlistName, YSearchType.Playlist, page, pageSize);
|
||||
|
||||
/// <summary>
|
||||
/// Поиск по плейлистам
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="playlistName">Имя плейлиста</param>
|
||||
/// <param name="pageNumber">Номер страницы</param>
|
||||
/// <param name="pageSize">Размер страницы</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<YSearch>> PlaylistAsync(AuthStorage storage, string playlistName, int pageNumber = 0, int pageSize = 20)
|
||||
{
|
||||
return SearchAsync(storage, playlistName, YSearchType.Playlist, pageNumber, pageSize);
|
||||
}
|
||||
public Task<YSearch?> PodcastEpisodeAsync(string podcastName, int page = 0, int pageSize = 20)
|
||||
=> SearchAsync(podcastName, YSearchType.PodcastEpisode, page, pageSize);
|
||||
|
||||
/// <summary>
|
||||
/// Поиск по плейлистам
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="podcastName">Имя подкаста</param>
|
||||
/// <param name="pageNumber">Номер страницы</param>
|
||||
/// <param name="pageSize">Размер страницы</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<YSearch>> PodcastEpisodeAsync(AuthStorage storage, string podcastName, int pageNumber = 0, int pageSize = 20)
|
||||
{
|
||||
return SearchAsync(storage, podcastName, YSearchType.PodcastEpisode, pageNumber, pageSize);
|
||||
}
|
||||
public Task<YSearch?> VideosAsync(string videoName, int page = 0, int pageSize = 20)
|
||||
=> SearchAsync(videoName, YSearchType.Video, page, pageSize);
|
||||
|
||||
/// <summary>
|
||||
/// Поиск по видео
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="videoName">Имя видео</param>
|
||||
/// <param name="pageNumber">Номер страницы</param>
|
||||
/// <param name="pageSize">Размер страницы</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<YSearch>> VideosAsync(AuthStorage storage, string videoName, int pageNumber = 0, int pageSize = 20)
|
||||
{
|
||||
return SearchAsync(storage, videoName, YSearchType.Video, pageNumber, pageSize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Поиск по пользователям
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="userName">Имя пользователя</param>
|
||||
/// <param name="pageNumber">Номер страницы</param>
|
||||
/// <param name="pageSize">Размер страницы</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<YSearch>> UsersAsync(AuthStorage storage, string userName, int pageNumber = 0, int pageSize = 20)
|
||||
{
|
||||
return SearchAsync(storage, userName, YSearchType.User, pageNumber, pageSize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Поиск
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="searchText">Поисковый запрос</param>
|
||||
/// <param name="searchType">Тип поиска</param>
|
||||
/// <param name="page">Страница</param>
|
||||
/// <param name="pageSize">Размер страницы</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<YSearch>> SearchAsync(AuthStorage storage, string searchText, YSearchType searchType, int page = 0, int pageSize = 20)
|
||||
{
|
||||
return new YSearchBuilder(api, storage)
|
||||
.Build((searchText, searchType, page, pageSize))
|
||||
.GetResponseAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Подсказка
|
||||
/// </summary>
|
||||
/// <param name="storage">Хранилище</param>
|
||||
/// <param name="searchText">Поисковый запрос</param>
|
||||
/// <returns></returns>
|
||||
public Task<YResponse<YSearchSuggest>> SuggestAsync(AuthStorage storage, string searchText)
|
||||
{
|
||||
return new YSearchSuggestBuilder(api, storage)
|
||||
.Build(searchText)
|
||||
.GetResponseAsync();
|
||||
}
|
||||
public Task<YSearch?> UsersAsync(string userName, int page = 0, int pageSize = 20)
|
||||
=> SearchAsync(userName, YSearchType.User, page, pageSize);
|
||||
|
||||
public Task<YSearch?> SearchAsync(string searchText, YSearchType searchType, int page = 0, int pageSize = 20)
|
||||
=> new YSearchBuilder(Api).ExecuteAsync((searchText, searchType, page, pageSize));
|
||||
|
||||
public Task<YSearchSuggest?> GetSearchSuggestionsAsync(string searchText)
|
||||
=> new YSearchSuggestBuilder(Api).ExecuteAsync(searchText);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user