diff --git a/.gitignore b/.gitignore index a6585b3..56a9f96 100644 --- a/.gitignore +++ b/.gitignore @@ -363,3 +363,5 @@ MigrationBackup/ FodyWeavers.xsd /PlaylistShared.Pwa/wwwroot/appsettings.Development.json /PlaylistShared.Api/appsettings.Development.json +/PlaylistShared.Pwa/ruvector.db +/PlaylistShared.Pwa/{ diff --git a/PlaylistShared.Api/Controllers/AccountController.cs b/PlaylistShared.Api/Controllers/AccountController.cs index eca67cf..4726f6d 100644 --- a/PlaylistShared.Api/Controllers/AccountController.cs +++ b/PlaylistShared.Api/Controllers/AccountController.cs @@ -1,11 +1,14 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using PlaylistShared.Api.Entities; using PlaylistShared.Api.Services; using PlaylistShared.Shared; using PlaylistShared.Shared.Auth; using PlaylistShared.Shared.DTO; +namespace PlaylistShared.Api.Controllers; + [ApiController] [Route("api/[controller]")] public class AccountController : ControllerBase @@ -72,7 +75,7 @@ public class AccountController : ControllerBase [HttpPost("refresh-token")] public async Task>> 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) return Unauthorized(ApiResponse.Fail(new ErrorResponse { StatusCode = 401, Message = "Неверный или просроченный refresh token" })); diff --git a/PlaylistShared.Api/Controllers/AudioController.cs b/PlaylistShared.Api/Controllers/AudioController.cs index 1b4b15f..b25116b 100644 --- a/PlaylistShared.Api/Controllers/AudioController.cs +++ b/PlaylistShared.Api/Controllers/AudioController.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using PlaylistShared.Api.Entities; @@ -18,47 +18,50 @@ public class AudioController : ControllerBase private readonly YandexMusicService _yandexService; private readonly SharedPlaylistService _sharedService; private readonly JwtService _jwtService; + private readonly IHttpClientFactory _httpClientFactory; public AudioController( UserManager userManager, YandexMusicService yandexService, SharedPlaylistService sharedService, - JwtService jwtService) + JwtService jwtService, + IHttpClientFactory httpClientFactory) { _userManager = userManager; _yandexService = yandexService; _sharedService = sharedService; _jwtService = jwtService; + _httpClientFactory = httpClientFactory; + } + + [HttpGet("play-token")] + [Authorize] + public IActionResult GetPlayToken() + { + var userId = User.GetUserId(); + var token = _jwtService.CreatePlayToken(userId); + return Ok(ApiResponse.Ok(token)); } - /// - /// Потоковое воспроизведение трека из Яндекс.Музыки. - /// - /// ID трека (например, "21696942"). - /// gwt пользователя - /// ID расшаренного плейлиста [HttpGet("track/{trackId}")] [AllowAnonymous] - public async Task StreamTrack(string trackId, [FromQuery] string? access_token = null, [FromQuery] string? shared_id = null) + public async Task 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) return Unauthorized(); var streamUrl = await _yandexService.GetTrackFileUrlAsync(user, trackId); if (string.IsNullOrEmpty(streamUrl)) return NotFound(); - var httpClient = new HttpClient(); + var httpClient = _httpClientFactory.CreateClient(); var request = new HttpRequestMessage(HttpMethod.Get, streamUrl); - // Пробрасываем Range-заголовок клиента к Яндекс.Музыке if (Request.Headers.ContainsKey("Range")) - { request.Headers.Add("Range", Request.Headers["Range"].ToString()); - } + var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead); - // Если Яндекс.Музыка поддерживает range, пробрасываем статус 206 Response.StatusCode = (int)response.StatusCode; Response.ContentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg"; @@ -75,9 +78,9 @@ public class AudioController : ControllerBase [HttpGet("track-info/{trackId}")] [AllowAnonymous] - public async Task>> GetTrackInfo(string trackId, [FromQuery] string? access_token = null, [FromQuery] string? shared_id = null) + public async Task>> 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) return Unauthorized(); @@ -99,30 +102,20 @@ public class AudioController : ControllerBase })); } - private async Task GetUserFromToken(string? token) + private async Task GetUserFromPlayToken(string? token) { if (string.IsNullOrEmpty(token)) return null; - - var principal = _jwtService.ValidateToken(token); - if (principal == null) return null; - - var userId = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value; - if (string.IsNullOrEmpty(userId)) return null; - - return await _userManager.FindByIdAsync(userId); + var userId = _jwtService.ValidatePlayToken(token); + if (!userId.HasValue) return null; + return await _userManager.FindByIdAsync(userId.Value.ToString()); } private async Task GetUserFromSharedPlaylistId(string? sharedId) { if (string.IsNullOrEmpty(sharedId)) return null; - var playlist = await _sharedService.GetEntityByTokenAsync(sharedId); - if (playlist == null) return null; - if (!await _sharedService.CanPlayEveryoneAsync(playlist)) return null; - return await _userManager.FindByIdAsync(playlist.CreatorUserId.ToString()); - } -} \ No newline at end of file +} diff --git a/PlaylistShared.Api/Controllers/OpenIdController.cs b/PlaylistShared.Api/Controllers/OpenIdController.cs index a789e31..91c85aa 100644 --- a/PlaylistShared.Api/Controllers/OpenIdController.cs +++ b/PlaylistShared.Api/Controllers/OpenIdController.cs @@ -54,7 +54,7 @@ public class OpenIdController : ControllerBase var user = await _userManager.FindByLoginAsync(info.LoginProvider, info.ProviderKey); if (user == null) { - user = await _userManager.FindByEmailAsync(email); + user = await _userManager.FindByEmailAsync(email!); if (user == null) { user = new ApplicationUser diff --git a/PlaylistShared.Api/Controllers/PlaylistsController.cs b/PlaylistShared.Api/Controllers/PlaylistsController.cs index 7a553a6..929d560 100644 --- a/PlaylistShared.Api/Controllers/PlaylistsController.cs +++ b/PlaylistShared.Api/Controllers/PlaylistsController.cs @@ -1,11 +1,10 @@ -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using PlaylistShared.Api.Entities; using PlaylistShared.Api.Extensions; using PlaylistShared.Api.Services; using PlaylistShared.Shared; -using PlaylistShared.Shared.Enums; using PlaylistShared.Shared.SharedPlaylist; using PlaylistShared.Shared.Yandex; @@ -19,18 +18,15 @@ public class PlaylistsController : ControllerBase private readonly UserManager _userManager; private readonly SharedPlaylistService _sharedService; private readonly YandexMusicService _yandexService; - private readonly YandexApiService _yandexApiService; public PlaylistsController( UserManager userManager, SharedPlaylistService sharedService, - YandexMusicService yandexService, - YandexApiService yandexApiService) + YandexMusicService yandexService) { _userManager = userManager; _sharedService = sharedService; _yandexService = yandexService; - _yandexApiService = yandexApiService; } [HttpGet] @@ -40,29 +36,30 @@ public class PlaylistsController : ControllerBase var user = await _userManager.FindByIdAsync(userId.ToString()); if (user == null) return Unauthorized(); - var decryptedToken = _yandexApiService.DecryptToken(user.YandexAccessToken); - if (string.IsNullOrEmpty(decryptedToken)) + if (string.IsNullOrEmpty(user.YandexAccessToken)) return BadRequest(ApiResponse.Fail(new ErrorResponse { StatusCode = 400, Message = "Токен Яндекс.Музыки не установлен или недействителен" })); - var authSuccess = await _yandexApiService.AuthAsync(decryptedToken); - if (!authSuccess) - return BadRequest(ApiResponse.Fail(new ErrorResponse { StatusCode = 400, Message = "Не удалось авторизоваться в Яндекс.Музыке. Проверьте токен." })); - - 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 result = ownPlaylists.Select(p => new YandexPlaylistShare + List result; + try { - Kind = p.Kind, - OwnerUid = p.Owner.Uid, - Title = p.Title, - CoverUrl = p.Cover?.GetUrl() ?? "", - TrackCount = p.TrackCount, - 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, - }).ToList(); + var (ownPlaylists, _) = await _yandexService.GetOwnFavoritesAsync(user); + var sharedPlaylists = await _sharedService.GetAllByUserAsync(userId); + + result = (ownPlaylists ?? []).Select(p => new YandexPlaylistShare + { + Kind = p.Kind, + OwnerUid = p.Owner.Uid, + Title = p.Title, + CoverUrl = p.Cover?.GetUrl() ?? "", + TrackCount = p.TrackCount, + 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, + }).ToList(); + } + catch (Exception ex) + { + return BadRequest(ApiResponse.Fail(new ErrorResponse { StatusCode = 400, Message = ex.Message })); + } return Ok(ApiResponse>.Ok(result)); } @@ -74,7 +71,6 @@ public class PlaylistsController : ControllerBase var user = await _userManager.FindByIdAsync(userId.ToString()); if (user == null) return Unauthorized(); - // Проверяем, что плейлист действительно принадлежит пользователю var playlist = await _yandexService.GetPlaylistAsync(user, request.OwnerUid, request.Kind); if (playlist == null) return BadRequest(ApiResponse.Fail(new ErrorResponse { StatusCode = 404, Message = "Плейлист не найден" })); @@ -86,13 +82,13 @@ public class PlaylistsController : ControllerBase YandexPlaylistOwnerUid = request.OwnerUid, Title = playlist.Title, Description = playlist.Description, - ViewPermission = ViewPermission.Everyone, - PlayPermission = ViewPermission.Everyone, - AddPermission = EditPermission.AuthorizedOnly, - RemovePermission = EditPermission.AddedByUserOnly, + ViewPermission = Shared.Enums.ViewPermission.Everyone, + PlayPermission = Shared.Enums.ViewPermission.Everyone, + AddPermission = Shared.Enums.EditPermission.AuthorizedOnly, + RemovePermission = Shared.Enums.EditPermission.AddedByUserOnly, }; var result = await _sharedService.CreateAsync(userId, dto); return Ok(ApiResponse.Ok(result)); } -} \ No newline at end of file +} diff --git a/PlaylistShared.Api/Controllers/SharedPlaylistController.cs b/PlaylistShared.Api/Controllers/SharedPlaylistController.cs index b027c2a..593c37f 100644 --- a/PlaylistShared.Api/Controllers/SharedPlaylistController.cs +++ b/PlaylistShared.Api/Controllers/SharedPlaylistController.cs @@ -7,7 +7,8 @@ using PlaylistShared.Api.Services; using PlaylistShared.Shared; using PlaylistShared.Shared.SharedPlaylist; using PlaylistShared.Shared.Yandex; -using YandexMusic.API.Models.Playlist; + +namespace PlaylistShared.Api.Controllers; [ApiController] [Route("api/[controller]")] @@ -40,19 +41,16 @@ public class SharedPlaylistController : ControllerBase [HttpGet("{token}")] public async Task>> GetByToken(string token) { - var playlist = await _sharedService.GetByTokenAsync(token); - if (playlist == null) + var currentUserId = User.GetUserIdOrNull(); + + var entity = await _sharedService.GetEntityByTokenAsync(token); + if (entity == null) return NotFound(ApiResponse.Fail(new ErrorResponse { StatusCode = 404, Message = "Плейлист не найден" })); - var currentUserId = User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; - var userIdGuid = !string.IsNullOrEmpty(currentUserId) ? Guid.Parse(currentUserId) : (Guid?)null; - - // Проверка прав просмотра (требует доступа к сущности) - var entity = await _sharedService.GetEntityByTokenAsync(token); - if (entity == null || !await _sharedService.CanViewAsync(entity, userIdGuid)) + if (!await _sharedService.CanViewAsync(entity, currentUserId)) return Unauthorized(ApiResponse.Fail(new ErrorResponse { StatusCode = 401, Message = "Недостаточно прав" })); - return Ok(ApiResponse.Ok(playlist)); + return Ok(ApiResponse.Ok(_sharedService.MapToDto(entity))); } // GET /api/sharedplaylist/{token}/tracks @@ -71,11 +69,10 @@ public class SharedPlaylistController : ControllerBase if (creator == null) return StatusCode(500, ApiResponse.Fail(new ErrorResponse { StatusCode = 500, Message = "Владелец плейлиста не найден" })); - var yandexPlaylist = await _yandexService.GetPlaylistAsync(creator, playlist.YandexPlaylistOwnerUid, playlist.YandexPlaylistKind); - if (yandexPlaylist == null) + var dto = await _yandexService.GetPlaylistDataAsync(creator, playlist.YandexPlaylistOwnerUid, playlist.YandexPlaylistKind); + if (dto == null) return NotFound(ApiResponse.Fail(new ErrorResponse { StatusCode = 404, Message = "Плейлист не найден в Яндекс.Музыке" })); - var dto = MapToYandexPlaylistData(yandexPlaylist); return Ok(ApiResponse.Ok(dto)); } @@ -129,6 +126,22 @@ public class SharedPlaylistController : ControllerBase return Ok(ApiResponse.Ok(new { message = "Треки добавлены" })); } + // GET /api/sharedplaylist/{token}/additions + [HttpGet("{token}/additions")] + public async Task>>> GetAdditions(string token) + { + var currentUserId = User.GetUserIdOrNull(); + var playlist = await _sharedService.GetEntityByTokenAsync(token); + if (playlist == null) + return NotFound(ApiResponse>.Fail(new ErrorResponse { StatusCode = 404, Message = "Плейлист не найден" })); + + if (!await _sharedService.CanViewAsync(playlist, currentUserId)) + return Unauthorized(); + + var additions = await _trackAdditionLogService.GetAdditionUserNamesAsync(playlist.Id); + return Ok(ApiResponse>.Ok(additions)); + } + // POST /api/sharedplaylist/{token}/remove-tracks [HttpPost("{token}/remove-tracks")] public async Task>> RemoveTracks(string token, [FromBody] UpdateTrackListRequest request) @@ -164,26 +177,4 @@ public class SharedPlaylistController : ControllerBase return Ok(ApiResponse.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() - }; - } } \ No newline at end of file diff --git a/PlaylistShared.Api/Controllers/YandexAccountController.cs b/PlaylistShared.Api/Controllers/YandexAccountController.cs index c7af3bc..7c17982 100644 --- a/PlaylistShared.Api/Controllers/YandexAccountController.cs +++ b/PlaylistShared.Api/Controllers/YandexAccountController.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using PlaylistShared.Api.Entities; @@ -31,11 +31,7 @@ public class YandexAccountController : ControllerBase var user = await _userManager.FindByIdAsync(userId.ToString()); if (user == null) return Unauthorized(); - user.YandexAccessToken = _yandexService.Service.EncryptToken(request.Token); - // Не храним refresh-токен, так как пользователь вводит только access-токен - user.YandexTokenExpiryUtc = DateTime.UtcNow.AddMonths(1); // условно, т.к. срок жизни токена неизвестен - await _userManager.UpdateAsync(user); - + await SaveYandexTokenAsync(user, request.Token); return Ok(ApiResponse.Ok(new { message = "Токен сохранён" })); } @@ -65,7 +61,6 @@ public class YandexAccountController : ControllerBase if (user == null) return Unauthorized(); var qr = await _yandexService.GetQrOrGenerate(user); - return Ok(ApiResponse.Ok(qr)); } @@ -81,10 +76,16 @@ public class YandexAccountController : ControllerBase 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.Ok(checkResult)); } -} \ No newline at end of file + + private async Task SaveYandexTokenAsync(ApplicationUser user, string token) + { + user.YandexAccessToken = _yandexService.Service.EncryptToken(token); + user.YandexTokenExpiryUtc = DateTime.UtcNow.AddMonths(1); + await _userManager.UpdateAsync(user); + } +} diff --git a/PlaylistShared.Api/Data/ApplicationDbContext.cs b/PlaylistShared.Api/Data/ApplicationDbContext.cs index 14c33bd..472ca8c 100644 --- a/PlaylistShared.Api/Data/ApplicationDbContext.cs +++ b/PlaylistShared.Api/Data/ApplicationDbContext.cs @@ -90,6 +90,12 @@ public class ApplicationDbContext : IdentityDbContext(entity => + { + entity.HasIndex(e => e.RefreshToken) + .HasDatabaseName("IX_AspNetUsers_RefreshToken"); + }); + builder.Entity(entity => { entity.HasKey(e => new { e.UserId, e.SharedPlaylistId }); @@ -128,10 +134,12 @@ public class ApplicationDbContext : IdentityDbContext e.TrackId) .HasMaxLength(100) .IsRequired(false); - entity.Property(e => e.CsfrToken) + entity.Property(e => e.CsrfToken) + .HasColumnName("CsfrToken") .HasMaxLength(200) .IsRequired(false); - entity.Property(e => e.HeaderCsfrToken) + entity.Property(e => e.HeaderCsrfToken) + .HasColumnName("HeaderCsfrToken") .HasMaxLength(200) .IsRequired(false); entity.Property(e => e.HeaderProcessId) diff --git a/PlaylistShared.Api/Entities/YandexAuthSession.cs b/PlaylistShared.Api/Entities/YandexAuthSession.cs index 2becf4e..3a15193 100644 --- a/PlaylistShared.Api/Entities/YandexAuthSession.cs +++ b/PlaylistShared.Api/Entities/YandexAuthSession.cs @@ -1,18 +1,18 @@ -namespace PlaylistShared.Api.Entities; +namespace PlaylistShared.Api.Entities; public class YandexAuthSession { public int Id { get; set; } public Guid? UserId { get; set; } - public string QrCodeUrl { get; set; } - public string SerializedCookies { get; set; } + public string QrCodeUrl { get; set; } = string.Empty; + public string SerializedCookies { get; set; } = string.Empty; public DateTime CreatedAt { get; set; } public DateTime? ConfirmedAt { get; set; } public bool IsConfirmed { get; set; } public string? TrackId { get; set; } - public string? CsfrToken { get; set; } + public string? CsrfToken { get; set; } public string? HeaderProcessId { get; set; } - public string? HeaderCsfrToken { get; set; } + public string? HeaderCsrfToken { get; set; } public ApplicationUser? User { get; set; } -} \ No newline at end of file +} diff --git a/PlaylistShared.Api/Program.cs b/PlaylistShared.Api/Program.cs index 45ea2e0..c8151bd 100644 --- a/PlaylistShared.Api/Program.cs +++ b/PlaylistShared.Api/Program.cs @@ -111,7 +111,7 @@ public class Program { options.AddPolicy("Production", policy => { - policy.WithOrigins(builder.Configuration.GetSection("Cors:Origins").Get()) + policy.WithOrigins(builder.Configuration.GetSection("Cors:Origins").Get() ?? []) .AllowAnyMethod() .AllowAnyHeader() .AllowCredentials(); @@ -123,18 +123,12 @@ public class Program { options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); - builder.Services.AddEndpointsApiExplorer(); - builder.Services.AddSwaggerGen(); - builder.Services.AddOpenApi(); var app = builder.Build(); app.MapOpenApi(); - app.UseSwagger(); - app.UseSwaggerUI(); - app.UseCors("Production"); if (!app.Environment.IsDevelopment()) diff --git a/PlaylistShared.Api/Services/FavoritesService.cs b/PlaylistShared.Api/Services/FavoritesService.cs index 7b0bd2f..4d4894a 100644 --- a/PlaylistShared.Api/Services/FavoritesService.cs +++ b/PlaylistShared.Api/Services/FavoritesService.cs @@ -90,10 +90,10 @@ public class FavoritesService Creator = sp.Creator != null ? new Shared.Auth.ApplicationUserDto { Id = sp.Creator.Id, - UserName = sp.Creator.UserName, - Email = sp.Creator.Email, + UserName = sp.Creator.UserName ?? "", + Email = sp.Creator.Email ?? "", YandexId = sp.Creator.YandexId, - DisplayName = sp.Creator.UserName + DisplayName = sp.Creator.UserName ?? "" } : null }).ToList(); } diff --git a/PlaylistShared.Api/Services/JwtService.cs b/PlaylistShared.Api/Services/JwtService.cs index 1adb510..63f46a9 100644 --- a/PlaylistShared.Api/Services/JwtService.cs +++ b/PlaylistShared.Api/Services/JwtService.cs @@ -1,4 +1,5 @@ -using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Identity; using Microsoft.IdentityModel.Tokens; using PlaylistShared.Api.Entities; using System.IdentityModel.Tokens.Jwt; @@ -11,11 +12,15 @@ public class JwtService { private readonly IConfiguration _configuration; private readonly UserManager _userManager; + private readonly ITimeLimitedDataProtector _playTokenProtector; - public JwtService(IConfiguration configuration, UserManager userManager) + public JwtService(IConfiguration configuration, UserManager userManager, IDataProtectionProvider dataProtectionProvider) { _configuration = configuration; _userManager = userManager; + _playTokenProtector = dataProtectionProvider + .CreateProtector("AudioPlayToken") + .ToTimeLimitedDataProtector(); } public async Task<(string Token, string RefreshToken, DateTime Expiration)> GenerateTokenAsync(ApplicationUser user) @@ -71,4 +76,20 @@ public class JwtService return null; } } -} \ No newline at end of file + + 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; + } + } +} diff --git a/PlaylistShared.Api/Services/SharedPlaylistService.cs b/PlaylistShared.Api/Services/SharedPlaylistService.cs index 9abb907..0f62ffa 100644 --- a/PlaylistShared.Api/Services/SharedPlaylistService.cs +++ b/PlaylistShared.Api/Services/SharedPlaylistService.cs @@ -42,14 +42,6 @@ public class SharedPlaylistService return MapToDto(entity); } - public async Task 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 GetEntityByTokenAsync(string token) { return await _db.SharedPlaylists @@ -142,8 +134,7 @@ public class SharedPlaylistService .ToListAsync(); } - // Ручное маппинг сущности в DTO - private SharedPlaylistDto MapToDto(SharedPlaylist entity) + public SharedPlaylistDto MapToDto(SharedPlaylist entity) { return new SharedPlaylistDto { diff --git a/PlaylistShared.Api/Services/TrackAdditionLogService.cs b/PlaylistShared.Api/Services/TrackAdditionLogService.cs index c59624f..e5cff9e 100644 --- a/PlaylistShared.Api/Services/TrackAdditionLogService.cs +++ b/PlaylistShared.Api/Services/TrackAdditionLogService.cs @@ -43,4 +43,17 @@ public class TrackAdditionLogService _db.TrackAdditionLogs.RemoveRange(logs); await _db.SaveChangesAsync(); } + + public async Task> 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); + } } \ No newline at end of file diff --git a/PlaylistShared.Api/Services/Yandex/YandexApiService.cs b/PlaylistShared.Api/Services/Yandex/YandexApiService.cs index b25545f..49de13f 100644 --- a/PlaylistShared.Api/Services/Yandex/YandexApiService.cs +++ b/PlaylistShared.Api/Services/Yandex/YandexApiService.cs @@ -80,7 +80,7 @@ public class YandexApiService : IDisposable /// /// /// - public string DecryptToken(string encryptedToken) + public string? DecryptToken(string encryptedToken) { try { diff --git a/PlaylistShared.Api/Services/Yandex/YandexAuthService.cs b/PlaylistShared.Api/Services/Yandex/YandexAuthService.cs index 5b2007e..8e3ef42 100644 --- a/PlaylistShared.Api/Services/Yandex/YandexAuthService.cs +++ b/PlaylistShared.Api/Services/Yandex/YandexAuthService.cs @@ -1,4 +1,4 @@ -using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore; using PlaylistShared.Api.Data; using PlaylistShared.Api.Entities; using PlaylistShared.Shared.Yandex; @@ -24,10 +24,10 @@ public class YandexAuthService internal async Task 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)) .OrderByDescending(s => s.CreatedAt) - .FirstOrDefault(); + .FirstOrDefaultAsync(); if (existingSession != null) { @@ -45,14 +45,14 @@ public class YandexAuthService { var qr = await Api.Passport.GetAuthQRLinkAsync(); 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 headerCsfrToken = Service.Client.AuthStorage.HeaderToken.CsfrToken; + var headerCsrfToken = Service.Client.AuthStorage.HeaderToken.CsfrToken; if (string.IsNullOrEmpty(qr)) throw new Exception("Не удалось получить QR-ссылку"); - var cookiesJson = SerializeCookies(_apiService.CookieContainer); + var cookiesJson = _apiService.EncryptToken(SerializeCookies(_apiService.CookieContainer)); var session = new YandexAuthSession { @@ -62,10 +62,9 @@ public class YandexAuthService CreatedAt = DateTime.UtcNow, IsConfirmed = false, TrackId = trackId, - CsfrToken = csfrToken, - HeaderCsfrToken = headerCsfrToken, + CsrfToken = csrfToken, + HeaderCsrfToken = headerCsrfToken, HeaderProcessId = headerProcessUuid, - }; _dbContext.YandexAuthSessions.Add(session); @@ -83,16 +82,19 @@ public class YandexAuthService var session = await _dbContext.YandexAuthSessions.FindAsync(sessionId); 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) { Service.Client.AuthStorage.AuthToken = new(); } - Service.Client.AuthStorage.AuthToken.CsfrToken = session?.CsfrToken ?? ""; - Service.Client.AuthStorage.AuthToken.TrackId = session?.TrackId ?? ""; - Service.Client.AuthStorage.HeaderToken.CsfrToken = session?.HeaderCsfrToken ?? ""; - Service.Client.AuthStorage.HeaderToken.ProcessUuid = session?.HeaderProcessId ?? ""; + Service.Client.AuthStorage.AuthToken.CsfrToken = session.CsrfToken ?? ""; + Service.Client.AuthStorage.AuthToken.TrackId = session.TrackId ?? ""; + Service.Client.AuthStorage.HeaderToken.CsfrToken = session.HeaderCsrfToken ?? ""; + Service.Client.AuthStorage.HeaderToken.ProcessUuid = session.HeaderProcessId ?? ""; var status = await Api.Passport.CheckQRStatusAsync(); @@ -100,36 +102,29 @@ public class YandexAuthService { 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(); - _dbContext.SaveChanges(); + await _dbContext.YandexAuthSessions + .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() - { - Status = Shared.Enums.YandexAuthQrStatus.Pending, - }; + return new() { Status = Shared.Enums.YandexAuthQrStatus.Pending }; } - private string SerializeCookies(CookieContainer container) { var allCookies = new List(); - - var cookies = container.GetAllCookies(); - foreach (Cookie cookie in cookies) - { + foreach (Cookie cookie in container.GetAllCookies()) allCookies.Add(new { cookie.Name, cookie.Value, cookie.Domain, cookie.Path }); - } - return JsonSerializer.Serialize(allCookies); } @@ -137,9 +132,7 @@ public class YandexAuthService { var cookies = JsonSerializer.Deserialize>(serializedCookies); foreach (var c in cookies) - { container.Add(new Cookie(c.Name, c.Value, c.Path, c.Domain)); - } } private class CookieData @@ -149,4 +142,4 @@ public class YandexAuthService public string Domain { get; set; } public string Path { get; set; } } -} \ No newline at end of file +} diff --git a/PlaylistShared.Api/Services/Yandex/YandexMusicService.cs b/PlaylistShared.Api/Services/Yandex/YandexMusicService.cs index eef0547..5db3a03 100644 --- a/PlaylistShared.Api/Services/Yandex/YandexMusicService.cs +++ b/PlaylistShared.Api/Services/Yandex/YandexMusicService.cs @@ -35,6 +35,21 @@ public class YandexMusicService return await Api.Playlist.GetAsync(ownerUid, kind); } + public async Task GetPlaylistDataAsync(ApplicationUser user, string ownerUid, string kind) + { + var playlist = await GetPlaylistAsync(user, ownerUid, kind); + return playlist == null ? null : MapToPlaylistData(playlist); + } + + public async Task<(List? 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 CreatePlaylistAsync(ApplicationUser user, string title) { await AuthorizeIfNot(user); @@ -339,4 +354,24 @@ public class YandexMusicService 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() + }; } \ No newline at end of file diff --git a/PlaylistShared.Pwa/Components/Common/TrackItem.razor b/PlaylistShared.Pwa/Components/Common/TrackItem.razor index 5250e05..3c97eee 100644 --- a/PlaylistShared.Pwa/Components/Common/TrackItem.razor +++ b/PlaylistShared.Pwa/Components/Common/TrackItem.razor @@ -17,7 +17,13 @@ @Track.Title - @string.Join(", ", Track.Artists.Select(a => a.Name)) + + @string.Join(", ", Track.Artists.Select(a => a.Name)) + @if (!string.IsNullOrEmpty(AddedByName)) + { + @AddedByName + } + @@ -33,4 +39,5 @@ [Parameter] public YandexTrack Track { get; set; } = null!; [Parameter] public string PlaylistShareToken { get; set; } = string.Empty; [Parameter] public bool CanPlay { get; set; } = true; + [Parameter] public string? AddedByName { get; set; } } \ No newline at end of file diff --git a/PlaylistShared.Pwa/Components/Global/AudioPlayer.razor b/PlaylistShared.Pwa/Components/Global/AudioPlayer.razor index 9e8223d..0f3c98a 100644 --- a/PlaylistShared.Pwa/Components/Global/AudioPlayer.razor +++ b/PlaylistShared.Pwa/Components/Global/AudioPlayer.razor @@ -11,6 +11,12 @@ + + - + + + @if (AudioPlayerService.CurrentTrack != null) { @@ -170,15 +182,9 @@ #endregion #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)) - { - var tokens = await TokenStorage.GetTokensAsync(); - accessToken = tokens.token; - } - - if (string.IsNullOrWhiteSpace(accessToken) && string.IsNullOrWhiteSpace(sharedPlaylistId)) + if (string.IsNullOrWhiteSpace(playToken) && string.IsNullOrWhiteSpace(sharedPlaylistId)) { Snackbar.Add("Токен авторизации не найден. Пожалуйста, войдите заново.", Severity.Error); return; @@ -186,7 +192,7 @@ var streamUrl = new Uri(Http.BaseAddress!, $"/api/audio/track/{trackId}").ToString(); await EnsureAudioModuleAsync(); - await _audioElement!.InvokeVoidAsync("loadAndPlay", streamUrl, accessToken, sharedPlaylistId); + await _audioElement!.InvokeVoidAsync("loadAndPlay", streamUrl, playToken, sharedPlaylistId); } private async Task OnServicePlay() diff --git a/PlaylistShared.Pwa/Pages/SharedPlaylistView.razor b/PlaylistShared.Pwa/Pages/SharedPlaylistView.razor index 753d11f..eccefb5 100644 --- a/PlaylistShared.Pwa/Pages/SharedPlaylistView.razor +++ b/PlaylistShared.Pwa/Pages/SharedPlaylistView.razor @@ -1,6 +1,17 @@ @page "/shared/{token}" @_playlist?.Title - Playlist Share + + + + @if (!string.IsNullOrEmpty(_playlist?.CoverUrl)) + { + + } + + + + @using PlaylistShared.Pwa.Components.Common @using PlaylistShared.Pwa.Components.Global @using PlaylistShared.Pwa.Components.SharedPlaylist @@ -245,7 +256,8 @@ Style="min-height: 0;"> - + @if (_canRemove) { @@ -444,6 +456,10 @@ /// private List _tracks = new(); /// + /// Словарь trackId → имя пользователя, добавившего трек. + /// + private Dictionary _trackAdditions = new(); + /// /// Продолжительность плейлиста. /// long _playlistDurationMs; @@ -621,6 +637,15 @@ _existingTrackIds = _tracks.Select(t => t.TrackId).ToHashSet(); _playlistDurationMs = _tracks.Sum(t => t.DurationMs); _playlistTrackCount = _tracks.Count(); + AudioPlayerService.SetQueue(_tracks, shareToken: Token); + + try + { + var additionsResp = await Http.GetFromJsonAsync>>($"/api/sharedplaylist/{Token}/additions"); + if (additionsResp?.Success == true && additionsResp.Data != null) + _trackAdditions = additionsResp.Data; + } + catch { } } else { diff --git a/PlaylistShared.Pwa/Services/AudioPlayerService.cs b/PlaylistShared.Pwa/Services/AudioPlayerService.cs index 66cbe5a..3c765ec 100644 --- a/PlaylistShared.Pwa/Services/AudioPlayerService.cs +++ b/PlaylistShared.Pwa/Services/AudioPlayerService.cs @@ -1,6 +1,7 @@ -using MudBlazor; +using MudBlazor; using PlaylistShared.Shared; using PlaylistShared.Shared.Yandex; +using System.Net.Http.Headers; using System.Net.Http.Json; namespace PlaylistShared.Pwa.Services; @@ -22,9 +23,16 @@ public class AudioPlayerService : IAudioPlayerService private string _currentTimeString = "0:00"; private string _totalTimeString = "0:00"; + private List _queue = new(); + private int _queueIndex = -1; + private string? _queueShareToken; + public string? CurrentTrackId => _currentTrackId; public YandexTrack? CurrentTrack => _currentTrack; public bool IsPlaying => _isPlaying; + public IReadOnlyList CurrentQueue => _queue; + public bool HasNext => _queueIndex >= 0 && _queueIndex < _queue.Count - 1; + public bool HasPrevious => _queueIndex > 0; public double CurrentVolume { get => _currentVolume; @@ -57,14 +65,10 @@ public class AudioPlayerService : IAudioPlayerService private async Task LoadVolume() { var savedVolume = await _playerStorage.GetVolumeAsync(); - if (savedVolume != null) - { _currentVolume = savedVolume.Value; - } } - // Внешние команды (вызываются из компонентов) public async Task LoadAndPlayAsync(string trackId, string? accessToken = null, string? playlistShareToken = null, YandexTrack? track = null) { if (_currentTrackId == trackId) @@ -75,30 +79,33 @@ public class AudioPlayerService : IAudioPlayerService _currentTrackId = trackId; - // Если accessToken не передан, пытаемся получить его из хранилища + var idx = _queue.FindIndex(t => t.TrackId == trackId); + if (idx >= 0) _queueIndex = idx; + if (string.IsNullOrWhiteSpace(accessToken)) { var tokens = await _tokenStorage.GetTokensAsync(); accessToken = tokens.token; } - // Проверяем, есть ли чем авторизоваться - if (string.IsNullOrWhiteSpace(accessToken) && string.IsNullOrWhiteSpace(playlistShareToken)) + string? playToken = null; + if (!string.IsNullOrWhiteSpace(accessToken)) + playToken = await FetchPlayTokenAsync(accessToken); + + if (string.IsNullOrWhiteSpace(playToken) && string.IsNullOrWhiteSpace(playlistShareToken)) { _snackbar.Add("Не удалось воспроизвести трек: отсутствует токен авторизации или идентификатор расшаренного плейлиста.", Severity.Error); return; } - // Если title и coverUrl не переданы, нужно запросить через API if (track is null) { try { - track = await GetTrackInfo(trackId, accessToken, playlistShareToken); + track = await GetTrackInfo(trackId, playToken, playlistShareToken); } catch (Exception ex) { - // Логируем ошибку, но продолжаем без обложки/названия Console.WriteLine($"Failed to fetch track info: {ex.Message}"); } } @@ -106,7 +113,7 @@ public class AudioPlayerService : IAudioPlayerService _currentTrack = track; _isPlaying = true; OnStateChanged?.Invoke(); - OnLoadAndPlayRequested?.Invoke(trackId, accessToken, playlistShareToken); + OnLoadAndPlayRequested?.Invoke(trackId, playToken, playlistShareToken); OnStartedTrack?.Invoke(); } @@ -138,14 +145,36 @@ public class AudioPlayerService : IAudioPlayerService await _playerStorage.SetVolumeAsync(volume); } - // События для связи с реальным AudioPlayer компонентом + public void SetQueue(IEnumerable 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? OnLoadAndPlayRequested; public event Func? OnPlayRequested; public event Func? OnPauseRequested; public event Func? OnSeekRequested; public event Func? OnVolumeChangeRequested; - // Внутренние методы для обновления состояния из AudioPlayer public void SetPlayingState(bool isPlaying) { _isPlaying = isPlaying; @@ -172,37 +201,40 @@ public class AudioPlayerService : IAudioPlayerService public void NotifyTrackEnded() { _isPlaying = false; - _currentTrackId = null; _currentProgress = 0; _currentTime = 0; _currentTimeString = "0:00"; _totalTime = 0; - _currentTimeString = "0:00"; + _totalTimeString = "0:00"; OnStateChanged?.Invoke(); OnEndedTrack?.Invoke(); + + if (HasNext) + _ = PlayNextAsync(); + else + _currentTrackId = null; } - /// - /// Вспомогательный метод для получения информации о треке через API - /// - /// - /// - /// - /// - private async Task GetTrackInfo(string trackId, string? accessToken, string? sharedPlaylistId) + private async Task FetchPlayTokenAsync(string jwt) + { + using var request = new HttpRequestMessage(HttpMethod.Get, "/api/audio/play-token"); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt); + using var response = await _http.SendAsync(request); + if (!response.IsSuccessStatusCode) return null; + var result = await response.Content.ReadFromJsonAsync>(); + return result?.Data; + } + + private async Task GetTrackInfo(string trackId, string? playToken, string? sharedPlaylistId) { var url = $"/api/audio/track-info/{trackId}"; - if (!string.IsNullOrEmpty(accessToken)) - url += $"?access_token={accessToken}"; + if (!string.IsNullOrEmpty(playToken)) + url += $"?play_token={playToken}"; else if (!string.IsNullOrEmpty(sharedPlaylistId)) url += $"?shared_id={sharedPlaylistId}"; var response = await _http.GetFromJsonAsync>(url); - if (response?.Success == true) - { - return response.Data; - } - return null; + return response?.Success == true ? response.Data : null; } private string FormatDuration(double seconds) @@ -211,4 +243,4 @@ public class AudioPlayerService : IAudioPlayerService var secs = (int)(seconds % 60); return $"{mins}:{secs:D2}"; } -} \ No newline at end of file +} diff --git a/PlaylistShared.Pwa/Services/IAudioPlayerService.cs b/PlaylistShared.Pwa/Services/IAudioPlayerService.cs index 3728bc0..7b215ee 100644 --- a/PlaylistShared.Pwa/Services/IAudioPlayerService.cs +++ b/PlaylistShared.Pwa/Services/IAudioPlayerService.cs @@ -12,6 +12,15 @@ public interface IAudioPlayerService /// ID текущего воспроизводимого трека (null, если ничего не играет). string? CurrentTrackId { get; } + /// Очередь треков. + IReadOnlyList CurrentQueue { get; } + + /// Есть ли следующий трек в очереди. + bool HasNext { get; } + + /// Есть ли предыдущий трек в очереди. + bool HasPrevious { get; } + /// Играет ли в данный момент (true) или приостановлен (false). bool IsPlaying { get; } @@ -57,6 +66,15 @@ public interface IAudioPlayerService /// Установить громкость (0–100). Task SetVolumeAsync(double volume); + + /// Установить очередь треков и начать воспроизведение с указанного индекса. + void SetQueue(IEnumerable tracks, int startIndex = 0, string? shareToken = null); + + /// Перейти к следующему треку в очереди. + Task PlayNextAsync(); + + /// Перейти к предыдущему треку в очереди. + Task PlayPreviousAsync(); #endregion #region События для подписки на изменения состояния diff --git a/PlaylistShared.Pwa/wwwroot/js/AudioPlayer.js b/PlaylistShared.Pwa/wwwroot/js/AudioPlayer.js index 015ece4..9f97393 100644 --- a/PlaylistShared.Pwa/wwwroot/js/AudioPlayer.js +++ b/PlaylistShared.Pwa/wwwroot/js/AudioPlayer.js @@ -12,7 +12,7 @@ const loadAndPlay = (src, token, sharedPlaylistId) => { 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); audio.src = url.toString(); audio.load();