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 ──────────────────────────────────────────────
/// POST /playlists — create. The 2023-03-21 client posts a
/// form with a single field name (verb 2 at
/// NLDBPDCNNCF.txt:11139-11140, field literal in
/// NLDBPDCNNCF_NestedType_NEECJOENHPI.txt — its only
/// Move rdx, "…" is "name"). The old non-optional
/// [FromBody] parameter made [ApiController] reject the
/// form content type with 415 before the action ran, so creating a
/// playlist from the watch was impossible. Fields are read from form,
/// query or JSON body so JSON callers (admin UI) keep working.
[HttpPost("/playlists")]
[Authorize]
[RecNetEnvelope]
public async Task Create()
{
var name = await ReadFieldAsync("name", "Name");
if (string.IsNullOrWhiteSpace(name)) return BadRequest(new { error = "missing_name" });
var description = await ReadFieldAsync("description", "Description");
var imageName = await ReadFieldAsync("imageName", "ImageName");
var tags = await ReadFieldsAsync("tag", "Tag", "tags", "Tags");
var tagsCsv = tags.Count > 0
? string.Join(',', tags.Distinct(StringComparer.OrdinalIgnoreCase))
: null;
var created = await playlists.CreateAsync(Me, name.Trim(), description, imageName, tagsCsv);
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) ───────────────
// The 2023-03-21 client sends these as form-urlencoded PUTs. A
// non-optional [FromBody] parameter made [ApiController] demand JSON and
// reject every one with 415 before the action ran, so renaming, re-imaging,
// re-tagging and publishing a playlist all failed silently. Values are now
// read from form, query or JSON body, whichever arrived.
[HttpPost("/playlists/{playlistId:long}/name")]
[HttpPut("/playlists/{playlistId:long}/name")]
[Authorize]
[RecNetEnvelope]
public async Task PlaylistName(long playlistId)
{
var value = await ReadFieldAsync("name", "Name", "value", "Value");
return await ApplyMutation(playlistId, p => p.Name = (value ?? p.Name).Trim());
}
[HttpPost("/playlists/{playlistId:long}/description")]
[HttpPut("/playlists/{playlistId:long}/description")]
[Authorize]
[RecNetEnvelope]
public async Task PlaylistDescription(long playlistId)
{
var value = await ReadFieldAsync("description", "Description", "value", "Value");
return await ApplyMutation(playlistId, p => p.Description = value ?? p.Description);
}
[HttpPost("/playlists/{playlistId:long}/image")]
[HttpPut("/playlists/{playlistId:long}/image")]
[Authorize]
[RecNetEnvelope]
public async Task PlaylistImage(long playlistId)
{
var value = await ReadFieldAsync("imageName", "ImageName", "value", "Value");
return await ApplyMutation(playlistId, p => p.ImageName = value ?? p.ImageName);
}
/// The client sends REPEATED form fields tag and
/// autoTag, not a single CSV Tags value.
[HttpPost("/playlists/{playlistId:long}/tags")]
[HttpPut("/playlists/{playlistId:long}/tags")]
[Authorize]
[RecNetEnvelope]
public async Task PlaylistTags(long playlistId)
{
var tags = await ReadFieldsAsync("tag", "Tag", "autoTag", "AutoTag", "tags", "Tags");
return await ApplyMutation(playlistId, p =>
{
if (tags.Count > 0)
p.TagsCsv = string.Join(',', tags.Distinct(StringComparer.OrdinalIgnoreCase));
});
}
[HttpPost("/playlists/{playlistId:long}/accessibility")]
[HttpPut("/playlists/{playlistId:long}/accessibility")]
[HttpPost("/playlists/{playlistId:long}/visibility")]
[HttpPut("/playlists/{playlistId:long}/visibility")]
[Authorize]
[RecNetEnvelope]
public async Task PlaylistAccessibility(long playlistId)
{
var value = await ReadFieldAsync(
"accessibility", "Accessibility", "visibility", "Visibility", "value", "Value");
return await ApplyMutation(playlistId, p =>
{
if (int.TryParse(value, out var v)) p.Accessibility = v;
});
}
[HttpPost("/playlists/{playlistId:long}/levelvoting")]
[HttpPut("/playlists/{playlistId:long}/levelvoting")]
[Authorize]
[RecNetEnvelope]
public async Task PlaylistLevelVoting(long playlistId)
{
var value = await ReadFieldAsync("supportsLevelVoting", "SupportsLevelVoting", "value", "Value");
return await ApplyMutation(playlistId, p =>
{
if (bool.TryParse(value, out var v)) p.SupportsLevelVoting = v;
});
}
[HttpPost("/playlists/{playlistId:long}/restrictions")]
[HttpPut("/playlists/{playlistId:long}/restrictions")]
[Authorize]
[RecNetEnvelope]
public async Task PlaylistRestrictions(long playlistId)
{
var juniors = await ReadFieldAsync("supportsJuniors", "SupportsJuniors");
var screens = await ReadFieldAsync("supportsScreens", "SupportsScreens");
var teleport = await ReadFieldAsync("supportsTeleportVR", "SupportsTeleportVR");
var walk = await ReadFieldAsync("supportsWalkVR", "SupportsWalkVR");
return await ApplyMutation(playlistId, p =>
{
if (bool.TryParse(juniors, out var j)) p.SupportsJuniors = j;
if (bool.TryParse(screens, out var c)) p.SupportsScreens = c;
if (bool.TryParse(teleport, out var t)) p.SupportsTeleportVR = t;
if (bool.TryParse(walk, out var w)) p.SupportsWalkVR = w;
});
}
[HttpPost("/playlists/{playlistId:long}/warning")]
[HttpPut("/playlists/{playlistId:long}/warning")]
[Authorize]
[RecNetEnvelope]
public async Task PlaylistWarning(long playlistId)
{
var mask = await ReadFieldAsync("warningMask", "WarningMask");
var custom = await ReadFieldAsync("customWarning", "CustomWarning");
return await ApplyMutation(playlistId, p =>
{
if (int.TryParse(mask, out var m)) p.WarningMask = m;
if (custom is not null) p.CustomWarning = custom.Length > 512 ? custom[..512] : custom;
});
}
/// First value present under any of the given names, looking at
/// the form, the query string, then a JSON body. Null means "not sent",
/// which every caller treats as "leave this field alone".
private async Task ReadFieldAsync(params string[] names)
{
var all = await ReadFieldsAsync(names);
return all.Count > 0 ? all[0] : null;
}
/// Every value present under any of the given names — the client
/// repeats a field name to send a list.
private async Task> ReadFieldsAsync(params string[] names)
{
var found = new List();
if (Request.HasFormContentType)
{
var form = await Request.ReadFormAsync();
foreach (var n in names)
foreach (var v in form[n])
if (!string.IsNullOrWhiteSpace(v)) found.Add(v!);
}
foreach (var n in names)
foreach (var v in Request.Query[n])
if (!string.IsNullOrWhiteSpace(v)) found.Add(v!);
if (found.Count > 0) return found;
if (!Request.HasFormContentType)
{
try
{
Request.EnableBuffering();
Request.Body.Position = 0;
using var doc = await System.Text.Json.JsonDocument.ParseAsync(Request.Body);
Request.Body.Position = 0;
var root = doc.RootElement;
// The tags endpoint sends a bare JSON array of strings.
if (root.ValueKind == System.Text.Json.JsonValueKind.Array)
{
foreach (var el in root.EnumerateArray())
if (el.ValueKind == System.Text.Json.JsonValueKind.String)
found.Add(el.GetString()!);
return found;
}
if (root.ValueKind == System.Text.Json.JsonValueKind.Object)
{
foreach (var n in names)
{
if (!root.TryGetProperty(n, out var v)) continue;
if (v.ValueKind == System.Text.Json.JsonValueKind.Array)
{
foreach (var el in v.EnumerateArray()) found.Add(el.ToString());
}
else if (v.ValueKind != System.Text.Json.JsonValueKind.Null)
{
found.Add(v.ToString());
}
if (found.Count > 0) break;
}
}
}
catch (System.Text.Json.JsonException) { /* no usable body */ }
}
return found;
}
/// KMKPEOGJDFK union entry with the per-playlist settings this
/// controller persists spliced back in.
/// still emits
/// hardcoded defaults for exactly the fields the client mutates here
/// (Accessibility=1, WarningMask=0,
/// SupportsLevelVoting=false, Supports*=true), so a creator
/// who published or restricted a playlist got the default echoed straight
/// back and the watch's toggle snapped to its old position. Key names are
/// the client's own setter fields —
/// NLDBPDCNNCF_NestedType_{BHAHEJIEIMF,PJFBAKCHIHI,GNCDFBNCADM,AIDDFKACPLN}.txt
/// — matched to the PascalCase read keys at
/// MKAMHOIHOJK.txt:516-612.
private static IDictionary UnionEntry(PlaylistEntity p)
{
var entry = RoomsController.BuildPlaylistUnionEntry(p);
entry["Accessibility"] = p.Accessibility;
entry["WarningMask"] = p.WarningMask;
entry["CustomWarning"] = p.CustomWarning;
entry["SupportsLevelVoting"] = p.SupportsLevelVoting;
entry["SupportsJuniors"] = p.SupportsJuniors;
entry["SupportsScreens"] = p.SupportsScreens;
entry["SupportsTeleportVR"] = p.SupportsTeleportVR;
entry["SupportsWalkVR"] = p.SupportsWalkVR;
return entry;
}
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]
[RecNetEnvelope]
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]
[RecNetEnvelope]
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(UnionEntry).ToList());
}
[HttpGet("/playlists/cheeredby/me")]
[Authorize]
public async Task CheeredByMe()
{
var rows = await playlists.CheeredByAsync(Me);
return Ok(rows.Select(UnionEntry).ToList());
}
[HttpGet("/playlists/favoritedby/me")]
[Authorize]
public async Task FavoritedByMe()
{
var rows = await playlists.FavoritedByAsync(Me);
return Ok(rows.Select(UnionEntry).ToList());
}
/// GET /playlists/visitedby/me — bare
/// List<CNIPGJIIFJF> (return type at
/// NLDBPDCNNCF.txt:4312, URL at :4355).
///
/// We have no PlaylistVisitEntity, but a playlist is nothing but an
/// ordered set of rooms, so "visited" is derivable exactly: the player
/// has visited a playlist iff they've visited any of its member rooms.
/// Ordered by the most recent member-room visit so the tab reads as a
/// history list. Previously hardcoded to an empty array, which left the
/// watch's recently-visited playlists tab permanently blank.
///
/// Aggregation happens in memory rather than in SQL: a single player's
/// RoomVisits row set is small, and this keeps the query provider-neutral
/// (SQLite in dev, Postgres in prod).
[HttpGet("/playlists/visitedby/me")]
[Authorize]
public async Task VisitedByMe()
{
var me = Me;
var visits = await db.RoomVisits
.Where(v => v.PlayerId == me)
.Select(v => new { v.RoomId, v.LastVisitAt })
.ToListAsync();
if (visits.Count == 0) return Ok(Array.Empty