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
+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),