#!/usr/bin/env node /** * download-textures.js — Bulk-download item/block textures for all supported * mods into the local texture cache. * * Supported mods (Prominence Hasturian Era II modpack): * Minecraft, Create, CC:Tweaked, Mythic Metals, Farmer's Delight, * Ad Astra, BetterEnd, Applied Energistics 2, Twilight Forest * * Run once (or on container start) to pre-populate the cache so the proxy * never needs to hit upstream for known textures. * * Usage: node download-textures.js [cacheDir] * Default: /data/texture-cache (matches server.js) */ import fs from 'fs'; import path from 'path'; const CACHE_DIR = process.argv[2] || process.env.TEXTURE_CACHE_DIR || '/data/texture-cache'; // ── Upstream repos (same namespaces as TEXTURE_UPSTREAMS in server.js) ───── const REPOS = { minecraft: { api: 'https://api.github.com/repos/InventivetalentDev/minecraft-assets/git/trees/1.21.4?recursive=1', raw: 'https://cdn.jsdelivr.net/gh/InventivetalentDev/minecraft-assets@1.21.4', prefix: 'assets/minecraft/textures/', folders: ['item/', 'block/'], }, create: { api: 'https://api.github.com/repos/Creators-of-Create/Create/git/trees/mc1.20.1/dev?recursive=1', raw: 'https://raw.githubusercontent.com/Creators-of-Create/Create/mc1.20.1/dev', prefix: 'src/main/resources/assets/create/textures/', folders: ['item/', 'block/'], }, computercraft: { api: 'https://api.github.com/repos/cc-tweaked/CC-Tweaked/git/trees/mc-1.20.x?recursive=1', raw: 'https://raw.githubusercontent.com/cc-tweaked/CC-Tweaked/mc-1.20.x', prefix: 'projects/common/src/main/resources/assets/computercraft/textures/', folders: ['item/', 'block/'], }, mythicmetals: { api: 'https://api.github.com/repos/Noaaan/MythicMetals/git/trees/1.20?recursive=1', raw: 'https://raw.githubusercontent.com/Noaaan/MythicMetals/1.20', prefix: 'src/main/resources/assets/mythicmetals/textures/', folders: ['item/', 'block/'], }, farmersdelight: { api: 'https://api.github.com/repos/vectorwing/FarmersDelight/git/trees/1.20?recursive=1', raw: 'https://raw.githubusercontent.com/vectorwing/FarmersDelight/1.20', prefix: 'src/main/resources/assets/farmersdelight/textures/', folders: ['item/', 'block/'], }, ad_astra: { api: 'https://api.github.com/repos/terrarium-earth/Ad-Astra/git/trees/1.20.1?recursive=1', raw: 'https://raw.githubusercontent.com/terrarium-earth/Ad-Astra/1.20.1', prefix: 'common/src/main/resources/assets/ad_astra/textures/', folders: ['item/', 'block/'], }, betterend: { api: 'https://api.github.com/repos/quiqueck/BetterEnd/git/trees/1.20?recursive=1', raw: 'https://raw.githubusercontent.com/quiqueck/BetterEnd/1.20', prefix: 'src/main/resources/assets/betterend/textures/', folders: ['item/', 'block/'], }, ae2: { api: 'https://api.github.com/repos/AppliedEnergistics/Applied-Energistics-2/git/trees/fabric/1.20.1?recursive=1', raw: 'https://raw.githubusercontent.com/AppliedEnergistics/Applied-Energistics-2/fabric/1.20.1', prefix: 'src/main/resources/assets/ae2/textures/', folders: ['item/', 'block/'], }, twilightforest: { api: 'https://api.github.com/repos/TeamTwilight/twilightforest/git/trees/1.20.1?recursive=1', raw: 'https://raw.githubusercontent.com/TeamTwilight/twilightforest/1.20.1', prefix: 'src/main/resources/assets/twilightforest/textures/', folders: ['item/', 'block/'], }, }; // ── Rate-limit-friendly parallel downloader ──────────────────────────────── const CONCURRENCY = 10; const RETRY_DELAY = 1000; const MAX_RETRIES = 3; async function downloadFile(url, dest, retries = 0) { try { const res = await fetch(url); if (res.status === 404) return false; // genuinely missing if (!res.ok) throw new Error(`HTTP ${res.status}`); const buffer = Buffer.from(await res.arrayBuffer()); fs.mkdirSync(path.dirname(dest), { recursive: true }); fs.writeFileSync(dest, buffer); return true; } catch (err) { if (retries < MAX_RETRIES) { await new Promise(r => setTimeout(r, RETRY_DELAY * (retries + 1))); return downloadFile(url, dest, retries + 1); } console.error(` ✗ ${url}: ${err.message}`); return false; } } async function runPool(tasks) { let idx = 0; let ok = 0; let fail = 0; async function worker() { while (idx < tasks.length) { const task = tasks[idx++]; const success = await downloadFile(task.url, task.dest); if (success) ok++; else fail++; } } const workers = Array.from({ length: Math.min(CONCURRENCY, tasks.length) }, () => worker()); await Promise.all(workers); return { ok, fail }; } // ── Main ─────────────────────────────────────────────────────────────────── async function downloadNamespace(name, repo) { console.log(`\n⬇ ${name}: fetching file list…`); const res = await fetch(repo.api, { headers: { 'User-Agent': 'inventory-manager-texture-dl' }, }); if (!res.ok) { console.error(` ✗ GitHub API ${res.status}: ${await res.text()}`); return; } const data = await res.json(); // Filter to only PNG files in the target folders under the prefix const tasks = []; for (const entry of data.tree || []) { if (entry.type !== 'blob') continue; if (!entry.path.startsWith(repo.prefix)) continue; if (!entry.path.endsWith('.png')) continue; const relPath = entry.path.slice(repo.prefix.length); // e.g. "item/diamond.png" const inFolder = repo.folders.some(f => relPath.startsWith(f)); if (!inFolder) continue; const dest = path.join(CACHE_DIR, name, relPath); if (fs.existsSync(dest)) continue; // already cached tasks.push({ url: `${repo.raw}/${entry.path}`, dest, }); } if (tasks.length === 0) { console.log(` ✓ ${name}: all textures already cached`); return; } console.log(` ↓ downloading ${tasks.length} textures…`); const { ok, fail } = await runPool(tasks); console.log(` ✓ ${name}: ${ok} downloaded, ${fail} failed`); } async function main() { console.log(`Texture cache dir: ${CACHE_DIR}`); fs.mkdirSync(CACHE_DIR, { recursive: true }); for (const [name, repo] of Object.entries(REPOS)) { await downloadNamespace(name, repo); } // Clean up any .miss files so newly-downloaded textures take effect let cleared = 0; function cleanMiss(dir) { try { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) cleanMiss(full); else if (entry.name.endsWith('.miss')) { fs.unlinkSync(full); cleared++; } } } catch (_) { /* ignore */ } } cleanMiss(CACHE_DIR); if (cleared) console.log(`\n🗑 Cleared ${cleared} negative-cache entries`); // Build and print index stats const stats = {}; function countPngs(dir, ns) { try { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) countPngs(full, ns); else if (entry.name.endsWith('.png')) stats[ns] = (stats[ns] || 0) + 1; } } catch (_) { /* ignore */ } } for (const ns of Object.keys(REPOS)) { countPngs(path.join(CACHE_DIR, ns), ns); } console.log('\n📊 Cache totals:'); for (const [ns, count] of Object.entries(stats)) { console.log(` ${ns}: ${count} textures`); } console.log('\n✅ Done'); } main().catch(err => { console.error('Fatal:', err); process.exit(1); });