From fcdd72a384ec67c063f30bebc90c4af04739ba16 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Sun, 13 Sep 2026 06:47:06 +0000 Subject: [PATCH] feat(book-requests): add newznab and nzbget support Support Usenet indexers via the built-in Newznab adapter and Usenet downloads via NZBGet. Generalize active download tracking from torrent client hashes to protocol-agnostic client keys, add NZB release scoring and file handling, and expose Newznab indexer and NZBGet client options in settings and grab flows. Closes #1312 --- client/src/assets/theme/tokens.css | 4 +- .../ReleaseFileInspectionPanel.spec.ts | 33 +- .../__tests__/ReleasePickerPanel.spec.ts | 52 +- .../__tests__/RequestAttemptsPanel.spec.ts | 12 +- .../__tests__/RequestDownloadProgress.spec.ts | 12 +- .../__tests__/RequestSeedPanel.spec.ts | 2 +- .../__tests__/sourceColors.spec.ts | 5 +- .../__tests__/useReleaseGrab.spec.ts | 19 +- .../components/ReleaseFileInspectionPanel.vue | 15 +- .../components/ReleasePickerPanel.vue | 70 +- .../components/RequestAttemptsPanel.vue | 15 +- .../components/RequestDownloadProgress.vue | 7 +- .../composables/useDownloadClients.ts | 8 +- .../composables/useReleaseGrab.ts | 21 +- .../features/book-requests/sourceColors.ts | 7 +- .../settings/DownloadClientsPanel.vue | 25 +- .../settings/RequestAutomationPanel.vue | 2 +- .../settings/RequestIndexersPanel.vue | 36 +- .../__tests__/DownloadClientsPanel.spec.ts | 1 + .../__tests__/RequestIndexersPanel.spec.ts | 27 +- .../components/RequestSourcesEmpty.vue | 4 +- .../settings/composables/useIndexerDraft.ts | 11 +- .../settings/composables/useIndexerPlugins.ts | 11 +- client/src/locales/en.json | 55 +- docs/BOOK_REQUESTS.md | 35 +- packages/types/src/download-client.ts | 29 +- packages/types/src/indexer.ts | 21 +- .../db/migrations/0089_add_newznab_nzbget.sql | 7 + .../src/db/migrations/meta/0089_snapshot.json | 18507 ++++++++++++++++ server/src/db/migrations/meta/_journal.json | 7 + server/src/db/schema/download-clients.ts | 10 +- .../book-request-admin.controller.ts | 2 +- .../book-request-self-fulfil.controller.ts | 2 +- .../book-request/book-request.mapper.ts | 2 +- .../book-request/book-request.module.ts | 17 +- .../adapters/client-http.utils.ts | 6 +- .../adapters/deluge.adapter.test.ts | 28 +- .../adapters/deluge.adapter.ts | 18 +- .../adapters/nzbget.adapter.test.ts | 322 + .../adapters/nzbget.adapter.ts | 275 + .../adapters/qbittorrent.adapter.test.ts | 44 +- .../adapters/qbittorrent.adapter.ts | 18 +- .../adapters/transmission.adapter.test.ts | 24 +- .../adapters/transmission.adapter.ts | 10 +- .../download-client-adapter.ts | 21 +- .../download-client-config.service.test.ts | 18 +- .../download-client-config.service.ts | 16 +- ...load-client-reconciliation.service.test.ts | 64 +- .../download-client-reconciliation.service.ts | 64 +- .../download-client.controller.ts | 12 +- .../book-request-download.repository.ts | 28 +- .../direct-download.service.test.ts | 58 +- .../fulfillment/direct-download.service.ts | 92 +- .../download-monitor.service.test.ts | 27 +- .../fulfillment/download-monitor.service.ts | 24 +- .../download-removal.service.test.ts | 2 +- .../fulfillment/download-removal.service.ts | 6 +- .../request-automation.service.test.ts | 23 +- .../fulfillment/request-automation.service.ts | 18 +- .../request-fulfillment.service.test.ts | 48 +- .../request-fulfillment.service.ts | 90 +- .../fulfillment/request-seed.service.test.ts | 16 +- .../fulfillment/request-seed.service.ts | 12 +- .../request-user-cleanup.service.test.ts | 2 +- .../request-user-cleanup.service.ts | 6 +- .../request-watchdog.service.test.ts | 10 +- .../fulfillment/request-watchdog.service.ts | 4 +- .../indexers/adapters/newznab.adapter.test.ts | 273 + .../indexers/adapters/newznab.adapter.ts | 296 + .../book-request/indexers/indexer-adapter.ts | 4 +- .../book-request/indexers/indexer-registry.ts | 10 + .../indexers/indexer-search.service.test.ts | 67 +- .../indexers/indexer-search.service.ts | 17 +- .../indexers/release-scoring.test.ts | 49 + .../book-request/indexers/release-scoring.ts | 107 +- server/test/qbittorrent-hybrid.repro.test.ts | 12 +- 76 files changed, 20792 insertions(+), 542 deletions(-) create mode 100644 server/src/db/migrations/0089_add_newznab_nzbget.sql create mode 100644 server/src/db/migrations/meta/0089_snapshot.json create mode 100644 server/src/modules/book-request/download-clients/adapters/nzbget.adapter.test.ts create mode 100644 server/src/modules/book-request/download-clients/adapters/nzbget.adapter.ts create mode 100644 server/src/modules/book-request/indexers/adapters/newznab.adapter.test.ts create mode 100644 server/src/modules/book-request/indexers/adapters/newznab.adapter.ts diff --git a/client/src/assets/theme/tokens.css b/client/src/assets/theme/tokens.css index d8ded0820..8c29e0d6f 100644 --- a/client/src/assets/theme/tokens.css +++ b/client/src/assets/theme/tokens.css @@ -162,9 +162,10 @@ --pattern-modifier: oklch(0.47 0.17 305); --pattern-optional: oklch(0.46 0.1 195); --pattern-fallback: oklch(0.48 0.13 68); - /* Release chips. Protocol is a fact about the release and keeps two fixed hues; the source hues + /* Release chips. Protocol is a fact about the release and keeps fixed hues; the source hues below are the operator's to assign. */ --pill-torrent: oklch(0.52 0.2 295); + --pill-usenet: oklch(0.47 0.13 235); --pill-direct: oklch(0.5 0.11 195); --pill-source-blue: oklch(0.55 0.16 245); @@ -382,6 +383,7 @@ --pattern-optional: oklch(0.82 0.09 195); --pattern-fallback: oklch(0.82 0.12 75); --pill-torrent: oklch(0.74 0.17 295); + --pill-usenet: oklch(0.75 0.12 235); --pill-direct: oklch(0.78 0.11 195); --pill-source-blue: oklch(0.72 0.14 245); diff --git a/client/src/features/book-requests/__tests__/ReleaseFileInspectionPanel.spec.ts b/client/src/features/book-requests/__tests__/ReleaseFileInspectionPanel.spec.ts index a23264f1d..8d2f71355 100644 --- a/client/src/features/book-requests/__tests__/ReleaseFileInspectionPanel.spec.ts +++ b/client/src/features/book-requests/__tests__/ReleaseFileInspectionPanel.spec.ts @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils' import { describe, expect, it } from 'vitest' -import type { ReleaseFileInspection } from '@bookorbit/types' +import type { DownloadDelivery, ReleaseFileInspection } from '@bookorbit/types' import ReleaseFileInspectionPanel from '../components/ReleaseFileInspectionPanel.vue' function inspection(overrides: Partial = {}): ReleaseFileInspection { @@ -20,7 +20,13 @@ function inspection(overrides: Partial = {}): ReleaseFile } function mountPanel( - overrides: Partial<{ inspection: ReleaseFileInspection | null; loading: boolean; failed: boolean; failureReason: string | null }> = {}, + overrides: Partial<{ + inspection: ReleaseFileInspection | null + delivery: DownloadDelivery + loading: boolean + failed: boolean + failureReason: string | null + }> = {}, ) { return mount(ReleaseFileInspectionPanel, { props: { inspection: inspection(), loading: false, failed: false, ...overrides }, @@ -183,6 +189,29 @@ describe('ReleaseFileInspectionPanel', () => { expect(wrapper.get('[role="status"]').text()).toContain('until a torrent client fetches the metadata from the swarm') }) + it('explains why a Usenet file list is unavailable without mentioning torrents', () => { + const wrapper = mountPanel({ + delivery: 'usenet', + inspection: inspection({ + source: 'nzb_file', + status: 'metadata_unavailable', + files: [], + totalFiles: null, + primaryFileCount: null, + }), + }) + + expect(wrapper.get('[role="status"]').text()).toContain('until NZBGet downloads and post-processes it') + expect(wrapper.text()).not.toContain('magnet') + expect(wrapper.text()).not.toContain('swarm') + }) + + it('states what inspection is doing for a Usenet release', () => { + const wrapper = mountPanel({ inspection: null, delivery: 'usenet', loading: true }) + + expect(wrapper.get('[role="status"]').text()).toContain('Fetching and validating the NZB') + }) + it('exposes loading and failed inspection states accessibly', async () => { const wrapper = mountPanel({ inspection: null, loading: true }) expect(wrapper.get('[role="status"]').text()).toContain('Reading the release file list') diff --git a/client/src/features/book-requests/__tests__/ReleasePickerPanel.spec.ts b/client/src/features/book-requests/__tests__/ReleasePickerPanel.spec.ts index 48f668396..a1d0a8414 100644 --- a/client/src/features/book-requests/__tests__/ReleasePickerPanel.spec.ts +++ b/client/src/features/book-requests/__tests__/ReleasePickerPanel.spec.ts @@ -106,6 +106,7 @@ function status(overrides: Partial = {}): IndexerSearchStat filtered: 0, query: { kind: 'titleAuthor', value: 'Spark of the Everflame Penn Cole' }, seedsBack: true, + delivery: 'torrent', ...overrides, } } @@ -131,6 +132,7 @@ const MIXED = { indexerName: 'Library Genesis', query: { kind: 'isbn', value: '9781234567897' }, seedsBack: false, + delivery: 'file', }), ], uncoveredIndexerCount: 0, @@ -165,7 +167,7 @@ const READY_INSPECTION = { /** The release list, not the source transcript above it: both are lists, so both hold `li`. */ function releaseRows(wrapper: VueWrapper): DOMWrapper[] { - return wrapper.findAll('ul[aria-label="Every other release, ranked"] > li') + return wrapper.findAll('ul[aria-label="Releases, ranked"] > li') } function changeSearch(wrapper: VueWrapper): DOMWrapper { @@ -257,7 +259,7 @@ describe('ReleasePickerPanel', () => { }) const panel = wrapper.get('section[aria-label="Active search"]') - expect(panel.text()).toContain('Nothing found for 9781234567897') + expect(panel.text()).toContain('Nothing found for Spark of the Everflame Penn Cole') expect(wrapper.get('#release-search-options').text()).toContain('9781250301697') // The panel is the empty state now, so nothing repeats it under the list. expect(wrapper.text()).not.toContain('No release matched this request.') @@ -488,8 +490,48 @@ describe('ReleasePickerPanel', () => { expect(chipClass(libgen, 'Direct')).toContain('--pill-direct') }) + it('labels a Newznab release as Usenet without showing swarm counts', async () => { + const search = { + ...MIXED, + indexers: [status({ seedsBack: false, delivery: 'usenet' })], + releases: [release({ seeders: null })], + } + + const row = releaseRows(await render(search))[0]! + expect(row.text()).toContain('Usenet') + expect(row.text()).not.toContain('Seeders') + expect( + row + .findAll('span') + .find((span) => span.text() === 'Usenet') + ?.attributes('class'), + ).toContain('--pill-usenet') + expect(row.text()).toContain('About contents') + }) + + it('headlines the title and author query that actually returned a Newznab fallback', async () => { + const search = { + ...MIXED, + indexers: [ + status({ + seedsBack: false, + delivery: 'usenet', + query: { kind: 'titleAuthor' as const, value: 'Spark of the Everflame Penn Cole' }, + }), + ], + releases: [release({ seeders: null })], + } + + const panel = (await render(search)).get('section[aria-label="Active search"]') + expect(panel.text()).toContain('Searched title and authorSpark of the Everflame Penn Cole') + expect(panel.text()).not.toContain('Searched9781234567897') + }) + it('wears the colour the operator gave the source, and stays neutral for one with none', async () => { - const search = { ...MIXED, indexers: [status({ color: 'orange' }), status({ indexerId: 2, indexerName: 'Library Genesis', seedsBack: false })] } + const search = { + ...MIXED, + indexers: [status({ color: 'orange' }), status({ indexerId: 2, indexerName: 'Library Genesis', seedsBack: false, delivery: 'file' })], + } const rows = releaseRows(await render(search)) const chipClass = (row: (typeof rows)[number], name: string) => row @@ -650,7 +692,7 @@ describe('ReleasePickerPanel', () => { it('filters to a single file', async () => { const wrapper = await render(AUDIO) - const chip = wrapper.findAll('[aria-pressed]').find((option) => option.text().startsWith('One book file')) + const chip = wrapper.findAll('[aria-pressed]').find((option) => option.text().startsWith('One listed file')) await chip!.trigger('click') const rows = releaseRows(wrapper).map((row) => row.text()) @@ -664,7 +706,7 @@ describe('ReleasePickerPanel', () => { ...AUDIO, releases: [...AUDIO.releases, release({ guid: 'silent', title: 'Unstated', fileCount: null })], }) - const chip = wrapper.findAll('[aria-pressed]').find((option) => option.text().startsWith('Several files')) + const chip = wrapper.findAll('[aria-pressed]').find((option) => option.text().startsWith('Multiple listed files')) await chip!.trigger('click') expect(releaseRows(wrapper).some((row) => row.text().includes('Unstated'))).toBe(false) diff --git a/client/src/features/book-requests/__tests__/RequestAttemptsPanel.spec.ts b/client/src/features/book-requests/__tests__/RequestAttemptsPanel.spec.ts index 82ca3f489..88bad3c90 100644 --- a/client/src/features/book-requests/__tests__/RequestAttemptsPanel.spec.ts +++ b/client/src/features/book-requests/__tests__/RequestAttemptsPanel.spec.ts @@ -21,7 +21,7 @@ function attempt(overrides: Partial = {}): BookRequestD automated: true, releaseTitle: 'Stalked by Seduction and Shadows [EPUB]', releaseSizeBytes: 2_300_000, - clientHash: null, + clientKey: null, status: 'failed', progressPercent: 0, downloadedBytes: 0, @@ -41,7 +41,7 @@ const CURRENT = attempt({ indexerId: 2, indexerName: 'Library Genesis', source: 'direct_url', - clientHash: 'a'.repeat(40), + clientKey: 'a'.repeat(40), status: 'imported', errorMessage: null, }) @@ -92,6 +92,14 @@ describe('RequestAttemptsPanel', () => { expect(text).not.toContain('Download failed') }) + it('identifies an NZB attempt as Usenet rather than a torrent', async () => { + const nzb = attempt({ id: 14, source: 'nzb_file', clientKey: 'b'.repeat(64) }) + const wrapper = await render([CURRENT, nzb]) + + expect(wrapper.find('.lucide-newspaper').exists()).toBe(true) + expect(wrapper.find('.lucide-magnet').exists()).toBe(false) + }) + /** The transfer section above is already showing it; saying it twice is not history. */ it('leaves out the attempt the request is already showing', async () => { const text = (await render([CURRENT])).text() diff --git a/client/src/features/book-requests/__tests__/RequestDownloadProgress.spec.ts b/client/src/features/book-requests/__tests__/RequestDownloadProgress.spec.ts index 9ec2052bc..d98ab8a24 100644 --- a/client/src/features/book-requests/__tests__/RequestDownloadProgress.spec.ts +++ b/client/src/features/book-requests/__tests__/RequestDownloadProgress.spec.ts @@ -18,7 +18,7 @@ function download(overrides: Partial = {}): BookRequest automated: false, releaseTitle: 'Pride and Prejudice', releaseSizeBytes: null, - clientHash: 'a'.repeat(40), + clientKey: 'a'.repeat(40), status: 'queued', progressPercent: 0, downloadedBytes: 0, @@ -123,6 +123,16 @@ describe('RequestDownloadProgress', () => { expect(pills[2]?.classes().join(' ')).toContain('--pill-source-green') }) + it('renders an NZB attempt as Usenet', () => { + const wrapper = mount(RequestDownloadProgress, { + props: { download: download({ source: 'nzb_file', downloadClientName: 'NZBGet' }), live: null }, + }) + + expect(wrapper.text()).toContain('Usenet') + expect(wrapper.text()).toContain('NZBGet') + expect(wrapper.findAll('.rounded-full.border')[1]?.classes().join(' ')).toContain('--pill-usenet') + }) + it('says a hand-pasted magnet came from no source rather than leaving a gap', () => { const wrapper = mount(RequestDownloadProgress, { props: { download: download({ indexerName: null, source: 'magnet', automated: false }), live: null }, diff --git a/client/src/features/book-requests/__tests__/RequestSeedPanel.spec.ts b/client/src/features/book-requests/__tests__/RequestSeedPanel.spec.ts index abf7d4688..10097f3d8 100644 --- a/client/src/features/book-requests/__tests__/RequestSeedPanel.spec.ts +++ b/client/src/features/book-requests/__tests__/RequestSeedPanel.spec.ts @@ -26,7 +26,7 @@ function seed(overrides: Partial = {}): BookRequestSeedSt downloadId: 11, downloadClientId: 4, downloadClientName: 'qbit', - clientHash: 'c9e15763f722f23e98a29decdfae341b98d53056', + clientKey: 'c9e15763f722f23e98a29decdfae341b98d53056', seeding: true, ratio: 1.25, ratioGoal: 2, diff --git a/client/src/features/book-requests/__tests__/sourceColors.spec.ts b/client/src/features/book-requests/__tests__/sourceColors.spec.ts index 79d2c5c3b..680a6fb66 100644 --- a/client/src/features/book-requests/__tests__/sourceColors.spec.ts +++ b/client/src/features/book-requests/__tests__/sourceColors.spec.ts @@ -26,11 +26,12 @@ describe('sourceColors', () => { expect(sourceChipClass('chartreuse' as never)).toBe(sourceChipClass(null)) }) - it('keeps the two protocol hues out of the palette an operator can assign', () => { + it('keeps protocol hues out of the palette an operator can assign', () => { const assignable = INDEXER_COLORS.map((color) => sourceChipClass(color)).join(' ') expect(assignable).not.toContain('--pill-torrent') + expect(assignable).not.toContain('--pill-usenet') expect(assignable).not.toContain('--pill-direct') - expect(protocolChipClass(true)).not.toBe(protocolChipClass(false)) + expect(new Set(['torrent', 'usenet', 'file'].map((delivery) => protocolChipClass(delivery as never))).size).toBe(3) }) }) diff --git a/client/src/features/book-requests/__tests__/useReleaseGrab.spec.ts b/client/src/features/book-requests/__tests__/useReleaseGrab.spec.ts index 36f11f6be..d5857e9b6 100644 --- a/client/src/features/book-requests/__tests__/useReleaseGrab.spec.ts +++ b/client/src/features/book-requests/__tests__/useReleaseGrab.spec.ts @@ -1,7 +1,7 @@ // @vitest-environment node import { ref } from 'vue' import { beforeEach, describe, expect, it, vi } from 'vitest' -import type { BookRequestItem, ReleaseCandidateItem, ReleaseFileInspection } from '@bookorbit/types' +import type { BookRequestItem, DownloadDelivery, ReleaseCandidateItem, ReleaseFileInspection } from '@bookorbit/types' const { toastMock } = vi.hoisted(() => ({ toastMock: { error: vi.fn<(title: string, options?: unknown) => void>(), success: vi.fn<(title: string) => void>() }, @@ -30,6 +30,7 @@ function makeGrab( overrides: { grab?: ReturnType inspectRelease?: ReturnType + deliveryFor?: (release: ReleaseCandidateItem) => DownloadDelivery } = {}, ) { const grab = overrides.grab ?? vi.fn<() => Promise>().mockResolvedValue({ item: request, reason: null, errorCode: null }) @@ -45,7 +46,7 @@ function makeGrab( setRequest, inspectRelease: inspectRelease as never, setFilesExpanded, - seedsBack: () => true, + deliveryFor: overrides.deliveryFor ?? (() => 'torrent'), }), grab, inspectRelease, @@ -154,4 +155,18 @@ describe('useReleaseGrab', () => { expect(picker.isRefused(release({ guid: 'g2' }))).toBe(true) expect(toastMock.error).toHaveBeenCalledWith('bookRequests.errors.grabFailed', { description: 'the tracker answered 406' }) }) + + it('scopes a download-client refusal to the release delivery', async () => { + const picker = makeGrab({ + grab: vi + .fn<() => Promise>() + .mockResolvedValue({ item: null, reason: 'the torrent client is offline', errorCode: 'GRAB_CLIENT_UNAVAILABLE' }), + deliveryFor: (candidate) => (candidate.indexerId === 5 ? 'usenet' : 'torrent'), + }) + + await picker.handleGrab(release({ indexerId: 9 })) + + expect(picker.isRefused(release({ indexerId: 9, guid: 'torrent-2' }))).toBe(true) + expect(picker.isRefused(release({ indexerId: 5, guid: 'usenet-1' }))).toBe(false) + }) }) diff --git a/client/src/features/book-requests/components/ReleaseFileInspectionPanel.vue b/client/src/features/book-requests/components/ReleaseFileInspectionPanel.vue index a197f7b52..ae2b26e98 100644 --- a/client/src/features/book-requests/components/ReleaseFileInspectionPanel.vue +++ b/client/src/features/book-requests/components/ReleaseFileInspectionPanel.vue @@ -3,18 +3,19 @@ import { computed } from 'vue' import { AudioLines, BookOpen, FileCheck2, FileWarning, Images, Loader2 } from '@lucide/vue' import { useI18n } from 'vue-i18n' import { releaseInspectionBlocksGrab } from '@bookorbit/types' -import type { ReleaseFileInspection, ReleaseUnitSummary } from '@bookorbit/types' +import type { DownloadDelivery, ReleaseFileInspection, ReleaseUnitSummary } from '@bookorbit/types' import { formatBytes } from '@/lib/formatting' const props = withDefaults( defineProps<{ inspection: ReleaseFileInspection | null + delivery?: DownloadDelivery loading: boolean failed: boolean /** What the tracker or the server said, where either said anything worth repeating. */ failureReason?: string | null }>(), - { failureReason: null }, + { delivery: 'torrent', failureReason: null }, ) const { t } = useI18n() @@ -62,7 +63,11 @@ function summary(): string { case 'multiple_supported_files': return t('bookRequests.releases.manifest.multipleBooks', { count: inspection.unitCount }) case 'metadata_unavailable': - return t('bookRequests.releases.manifest.metadataUnavailable') + return t( + inspection.source === 'nzb_file' + ? 'bookRequests.releases.manifest.usenetMetadataUnavailable' + : 'bookRequests.releases.manifest.metadataUnavailable', + ) } } @@ -91,7 +96,7 @@ function fileSize(sizeBytes: number | null): string | null {

diff --git a/client/src/features/book-requests/components/ReleasePickerPanel.vue b/client/src/features/book-requests/components/ReleasePickerPanel.vue index b1537ac3e..a618da4ff 100644 --- a/client/src/features/book-requests/components/ReleasePickerPanel.vue +++ b/client/src/features/book-requests/components/ReleasePickerPanel.vue @@ -14,6 +14,7 @@ import { Globe, Loader2, Magnet, + Newspaper, Pencil, Plus, RefreshCw, @@ -23,7 +24,7 @@ import { Zap, } from '@lucide/vue' import { Permission, releaseInspectionBlocksGrab } from '@bookorbit/types' -import type { IndexerSearchFailure, ReleaseCandidateItem, ReleaseFileInspection, ReleaseSearchCriteria } from '@bookorbit/types' +import type { DownloadDelivery, IndexerSearchFailure, ReleaseCandidateItem, ReleaseFileInspection, ReleaseSearchCriteria } from '@bookorbit/types' import { Button } from '@/components/ui/button' import { formatColorVar } from '@/features/book/lib/format-colors' import { formatDate, formatLanguageName } from '@/i18n/formatters' @@ -77,6 +78,7 @@ const { } = useReleasePicker(canManage) const { request, loading: requestLoading, error: requestError, fetchRequest, setRequest } = useBookRequestDetail() const { clients, fetchClients } = useDownloadClientSummaries(canManage) +const torrentClients = computed(() => clients.value.filter((client) => client.delivery === 'torrent')) const actions = useBookRequestActions(canManage) /** Near-universal for spoken audio, so stating it on every row would be noise, not information. */ @@ -132,7 +134,7 @@ const { manualOpen, grabbing, openManual, closeManual, forgetRefusals, handleGra setRequest, inspectRelease, setFilesExpanded, - seedsBack, + deliveryFor, }) /** @@ -152,6 +154,18 @@ const localOnlyCriteriaFacts = computed(() => (criteria.value ? localOnlyFacts(c * the router would not open for them. */ const canFixSources = computed(() => hasPermission(Permission.ManageAppSettings)) +/** + * The requested ISBN is not necessarily what a source ultimately searched. When every source + * used the same fallback, headline that real query instead of claiming the ISBN produced rows. + */ +const searchHeadline = computed<{ isbn: boolean; value: string }>(() => { + const queries = indexers.value.flatMap((indexer) => (indexer.query ? [indexer.query] : [])) + const first = queries[0] + if (first && queries.length === indexers.value.length && queries.every((query) => query.kind === first.kind && query.value === first.value)) { + return { isbn: first.kind === 'isbn', value: first.value } + } + return { isbn: Boolean(criteria.value?.activeIsbn), value: activeKeyText.value } +}) /** * One line per searched source, saying what it was asked and what it gave back. The query alone * was the old panel: it repeated the ISBN already stated above it, and stayed silent about the @@ -163,9 +177,7 @@ const sourceRows = computed(() => name: indexer.indexerName, query: indexer.query?.value ?? null, echoesKey: indexer.query - ? indexer.query.kind === 'isbn' - ? indexer.query.value === criteria.value?.activeIsbn - : !criteria.value?.activeIsbn + ? indexer.query.value === searchHeadline.value.value && (indexer.query.kind === 'isbn') === searchHeadline.value.isbn : false, isbnQuery: indexer.query?.kind === 'isbn', ok: indexer.ok, @@ -299,6 +311,7 @@ const summaryFacts = computed(() => { * source omitting a count, but a plain HTTP library that has no swarm to count. */ const swarmByIndexer = computed(() => new Map(indexers.value.map((indexer) => [indexer.indexerId, indexer.seedsBack]))) +const deliveryByIndexer = computed(() => new Map(indexers.value.map((indexer) => [indexer.indexerId, indexer.delivery]))) const { facetGroups } = useReleaseFacetGroups(filters, colorByIndexer, allFreeleech) @@ -425,6 +438,21 @@ function seedsBack(release: ReleaseCandidateItem): boolean { return swarmByIndexer.value.get(release.indexerId) ?? release.seeders !== null } +function deliveryFor(release: ReleaseCandidateItem): DownloadDelivery { + return deliveryByIndexer.value.get(release.indexerId) ?? (seedsBack(release) ? 'torrent' : 'file') +} + +function protocolText(release: ReleaseCandidateItem): string { + return t(`bookRequests.releases.protocol.${deliveryFor(release) === 'file' ? 'directPill' : deliveryFor(release)}`) +} + +function fileToggleText(release: ReleaseCandidateItem): string { + if (deliveryFor(release) === 'usenet') { + return t(filesAreExpanded(release) ? 'bookRequests.releases.hideDetails' : 'bookRequests.releases.viewDetails') + } + return t(filesAreExpanded(release) ? 'bookRequests.releases.hideFiles' : 'bookRequests.releases.viewFiles') +} + function indexerChipClass(release: ReleaseCandidateItem): string { return sourceChipClass(colorByIndexer.value.get(release.indexerId)) } @@ -589,7 +617,7 @@ function showEverySource(): void {