mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
[discovery][lists] semi working lists and discovery
This commit is contained in:
@@ -4,17 +4,23 @@ import { useWorkersLogger } from 'workers-tagged-logger'
|
|||||||
|
|
||||||
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
import { withCleanSpec, withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
|
|
||||||
import { DiscoverySections, json, PAGE_SOURCE_PARAM, ServiceStatus } from './openapi'
|
import {
|
||||||
import { fetchPageSource } from './page-sources'
|
DiscoverySections,
|
||||||
|
json,
|
||||||
|
PAGE_SOURCE_PARAM,
|
||||||
|
SECTION_IDS_PARAM,
|
||||||
|
ServiceStatus,
|
||||||
|
} from './openapi'
|
||||||
|
import { fetchPageSource, readSections, SECTIONS_CATALOGUE } from './page-sources'
|
||||||
|
|
||||||
import type { App } from './context'
|
import type { App } from './context'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Discovery Worker. Serves the layout of the client's discovery pages — which carousels a
|
* 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
|
* 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
|
* ASSETS binding (see `page-sources.ts`), plus `sections.json`, the id-keyed catalogue the
|
||||||
* section names a client-side feed the client resolves against the `rooms`/`api` workers
|
* bulk lookup filters. It does not serve the carousels' CONTENTS: each section names a
|
||||||
* itself.
|
* 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
|
* Unauthenticated: every client gets the same layout, and the client fetches this before
|
||||||
* anything player-specific.
|
* anything player-specific.
|
||||||
@@ -45,6 +51,57 @@ const app = new Hono<App>()
|
|||||||
(c) => c.json({ service: 'discovery', status: 'ok' })
|
(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',
|
||||||
|
'catalogue’s order rather than the query’s, 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',
|
||||||
|
'doesn’t 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`.
|
// One discovery page's section layout, served verbatim from `static/<type>.json`.
|
||||||
.get(
|
.get(
|
||||||
'/sections/pagesource/:type',
|
'/sections/pagesource/:type',
|
||||||
@@ -104,6 +161,9 @@ app.get(
|
|||||||
'than anything the code enumerates. Nothing is editable at runtime and every client',
|
'than anything the code enumerates. Nothing is editable at runtime and every client',
|
||||||
'gets the same answer, so the routes are unauthenticated.',
|
'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',
|
'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',
|
'rooms, items and accounts behind each carousel against the `rooms` and `api` workers',
|
||||||
'itself.',
|
'itself.',
|
||||||
|
|||||||
@@ -30,15 +30,42 @@ export const PAGE_SOURCE_PARAM: OpenAPIV3_1.ParameterObject = {
|
|||||||
description: [
|
description: [
|
||||||
'The page source — `WatchHome`, `PlayHighlight`, `CommunityBoard`, `PlayMenuTabs`,',
|
'The page source — `WatchHome`, `PlayHighlight`, `CommunityBoard`, `PlayMenuTabs`,',
|
||||||
'`PlayCategories`, `StoreCategories`, `StoreFeatured`, `StoreClothing`,',
|
'`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',
|
'(`<type>.json`) and is matched exactly, case included, so the set is whatever is',
|
||||||
'published rather than anything this worker enumerates.',
|
'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(' '),
|
].join(' '),
|
||||||
// Deliberately not an `enum`: the accepted values are the published files, and a spec
|
// 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.
|
// that froze today's list would be wrong the moment one is added.
|
||||||
schema: { type: 'string', example: 'WatchHome' },
|
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 ------------------------------------------------------
|
// ---- Response schemas ------------------------------------------------------
|
||||||
|
|
||||||
/** `GET /` — the liveness probe body. */
|
/** `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
|
* 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
|
* stricter and drops a section it doesn't like SILENTLY — no error reaches the client, the
|
||||||
|
|||||||
@@ -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))
|
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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ const PAGE_SOURCES = [
|
|||||||
'StoreFeatured',
|
'StoreFeatured',
|
||||||
'StoreClothing',
|
'StoreClothing',
|
||||||
'StoreConsumables',
|
'StoreConsumables',
|
||||||
'bulk',
|
|
||||||
]
|
]
|
||||||
|
|
||||||
interface Section {
|
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', () => {
|
describe('GET /openapi.json', () => {
|
||||||
it('generates a spec with no dangling $refs', async () => {
|
it('generates a spec with no dangling $refs', async () => {
|
||||||
const res = await SELF.fetch('https://discovery.example.com/openapi.json')
|
const res = await SELF.fetch('https://discovery.example.com/openapi.json')
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const spec = (await res.json()) as { paths: Record<string, unknown> }
|
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/pagesource/{type}')
|
||||||
|
expect(Object.keys(spec.paths)).toContain('/sections/bulk')
|
||||||
expect(JSON.stringify(spec).match(/\$ref/g)).toBeNull()
|
expect(JSON.stringify(spec).match(/\$ref/g)).toBeNull()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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\"}"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -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\"}"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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
@@ -5,6 +5,8 @@ import { getHotRooms } from '@repo/domain'
|
|||||||
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
import { withNotFound, withOnError } from '@repo/hono-helpers'
|
||||||
import { validateAndGetAccountId } from '@repo/jwt'
|
import { validateAndGetAccountId } from '@repo/jwt'
|
||||||
|
|
||||||
|
import { resolveCuratedList, serializeCuratedList } from './curated-lists'
|
||||||
|
|
||||||
import type { Context } from 'hono'
|
import type { Context } from 'hono'
|
||||||
import type { App } from './context'
|
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
|
* What the ids in an ALGORITHMIC list are — a BYTE on the client, so only 0–255
|
||||||
* algorithmic lists' — a BYTE on the client, so only 0–255 round-trips.
|
* 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
|
* 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
|
* 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. */
|
/** The largest value the client's byte-wide `Type` can carry back. */
|
||||||
const MAX_LIST_ENTITY_TYPE = 255
|
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
|
* 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
|
* 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
|
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>()
|
const app = new Hono<App>()
|
||||||
.use(
|
.use(
|
||||||
'*',
|
'*',
|
||||||
@@ -202,22 +133,35 @@ const app = new Hono<App>()
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
// The curated lists behind a discovery page (`GET /curatedlists`). The client asks with
|
// The curated list behind a discovery page (`GET /curatedlists`). The client asks with
|
||||||
// `?creatorAccountId=&type=&name=`, and `name` is the page it wants
|
// `?creatorAccountId=&type=&name=` and reads back ONE list object — not a collection.
|
||||||
// (`Discovery.PageSource.PlayExplore`, `…PlayLibrary`): it picks which canned page comes
|
// `type` is the page asking (`CuratedListType`: the Watch home, the store's Featured tab,
|
||||||
// back, matched case-insensitively. Nothing curates lists here, so the pages are static
|
// …) and dictates the name it asks under, so the two normally agree.
|
||||||
// captures — the same stand-in posture as `/curatedlists/bulk` above.
|
|
||||||
//
|
//
|
||||||
// `creatorAccountId` and `type` are accepted and IGNORED, deliberately: the reference was
|
// Nothing curates lists here, so every list is a static capture in
|
||||||
// observed answering a `type=5` request with a `Type` 7 list, so filtering on them would
|
// `static/curated-lists.json`. `resolveCuratedList` matches most-specific first and
|
||||||
// answer nothing where it answers a page. An unknown `name` falls back to Explore rather
|
// always answers something: a name this server has nothing under falls back to the page's
|
||||||
// than an empty array, which renders as an empty Play page.
|
// 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
|
// `ItemIds` are the discovery ROWS the page is built from (the section keys the `discovery`
|
||||||
// room ids — the client resolves each one itself.
|
// worker serves), not room or item ids — the client resolves each one itself.
|
||||||
.get('/curatedlists', async (c) => {
|
.get('/curatedlists', async (c) => {
|
||||||
const name = c.req.query('name') ?? ''
|
// Serialized by hand rather than through `c.json`: the reference's `ListId`s are
|
||||||
return c.json(CURATED_LIST_PAGES.get(name.toLowerCase()) ?? DEFAULT_CURATED_LIST_PAGE)
|
// 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
|
// One discovery ROW's contents (`GET /algorithmiclists/:list?type=1`). `:list` is the row
|
||||||
|
|||||||
@@ -103,99 +103,118 @@ 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(
|
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.status).toBe(200)
|
||||||
expect(res.headers.get('content-type')).toContain('application/json')
|
expect(res.headers.get('content-type')).toContain('application/json')
|
||||||
|
|
||||||
const text = await res.text()
|
const body = await res.text()
|
||||||
expect(JSON.parse(text)).toMatchObject([
|
// 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).
|
||||||
ListId: 1,
|
expect(body).toContain('"ListId":624765592684307326')
|
||||||
CreatorAccountId: 1,
|
|
||||||
Name: 'Discovery.PageSource.PlayExplore',
|
// Compared without the id: a literal here would round the same way the parse does, so
|
||||||
Description: null,
|
// the digits are checked on the raw body above and everything else on the object.
|
||||||
ImageName: 'DefaultRoomImage.jpg',
|
const { ListId: _id, ...rest } = JSON.parse(body) as Record<string, unknown>
|
||||||
Type: 7,
|
expect(rest).toEqual({
|
||||||
Accessibility: 1,
|
CreatorAccountId: 1,
|
||||||
CreatedAt: '2025-04-23T18:27:03.2643786Z',
|
Name: 'Discovery.PageSource.PlayExplore',
|
||||||
ItemIds: [
|
Description: null,
|
||||||
'Rooms_New_PlayHighlight_TabsTest_Explore',
|
ImageName: 'DefaultRoomImage.jpg',
|
||||||
'RoomCategories_MoodPlaylists_FeelingLucky',
|
Type: 7,
|
||||||
'Rooms_RecentlyUpdated_TabsTest_Explore',
|
ItemIds: [
|
||||||
'Rooms_Battle_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
|
'Rooms_New_PlayHighlight_TabsTest_Explore',
|
||||||
'Rooms_Quests_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
|
'RoomCategories_MoodPlaylists_FeelingLucky',
|
||||||
'Rooms_Roleplay_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
|
'Rooms_RecentlyUpdated_TabsTest_Explore',
|
||||||
'Rooms_Horror_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
|
'Rooms_Battle_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
|
||||||
'Rooms_Hangout_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
|
'Rooms_Quests_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
|
||||||
'Rooms_Casual_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
|
'Rooms_Roleplay_AlgoEndpoint_PlayHighlight_TabsTest_Explore',
|
||||||
'Rooms_Explore_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',
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('serves the PlayLibrary page when the query names it', async () => {
|
it('serves every capture in static/curated-lists.json by name', async () => {
|
||||||
const res = await SELF.fetch(
|
// One array holds every list, and each entry must be reachable by the keys the client
|
||||||
`${ORIGIN}/curatedlists?creatorAccountId=1&type=5&name=Discovery.PageSource.PlayLibrary`
|
// 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
|
||||||
expect(res.status).toBe(200)
|
// unique and reach it with their digits intact.
|
||||||
|
const names = [
|
||||||
const text = await res.text()
|
'Discovery.PageSource.PlayExplore',
|
||||||
expect(JSON.parse(text)).toMatchObject([
|
'Discovery.PageSource.PlayLibrary',
|
||||||
{
|
'RoomCategories.MoodPlaylists.AlgoEndpoint.FeelingLucky',
|
||||||
ListId: 2,
|
]
|
||||||
CreatorAccountId: 1,
|
const seen = new Set<string>()
|
||||||
Name: 'Discovery.PageSource.PlayLibrary',
|
for (const name of names) {
|
||||||
Description: null,
|
const res = await SELF.fetch(`${ORIGIN}/curatedlists?creatorAccountId=1&type=7&name=${name}`)
|
||||||
ImageName: 'DefaultRoomImage.jpg',
|
expect(res.status).toBe(200)
|
||||||
Type: 7,
|
const body = await res.text()
|
||||||
Accessibility: 1,
|
// Never a quoted id: the client's field is a number.
|
||||||
CreatedAt: '2025-04-23T18:25:31.5308539Z',
|
expect(body).toMatch(/"ListId":\d+,/)
|
||||||
ItemIds: [
|
const list = JSON.parse(body) as {
|
||||||
'Rooms_ContinuePlaying_PlayLibrary',
|
ListId: number
|
||||||
'Rooms_SavedForLater_PlayHighlight',
|
Type: number
|
||||||
'Rooms_Favorites_PlayLibrary',
|
Name: string
|
||||||
'Rooms_MyRooms_Play',
|
ItemIds: string[]
|
||||||
],
|
Description: string | null
|
||||||
},
|
ImageName: string
|
||||||
])
|
}
|
||||||
|
expect(list.Name).toBe(name)
|
||||||
// The name is matched case-insensitively, and `type` is not filtered on — the request
|
expect(list.Type).toBe(7)
|
||||||
// above asks for type 5 and gets the type 7 list, as the reference does.
|
expect(list.ItemIds.length).toBeGreaterThan(0)
|
||||||
const lower = await SELF.fetch(`${ORIGIN}/curatedlists?name=discovery.pagesource.playlibrary`)
|
expect(typeof list.ImageName).toBe('string')
|
||||||
expect(await lower.text()).toBe(text)
|
const id = /"ListId":(\d+),/.exec(body)?.[1]
|
||||||
})
|
expect(seen.has(id!)).toBe(false)
|
||||||
|
seen.add(id!)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
expect(new Set(ids).size).toBe(ids.length)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('falls back to the Explore page for a name it has nothing for', async () => {
|
it('matches the name case-insensitively and prefers it over the type', async () => {
|
||||||
// An empty array renders as an empty Play page, so an unknown page source answers
|
const canonical = await (
|
||||||
// SOMETHING — which is also what the reference was observed doing for RoomGenreTags.
|
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 (
|
const explore = await (
|
||||||
await SELF.fetch(`${ORIGIN}/curatedlists?name=Discovery.PageSource.PlayExplore`)
|
await SELF.fetch(`${ORIGIN}/curatedlists?name=Discovery.PageSource.PlayExplore`)
|
||||||
).text()
|
).text()
|
||||||
|
|
||||||
for (const query of ['?type=99&name=Nope', '?creatorAccountId=1&type=5&name=RoomGenreTags', '']) {
|
for (const query of [
|
||||||
const res = await SELF.fetch(`${ORIGIN}/curatedlists${query}`)
|
'?creatorAccountId=1&type=4&name=17859340',
|
||||||
expect(res.status).toBe(200)
|
'?type=99&name=Nope',
|
||||||
expect(await res.text()).toBe(explore)
|
'?creatorAccountId=7&type=&name=',
|
||||||
|
'',
|
||||||
|
]) {
|
||||||
|
const bare = await SELF.fetch(`${ORIGIN}/curatedlists${query}`)
|
||||||
|
expect(bare.status).toBe(200)
|
||||||
|
expect(await bare.text()).toBe(explore)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user