feat: normalize catalog tables and current character assets
This commit is contained in:
+432
-126
@@ -6,6 +6,7 @@ import * as z from "zod";
|
||||
import { getDb } from "@/db/client";
|
||||
import {
|
||||
catalogArtifacts,
|
||||
catalogCharacterCurrent,
|
||||
catalogCharacters,
|
||||
catalogSyncJobs,
|
||||
catalogVersions,
|
||||
@@ -19,7 +20,10 @@ import {
|
||||
splashImageName,
|
||||
talentSnapshot,
|
||||
} from "@/lib/catalog/lunaris";
|
||||
import type { CatalogConstellation, CatalogTalent } from "@/lib/catalog/lunaris";
|
||||
import type {
|
||||
CatalogConstellation,
|
||||
CatalogTalent,
|
||||
} from "@/lib/catalog/lunaris";
|
||||
import { customTravelers, isUpstreamTraveler } from "@/lib/catalog/travelers";
|
||||
import { getMediaStorage } from "@/lib/media/storage";
|
||||
|
||||
@@ -30,24 +34,33 @@ 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(),
|
||||
qualityType: z.string().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(),
|
||||
qualityType: z.string().optional(),
|
||||
}),
|
||||
);
|
||||
|
||||
interface SyncedCharacter {
|
||||
version: string;
|
||||
@@ -58,10 +71,10 @@ interface SyncedCharacter {
|
||||
weaponType: string | null;
|
||||
imageKey: string;
|
||||
elementIconKey: string | null;
|
||||
coopImageKey: string | null;
|
||||
splashImageKey: string | null;
|
||||
nameCardImageKey: string | null;
|
||||
sourceImage: string | null;
|
||||
coopSourceImage: string | null;
|
||||
splashSourceImage: string | null;
|
||||
nameCardSourceImage: string | null;
|
||||
localImage: string | null;
|
||||
constellations: CatalogConstellation[];
|
||||
@@ -69,15 +82,23 @@ interface SyncedCharacter {
|
||||
}
|
||||
|
||||
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}`);
|
||||
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" });
|
||||
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}`);
|
||||
if (!response.ok)
|
||||
throw new Error(`Lunaris returned ${response.status} for ${url}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -87,10 +108,21 @@ function webpName(value: string): string {
|
||||
|
||||
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;
|
||||
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[]> {
|
||||
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() {
|
||||
@@ -99,44 +131,81 @@ async function mapConcurrent<T, R>(items: T[], task: (item: T) => Promise<R>): P
|
||||
results[index] = await task(items[index]);
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(4, items.length) }, () => worker()));
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(4, items.length) }, () => worker()),
|
||||
);
|
||||
return results;
|
||||
}
|
||||
|
||||
async function uploadAssets(
|
||||
jobId: string,
|
||||
assets: Array<{ source?: string; body?: Uint8Array; target: string; type?: string }>,
|
||||
assets: Array<{
|
||||
source?: string;
|
||||
body?: Uint8Array;
|
||||
target: string;
|
||||
type?: 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));
|
||||
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++];
|
||||
if (asset.body) {
|
||||
await storage.write(asset.target, asset.body, { type: asset.type ?? "image/webp", acl: "public-read" });
|
||||
} else {
|
||||
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: asset.type ?? "image/webp", acl: "public-read" });
|
||||
if (!(await storage.file(asset.target).exists())) {
|
||||
if (asset.body) {
|
||||
await storage.write(asset.target, asset.body, {
|
||||
type: asset.type ?? "image/webp",
|
||||
acl: "public-read",
|
||||
});
|
||||
} else {
|
||||
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: asset.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 getDb()
|
||||
.update(catalogSyncJobs)
|
||||
.set({ progress: completed })
|
||||
.where(eq(catalogSyncJobs.id, jobId));
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(Array.from({ length: Math.min(4, assets.length) }, () => worker()));
|
||||
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);
|
||||
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();
|
||||
const [claimed] = await getDb()
|
||||
.update(catalogSyncJobs)
|
||||
.set({ status: "processing", startedAt: new Date(), error: null })
|
||||
.where(eq(catalogSyncJobs.id, pending.id))
|
||||
.returning();
|
||||
return claimed ?? null;
|
||||
}
|
||||
|
||||
@@ -144,7 +213,10 @@ 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));
|
||||
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`),
|
||||
@@ -155,71 +227,137 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
|
||||
const artifactData = artifactSchema.parse(artifactsRaw);
|
||||
|
||||
const remoteCharacters: SyncedCharacter[] = await mapConcurrent(
|
||||
Object.entries(characterData).filter(([, value]) => value.enName && value.enName !== "???" && !isUpstreamTraveler(value.enName) && value.CardImg),
|
||||
Object.entries(characterData).filter(
|
||||
([, value]) =>
|
||||
value.enName &&
|
||||
value.enName !== "???" &&
|
||||
!isUpstreamTraveler(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 detailRaw = await getOptionalJson(
|
||||
`${API_ROOT}/${version}/en/char/${encodeURIComponent(key)}.json`,
|
||||
);
|
||||
const detail = detailRaw
|
||||
? characterDetailSchema.parse(detailRaw)
|
||||
: null;
|
||||
const avatarIcon = webpName(avatarIconName(value.CardImg));
|
||||
const splashImage = splashImageName(value.CardImg);
|
||||
const nameCardImage = nameCardImageName(value.CardImg);
|
||||
const element = detail?.info?.element ?? value.element ?? null;
|
||||
return {
|
||||
version, key, name: value.enName, element, rarity: rarity(value.qualityType), weaponType: detail?.info?.weapon ?? value.weaponType ?? null,
|
||||
imageKey: `catalog/${version}/characters/${avatarIcon}`,
|
||||
elementIconKey: element ? `catalog/${version}/elements/${element.toLowerCase()}.png` : null,
|
||||
coopImageKey: `catalog/${version}/characters/splash/${splashImage}`,
|
||||
nameCardImageKey: `catalog/${version}/characters/namecard/${nameCardImage}`,
|
||||
version,
|
||||
key,
|
||||
name: value.enName,
|
||||
element,
|
||||
rarity: rarity(value.qualityType),
|
||||
weaponType: detail?.info?.weapon ?? value.weaponType ?? null,
|
||||
imageKey: `catalog/characters/avatar/${avatarIcon}`,
|
||||
elementIconKey: element
|
||||
? `catalog/elements/${element.toLowerCase()}.png`
|
||||
: null,
|
||||
splashImageKey: `catalog/characters/splash/${splashImage}`,
|
||||
nameCardImageKey: `catalog/characters/namecard/${nameCardImage}`,
|
||||
sourceImage: `${API_ROOT}/assets/avataricon/${encodeURIComponent(avatarIcon)}`,
|
||||
coopSourceImage: `${API_ROOT}/assets/gachaimg/${encodeURIComponent(splashImage)}`,
|
||||
splashSourceImage: `${API_ROOT}/assets/gachaimg/${encodeURIComponent(splashImage)}`,
|
||||
nameCardSourceImage: `${API_ROOT}/assets/namecardpic/${encodeURIComponent(nameCardImage)}`,
|
||||
localImage: null,
|
||||
constellations: detail ? constellationSnapshot(detail, (icon) => `catalog/${version}/constellations/${webpName(icon)}`) : [],
|
||||
talents: detail ? talentSnapshot(detail, (icon) => `catalog/${version}/talents/${webpName(icon)}`) : [],
|
||||
constellations: detail
|
||||
? constellationSnapshot(
|
||||
detail,
|
||||
(icon) => `catalog/characters/constellations/${webpName(icon)}`,
|
||||
)
|
||||
: [],
|
||||
talents: detail
|
||||
? talentSnapshot(
|
||||
detail,
|
||||
(icon) => `catalog/characters/talents/${webpName(icon)}`,
|
||||
)
|
||||
: [],
|
||||
};
|
||||
},
|
||||
);
|
||||
const customCharacters: SyncedCharacter[] = await mapConcurrent(
|
||||
[...customTravelers],
|
||||
async (traveler) => {
|
||||
const detail = characterDetailSchema.parse(
|
||||
await getJson(
|
||||
`${API_ROOT}/${version}/en/char/${traveler.detailKey}.json`,
|
||||
),
|
||||
);
|
||||
return {
|
||||
version,
|
||||
key: traveler.key,
|
||||
name: traveler.name,
|
||||
element: traveler.element,
|
||||
rarity: traveler.rarity,
|
||||
weaponType: "WEAPON_SWORD_ONE_HAND",
|
||||
imageKey: `catalog/characters/avatar/${traveler.asset}`,
|
||||
elementIconKey: `catalog/elements/${traveler.element.toLowerCase()}.png`,
|
||||
splashImageKey: null,
|
||||
nameCardImageKey: null,
|
||||
sourceImage: null,
|
||||
splashSourceImage: null,
|
||||
nameCardSourceImage: null,
|
||||
localImage: traveler.asset,
|
||||
constellations: constellationSnapshot(
|
||||
detail,
|
||||
(icon) => `catalog/characters/constellations/${webpName(icon)}`,
|
||||
),
|
||||
talents: talentSnapshot(
|
||||
detail,
|
||||
(icon) => `catalog/characters/talents/${webpName(icon)}`,
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
const customCharacters: SyncedCharacter[] = await mapConcurrent([...customTravelers], async (traveler) => {
|
||||
const detail = characterDetailSchema.parse(await getJson(`${API_ROOT}/${version}/en/char/${traveler.detailKey}.json`));
|
||||
return {
|
||||
version,
|
||||
key: traveler.key,
|
||||
name: traveler.name,
|
||||
element: traveler.element,
|
||||
rarity: traveler.rarity,
|
||||
weaponType: "WEAPON_SWORD_ONE_HAND",
|
||||
imageKey: `catalog/${version}/characters/${traveler.asset}`,
|
||||
elementIconKey: `catalog/${version}/elements/${traveler.element.toLowerCase()}.png`,
|
||||
coopImageKey: null,
|
||||
nameCardImageKey: null,
|
||||
sourceImage: null,
|
||||
coopSourceImage: null,
|
||||
nameCardSourceImage: null,
|
||||
localImage: traveler.asset,
|
||||
constellations: constellationSnapshot(detail, (icon) => `catalog/${version}/constellations/${webpName(icon)}`),
|
||||
talents: talentSnapshot(detail, (icon) => `catalog/${version}/talents/${webpName(icon)}`),
|
||||
};
|
||||
});
|
||||
let characters = [...remoteCharacters, ...customCharacters];
|
||||
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, rarity: rarity(value.qualityType),
|
||||
imageKey: `catalog/${version}/artifacts/${webpName(value.setIcon)}`,
|
||||
sourceImage: `${API_ROOT}/assets/artifacts/${encodeURIComponent(webpName(value.setIcon))}`,
|
||||
}));
|
||||
const uniqueAssets = new Map<string, { source?: string; body?: Uint8Array; target: string; type?: string }>();
|
||||
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,
|
||||
rarity: rarity(value.qualityType),
|
||||
imageKey: `catalog/${version}/artifacts/${webpName(value.setIcon)}`,
|
||||
sourceImage: `${API_ROOT}/assets/artifacts/${encodeURIComponent(webpName(value.setIcon))}`,
|
||||
}));
|
||||
const uniqueAssets = new Map<
|
||||
string,
|
||||
{ source?: string; body?: Uint8Array; target: string; type?: string }
|
||||
>();
|
||||
for (const character of characters) {
|
||||
uniqueAssets.set(character.imageKey, character.localImage ? {
|
||||
body: await readFile(join(process.cwd(), "lib/catalog/assets", character.localImage)),
|
||||
target: character.imageKey,
|
||||
} : { source: character.sourceImage!, target: character.imageKey });
|
||||
if (character.coopImageKey && character.coopSourceImage) {
|
||||
uniqueAssets.set(character.coopImageKey, {
|
||||
source: character.coopSourceImage,
|
||||
target: character.coopImageKey,
|
||||
uniqueAssets.set(
|
||||
character.imageKey,
|
||||
character.localImage
|
||||
? {
|
||||
body: await readFile(
|
||||
join(process.cwd(), "lib/catalog/assets", character.localImage),
|
||||
),
|
||||
target: character.imageKey,
|
||||
}
|
||||
: { source: character.sourceImage!, target: character.imageKey },
|
||||
);
|
||||
if (character.splashImageKey && character.splashSourceImage) {
|
||||
uniqueAssets.set(character.splashImageKey, {
|
||||
source: character.splashSourceImage,
|
||||
target: character.splashImageKey,
|
||||
type: "image/png",
|
||||
});
|
||||
}
|
||||
@@ -231,63 +369,231 @@ export async function processCatalogSyncJob(jobId: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const item of [...weapons, ...artifacts]) uniqueAssets.set(item.imageKey, { source: item.sourceImage, target: item.imageKey });
|
||||
for (const character of characters) {
|
||||
if (character.elementIconKey && character.element) uniqueAssets.set(character.elementIconKey, {
|
||||
source: `${API_ROOT}/assets/icons/${encodeURIComponent(character.element.toLowerCase())}.png`,
|
||||
target: character.elementIconKey,
|
||||
type: "image/png",
|
||||
for (const item of [...weapons, ...artifacts])
|
||||
uniqueAssets.set(item.imageKey, {
|
||||
source: item.sourceImage,
|
||||
target: item.imageKey,
|
||||
});
|
||||
for (const character of characters) {
|
||||
if (character.elementIconKey && character.element)
|
||||
uniqueAssets.set(character.elementIconKey, {
|
||||
source: `${API_ROOT}/assets/icons/${encodeURIComponent(character.element.toLowerCase())}.png`,
|
||||
target: character.elementIconKey,
|
||||
type: "image/png",
|
||||
});
|
||||
}
|
||||
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,
|
||||
});
|
||||
if (constellation.iconKey)
|
||||
uniqueAssets.set(constellation.iconKey, {
|
||||
source: `${API_ROOT}/assets/skills/${encodeURIComponent(constellation.iconKey.split("/").at(-1)!)}`,
|
||||
target: constellation.iconKey,
|
||||
});
|
||||
}
|
||||
for (const talent of character.talents) {
|
||||
if (talent.iconKey) uniqueAssets.set(talent.iconKey, {
|
||||
source: `${API_ROOT}/assets/skills/${encodeURIComponent(talent.iconKey.split("/").at(-1)!)}`,
|
||||
target: talent.iconKey,
|
||||
});
|
||||
if (talent.iconKey)
|
||||
uniqueAssets.set(talent.iconKey, {
|
||||
source: `${API_ROOT}/assets/skills/${encodeURIComponent(talent.iconKey.split("/").at(-1)!)}`,
|
||||
target: talent.iconKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
const missingAssets = await uploadAssets(jobId, [...uniqueAssets.values()]);
|
||||
characters = characters.filter((item) => !missingAssets.has(item.imageKey));
|
||||
characters = characters.map((item) => ({
|
||||
...item,
|
||||
elementIconKey: item.elementIconKey && !missingAssets.has(item.elementIconKey) ? item.elementIconKey : null,
|
||||
coopImageKey: item.coopImageKey && !missingAssets.has(item.coopImageKey) ? item.coopImageKey : null,
|
||||
nameCardImageKey: item.nameCardImageKey && !missingAssets.has(item.nameCardImageKey) ? item.nameCardImageKey : null,
|
||||
constellations: item.constellations.map((constellation) => ({ ...constellation, iconKey: constellation.iconKey && !missingAssets.has(constellation.iconKey) ? constellation.iconKey : null })),
|
||||
talents: item.talents.map((talent) => ({ ...talent, iconKey: talent.iconKey && !missingAssets.has(talent.iconKey) ? talent.iconKey : null })),
|
||||
elementIconKey:
|
||||
item.elementIconKey && !missingAssets.has(item.elementIconKey)
|
||||
? item.elementIconKey
|
||||
: null,
|
||||
splashImageKey:
|
||||
item.splashImageKey && !missingAssets.has(item.splashImageKey)
|
||||
? item.splashImageKey
|
||||
: null,
|
||||
nameCardImageKey:
|
||||
item.nameCardImageKey && !missingAssets.has(item.nameCardImageKey)
|
||||
? item.nameCardImageKey
|
||||
: null,
|
||||
constellations: item.constellations.map((constellation) => ({
|
||||
...constellation,
|
||||
iconKey:
|
||||
constellation.iconKey && !missingAssets.has(constellation.iconKey)
|
||||
? constellation.iconKey
|
||||
: null,
|
||||
})),
|
||||
talents: item.talents.map((talent) => ({
|
||||
...talent,
|
||||
iconKey:
|
||||
talent.iconKey && !missingAssets.has(talent.iconKey)
|
||||
? talent.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, elementIconKey: item.elementIconKey, coopImageKey: item.coopImageKey, nameCardImageKey: item.nameCardImageKey, constellations: item.constellations, talents: item.talents }))), { 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, rarity: item.rarity, imageKey: item.imageKey }))), { type: "application/json", acl: "public-read" }),
|
||||
storage.write(
|
||||
"catalog/characters.json",
|
||||
JSON.stringify(
|
||||
characters.map((item) => ({
|
||||
key: item.key,
|
||||
name: item.name,
|
||||
element: item.element,
|
||||
rarity: item.rarity,
|
||||
weaponType: item.weaponType,
|
||||
imageKey: item.imageKey,
|
||||
elementIconKey: item.elementIconKey,
|
||||
splashImageKey: item.splashImageKey,
|
||||
nameCardImageKey: item.nameCardImageKey,
|
||||
constellations: item.constellations,
|
||||
talents: item.talents,
|
||||
})),
|
||||
),
|
||||
{ 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,
|
||||
rarity: item.rarity,
|
||||
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, elementIconKey: item.elementIconKey, coopImageKey: item.coopImageKey, nameCardImageKey: item.nameCardImageKey, constellations: item.constellations, talents: item.talents })));
|
||||
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, rarity: item.rarity, imageKey: item.imageKey })));
|
||||
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(catalogCharacterCurrent);
|
||||
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,
|
||||
elementIconKey: item.elementIconKey,
|
||||
coopImageKey: item.splashImageKey,
|
||||
nameCardImageKey: item.nameCardImageKey,
|
||||
constellations: item.constellations,
|
||||
talents: item.talents,
|
||||
})),
|
||||
);
|
||||
if (characters.length)
|
||||
await tx.insert(catalogCharacterCurrent).values(
|
||||
characters.map((item) => ({
|
||||
key: item.key,
|
||||
name: item.name,
|
||||
element: item.element,
|
||||
rarity: item.rarity,
|
||||
weaponType: item.weaponType,
|
||||
imageKey: item.imageKey,
|
||||
elementIconKey: item.elementIconKey,
|
||||
splashImageKey: item.splashImageKey,
|
||||
nameCardImageKey: item.nameCardImageKey,
|
||||
constellations: item.constellations,
|
||||
talents: item.talents,
|
||||
})),
|
||||
);
|
||||
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,
|
||||
rarity: item.rarity,
|
||||
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 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",
|
||||
});
|
||||
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));
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
artifactProfiles,
|
||||
artifactRecommendations,
|
||||
catalogArtifacts,
|
||||
catalogCharacters,
|
||||
catalogCharacterCurrent,
|
||||
catalogSyncJobs,
|
||||
catalogVersions,
|
||||
catalogWeapons,
|
||||
@@ -54,7 +54,7 @@ export async function createGuide(input: unknown) {
|
||||
const db = getDb();
|
||||
const [version] = await db.select().from(catalogVersions).where(eq(catalogVersions.active, true)).limit(1);
|
||||
if (!version) throw new Error("กรุณา Sync ข้อมูล Lunaris ก่อนสร้างคู่มือ");
|
||||
const [character] = await db.select().from(catalogCharacters).where(and(eq(catalogCharacters.version, version.version), eq(catalogCharacters.key, parsed.characterKey))).limit(1);
|
||||
const [character] = await db.select().from(catalogCharacterCurrent).where(eq(catalogCharacterCurrent.key, parsed.characterKey)).limit(1);
|
||||
if (!character) throw new Error("ไม่พบตัวละครใน Catalog เวอร์ชันปัจจุบัน");
|
||||
const [cover] = await db.select().from(media).where(and(eq(media.id, parsed.coverMediaId), eq(media.status, "ready"))).limit(1);
|
||||
if (!cover) throw new Error("กรุณาอัปโหลดภาพ Guide ให้สำเร็จ");
|
||||
@@ -154,7 +154,7 @@ export async function saveTeams(guideId: string, input: unknown) {
|
||||
const weaponKeys = [...new Set(members.map((member) => member.weaponKey).filter((key): key is string => Boolean(key)))];
|
||||
const artifactKeys = [...new Set(members.map((member) => member.artifactKey).filter((key): key is string => Boolean(key)))];
|
||||
const [characters, weapons, artifacts] = await Promise.all([
|
||||
characterKeys.length ? getDb().select().from(catalogCharacters).where(and(eq(catalogCharacters.version, guide.catalogVersion), inArray(catalogCharacters.key, characterKeys))) : [],
|
||||
characterKeys.length ? getDb().select().from(catalogCharacterCurrent).where(inArray(catalogCharacterCurrent.key, characterKeys)) : [],
|
||||
weaponKeys.length ? getDb().select().from(catalogWeapons).where(and(eq(catalogWeapons.version, guide.catalogVersion), inArray(catalogWeapons.key, weaponKeys))) : [],
|
||||
artifactKeys.length ? getDb().select().from(catalogArtifacts).where(and(eq(catalogArtifacts.version, guide.catalogVersion), inArray(catalogArtifacts.key, artifactKeys))) : [],
|
||||
]);
|
||||
|
||||
+13
-19
@@ -8,7 +8,7 @@ import {
|
||||
artifactProfiles,
|
||||
artifactRecommendations,
|
||||
catalogArtifacts,
|
||||
catalogCharacters,
|
||||
catalogCharacterCurrent,
|
||||
catalogSyncJobs,
|
||||
catalogVersions,
|
||||
catalogWeapons,
|
||||
@@ -23,11 +23,11 @@ import {
|
||||
} from "@/db/schema";
|
||||
import { publicMediaUrl } from "@/lib/media/storage";
|
||||
|
||||
function catalogCharacterWithUrls(item: typeof catalogCharacters.$inferSelect) {
|
||||
function catalogCharacterWithUrls(item: typeof catalogCharacterCurrent.$inferSelect) {
|
||||
return {
|
||||
...item,
|
||||
imageUrl: publicMediaUrl(item.imageKey),
|
||||
coopImageUrl: item.coopImageKey ? publicMediaUrl(item.coopImageKey) : null,
|
||||
splashImageUrl: item.splashImageKey ? publicMediaUrl(item.splashImageKey) : null,
|
||||
nameCardImageUrl: item.nameCardImageKey ? publicMediaUrl(item.nameCardImageKey) : null,
|
||||
elementIconUrl: item.elementIconKey ? publicMediaUrl(item.elementIconKey) : null,
|
||||
constellations: item.constellations.map((constellation) => ({
|
||||
@@ -46,7 +46,7 @@ export async function getActiveCatalog() {
|
||||
const [version] = await db.select().from(catalogVersions).where(eq(catalogVersions.active, true)).limit(1);
|
||||
if (!version) return null;
|
||||
const [characters, weapons, artifacts] = await Promise.all([
|
||||
db.select().from(catalogCharacters).where(eq(catalogCharacters.version, version.version)).orderBy(asc(catalogCharacters.name)),
|
||||
db.select().from(catalogCharacterCurrent).orderBy(asc(catalogCharacterCurrent.name)),
|
||||
db.select().from(catalogWeapons).where(eq(catalogWeapons.version, version.version)).orderBy(asc(catalogWeapons.name)),
|
||||
db.select().from(catalogArtifacts).where(eq(catalogArtifacts.version, version.version)).orderBy(asc(catalogArtifacts.name)),
|
||||
]);
|
||||
@@ -82,10 +82,10 @@ export async function getGuideByCharacterKey(characterKey: string) {
|
||||
|
||||
export async function getPublicGuide(slug: string) {
|
||||
const [row] = await getDb()
|
||||
.select({ guide: guides, cover: media, character: catalogCharacters })
|
||||
.select({ guide: guides, cover: media, character: catalogCharacterCurrent })
|
||||
.from(guides)
|
||||
.innerJoin(media, eq(media.id, guides.coverMediaId))
|
||||
.innerJoin(catalogCharacters, and(eq(catalogCharacters.version, guides.catalogVersion), eq(catalogCharacters.key, guides.characterKey)))
|
||||
.innerJoin(catalogCharacterCurrent, eq(catalogCharacterCurrent.key, guides.characterKey))
|
||||
.where(and(eq(guides.slug, slug), isNull(guides.trashedAt)))
|
||||
.limit(1);
|
||||
if (!row) return null;
|
||||
@@ -101,7 +101,7 @@ export async function getPublicGuide(slug: string) {
|
||||
const artifactRows = groups.length ? await getDb().select({ recommendation: artifactRecommendations, artifact: catalogArtifacts }).from(artifactRecommendations).innerJoin(catalogArtifacts, and(eq(catalogArtifacts.version, artifactRecommendations.catalogVersion), eq(catalogArtifacts.key, artifactRecommendations.firstArtifactKey))).where(inArray(artifactRecommendations.groupId, groups.map((group) => group.id))).orderBy(asc(artifactRecommendations.sortOrder)) : [];
|
||||
const secondKeys = artifactRows.map(({ recommendation }) => recommendation.secondArtifactKey).filter((key): key is string => Boolean(key));
|
||||
const secondArtifacts = secondKeys.length ? await getDb().select().from(catalogArtifacts).where(and(eq(catalogArtifacts.version, row.guide.catalogVersion), inArray(catalogArtifacts.key, secondKeys))) : [];
|
||||
const members = teams.length ? await getDb().select({ member: teamMembers, character: catalogCharacters }).from(teamMembers).innerJoin(catalogCharacters, and(eq(catalogCharacters.version, teamMembers.catalogVersion), eq(catalogCharacters.key, teamMembers.characterKey))).where(inArray(teamMembers.teamId, teams.map((team) => team.id))).orderBy(asc(teamMembers.position)) : [];
|
||||
const members = teams.length ? await getDb().select({ member: teamMembers, character: catalogCharacterCurrent }).from(teamMembers).innerJoin(catalogCharacterCurrent, eq(catalogCharacterCurrent.key, teamMembers.characterKey)).where(inArray(teamMembers.teamId, teams.map((team) => team.id))).orderBy(asc(teamMembers.position)) : [];
|
||||
const memberWeaponKeys = members.map(({ member }) => member.weaponKey).filter((key): key is string => Boolean(key));
|
||||
const memberArtifactKeys = members.map(({ member }) => member.artifactKey).filter((key): key is string => Boolean(key));
|
||||
const [memberWeapons, memberArtifacts] = await Promise.all([
|
||||
@@ -156,14 +156,11 @@ export async function getPublicTeamExport(teamId: string) {
|
||||
if (!row) return null;
|
||||
|
||||
const members = await getDb()
|
||||
.select({ member: teamMembers, character: catalogCharacters })
|
||||
.select({ member: teamMembers, character: catalogCharacterCurrent })
|
||||
.from(teamMembers)
|
||||
.innerJoin(
|
||||
catalogCharacters,
|
||||
and(
|
||||
eq(catalogCharacters.version, teamMembers.catalogVersion),
|
||||
eq(catalogCharacters.key, teamMembers.characterKey),
|
||||
),
|
||||
catalogCharacterCurrent,
|
||||
eq(catalogCharacterCurrent.key, teamMembers.characterKey),
|
||||
)
|
||||
.where(eq(teamMembers.teamId, teamId))
|
||||
.orderBy(asc(teamMembers.position));
|
||||
@@ -232,7 +229,7 @@ export async function getPublicWeaponExport(guideId: string) {
|
||||
|
||||
export async function getPublicArtifactExport(guideId: string) {
|
||||
const [row] = await getDb()
|
||||
.select({ guide: guides, character: catalogCharacters })
|
||||
.select({ guide: guides, character: catalogCharacterCurrent })
|
||||
.from(guides)
|
||||
.innerJoin(
|
||||
guideSections,
|
||||
@@ -243,11 +240,8 @@ export async function getPublicArtifactExport(guideId: string) {
|
||||
),
|
||||
)
|
||||
.innerJoin(
|
||||
catalogCharacters,
|
||||
and(
|
||||
eq(catalogCharacters.version, guides.catalogVersion),
|
||||
eq(catalogCharacters.key, guides.characterKey),
|
||||
),
|
||||
catalogCharacterCurrent,
|
||||
eq(catalogCharacterCurrent.key, guides.characterKey),
|
||||
)
|
||||
.where(and(eq(guides.id, guideId), isNull(guides.trashedAt)))
|
||||
.limit(1);
|
||||
|
||||
Reference in New Issue
Block a user