Compare commits
16 Commits
ba9d97239e
...
v0.0.10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
add7f08215 | ||
|
|
36e28ce3fe | ||
|
|
5541d0ad27 | ||
|
|
ea9f392896 | ||
|
|
6dcf39de56 | ||
|
|
50da85be57 | ||
|
|
699d38da74 | ||
|
|
1779ecaca9 | ||
|
|
8abc6c5074 | ||
|
|
b8f78a5856 | ||
|
|
21a0c5abe6 | ||
|
|
a40b36ef96 | ||
|
|
266aa2e181 | ||
|
|
3e5b9beaf1 | ||
|
|
6f01772bb7 | ||
|
|
dd338e3f57 |
51
.gitea/workflows/release-package.yaml
Normal file
51
.gitea/workflows/release-package.yaml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
pack-and-publish:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: 10.0.x
|
||||||
|
|
||||||
|
- name: Set version from tag
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
TAG="${GITHUB_REF_NAME#v}"
|
||||||
|
echo "PACKAGE_VERSION=$TAG" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Replace ProjectReference with PackageReference
|
||||||
|
run: |
|
||||||
|
sed -i "s#<ProjectReference Include=\"..\/YandexMusic.API\/YandexMusic.API.csproj\" />#<PackageReference Include=\"YandexMusic.API\" Version=\"${{ steps.version.outputs.PACKAGE_VERSION }}\" />#" YandexMusic/YandexMusic.csproj
|
||||||
|
|
||||||
|
- name: Build and Pack projects
|
||||||
|
run: |
|
||||||
|
mkdir -p artifacts
|
||||||
|
for proj in $PROJECTS; do
|
||||||
|
echo "Restoring $proj..."
|
||||||
|
dotnet restore $proj
|
||||||
|
echo "Building $proj..."
|
||||||
|
dotnet build $proj -c Release -p:Version=${{ steps.version.outputs.PACKAGE_VERSION }}
|
||||||
|
echo "Packing $proj..."
|
||||||
|
dotnet pack $proj -c Release --no-build -p:PackageVersion=${{ steps.version.outputs.PACKAGE_VERSION }} -o ./artifacts
|
||||||
|
done
|
||||||
|
env:
|
||||||
|
PROJECTS: |
|
||||||
|
YandexMusic.API
|
||||||
|
YandexMusic
|
||||||
|
|
||||||
|
- name: Publish to NuGet
|
||||||
|
env:
|
||||||
|
NUGET_API_KEY: ${{ secrets.NUGET_API_KEY }}
|
||||||
|
run: dotnet nuget push ./artifacts/*.nupkg --source https://git.frigat.duckdns.org/api/packages/FrigaT/nuget/index.json --api-key $NUGET_API_KEY --skip-duplicate
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -361,3 +361,4 @@ MigrationBackup/
|
|||||||
|
|
||||||
# Fody - auto-generated XML schema
|
# Fody - auto-generated XML schema
|
||||||
FodyWeavers.xsd
|
FodyWeavers.xsd
|
||||||
|
YaMusicCli/
|
||||||
|
|||||||
@@ -1,23 +0,0 @@
|
|||||||
<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
|
|
||||||
<head>
|
|
||||||
<meta charset="utf-8" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<base href="/" />
|
|
||||||
<ResourcePreloader />
|
|
||||||
<link rel="stylesheet" href="@Assets["lib/bootstrap/dist/css/bootstrap.min.css"]" />
|
|
||||||
<link rel="stylesheet" href="@Assets["app.css"]" />
|
|
||||||
<link rel="stylesheet" href="@Assets["PlaylistShared.styles.css"]" />
|
|
||||||
<ImportMap />
|
|
||||||
<link rel="icon" type="image/png" href="favicon.png" />
|
|
||||||
<HeadOutlet />
|
|
||||||
</head>
|
|
||||||
|
|
||||||
<body>
|
|
||||||
<Routes />
|
|
||||||
<ReconnectModal />
|
|
||||||
<script src="@Assets["_framework/blazor.web.js"]"></script>
|
|
||||||
</body>
|
|
||||||
|
|
||||||
</html>
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
@inherits LayoutComponentBase
|
|
||||||
|
|
||||||
<div class="page">
|
|
||||||
<div class="sidebar">
|
|
||||||
<NavMenu />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<main>
|
|
||||||
<div class="top-row px-4">
|
|
||||||
<a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<article class="content px-4">
|
|
||||||
@Body
|
|
||||||
</article>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="blazor-error-ui" data-nosnippet>
|
|
||||||
An unhandled error has occurred.
|
|
||||||
<a href="." class="reload">Reload</a>
|
|
||||||
<span class="dismiss">🗙</span>
|
|
||||||
</div>
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
.page {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
main {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
background-image: linear-gradient(180deg, rgb(5, 39, 103) 0%, #3a0647 70%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-row {
|
|
||||||
background-color: #f7f7f7;
|
|
||||||
border-bottom: 1px solid #d6d5d5;
|
|
||||||
justify-content: flex-end;
|
|
||||||
height: 3.5rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
|
||||||
white-space: nowrap;
|
|
||||||
margin-left: 1.5rem;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-row ::deep a:hover, .top-row ::deep .btn-link:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-row ::deep a:first-child {
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 640.98px) {
|
|
||||||
.top-row {
|
|
||||||
justify-content: space-between;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-row ::deep a, .top-row ::deep .btn-link {
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 641px) {
|
|
||||||
.page {
|
|
||||||
flex-direction: row;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
width: 250px;
|
|
||||||
height: 100vh;
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-row {
|
|
||||||
position: sticky;
|
|
||||||
top: 0;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-row.auth ::deep a:first-child {
|
|
||||||
flex: 1;
|
|
||||||
text-align: right;
|
|
||||||
width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-row, article {
|
|
||||||
padding-left: 2rem !important;
|
|
||||||
padding-right: 1.5rem !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#blazor-error-ui {
|
|
||||||
color-scheme: light only;
|
|
||||||
background: lightyellow;
|
|
||||||
bottom: 0;
|
|
||||||
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: none;
|
|
||||||
left: 0;
|
|
||||||
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
|
|
||||||
position: fixed;
|
|
||||||
width: 100%;
|
|
||||||
z-index: 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
#blazor-error-ui .dismiss {
|
|
||||||
cursor: pointer;
|
|
||||||
position: absolute;
|
|
||||||
right: 0.75rem;
|
|
||||||
top: 0.5rem;
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
@using Microsoft.AspNetCore.Components.Authorization
|
|
||||||
<div class="top-row ps-3 navbar navbar-dark">
|
|
||||||
<div class="container-fluid">
|
|
||||||
<a class="navbar-brand" href="">PlaylistShared</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<input type="checkbox" title="Navigation menu" class="navbar-toggler" />
|
|
||||||
|
|
||||||
<div class="nav-scrollable" onclick="document.querySelector('.navbar-toggler').click()">
|
|
||||||
<nav class="nav flex-column">
|
|
||||||
<div class="nav-item px-3">
|
|
||||||
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
|
|
||||||
<span class="bi bi-house-door-fill-nav-menu" aria-hidden="true"></span> Главная
|
|
||||||
</NavLink>
|
|
||||||
</div>
|
|
||||||
<div class="nav-item px-3">
|
|
||||||
<NavLink class="nav-link" href="myplaylists">
|
|
||||||
<span class="bi bi-list-nested-nav-menu" aria-hidden="true"></span> Мои плейлисты
|
|
||||||
</NavLink>
|
|
||||||
</div>
|
|
||||||
@if (user?.Identity?.IsAuthenticated == true)
|
|
||||||
{
|
|
||||||
<div class="nav-item px-3">
|
|
||||||
<NavLink class="nav-link" href="Logout">
|
|
||||||
<span class="bi bi-box-arrow-right"></span> Выйти
|
|
||||||
</NavLink>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<div class="nav-item px-3">
|
|
||||||
<NavLink class="nav-link" href="Login">
|
|
||||||
<span class="bi bi-box-arrow-in-right"></span> Войти
|
|
||||||
</NavLink>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private AuthenticationState? _authState;
|
|
||||||
private ClaimsPrincipal? user => _authState?.User;
|
|
||||||
[CascadingParameter] private Task<AuthenticationState>? AuthenticationStateTask { get; set; }
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
if (AuthenticationStateTask is not null)
|
|
||||||
_authState = await AuthenticationStateTask;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
.navbar-toggler {
|
|
||||||
appearance: none;
|
|
||||||
cursor: pointer;
|
|
||||||
width: 3.5rem;
|
|
||||||
height: 2.5rem;
|
|
||||||
color: white;
|
|
||||||
position: absolute;
|
|
||||||
top: 0.5rem;
|
|
||||||
right: 1rem;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
||||||
background: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e") no-repeat center/1.75rem rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-toggler:checked {
|
|
||||||
background-color: rgba(255, 255, 255, 0.5);
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-row {
|
|
||||||
min-height: 3.5rem;
|
|
||||||
background-color: rgba(0,0,0,0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-brand {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bi {
|
|
||||||
display: inline-block;
|
|
||||||
position: relative;
|
|
||||||
width: 1.25rem;
|
|
||||||
height: 1.25rem;
|
|
||||||
margin-right: 0.75rem;
|
|
||||||
top: -1px;
|
|
||||||
background-size: cover;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bi-house-door-fill-nav-menu {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-house-door-fill' viewBox='0 0 16 16'%3E%3Cpath d='M6.5 14.5v-3.505c0-.245.25-.495.5-.495h2c.25 0 .5.25.5.5v3.5a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L13 5.793V2.5a.5.5 0 0 0-.5-.5h-1a.5.5 0 0 0-.5.5v1.293L8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4a.5.5 0 0 0 .5-.5Z'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
.bi-plus-square-fill-nav-menu {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-plus-square-fill' viewBox='0 0 16 16'%3E%3Cpath d='M2 0a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V2a2 2 0 0 0-2-2H2zm6.5 4.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3a.5.5 0 0 1 1 0z'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
.bi-list-nested-nav-menu {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' class='bi bi-list-nested' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M4.5 11.5A.5.5 0 0 1 5 11h10a.5.5 0 0 1 0 1H5a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 3 7h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm-2-4A.5.5 0 0 1 1 3h10a.5.5 0 0 1 0 1H1a.5.5 0 0 1-.5-.5z'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item:first-of-type {
|
|
||||||
padding-top: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item:last-of-type {
|
|
||||||
padding-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item ::deep .nav-link {
|
|
||||||
color: #d7d7d7;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
border-radius: 4px;
|
|
||||||
height: 3rem;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
line-height: 3rem;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item ::deep a.active {
|
|
||||||
background-color: rgba(255,255,255,0.37);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-item ::deep .nav-link:hover {
|
|
||||||
background-color: rgba(255,255,255,0.1);
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-scrollable {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.navbar-toggler:checked ~ .nav-scrollable {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 641px) {
|
|
||||||
.navbar-toggler {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.nav-scrollable {
|
|
||||||
/* Never collapse the sidebar for wide screens */
|
|
||||||
display: block;
|
|
||||||
|
|
||||||
/* Allow sidebar to scroll for tall menus */
|
|
||||||
height: calc(100vh - 3.5rem);
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.bi-box-arrow-right, .bi-box-arrow-in-right {
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' fill='white' viewBox='0 0 16 16'%3E%3Cpath fill-rule='evenodd' d='M10 12.5a.5.5 0 0 1-.5.5h-8a.5.5 0 0 1-.5-.5v-9a.5.5 0 0 1 .5-.5h8a.5.5 0 0 1 .5.5v2a.5.5 0 0 0 1 0v-2A1.5 1.5 0 0 0 9.5 2h-8A1.5 1.5 0 0 0 0 3.5v9A1.5 1.5 0 0 0 1.5 14h8a1.5 1.5 0 0 0 1.5-1.5v-2a.5.5 0 0 0-1 0v2z'/%3E%3Cpath fill-rule='evenodd' d='M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 0 0-.708.708L14.293 7.5H5.5a.5.5 0 0 0 0 1h8.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z'/%3E%3C/svg%3E");
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<script type="module" src="@Assets["Components/Layout/ReconnectModal.razor.js"]"></script>
|
|
||||||
|
|
||||||
<dialog id="components-reconnect-modal" data-nosnippet>
|
|
||||||
<div class="components-reconnect-container">
|
|
||||||
<div class="components-rejoining-animation" aria-hidden="true">
|
|
||||||
<div></div>
|
|
||||||
<div></div>
|
|
||||||
</div>
|
|
||||||
<p class="components-reconnect-first-attempt-visible">
|
|
||||||
Rejoining the server...
|
|
||||||
</p>
|
|
||||||
<p class="components-reconnect-repeated-attempt-visible">
|
|
||||||
Rejoin failed... trying again in <span id="components-seconds-to-next-attempt"></span> seconds.
|
|
||||||
</p>
|
|
||||||
<p class="components-reconnect-failed-visible">
|
|
||||||
Failed to rejoin.<br />Please retry or reload the page.
|
|
||||||
</p>
|
|
||||||
<button id="components-reconnect-button" class="components-reconnect-failed-visible">
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
<p class="components-pause-visible">
|
|
||||||
The session has been paused by the server.
|
|
||||||
</p>
|
|
||||||
<p class="components-resume-failed-visible">
|
|
||||||
Failed to resume the session.<br />Please retry or reload the page.
|
|
||||||
</p>
|
|
||||||
<button id="components-resume-button" class="components-pause-visible components-resume-failed-visible">
|
|
||||||
Resume
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</dialog>
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
.components-reconnect-first-attempt-visible,
|
|
||||||
.components-reconnect-repeated-attempt-visible,
|
|
||||||
.components-reconnect-failed-visible,
|
|
||||||
.components-pause-visible,
|
|
||||||
.components-resume-failed-visible,
|
|
||||||
.components-rejoining-animation {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible,
|
|
||||||
#components-reconnect-modal.components-reconnect-show .components-rejoining-animation,
|
|
||||||
#components-reconnect-modal.components-reconnect-paused .components-pause-visible,
|
|
||||||
#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible,
|
|
||||||
#components-reconnect-modal.components-reconnect-retrying,
|
|
||||||
#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible,
|
|
||||||
#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation,
|
|
||||||
#components-reconnect-modal.components-reconnect-failed,
|
|
||||||
#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
#components-reconnect-modal {
|
|
||||||
background-color: white;
|
|
||||||
width: 20rem;
|
|
||||||
margin: 20vh auto;
|
|
||||||
padding: 2rem;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3);
|
|
||||||
opacity: 0;
|
|
||||||
transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
|
|
||||||
animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
|
|
||||||
&[open]
|
|
||||||
|
|
||||||
{
|
|
||||||
animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
|
|
||||||
animation-fill-mode: both;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
#components-reconnect-modal::backdrop {
|
|
||||||
background-color: rgba(0, 0, 0, 0.4);
|
|
||||||
animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes components-reconnect-modal-slideUp {
|
|
||||||
0% {
|
|
||||||
transform: translateY(30px) scale(0.95);
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes components-reconnect-modal-fadeInOpacity {
|
|
||||||
0% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes components-reconnect-modal-fadeOutOpacity {
|
|
||||||
0% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.components-reconnect-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
gap: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
#components-reconnect-modal p {
|
|
||||||
margin: 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
#components-reconnect-modal button {
|
|
||||||
border: 0;
|
|
||||||
background-color: #6b9ed2;
|
|
||||||
color: white;
|
|
||||||
padding: 4px 24px;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#components-reconnect-modal button:hover {
|
|
||||||
background-color: #3b6ea2;
|
|
||||||
}
|
|
||||||
|
|
||||||
#components-reconnect-modal button:active {
|
|
||||||
background-color: #6b9ed2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.components-rejoining-animation {
|
|
||||||
position: relative;
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.components-rejoining-animation div {
|
|
||||||
position: absolute;
|
|
||||||
border: 3px solid #0087ff;
|
|
||||||
opacity: 1;
|
|
||||||
border-radius: 50%;
|
|
||||||
animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.components-rejoining-animation div:nth-child(2) {
|
|
||||||
animation-delay: -0.5s;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes components-rejoining-animation {
|
|
||||||
0% {
|
|
||||||
top: 40px;
|
|
||||||
left: 40px;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
4.9% {
|
|
||||||
top: 40px;
|
|
||||||
left: 40px;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
5% {
|
|
||||||
top: 40px;
|
|
||||||
left: 40px;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
100% {
|
|
||||||
top: 0px;
|
|
||||||
left: 0px;
|
|
||||||
width: 80px;
|
|
||||||
height: 80px;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
// Set up event handlers
|
|
||||||
const reconnectModal = document.getElementById("components-reconnect-modal");
|
|
||||||
reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged);
|
|
||||||
|
|
||||||
const retryButton = document.getElementById("components-reconnect-button");
|
|
||||||
retryButton.addEventListener("click", retry);
|
|
||||||
|
|
||||||
const resumeButton = document.getElementById("components-resume-button");
|
|
||||||
resumeButton.addEventListener("click", resume);
|
|
||||||
|
|
||||||
function handleReconnectStateChanged(event) {
|
|
||||||
if (event.detail.state === "show") {
|
|
||||||
reconnectModal.showModal();
|
|
||||||
} else if (event.detail.state === "hide") {
|
|
||||||
reconnectModal.close();
|
|
||||||
} else if (event.detail.state === "failed") {
|
|
||||||
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
|
||||||
} else if (event.detail.state === "rejected") {
|
|
||||||
location.reload();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function retry() {
|
|
||||||
document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Reconnect will asynchronously return:
|
|
||||||
// - true to mean success
|
|
||||||
// - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID)
|
|
||||||
// - exception to mean we didn't reach the server (this can be sync or async)
|
|
||||||
const successful = await Blazor.reconnect();
|
|
||||||
if (!successful) {
|
|
||||||
// We have been able to reach the server, but the circuit is no longer available.
|
|
||||||
// We'll reload the page so the user can continue using the app as quickly as possible.
|
|
||||||
const resumeSuccessful = await Blazor.resumeCircuit();
|
|
||||||
if (!resumeSuccessful) {
|
|
||||||
location.reload();
|
|
||||||
} else {
|
|
||||||
reconnectModal.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// We got an exception, server is currently unavailable
|
|
||||||
document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resume() {
|
|
||||||
try {
|
|
||||||
const successful = await Blazor.resumeCircuit();
|
|
||||||
if (!successful) {
|
|
||||||
location.reload();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function retryWhenDocumentBecomesVisible() {
|
|
||||||
if (document.visibilityState === "visible") {
|
|
||||||
await retry();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
@page "/createplaylist"
|
|
||||||
@attribute [Authorize]
|
|
||||||
@using System.Security.Claims
|
|
||||||
@using Microsoft.AspNetCore.Components.Authorization
|
|
||||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
|
||||||
@inject AppDbContext Db
|
|
||||||
@inject IYandexMusicService YandexService
|
|
||||||
@inject NavigationManager Navigation
|
|
||||||
|
|
||||||
<h3>Создание общего плейлиста</h3>
|
|
||||||
|
|
||||||
<EditForm Model="@model" OnValidSubmit="@HandleSubmit">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="title">Название плейлиста</label>
|
|
||||||
<InputText id="title" class="form-control" @bind-Value="model.Title" />
|
|
||||||
<ValidationMessage For="@(() => model.Title)" />
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label for="description">Описание (необязательно)</label>
|
|
||||||
<InputText id="description" class="form-control" @bind-Value="model.Description" />
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary" disabled="@isLoading">@(isLoading ? "Создание..." : "Создать")</button>
|
|
||||||
</EditForm>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private CreateModel model = new();
|
|
||||||
private bool isLoading;
|
|
||||||
private string? userId;
|
|
||||||
|
|
||||||
public class CreateModel
|
|
||||||
{
|
|
||||||
[Required]
|
|
||||||
public string Title { get; set; } = "";
|
|
||||||
public string? Description { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
|
||||||
userId = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleSubmit()
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(userId))
|
|
||||||
{
|
|
||||||
// пользователь не авторизован
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
isLoading = true;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// 1. Создаём плейлист в Яндекс Музыке
|
|
||||||
var yandexId = await YandexService.CreatePlaylistAsync(userId, model.Title);
|
|
||||||
// 2. Сохраняем в БД
|
|
||||||
var playlist = new SharedPlaylist
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
OwnerUserId = userId,
|
|
||||||
YandexPlaylistId = yandexId,
|
|
||||||
Title = model.Title,
|
|
||||||
Description = model.Description,
|
|
||||||
ShareSlug = Guid.NewGuid().ToString("N"),
|
|
||||||
CreatedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
Db.SharedPlaylists.Add(playlist);
|
|
||||||
await Db.SaveChangesAsync();
|
|
||||||
Navigation.NavigateTo("/myplaylists");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// показать ошибку (можно добавить переменную errorMessage)
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
isLoading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
@page "/Login"
|
|
||||||
@using Microsoft.AspNetCore.Identity
|
|
||||||
@using Microsoft.AspNetCore.Authentication
|
|
||||||
@inject SignInManager<ApplicationUser> SignInManager
|
|
||||||
|
|
||||||
<h3>Вход через Яндекс</h3>
|
|
||||||
<a class="btn btn-primary" href="/signin-yandex">Войти через Яндекс</a>
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
@page "/Logout"
|
|
||||||
@using Microsoft.AspNetCore.Identity
|
|
||||||
@inject SignInManager<ApplicationUser> SignInManager
|
|
||||||
@inject NavigationManager Navigation
|
|
||||||
|
|
||||||
@code {
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
await SignInManager.SignOutAsync();
|
|
||||||
Navigation.NavigateTo("/", true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
@page "/myplaylists"
|
|
||||||
@attribute [Authorize]
|
|
||||||
@using System.Security.Claims
|
|
||||||
@using Microsoft.EntityFrameworkCore
|
|
||||||
@using Microsoft.AspNetCore.Components.Authorization
|
|
||||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
|
||||||
@inject AppDbContext Db
|
|
||||||
@inject NavigationManager Navigation
|
|
||||||
|
|
||||||
<h3>Мои плейлисты</h3>
|
|
||||||
<a href="/createplaylist" class="btn btn-success mb-3">Создать новый плейлист</a>
|
|
||||||
|
|
||||||
@if (playlists == null)
|
|
||||||
{
|
|
||||||
<p>Загрузка...</p>
|
|
||||||
}
|
|
||||||
else if (!playlists.Any())
|
|
||||||
{
|
|
||||||
<p>У вас пока нет общих плейлистов. Создайте первый!</p>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<tr><th>Название</th><th>Дата создания</th><th>Ссылка</th><th>Настройки</th></tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@foreach (var pl in playlists)
|
|
||||||
{
|
|
||||||
<tr>
|
|
||||||
<td>@pl.Title</td>
|
|
||||||
<td>@pl.CreatedAt.ToShortDateString()</td>
|
|
||||||
<td><a href="/playlist/@pl.ShareSlug">открыть</a></td>
|
|
||||||
<td><a href="/settings/@pl.Id">настройки</a></td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
private List<SharedPlaylist>? playlists;
|
|
||||||
private string? userId;
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
|
||||||
userId = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
|
||||||
if (userId != null)
|
|
||||||
{
|
|
||||||
playlists = await Db.SharedPlaylists
|
|
||||||
.Where(p => p.OwnerUserId == userId)
|
|
||||||
.OrderByDescending(p => p.CreatedAt)
|
|
||||||
.ToListAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
@page "/not-found"
|
|
||||||
@layout MainLayout
|
|
||||||
|
|
||||||
<h3>Not Found</h3>
|
|
||||||
<p>Sorry, the content you are looking for does not exist.</p>
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
@page "/playlist/{slug}"
|
|
||||||
@using System.Security.Claims
|
|
||||||
@using Microsoft.AspNetCore.Components.Authorization
|
|
||||||
@using Microsoft.EntityFrameworkCore
|
|
||||||
@using PlaylistShared.Data.Entities
|
|
||||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
|
||||||
@inject AppDbContext Db
|
|
||||||
@inject IYandexMusicService YandexService
|
|
||||||
@inject IJSRuntime Js
|
|
||||||
@inject HttpClient Http
|
|
||||||
|
|
||||||
<h3>@playlist?.Title</h3>
|
|
||||||
<p>Владелец: @playlist?.Owner?.UserName</p>
|
|
||||||
<p>Описание: @playlist?.Description</p>
|
|
||||||
|
|
||||||
@if (canAdd)
|
|
||||||
{
|
|
||||||
<div class="mb-3">
|
|
||||||
<input type="text" id="trackId" placeholder="ID трека Яндекс.Музыки" class="form-control" />
|
|
||||||
<input type="text" id="trackTitle" placeholder="Название трека" class="form-control mt-1" />
|
|
||||||
<input type="text" id="artistName" placeholder="Исполнитель" class="form-control mt-1" />
|
|
||||||
<button class="btn btn-primary mt-2" id="addTrackBtn">Добавить</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<ul id="trackList" class="list-group">
|
|
||||||
@foreach (var track in tracks)
|
|
||||||
{
|
|
||||||
<li class="list-group-item d-flex justify-content-between align-items-center" data-track-id="@track.YandexTrackId">
|
|
||||||
<div>
|
|
||||||
<strong>@track.Title</strong> - @track.Artist
|
|
||||||
</div>
|
|
||||||
@if (canDelete(track))
|
|
||||||
{
|
|
||||||
<button class="btn btn-sm btn-danger deleteTrackBtn" data-track-id="@track.YandexTrackId">Удалить</button>
|
|
||||||
}
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public string slug { get; set; } = "";
|
|
||||||
|
|
||||||
private SharedPlaylist? playlist;
|
|
||||||
private List<PlaylistTrack> tracks = new();
|
|
||||||
private bool canAdd;
|
|
||||||
private string? currentUserId;
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
|
||||||
currentUserId = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
|
||||||
|
|
||||||
playlist = await Db.SharedPlaylists
|
|
||||||
.Include(p => p.Owner)
|
|
||||||
.Include(p => p.Tracks)
|
|
||||||
.FirstOrDefaultAsync(p => p.ShareSlug == slug);
|
|
||||||
if (playlist == null) return;
|
|
||||||
|
|
||||||
canAdd = playlist.Permissions.Add switch
|
|
||||||
{
|
|
||||||
AccessLevel.All => true,
|
|
||||||
AccessLevel.Authorized => currentUserId != null,
|
|
||||||
_ => false
|
|
||||||
};
|
|
||||||
|
|
||||||
// Здесь можно при желании синхронизировать с Яндекс API, но пока используем локальный кеш
|
|
||||||
// var yandexTracks = await YandexService.GetPlaylistTracksAsync(playlist.OwnerUserId, playlist.YandexPlaylistId);
|
|
||||||
tracks = playlist.Tracks.OrderBy(t => t.AddedAt).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool canDelete(PlaylistTrack track)
|
|
||||||
{
|
|
||||||
if (currentUserId == playlist?.OwnerUserId) return true;
|
|
||||||
return playlist?.Permissions.Delete switch
|
|
||||||
{
|
|
||||||
DeleteAccessLevel.All => true,
|
|
||||||
DeleteAccessLevel.Authorized => currentUserId != null,
|
|
||||||
DeleteAccessLevel.AdderOnly => currentUserId != null && currentUserId == track.AddedByUserId,
|
|
||||||
DeleteAccessLevel.OwnerOnly => false,
|
|
||||||
_ => false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
|
||||||
{
|
|
||||||
if (firstRender && playlist != null)
|
|
||||||
{
|
|
||||||
await Js.InvokeVoidAsync("initPlaylistInteractions", playlist.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
@page "/settings/{id:guid}"
|
|
||||||
@attribute [Authorize]
|
|
||||||
@using System.Security.Claims
|
|
||||||
@using Microsoft.AspNetCore.Components.Authorization
|
|
||||||
@using Microsoft.EntityFrameworkCore
|
|
||||||
@using PlaylistShared.Data.Entities
|
|
||||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
|
||||||
@inject AppDbContext Db
|
|
||||||
@inject NavigationManager Navigation
|
|
||||||
|
|
||||||
<h3>Настройки плейлиста</h3>
|
|
||||||
|
|
||||||
@if (playlist == null)
|
|
||||||
{
|
|
||||||
<p>Загрузка...</p>
|
|
||||||
}
|
|
||||||
else if (!isOwner)
|
|
||||||
{
|
|
||||||
<p>Только владелец может изменять настройки.</p>
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
<EditForm Model="@playlist.Permissions" OnValidSubmit="@SaveSettings">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label>Кто может просматривать</label>
|
|
||||||
<InputSelect @bind-Value="playlist.Permissions.View" class="form-control">
|
|
||||||
<option value="@AccessLevel.All">Все</option>
|
|
||||||
<option value="@AccessLevel.Authorized">Авторизованные</option>
|
|
||||||
<option value="@AccessLevel.None">Никто</option>
|
|
||||||
</InputSelect>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label>Кто может добавлять треки</label>
|
|
||||||
<InputSelect @bind-Value="playlist.Permissions.Add" class="form-control">
|
|
||||||
<option value="@AccessLevel.All">Все</option>
|
|
||||||
<option value="@AccessLevel.Authorized">Авторизованные</option>
|
|
||||||
<option value="@AccessLevel.None">Никто</option>
|
|
||||||
</InputSelect>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label>Кто может удалять треки</label>
|
|
||||||
<InputSelect @bind-Value="playlist.Permissions.Delete" class="form-control">
|
|
||||||
<option value="@DeleteAccessLevel.All">Все</option>
|
|
||||||
<option value="@DeleteAccessLevel.Authorized">Авторизованные</option>
|
|
||||||
<option value="@DeleteAccessLevel.AdderOnly">Тот, кто добавил</option>
|
|
||||||
<option value="@DeleteAccessLevel.OwnerOnly">Только владелец</option>
|
|
||||||
</InputSelect>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-primary">Сохранить</button>
|
|
||||||
</EditForm>
|
|
||||||
}
|
|
||||||
|
|
||||||
@code {
|
|
||||||
[Parameter] public Guid id { get; set; }
|
|
||||||
private SharedPlaylist? playlist;
|
|
||||||
private bool isOwner;
|
|
||||||
private string? currentUserId;
|
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
|
||||||
{
|
|
||||||
var authState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
|
|
||||||
currentUserId = authState.User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
|
|
||||||
|
|
||||||
playlist = await Db.SharedPlaylists
|
|
||||||
.Include(p => p.Owner)
|
|
||||||
.FirstOrDefaultAsync(p => p.Id == id);
|
|
||||||
if (playlist != null)
|
|
||||||
{
|
|
||||||
isOwner = currentUserId == playlist.OwnerUserId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SaveSettings()
|
|
||||||
{
|
|
||||||
if (playlist != null)
|
|
||||||
{
|
|
||||||
Db.SharedPlaylists.Update(playlist);
|
|
||||||
await Db.SaveChangesAsync();
|
|
||||||
Navigation.NavigateTo("/myplaylists");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
|
|
||||||
<Found Context="routeData">
|
|
||||||
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
|
|
||||||
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
|
||||||
</Found>
|
|
||||||
</Router>
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
@using System.Net.Http
|
|
||||||
@using System.Net.Http.Json
|
|
||||||
@using Microsoft.AspNetCore.Components.Forms
|
|
||||||
@using Microsoft.AspNetCore.Components.Routing
|
|
||||||
@using Microsoft.AspNetCore.Components.Web
|
|
||||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
|
||||||
@using Microsoft.AspNetCore.Components.Web.Virtualization
|
|
||||||
@using Microsoft.JSInterop
|
|
||||||
@using PlaylistShared
|
|
||||||
@using PlaylistShared.Components
|
|
||||||
@using PlaylistShared.Components.Layout
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
using PlaylistShared.Models;
|
|
||||||
using System.Net.Http.Headers;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace PlaylistShared.Controllers;
|
|
||||||
|
|
||||||
[Microsoft.AspNetCore.Mvc.Route("auth")]
|
|
||||||
[ApiController]
|
|
||||||
public class AuthController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly IConfiguration _config;
|
|
||||||
private readonly HttpClient _httpClient;
|
|
||||||
private readonly AppDbContext _db;
|
|
||||||
private readonly SignInManager<ApplicationUser> _signInManager;
|
|
||||||
private readonly ILogger<AuthController> _logger;
|
|
||||||
|
|
||||||
public AuthController(
|
|
||||||
IConfiguration config,
|
|
||||||
IHttpClientFactory factory,
|
|
||||||
AppDbContext db,
|
|
||||||
SignInManager<ApplicationUser> signInManager,
|
|
||||||
ILogger<AuthController> logger)
|
|
||||||
{
|
|
||||||
_config = config;
|
|
||||||
_httpClient = factory.CreateClient();
|
|
||||||
_db = db;
|
|
||||||
_signInManager = signInManager;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("login")]
|
|
||||||
public IActionResult Login()
|
|
||||||
{
|
|
||||||
var clientId = _config["YandexOAuth:ClientId"];
|
|
||||||
var redirectUri = Url.Action(nameof(Callback), "Auth", null, Request.Scheme);
|
|
||||||
var authUrl = $"{_config["YandexOAuth:AuthorizationEndpoint"]}?response_type=code&client_id={clientId}&redirect_uri={Uri.EscapeDataString(redirectUri!)}";
|
|
||||||
return Redirect(authUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpGet("callback")]
|
|
||||||
public async Task<IActionResult> Callback(string code)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(code))
|
|
||||||
return BadRequest("Missing code");
|
|
||||||
|
|
||||||
var tokenResponse = await ExchangeCodeForTokenAsync(code);
|
|
||||||
if (tokenResponse is null)
|
|
||||||
return StatusCode(500, "Token exchange failed");
|
|
||||||
|
|
||||||
var userInfo = await GetUserInfoAsync(tokenResponse.AccessToken);
|
|
||||||
if (userInfo is null)
|
|
||||||
return StatusCode(500, "User info fetch failed");
|
|
||||||
|
|
||||||
var user = await _db.Users.FirstOrDefaultAsync(u => u.YandexId == userInfo.Id);
|
|
||||||
if (user is null)
|
|
||||||
{
|
|
||||||
user = new ApplicationUser
|
|
||||||
{
|
|
||||||
UserName = userInfo.Login,
|
|
||||||
YandexId = userInfo.Id,
|
|
||||||
Email = userInfo.DefaultEmail,
|
|
||||||
AccessToken = tokenResponse.AccessToken,
|
|
||||||
RefreshToken = tokenResponse.RefreshToken,
|
|
||||||
AccessTokenExpiresAt = DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn)
|
|
||||||
};
|
|
||||||
_db.Users.Add(user);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
user.AccessToken = tokenResponse.AccessToken;
|
|
||||||
user.RefreshToken = tokenResponse.RefreshToken;
|
|
||||||
user.AccessTokenExpiresAt = DateTime.UtcNow.AddSeconds(tokenResponse.ExpiresIn);
|
|
||||||
_db.Users.Update(user);
|
|
||||||
}
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
|
|
||||||
await _signInManager.SignInAsync(user, isPersistent: false);
|
|
||||||
return Redirect("/");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<YandexTokenResponse?> ExchangeCodeForTokenAsync(string code)
|
|
||||||
{
|
|
||||||
var tokenUrl = _config["YandexOAuth:TokenEndpoint"];
|
|
||||||
var redirectUri = Url.Action(nameof(Callback), "Auth", null, Request.Scheme);
|
|
||||||
var content = new FormUrlEncodedContent(new[]
|
|
||||||
{
|
|
||||||
new KeyValuePair<string, string>("grant_type", "authorization_code"),
|
|
||||||
new KeyValuePair<string, string>("code", code),
|
|
||||||
new KeyValuePair<string, string>("client_id", _config["YandexOAuth:ClientId"]!),
|
|
||||||
new KeyValuePair<string, string>("client_secret", _config["YandexOAuth:ClientSecret"]!),
|
|
||||||
new KeyValuePair<string, string>("redirect_uri", redirectUri!)
|
|
||||||
});
|
|
||||||
|
|
||||||
var response = await _httpClient.PostAsync(tokenUrl!, content);
|
|
||||||
if (!response.IsSuccessStatusCode) return null;
|
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
|
||||||
return JsonSerializer.Deserialize<YandexTokenResponse>(json);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<YandexUserInfo?> GetUserInfoAsync(string accessToken)
|
|
||||||
{
|
|
||||||
_httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("OAuth", accessToken);
|
|
||||||
var response = await _httpClient.GetAsync(_config["YandexOAuth:UserInfoEndpoint"]!);
|
|
||||||
if (!response.IsSuccessStatusCode) return null;
|
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
|
||||||
return JsonSerializer.Deserialize<YandexUserInfo>(json);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
// Controllers/TrackController.cs
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace PlaylistShared.Controllers;
|
|
||||||
|
|
||||||
[ApiController]
|
|
||||||
[Microsoft.AspNetCore.Mvc.Route("api/tracks")]
|
|
||||||
[Authorize]
|
|
||||||
public class TrackController : ControllerBase
|
|
||||||
{
|
|
||||||
private readonly AppDbContext _db;
|
|
||||||
private readonly IYandexMusicService _yandex;
|
|
||||||
|
|
||||||
public TrackController(AppDbContext db, IYandexMusicService yandex)
|
|
||||||
{
|
|
||||||
_db = db;
|
|
||||||
_yandex = yandex;
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpPost("add")]
|
|
||||||
public async Task<IActionResult> AddTrack(Guid playlistId, string trackId, string? trackTitle, string? artist)
|
|
||||||
{
|
|
||||||
var playlist = await _db.SharedPlaylists
|
|
||||||
.Include(p => p.Owner)
|
|
||||||
.FirstOrDefaultAsync(p => p.Id == playlistId);
|
|
||||||
if (playlist == null) return NotFound();
|
|
||||||
|
|
||||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
||||||
if (!CanAdd(playlist, userId)) return Forbid();
|
|
||||||
|
|
||||||
// Добавляем в Яндекс
|
|
||||||
await _yandex.AddTrackToPlaylistAsync(playlist.OwnerUserId, playlist.YandexPlaylistId, trackId);
|
|
||||||
|
|
||||||
// Сохраняем информацию о треке в кеш
|
|
||||||
var track = new PlaylistTrack
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid(),
|
|
||||||
PlaylistId = playlist.Id,
|
|
||||||
YandexTrackId = trackId,
|
|
||||||
Title = trackTitle ?? "Unknown",
|
|
||||||
Artist = artist,
|
|
||||||
AddedByUserId = userId,
|
|
||||||
AddedAt = DateTime.UtcNow
|
|
||||||
};
|
|
||||||
_db.PlaylistTracks.Add(track);
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
return Ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
[HttpDelete("remove")]
|
|
||||||
public async Task<IActionResult> RemoveTrack(Guid playlistId, string trackId)
|
|
||||||
{
|
|
||||||
var playlist = await _db.SharedPlaylists
|
|
||||||
.Include(p => p.Owner)
|
|
||||||
.FirstOrDefaultAsync(p => p.Id == playlistId);
|
|
||||||
if (playlist == null) return NotFound();
|
|
||||||
|
|
||||||
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier);
|
|
||||||
var trackEntry = await _db.PlaylistTracks
|
|
||||||
.FirstOrDefaultAsync(t => t.PlaylistId == playlistId && t.YandexTrackId == trackId);
|
|
||||||
if (!CanDelete(playlist, userId, trackEntry?.AddedByUserId))
|
|
||||||
return Forbid();
|
|
||||||
|
|
||||||
await _yandex.RemoveTrackFromPlaylistAsync(playlist.OwnerUserId, playlist.YandexPlaylistId, trackId);
|
|
||||||
if (trackEntry != null) _db.PlaylistTracks.Remove(trackEntry);
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
return Ok();
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool CanAdd(SharedPlaylist playlist, string? userId)
|
|
||||||
{
|
|
||||||
return playlist.Permissions.Add switch
|
|
||||||
{
|
|
||||||
AccessLevel.All => true,
|
|
||||||
AccessLevel.Authorized => userId != null,
|
|
||||||
AccessLevel.None => false,
|
|
||||||
_ => false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool CanDelete(SharedPlaylist playlist, string? userId, string? adderUserId)
|
|
||||||
{
|
|
||||||
if (userId == playlist.OwnerUserId) return true; // владелец всегда может удалить
|
|
||||||
return playlist.Permissions.Delete switch
|
|
||||||
{
|
|
||||||
DeleteAccessLevel.All => true,
|
|
||||||
DeleteAccessLevel.Authorized => userId != null,
|
|
||||||
DeleteAccessLevel.AdderOnly => userId != null && userId == adderUserId,
|
|
||||||
DeleteAccessLevel.OwnerOnly => false,
|
|
||||||
_ => false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using PlaylistShared.Data.Entities;
|
|
||||||
|
|
||||||
namespace PlaylistShared.Data.Contexts;
|
|
||||||
|
|
||||||
public class AppDbContext : IdentityDbContext<ApplicationUser>
|
|
||||||
{
|
|
||||||
public DbSet<SharedPlaylist> SharedPlaylists => Set<SharedPlaylist>();
|
|
||||||
public DbSet<PlaylistTrack> PlaylistTracks => Set<PlaylistTrack>();
|
|
||||||
|
|
||||||
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
|
|
||||||
|
|
||||||
protected override void OnModelCreating(ModelBuilder builder)
|
|
||||||
{
|
|
||||||
base.OnModelCreating(builder);
|
|
||||||
|
|
||||||
builder.Entity<SharedPlaylist>(entity =>
|
|
||||||
{
|
|
||||||
entity.HasIndex(p => p.ShareSlug).IsUnique();
|
|
||||||
entity.OwnsOne(p => p.Permissions, per =>
|
|
||||||
{
|
|
||||||
per.Property(x => x.View).HasColumnName("PermView");
|
|
||||||
per.Property(x => x.Add).HasColumnName("PermAdd");
|
|
||||||
per.Property(x => x.Delete).HasColumnName("PermDelete");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Entity<PlaylistTrack>(entity =>
|
|
||||||
{
|
|
||||||
entity.HasOne(pt => pt.Playlist)
|
|
||||||
.WithMany(p => p.Tracks)
|
|
||||||
.HasForeignKey(pt => pt.PlaylistId)
|
|
||||||
.OnDelete(DeleteBehavior.Cascade);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace PlaylistShared.Data.Entities;
|
|
||||||
|
|
||||||
public class ApplicationUser : IdentityUser
|
|
||||||
{
|
|
||||||
public string? YandexId { get; set; }
|
|
||||||
public string? AccessToken { get; set; }
|
|
||||||
public string? RefreshToken { get; set; }
|
|
||||||
public DateTime? AccessTokenExpiresAt { get; set; }
|
|
||||||
public string? AvatarUrl { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace PlaylistShared.Data.Entities;
|
|
||||||
|
|
||||||
public enum AccessLevel { All, Authorized, None }
|
|
||||||
public enum DeleteAccessLevel { All, Authorized, AdderOnly, OwnerOnly }
|
|
||||||
|
|
||||||
[Owned]
|
|
||||||
public class PlaylistPermissions
|
|
||||||
{
|
|
||||||
public AccessLevel View { get; set; } = AccessLevel.All;
|
|
||||||
public AccessLevel Add { get; set; } = AccessLevel.Authorized;
|
|
||||||
public DeleteAccessLevel Delete { get; set; } = DeleteAccessLevel.OwnerOnly;
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
|
|
||||||
namespace PlaylistShared.Data.Entities;
|
|
||||||
|
|
||||||
public class PlaylistTrack
|
|
||||||
{
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
public Guid PlaylistId { get; set; }
|
|
||||||
public SharedPlaylist Playlist { get; set; } = null!;
|
|
||||||
|
|
||||||
public string YandexTrackId { get; set; } = null!;
|
|
||||||
public string Title { get; set; } = null!;
|
|
||||||
public string? Artist { get; set; }
|
|
||||||
public string? AlbumTitle { get; set; }
|
|
||||||
public int DurationMs { get; set; }
|
|
||||||
|
|
||||||
public string? AddedByUserId { get; set; }
|
|
||||||
|
|
||||||
[ForeignKey(nameof(AddedByUserId))]
|
|
||||||
public ApplicationUser? AddedByUser { get; set; }
|
|
||||||
|
|
||||||
public DateTime AddedAt { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using System.ComponentModel.DataAnnotations.Schema;
|
|
||||||
|
|
||||||
namespace PlaylistShared.Data.Entities;
|
|
||||||
|
|
||||||
public class SharedPlaylist
|
|
||||||
{
|
|
||||||
[Key]
|
|
||||||
public Guid Id { get; set; }
|
|
||||||
|
|
||||||
public string OwnerUserId { get; set; } = null!;
|
|
||||||
[ForeignKey(nameof(OwnerUserId))]
|
|
||||||
public ApplicationUser Owner { get; set; } = null!;
|
|
||||||
|
|
||||||
public string YandexPlaylistId { get; set; } = null!;
|
|
||||||
public string Title { get; set; } = null!;
|
|
||||||
public string? Description { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string ShareSlug { get; set; } = null!;
|
|
||||||
|
|
||||||
public PlaylistPermissions Permissions { get; set; } = new();
|
|
||||||
|
|
||||||
public DateTime CreatedAt { get; set; }
|
|
||||||
public DateTime? UpdatedAt { get; set; }
|
|
||||||
|
|
||||||
public List<PlaylistTrack> Tracks { get; set; } = new();
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
global using Microsoft.AspNetCore.Authorization;
|
|
||||||
global using Microsoft.AspNetCore.Components;
|
|
||||||
global using Microsoft.AspNetCore.Identity;
|
|
||||||
global using Microsoft.EntityFrameworkCore;
|
|
||||||
global using PlaylistShared.Data.Contexts;
|
|
||||||
global using PlaylistShared.Data.Entities;
|
|
||||||
global using PlaylistShared.Services;
|
|
||||||
global using System.ComponentModel.DataAnnotations;
|
|
||||||
global using System.Security.Claims;
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
namespace PlaylistShared.Models;
|
|
||||||
|
|
||||||
public record YandexTokenResponse(string AccessToken, string RefreshToken, int ExpiresIn);
|
|
||||||
public record YandexUserInfo(string Id, string Login, string? DisplayName, string? DefaultEmail, string? AvatarUrl);
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net10.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.5" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.5" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.5" />
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
|
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.5" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\YandexMusic\YandexMusic.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Folder Include="Data\Migrations\" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
using Microsoft.AspNetCore.Authentication;
|
|
||||||
using Microsoft.AspNetCore.Identity;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using PlaylistShared.Data.Contexts;
|
|
||||||
using PlaylistShared.Data.Entities;
|
|
||||||
using PlaylistShared.Services;
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
|
||||||
|
|
||||||
builder.Services.AddRazorPages();
|
|
||||||
builder.Services.AddServerSideBlazor();
|
|
||||||
|
|
||||||
builder.Services.AddDbContext<AppDbContext>(options =>
|
|
||||||
{
|
|
||||||
options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection"));
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.AddIdentity<ApplicationUser, IdentityRole>()
|
|
||||||
.AddEntityFrameworkStores<AppDbContext>()
|
|
||||||
.AddDefaultTokenProviders();
|
|
||||||
|
|
||||||
builder.Services.ConfigureApplicationCookie(options =>
|
|
||||||
{
|
|
||||||
options.LoginPath = "/Login";
|
|
||||||
options.LogoutPath = "/Logout";
|
|
||||||
options.AccessDeniedPath = "/AccessDenied";
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.AddAuthentication()
|
|
||||||
.AddOAuth("Yandex", options =>
|
|
||||||
{
|
|
||||||
options.ClientId = builder.Configuration["YandexOAuth:ClientId"];
|
|
||||||
options.ClientSecret = builder.Configuration["YandexOAuth:ClientSecret"];
|
|
||||||
options.AuthorizationEndpoint = builder.Configuration["YandexOAuth:AuthorizationEndpoint"];
|
|
||||||
options.TokenEndpoint = builder.Configuration["YandexOAuth:TokenEndpoint"];
|
|
||||||
options.UserInformationEndpoint = builder.Configuration["YandexOAuth:UserInfoEndpoint"];
|
|
||||||
options.CallbackPath = "/signin-yandex";
|
|
||||||
|
|
||||||
options.ClaimActions.MapJsonKey("urn:yandex:avatar_url", "avatar_url");
|
|
||||||
options.ClaimActions.MapJsonKey("urn:yandex:display_name", "display_name");
|
|
||||||
|
|
||||||
options.Events = new Microsoft.AspNetCore.Authentication.OAuth.OAuthEvents
|
|
||||||
{
|
|
||||||
OnCreatingTicket = async context =>
|
|
||||||
{
|
|
||||||
var request = new HttpRequestMessage(HttpMethod.Get, context.Options.UserInformationEndpoint);
|
|
||||||
request.Headers.Accept.Add(new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
|
|
||||||
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", context.AccessToken);
|
|
||||||
var response = await context.Backchannel.SendAsync(request);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
var user = System.Text.Json.JsonDocument.Parse(await response.Content.ReadAsStringAsync());
|
|
||||||
context.RunClaimActions(user.RootElement);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
builder.Services.AddScoped<IYandexMusicService, YandexMusicService>();
|
|
||||||
builder.Services.AddHttpClient();
|
|
||||||
|
|
||||||
var app = builder.Build();
|
|
||||||
|
|
||||||
if (!app.Environment.IsDevelopment())
|
|
||||||
{
|
|
||||||
app.UseExceptionHandler("/Error");
|
|
||||||
app.UseHsts();
|
|
||||||
}
|
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
|
||||||
app.UseStaticFiles();
|
|
||||||
app.UseRouting();
|
|
||||||
|
|
||||||
app.UseAuthentication();
|
|
||||||
app.UseAuthorization();
|
|
||||||
|
|
||||||
app.MapRazorPages();
|
|
||||||
app.MapBlazorHub();
|
|
||||||
app.MapFallbackToPage("/_Host");
|
|
||||||
|
|
||||||
app.Run();
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
|
||||||
"profiles": {
|
|
||||||
"http": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"dotnetRunMessages": true,
|
|
||||||
"launchBrowser": true,
|
|
||||||
"applicationUrl": "http://localhost:5058",
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"https": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"dotnetRunMessages": true,
|
|
||||||
"launchBrowser": true,
|
|
||||||
"applicationUrl": "https://localhost:7272;http://localhost:5058",
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
namespace PlaylistShared.Services;
|
|
||||||
|
|
||||||
public interface IYandexMusicService
|
|
||||||
{
|
|
||||||
Task<string> CreatePlaylistAsync(string userId, string title);
|
|
||||||
Task AddTrackToPlaylistAsync(string userId, string yandexPlaylistId, string trackId);
|
|
||||||
Task RemoveTrackFromPlaylistAsync(string userId, string yandexPlaylistId, string trackId);
|
|
||||||
Task<List<YandexTrackInfo>> GetPlaylistTracksAsync(string userId, string yandexPlaylistId);
|
|
||||||
Task<YandexPlaylistInfo> GetPlaylistInfoAsync(string userId, string yandexPlaylistId);
|
|
||||||
Task<string?> RefreshUserTokenAsync(string userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public record YandexTrackInfo(string Id, string Title, string? Artist, string? AlbumTitle, int DurationMs);
|
|
||||||
public record YandexPlaylistInfo(string Kind, string Title, int TrackCount);
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
using PlaylistShared.Data.Contexts;
|
|
||||||
using PlaylistShared.Data.Entities;
|
|
||||||
using System.Net.Http.Headers;
|
|
||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
|
|
||||||
namespace PlaylistShared.Services;
|
|
||||||
|
|
||||||
public class YandexMusicService : IYandexMusicService
|
|
||||||
{
|
|
||||||
private readonly AppDbContext _db;
|
|
||||||
private readonly HttpClient _http;
|
|
||||||
private readonly IConfiguration _config;
|
|
||||||
private readonly ILogger<YandexMusicService> _logger;
|
|
||||||
|
|
||||||
public YandexMusicService(AppDbContext db, IHttpClientFactory factory, IConfiguration config, ILogger<YandexMusicService> logger)
|
|
||||||
{
|
|
||||||
_db = db;
|
|
||||||
_http = factory.CreateClient();
|
|
||||||
_config = config;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<ApplicationUser> GetUserWithValidTokenAsync(string userId)
|
|
||||||
{
|
|
||||||
var user = await _db.Users.FindAsync(userId);
|
|
||||||
if (user == null) throw new Exception("User not found");
|
|
||||||
|
|
||||||
if (user.AccessTokenExpiresAt <= DateTime.UtcNow.AddMinutes(5))
|
|
||||||
{
|
|
||||||
var newToken = await RefreshYandexTokenAsync(user.RefreshToken!);
|
|
||||||
user.AccessToken = newToken.access_token;
|
|
||||||
user.RefreshToken = newToken.refresh_token;
|
|
||||||
user.AccessTokenExpiresAt = DateTime.UtcNow.AddSeconds(newToken.expires_in);
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
}
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<(string access_token, string refresh_token, int expires_in)> RefreshYandexTokenAsync(string refreshToken)
|
|
||||||
{
|
|
||||||
var content = new FormUrlEncodedContent(new[]
|
|
||||||
{
|
|
||||||
new KeyValuePair<string, string>("grant_type", "refresh_token"),
|
|
||||||
new KeyValuePair<string, string>("refresh_token", refreshToken),
|
|
||||||
new KeyValuePair<string, string>("client_id", _config["YandexOAuth:ClientId"]),
|
|
||||||
new KeyValuePair<string, string>("client_secret", _config["YandexOAuth:ClientSecret"])
|
|
||||||
});
|
|
||||||
var response = await _http.PostAsync(_config["YandexOAuth:TokenEndpoint"], content);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
|
||||||
using var doc = JsonDocument.Parse(json);
|
|
||||||
return (
|
|
||||||
doc.RootElement.GetProperty("access_token").GetString()!,
|
|
||||||
doc.RootElement.GetProperty("refresh_token").GetString()!,
|
|
||||||
doc.RootElement.GetProperty("expires_in").GetInt32()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string> CreatePlaylistAsync(string userId, string title)
|
|
||||||
{
|
|
||||||
var user = await GetUserWithValidTokenAsync(userId);
|
|
||||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("OAuth", user.AccessToken);
|
|
||||||
var payload = new { title, visibility = "public" };
|
|
||||||
var json = JsonSerializer.Serialize(payload);
|
|
||||||
var response = await _http.PostAsync("https://api.music.yandex.net/users/self/playlists/create",
|
|
||||||
new StringContent(json, Encoding.UTF8, "application/json"));
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
var resultJson = await response.Content.ReadAsStringAsync();
|
|
||||||
var result = JsonSerializer.Deserialize<YandexApiResponse<YandexPlaylistData>>(resultJson);
|
|
||||||
return result!.Result.Kind;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task AddTrackToPlaylistAsync(string userId, string yandexPlaylistId, string trackId)
|
|
||||||
{
|
|
||||||
var user = await GetUserWithValidTokenAsync(userId);
|
|
||||||
var uid = user.YandexId; // или user.Id? В API Яндекса используется uid из аккаунта, но можно использовать "self"
|
|
||||||
// Получаем альбом трека (опционально, можно передать без albumId)
|
|
||||||
// Для простоты используем трек без albumId
|
|
||||||
var diff = new[]
|
|
||||||
{
|
|
||||||
new
|
|
||||||
{
|
|
||||||
op = "insert",
|
|
||||||
at = 0,
|
|
||||||
tracks = new[] { new { id = trackId } }
|
|
||||||
}
|
|
||||||
};
|
|
||||||
var json = JsonSerializer.Serialize(diff);
|
|
||||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
||||||
var url = $"https://api.music.yandex.net/users/{uid}/playlists/{yandexPlaylistId}/change";
|
|
||||||
var response = await _http.PostAsync(url, content);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task RemoveTrackFromPlaylistAsync(string userId, string yandexPlaylistId, string trackId)
|
|
||||||
{
|
|
||||||
var user = await GetUserWithValidTokenAsync(userId);
|
|
||||||
var uid = user.YandexId;
|
|
||||||
// Сначала нужно получить позицию трека в плейлисте – упростим: удаляем по индексу (не надёжно)
|
|
||||||
// Лучше получить плейлист, найти индекс трека, потом удалить
|
|
||||||
var playlist = await GetPlaylistInfoAsync(userId, yandexPlaylistId);
|
|
||||||
var tracks = await GetPlaylistTracksAsync(userId, yandexPlaylistId);
|
|
||||||
var index = tracks.FindIndex(t => t.Id == trackId);
|
|
||||||
if (index == -1) throw new Exception("Track not found in playlist");
|
|
||||||
var diff = new[]
|
|
||||||
{
|
|
||||||
new
|
|
||||||
{
|
|
||||||
op = "delete",
|
|
||||||
from = index,
|
|
||||||
to = index + 1
|
|
||||||
}
|
|
||||||
};
|
|
||||||
var json = JsonSerializer.Serialize(diff);
|
|
||||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
|
||||||
var url = $"https://api.music.yandex.net/users/{uid}/playlists/{yandexPlaylistId}/change";
|
|
||||||
var response = await _http.PostAsync(url, content);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<List<YandexTrackInfo>> GetPlaylistTracksAsync(string userId, string yandexPlaylistId)
|
|
||||||
{
|
|
||||||
var user = await GetUserWithValidTokenAsync(userId);
|
|
||||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("OAuth", user.AccessToken);
|
|
||||||
var uid = user.YandexId;
|
|
||||||
var url = $"https://api.music.yandex.net/users/{uid}/playlists/{yandexPlaylistId}";
|
|
||||||
var response = await _http.GetAsync(url);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
|
||||||
var data = JsonSerializer.Deserialize<YandexApiResponse<YandexPlaylistFull>>(json);
|
|
||||||
return data!.Result.Tracks?.Select(t => new YandexTrackInfo(
|
|
||||||
t.Id.ToString(),
|
|
||||||
t.Title,
|
|
||||||
t.Artists?.FirstOrDefault()?.Name,
|
|
||||||
t.Albums?.FirstOrDefault()?.Title,
|
|
||||||
t.DurationMs
|
|
||||||
)).ToList() ?? new();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<YandexPlaylistInfo> GetPlaylistInfoAsync(string userId, string yandexPlaylistId)
|
|
||||||
{
|
|
||||||
var user = await GetUserWithValidTokenAsync(userId);
|
|
||||||
_http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("OAuth", user.AccessToken);
|
|
||||||
var uid = user.YandexId;
|
|
||||||
var url = $"https://api.music.yandex.net/users/{uid}/playlists/{yandexPlaylistId}";
|
|
||||||
var response = await _http.GetAsync(url);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
|
||||||
var data = JsonSerializer.Deserialize<YandexApiResponse<YandexPlaylistFull>>(json);
|
|
||||||
return new YandexPlaylistInfo(data!.Result.Kind, data.Result.Title, data.Result.TrackCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<string?> RefreshUserTokenAsync(string userId)
|
|
||||||
{
|
|
||||||
var user = await _db.Users.FindAsync(userId);
|
|
||||||
if (user?.RefreshToken == null) return null;
|
|
||||||
var newToken = await RefreshYandexTokenAsync(user.RefreshToken);
|
|
||||||
user.AccessToken = newToken.access_token;
|
|
||||||
user.RefreshToken = newToken.refresh_token;
|
|
||||||
user.AccessTokenExpiresAt = DateTime.UtcNow.AddSeconds(newToken.expires_in);
|
|
||||||
await _db.SaveChangesAsync();
|
|
||||||
return newToken.access_token;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Вспомогательные record
|
|
||||||
private record YandexApiResponse<T>(T Result);
|
|
||||||
private record YandexPlaylistData(string Kind);
|
|
||||||
private record YandexPlaylistFull(string Kind, string Title, int TrackCount, List<YandexTrack> Tracks);
|
|
||||||
private record YandexTrack(string Id, string Title, int DurationMs, List<YandexArtist> Artists, List<YandexAlbum> Albums);
|
|
||||||
private record YandexArtist(string Name);
|
|
||||||
private record YandexAlbum(string Title);
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"AllowedHosts": "*",
|
|
||||||
"YandexOAuth": {
|
|
||||||
"ClientId": "0916685f8a3641ca8fc382dbccf77236",
|
|
||||||
"ClientSecret": "f7398893cd814f8b84b85aeb2a0a6698",
|
|
||||||
"AuthorizationEndpoint": "https://oauth.yandex.ru/authorize",
|
|
||||||
"TokenEndpoint": "https://oauth.yandex.ru/token",
|
|
||||||
"UserInfoEndpoint": "https://login.yandex.ru/info"
|
|
||||||
},
|
|
||||||
"Jwt": {
|
|
||||||
"Secret": "very_long_secret_key_at_least_32_chars",
|
|
||||||
"Issuer": "PlaylistShared",
|
|
||||||
"Audience": "PlaylistSharedAPI"
|
|
||||||
},
|
|
||||||
"ConnectionStrings": {
|
|
||||||
"DefaultConnection": "Data Source=playlist.db"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
html, body {
|
|
||||||
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
a, .btn-link {
|
|
||||||
color: #006bb7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
color: #fff;
|
|
||||||
background-color: #1b6ec2;
|
|
||||||
border-color: #1861ac;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus {
|
|
||||||
box-shadow: 0 0 0 0.1rem white, 0 0 0 0.25rem #258cfb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
padding-top: 1.1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1:focus {
|
|
||||||
outline: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.valid.modified:not([type=checkbox]) {
|
|
||||||
outline: 1px solid #26b050;
|
|
||||||
}
|
|
||||||
|
|
||||||
.invalid {
|
|
||||||
outline: 1px solid #e50000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.validation-message {
|
|
||||||
color: #e50000;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blazor-error-boundary {
|
|
||||||
background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121;
|
|
||||||
padding: 1rem 1rem 1rem 3.7rem;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
|
|
||||||
.blazor-error-boundary::after {
|
|
||||||
content: "An error has occurred."
|
|
||||||
}
|
|
||||||
|
|
||||||
.darker-border-checkbox.form-check-input {
|
|
||||||
border-color: #929292;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-floating > .form-control-plaintext::placeholder, .form-floating > .form-control::placeholder {
|
|
||||||
color: var(--bs-secondary-color);
|
|
||||||
text-align: end;
|
|
||||||
}
|
|
||||||
|
|
||||||
.form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder {
|
|
||||||
text-align: start;
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,26 +0,0 @@
|
|||||||
window.initPlaylistInteractions = (playlistId) => {
|
|
||||||
const addBtn = document.getElementById('addTrackBtn');
|
|
||||||
if (addBtn) {
|
|
||||||
addBtn.addEventListener('click', async () => {
|
|
||||||
const trackId = document.getElementById('trackId').value;
|
|
||||||
const trackTitle = document.getElementById('trackTitle').value;
|
|
||||||
const artist = document.getElementById('artistName').value;
|
|
||||||
const response = await fetch(`/api/tracks/add?playlistId=${playlistId}&trackId=${trackId}&trackTitle=${encodeURIComponent(trackTitle)}&artist=${encodeURIComponent(artist)}`, {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
if (response.ok) location.reload();
|
|
||||||
else alert('Ошибка добавления трека');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.querySelectorAll('.deleteTrackBtn').forEach(btn => {
|
|
||||||
btn.addEventListener('click', async () => {
|
|
||||||
const trackId = btn.getAttribute('data-track-id');
|
|
||||||
const response = await fetch(`/api/tracks/remove?playlistId=${playlistId}&trackId=${trackId}`, {
|
|
||||||
method: 'DELETE'
|
|
||||||
});
|
|
||||||
if (response.ok) location.reload();
|
|
||||||
else alert('Ошибка удаления трека');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,597 +0,0 @@
|
|||||||
/*!
|
|
||||||
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
|
|
||||||
* Copyright 2011-2024 The Bootstrap Authors
|
|
||||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
|
||||||
*/
|
|
||||||
:root,
|
|
||||||
[data-bs-theme=light] {
|
|
||||||
--bs-blue: #0d6efd;
|
|
||||||
--bs-indigo: #6610f2;
|
|
||||||
--bs-purple: #6f42c1;
|
|
||||||
--bs-pink: #d63384;
|
|
||||||
--bs-red: #dc3545;
|
|
||||||
--bs-orange: #fd7e14;
|
|
||||||
--bs-yellow: #ffc107;
|
|
||||||
--bs-green: #198754;
|
|
||||||
--bs-teal: #20c997;
|
|
||||||
--bs-cyan: #0dcaf0;
|
|
||||||
--bs-black: #000;
|
|
||||||
--bs-white: #fff;
|
|
||||||
--bs-gray: #6c757d;
|
|
||||||
--bs-gray-dark: #343a40;
|
|
||||||
--bs-gray-100: #f8f9fa;
|
|
||||||
--bs-gray-200: #e9ecef;
|
|
||||||
--bs-gray-300: #dee2e6;
|
|
||||||
--bs-gray-400: #ced4da;
|
|
||||||
--bs-gray-500: #adb5bd;
|
|
||||||
--bs-gray-600: #6c757d;
|
|
||||||
--bs-gray-700: #495057;
|
|
||||||
--bs-gray-800: #343a40;
|
|
||||||
--bs-gray-900: #212529;
|
|
||||||
--bs-primary: #0d6efd;
|
|
||||||
--bs-secondary: #6c757d;
|
|
||||||
--bs-success: #198754;
|
|
||||||
--bs-info: #0dcaf0;
|
|
||||||
--bs-warning: #ffc107;
|
|
||||||
--bs-danger: #dc3545;
|
|
||||||
--bs-light: #f8f9fa;
|
|
||||||
--bs-dark: #212529;
|
|
||||||
--bs-primary-rgb: 13, 110, 253;
|
|
||||||
--bs-secondary-rgb: 108, 117, 125;
|
|
||||||
--bs-success-rgb: 25, 135, 84;
|
|
||||||
--bs-info-rgb: 13, 202, 240;
|
|
||||||
--bs-warning-rgb: 255, 193, 7;
|
|
||||||
--bs-danger-rgb: 220, 53, 69;
|
|
||||||
--bs-light-rgb: 248, 249, 250;
|
|
||||||
--bs-dark-rgb: 33, 37, 41;
|
|
||||||
--bs-primary-text-emphasis: #052c65;
|
|
||||||
--bs-secondary-text-emphasis: #2b2f32;
|
|
||||||
--bs-success-text-emphasis: #0a3622;
|
|
||||||
--bs-info-text-emphasis: #055160;
|
|
||||||
--bs-warning-text-emphasis: #664d03;
|
|
||||||
--bs-danger-text-emphasis: #58151c;
|
|
||||||
--bs-light-text-emphasis: #495057;
|
|
||||||
--bs-dark-text-emphasis: #495057;
|
|
||||||
--bs-primary-bg-subtle: #cfe2ff;
|
|
||||||
--bs-secondary-bg-subtle: #e2e3e5;
|
|
||||||
--bs-success-bg-subtle: #d1e7dd;
|
|
||||||
--bs-info-bg-subtle: #cff4fc;
|
|
||||||
--bs-warning-bg-subtle: #fff3cd;
|
|
||||||
--bs-danger-bg-subtle: #f8d7da;
|
|
||||||
--bs-light-bg-subtle: #fcfcfd;
|
|
||||||
--bs-dark-bg-subtle: #ced4da;
|
|
||||||
--bs-primary-border-subtle: #9ec5fe;
|
|
||||||
--bs-secondary-border-subtle: #c4c8cb;
|
|
||||||
--bs-success-border-subtle: #a3cfbb;
|
|
||||||
--bs-info-border-subtle: #9eeaf9;
|
|
||||||
--bs-warning-border-subtle: #ffe69c;
|
|
||||||
--bs-danger-border-subtle: #f1aeb5;
|
|
||||||
--bs-light-border-subtle: #e9ecef;
|
|
||||||
--bs-dark-border-subtle: #adb5bd;
|
|
||||||
--bs-white-rgb: 255, 255, 255;
|
|
||||||
--bs-black-rgb: 0, 0, 0;
|
|
||||||
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
|
||||||
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
||||||
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
|
|
||||||
--bs-body-font-family: var(--bs-font-sans-serif);
|
|
||||||
--bs-body-font-size: 1rem;
|
|
||||||
--bs-body-font-weight: 400;
|
|
||||||
--bs-body-line-height: 1.5;
|
|
||||||
--bs-body-color: #212529;
|
|
||||||
--bs-body-color-rgb: 33, 37, 41;
|
|
||||||
--bs-body-bg: #fff;
|
|
||||||
--bs-body-bg-rgb: 255, 255, 255;
|
|
||||||
--bs-emphasis-color: #000;
|
|
||||||
--bs-emphasis-color-rgb: 0, 0, 0;
|
|
||||||
--bs-secondary-color: rgba(33, 37, 41, 0.75);
|
|
||||||
--bs-secondary-color-rgb: 33, 37, 41;
|
|
||||||
--bs-secondary-bg: #e9ecef;
|
|
||||||
--bs-secondary-bg-rgb: 233, 236, 239;
|
|
||||||
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
|
|
||||||
--bs-tertiary-color-rgb: 33, 37, 41;
|
|
||||||
--bs-tertiary-bg: #f8f9fa;
|
|
||||||
--bs-tertiary-bg-rgb: 248, 249, 250;
|
|
||||||
--bs-heading-color: inherit;
|
|
||||||
--bs-link-color: #0d6efd;
|
|
||||||
--bs-link-color-rgb: 13, 110, 253;
|
|
||||||
--bs-link-decoration: underline;
|
|
||||||
--bs-link-hover-color: #0a58ca;
|
|
||||||
--bs-link-hover-color-rgb: 10, 88, 202;
|
|
||||||
--bs-code-color: #d63384;
|
|
||||||
--bs-highlight-color: #212529;
|
|
||||||
--bs-highlight-bg: #fff3cd;
|
|
||||||
--bs-border-width: 1px;
|
|
||||||
--bs-border-style: solid;
|
|
||||||
--bs-border-color: #dee2e6;
|
|
||||||
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
|
|
||||||
--bs-border-radius: 0.375rem;
|
|
||||||
--bs-border-radius-sm: 0.25rem;
|
|
||||||
--bs-border-radius-lg: 0.5rem;
|
|
||||||
--bs-border-radius-xl: 1rem;
|
|
||||||
--bs-border-radius-xxl: 2rem;
|
|
||||||
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
|
|
||||||
--bs-border-radius-pill: 50rem;
|
|
||||||
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
|
||||||
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
|
||||||
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
|
|
||||||
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
|
|
||||||
--bs-focus-ring-width: 0.25rem;
|
|
||||||
--bs-focus-ring-opacity: 0.25;
|
|
||||||
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
|
|
||||||
--bs-form-valid-color: #198754;
|
|
||||||
--bs-form-valid-border-color: #198754;
|
|
||||||
--bs-form-invalid-color: #dc3545;
|
|
||||||
--bs-form-invalid-border-color: #dc3545;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-bs-theme=dark] {
|
|
||||||
color-scheme: dark;
|
|
||||||
--bs-body-color: #dee2e6;
|
|
||||||
--bs-body-color-rgb: 222, 226, 230;
|
|
||||||
--bs-body-bg: #212529;
|
|
||||||
--bs-body-bg-rgb: 33, 37, 41;
|
|
||||||
--bs-emphasis-color: #fff;
|
|
||||||
--bs-emphasis-color-rgb: 255, 255, 255;
|
|
||||||
--bs-secondary-color: rgba(222, 226, 230, 0.75);
|
|
||||||
--bs-secondary-color-rgb: 222, 226, 230;
|
|
||||||
--bs-secondary-bg: #343a40;
|
|
||||||
--bs-secondary-bg-rgb: 52, 58, 64;
|
|
||||||
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
|
|
||||||
--bs-tertiary-color-rgb: 222, 226, 230;
|
|
||||||
--bs-tertiary-bg: #2b3035;
|
|
||||||
--bs-tertiary-bg-rgb: 43, 48, 53;
|
|
||||||
--bs-primary-text-emphasis: #6ea8fe;
|
|
||||||
--bs-secondary-text-emphasis: #a7acb1;
|
|
||||||
--bs-success-text-emphasis: #75b798;
|
|
||||||
--bs-info-text-emphasis: #6edff6;
|
|
||||||
--bs-warning-text-emphasis: #ffda6a;
|
|
||||||
--bs-danger-text-emphasis: #ea868f;
|
|
||||||
--bs-light-text-emphasis: #f8f9fa;
|
|
||||||
--bs-dark-text-emphasis: #dee2e6;
|
|
||||||
--bs-primary-bg-subtle: #031633;
|
|
||||||
--bs-secondary-bg-subtle: #161719;
|
|
||||||
--bs-success-bg-subtle: #051b11;
|
|
||||||
--bs-info-bg-subtle: #032830;
|
|
||||||
--bs-warning-bg-subtle: #332701;
|
|
||||||
--bs-danger-bg-subtle: #2c0b0e;
|
|
||||||
--bs-light-bg-subtle: #343a40;
|
|
||||||
--bs-dark-bg-subtle: #1a1d20;
|
|
||||||
--bs-primary-border-subtle: #084298;
|
|
||||||
--bs-secondary-border-subtle: #41464b;
|
|
||||||
--bs-success-border-subtle: #0f5132;
|
|
||||||
--bs-info-border-subtle: #087990;
|
|
||||||
--bs-warning-border-subtle: #997404;
|
|
||||||
--bs-danger-border-subtle: #842029;
|
|
||||||
--bs-light-border-subtle: #495057;
|
|
||||||
--bs-dark-border-subtle: #343a40;
|
|
||||||
--bs-heading-color: inherit;
|
|
||||||
--bs-link-color: #6ea8fe;
|
|
||||||
--bs-link-hover-color: #8bb9fe;
|
|
||||||
--bs-link-color-rgb: 110, 168, 254;
|
|
||||||
--bs-link-hover-color-rgb: 139, 185, 254;
|
|
||||||
--bs-code-color: #e685b5;
|
|
||||||
--bs-highlight-color: #dee2e6;
|
|
||||||
--bs-highlight-bg: #664d03;
|
|
||||||
--bs-border-color: #495057;
|
|
||||||
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
|
|
||||||
--bs-form-valid-color: #75b798;
|
|
||||||
--bs-form-valid-border-color: #75b798;
|
|
||||||
--bs-form-invalid-color: #ea868f;
|
|
||||||
--bs-form-invalid-border-color: #ea868f;
|
|
||||||
}
|
|
||||||
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
:root {
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--bs-body-font-family);
|
|
||||||
font-size: var(--bs-body-font-size);
|
|
||||||
font-weight: var(--bs-body-font-weight);
|
|
||||||
line-height: var(--bs-body-line-height);
|
|
||||||
color: var(--bs-body-color);
|
|
||||||
text-align: var(--bs-body-text-align);
|
|
||||||
background-color: var(--bs-body-bg);
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
margin: 1rem 0;
|
|
||||||
color: inherit;
|
|
||||||
border: 0;
|
|
||||||
border-top: var(--bs-border-width) solid;
|
|
||||||
opacity: 0.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
h6, h5, h4, h3, h2, h1 {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.2;
|
|
||||||
color: var(--bs-heading-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: calc(1.375rem + 1.5vw);
|
|
||||||
}
|
|
||||||
@media (min-width: 1200px) {
|
|
||||||
h1 {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: calc(1.325rem + 0.9vw);
|
|
||||||
}
|
|
||||||
@media (min-width: 1200px) {
|
|
||||||
h2 {
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: calc(1.3rem + 0.6vw);
|
|
||||||
}
|
|
||||||
@media (min-width: 1200px) {
|
|
||||||
h3 {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
font-size: calc(1.275rem + 0.3vw);
|
|
||||||
}
|
|
||||||
@media (min-width: 1200px) {
|
|
||||||
h4 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h5 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h6 {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
abbr[title] {
|
|
||||||
-webkit-text-decoration: underline dotted;
|
|
||||||
text-decoration: underline dotted;
|
|
||||||
cursor: help;
|
|
||||||
-webkit-text-decoration-skip-ink: none;
|
|
||||||
text-decoration-skip-ink: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
address {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
font-style: normal;
|
|
||||||
line-height: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
ol,
|
|
||||||
ul {
|
|
||||||
padding-left: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
ol,
|
|
||||||
ul,
|
|
||||||
dl {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
ol ol,
|
|
||||||
ul ul,
|
|
||||||
ol ul,
|
|
||||||
ul ol {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
dt {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
dd {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
blockquote {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
b,
|
|
||||||
strong {
|
|
||||||
font-weight: bolder;
|
|
||||||
}
|
|
||||||
|
|
||||||
small {
|
|
||||||
font-size: 0.875em;
|
|
||||||
}
|
|
||||||
|
|
||||||
mark {
|
|
||||||
padding: 0.1875em;
|
|
||||||
color: var(--bs-highlight-color);
|
|
||||||
background-color: var(--bs-highlight-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
sub,
|
|
||||||
sup {
|
|
||||||
position: relative;
|
|
||||||
font-size: 0.75em;
|
|
||||||
line-height: 0;
|
|
||||||
vertical-align: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
sub {
|
|
||||||
bottom: -0.25em;
|
|
||||||
}
|
|
||||||
|
|
||||||
sup {
|
|
||||||
top: -0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
|
|
||||||
}
|
|
||||||
|
|
||||||
a:not([href]):not([class]), a:not([href]):not([class]):hover {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre,
|
|
||||||
code,
|
|
||||||
kbd,
|
|
||||||
samp {
|
|
||||||
font-family: var(--bs-font-monospace);
|
|
||||||
font-size: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
display: block;
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
overflow: auto;
|
|
||||||
font-size: 0.875em;
|
|
||||||
}
|
|
||||||
pre code {
|
|
||||||
font-size: inherit;
|
|
||||||
color: inherit;
|
|
||||||
word-break: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
font-size: 0.875em;
|
|
||||||
color: var(--bs-code-color);
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
a > code {
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
kbd {
|
|
||||||
padding: 0.1875rem 0.375rem;
|
|
||||||
font-size: 0.875em;
|
|
||||||
color: var(--bs-body-bg);
|
|
||||||
background-color: var(--bs-body-color);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
kbd kbd {
|
|
||||||
padding: 0;
|
|
||||||
font-size: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
figure {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
img,
|
|
||||||
svg {
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
caption-side: bottom;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
caption {
|
|
||||||
padding-top: 0.5rem;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
color: var(--bs-secondary-color);
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
text-align: inherit;
|
|
||||||
text-align: -webkit-match-parent;
|
|
||||||
}
|
|
||||||
|
|
||||||
thead,
|
|
||||||
tbody,
|
|
||||||
tfoot,
|
|
||||||
tr,
|
|
||||||
td,
|
|
||||||
th {
|
|
||||||
border-color: inherit;
|
|
||||||
border-style: solid;
|
|
||||||
border-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:focus:not(:focus-visible) {
|
|
||||||
outline: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
|
||||||
button,
|
|
||||||
select,
|
|
||||||
optgroup,
|
|
||||||
textarea {
|
|
||||||
margin: 0;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: inherit;
|
|
||||||
line-height: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
button,
|
|
||||||
select {
|
|
||||||
text-transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
[role=button] {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
word-wrap: normal;
|
|
||||||
}
|
|
||||||
select:disabled {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
button,
|
|
||||||
[type=button],
|
|
||||||
[type=reset],
|
|
||||||
[type=submit] {
|
|
||||||
-webkit-appearance: button;
|
|
||||||
}
|
|
||||||
button:not(:disabled),
|
|
||||||
[type=button]:not(:disabled),
|
|
||||||
[type=reset]:not(:disabled),
|
|
||||||
[type=submit]:not(:disabled) {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-moz-focus-inner {
|
|
||||||
padding: 0;
|
|
||||||
border-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea {
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
fieldset {
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
legend {
|
|
||||||
float: left;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-size: calc(1.275rem + 0.3vw);
|
|
||||||
line-height: inherit;
|
|
||||||
}
|
|
||||||
@media (min-width: 1200px) {
|
|
||||||
legend {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
legend + * {
|
|
||||||
clear: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-datetime-edit-fields-wrapper,
|
|
||||||
::-webkit-datetime-edit-text,
|
|
||||||
::-webkit-datetime-edit-minute,
|
|
||||||
::-webkit-datetime-edit-hour-field,
|
|
||||||
::-webkit-datetime-edit-day-field,
|
|
||||||
::-webkit-datetime-edit-month-field,
|
|
||||||
::-webkit-datetime-edit-year-field {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-inner-spin-button {
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
[type=search] {
|
|
||||||
-webkit-appearance: textfield;
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* rtl:raw:
|
|
||||||
[type="tel"],
|
|
||||||
[type="url"],
|
|
||||||
[type="email"],
|
|
||||||
[type="number"] {
|
|
||||||
direction: ltr;
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
::-webkit-search-decoration {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-color-swatch-wrapper {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-file-upload-button {
|
|
||||||
font: inherit;
|
|
||||||
-webkit-appearance: button;
|
|
||||||
}
|
|
||||||
|
|
||||||
::file-selector-button {
|
|
||||||
font: inherit;
|
|
||||||
-webkit-appearance: button;
|
|
||||||
}
|
|
||||||
|
|
||||||
output {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
iframe {
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
summary {
|
|
||||||
display: list-item;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
progress {
|
|
||||||
vertical-align: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
[hidden] {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*# sourceMappingURL=bootstrap-reboot.css.map */
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,594 +0,0 @@
|
|||||||
/*!
|
|
||||||
* Bootstrap Reboot v5.3.3 (https://getbootstrap.com/)
|
|
||||||
* Copyright 2011-2024 The Bootstrap Authors
|
|
||||||
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
|
|
||||||
*/
|
|
||||||
:root,
|
|
||||||
[data-bs-theme=light] {
|
|
||||||
--bs-blue: #0d6efd;
|
|
||||||
--bs-indigo: #6610f2;
|
|
||||||
--bs-purple: #6f42c1;
|
|
||||||
--bs-pink: #d63384;
|
|
||||||
--bs-red: #dc3545;
|
|
||||||
--bs-orange: #fd7e14;
|
|
||||||
--bs-yellow: #ffc107;
|
|
||||||
--bs-green: #198754;
|
|
||||||
--bs-teal: #20c997;
|
|
||||||
--bs-cyan: #0dcaf0;
|
|
||||||
--bs-black: #000;
|
|
||||||
--bs-white: #fff;
|
|
||||||
--bs-gray: #6c757d;
|
|
||||||
--bs-gray-dark: #343a40;
|
|
||||||
--bs-gray-100: #f8f9fa;
|
|
||||||
--bs-gray-200: #e9ecef;
|
|
||||||
--bs-gray-300: #dee2e6;
|
|
||||||
--bs-gray-400: #ced4da;
|
|
||||||
--bs-gray-500: #adb5bd;
|
|
||||||
--bs-gray-600: #6c757d;
|
|
||||||
--bs-gray-700: #495057;
|
|
||||||
--bs-gray-800: #343a40;
|
|
||||||
--bs-gray-900: #212529;
|
|
||||||
--bs-primary: #0d6efd;
|
|
||||||
--bs-secondary: #6c757d;
|
|
||||||
--bs-success: #198754;
|
|
||||||
--bs-info: #0dcaf0;
|
|
||||||
--bs-warning: #ffc107;
|
|
||||||
--bs-danger: #dc3545;
|
|
||||||
--bs-light: #f8f9fa;
|
|
||||||
--bs-dark: #212529;
|
|
||||||
--bs-primary-rgb: 13, 110, 253;
|
|
||||||
--bs-secondary-rgb: 108, 117, 125;
|
|
||||||
--bs-success-rgb: 25, 135, 84;
|
|
||||||
--bs-info-rgb: 13, 202, 240;
|
|
||||||
--bs-warning-rgb: 255, 193, 7;
|
|
||||||
--bs-danger-rgb: 220, 53, 69;
|
|
||||||
--bs-light-rgb: 248, 249, 250;
|
|
||||||
--bs-dark-rgb: 33, 37, 41;
|
|
||||||
--bs-primary-text-emphasis: #052c65;
|
|
||||||
--bs-secondary-text-emphasis: #2b2f32;
|
|
||||||
--bs-success-text-emphasis: #0a3622;
|
|
||||||
--bs-info-text-emphasis: #055160;
|
|
||||||
--bs-warning-text-emphasis: #664d03;
|
|
||||||
--bs-danger-text-emphasis: #58151c;
|
|
||||||
--bs-light-text-emphasis: #495057;
|
|
||||||
--bs-dark-text-emphasis: #495057;
|
|
||||||
--bs-primary-bg-subtle: #cfe2ff;
|
|
||||||
--bs-secondary-bg-subtle: #e2e3e5;
|
|
||||||
--bs-success-bg-subtle: #d1e7dd;
|
|
||||||
--bs-info-bg-subtle: #cff4fc;
|
|
||||||
--bs-warning-bg-subtle: #fff3cd;
|
|
||||||
--bs-danger-bg-subtle: #f8d7da;
|
|
||||||
--bs-light-bg-subtle: #fcfcfd;
|
|
||||||
--bs-dark-bg-subtle: #ced4da;
|
|
||||||
--bs-primary-border-subtle: #9ec5fe;
|
|
||||||
--bs-secondary-border-subtle: #c4c8cb;
|
|
||||||
--bs-success-border-subtle: #a3cfbb;
|
|
||||||
--bs-info-border-subtle: #9eeaf9;
|
|
||||||
--bs-warning-border-subtle: #ffe69c;
|
|
||||||
--bs-danger-border-subtle: #f1aeb5;
|
|
||||||
--bs-light-border-subtle: #e9ecef;
|
|
||||||
--bs-dark-border-subtle: #adb5bd;
|
|
||||||
--bs-white-rgb: 255, 255, 255;
|
|
||||||
--bs-black-rgb: 0, 0, 0;
|
|
||||||
--bs-font-sans-serif: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", "Noto Sans", "Liberation Sans", Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
|
||||||
--bs-font-monospace: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
|
||||||
--bs-gradient: linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));
|
|
||||||
--bs-body-font-family: var(--bs-font-sans-serif);
|
|
||||||
--bs-body-font-size: 1rem;
|
|
||||||
--bs-body-font-weight: 400;
|
|
||||||
--bs-body-line-height: 1.5;
|
|
||||||
--bs-body-color: #212529;
|
|
||||||
--bs-body-color-rgb: 33, 37, 41;
|
|
||||||
--bs-body-bg: #fff;
|
|
||||||
--bs-body-bg-rgb: 255, 255, 255;
|
|
||||||
--bs-emphasis-color: #000;
|
|
||||||
--bs-emphasis-color-rgb: 0, 0, 0;
|
|
||||||
--bs-secondary-color: rgba(33, 37, 41, 0.75);
|
|
||||||
--bs-secondary-color-rgb: 33, 37, 41;
|
|
||||||
--bs-secondary-bg: #e9ecef;
|
|
||||||
--bs-secondary-bg-rgb: 233, 236, 239;
|
|
||||||
--bs-tertiary-color: rgba(33, 37, 41, 0.5);
|
|
||||||
--bs-tertiary-color-rgb: 33, 37, 41;
|
|
||||||
--bs-tertiary-bg: #f8f9fa;
|
|
||||||
--bs-tertiary-bg-rgb: 248, 249, 250;
|
|
||||||
--bs-heading-color: inherit;
|
|
||||||
--bs-link-color: #0d6efd;
|
|
||||||
--bs-link-color-rgb: 13, 110, 253;
|
|
||||||
--bs-link-decoration: underline;
|
|
||||||
--bs-link-hover-color: #0a58ca;
|
|
||||||
--bs-link-hover-color-rgb: 10, 88, 202;
|
|
||||||
--bs-code-color: #d63384;
|
|
||||||
--bs-highlight-color: #212529;
|
|
||||||
--bs-highlight-bg: #fff3cd;
|
|
||||||
--bs-border-width: 1px;
|
|
||||||
--bs-border-style: solid;
|
|
||||||
--bs-border-color: #dee2e6;
|
|
||||||
--bs-border-color-translucent: rgba(0, 0, 0, 0.175);
|
|
||||||
--bs-border-radius: 0.375rem;
|
|
||||||
--bs-border-radius-sm: 0.25rem;
|
|
||||||
--bs-border-radius-lg: 0.5rem;
|
|
||||||
--bs-border-radius-xl: 1rem;
|
|
||||||
--bs-border-radius-xxl: 2rem;
|
|
||||||
--bs-border-radius-2xl: var(--bs-border-radius-xxl);
|
|
||||||
--bs-border-radius-pill: 50rem;
|
|
||||||
--bs-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
|
||||||
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
|
||||||
--bs-box-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
|
|
||||||
--bs-box-shadow-inset: inset 0 1px 2px rgba(0, 0, 0, 0.075);
|
|
||||||
--bs-focus-ring-width: 0.25rem;
|
|
||||||
--bs-focus-ring-opacity: 0.25;
|
|
||||||
--bs-focus-ring-color: rgba(13, 110, 253, 0.25);
|
|
||||||
--bs-form-valid-color: #198754;
|
|
||||||
--bs-form-valid-border-color: #198754;
|
|
||||||
--bs-form-invalid-color: #dc3545;
|
|
||||||
--bs-form-invalid-border-color: #dc3545;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-bs-theme=dark] {
|
|
||||||
color-scheme: dark;
|
|
||||||
--bs-body-color: #dee2e6;
|
|
||||||
--bs-body-color-rgb: 222, 226, 230;
|
|
||||||
--bs-body-bg: #212529;
|
|
||||||
--bs-body-bg-rgb: 33, 37, 41;
|
|
||||||
--bs-emphasis-color: #fff;
|
|
||||||
--bs-emphasis-color-rgb: 255, 255, 255;
|
|
||||||
--bs-secondary-color: rgba(222, 226, 230, 0.75);
|
|
||||||
--bs-secondary-color-rgb: 222, 226, 230;
|
|
||||||
--bs-secondary-bg: #343a40;
|
|
||||||
--bs-secondary-bg-rgb: 52, 58, 64;
|
|
||||||
--bs-tertiary-color: rgba(222, 226, 230, 0.5);
|
|
||||||
--bs-tertiary-color-rgb: 222, 226, 230;
|
|
||||||
--bs-tertiary-bg: #2b3035;
|
|
||||||
--bs-tertiary-bg-rgb: 43, 48, 53;
|
|
||||||
--bs-primary-text-emphasis: #6ea8fe;
|
|
||||||
--bs-secondary-text-emphasis: #a7acb1;
|
|
||||||
--bs-success-text-emphasis: #75b798;
|
|
||||||
--bs-info-text-emphasis: #6edff6;
|
|
||||||
--bs-warning-text-emphasis: #ffda6a;
|
|
||||||
--bs-danger-text-emphasis: #ea868f;
|
|
||||||
--bs-light-text-emphasis: #f8f9fa;
|
|
||||||
--bs-dark-text-emphasis: #dee2e6;
|
|
||||||
--bs-primary-bg-subtle: #031633;
|
|
||||||
--bs-secondary-bg-subtle: #161719;
|
|
||||||
--bs-success-bg-subtle: #051b11;
|
|
||||||
--bs-info-bg-subtle: #032830;
|
|
||||||
--bs-warning-bg-subtle: #332701;
|
|
||||||
--bs-danger-bg-subtle: #2c0b0e;
|
|
||||||
--bs-light-bg-subtle: #343a40;
|
|
||||||
--bs-dark-bg-subtle: #1a1d20;
|
|
||||||
--bs-primary-border-subtle: #084298;
|
|
||||||
--bs-secondary-border-subtle: #41464b;
|
|
||||||
--bs-success-border-subtle: #0f5132;
|
|
||||||
--bs-info-border-subtle: #087990;
|
|
||||||
--bs-warning-border-subtle: #997404;
|
|
||||||
--bs-danger-border-subtle: #842029;
|
|
||||||
--bs-light-border-subtle: #495057;
|
|
||||||
--bs-dark-border-subtle: #343a40;
|
|
||||||
--bs-heading-color: inherit;
|
|
||||||
--bs-link-color: #6ea8fe;
|
|
||||||
--bs-link-hover-color: #8bb9fe;
|
|
||||||
--bs-link-color-rgb: 110, 168, 254;
|
|
||||||
--bs-link-hover-color-rgb: 139, 185, 254;
|
|
||||||
--bs-code-color: #e685b5;
|
|
||||||
--bs-highlight-color: #dee2e6;
|
|
||||||
--bs-highlight-bg: #664d03;
|
|
||||||
--bs-border-color: #495057;
|
|
||||||
--bs-border-color-translucent: rgba(255, 255, 255, 0.15);
|
|
||||||
--bs-form-valid-color: #75b798;
|
|
||||||
--bs-form-valid-border-color: #75b798;
|
|
||||||
--bs-form-invalid-color: #ea868f;
|
|
||||||
--bs-form-invalid-border-color: #ea868f;
|
|
||||||
}
|
|
||||||
|
|
||||||
*,
|
|
||||||
*::before,
|
|
||||||
*::after {
|
|
||||||
box-sizing: border-box;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: no-preference) {
|
|
||||||
:root {
|
|
||||||
scroll-behavior: smooth;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--bs-body-font-family);
|
|
||||||
font-size: var(--bs-body-font-size);
|
|
||||||
font-weight: var(--bs-body-font-weight);
|
|
||||||
line-height: var(--bs-body-line-height);
|
|
||||||
color: var(--bs-body-color);
|
|
||||||
text-align: var(--bs-body-text-align);
|
|
||||||
background-color: var(--bs-body-bg);
|
|
||||||
-webkit-text-size-adjust: 100%;
|
|
||||||
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
hr {
|
|
||||||
margin: 1rem 0;
|
|
||||||
color: inherit;
|
|
||||||
border: 0;
|
|
||||||
border-top: var(--bs-border-width) solid;
|
|
||||||
opacity: 0.25;
|
|
||||||
}
|
|
||||||
|
|
||||||
h6, h5, h4, h3, h2, h1 {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-weight: 500;
|
|
||||||
line-height: 1.2;
|
|
||||||
color: var(--bs-heading-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
h1 {
|
|
||||||
font-size: calc(1.375rem + 1.5vw);
|
|
||||||
}
|
|
||||||
@media (min-width: 1200px) {
|
|
||||||
h1 {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: calc(1.325rem + 0.9vw);
|
|
||||||
}
|
|
||||||
@media (min-width: 1200px) {
|
|
||||||
h2 {
|
|
||||||
font-size: 2rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-size: calc(1.3rem + 0.6vw);
|
|
||||||
}
|
|
||||||
@media (min-width: 1200px) {
|
|
||||||
h3 {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h4 {
|
|
||||||
font-size: calc(1.275rem + 0.3vw);
|
|
||||||
}
|
|
||||||
@media (min-width: 1200px) {
|
|
||||||
h4 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h5 {
|
|
||||||
font-size: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h6 {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
abbr[title] {
|
|
||||||
-webkit-text-decoration: underline dotted;
|
|
||||||
text-decoration: underline dotted;
|
|
||||||
cursor: help;
|
|
||||||
-webkit-text-decoration-skip-ink: none;
|
|
||||||
text-decoration-skip-ink: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
address {
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
font-style: normal;
|
|
||||||
line-height: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
ol,
|
|
||||||
ul {
|
|
||||||
padding-right: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
ol,
|
|
||||||
ul,
|
|
||||||
dl {
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
ol ol,
|
|
||||||
ul ul,
|
|
||||||
ol ul,
|
|
||||||
ul ol {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
dt {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
dd {
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
blockquote {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
b,
|
|
||||||
strong {
|
|
||||||
font-weight: bolder;
|
|
||||||
}
|
|
||||||
|
|
||||||
small {
|
|
||||||
font-size: 0.875em;
|
|
||||||
}
|
|
||||||
|
|
||||||
mark {
|
|
||||||
padding: 0.1875em;
|
|
||||||
color: var(--bs-highlight-color);
|
|
||||||
background-color: var(--bs-highlight-bg);
|
|
||||||
}
|
|
||||||
|
|
||||||
sub,
|
|
||||||
sup {
|
|
||||||
position: relative;
|
|
||||||
font-size: 0.75em;
|
|
||||||
line-height: 0;
|
|
||||||
vertical-align: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
sub {
|
|
||||||
bottom: -0.25em;
|
|
||||||
}
|
|
||||||
|
|
||||||
sup {
|
|
||||||
top: -0.5em;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 1));
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
a:hover {
|
|
||||||
--bs-link-color-rgb: var(--bs-link-hover-color-rgb);
|
|
||||||
}
|
|
||||||
|
|
||||||
a:not([href]):not([class]), a:not([href]):not([class]):hover {
|
|
||||||
color: inherit;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre,
|
|
||||||
code,
|
|
||||||
kbd,
|
|
||||||
samp {
|
|
||||||
font-family: var(--bs-font-monospace);
|
|
||||||
font-size: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
display: block;
|
|
||||||
margin-top: 0;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
overflow: auto;
|
|
||||||
font-size: 0.875em;
|
|
||||||
}
|
|
||||||
pre code {
|
|
||||||
font-size: inherit;
|
|
||||||
color: inherit;
|
|
||||||
word-break: normal;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
font-size: 0.875em;
|
|
||||||
color: var(--bs-code-color);
|
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
|
||||||
a > code {
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
kbd {
|
|
||||||
padding: 0.1875rem 0.375rem;
|
|
||||||
font-size: 0.875em;
|
|
||||||
color: var(--bs-body-bg);
|
|
||||||
background-color: var(--bs-body-color);
|
|
||||||
border-radius: 0.25rem;
|
|
||||||
}
|
|
||||||
kbd kbd {
|
|
||||||
padding: 0;
|
|
||||||
font-size: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
figure {
|
|
||||||
margin: 0 0 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
img,
|
|
||||||
svg {
|
|
||||||
vertical-align: middle;
|
|
||||||
}
|
|
||||||
|
|
||||||
table {
|
|
||||||
caption-side: bottom;
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
caption {
|
|
||||||
padding-top: 0.5rem;
|
|
||||||
padding-bottom: 0.5rem;
|
|
||||||
color: var(--bs-secondary-color);
|
|
||||||
text-align: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
th {
|
|
||||||
text-align: inherit;
|
|
||||||
text-align: -webkit-match-parent;
|
|
||||||
}
|
|
||||||
|
|
||||||
thead,
|
|
||||||
tbody,
|
|
||||||
tfoot,
|
|
||||||
tr,
|
|
||||||
td,
|
|
||||||
th {
|
|
||||||
border-color: inherit;
|
|
||||||
border-style: solid;
|
|
||||||
border-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
label {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
button:focus:not(:focus-visible) {
|
|
||||||
outline: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
input,
|
|
||||||
button,
|
|
||||||
select,
|
|
||||||
optgroup,
|
|
||||||
textarea {
|
|
||||||
margin: 0;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: inherit;
|
|
||||||
line-height: inherit;
|
|
||||||
}
|
|
||||||
|
|
||||||
button,
|
|
||||||
select {
|
|
||||||
text-transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
[role=button] {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
word-wrap: normal;
|
|
||||||
}
|
|
||||||
select:disabled {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
button,
|
|
||||||
[type=button],
|
|
||||||
[type=reset],
|
|
||||||
[type=submit] {
|
|
||||||
-webkit-appearance: button;
|
|
||||||
}
|
|
||||||
button:not(:disabled),
|
|
||||||
[type=button]:not(:disabled),
|
|
||||||
[type=reset]:not(:disabled),
|
|
||||||
[type=submit]:not(:disabled) {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-moz-focus-inner {
|
|
||||||
padding: 0;
|
|
||||||
border-style: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
textarea {
|
|
||||||
resize: vertical;
|
|
||||||
}
|
|
||||||
|
|
||||||
fieldset {
|
|
||||||
min-width: 0;
|
|
||||||
padding: 0;
|
|
||||||
margin: 0;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
legend {
|
|
||||||
float: right;
|
|
||||||
width: 100%;
|
|
||||||
padding: 0;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
font-size: calc(1.275rem + 0.3vw);
|
|
||||||
line-height: inherit;
|
|
||||||
}
|
|
||||||
@media (min-width: 1200px) {
|
|
||||||
legend {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
legend + * {
|
|
||||||
clear: right;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-datetime-edit-fields-wrapper,
|
|
||||||
::-webkit-datetime-edit-text,
|
|
||||||
::-webkit-datetime-edit-minute,
|
|
||||||
::-webkit-datetime-edit-hour-field,
|
|
||||||
::-webkit-datetime-edit-day-field,
|
|
||||||
::-webkit-datetime-edit-month-field,
|
|
||||||
::-webkit-datetime-edit-year-field {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-inner-spin-button {
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
[type=search] {
|
|
||||||
-webkit-appearance: textfield;
|
|
||||||
outline-offset: -2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
[type="tel"],
|
|
||||||
[type="url"],
|
|
||||||
[type="email"],
|
|
||||||
[type="number"] {
|
|
||||||
direction: ltr;
|
|
||||||
}
|
|
||||||
::-webkit-search-decoration {
|
|
||||||
-webkit-appearance: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-color-swatch-wrapper {
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
::-webkit-file-upload-button {
|
|
||||||
font: inherit;
|
|
||||||
-webkit-appearance: button;
|
|
||||||
}
|
|
||||||
|
|
||||||
::file-selector-button {
|
|
||||||
font: inherit;
|
|
||||||
-webkit-appearance: button;
|
|
||||||
}
|
|
||||||
|
|
||||||
output {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
iframe {
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
summary {
|
|
||||||
display: list-item;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
progress {
|
|
||||||
vertical-align: baseline;
|
|
||||||
}
|
|
||||||
|
|
||||||
[hidden] {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
/*# sourceMappingURL=bootstrap-reboot.rtl.css.map */
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
12057
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap.css
vendored
12057
PlaylistShared/wwwroot/lib/bootstrap/dist/css/bootstrap.css
vendored
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,6 +1,4 @@
|
|||||||
using YandexMusic.API.Common;
|
|
||||||
using YandexMusic.API.Models.Album;
|
using YandexMusic.API.Models.Album;
|
||||||
using YandexMusic.API.Models.Common;
|
|
||||||
using YandexMusic.API.Requests.Album;
|
using YandexMusic.API.Requests.Album;
|
||||||
|
|
||||||
namespace YandexMusic.API;
|
namespace YandexMusic.API;
|
||||||
@@ -8,21 +6,13 @@ namespace YandexMusic.API;
|
|||||||
/// <summary>API для работы с альбомами.</summary>
|
/// <summary>API для работы с альбомами.</summary>
|
||||||
public class YAlbumAPI : YCommonAPI
|
public class YAlbumAPI : YCommonAPI
|
||||||
{
|
{
|
||||||
/// <summary>Инициализирует новый экземпляр API альбомов.</summary>
|
public YAlbumAPI(YandexMusicApi api) : base(api) { }
|
||||||
/// <param name="yandex">Экземпляр основного API.</param>
|
|
||||||
public YAlbumAPI(YandexMusicApi yandex) : base(yandex) { }
|
|
||||||
|
|
||||||
/// <summary>Получает альбом по идентификатору.</summary>
|
/// <summary>Получает альбом по идентификатору.</summary>
|
||||||
/// <param name="storage">Хранилище данных авторизации.</param>
|
public Task<YAlbum?> GetAsync(string albumId)
|
||||||
/// <param name="albumId">Идентификатор альбома.</param>
|
=> new YGetAlbumBuilder(Api).ExecuteAsync(albumId);
|
||||||
/// <returns>Ответ API с моделью альбома.</returns>
|
|
||||||
public Task<YResponse<YAlbum>> GetAsync(AuthStorage storage, string albumId)
|
|
||||||
=> new YGetAlbumBuilder(api, storage).Build(albumId).GetResponseAsync();
|
|
||||||
|
|
||||||
/// <summary>Получает несколько альбомов по списку идентификаторов.</summary>
|
/// <summary>Получает несколько альбомов по списку идентификаторов.</summary>
|
||||||
/// <param name="storage">Хранилище данных авторизации.</param>
|
public Task<List<YAlbum>?> GetAsync(IEnumerable<string> albumIds)
|
||||||
/// <param name="albumIds">Список идентификаторов альбомов.</param>
|
=> new YGetAlbumsBuilder(Api).ExecuteAsync(albumIds);
|
||||||
/// <returns>Ответ API со списком альбомов.</returns>
|
|
||||||
public Task<YResponse<List<YAlbum>>> GetAsync(AuthStorage storage, IEnumerable<string> albumIds)
|
|
||||||
=> new YGetAlbumsBuilder(api, storage).Build(albumIds).GetResponseAsync();
|
|
||||||
}
|
}
|
||||||
@@ -1,71 +1,27 @@
|
|||||||
using YandexMusic.API.Common;
|
|
||||||
using YandexMusic.API.Models.Artist;
|
using YandexMusic.API.Models.Artist;
|
||||||
using YandexMusic.API.Models.Common;
|
|
||||||
using YandexMusic.API.Requests.Artist;
|
using YandexMusic.API.Requests.Artist;
|
||||||
|
|
||||||
namespace YandexMusic.API;
|
namespace YandexMusic.API;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>API для работы с исполнителями.</summary>
|
||||||
/// API для взаимодействия с исполнителями
|
|
||||||
/// </summary>
|
|
||||||
public class YArtistAPI : YCommonAPI
|
public class YArtistAPI : YCommonAPI
|
||||||
{
|
{
|
||||||
public YArtistAPI(YandexMusicApi yandex) : base(yandex)
|
public YArtistAPI(YandexMusicApi api) : base(api) { }
|
||||||
|
|
||||||
|
public Task<YArtistBriefInfo?> GetAsync(string artistId)
|
||||||
|
=> new YGetArtistBuilder(Api).ExecuteAsync(artistId);
|
||||||
|
|
||||||
|
public Task<List<YArtist>?> GetAsync(IEnumerable<string> artistIds)
|
||||||
|
=> new YGetArtistsBuilder(Api).ExecuteAsync(artistIds);
|
||||||
|
|
||||||
|
public Task<YTracksPage?> GetTracksAsync(string artistId, int page = 0, int pageSize = 20)
|
||||||
|
=> new YGetArtistTrackBuilder(Api).ExecuteAsync((artistId, page, pageSize));
|
||||||
|
|
||||||
|
public async Task<YTracksPage?> GetAllTracksAsync(string artistId)
|
||||||
{
|
{
|
||||||
|
var info = await GetAsync(artistId);
|
||||||
|
if (info?.Artist?.Counts?.Tracks == null)
|
||||||
|
return null;
|
||||||
|
return await GetTracksAsync(artistId, pageSize: info.Artist.Counts.Tracks);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Получение исполнителя
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="artistId">Идентификатор</param>
|
|
||||||
public Task<YResponse<YArtistBriefInfo>> GetAsync(AuthStorage storage, string artistId)
|
|
||||||
{
|
|
||||||
return new YGetArtistBuilder(api, storage)
|
|
||||||
.Build(artistId)
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Получение исполнителей
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="artistIds">Идентификаторы</param>
|
|
||||||
public Task<YResponse<List<YArtist>>> GetAsync(AuthStorage storage, IEnumerable<string> artistIds)
|
|
||||||
{
|
|
||||||
return new YGetArtistsBuilder(api, storage)
|
|
||||||
.Build(artistIds)
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Получение треков исполнителя с пагинацией
|
|
||||||
/// <remarks>
|
|
||||||
/// Треки поставляются по <paramref name="pageSize"/> штук на страницу,
|
|
||||||
/// для получения всех треков необходимо использовать метод <see cref="GetAllTracksAsync"/>
|
|
||||||
/// </remarks>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="artistId">Идентификатор исполнителя</param>
|
|
||||||
/// <param name="page">Страница ответов</param>
|
|
||||||
/// <param name="pageSize">Количество треков на странице ответов</param>
|
|
||||||
public Task<YResponse<YTracksPage>> GetTracksAsync(AuthStorage storage, string artistId, int page = 0, int pageSize = 20)
|
|
||||||
{
|
|
||||||
return new YGetArtistTrackBuilder(api, storage)
|
|
||||||
.Build((artistId, page, pageSize))
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Получение всех треков исполнителя
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="artistId">Идентификатор исполнителя</param>
|
|
||||||
public async Task<YResponse<YTracksPage>> GetAllTracksAsync(AuthStorage storage, string artistId)
|
|
||||||
{
|
|
||||||
YResponse<YArtistBriefInfo> response = await GetAsync(storage, artistId);
|
|
||||||
return await GetTracksAsync(storage, artistId, pageSize: response.Result.Artist.Counts.Tracks);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
namespace YandexMusic.API;
|
namespace YandexMusic.API;
|
||||||
|
|
||||||
/// <summary>Родительский класс для всех веток API.</summary>
|
/// <summary>Базовый класс для всех веток API.</summary>
|
||||||
public abstract class YCommonAPI
|
public abstract class YCommonAPI
|
||||||
{
|
{
|
||||||
/// <summary>Основной экземпляр API.</summary>
|
/// <summary>Основной экземпляр API.</summary>
|
||||||
protected readonly YandexMusicApi api;
|
protected YandexMusicApi Api { get; }
|
||||||
|
|
||||||
/// <summary>Инициализирует новый экземпляр.</summary>
|
protected YCommonAPI(YandexMusicApi api) => Api = api;
|
||||||
/// <param name="yandex">Экземпляр основного API.</param>
|
|
||||||
protected YCommonAPI(YandexMusicApi yandex) => api = yandex;
|
|
||||||
}
|
}
|
||||||
@@ -1,39 +1,19 @@
|
|||||||
using YandexMusic.API.Common;
|
|
||||||
using YandexMusic.API.Models.Common;
|
using YandexMusic.API.Models.Common;
|
||||||
using YandexMusic.API.Models.Label;
|
using YandexMusic.API.Models.Label;
|
||||||
using YandexMusic.API.Requests.Label;
|
using YandexMusic.API.Requests.Label;
|
||||||
|
|
||||||
namespace YandexMusic.API;
|
namespace YandexMusic.API;
|
||||||
|
|
||||||
public partial class YLabelAPI : YCommonAPI
|
/// <summary>API для работы с лейблами.</summary>
|
||||||
|
public class YLabelAPI : YCommonAPI
|
||||||
{
|
{
|
||||||
public YLabelAPI(YandexMusicApi yandex) : base(yandex)
|
public YLabelAPI(YandexMusicApi api) : base(api) { }
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Получает альбомы лейбла с пагинацией.</summary>
|
||||||
/// Постраничное получение альбомов лейбла
|
public Task<YLabelAlbums?> GetAlbumsByLabelAsync(YLabel label, int page = 0)
|
||||||
/// </summary>
|
=> new YGetLabelAlbumsBuilder(Api).ExecuteAsync((label, page));
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="label">Лейбл</param>
|
|
||||||
/// <param name="page">Страница</param>
|
|
||||||
public Task<YResponse<YLabelAlbums>> GetAlbumsByLabelAsync(AuthStorage storage, YLabel label, int page)
|
|
||||||
{
|
|
||||||
return new YGetLabelAlbumsBuilder(api, storage)
|
|
||||||
.Build((label, page))
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>Получает артистов лейбла с пагинацией.</summary>
|
||||||
/// Постраничное получение артистов лейбла
|
public Task<YLabelArtists?> GetArtistsByLabelAsync(YLabel label, int page = 0)
|
||||||
/// </summary>
|
=> new YGetLabelArtistsBuilder(Api).ExecuteAsync((label, page));
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="label">Лейбл</param>
|
|
||||||
/// <param name="page">Страница</param>
|
|
||||||
public Task<YResponse<YLabelArtists>> GetArtistsByLabelAsync(AuthStorage storage, YLabel label, int page)
|
|
||||||
{
|
|
||||||
return new YGetLabelArtistsBuilder(api, storage)
|
|
||||||
.Build((label, page))
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,43 +1,21 @@
|
|||||||
using YandexMusic.API.Common;
|
using YandexMusic.API.Models.Feed;
|
||||||
using YandexMusic.API.Models.Common;
|
|
||||||
using YandexMusic.API.Models.Feed;
|
|
||||||
using YandexMusic.API.Models.Landing;
|
using YandexMusic.API.Models.Landing;
|
||||||
using YandexMusic.API.Requests.Feed;
|
using YandexMusic.API.Requests.Feed;
|
||||||
using YandexMusic.API.Requests.Landing;
|
using YandexMusic.API.Requests.Landing;
|
||||||
|
|
||||||
namespace YandexMusic.API;
|
namespace YandexMusic.API;
|
||||||
|
|
||||||
/// <summary>API для взаимодействия с главной страницей (лендингом).</summary>
|
/// <summary>API для работы с главной страницей (лендингом).</summary>
|
||||||
public class YLandingAPI : YCommonAPI
|
public class YLandingAPI : YCommonAPI
|
||||||
{
|
{
|
||||||
/// <summary>Инициализирует новый экземпляр API лендинга.</summary>
|
public YLandingAPI(YandexMusicApi api) : base(api) { }
|
||||||
/// <param name="yandex">Экземпляр основного API.</param>
|
|
||||||
public YLandingAPI(YandexMusicApi yandex) : base(yandex) { }
|
|
||||||
|
|
||||||
/// <summary>Получает персональные блоки лендинга.</summary>
|
public Task<YLanding?> GetAsync(params YLandingBlockType[] blocks)
|
||||||
/// <param name="storage">Хранилище авторизации.</param>
|
=> new YGetLandingBuilder(Api).ExecuteAsync(blocks);
|
||||||
/// <param name="blocks">Типы запрашиваемых блоков.</param>
|
|
||||||
/// <returns>Ответ API с лендингом.</returns>
|
|
||||||
/// <exception cref="ArgumentNullException">Если массив blocks равен null.</exception>
|
|
||||||
public Task<YResponse<YLanding>> GetAsync(AuthStorage storage, params YLandingBlockType[] blocks)
|
|
||||||
{
|
|
||||||
if (blocks == null)
|
|
||||||
throw new ArgumentNullException(nameof(blocks), "Массив блоков не может быть null");
|
|
||||||
|
|
||||||
return new YGetLandingBuilder(api, storage)
|
public Task<YFeed?> GetFeedAsync()
|
||||||
.Build(blocks)
|
=> new YGetFeedBuilder(Api).ExecuteAsync(null!);
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Получает ленту событий (фид).</summary>
|
public Task<YChildrenLanding?> GetChildrenLandingAsync()
|
||||||
/// <param name="storage">Хранилище авторизации.</param>
|
=> new YGetChildrenLandingBuilder(Api).ExecuteAsync(null!);
|
||||||
/// <returns>Ответ API с лентой.</returns>
|
|
||||||
public Task<YResponse<YFeed>> GetFeedAsync(AuthStorage storage)
|
|
||||||
=> new YGetFeedBuilder(api, storage).Build(null!).GetResponseAsync();
|
|
||||||
|
|
||||||
/// <summary>Получает лендинг детского раздела.</summary>
|
|
||||||
/// <param name="storage">Хранилище авторизации.</param>
|
|
||||||
/// <returns>Ответ API с детским лендингом.</returns>
|
|
||||||
public Task<YResponse<YChildrenLanding>> GetChildrenLandingAsync(AuthStorage storage)
|
|
||||||
=> new YGetChildrenLandingBuilder(api, storage).Build(null!).GetResponseAsync();
|
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
using YandexMusic.API.Common;
|
|
||||||
using YandexMusic.API.Models.Album;
|
using YandexMusic.API.Models.Album;
|
||||||
using YandexMusic.API.Models.Artist;
|
using YandexMusic.API.Models.Artist;
|
||||||
using YandexMusic.API.Models.Common;
|
using YandexMusic.API.Models.Common;
|
||||||
@@ -10,244 +9,82 @@ using YandexMusic.API.Requests.Library;
|
|||||||
|
|
||||||
namespace YandexMusic.API;
|
namespace YandexMusic.API;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>API для работы с библиотекой (лайки, дизлайки, недавно прослушанное).</summary>
|
||||||
/// API для взаимодействия с библиотекой
|
public class YLibraryAPI : YCommonAPI
|
||||||
/// </summary>
|
|
||||||
public partial class YLibraryAPI : YCommonAPI
|
|
||||||
{
|
{
|
||||||
/// <summary>
|
public YLibraryAPI(YandexMusicApi api) : base(api) { }
|
||||||
/// Получение секции библиотеки
|
|
||||||
/// </summary>
|
|
||||||
/// <typeparam name="T">Тип объекта библиотеки</typeparam>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="section">Секция</param>
|
|
||||||
/// <param name="type">Тип</param>
|
|
||||||
/// <returns>Список объектов из секции</returns>
|
|
||||||
private Task<YResponse<T>> GetLibrarySection<T>(AuthStorage storage, YLibrarySection section, YLibrarySectionType type = YLibrarySectionType.Likes)
|
|
||||||
{
|
|
||||||
return new YGetLibrarySectionBuilder<T>(api, storage)
|
|
||||||
.Build((section, type))
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public YLibraryAPI(YandexMusicApi yandex) : base(yandex)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
#region Лайки
|
#region Лайки
|
||||||
|
|
||||||
/// <summary>
|
public Task<YLibraryTracks?> GetLikedTracksAsync()
|
||||||
/// Получение лайкнутых треков
|
=> new YGetLibrarySectionBuilder<YLibraryTracks>(Api).ExecuteAsync((YLibrarySection.Tracks, YLibrarySectionType.Likes));
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<YLibraryTracks>> GetLikedTracksAsync(AuthStorage storage)
|
|
||||||
{
|
|
||||||
return GetLibrarySection<YLibraryTracks>(storage, YLibrarySection.Tracks);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<List<YLibraryAlbum>?> GetLikedAlbumsAsync()
|
||||||
/// Получение лайкнутых альбомов
|
=> new YGetLibrarySectionBuilder<List<YLibraryAlbum>>(Api).ExecuteAsync((YLibrarySection.Albums, YLibrarySectionType.Likes));
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<List<YLibraryAlbum>>> GetLikedAlbumsAsync(AuthStorage storage)
|
|
||||||
{
|
|
||||||
return GetLibrarySection<List<YLibraryAlbum>>(storage, YLibrarySection.Albums);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<List<YArtist>?> GetLikedArtistsAsync()
|
||||||
/// Получение лайкнутых исполнителей
|
=> new YGetLibrarySectionBuilder<List<YArtist>>(Api).ExecuteAsync((YLibrarySection.Artists, YLibrarySectionType.Likes));
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<List<YArtist>>> GetLikedArtistsAsync(AuthStorage storage)
|
|
||||||
{
|
|
||||||
return GetLibrarySection<List<YArtist>>(storage, YLibrarySection.Artists);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<List<YLibraryPlaylists>?> GetLikedPlaylistsAsync()
|
||||||
/// Получение лайкнутых плейлистов
|
=> new YGetLibrarySectionBuilder<List<YLibraryPlaylists>>(Api).ExecuteAsync((YLibrarySection.Playlists, YLibrarySectionType.Likes));
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<List<YLibraryPlaylists>>> GetLikedPlaylistsAsync(AuthStorage storage)
|
|
||||||
{
|
|
||||||
return GetLibrarySection<List<YLibraryPlaylists>>(storage, YLibrarySection.Playlists);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion Лайки
|
#endregion
|
||||||
|
|
||||||
#region Дизлайки
|
#region Дизлайки
|
||||||
|
|
||||||
/// <summary>
|
public Task<YLibraryTracks?> GetDislikedTracksAsync()
|
||||||
/// Получение дизлайкнутых треков
|
=> new YGetLibrarySectionBuilder<YLibraryTracks>(Api).ExecuteAsync((YLibrarySection.Tracks, YLibrarySectionType.Dislikes));
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<YLibraryTracks>> GetDislikedTracksAsync(AuthStorage storage)
|
|
||||||
{
|
|
||||||
return GetLibrarySection<YLibraryTracks>(storage, YLibrarySection.Tracks, YLibrarySectionType.Dislikes);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<List<YArtist>?> GetDislikedArtistsAsync()
|
||||||
/// Получение дизлайкнутых исполнителей
|
=> new YGetLibrarySectionBuilder<List<YArtist>>(Api).ExecuteAsync((YLibrarySection.Artists, YLibrarySectionType.Dislikes));
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<List<YArtist>>> GetDislikedArtistsAsync(AuthStorage storage)
|
|
||||||
{
|
|
||||||
return GetLibrarySection<List<YArtist>>(storage, YLibrarySection.Artists, YLibrarySectionType.Dislikes);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion Дизлайки
|
#endregion
|
||||||
|
|
||||||
#region Добавление в списки лайков/дизлайков
|
#region Добавление/удаление
|
||||||
|
|
||||||
/// <summary>
|
public Task<int?> AddTrackLikeAsync(YTrack track)
|
||||||
/// Добавить трек в список лайкнутых
|
=> new YLibraryAddBuilder<YRevision>(Api).ExecuteAsync((track.Id, YLibrarySection.Tracks, YLibrarySectionType.Likes))
|
||||||
/// </summary>
|
.ContinueWith(t => t.Result?.Revision);
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="track">Трек</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<YPlaylist>> AddTrackLikeAsync(AuthStorage storage, YTrack track)
|
|
||||||
{
|
|
||||||
return new YLibraryAddBuilder<YPlaylist>(api, storage)
|
|
||||||
.Build((track.GetKey().ToString(), YLibrarySection.Tracks, YLibrarySectionType.Likes))
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<int?> RemoveTrackLikeAsync(YTrack track)
|
||||||
/// Удалить трек из списка лайкнутых
|
=> new YLibraryRemoveBuilder<YRevision>(Api).ExecuteAsync((track.Id, YLibrarySection.Tracks, YLibrarySectionType.Likes))
|
||||||
/// </summary>
|
.ContinueWith(t => t.Result?.Revision);
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="track">Трек</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<YRevision>> RemoveTrackLikeAsync(AuthStorage storage, YTrack track)
|
|
||||||
{
|
|
||||||
return new YLibraryRemoveBuilder<YRevision>(api, storage)
|
|
||||||
.Build((track.GetKey().ToString(), YLibrarySection.Tracks, YLibrarySectionType.Likes))
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<int?> AddTrackDislikeAsync(YTrack track)
|
||||||
/// Добавить трек в список дизлайкнутых
|
=> new YLibraryAddBuilder<YRevision>(Api).ExecuteAsync((track.Id, YLibrarySection.Tracks, YLibrarySectionType.Dislikes))
|
||||||
/// </summary>
|
.ContinueWith(t => t.Result?.Revision);
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="track">Трек</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<YRevision>> AddTrackDislikeAsync(AuthStorage storage, YTrack track)
|
|
||||||
{
|
|
||||||
return new YLibraryAddBuilder<YRevision>(api, storage)
|
|
||||||
.Build((track.GetKey().ToString(), YLibrarySection.Tracks, YLibrarySectionType.Dislikes))
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<int?> RemoveTrackDislikeAsync(YTrack track)
|
||||||
/// Удалить трек из списка дизлайкнутых
|
=> new YLibraryRemoveBuilder<YRevision>(Api).ExecuteAsync((track.Id, YLibrarySection.Tracks, YLibrarySectionType.Dislikes))
|
||||||
/// </summary>
|
.ContinueWith(t => t.Result?.Revision);
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="track">Трек</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<YRevision>> RemoveTrackDislikeAsync(AuthStorage storage, YTrack track)
|
|
||||||
{
|
|
||||||
return new YLibraryRemoveBuilder<YRevision>(api, storage)
|
|
||||||
.Build((track.GetKey().ToString(), YLibrarySection.Tracks, YLibrarySectionType.Dislikes))
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<string?> AddAlbumLikeAsync(YAlbum album)
|
||||||
/// Добавить альбом в список лайкнутых
|
=> new YLibraryAddBuilder<string>(Api).ExecuteAsync((album.Id, YLibrarySection.Albums, YLibrarySectionType.Likes));
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="album">Альбом</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<string>> AddAlbumLikeAsync(AuthStorage storage, YAlbum album)
|
|
||||||
{
|
|
||||||
return new YLibraryAddBuilder<string>(api, storage)
|
|
||||||
.Build((album.Id, YLibrarySection.Albums, YLibrarySectionType.Likes))
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<string?> RemoveAlbumLikeAsync(YAlbum album)
|
||||||
/// Удалить альбом из списка лайкнутых
|
=> new YLibraryRemoveBuilder<string>(Api).ExecuteAsync((album.Id, YLibrarySection.Albums, YLibrarySectionType.Likes));
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="album">Альбом</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<string>> RemoveAlbumLikeAsync(AuthStorage storage, YAlbum album)
|
|
||||||
{
|
|
||||||
return new YLibraryRemoveBuilder<string>(api, storage)
|
|
||||||
.Build((album.Id, YLibrarySection.Albums, YLibrarySectionType.Likes))
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<string?> AddArtistLikeAsync(YArtist artist)
|
||||||
/// Добавить исполнителя в список лайкнутых
|
=> new YLibraryAddBuilder<string>(Api).ExecuteAsync((artist.Id, YLibrarySection.Artists, YLibrarySectionType.Likes));
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="artist">Исполнитель</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<string>> AddArtistLikeAsync(AuthStorage storage, YArtist artist)
|
|
||||||
{
|
|
||||||
return new YLibraryAddBuilder<string>(api, storage)
|
|
||||||
.Build((artist.Id, YLibrarySection.Artists, YLibrarySectionType.Likes))
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<string?> RemoveArtistLikeAsync(YArtist artist)
|
||||||
/// Удалить исполнителя из списка лайкнутых
|
=> new YLibraryRemoveBuilder<string>(Api).ExecuteAsync((artist.Id, YLibrarySection.Artists, YLibrarySectionType.Likes));
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="artist">Исполнитель</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<string>> RemoveArtistLikeAsync(AuthStorage storage, YArtist artist)
|
|
||||||
{
|
|
||||||
return new YLibraryRemoveBuilder<string>(api, storage)
|
|
||||||
.Build((artist.Id, YLibrarySection.Artists, YLibrarySectionType.Likes))
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<string?> AddPlaylistLikeAsync(YPlaylist playlist)
|
||||||
/// Добавить плейлист в список лайкнутых
|
=> new YLibraryAddBuilder<string>(Api).ExecuteAsync((playlist.GetKey().ToString(), YLibrarySection.Playlists, YLibrarySectionType.Likes));
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="playlist">Плейлист</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<string>> AddPlaylistLikeAsync(AuthStorage storage, YPlaylist playlist)
|
|
||||||
{
|
|
||||||
return new YLibraryAddBuilder<string>(api, storage)
|
|
||||||
.Build((playlist.GetKey().ToString(), YLibrarySection.Playlists, YLibrarySectionType.Likes))
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<string?> RemovePlaylistLikeAsync(YPlaylist playlist)
|
||||||
/// Удалить плейлист из списка лайкнутых
|
=> new YLibraryRemoveBuilder<string>(Api).ExecuteAsync((playlist.GetKey().ToString(), YLibrarySection.Playlists, YLibrarySectionType.Likes));
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="playlist">Плейлист</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<string>> RemovePlaylistLikeAsync(AuthStorage storage, YPlaylist playlist)
|
|
||||||
{
|
|
||||||
return new YLibraryRemoveBuilder<string>(api, storage)
|
|
||||||
.Build((playlist.GetKey().ToString(), YLibrarySection.Playlists, YLibrarySectionType.Likes))
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion Добавление/удаление в списки лайков/дизлайков
|
#endregion
|
||||||
|
|
||||||
#region Получение списка "Вы недавно слушали"
|
#region Недавно прослушанное
|
||||||
|
|
||||||
public Task<YResponse<YRecentlyListenedContext>> GetRecentlyListenedAsync(AuthStorage storage, IEnumerable<YPlayContextType> contextTypes, int trackCount, int contextCount)
|
|
||||||
{
|
|
||||||
return new YGetLibraryRecentlyListenedBuilder(api, storage)
|
|
||||||
.Build((contextTypes, trackCount, contextCount))
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion Получение списка "Вы недавно слушали"
|
|
||||||
|
|
||||||
|
public Task<YRecentlyListenedContext?> GetRecentlyListenedAsync(
|
||||||
|
IEnumerable<YPlayContextType> contextTypes,
|
||||||
|
int trackCount = 50,
|
||||||
|
int contextCount = 10)
|
||||||
|
=> new YGetLibraryRecentlyListenedBuilder(Api).ExecuteAsync((contextTypes, trackCount, contextCount));
|
||||||
|
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
@@ -1,20 +1,13 @@
|
|||||||
using YandexMusic.API.Common;
|
|
||||||
using YandexMusic.API.Models.Common;
|
|
||||||
using YandexMusic.API.Models.Pins;
|
using YandexMusic.API.Models.Pins;
|
||||||
using YandexMusic.API.Requests.Pins;
|
using YandexMusic.API.Requests.Pins;
|
||||||
|
|
||||||
namespace YandexMusic.API;
|
namespace YandexMusic.API;
|
||||||
|
|
||||||
/// <summary>API для взаимодействия с закреплёнными объектами (пинами).</summary>
|
/// <summary>API для работы с закреплёнными объектами (пинами).</summary>
|
||||||
public class YPinsAPI : YCommonAPI
|
public class YPinsAPI : YCommonAPI
|
||||||
{
|
{
|
||||||
/// <summary>Инициализирует новый экземпляр API пинов.</summary>
|
public YPinsAPI(YandexMusicApi api) : base(api) { }
|
||||||
/// <param name="yandex">Экземпляр основного API.</param>
|
|
||||||
public YPinsAPI(YandexMusicApi yandex) : base(yandex) { }
|
|
||||||
|
|
||||||
/// <summary>Получает список закреплённых объектов.</summary>
|
public Task<YPins?> GetAsync()
|
||||||
/// <param name="storage">Хранилище авторизации.</param>
|
=> new YGetPinsBuilder(Api).ExecuteAsync(null!);
|
||||||
/// <returns>Ответ API со списком пинов.</returns>
|
|
||||||
public Task<YResponse<YPins>> GetAsync(AuthStorage storage)
|
|
||||||
=> new YGetPinsBuilder(api, storage).Build(null!).GetResponseAsync();
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
using YandexMusic.API.Common;
|
|
||||||
using YandexMusic.API.Models.Common;
|
|
||||||
using YandexMusic.API.Models.Landing;
|
using YandexMusic.API.Models.Landing;
|
||||||
using YandexMusic.API.Models.Landing.Entity.Entities;
|
using YandexMusic.API.Models.Landing.Entity.Entities;
|
||||||
using YandexMusic.API.Models.Playlist;
|
using YandexMusic.API.Models.Playlist;
|
||||||
@@ -8,142 +6,114 @@ using YandexMusic.API.Requests.Playlist;
|
|||||||
|
|
||||||
namespace YandexMusic.API;
|
namespace YandexMusic.API;
|
||||||
|
|
||||||
/// <summary>API для взаимодействия с плейлистами.</summary>
|
/// <summary>API для работы с плейлистами.</summary>
|
||||||
public class YPlaylistAPI : YCommonAPI
|
public class YPlaylistAPI : YCommonAPI
|
||||||
{
|
{
|
||||||
/// <summary>Инициализирует новый экземпляр API плейлистов.</summary>
|
public YPlaylistAPI(YandexMusicApi api) : base(api) { }
|
||||||
/// <param name="yandex">Экземпляр основного API.</param>
|
|
||||||
public YPlaylistAPI(YandexMusicApi yandex) : base(yandex) { }
|
|
||||||
|
|
||||||
/// <summary>Получает список персональных плейлистов с главной страницы.</summary>
|
public async Task<List<YPlaylist>> GetPersonalPlaylistsAsync()
|
||||||
/// <param name="storage">Хранилище авторизации.</param>
|
|
||||||
/// <returns>Список ответов с плейлистами.</returns>
|
|
||||||
public async Task<List<YResponse<YPlaylist>>> GetPersonalPlaylistsAsync(AuthStorage storage)
|
|
||||||
{
|
{
|
||||||
var landing = await api.Landing.GetAsync(storage, YLandingBlockType.PersonalPlaylists);
|
var landing = await Api.Landing.GetAsync(YLandingBlockType.PersonalPlaylists);
|
||||||
var block = landing.Result?.Blocks?.FirstOrDefault(b => b.Type == YLandingBlockType.PersonalPlaylists);
|
var block = landing?.Blocks?.FirstOrDefault(b => b.Type == YLandingBlockType.PersonalPlaylists);
|
||||||
if (block?.Entities == null)
|
if (block?.Entities == null)
|
||||||
return new List<YResponse<YPlaylist>>();
|
return new List<YPlaylist>();
|
||||||
|
|
||||||
var tasks = block.Entities
|
var tasks = block.Entities
|
||||||
.OfType<YLandingEntityPersonalPlaylist>()
|
.OfType<YLandingEntityPersonalPlaylist>()
|
||||||
.Select(e => api.Playlist.GetAsync(storage, e.Data?.Data));
|
.Select(e => GetAsync(e.Data?.Data?.Owner?.Uid ?? Api.Storage.User.Uid, e.Data?.Data?.Kind ?? ""))
|
||||||
|
.Where(t => t != null)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
return new List<YResponse<YPlaylist>>(await Task.WhenAll(tasks));
|
var results = await Task.WhenAll(tasks);
|
||||||
|
return results.Where(p => p != null).ToList()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Получает избранные плейлисты.</summary>
|
public Task<YPlaylist?> GetAsync(string user, string kind)
|
||||||
public Task<YResponse<List<YPlaylist>>> FavoritesAsync(AuthStorage storage)
|
=> new YGetPlaylistBuilder(Api).ExecuteAsync((user, kind));
|
||||||
=> new YGetPlaylistFavoritesBuilder(api, storage).Build(null!).GetResponseAsync();
|
|
||||||
|
|
||||||
/// <summary>Получает плейлист дня.</summary>
|
public Task<YPlaylist?> GetAsync(string uuid)
|
||||||
public Task<YResponse<YPlaylist>> OfTheDayAsync(AuthStorage storage)
|
=> new YGetPlaylistByUuidBuilder(Api).ExecuteAsync(uuid);
|
||||||
=> GetPersonalPlaylistAsync(storage, YGeneratedPlaylistType.PlaylistOfTheDay);
|
|
||||||
|
|
||||||
/// <summary>Получает плейлист «Дежавю».</summary>
|
public Task<YPlaylist?> GetAsync(YPlaylist playlist)
|
||||||
public Task<YResponse<YPlaylist>> DejaVuAsync(AuthStorage storage)
|
=> GetAsync(playlist.Owner.Uid, playlist.Kind);
|
||||||
=> GetPersonalPlaylistAsync(storage, YGeneratedPlaylistType.NeverHeard);
|
|
||||||
|
|
||||||
/// <summary>Получает плейлист «Премьера».</summary>
|
public Task<List<YPlaylist>?> GetAsync(IEnumerable<(string user, string kind)> ids)
|
||||||
public Task<YResponse<YPlaylist>> PremiereAsync(AuthStorage storage)
|
=> new YGetPlaylistsBuilder(Api).ExecuteAsync(ids);
|
||||||
=> GetPersonalPlaylistAsync(storage, YGeneratedPlaylistType.RecentTracks);
|
|
||||||
|
|
||||||
/// <summary>Получает плейлист «Тайник».</summary>
|
public Task<List<YPlaylist>?> FavoritesAsync()
|
||||||
public Task<YResponse<YPlaylist>> MissedAsync(AuthStorage storage)
|
=> new YGetPlaylistFavoritesBuilder(Api).ExecuteAsync(null!);
|
||||||
=> GetPersonalPlaylistAsync(storage, YGeneratedPlaylistType.MissedLikes);
|
|
||||||
|
|
||||||
/// <summary>Получает плейлист «Кинопоиск».</summary>
|
public async Task<YPlaylist?> OfTheDayAsync()
|
||||||
public Task<YResponse<YPlaylist>> KinopoiskAsync(AuthStorage storage)
|
=> (await GetPersonalPlaylistsAsync()).FirstOrDefault(p => p?.GeneratedPlaylistType == YGeneratedPlaylistType.PlaylistOfTheDay.ToString());
|
||||||
=> GetPersonalPlaylistAsync(storage, YGeneratedPlaylistType.Kinopoisk);
|
|
||||||
|
|
||||||
private async Task<YResponse<YPlaylist>> GetPersonalPlaylistAsync(AuthStorage storage, YGeneratedPlaylistType type)
|
public async Task<YPlaylist?> DejaVuAsync()
|
||||||
{
|
=> (await GetPersonalPlaylistsAsync()).FirstOrDefault(p => p?.GeneratedPlaylistType == YGeneratedPlaylistType.NeverHeard.ToString());
|
||||||
var list = await GetPersonalPlaylistsAsync(storage);
|
|
||||||
return list.FirstOrDefault(e => string.Equals(e.Result?.GeneratedPlaylistType, type.ToString(), StringComparison.CurrentCultureIgnoreCase))
|
|
||||||
?? throw new Exception($"Плейлист типа {type} не найден.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Получает плейлист по идентификатору пользователя и типа.</summary>
|
public async Task<YPlaylist?> PremiereAsync()
|
||||||
public Task<YResponse<YPlaylist>> GetAsync(AuthStorage storage, string user, string kind)
|
=> (await GetPersonalPlaylistsAsync()).FirstOrDefault(p => p?.GeneratedPlaylistType == YGeneratedPlaylistType.RecentTracks.ToString());
|
||||||
=> new YGetPlaylistBuilder(api, storage).Build((user, kind)).GetResponseAsync();
|
|
||||||
|
|
||||||
/// <summary>Получает плейлист по UUID.</summary>
|
public async Task<YPlaylist?> MissedAsync()
|
||||||
public Task<YResponse<YPlaylist>> GetAsync(AuthStorage storage, string uuid)
|
=> (await GetPersonalPlaylistsAsync()).FirstOrDefault(p => p?.GeneratedPlaylistType == YGeneratedPlaylistType.MissedLikes.ToString());
|
||||||
=> new YGetPlaylistByUuidBuilder(api, storage).Build(uuid).GetResponseAsync();
|
|
||||||
|
|
||||||
/// <summary>Получает несколько плейлистов по списку пар (пользователь, тип).</summary>
|
public async Task<YPlaylist?> KinopoiskAsync()
|
||||||
public Task<YResponse<List<YPlaylist>>> GetAsync(AuthStorage storage, IEnumerable<(string user, string kind)> ids)
|
=> (await GetPersonalPlaylistsAsync()).FirstOrDefault(p => p?.GeneratedPlaylistType == YGeneratedPlaylistType.Kinopoisk.ToString());
|
||||||
=> new YGetPlaylistsBuilder(api, storage).Build(ids).GetResponseAsync();
|
|
||||||
|
|
||||||
/// <summary>Получает плейлист по объекту плейлиста (обновляет его треки).</summary>
|
public Task<YPlaylist?> CreateAsync(string name)
|
||||||
public Task<YResponse<YPlaylist>> GetAsync(AuthStorage storage, YPlaylist playlist)
|
=> new YPlaylistCreateBuilder(Api).ExecuteAsync(name);
|
||||||
=> new YGetPlaylistBuilder(api, storage).Build((playlist.Owner.Uid, playlist.Kind)).GetResponseAsync();
|
|
||||||
|
|
||||||
/// <summary>Создаёт новый плейлист с заданным именем.</summary>
|
public Task<YPlaylist?> RenameAsync(string kind, string name)
|
||||||
public Task<YResponse<YPlaylist>> CreateAsync(AuthStorage storage, string name)
|
=> new YPlaylistRenameBuilder(Api).ExecuteAsync((kind, name));
|
||||||
=> new YPlaylistCreateBuilder(api, storage).Build(name).GetResponseAsync();
|
|
||||||
|
|
||||||
/// <summary>Переименовывает плейлист.</summary>
|
public Task<YPlaylist?> RenameAsync(YPlaylist playlist, string name)
|
||||||
public Task<YResponse<YPlaylist>> RenameAsync(AuthStorage storage, string kind, string name)
|
=> RenameAsync(playlist.Kind, name);
|
||||||
=> new YPlaylistRenameBuilder(api, storage).Build((kind, name)).GetResponseAsync();
|
|
||||||
|
|
||||||
/// <summary>Переименовывает плейлист.</summary>
|
public async Task<bool> DeleteAsync(string kind)
|
||||||
public Task<YResponse<YPlaylist>> RenameAsync(AuthStorage storage, YPlaylist playlist, string name)
|
|
||||||
=> RenameAsync(storage, playlist.Kind, name);
|
|
||||||
|
|
||||||
/// <summary>Удаляет плейлист.</summary>
|
|
||||||
public async Task<bool> DeleteAsync(AuthStorage storage, string kind)
|
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await new YPlaylistRemoveBuilder(api, storage).Build(kind).GetResponseAsync();
|
await new YPlaylistRemoveBuilder(Api).ExecuteAsync(kind);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch
|
||||||
{
|
{
|
||||||
// Логирование ошибки можно добавить через ILogger
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Удаляет плейлист.</summary>
|
public Task<bool> DeleteAsync(YPlaylist playlist)
|
||||||
public Task<bool> DeleteAsync(AuthStorage storage, YPlaylist playlist)
|
=> DeleteAsync(playlist.Kind);
|
||||||
=> DeleteAsync(storage, playlist.Kind);
|
|
||||||
|
|
||||||
/// <summary>Добавляет треки в начало плейлиста.</summary>
|
public async Task<YPlaylist?> InsertTracksAsync(YPlaylist playlist, IEnumerable<YTrack> tracks)
|
||||||
public async Task<YResponse<YPlaylist>> InsertTracksAsync(AuthStorage storage, YPlaylist playlist, IEnumerable<YTrack> tracks)
|
|
||||||
{
|
{
|
||||||
var change = await ChangePlaylistAsync(storage, playlist, new List<YPlaylistChange>
|
var change = await new YPlaylistChangeBuilder(Api).ExecuteAsync((playlist, new[]
|
||||||
{
|
{
|
||||||
new()
|
new YPlaylistChange
|
||||||
{
|
{
|
||||||
Operation = YPlaylistChangeType.Insert,
|
Operation = YPlaylistChangeType.Insert,
|
||||||
At = 0,
|
At = 0,
|
||||||
Tracks = tracks.Select(t => t.GetKey())
|
Tracks = tracks.Select(t => t.GetKey())
|
||||||
}
|
}
|
||||||
});
|
}));
|
||||||
return await GetAsync(storage, change.Result);
|
return change != null ? await GetAsync(change) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Удаляет треки из плейлиста.</summary>
|
public async Task<YPlaylist?> DeleteTracksAsync(YPlaylist playlist, IEnumerable<YTrack> tracks)
|
||||||
public Task<YResponse<YPlaylist>> DeleteTracksAsync(AuthStorage storage, YPlaylist playlist, IEnumerable<YTrack> tracks)
|
|
||||||
{
|
{
|
||||||
var distinctTracks = tracks.Distinct().ToList();
|
var distinctTracks = tracks.Distinct().ToList();
|
||||||
var changes = distinctTracks
|
var indices = distinctTracks
|
||||||
.Select(t => playlist.Tracks?.FindIndex(ct => ct.Track?.GetKey() == t.GetKey()) ?? -1)
|
.Select(t => playlist.Tracks?.FindIndex(ct => ct.Track?.GetKey() == t.GetKey()) ?? -1)
|
||||||
.Where(i => i != -1)
|
.Where(i => i != -1)
|
||||||
.Select(i => new YPlaylistChange
|
|
||||||
{
|
|
||||||
Operation = YPlaylistChangeType.Delete,
|
|
||||||
From = i,
|
|
||||||
To = i + 1,
|
|
||||||
Tracks = new List<YTrackAlbumPair> { playlist.Tracks![i].Track!.GetKey() }
|
|
||||||
})
|
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return ChangePlaylistAsync(storage, playlist, changes);
|
var changes = indices.Select(i => new YPlaylistChange
|
||||||
}
|
{
|
||||||
|
Operation = YPlaylistChangeType.Delete,
|
||||||
|
From = i,
|
||||||
|
To = i + 1,
|
||||||
|
Tracks = new[] { playlist.Tracks![i].Track!.GetKey() }
|
||||||
|
});
|
||||||
|
|
||||||
private Task<YResponse<YPlaylist>> ChangePlaylistAsync(AuthStorage storage, YPlaylist playlist, IEnumerable<YPlaylistChange> changes)
|
var change = await new YPlaylistChangeBuilder(Api).ExecuteAsync((playlist, changes));
|
||||||
=> new YPlaylistChangeBuilder(api, storage).Build((playlist, changes)).GetResponseAsync();
|
return change != null ? await GetAsync(change) : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,72 +1,22 @@
|
|||||||
using YandexMusic.API.Common;
|
|
||||||
using YandexMusic.API.Models.Common;
|
|
||||||
using YandexMusic.API.Models.Queue;
|
using YandexMusic.API.Models.Queue;
|
||||||
using YandexMusic.API.Requests.Queue;
|
using YandexMusic.API.Requests.Queue;
|
||||||
|
|
||||||
namespace YandexMusic.API;
|
namespace YandexMusic.API;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>API для работы с очередями воспроизведения.</summary>
|
||||||
/// API для взаимодействия с очередями
|
public class YQueueAPI : YCommonAPI
|
||||||
/// </summary>
|
|
||||||
public partial class YQueueAPI : YCommonAPI
|
|
||||||
{
|
{
|
||||||
public YQueueAPI(YandexMusicApi yandex) : base(yandex)
|
public YQueueAPI(YandexMusicApi api) : base(api) { }
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<YQueueItemsContainer?> ListAsync(string? device = null)
|
||||||
/// Получение всех очередей треков с разных устройств для синхронизации между ними
|
=> new YQueuesListBuilder(Api, device).ExecuteAsync(null!);
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="device">Устройство</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<YQueueItemsContainer>> ListAsync(AuthStorage storage, string? device = null)
|
|
||||||
{
|
|
||||||
return new YQueuesListBuilder(api, storage)
|
|
||||||
.Build(device)
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<YQueue?> GetAsync(string queueId)
|
||||||
/// Получение очереди
|
=> new YGetQueueBuilder(Api).ExecuteAsync(queueId);
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="queueId">Идентификатор очереди</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<YQueue>> GetAsync(AuthStorage storage, string queueId)
|
|
||||||
{
|
|
||||||
return new YGetQueueBuilder(api, storage)
|
|
||||||
.Build(queueId)
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<YNewQueue?> CreateAsync(YQueue queue, string? device = null)
|
||||||
/// Создание новой очереди треков
|
=> new YQueueCreateBuilder(Api, device).ExecuteAsync(queue);
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="queue">Очередь треков</param>
|
|
||||||
/// <param name="device">Устройство</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<YNewQueue>> CreateAsync(AuthStorage storage, YQueue queue, string? device = null)
|
|
||||||
{
|
|
||||||
return new YQueueCreateBuilder(api, storage, device)
|
|
||||||
.Build(queue)
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<YUpdatedQueue?> UpdatePositionAsync(string queueId, int currentIndex, bool isInteractive, string? device = null)
|
||||||
/// Установка текущего индекса проигрываемого трека в очереди треков
|
=> new YQueueUpdatePositionBuilder(Api, device).ExecuteAsync((queueId, currentIndex, isInteractive));
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="queueId">Идентификатор очереди</param>
|
|
||||||
/// <param name="currentIndex">Текущий индекс</param>
|
|
||||||
/// <param name="isInteractive">Флаг интерактивности</param>
|
|
||||||
/// <param name="device">Устройство</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<YUpdatedQueue>> UpdatePositionAsync(AuthStorage storage, string queueId, int currentIndex, bool isInteractive, string device = null)
|
|
||||||
{
|
|
||||||
return new YQueueUpdatePositionBuilder(api, storage, device)
|
|
||||||
.Build((queueId, currentIndex, isInteractive))
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,113 +1,37 @@
|
|||||||
using YandexMusic.API.Common;
|
|
||||||
using YandexMusic.API.Models.Common;
|
|
||||||
using YandexMusic.API.Models.Radio;
|
using YandexMusic.API.Models.Radio;
|
||||||
using YandexMusic.API.Models.Track;
|
using YandexMusic.API.Models.Track;
|
||||||
using YandexMusic.API.Requests.Radio;
|
using YandexMusic.API.Requests.Radio;
|
||||||
|
|
||||||
namespace YandexMusic.API;
|
namespace YandexMusic.API;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>API для работы с радио.</summary>
|
||||||
/// API для взаимодействия с радио
|
public class YRadioAPI : YCommonAPI
|
||||||
/// </summary>
|
|
||||||
public partial class YRadioAPI : YCommonAPI
|
|
||||||
{
|
{
|
||||||
public YRadioAPI(YandexMusicApi yandex) : base(yandex)
|
public YRadioAPI(YandexMusicApi api) : base(api) { }
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<YStationsDashboard?> GetStationsDashboardAsync()
|
||||||
/// Получение списка рекомендованных радиостанций
|
=> new YGetStationsDashboardBuilder(Api).ExecuteAsync(null!);
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<YStationsDashboard>> GetStationsDashboardAsync(AuthStorage storage)
|
|
||||||
{
|
|
||||||
return new YGetStationsDashboardBuilder(api, storage)
|
|
||||||
.Build(null)
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<List<YStation>?> GetStationsAsync()
|
||||||
/// Получение списка радиостанций
|
=> new YGetStationsBuilder(Api).ExecuteAsync(null!);
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<List<YStation>>> GetStationsAsync(AuthStorage storage)
|
|
||||||
{
|
|
||||||
return new YGetStationsBuilder(api, storage)
|
|
||||||
.Build(null)
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<List<YStation>?> GetStationAsync(string type, string tag)
|
||||||
/// Получение информации о радиостанции
|
=> new YGetStationBuilder(Api).ExecuteAsync((type, tag));
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="type">Тип</param>
|
|
||||||
/// <param name="tag">Тэг</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<List<YStation>>> GetStationAsync(AuthStorage storage, string type, string tag)
|
|
||||||
{
|
|
||||||
return new YGetStationBuilder(api, storage)
|
|
||||||
.Build((type, tag))
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<List<YStation>?> GetStationAsync(YStationId id)
|
||||||
/// Получение информации о радиостанции
|
=> GetStationAsync(id.Type, id.Tag);
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="id">Идентификатор станции</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<List<YStation>>> GetStationAsync(AuthStorage storage, YStationId id)
|
|
||||||
{
|
|
||||||
return GetStationAsync(storage, id.Type, id.Tag);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<YStationSequence?> GetStationTracksAsync(YStation station, string prevTrackId = "")
|
||||||
/// Получение последовательности треков радиостанции
|
=> new YGetStationTracksBuilder(Api).ExecuteAsync((station.Station, prevTrackId));
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="station">Радиостанция</param>
|
|
||||||
/// <param name="prevTrackId">Идентификатор предыдущего трека</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<YStationSequence>> GetStationTracksAsync(AuthStorage storage, YStation station, string prevTrackId = "")
|
|
||||||
{
|
|
||||||
return new YGetStationTracksBuilder(api, storage)
|
|
||||||
.Build((station.Station, prevTrackId))
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Установка настроек подбора треков
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="station">Радиостанция</param>
|
|
||||||
/// <param name="settings">Настройки</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<string>> SetStationSettings2Async(AuthStorage storage, YStation station, YStationSettings2 settings)
|
|
||||||
{
|
|
||||||
return new YSetSettings2Builder(api, storage)
|
|
||||||
.Build((station.Station, settings))
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Отправка обратной связи на действия при прослушивании радио
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="station">Радиостанция</param>
|
|
||||||
/// <param name="type">Тип обратной связи</param>
|
|
||||||
/// <param name="track">Трек</param>
|
|
||||||
/// <param name="batchId">Уникальный идентификатор партии треков. Возвращается при получении треков</param>
|
|
||||||
/// <param name="totalPlayedSeconds">Сколько было проиграно секунд трека перед действием</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<string> SendStationFeedBackAsync(AuthStorage storage, YStation station, YStationFeedbackType type, YTrack track = null, string batchId = "", double totalPlayedSeconds = 0)
|
|
||||||
{
|
|
||||||
return new YSetStationFeedbackBuilder(api, storage)
|
|
||||||
.Build((type, station, track, batchId, totalPlayedSeconds))
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
public Task<string?> SetStationSettings2Async(YStation station, YStationSettings2 settings)
|
||||||
|
=> new YSetSettings2Builder(Api).ExecuteAsync((station.Station, settings));
|
||||||
|
|
||||||
|
public Task<string?> SendStationFeedbackAsync(
|
||||||
|
YStation station,
|
||||||
|
YStationFeedbackType type,
|
||||||
|
YTrack? track = null,
|
||||||
|
string batchId = "",
|
||||||
|
double totalPlayedSeconds = 0)
|
||||||
|
=> new YSetStationFeedbackBuilder(Api).ExecuteAsync((type, station, track, batchId, totalPlayedSeconds));
|
||||||
}
|
}
|
||||||
@@ -1,139 +1,38 @@
|
|||||||
using YandexMusic.API.Common;
|
|
||||||
using YandexMusic.API.Models.Common;
|
using YandexMusic.API.Models.Common;
|
||||||
using YandexMusic.API.Models.Search;
|
using YandexMusic.API.Models.Search;
|
||||||
using YandexMusic.API.Requests.Search;
|
using YandexMusic.API.Requests.Search;
|
||||||
|
|
||||||
namespace YandexMusic.API;
|
namespace YandexMusic.API;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>API для поиска.</summary>
|
||||||
/// API для поиска
|
public class YSearchAPI : YCommonAPI
|
||||||
/// </summary>
|
|
||||||
public partial class YSearchAPI : YCommonAPI
|
|
||||||
{
|
{
|
||||||
|
public YSearchAPI(YandexMusicApi api) : base(api) { }
|
||||||
|
|
||||||
public YSearchAPI(YandexMusicApi yandex) : base(yandex)
|
public Task<YSearch?> TrackAsync(string trackName, int page = 0, int pageSize = 20)
|
||||||
{
|
=> SearchAsync(trackName, YSearchType.Track, page, pageSize);
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<YSearch?> AlbumsAsync(string albumName, int page = 0, int pageSize = 20)
|
||||||
/// Поиск по трекам
|
=> SearchAsync(albumName, YSearchType.Album, page, pageSize);
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="trackName">Имя трека</param>
|
|
||||||
/// <param name="pageNumber">Номер страницы</param>
|
|
||||||
/// <param name="pageSize">Размер страницы</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<YSearch>> TrackAsync(AuthStorage storage, string trackName, int pageNumber = 0, int pageSize = 20)
|
|
||||||
{
|
|
||||||
return SearchAsync(storage, trackName, YSearchType.Track, pageNumber, pageSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<YSearch?> ArtistAsync(string artistName, int page = 0, int pageSize = 20)
|
||||||
/// Поиск по альбомам
|
=> SearchAsync(artistName, YSearchType.Artist, page, pageSize);
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="albumName">Имя альбома</param>
|
|
||||||
/// <param name="pageNumber">Номер страницы</param>
|
|
||||||
/// <param name="pageSize">Размер страницы</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<YSearch>> AlbumsAsync(AuthStorage storage, string albumName, int pageNumber = 0, int pageSize = 20)
|
|
||||||
{
|
|
||||||
return SearchAsync(storage, albumName, YSearchType.Album, pageNumber, pageSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<YSearch?> PlaylistAsync(string playlistName, int page = 0, int pageSize = 20)
|
||||||
/// Поиск по артисту
|
=> SearchAsync(playlistName, YSearchType.Playlist, page, pageSize);
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="artistName">Имя артиста</param>
|
|
||||||
/// <param name="pageNumber">Номер страницы</param>
|
|
||||||
/// <param name="pageSize">Размер страницы</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<YSearch>> ArtistAsync(AuthStorage storage, string artistName, int pageNumber = 0, int pageSize = 20)
|
|
||||||
{
|
|
||||||
return SearchAsync(storage, artistName, YSearchType.Artist, pageNumber, pageSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<YSearch?> PodcastEpisodeAsync(string podcastName, int page = 0, int pageSize = 20)
|
||||||
/// Поиск по плейлистам
|
=> SearchAsync(podcastName, YSearchType.PodcastEpisode, page, pageSize);
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="playlistName">Имя плейлиста</param>
|
|
||||||
/// <param name="pageNumber">Номер страницы</param>
|
|
||||||
/// <param name="pageSize">Размер страницы</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<YSearch>> PlaylistAsync(AuthStorage storage, string playlistName, int pageNumber = 0, int pageSize = 20)
|
|
||||||
{
|
|
||||||
return SearchAsync(storage, playlistName, YSearchType.Playlist, pageNumber, pageSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<YSearch?> VideosAsync(string videoName, int page = 0, int pageSize = 20)
|
||||||
/// Поиск по плейлистам
|
=> SearchAsync(videoName, YSearchType.Video, page, pageSize);
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="podcastName">Имя подкаста</param>
|
|
||||||
/// <param name="pageNumber">Номер страницы</param>
|
|
||||||
/// <param name="pageSize">Размер страницы</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<YSearch>> PodcastEpisodeAsync(AuthStorage storage, string podcastName, int pageNumber = 0, int pageSize = 20)
|
|
||||||
{
|
|
||||||
return SearchAsync(storage, podcastName, YSearchType.PodcastEpisode, pageNumber, pageSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<YSearch?> UsersAsync(string userName, int page = 0, int pageSize = 20)
|
||||||
/// Поиск по видео
|
=> SearchAsync(userName, YSearchType.User, page, pageSize);
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="videoName">Имя видео</param>
|
|
||||||
/// <param name="pageNumber">Номер страницы</param>
|
|
||||||
/// <param name="pageSize">Размер страницы</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<YSearch>> VideosAsync(AuthStorage storage, string videoName, int pageNumber = 0, int pageSize = 20)
|
|
||||||
{
|
|
||||||
return SearchAsync(storage, videoName, YSearchType.Video, pageNumber, pageSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Поиск по пользователям
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="userName">Имя пользователя</param>
|
|
||||||
/// <param name="pageNumber">Номер страницы</param>
|
|
||||||
/// <param name="pageSize">Размер страницы</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<YSearch>> UsersAsync(AuthStorage storage, string userName, int pageNumber = 0, int pageSize = 20)
|
|
||||||
{
|
|
||||||
return SearchAsync(storage, userName, YSearchType.User, pageNumber, pageSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Поиск
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="searchText">Поисковый запрос</param>
|
|
||||||
/// <param name="searchType">Тип поиска</param>
|
|
||||||
/// <param name="page">Страница</param>
|
|
||||||
/// <param name="pageSize">Размер страницы</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<YSearch>> SearchAsync(AuthStorage storage, string searchText, YSearchType searchType, int page = 0, int pageSize = 20)
|
|
||||||
{
|
|
||||||
return new YSearchBuilder(api, storage)
|
|
||||||
.Build((searchText, searchType, page, pageSize))
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Подсказка
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="searchText">Поисковый запрос</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<YSearchSuggest>> SuggestAsync(AuthStorage storage, string searchText)
|
|
||||||
{
|
|
||||||
return new YSearchSuggestBuilder(api, storage)
|
|
||||||
.Build(searchText)
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
public Task<YSearch?> SearchAsync(string searchText, YSearchType searchType, int page = 0, int pageSize = 20)
|
||||||
|
=> new YSearchBuilder(Api).ExecuteAsync((searchText, searchType, page, pageSize));
|
||||||
|
|
||||||
|
public Task<YSearchSuggest?> GetSearchSuggestionsAsync(string searchText)
|
||||||
|
=> new YSearchSuggestBuilder(Api).ExecuteAsync(searchText);
|
||||||
}
|
}
|
||||||
@@ -1,307 +1,110 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
using YandexMusic.API.Common;
|
|
||||||
using YandexMusic.API.Models.Common;
|
using YandexMusic.API.Models.Common;
|
||||||
using YandexMusic.API.Models.Track;
|
using YandexMusic.API.Models.Track;
|
||||||
using YandexMusic.API.Requests.Track;
|
using YandexMusic.API.Requests.Track;
|
||||||
|
|
||||||
namespace YandexMusic.API;
|
namespace YandexMusic.API;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>API для работы с треками (получение, загрузка, метаданные).</summary>
|
||||||
/// API для взаимодействия с треками
|
public class YTrackAPI : YCommonAPI
|
||||||
/// </summary>
|
|
||||||
public partial class YTrackAPI : YCommonAPI
|
|
||||||
{
|
{
|
||||||
#region Вспомогательные функции
|
public YTrackAPI(YandexMusicApi api) : base(api) { }
|
||||||
|
|
||||||
private string BuildLinkForDownload(YTrackDownloadInfo mainDownloadResponse, YStorageDownloadFile storageDownload)
|
private static string BuildDownloadLink(YTrackDownloadInfo info, YStorageDownloadFile storageDownload)
|
||||||
{
|
{
|
||||||
string path = storageDownload.Path;
|
var path = storageDownload.Path;
|
||||||
string host = storageDownload.Host;
|
var host = storageDownload.Host;
|
||||||
string ts = storageDownload.Ts;
|
var ts = storageDownload.Ts;
|
||||||
string s = storageDownload.S;
|
var s = storageDownload.S;
|
||||||
string codec = mainDownloadResponse.Codec;
|
var codec = info.Codec;
|
||||||
|
|
||||||
string secret = $"XGRlBW9FXlekgbPrRHuSiA{path.Substring(1, path.Length - 1)}{s}";
|
var secret = $"XGRlBW9FXlekgbPrRHuSiA{path[1..]}{s}";
|
||||||
MD5 md5 = MD5.Create();
|
var md5Hash = MD5.HashData(Encoding.UTF8.GetBytes(secret));
|
||||||
byte[] md5Hash = md5.ComputeHash(Encoding.UTF8.GetBytes(secret));
|
var hmacsha1 = new HMACSHA1(md5Hash);
|
||||||
HMACSHA1 hmacsha1 = new();
|
var sign = BitConverter.ToString(hmacsha1.ComputeHash(md5Hash)).Replace("-", "").ToLower();
|
||||||
byte[] hmasha1Hash = hmacsha1.ComputeHash(md5Hash);
|
return $"https://{host}/get-{codec}/{sign}/{ts}{path}";
|
||||||
string sign = BitConverter.ToString(hmasha1Hash).Replace("-", "").ToLower();
|
|
||||||
|
|
||||||
string link = $"https://{host}/get-{codec}/{sign}/{ts}{path}";
|
|
||||||
|
|
||||||
return link;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion Вспомогательные функции
|
public Task<YTrack?> GetAsync(string trackId)
|
||||||
|
=> GetAsync(trackId);
|
||||||
|
|
||||||
|
public Task<List<YTrack>?> GetAsync(IEnumerable<string> trackIds)
|
||||||
|
=> new YGetTracksBuilder(Api).ExecuteAsync(trackIds);
|
||||||
|
public Task<List<YTrackDownloadInfo>?> GetMetadataForDownloadAsync(string trackKey, bool direct = false)
|
||||||
|
=> new YTrackDownloadInfoBuilder(Api).ExecuteAsync((trackKey, direct));
|
||||||
|
|
||||||
|
public Task<List<YTrackDownloadInfo>?> GetMetadataForDownloadAsync(YTrack track, bool direct = false)
|
||||||
|
=> GetMetadataForDownloadAsync(track.GetKey().ToString(), direct);
|
||||||
|
|
||||||
public YTrackAPI(YandexMusicApi yandex) : base(yandex)
|
public Task<YStorageDownloadFile?> GetDownloadFileInfoAsync(YTrackDownloadInfo metadataInfo)
|
||||||
|
=> new YStorageDownloadFileBuilder(Api).ExecuteAsync(metadataInfo.DownloadInfoUrl);
|
||||||
|
|
||||||
|
public async Task<string?> GetFileLinkAsync(string trackKey)
|
||||||
{
|
{
|
||||||
|
var meta = await GetMetadataForDownloadAsync(trackKey);
|
||||||
|
var info = meta?.OrderByDescending(i => i.BitrateInKbps).FirstOrDefault(m => m.Codec == "mp3");
|
||||||
|
if (info == null) return null;
|
||||||
|
var storageDownload = await GetDownloadFileInfoAsync(info);
|
||||||
|
if (storageDownload == null) return null;
|
||||||
|
return BuildDownloadLink(info, storageDownload);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public Task<string?> GetFileLinkAsync(YTrack track)
|
||||||
/// Получение треков
|
=> GetFileLinkAsync(track.GetKey().ToString());
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
public async Task ExtractToFileAsync(string trackKey, string filePath)
|
||||||
/// <param name="trackId">Идентификатор трека</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<List<YTrack>>> GetAsync(AuthStorage storage, string trackId)
|
|
||||||
{
|
{
|
||||||
return new YGetTracksBuilder(api, storage)
|
var url = await GetFileLinkAsync(trackKey);
|
||||||
.Build(new[] { trackId })
|
if (string.IsNullOrEmpty(url)) throw new Exception("Не удалось получить ссылку на трек");
|
||||||
.GetResponseAsync();
|
using var response = await Api.HttpClient.GetAsync(url);
|
||||||
|
await using var fs = File.Create(filePath);
|
||||||
|
await response.Content.CopyToAsync(fs);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public Task ExtractToFileAsync(YTrack track, string filePath)
|
||||||
/// Получение треков
|
=> ExtractToFileAsync(track.GetKey().ToString(), filePath);
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
public async Task<byte[]> ExtractDataAsync(string trackKey)
|
||||||
/// <param name="trackIds">Идентификаторы треков</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<List<YTrack>>> GetAsync(AuthStorage storage, IEnumerable<string> trackIds)
|
|
||||||
{
|
{
|
||||||
return new YGetTracksBuilder(api, storage)
|
var url = await GetFileLinkAsync(trackKey);
|
||||||
.Build(trackIds)
|
if (string.IsNullOrEmpty(url)) throw new Exception("Не удалось получить ссылку на трек");
|
||||||
.GetResponseAsync();
|
return await Api.HttpClient.GetByteArrayAsync(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public Task<byte[]> ExtractDataAsync(YTrack track)
|
||||||
/// Получение метаданных для загрузки
|
=> ExtractDataAsync(track.GetKey().ToString());
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
public async Task<Stream> ExtractStreamAsync(string trackKey, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead)
|
||||||
/// <param name="trackKey">Ключ трека в формате {идентифактор трека:идентификатор альбома}</param>
|
|
||||||
/// <param name="direct">Должен ли ответ содержать прямую ссылку на загрузку</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<List<YTrackDownloadInfo>>> GetMetadataForDownloadAsync(AuthStorage storage, string trackKey, bool direct = false)
|
|
||||||
{
|
{
|
||||||
return new YTrackDownloadInfoBuilder(api, storage)
|
var url = await GetFileLinkAsync(trackKey);
|
||||||
.Build((trackKey, direct))
|
if (string.IsNullOrEmpty(url)) throw new Exception("Не удалось получить ссылку на трек");
|
||||||
.GetResponseAsync();
|
var response = await Api.HttpClient.GetAsync(url, completionOption);
|
||||||
|
return await response.Content.ReadAsStreamAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public Task<Stream> ExtractStreamAsync(YTrack track, HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead)
|
||||||
/// Получение метаданных для загрузки
|
=> ExtractStreamAsync(track.GetKey().ToString(), completionOption);
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="track">Трек</param>
|
|
||||||
/// <param name="direct">Должен ли ответ содержать прямую ссылку на загрузку</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<List<YTrackDownloadInfo>>> GetMetadataForDownloadAsync(AuthStorage storage, YTrack track, bool direct = false)
|
|
||||||
{
|
|
||||||
return GetMetadataForDownloadAsync(storage, track.GetKey().ToString(), direct);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<string?> SendPlayTrackInfoAsync(
|
||||||
/// Получение информации для формирования ссылки для загрузки
|
YTrack track,
|
||||||
/// </summary>
|
string from,
|
||||||
/// <param name="storage">Хранилище</param>
|
bool fromCache = false,
|
||||||
/// <param name="metadataInfo">Метаданные для загрузки</param>
|
string playId = "",
|
||||||
/// <returns></returns>
|
string playlistId = "",
|
||||||
public Task<YStorageDownloadFile> GetDownloadFileInfoAsync(AuthStorage storage, YTrackDownloadInfo metadataInfo)
|
double totalPlayedSeconds = 0,
|
||||||
{
|
double endPositionSeconds = 0)
|
||||||
return new YStorageDownloadFileBuilder(api, storage)
|
=> new YSendTrackInfoBuilder(Api).ExecuteAsync((track, from, fromCache, playId, playlistId, totalPlayedSeconds, endPositionSeconds));
|
||||||
.Build(metadataInfo.DownloadInfoUrl)
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<YTrackSupplement?> GetSupplementAsync(string trackId)
|
||||||
/// Получение ссылки для загрузки
|
=> new YGetTrackSupplementBuilder(Api).ExecuteAsync(trackId);
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="trackKey">Ключ трека в формате {идентификатор трека:идентификатор альбома}</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public async Task<string> GetFileLinkAsync(AuthStorage storage, string trackKey)
|
|
||||||
{
|
|
||||||
YResponse<List<YTrackDownloadInfo>> meta = await GetMetadataForDownloadAsync(storage, trackKey);
|
|
||||||
YTrackDownloadInfo info = meta.Result
|
|
||||||
.OrderByDescending(i => i.BitrateInKbps)
|
|
||||||
.First(m => m.Codec == "mp3");
|
|
||||||
YStorageDownloadFile storageDownload = await GetDownloadFileInfoAsync(storage, info);
|
|
||||||
return BuildLinkForDownload(info, storageDownload);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<YTrackSupplement?> GetSupplementAsync(YTrack track)
|
||||||
/// Получение ссылки для загрузки
|
=> GetSupplementAsync(track.GetKey().ToString());
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="track">Трек</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<string> GetFileLinkAsync(AuthStorage storage, YTrack track)
|
|
||||||
{
|
|
||||||
return GetFileLinkAsync(storage, track.GetKey().ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Отправка текущего состояния прослушиваемого трека
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="track">Трек</param>
|
|
||||||
/// <param name="from">Наименования клиента, с которого происходит прослушивание</param>
|
|
||||||
/// <param name="fromCache">Проигрывается ли трек с кеша</param>
|
|
||||||
/// <param name="playId">Уникальный идентификатор проигрывания</param>
|
|
||||||
/// <param name="playlistId">Уникальный идентификатор плейлиста, если таковой прослушивается</param>
|
|
||||||
/// <param name="totalPlayedSeconds">Сколько было всего воспроизведено трека в секундах</param>
|
|
||||||
/// <param name="endPositionSeconds">Окончательное значение воспроизведенных секунд</param>
|
|
||||||
/// </summary>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<string> SendPlayTrackInfoAsync(AuthStorage storage, YTrack track, string from, bool fromCache = false, string playId = "", string playlistId = "", double totalPlayedSeconds = 0, double endPositionSeconds = 0)
|
|
||||||
{
|
|
||||||
return new YSendTrackInfoBuilder(api, storage)
|
|
||||||
.Build((track, from, fromCache, playId, playlistId, totalPlayedSeconds, endPositionSeconds))
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
#region GetSupplement
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Получение дополнительной информации для трека
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="trackId">Идентификатор трека</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<YTrackSupplement>> GetSupplementAsync(AuthStorage storage, string trackId)
|
|
||||||
{
|
|
||||||
return new YGetTrackSupplementBuilder(api, storage)
|
|
||||||
.Build(trackId)
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Получение дополнительной информации для трека
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="track">Трек</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<YTrackSupplement>> GetSupplementAsync(AuthStorage storage, YTrack track)
|
|
||||||
{
|
|
||||||
return new YGetTrackSupplementBuilder(api, storage)
|
|
||||||
.Build(track.GetKey().ToString())
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion GetSupplement
|
|
||||||
|
|
||||||
#region GetSimilar
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Получение похожих треков
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="trackId">Идентификатор трека</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<YTrackSimilar>> GetSimilarAsync(AuthStorage storage, string trackId)
|
|
||||||
{
|
|
||||||
return new YGetTrackSimilarBuilder(api, storage)
|
|
||||||
.Build(trackId)
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Получение похожих треков
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="track">Трек</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<YTrackSimilar>> GetSimilarAsync(AuthStorage storage, YTrack track)
|
|
||||||
{
|
|
||||||
return new YGetTrackSimilarBuilder(api, storage)
|
|
||||||
.Build(track.GetKey().ToString())
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion GetSimilar
|
|
||||||
|
|
||||||
#region Получение данных трека
|
|
||||||
|
|
||||||
#region В файл
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Выгрузка в файл
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="trackKey">Ключ трека в формате {идентификатор трека:идентификатор альбома}</param>
|
|
||||||
/// <param name="filePath">Путь для файла</param>
|
|
||||||
public async Task ExtractToFileAsync(AuthStorage storage, string trackKey, string filePath)
|
|
||||||
{
|
|
||||||
string url = await GetFileLinkAsync(storage, trackKey);
|
|
||||||
await new DataDownloader(storage).ToFile(url, filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Выгрузка в файл
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="track">Трек</param>
|
|
||||||
/// <param name="filePath">Путь для файла</param>
|
|
||||||
public Task ExtractToFileAsync(AuthStorage storage, YTrack track, string filePath)
|
|
||||||
{
|
|
||||||
return ExtractToFileAsync(storage, track.GetKey().ToString(), filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion В файл
|
|
||||||
|
|
||||||
#region В массив байт
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Получение двоичного массива данных
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="trackKey">Ключ трека в формате {идентификатор трека:идентификатор альбома}</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public async Task<byte[]> ExtractDataAsync(AuthStorage storage, string trackKey)
|
|
||||||
{
|
|
||||||
string url = await GetFileLinkAsync(storage, trackKey);
|
|
||||||
return await new DataDownloader(storage).AsBytes(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Получение двоичного массива данных
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="track">Трек</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<byte[]> ExtractDataAsync(AuthStorage storage, YTrack track)
|
|
||||||
{
|
|
||||||
return ExtractDataAsync(storage, track.GetKey().ToString());
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion В массив байт
|
|
||||||
|
|
||||||
#region В поток
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Получение потока данных
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="trackKey">Ключ трека в формате {идентификатор трека:идентификатор альбома}</param>
|
|
||||||
/// <param name="httpCompletionOption">Параметры передачи управления при http запросе</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public async Task<Stream> ExtractStreamAsync(AuthStorage storage, string trackKey,
|
|
||||||
HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead)
|
|
||||||
{
|
|
||||||
string url = await GetFileLinkAsync(storage, trackKey);
|
|
||||||
return await new DataDownloader(storage).AsStream(url, httpCompletionOption);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Получение потока данных
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="track">Трек</param>
|
|
||||||
/// <param name="httpCompletionOption">Параметры передачи управления при http запросе</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<Stream> ExtractStreamAsync(AuthStorage storage, YTrack track,
|
|
||||||
HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead)
|
|
||||||
{
|
|
||||||
return ExtractStreamAsync(storage, track.GetKey().ToString(), httpCompletionOption);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion В поток
|
|
||||||
|
|
||||||
#endregion Получение данных трека
|
|
||||||
|
|
||||||
|
public Task<YTrackSimilar?> GetSimilarAsync(string trackId)
|
||||||
|
=> new YGetTrackSimilarBuilder(Api).ExecuteAsync(trackId);
|
||||||
|
|
||||||
|
public Task<YTrackSimilar?> GetSimilarAsync(YTrack track)
|
||||||
|
=> GetSimilarAsync(track.GetKey().ToString());
|
||||||
}
|
}
|
||||||
@@ -1,71 +1,36 @@
|
|||||||
using YandexMusic.API.Common;
|
using YandexMusic.API.Models.Playlist;
|
||||||
using YandexMusic.API.Models.Common;
|
|
||||||
using YandexMusic.API.Models.Playlist;
|
|
||||||
using YandexMusic.API.Models.Ugc;
|
using YandexMusic.API.Models.Ugc;
|
||||||
using YandexMusic.API.Requests.Ugc;
|
using YandexMusic.API.Requests.Ugc;
|
||||||
|
|
||||||
namespace YandexMusic.API;
|
namespace YandexMusic.API;
|
||||||
|
|
||||||
public partial class YUgcAPI : YCommonAPI
|
/// <summary>API для загрузки пользовательского контента (UGC).</summary>
|
||||||
|
public class YUgcAPI : YCommonAPI
|
||||||
{
|
{
|
||||||
public YUgcAPI(YandexMusicApi yandex) : base(yandex)
|
public YUgcAPI(YandexMusicApi api) : base(api) { }
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public Task<YUgcUpload?> GetUgcUploadLinkAsync(YPlaylist playlist, string fileName)
|
||||||
/// Получение ссылки на загрузчик трека
|
=> new YUgcGetUploadLinkBuilder(Api).ExecuteAsync((playlist, fileName));
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="playlist">Плейлист, куда будет загружен трек</param>
|
|
||||||
/// <param name="fileName">Название файла для загрузки</param>
|
|
||||||
public Task<YUgcUpload> GetUgcUploadLinkAsync(AuthStorage storage, YPlaylist playlist, string fileName)
|
|
||||||
{
|
|
||||||
return new YUgcGetUploadLinkBuilder(api, storage)
|
|
||||||
.Build((playlist, fileName))
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
public async Task<string?> UploadTrackToPlaylistAsync(YPlaylist playlist, string fileName, string filePath)
|
||||||
/// Загрузка трека из файла
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="uploadLink">Ссылка на балансировщик для загрузки, можно получить из GetUgcUploadLinkAsync</param>
|
|
||||||
/// <param name="filePath">Загружаемый файл</param>
|
|
||||||
public Task<YResponse<string>> UploadUgcTrackAsync(AuthStorage storage, string uploadLink, string filePath)
|
|
||||||
{
|
{
|
||||||
if (!File.Exists(filePath))
|
if (!File.Exists(filePath))
|
||||||
throw new FileNotFoundException("Файл для загрузки не существует.", filePath);
|
throw new FileNotFoundException("Файл не найден", filePath);
|
||||||
|
return await UploadTrackToPlaylistAsync(playlist, fileName, await File.ReadAllBytesAsync(filePath));
|
||||||
return UploadUgcTrackAsync(storage, uploadLink, File.Open(filePath, FileMode.Open));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public async Task<string?> UploadTrackToPlaylistAsync(YPlaylist playlist, string fileName, Stream stream)
|
||||||
/// Загрузка трека из потока
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="uploadLink">Ссылка на балансировщик для загрузки, можно получить из GetUgcUploadLinkAsync</param>
|
|
||||||
/// <param name="stream">Поток с данными для загрузки</param>
|
|
||||||
public Task<YResponse<string>> UploadUgcTrackAsync(AuthStorage storage, string uploadLink, Stream stream)
|
|
||||||
{
|
{
|
||||||
if (stream == null)
|
using var ms = new MemoryStream();
|
||||||
throw new NullReferenceException("Пустая ссылка на поток загрузки.");
|
await stream.CopyToAsync(ms);
|
||||||
|
return await UploadTrackToPlaylistAsync(playlist, fileName, ms.ToArray());
|
||||||
using MemoryStream ms = new();
|
|
||||||
stream.CopyTo(ms);
|
|
||||||
|
|
||||||
return UploadUgcTrackAsync(storage, uploadLink, ms.ToArray());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public async Task<string?> UploadTrackToPlaylistAsync(YPlaylist playlist, string fileName, byte[] file)
|
||||||
/// Загрузка трека из массива
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="uploadLink">Ссылка на балансировщик для загрузки, можно получить из GetUgcUploadLinkAsync</param>
|
|
||||||
/// <param name="file">Загружаемый трек в виде массив байтов</param>
|
|
||||||
public Task<YResponse<string>> UploadUgcTrackAsync(AuthStorage storage, string uploadLink, byte[] file)
|
|
||||||
{
|
{
|
||||||
return new YUgcUploadBuilder(api, storage)
|
var uploadLink = await GetUgcUploadLinkAsync(playlist, fileName);
|
||||||
.Build((uploadLink, file))
|
if (uploadLink?.PostTarget == null) return null;
|
||||||
.GetResponseAsync();
|
var result = await new YUgcUploadBuilder(Api).ExecuteAsync((uploadLink.PostTarget, file));
|
||||||
|
return result?.Result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,300 +1,159 @@
|
|||||||
using System.Security.Authentication;
|
using System.Security.Authentication;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
using YandexMusic.API.Common;
|
|
||||||
using YandexMusic.API.Models.Account;
|
using YandexMusic.API.Models.Account;
|
||||||
using YandexMusic.API.Models.Common;
|
|
||||||
using YandexMusic.API.Requests.Account;
|
using YandexMusic.API.Requests.Account;
|
||||||
|
|
||||||
namespace YandexMusic.API;
|
namespace YandexMusic.API;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>API для работы с пользователем и авторизации.</summary>
|
||||||
/// API для пользователя
|
public class YUserAPI : YCommonAPI
|
||||||
/// </summary>
|
|
||||||
public partial class YUserAPI : YCommonAPI
|
|
||||||
{
|
{
|
||||||
#region Вспомогательные функции
|
public YUserAPI(YandexMusicApi api) : base(api) { }
|
||||||
|
|
||||||
private async Task<bool> GetCsrfTokenAsync(AuthStorage storage)
|
private async Task<bool> GetCsrfTokenAsync()
|
||||||
{
|
{
|
||||||
using HttpResponseMessage authMethodsResponse = await new YGetAuthMethodsBuilder(api, storage)
|
using var response = await new YGetAuthMethodsBuilder(Api).ExecuteRawAsync(null!);
|
||||||
.Build(null)
|
if (response == null || !response.IsSuccessStatusCode)
|
||||||
.GetResponseAsync();
|
throw new HttpRequestException("Не удалось получить CSRF-токен");
|
||||||
|
|
||||||
if (!authMethodsResponse.IsSuccessStatusCode)
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
throw new HttpRequestException("Невозможно получить CFRF-токен.");
|
var csrfMatch = Regex.Match(content, @"window\.__CSRF__\s*=\s*""([^""]+)""");
|
||||||
|
var processMatch = Regex.Match(content, @"'process_uuid'\s*:\s*'([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})'");
|
||||||
|
|
||||||
string responseString = await authMethodsResponse.Content
|
if (!csrfMatch.Success || !processMatch.Success)
|
||||||
.ReadAsStringAsync();
|
|
||||||
Match match = Regex.Match(responseString, "\"csrf_token\" value=\"([^\"]+)\"");
|
|
||||||
|
|
||||||
if (!match.Success || match.Groups.Count < 2)
|
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
storage.AuthToken = new YAuthToken
|
Api.Storage.AuthToken = new YAuthToken
|
||||||
{
|
{
|
||||||
CsfrToken = match.Groups[1].Value
|
CsfrToken = csrfMatch.Groups[1].Value,
|
||||||
|
ProcessUuid = processMatch.Groups[1].Value
|
||||||
};
|
};
|
||||||
|
|
||||||
|
await new YPostAuthStats(Api).ExecuteAsync(null!);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> LoginByCookiesAsync()
|
||||||
|
{
|
||||||
|
if (Api.Storage.AuthToken == null)
|
||||||
|
throw new AuthenticationException("Сессия входа не инициализирована");
|
||||||
|
|
||||||
|
var accessToken = await new YGetAuthCookiesBuilder(Api).ExecuteAsync(null!);
|
||||||
|
if (accessToken == null || string.IsNullOrEmpty(accessToken.AccessToken))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
Api.Storage.AccessToken = accessToken;
|
||||||
|
Api.Storage.Token = accessToken.AccessToken;
|
||||||
|
|
||||||
|
var shortInfo = await new YGetShortAccountInfoBuilder(Api).ExecuteAsync(null!);
|
||||||
|
if (shortInfo?.Status != YAuthStatus.Ok || string.IsNullOrWhiteSpace(shortInfo.Uid))
|
||||||
|
throw new Exception("Не удалось подтвердить авторизацию");
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> LoginByCookiesAsync(AuthStorage storage)
|
public async Task AuthorizeAsync(string token)
|
||||||
{
|
|
||||||
if (storage.AuthToken == null)
|
|
||||||
throw new AuthenticationException("Невозможно инициализировать сессию входа.");
|
|
||||||
|
|
||||||
YAccessToken accessToken = await new YGetAuthCookiesBuilder(api, storage)
|
|
||||||
.Build(null)
|
|
||||||
.GetResponseAsync();
|
|
||||||
|
|
||||||
storage.IsAuthorized = !string.IsNullOrEmpty(accessToken.AccessToken);
|
|
||||||
|
|
||||||
storage.AccessToken = accessToken;
|
|
||||||
storage.Token = accessToken.AccessToken;
|
|
||||||
|
|
||||||
YShortAccountInfo validateTokenResponse = await new YGetShortAccountInifoBuilder(api, storage)
|
|
||||||
.Build(null)
|
|
||||||
.GetResponseAsync();
|
|
||||||
|
|
||||||
if (validateTokenResponse.Status != YAuthStatus.Ok)
|
|
||||||
throw new Exception("Вход в аккаунт не выполнен.");
|
|
||||||
|
|
||||||
storage.IsAuthorized = !string.IsNullOrWhiteSpace(validateTokenResponse.Uid);
|
|
||||||
|
|
||||||
return storage.IsAuthorized;
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion Вспомогательные функции
|
|
||||||
|
|
||||||
public YUserAPI(YandexMusicApi yandex) : base(yandex)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Авторизация
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="token">Токен авторизации</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public async Task AuthorizeAsync(AuthStorage storage, string token)
|
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(token))
|
if (string.IsNullOrEmpty(token))
|
||||||
throw new Exception("Задан пустой токен авторизации.");
|
throw new Exception("Токен не может быть пустым");
|
||||||
|
|
||||||
storage.Token = token;
|
Api.Storage.Token = token;
|
||||||
|
var authInfo = await new YGetAuthInfoBuilder(Api).ExecuteAsync(null!);
|
||||||
|
if (authInfo?.Account?.Uid == null)
|
||||||
|
throw new Exception("Пользователь не авторизован");
|
||||||
|
|
||||||
// Пытаемся получить информацию о пользователе
|
Api.Storage.SetAuthorized(authInfo.Account, token);
|
||||||
YResponse<YAccountResult> authInfo = await GetUserAuthAsync(storage);
|
|
||||||
|
|
||||||
// Если не авторизован, то авторизуем
|
|
||||||
if (string.IsNullOrEmpty(authInfo.Result.Account.Uid))
|
|
||||||
throw new Exception("Пользователь незалогинен.");
|
|
||||||
|
|
||||||
// Флаг авторизации
|
|
||||||
storage.IsAuthorized = true;
|
|
||||||
storage.User = authInfo.Result.Account;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public Task<YAccountResult?> GetUserAuthAsync()
|
||||||
/// Получение информации об авторизации
|
=> new YGetAuthInfoBuilder(Api).ExecuteAsync(null!);
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
public async Task<YAuthTypes?> CreateAuthSessionAsync(string userName)
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YResponse<YAccountResult>> GetUserAuthAsync(AuthStorage storage)
|
|
||||||
{
|
{
|
||||||
return new YGetAuthInfoBuilder(api, storage)
|
if (!await GetCsrfTokenAsync())
|
||||||
.Build(null)
|
throw new Exception("Не удалось инициализировать сессию");
|
||||||
.GetResponseAsync();
|
|
||||||
|
var result = await new YGetAuthLoginUserBuilder(Api).ExecuteAsync((Api.Storage.AuthToken.CsfrToken, userName));
|
||||||
|
if (result?.TrackId != null)
|
||||||
|
Api.Storage.AuthToken.TrackId = result.TrackId;
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public async Task<string?> GetAuthQRLinkAsync()
|
||||||
/// Создание сеанса и получение доступных методов авторизации
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="userName">Имя пользователя</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public async Task<YAuthTypes> CreateAuthSessionAsync(AuthStorage storage, string userName)
|
|
||||||
{
|
{
|
||||||
if (!await GetCsrfTokenAsync(storage))
|
if (!await GetCsrfTokenAsync())
|
||||||
throw new Exception("Невозможно инициализировать сессию входа.");
|
throw new Exception("Не удалось инициализировать сессию");
|
||||||
|
|
||||||
YAuthTypes types = await new YGetAuthLoginUserBuilder(api, storage)
|
var qr = await new YGetAuthQRBuilder(Api).ExecuteAsync(null!);
|
||||||
.Build((storage.AuthToken.CsfrToken, userName))
|
if (qr?.Status != YAuthStatus.Ok || string.IsNullOrEmpty(qr.TrackId))
|
||||||
.GetResponseAsync();
|
return null;
|
||||||
|
|
||||||
storage.AuthToken.TrackId = types.TrackId;
|
Api.Storage.AuthToken = new YAuthToken
|
||||||
|
|
||||||
return types;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Получение ссылки на QR-код
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public async Task<string> GetAuthQRLinkAsync(AuthStorage storage)
|
|
||||||
{
|
|
||||||
if (!await GetCsrfTokenAsync(storage))
|
|
||||||
throw new Exception("Невозможно инициализировать сессию входа.");
|
|
||||||
|
|
||||||
YAuthQR result = await new YGetAuthQRBuilder(api, storage)
|
|
||||||
.Build(null)
|
|
||||||
.GetResponseAsync();
|
|
||||||
|
|
||||||
if (result.Status != YAuthStatus.Ok)
|
|
||||||
return string.Empty;
|
|
||||||
|
|
||||||
storage.AuthToken = new YAuthToken
|
|
||||||
{
|
{
|
||||||
TrackId = result.TrackId,
|
TrackId = qr.TrackId,
|
||||||
CsfrToken = result.CsrfToken
|
CsfrToken = qr.CsrfToken
|
||||||
};
|
};
|
||||||
|
return $"https://passport.yandex.ru/auth/magic/code/?track_id={qr.TrackId}";
|
||||||
return $"https://passport.yandex.ru/auth/magic/code/?track_id={result.TrackId}";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public async Task<YAuthQRStatus?> AuthorizeByQRAsync()
|
||||||
/// Авторизация по QR-коду
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public async Task<YAuthQRStatus> AuthorizeByQRAsync(AuthStorage storage)
|
|
||||||
{
|
{
|
||||||
if (storage.AuthToken == null)
|
if (Api.Storage.AuthToken == null)
|
||||||
throw new Exception("Не выполнен запрос на авторизацию по QR.");
|
throw new Exception("Сессия не инициализирована");
|
||||||
|
|
||||||
try
|
var status = await new YGetAuthLoginQRBuilder(Api).ExecuteAsync(null!);
|
||||||
{
|
if (status?.Status == YAuthStatus.Ok && await LoginByCookiesAsync())
|
||||||
YAuthQRStatus qrStatus = await new YGetAuthLoginQRBuilder(api, storage)
|
return status;
|
||||||
.Build(null)
|
throw new AuthenticationException("Ошибка авторизации по QR");
|
||||||
.GetResponseAsync();
|
|
||||||
if (qrStatus.Status != YAuthStatus.Ok)
|
|
||||||
return qrStatus;
|
|
||||||
|
|
||||||
bool ok = await LoginByCookiesAsync(storage);
|
|
||||||
if (!ok)
|
|
||||||
throw new AuthenticationException("Ошибка авторизации по QR.");
|
|
||||||
|
|
||||||
return qrStatus;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
throw new AuthenticationException("Ошибка авторизации по QR.", ex);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public Task<YAuthCaptcha?> GetCaptchaAsync()
|
||||||
/// Получение <see cref="YAuthCaptcha"/>
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YAuthCaptcha> GetCaptchaAsync(AuthStorage storage)
|
|
||||||
{
|
{
|
||||||
if (storage.AuthToken == null || string.IsNullOrWhiteSpace(storage.AuthToken.CsfrToken))
|
if (Api.Storage.AuthToken == null)
|
||||||
throw new AuthenticationException($"Не найдена сессия входа. Выполните {nameof(CreateAuthSessionAsync)} перед использованием.");
|
throw new AuthenticationException("Выполните CreateAuthSessionAsync перед использованием");
|
||||||
|
return new YGetAuthCaptchaBuilder(Api).ExecuteAsync(null!);
|
||||||
return new YGetAuthCaptchaBuilder(api, storage)
|
|
||||||
.Build(null)
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public Task<YAuthBase?> AuthorizeByCaptchaAsync(string captchaValue)
|
||||||
/// Авторизация по captcha
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="captchaValue">Значение captcha</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YAuthBase> AuthorizeByCaptchaAsync(AuthStorage storage, string captchaValue)
|
|
||||||
{
|
{
|
||||||
if (storage.AuthToken == null || string.IsNullOrWhiteSpace(storage.AuthToken.CsfrToken))
|
if (Api.Storage.AuthToken == null)
|
||||||
throw new AuthenticationException($"Не найдена сессия входа. Выполните {nameof(CreateAuthSessionAsync)} перед использованием.");
|
throw new AuthenticationException("Выполните CreateAuthSessionAsync перед использованием");
|
||||||
|
return new YGetAuthLoginCaptchaBuilder(Api).ExecuteAsync(captchaValue);
|
||||||
return new YGetAuthLoginCaptchaBuilder(api, storage)
|
|
||||||
.Build(captchaValue)
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public Task<YAuthLetter?> GetAuthLetterAsync()
|
||||||
/// Получение письма авторизации на почту пользователя
|
=> new YGetAuthLetterBuilder(Api).ExecuteAsync(null!);
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
public async Task<bool> AuthorizeByLetterAsync()
|
||||||
/// <returns></returns>
|
|
||||||
public Task<YAuthLetter> GetAuthLetterAsync(AuthStorage storage)
|
|
||||||
{
|
{
|
||||||
return new YGetAuthLetterBuilder(api, storage)
|
var status = await new YGetAuthLoginLetterBuilder(Api).ExecuteAsync(null!);
|
||||||
.Build(null)
|
if (status?.Status != YAuthStatus.Ok || !status.MagicLinkConfirmed)
|
||||||
.GetResponseAsync();
|
throw new Exception("Письмо не подтверждено");
|
||||||
|
return await LoginByCookiesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public async Task<YAuthBase?> AuthorizeByAppPasswordAsync(string password)
|
||||||
/// Авторизация после подтверждения входа через письмо
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public async Task<bool> AuthorizeByLetterAsync(AuthStorage storage)
|
|
||||||
{
|
{
|
||||||
YAuthLetterStatus status = await new YGetAuthLoginLetterBuilder(api, storage)
|
if (Api.Storage.AuthToken == null)
|
||||||
.Build(null)
|
throw new AuthenticationException("Выполните CreateAuthSessionAsync перед использованием");
|
||||||
.GetResponseAsync();
|
|
||||||
|
|
||||||
if (status.Status == YAuthStatus.Ok && !status.MagicLinkConfirmed)
|
var result = await new YGetAuthAppPasswordBuilder(Api).ExecuteAsync(password);
|
||||||
throw new Exception("Не подтвержден вход посредством e-mail.");
|
if (result?.Status == YAuthStatus.Ok && await LoginByCookiesAsync())
|
||||||
|
return result;
|
||||||
return await LoginByCookiesAsync(storage);
|
throw new AuthenticationException("Ошибка авторизации по паролю");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public async Task<YAccessToken?> GetAccessTokenAsync()
|
||||||
/// Авторизация с помощью пароля из приложения Яндекс
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="storage">Хранилище</param>
|
|
||||||
/// <param name="password">Пароль</param>
|
|
||||||
/// <returns></returns>
|
|
||||||
public async Task<YAuthBase> AuthorizeByAppPasswordAsync(AuthStorage storage, string password)
|
|
||||||
{
|
{
|
||||||
if (storage.AuthToken == null || string.IsNullOrWhiteSpace(storage.AuthToken.CsfrToken))
|
if (Api.Storage.AuthToken == null)
|
||||||
throw new AuthenticationException($"Не найдена сессия входа. Выполните {nameof(CreateAuthSessionAsync)} перед использованием.");
|
throw new Exception("Сессия не инициализована");
|
||||||
|
|
||||||
YAuthBase response = await new YGetAuthAppPasswordBuilder(api, storage)
|
var token = await new YGetMusicTokenBuilder(Api).ExecuteAsync(null!);
|
||||||
.Build(password)
|
if (token?.AccessToken != null)
|
||||||
.GetResponseAsync();
|
Api.Storage.Token = token.AccessToken;
|
||||||
|
return token;
|
||||||
if (response.Status == YAuthStatus.Ok)
|
|
||||||
{
|
|
||||||
bool ok = await LoginByCookiesAsync(storage);
|
|
||||||
if (!ok)
|
|
||||||
throw new AuthenticationException("Ошибка авторизации.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public Task<YLoginInfo?> GetLoginInfoAsync()
|
||||||
/// Получение <see cref="YAccessToken"/> после авторизации с помощью QR, e-mail, пароля из приложения
|
=> new YGetLoginInfoBuilder(Api).ExecuteAsync(null!);
|
||||||
/// </summary>
|
|
||||||
public async Task<YAccessToken> GetAccessTokenAsync(AuthStorage storage)
|
|
||||||
{
|
|
||||||
if (storage.AuthToken == null)
|
|
||||||
throw new Exception("Не найдена сессия входа.");
|
|
||||||
|
|
||||||
YAccessToken accessToken = await new YGetMusicTokenBuilder(api, storage)
|
|
||||||
.Build(null)
|
|
||||||
.GetResponseAsync();
|
|
||||||
|
|
||||||
storage.Token = accessToken.AccessToken;
|
|
||||||
|
|
||||||
return accessToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Получение информации о пользователе через логин Яндекса
|
|
||||||
/// </summary>
|
|
||||||
public Task<YLoginInfo> GetLoginInfoAsync(AuthStorage storage)
|
|
||||||
{
|
|
||||||
return new YGetLoginInfoBuilder(api, storage)
|
|
||||||
.Build(null)
|
|
||||||
.GetResponseAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,16 @@
|
|||||||
using YandexMusic.API.Common;
|
|
||||||
using YandexMusic.API.Common.Ynison;
|
using YandexMusic.API.Common.Ynison;
|
||||||
|
|
||||||
namespace YandexMusic.API;
|
namespace YandexMusic.API;
|
||||||
/// <summary>
|
|
||||||
/// API Ynison
|
/// <summary>API для работы с Ynison (WebSocket-плеер).</summary>
|
||||||
/// </summary>
|
public class YYnisonAPI : YCommonAPI
|
||||||
public partial class YYnisonAPI : YCommonAPI
|
|
||||||
{
|
{
|
||||||
public YYnisonAPI(YandexMusicApi yandex) : base(yandex)
|
public YYnisonAPI(YandexMusicApi api) : base(api) { }
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public YnisonPlayer GetPlayer(AuthStorage storage)
|
public YnisonPlayer GetPlayer()
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(storage.Token))
|
if (string.IsNullOrEmpty(Api.Storage.Token))
|
||||||
throw new Exception("Токен пользователя не задан.");
|
throw new Exception("Токен пользователя не задан");
|
||||||
|
return new YnisonPlayer(Api, Api.Storage);
|
||||||
return new(api, storage);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,80 +1,61 @@
|
|||||||
using System.Net;
|
|
||||||
using YandexMusic.API.Common.Providers;
|
|
||||||
using YandexMusic.API.Models.Account;
|
using YandexMusic.API.Models.Account;
|
||||||
using YandexMusic.API.Requests.Common;
|
|
||||||
|
|
||||||
namespace YandexMusic.API.Common;
|
namespace YandexMusic.API.Common;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Хранилище данных пользователя
|
/// Хранилище данных авторизации. Не содержит HTTP-зависимостей.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AuthStorage
|
public class AuthStorage
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Http-контекст
|
/// Флаг, указывающий, авторизован ли пользователь.
|
||||||
/// </summary>
|
|
||||||
public HttpContext Context { get; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Флаг авторизации
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsAuthorized { get; internal set; }
|
public bool IsAuthorized { get; internal set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Идентификатор устройства
|
/// Идентификатор устройства (используется в заголовках).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string DeviceId { get; set; } = "csharp";
|
public string DeviceId { get; set; } = "csharp";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Токен авторизации
|
/// OAuth-токен для доступа к API.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Token { get; internal set; }
|
public string Token { get; internal set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Аккаунт
|
/// Информация об аккаунте пользователя.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public YAccount User { get; set; }
|
public YAccount User { get; internal set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Провайдер запросов
|
/// Временный токен доступа (используется в некоторых сценариях авторизации).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IRequestProvider Provider { get; }
|
public YAccessToken AccessToken { get; internal set; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Токен доступа
|
/// Внутренние данные авторизации (CSRF, track_id и т.д.).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public YAccessToken AccessToken { get; set; }
|
internal YAuthToken AuthToken { get; set; } = new();
|
||||||
|
|
||||||
internal YAuthToken AuthToken { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Конструктор
|
/// Устанавливает флаг авторизации и сохраняет информацию об аккаунте.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public AuthStorage(IRequestProvider provider)
|
internal void SetAuthorized(YAccount user, string token)
|
||||||
|
{
|
||||||
|
User = user ?? throw new ArgumentNullException(nameof(user));
|
||||||
|
Token = token ?? throw new ArgumentNullException(nameof(token));
|
||||||
|
IsAuthorized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Сбрасывает состояние авторизации.
|
||||||
|
/// </summary>
|
||||||
|
internal void ResetAuthorization()
|
||||||
{
|
{
|
||||||
User = new YAccount();
|
User = new YAccount();
|
||||||
Context = new HttpContext();
|
Token = string.Empty;
|
||||||
Provider = provider;
|
AccessToken = new YAccessToken();
|
||||||
|
AuthToken = new YAuthToken();
|
||||||
|
IsAuthorized = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Конструктор
|
|
||||||
/// </summary>
|
|
||||||
public AuthStorage()
|
|
||||||
{
|
|
||||||
User = new YAccount();
|
|
||||||
Context = new HttpContext();
|
|
||||||
Provider = new DefaultRequestProvider(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Установка прокси для пользователия
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="proxy">Прокси</param>
|
|
||||||
public void SetProxy(IWebProxy proxy)
|
|
||||||
{
|
|
||||||
Context.WebProxy = proxy;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
|
|
||||||
namespace YandexMusic.API.Common;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Загрузчик файлов по ссылке
|
|
||||||
/// </summary>
|
|
||||||
public class DataDownloader
|
|
||||||
{
|
|
||||||
private AuthStorage authStorage;
|
|
||||||
|
|
||||||
private async Task<HttpContent> GetResponseContent(string url, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead)
|
|
||||||
{
|
|
||||||
HttpRequestMessage message = new(new HttpMethod(WebRequestMethods.Http.Get), url);
|
|
||||||
|
|
||||||
HttpResponseMessage response = await authStorage.Provider.GetWebResponseAsync(message, httpCompletionOption);
|
|
||||||
return response.Content;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Stream> AsStream(string url, HttpCompletionOption httpCompletionOption = HttpCompletionOption.ResponseContentRead)
|
|
||||||
{
|
|
||||||
HttpContent content = await GetResponseContent(url, httpCompletionOption);
|
|
||||||
return await content.ReadAsStreamAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<byte[]> AsBytes(string url)
|
|
||||||
{
|
|
||||||
HttpContent content = await GetResponseContent(url);
|
|
||||||
return await content.ReadAsByteArrayAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task ToFile(string url, string fileName)
|
|
||||||
{
|
|
||||||
using Stream stream = await AsStream(url);
|
|
||||||
using FileStream fs = File.Create(fileName);
|
|
||||||
await stream.CopyToAsync(fs);
|
|
||||||
}
|
|
||||||
|
|
||||||
public DataDownloader(AuthStorage storage)
|
|
||||||
{
|
|
||||||
authStorage = storage;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using YandexMusic.API.Models.Common;
|
|
||||||
|
|
||||||
namespace YandexMusic.API.Common.Providers;
|
|
||||||
|
|
||||||
/// <summary>Базовый провайдер HTTP-запросов с общей логикой десериализации.</summary>
|
|
||||||
public abstract class CommonRequestProvider : IRequestProvider
|
|
||||||
{
|
|
||||||
/// <summary>Хранилище данных авторизации.</summary>
|
|
||||||
protected readonly AuthStorage storage;
|
|
||||||
|
|
||||||
/// <summary>Настройки сериализации JSON (регистронезависимые, поддержка enum-строк).</summary>
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
|
||||||
{
|
|
||||||
PropertyNameCaseInsensitive = true,
|
|
||||||
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) }
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>Инициализирует новый экземпляр провайдера.</summary>
|
|
||||||
/// <param name="authStorage">Хранилище авторизации.</param>
|
|
||||||
protected CommonRequestProvider(AuthStorage authStorage)
|
|
||||||
{
|
|
||||||
storage = authStorage;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Выполняет HTTP-запрос и возвращает ответ.</summary>
|
|
||||||
public abstract Task<HttpResponseMessage> GetWebResponseAsync(
|
|
||||||
HttpRequestMessage message,
|
|
||||||
HttpCompletionOption completionOption = HttpCompletionOption.ResponseContentRead);
|
|
||||||
|
|
||||||
/// <summary>Преобразует HTTP-ответ в объект типа T.</summary>
|
|
||||||
public virtual async Task<T> GetDataFromResponseAsync<T>(
|
|
||||||
YandexMusicApi api,
|
|
||||||
HttpResponseMessage response)
|
|
||||||
{
|
|
||||||
var json = await response.Content.ReadAsStringAsync();
|
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
var error = JsonSerializer.Deserialize<YErrorResponse>(json, JsonOptions);
|
|
||||||
throw error ?? new Exception("Ошибка десериализации ответа с ошибкой.");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Если нужен контекст выполнения, он добавляется через кастомный конвертер
|
|
||||||
return JsonSerializer.Deserialize<T>(json, JsonOptions)
|
|
||||||
?? throw new JsonException("Десериализация вернула null");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
throw new Exception($"Ошибка десериализации: {ex.Message}", ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user