fix(requests): retrying a request after its client failed offers the same release again (#1677)

When a request's download fails in a way the queue can't resume, the
request is reopened and its Retry button opens the release list. The
release that just failed was drawn there as a red, disabled "Failed"
button for as long as the failed task stayed in the queue status, so you
couldn't pick it again even when the release was fine and the download
client was the problem. A bad qBittorrent password is the easy way to
hit it. Thanks @joshft91 for the report and the steps.

What changes:

- In the release list, a failed request download now shows Retry, the
same as a failure the queue can retry already does. A direct download
with nothing to retry from still shows Failed. The rule lives in one
small helper used by the release modal and both button-state paths in
`useDownloadTracking`.
- A client that refuses the login is no longer held against the release.
qbittorrent-api raises `LoginFailed` with no message for a wrong
password, and only "Forbidden" or "Unauthorized" for a bad session or
API key, so none of it matched the unreachable-client markers from #1655
and the release went into `failed_releases`. The qBittorrent client now
says "qBittorrent authentication failed (...)" for these.
- The request reopen and the auto-download retry used two copies of the
same marker list. They now share one in `status_messages.py`, which also
counts an explicit authentication failure as the client's fault. A bare
403 or 401 is not on it, since a dead .torrent link fails that way too
and that one is the release's. Genuine release failures (no seeders, a
rejected torrent, a dead .torrent link) are still remembered as #1655
intended.

Tested with a scratch instance against a mock Prowlarr and a fake
qBittorrent that rejects the login: before, the request's Retry opened
the list with the failed release as a red X; after, it shows Retry,
picking it fulfils the request again, and the request reopens without
the release in `failed_releases`. New tests cover the qBittorrent
messages, both reopen paths and the button state.

Fixes #1674


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
* Failed releases are no longer skipped after download-client connection
or login failures; download failures and stall cancellations remain
eligible to be skipped on the next search.
* Failed requests with an associated request can now show a retry
option, while direct downloads without retries remain marked as failed.
* qBittorrent login and authorization errors are reported more clearly.

* **Documentation**
* Clarified which failures cause a release to be skipped on a later
search.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
splitsec2 authored and GitHub committed 2026-11-08 23:11:25 -07:00
1 parent efc0f3df6b
commit 76d7df306f
12 files changed
+375 -43

No files matched your search

+2 -3
View File
@@ -26,6 +26,7 @@ import type {
import { isMetadataBook } from '../types';
import { bookSupportsTargets } from '../utils/bookTargetLoader';
import { getColorStyleFromHint } from '../utils/colorMaps';
import { failedDownloadButtonState } from '../utils/failedDownloadButton';
import { groupFilesByFormat, type FormatGroup } from '../utils/formatReview';
import {
LANGUAGE_OPTION_DEFAULT,
@@ -1195,9 +1196,7 @@ const ReleaseModalSession = ({
if (mode === 'blocked' || mode === 'request_book') {
return { text: 'Unavailable', state: 'blocked' };
}
return currentStatus.error[releaseId].retry_available === true
? { text: 'Retry', state: 'download' }
: { text: 'Failed', state: 'error' };
return failedDownloadButtonState(currentStatus.error[releaseId]);
}
// Check completed
if (currentStatus.complete && currentStatus.complete[releaseId]) {
+14 -10
View File
@@ -1,6 +1,7 @@
import { useState, useCallback } from 'react';
import type { StatusData, ButtonStateInfo } from '../types';
import { addFailedRelease, failedDownloadButtonState } from '../utils/failedDownloadButton';
interface UseDownloadTrackingReturn {
bookToReleaseMap: Record<string, string[]>;
@@ -12,6 +13,12 @@ interface UseDownloadTrackingReturn {
getUniversalButtonState: (bookId: string) => ButtonStateInfo;
}
/**
* Download button state for books and releases, from the queue status and this session's picks.
*
* In universal mode a book's button follows the releases downloaded for it, so the hook keeps
* which releases belong to which book and which books finished during this session.
*/
export function useDownloadTracking(currentStatus: StatusData): UseDownloadTrackingReturn {
// Track mapping of metadata book IDs to release source IDs for universal mode
const [bookToReleaseMap, setBookToReleaseMap] = useState<Record<string, string[]>>({});
@@ -38,9 +45,7 @@ export function useDownloadTracking(currentStatus: StatusData): UseDownloadTrack
const getButtonState = useCallback(
(bookId: string): ButtonStateInfo => {
if (currentStatus.error && currentStatus.error[bookId]) {
return currentStatus.error[bookId].retry_available === true
? { text: 'Retry', state: 'download' }
: { text: 'Failed', state: 'error' };
return failedDownloadButtonState(currentStatus.error[bookId]);
}
if (currentStatus.complete && currentStatus.complete[bookId]) {
return { text: 'Downloaded', state: 'complete' };
@@ -67,7 +72,11 @@ export function useDownloadTracking(currentStatus: StatusData): UseDownloadTrack
[currentStatus],
);
// Get button state for a metadata book in universal mode
/**
* The button for a metadata book in universal mode, from every release tracked for it. An
* active download wins, then a finished one, then a failure that can be retried, then any
* other failure.
*/
const getUniversalButtonState = useCallback(
(bookId: string): ButtonStateInfo => {
const releaseIds = bookToReleaseMap[bookId] || [];
@@ -111,12 +120,7 @@ export function useDownloadTracking(currentStatus: StatusData): UseDownloadTrack
bestState = { text: 'Downloaded', state: 'complete' };
foundActiveState = true;
} else if (currentStatus.error && currentStatus.error[releaseId]) {
if (bestState.state === 'download') {
bestState =
currentStatus.error[releaseId].retry_available === true
? { text: 'Retry', state: 'download' }
: { text: 'Failed', state: 'error' };
}
bestState = addFailedRelease(bestState, currentStatus.error[releaseId]);
}
}
}
@@ -0,0 +1,58 @@
import { describe, it, expect } from 'vitest';
import type { Book } from '../types';
import { addFailedRelease, failedDownloadButtonState } from '../utils/failedDownloadButton';
const failed = (fields: Partial<Book>): Book => ({
id: 'rel-1',
title: 'Dungeon Crawler Carl',
author: 'Matt Dinniman',
...fields,
});
describe('failedDownloadButtonState', () => {
it('offers a failed request download again so the request can be retried with it', () => {
expect(failedDownloadButtonState(failed({ request_id: 7, retry_available: false }))).toEqual({
text: 'Retry',
state: 'download',
});
});
it('offers a retry when the queue can retry the download', () => {
expect(failedDownloadButtonState(failed({ retry_available: true }))).toEqual({
text: 'Retry',
state: 'download',
});
});
it('marks a direct download with nothing to retry from as failed', () => {
expect(failedDownloadButtonState(failed({ retry_available: false }))).toEqual({
text: 'Failed',
state: 'error',
});
});
});
describe('addFailedRelease', () => {
const get = { text: 'Get', state: 'download' } as const;
const retryable = failed({ request_id: 7, retry_available: false });
const final = failed({ id: 'rel-2', retry_available: false });
it('keeps Retry when a release that cannot be retried is found after it', () => {
expect(addFailedRelease(addFailedRelease(get, retryable), final)).toEqual({
text: 'Retry',
state: 'download',
});
});
it('turns Failed into Retry when a retryable release is found after it', () => {
expect(addFailedRelease(addFailedRelease(get, final), retryable)).toEqual({
text: 'Retry',
state: 'download',
});
});
it('marks the book failed when no failed release can be retried', () => {
expect(addFailedRelease(get, final)).toEqual({ text: 'Failed', state: 'error' });
});
});
@@ -0,0 +1,32 @@
import type { Book, ButtonStateInfo } from '../types';
const RETRY: ButtonStateInfo = { text: 'Retry', state: 'download' };
const FAILED: ButtonStateInfo = { text: 'Failed', state: 'error' };
/**
* The button for a release whose last download failed.
*
* When the queue cannot resume a failed download of a request, the request is reopened so a
* release can be picked again, and the one that failed may be fine (its client was down or
* refused the login). So it is offered again, like a download the queue can retry. Any other
* failure with nothing to retry from stays marked as failed.
*/
export function failedDownloadButtonState(entry: Book): ButtonStateInfo {
return entry.retry_available === true || (entry.request_id ?? 0) > 0
? { ...RETRY }
: { ...FAILED };
}
/**
* The button for a book with several tracked releases once another of them is found failed.
*
* A failure that can be retried wins over one that can't, in whichever order they are found, so
* the book keeps Retry while any of its failed releases can be tried again.
*/
export function addFailedRelease(best: ButtonStateInfo, entry: Book): ButtonStateInfo {
if (best.state === RETRY.state && best.text === RETRY.text) {
return best;
}
const failed = failedDownloadButtonState(entry);
return failed.text === RETRY.text || best.state === 'download' ? failed : best;
}