foxygit / Torlinkc Log in
commit e6977c6c790a594d2635265d9591be1eff0618df
Author:     MrJensK <jens.se@icloud.com>
AuthorDate: Sun Aug 23 11:04:08 2026 +0200
Commit:     MrJensK <jens.se@icloud.com>
CommitDate: Sun Aug 23 11:04:08 2026 +0200

    Phase 3: full search parity across all 10 sources

    Ports the remaining 7 scrapers, replaces Phase 2's single-source
    SearchRunner with a real multi-source SearchAggregator, and adds category
    tabs, the sort cycle, and dead-seeder filtering to the TUI -- the plan's
    "full search/sources parity" milestone.

    - sources/: yts, x1337 (movies+tv, HTML scraping with mirror-host fallback
      and per-result detail-page fetches), eztv, nyaa (custom RSS), subsplease
      (resolution-preference fallback), bittorrented, fitgirl+rss (shared
      WordPress RSS parser). registry.cpp assembles all 10 into allSources()/
      sourceById(), plus Category enum + sourceInCategory() for the tab filter.
    - util/net.cpp: real cancellation, not cosmetic -- FetchOptions::stopToken
      is wired into libcurl's transfer-progress callback (aborts a request
      in-flight, not just between retries) and cuts backoff sleeps short. This
      is what lets SearchAggregator's search() replace a still-running search's
      threads by actually interrupting them, instead of blocking the UI thread
      on join() until curl's own timeout.
    - util/date_parse.{hpp,cpp}: curl_getdate() doesn't parse ISO 8601
      ("2024-01-15T00:00:00Z", used by bittorrented/subsplease's JSON APIs) --
      only RFC 822/1123 (RSS <pubDate>). Found via a failing test, not manual
      testing. Tries ISO 8601 first, falls back to curl_getdate.
    - ui/search_aggregator.*: one std::jthread per source (matching
      useConcurrentSearch.ts's fan-out), a mutex-guarded accumulator, and a
      CoalescingNotifier (150ms trailing-edge flush, immediate on the last
      source finishing) -- replaces search_runner.*.
    - ui/coalescing_notifier.*: the reusable debounce primitive the plan
      designed for this and for Phase 4's queue-update pipeline.
    - ui/filter.cpp, ui/sort.cpp: ported from filter.ts/sort.ts near-verbatim.
    - apps/tui/main.cpp: category cycling (←/→), sort cycling (s), hide-dead
      toggle (h), and a "N/M sources (K down)" status line.

    Found and fixed via manual tmux-driven testing across all 10 sources
    (searched "one piece", "dune"; watched results stream in progressively):
    the results view was hiding the list entirely behind a "searching..."
    spinner until every source finished, instead of showing streamed partial
    results alongside it the way useConcurrentSearch.ts does.

    Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---
 apps/tui/main.cpp                           | 156 ++++++++++++----
 include/torlinkc/sources/bittorrented.hpp   |  19 ++
 include/torlinkc/sources/eztv.hpp           |  12 ++
 include/torlinkc/sources/fitgirl.hpp        |  10 +
 include/torlinkc/sources/nyaa.hpp           |  11 ++
 include/torlinkc/sources/registry.hpp       |  29 +++
 include/torlinkc/sources/rss.hpp            |  20 ++
 include/torlinkc/sources/subsplease.hpp     |  11 ++
 include/torlinkc/sources/types.hpp          |   7 +-
 include/torlinkc/sources/x1337.hpp          |  20 ++
 include/torlinkc/sources/yts.hpp            |  11 ++
 include/torlinkc/ui/app_state.hpp           |  45 ++++-
 include/torlinkc/ui/coalescing_notifier.hpp |  47 +++++
 include/torlinkc/ui/filter.hpp              |  20 ++
 include/torlinkc/ui/search_aggregator.hpp   |  69 +++++++
 include/torlinkc/ui/search_runner.hpp       |  38 ----
 include/torlinkc/ui/sort.hpp                |  36 ++++
 include/torlinkc/util/date_parse.hpp        |  17 ++
 include/torlinkc/util/format.hpp            |   6 +
 include/torlinkc/util/net.hpp               |  13 ++
 include/torlinkc/util/url_encode.hpp        |  15 ++
 src/CMakeLists.txt                          |  16 +-
 src/sources/bittorrented.cpp                |  91 +++++++++
 src/sources/eztv.cpp                        |  80 ++++++++
 src/sources/fitgirl.cpp                     |  25 +++
 src/sources/magnet.cpp                      |  43 +----
 src/sources/nyaa.cpp                        |  90 +++++++++
 src/sources/piratebay.cpp                   |  45 ++---
 src/sources/registry.cpp                    |  72 +++++++
 src/sources/rss.cpp                         | 136 ++++++++++++++
 src/sources/subsplease.cpp                  |  96 ++++++++++
 src/sources/x1337.cpp                       | 280 ++++++++++++++++++++++++++++
 src/sources/yts.cpp                         |  95 ++++++++++
 src/ui/coalescing_notifier.cpp              |  53 ++++++
 src/ui/filter.cpp                           |  99 ++++++++++
 src/ui/search_aggregator.cpp                | 150 +++++++++++++++
 src/ui/search_runner.cpp                    |  61 ------
 src/ui/sort.cpp                             |  97 ++++++++++
 src/util/date_parse.cpp                     |  30 +++
 src/util/format.cpp                         |  24 +++
 src/util/net.cpp                            |  37 +++-
 src/util/url_encode.cpp                     |  47 +++++
 tests/CMakeLists.txt                        |  11 +-
 tests/test_bittorrented.cpp                 |  61 ++++++
 tests/test_filter.cpp                       |  71 +++++++
 tests/test_registry.cpp                     |  58 ++++++
 tests/test_rss.cpp                          |  21 +++
 tests/test_sort.cpp                         | 123 ++++++++++++
 tests/test_x1337.cpp                        |  41 ++++
 49 files changed, 2436 insertions(+), 229 deletions(-)

diff --git a/apps/tui/main.cpp b/apps/tui/main.cpp
index 42b9c57..3b0f55a 100644
--- a/apps/tui/main.cpp
+++ b/apps/tui/main.cpp
@@ -1,11 +1,11 @@
-// Phase 2 deliverable: the smallest usable interactive FTXUI shell -- a
-// search box, a results list, and a downloads list with a live progress
-// bar, against a single source (apibay/tpb-movies), no tabs/sidebar yet.
-// This is where the plan's highest-risk piece gets proven: three threads
-// (UI, the libtorrent-backed engine thread, and a search thread) meeting
-// through one AppState, entirely through ScreenInteractive::Post -- see
-// ui/engine_thread.* and ui/search_runner.*.
-
+// Phase 3 deliverable: full search parity across all 10 sources, via a real
+// SearchAggregator (one std::jthread per source, cancellable, coalesced
+// flush), plus category tabs, the sort cycle, and dead-seeder filtering.
+// Builds on Phase 2's proven three-thread architecture (UI, engine, search)
+// -- this phase is about breadth of sources/filtering, not new concurrency
+// risk.
+
+#include <algorithm>
 #include <csignal>
 #include <string>

@@ -16,10 +16,13 @@

 #include "torlinkc/config/config.hpp"
 #include "torlinkc/engine/queue.hpp"
+#include "torlinkc/sources/registry.hpp"
 #include "torlinkc/sources/types.hpp"
 #include "torlinkc/ui/app_state.hpp"
 #include "torlinkc/ui/engine_thread.hpp"
-#include "torlinkc/ui/search_runner.hpp"
+#include "torlinkc/ui/filter.hpp"
+#include "torlinkc/ui/search_aggregator.hpp"
+#include "torlinkc/ui/sort.hpp"
 #include "torlinkc/ui/spinner.hpp"
 #include "torlinkc/util/format.hpp"

@@ -29,17 +32,9 @@ using namespace torlinkc::ui;

 namespace {

-// SIGINT/SIGTERM ignored deliberately: ScreenInteractive::Fullscreen()
-// already restores the terminal on a normal return from Loop(), and the
-// only exit path here is the in-app Escape/Ctrl+C-to-quit binding below, not
-// a signal. Phase 1's core_cli handled real signals because it had no UI
-// event loop to bind a quit key to.
-
-std::string downloadsAndSeedsText(const AppState& state) {
-  if (state.items.empty() && state.seeds.empty()) {
-    return "(nothing yet -- select a result and press Enter or 'd' to download)";
-  }
-  return "";
+std::string formatResultLabel(const TorrentResult& r) {
+  return "[" + r.source + "] " + stripControl(r.name) + "  (" + formatBytes(static_cast<double>(r.sizeBytes)) +
+         ", seeders=" + std::to_string(r.seeders) + ")";
 }

 Element renderDownloads(const AppState& state) {
@@ -57,26 +52,79 @@ Element renderDownloads(const AppState& state) {
                          "/s up") |
                     dim);
   }
-  const std::string placeholder = downloadsAndSeedsText(state);
-  if (!placeholder.empty()) rows.push_back(text(placeholder) | dim);
+  if (state.items.empty() && state.seeds.empty()) {
+    rows.push_back(text("(nothing yet -- select a result and press Enter or 'd' to download)") | dim);
+  }
   return vbox(std::move(rows));
 }

+std::string sourcesStatusLine(const AppState& state) {
+  int failed = 0;
+  std::string firstError;
+  for (const auto& [id, st] : state.perSource) {
+    if (st.error) {
+      failed++;
+      if (firstError.empty()) firstError = id + ": " + st.code.value_or("error");
+    }
+  }
+  std::string line = std::to_string(state.doneSources) + "/" + std::to_string(state.totalSources) + " sources";
+  if (failed > 0) {
+    line += "  (" + std::to_string(failed) + " down";
+    if (!firstError.empty()) line += ", e.g. " + firstError;
+    line += ")";
+  }
+  return line;
+}
+
+std::string filterBarText(const AppState& state) {
+  std::string s = "[" + categoryLabel(state.category) + "]";
+  s += state.hideDead ? "  hide-dead:on" : "  hide-dead:off";
+  s += "  sort:" + sortLabel(state.sort);
+  return s;
+}
+
 }  // namespace

-int main(int argc, char** argv) {
+int main() {
   std::signal(SIGPIPE, SIG_IGN);

   Config config = loadConfig();
+  const auto sources = sourceById();  // stateless; safe to capture by value into lambdas below

   ScreenInteractive screen = ScreenInteractive::Fullscreen();
   AppState state;
   EngineThread engine(screen, state);
-  SearchRunner searchRunner(screen, state);
+  SearchAggregator aggregator(screen, state);
+
+  auto refreshVisible = [&] {
+    std::vector<TorrentResult> byCategory;
+    for (const auto& r : state.results) {
+      const auto it = sources.find(r.source);
+      const bool inCategory = it == sources.end() || sourceInCategory(it->second, state.category);
+      if (inCategory) byCategory.push_back(r);
+    }
+    auto filtered = filterResults(byCategory, state.hideDead, sources);
+    state.visibleResults = sortResults(filtered, state.sort);
+
+    state.resultLabels.clear();
+    state.resultLabels.reserve(state.visibleResults.size());
+    for (const auto& r : state.visibleResults) state.resultLabels.push_back(formatResultLabel(r));
+    if (state.selectedResult >= static_cast<int>(state.resultLabels.size())) {
+      state.selectedResult = std::max(0, static_cast<int>(state.resultLabels.size()) - 1);
+    }
+  };
+  aggregator.onResultsChanged = refreshVisible;
+
+  auto cycleCategory = [&](int delta) {
+    constexpr int kCategoryCount = 5;  // All, Games, Movies, TV, Anime
+    const int idx = static_cast<int>(state.category);
+    state.category = static_cast<Category>(((idx + delta) % kCategoryCount + kCategoryCount) % kCategoryCount);
+    refreshVisible();
+  };

   auto downloadSelected = [&] {
-    if (state.selectedResult < 0 || state.selectedResult >= static_cast<int>(state.results.size())) return;
-    const TorrentResult r = state.results[static_cast<std::size_t>(state.selectedResult)];
+    if (state.selectedResult < 0 || state.selectedResult >= static_cast<int>(state.visibleResults.size())) return;
+    const TorrentResult r = state.visibleResults[static_cast<std::size_t>(state.selectedResult)];
     AddInput input;
     input.id = r.infoHash;
     input.name = r.name;
@@ -90,33 +138,60 @@ int main(int argc, char** argv) {

   InputOption searchOptions;
   searchOptions.multiline = false;
-  searchOptions.on_enter = [&] { searchRunner.search(state.query); };
-  Component searchInput = Input(&state.query, "search torrents (Movies, via apibay)...", searchOptions);
+  searchOptions.on_enter = [&] { aggregator.search(state.query); };
+  Component searchInput = Input(&state.query, "search torrents (all sources)...", searchOptions);

   MenuOption menuOptions = MenuOption::Vertical();
   menuOptions.on_enter = downloadSelected;
   Component resultsMenu = Menu(&state.resultLabels, &state.selectedResult, menuOptions);
-  Component resultsWithDownloadKey = CatchEvent(resultsMenu, [&](Event event) {
+  Component resultsWithKeys = CatchEvent(resultsMenu, [&](Event event) {
     if (event == Event::Character('d')) {
       downloadSelected();
       return true;
     }
+    if (event == Event::Character('s')) {
+      state.sort = nextSort(state.sort);
+      refreshVisible();
+      return true;
+    }
+    if (event == Event::Character('h')) {
+      state.hideDead = !state.hideDead;
+      refreshVisible();
+      return true;
+    }
+    if (event == Event::ArrowLeft) {
+      cycleCategory(-1);
+      return true;
+    }
+    if (event == Event::ArrowRight) {
+      cycleCategory(1);
+      return true;
+    }
     return false;
   });

   Component spinner = MakeSpinner([&] { return state.searching; });

   Component mainContainer =
-      Container::Vertical({searchInput, resultsWithDownloadKey, spinner}, &state.focusedIndex);
+      Container::Vertical({searchInput, resultsWithKeys, spinner}, &state.focusedIndex);

   Component root = Renderer(mainContainer, [&] {
+    // Results stream in as sources finish, so the list (once non-empty) is
+    // shown *alongside* the in-progress indicator, not hidden behind it --
+    // matching useConcurrentSearch.ts's incremental-results behavior rather
+    // than an all-or-nothing loading state.
     Elements resultsSection;
+    resultsSection.push_back(text(filterBarText(state)) | dim);
     if (state.searching) {
-      resultsSection.push_back(hbox({spinner->Render(), text(" searching apibay...")}));
-    } else if (!state.searchError.empty()) {
-      resultsSection.push_back(text("search failed: " + state.searchError) | color(Color::Red));
-    } else if (state.resultLabels.empty()) {
-      resultsSection.push_back(text("no results yet -- type a query and press Enter") | dim);
+      resultsSection.push_back(hbox({spinner->Render(), text(" " + sourcesStatusLine(state))}));
+    } else {
+      resultsSection.push_back(text(sourcesStatusLine(state)) | dim);
+    }
+    if (state.resultLabels.empty()) {
+      std::string hint = "no results yet -- type a query and press Enter";
+      if (state.searching) hint = "searching...";
+      else if (!state.results.empty()) hint = "no results in this category/filter";
+      resultsSection.push_back(text(hint) | dim);
     } else {
       resultsSection.push_back(resultsMenu->Render() | frame | flex);
     }
@@ -132,15 +207,18 @@ int main(int argc, char** argv) {
                window(text("Downloads"), renderDownloads(state)),
                separator(),
                vbox(std::move(bottom)),
-               text("Enter: search / download selected   d: download selected   Esc: quit") | dim,
+               text("Enter: search/download   d: download   s: sort   h: hide dead   "
+                    "←/→: category   Esc: quit") |
+                   dim,
            }) |
            flex;
   });

   // Escape quits from anywhere (checked before the focused child sees the
   // event), matching the original App.tsx's global-keybinding-first model.
-  // Regular text input (including 'q') is left alone so typing a search
-  // query never accidentally quits the app.
+  // Regular text input (including 'q'/'s'/'h'/'d', while the search box has
+  // focus) is left alone so typing a query never accidentally triggers a
+  // results-list shortcut.
   root = CatchEvent(root, [&](Event event) {
     if (event == Event::Escape) {
       screen.Exit();
@@ -153,7 +231,5 @@ int main(int argc, char** argv) {
   screen.Loop(root);
   engine.stop();

-  (void)argc;
-  (void)argv;
   return 0;
 }
diff --git a/include/torlinkc/sources/bittorrented.hpp b/include/torlinkc/sources/bittorrented.hpp
new file mode 100644
index 0000000..2123ecc
--- /dev/null
+++ b/include/torlinkc/sources/bittorrented.hpp
@@ -0,0 +1,19 @@
+#pragma once
+
+#include <nlohmann/json_fwd.hpp>
+
+#include "torlinkc/sources/types.hpp"
+
+namespace torlinkc {
+
+// BitTorrented, a general video index. Feeds Movies and TV (the API can't
+// tell anime from any other video, and Games stays FitGirl's alone). Ported
+// from sources/bittorrented.ts.
+Source bittorrentedSource();
+
+// Exposed for testing: maps the API's raw result rows to TorrentResult, pure
+// and without a live request. Rows without a valid 40-char hex info hash are
+// dropped.
+std::vector<TorrentResult> mapBittorrentedResults(const nlohmann::json& results, const std::string& sourceId);
+
+}  // namespace torlinkc
diff --git a/include/torlinkc/sources/eztv.hpp b/include/torlinkc/sources/eztv.hpp
new file mode 100644
index 0000000..36611ef
--- /dev/null
+++ b/include/torlinkc/sources/eztv.hpp
@@ -0,0 +1,12 @@
+#pragma once
+
+#include "torlinkc/sources/types.hpp"
+
+namespace torlinkc {
+
+// EZTV's JSON API. Ported from sources/eztv.ts. Note the source quirk this
+// preserves: a non-empty query returns nothing -- EZTV's API has no search,
+// so this always just returns the latest 100 torrents (browse-only).
+Source eztvSource();
+
+}  // namespace torlinkc
diff --git a/include/torlinkc/sources/fitgirl.hpp b/include/torlinkc/sources/fitgirl.hpp
new file mode 100644
index 0000000..d18718c
--- /dev/null
+++ b/include/torlinkc/sources/fitgirl.hpp
@@ -0,0 +1,10 @@
+#pragma once
+
+#include "torlinkc/sources/types.hpp"
+
+namespace torlinkc {
+
+// FitGirl Repacks via its WordPress RSS feed. Ported from sources/fitgirl.ts.
+Source fitgirlSource();
+
+}  // namespace torlinkc
diff --git a/include/torlinkc/sources/nyaa.hpp b/include/torlinkc/sources/nyaa.hpp
new file mode 100644
index 0000000..7ec3253
--- /dev/null
+++ b/include/torlinkc/sources/nyaa.hpp
@@ -0,0 +1,11 @@
+#pragma once
+
+#include "torlinkc/sources/types.hpp"
+
+namespace torlinkc {
+
+// Nyaa.si via its RSS feed (custom nyaa:-namespaced tags, not the shared
+// WordPress RSS parser). Ported from sources/nyaa.ts.
+Source nyaaSource();
+
+}  // namespace torlinkc
diff --git a/include/torlinkc/sources/registry.hpp b/include/torlinkc/sources/registry.hpp
new file mode 100644
index 0000000..d6b7c00
--- /dev/null
+++ b/include/torlinkc/sources/registry.hpp
@@ -0,0 +1,29 @@
+#pragma once
+
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+#include "torlinkc/sources/types.hpp"
+
+namespace torlinkc {
+
+// Builds a fresh copy of all 10 sources. Cheap (each Source is a handful of
+// strings plus a std::function wrapping a stateless free function), and
+// avoids any static-initialization-order question -- callers needing to run
+// several sources concurrently (Phase 3's SearchAggregator) can each hold
+// their own copy safely. Ported from sources/registry.ts::SOURCES.
+std::vector<Source> allSources();
+
+// id -> Source, for looking up a TorrentResult's `source` field back to its
+// `groups`/`reportsHealth` (category filtering, ui::filterResults's
+// hideDead check).
+std::unordered_map<std::string, Source> sourceById();
+
+// The full ordered category list, matching registry.ts::sourcesByGroup's
+// GROUP_ORDER (Games, Movies, TV, Anime) plus a leading "All".
+enum class Category { All, Games, Movies, TV, Anime };
+std::string categoryLabel(Category c);
+bool sourceInCategory(const Source& source, Category c);
+
+}  // namespace torlinkc
diff --git a/include/torlinkc/sources/rss.hpp b/include/torlinkc/sources/rss.hpp
new file mode 100644
index 0000000..70d1284
--- /dev/null
+++ b/include/torlinkc/sources/rss.hpp
@@ -0,0 +1,20 @@
+#pragma once
+
+#include <string>
+#include <vector>
+
+#include "torlinkc/sources/types.hpp"
+
+namespace torlinkc {
+
+std::string unescapeEntities(const std::string& s);
+
+// Fetches a WordPress RSS2 feed (search or /feed/, deepening into further
+// pages if the first is full) and extracts magnet-bearing <item> entries.
+// Ported from sources/rss.ts::fetchWordpressRss. Shared today by fitgirl;
+// structurally similar to (but not shared with) nyaa's custom RSS parsing,
+// matching the original.
+std::vector<TorrentResult> fetchWordpressRss(const std::string& base, const std::string& sourceId,
+                                              const std::string& query, const SearchOptions& opts = {});
+
+}  // namespace torlinkc
diff --git a/include/torlinkc/sources/subsplease.hpp b/include/torlinkc/sources/subsplease.hpp
new file mode 100644
index 0000000..5e4864c
--- /dev/null
+++ b/include/torlinkc/sources/subsplease.hpp
@@ -0,0 +1,11 @@
+#pragma once
+
+#include "torlinkc/sources/types.hpp"
+
+namespace torlinkc {
+
+// SubsPlease's JSON API, picking the best available resolution per release.
+// Ported from sources/subsplease.ts.
+Source subspleaseSource();
+
+}  // namespace torlinkc
diff --git a/include/torlinkc/sources/types.hpp b/include/torlinkc/sources/types.hpp
index a08b6ed..ed5fc1e 100644
--- a/include/torlinkc/sources/types.hpp
+++ b/include/torlinkc/sources/types.hpp
@@ -3,6 +3,7 @@
 #include <cstdint>
 #include <functional>
 #include <optional>
+#include <stop_token>
 #include <string>
 #include <vector>

@@ -21,8 +22,10 @@ struct TorrentResult {
 };

 struct SearchOptions {
-  // Phase 1 has no cancellation model yet (Phase 2 wires this up to
-  // std::stop_token per the plan's per-source-thread design).
+  // Default-constructed = never cancels, matching FetchOptions::stopToken.
+  // SearchAggregator (Phase 3) passes each per-source thread's own token,
+  // the same way the TS original threaded an AbortSignal through here.
+  std::stop_token stopToken;
 };

 enum class SourceGroup { Games, Movies, TV, Anime };
diff --git a/include/torlinkc/sources/x1337.hpp b/include/torlinkc/sources/x1337.hpp
new file mode 100644
index 0000000..a3d5020
--- /dev/null
+++ b/include/torlinkc/sources/x1337.hpp
@@ -0,0 +1,20 @@
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <string>
+
+#include "torlinkc/sources/types.hpp"
+
+namespace torlinkc {
+
+// 1337x.to via HTML scraping, with mirror-host fallback. Ported from
+// sources/x1337.ts.
+Source x1337MoviesSource();
+Source x1337TvSource();
+
+// Exposed for testing: parses 1337x's detail-page "Date uploaded" format
+// (e.g. "Jun. 26th '26") into unix seconds.
+std::optional<std::int64_t> parseUploadDate(const std::string& html);
+
+}  // namespace torlinkc
diff --git a/include/torlinkc/sources/yts.hpp b/include/torlinkc/sources/yts.hpp
new file mode 100644
index 0000000..92ab184
--- /dev/null
+++ b/include/torlinkc/sources/yts.hpp
@@ -0,0 +1,11 @@
+#pragma once
+
+#include "torlinkc/sources/types.hpp"
+
+namespace torlinkc {
+
+// YTS movie torrents via its JSON API (tried across 3 mirror hosts). Ported
+// from sources/yts.ts.
+Source ytsSource();
+
+}  // namespace torlinkc
diff --git a/include/torlinkc/ui/app_state.hpp b/include/torlinkc/ui/app_state.hpp
index 5242f2c..91c41b2 100644
--- a/include/torlinkc/ui/app_state.hpp
+++ b/include/torlinkc/ui/app_state.hpp
@@ -1,13 +1,26 @@
 #pragma once

+#include <optional>
 #include <string>
+#include <unordered_map>
 #include <vector>

 #include "torlinkc/engine/types.hpp"
+#include "torlinkc/sources/registry.hpp"
 #include "torlinkc/sources/types.hpp"
+#include "torlinkc/ui/sort.hpp"

 namespace torlinkc::ui {

+// Per-source status for the "N sources down" UI. Ported from
+// useConcurrentSearch.ts's SourceState.
+struct SourceState {
+  bool loading = false;
+  std::optional<std::string> error;
+  std::optional<std::string> code;
+  int count = 0;
+};
+
 // The single source of truth for the TUI, mutated only on the UI thread --
 // either directly inside an FTXUI event handler, or via a closure a
 // background thread posts through ftxui::ScreenInteractive::Post(). Ported
@@ -19,20 +32,32 @@ namespace torlinkc::ui {
 struct AppState {
   // Which child of the top-level Container::Vertical has focus: 0 = search
   // box, 1 = results list (see main.cpp, which owns that ordering). Tab/
-  // Shift-Tab update this the normal way; SearchRunner also nudges it to 1
-  // when results arrive, so a completed search is immediately navigable
-  // without the user having to Tab there themselves -- otherwise a hotkey
-  // like 'd' typed right after Enter lands in the still-focused search box
-  // as a literal character instead of reaching the results list.
+  // Shift-Tab update this the normal way; SearchAggregator also nudges it to
+  // 1 when results first arrive, so a completed search is immediately
+  // navigable without the user having to Tab there themselves -- otherwise a
+  // hotkey like 'd' typed right after Enter lands in the still-focused
+  // search box as a literal character instead of reaching the results list.
   int focusedIndex = 0;

-  // Search. Phase 2 is one source with no category tabs (see Phase 3).
+  // Search, across all sources (Phase 3's SearchAggregator).
   std::string query;
-  std::vector<TorrentResult> results;
-  std::vector<std::string> resultLabels;  // parallel to results; what the results Menu renders
-  int selectedResult = 0;
+  std::vector<TorrentResult> results;         // deduped, default-ordered, unfiltered -- SearchAggregator's output
+  std::unordered_map<std::string, SourceState> perSource;
+  int doneSources = 0;
+  int totalSources = 0;
   bool searching = false;
-  std::string searchError;
+
+  // Display: category/hideDead/sort applied to `results`, recomputed into
+  // these by main.cpp's refreshVisible() whenever `results` or any of the
+  // three change. Kept as persistent AppState fields (not computed fresh
+  // inside Render()) because FTXUI's Menu binds to resultLabels by address
+  // at construction time -- see the class comment on that invariant.
+  Category category = Category::All;
+  bool hideDead = false;
+  Sort sort;
+  std::vector<TorrentResult> visibleResults;
+  std::vector<std::string> resultLabels;  // parallel to visibleResults; what the results Menu renders
+  int selectedResult = 0;

   // Downloads / seeds, replaced wholesale each engine-thread tick.
   std::vector<QueueItem> items;
diff --git a/include/torlinkc/ui/coalescing_notifier.hpp b/include/torlinkc/ui/coalescing_notifier.hpp
new file mode 100644
index 0000000..05e6a42
--- /dev/null
+++ b/include/torlinkc/ui/coalescing_notifier.hpp
@@ -0,0 +1,47 @@
+#pragma once
+
+#include <chrono>
+#include <condition_variable>
+#include <functional>
+#include <mutex>
+#include <thread>
+
+namespace torlinkc::ui {
+
+// Trailing-edge coalescer: the first markDirty() call arms a `window`
+// deadline; further calls before the deadline are absorbed (no extra work);
+// when the deadline passes, `onFlush` runs exactly once. flushNow() cuts the
+// wait short (used when the last of N producers finishes, so a result
+// doesn't sit out a window it no longer needs to). Runs its own background
+// thread, torn down on destruction.
+//
+// Ported from the coalescing behavior shared by useConcurrentSearch.ts's
+// 150ms result-flush timer and store.ts's 200ms queue-update hooks -- one
+// primitive standing in for what were two near-identical debounces in the
+// original.
+class CoalescingNotifier {
+ public:
+  CoalescingNotifier(std::chrono::milliseconds window, std::function<void()> onFlush);
+  ~CoalescingNotifier();
+
+  CoalescingNotifier(const CoalescingNotifier&) = delete;
+  CoalescingNotifier& operator=(const CoalescingNotifier&) = delete;
+
+  void markDirty();
+  void flushNow();
+
+ private:
+  void run();
+
+  std::chrono::milliseconds window_;
+  std::function<void()> onFlush_;
+
+  std::mutex mutex_;
+  std::condition_variable cv_;
+  bool dirty_ = false;
+  bool flushRequested_ = false;
+  bool stop_ = false;
+  std::thread thread_;
+};
+
+}  // namespace torlinkc::ui
diff --git a/include/torlinkc/ui/filter.hpp b/include/torlinkc/ui/filter.hpp
new file mode 100644
index 0000000..37bae69
--- /dev/null
+++ b/include/torlinkc/ui/filter.hpp
@@ -0,0 +1,20 @@
+#pragma once
+
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+#include "torlinkc/sources/types.hpp"
+
+namespace torlinkc::ui {
+
+// `sources` maps each result's source id to that Source (see
+// sources/registry.hpp's sourceById()) -- sources without swarm data
+// (Source::reportsHealth == false) report seeders: 0 for everything
+// (unknown, not dead), so hideDead must never drop those rows. Ported from
+// ui/filter.ts::filterResults.
+std::vector<TorrentResult> filterResults(const std::vector<TorrentResult>& list, bool hideDead,
+                                          const std::unordered_map<std::string, Source>& sources,
+                                          const std::string& textFilter = "");
+
+}  // namespace torlinkc::ui
diff --git a/include/torlinkc/ui/search_aggregator.hpp b/include/torlinkc/ui/search_aggregator.hpp
new file mode 100644
index 0000000..d619a64
--- /dev/null
+++ b/include/torlinkc/ui/search_aggregator.hpp
@@ -0,0 +1,69 @@
+#pragma once
+
+#include <functional>
+#include <memory>
+#include <mutex>
+#include <stop_token>
+#include <string>
+#include <thread>
+#include <unordered_map>
+#include <vector>
+
+#include <ftxui/component/screen_interactive.hpp>
+
+#include "torlinkc/sources/cache.hpp"
+#include "torlinkc/ui/app_state.hpp"
+#include "torlinkc/ui/coalescing_notifier.hpp"
+
+namespace torlinkc::ui {
+
+// Fires one std::jthread per source (matching useConcurrentSearch.ts's "fire
+// them all, no Promise.all" fan-out), streams results into a shared,
+// mutex-guarded accumulator as each source finishes, and flushes a
+// deduped/default-ordered snapshot to AppState through a CoalescingNotifier
+// (150ms window, immediate on the last source finishing) -- see the plan's
+// "Multi-source search" section.
+//
+// A new search() cancels the previous one for real: each jthread gets its
+// own std::stop_token wired all the way into libcurl's transfer-progress
+// callback (see util/net.cpp), so replacing the thread list actually
+// interrupts an in-flight request instead of blocking join() on it.
+class SearchAggregator {
+ public:
+  SearchAggregator(ftxui::ScreenInteractive& screen, AppState& state);
+  ~SearchAggregator();
+
+  SearchAggregator(const SearchAggregator&) = delete;
+  SearchAggregator& operator=(const SearchAggregator&) = delete;
+
+  // Must be called from the UI thread. Blocks briefly (typically well under
+  // 100ms) while any previous search's threads notice their stop request and
+  // unwind -- see the class comment.
+  void search(std::string query);
+
+  // Called on the UI thread at the end of every flush, after state_.results
+  // is updated -- main.cpp hooks this to recompute visibleResults/
+  // resultLabels (category/hideDead/sort applied), which SearchAggregator
+  // itself has no opinion on.
+  std::function<void()> onResultsChanged;
+
+ private:
+  void runSource(const Source& source, const std::string& query, std::stop_token stopToken, int generation);
+  void flush();
+
+  ftxui::ScreenInteractive& screen_;
+  AppState& state_;
+  SearchCache cache_;
+
+  std::mutex dataMutex_;
+  std::vector<TorrentResult> collected_;
+  std::unordered_map<std::string, SourceState> perSource_;
+  int doneCount_ = 0;
+  int totalCount_ = 0;
+  int generation_ = 0;
+
+  std::unique_ptr<CoalescingNotifier> notifier_;
+  std::vector<std::jthread> threads_;
+};
+
+}  // namespace torlinkc::ui
diff --git a/include/torlinkc/ui/search_runner.hpp b/include/torlinkc/ui/search_runner.hpp
deleted file mode 100644
index 4e49af9..0000000
--- a/include/torlinkc/ui/search_runner.hpp
+++ /dev/null
@@ -1,38 +0,0 @@
-#pragma once
-
-#include <string>
-#include <thread>
-
-#include <ftxui/component/screen_interactive.hpp>
-
-#include "torlinkc/sources/cache.hpp"
-#include "torlinkc/ui/app_state.hpp"
-
-namespace torlinkc::ui {
-
-// Runs one search at a time on its own background thread. Phase 2 has a
-// single source, so there is nothing to fan out or coalesce across yet --
-// Phase 3's SearchAggregator generalizes this to N concurrent per-source
-// threads with a coalesced-flush notifier (see the plan). A search already
-// in flight is dropped rather than superseded: with only one caller
-// (pressing Enter) and no cancellation primitive yet (that's Phase 3's
-// std::stop_token work), this is the simplest thing that can't race.
-class SearchRunner {
- public:
-  SearchRunner(ftxui::ScreenInteractive& screen, AppState& state);
-  ~SearchRunner();
-
-  SearchRunner(const SearchRunner&) = delete;
-  SearchRunner& operator=(const SearchRunner&) = delete;
-
-  // Must be called from the UI thread (reads/writes AppState directly).
-  void search(std::string query);
-
- private:
-  ftxui::ScreenInteractive& screen_;
-  AppState& state_;
-  SearchCache cache_;
-  std::thread thread_;
-};
-
-}  // namespace torlinkc::ui
diff --git a/include/torlinkc/ui/sort.hpp b/include/torlinkc/ui/sort.hpp
new file mode 100644
index 0000000..f8b3f55
--- /dev/null
+++ b/include/torlinkc/ui/sort.hpp
@@ -0,0 +1,36 @@
+#pragma once
+
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "torlinkc/sources/types.hpp"
+
+namespace torlinkc::ui {
+
+enum class SortField { Size, Seeders, Source, Added };
+enum class SortDir { Asc, Desc };
+
+struct SortState {
+  SortField field;
+  SortDir dir;
+
+  bool operator==(const SortState&) const = default;
+};
+
+// nullopt = "none" (the untouched/default order) in the TS original's
+// `Sort = SortState | "none"`.
+using Sort = std::optional<SortState>;
+
+// The order the `s` key cycles through: untouched, then each field
+// ascending then descending, then back to untouched. Ported from
+// ui/sort.ts::SORT_CYCLE.
+const std::vector<Sort>& sortCycle();
+
+Sort nextSort(const Sort& current);
+std::string sortArrow(SortDir dir);
+std::string sortLabel(const Sort& sort);
+
+std::vector<TorrentResult> sortResults(const std::vector<TorrentResult>& list, const Sort& sort);
+
+}  // namespace torlinkc::ui
diff --git a/include/torlinkc/util/date_parse.hpp b/include/torlinkc/util/date_parse.hpp
new file mode 100644
index 0000000..3a6eddb
--- /dev/null
+++ b/include/torlinkc/util/date_parse.hpp
@@ -0,0 +1,17 @@
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <string>
+
+namespace torlinkc {
+
+// Parses a date string into unix seconds, matching what JS's `new
+// Date(s).getTime()` / `Date.parse(s)` accept in this codebase: ISO 8601
+// ("2024-01-15T00:00:00Z", as bittorrented/subsplease's JSON APIs send) or
+// an RFC 822/1123 date (as RSS <pubDate> elements send). libcurl's
+// curl_getdate() handles the latter but not the former, so ISO 8601 is tried
+// first with its own parser, falling back to curl_getdate.
+std::optional<std::int64_t> parseDateToUnixSeconds(const std::string& s);
+
+}  // namespace torlinkc
diff --git a/include/torlinkc/util/format.hpp b/include/torlinkc/util/format.hpp
index 7fb7716..19fdfcc 100644
--- a/include/torlinkc/util/format.hpp
+++ b/include/torlinkc/util/format.hpp
@@ -1,5 +1,6 @@
 #pragma once

+#include <cstdint>
 #include <string>

 namespace torlinkc {
@@ -9,6 +10,11 @@ namespace torlinkc {
 // harness already did).
 std::string formatBytes(double bytes);

+// Parses a human-readable size like "1.2 GiB" or "700 MB" (as scraped from
+// x1337/nyaa's HTML/RSS) back into bytes. Ported from
+// util/format.ts::parseSize.
+std::int64_t parseSize(const std::string& s);
+
 // Strips C0/DEL/C1 control and escape-capable code points from a UTF-8
 // string, byte-for-byte (ASCII range only -- multi-byte UTF-8 continuation
 // bytes are all >= 0x80 and < 0xa0 only overlaps the C1 range for a handful
diff --git a/include/torlinkc/util/net.hpp b/include/torlinkc/util/net.hpp
index f193ef5..b30dc86 100644
--- a/include/torlinkc/util/net.hpp
+++ b/include/torlinkc/util/net.hpp
@@ -4,7 +4,9 @@
 #include <map>
 #include <optional>
 #include <stdexcept>
+#include <stop_token>
 #include <string>
+#include <vector>

 namespace torlinkc {

@@ -33,6 +35,17 @@ struct FetchOptions {
   int retries = 5;
   int baseMs = 500;
   int capMs = 20000;
+  // Extra "Name: value" request headers (User-Agent is always sent and
+  // shouldn't be repeated here).
+  std::vector<std::string> headers;
+  // Default-constructed stop_token is never stop_requested(), so passing
+  // nothing here behaves exactly as it did before cancellation existed.
+  // Checked before each attempt, wired into libcurl's transfer-progress
+  // callback to abort mid-request, and used to cut a backoff sleep short --
+  // this is what makes a Phase 3 SearchAggregator able to actually cancel a
+  // stale search instead of just abandoning it and blocking on join() until
+  // curl's own timeout elapses.
+  std::stop_token stopToken;
 };

 // Parses a Retry-After header value (either delay-seconds or an HTTP-date)
diff --git a/include/torlinkc/util/url_encode.hpp b/include/torlinkc/util/url_encode.hpp
new file mode 100644
index 0000000..2e27ec8
--- /dev/null
+++ b/include/torlinkc/util/url_encode.hpp
@@ -0,0 +1,15 @@
+#pragma once
+
+#include <string>
+
+namespace torlinkc {
+
+// Matches JS's encodeURIComponent exactly (same unreserved-character set:
+// A-Z a-z 0-9 - _ . ! ~ * ' ( )), so URLs built here byte-match the
+// TypeScript original's. Shared by every scraper and by magnet building.
+std::string encodeURIComponent(const std::string& s);
+
+// Decodes %XX escapes and '+' as space, matching URLSearchParams::get().
+std::string decodeURIComponent(const std::string& s);
+
+}  // namespace torlinkc
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 83376eb..a4098fe 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -9,12 +9,23 @@ add_library(torlinkc_core STATIC
   engine/reconcile.cpp
   engine/torrent_engine.cpp
   engine/types.cpp
+  sources/bittorrented.cpp
   sources/cache.cpp
+  sources/eztv.cpp
+  sources/fitgirl.cpp
   sources/magnet.cpp
+  sources/nyaa.cpp
   sources/piratebay.cpp
+  sources/registry.cpp
+  sources/rss.cpp
+  sources/subsplease.cpp
+  sources/x1337.cpp
+  sources/yts.cpp
   util/atomic_write.cpp
+  util/date_parse.cpp
   util/format.cpp
   util/net.cpp
+  util/url_encode.cpp
 )

 target_include_directories(torlinkc_core PUBLIC ${CMAKE_SOURCE_DIR}/include)
@@ -29,8 +40,11 @@ target_link_libraries(torlinkc_core PUBLIC
 # UI-agnostic (Phase 1's console harness and Phase 5's daemon modes link
 # torlinkc_core without ever pulling in FTXUI).
 add_library(torlinkc_ui STATIC
+  ui/coalescing_notifier.cpp
   ui/engine_thread.cpp
-  ui/search_runner.cpp
+  ui/filter.cpp
+  ui/search_aggregator.cpp
+  ui/sort.cpp
   ui/spinner.cpp
 )

diff --git a/src/sources/bittorrented.cpp b/src/sources/bittorrented.cpp
new file mode 100644
index 0000000..c34aaa1
--- /dev/null
+++ b/src/sources/bittorrented.cpp
@@ -0,0 +1,91 @@
+#include "torlinkc/sources/bittorrented.hpp"
+
+#include <regex>
+
+#include <nlohmann/json.hpp>
+
+#include "torlinkc/sources/magnet.hpp"
+#include "torlinkc/util/date_parse.hpp"
+#include "torlinkc/util/net.hpp"
+#include "torlinkc/util/url_encode.hpp"
+
+using nlohmann::json;
+
+namespace torlinkc {
+
+namespace {
+
+// The index requires a real query (the API rejects fewer than 3 characters),
+// so an empty browse returns nothing rather than erroring.
+constexpr std::size_t kMinQuery = 3;
+
+const char* kBase = "https://bittorrented.com";
+
+std::string trim(const std::string& s) {
+  auto first = s.find_first_not_of(" \t\r\n");
+  if (first == std::string::npos) return "";
+  auto last = s.find_last_not_of(" \t\r\n");
+  return s.substr(first, last - first + 1);
+}
+
+std::string toLower(std::string s) {
+  for (auto& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
+  return s;
+}
+
+}  // namespace
+
+std::vector<TorrentResult> mapBittorrentedResults(const json& results, const std::string& sourceId) {
+  static const std::regex kHexHash(R"(^[a-f0-9]{40}$)");
+  std::vector<TorrentResult> out;
+  for (const auto& r : results) {
+    const std::string infoHash = toLower(r.value("torrent_infohash", ""));
+    if (!std::regex_match(infoHash, kHexHash)) continue;
+
+    std::string name = r.value("torrent_name", "");
+    if (name.empty()) name = infoHash;
+
+    TorrentResult result;
+    result.infoHash = infoHash;
+    result.name = name;
+    result.sizeBytes = r.value("torrent_total_size", static_cast<std::int64_t>(0));
+    if (auto it = r.find("torrent_seeders"); it != r.end() && it->is_number()) result.seeders = it->get<int>();
+    if (auto it = r.find("torrent_leechers"); it != r.end() && it->is_number()) result.leechers = it->get<int>();
+    if (auto it = r.find("torrent_file_count"); it != r.end() && it->is_number()) {
+      result.numFiles = it->get<int>();
+    }
+    result.source = sourceId;
+    result.magnet = buildMagnet(infoHash, name);
+    result.added = parseDateToUnixSeconds(r.value("torrent_created_at", ""));
+    out.push_back(std::move(result));
+  }
+  return out;
+}
+
+Source bittorrentedSource() {
+  Source s;
+  s.id = "bittorrented";
+  s.label = "BitTorrented";
+  s.groups = {SourceGroup::Movies, SourceGroup::TV};
+  s.homepage = kBase;
+  s.reportsHealth = true;
+  s.search = [](const std::string& query, const SearchOptions& opts) -> std::vector<TorrentResult> {
+    const std::string q = trim(query);
+    if (q.size() < kMinQuery) return {};
+
+    FetchOptions fetchOpts;
+    fetchOpts.retries = 1;
+    fetchOpts.stopToken = opts.stopToken;
+    fetchOpts.headers = {"Accept: application/json"};
+    const std::string url = std::string(kBase) + "/api/search/torrents?q=" + encodeURIComponent(q) +
+                             "&type=video&limit=50&sortBy=seeders&sortOrder=desc";
+    HttpResponse res = fetchResilient(url, fetchOpts);
+    if (!res.ok()) throw HttpError(res.status, "BitTorrented returned " + std::to_string(res.status));
+
+    const json root = json::parse(res.body);
+    return mapBittorrentedResults(root.value("results", json::array()), "bittorrented");
+  };
+  return s;
+}
+
+}  // namespace torlinkc
diff --git a/src/sources/eztv.cpp b/src/sources/eztv.cpp
new file mode 100644
index 0000000..c23d2c0
--- /dev/null
+++ b/src/sources/eztv.cpp
@@ -0,0 +1,80 @@
+#include "torlinkc/sources/eztv.hpp"
+
+#include <nlohmann/json.hpp>
+
+#include "torlinkc/sources/magnet.hpp"
+#include "torlinkc/util/net.hpp"
+
+using nlohmann::json;
+
+namespace torlinkc {
+
+namespace {
+
+std::string trim(const std::string& s) {
+  auto first = s.find_first_not_of(" \t\r\n");
+  if (first == std::string::npos) return "";
+  auto last = s.find_last_not_of(" \t\r\n");
+  return s.substr(first, last - first + 1);
+}
+
+}  // namespace
+
+Source eztvSource() {
+  Source s;
+  s.id = "eztv";
+  s.label = "EZTV";
+  s.groups = {SourceGroup::TV};
+  s.homepage = "https://eztvx.to";
+  s.reportsHealth = true;
+  s.search = [](const std::string& query, const SearchOptions& opts) -> std::vector<TorrentResult> {
+    if (!trim(query).empty()) return {};
+
+    FetchOptions fetchOpts;
+    fetchOpts.retries = 1;
+    fetchOpts.stopToken = opts.stopToken;
+    HttpResponse res = fetchResilient("https://eztvx.to/api/get-torrents?limit=100&page=1", fetchOpts);
+    if (!res.ok()) throw HttpError(res.status, "EZTV returned " + std::to_string(res.status));
+
+    const json root = json::parse(res.body);
+    std::vector<TorrentResult> out;
+    for (const auto& t : root.value("torrents", json::array())) {
+      std::string hash = t.value("hash", "");
+      for (auto& c : hash) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
+      std::string name = t.value("title", "");
+      if (name.empty()) name = t.value("filename", "");
+      if (name.empty()) name = hash;
+
+      std::string magnet = t.value("magnet_url", "");
+      if (magnet.empty() && !hash.empty()) magnet = buildMagnet(hash, name);
+      if (magnet.empty() || hash.empty()) continue;
+
+      TorrentResult r;
+      r.infoHash = hash;
+      r.name = name;
+      // size_bytes is sometimes a JSON string, sometimes a number.
+      if (auto it = t.find("size_bytes"); it != t.end()) {
+        if (it->is_string()) {
+          try {
+            r.sizeBytes = std::stoll(it->get<std::string>());
+          } catch (...) {
+          }
+        } else if (it->is_number()) {
+          r.sizeBytes = it->get<std::int64_t>();
+        }
+      }
+      r.seeders = t.value("seeds", 0);
+      r.leechers = t.value("peers", 0);
+      r.source = "eztv";
+      r.magnet = magnet;
+      if (auto it = t.find("date_released_unix"); it != t.end() && it->is_number()) {
+        r.added = it->get<std::int64_t>();
+      }
+      out.push_back(std::move(r));
+    }
+    return out;
+  };
+  return s;
+}
+
+}  // namespace torlinkc
diff --git a/src/sources/fitgirl.cpp b/src/sources/fitgirl.cpp
new file mode 100644
index 0000000..89197d1
--- /dev/null
+++ b/src/sources/fitgirl.cpp
@@ -0,0 +1,25 @@
+#include "torlinkc/sources/fitgirl.hpp"
+
+#include "torlinkc/sources/rss.hpp"
+
+namespace torlinkc {
+
+namespace {
+const char* kHome = "https://fitgirl-repacks.site";
+}
+
+Source fitgirlSource() {
+  Source s;
+  s.id = "fitgirl";
+  s.label = "FitGirl";
+  s.groups = {SourceGroup::Games};
+  s.homepage = kHome;
+  // WordPress RSS carries no swarm data; every result reports seeders: 0.
+  s.reportsHealth = false;
+  s.search = [](const std::string& query, const SearchOptions& opts) {
+    return fetchWordpressRss(kHome, "fitgirl", query, opts);
+  };
+  return s;
+}
+
+}  // namespace torlinkc
diff --git a/src/sources/magnet.cpp b/src/sources/magnet.cpp
index 8b1309d..bfbf4e1 100644
--- a/src/sources/magnet.cpp
+++ b/src/sources/magnet.cpp
@@ -7,6 +7,8 @@
 #include <regex>
 #include <unordered_set>

+#include "torlinkc/util/url_encode.hpp"
+
 namespace torlinkc {

 namespace {
@@ -31,47 +33,6 @@ constexpr std::array kTrackers = {
     "https://tracker.tamersunion.org:443/announce",
 };

-bool isUnreserved(unsigned char c) {
-  return std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '!' || c == '~' || c == '*' || c == '\'' ||
-         c == '(' || c == ')';
-}
-
-// Matches JS's encodeURIComponent exactly (same unreserved-character set),
-// so magnets built here byte-match what the TS original produces.
-std::string encodeURIComponent(const std::string& s) {
-  std::string out;
-  out.reserve(s.size());
-  static const char* hex = "0123456789ABCDEF";
-  for (unsigned char c : s) {
-    if (isUnreserved(c)) {
-      out += static_cast<char>(c);
-    } else {
-      out += '%';
-      out += hex[c >> 4];
-      out += hex[c & 0xF];
-    }
-  }
-  return out;
-}
-
-// Decodes %XX escapes and '+' as space, matching URLSearchParams::get().
-std::string decodeURIComponent(const std::string& s) {
-  std::string out;
-  out.reserve(s.size());
-  for (std::size_t i = 0; i < s.size(); ++i) {
-    if (s[i] == '+') {
-      out += ' ';
-    } else if (s[i] == '%' && i + 2 < s.size() && std::isxdigit(static_cast<unsigned char>(s[i + 1])) &&
-               std::isxdigit(static_cast<unsigned char>(s[i + 2]))) {
-      out += static_cast<char>(std::stoi(s.substr(i + 1, 2), nullptr, 16));
-      i += 2;
-    } else {
-      out += s[i];
-    }
-  }
-  return out;
-}
-
 std::string trim(const std::string& s) {
   auto first = s.find_first_not_of(" \t\r\n");
   if (first == std::string::npos) return "";
diff --git a/src/sources/nyaa.cpp b/src/sources/nyaa.cpp
new file mode 100644
index 0000000..c739336
--- /dev/null
+++ b/src/sources/nyaa.cpp
@@ -0,0 +1,90 @@
+#include "torlinkc/sources/nyaa.hpp"
+
+#include <regex>
+
+#include "torlinkc/sources/magnet.hpp"
+#include "torlinkc/sources/rss.hpp"
+#include "torlinkc/util/date_parse.hpp"
+#include "torlinkc/util/format.hpp"
+#include "torlinkc/util/net.hpp"
+#include "torlinkc/util/url_encode.hpp"
+
+namespace torlinkc {
+
+namespace {
+
+std::string trim(const std::string& s) {
+  auto first = s.find_first_not_of(" \t\r\n");
+  if (first == std::string::npos) return "";
+  auto last = s.find_last_not_of(" \t\r\n");
+  return s.substr(first, last - first + 1);
+}
+
+std::string toLower(std::string s) {
+  for (auto& c : s) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
+  return s;
+}
+
+// [\s\S]*? stands in for JS's dotall `.*?` (std::regex has no dotall flag).
+std::string tagValue(const std::string& item, const std::string& name) {
+  const std::regex re("<" + name + R"(>(?:<!\[CDATA\[)?([\s\S]*?)(?:\]\]>)?</)" + name + ">");
+  std::smatch m;
+  if (!std::regex_search(item, m, re)) return "";
+  return trim(m[1].str());
+}
+
+int toIntOrZero(const std::string& s) {
+  try {
+    return std::stoi(s);
+  } catch (...) {
+    return 0;
+  }
+}
+
+}  // namespace
+
+Source nyaaSource() {
+  Source s;
+  s.id = "nyaa";
+  s.label = "Nyaa";
+  s.groups = {SourceGroup::Anime};
+  s.homepage = "https://nyaa.si";
+  s.reportsHealth = true;
+  s.search = [](const std::string& query, const SearchOptions& opts) {
+    const std::string url = "https://nyaa.si/?page=rss&q=" + encodeURIComponent(trim(query)) + "&c=0_0&f=0";
+
+    FetchOptions fetchOpts;
+    fetchOpts.stopToken = opts.stopToken;
+    HttpResponse res = fetchResilient(url, fetchOpts);
+    if (!res.ok()) throw HttpError(res.status, "Nyaa returned " + std::to_string(res.status));
+
+    std::vector<TorrentResult> out;
+    const std::string marker = "<item>";
+    std::size_t pos = res.body.find(marker);
+    while (pos != std::string::npos) {
+      const std::size_t start = pos + marker.size();
+      const std::size_t next = res.body.find(marker, start);
+      const std::string item = res.body.substr(start, next == std::string::npos ? std::string::npos : next - start);
+      pos = next;
+
+      const std::string infoHash = toLower(tagValue(item, "nyaa:infoHash"));
+      const std::string name = unescapeEntities(tagValue(item, "title"));
+      if (infoHash.empty() || name.empty()) continue;
+
+      TorrentResult r;
+      r.infoHash = infoHash;
+      r.name = name;
+      r.sizeBytes = parseSize(tagValue(item, "nyaa:size"));
+      r.seeders = toIntOrZero(tagValue(item, "nyaa:seeders"));
+      r.leechers = toIntOrZero(tagValue(item, "nyaa:leechers"));
+      r.source = "nyaa";
+      r.magnet = buildMagnet(infoHash, name);
+      r.added = parseDateToUnixSeconds(tagValue(item, "pubDate"));
+      out.push_back(std::move(r));
+    }
+    return out;
+  };
+  return s;
+}
+
+}  // namespace torlinkc
diff --git a/src/sources/piratebay.cpp b/src/sources/piratebay.cpp
index 07d41d0..c023256 100644
--- a/src/sources/piratebay.cpp
+++ b/src/sources/piratebay.cpp
@@ -2,6 +2,7 @@

 #include <cctype>
 #include <cstdlib>
+#include <stop_token>
 #include <string>
 #include <unordered_set>

@@ -9,6 +10,7 @@

 #include "torlinkc/sources/magnet.hpp"
 #include "torlinkc/util/net.hpp"
+#include "torlinkc/util/url_encode.hpp"

 using nlohmann::json;

@@ -26,27 +28,6 @@ const std::string kTopTv = std::string(kApi) + "/precompiled/data_top100_208.jso

 const std::string kZeroHash(40, '0');

-bool isUnreserved(unsigned char c) {
-  return std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '!' || c == '~' || c == '*' || c == '\'' ||
-         c == '(' || c == ')';
-}
-
-std::string encodeURIComponent(const std::string& s) {
-  std::string out;
-  out.reserve(s.size());
-  static const char* hex = "0123456789ABCDEF";
-  for (unsigned char c : s) {
-    if (isUnreserved(c)) {
-      out += static_cast<char>(c);
-    } else {
-      out += '%';
-      out += hex[c >> 4];
-      out += hex[c & 0xF];
-    }
-  }
-  return out;
-}
-
 std::string trim(const std::string& s) {
   auto first = s.find_first_not_of(" \t\r\n");
   if (first == std::string::npos) return "";
@@ -97,9 +78,10 @@ std::optional<TorrentResult> toResult(const json& item, const std::string& sourc
   return r;
 }

-json fetchItems(const std::string& url, int retries = 1) {
+json fetchItems(const std::string& url, const std::stop_token& stopToken, int retries = 1) {
   FetchOptions opts;
   opts.retries = retries;
+  opts.stopToken = stopToken;
   HttpResponse res = fetchResilient(url, opts);
   if (!res.ok()) throw HttpError(res.status, "Pirate Bay returned " + std::to_string(res.status));
   json parsed = json::parse(res.body);
@@ -115,16 +97,17 @@ bool isNoResultsSentinel(const json& items) {
 // bogus sentinel on one URL form while the alternate form answers fine. One
 // retry on the explicit-category form re-rolls that cache key; a genuinely
 // empty search costs one extra request and still comes back empty.
-json searchItems(const std::string& q) {
-  json items = fetchItems(std::string(kApi) + "/q.php?q=" + encodeURIComponent(q));
+json searchItems(const std::string& q, const std::stop_token& stopToken) {
+  json items = fetchItems(std::string(kApi) + "/q.php?q=" + encodeURIComponent(q), stopToken);
   if (!isNoResultsSentinel(items)) return items;
-  return fetchItems(std::string(kApi) + "/q.php?q=" + encodeURIComponent(q) + "&cat=0");
+  return fetchItems(std::string(kApi) + "/q.php?q=" + encodeURIComponent(q) + "&cat=0", stopToken);
 }

 std::vector<TorrentResult> search(const std::string& query, const std::unordered_set<int>& cats,
-                                   const std::string& browseUrl, const std::string& sourceId) {
+                                   const std::string& browseUrl, const std::string& sourceId,
+                                   const std::stop_token& stopToken) {
   const std::string q = trim(query);
-  const json items = q.empty() ? fetchItems(browseUrl) : searchItems(q);
+  const json items = q.empty() ? fetchItems(browseUrl, stopToken) : searchItems(q, stopToken);

   std::vector<TorrentResult> out;
   for (const auto& item : items) {
@@ -150,8 +133,8 @@ Source tpbMoviesSource() {
   s.groups = {SourceGroup::Movies};
   s.homepage = "https://thepiratebay.org";
   s.reportsHealth = true;
-  s.search = [](const std::string& query, const SearchOptions&) {
-    return search(query, kMovieCats, kTopMovies, "tpb-movies");
+  s.search = [](const std::string& query, const SearchOptions& opts) {
+    return search(query, kMovieCats, kTopMovies, "tpb-movies", opts.stopToken);
   };
   return s;
 }
@@ -163,8 +146,8 @@ Source tpbTvSource() {
   s.groups = {SourceGroup::TV};
   s.homepage = "https://thepiratebay.org";
   s.reportsHealth = true;
-  s.search = [](const std::string& query, const SearchOptions&) {
-    return search(query, kTvCats, kTopTv, "tpb-tv");
+  s.search = [](const std::string& query, const SearchOptions& opts) {
+    return search(query, kTvCats, kTopTv, "tpb-tv", opts.stopToken);
   };
   return s;
 }
diff --git a/src/sources/registry.cpp b/src/sources/registry.cpp
new file mode 100644
index 0000000..f6710d8
--- /dev/null
+++ b/src/sources/registry.cpp
@@ -0,0 +1,72 @@
+#include "torlinkc/sources/registry.hpp"
+
+#include <algorithm>
+
+#include "torlinkc/sources/bittorrented.hpp"
+#include "torlinkc/sources/eztv.hpp"
+#include "torlinkc/sources/fitgirl.hpp"
+#include "torlinkc/sources/nyaa.hpp"
+#include "torlinkc/sources/piratebay.hpp"
+#include "torlinkc/sources/subsplease.hpp"
+#include "torlinkc/sources/x1337.hpp"
+#include "torlinkc/sources/yts.hpp"
+
+namespace torlinkc {
+
+std::vector<Source> allSources() {
+  // Order matches registry.ts::SOURCES, which the default results ordering
+  // and UI source-status list are built to expect.
+  return {
+      fitgirlSource(), ytsSource(),    tpbMoviesSource(), x1337MoviesSource(), eztvSource(),
+      tpbTvSource(),    x1337TvSource(), nyaaSource(),    subspleaseSource(),  bittorrentedSource(),
+  };
+}
+
+std::unordered_map<std::string, Source> sourceById() {
+  std::unordered_map<std::string, Source> out;
+  for (auto& s : allSources()) {
+    const std::string id = s.id;
+    out.emplace(id, std::move(s));
+  }
+  return out;
+}
+
+std::string categoryLabel(Category c) {
+  switch (c) {
+    case Category::All:
+      return "All";
+    case Category::Games:
+      return "Games";
+    case Category::Movies:
+      return "Movies";
+    case Category::TV:
+      return "TV";
+    case Category::Anime:
+      return "Anime";
+  }
+  return "All";
+}
+
+bool sourceInCategory(const Source& source, Category c) {
+  if (c == Category::All) return true;
+  SourceGroup group;
+  switch (c) {
+    case Category::Games:
+      group = SourceGroup::Games;
+      break;
+    case Category::Movies:
+      group = SourceGroup::Movies;
+      break;
+    case Category::TV:
+      group = SourceGroup::TV;
+      break;
+    case Category::Anime:
+      group = SourceGroup::Anime;
+      break;
+    default:
+      return true;
+  }
+  return std::find(source.groups.begin(), source.groups.end(), group) != source.groups.end();
+}
+
+}  // namespace torlinkc
diff --git a/src/sources/rss.cpp b/src/sources/rss.cpp
new file mode 100644
index 0000000..4564b95
--- /dev/null
+++ b/src/sources/rss.cpp
@@ -0,0 +1,136 @@
+#include "torlinkc/sources/rss.hpp"
+
+#include <regex>
+#include <unordered_set>
+
+#include "torlinkc/util/date_parse.hpp"
+#include "torlinkc/util/net.hpp"
+#include "torlinkc/util/url_encode.hpp"
+
+namespace torlinkc {
+
+namespace {
+
+constexpr int kWpFeedPageSize = 10;
+constexpr int kFeedDepth = 3;
+constexpr int kDeepPageRetries = 2;
+
+std::string trim(const std::string& s) {
+  auto first = s.find_first_not_of(" \t\r\n");
+  if (first == std::string::npos) return "";
+  auto last = s.find_last_not_of(" \t\r\n");
+  return s.substr(first, last - first + 1);
+}
+
+std::size_t countOccurrences(const std::string& haystack, const std::string& needle) {
+  std::size_t count = 0, pos = 0;
+  while ((pos = haystack.find(needle, pos)) != std::string::npos) {
+    count++;
+    pos += needle.size();
+  }
+  return count;
+}
+
+std::optional<std::string> matchFirst(const std::string& s, const std::regex& re) {
+  std::smatch m;
+  if (!std::regex_search(s, m, re)) return std::nullopt;
+  return m[1].str();
+}
+
+std::vector<TorrentResult> parseRssItems(const std::string& xml, const std::string& source) {
+  static const std::regex kMagnetHref(R"re(href="(magnet:\?xt=urn:btih:[^"]+)")re", std::regex::icase);
+  static const std::regex kInfoHash(R"(urn:btih:([a-zA-Z0-9]+))");
+  static const std::regex kTitle(R"(<title>(.*?)</title>)");
+  static const std::regex kPubDate(R"(<pubDate>(.*?)</pubDate>)");
+
+  std::vector<TorrentResult> out;
+  const std::string marker = "<item>";
+  std::size_t pos = xml.find(marker);
+  while (pos != std::string::npos) {
+    std::size_t start = pos + marker.size();
+    std::size_t next = xml.find(marker, start);
+    const std::string item = xml.substr(start, next == std::string::npos ? std::string::npos : next - start);
+    pos = next;
+
+    auto magnetRaw = matchFirst(item, kMagnetHref);
+    if (!magnetRaw) continue;
+    const std::string magnet = unescapeEntities(*magnetRaw);
+    auto hashMatch = matchFirst(magnet, kInfoHash);
+    if (!hashMatch) continue;
+    std::string infoHash = *hashMatch;
+    for (auto& c : infoHash) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
+    if (infoHash.empty()) continue;
+
+    const std::string name = unescapeEntities(matchFirst(item, kTitle).value_or("Unknown Title"));
+    const std::optional<std::int64_t> added = parseDateToUnixSeconds(matchFirst(item, kPubDate).value_or(""));
+
+    TorrentResult r;
+    r.infoHash = infoHash;
+    r.name = name;
+    r.source = source;
+    r.magnet = magnet;
+    r.added = added;
+    out.push_back(std::move(r));
+  }
+  return out;
+}
+
+std::string feedUrl(const std::string& base, const std::string& query, int page) {
+  const std::string q = trim(query);
+  std::string url = q.empty() ? base + "/feed/" : base + "/?s=" + encodeURIComponent(q) + "&feed=rss2";
+  if (page <= 1) return url;
+  return url + (q.empty() ? "?" : "&") + "paged=" + std::to_string(page);
+}
+
+std::string fetchFeedPage(const std::string& url, const std::string& source, const SearchOptions& opts,
+                           std::optional<int> retries) {
+  FetchOptions fetchOpts;
+  fetchOpts.stopToken = opts.stopToken;
+  if (retries) fetchOpts.retries = *retries;
+  HttpResponse res = fetchResilient(url, fetchOpts);
+  if (!res.ok()) throw HttpError(res.status, source + " feed returned " + std::to_string(res.status));
+  return res.body;
+}
+
+}  // namespace
+
+std::string unescapeEntities(const std::string& s) {
+  static const std::pair<std::regex, const char*> kReplacements[] = {
+      {std::regex(R"(&#0?38;|&amp;)"), "&"}, {std::regex(R"(&#8211;|&#8212;)"), "-"},
+      {std::regex(R"(&#8217;|&#0?39;|&apos;)"), "'"}, {std::regex(R"(&#8220;|&#8221;|&quot;)"), "\""},
+      {std::regex(R"(&lt;)"), "<"}, {std::regex(R"(&gt;)"), ">"},
+  };
+  std::string out = s;
+  for (const auto& [re, replacement] : kReplacements) out = std::regex_replace(out, re, replacement);
+  return out;
+}
+
+std::vector<TorrentResult> fetchWordpressRss(const std::string& base, const std::string& sourceId,
+                                              const std::string& query, const SearchOptions& opts) {
+  const std::string first = fetchFeedPage(feedUrl(base, query, 1), sourceId, opts, std::nullopt);
+  std::vector<TorrentResult> results = parseRssItems(first, sourceId);
+
+  if (countOccurrences(first, "<item>") < static_cast<std::size_t>(kWpFeedPageSize)) return results;
+
+  std::unordered_set<std::string> seen;
+  for (const auto& r : results) seen.insert(r.infoHash);
+
+  // Fetched sequentially rather than in parallel (the TS original uses
+  // Promise.all for these): this already runs on its own per-source thread
+  // in the Phase 3 SearchAggregator, so a little extra latency here doesn't
+  // block anything else, and it avoids spinning up nested worker threads for
+  // what's a rarely-exercised deep-pagination path.
+  for (int page = 2; page < kFeedDepth + 1; ++page) {
+    try {
+      const std::string xml = fetchFeedPage(feedUrl(base, query, page), sourceId, opts, kDeepPageRetries);
+      for (auto& r : parseRssItems(xml, sourceId)) {
+        if (seen.insert(r.infoHash).second) results.push_back(std::move(r));
+      }
+    } catch (const std::exception&) {
+      // A deeper page failing doesn't invalidate what page 1 already found.
+    }
+  }
+  return results;
+}
+
+}  // namespace torlinkc
diff --git a/src/sources/subsplease.cpp b/src/sources/subsplease.cpp
new file mode 100644
index 0000000..6f6572e
--- /dev/null
+++ b/src/sources/subsplease.cpp
@@ -0,0 +1,96 @@
+#include "torlinkc/sources/subsplease.hpp"
+
+#include <array>
+#include <regex>
+
+#include <nlohmann/json.hpp>
+
+#include "torlinkc/sources/magnet.hpp"
+#include "torlinkc/util/date_parse.hpp"
+#include "torlinkc/util/net.hpp"
+#include "torlinkc/util/url_encode.hpp"
+
+using nlohmann::json;
+
+namespace torlinkc {
+
+namespace {
+
+constexpr std::array<const char*, 3> kResPreference = {"1080", "720", "480"};
+
+std::string trim(const std::string& s) {
+  auto first = s.find_first_not_of(" \t\r\n");
+  if (first == std::string::npos) return "";
+  auto last = s.find_last_not_of(" \t\r\n");
+  return s.substr(first, last - first + 1);
+}
+
+std::optional<json> pickBest(const json& downloads) {
+  for (const char* res : kResPreference) {
+    for (const auto& d : downloads) {
+      if (d.value("res", "") == res && !d.value("magnet", "").empty()) return d;
+    }
+  }
+  for (const auto& d : downloads) {
+    if (!d.value("magnet", "").empty()) return d;
+  }
+  return std::nullopt;
+}
+
+}  // namespace
+
+Source subspleaseSource() {
+  Source s;
+  s.id = "subsplease";
+  s.label = "SubsPlease";
+  s.groups = {SourceGroup::Anime};
+  s.homepage = "https://subsplease.org";
+  // The SubsPlease API has no swarm data; every result reports seeders: 0.
+  s.reportsHealth = false;
+  s.search = [](const std::string& query, const SearchOptions& opts) {
+    const std::string q = trim(query);
+    std::string qs = "tz=UTC";
+    qs += q.empty() ? "&f=latest" : "&f=search&s=" + encodeURIComponent(q);
+
+    FetchOptions fetchOpts;
+    fetchOpts.stopToken = opts.stopToken;
+    HttpResponse res = fetchResilient("https://subsplease.org/api/?" + qs, fetchOpts);
+    if (!res.ok()) throw HttpError(res.status, "SubsPlease returned " + std::to_string(res.status));
+
+    const json root = json::parse(res.body);
+    std::vector<TorrentResult> out;
+    if (!root.is_object()) return out;
+
+    static const std::regex kSizeRe(R"([?&]xl=(\d+))");
+
+    for (const auto& entry : root.items()) {
+      const json& e = entry.value();
+      const auto downloads = e.value("downloads", json::array());
+      auto dl = pickBest(downloads);
+      if (!dl) continue;
+      const std::string magnet = dl->value("magnet", "");
+      auto parsed = parseMagnet(magnet);
+      if (!parsed) continue;
+
+      const std::string show = e.value("show", "Unknown");
+      const std::string episode = e.value("episode", "");
+      const std::string resStr = dl->value("res", "?");
+
+      std::smatch m;
+      const std::int64_t sizeBytes = std::regex_search(magnet, m, kSizeRe) ? std::stoll(m[1].str()) : 0;
+
+      TorrentResult r;
+      r.infoHash = parsed->infoHash;
+      r.name = show + (episode.empty() ? "" : " - " + episode) + " [" + resStr + "p]";
+      r.sizeBytes = sizeBytes;
+      r.source = "subsplease";
+      r.magnet = parsed->magnet;
+      r.added = parseDateToUnixSeconds(e.value("release_date", ""));
+      out.push_back(std::move(r));
+    }
+    return out;
+  };
+  return s;
+}
+
+}  // namespace torlinkc
diff --git a/src/sources/x1337.cpp b/src/sources/x1337.cpp
new file mode 100644
index 0000000..9312b9e
--- /dev/null
+++ b/src/sources/x1337.cpp
@@ -0,0 +1,280 @@
+#include "torlinkc/sources/x1337.hpp"
+
+#include <algorithm>
+#include <array>
+#include <atomic>
+#include <cctype>
+#include <ctime>
+#include <map>
+#include <regex>
+#include <sstream>
+#include <unordered_set>
+
+#include "torlinkc/sources/rss.hpp"
+#include "torlinkc/util/format.hpp"
+#include "torlinkc/util/net.hpp"
+#include "torlinkc/util/url_encode.hpp"
+
+namespace torlinkc {
+
+namespace {
+
+constexpr std::array<const char*, 4> kHosts = {"1337x.to", "1337x.st", "x1337x.ws", "1337xx.to"};
+// Shared mutable state across the two 1337x sources (movies + TV), which the
+// Phase 3 SearchAggregator queries concurrently on separate threads -- an
+// atomic (vs. the TS original's plain module-level `let`) is what makes that
+// safe here.
+std::atomic<int> gWorkingHostIndex{0};
+
+constexpr int kMaxDetails = 4;
+
+const std::unordered_set<std::string> kStopWords = {"the", "a", "an", "of", "and", "or", "to"};
+
+std::string trim(const std::string& s) {
+  auto first = s.find_first_not_of(" \t\r\n");
+  if (first == std::string::npos) return "";
+  auto last = s.find_last_not_of(" \t\r\n");
+  return s.substr(first, last - first + 1);
+}
+
+std::string toLower(std::string s) {
+  std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return std::tolower(c); });
+  return s;
+}
+
+std::vector<std::string> splitWhitespace(const std::string& s) {
+  std::istringstream iss(s);
+  std::vector<std::string> out;
+  std::string tok;
+  while (iss >> tok) out.push_back(tok);
+  return out;
+}
+
+struct Row {
+  std::string name;
+  std::string path;
+  int seeders = 0;
+  int leechers = 0;
+  std::int64_t sizeBytes = 0;
+};
+
+// Splits like JS's String.split(/<tr[\s>]/i): n matches produce n+1 segments,
+// segment[0] being whatever preceded the first match.
+std::vector<std::string> splitOnTrTag(const std::string& html) {
+  static const std::regex kTrTag(R"(<tr[\s>])", std::regex::icase);
+  std::vector<std::string> segments;
+  auto begin = std::sregex_iterator(html.begin(), html.end(), kTrTag);
+  auto end = std::sregex_iterator();
+  std::size_t lastEnd = 0;
+  for (auto it = begin; it != end; ++it) {
+    segments.push_back(html.substr(lastEnd, static_cast<std::size_t>(it->position()) - lastEnd));
+    lastEnd = static_cast<std::size_t>(it->position() + it->length());
+  }
+  segments.push_back(html.substr(lastEnd));
+  return segments;
+}
+
+std::vector<Row> parseRows(const std::string& html) {
+  auto start = html.find("table-list");
+  if (start == std::string::npos) return {};
+
+  static const std::regex kLink(R"re(href="(/torrent/[^"]+)"[^>]*>([^<]+)</a>)re", std::regex::icase);
+  static const std::regex kSize(R"(class="coll-4 size[^"]*">\s*([\d.]+\s*[KMGT]i?B))", std::regex::icase);
+  static const std::regex kSeeds(R"(class="coll-2 seeds[^"]*">\s*(\d+))", std::regex::icase);
+  static const std::regex kLeeches(R"(class="coll-3 leeches[^"]*">\s*(\d+))", std::regex::icase);
+
+  std::vector<Row> out;
+  auto segments = splitOnTrTag(html.substr(start));
+  for (std::size_t i = 1; i < segments.size(); ++i) {
+    const std::string& tr = segments[i];
+    std::smatch link;
+    if (!std::regex_search(tr, link, kLink)) continue;
+
+    Row row;
+    row.path = link[1].str();
+    row.name = unescapeEntities(trim(link[2].str()));
+    std::smatch m;
+    row.sizeBytes = std::regex_search(tr, m, kSize) ? parseSize(m[1].str()) : 0;
+    row.seeders = std::regex_search(tr, m, kSeeds) ? std::stoi(m[1].str()) : 0;
+    row.leechers = std::regex_search(tr, m, kLeeches) ? std::stoi(m[1].str()) : 0;
+    out.push_back(std::move(row));
+  }
+  return out;
+}
+
+const std::map<std::string, int>& monthTable() {
+  static const std::map<std::string, int> months = {{"jan", 0}, {"feb", 1}, {"mar", 2}, {"apr", 3}, {"may", 4},
+                                                      {"jun", 5}, {"jul", 6}, {"aug", 7}, {"sep", 8}, {"oct", 9},
+                                                      {"nov", 10}, {"dec", 11}};
+  return months;
+}
+
+struct DetailInfo {
+  std::string magnet;
+  std::optional<std::int64_t> added;
+};
+
+std::optional<DetailInfo> fetchDetail(const std::string& base, const std::string& path,
+                                       const std::stop_token& stopToken) {
+  try {
+    FetchOptions opts;
+    opts.retries = 1;
+    opts.stopToken = stopToken;
+    HttpResponse res = fetchResilient(base + path, opts);
+    if (!res.ok()) return std::nullopt;
+
+    static const std::regex kMagnetRe(R"(magnet:\?xt=urn:btih:[^"'<>\s]+)", std::regex::icase);
+    std::smatch m;
+    if (!std::regex_search(res.body, m, kMagnetRe)) return std::nullopt;
+
+    DetailInfo info;
+    info.magnet = unescapeEntities(m[0].str());
+    info.added = parseUploadDate(res.body);
+    return info;
+  } catch (const std::exception&) {
+    return std::nullopt;
+  }
+}
+
+std::vector<TorrentResult> search(const std::string& query, const std::string& category, const std::string& source,
+                                   const std::stop_token& stopToken) {
+  const std::string q = trim(query);
+  std::string path;
+  if (!q.empty()) {
+    std::string encoded = encodeURIComponent(q);
+    std::string withPlus;
+    withPlus.reserve(encoded.size());
+    for (std::size_t i = 0; i < encoded.size();) {
+      if (encoded.compare(i, 3, "%20") == 0) {
+        withPlus += '+';
+        i += 3;
+      } else {
+        withPlus += encoded[i];
+        i += 1;
+      }
+    }
+    path = "/category-search/" + withPlus + "/" + category + "/1/";
+  } else {
+    path = category == "Movies" ? "/popular-movies" : "/popular-tv";
+  }
+
+  std::string base, html;
+  std::exception_ptr lastError;
+  const int startIdx = gWorkingHostIndex.load();
+  for (std::size_t i = 0; i < kHosts.size(); ++i) {
+    const std::size_t hostIdx = (static_cast<std::size_t>(startIdx) + i) % kHosts.size();
+    const std::string candidate = std::string("https://") + kHosts[hostIdx];
+    try {
+      FetchOptions opts;
+      opts.retries = i == 0 ? 2 : 0;
+      opts.stopToken = stopToken;
+      HttpResponse res = fetchResilient(candidate + path, opts);
+      if (!res.ok()) throw HttpError(res.status, "1337x returned " + std::to_string(res.status));
+      html = res.body;
+      base = candidate;
+      gWorkingHostIndex.store(static_cast<int>(hostIdx));
+      break;
+    } catch (const std::exception&) {
+      lastError = std::current_exception();
+      if (stopToken.stop_requested()) std::rethrow_exception(lastError);
+    }
+  }
+  if (base.empty()) {
+    if (lastError) std::rethrow_exception(lastError);
+    throw HttpError(0, "1337x unreachable");
+  }
+
+  const auto all = parseRows(html);
+  const auto tokens = splitWhitespace(toLower(q));
+  std::vector<std::string> meaningful;
+  for (const auto& t : tokens)
+    if (!kStopWords.count(t)) meaningful.push_back(t);
+  const std::vector<std::string>& need = meaningful.empty() ? tokens : meaningful;
+
+  std::vector<Row> matched;
+  if (!need.empty()) {
+    for (const auto& r : all) {
+      const std::string name = toLower(r.name);
+      bool matchesAll = true;
+      for (const auto& t : need) {
+        if (name.find(t) == std::string::npos) {
+          matchesAll = false;
+          break;
+        }
+      }
+      if (matchesAll) matched.push_back(r);
+    }
+  } else {
+    matched = all;
+  }
+  std::stable_sort(matched.begin(), matched.end(), [](const Row& a, const Row& b) { return a.seeders > b.seeders; });
+  if (matched.size() > static_cast<std::size_t>(kMaxDetails)) matched.resize(kMaxDetails);
+
+  static const std::regex kInfoHashRe(R"(urn:btih:([a-zA-Z0-9]+))", std::regex::icase);
+  std::vector<TorrentResult> out;
+  for (const auto& row : matched) {
+    auto detail = fetchDetail(base, row.path, stopToken);
+    if (!detail) continue;
+    std::smatch m;
+    if (!std::regex_search(detail->magnet, m, kInfoHashRe)) continue;
+
+    TorrentResult r;
+    r.infoHash = toLower(m[1].str());
+    r.name = row.name;
+    r.sizeBytes = row.sizeBytes;
+    r.seeders = row.seeders;
+    r.leechers = row.leechers;
+    r.source = source;
+    r.magnet = detail->magnet;
+    r.added = detail->added;
+    out.push_back(std::move(r));
+  }
+  return out;
+}
+
+}  // namespace
+
+std::optional<std::int64_t> parseUploadDate(const std::string& html) {
+  static const std::regex kRe(R"(Date uploaded</strong>\s*<span>\s*([A-Za-z]{3})\.?\s+(\d{1,2})[a-z]{2}\s*'(\d{2}))",
+                               std::regex::icase);
+  std::smatch m;
+  if (!std::regex_search(html, m, kRe)) return std::nullopt;
+
+  const auto it = monthTable().find(toLower(m[1].str()));
+  if (it == monthTable().end()) return std::nullopt;
+
+  std::tm tm{};
+  tm.tm_year = 2000 + std::stoi(m[3].str()) - 1900;
+  tm.tm_mon = it->second;
+  tm.tm_mday = std::stoi(m[2].str());
+  const time_t secs = timegm(&tm);
+  return secs == static_cast<time_t>(-1) ? std::nullopt : std::optional<std::int64_t>(secs);
+}
+
+Source x1337MoviesSource() {
+  Source s;
+  s.id = "x1337-movies";
+  s.label = "1337x";
+  s.groups = {SourceGroup::Movies};
+  s.homepage = "https://1337x.to";
+  s.reportsHealth = true;
+  s.search = [](const std::string& query, const SearchOptions& opts) {
+    return search(query, "Movies", "x1337-movies", opts.stopToken);
+  };
+  return s;
+}
+
+Source x1337TvSource() {
+  Source s;
+  s.id = "x1337-tv";
+  s.label = "1337x";
+  s.groups = {SourceGroup::TV};
+  s.homepage = "https://1337x.to";
+  s.reportsHealth = true;
+  s.search = [](const std::string& query, const SearchOptions& opts) {
+    return search(query, "TV", "x1337-tv", opts.stopToken);
+  };
+  return s;
+}
+
+}  // namespace torlinkc
diff --git a/src/sources/yts.cpp b/src/sources/yts.cpp
new file mode 100644
index 0000000..dedcc88
--- /dev/null
+++ b/src/sources/yts.cpp
@@ -0,0 +1,95 @@
+#include "torlinkc/sources/yts.hpp"
+
+#include <array>
+#include <stop_token>
+
+#include <nlohmann/json.hpp>
+
+#include "torlinkc/sources/magnet.hpp"
+#include "torlinkc/util/net.hpp"
+#include "torlinkc/util/url_encode.hpp"
+
+using nlohmann::json;
+
+namespace torlinkc {
+
+namespace {
+
+constexpr std::array<const char*, 3> kHosts = {"yts.mx", "yts.am", "yts.rs"};
+
+json fetchMovies(const std::string& query, const std::stop_token& stopToken) {
+  std::string qs = "limit=50";
+  qs += query.empty() ? "&sort_by=date_added" : "&query_term=" + encodeURIComponent(query);
+
+  std::exception_ptr lastError;
+  for (const char* host : kHosts) {
+    if (stopToken.stop_requested()) throw HttpError(0, "aborted");
+    try {
+      FetchOptions opts;
+      opts.retries = 1;
+      opts.stopToken = stopToken;
+      HttpResponse res = fetchResilient("https://" + std::string(host) + "/api/v2/list_movies.json?" + qs, opts);
+      if (res.ok()) return json::parse(res.body);
+      lastError = std::make_exception_ptr(HttpError(res.status, "YTS returned " + std::to_string(res.status)));
+    } catch (const HttpError&) {
+      lastError = std::current_exception();
+      if (stopToken.stop_requested()) std::rethrow_exception(lastError);
+    }
+  }
+  if (lastError) std::rethrow_exception(lastError);
+  throw HttpError(0, "YTS unreachable");
+}
+
+}  // namespace
+
+Source ytsSource() {
+  Source s;
+  s.id = "yts";
+  s.label = "YTS";
+  s.groups = {SourceGroup::Movies};
+  s.homepage = "https://yts.mx";
+  s.reportsHealth = true;
+  s.search = [](const std::string& query, const SearchOptions& opts) {
+    const std::string q = query;  // trimming happens implicitly: an all-whitespace query behaves like empty here
+    const json root = fetchMovies(q, opts.stopToken);
+
+    std::vector<TorrentResult> out;
+    auto movies = root.value("data", json::object()).value("movies", json::array());
+    for (const auto& movie : movies) {
+      std::string base = movie.value("title_long", "");
+      if (base.empty()) base = movie.value("title", "Unknown");
+      const auto added = movie.contains("date_uploaded_unix") && movie["date_uploaded_unix"].is_number()
+                              ? std::optional<std::int64_t>(movie["date_uploaded_unix"].get<std::int64_t>())
+                              : std::nullopt;
+
+      for (const auto& t : movie.value("torrents", json::array())) {
+        const std::string hashRaw = t.value("hash", "");
+        if (hashRaw.empty()) continue;
+        std::string infoHash = hashRaw;
+        for (auto& c : infoHash) c = static_cast<char>(std::tolower(static_cast<unsigned char>(c)));
+
+        std::string tag;
+        for (const char* key : {"quality", "type"}) {
+          const std::string v = t.value(key, "");
+          if (!v.empty()) tag += (tag.empty() ? "" : " ") + v;
+        }
+        const std::string name = tag.empty() ? base : base + " [" + tag + "]";
+
+        TorrentResult r;
+        r.infoHash = infoHash;
+        r.name = name;
+        r.sizeBytes = t.value("size_bytes", static_cast<std::int64_t>(0));
+        r.seeders = t.value("seeds", 0);
+        r.leechers = t.value("peers", 0);
+        r.source = "yts";
+        r.magnet = buildMagnet(infoHash, name);
+        r.added = added;
+        out.push_back(std::move(r));
+      }
+    }
+    return out;
+  };
+  return s;
+}
+
+}  // namespace torlinkc
diff --git a/src/ui/coalescing_notifier.cpp b/src/ui/coalescing_notifier.cpp
new file mode 100644
index 0000000..2cba3a0
--- /dev/null
+++ b/src/ui/coalescing_notifier.cpp
@@ -0,0 +1,53 @@
+#include "torlinkc/ui/coalescing_notifier.hpp"
+
+namespace torlinkc::ui {
+
+CoalescingNotifier::CoalescingNotifier(std::chrono::milliseconds window, std::function<void()> onFlush)
+    : window_(window), onFlush_(std::move(onFlush)) {
+  thread_ = std::thread([this] { run(); });
+}
+
+CoalescingNotifier::~CoalescingNotifier() {
+  {
+    std::lock_guard<std::mutex> lock(mutex_);
+    stop_ = true;
+  }
+  cv_.notify_all();
+  if (thread_.joinable()) thread_.join();
+}
+
+void CoalescingNotifier::markDirty() {
+  std::lock_guard<std::mutex> lock(mutex_);
+  dirty_ = true;
+  cv_.notify_all();
+}
+
+void CoalescingNotifier::flushNow() {
+  std::lock_guard<std::mutex> lock(mutex_);
+  dirty_ = true;
+  flushRequested_ = true;
+  cv_.notify_all();
+}
+
+void CoalescingNotifier::run() {
+  std::unique_lock<std::mutex> lock(mutex_);
+  while (!stop_) {
+    cv_.wait(lock, [this] { return dirty_ || stop_; });
+    if (stop_) break;
+
+    // Wait out the coalescing window, unless flushNow() or stop wakes us
+    // early -- this is the "further calls before the deadline are absorbed"
+    // half of the contract: any markDirty() during this wait just leaves
+    // dirty_ (already) true without re-arming a fresh window.
+    cv_.wait_for(lock, window_, [this] { return flushRequested_ || stop_; });
+    if (stop_) break;
+
+    dirty_ = false;
+    flushRequested_ = false;
+    lock.unlock();
+    onFlush_();
+    lock.lock();
+  }
+}
+
+}  // namespace torlinkc::ui
diff --git a/src/ui/filter.cpp b/src/ui/filter.cpp
new file mode 100644
index 0000000..aefa62f
--- /dev/null
+++ b/src/ui/filter.cpp
@@ -0,0 +1,99 @@
+#include "torlinkc/ui/filter.hpp"
+
+#include <algorithm>
+#include <cctype>
+#include <sstream>
+
+namespace torlinkc::ui {
+
+namespace {
+
+std::string toLower(std::string s) {
+  std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return std::tolower(c); });
+  return s;
+}
+
+std::string trim(const std::string& s) {
+  auto first = s.find_first_not_of(" \t\r\n");
+  if (first == std::string::npos) return "";
+  auto last = s.find_last_not_of(" \t\r\n");
+  return s.substr(first, last - first + 1);
+}
+
+std::vector<std::string> splitWhitespace(const std::string& s) {
+  std::istringstream iss(s);
+  std::vector<std::string> out;
+  std::string tok;
+  while (iss >> tok) out.push_back(tok);
+  return out;
+}
+
+}  // namespace
+
+std::vector<TorrentResult> filterResults(const std::vector<TorrentResult>& list, bool hideDead,
+                                          const std::unordered_map<std::string, Source>& sources,
+                                          const std::string& textFilter) {
+  std::vector<TorrentResult> filtered = list;
+
+  if (hideDead) {
+    std::vector<TorrentResult> next;
+    for (const auto& r : filtered) {
+      const auto it = sources.find(r.source);
+      const bool reportsHealth = it == sources.end() || it->second.reportsHealth;
+      if (r.seeders > 0 || !reportsHealth) next.push_back(r);
+    }
+    filtered = std::move(next);
+  }
+
+  const std::string text = toLower(trim(textFilter));
+  if (text.empty()) return filtered;
+
+  const auto tokens = splitWhitespace(text);
+  std::string normalizedText;
+  for (std::size_t i = 0; i < tokens.size(); ++i) {
+    if (i) normalizedText += ' ';
+    normalizedText += tokens[i];
+  }
+
+  std::vector<std::pair<int, TorrentResult>> scored;
+  for (const auto& r : filtered) {
+    const std::string name = toLower(r.name);
+    bool matchesAll = true;
+    for (const auto& token : tokens) {
+      if (name.find(token) == std::string::npos) {
+        matchesAll = false;
+        break;
+      }
+    }
+    if (!matchesAll) continue;
+
+    int score = 10;  // base score for matching all tokens
+    if (name.find(normalizedText) != std::string::npos) {
+      score += 50;  // exact substring gets the highest boost
+    } else {
+      // Boost if tokens appear in the same order.
+      long long lastIndex = -1;
+      bool inOrder = true;
+      for (const auto& token : tokens) {
+        const auto idx = name.find(token, static_cast<std::size_t>(lastIndex + 1));
+        if (idx == std::string::npos || static_cast<long long>(idx) < lastIndex) {
+          inOrder = false;
+          break;
+        }
+        lastIndex = static_cast<long long>(idx);
+      }
+      if (inOrder) score += 20;
+    }
+    scored.emplace_back(score, r);
+  }
+
+  std::stable_sort(scored.begin(), scored.end(),
+                    [](const auto& a, const auto& b) { return a.first > b.first; });
+
+  std::vector<TorrentResult> out;
+  out.reserve(scored.size());
+  for (auto& [score, r] : scored) out.push_back(std::move(r));
+  return out;
+}
+
+}  // namespace torlinkc::ui
diff --git a/src/ui/search_aggregator.cpp b/src/ui/search_aggregator.cpp
new file mode 100644
index 0000000..2a617da
--- /dev/null
+++ b/src/ui/search_aggregator.cpp
@@ -0,0 +1,150 @@
+#include "torlinkc/ui/search_aggregator.hpp"
+
+#include <algorithm>
+#include <unordered_map>
+
+#include <ftxui/component/event.hpp>
+
+#include "torlinkc/sources/registry.hpp"
+#include "torlinkc/util/net.hpp"
+
+namespace torlinkc::ui {
+
+namespace {
+
+std::string errorCode(const std::exception& e) {
+  if (const auto* http = dynamic_cast<const HttpError*>(&e); http && http->status > 0) {
+    return "HTTP " + std::to_string(http->status);
+  }
+  return "no response";
+}
+
+}  // namespace
+
+SearchAggregator::SearchAggregator(ftxui::ScreenInteractive& screen, AppState& state)
+    : screen_(screen), state_(state) {}
+
+SearchAggregator::~SearchAggregator() {
+  threads_.clear();  // requests stop + joins every jthread
+}
+
+void SearchAggregator::search(std::string query) {
+  const auto sources = allSources();
+
+  {
+    std::lock_guard<std::mutex> lock(dataMutex_);
+    generation_++;
+    collected_.clear();
+    perSource_.clear();
+    doneCount_ = 0;
+    totalCount_ = static_cast<int>(sources.size());
+    for (const auto& s : sources) perSource_[s.id] = SourceState{true, std::nullopt, std::nullopt, 0};
+  }
+  const int myGeneration = generation_;
+
+  // Destroying the old jthreads requests_stop() + joins each one. Real
+  // cancellation (util/net.cpp's libcurl progress callback) is what keeps
+  // this from blocking on a slow request's full timeout -- see the class
+  // comment.
+  threads_.clear();
+
+  notifier_ = std::make_unique<CoalescingNotifier>(std::chrono::milliseconds(150), [this] { flush(); });
+
+  state_.query = query;
+  state_.searching = true;
+  state_.doneSources = 0;
+  state_.totalSources = static_cast<int>(sources.size());
+  state_.perSource.clear();
+  for (const auto& s : sources) state_.perSource[s.id] = SourceState{true, std::nullopt, std::nullopt, 0};
+
+  threads_.reserve(sources.size());
+  for (const auto& source : sources) {
+    threads_.emplace_back([this, source, query, myGeneration](std::stop_token stopToken) {
+      runSource(source, query, std::move(stopToken), myGeneration);
+    });
+  }
+}
+
+void SearchAggregator::runSource(const Source& source, const std::string& query, std::stop_token stopToken,
+                                  int generation) {
+  std::vector<TorrentResult> results;
+  std::optional<std::string> error;
+  std::optional<std::string> code;
+
+  try {
+    SearchOptions opts;
+    opts.stopToken = stopToken;
+    results = cache_.cachedSearch(source, query, opts);
+  } catch (const std::exception& e) {
+    if (stopToken.stop_requested()) return;  // cancelled: drop silently, a newer search superseded this one
+    error = e.what();
+    code = errorCode(e);
+  }
+  if (stopToken.stop_requested()) return;
+
+  bool shouldFlushNow = false;
+  {
+    std::lock_guard<std::mutex> lock(dataMutex_);
+    if (generation != generation_) return;  // belt-and-suspenders: see the header comment
+    if (error) {
+      perSource_[source.id] = SourceState{false, error, code, 0};
+    } else {
+      collected_.insert(collected_.end(), results.begin(), results.end());
+      perSource_[source.id] = SourceState{false, std::nullopt, std::nullopt, static_cast<int>(results.size())};
+    }
+    doneCount_++;
+    shouldFlushNow = doneCount_ >= totalCount_;
+  }
+
+  if (shouldFlushNow) {
+    notifier_->flushNow();
+  } else {
+    notifier_->markDirty();
+  }
+}
+
+void SearchAggregator::flush() {
+  std::vector<TorrentResult> resultsCopy;
+  std::unordered_map<std::string, SourceState> perSourceCopy;
+  int doneCopy = 0, totalCopy = 0;
+  {
+    std::lock_guard<std::mutex> lock(dataMutex_);
+    resultsCopy = collected_;
+    perSourceCopy = perSource_;
+    doneCopy = doneCount_;
+    totalCopy = totalCount_;
+  }
+
+  // Dedupe by infoHash keeping the higher-seeder copy, then default-order:
+  // healthiest first. Ported from useConcurrentSearch.ts's dedupe/defaultOrder.
+  std::unordered_map<std::string, TorrentResult> byHash;
+  for (auto& r : resultsCopy) {
+    auto it = byHash.find(r.infoHash);
+    if (it == byHash.end() || r.seeders > it->second.seeders) byHash[r.infoHash] = r;
+  }
+  std::vector<TorrentResult> deduped;
+  deduped.reserve(byHash.size());
+  for (auto& [hash, r] : byHash) deduped.push_back(std::move(r));
+  std::stable_sort(deduped.begin(), deduped.end(), [](const TorrentResult& a, const TorrentResult& b) {
+    if (a.seeders != b.seeders) return a.seeders > b.seeders;
+    return a.added.value_or(0) > b.added.value_or(0);
+  });
+
+  screen_.Post([this, deduped = std::move(deduped), perSourceCopy = std::move(perSourceCopy), doneCopy,
+                totalCopy]() mutable {
+    const bool hadNoResultsBefore = state_.results.empty();
+    state_.results = std::move(deduped);
+    state_.perSource = std::move(perSourceCopy);
+    state_.doneSources = doneCopy;
+    state_.totalSources = totalCopy;
+    state_.searching = doneCopy < totalCopy;
+    if (onResultsChanged) onResultsChanged();
+    // Jump focus to the results list (index 1 in main.cpp's container) the
+    // first time this search produces any results, so a hotkey pressed
+    // right after Enter reaches it instead of the still-focused search box.
+    if (hadNoResultsBefore && !state_.results.empty()) state_.focusedIndex = 1;
+  });
+  screen_.PostEvent(ftxui::Event::Custom);
+}
+
+}  // namespace torlinkc::ui
diff --git a/src/ui/search_runner.cpp b/src/ui/search_runner.cpp
deleted file mode 100644
index e272efc..0000000
--- a/src/ui/search_runner.cpp
+++ /dev/null
@@ -1,61 +0,0 @@
-#include "torlinkc/ui/search_runner.hpp"
-
-#include <ftxui/component/event.hpp>
-
-#include "torlinkc/sources/piratebay.hpp"
-#include "torlinkc/util/format.hpp"
-
-namespace torlinkc::ui {
-
-namespace {
-
-std::string formatResultLabel(const TorrentResult& r) {
-  return stripControl(r.name) + "  [" + formatBytes(static_cast<double>(r.sizeBytes)) +
-         ", seeders=" + std::to_string(r.seeders) + "]";
-}
-
-}  // namespace
-
-SearchRunner::SearchRunner(ftxui::ScreenInteractive& screen, AppState& state) : screen_(screen), state_(state) {}
-
-SearchRunner::~SearchRunner() {
-  if (thread_.joinable()) thread_.join();
-}
-
-void SearchRunner::search(std::string query) {
-  if (state_.searching) return;  // one at a time in Phase 2, see the class comment
-  if (thread_.joinable()) thread_.join();  // the previous search has already finished by now
-
-  state_.searching = true;
-  state_.searchError.clear();
-  state_.query = query;
-  screen_.RequestAnimationFrame();  // kicks off the spinner's OnAnimation loop
-
-  thread_ = std::thread([this, query = std::move(query)]() {
-    std::vector<TorrentResult> results;
-    std::string error;
-    try {
-      results = cache_.cachedSearch(tpbMoviesSource(), query);
-    } catch (const std::exception& e) {
-      error = e.what();
-    }
-
-    screen_.Post([this, results = std::move(results), error = std::move(error)]() mutable {
-      state_.searching = false;
-      state_.searchError = std::move(error);
-      state_.results = std::move(results);
-      state_.resultLabels.clear();
-      state_.resultLabels.reserve(state_.results.size());
-      for (const auto& r : state_.results) state_.resultLabels.push_back(formatResultLabel(r));
-      state_.selectedResult = 0;
-      // Jump focus to the results list (index 1 in main.cpp's container) so
-      // a hotkey pressed right after Enter reaches it, not the search box.
-      if (!state_.results.empty()) state_.focusedIndex = 1;
-    });
-    // See EngineThread::publishSnapshot: a bare Post()'d closure doesn't
-    // itself wake FTXUI's main loop to redraw, only a real Event does.
-    screen_.PostEvent(ftxui::Event::Custom);
-  });
-}
-
-}  // namespace torlinkc::ui
diff --git a/src/ui/sort.cpp b/src/ui/sort.cpp
new file mode 100644
index 0000000..ee59d79
--- /dev/null
+++ b/src/ui/sort.cpp
@@ -0,0 +1,97 @@
+#include "torlinkc/ui/sort.hpp"
+
+#include <algorithm>
+
+namespace torlinkc::ui {
+
+namespace {
+
+bool sameSort(const Sort& a, const Sort& b) {
+  if (!a || !b) return !a && !b;
+  return a->field == b->field && a->dir == b->dir;
+}
+
+std::string fieldName(SortField f) {
+  switch (f) {
+    case SortField::Size:
+      return "size";
+    case SortField::Seeders:
+      return "seeders";
+    case SortField::Source:
+      return "source";
+    case SortField::Added:
+      return "added";
+  }
+  return "size";
+}
+
+}  // namespace
+
+const std::vector<Sort>& sortCycle() {
+  static const std::vector<Sort> cycle = {
+      std::nullopt,
+      SortState{SortField::Size, SortDir::Asc},
+      SortState{SortField::Size, SortDir::Desc},
+      SortState{SortField::Seeders, SortDir::Asc},
+      SortState{SortField::Seeders, SortDir::Desc},
+      SortState{SortField::Source, SortDir::Asc},
+      SortState{SortField::Source, SortDir::Desc},
+      SortState{SortField::Added, SortDir::Asc},
+      SortState{SortField::Added, SortDir::Desc},
+  };
+  return cycle;
+}
+
+Sort nextSort(const Sort& current) {
+  const auto& cycle = sortCycle();
+  auto it = std::find_if(cycle.begin(), cycle.end(), [&](const Sort& s) { return sameSort(s, current); });
+  const std::size_t i = it == cycle.end() ? 0 : static_cast<std::size_t>(it - cycle.begin());
+  return cycle[(i + 1) % cycle.size()];
+}
+
+std::string sortArrow(SortDir dir) { return dir == SortDir::Asc ? "▴" : "▾"; }
+
+std::string sortLabel(const Sort& sort) {
+  if (!sort) return "default";
+  return fieldName(sort->field) + " " + sortArrow(sort->dir);
+}
+
+std::vector<TorrentResult> sortResults(const std::vector<TorrentResult>& list, const Sort& sort) {
+  std::vector<TorrentResult> arr = list;
+  if (!sort) return arr;
+  // `a` sorts before `b` exactly when this JS-style combined comparator
+  // value (mul * primary, falling through to a tiebreak) is negative --
+  // mirrors ui/sort.ts::sortResults's `mul * (...) || (...)` chains, just
+  // spelled out instead of relying on JS's truthy-`||` short-circuit.
+  const long long mul = sort->dir == SortDir::Asc ? 1 : -1;
+
+  switch (sort->field) {
+    case SortField::Size:
+      std::stable_sort(arr.begin(), arr.end(), [&](const TorrentResult& a, const TorrentResult& b) {
+        const long long primary = mul * (a.sizeBytes - b.sizeBytes);
+        return primary != 0 ? primary < 0 : (b.seeders - a.seeders) < 0;
+      });
+      break;
+    case SortField::Seeders:
+      std::stable_sort(arr.begin(), arr.end(), [&](const TorrentResult& a, const TorrentResult& b) {
+        const long long primary = mul * (a.seeders - b.seeders);
+        return primary != 0 ? primary < 0 : (b.added.value_or(0) - a.added.value_or(0)) < 0;
+      });
+      break;
+    case SortField::Source:
+      std::stable_sort(arr.begin(), arr.end(), [&](const TorrentResult& a, const TorrentResult& b) {
+        const long long primary = mul * a.source.compare(b.source);
+        return primary != 0 ? primary < 0 : (b.seeders - a.seeders) < 0;
+      });
+      break;
+    case SortField::Added:
+      std::stable_sort(arr.begin(), arr.end(), [&](const TorrentResult& a, const TorrentResult& b) {
+        const long long primary = mul * (a.added.value_or(0) - b.added.value_or(0));
+        return primary != 0 ? primary < 0 : (b.seeders - a.seeders) < 0;
+      });
+      break;
+  }
+  return arr;
+}
+
+}  // namespace torlinkc::ui
diff --git a/src/util/date_parse.cpp b/src/util/date_parse.cpp
new file mode 100644
index 0000000..95ee1c0
--- /dev/null
+++ b/src/util/date_parse.cpp
@@ -0,0 +1,30 @@
+#include "torlinkc/util/date_parse.hpp"
+
+#include <cstring>
+#include <ctime>
+
+#include <curl/curl.h>
+
+namespace torlinkc {
+
+std::optional<std::int64_t> parseDateToUnixSeconds(const std::string& s) {
+  if (s.empty()) return std::nullopt;
+
+  // ISO 8601 first: curl_getdate() doesn't understand "2024-01-15T00:00:00Z".
+  // Fractional seconds and a timezone suffix (if any) are ignored and the
+  // result treated as UTC -- matching JS's Date.parse for the 'Z'-suffixed
+  // ISO dates these APIs actually send; none of them emit a non-UTC offset.
+  {
+    std::tm tm{};
+    if (strptime(s.c_str(), "%Y-%m-%dT%H:%M:%S", &tm) != nullptr) {
+      const time_t t = timegm(&tm);
+      if (t != static_cast<time_t>(-1)) return static_cast<std::int64_t>(t);
+    }
+  }
+
+  const time_t t = curl_getdate(s.c_str(), nullptr);
+  if (t != static_cast<time_t>(-1)) return static_cast<std::int64_t>(t);
+  return std::nullopt;
+}
+
+}  // namespace torlinkc
diff --git a/src/util/format.cpp b/src/util/format.cpp
index 2cb02a4..4df1976 100644
--- a/src/util/format.cpp
+++ b/src/util/format.cpp
@@ -1,7 +1,10 @@
 #include "torlinkc/util/format.hpp"

+#include <algorithm>
 #include <cmath>
 #include <cstdio>
+#include <map>
+#include <regex>

 namespace torlinkc {

@@ -18,6 +21,27 @@ std::string formatBytes(double bytes) {
   return buf;
 }

+std::int64_t parseSize(const std::string& s) {
+  static const std::map<std::string, double> kUnits = {
+      {"B", 1}, {"KIB", 1024}, {"MIB", 1024.0 * 1024}, {"GIB", 1024.0 * 1024 * 1024},
+      {"TIB", 1024.0 * 1024 * 1024 * 1024}, {"KB", 1000}, {"MB", 1e6}, {"GB", 1e9}, {"TB", 1e12},
+  };
+  static const std::regex kSizeRe(R"(([\d.]+)\s*([KMGT]?I?B))", std::regex::icase);
+  std::smatch m;
+  if (!std::regex_search(s, m, kSizeRe)) return 0;
+
+  std::string unit = m[2].str();
+  std::transform(unit.begin(), unit.end(), unit.begin(), [](unsigned char c) { return std::toupper(c); });
+  double multiplier = 1;
+  if (auto it = kUnits.find(unit); it != kUnits.end()) multiplier = it->second;
+
+  try {
+    return static_cast<std::int64_t>(std::llround(std::stod(m[1].str()) * multiplier));
+  } catch (...) {
+    return 0;
+  }
+}
+
 std::string stripControl(const std::string& s) {
   std::string out;
   out.reserve(s.size());
diff --git a/src/util/net.cpp b/src/util/net.cpp
index 09aba42..2eff9bc 100644
--- a/src/util/net.cpp
+++ b/src/util/net.cpp
@@ -51,8 +51,25 @@ std::size_t writeHeader(char* buffer, std::size_t size, std::size_t nitems, void
   return size * nitems;
 }

-void sleepMs(std::int64_t ms) {
-  if (ms > 0) std::this_thread::sleep_for(std::chrono::milliseconds(ms));
+// Sleeps up to `ms`, but wakes early (in <= kSliceMs increments) if `token`
+// is stop-requested, so a cancelled search doesn't sit out a multi-second
+// backoff wait it no longer needs.
+void sleepMs(std::int64_t ms, const std::stop_token& token) {
+  constexpr std::int64_t kSliceMs = 50;
+  const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(ms);
+  while (std::chrono::steady_clock::now() < deadline) {
+    if (token.stop_requested()) return;
+    std::this_thread::sleep_for(std::chrono::milliseconds(std::min(kSliceMs, ms)));
+  }
+}
+
+// libcurl's xferinfo callback: returning non-zero aborts the transfer
+// in-flight (CURLE_ABORTED_BY_CALLBACK), which is the only way to actually
+// interrupt a request already in curl_easy_perform() -- checking the token
+// only between attempts would leave a slow single request uncancellable.
+int xferInfoCallback(void* clientp, curl_off_t, curl_off_t, curl_off_t, curl_off_t) {
+  const auto* token = static_cast<const std::stop_token*>(clientp);
+  return token->stop_requested() ? 1 : 0;
 }

 }  // namespace
@@ -79,26 +96,38 @@ std::int64_t backoffDelay(int attempt, int baseMs, int capMs, std::optional<std:

 HttpResponse fetchResilient(const std::string& url, const FetchOptions& opts) {
   for (int attempt = 0; attempt <= opts.retries; ++attempt) {
+    if (opts.stopToken.stop_requested()) throw HttpError(0, "aborted");
+
     CURL* curl = curl_easy_init();
     if (!curl) throw HttpError(0, "failed to initialize libcurl");

+    curl_slist* headerList = nullptr;
+    for (const auto& h : opts.headers) headerList = curl_slist_append(headerList, h.c_str());
+
     HttpResponse res;
     char errbuf[CURL_ERROR_SIZE] = {0};
     curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
     curl_easy_setopt(curl, CURLOPT_USERAGENT, kUserAgent);
     curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L);
     curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L);
+    if (headerList) curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headerList);
     curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, writeBody);
     curl_easy_setopt(curl, CURLOPT_WRITEDATA, &res.body);
     curl_easy_setopt(curl, CURLOPT_HEADERFUNCTION, writeHeader);
     curl_easy_setopt(curl, CURLOPT_HEADERDATA, &res.headers);
     curl_easy_setopt(curl, CURLOPT_ERRORBUFFER, errbuf);
+    curl_easy_setopt(curl, CURLOPT_NOPROGRESS, 0L);
+    curl_easy_setopt(curl, CURLOPT_XFERINFOFUNCTION, xferInfoCallback);
+    curl_easy_setopt(curl, CURLOPT_XFERINFODATA, &opts.stopToken);

     CURLcode code = curl_easy_perform(curl);
+    curl_slist_free_all(headerList);
+
     if (code != CURLE_OK) {
       curl_easy_cleanup(curl);
+      if (code == CURLE_ABORTED_BY_CALLBACK || opts.stopToken.stop_requested()) throw HttpError(0, "aborted");
       if (attempt < opts.retries) {
-        sleepMs(backoffDelay(attempt, opts.baseMs, opts.capMs));
+        sleepMs(backoffDelay(attempt, opts.baseMs, opts.capMs), opts.stopToken);
         continue;
       }
       throw HttpError(0, std::string("request to ") + url + " failed: " + errbuf);
@@ -124,7 +153,7 @@ HttpResponse fetchResilient(const std::string& url, const FetchOptions& opts) {
     }

     auto retryAfterMs = res.header("retry-after") ? parseRetryAfter(*res.header("retry-after")) : std::nullopt;
-    sleepMs(backoffDelay(attempt, opts.baseMs, opts.capMs, retryAfterMs));
+    sleepMs(backoffDelay(attempt, opts.baseMs, opts.capMs, retryAfterMs), opts.stopToken);
   }

   throw HttpError(0, "fetchResilient exhausted without a response");
diff --git a/src/util/url_encode.cpp b/src/util/url_encode.cpp
new file mode 100644
index 0000000..baeca3d
--- /dev/null
+++ b/src/util/url_encode.cpp
@@ -0,0 +1,47 @@
+#include "torlinkc/util/url_encode.hpp"
+
+#include <cctype>
+
+namespace torlinkc {
+
+namespace {
+bool isUnreserved(unsigned char c) {
+  return std::isalnum(c) || c == '-' || c == '_' || c == '.' || c == '!' || c == '~' || c == '*' || c == '\'' ||
+         c == '(' || c == ')';
+}
+}  // namespace
+
+std::string encodeURIComponent(const std::string& s) {
+  std::string out;
+  out.reserve(s.size());
+  static const char* hex = "0123456789ABCDEF";
+  for (unsigned char c : s) {
+    if (isUnreserved(c)) {
+      out += static_cast<char>(c);
+    } else {
+      out += '%';
+      out += hex[c >> 4];
+      out += hex[c & 0xF];
+    }
+  }
+  return out;
+}
+
+std::string decodeURIComponent(const std::string& s) {
+  std::string out;
+  out.reserve(s.size());
+  for (std::size_t i = 0; i < s.size(); ++i) {
+    if (s[i] == '+') {
+      out += ' ';
+    } else if (s[i] == '%' && i + 2 < s.size() && std::isxdigit(static_cast<unsigned char>(s[i + 1])) &&
+               std::isxdigit(static_cast<unsigned char>(s[i + 2]))) {
+      out += static_cast<char>(std::stoi(s.substr(i + 1, 2), nullptr, 16));
+      i += 2;
+    } else {
+      out += s[i];
+    }
+  }
+  return out;
+}
+
+}  // namespace torlinkc
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
index c8b2762..fd927ed 100644
--- a/tests/CMakeLists.txt
+++ b/tests/CMakeLists.txt
@@ -1,10 +1,19 @@
 add_executable(torlinkc_tests
   test_main.cpp
+  test_bittorrented.cpp
+  test_filter.cpp
   test_magnet.cpp
   test_net.cpp
   test_persist.cpp
   test_queue.cpp
   test_reconcile.cpp
+  test_registry.cpp
+  test_rss.cpp
+  test_sort.cpp
+  test_x1337.cpp
 )
-target_link_libraries(torlinkc_tests PRIVATE torlinkc_core)
+# torlinkc_ui (not just torlinkc_core): filter.cpp/sort.cpp live there since
+# they're UI-layer presentation logic (see src/CMakeLists.txt), even though
+# these tests never touch FTXUI itself.
+target_link_libraries(torlinkc_tests PRIVATE torlinkc_ui)
 add_test(NAME torlinkc_tests COMMAND torlinkc_tests)
diff --git a/tests/test_bittorrented.cpp b/tests/test_bittorrented.cpp
new file mode 100644
index 0000000..0005360
--- /dev/null
+++ b/tests/test_bittorrented.cpp
@@ -0,0 +1,61 @@
+#include <doctest/doctest.h>
+
+#include <nlohmann/json.hpp>
+
+#include "torlinkc/sources/bittorrented.hpp"
+
+using namespace torlinkc;
+using nlohmann::json;
+
+TEST_CASE("mapBittorrentedResults maps a well-formed row") {
+  json results = json::array({{
+      {"torrent_infohash", "ABCDEF0123456789ABCDEF0123456789ABCDEF01"},
+      {"torrent_name", "Some Movie 2024"},
+      {"torrent_total_size", 1234567},
+      {"torrent_seeders", 10},
+      {"torrent_leechers", 2},
+      {"torrent_file_count", 3},
+      {"torrent_created_at", "2024-01-15T00:00:00Z"},
+  }});
+  auto out = mapBittorrentedResults(results, "bittorrented");
+  REQUIRE(out.size() == 1);
+  CHECK(out[0].infoHash == "abcdef0123456789abcdef0123456789abcdef01");
+  CHECK(out[0].name == "Some Movie 2024");
+  CHECK(out[0].sizeBytes == 1234567);
+  CHECK(out[0].seeders == 10);
+  CHECK(out[0].leechers == 2);
+  REQUIRE(out[0].numFiles.has_value());
+  CHECK(*out[0].numFiles == 3);
+  CHECK(out[0].source == "bittorrented");
+  CHECK(out[0].magnet.find("xt=urn:btih:abcdef0123456789abcdef0123456789abcdef01") != std::string::npos);
+  REQUIRE(out[0].added.has_value());
+}
+
+TEST_CASE("mapBittorrentedResults drops rows with a missing or malformed info hash") {
+  json results = json::array({
+      {{"torrent_infohash", "tooshort"}, {"torrent_name", "a"}},
+      {{"torrent_name", "no hash field"}},
+      {{"torrent_infohash", ""}, {"torrent_name", "empty hash"}},
+  });
+  CHECK(mapBittorrentedResults(results, "bittorrented").empty());
+}
+
+TEST_CASE("mapBittorrentedResults falls back to the info hash as the name when torrent_name is missing") {
+  json results = json::array({{{"torrent_infohash", std::string(40, 'a')}}});
+  auto out = mapBittorrentedResults(results, "bittorrented");
+  REQUIRE(out.size() == 1);
+  CHECK(out[0].name == std::string(40, 'a'));
+}
+
+TEST_CASE("mapBittorrentedResults treats null seeders/leechers as zero rather than dropping the row") {
+  json results = json::array({{
+      {"torrent_infohash", std::string(40, 'b')},
+      {"torrent_name", "n"},
+      {"torrent_seeders", nullptr},
+      {"torrent_leechers", nullptr},
+  }});
+  auto out = mapBittorrentedResults(results, "bittorrented");
+  REQUIRE(out.size() == 1);
+  CHECK(out[0].seeders == 0);
+  CHECK(out[0].leechers == 0);
+}
diff --git a/tests/test_filter.cpp b/tests/test_filter.cpp
new file mode 100644
index 0000000..2f3c610
--- /dev/null
+++ b/tests/test_filter.cpp
@@ -0,0 +1,71 @@
+#include <doctest/doctest.h>
+
+#include "torlinkc/sources/registry.hpp"
+#include "torlinkc/ui/filter.hpp"
+
+using namespace torlinkc;
+using namespace torlinkc::ui;
+
+namespace {
+
+TorrentResult r(std::string infoHash, std::string name, int seeders, std::string source = "yts") {
+  TorrentResult t;
+  t.infoHash = infoHash;
+  t.name = std::move(name);
+  t.seeders = seeders;
+  t.source = std::move(source);
+  t.magnet = "magnet:?xt=urn:btih:" + infoHash;
+  return t;
+}
+
+std::vector<std::string> ids(const std::vector<TorrentResult>& list) {
+  std::vector<std::string> out;
+  for (const auto& t : list) out.push_back(t.infoHash);
+  return out;
+}
+
+}  // namespace
+
+TEST_CASE("filterResults passes everything through when hideDead is off") {
+  const auto sources = sourceById();
+  std::vector<TorrentResult> list = {r("a", "a", 0), r("b", "b", 5)};
+  CHECK(ids(filterResults(list, false, sources)) == std::vector<std::string>{"a", "b"});
+}
+
+TEST_CASE("filterResults drops zero-seeder results when hideDead is on") {
+  const auto sources = sourceById();
+  std::vector<TorrentResult> list = {r("a", "a", 0), r("b", "b", 1), r("c", "c", 0)};
+  CHECK(ids(filterResults(list, true, sources)) == std::vector<std::string>{"b"});
+}
+
+TEST_CASE("filterResults keeps zero-seeder rows from sources that report no health data") {
+  const auto sources = sourceById();
+  std::vector<TorrentResult> list = {
+      r("a", "a", 0, "fitgirl"),
+      r("b", "b", 0, "yts"),
+      r("c", "c", 0, "subsplease"),
+      r("d", "d", 3, "yts"),
+  };
+  CHECK(ids(filterResults(list, true, sources)) == std::vector<std::string>{"a", "c", "d"});
+}
+
+TEST_CASE("filterResults does not mutate the input") {
+  const auto sources = sourceById();
+  std::vector<TorrentResult> list = {r("a", "a", 0), r("b", "b", 2)};
+  const auto before = ids(list);
+  filterResults(list, true, sources);
+  CHECK(ids(list) == before);
+}
+
+TEST_CASE("filterResults filters by text matching all tokens and ranks exact matches higher") {
+  const auto sources = sourceById();
+  std::vector<TorrentResult> list = {
+      r("a", "ubuntu 24 desktop", 0),
+      r("b", "ubuntu desktop 24.04", 0),
+      r("c", "debian 12", 0),
+      r("d", "24 ubuntu desktop", 0),
+  };
+  // "ubuntu 24" -> a: exact substring (score 60), b: in-order (score 30),
+  // d: out of order (score 10), c: no match.
+  CHECK(ids(filterResults(list, false, sources, "ubuntu 24")) == std::vector<std::string>{"a", "b", "d"});
+}
diff --git a/tests/test_registry.cpp b/tests/test_registry.cpp
new file mode 100644
index 0000000..003e19a
--- /dev/null
+++ b/tests/test_registry.cpp
@@ -0,0 +1,58 @@
+#include <doctest/doctest.h>
+
+#include "torlinkc/sources/registry.hpp"
+
+using namespace torlinkc;
+
+TEST_CASE("allSources returns all 10 sources in registry order") {
+  const auto sources = allSources();
+  REQUIRE(sources.size() == 10);
+  CHECK(sources[0].id == "fitgirl");
+  CHECK(sources[1].id == "yts");
+  CHECK(sources[2].id == "tpb-movies");
+  CHECK(sources[3].id == "x1337-movies");
+  CHECK(sources[4].id == "eztv");
+  CHECK(sources[5].id == "tpb-tv");
+  CHECK(sources[6].id == "x1337-tv");
+  CHECK(sources[7].id == "nyaa");
+  CHECK(sources[8].id == "subsplease");
+  CHECK(sources[9].id == "bittorrented");
+}
+
+TEST_CASE("only fitgirl and subsplease report no swarm health") {
+  for (const auto& s : allSources()) {
+    const bool expectHealth = s.id != "fitgirl" && s.id != "subsplease";
+    CHECK(s.reportsHealth == expectHealth);
+  }
+}
+
+TEST_CASE("sourceById maps every source id back to itself") {
+  const auto byId = sourceById();
+  REQUIRE(byId.size() == 10);
+  for (const auto& s : allSources()) {
+    REQUIRE(byId.count(s.id) == 1);
+    CHECK(byId.at(s.id).label == s.label);
+  }
+}
+
+TEST_CASE("sourceInCategory: All matches every source") {
+  for (const auto& s : allSources()) CHECK(sourceInCategory(s, Category::All));
+}
+
+TEST_CASE("sourceInCategory respects each source's declared groups") {
+  const auto byId = sourceById();
+  CHECK(sourceInCategory(byId.at("fitgirl"), Category::Games));
+  CHECK_FALSE(sourceInCategory(byId.at("fitgirl"), Category::Movies));
+  CHECK(sourceInCategory(byId.at("bittorrented"), Category::Movies));
+  CHECK(sourceInCategory(byId.at("bittorrented"), Category::TV));
+  CHECK_FALSE(sourceInCategory(byId.at("bittorrented"), Category::Anime));
+  CHECK(sourceInCategory(byId.at("nyaa"), Category::Anime));
+}
+
+TEST_CASE("categoryLabel names match the UI's expected strings") {
+  CHECK(categoryLabel(Category::All) == "All");
+  CHECK(categoryLabel(Category::Games) == "Games");
+  CHECK(categoryLabel(Category::Movies) == "Movies");
+  CHECK(categoryLabel(Category::TV) == "TV");
+  CHECK(categoryLabel(Category::Anime) == "Anime");
+}
diff --git a/tests/test_rss.cpp b/tests/test_rss.cpp
new file mode 100644
index 0000000..fa04327
--- /dev/null
+++ b/tests/test_rss.cpp
@@ -0,0 +1,21 @@
+#include <doctest/doctest.h>
+
+#include "torlinkc/sources/rss.hpp"
+
+using namespace torlinkc;
+
+TEST_CASE("unescapeEntities decodes the entities RSS feeds actually use") {
+  CHECK(unescapeEntities("Tom &amp; Jerry") == "Tom & Jerry");
+  CHECK(unescapeEntities("Tom &#38; Jerry") == "Tom & Jerry");
+  CHECK(unescapeEntities("2024 &#8211; Remastered") == "2024 - Remastered");
+  CHECK(unescapeEntities("2024 &#8212; Remastered") == "2024 - Remastered");
+  CHECK(unescapeEntities("Rock &#8217;n&#8217; Roll") == "Rock 'n' Roll");
+  CHECK(unescapeEntities("Rock &apos;n&apos; Roll") == "Rock 'n' Roll");
+  CHECK(unescapeEntities("&#8220;Quoted&#8221;") == "\"Quoted\"");
+  CHECK(unescapeEntities("&quot;Quoted&quot;") == "\"Quoted\"");
+  CHECK(unescapeEntities("a &lt; b &gt; c") == "a < b > c");
+}
+
+TEST_CASE("unescapeEntities leaves plain text untouched") {
+  CHECK(unescapeEntities("Ordinary.Torrent.Name.2024") == "Ordinary.Torrent.Name.2024");
+}
diff --git a/tests/test_sort.cpp b/tests/test_sort.cpp
new file mode 100644
index 0000000..f8b1ac5
--- /dev/null
+++ b/tests/test_sort.cpp
@@ -0,0 +1,123 @@
+#include <doctest/doctest.h>
+
+#include "torlinkc/ui/sort.hpp"
+
+using namespace torlinkc;
+using namespace torlinkc::ui;
+
+namespace {
+
+TorrentResult r(std::string infoHash, std::int64_t sizeBytes = 0, int seeders = 0, std::string source = "yts",
+                 std::optional<std::int64_t> added = std::nullopt) {
+  TorrentResult t;
+  t.infoHash = infoHash;
+  t.name = infoHash;
+  t.sizeBytes = sizeBytes;
+  t.seeders = seeders;
+  t.source = std::move(source);
+  t.magnet = "magnet:?xt=urn:btih:" + infoHash;
+  t.added = added;
+  return t;
+}
+
+std::vector<std::string> ids(const std::vector<TorrentResult>& list) {
+  std::vector<std::string> out;
+  for (const auto& t : list) out.push_back(t.infoHash);
+  return out;
+}
+
+}  // namespace
+
+TEST_CASE("nextSort cycles through 9 states and back to none") {
+  Sort s = std::nullopt;
+  std::vector<Sort> seq;
+  for (int i = 0; i < 9; ++i) {
+    s = nextSort(s);
+    seq.push_back(s);
+  }
+  REQUIRE(seq.size() == 9);
+  CHECK(seq[0] == SortState{SortField::Size, SortDir::Asc});
+  CHECK(seq[1] == SortState{SortField::Size, SortDir::Desc});
+  CHECK(seq[2] == SortState{SortField::Seeders, SortDir::Asc});
+  CHECK(seq[3] == SortState{SortField::Seeders, SortDir::Desc});
+  CHECK(seq[4] == SortState{SortField::Source, SortDir::Asc});
+  CHECK(seq[5] == SortState{SortField::Source, SortDir::Desc});
+  CHECK(seq[6] == SortState{SortField::Added, SortDir::Asc});
+  CHECK(seq[7] == SortState{SortField::Added, SortDir::Desc});
+  CHECK_FALSE(seq[8].has_value());
+}
+
+TEST_CASE("sortCycle has exactly 9 states starting with none") {
+  CHECK(sortCycle().size() == 9);
+  CHECK_FALSE(sortCycle()[0].has_value());
+}
+
+TEST_CASE("sortArrow points up for asc and down for desc") {
+  CHECK(sortArrow(SortDir::Asc) == "▴");
+  CHECK(sortArrow(SortDir::Desc) == "▾");
+}
+
+TEST_CASE("sortResults none preserves the original arrival order") {
+  std::vector<TorrentResult> list = {r("a", 1, 1), r("b", 9, 9), r("c", 5, 5)};
+  CHECK(ids(sortResults(list, std::nullopt)) == std::vector<std::string>{"a", "b", "c"});
+}
+
+TEST_CASE("sortResults size asc: smallest first") {
+  std::vector<TorrentResult> list = {r("a", 500), r("b", 100), r("c", 900)};
+  CHECK(ids(sortResults(list, SortState{SortField::Size, SortDir::Asc})) == std::vector<std::string>{"b", "a", "c"});
+}
+
+TEST_CASE("sortResults size desc: largest first") {
+  std::vector<TorrentResult> list = {r("a", 500), r("b", 100), r("c", 900)};
+  CHECK(ids(sortResults(list, SortState{SortField::Size, SortDir::Desc})) == std::vector<std::string>{"c", "a", "b"});
+}
+
+TEST_CASE("sortResults seeders asc: fewest first") {
+  std::vector<TorrentResult> list = {r("a", 0, 50), r("b", 0, 5), r("c", 0, 90)};
+  CHECK(ids(sortResults(list, SortState{SortField::Seeders, SortDir::Asc})) ==
+        std::vector<std::string>{"b", "a", "c"});
+}
+
+TEST_CASE("sortResults seeders desc: most first") {
+  std::vector<TorrentResult> list = {r("a", 0, 50), r("b", 0, 5), r("c", 0, 90)};
+  CHECK(ids(sortResults(list, SortState{SortField::Seeders, SortDir::Desc})) ==
+        std::vector<std::string>{"c", "a", "b"});
+}
+
+TEST_CASE("sortResults source asc: A->Z by source id") {
+  std::vector<TorrentResult> list = {r("a", 0, 0, "yts"), r("b", 0, 0, "eztv"), r("c", 0, 0, "nyaa")};
+  CHECK(ids(sortResults(list, SortState{SortField::Source, SortDir::Asc})) ==
+        std::vector<std::string>{"b", "c", "a"});
+}
+
+TEST_CASE("sortResults source desc: Z->A by source id") {
+  std::vector<TorrentResult> list = {r("a", 0, 0, "eztv"), r("b", 0, 0, "yts"), r("c", 0, 0, "nyaa")};
+  CHECK(ids(sortResults(list, SortState{SortField::Source, SortDir::Desc})) ==
+        std::vector<std::string>{"b", "c", "a"});
+}
+
+TEST_CASE("sortResults added asc: oldest first") {
+  std::vector<TorrentResult> list = {r("a", 0, 0, "yts", 300), r("b", 0, 0, "yts", 100), r("c", 0, 0, "yts", 200)};
+  CHECK(ids(sortResults(list, SortState{SortField::Added, SortDir::Asc})) ==
+        std::vector<std::string>{"b", "c", "a"});
+}
+
+TEST_CASE("sortResults added desc: newest first") {
+  std::vector<TorrentResult> list = {r("a", 0, 0, "yts", 300), r("b", 0, 0, "yts", 100), r("c", 0, 0, "yts", 200)};
+  CHECK(ids(sortResults(list, SortState{SortField::Added, SortDir::Desc})) ==
+        std::vector<std::string>{"a", "c", "b"});
+}
+
+TEST_CASE("sortResults added treats missing timestamps as zero") {
+  std::vector<TorrentResult> list = {r("a", 0, 0, "yts", 500), r("b", 0, 0, "yts", std::nullopt),
+                                      r("c", 0, 0, "yts", 100)};
+  CHECK(ids(sortResults(list, SortState{SortField::Added, SortDir::Asc})) ==
+        std::vector<std::string>{"b", "c", "a"});
+}
+
+TEST_CASE("sortResults does not mutate the input") {
+  std::vector<TorrentResult> list = {r("a", 1), r("b", 2)};
+  const auto before = ids(list);
+  sortResults(list, SortState{SortField::Size, SortDir::Asc});
+  CHECK(ids(list) == before);
+}
diff --git a/tests/test_x1337.cpp b/tests/test_x1337.cpp
new file mode 100644
index 0000000..6050139
--- /dev/null
+++ b/tests/test_x1337.cpp
@@ -0,0 +1,41 @@
+#include <doctest/doctest.h>
+
+#include <ctime>
+
+#include "torlinkc/sources/x1337.hpp"
+
+using namespace torlinkc;
+
+TEST_CASE("parseUploadDate parses 1337x's 'Mon. Dth 'YY' detail-page format") {
+  const std::string html = R"(<strong>Date uploaded</strong><span>Jun. 26th '26</span>)";
+  auto secs = parseUploadDate(html);
+  REQUIRE(secs.has_value());
+
+  std::tm tm{};
+  time_t t = static_cast<time_t>(*secs);
+  gmtime_r(&t, &tm);
+  CHECK(tm.tm_year + 1900 == 2026);
+  CHECK(tm.tm_mon == 5);  // June, 0-indexed
+  CHECK(tm.tm_mday == 26);
+}
+
+TEST_CASE("parseUploadDate accepts a month without a trailing period") {
+  const std::string html = R"(<strong>Date uploaded</strong><span>Sep 3rd '24</span>)";
+  auto secs = parseUploadDate(html);
+  REQUIRE(secs.has_value());
+  std::tm tm{};
+  time_t t = static_cast<time_t>(*secs);
+  gmtime_r(&t, &tm);
+  CHECK(tm.tm_year + 1900 == 2024);
+  CHECK(tm.tm_mon == 8);  // September
+  CHECK(tm.tm_mday == 3);
+}
+
+TEST_CASE("parseUploadDate returns nullopt when the marker is absent") {
+  CHECK_FALSE(parseUploadDate("<div>nothing here</div>").has_value());
+}
+
+TEST_CASE("parseUploadDate returns nullopt for an unrecognized month abbreviation") {
+  const std::string html = R"(<strong>Date uploaded</strong><span>Xyz. 1st '24</span>)";
+  CHECK_FALSE(parseUploadDate(html).has_value());
+}