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 { 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',
|
||||
'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`.
|
||||
.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,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,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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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\"}"
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user