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"(�?38;|&)"), "&"}, {std::regex(R"(–|—)"), "-"},
+ {std::regex(R"(’|�?39;|')"), "'"}, {std::regex(R"(“|”|")"), "\""},
+ {std::regex(R"(<)"), "<"}, {std::regex(R"(>)"), ">"},
+ };
+ 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 & Jerry") == "Tom & Jerry");
+ CHECK(unescapeEntities("Tom & Jerry") == "Tom & Jerry");
+ CHECK(unescapeEntities("2024 – Remastered") == "2024 - Remastered");
+ CHECK(unescapeEntities("2024 — Remastered") == "2024 - Remastered");
+ CHECK(unescapeEntities("Rock ’n’ Roll") == "Rock 'n' Roll");
+ CHECK(unescapeEntities("Rock 'n' Roll") == "Rock 'n' Roll");
+ CHECK(unescapeEntities("“Quoted”") == "\"Quoted\"");
+ CHECK(unescapeEntities(""Quoted"") == "\"Quoted\"");
+ CHECK(unescapeEntities("a < b > 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());
+}