Files
buzz-sheet/lib/catalog/sync.ts
T
gunshiz 1ec145a22e
CI / Verify (push) Successful in 1m6s
CI / Build immutable images and deploy (push) Successful in 5m43s
fix(catalog): use avatar icons and repair exports
2026-08-29 19:15:23 +00:00

191 lines
10 KiB
TypeScript

import { asc, eq } from "drizzle-orm";
import * as z from "zod";
import { getDb } from "@/db/client";
import {
catalogArtifacts,
catalogCharacters,
catalogSyncJobs,
catalogVersions,
catalogWeapons,
} from "@/db/schema";
import {
avatarIconName,
characterDetailSchema,
constellationSnapshot,
} from "@/lib/catalog/lunaris";
import { getMediaStorage } from "@/lib/media/storage";
const VERSION_URL = "https://api.lunaris.moe/data/version.json";
const API_ROOT = "https://api.lunaris.moe/data";
const versionSchema = z.object({
version: z.string().regex(/^\d+(?:\.\d+)+$/u),
timestamp: z.number().int().optional(),
});
const characterSchema = z.record(z.string(), z.object({
enName: z.string(),
CardImg: z.string(),
element: z.string().optional(),
qualityType: z.string().optional(),
weaponType: z.string().optional(),
}));
const weaponSchema = z.record(z.string(), z.object({
enName: z.string(),
weaponIcon: z.string(),
qualityType: z.string().optional(),
weaponType: z.string().optional(),
}));
const artifactSchema = z.record(z.string(), z.object({
enName: z.string(),
setIcon: z.string(),
}));
async function getJson(url: string): Promise<unknown> {
const response = await fetch(url, { signal: AbortSignal.timeout(30_000), cache: "no-store" });
if (!response.ok) throw new Error(`Lunaris returned ${response.status} for ${url}`);
return response.json();
}
async function getOptionalJson(url: string): Promise<unknown | null> {
const response = await fetch(url, { signal: AbortSignal.timeout(30_000), cache: "no-store" });
if (response.status === 404) return null;
if (!response.ok) throw new Error(`Lunaris returned ${response.status} for ${url}`);
return response.json();
}
function webpName(value: string): string {
return value.endsWith(".webp") ? value : `${value}.webp`;
}
function rarity(value: string | undefined): number | null {
if (!value) return null;
return { QUALITY_WHITE: 1, QUALITY_GREEN: 2, QUALITY_BLUE: 3, QUALITY_PURPLE: 4, QUALITY_ORANGE: 5 }[value] ?? null;
}
async function mapConcurrent<T, R>(items: T[], task: (item: T) => Promise<R>): Promise<R[]> {
const results = new Array<R>(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const index = next++;
results[index] = await task(items[index]);
}
}
await Promise.all(Array.from({ length: Math.min(4, items.length) }, () => worker()));
return results;
}
async function uploadAssets(
jobId: string,
assets: Array<{ source: string; target: string }>,
): Promise<Set<string>> {
const storage = await getMediaStorage();
let next = 0;
let completed = 0;
const missing = new Set<string>();
await getDb().update(catalogSyncJobs).set({ total: assets.length, progress: 0 }).where(eq(catalogSyncJobs.id, jobId));
async function worker() {
while (next < assets.length) {
const asset = assets[next++];
const response = await fetch(asset.source, { signal: AbortSignal.timeout(30_000), cache: "no-store" });
if (response.status === 404) missing.add(asset.target);
else if (!response.ok) throw new Error(`Asset returned ${response.status}: ${asset.source}`);
else await storage.write(asset.target, response, { type: "image/webp", acl: "public-read" });
completed += 1;
if (completed % 10 === 0 || completed === assets.length) {
await getDb().update(catalogSyncJobs).set({ progress: completed }).where(eq(catalogSyncJobs.id, jobId));
}
}
}
await Promise.all(Array.from({ length: Math.min(4, assets.length) }, () => worker()));
return missing;
}
export async function claimCatalogSyncJob() {
const [pending] = await getDb().select().from(catalogSyncJobs).where(eq(catalogSyncJobs.status, "pending")).orderBy(asc(catalogSyncJobs.createdAt)).limit(1);
if (!pending) return null;
const [claimed] = await getDb().update(catalogSyncJobs).set({ status: "processing", startedAt: new Date(), error: null }).where(eq(catalogSyncJobs.id, pending.id)).returning();
return claimed ?? null;
}
export async function processCatalogSyncJob(jobId: string): Promise<void> {
try {
const versionInfo = versionSchema.parse(await getJson(VERSION_URL));
const version = versionInfo.version;
await getDb().update(catalogSyncJobs).set({ version }).where(eq(catalogSyncJobs.id, jobId));
const [charactersRaw, weaponsRaw, artifactsRaw] = await Promise.all([
getJson(`${API_ROOT}/${version}/charlist.json`),
getJson(`${API_ROOT}/${version}/weaponlist.json`),
getJson(`${API_ROOT}/${version}/artifactlist.json`),
]);
const characterData = characterSchema.parse(charactersRaw);
const weaponData = weaponSchema.parse(weaponsRaw);
const artifactData = artifactSchema.parse(artifactsRaw);
let characters = await mapConcurrent(
Object.entries(characterData).filter(([, value]) => value.enName && value.enName !== "???" && value.CardImg),
async ([key, value]) => {
const detailRaw = await getOptionalJson(`${API_ROOT}/${version}/en/char/${encodeURIComponent(key)}.json`);
const detail = detailRaw ? characterDetailSchema.parse(detailRaw) : null;
const avatarIcon = webpName(avatarIconName(value.CardImg));
return {
version, key, name: value.enName, element: value.element ?? null, rarity: rarity(value.qualityType), weaponType: value.weaponType ?? null,
imageKey: `catalog/${version}/characters/${avatarIcon}`,
sourceImage: `${API_ROOT}/assets/avataricon/${encodeURIComponent(avatarIcon)}`,
constellations: detail ? constellationSnapshot(detail, (icon) => `catalog/${version}/constellations/${webpName(icon)}`) : [],
};
},
);
let weapons = Object.entries(weaponData).filter(([, value]) => value.enName && value.enName !== "???" && value.weaponIcon).map(([key, value]) => ({
version, key, name: value.enName, rarity: rarity(value.qualityType), weaponType: value.weaponType ?? null,
imageKey: `catalog/${version}/weapons/${webpName(value.weaponIcon)}`,
sourceImage: `${API_ROOT}/assets/weaponicon/${encodeURIComponent(webpName(value.weaponIcon))}`,
}));
let artifacts = Object.entries(artifactData).filter(([, value]) => value.enName && value.enName !== "???" && value.setIcon).map(([key, value]) => ({
version, key, name: value.enName,
imageKey: `catalog/${version}/artifacts/${webpName(value.setIcon)}`,
sourceImage: `${API_ROOT}/assets/artifacts/${encodeURIComponent(webpName(value.setIcon))}`,
}));
const uniqueAssets = new Map<string, { source: string; target: string }>();
for (const item of [...characters, ...weapons, ...artifacts]) uniqueAssets.set(item.imageKey, { source: item.sourceImage, target: item.imageKey });
for (const character of characters) {
for (const constellation of character.constellations) {
if (constellation.iconKey) uniqueAssets.set(constellation.iconKey, {
source: `${API_ROOT}/assets/skills/${encodeURIComponent(constellation.iconKey.split("/").at(-1)!)}`,
target: constellation.iconKey,
});
}
}
const missingAssets = await uploadAssets(jobId, [...uniqueAssets.values()]);
characters = characters.filter((item) => !missingAssets.has(item.imageKey));
characters = characters.map((item) => ({ ...item, constellations: item.constellations.map((constellation) => ({ ...constellation, iconKey: constellation.iconKey && !missingAssets.has(constellation.iconKey) ? constellation.iconKey : null })) }));
weapons = weapons.filter((item) => !missingAssets.has(item.imageKey));
artifacts = artifacts.filter((item) => !missingAssets.has(item.imageKey));
const storage = await getMediaStorage();
await Promise.all([
storage.write(`catalog/${version}/characters.json`, JSON.stringify(characters.map((item) => ({ version: item.version, key: item.key, name: item.name, element: item.element, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey, constellations: item.constellations }))), { type: "application/json", acl: "public-read" }),
storage.write(`catalog/${version}/weapons.json`, JSON.stringify(weapons.map((item) => ({ version: item.version, key: item.key, name: item.name, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey }))), { type: "application/json", acl: "public-read" }),
storage.write(`catalog/${version}/artifacts.json`, JSON.stringify(artifacts.map((item) => ({ version: item.version, key: item.key, name: item.name, imageKey: item.imageKey }))), { type: "application/json", acl: "public-read" }),
]);
await getDb().transaction(async (tx) => {
await tx.insert(catalogVersions).values({ version, sourceTimestamp: versionInfo.timestamp ?? null }).onConflictDoUpdate({ target: catalogVersions.version, set: { sourceTimestamp: versionInfo.timestamp ?? null, syncedAt: new Date() } });
await tx.delete(catalogCharacters).where(eq(catalogCharacters.version, version));
await tx.delete(catalogWeapons).where(eq(catalogWeapons.version, version));
await tx.delete(catalogArtifacts).where(eq(catalogArtifacts.version, version));
if (characters.length) await tx.insert(catalogCharacters).values(characters.map((item) => ({ version: item.version, key: item.key, name: item.name, element: item.element, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey, constellations: item.constellations })));
if (weapons.length) await tx.insert(catalogWeapons).values(weapons.map((item) => ({ version: item.version, key: item.key, name: item.name, rarity: item.rarity, weaponType: item.weaponType, imageKey: item.imageKey })));
if (artifacts.length) await tx.insert(catalogArtifacts).values(artifacts.map((item) => ({ version: item.version, key: item.key, name: item.name, imageKey: item.imageKey })));
await tx.update(catalogVersions).set({ active: false });
await tx.update(catalogVersions).set({ active: true, syncedAt: new Date() }).where(eq(catalogVersions.version, version));
await tx.update(catalogSyncJobs).set({ status: "completed", completedAt: new Date(), progress: uniqueAssets.size, total: uniqueAssets.size }).where(eq(catalogSyncJobs.id, jobId));
});
await storage.write("catalog/current.json", JSON.stringify({ version }), { type: "application/json", acl: "public-read" });
} catch (cause) {
await getDb().update(catalogSyncJobs).set({ status: "failed", completedAt: new Date(), error: cause instanceof Error ? cause.message.slice(0, 4_000) : "Unknown sync failure" }).where(eq(catalogSyncJobs.id, jobId));
throw cause;
}
}