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 01:35:59 +07:00
|
|
|
import { httpStatusOf } 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");
|
|
|
|
|
|
|
|
|
|
const folders: FavoriteFolder[] = [];
|
|
|
|
|
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
|
|
|
}
|
|
|
|
|
folders.push({
|
|
|
|
|
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 01:52:30 +07:00
|
|
|
return { folders, limits };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 02:22:09 +07:00
|
|
|
export async function moveAvatarToFolder(avatarId: string, folder: string): 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 });
|
|
|
|
|
await vrc.addFavorite({
|
|
|
|
|
body: { type: "avatar", favoriteId: avatarId, tags: [folder] },
|
|
|
|
|
throwOnError: true,
|
|
|
|
|
});
|
|
|
|
|
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);
|
|
|
|
|
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav }, throwOnError: true });
|
|
|
|
|
}
|
|
|
|
|
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);
|
|
|
|
|
if (fav) await vrc.removeFavorite({ path: { favoriteId: fav }, throwOnError: true });
|
|
|
|
|
await vrc.addFavorite({
|
|
|
|
|
body: { type: "avatar", favoriteId: id, tags: [folder] },
|
|
|
|
|
throwOnError: true,
|
|
|
|
|
});
|
|
|
|
|
moved++;
|
|
|
|
|
}
|
|
|
|
|
await reloadFavorites();
|
|
|
|
|
return { moved, skipped };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function canRefavorite(vrc: VRChat, avatarId: string): Promise<boolean> {
|
|
|
|
|
try {
|
|
|
|
|
await getAvatarRaw(vrc, avatarId);
|
|
|
|
|
return true;
|
|
|
|
|
} catch {
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function favoriteRecords(vrc: VRChat): Promise<Map<string, string>> {
|
|
|
|
|
return new Map((await favoriteRecordEntries(vrc)).map((f) => [f.favoriteId, f.id]));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
}
|