[discovery][lists] semi working lists and discovery

This commit is contained in:
Devin Zuczek
2026-08-20 13:35:18 -04:00
parent f678f87d00
commit 5db02db172
10 changed files with 685 additions and 289 deletions
+65 -5
View File
@@ -4,17 +4,23 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
import { DiscoverySections, json, PAGE_SOURCE_PARAM, ServiceStatus } from './openapi'
import { fetchPageSource } from './page-sources'
import {
DiscoverySections,
json,
PAGE_SOURCE_PARAM,
SECTION_IDS_PARAM,
ServiceStatus,
} from './openapi'
import { fetchPageSource, readSections, SECTIONS_CATALOGUE } from './page-sources'
import type { App } from './context'
/**
* Discovery Worker. Serves the layout of the client's discovery pages — which carousels a
* page shows and in what order — out of `static/`, one file per page source, through the
* ASSETS binding (see `page-sources.ts`). It does not serve the carousels' CONTENTS: each
* section names a client-side feed the client resolves against the `rooms`/`api` workers
* itself.
* ASSETS binding (see `page-sources.ts`), plus `sections.json`, the id-keyed catalogue the
* bulk lookup filters. It does not serve the carousels' CONTENTS: each section names a
* client-side feed the client resolves against the `rooms`/`api` workers itself.
*
* Unauthenticated: every client gets the same layout, and the client fetches this before
* anything player-specific.
@@ -45,6 +51,57 @@ const app = new Hono<App>()
(c) => c.json({ service: 'discovery', status: 'ok' })
)
// A set of sections looked up by id, out of the catalogue in `static/sections.json`.
//
// This is the id-keyed counterpart to the page-source route: a page source hands back a
// whole page's rows in draw order, while this hands back exactly the rows asked for,
// which is how the client refreshes sections it already knows the ids of without
// re-fetching every page they came from.
//
// The reference reads its catalogue file and filters it, so the failure modes are the
// file's, not the query's: an id matching nothing is simply absent from the answer
// rather than an error, and a query naming NO ids answers `[]` rather than the whole
// catalogue — the client asks for nothing when it wants nothing. Only a missing
// catalogue file is a 404.
.get(
'/sections/bulk',
describeRoute({
tags: ['Discovery'],
summary: 'Look up sections by id',
description: [
'The sections named by the repeated `?id=` query, drawn from the catalogue in',
'`static/sections.json` — the union of the rows the page sources are built from.',
'',
'The answer is that file FILTERED, which fixes the edges: rows come back in the',
'catalogues order rather than the querys, an id that matches nothing is left out',
'instead of erroring, and repeating an id still yields it once. A query with no `id`',
'at all answers `[]`. Rows are served exactly as stored, so a field this service',
'doesnt model survives the round trip.',
'',
'Same section shape as `/sections/pagesource/{type}`: a section NAMES a feed',
'(`source`/`sourceMetadata`) that the client resolves itself. Nothing here is',
'player-specific, so there is no auth.',
].join('\n'),
parameters: [SECTION_IDS_PARAM],
responses: {
200: json(DiscoverySections, 'The requested sections, in catalogue order'),
404: { description: 'The catalogue file is not published' },
},
}),
async (c) => {
// `queries` and not `query`: the ids arrive as a repeated parameter, and `query`
// would collapse them to the first one and silently drop the rest of the page.
const ids = c.req.queries('id')
if (ids === undefined || ids.length === 0) return c.json([])
const sections = await readSections(c, SECTIONS_CATALOGUE)
if (sections === null) return c.notFound()
const wanted = new Set(ids)
return c.json(sections.filter((s) => typeof s.id === 'string' && wanted.has(s.id)))
}
)
// One discovery page's section layout, served verbatim from `static/<type>.json`.
.get(
'/sections/pagesource/:type',
@@ -104,6 +161,9 @@ app.get(
'than anything the code enumerates. Nothing is editable at runtime and every client',
'gets the same answer, so the routes are unauthenticated.',
'',
'Sections can also be fetched by id rather than by page: `/sections/bulk` filters',
'`static/sections.json`, the catalogue those layouts draw their rows from.',
'',
'A section names a feed rather than carrying its contents: the client resolves the',
'rooms, items and accounts behind each carousel against the `rooms` and `api` workers',
'itself.',
+30 -2
View File
@@ -30,15 +30,42 @@ export const PAGE_SOURCE_PARAM: OpenAPIV3_1.ParameterObject = {
description: [
'The page source — `WatchHome`, `PlayHighlight`, `CommunityBoard`, `PlayMenuTabs`,',
'`PlayCategories`, `StoreCategories`, `StoreFeatured`, `StoreClothing`,',
'`StoreConsumables`, `bulk` at the time of writing. It names a file in `static/`',
'`StoreConsumables` at the time of writing. It names a file in `static/`',
'(`<type>.json`) and is matched exactly, case included, so the set is whatever is',
'published rather than anything this worker enumerates.',
'',
'`sections` is a file in `static/` too but is not one of these: it is the id-keyed',
'catalogue `/sections/bulk` filters, not a page anything draws.',
].join(' '),
// Deliberately not an `enum`: the accepted values are the published files, and a spec
// that froze today's list would be wrong the moment one is added.
schema: { type: 'string', example: 'WatchHome' },
}
/**
* The repeated `?id=` query the bulk lookup selects on. Repetition, not a delimiter: the
* client sends `?id=A&id=B&id=C`, so this is `explode: true` form style rather than a
* single comma-joined value.
*/
export const SECTION_IDS_PARAM: OpenAPIV3_1.ParameterObject = {
name: 'id',
in: 'query',
required: false,
description: [
'A section id to look up, repeated once per section wanted. Ids that match nothing are',
'skipped rather than erroring, and repeating one still yields it once — the answer is',
'the catalogue filtered, so it can only ever be a subset of it. Omitting the parameter',
'entirely answers `[]`.',
].join(' '),
style: 'form',
explode: true,
schema: { type: 'array', items: { type: 'string' } },
example: [
'Rooms_New_PlayHighlight_TabsTest_Explore',
'RoomCategories_MoodPlaylists_FeelingLucky',
],
}
// ---- Response schemas ------------------------------------------------------
/** `GET /` — the liveness probe body. */
@@ -82,7 +109,8 @@ export const DiscoverySection = z.object({
})
/**
* `GET /sections/pagesource/{type}` — a page's sections, in the order they are drawn.
* A list of sections — `GET /sections/pagesource/{type}` in the order a page draws them,
* or `GET /sections/bulk` in the catalogue's order.
*
* The DTO accepts anything (its validator is a no-op), but the STORE page builder is much
* stricter and drops a section it doesn't like SILENTLY — no error reaches the client, the
+35
View File
@@ -35,3 +35,38 @@ export async function fetchPageSource(c: Context<App>, type: string): Promise<Re
const res = await c.env.ASSETS.fetch(new Request(new URL(`/${type}.json`, c.req.url), c.req.raw))
return res.ok || res.status === 304 ? res : null
}
/**
* The file holding every section the client can ask for by id — the union of the rows the
* page sources draw from, which `/sections/bulk` filters. It is a plain file in `static/`
* like the page layouts, but it is NOT a page source: nothing draws it as a page, so it is
* deliberately not reachable through `/sections/pagesource/:type` (`SAFE_NAME` would let it
* through; the route simply isn't what asks for it).
*/
export const SECTIONS_CATALOGUE = 'sections'
/**
* One row of a section file, as it is stored. Read as a bare record rather than a typed
* section because the rows are served back UNCHANGED — only `id` is ever looked at, and a
* field this worker doesn't model has to survive the round trip rather than be dropped by
* a projection.
*/
export type SectionRow = Record<string, unknown>
/**
* Read and parse `static/<name>.json`. `null` when no such file is published.
*
* Unlike `fetchPageSource` this does NOT forward the caller's request. The body is needed
* here to filter, and forwarding would let a caller whose `If-None-Match` happens to match
* the FILE's etag get a bodiless 304 — wrong for a response that is a subset of the file
* rather than the file itself.
*/
export async function readSections(c: Context<App>, name: string): Promise<SectionRow[] | null> {
if (!SAFE_NAME.test(name)) return null
const res = await c.env.ASSETS.fetch(new URL(`/${name}.json`, c.req.url))
if (!res.ok) return null
const rows: unknown = await res.json()
return Array.isArray(rows) ? (rows as SectionRow[]) : null
}
+109 -1
View File
@@ -12,7 +12,6 @@ const PAGE_SOURCES = [
'StoreFeatured',
'StoreClothing',
'StoreConsumables',
'bulk',
]
interface Section {
@@ -152,12 +151,121 @@ describe('GET /sections/pagesource/:type', () => {
})
})
describe('GET /sections/bulk', () => {
/** Fetch a set of section ids and return the parsed rows. */
async function bulk(ids: string[]) {
const query = ids.map((id) => `id=${encodeURIComponent(id)}`).join('&')
const res = await SELF.fetch(`https://discovery.example.com/sections/bulk?${query}`)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('application/json')
return (await res.json()) as Section[]
}
// The ids arrive as a REPEATED parameter, not a delimited one. Reading only the first
// would drop every row but one and leave the page nearly empty.
it('serves every id the query repeats', async () => {
const ids = [
'Rooms_New_PlayHighlight_TabsTest_Explore',
'RoomCategories_MoodPlaylists_FeelingLucky',
'Rooms_RecentlyUpdated_TabsTest_Explore',
'Rooms_Battle_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Quests_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Roleplay_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Horror_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Hangout_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Casual_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Explore_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
]
const sections = await bulk(ids)
expect(sections.map((s) => s.id)).toEqual(ids)
})
it('serves the catalogue rows verbatim', async () => {
const sections = await bulk(['RoomCategories_MoodPlaylists_FeelingLucky'])
expect(sections).toEqual([
{
id: 'RoomCategories_MoodPlaylists_FeelingLucky',
// RoomCategoryListSection.
sectionType: 12,
sectionSubType: 'RoomCategories',
source: 'CuratedList',
sourceMetadata: 'RoomCategories.MoodPlaylists.AlgoEndpoint.FeelingLucky',
displayMetadata: expect.stringContaining('"DisplayTitle":"I\'m Feeling Lucky"'),
},
])
})
// The answer is the catalogue filtered, so it is ordered by the FILE and not by the
// query, and an id can only ever come back once however many times it is asked for.
it('answers in catalogue order regardless of the query order', async () => {
const sections = await bulk([
'Rooms_RecentlyUpdated_TabsTest_Explore',
'Rooms_New_PlayHighlight_TabsTest_Explore',
])
expect(sections.map((s) => s.id)).toEqual([
'Rooms_New_PlayHighlight_TabsTest_Explore',
'Rooms_RecentlyUpdated_TabsTest_Explore',
])
})
it('yields a repeated id once', async () => {
const sections = await bulk([
'Rooms_New_PlayHighlight_TabsTest_Explore',
'Rooms_New_PlayHighlight_TabsTest_Explore',
])
expect(sections.map((s) => s.id)).toEqual(['Rooms_New_PlayHighlight_TabsTest_Explore'])
})
// An unknown id is left out rather than erroring: one stale id in a page's list must not
// take the rest of the page down with it.
it('skips ids that match nothing', async () => {
const sections = await bulk(['nope', 'Rooms_MyRooms_Play', 'also-nope'])
expect(sections.map((s) => s.id)).toEqual(['Rooms_MyRooms_Play'])
})
it('answers an empty array when no id matches', async () => {
expect(await bulk(['nope'])).toEqual([])
})
// No ids asked for means nothing wanted — NOT the whole catalogue.
it('answers an empty array when the query names no ids', async () => {
const res = await SELF.fetch('https://discovery.example.com/sections/bulk')
expect(res.status).toBe(200)
expect(await res.json()).toEqual([])
})
// The catalogue is a file in `static/` like the layouts, so the page-source route reaches
// it too — `{type}` is the filename and nothing indexes which files are pages. Harmless
// and asserted so the overlap is a known fact rather than a surprise; the client asks for
// the catalogue through this route's `?id=` filter, never as a page.
it('is also reachable through the page-source route, unfiltered', async () => {
const res = await SELF.fetch('https://discovery.example.com/sections/pagesource/sections')
expect(res.status).toBe(200)
expect((await res.json()) as Section[]).toHaveLength(14)
})
// The response is a SUBSET of the file, so it must never be answered with the file's
// etag — a client that cached the file would otherwise be told its copy is still good.
it('ignores a conditional request matching the catalogue file', async () => {
const sections = await bulk(['Rooms_MyRooms_Play'])
expect(sections).toHaveLength(1)
const res = await SELF.fetch(
'https://discovery.example.com/sections/bulk?id=Rooms_MyRooms_Play',
{ headers: { 'if-none-match': '"anything"' } }
)
expect(res.status).toBe(200)
expect((await res.json()) as Section[]).toHaveLength(1)
})
})
describe('GET /openapi.json', () => {
it('generates a spec with no dangling $refs', async () => {
const res = await SELF.fetch('https://discovery.example.com/openapi.json')
expect(res.status).toBe(200)
const spec = (await res.json()) as { paths: Record<string, unknown> }
expect(Object.keys(spec.paths)).toContain('/sections/pagesource/{type}')
expect(Object.keys(spec.paths)).toContain('/sections/bulk')
expect(JSON.stringify(spec).match(/\$ref/g)).toBeNull()
})
})
-114
View File
@@ -1,114 +0,0 @@
[
{
"id": "Rooms_New_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_New",
"source": "Hot",
"sourceMetadata": "new",
"displayMetadata": "{\"DisplayTitle\":\"New\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\",\"unsupportedPlatforms\":[\"Switch\"]}"
},
{
"id": "RoomCategories_MoodPlaylists_FeelingLucky",
"sectionType": 12,
"sectionSubType": "RoomCategories",
"source": "CuratedList",
"sourceMetadata": "RoomCategories.MoodPlaylists.AlgoEndpoint.FeelingLucky",
"displayMetadata": "{\"DisplayTitle\":\"I'm Feeling Lucky\",\"unsupportedPlatforms\":[\"Switch\",\"Pico\",\"Oculus\"], \"unsupportedInteractionCategories\":[\"VR\"]}"
},
{
"id": "Rooms_RecentlyUpdated_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_RecentlyUpdated",
"source": "CarouselEndpoint",
"sourceMetadata": "recentlyupdated",
"displayMetadata": "{\"DisplayTitle\":\"Recently Updated\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Battle_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Battle",
"source": "CarouselEndpoint",
"sourceMetadata": "battle_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Battle\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Quests_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Quests",
"source": "CarouselEndpoint",
"sourceMetadata": "quests_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Quests\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Roleplay_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Roleplay",
"source": "CarouselEndpoint",
"sourceMetadata": "roleplay_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Roleplay\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Horror_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Horror",
"source": "CarouselEndpoint",
"sourceMetadata": "horror_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Horror\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Hangout_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Hangout",
"source": "CarouselEndpoint",
"sourceMetadata": "hangout_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Hangout\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Casual_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Casual",
"source": "CarouselEndpoint",
"sourceMetadata": "casual_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Casual\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Explore_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Exploration",
"source": "CarouselEndpoint",
"sourceMetadata": "explore_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Explore\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_ContinuePlaying_PlayLibrary",
"sectionType": 0,
"sectionSubType": "Rooms_ContinuePlaying",
"source": "Recent",
"sourceMetadata": null,
"displayMetadata": "{\"DisplayTitle\":\"Continue Playing\",\"supportsDedupe\":\"false\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_SavedForLater_PlayHighlight",
"sectionType": 0,
"sectionSubType": "Rooms_SavedForLater",
"source": "MyPlaylistByName",
"sourceMetadata": "__SavedForLater_Rooms",
"displayMetadata": "{\"DisplayTitle\":\"Saved for Later\",\"supportsDedupe\":\"false\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\", \"minItemsToShowSection\": 1}"
},
{
"id": "Rooms_Favorites_PlayLibrary",
"sectionType": 0,
"sectionSubType": "Rooms_PlayerFavorites",
"source": "MyFavorites",
"sourceMetadata": null,
"displayMetadata": "{\"DisplayTitle\":\"Favorites\",\"supportsDedupe\":\"false\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_MyRooms_Play",
"sectionType": 0,
"sectionSubType": "myrooms",
"source": "MyCreatedRooms",
"sourceMetadata": null,
"displayMetadata": "{\"DisplayTitle\":\"My Rooms\"}"
}
]
+114
View File
@@ -0,0 +1,114 @@
[
{
"id": "Rooms_New_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_New",
"source": "Hot",
"sourceMetadata": "new",
"displayMetadata": "{\"DisplayTitle\":\"New\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\",\"unsupportedPlatforms\":[\"Switch\"]}"
},
{
"id": "RoomCategories_MoodPlaylists_FeelingLucky",
"sectionType": 12,
"sectionSubType": "RoomCategories",
"source": "CuratedList",
"sourceMetadata": "RoomCategories.MoodPlaylists.AlgoEndpoint.FeelingLucky",
"displayMetadata": "{\"DisplayTitle\":\"I'm Feeling Lucky\",\"unsupportedPlatforms\":[\"Switch\",\"Pico\",\"Oculus\"], \"unsupportedInteractionCategories\":[\"VR\"]}"
},
{
"id": "Rooms_RecentlyUpdated_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_RecentlyUpdated",
"source": "CarouselEndpoint",
"sourceMetadata": "recentlyupdated",
"displayMetadata": "{\"DisplayTitle\":\"Recently Updated\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Battle_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Battle",
"source": "CarouselEndpoint",
"sourceMetadata": "battle_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Battle\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Quests_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Quests",
"source": "CarouselEndpoint",
"sourceMetadata": "quests_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Quests\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Roleplay_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Roleplay",
"source": "CarouselEndpoint",
"sourceMetadata": "roleplay_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Roleplay\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Horror_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Horror",
"source": "CarouselEndpoint",
"sourceMetadata": "horror_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Horror\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Hangout_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Hangout",
"source": "CarouselEndpoint",
"sourceMetadata": "hangout_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Hangout\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Casual_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Casual",
"source": "CarouselEndpoint",
"sourceMetadata": "casual_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Casual\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_Explore_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"sectionType": 0,
"sectionSubType": "Rooms_Exploration",
"source": "CarouselEndpoint",
"sourceMetadata": "explore_algoendpoint",
"displayMetadata": "{\"DisplayTitle\":\"Explore\",\"supportsDedupe\":\"true\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_ContinuePlaying_PlayLibrary",
"sectionType": 0,
"sectionSubType": "Rooms_ContinuePlaying",
"source": "Recent",
"sourceMetadata": null,
"displayMetadata": "{\"DisplayTitle\":\"Continue Playing\",\"supportsDedupe\":\"false\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_SavedForLater_PlayHighlight",
"sectionType": 0,
"sectionSubType": "Rooms_SavedForLater",
"source": "MyPlaylistByName",
"sourceMetadata": "__SavedForLater_Rooms",
"displayMetadata": "{\"DisplayTitle\":\"Saved for Later\",\"supportsDedupe\":\"false\",\"numRows\":\"1\",\"sizePerPlatform\":\"\",\"backgroundColor\":\"\",\"horizontalScrollEnabled\":\"false\", \"minItemsToShowSection\": 1}"
},
{
"id": "Rooms_Favorites_PlayLibrary",
"sectionType": 0,
"sectionSubType": "Rooms_PlayerFavorites",
"source": "MyFavorites",
"sourceMetadata": null,
"displayMetadata": "{\"DisplayTitle\":\"Favorites\",\"supportsDedupe\":\"false\",\"horizontalScrollEnabled\":\"false\"}"
},
{
"id": "Rooms_MyRooms_Play",
"sectionType": 0,
"sectionSubType": "myrooms",
"source": "MyCreatedRooms",
"sourceMetadata": null,
"displayMetadata": "{\"DisplayTitle\":\"My Rooms\"}"
}
]
+144
View File
@@ -0,0 +1,144 @@
import curatedLists from '../static/curated-lists.json'
/**
* Which PAGE a curated list belongs to — the `Type` on the list and the `?type=` the client
* asks with. Not to be confused with the entity-type enum the algorithmic lists echo: this
* one names a page source (the Watch home, the store's Featured tab, …), and the list it
* keys is that page's row set.
*
* `None` is the client's unset sentinel; it never reaches the wire, and nothing is captured
* under it.
*/
export const CuratedListType = {
WatchHome: 0,
PlayHighlight: 1,
CommunityBoard: 2,
MobileHome: 3,
StoreFeatured: 4,
StoreClothing: 5,
StoreConsumables: 6,
PlayCategories: 7,
StoreInventions: 8,
TitleScreen: 9,
PlayMenuTabs: 10,
OrientationStoreFeatured: 11,
AppNavPortalPanel: 12,
WWPanelList: 13,
RecRoomPlusBenefits: 14,
RecCenterStorefront: 15,
RecCenterCommunityContent: 16,
None: 999,
} as const
/**
* One curated list as the client parses it. `Description` may be null but `ImageName` must
* be a STRING — the client reads it straight into a string field — and `ItemIds` are
* strings even where they stand for numeric ids.
*
* `ListId` is a string HERE ONLY, and never reaches the client as one: see
* `serializeCuratedList`. `Accessibility` rides along from the captures untouched.
*/
export interface CuratedList {
ListId: string
CreatorAccountId: number
Name: string
Description: string | null
ImageName: string
Type: number
ItemIds: string[]
Accessibility?: number
CreatedAt: string
}
/**
* Every list this server serves, all of them in `static/curated-lists.json` — drop a
* capture into that array and it is served; nothing else needs editing. (One file per list
* would read better, but wrangler bundles with esbuild, which has no glob import: a
* directory can only be picked up by naming every file in an `import`. `import.meta.glob`
* is a Vite feature — vitest runs through Vite and would resolve it, while the deployed
* build ships the call verbatim and throws at runtime, so tests would pass and the worker
* would not.)
*
* Nothing curates lists here, so these are static captures; ORDER matters only in that the
* first list of a given type is that page's default (see `BY_TYPE`).
*
* `ItemIds` are the discovery ROWS each page is built from (the section keys the `discovery`
* worker serves under `/sections/pagesource/*`), not room or item ids: the client resolves
* each row itself.
*/
const CURATED_LISTS: CuratedList[] = curatedLists
/**
* A list's `ListId` is the reference's own, and those are 64-bit
* (`624765592684307326`) — past what a JS number holds exactly, so parsing one rounds it
* (…307326 → …307300). The captures therefore carry it as a STRING, which survives the
* round trip, and this puts the digits back on the wire unquoted: the client's field is a
* number, and a quoted id fails its parser.
*
* Only a run of digits is unquoted, so a malformed id is left alone rather than corrupting
* the JSON — the integration tests assert every capture has one.
*/
export function serializeCuratedList(list: CuratedList): string {
return JSON.stringify(list).replace(/"ListId":"(\d+)"/, '"ListId":$1')
}
/** Names are matched case-insensitively — the casing that reaches us is the client's. */
function nameKey(name: string): string {
return name.toLowerCase()
}
/** All three keys the query carries. The most specific match wins. */
const BY_CREATOR_TYPE_NAME = new Map<string, CuratedList>()
/** Same list without the creator — the client sometimes asks with a creator nothing owns. */
const BY_TYPE_NAME = new Map<string, CuratedList>()
/** By name alone, for a name whose `type` doesn't line up with what it is captured under. */
const BY_NAME = new Map<string, CuratedList>()
/**
* By type alone: the page's DEFAULT list, which is what answers a `name` this server has
* nothing under — the store's Featured page, for one, asks for its rows by the reference's
* numeric list id (`name=17859340`) rather than by a name.
*/
const BY_TYPE = new Map<number, CuratedList>()
for (const list of CURATED_LISTS) {
const name = nameKey(list.Name)
BY_CREATOR_TYPE_NAME.set(`${list.CreatorAccountId}/${list.Type}/${name}`, list)
if (!BY_TYPE_NAME.has(`${list.Type}/${name}`)) BY_TYPE_NAME.set(`${list.Type}/${name}`, list)
if (!BY_NAME.has(name)) BY_NAME.set(name, list)
// First captured wins, so a page with more than one list defaults to whichever sits
// earliest in `static/curated-lists.json`.
if (!BY_TYPE.has(list.Type)) BY_TYPE.set(list.Type, list)
}
/**
* What a request that matches nothing at all gets: the Play menu's Explore panel. An empty
* body — or a 404 — renders as a blank page rather than an error, so answering with
* SOMETHING is the better failure, and it is what the reference was observed doing for a
* list it had nothing under. Falls back to whatever sits first if that capture is ever
* dropped from the array.
*/
const DEFAULT_CURATED_LIST =
BY_NAME.get(nameKey('Discovery.PageSource.PlayExplore')) ?? CURATED_LISTS[0]
/**
* The list behind `GET /curatedlists?creatorAccountId=&type=&name=`, resolved most-specific
* first and never empty. `type` is the page asking (see `CuratedListType`) and dictates the
* name, so the two normally agree; when they don't, the page's default list still answers.
*/
export function resolveCuratedList(
creatorAccountId: string | undefined,
type: string | undefined,
name: string | undefined
): CuratedList {
const key = nameKey(name ?? '')
const parsedType = Number.parseInt(type ?? '', 10)
const hasType = Number.isInteger(parsedType)
return (
(hasType ? BY_CREATOR_TYPE_NAME.get(`${creatorAccountId}/${parsedType}/${key}`) : undefined) ??
(hasType ? BY_TYPE_NAME.get(`${parsedType}/${key}`) : undefined) ??
BY_NAME.get(key) ??
(hasType ? BY_TYPE.get(parsedType) : undefined) ??
DEFAULT_CURATED_LIST
)
}
+32 -88
View File
@@ -5,6 +5,8 @@ import { getHotRooms } from '@repo/domain'
import { withNotFound, withOnError } from '@repo/hono-helpers'
import { validateAndGetAccountId } from '@repo/jwt'
import { resolveCuratedList, serializeCuratedList } from './curated-lists'
import type { Context } from 'hono'
import type { App } from './context'
@@ -22,8 +24,10 @@ function unauthorized(c: Context<App>) {
}
/**
* What the ids in a list ARE. One enum shared by the curated lists' `Type` and the
* algorithmic lists' — a BYTE on the client, so only 0255 round-trips.
* What the ids in an ALGORITHMIC list are — a BYTE on the client, so only 0255
* round-trips. Not the curated lists' `Type`, which names the page a list belongs to
* (see `CuratedListType` in `curated-lists.ts`); the two are different enums that happen to
* share a field name.
*
* It is what tells the client which service to resolve the ids against, which is why the
* algorithmic route echoes back the type it was asked for rather than asserting one of its
@@ -45,64 +49,6 @@ const ListEntityType = {
/** The largest value the client's byte-wide `Type` can carry back. */
const MAX_LIST_ENTITY_TYPE = 255
/**
* The canned discovery pages served by `GET /curatedlists`, keyed by the `Name` the client
* asks for. `ItemIds` are discovery row keys (strings), `Description` is null but
* `ImageName` must be a string, and `CreatedAt` keeps its 7-digit fractional seconds — all
* as the client's parser expects them.
*
* `ListId` is ours to choose and is deliberately SMALL. The reference's ids are 64-bit
* (`624765592684307326`), past what a JS number holds exactly, so serving them verbatim
* meant carrying them as bigints and hand-writing the JSON to keep the digits — machinery
* for an id nothing on this server looks up. What the id has to be is stable and unique
* per page: the client caches a list against it, so two pages sharing one id would serve
* each other's rows from cache, and changing a page's id would drop its cache. Renumber
* only when the page's contents are meant to be re-fetched.
*/
const CURATED_LISTS = [
{
ListId: 1,
CreatorAccountId: 1,
Name: 'Discovery.PageSource.PlayExplore',
Description: null,
ImageName: 'DefaultRoomImage.jpg',
Type: ListEntityType.DiscoverySection,
ItemIds: [
'Rooms_New_PlayHighlight_TabsTest_Explore',
'RoomCategories_MoodPlaylists_FeelingLucky',
'Rooms_RecentlyUpdated_TabsTest_Explore',
'Rooms_Battle_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Quests_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Roleplay_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Horror_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Hangout_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Casual_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Explore_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
],
Accessibility: 1,
CreatedAt: '2025-04-23T18:27:03.2643786Z',
},
{
ListId: 2,
CreatorAccountId: 1,
Name: 'Discovery.PageSource.PlayLibrary',
Description: null,
ImageName: 'DefaultRoomImage.jpg',
Type: ListEntityType.DiscoverySection,
// The library page: what you were playing, saved, favorited and made. Note the
// second row is a `PlayHighlight` key rather than a `PlayLibrary` one — that is the
// reference's own naming, not a typo to tidy.
ItemIds: [
'Rooms_ContinuePlaying_PlayLibrary',
'Rooms_SavedForLater_PlayHighlight',
'Rooms_Favorites_PlayLibrary',
'Rooms_MyRooms_Play',
],
Accessibility: 1,
CreatedAt: '2025-04-23T18:25:31.5308539Z',
},
]
/**
* The entities an algorithmic list hands back (`GET /algorithmiclists/:list`) — ROOMS, which
* is what a Play/Explore row is built from. Nothing ranks anything here yet, so one canned
@@ -149,21 +95,6 @@ const HOT_LIST_FEED = 'community'
*/
const DEFAULT_ALGORITHMIC_LIST_TYPE = ListEntityType.Rooms
/**
* Each page keyed by its lowercased `Name` — the only thing a request decides is which one
* it gets. Each entry is an ARRAY of one list: the endpoint answers a collection, and the
* client reads it as such however many entries come back.
*/
const CURATED_LIST_PAGES = new Map(CURATED_LISTS.map((list) => [list.Name.toLowerCase(), [list]]))
/**
* What a request that names no page — or names one nothing is captured for — gets: the
* Explore page. A 404 or an empty array renders as an empty Play page, so answering with
* SOMETHING is the better failure, and it is also what the reference was observed doing
* for a `name` this server has no list under.
*/
const DEFAULT_CURATED_LIST_PAGE = CURATED_LIST_PAGES.get('discovery.pagesource.playexplore')!
const app = new Hono<App>()
.use(
'*',
@@ -202,22 +133,35 @@ const app = new Hono<App>()
])
})
// The curated lists behind a discovery page (`GET /curatedlists`). The client asks with
// `?creatorAccountId=&type=&name=`, and `name` is the page it wants
// (`Discovery.PageSource.PlayExplore`, `…PlayLibrary`): it picks which canned page comes
// back, matched case-insensitively. Nothing curates lists here, so the pages are static
// captures — the same stand-in posture as `/curatedlists/bulk` above.
// The curated list behind a discovery page (`GET /curatedlists`). The client asks with
// `?creatorAccountId=&type=&name=` and reads back ONE list object — not a collection.
// `type` is the page asking (`CuratedListType`: the Watch home, the store's Featured tab,
// …) and dictates the name it asks under, so the two normally agree.
//
// `creatorAccountId` and `type` are accepted and IGNORED, deliberately: the reference was
// observed answering a `type=5` request with a `Type` 7 list, so filtering on them would
// answer nothing where it answers a page. An unknown `name` falls back to Explore rather
// than an empty array, which renders as an empty Play page.
// Nothing curates lists here, so every list is a static capture in
// `static/curated-lists.json`. `resolveCuratedList` matches most-specific first and
// always answers something: a name this server has nothing under falls back to the page's
// default list rather than 404ing or answering empty, either of which renders as a blank
// page. That fallback is load-bearing — the store's Featured page asks for its rows by the
// reference's numeric list id (`name=17859340`), which is nothing's name here.
//
// `ItemIds` are the discovery ROWS the page is built from (algorithm/section keys), not
// room ids — the client resolves each one itself.
// `ItemIds` are the discovery ROWS the page is built from (the section keys the `discovery`
// worker serves), not room or item ids — the client resolves each one itself.
.get('/curatedlists', async (c) => {
const name = c.req.query('name') ?? ''
return c.json(CURATED_LIST_PAGES.get(name.toLowerCase()) ?? DEFAULT_CURATED_LIST_PAGE)
// Serialized by hand rather than through `c.json`: the reference's `ListId`s are
// 64-bit and are carried as strings so their digits survive being parsed — see
// `serializeCuratedList`, which puts them back on the wire as numbers.
return c.body(
serializeCuratedList(
resolveCuratedList(
c.req.query('creatorAccountId'),
c.req.query('type'),
c.req.query('name')
)
),
200,
{ 'content-type': 'application/json' }
)
})
// One discovery ROW's contents (`GET /algorithmiclists/:list?type=1`). `:list` is the row
+80 -61
View File
@@ -103,24 +103,28 @@ it('serves the canned curated-list bulk lookup', async () => {
])
})
it('serves the canned discovery page from /curatedlists', async () => {
it('serves one curated list object, not a collection', async () => {
// The client reads a single list off this endpoint — a bare object, not an array.
const res = await SELF.fetch(
`${ORIGIN}/curatedlists?creatorAccountId=1&type=5&name=RoomGenreTags`
`${ORIGIN}/curatedlists?creatorAccountId=1&type=7&name=Discovery.PageSource.PlayExplore`
)
expect(res.status).toBe(200)
expect(res.headers.get('content-type')).toContain('application/json')
const text = await res.text()
expect(JSON.parse(text)).toMatchObject([
{
ListId: 1,
const body = await res.text()
// The reference's list ids are 64-bit: they must reach the client as an unquoted number
// with every digit intact, which parsing them here would round away (…307326 → …307300).
expect(body).toContain('"ListId":624765592684307326')
// Compared without the id: a literal here would round the same way the parse does, so
// the digits are checked on the raw body above and everything else on the object.
const { ListId: _id, ...rest } = JSON.parse(body) as Record<string, unknown>
expect(rest).toEqual({
CreatorAccountId: 1,
Name: 'Discovery.PageSource.PlayExplore',
Description: null,
ImageName: 'DefaultRoomImage.jpg',
Type: 7,
Accessibility: 1,
CreatedAt: '2025-04-23T18:27:03.2643786Z',
ItemIds: [
'Rooms_New_PlayHighlight_TabsTest_Explore',
'RoomCategories_MoodPlaylists_FeelingLucky',
@@ -133,69 +137,84 @@ it('serves the canned discovery page from /curatedlists', async () => {
'Rooms_Casual_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
'Rooms_Explore_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
],
},
])
})
it('serves the PlayLibrary page when the query names it', async () => {
const res = await SELF.fetch(
`${ORIGIN}/curatedlists?creatorAccountId=1&type=5&name=Discovery.PageSource.PlayLibrary`
)
expect(res.status).toBe(200)
const text = await res.text()
expect(JSON.parse(text)).toMatchObject([
{
ListId: 2,
CreatorAccountId: 1,
Name: 'Discovery.PageSource.PlayLibrary',
Description: null,
ImageName: 'DefaultRoomImage.jpg',
Type: 7,
Accessibility: 1,
CreatedAt: '2025-04-23T18:25:31.5308539Z',
ItemIds: [
'Rooms_ContinuePlaying_PlayLibrary',
'Rooms_SavedForLater_PlayHighlight',
'Rooms_Favorites_PlayLibrary',
'Rooms_MyRooms_Play',
],
},
])
// The name is matched case-insensitively, and `type` is not filtered on — the request
// above asks for type 5 and gets the type 7 list, as the reference does.
const lower = await SELF.fetch(`${ORIGIN}/curatedlists?name=discovery.pagesource.playlibrary`)
expect(await lower.text()).toBe(text)
CreatedAt: '2025-04-23T18:27:03.2643786Z',
})
})
it('gives each curated page a distinct, JS-safe ListId', async () => {
// The client caches a list against its ListId, so two pages must never share one — and
// an id past Number.MAX_SAFE_INTEGER would round on the way through JSON, which is why
// these are small numbers of our own rather than the reference's 64-bit ones.
const pages = ['Discovery.PageSource.PlayExplore', 'Discovery.PageSource.PlayLibrary']
const ids: number[] = []
for (const name of pages) {
const [list] = (await (
await SELF.fetch(`${ORIGIN}/curatedlists?name=${name}`)
).json()) as Array<{ ListId: number }>
expect(Number.isSafeInteger(list.ListId)).toBe(true)
ids.push(list.ListId)
it('serves every capture in static/curated-lists.json by name', async () => {
// One array holds every list, and each entry must be reachable by the keys the client
// asks with. `ImageName` must be a string even when empty — the client parses it into
// one — while `Description` may be null. The ids the client caches against have to be
// unique and reach it with their digits intact.
const names = [
'Discovery.PageSource.PlayExplore',
'Discovery.PageSource.PlayLibrary',
'RoomCategories.MoodPlaylists.AlgoEndpoint.FeelingLucky',
]
const seen = new Set<string>()
for (const name of names) {
const res = await SELF.fetch(`${ORIGIN}/curatedlists?creatorAccountId=1&type=7&name=${name}`)
expect(res.status).toBe(200)
const body = await res.text()
// Never a quoted id: the client's field is a number.
expect(body).toMatch(/"ListId":\d+,/)
const list = JSON.parse(body) as {
ListId: number
Type: number
Name: string
ItemIds: string[]
Description: string | null
ImageName: string
}
expect(list.Name).toBe(name)
expect(list.Type).toBe(7)
expect(list.ItemIds.length).toBeGreaterThan(0)
expect(typeof list.ImageName).toBe('string')
const id = /"ListId":(\d+),/.exec(body)?.[1]
expect(seen.has(id!)).toBe(false)
seen.add(id!)
}
expect(new Set(ids).size).toBe(ids.length)
})
it('falls back to the Explore page for a name it has nothing for', async () => {
// An empty array renders as an empty Play page, so an unknown page source answers
// SOMETHING — which is also what the reference was observed doing for RoomGenreTags.
it('matches the name case-insensitively and prefers it over the type', async () => {
const canonical = await (
await SELF.fetch(
`${ORIGIN}/curatedlists?creatorAccountId=1&type=7&name=Discovery.PageSource.PlayLibrary`
)
).text()
expect(canonical).toContain('"ListId":5321092632685904804')
expect(JSON.parse(canonical)).toMatchObject({ Name: 'Discovery.PageSource.PlayLibrary', Type: 7 })
// Casing reaching us is the client's, not ours.
const lower = await SELF.fetch(`${ORIGIN}/curatedlists?name=discovery.pagesource.playlibrary`)
expect(await lower.text()).toBe(canonical)
// Every capture shares type 7, so the name is what tells them apart — a request naming
// Library must never come back with Explore's rows, and the type alone answers with the
// page default (the first list in the array).
const explore = await SELF.fetch(`${ORIGIN}/curatedlists?type=7`)
expect(((await explore.json()) as { Name: string }).Name).toBe('Discovery.PageSource.PlayExplore')
})
it('falls back to the default list for a name it has nothing under', async () => {
// The store's Featured page asks for its rows by the reference's numeric list id rather
// than by a name — nothing here is called that, and nothing is captured under its type
// either, so the default list answers. An empty body or a 404 would render as a blank
// page rather than an error.
const explore = await (
await SELF.fetch(`${ORIGIN}/curatedlists?name=Discovery.PageSource.PlayExplore`)
).text()
for (const query of ['?type=99&name=Nope', '?creatorAccountId=1&type=5&name=RoomGenreTags', '']) {
const res = await SELF.fetch(`${ORIGIN}/curatedlists${query}`)
expect(res.status).toBe(200)
expect(await res.text()).toBe(explore)
for (const query of [
'?creatorAccountId=1&type=4&name=17859340',
'?type=99&name=Nope',
'?creatorAccountId=7&type=&name=',
'',
]) {
const bare = await SELF.fetch(`${ORIGIN}/curatedlists${query}`)
expect(bare.status).toBe(200)
expect(await bare.text()).toBe(explore)
}
})
+58
View File
@@ -0,0 +1,58 @@
[
{
"ListId": "624765592684307326",
"CreatorAccountId": 1,
"Name": "Discovery.PageSource.PlayExplore",
"Description": null,
"ImageName": "DefaultRoomImage.jpg",
"Type": 7,
"ItemIds": [
"Rooms_New_PlayHighlight_TabsTest_Explore",
"RoomCategories_MoodPlaylists_FeelingLucky",
"Rooms_RecentlyUpdated_TabsTest_Explore",
"Rooms_Battle_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"Rooms_Quests_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"Rooms_Roleplay_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"Rooms_Horror_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"Rooms_Hangout_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"Rooms_Casual_AlgoEndpoint_PlayHighlight_TabsTest_Explore",
"Rooms_Explore_AlgoEndpoint_PlayHighlight_TabsTest_Explore"
],
"Accessibility": 1,
"CreatedAt": "2025-04-23T18:27:03.2643786Z"
},
{
"ListId": "5321092632685904804",
"CreatorAccountId": 1,
"Name": "Discovery.PageSource.PlayLibrary",
"Description": null,
"ImageName": "DefaultRoomImage.jpg",
"Type": 7,
"ItemIds": [
"Rooms_ContinuePlaying_PlayLibrary",
"Rooms_SavedForLater_PlayHighlight",
"Rooms_Favorites_PlayLibrary",
"Rooms_MyRooms_Play"
],
"Accessibility": 1,
"CreatedAt": "2025-04-23T18:25:31.5308539Z"
},
{
"ListId": "8578579969342570774",
"CreatorAccountId": 1,
"Name": "RoomCategories.MoodPlaylists.AlgoEndpoint.FeelingLucky",
"Description": "I'm Feeling Lucky",
"ImageName": "DefaultRoomImage.jpg",
"Type": 7,
"ItemIds": [
"action_algorithmiclist_roomcategory",
"hangout_roomcategory_card",
"horror_roomcategory_card",
"pvp_roomcategory_card",
"quests_roomcategory_card",
"roleplay_roomcategory_card"
],
"Accessibility": 1,
"CreatedAt": "2024-05-22T05:37:43.7726633Z"
}
]