mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-11-06 23:47:52 +08:00
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:
63
backend/src/test/test_nexusphp_rss.py
Normal file
63
backend/src/test/test_nexusphp_rss.py
Normal 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&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&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",
|
||||
"",
|
||||
)
|
||||
]
|
||||
@@ -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);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
79
webui/src/utils/__tests__/nexusphp.test.ts
Normal file
79
webui/src/utils/__tests__/nexusphp.test.ts
Normal 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'
|
||||
);
|
||||
});
|
||||
});
|
||||
30
webui/src/utils/nexusphp.ts
Normal file
30
webui/src/utils/nexusphp.ts
Normal 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`;
|
||||
}
|
||||
1
webui/types/dts/auto-imports.d.ts
vendored
1
webui/types/dts/auto-imports.d.ts
vendored
@@ -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']
|
||||
|
||||
Reference in New Issue
Block a user