mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(server): resolve hybrid torrent hashes in qbittorrent
Preserve the stored v1 identity while resolving primary hashes through bounded, cached lookups for polling, retries, tracker checks, removal, and reconciliation. Cover alias handling and lookup failures with unit tests and verify completion, import scheduling, duplicate adoption, and removal against qBittorrent 5.2.3. Closes #1318
This commit is contained in:
+288
-6
@@ -1,3 +1,4 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import type { ResolvedClientConfig } from '../download-client-adapter';
|
||||
@@ -30,12 +31,12 @@ function response(body: string | object, init: { status?: number; setCookie?: st
|
||||
|
||||
function mockFetch() {
|
||||
const calls: Array<{ url: string; init: RequestInit }> = [];
|
||||
const handlers = new Map<string, () => Response>();
|
||||
const handlers = new Map<string, (url: URL) => Response>();
|
||||
const fetchMock = vi.fn((url: URL | string, init: RequestInit = {}) => {
|
||||
const href = url.toString();
|
||||
calls.push({ url: href, init });
|
||||
for (const [fragment, handler] of handlers) {
|
||||
if (href.includes(fragment)) return Promise.resolve(handler());
|
||||
if (href.includes(fragment)) return Promise.resolve(handler(new URL(href)));
|
||||
}
|
||||
return Promise.resolve(response('Ok.'));
|
||||
});
|
||||
@@ -46,8 +47,9 @@ function mockFetch() {
|
||||
describe('QbittorrentAdapter', () => {
|
||||
let adapter: QbittorrentAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
adapter = new QbittorrentAdapter();
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({ providers: [QbittorrentAdapter] }).compile();
|
||||
adapter = module.get(QbittorrentAdapter);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -72,7 +74,8 @@ describe('QbittorrentAdapter', () => {
|
||||
handlers.set('torrents/info', () => response([]));
|
||||
await adapter.status([INFO_HASH], config({ id: 2, baseUrl: 'http://127.0.0.1:8080/qbt/' }));
|
||||
|
||||
expect(calls.at(-1)?.url).toBe(`http://127.0.0.1:8080/qbt/api/v2/torrents/info?hashes=${INFO_HASH}`);
|
||||
expect(calls[1]?.url).toBe(`http://127.0.0.1:8080/qbt/api/v2/torrents/info?hashes=${INFO_HASH}`);
|
||||
expect(calls.at(-1)?.url).toBe('http://127.0.0.1:8080/qbt/api/v2/torrents/info?sort=hash&limit=1000&offset=0');
|
||||
});
|
||||
|
||||
it('is unchanged for a client mounted at the root', async () => {
|
||||
@@ -243,6 +246,284 @@ describe('QbittorrentAdapter', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('hybrid hash resolution', () => {
|
||||
const V2 = 'fcaca6db3eab479062f70e78a27f51164760108979eb66c91e05b55d3720e49c';
|
||||
const PRIMARY = V2.slice(0, 40);
|
||||
const hybrid = { hash: PRIMARY, infohash_v1: INFO_HASH, infohash_v2: V2, state: 'stalledUP', progress: 1 };
|
||||
|
||||
function hybridClient() {
|
||||
const mock = mockFetch();
|
||||
mock.handlers.set('/torrents/info', (url) => {
|
||||
const hashes = url.searchParams.get('hashes');
|
||||
return response(hashes === null || hashes.split('|').includes(PRIMARY) ? [hybrid] : []);
|
||||
});
|
||||
return mock;
|
||||
}
|
||||
|
||||
it.each([INFO_HASH, V2, PRIMARY])('reports completion under the requested identity %s', async (hash) => {
|
||||
hybridClient();
|
||||
await expect(adapter.status([hash.toUpperCase()], config())).resolves.toEqual([
|
||||
expect.objectContaining({ infoHash: hash, state: 'completed', progressPercent: 100 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches aliases even when a client returns them in the filtered response', async () => {
|
||||
const { calls, handlers } = mockFetch();
|
||||
handlers.set('/torrents/info', () => response([{ ...hybrid, hash: PRIMARY.toUpperCase(), infohash_v1: INFO_HASH.toUpperCase() }]));
|
||||
expect(await adapter.status([INFO_HASH], config())).toEqual([expect.objectContaining({ infoHash: INFO_HASH })]);
|
||||
expect(calls.filter((call) => call.url.includes('/torrents/info'))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not duplicate results when aliases, duplicate requests, or repeated rows overlap', async () => {
|
||||
const { handlers } = mockFetch();
|
||||
handlers.set('/torrents/info', () => response([hybrid, hybrid]));
|
||||
const statuses = await adapter.status([INFO_HASH, INFO_HASH.toUpperCase(), PRIMARY, V2], config());
|
||||
expect(statuses.map((status) => status.infoHash).sort()).toEqual([INFO_HASH, PRIMARY, V2].sort());
|
||||
});
|
||||
|
||||
it.each([V2.slice(0, 12), V2.slice(0, 39), V2.slice(0, 41), '0'.repeat(40)])(
|
||||
'never matches an arbitrary prefix or absent hash %s',
|
||||
async (hash) => {
|
||||
const { handlers } = mockFetch();
|
||||
handlers.set('/torrents/info', () => response([{ ...hybrid, infohash_v1: '0'.repeat(40) }]));
|
||||
expect(await adapter.status([hash], config())).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it('ignores malformed alias fields without discarding the valid primary identity', async () => {
|
||||
const { handlers } = mockFetch();
|
||||
handlers.set('/torrents/info', () => response([{ ...hybrid, infohash_v1: 123, infohash_v2: { hash: V2 } }]));
|
||||
expect(await adapter.status([PRIMARY, INFO_HASH], config())).toEqual([expect.objectContaining({ infoHash: PRIMARY })]);
|
||||
});
|
||||
|
||||
it('pages the fallback once for the batch and caches only verified primary mappings', async () => {
|
||||
const { calls, handlers } = mockFetch();
|
||||
const other = { hash: 'f'.repeat(40) };
|
||||
handlers.set('/torrents/info', (url) => {
|
||||
const hashes = url.searchParams.get('hashes');
|
||||
if (hashes !== null) return response(hashes === PRIMARY ? [hybrid] : []);
|
||||
expect(url.searchParams.get('category')).toBeNull();
|
||||
expect(url.searchParams.get('limit')).toBe('1000');
|
||||
return response(url.searchParams.get('offset') === '0' ? Array.from({ length: 1000 }, () => other) : [hybrid]);
|
||||
});
|
||||
expect(await adapter.status([INFO_HASH, V2], config())).toHaveLength(2);
|
||||
expect(calls.filter((call) => call.url.includes('/torrents/info'))).toHaveLength(3);
|
||||
calls.length = 0;
|
||||
expect(await adapter.status([INFO_HASH, V2], config())).toHaveLength(2);
|
||||
expect(calls.map((call) => call.url)).toEqual([`http://127.0.0.1:8080/api/v2/torrents/info?hashes=${PRIMARY}`]);
|
||||
});
|
||||
|
||||
it('keeps aliases scoped to a client and drops them when configuration changes', async () => {
|
||||
const { calls } = hybridClient();
|
||||
await adapter.status([INFO_HASH], config());
|
||||
calls.length = 0;
|
||||
await adapter.status([INFO_HASH], config({ id: 2 }));
|
||||
expect(calls.find((call) => call.url.includes('/torrents/info'))?.url).toContain(`hashes=${INFO_HASH}`);
|
||||
adapter.forget(1);
|
||||
calls.length = 0;
|
||||
await adapter.status([INFO_HASH], config());
|
||||
expect(calls.find((call) => call.url.includes('/torrents/info'))?.url).toContain(`hashes=${INFO_HASH}`);
|
||||
});
|
||||
|
||||
it('expires idle aliases and revalidates them', async () => {
|
||||
const { calls } = hybridClient();
|
||||
const now = vi.spyOn(Date, 'now');
|
||||
try {
|
||||
now.mockReturnValue(1_000_000);
|
||||
await adapter.status([INFO_HASH], config());
|
||||
now.mockReturnValue(1_000_000 + 30 * 60 * 1000);
|
||||
calls.length = 0;
|
||||
await adapter.status([INFO_HASH], config());
|
||||
expect(calls.find((call) => call.url.includes('/torrents/info'))?.url).toContain(`hashes=${INFO_HASH}`);
|
||||
} finally {
|
||||
now.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not trust a cached mapping if the response no longer contains the requested alias', async () => {
|
||||
const { handlers } = hybridClient();
|
||||
await adapter.status([INFO_HASH], config());
|
||||
handlers.set('/torrents/info', () => response([{ hash: PRIMARY, infohash_v1: 'f'.repeat(40) }]));
|
||||
expect(await adapter.status([INFO_HASH], config())).toEqual([]);
|
||||
});
|
||||
|
||||
it('rediscovers an alias when the primary changes', async () => {
|
||||
const { calls, handlers } = hybridClient();
|
||||
await adapter.status([INFO_HASH], config());
|
||||
handlers.set('/torrents/info', (url) => response(url.searchParams.has('hashes') ? [] : [{ ...hybrid, hash: INFO_HASH }]));
|
||||
expect(await adapter.status([INFO_HASH], config())).toEqual([expect.objectContaining({ infoHash: INFO_HASH, state: 'completed' })]);
|
||||
calls.length = 0;
|
||||
await adapter.status([INFO_HASH], config());
|
||||
expect(calls[0].url).toContain(`hashes=${INFO_HASH}`);
|
||||
});
|
||||
|
||||
it('propagates an incomplete lookup instead of declaring the torrent missing', async () => {
|
||||
const { handlers } = mockFetch();
|
||||
handlers.set('/torrents/info', (url) =>
|
||||
response(url.searchParams.has('hashes') ? [] : 'Unavailable', { status: url.searchParams.has('hashes') ? 200 : 503 }),
|
||||
);
|
||||
await expect(adapter.status([INFO_HASH], config())).rejects.toThrow('qBittorrent answered 503');
|
||||
});
|
||||
|
||||
it.each([{}, [null], 'not json'])('rejects malformed listings instead of declaring torrents missing: %j', async (payload) => {
|
||||
const { handlers } = mockFetch();
|
||||
handlers.set('/torrents/info', () => response(payload));
|
||||
await expect(adapter.status([INFO_HASH], config())).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('bounds an endless fallback and refuses to infer absence from a truncated search', async () => {
|
||||
const { calls, handlers } = mockFetch();
|
||||
handlers.set('/torrents/info', (url) =>
|
||||
response(url.searchParams.has('hashes') ? [] : Array.from({ length: 1000 }, () => ({ hash: 'f'.repeat(40) }))),
|
||||
);
|
||||
await expect(adapter.status([INFO_HASH], config())).rejects.toThrow('hash lookup exceeded its scan limit');
|
||||
expect(calls.filter((call) => call.url.includes('sort=hash'))).toHaveLength(100);
|
||||
});
|
||||
|
||||
it('keeps ordinary polling batched without an inventory lookup', async () => {
|
||||
const { calls, handlers } = mockFetch();
|
||||
const hashes = Array.from({ length: 205 }, (_, index) => (index + 1).toString(16).padStart(40, '0'));
|
||||
handlers.set('/torrents/info', (url) =>
|
||||
response(
|
||||
url.searchParams
|
||||
.get('hashes')!
|
||||
.split('|')
|
||||
.map((hash) => ({ hash })),
|
||||
),
|
||||
);
|
||||
expect(await adapter.status(hashes, config())).toHaveLength(205);
|
||||
const queries = calls.filter((call) => call.url.includes('/torrents/info')).map((call) => new URL(call.url));
|
||||
expect(queries.map((url) => url.searchParams.get('hashes')!.split('|').length)).toEqual([100, 100, 5]);
|
||||
});
|
||||
|
||||
it('makes no HTTP calls for an empty batch', async () => {
|
||||
const { calls } = mockFetch();
|
||||
expect(await adapter.status([], config())).toEqual([]);
|
||||
expect(calls).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([200, 409])('adopts an existing hybrid on an add rejection with HTTP %i', async (status) => {
|
||||
const { handlers } = hybridClient();
|
||||
handlers.set('/torrents/add', () => response(status === 409 ? 'Conflict' : 'Fails.', { status }));
|
||||
expect(await adapter.add({ infoHash: INFO_HASH, magnet: `magnet:?xt=urn:btih:${INFO_HASH}&xt=urn:btmh:1220${V2}` }, config())).toEqual({
|
||||
clientHash: INFO_HASH,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([200, 503])('preserves a 409 rejection when presence cannot be confirmed (lookup HTTP %i)', async (status) => {
|
||||
const { handlers } = mockFetch();
|
||||
handlers.set('/torrents/add', () => response('Conflict', { status: 409 }));
|
||||
handlers.set('/torrents/info', () => response([], { status }));
|
||||
await expect(adapter.add({ infoHash: INFO_HASH, magnet: `magnet:?xt=urn:btih:${INFO_HASH}` }, config())).rejects.toThrow(
|
||||
'qBittorrent answered 409 for /api/v2/torrents/add',
|
||||
);
|
||||
});
|
||||
|
||||
it('does not adopt after an unrelated server failure', async () => {
|
||||
const { calls, handlers } = hybridClient();
|
||||
handlers.set('/torrents/add', () => response('Unavailable', { status: 503 }));
|
||||
await expect(adapter.add({ infoHash: INFO_HASH, magnet: `magnet:?xt=urn:btih:${INFO_HASH}` }, config())).rejects.toThrow(
|
||||
'qBittorrent answered 503',
|
||||
);
|
||||
expect(calls.some((call) => call.url.includes('/torrents/info'))).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the primary hash to query trackers while keeping the stored identity in status', async () => {
|
||||
const { calls, handlers } = mockFetch();
|
||||
handlers.set('/torrents/info', () => response([{ ...hybrid, state: 'metaDL', downloaded: 0, progress: 0 }]));
|
||||
handlers.set('/torrents/trackers', () => response([{ url: 'https://tracker.example/announce', status: 4, msg: 'Denied' }]));
|
||||
expect(await adapter.status([INFO_HASH], config())).toEqual([expect.objectContaining({ infoHash: INFO_HASH, trackerError: 'Denied' })]);
|
||||
expect(calls.find((call) => call.url.includes('/torrents/trackers'))?.url).toContain(`hash=${PRIMARY}`);
|
||||
});
|
||||
|
||||
it.each([false, true])('removes by primary hash with deleteFiles=%s', async (deleteFiles) => {
|
||||
const { calls } = hybridClient();
|
||||
await adapter.remove(INFO_HASH.toUpperCase(), config(), { deleteFiles });
|
||||
const body = calls.find((call) => call.url.includes('/torrents/delete'))?.init.body as URLSearchParams;
|
||||
expect(body.get('hashes')).toBe(PRIMARY);
|
||||
expect(body.get('deleteFiles')).toBe(String(deleteFiles));
|
||||
});
|
||||
|
||||
it('does not delete anything when the requested torrent is absent', async () => {
|
||||
const { calls, handlers } = mockFetch();
|
||||
handlers.set('/torrents/info', () => response([]));
|
||||
await adapter.remove(INFO_HASH, config(), { deleteFiles: true });
|
||||
expect(calls.some((call) => call.url.includes('/torrents/delete'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not delete anything when alias lookup fails', async () => {
|
||||
const { calls, handlers } = mockFetch();
|
||||
handlers.set('/torrents/info', () => response('Unavailable', { status: 503 }));
|
||||
await expect(adapter.remove(INFO_HASH, config(), { deleteFiles: true })).rejects.toThrow('qBittorrent answered 503');
|
||||
expect(calls.some((call) => call.url.includes('/torrents/delete'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not swallow a 409 from a different endpoint', async () => {
|
||||
const { handlers } = hybridClient();
|
||||
handlers.set('/torrents/delete', () => response('Conflict', { status: 409 }));
|
||||
await expect(adapter.remove(INFO_HASH, config(), { deleteFiles: false })).rejects.toThrow(
|
||||
'qBittorrent answered 409 for /api/v2/torrents/delete',
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves full and truncated v2 aliases even when v1 is primary', async () => {
|
||||
const { handlers } = mockFetch();
|
||||
handlers.set('/torrents/info', () => response([{ ...hybrid, hash: INFO_HASH, infohash_v2: V2.toUpperCase() }]));
|
||||
expect((await adapter.status([V2, PRIMARY], config())).map((status) => status.infoHash).sort()).toEqual([V2, PRIMARY].sort());
|
||||
});
|
||||
|
||||
it('evicts old aliases when the bounded cache fills without losing the ability to rediscover them', async () => {
|
||||
const { calls, handlers } = mockFetch();
|
||||
const entries = Array.from({ length: 10_001 }, (_, index) => ({
|
||||
hash: (index + 20_000).toString(16).padStart(40, '0'),
|
||||
infohash_v1: (index + 1).toString(16).padStart(40, '0'),
|
||||
}));
|
||||
const byHash = new Map(
|
||||
entries.flatMap(
|
||||
(entry) =>
|
||||
[
|
||||
[entry.hash, entry],
|
||||
[entry.infohash_v1, entry],
|
||||
] as const,
|
||||
),
|
||||
);
|
||||
handlers.set('/torrents/info', (url) =>
|
||||
response(
|
||||
url.searchParams
|
||||
.get('hashes')!
|
||||
.split('|')
|
||||
.map((hash) => byHash.get(hash)),
|
||||
),
|
||||
);
|
||||
expect(
|
||||
await adapter.status(
|
||||
entries.map((entry) => entry.infohash_v1),
|
||||
config(),
|
||||
),
|
||||
).toHaveLength(entries.length);
|
||||
calls.length = 0;
|
||||
await adapter.status([entries[0].infohash_v1, entries.at(-1)!.infohash_v1], config());
|
||||
const query = new URL(calls.at(-1)!.url).searchParams.get('hashes');
|
||||
expect(query).toBe(`${entries[0].infohash_v1}|${entries.at(-1)!.hash}`);
|
||||
});
|
||||
|
||||
it('marks owned inventory as truncated without returning more than the reconciliation limit', async () => {
|
||||
const { handlers } = mockFetch();
|
||||
handlers.set('/torrents/info', () => response(Array.from({ length: 1001 }, () => hybrid)));
|
||||
const inventory = await adapter.listOwned(config());
|
||||
expect(inventory.truncated).toBe(true);
|
||||
expect(inventory.items).toHaveLength(1000);
|
||||
});
|
||||
|
||||
it('reports owned hybrids under v1 so tracked downloads cannot appear orphaned', async () => {
|
||||
const { calls, handlers } = mockFetch();
|
||||
handlers.set('/torrents/info', () => response([hybrid, { hash: 'f'.repeat(40), infohash_v1: '0'.repeat(40) }]));
|
||||
const inventory = await adapter.listOwned(config());
|
||||
expect(inventory.items.map((item) => item.infoHash)).toEqual([INFO_HASH, 'f'.repeat(40)]);
|
||||
expect(new URL(calls.at(-1)!.url).searchParams.get('limit')).toBe('1001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('session handling', () => {
|
||||
it('reuses the session cookie across calls', async () => {
|
||||
const { calls, handlers } = mockFetch();
|
||||
@@ -300,7 +581,8 @@ describe('QbittorrentAdapter', () => {
|
||||
|
||||
describe('remove', () => {
|
||||
it('deletes by hash without touching the files on disk', async () => {
|
||||
const { calls } = mockFetch();
|
||||
const { calls, handlers } = mockFetch();
|
||||
handlers.set('/torrents/info', () => response([{ hash: INFO_HASH }]));
|
||||
|
||||
await adapter.remove(INFO_HASH, config(), { deleteFiles: false });
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||
import type { DownloadClientTestResult } from '@bookorbit/types';
|
||||
|
||||
import { ensureSafeUrl } from '../../../../common/utils/ssrf.utils';
|
||||
@@ -26,6 +26,9 @@ const STATUS_BATCH_SIZE = 100;
|
||||
*/
|
||||
const TRACKER_PROBE_LIMIT = 20;
|
||||
const RECONCILIATION_LIMIT = 1000;
|
||||
const HASH_LOOKUP_PAGE_SIZE = 1000;
|
||||
const HASH_LOOKUP_MAX_PAGES = 100;
|
||||
const HASH_ALIAS_CACHE_LIMIT = 10_000;
|
||||
|
||||
interface QbTracker {
|
||||
url?: string;
|
||||
@@ -36,6 +39,8 @@ interface QbTracker {
|
||||
|
||||
interface QbTorrentInfo {
|
||||
hash?: string;
|
||||
infohash_v1?: string;
|
||||
infohash_v2?: string;
|
||||
name?: string;
|
||||
state?: string;
|
||||
progress?: number;
|
||||
@@ -76,6 +81,7 @@ export class QbittorrentAdapter implements DownloadClientAdapter {
|
||||
|
||||
private readonly logger = new Logger(QbittorrentAdapter.name);
|
||||
private readonly sessions = new Map<number, { cookie: string; expiresAt: number }>();
|
||||
private readonly hashAliases = new Map<string, { primary: string; expiresAt: number }>();
|
||||
|
||||
async add(release: GrabPayload, config: ResolvedClientConfig): Promise<{ clientHash: string }> {
|
||||
const form = new FormData();
|
||||
@@ -97,7 +103,7 @@ export class QbittorrentAdapter implements DownloadClientAdapter {
|
||||
// "Fails." with a 200 is the one 200 that is not success, and it covers two unrelated cases:
|
||||
// a torrent the client already holds, and one it could not read. Only the client can tell them
|
||||
// apart, so ask rather than hand the operator both guesses at once.
|
||||
if (body.toLowerCase().startsWith('fail')) {
|
||||
if (response.status === 409 || body.toLowerCase().startsWith('fail')) {
|
||||
if (await this.holds(release.infoHash, config)) {
|
||||
// An earlier attempt on this release leaves its torrent behind when the import fails, and
|
||||
// without this every retry of that release is rejected by the client forever. The torrent
|
||||
@@ -107,6 +113,7 @@ export class QbittorrentAdapter implements DownloadClientAdapter {
|
||||
);
|
||||
return { clientHash: release.infoHash.toLowerCase() };
|
||||
}
|
||||
if (response.status === 409) throw new BadRequestException('qBittorrent answered 409 for /api/v2/torrents/add');
|
||||
throw new BadRequestException('qBittorrent could not read that torrent. The file may be corrupt, or the magnet link invalid.');
|
||||
}
|
||||
|
||||
@@ -120,51 +127,93 @@ export class QbittorrentAdapter implements DownloadClientAdapter {
|
||||
private async holds(infoHash: string, config: ResolvedClientConfig): Promise<boolean> {
|
||||
const hash = infoHash.toLowerCase();
|
||||
try {
|
||||
const response = await this.call(config, `/api/v2/torrents/info?hashes=${encodeURIComponent(hash)}`, { method: 'GET' });
|
||||
const payload = await readClientJson<unknown>(response, LABEL);
|
||||
return Array.isArray(payload) && (payload as QbTorrentInfo[]).some((entry) => entry.hash?.toLowerCase() === hash);
|
||||
return (await this.findTorrents([hash], config)).has(hash);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async status(hashes: string[], config: ResolvedClientConfig): Promise<DownloadStatus[]> {
|
||||
const wanted = new Set(hashes.map((hash) => hash.toLowerCase()));
|
||||
const entries = await this.findTorrents(hashes, config);
|
||||
const results: DownloadStatus[] = [];
|
||||
const stuck: DownloadStatus[] = [];
|
||||
|
||||
for (let index = 0; index < hashes.length; index += STATUS_BATCH_SIZE) {
|
||||
const batch = hashes.slice(index, index + STATUS_BATCH_SIZE).map((hash) => hash.toLowerCase());
|
||||
const response = await this.call(config, `/api/v2/torrents/info?hashes=${encodeURIComponent(batch.join('|'))}`, { method: 'GET' });
|
||||
const payload = await readClientJson<unknown>(response, LABEL);
|
||||
if (!Array.isArray(payload)) continue;
|
||||
|
||||
for (const entry of payload as QbTorrentInfo[]) {
|
||||
const hash = entry.hash?.toLowerCase();
|
||||
if (!hash || !wanted.has(hash)) continue;
|
||||
const status = toDownloadStatus(hash, entry);
|
||||
results.push(status);
|
||||
// Collected here rather than derived from `status`: which qBittorrent states are worth
|
||||
// asking about is this adapter's business, and does not belong in the shared shape.
|
||||
if (entry.state !== undefined && STUCK_STATES.has(entry.state) && status.downloadedBytes === 0) stuck.push(status);
|
||||
const stuck: Array<{ status: DownloadStatus; primary: string }> = [];
|
||||
for (const [hash, entry] of entries) {
|
||||
const status = toDownloadStatus(hash, entry);
|
||||
results.push(status);
|
||||
if (entry.state !== undefined && STUCK_STATES.has(entry.state) && status.downloadedBytes === 0) {
|
||||
stuck.push({ status, primary: entry.hash!.toLowerCase() });
|
||||
}
|
||||
}
|
||||
|
||||
await this.attachTrackerErrors(stuck.slice(0, TRACKER_PROBE_LIMIT), config);
|
||||
return results;
|
||||
}
|
||||
|
||||
async listOwned(config: ResolvedClientConfig): Promise<OwnedDownloadClientInventory> {
|
||||
const response = await this.call(config, `/api/v2/torrents/info?category=${encodeURIComponent(config.category)}`, { method: 'GET' });
|
||||
const payload = await readClientJson<unknown>(response, LABEL);
|
||||
if (!Array.isArray(payload)) return { supported: true, truncated: false, items: [] };
|
||||
/** Keep the caller's identity while resolving the primary hash required by qBittorrent's API. */
|
||||
private async findTorrents(hashes: string[], config: ResolvedClientConfig): Promise<Map<string, QbTorrentInfo>> {
|
||||
const wanted = new Set(hashes.map((hash) => hash.toLowerCase()));
|
||||
const found = new Map<string, QbTorrentInfo>();
|
||||
const collect = (entries: QbTorrentInfo[]) => {
|
||||
for (const entry of entries) {
|
||||
const primary = validHash(entry.hash, 40);
|
||||
if (!primary) continue;
|
||||
for (const alias of torrentHashes(entry)) {
|
||||
if (!wanted.has(alias) || found.has(alias)) continue;
|
||||
found.set(alias, entry);
|
||||
this.rememberHash(config.id, alias, primary);
|
||||
}
|
||||
}
|
||||
};
|
||||
const requested = [...new Set([...wanted].map((hash) => this.primaryHash(config.id, hash)))];
|
||||
for (let index = 0; index < requested.length; index += STATUS_BATCH_SIZE) {
|
||||
const batch = requested.slice(index, index + STATUS_BATCH_SIZE);
|
||||
collect(await this.readTorrents(config, `hashes=${encodeURIComponent(batch.join('|'))}`));
|
||||
}
|
||||
if (found.size === wanted.size) return found;
|
||||
|
||||
const entries = payload as QbTorrentInfo[];
|
||||
// qBittorrent 5.2 filters only by primary hash, even when infohash_v1 is present. Search
|
||||
// pages once for unresolved aliases, including torrents whose category an operator changed.
|
||||
// A truncated or failed search must never become evidence that a download disappeared.
|
||||
for (let page = 0; page < HASH_LOOKUP_MAX_PAGES; page++) {
|
||||
const entries = await this.readTorrents(config, `sort=hash&limit=${HASH_LOOKUP_PAGE_SIZE}&offset=${page * HASH_LOOKUP_PAGE_SIZE}`);
|
||||
collect(entries);
|
||||
if (found.size === wanted.size || entries.length < HASH_LOOKUP_PAGE_SIZE) return found;
|
||||
}
|
||||
throw new ServiceUnavailableException('qBittorrent hash lookup exceeded its scan limit; download presence could not be determined');
|
||||
}
|
||||
|
||||
private async readTorrents(config: ResolvedClientConfig, query: string): Promise<QbTorrentInfo[]> {
|
||||
const response = await this.call(config, `/api/v2/torrents/info?${query}`, { method: 'GET' });
|
||||
const payload = await readClientJson<unknown>(response, LABEL);
|
||||
if (!Array.isArray(payload) || payload.some((entry) => entry === null || typeof entry !== 'object')) {
|
||||
throw new BadRequestException('qBittorrent returned an invalid torrent listing');
|
||||
}
|
||||
return payload as QbTorrentInfo[];
|
||||
}
|
||||
|
||||
private primaryHash(clientId: number, hash: string): string {
|
||||
const key = `${clientId}:${hash}`;
|
||||
const cached = this.hashAliases.get(key);
|
||||
if (cached && cached.expiresAt > Date.now()) return cached.primary;
|
||||
this.hashAliases.delete(key);
|
||||
return hash;
|
||||
}
|
||||
|
||||
private rememberHash(clientId: number, hash: string, primary: string): void {
|
||||
const key = `${clientId}:${hash}`;
|
||||
this.hashAliases.delete(key);
|
||||
if (hash === primary) return;
|
||||
this.hashAliases.set(key, { primary, expiresAt: Date.now() + SESSION_TTL_MS });
|
||||
if (this.hashAliases.size > HASH_ALIAS_CACHE_LIMIT) this.hashAliases.delete(this.hashAliases.keys().next().value!);
|
||||
}
|
||||
|
||||
async listOwned(config: ResolvedClientConfig): Promise<OwnedDownloadClientInventory> {
|
||||
const entries = await this.readTorrents(config, `category=${encodeURIComponent(config.category)}&limit=${RECONCILIATION_LIMIT + 1}`);
|
||||
return {
|
||||
supported: true,
|
||||
truncated: entries.length > RECONCILIATION_LIMIT,
|
||||
items: entries.slice(0, RECONCILIATION_LIMIT).flatMap((entry) => {
|
||||
const hash = entry.hash?.toLowerCase();
|
||||
// Grabs store v1, so reconciliation must use that same identity for hybrid torrents.
|
||||
const hash = validHash(entry.infohash_v1, 40) ?? validHash(entry.hash, 40);
|
||||
if (!hash) return [];
|
||||
return [{ ...toDownloadStatus(hash, entry), name: entry.name?.trim() || hash }];
|
||||
}),
|
||||
@@ -177,10 +226,10 @@ export class QbittorrentAdapter implements DownloadClientAdapter {
|
||||
* tracker's own message is the only thing that distinguishes "no peers yet" from "this tracker
|
||||
* will never accept us".
|
||||
*/
|
||||
private async attachTrackerErrors(stuck: DownloadStatus[], config: ResolvedClientConfig): Promise<void> {
|
||||
for (const status of stuck) {
|
||||
private async attachTrackerErrors(stuck: Array<{ status: DownloadStatus; primary: string }>, config: ResolvedClientConfig): Promise<void> {
|
||||
for (const { status, primary } of stuck) {
|
||||
try {
|
||||
const response = await this.call(config, `/api/v2/torrents/trackers?hash=${encodeURIComponent(status.infoHash)}`, { method: 'GET' });
|
||||
const response = await this.call(config, `/api/v2/torrents/trackers?hash=${encodeURIComponent(primary)}`, { method: 'GET' });
|
||||
const payload = await readClientJson<unknown>(response, LABEL);
|
||||
if (!Array.isArray(payload)) continue;
|
||||
const message = trackerFailure(payload as QbTracker[]);
|
||||
@@ -197,7 +246,9 @@ export class QbittorrentAdapter implements DownloadClientAdapter {
|
||||
}
|
||||
|
||||
async remove(hash: string, config: ResolvedClientConfig, opts: { deleteFiles: boolean }): Promise<void> {
|
||||
const body = new URLSearchParams({ hashes: hash.toLowerCase(), deleteFiles: String(opts.deleteFiles) });
|
||||
const entry = (await this.findTorrents([hash], config)).get(hash.toLowerCase());
|
||||
if (!entry) return;
|
||||
const body = new URLSearchParams({ hashes: entry.hash!.toLowerCase(), deleteFiles: String(opts.deleteFiles) });
|
||||
await this.call(config, '/api/v2/torrents/delete', {
|
||||
method: 'POST',
|
||||
body,
|
||||
@@ -218,9 +269,12 @@ export class QbittorrentAdapter implements DownloadClientAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops the cached session so the next call re-authenticates against the current config. */
|
||||
/** Neither credentials nor hash aliases may outlive the client configuration they came from. */
|
||||
forget(clientId: number): void {
|
||||
this.sessions.delete(clientId);
|
||||
for (const key of this.hashAliases.keys()) {
|
||||
if (key.startsWith(`${clientId}:`)) this.hashAliases.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
private async call(config: ResolvedClientConfig, path: string, init: RequestInit, retryOnAuthFailure = true): Promise<Response> {
|
||||
@@ -237,7 +291,8 @@ export class QbittorrentAdapter implements DownloadClientAdapter {
|
||||
this.sessions.delete(config.id);
|
||||
return this.call(config, path, init, false);
|
||||
}
|
||||
if (!response.ok) {
|
||||
// Newer clients report duplicate adds as 409; add() verifies presence before adopting.
|
||||
if (!response.ok && !(response.status === 409 && path === '/api/v2/torrents/add')) {
|
||||
throwForClientServerError(response, LABEL, path.split('?')[0]!);
|
||||
throw new BadRequestException(`qBittorrent answered ${response.status} for ${path.split('?')[0]}`);
|
||||
}
|
||||
@@ -275,6 +330,16 @@ export class QbittorrentAdapter implements DownloadClientAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
function validHash(value: unknown, length: 40 | 64): string | undefined {
|
||||
if (typeof value !== 'string' || value.length !== length || !/^[a-f0-9]+$/i.test(value) || /^0+$/.test(value)) return undefined;
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
function torrentHashes(entry: QbTorrentInfo): string[] {
|
||||
const v2 = validHash(entry.infohash_v2, 64);
|
||||
return [validHash(entry.hash, 40), validHash(entry.infohash_v1, 40), v2, v2?.slice(0, 40)].filter((hash): hash is string => hash !== undefined);
|
||||
}
|
||||
|
||||
function toDownloadStatus(hash: string, entry: QbTorrentInfo): DownloadStatus {
|
||||
const totalBytes = entry.total_size ?? entry.size ?? null;
|
||||
const state = mapState(entry.state);
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { Test, type TestingModule } from '@nestjs/testing';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import type { BookRequestDownloadRow } from '../src/db/schema';
|
||||
import { BookRequestGateway } from '../src/modules/book-request/book-request.gateway';
|
||||
import { BookRequestRepository } from '../src/modules/book-request/book-request.repository';
|
||||
import { QbittorrentAdapter } from '../src/modules/book-request/download-clients/adapters/qbittorrent.adapter';
|
||||
import type { ResolvedClientConfig } from '../src/modules/book-request/download-clients/download-client-adapter';
|
||||
import { DownloadClientConfigService } from '../src/modules/book-request/download-clients/download-client-config.service';
|
||||
import { DownloadClientRegistry } from '../src/modules/book-request/download-clients/download-client-registry';
|
||||
import { BookRequestDownloadRepository } from '../src/modules/book-request/fulfillment/book-request-download.repository';
|
||||
import { DirectDownloadService } from '../src/modules/book-request/fulfillment/direct-download.service';
|
||||
import { DownloadMonitorService } from '../src/modules/book-request/fulfillment/download-monitor.service';
|
||||
import { RequestFulfillmentService } from '../src/modules/book-request/fulfillment/request-fulfillment.service';
|
||||
import { RequestImportService } from '../src/modules/book-request/fulfillment/request-import.service';
|
||||
import { infoHashFromMagnet } from '../src/modules/book-request/fulfillment/torrent.utils';
|
||||
|
||||
// Opt-in regression for issue 1318 against the pinned daemon that reproduced the bug.
|
||||
// BO_REPRO_1318=1 pnpm --filter server test test/qbittorrent-hybrid.repro.test.ts
|
||||
const IMAGE = 'lscr.io/linuxserver/qbittorrent:5.2.3@sha256:8d48fa8c619caadfb103f04efea8747e2a2762d6c7551690919cffb987e61cee';
|
||||
const exec = promisify(execFile);
|
||||
const CONTENT = Buffer.from('BookOrbit issue 1318 synthetic public test content.\n');
|
||||
const FILE_NAME = 'issue-1318.txt';
|
||||
|
||||
type Bencoded = number | Buffer | { [key: string]: Bencoded };
|
||||
|
||||
function bencode(value: Bencoded): Buffer {
|
||||
if (typeof value === 'number') return Buffer.from(`i${value}e`);
|
||||
if (Buffer.isBuffer(value)) return Buffer.concat([Buffer.from(`${value.length}:`), value]);
|
||||
return Buffer.concat([
|
||||
Buffer.from('d'),
|
||||
...Object.keys(value)
|
||||
.sort()
|
||||
.flatMap((key) => [bencode(Buffer.from(key)), bencode(value[key])]),
|
||||
Buffer.from('e'),
|
||||
]);
|
||||
}
|
||||
|
||||
const info = bencode({
|
||||
'file tree': { [FILE_NAME]: { '': { length: CONTENT.length, 'pieces root': createHash('sha256').update(CONTENT).digest() } } },
|
||||
length: CONTENT.length,
|
||||
'meta version': 2,
|
||||
name: Buffer.from(FILE_NAME),
|
||||
'piece length': 16384,
|
||||
pieces: createHash('sha1').update(CONTENT).digest(),
|
||||
});
|
||||
const V1 = createHash('sha1').update(info).digest('hex');
|
||||
const V2 = createHash('sha256').update(info).digest('hex');
|
||||
const PRIMARY = V2.slice(0, 40);
|
||||
const MAGNET = `magnet:?xt=urn:btih:${V1}&xt=urn:btmh:1220${V2}`;
|
||||
|
||||
describe.skipIf(process.env.BO_REPRO_1318 !== '1')('issue 1318 against real qBittorrent 5.2.3', () => {
|
||||
let root: string;
|
||||
let containerId: string;
|
||||
let config: ResolvedClientConfig;
|
||||
let adapter: QbittorrentAdapter;
|
||||
let module: TestingModule;
|
||||
|
||||
async function api(path: string, body?: URLSearchParams | FormData): Promise<Response> {
|
||||
const result = await fetch(`${config.baseUrl}/api/v2/${path}`, {
|
||||
method: body ? 'POST' : 'GET',
|
||||
body,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
if (!result.ok) throw new Error(`Fixture API ${path.split('?')[0]} returned ${result.status}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), 'bo-1318-'));
|
||||
await mkdir(join(root, 'config', 'qBittorrent'), { recursive: true });
|
||||
await mkdir(join(root, 'downloads'));
|
||||
await writeFile(join(root, 'downloads', FILE_NAME), CONTENT);
|
||||
await writeFile(
|
||||
join(root, 'config', 'qBittorrent', 'qBittorrent.conf'),
|
||||
[
|
||||
'[LegalNotice]',
|
||||
'Accepted=true',
|
||||
'[Preferences]',
|
||||
'WebUI\\AuthSubnetWhitelistEnabled=true',
|
||||
'WebUI\\AuthSubnetWhitelist=0.0.0.0/0',
|
||||
'WebUI\\CSRFProtection=false',
|
||||
'WebUI\\HostHeaderValidation=false',
|
||||
'[BitTorrent]',
|
||||
'Session\\DHTEnabled=false',
|
||||
'Session\\LSDEnabled=false',
|
||||
'Session\\PeXEnabled=false',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
const started = await exec(
|
||||
'docker',
|
||||
['run', '-d', '--rm', '-p', '127.0.0.1::8080', '-v', `${root}/config:/config`, '-v', `${root}/downloads:/downloads`, IMAGE],
|
||||
{ timeout: 180_000 },
|
||||
);
|
||||
containerId = started.stdout.trim();
|
||||
const port = (await exec('docker', ['port', containerId, '8080'])).stdout.trim().split(':').at(-1);
|
||||
config = {
|
||||
id: 1,
|
||||
name: 'issue 1318 isolated fixture',
|
||||
adapterType: 'qbittorrent',
|
||||
baseUrl: `http://127.0.0.1:${port}`,
|
||||
username: null,
|
||||
password: null,
|
||||
category: 'bookorbit',
|
||||
allowPrivateAddress: true,
|
||||
settings: null,
|
||||
};
|
||||
module = await Test.createTestingModule({ providers: [QbittorrentAdapter] }).compile();
|
||||
adapter = module.get(QbittorrentAdapter);
|
||||
await vi.waitFor(async () => expect(await (await api('app/version')).text()).toBe('v5.2.3'), { timeout: 30_000, interval: 250 });
|
||||
|
||||
expect(infoHashFromMagnet(MAGNET)).toBe(V1);
|
||||
expect(await adapter.add({ magnet: MAGNET, infoHash: infoHashFromMagnet(MAGNET) }, config)).toEqual({ clientHash: V1 });
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
expect(await (await api(`torrents/info?hashes=${PRIMARY}`)).json()).toEqual([
|
||||
expect.objectContaining({ hash: PRIMARY, infohash_v1: V1, infohash_v2: V2 }),
|
||||
]);
|
||||
},
|
||||
{ timeout: 10_000, interval: 100 },
|
||||
);
|
||||
|
||||
// Supply the same metadata and preseeded bytes without depending on trackers or peers.
|
||||
await api('torrents/delete', new URLSearchParams({ hashes: PRIMARY, deleteFiles: 'false' }));
|
||||
await vi.waitFor(async () => expect(await (await api('torrents/info')).json()).toEqual([]), { timeout: 10_000, interval: 100 });
|
||||
const form = new FormData();
|
||||
form.append('torrents', new Blob([new Uint8Array(Buffer.concat([Buffer.from('d4:info'), info, Buffer.from('e')]))]), 'hybrid.torrent');
|
||||
form.append('category', config.category);
|
||||
form.append('savepath', '/downloads');
|
||||
await api('torrents/add', form);
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
expect(await (await api('torrents/info?category=bookorbit')).json()).toEqual([
|
||||
expect.objectContaining({ hash: PRIMARY, infohash_v1: V1, infohash_v2: V2, progress: 1, state: 'stalledUP' }),
|
||||
]);
|
||||
},
|
||||
{ timeout: 30_000, interval: 100 },
|
||||
);
|
||||
}, 240_000);
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
await module?.close();
|
||||
} finally {
|
||||
if (containerId) await exec('docker', ['rm', '-f', containerId]);
|
||||
if (root) await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('has verified complete bytes, but the real API cannot find the torrent by v1', async () => {
|
||||
expect(await readFile(join(root, 'downloads', FILE_NAME))).toEqual(CONTENT);
|
||||
expect(await (await api(`torrents/info?hashes=${V1}`)).json()).toEqual([]);
|
||||
expect(await (await api(`torrents/info?hashes=${PRIMARY}`)).json()).toEqual([
|
||||
expect.objectContaining({ hash: PRIMARY, infohash_v1: V1, infohash_v2: V2, progress: 1, state: 'stalledUP' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('finds completion under both the stored hash and the primary hash', async () => {
|
||||
expect(await adapter.status([V1], config)).toEqual([expect.objectContaining({ infoHash: V1, state: 'completed', progressPercent: 100 })]);
|
||||
expect(await adapter.status([PRIMARY], config)).toEqual([
|
||||
expect.objectContaining({ infoHash: PRIMARY, state: 'completed', progressPercent: 100 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('schedules import beyond the old 120-second failure boundary', async () => {
|
||||
const grabbedAt = new Date('2026-01-01T00:00:00Z');
|
||||
const row = {
|
||||
id: 11,
|
||||
requestId: 7,
|
||||
downloadClientId: config.id,
|
||||
clientHash: V1,
|
||||
source: 'magnet',
|
||||
status: 'downloading',
|
||||
bookDockFileId: null,
|
||||
downloadedBytes: 0,
|
||||
grabbedAt,
|
||||
createdAt: grabbedAt,
|
||||
} as BookRequestDownloadRow;
|
||||
const failDownload = vi.fn().mockResolvedValue(undefined);
|
||||
const importDownload = vi.fn().mockResolvedValue(true);
|
||||
const monitorModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DownloadMonitorService,
|
||||
{
|
||||
provide: BookRequestDownloadRepository,
|
||||
useValue: {
|
||||
findActive: vi.fn().mockImplementation(() => Promise.resolve(row.status === 'downloading' ? [row] : [])),
|
||||
findCompletedAwaitingImport: vi.fn().mockResolvedValue([]),
|
||||
findById: vi.fn().mockImplementation(() => Promise.resolve(row)),
|
||||
updateIf: vi
|
||||
.fn()
|
||||
.mockImplementation((_id: number, _expected: unknown, patch: Partial<BookRequestDownloadRow>) =>
|
||||
Promise.resolve(Object.assign(row, patch)),
|
||||
),
|
||||
},
|
||||
},
|
||||
{ provide: BookRequestRepository, useValue: { findRequestViewerIds: vi.fn().mockResolvedValue(new Map()) } },
|
||||
{ provide: DownloadClientConfigService, useValue: { resolveConfig: vi.fn().mockResolvedValue(config) } },
|
||||
{ provide: DownloadClientRegistry, useValue: { require: vi.fn().mockReturnValue(adapter) } },
|
||||
{ provide: DirectDownloadService, useValue: {} },
|
||||
{ provide: RequestImportService, useValue: { importDownload } },
|
||||
{ provide: RequestFulfillmentService, useValue: { failDownload } },
|
||||
{ provide: BookRequestGateway, useValue: { emitProgress: vi.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
const monitor = monitorModule.get(DownloadMonitorService);
|
||||
const now = vi.spyOn(Date, 'now');
|
||||
try {
|
||||
now.mockReturnValue(grabbedAt.getTime() + 120_000);
|
||||
await monitor.tick();
|
||||
await monitor.whenPollsSettle();
|
||||
await vi.waitFor(() => expect(importDownload).toHaveBeenCalledExactlyOnceWith(row));
|
||||
expect(row).toMatchObject({ status: 'completed', progressPercent: 100, contentPath: `/downloads/${FILE_NAME}` });
|
||||
now.mockReturnValue(grabbedAt.getTime() + 125_000);
|
||||
await monitor.tick();
|
||||
await monitor.whenPollsSettle();
|
||||
expect(failDownload).not.toHaveBeenCalled();
|
||||
expect(importDownload).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
now.mockRestore();
|
||||
await monitorModule.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('adopts a retry after qBittorrent rejects the duplicate with 409', async () => {
|
||||
await expect(adapter.add({ magnet: MAGNET, infoHash: V1 }, config)).resolves.toEqual({ clientHash: V1 });
|
||||
expect(await adapter.status([PRIMARY], config)).toEqual([expect.objectContaining({ state: 'completed', progressPercent: 100 })]);
|
||||
});
|
||||
it('lists the hybrid under the tracked v1 identity and removes it without deleting its file', async () => {
|
||||
expect((await adapter.listOwned(config)).items).toEqual([expect.objectContaining({ infoHash: V1, state: 'completed' })]);
|
||||
await adapter.remove(V1, config, { deleteFiles: false });
|
||||
await vi.waitFor(async () => expect(await (await api('torrents/info')).json()).toEqual([]), { timeout: 10_000, interval: 100 });
|
||||
expect(await readFile(join(root, 'downloads', FILE_NAME))).toEqual(CONTENT);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user