mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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}",
|
||||
|
||||
Reference in New Issue
Block a user