mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
feat(book-requests): add indexer plugin updates and release profiles
Add an in-app update service and UI controls for installed indexer plugins, verifying releases and fetching updates securely. Introduce configurable release profiles for fine-tuning score weights, format preferences, and rejection criteria in book request automation.
This commit is contained in:
@@ -87,6 +87,7 @@ function release(overrides: Partial<ReleaseCandidateItem> = {}): ReleaseCandidat
|
||||
score: 89,
|
||||
tier: null,
|
||||
tierName: null,
|
||||
profileMismatch: null,
|
||||
reasons: [{ code: 'titleMatch', points: 55 }],
|
||||
...overrides,
|
||||
}
|
||||
@@ -802,5 +803,63 @@ describe('ReleasePickerPanel', () => {
|
||||
expect(rows[0].text()).toContain('Tiered but weaker')
|
||||
expect(rows[1].text()).toContain('Untiered but strong')
|
||||
})
|
||||
|
||||
it('explains why a high-scoring release is outside the profile and links an administrator to it', async () => {
|
||||
const wrapper = await render({
|
||||
...MIXED,
|
||||
profileActive: true,
|
||||
releases: [
|
||||
release({
|
||||
score: 73,
|
||||
indexerName: 'NZB.life',
|
||||
format: null,
|
||||
formats: [],
|
||||
profileMismatch: {
|
||||
tier: 2,
|
||||
tierName: 'EPUB fallback',
|
||||
failures: [{ code: 'formatUnknown', expected: ['epub'] }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
indexers: [status({ indexerName: 'NZB.life', seedsBack: false, delivery: 'usenet' })],
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('1 release was found, but automatic download will skip it')
|
||||
expect(wrapper.text()).toContain('Match score and profile rules are separate checks')
|
||||
expect(wrapper.text()).toContain('Closest tier: EPUB fallback')
|
||||
expect(wrapper.text()).toContain('NZB.life did not state a format; this tier requires EPUB')
|
||||
expect(wrapper.text()).not.toContain('add a tier with fewer conditions')
|
||||
|
||||
const link = wrapper.findAllComponents(RouterLinkStub).find((item) => item.text().includes('Review E-book profile'))
|
||||
expect(link?.props('to')).toEqual({
|
||||
name: 'settings-admin-requests',
|
||||
query: { tab: 'automation' },
|
||||
hash: '#release-profile-ebook',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the exact mismatch visible without linking a viewer who cannot manage profiles', async () => {
|
||||
hasPermission.mockImplementation((permission: string) => permission !== Permission.ManageAppSettings)
|
||||
const wrapper = await render({
|
||||
...MIXED,
|
||||
profileActive: true,
|
||||
releases: [
|
||||
release({
|
||||
profileMismatch: {
|
||||
tier: 0,
|
||||
tierName: 'English EPUB',
|
||||
failures: [
|
||||
{ code: 'format', expected: ['epub'], actual: ['pdf'] },
|
||||
{ code: 'languageUnknown', expected: ['en'] },
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
expect(wrapper.text()).toContain('Format is PDF; this tier requires EPUB')
|
||||
expect(wrapper.text()).toContain('did not state a language; this tier requires English')
|
||||
expect(wrapper.text()).not.toContain('Review E-book profile')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,6 +24,7 @@ function release(overrides: Partial<ReleaseCandidateItem> = {}): ReleaseCandidat
|
||||
score: 50,
|
||||
tier: null,
|
||||
tierName: null,
|
||||
profileMismatch: null,
|
||||
reasons: [],
|
||||
...overrides,
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ function release(overrides: Partial<ReleaseCandidateItem> = {}): ReleaseCandidat
|
||||
score: 50,
|
||||
tier: null,
|
||||
tierName: null,
|
||||
profileMismatch: null,
|
||||
reasons: [],
|
||||
...overrides,
|
||||
}
|
||||
|
||||
@@ -24,10 +24,17 @@ import {
|
||||
Zap,
|
||||
} from '@lucide/vue'
|
||||
import { Permission, releaseInspectionBlocksGrab } from '@bookorbit/types'
|
||||
import type { DownloadDelivery, IndexerSearchFailure, ReleaseCandidateItem, ReleaseFileInspection, ReleaseSearchCriteria } from '@bookorbit/types'
|
||||
import type {
|
||||
DownloadDelivery,
|
||||
IndexerSearchFailure,
|
||||
ReleaseCandidateItem,
|
||||
ReleaseFileInspection,
|
||||
ReleaseProfileMismatchFailure,
|
||||
ReleaseSearchCriteria,
|
||||
} from '@bookorbit/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatColorVar } from '@/features/book/lib/format-colors'
|
||||
import { formatDate, formatLanguageName } from '@/i18n/formatters'
|
||||
import { formatDate, formatLanguageName, formatList } from '@/i18n/formatters'
|
||||
import { formatBytes } from '@/lib/formatting'
|
||||
import GrabReleaseDialog from './GrabReleaseDialog.vue'
|
||||
import ReleaseFileInspectionPanel from './ReleaseFileInspectionPanel.vue'
|
||||
@@ -221,6 +228,11 @@ const noSourcesHint = computed(() => {
|
||||
const nothingMatchedProfile = computed(
|
||||
() => profileActive.value && releases.value.length > 0 && releases.value.every((release) => release.tier === null),
|
||||
)
|
||||
const profileSettingsLink = computed(() => ({
|
||||
name: 'settings-admin-requests',
|
||||
query: { tab: 'automation' },
|
||||
hash: `#release-profile-${criteria.value?.mediaKind ?? 'ebook'}`,
|
||||
}))
|
||||
/**
|
||||
* A search that ran and found nothing is stated by the panel itself, which is also where the next
|
||||
* key to try lives. Repeating it below the list only put the explanation further from the fix.
|
||||
@@ -519,6 +531,54 @@ function reasonPoints(points: number): string {
|
||||
return points > 0 ? `+${points}` : String(points)
|
||||
}
|
||||
|
||||
function profileFailureText(release: ReleaseCandidateItem, failure: ReleaseProfileMismatchFailure): string {
|
||||
const key = `bookRequests.releases.profileMismatch.failures.${failure.code}`
|
||||
switch (failure.code) {
|
||||
case 'formatUnknown':
|
||||
return t(key, { source: release.indexerName, expected: formatList(failure.expected.map((format) => format.toUpperCase())) })
|
||||
case 'format':
|
||||
return t(key, {
|
||||
actual: formatList(failure.actual.map((format) => format.toUpperCase())),
|
||||
expected: formatList(failure.expected.map((format) => format.toUpperCase())),
|
||||
})
|
||||
case 'fileLayoutUnknown':
|
||||
return t(key, { source: release.indexerName, expected: t(`bookRequests.releases.fileLayout.${failure.expected}`) })
|
||||
case 'fileLayout':
|
||||
return t(key, {
|
||||
actual: t(`bookRequests.releases.fileLayout.${failure.actual}`),
|
||||
expected: t(`bookRequests.releases.fileLayout.${failure.expected}`),
|
||||
})
|
||||
case 'channels':
|
||||
return t(key, {
|
||||
actual: t(`bookRequests.releases.channels.${failure.actual === 1 ? 'mono' : 'stereo'}`),
|
||||
expected: t(`bookRequests.releases.channels.${failure.expected === 1 ? 'mono' : 'stereo'}`),
|
||||
})
|
||||
case 'bitrate':
|
||||
case 'seeders':
|
||||
return t(key, { actual: failure.actual, expected: failure.expected })
|
||||
case 'languageUnknown':
|
||||
return t(key, { source: release.indexerName, expected: formatList(failure.expected.map(formatLanguageName)) })
|
||||
case 'language':
|
||||
return t(key, { actual: formatLanguageName(failure.actual), expected: formatList(failure.expected.map(formatLanguageName)) })
|
||||
case 'sizeUnknown':
|
||||
return t(key, { source: release.indexerName, expected: formatBytes(failure.expected) })
|
||||
case 'size':
|
||||
return t(key, { actual: formatBytes(failure.actual), expected: formatBytes(failure.expected) })
|
||||
case 'source':
|
||||
case 'freeleech':
|
||||
case 'vipOnly':
|
||||
return t(key)
|
||||
}
|
||||
}
|
||||
|
||||
function profileMismatchText(release: ReleaseCandidateItem): string | null {
|
||||
if (!release.profileMismatch) return null
|
||||
return t('bookRequests.releases.profileMismatch.row', {
|
||||
tier: release.profileMismatch.tierName,
|
||||
reasons: release.profileMismatch.failures.map((failure) => profileFailureText(release, failure)).join(' '),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* The row's facts as one ordered run, so the template can separate them instead of relying on a
|
||||
* gap between six spans of identical muted text to say where one fact ends and the next begins.
|
||||
@@ -1037,14 +1097,31 @@ function showEverySource(): void {
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<p
|
||||
<div
|
||||
v-if="nothingMatchedProfile"
|
||||
role="status"
|
||||
class="mt-3 flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10 p-3 text-xs text-foreground"
|
||||
>
|
||||
<TriangleAlert :size="14" class="mt-px shrink-0 text-warning" aria-hidden="true" />
|
||||
<span class="settings-prose">{{ t('bookRequests.releases.noneMatchedProfile') }}</span>
|
||||
</p>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p>
|
||||
{{
|
||||
t('bookRequests.releases.profileMismatch.summary', {
|
||||
count: releases.length,
|
||||
medium: t(`bookRequests.mediaKind.${criteria?.mediaKind ?? 'ebook'}`),
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<RouterLink
|
||||
v-if="canFixSources"
|
||||
:to="profileSettingsLink"
|
||||
class="mt-1.5 inline-flex items-center gap-1 font-medium text-foreground underline underline-offset-2 hover:text-primary focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
||||
>
|
||||
{{ t('bookRequests.releases.profileMismatch.review', { medium: t(`bookRequests.mediaKind.${criteria?.mediaKind ?? 'ebook'}`) }) }}
|
||||
<ArrowUpRight :size="12" aria-hidden="true" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!--
|
||||
Sort is one choice among a few, so it reads as a segmented control; the facets are
|
||||
@@ -1247,6 +1324,11 @@ function showEverySource(): void {
|
||||
{{ audioParts(release).join(' · ') }}
|
||||
</p>
|
||||
|
||||
<p v-if="profileMismatchText(release)" class="mt-1 flex items-start gap-1.5 text-xs text-warning">
|
||||
<TriangleAlert :size="12" class="mt-px shrink-0" aria-hidden="true" />
|
||||
<span>{{ profileMismatchText(release) }}</span>
|
||||
</p>
|
||||
|
||||
<span
|
||||
v-for="penalty in penalties(release)"
|
||||
:key="penalty.code"
|
||||
|
||||
@@ -11,6 +11,9 @@ import type {
|
||||
IndexerTestResult,
|
||||
PluginInspection,
|
||||
PluginInstallResult,
|
||||
PluginUpdateListResult,
|
||||
PluginUpdateReview,
|
||||
PluginUpdateStatus,
|
||||
UpdateIndexerPayload,
|
||||
} from '@bookorbit/types'
|
||||
|
||||
@@ -46,6 +49,7 @@ export function useIndexers() {
|
||||
const adapters = ref<IndexerAdapterDescriptor[]>([])
|
||||
/** Plugins that would not load. Shown, because one that silently vanishes cannot be debugged. */
|
||||
const pluginFailures = ref<IndexerPluginFailure[]>([])
|
||||
const pluginUpdates = ref<PluginUpdateStatus[]>([])
|
||||
/** False when `BOOK_REQUEST_ENCRYPTION_KEY` is unset, which is what refuses a saved credential. */
|
||||
const encryptionConfigured = ref(true)
|
||||
const loading = ref(false)
|
||||
@@ -183,10 +187,62 @@ export function useIndexers() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPluginUpdates(refresh = false): Promise<boolean> {
|
||||
try {
|
||||
const res = await api(`${BASE_PATH}/plugins/updates${refresh ? '/check' : ''}`, { method: refresh ? 'POST' : 'GET' })
|
||||
if (!res.ok) return false
|
||||
const result = (await res.json()) as Partial<PluginUpdateListResult>
|
||||
if (!Array.isArray(result.updates)) return false
|
||||
pluginUpdates.value = result.updates
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectPluginUpdate(type: string): Promise<{ inspection: PluginUpdateReview | null; error: string | null }> {
|
||||
try {
|
||||
const res = await api(`${BASE_PATH}/plugins/${encodeURIComponent(type)}/update/inspect`, { method: 'POST' })
|
||||
if (!res.ok) return { inspection: null, error: (await toFailure(res)).message }
|
||||
return { inspection: (await res.json()) as PluginUpdateReview, error: null }
|
||||
} catch {
|
||||
return { inspection: null, error: null }
|
||||
}
|
||||
}
|
||||
|
||||
async function installPluginUpdate(type: string, sha256: string): Promise<{ status: PluginUpdateStatus | null; error: string | null }> {
|
||||
try {
|
||||
const res = await api(`${BASE_PATH}/plugins/${encodeURIComponent(type)}/update`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sha256 }),
|
||||
})
|
||||
if (!res.ok) return { status: null, error: (await toFailure(res)).message }
|
||||
return { status: (await res.json()) as PluginUpdateStatus, error: null }
|
||||
} catch {
|
||||
return { status: null, error: null }
|
||||
}
|
||||
}
|
||||
|
||||
async function setPluginAutomaticUpdate(type: string, enabled: boolean): Promise<{ status: PluginUpdateStatus | null; error: string | null }> {
|
||||
try {
|
||||
const res = await api(`${BASE_PATH}/plugins/${encodeURIComponent(type)}/auto-update`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled }),
|
||||
})
|
||||
if (!res.ok) return { status: null, error: (await toFailure(res)).message }
|
||||
return { status: (await res.json()) as PluginUpdateStatus, error: null }
|
||||
} catch {
|
||||
return { status: null, error: null }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
indexers,
|
||||
adapters,
|
||||
pluginFailures,
|
||||
pluginUpdates,
|
||||
adapterFor,
|
||||
encryptionConfigured,
|
||||
loading,
|
||||
@@ -199,5 +255,9 @@ export function useIndexers() {
|
||||
inspectPlugin,
|
||||
installPlugin,
|
||||
removePlugin,
|
||||
fetchPluginUpdates,
|
||||
inspectPluginUpdate,
|
||||
installPluginUpdate,
|
||||
setPluginAutomaticUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onScopeDispose, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, onScopeDispose, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ChevronRight, Loader2 } from '@lucide/vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
@@ -28,7 +28,7 @@ const { t } = useI18n()
|
||||
|
||||
const { settings, loading, loadFailed, fetchSettings, save } = useRequestAutomation()
|
||||
const { libraries, fetchLibraries } = useLibraries()
|
||||
const { indexers, fetchIndexers } = useIndexers()
|
||||
const { indexers, adapters, fetchIndexers } = useIndexers()
|
||||
/**
|
||||
* Auto-grab switched on with nothing to search is the one setting on this tab that reports success
|
||||
* and does nothing. Every request it touches is handed straight back, and the summary above it
|
||||
@@ -89,7 +89,13 @@ watch(settings, (next) => {
|
||||
onMounted(fetchSettings)
|
||||
onMounted(fetchLibraries)
|
||||
onMounted(fetchSourceStatus)
|
||||
onMounted(() => fetchIndexers({ withAdapters: false }))
|
||||
onMounted(fetchIndexers)
|
||||
|
||||
watch(loading, async (isLoading) => {
|
||||
if (isLoading || !/^#release-profile-(ebook|audiobook|comic)$/.test(window.location.hash)) return
|
||||
await nextTick()
|
||||
document.getElementById(window.location.hash.slice(1))?.scrollIntoView({ block: 'start' })
|
||||
})
|
||||
|
||||
/**
|
||||
* What this server will actually do to an approved request, in one sentence. Assembled from the
|
||||
@@ -422,6 +428,47 @@ function handleVerificationChange(enabled: boolean) {
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<fieldset>
|
||||
<legend class="settings-card-header w-full">
|
||||
<span class="settings-card-title">{{ t('settings.system.requests.profiles.title') }}</span>
|
||||
</legend>
|
||||
|
||||
<div class="border-t border-border px-4 py-3.5 md:px-5 md:py-4">
|
||||
<p class="text-xs font-medium text-foreground">
|
||||
{{ t('settings.system.requests.profiles.effectiveRule', { score: minScore }) }}
|
||||
</p>
|
||||
<p class="mt-1 settings-hint">{{ t('settings.system.requests.profiles.summary') }}</p>
|
||||
|
||||
<details class="group mt-2">
|
||||
<summary
|
||||
class="flex cursor-pointer list-none items-center gap-1.5 text-xs font-medium text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
>
|
||||
<ChevronRight class="size-3.5 text-muted-foreground transition-transform group-open:rotate-90" aria-hidden="true" />
|
||||
{{ t('settings.system.requests.profiles.howProfiles') }}
|
||||
</summary>
|
||||
<p class="mt-2 settings-hint settings-prose">{{ t('settings.system.requests.profiles.hint') }}</p>
|
||||
</details>
|
||||
|
||||
<div class="mt-3 grid gap-4">
|
||||
<div v-for="mediaKind in MEDIA_KINDS" :id="`release-profile-${mediaKind}`" :key="mediaKind" class="scroll-mt-6">
|
||||
<h3 class="text-xs font-medium text-foreground">{{ t(`bookRequests.mediaKind.${mediaKind}`) }}</h3>
|
||||
<div class="mt-1.5">
|
||||
<ReleaseProfileEditor
|
||||
:key="`${mediaKind}-${editorRevision}`"
|
||||
:media-kind="mediaKind"
|
||||
:tiers="settings.profiles[mediaKind]"
|
||||
:indexers="indexers"
|
||||
:adapters="adapters"
|
||||
@update="handleProfileChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="settings-card-header">
|
||||
<span class="settings-card-title">{{ t('settings.system.requests.automation.groupImports') }}</span>
|
||||
@@ -485,33 +532,6 @@ function handleVerificationChange(enabled: boolean) {
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<fieldset>
|
||||
<legend class="settings-card-header w-full">
|
||||
<span class="settings-card-title">{{ t('settings.system.requests.profiles.title') }}</span>
|
||||
</legend>
|
||||
|
||||
<div class="border-t border-border px-4 py-3.5 md:px-5 md:py-4">
|
||||
<p class="settings-hint settings-prose">{{ t('settings.system.requests.profiles.hint') }}</p>
|
||||
|
||||
<div class="mt-3 grid gap-4">
|
||||
<div v-for="mediaKind in MEDIA_KINDS" :key="mediaKind">
|
||||
<h3 class="text-xs font-medium text-foreground">{{ t(`bookRequests.mediaKind.${mediaKind}`) }}</h3>
|
||||
<div class="mt-1.5">
|
||||
<ReleaseProfileEditor
|
||||
:key="`${mediaKind}-${editorRevision}`"
|
||||
:media-kind="mediaKind"
|
||||
:tiers="settings.profiles[mediaKind]"
|
||||
:indexers="indexers"
|
||||
@update="handleProfileChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<fieldset>
|
||||
<legend class="settings-card-header w-full">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
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 { Loader2, Plug, Plus, RefreshCw, Server as ServerIcon, Trash2, TriangleAlert, Upload } from '@lucide/vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { BOOK_REQUEST_MEDIA_KINDS, INDEXER_ADAPTER_TYPES } from '@bookorbit/types'
|
||||
import type { IndexerAdapterDescriptor, IndexerItem, IndexerSettingsField } from '@bookorbit/types'
|
||||
@@ -13,6 +13,7 @@ import ConnectionHealth from './ConnectionHealth.vue'
|
||||
import PluginDirectoryLink from './components/PluginDirectoryLink.vue'
|
||||
import RequestSourceRow from './components/RequestSourceRow.vue'
|
||||
import PluginVersionBadge from './components/PluginVersionBadge.vue'
|
||||
import PluginUpdateControls from './components/PluginUpdateControls.vue'
|
||||
import RequestSourcesEmpty from './components/RequestSourcesEmpty.vue'
|
||||
import SettingsEditorSheet from './components/SettingsEditorSheet.vue'
|
||||
import SettingsField from './components/SettingsField.vue'
|
||||
@@ -34,6 +35,7 @@ const {
|
||||
indexers,
|
||||
adapters,
|
||||
pluginFailures,
|
||||
pluginUpdates,
|
||||
adapterFor,
|
||||
encryptionConfigured,
|
||||
loading,
|
||||
@@ -46,6 +48,10 @@ const {
|
||||
inspectPlugin,
|
||||
installPlugin,
|
||||
removePlugin,
|
||||
fetchPluginUpdates,
|
||||
inspectPluginUpdate,
|
||||
installPluginUpdate,
|
||||
setPluginAutomaticUpdate,
|
||||
} = useIndexers()
|
||||
|
||||
watch(encryptionConfigured, (configured) => emit('encryptionState', configured))
|
||||
@@ -54,6 +60,8 @@ const { isSuperuser } = usePermissions()
|
||||
|
||||
/** Installing a plugin runs its code in the server process, so only an administrator may. */
|
||||
const canInstallPlugins = isSuperuser
|
||||
const hasPluginUpdateChannels = computed(() => adapters.value.some((adapter) => !adapter.builtIn && adapter.updateable))
|
||||
const reviewedPublishedUpdate = computed(() => (pluginReview.value && 'verified' in pluginReview.value ? pluginReview.value : null))
|
||||
|
||||
/** The row whose test is in flight, and the row whose enabled flag is; both go inert on their own. */
|
||||
const testingId = ref<number | null>(null)
|
||||
@@ -105,6 +113,7 @@ const {
|
||||
pluginReview,
|
||||
pluginBusy,
|
||||
pluginRestartPending,
|
||||
checkingPluginUpdates,
|
||||
removingPlugin,
|
||||
pluginPendingRemoval,
|
||||
pluginRows,
|
||||
@@ -116,12 +125,16 @@ const {
|
||||
pluginUseCount,
|
||||
pluginUsage,
|
||||
pendingRemovalUsage,
|
||||
updateStatusFor,
|
||||
askRemovePlugin,
|
||||
askRemovePluginType,
|
||||
cancelRemovePlugin,
|
||||
confirmRemovePlugin,
|
||||
handleRowPluginUpdate,
|
||||
handleRowPluginRemove,
|
||||
checkPluginUpdates,
|
||||
reviewPublishedUpdate,
|
||||
setAutomaticUpdate,
|
||||
startPluginInstall,
|
||||
startPluginUpdate,
|
||||
handlePluginChosen,
|
||||
@@ -132,17 +145,27 @@ const {
|
||||
indexers,
|
||||
adapters,
|
||||
pluginFailures,
|
||||
pluginUpdates,
|
||||
adapterFor,
|
||||
inspectPlugin,
|
||||
installPlugin,
|
||||
removePlugin,
|
||||
fetchPluginUpdates,
|
||||
inspectPluginUpdate,
|
||||
installPluginUpdate,
|
||||
setPluginAutomaticUpdate,
|
||||
fetchIndexers,
|
||||
editingType,
|
||||
cancelEdit,
|
||||
startCreateFor,
|
||||
})
|
||||
|
||||
onMounted(fetchIndexers)
|
||||
async function loadPanel() {
|
||||
await fetchIndexers()
|
||||
if (canInstallPlugins.value) await fetchPluginUpdates(false)
|
||||
}
|
||||
|
||||
onMounted(loadPanel)
|
||||
|
||||
/**
|
||||
* A built-in adapter has translated copy keyed on its type; a plugin can only supply untranslated
|
||||
@@ -297,10 +320,23 @@ function handleTestCurrent() {
|
||||
<h2 id="request-plugins-heading" class="settings-group-label mb-0">{{ t('settings.system.requests.indexers.plugins.title') }}</h2>
|
||||
<!-- Only while the group has rows. With none, the one call to action lives in the slot
|
||||
below rather than twice on the same line of the page. -->
|
||||
<Button v-if="canInstallPlugins && pluginRows.length" size="sm" variant="outline" :disabled="pluginBusy" @click="startPluginInstall">
|
||||
<Upload :size="14" aria-hidden="true" />
|
||||
{{ t('settings.system.requests.indexers.plugins.install') }}
|
||||
</Button>
|
||||
<div v-if="canInstallPlugins && pluginRows.length" class="flex items-center gap-2">
|
||||
<Button
|
||||
v-if="hasPluginUpdateChannels"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
:disabled="checkingPluginUpdates || pluginBusy"
|
||||
@click="checkPluginUpdates"
|
||||
>
|
||||
<Loader2 v-if="checkingPluginUpdates" class="animate-spin" aria-hidden="true" />
|
||||
<RefreshCw v-else :size="14" aria-hidden="true" />
|
||||
{{ t('settings.system.requests.indexers.plugins.checkUpdates') }}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" :disabled="pluginBusy" @click="startPluginInstall">
|
||||
<Upload :size="14" aria-hidden="true" />
|
||||
{{ t('settings.system.requests.indexers.plugins.install') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="settings-hint settings-prose mt-1.5">
|
||||
@@ -369,6 +405,13 @@ function handleTestCurrent() {
|
||||
</span>
|
||||
</div>
|
||||
<p class="mt-1 font-mono text-xs break-all text-muted-foreground">{{ row.adapter.type }}</p>
|
||||
<PluginUpdateControls
|
||||
:updateable="row.adapter.updateable === true"
|
||||
:status="updateStatusFor(row.adapter.type)"
|
||||
:busy="pluginBusy"
|
||||
@review="reviewPublishedUpdate(row.adapter.type)"
|
||||
@automatic="setAutomaticUpdate(row.adapter.type, $event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -425,6 +468,8 @@ function handleTestCurrent() {
|
||||
:busy="togglingId === row.indexer.id"
|
||||
:available="isAvailable(row.indexer.adapterType)"
|
||||
:plugin-version="adapterFor(row.indexer.adapterType)?.version"
|
||||
:plugin-updateable="adapterFor(row.indexer.adapterType)?.updateable"
|
||||
:plugin-update-status="updateStatusFor(row.indexer.adapterType)"
|
||||
:manage-plugin="canInstallPlugins"
|
||||
:plugin-busy="pluginBusy || removingPlugin === row.indexer.adapterType"
|
||||
@test="handleTest(row.indexer)"
|
||||
@@ -432,6 +477,8 @@ function handleTestCurrent() {
|
||||
@toggle="handleToggleEnabled(row.indexer, $event)"
|
||||
@update-plugin="handleRowPluginUpdate(row.indexer.adapterType)"
|
||||
@remove-plugin="handleRowPluginRemove(row.indexer.adapterType)"
|
||||
@review-plugin-update="reviewPublishedUpdate(row.indexer.adapterType)"
|
||||
@automatic-plugin-update="setAutomaticUpdate(row.indexer.adapterType, $event)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -889,6 +936,16 @@ function handleTestCurrent() {
|
||||
{{ pluginReview.version ?? t('settings.system.requests.indexers.plugins.versionUnknownValue') }}
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="reviewedPublishedUpdate">
|
||||
<dt class="settings-hint">{{ t('settings.system.requests.indexers.plugins.installedVersion') }}</dt>
|
||||
<dd class="text-foreground">
|
||||
{{ reviewedPublishedUpdate.currentVersion ?? t('settings.system.requests.indexers.plugins.versionUnknownValue') }}
|
||||
</dd>
|
||||
</div>
|
||||
<div v-if="pluginReview.update" class="sm:col-span-2">
|
||||
<dt class="settings-hint">{{ t('settings.system.requests.indexers.plugins.updateChannel') }}</dt>
|
||||
<dd class="font-mono text-xs break-all text-foreground">{{ pluginReview.update.manifestUrl }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="settings-hint">{{ t('settings.system.requests.indexers.plugins.media') }}</dt>
|
||||
<dd class="text-foreground">{{ pluginReview.mediaKinds.join(', ') }}</dd>
|
||||
@@ -908,6 +965,9 @@ function handleTestCurrent() {
|
||||
<p role="alert" class="settings-hint text-destructive">
|
||||
{{ t('settings.system.requests.indexers.plugins.trustWarning') }}
|
||||
</p>
|
||||
<p v-if="reviewedPublishedUpdate" role="status" class="settings-hint text-primary">
|
||||
{{ t('settings.system.requests.indexers.plugins.signatureVerified') }}
|
||||
</p>
|
||||
<p v-if="pluginReview.replaces" role="status" class="settings-hint text-primary">
|
||||
{{ t('settings.system.requests.indexers.plugins.replaces', { type: pluginReview.type }) }}
|
||||
</p>
|
||||
|
||||
@@ -26,12 +26,21 @@ async function mountPanel(loaded: BookRequestAutomationSettings = settings()) {
|
||||
if (url === '/api/v1/admin/request-indexers') {
|
||||
return response({
|
||||
indexers: [
|
||||
{ id: 11, name: 'Public books' },
|
||||
{ id: 12, name: 'Private books' },
|
||||
{ id: 11, name: 'Public books', adapterType: 'torznab' },
|
||||
{ id: 12, name: 'Private books', adapterType: 'newznab' },
|
||||
],
|
||||
encryptionConfigured: true,
|
||||
})
|
||||
}
|
||||
if (url === '/api/v1/admin/request-indexers/adapters') {
|
||||
return response({
|
||||
adapters: [
|
||||
{ type: 'torznab', delivery: 'torrent' },
|
||||
{ type: 'newznab', delivery: 'usenet' },
|
||||
],
|
||||
pluginFailures: [],
|
||||
})
|
||||
}
|
||||
if (url === '/api/v1/book-requests/source-status') return response({ configured: 2, enabled: 2 })
|
||||
return response(loaded)
|
||||
})
|
||||
@@ -83,10 +92,19 @@ describe('RequestAutomationPanel', () => {
|
||||
it('keeps the score floor out of the way until unattended grabbing is on', async () => {
|
||||
const wrapper = await mountPanel()
|
||||
|
||||
expect(wrapper.text()).not.toContain('Minimum release score')
|
||||
expect(wrapper.text()).not.toContain('Minimum score for automatic download')
|
||||
|
||||
const enabled = await mountPanel(settings({ autoGrabEnabled: true }))
|
||||
expect(enabled.text()).toContain('Minimum release score')
|
||||
expect(enabled.text()).toContain('Minimum score for automatic download')
|
||||
})
|
||||
|
||||
it('puts release profiles directly after automatic downloads and explains the combined gate', async () => {
|
||||
const wrapper = await mountPanel(settings({ autoGrabMinScore: 85 }))
|
||||
const cardTitles = wrapper.findAll('.settings-card-title').map((title) => title.text())
|
||||
|
||||
expect(cardTitles.slice(0, 2)).toEqual(['Automatic downloads', 'Release profiles'])
|
||||
expect(wrapper.text()).toContain('first matching tier, then highest score (minimum 85)')
|
||||
expect(wrapper.text()).toContain('Releases outside the profile remain available for manual selection')
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -125,6 +143,7 @@ describe('RequestAutomationPanel', () => {
|
||||
await slider.trigger('input')
|
||||
|
||||
expect(wrapper.text()).toContain('scores 95 or more')
|
||||
expect(wrapper.text()).toContain('minimum 95')
|
||||
expect(apiMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -294,10 +313,8 @@ describe('RequestAutomationPanel', () => {
|
||||
* those used to be its own PUT and its own "Saved" toast.
|
||||
*/
|
||||
describe('release profiles', () => {
|
||||
async function profilePanel() {
|
||||
const wrapper = await mountPanel(
|
||||
settings({ profiles: { ebook: [{ id: 'tier-1', name: 'Retail', conditions: {} }], audiobook: [], comic: [] } }),
|
||||
)
|
||||
async function profilePanel(conditions: BookRequestAutomationSettings['profiles']['ebook'][number]['conditions'] = {}) {
|
||||
const wrapper = await mountPanel(settings({ profiles: { ebook: [{ id: 'tier-1', name: 'Retail', conditions }], audiobook: [], comic: [] } }))
|
||||
apiMock.mockClear()
|
||||
apiMock.mockResolvedValue(response(settings()))
|
||||
return wrapper
|
||||
@@ -344,9 +361,9 @@ describe('RequestAutomationPanel', () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const wrapper = await profilePanel()
|
||||
const boxes = wrapper.findAll('input[type="number"]')
|
||||
const maxSize = wrapper.findAll('label').find((label) => label.text().includes('Max MB'))
|
||||
|
||||
await boxes[boxes.length - 1]!.setValue('700')
|
||||
await maxSize!.get('input').setValue('700')
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
|
||||
const profiles = lastBody().profiles as { ebook: { conditions: { maxSizeBytes: number } }[] }
|
||||
@@ -356,6 +373,65 @@ describe('RequestAutomationPanel', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('shows torrent conditions for an unrestricted tier', async () => {
|
||||
const wrapper = await profilePanel()
|
||||
const tier = wrapper.get('li')
|
||||
|
||||
expect(tier.text()).toContain('Torrent conditions')
|
||||
expect(tier.text()).toContain('Min seeders')
|
||||
expect(tier.text()).toContain('Freeleech')
|
||||
expect(tier.text()).toContain('Hide VIP only')
|
||||
})
|
||||
|
||||
it('places source selection after files and before capability-specific conditions', async () => {
|
||||
const wrapper = await profilePanel()
|
||||
const text = wrapper.get('li').text()
|
||||
|
||||
expect(text.indexOf('Files')).toBeLessThan(text.indexOf('Sources'))
|
||||
expect(text.indexOf('Sources')).toBeLessThan(text.indexOf('Torrent conditions'))
|
||||
})
|
||||
|
||||
it('keeps torrent conditions labelled when a tier mixes torrent and non-torrent sources', async () => {
|
||||
const wrapper = await profilePanel({ indexerIds: [11, 12] })
|
||||
|
||||
expect(wrapper.get('li').text()).toContain('Torrent conditions')
|
||||
})
|
||||
|
||||
it('hides torrent conditions from a Newznab-only tier while keeping its size ceiling', async () => {
|
||||
const wrapper = await profilePanel({ indexerIds: [12], maxSizeBytes: 700 * 1024 * 1024 })
|
||||
const tier = wrapper.get('li')
|
||||
|
||||
expect(tier.text()).not.toContain('Min seeders')
|
||||
expect(tier.text()).not.toContain('Freeleech')
|
||||
expect(tier.text()).not.toContain('Hide VIP only')
|
||||
expect(tier.text()).toContain('Max MB')
|
||||
expect(tier.text()).toContain('do not apply to the selected sources')
|
||||
})
|
||||
|
||||
it('exposes and removes incompatible saved torrent conditions from a Newznab-only tier', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const wrapper = await profilePanel({
|
||||
indexerIds: [12],
|
||||
minSeeders: 5,
|
||||
maxSizeBytes: 700 * 1024 * 1024,
|
||||
freeleechOnly: true,
|
||||
excludeVipOnly: true,
|
||||
})
|
||||
const tier = wrapper.get('li')
|
||||
|
||||
expect(tier.text()).toContain('still has saved torrent conditions')
|
||||
const removeConditions = tier.findAll('button').find((button) => button.text().includes('Remove torrent conditions'))
|
||||
await removeConditions!.trigger('click')
|
||||
await vi.advanceTimersByTimeAsync(1000)
|
||||
|
||||
const profiles = lastBody().profiles as { ebook: { conditions: Record<string, unknown> }[] }
|
||||
expect(profiles.ebook[0]!.conditions).toEqual({ indexerIds: [12], maxSizeBytes: 700 * 1024 * 1024 })
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Driven the way a person drives it, one pick at a time. The multi-selects this replaced passed
|
||||
* a `setValue([...])` that reached straight into `option.selected`, which is the one path a real
|
||||
|
||||
@@ -67,6 +67,7 @@ const PLUGIN = descriptor({
|
||||
label: 'Demo Tracker',
|
||||
builtIn: false,
|
||||
version: '2.4.1',
|
||||
updateable: true,
|
||||
requiresCredential: true,
|
||||
credentialKind: 'sessionId',
|
||||
baseUrlHint: "The tracker's own address.",
|
||||
@@ -130,11 +131,22 @@ function response(body: unknown, ok = true): Response {
|
||||
}
|
||||
|
||||
async function mountPanel(
|
||||
options: { indexers?: IndexerItem[]; adapters?: IndexerAdapterDescriptor[]; pluginFailures?: Array<{ directory: string; reason: string }> } = {},
|
||||
options: {
|
||||
indexers?: IndexerItem[]
|
||||
adapters?: IndexerAdapterDescriptor[]
|
||||
pluginFailures?: Array<{ directory: string; reason: string }>
|
||||
pluginUpdates?: unknown[]
|
||||
} = {},
|
||||
) {
|
||||
const { indexers = [], adapters = [descriptor(), OPEN_LIBRARY], pluginFailures = [] } = options
|
||||
const { indexers = [], adapters = [descriptor(), OPEN_LIBRARY], pluginFailures = [], pluginUpdates = [] } = options
|
||||
apiMock.mockImplementation((url: string) =>
|
||||
Promise.resolve(url.endsWith('/adapters') ? response({ adapters, pluginFailures }) : response({ indexers, encryptionConfigured: true })),
|
||||
Promise.resolve(
|
||||
url.endsWith('/adapters')
|
||||
? response({ adapters, pluginFailures })
|
||||
: url.endsWith('/plugins/updates')
|
||||
? response({ updates: pluginUpdates })
|
||||
: response({ indexers, encryptionConfigured: true }),
|
||||
),
|
||||
)
|
||||
const wrapper = mount(RequestIndexersPanel, { global: { stubs: DROPDOWN_STUBS } })
|
||||
mounted = wrapper
|
||||
@@ -349,6 +361,65 @@ describe('RequestIndexersPanel', () => {
|
||||
expect(labels).not.toContain('Save')
|
||||
})
|
||||
|
||||
it('shows, verifies and installs a published signed update', async () => {
|
||||
const updates = [{ type: 'demo-tracker', currentVersion: '2.4.1', latestVersion: '2.5.0', state: 'available', autoUpdate: false }]
|
||||
const review = {
|
||||
...INSPECTION,
|
||||
type: 'demo-tracker',
|
||||
label: 'Demo Tracker',
|
||||
version: '2.5.0',
|
||||
currentVersion: '2.4.1',
|
||||
sha256: 'a'.repeat(64),
|
||||
verified: true,
|
||||
replaces: true,
|
||||
}
|
||||
const usingPlugin = indexer({ adapterType: 'demo-tracker', name: 'A tracker' })
|
||||
apiMock.mockImplementation((url: string, init?: RequestInit) => {
|
||||
if (url.endsWith('/plugins/demo-tracker/update/inspect')) return Promise.resolve(response(review))
|
||||
if (url.endsWith('/plugins/demo-tracker/update') && init?.method === 'POST') {
|
||||
return Promise.resolve(response({ ...updates[0], currentVersion: '2.5.0', state: 'current' }))
|
||||
}
|
||||
if (url.endsWith('/plugins/updates')) return Promise.resolve(response({ updates }))
|
||||
if (url.endsWith('/adapters')) return Promise.resolve(response({ adapters: [descriptor(), PLUGIN], pluginFailures: [] }))
|
||||
return Promise.resolve(response({ indexers: [usingPlugin], encryptionConfigured: true }))
|
||||
})
|
||||
const wrapper = mount(RequestIndexersPanel, { global: { stubs: DROPDOWN_STUBS } })
|
||||
mounted = wrapper
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Version 2.5.0 available')
|
||||
await clickInPanel(wrapper, 'Review update')
|
||||
expect(sheet().textContent).toContain("matches the publisher's Ed25519 signature")
|
||||
|
||||
clickInSheet('Replace plugin')
|
||||
await flushPromises()
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith(
|
||||
`${PATH}/plugins/demo-tracker/update`,
|
||||
expect.objectContaining({ method: 'POST', body: JSON.stringify({ sha256: 'a'.repeat(64) }) }),
|
||||
)
|
||||
expect(toastMock.success).toHaveBeenCalledWith('Updated Demo Tracker to 2.5.0.')
|
||||
})
|
||||
|
||||
it('requires an explicit per-plugin opt-in for automatic signed updates', async () => {
|
||||
const status = { type: 'demo-tracker', currentVersion: '2.4.1', latestVersion: '2.5.0', state: 'available', autoUpdate: false }
|
||||
const usingPlugin = indexer({ adapterType: 'demo-tracker', name: 'A tracker' })
|
||||
const wrapper = await mountPanel({ indexers: [usingPlugin], adapters: [descriptor(), PLUGIN], pluginUpdates: [status] })
|
||||
apiMock.mockClear()
|
||||
apiMock.mockResolvedValue(response({ ...status, autoUpdate: true }))
|
||||
|
||||
const automatic = wrapper.find('[aria-label="Install signed updates automatically"]')
|
||||
expect(automatic.attributes('aria-checked')).toBe('false')
|
||||
await automatic.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith(
|
||||
`${PATH}/plugins/demo-tracker/auto-update`,
|
||||
expect.objectContaining({ method: 'PUT', body: JSON.stringify({ enabled: true }) }),
|
||||
)
|
||||
expect(automatic.attributes('aria-checked')).toBe('true')
|
||||
})
|
||||
|
||||
/**
|
||||
* A plugin is loaded into the running server as it is written, so the usual install asks for
|
||||
* nothing further. Telling every operator to restart trains them to ignore the one time it matters.
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { Download, Loader2 } from '@lucide/vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import type { PluginUpdateStatus } from '@bookorbit/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
|
||||
defineProps<{
|
||||
updateable: boolean
|
||||
status?: PluginUpdateStatus
|
||||
busy?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ review: []; automatic: [enabled: boolean] }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
function handleReview() {
|
||||
emit('review')
|
||||
}
|
||||
|
||||
function handleAutomatic(enabled: boolean) {
|
||||
emit('automatic', enabled)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-x-3 gap-y-2">
|
||||
<span v-if="status" role="status" class="rounded-full bg-muted px-2 py-0.5 text-[11px] font-medium text-muted-foreground">
|
||||
{{ t(`settings.system.requests.indexers.plugins.updateStates.${status.state}`, { version: status.latestVersion ?? '' }) }}
|
||||
</span>
|
||||
|
||||
<Button v-if="status?.state === 'available'" size="sm" variant="outline" :disabled="busy" @click="handleReview">
|
||||
<Loader2 v-if="busy" class="animate-spin" aria-hidden="true" />
|
||||
<Download v-else :size="13" aria-hidden="true" />
|
||||
{{ t('settings.system.requests.indexers.plugins.reviewUpdate') }}
|
||||
</Button>
|
||||
|
||||
<label v-if="updateable" class="flex items-center gap-2 text-xs text-foreground">
|
||||
<ToggleSwitch
|
||||
:model-value="status?.autoUpdate ?? false"
|
||||
:disabled="busy"
|
||||
:aria-label="t('settings.system.requests.indexers.plugins.autoUpdate')"
|
||||
@update:model-value="handleAutomatic"
|
||||
/>
|
||||
{{ t('settings.system.requests.indexers.plugins.autoUpdate') }}
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
@@ -10,7 +10,14 @@ import {
|
||||
MAX_RELEASE_TIERS,
|
||||
REQUEST_LANGUAGE_CODES,
|
||||
} from '@bookorbit/types'
|
||||
import type { BookRequestMediaKind, IndexerItem, ReleaseFileLayout, ReleaseTier, ReleaseTierConditions } from '@bookorbit/types'
|
||||
import type {
|
||||
BookRequestMediaKind,
|
||||
IndexerAdapterDescriptor,
|
||||
IndexerItem,
|
||||
ReleaseFileLayout,
|
||||
ReleaseTier,
|
||||
ReleaseTierConditions,
|
||||
} from '@bookorbit/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import TokenSelect from '@/components/ui/TokenSelect.vue'
|
||||
import { formatLanguageName } from '@/i18n/formatters'
|
||||
@@ -22,7 +29,8 @@ import { formatLanguageName } from '@/i18n/formatters'
|
||||
const props = defineProps<{
|
||||
mediaKind: BookRequestMediaKind
|
||||
tiers: ReleaseTier[]
|
||||
indexers: readonly Pick<IndexerItem, 'id' | 'name'>[]
|
||||
indexers: readonly Pick<IndexerItem, 'id' | 'name' | 'adapterType'>[]
|
||||
adapters: readonly Pick<IndexerAdapterDescriptor, 'type' | 'delivery'>[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ update: [mediaKind: BookRequestMediaKind, tiers: ReleaseTier[]] }>()
|
||||
@@ -80,6 +88,7 @@ const languageOptions = computed(() =>
|
||||
),
|
||||
)
|
||||
const indexerOptions = computed(() => props.indexers.map((indexer) => ({ value: String(indexer.id), label: indexer.name })))
|
||||
const adapterDelivery = computed(() => new Map(props.adapters.map((adapter) => [adapter.type, adapter.delivery])))
|
||||
|
||||
/** One hint for both pickers, so each field points at the same sentence rather than repeating it. */
|
||||
const multiSelectHintId = `release-profile-multi-select-hint-${props.mediaKind}`
|
||||
@@ -206,6 +215,39 @@ function handleExcludeVip(index: number, event: Event) {
|
||||
patch(index, { excludeVipOnly: (event.target as HTMLInputElement).checked })
|
||||
}
|
||||
|
||||
/**
|
||||
* Only hide torrent controls when every explicitly selected source is known to be non-torrent.
|
||||
* An unrestricted tier or a missing adapter can still produce torrent releases, so keeping the
|
||||
* controls visible is the safe and honest fallback.
|
||||
*/
|
||||
function usesOnlyNonTorrentSources(tier: ReleaseTier): boolean {
|
||||
const selected = tier.conditions.indexerIds
|
||||
if (!selected || selected.length === 0) return false
|
||||
|
||||
return selected.every((id) => {
|
||||
const indexer = props.indexers.find((candidate) => candidate.id === id)
|
||||
return (
|
||||
indexer !== undefined &&
|
||||
adapterDelivery.value.get(indexer.adapterType) !== undefined &&
|
||||
adapterDelivery.value.get(indexer.adapterType) !== 'torrent'
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function hasTorrentConditions(tier: ReleaseTier): boolean {
|
||||
return tier.conditions.minSeeders !== undefined || tier.conditions.freeleechOnly === true || tier.conditions.excludeVipOnly === true
|
||||
}
|
||||
|
||||
function clearTorrentConditions(index: number) {
|
||||
const next = clone(tiers.value)
|
||||
const tier = next[index]
|
||||
if (!tier) return
|
||||
delete tier.conditions.minSeeders
|
||||
delete tier.conditions.freeleechOnly
|
||||
delete tier.conditions.excludeVipOnly
|
||||
commit(next)
|
||||
}
|
||||
|
||||
function hasFormat(index: number, format: string): boolean {
|
||||
return (tiers.value[index]?.conditions.formats ?? []).includes(format)
|
||||
}
|
||||
@@ -261,124 +303,45 @@ function hasFormat(index: number, format: string): boolean {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-2.5 flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
{{ t('settings.system.requests.profiles.formats') }}
|
||||
</span>
|
||||
<button
|
||||
v-for="format in formats"
|
||||
:key="format"
|
||||
type="button"
|
||||
class="rounded-full border px-2 py-0.5 text-xs uppercase transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none"
|
||||
:class="
|
||||
hasFormat(index, format)
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border text-muted-foreground hover:border-ring hover:text-foreground'
|
||||
"
|
||||
:aria-pressed="hasFormat(index, format)"
|
||||
@click="toggleFormat(index, format)"
|
||||
>
|
||||
{{ format }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2.5 grid gap-2.5">
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<div class="flex flex-wrap items-center gap-1.5">
|
||||
<span class="text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
{{ t('settings.system.requests.profiles.formats') }}
|
||||
</span>
|
||||
<button
|
||||
v-for="format in formats"
|
||||
:key="format"
|
||||
type="button"
|
||||
class="rounded-full border px-2 py-0.5 text-xs uppercase transition-colors focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none motion-reduce:transition-none"
|
||||
:class="
|
||||
hasFormat(index, format)
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border text-muted-foreground hover:border-ring hover:text-foreground'
|
||||
"
|
||||
:aria-pressed="hasFormat(index, format)"
|
||||
@click="toggleFormat(index, format)"
|
||||
>
|
||||
{{ format }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{{ t('settings.system.requests.profiles.files') }}
|
||||
<select
|
||||
:value="tier.conditions.fileLayout ?? ''"
|
||||
class="rounded-md border border-input bg-background px-1.5 py-1 text-xs text-foreground"
|
||||
@change="handleLayout(index, $event)"
|
||||
>
|
||||
<option value="">{{ t('settings.system.requests.profiles.anyLayout') }}</option>
|
||||
<option v-for="layout in LAYOUTS" :key="layout" :value="layout">
|
||||
{{ t(`bookRequests.releases.fileLayout.${layout}`) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label v-if="isAudio" class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{{ t('settings.system.requests.profiles.minBitrate') }}
|
||||
<input
|
||||
:value="tier.conditions.minBitrateKbps ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
step="16"
|
||||
class="w-16 rounded-md border border-input bg-background px-1.5 py-1 text-xs tabular-nums text-foreground"
|
||||
@change="handleNumber(index, 'minBitrateKbps', $event)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label v-if="isAudio" class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{{ t('settings.system.requests.profiles.channels') }}
|
||||
<select
|
||||
:value="tier.conditions.channels ?? ''"
|
||||
class="rounded-md border border-input bg-background px-1.5 py-1 text-xs text-foreground"
|
||||
@change="handleChannels(index, $event)"
|
||||
>
|
||||
<option value="">{{ t('settings.system.requests.profiles.anyChannels') }}</option>
|
||||
<option :value="MONO">{{ t('settings.system.requests.profiles.mono') }}</option>
|
||||
<option :value="STEREO">{{ t('settings.system.requests.profiles.stereo') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{{ t('settings.system.requests.profiles.minSeeders') }}
|
||||
<input
|
||||
:value="tier.conditions.minSeeders ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-16 rounded-md border border-input bg-background px-1.5 py-1 text-xs tabular-nums text-foreground"
|
||||
@change="handleNumber(index, 'minSeeders', $event)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{{ t('settings.system.requests.profiles.maxSize') }}
|
||||
<input
|
||||
:value="sizeInMegabytes(tier.conditions.maxSizeBytes)"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-20 rounded-md border border-input bg-background px-1.5 py-1 text-xs tabular-nums text-foreground"
|
||||
@change="handleMaxSize(index, $event)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="accent-primary"
|
||||
:checked="tier.conditions.freeleechOnly === true"
|
||||
@change="handleFreeleech(index, $event)"
|
||||
/>
|
||||
{{ t('bookRequests.releases.freeleech') }}
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="accent-primary"
|
||||
:checked="tier.conditions.excludeVipOnly === true"
|
||||
@change="handleExcludeVip(index, $event)"
|
||||
/>
|
||||
{{ t('bookRequests.releases.hideVipOnly') }}
|
||||
</label>
|
||||
|
||||
<div class="flex min-w-48 flex-1 basis-56 flex-col items-stretch gap-1">
|
||||
<label :for="`${tier.id}-languages`" class="text-xs text-muted-foreground">
|
||||
{{ t('settings.system.requests.profiles.languages') }}
|
||||
<label class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{{ t('settings.system.requests.profiles.files') }}
|
||||
<select
|
||||
:value="tier.conditions.fileLayout ?? ''"
|
||||
class="rounded-md border border-input bg-background px-1.5 py-1 text-xs text-foreground"
|
||||
@change="handleLayout(index, $event)"
|
||||
>
|
||||
<option value="">{{ t('settings.system.requests.profiles.anyLayout') }}</option>
|
||||
<option v-for="layout in LAYOUTS" :key="layout" :value="layout">
|
||||
{{ t(`bookRequests.releases.fileLayout.${layout}`) }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
<TokenSelect
|
||||
:input-id="`${tier.id}-languages`"
|
||||
:options="languageOptions"
|
||||
:model-value="tier.conditions.languages ?? []"
|
||||
:placeholder="t('settings.system.requests.profiles.multiSelectAny')"
|
||||
:described-by="multiSelectHintId"
|
||||
@update:model-value="handleLanguages(index, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex min-w-48 flex-1 basis-56 flex-col items-stretch gap-1">
|
||||
<div class="flex min-w-0 flex-col items-stretch gap-1">
|
||||
<label :for="`${tier.id}-indexers`" class="text-xs text-muted-foreground">
|
||||
{{ t('settings.system.requests.profiles.indexers') }}
|
||||
</label>
|
||||
@@ -392,6 +355,115 @@ function hasFormat(index: number, format: string): boolean {
|
||||
@update:model-value="handleIndexers(index, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-end gap-x-4 gap-y-2">
|
||||
<div class="flex min-w-48 flex-1 basis-56 flex-col items-stretch gap-1">
|
||||
<label :for="`${tier.id}-languages`" class="text-xs text-muted-foreground">
|
||||
{{ t('settings.system.requests.profiles.languages') }}
|
||||
</label>
|
||||
<TokenSelect
|
||||
:input-id="`${tier.id}-languages`"
|
||||
:options="languageOptions"
|
||||
:model-value="tier.conditions.languages ?? []"
|
||||
:placeholder="t('settings.system.requests.profiles.multiSelectAny')"
|
||||
:described-by="multiSelectHintId"
|
||||
@update:model-value="handleLanguages(index, $event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{{ t('settings.system.requests.profiles.maxSize') }}
|
||||
<input
|
||||
:value="sizeInMegabytes(tier.conditions.maxSizeBytes)"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-20 rounded-md border border-input bg-background px-1.5 py-1 text-xs tabular-nums text-foreground"
|
||||
@change="handleMaxSize(index, $event)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="isAudio" class="flex flex-wrap items-center gap-x-4 gap-y-2">
|
||||
<label class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{{ t('settings.system.requests.profiles.minBitrate') }}
|
||||
<input
|
||||
:value="tier.conditions.minBitrateKbps ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
step="16"
|
||||
class="w-16 rounded-md border border-input bg-background px-1.5 py-1 text-xs tabular-nums text-foreground"
|
||||
@change="handleNumber(index, 'minBitrateKbps', $event)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{{ t('settings.system.requests.profiles.channels') }}
|
||||
<select
|
||||
:value="tier.conditions.channels ?? ''"
|
||||
class="rounded-md border border-input bg-background px-1.5 py-1 text-xs text-foreground"
|
||||
@change="handleChannels(index, $event)"
|
||||
>
|
||||
<option value="">{{ t('settings.system.requests.profiles.anyChannels') }}</option>
|
||||
<option :value="MONO">{{ t('settings.system.requests.profiles.mono') }}</option>
|
||||
<option :value="STEREO">{{ t('settings.system.requests.profiles.stereo') }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<fieldset
|
||||
v-if="!usesOnlyNonTorrentSources(tier)"
|
||||
class="flex max-w-full flex-wrap items-center justify-self-start gap-x-3 gap-y-2 rounded-md border border-border px-2.5 py-1.5"
|
||||
>
|
||||
<legend class="px-1 text-[11px] font-semibold tracking-wide text-muted-foreground uppercase">
|
||||
{{ t('settings.system.requests.profiles.torrentConditions') }}
|
||||
</legend>
|
||||
|
||||
<label class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
{{ t('settings.system.requests.profiles.minSeeders') }}
|
||||
<input
|
||||
:value="tier.conditions.minSeeders ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
class="w-16 rounded-md border border-input bg-background px-1.5 py-1 text-xs tabular-nums text-foreground"
|
||||
@change="handleNumber(index, 'minSeeders', $event)"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="accent-primary"
|
||||
:checked="tier.conditions.freeleechOnly === true"
|
||||
@change="handleFreeleech(index, $event)"
|
||||
/>
|
||||
{{ t('bookRequests.releases.freeleech') }}
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="accent-primary"
|
||||
:checked="tier.conditions.excludeVipOnly === true"
|
||||
@change="handleExcludeVip(index, $event)"
|
||||
/>
|
||||
{{ t('bookRequests.releases.hideVipOnly') }}
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div
|
||||
v-else-if="hasTorrentConditions(tier)"
|
||||
role="status"
|
||||
class="flex flex-wrap items-center justify-between gap-2 rounded-md border border-warning/30 bg-warning/10 px-2.5 py-2"
|
||||
>
|
||||
<p class="text-xs text-warning">{{ t('settings.system.requests.profiles.incompatibleTorrentConditions') }}</p>
|
||||
<Button variant="outline" size="sm" @click="clearTorrentConditions(index)">
|
||||
{{ t('settings.system.requests.profiles.removeTorrentConditions') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p v-else class="text-xs text-muted-foreground">
|
||||
{{ t('settings.system.requests.profiles.nonTorrentConditions') }}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Loader2, Pencil, Plug, Server, Trash2, TriangleAlert, Upload } from '@lucide/vue'
|
||||
import type { IndexerItem } from '@bookorbit/types'
|
||||
import type { IndexerItem, PluginUpdateStatus } from '@bookorbit/types'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
@@ -10,6 +10,7 @@ import ConnectionHealth from '../ConnectionHealth.vue'
|
||||
import SearchHealth from '../SearchHealth.vue'
|
||||
import { sourceDotClass } from '@/features/book-requests/sourceColors'
|
||||
import PluginVersionBadge from './PluginVersionBadge.vue'
|
||||
import PluginUpdateControls from './PluginUpdateControls.vue'
|
||||
|
||||
/**
|
||||
* One configured source. The same row in both groups on the Requests page: a plugin and a Torznab
|
||||
@@ -24,6 +25,8 @@ const props = defineProps<{
|
||||
plugin?: boolean
|
||||
/** The installed plugin release. Missing for legacy plugins that predate this metadata. */
|
||||
pluginVersion?: string
|
||||
pluginUpdateable?: boolean
|
||||
pluginUpdateStatus?: PluginUpdateStatus
|
||||
/** True while this row's enabled flag is in flight, so the switch cannot be double-flipped. */
|
||||
busy?: boolean
|
||||
/**
|
||||
@@ -35,7 +38,15 @@ const props = defineProps<{
|
||||
pluginBusy?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ test: []; edit: []; toggle: [enabled: boolean]; updatePlugin: []; removePlugin: [] }>()
|
||||
const emit = defineEmits<{
|
||||
test: []
|
||||
edit: []
|
||||
toggle: [enabled: boolean]
|
||||
updatePlugin: []
|
||||
removePlugin: []
|
||||
reviewPluginUpdate: []
|
||||
automaticPluginUpdate: [enabled: boolean]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -62,6 +73,14 @@ function handleUpdatePlugin() {
|
||||
function handleRemovePlugin() {
|
||||
emit('removePlugin')
|
||||
}
|
||||
|
||||
function handleReviewPluginUpdate() {
|
||||
emit('reviewPluginUpdate')
|
||||
}
|
||||
|
||||
function handleAutomaticPluginUpdate(enabled: boolean) {
|
||||
emit('automaticPluginUpdate', enabled)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -112,6 +131,14 @@ function handleRemovePlugin() {
|
||||
</p>
|
||||
<p v-if="failing && indexer.lastErrorMessage" class="mt-1.5 text-xs text-destructive">{{ indexer.lastErrorMessage }}</p>
|
||||
<p v-if="searchFailing && indexer.lastSearchError" class="mt-1.5 text-xs text-foreground">{{ indexer.lastSearchError }}</p>
|
||||
<PluginUpdateControls
|
||||
v-if="plugin"
|
||||
:updateable="pluginUpdateable === true"
|
||||
:status="pluginUpdateStatus"
|
||||
:busy="pluginBusy"
|
||||
@review="handleReviewPluginUpdate"
|
||||
@automatic="handleAutomaticPluginUpdate"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { computed, onScopeDispose, ref, type Ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import type { IndexerAdapterDescriptor, IndexerItem, IndexerPluginFailure, PluginInspection, PluginInstallResult } from '@bookorbit/types'
|
||||
import type {
|
||||
IndexerAdapterDescriptor,
|
||||
IndexerItem,
|
||||
IndexerPluginFailure,
|
||||
PluginInspection,
|
||||
PluginInstallResult,
|
||||
PluginUpdateReview,
|
||||
PluginUpdateStatus,
|
||||
} from '@bookorbit/types'
|
||||
|
||||
import { isBuiltInAdapter } from './useIndexerDraft'
|
||||
|
||||
@@ -15,10 +23,15 @@ export interface IndexerPluginsOptions {
|
||||
indexers: Ref<IndexerItem[]>
|
||||
adapters: Ref<IndexerAdapterDescriptor[]>
|
||||
pluginFailures: Ref<IndexerPluginFailure[]>
|
||||
pluginUpdates: Ref<PluginUpdateStatus[]>
|
||||
adapterFor: (type: string) => IndexerAdapterDescriptor | undefined
|
||||
inspectPlugin: (file: File) => Promise<{ inspection: PluginInspection | null; error?: string | null }>
|
||||
installPlugin: (file: File) => Promise<{ inspection: PluginInstallResult | null; error?: string | null }>
|
||||
removePlugin: (type: string) => Promise<boolean>
|
||||
fetchPluginUpdates: (refresh?: boolean) => Promise<boolean>
|
||||
inspectPluginUpdate: (type: string) => Promise<{ inspection: PluginUpdateReview | null; error: string | null }>
|
||||
installPluginUpdate: (type: string, sha256: string) => Promise<{ status: PluginUpdateStatus | null; error: string | null }>
|
||||
setPluginAutomaticUpdate: (type: string, enabled: boolean) => Promise<{ status: PluginUpdateStatus | null; error: string | null }>
|
||||
fetchIndexers: (options?: { silent?: boolean; withAdapters?: boolean }) => Promise<void>
|
||||
/** The type currently open in the editor, so removing its plugin closes the form behind it. */
|
||||
editingType: Ref<string | null>
|
||||
@@ -37,12 +50,27 @@ export interface IndexerPluginsOptions {
|
||||
*/
|
||||
export function useIndexerPlugins(options: IndexerPluginsOptions) {
|
||||
const { t } = useI18n()
|
||||
const { indexers, adapters, pluginFailures, adapterFor, inspectPlugin, installPlugin, removePlugin, fetchIndexers } = options
|
||||
const {
|
||||
indexers,
|
||||
adapters,
|
||||
pluginFailures,
|
||||
pluginUpdates,
|
||||
adapterFor,
|
||||
inspectPlugin,
|
||||
installPlugin,
|
||||
removePlugin,
|
||||
fetchIndexers,
|
||||
fetchPluginUpdates,
|
||||
inspectPluginUpdate,
|
||||
installPluginUpdate,
|
||||
setPluginAutomaticUpdate,
|
||||
} = options
|
||||
const { editingType, cancelEdit, startCreateFor } = options
|
||||
|
||||
const pluginInput = ref<HTMLInputElement | null>(null)
|
||||
const pluginFile = ref<File | null>(null)
|
||||
const pluginReview = ref<PluginInspection | null>(null)
|
||||
const pluginReview = ref<PluginInspection | PluginUpdateReview | null>(null)
|
||||
const remoteReview = ref<PluginUpdateReview | null>(null)
|
||||
const pluginError = ref<string | null>(null)
|
||||
const pluginBusy = ref(false)
|
||||
/**
|
||||
@@ -54,6 +82,7 @@ export function useIndexerPlugins(options: IndexerPluginsOptions) {
|
||||
/** Which plugin that was, so the notice can be retired once the restart has actually loaded it. */
|
||||
let restartPendingType: string | null = null
|
||||
const removingPlugin = ref<string | null>(null)
|
||||
const checkingPluginUpdates = ref(false)
|
||||
/** The plugin whose Update entry opened the picker, so the file can be held to that type. */
|
||||
const updatingPlugin = ref<string | null>(null)
|
||||
/**
|
||||
@@ -143,6 +172,12 @@ export function useIndexerPlugins(options: IndexerPluginsOptions) {
|
||||
|
||||
const pendingRemovalUsage = computed(() => indexerCountByType.value.get(pluginPendingRemoval.value?.type ?? '') ?? 0)
|
||||
|
||||
const updateStatusByType = computed(() => new Map(pluginUpdates.value.map((status) => [status.type, status])))
|
||||
|
||||
function updateStatusFor(type: string): PluginUpdateStatus | undefined {
|
||||
return updateStatusByType.value.get(type)
|
||||
}
|
||||
|
||||
function askRemovePlugin(plugin: IndexerAdapterDescriptor) {
|
||||
pluginPendingRemoval.value = { type: plugin.type, label: plugin.label }
|
||||
}
|
||||
@@ -175,6 +210,49 @@ export function useIndexerPlugins(options: IndexerPluginsOptions) {
|
||||
else askRemovePluginType(type, source?.name ?? type)
|
||||
}
|
||||
|
||||
async function checkPluginUpdates() {
|
||||
checkingPluginUpdates.value = true
|
||||
try {
|
||||
const ok = await fetchPluginUpdates(true)
|
||||
if (!ok) toast.error(t('settings.system.requests.indexers.plugins.updateCheckFailed'))
|
||||
else toast.success(t('settings.system.requests.indexers.plugins.updateCheckComplete'))
|
||||
} finally {
|
||||
checkingPluginUpdates.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function reviewPublishedUpdate(type: string) {
|
||||
pluginBusy.value = true
|
||||
try {
|
||||
const { inspection, error } = await inspectPluginUpdate(type)
|
||||
if (!inspection) {
|
||||
toast.error(error ?? t('settings.system.requests.indexers.plugins.updateInspectFailed'))
|
||||
return
|
||||
}
|
||||
remoteReview.value = inspection
|
||||
pluginReview.value = inspection
|
||||
} finally {
|
||||
pluginBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function setAutomaticUpdate(type: string, enabled: boolean) {
|
||||
pluginBusy.value = true
|
||||
try {
|
||||
const { status, error } = await setPluginAutomaticUpdate(type, enabled)
|
||||
if (!status) {
|
||||
toast.error(error ?? t('settings.system.requests.indexers.plugins.autoUpdateFailed'))
|
||||
return
|
||||
}
|
||||
const index = pluginUpdates.value.findIndex((entry) => entry.type === type)
|
||||
if (index === -1) pluginUpdates.value.push(status)
|
||||
else pluginUpdates.value[index] = status
|
||||
if (enabled && status.state === 'current') await fetchIndexers({ silent: true, withAdapters: true })
|
||||
} finally {
|
||||
pluginBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startPluginInstall() {
|
||||
updatingPlugin.value = null
|
||||
pluginInput.value?.click()
|
||||
@@ -227,9 +305,28 @@ export function useIndexerPlugins(options: IndexerPluginsOptions) {
|
||||
pluginFile.value = null
|
||||
pluginError.value = null
|
||||
updatingPlugin.value = null
|
||||
remoteReview.value = null
|
||||
}
|
||||
|
||||
async function confirmPluginInstall() {
|
||||
const published = remoteReview.value
|
||||
if (published) {
|
||||
pluginBusy.value = true
|
||||
try {
|
||||
const { status, error } = await installPluginUpdate(published.type, published.sha256)
|
||||
if (!status) {
|
||||
toast.error(error ?? t('settings.system.requests.indexers.plugins.installFailed'))
|
||||
return
|
||||
}
|
||||
toast.success(t('settings.system.requests.indexers.plugins.updated', { label: published.label, version: published.version }))
|
||||
cancelPluginInstall()
|
||||
await Promise.all([fetchIndexers({ silent: true, withAdapters: true }), fetchPluginUpdates(false)])
|
||||
} finally {
|
||||
pluginBusy.value = false
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const file = pluginFile.value
|
||||
if (!file) return
|
||||
|
||||
@@ -300,6 +397,7 @@ export function useIndexerPlugins(options: IndexerPluginsOptions) {
|
||||
pluginError,
|
||||
pluginBusy,
|
||||
pluginRestartPending,
|
||||
checkingPluginUpdates,
|
||||
removingPlugin,
|
||||
pluginPendingRemoval,
|
||||
pluginRows,
|
||||
@@ -311,12 +409,16 @@ export function useIndexerPlugins(options: IndexerPluginsOptions) {
|
||||
pluginUseCount,
|
||||
pluginUsage,
|
||||
pendingRemovalUsage,
|
||||
updateStatusFor,
|
||||
askRemovePlugin,
|
||||
askRemovePluginType,
|
||||
cancelRemovePlugin,
|
||||
confirmRemovePlugin,
|
||||
handleRowPluginUpdate,
|
||||
handleRowPluginRemove,
|
||||
checkPluginUpdates,
|
||||
reviewPublishedUpdate,
|
||||
setAutomaticUpdate,
|
||||
startPluginInstall,
|
||||
startPluginUpdate,
|
||||
handlePluginChosen,
|
||||
|
||||
@@ -3089,6 +3089,25 @@
|
||||
"usedBy": "{count, plural, one {used by # source} other {used by # sources}}",
|
||||
"unused": "no source uses it yet",
|
||||
"update": "Update plugin",
|
||||
"checkUpdates": "Check for updates",
|
||||
"updateCheckComplete": "Plugin update check complete.",
|
||||
"updateCheckFailed": "Could not check for plugin updates.",
|
||||
"updateInspectFailed": "The published plugin update could not be verified.",
|
||||
"reviewUpdate": "Review update",
|
||||
"updated": "Updated {label} to {version}.",
|
||||
"autoUpdate": "Install signed updates automatically",
|
||||
"autoUpdateFailed": "Could not change automatic updates for this plugin.",
|
||||
"installedVersion": "Installed version",
|
||||
"updateChannel": "Signed update channel",
|
||||
"signatureVerified": "The downloaded source matches the publisher's Ed25519 signature.",
|
||||
"updateStates": {
|
||||
"unsupported": "Manual updates",
|
||||
"unchecked": "Not checked yet",
|
||||
"current": "Up to date",
|
||||
"available": "Version {version} available",
|
||||
"custom": "Custom build",
|
||||
"failed": "Update check failed"
|
||||
},
|
||||
"wrongType": "That file is the {found} plugin, not {expected}.",
|
||||
"replaceTitle": "Replace this plugin?",
|
||||
"replace": "Replace plugin",
|
||||
@@ -3137,7 +3156,7 @@
|
||||
"autoGrabHint": "When a request is approved, its highest-scoring release goes straight to the download client and nobody has to open the release picker. Off by default, because matching a book to the right release is hard and a person is usually the better judge.",
|
||||
"autoGrabEnabled": "Automatic downloads are on",
|
||||
"autoGrabDisabled": "Automatic downloads are off",
|
||||
"minScore": "Minimum release score",
|
||||
"minScore": "Minimum score for automatic download",
|
||||
"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",
|
||||
@@ -3184,7 +3203,10 @@
|
||||
},
|
||||
"profiles": {
|
||||
"title": "Release profiles",
|
||||
"hint": "The edition you want, best first. A release is matched against these top to bottom and takes the first tier it fits. This is what unattended downloading chooses from: a release that matches no tier is never downloaded automatically, though it still appears in the picker for you to send by hand. Leave a medium empty and nothing is filtered or refused, and releases rank by score alone.",
|
||||
"effectiveRule": "Auto-download rule: first matching tier, then highest score (minimum {score}).",
|
||||
"summary": "Releases outside the profile remain available for manual selection. With no tiers, releases rank by score alone.",
|
||||
"howProfiles": "How release profiles work",
|
||||
"hint": "A release is matched against tiers from top to bottom and takes the first tier it fits. Automatic downloads consider only tiered releases when a profile exists. Manual selection still includes untiered releases after the tiered results.",
|
||||
"bitrateNote": "A bitrate floor turns away a release that states a lower one. Most sources measure very few of their releases, and an unmeasured release is not turned away, so this narrows less than it looks.",
|
||||
"empty": "No profile. Releases for this medium are ranked by score alone.",
|
||||
"addTier": "Add tier",
|
||||
@@ -3198,6 +3220,10 @@
|
||||
"anyLayout": "Any",
|
||||
"minBitrate": "Min kbps",
|
||||
"minSeeders": "Min seeders",
|
||||
"torrentConditions": "Torrent conditions",
|
||||
"nonTorrentConditions": "Seeder, freeleech and VIP conditions do not apply to the selected sources.",
|
||||
"incompatibleTorrentConditions": "This non-torrent tier still has saved torrent conditions. They may be ignored or prevent the tier from matching.",
|
||||
"removeTorrentConditions": "Remove torrent conditions",
|
||||
"channels": "Channels",
|
||||
"anyChannels": "Any",
|
||||
"mono": "Mono",
|
||||
@@ -6716,6 +6742,27 @@
|
||||
"vipOnly": "VIP only",
|
||||
"hideVipOnly": "Hide VIP only",
|
||||
"noneMatchedProfile": "No release here matches your profile for this medium, so none of them will be downloaded automatically. You can still send one by hand. To widen it, add a tier with fewer conditions below the ones you have.",
|
||||
"profileMismatch": {
|
||||
"summary": "{count, plural, one {# release was found, but automatic download will skip it because it does not match any {medium} profile tier.} other {# releases were found, but automatic download will skip them because none matches a {medium} profile tier.}} Match score and profile rules are separate checks. You can still click Send.",
|
||||
"review": "Review {medium} profile",
|
||||
"row": "Automatic download will skip this. Closest tier: {tier}. {reasons}",
|
||||
"failures": {
|
||||
"formatUnknown": "{source} did not state a format; this tier requires {expected}.",
|
||||
"format": "Format is {actual}; this tier requires {expected}.",
|
||||
"fileLayoutUnknown": "{source} did not state a file count; this tier requires {expected}.",
|
||||
"fileLayout": "File layout is {actual}; this tier requires {expected}.",
|
||||
"bitrate": "Bitrate is {actual} kbps; this tier requires at least {expected} kbps.",
|
||||
"channels": "Audio is {actual}; this tier requires {expected}.",
|
||||
"languageUnknown": "{source} did not state a language; this tier requires {expected}.",
|
||||
"language": "Language is {actual}; this tier requires {expected}.",
|
||||
"source": "This source is not included in the tier.",
|
||||
"seeders": "This release has {actual} seeders; this tier requires at least {expected}.",
|
||||
"sizeUnknown": "{source} did not state a size; this tier allows up to {expected}.",
|
||||
"size": "Size is {actual}; this tier allows up to {expected}.",
|
||||
"freeleech": "This tier requires freeleech.",
|
||||
"vipOnly": "This tier excludes VIP-only releases."
|
||||
}
|
||||
},
|
||||
"alreadyGrabbed": "Already snatched",
|
||||
"freeleechLabel": "Freeleech",
|
||||
"published": "Published",
|
||||
|
||||
Reference in New Issue
Block a user