I run a small home server for the usual homelab stuff: backups, media, and the occasional “I’ll download this and sort it out later”. For years, the tool I reached for was JDownloader2. It’s powerful, battle-tested, and it solves a lot of real problems.

My setup, however, was always a bit silly: JDownloader2 ran in Docker on the server, and I controlled it through VNC in a browser. It worked, but it kept getting in the way in small, annoying ways. Clipboard sharing was a two-hop problem (into the browser’s VNC client, then into the remote desktop, then into JD2). Resizing the desktop UI inside a browser never felt stable. On mobile it was close to unusable. And there wasn’t a clean way to add links from the CLI without leaning on MyJDownloader (online service + account), which I didn’t want for a box sitting in my rack.

At some point it became obvious I didn’t need a “download manager as a desktop app”. I needed a small service that exposes a queue.

So I built DLQ (Download Queue): a minimal headless download-queue daemon + CLI, inspired by JDownloader’s core idea, but designed for Docker and terminal use. And, honestly, I also wanted an excuse to learn Go and SvelteKit, so I picked them for the backend/CLI and the optional UI.

Project repo: github.com/Witriol/dlq-download-queue

TL;DR

  • DLQ is a persistent download queue: add links, watch progress, pause/resume, retry.
  • It runs headless, stores state in SQLite, and delegates downloads to aria2.
  • You can use it via CLI (for scripting/SSH) or an optional SvelteKit web UI.

Overview

DLQ is a daemon (dlqd) that stores jobs in SQLite, resolves URLs, and runs downloads via aria2 over JSON-RPC. On top of that, there’s a small CLI client (dlq) and an optional web UI. The guiding principle is simple: if the server restarts, the queue is still there; if a download fails, you can see why; and adding links should be a one-liner from a terminal.

Non-goals

DLQ is intentionally not a “download manager for every site on the internet”. It doesn’t try to replicate JD2’s plugin ecosystem or its whole desktop workflow (link grabber, auto rules, deep parsing, captcha flows). The goal is narrower: make the “queue + download + observe + retry” loop feel native in a headless environment.

Capabilities

At a high level, DLQ turns “a link” into “files on disk”, while keeping enough history to make failures and retries understandable.

The queue itself is persistent (SQLite, typically /state/dlq.db), and you can pause/resume, retry, and soft-delete jobs. DLQ surfaces progress as you’d expect (speed, ETA, and clear state transitions), and it keeps per-job events so you can answer “what happened?” without guessing.

Resolvers are pluggable. Right now it includes a simple HTTP/HTTPS passthrough, a Webshare resolver that tries to operate anonymously and returns explicit blocking reasons, and a MEGA resolver for public file links (resolve a temporary download URL, then download).

After a successful download, DLQ can optionally run a post-processing step to decrypt/extract archives. If that fails (wrong password, tool errors), the job ends up in a distinct state with the reason logged.

Design

DLQ is split into small pieces that talk over well-defined boundaries: dlqd (Go) is the daemon and HTTP API (default :8099), dlq (Go) is a thin CLI client over that API, aria2 is the downloader controlled through JSON-RPC, and dlq-webui (optional, SvelteKit) is a web UI that server-side proxies API calls to dlqd (default :8098).

A simplified view looks like this:

Browser -> dlq-webui (:8098) -> dlqd API (:8099) -> resolver -> queue -> aria2 RPC -> /data
                                 |-> SQLite (/state/dlq.db)
                                 |-> events/logs
Terminal -> dlq CLI -----------/

Why aria2

I didn’t want to re-invent “downloading files reliably”. aria2 already does the hard parts well (resuming, parallelism, and reporting) and exposes a clean JSON-RPC interface. DLQ focuses on orchestration: persistent queue state, retries, URL resolution, and post-processing.

Volumes and “where files are allowed to go”

Because DLQ is meant to run in a container, output paths need to be constrained. The daemon validates out_dir against configured DATA_* mounts so jobs can only write where you explicitly mounted storage. As a side effect, the UI can offer sensible destination presets derived from those mounts.

Workflow

The workflow is deliberately straightforward. You add URLs, the job gets resolved (host-specific logic), the download runs in aria2, and the daemon records progress and events. If a download fails, you don’t need to guess: you inspect job logs and retry.

From the CLI, it looks like:

# Add a job
docker exec -it dlq dlq add "<url>" --out /data/downloads

# Watch the queue
docker exec -it dlq dlq status --watch

# Inspect failures
docker exec -it dlq dlq logs 12 --tail 80

Web UI

The UI isn’t the center of the system; it’s a convenience layer. It’s a separate container, it talks to the same API as the CLI, and it does server-side proxying so you don’t have to fight CORS. For me, its value is simple: batch adds and a quick dashboard on devices where SSH is not a great experience.

Security

DLQ is designed for trusted networks (home LAN, Docker networks). The API has no authentication.

Practical implications: don’t publish :8099 to the internet; if you want remote access, put it behind a reverse proxy with authentication; and set an ARIA2_SECRET so aria2’s JSON-RPC isn’t an open door.

Tech choices

Part of the point of DLQ was learning. I wanted something real enough to have edge cases, but small enough to finish.

Go felt like a good fit for the daemon and CLI: a single static binary, straightforward concurrency for queue processing, and an ecosystem that makes “ship a small server” easy. For the UI I picked SvelteKit because I wanted to learn it, and because it’s a nice match for a thin dashboard that mostly proxies API calls and renders live state.