diff --git a/PlaylistShared.Api/Controllers/FavoritesController.cs b/PlaylistShared.Api/Controllers/FavoritesController.cs new file mode 100644 index 0000000..f10de23 --- /dev/null +++ b/PlaylistShared.Api/Controllers/FavoritesController.cs @@ -0,0 +1,82 @@ +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.Shared; + +namespace PlaylistShared.Api.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class FavoritesController : ControllerBase +{ + private readonly UserManager _userManager; + private readonly FavoritesService _favoritesService; + private readonly SharedPlaylistService _sharedPlaylistService; + + public FavoritesController( + UserManager userManager, + FavoritesService favoritesService, + SharedPlaylistService sharedPlaylistService) + { + _userManager = userManager; + _favoritesService = favoritesService; + _sharedPlaylistService = sharedPlaylistService; + } + + /// Получить список избранных плейлистов текущего пользователя. + [HttpGet] + public async Task>>> GetFavorites() + { + var userId = User.GetUserId(); + var user = await _userManager.FindByIdAsync(userId.ToString()); + if (user == null) return Unauthorized(); + + var favorites = await _favoritesService.GetUserFavoritesAsync(userId); + return Ok(ApiResponse>.Ok(favorites)); + } + + /// Проверить, добавлен ли плейлист в избранное. + [HttpGet("{shareToken}/check")] + public async Task>> CheckFavorite(string shareToken) + { + var userId = User.GetUserId(); + var user = await _userManager.FindByIdAsync(userId.ToString()); + if (user == null) return Unauthorized(); + + var isFavorite = await _favoritesService.IsFavoriteAsync(userId, shareToken); + return Ok(ApiResponse.Ok(isFavorite)); + } + + /// Добавить плейлист в избранное. + [HttpPost("{shareToken}")] + public async Task>> AddFavorite(string shareToken) + { + var userId = User.GetUserId(); + var user = await _userManager.FindByIdAsync(userId.ToString()); + if (user == null) return Unauthorized(); + + var playlist = await _sharedPlaylistService.GetEntityByTokenAsync(shareToken); + if (playlist == null) + return NotFound(ApiResponse.Fail(new ErrorResponse { StatusCode = 404, Message = "Плейлист не найден" })); + + await _favoritesService.AddFavoriteAsync(userId, shareToken); + return Ok(ApiResponse.Ok(new { message = "Плейлист добавлен в избранное" })); + } + + /// Удалить плейлист из избранного. + [HttpDelete("{shareToken}")] + public async Task>> RemoveFavorite(string shareToken) + { + var userId = User.GetUserId(); + var user = await _userManager.FindByIdAsync(userId.ToString()); + if (user == null) return Unauthorized(); + + await _favoritesService.RemoveFavoriteAsync(userId, shareToken); + return Ok(ApiResponse.Ok(new { message = "Плейлист удалён из избранного" })); + } +} \ No newline at end of file diff --git a/PlaylistShared.Api/Controllers/PlaylistsController.cs b/PlaylistShared.Api/Controllers/PlaylistsController.cs index 823f25a..aeea4de 100644 --- a/PlaylistShared.Api/Controllers/PlaylistsController.cs +++ b/PlaylistShared.Api/Controllers/PlaylistsController.cs @@ -82,6 +82,7 @@ public class PlaylistsController : ControllerBase var dto = new SharePlaylistDto { + YandexPlaylistUuid = playlist.PlaylistUuid, YandexPlaylistKind = request.Kind, YandexPlaylistOwnerUid = request.OwnerUid, Title = playlist.Title, diff --git a/PlaylistShared.Api/Data/ApplicationDbContext.cs b/PlaylistShared.Api/Data/ApplicationDbContext.cs index 419df82..abc0167 100644 --- a/PlaylistShared.Api/Data/ApplicationDbContext.cs +++ b/PlaylistShared.Api/Data/ApplicationDbContext.cs @@ -11,8 +11,9 @@ public class ApplicationDbContext : IdentityDbContext SharedPlaylists => Set(); public DbSet TrackAdditionLogs => Set(); - public DbSet UserSessions => Set(); public DbSet TrackRemovalLogs => Set(); + public DbSet UserSessions => Set(); + public DbSet FavoritePlaylists => Set(); protected override void OnModelCreating(ModelBuilder builder) { @@ -26,6 +27,7 @@ public class ApplicationDbContext : IdentityDbContext u.OwnedPlaylists) .HasForeignKey(e => e.CreatorUserId) .OnDelete(DeleteBehavior.Restrict); + entity.Property(e => e.YandexPlaylistUuid).IsRequired().HasMaxLength(50); entity.Property(e => e.YandexPlaylistKind).IsRequired().HasMaxLength(50); entity.Property(e => e.YandexPlaylistOwnerUid).IsRequired().HasMaxLength(50); entity.Property(e => e.Title).IsRequired().HasMaxLength(255); @@ -77,5 +79,19 @@ public class ApplicationDbContext : IdentityDbContext e.SessionId) .OnDelete(DeleteBehavior.Restrict); }); + + builder.Entity(entity => + { + entity.HasKey(e => new { e.UserId, e.SharedPlaylistId }); + entity.HasOne(e => e.User) + .WithMany(u => u.FavoritePlaylists) + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade); + entity.HasOne(e => e.SharedPlaylist) + .WithMany() + .HasForeignKey(e => e.SharedPlaylistId) + .OnDelete(DeleteBehavior.Cascade); + entity.Property(e => e.AddedAtUtc).IsRequired(); + }); } } \ No newline at end of file diff --git a/PlaylistShared.Api/Data/Migrations/20260414111229_AddUserFavorites.Designer.cs b/PlaylistShared.Api/Data/Migrations/20260414111229_AddUserFavorites.Designer.cs new file mode 100644 index 0000000..9ddcf63 --- /dev/null +++ b/PlaylistShared.Api/Data/Migrations/20260414111229_AddUserFavorites.Designer.cs @@ -0,0 +1,591 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using PlaylistShared.Api.Data; + +#nullable disable + +namespace PlaylistShared.Api.Data.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20260414111229_AddUserFavorites")] + partial class AddUserFavorites + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.5") + .HasAnnotation("Relational:MaxIdentifierLength", 128); + + SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex") + .HasFilter("[NormalizedName] IS NOT NULL"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("int"); + + SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("nvarchar(max)"); + + b.Property("ClaimValue") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderKey") + .HasColumnType("nvarchar(450)"); + + b.Property("ProviderDisplayName") + .HasColumnType("nvarchar(max)"); + + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("RoleId") + .HasColumnType("uniqueidentifier"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("LoginProvider") + .HasColumnType("nvarchar(450)"); + + b.Property("Name") + .HasColumnType("nvarchar(450)"); + + b.Property("Value") + .HasColumnType("nvarchar(max)"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AccessFailedCount") + .HasColumnType("int"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("nvarchar(max)"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("bit"); + + b.Property("LockoutEnabled") + .HasColumnType("bit"); + + b.Property("LockoutEnd") + .HasColumnType("datetimeoffset"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("PasswordHash") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumber") + .HasColumnType("nvarchar(max)"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("bit"); + + b.Property("RefreshToken") + .HasColumnType("nvarchar(max)"); + + b.Property("RefreshTokenExpiryUtc") + .HasColumnType("datetime2"); + + b.Property("SecurityStamp") + .HasColumnType("nvarchar(max)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("bit"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("nvarchar(256)"); + + b.Property("YandexAccessToken") + .HasColumnType("nvarchar(max)"); + + b.Property("YandexId") + .HasColumnType("nvarchar(max)"); + + b.Property("YandexRefreshToken") + .HasColumnType("nvarchar(max)"); + + b.Property("YandexTokenExpiryUtc") + .HasColumnType("datetime2"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex") + .HasFilter("[NormalizedUserName] IS NOT NULL"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.FavoritePlaylist", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("SharedPlaylistId") + .HasColumnType("uniqueidentifier"); + + b.Property("AddedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "SharedPlaylistId"); + + b.HasIndex("SharedPlaylistId"); + + b.ToTable("FavoritePlaylists"); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.SharedPlaylist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AddPermission") + .HasColumnType("int"); + + b.Property("CoverUrl") + .HasColumnType("nvarchar(max)"); + + b.Property("CreatedAt") + .HasColumnType("datetime2"); + + b.Property("CreatorUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("Description") + .HasColumnType("nvarchar(max)"); + + b.Property("IsDeleted") + .HasColumnType("bit"); + + b.Property("PlayPermission") + .HasColumnType("int"); + + b.Property("RemovePermission") + .HasColumnType("int"); + + b.Property("ShareToken") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("nvarchar(255)"); + + b.Property("UpdatedAt") + .HasColumnType("datetime2"); + + b.Property("ViewPermission") + .HasColumnType("int"); + + b.Property("YandexPlaylistKind") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("YandexPlaylistOwnerUid") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.Property("YandexPlaylistUuid") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatorUserId"); + + b.HasIndex("ShareToken") + .IsUnique(); + + b.ToTable("SharedPlaylists"); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.TrackAdditionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("AddedAtUtc") + .HasColumnType("datetime2"); + + b.Property("AddedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("nvarchar(449)"); + + b.Property("SharedPlaylistId") + .HasColumnType("uniqueidentifier"); + + b.Property("TrackId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("AddedByUserId"); + + b.HasIndex("SessionId"); + + b.HasIndex("SharedPlaylistId", "TrackId"); + + b.ToTable("TrackAdditionLogs"); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.UserSession", b => + { + b.Property("SessionId") + .HasMaxLength(449) + .HasColumnType("nvarchar(449)"); + + b.Property("AssociatedUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("ClientIpAddress") + .HasColumnType("nvarchar(max)"); + + b.Property("FirstSeenUtc") + .HasColumnType("datetime2"); + + b.Property("LastSeenUtc") + .HasColumnType("datetime2"); + + b.Property("UserAgent") + .HasColumnType("nvarchar(max)"); + + b.HasKey("SessionId"); + + b.HasIndex("AssociatedUserId"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("TrackRemovalLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uniqueidentifier"); + + b.Property("RemovedAtUtc") + .HasColumnType("datetime2"); + + b.Property("RemovedByUserId") + .HasColumnType("uniqueidentifier"); + + b.Property("SessionId") + .IsRequired() + .HasColumnType("nvarchar(449)"); + + b.Property("SharedPlaylistId") + .HasColumnType("uniqueidentifier"); + + b.Property("TrackId") + .IsRequired() + .HasColumnType("nvarchar(450)"); + + b.HasKey("Id"); + + b.HasIndex("RemovedByUserId"); + + b.HasIndex("SessionId"); + + b.HasIndex("SharedPlaylistId", "TrackId"); + + b.ToTable("TrackRemovalLogs"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.FavoritePlaylist", b => + { + b.HasOne("PlaylistShared.Api.Entities.SharedPlaylist", "SharedPlaylist") + .WithMany() + .HasForeignKey("SharedPlaylistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", "User") + .WithMany("FavoritePlaylists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SharedPlaylist"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.SharedPlaylist", b => + { + b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", "Creator") + .WithMany("OwnedPlaylists") + .HasForeignKey("CreatorUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Creator"); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.TrackAdditionLog", b => + { + b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", "AddedByUser") + .WithMany() + .HasForeignKey("AddedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("PlaylistShared.Api.Entities.UserSession", "Session") + .WithMany("TrackAdditionLogs") + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PlaylistShared.Api.Entities.SharedPlaylist", "SharedPlaylist") + .WithMany("TrackAdditionLogs") + .HasForeignKey("SharedPlaylistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AddedByUser"); + + b.Navigation("Session"); + + b.Navigation("SharedPlaylist"); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.UserSession", b => + { + b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", "User") + .WithMany() + .HasForeignKey("AssociatedUserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("User"); + }); + + modelBuilder.Entity("TrackRemovalLog", b => + { + b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", "RemovedByUser") + .WithMany() + .HasForeignKey("RemovedByUserId") + .OnDelete(DeleteBehavior.Restrict); + + b.HasOne("PlaylistShared.Api.Entities.UserSession", "Session") + .WithMany("TrackRemovalLogs") + .HasForeignKey("SessionId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("PlaylistShared.Api.Entities.SharedPlaylist", "SharedPlaylist") + .WithMany() + .HasForeignKey("SharedPlaylistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("RemovedByUser"); + + b.Navigation("Session"); + + b.Navigation("SharedPlaylist"); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.ApplicationUser", b => + { + b.Navigation("FavoritePlaylists"); + + b.Navigation("OwnedPlaylists"); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.SharedPlaylist", b => + { + b.Navigation("TrackAdditionLogs"); + }); + + modelBuilder.Entity("PlaylistShared.Api.Entities.UserSession", b => + { + b.Navigation("TrackAdditionLogs"); + + b.Navigation("TrackRemovalLogs"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/PlaylistShared.Api/Data/Migrations/20260414111229_AddUserFavorites.cs b/PlaylistShared.Api/Data/Migrations/20260414111229_AddUserFavorites.cs new file mode 100644 index 0000000..1d3bd8e --- /dev/null +++ b/PlaylistShared.Api/Data/Migrations/20260414111229_AddUserFavorites.cs @@ -0,0 +1,64 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PlaylistShared.Api.Data.Migrations +{ + /// + public partial class AddUserFavorites : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "YandexPlaylistUuid", + table: "SharedPlaylists", + type: "nvarchar(50)", + maxLength: 50, + nullable: false, + defaultValue: ""); + + migrationBuilder.CreateTable( + name: "FavoritePlaylists", + columns: table => new + { + UserId = table.Column(type: "uniqueidentifier", nullable: false), + SharedPlaylistId = table.Column(type: "uniqueidentifier", nullable: false), + AddedAtUtc = table.Column(type: "datetime2", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_FavoritePlaylists", x => new { x.UserId, x.SharedPlaylistId }); + table.ForeignKey( + name: "FK_FavoritePlaylists_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_FavoritePlaylists_SharedPlaylists_SharedPlaylistId", + column: x => x.SharedPlaylistId, + principalTable: "SharedPlaylists", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_FavoritePlaylists_SharedPlaylistId", + table: "FavoritePlaylists", + column: "SharedPlaylistId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "FavoritePlaylists"); + + migrationBuilder.DropColumn( + name: "YandexPlaylistUuid", + table: "SharedPlaylists"); + } + } +} diff --git a/PlaylistShared.Api/Data/Migrations/ApplicationDbContextModelSnapshot.cs b/PlaylistShared.Api/Data/Migrations/ApplicationDbContextModelSnapshot.cs index 9811f3c..4728777 100644 --- a/PlaylistShared.Api/Data/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/PlaylistShared.Api/Data/Migrations/ApplicationDbContextModelSnapshot.cs @@ -237,6 +237,24 @@ namespace PlaylistShared.Api.Data.Migrations b.ToTable("AspNetUsers", (string)null); }); + modelBuilder.Entity("PlaylistShared.Api.Entities.FavoritePlaylist", b => + { + b.Property("UserId") + .HasColumnType("uniqueidentifier"); + + b.Property("SharedPlaylistId") + .HasColumnType("uniqueidentifier"); + + b.Property("AddedAtUtc") + .HasColumnType("datetime2"); + + b.HasKey("UserId", "SharedPlaylistId"); + + b.HasIndex("SharedPlaylistId"); + + b.ToTable("FavoritePlaylists"); + }); + modelBuilder.Entity("PlaylistShared.Api.Entities.SharedPlaylist", b => { b.Property("Id") @@ -292,6 +310,11 @@ namespace PlaylistShared.Api.Data.Migrations .HasMaxLength(50) .HasColumnType("nvarchar(50)"); + b.Property("YandexPlaylistUuid") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("nvarchar(50)"); + b.HasKey("Id"); b.HasIndex("CreatorUserId"); @@ -449,6 +472,25 @@ namespace PlaylistShared.Api.Data.Migrations .IsRequired(); }); + modelBuilder.Entity("PlaylistShared.Api.Entities.FavoritePlaylist", b => + { + b.HasOne("PlaylistShared.Api.Entities.SharedPlaylist", "SharedPlaylist") + .WithMany() + .HasForeignKey("SharedPlaylistId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", "User") + .WithMany("FavoritePlaylists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SharedPlaylist"); + + b.Navigation("User"); + }); + modelBuilder.Entity("PlaylistShared.Api.Entities.SharedPlaylist", b => { b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", "Creator") @@ -524,6 +566,8 @@ namespace PlaylistShared.Api.Data.Migrations modelBuilder.Entity("PlaylistShared.Api.Entities.ApplicationUser", b => { + b.Navigation("FavoritePlaylists"); + b.Navigation("OwnedPlaylists"); }); diff --git a/PlaylistShared.Api/Entities/ApplicationUser.cs b/PlaylistShared.Api/Entities/ApplicationUser.cs index 64af85b..59deb71 100644 --- a/PlaylistShared.Api/Entities/ApplicationUser.cs +++ b/PlaylistShared.Api/Entities/ApplicationUser.cs @@ -25,4 +25,7 @@ public class ApplicationUser : IdentityUser /// Плейлисты, созданные пользователем. public ICollection OwnedPlaylists { get; set; } = new List(); + + /// Избранные плейлисты. + public ICollection FavoritePlaylists { get; set; } = new List(); } \ No newline at end of file diff --git a/PlaylistShared.Api/Entities/FavoritePlaylist.cs b/PlaylistShared.Api/Entities/FavoritePlaylist.cs new file mode 100644 index 0000000..a143c10 --- /dev/null +++ b/PlaylistShared.Api/Entities/FavoritePlaylist.cs @@ -0,0 +1,13 @@ +namespace PlaylistShared.Api.Entities; + +/// Избранный расшаренный плейлист пользователя. +public class FavoritePlaylist +{ + public Guid UserId { get; set; } + public Guid SharedPlaylistId { get; set; } + public DateTime AddedAtUtc { get; set; } + + // Навигационные свойства + public ApplicationUser User { get; set; } = null!; + public SharedPlaylist SharedPlaylist { get; set; } = null!; +} \ No newline at end of file diff --git a/PlaylistShared.Api/Entities/SharedPlaylist.cs b/PlaylistShared.Api/Entities/SharedPlaylist.cs index 2684db7..8d2d651 100644 --- a/PlaylistShared.Api/Entities/SharedPlaylist.cs +++ b/PlaylistShared.Api/Entities/SharedPlaylist.cs @@ -7,6 +7,7 @@ public class SharedPlaylist { public Guid Id { get; set; } public Guid CreatorUserId { get; set; } + public string YandexPlaylistUuid { get; set; } = null!; public string YandexPlaylistKind { get; set; } = null!; public string YandexPlaylistOwnerUid { get; set; } = null!; public string Title { get; set; } = null!; diff --git a/PlaylistShared.Api/Program.cs b/PlaylistShared.Api/Program.cs index 820573f..59356e7 100644 --- a/PlaylistShared.Api/Program.cs +++ b/PlaylistShared.Api/Program.cs @@ -96,6 +96,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddDataProtection(); builder.Services.AddHttpClient(); diff --git a/PlaylistShared.Api/Services/FavoritesService.cs b/PlaylistShared.Api/Services/FavoritesService.cs new file mode 100644 index 0000000..7ff1531 --- /dev/null +++ b/PlaylistShared.Api/Services/FavoritesService.cs @@ -0,0 +1,100 @@ +using Microsoft.EntityFrameworkCore; +using PlaylistShared.Api.Data; +using PlaylistShared.Api.Entities; +using PlaylistShared.Shared.Shared; + +namespace PlaylistShared.Api.Services; + +public class FavoritesService +{ + private readonly ApplicationDbContext _db; + private readonly SharedPlaylistService _sharedPlaylistService; + + public FavoritesService(ApplicationDbContext db, SharedPlaylistService sharedPlaylistService) + { + _db = db; + _sharedPlaylistService = sharedPlaylistService; + } + + public async Task IsFavoriteAsync(Guid userId, string shareToken) + { + var playlist = await _sharedPlaylistService.GetEntityByTokenAsync(shareToken); + if (playlist == null) return false; + return await _db.FavoritePlaylists + .AnyAsync(f => f.UserId == userId && f.SharedPlaylistId == playlist.Id); + } + + public async Task AddFavoriteAsync(Guid userId, string shareToken) + { + var playlist = await _sharedPlaylistService.GetEntityByTokenAsync(shareToken); + if (playlist == null) + throw new ArgumentException("Playlist not found"); + + var exists = await _db.FavoritePlaylists + .AnyAsync(f => f.UserId == userId && f.SharedPlaylistId == playlist.Id); + if (exists) return; + + var favorite = new FavoritePlaylist + { + UserId = userId, + SharedPlaylistId = playlist.Id, + AddedAtUtc = DateTime.UtcNow + }; + _db.FavoritePlaylists.Add(favorite); + await _db.SaveChangesAsync(); + } + + public async Task RemoveFavoriteAsync(Guid userId, string shareToken) + { + var playlist = await _sharedPlaylistService.GetEntityByTokenAsync(shareToken); + if (playlist == null) return; + + var favorite = await _db.FavoritePlaylists + .FirstOrDefaultAsync(f => f.UserId == userId && f.SharedPlaylistId == playlist.Id); + if (favorite != null) + { + _db.FavoritePlaylists.Remove(favorite); + await _db.SaveChangesAsync(); + } + } + + public async Task> GetUserFavoritesAsync(Guid userId) + { + var favoritePlaylists = await _db.FavoritePlaylists + .Include(f => f.SharedPlaylist) + .ThenInclude(sp => sp.Creator) + .Where(f => f.UserId == userId) + .OrderByDescending(f => f.AddedAtUtc) + .Select(f => f.SharedPlaylist) + .ToListAsync(); + + // Маппинг в DTO (можно использовать AutoMapper, но для простоты сделаем вручную) + return favoritePlaylists.Select(sp => new SharedPlaylistDto + { + Id = sp.Id, + CreatorUserId = sp.CreatorUserId, + YandexPlaylistUuid = sp.YandexPlaylistUuid, + YandexPlaylistKind = sp.YandexPlaylistKind, + YandexPlaylistOwnerUid = sp.YandexPlaylistOwnerUid, + Title = sp.Title, + Description = sp.Description, + CoverUrl = sp.CoverUrl, + CreatedAt = sp.CreatedAt, + UpdatedAt = sp.UpdatedAt, + IsDeleted = sp.IsDeleted, + ShareToken = sp.ShareToken, + ViewPermission = sp.ViewPermission, + PlayPermission = sp.PlayPermission, + AddPermission = sp.AddPermission, + RemovePermission = sp.RemovePermission, + Creator = sp.Creator != null ? new Shared.Auth.ApplicationUserDto + { + Id = sp.Creator.Id, + UserName = sp.Creator.UserName, + Email = sp.Creator.Email, + YandexId = sp.Creator.YandexId, + DisplayName = sp.Creator.UserName + } : null + }).ToList(); + } +} \ No newline at end of file diff --git a/PlaylistShared.Api/Services/SharedPlaylistService.cs b/PlaylistShared.Api/Services/SharedPlaylistService.cs index a9b157c..022c79f 100644 --- a/PlaylistShared.Api/Services/SharedPlaylistService.cs +++ b/PlaylistShared.Api/Services/SharedPlaylistService.cs @@ -27,6 +27,7 @@ public class SharedPlaylistService { Id = Guid.NewGuid(), CreatorUserId = creatorUserId, + YandexPlaylistUuid = dto.YandexPlaylistUuid, YandexPlaylistKind = dto.YandexPlaylistKind, YandexPlaylistOwnerUid = dto.YandexPlaylistOwnerUid, Title = dto.Title, diff --git a/PlaylistShared.Pwa/Layout/NavMenu.razor b/PlaylistShared.Pwa/Layout/NavMenu.razor index 705072a..57f1938 100644 --- a/PlaylistShared.Pwa/Layout/NavMenu.razor +++ b/PlaylistShared.Pwa/Layout/NavMenu.razor @@ -2,8 +2,9 @@ Главная - Мои плейлисты Профиль + Мои плейлисты + Избранное \ No newline at end of file diff --git a/PlaylistShared.Pwa/Pages/Favorites.razor b/PlaylistShared.Pwa/Pages/Favorites.razor new file mode 100644 index 0000000..7544732 --- /dev/null +++ b/PlaylistShared.Pwa/Pages/Favorites.razor @@ -0,0 +1,112 @@ +@page "/favorites" +@attribute [Authorize] +@using PlaylistShared.Shared.DTO +@inject HttpClient Http +@inject ISnackbar Snackbar +@inject NavigationManager Navigation + + + + + + Избранные плейлисты + Расшаренные плейлисты, которые вы добавили в избранное + + + + + + + @if (_loading) + { + + } + else if (_favorites == null || !_favorites.Any()) + { + + У вас пока нет избранных плейлистов. Перейдите на страницу расшаренного плейлиста и нажмите ★, чтобы добавить. + + } + else + { + + + Название + Владелец + Треков + + + + + + @context.Title + + + @context.Creator?.UserName + @context.TrackCount + + + + + + } + + + + +@code { + private List _favorites = new(); + private bool _loading = true; + + protected override async Task OnInitializedAsync() + { + await LoadFavorites(); + } + + private async Task LoadFavorites() + { + _loading = true; + try + { + var response = await Http.GetFromJsonAsync>>("/api/favorites"); + if (response?.Success == true) + _favorites = response.Data ?? new(); + else + Snackbar.Add(response?.Error?.Message ?? "Ошибка загрузки избранного", Severity.Error); + } + catch (Exception ex) + { + Snackbar.Add($"Ошибка: {ex.Message}", Severity.Error); + } + finally + { + _loading = false; + StateHasChanged(); + } + } + + private async Task RemoveFromFavorites(SharedPlaylistDto playlist) + { + try + { + var response = await Http.DeleteAsync($"/api/favorites/{playlist.ShareToken}"); + if (response.IsSuccessStatusCode) + { + Snackbar.Add($"Плейлист \"{playlist.Title}\" удалён из избранного", Severity.Success); + await LoadFavorites(); + } + else + { + var error = await response.Content.ReadFromJsonAsync>(); + Snackbar.Add(error?.Error?.Message ?? "Ошибка удаления", Severity.Error); + } + } + catch (Exception ex) + { + Snackbar.Add($"Ошибка: {ex.Message}", Severity.Error); + } + } +} \ No newline at end of file diff --git a/PlaylistShared.Pwa/Pages/SharedPlaylistView.razor b/PlaylistShared.Pwa/Pages/SharedPlaylistView.razor index 3c572c3..5b626b6 100644 --- a/PlaylistShared.Pwa/Pages/SharedPlaylistView.razor +++ b/PlaylistShared.Pwa/Pages/SharedPlaylistView.razor @@ -27,10 +27,21 @@
@if (!string.IsNullOrEmpty(_playlist.CoverUrl)) { - + }
- @_playlist.Title +
+ + @_playlist.Title + + + + +
Владелец: @_playlist.Creator?.UserName
@@ -212,6 +223,9 @@ private bool _savingPermissions; private string? _currentUserId; + private bool _isFavorite = false; + private bool _favoriteLoading = false; + private List _tracks = new(); private bool _tracksLoading; @@ -226,6 +240,67 @@ await LoadPlaylist(); } + private async Task CheckFavoriteStatus() + { + if (!_isAuthenticated || _playlist == null) return; + try + { + var response = await Http.GetFromJsonAsync>($"/api/favorites/{Token}/check"); + if (response?.Success == true) + _isFavorite = response.Data; + } + catch { } + } + + private async Task ToggleFavorite() + { + if (!_isAuthenticated) + { + Snackbar.Add("Добавление в избранное доступно только авторизованным пользователям", Severity.Warning); + return; + } + + _favoriteLoading = true; + try + { + if (_isFavorite) + { + var response = await Http.DeleteAsync($"/api/favorites/{Token}"); + if (response.IsSuccessStatusCode) + { + _isFavorite = false; + Snackbar.Add("Плейлист удалён из избранного", Severity.Success); + } + else + { + Snackbar.Add("Ошибка удаления из избранного", Severity.Error); + } + } + else + { + var response = await Http.PostAsync($"/api/favorites/{Token}", null); + if (response.IsSuccessStatusCode) + { + _isFavorite = true; + Snackbar.Add("Плейлист добавлен в избранное", Severity.Success); + } + else + { + Snackbar.Add("Ошибка добавления в избранное", Severity.Error); + } + } + } + catch (Exception ex) + { + Snackbar.Add($"Ошибка: {ex.Message}", Severity.Error); + } + finally + { + _favoriteLoading = false; + StateHasChanged(); + } + } + private async Task ConfigurePermissions() { if (_playlist is null) @@ -275,8 +350,8 @@ _playlist = response.Data; await ConfigurePermissions(); - await LoadTracks(); + await CheckFavoriteStatus(); } else { diff --git a/PlaylistShared.Shared/Playlist/SharePlaylistDto.cs b/PlaylistShared.Shared/Playlist/SharePlaylistDto.cs index 8f620c2..8ed6c45 100644 --- a/PlaylistShared.Shared/Playlist/SharePlaylistDto.cs +++ b/PlaylistShared.Shared/Playlist/SharePlaylistDto.cs @@ -6,6 +6,10 @@ namespace PlaylistShared.Shared.Playlist; /// Запрос на создание нового шеринг-плейлиста. public class SharePlaylistDto { + /// Идентификатор плейлиста в Яндекс.Музыке (guid). + [JsonPropertyName("yandexPlaylistId")] + public string YandexPlaylistUuid { get; set; } = null!; + /// Идентификатор плейлиста в Яндекс.Музыке (kind). [JsonPropertyName("yandexPlaylistKind")] public string YandexPlaylistKind { get; set; } = null!; diff --git a/PlaylistShared.Shared/Shared/SharedPlaylistDto.cs b/PlaylistShared.Shared/Shared/SharedPlaylistDto.cs index 3dc024e..19a6514 100644 --- a/PlaylistShared.Shared/Shared/SharedPlaylistDto.cs +++ b/PlaylistShared.Shared/Shared/SharedPlaylistDto.cs @@ -15,6 +15,10 @@ public class SharedPlaylistDto [JsonPropertyName("creatorUserId")] public Guid CreatorUserId { get; set; } + /// Uuid на яндекс плейлист + [JsonPropertyName("yandexPlaylistUuid")] + public string? YandexPlaylistUuid { get; set; } + /// Идентификатор плейлиста в Яндекс.Музыке (kind). [JsonPropertyName("yandexPlaylistKind")] public string YandexPlaylistKind { get; set; } = null!;