Добавлен playlist shared

This commit is contained in:
FrigaT
2026-04-11 15:41:24 +03:00
parent 8444fc5f8e
commit ba9d97239e
84 changed files with 61796 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
<!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>

View File

@@ -0,0 +1,23 @@
@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>

View File

@@ -0,0 +1,98 @@
.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;
}

View File

@@ -0,0 +1,51 @@
@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;
}
}

View File

@@ -0,0 +1,108 @@
.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");
}

View File

@@ -0,0 +1,31 @@
<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>

View File

@@ -0,0 +1,157 @@
.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;
}
}

View File

@@ -0,0 +1,63 @@
// 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();
}
}

View File

@@ -0,0 +1,80 @@
@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;
}
}
}

View File

@@ -0,0 +1,7 @@
@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>

View File

@@ -0,0 +1,12 @@
@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);
}
}

View File

@@ -0,0 +1,57 @@
@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();
}
}
}

View File

@@ -0,0 +1,5 @@
@page "/not-found"
@layout MainLayout
<h3>Not Found</h3>
<p>Sorry, the content you are looking for does not exist.</p>

View File

@@ -0,0 +1,92 @@
@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);
}
}
}

View File

@@ -0,0 +1,82 @@
@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");
}
}
}

View File

@@ -0,0 +1,6 @@
<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>

View File

@@ -0,0 +1,11 @@
@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

View File

@@ -0,0 +1,109 @@
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);
}
}

View File

@@ -0,0 +1,93 @@
// 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
};
}
}

View File

@@ -0,0 +1,37 @@
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);
});
}
}

View File

@@ -0,0 +1,10 @@
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; }
}

View File

@@ -0,0 +1,14 @@
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;
}

View File

@@ -0,0 +1,23 @@
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; }
}

View File

@@ -0,0 +1,28 @@
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();
}

View File

@@ -0,0 +1,9 @@
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;

View File

@@ -0,0 +1,4 @@
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);

View File

@@ -0,0 +1,26 @@
<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>

79
PlaylistShared/Program.cs Normal file
View File

@@ -0,0 +1,79 @@
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();

View File

@@ -0,0 +1,23 @@
{
"$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"
}
}
}
}

View File

@@ -0,0 +1,14 @@
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);

View File

@@ -0,0 +1,173 @@
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);
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,24 @@
{
"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"
}
}

View File

@@ -0,0 +1,60 @@
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.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,26 @@
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

View File

@@ -0,0 +1,597 @@
/*!
* 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

View File

@@ -0,0 +1,594 @@
/*!
* 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

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

View File

@@ -57,6 +57,16 @@ public class AuthStorage
Provider = provider;
}
/// <summary>
/// Конструктор
/// </summary>
public AuthStorage()
{
User = new YAccount();
Context = new HttpContext();
Provider = new DefaultRequestProvider(this);
}
/// <summary>
/// Установка прокси для пользователия
/// </summary>

View File

@@ -1,3 +1,5 @@
<Solution>
<Project Path="PlaylistShared/PlaylistShared.csproj" Id="b0fd4b2b-53cf-4087-a83a-703e803f64c6" />
<Project Path="YandexMusic.API/YandexMusic.API.csproj" />
<Project Path="YandexMusic/YandexMusic.csproj" Id="044fcef4-86d2-4cc9-9f7e-a577c19ae5c3" />
</Solution>

View File

@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\YandexMusic.API\YandexMusic.API.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,517 @@
using YandexMusic.API;
using YandexMusic.API.Common;
using YandexMusic.API.Common.Ynison;
using YandexMusic.API.Models.Account;
using YandexMusic.API.Models.Album;
using YandexMusic.API.Models.Artist;
using YandexMusic.API.Models.Common;
using YandexMusic.API.Models.Feed;
using YandexMusic.API.Models.Landing;
using YandexMusic.API.Models.Landing.Entity.Entities.Context;
using YandexMusic.API.Models.Library;
using YandexMusic.API.Models.Playlist;
using YandexMusic.API.Models.Queue;
using YandexMusic.API.Models.Radio;
using YandexMusic.API.Models.Search;
using YandexMusic.API.Models.Track;
namespace YandexMusic;
/// <summary>Асинхронный клиент для работы с API Яндекс Музыки.</summary>
public class YandexMusicClient : IDisposable
{
private readonly YandexMusicApi _api;
private readonly AuthStorage _storage;
private YnisonPlayer? _player;
/// <summary>Информация об аккаунте (после авторизации).</summary>
public YAccount Account => _storage.User;
/// <summary>Флаг авторизации.</summary>
public bool IsAuthorized => _storage.IsAuthorized;
/// <summary>Плеер Ynison (WebSocket) для синхронизации воспроизведения.</summary>
public YnisonPlayer? Ynison => _player;
/// <summary>Создаёт новый экземпляр клиента Яндекс Музыки.</summary>
public YandexMusicClient()
{
_api = new YandexMusicApi();
_storage = new AuthStorage();
_player = _api.Ynison.GetPlayer(_storage);
}
#region Авторизация
/// <summary>
/// Авторизация по токену
/// </summary>
/// <param name="token">Токен авторизации</param>
/// <returns></returns>
public async Task<bool> Authorize(string token)
{
await _api.User.AuthorizeAsync(_storage, token);
return _storage.IsAuthorized;
}
/// <summary>
/// Создание сеанса и получение доступных методов авторизации
/// </summary>
/// <param name="userName">Имя пользователя</param>
/// <returns></returns>
public Task<YAuthTypes> CreateAuthSession(string userName)
{
return _api.User.CreateAuthSessionAsync(_storage, userName);
}
/// <summary>
/// Получение ссылки на QR-код
/// </summary>
/// <returns></returns>
public Task<string> GetAuthQRLink()
{
return _api.User.GetAuthQRLinkAsync(_storage);
}
/// <summary>
/// Авторизация по QR-коду
/// </summary>
/// <returns></returns>
public Task<YAuthQRStatus> AuthorizeByQR()
{
return _api.User.AuthorizeByQRAsync(_storage);
}
/// <summary>
/// Получение <see cref="YAuthCaptcha"/>
/// </summary>
/// <returns></returns>
public Task<YAuthCaptcha> GetCaptcha()
{
return _api.User.GetCaptchaAsync(_storage);
}
/// <summary>
/// Авторизация по captcha
/// </summary>
/// <param name="captcha">Значение captcha</param>
/// <returns></returns>
public Task<YAuthBase> AuthorizeByCaptcha(string captcha)
{
return _api.User.AuthorizeByCaptchaAsync(_storage, captcha);
}
/// <summary>
/// Получение письма авторизации на почту пользователя
/// </summary>
/// <returns></returns>
public Task<YAuthLetter> GetAuthLetter()
{
return _api.User.GetAuthLetterAsync(_storage);
}
/// <summary>
/// Авторизация после подтверждения входа через письмо
/// </summary>
/// <returns></returns>
public Task<bool> AuthorizeByLetter()
{
return _api.User.AuthorizeByLetterAsync(_storage);
}
/// <summary>
/// Авторизация с помощью пароля из приложения Яндекс
/// </summary>
/// <param name="password">Пароль</param>
/// <returns></returns>
public Task<YAuthBase> AuthorizeByAppPassword(string password)
{
return _api.User.AuthorizeByAppPasswordAsync(_storage, password);
}
/// <summary>
/// Получение <see cref="YAccessToken"/> после авторизации с помощью QR, e-mail, пароля из приложения
/// </summary>
public Task<YAccessToken> GetAccessToken()
{
return _api.User.GetAccessTokenAsync(_storage);
}
/// <summary>
/// Получение информации о пользователе через логин Яндекса
/// </summary>
public Task<YLoginInfo> GetLoginInfo()
{
return _api.User.GetLoginInfoAsync(_storage);
}
#endregion Авторизация
#region Треки
/// <summary>Получает трек по идентификатору.</summary>
public async Task<YTrack?> GetTrackAsync(string id)
{
var response = await _api.Track.GetAsync(_storage, id);
return response?.Result?.FirstOrDefault();
}
/// <summary>Получает список треков по идентификаторам.</summary>
public async Task<List<YTrack>> GetTracksAsync(IEnumerable<string> ids)
{
var response = await _api.Track.GetAsync(_storage, ids);
return response?.Result ?? new List<YTrack>();
}
#endregion
#region Альбомы
/// <summary>Получает альбом по идентификатору.</summary>
public async Task<YAlbum?> GetAlbumAsync(string id)
{
var response = await _api.Album.GetAsync(_storage, id);
return response?.Result;
}
/// <summary>Получает список альбомов по идентификаторам.</summary>
public async Task<List<YAlbum>> GetAlbumsAsync(IEnumerable<string> ids)
{
var response = await _api.Album.GetAsync(_storage, ids);
return response?.Result ?? new List<YAlbum>();
}
#endregion
#region Главная страница
/// <summary>Получает персональные блоки лендинга.</summary>
public async Task<YLanding?> GetLandingAsync(params YLandingBlockType[] blocks)
{
var response = await _api.Landing.GetAsync(_storage, blocks);
return response?.Result;
}
/// <summary>Получает ленту событий.</summary>
public async Task<YFeed?> GetFeedAsync()
{
var response = await _api.Landing.GetFeedAsync(_storage);
return response?.Result;
}
/// <summary>Получает детский лендинг.</summary>
public async Task<YChildrenLanding?> GetChildrenLandingAsync()
{
var response = await _api.Landing.GetChildrenLandingAsync(_storage);
return response?.Result;
}
#endregion
#region Исполнители
/// <summary>Получает информацию об исполнителе.</summary>
public async Task<YArtistBriefInfo?> GetArtistAsync(string id)
{
var response = await _api.Artist.GetAsync(_storage, id);
return response?.Result;
}
/// <summary>Получает список исполнителей.</summary>
public async Task<List<YArtist>> GetArtistsAsync(IEnumerable<string> ids)
{
var response = await _api.Artist.GetAsync(_storage, ids);
return response?.Result ?? new List<YArtist>();
}
#endregion
#region Плейлисты
/// <summary>Получает плейлист по пользователю и идентификатору.</summary>
public async Task<YPlaylist?> GetPlaylistAsync(string user, string id)
{
var response = await _api.Playlist.GetAsync(_storage, user, id);
return response?.Result;
}
/// <summary>Получает плейлист по UUID.</summary>
public async Task<YPlaylist?> GetPlaylistAsync(string uuid)
{
var response = await _api.Playlist.GetAsync(_storage, uuid);
return response?.Result;
}
/// <summary>Получает список плейлистов по списку пар (пользователь, идентификатор).</summary>
public async Task<List<YPlaylist>> GetPlaylistsAsync(IEnumerable<(string user, string id)> ids)
{
var response = await _api.Playlist.GetAsync(_storage, ids);
return response?.Result ?? new List<YPlaylist>();
}
/// <summary>Получает персональные плейлисты (с главной страницы).</summary>
public async Task<List<YPlaylist>> GetPersonalPlaylistsAsync()
{
var playlists = await _api.Playlist.GetPersonalPlaylistsAsync(_storage);
return playlists.Select(r => r.Result).Where(p => p != null).ToList()!;
}
/// <summary>Получает избранные плейлисты.</summary>
public async Task<List<YPlaylist>> GetFavoritesAsync()
{
var response = await _api.Playlist.FavoritesAsync(_storage);
return response?.Result ?? new List<YPlaylist>();
}
/// <summary>Получает плейлист «Дежавю».</summary>
public async Task<YPlaylist?> GetDejaVuAsync()
{
var response = await _api.Playlist.DejaVuAsync(_storage);
return response?.Result;
}
/// <summary>Получает плейлист «Тайник».</summary>
public async Task<YPlaylist?> GetMissedAsync()
{
var response = await _api.Playlist.MissedAsync(_storage);
return response?.Result;
}
/// <summary>Получает плейлист дня.</summary>
public async Task<YPlaylist?> GetOfTheDayAsync()
{
var response = await _api.Playlist.OfTheDayAsync(_storage);
return response?.Result;
}
/// <summary>Получает плейлист «Кинопоиск».</summary>
public async Task<YPlaylist?> GetKinopoiskAsync()
{
var response = await _api.Playlist.KinopoiskAsync(_storage);
return response?.Result;
}
/// <summary>Получает плейлист «Премьера».</summary>
public async Task<YPlaylist?> GetPremiereAsync()
{
var response = await _api.Playlist.PremiereAsync(_storage);
return response?.Result;
}
/// <summary>Создаёт новый плейлист с заданным именем.</summary>
public async Task<YPlaylist?> CreatePlaylistAsync(string name)
{
var response = await _api.Playlist.CreateAsync(_storage, name);
return response?.Result;
}
#endregion
#region Поиск
/// <summary>Выполняет поиск.</summary>
public async Task<YSearch?> SearchAsync(string searchText, YSearchType searchType, int page = 0, int pageSize = 20)
{
var response = await _api.Search.SearchAsync(_storage, searchText, searchType, page, pageSize);
return response?.Result;
}
/// <summary>Получает подсказки для поискового запроса.</summary>
public async Task<YSearchSuggest?> GetSearchSuggestionsAsync(string searchText)
{
var response = await _api.Search.SuggestAsync(_storage, searchText);
return response?.Result;
}
#endregion
#region Библиотека
/// <summary>Получает лайкнутые треки.</summary>
public async Task<List<YTrack>> GetLikedTracksAsync()
{
var likes = await _api.Library.GetLikedTracksAsync(_storage);
var ids = likes.Result?.Library.Tracks.Select(t => t.Id).ToArray() ?? Array.Empty<string>();
if (ids.Length == 0) return new List<YTrack>();
var response = await _api.Track.GetAsync(_storage, ids);
return response?.Result ?? new List<YTrack>();
}
/// <summary>Получает дизлайкнутые треки.</summary>
public async Task<List<YTrack>> GetDislikedTracksAsync()
{
var dislikes = await _api.Library.GetDislikedTracksAsync(_storage);
var ids = dislikes.Result?.Library.Tracks.Select(t => t.Id).ToArray() ?? Array.Empty<string>();
if (ids.Length == 0) return new List<YTrack>();
var response = await _api.Track.GetAsync(_storage, ids);
return response?.Result ?? new List<YTrack>();
}
/// <summary>Получает лайкнутые альбомы.</summary>
public async Task<List<YAlbum>> GetLikedAlbumsAsync()
{
var albums = await _api.Library.GetLikedAlbumsAsync(_storage);
var ids = albums.Result?.Select(a => a.Id).ToArray() ?? Array.Empty<string>();
if (ids.Length == 0) return new List<YAlbum>();
var response = await _api.Album.GetAsync(_storage, ids);
return response?.Result ?? new List<YAlbum>();
}
/// <summary>Получает лайкнутых исполнителей.</summary>
public async Task<List<YArtist>> GetLikedArtistsAsync()
{
var artists = await _api.Library.GetLikedArtistsAsync(_storage);
var ids = artists.Result?.Select(a => a.Id).ToArray() ?? Array.Empty<string>();
if (ids.Length == 0) return new List<YArtist>();
var response = await _api.Artist.GetAsync(_storage, ids);
return response?.Result ?? new List<YArtist>();
}
/// <summary>Получает дизлайкнутых исполнителей.</summary>
public async Task<List<YArtist>> GetDislikedArtistsAsync()
{
var artists = await _api.Library.GetDislikedArtistsAsync(_storage);
var ids = artists.Result?.Select(a => a.Id).ToArray() ?? Array.Empty<string>();
if (ids.Length == 0) return new List<YArtist>();
var response = await _api.Artist.GetAsync(_storage, ids);
return response?.Result ?? new List<YArtist>();
}
/// <summary>Получает лайкнутые плейлисты.</summary>
public async Task<List<YPlaylist>> GetLikedPlaylistsAsync()
{
var playlists = await _api.Library.GetLikedPlaylistsAsync(_storage);
var ids = playlists.Result?
.Select(p => (p.Playlist.Uid, p.Playlist.Kind))
.ToArray() ?? Array.Empty<(string, string)>();
if (ids.Length == 0) return new List<YPlaylist>();
var response = await _api.Playlist.GetAsync(_storage, ids);
return response?.Result ?? new List<YPlaylist>();
}
/// <summary>Получает список недавно прослушанных треков.</summary>
/// <param name="contextTypes">Типы контекстов (альбом, исполнитель, плейлист).</param>
/// <param name="trackCount">Количество треков на контекст.</param>
/// <param name="contextCount">Количество контекстов.</param>
public async Task<List<YRecentlyListened>> GetRecentlyListenedAsync(
IEnumerable<YPlayContextType> contextTypes,
int trackCount = 50,
int contextCount = 10)
{
var response = await _api.Library.GetRecentlyListenedAsync(_storage, contextTypes, trackCount, contextCount);
return response?.Result?.Contexts ?? new List<YRecentlyListened>();
}
#endregion
#region Радио
/// <summary>Получает список рекомендованных радиостанций.</summary>
public async Task<List<YStation>> GetRadioDashboardAsync()
{
var response = await _api.Radio.GetStationsDashboardAsync(_storage);
return response?.Result?.Stations ?? new List<YStation>();
}
/// <summary>Получает список всех радиостанций.</summary>
public async Task<List<YStation>> GetRadioStationsAsync()
{
var response = await _api.Radio.GetStationsAsync(_storage);
return response?.Result ?? new List<YStation>();
}
/// <summary>Получает информацию о радиостанции по идентификатору.</summary>
public async Task<YStation?> GetRadioStationAsync(YStationId id)
{
var response = await _api.Radio.GetStationAsync(_storage, id);
return response?.Result?.FirstOrDefault();
}
#endregion
#region Очереди
/// <summary>Получает все очереди с разных устройств.</summary>
public async Task<YQueueItemsContainer?> GetQueuesAsync(string? device = null)
{
var response = await _api.Queue.ListAsync(_storage, device);
return response?.Result;
}
/// <summary>Получает очередь по идентификатору.</summary>
public async Task<YQueue?> GetQueueAsync(string queueId)
{
var response = await _api.Queue.GetAsync(_storage, queueId);
return response?.Result;
}
/// <summary>Создаёт новую очередь.</summary>
public async Task<YNewQueue?> CreateQueueAsync(YQueue queue, string? device = null)
{
var response = await _api.Queue.CreateAsync(_storage, queue, device);
return response?.Result;
}
/// <summary>Обновляет позицию в очереди.</summary>
public async Task<YUpdatedQueue?> UpdateQueuePositionAsync(string queueId, int currentIndex, bool isInteractive, string? device = null)
{
var response = await _api.Queue.UpdatePositionAsync(_storage, queueId, currentIndex, isInteractive, device);
return response?.Result;
}
#endregion
#region Загрузка треков (UGC)
/// <summary>Загружает трек из файла в плейлист.</summary>
public async Task<string?> UploadTrackToPlaylistAsync(YPlaylist playlist, string fileName, string filePath)
{
var uploadLink = await _api.UserGeneratedContent.GetUgcUploadLinkAsync(_storage, playlist, fileName);
var response = await _api.UserGeneratedContent.UploadUgcTrackAsync(_storage, uploadLink.PostTarget, filePath);
return response?.Result;
}
/// <summary>Загружает трек из потока в плейлист.</summary>
public async Task<string?> UploadTrackToPlaylistAsync(YPlaylist playlist, string fileName, Stream stream)
{
var uploadLink = await _api.UserGeneratedContent.GetUgcUploadLinkAsync(_storage, playlist, fileName);
var response = await _api.UserGeneratedContent.UploadUgcTrackAsync(_storage, uploadLink.PostTarget, stream);
return response?.Result;
}
/// <summary>Загружает трек из массива байтов в плейлист.</summary>
public async Task<string?> UploadTrackToPlaylistAsync(YPlaylist playlist, string fileName, byte[] file)
{
var uploadLink = await _api.UserGeneratedContent.GetUgcUploadLinkAsync(_storage, playlist, fileName);
var response = await _api.UserGeneratedContent.UploadUgcTrackAsync(_storage, uploadLink.PostTarget, file);
return response?.Result;
}
#endregion
#region Лейблы
/// <summary>Получает альбомы лейбла с пагинацией.</summary>
public async Task<List<YAlbum>> GetAlbumsByLabelAsync(YLabel label, int page = 0)
{
var response = await _api.Label.GetAlbumsByLabelAsync(_storage, label, page);
return response?.Result?.Albums ?? new List<YAlbum>();
}
/// <summary>Получает артистов лейбла с пагинацией.</summary>
public async Task<List<YArtist>> GetArtistsByLabelAsync(YLabel label, int page = 0)
{
var response = await _api.Label.GetArtistsByLabelAsync(_storage, label, page);
return response?.Result?.Artists ?? new List<YArtist>();
}
#endregion
#region Ynison (WebSocket плеер)
/// <summary>Подключается к Ynison и запускает синхронизацию состояния плеера.</summary>
public async Task ConnectYnisonAsync()
{
if (_player == null)
_player = _api.Ynison.GetPlayer(_storage);
await _player.ConnectAsync();
}
/// <summary>Отключается от Ynison.</summary>
public async Task DisconnectYnisonAsync()
{
if (_player != null)
await _player.DisconnectAsync();
}
#endregion
#region IDisposable
private bool _disposed;
/// <summary>Освобождает ресурсы.</summary>
public void Dispose()
{
if (_disposed) return;
_player?.Dispose();
_disposed = true;
GC.SuppressFinalize(this);
}
#endregion
}