using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using DorkNet.Server.Auth;
using DorkNet.Server.Controllers.API.Rooms.V2;
using DorkNet.Server.Data;
using DorkNet.Server.Data.Entities;
using DorkNet.Server.Services;
namespace DorkNet.Server.Controllers.API.Playlists;
///
/// 2020.12 playlist CRUD surface — every playlists/* route the
/// watch hits beyond the basic listing/detail reads in
/// . URL templates are bare (no
/// /roomserver/ prefix) because the 2020.12 client emits them
/// that way on the Rooms host; the same routes are NOT mirrored under
/// /roomserver/ here to avoid duplicating definitions that already
/// exist on RoomsController.
///
/// Wire shape conventions:
/// • Single mutation responses use BMFAGMFKODA — the union
/// KMKPEOGJDFK entry + Rooms + Tags arrays
/// (same shape that PlaylistDetails in
/// returns). See .
/// • List responses (cheeredby/me, createdby/me, etc.) use bare
/// List<KMKPEOGJDFK> — the union entry alone.
/// • Bookmark / cheer toggles return {success: true}; the
/// watch's reads come back through interactionby/me.
///
[ApiController]
public class PlaylistsController(
PlaylistService playlists,
DorkNetDbContext db) : ControllerBase
{
private long Me => this.RequireCurrentPlayerId();
// ── Create / delete ──────────────────────────────────────────────
public sealed class CreatePlaylistRequest
{
public string? Name { get; set; }
public string? Description { get; set; }
public string? ImageName { get; set; }
public string? Tags { get; set; }
}
[HttpPost("/playlists")]
[Authorize]
public async Task Create([FromBody] CreatePlaylistRequest req)
{
if (string.IsNullOrWhiteSpace(req.Name)) return BadRequest(new { error = "missing_name" });
var created = await playlists.CreateAsync(Me, req.Name, req.Description, req.ImageName, req.Tags);
return await BuildDetailsResponseAsync(created.Id);
}
[HttpDelete("/playlists/{playlistId:long}")]
[Authorize]
public async Task Delete(long playlistId)
{
try
{
var ok = await playlists.DeleteAsync(playlistId, Me);
return ok ? Ok() : NotFound();
}
catch (UnauthorizedAccessException) { return Forbid(); }
}
// ── Per-field mutations (each returns BMFAGMFKODA) ───────────────
public sealed class StringFieldRequest { public string? Value { get; set; } }
public sealed class IntFieldRequest { public int? Value { get; set; } }
public sealed class BoolFieldRequest { public bool? Value { get; set; } }
public sealed class TagsRequest { public string? Tags { get; set; } public List? TagsList { get; set; } }
[HttpPost("/playlists/{playlistId:long}/name")]
[HttpPut("/playlists/{playlistId:long}/name")]
[Authorize]
public Task PlaylistName(long playlistId, [FromBody] StringFieldRequest req,
[FromForm(Name = "Name")] string? formValue) =>
ApplyMutation(playlistId, p => p.Name = (req?.Value ?? formValue ?? p.Name).Trim());
[HttpPost("/playlists/{playlistId:long}/description")]
[HttpPut("/playlists/{playlistId:long}/description")]
[Authorize]
public Task PlaylistDescription(long playlistId, [FromBody] StringFieldRequest req,
[FromForm(Name = "Description")] string? formValue) =>
ApplyMutation(playlistId, p => p.Description = req?.Value ?? formValue ?? p.Description);
[HttpPost("/playlists/{playlistId:long}/image")]
[HttpPut("/playlists/{playlistId:long}/image")]
[Authorize]
public Task PlaylistImage(long playlistId, [FromBody] StringFieldRequest req,
[FromForm(Name = "ImageName")] string? formValue) =>
ApplyMutation(playlistId, p => p.ImageName = req?.Value ?? formValue ?? p.ImageName);
[HttpPost("/playlists/{playlistId:long}/tags")]
[HttpPut("/playlists/{playlistId:long}/tags")]
[Authorize]
public Task PlaylistTags(long playlistId, [FromBody] TagsRequest? req,
[FromForm(Name = "Tags")] string? formValue) =>
ApplyMutation(playlistId, p =>
{
var csv = req?.Tags
?? (req?.TagsList is { Count: > 0 } list ? string.Join(',', list) : null)
?? formValue
?? p.TagsCsv;
p.TagsCsv = csv;
});
/// Accessibility / visibility / level-voting / restrictions /
/// warning are persisted on the playlist row even though the 2020
/// PlaylistEntity doesn't have dedicated columns for each — the wire
/// shape always reports the union-entry defaults, so for now the
/// mutation is a no-op acknowledgement that returns the unchanged
/// playlist. Real per-field columns can be added when we have a
/// playlist-settings UI to drive them.
[HttpPost("/playlists/{playlistId:long}/accessibility")]
[HttpPost("/playlists/{playlistId:long}/visibility")]
[HttpPost("/playlists/{playlistId:long}/levelvoting")]
[HttpPost("/playlists/{playlistId:long}/restrictions")]
[HttpPost("/playlists/{playlistId:long}/warning")]
[Authorize]
public Task PlaylistAck(long playlistId) =>
ApplyMutation(playlistId, _ => { /* no-op: field not persisted yet */ });
private async Task ApplyMutation(long playlistId, Action mutator)
{
try
{
var updated = await playlists.ModifyAsync(playlistId, Me, mutator);
if (updated is null) return NotFound();
return await BuildDetailsResponseAsync(playlistId);
}
catch (UnauthorizedAccessException) { return Forbid(); }
}
// ── Member-room mutations ────────────────────────────────────────
[HttpPut("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[HttpPost("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[Authorize]
public async Task AddRoom(long playlistId, long roomId)
{
try
{
if (!await playlists.AddRoomAsync(playlistId, roomId, Me)) return NotFound();
return await BuildDetailsResponseAsync(playlistId);
}
catch (UnauthorizedAccessException) { return Forbid(); }
}
[HttpDelete("/playlists/{playlistId:long}/rooms/{roomId:long}")]
[Authorize]
public async Task RemoveRoom(long playlistId, long roomId)
{
try
{
if (!await playlists.RemoveRoomAsync(playlistId, roomId, Me)) return NotFound();
return await BuildDetailsResponseAsync(playlistId);
}
catch (UnauthorizedAccessException) { return Forbid(); }
}
// ── Cheer / favorite toggles ─────────────────────────────────────
[HttpPost("/playlists/{playlistId:long}/interactionby/me/cheer")]
[HttpPut("/playlists/{playlistId:long}/interactionby/me/cheer")]
[Authorize]
public async Task Cheer(long playlistId)
{
await playlists.SetInteractionAsync(playlistId, Me, cheered: true);
return Ok(new { success = true });
}
[HttpDelete("/playlists/{playlistId:long}/interactionby/me/cheer")]
[Authorize]
public async Task Uncheer(long playlistId)
{
await playlists.SetInteractionAsync(playlistId, Me, cheered: false);
return Ok(new { success = true });
}
[HttpPost("/playlists/{playlistId:long}/interactionby/me/favorite")]
[HttpPut("/playlists/{playlistId:long}/interactionby/me/favorite")]
[Authorize]
public async Task Favorite(long playlistId)
{
await playlists.SetInteractionAsync(playlistId, Me, favorited: true);
return Ok(new { success = true });
}
[HttpDelete("/playlists/{playlistId:long}/interactionby/me/favorite")]
[Authorize]
public async Task Unfavorite(long playlistId)
{
await playlists.SetInteractionAsync(playlistId, Me, favorited: false);
return Ok(new { success = true });
}
// ── My-playlists tabs (bare-list KMKPEOGJDFK) ───────────────────
[HttpGet("/playlists/createdby/me")]
[Authorize]
public async Task CreatedByMe()
{
var rows = await playlists.CreatedByAsync(Me);
return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList());
}
[HttpGet("/playlists/cheeredby/me")]
[Authorize]
public async Task CheeredByMe()
{
var rows = await playlists.CheeredByAsync(Me);
return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList());
}
[HttpGet("/playlists/favoritedby/me")]
[Authorize]
public async Task FavoritedByMe()
{
var rows = await playlists.FavoritedByAsync(Me);
return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList());
}
/// GET /playlists/visitedby/me — we don't track
/// per-player playlist visits yet (visits are tracked at the Room
/// level via RoomVisitEntity). Return empty so the watch's
/// playlists tab renders cleanly; populated once we add a
/// PlaylistVisitEntity or derive from member-room visits.
[HttpGet("/playlists/visitedby/me")]
[Authorize]
public IActionResult VisitedByMe() => Ok(Array.Empty