feat: improve favorite bulk moves

This commit is contained in:
2026-06-30 20:44:50 +07:00
parent 5bbef18fdf
commit 7151a3ac90
26 changed files with 702 additions and 218 deletions
+76 -20
View File
@@ -9,7 +9,7 @@ import type {
MoveResult,
} from "../../shared/types/avatar";
import { toAvatar } from "./mappers";
import { httpStatusOf } from "./errors";
import { httpStatusOf, isTransientError } from "./errors";
import { cachedRead } from "./cachedRead";
import { requireActiveClient } from "./client";
import { userCache, currentUser } from "./userService";
@@ -70,7 +70,7 @@ async function fetchFavorites(vrc: VRChat): Promise<{
]);
const avatarGroups = groups.filter((g) => g.type === "avatar");
const folders: FavoriteFolder[] = [];
const existing: FavoriteFolder[] = [];
for (const group of avatarGroups) {
let raw: Awaited<ReturnType<typeof getFavoritedAvatarsRaw>> = [];
try {
@@ -78,14 +78,37 @@ async function fetchFavorites(vrc: VRChat): Promise<{
} catch (err) {
if (httpStatusOf(err) !== 401 && httpStatusOf(err) !== 403) throw err;
}
folders.push({
existing.push({
name: group.name,
displayName: group.displayName || prettyFolderName(group.name),
visibility: normalizeVisibility(group.visibility),
avatars: raw.map(toAvatar),
});
}
return { folders, limits };
return { folders: fillAvatarSlots(existing, limits.maxGroups), limits };
}
function fillAvatarSlots(existing: FavoriteFolder[], max: number): FavoriteFolder[] {
const out = orderSlots(existing, "avatars");
const taken = new Set(out.map((f) => f.name));
for (let i = 1; out.length < max && i <= max; i++) {
const name = `avatars${i}`;
if (taken.has(name)) continue;
out.push({ name, displayName: prettyFolderName(name), visibility: "private", avatars: [] });
}
return out;
}
function orderSlots<T extends { name: string }>(groups: T[], prefix: string): T[] {
const slotNum = (name: string) => {
const m = new RegExp(`^${prefix}(\\d+)$`).exec(name);
return m ? Number(m[1]) : null;
};
const custom = groups.filter((g) => slotNum(g.name) === null);
const numbered = groups
.filter((g) => slotNum(g.name) !== null)
.sort((a, b) => slotNum(a.name)! - slotNum(b.name)!);
return [...custom, ...numbered];
}
async function fetchFavoriteLimits(vrc: VRChat): Promise<FavoriteLimits> {
@@ -164,17 +187,28 @@ export async function unfavoriteAvatar(avatarId: string): Promise<void> {
await reloadFavorites();
}
export async function moveAvatarToFolder(avatarId: string, folder: string): Promise<MoveResult> {
export async function moveAvatarToFolder(
avatarId: string,
folder: string,
reload = true,
): Promise<MoveResult> {
const vrc = requireActiveClient();
const fav = await findFavoriteRecord(vrc, avatarId);
if (fav?.tags?.includes(folder)) return { moved: 0, skipped: [] };
if (!(await canRefavorite(vrc, avatarId))) return { moved: 0, skipped: [avatarId] };
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
await vrc.addFavorite({
body: { type: "avatar", favoriteId: avatarId, tags: [folder] },
throwOnError: true,
});
await reloadFavorites();
try {
await vrc.addFavorite({
body: { type: "avatar", favoriteId: avatarId, tags: [folder] },
throwOnError: true,
});
} catch (err) {
if (fav) await restoreAvatarFavorite(vrc, fav);
if (reload) await reloadFavorites();
if (isTransientError(err)) throw err;
return { moved: 0, skipped: [avatarId] };
}
if (reload) await reloadFavorites();
return { moved: 1, skipped: [] };
}
@@ -183,7 +217,7 @@ export async function unfavoriteAvatars(avatarIds: string[]): Promise<void> {
const records = await favoriteRecords(vrc);
for (const id of avatarIds) {
const fav = records.get(id);
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav }, throwOnError: true });
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
}
await reloadFavorites();
}
@@ -202,11 +236,22 @@ export async function moveAvatarsToFolder(
continue;
}
const fav = records.get(id);
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav }, throwOnError: true });
await vrc.addFavorite({
body: { type: "avatar", favoriteId: id, tags: [folder] },
throwOnError: true,
});
if (fav?.tags?.includes(folder)) continue;
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
try {
await vrc.addFavorite({
body: { type: "avatar", favoriteId: id, tags: [folder] },
throwOnError: true,
});
} catch (err) {
if (fav) await restoreAvatarFavorite(vrc, fav);
if (isTransientError(err)) {
await reloadFavorites();
throw err;
}
skipped.push(id);
continue;
}
moved++;
}
await reloadFavorites();
@@ -217,7 +262,8 @@ async function canRefavorite(vrc: VRChat, avatarId: string): Promise<boolean> {
try {
await getAvatarRaw(vrc, avatarId);
return true;
} catch {
} catch (err) {
if (isTransientError(err)) throw err;
return false;
}
}
@@ -250,8 +296,18 @@ async function findFavoriteRecord(vrc: VRChat, avatarId: string) {
return (await favoriteRecordEntries(vrc)).find((f) => f.favoriteId === avatarId);
}
async function favoriteRecords(vrc: VRChat): Promise<Map<string, string>> {
return new Map((await favoriteRecordEntries(vrc)).map((f) => [f.favoriteId, f.id]));
async function favoriteRecords(vrc: VRChat) {
return new Map((await favoriteRecordEntries(vrc)).map((f) => [f.favoriteId, f]));
}
async function restoreAvatarFavorite(
vrc: VRChat,
fav: { favoriteId: string; tags: string[] },
): Promise<void> {
await vrc.addFavorite({
body: { type: "avatar", favoriteId: fav.favoriteId, tags: fav.tags.length ? fav.tags : ["avatars1"] },
throwOnError: true,
});
}
async function favoriteRecordEntries(vrc: VRChat) {
@@ -267,7 +323,7 @@ async function favoriteRecordEntries(vrc: VRChat) {
}
}
async function reloadFavorites(): Promise<void> {
export async function reloadFavorites(): Promise<void> {
userCache.invalidate(cacheKeys.avatarFavorites());
await loadFavoritedAvatars();
}
+13
View File
@@ -28,6 +28,12 @@ export function httpStatusOf(err: unknown): number | undefined {
return statusOf((err ?? {}) as HttpLike);
}
export function isTransientError(err: unknown): boolean {
const status = httpStatusOf(err);
if (status === undefined) return true;
return status === 429 || status >= 500;
}
function retryAfterOf(e: HttpLike): number | undefined {
const raw = e.response?.headers?.["retry-after"] ?? e.headers?.["retry-after"];
const n = raw != null ? Number(raw) : NaN;
@@ -47,6 +53,13 @@ export function toApiError(err: unknown): ApiError {
else if (status === 429) code = "rate_limited";
else if (status === undefined && /network|fetch|ENOTFOUND|ECONN/i.test(message)) code = "network";
if (status === 429) {
return { code, message: "VRChat API rate limit hit. Try again shortly.", retryAfter: retryAfterOf(e) };
}
if (status !== undefined && status >= 500) {
return { code, message: "VRChat API is temporarily unavailable. Try again shortly." };
}
return { code, message, retryAfter: retryAfterOf(e) };
}
+185 -49
View File
@@ -8,7 +8,7 @@ import type {
MoveResult,
World,
} from "../../shared/types/world";
import { httpStatusOf } from "./errors";
import { httpStatusOf, isTransientError } from "./errors";
import {
getCategoryWorlds,
getFavoriteGroupWorlds,
@@ -71,6 +71,7 @@ async function loadFavoriteWorlds(vrc: VRChat, userId: string): Promise<CachedFa
const seen = new Set<string>();
const members: { id: string; group: string }[] = [];
const names = new Map<string, string>();
const order = groups.map((g) => g.name);
for (const group of groups) {
if (group.displayName) names.set(group.name, group.displayName);
let raw;
@@ -91,12 +92,12 @@ async function loadFavoriteWorlds(vrc: VRChat, userId: string): Promise<CachedFa
}
broadcast("world:favoriteFolders", {
userId,
folders: groupIntoFolders(members, names),
folders: groupIntoFolders(order, members, names),
done: false,
});
}
const folders = groupIntoFolders(members, names);
const folders = groupIntoFolders(order, members, names);
broadcast("world:favoriteFolders", { userId, folders, done: true });
return { worlds, folders };
}
@@ -111,27 +112,26 @@ function isPrivateFavorites(err: unknown): boolean {
}
function groupIntoFolders(
order: string[],
members: { id: string; group: string }[],
names: Map<string, string>,
): FavoriteWorldFolder[] {
const order: string[] = [];
const byGroup = new Map<string, string[]>();
for (const { id, group } of members) {
const ids = byGroup.get(group);
if (ids) ids.push(id);
else {
byGroup.set(group, [id]);
order.push(group);
}
else byGroup.set(group, [id]);
}
return order.map((name) => ({
name,
displayName: names.get(name) ?? prettyFolderName(name),
worldIds: byGroup.get(name)!,
worldIds: byGroup.get(name) ?? [],
}));
}
function prettyFolderName(key: string): string {
const vp = /^vrcPlusWorlds(\d+)$/.exec(key);
if (vp) return `VRC+ Group ${vp[1]}`;
const m = /^worlds(\d+)$/.exec(key);
if (m) return `Group ${m[1]}`;
return key.charAt(0).toUpperCase() + key.slice(1);
@@ -175,6 +175,13 @@ async function loadCategory(
const DEFAULT_FOLDER = "worlds1";
type WorldFavoriteRecord = {
id: string;
favoriteId: string;
tags: string[];
type: WorldFavoriteGroupType;
};
export async function loadMyFavoriteWorlds(): Promise<void> {
const me = await currentUser();
const { groups, limits } = await cachedRead(
@@ -208,13 +215,13 @@ async function fetchMyFavorites(
vrc: VRChat,
userId: string,
): Promise<{ groups: FavoriteGroupInput[]; limits: FavoriteLimits }> {
const [{ data: rawGroups }, limits] = await Promise.all([
const [{ data: rawGroups }, { limits, caps }] = await Promise.all([
vrc.getFavoriteGroups({ query: { ownerId: userId, n: 100 }, throwOnError: true }),
fetchFavoriteLimits(vrc),
]);
const worldGroups = rawGroups.filter((g) => isWorldGroupType(g.type));
const groups: FavoriteGroupInput[] = [];
const existing: FavoriteGroupInput[] = [];
for (const group of worldGroups) {
let worlds: World[] = [];
try {
@@ -228,21 +235,64 @@ async function fetchMyFavorites(
} catch (err) {
if (!isPrivateFavorites(err)) throw err;
}
groups.push({
existing.push({
name: group.name,
displayName: group.displayName || prettyFolderName(group.name),
visibility: normalizeVisibility(group.visibility),
worlds,
vrcPlus: (group.type as WorldFavoriteGroupType) === "vrcPlusWorld",
});
}
return { groups, limits };
return { groups: fillSlots(existing, caps), limits };
}
async function fetchFavoriteLimits(vrc: VRChat): Promise<FavoriteLimits> {
function fillSlots(existing: FavoriteGroupInput[], caps: WorldFavoriteCaps): FavoriteGroupInput[] {
const out: FavoriteGroupInput[] = [];
for (const [prefix, vrcPlus, max] of [
["worlds", false, caps.world],
["vrcPlusWorlds", true, caps.vrcPlusWorld],
] as const) {
const mine = orderSlots(
existing.filter((g) => g.vrcPlus === vrcPlus),
prefix,
);
const taken = new Set(mine.map((g) => g.name));
for (let i = 1; mine.length < max && i <= max; i++) {
const name = `${prefix}${i}`;
if (taken.has(name)) continue;
mine.push({ name, displayName: prettyFolderName(name), visibility: "private", worlds: [], vrcPlus });
}
out.push(...mine);
}
return out;
}
function orderSlots<T extends { name: string }>(groups: T[], prefix: string): T[] {
const slotNum = (name: string) => {
const m = new RegExp(`^${prefix}(\\d+)$`).exec(name);
return m ? Number(m[1]) : null;
};
const custom = groups.filter((g) => slotNum(g.name) === null);
const numbered = groups
.filter((g) => slotNum(g.name) !== null)
.sort((a, b) => slotNum(a.name)! - slotNum(b.name)!);
return [...custom, ...numbered];
}
type WorldFavoriteCaps = { world: number; vrcPlusWorld: number };
async function fetchFavoriteLimits(
vrc: VRChat,
): Promise<{ limits: FavoriteLimits; caps: WorldFavoriteCaps }> {
const { data } = await vrc.getFavoriteLimits({ throwOnError: true });
const world = data.maxFavoriteGroups?.world ?? data.defaultMaxFavoriteGroups;
const vrcPlusWorld = data.maxFavoriteGroups?.vrcPlusWorld ?? 0;
return {
maxGroups: data.maxFavoriteGroups?.world ?? data.defaultMaxFavoriteGroups,
maxPerGroup: data.maxFavoritesPerGroup?.world ?? data.defaultMaxFavoritesPerGroup,
limits: {
maxGroups: world + vrcPlusWorld,
maxPerGroup: data.maxFavoritesPerGroup?.world ?? data.defaultMaxFavoritesPerGroup,
},
caps: { world, vrcPlusWorld },
};
}
@@ -253,7 +303,7 @@ function normalizeVisibility(v: string): FavoriteVisibility {
export async function favoriteWorld(worldId: string, folder = DEFAULT_FOLDER): Promise<void> {
const vrc = requireActiveClient();
await vrc.addFavorite({
body: { type: "world", favoriteId: worldId, tags: [folder] },
body: favoriteBody(favoriteTypeForFolder(folder), worldId, [folder]),
throwOnError: true,
});
await reloadMyFavorites();
@@ -266,17 +316,31 @@ export async function unfavoriteWorld(worldId: string): Promise<void> {
await reloadMyFavorites();
}
export async function moveWorldToFolder(worldId: string, folder: string): Promise<MoveResult> {
export async function moveWorldToFolder(
worldId: string,
folder: string,
reload = true,
): Promise<MoveResult> {
const vrc = requireActiveClient();
const me = await currentUser();
const fav = await findFavoriteRecord(vrc, worldId);
if (fav?.tags?.includes(folder)) return { moved: 0, skipped: [] };
if (isOnlyInFolder(fav, folder)) return { moved: 0, skipped: [] };
if (!(await canRefavorite(vrc, worldId))) return { moved: 0, skipped: [worldId] };
const type = await favoriteTypeForExistingFolder(vrc, me.id, folder);
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
await vrc.addFavorite({
body: { type: "world", favoriteId: worldId, tags: [folder] },
throwOnError: true,
});
await reloadMyFavorites();
try {
await vrc.addFavorite({
body: favoriteBody(type, worldId, [folder]),
throwOnError: true,
});
} catch (err) {
if (fav) await restoreWorldFavorite(vrc, fav);
if (reload) await reloadMyFavorites();
if (isTransientError(err)) throw err;
return { moved: 0, skipped: [worldId] };
}
if (reload) await reloadMyFavorites();
worldFavoritesStore.moveWorld(worldId, folder);
return { moved: 1, skipped: [] };
}
@@ -284,8 +348,8 @@ export async function unfavoriteWorlds(worldIds: string[]): Promise<void> {
const vrc = requireActiveClient();
const records = await favoriteRecords(vrc);
for (const id of worldIds) {
const recordId = records.get(id);
if (recordId) await vrc.removeFavorite({ path: { favoriteId: recordId }, throwOnError: true });
const fav = records.get(id);
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
}
await reloadMyFavorites();
}
@@ -295,7 +359,9 @@ export async function moveWorldsToFolder(
folder: string,
): Promise<MoveResult> {
const vrc = requireActiveClient();
const me = await currentUser();
const records = await favoriteRecords(vrc);
const type = await favoriteTypeForExistingFolder(vrc, me.id, folder);
const skipped: string[] = [];
let moved = 0;
for (const id of worldIds) {
@@ -303,23 +369,48 @@ export async function moveWorldsToFolder(
skipped.push(id);
continue;
}
const recordId = records.get(id);
if (recordId) await vrc.removeFavorite({ path: { favoriteId: recordId }, throwOnError: true });
await vrc.addFavorite({
body: { type: "world", favoriteId: id, tags: [folder] },
throwOnError: true,
});
const fav = records.get(id);
if (isOnlyInFolder(fav, folder)) continue;
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
try {
await vrc.addFavorite({
body: favoriteBody(type, id, [folder]),
throwOnError: true,
});
} catch (err) {
if (fav) await restoreWorldFavorite(vrc, fav);
if (isTransientError(err)) {
await reloadMyFavorites();
throw err;
}
skipped.push(id);
continue;
}
moved++;
}
await reloadMyFavorites();
for (const id of worldIds) if (!skipped.includes(id)) worldFavoritesStore.moveWorld(id, folder);
return { moved, skipped };
}
async function restoreWorldFavorite(vrc: VRChat, fav: WorldFavoriteRecord): Promise<void> {
const tags = fav.tags.length ? fav.tags : [DEFAULT_FOLDER];
await vrc.addFavorite({
body: favoriteBody(fav.type, fav.favoriteId, tags),
throwOnError: true,
});
}
function isOnlyInFolder(fav: WorldFavoriteRecord | undefined, folder: string): boolean {
return fav?.tags.length === 1 && fav.tags[0] === folder;
}
export async function clearFavoriteWorldFolder(folder: string): Promise<void> {
const vrc = requireActiveClient();
const me = await currentUser();
const type = await favoriteTypeForExistingFolder(vrc, me.id, folder);
await vrc.clearFavoriteGroup({
path: { favoriteGroupType: "world", favoriteGroupName: folder, userId: me.id },
path: { favoriteGroupType: type, favoriteGroupName: folder, userId: me.id },
throwOnError: true,
});
await reloadMyFavorites();
@@ -331,8 +422,9 @@ export async function updateFavoriteWorldFolder(
): Promise<void> {
const vrc = requireActiveClient();
const me = await currentUser();
const type = await favoriteTypeForExistingFolder(vrc, me.id, folder);
await vrc.updateFavoriteGroup({
path: { favoriteGroupType: "world", favoriteGroupName: folder, userId: me.id },
path: { favoriteGroupType: type, favoriteGroupName: folder, userId: me.id },
body: {
displayName: edit.displayName,
visibility: edit.visibility as never,
@@ -344,35 +436,79 @@ export async function updateFavoriteWorldFolder(
async function canRefavorite(vrc: VRChat, worldId: string): Promise<boolean> {
try {
await vrc.getWorld({ path: { worldId }, throwOnError: true });
return true;
} catch {
const { data } = await vrc.getWorld({ path: { worldId }, throwOnError: true });
const me = await currentUser();
return data.releaseStatus !== "private" || data.authorId === me.id;
} catch (err) {
if (isTransientError(err)) throw err;
return false;
}
}
function favoriteTypeForFolder(folder: string): WorldFavoriteGroupType {
return folder.startsWith("vrcPlusWorlds") ? "vrcPlusWorld" : "world";
}
async function favoriteTypeForExistingFolder(
vrc: VRChat,
userId: string,
folder: string,
): Promise<WorldFavoriteGroupType> {
const { data } = await vrc.getFavoriteGroups({
query: { ownerId: userId, n: 100 },
throwOnError: true,
});
return (
data.find((g) => g.name === folder && isWorldGroupType(g.type))?.type ??
favoriteTypeForFolder(folder)
);
}
type AddFavoriteBody = NonNullable<Parameters<VRChat["addFavorite"]>[0]>["body"];
function favoriteBody(
type: WorldFavoriteGroupType,
favoriteId: string,
tags: string[],
): AddFavoriteBody {
return { type, favoriteId, tags } as AddFavoriteBody;
}
async function findFavoriteRecord(vrc: VRChat, worldId: string) {
return (await favoriteRecordEntries(vrc)).find((f) => f.favoriteId === worldId);
}
async function favoriteRecords(vrc: VRChat): Promise<Map<string, string>> {
return new Map((await favoriteRecordEntries(vrc)).map((f) => [f.favoriteId, f.id]));
async function favoriteRecords(vrc: VRChat): Promise<Map<string, WorldFavoriteRecord>> {
return new Map((await favoriteRecordEntries(vrc)).map((f) => [f.favoriteId, f]));
}
async function favoriteRecordEntries(vrc: VRChat) {
async function favoriteRecordEntries(vrc: VRChat): Promise<WorldFavoriteRecord[]> {
const me = await currentUser();
const { data: rawGroups } = await vrc.getFavoriteGroups({
query: { ownerId: me.id, n: 100 },
throwOnError: true,
});
const groups = rawGroups.filter((g) => isWorldGroupType(g.type));
const pageSize = 100;
const entries = [];
for (let offset = 0; ; offset += pageSize) {
const { data } = await vrc.getFavorites({
query: { type: "world", n: pageSize, offset },
throwOnError: true,
});
entries.push(...data);
if (data.length < pageSize) return entries;
const entries: WorldFavoriteRecord[] = [];
for (const group of groups) {
let groupCount = 0;
for (let offset = 0; ; offset += pageSize) {
const res = await vrc.client.get({
url: `/favorites/groups/${group.type}/${encodeURIComponent(group.name)}`,
query: { ownerId: me.id, n: pageSize, offset },
throwOnError: true,
});
const data = res.data as { favorites?: WorldFavoriteRecord[]; totalCount?: number };
const page = (data.favorites ?? []).map((f) => ({ ...f, type: group.type }));
entries.push(...page);
groupCount += page.length;
if (page.length < pageSize || groupCount >= (data.totalCount ?? groupCount)) break;
}
}
return entries;
}
async function reloadMyFavorites(): Promise<void> {
export async function reloadMyFavorites(): Promise<void> {
userCache.invalidate(cacheKeys.myFavoriteWorlds());
const me = await currentUser();
userCache.invalidate(cacheKeys.favoriteWorlds(me.id));