fix(torrent): use before/after diff to recover qBit info-hash

Live testing surfaced: every download attempt failed with
'Torrent client refused the URL', but qBit was actually accepting
the add request fine. The bug was in our hash-lookup strategy.

qBittorrent's /api/v2/torrents/add returns 200 'Ok.' regardless
of whether the URL was actually valid / accepted / registered.
The previous code then queried /torrents/info?category=soulsync
to find the just-added torrent — but qBit hadn't categorised
the new torrent yet on the first poll, AND a fresh install has
no 'soulsync' category configured, so the lookup returned empty
and the adapter reported failure for every working download.

New strategy:
- Snapshot every torrent hash qBit currently tracks BEFORE
  posting to /add.
- POST /add, accept its (uninformative) 200 OK.
- Poll the all-torrents list for up to 5 seconds, looking for a
  hash that wasn't present in the before-snapshot. First new
  hash wins.

The diff strategy works the same for /add with urls= (HTTP URL /
magnet) and /add with files= (raw .torrent upload), so both
paths now share the same _all_hashes + _poll_for_new_hash
helpers. Adds a warning log when qBit returns an unexpected
body and an error log when no new hash appears (so future
investigation has breadcrumbs).
This commit is contained in:
Broque Thomas 2026-05-20 18:11:30 -07:00
parent 478fd25dd6
commit e83b661471

View file

@ -164,28 +164,60 @@ class QBittorrentAdapter:
category: str,
save_path: Optional[str],
) -> Optional[str]:
data = {'urls': url_or_magnet, 'category': category or self._category}
cat = category or self._category
# Snapshot the current set of torrent hashes BEFORE adding —
# qBittorrent's /add endpoint returns 200 "Ok." regardless of
# whether the URL was actually accepted or registered, and
# category-filtered lookups race the add (qBit hasn't
# categorised the new torrent yet on the first poll). Diffing
# before / after is the only reliable way to recover the hash.
before = self._all_hashes()
if before is None:
return None
data = {'urls': url_or_magnet, 'category': cat}
if save_path or self._save_path:
data['savepath'] = save_path or self._save_path
resp = self._call('POST', '/api/v2/torrents/add', data=data)
if not resp or not resp.ok:
logger.warning("qBittorrent /torrents/add returned HTTP %s body=%r",
resp.status_code if resp else 'no-response',
(resp.text[:200] if resp else ''))
return None
# qBittorrent's /add endpoint returns 200 "Ok." on success but
# NOT the resulting info-hash. We have to look it up via
# /torrents/info filtered by category — the just-added torrent
# will be the newest entry in that category.
return self._lookup_latest_hash(category or self._category)
if resp.text and resp.text.strip() and resp.text.strip() != 'Ok.':
logger.warning("qBittorrent /torrents/add unexpected body: %r", resp.text[:200])
return None
new_hash = self._poll_for_new_hash(before)
if not new_hash:
logger.error("qBittorrent accepted the request but no new torrent appeared — "
"URL may have been rejected (bad magnet, unreachable HTTPS, "
"duplicate hash, etc.)")
return new_hash
def _lookup_latest_hash(self, category: str) -> Optional[str]:
resp = self._call('GET', '/api/v2/torrents/info', params={'category': category, 'sort': 'added_on', 'reverse': 'true', 'limit': 1})
def _all_hashes(self) -> Optional[set]:
"""Return the set of every torrent hash qBit currently tracks,
or None on lookup failure."""
resp = self._call('GET', '/api/v2/torrents/info')
if not resp or not resp.ok:
return None
try:
items = resp.json()
if items:
return items[0].get('hash')
return {item.get('hash') for item in resp.json() if item.get('hash')}
except Exception as e:
logger.error("qBittorrent /torrents/info parse failed: %s", e)
return None
def _poll_for_new_hash(self, before: set) -> Optional[str]:
"""Poll up to ~5s for a new torrent to appear (qBit takes a
moment to fetch the .torrent file from the URL and register
it). Returns the new hash, or None if nothing showed up."""
import time as _time
for _ in range(10):
_time.sleep(0.5)
current = self._all_hashes()
if current is None:
continue
new = current - before
if new:
return next(iter(new))
return None
async def add_torrent_file(
@ -205,14 +237,18 @@ class QBittorrentAdapter:
category: str,
save_path: Optional[str],
) -> Optional[str]:
data = {'category': category or self._category}
cat = category or self._category
before = self._all_hashes()
if before is None:
return None
data = {'category': cat}
if save_path or self._save_path:
data['savepath'] = save_path or self._save_path
files = {'torrents': ('soulsync.torrent', file_bytes, 'application/x-bittorrent')}
resp = self._call('POST', '/api/v2/torrents/add', data=data, files=files)
if not resp or not resp.ok:
return None
return self._lookup_latest_hash(category or self._category)
return self._poll_for_new_hash(before)
async def get_status(self, torrent_id: str) -> Optional[TorrentStatus]:
loop = asyncio.get_event_loop()