fix: say which download client is not responding

Show one notice above the release picker naming the client, with a
Try again that resends the same release, instead of a chip on every
row. Grab failures carry the client name in errorMeta, and transport
errors read "connection refused" rather than "fetch failed".
This commit is contained in:
neon
2026-10-22 00:05:10 +00:00
parent 992224f2e8
commit 0320210767
8 changed files with 251 additions and 29 deletions
@@ -191,4 +191,62 @@ describe('useReleaseGrab', () => {
expect(picker.isRefused(release({ indexerId: 9, guid: 'torrent-2' }))).toBe(true)
expect(picker.isRefused(release({ indexerId: 5, guid: 'usenet-1' }))).toBe(false)
})
/** "Could not reach qBittorrent: fetch failed" on a toast, then the same chip on every row, was all it said. */
describe('when the download client is down', () => {
const unavailable: ReleaseGrabOutcome = {
item: null,
reason: 'Could not reach qBittorrent: connection refused',
errorCode: 'GRAB_CLIENT_UNAVAILABLE',
errorMeta: { client: 'QBT' },
}
it('names the client once instead of marking every row', async () => {
const picker = makeGrab({ grab: vi.fn<() => Promise<ReleaseGrabOutcome>>().mockResolvedValue(unavailable) })
await picker.handleGrab(release())
expect(picker.clientProblem.value).toMatchObject({ code: 'GRAB_CLIENT_UNAVAILABLE', client: 'QBT' })
expect(picker.isRefused(release({ guid: 'g2' }))).toBe(true)
expect(picker.refusalText(release({ guid: 'g2' }))).toBeNull()
expect(toastMock.error).toHaveBeenCalledWith('bookRequests.errors.grabFailed', {
description: 'bookRequests.releases.clientProblem.unavailable',
})
})
it('sends the same release again without inspecting it again', async () => {
const grab = vi
.fn<() => Promise<ReleaseGrabOutcome>>()
.mockResolvedValueOnce(unavailable)
.mockResolvedValue({ item: request, reason: null, errorCode: null })
const picker = makeGrab({ grab })
await picker.handleGrab(release())
await picker.retryClient()
expect(grab).toHaveBeenNthCalledWith(2, 7, { indexerId: 9, releaseGuid: 'g1' })
expect(picker.inspectRelease).toHaveBeenCalledTimes(1)
expect(picker.clientProblem.value).toBeNull()
expect(picker.isRefused(release({ guid: 'g2' }))).toBe(false)
})
it('keeps the notice when the client still does not answer', async () => {
const picker = makeGrab({ grab: vi.fn<() => Promise<ReleaseGrabOutcome>>().mockResolvedValue(unavailable) })
await picker.handleGrab(release())
await picker.retryClient()
expect(picker.clientProblem.value).toMatchObject({ code: 'GRAB_CLIENT_UNAVAILABLE' })
expect(picker.isRefused(release({ guid: 'g2' }))).toBe(true)
})
it('is cleared by a fresh search', async () => {
const picker = makeGrab({ grab: vi.fn<() => Promise<ReleaseGrabOutcome>>().mockResolvedValue(unavailable) })
await picker.handleGrab(release())
picker.forgetRefusals()
expect(picker.clientProblem.value).toBeNull()
})
})
})
@@ -7,6 +7,7 @@ import {
Check,
ChevronDown,
ChevronUp,
CircleAlert,
CircleCheck,
CircleMinus,
Download,
@@ -179,17 +180,29 @@ const {
resetSearchEditorState,
} = useReleaseSearchEditor({ request, criteria, attempts, requestId, fetchReleases, beforeSearch: startFreshSearch })
const { manualOpen, grabbing, holdForReview, openManual, closeManual, forgetRefusals, handleGrab, handleManualGrab, isRefused, refusalText } =
useReleaseGrab({
request,
requestId,
grab: actions.grab,
setRequest,
inspectRelease,
setFilesExpanded,
deliveryFor,
afterGrab,
})
const {
manualOpen,
grabbing,
holdForReview,
clientProblem,
openManual,
closeManual,
forgetRefusals,
handleGrab,
handleManualGrab,
retryClient,
isRefused,
refusalText,
} = useReleaseGrab({
request,
requestId,
grab: actions.grab,
setRequest,
inspectRelease,
setFilesExpanded,
deliveryFor,
afterGrab,
})
/**
* Every Send is off while one release is on its way, not only the row being inspected. The
@@ -197,6 +210,20 @@ const { manualOpen, grabbing, holdForReview, openManual, closeManual, forgetRefu
* request, so `actions` has nothing to be busy about yet.
*/
const busy = computed(() => grabbing.value || actions.isPending(request.value?.id))
const clientProblemTitle = computed(() => {
const problem = clientProblem.value
if (!problem) return ''
const kind = problem.code === 'GRAB_CLIENT_UNAVAILABLE' ? 'unavailable' : 'refused'
return problem.client
? t(`bookRequests.releases.clientProblem.${kind}`, { client: problem.client })
: t(`bookRequests.releases.clientProblem.${kind}Unnamed`)
})
const clientProblemHint = computed(() => {
const problem = clientProblem.value
if (!problem) return ''
if (problem.code === 'GRAB_CLIENT_UNAVAILABLE') return t('bookRequests.releases.clientProblem.unavailableHint')
return problem.reason ?? t('bookRequests.releases.clientProblem.refusedHint')
})
/** A retry is the same picker; only the framing changes, so the approver knows why it reopened. */
const isRetry = computed(() => request.value?.status === 'failed')
const failureText = computed(() => (request.value ? requestFailureText(request.value, (key, named) => t(key, named)) : null))
@@ -733,6 +760,10 @@ function startFreshSearch(): void {
forgetRefusals()
}
function handleRetryClient() {
void retryClient()
}
function handleRefresh() {
startFreshSearch()
if (!Number.isInteger(requestId.value)) return
@@ -1138,6 +1169,26 @@ function showEverySource(): void {
{{ t('bookRequests.releases.cached') }}
</p>
<!--
One notice for the client rather than a chip on every row it disables: the client is not a
property of any release, and the way out is the same whichever row was sent.
-->
<div
v-if="clientProblem && !finished"
role="alert"
class="mt-3 flex flex-wrap items-start gap-x-3 gap-y-2 rounded-lg border border-destructive/40 bg-destructive/10 p-3 text-sm"
>
<CircleAlert :size="16" class="mt-0.5 shrink-0 text-destructive" aria-hidden="true" />
<div class="min-w-0 flex-1 basis-56">
<p class="font-medium text-foreground">{{ clientProblemTitle }}</p>
<p class="mt-0.5 text-xs break-words text-muted-foreground">{{ clientProblemHint }}</p>
</div>
<Button variant="outline" size="sm" :disabled="busy" @click="handleRetryClient">
<RefreshCw :size="14" aria-hidden="true" />
{{ t('bookRequests.releases.clientProblem.retry') }}
</Button>
</div>
<!-- Where a pair ends once neither half needs a release: said here, not left to a passing toast. -->
<div v-if="finished" role="status" class="mt-10 flex flex-col items-center gap-3 text-center">
<span class="grid size-11 place-items-center rounded-full bg-success/15 text-success">
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { findGrabRefusal, releaseInspectionBlocksGrab } from '@bookorbit/types'
import type {
BookRequestFailureMeta,
BookRequestItem,
DownloadDelivery,
GrabBookRequestPayload,
@@ -13,13 +14,31 @@ import type {
ReleaseFileInspection,
} from '@bookorbit/types'
/** Only the refusals that rule another release out get a note; the rest are about one row. */
/**
* Only the refusals that rule another release out get a note; the rest are about one row. A client
* refusal is not about any row, so it is said once above the list instead of on every one.
*/
const REFUSAL_KEYS: Partial<Record<GrabFailureCode, string>> = {
GRAB_SOURCE_REFUSED: 'sourceRefused',
GRAB_SOURCE_UNAVAILABLE: 'sourceUnavailable',
GRAB_VIP_REQUIRED: 'vipRequired',
GRAB_CLIENT_REFUSED: 'clientRefused',
GRAB_CLIENT_UNAVAILABLE: 'clientUnavailable',
}
type ClientFailureCode = Extract<GrabFailureCode, 'GRAB_CLIENT_REFUSED' | 'GRAB_CLIENT_UNAVAILABLE'>
function isClientFailure(code: GrabFailureCode | null): code is ClientFailureCode {
return code === 'GRAB_CLIENT_REFUSED' || code === 'GRAB_CLIENT_UNAVAILABLE'
}
/** The download client that failed the last send, and that send, so it can be tried once more. */
export interface ClientProblem {
code: ClientFailureCode
/** The client's configured name, when the server said which one. */
client: string | null
/** The client's own words. Only shown for a refusal: "not responding" is all an outage needs. */
reason: string | null
body: GrabBookRequestPayload
delivery: DownloadDelivery
}
/** The refusal travels back with the call it belongs to, so one grab cannot describe another. */
@@ -27,6 +46,7 @@ export interface ReleaseGrabOutcome {
item: BookRequestItem | null
reason: string | null
errorCode: GrabFailureCode | null
errorMeta?: BookRequestFailureMeta | null
}
export interface ReleaseGrabOptions {
@@ -57,6 +77,7 @@ export function useReleaseGrab(options: ReleaseGrabOptions) {
const manualOpen = ref(false)
const refusals = ref<GrabRefusal[]>([])
const clientProblem = ref<ClientProblem | null>(null)
/**
* One release at a time, from the click rather than from the request.
*
@@ -85,6 +106,16 @@ export function useReleaseGrab(options: ReleaseGrabOptions) {
/** A fresh search is also how an approver retries a source that was merely slow. */
function forgetRefusals() {
refusals.value = []
clientProblem.value = null
}
function clientName(meta: BookRequestFailureMeta | null | undefined): string | null {
const name = meta?.client
return typeof name === 'string' && name.trim() ? name : null
}
function clientUnavailableText(client: string | null): string {
return client ? t('bookRequests.releases.clientProblem.unavailable', { client }) : t('bookRequests.releases.clientProblem.unavailableUnnamed')
}
/** A sent release leaves nothing to pick, so the drawer pops back to the request it belongs to, unless told otherwise. */
@@ -93,9 +124,18 @@ export function useReleaseGrab(options: ReleaseGrabOptions) {
const outcome = await grab(request.value.id, holdForReview.value ? { ...body, holdForReview: true } : body)
if (!outcome.item) {
rememberRefusal(body, delivery, outcome.errorCode)
const code = outcome.errorCode
if (isClientFailure(code)) {
const client = clientName(outcome.errorMeta)
clientProblem.value = { code, client, reason: outcome.reason, body, delivery }
const description = code === 'GRAB_CLIENT_UNAVAILABLE' ? clientUnavailableText(client) : outcome.reason
toast.error(t('bookRequests.errors.grabFailed'), description ? { description } : undefined)
return
}
toast.error(t('bookRequests.errors.grabFailed'), outcome.reason ? { description: outcome.reason } : undefined)
return
}
clientProblem.value = null
setRequest(outcome.item)
manualOpen.value = false
toast.success(t('bookRequests.toasts.grabbed'))
@@ -136,6 +176,23 @@ export function useReleaseGrab(options: ReleaseGrabOptions) {
void submitGrab(payload.body)
}
/**
* The same release again, once the client is back. Re-searching was the only way out before,
* which asked every tracker again to undo something none of them had said.
*/
async function retryClient(): Promise<void> {
const problem = clientProblem.value
if (!problem || grabbing.value) return
refusals.value = refusals.value.filter((refusal) => !isClientFailure(refusal.code))
clientProblem.value = null
grabbing.value = true
try {
await sendGrab(problem.body, problem.delivery)
} finally {
grabbing.value = false
}
}
/** Only a refusal the server classified; anything else says nothing about the other releases. */
function rememberRefusal(body: GrabBookRequestPayload, delivery: DownloadDelivery, code: GrabFailureCode | null) {
if (!code) return
@@ -161,12 +218,14 @@ export function useReleaseGrab(options: ReleaseGrabOptions) {
grabbing,
holdForReview,
refusals,
clientProblem,
openManual,
closeManual,
forgetRefusals,
submitGrab,
handleGrab,
handleManualGrab,
retryClient,
isRefused,
refusalText,
}
+9
View File
@@ -8869,6 +8869,15 @@
"clientRefused": "Your download client refused an earlier release",
"clientUnavailable": "Your download client did not answer an earlier release"
},
"clientProblem": {
"unavailable": "{client} is not responding",
"unavailableUnnamed": "Your download client is not responding",
"unavailableHint": "Releases that use it can't be sent until it answers. Check that it is running, then try again.",
"refused": "{client} turned down the release",
"refusedUnnamed": "Your download client turned down the release",
"refusedHint": "Releases that use it can't be sent until this is fixed.",
"retry": "Try again"
},
"singleFileNote": "A release can be one book in several formats, an audiobook in parts, or an archive - all of those import as a single book. A release holding several separate books asks you which one to keep.",
"holdForReview": "Review the download before it is filed",
"releasePageLabel": "{title}, release page on {indexer}",