openlist离线下载增加额外源选择

This commit is contained in:
mtvpls
2026-05-22 00:24:44 +08:00
parent a24de1346f
commit 7012a79210
7 changed files with 286 additions and 94 deletions
+105
View File
@@ -3155,6 +3155,11 @@ const OpenListConfigComponent = ({
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [rootPaths, setRootPaths] = useState<string[]>(['/']); const [rootPaths, setRootPaths] = useState<string[]>(['/']);
const [offlineDownloadPath, setOfflineDownloadPath] = useState('/'); const [offlineDownloadPath, setOfflineDownloadPath] = useState('/');
const [offlineDownloadUseCustomSource, setOfflineDownloadUseCustomSource] =
useState(false);
const [offlineDownloadUrl, setOfflineDownloadUrl] = useState('');
const [offlineDownloadUsername, setOfflineDownloadUsername] = useState('');
const [offlineDownloadPassword, setOfflineDownloadPassword] = useState('');
const [scanInterval, setScanInterval] = useState(0); const [scanInterval, setScanInterval] = useState(0);
const [scanMode, setScanMode] = useState<'torrent' | 'name' | 'hybrid'>( const [scanMode, setScanMode] = useState<'torrent' | 'name' | 'hybrid'>(
'hybrid' 'hybrid'
@@ -3183,6 +3188,16 @@ const OpenListConfigComponent = ({
: ['/']) : ['/'])
); );
setOfflineDownloadPath(config.OpenListConfig.OfflineDownloadPath || '/'); setOfflineDownloadPath(config.OpenListConfig.OfflineDownloadPath || '/');
setOfflineDownloadUseCustomSource(
config.OpenListConfig.OfflineDownloadUseCustomSource || false
);
setOfflineDownloadUrl(config.OpenListConfig.OfflineDownloadURL || '');
setOfflineDownloadUsername(
config.OpenListConfig.OfflineDownloadUsername || ''
);
setOfflineDownloadPassword(
config.OpenListConfig.OfflineDownloadPassword || ''
);
setScanInterval(config.OpenListConfig.ScanInterval || 0); setScanInterval(config.OpenListConfig.ScanInterval || 0);
setScanMode(config.OpenListConfig.ScanMode || 'hybrid'); setScanMode(config.OpenListConfig.ScanMode || 'hybrid');
setDisableVideoPreview( setDisableVideoPreview(
@@ -3233,6 +3248,10 @@ const OpenListConfigComponent = ({
Password: password, Password: password,
RootPaths: rootPaths, RootPaths: rootPaths,
OfflineDownloadPath: offlineDownloadPath, OfflineDownloadPath: offlineDownloadPath,
OfflineDownloadUseCustomSource: offlineDownloadUseCustomSource,
OfflineDownloadURL: offlineDownloadUrl,
OfflineDownloadUsername: offlineDownloadUsername,
OfflineDownloadPassword: offlineDownloadPassword,
ScanInterval: scanInterval, ScanInterval: scanInterval,
ScanMode: scanMode, ScanMode: scanMode,
DisableVideoPreview: disableVideoPreview, DisableVideoPreview: disableVideoPreview,
@@ -3592,6 +3611,92 @@ const OpenListConfigComponent = ({
</p> </p>
</div> </div>
<div className='space-y-4 rounded-lg border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800'>
<div className='flex items-center justify-between'>
<div>
<h3 className='text-sm font-medium text-gray-900 dark:text-gray-100'>
线使 OpenList
</h3>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
OpenList使 OpenList
</p>
</div>
<button
type='button'
onClick={() =>
setOfflineDownloadUseCustomSource(
!offlineDownloadUseCustomSource
)
}
disabled={!enabled}
className={`relative inline-flex h-6 w-11 shrink-0 items-center rounded-full transition-colors ${
offlineDownloadUseCustomSource
? 'bg-blue-600'
: 'bg-gray-200 dark:bg-gray-700'
} ${!enabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
offlineDownloadUseCustomSource
? 'translate-x-6'
: 'translate-x-1'
}`}
/>
</button>
</div>
{offlineDownloadUseCustomSource && (
<div className='space-y-4'>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
线 OpenList URL
</label>
<input
type='text'
value={offlineDownloadUrl}
onChange={(e) => setOfflineDownloadUrl(e.target.value)}
disabled={!enabled}
placeholder='https://download-openlist-server.com'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
/>
</div>
<div className='grid grid-cols-2 gap-4'>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
线
</label>
<input
type='text'
value={offlineDownloadUsername}
onChange={(e) =>
setOfflineDownloadUsername(e.target.value)
}
disabled={!enabled}
placeholder='admin'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
/>
</div>
<div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
线
</label>
<input
type='password'
value={offlineDownloadPassword}
onChange={(e) =>
setOfflineDownloadPassword(e.target.value)
}
disabled={!enabled}
placeholder='password'
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
/>
</div>
</div>
</div>
)}
</div>
<div> <div>
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'> <label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
+9 -48
View File
@@ -3,7 +3,11 @@ import { NextRequest, NextResponse } from 'next/server';
import { getAuthInfoFromCookie } from '@/lib/auth'; import { getAuthInfoFromCookie } from '@/lib/auth';
import { getConfig } from '@/lib/config'; import { getConfig } from '@/lib/config';
import { OpenListClient } from '@/lib/openlist.client'; import {
addOpenListOfflineDownload,
getOfflineDownloadBasePath,
joinOpenListPath,
} from '@/lib/openlist-offline-download';
import { hasFeaturePermission } from '@/lib/permissions'; import { hasFeaturePermission } from '@/lib/permissions';
export const runtime = 'nodejs'; export const runtime = 'nodejs';
@@ -55,56 +59,13 @@ export async function POST(req: NextRequest) {
// 获取 OpenList 配置 // 获取 OpenList 配置
const config = await getConfig(); const config = await getConfig();
const openlistConfig = config.OpenListConfig;
if (!openlistConfig?.Enabled) {
return NextResponse.json(
{ error: '私人影库功能未启用' },
{ status: 400 }
);
}
if (!openlistConfig.URL || !openlistConfig.Username || !openlistConfig.Password) {
return NextResponse.json(
{ error: 'OpenList 配置不完整' },
{ status: 400 }
);
}
// 构建下载路径(使用离线下载目录) // 构建下载路径(使用离线下载目录)
const offlineDownloadPath = openlistConfig.OfflineDownloadPath || '/'; const downloadPath = joinOpenListPath(
const downloadPath = `${offlineDownloadPath.replace(/\/$/, '')}/${name}`; getOfflineDownloadBasePath(config),
name
// 使用 OpenListClient 添加离线下载任务
const client = new OpenListClient(
openlistConfig.URL,
openlistConfig.Username,
openlistConfig.Password
); );
await addOpenListOfflineDownload(config, downloadPath, url, tool);
// 获取 Token 并调用 API
const token = await (client as any).getToken();
const openlistUrl = `${openlistConfig.URL.replace(/\/$/, '')}/api/fs/add_offline_download`;
const response = await fetch(openlistUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': token,
},
body: JSON.stringify({
path: downloadPath,
urls: [url],
tool,
}),
});
const data = await response.json();
// 检查响应状态
if (!response.ok || data.code !== 200) {
throw new Error(data.message || '添加离线下载任务失败');
}
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
+52 -1
View File
@@ -45,7 +45,22 @@ export async function POST(request: NextRequest) {
try { try {
const body = await request.json(); const body = await request.json();
const { action, Enabled, URL, Username, Password, RootPaths, OfflineDownloadPath, ScanInterval, ScanMode, DisableVideoPreview } = body; const {
action,
Enabled,
URL,
Username,
Password,
RootPaths,
OfflineDownloadPath,
OfflineDownloadUseCustomSource,
OfflineDownloadURL,
OfflineDownloadUsername,
OfflineDownloadPassword,
ScanInterval,
ScanMode,
DisableVideoPreview,
} = body;
const authInfo = getAuthInfoFromCookie(request); const authInfo = getAuthInfoFromCookie(request);
if (!authInfo || !authInfo.username) { if (!authInfo || !authInfo.username) {
@@ -74,6 +89,10 @@ export async function POST(request: NextRequest) {
Password: Password || '', Password: Password || '',
RootPaths: RootPaths || ['/'], RootPaths: RootPaths || ['/'],
OfflineDownloadPath: OfflineDownloadPath || '/', OfflineDownloadPath: OfflineDownloadPath || '/',
OfflineDownloadUseCustomSource: OfflineDownloadUseCustomSource || false,
OfflineDownloadURL: OfflineDownloadURL || '',
OfflineDownloadUsername: OfflineDownloadUsername || '',
OfflineDownloadPassword: OfflineDownloadPassword || '',
LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime, LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime,
ResourceCount: adminConfig.OpenListConfig?.ResourceCount, ResourceCount: adminConfig.OpenListConfig?.ResourceCount,
ScanInterval: 0, ScanInterval: 0,
@@ -97,6 +116,16 @@ export async function POST(request: NextRequest) {
); );
} }
if (
OfflineDownloadUseCustomSource &&
(!OfflineDownloadURL || !OfflineDownloadUsername || !OfflineDownloadPassword)
) {
return NextResponse.json(
{ error: '请提供离线下载 OpenList URL、账号和密码' },
{ status: 400 }
);
}
// 验证 RootPaths // 验证 RootPaths
if (!Array.isArray(RootPaths) || RootPaths.length === 0) { if (!Array.isArray(RootPaths) || RootPaths.length === 0) {
return NextResponse.json( return NextResponse.json(
@@ -130,6 +159,24 @@ export async function POST(request: NextRequest) {
); );
} }
if (OfflineDownloadUseCustomSource) {
try {
console.log('[OpenList Config] 验证离线下载 OpenList 账号密码');
await OpenListClient.login(
OfflineDownloadURL,
OfflineDownloadUsername,
OfflineDownloadPassword
);
console.log('[OpenList Config] 离线下载 OpenList 账号密码验证成功');
} catch (error) {
console.error('[OpenList Config] 离线下载 OpenList 账号密码验证失败:', error);
return NextResponse.json(
{ error: '离线下载 OpenList 账号密码验证失败: ' + (error as Error).message },
{ status: 400 }
);
}
}
adminConfig.OpenListConfig = { adminConfig.OpenListConfig = {
Enabled: true, Enabled: true,
URL, URL,
@@ -137,6 +184,10 @@ export async function POST(request: NextRequest) {
Password, Password,
RootPaths: cleanedRootPaths, RootPaths: cleanedRootPaths,
OfflineDownloadPath: OfflineDownloadPath || '/', OfflineDownloadPath: OfflineDownloadPath || '/',
OfflineDownloadUseCustomSource: OfflineDownloadUseCustomSource || false,
OfflineDownloadURL: OfflineDownloadURL || '',
OfflineDownloadUsername: OfflineDownloadUsername || '',
OfflineDownloadPassword: OfflineDownloadPassword || '',
LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime, LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime,
ResourceCount: adminConfig.OpenListConfig?.ResourceCount, ResourceCount: adminConfig.OpenListConfig?.ResourceCount,
ScanInterval: scanInterval, ScanInterval: scanInterval,
+4
View File
@@ -141,6 +141,10 @@ export interface AdminConfig {
RootPath?: string; // 旧字段:根目录路径(向后兼容,迁移后删除) RootPath?: string; // 旧字段:根目录路径(向后兼容,迁移后删除)
RootPaths?: string[]; // 新字段:多根目录路径列表 RootPaths?: string[]; // 新字段:多根目录路径列表
OfflineDownloadPath: string; // 离线下载目录,默认 "/" OfflineDownloadPath: string; // 离线下载目录,默认 "/"
OfflineDownloadUseCustomSource?: boolean; // 离线下载是否使用独立 OpenList 源
OfflineDownloadURL?: string; // 独立离线下载 OpenList 服务器地址
OfflineDownloadUsername?: string; // 独立离线下载 OpenList 账号
OfflineDownloadPassword?: string; // 独立离线下载 OpenList 密码
LastRefreshTime?: number; // 上次刷新时间戳 LastRefreshTime?: number; // 上次刷新时间戳
ResourceCount?: number; // 资源数量 ResourceCount?: number; // 资源数量
ScanInterval?: number; // 定时扫描间隔(分钟),0表示关闭,最低60分钟 ScanInterval?: number; // 定时扫描间隔(分钟),0表示关闭,最低60分钟
+11 -45
View File
@@ -5,7 +5,11 @@ import { parseStringPromise } from 'xml2js';
import { getConfig, setCachedConfig } from '@/lib/config'; import { getConfig, setCachedConfig } from '@/lib/config';
import { db, getStorage } from '@/lib/db'; import { db, getStorage } from '@/lib/db';
import { EmailService } from '@/lib/email.service'; import { EmailService } from '@/lib/email.service';
import { OpenListClient } from '@/lib/openlist.client'; import {
addOpenListOfflineDownload,
getOfflineDownloadBasePath,
joinOpenListPath,
} from '@/lib/openlist-offline-download';
import { AnimeSubscription, AnimeSubscriptionDownloadTool } from '@/types/anime-subscription'; import { AnimeSubscription, AnimeSubscriptionDownloadTool } from '@/types/anime-subscription';
const downloadTools: AnimeSubscriptionDownloadTool[] = ['aria2', 'qBittorrent', 'Transmission']; const downloadTools: AnimeSubscriptionDownloadTool[] = ['aria2', 'qBittorrent', 'Transmission'];
@@ -128,50 +132,11 @@ export async function addOfflineDownload(
downloadPath: string downloadPath: string
) { ) {
const config = await getConfig(); const config = await getConfig();
const openlistConfig = config.OpenListConfig;
const downloadTool = getAnimeSubscriptionDownloadTool( const downloadTool = getAnimeSubscriptionDownloadTool(
config.AnimeSubscriptionConfig?.DownloadTool config.AnimeSubscriptionConfig?.DownloadTool
); );
if (!openlistConfig?.Enabled) { await addOpenListOfflineDownload(config, downloadPath, torrentUrl, downloadTool);
throw new Error('私人影库功能未启用');
}
if (
!openlistConfig.URL ||
!openlistConfig.Username ||
!openlistConfig.Password
) {
throw new Error('OpenList 配置不完整');
}
const client = new OpenListClient(
openlistConfig.URL,
openlistConfig.Username,
openlistConfig.Password
);
const token = await (client as any).getToken();
const openlistUrl = `${openlistConfig.URL.replace(/\/$/, '')}/api/fs/add_offline_download`;
const response = await fetch(openlistUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: token,
},
body: JSON.stringify({
path: downloadPath,
urls: [torrentUrl],
tool: downloadTool,
}),
});
const data = await response.json();
if (!response.ok || data.code !== 200) {
throw new Error(data.message || '添加离线下载任务失败');
}
} }
/** /**
@@ -306,9 +271,7 @@ async function sendAnimeUpdateNotifications(
*/ */
export async function checkSubscription(subscription: AnimeSubscription) { export async function checkSubscription(subscription: AnimeSubscription) {
const config = await getConfig(); const config = await getConfig();
const openlistConfig = config.OpenListConfig; if (!config.OpenListConfig?.OfflineDownloadPath) {
if (!openlistConfig?.OfflineDownloadPath) {
throw new Error('OpenList 离线下载路径未配置'); throw new Error('OpenList 离线下载路径未配置');
} }
@@ -329,7 +292,10 @@ export async function checkSubscription(subscription: AnimeSubscription) {
const downloaded = []; const downloaded = [];
for (const item of newEpisodes) { for (const item of newEpisodes) {
try { try {
const downloadPath = `${openlistConfig.OfflineDownloadPath.replace(/\/$/, '')}/${subscription.title}`; const downloadPath = joinOpenListPath(
getOfflineDownloadBasePath(config),
subscription.title
);
await addOfflineDownload(item.torrentUrl, downloadPath); await addOfflineDownload(item.torrentUrl, downloadPath);
// 成功后更新 lastEpisode // 成功后更新 lastEpisode
+23
View File
@@ -558,6 +558,29 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
} }
adminConfig.LiveRefreshIntervalHours = normalizeLiveRefreshIntervalHours(adminConfig.LiveRefreshIntervalHours); adminConfig.LiveRefreshIntervalHours = normalizeLiveRefreshIntervalHours(adminConfig.LiveRefreshIntervalHours);
if (adminConfig.OpenListConfig) {
if (!adminConfig.OpenListConfig.RootPaths) {
adminConfig.OpenListConfig.RootPaths = adminConfig.OpenListConfig.RootPath
? [adminConfig.OpenListConfig.RootPath]
: ['/'];
}
if (!adminConfig.OpenListConfig.OfflineDownloadPath) {
adminConfig.OpenListConfig.OfflineDownloadPath = '/';
}
if (adminConfig.OpenListConfig.OfflineDownloadUseCustomSource === undefined) {
adminConfig.OpenListConfig.OfflineDownloadUseCustomSource = false;
}
if (adminConfig.OpenListConfig.OfflineDownloadURL === undefined) {
adminConfig.OpenListConfig.OfflineDownloadURL = '';
}
if (adminConfig.OpenListConfig.OfflineDownloadUsername === undefined) {
adminConfig.OpenListConfig.OfflineDownloadUsername = '';
}
if (adminConfig.OpenListConfig.OfflineDownloadPassword === undefined) {
adminConfig.OpenListConfig.OfflineDownloadPassword = '';
}
}
// 用户信息已迁移到新版数据库 // 用户信息已迁移到新版数据库
// 这里只保留站长用户用于兼容性,其他用户从数据库读取 // 这里只保留站长用户用于兼容性,其他用户从数据库读取
const ownerUser = process.env.USERNAME; const ownerUser = process.env.USERNAME;
+82
View File
@@ -0,0 +1,82 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { AdminConfig } from '@/lib/admin.types';
import { OpenListClient } from '@/lib/openlist.client';
type OpenListOfflineDownloadSource = {
url: string;
username: string;
password: string;
};
function getOfflineDownloadSource(config: AdminConfig): OpenListOfflineDownloadSource {
const openlistConfig = config.OpenListConfig;
if (!openlistConfig?.Enabled) {
throw new Error('私人影库功能未启用');
}
const useCustomSource = openlistConfig.OfflineDownloadUseCustomSource === true;
const source = useCustomSource
? {
url: openlistConfig.OfflineDownloadURL || '',
username: openlistConfig.OfflineDownloadUsername || '',
password: openlistConfig.OfflineDownloadPassword || '',
}
: {
url: openlistConfig.URL,
username: openlistConfig.Username,
password: openlistConfig.Password,
};
if (!source.url || !source.username || !source.password) {
throw new Error(
useCustomSource
? '离线下载 OpenList 配置不完整'
: 'OpenList 配置不完整'
);
}
return source;
}
export function getOfflineDownloadBasePath(config: AdminConfig): string {
const path = config.OpenListConfig?.OfflineDownloadPath || '/';
const normalizedPath = path.replace(/\/$/, '');
return normalizedPath || '/';
}
export function joinOpenListPath(basePath: string, name: string): string {
return basePath === '/' ? `/${name}` : `${basePath}/${name}`;
}
export async function addOpenListOfflineDownload(
config: AdminConfig,
downloadPath: string,
url: string,
tool: string
) {
const source = getOfflineDownloadSource(config);
const client = new OpenListClient(source.url, source.username, source.password);
const token = await (client as any).getToken();
const openlistUrl = `${source.url.replace(/\/$/, '')}/api/fs/add_offline_download`;
const response = await fetch(openlistUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: token,
},
body: JSON.stringify({
path: downloadPath,
urls: [url],
tool,
}),
});
const data = await response.json();
if (!response.ok || data.code !== 200) {
throw new Error(data.message || '添加离线下载任务失败');
}
}