feat: locally store assets

This commit is contained in:
2026-03-13 17:16:54 +07:00 Unverified
parent e1415d76b1
commit 942ed655b5
5 changed files with 100 additions and 27 deletions
+2 -1
View File
@@ -41,4 +41,5 @@ yarn-error.log*
package-lock.json
**/*.bun
dist
dist
/store/
+6
View File
@@ -23,6 +23,9 @@ services:
retries: 3
start_period: 2s
volumes:
- assets:/app/store
deploy:
replicas: 1
@@ -35,3 +38,6 @@ services:
restart_policy:
condition: any
volumes:
assets:
+17 -2
View File
@@ -1,9 +1,24 @@
import cluster from "node:cluster";
import os from "node:os";
import process from "node:process";
import { AssetManager } from "./lib/assets";
if (cluster.isPrimary) {
for (let i = 0; i < os.availableParallelism(); i++) cluster.fork();
for (let i = 0; i < 2; i++) cluster.fork();
queueMicrotask(async () => {
const assets = new AssetManager();
assets.getMissingAssets();
setInterval(() => {
assets.getMissingAssets();
}, 86400000); // refresh assets every 24 hours
});
import.meta.hot.dispose(() => {
for (const worker of Object.values(cluster.workers ?? {})) {
worker?.kill();
}
});
} else {
await import("./server");
console.log(`Worker ${process.pid} started`);
+69
View File
@@ -0,0 +1,69 @@
import { mkdir, readdir } from "node:fs/promises";
import { join } from "node:path";
import { file, hash, write } from "bun";
import { TTLCache } from "enka.ts";
import { AvatarStore } from "@/type/enka";
export const cache = new TTLCache();
export class AssetManager {
private cache = new Map<string, ArrayBuffer>();
async getMissingAssets() {
const store = await getEnkaAvatarStore();
await mkdir("store").catch(() => {});
const existing = await readdir("store");
const missing = new Set<string>();
Object.values(store)
.flatMap((a) => [
...a.Consts,
...Object.values(a.Skills),
a.SideIconName.replace("AvatarIcon_Side", "Gacha_AvatarImg"),
])
.map((n) => existing.includes(hash(n).toString(36)) || missing.add(n));
console.log(
`${missing.size} assets missing, ${existing.length} assets in store`,
);
for (const name of missing)
await fetch(new URL(name, "https://enka.network"))
.then((res) => {
if (res.status === 404) {
console.warn(`Asset not found: ${name}`);
return new ArrayBuffer(0);
}
if (!res.ok)
throw new Error(`Failed to fetch ${name}: ${res.statusText}`);
return res.arrayBuffer();
})
.then((d) => write(`store/${hash(name).toString(36)}`, d));
console.log("All missing assets fetched and stored");
}
async load(path: string) {
if (this.cache.has(path)) return this.cache.get(path)!;
const local = file(join("store", hash(path).toString(36)));
if (await local.exists()) return local.arrayBuffer();
const data = await fetch(new URL(path, "https://enka.network")).then(
(res) => res.arrayBuffer(),
);
this.cache.set(path, data);
queueMicrotask(() => write(local, data));
return data;
}
}
export async function getEnkaAvatarStore(): Promise<AvatarStore> {
const cached = cache.get("enkaAvatarStore");
if (cached) return cached as AvatarStore;
const response = await fetch(
"https://github.com/EnkaNetwork/API-docs/raw/refs/heads/master/store/gi/avatars.json",
);
const data = await response.json();
cache.set("enkaAvatarStore", data, 86400); // 24 hours
return data;
}
+6 -24
View File
@@ -7,7 +7,6 @@ import {
InvalidFormatError,
MaintenanceError,
RatelimitError,
TTLCache,
} from "enka.ts";
import { twj } from "tw-to-css";
import type { App } from "@";
@@ -17,9 +16,9 @@ import { featherImage } from "@/assets/feather";
import shadeSvg from "@/assets/shade.svg" with { type: "file" };
import fontFile from "@/assets/shinshin.woff2" with { type: "file" };
import { TalentSvg } from "@/assets/talent";
import { AvatarStore } from "@/type/enka";
import { AssetManager, getEnkaAvatarStore } from "@/lib/assets";
const cache = new TTLCache();
const assets = new AssetManager();
const font = file(fontFile);
const shade = file(shadeSvg);
@@ -65,21 +64,16 @@ export function card(app: App) {
...[...avatar.Consts, ...Object.values(avatar.Skills)].map(
async (path) => ({
src: path,
data: await fetch(
new URL(path, "https://enka.network"),
).then((res) => res.arrayBuffer()),
data: await assets.load(path),
}),
),
fetch(
new URL(
assets
.load(
avatar.SideIconName.replace(
"AvatarIcon_Side",
"Gacha_AvatarImg",
),
"https://enka.network",
),
)
.then((res) => res.arrayBuffer())
)
.then(async (buf) => ({
src: "/avatar",
data: await featherImage(buf, [0, 0.3, 0, 0]),
@@ -320,15 +314,3 @@ export function card(app: App) {
},
);
}
async function getEnkaAvatarStore(): Promise<AvatarStore> {
const cached = cache.get("enkaAvatarStore");
if (cached) return cached as AvatarStore;
const response = await fetch(
"https://github.com/EnkaNetwork/API-docs/raw/refs/heads/master/store/gi/avatars.json",
);
const data = await response.json();
cache.set("enkaAvatarStore", data, 86400); // 24 hours
return data;
}