The Rust worker is the product: edge/, built and checked in CI
Test and deploy / test (push) Successful in 46s
Test and deploy / edge (push) Successful in 1m54s
Test and deploy / deploy (push) Successful in 2m56s

User: Rust is the product (option a). tools/edge-bench-rs moves to edge/
(crate edge; binaries edge-node, the worker, and edge-bench, the replay).
CI's new job edge runs cargo test and edge/check-fixtures.sh: both
RangeIndexers on three committed captures (edge/fixtures, 25k overview
lines each of a.b.models, a.b.ath and a.b.boneless in 5 chunks, 3.5 MB),
compared field by field with check.ts; deploy waits for it. The
TypeScript RangeIndexer stays as check.ts's reference. README: what it
is now; the benchmark findings as history.

Random names: looksRandom missed long tokens of capitals and digits
("51294P1CUKR2R35X", release 74539068, readable and a Newznab title).
12+ capitals and digits switching 6+ times between them are random
(COD4MW2REMASTERED switches 4 times, BEATLES1964REMASTER twice); both
implementations, identical on the fixtures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Daan MeijerandClaude Opus 5.5 committed 2026-10-11 20:01:18 +02:00
1 parent 48f4e982f9
commit 5109481d67
38 files changed
+138 -54

No files matched your search

+1 -1
View File
@@ -3,4 +3,4 @@ build
tmp tmp
.env .env
.git .git
tools/edge-bench-rs/target edge/target
+21 -1
View File
@@ -66,8 +66,28 @@ jobs:
- name: Test - name: Test
run: node ace test run: node ace test
# The edge worker (edge/, Rust): its tests, and the Rust and TypeScript
# RangeIndexers compared on the committed captures (edge/fixtures).
edge:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- name: Rust toolchain
run: |
command -v cargo || curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
echo "$HOME/.cargo/bin" >> "$GITHUB_PATH"
- name: cargo test
run: cargo test --release --manifest-path edge/Cargo.toml
- name: Rust and TypeScript identical on the fixtures
run: edge/check-fixtures.sh
deploy: deploy:
needs: test needs: [test, edge]
if: github.ref == 'refs/heads/rebuild' if: github.ref == 'refs/heads/rebuild'
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
+2 -2
View File
@@ -2,7 +2,7 @@
# #
# Voyager 2's images (deploy/compose.yaml): # Voyager 2's images (deploy/compose.yaml):
# --target app the AdonisJS app: web server, coordinator, migrations, ace commands # --target app the AdonisJS app: web server, coordinator, migrations, ace commands
# --target edge the Rust edge worker (tools/edge-bench-rs, bin edge-node) # --target edge the Rust edge worker (edge/, bin edge-node)
# Node is pinned as in CI (.nvmrc): on 24.21, jsonschema 1.5.0 (used by Ace to # Node is pinned as in CI (.nvmrc): on 24.21, jsonschema 1.5.0 (used by Ace to
# validate commands) throws "Invalid URL" on $ref, so no ace command starts. # validate commands) throws "Invalid URL" on $ref, so no ace command starts.
@@ -29,7 +29,7 @@ CMD ["node", "ace", "list"]
# The Rust worker. Registry and target dir are build caches, so a rebuild only compiles what changed. # The Rust worker. Registry and target dir are build caches, so a rebuild only compiles what changed.
FROM rust:1-bookworm AS edge-build FROM rust:1-bookworm AS edge-build
WORKDIR /src WORKDIR /src
COPY tools/edge-bench-rs ./ COPY edge ./
RUN --mount=type=cache,target=/usr/local/cargo/registry \ RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/src/target \ --mount=type=cache,target=/src/target \
cargo build --release --locked --bin edge-node \ cargo build --release --locked --bin edge-node \
+2 -2
View File
@@ -119,7 +119,7 @@ dropping garbage; 28 TB of compressed overview traffic. Throughput goal:
2. **Edge worker** (node, TypeScript reusing `lib/`). Measured (`edge:bench`, 2. **Edge worker** (node, TypeScript reusing `lib/`). Measured (`edge:bench`,
2026-10-09, after two rounds of work): ~200–215 k headers/s per core on 2026-10-09, after two rounds of work): ~200–215 k headers/s per core on
recent a.b.boneless, ~320–340 k on a 2013 a.b.ath range, ~0.7 GB peak recent a.b.boneless, ~320–340 k on a 2013 a.b.ath range, ~0.7 GB peak
per process. **Rust** (`tools/edge-bench-rs`, the same RangeIndexer path per process. **Rust** (`edge/`, the same RangeIndexer path
over the same captures; `check.ts` finds every output identical, the over the same captures; `check.ts` finds every output identical, the
decoded payloads included): 3.5–3.8× less CPU (boneless 2.9 vs 11.1 decoded payloads included): 3.5–3.8× less CPU (boneless 2.9 vs 11.1
CPU-s per 2 M headers, ath 2.15 vs 7.5) and a third of the memory CPU-s per 2 M headers, ath 2.15 vs 7.5) and a third of the memory
@@ -130,7 +130,7 @@ dropping garbage; 28 TB of compressed overview traffic. Throughput goal:
cost: a second implementation of lib/headers, lib/release, lib/rules and cost: a second implementation of lib/headers, lib/release, lib/rules and
the chunk format, kept identical (check.ts) for as long as both exist. the chunk format, kept identical (check.ts) for as long as both exist.
Findings, how to run both and how to compare their outputs: Findings, how to run both and how to compare their outputs:
`tools/edge-bench-rs/README.md`. Since 2026-10-09 the Rust worker `edge/README.md`. Since 2026-10-09 the Rust worker
(`edge-node`, same crate) is the one running on homelab: ~8× less CPU (`edge-node`, same crate) is the one running on homelab: ~8× less CPU
per header and 20× less memory than `edge:node` in production. per header and 20× less memory than `edge:node` in production.
Per lease: compressed chunks of 10–50 k lines, in order → Per lease: compressed chunks of 10–50 k lines, in order →
File renamed without changes.
+1 -1
View File
@@ -141,7 +141,7 @@ dependencies = [
] ]
[[package]] [[package]]
name = "edge-bench-rs" name = "edge"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
@@ -1,8 +1,16 @@
[package] [package]
name = "edge-bench-rs" name = "edge"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
description = "The edge worker's indexing (lib/edge RangeIndexer) in Rust, to compare with edge:bench on captured ranges" description = "Voyager 2's edge worker (edge-node) and its benchmark (edge-bench): the RangeIndexer in Rust, kept identical to lib/edge (check.ts)"
[[bin]]
name = "edge-bench"
path = "src/main.rs"
[[bin]]
name = "edge-node"
path = "src/bin/edge-node.rs"
[dependencies] [dependencies]
base64 = "0.22" base64 = "0.22"
@@ -1,36 +1,40 @@
# edge-bench-rs: the edge worker's indexing in Rust # edge: voyager 2's edge worker (Rust)
Since 2026-10-09 this crate is also the production worker: `edge-node` The product: `edge-node` (`src/bin/edge-node.rs`) is the worker that runs
(`src/bin/edge-node.rs`) leases ranges from the coordinator and posts on homelab (compose service `edge`, image target `edge`). It leases ranges
batches like `node ace edge:node`, with the same indexing as the from the node API, reads their prefetched XOVER chunks, finds releases and
benchmark (`src/lib.rs`). On homelab it indexed 122M headers in 10 posts batches. `edge-bench` (`src/main.rs`) replays a captured range through
minutes with 0.37 cores and 565 MB, next to the TypeScript workers' 24M the same indexing (`src/lib.rs`) for timing and for comparing.
with 0.61 cores and 11.3 GB (8 threads vs 8 processes): about 8× less
CPU per header and 20× less memory. See "The worker" below.
A measurement, not a product. It answers whether a Rust edge worker would be The TypeScript `RangeIndexer` (`lib/edge`, `node ace edge:node`) is the
worth a second implementation of the TypeScript one (`edge:node`, REBUILD.md reference: `check.ts` runs both on a capture and compares every output field
part 2). It ports the path that `edge:bench` replays over a captured range: by field. An identity or rule change goes into both and has to come out
identical. CI (`.gitea/workflows/deploy.yaml`, job `edge`) runs `cargo test`
and `check-fixtures.sh`, which compares both on the committed captures in
`fixtures/` (25k overview lines each of a.b.models, a.b.ath and
a.b.boneless, in 5 chunks); a push to `rebuild` deploys only when they
match.
- inflate each XOVER chunk; ```sh
- scan the overview lines (no object per line); cargo test --release --manifest-path edge/Cargo.toml
- apply the owner rule from the Xref; edge/check-fixtures.sh # from the repository root
- build segments (file key, release identity); ```
- aggregate segments into files, and cut off idle ones as fragments,
per-article or incomplete files;
- every N chunks, judge the completed releases (rules: executables,
readable names, garbage tools and signatures, per-article random
subjects), encode their chunks (format v2) and pick probe candidates.
It does not port NNTP, the coordinator API, batch posting or anything on the In production since 2026-10-09: 122M headers in 10 minutes with 0.37 cores
server side. and 565 MB, next to the TypeScript workers' 24M with 0.61 cores and 11.3 GB
(8 threads vs 8 processes): about 8× less CPU per header and 20× less
memory. See "The worker" below.
It started as a measurement (moved from `tools/edge-bench-rs` on
2026-10-11): whether a Rust worker would be worth a second implementation.
The findings below are from then.
## Findings (2026-10-09) ## Findings (2026-10-09)
Same machine (the workstation, otherwise idle), same captures, 2M headers Same machine (the workstation, otherwise idle), same captures, 2M headers
each: each:
| Capture | TypeScript (`edge:bench`) | Rust (`edge-bench-rs`) | Less CPU | | Capture | TypeScript (`edge:bench`) | Rust (`edge-bench`) | Less CPU |
|---|---|---|---| |---|---|---|---|
| a.b.boneless 2026 (160950000000–160951999999) | 11.1 CPU-s (~215k headers/s), 0.70 GB peak | 2.9 CPU-s (~690k/s), 0.24 GB | 3.8× | | a.b.boneless 2026 (160950000000–160951999999) | 11.1 CPU-s (~215k headers/s), 0.70 GB peak | 2.9 CPU-s (~690k/s), 0.24 GB | 3.8× |
| a.b.boneless 2026 (160952000000–160953999999) | 11.8 CPU-s (~205k/s), 0.70 GB | 3.1 CPU-s (~640k/s), 0.24 GB | 3.8× | | a.b.boneless 2026 (160952000000–160953999999) | 11.8 CPU-s (~205k/s), 0.70 GB | 3.1 CPU-s (~640k/s), 0.24 GB | 3.8× |
@@ -92,8 +96,8 @@ Benchmark both on it:
```sh ```sh
node ace edge:bench --dir /path/to/capture # TypeScript: headers/s, CPU, inflate/index, peak RSS node ace edge:bench --dir /path/to/capture # TypeScript: headers/s, CPU, inflate/index, peak RSS
cd tools/edge-bench-rs && cargo build --release cd edge && cargo build --release
./target/release/edge-bench-rs /path/to/capture # Rust: the same summary ./target/release/edge-bench /path/to/capture # Rust: the same summary
``` ```
Both take results every 10 chunks (`--batch-chunks`) and give a file up after Both take results every 10 chunks (`--batch-chunks`) and give a file up after
@@ -109,8 +113,8 @@ themselves.
## Comparing the output ## Comparing the output
```sh ```sh
./target/release/edge-bench-rs /path/to/capture --dump /tmp/out.jsonl ./target/release/edge-bench /path/to/capture --dump /tmp/out.jsonl
cd ../.. && node --import=@poppinss/ts-exec tools/edge-bench-rs/check.ts /path/to/capture /tmp/out.jsonl cd .. && node --import=@poppinss/ts-exec edge/check.ts /path/to/capture /tmp/out.jsonl
``` ```
`--dump` writes every output of every batch as JSON lines: `--dump` writes every output of every batch as JSON lines:
@@ -223,12 +227,12 @@ COORDINATOR_URL=http://coordinator:3334 NODE_TOKEN=… ./target/release/edge-nod
node's download cap, shared by its threads. Plus a heartbeat when no node's download cap, shared by its threads. Plus a heartbeat when no
batch went out for a minute. batch went out for a minute.
- **Wire format:** `src/wire.rs` writes a batch as `toWire` does. - **Wire format:** `src/wire.rs` writes a batch as `toWire` does.
`edge-bench-rs --wire` and `check.ts --wire` compare it with the `edge-bench --wire` and `check.ts --wire` compare it with the
TypeScript's, field by field. TypeScript's, field by field.
- **End to end:** both workers indexed the a.b.models capture (spooled - **End to end:** both workers indexed the a.b.models capture (spooled
as a range) through the web server; what the server stored was the as a range) through the web server; what the server stored was the
same (releases with every column, chunks, candidates, rule counts, same (releases with every column, chunks, candidates, rule counts,
fragments with their JSON). fragments with their JSON).
- **On homelab:** container `usenet-rebuild-edge-rs` runs - **On homelab:** compose service `edge` (deploy/compose.yaml), the
`/mnt/bulk/usenet-rebuild/bin/edge-node --threads 8` in `edge` image target of the Dockerfile, `--threads $EDGE_THREADS` (8);
node:22-bookworm-slim (the binary needs glibc 2.34+). it talks to `node-api`.
+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
# The Rust and TypeScript RangeIndexers on the committed captures (edge/fixtures: 25k
# overview lines each of a.b.models, a.b.ath and a.b.boneless, in 5 chunks), compared
# by check.ts field by field: exits non-zero on any difference. CI runs it; run it from
# the repository root after any change to either side.
set -eu
cd "$(dirname "$0")/.."
cargo build --release --quiet --manifest-path edge/Cargo.toml --bin edge-bench
out=$(mktemp -d)
trap 'rm -rf "$out"' EXIT
status=0
for capture in edge/fixtures/*/; do
name=$(basename "$capture")
edge/target/release/edge-bench "$capture" --batch-chunks 2 --idle 2 --dump "$out/$name.jsonl" --wire "$out/$name.wire.jsonl" >/dev/null 2>&1
echo "== $name"
node --import=@poppinss/ts-exec edge/check.ts "$capture" "$out/$name.jsonl" --wire "$out/$name.wire.jsonl" --batch-chunks 2 --idle 2 || status=1
done
exit $status
@@ -1,12 +1,12 @@
/** /**
* Compares edge-bench-rs's output (--dump) with the TypeScript RangeIndexer * Compares edge-bench's output (--dump) with the TypeScript RangeIndexer
* on the same captured range, item by item: releases (every field, and the * on the same captured range, item by item: releases (every field, and the
* payload decoded with lib/chunks), samples, probe candidates, rule counts, * payload decoded with lib/chunks), samples, probe candidates, rule counts,
* fragments and batch boundaries. * fragments and batch boundaries.
* *
* node --import=@poppinss/ts-exec tools/edge-bench-rs/check.ts <dir> <dump.jsonl> [--wire batches.jsonl] [--batch-chunks 10] [--idle 5] * node --import=@poppinss/ts-exec edge/check.ts <dir> <dump.jsonl> [--wire batches.jsonl] [--batch-chunks 10] [--idle 5]
* *
* With --wire (edge-bench-rs --wire): also each batch as the Rust worker * With --wire (edge-bench --wire): also each batch as the Rust worker
* posts it, against toWire of the TypeScript batch, field by field. * posts it, against toWire of the TypeScript batch, field by field.
*/ */
import { readFile, readdir } from 'node:fs/promises' import { readFile, readdir } from 'node:fs/promises'
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"group":"alt.binaries.ath","start":2549992990,"end":2550017989}
+1
View File
@@ -0,0 +1 @@
{"group":"alt.binaries.boneless","start":160950000000,"end":160950024999}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"group":"alt.binaries.models","start":2017215,"end":2042214}
@@ -1,6 +1,6 @@
//! The edge worker in Rust (REBUILD.md, part 2; commands/edge_node.ts): leases //! The edge worker in Rust (REBUILD.md, part 2; commands/edge_node.ts): leases
//! ranges from the coordinator, reads their XOVER chunks from its stream, //! ranges from the coordinator, reads their XOVER chunks from its stream,
//! indexes them (the same RangeIndexer as edge-bench-rs, kept identical to //! indexes them (the same RangeIndexer as edge-bench, kept identical to
//! the TypeScript by check.ts) and posts the batches. One range per thread. //! the TypeScript by check.ts) and posts the batches. One range per thread.
//! //!
//! COORDINATOR_URL=… NODE_TOKEN=… edge-node [--threads N] [--batch-articles 500000] [--ranges N] [--max-mbit M] //! COORDINATOR_URL=… NODE_TOKEN=… edge-node [--threads N] [--batch-articles 500000] [--ranges N] [--max-mbit M]
@@ -8,9 +8,9 @@
//! SIGTERM/SIGINT: each thread gives its range back after the current chunk //! SIGTERM/SIGINT: each thread gives its range back after the current chunk
//! (the next worker resumes at the last stored batch). //! (the next worker resumes at the last stored batch).
use edge_bench_rs::indexer::{Batch, RangeIndexer}; use edge::indexer::{Batch, RangeIndexer};
use edge_bench_rs::names::Rules; use edge::names::Rules;
use edge_bench_rs::wire; use edge::wire;
use flate2::write::GzEncoder; use flate2::write::GzEncoder;
use serde::Deserialize; use serde::Deserialize;
use std::collections::HashMap; use std::collections::HashMap;
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -1,5 +1,5 @@
//! The edge worker's indexing (lib/edge RangeIndexer and what it uses) in //! The edge worker's indexing (lib/edge RangeIndexer and what it uses) in
//! Rust, kept identical to the TypeScript (check.ts): the edge-bench-rs //! Rust, kept identical to the TypeScript (check.ts): the edge-bench
//! benchmark and the edge-node worker share it. //! benchmark and the edge-node worker share it.
pub mod codec; pub mod codec;
@@ -2,14 +2,14 @@
//! --capture` (REBUILD.md, part 2: TypeScript or Rust). Same work as //! --capture` (REBUILD.md, part 2: TypeScript or Rust). Same work as
//! edge:bench's replay: inflate each chunk, scan it, aggregate files, cut off //! edge:bench's replay: inflate each chunk, scan it, aggregate files, cut off
//! idle ones, and every N chunks judge and encode the releases. `--dump` //! idle ones, and every N chunks judge and encode the releases. `--dump`
//! writes every output as JSON lines for tools/edge-bench-rs/check.ts. //! writes every output as JSON lines for edge/check.ts.
//! //!
//! edge-bench-rs <dir> [--batch-chunks 10] [--idle 5] [--dump out.jsonl] [--wire batches.jsonl] //! edge-bench <dir> [--batch-chunks 10] [--idle 5] [--dump out.jsonl] [--wire batches.jsonl]
use base64::Engine; use base64::Engine;
use edge_bench_rs::indexer::{self, Batch, RangeIndexer}; use edge::indexer::{self, Batch, RangeIndexer};
use edge_bench_rs::js::json_str; use edge::js::json_str;
use edge_bench_rs::{names, wire}; use edge::{names, wire};
use rustc_hash::FxHashMap; use rustc_hash::FxHashMap;
use std::io::Write; use std::io::Write;
use std::time::Instant; use std::time::Instant;
@@ -150,7 +150,7 @@ fn dump_batch(batch: &Batch, out: &mut impl Write) {
fn main() { fn main() {
let args: Vec<String> = std::env::args().collect(); let args: Vec<String> = std::env::args().collect();
let dir = args.get(1).expect("usage: edge-bench-rs <dir> [--batch-chunks N] [--idle N] [--dump file]").clone(); let dir = args.get(1).expect("usage: edge-bench <dir> [--batch-chunks N] [--idle N] [--dump file]").clone();
let flag = |name: &str| args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned(); let flag = |name: &str| args.iter().position(|a| a == name).and_then(|i| args.get(i + 1)).cloned();
let batch_chunks: usize = flag("--batch-chunks").map(|v| v.parse().unwrap()).unwrap_or(10); let batch_chunks: usize = flag("--batch-chunks").map(|v| v.parse().unwrap()).unwrap_or(10);
let idle: u64 = flag("--idle").map(|v| v.parse().unwrap()).unwrap_or(5); let idle: u64 = flag("--idle").map(|v| v.parse().unwrap()).unwrap_or(5);
@@ -591,6 +591,11 @@ pub fn looks_random(name: &str) -> bool {
if (8..=15).contains(&b.len()) && alnum && n_digit > 0 && n_lower > 0 && b[1..].iter().filter(|c| c.is_ascii_uppercase()).count() >= 3 { if (8..=15).contains(&b.len()) && alnum && n_digit > 0 && n_lower > 0 && b[1..].iter().filter(|c| c.is_ascii_uppercase()).count() >= 3 {
return true; return true;
} }
// 51294P1CUKR2R35X: capitals and digits only, switching between them all over the token
// (COD4MW2REMASTERED switches 4 times, BEATLES1964REMASTER twice).
if b.len() >= 12 && b.iter().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) && b.windows(2).filter(|w| w[0].is_ascii_digit() != w[1].is_ascii_digit()).count() >= 6 {
return true;
}
if !(b.len() >= 16 && alnum) || n_lower == 0 || n_upper == 0 { if !(b.len() >= 16 && alnum) || n_lower == 0 || n_upper == 0 {
return false; return false;
} }
@@ -985,6 +990,9 @@ mod tests {
fn random_names() { fn random_names() {
assert!(looks_random("prvjr7hapd0i1ny14pz4")); assert!(looks_random("prvjr7hapd0i1ny14pz4"));
assert!(looks_random("RmISakytFwXSCEzjVEpaKk")); assert!(looks_random("RmISakytFwXSCEzjVEpaKk"));
assert!(looks_random("51294P1CUKR2R35X"));
assert!(!looks_random("COD4MW2REMASTERED"));
assert!(!looks_random("BEATLES1964REMASTER"));
assert!(!looks_random("Some.Show.S01E01.1080p")); assert!(!looks_random("Some.Show.S01E01.1080p"));
assert!(looks_random("VIDEO_TS")); assert!(looks_random("VIDEO_TS"));
} }
File renamed without changes.
+14
View File
@@ -5,6 +5,17 @@ import { isGenericName } from './identity.js'
* separators, and the other generated shapes seen on Usenet (LOG.md * separators, and the other generated shapes seen on Usenet (LOG.md
* 2026-10-01 (3)). Kept in step with SQL's is_readable_name(). * 2026-10-01 (3)). Kept in step with SQL's is_readable_name().
*/ */
/** How often a token goes from a letter to a digit or back. */
function letterDigitSwitches(token: string): number {
let switches = 0
for (let i = 1; i < token.length; i++) {
const a = token.charCodeAt(i - 1) <= 57
const b = token.charCodeAt(i) <= 57
if (a !== b) switches++
}
return switches
}
export function looksRandom(name: string): boolean { export function looksRandom(name: string): boolean {
// An unknown short extension (e.g. ".bin") says nothing about the name. // An unknown short extension (e.g. ".bin") says nothing about the name.
name = name.replace(/\.[A-Za-z0-9]{1,4}$/, '') name = name.replace(/\.[A-Za-z0-9]{1,4}$/, '')
@@ -21,6 +32,9 @@ export function looksRandom(name: string): boolean {
if (/^[a-z0-9]{12,}$/.test(name) && (name.match(/\d/g)?.length ?? 0) >= 2 && (name.match(/[a-z]/g)?.length ?? 0) >= 4) return true if (/^[a-z0-9]{12,}$/.test(name) && (name.match(/\d/g)?.length ?? 0) >= 2 && (name.match(/[a-z]/g)?.length ?? 0) >= 4) return true
// wHJ7s71sAGE: short, but capitals all over the token, not only at word starts // wHJ7s71sAGE: short, but capitals all over the token, not only at word starts
if (/^[A-Za-z0-9]{8,15}$/.test(name) && /\d/.test(name) && /[a-z]/.test(name) && (name.slice(1).match(/[A-Z]/g)?.length ?? 0) >= 3) return true if (/^[A-Za-z0-9]{8,15}$/.test(name) && /\d/.test(name) && /[a-z]/.test(name) && (name.slice(1).match(/[A-Z]/g)?.length ?? 0) >= 3) return true
// 51294P1CUKR2R35X: capitals and digits only, switching between them all over the token
// (COD4MW2REMASTERED switches 4 times, BEATLES1964REMASTER twice).
if (/^[A-Z0-9]{12,}$/.test(name) && letterDigitSwitches(name) >= 6) return true
if (!/^[A-Za-z0-9]{16,}$/.test(name) || !/[a-z]/.test(name) || !/[A-Z]/.test(name)) return false if (!/^[A-Za-z0-9]{16,}$/.test(name) || !/[a-z]/.test(name) || !/[A-Z]/.test(name)) return false
// Digits, or many capitals without separators (e.g. RmISakytFwXSCEzjVEpaKk). // Digits, or many capitals without separators (e.g. RmISakytFwXSCEzjVEpaKk).
return /\d/.test(name) || (name.match(/[A-Z]/g) ?? []).length >= 6 return /\d/.test(name) || (name.match(/[A-Z]/g) ?? []).length >= 6
+9
View File
@@ -78,6 +78,15 @@ test.group('release / releaseIdentity', () => {
assert.equal(a.key, b.key) assert.equal(a.key, b.key)
}) })
test('capitals and digits switching all over a token are random', async ({ assert }) => {
const { looksRandom } = await import('#lib/release/index')
assert.isTrue(looksRandom('51294P1CUKR2R35X'))
assert.isTrue(looksRandom('51294P1CUKR2R35X.part01.rar'.replace(/\.part01\.rar$/, '')))
assert.isFalse(looksRandom('COD4MW2REMASTERED'))
assert.isFalse(looksRandom('BEATLES1964REMASTER'))
assert.isFalse(looksRandom('HDTV1080P'))
})
test('random extensionless names cannot be grouped by name', ({ assert }) => { test('random extensionless names cannot be grouped by name', ({ assert }) => {
const name = '204b914a977e5f891590ff1099c7a8caf1a0be537bfa5bf7f3a8b1213231' const name = '204b914a977e5f891590ff1099c7a8caf1a0be537bfa5bf7f3a8b1213231'
assert.isNull(releaseIdentity({ poster: 'x', subject: `"${name}" yEnc`, name, weak: true })) assert.isNull(releaseIdentity({ poster: 'x', subject: `"${name}" yEnc`, name, weak: true }))