Переработана страница поиска и добавления треков в плейлист

This commit is contained in:
FrigaT
2026-04-15 14:07:33 +03:00
parent c7bd97462a
commit e00b7a735c
26 changed files with 497 additions and 170 deletions

View File

@@ -5,7 +5,7 @@ using PlaylistShared.Api.Entities;
using PlaylistShared.Api.Extensions; using PlaylistShared.Api.Extensions;
using PlaylistShared.Api.Services; using PlaylistShared.Api.Services;
using PlaylistShared.Shared; using PlaylistShared.Shared;
using PlaylistShared.Shared.Shared; using PlaylistShared.Shared.SharedPlaylist;
namespace PlaylistShared.Api.Controllers; namespace PlaylistShared.Api.Controllers;

View File

@@ -7,7 +7,7 @@ using PlaylistShared.Api.Services;
using PlaylistShared.Shared; using PlaylistShared.Shared;
using PlaylistShared.Shared.Enums; using PlaylistShared.Shared.Enums;
using PlaylistShared.Shared.Playlist; using PlaylistShared.Shared.Playlist;
using PlaylistShared.Shared.Shared; using PlaylistShared.Shared.SharedPlaylist;
using YandexMusic; using YandexMusic;
namespace PlaylistShared.Api.Controllers; namespace PlaylistShared.Api.Controllers;

View File

@@ -6,7 +6,7 @@ using PlaylistShared.Api.Extensions;
using PlaylistShared.Api.Services; using PlaylistShared.Api.Services;
using PlaylistShared.Shared; using PlaylistShared.Shared;
using PlaylistShared.Shared.DTO; using PlaylistShared.Shared.DTO;
using PlaylistShared.Shared.Shared; using PlaylistShared.Shared.SharedPlaylist;
using YandexMusic.API.Models.Playlist; using YandexMusic.API.Models.Playlist;
[ApiController] [ApiController]
@@ -101,7 +101,7 @@ public class SharedPlaylistController : ControllerBase
// POST /api/sharedplaylist/{token}/add-tracks // POST /api/sharedplaylist/{token}/add-tracks
[HttpPost("{token}/add-tracks")] [HttpPost("{token}/add-tracks")]
public async Task<ActionResult<ApiResponse<object>>> AddTracks(string token, [FromBody] AddTracksRequest request) public async Task<ActionResult<ApiResponse<object>>> AddTracks(string token, [FromBody] UpdateTrackListRequest request)
{ {
var currentUserId = User.GetUserIdOrNull(); var currentUserId = User.GetUserIdOrNull();
var playlist = await _sharedService.GetEntityByTokenAsync(token); var playlist = await _sharedService.GetEntityByTokenAsync(token);
@@ -131,7 +131,7 @@ public class SharedPlaylistController : ControllerBase
// POST /api/sharedplaylist/{token}/remove-tracks // POST /api/sharedplaylist/{token}/remove-tracks
[HttpPost("{token}/remove-tracks")] [HttpPost("{token}/remove-tracks")]
public async Task<ActionResult<ApiResponse<object>>> RemoveTracks(string token, [FromBody] RemoveTracksRequest request) public async Task<ActionResult<ApiResponse<object>>> RemoveTracks(string token, [FromBody] UpdateTrackListRequest request)
{ {
var currentUserId = User.GetUserIdOrNull(); var currentUserId = User.GetUserIdOrNull();
var playlist = await _sharedService.GetEntityByTokenAsync(token); var playlist = await _sharedService.GetEntityByTokenAsync(token);
@@ -164,23 +164,6 @@ public class SharedPlaylistController : ControllerBase
return Ok(ApiResponse<object>.Ok(new { message = "Треки удалены" })); return Ok(ApiResponse<object>.Ok(new { message = "Треки удалены" }));
} }
// POST /api/sharedplaylist/{token}/add-track-by-link
[HttpPost("{token}/add-track-by-link")]
public async Task<ActionResult<ApiResponse<object>>> AddTrackByLink(string token, [FromBody] AddTrackByLinkRequest request)
{
var trackId = ExtractTrackIdFromLink(request.Link);
if (string.IsNullOrEmpty(trackId))
return BadRequest(ApiResponse<object>.Fail(new ErrorResponse { StatusCode = 400, Message = "Неверный формат ссылки" }));
return await AddTracks(token, new AddTracksRequest { TrackIds = new List<string> { trackId } });
}
private string? ExtractTrackIdFromLink(string link)
{
var match = System.Text.RegularExpressions.Regex.Match(link, @"/track/(\d+)");
return match.Success ? match.Groups[1].Value : null;
}
private YandexPlaylistData MapToYandexPlaylistData(YPlaylist playlist) private YandexPlaylistData MapToYandexPlaylistData(YPlaylist playlist)
{ {
return new YandexPlaylistData return new YandexPlaylistData

View File

@@ -6,6 +6,7 @@ using PlaylistShared.Api.Extensions;
using PlaylistShared.Api.Services; using PlaylistShared.Api.Services;
using PlaylistShared.Shared; using PlaylistShared.Shared;
using PlaylistShared.Shared.DTO; using PlaylistShared.Shared.DTO;
using PlaylistShared.Shared.Enums;
namespace PlaylistShared.Api.Controllers; namespace PlaylistShared.Api.Controllers;
@@ -26,9 +27,11 @@ public class YandexSearchController : ControllerBase
} }
[HttpGet("tracks")] [HttpGet("tracks")]
public async Task<ActionResult<ApiResponse<List<YandexTrack>>>> SearchTracks( public async Task<ActionResult<ApiResponse<List<YandexTrack>>>> SearchQuery(
[FromQuery] string query, [FromQuery] string query,
[FromQuery] int limit = 20, [FromQuery] int limit = 20,
[FromQuery] TrackSearchType? searchType = TrackSearchType.All,
[FromQuery] bool byId = false,
[FromQuery] string? shared_id = null) [FromQuery] string? shared_id = null)
{ {
if (string.IsNullOrWhiteSpace(query)) if (string.IsNullOrWhiteSpace(query))
@@ -68,7 +71,17 @@ public class YandexSearchController : ControllerBase
Message = "Токен Яндекс.Музыки не установлен или недействителен." Message = "Токен Яндекс.Музыки не установлен или недействителен."
})); }));
var results = await _yandexService.SearchTracksAsync(user, query, limit); List<YandexTrack>? results = null;
if (byId)
{
results = await _yandexService.SearchTracksByIdAsync(user, query, searchType.Value, limit);
}
else
{
results = await _yandexService.SearchTracksAsync(user, query, searchType, limit);
}
return Ok(ApiResponse<List<YandexTrack>>.Ok(results)); return Ok(ApiResponse<List<YandexTrack>>.Ok(results));
} }
} }

View File

@@ -1,7 +1,7 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using PlaylistShared.Api.Data; using PlaylistShared.Api.Data;
using PlaylistShared.Api.Entities; using PlaylistShared.Api.Entities;
using PlaylistShared.Shared.Shared; using PlaylistShared.Shared.SharedPlaylist;
namespace PlaylistShared.Api.Services; namespace PlaylistShared.Api.Services;

View File

@@ -4,7 +4,7 @@ using PlaylistShared.Api.Entities;
using PlaylistShared.Shared.Auth; using PlaylistShared.Shared.Auth;
using PlaylistShared.Shared.Enums; using PlaylistShared.Shared.Enums;
using PlaylistShared.Shared.Playlist; using PlaylistShared.Shared.Playlist;
using PlaylistShared.Shared.Shared; using PlaylistShared.Shared.SharedPlaylist;
namespace PlaylistShared.Api.Services; namespace PlaylistShared.Api.Services;

View File

@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using PlaylistShared.Api.Entities; using PlaylistShared.Api.Entities;
using PlaylistShared.Shared.DTO; using PlaylistShared.Shared.DTO;
using PlaylistShared.Shared.Enums;
using YandexMusic; using YandexMusic;
using YandexMusic.API.Extensions.API; using YandexMusic.API.Extensions.API;
using YandexMusic.API.Models.Playlist; using YandexMusic.API.Models.Playlist;
@@ -107,12 +108,26 @@ public class YandexMusicService
} }
} }
public async Task<List<YandexTrack>> SearchTracksAsync(ApplicationUser user, string query, int limit = 20) public async Task<List<YandexTrack>> SearchTracksAsync(
ApplicationUser user,
string query,
TrackSearchType? searchType = TrackSearchType.All,
int limit = 20
)
{ {
var client = await CreateClientAsync(user); var client = await CreateClientAsync(user);
if (client == null) return new List<YandexTrack>(); if (client == null) return new List<YandexTrack>();
var searchResult = await client.SearchAsync(query, YandexMusic.API.Models.Common.YSearchType.Track, page: 0, pageSize: limit); var ySerchType = searchType switch
{
TrackSearchType.Artist => YandexMusic.API.Models.Common.YSearchType.Artist,
TrackSearchType.Album => YandexMusic.API.Models.Common.YSearchType.Album,
TrackSearchType.Playlist => YandexMusic.API.Models.Common.YSearchType.Playlist,
TrackSearchType.Track => YandexMusic.API.Models.Common.YSearchType.Track,
_ => YandexMusic.API.Models.Common.YSearchType.All
};
var searchResult = await client.SearchAsync(query, ySerchType, page: 0, pageSize: limit);
if (searchResult?.Tracks?.Results == null) return new List<YandexTrack>(); if (searchResult?.Tracks?.Results == null) return new List<YandexTrack>();
return searchResult.Tracks.Results.Select(t => new YandexTrack return searchResult.Tracks.Results.Select(t => new YandexTrack
@@ -124,4 +139,48 @@ public class YandexMusicService
DurationMs = t.DurationMs, DurationMs = t.DurationMs,
}).ToList(); }).ToList();
} }
public async Task<List<YandexTrack>> SearchTracksByIdAsync(
ApplicationUser user,
string id,
TrackSearchType searchType,
int limit = 20
)
{
var client = await CreateClientAsync(user);
if (client == null) return new List<YandexTrack>();
var ySerchType = searchType switch
{
TrackSearchType.Artist => YandexMusic.API.Models.Common.YSearchType.Artist,
TrackSearchType.Album => YandexMusic.API.Models.Common.YSearchType.Album,
TrackSearchType.Playlist => YandexMusic.API.Models.Common.YSearchType.Playlist,
TrackSearchType.Track => YandexMusic.API.Models.Common.YSearchType.Track,
_ => YandexMusic.API.Models.Common.YSearchType.All
};
IEnumerable<YTrack> searchResult = searchType switch
{
TrackSearchType.Playlist => (await client.GetPlaylistAsync(id)).Tracks.Select(t => t.Track),
TrackSearchType.Track => (await client.GetTracksAsync([id])),
TrackSearchType.Album => (await client.GetAlbumAsync(id)).Volumes.SelectMany(t => t),
TrackSearchType.Artist => (await client.GetArtistAsync(id)).Albums.SelectMany(t => t.Volumes.SelectMany(v => v)),
_ => new List<YTrack>()
};
if (searchType != TrackSearchType.Track)
{
searchResult = searchResult.Distinct();
if (limit > 0) searchResult = searchResult.Take(limit);
}
return searchResult.Select(t => new YandexTrack
{
TrackId = t.Id,
Title = t.Title,
Artists = t.Artists?.Select(a => a.Name).ToList() ?? new List<string>(),
CoverUri = t.CoverUri ?? string.Empty,
DurationMs = t.DurationMs,
}).ToList();
}
} }

View File

@@ -7,28 +7,28 @@
<!-- Вертикальный список шагов --> <!-- Вертикальный список шагов -->
<MudStack Class="my-4"> <MudStack Class="my-4">
<MudStack AlignItems="AlignItems.Center"> <MudStack Row AlignItems="AlignItems.Center">
<MudPaper Elevation="0" Style="width: 28px; height: 28px; background-color: var(--mud-palette-primary); color: white; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: bold;">1</MudPaper> <MudPaper Elevation="0" Style="width: 28px; height: 28px; background-color: var(--mud-palette-primary); color: white; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: bold;">1</MudPaper>
<MudText> <MudText>
Перейдите по <MudLink Href="https://oauth.yandex.ru/authorize?response_type=token&client_id=23cabbbdc6cd418abb4b39c32c41195d" Target="_blank">ссылке</MudLink> Перейдите по <MudLink Href="https://oauth.yandex.ru/authorize?response_type=token&client_id=23cabbbdc6cd418abb4b39c32c41195d" Target="_blank">ссылке</MudLink>
</MudText> </MudText>
</MudStack> </MudStack>
<MudStack AlignItems="AlignItems.Center"> <MudStack Row AlignItems="AlignItems.Center">
<MudPaper Elevation="0" Style="width: 28px; height: 28px; background-color: var(--mud-palette-primary); color: white; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: bold;">1</MudPaper> <MudPaper Elevation="0" Style="width: 28px; height: 28px; background-color: var(--mud-palette-primary); color: white; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: bold;">1</MudPaper>
<MudText> <MudText>
Авторизуйтесь в Яндексе (если ещё не вошли) Авторизуйтесь в Яндексе (если ещё не вошли)
</MudText> </MudText>
</MudStack> </MudStack>
<MudStack AlignItems="AlignItems.Center"> <MudStack Row AlignItems="AlignItems.Center">
<MudPaper Elevation="0" Style="width: 28px; height: 28px; background-color: var(--mud-palette-primary); color: white; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: bold;">1</MudPaper> <MudPaper Elevation="0" Style="width: 28px; height: 28px; background-color: var(--mud-palette-primary); color: white; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: bold;">1</MudPaper>
<MudText> <MudText>
Нажмите «Разрешить» Нажмите «Разрешить»
</MudText> </MudText>
</MudStack> </MudStack>
<MudStack AlignItems="AlignItems.Center"> <MudStack Row AlignItems="AlignItems.Center">
<MudPaper Elevation="0" Style="width: 28px; height: 28px; background-color: var(--mud-palette-primary); color: white; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: bold;">1</MudPaper> <MudPaper Elevation="0" Style="width: 28px; height: 28px; background-color: var(--mud-palette-primary); color: white; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-weight: bold;">1</MudPaper>
<MudText> <MudText>
Скопируйте <strong>access_token</strong> из адресной строки после перенаправления Скопируйте <strong>access_token</strong> из адресной строки после перенаправления

View File

@@ -3,28 +3,19 @@
@inject HttpClient Http @inject HttpClient Http
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
<MudPaper Class="pa-4" Elevation="0" Style="background-color: rgba(0,0,0,0.05); border-radius: 8px;"> <MudStack AlignItems="AlignItems.Center">
<div style="display: flex; gap: 8px; margin-bottom: 16px;">
<MudTextField @bind-Value="_searchQuery" <MudTextField @bind-Value="_searchQuery"
Label="Название трека или исполнитель" Label="Название трека или исполнитель"
Variant="Variant.Outlined" Variant="Variant.Outlined"
Disabled="@_isSearching"
FullWidth="true" FullWidth="true"
OnKeyUp="@(async (e) => { if (e.Key == "Enter") await SearchTracks(); })" OnKeyUp="@(async (e) => { if (e.Key == "Enter") await SearchTracks(); })"
Placeholder="Например: Bohemian Rhapsody" /> Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.Search" AdornmentColor="Color.Secondary"
<MudButton Variant="Variant.Filled" />
Color="Color.Primary"
OnClick="SearchTracks"
Disabled="_isSearching"
StartIcon="@Icons.Material.Filled.Search">
Искать
</MudButton>
</div>
@if (_isSearching) @if (_isSearching)
{ {
<div style="text-align: center; padding: 20px;"> <MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-7" />
<MudProgressCircular Indeterminate />
</div>
} }
else if (_searchResults.Any()) else if (_searchResults.Any())
{ {
@@ -61,7 +52,7 @@
{ {
<MudAlert Severity="Severity.Info">Ничего не найдено. Попробуйте изменить запрос.</MudAlert> <MudAlert Severity="Severity.Info">Ничего не найдено. Попробуйте изменить запрос.</MudAlert>
} }
</MudPaper> </MudStack>
@code { @code {
[Parameter] public EventCallback<string> OnAddTrack { get; set; } [Parameter] public EventCallback<string> OnAddTrack { get; set; }
@@ -75,15 +66,11 @@
private async Task SearchTracks() private async Task SearchTracks()
{ {
if (string.IsNullOrWhiteSpace(_searchQuery))
return;
_isFirstSearch = false; _isFirstSearch = false;
_isSearching = true; _isSearching = true;
try try
{ {
var url = $"/api/yandexsearch/tracks?query={Uri.EscapeDataString(_searchQuery)}&limit=20"; var url = $"/api/yandexsearch/query?query={Uri.EscapeDataString(_searchQuery)}&limit=20";
if (!string.IsNullOrEmpty(ShareToken)) if (!string.IsNullOrEmpty(ShareToken))
url += $"&shared_id={Uri.EscapeDataString(ShareToken)}"; url += $"&shared_id={Uri.EscapeDataString(ShareToken)}";

View File

@@ -1,73 +1,210 @@
@using PlaylistShared.Shared.Shared @using PlaylistShared.Pwa.Components.Common
@using PlaylistShared.Shared.DTO
@using PlaylistShared.Shared.Enums
@using PlaylistShared.Shared.SharedPlaylist
@inject HttpClient Http @inject HttpClient Http
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
<MudPaper Class="mb-4" Elevation="0" Style="background-color: rgba(0,0,0,0.05); border-radius: 8px;"> <MudPaper Class="mb-4" Elevation="0" Style="background-color: rgba(0,0,0,0.05); border-radius: 8px;">
<MudTabs @bind-ActivePanelIndex="_activeTabIndex" Elevation="0" Style="border-bottom: 1px solid rgba(0,0,0,0.1);"> <MudStack AlignItems="AlignItems.Start">
<MudTabPanel Text="По ссылке" Style="padding: 16px;"> <MudTextField @bind-Value="_searchQuery"
<MudGrid> @bind-Value:after="SearchTracks"
<MudItem xs="12" sm="10"> Variant="Variant.Outlined"
<MudTextField @bind-Value="_trackLink" Label="Ссылка на трек Яндекс.Музыки" FullWidth
Variant="Variant.Outlined" FullWidth="true" Label="Название или ссылка на трек Яндекс.Музыки"
Placeholder="https://music.yandex.ru/album/2488464/track/21696942" Disabled="@_isSearching"
OnKeyUp="@(async (e) => { if (e.Key == "Enter") await AddTrackByLink(); })" /> Adornment="Adornment.End" AdornmentIcon="@Icons.Material.Filled.Search" AdornmentColor="Color.Secondary"
</MudItem> />
<MudItem xs="12" sm="2">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="AddTrackByLink" <MudToggleGroup T="TrackSearchType"
Disabled="_addingTrack" FullWidth="true" Style="height: 100%;"> @bind-Value="_searchType"
@if (_addingTrack) @bind-Value:after="SearchTracks"
Size="Size.Small"
Color="Color.Primary"
Disabled="@(_isSearching)"
>
<MudToggleItem Value="TrackSearchType.All" Text="Все" />
<MudToggleItem Value="TrackSearchType.Track" Text="Трек" />
<MudToggleItem Value="TrackSearchType.Album" Text="Альбом" />
<MudToggleItem Value="TrackSearchType.Artist" Text="Исполнитель" />
</MudToggleGroup>
@if (_isSearching)
{ {
<MudProgressCircular Size="Size.Small" Indeterminate /> <MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="my-7" />
} }
else else if (_searchResults.Any())
{ {
<MudText>Добавить</MudText> <div style="max-height: 400px; overflow-y: auto;">
@foreach (var track in _searchResults)
{
<div style="display: flex; align-items: center; gap: 12px; padding: 8px; border-bottom: 1px solid rgba(0,0,0,0.1);">
<div style="width: 40px; height: 40px; flex-shrink: 0;">
<TrackCoverWithPlay CoverUrl="@track.CoverUri"
TrackId="@track.TrackId"
TrackTitle="@track.Title"
PlaylistShareToken="@ShareToken"
Width="40" Height="40" />
</div>
<div style="flex: 1; min-width: 0;">
<MudText Typo="Typo.body1" Style="font-weight: 500;">@track.Title</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">@string.Join(", ", track.Artists)</MudText>
</div>
<div style="flex-shrink: 0;">
<MudText Typo="Typo.body2">@track.DurationMs.FormatDuration()</MudText>
</div>
<div style="flex-shrink: 0;">
<MudToggleIconButton Toggled="_addingTrackIds.Contains(track.TrackId)"
Icon="@Icons.Material.Filled.AddCircle"
Color="@Color.Primary"
ToggledIcon="@Icons.Material.Filled.RemoveCircle"
ToggledColor="@Color.Error"
ToggledChanged="() => ToggleTrack(track)" />
</div>
</div>
} }
</MudButton> </div>
</MudItem> }
</MudGrid> else if (!_isFirstSearch)
</MudTabPanel> {
<MudTabPanel Text="Поиск" Style="padding: 16px;"> <MudAlert Severity="Severity.Info">Ничего не найдено. Попробуйте изменить запрос.</MudAlert>
<AddTrackBySearch OnAddTrack="AddTrackById" }
ShareToken="@ShareToken" /> </MudStack>
</MudTabPanel>
</MudTabs>
</MudPaper> </MudPaper>
@code { @code {
private int _activeTabIndex = 0;
private string _trackLink = "";
private bool _addingTrack = false;
[Parameter] public string ShareToken { get; set; } = string.Empty; [Parameter] public string ShareToken { get; set; } = string.Empty;
[Parameter] public EventCallback OnTrackAdded { get; set; } [Parameter] public EventCallback OnTrackAdded { get; set; }
[Parameter] public EventCallback OnTrackRemoved { get; set; }
private async Task AddTrackByLink() private string _searchQuery = "";
private bool _isSearching = false;
private bool _isFirstSearch = true;
private TrackSearchType _searchType = TrackSearchType.All;
private List<YandexTrack> _searchResults = new();
private HashSet<string> _addingTrackIds = new();
private async Task SearchTracks()
{ {
if (string.IsNullOrWhiteSpace(_trackLink)) if (string.IsNullOrWhiteSpace(_searchQuery))
{ {
Snackbar.Add("Введите ссылку на трек", Severity.Warning);
return; return;
} }
var trackId = ExtractTrackIdFromLink(_trackLink); var query = _searchQuery.Trim();
if (string.IsNullOrEmpty(trackId)) var type = _searchType;
bool byId = false;
if (Uri.TryCreate(_searchQuery, UriKind.Absolute, out var uri) && uri.Host == "music.yandex.ru")
{ {
Snackbar.Add("Неверный формат ссылки", Severity.Warning); try
{
(type, query) = ParseYandexMusicUrl(uri);
byId = true;
}
catch (Exception ex)
{
Snackbar.Add($"Ошибка распознавания URL: {ex.Message}", Severity.Error);
return; return;
} }
}
await AddTrackById(trackId); _isFirstSearch = false;
if (!_addingTrack) // если не было ошибки _isSearching = true;
_trackLink = ""; try
{
var url = $"/api/yandexsearch/tracks?query={Uri.EscapeDataString(query)}&type={Uri.EscapeDataString(type.ToString())}&limit=20";
if (!string.IsNullOrEmpty(ShareToken))
url += $"&shared_id={Uri.EscapeDataString(ShareToken)}";
if (byId)
url += $"&byId={byId}";
var response = await Http.GetFromJsonAsync<ApiResponse<List<YandexTrack>>>(url);
if (response?.Success == true)
_searchResults = response.Data ?? new();
else
Snackbar.Add(response?.Error?.Message ?? "Ошибка поиска", Severity.Error);
}
catch (Exception ex)
{
Snackbar.Add($"Ошибка: {ex.Message}", Severity.Error);
}
finally
{
_isSearching = false;
StateHasChanged();
}
}
private async Task SearchTracksByQuery(string query)
{
if (string.IsNullOrWhiteSpace(query))
return;
}
private async Task ToggleTrack(YandexTrack track)
{
if (_addingTrackIds.Contains(track.TrackId))
{
await RemoveTrack(track);
}
else
{
await AddTrack(track);
}
}
private async Task RemoveTrack(YandexTrack track)
{
if (!_addingTrackIds.Remove(track.TrackId)) return;
try
{
await RemoveTrackById(track.TrackId);
await OnTrackRemoved.InvokeAsync();
Snackbar.Add($"Трек \"{track.Title}\" добавлен", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Ошибка добавления: {ex.Message}", Severity.Error);
_addingTrackIds.Add(track.TrackId);
}
finally
{
StateHasChanged();
}
}
private async Task AddTrack(YandexTrack track)
{
if (_addingTrackIds.Contains(track.TrackId)) return;
_addingTrackIds.Add(track.TrackId);
try
{
await AddTrackById(track.TrackId);
await OnTrackAdded.InvokeAsync();
Snackbar.Add($"Трек \"{track.Title}\" добавлен", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"Ошибка добавления: {ex.Message}", Severity.Error);
_addingTrackIds.Remove(track.TrackId);
}
finally
{
StateHasChanged();
}
} }
private async Task AddTrackById(string trackId) private async Task AddTrackById(string trackId)
{ {
_addingTrack = true;
try try
{ {
var request = new AddTracksRequest { TrackIds = new List<string> { trackId } }; var request = new UpdateTrackListRequest { TrackIds = new List<string> { trackId } };
var response = await Http.PostAsJsonAsync($"/api/sharedplaylist/{ShareToken}/add-tracks", request); var response = await Http.PostAsJsonAsync($"/api/sharedplaylist/{ShareToken}/add-tracks", request);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
@@ -86,14 +223,58 @@
} }
finally finally
{ {
_addingTrack = false;
StateHasChanged(); StateHasChanged();
} }
} }
private string? ExtractTrackIdFromLink(string link) private async Task RemoveTrackById(string trackId)
{ {
var match = System.Text.RegularExpressions.Regex.Match(link, @"/track/(\d+)"); try
return match.Success ? match.Groups[1].Value : null; {
var request = new UpdateTrackListRequest { TrackIds = new List<string> { trackId } };
var response = await Http.PostAsJsonAsync($"/api/sharedplaylist/{ShareToken}/remove-tracks", request);
if (response.IsSuccessStatusCode)
{
Snackbar.Add("Трек успешно удален", Severity.Success);
await OnTrackAdded.InvokeAsync(); // уведомляем родителя, что список треков изменился
}
else
{
var error = await response.Content.ReadFromJsonAsync<ApiResponse<object>>();
Snackbar.Add(error?.Error?.Message ?? "Ошибка удаления трека", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"Ошибка: {ex.Message}", Severity.Error);
}
finally
{
StateHasChanged();
}
}
private static (TrackSearchType Type, string Id) ParseYandexMusicUrl(Uri uri)
{
var segments = uri.AbsolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
var dataMap = segments
.Select((val, idx) => new { val, idx })
.GroupBy(x => x.idx / 2)
.ToDictionary(
g => g.First().val,
g => g.ElementAtOrDefault(1)?.val
);
if (dataMap.TryGetValue("track", out var trackId) && !string.IsNullOrEmpty(trackId))
return (TrackSearchType.Track, trackId);
if (dataMap.TryGetValue("album", out var albumId) && !string.IsNullOrEmpty(albumId))
return (TrackSearchType.Album, albumId);
if (dataMap.TryGetValue("playlist", out var playlistId) && !string.IsNullOrEmpty(playlistId))
return (TrackSearchType.Playlist, playlistId);
if (dataMap.TryGetValue("artist", out var artistId) && !string.IsNullOrEmpty(artistId))
return (TrackSearchType.Artist, artistId);
throw new ArgumentException("Unsupported URL pattern");
} }
} }

View File

@@ -1,5 +1,5 @@
@using PlaylistShared.Shared.Enums @using PlaylistShared.Shared.Enums
@using PlaylistShared.Shared.Shared @using PlaylistShared.Shared.SharedPlaylist
@inject HttpClient Http @inject HttpClient Http
@inject ISnackbar Snackbar @inject ISnackbar Snackbar

View File

@@ -1,20 +1,20 @@
@using PlaylistShared.Pwa.Components.Common @using PlaylistShared.Pwa.Components.Common
@using PlaylistShared.Shared.Shared
@using PlaylistShared.Shared.Enums @using PlaylistShared.Shared.Enums
@using System.Security.Claims @using System.Security.Claims
@using PlaylistShared.Shared.SharedPlaylist
@inject HttpClient Http @inject HttpClient Http
@inject NavigationManager Navigation @inject NavigationManager Navigation
@inject AuthenticationStateProvider AuthProvider @inject AuthenticationStateProvider AuthProvider
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject IDialogService DialogService @inject IDialogService DialogService
<div style="display: flex; gap: 16px; align-items: center;"> <MudStack Row AlignItems="AlignItems.Center">
@if (!string.IsNullOrEmpty(Playlist?.CoverUrl)) @if (!string.IsNullOrEmpty(Playlist?.CoverUrl))
{ {
<MudImage Src="@Playlist.CoverUrl.FormatCoverUrl(80, 80)" Height="80" Width="80" Class="rounded" /> <MudImage Src="@Playlist.CoverUrl.FormatCoverUrl(80, 80)" Height="80" Width="80" Class="rounded" />
} }
<div> <MudStack>
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap;"> <MudStack Row AlignItems="AlignItems.Center" Wrap="Wrap.Wrap">
<MudLink Href="@($"https://music.yandex.ru/playlists/{Playlist?.YandexPlaylistUuid}")" <MudLink Href="@($"https://music.yandex.ru/playlists/{Playlist?.YandexPlaylistUuid}")"
Typo="Typo.h5" Typo="Typo.h5"
Target="_blank" Target="_blank"
@@ -39,10 +39,10 @@
Title="Настройки доступа" Title="Настройки доступа"
Size="Size.Medium" /> Size="Size.Medium" />
} }
</div> </MudStack>
<MudText Typo="Typo.body2" Color="Color.Secondary">Владелец: @Playlist?.Creator?.UserName</MudText> <MudText Typo="Typo.body2" Color="Color.Secondary">Владелец: @Playlist?.Creator?.UserName</MudText>
</div> </MudStack>
</div> </MudStack>
@code { @code {
[Parameter] public SharedPlaylistDto? Playlist { get; set; } [Parameter] public SharedPlaylistDto? Playlist { get; set; }

View File

@@ -1,6 +1,6 @@
@using PlaylistShared.Pwa.Components.Common @using PlaylistShared.Pwa.Components.Common
@using PlaylistShared.Shared.DTO @using PlaylistShared.Shared.DTO
@using PlaylistShared.Shared.Shared @using PlaylistShared.Shared.SharedPlaylist
@inject HttpClient Http @inject HttpClient Http
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject IDialogService DialogService @inject IDialogService DialogService
@@ -119,7 +119,7 @@
try try
{ {
var request = new RemoveTracksRequest { TrackIds = new List<string> { track.TrackId } }; var request = new UpdateTrackListRequest { TrackIds = new List<string> { track.TrackId } };
var response = await Http.PostAsJsonAsync($"/api/sharedplaylist/{ShareToken}/remove-tracks", request); var response = await Http.PostAsJsonAsync($"/api/sharedplaylist/{ShareToken}/remove-tracks", request);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {

View File

@@ -1,5 +1,5 @@
@page "/favorites" @page "/favorites"
@using PlaylistShared.Shared.Shared @using PlaylistShared.Shared.SharedPlaylist
@attribute [Authorize] @attribute [Authorize]
@inject HttpClient Http @inject HttpClient Http
@inject ISnackbar Snackbar @inject ISnackbar Snackbar

View File

@@ -25,7 +25,7 @@
<!-- Локальная форма входа --> <!-- Локальная форма входа -->
<MudTextField @bind-Value="_loginModel.Username" Label="Имя пользователя" Variant="Variant.Outlined" FullWidth="true" Class="mb-3" /> <MudTextField @bind-Value="_loginModel.Username" Label="Имя пользователя" Variant="Variant.Outlined" FullWidth="true" Class="mb-3" />
<MudTextField @bind-Value="_loginModel.Password" Label="Пароль" Variant="Variant.Outlined" FullWidth="true" InputType="InputType.Password" OnKeyUp="@(async (e) => { if (e.Key == "Enter") await LocalLogin(); })" /> <MudTextField @bind-Value="_loginModel.Password" Label="Пароль" Variant="Variant.Outlined" FullWidth="true" InputType="InputType.Password" @bind-Value:after="LocalLogin" />
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" OnClick="LocalLogin" FullWidth="true" Class="mt-4"> <MudButton Variant="Variant.Outlined" Color="Color.Secondary" OnClick="LocalLogin" FullWidth="true" Class="mt-4">
Войти (локально) Войти (локально)

View File

@@ -14,7 +14,7 @@
</CardHeaderContent> </CardHeaderContent>
</MudCardHeader> </MudCardHeader>
<MudCardContent> <MudCardContent>
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-bottom: 16px;"> <MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-4">
<MudText Typo="Typo.body2"> <MudText Typo="Typo.body2">
Здесь вы можете указать токен доступа к Яндекс.Музыке. Здесь вы можете указать токен доступа к Яндекс.Музыке.
</MudText> </MudText>
@@ -22,7 +22,7 @@
Color="Color.Info" Color="Color.Info"
OnClick="() => _instructionDrawerOpen = true" OnClick="() => _instructionDrawerOpen = true"
Title="Как получить токен?" /> Title="Как получить токен?" />
</div> </MudStack>
<MudTextField @bind-Value="_token" Label="Токен Яндекс.Музыки" Variant="Variant.Outlined" FullWidth="true" /> <MudTextField @bind-Value="_token" Label="Токен Яндекс.Музыки" Variant="Variant.Outlined" FullWidth="true" />

View File

@@ -3,7 +3,7 @@
@using PlaylistShared.Shared.DTO @using PlaylistShared.Shared.DTO
@using PlaylistShared.Shared.Enums @using PlaylistShared.Shared.Enums
@using PlaylistShared.Pwa.Services @using PlaylistShared.Pwa.Services
@using PlaylistShared.Shared.Shared @using PlaylistShared.Shared.SharedPlaylist
@inject HttpClient Http @inject HttpClient Http
@inject ISnackbar Snackbar @inject ISnackbar Snackbar
@inject NavigationManager Navigation @inject NavigationManager Navigation
@@ -34,14 +34,15 @@
{ {
<AddTrackSection ShareToken="@Token" <AddTrackSection ShareToken="@Token"
OnTrackAdded="LoadTracks" OnTrackAdded="LoadTracks"
OnTrackRemoved="LoadTracks"
/> />
} }
<!-- Список треков --> <!-- Список треков -->
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;"> <MudStack Row Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-4">
<MudText Typo="Typo.h6">Треки</MudText> <MudText Typo="Typo.h6">Треки</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Refresh" OnClick="LoadTracks" Disabled="_tracksLoading" Size="Size.Medium" /> <MudIconButton Icon="@Icons.Material.Filled.Refresh" OnClick="LoadTracks" Disabled="_tracksLoading" Size="Size.Medium" />
</div> </MudStack>
<TracksTable @ref="_tracksTableRef" <TracksTable @ref="_tracksTableRef"
ShareToken="@Token" ShareToken="@Token"
@@ -58,8 +59,6 @@
@code { @code {
[Parameter] public string Token { get; set; } [Parameter] public string Token { get; set; }
private int _addTrackTabIndex = 0; // 0 - ссылка, 1 - поиск
private TracksTable? _tracksTableRef; private TracksTable? _tracksTableRef;
private SharedPlaylistDto? _playlist; private SharedPlaylistDto? _playlist;

View File

@@ -0,0 +1,101 @@
using PlaylistShared.Shared;
using PlaylistShared.Shared.SharedPlaylist;
using System.Net.Http.Json;
namespace PlaylistShared.Pwa.Services.Api;
public class SharedPlaylistClient
{
private readonly HttpClient _http;
public SharedPlaylistClient(HttpClient http)
{
_http = http;
}
/// <summary>
/// GET /api/sharedplaylist/{token}
/// </summary>
public async Task<ApiResponse<SharedPlaylistDto>> GetAsync(string token)
{
var response = await _http.GetFromJsonAsync<ApiResponse<SharedPlaylistDto>>($"/api/sharedplaylist/{token}");
return response ?? ApiResponse<SharedPlaylistDto>.Fail(new ErrorResponse { Message = "Invalid response" });
}
/// <summary>
/// GET /api/sharedplaylist/{token}/tracks
/// </summary>
public async Task<ApiResponse<YandexPlaylistData>> GetTracksAsync(string token)
{
var response = await _http.GetFromJsonAsync<ApiResponse<YandexPlaylistData>>($"/api/sharedplaylist/{token}/tracks");
return response ?? ApiResponse<YandexPlaylistData>.Fail(new ErrorResponse { Message = "Invalid response" });
}
/// <summary>
/// PUT /api/sharedplaylist/{token}/permissions
/// </summary>
public async Task<ApiResponse<SharedPlaylistDto>> UpdatePermissionsAsync(string token, UpdatePermissionsDto permissions)
{
var response = await _http.PutAsJsonAsync($"/api/sharedplaylist/{token}/permissions", permissions);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<ApiResponse<SharedPlaylistDto>>();
return result ?? ApiResponse<SharedPlaylistDto>.Fail(new ErrorResponse { Message = "Invalid response" });
}
else
{
var error = await response.Content.ReadFromJsonAsync<ApiResponse<SharedPlaylistDto>>();
return error ?? ApiResponse<SharedPlaylistDto>.Fail(new ErrorResponse
{
StatusCode = (int)response.StatusCode,
Message = response.ReasonPhrase ?? "Unknown error"
});
}
}
/// <summary>
/// POST /api/sharedplaylist/{token}/add-tracks
/// </summary>
public async Task<ApiResponse<object>> AddTracksAsync(string token, List<string> trackIds)
{
var request = new UpdateTrackListRequest { TrackIds = trackIds };
var response = await _http.PostAsJsonAsync($"/api/sharedplaylist/{token}/add-tracks", request);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<ApiResponse<object>>();
return result ?? ApiResponse<object>.Fail(new ErrorResponse { Message = "Invalid response" });
}
else
{
var error = await response.Content.ReadFromJsonAsync<ApiResponse<object>>();
return error ?? ApiResponse<object>.Fail(new ErrorResponse
{
StatusCode = (int)response.StatusCode,
Message = response.ReasonPhrase ?? "Unknown error"
});
}
}
/// <summary>
/// POST /api/sharedplaylist/{token}/remove-tracks
/// </summary>
public async Task<ApiResponse<object>> RemoveTracksAsync(string token, List<string> trackIds)
{
var request = new UpdateTrackListRequest { TrackIds = trackIds };
var response = await _http.PostAsJsonAsync($"/api/sharedplaylist/{token}/remove-tracks", request);
if (response.IsSuccessStatusCode)
{
var result = await response.Content.ReadFromJsonAsync<ApiResponse<object>>();
return result ?? ApiResponse<object>.Fail(new ErrorResponse { Message = "Invalid response" });
}
else
{
var error = await response.Content.ReadFromJsonAsync<ApiResponse<object>>();
return error ?? ApiResponse<object>.Fail(new ErrorResponse
{
StatusCode = (int)response.StatusCode,
Message = response.ReasonPhrase ?? "Unknown error"
});
}
}
}

View File

@@ -1,9 +0,0 @@
using System.Text.Json.Serialization;
namespace PlaylistShared.Shared.DTO;
public class RemoveTracksRequest
{
[JsonPropertyName("trackIds")]
public List<string> TrackIds { get; set; } = new();
}

View File

@@ -0,0 +1,13 @@
namespace PlaylistShared.Shared.Enums;
/// <summary>
/// Типы поиска треков в Яндекс.Музыке, которые можно указать при поисковом запросе.
/// </summary>
public enum TrackSearchType
{
All,
Artist,
Album,
Playlist,
Track,
}

View File

@@ -1,4 +1,4 @@
namespace PlaylistShared.Shared.Shared; namespace PlaylistShared.Shared.SharedPlaylist;
public class AddTrackByLinkRequest public class AddTrackByLinkRequest
{ {

View File

@@ -2,7 +2,7 @@
using PlaylistShared.Shared.Enums; using PlaylistShared.Shared.Enums;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace PlaylistShared.Shared.Shared; namespace PlaylistShared.Shared.SharedPlaylist;
/// <summary>DTO шеринг-плейлиста (без навигационных свойств).</summary> /// <summary>DTO шеринг-плейлиста (без навигационных свойств).</summary>
public class SharedPlaylistDto public class SharedPlaylistDto

View File

@@ -1,7 +1,7 @@
using PlaylistShared.Shared.Enums; using PlaylistShared.Shared.Enums;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace PlaylistShared.Shared.Shared; namespace PlaylistShared.Shared.SharedPlaylist;
/// <summary>Запрос на обновление прав доступа шеринг-плейлиста.</summary> /// <summary>Запрос на обновление прав доступа шеринг-плейлиста.</summary>
public class UpdatePermissionsDto public class UpdatePermissionsDto

View File

@@ -1,8 +1,8 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
namespace PlaylistShared.Shared.Shared; namespace PlaylistShared.Shared.SharedPlaylist;
public class AddTracksRequest public class UpdateTrackListRequest
{ {
[JsonPropertyName("trackIds")] [JsonPropertyName("trackIds")]
public List<string> TrackIds { get; set; } = new(); public List<string> TrackIds { get; set; } = new();

View File

@@ -1,6 +1,6 @@
using PlaylistShared.Shared.DTO; using PlaylistShared.Shared.DTO;
namespace PlaylistShared.Shared.Shared; namespace PlaylistShared.Shared.SharedPlaylist;
public class YandexPlaylistData public class YandexPlaylistData
{ {