play: drop playlists from Hot; raise take 12 -> 100

/roomserver/roomsandplaylists/hot and the path-style sibling
/roomserver/hot_roomsandplaylists/{tags} now return rooms-only — the
watch's Playlists tab fetches via /api/curatedroomplaylists +
/roomserver/playlists/{id} independently (verified
FPNPBJBCMKB.txt:053, EJDCNGBEICB.txt:1659/2192), so dropping playlists
from the Hot endpoint just stops mixing them into the rooms tab.

Wire shape unchanged: HJKAOMOICJG reads TotalResults + Results
(HJKAOMOICJG.txt:039/045) and the MKAMHOIHOJK factory discriminates
on the roomId key (MKAMHOIHOJK.txt:014), accepting a Results list
that's 100% room entries.

Take cap raised from 12 to 100 — the old limit only surfaced the
dozen highest-HotScore rooms, hiding the rest of the public catalog.
This commit is contained in:
Alexa
2026-05-27 10:43:31 +03:00
parent 4edf25b2d5
commit f5061f014b
@@ -1731,16 +1731,25 @@ public class RoomsController(
/// <c>roomId</c> key per the <c>MKAMHOIHOJK</c> factory at /// <c>roomId</c> key per the <c>MKAMHOIHOJK</c> factory at
/// <c>MKAMHOIHOJK.txt:621</c> — see <see cref="BuildRoomUnionEntry"/> /// <c>MKAMHOIHOJK.txt:621</c> — see <see cref="BuildRoomUnionEntry"/>
/// + <see cref="BuildPlaylistUnionEntry"/>. /// + <see cref="BuildPlaylistUnionEntry"/>.
///
/// <para><b>Why rooms-only despite the "and playlists" name:</b> the
/// Hot tab of the watch's Play page mounts this endpoint and used to
/// interleave playlist tiles next to room tiles, but design intent is
/// that playlists appear only under the Moods/Playlists section (their
/// own endpoints — <c>/api/curatedroomplaylists</c> +
/// <c>/roomserver/playlists/{id}</c>). The watch's union deserializer
/// happily accepts a Results list that's 100% rooms, so we keep the
/// wrapper shape but drop the playlist half.</para>
/// </summary> /// </summary>
[HttpGet("roomserver/roomsandplaylists/hot")] [HttpGet("roomserver/roomsandplaylists/hot")]
[HttpGet("roomsandplaylists/hot")] [HttpGet("roomsandplaylists/hot")]
public async Task<IActionResult> RoomsAndPlaylistsHot([FromQuery] string? tag) public async Task<IActionResult> RoomsAndPlaylistsHot([FromQuery] string? tag)
{ {
var (merged, total) = await BuildHotUnionAsync(tag); var (entries, total) = await BuildHotRoomsAsync(tag);
return Ok(new Dictionary<string, object>(StringComparer.Ordinal) return Ok(new Dictionary<string, object>(StringComparer.Ordinal)
{ {
["TotalResults"] = (long)total, ["TotalResults"] = (long)total,
["Results"] = merged, ["Results"] = entries,
}); });
} }
@@ -1760,12 +1769,16 @@ public class RoomsController(
// Watch joins multiple tags with "," — use the first one as a // Watch joins multiple tags with "," — use the first one as a
// substring filter since RoomService.HotAsync takes a single // substring filter since RoomService.HotAsync takes a single
// tag arg. Empty / null = unfiltered. // tag arg. Empty / null = unfiltered.
//
// Returns rooms-only for the same reason as the wrapped
// RoomsAndPlaylistsHot endpoint above: playlists belong in
// Moods, not in the Hot tab. See that endpoint's doc-comment.
var firstTag = string.IsNullOrWhiteSpace(tagsJoined) var firstTag = string.IsNullOrWhiteSpace(tagsJoined)
? null ? null
: tagsJoined.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) : tagsJoined.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault(); .FirstOrDefault();
var (merged, _) = await BuildHotUnionAsync(firstTag); var (entries, _) = await BuildHotRoomsAsync(firstTag);
return Ok(merged); return Ok(entries);
} }
/// <summary>GET <c>hot_rooms/{tagsJoined?}</c> — rooms-only variant /// <summary>GET <c>hot_rooms/{tagsJoined?}</c> — rooms-only variant
@@ -1795,34 +1808,37 @@ public class RoomsController(
} }
/// <summary> /// <summary>
/// Shared body for the two hot variants — pulls hot rooms + /// Shared body for the two hot variants — pulls hot rooms only,
/// playlists, fans out sub-room rows in one query to avoid N+1, /// fans out sub-room rows in one query to avoid N+1, and returns
/// and interleaves into the union shape the watch's factory /// the union-shape entries the watch's factory expects (each entry
/// expects. Returns (entries, totalCount) so the wrapped variant /// happens to be a room dict; the factory accepts that). Returns
/// can fill in TotalResults. /// (entries, totalCount) so the wrapped variant can fill in
/// TotalResults.
///
/// <para>Take limit raised to 100 (was 12) — the Hot tab used to
/// surface only the top dozen rooms which is far less than the
/// catalog the watch's browse UI can actually render. The watch
/// paginates client-side if it needs to chunk further.</para>
/// </summary> /// </summary>
private async Task<(List<IDictionary<string, object>> Entries, int Total)> BuildHotUnionAsync(string? tag) private async Task<(List<IDictionary<string, object>> Entries, int Total)> BuildHotRoomsAsync(string? tag)
{ {
// Sequential — both services share the scoped DbContext, which var roomList = await rooms.HotAsync(tag, take: 100);
// is not thread-safe (Task.WhenAll here throws "A second
// operation was started on this context instance before a
// previous operation completed").
var roomList = await rooms.HotAsync(tag, take: 12);
var playlistList = await playlists.HotAsync(tag, take: 12);
var roomIds = roomList.Select(r => r.Id).ToList(); var roomIds = roomList.Select(r => r.Id).ToList();
var sceneRows = await db.RoomScenes var sceneRows = roomIds.Count == 0
.Where(s => roomIds.Contains(s.RoomId)) ? new List<RoomSceneEntity>()
.OrderBy(s => s.OrderIndex) : await db.RoomScenes
.ToListAsync(); .Where(s => roomIds.Contains(s.RoomId))
.OrderBy(s => s.OrderIndex)
.ToListAsync();
var scenesByRoom = sceneRows var scenesByRoom = sceneRows
.GroupBy(s => s.RoomId) .GroupBy(s => s.RoomId)
.ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList()); .ToDictionary(g => g.Key, g => (IReadOnlyList<RoomSceneEntity>)g.ToList());
var merged = InterleaveUnion( var entries = roomList
roomList.Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id))), .Select(r => BuildRoomUnionEntry(r, scenesByRoom.GetValueOrDefault(r.Id)))
playlistList.Select(BuildPlaylistUnionEntry)); .ToList();
return (merged, roomList.Count + playlistList.Count); return (entries, entries.Count);
} }
/// <summary> /// <summary>