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.",
|
||||
|
||||
Reference in New Issue
Block a user