import type { VRChat } from "vrchat"; import type { Avatar, AvatarEdit, AvatarSnapshot, FavoriteGroupEdit, FavoriteLimits, FavoriteVisibility, MoveResult, } from "../../shared/types/avatar"; import { toAvatar } from "./mappers"; import { httpStatusOf, isTransientError } from "./errors"; import { cachedRead } from "./cachedRead"; import { requireActiveClient } from "./client"; import { userCache, currentUser } from "./userService"; import { entityStore } from "../store/entityStore"; import { avatarStore } from "../store/avatarStore"; import { cacheKeys, policies } from "../cache/policies"; import { getAvatarRaw, getMyAvatarsRaw, getFavoritedAvatarsRaw } from "./rawEndpoints"; type FavoriteFolder = { name: string; displayName: string; visibility: FavoriteVisibility; avatars: Avatar[]; }; export async function getAvatar(avatarId: string): Promise { 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 { const avatars = await cachedRead(cacheKeys.avatarMine(), policies.avatarMine, async (vrc) => { const data = await getMyAvatarsRaw(vrc); return data.map(toAvatar); }); avatarStore.setMine(avatars); } export async function loadFavoritedAvatars(): Promise { const { folders, limits } = await cachedRead( cacheKeys.avatarFavorites(), policies.avatarFavorites, fetchFavorites, ); avatarStore.setFavorites(folders, limits); } 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), ]); const avatarGroups = groups.filter((g) => g.type === "avatar"); const existing: FavoriteFolder[] = []; for (const group of avatarGroups) { let raw: Awaited> = []; try { raw = await getFavoritedAvatarsRaw(vrc, group.name); } catch (err) { if (httpStatusOf(err) !== 401 && httpStatusOf(err) !== 403) throw err; } existing.push({ name: group.name, displayName: group.displayName || prettyFolderName(group.name), visibility: normalizeVisibility(group.visibility), avatars: raw.map(toAvatar), }); } 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(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 { 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"; } 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 { 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 { 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); return avatar; } export async function deleteAvatar(avatarId: string): Promise { const vrc = requireActiveClient(); await vrc.deleteAvatar({ path: { avatarId }, throwOnError: true }); invalidateAvatar(avatarId); avatarStore.removeAvatar(avatarId); await refreshLists(vrc, new Set([avatarId])); } export async function favoriteAvatar(avatarId: string, folder = "avatars1"): Promise { const vrc = requireActiveClient(); await vrc.addFavorite({ body: { type: "avatar", favoriteId: avatarId, tags: [folder] }, throwOnError: true, }); await reloadFavorites(); } export async function unfavoriteAvatar(avatarId: string): Promise { const vrc = requireActiveClient(); const fav = await findFavoriteRecord(vrc, avatarId); if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true }); await reloadFavorites(); } export async function moveAvatarToFolder( avatarId: string, folder: string, reload = true, ): Promise { 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 }); 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: [] }; } export async function unfavoriteAvatars(avatarIds: string[]): Promise { const vrc = requireActiveClient(); const records = await favoriteRecords(vrc); for (const id of avatarIds) { const fav = records.get(id); if (fav) await vrc.removeFavorite({ path: { favoriteId: fav.id }, throwOnError: true }); } await reloadFavorites(); } export async function moveAvatarsToFolder( avatarIds: string[], folder: string, ): Promise { 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); 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(); return { moved, skipped }; } async function canRefavorite(vrc: VRChat, avatarId: string): Promise { try { await getAvatarRaw(vrc, avatarId); return true; } catch (err) { if (isTransientError(err)) throw err; return false; } } export async function clearFavoriteFolder(folder: string): Promise { const vrc = requireActiveClient(); const me = await currentUser(); await vrc.clearFavoriteGroup({ path: { favoriteGroupType: "avatar", favoriteGroupName: folder, userId: me.id }, throwOnError: true, }); await reloadFavorites(); } export async function updateFavoriteFolder(folder: string, edit: FavoriteGroupEdit): Promise { 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) { return (await favoriteRecordEntries(vrc)).find((f) => f.favoriteId === avatarId); } 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 { await vrc.addFavorite({ body: { type: "avatar", favoriteId: fav.favoriteId, tags: fav.tags.length ? fav.tags : ["avatars1"] }, throwOnError: true, }); } 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; } } export async function reloadFavorites(): Promise { userCache.invalidate(cacheKeys.avatarFavorites()); await loadFavoritedAvatars(); } function invalidateAvatar(avatarId: string): void { userCache.invalidate(cacheKeys.avatar(avatarId)); userCache.invalidate(cacheKeys.avatarMine()); userCache.invalidate(cacheKeys.avatarFavorites()); } async function refreshLists(vrc: VRChat, exclude = new Set()): Promise { avatarStore.setMine((await getMyAvatarsRaw(vrc)).map(toAvatar).filter((a) => !exclude.has(a.id))); const { folders, limits } = await fetchFavorites(vrc); avatarStore.setFavorites( folders.map((f) => ({ ...f, avatars: f.avatars.filter((a) => !exclude.has(a.id)) })), limits, ); }