Playlist support (#492)

This commit is contained in:
Dagg 2022-09-27 17:43:24 -07:00 committed by GitHub
commit d192060ef4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 501 additions and 7 deletions

View file

@ -1,4 +1,6 @@
#nullable enable
using System.Xml.Serialization;
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.Helpers;
using LBPUnion.ProjectLighthouse.Levels;
using LBPUnion.ProjectLighthouse.Levels.Categories;
@ -6,8 +8,8 @@ using LBPUnion.ProjectLighthouse.Logging;
using LBPUnion.ProjectLighthouse.PlayerData;
using LBPUnion.ProjectLighthouse.PlayerData.Profiles;
using LBPUnion.ProjectLighthouse.Serialization;
using LBPUnion.ProjectLighthouse.Types;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace LBPUnion.ProjectLighthouse.Servers.GameServer.Controllers.Slots;
@ -23,8 +25,128 @@ public class CollectionController : ControllerBase
this.database = database;
}
[HttpGet("playlists/{playlistId:int}/slots")]
public async Task<IActionResult> GetPlaylistSlots(int playlistId)
{
GameToken? token = await this.database.GameTokenFromRequest(this.Request);
if (token == null) return this.StatusCode(403, "");
Playlist? targetPlaylist = await this.database.Playlists.FirstOrDefaultAsync(p => p.PlaylistId == playlistId);
if (targetPlaylist == null) return this.BadRequest();
IQueryable<Slot> slots = this.database.Slots.Include(s => s.Creator)
.Include(s => s.Location)
.Where(s => targetPlaylist.SlotIds.Contains(s.SlotId));
string response = Enumerable.Aggregate(slots, string.Empty, (current, slot) => current + slot.Serialize());
int total = targetPlaylist.SlotIds.Length;
return this.Ok(LbpSerializer.TaggedStringElement("slots", response, "total", total));
}
[HttpPost("playlists/{playlistId:int}")]
[HttpPost("playlists/{playlistId:int}/slots")]
[HttpPost("playlists/{playlistId:int}/slots/{slotId:int}/delete")]
[HttpPost("playlists/{playlistId:int}/order_slots")]
public async Task<IActionResult> UpdatePlaylist(int playlistId, int slotId)
{
GameToken? token = await this.database.GameTokenFromRequest(this.Request);
if (token == null) return this.StatusCode(403, "");
Playlist? targetPlaylist = await this.database.Playlists.FirstOrDefaultAsync(p => p.PlaylistId == playlistId);
if (targetPlaylist == null) return this.BadRequest();
if (token.UserId != targetPlaylist.CreatorId) return this.BadRequest();
// Delete a slot from playlist
if (slotId != 0)
{
targetPlaylist.SlotIds = targetPlaylist.SlotIds.Where(s => s != slotId).ToArray();
await this.database.SaveChangesAsync();
return this.Ok(this.GetUserPlaylists(token.UserId));
}
Playlist? newPlaylist = await this.getPlaylistFromBody();
if (newPlaylist == null) return this.BadRequest();
if (newPlaylist.LevelIds != null)
{
HashSet<int> slotIds = new(targetPlaylist.SlotIds);
// Reorder
if (slotIds.SetEquals(newPlaylist.LevelIds))
{
targetPlaylist.SlotIds = newPlaylist.LevelIds;
}
// Add a level
else
{
foreach (int id in newPlaylist.LevelIds)
{
targetPlaylist.SlotIds = targetPlaylist.SlotIds.Append(id).ToArray();
}
}
}
if (!string.IsNullOrWhiteSpace(newPlaylist.Name)) targetPlaylist.Name = newPlaylist.Name;
if (!string.IsNullOrWhiteSpace(newPlaylist.Description)) targetPlaylist.Description = newPlaylist.Description;
await this.database.SaveChangesAsync();
return this.Ok(this.GetUserPlaylists(token.UserId));
}
private string GetUserPlaylists(int userId)
{
string response = Enumerable.Aggregate(
this.database.Playlists.Include(p => p.Creator).Where(p => p.CreatorId == userId),
string.Empty,
(current, slot) => current + slot.Serialize());
int total = this.database.Playlists.Count(p => p.CreatorId == userId);
return LbpSerializer.TaggedStringElement("playlists", response, new Dictionary<string, object>
{
{"total", total},
{"hint_start", total+1},
});
}
[HttpPost("playlists")]
public async Task<IActionResult> CreatePlaylist()
{
GameToken? token = await this.database.GameTokenFromRequest(this.Request);
if (token == null) return this.StatusCode(403, "");
int playlistCount = await this.database.Playlists.CountAsync(p => p.CreatorId == token.UserId);
if (playlistCount > ServerConfiguration.Instance.UserGeneratedContentLimits.ListsQuota) return this.BadRequest();
Playlist? playlist = await this.getPlaylistFromBody();
if (playlist == null) return this.BadRequest();
playlist.CreatorId = token.UserId;
this.database.Playlists.Add(playlist);
await this.database.SaveChangesAsync();
return this.Ok(this.GetUserPlaylists(token.UserId));
}
[HttpGet("user/{username}/playlists")]
public IActionResult GetUserPlaylists(string username) => this.Ok("<playlists></playlists>");
public async Task<IActionResult> GetUserPlaylists(string username)
{
GameToken? token = await this.database.GameTokenFromRequest(this.Request);
if (token == null) return this.StatusCode(403, "");
int targetUserId = await this.database.Users.Where(u => u.Username == username).Select(u => u.UserId).FirstOrDefaultAsync();
if (targetUserId == 0) return this.BadRequest();
return this.Ok(this.GetUserPlaylists(targetUserId));
}
[HttpGet("searches")]
[HttpGet("genres")]
@ -121,4 +243,19 @@ public class CollectionController : ControllerBase
)
);
}
private async Task<Playlist?> getPlaylistFromBody()
{
this.Request.Body.Position = 0;
string bodyString = await new StreamReader(this.Request.Body).ReadToEndAsync();
string rootElement = bodyString.Contains("levels") ? "levels" : "playlist";
XmlSerializer serializer = new(typeof(Playlist), new XmlRootAttribute(rootElement));
Playlist? playlist = (Playlist?)serializer.Deserialize(new StringReader(bodyString));
SanitizationHelper.SanitizeStringsInClass(playlist);
return playlist;
}
}

View file

@ -197,6 +197,64 @@ public class ListController : ControllerBase
#endregion
#region Hearted Playlists
[HttpGet("favouritePlaylists/{username}")]
public async Task<IActionResult> GetFavouritePlaylists(string username, [FromQuery] int pageStart, [FromQuery] int pageSize)
{
GameToken? token = await this.database.GameTokenFromRequest(this.Request);
if (token == null) return this.StatusCode(403, "");
if (pageSize <= 0) return this.BadRequest();
int targetUserId = await this.database.Users.Where(u => u.Username == username).Select(u => u.UserId).FirstOrDefaultAsync();
if (targetUserId == 0) return this.StatusCode(403, "");
IEnumerable<Playlist> heartedPlaylists = this.database.HeartedPlaylists.Where(p => p.UserId == targetUserId)
.Include(p => p.Playlist).Include(p => p.Playlist.Creator).OrderByDescending(p => p.HeartedPlaylistId).Select(p => p.Playlist);
string response = heartedPlaylists.Aggregate(string.Empty, (current, p) => current + p.Serialize());
return this.Ok
(
LbpSerializer.TaggedStringElement("favouritePlaylists", response, new Dictionary<string, object>
{
{ "total", this.database.HeartedPlaylists.Count(p => p.UserId == targetUserId) },
{ "hint_start", pageStart + Math.Min(pageSize, 30) },
})
);
}
[HttpPost("favourite/playlist/{playlistId:int}")]
public async Task<IActionResult> AddFavouritePlaylist(int playlistId)
{
GameToken? token = await this.database.GameTokenFromRequest(this.Request);
if (token == null) return this.StatusCode(403, "");
Playlist? playlist = await this.database.Playlists.FirstOrDefaultAsync(s => s.PlaylistId == playlistId);
if (playlist == null) return this.NotFound();
await this.database.HeartPlaylist(token.UserId, playlist);
return this.Ok();
}
[HttpPost("unfavourite/playlist/{playlistId:int}")]
public async Task<IActionResult> RemoveFavouritePlaylist(int playlistId)
{
GameToken? token = await this.database.GameTokenFromRequest(this.Request);
if (token == null) return this.StatusCode(403, "");
Playlist? playlist = await this.database.Playlists.FirstOrDefaultAsync(s => s.PlaylistId == playlistId);
if (playlist == null) return this.NotFound();
await this.database.UnheartPlaylist(token.UserId, playlist);
return this.Ok();
}
#endregion
#endregion Levels
#region Users

View file

@ -30,6 +30,7 @@ public class Database : DbContext
public DbSet<QueuedLevel> QueuedLevels { get; set; }
public DbSet<HeartedLevel> HeartedLevels { get; set; }
public DbSet<HeartedProfile> HeartedProfiles { get; set; }
public DbSet<HeartedPlaylist> HeartedPlaylists { get; set; }
public DbSet<Comment> Comments { get; set; }
public DbSet<GameToken> GameTokens { get; set; }
public DbSet<WebToken> WebTokens { get; set; }
@ -51,6 +52,7 @@ public class Database : DbContext
public DbSet<PasswordResetToken> PasswordResetTokens { get; set; }
public DbSet<RegistrationToken> RegistrationTokens { get; set; }
public DbSet<APIKey> APIKeys { get; set; }
public DbSet<Playlist> Playlists { get; set; }
protected override void OnConfiguring(DbContextOptionsBuilder options)
=> options.UseMySql(ServerConfiguration.Instance.DbConnectionString, MySqlServerVersion.LatestSupportedServerVersion);
@ -231,6 +233,28 @@ public class Database : DbContext
await this.SaveChangesAsync();
}
public async Task HeartPlaylist(int userId, Playlist heartedPlaylist)
{
HeartedPlaylist? heartedList = await this.HeartedPlaylists.FirstOrDefaultAsync(p => p.UserId == userId && p.PlaylistId == heartedPlaylist.PlaylistId);
if (heartedList != null) return;
this.HeartedPlaylists.Add(new HeartedPlaylist()
{
PlaylistId = heartedPlaylist.PlaylistId,
UserId = userId,
});
await this.SaveChangesAsync();
}
public async Task UnheartPlaylist(int userId, Playlist heartedPlaylist)
{
HeartedPlaylist? heartedList = await this.HeartedPlaylists.FirstOrDefaultAsync(p => p.UserId == userId && p.PlaylistId == heartedPlaylist.PlaylistId);
if (heartedList != null) this.HeartedPlaylists.Remove(heartedList);
await this.SaveChangesAsync();
}
public async Task HeartLevel(int userId, Slot heartedSlot)
{
HeartedLevel? heartedLevel = await this.HeartedLevels.FirstOrDefaultAsync(q => q.UserId == userId && q.SlotId == heartedSlot.SlotId);

View file

@ -0,0 +1,21 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using LBPUnion.ProjectLighthouse.PlayerData.Profiles;
namespace LBPUnion.ProjectLighthouse.Levels;
public class HeartedPlaylist
{
[Key]
public int HeartedPlaylistId { get; set; }
public int UserId { get; set; }
[ForeignKey(nameof(UserId))]
public User User { get; set; }
public int PlaylistId { get; set; }
[ForeignKey(nameof(PlaylistId))]
public Playlist Playlist { get; set; }
}

View file

@ -0,0 +1,70 @@
#nullable enable
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using System.Linq;
using System.Text.Json.Serialization;
using System.Xml.Serialization;
using LBPUnion.ProjectLighthouse.Configuration;
using LBPUnion.ProjectLighthouse.PlayerData.Profiles;
using LBPUnion.ProjectLighthouse.Serialization;
namespace LBPUnion.ProjectLighthouse.Levels;
public class Playlist
{
[Key]
public int PlaylistId { get; set; }
[XmlElement("name")]
public string Name { get; set; } = "";
[XmlElement("description")]
public string Description { get; set; } = "";
public int CreatorId { get; set; }
[ForeignKey(nameof(CreatorId))]
[JsonIgnore]
public User? Creator { get; set; }
public int Hearts(Database database) => database.HeartedPlaylists.Count(p => p.HeartedPlaylistId == this.PlaylistId);
public string SlotCollection { get; set; } = "";
[JsonIgnore]
[NotMapped]
[XmlElement("level_id")]
public int[]? LevelIds { get; set; }
[NotMapped]
public int[] SlotIds
{
get => this.SlotCollection.Split(",").Where(x => int.TryParse(x, out _)).Select(int.Parse).ToArray();
set => this.SlotCollection = string.Join(",", value);
}
public string Serialize()
{
using Database database = new();
string playlist = LbpSerializer.StringElement("id", this.PlaylistId) +
LbpSerializer.StringElement("author",
LbpSerializer.StringElement("npHandle", this.Creator?.Username)) +
LbpSerializer.StringElement("name", this.Name) +
LbpSerializer.StringElement("description", this.Description) +
LbpSerializer.StringElement("levels", this.SlotIds.Length) +
LbpSerializer.StringElement("hearts", this.Hearts(database)) +
LbpSerializer.StringElement("levels_quota", ServerConfiguration.Instance.UserGeneratedContentLimits.ListsQuota) +
this.SerializeIcons(database);
return LbpSerializer.StringElement("playlist", playlist);
}
private string SerializeIcons(Database database)
{
string iconList = this.SlotIds.Select(id => database.Slots.FirstOrDefault(s => s.SlotId == id))
.Where(slot => slot != null && slot.IconHash.Length > 0)
.Aggregate(string.Empty, (current, slot) => current + LbpSerializer.StringElement("icon", slot?.IconHash));
return LbpSerializer.StringElement("icons", iconList);
}
}

View file

@ -0,0 +1,94 @@
using LBPUnion.ProjectLighthouse;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ProjectLighthouse.Migrations
{
[DbContext(typeof(Database))]
[Migration("20220923042831_AddPlaylists")]
public partial class AddPlaylists : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Playlists",
columns: table => new
{
PlaylistId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
Name = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
Description = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4"),
CreatorId = table.Column<int>(type: "int", nullable: false),
SlotCollection = table.Column<string>(type: "longtext", nullable: false)
.Annotation("MySql:CharSet", "utf8mb4")
},
constraints: table =>
{
table.PrimaryKey("PK_Playlists", x => x.PlaylistId);
table.ForeignKey(
name: "FK_Playlists_Users_CreatorId",
column: x => x.CreatorId,
principalTable: "Users",
principalColumn: "UserId",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateTable(
name: "HeartedPlaylists",
columns: table => new
{
HeartedPlaylistId = table.Column<int>(type: "int", nullable: false)
.Annotation("MySql:ValueGenerationStrategy", MySqlValueGenerationStrategy.IdentityColumn),
UserId = table.Column<int>(type: "int", nullable: false),
PlaylistId = table.Column<int>(type: "int", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_HeartedPlaylists", x => x.HeartedPlaylistId);
table.ForeignKey(
name: "FK_HeartedPlaylists_Playlists_PlaylistId",
column: x => x.PlaylistId,
principalTable: "Playlists",
principalColumn: "PlaylistId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_HeartedPlaylists_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "UserId",
onDelete: ReferentialAction.Cascade);
})
.Annotation("MySql:CharSet", "utf8mb4");
migrationBuilder.CreateIndex(
name: "IX_HeartedPlaylists_PlaylistId",
table: "HeartedPlaylists",
column: "PlaylistId");
migrationBuilder.CreateIndex(
name: "IX_HeartedPlaylists_UserId",
table: "HeartedPlaylists",
column: "UserId");
migrationBuilder.CreateIndex(
name: "IX_Playlists_CreatorId",
table: "Playlists",
column: "CreatorId");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "HeartedPlaylists");
migrationBuilder.DropTable(
name: "Playlists");
}
}
}

View file

@ -50,7 +50,7 @@ public class User
[NotMapped]
[JsonIgnore]
public int Lists => 0;
public int Lists => this.database.Playlists.Count(p => p.CreatorId == this.UserId);
/// <summary>
/// A user-customizable biography shown on the profile card
@ -116,6 +116,10 @@ public class User
[JsonIgnore]
public int HeartedUsers => this.database.HeartedProfiles.Count(p => p.UserId == this.UserId);
[NotMapped]
[JsonIgnore]
public int HeartedPlaylists => this.database.HeartedPlaylists.Count(p => p.UserId == this.UserId);
[NotMapped]
[JsonIgnore]
public int QueuedLevels => this.database.QueuedLevels.Count(p => p.UserId == this.UserId);
@ -197,14 +201,14 @@ public class User
string user = LbpSerializer.TaggedStringElement("npHandle", this.Username, "icon", this.IconHash) +
LbpSerializer.StringElement("game", (int)gameVersion) +
this.serializeSlots(gameVersion) +
LbpSerializer.StringElement<string>("lists", this.Lists, true) +
LbpSerializer.StringElement<string>
LbpSerializer.StringElement<int>("lists", this.Lists, true) +
LbpSerializer.StringElement<int>
(
"lists_quota",
ServerConfiguration.Instance.UserGeneratedContentLimits.ListsQuota,
true
) + // technically not a part of the user but LBP expects it
LbpSerializer.StringElement<string>("heartCount", this.Hearts, true) +
LbpSerializer.StringElement<int>("heartCount", this.Hearts, true) +
this.serializeEarth(gameVersion) +
LbpSerializer.StringElement<string>("yay2", this.YayHash, true) +
LbpSerializer.StringElement<string>("boo2", this.BooHash, true) +
@ -218,6 +222,7 @@ public class User
LbpSerializer.StringElement("location", this.Location.Serialize()) +
LbpSerializer.StringElement<int>("favouriteSlotCount", this.HeartedLevels, true) +
LbpSerializer.StringElement<int>("favouriteUserCount", this.HeartedUsers, true) +
LbpSerializer.StringElement<int>("favouritePlaylistCount", this.HeartedPlaylists, true) +
LbpSerializer.StringElement<int>("lolcatftwCount", this.QueuedLevels, true) +
LbpSerializer.StringElement<string>("pins", this.Pins, true);

View file

@ -16,7 +16,7 @@ namespace ProjectLighthouse.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "6.0.8")
.HasAnnotation("ProductVersion", "6.0.9")
.HasAnnotation("Relational:MaxIdentifierLength", 64);
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Administration.CompletedMigration", b =>
@ -172,6 +172,55 @@ namespace ProjectLighthouse.Migrations
b.ToTable("HeartedLevels");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Levels.HeartedPlaylist", b =>
{
b.Property<int>("HeartedPlaylistId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<int>("PlaylistId")
.HasColumnType("int");
b.Property<int>("UserId")
.HasColumnType("int");
b.HasKey("HeartedPlaylistId");
b.HasIndex("PlaylistId");
b.HasIndex("UserId");
b.ToTable("HeartedPlaylists");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Levels.Playlist", b =>
{
b.Property<int>("PlaylistId")
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<int>("CreatorId")
.HasColumnType("int");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("longtext");
b.Property<string>("SlotCollection")
.IsRequired()
.HasColumnType("longtext");
b.HasKey("PlaylistId");
b.HasIndex("CreatorId");
b.ToTable("Playlists");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Levels.QueuedLevel", b =>
{
b.Property<int>("QueuedLevelId")
@ -273,6 +322,9 @@ namespace ProjectLighthouse.Migrations
b.Property<int>("InternalSlotId")
.HasColumnType("int");
b.Property<bool>("IsAdventurePlanet")
.HasColumnType("tinyint(1)");
b.Property<long>("LastUpdated")
.HasColumnType("bigint");
@ -903,6 +955,9 @@ namespace ProjectLighthouse.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("int");
b.Property<int>("ChildSlotId")
.HasColumnType("int");
b.Property<string>("PlayerIdCollection")
.HasColumnType("longtext");
@ -989,6 +1044,36 @@ namespace ProjectLighthouse.Migrations
b.Navigation("User");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Levels.HeartedPlaylist", b =>
{
b.HasOne("LBPUnion.ProjectLighthouse.Levels.Playlist", "Playlist")
.WithMany()
.HasForeignKey("PlaylistId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("LBPUnion.ProjectLighthouse.PlayerData.Profiles.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Playlist");
b.Navigation("User");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Levels.Playlist", b =>
{
b.HasOne("LBPUnion.ProjectLighthouse.PlayerData.Profiles.User", "Creator")
.WithMany()
.HasForeignKey("CreatorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Creator");
});
modelBuilder.Entity("LBPUnion.ProjectLighthouse.Levels.QueuedLevel", b =>
{
b.HasOne("LBPUnion.ProjectLighthouse.Levels.Slot", "Slot")