[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\"}"
}
]