feat: World Discovery

This commit is contained in:
2026-06-29 02:33:22 +07:00
parent 716fb82ae1
commit 99ac7761ce
29 changed files with 535 additions and 83 deletions
+2
View File
@@ -5,6 +5,7 @@ export const policies = {
user: { ttl: 5 * 60_000, staleWhileRevalidate: 10 * 60_000 },
userSearch: { ttl: 5 * 60_000 },
worldSearch: { ttl: 5 * 60_000 },
discover: { ttl: 10 * 60_000, staleWhileRevalidate: 30 * 60_000 },
friends: { ttl: 5 * 60_000, staleWhileRevalidate: 60_000 },
userWorlds: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 },
favoriteWorlds: { ttl: 15 * 60_000, staleWhileRevalidate: 60 * 60_000 },
@@ -25,6 +26,7 @@ export const cacheKeys = {
userByName: (name: string) => `user:name:${name.toLowerCase()}`,
userSearch: (q: string) => `user:search:${q.trim().toLowerCase()}`,
worldSearch: (q: string) => `world:search:${q.trim().toLowerCase()}`,
discover: () => "world:discover",
friends: () => "friends",
userWorlds: (id: string) => `user:worlds:${id}`,
favoriteWorlds: (id: string) => `worlds:favorites:${id}`,
+1
View File
@@ -50,6 +50,7 @@ const handlers = {
}),
"world:favorites": (userId) => guard(() => worlds.getFavoriteWorlds(userId)),
"world:search": (query) => guard(() => worlds.searchWorlds(query)),
"world:discover": () => guard(() => worlds.getDiscover()),
"world:get": (worldId) => guard(() => worlds.getWorld(worldId)),
"world:snapshot": () => guard(async () => worldStore.snapshot()),
+88 -1
View File
@@ -1,4 +1,4 @@
import type { VRChat, FavoritedWorld } from "vrchat";
import type { VRChat, FavoritedWorld, LimitedWorld } from "vrchat";
// VRChat web routes that are missing from the SDK.
@@ -37,3 +37,90 @@ export async function getFavoriteGroupWorlds(
}
return worlds;
}
interface InfoPushContentList {
name: string | { fallback?: string };
shortName?: string | null;
tag?: string | null;
sortHeading?: string;
sortOrder?: string;
sortOwnership?: string;
platform?: string;
}
interface InfoPushEntry {
id: string;
priority?: number;
startDate?: string;
endDate?: string;
data?: { contentList?: InfoPushContentList };
}
export interface WorldCategory {
id: string;
name: string;
tag?: string;
sort?: string;
order?: string;
platform?: string;
}
export async function getWorldCategories(vrc: VRChat): Promise<WorldCategory[]> {
const { data } = await vrc.client.get<InfoPushEntry[], unknown, true>({
url: "/infoPush",
query: { include: "user-all", require: "world-category" },
throwOnError: true,
});
// vrchat gives events the lowest priority number, but we want them first
return data
.filter((e) => e.data?.contentList)
.sort((a, b) => {
const ev = Number(isEvent(b)) - Number(isEvent(a));
if (ev !== 0) return ev;
return (a.priority ?? 0) - (b.priority ?? 0);
})
.map((e) => {
const cl = e.data!.contentList!;
return {
id: e.id,
name: categoryName(cl),
tag: cl.tag || undefined,
sort: cl.sortHeading,
order: cl.sortOrder,
platform: cl.platform === "ThisPlatformSupported" ? undefined : cl.platform,
};
});
}
function isEvent(e: InfoPushEntry): boolean {
const tag = e.data?.contentList?.tag ?? "";
return tag.startsWith("admin_") || Boolean(e.startDate) || Boolean(e.endDate);
}
function categoryName(cl: InfoPushContentList): string {
if (typeof cl.name === "string") return cl.name;
return cl.name.fallback ?? cl.shortName ?? "Worlds";
}
export async function getCategoryWorlds(
vrc: VRChat,
category: WorldCategory,
n: number,
): Promise<LimitedWorld[]> {
const tags = ["system_approved", category.tag].filter(Boolean).join(",");
const { data } = await vrc.client.get<LimitedWorld[], unknown, true>({
url: "/worlds",
query: {
releaseStatus: "public",
sort: category.sort,
order: category.order ?? "descending",
tag: tags || undefined,
featured: false,
n,
offset: 0,
},
throwOnError: true,
});
return data;
}
+27 -2
View File
@@ -1,7 +1,12 @@
import type { VRChat } from "vrchat";
import type { FavoriteWorldFolder, World } from "../../shared/types/world";
import type { DiscoverCategory, FavoriteWorldFolder, World } from "../../shared/types/world";
import { httpStatusOf } from "./errors";
import { getFavoriteGroupWorlds, type WorldFavoriteGroupType } from "./rawEndpoints";
import {
getCategoryWorlds,
getFavoriteGroupWorlds,
getWorldCategories,
type WorldFavoriteGroupType,
} from "./rawEndpoints";
import { toWorld } from "./mappers";
import { cachedRead } from "./cachedRead";
import { worldStore } from "../store/worldStore";
@@ -131,6 +136,26 @@ export async function searchWorlds(query: string): Promise<World[]> {
return worlds;
}
const DISCOVER_ROW_SIZE = 12;
export async function getDiscover(): Promise<DiscoverCategory[]> {
const categories = await cachedRead(cacheKeys.discover(), policies.discover, async (vrc) => {
const cats = await getWorldCategories(vrc);
const rows = await Promise.all(cats.map((cat) => loadCategory(vrc, cat).catch(() => null)));
return rows.filter((r): r is DiscoverCategory => r !== null && r.worlds.length > 0);
});
for (const cat of categories) for (const w of cat.worlds) worldStore.addWorld(w);
return categories;
}
async function loadCategory(
vrc: VRChat,
cat: Awaited<ReturnType<typeof getWorldCategories>>[number],
): Promise<DiscoverCategory> {
const raw = await getCategoryWorlds(vrc, cat, DISCOVER_ROW_SIZE);
return { id: cat.id, name: cat.name, worlds: raw.map(toWorld) };
}
export async function getUserWorlds(userId: string, isSelf: boolean): Promise<World[]> {
const worlds = await cachedRead(
cacheKeys.userWorlds(userId),