The modal held a snapshot of the staged upload, so IQDB results that landed from the background check queue never appeared until it was closed and reopened — the only hint a check was running was the e621 request history. It now follows the live uploads query, so candidates, progress and errors show up in place. Related gaps fixed along the way: - files flagged by the local visual-similarity check were skipped by the IQDB pass entirely (only 'pending' files were checked), so their modal could only ever show 'already in your library'; unresolved files of both statuses are now checked, and the check button shows on visual-match cards too; - a check with no candidates posted nothing, leaving 'never checked' and 'checked, no match' indistinguishable; results are stored even when empty and the modal now says which one it is; - per-file failures surface in the modal instead of being swallowed, the modal shows a spinner while the query runs and a check now/re-check button, and auto-runs skip files already checked (and videos, since IQDB is image-only). Backend production code unchanged; tests pin the empty-result recording (18 library tests, full suite 56 green).
J621
Self-hosted media library and e621 archive manager, rebuilt as a React SPA + Django REST API.
Structure
backend/ Django 6 + DRF API (MariaDB + Redis via docker compose)
frontend/ Vite + React + TypeScript SPA
deploy/ Docker images, compose variants and Tailscale serve configs
extras/ shell integrations (fish_greeting with fastfetch)
Development
Backend
cd backend
source venv/bin/activate
python manage.py migrate
python manage.py scan_files # index the watched folder
python manage.py runserver
Copy .env.example to .env and set WATCHED_FOLDER before scanning.
Frontend
cd frontend
npm install
npm run dev # http://localhost:5173, proxies /api to :8000
Production
Docker: see deploy/ for the two images (SPA on static
nginx, API on gunicorn), the three compose variants (both / frontend-only /
backend-only) behind a shared nginx service and a Tailscale sidecar, and the
public-funnel or tailnet-only serve configs. deploy/push_*.sh builds and
pushes the multi-arch images to the Gitea registry.
Random image endpoint
Used by the SPA's Random page and by shell greetings (fish_greeting + fastfetch):
curl -H "Authorization: Token <token>" \
"https://j621.example.ts.net/api/random/?rating=s,q&fastfetch=1"
{
"j_id": "J-59",
"filename": "J-59.jpg",
"extension": "jpg",
"rating": "e",
"url": "https://j621.example.ts.net/api/files/J-59/raw/?sig=…",
"download_url": "https://j621.example.ts.net/api/files/J-59/raw/?sig=…&download=1",
"thumbnail_url": "https://j621.example.ts.net/api/files/J-59/thumbnail/?sig=…",
"fastfetch": true
}
rating— comma separated subset ofs,q,e(default: any).fastfetch=1, or any request whose User-Agent containsfastfetch, limits the roll topng/jpg/gifso terminals can display it. Images are the only candidates in both modes.urlis absolute, and signed for authenticated callers, so fastfetch can load it without headers. Guests get an unsigned URL and only see guest-visible items./randomand/random/are aliases of/api/random/for scripts. Behind the bundled nginx those aliases negotiate onAccept: browsers get the SPA page, requesters like curl/wget/fastfetch get the JSON./api/random/is the unambiguous path for scripts; 404 when nothing matches the filters.- A ready-made shell greeting that uses this endpoint lives in
extras/fish_greeting/. - Scoped tokens for scripts: the Account page's Shell tokens section
(also
/tokens) issuesj621r_…tokens that only authenticate/api/random/— the rest of the API rejects them. They are stored as hashes, shown once, and revocable any time (/api/auth/greeting-tokens/).
Licence
Source-available, non-commercial: personal and other non-commercial use is welcome under the Jake Labs Non-Commercial Software Licence, which requires attribution and keeps derivative works under the same licence. Commercial use is not permitted. Third-party dependencies keep their own licences (all permissive: MIT, BSD, Apache-2.0, ISC).