using Microsoft.EntityFrameworkCore; using DorkNet.Server.Data; using DorkNet.Server.Data.Entities; namespace DorkNet.Server.Services; /// /// Backs the watch's /roomserver/roomsandplaylists/* + /api/curatedroomplaylists /// surfaces. The 2020.12 client deserializes responses as /// List<MKAMHOIHOJK>, an abstract union of Room /// (KLCOGEIGEBJ, discriminator = presence of lowercase /// roomId) and Playlist (KMKPEOGJDFK). This service owns the /// playlist half of that union — Rooms come from . /// public class PlaylistService(DorkNetDbContext db) { /// /// Hot playlists — drives /roomserver/roomsandplaylists/hot. /// Score is CheerCount + VisitorCount * 0.25 (cheers weighted /// heavier than passive visits) so a freshly-popular list with a /// burst of cheers can outrank an evergreen list with high traffic. /// Tag filter mirrors — substring /// match against after stripping /// a leading '#'. /// public async Task> HotAsync(string? tag, int take = 24) { IQueryable q = db.Playlists; if (!string.IsNullOrWhiteSpace(tag)) { var bareTag = tag.TrimStart('#').Trim(); if (bareTag.Length > 0) { var needle = $"%{bareTag}%"; q = q.Where(p => EF.Functions.Like(p.TagsCsv, needle)); } } // Score is computed client-side after pulling rows because // EF's SQLite provider can't translate ordering expressions // with a constant double multiplier predictably; the table // is tiny so the in-memory sort is fine. var rows = await q.ToListAsync(); return rows .OrderByDescending(p => p.CheerCount + p.VisitorCount * 0.25) .Take(take) .ToList(); } /// /// Curated playlists for /api/curatedroomplaylists. Returns /// only seed-curated rows in /// order so the editorial sort holds. /// public Task> CuratedAsync() => db.Playlists .Where(p => p.IsCurated) .OrderBy(p => p.OrderIndex) .ToListAsync(); /// /// Free-text search across playlists — drives the playlist half of /// /roomserver/search_roomsandplaylists/{query}. Matches Name /// or TagsCsv substrings (same predicate shape as /// ). /// public async Task> SearchAsync(string query, int take = 24) { if (string.IsNullOrWhiteSpace(query)) return new(); var needle = $"%{query}%"; return await db.Playlists .Where(p => EF.Functions.Like(p.Name, needle) || EF.Functions.Like(p.TagsCsv, needle)) .OrderByDescending(p => p.CheerCount) .Take(take) .ToListAsync(); } /// Member-room ids for a playlist, in display order. /// Callers fan-out per-id with /// when they need the full Room rows (the watch's playlist detail /// screen does this). public Task> RoomIdsAsync(long playlistId) => db.PlaylistRooms .Where(pr => pr.PlaylistId == playlistId) .OrderBy(pr => pr.OrderIndex) .Select(pr => pr.RoomId) .ToListAsync(); /// Single-playlist lookup. Returns null when missing so /// the controller can 404 cleanly. public Task GetByIdAsync(long playlistId) => db.Playlists.FirstOrDefaultAsync(p => p.Id == playlistId); /// Bulk-by-id lookup for playlists/bulk. Preserves /// caller order; missing rows are silently dropped. public async Task> BulkAsync(IEnumerable playlistIds) { var ids = playlistIds.Distinct().ToList(); if (ids.Count == 0) return new(); var rows = await db.Playlists.Where(p => ids.Contains(p.Id)).ToListAsync(); var byId = rows.ToDictionary(p => p.Id); return ids.Select(i => byId.TryGetValue(i, out var p) ? p : null) .Where(p => p is not null).Select(p => p!).ToList(); } /// Playlists created by a player. Drives /// playlists/createdby/me and any future profile-card /// equivalent. public Task> CreatedByAsync(long playerId) => db.Playlists .Where(p => p.CreatorPlayerId == playerId) .OrderByDescending(p => p.CreatedAt) .ToListAsync(); /// Playlists the player has cheered. Drives /// playlists/cheeredby/me. public async Task> CheeredByAsync(long playerId) { var ids = await db.PlaylistInteractions .Where(i => i.PlayerId == playerId && i.Cheered) .Select(i => i.PlaylistId).ToListAsync(); return await db.Playlists.Where(p => ids.Contains(p.Id)).ToListAsync(); } /// Playlists the player has favorited. Drives /// playlists/favoritedby/me. public async Task> FavoritedByAsync(long playerId) { var ids = await db.PlaylistInteractions .Where(i => i.PlayerId == playerId && i.Favorited) .Select(i => i.PlaylistId).ToListAsync(); return await db.Playlists.Where(p => ids.Contains(p.Id)).ToListAsync(); } /// Read the caller's interaction row for a playlist — /// drives playlists/{id}/interactionby/me. Returns (false, /// false) sentinel when no row exists yet (no toggle has fired). public async Task<(bool Cheered, bool Favorited)> InteractionForAsync(long playlistId, long playerId) { var row = await db.PlaylistInteractions .FirstOrDefaultAsync(i => i.PlaylistId == playlistId && i.PlayerId == playerId); return row is null ? (false, false) : (row.Cheered, row.Favorited); } /// Toggle a single interaction flag for the caller. The /// playlist's denormalized counter is bumped/decremented in the /// same SaveChanges so the hot ranking + tile badges stay in sync. /// Returns the new row state. public async Task SetInteractionAsync( long playlistId, long playerId, bool? cheered = null, bool? favorited = null) { var playlist = await db.Playlists.FirstOrDefaultAsync(p => p.Id == playlistId) ?? throw new InvalidOperationException($"playlist {playlistId} not found"); var row = await db.PlaylistInteractions .FirstOrDefaultAsync(i => i.PlaylistId == playlistId && i.PlayerId == playerId); if (row is null) { row = new PlaylistInteractionEntity { PlaylistId = playlistId, PlayerId = playerId, }; db.PlaylistInteractions.Add(row); } if (cheered is bool c && row.Cheered != c) { row.Cheered = c; playlist.CheerCount = Math.Max(0, playlist.CheerCount + (c ? 1 : -1)); } if (favorited is bool f && row.Favorited != f) { row.Favorited = f; playlist.FavoriteCount = Math.Max(0, playlist.FavoriteCount + (f ? 1 : -1)); } row.UpdatedAt = DateTime.UtcNow; playlist.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return row; } /// Create a new player-owned playlist. Curated lists are /// admin-only; user-created lists set IsCurated=false. Returns the /// new row; the controller projects it into the BMFAGMFKODA wire /// shape on the way out. public async Task CreateAsync( long creatorId, string name, string? description, string? imageName, string? tagsCsv) { var p = new PlaylistEntity { Name = (name ?? string.Empty).Trim(), Description = description ?? string.Empty, ImageName = imageName ?? string.Empty, CreatorPlayerId = creatorId, IsCurated = false, TagsCsv = tagsCsv ?? string.Empty, }; db.Playlists.Add(p); await db.SaveChangesAsync(); return p; } /// Apply an arbitrary mutation to a playlist after /// checking ownership. Returns the row on success, null on missing /// playlist; throws when /// the caller isn't the creator. Used by every modify endpoint. public async Task ModifyAsync(long playlistId, long callerId, Action mutator) { var p = await db.Playlists.FirstOrDefaultAsync(x => x.Id == playlistId); if (p is null) return null; if (p.CreatorPlayerId != callerId) throw new UnauthorizedAccessException(); mutator(p); p.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return p; } /// Delete a player-owned playlist + its member-room rows /// + every per-player interaction row. Curated playlists are /// admin-only — the controller still calls this method but only /// after gating on the caller's ownership. public async Task DeleteAsync(long playlistId, long callerId) { var p = await db.Playlists.FirstOrDefaultAsync(x => x.Id == playlistId); if (p is null) return false; if (p.CreatorPlayerId != callerId) throw new UnauthorizedAccessException(); var memberRows = await db.PlaylistRooms.Where(pr => pr.PlaylistId == playlistId).ToListAsync(); db.PlaylistRooms.RemoveRange(memberRows); var interactionRows = await db.PlaylistInteractions .Where(i => i.PlaylistId == playlistId).ToListAsync(); db.PlaylistInteractions.RemoveRange(interactionRows); db.Playlists.Remove(p); await db.SaveChangesAsync(); return true; } /// Add a room to a playlist at the end. Idempotent — a /// repeat add is a no-op. Throws /// when the caller isn't the owner. public async Task AddRoomAsync(long playlistId, long roomId, long callerId) { var p = await db.Playlists.FirstOrDefaultAsync(x => x.Id == playlistId); if (p is null) return false; if (p.CreatorPlayerId != callerId) throw new UnauthorizedAccessException(); var existing = await db.PlaylistRooms .AnyAsync(pr => pr.PlaylistId == playlistId && pr.RoomId == roomId); if (existing) return true; var nextOrder = await db.PlaylistRooms .Where(pr => pr.PlaylistId == playlistId) .Select(pr => (int?)pr.OrderIndex).MaxAsync() ?? -1; db.PlaylistRooms.Add(new PlaylistRoomEntity { PlaylistId = playlistId, RoomId = roomId, OrderIndex = nextOrder + 1, }); p.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return true; } /// Remove a room from a playlist. Idempotent — missing /// row is treated as success so the watch's repeat-delete path /// doesn't render a phantom error. public async Task RemoveRoomAsync(long playlistId, long roomId, long callerId) { var p = await db.Playlists.FirstOrDefaultAsync(x => x.Id == playlistId); if (p is null) return false; if (p.CreatorPlayerId != callerId) throw new UnauthorizedAccessException(); var row = await db.PlaylistRooms .FirstOrDefaultAsync(pr => pr.PlaylistId == playlistId && pr.RoomId == roomId); if (row is null) return true; db.PlaylistRooms.Remove(row); p.UpdatedAt = DateTime.UtcNow; await db.SaveChangesAsync(); return true; } /// /// Idempotent seed of the default curated playlists. Inserts three /// editorial lists (Featured, Newcomers, Sports) on first launch and /// populates each with up to five matching seeded rooms (looked up by /// the tag substrings each list curates). Re-running on a populated /// table is a no-op — once any playlist exists, this method bails. /// public async Task SeedCuratedAsync() { if (await db.Playlists.AnyAsync()) return; var curated = new (string Name, string Description, string Image, string Tags, string[] RoomLookupTags)[] { ("Featured", "Hand-picked rooms the team is loving this week — start here.", "featured.png", "featured", new[] { "featured" }), ("Newcomers", "Easy-to-pick-up rooms perfect for your first Rec Room sessions.", "newcomers.png", "newcomer,easy", new[] { "recroomoriginal" }), ("Sports", "Get sweaty — paintball, dodgeball, soccer, and the rest of the field.", "sports.png", "paintball,soccer,dodgeball", new[] { "sport" }), }; long playlistId = 1; int orderIndex = 0; foreach (var entry in curated) { var p = new PlaylistEntity { Id = playlistId++, Name = entry.Name, Description = entry.Description, ImageName = entry.Image, CreatorPlayerId = 1, IsCurated = true, OrderIndex = orderIndex++, TagsCsv = entry.Tags, CheerCount = 50 + orderIndex * 10, FavoriteCount = 25 + orderIndex * 5, VisitorCount = 1000 + orderIndex * 250, VisitCount = 4000 + orderIndex * 750, }; db.Playlists.Add(p); await db.SaveChangesAsync(); // need the Id for the join rows // Find 3-5 matching seeded rooms for this list. Search by // tag substring against RoomEntity.TagsCsv so the // playlists naturally pick up new seeded rooms that // carry the right tags without per-name hardcoding. var memberRooms = new List(); foreach (var lookupTag in entry.RoomLookupTags) { var needle = $"%{lookupTag}%"; var ids = await db.Rooms .Where(r => !r.IsDormRoom && !r.HiddenFromBrowse && EF.Functions.Like(r.TagsCsv, needle)) .OrderByDescending(r => r.HotScore) .Select(r => r.Id) .Take(5) .ToListAsync(); memberRooms.AddRange(ids); } // De-dupe in case two lookup tags catch the same room. memberRooms = memberRooms.Distinct().Take(5).ToList(); int memberOrder = 0; foreach (var roomId in memberRooms) { db.PlaylistRooms.Add(new PlaylistRoomEntity { PlaylistId = p.Id, RoomId = roomId, OrderIndex = memberOrder++, }); } await db.SaveChangesAsync(); } } }