A small web app that searches book catalogs and drops the results straight into your Stump library — search, click Add to Stump, and the file lands in your library directory and Stump rescans.
0
stars
5
commits
Go
primary language
Aug 29, 2026
updated
A small web app that searches book catalogs and drops the results straight into your Stump library — search, click Add to Stump, and the file lands in your library directory and Stump rescans.
Single Go binary, no dependencies, UI embedded.
Disclaimer: Z-library access is for educational purposes and for works you have the right to download. Respect copyright law. This project is not affiliated with Z-library.
Stump builds its library by scanning a directory on disk, so that is the integration point:
.part temp file in
your library directory, and renames it into place — so Stump's scanner never
sees a half-written book.scanLibrary GraphQL mutation so the book shows up
without waiting for the next scheduled scan.Stump 0.1.x serves its data over GraphQL at /api/graphql. There is no
/api/v1 — REST under /api/v2 is limited to auth, thumbnails and a few
per-file endpoints, and any unrouted path returns Stump's web UI with HTTP 200
rather than a 404.
Verified against Stump 0.1.6:
| Need | Operation |
|---|---|
| List libraries | query { libraries { nodes { id name path } } } |
| Rescan | mutation($id: ID!) { scanLibrary(id: $id) } |
| Log in | POST /api/v2/auth/login with {username, password} |
| API key | Authorization: Bearer <key> |
Because unrouted paths return the web UI, a wrong STUMP_URL looks like a
success at the HTTP level. The client detects an HTML body and says so instead
of reporting a parse error.
The rescan is an accelerator, not a requirement. If the Stump API call fails, the file is still on disk and Stump will pick it up on its next scan; the UI reports the failure but still counts the add as a success.
go build -o stumpzlib .
LIBRARY_PATH=/path/to/your/stump/library \
STUMP_URL=http://localhost:10801 \
STUMP_API_KEY=your-api-key \
STUMP_LIBRARY_ID=your-library-id \
./stumpzlib
Then open http://localhost:8080.
LIBRARY_PATH must be the directory as this process sees it. If Stump runs
in Docker, that is the host path bind-mounted into the container, not the
container path.
Don't know your library id? Start it with just LIBRARY_PATH and open
Settings (/settings.html) in the UI — enter your Stump URL and API key
and it lists every library with its id to pick from.
| Variable | Default | Meaning |
|---|---|---|
LIBRARY_PATH | (required) | Directory Stump scans; downloads are written here |
LISTEN | :8080 | Address to listen on |
STUMP_URL | http://localhost:10801 | Base URL of your Stump server — initial value only, see Settings page |
STUMP_API_KEY | — | API key, sent as a bearer token — initial value only |
STUMP_USERNAME / STUMP_PASSWORD | — | Used instead of an API key; exchanged for a session cookie — initial value only |
STUMP_LIBRARY_ID | — | Library to rescan after each add; omit to skip rescans — initial value only |
AUTH_USERNAME / AUTH_PASSWORD | — | Require a login to use the app; must be set together, or both left blank |
GUTENDEX_URL | https://gutendex.com | Point at your own Gutendex instance if you self-host one |
ZLIBRARY_URL | — | Z-library mirror origin (no path), e.g. https://z-lib.example — initial value only |
ZLIBRARY_EMAIL / ZLIBRARY_PASSWORD | — | Z-library account — initial value only; required for downloads and Recommended, not for search |
MAX_DOWNLOAD_MB | 200 | Per-file download ceiling |
-listen, -library-path, -stump-url and -stump-library-id are also
available as flags, which take precedence.
/settings.html edits the Stump URL, API key, username/password and library
id, plus the Z-library base URL, account and search filters, at runtime
without a redeploy. The STUMP_* and ZLIBRARY_* env vars above only seed
these on first run; the moment you save on the Settings page, the saved
values take over permanently — env vars are no longer read after that; edit
them from the Settings page instead (or delete the settings file to fall back
to env vars again).
If you don't know a working Z-library URL, use Auto-discover on the Settings page. It probes known mirrors (and skips ones that answer with a bot-check page instead of the API) and stores the one it picks. Search works without an account; Add to Stump and Recommended need email and password. Test login stores a session and shows today's download quota.
Changes are saved to .stumpzlib-settings.json, a hidden file inside
LIBRARY_PATH, mode 0600. Reusing LIBRARY_PATH means no extra volume to
mount in Coolify; Stump ignores dotfiles when scanning, and stumpzlib's own
filename sanitizer never produces one, so it won't collide with a book. The
Stump API key and password, and the Z-library password and session cookies,
are stored in that file in plaintext, same as they would be in an env var —
anyone with shell access to the container or the mounted volume can read them.
The Settings page is behind the same login as the rest of the app (see
Login) — set AUTH_USERNAME/AUTH_PASSWORD before exposing this
anywhere reachable, since without them anyone who reaches it can point your
library at a different Stump server or read out your API key's presence.
One thing decides the whole deployment: stumpzlib and Stump must see the same directory on disk. stumpzlib writes a book into it, Stump scans it. Get this wrong and downloads report success while nothing ever appears in Stump.
Two other container gotchas:
STUMP_URL must be the Stump container/service name, e.g.
http://stump:10801. localhost inside the container is stumpzlib itself.LIBRARY_PATH is the path as stumpzlib's container sees it, and must be
a library that already exists in Stump. The app exits at startup if the
directory is missing, rather than downloading into a path nothing scans.Use docker-compose.yaml, which deploys only stumpzlib and mounts your
existing library.
Do not deploy a second Stump. It would come up with its own empty library, stumpzlib would write into that, and your real library would stay empty — a deploy that looks green and does nothing.
docker-compose.yaml (both marked EDIT ME):
the bind mount source, and the published port. They cannot be environment
variables — see "Why those two are hardcoded" below.environment: is a ${VARIABLE}, so it appears there as an editable field.Finding the two literal values:
# 1. The library's host path — the bind mount whose target is /data.
# If Stump binds /opt/media -> /data and its library is /data/books,
# the host path you want is /opt/media/books.
docker inspect <stump-container> \
--format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}'
# 2. A free host port. 8080 is very often taken (Coolify itself, among others),
# which is why this defaults to 8081.
ss -ltn
Set in Coolify's UI:
| Variable | Example | Notes |
|---|---|---|
STUMP_URL | http://host.docker.internal:10801 | Default; works when Stump publishes 10801 on the host |
STUMP_API_KEY | Or STUMP_USERNAME + STUMP_PASSWORD | |
STUMP_LIBRARY_ID | Blank just skips rescans | |
MAX_DOWNLOAD_MB | 200 | |
ZLIBRARY_URL | Optional; or use Auto-discover in Settings | |
ZLIBRARY_EMAIL / ZLIBRARY_PASSWORD | Needed for Z-library downloads |
Coolify rejects a compose file that uses variable substitution in a volume source, to block command injection through a mount path:
Invalid volume source: contains forbidden character '${'
So the bind mount must be a literal path. The published port is literal for the
same reason — to keep the fields Coolify parses structurally free of
substitution. Credentials and URLs are unaffected: they live under
environment:, which is exactly where Coolify's variable UI applies.
If you'd rather manage the mount through Coolify's UI, deploy as an
Application (build pack: Dockerfile, port 8080) instead of a Docker
Compose resource, and add the bind mount under Storages. You'll then need
to add --add-host=host.docker.internal:host-gateway under custom Docker
options, or attach the app to Stump's network, so it can reach Stump.
Use docker-compose.with-stump.yaml, which brings up both services sharing one
books volume. In Coolify, set the compose file path on the resource's
General tab.
Either way, Stump must be on the same host — the integration is a shared filesystem. Across machines you'd need the library on an NFS/SMB share mounted by both.
The default STUMP_URL goes through host.docker.internal, which works
without knowing Stump's network name. To attach directly instead, add to the
stumpzlib service:
networks:
- stump
networks:
stump:
external: true
name: <stump's network name>
and set STUMP_URL=http://<stump-container-name>:10801.
| Coolify setting | Value |
|---|---|
| Port | container 8080, published on host 8081 |
| Health check path | /healthz |
| Storage | the literal bind mount in the compose file |
/healthz returns 503 when the library directory isn't writable, so a volume
that silently failed to mount shows up as an unhealthy container instead of an
app that accepts searches and fails every download.
The image runs as root by default; downloads are written 0644, so Stump can
read them whatever UID it runs as. To have books owned by Stump's user,
run stumpzlib with the same PUID/PGID (user: "1000:1000" in compose, or
Coolify's custom Docker options).
Ordering, since two of the settings don't exist until Stump is up:
LIBRARY_PATH and STUMP_URL. It will start and search;
rescans are simply skipped./settings.html, paste the API key in, and pick your library from the
list it shows — no redeploy needed, it takes effect immediately.Set AUTH_USERNAME/AUTH_PASSWORD (see Login below) before you
expose this anywhere reachable — without them, anyone who reaches it can write
files into your library. Even with a login configured, prefer keeping it on
Coolify's internal network and reaching it over a tunnel/VPN, or putting
Coolify's basic auth (or Cloudflare Access) in front of it as a second layer.
Don't attach a public FQDN and leave it open.
Setting AUTH_USERNAME and AUTH_PASSWORD (both, or neither) puts every
route behind a login page at /login. A correct login sets an HttpOnly,
SameSite=Lax session cookie good for 30 days; Log out in the header
clears it.
Sessions are held in memory, not a database — restarting the container logs everyone out, and this only really works for one shared login, not per-user accounts. That's the deliberate tradeoff for staying a dependency-free single binary; if you need real multi-user accounts, put a real auth provider (e.g. Cloudflare Access) in front instead.
Implement Source in sources.go and register it in newSources:
type Source interface {
Name() string
Label() string
DownloadHosts() []string
Search(ctx context.Context, q SearchQuery) ([]Book, error)
Resolve(ctx context.Context, id string) (*Book, error)
}
SearchQuery.Text is the user's search string. Languages, Extensions,
Order and List (popular / recommended) are for catalogs that support
them; Gutenberg ignores the extra fields.
DownloadHosts is enforced: the downloader refuses to fetch a file from any
host the source didn't declare, so a compromised or spoofed catalog API can't
turn this into an open proxy. Cleartext HTTP is only accepted for loopback.
The UI picks up new sources automatically from /api/status.
The catalog is a remote service, so its responses are treated as untrusted:
..,
NUL and control characters cannot survive, and the result is checked to land
directly inside LIBRARY_PATH.MAX_DOWNLOAD_MB and streamed to a temp file that is
removed if anything fails.Without AUTH_USERNAME/AUTH_PASSWORD set, the app has no authentication of
its own. Bind it to localhost, or put it behind something that does, rather
than exposing it to a network.
go test ./...
Covers filename sanitization and path-escape rejection, the download host
allowlist, format selection, the search and add handlers end to end against a
fake Gutendex and a fake Stump, duplicate handling, the size limit, the "save
succeeded but rescan failed" path, the login/session/logout flow (including
that requests are let through untouched when no login is configured), the
Settings page's API (secrets never round-trip to the browser, a blank secret
field on save keeps the stored value, clearApiKey/clearPassword remove
one explicitly, and settings persist across a store reload), and the
Z-library client against a fake eAPI (search, popular/recommended, login,
quota, session retry, download, auto-discover, bot-challenge handling, and
that Z-library secrets never round-trip).
5 commits
Go
76.0%
HTML
14.1%
CSS
9.5%
A small web app that searches book catalogs and drops the results straight into your Stump library — search, click Add to Stump, and the file lands in your library directory and Stump rescans.
0
stars
5
commits
Go
primary language
Aug 29, 2026
updated
A small web app that searches book catalogs and drops the results straight into your Stump library — search, click Add to Stump, and the file lands in your library directory and Stump rescans.
Single Go binary, no dependencies, UI embedded.
Disclaimer: Z-library access is for educational purposes and for works you have the right to download. Respect copyright law. This project is not affiliated with Z-library.
Stump builds its library by scanning a directory on disk, so that is the integration point:
.part temp file in
your library directory, and renames it into place — so Stump's scanner never
sees a half-written book.scanLibrary GraphQL mutation so the book shows up
without waiting for the next scheduled scan.Stump 0.1.x serves its data over GraphQL at /api/graphql. There is no
/api/v1 — REST under /api/v2 is limited to auth, thumbnails and a few
per-file endpoints, and any unrouted path returns Stump's web UI with HTTP 200
rather than a 404.
Verified against Stump 0.1.6:
| Need | Operation |
|---|---|
| List libraries | query { libraries { nodes { id name path } } } |
| Rescan | mutation($id: ID!) { scanLibrary(id: $id) } |
| Log in | POST /api/v2/auth/login with {username, password} |
| API key | Authorization: Bearer <key> |
Because unrouted paths return the web UI, a wrong STUMP_URL looks like a
success at the HTTP level. The client detects an HTML body and says so instead
of reporting a parse error.
The rescan is an accelerator, not a requirement. If the Stump API call fails, the file is still on disk and Stump will pick it up on its next scan; the UI reports the failure but still counts the add as a success.
go build -o stumpzlib .
LIBRARY_PATH=/path/to/your/stump/library \
STUMP_URL=http://localhost:10801 \
STUMP_API_KEY=your-api-key \
STUMP_LIBRARY_ID=your-library-id \
./stumpzlib
Then open http://localhost:8080.
LIBRARY_PATH must be the directory as this process sees it. If Stump runs
in Docker, that is the host path bind-mounted into the container, not the
container path.
Don't know your library id? Start it with just LIBRARY_PATH and open
Settings (/settings.html) in the UI — enter your Stump URL and API key
and it lists every library with its id to pick from.
| Variable | Default | Meaning |
|---|---|---|
LIBRARY_PATH | (required) | Directory Stump scans; downloads are written here |
LISTEN | :8080 | Address to listen on |
STUMP_URL | http://localhost:10801 | Base URL of your Stump server — initial value only, see Settings page |
STUMP_API_KEY | — | API key, sent as a bearer token — initial value only |
STUMP_USERNAME / STUMP_PASSWORD | — | Used instead of an API key; exchanged for a session cookie — initial value only |
STUMP_LIBRARY_ID | — | Library to rescan after each add; omit to skip rescans — initial value only |
AUTH_USERNAME / AUTH_PASSWORD | — | Require a login to use the app; must be set together, or both left blank |
GUTENDEX_URL | https://gutendex.com | Point at your own Gutendex instance if you self-host one |
ZLIBRARY_URL | — | Z-library mirror origin (no path), e.g. https://z-lib.example — initial value only |
ZLIBRARY_EMAIL / ZLIBRARY_PASSWORD | — | Z-library account — initial value only; required for downloads and Recommended, not for search |
MAX_DOWNLOAD_MB | 200 | Per-file download ceiling |
-listen, -library-path, -stump-url and -stump-library-id are also
available as flags, which take precedence.
/settings.html edits the Stump URL, API key, username/password and library
id, plus the Z-library base URL, account and search filters, at runtime
without a redeploy. The STUMP_* and ZLIBRARY_* env vars above only seed
these on first run; the moment you save on the Settings page, the saved
values take over permanently — env vars are no longer read after that; edit
them from the Settings page instead (or delete the settings file to fall back
to env vars again).
If you don't know a working Z-library URL, use Auto-discover on the Settings page. It probes known mirrors (and skips ones that answer with a bot-check page instead of the API) and stores the one it picks. Search works without an account; Add to Stump and Recommended need email and password. Test login stores a session and shows today's download quota.
Changes are saved to .stumpzlib-settings.json, a hidden file inside
LIBRARY_PATH, mode 0600. Reusing LIBRARY_PATH means no extra volume to
mount in Coolify; Stump ignores dotfiles when scanning, and stumpzlib's own
filename sanitizer never produces one, so it won't collide with a book. The
Stump API key and password, and the Z-library password and session cookies,
are stored in that file in plaintext, same as they would be in an env var —
anyone with shell access to the container or the mounted volume can read them.
The Settings page is behind the same login as the rest of the app (see
Login) — set AUTH_USERNAME/AUTH_PASSWORD before exposing this
anywhere reachable, since without them anyone who reaches it can point your
library at a different Stump server or read out your API key's presence.
One thing decides the whole deployment: stumpzlib and Stump must see the same directory on disk. stumpzlib writes a book into it, Stump scans it. Get this wrong and downloads report success while nothing ever appears in Stump.
Two other container gotchas:
STUMP_URL must be the Stump container/service name, e.g.
http://stump:10801. localhost inside the container is stumpzlib itself.LIBRARY_PATH is the path as stumpzlib's container sees it, and must be
a library that already exists in Stump. The app exits at startup if the
directory is missing, rather than downloading into a path nothing scans.Use docker-compose.yaml, which deploys only stumpzlib and mounts your
existing library.
Do not deploy a second Stump. It would come up with its own empty library, stumpzlib would write into that, and your real library would stay empty — a deploy that looks green and does nothing.
docker-compose.yaml (both marked EDIT ME):
the bind mount source, and the published port. They cannot be environment
variables — see "Why those two are hardcoded" below.environment: is a ${VARIABLE}, so it appears there as an editable field.Finding the two literal values:
# 1. The library's host path — the bind mount whose target is /data.
# If Stump binds /opt/media -> /data and its library is /data/books,
# the host path you want is /opt/media/books.
docker inspect <stump-container> \
--format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}'
# 2. A free host port. 8080 is very often taken (Coolify itself, among others),
# which is why this defaults to 8081.
ss -ltn
Set in Coolify's UI:
| Variable | Example | Notes |
|---|---|---|
STUMP_URL | http://host.docker.internal:10801 | Default; works when Stump publishes 10801 on the host |
STUMP_API_KEY | Or STUMP_USERNAME + STUMP_PASSWORD | |
STUMP_LIBRARY_ID | Blank just skips rescans | |
MAX_DOWNLOAD_MB | 200 | |
ZLIBRARY_URL | Optional; or use Auto-discover in Settings | |
ZLIBRARY_EMAIL / ZLIBRARY_PASSWORD | Needed for Z-library downloads |
Coolify rejects a compose file that uses variable substitution in a volume source, to block command injection through a mount path:
Invalid volume source: contains forbidden character '${'
So the bind mount must be a literal path. The published port is literal for the
same reason — to keep the fields Coolify parses structurally free of
substitution. Credentials and URLs are unaffected: they live under
environment:, which is exactly where Coolify's variable UI applies.
If you'd rather manage the mount through Coolify's UI, deploy as an
Application (build pack: Dockerfile, port 8080) instead of a Docker
Compose resource, and add the bind mount under Storages. You'll then need
to add --add-host=host.docker.internal:host-gateway under custom Docker
options, or attach the app to Stump's network, so it can reach Stump.
Use docker-compose.with-stump.yaml, which brings up both services sharing one
books volume. In Coolify, set the compose file path on the resource's
General tab.
Either way, Stump must be on the same host — the integration is a shared filesystem. Across machines you'd need the library on an NFS/SMB share mounted by both.
The default STUMP_URL goes through host.docker.internal, which works
without knowing Stump's network name. To attach directly instead, add to the
stumpzlib service:
networks:
- stump
networks:
stump:
external: true
name: <stump's network name>
and set STUMP_URL=http://<stump-container-name>:10801.
| Coolify setting | Value |
|---|---|
| Port | container 8080, published on host 8081 |
| Health check path | /healthz |
| Storage | the literal bind mount in the compose file |
/healthz returns 503 when the library directory isn't writable, so a volume
that silently failed to mount shows up as an unhealthy container instead of an
app that accepts searches and fails every download.
The image runs as root by default; downloads are written 0644, so Stump can
read them whatever UID it runs as. To have books owned by Stump's user,
run stumpzlib with the same PUID/PGID (user: "1000:1000" in compose, or
Coolify's custom Docker options).
Ordering, since two of the settings don't exist until Stump is up:
LIBRARY_PATH and STUMP_URL. It will start and search;
rescans are simply skipped./settings.html, paste the API key in, and pick your library from the
list it shows — no redeploy needed, it takes effect immediately.Set AUTH_USERNAME/AUTH_PASSWORD (see Login below) before you
expose this anywhere reachable — without them, anyone who reaches it can write
files into your library. Even with a login configured, prefer keeping it on
Coolify's internal network and reaching it over a tunnel/VPN, or putting
Coolify's basic auth (or Cloudflare Access) in front of it as a second layer.
Don't attach a public FQDN and leave it open.
Setting AUTH_USERNAME and AUTH_PASSWORD (both, or neither) puts every
route behind a login page at /login. A correct login sets an HttpOnly,
SameSite=Lax session cookie good for 30 days; Log out in the header
clears it.
Sessions are held in memory, not a database — restarting the container logs everyone out, and this only really works for one shared login, not per-user accounts. That's the deliberate tradeoff for staying a dependency-free single binary; if you need real multi-user accounts, put a real auth provider (e.g. Cloudflare Access) in front instead.
Implement Source in sources.go and register it in newSources:
type Source interface {
Name() string
Label() string
DownloadHosts() []string
Search(ctx context.Context, q SearchQuery) ([]Book, error)
Resolve(ctx context.Context, id string) (*Book, error)
}
SearchQuery.Text is the user's search string. Languages, Extensions,
Order and List (popular / recommended) are for catalogs that support
them; Gutenberg ignores the extra fields.
DownloadHosts is enforced: the downloader refuses to fetch a file from any
host the source didn't declare, so a compromised or spoofed catalog API can't
turn this into an open proxy. Cleartext HTTP is only accepted for loopback.
The UI picks up new sources automatically from /api/status.
The catalog is a remote service, so its responses are treated as untrusted:
..,
NUL and control characters cannot survive, and the result is checked to land
directly inside LIBRARY_PATH.MAX_DOWNLOAD_MB and streamed to a temp file that is
removed if anything fails.Without AUTH_USERNAME/AUTH_PASSWORD set, the app has no authentication of
its own. Bind it to localhost, or put it behind something that does, rather
than exposing it to a network.
go test ./...
Covers filename sanitization and path-escape rejection, the download host
allowlist, format selection, the search and add handlers end to end against a
fake Gutendex and a fake Stump, duplicate handling, the size limit, the "save
succeeded but rescan failed" path, the login/session/logout flow (including
that requests are let through untouched when no login is configured), the
Settings page's API (secrets never round-trip to the browser, a blank secret
field on save keeps the stored value, clearApiKey/clearPassword remove
one explicitly, and settings persist across a store reload), and the
Z-library client against a fake eAPI (search, popular/recommended, login,
quota, session retry, download, auto-discover, bot-challenge handling, and
that Z-library secrets never round-trip).
5 commits
Go
76.0%
HTML
14.1%
CSS
9.5%