foxygit / Torlinkc Log in
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 = &it;
+    }
+    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