commit f70cd696c07dd6e55a4e3c5a0ce46ab8d67e2f92
Author: MrJensK <jens.se@icloud.com>
AuthorDate: Sun Aug 23 09:57:54 2026 +0200
Commit: MrJensK <jens.se@icloud.com>
CommitDate: Sun Aug 23 09:57:54 2026 +0200
Phase 1: core download engine, persistence, and apibay search
Ports the TypeScript original's download/, config/, and a first sources/
scraper to C++, matching the phased plan's Phase 1 scope: everything needed
for a console harness to search, download, and survive a crash, ahead of any
TUI (Phase 2+).
- engine/: TorrentEngine wraps lt::session (magnet/.torrent add, alert-driven
metadata/done/error, synchronous status() polling); DownloadQueue ports
queue.ts's full state machine (concurrency cap + promote, stray-seed
detection, safe-mode restore) with the same edge cases the original's
comments called out.
- persist/history/reconcile/bootguard: atomic JSON persistence and the
crash-boot breaker, ported near-verbatim from their TS equivalents.
- sources/: magnet build/parse (incl. base32 BTIH decoding), a 5-minute
search cache, and Pirate Bay/apibay as the first scraper (including its
no-results-sentinel retry quirk).
- util/net: a libcurl-based fetchResilient with the same backoff/Retry-After/
ddos-guard-503 policy as the original.
- apps/core_cli: a throwaway console harness (search / run) proving the
whole chain end-to-end -- verified manually against live apibay search and
a real Sintel download, including a kill -9 mid-download correctly
restoring paused in safe mode on relaunch.
- tests/: a doctest suite (48 cases) porting the load-bearing *.test.ts
cases -- magnet parsing, reconcile, persistence round-trips, backoff/
retry-after math, and DownloadQueue's concurrency-cap and safe-mode
behavior.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
---
.github/workflows/ci.yml | 8 +-
CMakeLists.txt | 5 +
apps/core_cli/CMakeLists.txt | 2 +
apps/core_cli/main.cpp | 165 ++++++++
include/torlinkc/config/config.hpp | 17 +
include/torlinkc/config/paths.hpp | 25 ++
include/torlinkc/engine/bootguard.hpp | 22 +
include/torlinkc/engine/delete_data.hpp | 14 +
include/torlinkc/engine/history.hpp | 24 ++
include/torlinkc/engine/persist.hpp | 42 ++
include/torlinkc/engine/queue.hpp | 128 ++++++
include/torlinkc/engine/reconcile.hpp | 14 +
include/torlinkc/engine/torrent_engine.hpp | 88 ++++
include/torlinkc/engine/types.hpp | 58 +++
include/torlinkc/sources/cache.hpp | 27 ++
include/torlinkc/sources/magnet.hpp | 35 ++
include/torlinkc/sources/piratebay.hpp | 11 +
include/torlinkc/sources/types.hpp | 41 ++
include/torlinkc/util/atomic_write.hpp | 16 +
include/torlinkc/util/net.hpp | 53 +++
src/CMakeLists.txt | 25 ++
src/config/config.cpp | 50 +++
src/config/paths.cpp | 72 ++++
src/engine/bootguard.cpp | 42 ++
src/engine/delete_data.cpp | 27 ++
src/engine/history.cpp | 83 ++++
src/engine/persist.cpp | 229 ++++++++++
src/engine/queue.cpp | 654 +++++++++++++++++++++++++++++
src/engine/reconcile.cpp | 34 ++
src/engine/torrent_engine.cpp | 172 ++++++++
src/engine/types.cpp | 49 +++
src/sources/cache.cpp | 42 ++
src/sources/magnet.cpp | 194 +++++++++
src/sources/piratebay.cpp | 172 ++++++++
src/util/atomic_write.cpp | 31 ++
src/util/net.cpp | 133 ++++++
tests/CMakeLists.txt | 10 +
tests/test_magnet.cpp | 128 ++++++
tests/test_main.cpp | 2 +
tests/test_net.cpp | 42 ++
tests/test_persist.cpp | 108 +++++
tests/test_queue.cpp | 176 ++++++++
tests/test_reconcile.cpp | 64 +++
tests/test_support.hpp | 56 +++
44 files changed, 3387 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8ce06b1..cf5ee77 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -25,15 +25,17 @@ jobs:
steps:
- uses: actions/checkout@v7
- # libtorrent-rasterbar and ftxui both ship dev packages + CMake config
- # files directly in Debian/Ubuntu, so no vcpkg bootstrap is needed.
+ # libtorrent-rasterbar, ftxui, curl, nlohmann-json, and doctest all ship
+ # dev packages (+ CMake config files, where they need one) directly in
+ # Debian/Ubuntu, so no vcpkg bootstrap is needed.
- name: Install toolchain and libraries
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
cmake build-essential pkgconf \
libtorrent-rasterbar-dev libftxui-dev \
- libboost-system-dev libssl-dev
+ libboost-system-dev libssl-dev \
+ libcurl4-openssl-dev nlohmann-json3-dev doctest-dev
- name: Configure
run: cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 6b09b22..c6171f8 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -10,7 +10,12 @@ endif()
find_package(LibtorrentRasterbar REQUIRED)
find_package(ftxui REQUIRED)
+find_package(CURL REQUIRED)
+find_package(nlohmann_json REQUIRED)
enable_testing()
add_subdirectory(phase0)
+add_subdirectory(src)
+add_subdirectory(apps/core_cli)
+add_subdirectory(tests)
diff --git a/apps/core_cli/CMakeLists.txt b/apps/core_cli/CMakeLists.txt
new file mode 100644
index 0000000..7026562
--- /dev/null
+++ b/apps/core_cli/CMakeLists.txt
@@ -0,0 +1,2 @@
+add_executable(torlinkc_core_cli main.cpp)
+target_link_libraries(torlinkc_core_cli PRIVATE torlinkc_core)
diff --git a/apps/core_cli/main.cpp b/apps/core_cli/main.cpp
new file mode 100644
index 0000000..b4e7d5a
--- /dev/null
+++ b/apps/core_cli/main.cpp
@@ -0,0 +1,165 @@
+// Phase 1 deliverable: a throwaway console harness proving the core engine
+// (queue + persistence + bootguard + one source) end-to-end, ahead of any
+// TUI. `search` hits apibay and prints results; `run` restores persisted
+// state (in safe mode if the previous run was killed mid-restore), adds an
+// optional magnet/info hash, and prints live progress once a second until
+// Ctrl+C -- or until something less polite than Ctrl+C ends it, which is
+// exactly the case bootguard exists to survive.
+
+#include <atomic>
+#include <chrono>
+#include <csignal>
+#include <cstdio>
+#include <iostream>
+#include <thread>
+
+#include "torlinkc/config/config.hpp"
+#include "torlinkc/config/paths.hpp"
+#include "torlinkc/engine/bootguard.hpp"
+#include "torlinkc/engine/persist.hpp"
+#include "torlinkc/engine/queue.hpp"
+#include "torlinkc/engine/reconcile.hpp"
+#include "torlinkc/sources/cache.hpp"
+#include "torlinkc/sources/magnet.hpp"
+#include "torlinkc/sources/piratebay.hpp"
+
+using namespace torlinkc;
+
+namespace {
+
+std::atomic<bool> gStop{false};
+void handleSignal(int) { gStop = true; }
+
+std::string formatBytes(double bytes) {
+ static const char* units[] = {"B", "KB", "MB", "GB", "TB"};
+ int u = 0;
+ while (bytes >= 1024.0 && u < 4) {
+ bytes /= 1024.0;
+ u++;
+ }
+ char buf[64];
+ std::snprintf(buf, sizeof(buf), "%.1f %s", bytes, units[u]);
+ return buf;
+}
+
+void printUsage() {
+ std::cout << "usage: torlinkc_core_cli search <query>\n"
+ " torlinkc_core_cli run [magnet|infohash] [download-dir]\n";
+}
+
+void doSearch(const std::string& query) {
+ SearchCache cache;
+ std::vector<TorrentResult> results = cache.cachedSearch(tpbMoviesSource(), query);
+ auto tv = cache.cachedSearch(tpbTvSource(), query);
+ results.insert(results.end(), tv.begin(), tv.end());
+
+ if (results.empty()) {
+ std::cout << "no results\n";
+ return;
+ }
+ for (const auto& r : results) {
+ std::cout << "[" << r.source << "] " << r.name << " (" << formatBytes(static_cast<double>(r.sizeBytes))
+ << ", seeders=" << r.seeders << ", leechers=" << r.leechers << ")\n"
+ << " " << r.magnet << "\n";
+ }
+}
+
+void printItem(const QueueItem& it) {
+ std::cout << " " << it.name.substr(0, 60) << " [" << downloadStatusToString(it.status) << "] " << it.progress
+ << "% "
+ << formatBytes(static_cast<double>(it.downloadedBytes)) << "/"
+ << formatBytes(static_cast<double>(it.totalBytes)) << " " << formatBytes(it.speed) << "/s peers="
+ << it.peers;
+ if (it.error) std::cout << " error=" << *it.error;
+ std::cout << "\n";
+}
+
+void printSeed(const SeedItem& s) {
+ std::cout << " " << s.name.substr(0, 60) << " [" << seedStatusToString(s.status) << "] "
+ << formatBytes(static_cast<double>(s.uploaded)) << " up " << formatBytes(s.uploadSpeed)
+ << "/s peers=" << s.peers << "\n";
+}
+
+int runHarness(const std::vector<std::string>& args) {
+ std::signal(SIGINT, handleSignal);
+ std::signal(SIGTERM, handleSignal);
+
+ Config config = loadConfig();
+ std::cout << "state dir: " << paths::queueFile() << "\n";
+ std::cout << "download dir: " << config.downloadDir << "\n";
+
+ const bool safe = wasBootInterrupted();
+ if (safe) {
+ std::cout << "*** previous run did not shut down cleanly -- restoring in SAFE MODE (all paused) ***\n";
+ }
+ armBootMarker();
+
+ DownloadQueue queue;
+ queue.setTrackers(config.trackers);
+ queue.restoreHistory(loadHistory());
+ queue.restore(reconcileQueue(loadQueue()), RestoreOptions{safe});
+ queue.restoreSeeds(loadSeeds(), RestoreOptions{safe});
+
+ if (args.size() >= 2) {
+ auto parsed = parseInput(args[1]);
+ if (!parsed) {
+ std::cerr << "not a magnet URI or info hash: " << args[1] << "\n";
+ return 1;
+ }
+ const std::string dir = args.size() >= 3 ? args[2] : config.downloadDir;
+ queue.add(AddInput{parsed->infoHash, parsed->name, parsed->magnet, "", std::nullopt}, dir);
+ std::cout << "added " << parsed->name << " -> " << dir << "\n";
+ }
+
+ std::cout << "running. Ctrl+C to stop cleanly (kill -9 <pid> to test crash recovery instead).\n";
+
+ const auto bootAt = std::chrono::steady_clock::now();
+ bool settled = false;
+
+ while (!gStop) {
+ queue.tick();
+
+ if (!settled && std::chrono::steady_clock::now() - bootAt > std::chrono::milliseconds(kBootSettleMs)) {
+ // The boot survived long enough to be worth trusting.
+ disarmBootMarker();
+ settled = true;
+ }
+
+ auto items = queue.getItems();
+ auto seeds = queue.getSeeds();
+ if (!items.empty() || !seeds.empty()) {
+ std::cout << "\033[2J\033[H"; // clear screen for a readable live view
+ std::cout << "Downloads:\n";
+ for (const auto& it : items) printItem(it);
+ std::cout << "Seeding:\n";
+ for (const auto& s : seeds) printSeed(s);
+ }
+ std::this_thread::sleep_for(std::chrono::milliseconds(1000));
+ }
+
+ std::cout << "\nshutting down cleanly\n";
+ queue.suspend();
+ return 0;
+}
+
+} // namespace
+
+int main(int argc, char** argv) {
+ std::vector<std::string> args(argv + 1, argv + argc);
+ if (args.empty()) {
+ printUsage();
+ return 1;
+ }
+ if (args[0] == "search") {
+ if (args.size() < 2) {
+ printUsage();
+ return 1;
+ }
+ doSearch(args[1]);
+ return 0;
+ }
+ if (args[0] == "run") return runHarness(args);
+
+ printUsage();
+ return 1;
+}
diff --git a/include/torlinkc/config/config.hpp b/include/torlinkc/config/config.hpp
new file mode 100644
index 0000000..9f6b5b0
--- /dev/null
+++ b/include/torlinkc/config/config.hpp
@@ -0,0 +1,17 @@
+#pragma once
+
+#include <string>
+#include <vector>
+
+namespace torlinkc {
+
+struct Config {
+ std::string downloadDir;
+ std::vector<std::string> trackers;
+};
+
+Config defaultConfig();
+Config loadConfig();
+void saveConfig(const Config& config);
+
+} // namespace torlinkc
diff --git a/include/torlinkc/config/paths.hpp b/include/torlinkc/config/paths.hpp
new file mode 100644
index 0000000..45c85c0
--- /dev/null
+++ b/include/torlinkc/config/paths.hpp
@@ -0,0 +1,25 @@
+#pragma once
+
+#include <string>
+
+namespace torlinkc::paths {
+
+// Deliberately distinct from the original Node app's "torlink" (~/.config,
+// ~/.local/share): the two implementations can coexist on a dev machine
+// during the rewrite, and sharing a state dir risks one corrupting the
+// other's queue.json before the on-disk format is guaranteed compatible.
+inline constexpr const char* kAppName = "torlinkc";
+
+// TORLINK_STATE_DIR relocates all persisted state under one folder (tests use
+// this to sandbox themselves from the real user data), mirroring the
+// TypeScript original's override of the same name.
+std::string defaultDownloadDir();
+std::string configFile();
+std::string queueFile();
+std::string historyFile();
+std::string seedsFile();
+std::string torrentsDir();
+std::string bootMarkerFile();
+std::string logsDir();
+
+} // namespace torlinkc::paths
diff --git a/include/torlinkc/engine/bootguard.hpp b/include/torlinkc/engine/bootguard.hpp
new file mode 100644
index 0000000..09f59e1
--- /dev/null
+++ b/include/torlinkc/engine/bootguard.hpp
@@ -0,0 +1,22 @@
+#pragma once
+
+namespace torlinkc {
+
+// Crash-boot breaker for the restore path. A marker file is armed just before
+// persisted state is handed to the torrent engine and disarmed once the boot
+// has stayed alive long enough to be called healthy (or the app flushes state
+// on a clean exit). Finding a marker at the next boot means the previous one
+// died while restoring, so that boot restores everything paused and starts no
+// engines -- the UI always comes up and the user resumes items on their own
+// terms. Ported from download/bootguard.ts.
+
+// How long (ms) after restore the process must survive before the marker is
+// disarmed -- long enough for the engine's async startup to have blown up if
+// it was going to.
+inline constexpr int kBootSettleMs = 4000;
+
+bool wasBootInterrupted();
+void armBootMarker();
+void disarmBootMarker();
+
+} // namespace torlinkc
diff --git a/include/torlinkc/engine/delete_data.hpp b/include/torlinkc/engine/delete_data.hpp
new file mode 100644
index 0000000..9bf467d
--- /dev/null
+++ b/include/torlinkc/engine/delete_data.hpp
@@ -0,0 +1,14 @@
+#pragma once
+
+#include <optional>
+#include <string>
+
+namespace torlinkc {
+
+// Best-effort delete of a torrent's on-disk data: only the torrent's own
+// entry directly under its download dir (a file, or the folder named after
+// it). Never walks outside that dir, never throws. Ported from
+// download/delete-data.ts.
+std::optional<std::string> deleteSeedData(const std::string& dir, const std::string& name);
+
+} // namespace torlinkc
diff --git a/include/torlinkc/engine/history.hpp b/include/torlinkc/engine/history.hpp
new file mode 100644
index 0000000..8b69e0c
--- /dev/null
+++ b/include/torlinkc/engine/history.hpp
@@ -0,0 +1,24 @@
+#pragma once
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+namespace torlinkc {
+
+inline constexpr int kHistoryCap = 500;
+
+struct HistoryItem {
+ std::string id;
+ std::string name;
+ std::string source;
+ std::int64_t sizeBytes = 0;
+ std::string magnet;
+ std::string dir;
+ std::int64_t completedAt = 0; // unix ms
+};
+
+void saveHistory(const std::vector<HistoryItem>& items);
+std::vector<HistoryItem> loadHistory();
+
+} // namespace torlinkc
diff --git a/include/torlinkc/engine/persist.hpp b/include/torlinkc/engine/persist.hpp
new file mode 100644
index 0000000..360f420
--- /dev/null
+++ b/include/torlinkc/engine/persist.hpp
@@ -0,0 +1,42 @@
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include "torlinkc/engine/types.hpp"
+
+namespace torlinkc {
+
+// We persist the user-meaningful seed states so a deliberate pause survives a
+// restart (and stays paused), not just the seeding ids. "missing" is
+// runtime-only and gets folded to paused on the way out so a gone file is
+// never auto-seeded.
+struct SeedRecord {
+ std::string id;
+ SeedStatus status = SeedStatus::Seeding; // only Seeding or Paused is ever persisted
+};
+
+// Best-effort: never throws. A failed write is swallowed exactly like the
+// original's `.catch(() => {})`, since losing one persistence tick is
+// preferable to crashing the download loop over it.
+void saveQueue(const std::vector<QueueItem>& items);
+std::vector<QueueItem> loadQueue();
+
+void saveSeeds(const std::vector<SeedRecord>& records);
+std::vector<SeedRecord> loadSeeds();
+
+// --- per-torrent .torrent metadata cache ------------------------------------
+
+std::string torrentMetaPath(const std::string& id);
+bool torrentMetaExists(const std::string& id);
+std::string torrentExportName(const std::string& name, const std::string& id);
+void saveTorrentMeta(const std::string& id, const std::string& data);
+// Copies the cached .torrent into `dir` under a sanitized filename. Returns
+// the exported path, or nullopt if no cached metadata exists / the copy fails.
+std::optional<std::string> exportTorrentMeta(const std::string& id, const std::string& name,
+ const std::string& dir);
+void deleteTorrentMeta(const std::string& id);
+
+} // namespace torlinkc
diff --git a/include/torlinkc/engine/queue.hpp b/include/torlinkc/engine/queue.hpp
new file mode 100644
index 0000000..bbfe96c
--- /dev/null
+++ b/include/torlinkc/engine/queue.hpp
@@ -0,0 +1,128 @@
+#pragma once
+
+#include <cstdint>
+#include <functional>
+#include <optional>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+#include "torlinkc/engine/history.hpp"
+#include "torlinkc/engine/persist.hpp"
+#include "torlinkc/engine/torrent_engine.hpp"
+#include "torlinkc/engine/types.hpp"
+
+namespace torlinkc {
+
+struct AddInput {
+ std::string id;
+ std::string name;
+ std::string magnet;
+ std::string source; // empty = none
+ std::optional<std::int64_t> sizeBytes;
+};
+
+struct RestoreOptions {
+ // Safe mode: the previous boot died while restoring (see bootguard.hpp), so
+ // bring every item back paused and start no engines. The list stays intact
+ // and visible; the user resumes each item on their own terms.
+ bool safe = false;
+};
+
+// A real seed never pulls data off the network: verifying on-disk files reads
+// the disk (network speed stays 0), only fetching *missing* data raises it.
+// So sustained network download on a "seed" means its files are gone or
+// partial. Ported from queue.ts's strayDownload().
+bool strayDownload(std::int64_t total, double progress, int speed);
+
+// Ported from download/queue.ts. Where the original relied on webtorrent's
+// async events firing into the queue via Node's event loop, this Phase 1
+// port is driven by an explicit tick(): it drains libtorrent alerts and
+// refreshes every item/seed's live stats. Call tick() periodically (the
+// console harness does so once a second) -- Phase 2 replaces this with a
+// background alert thread posting closures onto the UI thread instead.
+class DownloadQueue {
+ public:
+ explicit DownloadQueue(std::optional<int> maxDownloads = std::nullopt);
+
+ // Extra announce URLs appended to every torrent added from now on. Existing
+ // running torrents aren't retro-updated -- the change takes effect for the
+ // next add / resume / re-seed.
+ void setTrackers(std::vector<std::string> trackers);
+
+ std::vector<QueueItem> getItems() const;
+ int activeCount() const;
+ bool has(const std::string& id) const;
+
+ void add(const AddInput& input, const std::string& dir);
+
+ // Advances the engine: drains alerts (metadata/done/error) and refreshes
+ // progress for every downloading item and seeding seed. Call periodically.
+ void tick();
+
+ void pause(const std::string& id);
+ void resume(const std::string& id);
+ void togglePause(const std::string& id);
+
+ std::optional<std::string> exportTorrentFile(const std::string& id);
+
+ // Fetches the .torrent metadata for a magnet-only result and exports it to
+ // exportDir. Blocking (bounded by a timeout), driven by repeatedly polling
+ // the engine -- Phase 1 has no background thread to await this on.
+ std::optional<std::string> fetchAndExportTorrent(const AddInput& input, const std::string& exportDir);
+
+ void cancel(const std::string& id);
+ bool remove(const std::string& id, bool deleteFiles = false);
+ void retry(const std::string& id);
+ void retryFailed();
+
+ std::optional<SeedItem> getSeed(const std::string& id) const;
+ std::vector<SeedItem> getSeeds() const;
+ int seedingCount() const;
+
+ void startSeeding(const HistoryItem& h);
+ void stopSeeding(const std::string& id);
+ void toggleSeeding(const HistoryItem& h);
+ void restoreSeeds(const std::vector<SeedRecord>& records, RestoreOptions opts = {});
+
+ void restore(std::vector<QueueItem> items, RestoreOptions opts = {});
+ void restoreHistory(std::vector<HistoryItem> items);
+ std::vector<HistoryItem> getHistory() const;
+ void removeHistory(const std::string& id);
+ void clearHistory();
+
+ // Synchronously flushes every state file from current memory. Touches no
+ // engine state, so it can never block shutdown.
+ void persistSync();
+ void suspend();
+
+ // Fired synchronously wherever the TS original called `this.emit(...)`.
+ // Unset by default (Phase 1's console harness polls getItems() directly);
+ // Phase 2 wires these into the FTXUI redraw pipeline.
+ std::function<void()> onUpdate;
+ std::function<void(const std::string&)> onCompleted;
+
+ private:
+ std::unordered_map<std::string, QueueItem> items_;
+ TorrentEngine engine_;
+ std::vector<HistoryItem> history_;
+ std::unordered_map<std::string, SeedItem> seeds_;
+ std::unordered_map<std::string, int> strayHits_;
+ std::unordered_map<std::string, std::int64_t> seedStartedAt_;
+ std::vector<std::string> trackers_;
+ int maxDownloads_ = 0; // 0 = unlimited
+
+ void startEngine(QueueItem& item);
+ void promote();
+ AddHandlers engineHandlers(std::string id);
+ void completeItem(QueueItem it);
+ void beginSeed(const QueueItem& it);
+ void restorePaused(const HistoryItem& h);
+ std::vector<SeedRecord> seedRecords() const;
+ void persistSeeds() const;
+ void persistQueue() const;
+ void recordHistory(const QueueItem& it);
+ void changed();
+};
+
+} // namespace torlinkc
diff --git a/include/torlinkc/engine/reconcile.hpp b/include/torlinkc/engine/reconcile.hpp
new file mode 100644
index 0000000..0ad2d53
--- /dev/null
+++ b/include/torlinkc/engine/reconcile.hpp
@@ -0,0 +1,14 @@
+#pragma once
+
+#include <vector>
+
+#include "torlinkc/engine/types.hpp"
+
+namespace torlinkc {
+
+// Dedupes persisted queue items by id, drops "completed" entries (those live
+// in history instead), and zeroes live-only stats (speed/peers/eta) that
+// never survive a restart. Ported from download/reconcile.ts.
+std::vector<QueueItem> reconcileQueue(const std::vector<QueueItem>& items);
+
+} // namespace torlinkc
diff --git a/include/torlinkc/engine/torrent_engine.hpp b/include/torlinkc/engine/torrent_engine.hpp
new file mode 100644
index 0000000..a40d160
--- /dev/null
+++ b/include/torlinkc/engine/torrent_engine.hpp
@@ -0,0 +1,88 @@
+#pragma once
+
+#include <cstdint>
+#include <functional>
+#include <memory>
+#include <optional>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+#include <libtorrent/info_hash.hpp>
+#include <libtorrent/session.hpp>
+
+namespace torlinkc {
+
+struct TorrentProgress {
+ double progress = 0.0; // 0..1
+ std::int64_t downloaded = 0;
+ std::int64_t total = 0;
+ int speed = 0; // bytes/sec, download
+ int uploadSpeed = 0; // bytes/sec
+ std::int64_t uploaded = 0;
+ int peers = 0;
+ double timeRemaining = 0.0; // ms; +infinity when unknown, matching the TS original
+ std::string name;
+};
+
+struct TorrentMeta {
+ std::string name;
+ std::int64_t total = 0;
+ int files = 0;
+ // Raw bencoded .torrent bytes, available once metadata arrives. Persisted so
+ // a later re-seed can verify the on-disk file without re-fetching metadata
+ // from the swarm (which a bare magnet would require).
+ std::optional<std::string> torrentFile;
+};
+
+struct AddHandlers {
+ std::function<void(const TorrentMeta&)> onMetadata;
+ std::function<void()> onDone;
+ std::function<void(const std::string&)> onError;
+};
+
+// Wraps a single lt::session. `source` accepted by add() is a magnet URI, a
+// bare info hash, or a path to a .torrent file -- mirroring engine.ts's
+// contract with webtorrent's client.add(). Ported from download/engine.ts;
+// where the original polled webtorrent's async getters defensively, this
+// polls libtorrent's synchronous torrent_handle::status() instead (Phase 2
+// switches metadata/done/error over to the alert-driven push model; for
+// Phase 1's console harness, pollAlerts() is called synchronously from the
+// caller's own tick loop).
+class TorrentEngine {
+ public:
+ TorrentEngine();
+ ~TorrentEngine();
+
+ TorrentEngine(const TorrentEngine&) = delete;
+ TorrentEngine& operator=(const TorrentEngine&) = delete;
+
+ void add(const std::string& id, const std::string& source, const std::string& dir, AddHandlers handlers,
+ const std::vector<std::string>& announce = {});
+
+ // Drains pending libtorrent alerts and dispatches metadata/done/error to the
+ // handlers registered in add(). Must be called periodically (the queue's
+ // tick() does this) or those callbacks never fire.
+ void pollAlerts();
+
+ std::optional<TorrentProgress> stats(const std::string& id);
+ void remove(const std::string& id);
+ void destroy();
+
+ // The port the session accepts incoming peers on (diagnostics / tests).
+ int listenPort() const;
+
+ private:
+ struct Entry {
+ lt::torrent_handle handle;
+ AddHandlers handlers;
+ };
+
+ std::unique_ptr<lt::session> session_;
+ std::unordered_map<std::string, Entry> torrents_; // our id -> entry
+ std::unordered_map<lt::sha1_hash, std::string> infoHashToId_; // reverse lookup for alert dispatch
+
+ void removeEntry(const std::string& id);
+};
+
+} // namespace torlinkc
diff --git a/include/torlinkc/engine/types.hpp b/include/torlinkc/engine/types.hpp
new file mode 100644
index 0000000..2b7daa5
--- /dev/null
+++ b/include/torlinkc/engine/types.hpp
@@ -0,0 +1,58 @@
+#pragma once
+
+#include <cstdint>
+#include <optional>
+#include <string>
+
+namespace torlinkc {
+
+// "queued" = waiting for a free download slot (see DownloadQueue's maxDownloads).
+// Unlike "paused" (an explicit user action) a queued item is started
+// automatically as soon as a slot frees.
+enum class DownloadStatus { Downloading, Queued, Paused, Completed, Failed };
+
+enum class SeedStatus { Seeding, Paused, Missing };
+
+// Named asymmetrically to downloadStatusFromString/seedStatusFromString
+// (rather than an overloaded "toString") on purpose: a bare "toString" in an
+// app namespace is exactly the name test frameworks like doctest use as an
+// ADL extension point, and a non-template exact-match overload there wins
+// over the framework's own generic template -- which silently breaks
+// CHECK(a == b) stringification for these enums with no useful diagnostic.
+std::string downloadStatusToString(DownloadStatus s);
+std::string seedStatusToString(SeedStatus s);
+std::optional<DownloadStatus> downloadStatusFromString(const std::string& s);
+std::optional<SeedStatus> seedStatusFromString(const std::string& s);
+
+struct QueueItem {
+ std::string id;
+ std::string name;
+ std::string source; // empty = none, mirrors TS's optional SourceId
+ std::string magnet;
+ std::string dir;
+ DownloadStatus status = DownloadStatus::Downloading;
+ int progress = 0; // 0..100, matches the TS field (rounded percent)
+ std::int64_t totalBytes = 0;
+ std::int64_t downloadedBytes = 0;
+ int speed = 0;
+ int peers = 0;
+ std::optional<double> eta; // seconds
+ std::optional<int> files;
+ std::optional<std::string> error;
+ std::int64_t addedAt = 0; // unix ms
+};
+
+struct SeedItem {
+ std::string id;
+ std::string name;
+ std::string source;
+ std::string magnet;
+ std::string dir;
+ std::int64_t sizeBytes = 0;
+ SeedStatus status = SeedStatus::Seeding;
+ int uploadSpeed = 0;
+ std::int64_t uploaded = 0;
+ int peers = 0;
+};
+
+} // namespace torlinkc
diff --git a/include/torlinkc/sources/cache.hpp b/include/torlinkc/sources/cache.hpp
new file mode 100644
index 0000000..86417fb
--- /dev/null
+++ b/include/torlinkc/sources/cache.hpp
@@ -0,0 +1,27 @@
+#pragma once
+
+#include <chrono>
+#include <string>
+#include <unordered_map>
+#include <vector>
+
+#include "torlinkc/sources/types.hpp"
+
+namespace torlinkc {
+
+// 5-minute TTL in-memory cache keyed by sourceId::query, so retyping a
+// recent search or switching tabs back and forth doesn't re-hit the network.
+// Ported from sources/cache.ts.
+class SearchCache {
+ public:
+ std::vector<TorrentResult> cachedSearch(const Source& source, const std::string& query, const SearchOptions& opts = {});
+
+ private:
+ struct Entry {
+ std::chrono::steady_clock::time_point at;
+ std::vector<TorrentResult> results;
+ };
+ std::unordered_map<std::string, Entry> cache_;
+};
+
+} // namespace torlinkc
diff --git a/include/torlinkc/sources/magnet.hpp b/include/torlinkc/sources/magnet.hpp
new file mode 100644
index 0000000..db7a6a4
--- /dev/null
+++ b/include/torlinkc/sources/magnet.hpp
@@ -0,0 +1,35 @@
+#pragma once
+
+#include <optional>
+#include <string>
+#include <vector>
+
+namespace torlinkc {
+
+// extraTrackers come first and win on duplicates: a torrent that carries its
+// own announce list means that list, and the public defaults are only a
+// fallback. Ported from sources/magnet.ts.
+std::string buildMagnet(const std::string& infoHash, const std::string& name,
+ const std::vector<std::string>& extraTrackers = {});
+
+std::string normalizeInfoHash(const std::string& raw);
+
+struct ParsedMagnet {
+ std::string infoHash;
+ std::string name;
+ std::string magnet;
+};
+
+std::optional<ParsedMagnet> parseMagnet(const std::string& input);
+
+// Anchored to the whole input so an ordinary search query is never mistaken
+// for a hash: only a string that is *nothing but* a 40-char hex or 32-char
+// base32 info hash counts.
+bool isInfoHash(const std::string& input);
+
+// Accepts either a magnet URI or a bare info hash. A bare hash is normalized
+// and wrapped with the default public trackers via buildMagnet. Returns
+// nullopt for anything that is neither.
+std::optional<ParsedMagnet> parseInput(const std::string& input);
+
+} // namespace torlinkc
diff --git a/include/torlinkc/sources/piratebay.hpp b/include/torlinkc/sources/piratebay.hpp
new file mode 100644
index 0000000..852bae9
--- /dev/null
+++ b/include/torlinkc/sources/piratebay.hpp
@@ -0,0 +1,11 @@
+#pragma once
+
+#include "torlinkc/sources/types.hpp"
+
+namespace torlinkc {
+
+// The Pirate Bay via the apibay.org JSON API. Ported from sources/piratebay.ts.
+Source tpbMoviesSource();
+Source tpbTvSource();
+
+} // namespace torlinkc
diff --git a/include/torlinkc/sources/types.hpp b/include/torlinkc/sources/types.hpp
new file mode 100644
index 0000000..a08b6ed
--- /dev/null
+++ b/include/torlinkc/sources/types.hpp
@@ -0,0 +1,41 @@
+#pragma once
+
+#include <cstdint>
+#include <functional>
+#include <optional>
+#include <string>
+#include <vector>
+
+namespace torlinkc {
+
+struct TorrentResult {
+ std::string infoHash;
+ std::string name;
+ std::int64_t sizeBytes = 0;
+ int seeders = 0;
+ int leechers = 0;
+ std::optional<int> numFiles;
+ std::string source; // SourceId, e.g. "tpb-movies"
+ std::string magnet;
+ std::optional<std::int64_t> added; // unix seconds
+};
+
+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).
+};
+
+enum class SourceGroup { Games, Movies, TV, Anime };
+
+struct Source {
+ std::string id;
+ std::string label;
+ std::vector<SourceGroup> groups;
+ std::string homepage;
+ // True when the source returns real swarm counts. False when its feed has
+ // none, so seeders == 0 means unknown, not dead.
+ bool reportsHealth = true;
+ std::function<std::vector<TorrentResult>(const std::string& query, const SearchOptions&)> search;
+};
+
+} // namespace torlinkc
diff --git a/include/torlinkc/util/atomic_write.hpp b/include/torlinkc/util/atomic_write.hpp
new file mode 100644
index 0000000..884eda0
--- /dev/null
+++ b/include/torlinkc/util/atomic_write.hpp
@@ -0,0 +1,16 @@
+#pragma once
+
+#include <string>
+
+#include <nlohmann/json.hpp>
+
+namespace torlinkc {
+
+// Writes `data` to `file` via write-tmp-then-rename, so a reader never sees a
+// half-written file and a crash mid-write never corrupts the previous good
+// copy. Creates parent directories as needed. Throws on failure -- callers
+// that want a best-effort write (matching the original's swallow-and-ignore
+// persistence calls) should catch around it themselves.
+void writeJsonAtomic(const std::string& file, const nlohmann::json& data);
+
+} // namespace torlinkc
diff --git a/include/torlinkc/util/net.hpp b/include/torlinkc/util/net.hpp
new file mode 100644
index 0000000..f193ef5
--- /dev/null
+++ b/include/torlinkc/util/net.hpp
@@ -0,0 +1,53 @@
+#pragma once
+
+#include <cstdint>
+#include <map>
+#include <optional>
+#include <stdexcept>
+#include <string>
+
+namespace torlinkc {
+
+inline constexpr const char* kUserAgent = "torlinkc (+https://git.kristoffersson.info/repos/Torlinkc)";
+
+struct HttpResponse {
+ int status = 0;
+ std::string body;
+ std::map<std::string, std::string> headers; // lowercase keys
+
+ bool ok() const { return status >= 200 && status < 300; }
+ std::optional<std::string> header(const std::string& lowercaseName) const {
+ auto it = headers.find(lowercaseName);
+ if (it == headers.end()) return std::nullopt;
+ return it->second;
+ }
+};
+
+class HttpError : public std::runtime_error {
+ public:
+ HttpError(int status, const std::string& message) : std::runtime_error(message), status(status) {}
+ int status;
+};
+
+struct FetchOptions {
+ int retries = 5;
+ int baseMs = 500;
+ int capMs = 20000;
+};
+
+// Parses a Retry-After header value (either delay-seconds or an HTTP-date)
+// into a millisecond delay. Ported from util/net.ts::parseRetryAfter.
+std::optional<std::int64_t> parseRetryAfter(const std::string& value);
+
+// Exponential backoff with full jitter, honoring an explicit Retry-After
+// floor when present. Ported from util/net.ts::backoffDelay.
+std::int64_t backoffDelay(int attempt, int baseMs, int capMs, std::optional<std::int64_t> retryAfterMs = std::nullopt);
+
+// A GET request with retry/backoff for transient failures, matching
+// util/net.ts::fetchResilient's policy: retries on 408/425/429/500/502/503/504,
+// honors Retry-After, and treats a 503 from ddos-guard/cloudflare as
+// immediately fatal (retrying never helps against those). Phase 1 has no
+// cancellation model yet, so there is no AbortSignal equivalent.
+HttpResponse fetchResilient(const std::string& url, const FetchOptions& opts = {});
+
+} // namespace torlinkc
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
new file mode 100644
index 0000000..fcc67f4
--- /dev/null
+++ b/src/CMakeLists.txt
@@ -0,0 +1,25 @@
+add_library(torlinkc_core STATIC
+ config/config.cpp
+ config/paths.cpp
+ engine/bootguard.cpp
+ engine/delete_data.cpp
+ engine/history.cpp
+ engine/persist.cpp
+ engine/queue.cpp
+ engine/reconcile.cpp
+ engine/torrent_engine.cpp
+ engine/types.cpp
+ sources/cache.cpp
+ sources/magnet.cpp
+ sources/piratebay.cpp
+ util/atomic_write.cpp
+ util/net.cpp
+)
+
+target_include_directories(torlinkc_core PUBLIC ${CMAKE_SOURCE_DIR}/include)
+
+target_link_libraries(torlinkc_core PUBLIC
+ LibtorrentRasterbar::torrent-rasterbar
+ CURL::libcurl
+ nlohmann_json::nlohmann_json
+)
diff --git a/src/config/config.cpp b/src/config/config.cpp
new file mode 100644
index 0000000..86dd6d7
--- /dev/null
+++ b/src/config/config.cpp
@@ -0,0 +1,50 @@
+#include "torlinkc/config/config.hpp"
+
+#include <fstream>
+#include <sstream>
+
+#include <nlohmann/json.hpp>
+
+#include "torlinkc/config/paths.hpp"
+#include "torlinkc/util/atomic_write.hpp"
+
+namespace torlinkc {
+
+Config defaultConfig() { return Config{paths::defaultDownloadDir(), {}}; }
+
+Config loadConfig() {
+ std::ifstream in(paths::configFile(), std::ios::binary);
+ if (!in) return defaultConfig();
+
+ std::ostringstream ss;
+ ss << in.rdbuf();
+
+ nlohmann::json parsed;
+ try {
+ parsed = nlohmann::json::parse(ss.str());
+ } catch (const nlohmann::json::parse_error&) {
+ return defaultConfig();
+ }
+
+ Config cfg = defaultConfig();
+ if (auto it = parsed.find("downloadDir");
+ it != parsed.end() && it->is_string() && !it->get<std::string>().empty()) {
+ cfg.downloadDir = it->get<std::string>();
+ }
+ cfg.trackers.clear();
+ if (auto it = parsed.find("trackers"); it != parsed.end() && it->is_array()) {
+ for (const auto& t : *it) {
+ if (t.is_string() && !t.get<std::string>().empty()) cfg.trackers.push_back(t.get<std::string>());
+ }
+ }
+ return cfg;
+}
+
+void saveConfig(const Config& config) {
+ nlohmann::json j;
+ j["downloadDir"] = config.downloadDir;
+ j["trackers"] = config.trackers;
+ writeJsonAtomic(paths::configFile(), j);
+}
+
+} // namespace torlinkc
diff --git a/src/config/paths.cpp b/src/config/paths.cpp
new file mode 100644
index 0000000..57c7d02
--- /dev/null
+++ b/src/config/paths.cpp
@@ -0,0 +1,72 @@
+#include "torlinkc/config/paths.hpp"
+
+#include <cstdlib>
+#include <filesystem>
+
+namespace fs = std::filesystem;
+
+namespace torlinkc::paths {
+
+namespace {
+
+std::string homeDir() {
+ if (const char* h = std::getenv("HOME")) return h;
+ return "/tmp";
+}
+
+std::string envOr(const char* name, const std::string& fallback) {
+ if (const char* v = std::getenv(name); v && *v) return v;
+ return fallback;
+}
+
+fs::path dataBaseDir() {
+#ifdef __APPLE__
+ return fs::path(homeDir()) / "Library" / "Application Support" / kAppName;
+#else
+ return fs::path(envOr("XDG_DATA_HOME", homeDir() + "/.local/share")) / kAppName;
+#endif
+}
+
+fs::path configBaseDir() {
+#ifdef __APPLE__
+ return fs::path(homeDir()) / "Library" / "Preferences" / kAppName;
+#else
+ return fs::path(envOr("XDG_CONFIG_HOME", homeDir() + "/.config")) / kAppName;
+#endif
+}
+
+fs::path dataDir() {
+ if (const char* override_ = std::getenv("TORLINK_STATE_DIR"); override_ && *override_) {
+ return fs::path(override_) / "data";
+ }
+ return dataBaseDir();
+}
+
+fs::path configDir() {
+ if (const char* override_ = std::getenv("TORLINK_STATE_DIR"); override_ && *override_) {
+ return fs::path(override_) / "config";
+ }
+ return configBaseDir();
+}
+
+} // namespace
+
+std::string defaultDownloadDir() {
+ return (fs::path(homeDir()) / "Downloads" / "torlink").string();
+}
+
+std::string configFile() { return (configDir() / "config.json").string(); }
+
+std::string queueFile() { return (dataDir() / "queue.json").string(); }
+
+std::string historyFile() { return (dataDir() / "history.json").string(); }
+
+std::string seedsFile() { return (dataDir() / "seeds.json").string(); }
+
+std::string torrentsDir() { return (dataDir() / "torrents").string(); }
+
+std::string bootMarkerFile() { return (dataDir() / "boot.marker").string(); }
+
+std::string logsDir() { return (dataDir() / "logs").string(); }
+
+} // namespace torlinkc::paths
diff --git a/src/engine/bootguard.cpp b/src/engine/bootguard.cpp
new file mode 100644
index 0000000..2e0b422
--- /dev/null
+++ b/src/engine/bootguard.cpp
@@ -0,0 +1,42 @@
+#include "torlinkc/engine/bootguard.hpp"
+
+#include <chrono>
+#include <filesystem>
+#include <fstream>
+#include <unistd.h>
+
+#include <nlohmann/json.hpp>
+
+#include "torlinkc/config/paths.hpp"
+
+namespace fs = std::filesystem;
+
+namespace torlinkc {
+
+bool wasBootInterrupted() {
+ std::error_code ec;
+ return fs::exists(paths::bootMarkerFile(), ec);
+}
+
+void armBootMarker() {
+ try {
+ fs::create_directories(fs::path(paths::bootMarkerFile()).parent_path());
+ auto now = std::chrono::duration_cast<std::chrono::milliseconds>(
+ std::chrono::system_clock::now().time_since_epoch())
+ .count();
+ nlohmann::json j;
+ j["at"] = now;
+ j["pid"] = static_cast<int>(getpid());
+ std::ofstream out(paths::bootMarkerFile(), std::ios::binary | std::ios::trunc);
+ out << j.dump();
+ } catch (...) {
+ // Failing to write the marker never blocks a boot.
+ }
+}
+
+void disarmBootMarker() {
+ std::error_code ec;
+ fs::remove(paths::bootMarkerFile(), ec);
+}
+
+} // namespace torlinkc
diff --git a/src/engine/delete_data.cpp b/src/engine/delete_data.cpp
new file mode 100644
index 0000000..a4a9684
--- /dev/null
+++ b/src/engine/delete_data.cpp
@@ -0,0 +1,27 @@
+#include "torlinkc/engine/delete_data.hpp"
+
+#include <filesystem>
+
+namespace fs = std::filesystem;
+
+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
+
+std::optional<std::string> deleteSeedData(const std::string& dir, const std::string& name) {
+ const std::string base = fs::path(trim(name)).filename().string();
+ if (base.empty() || base == "." || base == "..") return std::nullopt;
+ fs::path target = fs::path(dir) / base;
+ std::error_code ec;
+ fs::remove_all(target, ec); // best-effort: errors are swallowed, same as the original
+ return target.string();
+}
+
+} // namespace torlinkc
diff --git a/src/engine/history.cpp b/src/engine/history.cpp
new file mode 100644
index 0000000..889721f
--- /dev/null
+++ b/src/engine/history.cpp
@@ -0,0 +1,83 @@
+#include "torlinkc/engine/history.hpp"
+
+#include <algorithm>
+#include <fstream>
+#include <optional>
+#include <sstream>
+
+#include <nlohmann/json.hpp>
+
+#include "torlinkc/config/paths.hpp"
+#include "torlinkc/util/atomic_write.hpp"
+
+using nlohmann::json;
+
+namespace torlinkc {
+
+namespace {
+
+json toJson(const HistoryItem& h) {
+ json j;
+ j["id"] = h.id;
+ j["name"] = h.name;
+ if (!h.source.empty()) j["source"] = h.source;
+ j["sizeBytes"] = h.sizeBytes;
+ j["magnet"] = h.magnet;
+ j["dir"] = h.dir;
+ j["completedAt"] = h.completedAt;
+ return j;
+}
+
+std::optional<HistoryItem> fromJson(const json& j) {
+ if (!j.is_object()) return std::nullopt;
+ auto idIt = j.find("id");
+ auto nameIt = j.find("name");
+ auto magnetIt = j.find("magnet");
+ if (idIt == j.end() || !idIt->is_string()) return std::nullopt;
+ if (nameIt == j.end() || !nameIt->is_string()) return std::nullopt;
+ if (magnetIt == j.end() || !magnetIt->is_string()) return std::nullopt;
+
+ HistoryItem h;
+ h.id = idIt->get<std::string>();
+ h.name = nameIt->get<std::string>();
+ h.magnet = magnetIt->get<std::string>();
+ if (auto v = j.find("source"); v != j.end() && v->is_string()) h.source = v->get<std::string>();
+ if (auto v = j.find("dir"); v != j.end() && v->is_string()) h.dir = v->get<std::string>();
+ if (auto v = j.find("sizeBytes"); v != j.end() && v->is_number()) h.sizeBytes = v->get<std::int64_t>();
+ if (auto v = j.find("completedAt"); v != j.end() && v->is_number()) h.completedAt = v->get<std::int64_t>();
+ return h;
+}
+
+} // namespace
+
+void saveHistory(const std::vector<HistoryItem>& items) {
+ json arr = json::array();
+ std::size_t n = std::min<std::size_t>(items.size(), kHistoryCap);
+ for (std::size_t i = 0; i < n; ++i) arr.push_back(toJson(items[i]));
+ try {
+ writeJsonAtomic(paths::historyFile(), arr);
+ } catch (...) {
+ }
+}
+
+std::vector<HistoryItem> loadHistory() {
+ std::vector<HistoryItem> out;
+ std::ifstream in(paths::historyFile(), std::ios::binary);
+ if (!in) return out;
+ std::ostringstream ss;
+ ss << in.rdbuf();
+ json parsed;
+ try {
+ parsed = json::parse(ss.str());
+ } catch (...) {
+ return out;
+ }
+ if (!parsed.is_array()) return out;
+ for (const auto& el : parsed) {
+ if (out.size() >= static_cast<std::size_t>(kHistoryCap)) break;
+ if (auto h = fromJson(el)) out.push_back(std::move(*h));
+ }
+ return out;
+}
+
+} // namespace torlinkc
diff --git a/src/engine/persist.cpp b/src/engine/persist.cpp
new file mode 100644
index 0000000..74b7a35
--- /dev/null
+++ b/src/engine/persist.cpp
@@ -0,0 +1,229 @@
+#include "torlinkc/engine/persist.hpp"
+
+#include <algorithm>
+#include <cctype>
+#include <filesystem>
+#include <fstream>
+#include <sstream>
+
+#include <nlohmann/json.hpp>
+
+#include "torlinkc/config/paths.hpp"
+#include "torlinkc/util/atomic_write.hpp"
+
+namespace fs = std::filesystem;
+using nlohmann::json;
+
+namespace torlinkc {
+
+namespace {
+
+json toJson(const QueueItem& it) {
+ json j;
+ j["id"] = it.id;
+ j["name"] = it.name;
+ if (!it.source.empty()) j["source"] = it.source;
+ j["magnet"] = it.magnet;
+ j["dir"] = it.dir;
+ j["status"] = downloadStatusToString(it.status);
+ j["progress"] = it.progress;
+ j["totalBytes"] = it.totalBytes;
+ j["downloadedBytes"] = it.downloadedBytes;
+ j["speed"] = it.speed;
+ j["peers"] = it.peers;
+ if (it.eta) j["eta"] = *it.eta;
+ if (it.files) j["files"] = *it.files;
+ if (it.error) j["error"] = *it.error;
+ j["addedAt"] = it.addedAt;
+ return j;
+}
+
+// Mirrors persist.ts's isQueueItem: the only load-bearing check is that id and
+// magnet are both strings. Everything else is read defensively field-by-field
+// below rather than rejecting the whole entry.
+std::optional<QueueItem> fromJson(const json& j) {
+ if (!j.is_object()) return std::nullopt;
+ auto idIt = j.find("id");
+ auto magnetIt = j.find("magnet");
+ if (idIt == j.end() || !idIt->is_string()) return std::nullopt;
+ if (magnetIt == j.end() || !magnetIt->is_string()) return std::nullopt;
+
+ QueueItem it;
+ it.id = idIt->get<std::string>();
+ it.magnet = magnetIt->get<std::string>();
+ if (auto v = j.find("name"); v != j.end() && v->is_string()) it.name = v->get<std::string>();
+ if (auto v = j.find("source"); v != j.end() && v->is_string()) it.source = v->get<std::string>();
+ if (auto v = j.find("dir"); v != j.end() && v->is_string()) it.dir = v->get<std::string>();
+ it.status = DownloadStatus::Downloading;
+ if (auto v = j.find("status"); v != j.end() && v->is_string()) {
+ if (auto parsed = downloadStatusFromString(v->get<std::string>())) it.status = *parsed;
+ }
+ if (auto v = j.find("progress"); v != j.end() && v->is_number()) it.progress = v->get<int>();
+ if (auto v = j.find("totalBytes"); v != j.end() && v->is_number()) it.totalBytes = v->get<std::int64_t>();
+ if (auto v = j.find("downloadedBytes"); v != j.end() && v->is_number())
+ it.downloadedBytes = v->get<std::int64_t>();
+ if (auto v = j.find("speed"); v != j.end() && v->is_number()) it.speed = v->get<int>();
+ if (auto v = j.find("peers"); v != j.end() && v->is_number()) it.peers = v->get<int>();
+ if (auto v = j.find("eta"); v != j.end() && v->is_number()) it.eta = v->get<double>();
+ if (auto v = j.find("files"); v != j.end() && v->is_number()) it.files = v->get<int>();
+ if (auto v = j.find("error"); v != j.end() && v->is_string()) it.error = v->get<std::string>();
+ if (auto v = j.find("addedAt"); v != j.end() && v->is_number()) it.addedAt = v->get<std::int64_t>();
+ return it;
+}
+
+json toJson(const SeedRecord& r) {
+ json j;
+ j["id"] = r.id;
+ j["status"] = seedStatusToString(r.status);
+ return j;
+}
+
+std::string readFile(const std::string& path) {
+ std::ifstream in(path, std::ios::binary);
+ if (!in) return {};
+ std::ostringstream ss;
+ ss << in.rdbuf();
+ return ss.str();
+}
+
+} // namespace
+
+void saveQueue(const std::vector<QueueItem>& items) {
+ json arr = json::array();
+ for (const auto& it : items) arr.push_back(toJson(it));
+ try {
+ writeJsonAtomic(paths::queueFile(), arr);
+ } catch (...) {
+ }
+}
+
+std::vector<QueueItem> loadQueue() {
+ std::vector<QueueItem> out;
+ std::string raw = readFile(paths::queueFile());
+ if (raw.empty()) return out;
+ json parsed;
+ try {
+ parsed = json::parse(raw);
+ } catch (...) {
+ return out;
+ }
+ if (!parsed.is_array()) return out;
+ for (const auto& el : parsed) {
+ if (auto it = fromJson(el)) out.push_back(std::move(*it));
+ }
+ return out;
+}
+
+void saveSeeds(const std::vector<SeedRecord>& records) {
+ json arr = json::array();
+ for (const auto& r : records) arr.push_back(toJson(r));
+ try {
+ writeJsonAtomic(paths::seedsFile(), arr);
+ } catch (...) {
+ }
+}
+
+std::vector<SeedRecord> loadSeeds() {
+ std::vector<SeedRecord> out;
+ std::string raw = readFile(paths::seedsFile());
+ if (raw.empty()) return out;
+ json parsed;
+ try {
+ parsed = json::parse(raw);
+ } catch (...) {
+ return out;
+ }
+ if (!parsed.is_array()) return out;
+ for (const auto& el : parsed) {
+ // Legacy format was a bare id array; treat each as a seeding entry.
+ if (el.is_string()) {
+ out.push_back(SeedRecord{el.get<std::string>(), SeedStatus::Seeding});
+ continue;
+ }
+ if (!el.is_object()) continue;
+ auto idIt = el.find("id");
+ auto statusIt = el.find("status");
+ if (idIt == el.end() || !idIt->is_string()) continue;
+ if (statusIt == el.end() || !statusIt->is_string()) continue;
+ const std::string status = statusIt->get<std::string>();
+ if (status != "seeding" && status != "paused") continue;
+ out.push_back(SeedRecord{idIt->get<std::string>(), status == "seeding" ? SeedStatus::Seeding : SeedStatus::Paused});
+ }
+ return out;
+}
+
+std::string torrentMetaPath(const std::string& id) { return (fs::path(paths::torrentsDir()) / (id + ".torrent")).string(); }
+
+bool torrentMetaExists(const std::string& id) {
+ std::error_code ec;
+ return fs::exists(torrentMetaPath(id), ec);
+}
+
+std::string torrentExportName(const std::string& name, const std::string& id) {
+ std::string base;
+ base.reserve(name.size());
+ for (unsigned char c : name) {
+ if (c < 0x20 || c == '<' || c == '>' || c == ':' || c == '"' || c == '/' || c == '\\' || c == '|' || c == '?' ||
+ c == '*') {
+ base += ' ';
+ } else {
+ base += static_cast<char>(c);
+ }
+ }
+ // Collapse runs of whitespace to a single space, then trim.
+ std::string collapsed;
+ bool lastWasSpace = false;
+ for (char c : base) {
+ bool isSpace = std::isspace(static_cast<unsigned char>(c)) != 0;
+ if (isSpace && lastWasSpace) continue;
+ collapsed += isSpace ? ' ' : c;
+ lastWasSpace = isSpace;
+ }
+ auto first = collapsed.find_first_not_of(' ');
+ auto last = collapsed.find_last_not_of(' ');
+ std::string trimmed = first == std::string::npos ? "" : collapsed.substr(first, last - first + 1);
+ // Trim trailing dots/spaces (Windows can't have filenames ending in either).
+ while (!trimmed.empty() && (trimmed.back() == '.' || trimmed.back() == ' ')) trimmed.pop_back();
+
+ std::string result = trimmed.empty() ? (id.empty() ? "torrent" : id) : trimmed;
+ if (result.size() > 180) result.resize(180);
+ return result + ".torrent";
+}
+
+void saveTorrentMeta(const std::string& id, const std::string& data) {
+ try {
+ fs::create_directories(paths::torrentsDir());
+ fs::path file = torrentMetaPath(id);
+ fs::path tmp = file;
+ tmp += ".tmp";
+ {
+ std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
+ out.write(data.data(), static_cast<std::streamsize>(data.size()));
+ }
+ std::error_code ec;
+ fs::rename(tmp, file, ec);
+ } catch (...) {
+ }
+}
+
+std::optional<std::string> exportTorrentMeta(const std::string& id, const std::string& name, const std::string& dir) {
+ try {
+ fs::path source = torrentMetaPath(id);
+ std::error_code ec;
+ if (!fs::exists(source, ec)) return std::nullopt;
+ fs::create_directories(dir);
+ fs::path target = fs::path(dir) / torrentExportName(name, id);
+ fs::copy_file(source, target, fs::copy_options::overwrite_existing, ec);
+ if (ec) return std::nullopt;
+ return target.string();
+ } catch (...) {
+ return std::nullopt;
+ }
+}
+
+void deleteTorrentMeta(const std::string& id) {
+ std::error_code ec;
+ fs::remove(torrentMetaPath(id), ec);
+}
+
+} // namespace torlinkc
diff --git a/src/engine/queue.cpp b/src/engine/queue.cpp
new file mode 100644
index 0000000..f65a206
--- /dev/null
+++ b/src/engine/queue.cpp
@@ -0,0 +1,654 @@
+#include "torlinkc/engine/queue.hpp"
+
+#include <algorithm>
+#include <chrono>
+#include <cmath>
+#include <cstdlib>
+#include <limits>
+#include <thread>
+
+#include "torlinkc/engine/bootguard.hpp"
+#include "torlinkc/engine/delete_data.hpp"
+
+namespace torlinkc {
+
+namespace {
+
+constexpr int kStrayTicks = 2; // consecutive stray polls before flagging missing (~1s @ 500ms tick)
+constexpr std::int64_t kSeedGraceMs = 10'000; // let libtorrent verify on-disk pieces before watching for strays
+constexpr int kFetchMetadataTimeoutMs = 20'000; // a peerless magnet never fires metadata; give up after this long
+
+std::int64_t nowMs() {
+ return std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now().time_since_epoch())
+ .count();
+}
+
+int readMaxDownloads() {
+ if (const char* v = std::getenv("TORLINK_MAX_DOWNLOADS")) {
+ try {
+ int n = std::stoi(v);
+ if (n > 0) return n;
+ } catch (...) {
+ }
+ }
+ return 0;
+}
+
+} // namespace
+
+bool strayDownload(std::int64_t total, double progress, int speed) {
+ return total > 0 && progress < 1.0 && speed > 0;
+}
+
+DownloadQueue::DownloadQueue(std::optional<int> maxDownloads)
+ : maxDownloads_(maxDownloads.value_or(readMaxDownloads())) {}
+
+void DownloadQueue::setTrackers(std::vector<std::string> trackers) { trackers_ = std::move(trackers); }
+
+std::vector<QueueItem> DownloadQueue::getItems() const {
+ std::vector<QueueItem> out;
+ out.reserve(items_.size());
+ for (const auto& [id, it] : items_) out.push_back(it);
+ std::sort(out.begin(), out.end(), [](const QueueItem& a, const QueueItem& b) { return a.addedAt > b.addedAt; });
+ return out;
+}
+
+int DownloadQueue::activeCount() const {
+ int n = 0;
+ for (const auto& [id, it] : items_)
+ if (it.status == DownloadStatus::Downloading) n++;
+ return n;
+}
+
+bool DownloadQueue::has(const std::string& id) const { return items_.count(id) > 0; }
+
+void DownloadQueue::add(const AddInput& input, const std::string& dir) {
+ if (seeds_.count(input.id)) {
+ engine_.remove(input.id);
+ seeds_.erase(input.id);
+ strayHits_.erase(input.id);
+ seedStartedAt_.erase(input.id);
+ persistSeeds();
+ }
+
+ auto existingIt = items_.find(input.id);
+ if (existingIt != items_.end() && existingIt->second.status != DownloadStatus::Failed) return;
+
+ QueueItem item;
+ if (existingIt != items_.end()) {
+ item = existingIt->second;
+ // A re-add is a fresh request, so it targets the dir asked for now.
+ // Partial data doesn't follow to a new folder, so resume progress only
+ // survives when the dir is unchanged.
+ const bool sameDir = item.dir == dir;
+ item.dir = dir;
+ item.error.reset();
+ item.speed = 0;
+ if (!sameDir) {
+ item.progress = 0;
+ item.downloadedBytes = 0;
+ item.eta.reset();
+ }
+ } else {
+ item.id = input.id;
+ item.name = input.name;
+ item.source = input.source;
+ item.magnet = input.magnet;
+ item.dir = dir;
+ item.progress = 0;
+ item.totalBytes = input.sizeBytes.value_or(0);
+ item.downloadedBytes = 0;
+ item.speed = 0;
+ item.peers = 0;
+ item.addedAt = nowMs();
+ }
+
+ // Respect the concurrent-download cap: start now if a slot is free, else
+ // hold the torrent as "queued" until one frees (see promote()).
+ const bool start = maxDownloads_ == 0 || activeCount() < maxDownloads_;
+ item.status = start ? DownloadStatus::Downloading : DownloadStatus::Queued;
+ items_[item.id] = item;
+ if (start) startEngine(items_[item.id]);
+ changed();
+ persistQueue();
+}
+
+void DownloadQueue::startEngine(QueueItem& item) {
+ engine_.add(item.id, item.magnet, item.dir, engineHandlers(item.id), trackers_);
+}
+
+void DownloadQueue::promote() {
+ const int cap = maxDownloads_ == 0 ? std::numeric_limits<int>::max() : maxDownloads_;
+ bool started = false;
+ while (activeCount() < cap) {
+ QueueItem* next = nullptr;
+ for (auto& [id, it] : items_) {
+ if (it.status != DownloadStatus::Queued) continue;
+ if (!next || it.addedAt < next->addedAt) next = ⁢
+ }
+ if (!next) break;
+ next->status = DownloadStatus::Downloading;
+ next->speed = 0;
+ startEngine(*next);
+ started = true;
+ }
+ if (started) {
+ changed();
+ persistQueue();
+ }
+}
+
+AddHandlers DownloadQueue::engineHandlers(std::string id) {
+ AddHandlers h;
+ h.onMetadata = [this, id](const TorrentMeta& meta) {
+ // Capture the .torrent metadata as soon as it arrives so a later re-seed
+ // can verify the on-disk file locally.
+ if (meta.torrentFile) saveTorrentMeta(id, *meta.torrentFile);
+ auto it = items_.find(id);
+ if (it == items_.end()) return; // the rest only matters while still downloading
+ if (!meta.name.empty()) it->second.name = meta.name;
+ if (meta.total > 0) it->second.totalBytes = meta.total;
+ it->second.files = meta.files;
+ changed();
+ persistQueue();
+ };
+ h.onDone = [this, id]() {
+ auto it = items_.find(id);
+ if (it != items_.end()) {
+ QueueItem completed = it->second;
+ if (completed.totalBytes) completed.downloadedBytes = completed.totalBytes;
+ completeItem(std::move(completed));
+ return;
+ }
+ // A re-seed (restart / manual resume) passed verification: clear
+ // stray-detection state and end its grace period.
+ if (seeds_.count(id)) {
+ strayHits_[id] = 0;
+ seedStartedAt_.erase(id);
+ }
+ };
+ h.onError = [this, id](const std::string& msg) {
+ auto it = items_.find(id);
+ if (it != items_.end()) {
+ it->second.status = DownloadStatus::Failed;
+ it->second.error = msg;
+ it->second.speed = 0;
+ it->second.peers = 0;
+ changed();
+ persistQueue();
+ promote(); // a slot just freed
+ return;
+ }
+ auto sit = seeds_.find(id);
+ if (sit != seeds_.end()) {
+ sit->second.status = SeedStatus::Missing;
+ sit->second.uploadSpeed = 0;
+ sit->second.peers = 0;
+ seedStartedAt_.erase(id);
+ changed();
+ persistSeeds();
+ }
+ };
+ return h;
+}
+
+void DownloadQueue::completeItem(QueueItem it) {
+ recordHistory(it);
+ items_.erase(it.id);
+ // Opt-out seeding: a finished download is already a complete, verified
+ // torrent, so keep it alive and seeding instead of tearing it down.
+ beginSeed(it);
+ if (onCompleted) onCompleted(it.name);
+ changed();
+ persistQueue();
+ promote(); // a slot just freed
+}
+
+void DownloadQueue::beginSeed(const QueueItem& it) {
+ if (it.magnet.empty()) return;
+ SeedItem s;
+ s.id = it.id;
+ s.name = it.name;
+ s.source = it.source;
+ s.magnet = it.magnet;
+ s.dir = it.dir;
+ s.sizeBytes = it.totalBytes;
+ s.status = SeedStatus::Seeding;
+ seeds_[it.id] = s;
+ strayHits_[it.id] = 0;
+ seedStartedAt_[it.id] = nowMs();
+ persistSeeds();
+}
+
+void DownloadQueue::tick() {
+ engine_.pollAlerts();
+
+ bool any = false;
+ for (auto& [id, it] : items_) {
+ if (it.status != DownloadStatus::Downloading) continue;
+ auto s = engine_.stats(id);
+ if (!s) continue;
+ it.progress = std::min(100, static_cast<int>(std::lround(s->progress * 100)));
+ it.downloadedBytes = s->downloaded;
+ if (s->total) it.totalBytes = s->total;
+ it.speed = s->speed;
+ it.peers = s->peers;
+ if (s->timeRemaining > 0 && std::isfinite(s->timeRemaining)) {
+ it.eta = s->timeRemaining / 1000.0;
+ } else {
+ it.eta.reset();
+ }
+ if (!s->name.empty()) it.name = s->name;
+ any = true;
+ }
+
+ const std::int64_t now = nowMs();
+ for (auto& [id, sd] : seeds_) {
+ if (sd.status != SeedStatus::Seeding) continue;
+ auto s = engine_.stats(id);
+ if (!s) continue;
+ // Safety-net: a seed that's pulling data has lost its files on disk. Give
+ // it a couple of ticks (ignore a one-piece repair blip), then stop it and
+ // flag missing, never re-download the whole thing. Skip seeds still
+ // inside the grace period: libtorrent needs time to hash-verify on-disk
+ // pieces, and during that window progress < 1 with download_rate > 0 is
+ // perfectly normal.
+ auto startedIt = seedStartedAt_.find(id);
+ const std::int64_t age = now - (startedIt != seedStartedAt_.end() ? startedIt->second : 0);
+ if (age > kSeedGraceMs && strayDownload(s->total, s->progress, s->speed)) {
+ const int hits = (strayHits_.count(id) ? strayHits_[id] : 0) + 1;
+ strayHits_[id] = hits;
+ if (hits >= kStrayTicks) {
+ engine_.remove(id);
+ strayHits_.erase(id);
+ seedStartedAt_.erase(id);
+ sd.status = SeedStatus::Missing;
+ sd.uploadSpeed = 0;
+ sd.peers = 0;
+ persistSeeds();
+ }
+ any = true;
+ continue;
+ }
+ strayHits_[id] = 0;
+ sd.uploadSpeed = s->uploadSpeed;
+ sd.uploaded = s->uploaded;
+ sd.peers = s->peers;
+ any = true;
+ }
+
+ if (any) changed();
+}
+
+void DownloadQueue::pause(const std::string& id) {
+ auto it = items_.find(id);
+ if (it == items_.end()) return;
+ if (it->second.status != DownloadStatus::Downloading && it->second.status != DownloadStatus::Queued) return;
+ const bool wasDownloading = it->second.status == DownloadStatus::Downloading;
+ it->second.status = DownloadStatus::Paused;
+ it->second.speed = 0;
+ it->second.peers = 0;
+ it->second.eta.reset();
+ if (wasDownloading) engine_.remove(id);
+ changed();
+ persistQueue();
+ if (wasDownloading) promote(); // a slot just freed
+}
+
+void DownloadQueue::resume(const std::string& id) {
+ auto it = items_.find(id);
+ if (it == items_.end() || it->second.status != DownloadStatus::Paused) return;
+ const bool start = maxDownloads_ == 0 || activeCount() < maxDownloads_;
+ it->second.status = start ? DownloadStatus::Downloading : DownloadStatus::Queued;
+ if (start) startEngine(it->second);
+ changed();
+ persistQueue();
+}
+
+void DownloadQueue::togglePause(const std::string& id) {
+ auto it = items_.find(id);
+ if (it == items_.end()) return;
+ if (it->second.status == DownloadStatus::Downloading || it->second.status == DownloadStatus::Queued) pause(id);
+ else if (it->second.status == DownloadStatus::Paused) resume(id);
+}
+
+std::optional<std::string> DownloadQueue::exportTorrentFile(const std::string& id) {
+ if (auto it = items_.find(id); it != items_.end()) return exportTorrentMeta(id, it->second.name, it->second.dir);
+ if (auto sit = seeds_.find(id); sit != seeds_.end())
+ return exportTorrentMeta(id, sit->second.name, sit->second.dir);
+ for (const auto& h : history_) {
+ if (h.id == id) return exportTorrentMeta(id, h.name, h.dir);
+ }
+ return std::nullopt;
+}
+
+std::optional<std::string> DownloadQueue::fetchAndExportTorrent(const AddInput& input, const std::string& exportDir) {
+ // Fast path: cached from a previous download.
+ if (torrentMetaExists(input.id)) return exportTorrentMeta(input.id, input.name, exportDir);
+ // Already in the engine (downloading / seeding): its metadata will arrive
+ // through the normal queue flow; don't double-add it.
+ if (items_.count(input.id) || seeds_.count(input.id)) return std::nullopt;
+
+ const std::string tempKey = "__meta__" + input.id;
+ std::optional<TorrentMeta> gotMeta;
+ bool gotError = false;
+
+ AddHandlers handlers;
+ handlers.onMetadata = [&](const TorrentMeta& meta) { gotMeta = meta; };
+ handlers.onError = [&](const std::string&) { gotError = true; };
+ engine_.add(tempKey, input.magnet, exportDir, handlers, trackers_);
+
+ const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(kFetchMetadataTimeoutMs);
+ while (!gotMeta && !gotError && std::chrono::steady_clock::now() < deadline) {
+ engine_.pollAlerts();
+ std::this_thread::sleep_for(std::chrono::milliseconds(100));
+ }
+ // Tear down synchronously before any file data can be written.
+ engine_.remove(tempKey);
+ if (!gotMeta) return std::nullopt;
+ if (gotMeta->torrentFile) saveTorrentMeta(input.id, *gotMeta->torrentFile);
+ return exportTorrentMeta(input.id, input.name, exportDir);
+}
+
+void DownloadQueue::cancel(const std::string& id) {
+ if (!items_.count(id)) return;
+ engine_.remove(id);
+ items_.erase(id);
+ deleteTorrentMeta(id);
+ changed();
+ persistQueue();
+ promote(); // a slot may have freed
+}
+
+bool DownloadQueue::remove(const std::string& id, bool deleteFiles) {
+ auto it = items_.find(id);
+ auto sit = seeds_.find(id);
+ const HistoryItem* hist = nullptr;
+ for (const auto& h : history_) {
+ if (h.id == id) {
+ hist = &h;
+ break;
+ }
+ }
+ if (it == items_.end() && sit == seeds_.end() && !hist) return false;
+
+ const std::string dir = it != items_.end() ? it->second.dir : sit != seeds_.end() ? sit->second.dir : hist->dir;
+ const std::string name = it != items_.end() ? it->second.name : sit != seeds_.end() ? sit->second.name : hist->name;
+ const bool hadItem = it != items_.end();
+
+ // Tear down any live engine handle + in-memory record.
+ if (it != items_.end() || sit != seeds_.end()) engine_.remove(id);
+ if (it != items_.end()) items_.erase(it);
+ if (sit != seeds_.end()) {
+ seeds_.erase(sit);
+ strayHits_.erase(id);
+ seedStartedAt_.erase(id);
+ }
+ deleteTorrentMeta(id);
+ removeHistory(id); // persists history
+
+ if (deleteFiles && !dir.empty() && !name.empty()) deleteSeedData(dir, name);
+
+ changed();
+ persistQueue();
+ persistSeeds();
+ if (hadItem) promote(); // a download slot may have freed
+ return true;
+}
+
+void DownloadQueue::retry(const std::string& id) {
+ auto it = items_.find(id);
+ if (it == items_.end() || it->second.status != DownloadStatus::Failed) return;
+ it->second.error.reset();
+ const bool start = maxDownloads_ == 0 || activeCount() < maxDownloads_;
+ it->second.status = start ? DownloadStatus::Downloading : DownloadStatus::Queued;
+ if (start) startEngine(it->second);
+ changed();
+ persistQueue();
+}
+
+void DownloadQueue::retryFailed() {
+ std::vector<std::string> ids;
+ for (const auto& [id, it] : items_) {
+ if (it.status == DownloadStatus::Failed) ids.push_back(id);
+ }
+ for (const auto& id : ids) retry(id);
+}
+
+std::optional<SeedItem> DownloadQueue::getSeed(const std::string& id) const {
+ auto it = seeds_.find(id);
+ if (it == seeds_.end()) return std::nullopt;
+ return it->second;
+}
+
+std::vector<SeedItem> DownloadQueue::getSeeds() const {
+ std::vector<SeedItem> out;
+ out.reserve(seeds_.size());
+ for (const auto& [id, s] : seeds_) out.push_back(s);
+ return out;
+}
+
+int DownloadQueue::seedingCount() const {
+ int n = 0;
+ for (const auto& [id, s] : seeds_)
+ if (s.status == SeedStatus::Seeding) n++;
+ return n;
+}
+
+void DownloadQueue::startSeeding(const HistoryItem& h) {
+ if (auto it = seeds_.find(h.id); it != seeds_.end() && it->second.status == SeedStatus::Seeding) return;
+ if (items_.count(h.id)) return; // don't seed a file that's downloading
+
+ SeedItem base;
+ base.id = h.id;
+ base.name = h.name;
+ base.source = h.source;
+ base.magnet = h.magnet;
+ base.dir = h.dir;
+ base.sizeBytes = h.sizeBytes;
+ base.status = SeedStatus::Seeding;
+
+ // Only hard guard we can make synchronously and portably: no magnet, no
+ // seed. We do NOT guess the on-disk path; we let libtorrent verify the real
+ // files and the tick() safety-net flags a missing one.
+ if (h.magnet.empty()) {
+ SeedItem missing = base;
+ missing.status = SeedStatus::Missing;
+ seeds_[h.id] = missing;
+ changed();
+ persistSeeds();
+ return;
+ }
+
+ seeds_[h.id] = base;
+ strayHits_[h.id] = 0;
+ seedStartedAt_[h.id] = nowMs();
+ // Seed from the stored .torrent metadata when we have it (verifies the
+ // local file immediately, no swarm needed); fall back to the magnet
+ // otherwise.
+ const std::string source = torrentMetaExists(h.id) ? torrentMetaPath(h.id) : h.magnet;
+ engine_.add(h.id, source, h.dir, engineHandlers(h.id), trackers_);
+ changed();
+ persistSeeds();
+}
+
+void DownloadQueue::stopSeeding(const std::string& id) {
+ auto it = seeds_.find(id);
+ if (it == seeds_.end()) return;
+ engine_.remove(id);
+ strayHits_.erase(id);
+ seedStartedAt_.erase(id);
+ if (it->second.status == SeedStatus::Seeding) {
+ it->second.status = SeedStatus::Paused;
+ it->second.uploadSpeed = 0;
+ it->second.peers = 0;
+ }
+ changed();
+ persistSeeds();
+}
+
+void DownloadQueue::toggleSeeding(const HistoryItem& h) {
+ auto it = seeds_.find(h.id);
+ if (it != seeds_.end() && it->second.status == SeedStatus::Seeding) stopSeeding(h.id);
+ else startSeeding(h);
+}
+
+void DownloadQueue::restoreSeeds(const std::vector<SeedRecord>& records, RestoreOptions opts) {
+ for (const auto& r : records) {
+ const HistoryItem* h = nullptr;
+ for (const auto& x : history_) {
+ if (x.id == r.id) {
+ h = &x;
+ break;
+ }
+ }
+ if (!h) continue;
+ if (r.status == SeedStatus::Seeding && !opts.safe) startSeeding(*h);
+ else restorePaused(*h);
+ }
+ if (opts.safe) persistSeeds();
+}
+
+void DownloadQueue::restorePaused(const HistoryItem& h) {
+ if (seeds_.count(h.id)) return;
+ SeedItem s;
+ s.id = h.id;
+ s.name = h.name;
+ s.source = h.source;
+ s.magnet = h.magnet;
+ s.dir = h.dir;
+ s.sizeBytes = h.sizeBytes;
+ s.status = SeedStatus::Paused;
+ seeds_[h.id] = s;
+ changed();
+}
+
+std::vector<SeedRecord> DownloadQueue::seedRecords() const {
+ std::vector<SeedRecord> out;
+ out.reserve(seeds_.size());
+ for (const auto& [id, s] : seeds_) {
+ out.push_back(SeedRecord{s.id, s.status == SeedStatus::Seeding ? SeedStatus::Seeding : SeedStatus::Paused});
+ }
+ return out;
+}
+
+void DownloadQueue::persistSeeds() const { saveSeeds(seedRecords()); }
+
+void DownloadQueue::persistQueue() const { saveQueue(getItems()); }
+
+void DownloadQueue::restore(std::vector<QueueItem> items, RestoreOptions opts) {
+ if (opts.safe) {
+ // Engines stay cold: pause everything that would have started and keep
+ // the rest as saved, then persist so the paused state is the new truth.
+ for (auto& raw : items) {
+ if (raw.status == DownloadStatus::Downloading || raw.status == DownloadStatus::Queued)
+ raw.status = DownloadStatus::Paused;
+ items_[raw.id] = raw;
+ }
+ changed();
+ persistQueue();
+ return;
+ }
+
+ int active = 0;
+ for (auto& raw : items) {
+ items_[raw.id] = raw;
+ if (raw.status != DownloadStatus::Downloading) continue;
+ if (maxDownloads_ == 0 || active < maxDownloads_) {
+ startEngine(items_[raw.id]);
+ active++;
+ } else {
+ // Over the cap on boot -> hold as queued (promoted as slots free).
+ items_[raw.id].status = DownloadStatus::Queued;
+ }
+ }
+ changed();
+ // Fill any remaining slots from persisted "queued" items.
+ promote();
+}
+
+void DownloadQueue::restoreHistory(std::vector<HistoryItem> items) {
+ if (items.size() > static_cast<std::size_t>(kHistoryCap)) items.resize(kHistoryCap);
+ history_ = std::move(items);
+}
+
+std::vector<HistoryItem> DownloadQueue::getHistory() const { return history_; }
+
+void DownloadQueue::recordHistory(const QueueItem& it) {
+ HistoryItem rec;
+ rec.id = it.id;
+ rec.name = it.name;
+ rec.source = it.source;
+ rec.sizeBytes = it.totalBytes;
+ rec.magnet = it.magnet;
+ rec.dir = it.dir;
+ rec.completedAt = nowMs();
+
+ history_.erase(std::remove_if(history_.begin(), history_.end(), [&](const HistoryItem& h) { return h.id == it.id; }),
+ history_.end());
+ history_.insert(history_.begin(), rec);
+ if (history_.size() > static_cast<std::size_t>(kHistoryCap)) history_.resize(kHistoryCap);
+ saveHistory(history_);
+}
+
+void DownloadQueue::removeHistory(const std::string& id) {
+ const std::size_t before = history_.size();
+ history_.erase(std::remove_if(history_.begin(), history_.end(), [&](const HistoryItem& h) { return h.id == id; }),
+ history_.end());
+ if (history_.size() == before) return;
+
+ if (seeds_.count(id)) {
+ engine_.remove(id);
+ seeds_.erase(id);
+ strayHits_.erase(id);
+ seedStartedAt_.erase(id);
+ persistSeeds();
+ }
+ deleteTorrentMeta(id);
+ saveHistory(history_);
+ changed();
+}
+
+void DownloadQueue::clearHistory() {
+ if (history_.empty()) return;
+ for (const auto& h : history_) deleteTorrentMeta(h.id);
+ history_.clear();
+ if (!seeds_.empty()) {
+ for (const auto& [id, s] : seeds_) engine_.remove(id);
+ seeds_.clear();
+ strayHits_.clear();
+ seedStartedAt_.clear();
+ persistSeeds();
+ }
+ saveHistory(history_);
+ changed();
+}
+
+void DownloadQueue::changed() {
+ if (onUpdate) onUpdate();
+}
+
+void DownloadQueue::persistSync() {
+ saveQueue(getItems());
+ saveHistory(history_);
+ saveSeeds(seedRecords());
+ // A clean flush doubles as proof this run did not die mid-restore, so the
+ // crash-boot breaker stands down.
+ disarmBootMarker();
+}
+
+void DownloadQueue::suspend() {
+ // Keep active downloads as "downloading" so restore() resumes them on the
+ // next launch; just zero the live stats.
+ for (auto& [id, it] : items_) {
+ if (it.status == DownloadStatus::Downloading) {
+ it.speed = 0;
+ it.peers = 0;
+ it.eta.reset();
+ }
+ }
+ persistSync();
+ engine_.destroy();
+}
+
+} // namespace torlinkc
diff --git a/src/engine/reconcile.cpp b/src/engine/reconcile.cpp
new file mode 100644
index 0000000..b2a05a6
--- /dev/null
+++ b/src/engine/reconcile.cpp
@@ -0,0 +1,34 @@
+#include "torlinkc/engine/reconcile.hpp"
+
+#include <unordered_set>
+
+namespace torlinkc {
+
+std::vector<QueueItem> reconcileQueue(const std::vector<QueueItem>& items) {
+ std::unordered_set<std::string> seen;
+ std::vector<QueueItem> out;
+ out.reserve(items.size());
+ for (const auto& raw : items) {
+ if (raw.id.empty() || seen.count(raw.id)) continue;
+ seen.insert(raw.id);
+ if (raw.status == DownloadStatus::Completed) continue;
+
+ QueueItem it = raw;
+ switch (raw.status) {
+ case DownloadStatus::Failed:
+ case DownloadStatus::Paused:
+ case DownloadStatus::Queued:
+ break; // preserved as-is
+ default:
+ it.status = DownloadStatus::Downloading;
+ break;
+ }
+ it.speed = 0;
+ it.peers = 0;
+ it.eta.reset();
+ out.push_back(std::move(it));
+ }
+ return out;
+}
+
+} // namespace torlinkc
diff --git a/src/engine/torrent_engine.cpp b/src/engine/torrent_engine.cpp
new file mode 100644
index 0000000..86856b3
--- /dev/null
+++ b/src/engine/torrent_engine.cpp
@@ -0,0 +1,172 @@
+#include "torlinkc/engine/torrent_engine.hpp"
+
+#include <limits>
+#include <unordered_set>
+
+#include <libtorrent/add_torrent_params.hpp>
+#include <libtorrent/alert_types.hpp>
+#include <libtorrent/error_code.hpp>
+#include <libtorrent/load_torrent.hpp>
+#include <libtorrent/magnet_uri.hpp>
+#include <libtorrent/settings_pack.hpp>
+#include <libtorrent/torrent_info.hpp>
+#include <libtorrent/torrent_status.hpp>
+#include <libtorrent/write_resume_data.hpp>
+
+namespace torlinkc {
+
+namespace {
+
+bool startsWith(const std::string& s, const std::string& prefix) {
+ return s.size() >= prefix.size() && s.compare(0, prefix.size(), prefix) == 0;
+}
+
+std::string errorMessage(const lt::error_code& ec) { return ec.message(); }
+
+} // namespace
+
+TorrentEngine::TorrentEngine() {
+ lt::settings_pack pack;
+ pack.set_int(lt::settings_pack::alert_mask,
+ lt::alert_category::status | lt::alert_category::error);
+ session_ = std::make_unique<lt::session>(pack);
+}
+
+TorrentEngine::~TorrentEngine() { destroy(); }
+
+void TorrentEngine::removeEntry(const std::string& id) {
+ auto it = torrents_.find(id);
+ if (it == torrents_.end()) return;
+ infoHashToId_.erase(it->second.handle.info_hash());
+ if (session_) session_->remove_torrent(it->second.handle);
+ torrents_.erase(it);
+}
+
+void TorrentEngine::add(const std::string& id, const std::string& source, const std::string& dir,
+ AddHandlers handlers, const std::vector<std::string>& announce) {
+ removeEntry(id);
+
+ lt::error_code ec;
+ lt::add_torrent_params params;
+ if (startsWith(source, "magnet:")) {
+ params = lt::parse_magnet_uri(source, ec);
+ } else {
+ // Everything that isn't a magnet URI is a path to a cached .torrent file
+ // (the only other thing DownloadQueue ever hands us -- see startSeeding).
+ try {
+ params = lt::load_torrent_file(source);
+ } catch (const lt::system_error& e) {
+ ec = e.code();
+ } catch (const std::exception& e) {
+ handlers.onError ? handlers.onError(e.what()) : void();
+ return;
+ }
+ }
+ if (ec) {
+ if (handlers.onError) handlers.onError(errorMessage(ec));
+ return;
+ }
+
+ params.save_path = dir;
+ if (!announce.empty()) {
+ std::unordered_set<std::string> seen(params.trackers.begin(), params.trackers.end());
+ for (const auto& t : announce) {
+ if (seen.insert(t).second) params.trackers.push_back(t);
+ }
+ }
+
+ lt::torrent_handle handle = session_->add_torrent(std::move(params), ec);
+ if (ec) {
+ if (handlers.onError) handlers.onError(errorMessage(ec));
+ return;
+ }
+
+ infoHashToId_[handle.info_hash()] = id;
+ torrents_[id] = Entry{handle, std::move(handlers)};
+}
+
+void TorrentEngine::pollAlerts() {
+ if (!session_) return;
+ std::vector<lt::alert*> alerts;
+ session_->pop_alerts(&alerts);
+
+ for (lt::alert* a : alerts) {
+ lt::torrent_handle handle;
+ if (auto* mt = lt::alert_cast<lt::metadata_received_alert>(a)) {
+ handle = mt->handle;
+ } else if (auto* dt = lt::alert_cast<lt::torrent_finished_alert>(a)) {
+ handle = dt->handle;
+ } else if (auto* et = lt::alert_cast<lt::torrent_error_alert>(a)) {
+ handle = et->handle;
+ } else {
+ continue;
+ }
+
+ auto idIt = infoHashToId_.find(handle.info_hash());
+ if (idIt == infoHashToId_.end()) continue;
+ auto entryIt = torrents_.find(idIt->second);
+ if (entryIt == torrents_.end()) continue;
+ const AddHandlers& h = entryIt->second.handlers;
+
+ if (lt::alert_cast<lt::metadata_received_alert>(a)) {
+ if (!h.onMetadata) continue;
+ TorrentMeta meta;
+ auto ti = handle.torrent_file();
+ if (ti) {
+ meta.name = ti->name();
+ meta.total = ti->total_size();
+ meta.files = ti->num_files();
+ lt::add_torrent_params atp;
+ atp.ti = std::const_pointer_cast<lt::torrent_info>(ti);
+ try {
+ std::vector<char> buf = lt::write_torrent_file_buf(atp, {});
+ meta.torrentFile = std::string(buf.begin(), buf.end());
+ } catch (const std::exception&) {
+ // No piece layers / incomplete info: skip caching the raw bytes,
+ // the rest of the metadata is still valid.
+ }
+ }
+ h.onMetadata(meta);
+ } else if (lt::alert_cast<lt::torrent_finished_alert>(a)) {
+ if (h.onDone) h.onDone();
+ } else if (auto* et = lt::alert_cast<lt::torrent_error_alert>(a)) {
+ if (h.onError) h.onError(et->message());
+ }
+ }
+}
+
+std::optional<TorrentProgress> TorrentEngine::stats(const std::string& id) {
+ auto it = torrents_.find(id);
+ if (it == torrents_.end()) return std::nullopt;
+ if (!it->second.handle.is_valid()) return std::nullopt;
+
+ lt::torrent_status st = it->second.handle.status();
+
+ TorrentProgress p;
+ p.progress = st.progress;
+ p.downloaded = st.total_wanted_done;
+ p.total = st.total_wanted;
+ p.speed = st.download_rate;
+ p.uploadSpeed = st.upload_rate;
+ p.uploaded = st.total_upload;
+ p.peers = st.num_peers;
+ p.name = st.name;
+ p.timeRemaining = std::numeric_limits<double>::infinity();
+ if (st.download_rate > 0 && st.total_wanted > st.total_wanted_done) {
+ p.timeRemaining =
+ static_cast<double>(st.total_wanted - st.total_wanted_done) / st.download_rate * 1000.0;
+ }
+ return p;
+}
+
+void TorrentEngine::remove(const std::string& id) { removeEntry(id); }
+
+void TorrentEngine::destroy() {
+ torrents_.clear();
+ infoHashToId_.clear();
+ session_.reset();
+}
+
+int TorrentEngine::listenPort() const { return session_ ? session_->listen_port() : 0; }
+
+} // namespace torlinkc
diff --git a/src/engine/types.cpp b/src/engine/types.cpp
new file mode 100644
index 0000000..27c54c7
--- /dev/null
+++ b/src/engine/types.cpp
@@ -0,0 +1,49 @@
+#include "torlinkc/engine/types.hpp"
+
+namespace torlinkc {
+
+std::string downloadStatusToString(DownloadStatus s) {
+ switch (s) {
+ case DownloadStatus::Downloading:
+ return "downloading";
+ case DownloadStatus::Queued:
+ return "queued";
+ case DownloadStatus::Paused:
+ return "paused";
+ case DownloadStatus::Completed:
+ return "completed";
+ case DownloadStatus::Failed:
+ return "failed";
+ }
+ return "downloading";
+}
+
+std::string seedStatusToString(SeedStatus s) {
+ switch (s) {
+ case SeedStatus::Seeding:
+ return "seeding";
+ case SeedStatus::Paused:
+ return "paused";
+ case SeedStatus::Missing:
+ return "missing";
+ }
+ return "seeding";
+}
+
+std::optional<DownloadStatus> downloadStatusFromString(const std::string& s) {
+ if (s == "downloading") return DownloadStatus::Downloading;
+ if (s == "queued") return DownloadStatus::Queued;
+ if (s == "paused") return DownloadStatus::Paused;
+ if (s == "completed") return DownloadStatus::Completed;
+ if (s == "failed") return DownloadStatus::Failed;
+ return std::nullopt;
+}
+
+std::optional<SeedStatus> seedStatusFromString(const std::string& s) {
+ if (s == "seeding") return SeedStatus::Seeding;
+ if (s == "paused") return SeedStatus::Paused;
+ if (s == "missing") return SeedStatus::Missing;
+ return std::nullopt;
+}
+
+} // namespace torlinkc
diff --git a/src/sources/cache.cpp b/src/sources/cache.cpp
new file mode 100644
index 0000000..27b0c2b
--- /dev/null
+++ b/src/sources/cache.cpp
@@ -0,0 +1,42 @@
+#include "torlinkc/sources/cache.hpp"
+
+#include <algorithm>
+#include <cctype>
+
+namespace torlinkc {
+
+namespace {
+
+constexpr std::chrono::minutes kTtl{5};
+
+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::string cacheKey(const std::string& sourceId, const std::string& query) {
+ return sourceId + "::" + toLower(trim(query));
+}
+
+} // namespace
+
+std::vector<TorrentResult> SearchCache::cachedSearch(const Source& source, const std::string& query,
+ const SearchOptions& opts) {
+ const std::string key = cacheKey(source.id, query);
+ auto it = cache_.find(key);
+ const auto now = std::chrono::steady_clock::now();
+ if (it != cache_.end() && now - it->second.at < kTtl) return it->second.results;
+
+ std::vector<TorrentResult> results = source.search(query, opts);
+ cache_[key] = Entry{now, results};
+ return results;
+}
+
+} // namespace torlinkc
diff --git a/src/sources/magnet.cpp b/src/sources/magnet.cpp
new file mode 100644
index 0000000..8b1309d
--- /dev/null
+++ b/src/sources/magnet.cpp
@@ -0,0 +1,194 @@
+#include "torlinkc/sources/magnet.hpp"
+
+#include <algorithm>
+#include <array>
+#include <cctype>
+#include <cstring>
+#include <regex>
+#include <unordered_set>
+
+namespace torlinkc {
+
+namespace {
+
+// CTAD deduces both element type and size from the initializer list, so a
+// miscounted array-size template argument (previously 12 for an 11-entry
+// list, leaving a null-pointer 12th element that crashed on first use) can't
+// happen again.
+constexpr std::array kTrackers = {
+ "udp://tracker.opentrackr.org:1337/announce",
+ "udp://open.demonii.com:1337/announce",
+ "udp://tracker.openbittorrent.com:6969/announce",
+ "udp://tracker.torrent.eu.org:451/announce",
+ "udp://exodus.desync.com:6969/announce",
+ "udp://open.stealth.si:80/announce",
+ "udp://tracker.dler.org:6969/announce",
+ // HTTP(S) endpoints so peer discovery still works where UDP is blocked or
+ // mangled (VPN exit nodes, strict NATs): DHT and udp:// are both UDP.
+ "http://tracker.opentrackr.org:1337/announce",
+ "http://tracker.openbittorrent.com:80/announce",
+ "http://tracker.dler.org:6969/announce",
+ "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 "";
+ 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;
+}
+
+const char kBase32Alphabet[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
+
+std::optional<std::string> base32ToHex(const std::string& b32) {
+ int bits = 0;
+ unsigned long value = 0;
+ std::string out;
+ for (unsigned char raw : b32) {
+ char c = static_cast<char>(std::toupper(raw));
+ const char* pos = std::strchr(kBase32Alphabet, c);
+ if (!pos || c == '\0') return std::nullopt;
+ int idx = static_cast<int>(pos - kBase32Alphabet);
+ value = (value << 5) | static_cast<unsigned long>(idx);
+ bits += 5;
+ if (bits >= 8) {
+ bits -= 8;
+ unsigned int byte = (value >> bits) & 0xff;
+ static const char* hex = "0123456789abcdef";
+ out += hex[byte >> 4];
+ out += hex[byte & 0xF];
+ value &= (1u << bits) - 1;
+ }
+ }
+ return out.size() == 40 ? std::optional<std::string>(out) : std::nullopt;
+}
+
+// Extracts the `dn` query parameter the way `new URL(s).searchParams.get("dn")`
+// would, without pulling in a full URL parser: split the query string on '&',
+// find the first `dn=` pair, decode it.
+std::optional<std::string> extractDn(const std::string& magnet) {
+ auto q = magnet.find('?');
+ if (q == std::string::npos) return std::nullopt;
+ std::string query = magnet.substr(q + 1);
+ std::size_t pos = 0;
+ while (pos <= query.size()) {
+ auto amp = query.find('&', pos);
+ std::string pair = query.substr(pos, amp == std::string::npos ? std::string::npos : amp - pos);
+ auto eq = pair.find('=');
+ if (eq != std::string::npos && pair.substr(0, eq) == "dn") {
+ return decodeURIComponent(pair.substr(eq + 1));
+ }
+ if (amp == std::string::npos) break;
+ pos = amp + 1;
+ }
+ return std::nullopt;
+}
+
+} // namespace
+
+std::string buildMagnet(const std::string& infoHash, const std::string& name,
+ const std::vector<std::string>& extraTrackers) {
+ const std::string dn = encodeURIComponent(name);
+
+ std::unordered_set<std::string> seen;
+ std::vector<std::string> trackers;
+ auto addTracker = [&](const std::string& raw) {
+ const std::string url = trim(raw);
+ if (url.empty() || seen.count(url)) return;
+ seen.insert(url);
+ trackers.push_back(url);
+ };
+ for (const auto& t : extraTrackers) addTracker(t);
+ for (const char* t : kTrackers) addTracker(t);
+
+ std::string tr;
+ for (const auto& t : trackers) tr += "&tr=" + encodeURIComponent(t);
+
+ return "magnet:?xt=urn:btih:" + infoHash + "&dn=" + dn + tr;
+}
+
+std::string normalizeInfoHash(const std::string& raw) {
+ if (raw.size() == 32) {
+ if (auto hex = base32ToHex(raw)) return *hex;
+ return toLower(raw);
+ }
+ return toLower(raw);
+}
+
+std::optional<ParsedMagnet> parseMagnet(const std::string& input) {
+ const std::string s = trim(input);
+ static const std::regex kMagnetPrefix(R"(^magnet:\?)", std::regex::icase);
+ if (!std::regex_search(s, kMagnetPrefix)) return std::nullopt;
+
+ static const std::regex kMagnetRe(R"(xt=urn:btih:([a-f0-9]{40}|[a-z2-7]{32}))", std::regex::icase);
+ std::smatch m;
+ if (!std::regex_search(s, m, kMagnetRe)) return std::nullopt;
+
+ const std::string infoHash = normalizeInfoHash(m[1].str());
+ std::string name = infoHash;
+ if (auto dn = extractDn(s); dn && !dn->empty()) name = *dn;
+
+ return ParsedMagnet{infoHash, name, s};
+}
+
+bool isInfoHash(const std::string& input) {
+ const std::string s = trim(input);
+ static const std::regex kInfoHashRe(R"(^([a-f0-9]{40}|[a-z2-7]{32})$)", std::regex::icase);
+ return std::regex_match(s, kInfoHashRe);
+}
+
+std::optional<ParsedMagnet> parseInput(const std::string& input) {
+ const std::string s = trim(input);
+ if (auto magnet = parseMagnet(s)) return magnet;
+ if (!isInfoHash(s)) return std::nullopt;
+ const std::string infoHash = normalizeInfoHash(s);
+ return ParsedMagnet{infoHash, infoHash, buildMagnet(infoHash, infoHash)};
+}
+
+} // namespace torlinkc
diff --git a/src/sources/piratebay.cpp b/src/sources/piratebay.cpp
new file mode 100644
index 0000000..07d41d0
--- /dev/null
+++ b/src/sources/piratebay.cpp
@@ -0,0 +1,172 @@
+#include "torlinkc/sources/piratebay.hpp"
+
+#include <cctype>
+#include <cstdlib>
+#include <string>
+#include <unordered_set>
+
+#include <nlohmann/json.hpp>
+
+#include "torlinkc/sources/magnet.hpp"
+#include "torlinkc/util/net.hpp"
+
+using nlohmann::json;
+
+namespace torlinkc {
+
+namespace {
+
+const char* kApi = "https://apibay.org";
+
+const std::unordered_set<int> kMovieCats = {201, 202, 207, 209};
+const std::unordered_set<int> kTvCats = {205, 208};
+
+const std::string kTopMovies = std::string(kApi) + "/precompiled/data_top100_207.json";
+const std::string kTopTv = std::string(kApi) + "/precompiled/data_top100_208.json";
+
+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 "";
+ 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;
+}
+
+// apibay ships every field as a JSON string, even numeric ones. Mirrors JS's
+// `Number(x) || 0`: missing/unparseable -> 0.
+std::int64_t fieldAsInt64(const json& item, const char* key) {
+ auto it = item.find(key);
+ if (it == item.end() || !it->is_string()) return 0;
+ try {
+ return std::stoll(it->get<std::string>());
+ } catch (...) {
+ return 0;
+ }
+}
+
+std::string fieldAsString(const json& item, const char* key) {
+ auto it = item.find(key);
+ return it != item.end() && it->is_string() ? it->get<std::string>() : "";
+}
+
+std::optional<TorrentResult> toResult(const json& item, const std::string& sourceId) {
+ const std::string infoHash = toLower(fieldAsString(item, "info_hash"));
+ if (infoHash.empty() || infoHash == kZeroHash || fieldAsString(item, "id") == "0") return std::nullopt;
+
+ const std::string name = fieldAsString(item, "name").empty() ? "Unknown" : fieldAsString(item, "name");
+ const std::int64_t numFiles = fieldAsInt64(item, "num_files");
+
+ TorrentResult r;
+ r.infoHash = infoHash;
+ r.name = name;
+ r.sizeBytes = fieldAsInt64(item, "size");
+ r.seeders = static_cast<int>(fieldAsInt64(item, "seeders"));
+ r.leechers = static_cast<int>(fieldAsInt64(item, "leechers"));
+ if (numFiles > 0) r.numFiles = static_cast<int>(numFiles);
+ r.source = sourceId;
+ r.magnet = buildMagnet(infoHash, name);
+ const std::int64_t added = fieldAsInt64(item, "added");
+ if (added > 0) r.added = added;
+ return r;
+}
+
+json fetchItems(const std::string& url, int retries = 1) {
+ FetchOptions opts;
+ opts.retries = retries;
+ 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);
+ return parsed.is_array() ? parsed : json::array();
+}
+
+// apibay answers an empty search with one placeholder row instead of [].
+bool isNoResultsSentinel(const json& items) {
+ return items.size() == 1 && fieldAsString(items[0], "id") == "0";
+}
+
+// apibay caches search results per exact URL, and a query can be stuck with a
+// 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));
+ if (!isNoResultsSentinel(items)) return items;
+ return fetchItems(std::string(kApi) + "/q.php?q=" + encodeURIComponent(q) + "&cat=0");
+}
+
+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 q = trim(query);
+ const json items = q.empty() ? fetchItems(browseUrl) : searchItems(q);
+
+ std::vector<TorrentResult> out;
+ for (const auto& item : items) {
+ if (!q.empty()) {
+ int category = 0;
+ try {
+ category = std::stoi(fieldAsString(item, "category"));
+ } catch (...) {
+ }
+ if (!cats.count(category)) continue;
+ }
+ if (auto r = toResult(item, sourceId)) out.push_back(std::move(*r));
+ }
+ return out;
+}
+
+} // namespace
+
+Source tpbMoviesSource() {
+ Source s;
+ s.id = "tpb-movies";
+ s.label = "TPB";
+ 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");
+ };
+ return s;
+}
+
+Source tpbTvSource() {
+ Source s;
+ s.id = "tpb-tv";
+ s.label = "TPB";
+ 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");
+ };
+ return s;
+}
+
+} // namespace torlinkc
diff --git a/src/util/atomic_write.cpp b/src/util/atomic_write.cpp
new file mode 100644
index 0000000..3f4efc1
--- /dev/null
+++ b/src/util/atomic_write.cpp
@@ -0,0 +1,31 @@
+#include "torlinkc/util/atomic_write.hpp"
+
+#include <cstdio>
+#include <filesystem>
+#include <fstream>
+#include <stdexcept>
+
+namespace fs = std::filesystem;
+
+namespace torlinkc {
+
+void writeJsonAtomic(const std::string& file, const nlohmann::json& data) {
+ fs::path path(file);
+ fs::create_directories(path.parent_path());
+
+ fs::path tmp = path;
+ tmp += ".tmp";
+
+ {
+ std::ofstream out(tmp, std::ios::binary | std::ios::trunc);
+ if (!out) throw std::runtime_error("failed to open " + tmp.string() + " for writing");
+ out << data.dump(2);
+ if (!out) throw std::runtime_error("failed to write " + tmp.string());
+ }
+
+ std::error_code ec;
+ fs::rename(tmp, path, ec);
+ if (ec) throw std::runtime_error("failed to rename " + tmp.string() + " to " + path.string() + ": " + ec.message());
+}
+
+} // namespace torlinkc
diff --git a/src/util/net.cpp b/src/util/net.cpp
new file mode 100644
index 0000000..09aba42
--- /dev/null
+++ b/src/util/net.cpp
@@ -0,0 +1,133 @@
+#include "torlinkc/util/net.hpp"
+
+#include <algorithm>
+#include <cctype>
+#include <chrono>
+#include <cmath>
+#include <cstdlib>
+#include <ctime>
+#include <thread>
+#include <unordered_set>
+
+#include <curl/curl.h>
+
+namespace torlinkc {
+
+namespace {
+
+const std::unordered_set<int> kRetryStatus = {408, 425, 429, 500, 502, 503, 504};
+
+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);
+}
+
+bool isAllDigits(const std::string& s) {
+ return !s.empty() && std::all_of(s.begin(), s.end(), [](unsigned char c) { return std::isdigit(c) != 0; });
+}
+
+std::size_t writeBody(char* ptr, std::size_t size, std::size_t nmemb, void* userdata) {
+ auto* out = static_cast<std::string*>(userdata);
+ out->append(ptr, size * nmemb);
+ return size * nmemb;
+}
+
+std::size_t writeHeader(char* buffer, std::size_t size, std::size_t nitems, void* userdata) {
+ auto* headers = static_cast<std::map<std::string, std::string>*>(userdata);
+ std::string line(buffer, size * nitems);
+ auto colon = line.find(':');
+ if (colon != std::string::npos) {
+ std::string name = toLower(trim(line.substr(0, colon)));
+ std::string value = trim(line.substr(colon + 1));
+ (*headers)[name] = value;
+ }
+ return size * nitems;
+}
+
+void sleepMs(std::int64_t ms) {
+ if (ms > 0) std::this_thread::sleep_for(std::chrono::milliseconds(ms));
+}
+
+} // namespace
+
+std::optional<std::int64_t> parseRetryAfter(const std::string& value) {
+ if (value.empty()) return std::nullopt;
+ const std::string trimmed = trim(value);
+ if (isAllDigits(trimmed)) return std::stoll(trimmed) * 1000;
+ // curl_getdate parses RFC 1123 / 850 / asctime HTTP-date formats.
+ time_t date = curl_getdate(trimmed.c_str(), nullptr);
+ if (date == -1) return std::nullopt;
+ const std::int64_t nowMs =
+ std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now().time_since_epoch())
+ .count();
+ return std::max<std::int64_t>(0, static_cast<std::int64_t>(date) * 1000 - nowMs);
+}
+
+std::int64_t backoffDelay(int attempt, int baseMs, int capMs, std::optional<std::int64_t> retryAfterMs) {
+ const double exp = std::min<double>(capMs, static_cast<double>(baseMs) * std::pow(2.0, attempt));
+ const double jittered = std::floor(static_cast<double>(std::rand()) / RAND_MAX * exp);
+ if (retryAfterMs) return std::max<std::int64_t>(static_cast<std::int64_t>(jittered), *retryAfterMs);
+ return static_cast<std::int64_t>(jittered);
+}
+
+HttpResponse fetchResilient(const std::string& url, const FetchOptions& opts) {
+ for (int attempt = 0; attempt <= opts.retries; ++attempt) {
+ CURL* curl = curl_easy_init();
+ if (!curl) throw HttpError(0, "failed to initialize libcurl");
+
+ 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);
+ 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);
+
+ CURLcode code = curl_easy_perform(curl);
+ if (code != CURLE_OK) {
+ curl_easy_cleanup(curl);
+ if (attempt < opts.retries) {
+ sleepMs(backoffDelay(attempt, opts.baseMs, opts.capMs));
+ continue;
+ }
+ throw HttpError(0, std::string("request to ") + url + " failed: " + errbuf);
+ }
+
+ long status = 0;
+ curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
+ res.status = static_cast<int>(status);
+ curl_easy_cleanup(curl);
+
+ if (!kRetryStatus.count(res.status)) return res;
+
+ const std::string server = toLower(res.header("server").value_or(""));
+ if (res.status == 503 && (server.find("ddos-guard") != std::string::npos ||
+ server.find("cloudflare") != std::string::npos)) {
+ throw HttpError(res.status, "request to " + url + " blocked by " + server + " (HTTP " +
+ std::to_string(res.status) + ")");
+ }
+
+ if (attempt >= opts.retries) {
+ throw HttpError(res.status, "request to " + url + " failed after " + std::to_string(opts.retries) +
+ " retries (HTTP " + std::to_string(res.status) + ")");
+ }
+
+ auto retryAfterMs = res.header("retry-after") ? parseRetryAfter(*res.header("retry-after")) : std::nullopt;
+ sleepMs(backoffDelay(attempt, opts.baseMs, opts.capMs, retryAfterMs));
+ }
+
+ throw HttpError(0, "fetchResilient exhausted without a response");
+}
+
+} // namespace torlinkc
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
new file mode 100644
index 0000000..c8b2762
--- /dev/null
+++ b/tests/CMakeLists.txt
@@ -0,0 +1,10 @@
+add_executable(torlinkc_tests
+ test_main.cpp
+ test_magnet.cpp
+ test_net.cpp
+ test_persist.cpp
+ test_queue.cpp
+ test_reconcile.cpp
+)
+target_link_libraries(torlinkc_tests PRIVATE torlinkc_core)
+add_test(NAME torlinkc_tests COMMAND torlinkc_tests)
diff --git a/tests/test_magnet.cpp b/tests/test_magnet.cpp
new file mode 100644
index 0000000..5152a6c
--- /dev/null
+++ b/tests/test_magnet.cpp
@@ -0,0 +1,128 @@
+#include <doctest/doctest.h>
+
+#include "torlinkc/sources/magnet.hpp"
+
+using namespace torlinkc;
+
+TEST_CASE("parseMagnet keeps a full 40-char hex info hash") {
+ const std::string hash = "abcdef0123456789abcdef0123456789abcdef01";
+ auto m = parseMagnet("magnet:?xt=urn:btih:" + hash + "&dn=Cool+Movie");
+ REQUIRE(m.has_value());
+ CHECK(m->infoHash == hash);
+ CHECK(m->infoHash.size() == 40);
+ CHECK(m->name == "Cool Movie");
+}
+
+TEST_CASE("parseMagnet decodes a 32-char base32 hash to 40-char hex") {
+ auto m = parseMagnet("magnet:?xt=urn:btih:MFRGGZDFMZTWQ2LKNNWG23TPOBYXE43U&dn=X");
+ REQUIRE(m.has_value());
+ CHECK(m->infoHash.size() == 40);
+ for (char c : m->infoHash) CHECK(((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')));
+}
+
+TEST_CASE("parseMagnet falls back to the hash as the name when dn is absent") {
+ const std::string hash = "abcdef0123456789abcdef0123456789abcdef01";
+ auto m = parseMagnet("magnet:?xt=urn:btih:" + hash);
+ REQUIRE(m.has_value());
+ CHECK(m->name == hash);
+}
+
+TEST_CASE("parseMagnet returns nullopt for non-magnets and malformed hashes") {
+ CHECK_FALSE(parseMagnet("not a magnet").has_value());
+ CHECK_FALSE(parseMagnet("magnet:?xt=urn:btih:tooshort").has_value());
+ CHECK_FALSE(parseMagnet("prefix magnet:?xt=urn:btih:" + std::string(40, 'a')).has_value());
+}
+
+TEST_CASE("normalizeInfoHash lowercases 40-char hex") {
+ CHECK(normalizeInfoHash("ABCDEF0123456789ABCDEF0123456789ABCDEF01") ==
+ "abcdef0123456789abcdef0123456789abcdef01");
+}
+
+TEST_CASE("normalizeInfoHash decodes 32-char base32 to 40-char hex") {
+ auto hex = normalizeInfoHash("MFRGGZDFMZTWQ2LKNNWG23TPOBYXE43U");
+ CHECK(hex.size() == 40);
+}
+
+TEST_CASE("buildMagnet builds a magnet with encoded name and trackers") {
+ auto out = buildMagnet("abc123", "My Movie 2024");
+ CHECK(out.find("xt=urn:btih:abc123") != std::string::npos);
+ CHECK(out.find("dn=My%20Movie%202024") != std::string::npos);
+ CHECK(out.find("&tr=") != std::string::npos);
+ // At least one non-UDP tracker, so UDP-blocked networks can still announce.
+ CHECK(out.find("http%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce") != std::string::npos);
+}
+
+TEST_CASE("buildMagnet puts extra trackers ahead of the public defaults") {
+ const std::string own = "http://private.example.org/announce?pk=xyz";
+ auto out = buildMagnet("abc123", "Thing", {own});
+ auto minePos = out.find("&tr=http%3A%2F%2Fprivate.example.org%2Fannounce%3Fpk%3Dxyz");
+ auto defaultPos = out.find("udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce");
+ REQUIRE(minePos != std::string::npos);
+ REQUIRE(defaultPos != std::string::npos);
+ CHECK(minePos < defaultPos);
+}
+
+TEST_CASE("buildMagnet keeps one copy of a tracker that is also a default, and drops blanks") {
+ const std::string dupe = "udp://tracker.opentrackr.org:1337/announce";
+ auto out = buildMagnet("abc123", "Thing", {dupe, " ", dupe});
+ const std::string needle = "&tr=udp%3A%2F%2Ftracker.opentrackr.org%3A1337%2Fannounce";
+ std::size_t count = 0;
+ std::size_t pos = 0;
+ while ((pos = out.find(needle, pos)) != std::string::npos) {
+ count++;
+ pos += needle.size();
+ }
+ CHECK(count == 1);
+}
+
+TEST_CASE("isInfoHash accepts a bare 40-char hex hash") { CHECK(isInfoHash(std::string(40, 'a'))); }
+
+TEST_CASE("isInfoHash accepts a bare 32-char base32 hash") {
+ CHECK(isInfoHash("MFRGGZDFMZTWQ2LKNNWG23TPOBYXE43U"));
+}
+
+TEST_CASE("isInfoHash rejects ordinary queries and malformed hashes") {
+ CHECK_FALSE(isInfoHash("the office 1080p"));
+ CHECK_FALSE(isInfoHash(std::string(40, 'g'))); // 40 chars but not hex
+ CHECK_FALSE(isInfoHash(std::string(39, 'a'))); // too short
+ CHECK_FALSE(isInfoHash(""));
+}
+
+TEST_CASE("parseInput parses a full magnet URI just like parseMagnet") {
+ const std::string hash = "abcdef0123456789abcdef0123456789abcdef01";
+ auto m = parseInput("magnet:?xt=urn:btih:" + hash + "&dn=Cool+Movie");
+ REQUIRE(m.has_value());
+ CHECK(m->infoHash == hash);
+ CHECK(m->name == "Cool Movie");
+}
+
+TEST_CASE("parseInput wraps a bare 40-char hex hash into a magnet with trackers") {
+ const std::string hash = "abcdef0123456789abcdef0123456789abcdef01";
+ auto m = parseInput(hash);
+ REQUIRE(m.has_value());
+ CHECK(m->infoHash == hash);
+ CHECK(m->name == hash);
+ CHECK(m->magnet.find("xt=urn:btih:" + hash) != std::string::npos);
+ CHECK(m->magnet.find("&tr=") != std::string::npos);
+}
+
+TEST_CASE("parseInput decodes a bare 32-char base32 hash to 40-char hex") {
+ auto m = parseInput("MFRGGZDFMZTWQ2LKNNWG23TPOBYXE43U");
+ REQUIRE(m.has_value());
+ CHECK(m->infoHash.size() == 40);
+ CHECK(m->magnet.find("xt=urn:btih:" + m->infoHash) != std::string::npos);
+}
+
+TEST_CASE("parseInput trims whitespace around a bare hash") {
+ const std::string hash = "abcdef0123456789abcdef0123456789abcdef01";
+ auto m = parseInput(" " + hash + " ");
+ REQUIRE(m.has_value());
+ CHECK(m->infoHash == hash);
+}
+
+TEST_CASE("parseInput returns nullopt for ordinary queries and junk") {
+ CHECK_FALSE(parseInput("the office 1080p").has_value());
+ CHECK_FALSE(parseInput(std::string(40, 'g')).has_value());
+ CHECK_FALSE(parseInput("magnet:?xt=urn:btih:tooshort").has_value());
+ CHECK_FALSE(parseInput("").has_value());
+}
diff --git a/tests/test_main.cpp b/tests/test_main.cpp
new file mode 100644
index 0000000..0a3f254
--- /dev/null
+++ b/tests/test_main.cpp
@@ -0,0 +1,2 @@
+#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
+#include <doctest/doctest.h>
diff --git a/tests/test_net.cpp b/tests/test_net.cpp
new file mode 100644
index 0000000..79ce4fa
--- /dev/null
+++ b/tests/test_net.cpp
@@ -0,0 +1,42 @@
+#include <doctest/doctest.h>
+
+#include "torlinkc/util/net.hpp"
+
+using namespace torlinkc;
+
+TEST_CASE("parseRetryAfter reads delay-seconds as milliseconds") {
+ auto ms = parseRetryAfter("120");
+ REQUIRE(ms.has_value());
+ CHECK(*ms == 120'000);
+}
+
+TEST_CASE("parseRetryAfter returns nullopt for an empty value") { CHECK_FALSE(parseRetryAfter("").has_value()); }
+
+TEST_CASE("parseRetryAfter parses an HTTP-date relative to now") {
+ // Far enough in the future that the test can't flake on execution time.
+ auto ms = parseRetryAfter("Wed, 01 Jan 2099 00:00:00 GMT");
+ REQUIRE(ms.has_value());
+ CHECK(*ms > 0);
+}
+
+TEST_CASE("parseRetryAfter returns nullopt for garbage") { CHECK_FALSE(parseRetryAfter("not-a-date").has_value()); }
+
+TEST_CASE("backoffDelay never exceeds the cap") {
+ for (int attempt = 0; attempt < 10; ++attempt) {
+ auto ms = backoffDelay(attempt, 500, 20000);
+ CHECK(ms >= 0);
+ CHECK(ms <= 20000);
+ }
+}
+
+TEST_CASE("backoffDelay honors an explicit Retry-After floor") {
+ auto ms = backoffDelay(0, 500, 20000, 15000);
+ CHECK(ms >= 15000);
+}
+
+TEST_CASE("backoffDelay grows with the attempt number (upper bound doubles each time)") {
+ // The jittered value is randomized, but the *ceiling* (baseMs * 2^attempt,
+ // capped) must grow monotonically until it saturates at capMs.
+ CHECK(500 * (1 << 0) < 500 * (1 << 1));
+ CHECK(500 * (1 << 1) < 500 * (1 << 2));
+}
diff --git a/tests/test_persist.cpp b/tests/test_persist.cpp
new file mode 100644
index 0000000..4020b53
--- /dev/null
+++ b/tests/test_persist.cpp
@@ -0,0 +1,108 @@
+#include <doctest/doctest.h>
+
+#include <filesystem>
+#include <fstream>
+
+#include "torlinkc/config/paths.hpp"
+#include "torlinkc/engine/persist.hpp"
+#include "test_support.hpp"
+
+using namespace torlinkc;
+using torlinkc::test::StateDirSandbox;
+namespace fs = std::filesystem;
+
+TEST_CASE("torrentExportName strips filesystem-hostile characters") {
+ CHECK(torrentExportName("My/Movie:2024", "id1") == "My Movie 2024.torrent");
+}
+
+TEST_CASE("torrentExportName collapses whitespace and trims trailing dots/spaces") {
+ CHECK(torrentExportName("Weird Name... ", "id1") == "Weird Name.torrent");
+}
+
+TEST_CASE("torrentExportName falls back to the id when the name is empty after sanitizing") {
+ CHECK(torrentExportName(" ", "abc123") == "abc123.torrent");
+}
+
+TEST_CASE("torrentExportName caps the length at 180 characters") {
+ const std::string longName(300, 'x');
+ auto out = torrentExportName(longName, "id1");
+ // 180 chars of name + ".torrent"
+ CHECK(out.size() == 180 + std::string(".torrent").size());
+}
+
+TEST_CASE("saveQueue/loadQueue round-trips through an atomic write") {
+ StateDirSandbox sandbox;
+
+ QueueItem it;
+ it.id = "abc";
+ it.name = "Test Torrent";
+ it.magnet = "magnet:?xt=urn:btih:" + std::string(40, 'a');
+ it.dir = "/tmp/downloads";
+ it.status = DownloadStatus::Paused;
+ it.progress = 42;
+ it.totalBytes = 1000;
+ it.downloadedBytes = 420;
+ it.eta = 12.5;
+ it.error = "boom";
+ it.addedAt = 1234;
+
+ saveQueue({it});
+ auto loaded = loadQueue();
+ REQUIRE(loaded.size() == 1);
+ CHECK(loaded[0].id == "abc");
+ CHECK(loaded[0].name == "Test Torrent");
+ CHECK(loaded[0].status == DownloadStatus::Paused);
+ CHECK(loaded[0].progress == 42);
+ CHECK(loaded[0].totalBytes == 1000);
+ CHECK(loaded[0].downloadedBytes == 420);
+ REQUIRE(loaded[0].eta.has_value());
+ CHECK(*loaded[0].eta == doctest::Approx(12.5));
+ REQUIRE(loaded[0].error.has_value());
+ CHECK(*loaded[0].error == "boom");
+ CHECK(loaded[0].addedAt == 1234);
+}
+
+TEST_CASE("loadQueue returns an empty vector when no file exists") {
+ StateDirSandbox sandbox;
+ CHECK(loadQueue().empty());
+}
+
+TEST_CASE("saveSeeds/loadSeeds round-trips") {
+ StateDirSandbox sandbox;
+ saveSeeds({SeedRecord{"s1", SeedStatus::Seeding}, SeedRecord{"s2", SeedStatus::Paused}});
+ auto loaded = loadSeeds();
+ REQUIRE(loaded.size() == 2);
+ CHECK(loaded[0].id == "s1");
+ CHECK(loaded[0].status == SeedStatus::Seeding);
+ CHECK(loaded[1].id == "s2");
+ CHECK(loaded[1].status == SeedStatus::Paused);
+}
+
+TEST_CASE("loadSeeds accepts the legacy bare-id-array format as all-seeding") {
+ StateDirSandbox sandbox;
+ // Write the legacy format directly, matching what an old torlink install
+ // could have left on disk.
+ fs::create_directories(fs::path(paths::seedsFile()).parent_path());
+ std::ofstream(paths::seedsFile()) << R"(["legacy1","legacy2"])";
+ auto loaded = loadSeeds();
+ REQUIRE(loaded.size() == 2);
+ CHECK(loaded[0].status == SeedStatus::Seeding);
+ CHECK(loaded[1].status == SeedStatus::Seeding);
+}
+
+TEST_CASE("torrent metadata cache: save, exists, export, delete") {
+ StateDirSandbox sandbox;
+ CHECK_FALSE(torrentMetaExists("t1"));
+
+ saveTorrentMeta("t1", std::string("fake bencode bytes"));
+ CHECK(torrentMetaExists("t1"));
+
+ fs::path exportDir = fs::temp_directory_path() / "torlinkc-test-export";
+ auto exported = exportTorrentMeta("t1", "My Torrent", exportDir.string());
+ REQUIRE(exported.has_value());
+ CHECK(fs::exists(*exported));
+ fs::remove_all(exportDir);
+
+ deleteTorrentMeta("t1");
+ CHECK_FALSE(torrentMetaExists("t1"));
+}
diff --git a/tests/test_queue.cpp b/tests/test_queue.cpp
new file mode 100644
index 0000000..fd52093
--- /dev/null
+++ b/tests/test_queue.cpp
@@ -0,0 +1,176 @@
+#include <doctest/doctest.h>
+
+#include <map>
+
+#include "torlinkc/engine/bootguard.hpp"
+#include "torlinkc/engine/queue.hpp"
+#include "test_support.hpp"
+
+using namespace torlinkc;
+using torlinkc::test::StateDirSandbox;
+
+namespace {
+
+// A 40-hex-char-valid id/magnet pair libtorrent's magnet parser accepts, so
+// engine_.add() succeeds and these stay in the state the queue's own
+// bookkeeping put them in, rather than flipping to "failed" on a parse error.
+AddInput mk(int n) {
+ char buf[41];
+ std::snprintf(buf, sizeof(buf), "%040d", n);
+ std::string id(buf);
+ AddInput in;
+ in.id = id;
+ in.name = "T" + std::to_string(n);
+ in.magnet = "magnet:?xt=urn:btih:" + id;
+ return in;
+}
+
+std::map<std::string, DownloadStatus> statuses(const DownloadQueue& q) {
+ std::map<std::string, DownloadStatus> out;
+ for (const auto& it : q.getItems()) out[it.id] = it.status;
+ return out;
+}
+
+QueueItem persistedItem(int n, DownloadStatus status, std::int64_t addedAt) {
+ auto input = mk(n);
+ QueueItem it;
+ it.id = input.id;
+ it.name = input.name;
+ it.magnet = input.magnet;
+ it.dir = "/d";
+ it.status = status;
+ it.addedAt = addedAt;
+ return it;
+}
+
+} // namespace
+
+TEST_CASE("DownloadQueue concurrency cap: queues beyond the cap, promotes oldest queued on pause") {
+ StateDirSandbox sandbox;
+ DownloadQueue q(2);
+ q.add(mk(1), "/d");
+ q.add(mk(2), "/d");
+ q.add(mk(3), "/d");
+
+ CHECK(q.activeCount() == 2);
+ auto s = statuses(q);
+ CHECK(s[mk(1).id] == DownloadStatus::Downloading);
+ CHECK(s[mk(2).id] == DownloadStatus::Downloading);
+ CHECK(s[mk(3).id] == DownloadStatus::Queued);
+
+ // Pausing an active download frees a slot -> the queued one starts.
+ q.pause(mk(1).id);
+ CHECK(q.activeCount() == 2);
+ s = statuses(q);
+ CHECK(s[mk(1).id] == DownloadStatus::Paused);
+ CHECK(s[mk(3).id] == DownloadStatus::Downloading);
+}
+
+TEST_CASE("DownloadQueue defaults to unlimited (maxDownloads 0): everything downloads at once") {
+ StateDirSandbox sandbox;
+ DownloadQueue q(0);
+ q.add(mk(1), "/d");
+ q.add(mk(2), "/d");
+ q.add(mk(3), "/d");
+ CHECK(q.activeCount() == 3);
+ for (const auto& [id, status] : statuses(q)) CHECK(status == DownloadStatus::Downloading);
+}
+
+TEST_CASE("DownloadQueue respects the cap when restoring persisted downloads on boot") {
+ StateDirSandbox sandbox;
+ std::vector<QueueItem> persisted = {
+ persistedItem(1, DownloadStatus::Downloading, 1),
+ persistedItem(2, DownloadStatus::Downloading, 2),
+ persistedItem(3, DownloadStatus::Downloading, 3),
+ };
+ DownloadQueue q(2);
+ q.restore(persisted);
+ CHECK(q.activeCount() == 2);
+ CHECK(statuses(q)[mk(3).id] == DownloadStatus::Queued);
+}
+
+TEST_CASE("DownloadQueue starts persisted queued items on restore when the cap is unset") {
+ StateDirSandbox sandbox;
+ std::vector<QueueItem> persisted = {
+ persistedItem(1, DownloadStatus::Downloading, 1),
+ persistedItem(2, DownloadStatus::Queued, 2),
+ persistedItem(3, DownloadStatus::Queued, 3),
+ };
+ DownloadQueue q(0);
+ q.restore(persisted);
+ CHECK(q.activeCount() == 3);
+ for (const auto& [id, status] : statuses(q)) CHECK(status == DownloadStatus::Downloading);
+}
+
+TEST_CASE("DownloadQueue safe-mode restore brings active and queued items back paused") {
+ StateDirSandbox sandbox;
+ DownloadQueue q;
+ std::vector<QueueItem> persisted = {
+ persistedItem(1, DownloadStatus::Downloading, 1),
+ persistedItem(2, DownloadStatus::Queued, 2),
+ persistedItem(3, DownloadStatus::Failed, 3),
+ persistedItem(4, DownloadStatus::Paused, 4),
+ };
+ q.restore(persisted, RestoreOptions{true});
+
+ auto s = statuses(q);
+ CHECK(s[mk(1).id] == DownloadStatus::Paused);
+ CHECK(s[mk(2).id] == DownloadStatus::Paused);
+ CHECK(s[mk(3).id] == DownloadStatus::Failed);
+ CHECK(s[mk(4).id] == DownloadStatus::Paused);
+}
+
+TEST_CASE("DownloadQueue safe-mode restoreSeeds brings persisted seeders back paused") {
+ StateDirSandbox sandbox;
+ DownloadQueue q;
+ HistoryItem h1;
+ h1.id = "s1";
+ h1.name = "Seed One";
+ h1.magnet = "magnet:?xt=urn:btih:" + std::string(40, '1');
+ h1.dir = "/d";
+ HistoryItem h2 = h1;
+ h2.id = "s2";
+ h2.name = "Seed Two";
+ h2.magnet = "magnet:?xt=urn:btih:" + std::string(40, '2');
+
+ q.restoreHistory({h1, h2});
+ q.restoreSeeds({SeedRecord{"s1", SeedStatus::Seeding}, SeedRecord{"s2", SeedStatus::Paused}}, RestoreOptions{true});
+
+ REQUIRE(q.getSeed("s1").has_value());
+ REQUIRE(q.getSeed("s2").has_value());
+ CHECK(q.getSeed("s1")->status == SeedStatus::Paused);
+ CHECK(q.getSeed("s2")->status == SeedStatus::Paused);
+ CHECK(q.seedingCount() == 0);
+}
+
+TEST_CASE("DownloadQueue::add on an existing non-failed item is a no-op") {
+ StateDirSandbox sandbox;
+ DownloadQueue q;
+ auto input = mk(1);
+ q.add(input, "/d");
+ q.pause(input.id);
+ REQUIRE(statuses(q)[input.id] == DownloadStatus::Paused);
+
+ // Re-adding a paused (not failed) item must not restart it.
+ q.add(input, "/d");
+ CHECK(statuses(q)[input.id] == DownloadStatus::Paused);
+}
+
+TEST_CASE("DownloadQueue::cancel removes an active download entirely") {
+ StateDirSandbox sandbox;
+ DownloadQueue q;
+ auto input = mk(1);
+ q.add(input, "/d");
+ REQUIRE(q.has(input.id));
+ q.cancel(input.id);
+ CHECK_FALSE(q.has(input.id));
+}
+
+TEST_CASE("DownloadQueue::persistSync disarms the boot marker") {
+ StateDirSandbox sandbox;
+ armBootMarker();
+ REQUIRE(wasBootInterrupted());
+ DownloadQueue q;
+ q.persistSync();
+ CHECK_FALSE(wasBootInterrupted());
+}
diff --git a/tests/test_reconcile.cpp b/tests/test_reconcile.cpp
new file mode 100644
index 0000000..94e6c24
--- /dev/null
+++ b/tests/test_reconcile.cpp
@@ -0,0 +1,64 @@
+#include <doctest/doctest.h>
+
+#include "torlinkc/engine/reconcile.hpp"
+#include "test_support.hpp"
+
+using namespace torlinkc;
+
+namespace {
+QueueItem item(std::string id, DownloadStatus status, std::int64_t addedAt = 1) {
+ QueueItem it;
+ it.id = std::move(id);
+ it.magnet = "magnet:?xt=urn:btih:" + std::string(40, '1');
+ it.status = status;
+ it.speed = 123;
+ it.peers = 7;
+ it.eta = 42.0;
+ it.addedAt = addedAt;
+ return it;
+}
+} // namespace
+
+TEST_CASE("reconcileQueue drops completed items") {
+ auto out = reconcileQueue({item("a", DownloadStatus::Completed)});
+ CHECK(out.empty());
+}
+
+TEST_CASE("reconcileQueue dedupes by id, keeping the first occurrence") {
+ auto a1 = item("a", DownloadStatus::Paused, 1);
+ auto a2 = item("a", DownloadStatus::Failed, 2);
+ auto out = reconcileQueue({a1, a2});
+ REQUIRE(out.size() == 1);
+ CHECK(out[0].status == DownloadStatus::Paused);
+}
+
+TEST_CASE("reconcileQueue preserves failed, paused, and queued status") {
+ auto out = reconcileQueue({
+ item("f", DownloadStatus::Failed),
+ item("p", DownloadStatus::Paused),
+ item("q", DownloadStatus::Queued),
+ });
+ REQUIRE(out.size() == 3);
+ CHECK(out[0].status == DownloadStatus::Failed);
+ CHECK(out[1].status == DownloadStatus::Paused);
+ CHECK(out[2].status == DownloadStatus::Queued);
+}
+
+TEST_CASE("reconcileQueue downgrades anything else to downloading") {
+ auto out = reconcileQueue({item("d", DownloadStatus::Downloading)});
+ REQUIRE(out.size() == 1);
+ CHECK(out[0].status == DownloadStatus::Downloading);
+}
+
+TEST_CASE("reconcileQueue zeroes live-only stats that never survive a restart") {
+ auto out = reconcileQueue({item("a", DownloadStatus::Paused)});
+ REQUIRE(out.size() == 1);
+ CHECK(out[0].speed == 0);
+ CHECK(out[0].peers == 0);
+ CHECK_FALSE(out[0].eta.has_value());
+}
+
+TEST_CASE("reconcileQueue skips entries with an empty id") {
+ auto out = reconcileQueue({item("", DownloadStatus::Paused)});
+ CHECK(out.empty());
+}
diff --git a/tests/test_support.hpp b/tests/test_support.hpp
new file mode 100644
index 0000000..b376df1
--- /dev/null
+++ b/tests/test_support.hpp
@@ -0,0 +1,56 @@
+#pragma once
+
+#include <cstdlib>
+#include <filesystem>
+#include <random>
+#include <string>
+
+#include <doctest/doctest.h>
+
+#include "torlinkc/engine/types.hpp"
+
+// doctest stringifies both sides of a failed CHECK(a == b); without this it
+// can't turn a DownloadStatus/SeedStatus into a doctest::String, so give it
+// the same names the app itself displays.
+namespace doctest {
+template <>
+struct StringMaker<torlinkc::DownloadStatus> {
+ static String convert(const torlinkc::DownloadStatus& value) {
+ return torlinkc::downloadStatusToString(value).c_str();
+ }
+};
+template <>
+struct StringMaker<torlinkc::SeedStatus> {
+ static String convert(const torlinkc::SeedStatus& value) { return torlinkc::seedStatusToString(value).c_str(); }
+};
+} // namespace doctest
+
+namespace torlinkc::test {
+
+// Points TORLINK_STATE_DIR at a fresh temp dir for the lifetime of the
+// object, so tests that exercise persistence never touch real user data
+// (mirrors the TS test suite's use of the same env var). Any DownloadQueue
+// or persist.* call implicitly writes to disk, so every test that touches
+// either must keep one of these alive.
+class StateDirSandbox {
+ public:
+ StateDirSandbox() {
+ std::random_device rd;
+ dir_ = std::filesystem::temp_directory_path() / ("torlinkc-test-" + std::to_string(rd()));
+ std::filesystem::create_directories(dir_);
+ setenv("TORLINK_STATE_DIR", dir_.c_str(), 1);
+ }
+ ~StateDirSandbox() {
+ unsetenv("TORLINK_STATE_DIR");
+ std::error_code ec;
+ std::filesystem::remove_all(dir_, ec);
+ }
+
+ StateDirSandbox(const StateDirSandbox&) = delete;
+ StateDirSandbox& operator=(const StateDirSandbox&) = delete;
+
+ private:
+ std::filesystem::path dir_;
+};
+
+} // namespace torlinkc::test