โ† Kevin Yoder
Web Apps & Games

Asset Harvester

One search box that fans out across ~20 asset sources, then downloads, de-duplicates, thumbnails and AI-tags the results into a tidy, license-aware library for building games.

Built Mar 2026 Self-hosted ยท LAN-only Dormant โ€” container removed

โš™ How it works ๐Ÿ–ผ Screenshots

01 Overview

A self-hosted service for collecting game-development assets. You type one query; it searches roughly twenty pluggable source connectors (18 of them surfaced in the UI) at once, and the results you pick flow through a shared pipeline โ€” download โ†’ de-duplicate โ†’ file โ†’ thumbnail โ†’ AI-tag โ€” into a searchable library with per-project workspaces. It was the "find real, free assets" counterpart to a sibling generation tool, and it fed the AI game-building pipeline as an asset backend.

Images & photos

Stock & commons

Pexels, Unsplash, Pixabay, OpenVerse, Wikimedia Commons, NASA, Internet Archive.

Audio & music

Sound & tracks

Freesound, ccMixter, Incompetech, and CC / royalty-free YouTube audio via yt-dlp.

3D & game-ready

Models & textures

Poly Haven, Sketchfab, Quaternius, Kenney.nl asset packs.

Icons & sprites

Art bits

Game-Icons, OpenGameArt, itch.io feeds, plus a paste-a-URL page scraper.

02 Why I built it

The AI game-building pipeline needed art, sound and music, and I wanted it to reach for real, licensed free assets first and only fall back to generated ones โ€” so the game platform gathers from Harvester first, and from the generation tool second. That made two things matter: breadth of good sources, and license discipline. Every result carries its license and source URL, so nothing gets used unattributed. And because it ran on a small home server, the whole thing is built to be gentle on memory, CPU and network rather than fast.

03 What I built & how it works

One federated search, then a single serial pipeline every asset passes through.

Browser SPA 6 pages ยท vanilla JS ยท Socket.IO progress REST ยท search / download Socket.IO ยท progress ยท logs ยท tags server.js Express ยท REST + Socket.IO ยท :8214 /api/search searchAll() ยท parallel Promise.allSettled serial download queue one at a time ยท SQLite-persisted rebuilt on restart ยท stuck โ†’ pending asset pipeline ยท one at a time download โ†’ SHA-256 dedup โ†’ date-filed 256px thumbnail โ†’ AI describe + tag SQLite better-sqlite3 ยท WAL mode assets ยท projects ยท downloads ยท source_configs

Fig. 1 โ€” a search fans out across the connectors; each chosen result runs the same download-to-tag pipeline, one at a time.

  1. Search โ€” one query fans out across the enabled connectors in parallel; per-source errors are surfaced without failing the whole search.
  2. Queue โ€” the results you pick enter a serial, SQLite-persisted download queue that rebuilds itself after a restart (stuck downloads reset to pending).
  3. De-duplicate โ€” each file is streamed and SHA-256 hashed; a duplicate completes instantly and points at the asset already on disk.
  4. File & thumbnail โ€” the asset is filed into a date-bucketed, typed layout and given a 256px thumbnail (image resize, audio waveform, video frame, or a typed placeholder).
  5. Tag โ€” in the background, a vision model writes a description and tags, stored separately from any tags you add by hand.

04 ๐Ÿ›  Skills & tech used

Languages
Node 20vanilla JS (SPA)SQL / SQLiteCSS design tokensBashDockerfile
ML / AI
LiteLLM gatewayGemini Flash vision taggingbase64 data-URI promptsstrict-JSON prompt + fence-stripping fallbackAI-searchable gallery
Infra / Ops
Docker + Composeffmpeg ยท imagemagick ยท yt-dlp imagemem_limit + healthcheckcrash-safe persistent queueEXDEV cross-mount handling
Frontend
6-page hash-routed SPASocket.IO live progressphoto grid + lightboxCSS-only bar chartsinline-SVG favicon
Data
better-sqlite3 (WAL + FK)prepared statementsORDER BY whitelistSHA-256 dedupdate-bucketed storage
Techniques
federated search (Promise.allSettled)5 acquisition strategiesTTL caches (6โ€“24h)rate-limit hardeninglicense normalization

05 Notable challenges & decisions

The interesting part wasn't any one connector โ€” it was making a pile of mismatched, sometimes-flaky sources behave like one search box.

Acquisition

Five different ways to fetch a file

No two sources agree on how to hand over an asset, so the connectors use five strategies behind one contract: REST APIs, HTML scraping (Kenney, OpenGameArt, Incompetech), GitHub catalog mining (Game-Icons, Quaternius), RSS feeds (itch.io), and subprocess extraction (YouTube via yt-dlp). Each normalizes into the same result shape, with per-source TTL caches so a single popular query doesn't hammer a source.

Fault tolerance

One flaky source shouldn't sink the search

Federated search runs the connectors under Promise.allSettled with a capability pre-filter (skip sources that can't serve the requested type) and a strict post-filter on the way back. A scraper timing out or a rate-limited API surfaces as a per-source note, not a failed query.

Scope correction

Coverage isn't the goal; usable results are

A generic web-search source looked like free breadth, but it returned Pinterest / Etsy / Reddit noise rather than downloadable assets, so I delisted it and replaced it with an images-only source. This is also why the honest source count is ~20 connectors and 18 user-facing โ€” not a bigger, padded number.

Dead end

A scraping stack that never shipped

The plan was a self-hosted Firecrawl stack for the paste-a-URL scraper, but its official image was access-restricted. Rather than trust an unofficial mirror, I rewrote the same behavior in-process with axios and regex extraction and deleted three now-unneeded compose services.

Built for a small box, and honest about it. The download queue runs one item at a time on purpose โ€” a deliberate constraint to protect a ~15 GB home server's memory, CPU and network. Filing across a container filesystem and a USB mount also meant handling cross-device EXDEV by copying rather than renaming โ€” the kind of small thing that only shows up once real files move.

06 Results

~20
source connectors built
18
sources surfaced in the UI
5
acquisition strategies behind one contract
8
asset types (image ยท audio ยท video ยท 3D ยท sprite ยท texture ยท font ยท other)
1
download at a time โ€” a deliberate constraint
256px
thumbnail for every asset type

Sources: the project's source files and the SQLite database, read directly. Counts describe what was built, not scale of use โ€” see the honest status below.

07 Screenshots

Captured 2026-08-02 from the verified runnable clone (Steps to Build tab) โ€” the original service is torn down, but the clone reproduces these screens.

The search page: a single 'castle' query returning 137 source-labeled result cards, each with a license chip (Public domain, CC BY-SA) and a download button.
The search page: a single query, results from many sources side by side, each tagged with its source and license.
The asset gallery: nine downloaded assets with generated thumbnails โ€” a brick texture, SVG sprites, audio waveforms and NASA images โ€” each with type and license badges.
The gallery after a key-free harvest: generated thumbnails, type and license badges on every asset.
Sources admin: per-source cards with enable toggles; keyed sources show a masked API-key field and Save button.
Sources admin: each connector can be enabled or disabled and given a key from the UI.

08 Honest status

Asset Harvester was built and iterated over a short sprint in early March 2026 โ€” sources grew 9 โ†’ 14 โ†’ 18 in a single day โ€” and then it settled into a quiet role as an asset backend for the game pipeline. It ran self-hosted, LAN-only, with no authentication. It was never exercised at scale: the database records only 13 successful downloads and 14 failures, so several source paths (the scrapers and NASA's manifest fetch in particular) were never proven in real use. Its container has since been removed; the code and data still persist on the server. One correction worth stating plainly: earlier labels called it a "22-source" tool โ€” the defensible number is 20 connectors. Update (2026-08-02): building the runnable clone (Steps to Build tab) surfaced seven real bugs โ€” including the two that explain why the original's own database mis-typed every download โ€” plus two connectors broken by upstream API changes; all were fixed in the clone and backported to the original's source. The screenshots above are from that verified clone.

asset-harvester โ€” Build Recipe

Take a bare machine to a running game-asset search & organization hub: one web app that searches 20 sources (Openverse, Wikimedia Commons, Internet Archive, NASA, Poly Haven, Kenney, Game-icons.net, Quaternius, OpenGameArt, ccMixter, itch.io, YouTube audio, and more) in one query, downloads assets through a persistent queue with live progress, deduplicates by SHA-256, auto-thumbnails everything (images, audio waveforms, video frames), and organizes results into per-project workspaces. Key-free by default โ€” 13 sources need no API key, and a small demo library is imported on first boot.

Status: โœ… Verified 2026-08-02 โ€” built from this clone on a Linux Docker host (docker compose up -d --build). First boot imported the bundled demo library (9 assets, 15 MB โ€” images, SVG sprites, audio, a texture โ€” with thumbnails and a "Demo Game" project); an all-source key-free search returned 137 results from 7 live sources with zero errors; 9 of 10 real downloads completed end-to-end through the queue (hash โ†’ dedup โ†’ organized storage โ†’ thumbnail; the one failure was a transient Wikimedia rate-limit); a restart keeps the data without re-seeding; a real-browser pass showed the gallery, sources, and project pages working with 0 console errors. All three tiers covered; the prebuilt tarball (docker save asset-harvester-clone) is ~885 MB. The clean clone lives in clone/.

Sensitive data: none. The source held no keys (all env-based, and the live app's stored key fields were empty); the demo seed was harvested from public CC0 / CC-BY / public-domain sources with license + author recorded on every row; personal server references were scrubbed. The 7 bug fixes below were also backported to the original live app.


What it is

A single Node/Express + Socket.IO container:

Piece Role
server.js + queue.js REST API, live Socket.IO progress, persistent one-at-a-time download queue
sources/ (20 modules) one adapter per asset source โ€” API, RSS, or scrape; each degrades gracefully
processors/ SHA-256 dedup hashing, sharp/ffmpeg thumbnails, opt-in AI tagging
db.js SQLite (better-sqlite3, WAL) โ€” assets, downloads, projects, source configs
public/ vanilla-JS SPA: search, downloads, gallery, projects, sources, stats โ€” no CDNs

System deps baked into the image: ffmpeg (waveform/frame thumbnails), yt-dlp (YouTube audio source). No GPU, no build step for the frontend.

Prerequisites

New machine? See ../SETUP.md. Then, per tier:

Tier You need
1 โ€” prebuilt container (recommended) Docker (see ../SETUP.md) + this bundle's clone/ folder (for the compose file).
2 โ€” build from source Same as Tier 1.
3 โ€” bare-metal Node 20+ and ffmpeg (install commands below); optional yt-dlp for the YouTube source.

Configuration โ€” nothing required

Runs key-free: 13 of 20 sources work with no key, and a bundled demo library populates the gallery on first boot. Optional extras (each unlocks more, all documented in clone/.env.example): six per-source API keys, AI auto-tagging via any OpenAI-compatible endpoint, and SearXNG web-image search.


Tier 1 โ€” Run the prebuilt container (recommended)

Availability: the prebuilt image is available on request โ€” it is not published or linked anywhere. Ask Kevin for it, or build from source via Tier 2.

docker load -i asset-harvester-clone-image.tar
cd clone
docker compose up -d                        # โ†’ http://localhost:8214

Tier 2 โ€” Build from source

cd clone
docker compose up -d --build                # โ†’ http://localhost:8214
curl -s localhost:8214/health               # โ†’ {"status":"ok", ...}

Open http://localhost:8214 โ€” the gallery starts populated (demo seed), and a search like castle or nebula across "All sources" returns live results from the keyless sources.

Tier 3 โ€” Bare-metal (one Node process)

Install the two prerequisites first (../SETUP.md covers Docker/Python but not these):

# Node 20+ and ffmpeg โ€”
#   Windows: winget install OpenJS.NodeJS.LTS Gyan.FFmpeg
#   Debian/Ubuntu: sudo apt install -y nodejs npm ffmpeg   (Node 20+ via nodesource if the distro's is older)
#   macOS: brew install node ffmpeg
# optional, for the YouTube source: pip install yt-dlp
cd clone
npm ci --omit=dev
# ffmpeg must be on PATH (thumbnails); yt-dlp optional (YouTube source)
DATA_DIR=./data ASSETS_DIR=./assets THUMBNAILS_DIR=./thumbnails TEMP_DIR=./temp \
  SEED_DIR=./seed node server.js            # โ†’ http://localhost:8214

On Windows, set the same variables with set/$env: before node server.js.

Optional extras (all off by default)

  • Per-source API keys โ€” Pixabay, Pexels, Unsplash, Freesound, Sketchfab, itch.io. Free-tier signup links are in clone/.env.example; paste a key on the Sources page (or in .env) and toggle the source on.
  • AI auto-tagging โ€” point LITELLM_URL at any OpenAI-compatible endpoint and every downloaded asset gets an AI description + tags. Local Ollama works key-free (ollama pull llama3.2; a vision model improves image tagging):

bash # in clone/.env LITELLM_URL=http://host.docker.internal:11434/v1 LITELLM_API_KEY=ollama LITELLM_MODEL=llama3.2

  • Web-image meta-search โ€” set SEARXNG_URL to a SearXNG instance (JSON API enabled) and enable the "Web Images (SearXNG)" source.

Provenance โ€” the demo seed

The bundled first-boot library was harvested through this app itself from its keyless sources (Wikimedia Commons, NASA, Openverse, Game-icons.net, Poly Haven, Kenney, and similar) โ€” public-domain and Creative-Commons items only. Every seed asset's source, license, author, and origin URL are recorded in its database row and visible in the asset detail view. No model weights, no datasets.

What was stripped from the personal version (de-identification)

  • Home-server User-Agent strings (they identified the author's server) โ†’ generic, non-identifying AssetHarvester/1.0 UAs throughout (the YouTube source keeps a stock browser UA for rate-limit reasons, as the original did).
  • Private LLM-gateway defaults โ€” the original defaulted to an internal LiteLLM endpoint and a private model alias for auto-tagging. Tagging is now opt-in (LITELLM_URL unset = off) against any OpenAI-compatible endpoint.
  • Internal SearXNG dependency โ€” the web-image source assumed a search container on the home network; now opt-in via SEARXNG_URL, off by default.
  • A TLS-verification bypass for one source (ccMixter's occasionally incomplete cert chain) was removed โ€” the clone verifies TLS normally and that source simply degrades to empty if the remote chain is broken.
  • An orphaned dead-code module (searxng.js, superseded by webimages.js) was deleted; personal hardware references in comments were generalized.

Bugs found and fixed during cloning (faithful, minimal fixes)

  1. Every downloaded asset got type other / format tmp โ€” two compounding bugs, visible in the original's own database. The UI sends assetType but the server read asset_type, so the download modal's type selection was silently dropped; and format detection read the extension of the queue's <id>.tmp temp file (always "tmp") instead of the source metadata. Types, formats, thumbnails, and download filenames all work now.
  2. 12 sources couldn't be configured โ€” the DB seeded only 9 source_configs rows but 20 sources are registered; the settings API 404s without a row, so most sources could never be toggled or keyed through the UI. The seed now covers every registered source.
  3. Two keyed sources had no key input โ€” the UI's needs-a-key list omitted Pexels and Unsplash, so their keys couldn't be entered at all.
  4. Saving settings could wipe a stored key โ€” the server masks keys in API responses, but clicking Save sent the mask string back and overwrote the real key. The server now ignores the masked echo.
  5. Poly Haven returned nothing under a type filter โ€” its API reports asset types as numeric codes, which the adapter compared against strings; results were mislabeled and filtered out (and download URLs malformed). The adapter now tracks which list each entry came from.
  6. Game-icons.net returned nothing โ€” the upstream GitHub repo moved to a flat author/icon.svg layout; the adapter only matched the old author/originals/svg/ paths. It now accepts both.
  7. yt-dlp pointed at a hardcoded Node path (/usr/local/bin/node) that only exists in the original image; it now uses the running Node binary (process.execPath), so bare-metal installs work too.

Known limitations (stated honestly)

Personal-LAN tool: no authentication โ€” put it behind a reverse proxy if exposed. Downloads run one at a time by design (original ran on a small shared server). The scraping-based sources are best-effort: Incompetech's modern site is JS-rendered so that source usually returns nothing; Kenney falls back to a fixed pack list if its page layout changes; OpenGameArt sits behind intermittent anti-bot protection. Freesound downloads are the HQ MP3 previews (originals require OAuth2); Sketchfab is search/link-out only. YouTube search and download are subject to rate limits and yt-dlp aging โ€” a rebuilt image months from now may need a yt-dlp bump. The container runs as root (as the original did); hardening is left to the deployer.