From 21ab158e349506484a3535566e9ec4371e70d5f1 Mon Sep 17 00:00:00 2001 From: dim145 Date: Fri, 4 Sep 2026 22:03:53 +0200 Subject: [PATCH 01/28] test(e2e): the stack had no tracker, so every swarm rendered empty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- tests/e2e/.gitignore | 3 +- tests/e2e/README.md | 46 +- tests/e2e/demoSwarm.mjs | 164 +++++ tests/e2e/demoTorrents.mjs | 1237 ++++++++++++++++++++++++++++++++++ tests/e2e/docker-compose.yml | 67 +- tests/e2e/import-dump.sh | 139 ++++ tests/e2e/import-dump.sql | 170 +++++ tests/e2e/lib.mjs | 93 +++ tests/e2e/run.sh | 18 +- 9 files changed, 1924 insertions(+), 13 deletions(-) create mode 100644 tests/e2e/demoSwarm.mjs create mode 100644 tests/e2e/demoTorrents.mjs create mode 100755 tests/e2e/import-dump.sh create mode 100644 tests/e2e/import-dump.sql diff --git a/tests/e2e/.gitignore b/tests/e2e/.gitignore index 8a411b7b..f6569365 100644 --- a/tests/e2e/.gitignore +++ b/tests/e2e/.gitignore @@ -1,2 +1,3 @@ -session.json +session*.json crypto.mjs +tmdb.env diff --git a/tests/e2e/README.md b/tests/e2e/README.md index c51be2ae..680fb5f1 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -27,16 +27,45 @@ Useful flags: - `--keep` — leave the stack running afterwards, for poking at (web) and (api) -Two scripts fill a kept stack with something worth looking at. Neither asserts -anything, so neither is a scenario, and `run.sh` does not call them: +Four scripts fill a kept stack with something worth looking at. None asserts +anything, so none is a scenario, and `run.sh` does not call them: ```bash node tests/e2e/demo.mjs # roles, both messaging surfaces, a room node tests/e2e/forumTickets.mjs # three forum categories, six topics, five tickets +node tests/e2e/demoTorrents.mjs # ten releases, a cross-seed, a supersession, a swarm +node tests/e2e/demoSwarm.mjs # a swarm for every torrent already in the catalogue ``` -Both go through HTTP like the seeder, and both are re-runnable: a topic or a -ticket whose subject is already there is skipped rather than duplicated. +All four go through HTTP like the seeder, and all four are re-runnable: a topic, +a ticket or a torrent that is already there is skipped rather than duplicated, +and `demoSwarm` derives each peer_id from the infohash so a second run +re-announces the same peers instead of doubling the swarm. + +### A real catalogue + +Hand-made fixtures are clean by construction, which is exactly what a layout is +not tested by. To fill the stack from an operator's own `pg_dump` instead: + +```bash +bash tests/e2e/import-dump.sh /path/to/dump.sql +node tests/e2e/demoSwarm.mjs +``` + +It takes `torrents` and what hangs off it — categories, tags, links, comments, +the swarm snapshot, favourites, moderation threads, seed obligations. It does +**not** take 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 instead. The dump may come from an older schema — the +columns are read from its own `COPY` headers and checked against the running +database, and the import refuses to start if one has since disappeared. + +### TMDb + +`tests/e2e/tmdb.env` is read by the `api` service if it exists, and `run.sh` +writes it from the repository's own `.env`. Without a key the stack works +perfectly well — but every detail page then renders its "no poster" substitute, +which for a long time was the only state anybody had ever looked at. - `--no-build` — reuse the images from the last run - `--only themes` — run one scenario file @@ -44,9 +73,12 @@ ticket whose subject is already there is skipped rather than duplicated. Deliberately lean: no PgBouncer (the API talks to Postgres directly, which is what `MIGRATIONS_DATABASE_URL` does in production anyway), no Caddy (the web -container emits its own CSP, so the policy under test is the real one), no -tracker (nothing here announces). Ports are shifted into the 5xxxx range so it -cannot collide with a development stack. +container emits its own CSP, so the policy under test is the real one). Ports +are shifted into the 5xxxx range so it cannot collide with a development stack. + +The tracker **is** here, on port 54200, even though no scenario announces. It +earns its build because without it every swarm renders empty — and a page that +only ever shows its zero-seeder state has never really been looked at. Every secret in `docker-compose.yml` is obviously fake and hardcoded. This stack is not reachable from anywhere and is destroyed at the end of a run. diff --git a/tests/e2e/demoSwarm.mjs b/tests/e2e/demoSwarm.mjs new file mode 100644 index 00000000..c17ea463 --- /dev/null +++ b/tests/e2e/demoSwarm.mjs @@ -0,0 +1,164 @@ +/** + * Donne un essaim à tout le catalogue d'une pile gardée. + * + * `demoTorrents.mjs` annonce pour les dix releases qu'il dépose lui-même. Dès + * qu'un catalogue arrive par un autre chemin — une restauration de sauvegarde, + * un import — ces torrents-là n'ont aucun pair, et chaque page de détail rend + * son état « zéro seeder » : la carte de décision propose une remise en + * partage, le tableau des pairs affiche son vide, et le dock annonce 0/0. Ce + * n'est pas faux, mais c'est le seul état qu'on regarde alors, sur toutes les + * pages à la fois. + * + * node tests/e2e/demoSwarm.mjs + * + * Ce que ce script ne fait PAS : reprendre les compteurs d'une sauvegarde. La + * table `torrent_stats` est un instantané que le collecteur réécrit ; les + * chiffres qu'une page de détail affiche viennent de Redis, donc de vraies + * annonces. La forme de chaque essaim est donc DÉRIVÉE de l'infohash — + * déterministe, donc une relance réannonce les mêmes pairs au lieu de doubler + * l'essaim, et deux torrents n'ont pas le même nombre de seeders. + * + * Comme `demo.mjs`, ce script n'asserte rien : ce n'est pas un scénario. + */ +import { API, announce, passkeyOf, resetRateLimits, sessions, sleep } from './lib.mjs'; + +/* Les comptes du harnais, plus ceux que `demoTorrents.mjs` a inscrits. Un pair + est rattaché au membre dont la passkey a servi à l'annonce : sans plusieurs + comptes, un essaim plafonne à une ligne. */ +const EXTRA_CACHE = new URL('./session-torrents.json', import.meta.url); + +/** Combien de pairs, et lesquels, pour un torrent donné. */ +function shapeFor(infoHash, memberCount) { + // Deux octets de l'infohash suffisent : on veut une variété stable, pas de + // l'aléatoire. Au moins un seeder — un catalogue où rien n'est disponible + // n'apprend rien de plus qu'un catalogue vide. + const a = parseInt(infoHash.slice(0, 2), 16); + const b = parseInt(infoHash.slice(2, 4), 16); + const seeders = 1 + (a % Math.max(1, Math.min(memberCount, 5))); + const leechers = b % 3; + return { seeders, leechers: Math.min(leechers, Math.max(0, memberCount - seeders)) }; +} + +async function main() { + const S = sessions(['founder']); + + let extra = {}; + try { + const { readFileSync } = await import('node:fs'); + extra = JSON.parse(readFileSync(EXTRA_CACHE, 'utf8')); + } catch { + // Absent : la pile n'a que les trois comptes du seed. Les essaims seront + // plus petits, rien de plus. + } + + const cookies = Object.fromEntries([ + ...Object.entries(S).map(([k, v]) => [k, v.cookie]), + ...Object.entries(extra).map(([k, v]) => [k, v.cookie]), + ]); + + await resetRateLimits().catch(() => {}); + + console.log('\n1. passkeys'); + const members = []; + for (const [who, cookie] of Object.entries(cookies)) { + const pk = await passkeyOf(cookie); + if (pk) members.push({ who, passkey: pk }); + } + console.log(` ${members.length} membres : ${members.map((m) => m.who).join(' ')}`); + if (!members.length) { + console.error('aucune passkey — la pile est-elle bien celle du seed ?'); + process.exit(1); + } + + console.log('\n2. catalogue'); + // Le catalogue est lu AVEC LES YEUX DE `founder`, et c'est une contrainte, + // pas un détail : `/api/torrents` décide de montrer l'adulte d'après la + // colonne `show_adult_content` de l'APPELANT, jamais d'après un paramètre de + // requête. Un `?showAdultContent=true` dans l'URL ne fait rien du tout — zod + // le retire, le schéma ne le déclare pas. Les lignes en attente, elles, + // sortent parce que l'appelant est administrateur. + // + // Donc si le compte n'a pas activé l'adulte, ces torrents-là n'auront pas + // d'essaim, et le compte rendu final le dit plutôt que de le taire. + const items = []; + for (let page = 1; page <= 20; page += 1) { + const res = await fetch( + `${API}/api/torrents?limit=100&page=${page}`, + { headers: { cookie: S.founder.cookie } }, + ); + if (!res.ok) break; + const body = await res.json(); + const batch = body?.data ?? []; + items.push(...batch); + if (batch.length < 100 || page >= (body?.pagination?.pages ?? 1)) break; + } + const total = items.length; + console.log(` ${total} torrents visibles par founder`); + + console.log('\n3. annonces'); + let announced = 0; + let refused = 0; + let skipped = 0; + for (const t of items) { + if (!t?.infoHash || typeof t.size !== 'number') { + skipped += 1; + continue; + } + const { seeders, leechers } = shapeFor(t.infoHash, members.length); + const picked = []; + for (let i = 0; i < seeders + leechers; i += 1) { + // Décalage par l'infohash : ce n'est pas toujours `founder` qui seede. + const offset = parseInt(t.infoHash.slice(4, 6), 16); + picked.push(members[(offset + i) % members.length]); + } + for (let i = 0; i < picked.length; i += 1) { + const m = picked[i]; + const isSeeder = i < seeders; + const seed = `${t.infoHash}:${m.who}`; + const port = 6881 + (i % 32); + const first = await announce(m.passkey, t.infoHash, seed, { + left: isSeeder ? t.size : Math.round(t.size * (0.15 + 0.7 * ((i + 1) / 8))), + downloaded: 0, + event: 'started', + port, + }); + if (first.failure) { + refused += 1; + if (refused <= 3) console.log(` refus ${m.who} -> ${t.infoHash.slice(0, 10)} : ${first.failure}`); + continue; + } + if (isSeeder) { + // La seconde annonce ferme le téléchargement : c'est elle qui compte un + // snatch et qui fait passer le pair de leecher à seeder. + await announce(m.passkey, t.infoHash, seed, { + left: 0, + uploaded: Math.round(t.size * (0.3 + (i % 4))), + downloaded: t.size, + event: 'completed', + port, + }); + } + announced += 1; + } + await sleep(40); + } + console.log(` ${announced} pairs posés, ${refused} refusés, ${skipped} torrents ignorés`); + if (refused) { + console.log( + ' Un refus « Torrent not found or inactive » est le tracker qui fait son travail :', + ); + console.log( + ' une ligne en attente de modération n\'est pas annonçable, et ne doit pas l\'être.', + ); + } + + // Le versement des crédits d'octets est groupé (5 s dans cette pile). + console.log('\n attente du versement des crédits (7 s)…'); + await sleep(7000); + console.log('\nfini.'); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/tests/e2e/demoTorrents.mjs b/tests/e2e/demoTorrents.mjs new file mode 100644 index 00000000..634386d2 --- /dev/null +++ b/tests/e2e/demoTorrents.mjs @@ -0,0 +1,1237 @@ +/** + * Remplit une pile e2e gardée de torrents qui valent le coup d'œil. + * + * `demo.mjs` fabrique des rôles, des conversations et un salon — tout sauf des + * torrents. `torrentVisibility.mjs` en dépose bien quatre, mais ce sont des + * fixtures d'assertion : des noms comme `e2e-vis-public-accepted`, aucune + * description, aucun NFO, aucun pair. Une page de détail nourrie de ça ne + * montre que ses états vides, et c'est exactement ce qu'on ne peut pas juger. + * + * Ce script construit donc ce que la page est censée rendre : + * + * - des noms de release réels, parce que les pastilles de qualité sont + * ANALYSÉES depuis le nom (`releaseChips.ts`) et non stockées ; + * - un bloc MediaInfo dans le NFO, parce que les tables de pistes audio et + * de sous-titres sont analysées depuis ce texte — le schéma n'a aucune + * colonne par piste (voir l'en-tête de `TrackTables.vue`) ; + * - une description en BBCode, pour que `DescriptionRender` ait de quoi + * faire et que le pavé de l'uploadeur pèse ce qu'il pèse en vrai ; + * - un cross-seed VRAI : même liste de fichiers, `piece length` différente, + * donc même `content_signature` et un info_hash distinct ; + * - un groupe : deux éditions du même film sous le même `tmdbId`, ce qui est + * la seule façon de voir la table des versions se remplir ; + * - une supersession, un freeleech avec échéance, un épinglage ; + * - des commentaires, un favori, une obligation de seed ; + * - un essaim, en annonçant sur le VRAI tracker avec la VRAIE passkey de + * chaque compte. + * + * node tests/e2e/demoTorrents.mjs + * + * Rejouable : un torrent déjà déposé est repris tel quel plutôt que dupliqué, + * et les comptes déjà inscrits se connectent. + * + * Comme `demo.mjs`, ce script n'asserte rien — ce n'est pas un scénario, et + * `run.sh` ne l'appelle pas. + */ +import { createHash, randomBytes } from 'node:crypto'; +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { generateCredentials, generateLoginProof, solvePoW } from './crypto.mjs'; +import { announce, resetRateLimits } from './lib.mjs'; + +const API = process.env.E2E_API ?? 'http://localhost:54000'; +const WEB = process.env.E2E_WEB ?? 'http://localhost:53000'; +const HERE = new URL('.', import.meta.url); + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const S = JSON.parse(readFileSync(new URL('session.json', HERE), 'utf8')); + +/* ── HTTP ─────────────────────────────────────────────────────────────────── */ + +const jars = new Map(); +const jarFor = (name) => { + if (!jars.has(name)) jars.set(name, new Map()); + return jars.get(name); +}; +const cookieHeader = (jar) => [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; '); +function absorb(jar, res) { + for (const raw of res.headers.getSetCookie?.() ?? []) { + const [pair] = raw.split(';'); + const i = pair.indexOf('='); + if (i > 0) jar.set(pair.slice(0, i).trim(), pair.slice(i + 1).trim()); + } +} + +/** Le cookie d'un compte : celui du seed pour les trois de base, le jar sinon. */ +function cookieOf(who) { + if (S[who]?.cookie) return S[who].cookie; + const jar = jarFor(who); + return jar.size ? cookieHeader(jar) : ''; +} + +async function call(path, { method = 'GET', body, who, jar, raw = false } = {}) { + const headers = {}; + if (body !== undefined) headers['content-type'] = 'application/json'; + const cookie = jar ? cookieHeader(jar) : who ? cookieOf(who) : ''; + if (cookie) headers.cookie = cookie; + const res = await fetch(API + path, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + redirect: 'manual', + }); + if (jar) absorb(jar, res); + if (raw) return { status: res.status, buffer: Buffer.from(await res.arrayBuffer()) }; + const text = await res.text(); + let parsed = null; + try { + parsed = JSON.parse(text); + } catch { + parsed = text; + } + return { status: res.status, body: parsed }; +} + +const ok = (s) => s >= 200 && s < 300; +let steps = 0; +let problems = 0; +const step = (label, good, detail = '') => { + steps += 1; + if (!good) problems += 1; + console.log(` ${good ? 'ok ' : 'FAIL'} ${label}${detail ? ` ${detail}` : ''}`); +}; + +/* ── bencode ──────────────────────────────────────────────────────────────── */ +// +// Repris de `torrentVisibility.mjs` : le harnais est délibérément sans +// dépendance, et une deuxième implémentation d'un encodeur de dix lignes coûte +// moins qu'un paquet à auditer. + +function bencode(value) { + if (Buffer.isBuffer(value)) return Buffer.concat([Buffer.from(`${value.length}:`), value]); + if (typeof value === 'string') return bencode(Buffer.from(value, 'utf8')); + if (typeof value === 'number') return Buffer.from(`i${Math.trunc(value)}e`); + if (Array.isArray(value)) { + return Buffer.concat([Buffer.from('l'), ...value.map(bencode), Buffer.from('e')]); + } + if (value && typeof value === 'object') { + // Clés en ordre lexicographique : sans ça l'info_hash calculé n'est pas + // celui qu'un client calculerait. + const keys = Object.keys(value).sort(); + return Buffer.concat([ + Buffer.from('d'), + ...keys.flatMap((k) => [bencode(k), bencode(value[k])]), + Buffer.from('e'), + ]); + } + throw new Error(`cannot bencode ${typeof value}`); +} + +/** + * Un .torrent multi-fichiers. + * + * Les hachages de pièces sont dérivés du nom, pas d'un contenu : rien sur le + * chemin d'upload ne les vérifie, il lui faut vingt octets par pièce. Ce qui + * compte ici c'est que la liste de fichiers soit réelle, parce que c'est elle + * que `computeContentSignature` canonise — et donc elle qui décide de ce qui + * est un cross-seed de quoi. + * + * `pieceLength` est un paramètre pour cette raison précise : le passer à une + * autre puissance de deux change l'info_hash SANS changer la signature de + * contenu. C'est la définition d'un cross-seed, et c'est comment on en + * fabrique un. + * + * Le nombre de pièces est plafonné : 82 Gio en pièces de 4 Mio, ce sont 21 000 + * hachages, soit 420 Kio de `pieces` — au-dessus de la limite de taille du + * .torrent envoyé, et sans utilité ici puisque personne ne vérifie un octet. + */ +function torrentFile({ name, files, pieceLength = 4 * 1024 * 1024 }) { + const total = files.reduce((n, f) => n + f.length, 0); + const count = Math.min(512, Math.max(1, Math.ceil(total / pieceLength))); + const pieces = Buffer.concat( + Array.from({ length: count }, (_, i) => + createHash('sha1').update(`${name}:${pieceLength}:${i}`).digest(), + ), + ); + return bencode({ + announce: 'http://tracker.e2e.test/announce', + 'created by': 'trackarr-e2e/demoTorrents', + 'creation date': Math.floor(Date.now() / 1000), + info: { + files: files.map((f) => ({ length: f.length, path: f.path.split('/') })), + name, + 'piece length': pieceLength, + pieces, + private: 1, + }, + }); +} + +/** L'upload est multipart ; `call` parle JSON, donc celui-ci est à la main. */ +async function upload(who, fields, torrentBytes) { + const boundary = `----trackarr${randomBytes(8).toString('hex')}`; + const parts = []; + const field = (headers, body) => { + parts.push(Buffer.from(`--${boundary}\r\n${headers}\r\n\r\n`), body, Buffer.from('\r\n')); + }; + field( + 'Content-Disposition: form-data; name="torrent"; filename="release.torrent"\r\n' + + 'Content-Type: application/x-bittorrent', + torrentBytes, + ); + for (const [key, value] of Object.entries(fields)) { + if (value === undefined || value === null || value === '') continue; + field(`Content-Disposition: form-data; name="${key}"`, Buffer.from(String(value), 'utf8')); + } + parts.push(Buffer.from(`--${boundary}--\r\n`)); + + const res = await fetch(`${API}/api/torrents`, { + method: 'POST', + headers: { + cookie: cookieOf(who), + 'content-type': `multipart/form-data; boundary=${boundary}`, + }, + body: Buffer.concat(parts), + }); + const text = await res.text(); + let body = null; + try { + body = JSON.parse(text); + } catch { + body = text; + } + return { status: res.status, body }; +} + +/* ── MediaInfo ────────────────────────────────────────────────────────────── */ +// +// Le format est celui de MediaInfo, pas un résumé maison : c'est ce que +// `parseMediaInfoText()` sait relire (`^(General|Video|Audio|Text)\s*#?\d*$` +// puis des lignes `clé : valeur`). Un libellé inventé est ignoré en silence. + +const mi = (blocks) => + blocks + .map(([title, rows]) => + [title, ...rows.filter(([, v]) => v).map(([k, v]) => `${k.padEnd(40)}: ${v}`)].join('\n'), + ) + .join('\n\n'); + +/* ── Les fixtures ─────────────────────────────────────────────────────────── */ + +const DUNE_ROOT = + 'Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR'; + +const duneFiles = [ + { path: `${DUNE_ROOT}/${DUNE_ROOT}.mkv`, length: 88_412_913_664 }, + { path: `${DUNE_ROOT}/FraMeSToR.nfo`, length: 12_284 }, + { path: `${DUNE_ROOT}/Sample/sample.mkv`, length: 214_958_080 }, +]; + +const duneNfo = mi([ + ['General', [ + ['Complete name', `${DUNE_ROOT}.mkv`], + ['Format', 'Matroska'], + ['File size', '82.3 GiB'], + ['Duration', '2 h 46 min'], + ['Overall bit rate', '70.9 Mb/s'], + ]], + ['Video', [ + ['Format', 'HEVC'], + ['Format profile', 'Main 10@L5.1@High'], + ['Width', '3 840 pixels'], + ['Height', '2 160 pixels'], + ['Frame rate', '23.976 FPS'], + ['Bit depth', '10 bits'], + ['Bit rate', '61.5 Mb/s'], + ['Writing library', 'x265'], + ['Language', 'English'], + ['Default', 'Yes'], + ['Forced', 'No'], + ]], + ['Audio #1', [ + ['Format', 'MLP FBA 16-ch'], + ['Commercial name', 'Dolby TrueHD with Dolby Atmos'], + ['Channel(s)', '8 channels'], + ['Bit rate', '4 448 kb/s'], + ['Language', 'English'], + ['Title', 'TrueHD 7.1 Atmos'], + ['Default', 'Yes'], + ['Forced', 'No'], + ]], + ['Audio #2', [ + ['Format', 'DTS'], + ['Commercial name', 'DTS-HD Master Audio'], + ['Channel(s)', '6 channels'], + ['Bit rate', '3 018 kb/s'], + ['Language', 'French'], + ['Title', 'VFF DTS-HD MA 5.1'], + ['Default', 'No'], + ['Forced', 'No'], + ]], + ['Audio #3', [ + ['Format', 'E-AC-3'], + ['Commercial name', 'Dolby Digital Plus'], + ['Channel(s)', '6 channels'], + ['Bit rate', '640 kb/s'], + ['Language', 'Spanish'], + ['Title', 'Castellano DD+ 5.1'], + ['Default', 'No'], + ['Forced', 'No'], + ]], + ['Text #1', [ + ['Format', 'UTF-8'], + ['Language', 'French'], + ['Title', 'VFF complet'], + ['Default', 'Yes'], + ['Forced', 'No'], + ]], + ['Text #2', [ + ['Format', 'UTF-8'], + ['Language', 'French'], + ['Title', 'VFF forces'], + ['Default', 'No'], + ['Forced', 'Yes'], + ]], + ['Text #3', [ + ['Format', 'PGS'], + ['Language', 'English'], + ['Title', 'English SDH'], + ['Default', 'No'], + ['Forced', 'No'], + ['Hearing impaired', 'Yes'], + ]], + ['Text #4', [ + ['Format', 'PGS'], + ['Language', 'Spanish'], + ['Default', 'No'], + ['Forced', 'No'], + ]], + ['Text #5', [ + ['Format', 'PGS'], + ['Language', 'Japanese'], + ['Default', 'No'], + ['Forced', 'No'], + ]], +]); + +const duneDescription = `[center][b]DUNE : DEUXIÈME PARTIE[/b] — 2024 — Denis Villeneuve[/center] + +[quote]Paul Atreides s'unit à Chani et aux Fremen pour mener la révolte contre ceux qui ont détruit sa famille.[/quote] + +[b]Source[/b] : UHD BluRay FR (Warner) — remux intégral, aucun réencodage. +[b]Vidéo[/b] : HEVC 10 bits, Dolby Vision Profile 7 avec repli HDR10, grain préservé. +[b]Audio[/b] : la piste anglaise est la TrueHD 7.1 Atmos d'origine. La VFF est la DTS-HD MA 5.1 du disque français, non recompressée. +[b]Sous-titres[/b] : VFF complets et forcés, anglais SDH, espagnol, japonais. + +[b]Notes[/b] +[list] +[*]Les chapitres du disque sont conservés. +[*]Le dossier [i]Sample[/i] contient 90 s tirées de l'arrivée sur Arrakeen. +[*]Merci de rester en seed : c'est 82 Gio, personne ne les récupère en une heure. +[/list]`; + +const BEAR_ROOT = 'The.Bear.S03.1080p.WEB-DL.DDP5.1.H.264-NTb'; +const bearFiles = Array.from({ length: 10 }, (_, i) => ({ + path: `${BEAR_ROOT}/The.Bear.S03E${String(i + 1).padStart(2, '0')}.1080p.WEB-DL.DDP5.1.H.264-NTb.mkv`, + length: 1_932_735_283 + i * 41_231_686, +})); + +const bearNfo = mi([ + ['General', [ + ['Complete name', 'The.Bear.S03E01.1080p.WEB-DL.DDP5.1.H.264-NTb.mkv'], + ['Format', 'Matroska'], + ['File size', '1.80 GiB'], + ['Duration', '32 min 4 s'], + ['Overall bit rate', '8 043 kb/s'], + ]], + ['Video', [ + ['Format', 'AVC'], + ['Format profile', 'High@L4'], + ['Width', '1 920 pixels'], + ['Height', '1 080 pixels'], + ['Frame rate', '23.976 FPS'], + ['Bit depth', '8 bits'], + ['Bit rate', '7 464 kb/s'], + ['Language', 'English'], + ]], + ['Audio #1', [ + ['Format', 'E-AC-3'], + ['Commercial name', 'Dolby Digital Plus'], + ['Channel(s)', '6 channels'], + ['Bit rate', '640 kb/s'], + ['Language', 'English'], + ['Default', 'Yes'], + ]], + ['Audio #2', [ + ['Format', 'E-AC-3'], + ['Channel(s)', '6 channels'], + ['Bit rate', '640 kb/s'], + ['Language', 'French'], + ['Title', 'VF Québec'], + ['Default', 'No'], + ]], + ['Text #1', [ + ['Format', 'UTF-8'], + ['Language', 'French'], + ['Default', 'Yes'], + ]], + ['Text #2', [ + ['Format', 'UTF-8'], + ['Language', 'English'], + ['Title', 'English SDH'], + ['Hearing impaired', 'Yes'], + ]], +]); + +const FLAC_ROOT = 'Radiohead - In Rainbows (2007) [FLAC 24-96]'; +const flacFiles = [ + ...Array.from({ length: 10 }, (_, i) => ({ + path: `${FLAC_ROOT}/${String(i + 1).padStart(2, '0')} - Track ${i + 1}.flac`, + length: 68_157_440 + i * 5_242_880, + })), + { path: `${FLAC_ROOT}/cover.jpg`, length: 2_411_724 }, + { path: `${FLAC_ROOT}/In Rainbows.log`, length: 8_432 }, +]; + +const flacNfo = mi([ + ['General', [ + ['Complete name', '01 - 15 Step.flac'], + ['Format', 'FLAC'], + ['File size', '65.0 MiB'], + ['Duration', '3 min 57 s'], + ['Overall bit rate', '2 298 kb/s'], + ]], + ['Audio', [ + ['Format', 'FLAC'], + ['Channel(s)', '2 channels'], + ['Bit rate', '2 298 kb/s'], + ['Language', 'English'], + ['Title', 'Vinyl rip 24 bits / 96 kHz'], + ['Default', 'Yes'], + ]], +]); + +const GAME_ROOT = "Baldur's Gate 3 v4.1.1.4667176 (GOG)"; + +/** + * Ce que ce script dépose. + * + * `tmdbId` sert au regroupement de la page (`tmdb:693134` pour les quatre + * éditions du film) — c'est la seule façon de voir la table des versions + * autrement que vide. + */ +const RELEASES = [ + { + key: 'dune4k', + who: 'founder', + category: 'films', + name: DUNE_ROOT, + files: duneFiles, + nfo: duneNfo, + description: duneDescription, + tmdbId: '693134', + imdbId: 'tt15239678', + tags: 'remux, dolby-vision, atmos, vff, science-fiction', + }, + { + // Le cross-seed : MÊME liste de fichiers, `piece length` de 8 Mio au lieu + // de 4 — donc même `content_signature`, info_hash différent. Le nom + // affiché diffère (c'est le champ `name` du formulaire, pas celui du + // .torrent), comme quand la même release circule sous deux conventions. + key: 'dune4kXseed', + who: 'donator', + category: 'films', + name: `${DUNE_ROOT} [pieces 8MiB]`, + torrentName: DUNE_ROOT, + files: duneFiles, + pieceLength: 8 * 1024 * 1024, + nfo: duneNfo, + description: `Cross-seed du remux FraMeSToR — contenu identique, découpage en pièces de 8 Mio. + +Utile si votre client refuse de re-vérifier 82 Gio pour la version 4 Mio.`, + tmdbId: '693134', + tags: 'remux, cross-seed, dolby-vision', + }, + { + key: 'dune1080', + who: 'donator', + category: 'films', + name: 'Dune.Part.Two.2024.1080p.BluRay.x264.DTS-HD.MA.5.1-SbR', + files: [ + { + path: 'Dune.Part.Two.2024.1080p.BluRay.x264.DTS-HD.MA.5.1-SbR/Dune.Part.Two.2024.1080p.BluRay.x264.DTS-HD.MA.5.1-SbR.mkv', + length: 18_253_611_008, + }, + { path: 'Dune.Part.Two.2024.1080p.BluRay.x264.DTS-HD.MA.5.1-SbR/sbr.nfo', length: 4_096 }, + ], + description: `L'édition 1080p, pour qui n'a pas 82 Gio à donner au film. + +[b]Remplacée[/b] par le PROPER : la VFF de cette version est désynchronisée d'environ 400 ms à partir du chapitre 12.`, + tmdbId: '693134', + tags: 'x264, vff, science-fiction', + }, + { + key: 'dune1080proper', + who: 'founder', + category: 'films', + name: 'Dune.Part.Two.2024.PROPER.1080p.BluRay.x264.DTS-HD.MA.5.1-SbR', + files: [ + { + path: 'Dune.Part.Two.2024.PROPER.1080p.BluRay.x264.DTS-HD.MA.5.1-SbR/Dune.Part.Two.2024.PROPER.1080p.BluRay.x264.DTS-HD.MA.5.1-SbR.mkv', + length: 18_254_659_584, + }, + { + path: 'Dune.Part.Two.2024.PROPER.1080p.BluRay.x264.DTS-HD.MA.5.1-SbR/sbr.nfo', + length: 4_112, + }, + ], + description: + 'PROPER : la piste VFF est resynchronisée. Le reste est identique bit pour bit.', + tmdbId: '693134', + tags: 'x264, proper, vff', + }, + { + key: 'bear', + who: 'founder', + category: 'series', + name: BEAR_ROOT, + files: bearFiles, + nfo: bearNfo, + description: `[b]The Bear[/b] — saison 3 complète, 10 épisodes, WEB-DL Disney+ FR. + +[list] +[*]Piste anglaise DDP 5.1 d'origine. +[*]VF québécoise incluse, même débit. +[*]Sous-titres français et anglais SDH, extraits du flux, non OCR. +[/list] + +[b]Upload ×2 jusqu'à dimanche[/b] — la saison 4 sort la semaine suivante et l'essaim va se vider.`, + tvdbId: '387662', + tags: 'web-dl, saison-complete, vf, comedie', + }, + { + key: 'flac', + who: 'donator', + category: 'musique', + name: 'Radiohead - In Rainbows (2007) [FLAC 24-96 Vinyl]', + torrentName: FLAC_ROOT, + files: flacFiles, + nfo: flacNfo, + description: `Rip vinyle 24 bits / 96 kHz, platine Technics SL-1200GR + Ortofon 2M Black. + +[b]Chaîne[/b] : préampli Rega Fono MM → RME ADI-2 Pro → FLAC niveau 8, sans normalisation ni declic automatique. +[b]Log[/b] : inclus dans le dossier. + +Le pressage 2016 (XLLP 2007) — pas la réédition 2021, qui est un report du CD.`, + tags: 'flac, 24bit, vinyle, rock', + }, + { + key: 'game', + who: 'founder', + category: 'jeux', + name: "Baldur's Gate 3 v4.1.1.4667176 (GOG) + All DLCs", + torrentName: GAME_ROOT, + files: [ + { + path: `${GAME_ROOT}/setup_baldurs_gate_3_4.1.1.4667176_(64bit)_(78334).exe`, + length: 4_294_967_296, + }, + ...Array.from({ length: 27 }, (_, i) => ({ + path: `${GAME_ROOT}/setup_baldurs_gate_3_4.1.1.4667176_(64bit)_(78334)-${i + 1}.bin`, + length: 4_294_967_296, + })), + ], + description: `Installeur GOG hors-ligne, patch 8 (v4.1.1.4667176). Aucun crack : la version GOG n'a pas de DRM. + +[b]Contenu[/b] : jeu de base + Digital Deluxe Edition DLC. +[b]Langues[/b] : toutes celles du dépôt GOG, dont la VF intégrale (textes et voix). +[b]Installation[/b] : lancer le [i].exe[/i], les [i].bin[/i] sont lus automatiquement.`, + tags: 'gog, drm-free, rpg, vf', + }, + { + key: 'ebook', + who: 'plainuser', + category: 'livres', + name: 'Frank Herbert - Le Cycle de Dune (6 tomes) [EPUB]', + torrentName: 'Frank Herbert - Le Cycle de Dune', + files: Array.from({ length: 6 }, (_, i) => ({ + path: `Frank Herbert - Le Cycle de Dune/Tome ${i + 1}.epub`, + length: 1_048_576 + i * 262_144, + })), + description: `Les six romans de Frank Herbert, traduction Michel Demuth revue (Robert Laffont, collection Ailleurs & Demain). + +EPUB 3, sans DRM, table des matières et notes de bas de page conservées.`, + tags: 'epub, science-fiction, vf', + // Personne n'annonce : c'est le torrent sans seeder, qui fait apparaître + // la demande de reseed sur la page. + noSwarm: true, + }, + { + key: 'pending', + who: 'plainuser', + category: 'films', + name: 'Sicario.2015.1080p.BluRay.x265.10bit.AAC5.1-YOLO', + files: [ + { + path: 'Sicario.2015.1080p.BluRay.x265.10bit.AAC5.1-YOLO/Sicario.2015.1080p.x265-YOLO.mkv', + length: 2_147_483_648, + }, + ], + description: 'Encodage maison x265, CRF 20, preset slow. Audio en AAC 5.1 à 448 kb/s.', + tmdbId: '242582', + tags: 'x265, encodage-maison', + // Laissé en attente : c'est ce qui rend visible le bandeau de modération + // sur la page, pour son auteur et pour le personnel. + keepPending: true, + noSwarm: true, + }, + { + key: 'adult', + who: 'founder', + category: 'adulte', + name: 'E2E.Adult.Fixture.2024.1080p.WEB-DL.x264-DEMO', + files: [ + { + path: 'E2E.Adult.Fixture.2024.1080p.WEB-DL.x264-DEMO/fixture.mkv', + length: 3_221_225_472, + }, + ], + description: + "Fixture de catégorie adulte. Elle n'existe que pour vérifier le voile de la page de détail et le réglage « afficher le contenu adulte ».", + tags: 'web-dl', + }, + + /* ── Les VERSIONS d'une même unité ──────────────────────────────────────── + * + * Tout ce qui précède est une release par œuvre, ou presque : la question + * « laquelle de ces six je prends ? » ne se posait qu'au niveau du film. Or + * c'est au niveau de l'ÉPISODE qu'elle se pose le plus souvent sur un + * tracker d'animation — quatre équipes sortent le même épisode le même + * soir, en VOSTFR, en VF, en 1080p, en 2160p. + * + * Les neuf entrées ci-dessous couvrent les trois portées que + * `/api/torrents/group` sait distinguer, sous un seul `tmdbId` : + * + * - portée `episode` : E09 en quatre versions, E10 en deux + * - portée `season` : la saison complète en deux versions + * - portée `integral` : un pack sans numéro de saison + * + * `season` et `episode` ne sont PAS des champs du formulaire : ils sont + * analysés depuis le nom de release par `parseReleaseName` (le motif + * `SxxExx`). D'où des noms scrupuleusement conformes — un nom approximatif + * ne produit pas une release mal rangée, il produit une release SANS unité, + * donc invisible dans toutes les vues par portée. + * ────────────────────────────────────────────────────────────────────── */ + + { + key: 'frierenE09a', + who: 'founder', + category: 'series', + name: 'Sousou.no.Frieren.S01E09.VOSTFR.1080p.WEB-DL.AAC.2.0.H.264-TsundereRaws', + files: [ + { + path: 'Sousou.no.Frieren.S01E09.VOSTFR.1080p.WEB-DL.AAC.2.0.H.264-TsundereRaws.mkv', + length: 1_476_395_008, + }, + ], + nfo: mi([ + ['General', [ + ['Complete name', 'Sousou.no.Frieren.S01E09.VOSTFR.1080p.WEB-DL.AAC.2.0.H.264-TsundereRaws.mkv'], + ['Format', 'Matroska'], + ['File size', '1.38 GiB'], + ['Duration', '23 min 40 s'], + ['Overall bit rate', '8 312 kb/s'], + ]], + ['Video', [ + ['Format', 'AVC'], + ['Format profile', 'High@L4'], + ['Width', '1 920 pixels'], + ['Height', '1 080 pixels'], + ['Frame rate', '23.976 FPS'], + ['Bit depth', '8 bits'], + ['Bit rate', '8 000 kb/s'], + ['Language', 'Japanese'], + ]], + ['Audio', [ + ['Format', 'AAC'], + ['Channel(s)', '2 channels'], + ['Bit rate', '192 kb/s'], + ['Language', 'Japanese'], + ['Default', 'Yes'], + ]], + ['Text', [ + ['Format', 'ASS'], + ['Language', 'French'], + ['Title', 'VOSTFR'], + ['Default', 'Yes'], + ]], + ]), + description: `Épisode 09 — sortie du soir, source Crunchyroll FR. + +Sous-titres ASS avec styles et karaoké sur l'opening. Aucun réencodage vidéo.`, + tmdbId: 'tv/209867', + tags: 'web-dl, vostfr, anime', + }, + { + key: 'frierenE09b', + who: 'donator', + category: 'series', + name: 'Sousou.no.Frieren.S01E09.VOSTFR.1080p.WEBRip.AAC.2.0.x265-NOTAG', + files: [ + { + path: 'Sousou.no.Frieren.S01E09.VOSTFR.1080p.WEBRip.AAC.2.0.x265-NOTAG.mkv', + length: 734_003_200, + }, + ], + description: `Réencodage x265 du même épisode : la moitié du poids pour un œil non exercé. + +À prendre si vous archivez la saison entière et que 24 × 1,4 Gio ne passe pas.`, + tmdbId: 'tv/209867', + tags: 'webrip, x265, vostfr, anime', + }, + { + key: 'frierenE09c', + who: 'founder', + category: 'series', + name: 'Sousou.no.Frieren.S01E09.MULTi.2160p.WEB-DL.DDP5.1.HDR.HEVC-FLUX', + files: [ + { + path: 'Sousou.no.Frieren.S01E09.MULTi.2160p.WEB-DL.DDP5.1.HDR.HEVC-FLUX.mkv', + length: 4_509_715_660, + }, + ], + description: `L'épisode 09 en 2160p HDR10, pistes japonaise et française, DDP 5.1. + +Le seul upscale du lot est celui de la plateforme elle-même — la source n'existe pas en 4K natif.`, + tmdbId: 'tv/209867', + tags: 'web-dl, hdr, multi, anime', + }, + { + key: 'frierenE09d', + who: 'donator', + category: 'series', + name: 'Sousou.no.Frieren.S01E09.VF.1080p.WEB-DL.EAC3.2.0.H.264-QCTeam', + files: [ + { + path: 'Sousou.no.Frieren.S01E09.VF.1080p.WEB-DL.EAC3.2.0.H.264-QCTeam.mkv', + length: 1_395_864_371, + }, + ], + description: 'Version française uniquement, doublage québécois. Aucun sous-titre.', + tmdbId: 'tv/209867', + tags: 'web-dl, vf, anime', + }, + { + key: 'frierenE10a', + who: 'founder', + category: 'series', + name: 'Sousou.no.Frieren.S01E10.VOSTFR.1080p.WEB-DL.AAC.2.0.H.264-TsundereRaws', + files: [ + { + path: 'Sousou.no.Frieren.S01E10.VOSTFR.1080p.WEB-DL.AAC.2.0.H.264-TsundereRaws.mkv', + length: 1_503_238_553, + }, + ], + description: 'Épisode 10, même chaîne de traitement que le 09.', + tmdbId: 'tv/209867', + tags: 'web-dl, vostfr, anime', + }, + { + key: 'frierenE10b', + who: 'donator', + category: 'series', + name: 'Sousou.no.Frieren.S01E10.MULTi.1080p.WEB-DL.DDP2.0.H.264-VARYG', + files: [ + { + path: 'Sousou.no.Frieren.S01E10.MULTi.1080p.WEB-DL.DDP2.0.H.264-VARYG.mkv', + length: 1_610_612_736, + }, + ], + description: 'Épisode 10 bilingue, pistes japonaise et française dans le même fichier.', + tmdbId: 'tv/209867', + tags: 'web-dl, multi, anime', + }, + { + key: 'frierenS01a', + who: 'founder', + category: 'series', + name: 'Sousou.no.Frieren.S01.MULTi.1080p.BluRay.x265.10bit.FLAC.2.0-KAWAII', + torrentName: 'Sousou.no.Frieren.S01.MULTi.1080p.BluRay.x265.10bit.FLAC.2.0-KAWAII', + files: Array.from({ length: 28 }, (_, i) => ({ + path: `Sousou.no.Frieren.S01.MULTi.1080p.BluRay.x265.10bit.FLAC.2.0-KAWAII/Sousou.no.Frieren.S01E${String(i + 1).padStart(2, '0')}.MULTi.1080p.BluRay.x265.10bit.FLAC.2.0-KAWAII.mkv`, + length: 687_194_767 + i * 8_388_608, + })), + description: `[b]Saison 1 complète[/b] — 28 épisodes, source BluRay japonais. + +[list] +[*]Encodage x265 10 bits, CRF 18, grain préservé. +[*]Pistes japonaise FLAC 2.0 et française EAC3 2.0. +[*]Sous-titres VOSTFR et VF forcés. +[/list]`, + tmdbId: 'tv/209867', + tags: 'bluray, x265, multi, saison-complete, anime', + }, + { + key: 'frierenS01b', + who: 'donator', + category: 'series', + name: 'Sousou.no.Frieren.S01.VOSTFR.1080p.WEB-DL.AAC.2.0.H.264-TsundereRaws', + torrentName: 'Sousou.no.Frieren.S01.VOSTFR.1080p.WEB-DL.AAC.2.0.H.264-TsundereRaws', + files: Array.from({ length: 28 }, (_, i) => ({ + path: `Sousou.no.Frieren.S01.VOSTFR.1080p.WEB-DL.AAC.2.0.H.264-TsundereRaws/Sousou.no.Frieren.S01E${String(i + 1).padStart(2, '0')}.VOSTFR.1080p.WEB-DL.AAC.2.0.H.264-TsundereRaws.mkv`, + length: 1_476_395_008 + i * 4_194_304, + })), + description: `Le pack des sorties hebdomadaires, sans réencodage. Plus lourd que la version BluRay et sorti six mois plus tôt.`, + tmdbId: 'tv/209867', + tags: 'web-dl, vostfr, saison-complete, anime', + }, + { + // Sans numéro de saison dans le nom : `parseReleaseName` ne trouve rien, + // donc `season` reste NULL et la release tombe dans la portée `integral`. + // C'est le cas qui rend cette portée visible. + key: 'frierenIntegral', + who: 'founder', + category: 'series', + name: 'Sousou no Frieren - Integrale MULTi 1080p BluRay x265 10bit FLAC-KAWAII', + torrentName: 'Sousou no Frieren - Integrale', + files: Array.from({ length: 28 }, (_, i) => ({ + path: `Sousou no Frieren - Integrale/Frieren - ${String(i + 1).padStart(2, '0')}.mkv`, + length: 687_194_767 + i * 8_388_608, + })), + description: `L'intégrale en un seul torrent, épisodes renommés sans convention de scène — le pack « je veux tout et je m'en fous du nommage ».`, + tmdbId: 'tv/209867', + tags: 'bluray, x265, multi, anime', + }, +]; + +const CATEGORIES = [ + { key: 'films', name: 'Films', isAdult: false }, + { key: 'series', name: 'Séries', isAdult: false }, + { key: 'musique', name: 'Musique', isAdult: false }, + { key: 'jeux', name: 'Jeux', isAdult: false }, + { key: 'livres', name: 'Livres', isAdult: false }, + { key: 'adulte', name: 'XXX', isAdult: true }, +]; + +/* ── Les comptes de l'essaim ──────────────────────────────────────────────── */ +// +// Un pair est rattaché au membre dont la passkey a servi à l'annonce. Avec les +// trois comptes du seed, un essaim plafonne à trois lignes. Quatre membres de +// plus, inscrits par le VRAI chemin, suffisent à ce qu'un essaim ressemble à +// un essaim. +// +// Le cache disque évite de repayer la preuve de travail à chaque relance. + +const SWARM_ACCOUNTS = [ + { username: 'mando', email: 'mando@e2e.test', password: 'E2e-Passw0rd!mando' }, + { username: 'shai_hulud', email: 'shai@e2e.test', password: 'E2e-Passw0rd!shai' }, + { username: 'harkonnen', email: 'harko@e2e.test', password: 'E2e-Passw0rd!harko' }, + { username: 'bene_gesserit', email: 'bene@e2e.test', password: 'E2e-Passw0rd!bene' }, +]; + +const CACHE = new URL('session-torrents.json', HERE); + +async function pow() { + const { body } = await call('/api/auth/pow'); + return solvePoW(body.challenge, body.difficulty, undefined, 240_000); +} + +async function register(acc) { + const jar = jarFor(acc.username); + const creds = await generateCredentials(acc.password); + const solved = await pow(); + return call('/api/auth/register', { + method: 'POST', + jar, + body: { + username: acc.username, + email: acc.email, + password: acc.password, + confirmPassword: acc.password, + authSalt: creds.salt, + authVerifier: creds.verifier, + powChallenge: solved.challenge, + powNonce: solved.nonce, + powHash: solved.hash, + }, + }); +} + +async function login(acc) { + const jar = jarFor(acc.username); + jar.clear(); + const chal = await call(`/api/auth/challenge?username=${encodeURIComponent(acc.username)}`, { + jar, + }); + if (!ok(chal.status)) return chal; + const proof = await generateLoginProof(acc.password, chal.body.salt, chal.body.challenge); + return call('/api/auth/login', { + method: 'POST', + jar, + body: { username: acc.username, challenge: chal.body.challenge, proof }, + }); +} + +async function ensureSwarmAccounts() { + const cached = existsSync(CACHE) ? JSON.parse(readFileSync(CACHE, 'utf8')) : {}; + + // L'inscription se ferme derrière le premier compte enregistré ; le seed l'a + // rouverte, mais un scénario peut l'avoir refermée depuis. + await call('/api/admin/settings', { + method: 'PUT', + who: 'founder', + body: { registrationOpen: true }, + }); + + const out = {}; + for (const acc of SWARM_ACCOUNTS) { + if (cached[acc.username]?.cookie) { + // Le cookie du cache peut appartenir à une pile précédente, détruite + // depuis : la seule preuve qu'il vaut encore est de s'en servir. + const jar = jarFor(acc.username); + jar.clear(); + for (const pair of cached[acc.username].cookie.split('; ')) { + const i = pair.indexOf('='); + if (i > 0) jar.set(pair.slice(0, i), pair.slice(i + 1)); + } + const who = await call('/api/auth/status', { jar }); + if (who.body?.user?.id) { + out[acc.username] = { ...cached[acc.username], id: who.body.user.id }; + step(`${acc.username} (cache)`, true); + continue; + } + jar.clear(); + } + + await sleep(2500); + let r = await register(acc); + let via = 'register'; + if (!ok(r.status)) { + r = await login(acc); + via = 'login'; + } + if (!ok(r.status)) { + step(acc.username, false, `${r.status} ${JSON.stringify(r.body).slice(0, 120)}`); + continue; + } + const jar = jarFor(acc.username); + const who = await call('/api/auth/status', { jar }); + out[acc.username] = { cookie: cookieHeader(jar), via, id: who.body?.user?.id ?? null }; + step(`${acc.username} (${via})`, true); + } + writeFileSync(CACHE, JSON.stringify(out, null, 2)); + return out; +} + +/* ── L'essaim ─────────────────────────────────────────────────────────────── */ + +/** La passkey d'annonce d'un membre, par le même appel que la page /me. */ +async function passkeyOf(who) { + const { status, body } = await call('/api/auth/passkey', { who }); + return ok(status) ? (body?.passkey ?? null) : null; +} + +/* ── Le programme ─────────────────────────────────────────────────────────── */ + +async function main() { + if (!S.founder?.cookie) { + console.error('session.json ne porte pas de founder — lancer run.sh --keep d’abord.'); + process.exit(1); + } + + console.log('\n1. catégories'); + await resetRateLimits().catch(() => {}); + const catId = {}; + for (const c of CATEGORIES) { + const made = await call('/api/admin/categories', { + method: 'POST', + who: 'founder', + body: { name: c.name, isAdult: c.isAdult }, + }); + let id = made.body?.id ?? made.body?.category?.id ?? made.body?.data?.id ?? null; + if (!id) { + // Déjà là (relance) : la retrouver. `includeAdult` est l'échappatoire du + // personnel, sans quoi la catégorie XXX est invisible même pour son auteur. + const { body } = await call('/api/categories?includeAdult=true', { who: 'founder' }); + const list = Array.isArray(body) ? body : (body?.data ?? body?.categories ?? []); + const flat = list.flatMap((x) => [x, ...(x.subcategories ?? [])]); + id = flat.find((x) => x.name === c.name)?.id ?? null; + } + catId[c.key] = id; + step(c.name, !!id, id ? '' : JSON.stringify(made.body).slice(0, 120)); + } + + console.log('\n2. comptes de l’essaim'); + const swarmAccounts = await ensureSwarmAccounts(); + + console.log('\n3. uploads'); + await resetRateLimits().catch(() => {}); + const rows = {}; + for (const r of RELEASES) { + const bytes = torrentFile({ + name: r.torrentName ?? r.name, + files: r.files, + pieceLength: r.pieceLength, + }); + const res = await upload( + r.who, + { + name: r.name, + categoryId: catId[r.category], + description: r.description, + nfo: r.nfo, + tags: r.tags, + tmdbId: r.tmdbId, + imdbId: r.imdbId, + tvdbId: r.tvdbId, + }, + bytes, + ); + const row = res.body?.data ?? res.body?.torrent ?? res.body; + const hash = row?.infoHash ?? null; + if (hash) { + rows[r.key] = { ...r, infoHash: hash, size: r.files.reduce((n, f) => n + f.length, 0) }; + step(r.name.slice(0, 60), true, hash.slice(0, 12)); + } else { + step(r.name.slice(0, 60), false, `${res.status} ${JSON.stringify(res.body).slice(0, 160)}`); + } + await sleep(400); + } + + console.log('\n4. modération'); + for (const r of Object.values(rows)) { + if (r.keepPending) { + step(`${r.key} laissé en attente`, true, 'bandeau de modération'); + continue; + } + // Un upload du personnel contourne déjà la modération ; approuver un + // torrent déjà accepté n'est pas une erreur pour cette route. + const res = await call(`/api/mod/torrents/${r.infoHash}/approve`, { + method: 'POST', + who: 'founder', + }); + step(`${r.key} accepté`, ok(res.status) || res.status === 409, String(res.status)); + await sleep(200); + } + + console.log('\n5. supersession, buffs, épinglage'); + if (rows.dune1080 && rows.dune1080proper) { + const res = await call(`/api/mod/torrents/${rows.dune1080.infoHash}/supersede`, { + method: 'PUT', + who: 'founder', + body: { + supersededById: rows.dune1080proper.infoHash, + reason: 'VFF désynchronisée d’environ 400 ms à partir du chapitre 12.', + }, + }); + step('le 1080p est remplacé par le PROPER', ok(res.status), String(res.status)); + } + if (rows.dune4k) { + const until = new Date(Date.now() + 7 * 86_400_000).toISOString(); + const res = await call(`/api/mod/torrents/${rows.dune4k.infoHash}/buffs`, { + method: 'PUT', + who: 'founder', + body: { downloadMultiplier: 0, uploadMultiplier: 100, until, isSticky: true }, + }); + step('freeleech 7 jours + épinglage sur le remux', ok(res.status), String(res.status)); + } + if (rows.bear) { + const res = await call(`/api/mod/torrents/${rows.bear.infoHash}/buffs`, { + method: 'PUT', + who: 'founder', + body: { uploadMultiplier: 200, until: new Date(Date.now() + 3 * 86_400_000).toISOString() }, + }); + step('upload ×2 sur The Bear S03', ok(res.status), String(res.status)); + } + + console.log('\n6. commentaires'); + const COMMENTS = [ + [ + 'dune4k', + 'donator', + 'Le meilleur remux du film à ce jour. La VFF est bien celle du disque français, pas un upmix.', + ], + [ + 'dune4k', + 'plainuser', + 'Question bête : mon téléviseur ne fait que du HDR10, est-ce que le Dolby Vision Profile 7 se replie correctement ?', + ], + [ + 'dune4k', + 'founder', + '[quote=plainuser]est-ce que le Dolby Vision Profile 7 se replie correctement ?[/quote]\nOui — le profil 7 embarque la couche HDR10 de base. Ton téléviseur lira le HDR10 et ignorera la couche RPU.', + ], + ['dune4k', 'mando', 'En seed depuis ce matin, 82 Gio à 1,2 Gio/s en pointe. Merci pour le freeleech.'], + [ + 'dune1080', + 'plainuser', + 'Le décalage de la VFF est franchement audible à partir de la scène du ver. Prenez le PROPER.', + ], + ['bear', 'harkonnen', 'La VF québécoise est un vrai plus, on ne la trouve nulle part ailleurs.'], + ['flac', 'bene_gesserit', 'Le log est propre et le rip ne montre aucun declic. Beau travail.'], + [ + 'game', + 'shai_hulud', + '27 fichiers .bin, prévoyez la place : il faut ~120 Gio pour l’archive plus l’installation.', + ], + ]; + for (const [key, who, content] of COMMENTS) { + const r = rows[key]; + if (!r) continue; + if (!S[who] && !swarmAccounts[who]) continue; + const res = await call(`/api/torrents/${r.infoHash}/comments`, { + method: 'POST', + who, + body: { content }, + }); + step(`${who} sur ${key}`, ok(res.status), String(res.status)); + await sleep(250); + } + + console.log('\n7. favoris et obligation de seed'); + if (rows.dune4k) { + for (const who of ['plainuser', 'donator']) { + const res = await call(`/api/torrents/${rows.dune4k.infoHash}/favorite`, { + method: 'POST', + who, + }); + step(`${who} met le remux en favori`, ok(res.status) || res.status === 409, String(res.status)); + } + // Le clic sur le .torrent est ce qui pose la ligne `hnr_tracking`. Sans + // lui, l'annonce qui suit n'a rien à compléter et la carte d'obligation + // reste absente — c'est le chemin réel, pas un raccourci. + const dl = await call(`/api/torrents/${rows.dune4k.infoHash}/download`, { + who: 'plainuser', + raw: true, + }); + step('plainuser récupère le .torrent', ok(dl.status), `${dl.buffer?.length ?? 0} octets`); + } + + console.log('\n8. essaim (annonces réelles)'); + const passkeys = {}; + for (const who of ['founder', 'donator', 'plainuser', ...Object.keys(swarmAccounts)]) { + const pk = await passkeyOf(who); + if (pk) passkeys[who] = pk; + } + step('passkeys récupérées', Object.keys(passkeys).length > 0, Object.keys(passkeys).join(' ')); + + const GiB = 1024 ** 3; + // Qui est dans quel essaim, et dans quel état. `done: 1` = seeder ; + // `done < 1` = leecher, et le pourcentage que la page affiche vient de là. + const SWARM = [ + ['dune4k', 'founder', { done: 1, up: 412 * GiB }], + ['dune4k', 'mando', { done: 1, up: 96 * GiB }], + ['dune4k', 'shai_hulud', { done: 1, up: 11 * GiB }], + ['dune4k', 'harkonnen', { done: 0.34, up: 2 * GiB }], + ['dune4k', 'bene_gesserit', { done: 0.71, up: 8 * GiB }], + ['dune4k', 'plainuser', { done: 1, up: 0 }], + ['dune4kXseed', 'donator', { done: 1, up: 4 * GiB }], + ['dune4kXseed', 'mando', { done: 1, up: 19 * GiB }], + ['dune1080', 'donator', { done: 1, up: 61 * GiB }], + ['dune1080', 'bene_gesserit', { done: 1, up: 3 * GiB }], + ['dune1080proper', 'founder', { done: 1, up: 22 * GiB }], + ['dune1080proper', 'mando', { done: 1, up: 7 * GiB }], + ['dune1080proper', 'harkonnen', { done: 0.12, up: 0 }], + ['bear', 'founder', { done: 1, up: 148 * GiB }], + ['bear', 'shai_hulud', { done: 1, up: 31 * GiB }], + ['bear', 'harkonnen', { done: 1, up: 12 * GiB }], + ['bear', 'bene_gesserit', { done: 0.88, up: 1 * GiB }], + ['bear', 'plainuser', { done: 0.05, up: 0 }], + ['flac', 'donator', { done: 1, up: 9 * GiB }], + ['flac', 'bene_gesserit', { done: 1, up: 2 * GiB }], + ['game', 'founder', { done: 1, up: 244 * GiB }], + ['game', 'shai_hulud', { done: 0.46, up: 0 }], + ['adult', 'founder', { done: 1, up: 1 * GiB }], + // Les versions : des essaims de tailles TRÈS différentes sur la même + // unité, parce que c'est ce qui départage deux versions quand la qualité + // ne suffit pas — le 2160p peut être meilleur et introuvable. + ['frierenE09a', 'founder', { done: 1, up: 31 * GiB }], + ['frierenE09a', 'mando', { done: 1, up: 12 * GiB }], + ['frierenE09a', 'shai_hulud', { done: 1, up: 4 * GiB }], + ['frierenE09a', 'harkonnen', { done: 0.62, up: 0 }], + ['frierenE09b', 'donator', { done: 1, up: 8 * GiB }], + ['frierenE09c', 'founder', { done: 1, up: 2 * GiB }], + ['frierenE09d', 'donator', { done: 1, up: 1 * GiB }], + ['frierenE10a', 'founder', { done: 1, up: 22 * GiB }], + ['frierenE10a', 'bene_gesserit', { done: 1, up: 3 * GiB }], + ['frierenE10b', 'donator', { done: 1, up: 5 * GiB }], + ['frierenS01a', 'founder', { done: 1, up: 184 * GiB }], + ['frierenS01a', 'mando', { done: 1, up: 41 * GiB }], + ['frierenS01a', 'shai_hulud', { done: 0.23, up: 0 }], + ['frierenS01b', 'donator', { done: 1, up: 96 * GiB }], + ['frierenIntegral', 'founder', { done: 1, up: 58 * GiB }], + ]; + + let announced = 0; + let refused = 0; + for (const [key, who, spec] of SWARM) { + const r = rows[key]; + const pk = passkeys[who]; + if (!r || !pk || r.noSwarm) continue; + const left = Math.round(r.size * (1 - spec.done)); + const port = 6881 + (announced % 64); + // Deux annonces pour qui a fini : `started` puis `completed`. C'est la + // seconde qui incrémente le compteur de snatchs et qui ferme la ligne + // `hnr_tracking` — un seeder posé d'une seule annonce `started` laisse la + // page dire « jamais complété ». + const first = await announce(pk, r.infoHash, `${key}:${who}`, { + left: spec.done >= 1 ? r.size : left, + uploaded: 0, + downloaded: spec.done >= 1 ? 0 : r.size - left, + event: 'started', + port, + }); + if (first.failure) { + refused += 1; + step(`${who} -> ${key}`, false, first.failure); + continue; + } + if (spec.done >= 1) { + await announce(pk, r.infoHash, `${key}:${who}`, { + left: 0, + uploaded: spec.up, + downloaded: r.size, + event: 'completed', + port, + }); + } + announced += 1; + step(`${who} -> ${key}`, true, spec.done >= 1 ? 'seeder' : `${Math.round(spec.done * 100)} %`); + await sleep(120); + } + step('annonces', refused === 0, `${announced} acceptées, ${refused} refusées`); + + // Le versement des crédits d'octets est groupé (5 s dans cette pile) : sans + // cette attente, les ratios affichés sont ceux d'avant les annonces. + console.log('\n attente du versement des crédits (7 s)…'); + await sleep(7000); + + console.log('\nÀ regarder'); + const link = (key, label) => + rows[key] ? console.log(` ${label.padEnd(34)} ${WEB}/torrents/${rows[key].infoHash}`) : null; + link('dune4k', 'remux 4K, tout allumé'); + link('dune4kXseed', 'son cross-seed'); + link('dune1080', 'remplacé par un PROPER'); + link('dune1080proper', 'le PROPER'); + link('bear', 'saison complète, upload x2'); + link('flac', 'musique, pistes audio seules'); + link('game', 'jeu, 28 fichiers'); + link('ebook', 'aucun seeder -> demande de reseed'); + link('pending', 'en attente de modération'); + link('adult', 'voile adulte'); + link('frierenE09a', 'épisode 09, 4 versions'); + link('frierenS01a', 'saison 1, 2 versions'); + link('frierenIntegral', 'intégrale, sans saison'); + if (rows.dune4k) { + console.log(` ${'groupe (quatre éditions)'.padEnd(34)} ${WEB}/torrents/group/tmdb:693134`); + } + if (rows.frierenE09a) { + console.log(` ${'groupe (3 portées)'.padEnd(34)} ${WEB}/torrents/group/tmdb:tv/209867`); + } + console.log(`\n ${steps} étapes, ${problems} en échec.`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/tests/e2e/docker-compose.yml b/tests/e2e/docker-compose.yml index 8169d59d..99859121 100644 --- a/tests/e2e/docker-compose.yml +++ b/tests/e2e/docker-compose.yml @@ -1,9 +1,9 @@ # Throwaway end-to-end stack for the themes work. # -# Deliberately lean: postgres, redis, api, web. No pgbouncer (the API talks to -# Postgres directly, which is also how MIGRATIONS_DATABASE_URL works in prod), -# no Caddy (the web container emits its own security headers, so the CSP under -# test is the real one), no tracker (nothing here announces). +# Deliberately lean: postgres, redis, api, web, relay, tracker. No pgbouncer (the +# API talks to Postgres directly, which is also how MIGRATIONS_DATABASE_URL works +# in prod), no Caddy (the web container emits its own security headers, so the CSP +# under test is the real one). # # The API applies its committed migrations at boot, so this database is built by # the real chain from empty — which is exactly what an operator gets. @@ -61,6 +61,26 @@ services: MESSAGING_TOKEN_SECRET: e2e-messaging-secret-0123456789abcdef ports: - "54000:4000" + # Métadonnées TMDb, quand l'exploitant en a une clé. + # + # Facultatif : la pile marche sans, et la carte d'identité rend alors son + # état « sans affiche » — le seul que la suite voyait, donc le seul qui + # avait jamais été regardé, alors qu'un titre, une affiche et un synopsis + # changent la mise en page de cette carte. + # + # Un FICHIER et non `${TMDB_API_KEY}` dans `environment`, parce qu'une + # substitution de shell se perd : `docker compose up -d web` lancé depuis un + # terminal où la variable n'est pas exportée réévalue la configuration de + # TOUS les services, voit `api` changer, et la recrée sans la clé. Silence + # complet, et l'affiche disparaît. `run.sh` écrit ce fichier depuis le + # `.env` de la racine ; `required: false` pour que la pile démarre sans. + # + # Un fichier dédié plutôt que le `.env` de la racine : `env_file` verse + # TOUT ce qu'il contient dans le conteneur, et la racine porte les secrets + # de production d'un exploitant. + env_file: + - path: ./tmdb.env + required: false volumes: - uploads:/app/data/uploads tmpfs: @@ -133,5 +153,44 @@ services: - spa - api + # Le tracker, enfin. + # + # Le commentaire d'en-tête disait « no tracker (nothing here announces) », et + # c'était vrai : aucun scénario n'annonce. La conséquence n'était visible + # nulle part dans la suite mais elle l'est à l'œil nu sur une pile gardée — + # toute page qui montre un essaim montrait zéro pair, zéro seeder, zéro + # leecher, et la carte de décision de la page de détail rendait en + # permanence son état vide. On ne peut pas juger un affichage de swarm sans + # swarm. + # + # `demoTorrents.mjs` annonce ici avec la passkey réelle de chaque compte, donc + # ce que la page affiche vient du vrai chemin : announce → Redis → peers. + tracker: + image: trackarr-e2e-tracker:local + environment: + DATABASE_URL: postgres://tracker:tracker@postgres:5432/trackarr?sslmode=disable + DB_SSL: "false" + REDIS_URL: redis://redis:6379 + REDIS_PASSWORD: e2e-redis-password + IP_HASH_SECRET: e2e-ip-hash-secret-0123456789abcdef + TRACKER_HTTP_PORT: 8080 + # Versement des crédits d'octets en 5 s au lieu de 60 : sur une pile qu'on + # essaie à la main, attendre une minute pour voir un ratio bouger revient + # à croire que le compteur est cassé. + TRACKER_STATS_FLUSH_INTERVAL: 5s + TRUST_PROXY: "false" + ports: + - "54200:8080" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + # Le tracker ne migre pas ; c'est l'API qui applique la chaîne au boot. + # Sans cette attente il ouvre un pool sur une base encore vide et son + # premier announce échoue sur une table absente. + api: + condition: service_started + volumes: uploads: diff --git a/tests/e2e/import-dump.sh b/tests/e2e/import-dump.sh new file mode 100755 index 00000000..30170251 --- /dev/null +++ b/tests/e2e/import-dump.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# +# Verse les torrents d'un `pg_dump` d'exploitation dans la pile e2e gardée. +# +# bash tests/e2e/import-dump.sh /chemin/vers/dump.sql +# +# Pourquoi ce script existe : un catalogue fabriqué à la main ne ressemble pas +# à un vrai. `demoTorrents.mjs` dépose dix releases dont chaque champ a été +# choisi pour éclairer un bout d'interface — donc chaque champ est propre, de la +# bonne longueur, et rempli. Un vrai catalogue porte des descriptions de 3 Kio +# avec des images mortes dedans, des NFO de 50 Kio, des identifiants TMDb saisis +# par URL (`tv/34114` et non `34114`), des titres à rallonge et des champs +# vides. Ce sont ces cas-là qui cassent une mise en page, et on ne les invente +# pas. +# +# Ce qui est importé : `torrents` et ce qui y pend — catégories, étiquettes, +# liaisons, commentaires, instantané d'essaim, favoris, fils de modération, +# obligations de seed. +# +# Ce qui n'est PAS importé : la table `users`. Elle porte `auth_verifier`, +# `passkey`, `totp_secret` et `panic_password_hash` — des secrets de l'instance +# d'origine, qui n'ont rien à faire dans une pile jetable. Les identifiants +# d'auteur et de propriétaire sont donc REMAPPÉS sur les comptes du harnais, +# dont les mots de passe sont faux et publics. +# +# Le dump peut venir d'une version ANTÉRIEURE du schéma : les colonnes sont +# lues dans son propre `COPY`, comparées à celles de la base qui tourne, et +# l'import s'arrête net si une colonne du dump a disparu depuis. Les colonnes +# AJOUTÉES depuis n'ont pas d'importance — elles ont toutes un défaut ou sont +# nullables, sinon la migration qui les a créées n'aurait pas pu tourner. +set -euo pipefail + +DUMP="${1:-}" +if [ -z "$DUMP" ] || [ ! -f "$DUMP" ]; then + echo "usage: bash tests/e2e/import-dump.sh " >&2 + exit 2 +fi + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +COMPOSE="docker compose -f $HERE/docker-compose.yml" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +say() { printf '\n\033[1m▸ %s\033[0m\n' "$*"; } + +CID="$($COMPOSE ps -q postgres)" +[ -n "$CID" ] || { echo "la pile e2e ne tourne pas — bash tests/e2e/run.sh --keep" >&2; exit 1; } + +say "extraction des blocs COPY" +# Un bloc VIDE (`FROM stdin;` suivi immédiatement de `\.`) piège une capture +# non gourmande : elle s'étend jusqu'au `\.` suivant et ramasse la table +# d'après. D'où le découpage ligne à ligne plutôt qu'une expression régulière +# sur tout le fichier. +python3 - "$DUMP" "$WORK" <<'PY' +import sys, pathlib +dump, work = sys.argv[1], pathlib.Path(sys.argv[2]) +WANT = ['categories', 'tags', 'torrents', 'torrent_tags', 'torrent_comments', + 'torrent_stats', 'torrent_favorites', 'torrent_moderation_messages', + 'hnr_tracking'] +cols, counts = {}, {} +current = None +out = None +for line in pathlib.Path(dump).open(encoding='utf8', errors='replace'): + if current is None: + if line.startswith('COPY public.'): + name = line[len('COPY public.'):].split(' ', 1)[0] + if name in WANT: + current = name + cols[name] = [c.strip() for c in line[line.index('(') + 1:line.index(')')].split(',')] + out = (work / f'{name}.tsv').open('w', encoding='utf8') + counts[name] = 0 + continue + if line.rstrip('\n') == '\\.': + out.close(); current = None; out = None + continue + out.write(line); counts[current] += 1 +if out: out.close() +# Le `\n` final n'est pas cosmétique : `while read` laisse tomber une dernière +# ligne sans fin de ligne, et `pg_dump` émet les tables dans l'ordre +# alphabétique — donc la ligne perdue était `torrents`, la seule qui compte +# vraiment. Le contrôle affichait huit tables sur neuf et avait l'air complet. +(work / 'columns.txt').write_text( + ''.join(f'{k}\t{",".join(v)}\n' for k, v in cols.items()) +) +missing = [t for t in WANT if t not in cols] +for t in WANT: + print(f' {t:32} {counts.get(t, 0):>6} lignes' + (' (absente du dump)' if t in missing else '')) +PY + +say "contrôle des colonnes contre la base qui tourne" +while IFS=$'\t' read -r table dumpcols; do + [ -n "$table" ] || continue + # `< /dev/null` : sans lui, `docker compose exec -T` lit STDIN — c'est-à-dire + # le reste de `columns.txt` — et la boucle s'arrête après la première table. + # Elle affichait « categories : ok » et rien d'autre, ce qui ressemble à un + # contrôle qui passe. + live=$($COMPOSE exec -T postgres psql -U tracker -d trackarr -tA -c \ + "select string_agg(column_name, ',') from information_schema.columns where table_name='$table';" \ + < /dev/null | tr -d '\r') + missing="" + IFS=',' read -ra want <<< "$dumpcols" + for c in "${want[@]}"; do + case ",$live," in *",$c,"*) ;; *) missing="$missing $c" ;; esac + done + if [ -n "$missing" ]; then + echo " $table : colonnes du dump disparues depuis —$missing" >&2 + echo " L'import s'arrête : ces données n'ont plus de place où aller." >&2 + exit 1 + fi + echo " $table : ok" +done < "$WORK/columns.txt" + +say "copie dans le conteneur" +# Une table absente du dump doit donner un fichier VIDE, pas un fichier +# manquant : `COPY` d'un fichier absent echoue, `COPY` d'un fichier vide charge +# zero ligne, et c'est exactement ce qu'on veut dire. +for t in categories tags torrents torrent_tags torrent_comments torrent_stats \ + torrent_favorites torrent_moderation_messages hnr_tracking; do + [ -f "$WORK/$t.tsv" ] || : > "$WORK/$t.tsv" +done +docker exec "$CID" sh -c 'rm -rf /tmp/imp && mkdir -p /tmp/imp' +for f in "$WORK"/*.tsv; do docker cp "$f" "$CID:/tmp/imp/$(basename "$f")"; done +# Les montages de Docker Desktop servent parfois un fichier tronqué sans le +# dire. `docker cp` n'est pas un montage, mais le contrôle coûte une seconde et +# un import à moitié copié est indétectable ensuite. +for f in "$WORK"/*.tsv; do + b="$(basename "$f")" + a=$(md5 -q "$f" 2>/dev/null || md5sum "$f" | cut -d' ' -f1) + c=$(docker exec "$CID" md5sum "/tmp/imp/$b" | cut -d' ' -f1) + [ "$a" = "$c" ] || { echo " $b : md5 différent après copie ($a vs $c)" >&2; exit 1; } +done +echo " md5 identiques" + +docker cp "$HERE/import-dump.sql" "$CID:/tmp/imp/import.sql" + +say "import" +docker exec "$CID" psql -U tracker -d trackarr -v ON_ERROR_STOP=1 -f /tmp/imp/import.sql + +say "terminé — donnez-leur un essaim avec : node tests/e2e/demoSwarm.mjs" diff --git a/tests/e2e/import-dump.sql b/tests/e2e/import-dump.sql new file mode 100644 index 00000000..7135ef4e --- /dev/null +++ b/tests/e2e/import-dump.sql @@ -0,0 +1,170 @@ +BEGIN; + +-- ───────────────────────────────────────────────────────────────────────────── +-- Tables de transit, aux colonnes de l'ANCIEN schéma. +-- +-- Le dump date d'une version antérieure. Pour `torrents`, les 24 colonnes de +-- l'époque sont un sous-ensemble strict des 33 d'aujourd'hui, et les neuf +-- ajoutées sont toutes nullables ou pourvues d'un défaut — donc un INSERT à +-- liste de colonnes explicite passe tel quel. Pour les huit tables liées, les +-- colonnes sont identiques au nom près de l'ordre. Vérifié colonne par colonne +-- contre `information_schema` de la base qui tourne, pas supposé. +-- ───────────────────────────────────────────────────────────────────────────── + +CREATE TEMP TABLE s_categories (id text, name text, slug text, parent_id text, + created_at timestamp, newznab_id integer, is_adult boolean, type text, icon text); +CREATE TEMP TABLE s_tags (id text, name text, slug text, color text, created_at timestamp); +CREATE TEMP TABLE s_torrents (id text, info_hash text, name text, size bigint, + description text, torrent_data bytea, uploader_id text, category_id text, + is_active boolean, created_at timestamp, nfo text, imdb_id text, tmdb_id text, + tvdb_id text, moderation_status text, moderated_by_id text, moderated_at timestamp, + igdb_id text, openlibrary_id text, content_signature text, updated_at timestamp, + episode smallint, federate_swarm boolean, season smallint); +CREATE TEMP TABLE s_torrent_tags (torrent_id text, tag_id text, created_at timestamp); +CREATE TEMP TABLE s_comments (id text, torrent_id text, author_id text, content text, + created_at timestamp, updated_at timestamp); +CREATE TEMP TABLE s_stats (info_hash text, seeders integer, leechers integer, + completed integer, updated_at timestamp); +CREATE TEMP TABLE s_favorites (user_id text, torrent_id text, created_at timestamp); +CREATE TEMP TABLE s_modmsgs (id text, torrent_id text, author_id text, body text, + is_system boolean, status_change text, created_at timestamp); +CREATE TEMP TABLE s_hnr (id text, user_id text, torrent_id text, downloaded_at timestamp, + seed_time integer, required_seed_time integer, is_hnr boolean, is_exempt boolean, + completed_at timestamp, uploaded bigint, downloaded bigint); + +COPY s_categories FROM '/tmp/imp/categories.tsv'; +COPY s_tags FROM '/tmp/imp/tags.tsv'; +COPY s_torrents FROM '/tmp/imp/torrents.tsv'; +COPY s_torrent_tags FROM '/tmp/imp/torrent_tags.tsv'; +COPY s_comments FROM '/tmp/imp/torrent_comments.tsv'; +COPY s_stats FROM '/tmp/imp/torrent_stats.tsv'; +COPY s_favorites FROM '/tmp/imp/torrent_favorites.tsv'; +COPY s_modmsgs FROM '/tmp/imp/torrent_moderation_messages.tsv'; +COPY s_hnr FROM '/tmp/imp/hnr_tracking.tsv'; + +-- ───────────────────────────────────────────────────────────────────────────── +-- À qui rattacher tout ça. +-- +-- Le dump ne porte qu'UN compte, et sa ligne `users` contient `auth_verifier`, +-- `passkey`, `totp_secret` et `panic_password_hash` — des secrets de +-- l'instance d'origine. Elle n'est pas importée. Les identifiants d'auteur et +-- de propriétaire sont remappés sur les comptes que le harnais a créés, dont +-- les mots de passe sont publics et faux. +-- ───────────────────────────────────────────────────────────────────────────── +CREATE TEMP TABLE who AS +SELECT id, username, row_number() OVER (ORDER BY created_at) - 1 AS n + FROM users; + +CREATE TEMP TABLE founder AS SELECT id FROM users WHERE username = 'founder' LIMIT 1; + +-- ── Catégories ────────────────────────────────────────────────────────────── +-- `categories_slug_unique` en plus de la clé primaire : deux contraintes +-- uniques, donc un ON CONFLICT ne suffit pas. Filtre explicite. +INSERT INTO categories (id, name, slug, parent_id, created_at, newznab_id, is_adult, type, icon) +SELECT s.id, s.name, s.slug, s.parent_id, s.created_at, s.newznab_id, s.is_adult, s.type, s.icon + FROM s_categories s + WHERE NOT EXISTS (SELECT 1 FROM categories c WHERE c.id = s.id OR c.slug = s.slug); + +-- ── Étiquettes ────────────────────────────────────────────────────────────── +-- `tags` porte TROIS index uniques (id, name, slug). Cinq étiquettes du dump +-- existent déjà sous le même nom (1080p, BluRay, FLAC, 2160p, HEVC) : elles ne +-- sont pas dupliquées, leur ancien identifiant est REMAPPÉ sur celui en place, +-- sinon les 68 liaisons `torrent_tags` viseraient des lignes absentes. +CREATE TEMP TABLE tag_map AS +SELECT s.id AS old_id, COALESCE(m.id, s.id) AS new_id, (m.id IS NULL) AS fresh + FROM s_tags s + LEFT JOIN LATERAL ( + SELECT t.id FROM tags t + WHERE lower(t.slug) = lower(s.slug) OR lower(t.name) = lower(s.name) + LIMIT 1 + ) m ON true; + +INSERT INTO tags (id, name, slug, color, created_at) +SELECT s.id, s.name, s.slug, s.color, s.created_at + FROM s_tags s JOIN tag_map m ON m.old_id = s.id AND m.fresh; + +-- ── Torrents ──────────────────────────────────────────────────────────────── +INSERT INTO torrents ( + id, info_hash, name, size, description, torrent_data, uploader_id, category_id, + is_active, created_at, nfo, imdb_id, tmdb_id, tvdb_id, moderation_status, + moderated_by_id, moderated_at, igdb_id, openlibrary_id, content_signature, + updated_at, episode, federate_swarm, season) +SELECT s.id, s.info_hash, s.name, s.size, s.description, s.torrent_data, + (SELECT id FROM founder), s.category_id, + s.is_active, s.created_at, s.nfo, s.imdb_id, s.tmdb_id, s.tvdb_id, + s.moderation_status, + CASE WHEN s.moderated_by_id IS NULL THEN NULL ELSE (SELECT id FROM founder) END, + s.moderated_at, s.igdb_id, s.openlibrary_id, s.content_signature, + s.updated_at, s.episode, s.federate_swarm, s.season + FROM s_torrents s + WHERE NOT EXISTS (SELECT 1 FROM torrents t WHERE t.id = s.id OR t.info_hash = s.info_hash); + +-- ── Liaisons, commentaires, essaim ────────────────────────────────────────── +INSERT INTO torrent_tags (torrent_id, tag_id, created_at) +SELECT s.torrent_id, m.new_id, s.created_at + FROM s_torrent_tags s + JOIN tag_map m ON m.old_id = s.tag_id + WHERE EXISTS (SELECT 1 FROM torrents t WHERE t.id = s.torrent_id) + AND EXISTS (SELECT 1 FROM tags g WHERE g.id = m.new_id) +ON CONFLICT (torrent_id, tag_id) DO NOTHING; + +-- Les auteurs sont RÉPARTIS sur les comptes du harnais. Le dump n'en a qu'un, +-- et un fil où tout le monde est la même personne ne dit rien de la mise en +-- page d'un fil — c'est de la donnée de démonstration, pas une attribution. +WITH numbered AS ( + SELECT s.*, (row_number() OVER (ORDER BY s.created_at) - 1) AS rn + FROM s_comments s + WHERE EXISTS (SELECT 1 FROM torrents t WHERE t.id = s.torrent_id) +) +INSERT INTO torrent_comments (id, torrent_id, author_id, content, created_at, updated_at) +SELECT n.id, n.torrent_id, w.id, n.content, n.created_at, n.updated_at + FROM numbered n + JOIN who w ON w.n = n.rn % (SELECT count(*) FROM who) +ON CONFLICT (id) DO NOTHING; + +INSERT INTO torrent_stats (info_hash, seeders, leechers, completed, updated_at) +SELECT s.info_hash, s.seeders, s.leechers, s.completed, s.updated_at + FROM s_stats s + WHERE EXISTS (SELECT 1 FROM torrents t WHERE t.info_hash = s.info_hash) +ON CONFLICT (info_hash) DO UPDATE + SET seeders = excluded.seeders, leechers = excluded.leechers, + completed = excluded.completed, updated_at = excluded.updated_at; + +INSERT INTO torrent_favorites (user_id, torrent_id, created_at) +SELECT (SELECT id FROM founder), s.torrent_id, s.created_at + FROM s_favorites s + WHERE EXISTS (SELECT 1 FROM torrents t WHERE t.id = s.torrent_id) +ON CONFLICT (user_id, torrent_id) DO NOTHING; + +INSERT INTO torrent_moderation_messages (id, torrent_id, author_id, body, is_system, status_change, created_at) +SELECT s.id, s.torrent_id, + CASE WHEN s.author_id IS NULL THEN NULL ELSE (SELECT id FROM founder) END, + s.body, s.is_system, s.status_change, s.created_at + FROM s_modmsgs s + WHERE EXISTS (SELECT 1 FROM torrents t WHERE t.id = s.torrent_id) +ON CONFLICT (id) DO NOTHING; + +-- L'obligation de seed va sur `plainuser` : c'est le compte depuis lequel on +-- regarde une page en tant que simple membre, et la carte d'obligation ne se +-- rend que pour le membre concerné. +INSERT INTO hnr_tracking (id, user_id, torrent_id, downloaded_at, seed_time, + required_seed_time, is_hnr, is_exempt, completed_at, uploaded, downloaded) +SELECT s.id, (SELECT id FROM users WHERE username = 'plainuser' LIMIT 1), s.torrent_id, + s.downloaded_at, s.seed_time, s.required_seed_time, s.is_hnr, s.is_exempt, + s.completed_at, s.uploaded, s.downloaded + FROM s_hnr s + WHERE EXISTS (SELECT 1 FROM torrents t WHERE t.id = s.torrent_id) + AND EXISTS (SELECT 1 FROM users u WHERE u.username = 'plainuser') +ON CONFLICT (user_id, torrent_id) DO NOTHING; + +COMMIT; + +SELECT 'categories' AS t, count(*) FROM categories +UNION ALL SELECT 'tags', count(*) FROM tags +UNION ALL SELECT 'torrents', count(*) FROM torrents +UNION ALL SELECT 'torrent_tags', count(*) FROM torrent_tags +UNION ALL SELECT 'torrent_comments', count(*) FROM torrent_comments +UNION ALL SELECT 'torrent_stats', count(*) FROM torrent_stats +UNION ALL SELECT 'torrent_favorites', count(*) FROM torrent_favorites +UNION ALL SELECT 'mod_messages', count(*) FROM torrent_moderation_messages +UNION ALL SELECT 'hnr_tracking', count(*) FROM hnr_tracking; diff --git a/tests/e2e/lib.mjs b/tests/e2e/lib.mjs index e2890e69..ef1f8f55 100644 --- a/tests/e2e/lib.mjs +++ b/tests/e2e/lib.mjs @@ -4,6 +4,7 @@ * stack it owns. */ import { connect } from 'node:net'; +import { createHash } from 'node:crypto'; import { readFileSync } from 'node:fs'; export const API = process.env.E2E_API ?? 'http://localhost:54000'; @@ -157,3 +158,95 @@ export function caller(S) { return { status: res.status, body: json, headers: res.headers }; }; } + +// ── Le tracker ─────────────────────────────────────────────────────── +// +// Aucun scénario n'annonce, mais deux scripts de remplissage le font, et une +// deuxième copie de ces trente lignes aurait dérivé de la première. Le tracker +// est un service de la pile depuis que `docker-compose.yml` le monte. + +export const TRACKER = process.env.E2E_TRACKER ?? 'http://localhost:54200'; + +/** + * Percent-encodage des octets bruts. + * + * `info_hash` et `peer_id` sont vingt octets binaires sur le fil, pas du + * texte : `encodeURIComponent` les corromprait en les traitant comme de + * l'UTF-8. + */ +export function pctEncodeBytes(buf) { + let s = ''; + for (const b of buf) { + const unreserved = + (b >= 0x30 && b <= 0x39) || + (b >= 0x41 && b <= 0x5a) || + (b >= 0x61 && b <= 0x7a) || + b === 0x2d || + b === 0x2e || + b === 0x5f || + b === 0x7e; + s += unreserved ? String.fromCharCode(b) : `%${b.toString(16).padStart(2, '0')}`; + } + return s; +} + +/** + * Un peer_id crédible, et STABLE pour une même graine. + * + * Le préfixe compte : `anticheat.Inspect` lève un drapeau quand un pair + * téléverse sous un préfixe BEP 20 inconnu ET un User-Agent qui ne ressemble + * à rien. On annonce donc comme qBittorrent, ce qui est aussi ce que fait un + * vrai pair. + * + * La stabilité est ce qui rend les scripts rejouables : la même graine + * réannonce le MÊME pair, donc l'essaim ne double pas à chaque relance. + */ +export function peerIdFor(seed) { + return Buffer.concat([ + Buffer.from('-qB4650-', 'ascii'), + createHash('sha1').update(seed).digest().subarray(0, 12), + ]); +} + +/** + * Une annonce, telle qu'un client l'émet. + * + * Renvoie `{ status, failure, text }`. `failure` est renseigné quand le + * tracker répond un échec — ce qu'il fait en bencode AVEC un 200 + * (`d14:failure reason…e`), donc un appelant qui ne regarde que le code HTTP + * compte ses refus comme des réussites. + */ +export async function announce( + passkey, + infoHashHex, + seed, + { left, uploaded = 0, downloaded = 0, event, port = 6881 }, +) { + const q = [ + `passkey=${passkey}`, + `info_hash=${pctEncodeBytes(Buffer.from(infoHashHex, 'hex'))}`, + `peer_id=${pctEncodeBytes(peerIdFor(seed))}`, + `port=${port}`, + `uploaded=${uploaded}`, + `downloaded=${downloaded}`, + `left=${left}`, + 'compact=1', + 'numwant=50', + ...(event ? [`event=${event}`] : []), + ].join('&'); + const res = await fetch(`${TRACKER}/announce?${q}`, { + headers: { 'user-agent': 'qBittorrent/4.6.5' }, + }); + const text = await res.text(); + const at = text.indexOf('14:failure reason'); + const failure = at < 0 ? null : text.slice(at + 17).replace(/^\d+:/, '').slice(0, 70); + return { status: res.status, failure, text: text.slice(0, 120) }; +} + +/** La passkey d'annonce d'un membre, par le même appel que la page /me. */ +export async function passkeyOf(cookie) { + const res = await fetch(`${API}/api/auth/passkey`, { headers: { cookie } }); + if (!res.ok) return null; + const body = await res.json().catch(() => null); + return body?.passkey ?? null; +} diff --git a/tests/e2e/run.sh b/tests/e2e/run.sh index 94db16d4..9c20946a 100755 --- a/tests/e2e/run.sh +++ b/tests/e2e/run.sh @@ -48,6 +48,22 @@ if [ "$BUILD" = "1" ]; then # separate process, so a suite that never boots it proves nothing about # the split. docker build -q -f "$ROOT/apps/relay/Dockerfile" -t trackarr-e2e-relay:local "$ROOT" + # Le tracker aussi. Aucun scénario n'annonce, donc il ne sert à aucune + # assertion — mais sans lui une pile gardée montre un essaim vide partout, et + # c'est précisément ce qu'on vient regarder à l'œil nu. Voir docker-compose.yml. + docker build -q -f "$ROOT/apps/tracker/Dockerfile" -t trackarr-e2e-tracker:local "$ROOT" +fi + +# La clé TMDb de l'exploitant, si le `.env` à la racine en porte une. +# +# Recopiée dans un fichier que le compose lit, et non exportée : une +# substitution `${TMDB_API_KEY}` se perd au premier `docker compose up` lancé +# depuis un terminal qui ne l'a pas — ce qui recrée `api` sans la clé, sans un +# mot. Le fichier, lui, est relu à chaque fois. +# +# Le fichier est ignoré par git (voir .gitignore) : il porte un secret. +if [ -f "$ROOT/.env" ]; then + sed -n 's/^\(TMDB_API_KEY=.*\)$/\1/p' "$ROOT/.env" | head -1 > "$HERE/tmdb.env" fi say "booting" @@ -133,7 +149,7 @@ for scenario in "$HERE"/*.mjs; do name="$(basename "$scenario" .mjs)" # `demo` et `forumTickets` remplissent une pile gardée pour l'essayer à la # main ; ce ne sont pas des scénarios et ils n'assertent rien. - case "$name" in seed|crypto|lib|demo|forumTickets) continue ;; esac + case "$name" in seed|crypto|lib|demo|demoTorrents|demoSwarm|forumTickets) continue ;; esac case " ${SCENARIOS[*]} " in *" $name "*) continue ;; esac echo "WARNING: $name.mjs is not in SCENARIOS and was not run" >&2 done From 22e9fdd5bcf735c83d2ba160e3f2725cc12be521 Mon Sep 17 00:00:00 2001 From: dim145 Date: Fri, 4 Sep 2026 22:04:34 +0200 Subject: [PATCH 02/28] feat(web): rebuild the torrent detail page around the reader's questions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` + + + + diff --git a/apps/web/app/components/torrent/detail/AdminSwarmTable.vue b/apps/web/app/components/torrent/detail/AdminSwarmTable.vue new file mode 100644 index 00000000..627d08bf --- /dev/null +++ b/apps/web/app/components/torrent/detail/AdminSwarmTable.vue @@ -0,0 +1,400 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/AdultGate.vue b/apps/web/app/components/torrent/detail/AdultGate.vue new file mode 100644 index 00000000..5a1abe26 --- /dev/null +++ b/apps/web/app/components/torrent/detail/AdultGate.vue @@ -0,0 +1,406 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/BuffsForm.vue b/apps/web/app/components/torrent/detail/BuffsForm.vue new file mode 100644 index 00000000..019aa566 --- /dev/null +++ b/apps/web/app/components/torrent/detail/BuffsForm.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/DecisionCard.vue b/apps/web/app/components/torrent/detail/DecisionCard.vue new file mode 100644 index 00000000..53988eea --- /dev/null +++ b/apps/web/app/components/torrent/detail/DecisionCard.vue @@ -0,0 +1,516 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/DownloadCta.vue b/apps/web/app/components/torrent/detail/DownloadCta.vue new file mode 100644 index 00000000..53425cf5 --- /dev/null +++ b/apps/web/app/components/torrent/detail/DownloadCta.vue @@ -0,0 +1,251 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/ExpandAllToggle.vue b/apps/web/app/components/torrent/detail/ExpandAllToggle.vue new file mode 100644 index 00000000..e05cd4fa --- /dev/null +++ b/apps/web/app/components/torrent/detail/ExpandAllToggle.vue @@ -0,0 +1,101 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/FederationSwarmToggle.vue b/apps/web/app/components/torrent/detail/FederationSwarmToggle.vue new file mode 100644 index 00000000..801a3c3b --- /dev/null +++ b/apps/web/app/components/torrent/detail/FederationSwarmToggle.vue @@ -0,0 +1,111 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/IdentityCard.vue b/apps/web/app/components/torrent/detail/IdentityCard.vue new file mode 100644 index 00000000..0df954e4 --- /dev/null +++ b/apps/web/app/components/torrent/detail/IdentityCard.vue @@ -0,0 +1,723 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/NfoPanel.vue b/apps/web/app/components/torrent/detail/NfoPanel.vue new file mode 100644 index 00000000..ca809f94 --- /dev/null +++ b/apps/web/app/components/torrent/detail/NfoPanel.vue @@ -0,0 +1,196 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/OperatorArea.vue b/apps/web/app/components/torrent/detail/OperatorArea.vue new file mode 100644 index 00000000..cf1869f5 --- /dev/null +++ b/apps/web/app/components/torrent/detail/OperatorArea.vue @@ -0,0 +1,253 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/ProvenanceRow.vue b/apps/web/app/components/torrent/detail/ProvenanceRow.vue new file mode 100644 index 00000000..fcc4186c --- /dev/null +++ b/apps/web/app/components/torrent/detail/ProvenanceRow.vue @@ -0,0 +1,277 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/QualityChips.vue b/apps/web/app/components/torrent/detail/QualityChips.vue new file mode 100644 index 00000000..d353e069 --- /dev/null +++ b/apps/web/app/components/torrent/detail/QualityChips.vue @@ -0,0 +1,298 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/RelatedReleases.vue b/apps/web/app/components/torrent/detail/RelatedReleases.vue new file mode 100644 index 00000000..8de44613 --- /dev/null +++ b/apps/web/app/components/torrent/detail/RelatedReleases.vue @@ -0,0 +1,536 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/SeedObligationCard.vue b/apps/web/app/components/torrent/detail/SeedObligationCard.vue new file mode 100644 index 00000000..c2fc51a6 --- /dev/null +++ b/apps/web/app/components/torrent/detail/SeedObligationCard.vue @@ -0,0 +1,277 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/StickyDock.vue b/apps/web/app/components/torrent/detail/StickyDock.vue new file mode 100644 index 00000000..7721288b --- /dev/null +++ b/apps/web/app/components/torrent/detail/StickyDock.vue @@ -0,0 +1,212 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/SupersedeForm.vue b/apps/web/app/components/torrent/detail/SupersedeForm.vue new file mode 100644 index 00000000..853cc6a9 --- /dev/null +++ b/apps/web/app/components/torrent/detail/SupersedeForm.vue @@ -0,0 +1,204 @@ + + + + + diff --git a/apps/web/app/components/torrent/detail/TorrentComments.vue b/apps/web/app/components/torrent/detail/TorrentComments.vue new file mode 100644 index 00000000..44d07b0c --- /dev/null +++ b/apps/web/app/components/torrent/detail/TorrentComments.vue @@ -0,0 +1,510 @@ + + +