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();
}
}
}