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()); // ── Bulk lookup ────────────────────────────────────────────────── /// GET /playlists/bulk?id=A&id=B — bulk-by-id /// lookup for the watch's playlist cache warmup. Bare list of /// KMKPEOGJDFK entries, one per resolved id (missing ids /// silently dropped). [HttpGet("/playlists/bulk")] public async Task Bulk() { var ids = Request.Query["id"].Concat(Request.Query["ids"]) .SelectMany(v => (v ?? string.Empty).Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) .Select(s => long.TryParse(s, out var n) ? n : 0) .Where(n => n != 0) .ToList(); if (ids.Count == 0) return Ok(Array.Empty()); var rows = await playlists.BulkAsync(ids); return Ok(rows.Select(RoomsController.BuildPlaylistUnionEntry).ToList()); } // ── Detail-response builder (BMFAGMFKODA shape) ───────────────── /// /// BMFAGMFKODA wire shape: KMKPEOGJDFK union entry + Rooms list /// (List<KLCOGEIGEBJ>) + Tags list (List<DPHPFLGAICI>). /// Mirrors the inline logic in /// so the BARE-PATH mutation routes here return the same shape the /// watch's PlaylistDetails.Deserialize reads. /// private async Task BuildDetailsResponseAsync(long playlistId) { var p = await playlists.GetByIdAsync(playlistId); if (p is null) return NotFound(); var roomIds = await playlists.RoomIdsAsync(playlistId); List memberRooms = new(); Dictionary> scenesByRoom = new(); if (roomIds.Count > 0) { var roomRows = await db.Rooms.Where(r => roomIds.Contains(r.Id)).ToListAsync(); var byId = roomRows.ToDictionary(r => r.Id); memberRooms = roomIds .Select(rid => byId.TryGetValue(rid, out var r) ? r : null) .Where(r => r is not null).Select(r => r!).ToList(); var sceneRows = await db.RoomScenes .Where(s => roomIds.Contains(s.RoomId)) .OrderBy(s => s.OrderIndex).ToListAsync(); scenesByRoom = sceneRows.GroupBy(s => s.RoomId) .ToDictionary(g => g.Key, g => (IReadOnlyList)g.ToList()); } var roomsWire = memberRooms .Select(r => RoomsController.BuildRoomServerDetails(r, scenesByRoom.GetValueOrDefault(r.Id))) .ToList(); var tagsWire = string.IsNullOrWhiteSpace(p.TagsCsv) ? new List() : p.TagsCsv .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(t => (object)new { Type = 0, Tag = t }) .ToList(); var result = RoomsController.BuildPlaylistUnionEntry(p); result["Rooms"] = roomsWire; result["Tags"] = tagsWire; return Ok(result); } }