Rebuild the torrent detail page and the catalogue around the reader's questions - #30
Merged
Merged
Conversation
The compose header said it plainly — "no tracker (nothing here announces)" —
and no scenario ever noticed, because no scenario asserts on a swarm. But on
a kept stack the consequence is visible to the naked eye: every page that
shows peers showed none, the decision card on a torrent page rendered its
zero-seeder state forever, and that was the only state anyone had ever looked
at. A swarm display cannot be judged without a swarm.
The tracker is now a service on port 54200. `demoTorrents.mjs` and
`demoSwarm.mjs` announce to it with each account's REAL passkey, so what a
page displays comes from the real path: announce, Redis, peers.
- `demoTorrents.mjs` deposits nineteen releases chosen to light up one piece
of interface each: a true cross-seed (same file list, different piece
length, so the same content signature under a different info hash), four
editions of one film under one tmdbId, a supersession, a freeleech with an
expiry, a pin, comments, a favourite, a seed obligation, an adult fixture
and a pending one. Plus nine releases of one series that cover the three
scopes the group endpoint distinguishes — four versions of episode 9, two
of episode 10, two season packs and an integral. `season` and `episode` are
not form fields: they are parsed from the release name, so the names are
scrupulously conventional.
- `demoSwarm.mjs` gives a swarm to a catalogue that arrived by another route —
a restore, an import. Each swarm's shape is derived from the infohash, so a
second run re-announces the same peers instead of doubling them.
- `import-dump.sh` fills the stack from an operator's own pg_dump. Hand-made
fixtures are clean by construction, which is exactly what a layout is not
tested by; a real catalogue carries 3 KiB descriptions with dead images in
them, 50 KiB NFOs, and TMDb ids entered as URLs. It never imports the
`users` table — that one carries auth_verifier, passkey, totp_secret and
panic_password_hash — so uploader and author ids are remapped onto the
harness accounts. Column names are read from the dump's own COPY headers and
checked against the running database, so an older dump either applies or
stops.
- `tmdb.env`, read by the api service when it exists and written by `run.sh`
from the repository's `.env`. A file rather than `${TMDB_API_KEY}` in
`environment:`, because a shell substitution is lost: `docker compose up -d
web` from a terminal without the variable re-evaluates every service, sees
`api` change, and recreates it without the key. Silently.
Three bugs found while testing the importer itself, all of which looked like
success: `docker compose exec -T` ate the check loop's stdin so eight tables
of nine were verified, a missing trailing newline dropped the last line of the
column manifest — which pg_dump orders alphabetically, so the lost one was
`torrents` — and a non-greedy capture over an EMPTY COPY block swallowed the
following table's rows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The page had grown to 3578 lines in one file — 812 of script, 1855 of style —
and nothing in it was testable: every permission, every derived label, every
request lived inside the component that displayed them. It also made SEVEN
awaits at setup level, under a comment claiming the side requests were done
that way "so a slow signature lookup never blocks the page render". The
opposite was true: the first byte left after seven round trips.
It is now 411 lines of page plus 21 components, one composable, three utils
and a guard test. One request blocks the server render — the detail, without
which there is no page — and the rest are lazy.
Four features were already built end to end with no way in from the site, and
now have one: comments (route, POST, notification, push and e-mail, and no web
caller), the quality chips, the group page at `/torrents/group/{key}`, and the
seed obligation in `hnr_tracking`. The last one needed a route, so
`GET /api/torrents/:hash/my-obligation` is new: it answers `null` rather than
404 when there is no row, because "this member never took this torrent" is an
answer, not a failure.
VERSIONS
`/api/torrents/group` has distinguished episode, season and integral scopes
for a long time; the detail page never asked. It does now, and it names the
unit — "Épisode 09 · Saison 01" — before listing the versions of THAT unit.
The old table ranked by size into Light / Balanced / Max picture, which is a
lie twice over. Across units it compared two seasons as if one were a better
encode of the other. Within a unit it crowned the biggest file: measured on
the catalogue, a 40 GiB WEB-DL took "Max picture" over the 20.9 GiB BluRay
that actually has the better image. The markers are now properties, not a
ranking — Max picture on resolution then HDR then source, Compat for "plays
anywhere", Light only when the gap is real — and a marker that separates
nothing stays silent.
RESTORED
The rebuild dropped four things the old page carried, three of them reported:
"uploaded by" in its three distinct states (member, anonymous upload, deleted
account — a concealed uploader and a deleted one both arrive as a null
uploader, and only `uploaderAnonymous` tells them apart), the full infohash
with a copy button, the IMDb / TMDb / TVDB / IGDB / Open Library links, and
the torrent's own tags, which no longer appeared anywhere.
FIXED ALONG THE WAY
- The composable was `async`, so its first `await` lost the Nuxt context and
every page returned 500 (`NUXT_E1001`) — server-side only. The typecheck
returned 0 and 861 tests passed, because the client has a global nuxtApp
fallback. Vue's compiler wraps top-level awaits in a `<script setup>`; a
`.ts` file has no such net.
- The DecisionCard never rendered the `cta` slot the page passed it. Vue says
nothing about a slot given and never used, so above 1280px the page offered
NO way to take the torrent — the only other download button is the dock's,
hidden at that width.
- The description and the comments were wrapped in `<ClientOnly>`, which meant
a BBCode comment went over the wire with its brackets showing, and the
server's placeholder was replaced by different markup on hydration. DOMPurify
runs under Node here; the wrappers were never needed.
- The poster was fetched client-side, so the server always wrote "no poster"
and the client always replaced it — a hydration mismatch on every page whose
work is known. The lookup is awaited at the page's own top level now, where
the compiler restores the context; it serves from Redis, so only the first
visit to a work pays a round trip.
- `groupKey` stripped the `movie/` or `tv/` prefix from the TMDb id. The
server's key contains it, the group route is a catch-all precisely so the
slash survives the URL, and the season split tests `tmdb_id LIKE 'tv/%'`.
Every series whose id was entered as a URL — which the upload form
encourages — resolved to nothing.
- The favourite button called an i18n key that exists in no locale, and read
its initial state from `torrent.favorited` where the route projects
`viewerFavorited`. The star came back empty on reload and the next click
re-added what was already there.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`/torrents?tag=2160p&v=grouped` returned the whole catalogue — books, music, games, 1080p releases — while the same filter on the flat view returned the six torrents that actually carry the tag. The defect was on both sides of the wire, which is why neither half alone would have fixed it: - `groups.get.ts` never declared `tag` in its Zod schema, and Zod strips what it does not declare. The parameter arrived and was dropped without an error, a warning, or a log line. A filter that is absent is worse than one that is broken: nothing points at it. - The listing page did not send `tag` on its grouped query either. It passed search, categoryId, sources, page, limit, sortBy and order. The predicate now lives once, in `utils/tags.ts`, rather than being copied into the second caller — two copies of "carries every one of these tags" would diverge, and the first divergence is what this commit is about. Its behaviour is unchanged: an unknown slug yields an empty result rather than widening to "ignore the filter", because no torrent can carry a tag that does not exist. The federation mirror is excluded whenever a tag filter is asked for. `remote_torrents` does carry a `tags` column, but as `jsonb` in the partner's own vocabulary; the table is empty on the test stack and nothing in the ingestion path pins its shape, so writing a `jsonb` predicate blind would reproduce exactly the defect being fixed — rows crossing a filter without being evaluated. An absent mirror beats an unfiltered one. The flat listing does not federate at all, so both views now agree when a tag is in play. Measured after: 6 groups for `?tag=2160p`, the same count the flat view returns, and 0 for a slug that does not exist. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A design pass on the detail page, measured against the rest of the site rather than judged alone. The page was coherent with itself and with nothing else. WHAT THE MEASUREMENT SAID The site's own vocabulary, counted across 71 pages: `.btn` on 36, `.eyebrow` on 34, `.card` on 21. And `me.vue` — the most worked-on page — already had the recipe this one was approximating: `§` mark, italic display title in `clamp(1.15rem, 2.2vw, 1.55rem)`, a rule tinted by `--section-tint`. Against that, the detail page: - Rendered section headings in TWO typographic systems on one screen. "Versions" at 25.6px italic display, "NFO" and "Pistes audio" at 19.2px upright Inter — peers of the same rank, and no rule saying which applied when. `compact` was switching typeface, not size. - Stacked eleven panels carrying FOUR radii (0, 6, 8, 12px plus an asymmetric pair), five of them with no surface at all and five with one. - Sized its `h1` at 21px while its own section headings reached 25.6 and the index that leads to it uses 29.6. The deepest page in the hierarchy had the smallest title, dominated by its own subheadings. - Drew 30 small-caps labels using five sizes, four weights, nine colours and THIRTEEN letter-spacings — 0.025em, 0.06, 0.07, 0.08, 0.09, 0.10, 0.11, 0.12, 0.14, 0.16, 0.18, 0.20, 0.22. Nobody decided that 0.11 and 0.12 were different things; that is thirty decisions taken thirty times, whose sum has no rule. WHAT CHANGED One heading language (compact varies size, never typeface), one shell for every top-level block (`me.vue`'s `.panel` recipe), a title that outranks its own sections, and a label scale of three sizes, one weight and two trackings — declared on the page rather than in `main.css`, because these are layout values and `themeTokens.test.ts` reads that file as the theme's source of truth. Two alignment defects fell out of the same reading. The NFO panel was the only block with no padding, its heading one pixel from the border, and it kept its heading's bottom margin while its content was collapsed — 54px of panel for 34px of heading, all the slack below. And the bonus row aligned its columns on their BOTTOM edge, so the one field carrying help text pushed its label 24px above its neighbours; the `padding-bottom: 0.55rem` on the checkbox was a hand-cut compensation for exactly that. FIVE DEFECTS, AND TWO NON-DEFECTS - The download button was the 34th and last tab stop: the dock must be the last child for `position: sticky` to work, and below 1280px it was the only CTA. The inline one now shows at every width and the dock's copy is a pointer-only duplicate. 34th to 15th. - `<html lang>` was EMPTY on a bilingual site — WCAG 3.1.1, level A. Set in `app.head` (the static build never runs `useHead` in `app.vue` — the same trap this repo has hit twice) and refined to the real BCP-47 tag at runtime. - The poster carried `loading="lazy"` above the fold. - Ten pointer targets sat under the 24×24 of WCAG 2.5.8. - Three tables had no `<caption>`. Two suspicions did not survive checking, and are recorded so nobody re-opens them: the poster's box was already reserved by `aspect-ratio`, so there was no layout shift; and the two checkboxes measured at 18 and 13px are wrapped in labels of 468×44 and 188×28, so their real target always passed. MADE HANDSOMER, TOO `backdropUrl` had been arriving in the TMDb payload since forever and was rendered nowhere. It becomes a wash behind the poster, masked against the poster column so text keeps its ground — worst case measured at 12.27:1 in dark and 15.17:1 in light, against 18.42 and 21.00 on the bare surface. The poster stops being a 72px thumbnail, the decision card is the only panel lifted off the stack, and the blocks arrive staggered by 40ms through `--motion-scale`, which `main.css` already zeroes under `prefers-reduced-motion`. THE CHIPS ABSORB THE TAGS THEY ALREADY SAY Measured on 49 torrents: 149 tags, of which 107 (72%) repeated a chip word for word, and most of the rest were the same fact under another name — `x264` nine times while the chip read `AVC`. The only real addition was the audio codec, and it was not missing from the parser: `2.0` sat before `AAC` in one list and the first match took the slot. Format and channels now compose into `AAC 2.0`, the chips link to the filtered catalogue, and the tag row keeps only what no chip says — so it disappears entirely on most releases. The equivalence table behind that is deliberately timid: `x264/h264/xvid → AVC` and `x265/h265 → HEVC`, nothing else. `MULTI` and `VOSTFR` are NOT folded together — several audio tracks is not a subtitled track — and 13 tests hold that line, including the one asserting `2.0` is never split into the atoms `2` and `0`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… with 381px of nothing to its right
The "tout afficher (Ctrl+F)" control had a row to itself, 468x44px for a box
and two words, planted at rank 6 of the page's 13 blocks. Directly above it,
the "back to index" link occupied 119x24 and left the rest of its line empty.
Both are commands of the PAGE — as opposed to the commands of the torrent,
which live in the decision card — so they now share one `.page-bar` row:
link left, toggle flush right. `flex-wrap` because the label is translated and
a wordier language must push the control onto a second line rather than out of
the frame. Measured: one line at 1208px wide and 32 tall, wrapping below ~460px
with no horizontal scroll.
The pill drops from 2.75rem to 2rem. It now sits beside a 24px link, where a
44px capsule outweighed the page; 32px still clears the 24 of WCAG 2.5.8, and
the target is the whole `<label>`, not the 17.6px box.
The confirmation sentence used to appear under the box once checked. In a
single-line bar it would cost more height than the control itself, and what it
announces — every section is open — is what you see when you look at the page.
It stays in the document as `sr-only`, tied to the box by `aria-describedby`,
for whoever does not get that glance. The description is dropped when the box
is unchecked: the unchecked state has nothing to describe.
`useExpandAll()` is a `useState` defaulting to false, so the conditional
attribute renders identically on both sides — no hydration mismatch. Verified
in the served HTML, not only in the DOM.
Also removes a Python format-string artefact left in the `.back-link` comment
by an earlier automated edit ("Mesuré à lien de retour, 20 px avant").
893 web tests green, `nuxt typecheck` clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…the infohash kept an empty column beside it The identity card was a two-column grid: poster left, everything else right. That column stays open for the full height of the card, so at 540px the poster stopped at y=254 while the release name and the infohash — 141px of content — still started at x=114.6, with 66px of nothing to their left. The card was 338.9px tall for it. The poster now floats. What sits at its height goes beside it; what comes after takes the full width. No breakpoint to write, because the handover depends on the real height of the content, which varies with whether TMDb answered, whether there is an original title, four genres or none. Same page, same width: both blocks now start at x=49, flush with the poster, and the card is 319.2px tall. The rule to know before touching this file: a child that establishes a block formatting context (`flex`, `grid`, non-visible `overflow`) never overlaps a float — it is placed entirely beside it, for its whole height. That is exactly what the BORDERED blocks need; without it the release-name box would draw its border under the poster while its text avoided it. It is also why `.idc-meta` stopped being a flex container: it runs to two lines as soon as there are four genres, and the poster often ends between them. In inline flow each LINE shortens or not according to what faces it. Vue strips the whitespace text nodes between tags, so the separators carry the spacing themselves. The release name and the infohash stay one unit: they move down together or not at all. This file already argues they designate the same release; letting them align on different left edges would have produced a staircase. Also single-sources the poster width. It was copied in three places — the grid track, the image, the backdrop mask — and the narrow breakpoint corrected only two: the wash stayed anchored at 4.5rem while the poster dropped to 3.5, so it spread under text it was meant to clear. Worst-case contrast for the labels over the wash measures 4.75:1 in dark (`--fg-muted`, 14% opacity, a white backdrop pixel), above the threshold. Checked at 380/540/700/768/1100/1600: no bordered box and no text line overlaps the poster, no horizontal overflow. Both themes. The no-poster placeholder floats the same way. 893 web tests green, `nuxt typecheck` clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… could shrink to, so the tag list dropped under its own label Two defects, one cause. The visible one: below 768px every `.prov-cell` was forced into a column, so "@Donator" — one word — sat under "UPLOADÉ PAR" with 228px of nothing to its right at 390px. The comment justifying it ("a key and its value side by side leave the value two words per line") was true of the tags, never of the uploader. Restricting that rule to the tags cell only moved the defect: a stacked cell still shared the row with the uploader, so "TAGS" rose to the username's line and the pills hung below it, indented. Same shape, different width. The cause is one line of flex layout. A wrapping container decides to break on the HYPOTHETICAL main size of its items, not on what they could shrink to. With an auto basis the pill list offers its max-content width — every pill end to end — so it does not fit beside "TAGS" and wraps. It then restarts at the CELL's left edge, which is under the word "TAGS" itself, leaving the gutter to its left. Observed with three real tags at 501px, reproduced with twelve at 768. `flex: 1 1 0` on the tags value: the list asks for nothing, forces no break, takes what is left and wraps INSIDE. The label stays level with the first row of pills and the rest align under them — a hanging indent, which is what a definition list should do. The breakpoint goes with it; it had nothing left to correct. `.prov-cell` keeps `flex-wrap: wrap` so a genuinely oversized value still drops to its own line: a 39-character username at 390px wraps and stays inside the ribbon (right edge 340 against 358), no horizontal scroll. Measured at 390/501/640/767/768/1024/1440 with three tags and with twelve: the pill block never lands under its label, "TAGS" is always level with the first pill, and the gutter is a constant 32.5px — the label plus its gap. At 501px, the width of the report, the ribbon goes from 92.2px tall to 44.3 on a single line. 893 web tests green, `nuxt typecheck` clean, verified in the CSS actually served to the browser rather than in the source. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…rivacy link was a 15px target on 49 routes out of 63
A measured pass over the whole site, not an opinion: a harness loads each of
the 63 routes in an iframe of a fixed width and records, inside that document,
horizontal overflow, boxes crushed to zero, pointer targets under 24px (WCAG
2.5.8) and text under 10px.
## Horizontal overflow at 375px: four pages, now none
/mod/pending 179px segmented control, inline-flex with no wrap: it
asked for 517px inside a 309px card
/stats 65px minmax(26rem, 1fr) — the track keeps its 416px
/admin/invites 58px the same segmented control
/me 6px flex-shrink: 0 on a translated label pushed the
chevron 26px outside its own button
Three systemic causes behind them, not four incidents:
1. Twenty-four grid tracks that cannot collapse. `repeat(auto-fit, minmax(N,
1fr))` does NOT go below N — the track keeps its width and pushes the page
out. The safe idiom, `minmax(min(100%, N), 1fr)`, was ALREADY used five
times in this repository (Roles, templates, forum); the other twenty-four
never got it. Applied everywhere, so none of these grids can overflow again
whatever the viewport or the operator's `--ui-scale`.
2. Seven full-bleed backdrops on `width: 100vw`. `100vw` COUNTS the scrollbar,
so the backdrop was 15px wider than the visual viewport. They are already
`position: fixed`, where `left: 0; right: 0` is exact by construction —
plus `overflow: hidden`, so the 520px blur blobs stop escaping.
3. Three segmented controls (`.segments`, `.ledger-segments`,
`.queue-segments`) that were `inline-flex` with no `flex-wrap`.
## Pointer targets: from about 200 under 24px to 33
The largest single item was the footer's "retention register" link — 168x15px
on 49 of the 63 routes — while its immediate neighbour, the globe icon,
already carries `min-h-[1.5rem]`. The rule was known; it just never reached
the text link. Then a family of seventeen row-link classes, all between 14 and
22px: `.st-rank-name` x20, `.user-cell-name` x10, the torznab name links x11,
`.mc-hot-name`, `.log-target`, `.au-actor-name`, `.ac-case-target`,
`.room-msg-author`, `.article-crumb-link`, `.tq-who`, `.urt-switch`,
`.media-card-link`, `.back-link`, `.kpi-sub`, `.queue-link`, `.queue-name`,
`.profile-back`.
Two treatments, deliberately: `min-height` where the box is already a flex,
and vertical PADDING where the rule truncates with an ellipsis — a min-height
there would have shoved the text to the top of its line box while the row
around it stayed centred.
`/torrents/upload` and `/templates` write `class="back-link"` without ever
defining it: they render the global rule in `upload-form.css`, so that is
where their fix went.
## Method, twice corrected
A fixed 500ms settle was reading a render still in flight — it produced as
many false positives as false negatives; replaced by waiting for the DOM to
stop changing. And one whole pass measured the login page without saying so,
because ~130 rapid loads had put the web container on the API's own DDoS
blacklist. Every number here comes from after both corrections.
Verified on 30 routes at 375px: zero horizontal overflow. `/admin/users`
checked at 1440px so the added padding did not disturb the desktop tables.
893 web tests green, `nuxt typecheck` clean, md5 cross-checked host vs
container on all 36 files before each build.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tion walked out of the page — 78px of sideways scroll at 320 Same measured method as the site-wide pass, aimed at one page: six data shapes (full metadata, no poster, NFO + comments, long description, awaiting moderation, adult gate) across seven widths from 320 to 1920. ## Horizontal overflow: 78px at 320, 23px at 375, now zero One cause behind three symptoms. `.section-head` was `display: flex` with no wrap, so the action at the end of the line left the head instead of dropping below it: "Toutes les releases →" is 158px wide, the head offers 269 once the title is placed, and the link reached x=390 inside a 367px viewport. The NFO hint (125px) and the swarm tag (126px) did the same. Above 430px none of it showed. `flex-wrap: wrap` cannot change a layout where everything already fits on one line, so this is safe for the other pages that share the component. ## Two incoherences, settled against this codebase's own vocabulary The versions table broke release names mid-token — `MU`/`LTi`, `VO`/`STFR` — because below 768px the column switches to `overflow-wrap: anywhere`. The identity card, 500px higher on the same page, has solved exactly this from the start with a `<wbr>` after every `.`, `_` and `-`, and its docblock names the failure. And this table is the one place on the page where release names are COMPARED, which is where an unreadable break costs most. Same treatment applied; `overflow-wrap: anywhere` stays as the last resort for a segment that still does not fit. The unit bar split a term from its definition when it wrapped: at 390px, "ÉPISODE Épisode 09 SAISON" on the first line and "Saison 01" orphaned on the second. Each pair now sits in its own `<div>` — a valid child of `<dl>`, and the element that groups a term with its definition. Three ragged lines become two clean ones. ## Pointer targets: eight under 24px, now none `.cm-who`, the comment author link, measured 19px at EVERY width — not a layout accident, an omission. `.buffs-toggle`'s checkbox is 13x13 and its `<label>` hugged the text instead of carrying the target; it also gains the pointer cursor it always implied. Verified on all 42 combinations (6 shapes x 7 widths): no horizontal overflow, no pointer target under 24px. Left alone and reported instead: `.idc-poster-note` renders at 9px — the "no poster" caption inside a 56px tile, a deliberate size documented in the component and doubled by an `aria-label`. 893 web tests green, `nuxt typecheck` clean, md5 cross-checked host vs container before each build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… one for the whole site — 25 page views in 10 seconds blacklisted the instance Measured on the e2e stack in production trust mode (TRUST_PROXY=true): a browser call routed through Caddy to /api/* is counted against the member's IP, as intended. A server-side render is not. Its API calls arrive from the web container with no forwarded-client header, so they land on that container's own address — one counter shared by every visitor. Three anonymous renders of /privacy put 12 requests on it: four per page view. With DDOS_THRESHOLD at 100 requests per 10 seconds, about twenty-five page views in ten seconds blacklisted the whole instance for five minutes, doubling on each repeat up to a day, and the only log line was "Blocked blacklisted IP". The discriminator already existed; the web proxy produced it without knowing. A request it RELAYS for a browser has an observed peer, so it sets x-forwarded-for. A request the renderer makes for itself has no peer, so no forwarding header is set at all. Both halves were measured: the container's counter only ever grew from the second population, the peer's from the first. `isInternalOrigin(event)`: private socket peer AND no forwarded-client header. Neither condition can be forged from the internet — a socket address is not chosen, and Caddy sets X-Forwarded-For on everything it relays, so a browser request never arrives without one. Adding a header can only make a request count MORE. The filter reads the socket peer, never the resolved IP, so TRUST_PROXY cannot turn a header into a private address; a test pins that. Exempted: the coarse counter and the blacklist it feeds. Not exempted: the user-agent filter, path and parameter validation, IP bans, per-route limits, authentication. What remains is a network neighbour reaching the API with no header — it still meets everything else, and someone already inside the network has better levers than this one. After the fix, ten server-side renders create no counter for the web container; a relayed browser call and a direct call behind Caddy are still counted, the latter under the member's own IP. The web proxy carries a note: the ABSENCE of a fallback x-forwarded-for is now a contract, and adding one would put every render back on the shared counter. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… swarm health and what the download costs The page was a stack of thirteen panels of equal weight; the eye had nowhere to land, and the one decision — which version, then download — sat fifth, then in a dock. Three moves, in the order they were proposed. ## The hero The TMDb backdrop, in the payload since the beginning and painted at 14% inside a card, becomes a band flush with the header that bleeds past the page frame — through negative margins equal to what <main> and the page add, no `vw`, so no scrollbar counted twice (the defect behind seven full-bleed backdrops elsewhere). The poster overlaps its lower edge; the work title moves to Fraunces italic, the face of this site's page titles and section heads; the synopsis, also in the payload and never rendered, gets two lines. The identity strip (release name, infohash, quality chips) follows at full width, with the uploader at the end of the chip line — a fact about this release, not a card of its own. The command bar floats over the band as two translucent pills: a row above it left 53px of bare ground between header and backdrop. ## Two columns from 1024px Reading on the left: versions first (the central question moves from rank 6 to 1), note, tracks, NFO, comments. Decision on the right, pinned: download, swarm health, figures, cost, obligation, then actions and an in-page table of contents. The decision column comes FIRST in the document, so on one column it follows the band where the decision card used to be, and the download control keeps its early tab stop. The dock disappears at 1024px: the pinned card never leaves the screen, and two copies of one gesture is a duplicate. Two regressions of the new width were found by measurement and fixed. The versions table read its breakpoints off the VIEWPORT and, in an 836px column at 1280px, laid out eleven columns and broke every name over six lines; it now queries its own width. And the column tracks were implicit `auto`, sized by the widest child — a moderation banner whose text fits one 885px line imposed 1025px on a 580px column at 1024px; `minmax(0, 1fr)` bounds them. The pinned column also had to FIT a 900px-tall screen without its own scrollbar. Four stat tiles stacked two by two were 310px for three numbers and a size the button already stated; they become one three-cell strip. Actions become a wrapping row instead of one button per line. Provenance leaves the column. The card goes from about 600px to 385px, the column from 1150px with a scrollbar to 733px without; the table of contents withdraws under 820px of height, since a table of contents you must scroll to see is not one. ## What the download costs Ratio before → after, with the download multiplier applied — the figure that actually decides a download on a private tracker, and one nobody displayed: size, own counter and current bonus had to be known and worked out by hand. Everything was already on the page. "Unchanged" is judged on what is SHOWN: 1.4 GiB against 290 GiB received moves 1.4157 to 1.4090, both written "1,42", and an arrow between two identical numbers read as a bug. A track summary heads the tracks section: the video, every audio track, every subtitle, with language, format, channels, bit rate and the default flag. "Is there a French subtitle?" used to mean thirty lines of MediaInfo or two folded tables; the tables stay below, folded, as sub-sections. ## Swarm health (front + back) `torrent_stats_history`, one row per torrent per day, keyed (info_hash, day). `torrent_stats` is a snapshot rewritten on every pass; it says how many sources there are now, never whether there were more yesterday, and that trend is what answers "will it come down?". The collector writes it at the end of a COMPLETE pass — read back from torrent_stats after the zeroing sweep, so swarms that die are recorded too — and prunes past thirty days. A truncated scan saw an arbitrary subset; a missing day beats a false one. `GET /api/torrents/:hash/stats-history` serves the last seven points to members. `SwarmHealth` shows the state in a word (healthy from five sources, fragile, no source — the word doubles the colour), the last announce from the anonymised peers already in the payload, and the seven-day curve whose last point is the live value. With one day of history it says so instead of drawing a line through one point. Verified on 36 combinations — six data shapes (full metadata, no poster, NFO + comments, long description, awaiting moderation, adult gate) at 320, 375, 768, 1024, 1280 and 1440 — no horizontal overflow, no pointer target under 24px, the right column count at every width, both themes, no hydration warning. On the stack: the migration applies at boot, the first pass wrote 48 history rows. 895 web tests, 545 API tests, `nuxt typecheck` clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…placeholder tile, a plain band and the release name twice A review pass over twelve data shapes (series episode, film, lone film, superseded, awaiting moderation, adult gate, long description, a 104-character name, music, game, book, an oddly titled series) at six widths from 320 to 1440, in both themes. ## The common case was the broken one Music, games, books — everything TMDb does not know — is the COMMON case, and the hero treated it as the exception: a 192x288 "no poster" tile, a plain 192px band, and the raw release name as a two-line display title, repeated verbatim in the identity strip 40px below. About 480px of hero for zero information; on a phone the title ran to four lines and rose under the two floating command pills. Now a "plain" mode: no tile (a placeholder only makes sense where a poster was expected), the band shrinks to a 7–9rem threshold, and the title is what the parser extracts from the name — "Radiohead - In Rainbows", "Frank Herbert - Le Cycle de Dune" — used only when it RECOGNISED something (a kind or a year). Otherwise nothing: the strip carries the h1, the page is never without one, and that is what saves "Baldur's Gate 3", whose version the parser mangles to "v4 1 1 4667176". On phones the plain band is 8.5rem: the pills wrap to two lines and reach 165px, a 7rem band put the title at 161. Measured, then measured again: 185. ## Two pages scrolled sideways Re:ZERO at 375px scrolled 322px: a 104-character token in the uploader's note with `overflow-wrap: normal` — the paragraph kept its width, its text did not. The superseded Dune scrolled 18px: the sentence quoting the replacing release name had no break point either. `overflow-wrap: anywhere` on both. ## Less repetition, less wall The detailed track tables now render only when the summary had to truncate (more than four tracks of a kind): on a FLAC album, "Tracks (1)" followed by a folded "Audio tracks (1)" said the same line twice. Notes over 1,200 characters fold to 26rem with a fade and a "show more" button — decided on LENGTH at server render, not on height after mount, or the section would shrink after paint and everything below would jump; the "show all" toggle unfolds it too. "0 per leecher" under zero sources no longer renders. Verified after the fixes on the same 72 combinations: no horizontal overflow from the page, no pointer target under 24px, no title under the pills. Two things the review surfaced that are not the page: the bogus `http://ReZERO.Starting.Life…` link is in the IMPORTED text (the source tracker's converter linkified a dotted name; our renderer does not autolink bare domains), and Sicario showing Nightcrawler is a wrong tmdb_id in the fixture — which does expose the lack of a "wrong match" affordance. The site header overflows 20px at exactly 768px on every page now that the test account carries real figures; left alone by request. 895 web tests green, `nuxt typecheck` clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e API but exposed by no interface — the 24h on every torrent page was a code default `hnr_enabled`, `hnr_required_seed_time` and `hnr_grace_period` exist as settings: the Go tracker reads them (`cache.go`, `KeyHnr*`), the API freezes the threshold onto every `hnr_tracking` row it creates, and the torrent page announces it — "commits you to 24 h of seeding". Nothing let an operator set them. The 24 h came from the fallback in `getHnrRequiredSeedTime()`, the only way to change it was SQL, and doc/guide/hit-and-run.md said "configurable from /admin/settings". A "Hit & Run" card on the settings page: tracking on/off, seed threshold and grace period in HOURS — nobody sets a seeding requirement in seconds, and 86400 does not read as a day — converted on the server, validated by Zod and bounded to a year, read and written through the existing settings route. The inputs are disabled while tracking is off, since nothing is created then. Round trip on the stack: 48 h written, a never-downloaded torrent's obligation answers 172800; back to 24 h, 86400. One real gap on the way. The obligation route returned nothing until a row existed, so the page only stated the commitment AFTER the click. It now answers with the current global threshold for a release the member has never taken — the value that will be frozen if they do — and nothing at all when tracking is off. The "what it costs you" card finally says it up front. The threshold is frozen per download at click time; changing it does not re-grade past downloads. The guide says so now, in hours, with the setting names and the default-off switch. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… fold, repeated every track twice and had no way to say "wrong film" Six additions to the torrent page, then the three faults they surfaced. ## A breadcrumb in the hero Category / work / unit above the title — "Séries / Frieren: Beyond Journey's End / Saison 01 · Épisode 09", the unit in the accent colour. The episode was only stated in the versions table's unit bar, some 700px down: a member opened the page without knowing WHICH episode they were looking at. The category links to the filtered catalogue, the work to the group page. Under 40rem the work drops out of the trail: the Fraunces title repeats it 40px below, and at 390px the three items took three lines (73px) — now one. The category link exposed a dead one. The catalogue filters on `?c=<id>`, not `?category=<slug>`; the stats page had linked with the slug since it existed, so every category there landed on the unfiltered catalogue. Both use the id. ## "Wrong match?" next to the public records Sicario displayed Nightcrawler's record and a member had no way to say so. A flat button at the end of the TMDB/IMDb/TVDB pills opens the existing report flow with a new reason, "wrong work record", preselected: the moderator gets a report already qualified. `ReportModal` takes a `presetReason` applied on each opening; the plain "Report" button still opens it blank. ## D and F `D` clicks the download call to action, `F` toggles the favourite. Never while typing (input, textarea, editable), never with a modifier — Ctrl+F stays the browser's search, which is precisely what the "show everything" checkbox serves. The keys are written in the actions card and declared through `aria-keyshortcuts`; a shortcut nobody knows is a shortcut nobody uses. ## What differs between versions On Frieren S01E09 the four versions were all 1080p WEB-DL x264: three columns identical on four rows, and the one that decides — the language — set like the others. Each row is now compared to the release shown on four axes (language, resolution, source, codec). Same values lose their weight and recede; differing ones gain weight, `--fg-strong` and an underline in the accent — a cue that is not a colour, named in the legend. An absent value stays bold but is not underlined: an underlined dash reads as an equals sign. The release group is a name, not a quality, and is not compared. Without a "here" row in the table nothing changes. ## The band drifts The backdrop moves at 30% of the scroll — the only motion on the page, and the only one with a meaning here: the veil suggests depth, the drift gives it. The image sits on its own layer, 24% taller than the frame and pinned to the top of the picture, so that at rest the frame shows exactly what `center 28%` showed (measured at 1280: image rows 92–476 against 94–478 before) and the extra 24% is what the drift consumes — bounded to it, so no edge ever shows. The mask stays on the frame; the veil does not move. A first cut used a 35% margin positioned at 30%: it showed a lower slice, and Subaru's head left the frame on Re:ZERO. Under 48rem the layer IS the frame and nothing moves: the band there is nearly as tall as the image. `prefers-reduced-motion` moves nothing. Both gates are read on every frame, not once at mount — a window widened after load gains the drift; the first version tested the width once and a page loaded narrow never moved. ## Tracks: the summary OR the table The summary listed four tracks, then a full table rendered underneath; open, the first four read twice, and closed, two controls — an inert "+16 more" and a chevron — pointed at the same thing. Measured on Re:ZERO, 20 audio tracks and 18 subtitles. "+16 more" is now the button: it opens the full table IN PLACE of the four lines, in the same box, with the count and a "Collapse" control. Never both. The separate "Audio tracks" and "Subtitles" sections are gone; "show everything" still opens all. ## The uploader's note opens unfolded A long note now starts open, and the member sets that default: folding it folds every page, unfolding it unfolds every page. A cookie rather than localStorage — the server render reads it, so the page arrives in the chosen state instead of a note that snaps shut after hydration. Verified: fold, reload folded server-side, unfold, reload unfolded. Typecheck clean, 897 tests, md5 host/container identical, checked in the browser at 390 and 1280 on Frieren, Re:ZERO, Dune, Radiohead and Baldur's Gate. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… gutter, a lighter track and the hero cut short of the edge, even on a Mac set to overlay bars `main.css` styled `::-webkit-scrollbar`, `-track` and `-thumb` globally. In Chromium and WebKit that does far more than colour: any rule on those pseudo-elements switches the scroller to a "classic" custom bar that takes a gutter and never overlays — including on macOS with overlay scrollbars, which would otherwise show no bar at all. Measured on the root: 8px of gutter with the block, 0 without; the hero backdrop ended at 636 of 644px and now reaches 644. Three components repeated the pattern on their own lists: the notification list (6px), the icon picker grid (8px), the branding preview (4px). The two standard properties, `scrollbar-width` and `scrollbar-color`, do not have that effect. Where the system floats its bars (macOS by default, iOS, Android) they float: content underneath, bar on top. Where it fixes them (Windows, Linux, macOS "always") they are thin, the transparent track shows the page or the box behind, and the thumb takes the theme's strong line. `scrollbar-color` inherits, `scrollbar-width` does not — hence `*`. No `::-webkit-scrollbar` fallback for Safari before 18.2, on purpose: it would recreate the fault; those keep the native, floating bar. The two intentional hides of horizontal rails (`.no-scrollbar`, `.cats-row`) stay — `display: none` on the pseudo-element hides without forcing a visible classic bar. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…" sat over "0 sources", and every small control was 24px under a thumb An audit of the torrent page on five fixtures (series episode, season with a long note, film remux, plain music, mismatched film), both themes, 390 and 1280px: contrast computed by compositing the backgrounds, pointer targets, accessible names, heading outline, overflow, a real Tab walk. No page text fell under 4.5:1 in either theme, nothing scrolled sideways, focus rings and order were right. What follows is what did not pass. ## Structure The uploader's note renders Markdown, and a `# Title` became an `<h1>` under the section's `<h2>` — on Re:ZERO the note alone contributed h1, h2, h2, h3. `DescriptionRender` takes a `headingOffset`; the page passes 2, so h1 → h3 and the document keeps a single h1 (measured: H3,H4,H4,H5 in the note now). Sizes for h4–h6 added so the shifted levels still step down. ## Copy that repeated or contradicted itself - "Aucune source" over "0 sources": the dead state now shows the last announce, or "Personne ne la partage en ce moment." - "snatchs" → "au total": the tile is the all-time count, that is the fact that distinguishes it from the two live ones beside it. - "Pas encore prise" → "Pas encore téléchargée". - "ratio inchangé" next to "Il compte en entier dans votre ratio" read as a contradiction; a dedicated note says why both are true. On a freeleech release the cost block repeated the GRATUIT band's sentence; it now keeps only "Sans le bonus : 1,42 → 1,11." - "Tout afficher (Ctrl+F)" read as the key that toggles the box — all the more since D and F are announced in <kbd> below. Now "pour Ctrl+F", with a tooltip saying what it is for. - "0 S" in the versions table (the column head hides under 42rem) becomes the decision card's chevron plus a screen-reader word; `seedUnit` is gone, the related-releases panel that also read it uses the same glyph. ## Hero facts wrapped on their separators At 390px on Dune the meta row broke on a dot: "· Science Fiction · Adventure ·" orphaned at both ends. The dots are `::before` content inside a 1.1rem left padding, with the row shifted and clipped by the same amount: a fact that opens a line has its dot in the clipped zone, a fact mid-line shows it between its neighbours. Verified fact by fact: none at a line start. ## Header contrast and targets (visible on this page, fixed in place) The version label was 2.45:1 (`text-text-muted/60`); it passes now. The notification badge used a hard-coded `#f43f5e` under white text: 3.67:1 in the light theme. With `rgb(var(--danger))` it measures 6.47:1 light, 4.62 dark. The stats refresh button was 20×20 (`p-1` around a 12px icon); 24×24. ## Pointer targets under a thumb Chips, provider pills, breadcrumb links, "+16 autres", the action buttons and the copy buttons all sat at 24–28px on a phone. Under `pointer: coarse` they are 36px, from one `:deep()` rule on the page. The buffs form's native 13×13 checkbox is 16px in the site's accent. ## Screen reader The copy buttons only changed icon and tooltip; a `role="status"` region now announces "Copié". Left as they are, on purpose: the 9px mono labels are a system token already decided; the English synopsis has no `lang` because the provider's language is not known; "e:ZERO" without its R is the fixture's own text. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…y motions are the ones that mean something — the click, the curve, the folds, the numbers, the place Ten additions from a motion and finish proposal, under one principle: a movement says either that something just changed or where you are. Every duration rides the `--dur-*` tokens, and each component also cuts its own motion under `prefers-reduced-motion` — the `--motion-scale` token is fixed and does not follow the system preference, so the global kill switch in `main.css` is the only other guard. ## The click is the one rewarded moment The arrow becomes a check that pops in, and a ring closes around the button in 300ms. The page then re-reads the obligation. Not once, 1.2s later — the first cut did that and measured the row still "not downloaded": the download route creates the tracking row, but the state only moves at the client's FIRST announce, when `downloaded` leaves zero. So it re-reads at 20, 45, 80 and 120s and stops as soon as the state moves: four requests at most. On the stack, the card went from "Pas encore téléchargée" to "En cours" with its meter when bytes appeared on the row, and the state tint sweeps across it. ## The curve draws itself `pathLength="1"` on the sparkline, so the stroke animates in CSS with no measuring: 600ms left to right when the card ENTERS the viewport (an IntersectionObserver at 40%), the area fades in after, the present-day dot pops last. Measured mid-course at 9% of the path, then complete. A living swarm's pip breathes three times after arrival, at low amplitude, then rests; a dead swarm does not move. ## Folds that open instead of jumping The tracks table and the four summary lines settle into place after a gesture — never on first render, gated by an `interacted` flag. The versions rows crossfade through a `TransitionGroup` when the unit changes, exits shorter than entries and out of flow. The note folds in height: 3114px, 871 at 60ms, 416 at the end, and back. Two traps on the way: the computed value of a custom property is the string `calc(200ms * 1)`, which `parseFloat` cannot read — the duration is read off the transition once set; and the start value must be COMPUTED before the target or the transition runs from `none`, which does not animate — reading `offsetHeight` forces it. ## Numbers, and the place A swarm value that changes slides up (a keyed `<Transition>`) and its tile tints for half a second. The table of contents' active bar glides between entries in 180ms, measured from 0 to 30px on a section change, instead of jumping. And the entrance is confined to the first screen: the backdrop develops from a 1.03 scale, the poster rises 80ms later, title and identity strip at 160, the decision card at 200 — done by 400ms. The previous version animated every section, including those below the fold: motion nobody saw that delayed what they read. `animation-fill-mode: backwards` on the backdrop so the drift's own `transform` takes over when the sequence ends. ## The colour of the work The poster is sampled on a 12×18 canvas: saturation-weighted mean of the pixels, blacks and whites excluded, then clamped in saturation and lightness so it is a HUE, never a brightness, and mixes the same on both themes. Frieren pulls green (70 185 112), Dune orange (211 129 44), Re:ZERO violet (185 70 160). It touches decorative surfaces only: the top of the veil and the decision card's border, mixed a third into the gold. Poster CDNs send no CORS header — measured on TMDB: the image does not load in `anonymous` mode, and without it the canvas is tainted. `/_media/sample`, a route on this server, relays the pixel: three known hosts, HTTPS only, no redirects followed, an `image/*` type, 400 KB and four seconds at most, and only from our own pages (`Sec-Fetch-Site`). Probed: 200 for TMDB, 400 for a foreign host, 403 cross-site. Cached a day. A 6% SVG noise on the veil keeps the gradient from banding on large screens, and the versions table — the one that decides — gets a little more air. Not verified: the breathing pip (no fixture with live sources on the stack) and the reduced-motion path (the browser pane cannot emulate it). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…y port and any image type — and a held-down D key downloaded on repeat A review pass over the seventeen commits of the branch: the server-side changes read line by line, the composable and the page script, the forms; semgrep in two passes over the 101 changed files, gitleaks over the commits (nothing). Semgrep's findings were all known arbitrations or a URL inside a comment; what follows came from reading. ## `/_media/sample`, the one new server route It relayed a poster pixel so the canvas could sample it. Six holes, each closed: - `Sec-Fetch-Site` was only checked when present, so curl and a typed URL went through. Now `same-origin` AND `Sec-Fetch-Dest: image` are REQUIRED; a client without them (curl, a navigation, an old Safari) gets 403 and the page is simply untinted. Probed: 403 without, 200 with. - Any `content-type: image/*` was echoed, SVG included. An SVG served from our origin and opened as a document runs its scripts as us. Raster types only: jpeg, png, webp, gif, avif. - The body was read whole before the size check, so a chunked response with no `content-length` could fill memory first. It is now read in chunks and cancelled at the first byte over 400 KB. - `https://image.tmdb.org:8443/…` and `https://u:p@image.tmdb.org/…` passed the host check. Port and credentials are refused (probed: 400). - The response now carries `Content-Security-Policy: default-src 'none'; sandbox`, so even a mis-typed asset cannot act as a document. - Sixty requests a minute per address, so nobody makes this server hammer the CDNs; a page asks for one. ## The keyboard Holding `D` fired a download per key repeat; a composing IME keystroke counted as a letter; and `D`/`F` kept working with the report or confirm dialog open. `e.repeat`, `e.isComposing` and an open `[aria-modal]` now return early. Verified with the report dialog open: zero clicks. ## Two small hardenings on the hero The backdrop URL went into a CSS variable as `url(…)` unquoted; it is `url("…")` with quotes and backslashes escaped, so a URL never closes its own function. The tint sampler now ignores a result that arrives after unmount or after the poster changed underneath it. The page's meta description carried raw BBCode; tags and markup punctuation are stripped. Reviewed and left as is: `isInternalOrigin` (peer socket AND no forwarding header, not forgeable from outside, four tests), the two new routes (session required, infohash validated, a member reads only their own row), the collector's parameterised SQL, the migration's cascade FK and increasing timestamp, heading demotion applied after DOMPurify, forms relying on server validation, and the page remounting between torrents so the obligation polls stop. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…for an hour — thirty posters gone for a network hiccup The three source clients (TMDB, IGDB, Open Library + Google Books) returned `null` for a 404 and for everything else alike — a timeout, a 5xx, a 429, an unreadable body. The callers could not tell them apart and wrote the same negative sentinel with the same TTL: `META_TTL.NEG_S`, one hour. Measured on the e2e stack: a burst of lookups on the catalogue page hit the 8s timeout, and every one of them was remembered as "TMDB does not know this id" for an hour. Thirty pages without a poster, from a network that coughed once. An absence and an outage are now two different things. The clients throw `UpstreamUnavailableError` on anything that is neither 200 nor 404 — timeout, network error, 5xx, 429, 401, unreadable JSON. A shared `guarded()` wraps the eight functions that write the cache: on that error it stores the empty sentinel under the same key for `META_TTL.ERR_S` — two minutes, long enough not to hammer an upstream that is struggling, short enough for the poster to come back with it — and returns the empty value. A real 404 still costs an hour, a hit still lasts a day. The registry's three entry points catch the error as a last resort, so a page renders without a poster rather than failing with a 500. Four unit tests with a fake Redis and a stubbed `fetch`: a 404 is kept 3600s, a `TimeoutError` and a 503 are kept 120s, a hit 86400s. Full API suite: 37 files, 549 tests. On the stack, a known id still answers from cache and a nonexistent one produces a 3600s empty entry, as Redis shows. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…— now a bar that understands what you type, a rail of counted facets, works before releases, and an empty state that proposes Members type "frieren 1080p x265 vostfr s01": the bar turns the four qualifiers into typed chips at the first space and keeps the title as text. Chips are OR within a family (1080p 2160p), AND across families; synonyms travel with each chip (x265 = hevc = h265) so a slug missing from this instance's vocabulary never empties the result. A pasted TMDb/IMDb/TVDB link still becomes the id filter. Chips, options, view, sort and page all live in the URL (tk/se/ep/y/u/o/v/s/d), so a search is a link. Under the bar, when it has focus: the works the text designates (poster, year, release count — chosen with the arrows and Enter, a choice filters the catalogue on that work), the forms the bar understands, and the six most recent searches. Works are the default view: one card per work with poster, title and year read from the metadata cache (never from upstream — that burst is what put a whole page without posters for an hour), category, release count, the resolutions and sources present, the swarm state, and the releases folded inside by unit (S01 · E09, Season, Complete) with the page's own filters applied. The card's left rule takes the poster's dominant colour, which the detail page already computes: it now deposits it (POST /api/metadata/tint, 30 days in Redis) and worksFromCache serves it with the title. The Releases view rows read in the order the eye asks: work title and unit, then the file name in mono, the detail page's quality chips, size, swarm with a named state pip, uploader; badges Free / Replaced / Taken; ↑↓ Enter D F on the keyboard. Density (comfortable/compact) is a cookie. A sticky rail counts every facet under the current search — category, resolution, source, codec, language, HDR, audio, a year histogram, and four member questions (with seeders, free, not taken yet, hide replaced). Facet rule: a dimension is counted with every filter but its own, and tagGroups is dropped one group at a time (tagsByGroup) so ticking 1080p keeps the other resolutions readable. Under 1100px the rail is a bottom sheet: a floating "Filters (n)" button, a modal with backdrop and focus, "See n results" to close. Above the results: saved searches as pills with "n new" since last opened here (seen_count, migration 0074, POST …/seen), pinned releases as poster cards, and when nothing matches, what the same search gives without each criterion — counted with the listing's own exact-then-fuzzy rule, because a suggestion that promises 9 and opens a page of 1 is worse than none — then "Create a request" and "Alert me when it lands". API: the listing's predicates move to utils/torrentListing.ts and are shared by the flat listing, the grouped view (which had silently dropped the tag filter once, and now also accepts imdbid/tmdbid/tvdbid) and the new facets route. New filters: tagGroups, uploader, year, season, episode, minSeeders, freeleech, notTaken, hideSuperseded, groupKey. Rows carry uploader, viewerTaken, freeleech and work; groups carry work and tagSlugs; two reads per page regardless of row count. Measured on the e2e stack: 923 web tests and 549 API tests green, both typechecks at 0 errors, pages checked at 1280 and 390 in both themes. Two traps met and noted: a literal @ in a vue-i18n string breaks server rendering with the bare message "10"; drizzle renders a column unqualified inside a SELECT list, so a correlated subquery there read the wrong table. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…se name was a single token for the text-search parser, and the fuzzy fallback hid the hole whenever the exact search returned zero `Sousou.no.Frieren.S01E09.VOSTFR.1080p.WEBRip` is one token for Postgres' default parser (a host- or file-shaped word), so the prefix query "frieren:*" matched only the release whose name used spaces. Because the listing falls back to trigram similarity when the exact search is empty, the miss showed as a wrong COUNT rather than an empty page — and the catalogue's "without this criterion" suggestions made it visible. The indexed vector now replaces dots and underscores with spaces before to_tsvector, in the same expression on both sides (ftsVector is what schema.ts emits as the GIN index and what the routes use as predicate); hyphens stay, the parser already splits them. The saved-search fan-out compared the new torrent's name with a literal to_tsvector and would have skipped "frieren" alerts on dotted uploads: it uses the shared vector now. Migration 0075 drops and recreates the four GIN indexes — generated by drizzle-kit from the 0074 snapshot, then documented. On the e2e catalogue: frieren 1 → 9, "sousou no frieren" 0 → 9, "frieren s01e09" 0 → 4. A name with no separator at all (ReZERO) is still one word; that is the name, not the parser. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ing, work titles behind a did-you-mean, alerts that match what the page filters, search misses, category merge and catalogue settings - `relevance` sort key: ts_rank_cd on the exact match, word_similarity on the fuzzy fallback; groups fall back to age since they have no rank - `work_titles` (source, id, locale) filled by lookupMetadata and a warmer plugin (one lookup per tick, cron lock, done-set in Redis); the facets answer `didYouMean` from it with word_similarity >= 0.45 - facets: 20 s Redis cache per user and query, strict total, `statsAt` as a real UTC instant, `dropOne` counts computed like the listing (exact, then fuzzy), options incl. favorites, hand-qualified `torrents.*` in correlated subqueries (drizzle renders selected columns unqualified) - saved searches carry tagGroups, season, episode, year, uploader; the fanout matches them (candidates get season/episode from the release name); `seen` aligns seenCount on matchCount - `search_misses` recorded only for a text-only, page-1, empty search; admin routes to list and delete them - admin: merge a category into another in one transaction (torrents, children, saved searches, requests, upload rules, remote map); catalogue settings (default view, default sort, page size, visible facets) exposed by branding - shared listing predicates (`torrentListing.ts`): visibility, filters, search, enrichment from `torrent_stats` in one query; `groupKey`/`groupScope` for the releases of one work; `firstScope` per group under the requested sort - me: `catalogueDefaults` (<= 12 keys); metadata tint stored 30 days - migration 0076 with its snapshot (generated files come out 600: chmod 644) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… and episodes as folding rows, the age on every release; one band for filters and count, a type scale, alerts capped at five The scope pill was the only way into a work's releases, and a small one. Now the header itself opens on the first torrent's scope, a chevron at the end says so, and the pills only choose the cut when there is more than one. Inside, the structure reads before the files: a block per season, episode rows with their count, resolutions and seeders, releases under the one you unfold; the latest season and its first episode start open. - nested release rows: file name on top (two lines at most), size, swarm and age at the right, qualities in one line of value-only chips below; the unit chip is gone since the header above names it; the exact publication date on hover of every age (rows and cards) — lifted above the stretched title link, which swallowed the hover of age, swarm and status dot - one sticky band: count and total size when no filter, the sources toggle among the tools; the separate results line and its duplicate count are gone - type scale 10·11·12·13·14·16·20·26 px across the page and the six search components; card meta in Inter lowercase; 16/24/16/8 vertical rhythm; card radius aligned with the detail page; mobile card without the quality ladder - pinned releases: horizontal eyebrow (the vertical spine was an invalid grid the browser dropped whole) and hidden in the works view (their data comes with the flat listing, which the works view never reloads) - rail alerts capped at five with "Manage · n more"; suggestions panel below the field; recognised works heading; "load n more" says the real remainder - stuck state of the bar measured under the site header, not the screen edge; the filters sheet gives focus back to its button; aria-controls only when the target exists; row titles clamp to two lines instead of an ellipsis - relevance sort with text, saved defaults on the account, recently viewed in the suggestions, version grouping, load more, favorites option, did-you-mean pills in the empty state; admin screens for search misses, category merge and catalogue settings - six dead `search.*` keys and a transition's leftover CSS removed Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… by the minifier — five glass surfaces had no blur in Chrome The build merges `backdrop-filter` and `-webkit-backdrop-filter` into one logical property and keeps the last one written. Where the source listed the standard form first, only the prefixed one survived, and Chrome does not read it: `getComputedStyle(el).backdropFilter` was `none` on the home KPIs, the anti-cheat bulk bar, the upload status pin (three scoped copies) and the catalogue's sticky bar. Sources that wrote only the standard form came out with both. The twins are gone; the build adds the prefixes the targets need. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… ways it told a member more than it should
## The menu needed a filter that did not exist
`since=24h|7d|30d` on the listing, the grouped view and the facets, from a
closed table of intervals read with `Object.hasOwn` so no inherited property
can reach `sql.raw`. The facets count the last 24 hours next to the other
options, and `since` joins the criteria the empty state drops one by one.
## Security review
- `users.anonymous_uploads` was honoured by the detail page and the feeds but
by none of the new catalogue paths: the enriched listing returned the name,
`?uploader=<name>` listed an anonymous member's whole history, and a saved
search on that name notified every one of their uploads. `redactUploader`
now runs in `enrichListing`, the filter resolves the name only for the
member themself and staff, and the write path answers 400 like an unknown
member so the two cases stay indistinguishable.
- `tagGroups` was unbounded: one facets request fanned out to one query per
group, each carrying the others as correlated `EXISTS`, on a pool of ten.
Capped at eight groups of ten, deduplicated, and both `facets` and the flat
listing now take `RATE_LIMITS.public` like the grouped view already did.
- `search_misses` grew one row per distinct empty search with no ceiling.
Thirty per member per hour, five thousand rows total, the least asked and
oldest pruned first — and `since` counts as a filter, so a window that
returns nothing no longer claims the catalogue lacks the title.
- `didYouMean` read `work_titles` with no visibility: a suggestion could name
an adult or unapproved work to a member who cannot open it. It now requires
at least one release that member can see.
- `stats-history` and `my-obligation` answered for any hash: an existence
oracle for a pending or withdrawn release. `assertVisibleTorrent` gives them
the listing's own rule.
- `/api/metadata/tint` accepted any 1–128 character id, so a member could sow
Redis keys at will. The shape is now checked per source and the work must
already be in the metadata cache.
- Category merge: the upload-pattern table is keyed on the category, so the
nominal case (two categories that both carry a pattern) rolled back with a
500; an adult category merged into an ordinary one made its torrents visible
to members who opted out; and nothing refused a target that descends from
the source. Four explicit refusals, ids validated as UUIDs.
- `search-misses` DELETE parsed its body with a bare `.parse`, so `{query:""}`
escaped as a 500.
## Correctness review
- The grouped view searched name/description/nfo only — no work titles, no
fuzzy fallback — while the facets counted all three. The band read
"9 releases · 0 works" over an empty grid. It now shares `searchConditions`.
- That work-title arm was a correlated `EXISTS` inside the `OR`, which cannot
join a BitmapOr: every text search became a sequential scan with a per-row
subplan. Two steps instead — the titles first, then `inArray` on the id
columns — and `EXPLAIN` shows the BitmapOr over both GIN indexes.
- The grouped tag enrichment filtered on `groupKeySql IN (…)`, a CASE
expression no index serves; it now uses the same member predicates the
single-group listing uses.
- `age`, `relevance` and the group orders had no unique last key, so equal
timestamps could repeat or drop a row between appended pages.
- The metadata warmer marked a work done for seven days when the upstream was
merely unavailable; failures now land in a one-hour retry set.
- Saved-search fanout compared a bare TMDb id against the prefixed form and
ignored sub-categories, so alerts saved from the work picker never fired.
- The year facet took the FIRST match per name and counted `1920x1080`: a name
with two years under-counted one of them. One shared pattern for the filter
and the facet, every occurrence counted, resolutions excluded.
## Comments no longer ship whole
The detail payload carried every comment on every visit. It now carries the
first twenty and the total; `GET /api/torrents/:hash/comments` serves the rest
by cursor, because an `OFFSET` shifts under a comment posted between clicks.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… block ## The menu Hovering "Torrents" in the header opens the catalogue: four shortcuts (all, today, freeleech, favourites), this browser's recent searches, then every category family with its sub-categories, each a direct link to the filtered catalogue. Hover has an intention — 90 ms to open, 220 ms of grace to leave, an invisible bridge to the panel — and it is never the only way in: the chevron opens on click and on the keyboard, Escape returns focus to it, and on a touch screen hover is ignored. The categories are fetched once, on the first open, and only for a signed-in member. ## The panel that linked the cards `.card` paints `--bg-surface`, exactly the colour of a work card, so the 8 px between two cards read as a divider inside one panel rather than as space between two objects — and its `:hover` brightened the whole thing. The grouped view no longer wraps anything; the flat view keeps its panel, which holds a real table. ## Review fixes - Picking a recent search dropped its text: the route watcher saw the still focused input and skipped `q`. The state is now applied at the source. - "Load more" had no generation guard, so a slow page landed under a newer query; the open work cards reloaded on every keystroke and could keep a stale response. Both now carry a guard, and the cards get the query the page actually fetched with. - A repeated parameter (`?q=a&q=b`) arrived as an array and threw during server-side rendering; every read goes through one coercion. - A view change coming from the URL no longer resets the page: back from a menu link kept sending the reader to page one. - A rail toggle was not reversible through the URL — unchecking a synonym did not survive a reload, because the typed word re-expanded its whole family. The URL now carries the exact list. - An unknown, withdrawn or refused hash rendered a blank page with status 200 instead of the site's 404. Comment authors linked to a route that does not exist. The mobile dock did not restart the obligation polling after a download. The "Tracks" entry appeared then vanished on mount. A duration of four days and 23 h 50 read "4 d 24 hr". DivX and XviD were folded into AVC, so a hand-set tag could be hidden as redundant. A failed Hit & Run save was silent. - The poster relay mapped a timeout during the body read to a 500 with a stack trace; the API proxy's header stripping was a no-op under h3, so a browser's own `x-real-ip`, `cf-connecting-ip`, `true-client-ip` and `forwarded` reached the API. - Alert pills replayed only half of what an alert now stores, so a search saved as "frieren S02" reopened the whole series. - Rail alerts, facet checkbox ids, the suggestions panel id and the menu's error copy: one identifier per control, stable between server and client. ## Comments by pages Twenty at a time with a cursor, and the table of contents announces the real total rather than what the page happens to hold. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ness waited on the wrong signal Rendered through the same dialect the query builder uses, so the assertions read the statement Postgres receives: the year pattern and its parameter, the three `since` windows and the refusal of anything else (including an inherited property), favourites and taken scoped to the viewer, a group key that stays indexable, and the tag-group bounds. Plus the saved-search link's newer criteria, the rail toggle's round trip through the URL, the duration carry at 24 h, and the codecs that must not be folded together. The e2e tracker waited on `service_started` while the comment above it claimed it waited for the migrations the API applies at boot; the API image has a health check, so it now waits for that. And `sed | head -1` under `pipefail` could abort the whole run on a SIGPIPE — `grep -m1` stops on its own. `doc/guide/security.md` claimed the server-side rendering exemption applies in production. Behind the shipped Caddy those calls inherit the visitor's `X-Forwarded-For`, so they are counted on the visitor's address and the exemption does not apply; the paragraph now says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…four of its own fixes were wrong A security and a correctness review of `7d60548`/`a1ff305` — the commits that carried the previous review's fixes — plus semgrep, gitleaks and runtime probes. Semgrep found nothing new (17 already-arbitrated warnings), gitleaks nothing. The reviews found this. ## What the last commit message asserted and the code did not do `rateLimit(event, RATE_LIMITS.public)` on the flat listing and on the facets: the edit was lost, and `git log -S` confirms the line never existed in either file. One member could therefore issue the fifty-odd statements of an empty-state facets request as fast as they liked, bounded only by the coarse abuse counter, against a pool of ten connections. Measured after the fix: ten of a hundred and ten requests answer 429. ## Four fixes that were themselves wrong - The work-title search took `LIMIT 500` with **no `ORDER BY`**: an arbitrary five hundred, re-picked per call, so the count in the band and the rows in the grid could be built from different sets — the very failure it was meant to close. Ordered now, and the cap is named. - It also invented both TMDb prefixes for every title row, so a series title matched the film that shares its number. Only the row's own form is used, and the prefixes only when the row carries none. - The year pattern excluded a lowercase `x` only, so `1920X1080` still filled a bogus bucket, and its prefix class silently **lost** a year glued after a letter x (`Matrix2019`). Four lookarounds instead, verified against Postgres on eleven names. - The metadata warmer's retry set was marked with `SADD` + `EXPIRE`, and `EXPIRE` moves the whole key: with one mark every twenty seconds the set never expired and a failed work was never retried. The done-set had the same shape. One marker per work now, each with its own lifetime. ## And four more - Posting a comment after loading older pages dropped one comment from the thread and left the button pointing at a cursor that returned nothing. - The comment cursor was a bare timestamp: two comments written in the same transaction (an import shares one `now()`) hid the boundary row forever. It now carries the id as well, compared as a row. - `POST /api/metadata/tint` answered 404 for a work the cache does not hold, which answers "does this instance carry work X" to anyone who asks. It answers the same either way and writes only what it knows. - The category-merge ancestry walk stopped after ten hops, and nothing caps the tree's depth; a visited set walks the whole chain instead. ## Smaller The saved-search link emitted one synonym per family, which the bar re-expanded to the whole family — it now carries the exact list. The flat table showed an anonymous uploader as a deleted account. An API 500 on the detail page was reported to the member as "release not found". A veiled adult page fired the obligation fetch on every visit, for a 404 it could not use. The saved-search fanout compared TMDb ids without their prefix, so `movie/603` matched `tv/603`. And the search-miss budget lost its expiry if the process died between `INCR` and `EXPIRE`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two pages rebuilt around the questions their readers actually arrive with — the
torrent detail page and the catalogue — plus the review passes that followed.
28 commits, and the findings that mattered came from probing the running stack:
semgrep and gitleaks were clean or already arbitrated, while calling the changed
routes without a session, then with another member's, is what turned up the
leaks below.
The torrent detail page
A hero band that says what the release is above the fold, two columns with the
decision pinned, swarm health, and what the download costs — the page used to
answer "what is this?" 700px down, repeated every track twice, and offered no
way to say "wrong film". The work's own colour tints the band, and the only
motions left are the ones that mean something.
Along the way: the Hit & Run thresholds were read by the tracker and the API but
exposed by no interface (the 24h on every torrent page was a code default); a
timed-out metadata lookup was cached as "no such record" for an hour, so a
network hiccup cost thirty posters; the poster relay accepted any client with no
Sec-Fetchheaders, any port and any image type; and a held-downDkeydownloaded on repeat.
The catalogue
A search bar that understands what you type (
s02e04,@member, a TMDb link, aresolution), a rail of counted facets, works before releases, and an empty state
that proposes rather than apologises. A work card opens on its own header with a
chevron, and inside, seasons and episodes fold down to the releases — the scope
badge used to be the only way in. Every release carries its age, with the exact
date on hover.
Behind it: relevance ranking for text searches, work titles behind a
did-you-mean, saved searches that match what the page filters, a record of
searches that found nothing, category merge, per-instance catalogue defaults,
and a hover menu on Torrents with shortcuts, recent searches and the
category tree.
Fixes that were not cosmetic
whole site: 25 page views in 10 seconds blacklisted the instance for
everyone. Server-side rendering is now recognised by a discriminant an
Internet client cannot forge, and only the coarse counter is relaxed.
anonymous_uploadswas honoured by the detail page and the feeds but bynone of the new catalogue paths: the listing returned the name,
?uploader=<name>listed an anonymous member's whole history, and a savedsearch on that name notified every one of their uploads.
single token for the text-search parser, and the fuzzy fallback hid the hole
whenever the exact search returned zero.
backdrop-filterwritten before its-webkittwin was dropped by theminifier: five glass surfaces across the site had no blur in Chrome, with a
correct source and a stripped stylesheet.
tagGroupsfanned out to one query per group on a pool often,
search_missesgrew a row per distinct empty search, and both thelisting and the facets ran without a rate limit.
routes answered for a torrent the member cannot see.
category's torrents visible to members who opted out.
Verified
nuxt typecheckand the APItscclean; 558 API and 948 web unit tests; schemaparity between
schema.tsand the 77 migrations; the e2e suite (13 scenarios,543 checks) on images built from this branch; and a browser pass at 1280 and
390, dark and light, with no horizontal scroll, no truncated text, no dangling
aria-controlsand no failed asset.🤖 Generated with Claude Code