From 8ede68b1247b02db2e1f0b95e58f9337d162fa6f Mon Sep 17 00:00:00 2001
From: Alexa <53471801+Alexa-RR@users.noreply.github.com>
Date: Wed, 8 Jul 2026 01:29:54 +0300
Subject: [PATCH] feat: Update Images and Rooms APIs to support 2023 client URL
structure and add tests for new functionality
---
DorkNet.Server.Tests/RoomBrowse2023Tests.cs | 91 +++++++++++++++++++
.../API/Images/ImagesController.cs | 31 ++++++-
.../API/Rooms/V2/RoomsController.cs | 60 ++++++++++--
3 files changed, 168 insertions(+), 14 deletions(-)
create mode 100644 DorkNet.Server.Tests/RoomBrowse2023Tests.cs
diff --git a/DorkNet.Server.Tests/RoomBrowse2023Tests.cs b/DorkNet.Server.Tests/RoomBrowse2023Tests.cs
new file mode 100644
index 0000000..73846a8
--- /dev/null
+++ b/DorkNet.Server.Tests/RoomBrowse2023Tests.cs
@@ -0,0 +1,91 @@
+using System.Net.Http.Headers;
+using System.Text.Json;
+using DorkNet.Server.Data;
+using DorkNet.Server.Data.Entities;
+using DorkNet.Server.Services;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace DorkNet.Server.Tests;
+
+///
+/// 2023 play-menu browse sources. The client's room-discovery URL
+/// builder (IBEOONPEELF/GGDPFFDEDKN) requests
+/// rooms/search_rooms/{query}&skip={skip}&take={take} —
+/// note the paging pairs live INSIDE the final path segment, and the
+/// whole family is prefixed with rooms/ on the rooms host.
+/// The room photo gallery sends sort/filter as enum
+/// names (CheerCount_Desc, PublicOnly), which must not
+/// trip int model binding.
+///
+public sealed class RoomBrowse2023Tests : IClassFixture
+{
+ private readonly DorkNetServerFactory _factory;
+
+ public RoomBrowse2023Tests(DorkNetServerFactory factory) => _factory = factory;
+
+ [Fact]
+ public async Task Search_rooms_2023_path_with_embedded_paging_returns_matches()
+ {
+ using var setupClient = Client();
+ var owner = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
+
+ var marker = $"Zz{Guid.NewGuid():N}"[..14];
+ await using (var scope = _factory.Services.CreateAsyncScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.Rooms.Add(new RoomEntity
+ {
+ Id = 9_300_000 + Random.Shared.Next(1, 99_999),
+ Name = $"Search{marker}",
+ Description = "2023 search test room",
+ CreatorPlayerId = owner.PlayerId,
+ ImageName = RoomService.DefaultRoomImageName,
+ Accessibility = 1,
+ IsAGRoom = true,
+ IsDormRoom = false,
+ });
+ await db.SaveChangesAsync();
+ }
+
+ using var roomsClient = Client(owner, subdomain: "rooms");
+ using var response = await roomsClient.GetAsync($"/rooms/search_rooms/{marker}&skip=0&take=20");
+ var text = await response.Content.ReadAsStringAsync();
+ Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {text}");
+
+ using var json = JsonDocument.Parse(text);
+ var names = json.RootElement.EnumerateArray()
+ .Select(e => e.GetProperty("Name").GetString() ?? string.Empty)
+ .ToArray();
+ Assert.Contains(names, n => n.Contains(marker));
+ }
+
+ [Fact]
+ public async Task Room_images_accept_2023_enum_name_sort_and_filter()
+ {
+ using var setupClient = Client();
+ var player = await GameClientSessionFactory.CreateAsync(setupClient, _factory.ApexDomain);
+
+ using var apiClient = Client(player);
+ using var response = await apiClient.GetAsync(
+ "/api/images/v4/room/116?sort=CheerCount_Desc&filter=PublicOnly&take=8&skip=0");
+ var text = await response.Content.ReadAsStringAsync();
+ Assert.True(response.IsSuccessStatusCode, $"{(int)response.StatusCode}: {text}");
+
+ using var json = JsonDocument.Parse(text);
+ Assert.Equal(JsonValueKind.Array, json.RootElement.ValueKind);
+ }
+
+ private HttpClient Client(GameClientSession? session = null, string subdomain = "api")
+ {
+ var client = _factory.CreateClient(new() { AllowAutoRedirect = false });
+ client.BaseAddress = new Uri($"http://{subdomain}.{_factory.ApexDomain}");
+ client.Timeout = TimeSpan.FromSeconds(30);
+ client.DefaultRequestHeaders.Accept.ParseAdd("application/json");
+ client.DefaultRequestHeaders.UserAgent.ParseAdd("RecRoom/2023.03.21");
+ client.DefaultRequestHeaders.TryAddWithoutValidation("X-DorkNet-Version", "march_2023_03_21");
+ if (session is not null)
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", session.AccessToken);
+ return client;
+ }
+}
diff --git a/DorkNet.Server/Controllers/API/Images/ImagesController.cs b/DorkNet.Server/Controllers/API/Images/ImagesController.cs
index 27d2dcc..ba0fb3f 100644
--- a/DorkNet.Server/Controllers/API/Images/ImagesController.cs
+++ b/DorkNet.Server/Controllers/API/Images/ImagesController.cs
@@ -401,14 +401,14 @@ public class ImagesController(
[HttpGet("api/images/v4/player/{accountId:long}")]
[HttpGet("api/images/v5/player/{accountId:long}")]
public async Task GetByAccount(long accountId,
- [FromQuery] int take = 50, [FromQuery] int skip = 0, [FromQuery] int sort = 0)
+ [FromQuery] int take = 50, [FromQuery] int skip = 0, [FromQuery] string? sort = null)
{
take = Math.Clamp(take, 1, 200);
skip = Math.Max(0, skip);
var query = db.Photos
.Where(p => p.UploaderPlayerId == accountId && p.IsPublic && p.DeletedAt == null)
.AsQueryable();
- query = sort switch
+ query = ParseSort(sort) switch
{
1 => query.OrderByDescending(p => p.CheerCount).ThenByDescending(p => p.CreatedAt),
2 => query.OrderByDescending(p => p.ViewCount).ThenByDescending(p => p.CreatedAt),
@@ -422,6 +422,13 @@ public class ImagesController(
return Ok(rows.Select(p => BuildImageInfo(p, accountId)));
}
+ /// GET api/images/v{2-5}/room/{roomId} — a room's public
+ /// photo gallery. The 2023 client sends sort/filter as
+ /// ENUM NAMES (?sort=CheerCount_Desc&filter=PublicOnly),
+ /// not ints — binding them as int makes [ApiController]
+ /// auto-400 and the watch toasts "Could not show images for room".
+ /// filter is accepted but unused: we only store public,
+ /// non-deleted photos for this view anyway (PublicOnly semantics).
[HttpGet("api/images/v2/room/{roomId:long}")]
[HttpGet("api/images/v3/room/{roomId:long}")]
[HttpGet("api/images/v4/room/{roomId:long}")]
@@ -430,16 +437,17 @@ public class ImagesController(
long roomId,
[FromQuery] int take = 50,
[FromQuery] int skip = 0,
- [FromQuery] int sort = 0,
- [FromQuery] int filter = 0)
+ [FromQuery] string? sort = null,
+ [FromQuery] string? filter = null)
{
take = Math.Clamp(take, 1, 200);
skip = Math.Max(0, skip);
+ _ = filter;
var query = db.Photos
.Where(p => p.RoomId == roomId && p.IsPublic && p.DeletedAt == null)
.AsQueryable();
- query = sort switch
+ query = ParseSort(sort) switch
{
1 => query.OrderByDescending(p => p.CheerCount).ThenByDescending(p => p.CreatedAt),
2 => query.OrderByDescending(p => p.ViewCount).ThenByDescending(p => p.CreatedAt),
@@ -453,6 +461,19 @@ public class ImagesController(
return Ok(rows.Select(p => BuildImageInfo(p, p.UploaderPlayerId)));
}
+ /// Sort selector shared by the album views. Accepts the
+ /// 2020 client's ints (0/1/2) and the 2023 client's enum names —
+ /// the names contain the metric ("CheerCount_Desc", "ViewCount_…");
+ /// anything unrecognised falls back to newest-first.
+ private static int ParseSort(string? sort)
+ {
+ if (string.IsNullOrWhiteSpace(sort)) return 0;
+ if (int.TryParse(sort, out var n)) return n;
+ if (sort.Contains("Cheer", StringComparison.OrdinalIgnoreCase)) return 1;
+ if (sort.Contains("View", StringComparison.OrdinalIgnoreCase)) return 2;
+ return 0;
+ }
+
/// POST api/images/v{2-5}/share/{id} — flip a saved photo
/// to public so it shows up on feed.rec.net. The watch's "Share"
/// button hits this; if the watch posts to a different URL we'll
diff --git a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs
index a392282..2ece508 100644
--- a/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs
+++ b/DorkNet.Server/Controllers/API/Rooms/V2/RoomsController.cs
@@ -2758,18 +2758,21 @@ public class RoomsController(
///
[HttpGet("roomserver/hot_roomsandplaylists/{tagsJoined?}")]
[HttpGet("hot_roomsandplaylists/{tagsJoined?}")]
+ [HttpGet("rooms/hot_roomsandplaylists/{tagsJoined?}")]
public async Task HotRoomsAndPlaylistsPath(string? tagsJoined)
{
// Watch joins multiple tags with "," — use the first one as a
// substring filter since RoomService.HotAsync takes a single
- // tag arg. Empty / null = unfiltered.
+ // tag arg. Empty / null = unfiltered. The 2023 client embeds
+ // &skip/&take in the same segment (see ParsePagedPathSegment).
//
// 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 (tagsValue, _, _) = ParsePagedPathSegment(tagsJoined, defaultTake: 100);
+ var firstTag = string.IsNullOrWhiteSpace(tagsValue)
? null
- : tagsJoined.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ : tagsValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault();
var (entries, _) = await BuildHotRoomsAsync(firstTag);
return Ok(entries);
@@ -2782,11 +2785,14 @@ public class RoomsController(
/// rows so the watch's rooms-only browse tab doesn't render
/// playlist tiles that have nowhere to go.
[HttpGet("hot_rooms/{tagsJoined?}")]
+ [HttpGet("rooms/hot_rooms/{tagsJoined?}")]
+ [HttpGet("roomserver/hot_rooms/{tagsJoined?}")]
public async Task HotRoomsOnlyPath(string? tagsJoined)
{
- var firstTag = string.IsNullOrWhiteSpace(tagsJoined)
+ var (tagsValue, _, _) = ParsePagedPathSegment(tagsJoined, defaultTake: 12);
+ var firstTag = string.IsNullOrWhiteSpace(tagsValue)
? null
- : tagsJoined.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ : tagsValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.FirstOrDefault();
var roomList = await rooms.HotAsync(firstTag, take: 12);
var roomIds = roomList.Select(r => r.Id).ToList();
@@ -2983,22 +2989,35 @@ public class RoomsController(
///
[HttpGet("roomserver/search_roomsandplaylists/{query}")]
[HttpGet("search_roomsandplaylists/{query}")]
+ [HttpGet("rooms/search_roomsandplaylists/{query}")]
public async Task SearchRoomsAndPlaylists(string query)
{
- var (merged, _) = await BuildSearchUnionAsync(query);
+ var (text, _, _) = ParsePagedPathSegment(query, defaultTake: 50);
+ var (merged, _) = await BuildSearchUnionAsync(text);
return Ok(merged);
}
/// GET search_rooms/{query} — rooms-only search
/// variant. Same MKAMHOIHOJK union entry shape as
/// search_roomsandplaylists/{query}, just skips playlist
- /// rows.
+ /// rows.
+ ///
+ /// The 2023 client's play-menu search (IBEOONPEELF.SearchRooms,
+ /// URL builder GGDPFFDEDKN) calls the rooms/-prefixed
+ /// variant and embeds paging in the SAME path segment:
+ /// rooms/search_rooms/{query}&skip={skip}&take={take}.
+ /// Missing the prefixed route = "Error refreshing rooms from source
+ /// ServerSearch" in the play menu.
[HttpGet("search_rooms/{query}")]
+ [HttpGet("rooms/search_rooms/{query}")]
+ [HttpGet("roomserver/search_rooms/{query}")]
public async Task SearchRoomsOnly(string query)
{
- if (string.IsNullOrWhiteSpace(query))
+ var (text, skip, take) = ParsePagedPathSegment(query, defaultTake: 50);
+ if (string.IsNullOrWhiteSpace(text))
return Ok(Array.Empty