mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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> = {}): ReleaseFileInspection {
|
||||
@@ -20,7 +20,13 @@ function inspection(overrides: Partial<ReleaseFileInspection> = {}): 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')
|
||||
|
||||
@@ -106,6 +106,7 @@ function status(overrides: Partial<IndexerSearchStatus> = {}): 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<Element>[] {
|
||||
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<Element> {
|
||||
@@ -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)
|
||||
|
||||
@@ -21,7 +21,7 @@ function attempt(overrides: Partial<BookRequestDownloadItem> = {}): 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()
|
||||
|
||||
@@ -18,7 +18,7 @@ function download(overrides: Partial<BookRequestDownloadItem> = {}): 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 },
|
||||
|
||||
@@ -26,7 +26,7 @@ function seed(overrides: Partial<BookRequestSeedStatus> = {}): BookRequestSeedSt
|
||||
downloadId: 11,
|
||||
downloadClientId: 4,
|
||||
downloadClientName: 'qbit',
|
||||
clientHash: 'c9e15763f722f23e98a29decdfae341b98d53056',
|
||||
clientKey: 'c9e15763f722f23e98a29decdfae341b98d53056',
|
||||
seeding: true,
|
||||
ratio: 1.25,
|
||||
ratioGoal: 2,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<typeof vi.fn>
|
||||
inspectRelease?: ReturnType<typeof vi.fn>
|
||||
deliveryFor?: (release: ReleaseCandidateItem) => DownloadDelivery
|
||||
} = {},
|
||||
) {
|
||||
const grab = overrides.grab ?? vi.fn<() => Promise<ReleaseGrabOutcome>>().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<ReleaseGrabOutcome>>()
|
||||
.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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
<div class="mt-3 rounded-lg border border-border bg-background/60 p-3">
|
||||
<p v-if="loading" role="status" class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Loader2 class="size-3.5 animate-spin" aria-hidden="true" />
|
||||
{{ t('bookRequests.releases.inspectingFiles') }}
|
||||
{{ t(props.delivery === 'usenet' ? 'bookRequests.releases.inspectingNzb' : 'bookRequests.releases.inspectingFiles') }}
|
||||
</p>
|
||||
|
||||
<p v-else-if="failed" role="alert" class="flex items-start gap-2 text-xs text-destructive">
|
||||
@@ -100,7 +105,7 @@ function fileSize(sizeBytes: number | null): string | null {
|
||||
<!-- The tracker's own sentence where there is one: "VIP torrent and you are not VIP or
|
||||
higher" says what to do next, where the generic line says only that something broke. -->
|
||||
<span class="block">{{ props.failureReason ?? t('bookRequests.releases.inspectionFailed') }}</span>
|
||||
<span class="mt-0.5 block text-muted-foreground">{{ t('bookRequests.releases.inspectionFailedHint') }}</span>
|
||||
<span class="mt-0.5 block text-muted-foreground">{{ t('bookRequests.releases.inspectionFailedHintGeneric') }}</span>
|
||||
</span>
|
||||
</p>
|
||||
|
||||
|
||||
@@ -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<SourceRow[]>(() =>
|
||||
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 {
|
||||
<RefreshCw :size="14" aria-hidden="true" />
|
||||
{{ t('bookRequests.releases.refresh') }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" @click="openManual">{{ t('bookRequests.releases.manual') }}</Button>
|
||||
<Button variant="ghost" size="sm" @click="openManual">{{ t('bookRequests.releases.manualTorrent') }}</Button>
|
||||
</template>
|
||||
</RequestDrawerToolbar>
|
||||
|
||||
@@ -647,16 +675,16 @@ function showEverySource(): void {
|
||||
class="min-w-0 text-sm font-medium text-foreground"
|
||||
>
|
||||
<template #key>
|
||||
<span :class="criteria.activeIsbn && 'font-mono tabular-nums'">{{ activeKeyText }}</span>
|
||||
<span :class="searchHeadline.isbn && 'font-mono tabular-nums'">{{ searchHeadline.value }}</span>
|
||||
</template>
|
||||
</i18n-t>
|
||||
<p v-else class="flex min-w-0 items-center gap-2 text-sm">
|
||||
<Search :size="14" class="shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<span class="shrink-0 text-muted-foreground">
|
||||
{{ t(criteria.activeIsbn ? 'bookRequests.releases.criteria.searched' : 'bookRequests.releases.criteria.searchedTitleAuthor') }}
|
||||
{{ t(searchHeadline.isbn ? 'bookRequests.releases.criteria.searched' : 'bookRequests.releases.criteria.searchedTitleAuthor') }}
|
||||
</span>
|
||||
<span class="truncate text-foreground" :class="criteria.activeIsbn && 'font-mono tabular-nums'" :title="activeKeyText">
|
||||
{{ activeKeyText }}
|
||||
<span class="truncate text-foreground" :class="searchHeadline.isbn && 'font-mono tabular-nums'" :title="searchHeadline.value">
|
||||
{{ searchHeadline.value }}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
@@ -968,7 +996,7 @@ function showEverySource(): void {
|
||||
<ArrowUpRight :size="14" aria-hidden="true" />
|
||||
</RouterLink>
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" @click="openManual">{{ t('bookRequests.releases.manual') }}</Button>
|
||||
<Button variant="ghost" size="sm" @click="openManual">{{ t('bookRequests.releases.manualTorrent') }}</Button>
|
||||
</RequestEmptyState>
|
||||
|
||||
<!--
|
||||
@@ -986,7 +1014,7 @@ function showEverySource(): void {
|
||||
<RefreshCw :size="14" aria-hidden="true" />
|
||||
{{ t('bookRequests.releases.refresh') }}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" @click="openManual">{{ t('bookRequests.releases.manual') }}</Button>
|
||||
<Button variant="ghost" size="sm" @click="openManual">{{ t('bookRequests.releases.manualTorrent') }}</Button>
|
||||
</RequestEmptyState>
|
||||
|
||||
<template v-else-if="releases.length > 0">
|
||||
@@ -1133,11 +1161,12 @@ function showEverySource(): void {
|
||||
<div class="mt-1 flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<span
|
||||
class="inline-flex items-center gap-1 rounded-full border px-1.5 py-px font-medium"
|
||||
:class="protocolChipClass(seedsBack(release))"
|
||||
:class="protocolChipClass(deliveryFor(release))"
|
||||
>
|
||||
<Magnet v-if="seedsBack(release)" :size="11" aria-hidden="true" />
|
||||
<Magnet v-if="deliveryFor(release) === 'torrent'" :size="11" aria-hidden="true" />
|
||||
<Newspaper v-else-if="deliveryFor(release) === 'usenet'" :size="11" aria-hidden="true" />
|
||||
<Globe v-else :size="11" aria-hidden="true" />
|
||||
{{ seedsBack(release) ? t('bookRequests.releases.protocol.torrent') : t('bookRequests.releases.protocol.directPill') }}
|
||||
{{ protocolText(release) }}
|
||||
</span>
|
||||
<!--
|
||||
Neutral until an operator says otherwise. There is no closed set of
|
||||
@@ -1243,7 +1272,7 @@ function showEverySource(): void {
|
||||
<Button variant="ghost" size="sm" :aria-expanded="filesAreExpanded(release)" @click="toggleFiles(release)">
|
||||
<EyeOff v-if="filesAreExpanded(release)" :size="14" aria-hidden="true" />
|
||||
<Eye v-else :size="14" aria-hidden="true" />
|
||||
{{ filesAreExpanded(release) ? t('bookRequests.releases.hideFiles') : t('bookRequests.releases.viewFiles') }}
|
||||
{{ fileToggleText(release) }}
|
||||
</Button>
|
||||
<Button
|
||||
:variant="isBest(index) ? 'default' : 'outline'"
|
||||
@@ -1267,6 +1296,7 @@ function showEverySource(): void {
|
||||
<ReleaseFileInspectionPanel
|
||||
v-if="filesAreExpanded(release)"
|
||||
:inspection="inspectionFor(release)"
|
||||
:delivery="deliveryFor(release)"
|
||||
:loading="isInspecting(release)"
|
||||
:failed="inspectionHasFailed(release)"
|
||||
:failure-reason="inspectionFailureReason(release)"
|
||||
@@ -1277,7 +1307,13 @@ function showEverySource(): void {
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<GrabReleaseDialog :request="manualOpen ? request : null" :clients="clients" :busy="busy" @close="closeManual" @grab="handleManualGrab" />
|
||||
<GrabReleaseDialog
|
||||
:request="manualOpen ? request : null"
|
||||
:clients="torrentClients"
|
||||
:busy="busy"
|
||||
@close="closeManual"
|
||||
@grab="handleManualGrab"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ChevronDown, Globe, Loader2, Magnet } from '@lucide/vue'
|
||||
import type { BookRequestDownloadItem, BookRequestItem } from '@bookorbit/types'
|
||||
import { ChevronDown, Globe, Loader2, Magnet, Newspaper } from '@lucide/vue'
|
||||
import { DELIVERY_BY_DOWNLOAD_SOURCE, type BookRequestDownloadItem, type BookRequestItem } from '@bookorbit/types'
|
||||
import { formatDate } from '@/i18n/formatters'
|
||||
import { formatBytes } from '@/lib/formatting'
|
||||
import { useRequestAttempts } from '../composables/useRequestAttempts'
|
||||
@@ -35,11 +35,11 @@ watch(
|
||||
)
|
||||
|
||||
/**
|
||||
* A refused attempt never reached a client, which is what the missing hash means. Calling that
|
||||
* A refused attempt never reached a client, which is what the missing key means. Calling that
|
||||
* "download failed" would say something that did not happen: nothing was ever downloaded.
|
||||
*/
|
||||
function outcomeText(attempt: BookRequestDownloadItem): string {
|
||||
if (attempt.status === 'failed' && attempt.clientHash === null) return t('bookRequests.attempts.refused')
|
||||
if (attempt.status === 'failed' && attempt.clientKey === null) return t('bookRequests.attempts.refused')
|
||||
return t(`bookRequests.download.status.${attempt.status}`)
|
||||
}
|
||||
|
||||
@@ -47,10 +47,6 @@ function isRefusal(attempt: BookRequestDownloadItem): boolean {
|
||||
return attempt.status === 'failed'
|
||||
}
|
||||
|
||||
function seedsBack(attempt: BookRequestDownloadItem): boolean {
|
||||
return attempt.source !== 'direct_url'
|
||||
}
|
||||
|
||||
function facts(attempt: BookRequestDownloadItem): string[] {
|
||||
const parts = [attempt.indexerName ?? t('bookRequests.attempts.handPicked')]
|
||||
if (attempt.releaseSizeBytes !== null) parts.push(formatBytes(attempt.releaseSizeBytes))
|
||||
@@ -83,7 +79,8 @@ function facts(attempt: BookRequestDownloadItem): string[] {
|
||||
class="inline-flex items-center gap-1 rounded-full border px-1.5 py-px text-xs font-medium"
|
||||
:class="isRefusal(attempt) ? 'border-destructive/40 bg-destructive/10 text-destructive' : 'border-border text-muted-foreground'"
|
||||
>
|
||||
<Magnet v-if="seedsBack(attempt)" :size="11" aria-hidden="true" />
|
||||
<Magnet v-if="DELIVERY_BY_DOWNLOAD_SOURCE[attempt.source] === 'torrent'" :size="11" aria-hidden="true" />
|
||||
<Newspaper v-else-if="DELIVERY_BY_DOWNLOAD_SOURCE[attempt.source] === 'usenet'" :size="11" aria-hidden="true" />
|
||||
<Globe v-else :size="11" aria-hidden="true" />
|
||||
{{ outcomeText(attempt) }}
|
||||
</span>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { RefreshCw } from '@lucide/vue'
|
||||
import { DELIVERY_BY_DOWNLOAD_SOURCE } from '@bookorbit/types'
|
||||
import type { BookRequestDownloadItem, BookRequestProgressEvent } from '@bookorbit/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatPercent } from '@/i18n/formatters'
|
||||
@@ -46,9 +47,9 @@ const showCancel = computed(() => showBar.value && props.canCancel)
|
||||
* the one thing an approver asks about it afterwards is where it came from - not least because
|
||||
* the release that worked is often not the one the request started with.
|
||||
*/
|
||||
const isTorrent = computed(() => props.download.source !== 'direct_url')
|
||||
const delivery = computed(() => DELIVERY_BY_DOWNLOAD_SOURCE[props.download.source])
|
||||
const sourceName = computed(() => props.download.indexerName ?? t('bookRequests.download.source.pasted'))
|
||||
const protocolName = computed(() => t(isTorrent.value ? 'bookRequests.releases.protocol.torrent' : 'bookRequests.releases.protocol.direct'))
|
||||
const protocolName = computed(() => t(`bookRequests.releases.protocol.${delivery.value === 'file' ? 'direct' : delivery.value}`))
|
||||
const selectionName = computed(() => t(props.download.automated ? 'bookRequests.download.source.automatic' : 'bookRequests.download.source.manual'))
|
||||
|
||||
const sizeLine = computed(() => {
|
||||
@@ -105,7 +106,7 @@ function handleCancel() {
|
||||
<span class="rounded-full border px-1.5 py-px font-medium" :class="sourceChipClass(download.indexerColor)">
|
||||
{{ sourceName }}
|
||||
</span>
|
||||
<span class="rounded-full border px-1.5 py-px font-medium" :class="protocolChipClass(isTorrent)">
|
||||
<span class="rounded-full border px-1.5 py-px font-medium" :class="protocolChipClass(delivery)">
|
||||
{{ protocolName }}
|
||||
</span>
|
||||
<span
|
||||
|
||||
@@ -155,10 +155,10 @@ export function useDownloadClients() {
|
||||
}
|
||||
}
|
||||
|
||||
async function adopt(clientId: number, infoHash: string, downloadId: number): Promise<DownloadClientFailure | null> {
|
||||
async function adopt(clientId: number, clientKey: string, downloadId: number): Promise<DownloadClientFailure | null> {
|
||||
markReconciling(clientId, true)
|
||||
try {
|
||||
const res = await api(`${BASE_PATH}/${clientId}/reconciliation/${encodeURIComponent(infoHash)}/adopt`, {
|
||||
const res = await api(`${BASE_PATH}/${clientId}/reconciliation/${encodeURIComponent(clientKey)}/adopt`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ downloadId }),
|
||||
@@ -172,10 +172,10 @@ export function useDownloadClients() {
|
||||
}
|
||||
}
|
||||
|
||||
async function removeOrphan(clientId: number, infoHash: string): Promise<DownloadClientFailure | null> {
|
||||
async function removeOrphan(clientId: number, clientKey: string): Promise<DownloadClientFailure | null> {
|
||||
markReconciling(clientId, true)
|
||||
try {
|
||||
const res = await api(`${BASE_PATH}/${clientId}/reconciliation/${encodeURIComponent(infoHash)}`, {
|
||||
const res = await api(`${BASE_PATH}/${clientId}/reconciliation/${encodeURIComponent(clientKey)}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ deleteFiles: false }),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { toast } from 'vue-sonner'
|
||||
import { findGrabRefusal, releaseInspectionBlocksGrab } from '@bookorbit/types'
|
||||
import type {
|
||||
BookRequestItem,
|
||||
DownloadDelivery,
|
||||
GrabBookRequestPayload,
|
||||
GrabFailureCode,
|
||||
GrabRefusal,
|
||||
@@ -36,8 +37,8 @@ export interface ReleaseGrabOptions {
|
||||
inspectRelease: (requestId: number, release: ReleaseCandidateItem) => Promise<ReleaseFileInspection | null>
|
||||
/** Opens a row's file list, which is where a blocked inspection explains itself. */
|
||||
setFilesExpanded: (release: ReleaseCandidateItem, expanded: boolean) => void
|
||||
/** Whether this release's source joins a swarm, which decides if a client refusal generalises. */
|
||||
seedsBack: (release: ReleaseCandidateItem) => boolean
|
||||
/** Which download path this release uses. */
|
||||
deliveryFor: (release: ReleaseCandidateItem) => DownloadDelivery
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,7 +51,7 @@ export function useReleaseGrab(options: ReleaseGrabOptions) {
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { request, requestId, grab, setRequest, inspectRelease, setFilesExpanded, seedsBack } = options
|
||||
const { request, requestId, grab, setRequest, inspectRelease, setFilesExpanded, deliveryFor } = options
|
||||
|
||||
const manualOpen = ref(false)
|
||||
const refusals = ref<GrabRefusal[]>([])
|
||||
@@ -79,11 +80,11 @@ export function useReleaseGrab(options: ReleaseGrabOptions) {
|
||||
}
|
||||
|
||||
/** A sent release leaves nothing to pick, so the drawer pops back to the request it belongs to. */
|
||||
async function sendGrab(body: GrabBookRequestPayload) {
|
||||
async function sendGrab(body: GrabBookRequestPayload, delivery: DownloadDelivery) {
|
||||
if (!request.value) return
|
||||
const outcome = await grab(request.value.id, body)
|
||||
if (!outcome.item) {
|
||||
rememberRefusal(body, outcome.errorCode)
|
||||
rememberRefusal(body, delivery, outcome.errorCode)
|
||||
toast.error(t('bookRequests.errors.grabFailed'), outcome.reason ? { description: outcome.reason } : undefined)
|
||||
return
|
||||
}
|
||||
@@ -97,7 +98,7 @@ export function useReleaseGrab(options: ReleaseGrabOptions) {
|
||||
if (grabbing.value) return
|
||||
grabbing.value = true
|
||||
try {
|
||||
await sendGrab(body)
|
||||
await sendGrab(body, 'torrent')
|
||||
} finally {
|
||||
grabbing.value = false
|
||||
}
|
||||
@@ -116,7 +117,7 @@ export function useReleaseGrab(options: ReleaseGrabOptions) {
|
||||
setFilesExpanded(release, true)
|
||||
return
|
||||
}
|
||||
await sendGrab({ indexerId: release.indexerId, releaseGuid: release.guid })
|
||||
await sendGrab({ indexerId: release.indexerId, releaseGuid: release.guid }, deliveryFor(release))
|
||||
} finally {
|
||||
grabbing.value = false
|
||||
}
|
||||
@@ -127,13 +128,13 @@ export function useReleaseGrab(options: ReleaseGrabOptions) {
|
||||
}
|
||||
|
||||
/** Only a refusal the server classified; anything else says nothing about the other releases. */
|
||||
function rememberRefusal(body: GrabBookRequestPayload, code: GrabFailureCode | null) {
|
||||
function rememberRefusal(body: GrabBookRequestPayload, delivery: DownloadDelivery, code: GrabFailureCode | null) {
|
||||
if (!code) return
|
||||
refusals.value = [...refusals.value, { indexerId: body.indexerId ?? null, code }]
|
||||
refusals.value = [...refusals.value, { indexerId: body.indexerId ?? null, delivery, code }]
|
||||
}
|
||||
|
||||
function refusalFor(release: ReleaseCandidateItem): GrabRefusal | null {
|
||||
return findGrabRefusal({ indexerId: release.indexerId, vipOnly: release.vipOnly, seedsBack: seedsBack(release) }, refusals.value)
|
||||
return findGrabRefusal({ indexerId: release.indexerId, vipOnly: release.vipOnly, delivery: deliveryFor(release) }, refusals.value)
|
||||
}
|
||||
|
||||
function isRefused(release: ReleaseCandidateItem): boolean {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { INDEXER_COLORS, type IndexerColor } from '@bookorbit/types'
|
||||
import { INDEXER_COLORS, type DownloadDelivery, type IndexerColor } from '@bookorbit/types'
|
||||
|
||||
/**
|
||||
* Written out rather than built from the slug, because Tailwind reads these files as text: a class
|
||||
@@ -37,6 +37,7 @@ const DOT_CLASSES: Record<IndexerColor, string> = {
|
||||
*/
|
||||
const PROTOCOL_CLASSES = {
|
||||
torrent: 'border-[var(--pill-torrent)]/40 bg-[var(--pill-torrent)]/10 text-[var(--pill-torrent)]',
|
||||
usenet: 'border-[var(--pill-usenet)]/40 bg-[var(--pill-usenet)]/10 text-[var(--pill-usenet)]',
|
||||
direct: 'border-[var(--pill-direct)]/40 bg-[var(--pill-direct)]/10 text-[var(--pill-direct)]',
|
||||
} as const
|
||||
|
||||
@@ -56,6 +57,6 @@ export function sourceDotClass(color: IndexerColor | null | undefined): string {
|
||||
return isIndexerColor(color) ? DOT_CLASSES[color] : 'bg-muted-foreground/40'
|
||||
}
|
||||
|
||||
export function protocolChipClass(seedsBack: boolean): string {
|
||||
return seedsBack ? PROTOCOL_CLASSES.torrent : PROTOCOL_CLASSES.direct
|
||||
export function protocolChipClass(delivery: DownloadDelivery): string {
|
||||
return delivery === 'file' ? PROTOCOL_CLASSES.direct : PROTOCOL_CLASSES[delivery]
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
DownloadClientReconciliationItem,
|
||||
DownloadClientType,
|
||||
IndexerColor,
|
||||
UpdateDownloadClientPayload,
|
||||
} from '@bookorbit/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
|
||||
@@ -87,7 +88,7 @@ const testingId = ref<number | null>(null)
|
||||
const passwordVisible = ref(false)
|
||||
const hardlinkResults = reactive<Record<string, string>>({})
|
||||
const fieldErrors = reactive<Partial<Record<FieldKey, string>>>({})
|
||||
const pendingOrphanRemoval = ref<{ clientId: number; infoHash: string; name: string } | null>(null)
|
||||
const pendingOrphanRemoval = ref<{ clientId: number; clientKey: string; name: string } | null>(null)
|
||||
|
||||
/**
|
||||
* Server codes carry the copy; the English `message` is a last resort for anything unmapped, which
|
||||
@@ -264,11 +265,10 @@ function filledMappings(current: ClientDraft): MappingDraft[] {
|
||||
.map((mapping) => ({ remotePath: mapping.remotePath.trim(), localPath: mapping.localPath.trim() }))
|
||||
}
|
||||
|
||||
function toPayload(current: ClientDraft): CreateDownloadClientPayload {
|
||||
return {
|
||||
function toPayload(current: ClientDraft): CreateDownloadClientPayload | UpdateDownloadClientPayload {
|
||||
const payload: UpdateDownloadClientPayload = {
|
||||
name: current.name.trim(),
|
||||
color: current.color,
|
||||
adapterType: current.adapterType,
|
||||
baseUrl: current.baseUrl.trim(),
|
||||
username: current.username.trim(),
|
||||
...(current.passwordCleared ? { password: '' } : current.passwordTouched ? { password: current.password } : {}),
|
||||
@@ -279,6 +279,7 @@ function toPayload(current: ClientDraft): CreateDownloadClientPayload {
|
||||
allowPrivateAddress: current.allowPrivateAddress,
|
||||
pathMappings: filledMappings(current),
|
||||
}
|
||||
return current.id === null ? { ...payload, adapterType: current.adapterType } : payload
|
||||
}
|
||||
|
||||
const editingClient = computed(() => (draft.value?.id === null ? null : (clients.value.find((row) => row.id === draft.value?.id) ?? null)))
|
||||
@@ -378,13 +379,13 @@ async function handleReconcile(client: DownloadClientItem) {
|
||||
}
|
||||
|
||||
async function handleAdopt(client: DownloadClientItem, item: DownloadClientReconciliationItem, attempt: DownloadClientReconciliationAttempt) {
|
||||
const failure = await adopt(client.id, item.infoHash, attempt.downloadId)
|
||||
const failure = await adopt(client.id, item.clientKey, attempt.downloadId)
|
||||
if (failure) toast.error(describeFailure(failure))
|
||||
else toast.success(t('settings.system.requests.reconciliation.adopted', { title: attempt.requestTitle }))
|
||||
}
|
||||
|
||||
function requestOrphanRemoval(client: DownloadClientItem, item: DownloadClientReconciliationItem) {
|
||||
pendingOrphanRemoval.value = { clientId: client.id, infoHash: item.infoHash, name: item.name }
|
||||
pendingOrphanRemoval.value = { clientId: client.id, clientKey: item.clientKey, name: item.name }
|
||||
}
|
||||
|
||||
function cancelOrphanRemoval() {
|
||||
@@ -394,7 +395,7 @@ function cancelOrphanRemoval() {
|
||||
async function confirmOrphanRemoval() {
|
||||
const pending = pendingOrphanRemoval.value
|
||||
if (!pending) return
|
||||
const failure = await removeOrphan(pending.clientId, pending.infoHash)
|
||||
const failure = await removeOrphan(pending.clientId, pending.clientKey)
|
||||
if (failure) toast.error(describeFailure(failure))
|
||||
else {
|
||||
toast.success(t('settings.system.requests.reconciliation.removed'))
|
||||
@@ -534,11 +535,11 @@ async function confirmOrphanRemoval() {
|
||||
{{ t('settings.system.requests.reconciliation.clean') }}
|
||||
</p>
|
||||
<ul v-else class="space-y-2">
|
||||
<li v-for="item in reconciliationIssues(client.id)" :key="item.infoHash" class="rounded-lg border border-border p-3">
|
||||
<li v-for="item in reconciliationIssues(client.id)" :key="item.clientKey" class="rounded-lg border border-border p-3">
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-foreground">{{ item.name }}</p>
|
||||
<p class="mt-0.5 break-all font-mono text-xs text-muted-foreground">{{ item.infoHash }}</p>
|
||||
<p class="mt-0.5 break-all font-mono text-xs text-muted-foreground">{{ item.clientKey }}</p>
|
||||
<p class="mt-1 text-xs text-muted-foreground">
|
||||
{{
|
||||
item.trackedAttempt
|
||||
@@ -669,7 +670,11 @@ async function confirmOrphanRemoval() {
|
||||
</template>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField v-if="hasTypeChoice" :label="t('settings.system.requests.fields.type')" input-id="download-client-type">
|
||||
<SettingsField
|
||||
v-if="hasTypeChoice && draft.id === null"
|
||||
:label="t('settings.system.requests.fields.type')"
|
||||
input-id="download-client-type"
|
||||
>
|
||||
<select id="download-client-type" v-model="draft.adapterType" class="settings-control">
|
||||
<option v-for="type in DOWNLOAD_CLIENT_TYPES" :key="type" :value="type">
|
||||
{{ t(`settings.system.requests.clientTypes.${type}`) }}
|
||||
|
||||
@@ -412,7 +412,7 @@ function handleVerificationChange(enabled: boolean) {
|
||||
</summary>
|
||||
<div class="mt-2 space-y-2 settings-prose">
|
||||
<p class="settings-hint">{{ t('settings.system.requests.automation.autoGrabHint') }}</p>
|
||||
<p class="settings-hint">{{ t('settings.system.requests.automation.minScoreHint') }}</p>
|
||||
<p class="settings-hint">{{ t('settings.system.requests.automation.minScoreHintV2') }}</p>
|
||||
<p class="settings-hint">{{ t('settings.system.requests.automation.autoRetryHint') }}</p>
|
||||
<p class="settings-hint">{{ t('settings.system.requests.automation.maxAttemptsHint') }}</p>
|
||||
<p class="settings-hint">{{ t('settings.system.requests.automation.autoSearchHint') }}</p>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Loader2, Plug, Plus, Server as ServerIcon, Trash2, TriangleAlert, Upload } from '@lucide/vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { BOOK_REQUEST_MEDIA_KINDS } from '@bookorbit/types'
|
||||
import { BOOK_REQUEST_MEDIA_KINDS, INDEXER_ADAPTER_TYPES } from '@bookorbit/types'
|
||||
import type { IndexerAdapterDescriptor, IndexerItem, IndexerSettingsField } from '@bookorbit/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
@@ -72,6 +72,7 @@ const {
|
||||
describeFailure,
|
||||
startCreate,
|
||||
startCreateFor,
|
||||
handleCreateTypeChange,
|
||||
startEdit,
|
||||
cancelEdit,
|
||||
handleNameInput,
|
||||
@@ -107,7 +108,7 @@ const {
|
||||
removingPlugin,
|
||||
pluginPendingRemoval,
|
||||
pluginRows,
|
||||
torznabRows,
|
||||
builtInRows,
|
||||
nothingConfigured,
|
||||
allSourcesDisabled,
|
||||
editingPluginType,
|
||||
@@ -283,9 +284,8 @@ function handleTestCurrent() {
|
||||
/>
|
||||
|
||||
<!--
|
||||
Two things, added two ways. A plugin is a file you install and then fill in; a Torznab indexer
|
||||
is an address you already have. Everything else on this page used to be the seam between those
|
||||
two being explained rather than shown.
|
||||
Two things, added two ways. A plugin is a file you install and then fill in; a built-in
|
||||
indexer is an address you already have.
|
||||
-->
|
||||
<!-- A wider gap than anything inside a group takes, so the two labels read as a boundary. -->
|
||||
<div v-else class="space-y-6">
|
||||
@@ -441,7 +441,7 @@ function handleTestCurrent() {
|
||||
<div>
|
||||
<div class="flex min-h-8 items-center justify-between gap-2">
|
||||
<h2 id="request-indexers-heading" class="settings-group-label mb-0">{{ t('settings.system.requests.indexers.title') }}</h2>
|
||||
<Button v-if="torznabRows.length" size="sm" variant="outline" @click="startCreate">
|
||||
<Button v-if="builtInRows.length" size="sm" variant="outline" @click="startCreate">
|
||||
<Plus :size="14" aria-hidden="true" />
|
||||
{{ t('settings.system.requests.indexers.add') }}
|
||||
</Button>
|
||||
@@ -450,7 +450,7 @@ function handleTestCurrent() {
|
||||
<p class="settings-hint settings-prose mt-1.5">{{ t('settings.system.requests.indexers.hint') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!torznabRows.length" class="settings-empty-state flex flex-wrap items-center gap-x-4 gap-y-3 px-4 py-3.5 text-start md:px-5">
|
||||
<div v-if="!builtInRows.length" class="settings-empty-state flex flex-wrap items-center gap-x-4 gap-y-3 px-4 py-3.5 text-start md:px-5">
|
||||
<span class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground" aria-hidden="true">
|
||||
<ServerIcon :size="17" />
|
||||
</span>
|
||||
@@ -464,7 +464,7 @@ function handleTestCurrent() {
|
||||
</div>
|
||||
|
||||
<ul v-else class="space-y-2">
|
||||
<li v-for="indexer in torznabRows" :key="indexer.id" class="settings-card">
|
||||
<li v-for="indexer in builtInRows" :key="indexer.id" class="settings-card">
|
||||
<RequestSourceRow
|
||||
:indexer="indexer"
|
||||
:testing="testingId === indexer.id"
|
||||
@@ -544,10 +544,22 @@ function handleTestCurrent() {
|
||||
|
||||
<template #default>
|
||||
<SettingsSection :title="t('settings.system.requests.sections.connection')">
|
||||
<!-- The type is settled in the picker before this form exists, and is fixed afterwards:
|
||||
a row carries that adapter's base URL, categories and settings, so swapping it here
|
||||
would leave every one of them describing an adapter that no longer applies. It is
|
||||
stated in the header badge instead. -->
|
||||
<SettingsField v-if="draft.id === null" :label="t('settings.system.requests.indexers.fields.type')" input-id="indexer-type">
|
||||
<template #default="{ describedBy }">
|
||||
<select
|
||||
id="indexer-type"
|
||||
:value="draft.adapterType"
|
||||
class="settings-control"
|
||||
:aria-describedby="describedBy"
|
||||
@change="handleCreateTypeChange"
|
||||
>
|
||||
<option v-for="type in INDEXER_ADAPTER_TYPES" :key="type" :value="type">
|
||||
{{ t(`settings.system.requests.indexers.types.${type}`) }}
|
||||
</option>
|
||||
</select>
|
||||
</template>
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField :label="t('settings.system.requests.indexers.fields.name')" input-id="indexer-name" required :error="fieldErrors.name">
|
||||
<template #default="{ describedBy, invalid }">
|
||||
<input
|
||||
|
||||
@@ -128,6 +128,7 @@ describe('DownloadClientsPanel', () => {
|
||||
'qbittorrent',
|
||||
'transmission',
|
||||
'deluge',
|
||||
'nzbget',
|
||||
])
|
||||
expect(sheet().querySelector('#download-client-name')).toBeNull()
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ function descriptor(overrides: Partial<IndexerAdapterDescriptor> = {}): IndexerA
|
||||
mediaKinds: ['ebook', 'audiobook', 'comic'],
|
||||
usesCategories: true,
|
||||
seedsBack: true,
|
||||
delivery: 'torrent',
|
||||
supportsIsbnSearch: false,
|
||||
defaultCategories: { ebook: [7020], audiobook: [3030], comic: [7030] },
|
||||
settingsFields: [],
|
||||
@@ -44,8 +45,8 @@ function descriptor(overrides: Partial<IndexerAdapterDescriptor> = {}): IndexerA
|
||||
|
||||
/**
|
||||
* An open library: no credential, no categories, nothing seeded back, and an address of its own.
|
||||
* A plugin rather than a built-in, because torznab is the only built-in and it is the opposite of
|
||||
* this on every one of those.
|
||||
* A plugin rather than a built-in because it serves files directly rather than speaking one of
|
||||
* the built-in indexer protocols.
|
||||
*/
|
||||
const OPEN_LIBRARY = descriptor({
|
||||
type: 'open-library',
|
||||
@@ -55,6 +56,7 @@ const OPEN_LIBRARY = descriptor({
|
||||
mediaKinds: ['ebook'],
|
||||
usesCategories: false,
|
||||
seedsBack: false,
|
||||
delivery: 'file',
|
||||
defaultCategories: { ebook: [], audiobook: [], comic: [] },
|
||||
defaultBaseUrl: 'https://openlibrary.example',
|
||||
})
|
||||
@@ -600,18 +602,25 @@ describe('RequestIndexersPanel', () => {
|
||||
expect(torznab.findAll('button').filter((button) => button.text().includes('Add indexer'))).toHaveLength(1)
|
||||
})
|
||||
|
||||
/**
|
||||
* Two groups, because there are two things. Torznab is the only built-in, so adding an indexer
|
||||
* asks nothing about which kind it is: that question only existed because plugins shared the list.
|
||||
*/
|
||||
it('adds a torznab indexer without asking which kind it is', async () => {
|
||||
const wrapper = await mountPanel({ adapters: [descriptor(), OPEN_LIBRARY, PLUGIN] })
|
||||
it('offers every built-in indexer type for a new source', async () => {
|
||||
const newznab = descriptor({ type: 'newznab', label: 'Newznab', seedsBack: false, delivery: 'usenet' })
|
||||
const wrapper = await mountPanel({ adapters: [descriptor(), newznab, OPEN_LIBRARY, PLUGIN] })
|
||||
|
||||
await clickInPanel(wrapper, 'Add indexer')
|
||||
|
||||
expect(sheet().querySelector('input[name="indexer-adapter-type"]')).toBeNull()
|
||||
expect([...sheet().querySelectorAll<HTMLOptionElement>('#indexer-type option')].map((option) => option.value)).toEqual(['torznab', 'newznab'])
|
||||
expect(sheet().querySelector('#indexer-name')).not.toBeNull()
|
||||
expect(sheet().textContent).toContain('torznab')
|
||||
|
||||
const name = sheet().querySelector<HTMLInputElement>('#indexer-name')!
|
||||
name.value = 'My indexer'
|
||||
name.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
const type = sheet().querySelector<HTMLSelectElement>('#indexer-type')!
|
||||
type.value = 'newznab'
|
||||
type.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(sheet().querySelector<HTMLInputElement>('#indexer-name')?.value).toBe('My indexer')
|
||||
})
|
||||
|
||||
it('keeps plugins and torznab indexers in groups of their own', async () => {
|
||||
|
||||
@@ -62,10 +62,10 @@ function handleAddIndexer() {
|
||||
<div class="flex flex-col rounded-lg border border-border bg-card p-4 shadow-xs">
|
||||
<div class="flex items-center gap-2 text-muted-foreground">
|
||||
<Server :size="15" aria-hidden="true" />
|
||||
<h3 class="text-sm font-medium text-foreground">{{ t('settings.system.requests.indexers.empty.torznabTitle') }}</h3>
|
||||
<h3 class="text-sm font-medium text-foreground">{{ t('settings.system.requests.indexers.empty.builtInTitle') }}</h3>
|
||||
</div>
|
||||
<p class="mt-1.5 flex-1 text-xs leading-relaxed text-muted-foreground">
|
||||
{{ t('settings.system.requests.indexers.empty.torznabBody') }}
|
||||
{{ t('settings.system.requests.indexers.empty.builtInBody') }}
|
||||
</p>
|
||||
<div class="mt-3.5">
|
||||
<Button size="sm" @click="handleAddIndexer">
|
||||
|
||||
@@ -166,9 +166,15 @@ export function useIndexerDraft(options: IndexerDraftOptions) {
|
||||
clearFieldErrors()
|
||||
}
|
||||
|
||||
/** Torznab is the only built-in, so adding an indexer is adding a Torznab one. Nothing to pick. */
|
||||
/** Start on the first built-in; a fresh draft may switch type before any adapter-specific values are entered. */
|
||||
function startCreate() {
|
||||
openDraft(emptyDraft('torznab'))
|
||||
openDraft(emptyDraft(INDEXER_ADAPTER_TYPES[0]))
|
||||
}
|
||||
|
||||
function handleCreateTypeChange(event: Event) {
|
||||
if (draft.value?.id !== null) return
|
||||
const type = (event.target as HTMLSelectElement).value
|
||||
openDraft({ ...emptyDraft(type), name: draft.value.name })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -425,6 +431,7 @@ export function useIndexerDraft(options: IndexerDraftOptions) {
|
||||
describeFailure,
|
||||
startCreate,
|
||||
startCreateFor,
|
||||
handleCreateTypeChange,
|
||||
startEdit,
|
||||
cancelEdit,
|
||||
handleNameInput,
|
||||
|
||||
@@ -80,8 +80,8 @@ export function useIndexerPlugins(options: IndexerPluginsOptions) {
|
||||
* the wrong list. So an installed plugin nothing uses appears in this list as the row it is on the
|
||||
* way to becoming, and finishing it is a step on that row rather than a different section.
|
||||
*
|
||||
* Only a plugin does this. Torznab is compiled in and always offerable, so an unconfigured torznab
|
||||
* is not an object anyone owns and would be a permanent row for a thing that does not exist.
|
||||
* Only a plugin does this. Built-ins are always offerable, so an unconfigured adapter is not an
|
||||
* object anyone owns and would be a permanent row for a thing that does not exist.
|
||||
*/
|
||||
const pluginRows = computed<PluginRow[]>(() => {
|
||||
const configured = new Set<string>()
|
||||
@@ -104,15 +104,14 @@ export function useIndexerPlugins(options: IndexerPluginsOptions) {
|
||||
return rows
|
||||
})
|
||||
|
||||
/** Torznab is the only built-in, so this group is exactly the Prowlarr and Jackett endpoints. */
|
||||
const torznabRows = computed(() => indexers.value.filter((indexer) => isBuiltInAdapter(indexer.adapterType)))
|
||||
const builtInRows = computed(() => indexers.value.filter((indexer) => isBuiltInAdapter(indexer.adapterType)))
|
||||
|
||||
/**
|
||||
* Nothing at all, which is what a fresh install looks like and the only state that gets its own
|
||||
* panel. A plugin that failed to load counts as something: it is the one row somebody has to act
|
||||
* on, and burying it under "no sources yet" would say the opposite.
|
||||
*/
|
||||
const nothingConfigured = computed(() => pluginRows.value.length === 0 && torznabRows.value.length === 0)
|
||||
const nothingConfigured = computed(() => pluginRows.value.length === 0 && builtInRows.value.length === 0)
|
||||
|
||||
/**
|
||||
* Rows exist and not one of them is on, which searches exactly as far as having none: the rows
|
||||
@@ -304,7 +303,7 @@ export function useIndexerPlugins(options: IndexerPluginsOptions) {
|
||||
removingPlugin,
|
||||
pluginPendingRemoval,
|
||||
pluginRows,
|
||||
torznabRows,
|
||||
builtInRows,
|
||||
nothingConfigured,
|
||||
allSourcesDisabled,
|
||||
editingPluginType,
|
||||
|
||||
+35
-20
@@ -2831,8 +2831,8 @@
|
||||
"deleteClient": "Delete {name}",
|
||||
"clientsEmpty": {
|
||||
"title": "No download client yet",
|
||||
"body": "A direct HTTP download needs no client. Connect one so BookOrbit can fetch what a tracker offers as a torrent too.",
|
||||
"note": "qBittorrent, Transmission and Deluge are supported."
|
||||
"body": "A direct HTTP download needs no client. Connect one so BookOrbit can fetch releases delivered by torrent or Usenet too.",
|
||||
"note": "qBittorrent, Transmission, Deluge and NZBGet are supported."
|
||||
},
|
||||
"summary": "{type} - category {category}, priority {priority}",
|
||||
"disabled": "Disabled",
|
||||
@@ -2847,7 +2847,8 @@
|
||||
"clientTypes": {
|
||||
"qbittorrent": "qBittorrent",
|
||||
"transmission": "Transmission",
|
||||
"deluge": "Deluge"
|
||||
"deluge": "Deluge",
|
||||
"nzbget": "NZBGet"
|
||||
},
|
||||
"color": {
|
||||
"label": "Color",
|
||||
@@ -2865,14 +2866,14 @@
|
||||
"passwordKeepAction": "Keep",
|
||||
"passwordWillClear": "The stored password is removed when you save",
|
||||
"category": "Category",
|
||||
"categoryHint": "Tags the torrents BookOrbit adds, so it only ever acts on its own.",
|
||||
"categoryHint": "Tags the downloads BookOrbit adds, so it only ever acts on its own.",
|
||||
"priority": "Priority",
|
||||
"enabled": "Enabled",
|
||||
"useHardlinks": "Use hardlinks",
|
||||
"allowPrivateAddress": "Allow private addresses",
|
||||
"allowPrivateAddressHint": "Needed when the client runs on your local network or the same machine.",
|
||||
"passwordBrief": "Stored encrypted.",
|
||||
"useHardlinksBrief": "Seeds without using the disk twice. Falls back to a copy across filesystems."
|
||||
"useHardlinksBrief": "Imports without using the disk twice. Falls back to a copy across filesystems."
|
||||
},
|
||||
"mappings": {
|
||||
"title": "Path mappings",
|
||||
@@ -2934,8 +2935,8 @@
|
||||
"confirm": "Remove client"
|
||||
},
|
||||
"indexers": {
|
||||
"title": "Torrent indexers",
|
||||
"hint": "Your own Prowlarr, Jackett or NZBHydra, over Torznab. Add one per Torznab feed you want searched.",
|
||||
"title": "Built-in indexers",
|
||||
"hint": "Connect a Torznab feed for torrents or a Newznab feed for Usenet releases.",
|
||||
"allDisabled": "Every source below is switched off, so no request search will find anything. Switch at least one back on.",
|
||||
"loading": "Loading indexers...",
|
||||
"empty": {
|
||||
@@ -2946,7 +2947,9 @@
|
||||
"pluginTitle": "Install a plugin",
|
||||
"pluginBody": "One file that teaches BookOrbit to search a specific site. You supply it, then fill in its settings.",
|
||||
"torznabTitle": "Add a Torznab indexer",
|
||||
"torznabBody": "Point at a Prowlarr, Jackett or NZBHydra feed you already run. One per feed."
|
||||
"torznabBody": "Point at a Prowlarr, Jackett or NZBHydra feed you already run. One per feed.",
|
||||
"builtInTitle": "Add an indexer",
|
||||
"builtInBody": "Point at a Torznab or Newznab feed you already use. One configuration per feed."
|
||||
},
|
||||
"add": "Add indexer",
|
||||
"edit": "Edit indexer",
|
||||
@@ -2976,14 +2979,16 @@
|
||||
"proxyBrief": "An http or https proxy for this source. SOCKS is not supported."
|
||||
},
|
||||
"types": {
|
||||
"torznab": "Torznab (Jackett, Prowlarr, NZBHydra)"
|
||||
"torznab": "Torznab (Jackett, Prowlarr, NZBHydra)",
|
||||
"newznab": "Newznab"
|
||||
},
|
||||
"fields": {
|
||||
"name": "Name",
|
||||
"type": "Indexer type",
|
||||
"baseUrl": "Base URL",
|
||||
"baseUrlHint": {
|
||||
"torznab": "The torznab feed URL, without the query string. Jackett shows it on each indexer as \"Torznab Feed\"."
|
||||
"torznab": "The Torznab feed URL, without the query string. Jackett shows it on each indexer as \"Torznab Feed\".",
|
||||
"newznab": "The Newznab site URL or API endpoint, without a search query."
|
||||
},
|
||||
"credential": {
|
||||
"apiKey": "API key",
|
||||
@@ -2996,13 +3001,13 @@
|
||||
"enabled": "Enabled",
|
||||
"allowPrivateAddress": "Allow private addresses",
|
||||
"credentialBrief": "Stored encrypted.",
|
||||
"allowPrivateAddressBrief": "Off by default. Only needed for a proxy such as Jackett on your local network.",
|
||||
"allowPrivateAddressBrief": "Off by default. Only needed when the source is on your local network.",
|
||||
"searchByIsbn": "Search by ISBN",
|
||||
"searchByIsbnBrief": "On by default. Turn it off if this source returns the wrong book for an exact ISBN, and it will be searched by title and author instead."
|
||||
},
|
||||
"color": {
|
||||
"label": "Color",
|
||||
"hint": "Marks this source on every release it returns, so you can tell where a result came from before reading its name. Torrent and direct download keep their own two colors, which is why neither is offered here.",
|
||||
"hint": "Marks this source on every release it returns, so you can tell where a result came from before reading its name. Delivery protocols keep their own colors, which is why those colors are not offered here.",
|
||||
"none": "No color",
|
||||
"options": {
|
||||
"blue": "Blue",
|
||||
@@ -3055,13 +3060,14 @@
|
||||
"builtIn": "Built in",
|
||||
"plugin": "Plugin",
|
||||
"typeDescriptions": {
|
||||
"torznab": "Your own Jackett, Prowlarr or NZBHydra. Uses categories and seed goals."
|
||||
"torznab": "Your own Jackett, Prowlarr or NZBHydra. Uses categories and seed goals.",
|
||||
"newznab": "A Newznab-compatible Usenet indexer. Uses categories and requires an API key."
|
||||
},
|
||||
"plugins": {
|
||||
"title": "Plugins",
|
||||
"hint": "A site BookOrbit can search, added as a single plugin file. Install one, then fill in its settings.",
|
||||
"directoryLink": "Browse open library plugins",
|
||||
"none": "No plugin installed. Install one to search a site Torznab does not cover.",
|
||||
"none": "No plugin installed. Install one to search a site the built-in protocols do not cover.",
|
||||
"install": "Install plugin",
|
||||
"installed": "Installed {label}.",
|
||||
"installFailed": "That plugin could not be installed.",
|
||||
@@ -3133,6 +3139,7 @@
|
||||
"autoGrabDisabled": "Automatic downloads are off",
|
||||
"minScore": "Minimum release score",
|
||||
"minScoreHint": "A release scoring below this is never downloaded automatically. Scores run to 100, and how well the title and author match makes up most of one, with format, file size, seeders and source priority behind it. If nothing scores high enough, the request waits for someone to pick a release by hand and says why it stopped.",
|
||||
"minScoreHintV2": "A release scoring below this is never downloaded automatically. Scores run to 100. Title and author relevance contributes most, followed by format and plausible file size, with swarm health and freeleech used when a source provides them. If nothing scores high enough, the request waits for someone to pick a release by hand and says why it stopped.",
|
||||
"autoRetry": "Try the next release after a failure",
|
||||
"autoRetryHint": "When an automatic download fails, the next-best release is tried instead of giving up. This covers a source refusing to hand the file over and a download that dies partway through. It only applies to releases BookOrbit chose itself: one an approver picked by hand stays failed and waits for them.",
|
||||
"maxAttempts": "Attempts per request",
|
||||
@@ -3223,11 +3230,12 @@
|
||||
"external": "External",
|
||||
"editorDescription": "Settings for this download client.",
|
||||
"chooseClientType": "Choose a client type",
|
||||
"chooseClientTypeHint": "Pick which program BookOrbit hands torrents to.",
|
||||
"chooseClientTypeHint": "Pick which program BookOrbit hands downloads to.",
|
||||
"clientTypeDescriptions": {
|
||||
"qbittorrent": "Your own qBittorrent. Needs an address and an account.",
|
||||
"transmission": "Your own Transmission. It has no categories, so the category becomes a subfolder of its download directory.",
|
||||
"deluge": "Your own Deluge. Sign in with the Web UI password; the category needs the Label plugin switched on."
|
||||
"deluge": "Your own Deluge. Sign in with the Web UI password; the category needs the Label plugin switched on.",
|
||||
"nzbget": "Your own NZBGet. BookOrbit sends NZB files over its JSON-RPC API."
|
||||
},
|
||||
"searchHealth": {
|
||||
"failingOnce": "Last search failed",
|
||||
@@ -6653,7 +6661,10 @@
|
||||
"grab": "Download release",
|
||||
"viewFiles": "View files",
|
||||
"hideFiles": "Hide files",
|
||||
"viewDetails": "About contents",
|
||||
"hideDetails": "Hide details",
|
||||
"inspectingFiles": "Reading the release file list...",
|
||||
"inspectingNzb": "Fetching and validating the NZB...",
|
||||
"inspectingShort": "Checking files...",
|
||||
"inspectionFailed": "This release's file list could not be read.",
|
||||
"manifest": {
|
||||
@@ -6663,6 +6674,7 @@
|
||||
"multipleBooks": "This release contains {count} separate books. After it downloads you will be asked which one to import.",
|
||||
"multiPartBook": "{count, plural, one {This release is one book made of # file.} other {This release is one book made of # files, imported together as a single book.}}",
|
||||
"metadataUnavailable": "This is a magnet link. Its file list is unavailable until a torrent client fetches the metadata from the swarm.",
|
||||
"usenetMetadataUnavailable": "This is a Usenet release. Its final file list is unavailable until NZBGet downloads and post-processes it.",
|
||||
"bookFile": "Book file",
|
||||
"extraFile": "Extra",
|
||||
"truncated": "Showing {shown} of {total} files.",
|
||||
@@ -6677,6 +6689,7 @@
|
||||
}
|
||||
},
|
||||
"manual": "Paste a link instead",
|
||||
"manualTorrent": "Use magnet or .torrent instead",
|
||||
"indexer": "Indexer",
|
||||
"size": "Size",
|
||||
"seeders": "Seeders",
|
||||
@@ -6686,8 +6699,8 @@
|
||||
"format": "Format",
|
||||
"files": "Files",
|
||||
"fileLayout": {
|
||||
"single": "One book file",
|
||||
"multi": "Several files"
|
||||
"single": "One listed file",
|
||||
"multi": "Multiple listed files"
|
||||
},
|
||||
"fileCount": "{count, plural, one {# file} other {# files}}",
|
||||
"audio": "Audio",
|
||||
@@ -6739,10 +6752,11 @@
|
||||
"score": "Score",
|
||||
"release": "Release",
|
||||
"send": "Send",
|
||||
"tableCaption": "Every other release, ranked",
|
||||
"tableCaption": "Releases, ranked",
|
||||
"onlyBestMatch": "That is the only release that matched.",
|
||||
"tiedWithBest": "{count, plural, one {# other release scored the same} other {# other releases scored the same}}",
|
||||
"inspectionFailedHint": "It was not sent to a download client. Try again, or use Paste a link instead.",
|
||||
"inspectionFailedHintGeneric": "It was not sent to a download client. Try again, or choose another release.",
|
||||
"mediumUncovered": "No source carries this kind of book.",
|
||||
"mediumUncoveredHint": "None of your enabled indexers cover it. Add one that does, or paste a link yourself.",
|
||||
"noSourcesConfigured": "No search sources are set up yet.",
|
||||
@@ -6754,6 +6768,7 @@
|
||||
"reviewSources": "Review sources",
|
||||
"protocol": {
|
||||
"torrent": "Torrent",
|
||||
"usenet": "Usenet",
|
||||
"direct": "Direct download",
|
||||
"directPill": "Direct"
|
||||
}
|
||||
@@ -6833,7 +6848,7 @@
|
||||
},
|
||||
"confirm": {
|
||||
"cancelDownloadTitle": "Cancel this download?",
|
||||
"cancelDownloadDescription": "The active transfer stops and the request stays open so another release can be chosen. Torrent data is kept; partial direct-download staging is deleted.",
|
||||
"cancelDownloadDescription": "The active transfer stops and the request stays open so another release can be chosen. Client-managed data is kept; partial direct-download staging is deleted.",
|
||||
"cancelTitle": "Stop this request?",
|
||||
"cancelDescription": "It moves to cancelled. Nobody waiting on it will get the book.",
|
||||
"cancelDescriptionWithDownload": "The active transfer is stopped. Data in the download client is kept; partial direct-download staging is deleted.",
|
||||
|
||||
+22
-13
@@ -10,9 +10,9 @@ install has no sources, no download clients and no automation.
|
||||
## What BookOrbit ships
|
||||
|
||||
**BookOrbit ships no sources.** There is no bundled tracker, no default indexer, and no list of
|
||||
sites to pick from. One adapter type is compiled in, `torznab`, and it is a protocol rather than a
|
||||
source: it needs an address you supply, usually a Prowlarr or Jackett instance you run. Everything
|
||||
else is an indexer plugin you install yourself.
|
||||
sites to pick from. Two protocol adapters are compiled in: `torznab` for torrent indexers and
|
||||
`newznab` for Usenet indexers. Both need an address and API key you supply. Everything else is an
|
||||
indexer plugin you install yourself.
|
||||
|
||||
Which sources you point BookOrbit at, and what you search for and download through them, is your
|
||||
decision and your responsibility. BookOrbit does not provide or endorse indexer sources. Its URL,
|
||||
@@ -54,10 +54,13 @@ discover the loss during a download.
|
||||
|
||||
Settings > System > Requests > Sources.
|
||||
|
||||
Two kinds of row, added two ways, identical once they exist:
|
||||
Three kinds of row, added two ways, identical once they exist:
|
||||
|
||||
- **Torznab**: an address plus an API key. This is how you attach a Prowlarr or Jackett instance,
|
||||
and one such instance can expose many trackers behind one row per tracker.
|
||||
- **Newznab**: an address plus an API key. BookOrbit uses generic searches, including an ISBN as
|
||||
the query when enabled, and fetches the selected NZB without exposing the key to the download
|
||||
client.
|
||||
- **Plugins**: a single `.mjs` file installed through the page, loaded at boot from
|
||||
`<APP_DATA_PATH>/plugins/indexers/<name>/index.mjs`. Plugins live in their own repositories and
|
||||
are not distributed with BookOrbit.
|
||||
@@ -79,8 +82,14 @@ Two health facts are shown per row, and they answer different questions:
|
||||
|
||||
## Download clients
|
||||
|
||||
Settings > System > Requests > Download clients. qBittorrent, Transmission and Deluge are
|
||||
supported.
|
||||
Settings > System > Requests > Download clients. qBittorrent, Transmission and Deluge accept
|
||||
torrents; NZBGet accepts NZBs. A Newznab source therefore needs an enabled NZBGet client, while a
|
||||
Torznab source needs an enabled torrent client. Direct HTTP sources need no external client.
|
||||
|
||||
BookOrbit does not connect to an NNTP article server. Configure the article-server account in
|
||||
NZBGet, then give BookOrbit a separate NZBGet API account. BookOrbit fetches the credentialed NZB,
|
||||
sends its bytes to NZBGet, and monitors the item by a stable ownership key rather than a transient
|
||||
NZBGet queue ID.
|
||||
|
||||
Each client needs at least one **path mapping**, including when BookOrbit and the client run on the
|
||||
same host. A mapping translates the download directory the client reports into a path BookOrbit can
|
||||
@@ -88,10 +97,10 @@ open, and it is also the root the import is allowed to read out of. On a single
|
||||
is an identity, `/downloads -> /downloads`; it still has to be stated, because "the same path" and
|
||||
"no path stated" are different things and only one of them is safe to import from.
|
||||
|
||||
Hardlinking from the download directory into the library is what keeps a torrent seeding after the
|
||||
book is filed. The mapping form can test whether a hardlink actually works between the two paths,
|
||||
which is worth doing before the first grab: a mapping across filesystems copies instead, silently
|
||||
doubling the space every book takes.
|
||||
Hardlinking from the download directory into the library avoids storing the imported bytes twice
|
||||
and keeps a torrent seeding after the book is filed. The mapping form can test whether a hardlink
|
||||
actually works between the two paths, which is worth doing before the first grab: a mapping across
|
||||
filesystems copies instead, silently doubling the space every book takes.
|
||||
|
||||
The same mount is necessary but is not sufficient. On Linux, `fs.protected_hardlinks=1` normally
|
||||
requires the BookOrbit process user to own the completed source file or be able to write it. The
|
||||
@@ -101,7 +110,7 @@ file, so it proves that the paths and mount permit hardlinks but cannot prove th
|
||||
owned by a different user will be linkable. If the real file cannot be linked, BookOrbit falls back
|
||||
to copying it and the book uses space twice while the original keeps seeding.
|
||||
|
||||
BookOrbit never stops a seed on its own. Removing a torrent from its client is an explicit action
|
||||
BookOrbit never stops a seed on its own. Removing a client-managed download is an explicit action
|
||||
with its own confirmation, and removing one that is still working also fails the request.
|
||||
|
||||
## Automation
|
||||
@@ -160,8 +169,8 @@ resolved outside the one it has. Add or widen the mapping under Settings > Syste
|
||||
**Credentials will not save**: `BOOK_REQUEST_ENCRYPTION_KEY` is unset or is not 64 hex characters.
|
||||
|
||||
**A source shows "Searches failing" but tests fine**: the address answers a capabilities call and
|
||||
refuses searches. Usually a rate limit, an expired session, or categories that do not exist on that
|
||||
tracker.
|
||||
refuses searches. Usually a rate limit, an expired credential, or categories that do not exist on
|
||||
that indexer.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { IndexerColor, ReleaseUnitChoice } from "./indexer";
|
||||
* deliberately absent: it has no address, no credentials and nothing to choose, so making it a
|
||||
* configurable client type would only ask an operator to create a record of nothing.
|
||||
*/
|
||||
export const DOWNLOAD_CLIENT_TYPES = ["qbittorrent", "transmission", "deluge"] as const;
|
||||
export const DOWNLOAD_CLIENT_TYPES = ["qbittorrent", "transmission", "deluge", "nzbget"] as const;
|
||||
export type DownloadClientType = (typeof DOWNLOAD_CLIENT_TYPES)[number];
|
||||
|
||||
/**
|
||||
@@ -14,12 +14,13 @@ export type DownloadClientType = (typeof DOWNLOAD_CLIENT_TYPES)[number];
|
||||
* join a swarm, so this is what decides which configured client a given grab goes to rather than
|
||||
* simply taking the highest-priority enabled row.
|
||||
*/
|
||||
export type DownloadDelivery = "torrent" | "file";
|
||||
export type DownloadDelivery = "torrent" | "file" | "usenet";
|
||||
|
||||
export const DOWNLOAD_CLIENT_DELIVERY: Record<DownloadClientType, DownloadDelivery> = {
|
||||
qbittorrent: "torrent",
|
||||
transmission: "torrent",
|
||||
deluge: "torrent",
|
||||
nzbget: "usenet",
|
||||
};
|
||||
|
||||
export interface DownloadClientPathMapping {
|
||||
@@ -86,6 +87,7 @@ export interface DownloadClientSummary {
|
||||
id: number;
|
||||
name: string;
|
||||
color: IndexerColor | null;
|
||||
delivery: DownloadDelivery;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,7 +107,7 @@ export interface DownloadClientReconciliationAttempt {
|
||||
}
|
||||
|
||||
export interface DownloadClientReconciliationItem {
|
||||
infoHash: string;
|
||||
clientKey: string;
|
||||
name: string;
|
||||
state: "queued" | "downloading" | "completed" | "failed" | "unknown";
|
||||
progressPercent: number;
|
||||
@@ -145,7 +147,7 @@ export interface CreateDownloadClientPayload {
|
||||
pathMappings?: DownloadClientPathMappingInput[];
|
||||
}
|
||||
|
||||
export type UpdateDownloadClientPayload = Partial<CreateDownloadClientPayload>;
|
||||
export type UpdateDownloadClientPayload = Partial<Omit<CreateDownloadClientPayload, "adapterType">>;
|
||||
|
||||
export interface DownloadClientTestResult {
|
||||
success: boolean;
|
||||
@@ -208,7 +210,7 @@ export const UNSETTLED_BOOK_REQUEST_DOWNLOAD_STATUSES: readonly BookRequestDownl
|
||||
"needs_review",
|
||||
];
|
||||
|
||||
export const BOOK_REQUEST_DOWNLOAD_SOURCES = ["magnet", "torrent_file", "direct_url"] as const;
|
||||
export const BOOK_REQUEST_DOWNLOAD_SOURCES = ["magnet", "torrent_file", "direct_url", "nzb_file"] as const;
|
||||
export type BookRequestDownloadSource = (typeof BOOK_REQUEST_DOWNLOAD_SOURCES)[number];
|
||||
|
||||
/** Which kind of client can carry out a grab of each source. */
|
||||
@@ -216,6 +218,7 @@ export const DELIVERY_BY_DOWNLOAD_SOURCE: Record<BookRequestDownloadSource, Down
|
||||
magnet: "torrent",
|
||||
torrent_file: "torrent",
|
||||
direct_url: "file",
|
||||
nzb_file: "usenet",
|
||||
};
|
||||
|
||||
export interface BookRequestDownloadItem {
|
||||
@@ -234,7 +237,7 @@ export interface BookRequestDownloadItem {
|
||||
releaseTitle: string;
|
||||
releaseSizeBytes: number | null;
|
||||
/** Null for an attempt a source refused before there was anything to download. */
|
||||
clientHash: string | null;
|
||||
clientKey: string | null;
|
||||
status: BookRequestDownloadStatus;
|
||||
progressPercent: number;
|
||||
downloadedBytes: number;
|
||||
@@ -264,7 +267,7 @@ export interface BookRequestSeedStatus {
|
||||
downloadId: number;
|
||||
downloadClientId: number;
|
||||
downloadClientName: string | null;
|
||||
clientHash: string;
|
||||
clientKey: string;
|
||||
/** False once the client has stopped the torrent, whether by goal, by pause or by error. */
|
||||
seeding: boolean;
|
||||
ratio: number | null;
|
||||
@@ -333,6 +336,8 @@ export const SOURCE_WIDE_GRAB_FAILURE_CODES: readonly GrabFailureCode[] = ["GRAB
|
||||
export interface GrabRefusal {
|
||||
/** Null for a hand-pasted magnet or .torrent, which came from no source in the list. */
|
||||
indexerId: number | null;
|
||||
/** The delivery that failed. Client-wide refusals only apply to another release using this delivery. */
|
||||
delivery: DownloadDelivery;
|
||||
code: GrabFailureCode;
|
||||
}
|
||||
|
||||
@@ -342,16 +347,18 @@ export interface GrabRefusal {
|
||||
*
|
||||
* Shared because the failover and the picker have to agree about the same list on the same
|
||||
* screen: the automation stops trying what this rules out, and the picker stops offering it.
|
||||
* `seedsBack` is what makes a release depend on a download client, which is why a client refusing
|
||||
* one torrent says nothing about a source BookOrbit downloads from itself.
|
||||
* A client refusal is scoped to its delivery. A torrent client being unavailable says nothing
|
||||
* about an NZBGet release or a source BookOrbit downloads itself.
|
||||
*/
|
||||
export function findGrabRefusal(
|
||||
release: { indexerId: number; vipOnly: boolean; seedsBack: boolean },
|
||||
release: { indexerId: number; vipOnly: boolean; delivery: DownloadDelivery },
|
||||
refusals: readonly GrabRefusal[],
|
||||
): GrabRefusal | null {
|
||||
return (
|
||||
refusals.find((refusal) => {
|
||||
if (refusal.code === "GRAB_CLIENT_REFUSED" || refusal.code === "GRAB_CLIENT_UNAVAILABLE") return release.seedsBack;
|
||||
if (refusal.code === "GRAB_CLIENT_REFUSED" || refusal.code === "GRAB_CLIENT_UNAVAILABLE") {
|
||||
return release.delivery !== "file" && refusal.delivery === release.delivery;
|
||||
}
|
||||
if (refusal.indexerId !== release.indexerId) return false;
|
||||
if (refusal.code === "GRAB_VIP_REQUIRED") return release.vipOnly;
|
||||
return SOURCE_WIDE_GRAB_FAILURE_CODES.includes(refusal.code);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { BookRequestMediaKind } from "./book-request";
|
||||
import type { BookRequestDownloadSource } from "./download-client";
|
||||
import type { BookRequestDownloadSource, DownloadDelivery } from "./download-client";
|
||||
import type { NetworkProfile } from "./network-profile";
|
||||
import { REQUEST_CREDENTIAL_ERROR_CODES } from "./request-credential";
|
||||
|
||||
@@ -8,10 +8,10 @@ import { REQUEST_CREDENTIAL_ERROR_CODES } from "./request-credential";
|
||||
* tracker-specific beyond the type name: BookOrbit ships adapter code, never a tracker, never a
|
||||
* credential, and no indexer is preconfigured or enabled by default.
|
||||
*
|
||||
* Only the generic protocol is built in. Every named source, open library or otherwise, is a
|
||||
* Only generic protocols are built in. Every named source, open library or otherwise, is a
|
||||
* plugin loaded from disk and maintained outside this repository.
|
||||
*/
|
||||
export const INDEXER_ADAPTER_TYPES = ["torznab"] as const;
|
||||
export const INDEXER_ADAPTER_TYPES = ["torznab", "newznab"] as const;
|
||||
export type IndexerAdapterType = (typeof INDEXER_ADAPTER_TYPES)[number];
|
||||
|
||||
/** Maximum size of an opaque release identifier accepted from an indexer feed and grab request. */
|
||||
@@ -53,16 +53,19 @@ export type IndexerCategoryMap = Record<BookRequestMediaKind, number[]>;
|
||||
*/
|
||||
export const DEFAULT_INDEXER_CATEGORIES: Record<IndexerAdapterType, IndexerCategoryMap> = {
|
||||
torznab: { ebook: [7020], audiobook: [3030], comic: [7030] },
|
||||
newznab: { ebook: [7020], audiobook: [3030], comic: [7030] },
|
||||
};
|
||||
|
||||
/** What the credential field holds. Null where the source needs none, as an open library does. */
|
||||
export const INDEXER_CREDENTIAL_KINDS: Record<IndexerAdapterType, "apiKey" | "sessionId" | null> = {
|
||||
torznab: "apiKey",
|
||||
newznab: "apiKey",
|
||||
};
|
||||
|
||||
/** Whether the adapter searches by numeric category at all, which decides if the editor shows. */
|
||||
export const INDEXER_USES_CATEGORIES: Record<IndexerAdapterType, boolean> = {
|
||||
torznab: true,
|
||||
newznab: true,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -72,6 +75,13 @@ export const INDEXER_USES_CATEGORIES: Record<IndexerAdapterType, boolean> = {
|
||||
*/
|
||||
export const INDEXER_SEEDS_BACK: Record<IndexerAdapterType, boolean> = {
|
||||
torznab: true,
|
||||
newznab: false,
|
||||
};
|
||||
|
||||
/** How a selected release from each built-in reaches BookOrbit. */
|
||||
export const INDEXER_DELIVERY: Record<IndexerAdapterType, DownloadDelivery> = {
|
||||
torznab: "torrent",
|
||||
newznab: "usenet",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -80,6 +90,7 @@ export const INDEXER_SEEDS_BACK: Record<IndexerAdapterType, boolean> = {
|
||||
*/
|
||||
export const INDEXER_MEDIA_KINDS: Record<IndexerAdapterType, readonly BookRequestMediaKind[]> = {
|
||||
torznab: ["ebook", "audiobook", "comic"],
|
||||
newznab: ["ebook", "audiobook", "comic"],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -135,6 +146,8 @@ export interface IndexerAdapterDescriptor {
|
||||
mediaKinds: BookRequestMediaKind[];
|
||||
usesCategories: boolean;
|
||||
seedsBack: boolean;
|
||||
/** Which kind of downloader can accept a release from this source. */
|
||||
delivery: DownloadDelivery;
|
||||
/**
|
||||
* Whether this adapter can search an ISBN at all. The operator decides per source whether it
|
||||
* should, because a catalogue that answers an ISBN badly is worse than one that cannot.
|
||||
@@ -432,6 +445,8 @@ export interface IndexerSearchStatus {
|
||||
* are the same silence and completely different facts.
|
||||
*/
|
||||
seedsBack: boolean;
|
||||
/** Which downloader capability a selected release from this source requires. */
|
||||
delivery: DownloadDelivery;
|
||||
}
|
||||
|
||||
/** Request metadata used across indexer retrieval, hard filters, and release scoring. */
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE "book_request_downloads" RENAME COLUMN "client_hash" TO "client_key";--> statement-breakpoint
|
||||
ALTER TABLE "book_request_downloads" DROP CONSTRAINT "book_request_downloads_source_chk";--> statement-breakpoint
|
||||
ALTER TABLE "download_clients" DROP CONSTRAINT "download_clients_adapter_type_chk";--> statement-breakpoint
|
||||
DROP INDEX "book_request_downloads_active_hash_uidx";--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "book_request_downloads_active_hash_uidx" ON "book_request_downloads" USING btree (coalesce("download_client_id", 0),"client_key") WHERE "book_request_downloads"."status" in ('queued', 'downloading', 'completed', 'importing', 'needs_review');--> statement-breakpoint
|
||||
ALTER TABLE "book_request_downloads" ADD CONSTRAINT "book_request_downloads_source_chk" CHECK ("book_request_downloads"."source" in ('magnet', 'torrent_file', 'direct_url', 'nzb_file'));--> statement-breakpoint
|
||||
ALTER TABLE "download_clients" ADD CONSTRAINT "download_clients_adapter_type_chk" CHECK ("download_clients"."adapter_type" in ('qbittorrent', 'transmission', 'deluge', 'nzbget'));
|
||||
File diff suppressed because it is too large
Load Diff
@@ -624,6 +624,13 @@
|
||||
"when": 1789098337223,
|
||||
"tag": "0088_audiobook_contract_v2",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 89,
|
||||
"version": "7",
|
||||
"when": 1789273281909,
|
||||
"tag": "0089_add_newznab_nzbget",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ export const downloadClients = pgTable(
|
||||
(t) => [
|
||||
uniqueIndex('download_clients_name_lower_uidx').on(sql`lower(${t.name})`),
|
||||
index('download_clients_enabled_priority_idx').on(t.enabled, t.priority),
|
||||
check('download_clients_adapter_type_chk', sql`${t.adapterType} in ('qbittorrent', 'transmission', 'deluge')`),
|
||||
check('download_clients_adapter_type_chk', sql`${t.adapterType} in ('qbittorrent', 'transmission', 'deluge', 'nzbget')`),
|
||||
|
||||
check('download_clients_priority_range_chk', sql`${t.priority} >= 1 and ${t.priority} <= 100`),
|
||||
],
|
||||
@@ -108,7 +108,7 @@ export const bookRequestDownloads = pgTable(
|
||||
* Null for an attempt that was refused before anything was handed over: a tracker that would
|
||||
* not serve the .torrent leaves a record of having been asked, and nothing to poll.
|
||||
*/
|
||||
clientHash: varchar('client_hash', { length: 64 }),
|
||||
clientKey: varchar('client_key', { length: 64 }),
|
||||
status: varchar('status', { length: 20 }).notNull().default('queued'),
|
||||
progressPercent: integer('progress_percent').notNull().default(0),
|
||||
downloadedBytes: bigint('downloaded_bytes', { mode: 'number' }).notNull().default(0),
|
||||
@@ -152,7 +152,7 @@ export const bookRequestDownloads = pgTable(
|
||||
// transaction share a timestamp, and the id is what puts them in order.
|
||||
index('book_request_downloads_request_latest_idx').on(t.requestId, t.id.desc()),
|
||||
index('book_request_downloads_book_dock_file_id_idx').on(t.bookDockFileId),
|
||||
// One live attempt per infohash per client: re-grabbing a torrent the client is already
|
||||
// One live attempt per client key per client: re-grabbing a release the client is already
|
||||
// working on would produce two rows racing to import the same file. Coalesced because the
|
||||
// built-in downloader has no client row, and two direct grabs of one URL would otherwise
|
||||
// both be let through to write the same staging file.
|
||||
@@ -163,10 +163,10 @@ export const bookRequestDownloads = pgTable(
|
||||
// failure cleanup or its removal then deletes the files the held attempt's import is waiting
|
||||
// to read.
|
||||
uniqueIndex('book_request_downloads_active_hash_uidx')
|
||||
.on(sql`coalesce(${t.downloadClientId}, 0)`, t.clientHash)
|
||||
.on(sql`coalesce(${t.downloadClientId}, 0)`, t.clientKey)
|
||||
|
||||
.where(sql`${t.status} in ('queued', 'downloading', 'completed', 'importing', 'needs_review')`),
|
||||
check('book_request_downloads_source_chk', sql`${t.source} in ('magnet', 'torrent_file', 'direct_url')`),
|
||||
check('book_request_downloads_source_chk', sql`${t.source} in ('magnet', 'torrent_file', 'direct_url', 'nzb_file')`),
|
||||
check(
|
||||
'book_request_downloads_status_chk',
|
||||
sql`${t.status} in ('queued', 'downloading', 'completed', 'importing', 'needs_review', 'imported', 'failed')`,
|
||||
|
||||
@@ -68,7 +68,7 @@ export class BookRequestAdminController {
|
||||
*/
|
||||
@Get('download-clients')
|
||||
listDownloadClients() {
|
||||
return this.downloadClients.findEnabledSummaries('torrent');
|
||||
return this.downloadClients.findEnabledSummaries();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -48,7 +48,7 @@ export class BookRequestSelfFulfilController {
|
||||
/** Names and ids only, so the grab dialog can offer a choice it is allowed to act on. */
|
||||
@Get('download-clients')
|
||||
listDownloadClients() {
|
||||
return this.downloadClients.findEnabledSummaries('torrent');
|
||||
return this.downloadClients.findEnabledSummaries();
|
||||
}
|
||||
|
||||
@Get(':id/releases')
|
||||
|
||||
@@ -88,7 +88,7 @@ export function mapBookRequestDownload({
|
||||
automated: download.automated,
|
||||
releaseTitle: download.releaseTitle,
|
||||
releaseSizeBytes: download.releaseSizeBytes,
|
||||
clientHash: download.clientHash,
|
||||
clientKey: download.clientKey,
|
||||
status: download.status as BookRequestDownloadStatus,
|
||||
progressPercent: download.progressPercent,
|
||||
downloadedBytes: download.downloadedBytes,
|
||||
|
||||
@@ -33,6 +33,7 @@ import { PathMappingService } from './download-clients/path-mapping.service';
|
||||
import { QbittorrentAdapter } from './download-clients/adapters/qbittorrent.adapter';
|
||||
import { TransmissionAdapter } from './download-clients/adapters/transmission.adapter';
|
||||
import { DelugeAdapter } from './download-clients/adapters/deluge.adapter';
|
||||
import { NzbgetAdapter } from './download-clients/adapters/nzbget.adapter';
|
||||
import { INDEXER_ADAPTERS } from './indexers/indexer-adapter';
|
||||
import { IndexerConfigService } from './indexers/indexer-config.service';
|
||||
import { IndexerController } from './indexers/indexer.controller';
|
||||
@@ -45,6 +46,7 @@ import { PluginInstallService } from './indexers/plugins/plugin-install.service'
|
||||
import { IndexerRepository } from './indexers/indexer.repository';
|
||||
import { IndexerSearchService } from './indexers/indexer-search.service';
|
||||
import { TorznabAdapter } from './indexers/adapters/torznab.adapter';
|
||||
import { NewznabAdapter } from './indexers/adapters/newznab.adapter';
|
||||
import { BookRequestDownloadRepository } from './fulfillment/book-request-download.repository';
|
||||
import { DirectDownloadService } from './fulfillment/direct-download.service';
|
||||
import { DownloadMonitorService } from './fulfillment/download-monitor.service';
|
||||
@@ -107,10 +109,16 @@ import { RequestWatchdogService } from './fulfillment/request-watchdog.service';
|
||||
QbittorrentAdapter,
|
||||
TransmissionAdapter,
|
||||
DelugeAdapter,
|
||||
NzbgetAdapter,
|
||||
{
|
||||
provide: DOWNLOAD_CLIENT_ADAPTERS,
|
||||
useFactory: (qbittorrent: QbittorrentAdapter, transmission: TransmissionAdapter, deluge: DelugeAdapter) => [qbittorrent, transmission, deluge],
|
||||
inject: [QbittorrentAdapter, TransmissionAdapter, DelugeAdapter],
|
||||
useFactory: (qbittorrent: QbittorrentAdapter, transmission: TransmissionAdapter, deluge: DelugeAdapter, nzbget: NzbgetAdapter) => [
|
||||
qbittorrent,
|
||||
transmission,
|
||||
deluge,
|
||||
nzbget,
|
||||
],
|
||||
inject: [QbittorrentAdapter, TransmissionAdapter, DelugeAdapter, NzbgetAdapter],
|
||||
},
|
||||
|
||||
IndexerRepository,
|
||||
@@ -123,10 +131,11 @@ import { RequestWatchdogService } from './fulfillment/request-watchdog.service';
|
||||
IndexerKeepaliveService,
|
||||
IndexerOperationLock,
|
||||
TorznabAdapter,
|
||||
NewznabAdapter,
|
||||
{
|
||||
provide: INDEXER_ADAPTERS,
|
||||
useFactory: (torznab: TorznabAdapter) => [torznab],
|
||||
inject: [TorznabAdapter],
|
||||
useFactory: (torznab: TorznabAdapter, newznab: NewznabAdapter) => [torznab, newznab],
|
||||
inject: [TorznabAdapter, NewznabAdapter],
|
||||
},
|
||||
|
||||
BookRequestDownloadRepository,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BadRequestException, ServiceUnavailableException } from '@nestjs/common';
|
||||
|
||||
import { readBoundedText, ResponseTooLargeError } from '../../../../common/utils/bounded-response';
|
||||
import { safeFetch, type SafeFetchOptions } from '../../../../common/utils/safe-fetch';
|
||||
|
||||
/** Every adapter talks to somebody else's daemon over the network, so none of them may hang. */
|
||||
const CLIENT_REQUEST_TIMEOUT_MS = 20_000;
|
||||
@@ -36,11 +37,12 @@ export function endpointUrl(base: URL, path: string): URL {
|
||||
* `fetch` reports them as the same opaque `TypeError`. `label` is the client's own name so the
|
||||
* settings form says which daemon went quiet when several are configured.
|
||||
*/
|
||||
export async function fetchClient(url: URL, init: RequestInit, label: string): Promise<Response> {
|
||||
export async function fetchClient(url: URL, init: RequestInit, label: string, safety?: SafeFetchOptions): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), CLIENT_REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
return await fetch(url, { ...init, signal: controller.signal, redirect: 'manual' });
|
||||
const request = { ...init, signal: controller.signal, redirect: 'manual' as const };
|
||||
return safety ? await safeFetch(url.href, request, safety) : await fetch(url, request);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
throw new ServiceUnavailableException(`${label} did not answer in time`);
|
||||
|
||||
@@ -173,8 +173,8 @@ describe('DelugeAdapter', () => {
|
||||
const { calls, handlers } = mockRpc();
|
||||
handlers.set('core.add_torrent_magnet', () => result(INFO_HASH));
|
||||
|
||||
await expect(adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, infoHash: INFO_HASH }, config())).resolves.toEqual({
|
||||
clientHash: INFO_HASH,
|
||||
await expect(adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, clientKey: INFO_HASH }, config())).resolves.toEqual({
|
||||
clientKey: INFO_HASH,
|
||||
});
|
||||
|
||||
const add = calls.find((call) => call.method === 'core.add_torrent_magnet');
|
||||
@@ -186,7 +186,7 @@ describe('DelugeAdapter', () => {
|
||||
const { calls, handlers } = mockRpc();
|
||||
handlers.set('core.add_torrent_file', () => result(INFO_HASH));
|
||||
|
||||
await adapter.add({ torrentFile: Buffer.from('d4:infod4:name4:duneee'), torrentFileName: 'dune.torrent', infoHash: INFO_HASH }, config());
|
||||
await adapter.add({ torrentFile: Buffer.from('d4:infod4:name4:duneee'), torrentFileName: 'dune.torrent', clientKey: INFO_HASH }, config());
|
||||
|
||||
const add = calls.find((call) => call.method === 'core.add_torrent_file');
|
||||
expect(add?.params[0]).toBe('dune.torrent');
|
||||
@@ -197,7 +197,7 @@ describe('DelugeAdapter', () => {
|
||||
const { calls, handlers } = mockRpc();
|
||||
handlers.set('core.add_torrent_magnet', () => result(INFO_HASH));
|
||||
|
||||
await adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, infoHash: INFO_HASH, seedRatioGoal: 2 }, config());
|
||||
await adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, clientKey: INFO_HASH, seedRatioGoal: 2 }, config());
|
||||
|
||||
expect(calls.find((call) => call.method === 'core.add_torrent_magnet')?.params[1]).toEqual({
|
||||
add_paused: false,
|
||||
@@ -217,8 +217,8 @@ describe('DelugeAdapter', () => {
|
||||
handlers.set('core.add_torrent_magnet', () => failure('Torrent already in session'));
|
||||
handlers.set('core.get_torrents_status', () => result({ [INFO_HASH]: { hash: INFO_HASH } }));
|
||||
|
||||
await expect(adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, infoHash: INFO_HASH }, config())).resolves.toEqual({
|
||||
clientHash: INFO_HASH,
|
||||
await expect(adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, clientKey: INFO_HASH }, config())).resolves.toEqual({
|
||||
clientKey: INFO_HASH,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -227,12 +227,12 @@ describe('DelugeAdapter', () => {
|
||||
handlers.set('core.add_torrent_magnet', () => failure('Unable to add torrent'));
|
||||
handlers.set('core.get_torrents_status', () => result({}));
|
||||
|
||||
await expect(adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, infoHash: INFO_HASH }, config())).rejects.toThrow(BadRequestException);
|
||||
await expect(adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, clientKey: INFO_HASH }, config())).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('rejects a grab carrying neither a magnet nor a file', async () => {
|
||||
mockRpc();
|
||||
await expect(adapter.add({ infoHash: INFO_HASH }, config())).rejects.toThrow(BadRequestException);
|
||||
await expect(adapter.add({ clientKey: INFO_HASH }, config())).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
describe('labels', () => {
|
||||
@@ -241,7 +241,7 @@ describe('DelugeAdapter', () => {
|
||||
handlers.set('core.get_enabled_plugins', () => result(['Label']));
|
||||
handlers.set('core.add_torrent_magnet', () => result(INFO_HASH));
|
||||
|
||||
await adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, infoHash: INFO_HASH }, config());
|
||||
await adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, clientKey: INFO_HASH }, config());
|
||||
|
||||
expect(calls.find((call) => call.method === 'label.add')?.params).toEqual(['bookorbit']);
|
||||
expect(calls.find((call) => call.method === 'label.set_torrent')?.params).toEqual([INFO_HASH, 'bookorbit']);
|
||||
@@ -253,7 +253,7 @@ describe('DelugeAdapter', () => {
|
||||
handlers.set('core.get_enabled_plugins', () => result(['Label']));
|
||||
handlers.set('core.add_torrent_magnet', () => result(INFO_HASH));
|
||||
|
||||
await adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, infoHash: INFO_HASH }, config({ category: 'Book Orbit v2.0' }));
|
||||
await adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, clientKey: INFO_HASH }, config({ category: 'Book Orbit v2.0' }));
|
||||
|
||||
expect(calls.find((call) => call.method === 'label.add')?.params).toEqual(['book-orbit-v2-0']);
|
||||
});
|
||||
@@ -262,7 +262,7 @@ describe('DelugeAdapter', () => {
|
||||
const { calls, handlers } = mockRpc();
|
||||
handlers.set('core.add_torrent_magnet', () => result(INFO_HASH));
|
||||
|
||||
await adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, infoHash: INFO_HASH }, config());
|
||||
await adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, clientKey: INFO_HASH }, config());
|
||||
|
||||
expect(calls.some((call) => call.method.startsWith('label.'))).toBe(false);
|
||||
});
|
||||
@@ -277,8 +277,8 @@ describe('DelugeAdapter', () => {
|
||||
handlers.set('core.add_torrent_magnet', () => result(INFO_HASH));
|
||||
handlers.set('label.set_torrent', () => failure('Unknown label'));
|
||||
|
||||
await expect(adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, infoHash: INFO_HASH }, config())).resolves.toEqual({
|
||||
clientHash: INFO_HASH,
|
||||
await expect(adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, clientKey: INFO_HASH }, config())).resolves.toEqual({
|
||||
clientKey: INFO_HASH,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -310,7 +310,7 @@ describe('DelugeAdapter', () => {
|
||||
|
||||
await expect(adapter.status([INFO_HASH], config())).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
infoHash: INFO_HASH,
|
||||
clientKey: INFO_HASH,
|
||||
state: 'downloading',
|
||||
progressPercent: 50,
|
||||
downloadedBytes: 512,
|
||||
|
||||
@@ -108,7 +108,7 @@ export class DelugeAdapter implements DownloadClientAdapter {
|
||||
private readonly sessions = new Map<number, DelugeSession>();
|
||||
private requestId = 0;
|
||||
|
||||
async add(release: GrabPayload, config: ResolvedClientConfig): Promise<{ clientHash: string }> {
|
||||
async add(release: GrabPayload, config: ResolvedClientConfig): Promise<{ clientKey: string }> {
|
||||
if (!release.torrentFile && !release.magnet) {
|
||||
throw new BadRequestException('A grab needs either a magnet link or a .torrent file');
|
||||
}
|
||||
@@ -131,28 +131,28 @@ export class DelugeAdapter implements DownloadClientAdapter {
|
||||
options,
|
||||
])
|
||||
: await this.rpc<string | null>(config, 'core.add_torrent_magnet', [release.magnet, options]);
|
||||
hash = (added ?? release.infoHash).toLowerCase();
|
||||
hash = (added ?? release.clientKey).toLowerCase();
|
||||
} catch (error) {
|
||||
// Deluge refuses a torrent it already holds outright rather than adopting it. An earlier
|
||||
// attempt on this release leaves its torrent behind when the import fails, and without this
|
||||
// every retry of that release is refused forever. Only the client can say which case it is.
|
||||
if (!(await this.holds(release.infoHash, config))) throw error;
|
||||
if (!(await this.holds(release.clientKey, config))) throw error;
|
||||
this.logger.log(
|
||||
`[download_client.add] [end] clientId=${config.id} hash=${release.infoHash.toLowerCase()} adopted=true - the client already held this torrent`,
|
||||
`[download_client.add] [end] clientId=${config.id} hash=${release.clientKey.toLowerCase()} adopted=true - the client already held this torrent`,
|
||||
);
|
||||
hash = release.infoHash.toLowerCase();
|
||||
hash = release.clientKey.toLowerCase();
|
||||
}
|
||||
|
||||
await this.applyLabel(hash, config);
|
||||
return { clientHash: hash };
|
||||
return { clientKey: hash };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the client is already holding this exact infohash. Any failure to find out answers
|
||||
* "no", so an unreachable client surfaces the add failure rather than a false success.
|
||||
*/
|
||||
private async holds(infoHash: string, config: ResolvedClientConfig): Promise<boolean> {
|
||||
const hash = infoHash.toLowerCase();
|
||||
private async holds(clientKey: string, config: ResolvedClientConfig): Promise<boolean> {
|
||||
const hash = clientKey.toLowerCase();
|
||||
try {
|
||||
const result = await this.rpc<Record<string, DelugeTorrent> | null>(config, 'core.get_torrents_status', [{ id: [hash] }, ['hash']]);
|
||||
return Object.keys(result ?? {}).some((key) => key.toLowerCase() === hash);
|
||||
@@ -388,7 +388,7 @@ function toDownloadStatus(hash: string, entry: DelugeTorrent): DownloadStatus {
|
||||
const state = mapState(entry);
|
||||
const tracker = trackerFailure(entry);
|
||||
return {
|
||||
infoHash: hash,
|
||||
clientKey: hash,
|
||||
state,
|
||||
progressPercent: Math.max(0, Math.min(100, Math.round(entry.progress ?? 0))),
|
||||
downloadedBytes: entry.total_done ?? 0,
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
vi.mock('undici', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('undici')>()),
|
||||
fetch: (...args: Parameters<typeof fetch>) => fetch(...args),
|
||||
}));
|
||||
|
||||
vi.mock('dns/promises', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('dns/promises')>()),
|
||||
lookup: vi.fn(() => Promise.resolve([{ address: '127.0.0.1', family: 4 }])),
|
||||
}));
|
||||
|
||||
import type { ResolvedClientConfig } from '../download-client-adapter';
|
||||
import { forgetDispatchers } from '../../../../common/utils/safe-fetch';
|
||||
import { NzbgetAdapter } from './nzbget.adapter';
|
||||
|
||||
const CLIENT_KEY = 'a'.repeat(64);
|
||||
|
||||
function config(overrides: Partial<ResolvedClientConfig> = {}): ResolvedClientConfig {
|
||||
return {
|
||||
id: 9,
|
||||
name: 'local nzbget',
|
||||
adapterType: 'nzbget',
|
||||
baseUrl: 'http://127.0.0.1:6789',
|
||||
username: 'nzbget',
|
||||
password: 'password',
|
||||
category: 'bookorbit',
|
||||
allowPrivateAddress: true,
|
||||
settings: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
interface RpcCall {
|
||||
url: string;
|
||||
method: string;
|
||||
params: unknown[];
|
||||
authorization: string | null;
|
||||
}
|
||||
|
||||
function mockRpc(results: Record<string, unknown>) {
|
||||
const calls: RpcCall[] = [];
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn((url: URL | string, init: RequestInit = {}) => {
|
||||
const body = JSON.parse(init.body as string) as { method: string; params: unknown[] };
|
||||
calls.push({
|
||||
url: url.toString(),
|
||||
method: body.method,
|
||||
params: body.params,
|
||||
authorization: new Headers(init.headers).get('authorization'),
|
||||
});
|
||||
return Promise.resolve(new Response(JSON.stringify({ result: results[body.method] ?? null, error: null })));
|
||||
}),
|
||||
);
|
||||
return calls;
|
||||
}
|
||||
|
||||
describe('NzbgetAdapter', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
forgetDispatchers();
|
||||
});
|
||||
|
||||
it('tests the JSON-RPC endpoint with Basic authentication', async () => {
|
||||
const calls = mockRpc({ version: '24.8' });
|
||||
|
||||
await expect(new NzbgetAdapter().test(config())).resolves.toEqual({ success: true, version: '24.8' });
|
||||
expect(calls[0]).toMatchObject({ url: 'http://127.0.0.1:6789/jsonrpc', method: 'version' });
|
||||
expect(calls[0]?.authorization).toBe(`Basic ${Buffer.from('nzbget:password').toString('base64')}`);
|
||||
});
|
||||
|
||||
it('uploads the NZB and stamps the stable ownership duplicate key', async () => {
|
||||
const calls = mockRpc({ listgroups: [], history: [], append: 42 });
|
||||
|
||||
await expect(
|
||||
new NzbgetAdapter().add({ nzbFile: Buffer.from('<nzb />'), nzbFileName: '../Dune.nzb', clientKey: CLIENT_KEY }, config()),
|
||||
).resolves.toEqual({ clientKey: CLIENT_KEY });
|
||||
|
||||
const append = calls.find((call) => call.method === 'append');
|
||||
expect(append?.params).toEqual([
|
||||
'Dune.nzb',
|
||||
Buffer.from('<nzb />').toString('base64'),
|
||||
'bookorbit',
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
`bookorbit:9:${CLIENT_KEY}`,
|
||||
0,
|
||||
'ALL',
|
||||
[],
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the NZB extension when a supplied filename is truncated', async () => {
|
||||
const calls = mockRpc({ listgroups: [], history: [], append: 42 });
|
||||
|
||||
await new NzbgetAdapter().add({ nzbFile: Buffer.from('<nzb />'), nzbFileName: `${'D'.repeat(600)}.nzb`, clientKey: CLIENT_KEY }, config());
|
||||
|
||||
const filename = calls.find((call) => call.method === 'append')?.params[0];
|
||||
expect(filename).toHaveLength(500);
|
||||
expect(filename).toMatch(/\.nzb$/);
|
||||
});
|
||||
|
||||
it('adopts an existing owned item instead of appending it again', async () => {
|
||||
const calls = mockRpc({ listgroups: [{ NZBID: 3, DupeKey: `bookorbit:9:${CLIENT_KEY}` }] });
|
||||
|
||||
await new NzbgetAdapter().add({ nzbFile: Buffer.from('<nzb />'), clientKey: CLIENT_KEY }, config());
|
||||
|
||||
expect(calls.some((call) => call.method === 'append')).toBe(false);
|
||||
});
|
||||
|
||||
it('maps queue and successful history items into bounded download statuses', async () => {
|
||||
mockRpc({
|
||||
listgroups: [
|
||||
{
|
||||
NZBID: 3,
|
||||
NZBName: 'Dune',
|
||||
DupeKey: `bookorbit:9:${CLIENT_KEY}`,
|
||||
Status: 'DOWNLOADING',
|
||||
FileSizeHi: 0,
|
||||
FileSizeLo: 1000,
|
||||
RemainingSizeHi: 0,
|
||||
RemainingSizeLo: 250,
|
||||
DestDir: '/downloads/Dune',
|
||||
},
|
||||
],
|
||||
history: [],
|
||||
});
|
||||
|
||||
await expect(new NzbgetAdapter().status([CLIENT_KEY], config())).resolves.toEqual([
|
||||
expect.objectContaining({ clientKey: CLIENT_KEY, state: 'downloading', progressPercent: 75, downloadedBytes: 750, totalBytes: 1000 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses FinalDir for a completed history item', async () => {
|
||||
mockRpc({
|
||||
listgroups: [],
|
||||
history: [
|
||||
{
|
||||
NZBID: 4,
|
||||
DupeKey: `bookorbit:9:${CLIENT_KEY}`,
|
||||
Status: 'SUCCESS/ALL',
|
||||
FileSizeHi: 0,
|
||||
FileSizeLo: 1000,
|
||||
DownloadedSizeHi: 0,
|
||||
DownloadedSizeLo: 1000,
|
||||
DestDir: '/downloads/intermediate',
|
||||
FinalDir: '/downloads/final',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(new NzbgetAdapter().status([CLIENT_KEY], config())).resolves.toEqual([
|
||||
expect.objectContaining({ state: 'completed', progressPercent: 100, contentPath: '/downloads/final' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('reports completed history at integral 100 percent when NZBGet counters differ after repair', async () => {
|
||||
mockRpc({
|
||||
listgroups: [],
|
||||
history: [
|
||||
{
|
||||
NZBID: 4,
|
||||
DupeKey: `bookorbit:9:${CLIENT_KEY}`,
|
||||
Status: 'SUCCESS/PAR',
|
||||
FileSizeHi: 0,
|
||||
FileSizeLo: 1_344_342,
|
||||
DownloadedSizeHi: 0,
|
||||
DownloadedSizeLo: 274_691,
|
||||
DestDir: '/downloads/final',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(new NzbgetAdapter().status([CLIENT_KEY], config())).resolves.toEqual([
|
||||
expect.objectContaining({ state: 'completed', progressPercent: 100, downloadedBytes: 274_691, totalBytes: 1_344_342 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rounds in-progress percentages to the database integer contract', async () => {
|
||||
mockRpc({
|
||||
listgroups: [
|
||||
{
|
||||
NZBID: 3,
|
||||
DupeKey: `bookorbit:9:${CLIENT_KEY}`,
|
||||
Status: 'DOWNLOADING',
|
||||
FileSizeHi: 0,
|
||||
FileSizeLo: 3,
|
||||
RemainingSizeHi: 0,
|
||||
RemainingSizeLo: 2,
|
||||
},
|
||||
],
|
||||
history: [],
|
||||
});
|
||||
|
||||
await expect(new NzbgetAdapter().status([CLIENT_KEY], config())).resolves.toEqual([
|
||||
expect.objectContaining({ state: 'downloading', progressPercent: 33 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps post-processing completion and warning or deleted history states explicitly', async () => {
|
||||
const queuedKey = 'b'.repeat(64);
|
||||
const failedKey = 'c'.repeat(64);
|
||||
mockRpc({
|
||||
listgroups: [{ NZBID: 3, DupeKey: `bookorbit:9:${queuedKey}`, Status: 'PP_FINISHED', DestDir: '/downloads/queued' }],
|
||||
history: [
|
||||
{ NZBID: 4, DupeKey: `bookorbit:9:${CLIENT_KEY}`, Status: 'WARNING/SCRIPT', DestDir: '/downloads/warning' },
|
||||
{ NZBID: 5, DupeKey: `bookorbit:9:${failedKey}`, Status: 'DELETED/MANUAL', DestDir: '/downloads/deleted' },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(new NzbgetAdapter().status([CLIENT_KEY, queuedKey, failedKey], config())).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ clientKey: queuedKey, state: 'completed', contentPath: '/downloads/queued' }),
|
||||
expect.objectContaining({ clientKey: CLIENT_KEY, state: 'completed', contentPath: '/downloads/warning' }),
|
||||
expect.objectContaining({ clientKey: failedKey, state: 'failed', contentPath: null }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('combines unsigned high and low size words and clamps values beyond safe integers', async () => {
|
||||
mockRpc({
|
||||
listgroups: [
|
||||
{
|
||||
NZBID: 3,
|
||||
DupeKey: `bookorbit:9:${CLIENT_KEY}`,
|
||||
Status: 'DOWNLOADING',
|
||||
FileSizeHi: 0x20_0000,
|
||||
FileSizeLo: 0xffff_ffff,
|
||||
RemainingSizeHi: 0,
|
||||
RemainingSizeLo: 1,
|
||||
},
|
||||
],
|
||||
history: [],
|
||||
});
|
||||
|
||||
await expect(new NzbgetAdapter().status([CLIENT_KEY], config())).resolves.toEqual([
|
||||
expect.objectContaining({ totalBytes: Number.MAX_SAFE_INTEGER, downloadedBytes: Number.MAX_SAFE_INTEGER - 1, progressPercent: 100 }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists only items carrying both BookOrbit ownership and the configured category', async () => {
|
||||
mockRpc({
|
||||
listgroups: [
|
||||
{ NZBID: 1, NZBName: 'owned', Category: 'bookorbit', DupeKey: `bookorbit:9:${CLIENT_KEY}`, Status: 'QUEUED' },
|
||||
{ NZBID: 2, NZBName: 'other category', Category: 'other', DupeKey: `bookorbit:9:${'b'.repeat(64)}`, Status: 'QUEUED' },
|
||||
{ NZBID: 3, NZBName: 'other client', Category: 'bookorbit', DupeKey: `bookorbit:10:${'c'.repeat(64)}`, Status: 'QUEUED' },
|
||||
{ NZBID: 4, NZBName: 'not owned', Category: 'bookorbit', DupeKey: 'another-app', Status: 'QUEUED' },
|
||||
],
|
||||
history: [],
|
||||
});
|
||||
|
||||
await expect(new NzbgetAdapter().listOwned(config())).resolves.toMatchObject({ supported: true, truncated: false, items: [{ name: 'owned' }] });
|
||||
});
|
||||
|
||||
it('bounds owned reconciliation inventory and reports truncation', async () => {
|
||||
const queue = Array.from({ length: 1001 }, (_, index) => ({
|
||||
NZBID: index + 1,
|
||||
NZBName: `owned-${index}`,
|
||||
Category: 'bookorbit',
|
||||
DupeKey: `bookorbit:9:${index.toString(16).padStart(64, '0')}`,
|
||||
Status: 'QUEUED',
|
||||
}));
|
||||
mockRpc({ listgroups: queue, history: [] });
|
||||
|
||||
await expect(new NzbgetAdapter().listOwned(config())).resolves.toMatchObject({ supported: true, truncated: true, items: { length: 1000 } });
|
||||
});
|
||||
|
||||
it('removes a queue group by its NZBID', async () => {
|
||||
const calls = mockRpc({ listgroups: [{ NZBID: 3, DupeKey: `bookorbit:9:${CLIENT_KEY}` }], editqueue: true });
|
||||
|
||||
await new NzbgetAdapter().remove(CLIENT_KEY, config(), { deleteFiles: true });
|
||||
|
||||
expect(calls.find((call) => call.method === 'editqueue')?.params).toEqual(['GroupFinalDelete', '', [3]]);
|
||||
});
|
||||
|
||||
it('keeps partial files when removing a queue group without file deletion', async () => {
|
||||
const calls = mockRpc({ listgroups: [{ NZBID: 3, DupeKey: `bookorbit:9:${CLIENT_KEY}` }], editqueue: true });
|
||||
|
||||
await new NzbgetAdapter().remove(CLIENT_KEY, config(), { deleteFiles: false });
|
||||
|
||||
expect(calls.find((call) => call.method === 'editqueue')?.params).toEqual(['GroupParkDelete', '', [3]]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ deleteFiles: false, command: 'HistoryDelete' },
|
||||
{ deleteFiles: true, command: 'HistoryFinalDelete' },
|
||||
])('removes completed history by NZBID with $command', async ({ deleteFiles, command }) => {
|
||||
const calls = mockRpc({ listgroups: [], history: [{ NZBID: 4, DupeKey: `bookorbit:9:${CLIENT_KEY}` }], editqueue: true });
|
||||
|
||||
await new NzbgetAdapter().remove(CLIENT_KEY, config(), { deleteFiles });
|
||||
expect(calls.find((call) => call.method === 'editqueue')?.params).toEqual([command, '', [4]]);
|
||||
});
|
||||
|
||||
it('ignores history entries without BookOrbit ownership', async () => {
|
||||
const calls = mockRpc({ listgroups: [], history: [{ NZBID: 4, DupeKey: `bookorbit:9:${CLIENT_KEY}` }], editqueue: true });
|
||||
|
||||
await new NzbgetAdapter().remove('b'.repeat(64), config(), { deleteFiles: false });
|
||||
expect(calls.some((call) => call.method === 'editqueue')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects malformed JSON-RPC envelopes and invalid list results', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => Promise.resolve(new Response('{'))),
|
||||
);
|
||||
await expect(new NzbgetAdapter().status([CLIENT_KEY], config())).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
mockRpc({ listgroups: { not: 'an array' } });
|
||||
await expect(new NzbgetAdapter().status([CLIENT_KEY], config())).rejects.toThrow('invalid listgroups result');
|
||||
});
|
||||
|
||||
it('rejects a JSON-RPC error and an invalid client key', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn(() => Promise.resolve(new Response(JSON.stringify({ result: null, error: { message: 'bad request' } })))),
|
||||
);
|
||||
await expect(new NzbgetAdapter().test(config())).resolves.toMatchObject({ success: false, error: expect.stringContaining('bad request') });
|
||||
await expect(new NzbgetAdapter().status(['not-a-key'], config())).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import type { DownloadClientTestResult } from '@bookorbit/types';
|
||||
|
||||
import { sanitizeLogValue } from '../../../../common/utils/log-sanitize.utils';
|
||||
import { ensureSafeUrl } from '../../../../common/utils/ssrf.utils';
|
||||
import type {
|
||||
DownloadClientAdapter,
|
||||
DownloadState,
|
||||
DownloadStatus,
|
||||
GrabPayload,
|
||||
OwnedDownloadClientInventory,
|
||||
ResolvedClientConfig,
|
||||
} from '../download-client-adapter';
|
||||
import { basicAuthHeader, endpointUrl, fetchClient, readClientJson, throwForClientServerError } from './client-http.utils';
|
||||
|
||||
const LABEL = 'NZBGet';
|
||||
const OWNERSHIP_PREFIX = 'bookorbit:';
|
||||
const RECONCILIATION_LIMIT = 1000;
|
||||
|
||||
interface RpcEnvelope<T> {
|
||||
result?: T;
|
||||
error?: { code?: number; message?: string } | string | null;
|
||||
}
|
||||
|
||||
interface NzbGetItem {
|
||||
NZBID?: number;
|
||||
NZBName?: string;
|
||||
Name?: string;
|
||||
Category?: string;
|
||||
DupeKey?: string;
|
||||
Status?: string;
|
||||
DestDir?: string;
|
||||
FinalDir?: string;
|
||||
FileSizeHi?: number;
|
||||
FileSizeLo?: number;
|
||||
RemainingSizeHi?: number;
|
||||
RemainingSizeLo?: number;
|
||||
DownloadedSizeHi?: number;
|
||||
DownloadedSizeLo?: number;
|
||||
}
|
||||
|
||||
interface LocatedItem {
|
||||
item: NzbGetItem;
|
||||
location: 'queue' | 'history';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NzbgetAdapter implements DownloadClientAdapter {
|
||||
readonly type = 'nzbget' as const;
|
||||
readonly label = LABEL;
|
||||
readonly delivers = 'usenet' as const;
|
||||
|
||||
private readonly logger = new Logger(NzbgetAdapter.name);
|
||||
|
||||
async add(release: GrabPayload, config: ResolvedClientConfig): Promise<{ clientKey: string }> {
|
||||
if (!release.nzbFile?.length) throw new BadRequestException('An NZBGet grab needs an NZB file');
|
||||
const dupeKey = ownedKey(config.id, release.clientKey);
|
||||
if (await this.findByDupeKeys(new Set([release.clientKey]), config)) return { clientKey: release.clientKey };
|
||||
|
||||
const filename = safeNzbName(release.nzbFileName);
|
||||
// The v16 signature remains accepted by current NZBGet and also works before v25 added
|
||||
// AutoCategory as an extra parameter. The explicit category above means we do not need it.
|
||||
const id = await this.rpc<number>(config, 'append', [
|
||||
filename,
|
||||
release.nzbFile.toString('base64'),
|
||||
config.category,
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
dupeKey,
|
||||
0,
|
||||
'ALL',
|
||||
[],
|
||||
]);
|
||||
if (!Number.isInteger(id) || id <= 0) throw new BadRequestException('NZBGet refused that NZB');
|
||||
return { clientKey: release.clientKey };
|
||||
}
|
||||
|
||||
async status(keys: string[], config: ResolvedClientConfig): Promise<DownloadStatus[]> {
|
||||
const wanted = new Set(keys.map(normalizeKey));
|
||||
const found = await this.findByDupeKeys(wanted, config);
|
||||
if (!found) return [];
|
||||
return [...found.values()].map(({ item, location }) => toStatus(item, location, config.id));
|
||||
}
|
||||
|
||||
async listOwned(config: ResolvedClientConfig): Promise<OwnedDownloadClientInventory> {
|
||||
const [queue, history] = await Promise.all([this.listGroups(config), this.history(config)]);
|
||||
const byKey = new Map<string, LocatedItem>();
|
||||
for (const item of history) addOwned(byKey, item, 'history', config.category, config.id);
|
||||
for (const item of queue) addOwned(byKey, item, 'queue', config.category, config.id);
|
||||
const items = [...byKey.values()];
|
||||
return {
|
||||
supported: true,
|
||||
truncated: items.length > RECONCILIATION_LIMIT,
|
||||
items: items.slice(0, RECONCILIATION_LIMIT).map(({ item, location }) => ({
|
||||
...toStatus(item, location, config.id),
|
||||
name: item.NZBName?.trim() || item.Name?.trim() || clientKey(item.DupeKey, config.id) || String(item.NZBID ?? ''),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async remove(key: string, config: ResolvedClientConfig, opts: { deleteFiles: boolean }): Promise<void> {
|
||||
const found = await this.findByDupeKeys(new Set([normalizeKey(key)]), config);
|
||||
const located = found?.get(normalizeKey(key));
|
||||
if (!located) return;
|
||||
const id = located.item.NZBID;
|
||||
if (!Number.isInteger(id)) throw new BadRequestException('NZBGet returned an invalid queue id');
|
||||
const command =
|
||||
located.location === 'queue'
|
||||
? opts.deleteFiles
|
||||
? 'GroupFinalDelete'
|
||||
: 'GroupParkDelete'
|
||||
: opts.deleteFiles
|
||||
? 'HistoryFinalDelete'
|
||||
: 'HistoryDelete';
|
||||
const removed = await this.rpc<boolean>(config, 'editqueue', [command, '', [id]]);
|
||||
if (!removed) throw new BadRequestException('NZBGet refused to remove that download');
|
||||
}
|
||||
|
||||
async test(config: ResolvedClientConfig): Promise<DownloadClientTestResult> {
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const version = await this.rpc<string>(config, 'version', []);
|
||||
if (typeof version !== 'string' || !version.trim()) return { success: false, error: 'NZBGet returned an invalid version' };
|
||||
return { success: true, version: version.trim() };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(
|
||||
`[download_client.test] [fail] clientId=${config.id} durationMs=${Date.now() - startedAt} errorClass=${error instanceof Error ? error.constructor.name : 'Error'} error="${sanitizeLogValue(message)}" - NZBGet connection test failed`,
|
||||
);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
private async findByDupeKeys(wanted: Set<string>, config: ResolvedClientConfig): Promise<Map<string, LocatedItem> | null> {
|
||||
if (wanted.size === 0) return new Map();
|
||||
const found = new Map<string, LocatedItem>();
|
||||
for (const item of await this.listGroups(config)) collectWanted(found, item, 'queue', wanted, config.id);
|
||||
if (found.size < wanted.size) {
|
||||
for (const item of await this.history(config)) collectWanted(found, item, 'history', wanted, config.id);
|
||||
}
|
||||
return found.size > 0 ? found : null;
|
||||
}
|
||||
|
||||
private listGroups(config: ResolvedClientConfig): Promise<NzbGetItem[]> {
|
||||
return this.rpcItems(config, 'listgroups', [0]);
|
||||
}
|
||||
|
||||
private history(config: ResolvedClientConfig): Promise<NzbGetItem[]> {
|
||||
return this.rpcItems(config, 'history', [false]);
|
||||
}
|
||||
|
||||
private async rpcItems(config: ResolvedClientConfig, method: string, params: unknown[]): Promise<NzbGetItem[]> {
|
||||
const result = await this.rpc<unknown>(config, method, params);
|
||||
if (!Array.isArray(result) || result.some((item) => item === null || typeof item !== 'object')) {
|
||||
throw new BadRequestException(`NZBGet returned an invalid ${method} result`);
|
||||
}
|
||||
return result as NzbGetItem[];
|
||||
}
|
||||
|
||||
private async rpc<T>(config: ResolvedClientConfig, method: string, params: unknown[]): Promise<T> {
|
||||
const base = await ensureSafeUrl(config.baseUrl, { allowPrivate: config.allowPrivateAddress });
|
||||
const response = await fetchClient(
|
||||
endpointUrl(base, '/jsonrpc'),
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...basicAuthHeader(config.username, config.password) },
|
||||
body: JSON.stringify({ method, params }),
|
||||
},
|
||||
LABEL,
|
||||
{ allowPrivate: config.allowPrivateAddress, pinResolvedAddress: true },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throwForClientServerError(response, LABEL, method);
|
||||
if (response.status === 401 || response.status === 403) throw new BadRequestException('NZBGet rejected the username or password');
|
||||
throw new BadRequestException(`NZBGet answered ${response.status} for ${method}`);
|
||||
}
|
||||
const envelope = await readClientJson<RpcEnvelope<T>>(response, LABEL);
|
||||
if (!envelope || typeof envelope !== 'object') throw new BadRequestException('NZBGet returned an invalid JSON-RPC response');
|
||||
if (envelope.error) {
|
||||
const detail = typeof envelope.error === 'string' ? envelope.error : envelope.error.message;
|
||||
throw new BadRequestException(detail ? `NZBGet refused ${method}: ${detail}` : `NZBGet refused ${method}`);
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(envelope, 'result')) throw new BadRequestException('NZBGet returned no JSON-RPC result');
|
||||
return envelope.result as T;
|
||||
}
|
||||
}
|
||||
|
||||
function collectWanted(
|
||||
found: Map<string, LocatedItem>,
|
||||
item: NzbGetItem,
|
||||
location: LocatedItem['location'],
|
||||
wanted: Set<string>,
|
||||
clientId: number,
|
||||
): void {
|
||||
const key = clientKey(item.DupeKey, clientId);
|
||||
if (key && wanted.has(key) && !found.has(key)) found.set(key, { item, location });
|
||||
}
|
||||
|
||||
function addOwned(found: Map<string, LocatedItem>, item: NzbGetItem, location: LocatedItem['location'], category: string, clientId: number): void {
|
||||
const key = clientKey(item.DupeKey, clientId);
|
||||
if (!key || item.Category !== category) return;
|
||||
found.set(key, { item, location });
|
||||
}
|
||||
|
||||
function toStatus(item: NzbGetItem, location: LocatedItem['location'], clientId: number): DownloadStatus {
|
||||
const key = clientKey(item.DupeKey, clientId);
|
||||
if (!key) throw new BadRequestException('NZBGet returned an invalid BookOrbit duplicate key');
|
||||
const totalBytes = uint64(item.FileSizeHi, item.FileSizeLo);
|
||||
const remainingBytes = uint64(item.RemainingSizeHi, item.RemainingSizeLo) ?? 0;
|
||||
const downloadedBytes = uint64(item.DownloadedSizeHi, item.DownloadedSizeLo) ?? Math.max(0, (totalBytes ?? 0) - remainingBytes);
|
||||
const state = location === 'history' ? historyState(item.Status) : queueState(item.Status);
|
||||
const path = item.FinalDir?.trim() || item.DestDir?.trim() || null;
|
||||
return {
|
||||
clientKey: key,
|
||||
state,
|
||||
progressPercent: state === 'completed' ? 100 : totalBytes && totalBytes > 0 ? clampPercent((downloadedBytes / totalBytes) * 100) : 0,
|
||||
downloadedBytes,
|
||||
totalBytes,
|
||||
contentPath: state === 'completed' ? path : null,
|
||||
...(state === 'failed' ? { errorMessage: `NZBGet finished with status ${item.Status ?? 'unknown'}` } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function queueState(status: string | undefined): DownloadState {
|
||||
if (status === 'PP_FINISHED') return 'completed';
|
||||
if (status === 'QUEUED' || status === 'PAUSED' || status === 'PP_QUEUED') return 'queued';
|
||||
if (status) return 'downloading';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function historyState(status: string | undefined): DownloadState {
|
||||
if (status?.startsWith('SUCCESS/') || status === 'WARNING/SCRIPT') return 'completed';
|
||||
if (status?.startsWith('FAILURE/') || status?.startsWith('DELETED/') || status?.startsWith('WARNING/')) return 'failed';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
function uint64(hi: number | undefined, lo: number | undefined): number | null {
|
||||
if (!Number.isInteger(hi) || !Number.isInteger(lo)) return null;
|
||||
const value = (hi! >>> 0) * 0x1_0000_0000 + (lo! >>> 0);
|
||||
return Number.isSafeInteger(value) ? value : Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
function clampPercent(value: number): number {
|
||||
return Math.max(0, Math.min(100, Math.round(value)));
|
||||
}
|
||||
|
||||
function ownedKey(clientId: number, key: string): string {
|
||||
return `${OWNERSHIP_PREFIX}${clientId}:${normalizeKey(key)}`;
|
||||
}
|
||||
|
||||
function clientKey(value: string | undefined, clientId: number | null): string | null {
|
||||
if (clientId === null) return null;
|
||||
const prefix = `${OWNERSHIP_PREFIX}${clientId}:`;
|
||||
if (!value?.startsWith(prefix)) return null;
|
||||
const key = value.slice(prefix.length).toLowerCase();
|
||||
return /^[a-f0-9]{40,64}$/.test(key) ? key : null;
|
||||
}
|
||||
|
||||
function normalizeKey(value: string): string {
|
||||
const key = value.trim().toLowerCase();
|
||||
if (!/^[a-f0-9]{40,64}$/.test(key)) throw new BadRequestException('Invalid client key');
|
||||
return key;
|
||||
}
|
||||
|
||||
function safeNzbName(value: string | undefined): string {
|
||||
const base =
|
||||
value
|
||||
?.trim()
|
||||
.split(/[\\/]/)
|
||||
.pop()
|
||||
?.replace(/[^a-z0-9._ -]/gi, '_') || 'bookorbit.nzb';
|
||||
const stem = base.toLowerCase().endsWith('.nzb') ? base.slice(0, -4) : base;
|
||||
return `${stem.slice(0, 496)}.nzb`;
|
||||
}
|
||||
+22
-22
@@ -89,8 +89,8 @@ describe('QbittorrentAdapter', () => {
|
||||
it('posts a magnet with the configured category and returns the caller-derived hash', async () => {
|
||||
const { calls } = mockFetch();
|
||||
|
||||
await expect(adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, infoHash: INFO_HASH }, config())).resolves.toEqual({
|
||||
clientHash: INFO_HASH,
|
||||
await expect(adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, clientKey: INFO_HASH }, config())).resolves.toEqual({
|
||||
clientKey: INFO_HASH,
|
||||
});
|
||||
|
||||
const add = calls.find((call) => call.url.includes('/api/v2/torrents/add'));
|
||||
@@ -102,7 +102,7 @@ describe('QbittorrentAdapter', () => {
|
||||
it('passes seed goals through so the client, not BookOrbit, enforces them', async () => {
|
||||
const { calls } = mockFetch();
|
||||
|
||||
await adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, infoHash: INFO_HASH, seedRatioGoal: 2, seedTimeMinutes: 4320 }, config());
|
||||
await adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, clientKey: INFO_HASH, seedRatioGoal: 2, seedTimeMinutes: 4320 }, config());
|
||||
|
||||
const form = calls.find((call) => call.url.includes('/torrents/add'))?.init.body as FormData;
|
||||
expect(form.get('ratioLimit')).toBe('2');
|
||||
@@ -112,7 +112,7 @@ describe('QbittorrentAdapter', () => {
|
||||
it('uploads a .torrent as a file part', async () => {
|
||||
const { calls } = mockFetch();
|
||||
|
||||
await adapter.add({ torrentFile: Buffer.from('d4:infod4:name4:duneee'), torrentFileName: 'dune.torrent', infoHash: INFO_HASH }, config());
|
||||
await adapter.add({ torrentFile: Buffer.from('d4:infod4:name4:duneee'), torrentFileName: 'dune.torrent', clientKey: INFO_HASH }, config());
|
||||
|
||||
const form = calls.find((call) => call.url.includes('/torrents/add'))?.init.body as FormData;
|
||||
expect(form.get('torrents')).toBeInstanceOf(Blob);
|
||||
@@ -125,7 +125,7 @@ describe('QbittorrentAdapter', () => {
|
||||
handlers.set('/torrents/add', () => response('Fails.'));
|
||||
handlers.set('/torrents/info', () => response([]));
|
||||
|
||||
await expect(adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, infoHash: INFO_HASH }, config())).rejects.toThrow(
|
||||
await expect(adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, clientKey: INFO_HASH }, config())).rejects.toThrow(
|
||||
/could not read that torrent/,
|
||||
);
|
||||
});
|
||||
@@ -140,8 +140,8 @@ describe('QbittorrentAdapter', () => {
|
||||
handlers.set('/torrents/add', () => response('Fails.'));
|
||||
handlers.set('/torrents/info', () => response([{ hash: INFO_HASH, state: 'stalledUP', progress: 1 }]));
|
||||
|
||||
await expect(adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, infoHash: INFO_HASH }, config())).resolves.toEqual({
|
||||
clientHash: INFO_HASH,
|
||||
await expect(adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, clientKey: INFO_HASH }, config())).resolves.toEqual({
|
||||
clientKey: INFO_HASH,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -150,12 +150,12 @@ describe('QbittorrentAdapter', () => {
|
||||
handlers.set('/torrents/add', () => response('Fails.'));
|
||||
handlers.set('/torrents/info', () => response('Forbidden', { status: 403 }));
|
||||
|
||||
await expect(adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, infoHash: INFO_HASH }, config())).rejects.toThrow(BadRequestException);
|
||||
await expect(adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, clientKey: INFO_HASH }, config())).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('refuses a payload with neither a magnet nor a file', async () => {
|
||||
mockFetch();
|
||||
await expect(adapter.add({ infoHash: INFO_HASH }, config())).rejects.toThrow(BadRequestException);
|
||||
await expect(adapter.add({ clientKey: INFO_HASH }, config())).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -175,7 +175,7 @@ describe('QbittorrentAdapter', () => {
|
||||
expect(calls.filter((call) => call.url.includes('/torrents/info'))).toHaveLength(1);
|
||||
expect(statuses).toEqual([
|
||||
{
|
||||
infoHash: INFO_HASH,
|
||||
clientKey: INFO_HASH,
|
||||
state: 'downloading',
|
||||
progressPercent: 42,
|
||||
downloadedBytes: 420,
|
||||
@@ -263,14 +263,14 @@ describe('QbittorrentAdapter', () => {
|
||||
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 }),
|
||||
expect.objectContaining({ clientKey: 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(await adapter.status([INFO_HASH], config())).toEqual([expect.objectContaining({ clientKey: INFO_HASH })]);
|
||||
expect(calls.filter((call) => call.url.includes('/torrents/info'))).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -278,7 +278,7 @@ describe('QbittorrentAdapter', () => {
|
||||
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());
|
||||
expect(statuses.map((status) => status.clientKey).sort()).toEqual([INFO_HASH, PRIMARY, V2].sort());
|
||||
});
|
||||
|
||||
it.each([V2.slice(0, 12), V2.slice(0, 39), V2.slice(0, 41), '0'.repeat(40)])(
|
||||
@@ -293,7 +293,7 @@ describe('QbittorrentAdapter', () => {
|
||||
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 })]);
|
||||
expect(await adapter.status([PRIMARY, INFO_HASH], config())).toEqual([expect.objectContaining({ clientKey: PRIMARY })]);
|
||||
});
|
||||
|
||||
it('pages the fallback once for the batch and caches only verified primary mappings', async () => {
|
||||
@@ -351,7 +351,7 @@ describe('QbittorrentAdapter', () => {
|
||||
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' })]);
|
||||
expect(await adapter.status([INFO_HASH], config())).toEqual([expect.objectContaining({ clientKey: INFO_HASH, state: 'completed' })]);
|
||||
calls.length = 0;
|
||||
await adapter.status([INFO_HASH], config());
|
||||
expect(calls[0].url).toContain(`hashes=${INFO_HASH}`);
|
||||
@@ -405,8 +405,8 @@ describe('QbittorrentAdapter', () => {
|
||||
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,
|
||||
expect(await adapter.add({ clientKey: INFO_HASH, magnet: `magnet:?xt=urn:btih:${INFO_HASH}&xt=urn:btmh:1220${V2}` }, config())).toEqual({
|
||||
clientKey: INFO_HASH,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -414,7 +414,7 @@ describe('QbittorrentAdapter', () => {
|
||||
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(
|
||||
await expect(adapter.add({ clientKey: INFO_HASH, magnet: `magnet:?xt=urn:btih:${INFO_HASH}` }, config())).rejects.toThrow(
|
||||
'qBittorrent answered 409 for /api/v2/torrents/add',
|
||||
);
|
||||
});
|
||||
@@ -422,7 +422,7 @@ describe('QbittorrentAdapter', () => {
|
||||
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(
|
||||
await expect(adapter.add({ clientKey: 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);
|
||||
@@ -432,7 +432,7 @@ describe('QbittorrentAdapter', () => {
|
||||
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(await adapter.status([INFO_HASH], config())).toEqual([expect.objectContaining({ clientKey: INFO_HASH, trackerError: 'Denied' })]);
|
||||
expect(calls.find((call) => call.url.includes('/torrents/trackers'))?.url).toContain(`hash=${PRIMARY}`);
|
||||
});
|
||||
|
||||
@@ -469,7 +469,7 @@ describe('QbittorrentAdapter', () => {
|
||||
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());
|
||||
expect((await adapter.status([V2, PRIMARY], config())).map((status) => status.clientKey).sort()).toEqual([V2, PRIMARY].sort());
|
||||
});
|
||||
|
||||
it('evicts old aliases when the bounded cache fills without losing the ability to rediscover them', async () => {
|
||||
@@ -519,7 +519,7 @@ describe('QbittorrentAdapter', () => {
|
||||
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(inventory.items.map((item) => item.clientKey)).toEqual([INFO_HASH, 'f'.repeat(40)]);
|
||||
expect(new URL(calls.at(-1)!.url).searchParams.get('limit')).toBe('1001');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -83,7 +83,7 @@ export class QbittorrentAdapter implements DownloadClientAdapter {
|
||||
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 }> {
|
||||
async add(release: GrabPayload, config: ResolvedClientConfig): Promise<{ clientKey: string }> {
|
||||
const form = new FormData();
|
||||
if (release.torrentFile) {
|
||||
form.append('torrents', new Blob([new Uint8Array(release.torrentFile)]), release.torrentFileName ?? 'upload.torrent');
|
||||
@@ -104,28 +104,28 @@ export class QbittorrentAdapter implements DownloadClientAdapter {
|
||||
// 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 (response.status === 409 || body.toLowerCase().startsWith('fail')) {
|
||||
if (await this.holds(release.infoHash, config)) {
|
||||
if (await this.holds(release.clientKey, 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
|
||||
// we asked for being present is the outcome we wanted, not a failure.
|
||||
this.logger.log(
|
||||
`[download_client.add] [end] clientId=${config.id} hash=${release.infoHash.toLowerCase()} adopted=true - the client already held this torrent`,
|
||||
`[download_client.add] [end] clientId=${config.id} hash=${release.clientKey.toLowerCase()} adopted=true - the client already held this torrent`,
|
||||
);
|
||||
return { clientHash: release.infoHash.toLowerCase() };
|
||||
return { clientKey: release.clientKey.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.');
|
||||
}
|
||||
|
||||
return { clientHash: release.infoHash.toLowerCase() };
|
||||
return { clientKey: release.clientKey.toLowerCase() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the client is already holding this exact infohash. Any failure to find out answers
|
||||
* "no", so an unreachable client surfaces the add failure rather than a false success.
|
||||
*/
|
||||
private async holds(infoHash: string, config: ResolvedClientConfig): Promise<boolean> {
|
||||
const hash = infoHash.toLowerCase();
|
||||
private async holds(clientKey: string, config: ResolvedClientConfig): Promise<boolean> {
|
||||
const hash = clientKey.toLowerCase();
|
||||
try {
|
||||
return (await this.findTorrents([hash], config)).has(hash);
|
||||
} catch {
|
||||
@@ -239,7 +239,7 @@ export class QbittorrentAdapter implements DownloadClientAdapter {
|
||||
// the torrent keeps its state and the watchdog remains the backstop.
|
||||
const detail = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(
|
||||
`[download_client.trackers] [fail] clientId=${config.id} hash=${status.infoHash} error="${sanitizeLogValue(detail)}" - could not read tracker status`,
|
||||
`[download_client.trackers] [fail] clientId=${config.id} hash=${status.clientKey} error="${sanitizeLogValue(detail)}" - could not read tracker status`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -344,7 +344,7 @@ function toDownloadStatus(hash: string, entry: QbTorrentInfo): DownloadStatus {
|
||||
const totalBytes = entry.total_size ?? entry.size ?? null;
|
||||
const state = mapState(entry.state);
|
||||
return {
|
||||
infoHash: hash,
|
||||
clientKey: hash,
|
||||
state,
|
||||
progressPercent: Math.max(0, Math.min(100, Math.round((entry.progress ?? 0) * 100))),
|
||||
downloadedBytes: entry.downloaded ?? 0,
|
||||
|
||||
+12
-12
@@ -141,8 +141,8 @@ describe('TransmissionAdapter', () => {
|
||||
handlers.set('session-get', () => success({ 'download-dir': '/downloads' }));
|
||||
handlers.set('torrent-add', () => success({ 'torrent-added': { hashString: INFO_HASH } }));
|
||||
|
||||
await expect(adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, infoHash: INFO_HASH }, config())).resolves.toEqual({
|
||||
clientHash: INFO_HASH,
|
||||
await expect(adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, clientKey: INFO_HASH }, config())).resolves.toEqual({
|
||||
clientKey: INFO_HASH,
|
||||
});
|
||||
|
||||
const add = calls.find((call) => call.method === 'torrent-add');
|
||||
@@ -155,7 +155,7 @@ describe('TransmissionAdapter', () => {
|
||||
const { calls, handlers } = mockRpc();
|
||||
handlers.set('torrent-add', () => success({ 'torrent-added': { hashString: INFO_HASH } }));
|
||||
|
||||
await adapter.add({ torrentFile: Buffer.from('d4:infod4:name4:duneee'), torrentFileName: 'dune.torrent', infoHash: INFO_HASH }, config());
|
||||
await adapter.add({ torrentFile: Buffer.from('d4:infod4:name4:duneee'), torrentFileName: 'dune.torrent', clientKey: INFO_HASH }, config());
|
||||
|
||||
const add = calls.find((call) => call.method === 'torrent-add');
|
||||
expect(add?.args.metainfo).toBe(Buffer.from('d4:infod4:name4:duneee').toString('base64'));
|
||||
@@ -170,8 +170,8 @@ describe('TransmissionAdapter', () => {
|
||||
const { handlers } = mockRpc();
|
||||
handlers.set('torrent-add', () => success({ 'torrent-duplicate': { hashString: INFO_HASH } }));
|
||||
|
||||
await expect(adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, infoHash: INFO_HASH }, config())).resolves.toEqual({
|
||||
clientHash: INFO_HASH,
|
||||
await expect(adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, clientKey: INFO_HASH }, config())).resolves.toEqual({
|
||||
clientKey: INFO_HASH,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -179,7 +179,7 @@ describe('TransmissionAdapter', () => {
|
||||
const { calls, handlers } = mockRpc();
|
||||
handlers.set('torrent-add', () => success({ 'torrent-added': { hashString: INFO_HASH } }));
|
||||
|
||||
await adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, infoHash: INFO_HASH, seedRatioGoal: 2 }, config());
|
||||
await adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, clientKey: INFO_HASH, seedRatioGoal: 2 }, config());
|
||||
|
||||
const set = calls.find((call) => call.method === 'torrent-set');
|
||||
expect(set?.args).toEqual({ ids: [INFO_HASH], seedRatioLimit: 2, seedRatioMode: 1 });
|
||||
@@ -195,8 +195,8 @@ describe('TransmissionAdapter', () => {
|
||||
handlers.set('torrent-add', () => success({ 'torrent-added': { hashString: INFO_HASH } }));
|
||||
handlers.set('torrent-set', () => new Response('', { status: 500 }));
|
||||
|
||||
await expect(adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, infoHash: INFO_HASH, seedRatioGoal: 2 }, config())).resolves.toEqual({
|
||||
clientHash: INFO_HASH,
|
||||
await expect(adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, clientKey: INFO_HASH, seedRatioGoal: 2 }, config())).resolves.toEqual({
|
||||
clientKey: INFO_HASH,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -209,14 +209,14 @@ describe('TransmissionAdapter', () => {
|
||||
const { calls, handlers } = mockRpc();
|
||||
handlers.set('torrent-add', () => success({ 'torrent-added': { hashString: INFO_HASH } }));
|
||||
|
||||
await adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, infoHash: INFO_HASH, seedTimeMinutes: 4320 }, config());
|
||||
await adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, clientKey: INFO_HASH, seedTimeMinutes: 4320 }, config());
|
||||
|
||||
expect(calls.find((call) => call.method === 'torrent-set')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('rejects a grab carrying neither a magnet nor a file', async () => {
|
||||
mockRpc();
|
||||
await expect(adapter.add({ infoHash: INFO_HASH }, config())).rejects.toThrow(BadRequestException);
|
||||
await expect(adapter.add({ clientKey: INFO_HASH }, config())).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
/** `..` is inside the category's permitted character set and would escape the download root. */
|
||||
@@ -225,7 +225,7 @@ describe('TransmissionAdapter', () => {
|
||||
handlers.set('session-get', () => success({ 'download-dir': '/downloads' }));
|
||||
handlers.set('torrent-add', () => success({ 'torrent-added': { hashString: INFO_HASH } }));
|
||||
|
||||
await adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, infoHash: INFO_HASH }, config({ category: '..' }));
|
||||
await adapter.add({ magnet: `magnet:?xt=urn:btih:${INFO_HASH}`, clientKey: INFO_HASH }, config({ category: '..' }));
|
||||
|
||||
expect(calls.find((call) => call.method === 'torrent-add')?.args['download-dir']).toBeUndefined();
|
||||
});
|
||||
@@ -260,7 +260,7 @@ describe('TransmissionAdapter', () => {
|
||||
|
||||
await expect(adapter.status([INFO_HASH], config())).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
infoHash: INFO_HASH,
|
||||
clientKey: INFO_HASH,
|
||||
state: 'downloading',
|
||||
progressPercent: 50,
|
||||
downloadedBytes: 512,
|
||||
|
||||
@@ -94,7 +94,7 @@ export class TransmissionAdapter implements DownloadClientAdapter {
|
||||
/** The CSRF token the daemon issued, per client row. Not a login: it survives no restart. */
|
||||
private readonly sessions = new Map<number, string>();
|
||||
|
||||
async add(release: GrabPayload, config: ResolvedClientConfig): Promise<{ clientHash: string }> {
|
||||
async add(release: GrabPayload, config: ResolvedClientConfig): Promise<{ clientKey: string }> {
|
||||
const args: Record<string, unknown> = { paused: false };
|
||||
if (release.torrentFile) {
|
||||
args.metainfo = release.torrentFile.toString('base64');
|
||||
@@ -121,14 +121,14 @@ export class TransmissionAdapter implements DownloadClientAdapter {
|
||||
}
|
||||
// The poll loop asks about the hash the grab was recorded under, so a client that named a
|
||||
// different one would leave the download sitting in `queued` until the watchdog gave up.
|
||||
if (hash !== release.infoHash.toLowerCase()) {
|
||||
if (hash !== release.clientKey.toLowerCase()) {
|
||||
this.logger.warn(
|
||||
`[download_client.add] [fail] clientId=${config.id} hash=${hash} expected=${release.infoHash.toLowerCase()} - Transmission named a different infohash`,
|
||||
`[download_client.add] [fail] clientId=${config.id} hash=${hash} expected=${release.clientKey.toLowerCase()} - Transmission named a different infohash`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.applySeedGoal(hash, release, config);
|
||||
return { clientHash: hash };
|
||||
return { clientKey: hash };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -288,7 +288,7 @@ function toDownloadStatus(hash: string, entry: TransmissionTorrent): DownloadSta
|
||||
const totalBytes = entry.sizeWhenDone ?? entry.totalSize ?? null;
|
||||
const state = mapState(entry);
|
||||
return {
|
||||
infoHash: hash,
|
||||
clientKey: hash,
|
||||
state,
|
||||
progressPercent: Math.max(0, Math.min(100, Math.round((entry.percentDone ?? 0) * 100))),
|
||||
downloadedBytes: entry.downloadedEver ?? 0,
|
||||
|
||||
@@ -14,19 +14,20 @@ export interface ResolvedClientConfig {
|
||||
}
|
||||
|
||||
export interface GrabPayload {
|
||||
/** Exactly one of these three is set. */
|
||||
/** Exactly one delivery payload is set. */
|
||||
magnet?: string;
|
||||
torrentFile?: Buffer;
|
||||
torrentFileName?: string;
|
||||
nzbFile?: Buffer;
|
||||
nzbFileName?: string;
|
||||
/** A source that serves the file itself, resolved by the indexer adapter to one direct URL. */
|
||||
fileUrl?: string;
|
||||
fileName?: string;
|
||||
/**
|
||||
* How the client and the poll loop identify this download. An infohash for a torrent; for a
|
||||
* direct download there is no such thing, so the caller derives a stable digest of the URL,
|
||||
* which keeps the same shape and keeps the duplicate-grab unique index meaningful.
|
||||
* How the client and poll loop identify this download. It is an infohash for a torrent and a
|
||||
* stable digest for other deliveries, keeping duplicate-grab protection client-independent.
|
||||
*/
|
||||
infoHash: string;
|
||||
clientKey: string;
|
||||
/**
|
||||
* Passed to the client at add time so the client, not BookOrbit, enforces the seed goal.
|
||||
* Unset in phase 2: there is no indexer to take a goal from yet.
|
||||
@@ -54,7 +55,7 @@ export interface SeedInfo {
|
||||
}
|
||||
|
||||
export interface DownloadStatus {
|
||||
infoHash: string;
|
||||
clientKey: string;
|
||||
state: DownloadState;
|
||||
/** 0-100. */
|
||||
progressPercent: number;
|
||||
@@ -89,14 +90,14 @@ export interface DownloadClientAdapter {
|
||||
/** What this client can be handed, which is what decides whether a given grab may go to it. */
|
||||
readonly delivers: DownloadDelivery;
|
||||
|
||||
add(release: GrabPayload, config: ResolvedClientConfig): Promise<{ clientHash: string }>;
|
||||
add(release: GrabPayload, config: ResolvedClientConfig): Promise<{ clientKey: string }>;
|
||||
/**
|
||||
* Batched deliberately: one poll tick is one HTTP call per client however many downloads are
|
||||
* in flight. A hash the client no longer knows about is simply absent from the result.
|
||||
* in flight. A key the client no longer knows about is simply absent from the result.
|
||||
*/
|
||||
status(hashes: string[], config: ResolvedClientConfig): Promise<DownloadStatus[]>;
|
||||
status(clientKeys: string[], config: ResolvedClientConfig): Promise<DownloadStatus[]>;
|
||||
listOwned(config: ResolvedClientConfig): Promise<OwnedDownloadClientInventory>;
|
||||
remove(hash: string, config: ResolvedClientConfig, opts: { deleteFiles: boolean }): Promise<void>;
|
||||
remove(clientKey: string, config: ResolvedClientConfig, opts: { deleteFiles: boolean }): Promise<void>;
|
||||
test(config: ResolvedClientConfig): Promise<DownloadClientTestResult>;
|
||||
/**
|
||||
* Drop anything cached against this client id. Called whenever a row's URL or credentials
|
||||
|
||||
+13
-5
@@ -94,16 +94,17 @@ describe('DownloadClientConfigService', () => {
|
||||
});
|
||||
|
||||
/** An approver may pick a client without being trusted with how to reach it. */
|
||||
it('summarises only compatible enabled clients as names and ids', async () => {
|
||||
it('summarises enabled clients with their delivery capability', async () => {
|
||||
const { service } = makeService({
|
||||
repo: {
|
||||
findAllEnabled: vi.fn().mockResolvedValue([clientRow()]),
|
||||
findAllEnabled: vi.fn().mockResolvedValue([clientRow(), clientRow({ id: 5, name: 'usenet', adapterType: 'nzbget' })]),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.findEnabledSummaries('torrent')).resolves.toEqual([{ id: 4, name: 'qbit', color: null }]);
|
||||
// Direct files are fetched by BookOrbit itself, so there is never a client row to pick.
|
||||
await expect(service.findEnabledSummaries('file')).resolves.toEqual([]);
|
||||
await expect(service.findEnabledSummaries()).resolves.toEqual([
|
||||
{ id: 4, name: 'qbit', color: null, delivery: 'torrent' },
|
||||
{ id: 5, name: 'usenet', color: null, delivery: 'usenet' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('answers with nothing rather than inventing a client when none is configured', async () => {
|
||||
@@ -126,6 +127,13 @@ describe('DownloadClientConfigService', () => {
|
||||
expect(adapter.forget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses to change the type of a saved client', async () => {
|
||||
const { service, repo } = makeService();
|
||||
|
||||
await expect(service.update(4, { adapterType: 'nzbget' })).rejects.toThrow('cannot change type');
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('persists an assigned color without reopening the client connection', async () => {
|
||||
const { service, repo, adapter } = makeService();
|
||||
await service.update(4, { color: 'green' });
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
DownloadClientSummary,
|
||||
DownloadClientTestResult,
|
||||
DownloadClientType,
|
||||
DownloadDelivery,
|
||||
PathMappingHardlinkTestResult,
|
||||
} from '@bookorbit/types';
|
||||
import { DOWNLOAD_CLIENT_DELIVERY } from '@bookorbit/types';
|
||||
@@ -56,11 +55,14 @@ export class DownloadClientConfigService {
|
||||
* one answers to `ManageBookRequests`, and a base URL or a `hasPassword` flag is not something
|
||||
* moderating a queue should carry with it.
|
||||
*/
|
||||
async findEnabledSummaries(delivery: DownloadDelivery): Promise<DownloadClientSummary[]> {
|
||||
async findEnabledSummaries(): Promise<DownloadClientSummary[]> {
|
||||
const rows = await this.repo.findAllEnabled();
|
||||
return rows
|
||||
.filter((row) => DOWNLOAD_CLIENT_DELIVERY[row.adapterType as DownloadClientType] === delivery)
|
||||
.map((row) => ({ id: row.id, name: row.name, color: row.color ?? null }));
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
color: row.color ?? null,
|
||||
delivery: DOWNLOAD_CLIENT_DELIVERY[row.adapterType as DownloadClientType],
|
||||
}));
|
||||
}
|
||||
|
||||
async findOne(id: number): Promise<DownloadClientItem> {
|
||||
@@ -103,6 +105,9 @@ export class DownloadClientConfigService {
|
||||
|
||||
async update(id: number, dto: UpdateDownloadClientDto): Promise<DownloadClientItem> {
|
||||
const existing = await this.requireClient(id);
|
||||
if (dto.adapterType !== undefined && dto.adapterType !== existing.client.adapterType) {
|
||||
throw new BadRequestException('A saved download client cannot change type. Create a separate client instead.');
|
||||
}
|
||||
|
||||
const baseUrl = dto.baseUrl?.trim() ?? existing.client.baseUrl;
|
||||
const allowPrivate = dto.allowPrivateAddress ?? existing.client.allowPrivateAddress;
|
||||
@@ -240,7 +245,6 @@ export class DownloadClientConfigService {
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
* Clients usually live on the LAN, so `allowPrivate` is a per-row opt-in with the implication
|
||||
* stated in the UI rather than a blanket relaxation.
|
||||
*/
|
||||
|
||||
+32
-32
@@ -4,27 +4,27 @@ import type { BookRequestDownloadRow } from '../../../db/schema';
|
||||
import type { ReconciliationAttemptRow } from '../fulfillment/book-request-download.repository';
|
||||
import { DownloadClientReconciliationService } from './download-client-reconciliation.service';
|
||||
|
||||
const CLIENT_HASH = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
const ORPHAN_HASH = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
|
||||
const MISSING_HASH = 'cccccccccccccccccccccccccccccccccccccccc';
|
||||
const CLIENT_KEY = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
|
||||
const ORPHAN_KEY = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
|
||||
const MISSING_KEY = 'cccccccccccccccccccccccccccccccccccccccc';
|
||||
|
||||
function attempt(
|
||||
id: number,
|
||||
requestId: number,
|
||||
clientHash: string,
|
||||
clientKey: string,
|
||||
status: BookRequestDownloadRow['status'],
|
||||
requestTitle: string,
|
||||
): ReconciliationAttemptRow {
|
||||
return {
|
||||
download: { id, requestId, clientHash, status } as BookRequestDownloadRow,
|
||||
download: { id, requestId, clientKey, status } as BookRequestDownloadRow,
|
||||
requestTitle,
|
||||
};
|
||||
}
|
||||
|
||||
function owned(infoHash: string, state: 'queued' | 'downloading' | 'completed' = 'downloading') {
|
||||
function owned(clientKey: string, state: 'queued' | 'downloading' | 'completed' = 'downloading') {
|
||||
return {
|
||||
infoHash,
|
||||
name: `Item ${infoHash.slice(0, 4)}`,
|
||||
clientKey,
|
||||
name: `Item ${clientKey.slice(0, 4)}`,
|
||||
state,
|
||||
progressPercent: state === 'completed' ? 100 : 35,
|
||||
downloadedBytes: 350,
|
||||
@@ -43,8 +43,8 @@ function makeService() {
|
||||
};
|
||||
const registry = { require: vi.fn().mockReturnValue(adapter) };
|
||||
const downloads = {
|
||||
findTrackedForClientHashes: vi.fn().mockResolvedValue([]),
|
||||
findAdoptableForHashes: vi.fn().mockResolvedValue([]),
|
||||
findTrackedForClientKeys: vi.fn().mockResolvedValue([]),
|
||||
findAdoptableForClientKeys: vi.fn().mockResolvedValue([]),
|
||||
findActiveForClient: vi.fn().mockResolvedValue([]),
|
||||
adoptFailedAttempt: vi.fn(),
|
||||
};
|
||||
@@ -57,16 +57,16 @@ function makeService() {
|
||||
describe('DownloadClientReconciliationService', () => {
|
||||
it('diffs the client inventory against tracked, adoptable, and missing attempts', async () => {
|
||||
const { service, adapter, downloads } = makeService();
|
||||
const tracked = attempt(11, 7, CLIENT_HASH, 'downloading', 'Tracked book');
|
||||
const adoptable = attempt(12, 8, ORPHAN_HASH, 'failed', 'Retry this book');
|
||||
const missing = attempt(13, 9, MISSING_HASH, 'queued', 'Missing book');
|
||||
const tracked = attempt(11, 7, CLIENT_KEY, 'downloading', 'Tracked book');
|
||||
const adoptable = attempt(12, 8, ORPHAN_KEY, 'failed', 'Retry this book');
|
||||
const missing = attempt(13, 9, MISSING_KEY, 'queued', 'Missing book');
|
||||
adapter.listOwned.mockResolvedValue({
|
||||
supported: true,
|
||||
truncated: false,
|
||||
items: [owned(CLIENT_HASH), owned(ORPHAN_HASH, 'completed')],
|
||||
items: [owned(CLIENT_KEY), owned(ORPHAN_KEY, 'completed')],
|
||||
});
|
||||
downloads.findTrackedForClientHashes.mockResolvedValue([tracked]);
|
||||
downloads.findAdoptableForHashes.mockResolvedValue([adoptable]);
|
||||
downloads.findTrackedForClientKeys.mockResolvedValue([tracked]);
|
||||
downloads.findAdoptableForClientKeys.mockResolvedValue([adoptable]);
|
||||
downloads.findActiveForClient.mockResolvedValue([tracked, missing]);
|
||||
|
||||
const result = await service.reconcile(4);
|
||||
@@ -78,12 +78,12 @@ describe('DownloadClientReconciliationService', () => {
|
||||
truncated: false,
|
||||
items: [
|
||||
{
|
||||
infoHash: CLIENT_HASH,
|
||||
clientKey: CLIENT_KEY,
|
||||
trackedAttempt: { downloadId: 11, requestId: 7, requestTitle: 'Tracked book', status: 'downloading' },
|
||||
adoptableAttempts: [],
|
||||
},
|
||||
{
|
||||
infoHash: ORPHAN_HASH,
|
||||
clientKey: ORPHAN_KEY,
|
||||
trackedAttempt: null,
|
||||
adoptableAttempts: [{ downloadId: 12, requestId: 8, requestTitle: 'Retry this book', status: 'failed' }],
|
||||
},
|
||||
@@ -94,7 +94,7 @@ describe('DownloadClientReconciliationService', () => {
|
||||
|
||||
it('does not report missing attempts from a truncated client inventory', async () => {
|
||||
const { service, adapter, downloads } = makeService();
|
||||
adapter.listOwned.mockResolvedValue({ supported: true, truncated: true, items: [owned(CLIENT_HASH)] });
|
||||
adapter.listOwned.mockResolvedValue({ supported: true, truncated: true, items: [owned(CLIENT_KEY)] });
|
||||
|
||||
const result = await service.reconcile(4);
|
||||
|
||||
@@ -108,24 +108,24 @@ describe('DownloadClientReconciliationService', () => {
|
||||
adapter.listOwned.mockResolvedValue({ supported: false, truncated: false, items: [] });
|
||||
|
||||
await expect(service.reconcile(4)).resolves.toMatchObject({ supported: false, ownershipMarker: 'bookorbit', items: [] });
|
||||
expect(downloads.findTrackedForClientHashes).not.toHaveBeenCalled();
|
||||
expect(downloads.findTrackedForClientKeys).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rechecks the client item and adopts the exact failed attempt atomically', async () => {
|
||||
const { service, adapter, downloads, gateway } = makeService();
|
||||
const candidate = attempt(12, 8, ORPHAN_HASH, 'failed', 'Retry this book');
|
||||
const candidate = attempt(12, 8, ORPHAN_KEY, 'failed', 'Retry this book');
|
||||
const adopted = { ...candidate.download, downloadClientId: 4, status: 'completed' } as BookRequestDownloadRow;
|
||||
adapter.listOwned.mockResolvedValue({ supported: true, truncated: false, items: [owned(ORPHAN_HASH, 'completed')] });
|
||||
downloads.findAdoptableForHashes.mockResolvedValue([candidate]);
|
||||
adapter.listOwned.mockResolvedValue({ supported: true, truncated: false, items: [owned(ORPHAN_KEY, 'completed')] });
|
||||
downloads.findAdoptableForClientKeys.mockResolvedValue([candidate]);
|
||||
downloads.adoptFailedAttempt.mockResolvedValue(adopted);
|
||||
|
||||
await expect(service.adopt(4, ORPHAN_HASH.toUpperCase(), 12)).resolves.toEqual({
|
||||
await expect(service.adopt(4, ORPHAN_KEY.toUpperCase(), 12)).resolves.toEqual({
|
||||
downloadId: 12,
|
||||
requestId: 8,
|
||||
requestTitle: 'Retry this book',
|
||||
status: 'completed',
|
||||
});
|
||||
expect(downloads.adoptFailedAttempt).toHaveBeenCalledWith(12, 4, ORPHAN_HASH, {
|
||||
expect(downloads.adoptFailedAttempt).toHaveBeenCalledWith(12, 4, ORPHAN_KEY, {
|
||||
status: 'completed',
|
||||
progressPercent: 100,
|
||||
downloadedBytes: 350,
|
||||
@@ -137,10 +137,10 @@ describe('DownloadClientReconciliationService', () => {
|
||||
|
||||
it('refuses to remove an item once any database attempt owns it', async () => {
|
||||
const { service, adapter, downloads } = makeService();
|
||||
adapter.listOwned.mockResolvedValue({ supported: true, truncated: false, items: [owned(ORPHAN_HASH)] });
|
||||
downloads.findTrackedForClientHashes.mockResolvedValue([attempt(12, 8, ORPHAN_HASH, 'failed', 'Tracked book')]);
|
||||
adapter.listOwned.mockResolvedValue({ supported: true, truncated: false, items: [owned(ORPHAN_KEY)] });
|
||||
downloads.findTrackedForClientKeys.mockResolvedValue([attempt(12, 8, ORPHAN_KEY, 'failed', 'Tracked book')]);
|
||||
|
||||
await expect(service.removeOrphan(4, ORPHAN_HASH, false)).rejects.toMatchObject({
|
||||
await expect(service.removeOrphan(4, ORPHAN_KEY, false)).rejects.toMatchObject({
|
||||
response: { errorCode: 'DOWNLOAD_CLIENT_RECONCILIATION_NOT_ORPHAN' },
|
||||
});
|
||||
expect(adapter.remove).not.toHaveBeenCalled();
|
||||
@@ -148,14 +148,14 @@ describe('DownloadClientReconciliationService', () => {
|
||||
|
||||
it('removes a freshly confirmed orphan while preserving its files', async () => {
|
||||
const { service, adapter } = makeService();
|
||||
adapter.listOwned.mockResolvedValue({ supported: true, truncated: false, items: [owned(ORPHAN_HASH)] });
|
||||
adapter.listOwned.mockResolvedValue({ supported: true, truncated: false, items: [owned(ORPHAN_KEY)] });
|
||||
|
||||
await service.removeOrphan(4, ORPHAN_HASH, false);
|
||||
await service.removeOrphan(4, ORPHAN_KEY, false);
|
||||
|
||||
expect(adapter.remove).toHaveBeenCalledWith(ORPHAN_HASH, expect.anything(), { deleteFiles: false });
|
||||
expect(adapter.remove).toHaveBeenCalledWith(ORPHAN_KEY, expect.anything(), { deleteFiles: false });
|
||||
});
|
||||
|
||||
it('rejects malformed hashes before asking the client', async () => {
|
||||
it('rejects malformed client keys before asking the client', async () => {
|
||||
const { service, adapter } = makeService();
|
||||
|
||||
await expect(service.adopt(4, '../not-a-hash', 12)).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
+32
-32
@@ -39,15 +39,15 @@ export class DownloadClientReconciliationService {
|
||||
};
|
||||
}
|
||||
|
||||
const hashes = [...new Set(inventory.items.map((item) => item.infoHash.toLowerCase()))];
|
||||
const clientKeys = [...new Set(inventory.items.map((item) => item.clientKey.toLowerCase()))];
|
||||
const [tracked, adoptable, active] = await Promise.all([
|
||||
this.downloads.findTrackedForClientHashes(clientId, hashes),
|
||||
this.downloads.findAdoptableForHashes(hashes),
|
||||
this.downloads.findTrackedForClientKeys(clientId, clientKeys),
|
||||
this.downloads.findAdoptableForClientKeys(clientKeys),
|
||||
inventory.truncated ? Promise.resolve([]) : this.downloads.findActiveForClient(clientId),
|
||||
]);
|
||||
const trackedByHash = firstByHash(tracked);
|
||||
const adoptableByHash = groupByHash(adoptable);
|
||||
const inventoryHashes = new Set(hashes);
|
||||
const trackedByKey = firstByClientKey(tracked);
|
||||
const adoptableByKey = groupByClientKey(adoptable);
|
||||
const inventoryKeys = new Set(clientKeys);
|
||||
|
||||
return {
|
||||
clientId,
|
||||
@@ -55,40 +55,40 @@ export class DownloadClientReconciliationService {
|
||||
ownershipMarker: config.category,
|
||||
truncated: inventory.truncated,
|
||||
items: inventory.items.map((item) => ({
|
||||
infoHash: item.infoHash.toLowerCase(),
|
||||
clientKey: item.clientKey.toLowerCase(),
|
||||
name: item.name,
|
||||
state: item.state,
|
||||
progressPercent: item.progressPercent,
|
||||
trackedAttempt: toAttempt(trackedByHash.get(item.infoHash.toLowerCase()) ?? null),
|
||||
trackedAttempt: toAttempt(trackedByKey.get(item.clientKey.toLowerCase()) ?? null),
|
||||
adoptableAttempts: ADOPTABLE_STATES.has(item.state)
|
||||
? (adoptableByHash.get(item.infoHash.toLowerCase()) ?? []).map((row) => toAttempt(row))
|
||||
? (adoptableByKey.get(item.clientKey.toLowerCase()) ?? []).map((row) => toAttempt(row))
|
||||
: [],
|
||||
})),
|
||||
missingAttempts: active
|
||||
.filter((row) => row.download.clientHash !== null && !inventoryHashes.has(row.download.clientHash.toLowerCase()))
|
||||
.filter((row) => row.download.clientKey !== null && !inventoryKeys.has(row.download.clientKey.toLowerCase()))
|
||||
.map((row) => toAttempt(row)),
|
||||
};
|
||||
}
|
||||
|
||||
async adopt(clientId: number, infoHash: string, downloadId: number): Promise<DownloadClientReconciliationAttempt> {
|
||||
const hash = normalizeHash(infoHash);
|
||||
async adopt(clientId: number, clientKey: string, downloadId: number): Promise<DownloadClientReconciliationAttempt> {
|
||||
const key = normalizeKey(clientKey);
|
||||
const config = await this.clients.resolveConfig(clientId);
|
||||
const inventory = await this.registry.require(config.adapterType).listOwned(config);
|
||||
if (!inventory.supported) throw reconciliationError('DOWNLOAD_CLIENT_RECONCILIATION_UNSUPPORTED', 'This client cannot enumerate owned downloads');
|
||||
|
||||
const item = inventory.items.find((entry) => entry.infoHash.toLowerCase() === hash);
|
||||
const item = inventory.items.find((entry) => entry.clientKey.toLowerCase() === key);
|
||||
if (!item || !ADOPTABLE_STATES.has(item.state)) {
|
||||
throw reconciliationError('DOWNLOAD_CLIENT_RECONCILIATION_NOT_ADOPTABLE', 'That client item is no longer available to adopt');
|
||||
}
|
||||
|
||||
const candidates = await this.downloads.findAdoptableForHashes([hash]);
|
||||
const candidates = await this.downloads.findAdoptableForClientKeys([key]);
|
||||
const candidate = candidates.find((entry) => entry.download.id === downloadId);
|
||||
if (!candidate) {
|
||||
throw reconciliationError('DOWNLOAD_CLIENT_RECONCILIATION_NOT_ADOPTABLE', 'That attempt can no longer adopt this client item');
|
||||
}
|
||||
|
||||
const status = adoptionStatus(item.state);
|
||||
const adopted = await this.downloads.adoptFailedAttempt(downloadId, clientId, hash, {
|
||||
const adopted = await this.downloads.adoptFailedAttempt(downloadId, clientId, key, {
|
||||
status,
|
||||
progressPercent: item.progressPercent,
|
||||
downloadedBytes: item.downloadedBytes,
|
||||
@@ -99,34 +99,34 @@ export class DownloadClientReconciliationService {
|
||||
|
||||
this.gateway.emitChanged();
|
||||
this.logger.log(
|
||||
`[download_client.reconcile] [end] clientId=${clientId} downloadId=${downloadId} requestId=${adopted.requestId} hash=${hash} action=adopt - client item attached to its failed attempt`,
|
||||
`[download_client.reconcile] [end] clientId=${clientId} downloadId=${downloadId} requestId=${adopted.requestId} key=${key} action=adopt - client item attached to its failed attempt`,
|
||||
);
|
||||
return toAttempt({ download: adopted, requestTitle: candidate.requestTitle });
|
||||
}
|
||||
|
||||
async removeOrphan(clientId: number, infoHash: string, deleteFiles: boolean): Promise<void> {
|
||||
const hash = normalizeHash(infoHash);
|
||||
async removeOrphan(clientId: number, clientKey: string, deleteFiles: boolean): Promise<void> {
|
||||
const key = normalizeKey(clientKey);
|
||||
const config = await this.clients.resolveConfig(clientId);
|
||||
const adapter = this.registry.require(config.adapterType);
|
||||
const inventory = await adapter.listOwned(config);
|
||||
if (!inventory.supported) throw reconciliationError('DOWNLOAD_CLIENT_RECONCILIATION_UNSUPPORTED', 'This client cannot enumerate owned downloads');
|
||||
if (!inventory.items.some((entry) => entry.infoHash.toLowerCase() === hash)) throw new NotFoundException('That client item no longer exists');
|
||||
if (!inventory.items.some((entry) => entry.clientKey.toLowerCase() === key)) throw new NotFoundException('That client item no longer exists');
|
||||
|
||||
const tracked = await this.downloads.findTrackedForClientHashes(clientId, [hash]);
|
||||
const tracked = await this.downloads.findTrackedForClientKeys(clientId, [key]);
|
||||
if (tracked.length > 0) {
|
||||
throw reconciliationError('DOWNLOAD_CLIENT_RECONCILIATION_NOT_ORPHAN', 'That client item is still attached to a download attempt');
|
||||
}
|
||||
|
||||
await adapter.remove(hash, config, { deleteFiles });
|
||||
await adapter.remove(key, config, { deleteFiles });
|
||||
this.logger.log(
|
||||
`[download_client.reconcile] [end] clientId=${clientId} hash=${hash} action=remove deleteFiles=${deleteFiles} - orphaned client item removed`,
|
||||
`[download_client.reconcile] [end] clientId=${clientId} key=${key} action=remove deleteFiles=${deleteFiles} - orphaned client item removed`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHash(value: string): string {
|
||||
function normalizeKey(value: string): string {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!/^[a-f0-9]{40,64}$/.test(normalized)) throw new BadRequestException('Invalid client hash');
|
||||
if (!/^[a-f0-9]{40,64}$/.test(normalized)) throw new BadRequestException('Invalid client key');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
@@ -136,23 +136,23 @@ function adoptionStatus(state: OwnedDownloadClientItem['state']): Extract<BookRe
|
||||
return 'downloading';
|
||||
}
|
||||
|
||||
function firstByHash(rows: ReconciliationAttemptRow[]): Map<string, ReconciliationAttemptRow> {
|
||||
function firstByClientKey(rows: ReconciliationAttemptRow[]): Map<string, ReconciliationAttemptRow> {
|
||||
const result = new Map<string, ReconciliationAttemptRow>();
|
||||
for (const row of rows) {
|
||||
const hash = row.download.clientHash?.toLowerCase();
|
||||
if (hash && !result.has(hash)) result.set(hash, row);
|
||||
const key = row.download.clientKey?.toLowerCase();
|
||||
if (key && !result.has(key)) result.set(key, row);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function groupByHash(rows: ReconciliationAttemptRow[]): Map<string, ReconciliationAttemptRow[]> {
|
||||
function groupByClientKey(rows: ReconciliationAttemptRow[]): Map<string, ReconciliationAttemptRow[]> {
|
||||
const result = new Map<string, ReconciliationAttemptRow[]>();
|
||||
for (const row of rows) {
|
||||
const hash = row.download.clientHash?.toLowerCase();
|
||||
if (!hash) continue;
|
||||
const bucket = result.get(hash) ?? [];
|
||||
const key = row.download.clientKey?.toLowerCase();
|
||||
if (!key) continue;
|
||||
const bucket = result.get(key) ?? [];
|
||||
bucket.push(row);
|
||||
result.set(hash, bucket);
|
||||
result.set(key, bucket);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -36,18 +36,18 @@ export class DownloadClientController {
|
||||
return this.reconciliation.reconcile(id);
|
||||
}
|
||||
|
||||
@Post(':id/reconciliation/:hash/adopt')
|
||||
@Post(':id/reconciliation/:clientKey/adopt')
|
||||
@Auditable({
|
||||
action: AuditAction.DownloadClientUpdate,
|
||||
resource: AuditResource.DownloadClient,
|
||||
getResourceId: (req) => Number(req.params.id),
|
||||
description: (req) => `Adopted an owned download into client #${req.params.id}`,
|
||||
})
|
||||
adopt(@Param('id', ParseIntPipe) id: number, @Param('hash') hash: string, @Body() dto: AdoptDownloadClientItemDto) {
|
||||
return this.reconciliation.adopt(id, hash, dto.downloadId);
|
||||
adopt(@Param('id', ParseIntPipe) id: number, @Param('clientKey') clientKey: string, @Body() dto: AdoptDownloadClientItemDto) {
|
||||
return this.reconciliation.adopt(id, clientKey, dto.downloadId);
|
||||
}
|
||||
|
||||
@Delete(':id/reconciliation/:hash')
|
||||
@Delete(':id/reconciliation/:clientKey')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@Auditable({
|
||||
action: AuditAction.DownloadClientUpdate,
|
||||
@@ -55,8 +55,8 @@ export class DownloadClientController {
|
||||
getResourceId: (req) => Number(req.params.id),
|
||||
description: (req) => `Removed an orphaned owned download from client #${req.params.id}`,
|
||||
})
|
||||
removeOrphan(@Param('id', ParseIntPipe) id: number, @Param('hash') hash: string, @Body() dto: RemoveOrphanedDownloadClientItemDto) {
|
||||
return this.reconciliation.removeOrphan(id, hash, dto.deleteFiles ?? false);
|
||||
removeOrphan(@Param('id', ParseIntPipe) id: number, @Param('clientKey') clientKey: string, @Body() dto: RemoveOrphanedDownloadClientItemDto) {
|
||||
return this.reconciliation.removeOrphan(id, clientKey, dto.deleteFiles ?? false);
|
||||
}
|
||||
|
||||
@Post()
|
||||
|
||||
@@ -198,8 +198,8 @@ export class BookRequestDownloadRepository {
|
||||
/**
|
||||
* Attempts one download client is still working on, which is what makes deleting it unsafe.
|
||||
*
|
||||
* The FK nulls on delete, so a torrent whose client is removed stops being pollable, mappable
|
||||
* and removable in the same instant while the client goes on seeding it. Counting first is what
|
||||
* The FK nulls on delete, so a client-managed download stops being pollable, mappable and
|
||||
* removable in the same instant while the client continues handling it. Counting first is what
|
||||
* lets the operator be told that rather than discover it.
|
||||
*/
|
||||
async countInFlightForClient(clientId: number): Promise<number> {
|
||||
@@ -212,25 +212,25 @@ export class BookRequestDownloadRepository {
|
||||
return row?.total ?? 0;
|
||||
}
|
||||
|
||||
async findTrackedForClientHashes(clientId: number, hashes: string[]): Promise<ReconciliationAttemptRow[]> {
|
||||
if (hashes.length === 0) return [];
|
||||
async findTrackedForClientKeys(clientId: number, clientKeys: string[]): Promise<ReconciliationAttemptRow[]> {
|
||||
if (clientKeys.length === 0) return [];
|
||||
return this.db
|
||||
.select({ download: bookRequestDownloads, requestTitle: bookRequests.title })
|
||||
.from(bookRequestDownloads)
|
||||
.innerJoin(bookRequests, eq(bookRequests.id, bookRequestDownloads.requestId))
|
||||
.where(and(eq(bookRequestDownloads.downloadClientId, clientId), inArray(bookRequestDownloads.clientHash, hashes)))
|
||||
.where(and(eq(bookRequestDownloads.downloadClientId, clientId), inArray(bookRequestDownloads.clientKey, clientKeys)))
|
||||
.orderBy(desc(bookRequestDownloads.id));
|
||||
}
|
||||
|
||||
async findAdoptableForHashes(hashes: string[]): Promise<ReconciliationAttemptRow[]> {
|
||||
if (hashes.length === 0) return [];
|
||||
async findAdoptableForClientKeys(clientKeys: string[]): Promise<ReconciliationAttemptRow[]> {
|
||||
if (clientKeys.length === 0) return [];
|
||||
return this.db
|
||||
.select({ download: bookRequestDownloads, requestTitle: bookRequests.title })
|
||||
.from(bookRequestDownloads)
|
||||
.innerJoin(bookRequests, eq(bookRequests.id, bookRequestDownloads.requestId))
|
||||
.where(
|
||||
and(
|
||||
inArray(bookRequestDownloads.clientHash, hashes),
|
||||
inArray(bookRequestDownloads.clientKey, clientKeys),
|
||||
eq(bookRequestDownloads.status, 'failed'),
|
||||
inArray(bookRequests.status, [...GRABBABLE_BOOK_REQUEST_STATUSES]),
|
||||
),
|
||||
@@ -254,7 +254,7 @@ export class BookRequestDownloadRepository {
|
||||
async adoptFailedAttempt(
|
||||
downloadId: number,
|
||||
clientId: number,
|
||||
clientHash: string,
|
||||
clientKey: string,
|
||||
data: Pick<NewBookRequestDownloadRow, 'status' | 'progressPercent' | 'downloadedBytes' | 'totalBytes' | 'contentPath'>,
|
||||
): Promise<BookRequestDownloadRow | undefined> {
|
||||
try {
|
||||
@@ -263,7 +263,7 @@ export class BookRequestDownloadRepository {
|
||||
.update(bookRequestDownloads)
|
||||
.set({ ...data, downloadClientId: clientId, errorMessage: null })
|
||||
.where(
|
||||
and(eq(bookRequestDownloads.id, downloadId), eq(bookRequestDownloads.clientHash, clientHash), eq(bookRequestDownloads.status, 'failed')),
|
||||
and(eq(bookRequestDownloads.id, downloadId), eq(bookRequestDownloads.clientKey, clientKey), eq(bookRequestDownloads.status, 'failed')),
|
||||
)
|
||||
.returning();
|
||||
if (!download) throw new AdoptionRaceError();
|
||||
@@ -307,18 +307,18 @@ export class BookRequestDownloadRepository {
|
||||
* an import or a held review has not finished with. Every other directory under the direct
|
||||
* download root is spent, which is what the bootstrap reap acts on.
|
||||
*/
|
||||
async findLiveDirectHashes(): Promise<string[]> {
|
||||
async findLiveDirectClientKeys(): Promise<string[]> {
|
||||
const rows = await this.db
|
||||
.select({ clientHash: bookRequestDownloads.clientHash })
|
||||
.select({ clientKey: bookRequestDownloads.clientKey })
|
||||
.from(bookRequestDownloads)
|
||||
.where(
|
||||
and(
|
||||
eq(bookRequestDownloads.source, 'direct_url'),
|
||||
isNotNull(bookRequestDownloads.clientHash),
|
||||
isNotNull(bookRequestDownloads.clientKey),
|
||||
inArray(bookRequestDownloads.status, [...UNSETTLED_BOOK_REQUEST_DOWNLOAD_STATUSES]),
|
||||
),
|
||||
);
|
||||
return rows.map((row) => row.clientHash).filter((hash): hash is string => hash !== null);
|
||||
return rows.map((row) => row.clientKey).filter((key): key is string => key !== null);
|
||||
}
|
||||
|
||||
/** How many times the automation has already tried this request, which is what bounds retries. */
|
||||
|
||||
@@ -57,7 +57,7 @@ describe('DirectDownloadService', () => {
|
||||
it('writes the file into its staging directory and reports where it landed', async () => {
|
||||
fetchMock.mockResolvedValue(fileResponse('a real epub would go here'));
|
||||
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', infoHash: HASH });
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
||||
const status = await settle();
|
||||
|
||||
expect(status.state).toBe('completed');
|
||||
@@ -84,7 +84,7 @@ describe('DirectDownloadService', () => {
|
||||
const resumed = await service.resume({
|
||||
id: 44,
|
||||
source: 'direct_url',
|
||||
clientHash: HASH,
|
||||
clientKey: HASH,
|
||||
directUrl: 'https://archive.org/download/x/book.epub',
|
||||
directFileName: 'book.epub',
|
||||
directEtag: '"edition-1"',
|
||||
@@ -109,7 +109,7 @@ describe('DirectDownloadService', () => {
|
||||
await service.resume({
|
||||
id: 45,
|
||||
source: 'direct_url',
|
||||
clientHash: HASH,
|
||||
clientKey: HASH,
|
||||
directUrl: 'https://archive.org/download/x/book.epub',
|
||||
directFileName: 'book.epub',
|
||||
directEtag: '"edition-1"',
|
||||
@@ -132,7 +132,7 @@ describe('DirectDownloadService', () => {
|
||||
await service.resume({
|
||||
id: 46,
|
||||
source: 'direct_url',
|
||||
clientHash: HASH,
|
||||
clientKey: HASH,
|
||||
directUrl: 'https://archive.org/download/x/book.epub',
|
||||
directFileName: 'book.epub',
|
||||
directEtag: '"edition-1"',
|
||||
@@ -159,7 +159,7 @@ describe('DirectDownloadService', () => {
|
||||
await service.resume({
|
||||
id: 47,
|
||||
source: 'direct_url',
|
||||
clientHash: HASH,
|
||||
clientKey: HASH,
|
||||
directUrl: 'https://archive.org/download/x/book.epub',
|
||||
directFileName: 'book.epub',
|
||||
directEtag: '"edition-1"',
|
||||
@@ -180,7 +180,7 @@ describe('DirectDownloadService', () => {
|
||||
service.resume({
|
||||
id: 48,
|
||||
source: 'direct_url',
|
||||
clientHash: HASH,
|
||||
clientKey: HASH,
|
||||
directUrl: 'https://archive.org/download/x/book.epub',
|
||||
directFileName: 'book.epub',
|
||||
directEtag: null,
|
||||
@@ -200,7 +200,7 @@ describe('DirectDownloadService', () => {
|
||||
it('stages a nameless file under the format the source declared', async () => {
|
||||
fetchMock.mockResolvedValue(fileResponse('a real epub would go here'));
|
||||
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/get', fileName: 'download', format: 'epub', infoHash: HASH });
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/get', fileName: 'download', format: 'epub', clientKey: HASH });
|
||||
|
||||
expect((await settle()).contentPath).toBe(join(appDataPath, 'request-downloads', HASH, 'download.epub'));
|
||||
});
|
||||
@@ -213,7 +213,7 @@ describe('DirectDownloadService', () => {
|
||||
it('accepts a content-length that does not divide evenly', async () => {
|
||||
fetchMock.mockResolvedValue(fileResponse('x'.repeat(187712), { 'content-length': '187712' }));
|
||||
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', infoHash: HASH });
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
||||
|
||||
expect((await settle()).state).toBe('completed');
|
||||
});
|
||||
@@ -306,7 +306,7 @@ describe('DirectDownloadService', () => {
|
||||
return Promise.resolve(fileResponse('a real epub would go here'));
|
||||
});
|
||||
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', infoHash: HASH });
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
||||
await until(() => handed !== undefined);
|
||||
// Well past the connect deadline, which by then has nothing left to say about the transfer.
|
||||
vi.advanceTimersByTime(90_000);
|
||||
@@ -327,7 +327,7 @@ describe('DirectDownloadService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', infoHash: HASH });
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
||||
await until(() => asked);
|
||||
vi.advanceTimersByTime(31_000);
|
||||
|
||||
@@ -346,7 +346,7 @@ describe('DirectDownloadService', () => {
|
||||
const trickle = trickleResponse();
|
||||
fetchMock.mockResolvedValue(trickle.response);
|
||||
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', infoHash: HASH });
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
||||
// Ten minutes of transfer, for a file the old size-scaled budget allowed two.
|
||||
const chunk = 'another chunk of the audiobook';
|
||||
for (let minute = 0; minute < 10; minute++) {
|
||||
@@ -366,7 +366,7 @@ describe('DirectDownloadService', () => {
|
||||
const trickle = trickleResponse();
|
||||
fetchMock.mockResolvedValue(trickle.response);
|
||||
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', infoHash: HASH });
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
||||
const chunk = 'the only chunk that ever arrives';
|
||||
trickle.push(chunk);
|
||||
await untilDownloaded(chunk.length);
|
||||
@@ -380,7 +380,7 @@ describe('DirectDownloadService', () => {
|
||||
it('fails a response that is a web page rather than a file', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('<html>not found</html>', { headers: { 'content-type': 'text/html; charset=utf-8' } }));
|
||||
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', infoHash: HASH });
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
||||
const status = await settle();
|
||||
|
||||
expect(status.state).toBe('failed');
|
||||
@@ -394,7 +394,7 @@ describe('DirectDownloadService', () => {
|
||||
it('names itself rather than letting Node announce the request as node', async () => {
|
||||
fetchMock.mockResolvedValue(fileResponse('a real epub would go here'));
|
||||
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', infoHash: HASH });
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
||||
await settle();
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [URL, RequestInit];
|
||||
@@ -404,7 +404,7 @@ describe('DirectDownloadService', () => {
|
||||
it('fails a file that declares itself past the size cap', async () => {
|
||||
fetchMock.mockResolvedValue(fileResponse('x', { 'content-length': String(64 * 1024 * 1024 * 1024) }));
|
||||
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/huge.m4b', fileName: 'huge.m4b', infoHash: HASH });
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/huge.m4b', fileName: 'huge.m4b', clientKey: HASH });
|
||||
|
||||
expect((await settle()).state).toBe('failed');
|
||||
});
|
||||
@@ -412,7 +412,7 @@ describe('DirectDownloadService', () => {
|
||||
it('fails an empty file rather than importing nothing', async () => {
|
||||
fetchMock.mockResolvedValue(fileResponse(''));
|
||||
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', infoHash: HASH });
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
||||
|
||||
expect((await settle()).state).toBe('failed');
|
||||
});
|
||||
@@ -424,7 +424,7 @@ describe('DirectDownloadService', () => {
|
||||
it('refuses a redirect to a private address', async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response('', { status: 302, headers: { location: 'http://127.0.0.1:8080/secret' } }));
|
||||
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', infoHash: HASH });
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
||||
|
||||
expect((await settle()).state).toBe('failed');
|
||||
});
|
||||
@@ -432,7 +432,7 @@ describe('DirectDownloadService', () => {
|
||||
it('gives up rather than following redirects forever', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('', { status: 302, headers: { location: 'https://archive.org/again' } }));
|
||||
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', infoHash: HASH });
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
||||
const status = await settle();
|
||||
|
||||
expect(status.state).toBe('failed');
|
||||
@@ -443,7 +443,7 @@ describe('DirectDownloadService', () => {
|
||||
it('keeps a traversing filename inside the download directory', async () => {
|
||||
fetchMock.mockResolvedValue(fileResponse('payload'));
|
||||
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/b', fileName: '../../escaped.epub', infoHash: HASH });
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/b', fileName: '../../escaped.epub', clientKey: HASH });
|
||||
const status = await settle();
|
||||
|
||||
expect(status.state).toBe('completed');
|
||||
@@ -458,7 +458,7 @@ describe('DirectDownloadService', () => {
|
||||
it('connects only to the address that passed policy', async () => {
|
||||
fetchMock.mockResolvedValue(fileResponse('a real epub would go here'));
|
||||
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', infoHash: HASH });
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
||||
await settle();
|
||||
|
||||
expect(fetchMock.mock.calls[0][1]).toMatchObject({ dispatcher: expect.any(Agent) });
|
||||
@@ -475,7 +475,7 @@ describe('DirectDownloadService', () => {
|
||||
|
||||
it('deletes only the staged copy, the library hardlink being a separate one', async () => {
|
||||
fetchMock.mockResolvedValue(fileResponse('payload'));
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', infoHash: HASH });
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
||||
await settle();
|
||||
|
||||
await service.remove(HASH, { deleteFiles: true });
|
||||
@@ -502,7 +502,7 @@ describe('DirectDownloadService', () => {
|
||||
it('leaves nothing behind when the server answers with a page rather than a file', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('<html>not found</html>', { headers: { 'content-type': 'text/html' } }));
|
||||
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', infoHash: HASH });
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
||||
await settle();
|
||||
|
||||
expect(await stagingExists()).toBe(false);
|
||||
@@ -511,7 +511,7 @@ describe('DirectDownloadService', () => {
|
||||
it('leaves nothing behind when the file declares itself past the size cap', async () => {
|
||||
fetchMock.mockResolvedValue(fileResponse('x', { 'content-length': String(64 * 1024 * 1024 * 1024) }));
|
||||
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/huge.m4b', fileName: 'huge.m4b', infoHash: HASH });
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/huge.m4b', fileName: 'huge.m4b', clientKey: HASH });
|
||||
await settle();
|
||||
|
||||
expect(await stagingExists()).toBe(false);
|
||||
@@ -520,7 +520,7 @@ describe('DirectDownloadService', () => {
|
||||
it('leaves nothing behind when the connection fails outright', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', infoHash: HASH });
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
||||
await settle();
|
||||
|
||||
expect(await stagingExists()).toBe(false);
|
||||
@@ -535,7 +535,7 @@ describe('DirectDownloadService', () => {
|
||||
});
|
||||
fetchMock.mockResolvedValue(new Response(body, { headers: { 'content-type': 'application/epub+zip' } }));
|
||||
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', infoHash: HASH });
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
||||
await settle();
|
||||
|
||||
expect(await stagingExists()).toBe(false);
|
||||
@@ -544,7 +544,7 @@ describe('DirectDownloadService', () => {
|
||||
/** Read once by the poll loop and then never again, so the entry must not outlive the process. */
|
||||
it('drops a terminal entry once nothing could still be reading it', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', infoHash: HASH });
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
||||
expect((await settle()).state).toBe('failed');
|
||||
|
||||
const reported = await atLaterTime(11 * 60 * 1000, () => service.status([HASH]));
|
||||
@@ -555,7 +555,7 @@ describe('DirectDownloadService', () => {
|
||||
/** Dropping it too early would replace the reason it failed with "the client has never heard of it". */
|
||||
it('keeps a terminal entry readable while the poll loop could still want it', async () => {
|
||||
fetchMock.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', infoHash: HASH });
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
||||
await settle();
|
||||
|
||||
const state = await atLaterTime(60 * 1000, async () => (await service.status([HASH]))[0].state);
|
||||
@@ -587,7 +587,7 @@ describe('DirectDownloadService', () => {
|
||||
it('never reaps a directory this process is downloading into', async () => {
|
||||
const trickle = trickleResponse();
|
||||
fetchMock.mockResolvedValue(trickle.response);
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', infoHash: HASH });
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
||||
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalled());
|
||||
|
||||
expect(await service.reapStaging(new Set())).toBe(0);
|
||||
@@ -610,7 +610,7 @@ describe('DirectDownloadService', () => {
|
||||
},
|
||||
});
|
||||
fetchMock.mockResolvedValue(new Response(body, { headers: { 'content-type': 'application/epub+zip', 'content-length': '1000' } }));
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', infoHash: HASH });
|
||||
await service.add({ fileUrl: 'https://archive.org/download/x/book.epub', fileName: 'book.epub', clientKey: HASH });
|
||||
await vi.waitFor(async () => {
|
||||
expect((await service.status([HASH]))[0].downloadedBytes).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -74,7 +74,7 @@ const MAX_REDIRECTS = 5;
|
||||
* a terminal state, so this is several thousand times longer than the outcome needs to survive.
|
||||
* It is generous because the alternative to a stale entry is an outcome nobody ever saw: an
|
||||
* attempt whose `failed` is dropped too early reads as `unknown` instead, and the reason it
|
||||
* failed is replaced by "the download client no longer has this torrent".
|
||||
* failed is replaced by "the download client no longer has this download".
|
||||
*/
|
||||
const TERMINAL_RETENTION_MS = 10 * 60 * 1000;
|
||||
|
||||
@@ -118,7 +118,7 @@ export interface DirectDownloadRequest {
|
||||
* How the poll loop identifies this transfer. A direct file has no infohash, so the caller
|
||||
* derives a stable digest of the URL, which keeps the duplicate-grab index meaningful.
|
||||
*/
|
||||
infoHash: string;
|
||||
clientKey: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -157,32 +157,32 @@ export class DirectDownloadService {
|
||||
return directDownloadRoot(this.storage.appDataPath);
|
||||
}
|
||||
|
||||
async add(release: DirectDownloadRequest): Promise<{ clientHash: string }> {
|
||||
async add(release: DirectDownloadRequest): Promise<{ clientKey: string }> {
|
||||
const url = await ensureSafeUrl(release.fileUrl, { allowPrivate: ALLOW_PRIVATE });
|
||||
|
||||
const directory = join(this.root, release.infoHash);
|
||||
const directory = join(this.root, release.clientKey);
|
||||
const target = safeJoin(directory, stagedDirectFileName(release.fileName, release.format));
|
||||
|
||||
await mkdir(directory, { recursive: true });
|
||||
this.start(release.downloadId, release.infoHash, url, target, directory, 0, null, null);
|
||||
this.start(release.downloadId, release.clientKey, url, target, directory, 0, null, null);
|
||||
|
||||
return { clientHash: release.infoHash };
|
||||
return { clientKey: release.clientKey };
|
||||
}
|
||||
|
||||
/** Restores one active direct attempt left by the previous process. */
|
||||
async resume(download: BookRequestDownloadRow): Promise<boolean> {
|
||||
if (
|
||||
download.source !== 'direct_url' ||
|
||||
download.clientHash === null ||
|
||||
download.clientKey === null ||
|
||||
download.directUrl === null ||
|
||||
download.directFileName === null ||
|
||||
this.progress.has(download.clientHash)
|
||||
this.progress.has(download.clientKey)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const url = await ensureSafeUrl(download.directUrl, { allowPrivate: ALLOW_PRIVATE });
|
||||
const directory = join(this.root, download.clientHash);
|
||||
const directory = join(this.root, download.clientKey);
|
||||
const target = safeJoin(directory, download.directFileName);
|
||||
const existingBytes = await stat(target)
|
||||
.then((entry) => (entry.isFile() ? entry.size : 0))
|
||||
@@ -192,16 +192,16 @@ export class DirectDownloadService {
|
||||
if (existingBytes > MAX_FILE_BYTES) return false;
|
||||
|
||||
await mkdir(directory, { recursive: true });
|
||||
this.start(download.id, download.clientHash, url, target, directory, existingBytes, validator, download.totalBytes);
|
||||
this.start(download.id, download.clientKey, url, target, directory, existingBytes, validator, download.totalBytes);
|
||||
this.logger.log(
|
||||
`[direct_download.resume] [start] downloadId=${download.id} hash=${download.clientHash} bytes=${existingBytes} - resuming an interrupted direct download`,
|
||||
`[direct_download.resume] [start] downloadId=${download.id} key=${download.clientKey} bytes=${existingBytes} - resuming an interrupted direct download`,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
private start(
|
||||
downloadId: number,
|
||||
infoHash: string,
|
||||
clientKey: string,
|
||||
url: URL,
|
||||
target: string,
|
||||
directory: string,
|
||||
@@ -209,23 +209,23 @@ export class DirectDownloadService {
|
||||
validator: string | null,
|
||||
expectedBytes: number | null,
|
||||
): void {
|
||||
this.progress.set(infoHash, {
|
||||
this.progress.set(clientKey, {
|
||||
state: 'downloading',
|
||||
downloadedBytes: offset,
|
||||
totalBytes: expectedBytes,
|
||||
contentPath: null,
|
||||
});
|
||||
const controller = new AbortController();
|
||||
this.controllers.set(infoHash, controller);
|
||||
this.controllers.set(clientKey, controller);
|
||||
|
||||
// Deliberately not awaited: `add` hands the work over the way a torrent client does, and the
|
||||
// poll loop is what reports on it from here.
|
||||
const task = this.run(downloadId, infoHash, url, target, controller.signal, offset, validator, expectedBytes)
|
||||
const task = this.run(downloadId, clientKey, url, target, controller.signal, offset, validator, expectedBytes)
|
||||
.catch((error: unknown) => {
|
||||
if (controller.signal.aborted) return;
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const current = this.progress.get(infoHash);
|
||||
this.progress.set(infoHash, {
|
||||
const current = this.progress.get(clientKey);
|
||||
this.progress.set(clientKey, {
|
||||
state: 'failed',
|
||||
downloadedBytes: current?.downloadedBytes ?? offset,
|
||||
totalBytes: current?.totalBytes ?? expectedBytes,
|
||||
@@ -233,14 +233,14 @@ export class DirectDownloadService {
|
||||
errorMessage: message,
|
||||
terminalAt: Date.now(),
|
||||
});
|
||||
this.logger.warn(`[direct_download.fetch] [fail] hash=${infoHash} error="${sanitizeLogValue(message)}" - direct download failed`);
|
||||
this.logger.warn(`[direct_download.fetch] [fail] key=${clientKey} error="${sanitizeLogValue(message)}" - direct download failed`);
|
||||
})
|
||||
.finally(async () => {
|
||||
this.controllers.delete(infoHash);
|
||||
this.tasks.delete(infoHash);
|
||||
if (this.progress.get(infoHash)?.state !== 'completed') await this.discard(infoHash, directory);
|
||||
this.controllers.delete(clientKey);
|
||||
this.tasks.delete(clientKey);
|
||||
if (this.progress.get(clientKey)?.state !== 'completed') await this.discard(clientKey, directory);
|
||||
});
|
||||
this.tasks.set(infoHash, task);
|
||||
this.tasks.set(clientKey, task);
|
||||
void task;
|
||||
}
|
||||
|
||||
@@ -253,14 +253,14 @@ export class DirectDownloadService {
|
||||
* read to the poll loop as an unmapped state, which it spells `downloading`, so an interrupted
|
||||
* transfer sat at "downloading 0%" until the twelve-hour stall timeout - a timeout sized for a
|
||||
* seederless torrent that might still finish, not for a transfer that certainly will not.
|
||||
* Leaving the hash out puts it on the monitor's missing path, which gives it two minutes to
|
||||
* Leaving the key out puts it on the monitor's missing path, which gives it two minutes to
|
||||
* reappear and then fails it with a reason.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/require-await -- matches the client adapters it is polled alongside.
|
||||
async status(hashes: string[]): Promise<DownloadStatus[]> {
|
||||
async status(clientKeys: string[]): Promise<DownloadStatus[]> {
|
||||
this.pruneTerminal();
|
||||
return hashes.flatMap((infoHash) => {
|
||||
const current = this.progress.get(infoHash);
|
||||
return clientKeys.flatMap((clientKey) => {
|
||||
const current = this.progress.get(clientKey);
|
||||
if (!current) return [];
|
||||
const percent =
|
||||
current.state === 'completed'
|
||||
@@ -270,7 +270,7 @@ export class DirectDownloadService {
|
||||
: 0;
|
||||
return [
|
||||
{
|
||||
infoHash,
|
||||
clientKey,
|
||||
state: current.state,
|
||||
progressPercent: percent,
|
||||
downloadedBytes: current.downloadedBytes,
|
||||
@@ -286,27 +286,27 @@ export class DirectDownloadService {
|
||||
* There is no swarm to leave, so removing is only ever about the staged copy. The import has
|
||||
* already hardlinked what it wanted, and dropping our link does not touch the library's.
|
||||
*/
|
||||
async remove(hash: string, opts: { deleteFiles: boolean }): Promise<void> {
|
||||
this.controllers.get(hash)?.abort();
|
||||
await this.tasks.get(hash);
|
||||
this.progress.delete(hash);
|
||||
if (opts.deleteFiles) await rm(join(this.root, hash), { recursive: true, force: true });
|
||||
async remove(clientKey: string, opts: { deleteFiles: boolean }): Promise<void> {
|
||||
this.controllers.get(clientKey)?.abort();
|
||||
await this.tasks.get(clientKey);
|
||||
this.progress.delete(clientKey);
|
||||
if (opts.deleteFiles) await rm(join(this.root, clientKey), { recursive: true, force: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops staging directories no attempt is behind any more, against the hashes of the attempts
|
||||
* Drops staging directories no attempt is behind any more, against the keys of the attempts
|
||||
* that are. Bootstrap first resumes safe partial transfers and fails the rest, then calls this
|
||||
* to remove directories whose attempts no longer have work behind them.
|
||||
*
|
||||
* Safe to call while transfers are running only because anything this process is working on is
|
||||
* held in `progress` and skipped; the caller is still expected to be the boot path.
|
||||
*/
|
||||
async reapStaging(liveHashes: ReadonlySet<string>): Promise<number> {
|
||||
async reapStaging(liveClientKeys: ReadonlySet<string>): Promise<number> {
|
||||
const entries = await readdir(this.root, { withFileTypes: true }).catch(() => []);
|
||||
|
||||
let reaped = 0;
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || liveHashes.has(entry.name) || this.progress.has(entry.name)) continue;
|
||||
if (!entry.isDirectory() || liveClientKeys.has(entry.name) || this.progress.has(entry.name)) continue;
|
||||
await this.discard(entry.name, join(this.root, entry.name));
|
||||
reaped++;
|
||||
}
|
||||
@@ -317,12 +317,14 @@ export class DirectDownloadService {
|
||||
* Best-effort: staging that could not be removed is wasted disk, and failing the transfer over
|
||||
* it would replace the reason it actually ended with a filesystem error nobody can act on.
|
||||
*/
|
||||
private async discard(hash: string, directory: string): Promise<void> {
|
||||
private async discard(clientKey: string, directory: string): Promise<void> {
|
||||
try {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`[direct_download.discard] [fail] hash=${hash} error="${sanitizeLogValue(message)}" - staged download could not be removed`);
|
||||
this.logger.warn(
|
||||
`[direct_download.discard] [fail] key=${clientKey} error="${sanitizeLogValue(message)}" - staged download could not be removed`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,14 +335,14 @@ export class DirectDownloadService {
|
||||
*/
|
||||
private pruneTerminal(): void {
|
||||
const cutoff = Date.now() - TERMINAL_RETENTION_MS;
|
||||
for (const [hash, entry] of this.progress) {
|
||||
if (entry.terminalAt !== undefined && entry.terminalAt <= cutoff) this.progress.delete(hash);
|
||||
for (const [clientKey, entry] of this.progress) {
|
||||
if (entry.terminalAt !== undefined && entry.terminalAt <= cutoff) this.progress.delete(clientKey);
|
||||
}
|
||||
}
|
||||
|
||||
private async run(
|
||||
downloadId: number,
|
||||
infoHash: string,
|
||||
clientKey: string,
|
||||
url: URL,
|
||||
target: string,
|
||||
signal: AbortSignal,
|
||||
@@ -357,7 +359,7 @@ export class DirectDownloadService {
|
||||
|
||||
if (responseMeta.alreadyComplete) {
|
||||
await response.body?.cancel().catch(() => {});
|
||||
this.progress.set(infoHash, {
|
||||
this.progress.set(clientKey, {
|
||||
state: 'completed',
|
||||
downloadedBytes: offset,
|
||||
totalBytes: offset,
|
||||
@@ -413,8 +415,8 @@ export class DirectDownloadService {
|
||||
};
|
||||
transfer.addEventListener('abort', cancelReader, { once: true });
|
||||
const onProgress = (bytes: number) => {
|
||||
const current = this.progress.get(infoHash);
|
||||
if (current) this.progress.set(infoHash, { ...current, downloadedBytes: bytes, totalBytes });
|
||||
const current = this.progress.get(clientKey);
|
||||
if (current) this.progress.set(clientKey, { ...current, downloadedBytes: bytes, totalBytes });
|
||||
};
|
||||
|
||||
// Counted as it streams rather than buffered: an audiobook is gigabytes, and the point of
|
||||
@@ -452,14 +454,14 @@ export class DirectDownloadService {
|
||||
if (totalBytes !== null && downloaded !== totalBytes) {
|
||||
throw new Error(`That source ended after ${downloaded} of ${totalBytes} bytes`);
|
||||
}
|
||||
this.progress.set(infoHash, {
|
||||
this.progress.set(clientKey, {
|
||||
state: 'completed',
|
||||
downloadedBytes: downloaded,
|
||||
totalBytes: totalBytes ?? downloaded,
|
||||
contentPath: target,
|
||||
terminalAt: Date.now(),
|
||||
});
|
||||
this.logger.log(`[direct_download.fetch] [end] hash=${infoHash} bytes=${downloaded} - direct download finished`);
|
||||
this.logger.log(`[direct_download.fetch] [end] key=${clientKey} bytes=${downloaded} - direct download finished`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,7 @@ function row(overrides: Partial<BookRequestDownloadRow> = {}): BookRequestDownlo
|
||||
id: 11,
|
||||
requestId: 7,
|
||||
downloadClientId: 4,
|
||||
clientHash: HASH_A,
|
||||
clientKey: HASH_A,
|
||||
status: 'downloading',
|
||||
progressPercent: 10,
|
||||
downloadedBytes: 100,
|
||||
@@ -28,7 +28,7 @@ function row(overrides: Partial<BookRequestDownloadRow> = {}): BookRequestDownlo
|
||||
|
||||
function status(overrides: Partial<DownloadStatus> = {}): DownloadStatus {
|
||||
return {
|
||||
infoHash: HASH_A,
|
||||
clientKey: HASH_A,
|
||||
state: 'downloading',
|
||||
progressPercent: 50,
|
||||
downloadedBytes: 500,
|
||||
@@ -129,10 +129,10 @@ describe('DownloadMonitorService.tick', () => {
|
||||
expect(adapter.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('batches every hash for one client into a single call', async () => {
|
||||
it('batches every client key for one client into a single call', async () => {
|
||||
const { service, adapter } = makeService({
|
||||
active: [row(), row({ id: 12, clientHash: HASH_B })],
|
||||
statuses: [status(), status({ infoHash: HASH_B })],
|
||||
active: [row(), row({ id: 12, clientKey: HASH_B })],
|
||||
statuses: [status(), status({ clientKey: HASH_B })],
|
||||
});
|
||||
|
||||
await polled(service);
|
||||
@@ -279,16 +279,19 @@ describe('DownloadMonitorService.tick', () => {
|
||||
expect(downloads.updateIf).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('gives a just-grabbed torrent time to appear before calling it missing', async () => {
|
||||
it('gives a just-grabbed client item time to appear before calling it missing', async () => {
|
||||
const { service, fulfillment } = makeService({ active: [row({ grabbedAt: new Date() })], statuses: [] });
|
||||
await polled(service);
|
||||
expect(fulfillment.failDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails a torrent the client has forgotten once the grace period is over', async () => {
|
||||
it('fails a client item the client has forgotten once the grace period is over', async () => {
|
||||
const { service, fulfillment } = makeService({ active: [row({ grabbedAt: new Date(Date.now() - 5 * 60 * 1000) })], statuses: [] });
|
||||
await polled(service);
|
||||
expect(fulfillment.failDownload).toHaveBeenCalledWith(expect.objectContaining({ id: 11 }), expect.stringContaining('no longer has this torrent'));
|
||||
expect(fulfillment.failDownload).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 11 }),
|
||||
expect.stringContaining('no longer has this download'),
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -354,8 +357,8 @@ describe('DownloadMonitorService.tick', () => {
|
||||
expect(context.imports.importDownload).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A second transfer reports five seconds later, with the first import still running.
|
||||
context.downloads.findActive.mockResolvedValue([row({ id: 12, clientHash: HASH_B })]);
|
||||
context.adapter.status.mockResolvedValue([status({ infoHash: HASH_B, progressPercent: 40, downloadedBytes: 400 })]);
|
||||
context.downloads.findActive.mockResolvedValue([row({ id: 12, clientKey: HASH_B })]);
|
||||
context.adapter.status.mockResolvedValue([status({ clientKey: HASH_B, progressPercent: 40, downloadedBytes: 400 })]);
|
||||
now.mockReturnValue(66_000);
|
||||
await polled(context.service);
|
||||
|
||||
@@ -405,8 +408,8 @@ describe('DownloadMonitorService.tick', () => {
|
||||
/** A torrent client whose read never comes back, and a direct transfer alongside it. */
|
||||
function stalledClient() {
|
||||
const context = makeService({
|
||||
active: [row(), row({ id: 12, source: 'direct_url', downloadClientId: null, clientHash: HASH_B })],
|
||||
statuses: [status({ infoHash: HASH_B })],
|
||||
active: [row(), row({ id: 12, source: 'direct_url', downloadClientId: null, clientKey: HASH_B })],
|
||||
statuses: [status({ clientKey: HASH_B })],
|
||||
});
|
||||
let release: () => void = () => {};
|
||||
context.adapter.status.mockReturnValue(new Promise<DownloadStatus[]>((resolve) => (release = () => resolve([]))));
|
||||
|
||||
@@ -21,7 +21,7 @@ import { RequestImportService } from './request-import.service';
|
||||
* A torrent the client has never heard of is usually one it has not indexed yet. Only treat it
|
||||
* as gone once it has had time to appear.
|
||||
*/
|
||||
const MISSING_TORRENT_GRACE_MS = 2 * 60 * 1000;
|
||||
const MISSING_CLIENT_ITEM_GRACE_MS = 2 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* A tracker that is briefly down is indistinguishable from one that is refusing us, and only the
|
||||
@@ -222,19 +222,19 @@ export class DownloadMonitorService implements OnModuleDestroy {
|
||||
|
||||
private async pollTarget(target: PollTarget, rows: BookRequestDownloadRow[]): Promise<void> {
|
||||
// A refused attempt is a record of having asked, with nothing handed to anything: there is no
|
||||
// hash to ask about, and it is never in a status this polls for anyway.
|
||||
const polled = rows.filter((row): row is BookRequestDownloadRow & { clientHash: string } => row.clientHash !== null);
|
||||
// client key to ask about, and it is never in a status this polls for anyway.
|
||||
const polled = rows.filter((row): row is BookRequestDownloadRow & { clientKey: string } => row.clientKey !== null);
|
||||
if (polled.length === 0) return;
|
||||
const hashes = polled.map((row) => row.clientHash);
|
||||
const clientKeys = polled.map((row) => row.clientKey);
|
||||
|
||||
let statuses: DownloadStatus[];
|
||||
try {
|
||||
if (target === DIRECT) {
|
||||
statuses = await this.direct.status(hashes);
|
||||
statuses = await this.direct.status(clientKeys);
|
||||
} else {
|
||||
const config = await this.clients.resolveConfig(target);
|
||||
const adapter = this.registry.require(config.adapterType);
|
||||
statuses = await adapter.status(hashes, config);
|
||||
statuses = await adapter.status(clientKeys, config);
|
||||
}
|
||||
} catch (error) {
|
||||
// A client that is down, misconfigured or mid-restart must not fail every download it
|
||||
@@ -246,13 +246,13 @@ export class DownloadMonitorService implements OnModuleDestroy {
|
||||
return;
|
||||
}
|
||||
|
||||
const byHash = new Map(statuses.map((status) => [status.infoHash.toLowerCase(), status]));
|
||||
const byKey = new Map(statuses.map((status) => [status.clientKey.toLowerCase(), status]));
|
||||
// Once for the batch rather than once per progress tick: the audience of a tick is the same
|
||||
// set the request list is scoped by, and reading it per download would add a round trip per
|
||||
// active transfer per poll.
|
||||
const viewers = await this.requests.findRequestViewerIds([...new Set(polled.map((row) => row.requestId))]);
|
||||
for (const row of polled) {
|
||||
const status = byHash.get(row.clientHash.toLowerCase());
|
||||
const status = byKey.get(row.clientKey.toLowerCase());
|
||||
if (!status) {
|
||||
await this.handleMissing(row);
|
||||
continue;
|
||||
@@ -262,17 +262,17 @@ export class DownloadMonitorService implements OnModuleDestroy {
|
||||
}
|
||||
|
||||
/**
|
||||
* A hash the target did not report. For a torrent that is usually a client that has not indexed
|
||||
* it yet, so it gets a grace period; for a direct transfer it means startup could not resume it
|
||||
* A client key the target did not report. An external client may not have indexed a newly added
|
||||
* item yet, so it gets a grace period; for a direct transfer it means startup could not resume it
|
||||
* or the in-process transfer disappeared, so waiting longer cannot make it return.
|
||||
*/
|
||||
private async handleMissing(row: BookRequestDownloadRow): Promise<void> {
|
||||
if (!this.olderThan(row, MISSING_TORRENT_GRACE_MS)) return;
|
||||
if (!this.olderThan(row, MISSING_CLIENT_ITEM_GRACE_MS)) return;
|
||||
await this.fulfillment.failDownload(
|
||||
row,
|
||||
row.source === 'direct_url'
|
||||
? 'The download was interrupted before it finished and cannot be resumed'
|
||||
: 'The download client no longer has this torrent',
|
||||
: 'The download client no longer has this download',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ function download(overrides: Partial<BookRequestDownloadRow> = {}): BookRequestD
|
||||
id: 11,
|
||||
requestId: 7,
|
||||
downloadClientId: 4,
|
||||
clientHash: INFO_HASH,
|
||||
clientKey: INFO_HASH,
|
||||
source: 'torrent_file',
|
||||
status: 'downloading',
|
||||
...overrides,
|
||||
|
||||
@@ -115,18 +115,18 @@ export class DownloadRemovalService {
|
||||
/** A direct file is ours to drop; a torrent is detached from the client that holds it. */
|
||||
private async detach(download: BookRequestDownloadRow, deleteFiles: boolean): Promise<void> {
|
||||
// An attempt a source refused was never handed to anything, so there is nothing holding it.
|
||||
if (download.clientHash === null) return;
|
||||
if (download.clientKey === null) return;
|
||||
|
||||
const isDirect = download.source === 'direct_url';
|
||||
// There is no swarm to preserve on a staged file, so its bytes always go with the attempt.
|
||||
const shouldDeleteFiles = isDirect || deleteFiles;
|
||||
|
||||
if (isDirect) {
|
||||
await this.direct.remove(download.clientHash, { deleteFiles: shouldDeleteFiles });
|
||||
await this.direct.remove(download.clientKey, { deleteFiles: shouldDeleteFiles });
|
||||
} else {
|
||||
const config = await this.clients.resolveConfig(download.downloadClientId as number);
|
||||
const adapter = this.registry.require(config.adapterType);
|
||||
await adapter.remove(download.clientHash, config, { deleteFiles: shouldDeleteFiles });
|
||||
await adapter.remove(download.clientKey, config, { deleteFiles: shouldDeleteFiles });
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
|
||||
@@ -31,8 +31,8 @@ function refusal(errorCode: GrabFailureCode, message: string): BadRequestExcepti
|
||||
return new BadRequestException({ message, errorCode, statusCode: 400 });
|
||||
}
|
||||
|
||||
function indexerStatus(indexerId: number, seedsBack: boolean): IndexerSearchStatus {
|
||||
return { indexerId, indexerName: `indexer-${indexerId}`, ok: true, count: 1, filtered: 0, seedsBack };
|
||||
function indexerStatus(indexerId: number, delivery: IndexerSearchStatus['delivery']): IndexerSearchStatus {
|
||||
return { indexerId, indexerName: `indexer-${indexerId}`, ok: true, count: 1, filtered: 0, seedsBack: delivery === 'torrent', delivery };
|
||||
}
|
||||
|
||||
function joined(overrides: Partial<BookRequestRow> = {}) {
|
||||
@@ -556,7 +556,7 @@ describe('RequestAutomationService.considerRequest', () => {
|
||||
release({ indexerId: 4, indexerName: 'other-tracker', guid: 'torrent-2', score: 88 }),
|
||||
release({ indexerId: 5, indexerName: 'libgen', guid: 'direct-1', score: 70 }),
|
||||
],
|
||||
indexers: [indexerStatus(9, true), indexerStatus(4, true), indexerStatus(5, false)],
|
||||
indexers: [indexerStatus(9, 'torrent'), indexerStatus(4, 'torrent'), indexerStatus(5, 'file')],
|
||||
});
|
||||
fulfillment.grab.mockRejectedValueOnce(refusal('GRAB_CLIENT_REFUSED', 'qBittorrent rejected the torrent'));
|
||||
|
||||
@@ -566,6 +566,23 @@ describe('RequestAutomationService.considerRequest', () => {
|
||||
expect(fulfillment.grab.mock.calls.map((call) => (call[1] as { releaseGuid: string }).releaseGuid)).toEqual(['torrent-1', 'direct-1']);
|
||||
});
|
||||
|
||||
it('keeps trying Usenet releases after a torrent client refuses one', async () => {
|
||||
const { service, fulfillment } = makeService({
|
||||
settings: { autoGrabMinScore: 70 },
|
||||
releases: [
|
||||
release({ indexerId: 9, guid: 'torrent-1', score: 92 }),
|
||||
release({ indexerId: 5, indexerName: 'usenet-indexer', guid: 'usenet-1', score: 88 }),
|
||||
],
|
||||
indexers: [indexerStatus(9, 'torrent'), indexerStatus(5, 'usenet')],
|
||||
});
|
||||
fulfillment.grab.mockRejectedValueOnce(refusal('GRAB_CLIENT_REFUSED', 'qBittorrent rejected the torrent'));
|
||||
|
||||
service.considerRequest(7);
|
||||
await settle();
|
||||
|
||||
expect(fulfillment.grab.mock.calls.map((call) => (call[1] as { releaseGuid: string }).releaseGuid)).toEqual(['torrent-1', 'usenet-1']);
|
||||
});
|
||||
|
||||
/** The switch is what "stop at the first failure" means, so it has to stop a classified one too. */
|
||||
it('stops at the first refusal when falling back is turned off', async () => {
|
||||
const { service, fulfillment } = makeService({
|
||||
|
||||
@@ -593,22 +593,24 @@ interface AutomationSearchVariant {
|
||||
*/
|
||||
class BlockedSources {
|
||||
private readonly refusals: GrabRefusal[] = [];
|
||||
/** Whether a grab from each source joins a swarm, which is the same thing as needing a client. */
|
||||
private readonly seedsBack: Map<number, boolean>;
|
||||
private readonly deliveries: Map<number, IndexerSearchStatus['delivery']>;
|
||||
|
||||
constructor(indexers: IndexerSearchStatus[]) {
|
||||
this.seedsBack = new Map(indexers.map((indexer) => [indexer.indexerId, indexer.seedsBack]));
|
||||
this.deliveries = new Map(indexers.map((indexer) => [indexer.indexerId, indexer.delivery]));
|
||||
}
|
||||
|
||||
blocks(release: ReleaseCandidateItem): boolean {
|
||||
// A source the search never reported on cannot be shown to be a direct download, and a
|
||||
// torrent is the safer thing to assume: it is the shape that needs a client.
|
||||
const seedsBack = this.seedsBack.get(release.indexerId) ?? true;
|
||||
return findGrabRefusal({ indexerId: release.indexerId, vipOnly: release.vipOnly, seedsBack }, this.refusals) !== null;
|
||||
return findGrabRefusal({ indexerId: release.indexerId, vipOnly: release.vipOnly, delivery: this.deliveryFor(release) }, this.refusals) !== null;
|
||||
}
|
||||
|
||||
record(release: ReleaseCandidateItem, code: GrabFailureCode): void {
|
||||
this.refusals.push({ indexerId: release.indexerId, code });
|
||||
this.refusals.push({ indexerId: release.indexerId, delivery: this.deliveryFor(release), code });
|
||||
}
|
||||
|
||||
private deliveryFor(release: ReleaseCandidateItem): IndexerSearchStatus['delivery'] {
|
||||
// A source omitted from search status cannot be proven to be client-free. Torrent preserves
|
||||
// the established conservative fallback for older or malformed search responses.
|
||||
return this.deliveries.get(release.indexerId) ?? 'torrent';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { NotificationType, UNSETTLED_BOOK_REQUEST_DOWNLOAD_STATUSES, WORKER_WRIT
|
||||
|
||||
import type { RequestUser } from '../../../common/types/request-user';
|
||||
import type { BookRequestDownloadRow, BookRequestRow } from '../../../db/schema';
|
||||
import { newznabClientKey } from '../indexers/adapters/newznab.adapter';
|
||||
import { IndexerSearchException } from '../indexers/indexer-adapter';
|
||||
import { RequestFulfillmentService } from './request-fulfillment.service';
|
||||
|
||||
@@ -79,9 +80,9 @@ function makeService(
|
||||
resolveConfig: vi.fn().mockResolvedValue({ id: 4, adapterType: 'qbittorrent' }),
|
||||
...overrides.clients,
|
||||
};
|
||||
const adapter = { add: vi.fn().mockResolvedValue({ clientHash: INFO_HASH }) };
|
||||
const adapter = { add: vi.fn().mockResolvedValue({ clientKey: INFO_HASH }) };
|
||||
const registry = { require: vi.fn().mockReturnValue(adapter) };
|
||||
const direct = { add: vi.fn().mockResolvedValue({ clientHash: INFO_HASH }) };
|
||||
const direct = { add: vi.fn().mockResolvedValue({ clientKey: INFO_HASH }) };
|
||||
const indexers = {
|
||||
resolveConfig: vi.fn().mockResolvedValue({ id: 9, name: 'tracker', adapterType: 'torznab' }),
|
||||
...overrides.indexers,
|
||||
@@ -136,7 +137,7 @@ describe('RequestFulfillmentService.grab', () => {
|
||||
await service.grab(7, { magnet: MAGNET }, user());
|
||||
|
||||
expect(downloads.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requestId: 7, source: 'magnet', clientHash: INFO_HASH, status: 'queued', releaseTitle: 'Dune' }),
|
||||
expect.objectContaining({ requestId: 7, source: 'magnet', clientKey: INFO_HASH, status: 'queued', releaseTitle: 'Dune' }),
|
||||
);
|
||||
expect(downloads.create.mock.invocationCallOrder[0]).toBeLessThan(adapter.add.mock.invocationCallOrder[0]);
|
||||
});
|
||||
@@ -158,7 +159,7 @@ describe('RequestFulfillmentService.grab', () => {
|
||||
it('hands the client the trimmed magnet the hash was derived from', async () => {
|
||||
const { service, adapter } = makeService();
|
||||
await service.grab(7, { magnet: ` ${MAGNET}\n` }, user());
|
||||
expect(adapter.add).toHaveBeenCalledWith(expect.objectContaining({ magnet: MAGNET, infoHash: INFO_HASH }), expect.anything());
|
||||
expect(adapter.add).toHaveBeenCalledWith(expect.objectContaining({ magnet: MAGNET, clientKey: INFO_HASH }), expect.anything());
|
||||
});
|
||||
|
||||
/** The size of what the release carries; a magnet does not state one, so it stays null. */
|
||||
@@ -199,9 +200,7 @@ describe('RequestFulfillmentService.grab', () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const { service, downloads, adapter } = makeService();
|
||||
adapter.add
|
||||
.mockRejectedValueOnce(new ServiceUnavailableException('qBittorrent is restarting'))
|
||||
.mockResolvedValueOnce({ clientHash: INFO_HASH });
|
||||
adapter.add.mockRejectedValueOnce(new ServiceUnavailableException('qBittorrent is restarting')).mockResolvedValueOnce({ clientKey: INFO_HASH });
|
||||
|
||||
const grabbed = service.grab(7, { magnet: MAGNET }, null);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
@@ -375,8 +374,33 @@ describe('RequestFulfillmentService.grab from a picked release', () => {
|
||||
|
||||
await service.grab(7, { indexerId: 9, releaseGuid: 'r-1' }, user());
|
||||
|
||||
expect(downloads.create).toHaveBeenCalledWith(expect.objectContaining({ source: 'magnet', clientHash: INFO_HASH }));
|
||||
expect(adapter.add).toHaveBeenCalledWith(expect.objectContaining({ magnet: MAGNET, infoHash: INFO_HASH }), expect.anything());
|
||||
expect(downloads.create).toHaveBeenCalledWith(expect.objectContaining({ source: 'magnet', clientKey: INFO_HASH }));
|
||||
expect(adapter.add).toHaveBeenCalledWith(expect.objectContaining({ magnet: MAGNET, clientKey: INFO_HASH }), expect.anything());
|
||||
});
|
||||
|
||||
it('fetches a Newznab release and hands its NZB to an NZBGet client', async () => {
|
||||
const nzb = Buffer.from('<?xml version="1.0"?><nzb />');
|
||||
const clientKey = newznabClientKey(9, 'r-1');
|
||||
const { service, downloads, adapter, indexerAdapter } = makeService({
|
||||
releases: { find: vi.fn().mockReturnValue(RELEASE) },
|
||||
clients: {
|
||||
findOne: vi.fn().mockResolvedValue({ id: 8, name: 'nzbget', adapterType: 'nzbget', enabled: true, pathMappings: [{ id: 1 }] }),
|
||||
findPreferredEnabled: vi.fn().mockResolvedValue({ id: 8 }),
|
||||
resolveConfig: vi.fn().mockResolvedValue({ id: 8, adapterType: 'nzbget' }),
|
||||
},
|
||||
indexers: { resolveConfig: vi.fn().mockResolvedValue({ id: 9, name: 'usenet', adapterType: 'newznab' }) },
|
||||
indexerAdapter: { fetchNzbFile: vi.fn().mockResolvedValue(nzb) },
|
||||
});
|
||||
adapter.add.mockResolvedValue({ clientKey });
|
||||
|
||||
await service.grab(7, { indexerId: 9, releaseGuid: 'r-1' }, user());
|
||||
|
||||
expect(indexerAdapter.fetchNzbFile).toHaveBeenCalledWith(RELEASE, expect.objectContaining({ adapterType: 'newznab' }));
|
||||
expect(downloads.create).toHaveBeenCalledWith(expect.objectContaining({ source: 'nzb_file', clientKey, downloadClientId: 8 }));
|
||||
expect(adapter.add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ nzbFile: nzb, nzbFileName: 'Dune - Frank Herbert [EPUB].nzb', clientKey }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('inspects the torrent without creating an attempt and reuses it for the following grab', async () => {
|
||||
@@ -596,7 +620,7 @@ describe('RequestFulfillmentService.grab from a picked release', () => {
|
||||
// Recorded as a refused attempt the way any other unimportable release is, and never fetched.
|
||||
await expect(service.grab(7, { indexerId: 9, releaseGuid: 'r-1' }, user())).rejects.toMatchObject({ status: 400 });
|
||||
expect(direct.add).not.toHaveBeenCalled();
|
||||
expect(downloads.create).toHaveBeenCalledWith(expect.objectContaining({ status: 'failed', clientHash: null }));
|
||||
expect(downloads.create).toHaveBeenCalledWith(expect.objectContaining({ status: 'failed', clientKey: null }));
|
||||
});
|
||||
|
||||
/** A `.zip` holding an epub is an archive the importer extracts, and `book.zip.epub` is not. */
|
||||
@@ -645,7 +669,7 @@ describe('RequestFulfillmentService.grab from a picked release', () => {
|
||||
await service.grab(7, { indexerId: 9, releaseGuid: 'r-1' }, user());
|
||||
|
||||
expect(indexerAdapter.fetchTorrentFile).not.toHaveBeenCalled();
|
||||
expect(downloads.create).toHaveBeenCalledWith(expect.objectContaining({ source: 'magnet', clientHash: INFO_HASH }));
|
||||
expect(downloads.create).toHaveBeenCalledWith(expect.objectContaining({ source: 'magnet', clientKey: INFO_HASH }));
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -692,7 +716,7 @@ describe('RequestFulfillmentService.grab from a picked release', () => {
|
||||
status: 'failed',
|
||||
// No client took it and no hash exists, which is what tells a refusal from a failed download.
|
||||
downloadClientId: null,
|
||||
clientHash: null,
|
||||
clientKey: null,
|
||||
releaseGuid: 'r-1',
|
||||
errorMessage: 'tracker answered 406: Download blocked: VIP torrent',
|
||||
}),
|
||||
|
||||
@@ -72,6 +72,7 @@ import { resolveFormat } from '../indexers/release-scoring';
|
||||
import { BookRequestDownloadRepository } from './book-request-download.repository';
|
||||
import { DirectDownloadService, stagedDirectFileName } from './direct-download.service';
|
||||
import { infoHashFromMagnet, magnetDisplayName, MAX_TORRENT_FILE_BYTES, torrentMetadataFromFile, type TorrentFileMetadata } from './torrent.utils';
|
||||
import { newznabClientKey } from '../indexers/adapters/newznab.adapter';
|
||||
import type { InspectBookRequestReleaseDto } from '../dto/inspect-book-request-release.dto';
|
||||
import type { GrabBookRequestDto } from '../dto/grab-book-request.dto';
|
||||
import type { SearchBookRequestReleasesDto } from '../dto/search-book-request-releases.dto';
|
||||
@@ -81,6 +82,7 @@ const RESOLVE_FILE_TIMEOUT_MS = 20_000;
|
||||
const TRANSIENT_GRAB_RETRY_DELAY_MS = 500;
|
||||
const RESOLVED_RELEASE_TTL_MS = 60_000;
|
||||
const MAX_RESOLVED_RELEASES = 25;
|
||||
const MAX_RESOLVED_RELEASE_CACHE_BYTES = 64 * 1024 * 1024;
|
||||
const MAX_DISPLAYED_MANIFEST_FILES = 200;
|
||||
/** A chooser is a list a human reads, and a release with more books than this is not one. */
|
||||
const MAX_DISPLAYED_UNITS = 25;
|
||||
@@ -265,7 +267,7 @@ export class RequestFulfillmentService {
|
||||
releaseSeeders: grab.releaseSeeders ?? null,
|
||||
releaseFormat: grab.releaseFormat ?? null,
|
||||
freeleech: grab.freeleech ?? false,
|
||||
clientHash: grab.infoHash,
|
||||
clientKey: grab.clientKey,
|
||||
directUrl,
|
||||
directFileName,
|
||||
status: 'queued',
|
||||
@@ -284,7 +286,7 @@ export class RequestFulfillmentService {
|
||||
downloadId: download.id,
|
||||
fileUrl: directUrl as string,
|
||||
fileName: directFileName as string,
|
||||
infoHash: grab.infoHash,
|
||||
clientKey: grab.clientKey,
|
||||
});
|
||||
} else {
|
||||
const config = await this.clients.resolveConfig(client.id);
|
||||
@@ -295,7 +297,9 @@ export class RequestFulfillmentService {
|
||||
magnet: grab.magnet,
|
||||
torrentFile: grab.torrentFile,
|
||||
torrentFileName: grab.torrentFileName ?? dto.torrentFileName,
|
||||
infoHash: grab.infoHash,
|
||||
nzbFile: grab.nzbFile,
|
||||
nzbFileName: grab.nzbFileName,
|
||||
clientKey: grab.clientKey,
|
||||
// The indexer's goals, enforced by the client: BookOrbit never stops a seed itself.
|
||||
...(grab.seedRatioGoal !== null && grab.seedRatioGoal !== undefined ? { seedRatioGoal: grab.seedRatioGoal } : {}),
|
||||
...(grab.seedTimeMinutes !== null && grab.seedTimeMinutes !== undefined ? { seedTimeMinutes: grab.seedTimeMinutes } : {}),
|
||||
@@ -329,7 +333,7 @@ export class RequestFulfillmentService {
|
||||
// the book when it lands. Leaving the dismissal on would hide the arrival too.
|
||||
await this.requests.clearDismissals(requestId);
|
||||
this.logger.log(
|
||||
`[book_request.grab] [end] requestId=${requestId} downloadId=${download.id} clientId=${client?.id ?? 'direct'} indexerId=${grab.indexerId ?? 'none'} indexer="${sanitizeLogValue(grab.indexerName ?? 'pasted by hand')}" userId=${user?.id ?? 'automation'} source=${grab.source} hash=${grab.infoHash} - release grab started`,
|
||||
`[book_request.grab] [end] requestId=${requestId} downloadId=${download.id} clientId=${client?.id ?? 'direct'} indexerId=${grab.indexerId ?? 'none'} indexer="${sanitizeLogValue(grab.indexerName ?? 'pasted by hand')}" userId=${user?.id ?? 'automation'} source=${grab.source} key=${grab.clientKey} - release grab started`,
|
||||
);
|
||||
this.gateway.emitChanged();
|
||||
|
||||
@@ -381,7 +385,7 @@ export class RequestFulfillmentService {
|
||||
releaseSeeders: release.seeders,
|
||||
releaseFormat: resolveFormat(release)?.slice(0, 20) ?? null,
|
||||
freeleech: release.freeleech ?? false,
|
||||
clientHash: null,
|
||||
clientKey: null,
|
||||
status: 'failed',
|
||||
errorMessage: error instanceof Error ? error.message : String(error),
|
||||
grabbedAt: new Date(),
|
||||
@@ -403,7 +407,10 @@ export class RequestFulfillmentService {
|
||||
if (release.magnet) return 'magnet';
|
||||
try {
|
||||
const indexer = await this.indexers.resolveConfig(release.indexerId);
|
||||
return this.indexerRegistry.require(indexer.adapterType).resolveFile ? 'direct_url' : 'torrent_file';
|
||||
const adapter = this.indexerRegistry.require(indexer.adapterType);
|
||||
if (adapter.resolveFile) return 'direct_url';
|
||||
if (adapter.fetchNzbFile) return 'nzb_file';
|
||||
return 'torrent_file';
|
||||
} catch {
|
||||
return 'torrent_file';
|
||||
}
|
||||
@@ -602,7 +609,7 @@ export class RequestFulfillmentService {
|
||||
...snapshot,
|
||||
source: 'magnet',
|
||||
magnet: release.magnet,
|
||||
infoHash: infoHashFromMagnet(release.magnet),
|
||||
clientKey: infoHashFromMagnet(release.magnet),
|
||||
inspection: metadataUnavailableInspection('magnet'),
|
||||
};
|
||||
}
|
||||
@@ -623,7 +630,7 @@ export class RequestFulfillmentService {
|
||||
fileUrl: file.url,
|
||||
fileName,
|
||||
// A digest of the URL, because there is no infohash and the poll loop still needs a key.
|
||||
infoHash: createHash('sha1').update(file.url).digest('hex'),
|
||||
clientKey: createHash('sha1').update(file.url).digest('hex'),
|
||||
releaseFormat: file.format.slice(0, 20),
|
||||
// The one figure the search could not state: an item's size is not the book's size.
|
||||
releaseSizeBytes: file.sizeBytes ?? snapshot.releaseSizeBytes,
|
||||
@@ -634,6 +641,21 @@ export class RequestFulfillmentService {
|
||||
};
|
||||
}
|
||||
|
||||
if (adapter.fetchNzbFile) {
|
||||
const clientKey = newznabClientKey(indexer.id, release.guid);
|
||||
const nzbFile = await fetchNzbFile(adapter, release, indexer);
|
||||
return {
|
||||
...snapshot,
|
||||
source: 'nzb_file',
|
||||
nzbFile,
|
||||
nzbFileName: `${snapshot.releaseTitle}.nzb`,
|
||||
clientKey,
|
||||
seedRatioGoal: null,
|
||||
seedTimeMinutes: null,
|
||||
inspection: metadataUnavailableInspection('nzb_file'),
|
||||
};
|
||||
}
|
||||
|
||||
// A private tracker's download link is credentialed, so the .torrent is fetched here with the
|
||||
// indexer's own session rather than handed to the download client as a URL it cannot open.
|
||||
if (!adapter.fetchTorrentFile) {
|
||||
@@ -646,7 +668,7 @@ export class RequestFulfillmentService {
|
||||
...snapshot,
|
||||
source: 'magnet',
|
||||
magnet: torrentFile.magnet,
|
||||
infoHash: infoHashFromMagnet(torrentFile.magnet),
|
||||
clientKey: infoHashFromMagnet(torrentFile.magnet),
|
||||
inspection: metadataUnavailableInspection('magnet'),
|
||||
};
|
||||
}
|
||||
@@ -658,7 +680,7 @@ export class RequestFulfillmentService {
|
||||
// Named after the hash rather than the guid, which on torznab is a URL and would put path
|
||||
// separators into a multipart filename.
|
||||
torrentFileName: `${metadata.infoHash}.torrent`,
|
||||
infoHash: metadata.infoHash,
|
||||
clientKey: metadata.infoHash,
|
||||
releaseSizeBytes: snapshot.releaseSizeBytes ?? metadata.totalLength,
|
||||
inspection: torrentInspection(metadata),
|
||||
};
|
||||
@@ -669,9 +691,15 @@ export class RequestFulfillmentService {
|
||||
for (const [cachedKey, cached] of this.resolvedReleases) {
|
||||
if (cached.expiresAt <= now) this.resolvedReleases.delete(cachedKey);
|
||||
}
|
||||
if (this.resolvedReleases.size >= MAX_RESOLVED_RELEASES) {
|
||||
const grabBytes = resolvedGrabBytes(grab);
|
||||
while (
|
||||
this.resolvedReleases.size > 0 &&
|
||||
(this.resolvedReleases.size >= MAX_RESOLVED_RELEASES ||
|
||||
resolvedReleaseCacheBytes(this.resolvedReleases) + grabBytes > MAX_RESOLVED_RELEASE_CACHE_BYTES)
|
||||
) {
|
||||
const oldest = this.resolvedReleases.keys().next().value as string | undefined;
|
||||
if (oldest) this.resolvedReleases.delete(oldest);
|
||||
if (!oldest) break;
|
||||
this.resolvedReleases.delete(oldest);
|
||||
}
|
||||
this.resolvedReleases.set(key, { candidate, grab, expiresAt: now + RESOLVED_RELEASE_TTL_MS });
|
||||
}
|
||||
@@ -689,10 +717,11 @@ export class RequestFulfillmentService {
|
||||
if (requestedId === null) {
|
||||
if (DELIVERY_BY_DOWNLOAD_SOURCE[source] === 'file') return null;
|
||||
|
||||
const types = DOWNLOAD_CLIENT_TYPES.filter((type) => DOWNLOAD_CLIENT_DELIVERY[type] === 'torrent');
|
||||
const delivery = DELIVERY_BY_DOWNLOAD_SOURCE[source];
|
||||
const types = DOWNLOAD_CLIENT_TYPES.filter((type) => DOWNLOAD_CLIENT_DELIVERY[type] === delivery);
|
||||
const preferred = await this.clients.findPreferredEnabled(types);
|
||||
if (!preferred) {
|
||||
throw grabError('GRAB_CLIENT_REFUSED', 'No torrent download client is configured. Add one under Settings > System > Requests.');
|
||||
throw grabError('GRAB_CLIENT_REFUSED', `No ${delivery} download client is configured. Add one under Settings > System > Requests.`);
|
||||
}
|
||||
const preferredClient = await this.clients.findOne(preferred.id);
|
||||
this.assertClientCanImport(preferredClient);
|
||||
@@ -727,7 +756,9 @@ export class RequestFulfillmentService {
|
||||
'GRAB_CLIENT_REFUSED',
|
||||
expected === 'file'
|
||||
? `Download client "${client.name}" cannot fetch a direct file`
|
||||
: `Download client "${client.name}" cannot accept a magnet link or .torrent file`,
|
||||
: expected === 'usenet'
|
||||
? `Download client "${client.name}" cannot accept an NZB file`
|
||||
: `Download client "${client.name}" cannot accept a magnet link or .torrent file`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -746,6 +777,15 @@ async function fetchTorrentFile(adapter: IndexerAdapter, release: ReleaseCandida
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNzbFile(adapter: IndexerAdapter, release: ReleaseCandidate, indexer: ResolvedIndexerConfig): Promise<Buffer> {
|
||||
try {
|
||||
return await adapter.fetchNzbFile!(release, indexer);
|
||||
} catch (error) {
|
||||
if (!(error instanceof IndexerSearchException)) throw error;
|
||||
throw indexerRefusal(error, release);
|
||||
}
|
||||
}
|
||||
|
||||
/** The same mapping for a source that serves the file itself, which refuses in the same ways. */
|
||||
async function resolveReleaseFile(adapter: IndexerAdapter, release: ReleaseCandidate, indexer: ResolvedIndexerConfig): Promise<ReleaseFile> {
|
||||
try {
|
||||
@@ -840,10 +880,12 @@ export function grabFailureCode(error: unknown): GrabFailureCode | null {
|
||||
|
||||
interface ParsedGrab {
|
||||
source: BookRequestDownloadSource;
|
||||
infoHash: string;
|
||||
clientKey: string;
|
||||
magnet?: string;
|
||||
torrentFile?: Buffer;
|
||||
torrentFileName?: string;
|
||||
nzbFile?: Buffer;
|
||||
nzbFileName?: string;
|
||||
fileUrl?: string;
|
||||
fileName?: string;
|
||||
releaseTitle: string;
|
||||
@@ -870,13 +912,23 @@ interface CachedResolvedRelease {
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
function resolvedGrabBytes(grab: ResolvedGrab): number {
|
||||
return (grab.torrentFile?.byteLength ?? 0) + (grab.nzbFile?.byteLength ?? 0);
|
||||
}
|
||||
|
||||
function resolvedReleaseCacheBytes(cache: ReadonlyMap<string, CachedResolvedRelease>): number {
|
||||
let total = 0;
|
||||
for (const { grab } of cache.values()) total += resolvedGrabBytes(grab);
|
||||
return total;
|
||||
}
|
||||
|
||||
function parseGrabPayload(dto: GrabBookRequestDto): ParsedGrab {
|
||||
if (dto.magnet?.trim()) {
|
||||
const magnet = dto.magnet!.trim();
|
||||
const infoHash = infoHashFromMagnet(magnet);
|
||||
return {
|
||||
source: 'magnet',
|
||||
infoHash,
|
||||
clientKey: infoHash,
|
||||
magnet,
|
||||
releaseTitle: magnetDisplayName(magnet, infoHash).slice(0, 500),
|
||||
releaseSizeBytes: null,
|
||||
@@ -892,7 +944,7 @@ function parseGrabPayload(dto: GrabBookRequestDto): ParsedGrab {
|
||||
const releaseTitle = (metadata.name ?? dto.torrentFileName ?? metadata.infoHash).slice(0, 500);
|
||||
return {
|
||||
source: 'torrent_file',
|
||||
infoHash: metadata.infoHash,
|
||||
clientKey: metadata.infoHash,
|
||||
torrentFile,
|
||||
releaseTitle,
|
||||
releaseSizeBytes: metadata.totalLength,
|
||||
@@ -966,7 +1018,7 @@ function directFileInspection(fileName: string, sizeBytes: number | null | undef
|
||||
};
|
||||
}
|
||||
|
||||
function metadataUnavailableInspection(source: 'magnet'): ReleaseFileInspection {
|
||||
function metadataUnavailableInspection(source: 'magnet' | 'nzb_file'): ReleaseFileInspection {
|
||||
return {
|
||||
source,
|
||||
status: 'metadata_unavailable',
|
||||
|
||||
@@ -16,7 +16,7 @@ function download(overrides: Partial<BookRequestDownloadRow> = {}): BookRequestD
|
||||
id: 11,
|
||||
requestId: 7,
|
||||
downloadClientId: 4,
|
||||
clientHash: INFO_HASH,
|
||||
clientKey: INFO_HASH,
|
||||
source: 'torrent_file',
|
||||
status: 'imported',
|
||||
...overrides,
|
||||
@@ -50,7 +50,7 @@ function makeService(
|
||||
status: vi.fn().mockResolvedValue(
|
||||
options.status ?? [
|
||||
{
|
||||
infoHash: INFO_HASH,
|
||||
clientKey: INFO_HASH,
|
||||
seed: { seeding: true, ratio: 1.4, ratioGoal: 2, seedingTimeSeconds: 60, seedingTimeGoalMinutes: 4320, uploadedBytes: 900 },
|
||||
},
|
||||
],
|
||||
@@ -86,7 +86,7 @@ describe('RequestSeedService.getSeedStatus', () => {
|
||||
downloadId: 11,
|
||||
downloadClientId: 4,
|
||||
downloadClientName: 'qbit',
|
||||
clientHash: INFO_HASH,
|
||||
clientKey: INFO_HASH,
|
||||
seeding: true,
|
||||
ratio: 1.4,
|
||||
ratioGoal: 2,
|
||||
@@ -123,9 +123,17 @@ describe('RequestSeedService.getSeedStatus', () => {
|
||||
expect(adapter.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not ask a Usenet client for seed state', async () => {
|
||||
const { service, clients, adapter } = makeService({ latest: download({ source: 'nzb_file' }) });
|
||||
|
||||
await expect(service.getSeedStatus(7)).resolves.toBeNull();
|
||||
expect(clients.resolveConfig).not.toHaveBeenCalled();
|
||||
expect(adapter.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/** An adapter with nothing to say about seeding must not read as a torrent seeding at zero. */
|
||||
it('reads a client that reports no seed detail as not seeding', async () => {
|
||||
const { service } = makeService({ status: [{ infoHash: INFO_HASH }] });
|
||||
const { service } = makeService({ status: [{ clientKey: INFO_HASH }] });
|
||||
await expect(service.getSeedStatus(7)).resolves.toMatchObject({ seeding: false, ratio: null });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { WORKER_WRITABLE_BOOK_REQUEST_STATUSES } from '@bookorbit/types';
|
||||
import { DELIVERY_BY_DOWNLOAD_SOURCE, WORKER_WRITABLE_BOOK_REQUEST_STATUSES } from '@bookorbit/types';
|
||||
import type { BookRequestItem, BookRequestSeedStatus } from '@bookorbit/types';
|
||||
|
||||
import type { RequestUser } from '../../../common/types/request-user';
|
||||
@@ -40,21 +40,21 @@ export class RequestSeedService {
|
||||
}
|
||||
|
||||
const { download, downloadClientName } = latest;
|
||||
if (download.source === 'direct_url' || download.downloadClientId === null) return null;
|
||||
if (DELIVERY_BY_DOWNLOAD_SOURCE[download.source] !== 'torrent' || download.downloadClientId === null) return null;
|
||||
// A refused attempt never reached a client, so there is no torrent to report a seed for.
|
||||
const clientHash = download.clientHash;
|
||||
if (clientHash === null) return null;
|
||||
const clientKey = download.clientKey;
|
||||
if (clientKey === null) return null;
|
||||
|
||||
const config = await this.clients.resolveConfig(download.downloadClientId);
|
||||
const adapter = this.registry.require(config.adapterType);
|
||||
const [status] = await adapter.status([clientHash], config);
|
||||
const [status] = await adapter.status([clientKey], config);
|
||||
if (!status) return null;
|
||||
|
||||
return {
|
||||
downloadId: download.id,
|
||||
downloadClientId: download.downloadClientId,
|
||||
downloadClientName,
|
||||
clientHash,
|
||||
clientKey,
|
||||
seeding: status.seed?.seeding ?? false,
|
||||
ratio: status.seed?.ratio ?? null,
|
||||
ratioGoal: status.seed?.ratioGoal ?? null,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { USER_DELETING, UserEventsService, type UserDeletingEvent } from '../../
|
||||
import { RequestUserCleanupService } from './request-user-cleanup.service';
|
||||
|
||||
function attempt(overrides: Partial<BookRequestDownloadRow> = {}): BookRequestDownloadRow {
|
||||
return { id: 11, requestId: 5, clientHash: 'a'.repeat(40), source: 'torrent_file', downloadClientId: 2, ...overrides } as BookRequestDownloadRow;
|
||||
return { id: 11, requestId: 5, clientKey: 'a'.repeat(40), source: 'torrent_file', downloadClientId: 2, ...overrides } as BookRequestDownloadRow;
|
||||
}
|
||||
|
||||
function makeService(rows: BookRequestDownloadRow[] = []) {
|
||||
|
||||
@@ -10,9 +10,9 @@ import { DownloadRemovalService } from './download-removal.service';
|
||||
* rows with it.
|
||||
*
|
||||
* `book_requests.user_id` cascades, and every attempt hangs off the request, so deleting somebody
|
||||
* removes the only record of which torrents and which staged files were theirs. The client goes on
|
||||
* seeding a torrent nothing points at, and a direct download this process is still writing goes on
|
||||
* writing. Detaching first is the only moment either is still findable.
|
||||
* removes the only record of which client items and which staged files were theirs. An external
|
||||
* client goes on handling a download nothing points at, and a direct download this process is still
|
||||
* writing goes on writing. Detaching first is the only moment either is still findable.
|
||||
*
|
||||
* Its own service rather than a branch in `UserService`, because the user module must not learn
|
||||
* what a book request is: the dependency runs one way, from here to the event the user module
|
||||
|
||||
@@ -8,7 +8,7 @@ function makeService(
|
||||
) {
|
||||
const downloads = {
|
||||
findByStatusOlderThan: vi.fn().mockImplementation((statuses: string[]) => Promise.resolve(byStatus[statuses.join(',')] ?? [])),
|
||||
findLiveDirectHashes: vi.fn().mockResolvedValue([]),
|
||||
findLiveDirectClientKeys: vi.fn().mockResolvedValue([]),
|
||||
findActiveDirect: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
const fulfillment = { failDownload: vi.fn().mockResolvedValue(undefined) };
|
||||
@@ -146,7 +146,7 @@ describe('RequestWatchdogService stranded searches', () => {
|
||||
|
||||
describe('RequestWatchdogService direct-download startup reconciliation', () => {
|
||||
it('resumes every active direct attempt before reaping staging', async () => {
|
||||
const row = { id: 11, source: 'direct_url', clientHash: 'aaa' } as BookRequestDownloadRow;
|
||||
const row = { id: 11, source: 'direct_url', clientKey: 'aaa' } as BookRequestDownloadRow;
|
||||
const { service, downloads, direct, fulfillment } = makeService();
|
||||
downloads.findActiveDirect.mockResolvedValue([row]);
|
||||
|
||||
@@ -158,7 +158,7 @@ describe('RequestWatchdogService direct-download startup reconciliation', () =>
|
||||
});
|
||||
|
||||
it('fails an interrupted attempt immediately when it cannot be resumed safely', async () => {
|
||||
const row = { id: 11, source: 'direct_url', clientHash: 'aaa' } as BookRequestDownloadRow;
|
||||
const row = { id: 11, source: 'direct_url', clientKey: 'aaa' } as BookRequestDownloadRow;
|
||||
const { service, downloads, direct, fulfillment } = makeService();
|
||||
downloads.findActiveDirect.mockResolvedValue([row]);
|
||||
direct.resume.mockResolvedValue(false);
|
||||
@@ -170,7 +170,7 @@ describe('RequestWatchdogService direct-download startup reconciliation', () =>
|
||||
|
||||
it('reaps staging against the attempts that could still read it', async () => {
|
||||
const { service, downloads, direct } = makeService();
|
||||
downloads.findLiveDirectHashes.mockResolvedValue(['aaa', 'bbb']);
|
||||
downloads.findLiveDirectClientKeys.mockResolvedValue(['aaa', 'bbb']);
|
||||
|
||||
await service.onApplicationBootstrap();
|
||||
|
||||
@@ -182,7 +182,7 @@ describe('RequestWatchdogService direct-download startup reconciliation', () =>
|
||||
const { service, downloads, requests } = makeService();
|
||||
// Reset first: the harness queues a one-shot batch that would be consumed before the rejection.
|
||||
requests.findStrandedSearching.mockReset().mockRejectedValue(new Error('db down'));
|
||||
downloads.findLiveDirectHashes.mockRejectedValue(new Error('db down'));
|
||||
downloads.findLiveDirectClientKeys.mockRejectedValue(new Error('db down'));
|
||||
|
||||
await expect(service.onApplicationBootstrap()).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -160,11 +160,11 @@ export class RequestWatchdogService implements OnApplicationBootstrap {
|
||||
* Staging directories with no attempt behind them any more.
|
||||
*
|
||||
* Direct-download progress lives in memory, so a transfer a restart interrupted leaves bytes
|
||||
* nothing will poll, import or remove - and each failed URL stages under its own hash, so this
|
||||
* nothing will poll, import or remove - and each failed URL stages under its own client key, so this
|
||||
* accumulates rather than overwrites.
|
||||
*/
|
||||
private async reapStagedDownloads(): Promise<void> {
|
||||
const live = new Set(await this.downloads.findLiveDirectHashes());
|
||||
const live = new Set(await this.downloads.findLiveDirectClientKeys());
|
||||
const reaped = await this.direct.reapStaging(live);
|
||||
if (reaped === 0) return;
|
||||
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import { DEFAULT_INDEXER_CATEGORIES } from '@bookorbit/types';
|
||||
|
||||
vi.mock('undici', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('undici')>()),
|
||||
fetch: (...args: Parameters<typeof fetch>) => fetch(...args),
|
||||
}));
|
||||
|
||||
vi.mock('dns/promises', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('dns/promises')>()),
|
||||
lookup: vi.fn(() => Promise.resolve([{ address: '93.184.216.34', family: 4 }])),
|
||||
}));
|
||||
|
||||
import { forgetDispatchers } from '../../../../common/utils/safe-fetch';
|
||||
import { IndexerSearchException, type ReleaseQuery, type ResolvedIndexerConfig } from '../indexer-adapter';
|
||||
import { NewznabAdapter, newznabClientKey } from './newznab.adapter';
|
||||
|
||||
const NZB = Buffer.from('<?xml version="1.0"?><nzb xmlns="http://www.newzbin.com/DTD/2003/nzb"><file /></nzb>');
|
||||
|
||||
function config(overrides: Partial<ResolvedIndexerConfig> = {}): ResolvedIndexerConfig {
|
||||
return {
|
||||
id: 7,
|
||||
name: 'usenet indexer',
|
||||
adapterType: 'newznab',
|
||||
baseUrl: 'https://indexer.example.com',
|
||||
credential: 'secret-key',
|
||||
credentialError: null,
|
||||
allowPrivateAddress: false,
|
||||
categories: DEFAULT_INDEXER_CATEGORIES.newznab,
|
||||
disabledMediaKinds: [],
|
||||
isbnSearchDisabled: false,
|
||||
settings: null,
|
||||
networkProfile: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function query(overrides: Partial<ReleaseQuery> = {}): ReleaseQuery {
|
||||
return {
|
||||
title: 'Dune',
|
||||
author: 'Frank Herbert',
|
||||
isbn13: '9780441013593',
|
||||
isbn13s: ['9780441013593'],
|
||||
mediaKind: 'ebook',
|
||||
language: null,
|
||||
limit: 50,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function feed(items: string): string {
|
||||
return `<?xml version="1.0"?><rss xmlns:newznab="http://www.newznab.com/DTD/2010/feeds/attributes/"><channel>${items}</channel></rss>`;
|
||||
}
|
||||
|
||||
const ITEM = `<item>
|
||||
<title>Dune - Frank Herbert EPUB</title>
|
||||
<guid isPermaLink="false">release-guid</guid>
|
||||
<link>https://indexer.example.com/api?t=get&id=release-guid&apikey=secret-key</link>
|
||||
<pubDate>Tue, 01 Apr 2025 10:00:00 +0000</pubDate>
|
||||
<newznab:attr name="size" value="1048576" />
|
||||
<newznab:attr name="files" value="2" />
|
||||
<newznab:attr name="author" value="Frank Herbert" />
|
||||
<newznab:attr name="booktitle" value="Dune" />
|
||||
<newznab:attr name="isbn" value="9780441013593" />
|
||||
<newznab:attr name="format" value="EPUB" />
|
||||
</item>`;
|
||||
|
||||
describe('NewznabAdapter', () => {
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
forgetDispatchers();
|
||||
});
|
||||
|
||||
it('searches by ISBN with the configured category and API key', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(feed(ITEM)));
|
||||
|
||||
const releases = await new NewznabAdapter().search(query(), config(), AbortSignal.timeout(1000));
|
||||
|
||||
const url = new URL(fetchMock.mock.calls[0]![0].toString());
|
||||
expect(url.pathname).toBe('/api');
|
||||
expect(url.searchParams.get('t')).toBe('search');
|
||||
expect(url.searchParams.get('q')).toBe('9780441013593');
|
||||
expect(url.searchParams.get('cat')).toBe('7020');
|
||||
expect(url.searchParams.get('apikey')).toBe('secret-key');
|
||||
expect(releases[0]).toMatchObject({
|
||||
guid: 'release-guid',
|
||||
title: 'Dune - Frank Herbert EPUB',
|
||||
sizeBytes: 1048576,
|
||||
fileCount: 2,
|
||||
author: 'Frank Herbert',
|
||||
bookTitle: 'Dune',
|
||||
isbn: '9780441013593',
|
||||
format: 'EPUB',
|
||||
seeders: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to title and author for a search without an ISBN', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(feed('')));
|
||||
|
||||
await new NewznabAdapter().search(query({ isbn13: null, isbn13s: [] }), config(), AbortSignal.timeout(1000));
|
||||
|
||||
expect(new URL(fetchMock.mock.calls[0]![0].toString()).searchParams.get('q')).toBe('Dune Frank Herbert');
|
||||
});
|
||||
|
||||
it('does not expose a credentialed download URL as the release identifier', async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
new Response(
|
||||
feed(`<item>
|
||||
<title>Dune EPUB</title>
|
||||
<enclosure url="https://indexer.example.com/api?t=get&id=release-guid&apikey=secret-key" length="1048576" />
|
||||
</item>`),
|
||||
),
|
||||
);
|
||||
|
||||
const [release] = await new NewznabAdapter().search(query(), config(), AbortSignal.timeout(1000));
|
||||
|
||||
expect(release?.guid).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(release?.guid).not.toContain('secret-key');
|
||||
});
|
||||
|
||||
it('turns a Newznab credential error document into an unauthorized failure', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('<error code="100" description="Incorrect user credentials" />'));
|
||||
|
||||
await expect(new NewznabAdapter().search(query(), config(), AbortSignal.timeout(1000))).rejects.toMatchObject({
|
||||
failure: 'unauthorized',
|
||||
});
|
||||
});
|
||||
|
||||
it('classifies HTTP throttling separately from an indexer error', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('', { status: 429 }));
|
||||
|
||||
await expect(new NewznabAdapter().search(query(), config(), AbortSignal.timeout(1000))).rejects.toMatchObject({
|
||||
failure: 'throttled',
|
||||
});
|
||||
});
|
||||
|
||||
it('follows search redirects manually without carrying the original API query onto the target', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: '/newznab-feed' } }))
|
||||
.mockResolvedValueOnce(new Response(feed(ITEM)));
|
||||
|
||||
await new NewznabAdapter().search(query(), config(), AbortSignal.timeout(1000));
|
||||
|
||||
const redirected = new URL(fetchMock.mock.calls[1]![0].toString());
|
||||
expect(redirected.pathname).toBe('/newznab-feed');
|
||||
expect(redirected.search).toBe('');
|
||||
});
|
||||
|
||||
it('bounds search responses before parsing them', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('<rss />', { headers: { 'content-length': String(16 * 1024 * 1024 + 1) } }));
|
||||
|
||||
await expect(new NewznabAdapter().search(query(), config(), AbortSignal.timeout(1000))).rejects.toMatchObject({
|
||||
failure: 'error',
|
||||
message: expect.stringContaining('more XML'),
|
||||
});
|
||||
});
|
||||
|
||||
it('tests the capabilities endpoint', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('<caps><server title="Example Indexer" /></caps>'));
|
||||
|
||||
await expect(new NewznabAdapter().test(config())).resolves.toEqual({ success: true, indexerName: 'Example Indexer' });
|
||||
expect(new URL(fetchMock.mock.calls[0]![0].toString()).searchParams.get('t')).toBe('caps');
|
||||
});
|
||||
|
||||
it('fetches and validates the credentialed NZB', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(NZB));
|
||||
|
||||
await expect(
|
||||
new NewznabAdapter().fetchNzbFile(
|
||||
{
|
||||
indexerId: 7,
|
||||
guid: 'release-guid',
|
||||
title: 'Dune',
|
||||
downloadUrl: 'https://indexer.example.com/get/1',
|
||||
sizeBytes: null,
|
||||
seeders: null,
|
||||
leechers: null,
|
||||
},
|
||||
config(),
|
||||
),
|
||||
).resolves.toEqual(NZB);
|
||||
});
|
||||
|
||||
it('follows a relative NZB redirect and validates the final document', async () => {
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(new Response(null, { status: 302, headers: { location: '/download/final.nzb' } }))
|
||||
.mockResolvedValueOnce(new Response(NZB));
|
||||
|
||||
await expect(
|
||||
new NewznabAdapter().fetchNzbFile(
|
||||
{
|
||||
indexerId: 7,
|
||||
guid: 'release-guid',
|
||||
title: 'Dune',
|
||||
downloadUrl: 'https://indexer.example.com/get/1',
|
||||
sizeBytes: null,
|
||||
seeders: null,
|
||||
leechers: null,
|
||||
},
|
||||
config(),
|
||||
),
|
||||
).resolves.toEqual(NZB);
|
||||
expect(new URL(fetchMock.mock.calls[1]![0].toString()).pathname).toBe('/download/final.nzb');
|
||||
});
|
||||
|
||||
it('rejects empty and oversized NZB responses', async () => {
|
||||
const release = {
|
||||
indexerId: 7,
|
||||
guid: 'release-guid',
|
||||
title: 'Dune',
|
||||
downloadUrl: 'https://indexer.example.com/get/1',
|
||||
sizeBytes: null,
|
||||
seeders: null,
|
||||
leechers: null,
|
||||
};
|
||||
fetchMock.mockResolvedValueOnce(new Response(''));
|
||||
await expect(new NewznabAdapter().fetchNzbFile(release, config())).rejects.toMatchObject({ message: expect.stringContaining('empty NZB') });
|
||||
|
||||
fetchMock.mockResolvedValueOnce(new Response(NZB, { headers: { 'content-length': String(32 * 1024 * 1024 + 1) } }));
|
||||
await expect(new NewznabAdapter().fetchNzbFile(release, config())).rejects.toMatchObject({ message: expect.stringContaining('too large') });
|
||||
});
|
||||
|
||||
it('rejects a download response that is not an NZB document', async () => {
|
||||
fetchMock.mockResolvedValue(new Response('<html>login</html>'));
|
||||
|
||||
await expect(
|
||||
new NewznabAdapter().fetchNzbFile(
|
||||
{
|
||||
indexerId: 7,
|
||||
guid: 'release-guid',
|
||||
title: 'Dune',
|
||||
downloadUrl: 'https://indexer.example.com/get/1',
|
||||
sizeBytes: null,
|
||||
seeders: null,
|
||||
leechers: null,
|
||||
},
|
||||
config(),
|
||||
),
|
||||
).rejects.toBeInstanceOf(IndexerSearchException);
|
||||
});
|
||||
|
||||
it('classifies an NZB fetch timeout for automation retry', async () => {
|
||||
fetchMock.mockRejectedValue(new DOMException('timed out', 'TimeoutError'));
|
||||
|
||||
await expect(
|
||||
new NewznabAdapter().fetchNzbFile(
|
||||
{
|
||||
indexerId: 7,
|
||||
guid: 'release-guid',
|
||||
title: 'Dune',
|
||||
downloadUrl: 'https://indexer.example.com/get/1',
|
||||
sizeBytes: null,
|
||||
seeders: null,
|
||||
leechers: null,
|
||||
},
|
||||
config(),
|
||||
),
|
||||
).rejects.toMatchObject({ failure: 'timeout' });
|
||||
});
|
||||
|
||||
it('derives a stable source-scoped client key', () => {
|
||||
expect(newznabClientKey(7, 'release-guid')).toHaveLength(64);
|
||||
expect(newznabClientKey(7, 'release-guid')).toBe(newznabClientKey(7, 'release-guid'));
|
||||
expect(newznabClientKey(8, 'release-guid')).not.toBe(newznabClientKey(7, 'release-guid'));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { XMLParser, XMLValidator } from 'fast-xml-parser';
|
||||
import { createHash } from 'crypto';
|
||||
import type { BookRequestMediaKind, IndexerTestResult } from '@bookorbit/types';
|
||||
import { MAX_INDEXER_RELEASE_GUID_LENGTH } from '@bookorbit/types';
|
||||
|
||||
import { readBoundedBytes, ResponseTooLargeError } from '../../../../common/utils/bounded-response';
|
||||
import { sanitizeLogValue } from '../../../../common/utils/log-sanitize.utils';
|
||||
import { safeFetch } from '../../../../common/utils/safe-fetch';
|
||||
import { ensureSafeUrl, PrivateAddressException } from '../../../../common/utils/ssrf.utils';
|
||||
import { buildSearchText } from '../search-text';
|
||||
import {
|
||||
IndexerSearchException,
|
||||
type IndexerAdapter,
|
||||
type ReleaseCandidate,
|
||||
type ReleaseQuery,
|
||||
type ResolvedIndexerConfig,
|
||||
} from '../indexer-adapter';
|
||||
|
||||
const MAX_XML_RESPONSE_BYTES = 16 * 1024 * 1024;
|
||||
const MAX_NZB_FILE_BYTES = 32 * 1024 * 1024;
|
||||
const MAX_FEED_ITEMS = 500;
|
||||
const MAX_FEED_FIELD_CHARS = MAX_INDEXER_RELEASE_GUID_LENGTH;
|
||||
const REQUEST_TIMEOUT_MS = 25_000;
|
||||
const MAX_REDIRECTS = 5;
|
||||
const XML_ACCEPT = 'application/rss+xml, application/xml, text/xml, */*;q=0.1';
|
||||
const NZB_ACCEPT = 'application/x-nzb, application/xml, text/xml, */*;q=0.1';
|
||||
|
||||
const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', textNodeName: '#text' });
|
||||
|
||||
interface NewznabAttr {
|
||||
'@_name'?: string;
|
||||
'@_value'?: string | number;
|
||||
}
|
||||
|
||||
interface NewznabItem {
|
||||
title?: string;
|
||||
guid?: unknown;
|
||||
link?: string;
|
||||
size?: string | number;
|
||||
pubDate?: string;
|
||||
category?: string | string[];
|
||||
enclosure?: { '@_url'?: string; '@_length'?: string | number } | Array<{ '@_url'?: string; '@_length'?: string | number }>;
|
||||
'newznab:attr'?: NewznabAttr | NewznabAttr[];
|
||||
'torznab:attr'?: NewznabAttr | NewznabAttr[];
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class NewznabAdapter implements IndexerAdapter {
|
||||
readonly type = 'newznab' as const;
|
||||
readonly label = 'Newznab';
|
||||
readonly mediaKinds: readonly BookRequestMediaKind[] = ['ebook', 'audiobook', 'comic'];
|
||||
readonly supportsIsbnSearch = true;
|
||||
readonly requiresCredential = true;
|
||||
|
||||
private readonly logger = new Logger(NewznabAdapter.name);
|
||||
|
||||
async search(query: ReleaseQuery, config: ResolvedIndexerConfig, signal: AbortSignal): Promise<ReleaseCandidate[]> {
|
||||
const searchText = query.isbn13 ?? buildSearchText(query);
|
||||
const params = new URLSearchParams({ t: 'search', q: searchText, limit: String(query.limit), extended: '1' });
|
||||
params.set('apikey', requireCredential(config));
|
||||
const categories = config.categories[query.mediaKind];
|
||||
if (categories.length > 0) params.set('cat', categories.join(','));
|
||||
|
||||
return parseItems(await this.callXml(config, params, signal), config.id);
|
||||
}
|
||||
|
||||
async test(config: ResolvedIndexerConfig): Promise<IndexerTestResult> {
|
||||
const params = new URLSearchParams({ t: 'caps', apikey: requireCredential(config) });
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const xml = await this.callXml(config, params, AbortSignal.timeout(REQUEST_TIMEOUT_MS));
|
||||
const parsed = parser.parse(xml) as { caps?: { server?: { '@_title'?: string } } };
|
||||
if (!parsed.caps) return { success: false, error: 'That URL answered, but not with a Newznab capabilities document' };
|
||||
return { success: true, indexerName: parsed.caps.server?.['@_title'] };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(
|
||||
`[request_indexer.test] [fail] indexerId=${config.id} durationMs=${Date.now() - startedAt} errorClass=${error instanceof Error ? error.constructor.name : 'Error'} error="${sanitizeLogValue(message)}" - newznab test failed`,
|
||||
);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
async fetchNzbFile(release: ReleaseCandidate, config: ResolvedIndexerConfig): Promise<Buffer> {
|
||||
if (!release.downloadUrl) throw new IndexerSearchException('error', 'That release has no NZB download link');
|
||||
|
||||
try {
|
||||
let current = await ensureSafeUrl(release.downloadUrl, { allowPrivate: config.allowPrivateAddress });
|
||||
const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||
const response = await safeFetch(
|
||||
current.href,
|
||||
{ signal, redirect: 'manual', headers: { Accept: NZB_ACCEPT } },
|
||||
{ allowPrivate: config.allowPrivateAddress, profile: config.networkProfile, pinResolvedAddress: true },
|
||||
);
|
||||
|
||||
if (response.status >= 300 && response.status < 400) {
|
||||
const location = response.headers.get('location');
|
||||
void response.body?.cancel().catch(() => undefined);
|
||||
if (!location) throw new IndexerSearchException('error', `The indexer answered ${response.status} without saying where the NZB is`);
|
||||
current = await ensureSafeUrl(new URL(location, current).href, { allowPrivate: config.allowPrivateAddress });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!response.ok) throw new IndexerSearchException('error', `The indexer answered ${response.status} for that NZB`);
|
||||
const body = await readBounded(response, MAX_NZB_FILE_BYTES, 'The indexer returned an NZB that is too large');
|
||||
assertNzb(body);
|
||||
return body;
|
||||
}
|
||||
|
||||
throw new IndexerSearchException('error', `That NZB link redirected more than ${MAX_REDIRECTS} times`);
|
||||
} catch (error) {
|
||||
if (error instanceof IndexerSearchException) throw error;
|
||||
if (error instanceof PrivateAddressException) throw new IndexerSearchException('error', error.message);
|
||||
if (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')) {
|
||||
throw new IndexerSearchException('timeout', `${config.name} did not return the NZB in time`);
|
||||
}
|
||||
throw new IndexerSearchException(
|
||||
'unreachable',
|
||||
`Could not fetch the NZB from ${config.name}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async callXml(config: ResolvedIndexerConfig, params: URLSearchParams, signal: AbortSignal): Promise<string> {
|
||||
const options = { allowPrivate: config.allowPrivateAddress, profile: config.networkProfile, pinResolvedAddress: true };
|
||||
let current = await endpointUrl(config, params);
|
||||
let response: Response | null = null;
|
||||
|
||||
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
||||
try {
|
||||
response = await safeFetch(current.href, { signal, redirect: 'manual', headers: { Accept: XML_ACCEPT } }, options);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && (error.name === 'AbortError' || error.name === 'TimeoutError')) {
|
||||
throw new IndexerSearchException('timeout', `${config.name} did not answer in time`);
|
||||
}
|
||||
throw new IndexerSearchException('unreachable', `Could not reach ${config.name}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
if (response.status < 300 || response.status >= 400) break;
|
||||
const location = response.headers.get('location');
|
||||
void response.body?.cancel().catch(() => undefined);
|
||||
if (!location) throw new IndexerSearchException('error', `${config.name} answered ${response.status} without saying where to go`);
|
||||
current = await ensureSafeUrl(new URL(location, current).href, options);
|
||||
response = null;
|
||||
}
|
||||
|
||||
if (!response) throw new IndexerSearchException('error', `${config.name} redirected more than ${MAX_REDIRECTS} times`);
|
||||
if (response.status === 429) throw new IndexerSearchException('throttled', `${config.name} is rate limiting us`);
|
||||
if (response.status === 401 || response.status === 403) throw new IndexerSearchException('unauthorized', `${config.name} rejected the API key`);
|
||||
if (!response.ok) throw new IndexerSearchException('error', `${config.name} answered ${response.status}`);
|
||||
|
||||
const body = (await readBounded(response, MAX_XML_RESPONSE_BYTES, `${config.name} answered with more XML than BookOrbit will read`)).toString(
|
||||
'utf8',
|
||||
);
|
||||
const error = readErrorElement(body);
|
||||
if (error) throw new IndexerSearchException(error.failure, `${config.name}: ${error.description}`);
|
||||
return body;
|
||||
}
|
||||
}
|
||||
|
||||
export function newznabClientKey(indexerId: number, guid: string): string {
|
||||
return createHash('sha256').update(String(indexerId)).update('\0').update(guid).digest('hex');
|
||||
}
|
||||
|
||||
function requireCredential(config: ResolvedIndexerConfig): string {
|
||||
if (!config.credential) throw new IndexerSearchException('unauthorized', `${config.name} needs an API key`);
|
||||
return config.credential;
|
||||
}
|
||||
|
||||
async function endpointUrl(config: ResolvedIndexerConfig, params: URLSearchParams): Promise<URL> {
|
||||
const base = await ensureSafeUrl(config.baseUrl, { allowPrivate: config.allowPrivateAddress });
|
||||
const prefix = base.pathname.replace(/\/+$/, '');
|
||||
const target = new URL(base.href);
|
||||
const completeEndpoint = prefix.endsWith('/api') || /\/api\/v\d+\/indexer\/\d+\/newznab$/i.test(prefix);
|
||||
target.pathname = completeEndpoint ? prefix : `${prefix}/api`;
|
||||
const merged = new URLSearchParams(base.search);
|
||||
for (const key of new Set(params.keys())) merged.delete(key);
|
||||
for (const [key, value] of params) merged.append(key, value);
|
||||
target.search = merged.toString();
|
||||
target.hash = '';
|
||||
return target;
|
||||
}
|
||||
|
||||
async function readBounded(response: Response, limit: number, message: string): Promise<Buffer> {
|
||||
try {
|
||||
return await readBoundedBytes(response, limit);
|
||||
} catch (error) {
|
||||
if (error instanceof ResponseTooLargeError) throw new IndexerSearchException('error', message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function assertNzb(body: Buffer): void {
|
||||
if (body.byteLength === 0) throw new IndexerSearchException('error', 'The indexer returned an empty NZB');
|
||||
const xml = body.toString('utf8');
|
||||
if (!/<nzb\b/i.test(xml.slice(0, 4096)) || XMLValidator.validate(xml) !== true) {
|
||||
throw new IndexerSearchException('error', 'The indexer response was not a valid NZB document');
|
||||
}
|
||||
}
|
||||
|
||||
function readErrorElement(body: string): { failure: 'unauthorized' | 'error'; description: string } | null {
|
||||
const element = /<error\b[^>]*\/?>/i.exec(body);
|
||||
if (!element) return null;
|
||||
const description = /\bdescription="([^"]*)"/i.exec(element[0])?.[1] ?? 'the indexer reported an error';
|
||||
const code = Number(/\bcode="(\d+)"/i.exec(element[0])?.[1]);
|
||||
return { failure: Number.isFinite(code) && code >= 100 && code < 200 ? 'unauthorized' : 'error', description };
|
||||
}
|
||||
|
||||
function parseItems(xml: string, indexerId: number): ReleaseCandidate[] {
|
||||
const parsed = parser.parse(xml) as { rss?: { channel?: { item?: NewznabItem | NewznabItem[] } } };
|
||||
return toArray(parsed.rss?.channel?.item)
|
||||
.slice(0, MAX_FEED_ITEMS)
|
||||
.flatMap((item) => {
|
||||
const title = typeof item.title === 'string' ? item.title.trim() : '';
|
||||
if (!title || !withinBound(title)) return [];
|
||||
const attrs = attrMap(item);
|
||||
const enclosure = toArray(item.enclosure)[0];
|
||||
const link = item.link ?? enclosure?.['@_url'];
|
||||
const sourceGuid = readGuid(item);
|
||||
if (!withinBound(sourceGuid) || !withinBound(link) || !link || !/^https?:/i.test(link)) return [];
|
||||
const guid = publicReleaseGuid(indexerId, sourceGuid ?? link);
|
||||
const publishedAt = parseDate(item.pubDate);
|
||||
const fileCount = toNumber(attrs.get('files'));
|
||||
const format = attrs.get('format');
|
||||
return [
|
||||
{
|
||||
indexerId,
|
||||
guid,
|
||||
title,
|
||||
downloadUrl: link,
|
||||
sizeBytes: toNumber(attrs.get('size') ?? item.size ?? enclosure?.['@_length']),
|
||||
seeders: null,
|
||||
leechers: null,
|
||||
...(attrs.get('author') ? { author: attrs.get('author') } : {}),
|
||||
...(attrs.get('language') ? { language: attrs.get('language') } : {}),
|
||||
...(attrs.get('booktitle') ? { bookTitle: attrs.get('booktitle') } : {}),
|
||||
...(attrs.get('isbn') ? { isbn: attrs.get('isbn') } : {}),
|
||||
...(format ? { format } : {}),
|
||||
...(publishedAt ? { publishedAt } : {}),
|
||||
...(fileCount !== null ? { fileCount } : {}),
|
||||
} satisfies ReleaseCandidate,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function attrMap(item: NewznabItem): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
for (const attr of [...toArray(item['newznab:attr']), ...toArray(item['torznab:attr'])]) {
|
||||
const name = attr['@_name']?.toLowerCase();
|
||||
const value = attr['@_value'];
|
||||
if (!name || value === undefined || map.has(name)) continue;
|
||||
const text = String(value);
|
||||
if (withinBound(text)) map.set(name, text);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function readGuid(item: NewznabItem): string | undefined {
|
||||
if (typeof item.guid === 'string' && item.guid.trim()) return item.guid.trim();
|
||||
if (typeof item.guid === 'number') return String(item.guid);
|
||||
if (item.guid && typeof item.guid === 'object') {
|
||||
const text = (item.guid as { '#text'?: unknown })['#text'];
|
||||
if (typeof text === 'string' && text.trim()) return text.trim();
|
||||
if (typeof text === 'number') return String(text);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** A URL-shaped GUID can carry the API key, so expose only a stable opaque identifier for it. */
|
||||
function publicReleaseGuid(indexerId: number, value: string): string {
|
||||
if (!/^https?:/i.test(value)) return value;
|
||||
return createHash('sha256').update(String(indexerId)).update('\0release\0').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function withinBound(value: string | undefined): boolean {
|
||||
return value === undefined || value.length <= MAX_FEED_FIELD_CHARS;
|
||||
}
|
||||
|
||||
function toNumber(value: string | number | undefined | null): number | null {
|
||||
if (value === undefined || value === null || value === '') return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function parseDate(raw: string | undefined): string | undefined {
|
||||
if (!raw) return undefined;
|
||||
const parsed = new Date(raw);
|
||||
return Number.isNaN(parsed.getTime()) ? undefined : parsed.toISOString();
|
||||
}
|
||||
|
||||
function toArray<T>(value: T | T[] | undefined | null): T[] {
|
||||
if (value === undefined || value === null) return [];
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
@@ -19,7 +19,7 @@ export interface ResolvedIndexerConfig {
|
||||
color: IndexerColor | null;
|
||||
adapterType: IndexerAdapterType;
|
||||
baseUrl: string;
|
||||
/** A torznab API key or a tracker session id, depending on the adapter. */
|
||||
/** An API key or tracker session id, depending on the adapter. */
|
||||
credential: string | null;
|
||||
/**
|
||||
* Why the stored credential could not be read, where that is what happened. Set only on the
|
||||
@@ -174,6 +174,8 @@ export interface IndexerAdapter {
|
||||
test(config: ResolvedIndexerConfig): Promise<IndexerTestResult>;
|
||||
/** Private trackers need an authenticated .torrent fetch rather than a public magnet. */
|
||||
fetchTorrentFile?(release: ReleaseCandidate, config: ResolvedIndexerConfig): Promise<TorrentFetchResult>;
|
||||
/** Newznab download endpoint resolved to an NZB before its credential can reach the client. */
|
||||
fetchNzbFile?(release: ReleaseCandidate, config: ResolvedIndexerConfig): Promise<Buffer>;
|
||||
/**
|
||||
* Sources that serve the file itself. An adapter implements this or `fetchTorrentFile`, never
|
||||
* both: which one it declares is what decides whether a grab becomes a torrent or a download.
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
DEFAULT_INDEXER_CATEGORIES,
|
||||
INDEXER_CREDENTIAL_KINDS,
|
||||
INDEXER_DEFAULT_BASE_URLS,
|
||||
INDEXER_DELIVERY,
|
||||
INDEXER_MEDIA_KINDS,
|
||||
INDEXER_SEEDS_BACK,
|
||||
INDEXER_USES_CATEGORIES,
|
||||
@@ -92,6 +93,13 @@ export class IndexerRegistry implements OnModuleInit {
|
||||
return adapter instanceof PluginIndexerAdapter ? adapter.plugin.seedsBack : INDEXER_SEEDS_BACK[adapter.type];
|
||||
}
|
||||
|
||||
delivery(type: IndexerAdapterType | string) {
|
||||
const adapter = this.find(type);
|
||||
if (!adapter) return 'torrent' as const;
|
||||
if (adapter instanceof PluginIndexerAdapter) return adapter.plugin.seedsBack ? ('torrent' as const) : ('file' as const);
|
||||
return INDEXER_DELIVERY[adapter.type];
|
||||
}
|
||||
|
||||
/**
|
||||
* The categories a source of this type searches when the operator has not chosen any.
|
||||
*
|
||||
@@ -127,6 +135,7 @@ function describeBuiltIn(adapter: IndexerAdapter): IndexerAdapterDescriptor {
|
||||
mediaKinds: [...INDEXER_MEDIA_KINDS[type]],
|
||||
usesCategories: INDEXER_USES_CATEGORIES[type],
|
||||
seedsBack: INDEXER_SEEDS_BACK[type],
|
||||
delivery: INDEXER_DELIVERY[type],
|
||||
supportsIsbnSearch: adapter.supportsIsbnSearch,
|
||||
defaultCategories: DEFAULT_INDEXER_CATEGORIES[type],
|
||||
...(INDEXER_DEFAULT_BASE_URLS[type] ? { defaultBaseUrl: INDEXER_DEFAULT_BASE_URLS[type] } : {}),
|
||||
@@ -154,6 +163,7 @@ function describePlugin(adapter: PluginIndexerAdapter): IndexerAdapterDescriptor
|
||||
mediaKinds: [...plugin.mediaKinds],
|
||||
usesCategories: plugin.usesCategories,
|
||||
seedsBack: plugin.seedsBack,
|
||||
delivery: plugin.seedsBack ? 'torrent' : 'file',
|
||||
// The adapter's, not the plugin's: a plugin may leave the field off entirely.
|
||||
supportsIsbnSearch: adapter.supportsIsbnSearch,
|
||||
defaultCategories: plugin.defaultCategories ?? { ebook: [], audiobook: [], comic: [] },
|
||||
|
||||
@@ -63,7 +63,16 @@ function release(overrides: Partial<ReleaseCandidate> = {}): ReleaseCandidate {
|
||||
*/
|
||||
function makeService(
|
||||
configs: ResolvedIndexerConfig[],
|
||||
adapters: Record<string, { search: ReturnType<typeof vi.fn>; mediaKinds?: readonly string[]; seedsBack?: boolean; supportsIsbnSearch?: boolean }>,
|
||||
adapters: Record<
|
||||
string,
|
||||
{
|
||||
search: ReturnType<typeof vi.fn>;
|
||||
mediaKinds?: readonly string[];
|
||||
seedsBack?: boolean;
|
||||
supportsIsbnSearch?: boolean;
|
||||
delivery?: 'torrent' | 'usenet' | 'file';
|
||||
}
|
||||
>,
|
||||
) {
|
||||
// The rows that exist, of which `configs` is the enabled subset. Every case here builds its
|
||||
// sources enabled, so the two counts match unless a case says otherwise.
|
||||
@@ -79,6 +88,7 @@ function makeService(
|
||||
return adapter ? { mediaKinds: ['ebook', 'audiobook', 'comic'], supportsIsbnSearch: false, ...adapter } : undefined;
|
||||
}),
|
||||
seedsBack: vi.fn((type: string) => adapters[type]?.seedsBack ?? true),
|
||||
delivery: vi.fn((type: string) => adapters[type]?.delivery ?? (adapters[type]?.seedsBack === false ? 'file' : 'torrent')),
|
||||
};
|
||||
// No profile, which is the shipped default and keeps these cases about search and scoring.
|
||||
const automationSettings = { get: vi.fn(() => Promise.resolve({ profiles: emptyReleaseProfiles() })) };
|
||||
@@ -207,7 +217,7 @@ describe('IndexerSearchService', () => {
|
||||
});
|
||||
|
||||
it('searches only the recommended ISBN while retaining every distinct provider ISBN as an alternative', async () => {
|
||||
const libgen = { search: vi.fn().mockResolvedValue([]), supportsIsbnSearch: true };
|
||||
const libgen = { search: vi.fn().mockResolvedValueOnce([release()]), supportsIsbnSearch: true };
|
||||
const { service } = makeService([indexer({ adapterType: pluginType('libgen') })], { libgen });
|
||||
|
||||
const result = await service.search(
|
||||
@@ -228,6 +238,51 @@ describe('IndexerSearchService', () => {
|
||||
expect(result.indexers[0]?.query).toEqual({ kind: 'isbn', value: '9780441013593' });
|
||||
});
|
||||
|
||||
it('falls back from an empty ISBN search to title and author without identifiers', async () => {
|
||||
const libgen = {
|
||||
search: vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([release()]),
|
||||
supportsIsbnSearch: true,
|
||||
};
|
||||
const { service } = makeService([indexer({ adapterType: pluginType('libgen') })], { libgen });
|
||||
|
||||
const result = await service.search(request({ isbn13: '9780441172719' }));
|
||||
|
||||
expect(libgen.search).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ isbn13: '9780441172719', isbn13s: ['9780441172719'] }),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(libgen.search).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ title: 'Dune', author: 'Frank Herbert', isbn13: null, isbn13s: [] }),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(result.releases).toHaveLength(1);
|
||||
expect(result.indexers[0]?.query).toEqual({ kind: 'titleAuthor', value: 'Dune Frank Herbert' });
|
||||
});
|
||||
|
||||
it('uses the existing title-only fallback after ISBN and title-author searches are empty', async () => {
|
||||
const libgen = {
|
||||
search: vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([]).mockResolvedValueOnce([release()]),
|
||||
supportsIsbnSearch: true,
|
||||
};
|
||||
const { service } = makeService([indexer({ adapterType: pluginType('libgen') })], { libgen });
|
||||
|
||||
const result = await service.search(request({ isbn13: '9780441172719' }));
|
||||
|
||||
expect(libgen.search).toHaveBeenCalledTimes(3);
|
||||
expect(libgen.search).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
expect.objectContaining({ title: 'Dune', author: null, isbn13: null, isbn13s: [] }),
|
||||
expect.anything(),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(result.releases).toHaveLength(1);
|
||||
expect(result.indexers[0]?.query).toEqual({ kind: 'titleAuthor', value: 'Dune Frank Herbert' });
|
||||
});
|
||||
|
||||
it('uses explicit picker overrides instead of silently retaining request search fields', async () => {
|
||||
const libgen = { search: vi.fn().mockResolvedValue([]), supportsIsbnSearch: true };
|
||||
const { service } = makeService([indexer({ adapterType: pluginType('libgen') })], { libgen });
|
||||
@@ -306,7 +361,7 @@ describe('IndexerSearchService', () => {
|
||||
|
||||
it('limits fallback passes to ISBN-capable indexers', async () => {
|
||||
const torznab = { search: vi.fn().mockResolvedValue([]) };
|
||||
const libgen = { search: vi.fn().mockResolvedValue([]), supportsIsbnSearch: true };
|
||||
const libgen = { search: vi.fn().mockResolvedValue([release({ indexerId: 2 })]), supportsIsbnSearch: true };
|
||||
const { service } = makeService([indexer(), indexer({ id: 2, name: 'libgen', adapterType: pluginType('libgen') })], {
|
||||
torznab,
|
||||
libgen,
|
||||
@@ -354,6 +409,7 @@ describe('IndexerSearchService', () => {
|
||||
{
|
||||
indexerId: 1,
|
||||
indexerName: 'jackett',
|
||||
color: undefined,
|
||||
ok: false,
|
||||
count: 0,
|
||||
filtered: 0,
|
||||
@@ -361,15 +417,18 @@ describe('IndexerSearchService', () => {
|
||||
failure: 'unauthorized',
|
||||
error: 'bad key',
|
||||
seedsBack: true,
|
||||
delivery: 'torrent',
|
||||
},
|
||||
{
|
||||
indexerId: 2,
|
||||
indexerName: 'archive',
|
||||
color: undefined,
|
||||
ok: true,
|
||||
count: 1,
|
||||
filtered: 0,
|
||||
query: { kind: 'titleAuthor', value: 'Dune Frank Herbert' },
|
||||
seedsBack: true,
|
||||
delivery: 'torrent',
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -455,7 +514,7 @@ describe('IndexerSearchService', () => {
|
||||
});
|
||||
|
||||
it('keeps all-indexer and ISBN-capable search caches separate', async () => {
|
||||
const libgen = { search: vi.fn().mockResolvedValue([]), supportsIsbnSearch: true };
|
||||
const libgen = { search: vi.fn().mockResolvedValue([release()]), supportsIsbnSearch: true };
|
||||
const { service } = makeService([indexer({ adapterType: pluginType('libgen') })], { libgen });
|
||||
const book = request({ isbn13: '9780441172719' });
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ export class IndexerSearchService {
|
||||
|
||||
for (const { config, releases, query: indexerQuery, failure, error } of outcomes) {
|
||||
const seedsBack = this.registry.seedsBack(config.adapterType);
|
||||
const delivery = this.registry.delivery(config.adapterType);
|
||||
if (failure) {
|
||||
statuses.push({
|
||||
indexerId: config.id,
|
||||
@@ -110,6 +111,7 @@ export class IndexerSearchService {
|
||||
failure,
|
||||
error,
|
||||
seedsBack,
|
||||
delivery,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -155,6 +157,7 @@ export class IndexerSearchService {
|
||||
failure: 'error',
|
||||
error: `${config.name} returned a release BookOrbit could not read: ${message}`,
|
||||
seedsBack,
|
||||
delivery,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -168,6 +171,7 @@ export class IndexerSearchService {
|
||||
filtered,
|
||||
query: indexerQuery,
|
||||
seedsBack,
|
||||
delivery,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -303,9 +307,9 @@ export class IndexerSearchService {
|
||||
}
|
||||
|
||||
// Withheld rather than flagged, so an adapter cannot search an identifier it was never given.
|
||||
const indexerQuery: IndexerSearchQuery =
|
||||
let indexerQuery: IndexerSearchQuery =
|
||||
this.searchesIsbn(config) && query.isbn13 ? { kind: 'isbn', value: query.isbn13 } : { kind: 'titleAuthor', value: buildSearchText(query) };
|
||||
const indexerScopedQuery: ReleaseQuery = indexerQuery.kind === 'isbn' ? query : { ...query, isbn13: null, isbn13s: [] };
|
||||
let indexerScopedQuery: ReleaseQuery = indexerQuery.kind === 'isbn' ? query : { ...query, isbn13: null, isbn13s: [] };
|
||||
|
||||
const deadline = AbortSignal.timeout(PER_INDEXER_TIMEOUT_MS);
|
||||
try {
|
||||
@@ -318,6 +322,15 @@ export class IndexerSearchService {
|
||||
deadline,
|
||||
() => new IndexerSearchException('timeout', `${config.name} did not answer in time`),
|
||||
);
|
||||
if (releases.length === 0 && indexerQuery.kind === 'isbn') {
|
||||
indexerQuery = { kind: 'titleAuthor', value: buildSearchText(query) };
|
||||
indexerScopedQuery = { ...query, isbn13: null, isbn13s: [] };
|
||||
releases = await withDeadline(
|
||||
adapter.search(indexerScopedQuery, config, deadline),
|
||||
deadline,
|
||||
() => new IndexerSearchException('timeout', `${config.name} did not answer in time`),
|
||||
);
|
||||
}
|
||||
if (releases.length === 0 && indexerQuery.kind === 'titleAuthor' && query.author?.trim()) {
|
||||
const titleOnlyQuery = { ...indexerScopedQuery, author: null };
|
||||
releases = await withDeadline(
|
||||
|
||||
@@ -94,6 +94,19 @@ describe('rejectRelease', () => {
|
||||
expect(rejectRelease(candidate, request({ language: 'en' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('reads a full scene language flag when a release label follows it', () => {
|
||||
const danish = release({ title: 'Hilary.Mantel.Wolf.Hall.2011.DANiSH.RETAiL.ePub.eBOOK-DECiPHER', language: undefined });
|
||||
|
||||
expect(rejectRelease(danish, request({ title: 'Wolf Hall', authors: ['Hilary Mantel'], language: 'en' }))).toContain('not en');
|
||||
expect(rejectRelease(danish, request({ title: 'Wolf Hall', authors: ['Hilary Mantel'], language: 'da' }))).toBeNull();
|
||||
});
|
||||
|
||||
it('does not read a full language name in the work title as a scene flag', () => {
|
||||
const titled = release({ title: 'Peter Ho Davies - The Welsh Girl [EPUB]', language: undefined });
|
||||
|
||||
expect(rejectRelease(titled, request({ title: 'The Welsh Girl', authors: ['Peter Ho Davies'], language: 'en' }))).toBeNull();
|
||||
});
|
||||
|
||||
/**
|
||||
* The filter must stay skipped rather than reject on a language it invented. `normalizeLanguage`
|
||||
* truncates an unknown token to two letters, which would otherwise read "[VIP]" as Vietnamese.
|
||||
@@ -148,6 +161,42 @@ describe('scoreRelease', () => {
|
||||
expect(matched.score).toBeGreaterThan(wrongAuthor.score);
|
||||
});
|
||||
|
||||
it('ranks an exact work above related titles from the same author and series', () => {
|
||||
const asked = request({ title: 'Wolf Hall', authors: ['Hilary Mantel'] });
|
||||
const exact = scoreRelease(release({ title: 'Hilary Mantel - [Wolf Hall 01] - Wolf Hall (epub)', seeders: null }), asked);
|
||||
const pictureBook = scoreRelease(
|
||||
release({ title: 'Hilary.Mantel.Ben.Miles.-.The.Wolf.Hall.Picture.Book.2022.RETAIL.EPUB.eBook-CTO', seeders: null }),
|
||||
asked,
|
||||
);
|
||||
const sequel = scoreRelease(
|
||||
release({ title: 'Hilary Mantel - [The Wolf Hall Trilogy 03] - The Mirror and the Light (epub)', seeders: null }),
|
||||
asked,
|
||||
);
|
||||
|
||||
expect(pointsFor(exact, 'authorMatch')).toBe(61);
|
||||
expect(exact.score).toBeGreaterThan(pictureBook.score);
|
||||
expect(exact.score).toBeGreaterThan(sequel.score);
|
||||
expect(new Set([exact.score, pictureBook.score, sequel.score]).size).toBe(3);
|
||||
});
|
||||
|
||||
it('retains numbers that are part of the requested title', () => {
|
||||
const scored = scoreRelease(
|
||||
release({ title: 'George Orwell - 1984 (Retail EPUB)', seeders: null }),
|
||||
request({ title: '1984', authors: ['George Orwell'] }),
|
||||
);
|
||||
|
||||
expect(pointsFor(scored, 'authorMatch')).toBe(61);
|
||||
});
|
||||
|
||||
it('does not count a terminal scene group as part of the work title', () => {
|
||||
const scored = scoreRelease(
|
||||
release({ title: 'Hilary.Mantel.Wolf.Hall.2011.DANiSH.RETAiL.ePub.eBOOK-DECiPHER', seeders: null }),
|
||||
request({ title: 'Wolf Hall', authors: ['Hilary Mantel'] }),
|
||||
);
|
||||
|
||||
expect(pointsFor(scored, 'authorMatch')).toBe(61);
|
||||
});
|
||||
|
||||
it('treats an ISBN in the release name as decisive', () => {
|
||||
const scored = scoreRelease(release({ title: 'Dune 9780441013593 [EPUB]' }), request({ isbn13: '978-0-441-01359-3' }));
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
import type { BookRequestMediaKind, ReleaseCandidateItem, ReleaseScoreReason, ReleaseTier } from '@bookorbit/types';
|
||||
|
||||
import { normalizeIsbn, normalizeMetadataIsbn } from '../../../common/text-match/isbn-normalize';
|
||||
import { TITLE_MATCH_SCORES, scoreTitleMatch } from '../../../common/text-match/title-match';
|
||||
import { normalizeTitleText, significantTokens, symmetricTitleSimilarity, tokenizeTitleText } from '../../../common/text-match/title-match';
|
||||
import type { ReleaseCandidate } from './indexer-adapter';
|
||||
|
||||
/**
|
||||
@@ -90,6 +90,41 @@ const FORMATS_BY_MEDIA_KIND: Record<BookRequestMediaKind, (format: string) => bo
|
||||
*/
|
||||
const FORMAT_TOKENS: readonly string[] = [...EBOOK_FORMAT_LIST, ...AUDIO_FORMAT_LIST, ...COMIC_FORMAT_LIST];
|
||||
|
||||
const SCENE_LANGUAGE_NAMES: ReadonlyMap<string, string> = new Map([
|
||||
['chinese', 'zh'],
|
||||
['danish', 'da'],
|
||||
['dutch', 'nl'],
|
||||
['english', 'en'],
|
||||
['finnish', 'fi'],
|
||||
['french', 'fr'],
|
||||
['german', 'de'],
|
||||
['italian', 'it'],
|
||||
['japanese', 'ja'],
|
||||
['korean', 'ko'],
|
||||
['norwegian', 'no'],
|
||||
['polish', 'pl'],
|
||||
['portuguese', 'pt'],
|
||||
['russian', 'ru'],
|
||||
['spanish', 'es'],
|
||||
['swedish', 'sv'],
|
||||
]);
|
||||
|
||||
/** Release-scene labels that describe packaging rather than the work's title. */
|
||||
const RELEASE_TITLE_NOISE = new Set([
|
||||
...FORMAT_TOKENS,
|
||||
...SCENE_LANGUAGE_NAMES.keys(),
|
||||
'abridged',
|
||||
'audiobook',
|
||||
'digital',
|
||||
'ebook',
|
||||
'proper',
|
||||
'repack',
|
||||
'retail',
|
||||
'retag',
|
||||
'unabridged',
|
||||
'xpost',
|
||||
]);
|
||||
|
||||
/** The work a release is scored against: the request snapshot, never the library. */
|
||||
export interface ScoringRequest {
|
||||
title: string;
|
||||
@@ -204,24 +239,14 @@ export function toReleaseItem(scored: ScoredRelease, indexerName: string, reques
|
||||
* wrong book used to score a full match and lead the picker ahead of the right one, with no other
|
||||
* signal able to outweigh it.
|
||||
*
|
||||
* Title and author agreement is the qualifier because `scoreTitleMatch` already answers exactly
|
||||
* the question being asked: it returns zero for two unrelated titles rather than a small number,
|
||||
* so this only rejects a release that describes some other book. Anything that agrees even loosely
|
||||
* still takes the full points.
|
||||
*
|
||||
* `scoreTitleMatch` is deliberately generous about a candidate that extends the query, which is
|
||||
* right here (a release name carries the author, the year and the format) and is why a sequel can
|
||||
* still rank well. Import verification, which compares symmetrically, is the gate that catches it.
|
||||
* Title and author agreement is the qualifier. The decorated release name is cleaned before a
|
||||
* symmetric comparison so an exact work stays exact after its author, year and format are removed,
|
||||
* while a sequel or a picture book that merely contains the requested title loses points.
|
||||
*/
|
||||
function matchReason(candidate: ReleaseCandidate, request: ScoringRequest): ReleaseScoreReason {
|
||||
// The bare work title where the indexer published one, since the release name carries the
|
||||
// author, the year and the format flags and only dilutes the comparison.
|
||||
const titleText = candidate.bookTitle ?? candidate.title;
|
||||
const titleScore = scoreTitleMatch(request.title, titleText) / TITLE_MATCH_SCORES.exact;
|
||||
const authorText = candidate.author ?? candidate.title;
|
||||
const titleScore = titleSimilarity(candidate, request);
|
||||
const authors = request.authors.filter(Boolean);
|
||||
const authorScore =
|
||||
authors.length > 0 ? Math.max(...authors.map((author) => scoreTitleMatch(author, authorText) / TITLE_MATCH_SCORES.exact)) : null;
|
||||
const authorScore = authors.length > 0 ? authorSimilarity(candidate, authors) : null;
|
||||
|
||||
const requestedIsbns = [...request.isbns, normalizeMetadataIsbn(request.isbn13), normalizeMetadataIsbn(request.isbn10)].filter(Boolean);
|
||||
if (requestedIsbns.length > 0 && (titleScore > 0 || (authorScore ?? 0) > 0)) {
|
||||
@@ -241,6 +266,45 @@ function matchReason(candidate: ReleaseCandidate, request: ScoringRequest): Rele
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares the work, not the scene label. A requested number is retained because it may be the
|
||||
* whole identity of a title such as 1984; other bare numbers are years, series positions or
|
||||
* version flags. Requested author tokens are removed only where they are not also title tokens.
|
||||
*/
|
||||
function titleSimilarity(candidate: ReleaseCandidate, request: ScoringRequest): number {
|
||||
if (candidate.bookTitle?.trim()) return symmetricTitleSimilarity(request.title, candidate.bookTitle);
|
||||
|
||||
const requestedTokens = new Set(significantTokens(tokenizeTitleText(normalizeTitleText(request.title))));
|
||||
const authorTokens = new Set(request.authors.flatMap((author) => significantTokens(tokenizeTitleText(normalizeTitleText(author)))));
|
||||
const candidateTokens = tokenizeTitleText(normalizeTitleText(candidate.title));
|
||||
const hasTerminalReleaseGroup = /-\s*[\p{L}\p{N}]{2,20}$/u.test(candidate.title.trim());
|
||||
const comparable = candidateTokens.filter((token, index) => {
|
||||
if (requestedTokens.has(token)) return true;
|
||||
if (authorTokens.has(token) || RELEASE_TITLE_NOISE.has(token)) return false;
|
||||
if (/^\d+(?:\.\d+)?$/.test(token) || /^(?:v|ver|version)\d+$/.test(token)) return false;
|
||||
if (hasTerminalReleaseGroup && index === candidateTokens.length - 1) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
return symmetricTitleSimilarity(request.title, comparable.join(' '));
|
||||
}
|
||||
|
||||
/** An author embedded in a decorated release name is still an exact author signal. */
|
||||
function authorSimilarity(candidate: ReleaseCandidate, authors: string[]): number {
|
||||
if (candidate.author?.trim()) {
|
||||
return Math.max(...authors.map((author) => symmetricTitleSimilarity(author, candidate.author!)));
|
||||
}
|
||||
|
||||
const candidateTokens = new Set(tokenizeTitleText(normalizeTitleText(candidate.title)));
|
||||
return Math.max(
|
||||
...authors.map((author) => {
|
||||
const tokens = significantTokens(tokenizeTitleText(normalizeTitleText(author)));
|
||||
if (tokens.length === 0) return 0;
|
||||
return tokens.filter((token) => candidateTokens.has(token)).length / tokens.length;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function formatReason(formats: string[], request: ScoringRequest): ReleaseScoreReason {
|
||||
if (formats.length === 0) return { code: 'unknownFormat', points: WEIGHTS.unknownFormat };
|
||||
|
||||
@@ -358,8 +422,9 @@ const TITLE_LANGUAGE_TOKENS: ReadonlySet<string> = new Set(
|
||||
* fallback `candidate.language` is undefined for every release it returns, and the hard filter in
|
||||
* `rejectRelease` silently stops running rather than failing.
|
||||
*
|
||||
* Only bracketed segments are considered. A bare three-letter word in a title is far more likely
|
||||
* to be part of the title.
|
||||
* Three-letter codes are considered only inside brackets. Full language names are also accepted
|
||||
* when immediately followed by a release label such as RETAIL or EPUB, which distinguishes a
|
||||
* scene flag such as `DANiSH.RETAiL` from a book whose title happens to contain "Danish".
|
||||
*/
|
||||
export function resolveLanguage(candidate: ReleaseCandidate): string | null {
|
||||
if (candidate.language) return candidate.language;
|
||||
@@ -370,6 +435,12 @@ export function resolveLanguage(candidate: ReleaseCandidate): string | null {
|
||||
if (TITLE_LANGUAGE_TOKENS.has(token.toLowerCase())) return token;
|
||||
}
|
||||
}
|
||||
|
||||
const sceneTokens = tokenizeTitleText(normalizeTitleText(candidate.title));
|
||||
for (let index = 0; index < sceneTokens.length - 1; index++) {
|
||||
const language = SCENE_LANGUAGE_NAMES.get(sceneTokens[index]);
|
||||
if (language && RELEASE_TITLE_NOISE.has(sceneTokens[index + 1])) return language;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ describe.skipIf(process.env.BO_REPRO_1318 !== '1')('issue 1318 against real qBit
|
||||
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 });
|
||||
expect(await adapter.add({ magnet: MAGNET, clientKey: infoHashFromMagnet(MAGNET) }, config)).toEqual({ clientKey: V1 });
|
||||
await vi.waitFor(
|
||||
async () => {
|
||||
expect(await (await api(`torrents/info?hashes=${PRIMARY}`)).json()).toEqual([
|
||||
@@ -162,9 +162,9 @@ describe.skipIf(process.env.BO_REPRO_1318 !== '1')('issue 1318 against real qBit
|
||||
});
|
||||
|
||||
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([V1], config)).toEqual([expect.objectContaining({ clientKey: V1, state: 'completed', progressPercent: 100 })]);
|
||||
expect(await adapter.status([PRIMARY], config)).toEqual([
|
||||
expect.objectContaining({ infoHash: PRIMARY, state: 'completed', progressPercent: 100 }),
|
||||
expect.objectContaining({ clientKey: PRIMARY, state: 'completed', progressPercent: 100 }),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -174,7 +174,7 @@ describe.skipIf(process.env.BO_REPRO_1318 !== '1')('issue 1318 against real qBit
|
||||
id: 11,
|
||||
requestId: 7,
|
||||
downloadClientId: config.id,
|
||||
clientHash: V1,
|
||||
clientKey: V1,
|
||||
source: 'magnet',
|
||||
status: 'downloading',
|
||||
bookDockFileId: null,
|
||||
@@ -229,11 +229,11 @@ describe.skipIf(process.env.BO_REPRO_1318 !== '1')('issue 1318 against real qBit
|
||||
});
|
||||
|
||||
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 });
|
||||
await expect(adapter.add({ magnet: MAGNET, clientKey: V1 }, config)).resolves.toEqual({ clientKey: 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' })]);
|
||||
expect((await adapter.listOwned(config)).items).toEqual([expect.objectContaining({ clientKey: 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