Skip to content

fix: merge duplicate rows instead of dropping the losers' trackers - #175

Merged
baairon merged 1 commit into
baairon:mainfrom
ugurckr:fix/merge-duplicate-trackers
Aug 28, 2026
Merged

fix: merge duplicate rows instead of dropping the losers' trackers#175
baairon merged 1 commit into
baairon:mainfrom
ugurckr:fix/merge-duplicate-trackers

Conversation

@ugurckr

@ugurckr ugurckr commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

What and why

dedupe() in useConcurrentSearch.ts kept the row with the most seeders and dropped the rest:

const existing = byHash.get(r.infoHash);
if (!existing || r.seeders > existing.seeders) byHash.set(r.infoHash, r);

Dropping the row also drops its magnet, and a magnet's announce list is part of what that source knew about the torrent:

  • 1337x takes its magnet straight off the detail page (x1337.ts:74) and EZTV uses the API's magnet_url (eztv.ts:36) — both carry the torrent's own trackers.
  • TPB, YTS, Nyaa rows are assembled by buildMagnet(), which writes only torlink's eleven public defaults.

So whenever those overlap — and TV and Movies overlap constantly, that is what dedupe is for — the higher-seeder row wins and can trade a working announce list for a generic one. buildMagnet already states the principle this breaks:

// extraTrackers come first and win on duplicates: a torrent that carries its own
// announce list means that list, and the public defaults are only a fallback.

It is the same loss #146 fixed for a .torrent file's own trackers, arriving by a different route. Two rows for one infohash is exactly the case where torlink has more information than either source alone, and the old code was the only place that spent it.

numFiles and added go the same way — EZTV reports date_released_unix and 1337x parses an upload date, so a YTS row with neither can win and leave the Added column blank for a torrent whose date was right there.

What changed

The winner is chosen exactly as before, and still supplies every visible field. The rows it beats are folded into it rather than discarded:

function merge(a: TorrentResult, b: TorrentResult): TorrentResult {
  const [win, lost] = b.seeders > a.seeders ? [b, a] : [a, b];
  return {
    ...win,
    magnet: mergeMagnetTrackers(win.magnet, [lost.magnet]),
    numFiles: win.numFiles ?? lost.numFiles,
    added: win.added ?? lost.added,
  };
}

??, so a backfill only fills a genuine gap — a field the winner never reported is missing information, not a decision to overrule.

mergeMagnetTrackers lives in src/sources/magnet.ts next to buildMagnet, and appends the missing tr values rather than rebuilding the URI. That keeps the winning magnet byte for byte, so parameters torlink does not write itself survive — SubsPlease magnets carry xl, and rebuilding through buildMagnet() would have silently dropped it. Order is not a concern: every tr in a magnet lands in one announce list and the client contacts all of them.

Anything that is not a magnet is returned untouched, so a malformed URI from a source can never be corrupted further.

Why dedupe moved file

dedupe was a private function inside a React hook, which is why it had no test. It is a pure result-list transform, so it now sits in src/ui/dedupe.ts beside src/ui/sort.ts and src/ui/filter.ts — the two functions of exactly the same shape — and the hook keeps one import. No behaviour rides on the move.

Tests

src/ui/dedupe.test.ts (new): distinct hashes are untouched; the healthiest row still supplies name/source/seeders; a losing 1337x row's private-tracker URL survives into the winning magnet; three sources on one hash produce one row and no repeated tracker; numFiles/added backfill only into gaps; a seeder tie keeps the first row, as before.

src/sources/magnet.test.ts: mergeMagnetTrackers appends only what is missing, returns the primary unchanged when there is nothing to add, preserves xl, and leaves non-magnets alone.

npm run typecheck   clean
npm test            46 files, 322 tests, all passing

Checklist

  • npm run typecheck is clean
  • npm test passes
  • New logic has a test (vitest; mock node built-ins for platform code)
  • If I added a key, I updated both HELP_GROUPS and footerHints in src/ui/keymap.ts — n/a, no new key
  • If I added a Store field, I updated makeStore in scripts/render-previews-impl.tsx — n/a, no new Store field
  • OS-touching code works on Windows, macOS, and Linux — n/a, pure logic
  • One concern, with a Conventional Commits title (feat: / fix: / docs: / chore:)

When the same infohash arrives from more than one source, dedupe kept
the row with the most seeders and threw the rest away. That also threw
away their magnets, and a magnet's announce list is part of what a
source knows about the torrent: 1337x and EZTV hand back the torrent's
own trackers, while rows built through buildMagnet() carry only
torlink's eleven public defaults. So a 1337x row losing on seeders to a
TPB row traded a working announce list for a generic one -- the same
loss baairon#146 fixed for a .torrent file's own trackers, arriving by a
different route.

The healthiest row still wins and still supplies every visible field.
What changes is that the rows it beats are folded into it: their
trackers are appended to its magnet, and numFiles / added are backfilled
only where the winner reported nothing. Appending, rather than rebuilding
through buildMagnet(), keeps the winning URI byte for byte, so magnet
parameters torlink does not write (xl on SubsPlease rows) survive.

dedupe moves to src/ui/dedupe.ts next to sort.ts and filter.ts, the two
other pure result-list transforms, so it can be tested directly.
@baairon
baairon merged commit 2ede3e4 into baairon:main Aug 28, 2026
9 checks passed
baairon added a commit to funsaized/torlink that referenced this pull request Aug 28, 2026
The branch added its own dedupe in src/sources/results.ts. baairon#175 landed
dedupeResults in src/ui/dedupe.ts first, and that version folds the
beaten rows' announce lists into the survivor instead of discarding
them, so the JSON document carries merged trackers by using it.

defaultOrder moves from useConcurrentSearch into src/ui/sort.ts, beside
sortResults whose "none" case already preserved that order. The TUI and
the headless command now order through one function, so the two cannot
drift apart.

src/index.tsx keeps its existing indentation. The branch re-indented the
whole TUI block, which restated the alt-screen, signal-handler and
uncaughtException paths in the diff without changing any of them.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants