derek-thomas commited on
Commit
b457dd3
·
1 Parent(s): 5ac82ff

Add cache backup support

Browse files
Files changed (7) hide show
  1. .gitignore +1 -0
  2. Dockerfile +1 -0
  3. README.md +10 -1
  4. package-lock.json +66 -5
  5. package.json +1 -0
  6. server/index.js +134 -5
  7. spec.md +8 -2
.gitignore CHANGED
@@ -1,6 +1,7 @@
1
  node_modules
2
  dist
3
  .cache
 
4
  .DS_Store
5
  .env
6
  .env.*
 
1
  node_modules
2
  dist
3
  .cache
4
+ .idea
5
  .DS_Store
6
  .env
7
  .env.*
Dockerfile CHANGED
@@ -20,6 +20,7 @@ RUN npm install --omit=dev
20
 
21
  COPY --from=build /app/dist ./dist
22
  COPY server ./server
 
23
 
24
  EXPOSE 7860
25
 
 
20
 
21
  COPY --from=build /app/dist ./dist
22
  COPY server ./server
23
+ COPY dupr_players.csv ./dupr_players.csv
24
 
25
  EXPOSE 7860
26
 
README.md CHANGED
@@ -24,13 +24,20 @@ This Hugging Face Docker Space displays a May 2026 DUPR event roster from `dupr_
24
  Add this Hugging Face Space Secret:
25
 
26
  - `DUPR_BEARER_TOKEN`
 
27
 
28
- The value should be a valid DUPR bearer token copied from an authenticated DUPR session. Keep the Space private unless you are comfortable with other users making requests through that session.
 
 
 
 
 
29
 
30
  ## Data Files
31
 
32
  - `dupr_players.csv` is the roster source.
33
  - `.cache/player-cache.json` is generated at runtime and stores DUPR lookups, ratings, match summaries, and unresolved lookup errors.
 
34
 
35
  The CSV expects these columns:
36
 
@@ -46,6 +53,7 @@ Create a local `.env` file:
46
 
47
  ```bash
48
  DUPR_BEARER_TOKEN=your_token
 
49
  ```
50
 
51
  Then run:
@@ -71,5 +79,6 @@ Then open `http://127.0.0.1:7860`.
71
  - Runtime port is `7860`.
72
  - Create the Space with the Docker SDK.
73
  - Keep `DUPR_BEARER_TOKEN` configured as a Space Secret.
 
74
  - The DUPR token can expire and may need to be refreshed.
75
  - Do not commit tokens or private DUPR session data.
 
24
  Add this Hugging Face Space Secret:
25
 
26
  - `DUPR_BEARER_TOKEN`
27
+ - `HF_TOKEN`
28
 
29
+ `DUPR_BEARER_TOKEN` should be a valid DUPR bearer token copied from an authenticated DUPR session. `HF_TOKEN` should have write access to the backup dataset. Keep the Space private unless you are comfortable with other users making requests through that session.
30
+
31
+ Optional cache backup settings:
32
+
33
+ - `HF_DATASET_REPO`, defaults to `derek-thomas/dupr_explorer_may_2026`
34
+ - `HF_CACHE_PATH`, defaults to `player-cache.json`
35
 
36
  ## Data Files
37
 
38
  - `dupr_players.csv` is the roster source.
39
  - `.cache/player-cache.json` is generated at runtime and stores DUPR lookups, ratings, match summaries, and unresolved lookup errors.
40
+ - When `HF_TOKEN` is configured, the server restores `.cache/player-cache.json` from the Hugging Face dataset if the local file is missing, then backs up each cache write to `derek-thomas/dupr_explorer_may_2026`.
41
 
42
  The CSV expects these columns:
43
 
 
53
 
54
  ```bash
55
  DUPR_BEARER_TOKEN=your_token
56
+ HF_TOKEN=your_hf_token
57
  ```
58
 
59
  Then run:
 
79
  - Runtime port is `7860`.
80
  - Create the Space with the Docker SDK.
81
  - Keep `DUPR_BEARER_TOKEN` configured as a Space Secret.
82
+ - Keep `HF_TOKEN` configured as a Space Secret so runtime cache updates survive Space restarts and DUPR token interruptions.
83
  - The DUPR token can expire and may need to be refreshed.
84
  - Do not commit tokens or private DUPR session data.
package-lock.json CHANGED
@@ -8,6 +8,7 @@
8
  "name": "dupr-url-resolver",
9
  "version": "1.0.0",
10
  "dependencies": {
 
11
  "dotenv": "^17.4.2",
12
  "express": "^4.19.2"
13
  },
@@ -748,6 +749,47 @@
748
  "node": ">=18"
749
  }
750
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
751
  "node_modules/@jridgewell/gen-mapping": {
752
  "version": "0.3.13",
753
  "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1284,7 +1326,7 @@
1284
  "version": "5.0.1",
1285
  "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
1286
  "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
1287
- "dev": true,
1288
  "license": "MIT",
1289
  "engines": {
1290
  "node": ">=8"
@@ -1487,6 +1529,19 @@
1487
  "node": ">=8"
1488
  }
1489
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
1490
  "node_modules/cliui": {
1491
  "version": "8.0.1",
1492
  "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
@@ -1670,7 +1725,7 @@
1670
  "version": "8.0.0",
1671
  "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
1672
  "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
1673
- "dev": true,
1674
  "license": "MIT"
1675
  },
1676
  "node_modules/encodeurl": {
@@ -1933,6 +1988,12 @@
1933
  "url": "https://github.com/sponsors/ljharb"
1934
  }
1935
  },
 
 
 
 
 
 
1936
  "node_modules/gensync": {
1937
  "version": "1.0.0-beta.2",
1938
  "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -2087,7 +2148,7 @@
2087
  "version": "3.0.0",
2088
  "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
2089
  "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
2090
- "dev": true,
2091
  "license": "MIT",
2092
  "engines": {
2093
  "node": ">=8"
@@ -2708,7 +2769,7 @@
2708
  "version": "4.2.3",
2709
  "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
2710
  "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
2711
- "dev": true,
2712
  "license": "MIT",
2713
  "dependencies": {
2714
  "emoji-regex": "^8.0.0",
@@ -2723,7 +2784,7 @@
2723
  "version": "6.0.1",
2724
  "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
2725
  "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
2726
- "dev": true,
2727
  "license": "MIT",
2728
  "dependencies": {
2729
  "ansi-regex": "^5.0.1"
 
8
  "name": "dupr-url-resolver",
9
  "version": "1.0.0",
10
  "dependencies": {
11
+ "@huggingface/hub": "^2.13.0",
12
  "dotenv": "^17.4.2",
13
  "express": "^4.19.2"
14
  },
 
749
  "node": ">=18"
750
  }
751
  },
752
+ "node_modules/@huggingface/blake3-jit": {
753
+ "version": "0.0.2",
754
+ "resolved": "https://registry.npmjs.org/@huggingface/blake3-jit/-/blake3-jit-0.0.2.tgz",
755
+ "integrity": "sha512-Bq7B5qabyjrJfhBsl85Jd2QBtf+HzRD7h7A9GfN2lzrrsABhOa5evVPgzoCTxR7Ub0QFj7YDK1YkYRWBU25+2w==",
756
+ "license": "MIT"
757
+ },
758
+ "node_modules/@huggingface/hub": {
759
+ "version": "2.13.0",
760
+ "resolved": "https://registry.npmjs.org/@huggingface/hub/-/hub-2.13.0.tgz",
761
+ "integrity": "sha512-IAoqdpTV9HeMyooxKVvVGWirOJ+S2IAKnU2FSQSMz62ehPTKzxADAATy1fwAlYYQdHCV119GHFf4p9+ECQ+I5g==",
762
+ "license": "MIT",
763
+ "dependencies": {
764
+ "@huggingface/tasks": "^0.21.1",
765
+ "@huggingface/xetchunk-wasm": "^0.0.6"
766
+ },
767
+ "bin": {
768
+ "hfjs": "dist/cli.js"
769
+ },
770
+ "engines": {
771
+ "node": ">=18"
772
+ },
773
+ "optionalDependencies": {
774
+ "cli-progress": "^3.12.0"
775
+ }
776
+ },
777
+ "node_modules/@huggingface/tasks": {
778
+ "version": "0.21.1",
779
+ "resolved": "https://registry.npmjs.org/@huggingface/tasks/-/tasks-0.21.1.tgz",
780
+ "integrity": "sha512-EGy9VE8h9d39JgyKY5+Nwl0mcGQOK3el8rlR9Y09KVCZOHsLHAsOP1e7D9M7P781dSObZW9lokdfT+Sm26Iv3g==",
781
+ "license": "MIT"
782
+ },
783
+ "node_modules/@huggingface/xetchunk-wasm": {
784
+ "version": "0.0.6",
785
+ "resolved": "https://registry.npmjs.org/@huggingface/xetchunk-wasm/-/xetchunk-wasm-0.0.6.tgz",
786
+ "integrity": "sha512-LoYPl7jvUvOnkysrhMjAeBzK5mVe2GFu8O3IsZb1w7l7v+NqjDsyRduwct3yraPAGmzTxxdb/2aIORL98Y949g==",
787
+ "license": "MIT",
788
+ "dependencies": {
789
+ "@huggingface/blake3-jit": "0.0.2",
790
+ "gearhash-jit": "1.0.2"
791
+ }
792
+ },
793
  "node_modules/@jridgewell/gen-mapping": {
794
  "version": "0.3.13",
795
  "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
 
1326
  "version": "5.0.1",
1327
  "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
1328
  "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
1329
+ "devOptional": true,
1330
  "license": "MIT",
1331
  "engines": {
1332
  "node": ">=8"
 
1529
  "node": ">=8"
1530
  }
1531
  },
1532
+ "node_modules/cli-progress": {
1533
+ "version": "3.12.0",
1534
+ "resolved": "https://registry.npmjs.org/cli-progress/-/cli-progress-3.12.0.tgz",
1535
+ "integrity": "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A==",
1536
+ "license": "MIT",
1537
+ "optional": true,
1538
+ "dependencies": {
1539
+ "string-width": "^4.2.3"
1540
+ },
1541
+ "engines": {
1542
+ "node": ">=4"
1543
+ }
1544
+ },
1545
  "node_modules/cliui": {
1546
  "version": "8.0.1",
1547
  "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
 
1725
  "version": "8.0.0",
1726
  "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
1727
  "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
1728
+ "devOptional": true,
1729
  "license": "MIT"
1730
  },
1731
  "node_modules/encodeurl": {
 
1988
  "url": "https://github.com/sponsors/ljharb"
1989
  }
1990
  },
1991
+ "node_modules/gearhash-jit": {
1992
+ "version": "1.0.2",
1993
+ "resolved": "https://registry.npmjs.org/gearhash-jit/-/gearhash-jit-1.0.2.tgz",
1994
+ "integrity": "sha512-UhzJL4KXSdqAKepy/tZwmi2Rcy0YMmtiC4DQS4SURCuIWdh8ECZtnXK2ePRMLigfB61hRKdLK/Vgg2bSw73izQ==",
1995
+ "license": "MIT"
1996
+ },
1997
  "node_modules/gensync": {
1998
  "version": "1.0.0-beta.2",
1999
  "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
 
2148
  "version": "3.0.0",
2149
  "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
2150
  "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
2151
+ "devOptional": true,
2152
  "license": "MIT",
2153
  "engines": {
2154
  "node": ">=8"
 
2769
  "version": "4.2.3",
2770
  "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
2771
  "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
2772
+ "devOptional": true,
2773
  "license": "MIT",
2774
  "dependencies": {
2775
  "emoji-regex": "^8.0.0",
 
2784
  "version": "6.0.1",
2785
  "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
2786
  "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
2787
+ "devOptional": true,
2788
  "license": "MIT",
2789
  "dependencies": {
2790
  "ansi-regex": "^5.0.1"
package.json CHANGED
@@ -9,6 +9,7 @@
9
  "start": "NODE_ENV=production node server/index.js"
10
  },
11
  "dependencies": {
 
12
  "dotenv": "^17.4.2",
13
  "express": "^4.19.2"
14
  },
 
9
  "start": "NODE_ENV=production node server/index.js"
10
  },
11
  "dependencies": {
12
+ "@huggingface/hub": "^2.13.0",
13
  "dotenv": "^17.4.2",
14
  "express": "^4.19.2"
15
  },
server/index.js CHANGED
@@ -3,6 +3,7 @@ import "dotenv/config";
3
  import fs from "node:fs/promises";
4
  import path from "node:path";
5
  import { fileURLToPath } from "node:url";
 
6
 
7
  const DUPR_SEARCH_URL = "https://api.dupr.gg/player/v1.0/search";
8
  const DUPR_PLAYER_URL = "https://api.dupr.gg/player/v1.0";
@@ -11,6 +12,9 @@ const DEFAULT_LAT = 24.453884;
11
  const DEFAULT_LNG = 54.3773438;
12
  const EXPLORER_URL_PATTERN =
13
  process.env.DUPR_EXPLORER_URL_PATTERN || "https://duprexplorer.com/player/{numericPlayerId}";
 
 
 
14
 
15
  const __filename = fileURLToPath(import.meta.url);
16
  const __dirname = path.dirname(__filename);
@@ -19,6 +23,13 @@ const distPath = path.resolve(rootPath, "dist");
19
  const rosterPath = path.resolve(rootPath, "dupr_players.csv");
20
  const cacheDir = path.resolve(rootPath, ".cache");
21
  const cachePath = path.resolve(cacheDir, "player-cache.json");
 
 
 
 
 
 
 
22
 
23
  const app = express();
24
 
@@ -265,7 +276,78 @@ async function fetchMatchInfo(numericPlayerId) {
265
  };
266
  }
267
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
268
  async function readCache() {
 
 
269
  try {
270
  const text = await fs.readFile(cachePath, "utf8");
271
  const parsed = JSON.parse(text);
@@ -284,7 +366,33 @@ async function readCache() {
284
 
285
  async function writeCache(cache) {
286
  await fs.mkdir(cacheDir, { recursive: true });
287
- await fs.writeFile(cachePath, `${JSON.stringify(cache, null, 2)}\n`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  }
289
 
290
  function walkJson(value) {
@@ -545,7 +653,12 @@ async function readRoster() {
545
  },
546
  cache: {
547
  cachedPlayers: attemptedPlayers.length,
548
- explorerUrlPattern: EXPLORER_URL_PATTERN
 
 
 
 
 
549
  }
550
  };
551
  }
@@ -590,10 +703,20 @@ async function refreshPlayer(duprId) {
590
  await writeCache(cache);
591
  return entry;
592
  } catch (error) {
 
 
 
 
 
 
 
 
 
 
593
  const entry = {
594
  status: "error",
595
  fetchedAt: new Date().toISOString(),
596
- errorCode: error?.body?.errorCode || error?.errorCode || "refresh_failed",
597
  message: error?.body?.message || error?.message || "Could not refresh this player.",
598
  matchedPlayer: error?.body?.matchedPlayer ?? null,
599
  raw: error?.body?.raw ?? null
@@ -611,7 +734,8 @@ app.get("/api/health", (_req, res) => {
611
  app.get("/api/roster", async (_req, res) => {
612
  try {
613
  res.json(await readRoster());
614
- } catch {
 
615
  res.status(500).json({
616
  ok: false,
617
  errorCode: "roster_read_failed",
@@ -634,7 +758,12 @@ app.get("/api/cache", async (_req, res) => {
634
  message: entry.message ?? null
635
  }
636
  ])
637
- )
 
 
 
 
 
638
  });
639
  } catch {
640
  res.status(500).json({
 
3
  import fs from "node:fs/promises";
4
  import path from "node:path";
5
  import { fileURLToPath } from "node:url";
6
+ import { downloadFile, uploadFile } from "@huggingface/hub";
7
 
8
  const DUPR_SEARCH_URL = "https://api.dupr.gg/player/v1.0/search";
9
  const DUPR_PLAYER_URL = "https://api.dupr.gg/player/v1.0";
 
12
  const DEFAULT_LNG = 54.3773438;
13
  const EXPLORER_URL_PATTERN =
14
  process.env.DUPR_EXPLORER_URL_PATTERN || "https://duprexplorer.com/player/{numericPlayerId}";
15
+ const HF_DATASET_REPO = process.env.HF_DATASET_REPO || "derek-thomas/dupr_explorer_may_2026";
16
+ const HF_CACHE_PATH = process.env.HF_CACHE_PATH || "player-cache.json";
17
+ const HF_TOKEN = process.env.HF_TOKEN || process.env.HUGGINGFACE_HUB_TOKEN;
18
 
19
  const __filename = fileURLToPath(import.meta.url);
20
  const __dirname = path.dirname(__filename);
 
23
  const rosterPath = path.resolve(rootPath, "dupr_players.csv");
24
  const cacheDir = path.resolve(rootPath, ".cache");
25
  const cachePath = path.resolve(cacheDir, "player-cache.json");
26
+ let hydrateCachePromise = null;
27
+ let lastCacheBackup = {
28
+ status: HF_TOKEN ? "pending" : "disabled",
29
+ message: HF_TOKEN ? "Waiting for cache changes." : "Set HF_TOKEN to enable Hugging Face dataset backup.",
30
+ fetchedAt: null,
31
+ pushedAt: null
32
+ };
33
 
34
  const app = express();
35
 
 
276
  };
277
  }
278
 
279
+ function getHfRepo() {
280
+ return {
281
+ type: "dataset",
282
+ name: HF_DATASET_REPO
283
+ };
284
+ }
285
+
286
+ function updateCacheBackupStatus(status, message, extra = {}) {
287
+ lastCacheBackup = {
288
+ ...lastCacheBackup,
289
+ status,
290
+ message,
291
+ ...extra
292
+ };
293
+ }
294
+
295
+ async function hydrateCacheFromDataset() {
296
+ if (!HF_TOKEN) {
297
+ return;
298
+ }
299
+
300
+ try {
301
+ await fs.access(cachePath);
302
+ updateCacheBackupStatus("local", "Using local cache; dataset backup is available for future writes.");
303
+ return;
304
+ } catch (error) {
305
+ if (error.code !== "ENOENT") {
306
+ throw error;
307
+ }
308
+ }
309
+
310
+ try {
311
+ const remoteFile = await downloadFile({
312
+ repo: getHfRepo(),
313
+ path: HF_CACHE_PATH,
314
+ accessToken: HF_TOKEN
315
+ });
316
+
317
+ if (!remoteFile) {
318
+ updateCacheBackupStatus("empty", `No cache backup found in ${HF_DATASET_REPO}/${HF_CACHE_PATH}.`);
319
+ return;
320
+ }
321
+
322
+ const text = await remoteFile.text();
323
+ const parsed = JSON.parse(text);
324
+
325
+ if (!parsed.players || typeof parsed.players !== "object") {
326
+ throw new Error("Remote cache backup does not contain a players object.");
327
+ }
328
+
329
+ await fs.mkdir(cacheDir, { recursive: true });
330
+ await fs.writeFile(cachePath, text.endsWith("\n") ? text : `${text}\n`);
331
+ updateCacheBackupStatus("hydrated", `Hydrated cache from ${HF_DATASET_REPO}/${HF_CACHE_PATH}.`, {
332
+ fetchedAt: new Date().toISOString()
333
+ });
334
+ } catch (error) {
335
+ console.error("Could not hydrate cache from Hugging Face dataset.", error);
336
+ updateCacheBackupStatus(
337
+ "hydrate_failed",
338
+ "Could not hydrate cache from the Hugging Face dataset; using local cache only."
339
+ );
340
+ }
341
+ }
342
+
343
+ async function ensureCacheHydrated() {
344
+ hydrateCachePromise ??= hydrateCacheFromDataset();
345
+ await hydrateCachePromise;
346
+ }
347
+
348
  async function readCache() {
349
+ await ensureCacheHydrated();
350
+
351
  try {
352
  const text = await fs.readFile(cachePath, "utf8");
353
  const parsed = JSON.parse(text);
 
366
 
367
  async function writeCache(cache) {
368
  await fs.mkdir(cacheDir, { recursive: true });
369
+ const serialized = `${JSON.stringify(cache, null, 2)}\n`;
370
+ await fs.writeFile(cachePath, serialized);
371
+
372
+ if (!HF_TOKEN) {
373
+ return;
374
+ }
375
+
376
+ try {
377
+ await uploadFile({
378
+ repo: getHfRepo(),
379
+ file: {
380
+ path: HF_CACHE_PATH,
381
+ content: new Blob([serialized], { type: "application/json" })
382
+ },
383
+ accessToken: HF_TOKEN,
384
+ commitTitle: "Update DUPR player cache backup"
385
+ });
386
+ updateCacheBackupStatus("pushed", `Backed up cache to ${HF_DATASET_REPO}/${HF_CACHE_PATH}.`, {
387
+ pushedAt: new Date().toISOString()
388
+ });
389
+ } catch (error) {
390
+ console.error("Could not back up cache to Hugging Face dataset.", error);
391
+ updateCacheBackupStatus(
392
+ "push_failed",
393
+ "Could not back up cache to the Hugging Face dataset; local cache was still written."
394
+ );
395
+ }
396
  }
397
 
398
  function walkJson(value) {
 
653
  },
654
  cache: {
655
  cachedPlayers: attemptedPlayers.length,
656
+ explorerUrlPattern: EXPLORER_URL_PATTERN,
657
+ backup: {
658
+ repo: HF_DATASET_REPO,
659
+ path: HF_CACHE_PATH,
660
+ ...lastCacheBackup
661
+ }
662
  }
663
  };
664
  }
 
703
  await writeCache(cache);
704
  return entry;
705
  } catch (error) {
706
+ const existingEntry = cache.players[normalizedDuprId];
707
+ const errorCode = error?.body?.errorCode || error?.errorCode || "refresh_failed";
708
+
709
+ if (
710
+ existingEntry?.status === "success" &&
711
+ ["invalid_token", "rate_limited", "network_error", "dupr_api_error"].includes(errorCode)
712
+ ) {
713
+ return existingEntry;
714
+ }
715
+
716
  const entry = {
717
  status: "error",
718
  fetchedAt: new Date().toISOString(),
719
+ errorCode,
720
  message: error?.body?.message || error?.message || "Could not refresh this player.",
721
  matchedPlayer: error?.body?.matchedPlayer ?? null,
722
  raw: error?.body?.raw ?? null
 
734
  app.get("/api/roster", async (_req, res) => {
735
  try {
736
  res.json(await readRoster());
737
+ } catch (error) {
738
+ console.error("Could not read roster CSV.", error);
739
  res.status(500).json({
740
  ok: false,
741
  errorCode: "roster_read_failed",
 
758
  message: entry.message ?? null
759
  }
760
  ])
761
+ ),
762
+ backup: {
763
+ repo: HF_DATASET_REPO,
764
+ path: HF_CACHE_PATH,
765
+ ...lastCacheBackup
766
+ }
767
  });
768
  } catch {
769
  res.status(500).json({
spec.md CHANGED
@@ -80,7 +80,7 @@ Required detail behavior:
80
 
81
  ### `GET /api/roster`
82
 
83
- Reads `dupr_players.csv`, joins cached DUPR enrichment from `.cache/player-cache.json`, and returns teams plus aggregate stats.
84
 
85
  The response includes:
86
 
@@ -100,7 +100,12 @@ The response includes:
100
  },
101
  "cache": {
102
  "cachedPlayers": 0,
103
- "explorerUrlPattern": "https://duprexplorer.com/player/{numericPlayerId}"
 
 
 
 
 
104
  }
105
  }
106
  ```
@@ -108,6 +113,7 @@ The response includes:
108
  ### `POST /api/players/refresh`
109
 
110
  Refreshes missing, failed, or incomplete player cache entries. By default it skips successful cached players that already have match information.
 
111
 
112
  Request body:
113
 
 
80
 
81
  ### `GET /api/roster`
82
 
83
+ Reads `dupr_players.csv`, joins cached DUPR enrichment from `.cache/player-cache.json`, and returns teams plus aggregate stats. If the local cache is missing and `HF_TOKEN` is configured, the server first attempts to hydrate the cache from the Hugging Face dataset backup.
84
 
85
  The response includes:
86
 
 
100
  },
101
  "cache": {
102
  "cachedPlayers": 0,
103
+ "explorerUrlPattern": "https://duprexplorer.com/player/{numericPlayerId}",
104
+ "backup": {
105
+ "repo": "derek-thomas/dupr_explorer_may_2026",
106
+ "path": "player-cache.json",
107
+ "status": "pushed"
108
+ }
109
  }
110
  }
111
  ```
 
113
  ### `POST /api/players/refresh`
114
 
115
  Refreshes missing, failed, or incomplete player cache entries. By default it skips successful cached players that already have match information.
116
+ Successful existing entries are preserved if a refresh fails because the DUPR token is invalid, DUPR is rate-limited, or the network/upstream API is unavailable.
117
 
118
  Request body:
119