Files
VRC-Circle/src/main/vrchat/avatarService.ts
T

345 lines
11 KiB
TypeScript
Raw Normal View History

2026-06-30 01:35:59 +07:00
import type { VRChat } from "vrchat";
2026-06-30 01:52:30 +07:00
import type {
Avatar,
AvatarEdit,
AvatarSnapshot,
FavoriteGroupEdit,
FavoriteLimits,
FavoriteVisibility,
2026-06-30 02:22:09 +07:00
MoveResult,
2026-06-30 01:52:30 +07:00
} from "../../shared/types/avatar";
2026-06-25 19:25:00 +07:00
import { toAvatar } from "./mappers";
2026-06-30 20:09:20 +07:00
import { httpStatusOf, isTransientError } from "./errors";
2026-06-25 19:25:00 +07:00
import { cachedRead } from "./cachedRead";
2026-06-30 01:35:59 +07:00
import { requireActiveClient } from "./client";
2026-06-30 01:52:30 +07:00
import { userCache, currentUser } from "./userService";
2026-06-30 01:35:59 +07:00
import { entityStore } from "../store/entityStore";
import { avatarStore } from "../store/avatarStore";
2026-06-25 19:25:00 +07:00
import { cacheKeys, policies } from "../cache/policies";
2026-06-30 01:35:59 +07:00
import { getAvatarRaw, getMyAvatarsRaw, getFavoritedAvatarsRaw } from "./rawEndpoints";
2026-06-30 01:52:30 +07:00
type FavoriteFolder = {
name: string;
displayName: string;
visibility: FavoriteVisibility;
avatars: Avatar[];
};
2026-06-25 19:25:00 +07:00
export async function getAvatar(avatarId: string): Promise<Avatar> {
2026-06-30 01:35:59 +07:00
try {
const avatar = await cachedRead(cacheKeys.avatar(avatarId), policies.avatar, async (vrc) => {
return toAvatar(await getAvatarRaw(vrc, avatarId));
});
avatarStore.addAvatar(avatar);
return avatar;
} catch (err) {
const fallback = avatarStore.get(avatarId);
if (fallback) return fallback;
throw err;
}
}
export function avatarSnapshot(): AvatarSnapshot {
return avatarStore.snapshot();
}
export async function loadMyAvatars(): Promise<void> {
const avatars = await cachedRead(cacheKeys.avatarMine(), policies.avatarMine, async (vrc) => {
const data = await getMyAvatarsRaw(vrc);
return data.map(toAvatar);
2026-06-25 19:25:00 +07:00
});
2026-06-30 01:35:59 +07:00
avatarStore.setMine(avatars);
}
export async function loadFavoritedAvatars(): Promise<void> {
2026-06-30 01:52:30 +07:00
const { folders, limits } = await cachedRead(
2026-06-30 01:35:59 +07:00
cacheKeys.avatarFavorites(),
policies.avatarFavorites,
2026-06-30 01:52:30 +07:00
fetchFavorites,
2026-06-30 01:35:59 +07:00
);
2026-06-30 01:52:30 +07:00
avatarStore.setFavorites(folders, limits);
2026-06-30 01:35:59 +07:00
}
2026-06-30 01:52:30 +07:00
async function fetchFavorites(vrc: VRChat): Promise<{
folders: FavoriteFolder[];
limits: FavoriteLimits;
}> {
const [{ data: groups }, limits] = await Promise.all([
vrc.getFavoriteGroups({ query: { n: 100 }, throwOnError: true }),
fetchFavoriteLimits(vrc),
]);
2026-06-30 01:35:59 +07:00
const avatarGroups = groups.filter((g) => g.type === "avatar");
2026-06-30 20:09:20 +07:00
const existing: FavoriteFolder[] = [];
2026-06-30 01:35:59 +07:00
for (const group of avatarGroups) {
2026-06-30 01:52:30 +07:00
let raw: Awaited<ReturnType<typeof getFavoritedAvatarsRaw>> = [];
2026-06-30 01:35:59 +07:00
try {
raw = await getFavoritedAvatarsRaw(vrc, group.name);
} catch (err) {
2026-06-30 01:52:30 +07:00
if (httpStatusOf(err) !== 401 && httpStatusOf(err) !== 403) throw err;
2026-06-30 01:35:59 +07:00
}
2026-06-30 20:09:20 +07:00
existing.push({
2026-06-30 01:35:59 +07:00
name: group.name,
displayName: group.displayName || prettyFolderName(group.name),
2026-06-30 01:52:30 +07:00
visibility: normalizeVisibility(group.visibility),
2026-06-30 01:35:59 +07:00
avatars: raw.map(toAvatar),
});
}
2026-06-30 20:09:20 +07:00
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];
2026-06-30 01:52:30 +07:00
}
async function fetchFavoriteLimits(vrc: VRChat): Promise<FavoriteLimits> {
const { data } = await vrc.getFavoriteLimits({ throwOnError: true });
return {
maxGroups: data.maxFavoriteGroups?.avatar ?? data.defaultMaxFavoriteGroups,
maxPerGroup: data.maxFavoritesPerGroup?.avatar ?? data.defaultMaxFavoritesPerGroup,
};
}
function normalizeVisibility(v: string): FavoriteVisibility {
return v === "friends" || v === "public" ? v : "private";
2026-06-30 01:35:59 +07:00
}
function prettyFolderName(key: string): string {
const m = /^avatars(\d+)$/.exec(key);
if (m) return `Group ${m[1]}`;
return key.charAt(0).toUpperCase() + key.slice(1);
}
export async function selectAvatar(avatarId: string): Promise<void> {
const vrc = requireActiveClient();
const { data } = await vrc.selectAvatar({ path: { avatarId }, throwOnError: true });
userCache.invalidate(cacheKeys.currentUser());
entityStore.upsertFrom(
{
id: data.id,
currentAvatarId: data.currentAvatar,
currentAvatarImageUrl: data.currentAvatarImageUrl,
currentAvatarThumbnailImageUrl: data.currentAvatarThumbnailImageUrl,
},
"rest:detail",
Date.now(),
);
}
export async function updateAvatar(avatarId: string, edit: AvatarEdit): Promise<Avatar> {
const vrc = requireActiveClient();
const { data } = await vrc.updateAvatar({
path: { avatarId },
body: {
name: edit.name,
description: edit.description,
releaseStatus: edit.releaseStatus as never,
},
throwOnError: true,
});
const avatar = toAvatar(data);
invalidateAvatar(avatarId);
avatarStore.addAvatar(avatar);
await refreshLists(vrc);
2026-06-25 19:25:00 +07:00
return avatar;
}
2026-06-30 01:35:59 +07:00
export async function deleteAvatar(avatarId: string): Promise<void> {
const vrc = requireActiveClient();
await vrc.deleteAvatar({ path: { avatarId }, throwOnError: true });
invalidateAvatar(avatarId);
avatarStore.removeAvatar(avatarId);
2026-06-30 03:46:04 +07:00
await refreshLists(vrc, new Set([avatarId]));
2026-06-30 01:35:59 +07:00
}
2026-06-30 01:52:30 +07:00
export async function favoriteAvatar(avatarId: string, folder = "avatars1"): Promise<void> {
2026-06-30 01:35:59 +07:00
const vrc = requireActiveClient();
2026-06-30 01:52:30 +07:00
await vrc.addFavorite({
body: { type: "avatar", favoriteId: avatarId, tags: [folder] },
throwOnError: true,
});
await reloadFavorites();
}
export async function unfavoriteAvatar(avatarId: string): Promise<void> {
const vrc = requireActiveClient();
const fav = await findFavoriteRecord(vrc, avatarId);
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
await reloadFavorites();
}
2026-06-30 20:09:20 +07:00
export async function moveAvatarToFolder(
avatarId: string,
folder: string,
reload = true,
): Promise<MoveResult> {
2026-06-30 01:52:30 +07:00
const vrc = requireActiveClient();
const fav = await findFavoriteRecord(vrc, avatarId);
2026-06-30 02:22:09 +07:00
if (fav?.tags?.includes(folder)) return { moved: 0, skipped: [] };
if (!(await canRefavorite(vrc, avatarId))) return { moved: 0, skipped: [avatarId] };
2026-06-30 01:52:30 +07:00
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
2026-06-30 20:09:20 +07:00
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();
2026-06-30 02:22:09 +07:00
return { moved: 1, skipped: [] };
}
export async function unfavoriteAvatars(avatarIds: string[]): Promise<void> {
const vrc = requireActiveClient();
const records = await favoriteRecords(vrc);
for (const id of avatarIds) {
const fav = records.get(id);
2026-06-30 20:09:20 +07:00
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true });
2026-06-30 02:22:09 +07:00
}
await reloadFavorites();
}
export async function moveAvatarsToFolder(
avatarIds: string[],
folder: string,
): Promise<MoveResult> {
const vrc = requireActiveClient();
const records = await favoriteRecords(vrc);
const skipped: string[] = [];
let moved = 0;
for (const id of avatarIds) {
if (!(await canRefavorite(vrc, id))) {
skipped.push(id);
continue;
}
const fav = records.get(id);
2026-06-30 20:09:20 +07:00
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;
}
2026-06-30 02:22:09 +07:00
moved++;
}
await reloadFavorites();
return { moved, skipped };
}
async function canRefavorite(vrc: VRChat, avatarId: string): Promise<boolean> {
try {
await getAvatarRaw(vrc, avatarId);
return true;
2026-06-30 20:09:20 +07:00
} catch (err) {
if (isTransientError(err)) throw err;
2026-06-30 02:22:09 +07:00
return false;
}
}
export async function clearFavoriteFolder(folder: string): Promise<void> {
const vrc = requireActiveClient();
const me = await currentUser();
await vrc.clearFavoriteGroup({
path: { favoriteGroupType: "avatar", favoriteGroupName: folder, userId: me.id },
throwOnError: true,
});
await reloadFavorites();
2026-06-30 01:52:30 +07:00
}
export async function updateFavoriteFolder(folder: string, edit: FavoriteGroupEdit): Promise<void> {
const vrc = requireActiveClient();
const me = await currentUser();
await vrc.updateFavoriteGroup({
path: { favoriteGroupType: "avatar", favoriteGroupName: folder, userId: me.id },
body: {
displayName: edit.displayName,
visibility: edit.visibility as never,
},
throwOnError: true,
});
await reloadFavorites();
}
async function findFavoriteRecord(vrc: VRChat, avatarId: string) {
2026-06-30 02:22:09 +07:00
return (await favoriteRecordEntries(vrc)).find((f) => f.favoriteId === avatarId);
}
2026-06-30 20:09:20 +07:00
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,
});
2026-06-30 02:22:09 +07:00
}
async function favoriteRecordEntries(vrc: VRChat) {
const pageSize = 100;
const entries = [];
for (let offset = 0; ; offset += pageSize) {
const { data } = await vrc.getFavorites({
query: { type: "avatar", n: pageSize, offset },
throwOnError: true,
});
entries.push(...data);
if (data.length < pageSize) return entries;
}
2026-06-30 01:52:30 +07:00
}
2026-06-30 20:09:20 +07:00
export async function reloadFavorites(): Promise<void> {
2026-06-30 01:35:59 +07:00
userCache.invalidate(cacheKeys.avatarFavorites());
await loadFavoritedAvatars();
}
function invalidateAvatar(avatarId: string): void {
userCache.invalidate(cacheKeys.avatar(avatarId));
userCache.invalidate(cacheKeys.avatarMine());
userCache.invalidate(cacheKeys.avatarFavorites());
}
2026-06-30 03:46:04 +07:00
async function refreshLists(vrc: VRChat, exclude = new Set<string>()): Promise<void> {
avatarStore.setMine((await getMyAvatarsRaw(vrc)).map(toAvatar).filter((a) => !exclude.has(a.id)));
2026-06-30 01:52:30 +07:00
const { folders, limits } = await fetchFavorites(vrc);
2026-06-30 03:46:04 +07:00
avatarStore.setFavorites(
folders.map((f) => ({ ...f, avatars: f.avatars.filter((a) => !exclude.has(a.id)) })),
limits,
);
2026-06-25 19:25:00 +07:00
}