Проведен аудит. Добавлено переключение треков

This commit is contained in:
FrigaT
2026-05-21 20:49:55 +03:00
parent 38af6174fa
commit 9139d8ecfe
23 changed files with 351 additions and 222 deletions

2
.gitignore vendored
View File

@@ -363,3 +363,5 @@ MigrationBackup/
FodyWeavers.xsd FodyWeavers.xsd
/PlaylistShared.Pwa/wwwroot/appsettings.Development.json /PlaylistShared.Pwa/wwwroot/appsettings.Development.json
/PlaylistShared.Api/appsettings.Development.json /PlaylistShared.Api/appsettings.Development.json
/PlaylistShared.Pwa/ruvector.db
/PlaylistShared.Pwa/{

View File

@@ -1,11 +1,14 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using PlaylistShared.Api.Entities; using PlaylistShared.Api.Entities;
using PlaylistShared.Api.Services; using PlaylistShared.Api.Services;
using PlaylistShared.Shared; using PlaylistShared.Shared;
using PlaylistShared.Shared.Auth; using PlaylistShared.Shared.Auth;
using PlaylistShared.Shared.DTO; using PlaylistShared.Shared.DTO;
namespace PlaylistShared.Api.Controllers;
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
public class AccountController : ControllerBase public class AccountController : ControllerBase
@@ -72,7 +75,7 @@ public class AccountController : ControllerBase
[HttpPost("refresh-token")] [HttpPost("refresh-token")]
public async Task<ActionResult<ApiResponse<LoginResponse>>> RefreshToken([FromBody] RefreshTokenRequest request) public async Task<ActionResult<ApiResponse<LoginResponse>>> RefreshToken([FromBody] RefreshTokenRequest request)
{ {
var user = _userManager.Users.FirstOrDefault(u => u.RefreshToken == request.RefreshToken && u.RefreshTokenExpiryUtc > DateTime.UtcNow); var user = await _userManager.Users.FirstOrDefaultAsync(u => u.RefreshToken == request.RefreshToken && u.RefreshTokenExpiryUtc > DateTime.UtcNow);
if (user == null) if (user == null)
return Unauthorized(ApiResponse<LoginResponse>.Fail(new ErrorResponse { StatusCode = 401, Message = "Неверный или просроченный refresh token" })); return Unauthorized(ApiResponse<LoginResponse>.Fail(new ErrorResponse { StatusCode = 401, Message = "Неверный или просроченный refresh token" }));

View File

@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using PlaylistShared.Api.Entities; using PlaylistShared.Api.Entities;
@@ -18,47 +18,50 @@ public class AudioController : ControllerBase
private readonly YandexMusicService _yandexService; private readonly YandexMusicService _yandexService;
private readonly SharedPlaylistService _sharedService; private readonly SharedPlaylistService _sharedService;
private readonly JwtService _jwtService; private readonly JwtService _jwtService;
private readonly IHttpClientFactory _httpClientFactory;
public AudioController( public AudioController(
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
YandexMusicService yandexService, YandexMusicService yandexService,
SharedPlaylistService sharedService, SharedPlaylistService sharedService,
JwtService jwtService) JwtService jwtService,
IHttpClientFactory httpClientFactory)
{ {
_userManager = userManager; _userManager = userManager;
_yandexService = yandexService; _yandexService = yandexService;
_sharedService = sharedService; _sharedService = sharedService;
_jwtService = jwtService; _jwtService = jwtService;
_httpClientFactory = httpClientFactory;
}
[HttpGet("play-token")]
[Authorize]
public IActionResult GetPlayToken()
{
var userId = User.GetUserId();
var token = _jwtService.CreatePlayToken(userId);
return Ok(ApiResponse<string>.Ok(token));
} }
/// <summary>
/// Потоковое воспроизведение трека из Яндекс.Музыки.
/// </summary>
/// <param name="trackId">ID трека (например, "21696942").</param>
/// <param name="access_token">gwt пользователя</param>
/// <param name="shared_id">ID расшаренного плейлиста</param>
[HttpGet("track/{trackId}")] [HttpGet("track/{trackId}")]
[AllowAnonymous] [AllowAnonymous]
public async Task<IActionResult> StreamTrack(string trackId, [FromQuery] string? access_token = null, [FromQuery] string? shared_id = null) public async Task<IActionResult> StreamTrack(string trackId, [FromQuery] string? play_token = null, [FromQuery] string? shared_id = null)
{ {
var user = await GetUserFromToken(access_token); var user = await GetUserFromPlayToken(play_token);
if (user == null || user.YandexAccessToken is null) user = await GetUserFromSharedPlaylistId(shared_id); if (user == null || user.YandexAccessToken is null) user = await GetUserFromSharedPlaylistId(shared_id);
if (user == null) return Unauthorized(); if (user == null) return Unauthorized();
var streamUrl = await _yandexService.GetTrackFileUrlAsync(user, trackId); var streamUrl = await _yandexService.GetTrackFileUrlAsync(user, trackId);
if (string.IsNullOrEmpty(streamUrl)) return NotFound(); if (string.IsNullOrEmpty(streamUrl)) return NotFound();
var httpClient = new HttpClient(); var httpClient = _httpClientFactory.CreateClient();
var request = new HttpRequestMessage(HttpMethod.Get, streamUrl); var request = new HttpRequestMessage(HttpMethod.Get, streamUrl);
// Пробрасываем Range-заголовок клиента к Яндекс.Музыке
if (Request.Headers.ContainsKey("Range")) if (Request.Headers.ContainsKey("Range"))
{
request.Headers.Add("Range", Request.Headers["Range"].ToString()); request.Headers.Add("Range", Request.Headers["Range"].ToString());
}
var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
// Если Яндекс.Музыка поддерживает range, пробрасываем статус 206
Response.StatusCode = (int)response.StatusCode; Response.StatusCode = (int)response.StatusCode;
Response.ContentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg"; Response.ContentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg";
@@ -75,9 +78,9 @@ public class AudioController : ControllerBase
[HttpGet("track-info/{trackId}")] [HttpGet("track-info/{trackId}")]
[AllowAnonymous] [AllowAnonymous]
public async Task<ActionResult<ApiResponse<YandexTrack>>> GetTrackInfo(string trackId, [FromQuery] string? access_token = null, [FromQuery] string? shared_id = null) public async Task<ActionResult<ApiResponse<YandexTrack>>> GetTrackInfo(string trackId, [FromQuery] string? play_token = null, [FromQuery] string? shared_id = null)
{ {
var user = await GetUserFromToken(access_token); var user = await GetUserFromPlayToken(play_token);
if (user == null || user.YandexAccessToken is null) user = await GetUserFromSharedPlaylistId(shared_id); if (user == null || user.YandexAccessToken is null) user = await GetUserFromSharedPlaylistId(shared_id);
if (user == null) return Unauthorized(); if (user == null) return Unauthorized();
@@ -99,30 +102,20 @@ public class AudioController : ControllerBase
})); }));
} }
private async Task<ApplicationUser?> GetUserFromToken(string? token) private async Task<ApplicationUser?> GetUserFromPlayToken(string? token)
{ {
if (string.IsNullOrEmpty(token)) return null; if (string.IsNullOrEmpty(token)) return null;
var userId = _jwtService.ValidatePlayToken(token);
var principal = _jwtService.ValidateToken(token); if (!userId.HasValue) return null;
if (principal == null) return null; return await _userManager.FindByIdAsync(userId.Value.ToString());
var userId = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId)) return null;
return await _userManager.FindByIdAsync(userId);
} }
private async Task<ApplicationUser?> GetUserFromSharedPlaylistId(string? sharedId) private async Task<ApplicationUser?> GetUserFromSharedPlaylistId(string? sharedId)
{ {
if (string.IsNullOrEmpty(sharedId)) return null; if (string.IsNullOrEmpty(sharedId)) return null;
var playlist = await _sharedService.GetEntityByTokenAsync(sharedId); var playlist = await _sharedService.GetEntityByTokenAsync(sharedId);
if (playlist == null) return null; if (playlist == null) return null;
if (!await _sharedService.CanPlayEveryoneAsync(playlist)) return null; if (!await _sharedService.CanPlayEveryoneAsync(playlist)) return null;
return await _userManager.FindByIdAsync(playlist.CreatorUserId.ToString()); return await _userManager.FindByIdAsync(playlist.CreatorUserId.ToString());
} }
} }

View File

@@ -54,7 +54,7 @@ public class OpenIdController : ControllerBase
var user = await _userManager.FindByLoginAsync(info.LoginProvider, info.ProviderKey); var user = await _userManager.FindByLoginAsync(info.LoginProvider, info.ProviderKey);
if (user == null) if (user == null)
{ {
user = await _userManager.FindByEmailAsync(email); user = await _userManager.FindByEmailAsync(email!);
if (user == null) if (user == null)
{ {
user = new ApplicationUser user = new ApplicationUser

View File

@@ -1,11 +1,10 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using PlaylistShared.Api.Entities; 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.Enums;
using PlaylistShared.Shared.SharedPlaylist; using PlaylistShared.Shared.SharedPlaylist;
using PlaylistShared.Shared.Yandex; using PlaylistShared.Shared.Yandex;
@@ -19,18 +18,15 @@ public class PlaylistsController : ControllerBase
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly SharedPlaylistService _sharedService; private readonly SharedPlaylistService _sharedService;
private readonly YandexMusicService _yandexService; private readonly YandexMusicService _yandexService;
private readonly YandexApiService _yandexApiService;
public PlaylistsController( public PlaylistsController(
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
SharedPlaylistService sharedService, SharedPlaylistService sharedService,
YandexMusicService yandexService, YandexMusicService yandexService)
YandexApiService yandexApiService)
{ {
_userManager = userManager; _userManager = userManager;
_sharedService = sharedService; _sharedService = sharedService;
_yandexService = yandexService; _yandexService = yandexService;
_yandexApiService = yandexApiService;
} }
[HttpGet] [HttpGet]
@@ -40,20 +36,16 @@ public class PlaylistsController : ControllerBase
var user = await _userManager.FindByIdAsync(userId.ToString()); var user = await _userManager.FindByIdAsync(userId.ToString());
if (user == null) return Unauthorized(); if (user == null) return Unauthorized();
var decryptedToken = _yandexApiService.DecryptToken(user.YandexAccessToken); if (string.IsNullOrEmpty(user.YandexAccessToken))
if (string.IsNullOrEmpty(decryptedToken))
return BadRequest(ApiResponse<object>.Fail(new ErrorResponse { StatusCode = 400, Message = "Токен Яндекс.Музыки не установлен или недействителен" })); return BadRequest(ApiResponse<object>.Fail(new ErrorResponse { StatusCode = 400, Message = "Токен Яндекс.Музыки не установлен или недействителен" }));
var authSuccess = await _yandexApiService.AuthAsync(decryptedToken); List<YandexPlaylistShare> result;
if (!authSuccess) try
return BadRequest(ApiResponse<object>.Fail(new ErrorResponse { StatusCode = 400, Message = "Не удалось авторизоваться в Яндекс.Музыке. Проверьте токен." })); {
var (ownPlaylists, _) = await _yandexService.GetOwnFavoritesAsync(user);
var favorites = await _yandexApiService.Client.Api.Playlist.FavoritesAsync();
var ownPlaylists = favorites.Where(p => p.Owner.Uid == _yandexApiService.Client.Account.Uid).ToList();
var sharedPlaylists = await _sharedService.GetAllByUserAsync(userId); var sharedPlaylists = await _sharedService.GetAllByUserAsync(userId);
var result = ownPlaylists.Select(p => new YandexPlaylistShare result = (ownPlaylists ?? []).Select(p => new YandexPlaylistShare
{ {
Kind = p.Kind, Kind = p.Kind,
OwnerUid = p.Owner.Uid, OwnerUid = p.Owner.Uid,
@@ -63,6 +55,11 @@ public class PlaylistsController : ControllerBase
IsShared = sharedPlaylists.Any(s => s.YandexPlaylistKind == p.Kind && s.YandexPlaylistOwnerUid == p.Owner.Uid), IsShared = sharedPlaylists.Any(s => s.YandexPlaylistKind == p.Kind && s.YandexPlaylistOwnerUid == p.Owner.Uid),
ShareToken = sharedPlaylists.FirstOrDefault(s => s.YandexPlaylistKind == p.Kind && s.YandexPlaylistOwnerUid == p.Owner.Uid)?.ShareToken, ShareToken = sharedPlaylists.FirstOrDefault(s => s.YandexPlaylistKind == p.Kind && s.YandexPlaylistOwnerUid == p.Owner.Uid)?.ShareToken,
}).ToList(); }).ToList();
}
catch (Exception ex)
{
return BadRequest(ApiResponse<object>.Fail(new ErrorResponse { StatusCode = 400, Message = ex.Message }));
}
return Ok(ApiResponse<List<YandexPlaylistShare>>.Ok(result)); return Ok(ApiResponse<List<YandexPlaylistShare>>.Ok(result));
} }
@@ -74,7 +71,6 @@ public class PlaylistsController : ControllerBase
var user = await _userManager.FindByIdAsync(userId.ToString()); var user = await _userManager.FindByIdAsync(userId.ToString());
if (user == null) return Unauthorized(); if (user == null) return Unauthorized();
// Проверяем, что плейлист действительно принадлежит пользователю
var playlist = await _yandexService.GetPlaylistAsync(user, request.OwnerUid, request.Kind); var playlist = await _yandexService.GetPlaylistAsync(user, request.OwnerUid, request.Kind);
if (playlist == null) if (playlist == null)
return BadRequest(ApiResponse<object>.Fail(new ErrorResponse { StatusCode = 404, Message = "Плейлист не найден" })); return BadRequest(ApiResponse<object>.Fail(new ErrorResponse { StatusCode = 404, Message = "Плейлист не найден" }));
@@ -86,10 +82,10 @@ public class PlaylistsController : ControllerBase
YandexPlaylistOwnerUid = request.OwnerUid, YandexPlaylistOwnerUid = request.OwnerUid,
Title = playlist.Title, Title = playlist.Title,
Description = playlist.Description, Description = playlist.Description,
ViewPermission = ViewPermission.Everyone, ViewPermission = Shared.Enums.ViewPermission.Everyone,
PlayPermission = ViewPermission.Everyone, PlayPermission = Shared.Enums.ViewPermission.Everyone,
AddPermission = EditPermission.AuthorizedOnly, AddPermission = Shared.Enums.EditPermission.AuthorizedOnly,
RemovePermission = EditPermission.AddedByUserOnly, RemovePermission = Shared.Enums.EditPermission.AddedByUserOnly,
}; };
var result = await _sharedService.CreateAsync(userId, dto); var result = await _sharedService.CreateAsync(userId, dto);

View File

@@ -7,7 +7,8 @@ using PlaylistShared.Api.Services;
using PlaylistShared.Shared; using PlaylistShared.Shared;
using PlaylistShared.Shared.SharedPlaylist; using PlaylistShared.Shared.SharedPlaylist;
using PlaylistShared.Shared.Yandex; using PlaylistShared.Shared.Yandex;
using YandexMusic.API.Models.Playlist;
namespace PlaylistShared.Api.Controllers;
[ApiController] [ApiController]
[Route("api/[controller]")] [Route("api/[controller]")]
@@ -40,19 +41,16 @@ public class SharedPlaylistController : ControllerBase
[HttpGet("{token}")] [HttpGet("{token}")]
public async Task<ActionResult<ApiResponse<SharedPlaylistDto>>> GetByToken(string token) public async Task<ActionResult<ApiResponse<SharedPlaylistDto>>> GetByToken(string token)
{ {
var playlist = await _sharedService.GetByTokenAsync(token); var currentUserId = User.GetUserIdOrNull();
if (playlist == null)
var entity = await _sharedService.GetEntityByTokenAsync(token);
if (entity == null)
return NotFound(ApiResponse<SharedPlaylistDto>.Fail(new ErrorResponse { StatusCode = 404, Message = "Плейлист не найден" })); return NotFound(ApiResponse<SharedPlaylistDto>.Fail(new ErrorResponse { StatusCode = 404, Message = "Плейлист не найден" }));
var currentUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; if (!await _sharedService.CanViewAsync(entity, currentUserId))
var userIdGuid = !string.IsNullOrEmpty(currentUserId) ? Guid.Parse(currentUserId) : (Guid?)null;
// Проверка прав просмотра (требует доступа к сущности)
var entity = await _sharedService.GetEntityByTokenAsync(token);
if (entity == null || !await _sharedService.CanViewAsync(entity, userIdGuid))
return Unauthorized(ApiResponse<SharedPlaylistDto>.Fail(new ErrorResponse { StatusCode = 401, Message = "Недостаточно прав" })); return Unauthorized(ApiResponse<SharedPlaylistDto>.Fail(new ErrorResponse { StatusCode = 401, Message = "Недостаточно прав" }));
return Ok(ApiResponse<SharedPlaylistDto>.Ok(playlist)); return Ok(ApiResponse<SharedPlaylistDto>.Ok(_sharedService.MapToDto(entity)));
} }
// GET /api/sharedplaylist/{token}/tracks // GET /api/sharedplaylist/{token}/tracks
@@ -71,11 +69,10 @@ public class SharedPlaylistController : ControllerBase
if (creator == null) if (creator == null)
return StatusCode(500, ApiResponse<YandexPlaylistData>.Fail(new ErrorResponse { StatusCode = 500, Message = "Владелец плейлиста не найден" })); return StatusCode(500, ApiResponse<YandexPlaylistData>.Fail(new ErrorResponse { StatusCode = 500, Message = "Владелец плейлиста не найден" }));
var yandexPlaylist = await _yandexService.GetPlaylistAsync(creator, playlist.YandexPlaylistOwnerUid, playlist.YandexPlaylistKind); var dto = await _yandexService.GetPlaylistDataAsync(creator, playlist.YandexPlaylistOwnerUid, playlist.YandexPlaylistKind);
if (yandexPlaylist == null) if (dto == null)
return NotFound(ApiResponse<YandexPlaylistData>.Fail(new ErrorResponse { StatusCode = 404, Message = "Плейлист не найден в Яндекс.Музыке" })); return NotFound(ApiResponse<YandexPlaylistData>.Fail(new ErrorResponse { StatusCode = 404, Message = "Плейлист не найден в Яндекс.Музыке" }));
var dto = MapToYandexPlaylistData(yandexPlaylist);
return Ok(ApiResponse<YandexPlaylistData>.Ok(dto)); return Ok(ApiResponse<YandexPlaylistData>.Ok(dto));
} }
@@ -129,6 +126,22 @@ public class SharedPlaylistController : ControllerBase
return Ok(ApiResponse<object>.Ok(new { message = "Треки добавлены" })); return Ok(ApiResponse<object>.Ok(new { message = "Треки добавлены" }));
} }
// GET /api/sharedplaylist/{token}/additions
[HttpGet("{token}/additions")]
public async Task<ActionResult<ApiResponse<Dictionary<string, string?>>>> GetAdditions(string token)
{
var currentUserId = User.GetUserIdOrNull();
var playlist = await _sharedService.GetEntityByTokenAsync(token);
if (playlist == null)
return NotFound(ApiResponse<Dictionary<string, string?>>.Fail(new ErrorResponse { StatusCode = 404, Message = "Плейлист не найден" }));
if (!await _sharedService.CanViewAsync(playlist, currentUserId))
return Unauthorized();
var additions = await _trackAdditionLogService.GetAdditionUserNamesAsync(playlist.Id);
return Ok(ApiResponse<Dictionary<string, string?>>.Ok(additions));
}
// 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] UpdateTrackListRequest request) public async Task<ActionResult<ApiResponse<object>>> RemoveTracks(string token, [FromBody] UpdateTrackListRequest request)
@@ -164,26 +177,4 @@ public class SharedPlaylistController : ControllerBase
return Ok(ApiResponse<object>.Ok(new { message = "Треки удалены" })); return Ok(ApiResponse<object>.Ok(new { message = "Треки удалены" }));
} }
private YandexPlaylistData MapToYandexPlaylistData(YPlaylist playlist)
{
return new YandexPlaylistData
{
Title = playlist.Title,
Description = playlist.Description,
Tracks = playlist.Tracks.Select(t => new YandexTrack
{
TrackId = t.Track.Id,
Title = t.Track.Title,
Artists = t.Track.Artists.Select(t => new YandexArtist()
{
Id = t.Id,
Name = t.Name,
CoverUrl = t.Cover.GetUrl(),
Description = t.Description?.Text ?? string.Empty,
}).ToList(),
DurationMs = (int)(t.Track?.DurationMs ?? 0),
CoverUri = t.Track?.CoverUri ?? ""
}).ToList() ?? new List<YandexTrack>()
};
}
} }

View File

@@ -1,4 +1,4 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using PlaylistShared.Api.Entities; using PlaylistShared.Api.Entities;
@@ -31,11 +31,7 @@ public class YandexAccountController : ControllerBase
var user = await _userManager.FindByIdAsync(userId.ToString()); var user = await _userManager.FindByIdAsync(userId.ToString());
if (user == null) return Unauthorized(); if (user == null) return Unauthorized();
user.YandexAccessToken = _yandexService.Service.EncryptToken(request.Token); await SaveYandexTokenAsync(user, request.Token);
// Не храним refresh-токен, так как пользователь вводит только access-токен
user.YandexTokenExpiryUtc = DateTime.UtcNow.AddMonths(1); // условно, т.к. срок жизни токена неизвестен
await _userManager.UpdateAsync(user);
return Ok(ApiResponse<object>.Ok(new { message = "Токен сохранён" })); return Ok(ApiResponse<object>.Ok(new { message = "Токен сохранён" }));
} }
@@ -65,7 +61,6 @@ public class YandexAccountController : ControllerBase
if (user == null) return Unauthorized(); if (user == null) return Unauthorized();
var qr = await _yandexService.GetQrOrGenerate(user); var qr = await _yandexService.GetQrOrGenerate(user);
return Ok(ApiResponse<YandexAuthQr>.Ok(qr)); return Ok(ApiResponse<YandexAuthQr>.Ok(qr));
} }
@@ -81,10 +76,16 @@ public class YandexAccountController : ControllerBase
if (checkResult.Status == Shared.Enums.YandexAuthQrStatus.Authorized) if (checkResult.Status == Shared.Enums.YandexAuthQrStatus.Authorized)
{ {
await SetToken(new() { Token = _yandexService.Service.Client.AuthStorage.Token }); await SaveYandexTokenAsync(user, _yandexService.Service.Client.AuthStorage.Token);
} }
return Ok(ApiResponse<YandexAuthQrCheck>.Ok(checkResult)); return Ok(ApiResponse<YandexAuthQrCheck>.Ok(checkResult));
} }
private async Task SaveYandexTokenAsync(ApplicationUser user, string token)
{
user.YandexAccessToken = _yandexService.Service.EncryptToken(token);
user.YandexTokenExpiryUtc = DateTime.UtcNow.AddMonths(1);
await _userManager.UpdateAsync(user);
}
} }

View File

@@ -90,6 +90,12 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser, IdentityR
.OnDelete(DeleteBehavior.Restrict); .OnDelete(DeleteBehavior.Restrict);
}); });
builder.Entity<ApplicationUser>(entity =>
{
entity.HasIndex(e => e.RefreshToken)
.HasDatabaseName("IX_AspNetUsers_RefreshToken");
});
builder.Entity<FavoritePlaylist>(entity => builder.Entity<FavoritePlaylist>(entity =>
{ {
entity.HasKey(e => new { e.UserId, e.SharedPlaylistId }); entity.HasKey(e => new { e.UserId, e.SharedPlaylistId });
@@ -128,10 +134,12 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser, IdentityR
entity.Property(e => e.TrackId) entity.Property(e => e.TrackId)
.HasMaxLength(100) .HasMaxLength(100)
.IsRequired(false); .IsRequired(false);
entity.Property(e => e.CsfrToken) entity.Property(e => e.CsrfToken)
.HasColumnName("CsfrToken")
.HasMaxLength(200) .HasMaxLength(200)
.IsRequired(false); .IsRequired(false);
entity.Property(e => e.HeaderCsfrToken) entity.Property(e => e.HeaderCsrfToken)
.HasColumnName("HeaderCsfrToken")
.HasMaxLength(200) .HasMaxLength(200)
.IsRequired(false); .IsRequired(false);
entity.Property(e => e.HeaderProcessId) entity.Property(e => e.HeaderProcessId)

View File

@@ -1,18 +1,18 @@
namespace PlaylistShared.Api.Entities; namespace PlaylistShared.Api.Entities;
public class YandexAuthSession public class YandexAuthSession
{ {
public int Id { get; set; } public int Id { get; set; }
public Guid? UserId { get; set; } public Guid? UserId { get; set; }
public string QrCodeUrl { get; set; } public string QrCodeUrl { get; set; } = string.Empty;
public string SerializedCookies { get; set; } public string SerializedCookies { get; set; } = string.Empty;
public DateTime CreatedAt { get; set; } public DateTime CreatedAt { get; set; }
public DateTime? ConfirmedAt { get; set; } public DateTime? ConfirmedAt { get; set; }
public bool IsConfirmed { get; set; } public bool IsConfirmed { get; set; }
public string? TrackId { get; set; } public string? TrackId { get; set; }
public string? CsfrToken { get; set; } public string? CsrfToken { get; set; }
public string? HeaderProcessId { get; set; } public string? HeaderProcessId { get; set; }
public string? HeaderCsfrToken { get; set; } public string? HeaderCsrfToken { get; set; }
public ApplicationUser? User { get; set; } public ApplicationUser? User { get; set; }
} }

View File

@@ -111,7 +111,7 @@ public class Program
{ {
options.AddPolicy("Production", policy => options.AddPolicy("Production", policy =>
{ {
policy.WithOrigins(builder.Configuration.GetSection("Cors:Origins").Get<string[]>()) policy.WithOrigins(builder.Configuration.GetSection("Cors:Origins").Get<string[]>() ?? [])
.AllowAnyMethod() .AllowAnyMethod()
.AllowAnyHeader() .AllowAnyHeader()
.AllowCredentials(); .AllowCredentials();
@@ -123,18 +123,12 @@ public class Program
{ {
options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
}); });
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddOpenApi(); builder.Services.AddOpenApi();
var app = builder.Build(); var app = builder.Build();
app.MapOpenApi(); app.MapOpenApi();
app.UseSwagger();
app.UseSwaggerUI();
app.UseCors("Production"); app.UseCors("Production");
if (!app.Environment.IsDevelopment()) if (!app.Environment.IsDevelopment())

View File

@@ -90,10 +90,10 @@ public class FavoritesService
Creator = sp.Creator != null ? new Shared.Auth.ApplicationUserDto Creator = sp.Creator != null ? new Shared.Auth.ApplicationUserDto
{ {
Id = sp.Creator.Id, Id = sp.Creator.Id,
UserName = sp.Creator.UserName, UserName = sp.Creator.UserName ?? "",
Email = sp.Creator.Email, Email = sp.Creator.Email ?? "",
YandexId = sp.Creator.YandexId, YandexId = sp.Creator.YandexId,
DisplayName = sp.Creator.UserName DisplayName = sp.Creator.UserName ?? ""
} : null } : null
}).ToList(); }).ToList();
} }

View File

@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.IdentityModel.Tokens; using Microsoft.IdentityModel.Tokens;
using PlaylistShared.Api.Entities; using PlaylistShared.Api.Entities;
using System.IdentityModel.Tokens.Jwt; using System.IdentityModel.Tokens.Jwt;
@@ -11,11 +12,15 @@ public class JwtService
{ {
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly UserManager<ApplicationUser> _userManager; private readonly UserManager<ApplicationUser> _userManager;
private readonly ITimeLimitedDataProtector _playTokenProtector;
public JwtService(IConfiguration configuration, UserManager<ApplicationUser> userManager) public JwtService(IConfiguration configuration, UserManager<ApplicationUser> userManager, IDataProtectionProvider dataProtectionProvider)
{ {
_configuration = configuration; _configuration = configuration;
_userManager = userManager; _userManager = userManager;
_playTokenProtector = dataProtectionProvider
.CreateProtector("AudioPlayToken")
.ToTimeLimitedDataProtector();
} }
public async Task<(string Token, string RefreshToken, DateTime Expiration)> GenerateTokenAsync(ApplicationUser user) public async Task<(string Token, string RefreshToken, DateTime Expiration)> GenerateTokenAsync(ApplicationUser user)
@@ -71,4 +76,20 @@ public class JwtService
return null; return null;
} }
} }
public string CreatePlayToken(Guid userId) =>
_playTokenProtector.Protect(userId.ToString(), TimeSpan.FromMinutes(5));
public Guid? ValidatePlayToken(string token)
{
try
{
var userId = _playTokenProtector.Unprotect(token);
return Guid.Parse(userId);
}
catch
{
return null;
}
}
} }

View File

@@ -42,14 +42,6 @@ public class SharedPlaylistService
return MapToDto(entity); return MapToDto(entity);
} }
public async Task<SharedPlaylistDto?> GetByTokenAsync(string token)
{
var entity = await _db.SharedPlaylists
.Include(sp => sp.Creator)
.FirstOrDefaultAsync(sp => sp.ShareToken == token && !sp.IsDeleted);
return entity == null ? null : MapToDto(entity);
}
public async Task<SharedPlaylist?> GetEntityByTokenAsync(string token) public async Task<SharedPlaylist?> GetEntityByTokenAsync(string token)
{ {
return await _db.SharedPlaylists return await _db.SharedPlaylists
@@ -142,8 +134,7 @@ public class SharedPlaylistService
.ToListAsync(); .ToListAsync();
} }
// Ручное маппинг сущности в DTO public SharedPlaylistDto MapToDto(SharedPlaylist entity)
private SharedPlaylistDto MapToDto(SharedPlaylist entity)
{ {
return new SharedPlaylistDto return new SharedPlaylistDto
{ {

View File

@@ -43,4 +43,17 @@ public class TrackAdditionLogService
_db.TrackAdditionLogs.RemoveRange(logs); _db.TrackAdditionLogs.RemoveRange(logs);
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
} }
public async Task<Dictionary<string, string?>> GetAdditionUserNamesAsync(Guid sharedPlaylistId)
{
var rows = await _db.TrackAdditionLogs
.Where(l => l.SharedPlaylistId == sharedPlaylistId)
.Include(l => l.AddedByUser)
.OrderByDescending(l => l.AddedAtUtc)
.ToListAsync();
return rows
.GroupBy(l => l.TrackId)
.ToDictionary(g => g.Key, g => g.First().AddedByUser?.UserName);
}
} }

View File

@@ -80,7 +80,7 @@ public class YandexApiService : IDisposable
/// </summary> /// </summary>
/// <param name="encryptedToken"></param> /// <param name="encryptedToken"></param>
/// <returns></returns> /// <returns></returns>
public string DecryptToken(string encryptedToken) public string? DecryptToken(string encryptedToken)
{ {
try try
{ {

View File

@@ -1,4 +1,4 @@
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.Yandex; using PlaylistShared.Shared.Yandex;
@@ -24,10 +24,10 @@ public class YandexAuthService
internal async Task<YandexAuthQr> GetQrOrGenerate(ApplicationUser user) internal async Task<YandexAuthQr> GetQrOrGenerate(ApplicationUser user)
{ {
var existingSession = _dbContext.YandexAuthSessions var existingSession = await _dbContext.YandexAuthSessions
.Where(s => s.UserId == user.Id && !s.IsConfirmed && s.CreatedAt > DateTime.UtcNow.AddMinutes(-5)) .Where(s => s.UserId == user.Id && !s.IsConfirmed && s.CreatedAt > DateTime.UtcNow.AddMinutes(-5))
.OrderByDescending(s => s.CreatedAt) .OrderByDescending(s => s.CreatedAt)
.FirstOrDefault(); .FirstOrDefaultAsync();
if (existingSession != null) if (existingSession != null)
{ {
@@ -45,14 +45,14 @@ public class YandexAuthService
{ {
var qr = await Api.Passport.GetAuthQRLinkAsync(); var qr = await Api.Passport.GetAuthQRLinkAsync();
var trackId = Service.Client.AuthStorage.AuthToken.TrackId; var trackId = Service.Client.AuthStorage.AuthToken.TrackId;
var csfrToken = Service.Client.AuthStorage.AuthToken.CsfrToken; var csrfToken = Service.Client.AuthStorage.AuthToken.CsfrToken;
var headerProcessUuid = Service.Client.AuthStorage.HeaderToken.ProcessUuid; var headerProcessUuid = Service.Client.AuthStorage.HeaderToken.ProcessUuid;
var headerCsfrToken = Service.Client.AuthStorage.HeaderToken.CsfrToken; var headerCsrfToken = Service.Client.AuthStorage.HeaderToken.CsfrToken;
if (string.IsNullOrEmpty(qr)) if (string.IsNullOrEmpty(qr))
throw new Exception("Не удалось получить QR-ссылку"); throw new Exception("Не удалось получить QR-ссылку");
var cookiesJson = SerializeCookies(_apiService.CookieContainer); var cookiesJson = _apiService.EncryptToken(SerializeCookies(_apiService.CookieContainer));
var session = new YandexAuthSession var session = new YandexAuthSession
{ {
@@ -62,10 +62,9 @@ public class YandexAuthService
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
IsConfirmed = false, IsConfirmed = false,
TrackId = trackId, TrackId = trackId,
CsfrToken = csfrToken, CsrfToken = csrfToken,
HeaderCsfrToken = headerCsfrToken, HeaderCsrfToken = headerCsrfToken,
HeaderProcessId = headerProcessUuid, HeaderProcessId = headerProcessUuid,
}; };
_dbContext.YandexAuthSessions.Add(session); _dbContext.YandexAuthSessions.Add(session);
@@ -83,16 +82,19 @@ public class YandexAuthService
var session = await _dbContext.YandexAuthSessions.FindAsync(sessionId); var session = await _dbContext.YandexAuthSessions.FindAsync(sessionId);
if (session == null) return null; if (session == null) return null;
RestoreCookies(Service.CookieContainer, session.SerializedCookies); var decryptedCookies = _apiService.DecryptToken(session.SerializedCookies);
if (decryptedCookies == null) return null;
RestoreCookies(Service.CookieContainer, decryptedCookies);
if (Service.Client.AuthStorage.AuthToken is null) if (Service.Client.AuthStorage.AuthToken is null)
{ {
Service.Client.AuthStorage.AuthToken = new(); Service.Client.AuthStorage.AuthToken = new();
} }
Service.Client.AuthStorage.AuthToken.CsfrToken = session?.CsfrToken ?? ""; Service.Client.AuthStorage.AuthToken.CsfrToken = session.CsrfToken ?? "";
Service.Client.AuthStorage.AuthToken.TrackId = session?.TrackId ?? ""; Service.Client.AuthStorage.AuthToken.TrackId = session.TrackId ?? "";
Service.Client.AuthStorage.HeaderToken.CsfrToken = session?.HeaderCsfrToken ?? ""; Service.Client.AuthStorage.HeaderToken.CsfrToken = session.HeaderCsrfToken ?? "";
Service.Client.AuthStorage.HeaderToken.ProcessUuid = session?.HeaderProcessId ?? ""; Service.Client.AuthStorage.HeaderToken.ProcessUuid = session.HeaderProcessId ?? "";
var status = await Api.Passport.CheckQRStatusAsync(); var status = await Api.Passport.CheckQRStatusAsync();
@@ -100,36 +102,29 @@ public class YandexAuthService
{ {
try try
{ {
var auth = await Api.Passport.AuthorizeByQRAsync(); await Api.Passport.AuthorizeByQRAsync();
} }
catch (Exception ex) catch
{ {
return new() { Status = Shared.Enums.YandexAuthQrStatus.Error, }; return new() { Status = Shared.Enums.YandexAuthQrStatus.Error };
} }
_dbContext.YandexAuthSessions.Where(t => t.UserId == session.UserId).ExecuteDelete(); await _dbContext.YandexAuthSessions
_dbContext.SaveChanges(); .Where(t => t.UserId == session.UserId)
.ExecuteDeleteAsync();
await _dbContext.SaveChangesAsync();
return new() { Status = Shared.Enums.YandexAuthQrStatus.Authorized, }; return new() { Status = Shared.Enums.YandexAuthQrStatus.Authorized };
} }
return new() return new() { Status = Shared.Enums.YandexAuthQrStatus.Pending };
{
Status = Shared.Enums.YandexAuthQrStatus.Pending,
};
} }
private string SerializeCookies(CookieContainer container) private string SerializeCookies(CookieContainer container)
{ {
var allCookies = new List<object>(); var allCookies = new List<object>();
foreach (Cookie cookie in container.GetAllCookies())
var cookies = container.GetAllCookies();
foreach (Cookie cookie in cookies)
{
allCookies.Add(new { cookie.Name, cookie.Value, cookie.Domain, cookie.Path }); allCookies.Add(new { cookie.Name, cookie.Value, cookie.Domain, cookie.Path });
}
return JsonSerializer.Serialize(allCookies); return JsonSerializer.Serialize(allCookies);
} }
@@ -137,10 +132,8 @@ public class YandexAuthService
{ {
var cookies = JsonSerializer.Deserialize<List<CookieData>>(serializedCookies); var cookies = JsonSerializer.Deserialize<List<CookieData>>(serializedCookies);
foreach (var c in cookies) foreach (var c in cookies)
{
container.Add(new Cookie(c.Name, c.Value, c.Path, c.Domain)); container.Add(new Cookie(c.Name, c.Value, c.Path, c.Domain));
} }
}
private class CookieData private class CookieData
{ {

View File

@@ -35,6 +35,21 @@ public class YandexMusicService
return await Api.Playlist.GetAsync(ownerUid, kind); return await Api.Playlist.GetAsync(ownerUid, kind);
} }
public async Task<YandexPlaylistData?> GetPlaylistDataAsync(ApplicationUser user, string ownerUid, string kind)
{
var playlist = await GetPlaylistAsync(user, ownerUid, kind);
return playlist == null ? null : MapToPlaylistData(playlist);
}
public async Task<(List<YPlaylist>? OwnPlaylists, string? AccountUid)> GetOwnFavoritesAsync(ApplicationUser user)
{
await AuthorizeIfNot(user);
var favorites = await Api.Playlist.FavoritesAsync();
var accountUid = _yandexApiService.Client.Account?.Uid;
var ownPlaylists = favorites?.Where(p => p.Owner?.Uid == accountUid).ToList();
return (ownPlaylists, accountUid);
}
public async Task<YPlaylist?> CreatePlaylistAsync(ApplicationUser user, string title) public async Task<YPlaylist?> CreatePlaylistAsync(ApplicationUser user, string title)
{ {
await AuthorizeIfNot(user); await AuthorizeIfNot(user);
@@ -339,4 +354,24 @@ public class YandexMusicService
return result; return result;
} }
private static YandexPlaylistData MapToPlaylistData(YPlaylist playlist) => new()
{
Title = playlist.Title,
Description = playlist.Description,
Tracks = playlist.Tracks.Select(t => new YandexTrack
{
TrackId = t.Track.Id,
Title = t.Track.Title,
Artists = t.Track.Artists.Select(a => new YandexArtist
{
Id = a.Id,
Name = a.Name,
CoverUrl = a.Cover.GetUrl(),
Description = a.Description?.Text ?? string.Empty,
}).ToList(),
DurationMs = (int)(t.Track?.DurationMs ?? 0),
CoverUri = t.Track?.CoverUri ?? ""
}).ToList()
};
} }

View File

@@ -17,7 +17,13 @@
<MudItem> <MudItem>
<MudStack Spacing="0"> <MudStack Spacing="0">
<MudText Typo="Typo.body1" Color="Color.Secondary">@Track.Title</MudText> <MudText Typo="Typo.body1" Color="Color.Secondary">@Track.Title</MudText>
<MudStack Row AlignItems="AlignItems.Center" Spacing="1">
<MudText Typo="Typo.body2">@string.Join(", ", Track.Artists.Select(a => a.Name))</MudText> <MudText Typo="Typo.body2">@string.Join(", ", Track.Artists.Select(a => a.Name))</MudText>
@if (!string.IsNullOrEmpty(AddedByName))
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="Color.Secondary" Class="pa-0 px-1" Style="height: 18px; font-size: 10px;">@AddedByName</MudChip>
}
</MudStack>
</MudStack> </MudStack>
</MudItem> </MudItem>
@@ -33,4 +39,5 @@
[Parameter] public YandexTrack Track { get; set; } = null!; [Parameter] public YandexTrack Track { get; set; } = null!;
[Parameter] public string PlaylistShareToken { get; set; } = string.Empty; [Parameter] public string PlaylistShareToken { get; set; } = string.Empty;
[Parameter] public bool CanPlay { get; set; } = true; [Parameter] public bool CanPlay { get; set; } = true;
[Parameter] public string? AddedByName { get; set; }
} }

View File

@@ -11,6 +11,12 @@
<MudStack Spacing="1" Row AlignItems="AlignItems.Center" Wrap="Wrap.NoWrap"> <MudStack Spacing="1" Row AlignItems="AlignItems.Center" Wrap="Wrap.NoWrap">
<!-- Кнопки управления --> <!-- Кнопки управления -->
<MudIconButton Icon="@Icons.Material.Filled.SkipPrevious"
Size="Size.Small"
Color="Color.Default"
Disabled="@(!AudioPlayerService.HasPrevious)"
OnClick="() => AudioPlayerService.PlayPreviousAsync()" />
<MudItem @onmouseenter="() => { _isPlayHovered = true; }" <MudItem @onmouseenter="() => { _isPlayHovered = true; }"
@onmouseleave="() => { _isPlayHovered = false; }" @onmouseleave="() => { _isPlayHovered = false; }"
Class="relative d-inline-block rounded overflow-hidden cursor-pointer" Class="relative d-inline-block rounded overflow-hidden cursor-pointer"
@@ -33,6 +39,12 @@
</MudItem> </MudItem>
</MudItem> </MudItem>
<MudIconButton Icon="@Icons.Material.Filled.SkipNext"
Size="Size.Small"
Color="Color.Default"
Disabled="@(!AudioPlayerService.HasNext)"
OnClick="() => AudioPlayerService.PlayNextAsync()" />
<!-- Название и прогресс --> <!-- Название и прогресс -->
@if (AudioPlayerService.CurrentTrack != null) @if (AudioPlayerService.CurrentTrack != null)
{ {
@@ -170,15 +182,9 @@
#endregion #endregion
#region Обработка сервиса #region Обработка сервиса
private async Task OnServiceLoadAndPlay(string trackId, string? accessToken, string? sharedPlaylistId) private async Task OnServiceLoadAndPlay(string trackId, string? playToken, string? sharedPlaylistId)
{ {
if (string.IsNullOrWhiteSpace(accessToken)) if (string.IsNullOrWhiteSpace(playToken) && string.IsNullOrWhiteSpace(sharedPlaylistId))
{
var tokens = await TokenStorage.GetTokensAsync();
accessToken = tokens.token;
}
if (string.IsNullOrWhiteSpace(accessToken) && string.IsNullOrWhiteSpace(sharedPlaylistId))
{ {
Snackbar.Add("Токен авторизации не найден. Пожалуйста, войдите заново.", Severity.Error); Snackbar.Add("Токен авторизации не найден. Пожалуйста, войдите заново.", Severity.Error);
return; return;
@@ -186,7 +192,7 @@
var streamUrl = new Uri(Http.BaseAddress!, $"/api/audio/track/{trackId}").ToString(); var streamUrl = new Uri(Http.BaseAddress!, $"/api/audio/track/{trackId}").ToString();
await EnsureAudioModuleAsync(); await EnsureAudioModuleAsync();
await _audioElement!.InvokeVoidAsync("loadAndPlay", streamUrl, accessToken, sharedPlaylistId); await _audioElement!.InvokeVoidAsync("loadAndPlay", streamUrl, playToken, sharedPlaylistId);
} }
private async Task OnServicePlay() private async Task OnServicePlay()

View File

@@ -1,6 +1,17 @@
@page "/shared/{token}" @page "/shared/{token}"
<PageTitle>@_playlist?.Title - Playlist Share</PageTitle> <PageTitle>@_playlist?.Title - Playlist Share</PageTitle>
<HeadContent>
<meta property="og:title" content="@(_playlist?.Title ?? "Поделиться плейлистом")" />
<meta property="og:description" content="@($"Слушайте плейлист «{_playlist?.Title}» на Playlist Share")" />
@if (!string.IsNullOrEmpty(_playlist?.CoverUrl))
{
<meta property="og:image" content="@_playlist.CoverUrl" />
}
<meta property="og:url" content="@Navigation.Uri" />
<meta property="og:type" content="music.playlist" />
</HeadContent>
@using PlaylistShared.Pwa.Components.Common @using PlaylistShared.Pwa.Components.Common
@using PlaylistShared.Pwa.Components.Global @using PlaylistShared.Pwa.Components.Global
@using PlaylistShared.Pwa.Components.SharedPlaylist @using PlaylistShared.Pwa.Components.SharedPlaylist
@@ -245,7 +256,8 @@
Style="min-height: 0;"> Style="min-height: 0;">
<RowTemplate> <RowTemplate>
<MudTd Class="py-1 px-0" Style="width: 100%;"> <MudTd Class="py-1 px-0" Style="width: 100%;">
<TrackItem Track="@context" PlaylistShareToken="@Token" CanPlay="@_canPlay" /> <TrackItem Track="@context" PlaylistShareToken="@Token" CanPlay="@_canPlay"
AddedByName="@(_trackAdditions.TryGetValue(context.TrackId, out var n) ? n : null)" />
</MudTd> </MudTd>
@if (_canRemove) @if (_canRemove)
{ {
@@ -444,6 +456,10 @@
/// </summary> /// </summary>
private List<YandexTrack> _tracks = new(); private List<YandexTrack> _tracks = new();
/// <summary> /// <summary>
/// Словарь trackId → имя пользователя, добавившего трек.
/// </summary>
private Dictionary<string, string?> _trackAdditions = new();
/// <summary>
/// Продолжительность плейлиста. /// Продолжительность плейлиста.
/// </summary> /// </summary>
long _playlistDurationMs; long _playlistDurationMs;
@@ -621,6 +637,15 @@
_existingTrackIds = _tracks.Select(t => t.TrackId).ToHashSet(); _existingTrackIds = _tracks.Select(t => t.TrackId).ToHashSet();
_playlistDurationMs = _tracks.Sum(t => t.DurationMs); _playlistDurationMs = _tracks.Sum(t => t.DurationMs);
_playlistTrackCount = _tracks.Count(); _playlistTrackCount = _tracks.Count();
AudioPlayerService.SetQueue(_tracks, shareToken: Token);
try
{
var additionsResp = await Http.GetFromJsonAsync<ApiResponse<Dictionary<string, string?>>>($"/api/sharedplaylist/{Token}/additions");
if (additionsResp?.Success == true && additionsResp.Data != null)
_trackAdditions = additionsResp.Data;
}
catch { }
} }
else else
{ {

View File

@@ -1,6 +1,7 @@
using MudBlazor; using MudBlazor;
using PlaylistShared.Shared; using PlaylistShared.Shared;
using PlaylistShared.Shared.Yandex; using PlaylistShared.Shared.Yandex;
using System.Net.Http.Headers;
using System.Net.Http.Json; using System.Net.Http.Json;
namespace PlaylistShared.Pwa.Services; namespace PlaylistShared.Pwa.Services;
@@ -22,9 +23,16 @@ public class AudioPlayerService : IAudioPlayerService
private string _currentTimeString = "0:00"; private string _currentTimeString = "0:00";
private string _totalTimeString = "0:00"; private string _totalTimeString = "0:00";
private List<YandexTrack> _queue = new();
private int _queueIndex = -1;
private string? _queueShareToken;
public string? CurrentTrackId => _currentTrackId; public string? CurrentTrackId => _currentTrackId;
public YandexTrack? CurrentTrack => _currentTrack; public YandexTrack? CurrentTrack => _currentTrack;
public bool IsPlaying => _isPlaying; public bool IsPlaying => _isPlaying;
public IReadOnlyList<YandexTrack> CurrentQueue => _queue;
public bool HasNext => _queueIndex >= 0 && _queueIndex < _queue.Count - 1;
public bool HasPrevious => _queueIndex > 0;
public double CurrentVolume public double CurrentVolume
{ {
get => _currentVolume; get => _currentVolume;
@@ -57,14 +65,10 @@ public class AudioPlayerService : IAudioPlayerService
private async Task LoadVolume() private async Task LoadVolume()
{ {
var savedVolume = await _playerStorage.GetVolumeAsync(); var savedVolume = await _playerStorage.GetVolumeAsync();
if (savedVolume != null) if (savedVolume != null)
{
_currentVolume = savedVolume.Value; _currentVolume = savedVolume.Value;
} }
}
// Внешние команды (вызываются из компонентов)
public async Task LoadAndPlayAsync(string trackId, string? accessToken = null, string? playlistShareToken = null, YandexTrack? track = null) public async Task LoadAndPlayAsync(string trackId, string? accessToken = null, string? playlistShareToken = null, YandexTrack? track = null)
{ {
if (_currentTrackId == trackId) if (_currentTrackId == trackId)
@@ -75,30 +79,33 @@ public class AudioPlayerService : IAudioPlayerService
_currentTrackId = trackId; _currentTrackId = trackId;
// Если accessToken не передан, пытаемся получить его из хранилища var idx = _queue.FindIndex(t => t.TrackId == trackId);
if (idx >= 0) _queueIndex = idx;
if (string.IsNullOrWhiteSpace(accessToken)) if (string.IsNullOrWhiteSpace(accessToken))
{ {
var tokens = await _tokenStorage.GetTokensAsync(); var tokens = await _tokenStorage.GetTokensAsync();
accessToken = tokens.token; accessToken = tokens.token;
} }
// Проверяем, есть ли чем авторизоваться string? playToken = null;
if (string.IsNullOrWhiteSpace(accessToken) && string.IsNullOrWhiteSpace(playlistShareToken)) if (!string.IsNullOrWhiteSpace(accessToken))
playToken = await FetchPlayTokenAsync(accessToken);
if (string.IsNullOrWhiteSpace(playToken) && string.IsNullOrWhiteSpace(playlistShareToken))
{ {
_snackbar.Add("Не удалось воспроизвести трек: отсутствует токен авторизации или идентификатор расшаренного плейлиста.", Severity.Error); _snackbar.Add("Не удалось воспроизвести трек: отсутствует токен авторизации или идентификатор расшаренного плейлиста.", Severity.Error);
return; return;
} }
// Если title и coverUrl не переданы, нужно запросить через API
if (track is null) if (track is null)
{ {
try try
{ {
track = await GetTrackInfo(trackId, accessToken, playlistShareToken); track = await GetTrackInfo(trackId, playToken, playlistShareToken);
} }
catch (Exception ex) catch (Exception ex)
{ {
// Логируем ошибку, но продолжаем без обложки/названия
Console.WriteLine($"Failed to fetch track info: {ex.Message}"); Console.WriteLine($"Failed to fetch track info: {ex.Message}");
} }
} }
@@ -106,7 +113,7 @@ public class AudioPlayerService : IAudioPlayerService
_currentTrack = track; _currentTrack = track;
_isPlaying = true; _isPlaying = true;
OnStateChanged?.Invoke(); OnStateChanged?.Invoke();
OnLoadAndPlayRequested?.Invoke(trackId, accessToken, playlistShareToken); OnLoadAndPlayRequested?.Invoke(trackId, playToken, playlistShareToken);
OnStartedTrack?.Invoke(); OnStartedTrack?.Invoke();
} }
@@ -138,14 +145,36 @@ public class AudioPlayerService : IAudioPlayerService
await _playerStorage.SetVolumeAsync(volume); await _playerStorage.SetVolumeAsync(volume);
} }
// События для связи с реальным AudioPlayer компонентом public void SetQueue(IEnumerable<YandexTrack> tracks, int startIndex = 0, string? shareToken = null)
{
_queue = tracks.ToList();
_queueIndex = _queue.Count > 0 ? Math.Clamp(startIndex, 0, _queue.Count - 1) : -1;
_queueShareToken = shareToken;
OnStateChanged?.Invoke();
}
public async Task PlayNextAsync()
{
if (!HasNext) return;
_queueIndex++;
var track = _queue[_queueIndex];
await LoadAndPlayAsync(track.TrackId, playlistShareToken: _queueShareToken, track: track);
}
public async Task PlayPreviousAsync()
{
if (!HasPrevious) return;
_queueIndex--;
var track = _queue[_queueIndex];
await LoadAndPlayAsync(track.TrackId, playlistShareToken: _queueShareToken, track: track);
}
public event Func<string, string?, string?, Task>? OnLoadAndPlayRequested; public event Func<string, string?, string?, Task>? OnLoadAndPlayRequested;
public event Func<Task>? OnPlayRequested; public event Func<Task>? OnPlayRequested;
public event Func<Task>? OnPauseRequested; public event Func<Task>? OnPauseRequested;
public event Func<double, Task>? OnSeekRequested; public event Func<double, Task>? OnSeekRequested;
public event Func<double, Task>? OnVolumeChangeRequested; public event Func<double, Task>? OnVolumeChangeRequested;
// Внутренние методы для обновления состояния из AudioPlayer
public void SetPlayingState(bool isPlaying) public void SetPlayingState(bool isPlaying)
{ {
_isPlaying = isPlaying; _isPlaying = isPlaying;
@@ -172,37 +201,40 @@ public class AudioPlayerService : IAudioPlayerService
public void NotifyTrackEnded() public void NotifyTrackEnded()
{ {
_isPlaying = false; _isPlaying = false;
_currentTrackId = null;
_currentProgress = 0; _currentProgress = 0;
_currentTime = 0; _currentTime = 0;
_currentTimeString = "0:00"; _currentTimeString = "0:00";
_totalTime = 0; _totalTime = 0;
_currentTimeString = "0:00"; _totalTimeString = "0:00";
OnStateChanged?.Invoke(); OnStateChanged?.Invoke();
OnEndedTrack?.Invoke(); OnEndedTrack?.Invoke();
if (HasNext)
_ = PlayNextAsync();
else
_currentTrackId = null;
} }
/// <summary> private async Task<string?> FetchPlayTokenAsync(string jwt)
/// Вспомогательный метод для получения информации о треке через API {
/// </summary> using var request = new HttpRequestMessage(HttpMethod.Get, "/api/audio/play-token");
/// <param name="trackId"></param> request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
/// <param name="accessToken"></param> using var response = await _http.SendAsync(request);
/// <param name="sharedPlaylistId"></param> if (!response.IsSuccessStatusCode) return null;
/// <returns></returns> var result = await response.Content.ReadFromJsonAsync<ApiResponse<string>>();
private async Task<YandexTrack?> GetTrackInfo(string trackId, string? accessToken, string? sharedPlaylistId) return result?.Data;
}
private async Task<YandexTrack?> GetTrackInfo(string trackId, string? playToken, string? sharedPlaylistId)
{ {
var url = $"/api/audio/track-info/{trackId}"; var url = $"/api/audio/track-info/{trackId}";
if (!string.IsNullOrEmpty(accessToken)) if (!string.IsNullOrEmpty(playToken))
url += $"?access_token={accessToken}"; url += $"?play_token={playToken}";
else if (!string.IsNullOrEmpty(sharedPlaylistId)) else if (!string.IsNullOrEmpty(sharedPlaylistId))
url += $"?shared_id={sharedPlaylistId}"; url += $"?shared_id={sharedPlaylistId}";
var response = await _http.GetFromJsonAsync<ApiResponse<YandexTrack>>(url); var response = await _http.GetFromJsonAsync<ApiResponse<YandexTrack>>(url);
if (response?.Success == true) return response?.Success == true ? response.Data : null;
{
return response.Data;
}
return null;
} }
private string FormatDuration(double seconds) private string FormatDuration(double seconds)

View File

@@ -12,6 +12,15 @@ public interface IAudioPlayerService
/// <summary>ID текущего воспроизводимого трека (null, если ничего не играет).</summary> /// <summary>ID текущего воспроизводимого трека (null, если ничего не играет).</summary>
string? CurrentTrackId { get; } string? CurrentTrackId { get; }
/// <summary>Очередь треков.</summary>
IReadOnlyList<YandexTrack> CurrentQueue { get; }
/// <summary>Есть ли следующий трек в очереди.</summary>
bool HasNext { get; }
/// <summary>Есть ли предыдущий трек в очереди.</summary>
bool HasPrevious { get; }
/// <summary>Играет ли в данный момент (true) или приостановлен (false).</summary> /// <summary>Играет ли в данный момент (true) или приостановлен (false).</summary>
bool IsPlaying { get; } bool IsPlaying { get; }
@@ -57,6 +66,15 @@ public interface IAudioPlayerService
/// <summary>Установить громкость (0100).</summary> /// <summary>Установить громкость (0100).</summary>
Task SetVolumeAsync(double volume); Task SetVolumeAsync(double volume);
/// <summary>Установить очередь треков и начать воспроизведение с указанного индекса.</summary>
void SetQueue(IEnumerable<YandexTrack> tracks, int startIndex = 0, string? shareToken = null);
/// <summary>Перейти к следующему треку в очереди.</summary>
Task PlayNextAsync();
/// <summary>Перейти к предыдущему треку в очереди.</summary>
Task PlayPreviousAsync();
#endregion #endregion
#region События для подписки на изменения состояния #region События для подписки на изменения состояния

View File

@@ -12,7 +12,7 @@
const loadAndPlay = (src, token, sharedPlaylistId) => { const loadAndPlay = (src, token, sharedPlaylistId) => {
const url = new URL(src, window.location.href); const url = new URL(src, window.location.href);
if (token) url.searchParams.set('access_token', token); if (token) url.searchParams.set('play_token', token);
if (sharedPlaylistId) url.searchParams.set('shared_id', sharedPlaylistId); if (sharedPlaylistId) url.searchParams.set('shared_id', sharedPlaylistId);
audio.src = url.toString(); audio.src = url.toString();
audio.load(); audio.load();