feat: locally store assets
This commit is contained in:
+2
-1
@@ -41,4 +41,5 @@ yarn-error.log*
|
|||||||
package-lock.json
|
package-lock.json
|
||||||
**/*.bun
|
**/*.bun
|
||||||
|
|
||||||
dist
|
dist
|
||||||
|
/store/
|
||||||
@@ -23,6 +23,9 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 2s
|
start_period: 2s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
- assets:/app/store
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
replicas: 1
|
replicas: 1
|
||||||
|
|
||||||
@@ -35,3 +38,6 @@ services:
|
|||||||
|
|
||||||
restart_policy:
|
restart_policy:
|
||||||
condition: any
|
condition: any
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
assets:
|
||||||
|
|||||||
+17
-2
@@ -1,9 +1,24 @@
|
|||||||
import cluster from "node:cluster";
|
import cluster from "node:cluster";
|
||||||
import os from "node:os";
|
|
||||||
import process from "node:process";
|
import process from "node:process";
|
||||||
|
import { AssetManager } from "./lib/assets";
|
||||||
|
|
||||||
if (cluster.isPrimary) {
|
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 {
|
} else {
|
||||||
await import("./server");
|
await import("./server");
|
||||||
console.log(`Worker ${process.pid} started`);
|
console.log(`Worker ${process.pid} started`);
|
||||||
|
|||||||
@@ -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
@@ -7,7 +7,6 @@ import {
|
|||||||
InvalidFormatError,
|
InvalidFormatError,
|
||||||
MaintenanceError,
|
MaintenanceError,
|
||||||
RatelimitError,
|
RatelimitError,
|
||||||
TTLCache,
|
|
||||||
} from "enka.ts";
|
} from "enka.ts";
|
||||||
import { twj } from "tw-to-css";
|
import { twj } from "tw-to-css";
|
||||||
import type { App } from "@";
|
import type { App } from "@";
|
||||||
@@ -17,9 +16,9 @@ import { featherImage } from "@/assets/feather";
|
|||||||
import shadeSvg from "@/assets/shade.svg" with { type: "file" };
|
import shadeSvg from "@/assets/shade.svg" with { type: "file" };
|
||||||
import fontFile from "@/assets/shinshin.woff2" with { type: "file" };
|
import fontFile from "@/assets/shinshin.woff2" with { type: "file" };
|
||||||
import { TalentSvg } from "@/assets/talent";
|
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 font = file(fontFile);
|
||||||
const shade = file(shadeSvg);
|
const shade = file(shadeSvg);
|
||||||
@@ -65,21 +64,16 @@ export function card(app: App) {
|
|||||||
...[...avatar.Consts, ...Object.values(avatar.Skills)].map(
|
...[...avatar.Consts, ...Object.values(avatar.Skills)].map(
|
||||||
async (path) => ({
|
async (path) => ({
|
||||||
src: path,
|
src: path,
|
||||||
data: await fetch(
|
data: await assets.load(path),
|
||||||
new URL(path, "https://enka.network"),
|
|
||||||
).then((res) => res.arrayBuffer()),
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
fetch(
|
assets
|
||||||
new URL(
|
.load(
|
||||||
avatar.SideIconName.replace(
|
avatar.SideIconName.replace(
|
||||||
"AvatarIcon_Side",
|
"AvatarIcon_Side",
|
||||||
"Gacha_AvatarImg",
|
"Gacha_AvatarImg",
|
||||||
),
|
),
|
||||||
"https://enka.network",
|
)
|
||||||
),
|
|
||||||
)
|
|
||||||
.then((res) => res.arrayBuffer())
|
|
||||||
.then(async (buf) => ({
|
.then(async (buf) => ({
|
||||||
src: "/avatar",
|
src: "/avatar",
|
||||||
data: await featherImage(buf, [0, 0.3, 0, 0]),
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user