feat(webui): NexusPHP PT-site preset for search providers

Add PT (private-tracker) support via the existing RSS-based search
pipeline. The Add Search Provider dialog gains a "PT Site (NexusPHP)"
mode that builds a torrentrss.php search template from base URL +
passkey + optional category ID (e.g. Audiences.me anime = 402), so
search -> subscribe -> auto-download -> rename works end-to-end on
NexusPHP trackers with no backend changes.

- webui/src/utils/nexusphp.ts: URL-template builder (vitest-covered)
- config-search-provider.vue: segmented custom/NexusPHP add dialog
  with live URL preview; en + zh-CN strings
- backend test_nexusphp_rss.py: regression tests proving NexusPHP
  torrentrss output (enclosure and linktype=dl variants) parses
  through the generic rss_parser path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JhTduCYBGeHETU7dSWoh37
This commit is contained in:
Estrella Pan
2026-07-07 10:19:26 +02:00
parent c19da52497
commit 378acc90cb
7 changed files with 323 additions and 19 deletions

View File

@@ -0,0 +1,63 @@
"""NexusPHP PT 站点(如 Audiences.me)torrentrss.php 输出的解析回归测试。
PT 支持依赖通用 RSS 解析路径:条目带 <enclosure>(.torrent 下载地址含
passkey)时取 enclosure 为下载链接、<link>(details.php)为主页;
linktype=dl 且无 enclosure 时 <link> 本身就是下载地址。
"""
import xml.etree.ElementTree as ET
from module.network.site import rss_parser
NEXUSPHP_RSS_WITH_ENCLOSURE = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Audiences :: torrents</title>
<link>https://audiences.me</link>
<item>
<title>[Sakurato] Kusuriya no Hitorigoto [21][AVC-8bit 1080p AAC][CHS]</title>
<link>https://audiences.me/details.php?id=12345</link>
<enclosure url="https://audiences.me/download.php?id=12345&amp;passkey=abc123" type="application/x-bittorrent" length="694157244"/>
<guid>https://audiences.me/details.php?id=12345</guid>
<pubDate>Sat, 04 Jul 2026 12:00:00 +0800</pubDate>
</item>
</channel>
</rss>"""
NEXUSPHP_RSS_LINKTYPE_DL = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
<channel>
<title>Audiences :: torrents</title>
<link>https://audiences.me</link>
<item>
<title>[ANi] Boku no Hero Academia S07 - 08 [1080P][WEB-DL][AAC AVC][CHT]</title>
<link>https://audiences.me/download.php?id=67890&amp;passkey=abc123</link>
<guid>https://audiences.me/details.php?id=67890</guid>
<pubDate>Sat, 04 Jul 2026 12:00:00 +0800</pubDate>
</item>
</channel>
</rss>"""
def test_rss_parser_nexusphp_enclosure_yields_download_url() -> None:
soup = ET.fromstring(NEXUSPHP_RSS_WITH_ENCLOSURE)
results = rss_parser(soup)
assert results == [
(
"[Sakurato] Kusuriya no Hitorigoto [21][AVC-8bit 1080p AAC][CHS]",
"https://audiences.me/download.php?id=12345&passkey=abc123",
"https://audiences.me/details.php?id=12345",
)
]
def test_rss_parser_nexusphp_linktype_dl_uses_link_as_download_url() -> None:
soup = ET.fromstring(NEXUSPHP_RSS_LINKTYPE_DL)
results = rss_parser(soup)
assert results == [
(
"[ANi] Boku no Hero Academia S07 - 08 [1080P][WEB-DL][AAC AVC][CHT]",
"https://audiences.me/download.php?id=67890&passkey=abc123",
"",
)
]

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup>
import { Delete, EditTwo, Plus } from '@icon-park/vue-next';
import { useConfirm } from '@/hooks/useConfirm';
import { buildNexusPhpSearchUrl } from '@/utils/nexusphp';
interface SearchProvider {
name: string;
@@ -33,6 +34,39 @@ const editingIndex = ref<number>(-1);
const formName = ref('');
const formUrl = ref('');
// Add-dialog mode: custom URL template, or NexusPHP PT-site preset that
// builds a torrentrss.php search template from base URL + passkey.
type AddMode = 'custom' | 'nexusphp';
const formMode = ref<AddMode>('custom');
const formBaseUrl = ref('');
const formPasskey = ref('');
const formCategoryId = ref('');
const addModeOptions = computed(() => [
{ label: t('config.search_provider_set.mode_custom'), value: 'custom' },
{ label: t('config.search_provider_set.mode_nexusphp'), value: 'nexusphp' },
]);
const builtNexusPhpUrl = computed(() => {
if (!formBaseUrl.value.trim() || !formPasskey.value.trim()) return '';
return buildNexusPhpSearchUrl({
baseUrl: formBaseUrl.value,
passkey: formPasskey.value,
categoryId: formCategoryId.value,
});
});
const addFormUrl = computed(() =>
formMode.value === 'nexusphp' ? builtNexusPhpUrl.value : formUrl.value.trim()
);
const canAdd = computed(
() =>
!!formName.value.trim() &&
!!addFormUrl.value &&
validateUrl(addFormUrl.value)
);
// Default providers that cannot be deleted
const defaultProviderNames = ['mikan', 'nyaa', 'dmhy'];
@@ -76,6 +110,10 @@ async function saveProviders() {
function openAddDialog() {
formName.value = '';
formUrl.value = '';
formMode.value = 'custom';
formBaseUrl.value = '';
formPasskey.value = '';
formCategoryId.value = '';
showAddDialog.value = true;
}
@@ -88,7 +126,7 @@ function openEditDialog(provider: SearchProvider, index: number) {
}
async function handleAdd() {
if (!formName.value.trim() || !formUrl.value.trim()) return;
if (!canAdd.value) return;
// Check for duplicate name
if (providers.value.some((p) => p.name === formName.value.trim())) {
@@ -97,13 +135,16 @@ async function handleAdd() {
providers.value.push({
name: formName.value.trim(),
url: formUrl.value.trim(),
url: addFormUrl.value,
});
await saveProviders();
showAddDialog.value = false;
formName.value = '';
formUrl.value = '';
formBaseUrl.value = '';
formPasskey.value = '';
formCategoryId.value = '';
}
async function handleEdit() {
@@ -226,26 +267,83 @@ function validateUrl(url: string): boolean {
:title="$t('config.search_provider_set.add_title')"
>
<div space-y-16>
<ab-segmented
v-model:value="formMode"
size="sm"
:options="addModeOptions"
:aria-label="$t('config.search_provider_set.add_title')"
/>
<ab-field :label="$t('config.search_provider_set.name')">
<ab-input
v-model="formName"
:placeholder="$t('config.search_provider_set.name_placeholder')"
:placeholder="
formMode === 'nexusphp'
? $t('config.search_provider_set.name_placeholder_nexusphp')
: $t('config.search_provider_set.name_placeholder')
"
:maxlength="32"
/>
</ab-field>
<ab-field :label="$t('config.search_provider_set.url')">
<ab-input
v-model="formUrl"
type="text"
:placeholder="$t('config.search_provider_set.url_placeholder')"
@keyup.enter="handleAdd"
/>
</ab-field>
<template v-if="formMode === 'custom'">
<ab-field :label="$t('config.search_provider_set.url')">
<ab-input
v-model="formUrl"
type="text"
:placeholder="$t('config.search_provider_set.url_placeholder')"
@keyup.enter="handleAdd"
/>
</ab-field>
<div v-if="formUrl && !validateUrl(formUrl)" class="validation-warning">
{{ $t('config.search_provider_set.url_missing_placeholder') }}
</div>
<div
v-if="formUrl && !validateUrl(formUrl)"
class="validation-warning"
>
{{ $t('config.search_provider_set.url_missing_placeholder') }}
</div>
</template>
<template v-else>
<ab-field :label="$t('config.search_provider_set.base_url')">
<ab-input
v-model="formBaseUrl"
type="text"
:placeholder="
$t('config.search_provider_set.base_url_placeholder')
"
/>
</ab-field>
<ab-field :label="$t('config.search_provider_set.passkey')">
<ab-input
v-model="formPasskey"
type="text"
:placeholder="
$t('config.search_provider_set.passkey_placeholder')
"
/>
</ab-field>
<ab-field :label="$t('config.search_provider_set.category_id')">
<ab-input
v-model="formCategoryId"
type="text"
:placeholder="
$t('config.search_provider_set.category_id_placeholder')
"
@keyup.enter="handleAdd"
/>
</ab-field>
<div class="hint-text">
{{ $t('config.search_provider_set.nexusphp_hint') }}
</div>
<div v-if="builtNexusPhpUrl" class="url-preview">
{{ builtNexusPhpUrl }}
</div>
</template>
</div>
<template #footer>
@@ -255,9 +353,7 @@ function validateUrl(url: string): boolean {
<ab-button
size="sm"
variant="primary"
:disabled="
!formName.trim() || !formUrl.trim() || !validateUrl(formUrl)
"
:disabled="!canAdd"
@click="handleAdd"
>
{{ $t('config.apply') }}
@@ -381,6 +477,21 @@ function validateUrl(url: string): boolean {
line-height: 1.5;
}
.url-preview {
font-size: 12px;
color: var(--color-text-secondary);
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas,
monospace;
padding: 8px 12px;
background: var(--color-surface-elevated, #f9fafb);
border-radius: 6px;
word-break: break-all;
:root.dark & {
background: var(--color-surface-elevated, #1f2937);
}
}
.validation-warning {
font-size: 12px;
color: var(--color-danger, #ef4444);

View File

@@ -142,7 +142,17 @@
"delete_confirm": "Are you sure you want to delete this provider?",
"load_failed": "Failed to load search providers",
"save_failed": "Failed to save search providers",
"save_success": "Search providers saved"
"save_success": "Search providers saved",
"mode_custom": "Custom URL",
"mode_nexusphp": "PT Site (NexusPHP)",
"name_placeholder_nexusphp": "e.g., audiences",
"base_url": "Site URL",
"base_url_placeholder": "https://audiences.me",
"passkey": "Passkey",
"passkey_placeholder": "Passkey from the site's user control panel",
"category_id": "Category ID (optional)",
"category_id_placeholder": "e.g., 402",
"nexusphp_hint": "Builds a torrentrss.php search template from your passkey. Works with NexusPHP-based PT sites such as Audiences.me."
}
},
"downloader": {

View File

@@ -142,7 +142,17 @@
"delete_confirm": "确定删除此搜索源?",
"load_failed": "加载搜索源失败",
"save_failed": "保存搜索源失败",
"save_success": "搜索源已保存"
"save_success": "搜索源已保存",
"mode_custom": "自定义 URL",
"mode_nexusphp": "PT 站点(NexusPHP)",
"name_placeholder_nexusphp": "例如:audiences",
"base_url": "站点地址",
"base_url_placeholder": "https://audiences.me",
"passkey": "Passkey",
"passkey_placeholder": "站点用户控制面板中的 Passkey",
"category_id": "分类 ID(可选)",
"category_id_placeholder": "例如:402",
"nexusphp_hint": "根据 Passkey 生成 torrentrss.php 搜索模板,适用于 NexusPHP 架构的 PT 站点(如 Audiences.me)。"
}
},
"downloader": {

View File

@@ -0,0 +1,79 @@
/**
* NexusPHP PT 站点(如 Audiences.me)搜索源 URL 模板构造。
* 生成的模板交给后端 search_provider 使用:后端用 str.replace 把
* 字面量 "%s" 换成搜索词,因此 %s 必须原样保留、不能被编码。
*/
import { describe, expect, it } from 'vitest';
import { buildNexusPhpSearchUrl } from '../nexusphp';
describe('buildNexusPhpSearchUrl', () => {
it('should build a torrentrss URL template when given base URL and passkey', () => {
const url = buildNexusPhpSearchUrl({
baseUrl: 'https://audiences.me',
passkey: 'abc123',
});
expect(url).toBe(
'https://audiences.me/torrentrss.php?rows=50&linktype=dl&passkey=abc123&search=%s'
);
});
it('should keep the literal %s placeholder unencoded', () => {
const url = buildNexusPhpSearchUrl({
baseUrl: 'https://audiences.me',
passkey: 'abc123',
});
expect(url.endsWith('search=%s')).toBe(true);
});
it('should append the cat parameter when a category ID is provided', () => {
const url = buildNexusPhpSearchUrl({
baseUrl: 'https://audiences.me',
passkey: 'abc123',
categoryId: '402',
});
expect(url).toContain('&cat=402&search=%s');
});
it('should omit the cat parameter when the category ID is blank', () => {
const url = buildNexusPhpSearchUrl({
baseUrl: 'https://audiences.me',
passkey: 'abc123',
categoryId: ' ',
});
expect(url).not.toContain('cat=');
});
it('should strip trailing slashes from the base URL', () => {
const url = buildNexusPhpSearchUrl({
baseUrl: 'https://audiences.me/',
passkey: 'abc123',
});
expect(url).toContain('audiences.me/torrentrss.php');
});
it('should prepend https scheme when the base URL has none', () => {
const url = buildNexusPhpSearchUrl({
baseUrl: 'audiences.me',
passkey: 'abc123',
});
expect(url.startsWith('https://audiences.me/')).toBe(true);
});
it('should URL-encode reserved characters in the passkey', () => {
const url = buildNexusPhpSearchUrl({
baseUrl: 'https://audiences.me',
passkey: 'a&b=c',
});
expect(url).toContain('passkey=a%26b%3Dc');
});
it('should trim surrounding whitespace from inputs', () => {
const url = buildNexusPhpSearchUrl({
baseUrl: ' https://audiences.me ',
passkey: ' abc123 ',
});
expect(url).toBe(
'https://audiences.me/torrentrss.php?rows=50&linktype=dl&passkey=abc123&search=%s'
);
});
});

View File

@@ -0,0 +1,30 @@
/**
* NexusPHP PT 站点(如 Audiences.me)搜索源 URL 模板构造。
*
* NexusPHP 的 torrentrss.php 支持 search 参数,因此 PT 站点可以直接作为
* AB 的 RSS 型搜索源接入:passkey 放在 URL 里鉴权,linktype=dl 让条目
* 链接指向 .torrent 下载地址,qBittorrent 可直接抓取。
*
* 返回的是后端 search_provider 的 URL 模板:字面量 "%s" 由后端替换为
* 搜索词,必须保持未编码。
*/
export interface NexusPhpProviderOptions {
baseUrl: string;
passkey: string;
/** NexusPHP 分类 ID(如 Audiences.me 的剧集/动漫为 402),留空则不过滤 */
categoryId?: string;
}
export function buildNexusPhpSearchUrl(
options: NexusPhpProviderOptions
): string {
let base = options.baseUrl.trim().replace(/\/+$/, '');
if (!/^https?:\/\//i.test(base)) {
base = `https://${base}`;
}
const passkey = encodeURIComponent(options.passkey.trim());
const cat = options.categoryId?.trim();
const catParam = cat ? `&cat=${encodeURIComponent(cat)}` : '';
return `${base}/torrentrss.php?rows=50&linktype=dl&passkey=${passkey}${catParam}&search=%s`;
}

View File

@@ -24,6 +24,7 @@ declare global {
const axios: typeof import('../../src/utils/axios')['axios']
const beforeAll: typeof import('vitest')['beforeAll']
const beforeEach: typeof import('vitest')['beforeEach']
const buildNexusPhpSearchUrl: typeof import('../../src/utils/nexusphp')['buildNexusPhpSearchUrl']
const chai: typeof import('vitest')['chai']
const computed: typeof import('vue')['computed']
const countLogLevels: typeof import('../../src/utils/log-parse')['countLogLevels']