mirror of
https://github.com/YuzuZensai/VRC-Circle.git
synced 2026-09-13 19:08:52 +00:00
✨ feat: World Discovery
This commit is contained in:
Vendored
+2
@@ -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}`,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user