Compare commits

..

6 Commits

38 changed files with 2766 additions and 251 deletions

View File

@@ -13,15 +13,18 @@ public class AudioController : ControllerBase
{
private readonly UserManager<ApplicationUser> _userManager;
private readonly YandexMusicService _yandexService;
private readonly SharedPlaylistService _sharedService;
private readonly JwtService _jwtService;
public AudioController(
UserManager<ApplicationUser> userManager,
YandexMusicService yandexService,
SharedPlaylistService sharedService,
JwtService jwtService)
{
_userManager = userManager;
_yandexService = yandexService;
_sharedService = sharedService;
_jwtService = jwtService;
}
@@ -29,17 +32,18 @@ public class AudioController : ControllerBase
/// Потоковое воспроизведение трека из Яндекс.Музыки.
/// </summary>
/// <param name="trackId">ID трека (например, "21696942").</param>
/// <param name="access_token">gwt пользователя</param>
/// <param name="shared_id">ID расшаренного плейлиста</param>
[HttpGet("track/{trackId}")]
[AllowAnonymous]
public async Task<IActionResult> StreamTrack(string trackId, [FromQuery] string? access_token = null)
public async Task<IActionResult> StreamTrack(string trackId, [FromQuery] string? access_token = null, [FromQuery] string? shared_id = null)
{
var user = await GetUserFromToken(access_token);
if (user == null)
return Unauthorized();
if (user == null) user = await GetUserFromSharedPlaylistId(shared_id);
if (user == null) return Unauthorized();
var streamUrl = await _yandexService.GetTrackFileUrlAsync(user, trackId);
if (string.IsNullOrEmpty(streamUrl))
return NotFound();
if (string.IsNullOrEmpty(streamUrl)) return NotFound();
var httpClient = new HttpClient();
var request = new HttpRequestMessage(HttpMethod.Get, streamUrl);
@@ -56,11 +60,11 @@ public class AudioController : ControllerBase
Response.ContentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg";
if (response.Content.Headers.Contains("Content-Range"))
Response.Headers.Add("Content-Range", response.Content.Headers.ContentRange?.ToString());
Response.Headers.Append("Content-Range", response.Content.Headers.ContentRange?.ToString());
if (response.Headers.Contains("Accept-Ranges"))
Response.Headers.Add("Accept-Ranges", response.Headers.AcceptRanges?.ToString());
Response.Headers.Append("Accept-Ranges", response.Headers.AcceptRanges?.ToString());
if (response.Content.Headers.Contains("Content-Length"))
Response.Headers.Add("Content-Length", response.Content.Headers.ContentLength?.ToString());
Response.Headers.Append("Content-Length", response.Content.Headers.ContentLength?.ToString());
await response.Content.CopyToAsync(Response.Body);
return new EmptyResult();
@@ -68,17 +72,28 @@ public class AudioController : ControllerBase
private async Task<ApplicationUser?> GetUserFromToken(string? token)
{
if (string.IsNullOrEmpty(token))
return null;
if (string.IsNullOrEmpty(token)) return null;
var principal = _jwtService.ValidateToken(token);
if (principal == null)
return null;
if (principal == null) return null;
var userId = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(userId))
return null;
if (string.IsNullOrEmpty(userId)) return null;
return await _userManager.FindByIdAsync(userId);
}
private async Task<ApplicationUser?> 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());
}
}

View File

@@ -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<ApplicationUser> _userManager;
private readonly FavoritesService _favoritesService;
private readonly SharedPlaylistService _sharedPlaylistService;
public FavoritesController(
UserManager<ApplicationUser> userManager,
FavoritesService favoritesService,
SharedPlaylistService sharedPlaylistService)
{
_userManager = userManager;
_favoritesService = favoritesService;
_sharedPlaylistService = sharedPlaylistService;
}
/// <summary>Получить список избранных плейлистов текущего пользователя.</summary>
[HttpGet]
public async Task<ActionResult<ApiResponse<List<SharedPlaylistDto>>>> 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<List<SharedPlaylistDto>>.Ok(favorites));
}
/// <summary>Проверить, добавлен ли плейлист в избранное.</summary>
[HttpGet("{shareToken}/check")]
public async Task<ActionResult<ApiResponse<bool>>> 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<bool>.Ok(isFavorite));
}
/// <summary>Добавить плейлист в избранное.</summary>
[HttpPost("{shareToken}")]
public async Task<ActionResult<ApiResponse<object>>> 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<object>.Fail(new ErrorResponse { StatusCode = 404, Message = "Плейлист не найден" }));
await _favoritesService.AddFavoriteAsync(userId, shareToken);
return Ok(ApiResponse<object>.Ok(new { message = "Плейлист добавлен в избранное" }));
}
/// <summary>Удалить плейлист из избранного.</summary>
[HttpDelete("{shareToken}")]
public async Task<ActionResult<ApiResponse<object>>> 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<object>.Ok(new { message = "Плейлист удалён из избранного" }));
}
}

View File

@@ -82,13 +82,15 @@ public class PlaylistsController : ControllerBase
var dto = new SharePlaylistDto
{
YandexPlaylistUuid = playlist.PlaylistUuid,
YandexPlaylistKind = request.Kind,
YandexPlaylistOwnerUid = request.OwnerUid,
Title = playlist.Title,
Description = playlist.Description,
ViewPermission = ViewPermission.Everyone,
PlayPermission = ViewPermission.Everyone,
AddPermission = EditPermission.AuthorizedOnly,
RemovePermission = EditPermission.AddedByUserOnly
RemovePermission = EditPermission.AddedByUserOnly,
};
var result = await _sharedService.CreateAsync(userId, dto);

View File

@@ -1,23 +1,33 @@
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using PlaylistShared.Api.Entities;
namespace PlaylistShared.Api.Data;
public class ApplicationDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid>, Guid>
public class ApplicationDbContext : IdentityDbContext<ApplicationUser, IdentityRole<Guid>, Guid>, IDataProtectionKeyContext
{
public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { }
public DbSet<FavoritePlaylist> FavoritePlaylists => Set<FavoritePlaylist>();
public DbSet<SharedPlaylist> SharedPlaylists => Set<SharedPlaylist>();
public DbSet<TrackAdditionLog> TrackAdditionLogs => Set<TrackAdditionLog>();
public DbSet<UserSession> UserSessions => Set<UserSession>();
public DbSet<TrackRemovalLog> TrackRemovalLogs => Set<TrackRemovalLog>();
public DbSet<UserSession> UserSessions => Set<UserSession>();
public DbSet<DataProtectionKey> DataProtectionKeys { get; set; }
protected override void OnModelCreating(ModelBuilder builder)
{
base.OnModelCreating(builder);
builder.Entity<DataProtectionKey>(entity =>
{
entity.HasKey(e => e.Id);
entity.Property(e => e.FriendlyName).IsRequired();
});
builder.Entity<SharedPlaylist>(entity =>
{
entity.HasKey(e => e.Id);
@@ -26,6 +36,7 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser, IdentityR
.WithMany(u => 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 +88,19 @@ public class ApplicationDbContext : IdentityDbContext<ApplicationUser, IdentityR
.HasForeignKey(e => e.SessionId)
.OnDelete(DeleteBehavior.Restrict);
});
builder.Entity<FavoritePlaylist>(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();
});
}
}

View File

@@ -0,0 +1,547 @@
// <auto-generated />
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("20260414094124_AddSharedPermissions")]
partial class AddSharedPermissions
{
/// <inheritdoc />
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<System.Guid>", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("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<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("RoleId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderKey")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderDisplayName")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("RoleId")
.HasColumnType("uniqueidentifier");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<string>("Value")
.HasColumnType("nvarchar(max)");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("PlaylistShared.Api.Entities.ApplicationUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("bit");
b.Property<bool>("LockoutEnabled")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("datetimeoffset");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("RefreshToken")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("RefreshTokenExpiryUtc")
.HasColumnType("datetime2");
b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("YandexAccessToken")
.HasColumnType("nvarchar(max)");
b.Property<string>("YandexId")
.HasColumnType("nvarchar(max)");
b.Property<string>("YandexRefreshToken")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("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.SharedPlaylist", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("AddPermission")
.HasColumnType("int");
b.Property<string>("CoverUrl")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid>("CreatorUserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<int>("PlayPermission")
.HasColumnType("int");
b.Property<int>("RemovePermission")
.HasColumnType("int");
b.Property<string>("ShareToken")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<int>("ViewPermission")
.HasColumnType("int");
b.Property<string>("YandexPlaylistKind")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("YandexPlaylistOwnerUid")
.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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("AddedAtUtc")
.HasColumnType("datetime2");
b.Property<Guid?>("AddedByUserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("SessionId")
.IsRequired()
.HasColumnType("nvarchar(449)");
b.Property<Guid>("SharedPlaylistId")
.HasColumnType("uniqueidentifier");
b.Property<string>("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<string>("SessionId")
.HasMaxLength(449)
.HasColumnType("nvarchar(449)");
b.Property<Guid?>("AssociatedUserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("ClientIpAddress")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("FirstSeenUtc")
.HasColumnType("datetime2");
b.Property<DateTime>("LastSeenUtc")
.HasColumnType("datetime2");
b.Property<string>("UserAgent")
.HasColumnType("nvarchar(max)");
b.HasKey("SessionId");
b.HasIndex("AssociatedUserId");
b.ToTable("UserSessions");
});
modelBuilder.Entity("TrackRemovalLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("RemovedAtUtc")
.HasColumnType("datetime2");
b.Property<Guid?>("RemovedByUserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("SessionId")
.IsRequired()
.HasColumnType("nvarchar(449)");
b.Property<Guid>("SharedPlaylistId")
.HasColumnType("uniqueidentifier");
b.Property<string>("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<System.Guid>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", 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<System.Guid>", b =>
{
b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
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("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
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PlaylistShared.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddSharedPermissions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "PlayPermission",
table: "SharedPlaylists",
type: "int",
nullable: false,
defaultValue: 0);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "PlayPermission",
table: "SharedPlaylists");
}
}
}

View File

@@ -0,0 +1,591 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<System.Guid>", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("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<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("RoleId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderKey")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderDisplayName")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("RoleId")
.HasColumnType("uniqueidentifier");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<string>("Value")
.HasColumnType("nvarchar(max)");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("PlaylistShared.Api.Entities.ApplicationUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("bit");
b.Property<bool>("LockoutEnabled")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("datetimeoffset");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("RefreshToken")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("RefreshTokenExpiryUtc")
.HasColumnType("datetime2");
b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("YandexAccessToken")
.HasColumnType("nvarchar(max)");
b.Property<string>("YandexId")
.HasColumnType("nvarchar(max)");
b.Property<string>("YandexRefreshToken")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("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<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("SharedPlaylistId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("AddedAtUtc")
.HasColumnType("datetime2");
b.HasKey("UserId", "SharedPlaylistId");
b.HasIndex("SharedPlaylistId");
b.ToTable("FavoritePlaylists");
});
modelBuilder.Entity("PlaylistShared.Api.Entities.SharedPlaylist", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("AddPermission")
.HasColumnType("int");
b.Property<string>("CoverUrl")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid>("CreatorUserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<int>("PlayPermission")
.HasColumnType("int");
b.Property<int>("RemovePermission")
.HasColumnType("int");
b.Property<string>("ShareToken")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<int>("ViewPermission")
.HasColumnType("int");
b.Property<string>("YandexPlaylistKind")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("YandexPlaylistOwnerUid")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("AddedAtUtc")
.HasColumnType("datetime2");
b.Property<Guid?>("AddedByUserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("SessionId")
.IsRequired()
.HasColumnType("nvarchar(449)");
b.Property<Guid>("SharedPlaylistId")
.HasColumnType("uniqueidentifier");
b.Property<string>("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<string>("SessionId")
.HasMaxLength(449)
.HasColumnType("nvarchar(449)");
b.Property<Guid?>("AssociatedUserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("ClientIpAddress")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("FirstSeenUtc")
.HasColumnType("datetime2");
b.Property<DateTime>("LastSeenUtc")
.HasColumnType("datetime2");
b.Property<string>("UserAgent")
.HasColumnType("nvarchar(max)");
b.HasKey("SessionId");
b.HasIndex("AssociatedUserId");
b.ToTable("UserSessions");
});
modelBuilder.Entity("TrackRemovalLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("RemovedAtUtc")
.HasColumnType("datetime2");
b.Property<Guid?>("RemovedByUserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("SessionId")
.IsRequired()
.HasColumnType("nvarchar(449)");
b.Property<Guid>("SharedPlaylistId")
.HasColumnType("uniqueidentifier");
b.Property<string>("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<System.Guid>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", 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<System.Guid>", 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
}
}
}

View File

@@ -0,0 +1,64 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PlaylistShared.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddUserFavorites : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "YandexPlaylistUuid",
table: "SharedPlaylists",
type: "nvarchar(50)",
maxLength: 50,
nullable: false,
defaultValue: "");
migrationBuilder.CreateTable(
name: "FavoritePlaylists",
columns: table => new
{
UserId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
SharedPlaylistId = table.Column<Guid>(type: "uniqueidentifier", nullable: false),
AddedAtUtc = table.Column<DateTime>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "FavoritePlaylists");
migrationBuilder.DropColumn(
name: "YandexPlaylistUuid",
table: "SharedPlaylists");
}
}
}

View File

@@ -0,0 +1,611 @@
// <auto-generated />
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("20260414121754_AddDataProtectionKeys")]
partial class AddDataProtectionKeys
{
/// <inheritdoc />
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.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("FriendlyName")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Xml")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("DataProtectionKeys");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("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<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("RoleId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("nvarchar(max)");
b.Property<string>("ClaimValue")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderKey")
.HasColumnType("nvarchar(450)");
b.Property<string>("ProviderDisplayName")
.HasColumnType("nvarchar(max)");
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("RoleId")
.HasColumnType("uniqueidentifier");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("LoginProvider")
.HasColumnType("nvarchar(450)");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
b.Property<string>("Value")
.HasColumnType("nvarchar(max)");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("PlaylistShared.Api.Entities.ApplicationUser", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("AccessFailedCount")
.HasColumnType("int");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("nvarchar(max)");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("bit");
b.Property<bool>("LockoutEnabled")
.HasColumnType("bit");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("datetimeoffset");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("PasswordHash")
.HasColumnType("nvarchar(max)");
b.Property<string>("PhoneNumber")
.HasColumnType("nvarchar(max)");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("bit");
b.Property<string>("RefreshToken")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("RefreshTokenExpiryUtc")
.HasColumnType("datetime2");
b.Property<string>("SecurityStamp")
.HasColumnType("nvarchar(max)");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("bit");
b.Property<string>("UserName")
.HasMaxLength(256)
.HasColumnType("nvarchar(256)");
b.Property<string>("YandexAccessToken")
.HasColumnType("nvarchar(max)");
b.Property<string>("YandexId")
.HasColumnType("nvarchar(max)");
b.Property<string>("YandexRefreshToken")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("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<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("SharedPlaylistId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("AddedAtUtc")
.HasColumnType("datetime2");
b.HasKey("UserId", "SharedPlaylistId");
b.HasIndex("SharedPlaylistId");
b.ToTable("FavoritePlaylists");
});
modelBuilder.Entity("PlaylistShared.Api.Entities.SharedPlaylist", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<int>("AddPermission")
.HasColumnType("int");
b.Property<string>("CoverUrl")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("datetime2");
b.Property<Guid>("CreatorUserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<int>("PlayPermission")
.HasColumnType("int");
b.Property<int>("RemovePermission")
.HasColumnType("int");
b.Property<string>("ShareToken")
.IsRequired()
.HasColumnType("nvarchar(450)");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("nvarchar(255)");
b.Property<DateTime>("UpdatedAt")
.HasColumnType("datetime2");
b.Property<int>("ViewPermission")
.HasColumnType("int");
b.Property<string>("YandexPlaylistKind")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("YandexPlaylistOwnerUid")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("AddedAtUtc")
.HasColumnType("datetime2");
b.Property<Guid?>("AddedByUserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("SessionId")
.IsRequired()
.HasColumnType("nvarchar(449)");
b.Property<Guid>("SharedPlaylistId")
.HasColumnType("uniqueidentifier");
b.Property<string>("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<string>("SessionId")
.HasMaxLength(449)
.HasColumnType("nvarchar(449)");
b.Property<Guid?>("AssociatedUserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("ClientIpAddress")
.HasColumnType("nvarchar(max)");
b.Property<DateTime>("FirstSeenUtc")
.HasColumnType("datetime2");
b.Property<DateTime>("LastSeenUtc")
.HasColumnType("datetime2");
b.Property<string>("UserAgent")
.HasColumnType("nvarchar(max)");
b.HasKey("SessionId");
b.HasIndex("AssociatedUserId");
b.ToTable("UserSessions");
});
modelBuilder.Entity("TrackRemovalLog", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("RemovedAtUtc")
.HasColumnType("datetime2");
b.Property<Guid?>("RemovedByUserId")
.HasColumnType("uniqueidentifier");
b.Property<string>("SessionId")
.IsRequired()
.HasColumnType("nvarchar(449)");
b.Property<Guid>("SharedPlaylistId")
.HasColumnType("uniqueidentifier");
b.Property<string>("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<System.Guid>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("PlaylistShared.Api.Entities.ApplicationUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", 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<System.Guid>", 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
}
}
}

View File

@@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PlaylistShared.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddDataProtectionKeys : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "DataProtectionKeys",
columns: table => new
{
Id = table.Column<int>(type: "int", nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
FriendlyName = table.Column<string>(type: "nvarchar(max)", nullable: false),
Xml = table.Column<string>(type: "nvarchar(max)", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_DataProtectionKeys", x => x.Id);
});
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "DataProtectionKeys");
}
}
}

View File

@@ -22,6 +22,26 @@ namespace PlaylistShared.Api.Data.Migrations
SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder);
modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("int");
SqlServerPropertyBuilderExtensions.UseIdentityColumn(b.Property<int>("Id"));
b.Property<string>("FriendlyName")
.IsRequired()
.HasColumnType("nvarchar(max)");
b.Property<string>("Xml")
.HasColumnType("nvarchar(max)");
b.HasKey("Id");
b.ToTable("DataProtectionKeys");
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole<System.Guid>", b =>
{
b.Property<Guid>("Id")
@@ -237,6 +257,24 @@ namespace PlaylistShared.Api.Data.Migrations
b.ToTable("AspNetUsers", (string)null);
});
modelBuilder.Entity("PlaylistShared.Api.Entities.FavoritePlaylist", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uniqueidentifier");
b.Property<Guid>("SharedPlaylistId")
.HasColumnType("uniqueidentifier");
b.Property<DateTime>("AddedAtUtc")
.HasColumnType("datetime2");
b.HasKey("UserId", "SharedPlaylistId");
b.HasIndex("SharedPlaylistId");
b.ToTable("FavoritePlaylists");
});
modelBuilder.Entity("PlaylistShared.Api.Entities.SharedPlaylist", b =>
{
b.Property<Guid>("Id")
@@ -261,6 +299,9 @@ namespace PlaylistShared.Api.Data.Migrations
b.Property<bool>("IsDeleted")
.HasColumnType("bit");
b.Property<int>("PlayPermission")
.HasColumnType("int");
b.Property<int>("RemovePermission")
.HasColumnType("int");
@@ -289,6 +330,11 @@ namespace PlaylistShared.Api.Data.Migrations
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.Property<string>("YandexPlaylistUuid")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("nvarchar(50)");
b.HasKey("Id");
b.HasIndex("CreatorUserId");
@@ -446,6 +492,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")
@@ -521,6 +586,8 @@ namespace PlaylistShared.Api.Data.Migrations
modelBuilder.Entity("PlaylistShared.Api.Entities.ApplicationUser", b =>
{
b.Navigation("FavoritePlaylists");
b.Navigation("OwnedPlaylists");
});

View File

@@ -25,4 +25,7 @@ public class ApplicationUser : IdentityUser<Guid>
/// <summary>Плейлисты, созданные пользователем.</summary>
public ICollection<SharedPlaylist> OwnedPlaylists { get; set; } = new List<SharedPlaylist>();
/// <summary>Избранные плейлисты.</summary>
public ICollection<FavoritePlaylist> FavoritePlaylists { get; set; } = new List<FavoritePlaylist>();
}

View File

@@ -0,0 +1,13 @@
namespace PlaylistShared.Api.Entities;
/// <summary>Избранный расшаренный плейлист пользователя.</summary>
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!;
}

View File

@@ -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!;
@@ -17,6 +18,7 @@ public class SharedPlaylist
public bool IsDeleted { get; set; }
public string ShareToken { get; set; } = null!;
public ViewPermission ViewPermission { get; set; }
public ViewPermission PlayPermission { get; set; }
public EditPermission AddPermission { get; set; }
public EditPermission RemovePermission { get; set; }

View File

@@ -1,16 +0,0 @@
using AutoMapper;
using PlaylistShared.Api.Entities;
using PlaylistShared.Shared.Auth;
using PlaylistShared.Shared.Shared;
namespace PlaylistShared.Api.Mapping;
public class AppMappingProfile : Profile
{
public AppMappingProfile()
{
CreateMap<SharedPlaylist, SharedPlaylistDto>()
.ForMember(dest => dest.Creator, opt => opt.MapFrom(src => src.Creator));
CreateMap<ApplicationUser, ApplicationUserDto>();
}
}

View File

@@ -10,9 +10,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AutoMapper" Version="16.1.1" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.5" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection.EntityFrameworkCore" Version="10.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.5" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.5" />

View File

@@ -1,11 +1,11 @@
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.IdentityModel.Tokens;
using PlaylistShared.Api.Data;
using PlaylistShared.Api.Entities;
using PlaylistShared.Api.Mapping;
using PlaylistShared.Api.Services;
using System.IdentityModel.Tokens.Jwt;
using System.Text;
@@ -23,7 +23,8 @@ public class Program
// DbContext
builder.Services.AddDbContext<ApplicationDbContext>(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));// Identity
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
// Identity
builder.Services.AddIdentity<ApplicationUser, IdentityRole<Guid>>(options =>
{
options.User.RequireUniqueEmail = true;
@@ -88,30 +89,24 @@ public class Program
builder.Services.AddHttpContextAccessor();
builder.Services.AddAuthorization();
builder.Services.AddAutoMapper(t => t.AddProfile<AppMappingProfile>());
builder.Services.AddScoped<JwtService>();
builder.Services.AddScoped<UserSessionService>();
builder.Services.AddScoped<YandexMusicService>();
builder.Services.AddScoped<SharedPlaylistService>();
builder.Services.AddScoped<TrackAdditionLogService>();
builder.Services.AddScoped<TrackRemovalLogService>();
builder.Services.AddDataProtection();
builder.Services.AddScoped<FavoritesService>();
builder.Services.AddDataProtection()
.PersistKeysToDbContext<ApplicationDbContext>()
.SetApplicationName("PlaylistShared.Api");
builder.Services.AddHttpClient();
builder.Services.AddCors(options =>
{
options.AddPolicy("Development", policy =>
{
policy.WithOrigins("http://localhost:5053", "https://localhost:7225", "http://localhost:5181", "https://api.playlistshare.frigat.duckdns.org", "https://playlistshare.frigat.duckdns.org")
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
});
options.AddPolicy("Production", policy =>
{
policy.WithOrigins("https://api.playlistshare.frigat.duckdns.org", "https://playlistshare.frigat.duckdns.org")
policy.WithOrigins(builder.Configuration.GetSection("Cors:Origins").Get<string[]>())
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
@@ -131,17 +126,14 @@ public class Program
app.UseSwagger();
app.UseSwaggerUI();
if (app.Environment.IsDevelopment())
{
app.UseCors("Development");
}
else
{
app.UseHttpsRedirection();
app.UseCors("Production");
if (!app.Environment.IsDevelopment())
{
app.UseHttpsRedirection();
}
app.UseSession();
app.UseAuthentication();
app.UseAuthorization();

View File

@@ -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<bool> 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<List<SharedPlaylistDto>> 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();
}
}

View File

@@ -1,7 +1,7 @@
using AutoMapper;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using PlaylistShared.Api.Data;
using PlaylistShared.Api.Entities;
using PlaylistShared.Shared.Auth;
using PlaylistShared.Shared.Enums;
using PlaylistShared.Shared.Playlist;
using PlaylistShared.Shared.Shared;
@@ -11,13 +11,11 @@ namespace PlaylistShared.Api.Services;
public class SharedPlaylistService
{
private readonly ApplicationDbContext _db;
private readonly IMapper _mapper;
private readonly TrackAdditionLogService _trackLogService;
public SharedPlaylistService(ApplicationDbContext db, IMapper mapper, TrackAdditionLogService trackLogService)
public SharedPlaylistService(ApplicationDbContext db, TrackAdditionLogService trackLogService)
{
_db = db;
_mapper = mapper;
_trackLogService = trackLogService;
}
@@ -27,6 +25,7 @@ public class SharedPlaylistService
{
Id = Guid.NewGuid(),
CreatorUserId = creatorUserId,
YandexPlaylistUuid = dto.YandexPlaylistUuid,
YandexPlaylistKind = dto.YandexPlaylistKind,
YandexPlaylistOwnerUid = dto.YandexPlaylistOwnerUid,
Title = dto.Title,
@@ -34,13 +33,14 @@ public class SharedPlaylistService
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ShareToken = GenerateToken(),
PlayPermission = dto.PlayPermission,
ViewPermission = dto.ViewPermission,
AddPermission = dto.AddPermission,
RemovePermission = dto.RemovePermission
};
_db.SharedPlaylists.Add(entity);
await _db.SaveChangesAsync();
return _mapper.Map<SharedPlaylistDto>(entity);
return MapToDto(entity);
}
public async Task<SharedPlaylistDto?> GetByTokenAsync(string token)
@@ -48,7 +48,7 @@ public class SharedPlaylistService
var entity = await _db.SharedPlaylists
.Include(sp => sp.Creator)
.FirstOrDefaultAsync(sp => sp.ShareToken == token && !sp.IsDeleted);
return entity == null ? null : _mapper.Map<SharedPlaylistDto>(entity);
return entity == null ? null : MapToDto(entity);
}
public async Task<SharedPlaylist?> GetEntityByTokenAsync(string token)
@@ -63,11 +63,12 @@ public class SharedPlaylistService
var entity = await _db.SharedPlaylists.FindAsync(playlistId);
if (entity == null) return null;
entity.ViewPermission = dto.ViewPermission;
entity.PlayPermission = dto.PlayPermission;
entity.AddPermission = dto.AddPermission;
entity.RemovePermission = dto.RemovePermission;
entity.UpdatedAt = DateTime.UtcNow;
await _db.SaveChangesAsync();
return _mapper.Map<SharedPlaylistDto>(entity);
return MapToDto(entity);
}
public async Task<bool> DeleteAsync(Guid playlistId)
@@ -80,6 +81,18 @@ public class SharedPlaylistService
return true;
}
public async Task<bool> CanPlayAsync(SharedPlaylist playlist, Guid? currentUserId)
{
if (currentUserId == playlist.CreatorUserId) return true;
return playlist.PlayPermission == ViewPermission.Everyone ||
(playlist.PlayPermission == ViewPermission.AuthorizedOnly && currentUserId.HasValue);
}
public async Task<bool> CanPlayEveryoneAsync(SharedPlaylist playlist)
{
return playlist.PlayPermission == ViewPermission.Everyone;
}
public async Task<bool> CanViewAsync(SharedPlaylist playlist, Guid? currentUserId)
{
if (currentUserId == playlist.CreatorUserId) return true;
@@ -129,4 +142,36 @@ public class SharedPlaylistService
.Where(sp => sp.CreatorUserId == userId && !sp.IsDeleted)
.ToListAsync();
}
// Ручное маппинг сущности в DTO
private SharedPlaylistDto MapToDto(SharedPlaylist entity)
{
return new SharedPlaylistDto
{
Id = entity.Id,
CreatorUserId = entity.CreatorUserId,
YandexPlaylistUuid = entity.YandexPlaylistUuid,
YandexPlaylistKind = entity.YandexPlaylistKind,
YandexPlaylistOwnerUid = entity.YandexPlaylistOwnerUid,
Title = entity.Title,
Description = entity.Description,
CoverUrl = entity.CoverUrl,
CreatedAt = entity.CreatedAt,
UpdatedAt = entity.UpdatedAt,
IsDeleted = entity.IsDeleted,
ShareToken = entity.ShareToken,
ViewPermission = entity.ViewPermission,
PlayPermission = entity.PlayPermission,
AddPermission = entity.AddPermission,
RemovePermission = entity.RemovePermission,
Creator = entity.Creator != null ? new ApplicationUserDto
{
Id = entity.Creator.Id,
UserName = entity.Creator.UserName ?? string.Empty,
Email = entity.Creator.Email,
YandexId = entity.Creator.YandexId,
DisplayName = entity.Creator.UserName
} : null
};
}
}

View File

@@ -7,6 +7,12 @@
"Issuer": "PlaylistShared.Api",
"Audience": "PlaylistShared.Client"
},
"Cors": {
"Origins": [
"https://api.playlistshare.frigat.duckdns.org",
"https://playlistshare.frigat.duckdns.org"
]
},
"Yandex": {
"ClientId": "0916685f8a3641ca8fc382dbccf77236",
"ClientSecret": "f7398893cd814f8b84b85aeb2a0a6698"

View File

@@ -59,12 +59,16 @@
[Inject] protected IJSRuntime JS { get; set; } = null!;
[Inject] private TokenStorage TokenStorage { get; set; } = null!;
[Inject] private PlayerStorage PlayerStorage { get; set; } = null!;
[Inject] private AuthenticationStateProvider AuthProvider { get; set; } = null!;
[Inject] private ISnackbar Snackbar { get; set; } = null!;
/// <summary>Требовать ли авторизацию для воспроизведения (по умолчанию true).</summary>
[Parameter] public bool RequireAuth { get; set; } = true;
/// <summary>ID расшаренного плейлиста.</summary>
[Parameter] public string SharedPlaylistId { get; set; } = string.Empty;
/// <summary>Событие при завершении трека.</summary>
[Parameter] public EventCallback OnTrackEnded { get; set; }
@@ -73,6 +77,8 @@
if (firstRender)
{
await EnsureAudioModuleAsync();
await ChangeVolume(await PlayerStorage.GetVolumeAsync());
StateHasChanged();
}
}
@@ -139,16 +145,16 @@
var tokens = await TokenStorage.GetTokensAsync();
var accessToken = tokens.token;
if (string.IsNullOrEmpty(accessToken))
if (string.IsNullOrWhiteSpace(accessToken) && string.IsNullOrWhiteSpace(SharedPlaylistId))
{
Snackbar.Add("Токен авторизации не найден. Пожалуйста, войдите заново.", Severity.Error);
return;
}
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 _audioElement!.InvokeVoidAsync("loadAndPlay", streamUrl, accessToken);
await _audioElement!.InvokeVoidAsync("loadAndPlay", streamUrl, accessToken, SharedPlaylistId);
_isPlaying = true;
StartProgressTimer();
StateHasChanged();
@@ -223,6 +229,8 @@
var volume = value / 100;
await _audioElement.InvokeVoidAsync("setVolume", volume);
_isMuted = false;
_currentVolume = value;
await PlayerStorage.SetVolumeAsync(value);
StateHasChanged();
}
catch (Exception ex)

View File

@@ -0,0 +1,75 @@
@* Компонент с инструкцией по получению токена Яндекс.Музыки *@
<MudContainer Class="pa-4">
<MudText Typo="Typo.body2" GutterBottom>
Токен нужен для доступа к вашим плейлистам. Получите его один раз:
</MudText>
<!-- Вертикальный список шагов -->
<div class="instruction-steps">
<div class="step-item">
<div class="step-number">1</div>
<div class="step-content">
Перейдите по <MudLink Href="https://oauth.yandex.ru/authorize?response_type=token&client_id=23cabbbdc6cd418abb4b39c32c41195d" Target="_blank">ссылке</MudLink>
</div>
</div>
<div class="step-item">
<div class="step-number">2</div>
<div class="step-content">
Авторизуйтесь в Яндексе (если ещё не вошли)
</div>
</div>
<div class="step-item">
<div class="step-number">3</div>
<div class="step-content">
Нажмите «Разрешить»
</div>
</div>
<div class="step-item">
<div class="step-number">4</div>
<div class="step-content">
Скопируйте <strong>access_token</strong> из адресной строки после перенаправления
</div>
</div>
</div>
<MudAlert Severity="Severity.Info" Class="mt-4">
Пример: <code>https://music.yandex.ru/#access_token=ВАШ_ТОКЕН&...</code>
</MudAlert>
<MudAlert Severity="Severity.Warning" Class="mt-2">
Токен даёт доступ к вашим плейлистам. Никому его не сообщайте.
</MudAlert>
<MudAlert Severity="Severity.Success" Class="mt-2" Icon="@Icons.Material.Filled.Security">
Ваш токен сохраняется в зашифрованном виде и никому не передаётся.
</MudAlert>
</MudContainer>
<style>
.instruction-steps {
margin: 16px 0;
}
.step-item {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.step-number {
width: 28px;
height: 28px;
background-color: var(--mud-palette-primary);
color: white;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: bold;
flex-shrink: 0;
}
.step-content {
flex: 1;
font-size: 0.9rem;
}
</style>

View File

@@ -2,18 +2,23 @@
<AuthorizeView>
<Authorized>
<MudText Typo="Typo.body2" Class="d-inline mr-2">Здравствуйте, @context.User.Identity?.Name!</MudText>
<MudButton Variant="Variant.Text" Color="Color.Inherit" OnClick="BeginLogOut">Выйти</MudButton>
<MudMenu Label="@context.User.Identity?.Name" Variant="Variant.Text" Color="Color.Inherit" Class="user-menu">
<MudMenuItem OnClick="GoToProfile">Профиль</MudMenuItem>
<MudMenuItem OnClick="BeginLogOut">Выйти</MudMenuItem>
</MudMenu>
</Authorized>
<NotAuthorized>
<MudLink Href="/login" Color="Color.Inherit" Underline="Underline.Hover" Typo="Typo.body2">Вход</MudLink>
<MudText Class="d-inline mx-1">|</MudText>
<MudLink Href="/register" Color="Color.Inherit" Underline="Underline.Hover" Typo="Typo.body2">Регистрация</MudLink>
</NotAuthorized>
</AuthorizeView>
@code {
public void BeginLogOut()
private void GoToProfile()
{
Navigation.NavigateTo("/profile");
}
private void BeginLogOut()
{
Navigation.NavigateTo("/logout");
}

View File

@@ -2,8 +2,9 @@
<MudNavLink Href="/" Match="NavLinkMatch.All" Icon="@Icons.Material.Filled.Home">Главная</MudNavLink>
<AuthorizeView>
<Authorized>
<MudNavLink Href="/my-playlists" Icon="@Icons.Material.Filled.QueueMusic">Мои плейлисты</MudNavLink>
<MudNavLink Href="/profile" Icon="@Icons.Material.Filled.Person">Профиль</MudNavLink>
<MudNavLink Href="/my-playlists" Icon="@Icons.Material.Filled.QueueMusic">Мои плейлисты</MudNavLink>
<MudNavLink Href="/favorites" Icon="@Icons.Material.Filled.Star">Избранное</MudNavLink>
</Authorized>
</AuthorizeView>
</MudNavMenu>

View File

@@ -0,0 +1,110 @@
@page "/favorites"
@using PlaylistShared.Shared.Shared
@attribute [Authorize]
@inject HttpClient Http
@inject ISnackbar Snackbar
@inject NavigationManager Navigation
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudCard>
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h5">Избранные плейлисты</MudText>
<MudText Typo="Typo.body2">Расшаренные плейлисты, которые вы добавили в избранное</MudText>
</CardHeaderContent>
<CardHeaderActions>
<MudIconButton Icon="@Icons.Material.Filled.Refresh" OnClick="LoadFavorites" />
</CardHeaderActions>
</MudCardHeader>
<MudCardContent>
@if (_loading)
{
<MudProgressCircular Indeterminate />
}
else if (_favorites == null || !_favorites.Any())
{
<MudAlert Severity="Severity.Info">
У вас пока нет избранных плейлистов. Перейдите на страницу расшаренного плейлиста и нажмите ★, чтобы добавить.
</MudAlert>
}
else
{
<MudTable Items="@_favorites">
<HeaderContent>
<MudTh>Название</MudTh>
<MudTh>Владелец</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd DataLabel="Название">
<MudLink Href="@($"/shared/{context.ShareToken}")" Underline="Underline.Hover">
@context.Title
</MudLink>
</MudTd>
<MudTd DataLabel="Владелец">@context.Creator?.UserName</MudTd>
<MudTd DataLabel="">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
OnClick="() => RemoveFromFavorites(context)"
Title="Удалить из избранного" />
</MudTd>
</RowTemplate>
</MudTable>
}
</MudCardContent>
</MudCard>
</MudContainer>
@code {
private List<SharedPlaylistDto> _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<ApiResponse<List<SharedPlaylistDto>>>("/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<ApiResponse<object>>();
Snackbar.Add(error?.Error?.Message ?? "Ошибка удаления", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"Ошибка: {ex.Message}", Severity.Error);
}
}
}

View File

@@ -1,91 +1,74 @@
@page "/"
@using PlaylistShared.Pwa.Services
@inject NavigationManager Navigation
@inject AuthenticationStateProvider AuthProvider
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-8">
<MudCard>
<MudCardHeader>
<CardHeaderContent>
<MudText Typo="Typo.h4" GutterBottom>🎵 Playlist share</MudText>
<MudText Typo="Typo.body1">Делитесь плейлистами Яндекс.Музыки с друзьями и управляйте треками вместе!</MudText>
<MudText Typo="Typo.body1">
Делитесь плейлистами Яндекс.Музыки с друзьями и управляйте треками вместе!
</MudText>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudText Typo="Typo.h6" GutterBottom>🚀 Как начать</MudText>
<MudText Typo="Typo.body2" Class="mb-4">
Playlist share — это веб-приложение, которое позволяет создавать совместные плейлисты,
предоставлять доступ к ним по ссылке и слушать музыку прямо в браузере.
Для работы требуется аккаунт Яндекс.Музыки (подписка не обязательна).
</MudText>
<!-- Блок с требованием регистрации для расшаривания -->
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="my-4">
<div style="display: flex; align-items: center; gap: 12px; flex-wrap: wrap;">
<MudIcon Icon="@Icons.Material.Filled.Share" Size="Size.Medium" />
<div style="flex: 1;">
<MudText Typo="Typo.body1" FontWeight="FontWeight.Bold">
Чтобы расшаривать плейлисты, необходимо зарегистрироваться
</MudText>
<MudText Typo="Typo.body2">
Создайте аккаунт или войдите в существующий — это займёт всего минуту.
</MudText>
</div>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Href="/register">
Зарегистрироваться
</MudButton>
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Href="/login">
Войти
</MudButton>
</div>
</MudAlert>
<!-- Краткие преимущества -->
<MudGrid>
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="0" Style="background-color: rgba(0,0,0,0.04); border-radius: 8px;">
<MudText Typo="Typo.h6" GutterBottom>1⃣ Регистрация и вход</MudText>
<MudText Typo="Typo.body2" Class="mb-2">
• Нажмите <MudLink Href="/register" Style="font-weight:bold;">«Регистрация»</MudLink> и создайте аккаунт.<br />
• Или <MudLink Href="/login" Style="font-weight:bold;">войдите</MudLink> в систему, если уже зарегистрированы.
</MudText>
<MudItem xs="12" sm="4">
<MudPaper Class="pa-3 text-center" Elevation="0" Style="background-color: rgba(0,0,0,0.04); border-radius: 8px;">
<MudIcon Icon="@Icons.Material.Filled.Link" Size="Size.Medium" Color="Color.Primary" />
<MudText Typo="Typo.body2" Class="mt-2">Создавайте ссылки-приглашения</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="0" Style="background-color: rgba(0,0,0,0.04); border-radius: 8px;">
<MudText Typo="Typo.h6" GutterBottom>2⃣ Получение токена Яндекс.Музыки</MudText>
<MudText Typo="Typo.body2" Class="mb-2">
Токен нужен для доступа к вашим плейлистам. Получите его один раз:
</MudText>
<ol style="margin-left: 1.2rem;">
<li>Перейдите по <MudLink Href="https://oauth.yandex.ru/authorize?response_type=token&client_id=23cabbbdc6cd418abb4b39c32c41195d" Target="_blank">ссылке</MudLink></li>
<li>Авторизуйтесь в Яндексе (если ещё не вошли)</li>
<li>Нажмите «Разрешить»</li>
<li>Скопируйте <strong>access_token</strong> из адресной строки после перенаправления</li>
</ol>
<MudAlert Severity="Severity.Info" Class="mt-2">
Пример: <code>https://music.yandex.ru/#access_token=ВАШ_ТОКЕН&...</code>
</MudAlert>
<MudItem xs="12" sm="4">
<MudPaper Class="pa-3 text-center" Elevation="0" Style="background-color: rgba(0,0,0,0.04); border-radius: 8px;">
<MudIcon Icon="@Icons.Material.Filled.People" Size="Size.Medium" Color="Color.Primary" />
<MudText Typo="Typo.body2" Class="mt-2">Совместное управление треками</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="0" Style="background-color: rgba(0,0,0,0.04); border-radius: 8px;">
<MudText Typo="Typo.h6" GutterBottom>3⃣ Добавление токена в профиле</MudText>
<MudText Typo="Typo.body2" Class="mb-2">
• Перейдите в <MudLink Href="/profile" Style="font-weight:bold;">Профиль</MudLink><br />
• Вставьте скопированный токен в поле «Токен Яндекс.Музыки»<br />
• Нажмите «Сохранить»
</MudText>
<MudAlert Severity="Severity.Success" Class="mt-2">✅ Токен сохраняется в зашифрованном виде.</MudAlert>
</MudPaper>
</MudItem>
<MudItem xs="12" md="6">
<MudPaper Class="pa-4" Elevation="0" Style="background-color: rgba(0,0,0,0.04); border-radius: 8px;">
<MudText Typo="Typo.h6" GutterBottom>4⃣ Расшаривание плейлиста</MudText>
<MudText Typo="Typo.body2" Class="mb-2">
• Откройте <MudLink Href="/my-playlists" Style="font-weight:bold;">Мои плейлисты</MudLink><br />
• Нажмите «Поделиться» рядом с нужным плейлистом<br />
• Скопируйте полученную ссылку и отправьте друзьям
</MudText>
<MudAlert Severity="Severity.Info" Class="mt-2">
Вы можете настроить права на добавление/удаление треков для гостей.
</MudAlert>
<MudItem xs="12" sm="4">
<MudPaper Class="pa-3 text-center" Elevation="0" Style="background-color: rgba(0,0,0,0.04); border-radius: 8px;">
<MudIcon Icon="@Icons.Material.Filled.Security" Size="Size.Medium" Color="Color.Primary" />
<MudText Typo="Typo.body2" Class="mt-2">Гибкие настройки доступа</MudText>
</MudPaper>
</MudItem>
</MudGrid>
<MudDivider Class="my-6" />
<MudDivider Class="my-4" />
<MudText Typo="Typo.h6" GutterBottom>📌 Важно</MudText>
<MudGrid>
<MudItem xs="12" sm="6">
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined">
🔐 Токен даёт доступ к вашим плейлистам. Никому его не сообщайте.
</MudAlert>
</MudItem>
<MudItem xs="12" sm="6">
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined">
🎧 Для работы с плейлистами нужна активная подписка Яндекс.Плюс?<br />
<MudText Typo="Typo.body2">Нет, достаточно обычного аккаунта.</MudText>
</MudAlert>
</MudItem>
</MudGrid>
<MudText Typo="Typo.body2" Color="Color.Secondary">
🔐 Все данные передаются по защищённому соединению, токены хранятся в зашифрованном виде.
</MudText>
</MudCardContent>
</MudCard>
</MudContainer>

View File

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

View File

@@ -13,17 +13,45 @@
</CardHeaderContent>
</MudCardHeader>
<MudCardContent>
<MudText Typo="Typo.body2" Class="mb-4">Здесь вы можете указать токен доступа к Яндекс.Музыке.</MudText>
<div style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 8px; margin-bottom: 16px;">
<MudText Typo="Typo.body2">
Здесь вы можете указать токен доступа к Яндекс.Музыке.
</MudText>
<MudIconButton Icon="@Icons.Material.Filled.HelpOutline"
Color="Color.Info"
OnClick="() => _instructionDrawerOpen = true"
Title="Как получить токен?" />
</div>
<MudTextField @bind-Value="_token" Label="Токен Яндекс.Музыки" Variant="Variant.Outlined" FullWidth="true" />
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveToken" Class="mt-4" FullWidth="true">Сохранить токен</MudButton>
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="SaveToken" Class="mt-4" FullWidth="true">
Сохранить токен
</MudButton>
<MudText Class="mt-4" Typo="Typo.body2">Статус: @_statusText</MudText>
</MudCardContent>
</MudCard>
</MudContainer>
<!-- Выдвижная панель с инструкцией -->
<MudDrawer @bind-Open="_instructionDrawerOpen"
Anchor="Anchor.Right"
Variant="DrawerVariant.Temporary"
Elevation="3"
Width="500px"
MiniWidth="0px">
<MudDrawerHeader>
<MudText Typo="Typo.h6">Как получить токен Яндекс.Музыки</MudText>
</MudDrawerHeader>
<MudDivider />
<YandexTokenInstructions />
</MudDrawer>
@code {
private string _token = "";
private string _statusText = "Загрузка...";
private bool _instructionDrawerOpen = false;
protected override async Task OnInitializedAsync()
{

View File

@@ -27,10 +27,21 @@
<div style="display: flex; gap: 16px; align-items: center;">
@if (!string.IsNullOrEmpty(_playlist.CoverUrl))
{
<MudImage Src="@FormatCoverUrl(_playlist.CoverUrl)" Height="80" Width="80" Class="rounded" />
<MudImage Src="@FormatCoverUrl(_playlist.CoverUrl, "80x80")" Height="80" Width="80" Class="rounded" />
}
<div>
<MudText Typo="Typo.h5">@_playlist.Title</MudText>
<div style="display: flex; align-items: center; gap: 8px;">
<MudLink Href="@($"https://music.yandex.ru/playlists/{_playlist.YandexPlaylistUuid}")" Typo="Typo.h5" Target="_blank" Underline="Underline.Hover">
@_playlist.Title
<MudIcon Icon="@Icons.Material.Filled.OpenInNew" Size="Size.Small" Class="ml-1" />
</MudLink>
<MudIconButton Icon="@(_isFavorite? Icons.Material.Filled.Star : Icons.Material.Outlined.StarBorder)"
Color="Color.Warning"
OnClick="ToggleFavorite"
Disabled="_favoriteLoading"
Size="Size.Medium" />
</div>
<MudText Typo="Typo.body2" Color="Color.Secondary">Владелец: @_playlist.Creator?.UserName</MudText>
</div>
</div>
@@ -50,6 +61,12 @@
<MudSelectItem Value="ViewPermission.AuthorizedOnly">Только авторизованные</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" sm="4">
<MudSelect T="ViewPermission" Label="Воспроизведение" @bind-Value="_editPermissions.PlayPermission" Variant="Variant.Outlined" FullWidth="true">
<MudSelectItem Value="ViewPermission.Everyone">Все</MudSelectItem>
<MudSelectItem Value="ViewPermission.AuthorizedOnly">Только авторизованные</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" sm="4">
<MudSelect T="EditPermission" Label="Добавление треков" @bind-Value="_editPermissions.AddPermission" Variant="Variant.Outlined" FullWidth="true">
<MudSelectItem Value="EditPermission.Everyone">Все</MudSelectItem>
@@ -143,6 +160,8 @@
<MudTd DataLabel="#" Style="font-weight: normal;">@context.Index</MudTd>
<MudTd DataLabel="Обложка">
@if (!string.IsNullOrEmpty(context.CoverUri))
{
@if (@_canPlay)
{
<TrackCoverWithPlay CoverUrl="@context.CoverUri"
TrackId="@context.Id"
@@ -150,6 +169,12 @@
IsPlaying="@(_currentTrackId == context.Id && _isPlaying)"
OnPlay="PlayTrack" />
}
else
{
<MudImage Src="@FormatCoverUrl(context.CoverUri, "50x50")" Height="50" Width="50" Class="rounded" Style="display: block;" />
}
}
</MudTd>
<MudTd DataLabel="Название">
<MudLink Href="@($"https://music.yandex.ru/track/{context.Id}")" Target="_blank" Underline="Underline.Hover">
@@ -174,7 +199,7 @@
<!-- Фиксированный плеер внизу -->
<div class="fixed-player" style="display: @(_isPlayerVisible ? "block" : "none");">
<AudioPlayer @ref="_audioPlayer" OnTrackEnded="OnTrackEnded" />
<AudioPlayer @ref="_audioPlayer" OnTrackEnded="OnTrackEnded" RequireAuth="false" SharedPlaylistId="@Token"/>
</div>
</MudContainer>
@@ -191,12 +216,16 @@
private bool _loading = true;
private bool _isAuthenticated;
private bool _isCreator;
private bool _canPlay;
private bool _canAdd;
private bool _canRemove;
private UpdatePermissionsDto _editPermissions = new();
private bool _savingPermissions;
private string? _currentUserId;
private bool _isFavorite = false;
private bool _favoriteLoading = false;
private List<YandexTrackDisplay> _tracks = new();
private bool _tracksLoading;
@@ -211,14 +240,78 @@
await LoadPlaylist();
}
private async Task LoadPlaylist()
private async Task CheckFavoriteStatus()
{
if (!_isAuthenticated || _playlist == null) return;
try
{
var response = await Http.GetFromJsonAsync<ApiResponse<SharedPlaylistDto>>($"/api/sharedplaylist/{Token}");
var response = await Http.GetFromJsonAsync<ApiResponse<bool>>($"/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)
{
_isCreator = false;
_canAdd = false;
_canRemove = false;
_canPlay = false;
}
else
{
_playlist = response.Data;
_isCreator = _playlist.CreatorUserId.ToString() == _currentUserId;
_canAdd = _isCreator
@@ -229,17 +322,36 @@
|| _playlist.RemovePermission == EditPermission.Everyone
|| (_playlist.RemovePermission == EditPermission.AuthorizedOnly && _isAuthenticated);
_canPlay = _isCreator
|| _playlist.PlayPermission == ViewPermission.Everyone
|| (_playlist.PlayPermission == ViewPermission.AuthorizedOnly && _isAuthenticated);
if (_isCreator && _isAuthenticated)
{
_editPermissions = new UpdatePermissionsDto
{
ViewPermission = _playlist.ViewPermission,
AddPermission = _playlist.AddPermission,
RemovePermission = _playlist.RemovePermission
RemovePermission = _playlist.RemovePermission,
PlayPermission = _playlist.PlayPermission,
};
}
}
}
private async Task LoadPlaylist()
{
try
{
var response = await Http.GetFromJsonAsync<ApiResponse<SharedPlaylistDto>>($"/api/sharedplaylist/{Token}");
if (response?.Success == true)
{
_playlist = response.Data;
await ConfigurePermissions();
await LoadTracks();
await CheckFavoriteStatus();
}
else
{
@@ -371,11 +483,8 @@
if (result?.Success == true)
{
_playlist = result.Data;
Snackbar.Add("Права доступа обновлены", Severity.Success);
_canAdd = _isCreator || _playlist.AddPermission == EditPermission.Everyone ||
(_playlist.AddPermission == EditPermission.AuthorizedOnly && _isAuthenticated);
_canRemove = _isCreator || _playlist.RemovePermission == EditPermission.Everyone ||
(_playlist.RemovePermission == EditPermission.AuthorizedOnly && _isAuthenticated);
await ConfigurePermissions();
}
else
{

View File

@@ -21,6 +21,7 @@ internal class Program
});
builder.Services.AddScoped<TokenStorage>();
builder.Services.AddScoped<PlayerStorage>();
builder.Services.AddScoped<AuthStateProvider>();
builder.Services.AddScoped<AuthenticationStateProvider>(sp => sp.GetRequiredService<AuthStateProvider>());
builder.Services.AddScoped<ApiClient>();

View File

@@ -0,0 +1,30 @@
using Microsoft.JSInterop;
namespace PlaylistShared.Pwa.Services;
public class PlayerStorage
{
private readonly IJSRuntime _js;
private const string VolumeKey = "audio_player_volume";
public PlayerStorage(IJSRuntime js) => _js = js;
public async Task SetVolumeAsync(double volume)
{
await _js.InvokeVoidAsync("localStorage.setItem", VolumeKey, volume);
}
public async Task<double> GetVolumeAsync()
{
var volume = await _js.InvokeAsync<string>("localStorage.getItem", VolumeKey);
if (double.TryParse(volume, out var result))
{
result = Math.Clamp(result, 0, 100);
return result;
}
return 0;
}
}

View File

@@ -16,74 +16,6 @@
<link rel="apple-touch-icon" sizes="512x512" href="icon-512.png" />
<link rel="apple-touch-icon" sizes="192x192" href="icon-192.png" />
<script type="importmap"></script>
<style>
html, body {
background-color: #1a1a27 !important;
margin: 0;
padding: 0;
height: 100%;
}
/* Кастомный спиннер в стиле MudBlazor (тёмная тема) */
.loading-progress {
position: relative;
display: block;
width: 64px;
height: 64px;
margin: 0 auto;
}
.loading-progress circle {
fill: none;
stroke: #2a2833;
stroke-width: 4;
transform-origin: 50% 50%;
animation: spin 1.5s linear infinite;
}
.loading-progress circle:last-child {
stroke: #7e6fff;
stroke-dasharray: 126;
stroke-dashoffset: 126;
animation: dash 1.5s ease-in-out infinite;
}
@keyframes spin {
100% {
transform: rotate(360deg);
}
}
@keyframes dash {
0% {
stroke-dashoffset: 126;
}
50% {
stroke-dashoffset: 63;
transform: rotate(135deg);
}
100% {
stroke-dashoffset: 126;
transform: rotate(450deg);
}
}
.loading-progress-text {
text-align: center;
margin-top: 16px;
color: #b2b0bf;
font-family: 'Roboto', sans-serif;
font-size: 14px;
}
/* Убираем белые вспышки */
#app {
background-color: #1a1a27;
min-height: 100vh;
}
</style>
</head>
<body>

View File

@@ -10,9 +10,10 @@
return isNaN(num) ? 0 : num;
};
const loadAndPlay = (src, token) => {
const loadAndPlay = (src, token, sharedPlaylistId) => {
const url = new URL(src, window.location.href);
if (token) url.searchParams.set('access_token', token);
if (sharedPlaylistId) url.searchParams.set('shared_id', sharedPlaylistId);
audio.src = url.toString();
audio.load();
durationReady = false;

View File

@@ -6,6 +6,10 @@ namespace PlaylistShared.Shared.Playlist;
/// <summary>Запрос на создание нового шеринг-плейлиста.</summary>
public class SharePlaylistDto
{
/// <summary>Идентификатор плейлиста в Яндекс.Музыке (guid).</summary>
[JsonPropertyName("yandexPlaylistId")]
public string YandexPlaylistUuid { get; set; } = null!;
/// <summary>Идентификатор плейлиста в Яндекс.Музыке (kind).</summary>
[JsonPropertyName("yandexPlaylistKind")]
public string YandexPlaylistKind { get; set; } = null!;
@@ -38,6 +42,10 @@ public class SharePlaylistDto
[JsonPropertyName("viewPermission")]
public ViewPermission ViewPermission { get; set; }
/// <summary>Права на воспроизведение.</summary>
[JsonPropertyName("playPermission")]
public ViewPermission PlayPermission { get; set; }
/// <summary>Права на добавление треков.</summary>
[JsonPropertyName("addPermission")]
public EditPermission AddPermission { get; set; }

View File

@@ -15,6 +15,10 @@ public class SharedPlaylistDto
[JsonPropertyName("creatorUserId")]
public Guid CreatorUserId { get; set; }
/// <summary>Uuid на яндекс плейлист</summary>
[JsonPropertyName("yandexPlaylistUuid")]
public string? YandexPlaylistUuid { get; set; }
/// <summary>Идентификатор плейлиста в Яндекс.Музыке (kind).</summary>
[JsonPropertyName("yandexPlaylistKind")]
public string YandexPlaylistKind { get; set; } = null!;
@@ -55,6 +59,10 @@ public class SharedPlaylistDto
[JsonPropertyName("viewPermission")]
public ViewPermission ViewPermission { get; set; }
/// <summary>Права на воспроизведение.</summary>
[JsonPropertyName("playPermission")]
public ViewPermission PlayPermission { get; set; }
/// <summary>Права на добавление треков.</summary>
[JsonPropertyName("addPermission")]
public EditPermission AddPermission { get; set; }

View File

@@ -6,15 +6,19 @@ namespace PlaylistShared.Shared.Shared;
/// <summary>Запрос на обновление прав доступа шеринг-плейлиста.</summary>
public class UpdatePermissionsDto
{
/// <summary>Новые права на просмотр.</summary>
/// <summary>Права на просмотр.</summary>
[JsonPropertyName("viewPermission")]
public ViewPermission ViewPermission { get; set; }
/// <summary>Новые права на добавление треков.</summary>
/// <summary>Права на воспроизведение треков.</summary>
[JsonPropertyName("playPermission")]
public ViewPermission PlayPermission { get; set; }
/// <summary>Права на добавление треков.</summary>
[JsonPropertyName("addPermission")]
public EditPermission AddPermission { get; set; }
/// <summary>Новые права на удаление треков.</summary>
/// <summary>Права на удаление треков.</summary>
[JsonPropertyName("removePermission")]
public EditPermission RemovePermission { get; set; }
}

View File

@@ -1,7 +1,6 @@
services:
playlistshared.api:
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ASPNETCORE_HTTP_PORTS=8080
- ASPNETCORE_HTTPS_PORTS=8081
ports: