- MD5 auto-match now sends 75 md5: metatags per posts.json query, the same
batch size the original J621-Django app used (was 20); the limit cap no
longer truncates batches.
- every settled upload is upserted into the staged list right away, so the
Pending / Visual Similarity / Auto-uploaded columns move as files land
instead of waiting for the whole batch (auto-matched resolves and IQDB
results use the same path).
- finished upload tiles fade out and remove themselves ~2s after completing;
failures stay until cleared. Batch counters are tracked separately from the
visible tiles so the header and progress bar stay accurate as tiles vanish.
- AGENTS.md now points at the original Django app for reference behavior.
Verified live with a headless upload run: the columns showed the new files
immediately and the 8 tiles were gone ~3s after finishing.
e621 intermittently answers 429 to the IQDB endpoint; browsers hide that
status behind CORS ('Access-Control-Allow-Origin missing'), so the SPA
cannot read it. Treat every network-level failure as a possible rate limit
and pause all e621 traffic for a minute. The cooldown is shared through
localStorage so extra tabs respect it, requests are spaced 1.5s apart
instead of 1s, user-cancelled requests do not trigger a cooldown, and the
upload queue waits the cooldown out with a countdown instead of looking
stuck.
Server side: the per-process e621 gap goes from 0.5s to 1s so two gunicorn
workers cannot together exceed e621's 2/s hard limit.
The dev Vite proxy rewrites the request Host to 127.0.0.1:8000, so the
backend's absolute signed file URLs pointed at a different origin than the
SPA (localhost:5173). Images tolerated it, but the auth'd fetch that reads
the staging blob for IQDB was blocked ('Cross-Origin Request Blocked') and
every similarity check died before reaching e621.
apiUrl() now keeps API-built absolute URLs on the page's origin whenever the
SPA is in same-origin mode (dev proxy, deploy nginx) and leaves them
absolute when an explicit backend URL is configured. All consumers use it:
staging previews and the bulk modal, library cards, optimizer (range sniff +
worker), IQDB card, delete page, similar page.
e621 identification now follows the documented 'App/version (developer)'
form: server-side requests send 'J621/<hash> (JakeBreath)' and the browser
_client gets the same string, with the hash baked into the frontend image
(GIT_HASH build arg; guarded at runtime so the dev server still works).
IQDB stalls: requests now time out after 20s (a hung fetch used to block the
serialized e621 queue forever), and all checks run through one serial drain
so repeated 'Check similarity' clicks can no longer start overlapping runs
that re-download the same staging blobs. Auth/rate-limit/timeout/network
failures stop the queue with the reason and a retry button instead of
grinding through the rest.
Verified live: staged file URL is same-origin through the proxy and fetches
200 through it.
Follow lists were paginated at the API default of 48, but the SPA treats
them as complete sets: the tag/pool toggles read their state from page one
(so the 49th follow looked unfollowed and its spinner waited for a page that
could never contain it) and the Followed page rendered only 48 cards while
showing that as the count. Both follow endpoints are now unpaginated — they
are per-user sets and still restricted to the caller's rows — and the three
consumers take plain arrays.
Post visibility: the old J621-Django online view fetched limit=320 (e621's
maximum) while ours hard-coded 48, and fetchPostsByIds capped id batches at
100. The Online browser now has a 'Posts per page' setting (48/100/200/320)
in its sidebar, mirrored in Account -> Browsing preferences, stored per user
as e621_per_page and also used for pool loading; the id-batch cap is raised
to 320.
Tests: follow list shape/isolation (4) and preference validation/merge (3)
added; the full backend suite is 46 green. Live-checked the array response
shape and the preference bounds (200 accepted, 500 rejected).
Backend: a GreetingToken model stores only a SHA-256 hash of a j621r_…
key (shown once at creation) plus label, prefix, created/last-used. A
dedicated GreetingTokenAuthentication understands the usual
'Authorization: Token …' header but is registered only on RandomItemView
(alongside the normal token auth), so a greeting token authenticates
/api/random/ and is rejected with 401 everywhere else — exactly the scope
shell greetings need. Endpoints: GET/POST /api/auth/greeting-tokens/ and
DELETE /api/auth/greeting-tokens/{id}/ (own tokens only; the list never
returns keys or hashes).
Frontend: /tokens page (Account → Shell tokens card, command palette entry)
lists tokens with label, prefix, created/last-used and revoke (shared
confirm dialog). Creating one shows the key with Copy and 'Copy for fish'
buttons plus a pointer to extras/fish_greeting.
Tests: apps/accounts/tests/test_greeting_tokens.py — 9 tests covering
create-once semantics and hashing, hidden keys in listings, the scope
guarantee (random 200 with a signed URL; 401 on files, storage, me, tags
cloud, delete and the token list itself), unknown/revoked keys, cross-user
revocation, last-used tracking and label limits.
Verified live: created a token, rolled /random (signed URL), got 401 from
four other endpoints, saw the list omit secrets, revoked it (204) and the
same key then 401'd on /random. Full suite: 39 tests green.
Backend: GET /api/random/ (aliases /random and /random/) returns a random
library image with:
- rating=s,q,e filtering (comma separated, default any);
- fastfetch mode (?fastfetch=1 or any User-Agent containing "fastfetch")
that only considers png/jpg/gif - what terminal viewers can show;
- JSON with j_id, filename, extension, rating, size, e621 id plus absolute
url/download_url/thumbnail_url. Authenticated callers get signed URLs so
fastfetch and image viewers can load them without headers; guests get
unsigned URLs and never receive hidden_from_guests items.
Tests: apps/library/tests/test_random.py (8 tests) covering the response
contract, guest signatures, image-only default, the fastfetch format
restriction (flag and User-Agent), rating filters, guest visibility and the
short alias.
Frontend: /random page with rating pills, R to roll, Open/Download and a
library link, plus navigation and command palette entries; needs a backend,
hidden in local mode.
nginx: /random negotiates on Accept so browsers keep getting the SPA while
scripts get the JSON (verified with the proxy and frontend containers).
Also fixes a regression from the SSRF change: the guest download proxy
still referenced the removed 'parsed' variable on its success path, so
every proxied download would have 500'd. Redirect hops are now covered by
tests with a mocked requests.get.
The app can now operate backend-agnostically: a production build still
asks on first start, but /setup also offers 'Continue without a backend'
(stored as the sentinel 'none'), and the shell adapts:
- Local mode shows only the e621-facing pages: Online (search, post view,
favorites, blacklist editor, direct downloads) and Pools. Library,
uploads, duplicates, stats, users, follows and similarity are hidden
from the nav and palette and render a 'backend needed' state when
reached directly; /detail/<e621 id> still works while /detail/J-x asks
for a backend.
- e621 credentials are stored in this browser (j621.e621) and the
Account page becomes a credentials-only screen; the store reads/writes
locally instead of /api/auth/e621/.
- The header replaces the status pill and login/user area with an e621
credentials button and a 'Setup Backend' button; the footer shows
'Local mode — e621 features only' with the same entry point.
- In-library lookups (badges/browse markers) are skipped without a
backend; 'Download to client' links straight to the e621 file instead
of the backend proxy; follow buttons and palette follow toggles are
hidden; api() fails fast with a clear message if something slips
through.
Mode logic lives in lib/backend.ts (URL / '' same-origin / 'none') with
its matrix verified in Node; tsc, oxlint and the build are clean.
New online_hot_default preference (on by default, so guests and accounts
that never saved preferences get it): opening /online without a search
replaces the URL with ?tags=order:hot — e621's metatag for the order the
Hot page uses — so it is visible in the search field and shareable.
Existing searches are never touched: they live in the URL, so a refresh
or back/forward keeps them, while a fresh visit (nav pill, first time,
after a long time away) gets the hot default again. Turning the toggle
off opens Online on the site-wide newest posts as before.
The toggle sits in Account -> Browsing preferences and saves with the
rest of the settings; the backend validates the new boolean
(400 for non-boolean input).
Footer:
- Left is now 'Backend Storage:' with a capacity bar (blue, peach at 80%,
red at 95% per DESIGN.md) and a used/total/free tooltip; the watched
folder path is no longer printed. /api/status/ returns a compact storage
summary instead of the path (the full storage page still shows paths to
authenticated users).
- Centre shows the backend API origin (empty = same origin). Staff get a
link to /setup to point the browser elsewhere; everyone else sees it as
plain text. The Account 'Backend connection' card is gone — this is
installation plumbing, not a per-user setting.
- Design spec updated to match.
Staff role:
- The custom role did nothing on several endpoints that only accepted
Django's is_staff/is_superuser. One canonical check now exists:
User.is_app_staff (superuser, Django staff, or the staff role), used by
the stats/users APIs, item object permissions, can_delete, upload/
similarity/download/match querysets, and the management commands
(which also pick staff-role accounts for e621 sync/match and file
ownership).
Verified with a role-only staff account (is_staff/is_superuser false):
stats/users 200, all 32 downloads + 2 scans visible, others' items
editable; the same account as role=user gets 403 for all of those.
Replaces the build-time VITE_API_BASE knob with a runtime setup screen so
one build works same-origin and cross-origin:
- frontend/src/lib/backend.ts stores the API origin in localStorage
(empty = same origin). DEFAULT_BACKEND_URL is the clearly marked,
easily edited prefilled default — the matrix.org equivalent; set it to
your public API origin.
- Production builds show /setup before anything else on first start,
with a connection test against /health (or leave it blank for this
server). The route stays reachable from Account -> Backend connection;
switching backends clears the previous backend's token and reloads.
- Input normalisation: scheme defaulted (https, http for localhost),
trailing slashes trimmed; a failed cross-origin test points at
CORS_ALLOWED_ORIGINS.
- Dev keeps defaulting to the same-origin Vite proxy; /setup can be
visited manually.
Verified: normalisation cases in Node, /health returns CORS headers for
an allowed origin, tsc/oxlint/build clean.
- django-cors-headers with env-driven CORS_ALLOWED_ORIGINS,
CORS_ALLOW_ALL_ORIGINS, CORS_ALLOW_CREDENTIALS and CSRF_TRUSTED_ORIGINS;
same-origin traffic is unaffected and a disallowed origin gets no CORS
headers. Token auth needs no cookies, so credentials stay off by default.
- TRUST_PROXY_HEADERS=true lets a TLS-terminating proxy supply
X-Forwarded-Proto/Host for correct absolute URLs.
- API media URLs (raw/thumbnail/upload/similarity/staged previews) are now
absolute, built from the request host, so <img>/<video>/fetch() keep
working when the SPA is served from another origin. Signed URLs are still
per-user; nothing is stored in the DB.
- The SPA gains VITE_API_BASE (build-time, empty = same-origin) applied by
a small apiUrl() helper used for XHR/fetch and the few URL fallbacks.
Verified with a throwaway instance: preflight and GET responses carry the
allowed origin, foreign origins get nothing, media GETs include CORS for
cross-origin fetch(), and payload URLs use the request host (dev :8000
unchanged).
Adds a preferences JSON field on the user plus GET/POST
/api/auth/preferences/ (merge semantics, validated keys), surfaced in
/auth/me/ and typed on the frontend.
The Account page gains a Browsing preferences card: landing page,
default rating filter, default sort, items per page and thumbnail size.
Signed-in users also sync these while browsing (the Library sidebar's
rating/sort/per-page controls and the new thumbnail slider), debounced;
on load the account's values seed the local UI state, so settings follow
the user across browsers. Guests keep the existing localStorage
behaviour. The thumbnail size drives the media grids (Library, Online,
pool detail) between 140 and 320px columns.
Verified the API against the dev server: merge keeps untouched keys,
invalid values 400, values round-trip through /auth/me/.
DESIGN.md asks for metadata panels to slide up from the bottom under
768px. A BottomSheet component provides the trigger pill and the sheet
(backdrop blur, scroll lock, Escape to close) and ResponsivePanel swaps
between it and the existing desktop <aside>, so panel content is mounted
once either way.
Applied to the Library/Online/Similar detail asides and to long pool
descriptions. Upload needed nothing: its metadata editor is already a
full-screen modal that stacks cleanly on small screens.
- Active jobs on /stats get a cancel button wired to the existing
download/match cancel endpoints, showing "cancelling..." and an inline
error when the task already finished.
- Cancelling now sets the status immediately, so a task whose runner died
in a restart stops showing as "downloading".
- Download streams use a bounded read timeout (10 s connect / 60 s read):
a stalled socket fails within a minute (previously it could block
forever), and a task cancelled while stalled is marked cancelled rather
than error.
- The stats job list reaps stale download/match tasks, so phantom jobs
never appear on the dashboard.
Backend: GET /api/stats/ (staff only) gathers psutil CPU/memory counters,
nvidia-smi GPU stats, the cached disk numbers and the running/finished
download + match jobs. Root logging now also writes a rotating file
(backend/logs/j621.log) so the dashboard can tail it, and psutil joins the
requirements. The storage payload computation is shared with the existing
storage endpoint.
Frontend: a /stats route + Stats nav entry for staff, polling every 2 s —
per-core CPU bars, memory and swap, GPUs (utilization, VRAM, temperature),
disk with the media/temp breakdown, active jobs with progress bars,
recently finished jobs with summaries, and the log tail with level colours
and an auto-scroll toggle. Section 4 of the roadmap is complete.
The Online sidebar's "Your blacklist" section is editable now: typing a
tag appends it and each entry gets an x to remove it. Changes are written
straight to the e621 account (PATCH /users/{id}.json with
user[blacklisted_tags]); the store keeps the fetched profile for the id,
updates the list optimistically and reloads it from e621. The Followed
page's blacklist cloud is rebuilt afterwards via the new
/api/follows/cloud/?refresh=1 force flag.
Verified against the live API with a reversible add/verify/restore test.
- /similar (nav: Similar): drop a file to get the exact MD5 match, the
perceptual matches against the library, and e621 IQDB candidates
(auto-run for images when credentials are configured). Read-only —
nothing enters the library.
- SimilarityCheck model + /api/similarity/ (create/list/retrieve/delete)
with signed preview URLs and an expires_at timestamp.
- Temp files are wiped on startup (AppConfig.ready, file-only so no
database access during initialization), lazily past
SIMILARITY_TTL_MINUTES (default 30, env-overridable), on delete, and
by manage.py cleanup_similarity.
- uploadFile() takes a target path; .env.example documents the TTL.
Logging out or switching accounts kept the previous user's React Query
cache (follows, feed, cloud, e621 pages), so the new account briefly
saw the old one's followed tags/pools until each query refetched. The
query client now lives in lib/queryClient.ts and login/register/logout
clear it alongside the e621 credential store.
- /pools: search by name, category/active filters, sort options and
pagination per the OpenAPI spec, with covers taken from each pool's
first post in one batched post call; blacklisted covers fall back to a
placeholder and deleted pools get an archive marker.
- /pools/<id>: DText description, post grid kept in the pool's own order
with chunked loading, in-library badges, a blacklist reveal toggle and
a Follow pool button wired into the follows API.
- e621 client gains fetchPools/fetchPool; Pools nav entry added.
Backend (new apps.follows):
- FollowedTag/FollowedPool/FollowedPost models; per-user follows with
unseen tracking, plus FollowCloud for the cached blacklist cloud.
- Two periodic commands sharing one fetch path: sync_followed_tags and
sync_followed_pools fetch each followed tag/pool's newest posts (one
e621 search per unique follow), store unseen feed rows, refresh covers
and pool metadata; both fall back to anonymous e621 access.
- API: /api/follows/tags|pools (follow, unfollow, mark seen), a merged
feed with per-follow filtering, and /api/follows/cloud/ which rebuilds
the blacklisted-tag cloud in a daemon thread when its 10 min cache is
stale (polling returns building/ready).
- e621 client now supports anonymous reads; trimmed posts carry preview
URLs for covers and feed tiles.
Frontend:
- /followed page: follow forms, cover cards with unseen badges and
Mark seen, merged feed with filter/unseen toggle, and a blacklist
cloud panel that polls while building. Followed nav entry added.
A homescreen-style age gate shown before auth or any route: J621 brand
mark, the explicit 18+ check, an Enter action remembered per browser in
localStorage (j621.age-verified), and a blocked state if the visitor
chooses Leave.
- MediaItem gains e621_match_status (unknown/matched/not_found/deleted)
and e621_checked_at, backfilled for existing matched items.
- Server-side e621 client (apps/library/e621.py) using the user's stored
credentials, throttled to 2 req/s, with typed errors.
- Matching service: MD5 lookup, manual post linking (flags MD5
mismatches), unlink, metadata refresh, deleted-post detection.
- Detail actions POST /api/files/J-x/match/ and /unlink/ (uploader or
staff only).
- Background library scans: MatchTask + /api/matches/ with missing/all
scopes, progress polling, cancel and stale-task reaping; the scan
counts toward the footer's Active Workers. Same pass available as
manage.py match_e621 for cron.
- Library gains not_found/deleted status filters; the detail page adds
an e621 match card (check / link by post ID / unlink) and the metadata
card warns when a post was deleted on e621.
Items flagged hidden_from_guests (blacklisted tags) returned 404 for
<img> requests since tags cannot send the auth header. The API now
exposes signed raw_url/thumbnail_url fields (mirroring upload previews
and avatars), and the SPA uses them in the gallery, detail view,
duplicates and delete screens, and upload visual matches.
Backend:
- Perceptual hashes (aHash/dHash/pHash/wHash via imagehash, no imgdd)
stored on items, computed on upload/download and by the new
compute_visual_hashes command
- Duplicates API: exact duplicates (multi-location items), visual matches
for one item, union-find similarity groups with pagination
- Delete API with ownership/staff checks, per-item and per-copy deletion,
watched-folder path validation; storage overview and temp cleanup;
file list accepts j_ids batches
- Staged uploads are flagged visual_match with their library matches
(threshold via VISUAL_MATCH_THRESHOLD)
- Staff users API: list with upload counts, set role and avatar by J-ID;
User.avatar FK with signed avatar URLs
- Download threads close their DB connection and stale tasks are reaped,
keeping behaviour Gunicorn-friendly
Frontend:
- /duplicates: exact duplicate groups with per-copy delete, visual
similarity controls, search similar to a J-ID, paginated groups with
selection, bulk delete and dismiss
- /delete: storage cards, delete by J-ID with preview grid, temp cleanup
- /users: staff directory with role selects and avatar J-ID inputs
- Nav + command palette entries; top-bar avatar; upload cards and the
metadata modal show library visual matches
Backend:
- DownloadTask model + background thread runner: streams the file with
progress (%, bytes, speed) and a cancel flag, then indexes it, names it
J-<id>.<ext> and applies the e621 metadata
- DownloadTaskViewSet (create/retrieve/cancel) replaces the synchronous
endpoint; the status footer's worker counts now reflect download jobs
- Client download proxy (/api/online/file/) streams an e621 original to
the browser with Content-Disposition: attachment, restricted to the
configured e621 CDN hosts so it cannot be used as an open proxy
Frontend:
- Online detail: progress bar with percentage, transferred size, speed
and cancel while downloading; success links to the new J-ID
- New 'Download to client' button available to everyone (guests too)
- 'dismiss all' clears every indexed record at once
- Indexed cards show the actual file preview: completed records now get a
signed library media URL (raw for images, thumbnail for videos) so
<img>/<video> tags can load it, including items hidden from guests
- Media raw/thumbnail endpoints accept the signature for anonymous
requests and fall back to the normal guest-filtered path otherwise
- Guest blacklist keeps a persistent Redis mirror: an expired TTL or an
unreachable e621 keeps the last successful list instead of falling
back to the small local list
- IQDB responses carry no preview/file data, so candidates only showed an
ID; the SPA now enriches them with one batched posts lookup (preview,
rating, score, favourites, dimensions, tag preview)
- Candidate tiles are selectable instead of instantly resolving: picking
one shows its info and an explicit 'Link selected post' button
- Linking a post now fetches the e621 original into the library and
drops the staged upload; when the staged file's MD5 already equals the
post's file, the staged copy is moved instead (identical bytes)
- Keep the file URL in stored e621 metadata; sanitize the new candidate
fields server-side
Backend:
- TempUpload model: staged files (pending / visual_match / completed /
error) with resolution, e621 payload, custom metadata and IQDB data
- Files land in a temp folder and only move into the watched library
folder once resolved; duplicates resolve immediately without a copy
- Endpoints: stage (multipart), list, retrieve, temp file, IQDB save,
resolve (link to post or custom metadata), discard/dismiss
- cleanup_temp_uploads command for old staged files
- Replaces the old direct-to-library upload endpoint
Frontend:
- Upload page is now a three-column board (Pending & Unmatched /
Visual Similarity Detected / Auto-uploaded & Indexed)
- After upload: MD5s are batch-checked against e621 and matches
auto-complete with full post metadata; remaining files run through
IQDB and move to the similarity column when candidates exist
- Metadata modal with IQDB candidates, post-ID linking and custom
tags/rating/notes; discard and dismiss actions
- e621 client gains fetchPostsByMd5 and iqdbSearch helpers
Roadmap updated with the completed upload items.
- New e621 metrics store tracks request count, cumulative time and the
last request (path + duration) for the session
- The e621 client measures each request around fetch and JSON parsing
- Status pill appends a teal 'e621: <total>' segment with a tooltip
showing request count and the last request; a server-side e621 time
is shown separately if the backend ever reports one
Roles:
- JakeBreathild is now staff + superuser (the real account); the 'jake'
smoke-test account was demoted to a regular user
- /me exposes is_superuser and the account page shows an admin badge
e621 metadata:
- MediaItem gains e621_post_id and e621_data (trimmed post payload:
tags by category, rating, score, favourites, comments, sources,
description, pools, relationships, file info, uploader)
- Download to Library accepts the post payload from the SPA and stores
it; the item's custom rating is seeded from the e621 rating when empty
- Library detail shows an e621 metadata card: link to the in-app post,
score/favourites/comments, taxonomy-coloured tags, DText description,
sources and pools; grid cards get an e621 badge and fall back to the
e621 rating for their colour (display_rating)
- Guest visibility now also considers e621 tags, so downloaded explicit
content is hidden from anonymous visitors
Backend:
- User.role (user/uploader/staff) with can_upload; uploads and downloads
gated to uploader+; owners and staff can edit their items
- MediaItem.uploaded_by plus J-<id> identity (serializer, admin,
scan_files --user, first superuser as default owner)
- API resolves J-<id>, bare numeric ids and MD5s; neighbors and lookup
return j_ids
- Guest safety: mirror e621's anonymous default blacklist into Redis
(parses comments, negations and wildcards), flag hidden_from_guests
and filter lists, details and lookups for anonymous users
- POST /api/online/downloads/ writes an e621 file into the watched
folder and indexes it for the uploader
- MariaDB + Redis via docker compose (host ports 3307/6380), PyMySQL
driver shim, Redis cache replacing the file cache; SQLite data
dumped and loaded into MariaDB
Frontend:
- Single /detail/:itemId route with an adaptive shell: J-<id> renders
the library item, bare numbers render the e621 post
- Legacy /view/<md5> and /online/view/<id> redirect to canonical URLs
- Cards expose J-IDs; library custom-data editor is read-only for
non-owners
- Role gating: Upload hidden/blocked for regular users, account shows
the role, guest hint on the library
- New PostThumb component fetches the post through the e621 client
(cached 30 minutes, no retries) and renders its preview image linked
to the in-app post page, with the post id underneath
- Falls back to an external e621 link while loading or when the post
cannot be fetched
Checked against https://e621.net/help/dtext and filled the gaps:
- backtick inline code spans and backslash-backtick escaping
- [color=...] accepts tag category names (artist, copyright, species,
...) alongside CSS colour names and 3/6/8-digit hex
- links: <url> brackets, "title":[url], "title":/relative paths,
wiki links with custom titles and #anchors, {{tag search}} and entity
references (post/topic/pool/set/comment/... #id)
- [quote=red] colours the bar instead of being read as an author
- nested lists via repeated * / # markers
- [section,expanded=Title] renders expanded
- [table] thead/tr/th/td tables and [ltable] pipe tables
- [#anchor] targets and [[#anchor]] in-page links
- headings always start their own block
- e621 list/post queries use a 5 minute staleTime and 30 minute gcTime,
so opening a post and coming back shows the same results with no
refetch; global gcTime raised to 30 minutes
- post card links carry their originating search in route state; the
detail back link returns to it and related-post links keep it
- new navigations scroll to top while history back/forward keeps the
previous position
- DText renderer for e621 descriptions (b/i/u/s, sup/sub, code, spoiler,
quote, color, url/wiki/thumb, headings, lists, sections, expand
blocks, named and bare links) built as React elements, no raw HTML
- Spoiler moved to its own component; trailing punctuation no longer
swallowed into links
Backend:
- POST /api/files/lookup/ reports which MD5s are already in the library
Frontend:
- e621 client extended with post/tag/favorite types and helpers: post
search, post detail, batch posts by id, tag autocomplete, toggle
favorite
- /online: tag search with autocomplete, post grid with rating colors
and in-library badges, numbered pagination, page tag cloud, blacklist
panel and filtered counts from /users/me.json, anonymous hint
- /online/view/🆔 media viewer (sample or original, video support),
taxonomy-colored tags by category, specs sheet, favorite/unfavorite,
description, sources, pools and parent/children thumbnails
- Shared CollapsibleSidebar extracted from the library page; Online
added to the nav, command palette and sidebar toggle
Backend:
- User model gains e621_username / e621_api_key / e621_base_url
- GET/PUT /api/auth/e621/ for the owner's credentials; /me exposes only
the username and a configured flag, never the key
Frontend:
- e621 client core: Basic auth, _client param (browsers cannot set a
User-Agent), serialized queue throttled to 1 request/second, readable
error mapping
- Account screen (/account): username, API key with reveal toggle,
base URL (e621 / e926 / custom), Save + Test connection
- Credentials are fetched from the backend and held in memory only,
cleared on logout
Backend:
- GET /api/files/{md5}/neighbors/ returns previous/next items in the
current ordering (name, size, created_at) for keyboard navigation
Frontend:
- / focuses the library search input
- D downloads the file on the detail page
- [ and ] navigate to the previous/next item; hint shown on the page
- Ctrl/Cmd+K opens a command palette (navigation, toggle filters,
focus search, log in/out); Escape closes it
- Sort order now persists alongside the other library filters so
prev/next stays consistent
Backend:
- apps.core with GET /api/status/ (env, git hash, OS, watched folder,
worker counts) and a TimingMiddleware adding X-Server-Time-Ms
- status endpoint reports its own server-side assembly time
Frontend:
- top bar shows the storage line (watched folder) and a status pill
with ENV, git hash, host OS, server time (e621 time once it exists)
- fixed 32px footer strip: storage path, active workers, build version
- collapsible filter sidebar: closed by default, Ctrl/Cmd+B toggle,
overlay drawer with backdrop blur under 1024px
- rating filters, per-page and sidebar state persist in localStorage
- Spec header, status pill and footer now document '<env> @ <hash>'
instead of invented release numbers
- Backend settings expose GIT_COMMIT_HASH / APP_ENV / APP_VERSION,
mirroring the original app
- Frontend bakes the short hash in at build time and shows it in the
top bar
Backend (Django 6.1 + DRF):
- Token auth with a custom User model (register/login/logout/me)
- Library models (MediaItem, MediaLocation) and REST endpoints
- File list/detail with search, rating filter, sorting, pagination
- Multipart upload with optional rating/tags/notes
- Range-aware media serving (video seeking) and ffmpeg thumbnails
- scan_files management command for the watched folder
Frontend (React 19 + Vite + TypeScript):
- Catppuccin Mocha design tokens from the design docs
- App shell, token persistence, protected routes
- Library grid with filters, file detail with custom data editor
- Upload page with per-file progress via XHR
- Dev proxy to the Django API