# SPDX-License-Identifier: MIT # HeapLeach — parallel bulk downloader # # The default build runs entirely inside Docker (no local Go or Node needed) # and exports the application and its ocr/ data to ./bin on the host. # The TypeScript UI is compiled and embedded into the binary. SHELL := /bin/bash .DEFAULT_GOAL := help BIN_DIR := bin DIST_DIR := dist BINARY := $(BIN_DIR)/heapleach # cgo needs a C toolchain for each target. Releases use native CI runners; # local archives default to this host. Set RELEASE_TARGETS and CC to cross-build. RELEASE_TARGETS ?= $(shell go env GOOS)/$(shell go env GOARCH) IMAGE ?= heapleach TAG ?= latest VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) PORT ?= 8080 DOWNLOADS ?= $(HOME)/Downloads CONCURRENCY ?= 4 DIST := backend/internal/webui/dist # What the exported binary is made of, so `make build` can tell a stale # binary from a current one and do nothing when there is nothing to do. # # The frontend counts as much as the Go does: it is compiled into the binary, # so a UI edit leaves it just as out of date as an extractor edit would. The # embed directory itself is excluded — it is build output, rewritten by every # frontend build, and treating it as a source would mean never settling. GO_SOURCES := $(shell find backend -type f \( -name '*.go' -o -name '*.sh' \) -not -path '$(DIST)/*' 2>/dev/null) \ backend/go.mod backend/go.sum UI_SOURCES := $(shell find frontend -type f \ \( -name '*.ts' -o -name '*.tsx' -o -name '*.css' -o -name '*.html' -o -name '*.json' \) \ -not -path 'frontend/node_modules/*' -not -path 'frontend/dist/*' 2>/dev/null) BUILD_SOURCES := $(GO_SOURCES) $(UI_SOURCES) Dockerfile Makefile .dockerignore helpers/captcha/install.sh # Optional helpers the service uses when present: yt-dlp resolves YouTube, # ffmpeg rewraps and muxes, and deno runs the player JavaScript YouTube signs # its media URLs with — yt-dlp has deprecated extracting without a runtime. # Local OCR runs in-process using a pinned model and native runtime. # `make dependencies` puts these helpers in ./bin, where the service looks # before falling back to PATH. UNAME_M := $(shell uname -m) ifeq ($(UNAME_M),aarch64) YTDLP_ASSET := yt-dlp_linux_aarch64 FFMPEG_ASSET := ffmpeg-master-latest-linuxarm64-gpl.tar.xz DENO_ASSET := deno-aarch64-unknown-linux-gnu.zip else YTDLP_ASSET := yt-dlp_linux FFMPEG_ASSET := ffmpeg-master-latest-linux64-gpl.tar.xz DENO_ASSET := deno-x86_64-unknown-linux-gnu.zip endif YTDLP_URL := https://github.com/yt-dlp/yt-dlp/releases/latest/download/$(YTDLP_ASSET) FFMPEG_URL := https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/$(FFMPEG_ASSET) DENO_URL := https://github.com/denoland/deno/releases/latest/download/$(DENO_ASSET) NODE_IMAGE := node:24-alpine UID_GID := $(shell id -u):$(shell id -g) # Prefer the host toolchain when it exists, otherwise fall back to Docker. HAVE_NPM := $(shell command -v npm 2>/dev/null) HAVE_GO := $(shell command -v go 2>/dev/null) .PHONY: help build binary run image run-image stop logs shell dev dev-backend dev-frontend \ frontend frontend-clean screenshots dist tag native test test-frontend test-live check fmt fmt-check vet tidy lock dependencies \ hosts hosts-check ocr-assets captcha-helper test-captcha test-release spdx-check \ clean distclean ## help: show this help help: @echo "HeapLeach — parallel bulk downloader" @echo @grep -E '^## ' $(MAKEFILE_LIST) | sed 's/^## / make /' | column -t -s ':' @echo @echo " Variables: PORT=$(PORT) (run-image)" @echo " DOWNLOADS=$(DOWNLOADS) IMAGE=$(IMAGE):$(TAG)" ## build: build in Docker and export the application and OCR data into ./bin build: $(BINARY) $(BIN_DIR)/ocr/VERSIONS # The rule that does the work. Its prerequisites are the sources the binary # is made of, which is what lets `build` be asked for freely: it is a no-op # while the binary is newer than all of them, and it rebuilds the moment one # changes. Anything that runs the binary depends on this rather than on the # file existing, since a stale binary is exactly what looks like a bug. $(BINARY): $(BUILD_SOURCES) | $(BIN_DIR) @echo ">> building in Docker, exporting binary to $(BINARY)" DOCKER_BUILDKIT=1 docker build \ --target export \ --build-arg VERSION=$(VERSION) \ --output type=local,dest=$(BIN_DIR) \ -f Dockerfile . @chmod +x $(BINARY) @# BuildKit writes the file with the timestamp it had in the image, which @# can predate the sources and would leave the target permanently stale. @touch $(BINARY) @echo ">> $(BINARY) ($$(du -h $(BINARY) | cut -f1)) — UI embedded, OCR data in $(BIN_DIR)/ocr" ## binary: alias for build binary: build ## run: build if needed, serve on a free local port and open the browser run: build $(BINARY) -addr 127.0.0.1:0 -open "$(DOWNLOADS)" ## image: build the runnable Docker image image: DOCKER_BUILDKIT=1 docker build \ --target runtime \ --build-arg VERSION=$(VERSION) \ -t $(IMAGE):$(TAG) \ -f Dockerfile . @echo ">> built $(IMAGE):$(TAG)" ## run-image: run the container image instead of the host binary run-image: image @mkdir -p "$(DOWNLOADS)" docker run --rm -it \ --name heapleach \ -p $(PORT):8080 \ -e HEAPLEACH_CONCURRENCY=$(CONCURRENCY) \ -v "$(DOWNLOADS)":/downloads \ --user $(UID_GID) \ $(IMAGE):$(TAG) ## stop: stop a detached container started by `make run-image` stop: -docker stop heapleach ## logs: follow container logs logs: docker logs -f heapleach ## shell: open a shell in the runtime image shell: image docker run --rm -it --entrypoint sh $(IMAGE):$(TAG) ## native: build on the host (needs Go; uses Docker for the UI if Node is absent) native: frontend ocr-assets $(BIN_DIR) ifndef HAVE_GO $(error Go is not installed — use `make build` to build in Docker instead) endif cd backend && CGO_ENABLED=1 go build -trimpath \ -ldflags "-s -w -X main.version=$(VERSION)" \ -o ../$(BINARY) ./cmd/heapleach @echo ">> $(BINARY)" ## dist: build release archives for this host (or RELEASE_TARGETS with a suitable CC) dist: frontend ifndef HAVE_GO $(error Go is not installed — a release build needs it on the host) endif @rm -rf $(DIST_DIR) && mkdir -p $(DIST_DIR) @set -euo pipefail; \ for target in $(RELEASE_TARGETS); do \ goos=$${target%%/*}; goarch=$${target##*/}; \ binary=heapleach; \ [ "$$goos" = windows ] && binary=heapleach.exe; \ stage=$$(mktemp -d); \ ( cd backend && CGO_ENABLED=1 GOOS=$$goos GOARCH=$$goarch go build -trimpath \ -ldflags "-s -w -X main.version=$(VERSION)" \ -o "$$stage/$$binary" ./cmd/heapleach ); \ cp README.md LICENSE "$$stage/"; \ bash helpers/captcha/install.sh "$$target" "$$stage/ocr"; \ base="heapleach_$(VERSION)_$${goos}_$${goarch}"; \ if [ "$$goos" = windows ]; then \ if command -v zip >/dev/null 2>&1; then \ ( cd "$$stage" && zip -qr - . ) > "$(DIST_DIR)/$$base.zip"; \ else \ ( cd "$$stage" && 7z a -tzip "$(abspath $(DIST_DIR))/$$base.zip" . >/dev/null ); \ fi; \ else \ tar -czf "$(DIST_DIR)/$$base.tar.gz" -C "$$stage" .; \ fi; \ rm -rf "$$stage"; \ echo ">> $(DIST_DIR)/$$base"; \ done; \ cd $(DIST_DIR) && shopt -s nullglob && archives=(*.tar.gz *.zip) && \ if command -v sha256sum >/dev/null 2>&1; then \ sha256sum "$${archives[@]}"; \ else shasum -a 256 "$${archives[@]}"; fi > SHA256SUMS @echo ">> $(DIST_DIR)/SHA256SUMS" ## tag: bump the patch and build a release; set V=v0.1.0 to override (needs gh) tag: export RELEASE_VERSION = $(V) tag: @[[ -z "$$RELEASE_VERSION" || "$$RELEASE_VERSION" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$$ ]] || \ { echo "usage: make tag [V=v0.1.0]" >&2; exit 1; } @test -z "$$(git status --porcelain)" || { echo "the working tree is dirty" >&2; exit 1; } @test "$$(git rev-parse HEAD)" = "$$(git ls-remote origin refs/heads/main | cut -f1)" || \ { echo "push or update to origin/main before choosing its version" >&2; exit 1; } gh workflow run version.yml --ref main -f version="$$RELEASE_VERSION" @echo ">> version workflow requested; release archives will be built and published automatically" ## screenshots: regenerate the pictures in docs/ (needs Chrome or Chromium) screenshots: frontend bash docs/screenshots.sh ## frontend: compile the TypeScript UI into the Go embed directory frontend: ifdef HAVE_NPM cd frontend && npm ci --no-audit --no-fund && npm run build else @echo ">> npm not found — building the UI in Docker" docker run --rm \ -v "$(CURDIR)":/w -w /w/frontend \ -u $(UID_GID) \ -e npm_config_cache=/tmp/.npm -e HOME=/tmp \ $(NODE_IMAGE) \ sh -c 'npm ci --no-audit --no-fund && npm run build' endif @echo ">> UI built into $(DIST)" # The executable discovers this directory next to itself. The installer can # also run natively on macOS and Windows (Git Bash), without Python or Docker. OCR_TARGET ?= $(if $(HAVE_GO),$(shell go env GOOS)/$(shell go env GOARCH),linux/$(if $(filter aarch64,$(UNAME_M)),arm64,amd64)) ## ocr-assets: install the pinned OCR model and CPU runtime into ./bin/ocr ocr-assets: bash helpers/captcha/install.sh "$(OCR_TARGET)" "$(BIN_DIR)/ocr" @touch $(BIN_DIR)/ocr/VERSIONS $(BIN_DIR)/ocr/VERSIONS: helpers/captcha/install.sh $(MAKE) ocr-assets ## captcha-helper: compatibility alias for ocr-assets (no external OCR process) captcha-helper: ocr-assets ## dependencies: install yt-dlp, ffmpeg, deno and local OCR data into ./bin dependencies: ocr-assets $(BIN_DIR) @echo ">> yt-dlp ($(YTDLP_ASSET))" @curl -fsSL --retry 3 -o "$(BIN_DIR)/yt-dlp.tmp" "$(YTDLP_URL)" @chmod +x "$(BIN_DIR)/yt-dlp.tmp" && mv "$(BIN_DIR)/yt-dlp.tmp" "$(BIN_DIR)/yt-dlp" @echo ">> ffmpeg ($(FFMPEG_ASSET))" @tmp=$$(mktemp -d) && \ curl -fsSL --retry 3 -o "$$tmp/ffmpeg.tar.xz" "$(FFMPEG_URL)" && \ tar -xJf "$$tmp/ffmpeg.tar.xz" -C "$$tmp" --strip-components=2 \ --wildcards '*/bin/ffmpeg' '*/bin/ffprobe' && \ mv "$$tmp/ffmpeg" "$$tmp/ffprobe" "$(BIN_DIR)/" && \ rm -rf "$$tmp" @chmod +x "$(BIN_DIR)/ffmpeg" "$(BIN_DIR)/ffprobe" @echo ">> deno ($(DENO_ASSET))" @# deno publishes zips and nothing else, so this one step needs unzip @# where the others get by on tar. Say so rather than failing inside a @# pipeline that has already downloaded 40MB. @command -v unzip >/dev/null 2>&1 || { \ echo "unzip is required to unpack deno; install it and re-run" >&2; exit 1; } @tmp=$$(mktemp -d) && \ curl -fsSL --retry 3 -o "$$tmp/deno.zip" "$(DENO_URL)" && \ unzip -oq "$$tmp/deno.zip" -d "$$tmp" && \ mv "$$tmp/deno" "$(BIN_DIR)/" && \ rm -rf "$$tmp" @chmod +x "$(BIN_DIR)/deno" @printf ' yt-dlp %s\n' "$$($(BIN_DIR)/yt-dlp --version 2>/dev/null)" @$(BIN_DIR)/ffmpeg -version 2>/dev/null | head -1 | sed 's/^/ /' @$(BIN_DIR)/deno --version 2>/dev/null | head -1 | sed 's/^/ /' @echo ">> ready in $(BIN_DIR)/ — the service prefers these over PATH" ## lock: generate frontend/package-lock.json (reproducible installs) lock: docker run --rm \ -v "$(CURDIR)":/w -w /w/frontend \ -u $(UID_GID) \ -e npm_config_cache=/tmp/.npm -e HOME=/tmp \ $(NODE_IMAGE) \ npm install --package-lock-only --no-audit --no-fund @echo ">> wrote frontend/package-lock.json" ## dev: run the Go API and the Vite dev server together (hot reload) dev: @echo ">> API on :8080, UI on http://localhost:5173" @trap 'kill 0' EXIT INT TERM; \ ( cd backend && HEAPLEACH_OCR_DIR="$(CURDIR)/$(BIN_DIR)/ocr" go run ./cmd/heapleach -debug "$(DOWNLOADS)" ) & \ ( cd frontend && npm run dev ) & \ wait ## dev-backend: run only the Go API on port 8080 dev-backend: cd backend && HEAPLEACH_OCR_DIR="$(CURDIR)/$(BIN_DIR)/ocr" go run ./cmd/heapleach -debug "$(DOWNLOADS)" ## dev-frontend: run only the Vite dev server on port 5173 dev-frontend: cd frontend && npm run dev ## hosts: regenerate the supported-site list in README.md from the registry hosts: ifndef HAVE_GO $(error Go is not installed — the host list is generated by the program itself) endif @cd backend && go run ./cmd/hostlist -write ../README.md ## hosts-check: fail if README.md's host list is not what the code produces # # Regenerated into a copy and compared against the file itself, rather than # written in place and compared against git: the section is then checked in # whatever state the tree is in, so a README with prose edits in progress # does not read as a stale inventory, and CI — where the tree is the commit # — still fails on a regeneration that was never committed. hosts-check: ifndef HAVE_GO $(error Go is not installed — the host list is generated by the program itself) endif @set -e; tmp=$$(mktemp); trap 'rm -f "$$tmp"' EXIT; \ cp README.md "$$tmp"; \ (cd backend && go run ./cmd/hostlist -write "$$tmp" >/dev/null); \ if ! cmp -s README.md "$$tmp"; then \ echo "README.md's host list is out of date — run 'make hosts' and commit"; \ diff -u README.md "$$tmp" | head -40; exit 1; \ fi @echo ">> host list is current" ## test: run the Go unit tests, with the race detector as CI does test: cd backend && go test ./... -race ## check: what CI checks, before pushing — formatting, vet, the host list, the tests check: fmt-check vet hosts-check spdx-check test test-frontend test-captcha test-release ## spdx-check: fail if a source file does not name its license # Every source file carries an SPDX identifier, so a legal team can approve # the dependency by scanning rather than reading. Test fixtures are data. spdx-check: @set -e; missing=$$(git ls-files '*.go' '*.ts' '*.tsx' '*.css' '*.sh' '*.py' Makefile Dockerfile \ | grep -v '/testdata/' | xargs grep -L 'SPDX-License-Identifier: MIT' || true); \ if [ -n "$$missing" ]; then echo "no SPDX-License-Identifier in:"; echo "$$missing"; exit 1; fi @echo ">> every source file names its license" ## fmt-check: fail if gofmt would change anything fmt-check: @set -e; unformatted=$$(cd backend && gofmt -l ./internal ./cmd); \ if [ -n "$$unformatted" ]; then echo "gofmt would change:"; echo "$$unformatted"; exit 1; fi @echo ">> gofmt is happy" ## test-frontend: type-check and test the UI (Docker when npm is absent) test-frontend: ifdef HAVE_NPM cd frontend && npm ci --no-audit --no-fund && npm run typecheck && npm test else docker run --rm \ -v "$(CURDIR)":/w -w /w/frontend \ -u $(UID_GID) \ -e npm_config_cache=/tmp/.npm -e HOME=/tmp \ $(NODE_IMAGE) \ sh -c 'npm ci --no-audit --no-fund >/dev/null && npm run typecheck && npm test' endif ## test-captcha: test the in-process OCR reader with the real model (Docker fallback) ifdef HAVE_GO test-captcha: ocr-assets cd backend && HEAPLEACH_OCR_DIR="$(CURDIR)/$(BIN_DIR)/ocr" CGO_ENABLED=1 go test -race -count=1 ./internal/captcha else test-captcha: DOCKER_BUILDKIT=1 docker build --target captcha-test -f Dockerfile . endif ## test-release: check image versions and manual tags against a temporary Git remote test-release: bash .github/scripts/test-release-tag.sh ## test-live: run the extractor tests against the real sites (needs network) test-live: cd backend && go test -tags live -v ./internal/extractor/ ## fmt: format the Go sources fmt: cd backend && gofmt -w . ## vet: run go vet, over the live tests too vet: cd backend && go vet ./... && go vet -tags live ./... ## tidy: tidy the Go module tidy: cd backend && go mod tidy $(BIN_DIR): @mkdir -p $(BIN_DIR) ## clean: remove build output and restore the embed placeholder clean: frontend-clean # Only the binary: running `./bin/heapleach` from inside bin/ puts downloads # in bin/downloads, and `rm -rf bin` would take them with it. rm -f $(BINARY) -rmdir $(BIN_DIR) 2>/dev/null || true -docker rmi $(IMAGE):$(TAG) 2>/dev/null || true frontend-clean: rm -rf $(DIST) @mkdir -p $(DIST) @printf '%s\n' \ '' \ '' \ ' HeapLeach' \ '

Frontend not built

' \ '

Run make frontend or make build.

' \ '' > $(DIST)/index.html @echo ">> restored the $(DIST) placeholder" ## distclean: clean plus node_modules and the downloaded helper tools distclean: clean rm -rf frontend/node_modules rm -f $(BIN_DIR)/yt-dlp $(BIN_DIR)/ffmpeg $(BIN_DIR)/ffprobe $(BIN_DIR)/deno @echo ">> left $(DOWNLOADS) alone; remove it by hand if you want to"