Compare commits

...

5 Commits

Author SHA1 Message Date
jxxghp
2931f5df46 更新 package.json 2026-05-08 11:21:47 +08:00
jxxghp
e14c81d178 feat(settings): persist LLM base URL presets 2026-05-08 10:52:30 +08:00
jxxghp
a9403c9c34 chore: bump version to 2.10.11 2026-05-07 08:23:20 +08:00
jxxghp
dc4914e3ca style: adjust downloader card API key field to span full width 2026-05-07 08:22:39 +08:00
jxxghp
f3dbc4afad feat: add qBittorrent API key setup support
Expose qBittorrent WebUI API Key fields in settings and setup so 5.2 users can connect without requiring username/password.

Refs jxxghp/MoviePilot#5724
2026-05-07 07:41:05 +08:00
10 changed files with 115 additions and 8 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "moviepilot",
"version": "2.10.10",
"version": "2.10.12",
"private": true,
"type": "module",
"bin": "dist/service.js",

View File

@@ -346,11 +346,23 @@ onUnmounted(() => {
prepend-inner-icon="mdi-server"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="downloaderInfo.config.apikey"
type="password"
:label="t('downloader.apiKey')"
:hint="t('downloader.qbittorrentApiKeyHint')"
persistent-hint
active
prepend-inner-icon="mdi-key-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="downloaderInfo.config.username"
:label="t('downloader.username')"
:hint="t('downloader.username')"
:disabled="!!downloaderInfo.config.apikey"
persistent-hint
active
prepend-inner-icon="mdi-account"
@@ -362,6 +374,7 @@ onUnmounted(() => {
type="password"
:label="t('downloader.password')"
:hint="t('downloader.password')"
:disabled="!!downloaderInfo.config.apikey"
persistent-hint
active
prepend-inner-icon="mdi-lock"

View File

@@ -17,11 +17,13 @@ export interface LlmProviderAuthStatus {
}
export interface LlmProviderUrlPreset {
id: string
label: string
value: string
}
export interface LlmProviderUrlPresetItem {
id: string
title: string
value: string
subtitle?: string
@@ -80,6 +82,7 @@ interface UseLlmProviderDirectoryOptions {
provider: Ref<string>
apiKey: Ref<string>
baseUrl: Ref<string>
baseUrlPreset?: Ref<string>
model: Ref<string>
maxContextTokens?: Ref<number>
authConnected?: Ref<boolean>
@@ -110,6 +113,7 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
const providerItems = computed(() => providers.value.map(item => ({ title: item.name, value: item.id })))
const baseUrlPresetItems = computed<LlmProviderUrlPresetItem[]>(() =>
(selectedProvider.value?.base_url_presets || []).map(item => ({
id: item.id,
title: item.value,
value: item.value,
subtitle: item.label,
@@ -150,14 +154,37 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
const currentBaseUrl = normalizeValue(options.baseUrl.value)
const defaultBaseUrl = provider.default_base_url || ''
const defaultPresetId = normalizeValue(provider.base_url_presets?.[0]?.id)
if (reset) {
options.baseUrl.value = defaultBaseUrl
if (options.baseUrlPreset) {
options.baseUrlPreset.value = defaultPresetId
}
return
}
if (!currentBaseUrl && defaultBaseUrl) {
options.baseUrl.value = defaultBaseUrl
}
if (!options.baseUrlPreset) return
const currentPresetId = normalizeValue(options.baseUrlPreset.value)
if (currentPresetId) return
const matchedPreset = (provider.base_url_presets || []).find(
item => normalizeValue(item.value) === normalizeValue(options.baseUrl.value),
)
options.baseUrlPreset.value = matchedPreset?.id || defaultPresetId
}
function setBaseUrlPreset(presetId?: string, presetValue?: string) {
if (!options.baseUrlPreset) return
options.baseUrlPreset.value = normalizeValue(presetId)
if (presetValue !== undefined) {
options.baseUrl.value = presetValue || ''
}
}
function handleProviderSelection(resetBaseUrl = true) {
@@ -225,6 +252,7 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
provider: normalizeValue(options.provider.value),
api_key: normalizeValue(options.apiKey.value) || undefined,
base_url: normalizeValue(options.baseUrl.value) || undefined,
base_url_preset: normalizeValue(options.baseUrlPreset?.value) || undefined,
force_refresh: forceRefresh,
},
})
@@ -363,6 +391,7 @@ export function useLlmProviderDirectory(options: UseLlmProviderDirectoryOptions)
showApiKeyField,
hasUsableCredential,
canRefreshModels,
setBaseUrlPreset,
authDialogVisible,
authPolling,
authPopupBlocked,

View File

@@ -60,6 +60,7 @@ export interface WizardData {
supportAudioInputOutput: boolean
apiKey: string
baseUrl: string
baseUrlPreset: string
maxContextTokens: number
voiceApiKey: string
voiceBaseUrl: string
@@ -107,6 +108,7 @@ export interface ValidationErrorState {
downloader: {
name: boolean
host: boolean
apikey: boolean
username: boolean
password: boolean
}
@@ -239,6 +241,7 @@ const wizardData = ref<WizardData>({
supportAudioInputOutput: false,
apiKey: '',
baseUrl: 'https://api.deepseek.com',
baseUrlPreset: '',
maxContextTokens: 64,
voiceApiKey: '',
voiceBaseUrl: '',
@@ -277,6 +280,7 @@ const validationErrors = ref<ValidationErrorState>({
downloader: {
name: false,
host: false,
apikey: false,
username: false,
password: false,
},
@@ -466,6 +470,7 @@ export function useSetupWizard() {
validationErrors.value.downloader = {
name: false,
host: false,
apikey: false,
username: false,
password: false,
}
@@ -548,9 +553,18 @@ export function useSetupWizard() {
}
// 根据下载器类型验证其他必输项
if (
wizardData.value.downloader.type === 'qbittorrent'
|| wizardData.value.downloader.type === 'transmission'
if (wizardData.value.downloader.type === 'qbittorrent') {
const hasApiKey = !!wizardData.value.downloader.config?.apikey?.trim()
if (!hasApiKey && !wizardData.value.downloader.config?.username?.trim()) {
errors.push(t('downloader.usernameRequired'))
validationErrors.value.downloader.username = true
}
if (!hasApiKey && !wizardData.value.downloader.config?.password?.trim()) {
errors.push(t('downloader.passwordRequired'))
validationErrors.value.downloader.password = true
}
} else if (
wizardData.value.downloader.type === 'transmission'
|| wizardData.value.downloader.type === 'rtorrent'
) {
if (!wizardData.value.downloader.config?.username?.trim()) {
@@ -1384,6 +1398,7 @@ export function useSetupWizard() {
LLM_SUPPORT_AUDIO_INPUT_OUTPUT: wizardData.value.agent.supportAudioInputOutput,
LLM_API_KEY: wizardData.value.agent.apiKey,
LLM_BASE_URL: wizardData.value.agent.baseUrl || null,
LLM_BASE_URL_PRESET: wizardData.value.agent.baseUrlPreset || null,
LLM_MAX_CONTEXT_TOKENS: wizardData.value.agent.maxContextTokens,
AI_VOICE_API_KEY: wizardData.value.agent.voiceApiKey || null,
AI_VOICE_BASE_URL: wizardData.value.agent.voiceBaseUrl || null,
@@ -1491,6 +1506,7 @@ export function useSetupWizard() {
wizardData.value.agent.supportAudioInputOutput = Boolean(result.data.LLM_SUPPORT_AUDIO_INPUT_OUTPUT)
wizardData.value.agent.apiKey = result.data.LLM_API_KEY || ''
wizardData.value.agent.baseUrl = result.data.LLM_BASE_URL || ''
wizardData.value.agent.baseUrlPreset = result.data.LLM_BASE_URL_PRESET || ''
wizardData.value.agent.maxContextTokens = result.data.LLM_MAX_CONTEXT_TOKENS || 64
wizardData.value.agent.voiceApiKey = result.data.AI_VOICE_API_KEY || ''
wizardData.value.agent.voiceBaseUrl = result.data.AI_VOICE_BASE_URL || ''

View File

@@ -2933,8 +2933,10 @@ export default {
rtorrentHostHint: 'HTTP: http://ip:port/RPC2 or SCGI: scgi://ip:port',
default: 'Default',
host: 'Host',
apiKey: 'API Key',
username: 'Username',
password: 'Password',
qbittorrentApiKeyHint: 'For qBittorrent 5.2+, you can use the WebUI API Key directly. When set, API Key auth is preferred.',
category: 'Auto Category Management',
sequentail: 'Sequential Download',
force_resume: 'Force Resume',

View File

@@ -2886,8 +2886,10 @@ export default {
rtorrentHostHint: 'HTTP: http://ip:port/RPC2 或 SCGI: scgi://ip:port',
default: '默认',
host: '地址',
apiKey: 'API Key',
username: '用户名',
password: '密码',
qbittorrentApiKeyHint: 'qBittorrent 5.2+ 可直接使用 WebUI API Key填写后将优先使用 API Key 登录。',
category: '自动分类管理',
sequentail: '顺序下载',
force_resume: '强制继续',

View File

@@ -2888,8 +2888,10 @@ export default {
enabled: '啟用',
default: '預設',
host: '地址',
apiKey: 'API Key',
username: '用戶名',
password: '密碼',
qbittorrentApiKeyHint: 'qBittorrent 5.2+ 可直接使用 WebUI API Key填寫後將優先使用 API Key 登入。',
category: '自動分類管理',
sequentail: '順序下載',
force_resume: '強制繼續',

View File

@@ -46,6 +46,7 @@ const SystemSettings = ref<any>({
LLM_SUPPORT_AUDIO_INPUT_OUTPUT: false,
LLM_API_KEY: null,
LLM_BASE_URL: 'https://api.deepseek.com',
LLM_BASE_URL_PRESET: null,
AI_VOICE_API_KEY: null,
AI_VOICE_BASE_URL: null,
AI_VOICE_STT_MODEL: 'gpt-4o-mini-transcribe',
@@ -179,6 +180,7 @@ type LlmSettingsSnapshot = {
LLM_THINKING_LEVEL: string
LLM_API_KEY: string
LLM_BASE_URL: string
LLM_BASE_URL_PRESET: string
}
let llmTestRequestId = 0
@@ -205,6 +207,13 @@ const llmBaseUrlRef = computed({
},
})
const llmBaseUrlPresetRef = computed({
get: () => String(SystemSettings.value.Basic.LLM_BASE_URL_PRESET ?? ''),
set: value => {
SystemSettings.value.Basic.LLM_BASE_URL_PRESET = value || ''
},
})
const llmModelRef = computed({
get: () => String(SystemSettings.value.Basic.LLM_MODEL ?? ''),
set: value => {
@@ -231,6 +240,7 @@ const {
showBaseUrlField,
showApiKeyField,
canRefreshModels,
setBaseUrlPreset,
authDialogVisible,
authPolling,
authPopupBlocked,
@@ -248,6 +258,7 @@ const {
provider: llmProviderRef,
apiKey: llmApiKeyRef,
baseUrl: llmBaseUrlRef,
baseUrlPreset: llmBaseUrlPresetRef,
model: llmModelRef,
maxContextTokens: llmMaxContextRef,
})
@@ -260,6 +271,7 @@ function buildLlmSnapshot(): LlmSettingsSnapshot {
LLM_THINKING_LEVEL: String(SystemSettings.value.Basic.LLM_THINKING_LEVEL ?? 'off'),
LLM_API_KEY: String(SystemSettings.value.Basic.LLM_API_KEY ?? ''),
LLM_BASE_URL: String(SystemSettings.value.Basic.LLM_BASE_URL ?? ''),
LLM_BASE_URL_PRESET: String(SystemSettings.value.Basic.LLM_BASE_URL_PRESET ?? ''),
}
}
@@ -275,6 +287,7 @@ function buildLlmTestPayload(snapshot: LlmSettingsSnapshot) {
thinking_level: snapshot.LLM_THINKING_LEVEL.trim(),
api_key: snapshot.LLM_API_KEY.trim(),
base_url: snapshot.LLM_BASE_URL.trim(),
base_url_preset: snapshot.LLM_BASE_URL_PRESET.trim(),
}
}
@@ -1016,7 +1029,11 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
<VCombobox
:model-value="SystemSettings.Basic.LLM_BASE_URL"
@update:model-value="(value: any) => {
SystemSettings.Basic.LLM_BASE_URL = typeof value === 'object' && value !== null ? value.value : (value || '');
if (typeof value === 'object' && value !== null) {
setBaseUrlPreset(value.id, value.value);
} else {
setBaseUrlPreset('', value || '');
}
}"
:label="t('setting.system.llmBaseUrl')"
:hint="t('setting.system.llmBaseUrlHint')"

View File

@@ -30,6 +30,13 @@ const baseUrlRef = computed({
},
})
const baseUrlPresetRef = computed({
get: () => wizardData.value.agent.baseUrlPreset,
set: value => {
wizardData.value.agent.baseUrlPreset = value || ''
},
})
const modelRef = computed({
get: () => wizardData.value.agent.model,
set: value => {
@@ -63,6 +70,7 @@ const {
showBaseUrlField,
showApiKeyField,
canRefreshModels,
setBaseUrlPreset,
authDialogVisible,
authPolling,
authPopupBlocked,
@@ -80,6 +88,7 @@ const {
provider: providerRef,
apiKey: apiKeyRef,
baseUrl: baseUrlRef,
baseUrlPreset: baseUrlPresetRef,
model: modelRef,
maxContextTokens: maxContextTokensRef,
authConnected: authConnectedRef,
@@ -232,7 +241,11 @@ onMounted(async () => {
<VCombobox
:model-value="wizardData.agent.baseUrl"
@update:model-value="(value: any) => {
wizardData.agent.baseUrl = typeof value === 'object' && value !== null ? value.value : (value || '');
if (typeof value === 'object' && value !== null) {
setBaseUrlPreset(value.id, value.value);
} else {
setBaseUrlPreset('', value || '');
}
}"
:label="t('setting.system.llmBaseUrl')"
:hint="t('setting.system.llmBaseUrlHint')"

View File

@@ -104,6 +104,17 @@ const { wizardData, selectDownloader, validationErrors } = useSetupWizard()
required
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.downloader.config.apikey"
type="password"
:label="t('downloader.apiKey')"
:hint="t('downloader.qbittorrentApiKeyHint')"
persistent-hint
active
prepend-inner-icon="mdi-key-variant"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model="wizardData.downloader.config.username"
@@ -111,10 +122,11 @@ const { wizardData, selectDownloader, validationErrors } = useSetupWizard()
:hint="t('downloader.username')"
:error="validationErrors.downloader.username"
:error-messages="validationErrors.downloader.username ? [t('downloader.usernameRequired')] : []"
:disabled="!!wizardData.downloader.config.apikey"
persistent-hint
active
prepend-inner-icon="mdi-account"
required
:required="!wizardData.downloader.config.apikey"
/>
</VCol>
<VCol cols="12" md="6">
@@ -125,10 +137,11 @@ const { wizardData, selectDownloader, validationErrors } = useSetupWizard()
:hint="t('downloader.password')"
:error="validationErrors.downloader.password"
:error-messages="validationErrors.downloader.password ? [t('downloader.passwordRequired')] : []"
:disabled="!!wizardData.downloader.config.apikey"
persistent-hint
active
prepend-inner-icon="mdi-lock"
required
:required="!wizardData.downloader.config.apikey"
/>
</VCol>
<VCol cols="12" md="6">