Merge branch 'dev'
This commit is contained in:
@@ -44,11 +44,13 @@
|
|||||||
"lucide-react": "^0.438.0",
|
"lucide-react": "^0.438.0",
|
||||||
"media-icons": "^1.1.5",
|
"media-icons": "^1.1.5",
|
||||||
"mux.js": "^6.3.0",
|
"mux.js": "^6.3.0",
|
||||||
|
"nanoid": "^5.1.6",
|
||||||
"next": "^14.2.33",
|
"next": "^14.2.33",
|
||||||
"next-pwa": "^5.6.0",
|
"next-pwa": "^5.6.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"node-fetch": "^2.7.0",
|
"node-fetch": "^2.7.0",
|
||||||
"nprogress": "^0.2.0",
|
"nprogress": "^0.2.0",
|
||||||
|
"opencc-js": "^1.0.5",
|
||||||
"parse-torrent-name": "^0.5.4",
|
"parse-torrent-name": "^0.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
|||||||
Generated
+18
@@ -80,6 +80,9 @@ importers:
|
|||||||
mux.js:
|
mux.js:
|
||||||
specifier: ^6.3.0
|
specifier: ^6.3.0
|
||||||
version: 6.3.0
|
version: 6.3.0
|
||||||
|
nanoid:
|
||||||
|
specifier: ^5.1.6
|
||||||
|
version: 5.1.6
|
||||||
next:
|
next:
|
||||||
specifier: ^14.2.33
|
specifier: ^14.2.33
|
||||||
version: 14.2.35(@babel/[email protected])([email protected]([email protected]))([email protected])
|
version: 14.2.35(@babel/[email protected])([email protected]([email protected]))([email protected])
|
||||||
@@ -95,6 +98,9 @@ importers:
|
|||||||
nprogress:
|
nprogress:
|
||||||
specifier: ^0.2.0
|
specifier: ^0.2.0
|
||||||
version: 0.2.0
|
version: 0.2.0
|
||||||
|
opencc-js:
|
||||||
|
specifier: ^1.0.5
|
||||||
|
version: 1.0.5
|
||||||
parse-torrent-name:
|
parse-torrent-name:
|
||||||
specifier: ^0.5.4
|
specifier: ^0.5.4
|
||||||
version: 0.5.4
|
version: 0.5.4
|
||||||
@@ -4420,6 +4426,11 @@ packages:
|
|||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==}
|
||||||
|
engines: {node: ^18 || >=20}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
|
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
|
||||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||||
@@ -4562,6 +4573,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
|
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
[email protected]:
|
||||||
|
resolution: {integrity: sha512-LD+1SoNnZdlRwtYTjnQdFrSVCAaYpuDqL5CkmOaHOkKoKh7mFxUicLTRVNLU5C+Jmi1vXQ3QL4jWdgSaa4sKjg==}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-tmZDan2LRIRQyhUGvkff68/O0R8UmF+Btmiiz0SmSw2ng3CfPZB9wJlIjHpe/MKUZqyIZkVIXCrwr1tIN+0Dzg==}
|
resolution: {integrity: sha512-tmZDan2LRIRQyhUGvkff68/O0R8UmF+Btmiiz0SmSw2ng3CfPZB9wJlIjHpe/MKUZqyIZkVIXCrwr1tIN+0Dzg==}
|
||||||
|
|
||||||
@@ -11386,6 +11400,8 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
@@ -11547,6 +11563,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
mimic-fn: 2.1.0
|
mimic-fn: 2.1.0
|
||||||
|
|
||||||
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
kind-of: 6.0.3
|
kind-of: 6.0.3
|
||||||
|
|||||||
+758
-68
@@ -390,6 +390,7 @@ interface CollapsibleTabProps {
|
|||||||
isExpanded: boolean;
|
isExpanded: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
isParent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CollapsibleTab = ({
|
const CollapsibleTab = ({
|
||||||
@@ -398,25 +399,38 @@ const CollapsibleTab = ({
|
|||||||
isExpanded,
|
isExpanded,
|
||||||
onToggle,
|
onToggle,
|
||||||
children,
|
children,
|
||||||
|
isParent = false,
|
||||||
}: CollapsibleTabProps) => {
|
}: CollapsibleTabProps) => {
|
||||||
return (
|
return (
|
||||||
<div className='rounded-xl shadow-sm mb-4 overflow-hidden bg-white/80 backdrop-blur-md dark:bg-gray-800/50 dark:ring-1 dark:ring-gray-700'>
|
<div className={`rounded-xl shadow-sm mb-4 overflow-hidden ${
|
||||||
|
isParent
|
||||||
|
? 'bg-gradient-to-r from-yellow-50 to-amber-50 dark:from-yellow-900/20 dark:to-amber-900/20 ring-2 ring-yellow-400/50 dark:ring-yellow-600/50'
|
||||||
|
: 'bg-white/80 backdrop-blur-md dark:bg-gray-800/50 dark:ring-1 dark:ring-gray-700'
|
||||||
|
}`}>
|
||||||
<button
|
<button
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
className='w-full px-6 py-4 flex items-center justify-between bg-gray-50/70 dark:bg-gray-800/60 hover:bg-gray-100/80 dark:hover:bg-gray-700/60 transition-colors'
|
className={`w-full px-6 py-4 flex items-center justify-between transition-colors ${
|
||||||
|
isParent
|
||||||
|
? 'bg-yellow-100/50 dark:bg-yellow-900/30 hover:bg-yellow-100/70 dark:hover:bg-yellow-900/40'
|
||||||
|
: 'bg-gray-50/70 dark:bg-gray-800/60 hover:bg-gray-100/80 dark:hover:bg-gray-700/60'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
<div className='flex items-center gap-3'>
|
<div className='flex items-center gap-3'>
|
||||||
{icon}
|
{icon}
|
||||||
<h3 className='text-lg font-medium text-gray-900 dark:text-gray-100'>
|
<h3 className={`text-lg font-medium ${
|
||||||
|
isParent
|
||||||
|
? 'text-yellow-900 dark:text-yellow-200'
|
||||||
|
: 'text-gray-900 dark:text-gray-100'
|
||||||
|
}`}>
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className='text-gray-500 dark:text-gray-400'>
|
<div className={isParent ? 'text-yellow-700 dark:text-yellow-400' : 'text-gray-500 dark:text-gray-400'}>
|
||||||
{isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
|
{isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isExpanded && <div className='px-6 py-4'>{children}</div>}
|
{isExpanded && <div className={isParent ? 'px-0.5 md:px-6 py-4' : 'px-6 py-4'}>{children}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -2763,7 +2777,7 @@ const OpenListConfigComponent = ({
|
|||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [rootPath, setRootPath] = useState('/');
|
const [rootPaths, setRootPaths] = useState<string[]>(['/']);
|
||||||
const [offlineDownloadPath, setOfflineDownloadPath] = useState('/');
|
const [offlineDownloadPath, setOfflineDownloadPath] = useState('/');
|
||||||
const [scanInterval, setScanInterval] = useState(0);
|
const [scanInterval, setScanInterval] = useState(0);
|
||||||
const [scanMode, setScanMode] = useState<'torrent' | 'name' | 'hybrid'>('hybrid');
|
const [scanMode, setScanMode] = useState<'torrent' | 'name' | 'hybrid'>('hybrid');
|
||||||
@@ -2783,7 +2797,7 @@ const OpenListConfigComponent = ({
|
|||||||
setUrl(config.OpenListConfig.URL || '');
|
setUrl(config.OpenListConfig.URL || '');
|
||||||
setUsername(config.OpenListConfig.Username || '');
|
setUsername(config.OpenListConfig.Username || '');
|
||||||
setPassword(config.OpenListConfig.Password || '');
|
setPassword(config.OpenListConfig.Password || '');
|
||||||
setRootPath(config.OpenListConfig.RootPath || '/');
|
setRootPaths(config.OpenListConfig.RootPaths || (config.OpenListConfig.RootPath ? [config.OpenListConfig.RootPath] : ['/']));
|
||||||
setOfflineDownloadPath(config.OpenListConfig.OfflineDownloadPath || '/');
|
setOfflineDownloadPath(config.OpenListConfig.OfflineDownloadPath || '/');
|
||||||
setScanInterval(config.OpenListConfig.ScanInterval || 0);
|
setScanInterval(config.OpenListConfig.ScanInterval || 0);
|
||||||
setScanMode(config.OpenListConfig.ScanMode || 'hybrid');
|
setScanMode(config.OpenListConfig.ScanMode || 'hybrid');
|
||||||
@@ -2824,7 +2838,7 @@ const OpenListConfigComponent = ({
|
|||||||
URL: url,
|
URL: url,
|
||||||
Username: username,
|
Username: username,
|
||||||
Password: password,
|
Password: password,
|
||||||
RootPath: rootPath,
|
RootPaths: rootPaths,
|
||||||
OfflineDownloadPath: offlineDownloadPath,
|
OfflineDownloadPath: offlineDownloadPath,
|
||||||
ScanInterval: scanInterval,
|
ScanInterval: scanInterval,
|
||||||
ScanMode: scanMode,
|
ScanMode: scanMode,
|
||||||
@@ -3104,18 +3118,49 @@ const OpenListConfigComponent = ({
|
|||||||
|
|
||||||
<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'>
|
||||||
根目录
|
根目录列表
|
||||||
</label>
|
</label>
|
||||||
<input
|
<div className='space-y-2'>
|
||||||
type='text'
|
{rootPaths.map((path, index) => (
|
||||||
value={rootPath}
|
<div key={index} className='flex gap-2'>
|
||||||
onChange={(e) => setRootPath(e.target.value)}
|
<input
|
||||||
disabled={!enabled}
|
type='text'
|
||||||
placeholder='/'
|
value={path}
|
||||||
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'
|
onChange={(e) => {
|
||||||
/>
|
const newPaths = [...rootPaths];
|
||||||
|
newPaths[index] = e.target.value;
|
||||||
|
setRootPaths(newPaths);
|
||||||
|
}}
|
||||||
|
disabled={!enabled}
|
||||||
|
placeholder='/'
|
||||||
|
className='flex-1 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'
|
||||||
|
/>
|
||||||
|
{rootPaths.length > 1 && (
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => {
|
||||||
|
const newPaths = rootPaths.filter((_, i) => i !== index);
|
||||||
|
setRootPaths(newPaths);
|
||||||
|
}}
|
||||||
|
disabled={!enabled}
|
||||||
|
className='px-3 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type='button'
|
||||||
|
onClick={() => setRootPaths([...rootPaths, '/'])}
|
||||||
|
disabled={!enabled}
|
||||||
|
className='w-full px-3 py-2 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg text-gray-600 dark:text-gray-400 hover:border-blue-500 hover:text-blue-500 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||||
|
>
|
||||||
|
+ 添加根目录
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
OpenList 中的视频文件夹路径,默认为根目录 /
|
OpenList 中的视频文件夹路径,可以配置多个根目录
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -3414,6 +3459,7 @@ const EmbyConfigComponent = ({
|
|||||||
const [sources, setSources] = useState<any[]>([]);
|
const [sources, setSources] = useState<any[]>([]);
|
||||||
const [editingSource, setEditingSource] = useState<any | null>(null);
|
const [editingSource, setEditingSource] = useState<any | null>(null);
|
||||||
const [showAddForm, setShowAddForm] = useState(false);
|
const [showAddForm, setShowAddForm] = useState(false);
|
||||||
|
const [selectedSources, setSelectedSources] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
// 表单状态
|
// 表单状态
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -3663,6 +3709,81 @@ const EmbyConfigComponent = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 批量启用
|
||||||
|
const handleBatchEnable = async () => {
|
||||||
|
if (selectedSources.size === 0) return;
|
||||||
|
await withLoading('batchEnableEmby', async () => {
|
||||||
|
try {
|
||||||
|
const newSources = sources.map(s =>
|
||||||
|
selectedSources.has(s.key) ? { ...s, enabled: true } : s
|
||||||
|
);
|
||||||
|
const response = await fetch('/api/admin/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ...config, EmbyConfig: { Sources: newSources } }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('批量启用失败');
|
||||||
|
await refreshConfig();
|
||||||
|
setSelectedSources(new Set());
|
||||||
|
showSuccess(`已启用 ${selectedSources.size} 个源`, showAlert);
|
||||||
|
} catch (error) {
|
||||||
|
showError(error instanceof Error ? error.message : '批量启用失败', showAlert);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 批量禁用
|
||||||
|
const handleBatchDisable = async () => {
|
||||||
|
if (selectedSources.size === 0) return;
|
||||||
|
await withLoading('batchDisableEmby', async () => {
|
||||||
|
try {
|
||||||
|
const newSources = sources.map(s =>
|
||||||
|
selectedSources.has(s.key) ? { ...s, enabled: false } : s
|
||||||
|
);
|
||||||
|
const response = await fetch('/api/admin/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ...config, EmbyConfig: { Sources: newSources } }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('批量禁用失败');
|
||||||
|
await refreshConfig();
|
||||||
|
setSelectedSources(new Set());
|
||||||
|
showSuccess(`已禁用 ${selectedSources.size} 个源`, showAlert);
|
||||||
|
} catch (error) {
|
||||||
|
showError(error instanceof Error ? error.message : '批量禁用失败', showAlert);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// 批量删除
|
||||||
|
const handleBatchDelete = async () => {
|
||||||
|
if (selectedSources.size === 0) return;
|
||||||
|
showAlert({
|
||||||
|
type: 'warning',
|
||||||
|
title: '确认批量删除',
|
||||||
|
message: `确定要删除选中的 ${selectedSources.size} 个源吗?此操作不可恢复。`,
|
||||||
|
showConfirm: true,
|
||||||
|
onConfirm: async () => {
|
||||||
|
await withLoading('batchDeleteEmby', async () => {
|
||||||
|
try {
|
||||||
|
const newSources = sources.filter(s => !selectedSources.has(s.key));
|
||||||
|
const response = await fetch('/api/admin/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ...config, EmbyConfig: { Sources: newSources } }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('批量删除失败');
|
||||||
|
await refreshConfig();
|
||||||
|
setSelectedSources(new Set());
|
||||||
|
showSuccess(`已删除 ${selectedSources.size} 个源`, showAlert);
|
||||||
|
} catch (error) {
|
||||||
|
showError(error instanceof Error ? error.message : '批量删除失败', showAlert);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='space-y-6'>
|
<div className='space-y-6'>
|
||||||
<AlertModal
|
<AlertModal
|
||||||
@@ -3682,14 +3803,51 @@ const EmbyConfigComponent = ({
|
|||||||
<h3 className='text-lg font-medium text-gray-900 dark:text-gray-100'>
|
<h3 className='text-lg font-medium text-gray-900 dark:text-gray-100'>
|
||||||
Emby 源列表 ({sources.length})
|
Emby 源列表 ({sources.length})
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<div className='flex gap-2'>
|
||||||
onClick={handleAdd}
|
<button
|
||||||
className={buttonStyles.success}
|
onClick={handleAdd}
|
||||||
>
|
className={buttonStyles.success}
|
||||||
添加新源
|
>
|
||||||
</button>
|
添加新源
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedSources.size > 0 && (
|
||||||
|
<div className='flex items-center gap-2 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg'>
|
||||||
|
<span className='text-sm text-gray-700 dark:text-gray-300'>
|
||||||
|
已选择 {selectedSources.size} 项
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleBatchEnable}
|
||||||
|
disabled={isLoading('batchEnableEmby')}
|
||||||
|
className={buttonStyles.successSmall}
|
||||||
|
>
|
||||||
|
批量启用
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleBatchDisable}
|
||||||
|
disabled={isLoading('batchDisableEmby')}
|
||||||
|
className={buttonStyles.warningSmall}
|
||||||
|
>
|
||||||
|
批量禁用
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleBatchDelete}
|
||||||
|
disabled={isLoading('batchDeleteEmby')}
|
||||||
|
className={buttonStyles.dangerSmall}
|
||||||
|
>
|
||||||
|
批量删除
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedSources(new Set())}
|
||||||
|
className={buttonStyles.secondarySmall}
|
||||||
|
>
|
||||||
|
取消选择
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{sources.length === 0 ? (
|
{sources.length === 0 ? (
|
||||||
<div className='text-center py-8 text-gray-500 dark:text-gray-400'>
|
<div className='text-center py-8 text-gray-500 dark:text-gray-400'>
|
||||||
暂无Emby源,点击"添加新源"开始配置
|
暂无Emby源,点击"添加新源"开始配置
|
||||||
@@ -3701,37 +3859,53 @@ const EmbyConfigComponent = ({
|
|||||||
className='border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-800'
|
className='border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-white dark:bg-gray-800'
|
||||||
>
|
>
|
||||||
<div className='flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3'>
|
<div className='flex flex-col sm:flex-row sm:items-start sm:justify-between gap-3'>
|
||||||
<div className='flex-1'>
|
<div className='flex items-center gap-3 flex-1'>
|
||||||
<div className='flex items-center gap-3 flex-wrap'>
|
<input
|
||||||
<h4 className='text-base font-medium text-gray-900 dark:text-gray-100'>
|
type='checkbox'
|
||||||
{source.name}
|
checked={selectedSources.has(source.key)}
|
||||||
</h4>
|
onChange={(e) => {
|
||||||
{source.isDefault && (
|
const newSelected = new Set(selectedSources);
|
||||||
<span className='px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200 rounded'>
|
if (e.target.checked) {
|
||||||
默认
|
newSelected.add(source.key);
|
||||||
|
} else {
|
||||||
|
newSelected.delete(source.key);
|
||||||
|
}
|
||||||
|
setSelectedSources(newSelected);
|
||||||
|
}}
|
||||||
|
className='w-4 h-4 text-blue-600 rounded border-gray-300 dark:border-gray-600'
|
||||||
|
/>
|
||||||
|
<div className='flex-1'>
|
||||||
|
<div className='flex items-center gap-3 flex-wrap'>
|
||||||
|
<h4 className='text-base font-medium text-gray-900 dark:text-gray-100'>
|
||||||
|
{source.name}
|
||||||
|
</h4>
|
||||||
|
{source.isDefault && (
|
||||||
|
<span className='px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-200 rounded'>
|
||||||
|
默认
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 text-xs font-medium rounded ${
|
||||||
|
source.enabled
|
||||||
|
? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-200'
|
||||||
|
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{source.enabled ? '已启用' : '已禁用'}
|
||||||
</span>
|
</span>
|
||||||
)}
|
</div>
|
||||||
<span
|
|
||||||
className={`px-2 py-0.5 text-xs font-medium rounded ${
|
|
||||||
source.enabled
|
|
||||||
? 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-200'
|
|
||||||
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{source.enabled ? '已启用' : '已禁用'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className='mt-1 text-sm text-gray-600 dark:text-gray-400'>
|
|
||||||
标识符: {source.key}
|
|
||||||
</p>
|
|
||||||
<p className='mt-1 text-sm text-gray-600 dark:text-gray-400'>
|
|
||||||
服务器: {source.ServerURL}
|
|
||||||
</p>
|
|
||||||
{source.UserId && (
|
|
||||||
<p className='mt-1 text-sm text-gray-600 dark:text-gray-400'>
|
<p className='mt-1 text-sm text-gray-600 dark:text-gray-400'>
|
||||||
用户ID: {source.UserId}
|
标识符: {source.key}
|
||||||
</p>
|
</p>
|
||||||
)}
|
<p className='mt-1 text-sm text-gray-600 dark:text-gray-400'>
|
||||||
|
服务器: {source.ServerURL}
|
||||||
|
</p>
|
||||||
|
{source.UserId && (
|
||||||
|
<p className='mt-1 text-sm text-gray-600 dark:text-gray-400'>
|
||||||
|
用户ID: {source.UserId}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className='flex gap-2 flex-wrap sm:flex-nowrap'>
|
<div className='flex gap-2 flex-wrap sm:flex-nowrap'>
|
||||||
<button
|
<button
|
||||||
@@ -8342,6 +8516,483 @@ const CustomAdFilterConfig = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 小雅配置组件
|
||||||
|
const XiaoyaConfigComponent = ({
|
||||||
|
config,
|
||||||
|
refreshConfig,
|
||||||
|
}: {
|
||||||
|
config: AdminConfig | null;
|
||||||
|
refreshConfig: () => Promise<void>;
|
||||||
|
}) => {
|
||||||
|
const { alertModal, showAlert, hideAlert } = useAlertModal();
|
||||||
|
const { isLoading, withLoading } = useLoadingState();
|
||||||
|
const [enabled, setEnabled] = useState(false);
|
||||||
|
const [serverURL, setServerURL] = useState('');
|
||||||
|
const [token, setToken] = useState('');
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config?.XiaoyaConfig) {
|
||||||
|
setEnabled(config.XiaoyaConfig.Enabled || false);
|
||||||
|
setServerURL(config.XiaoyaConfig.ServerURL || '');
|
||||||
|
setToken(config.XiaoyaConfig.Token || '');
|
||||||
|
setUsername(config.XiaoyaConfig.Username || '');
|
||||||
|
setPassword(config.XiaoyaConfig.Password || '');
|
||||||
|
}
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
await withLoading('saveXiaoya', async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/xiaoya', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'save',
|
||||||
|
Enabled: enabled,
|
||||||
|
ServerURL: serverURL,
|
||||||
|
Token: token,
|
||||||
|
Username: username,
|
||||||
|
Password: password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
throw new Error(data.error || '保存失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
showSuccess('保存成功', showAlert);
|
||||||
|
await refreshConfig();
|
||||||
|
} catch (error) {
|
||||||
|
showError(error instanceof Error ? error.message : '保存失败', showAlert);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTest = async () => {
|
||||||
|
await withLoading('testXiaoya', async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/xiaoya', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
action: 'test',
|
||||||
|
ServerURL: serverURL,
|
||||||
|
Token: token,
|
||||||
|
Username: username,
|
||||||
|
Password: password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.success) {
|
||||||
|
showSuccess('连接成功', showAlert);
|
||||||
|
} else {
|
||||||
|
showError(data.message || '连接失败', showAlert);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showError(error instanceof Error ? error.message : '连接失败', showAlert);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-6'>
|
||||||
|
<div className='bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4'>
|
||||||
|
<h3 className='text-sm font-medium text-blue-900 dark:text-blue-100 mb-2'>
|
||||||
|
关于小雅
|
||||||
|
</h3>
|
||||||
|
<div className='text-sm text-blue-800 dark:text-blue-200 space-y-1'>
|
||||||
|
<p>• 小雅是基于 Alist 的网盘资源聚合服务</p>
|
||||||
|
<p>• 支持文件夹名自动识别 TMDb ID(格式:标题 (年份) {'{tmdb-id}'})</p>
|
||||||
|
<p>• 支持 NFO 文件元数据(poster.jpg、background.jpg)</p>
|
||||||
|
<p>• 按需加载,无需全量扫描</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<div className='flex items-center justify-between py-3 border-b border-gray-200 dark:border-gray-700'>
|
||||||
|
<div>
|
||||||
|
<h3 className='text-sm font-medium text-gray-900 dark:text-white'>
|
||||||
|
启用小雅功能
|
||||||
|
</h3>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
关闭后将不显示小雅入口
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setEnabled(!enabled)}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||||
|
enabled ? 'bg-blue-600' : 'bg-gray-200 dark:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||||
|
enabled ? 'translate-x-6' : 'translate-x-1'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
Alist 服务器地址
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
value={serverURL}
|
||||||
|
onChange={(e) => setServerURL(e.target.value)}
|
||||||
|
placeholder='http://localhost:5244'
|
||||||
|
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'
|
||||||
|
/>
|
||||||
|
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||||
|
小雅 Alist 服务器的完整地址
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
Token(推荐)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='password'
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setToken(e.target.value)}
|
||||||
|
placeholder='可选,使用 Token 认证'
|
||||||
|
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'
|
||||||
|
/>
|
||||||
|
</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={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
placeholder='可选,用户名密码认证'
|
||||||
|
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'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
密码
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type='password'
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder='可选'
|
||||||
|
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'
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className='flex gap-3'>
|
||||||
|
<button
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={!serverURL || isLoading('testXiaoya')}
|
||||||
|
className={buttonStyles.primary}
|
||||||
|
>
|
||||||
|
{isLoading('testXiaoya') ? '测试中...' : '测试连接'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={isLoading('saveXiaoya')}
|
||||||
|
className={buttonStyles.success}
|
||||||
|
>
|
||||||
|
{isLoading('saveXiaoya') ? '保存中...' : '保存配置'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertModal
|
||||||
|
isOpen={alertModal.isOpen}
|
||||||
|
onClose={hideAlert}
|
||||||
|
type={alertModal.type}
|
||||||
|
title={alertModal.title}
|
||||||
|
message={alertModal.message}
|
||||||
|
timer={alertModal.timer}
|
||||||
|
showConfirm={alertModal.showConfirm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 求片列表组件
|
||||||
|
const MovieRequestsComponent = ({ config, refreshConfig }: { config: AdminConfig | null; refreshConfig: () => Promise<void> }) => {
|
||||||
|
const { alertModal, showAlert, hideAlert } = useAlertModal();
|
||||||
|
const { isLoading, withLoading } = useLoadingState();
|
||||||
|
const [requests, setRequests] = useState<any[]>([]);
|
||||||
|
const [filter, setFilter] = useState<'pending' | 'fulfilled'>('pending');
|
||||||
|
const [pendingCount, setPendingCount] = useState(0);
|
||||||
|
const [fulfilledCount, setFulfilledCount] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// 求片功能设置
|
||||||
|
const [enableMovieRequest, setEnableMovieRequest] = useState(config?.SiteConfig?.EnableMovieRequest ?? true);
|
||||||
|
const [movieRequestCooldown, setMovieRequestCooldown] = useState(config?.SiteConfig?.MovieRequestCooldown ?? 3600);
|
||||||
|
const [savingSettings, setSavingSettings] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRequests();
|
||||||
|
loadCounts();
|
||||||
|
}, [filter]);
|
||||||
|
|
||||||
|
const loadCounts = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/movie-requests');
|
||||||
|
const data = await response.json();
|
||||||
|
const allRequests = data.requests || [];
|
||||||
|
setPendingCount(allRequests.filter((r: any) => r.status === 'pending').length);
|
||||||
|
setFulfilledCount(allRequests.filter((r: any) => r.status === 'fulfilled').length);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载求片数量失败:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadRequests = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/movie-requests?status=${filter}&detail=true`);
|
||||||
|
const data = await response.json();
|
||||||
|
setRequests(data.requests || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('加载求片列表失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFulfill = async (id: string) => {
|
||||||
|
await withLoading(`fulfill_${id}`, async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/movie-requests/${id}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ status: 'fulfilled' }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('操作失败');
|
||||||
|
showSuccess('已标记为已上架', showAlert);
|
||||||
|
await loadRequests();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err instanceof Error ? err.message : '操作失败', showAlert);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
await withLoading(`delete_${id}`, async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/movie-requests/${id}`, { method: 'DELETE' });
|
||||||
|
if (!response.ok) throw new Error('删除失败');
|
||||||
|
showSuccess('删除成功', showAlert);
|
||||||
|
await loadRequests();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err instanceof Error ? err.message : '删除失败', showAlert);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveSettings = async () => {
|
||||||
|
setSavingSettings(true);
|
||||||
|
try {
|
||||||
|
if (!config) throw new Error('配置未加载');
|
||||||
|
|
||||||
|
const updatedConfig = {
|
||||||
|
...config,
|
||||||
|
SiteConfig: {
|
||||||
|
...config.SiteConfig,
|
||||||
|
EnableMovieRequest: enableMovieRequest,
|
||||||
|
MovieRequestCooldown: movieRequestCooldown,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch('/api/admin/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updatedConfig),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) throw new Error('保存失败');
|
||||||
|
|
||||||
|
showSuccess('求片设置已保存', showAlert);
|
||||||
|
await refreshConfig();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err instanceof Error ? err.message : '保存失败', showAlert);
|
||||||
|
} finally {
|
||||||
|
setSavingSettings(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{/* 求片功能设置 */}
|
||||||
|
<div className='p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'>
|
||||||
|
<h3 className='text-lg font-medium text-gray-900 dark:text-gray-100 mb-4'>求片功能设置</h3>
|
||||||
|
<div className='space-y-4'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div>
|
||||||
|
<label className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
启用求片功能
|
||||||
|
</label>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
关闭后用户将无法访问求片页面
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className='relative inline-flex items-center cursor-pointer'>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
checked={enableMovieRequest}
|
||||||
|
onChange={(e) => setEnableMovieRequest(e.target.checked)}
|
||||||
|
className='sr-only peer'
|
||||||
|
/>
|
||||||
|
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600"></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||||
|
求片冷却时间(秒)
|
||||||
|
</label>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 mb-2'>
|
||||||
|
用户两次求片之间的最小间隔时间,默认3600秒(1小时)
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type='number'
|
||||||
|
min='0'
|
||||||
|
value={movieRequestCooldown}
|
||||||
|
onChange={(e) => setMovieRequestCooldown(parseInt(e.target.value) || 0)}
|
||||||
|
className='w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100'
|
||||||
|
/>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
{movieRequestCooldown >= 3600
|
||||||
|
? `约 ${Math.floor(movieRequestCooldown / 3600)} 小时 ${Math.floor((movieRequestCooldown % 3600) / 60)} 分钟`
|
||||||
|
: movieRequestCooldown >= 60
|
||||||
|
? `约 ${Math.floor(movieRequestCooldown / 60)} 分钟`
|
||||||
|
: `${movieRequestCooldown} 秒`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleSaveSettings}
|
||||||
|
disabled={savingSettings}
|
||||||
|
className={buttonStyles.primary}
|
||||||
|
>
|
||||||
|
{savingSettings ? '保存中...' : '保存设置'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 求片列表 */}
|
||||||
|
<div className='p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700'>
|
||||||
|
<h3 className='text-lg font-medium text-gray-900 dark:text-gray-100 mb-4'>求片列表</h3>
|
||||||
|
<div className='flex gap-2 mb-4'>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('pending')}
|
||||||
|
className={`px-4 py-2 rounded-lg ${
|
||||||
|
filter === 'pending'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
待处理 ({pendingCount})
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setFilter('fulfilled')}
|
||||||
|
className={`px-4 py-2 rounded-lg ${
|
||||||
|
filter === 'fulfilled'
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
已上架 ({fulfilledCount})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className='flex justify-center py-8'>
|
||||||
|
<div className='w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin' />
|
||||||
|
</div>
|
||||||
|
) : requests.length === 0 ? (
|
||||||
|
<div className='text-center py-8 text-gray-500 dark:text-gray-400'>
|
||||||
|
暂无求片
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='space-y-3'>
|
||||||
|
{requests.map((req) => (
|
||||||
|
<div
|
||||||
|
key={req.id}
|
||||||
|
className='p-4 bg-gray-50 dark:bg-gray-800 rounded-lg'
|
||||||
|
>
|
||||||
|
<div className='flex gap-4'>
|
||||||
|
{req.poster && (
|
||||||
|
<img
|
||||||
|
src={req.poster}
|
||||||
|
alt={req.title}
|
||||||
|
className='w-16 h-24 object-cover rounded'
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className='flex-1'>
|
||||||
|
<h3 className='font-medium text-gray-900 dark:text-gray-100'>
|
||||||
|
{req.title} {req.year && `(${req.year})`}
|
||||||
|
</h3>
|
||||||
|
<p className='text-sm text-gray-600 dark:text-gray-400 mt-1'>
|
||||||
|
求片人数: {req.requestCount} 人
|
||||||
|
</p>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-500 mt-1'>
|
||||||
|
{new Date(req.createdAt).toLocaleString('zh-CN')}
|
||||||
|
</p>
|
||||||
|
{req.requestedBy && (
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-500 mt-1'>
|
||||||
|
求片用户: {req.requestedBy.join(', ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className='flex flex-col gap-2'>
|
||||||
|
{filter === 'pending' && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleFulfill(req.id)}
|
||||||
|
disabled={isLoading(`fulfill_${req.id}`)}
|
||||||
|
className={buttonStyles.successSmall}
|
||||||
|
>
|
||||||
|
{isLoading(`fulfill_${req.id}`) ? '处理中...' : '标记已上架'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(req.id)}
|
||||||
|
disabled={isLoading(`delete_${req.id}`)}
|
||||||
|
className={buttonStyles.dangerSmall}
|
||||||
|
>
|
||||||
|
{isLoading(`delete_${req.id}`) ? '删除中...' : '删除'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AlertModal
|
||||||
|
isOpen={alertModal.isOpen}
|
||||||
|
onClose={hideAlert}
|
||||||
|
type={alertModal.type}
|
||||||
|
title={alertModal.title}
|
||||||
|
message={alertModal.message}
|
||||||
|
timer={alertModal.timer}
|
||||||
|
showConfirm={alertModal.showConfirm}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
// AI配置组件
|
// AI配置组件
|
||||||
const AIConfigComponent = ({
|
const AIConfigComponent = ({
|
||||||
config,
|
config,
|
||||||
@@ -9504,8 +10155,10 @@ function AdminPageClient() {
|
|||||||
const [expandedTabs, setExpandedTabs] = useState<{ [key: string]: boolean }>({
|
const [expandedTabs, setExpandedTabs] = useState<{ [key: string]: boolean }>({
|
||||||
userConfig: false,
|
userConfig: false,
|
||||||
videoSource: false,
|
videoSource: false,
|
||||||
|
mediaLibrary: false,
|
||||||
openListConfig: false,
|
openListConfig: false,
|
||||||
embyConfig: false,
|
embyConfig: false,
|
||||||
|
xiaoyaConfig: false,
|
||||||
aiConfig: false,
|
aiConfig: false,
|
||||||
liveSource: false,
|
liveSource: false,
|
||||||
siteConfig: false,
|
siteConfig: false,
|
||||||
@@ -9807,28 +10460,65 @@ function AdminPageClient() {
|
|||||||
<LiveSourceConfig config={config} refreshConfig={fetchConfig} />
|
<LiveSourceConfig config={config} refreshConfig={fetchConfig} />
|
||||||
</CollapsibleTab>
|
</CollapsibleTab>
|
||||||
|
|
||||||
{/* 私人影库配置标签 */}
|
{/* 私人影库大类 */}
|
||||||
<CollapsibleTab
|
<CollapsibleTab
|
||||||
title='私人影库'
|
title='私人影库'
|
||||||
icon={
|
icon={
|
||||||
<FolderOpen size={20} className='text-gray-600 dark:text-gray-400' />
|
<Database size={20} className='text-yellow-700 dark:text-yellow-400' />
|
||||||
}
|
}
|
||||||
isExpanded={expandedTabs.openListConfig}
|
isExpanded={expandedTabs.mediaLibrary}
|
||||||
onToggle={() => toggleTab('openListConfig')}
|
onToggle={() => toggleTab('mediaLibrary')}
|
||||||
|
isParent={true}
|
||||||
>
|
>
|
||||||
<OpenListConfigComponent config={config} refreshConfig={fetchConfig} />
|
<div className='space-y-4'>
|
||||||
</CollapsibleTab>
|
{/* Openlist配置子标签 */}
|
||||||
|
<CollapsibleTab
|
||||||
|
title='Openlist配置'
|
||||||
|
icon={
|
||||||
|
<FolderOpen size={20} className='text-gray-600 dark:text-gray-400' />
|
||||||
|
}
|
||||||
|
isExpanded={expandedTabs.openListConfig}
|
||||||
|
onToggle={() => toggleTab('openListConfig')}
|
||||||
|
>
|
||||||
|
<OpenListConfigComponent config={config} refreshConfig={fetchConfig} />
|
||||||
|
</CollapsibleTab>
|
||||||
|
|
||||||
{/* Emby 媒体库标签 */}
|
{/* Emby 媒体库子标签 */}
|
||||||
<CollapsibleTab
|
<CollapsibleTab
|
||||||
title='Emby 媒体库'
|
title='Emby 媒体库'
|
||||||
icon={
|
icon={
|
||||||
<FolderOpen size={20} className='text-gray-600 dark:text-gray-400' />
|
<FolderOpen size={20} className='text-gray-600 dark:text-gray-400' />
|
||||||
}
|
}
|
||||||
isExpanded={expandedTabs.embyConfig}
|
isExpanded={expandedTabs.embyConfig}
|
||||||
onToggle={() => toggleTab('embyConfig')}
|
onToggle={() => toggleTab('embyConfig')}
|
||||||
>
|
>
|
||||||
<EmbyConfigComponent config={config} refreshConfig={fetchConfig} />
|
<EmbyConfigComponent config={config} refreshConfig={fetchConfig} />
|
||||||
|
</CollapsibleTab>
|
||||||
|
|
||||||
|
{/* 小雅配置子标签 */}
|
||||||
|
<CollapsibleTab
|
||||||
|
title='小雅配置'
|
||||||
|
icon={
|
||||||
|
<FolderOpen size={20} className='text-gray-600 dark:text-gray-400' />
|
||||||
|
}
|
||||||
|
isExpanded={expandedTabs.xiaoyaConfig}
|
||||||
|
onToggle={() => toggleTab('xiaoyaConfig')}
|
||||||
|
>
|
||||||
|
<XiaoyaConfigComponent config={config} refreshConfig={fetchConfig} />
|
||||||
|
</CollapsibleTab>
|
||||||
|
|
||||||
|
{/* 求片管理子标签 */}
|
||||||
|
<CollapsibleTab
|
||||||
|
title='求片管理'
|
||||||
|
icon={
|
||||||
|
<Video size={20} className='text-gray-600 dark:text-gray-400' />
|
||||||
|
}
|
||||||
|
isExpanded={expandedTabs.movieRequests}
|
||||||
|
onToggle={() => toggleTab('movieRequests')}
|
||||||
|
>
|
||||||
|
<MovieRequestsComponent config={config} refreshConfig={fetchConfig} />
|
||||||
|
</CollapsibleTab>
|
||||||
|
</div>
|
||||||
</CollapsibleTab>
|
</CollapsibleTab>
|
||||||
|
|
||||||
{/* AI配置标签 */}
|
{/* AI配置标签 */}
|
||||||
|
|||||||
@@ -155,8 +155,8 @@ async function getUserPasswordV2(username: string): Promise<string | null> {
|
|||||||
// 直接调用hGetAll获取完整用户信息(包括密码)
|
// 直接调用hGetAll获取完整用户信息(包括密码)
|
||||||
const userInfoKey = `user:${username}:info`;
|
const userInfoKey = `user:${username}:info`;
|
||||||
|
|
||||||
if (typeof storage.withRetry === 'function' && storage.client?.hGetAll) {
|
if (typeof storage.withRetry === 'function' && storage.client?.hgetall) {
|
||||||
const userInfo = await storage.withRetry(() => storage.client.hGetAll(userInfoKey));
|
const userInfo = await storage.withRetry(() => storage.client.hgetall(userInfoKey));
|
||||||
if (userInfo && userInfo.password) {
|
if (userInfo && userInfo.password) {
|
||||||
return userInfo.password;
|
return userInfo.password;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,10 +149,10 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 使用storage.withRetry直接设置用户信息
|
// 使用storage.withRetry直接设置用户信息
|
||||||
await storage.withRetry(() => storage.client.hSet(userInfoKey, userInfo));
|
await storage.withRetry(() => storage.client.hset(userInfoKey, userInfo));
|
||||||
|
|
||||||
// 添加到用户列表
|
// 添加到用户列表
|
||||||
await storage.withRetry(() => storage.client.zAdd('user:list', {
|
await storage.withRetry(() => storage.client.zadd('user:list', {
|
||||||
score: createdAt,
|
score: createdAt,
|
||||||
value: username,
|
value: username,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { action, Enabled, URL, Username, Password, RootPath, OfflineDownloadPath, ScanInterval, ScanMode } = body;
|
const { action, Enabled, URL, Username, Password, RootPaths, OfflineDownloadPath, ScanInterval, ScanMode } = body;
|
||||||
|
|
||||||
const authInfo = getAuthInfoFromCookie(request);
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
if (!authInfo || !authInfo.username) {
|
if (!authInfo || !authInfo.username) {
|
||||||
@@ -53,7 +53,7 @@ export async function POST(request: NextRequest) {
|
|||||||
URL: URL || '',
|
URL: URL || '',
|
||||||
Username: Username || '',
|
Username: Username || '',
|
||||||
Password: Password || '',
|
Password: Password || '',
|
||||||
RootPath: RootPath || '/',
|
RootPaths: RootPaths || ['/'],
|
||||||
OfflineDownloadPath: OfflineDownloadPath || '/',
|
OfflineDownloadPath: OfflineDownloadPath || '/',
|
||||||
LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime,
|
LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime,
|
||||||
ResourceCount: adminConfig.OpenListConfig?.ResourceCount,
|
ResourceCount: adminConfig.OpenListConfig?.ResourceCount,
|
||||||
@@ -77,6 +77,14 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证 RootPaths
|
||||||
|
if (!Array.isArray(RootPaths) || RootPaths.length === 0) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '请至少提供一个根目录' },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 验证扫描间隔
|
// 验证扫描间隔
|
||||||
let scanInterval = parseInt(ScanInterval) || 0;
|
let scanInterval = parseInt(ScanInterval) || 0;
|
||||||
if (scanInterval > 0 && scanInterval < 60) {
|
if (scanInterval > 0 && scanInterval < 60) {
|
||||||
@@ -104,7 +112,7 @@ export async function POST(request: NextRequest) {
|
|||||||
URL,
|
URL,
|
||||||
Username,
|
Username,
|
||||||
Password,
|
Password,
|
||||||
RootPath: RootPath || '/',
|
RootPaths,
|
||||||
OfflineDownloadPath: OfflineDownloadPath || '/',
|
OfflineDownloadPath: OfflineDownloadPath || '/',
|
||||||
LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime,
|
LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime,
|
||||||
ResourceCount: adminConfig.OpenListConfig?.ResourceCount,
|
ResourceCount: adminConfig.OpenListConfig?.ResourceCount,
|
||||||
|
|||||||
@@ -65,16 +65,16 @@ export async function POST(request: NextRequest) {
|
|||||||
if (!key || !name || !api) {
|
if (!key || !name || !api) {
|
||||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||||
}
|
}
|
||||||
// 禁止添加 openlist 和 emby 保留关键字
|
// 禁止添加保留关键字
|
||||||
if (key === 'openlist') {
|
if (key === 'openlist' || key === 'xiaoya') {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'openlist 是保留关键字,不能作为视频源 key' },
|
{ error: `${key} 是保留关键字,不能作为视频源 key` },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (key === 'emby') {
|
if (key.startsWith('emby')) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{ error: 'emby 是保留关键字,不能作为视频源 key' },
|
{ error: 'emby 开头的 key 是保留关键字,不能作为视频源 key' },
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { XiaoyaClient } from '@/lib/xiaoya.client';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/admin/xiaoya
|
||||||
|
* 管理小雅配置
|
||||||
|
*/
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || (authInfo.role !== 'admin' && authInfo.role !== 'owner')) {
|
||||||
|
return NextResponse.json({ error: '无权限' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { action, ...configData } = body;
|
||||||
|
|
||||||
|
if (action === 'test') {
|
||||||
|
// 测试连接
|
||||||
|
try {
|
||||||
|
const client = new XiaoyaClient(
|
||||||
|
configData.ServerURL,
|
||||||
|
configData.Username,
|
||||||
|
configData.Password,
|
||||||
|
configData.Token
|
||||||
|
);
|
||||||
|
|
||||||
|
// 尝试列出根目录
|
||||||
|
await client.listDirectory('/');
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: '连接成功' });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ success: false, message: (error as Error).message },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'save') {
|
||||||
|
// 保存配置
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
|
config.XiaoyaConfig = {
|
||||||
|
Enabled: configData.Enabled || false,
|
||||||
|
ServerURL: configData.ServerURL || '',
|
||||||
|
Token: configData.Token,
|
||||||
|
Username: configData.Username,
|
||||||
|
Password: configData.Password,
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.saveAdminConfig(config);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, message: '保存成功' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ error: '无效的操作' }, { status: 400 });
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -268,14 +268,14 @@ async function handleOpenListProxy(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 读取 metainfo (从数据库或缓存)
|
// 读取 metainfo (从数据库或缓存)
|
||||||
let metaInfo: MetaInfo | null = getCachedMetaInfo(rootPath);
|
let metaInfo: MetaInfo | null = getCachedMetaInfo();
|
||||||
|
|
||||||
if (!metaInfo) {
|
if (!metaInfo) {
|
||||||
try {
|
try {
|
||||||
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||||||
if (metainfoJson) {
|
if (metainfoJson) {
|
||||||
metaInfo = JSON.parse(metainfoJson) as MetaInfo;
|
metaInfo = JSON.parse(metainfoJson) as MetaInfo;
|
||||||
setCachedMetaInfo(rootPath, metaInfo);
|
setCachedMetaInfo(metaInfo);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -45,13 +45,13 @@ export async function GET(request: NextRequest) {
|
|||||||
const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache');
|
const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache');
|
||||||
const { db } = await import('@/lib/db');
|
const { db } = await import('@/lib/db');
|
||||||
|
|
||||||
metaInfo = getCachedMetaInfo(rootPath);
|
metaInfo = getCachedMetaInfo();
|
||||||
|
|
||||||
if (!metaInfo) {
|
if (!metaInfo) {
|
||||||
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||||||
if (metainfoJson) {
|
if (metainfoJson) {
|
||||||
metaInfo = JSON.parse(metainfoJson);
|
metaInfo = JSON.parse(metainfoJson);
|
||||||
setCachedMetaInfo(rootPath, metaInfo);
|
setCachedMetaInfo(metaInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { fetchDoubanData } from '@/lib/douban';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
interface DoubanSearchResult {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
year: string;
|
||||||
|
type?: string;
|
||||||
|
sub_title?: string;
|
||||||
|
episode?: string;
|
||||||
|
img?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DoubanSearchResponse {
|
||||||
|
code: number;
|
||||||
|
data?: DoubanSearchResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/douban/search?q=<query>
|
||||||
|
* 搜索豆瓣影视作品
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const query = searchParams.get('q');
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return NextResponse.json({ error: '缺少搜索关键词' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const target = `https://movie.douban.com/j/subject_suggest?q=${encodeURIComponent(query)}`;
|
||||||
|
const data = await fetchDoubanData<DoubanSearchResult[]>(target);
|
||||||
|
|
||||||
|
const response: DoubanSearchResponse = {
|
||||||
|
code: 200,
|
||||||
|
data: data,
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(response);
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '搜索豆瓣数据失败', details: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { NextResponse } from 'next/server';
|
|||||||
import { embyManager } from '@/lib/emby-manager';
|
import { embyManager } from '@/lib/emby-manager';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic'; // 禁用缓存
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取所有启用的Emby源列表
|
* 获取所有启用的Emby源列表
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getStorage } from '@/lib/db';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
// GET: 获取单个求片详情
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storage = getStorage();
|
||||||
|
const movieRequest = await storage.getMovieRequest(params.id);
|
||||||
|
|
||||||
|
if (!movieRequest) {
|
||||||
|
return NextResponse.json({ error: '求片不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ request: movieRequest });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取求片详情失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH: 更新求片状态(标记已上架)
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storage = getStorage();
|
||||||
|
|
||||||
|
// 检查权限:只有管理员和站长可以操作
|
||||||
|
if (storage.getUserInfoV2) {
|
||||||
|
const userInfo = await storage.getUserInfoV2(authInfo.username);
|
||||||
|
if (userInfo?.role !== 'admin' && userInfo?.role !== 'owner') {
|
||||||
|
return NextResponse.json({ error: '无权限操作' }, { status: 403 });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果不支持 getUserInfoV2,只允许站长操作
|
||||||
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
|
return NextResponse.json({ error: '无权限操作' }, { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { status, fulfilledSource, fulfilledId } = body;
|
||||||
|
|
||||||
|
const movieRequest = await storage.getMovieRequest(params.id);
|
||||||
|
if (!movieRequest) {
|
||||||
|
return NextResponse.json({ error: '求片不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新状态
|
||||||
|
const updates: any = {
|
||||||
|
status,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (status === 'fulfilled') {
|
||||||
|
updates.fulfilledAt = Date.now();
|
||||||
|
updates.fulfilledSource = fulfilledSource;
|
||||||
|
updates.fulfilledId = fulfilledId;
|
||||||
|
|
||||||
|
// 给所有求片用户发送通知
|
||||||
|
for (const username of movieRequest.requestedBy) {
|
||||||
|
await storage.addNotification(username, {
|
||||||
|
id: `req_fulfilled_${params.id}_${Date.now()}`,
|
||||||
|
type: 'request_fulfilled',
|
||||||
|
title: '求片已上架',
|
||||||
|
message: `您求的《${movieRequest.title}》已上架`,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
read: false,
|
||||||
|
metadata: {
|
||||||
|
requestId: params.id,
|
||||||
|
source: fulfilledSource,
|
||||||
|
id: fulfilledId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await storage.updateMovieRequest(params.id, updates);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: '更新成功',
|
||||||
|
request: { ...movieRequest, ...updates },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('更新求片失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE: 删除求片
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const storage = getStorage();
|
||||||
|
|
||||||
|
// 检查权限:只有管理员和站长可以删除
|
||||||
|
if (storage.getUserInfoV2) {
|
||||||
|
const userInfo = await storage.getUserInfoV2(authInfo.username);
|
||||||
|
if (userInfo?.role !== 'admin' && userInfo?.role !== 'owner') {
|
||||||
|
return NextResponse.json({ error: '无权限操作' }, { status: 403 });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果不支持 getUserInfoV2,只允许站长操作
|
||||||
|
if (authInfo.username !== process.env.USERNAME) {
|
||||||
|
return NextResponse.json({ error: '无权限操作' }, { status: 403 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const movieRequest = await storage.getMovieRequest(params.id);
|
||||||
|
if (!movieRequest) {
|
||||||
|
return NextResponse.json({ error: '求片不存在' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除求片
|
||||||
|
await storage.deleteMovieRequest(params.id);
|
||||||
|
|
||||||
|
// 从所有用户的求片列表中移除
|
||||||
|
for (const username of movieRequest.requestedBy) {
|
||||||
|
await storage.removeUserMovieRequest(username, params.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ message: '删除成功' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('删除求片失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getStorage } from '@/lib/db';
|
||||||
|
import { MovieRequest } from '@/lib/types';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
// GET: 获取求片列表
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const status = searchParams.get('status') as 'pending' | 'fulfilled' | null;
|
||||||
|
const detail = searchParams.get('detail') !== 'false';
|
||||||
|
const myRequests = searchParams.get('my') === 'true';
|
||||||
|
|
||||||
|
const storage = getStorage();
|
||||||
|
|
||||||
|
if (myRequests) {
|
||||||
|
// 获取用户自己的求片
|
||||||
|
const requestIds = await storage.getUserMovieRequests(authInfo.username);
|
||||||
|
const requests = await Promise.all(
|
||||||
|
requestIds.map(id => storage.getMovieRequest(id))
|
||||||
|
);
|
||||||
|
const filtered = requests.filter(r => r !== null) as MovieRequest[];
|
||||||
|
return NextResponse.json({ requests: filtered });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有求片
|
||||||
|
let requests = await storage.getAllMovieRequests();
|
||||||
|
|
||||||
|
// 按状态筛选
|
||||||
|
if (status) {
|
||||||
|
requests = requests.filter(r => r.status === status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 列表页不返回 requestedBy
|
||||||
|
if (!detail) {
|
||||||
|
requests = requests.map(r => ({ ...r, requestedBy: [] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按求片人数和时间排序
|
||||||
|
requests.sort((a, b) => {
|
||||||
|
if (b.requestCount !== a.requestCount) {
|
||||||
|
return b.requestCount - a.requestCount;
|
||||||
|
}
|
||||||
|
return b.createdAt - a.createdAt;
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json({ requests });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取求片列表失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST: 创建或加入求片
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 检查求片功能是否启用并获取冷却时间
|
||||||
|
const { getConfig } = await import('@/lib/config');
|
||||||
|
const config = await getConfig();
|
||||||
|
|
||||||
|
if (config.SiteConfig.EnableMovieRequest === false) {
|
||||||
|
return NextResponse.json({ error: '求片功能已关闭' }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { tmdbId, title, year, mediaType, season, poster, overview } = body;
|
||||||
|
|
||||||
|
if (!title || !mediaType) {
|
||||||
|
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const storage = getStorage();
|
||||||
|
|
||||||
|
// 检查频率限制 - 使用配置中的冷却时间
|
||||||
|
const cooldownSeconds = config.SiteConfig.MovieRequestCooldown ?? 3600;
|
||||||
|
const rateLimit = cooldownSeconds * 1000;
|
||||||
|
|
||||||
|
if (storage.getUserInfoV2) {
|
||||||
|
const userInfo = await storage.getUserInfoV2(authInfo.username);
|
||||||
|
if (userInfo?.last_movie_request_time) {
|
||||||
|
const elapsed = Date.now() - userInfo.last_movie_request_time;
|
||||||
|
if (elapsed < rateLimit) {
|
||||||
|
const remaining = Math.ceil((rateLimit - elapsed) / 60000);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `操作太频繁,请${remaining}分钟后再试` },
|
||||||
|
{ status: 429 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查重(剧集需要匹配季度)
|
||||||
|
const allRequests = await storage.getAllMovieRequests();
|
||||||
|
const existing = allRequests.find(r =>
|
||||||
|
(tmdbId && r.tmdbId === tmdbId && r.season === season) ||
|
||||||
|
(r.title === title && r.year === year && r.season === season)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// 如果已上架,不允许再求
|
||||||
|
if (existing.status === 'fulfilled') {
|
||||||
|
return NextResponse.json({ error: '该影片已上架' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户是否已经求过
|
||||||
|
if (existing.requestedBy.includes(authInfo.username)) {
|
||||||
|
return NextResponse.json({ error: '您已经求过这部影片了' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加入求片
|
||||||
|
existing.requestedBy.push(authInfo.username);
|
||||||
|
existing.requestCount++;
|
||||||
|
existing.updatedAt = Date.now();
|
||||||
|
await storage.updateMovieRequest(existing.id, existing);
|
||||||
|
await storage.addUserMovieRequest(authInfo.username, existing.id);
|
||||||
|
|
||||||
|
// 给站长发送通知
|
||||||
|
const ownerUsername = process.env.USERNAME;
|
||||||
|
if (ownerUsername) {
|
||||||
|
await storage.addNotification(ownerUsername, {
|
||||||
|
id: `movie_request_join_${existing.id}_${Date.now()}`,
|
||||||
|
type: 'movie_request',
|
||||||
|
title: '求片人数增加',
|
||||||
|
message: `${authInfo.username} 也想看:${existing.title}${existing.season ? ` 第${existing.season}季` : ''} (${existing.requestCount}人)`,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
read: false,
|
||||||
|
metadata: {
|
||||||
|
requestId: existing.id,
|
||||||
|
username: authInfo.username,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: '已加入求片',
|
||||||
|
request: existing
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新求片
|
||||||
|
const newRequest: MovieRequest = {
|
||||||
|
id: nanoid(),
|
||||||
|
tmdbId,
|
||||||
|
title,
|
||||||
|
year,
|
||||||
|
mediaType,
|
||||||
|
season,
|
||||||
|
poster,
|
||||||
|
overview,
|
||||||
|
requestedBy: [authInfo.username],
|
||||||
|
requestCount: 1,
|
||||||
|
status: 'pending',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
await storage.createMovieRequest(newRequest);
|
||||||
|
await storage.addUserMovieRequest(authInfo.username, newRequest.id);
|
||||||
|
|
||||||
|
// 更新频率限制 - 保存到用户信息的 hash 中
|
||||||
|
if ('client' in storage && storage.client && typeof (storage.client as any).hSet === 'function') {
|
||||||
|
await (storage.client as any).hSet(
|
||||||
|
`user:${authInfo.username}:info`,
|
||||||
|
'last_movie_request_time',
|
||||||
|
Date.now().toString()
|
||||||
|
);
|
||||||
|
|
||||||
|
// 清除用户信息缓存,确保下次读取到最新数据
|
||||||
|
const { userInfoCache } = await import('@/lib/user-cache');
|
||||||
|
userInfoCache?.delete(authInfo.username);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 给站长发送通知
|
||||||
|
const ownerUsername = process.env.USERNAME;
|
||||||
|
if (ownerUsername) {
|
||||||
|
await storage.addNotification(ownerUsername, {
|
||||||
|
id: `movie_request_${newRequest.id}_${Date.now()}`,
|
||||||
|
type: 'movie_request',
|
||||||
|
title: '新求片请求',
|
||||||
|
message: `${authInfo.username} 求片:${title}${season ? ` 第${season}季` : ''}`,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
read: false,
|
||||||
|
metadata: {
|
||||||
|
requestId: newRequest.id,
|
||||||
|
username: authInfo.username,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
message: '求片成功',
|
||||||
|
request: newRequest
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('创建求片失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,7 +76,7 @@ export async function GET(
|
|||||||
const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache');
|
const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache');
|
||||||
const { db } = await import('@/lib/db');
|
const { db } = await import('@/lib/db');
|
||||||
|
|
||||||
let metaInfo = getCachedMetaInfo(rootPath);
|
let metaInfo = getCachedMetaInfo();
|
||||||
|
|
||||||
if (!metaInfo) {
|
if (!metaInfo) {
|
||||||
try {
|
try {
|
||||||
@@ -84,7 +84,7 @@ export async function GET(
|
|||||||
if (metainfoJson) {
|
if (metainfoJson) {
|
||||||
metaInfo = JSON.parse(metainfoJson);
|
metaInfo = JSON.parse(metainfoJson);
|
||||||
if (metaInfo) {
|
if (metaInfo) {
|
||||||
setCachedMetaInfo(rootPath, metaInfo);
|
setCachedMetaInfo(metaInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootPath = openListConfig.RootPath || '/';
|
|
||||||
const client = new OpenListClient(
|
const client = new OpenListClient(
|
||||||
openListConfig.URL,
|
openListConfig.URL,
|
||||||
openListConfig.Username,
|
openListConfig.Username,
|
||||||
@@ -73,7 +72,7 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 读取现有 metainfo (从数据库或缓存)
|
// 读取现有 metainfo (从数据库或缓存)
|
||||||
let metaInfo: MetaInfo | null = getCachedMetaInfo(rootPath);
|
let metaInfo: MetaInfo | null = getCachedMetaInfo();
|
||||||
|
|
||||||
if (!metaInfo) {
|
if (!metaInfo) {
|
||||||
try {
|
try {
|
||||||
@@ -132,8 +131,8 @@ export async function POST(request: NextRequest) {
|
|||||||
await db.setGlobalValue('video.metainfo', metainfoContent);
|
await db.setGlobalValue('video.metainfo', metainfoContent);
|
||||||
|
|
||||||
// 更新缓存
|
// 更新缓存
|
||||||
invalidateMetaInfoCache(rootPath);
|
invalidateMetaInfoCache();
|
||||||
setCachedMetaInfo(rootPath, metaInfo);
|
setCachedMetaInfo(metaInfo);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -49,8 +49,6 @@ export async function POST(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootPath = openListConfig.RootPath || '/';
|
|
||||||
|
|
||||||
// 从数据库读取 metainfo
|
// 从数据库读取 metainfo
|
||||||
const metainfoContent = await db.getGlobalValue('video.metainfo');
|
const metainfoContent = await db.getGlobalValue('video.metainfo');
|
||||||
if (!metainfoContent) {
|
if (!metainfoContent) {
|
||||||
@@ -78,8 +76,8 @@ export async function POST(request: NextRequest) {
|
|||||||
await db.setGlobalValue('video.metainfo', updatedMetainfoContent);
|
await db.setGlobalValue('video.metainfo', updatedMetainfoContent);
|
||||||
|
|
||||||
// 更新缓存
|
// 更新缓存
|
||||||
invalidateMetaInfoCache(rootPath);
|
invalidateMetaInfoCache();
|
||||||
setCachedMetaInfo(rootPath, metaInfo);
|
setCachedMetaInfo(metaInfo);
|
||||||
|
|
||||||
// 更新配置中的资源数量
|
// 更新配置中的资源数量
|
||||||
if (config.OpenListConfig) {
|
if (config.OpenListConfig) {
|
||||||
|
|||||||
@@ -45,8 +45,8 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 });
|
return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootPath = openListConfig.RootPath || '/';
|
// folderName 已经是完整路径,直接使用
|
||||||
const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folderName}`;
|
const folderPath = folderName;
|
||||||
const client = new OpenListClient(
|
const client = new OpenListClient(
|
||||||
openListConfig.URL,
|
openListConfig.URL,
|
||||||
openListConfig.Username,
|
openListConfig.Username,
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ export async function GET(request: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootPath = openListConfig.RootPath || '/';
|
|
||||||
const client = new OpenListClient(
|
const client = new OpenListClient(
|
||||||
openListConfig.URL,
|
openListConfig.URL,
|
||||||
openListConfig.Username,
|
openListConfig.Username,
|
||||||
@@ -62,7 +61,7 @@ export async function GET(request: NextRequest) {
|
|||||||
if (noCache) {
|
if (noCache) {
|
||||||
// noCache 模式:跳过缓存
|
// noCache 模式:跳过缓存
|
||||||
} else {
|
} else {
|
||||||
metaInfo = getCachedMetaInfo(rootPath);
|
metaInfo = getCachedMetaInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!metaInfo) {
|
if (!metaInfo) {
|
||||||
@@ -83,7 +82,7 @@ export async function GET(request: NextRequest) {
|
|||||||
|
|
||||||
// 只有在不是 noCache 模式时才更新缓存
|
// 只有在不是 noCache 模式时才更新缓存
|
||||||
if (!noCache) {
|
if (!noCache) {
|
||||||
setCachedMetaInfo(rootPath, metaInfo);
|
setCachedMetaInfo(metaInfo);
|
||||||
}
|
}
|
||||||
} catch (parseError) {
|
} catch (parseError) {
|
||||||
console.error('[OpenList List] JSON 解析或验证失败:', parseError);
|
console.error('[OpenList List] JSON 解析或验证失败:', parseError);
|
||||||
|
|||||||
@@ -9,9 +9,59 @@ import { OpenListClient } from '@/lib/openlist.client';
|
|||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/openlist/play?folder=xxx&fileName=xxx
|
* 使用 HEAD 请求跟随重定向获取最终 URL(直连方法 - 降级使用)
|
||||||
* 获取单个视频文件的播放链接(懒加载)
|
*/
|
||||||
* 返回重定向到真实播放 URL
|
async function getFinalUrl(url: string, maxRedirects = 5): Promise<string> {
|
||||||
|
let currentUrl = url;
|
||||||
|
let redirectCount = 0;
|
||||||
|
|
||||||
|
while (redirectCount < maxRedirects) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(currentUrl, {
|
||||||
|
method: 'HEAD',
|
||||||
|
redirect: 'manual',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status >= 300 && response.status < 400) {
|
||||||
|
const location = response.headers.get('location');
|
||||||
|
if (!location) {
|
||||||
|
return currentUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location.startsWith('http://') || location.startsWith('https://')) {
|
||||||
|
currentUrl = location;
|
||||||
|
} else if (location.startsWith('/')) {
|
||||||
|
const urlObj = new URL(currentUrl);
|
||||||
|
currentUrl = `${urlObj.protocol}//${urlObj.host}${location}`;
|
||||||
|
} else {
|
||||||
|
const urlObj = new URL(currentUrl);
|
||||||
|
const pathParts = urlObj.pathname.split('/');
|
||||||
|
pathParts.pop();
|
||||||
|
pathParts.push(location);
|
||||||
|
currentUrl = `${urlObj.protocol}//${urlObj.host}${pathParts.join('/')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectCount++;
|
||||||
|
} else {
|
||||||
|
return currentUrl;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[openlist/play] 获取最终 URL 失败:', error);
|
||||||
|
return currentUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/openlist/play?folder=xxx&fileName=xxx&format=json
|
||||||
|
* 获取单个视频文件的播放链接(优先使用视频预览流,失败时降级到直连)
|
||||||
|
* format=json: 返回 JSON 格式(用于 play 页面)
|
||||||
|
* 默认: 返回重定向(用于 tvbox 等)
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@@ -23,6 +73,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const { searchParams } = new URL(request.url);
|
const { searchParams } = new URL(request.url);
|
||||||
const folderName = searchParams.get('folder');
|
const folderName = searchParams.get('folder');
|
||||||
const fileName = searchParams.get('fileName');
|
const fileName = searchParams.get('fileName');
|
||||||
|
const format = searchParams.get('format'); // 新增 format 参数
|
||||||
|
|
||||||
if (!folderName || !fileName) {
|
if (!folderName || !fileName) {
|
||||||
return NextResponse.json({ error: '缺少参数' }, { status: 400 });
|
return NextResponse.json({ error: '缺少参数' }, { status: 400 });
|
||||||
@@ -41,8 +92,8 @@ export async function GET(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 });
|
return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootPath = openListConfig.RootPath || '/';
|
// folderName 已经是完整路径,直接使用
|
||||||
const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folderName}`;
|
const folderPath = folderName;
|
||||||
const filePath = `${folderPath}/${fileName}`;
|
const filePath = `${folderPath}/${fileName}`;
|
||||||
|
|
||||||
const client = new OpenListClient(
|
const client = new OpenListClient(
|
||||||
@@ -51,23 +102,71 @@ export async function GET(request: NextRequest) {
|
|||||||
openListConfig.Password
|
openListConfig.Password
|
||||||
);
|
);
|
||||||
|
|
||||||
// 获取文件的播放链接
|
// 优先尝试视频预览流方法
|
||||||
const fileResponse = await client.getFile(filePath);
|
try {
|
||||||
|
const data = await client.getVideoPreview(filePath);
|
||||||
|
|
||||||
if (fileResponse.code !== 200 || !fileResponse.data.raw_url) {
|
const taskList = data.data?.video_preview_play_info?.live_transcoding_task_list;
|
||||||
console.error('[OpenList Play] 获取播放URL失败:', {
|
if (!taskList || taskList.length === 0) {
|
||||||
fileName,
|
throw new Error('未找到可用的播放链接');
|
||||||
code: fileResponse.code,
|
}
|
||||||
message: fileResponse.message,
|
|
||||||
});
|
const qualityOrder: Record<string, number> = {
|
||||||
return NextResponse.json(
|
'FHD': 1,
|
||||||
{ error: '获取播放链接失败' },
|
'HD': 2,
|
||||||
{ status: 500 }
|
'LD': 3,
|
||||||
);
|
'SD': 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const qualities = taskList
|
||||||
|
.filter((task: any) => task.status === 'finished')
|
||||||
|
.map((task: any) => ({
|
||||||
|
name: task.template_id,
|
||||||
|
url: task.url,
|
||||||
|
}))
|
||||||
|
.sort((a: any, b: any) => (qualityOrder[a.name] || 999) - (qualityOrder[b.name] || 999));
|
||||||
|
|
||||||
|
if (qualities.length === 0) {
|
||||||
|
throw new Error('未找到已完成的播放链接');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果指定了 format=json,返回 JSON 格式
|
||||||
|
if (format === 'json') {
|
||||||
|
return NextResponse.json({
|
||||||
|
url: qualities[0].url,
|
||||||
|
qualities
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认返回重定向(用于 tvbox)
|
||||||
|
return NextResponse.redirect(qualities[0].url);
|
||||||
|
} catch (error) {
|
||||||
|
// 视频预览流失败,降级到直连方法
|
||||||
|
console.log('[openlist/play] 视频预览流失败,降级到直连方法:', (error as Error).message);
|
||||||
|
|
||||||
|
const fileResponse = await client.getFile(filePath);
|
||||||
|
|
||||||
|
if (fileResponse.code !== 200 || !fileResponse.data.raw_url) {
|
||||||
|
console.error('[OpenList Play] 获取播放URL失败:', {
|
||||||
|
fileName,
|
||||||
|
code: fileResponse.code,
|
||||||
|
message: fileResponse.message,
|
||||||
|
});
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: '获取播放链接失败' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果指定了 format=json,使用 getFinalUrl 并返回 JSON
|
||||||
|
if (format === 'json') {
|
||||||
|
const finalUrl = await getFinalUrl(fileResponse.data.raw_url);
|
||||||
|
return NextResponse.json({ url: finalUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认返回重定向(用于 tvbox)
|
||||||
|
return NextResponse.redirect(fileResponse.data.raw_url);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回重定向到真实播放 URL
|
|
||||||
return NextResponse.redirect(fileResponse.data.raw_url);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取播放链接失败:', error);
|
console.error('获取播放链接失败:', error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 });
|
return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootPath = openListConfig.RootPath || '/';
|
// folder 已经是完整路径,直接使用
|
||||||
const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folder}`;
|
const folderPath = folder;
|
||||||
const client = new OpenListClient(
|
const client = new OpenListClient(
|
||||||
openListConfig.URL,
|
openListConfig.URL,
|
||||||
openListConfig.Username,
|
openListConfig.Username,
|
||||||
|
|||||||
@@ -105,15 +105,14 @@ export async function GET(request: NextRequest) {
|
|||||||
const { getTMDBImageUrl } = await import('@/lib/tmdb.search');
|
const { getTMDBImageUrl } = await import('@/lib/tmdb.search');
|
||||||
const { db } = await import('@/lib/db');
|
const { db } = await import('@/lib/db');
|
||||||
|
|
||||||
const rootPath = config.OpenListConfig!.RootPath || '/';
|
let metaInfo = getCachedMetaInfo();
|
||||||
let metaInfo = getCachedMetaInfo(rootPath);
|
|
||||||
|
|
||||||
if (!metaInfo) {
|
if (!metaInfo) {
|
||||||
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||||||
if (metainfoJson) {
|
if (metainfoJson) {
|
||||||
metaInfo = JSON.parse(metainfoJson);
|
metaInfo = JSON.parse(metainfoJson);
|
||||||
if (metaInfo) {
|
if (metaInfo) {
|
||||||
setCachedMetaInfo(rootPath, metaInfo);
|
setCachedMetaInfo(metaInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -213,15 +213,14 @@ export async function GET(request: NextRequest) {
|
|||||||
const { getTMDBImageUrl } = await import('@/lib/tmdb.search');
|
const { getTMDBImageUrl } = await import('@/lib/tmdb.search');
|
||||||
const { db } = await import('@/lib/db');
|
const { db } = await import('@/lib/db');
|
||||||
|
|
||||||
const rootPath = config.OpenListConfig!.RootPath || '/';
|
let metaInfo = getCachedMetaInfo();
|
||||||
let metaInfo = getCachedMetaInfo(rootPath);
|
|
||||||
|
|
||||||
if (!metaInfo) {
|
if (!metaInfo) {
|
||||||
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||||||
if (metainfoJson) {
|
if (metainfoJson) {
|
||||||
metaInfo = JSON.parse(metainfoJson);
|
metaInfo = JSON.parse(metainfoJson);
|
||||||
if (metaInfo) {
|
if (metaInfo) {
|
||||||
setCachedMetaInfo(rootPath, metaInfo);
|
setCachedMetaInfo(metaInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getConfig } from '@/lib/config';
|
|||||||
import { CURRENT_VERSION } from '@/lib/version'
|
import { CURRENT_VERSION } from '@/lib/version'
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic'; // 禁用缓存
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
console.log('server-config called: ', request.url);
|
console.log('server-config called: ', request.url);
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export async function GET(request: NextRequest) {
|
|||||||
const id = searchParams.get('id');
|
const id = searchParams.get('id');
|
||||||
const sourceCode = searchParams.get('source');
|
const sourceCode = searchParams.get('source');
|
||||||
const title = searchParams.get('title'); // 用于搜索的标题
|
const title = searchParams.get('title'); // 用于搜索的标题
|
||||||
|
const fileName = searchParams.get('fileName'); // 小雅源:用户点击的文件名
|
||||||
|
|
||||||
if (!id || !sourceCode || !title) {
|
if (!id || !sourceCode || !title) {
|
||||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||||
@@ -124,6 +125,102 @@ export async function GET(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 特殊处理 xiaoya 源
|
||||||
|
if (sourceCode === 'xiaoya') {
|
||||||
|
try {
|
||||||
|
const config = await getConfig();
|
||||||
|
const xiaoyaConfig = config.XiaoyaConfig;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!xiaoyaConfig ||
|
||||||
|
!xiaoyaConfig.Enabled ||
|
||||||
|
!xiaoyaConfig.ServerURL
|
||||||
|
) {
|
||||||
|
throw new Error('小雅未配置或未启用');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { XiaoyaClient } = await import('@/lib/xiaoya.client');
|
||||||
|
const { getXiaoyaMetadata, getXiaoyaEpisodes } = await import('@/lib/xiaoya-metadata');
|
||||||
|
const { base58Decode, base58Encode } = await import('@/lib/utils');
|
||||||
|
|
||||||
|
const client = new XiaoyaClient(
|
||||||
|
xiaoyaConfig.ServerURL,
|
||||||
|
xiaoyaConfig.Username,
|
||||||
|
xiaoyaConfig.Password,
|
||||||
|
xiaoyaConfig.Token
|
||||||
|
);
|
||||||
|
|
||||||
|
// 对id进行base58解码得到目录路径
|
||||||
|
let decodedDirPath: string;
|
||||||
|
try {
|
||||||
|
decodedDirPath = base58Decode(id);
|
||||||
|
console.log('[xiaoya] 解码目录路径:', decodedDirPath);
|
||||||
|
} catch (decodeError) {
|
||||||
|
console.error('[xiaoya] Base58解码失败:', decodeError);
|
||||||
|
throw new Error('无效的视频ID');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证解码后的路径
|
||||||
|
if (!decodedDirPath || decodedDirPath.trim() === '') {
|
||||||
|
throw new Error('解码后的路径为空');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有fileName参数,拼接完整文件路径
|
||||||
|
let clickedFilePath: string | undefined;
|
||||||
|
if (fileName) {
|
||||||
|
// 拼接目录路径和文件名
|
||||||
|
clickedFilePath = `${decodedDirPath}${decodedDirPath.endsWith('/') ? '' : '/'}${fileName}`;
|
||||||
|
console.log('[xiaoya] 用户点击的文件路径:', clickedFilePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取元数据(使用目录路径或点击的文件路径)
|
||||||
|
const metadataPath = clickedFilePath || decodedDirPath;
|
||||||
|
const metadata = await getXiaoyaMetadata(
|
||||||
|
client,
|
||||||
|
metadataPath,
|
||||||
|
config.SiteConfig.TMDBApiKey,
|
||||||
|
config.SiteConfig.TMDBProxy
|
||||||
|
);
|
||||||
|
|
||||||
|
// 获取集数列表(使用目录路径或点击的文件路径)
|
||||||
|
const episodes = await getXiaoyaEpisodes(client, metadataPath);
|
||||||
|
|
||||||
|
// 如果有点击的文件路径,找到对应的集数索引
|
||||||
|
let clickedFileIndex = -1;
|
||||||
|
if (clickedFilePath) {
|
||||||
|
clickedFileIndex = episodes.findIndex(ep => ep.path === clickedFilePath);
|
||||||
|
console.log('[xiaoya] 文件在集数列表中的索引:', clickedFileIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
source: 'xiaoya',
|
||||||
|
source_name: '小雅',
|
||||||
|
id: id, // 保持编码后的目录id
|
||||||
|
title: metadata.title,
|
||||||
|
poster: metadata.poster || '',
|
||||||
|
year: metadata.year || '',
|
||||||
|
douban_id: 0,
|
||||||
|
desc: metadata.plot || '',
|
||||||
|
episodes: episodes.map(ep => `/api/xiaoya/play?path=${encodeURIComponent(base58Encode(ep.path))}`),
|
||||||
|
episodes_titles: episodes.map(ep => ep.title),
|
||||||
|
subtitles: [],
|
||||||
|
proxyMode: false,
|
||||||
|
// 返回用户点击的文件索引(如果找到的话)
|
||||||
|
initialEpisodeIndex: clickedFileIndex >= 0 ? clickedFileIndex : undefined,
|
||||||
|
// 返回元数据来源
|
||||||
|
metadataSource: metadata.source,
|
||||||
|
};
|
||||||
|
|
||||||
|
return NextResponse.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[xiaoya] 获取详情失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 特殊处理 openlist 源 - 直接调用 /api/detail
|
// 特殊处理 openlist 源 - 直接调用 /api/detail
|
||||||
if (sourceCode === 'openlist') {
|
if (sourceCode === 'openlist') {
|
||||||
try {
|
try {
|
||||||
@@ -149,13 +246,13 @@ export async function GET(request: NextRequest) {
|
|||||||
const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache');
|
const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache');
|
||||||
const { db } = await import('@/lib/db');
|
const { db } = await import('@/lib/db');
|
||||||
|
|
||||||
metaInfo = getCachedMetaInfo(rootPath);
|
metaInfo = getCachedMetaInfo();
|
||||||
|
|
||||||
if (!metaInfo) {
|
if (!metaInfo) {
|
||||||
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||||||
if (metainfoJson) {
|
if (metainfoJson) {
|
||||||
metaInfo = JSON.parse(metainfoJson);
|
metaInfo = JSON.parse(metainfoJson);
|
||||||
setCachedMetaInfo(rootPath, metaInfo);
|
setCachedMetaInfo(metaInfo);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getConfig } from '@/lib/config';
|
|||||||
import { getThemeCSS } from '@/styles/themes';
|
import { getThemeCSS } from '@/styles/themes';
|
||||||
|
|
||||||
export const runtime = 'nodejs';
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic'; // 禁用缓存
|
||||||
|
|
||||||
export async function GET(request: NextRequest) {
|
export async function GET(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -162,6 +162,7 @@ export async function GET(request: NextRequest) {
|
|||||||
overview: details.overview || '',
|
overview: details.overview || '',
|
||||||
rating: details.vote_average ? details.vote_average.toFixed(1) : '',
|
rating: details.vote_average ? details.vote_average.toFixed(1) : '',
|
||||||
releaseDate: details.release_date || details.first_air_date || '',
|
releaseDate: details.release_date || details.first_air_date || '',
|
||||||
|
genres: details.genres || [], // 添加类型标签
|
||||||
};
|
};
|
||||||
|
|
||||||
return NextResponse.json(responseData, {
|
return NextResponse.json(responseData, {
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { XiaoyaClient } from '@/lib/xiaoya.client';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/xiaoya/browse?path=<path>
|
||||||
|
* 浏览小雅目录
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const path = searchParams.get('path') || '/';
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
const xiaoyaConfig = config.XiaoyaConfig;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!xiaoyaConfig ||
|
||||||
|
!xiaoyaConfig.Enabled ||
|
||||||
|
!xiaoyaConfig.ServerURL
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: '小雅未配置或未启用' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new XiaoyaClient(
|
||||||
|
xiaoyaConfig.ServerURL,
|
||||||
|
xiaoyaConfig.Username,
|
||||||
|
xiaoyaConfig.Password,
|
||||||
|
xiaoyaConfig.Token
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await client.listDirectory(path);
|
||||||
|
|
||||||
|
// 过滤出文件夹和视频文件
|
||||||
|
const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm'];
|
||||||
|
|
||||||
|
const folders = result.content
|
||||||
|
.filter(item => item.is_dir)
|
||||||
|
.map(item => ({
|
||||||
|
name: item.name,
|
||||||
|
path: `${path}${path.endsWith('/') ? '' : '/'}${item.name}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const files = result.content
|
||||||
|
.filter(item =>
|
||||||
|
!item.is_dir &&
|
||||||
|
videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext))
|
||||||
|
)
|
||||||
|
.map(item => ({
|
||||||
|
name: item.name,
|
||||||
|
path: `${path}${path.endsWith('/') ? '' : '/'}${item.name}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
folders,
|
||||||
|
files,
|
||||||
|
currentPath: path,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
import { XiaoyaClient } from '@/lib/xiaoya.client';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用 HEAD 请求跟随重定向获取最终 URL(直连方法 - 降级使用)
|
||||||
|
*/
|
||||||
|
async function getFinalUrl(url: string, maxRedirects = 5): Promise<string> {
|
||||||
|
let currentUrl = url;
|
||||||
|
let redirectCount = 0;
|
||||||
|
|
||||||
|
while (redirectCount < maxRedirects) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(currentUrl, {
|
||||||
|
method: 'HEAD',
|
||||||
|
redirect: 'manual',
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.status >= 300 && response.status < 400) {
|
||||||
|
const location = response.headers.get('location');
|
||||||
|
if (!location) {
|
||||||
|
return currentUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (location.startsWith('http://') || location.startsWith('https://')) {
|
||||||
|
currentUrl = location;
|
||||||
|
} else if (location.startsWith('/')) {
|
||||||
|
const urlObj = new URL(currentUrl);
|
||||||
|
currentUrl = `${urlObj.protocol}//${urlObj.host}${location}`;
|
||||||
|
} else {
|
||||||
|
const urlObj = new URL(currentUrl);
|
||||||
|
const pathParts = urlObj.pathname.split('/');
|
||||||
|
pathParts.pop();
|
||||||
|
pathParts.push(location);
|
||||||
|
currentUrl = `${urlObj.protocol}//${urlObj.host}${pathParts.join('/')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
redirectCount++;
|
||||||
|
} else {
|
||||||
|
return currentUrl;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[xiaoya/play] 获取最终 URL 失败:', error);
|
||||||
|
return currentUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return currentUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/xiaoya/play?path=<path>&format=json
|
||||||
|
* 获取小雅视频的播放链接(优先使用视频预览流,失败时降级到直连)
|
||||||
|
* path参数为base58编码的路径
|
||||||
|
* format=json: 返回 JSON 格式(用于 play 页面)
|
||||||
|
* 默认: 返回重定向(用于 tvbox 等)
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const encodedPath = searchParams.get('path');
|
||||||
|
const format = searchParams.get('format'); // 新增 format 参数
|
||||||
|
|
||||||
|
if (!encodedPath) {
|
||||||
|
return NextResponse.json({ error: '缺少参数' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对path进行base58解码
|
||||||
|
const { base58Decode } = await import('@/lib/utils');
|
||||||
|
const path = base58Decode(encodedPath);
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
const xiaoyaConfig = config.XiaoyaConfig;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!xiaoyaConfig ||
|
||||||
|
!xiaoyaConfig.Enabled ||
|
||||||
|
!xiaoyaConfig.ServerURL
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: '小雅未配置或未启用' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = new XiaoyaClient(
|
||||||
|
xiaoyaConfig.ServerURL,
|
||||||
|
xiaoyaConfig.Username,
|
||||||
|
xiaoyaConfig.Password,
|
||||||
|
xiaoyaConfig.Token
|
||||||
|
);
|
||||||
|
|
||||||
|
// 优先尝试视频预览流方法
|
||||||
|
try {
|
||||||
|
const token = await client.getToken();
|
||||||
|
|
||||||
|
const response = await fetch(`${xiaoyaConfig.ServerURL}/api/fs/other`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': token,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
path: path,
|
||||||
|
method: 'video_preview',
|
||||||
|
password: '',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`视频预览请求失败: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.code !== 200) {
|
||||||
|
throw new Error(`视频预览失败: ${data.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskList = data.data?.video_preview_play_info?.live_transcoding_task_list;
|
||||||
|
if (!taskList || taskList.length === 0) {
|
||||||
|
throw new Error('未找到可用的播放链接');
|
||||||
|
}
|
||||||
|
|
||||||
|
const qualityOrder: Record<string, number> = {
|
||||||
|
'FHD': 1,
|
||||||
|
'HD': 2,
|
||||||
|
'LD': 3,
|
||||||
|
'SD': 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
const qualities = taskList
|
||||||
|
.filter((task: any) => task.status === 'finished')
|
||||||
|
.map((task: any) => ({
|
||||||
|
name: task.template_id,
|
||||||
|
url: task.url,
|
||||||
|
}))
|
||||||
|
.sort((a: any, b: any) => (qualityOrder[a.name] || 999) - (qualityOrder[b.name] || 999));
|
||||||
|
|
||||||
|
if (qualities.length === 0) {
|
||||||
|
throw new Error('未找到已完成的播放链接');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果指定了 format=json,返回 JSON 格式
|
||||||
|
if (format === 'json') {
|
||||||
|
return NextResponse.json({
|
||||||
|
url: qualities[0].url,
|
||||||
|
qualities
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认返回重定向(用于 tvbox)
|
||||||
|
return NextResponse.redirect(qualities[0].url);
|
||||||
|
} catch (error) {
|
||||||
|
// 视频预览流失败,降级到直连方法
|
||||||
|
console.log('[xiaoya/play] 视频预览流失败,降级到直连方法:', (error as Error).message);
|
||||||
|
|
||||||
|
const playUrl = await client.getDownloadUrl(path);
|
||||||
|
|
||||||
|
// 如果指定了 format=json,使用 getFinalUrl 并返回 JSON
|
||||||
|
if (format === 'json') {
|
||||||
|
const finalUrl = await getFinalUrl(playUrl);
|
||||||
|
return NextResponse.json({ url: finalUrl });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认返回重定向(用于 tvbox)
|
||||||
|
return NextResponse.redirect(playUrl);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { getAuthInfoFromCookie } from '@/lib/auth';
|
||||||
|
import { getConfig } from '@/lib/config';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/xiaoya/search?keyword=<keyword>&type=<type>
|
||||||
|
* 搜索小雅视频(使用小雅的网页搜索引擎)
|
||||||
|
*/
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const authInfo = getAuthInfoFromCookie(request);
|
||||||
|
if (!authInfo || !authInfo.username) {
|
||||||
|
return NextResponse.json({ error: '未授权' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
const keyword = searchParams.get('keyword');
|
||||||
|
const type = searchParams.get('type') || 'video'; // video, music, ebook, all
|
||||||
|
|
||||||
|
if (!keyword) {
|
||||||
|
return NextResponse.json({ error: '缺少搜索关键词' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = await getConfig();
|
||||||
|
const xiaoyaConfig = config.XiaoyaConfig;
|
||||||
|
|
||||||
|
if (
|
||||||
|
!xiaoyaConfig ||
|
||||||
|
!xiaoyaConfig.Enabled ||
|
||||||
|
!xiaoyaConfig.ServerURL
|
||||||
|
) {
|
||||||
|
return NextResponse.json({ error: '小雅未配置或未启用' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用小雅的搜索引擎
|
||||||
|
const searchUrl = `${xiaoyaConfig.ServerURL}/search?box=${encodeURIComponent(keyword)}&type=${type}&url=`;
|
||||||
|
|
||||||
|
const response = await fetch(searchUrl, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`搜索请求失败: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const html = await response.text();
|
||||||
|
|
||||||
|
// 解析 HTML 中的链接
|
||||||
|
// 格式: <a href=/path/to/file>path/to/file</a>
|
||||||
|
const linkRegex = /<a href=([^>]+)>([^<]+)<\/a>/g;
|
||||||
|
const results: Array<{ name: string; path: string }> = [];
|
||||||
|
|
||||||
|
let match;
|
||||||
|
while ((match = linkRegex.exec(html)) !== null) {
|
||||||
|
let path = match[1];
|
||||||
|
const displayText = match[2];
|
||||||
|
|
||||||
|
// 跳过返回首页和频道链接
|
||||||
|
if (path === '/' || path.startsWith('http')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL 解码路径
|
||||||
|
try {
|
||||||
|
path = decodeURIComponent(path);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('URL 解码失败:', path, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取文件名(路径的最后一部分)
|
||||||
|
const pathParts = displayText.split('/');
|
||||||
|
const fileName = pathParts[pathParts.length - 1];
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
name: fileName,
|
||||||
|
path: path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
videos: results,
|
||||||
|
total: results.length,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('小雅搜索失败:', error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: (error as Error).message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-2
@@ -66,6 +66,7 @@ export default async function RootLayout({
|
|||||||
let tmdbApiKey = '';
|
let tmdbApiKey = '';
|
||||||
let openListEnabled = false;
|
let openListEnabled = false;
|
||||||
let embyEnabled = false;
|
let embyEnabled = false;
|
||||||
|
let xiaoyaEnabled = false;
|
||||||
let loginBackgroundImage = '';
|
let loginBackgroundImage = '';
|
||||||
let registerBackgroundImage = '';
|
let registerBackgroundImage = '';
|
||||||
let enableRegistration = false;
|
let enableRegistration = false;
|
||||||
@@ -81,6 +82,7 @@ export default async function RootLayout({
|
|||||||
let aiEnablePlayPageEntry = false;
|
let aiEnablePlayPageEntry = false;
|
||||||
let aiDefaultMessageNoVideo = '';
|
let aiDefaultMessageNoVideo = '';
|
||||||
let aiDefaultMessageWithVideo = '';
|
let aiDefaultMessageWithVideo = '';
|
||||||
|
let enableMovieRequest = true;
|
||||||
let customCategories = [] as {
|
let customCategories = [] as {
|
||||||
name: string;
|
name: string;
|
||||||
type: 'movie' | 'tv';
|
type: 'movie' | 'tv';
|
||||||
@@ -123,6 +125,8 @@ export default async function RootLayout({
|
|||||||
aiEnablePlayPageEntry = config.AIConfig?.EnablePlayPageEntry || false;
|
aiEnablePlayPageEntry = config.AIConfig?.EnablePlayPageEntry || false;
|
||||||
aiDefaultMessageNoVideo = config.AIConfig?.DefaultMessageNoVideo || '';
|
aiDefaultMessageNoVideo = config.AIConfig?.DefaultMessageNoVideo || '';
|
||||||
aiDefaultMessageWithVideo = config.AIConfig?.DefaultMessageWithVideo || '';
|
aiDefaultMessageWithVideo = config.AIConfig?.DefaultMessageWithVideo || '';
|
||||||
|
// 求片功能配置
|
||||||
|
enableMovieRequest = config.SiteConfig.EnableMovieRequest ?? true;
|
||||||
// 检查是否启用了 OpenList 功能
|
// 检查是否启用了 OpenList 功能
|
||||||
openListEnabled = !!(
|
openListEnabled = !!(
|
||||||
config.OpenListConfig?.Enabled &&
|
config.OpenListConfig?.Enabled &&
|
||||||
@@ -136,6 +140,11 @@ export default async function RootLayout({
|
|||||||
config.EmbyConfig.Sources.length > 0 &&
|
config.EmbyConfig.Sources.length > 0 &&
|
||||||
config.EmbyConfig.Sources.some(s => s.enabled && s.ServerURL)
|
config.EmbyConfig.Sources.some(s => s.enabled && s.ServerURL)
|
||||||
);
|
);
|
||||||
|
// 检查是否启用了小雅功能
|
||||||
|
xiaoyaEnabled = !!(
|
||||||
|
config.XiaoyaConfig?.Enabled &&
|
||||||
|
config.XiaoyaConfig?.ServerURL
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
|
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
|
||||||
@@ -155,7 +164,8 @@ export default async function RootLayout({
|
|||||||
VOICE_CHAT_STRATEGY: process.env.NEXT_PUBLIC_VOICE_CHAT_STRATEGY || 'webrtc-fallback',
|
VOICE_CHAT_STRATEGY: process.env.NEXT_PUBLIC_VOICE_CHAT_STRATEGY || 'webrtc-fallback',
|
||||||
OPENLIST_ENABLED: openListEnabled,
|
OPENLIST_ENABLED: openListEnabled,
|
||||||
EMBY_ENABLED: embyEnabled,
|
EMBY_ENABLED: embyEnabled,
|
||||||
PRIVATE_LIBRARY_ENABLED: openListEnabled || embyEnabled,
|
XIAOYA_ENABLED: xiaoyaEnabled,
|
||||||
|
PRIVATE_LIBRARY_ENABLED: openListEnabled || embyEnabled || xiaoyaEnabled,
|
||||||
LOGIN_BACKGROUND_IMAGE: loginBackgroundImage,
|
LOGIN_BACKGROUND_IMAGE: loginBackgroundImage,
|
||||||
REGISTER_BACKGROUND_IMAGE: registerBackgroundImage,
|
REGISTER_BACKGROUND_IMAGE: registerBackgroundImage,
|
||||||
ENABLE_REGISTRATION: enableRegistration,
|
ENABLE_REGISTRATION: enableRegistration,
|
||||||
@@ -171,7 +181,7 @@ export default async function RootLayout({
|
|||||||
AI_ENABLE_PLAYPAGE_ENTRY: aiEnablePlayPageEntry,
|
AI_ENABLE_PLAYPAGE_ENTRY: aiEnablePlayPageEntry,
|
||||||
AI_DEFAULT_MESSAGE_NO_VIDEO: aiDefaultMessageNoVideo,
|
AI_DEFAULT_MESSAGE_NO_VIDEO: aiDefaultMessageNoVideo,
|
||||||
AI_DEFAULT_MESSAGE_WITH_VIDEO: aiDefaultMessageWithVideo,
|
AI_DEFAULT_MESSAGE_WITH_VIDEO: aiDefaultMessageWithVideo,
|
||||||
ENABLE_SOURCE_SEARCH: process.env.NEXT_PUBLIC_ENABLE_SOURCE_SEARCH !== 'false',
|
ENABLE_MOVIE_REQUEST: enableMovieRequest,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -0,0 +1,430 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { CheckCircle, AlertCircle, Plus } from 'lucide-react';
|
||||||
|
|
||||||
|
import PageLayout from '@/components/PageLayout';
|
||||||
|
|
||||||
|
interface TMDBResult {
|
||||||
|
id: number;
|
||||||
|
title?: string;
|
||||||
|
name?: string;
|
||||||
|
release_date?: string;
|
||||||
|
first_air_date?: string;
|
||||||
|
poster_path?: string;
|
||||||
|
overview?: string;
|
||||||
|
media_type: 'movie' | 'tv';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MovieRequest {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
year?: string;
|
||||||
|
mediaType: 'movie' | 'tv';
|
||||||
|
season?: number;
|
||||||
|
poster?: string;
|
||||||
|
requestCount: number;
|
||||||
|
status: 'pending' | 'fulfilled';
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MovieRequestPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState('');
|
||||||
|
const [searchResults, setSearchResults] = useState<TMDBResult[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [showSeasonDialog, setShowSeasonDialog] = useState(false);
|
||||||
|
const [selectedItem, setSelectedItem] = useState<TMDBResult | null>(null);
|
||||||
|
const [seasons, setSeasons] = useState<Array<{ season_number: number; name: string; poster_path?: string | null }>>([]);
|
||||||
|
const [loadingSeasons, setLoadingSeasons] = useState(false);
|
||||||
|
const [selectedSeason, setSelectedSeason] = useState<number>(1);
|
||||||
|
const [alertModal, setAlertModal] = useState<{
|
||||||
|
isOpen: boolean;
|
||||||
|
type: 'success' | 'error';
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
}>({ isOpen: false, type: 'success', title: '', message: '' });
|
||||||
|
const [myRequests, setMyRequests] = useState<MovieRequest[]>([]);
|
||||||
|
const [loadingMyRequests, setLoadingMyRequests] = useState(true);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [isFeatureEnabled, setIsFeatureEnabled] = useState(true);
|
||||||
|
|
||||||
|
// 检查求片功能是否启用
|
||||||
|
useEffect(() => {
|
||||||
|
const runtimeConfig = (window as any).RUNTIME_CONFIG;
|
||||||
|
if (runtimeConfig && runtimeConfig.ENABLE_MOVIE_REQUEST === false) {
|
||||||
|
setIsFeatureEnabled(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// TMDB搜索
|
||||||
|
const handleSearch = async () => {
|
||||||
|
if (!searchKeyword.trim()) return;
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tmdb/search?query=${encodeURIComponent(searchKeyword)}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.results) {
|
||||||
|
setSearchResults(data.results.slice(0, 20));
|
||||||
|
} else {
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('搜索失败:', err);
|
||||||
|
setAlertModal({ isOpen: true, type: 'error', title: '搜索失败', message: '请稍后重试' });
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 提交求片
|
||||||
|
const handleRequest = async (item: TMDBResult) => {
|
||||||
|
if (item.media_type === 'tv') {
|
||||||
|
setSelectedItem(item);
|
||||||
|
setLoadingSeasons(true);
|
||||||
|
setShowSeasonDialog(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/tmdb/seasons?tvId=${item.id}`);
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.seasons) {
|
||||||
|
const validSeasons = data.seasons.filter((s: any) => s.season_number > 0);
|
||||||
|
setSeasons(validSeasons);
|
||||||
|
|
||||||
|
if (validSeasons.length === 1) {
|
||||||
|
// 只有一季,自动提交
|
||||||
|
setShowSeasonDialog(false);
|
||||||
|
submitRequest(item, validSeasons[0].season_number);
|
||||||
|
} else {
|
||||||
|
setSelectedSeason(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载季度失败:', err);
|
||||||
|
} finally {
|
||||||
|
setLoadingSeasons(false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
submitRequest(item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitRequest = async (item: TMDBResult, season?: number) => {
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
let poster = item.poster_path ? `https://image.tmdb.org/t/p/w500${item.poster_path}` : undefined;
|
||||||
|
let title = item.title || item.name || '';
|
||||||
|
|
||||||
|
if (season && seasons.length > 0) {
|
||||||
|
const seasonData = seasons.find(s => s.season_number === season);
|
||||||
|
if (seasonData) {
|
||||||
|
title = `${title} ${seasonData.name}`;
|
||||||
|
if (seasonData.poster_path) {
|
||||||
|
poster = `https://image.tmdb.org/t/p/w500${seasonData.poster_path}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch('/api/movie-requests', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
tmdbId: item.id,
|
||||||
|
title,
|
||||||
|
year: (item.release_date || item.first_air_date)?.split('-')[0],
|
||||||
|
mediaType: item.media_type,
|
||||||
|
season,
|
||||||
|
poster,
|
||||||
|
overview: item.overview,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
setShowSeasonDialog(false);
|
||||||
|
setAlertModal({ isOpen: true, type: 'success', title: '求片成功', message: data.message });
|
||||||
|
refreshMyRequests();
|
||||||
|
} else {
|
||||||
|
setAlertModal({ isOpen: true, type: 'error', title: '求片失败', message: data.error || '请稍后重试' });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('求片失败:', err);
|
||||||
|
setAlertModal({ isOpen: true, type: 'error', title: '求片失败', message: '请稍后重试' });
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSeasonConfirm = () => {
|
||||||
|
if (selectedItem) {
|
||||||
|
submitRequest(selectedItem, selectedSeason);
|
||||||
|
}
|
||||||
|
setShowSeasonDialog(false);
|
||||||
|
setSelectedItem(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 加载我的求片列表
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchMyRequests = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/movie-requests?my=true');
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.requests) {
|
||||||
|
setMyRequests(data.requests);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('加载求片列表失败:', err);
|
||||||
|
} finally {
|
||||||
|
setLoadingMyRequests(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchMyRequests();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 刷新我的求片列表
|
||||||
|
const refreshMyRequests = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/movie-requests?my=true');
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.requests) {
|
||||||
|
setMyRequests(data.requests);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('刷新求片列表失败:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageLayout activePath='/movie-request'>
|
||||||
|
<div className='container mx-auto px-4 py-6'>
|
||||||
|
<div className='mb-6'>
|
||||||
|
<h1 className='text-2xl font-bold text-gray-900 dark:text-gray-100'>
|
||||||
|
求片
|
||||||
|
</h1>
|
||||||
|
<p className='text-sm text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
{isFeatureEnabled ? '搜索并提交您想看的影片' : '求片功能已关闭,仅可查看已求片列表'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 功能关闭提示 */}
|
||||||
|
{!isFeatureEnabled && (
|
||||||
|
<div className='mb-6 p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg'>
|
||||||
|
<p className='text-sm text-yellow-800 dark:text-yellow-200'>
|
||||||
|
求片功能已被管理员关闭,您可以查看已提交的求片记录
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 搜索框 - 仅在功能启用时显示 */}
|
||||||
|
{isFeatureEnabled && (
|
||||||
|
<div className='mb-6'>
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='搜索影片名称...'
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSearch();
|
||||||
|
}}
|
||||||
|
className='flex-1 px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={!searchKeyword.trim() || isSearching}
|
||||||
|
className='px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed'
|
||||||
|
>
|
||||||
|
{isSearching ? '搜索中...' : '搜索'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 我的求片列表 */}
|
||||||
|
{searchResults.length === 0 && (
|
||||||
|
<div className='mb-8'>
|
||||||
|
<h2 className='text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4'>
|
||||||
|
我的求片
|
||||||
|
</h2>
|
||||||
|
{loadingMyRequests ? (
|
||||||
|
<div className='flex justify-center py-8'>
|
||||||
|
<div className='w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin' />
|
||||||
|
</div>
|
||||||
|
) : myRequests.length === 0 ? (
|
||||||
|
<div className='text-center py-8 text-gray-500 dark:text-gray-400'>
|
||||||
|
暂无求片记录
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4'>
|
||||||
|
{myRequests.map((request) => (
|
||||||
|
<div
|
||||||
|
key={request.id}
|
||||||
|
className='bg-white dark:bg-gray-800 rounded-lg overflow-hidden shadow hover:shadow-lg transition-shadow'
|
||||||
|
>
|
||||||
|
{request.poster ? (
|
||||||
|
<img
|
||||||
|
src={request.poster}
|
||||||
|
alt={request.title}
|
||||||
|
className='w-full aspect-[2/3] object-cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className='w-full aspect-[2/3] bg-gray-200 dark:bg-gray-700 flex items-center justify-center'>
|
||||||
|
<span className='text-gray-400'>无海报</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className='p-3'>
|
||||||
|
<h3 className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate mb-1'>
|
||||||
|
{request.title}
|
||||||
|
</h3>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 mb-2'>
|
||||||
|
{request.year || '未知'} · {request.requestCount}人求片
|
||||||
|
</p>
|
||||||
|
<div className={`text-xs px-2 py-1 rounded text-center ${
|
||||||
|
request.status === 'fulfilled'
|
||||||
|
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
|
||||||
|
: 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300'
|
||||||
|
}`}>
|
||||||
|
{request.status === 'fulfilled' ? '已上架' : '待处理'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 搜索结果 */}
|
||||||
|
{searchResults.length > 0 ? (
|
||||||
|
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4'>
|
||||||
|
{searchResults.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className='bg-white dark:bg-gray-800 rounded-lg overflow-hidden shadow hover:shadow-lg transition-shadow'
|
||||||
|
>
|
||||||
|
{item.poster_path ? (
|
||||||
|
<img
|
||||||
|
src={`https://image.tmdb.org/t/p/w500${item.poster_path}`}
|
||||||
|
alt={item.title || item.name}
|
||||||
|
className='w-full aspect-[2/3] object-cover'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className='w-full aspect-[2/3] bg-gray-200 dark:bg-gray-700 flex items-center justify-center'>
|
||||||
|
<span className='text-gray-400'>无海报</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className='p-3'>
|
||||||
|
<h3 className='text-sm font-medium text-gray-900 dark:text-gray-100 truncate mb-1'>
|
||||||
|
{item.title || item.name}
|
||||||
|
</h3>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 mb-2'>
|
||||||
|
{(item.release_date || item.first_air_date)?.split('-')[0] || '未知'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRequest(item)}
|
||||||
|
disabled={submitting || !isFeatureEnabled}
|
||||||
|
className='w-full px-3 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed'
|
||||||
|
>
|
||||||
|
{submitting ? '处理中...' : !isFeatureEnabled ? '功能已关闭' : '求片'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : searchKeyword && !isSearching ? (
|
||||||
|
<div className='text-center py-12 text-gray-500 dark:text-gray-400'>
|
||||||
|
未找到相关影片
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 提示弹窗 */}
|
||||||
|
{alertModal.isOpen && typeof window !== 'undefined' && createPortal(
|
||||||
|
<div className='fixed inset-0 bg-black/50 z-[1002] flex items-center justify-center p-4'>
|
||||||
|
<div className='bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-sm w-full p-6'>
|
||||||
|
<div className='flex justify-center mb-4'>
|
||||||
|
{alertModal.type === 'success' ? (
|
||||||
|
<CheckCircle className='w-12 h-12 text-green-500' />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className='w-12 h-12 text-red-500' />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h3 className='text-lg font-semibold text-gray-900 dark:text-gray-100 text-center mb-2'>
|
||||||
|
{alertModal.title}
|
||||||
|
</h3>
|
||||||
|
<p className='text-gray-600 dark:text-gray-400 text-center mb-4'>
|
||||||
|
{alertModal.message}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setAlertModal({ ...alertModal, isOpen: false })}
|
||||||
|
className='w-full px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg'
|
||||||
|
>
|
||||||
|
确定
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 季度选择弹窗 */}
|
||||||
|
{showSeasonDialog && typeof window !== 'undefined' && createPortal(
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className='fixed inset-0 bg-black/50 backdrop-blur-sm z-[1000]'
|
||||||
|
onClick={() => setShowSeasonDialog(false)}
|
||||||
|
/>
|
||||||
|
<div className='fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-full max-w-md bg-white dark:bg-gray-900 rounded-xl shadow-xl z-[1001] p-6'>
|
||||||
|
<h3 className='text-lg font-bold text-gray-800 dark:text-gray-200 mb-4'>
|
||||||
|
选择季度
|
||||||
|
</h3>
|
||||||
|
<p className='text-sm text-gray-600 dark:text-gray-400 mb-4'>
|
||||||
|
{selectedItem?.title || selectedItem?.name}
|
||||||
|
</p>
|
||||||
|
{loadingSeasons ? (
|
||||||
|
<div className='flex justify-center py-8'>
|
||||||
|
<div className='w-6 h-6 border-2 border-blue-600 border-t-transparent rounded-full animate-spin' />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className='space-y-2 mb-4 max-h-60 overflow-y-auto'>
|
||||||
|
{seasons.map((season) => (
|
||||||
|
<button
|
||||||
|
key={season.season_number}
|
||||||
|
onClick={() => setSelectedSeason(season.season_number)}
|
||||||
|
className={`w-full p-3 rounded-lg text-left transition-colors ${
|
||||||
|
selectedSeason === season.season_number
|
||||||
|
? 'bg-blue-600 text-white'
|
||||||
|
: 'bg-gray-100 dark:bg-gray-800 text-gray-900 dark:text-gray-100 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{season.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className='flex gap-2'>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowSeasonDialog(false)}
|
||||||
|
className='flex-1 px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600'
|
||||||
|
>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSeasonConfirm}
|
||||||
|
className='flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg'
|
||||||
|
>
|
||||||
|
确认
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</PageLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
+364
-37
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Heart, Search, X, Cloud, Sparkles } from 'lucide-react';
|
import { Heart, Search, X, Cloud, Sparkles, AlertCircle } from 'lucide-react';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { Suspense, useEffect, useRef, useState } from 'react';
|
import { Suspense, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
@@ -51,6 +51,7 @@ import {
|
|||||||
import type { DanmakuAnime, DanmakuSelection, DanmakuSettings, DanmakuComment } from '@/lib/danmaku/types';
|
import type { DanmakuAnime, DanmakuSelection, DanmakuSettings, DanmakuComment } from '@/lib/danmaku/types';
|
||||||
import { SearchResult, DanmakuFilterConfig, EpisodeFilterConfig } from '@/lib/types';
|
import { SearchResult, DanmakuFilterConfig, EpisodeFilterConfig } from '@/lib/types';
|
||||||
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
|
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
|
||||||
|
import { getTMDBImageUrl } from '@/lib/tmdb.search';
|
||||||
|
|
||||||
import EpisodeSelector from '@/components/EpisodeSelector';
|
import EpisodeSelector from '@/components/EpisodeSelector';
|
||||||
import DownloadEpisodeSelector from '@/components/DownloadEpisodeSelector';
|
import DownloadEpisodeSelector from '@/components/DownloadEpisodeSelector';
|
||||||
@@ -63,6 +64,7 @@ import AIChatPanel from '@/components/AIChatPanel';
|
|||||||
import { useEnableComments } from '@/hooks/useEnableComments';
|
import { useEnableComments } from '@/hooks/useEnableComments';
|
||||||
import PansouSearch from '@/components/PansouSearch';
|
import PansouSearch from '@/components/PansouSearch';
|
||||||
import CustomHeatmap from '@/components/CustomHeatmap';
|
import CustomHeatmap from '@/components/CustomHeatmap';
|
||||||
|
import CorrectDialog from '@/components/CorrectDialog';
|
||||||
|
|
||||||
// 扩展 HTMLVideoElement 类型以支持 hls 属性
|
// 扩展 HTMLVideoElement 类型以支持 hls 属性
|
||||||
declare global {
|
declare global {
|
||||||
@@ -122,6 +124,9 @@ function PlayPageClient() {
|
|||||||
const [aiEnabled, setAiEnabled] = useState(false);
|
const [aiEnabled, setAiEnabled] = useState(false);
|
||||||
const [aiDefaultMessageWithVideo, setAiDefaultMessageWithVideo] = useState('');
|
const [aiDefaultMessageWithVideo, setAiDefaultMessageWithVideo] = useState('');
|
||||||
|
|
||||||
|
// 纠错弹窗状态
|
||||||
|
const [showCorrectDialog, setShowCorrectDialog] = useState(false);
|
||||||
|
|
||||||
// 检查AI功能是否启用
|
// 检查AI功能是否启用
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
@@ -464,9 +469,13 @@ function PlayPageClient() {
|
|||||||
const [doubanAka, setDoubanAka] = useState<string[]>([]);
|
const [doubanAka, setDoubanAka] = useState<string[]>([]);
|
||||||
const [doubanYear, setDoubanYear] = useState<string>(''); // 从 pubdate 提取的年份
|
const [doubanYear, setDoubanYear] = useState<string>(''); // 从 pubdate 提取的年份
|
||||||
|
|
||||||
|
// 纠错后的描述信息(用于显示,不触发 detail 更新)
|
||||||
|
const [correctedDesc, setCorrectedDesc] = useState<string>('');
|
||||||
|
|
||||||
// 当前源和ID - source 直接存储完整格式(如 'emby_wumei' 或 'emby')
|
// 当前源和ID - source 直接存储完整格式(如 'emby_wumei' 或 'emby')
|
||||||
const [currentSource, setCurrentSource] = useState(searchParams.get('source') || '');
|
const [currentSource, setCurrentSource] = useState(searchParams.get('source') || '');
|
||||||
const [currentId, setCurrentId] = useState(searchParams.get('id') || '');
|
const [currentId, setCurrentId] = useState(searchParams.get('id') || '');
|
||||||
|
const [fileName] = useState(searchParams.get('fileName') || ''); // 小雅源:用户点击的文件名
|
||||||
|
|
||||||
// 解析 source 参数以获取 embyKey(仅用于 API 调用)
|
// 解析 source 参数以获取 embyKey(仅用于 API 调用)
|
||||||
const parseSourceForApi = (source: string): { source: string; embyKey?: string } => {
|
const parseSourceForApi = (source: string): { source: string; embyKey?: string } => {
|
||||||
@@ -993,6 +1002,11 @@ function PlayPageClient() {
|
|||||||
if (detCacheAge < detCacheMaxAge && data && data.backdrop) {
|
if (detCacheAge < detCacheMaxAge && data && data.backdrop) {
|
||||||
console.log('使用缓存的TMDB详情数据');
|
console.log('使用缓存的TMDB详情数据');
|
||||||
setTmdbBackdrop(data.backdrop);
|
setTmdbBackdrop(data.backdrop);
|
||||||
|
|
||||||
|
// 如果没有豆瓣ID,使用TMDb数据补充
|
||||||
|
if (!videoDoubanId || videoDoubanId === 0) {
|
||||||
|
populateDoubanFieldsFromTMDB(data);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1023,6 +1037,11 @@ function PlayPageClient() {
|
|||||||
if (result.backdrop) {
|
if (result.backdrop) {
|
||||||
setTmdbBackdrop(result.backdrop);
|
setTmdbBackdrop(result.backdrop);
|
||||||
|
|
||||||
|
// 如果没有豆瓣ID,使用TMDb数据补充
|
||||||
|
if (!videoDoubanId || videoDoubanId === 0) {
|
||||||
|
populateDoubanFieldsFromTMDB(result);
|
||||||
|
}
|
||||||
|
|
||||||
// 保存title到tmdbId的映射到localStorage(1个月)
|
// 保存title到tmdbId的映射到localStorage(1个月)
|
||||||
if (result.tmdbId) {
|
if (result.tmdbId) {
|
||||||
try {
|
try {
|
||||||
@@ -1056,13 +1075,46 @@ function PlayPageClient() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 辅助函数:使用TMDb数据填充豆瓣字段
|
||||||
|
const populateDoubanFieldsFromTMDB = (tmdbData: any) => {
|
||||||
|
// 设置评分
|
||||||
|
if (tmdbData.rating) {
|
||||||
|
const ratingValue = parseFloat(tmdbData.rating);
|
||||||
|
setDoubanRating({
|
||||||
|
value: ratingValue,
|
||||||
|
count: 0, // TMDb不提供评分人数
|
||||||
|
star_count: Math.round(ratingValue / 2), // 转换为5星制
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置年份
|
||||||
|
if (tmdbData.releaseDate) {
|
||||||
|
const year = tmdbData.releaseDate.split('-')[0];
|
||||||
|
setDoubanYear(year);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置card_subtitle(优先使用genres标签,否则使用年份和类型)
|
||||||
|
if (tmdbData.genres && Array.isArray(tmdbData.genres) && tmdbData.genres.length > 0) {
|
||||||
|
const genreNames = tmdbData.genres.map((g: any) => g.name).join(' / ');
|
||||||
|
setDoubanCardSubtitle(genreNames);
|
||||||
|
} else if (tmdbData.mediaType && tmdbData.releaseDate) {
|
||||||
|
// 兜底:如果没有genres,使用年份和类型
|
||||||
|
const year = tmdbData.releaseDate.split('-')[0];
|
||||||
|
const typeText = tmdbData.mediaType === 'movie' ? '电影' : '电视剧';
|
||||||
|
setDoubanCardSubtitle(`${year} / ${typeText}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
fetchTMDBBackdrop();
|
fetchTMDBBackdrop();
|
||||||
}, [videoTitle]);
|
}, [videoTitle, videoDoubanId]);
|
||||||
|
|
||||||
|
|
||||||
// 视频播放地址
|
// 视频播放地址
|
||||||
const [videoUrl, setVideoUrl] = useState('');
|
const [videoUrl, setVideoUrl] = useState('');
|
||||||
|
|
||||||
|
// 视频清晰度列表
|
||||||
|
const [videoQualities, setVideoQualities] = useState<Array<{name: string, url: string}>>([]);
|
||||||
|
|
||||||
// 视频源代理模式状态
|
// 视频源代理模式状态
|
||||||
const [sourceProxyMode, setSourceProxyMode] = useState(false);
|
const [sourceProxyMode, setSourceProxyMode] = useState(false);
|
||||||
|
|
||||||
@@ -1101,7 +1153,7 @@ function PlayPageClient() {
|
|||||||
|
|
||||||
// 保存优选时的测速结果,避免EpisodeSelector重复测速
|
// 保存优选时的测速结果,避免EpisodeSelector重复测速
|
||||||
const [precomputedVideoInfo, setPrecomputedVideoInfo] = useState<
|
const [precomputedVideoInfo, setPrecomputedVideoInfo] = useState<
|
||||||
Map<string, { quality: string; loadSpeed: string; pingTime: number }>
|
Map<string, { quality: string; loadSpeed: string; pingTime: number; bitrate: string }>
|
||||||
>(new Map());
|
>(new Map());
|
||||||
|
|
||||||
// 折叠状态(仅在 lg 及以上屏幕有效)
|
// 折叠状态(仅在 lg 及以上屏幕有效)
|
||||||
@@ -1199,7 +1251,7 @@ function PlayPageClient() {
|
|||||||
const batchSize = Math.ceil(sources.length / 2);
|
const batchSize = Math.ceil(sources.length / 2);
|
||||||
const allResults: Array<{
|
const allResults: Array<{
|
||||||
source: SearchResult;
|
source: SearchResult;
|
||||||
testResult: { quality: string; loadSpeed: string; pingTime: number };
|
testResult: { quality: string; loadSpeed: string; pingTime: number; bitrate: string };
|
||||||
} | null> = [];
|
} | null> = [];
|
||||||
|
|
||||||
for (let start = 0; start < sources.length; start += batchSize) {
|
for (let start = 0; start < sources.length; start += batchSize) {
|
||||||
@@ -1239,6 +1291,7 @@ function PlayPageClient() {
|
|||||||
quality: string;
|
quality: string;
|
||||||
loadSpeed: string;
|
loadSpeed: string;
|
||||||
pingTime: number;
|
pingTime: number;
|
||||||
|
bitrate: string;
|
||||||
hasError?: boolean;
|
hasError?: boolean;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
@@ -1255,7 +1308,7 @@ function PlayPageClient() {
|
|||||||
// 过滤出成功的结果用于优选计算
|
// 过滤出成功的结果用于优选计算
|
||||||
const successfulResults = allResults.filter(Boolean) as Array<{
|
const successfulResults = allResults.filter(Boolean) as Array<{
|
||||||
source: SearchResult;
|
source: SearchResult;
|
||||||
testResult: { quality: string; loadSpeed: string; pingTime: number };
|
testResult: { quality: string; loadSpeed: string; pingTime: number; bitrate: string };
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
setPrecomputedVideoInfo(newVideoInfoMap);
|
setPrecomputedVideoInfo(newVideoInfoMap);
|
||||||
@@ -1419,6 +1472,21 @@ function PlayPageClient() {
|
|||||||
detailData: SearchResult | null,
|
detailData: SearchResult | null,
|
||||||
episodeIndex: number
|
episodeIndex: number
|
||||||
) => {
|
) => {
|
||||||
|
// 动态设置 referrer policy:只在小雅源时不发送 Referer
|
||||||
|
const existingMeta = document.querySelector('meta[name="referrer"]');
|
||||||
|
if (detailData?.source === 'xiaoya') {
|
||||||
|
if (!existingMeta) {
|
||||||
|
const meta = document.createElement('meta');
|
||||||
|
meta.name = 'referrer';
|
||||||
|
meta.content = 'no-referrer';
|
||||||
|
document.head.appendChild(meta);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (existingMeta) {
|
||||||
|
existingMeta.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!detailData ||
|
!detailData ||
|
||||||
!detailData.episodes ||
|
!detailData.episodes ||
|
||||||
@@ -1434,6 +1502,33 @@ function PlayPageClient() {
|
|||||||
|
|
||||||
let newUrl = detailData?.episodes[episodeIndex] || '';
|
let newUrl = detailData?.episodes[episodeIndex] || '';
|
||||||
|
|
||||||
|
// 如果是小雅或 openlist 接口,先请求获取真实 URL
|
||||||
|
if (newUrl.startsWith('/api/xiaoya/play') || newUrl.startsWith('/api/openlist/play')) {
|
||||||
|
try {
|
||||||
|
// 添加 format=json 参数
|
||||||
|
const separator = newUrl.includes('?') ? '&' : '?';
|
||||||
|
const fetchUrl = `${newUrl}${separator}format=json`;
|
||||||
|
|
||||||
|
const response = await fetch(fetchUrl);
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.url) {
|
||||||
|
newUrl = data.url;
|
||||||
|
// 保存清晰度列表
|
||||||
|
if (data.qualities && data.qualities.length > 0) {
|
||||||
|
setVideoQualities(data.qualities);
|
||||||
|
} else {
|
||||||
|
setVideoQualities([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取播放链接失败:', error);
|
||||||
|
setVideoQualities([]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 非小雅/openlist 源,清空清晰度列表
|
||||||
|
setVideoQualities([]);
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否有本地下载的文件
|
// 检查是否有本地下载的文件
|
||||||
const hasLocalFile = await checkLocalDownload(currentSource, currentId, episodeIndex);
|
const hasLocalFile = await checkLocalDownload(currentSource, currentId, episodeIndex);
|
||||||
|
|
||||||
@@ -2267,18 +2362,23 @@ function PlayPageClient() {
|
|||||||
const fetchSourceDetail = async (
|
const fetchSourceDetail = async (
|
||||||
source: string,
|
source: string,
|
||||||
id: string,
|
id: string,
|
||||||
title: string
|
title: string,
|
||||||
|
fileNameParam?: string
|
||||||
): Promise<SearchResult[]> => {
|
): Promise<SearchResult[]> => {
|
||||||
try {
|
try {
|
||||||
const detailResponse = await fetch(
|
let url = `/api/source-detail?source=${source}&id=${id}&title=${encodeURIComponent(title)}`;
|
||||||
`/api/source-detail?source=${source}&id=${id}&title=${encodeURIComponent(title)}`
|
// 如果有fileName参数(小雅源),添加到URL
|
||||||
);
|
if (fileNameParam) {
|
||||||
|
url += `&fileName=${encodeURIComponent(fileNameParam)}`;
|
||||||
|
}
|
||||||
|
const detailResponse = await fetch(url);
|
||||||
if (!detailResponse.ok) {
|
if (!detailResponse.ok) {
|
||||||
throw new Error('获取视频详情失败');
|
throw new Error('获取视频详情失败');
|
||||||
}
|
}
|
||||||
const detailData = (await detailResponse.json()) as SearchResult;
|
const detailData = (await detailResponse.json()) as SearchResult;
|
||||||
setAvailableSources([detailData]);
|
const sourcesWithCorrections = applyCorrectionsToSources([detailData]);
|
||||||
return [detailData];
|
setAvailableSources(sourcesWithCorrections);
|
||||||
|
return sourcesWithCorrections;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('获取视频详情失败:', err);
|
console.error('获取视频详情失败:', err);
|
||||||
return [];
|
return [];
|
||||||
@@ -2361,7 +2461,7 @@ function PlayPageClient() {
|
|||||||
: true)
|
: true)
|
||||||
);
|
);
|
||||||
|
|
||||||
setAvailableSources(results);
|
setAvailableSources(applyCorrectionsToSources(results));
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -2395,7 +2495,7 @@ function PlayPageClient() {
|
|||||||
? getType(result) === searchType
|
? getType(result) === searchType
|
||||||
: true)
|
: true)
|
||||||
);
|
);
|
||||||
setAvailableSources(results);
|
setAvailableSources(applyCorrectionsToSources(results));
|
||||||
return results;
|
return results;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setSourceSearchError(err instanceof Error ? err.message : '搜索失败');
|
setSourceSearchError(err instanceof Error ? err.message : '搜索失败');
|
||||||
@@ -2428,7 +2528,13 @@ function PlayPageClient() {
|
|||||||
// 先快速获取当前源的详情
|
// 先快速获取当前源的详情
|
||||||
try {
|
try {
|
||||||
// currentSource 已经是完整格式(如 'emby_wumei')
|
// currentSource 已经是完整格式(如 'emby_wumei')
|
||||||
const currentSourceDetail = await fetchSourceDetail(currentSource, currentId, searchTitle || videoTitle);
|
// 如果是小雅源且有fileName参数,传递给API
|
||||||
|
const currentSourceDetail = await fetchSourceDetail(
|
||||||
|
currentSource,
|
||||||
|
currentId,
|
||||||
|
searchTitle || videoTitle,
|
||||||
|
currentSource === 'xiaoya' ? fileName : undefined
|
||||||
|
);
|
||||||
if (currentSourceDetail.length > 0) {
|
if (currentSourceDetail.length > 0) {
|
||||||
detailData = currentSourceDetail[0];
|
detailData = currentSourceDetail[0];
|
||||||
sourcesInfo = currentSourceDetail;
|
sourcesInfo = currentSourceDetail;
|
||||||
@@ -2448,7 +2554,7 @@ function PlayPageClient() {
|
|||||||
allSources.push(source);
|
allSources.push(source);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setAvailableSources(allSources);
|
setAvailableSources(applyCorrectionsToSources(allSources));
|
||||||
setBackgroundSourcesLoading(false);
|
setBackgroundSourcesLoading(false);
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
console.error('异步获取其他源失败:', err);
|
console.error('异步获取其他源失败:', err);
|
||||||
@@ -2500,7 +2606,7 @@ function PlayPageClient() {
|
|||||||
setLoadingStage('preferring');
|
setLoadingStage('preferring');
|
||||||
setLoadingMessage('⚡ 正在优选最佳播放源...');
|
setLoadingMessage('⚡ 正在优选最佳播放源...');
|
||||||
|
|
||||||
// 过滤掉 openlist 和所有 emby 源,它们不参与测速
|
// 过滤掉 openlist、所有 emby 源和 xiaoya 源,它们不参与测速
|
||||||
const sourcesToTest = sourcesInfo.filter(s => {
|
const sourcesToTest = sourcesInfo.filter(s => {
|
||||||
// 检查是否为 openlist
|
// 检查是否为 openlist
|
||||||
if (s.source === 'openlist') return false;
|
if (s.source === 'openlist') return false;
|
||||||
@@ -2508,19 +2614,23 @@ function PlayPageClient() {
|
|||||||
// 检查是否为 emby 源(包括 emby 和 emby_xxx 格式)
|
// 检查是否为 emby 源(包括 emby 和 emby_xxx 格式)
|
||||||
if (s.source === 'emby' || s.source.startsWith('emby_')) return false;
|
if (s.source === 'emby' || s.source.startsWith('emby_')) return false;
|
||||||
|
|
||||||
|
// 检查是否为 xiaoya 源
|
||||||
|
if (s.source === 'xiaoya') return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
const excludedSources = sourcesInfo.filter(s =>
|
const excludedSources = sourcesInfo.filter(s =>
|
||||||
s.source === 'openlist' ||
|
s.source === 'openlist' ||
|
||||||
s.source === 'emby' ||
|
s.source === 'emby' ||
|
||||||
s.source.startsWith('emby_')
|
s.source.startsWith('emby_') ||
|
||||||
|
s.source === 'xiaoya'
|
||||||
);
|
);
|
||||||
|
|
||||||
if (sourcesToTest.length > 0) {
|
if (sourcesToTest.length > 0) {
|
||||||
detailData = await preferBestSource(sourcesToTest);
|
detailData = await preferBestSource(sourcesToTest);
|
||||||
} else if (excludedSources.length > 0) {
|
} else if (excludedSources.length > 0) {
|
||||||
// 如果只有 openlist/emby 源,直接使用第一个
|
// 如果只有 openlist/emby/xiaoya 源,直接使用第一个
|
||||||
detailData = excludedSources[0];
|
detailData = excludedSources[0];
|
||||||
} else {
|
} else {
|
||||||
detailData = sourcesInfo[0];
|
detailData = sourcesInfo[0];
|
||||||
@@ -2542,23 +2652,43 @@ function PlayPageClient() {
|
|||||||
// 直接使用 detailData.source(已经是完整格式)
|
// 直接使用 detailData.source(已经是完整格式)
|
||||||
setCurrentSource(detailData.source);
|
setCurrentSource(detailData.source);
|
||||||
setCurrentId(detailData.id);
|
setCurrentId(detailData.id);
|
||||||
|
|
||||||
|
// 如果是小雅源,检查并应用纠错信息
|
||||||
|
if (detailData.source === 'xiaoya') {
|
||||||
|
const correction = getXiaoyaCorrection(detailData.source, detailData.id);
|
||||||
|
if (correction) {
|
||||||
|
console.log('发现小雅源纠错信息,正在应用...', correction);
|
||||||
|
detailData = applyCorrection(detailData, correction);
|
||||||
|
// 同时设置纠错后的描述
|
||||||
|
if (correction.overview) {
|
||||||
|
setCorrectedDesc(correction.overview);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新所有相关状态(在应用纠错信息之后)
|
||||||
setVideoYear(detailData.year);
|
setVideoYear(detailData.year);
|
||||||
setVideoTitle(detailData.title || videoTitleRef.current);
|
setVideoTitle(detailData.title || videoTitleRef.current);
|
||||||
setVideoCover(detailData.poster);
|
setVideoCover(detailData.poster);
|
||||||
setVideoDoubanId(detailData.douban_id || 0);
|
setVideoDoubanId(detailData.douban_id || 0);
|
||||||
|
|
||||||
setDetail(detailData);
|
setDetail(detailData);
|
||||||
setSourceProxyMode(detailData.proxyMode || false); // 从 detail 数据中读取代理模式
|
setSourceProxyMode(detailData.proxyMode || false); // 从 detail 数据中读取代理模式
|
||||||
if (currentEpisodeIndex >= detailData.episodes.length) {
|
if (currentEpisodeIndex >= detailData.episodes.length) {
|
||||||
setCurrentEpisodeIndex(0);
|
setCurrentEpisodeIndex(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 规范URL参数(不更新title,避免循环刷新)
|
// 规范URL参数
|
||||||
const newUrl = new URL(window.location.href);
|
const newUrl = new URL(window.location.href);
|
||||||
newUrl.searchParams.set('source', detailData.source);
|
newUrl.searchParams.set('source', detailData.source);
|
||||||
newUrl.searchParams.set('id', detailData.id);
|
newUrl.searchParams.set('id', detailData.id);
|
||||||
newUrl.searchParams.set('year', detailData.year);
|
newUrl.searchParams.set('year', detailData.year);
|
||||||
// 保持原有的 title,不更新
|
newUrl.searchParams.set('title', detailData.title);
|
||||||
newUrl.searchParams.delete('prefer');
|
newUrl.searchParams.delete('prefer');
|
||||||
|
// 只有当元数据不是从文件获取时,才删除fileName参数
|
||||||
|
if (detailData.metadataSource !== 'file') {
|
||||||
|
newUrl.searchParams.delete('fileName');
|
||||||
|
}
|
||||||
window.history.replaceState({}, '', newUrl.toString());
|
window.history.replaceState({}, '', newUrl.toString());
|
||||||
|
|
||||||
setLoadingStage('ready');
|
setLoadingStage('ready');
|
||||||
@@ -2570,18 +2700,52 @@ function PlayPageClient() {
|
|||||||
const key = generateStorageKey(detailData.source, detailData.id);
|
const key = generateStorageKey(detailData.source, detailData.id);
|
||||||
const record = allRecords[key];
|
const record = allRecords[key];
|
||||||
|
|
||||||
|
// 确定初始集数索引
|
||||||
|
let initialIndex = 0;
|
||||||
|
let shouldResumeTime = false;
|
||||||
|
|
||||||
if (record) {
|
if (record) {
|
||||||
const targetIndex = record.index - 1;
|
// 有播放记录
|
||||||
const targetTime = record.play_time;
|
const recordIndex = record.index - 1;
|
||||||
|
const recordTime = record.play_time;
|
||||||
|
|
||||||
// 更新当前选集索引
|
// 如果有initialEpisodeIndex(用户从文件点击进入)
|
||||||
if (targetIndex < detailData.episodes.length && targetIndex >= 0) {
|
if (detailData.initialEpisodeIndex !== undefined) {
|
||||||
setCurrentEpisodeIndex(targetIndex);
|
// 如果播放记录的集数和点击的文件集数一致,则使用播放记录的时间
|
||||||
currentEpisodeIndexRef.current = targetIndex;
|
if (recordIndex === detailData.initialEpisodeIndex) {
|
||||||
|
initialIndex = recordIndex;
|
||||||
|
shouldResumeTime = true;
|
||||||
|
resumeTimeRef.current = recordTime;
|
||||||
|
console.log('[Play] 播放记录集数与点击文件一致,恢复播放进度:', recordTime);
|
||||||
|
} else {
|
||||||
|
// 否则使用点击的文件集数,从头开始播放
|
||||||
|
initialIndex = detailData.initialEpisodeIndex;
|
||||||
|
console.log('[Play] 使用点击的文件集数:', initialIndex);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 没有initialEpisodeIndex,使用播放记录
|
||||||
|
initialIndex = recordIndex;
|
||||||
|
shouldResumeTime = true;
|
||||||
|
resumeTimeRef.current = recordTime;
|
||||||
|
console.log('[Play] 使用播放记录集数:', initialIndex);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// 没有播放记录
|
||||||
|
if (detailData.initialEpisodeIndex !== undefined) {
|
||||||
|
// 使用点击的文件集数
|
||||||
|
initialIndex = detailData.initialEpisodeIndex;
|
||||||
|
console.log('[Play] 没有播放记录,使用点击的文件集数:', initialIndex);
|
||||||
|
} else {
|
||||||
|
// 默认从第0集开始
|
||||||
|
initialIndex = 0;
|
||||||
|
console.log('[Play] 没有播放记录,从第0集开始');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 保存待恢复的播放进度,待播放器就绪后跳转
|
// 更新当前选集索引
|
||||||
resumeTimeRef.current = targetTime;
|
if (initialIndex < detailData.episodes.length && initialIndex >= 0) {
|
||||||
|
setCurrentEpisodeIndex(initialIndex);
|
||||||
|
currentEpisodeIndexRef.current = initialIndex;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('读取播放记录失败:', err);
|
console.error('读取播放记录失败:', err);
|
||||||
@@ -2836,9 +3000,33 @@ function PlayPageClient() {
|
|||||||
newUrl.searchParams.set('title', newDetail.title || newTitle);
|
newUrl.searchParams.set('title', newDetail.title || newTitle);
|
||||||
window.history.replaceState({}, '', newUrl.toString());
|
window.history.replaceState({}, '', newUrl.toString());
|
||||||
|
|
||||||
setVideoTitle(newDetail.title || newTitle);
|
// 如果是小雅源,检查并应用纠错信息
|
||||||
|
let finalTitle = newDetail.title || newTitle;
|
||||||
|
let finalCover = newDetail.poster;
|
||||||
|
let finalDesc = '';
|
||||||
|
|
||||||
|
if (newDetail.source === 'xiaoya') {
|
||||||
|
const correction = getXiaoyaCorrection(newDetail.source, newDetail.id);
|
||||||
|
if (correction) {
|
||||||
|
console.log('换源到小雅源,发现纠错信息,正在应用...', correction);
|
||||||
|
if (correction.title) {
|
||||||
|
finalTitle = correction.title;
|
||||||
|
}
|
||||||
|
if (correction.posterPath) {
|
||||||
|
finalCover = getTMDBImageUrl(correction.posterPath);
|
||||||
|
}
|
||||||
|
if (correction.overview) {
|
||||||
|
finalDesc = correction.overview;
|
||||||
|
}
|
||||||
|
// 应用纠错信息到 newDetail
|
||||||
|
newDetail = applyCorrection(newDetail, correction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setVideoTitle(finalTitle);
|
||||||
setVideoYear(newDetail.year);
|
setVideoYear(newDetail.year);
|
||||||
setVideoCover(newDetail.poster);
|
setVideoCover(finalCover);
|
||||||
|
setCorrectedDesc(finalDesc);
|
||||||
setVideoDoubanId(newDetail.douban_id || 0);
|
setVideoDoubanId(newDetail.douban_id || 0);
|
||||||
// newSource 已经是完整格式
|
// newSource 已经是完整格式
|
||||||
setCurrentSource(newSource);
|
setCurrentSource(newSource);
|
||||||
@@ -3164,6 +3352,18 @@ function PlayPageClient() {
|
|||||||
setDanmakuCount(danmakuData.length);
|
setDanmakuCount(danmakuData.length);
|
||||||
console.log(`弹幕加载成功,共 ${danmakuData.length} 条`);
|
console.log(`弹幕加载成功,共 ${danmakuData.length} 条`);
|
||||||
|
|
||||||
|
// 更新当前选择状态,包含弹幕数量
|
||||||
|
if (metadata) {
|
||||||
|
setCurrentDanmakuSelection({
|
||||||
|
animeId: metadata.animeId || 0,
|
||||||
|
episodeId: episodeId,
|
||||||
|
animeTitle: metadata.animeTitle || '',
|
||||||
|
episodeTitle: metadata.episodeTitle || '',
|
||||||
|
searchKeyword: metadata.searchKeyword,
|
||||||
|
danmakuCount: danmakuData.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 延迟一下让用户看到弹幕数量
|
// 延迟一下让用户看到弹幕数量
|
||||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -3820,6 +4020,45 @@ function PlayPageClient() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 纠错成功后的回调
|
||||||
|
const handleCorrectSuccess = () => {
|
||||||
|
if (!detail || detail.source !== 'xiaoya') return;
|
||||||
|
|
||||||
|
// 从 localStorage 读取纠错信息
|
||||||
|
const correction = getXiaoyaCorrection(detail.source, detail.id);
|
||||||
|
if (correction) {
|
||||||
|
console.log('应用纠错信息:', correction);
|
||||||
|
|
||||||
|
// 只更新显示相关的状态,不更新 detail(避免触发其他 useEffect)
|
||||||
|
if (correction.title) {
|
||||||
|
setVideoTitle(correction.title);
|
||||||
|
}
|
||||||
|
if (correction.posterPath) {
|
||||||
|
const fullPosterUrl = getTMDBImageUrl(correction.posterPath);
|
||||||
|
setVideoCover(fullPosterUrl);
|
||||||
|
}
|
||||||
|
if (correction.overview) {
|
||||||
|
setCorrectedDesc(correction.overview);
|
||||||
|
}
|
||||||
|
if (correction.doubanId) {
|
||||||
|
const doubanIdNum = typeof correction.doubanId === 'string'
|
||||||
|
? parseInt(correction.doubanId, 10)
|
||||||
|
: correction.doubanId;
|
||||||
|
setVideoDoubanId(doubanIdNum);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 detailRef,这样其他地方使用 detailRef 时能获取到最新信息
|
||||||
|
if (detailRef.current) {
|
||||||
|
detailRef.current = applyCorrection(detailRef.current, correction);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 availableSources 中的小雅源信息
|
||||||
|
setAvailableSources(prevSources => applyCorrectionsToSources(prevSources));
|
||||||
|
|
||||||
|
console.log('已应用纠错信息');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
!videoUrl ||
|
!videoUrl ||
|
||||||
@@ -3947,7 +4186,7 @@ function PlayPageClient() {
|
|||||||
Artplayer.USE_RAF = true;
|
Artplayer.USE_RAF = true;
|
||||||
|
|
||||||
// 获取当前集的字幕
|
// 获取当前集的字幕
|
||||||
const currentSubtitles = detail?.subtitles?.[currentEpisodeIndex] || [];
|
const currentSubtitles = detailRef.current?.subtitles?.[currentEpisodeIndex] || [];
|
||||||
const savedSubtitleSize = typeof window !== 'undefined' ? localStorage.getItem('subtitleSize') || '2em' : '2em';
|
const savedSubtitleSize = typeof window !== 'undefined' ? localStorage.getItem('subtitleSize') || '2em' : '2em';
|
||||||
|
|
||||||
artPlayerRef.current = new Artplayer({
|
artPlayerRef.current = new Artplayer({
|
||||||
@@ -3992,10 +4231,17 @@ function PlayPageClient() {
|
|||||||
fastForward: true,
|
fastForward: true,
|
||||||
autoOrientation: true,
|
autoOrientation: true,
|
||||||
lock: true,
|
lock: true,
|
||||||
|
...(videoQualities.length > 0 ? {
|
||||||
|
quality: videoQualities.map((q, index) => ({
|
||||||
|
default: index === 0,
|
||||||
|
html: q.name,
|
||||||
|
url: q.url,
|
||||||
|
})),
|
||||||
|
} : {}),
|
||||||
moreVideoAttr: {
|
moreVideoAttr: {
|
||||||
crossOrigin: 'anonymous',
|
|
||||||
playsInline: true,
|
playsInline: true,
|
||||||
'webkit-playsinline': 'true',
|
'webkit-playsinline': 'true',
|
||||||
|
...(detail?.source === 'xiaoya' ? { referrerpolicy: 'no-referrer' } : {}),
|
||||||
} as any,
|
} as any,
|
||||||
// HLS 支持配置
|
// HLS 支持配置
|
||||||
customType: {
|
customType: {
|
||||||
@@ -4901,7 +5147,7 @@ function PlayPageClient() {
|
|||||||
console.log('[PlayPage] Player ready, triggering sync setup');
|
console.log('[PlayPage] Player ready, triggering sync setup');
|
||||||
|
|
||||||
// 添加字幕切换功能
|
// 添加字幕切换功能
|
||||||
const currentSubtitles = detail?.subtitles?.[currentEpisodeIndex] || [];
|
const currentSubtitles = detailRef.current?.subtitles?.[currentEpisodeIndex] || [];
|
||||||
if (currentSubtitles.length > 0 && artPlayerRef.current) {
|
if (currentSubtitles.length > 0 && artPlayerRef.current) {
|
||||||
const subtitleOptions = [
|
const subtitleOptions = [
|
||||||
{
|
{
|
||||||
@@ -5824,7 +6070,7 @@ function PlayPageClient() {
|
|||||||
|
|
||||||
// 调用异步初始化函数
|
// 调用异步初始化函数
|
||||||
initPlayer();
|
initPlayer();
|
||||||
}, [videoUrl, loading, blockAdEnabled, currentEpisodeIndex, detail]);
|
}, [videoUrl, loading, blockAdEnabled, currentEpisodeIndex]);
|
||||||
|
|
||||||
// 当组件卸载时清理定时器、Wake Lock 和播放器资源
|
// 当组件卸载时清理定时器、Wake Lock 和播放器资源
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -6777,6 +7023,19 @@ function PlayPageClient() {
|
|||||||
<Sparkles className='h-6 w-6 text-gray-700 dark:text-gray-300' />
|
<Sparkles className='h-6 w-6 text-gray-700 dark:text-gray-300' />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{/* 纠错按钮 - 仅小雅源显示 */}
|
||||||
|
{detail && detail.source === 'xiaoya' && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowCorrectDialog(true);
|
||||||
|
}}
|
||||||
|
className='flex-shrink-0 hover:opacity-80 transition-opacity'
|
||||||
|
title='纠错'
|
||||||
|
>
|
||||||
|
<AlertCircle className='h-6 w-6 text-gray-700 dark:text-gray-300' />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{/* 豆瓣评分显示 */}
|
{/* 豆瓣评分显示 */}
|
||||||
{doubanRating && doubanRating.value > 0 && (
|
{doubanRating && doubanRating.value > 0 && (
|
||||||
<div className='flex items-center gap-2 text-base font-normal'>
|
<div className='flex items-center gap-2 text-base font-normal'>
|
||||||
@@ -6857,14 +7116,16 @@ function PlayPageClient() {
|
|||||||
<span>{doubanYear || detail?.year || videoYear}</span>
|
<span>{doubanYear || detail?.year || videoYear}</span>
|
||||||
)}
|
)}
|
||||||
{detail?.source_name && (
|
{detail?.source_name && (
|
||||||
<span className='border border-gray-500/60 px-2 py-[1px] rounded'>
|
<span className={`border px-2 py-[1px] rounded ${
|
||||||
|
detail.source === 'xiaoya' ? 'border-blue-500' : detail.source === 'openlist' || detail.source === 'emby' || detail.source?.startsWith('emby_') ? 'border-yellow-500' : 'border-gray-500/60'
|
||||||
|
}`}>
|
||||||
{detail.source_name}
|
{detail.source_name}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{detail?.type_name && <span>{detail.type_name}</span>}
|
{detail?.type_name && <span>{detail.type_name}</span>}
|
||||||
</div>
|
</div>
|
||||||
{/* 剧情简介 */}
|
{/* 剧情简介 */}
|
||||||
{(doubanCardSubtitle || detail?.desc) && (
|
{(doubanCardSubtitle || correctedDesc || detail?.desc) && (
|
||||||
<div
|
<div
|
||||||
className={`mt-0 text-base leading-relaxed opacity-90 overflow-y-auto pr-2 flex-1 min-h-0 scrollbar-hide ${tmdbBackdrop ? 'text-white' : ''}`}
|
className={`mt-0 text-base leading-relaxed opacity-90 overflow-y-auto pr-2 flex-1 min-h-0 scrollbar-hide ${tmdbBackdrop ? 'text-white' : ''}`}
|
||||||
style={{ whiteSpace: 'pre-line' }}
|
style={{ whiteSpace: 'pre-line' }}
|
||||||
@@ -6875,7 +7136,7 @@ function PlayPageClient() {
|
|||||||
{doubanCardSubtitle}
|
{doubanCardSubtitle}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{detail?.desc}
|
{correctedDesc || detail?.desc}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -7049,10 +7310,76 @@ function PlayPageClient() {
|
|||||||
welcomeMessage={aiDefaultMessageWithVideo ? aiDefaultMessageWithVideo.replace('{title}', detail.title || '') : `想了解《${detail.title}》的更多信息吗?我可以帮你查询剧情、演员、评价等。`}
|
welcomeMessage={aiDefaultMessageWithVideo ? aiDefaultMessageWithVideo.replace('{title}', detail.title || '') : `想了解《${detail.title}》的更多信息吗?我可以帮你查询剧情、演员、评价等。`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 纠错弹窗 - 仅小雅源显示 */}
|
||||||
|
{detail && detail.source === 'xiaoya' && (
|
||||||
|
<CorrectDialog
|
||||||
|
isOpen={showCorrectDialog}
|
||||||
|
onClose={() => setShowCorrectDialog(false)}
|
||||||
|
videoKey={`${detail.source}_${detail.id}`}
|
||||||
|
currentTitle={detail.title}
|
||||||
|
currentVideo={{
|
||||||
|
tmdbId: detail.tmdb_id,
|
||||||
|
doubanId: detail.douban_id ? String(detail.douban_id) : undefined,
|
||||||
|
poster: detail.poster,
|
||||||
|
releaseDate: detail.year,
|
||||||
|
overview: detail.desc,
|
||||||
|
voteAverage: detail.rating,
|
||||||
|
mediaType: detail.type_name === '电影' ? 'movie' : 'tv',
|
||||||
|
}}
|
||||||
|
source="xiaoya"
|
||||||
|
onCorrect={() => {
|
||||||
|
// 纠错成功后的回调
|
||||||
|
handleCorrectSuccess();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 从 localStorage 读取小雅源的纠错信息
|
||||||
|
const getXiaoyaCorrection = (source: string, id: string) => {
|
||||||
|
try {
|
||||||
|
const storageKey = `xiaoya_correction_${source}_${id}`;
|
||||||
|
const correctionJson = localStorage.getItem(storageKey);
|
||||||
|
if (correctionJson) {
|
||||||
|
return JSON.parse(correctionJson);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('读取纠错信息失败:', error);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 应用纠错信息到 detail 对象
|
||||||
|
const applyCorrection = (detail: SearchResult, correction: any): SearchResult => {
|
||||||
|
return {
|
||||||
|
...detail,
|
||||||
|
title: correction.title || detail.title,
|
||||||
|
poster: correction.posterPath ? getTMDBImageUrl(correction.posterPath) : detail.poster,
|
||||||
|
year: correction.releaseDate || detail.year,
|
||||||
|
desc: correction.overview || detail.desc,
|
||||||
|
rating: correction.voteAverage || detail.rating,
|
||||||
|
tmdb_id: correction.tmdbId || detail.tmdb_id,
|
||||||
|
douban_id: correction.doubanId ? (typeof correction.doubanId === 'string' ? parseInt(correction.doubanId, 10) : correction.doubanId) : detail.douban_id,
|
||||||
|
type_name: correction.mediaType === 'movie' ? '电影' : (correction.mediaType === 'tv' ? '电视剧' : detail.type_name),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// 批量应用纠错信息到源列表
|
||||||
|
const applyCorrectionsToSources = (sources: SearchResult[]): SearchResult[] => {
|
||||||
|
return sources.map(source => {
|
||||||
|
if (source.source === 'xiaoya') {
|
||||||
|
const correction = getXiaoyaCorrection(source.source, source.id);
|
||||||
|
if (correction) {
|
||||||
|
return applyCorrection(source, correction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return source;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// FavoriteIcon 组件
|
// FavoriteIcon 组件
|
||||||
const FavoriteIcon = ({ filled }: { filled: boolean }) => {
|
const FavoriteIcon = ({ filled }: { filled: boolean }) => {
|
||||||
if (filled) {
|
if (filled) {
|
||||||
|
|||||||
@@ -4,12 +4,14 @@
|
|||||||
|
|
||||||
import { useRouter, useSearchParams } from 'next/navigation';
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { useEffect, useState, useRef, useMemo } from 'react';
|
import { useEffect, useState, useRef, useMemo } from 'react';
|
||||||
|
import { Film } from 'lucide-react';
|
||||||
|
|
||||||
import CapsuleSwitch from '@/components/CapsuleSwitch';
|
import CapsuleSwitch from '@/components/CapsuleSwitch';
|
||||||
import PageLayout from '@/components/PageLayout';
|
import PageLayout from '@/components/PageLayout';
|
||||||
import VideoCard from '@/components/VideoCard';
|
import VideoCard from '@/components/VideoCard';
|
||||||
|
import { base58Encode } from '@/lib/utils';
|
||||||
|
|
||||||
type LibrarySourceType = 'openlist' | 'emby' | `emby:${string}` | `emby_${string}`;
|
type LibrarySourceType = 'openlist' | 'emby' | 'xiaoya' | `emby:${string}` | `emby_${string}`;
|
||||||
|
|
||||||
interface EmbySourceOption {
|
interface EmbySourceOption {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -45,7 +47,7 @@ export default function PrivateLibraryPage() {
|
|||||||
if (typeof window !== 'undefined' && (window as any).RUNTIME_CONFIG) {
|
if (typeof window !== 'undefined' && (window as any).RUNTIME_CONFIG) {
|
||||||
return (window as any).RUNTIME_CONFIG;
|
return (window as any).RUNTIME_CONFIG;
|
||||||
}
|
}
|
||||||
return { OPENLIST_ENABLED: false, EMBY_ENABLED: false };
|
return { OPENLIST_ENABLED: false, EMBY_ENABLED: false, XIAOYA_ENABLED: false };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 解析URL中的source参数(支持 emby:emby1 格式)
|
// 解析URL中的source参数(支持 emby:emby1 格式)
|
||||||
@@ -72,6 +74,14 @@ export default function PrivateLibraryPage() {
|
|||||||
const [embyViews, setEmbyViews] = useState<EmbyView[]>([]);
|
const [embyViews, setEmbyViews] = useState<EmbyView[]>([]);
|
||||||
const [selectedView, setSelectedView] = useState<string>('all');
|
const [selectedView, setSelectedView] = useState<string>('all');
|
||||||
const [loadingViews, setLoadingViews] = useState(false);
|
const [loadingViews, setLoadingViews] = useState(false);
|
||||||
|
// 小雅相关状态
|
||||||
|
const [xiaoyaPath, setXiaoyaPath] = useState<string>('/');
|
||||||
|
const [xiaoyaFolders, setXiaoyaFolders] = useState<Array<{ name: string; path: string }>>([]);
|
||||||
|
const [xiaoyaFiles, setXiaoyaFiles] = useState<Array<{ name: string; path: string }>>([]);
|
||||||
|
const [xiaoyaSearchKeyword, setXiaoyaSearchKeyword] = useState<string>('');
|
||||||
|
const [xiaoyaSearchResults, setXiaoyaSearchResults] = useState<Array<{ name: string; path: string }>>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
const observerTarget = useRef<HTMLDivElement>(null);
|
const observerTarget = useRef<HTMLDivElement>(null);
|
||||||
const isFetchingRef = useRef(false);
|
const isFetchingRef = useRef(false);
|
||||||
@@ -84,6 +94,38 @@ export default function PrivateLibraryPage() {
|
|||||||
const isInitializedRef = useRef(false);
|
const isInitializedRef = useRef(false);
|
||||||
const hasRestoredViewRef = useRef(false);
|
const hasRestoredViewRef = useRef(false);
|
||||||
|
|
||||||
|
// 客户端挂载标记
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 小雅搜索处理函数
|
||||||
|
const handleXiaoyaSearch = async () => {
|
||||||
|
if (!xiaoyaSearchKeyword.trim()) return;
|
||||||
|
|
||||||
|
setIsSearching(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/xiaoya/search?keyword=${encodeURIComponent(xiaoyaSearchKeyword)}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('搜索失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.error) {
|
||||||
|
setError(data.error);
|
||||||
|
setXiaoyaSearchResults([]);
|
||||||
|
} else {
|
||||||
|
setXiaoyaSearchResults(data.videos || []);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('搜索失败:', err);
|
||||||
|
setError('搜索失败');
|
||||||
|
setXiaoyaSearchResults([]);
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 从URL初始化状态,并检查配置自动跳转
|
// 从URL初始化状态,并检查配置自动跳转
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const urlSourceParam = searchParams.get('source');
|
const urlSourceParam = searchParams.get('source');
|
||||||
@@ -270,6 +312,12 @@ export default function PrivateLibraryPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果选择了 xiaoya 但未配置,不发起请求
|
||||||
|
if (sourceType === 'xiaoya' && !runtimeConfig.XIAOYA_ENABLED) {
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 创建新的 AbortController
|
// 创建新的 AbortController
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
abortControllerRef.current = abortController;
|
abortControllerRef.current = abortController;
|
||||||
@@ -285,6 +333,8 @@ export default function PrivateLibraryPage() {
|
|||||||
|
|
||||||
const endpoint = sourceType === 'openlist'
|
const endpoint = sourceType === 'openlist'
|
||||||
? `/api/openlist/list?page=${page}&pageSize=${pageSize}`
|
? `/api/openlist/list?page=${page}&pageSize=${pageSize}`
|
||||||
|
: sourceType === 'xiaoya'
|
||||||
|
? `/api/xiaoya/browse?path=${encodeURIComponent(xiaoyaPath)}`
|
||||||
: `/api/emby/list?page=${page}&pageSize=${pageSize}${selectedView !== 'all' ? `&parentId=${selectedView}` : ''}&embyKey=${embyKey}`;
|
: `/api/emby/list?page=${page}&pageSize=${pageSize}${selectedView !== 'all' ? `&parentId=${selectedView}` : ''}&embyKey=${embyKey}`;
|
||||||
|
|
||||||
const response = await fetch(endpoint, { signal: abortController.signal });
|
const response = await fetch(endpoint, { signal: abortController.signal });
|
||||||
@@ -301,19 +351,27 @@ export default function PrivateLibraryPage() {
|
|||||||
setVideos([]);
|
setVideos([]);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const newVideos = data.list || [];
|
// 小雅返回的是文件夹和文件列表
|
||||||
|
if (sourceType === 'xiaoya') {
|
||||||
if (isInitial) {
|
setXiaoyaFolders(data.folders || []);
|
||||||
setVideos(newVideos);
|
setXiaoyaFiles(data.files || []);
|
||||||
|
setVideos([]); // 小雅不使用 videos 状态
|
||||||
|
setHasMore(false); // 小雅不需要分页
|
||||||
} else {
|
} else {
|
||||||
setVideos((prev) => [...prev, ...newVideos]);
|
const newVideos = data.list || [];
|
||||||
}
|
|
||||||
|
|
||||||
// 检查是否还有更多数据
|
if (isInitial) {
|
||||||
const currentPage = data.page || page;
|
setVideos(newVideos);
|
||||||
const totalPages = data.totalPages || 1;
|
} else {
|
||||||
const hasMoreData = currentPage < totalPages;
|
setVideos((prev) => [...prev, ...newVideos]);
|
||||||
setHasMore(hasMoreData);
|
}
|
||||||
|
|
||||||
|
// 检查是否还有更多数据
|
||||||
|
const currentPage = data.page || page;
|
||||||
|
const totalPages = data.totalPages || 1;
|
||||||
|
const hasMoreData = currentPage < totalPages;
|
||||||
|
setHasMore(hasMoreData);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
// 忽略取消请求的错误
|
// 忽略取消请求的错误
|
||||||
@@ -346,7 +404,7 @@ export default function PrivateLibraryPage() {
|
|||||||
abortControllerRef.current.abort();
|
abortControllerRef.current.abort();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [sourceType, embyKey, page, selectedView, runtimeConfig]);
|
}, [sourceType, embyKey, page, selectedView, xiaoyaPath, runtimeConfig]);
|
||||||
|
|
||||||
const handleVideoClick = (video: Video) => {
|
const handleVideoClick = (video: Video) => {
|
||||||
// 构建source参数
|
// 构建source参数
|
||||||
@@ -390,26 +448,40 @@ export default function PrivateLibraryPage() {
|
|||||||
return (
|
return (
|
||||||
<PageLayout activePath='/private-library'>
|
<PageLayout activePath='/private-library'>
|
||||||
<div className='container mx-auto px-4 py-6'>
|
<div className='container mx-auto px-4 py-6'>
|
||||||
<div className='mb-6'>
|
<div className='mb-6 flex justify-between items-start'>
|
||||||
<h1 className='text-2xl font-bold text-gray-900 dark:text-gray-100'>
|
<div>
|
||||||
私人影库
|
<h1 className='text-2xl font-bold text-gray-900 dark:text-gray-100'>
|
||||||
</h1>
|
私人影库
|
||||||
<p className='text-sm text-gray-500 dark:text-gray-400 mt-1'>
|
</h1>
|
||||||
观看自我收藏的高清视频吧
|
<p className='text-sm text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
</p>
|
观看自我收藏的高清视频吧
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{mounted && (
|
||||||
|
<button
|
||||||
|
onClick={() => router.push('/movie-request')}
|
||||||
|
className='flex items-center gap-2 px-3 py-2 text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors'
|
||||||
|
>
|
||||||
|
<Film size={20} />
|
||||||
|
<span>求片</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 第一级:源类型选择(OpenList / Emby) */}
|
{/* 第一级:源类型选择(OpenList / Emby / 小雅) */}
|
||||||
<div className='mb-6 flex justify-center'>
|
{mounted && (
|
||||||
<CapsuleSwitch
|
<div className='mb-6 flex justify-center'>
|
||||||
options={[
|
<CapsuleSwitch
|
||||||
{ label: 'OpenList', value: 'openlist' },
|
options={[
|
||||||
{ label: 'Emby', value: 'emby' },
|
...(runtimeConfig.OPENLIST_ENABLED ? [{ label: 'OpenList', value: 'openlist' }] : []),
|
||||||
]}
|
...(runtimeConfig.EMBY_ENABLED ? [{ label: 'Emby', value: 'emby' }] : []),
|
||||||
active={sourceType}
|
...(runtimeConfig.XIAOYA_ENABLED ? [{ label: '小雅', value: 'xiaoya' }] : []),
|
||||||
onChange={(value) => setSourceType(value as LibrarySourceType)}
|
]}
|
||||||
/>
|
active={sourceType}
|
||||||
</div>
|
onChange={(value) => setSourceType(value as LibrarySourceType)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 第二级:Emby源选择(仅当选择Emby且有多个源时显示) */}
|
{/* 第二级:Emby源选择(仅当选择Emby且有多个源时显示) */}
|
||||||
{sourceType === 'emby' && embySourceOptions.length > 1 && (
|
{sourceType === 'emby' && embySourceOptions.length > 1 && (
|
||||||
@@ -527,13 +599,241 @@ export default function PrivateLibraryPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4'>
|
sourceType === 'xiaoya' ? (
|
||||||
{Array.from({ length: pageSize }).map((_, index) => (
|
// 小雅加载骨架屏 - 文件夹列表样式
|
||||||
<div
|
<div className='space-y-4'>
|
||||||
key={index}
|
{/* 文件夹骨架屏 */}
|
||||||
className='animate-pulse bg-gray-200 dark:bg-gray-700 rounded-lg aspect-[2/3]'
|
<div className='space-y-2'>
|
||||||
/>
|
<div className='h-5 w-16 bg-gray-200 dark:bg-gray-700 rounded animate-pulse' />
|
||||||
))}
|
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2'>
|
||||||
|
{Array.from({ length: 12 }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className='h-12 bg-gray-200 dark:bg-gray-700 rounded-lg animate-pulse'
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// OpenList/Emby 加载骨架屏 - 海报卡片样式
|
||||||
|
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4'>
|
||||||
|
{Array.from({ length: pageSize }).map((_, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className='animate-pulse bg-gray-200 dark:bg-gray-700 rounded-lg aspect-[2/3]'
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : sourceType === 'xiaoya' ? (
|
||||||
|
// 小雅浏览模式
|
||||||
|
<div className='space-y-4'>
|
||||||
|
{/* 搜索框 */}
|
||||||
|
<div className='flex justify-center md:justify-end'>
|
||||||
|
<div className='relative w-full max-w-md'>
|
||||||
|
<input
|
||||||
|
type='text'
|
||||||
|
placeholder='搜索视频...'
|
||||||
|
value={xiaoyaSearchKeyword}
|
||||||
|
onChange={(e) => setXiaoyaSearchKeyword(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter' && xiaoyaSearchKeyword.trim()) {
|
||||||
|
handleXiaoyaSearch();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className='w-full px-4 py-2 pr-10 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-500'
|
||||||
|
/>
|
||||||
|
{xiaoyaSearchKeyword ? (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setXiaoyaSearchKeyword('');
|
||||||
|
setXiaoyaSearchResults([]);
|
||||||
|
}}
|
||||||
|
className='absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300'
|
||||||
|
>
|
||||||
|
<svg className='w-5 h-5' fill='currentColor' viewBox='0 0 20 20'>
|
||||||
|
<path fillRule='evenodd' d='M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z' clipRule='evenodd' />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={handleXiaoyaSearch}
|
||||||
|
disabled={!xiaoyaSearchKeyword.trim() || isSearching}
|
||||||
|
className='absolute right-2 top-1/2 -translate-y-1/2 p-1 text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 disabled:opacity-50 disabled:cursor-not-allowed'
|
||||||
|
>
|
||||||
|
<svg className='w-5 h-5' fill='currentColor' viewBox='0 0 20 20'>
|
||||||
|
<path fillRule='evenodd' d='M8 4a4 4 0 100 8 4 4 0 000-8zM2 8a6 6 0 1110.89 3.476l4.817 4.817a1 1 0 01-1.414 1.414l-4.816-4.816A6 6 0 012 8z' clipRule='evenodd' />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 搜索结果 */}
|
||||||
|
{xiaoyaSearchResults.length > 0 ? (
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<h3 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
搜索结果 ({xiaoyaSearchResults.length})
|
||||||
|
</h3>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setXiaoyaSearchKeyword('');
|
||||||
|
setXiaoyaSearchResults([]);
|
||||||
|
}}
|
||||||
|
className='text-sm text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300'
|
||||||
|
>
|
||||||
|
返回浏览
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className='grid grid-cols-1 gap-2'>
|
||||||
|
{xiaoyaSearchResults.map((item) => {
|
||||||
|
// 判断是否为视频文件
|
||||||
|
const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm'];
|
||||||
|
const isVideoFile = videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext));
|
||||||
|
|
||||||
|
// 从路径中提取文件夹名作为标题
|
||||||
|
const pathParts = item.path.split('/').filter(Boolean);
|
||||||
|
const folderName = pathParts[pathParts.length - (isVideoFile ? 2 : 1)] || '';
|
||||||
|
const title = folderName
|
||||||
|
.replace(/\s*\(\d{4}\)\s*\{tmdb-\d+\}$/i, '')
|
||||||
|
.trim() || item.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.path}
|
||||||
|
onClick={() => {
|
||||||
|
if (isVideoFile) {
|
||||||
|
// 视频文件:提取父目录作为ID,传递文件名
|
||||||
|
const pathParts = item.path.split('/').filter(Boolean);
|
||||||
|
const parentDir = '/' + pathParts.slice(0, -1).join('/');
|
||||||
|
const fileName = pathParts[pathParts.length - 1];
|
||||||
|
const encodedDirPath = base58Encode(parentDir);
|
||||||
|
router.push(`/play?source=xiaoya&id=${encodeURIComponent(encodedDirPath)}&fileName=${encodeURIComponent(fileName)}&title=${encodeURIComponent(title)}`);
|
||||||
|
} else {
|
||||||
|
// 文件夹:进入浏览
|
||||||
|
setXiaoyaPath(item.path);
|
||||||
|
setXiaoyaSearchKeyword('');
|
||||||
|
setXiaoyaSearchResults([]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className='flex items-center gap-2 p-3 bg-gray-100 dark:bg-gray-800 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors text-left'
|
||||||
|
>
|
||||||
|
{isVideoFile ? (
|
||||||
|
<svg className='w-5 h-5 text-green-600 flex-shrink-0' fill='currentColor' viewBox='0 0 20 20'>
|
||||||
|
<path d='M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z' />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className='w-5 h-5 text-blue-600 flex-shrink-0' fill='currentColor' viewBox='0 0 20 20'>
|
||||||
|
<path d='M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z' />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
<div className='flex-1 min-w-0'>
|
||||||
|
<div className='text-sm truncate'>{item.name}</div>
|
||||||
|
<div className='text-xs text-gray-500 dark:text-gray-400 truncate'>{item.path}</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : isSearching ? (
|
||||||
|
<div className='flex justify-center py-8'>
|
||||||
|
<div className='flex items-center gap-2 text-gray-600 dark:text-gray-400'>
|
||||||
|
<div className='w-5 h-5 border-2 border-blue-600 border-t-transparent rounded-full animate-spin' />
|
||||||
|
<span>搜索中...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* 面包屑导航 */}
|
||||||
|
<div className='flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400'>
|
||||||
|
<button
|
||||||
|
onClick={() => setXiaoyaPath('/')}
|
||||||
|
className='hover:text-blue-600 dark:hover:text-blue-400'
|
||||||
|
>
|
||||||
|
根目录
|
||||||
|
</button>
|
||||||
|
{xiaoyaPath.split('/').filter(Boolean).map((part, index, arr) => {
|
||||||
|
const path = '/' + arr.slice(0, index + 1).join('/');
|
||||||
|
return (
|
||||||
|
<span key={path} className='flex items-center gap-2'>
|
||||||
|
<span>/</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setXiaoyaPath(path)}
|
||||||
|
className='hover:text-blue-600 dark:hover:text-blue-400'
|
||||||
|
>
|
||||||
|
{part}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 文件夹列表 */}
|
||||||
|
{xiaoyaFolders.length > 0 && (
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<h3 className='text-sm font-medium text-gray-700 dark:text-gray-300'>文件夹</h3>
|
||||||
|
<div className='grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-2'>
|
||||||
|
{xiaoyaFolders.map((folder) => (
|
||||||
|
<button
|
||||||
|
key={folder.path}
|
||||||
|
onClick={() => setXiaoyaPath(folder.path)}
|
||||||
|
className='flex items-center gap-2 p-3 bg-gray-100 dark:bg-gray-800 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors text-left'
|
||||||
|
>
|
||||||
|
<svg className='w-5 h-5 text-blue-600' fill='currentColor' viewBox='0 0 20 20'>
|
||||||
|
<path d='M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z' />
|
||||||
|
</svg>
|
||||||
|
<span className='text-sm truncate'>{folder.name}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 视频文件列表 */}
|
||||||
|
{xiaoyaFiles.length > 0 && (
|
||||||
|
<div className='space-y-2'>
|
||||||
|
<h3 className='text-sm font-medium text-gray-700 dark:text-gray-300'>视频文件</h3>
|
||||||
|
<div className='grid grid-cols-1 gap-2'>
|
||||||
|
{xiaoyaFiles.map((file) => {
|
||||||
|
// 从当前路径提取文件夹名作为标题
|
||||||
|
const pathParts = xiaoyaPath.split('/').filter(Boolean);
|
||||||
|
const folderName = pathParts[pathParts.length - 1] || '';
|
||||||
|
// 清理文件夹名(移除年份和 TMDb ID)
|
||||||
|
const title = folderName
|
||||||
|
.replace(/\s*\(\d{4}\)\s*\{tmdb-\d+\}$/i, '')
|
||||||
|
.trim() || file.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={file.path}
|
||||||
|
onClick={() => {
|
||||||
|
// ID使用目录路径,额外传递文件名(不需要编码)
|
||||||
|
const encodedDirPath = base58Encode(xiaoyaPath);
|
||||||
|
router.push(`/play?source=xiaoya&id=${encodeURIComponent(encodedDirPath)}&fileName=${encodeURIComponent(file.name)}&title=${encodeURIComponent(title)}`);
|
||||||
|
}}
|
||||||
|
className='flex items-center gap-2 p-3 bg-gray-100 dark:bg-gray-800 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors text-left'
|
||||||
|
>
|
||||||
|
<svg className='w-5 h-5 text-green-600' fill='currentColor' viewBox='0 0 20 20'>
|
||||||
|
<path d='M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z' />
|
||||||
|
</svg>
|
||||||
|
<span className='text-sm truncate'>{file.name}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{xiaoyaFolders.length === 0 && xiaoyaFiles.length === 0 && (
|
||||||
|
<div className='text-center py-12'>
|
||||||
|
<p className='text-gray-500 dark:text-gray-400'>此目录为空</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : videos.length === 0 ? (
|
) : videos.length === 0 ? (
|
||||||
<div className='text-center py-12'>
|
<div className='text-center py-12'>
|
||||||
|
|||||||
+85
-7
@@ -36,6 +36,10 @@ function SearchPageClient() {
|
|||||||
const [triggerAcgSearch, setTriggerAcgSearch] = useState(false);
|
const [triggerAcgSearch, setTriggerAcgSearch] = useState(false);
|
||||||
// 用户权限
|
// 用户权限
|
||||||
const [userRole, setUserRole] = useState<'owner' | 'admin' | 'user' | null>(null);
|
const [userRole, setUserRole] = useState<'owner' | 'admin' | 'user' | null>(null);
|
||||||
|
// 繁体转简体转换器
|
||||||
|
const converterRef = useRef<((text: string) => string) | null>(null);
|
||||||
|
// 转换器是否已初始化
|
||||||
|
const [converterReady, setConverterReady] = useState(false);
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
@@ -538,6 +542,26 @@ function SearchPageClient() {
|
|||||||
const authInfo = getAuthInfoFromBrowserCookie();
|
const authInfo = getAuthInfoFromBrowserCookie();
|
||||||
setUserRole(authInfo?.role || null);
|
setUserRole(authInfo?.role || null);
|
||||||
|
|
||||||
|
// 初始化繁体转简体转换器
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
import('opencc-js').then((module) => {
|
||||||
|
try {
|
||||||
|
const OpenCC = module.default || module;
|
||||||
|
const converter = OpenCC.Converter({ from: 'hk', to: 'cn' });
|
||||||
|
converterRef.current = converter;
|
||||||
|
setConverterReady(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('初始化繁体转简体转换器失败:', error);
|
||||||
|
setConverterReady(true); // 即使失败也设置为 true,避免阻塞
|
||||||
|
}
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('加载 opencc-js 失败:', error);
|
||||||
|
setConverterReady(true); // 即使失败也设置为 true,避免阻塞
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setConverterReady(true);
|
||||||
|
}
|
||||||
|
|
||||||
// 初始加载搜索历史
|
// 初始加载搜索历史
|
||||||
getSearchHistory().then(setSearchHistory);
|
getSearchHistory().then(setSearchHistory);
|
||||||
|
|
||||||
@@ -600,8 +624,36 @@ function SearchPageClient() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// 等待转换器初始化完成
|
||||||
|
if (!converterReady) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 当搜索参数变化时更新搜索状态
|
// 当搜索参数变化时更新搜索状态
|
||||||
const query = searchParams.get('q') || '';
|
let query = searchParams.get('q') || '';
|
||||||
|
|
||||||
|
// 如果开启了繁体转简体,进行转换
|
||||||
|
if (query && typeof window !== 'undefined') {
|
||||||
|
const searchTraditionalToSimplified = localStorage.getItem('searchTraditionalToSimplified');
|
||||||
|
|
||||||
|
if (searchTraditionalToSimplified === 'true' && converterRef.current) {
|
||||||
|
try {
|
||||||
|
const originalQuery = query;
|
||||||
|
query = converterRef.current(query);
|
||||||
|
|
||||||
|
// 如果转换后的文本与原文本不同,更新 URL
|
||||||
|
if (originalQuery !== query) {
|
||||||
|
const trimmedConverted = query.trim();
|
||||||
|
// 使用 replace 而不是 push,避免在历史记录中留下繁体版本
|
||||||
|
router.replace(`/search?q=${encodeURIComponent(trimmedConverted)}${searchParams.get('type') ? `&type=${searchParams.get('type')}` : ''}`);
|
||||||
|
return; // 等待 URL 更新后重新触发此 effect
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[URL参数监听] 繁体转简体转换失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
currentQueryRef.current = query.trim();
|
currentQueryRef.current = query.trim();
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
@@ -799,7 +851,7 @@ function SearchPageClient() {
|
|||||||
setShowResults(false);
|
setShowResults(false);
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
}
|
}
|
||||||
}, [searchParams, forceRefresh]);
|
}, [searchParams, forceRefresh, converterReady]);
|
||||||
|
|
||||||
// 组件卸载时,关闭可能存在的连接
|
// 组件卸载时,关闭可能存在的连接
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -838,9 +890,21 @@ function SearchPageClient() {
|
|||||||
// 搜索表单提交时触发,处理搜索逻辑
|
// 搜索表单提交时触发,处理搜索逻辑
|
||||||
const handleSearch = (e: React.FormEvent) => {
|
const handleSearch = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const trimmed = searchQuery.trim().replace(/\s+/g, ' ');
|
let trimmed = searchQuery.trim().replace(/\s+/g, ' ');
|
||||||
if (!trimmed) return;
|
if (!trimmed) return;
|
||||||
|
|
||||||
|
// 如果开启了繁体转简体,进行转换
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const searchTraditionalToSimplified = localStorage.getItem('searchTraditionalToSimplified');
|
||||||
|
if (searchTraditionalToSimplified === 'true' && converterRef.current) {
|
||||||
|
try {
|
||||||
|
trimmed = converterRef.current(trimmed);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('繁体转简体转换失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 回显搜索框
|
// 回显搜索框
|
||||||
setSearchQuery(trimmed);
|
setSearchQuery(trimmed);
|
||||||
setShowResults(true);
|
setShowResults(true);
|
||||||
@@ -863,7 +927,21 @@ function SearchPageClient() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSuggestionSelect = (suggestion: string) => {
|
const handleSuggestionSelect = (suggestion: string) => {
|
||||||
setSearchQuery(suggestion);
|
let processedSuggestion = suggestion;
|
||||||
|
|
||||||
|
// 如果开启了繁体转简体,进行转换
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const searchTraditionalToSimplified = localStorage.getItem('searchTraditionalToSimplified');
|
||||||
|
if (searchTraditionalToSimplified === 'true' && converterRef.current) {
|
||||||
|
try {
|
||||||
|
processedSuggestion = converterRef.current(suggestion);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('繁体转简体转换失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearchQuery(processedSuggestion);
|
||||||
setShowSuggestions(false);
|
setShowSuggestions(false);
|
||||||
|
|
||||||
// 自动执行搜索
|
// 自动执行搜索
|
||||||
@@ -872,15 +950,15 @@ function SearchPageClient() {
|
|||||||
// 根据当前选项卡执行不同的搜索
|
// 根据当前选项卡执行不同的搜索
|
||||||
if (activeTab === 'video') {
|
if (activeTab === 'video') {
|
||||||
// 影视搜索
|
// 影视搜索
|
||||||
router.push(`/search?q=${encodeURIComponent(suggestion)}&type=video`);
|
router.push(`/search?q=${encodeURIComponent(processedSuggestion)}&type=video`);
|
||||||
// 其余由 searchParams 变化的 effect 处理
|
// 其余由 searchParams 变化的 effect 处理
|
||||||
} else if (activeTab === 'pansou') {
|
} else if (activeTab === 'pansou') {
|
||||||
// 网盘搜索 - 触发搜索
|
// 网盘搜索 - 触发搜索
|
||||||
router.push(`/search?q=${encodeURIComponent(suggestion)}&type=pansou`);
|
router.push(`/search?q=${encodeURIComponent(processedSuggestion)}&type=pansou`);
|
||||||
setTriggerPansouSearch(prev => !prev);
|
setTriggerPansouSearch(prev => !prev);
|
||||||
} else if (activeTab === 'acg') {
|
} else if (activeTab === 'acg') {
|
||||||
// ACG 磁力搜索 - 触发搜索
|
// ACG 磁力搜索 - 触发搜索
|
||||||
router.push(`/search?q=${encodeURIComponent(suggestion)}&type=acg`);
|
router.push(`/search?q=${encodeURIComponent(processedSuggestion)}&type=acg`);
|
||||||
setTriggerAcgSearch(prev => !prev);
|
setTriggerAcgSearch(prev => !prev);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ interface CorrectDialogProps {
|
|||||||
seasonName?: string;
|
seasonName?: string;
|
||||||
};
|
};
|
||||||
onCorrect: () => void;
|
onCorrect: () => void;
|
||||||
|
source?: string; // 新增:视频源类型(xiaoya, openlist等)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CorrectDialog({
|
export default function CorrectDialog({
|
||||||
@@ -58,6 +59,7 @@ export default function CorrectDialog({
|
|||||||
currentTitle,
|
currentTitle,
|
||||||
currentVideo,
|
currentVideo,
|
||||||
onCorrect,
|
onCorrect,
|
||||||
|
source = 'openlist', // 默认为 openlist
|
||||||
}: CorrectDialogProps) {
|
}: CorrectDialogProps) {
|
||||||
const [searchQuery, setSearchQuery] = useState(currentTitle);
|
const [searchQuery, setSearchQuery] = useState(currentTitle);
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
@@ -229,8 +231,7 @@ export default function CorrectDialog({
|
|||||||
finalTitle = `${finalTitle} ${season.name}`;
|
finalTitle = `${finalTitle} ${season.name}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const body: any = {
|
const correctionData: any = {
|
||||||
key: videoKey,
|
|
||||||
tmdbId: finalTmdbId,
|
tmdbId: finalTmdbId,
|
||||||
title: finalTitle,
|
title: finalTitle,
|
||||||
posterPath: season?.poster_path || result.poster_path,
|
posterPath: season?.poster_path || result.poster_path,
|
||||||
@@ -240,20 +241,38 @@ export default function CorrectDialog({
|
|||||||
mediaType: result.media_type,
|
mediaType: result.media_type,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 如果有季度信息,添加到请求中
|
// 如果有季度信息,添加到数据中
|
||||||
if (season) {
|
if (season) {
|
||||||
body.seasonNumber = season.season_number;
|
correctionData.seasonNumber = season.season_number;
|
||||||
body.seasonName = season.name;
|
correctionData.seasonName = season.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch('/api/openlist/correct', {
|
// 根据源类型选择不同的存储方式
|
||||||
method: 'POST',
|
if (source === 'xiaoya') {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
// 小雅源:存储到 localStorage
|
||||||
body: JSON.stringify(body),
|
const storageKey = `xiaoya_correction_${videoKey}`;
|
||||||
});
|
const correctionInfo = {
|
||||||
|
...correctionData,
|
||||||
|
correctedAt: Date.now(),
|
||||||
|
};
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(correctionInfo));
|
||||||
|
console.log('小雅源纠错信息已存储到 localStorage:', storageKey, correctionInfo);
|
||||||
|
} else {
|
||||||
|
// openlist 等其他源:调用 API
|
||||||
|
const body: any = {
|
||||||
|
key: videoKey,
|
||||||
|
...correctionData,
|
||||||
|
};
|
||||||
|
|
||||||
if (!response.ok) {
|
const response = await fetch('/api/openlist/correct', {
|
||||||
throw new Error('纠错失败');
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('纠错失败');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onCorrect();
|
onCorrect();
|
||||||
@@ -313,8 +332,7 @@ export default function CorrectDialog({
|
|||||||
setError('');
|
setError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const body: any = {
|
const correctionData: any = {
|
||||||
key: videoKey,
|
|
||||||
title: manualData.title.trim(),
|
title: manualData.title.trim(),
|
||||||
posterPath: manualData.posterPath.trim() || null,
|
posterPath: manualData.posterPath.trim() || null,
|
||||||
releaseDate: manualData.releaseDate.trim() || '',
|
releaseDate: manualData.releaseDate.trim() || '',
|
||||||
@@ -325,28 +343,46 @@ export default function CorrectDialog({
|
|||||||
|
|
||||||
// 添加 TMDB ID(如果提供)
|
// 添加 TMDB ID(如果提供)
|
||||||
if (manualData.tmdbId.trim()) {
|
if (manualData.tmdbId.trim()) {
|
||||||
body.tmdbId = Number(manualData.tmdbId);
|
correctionData.tmdbId = Number(manualData.tmdbId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加豆瓣 ID(如果提供)
|
// 添加豆瓣 ID(如果提供)
|
||||||
if (manualData.doubanId.trim()) {
|
if (manualData.doubanId.trim()) {
|
||||||
body.doubanId = manualData.doubanId.trim();
|
correctionData.doubanId = manualData.doubanId.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果是电视剧且有季度信息
|
// 如果是电视剧且有季度信息
|
||||||
if (manualData.mediaType === 'tv' && manualData.seasonNumber) {
|
if (manualData.mediaType === 'tv' && manualData.seasonNumber) {
|
||||||
body.seasonNumber = Number(manualData.seasonNumber);
|
correctionData.seasonNumber = Number(manualData.seasonNumber);
|
||||||
body.seasonName = manualData.seasonName.trim() || `第 ${manualData.seasonNumber} 季`;
|
correctionData.seasonName = manualData.seasonName.trim() || `第 ${manualData.seasonNumber} 季`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch('/api/openlist/correct', {
|
// 根据源类型选择不同的存储方式
|
||||||
method: 'POST',
|
if (source === 'xiaoya') {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
// 小雅源:存储到 localStorage
|
||||||
body: JSON.stringify(body),
|
const storageKey = `xiaoya_correction_${videoKey}`;
|
||||||
});
|
const correctionInfo = {
|
||||||
|
...correctionData,
|
||||||
|
correctedAt: Date.now(),
|
||||||
|
};
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(correctionInfo));
|
||||||
|
console.log('小雅源纠错信息已存储到 localStorage:', storageKey, correctionInfo);
|
||||||
|
} else {
|
||||||
|
// openlist 等其他源:调用 API
|
||||||
|
const body: any = {
|
||||||
|
key: videoKey,
|
||||||
|
...correctionData,
|
||||||
|
};
|
||||||
|
|
||||||
if (!response.ok) {
|
const response = await fetch('/api/openlist/correct', {
|
||||||
throw new Error('纠错失败');
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('纠错失败');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onCorrect();
|
onCorrect();
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface VideoInfo {
|
|||||||
quality: string;
|
quality: string;
|
||||||
loadSpeed: string;
|
loadSpeed: string;
|
||||||
pingTime: number;
|
pingTime: number;
|
||||||
|
bitrate: string; // 视频码率
|
||||||
hasError?: boolean; // 添加错误状态标识
|
hasError?: boolean; // 添加错误状态标识
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +214,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
quality: '错误',
|
quality: '错误',
|
||||||
loadSpeed: '未知',
|
loadSpeed: '未知',
|
||||||
pingTime: 0,
|
pingTime: 0,
|
||||||
|
bitrate: '未知',
|
||||||
hasError: true,
|
hasError: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -271,16 +273,18 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
if (
|
if (
|
||||||
!optimizationEnabled || // 若关闭测速则直接退出
|
!optimizationEnabled || // 若关闭测速则直接退出
|
||||||
activeTab !== 'sources' ||
|
activeTab !== 'sources' ||
|
||||||
availableSources.length === 0 ||
|
availableSources.length === 0
|
||||||
currentSource === 'openlist' || // 私人影库不进行测速
|
|
||||||
currentSource === 'emby' // Emby 不进行测速
|
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// 筛选出尚未测速的播放源
|
// 筛选出尚未测速的播放源,并排除不需要测速的源(openlist/emby/xiaoya)
|
||||||
const pendingSources = availableSources.filter((source) => {
|
const pendingSources = availableSources.filter((source) => {
|
||||||
const sourceKey = `${source.source}-${source.id}`;
|
const sourceKey = `${source.source}-${source.id}`;
|
||||||
return !attemptedSourcesRef.current.has(sourceKey);
|
// 跳过已测速的源
|
||||||
|
if (attemptedSourcesRef.current.has(sourceKey)) return false;
|
||||||
|
// 跳过不需要测速的源
|
||||||
|
if (source.source === 'openlist' || source.source === 'emby' || source.source.startsWith('emby_') || source.source === 'xiaoya') return false;
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (pendingSources.length === 0) return;
|
if (pendingSources.length === 0) return;
|
||||||
@@ -308,11 +312,15 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
// 当后台加载从 true 变为 false 时(即加载完成)
|
// 当后台加载从 true 变为 false 时(即加载完成)
|
||||||
if (prevBackgroundLoadingRef.current && !backgroundSourcesLoading) {
|
if (prevBackgroundLoadingRef.current && !backgroundSourcesLoading) {
|
||||||
// 如果当前选项卡在换源位置,触发测速
|
// 如果当前选项卡在换源位置,触发测速
|
||||||
if (activeTab === 'sources' && optimizationEnabled && currentSource !== 'openlist' && currentSource !== 'emby') {
|
if (activeTab === 'sources' && optimizationEnabled) {
|
||||||
// 筛选出尚未测速的播放源
|
// 筛选出尚未测速的播放源,并排除不需要测速的源(openlist/emby/xiaoya)
|
||||||
const pendingSources = availableSources.filter((source) => {
|
const pendingSources = availableSources.filter((source) => {
|
||||||
const sourceKey = `${source.source}-${source.id}`;
|
const sourceKey = `${source.source}-${source.id}`;
|
||||||
return !attemptedSourcesRef.current.has(sourceKey);
|
// 跳过已测速的源
|
||||||
|
if (attemptedSourcesRef.current.has(sourceKey)) return false;
|
||||||
|
// 跳过不需要测速的源
|
||||||
|
if (source.source === 'openlist' || source.source === 'emby' || source.source.startsWith('emby_') || source.source === 'xiaoya') return false;
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (pendingSources.length > 0) {
|
if (pendingSources.length > 0) {
|
||||||
@@ -683,6 +691,13 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
if (title.match(/^OVA\s+\d+/i)) {
|
if (title.match(/^OVA\s+\d+/i)) {
|
||||||
return title;
|
return title;
|
||||||
}
|
}
|
||||||
|
// 如果匹配 S01E01 格式,提取并返回
|
||||||
|
const sxxexxMatch = title.match(/[Ss](\d+)[Ee](\d{1,4}(?:\.\d+)?)/);
|
||||||
|
if (sxxexxMatch) {
|
||||||
|
const season = sxxexxMatch[1].padStart(2, '0');
|
||||||
|
const episode = sxxexxMatch[2];
|
||||||
|
return `S${season}E${episode}`;
|
||||||
|
}
|
||||||
// 如果匹配"第X集"、"第X话"、"X集"、"X话"格式,提取中间的数字(支持小数)
|
// 如果匹配"第X集"、"第X话"、"X集"、"X话"格式,提取中间的数字(支持小数)
|
||||||
const match = title.match(/(?:第)?(\d+(?:\.\d+)?)(?:集|话)/);
|
const match = title.match(/(?:第)?(\d+(?:\.\d+)?)(?:集|话)/);
|
||||||
if (match) {
|
if (match) {
|
||||||
@@ -856,7 +871,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
{/* 源名称和集数信息 - 垂直居中 */}
|
{/* 源名称和集数信息 - 垂直居中 */}
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<span className={`text-xs px-2 py-1 border rounded text-gray-700 dark:text-gray-300 ${
|
<span className={`text-xs px-2 py-1 border rounded text-gray-700 dark:text-gray-300 ${
|
||||||
source.source === 'openlist' || source.source === 'emby' || source.source?.startsWith('emby_')
|
source.source === 'xiaoya' ? 'border-blue-500' : source.source === 'openlist' || source.source === 'emby' || source.source?.startsWith('emby_')
|
||||||
? 'border-yellow-500'
|
? 'border-yellow-500'
|
||||||
: 'border-gray-500/60'
|
: 'border-gray-500/60'
|
||||||
}`}>
|
}`}>
|
||||||
@@ -885,6 +900,11 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
<div className='text-orange-600 dark:text-orange-400 font-medium text-xs'>
|
<div className='text-orange-600 dark:text-orange-400 font-medium text-xs'>
|
||||||
{videoInfo.pingTime}ms
|
{videoInfo.pingTime}ms
|
||||||
</div>
|
</div>
|
||||||
|
{videoInfo.bitrate && videoInfo.bitrate !== '未知' && (
|
||||||
|
<div className='text-purple-600 dark:text-purple-400 font-medium text-xs'>
|
||||||
|
{videoInfo.bitrate}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -900,8 +920,8 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
{/* 重新测试按钮 */}
|
{/* 重新测试按钮 */}
|
||||||
{(() => {
|
{(() => {
|
||||||
// 私人影库和 Emby 不显示重新测试按钮
|
// 私人影库、Emby 和小雅不显示重新测试按钮
|
||||||
if (source.source === 'openlist' || source.source === 'emby' || source.source.startsWith('emby_')) {
|
if (source.source === 'openlist' || source.source === 'emby' || source.source.startsWith('emby_') || source.source === 'xiaoya') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -259,6 +259,25 @@ const MultiLevelSelector: React.FC<MultiLevelSelectorProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 动态生成年份选项
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
const currentDecade = Math.floor(currentYear / 10) * 10;
|
||||||
|
const yearOptions: MultiLevelOption[] = [
|
||||||
|
{ label: '全部', value: 'all' },
|
||||||
|
{ label: `${currentDecade}年代`, value: `${currentDecade}s` },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 添加当前年份到当前年代的起始年份
|
||||||
|
for (let year = currentYear; year >= currentDecade; year--) {
|
||||||
|
yearOptions.push({ label: String(year), value: String(year) });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加历史年代
|
||||||
|
for (let decade = currentDecade - 10; decade >= 1960; decade -= 10) {
|
||||||
|
yearOptions.push({ label: `${decade}年代`, value: `${decade}s` });
|
||||||
|
}
|
||||||
|
yearOptions.push({ label: '更早', value: 'earlier' });
|
||||||
|
|
||||||
// 分类配置
|
// 分类配置
|
||||||
const categories: MultiLevelCategory[] = [
|
const categories: MultiLevelCategory[] = [
|
||||||
...(contentType !== 'anime-tv' && contentType !== 'anime-movie'
|
...(contentType !== 'anime-tv' && contentType !== 'anime-movie'
|
||||||
@@ -284,24 +303,7 @@ const MultiLevelSelector: React.FC<MultiLevelSelectorProps> = ({
|
|||||||
{
|
{
|
||||||
key: 'year',
|
key: 'year',
|
||||||
label: '年代',
|
label: '年代',
|
||||||
options: [
|
options: yearOptions,
|
||||||
{ label: '全部', value: 'all' },
|
|
||||||
{ label: '2020年代', value: '2020s' },
|
|
||||||
{ label: '2025', value: '2025' },
|
|
||||||
{ label: '2024', value: '2024' },
|
|
||||||
{ label: '2023', value: '2023' },
|
|
||||||
{ label: '2022', value: '2022' },
|
|
||||||
{ label: '2021', value: '2021' },
|
|
||||||
{ label: '2020', value: '2020' },
|
|
||||||
{ label: '2019', value: '2019' },
|
|
||||||
{ label: '2010年代', value: '2010s' },
|
|
||||||
{ label: '2000年代', value: '2000s' },
|
|
||||||
{ label: '90年代', value: '1990s' },
|
|
||||||
{ label: '80年代', value: '1980s' },
|
|
||||||
{ label: '70年代', value: '1970s' },
|
|
||||||
{ label: '60年代', value: '1960s' },
|
|
||||||
{ label: '更早', value: 'earlier' },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
// 只在电视剧和综艺时显示平台选项
|
// 只在电视剧和综艺时显示平台选项
|
||||||
...(contentType === 'tv' ||
|
...(contentType === 'tv' ||
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export const UserMenu: React.FC = () => {
|
|||||||
const [bufferStrategy, setBufferStrategy] = useState('medium');
|
const [bufferStrategy, setBufferStrategy] = useState('medium');
|
||||||
const [nextEpisodePreCache, setNextEpisodePreCache] = useState(true);
|
const [nextEpisodePreCache, setNextEpisodePreCache] = useState(true);
|
||||||
const [isBufferStrategyDropdownOpen, setIsBufferStrategyDropdownOpen] = useState(false);
|
const [isBufferStrategyDropdownOpen, setIsBufferStrategyDropdownOpen] = useState(false);
|
||||||
|
const [searchTraditionalToSimplified, setSearchTraditionalToSimplified] = useState(false);
|
||||||
|
|
||||||
// 折叠面板状态
|
// 折叠面板状态
|
||||||
const [isDoubanSectionOpen, setIsDoubanSectionOpen] = useState(true);
|
const [isDoubanSectionOpen, setIsDoubanSectionOpen] = useState(true);
|
||||||
@@ -378,6 +379,12 @@ export const UserMenu: React.FC = () => {
|
|||||||
console.error('解析首页模块配置失败:', error);
|
console.error('解析首页模块配置失败:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载搜索繁体转简体设置
|
||||||
|
const savedSearchTraditionalToSimplified = localStorage.getItem('searchTraditionalToSimplified');
|
||||||
|
if (savedSearchTraditionalToSimplified !== null) {
|
||||||
|
setSearchTraditionalToSimplified(savedSearchTraditionalToSimplified === 'true');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -641,6 +648,13 @@ export const UserMenu: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSearchTraditionalToSimplifiedToggle = (value: boolean) => {
|
||||||
|
setSearchTraditionalToSimplified(value);
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('searchTraditionalToSimplified', String(value));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 首页模块配置处理函数
|
// 首页模块配置处理函数
|
||||||
const handleHomeModuleToggle = (id: string, enabled: boolean) => {
|
const handleHomeModuleToggle = (id: string, enabled: boolean) => {
|
||||||
const updatedModules = homeModules.map(module =>
|
const updatedModules = homeModules.map(module =>
|
||||||
@@ -732,6 +746,7 @@ export const UserMenu: React.FC = () => {
|
|||||||
setBufferStrategy('medium');
|
setBufferStrategy('medium');
|
||||||
setNextEpisodePreCache(true);
|
setNextEpisodePreCache(true);
|
||||||
setHomeModules(defaultHomeModules);
|
setHomeModules(defaultHomeModules);
|
||||||
|
setSearchTraditionalToSimplified(false);
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
localStorage.setItem('defaultAggregateSearch', JSON.stringify(true));
|
localStorage.setItem('defaultAggregateSearch', JSON.stringify(true));
|
||||||
@@ -747,6 +762,7 @@ export const UserMenu: React.FC = () => {
|
|||||||
localStorage.setItem('bufferStrategy', 'medium');
|
localStorage.setItem('bufferStrategy', 'medium');
|
||||||
localStorage.setItem('nextEpisodePreCache', 'true');
|
localStorage.setItem('nextEpisodePreCache', 'true');
|
||||||
localStorage.setItem('homeModules', JSON.stringify(defaultHomeModules));
|
localStorage.setItem('homeModules', JSON.stringify(defaultHomeModules));
|
||||||
|
localStorage.setItem('searchTraditionalToSimplified', 'false');
|
||||||
window.dispatchEvent(new CustomEvent('homeModulesUpdated'));
|
window.dispatchEvent(new CustomEvent('homeModulesUpdated'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1423,6 +1439,30 @@ export const UserMenu: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 搜索繁体转简体 */}
|
||||||
|
<div className='flex items-center justify-between'>
|
||||||
|
<div>
|
||||||
|
<h4 className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||||
|
搜索繁体转简体
|
||||||
|
</h4>
|
||||||
|
<p className='text-xs text-gray-500 dark:text-gray-400 mt-1'>
|
||||||
|
搜索时自动将繁体中文转换为简体中文
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<label className='flex items-center cursor-pointer'>
|
||||||
|
<div className='relative'>
|
||||||
|
<input
|
||||||
|
type='checkbox'
|
||||||
|
className='sr-only peer'
|
||||||
|
checked={searchTraditionalToSimplified}
|
||||||
|
onChange={(e) => handleSearchTraditionalToSimplifiedToggle(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<div className='w-11 h-6 bg-gray-300 rounded-full peer-checked:bg-green-500 transition-colors dark:bg-gray-600'></div>
|
||||||
|
<div className='absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full transition-transform peer-checked:translate-x-5'></div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1246,7 +1246,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
|||||||
{config.showSourceName && source_name && !cmsData && (
|
{config.showSourceName && source_name && !cmsData && (
|
||||||
<span
|
<span
|
||||||
className={`inline-block border rounded px-1 py-0.5 text-[8px] text-white/90 bg-black/30 backdrop-blur-sm ${
|
className={`inline-block border rounded px-1 py-0.5 text-[8px] text-white/90 bg-black/30 backdrop-blur-sm ${
|
||||||
actualSource === 'openlist' || actualSource === 'emby' || actualSource?.startsWith('emby_') ? 'border-yellow-500' : 'border-white/60'
|
actualSource === 'xiaoya' ? 'border-blue-500' : actualSource === 'openlist' || actualSource === 'emby' || actualSource?.startsWith('emby_') ? 'border-yellow-500' : 'border-white/60'
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
WebkitUserSelect: 'none',
|
WebkitUserSelect: 'none',
|
||||||
|
|||||||
+12
-1
@@ -40,6 +40,9 @@ export interface AdminConfig {
|
|||||||
TurnstileSiteKey?: string; // Cloudflare Turnstile Site Key
|
TurnstileSiteKey?: string; // Cloudflare Turnstile Site Key
|
||||||
TurnstileSecretKey?: string; // Cloudflare Turnstile Secret Key
|
TurnstileSecretKey?: string; // Cloudflare Turnstile Secret Key
|
||||||
DefaultUserTags?: string[]; // 新注册用户的默认用户组
|
DefaultUserTags?: string[]; // 新注册用户的默认用户组
|
||||||
|
// 求片功能配置
|
||||||
|
EnableMovieRequest?: boolean; // 启用求片功能
|
||||||
|
MovieRequestCooldown?: number; // 求片冷却时间(秒),默认3600
|
||||||
// OIDC配置
|
// OIDC配置
|
||||||
EnableOIDCLogin?: boolean; // 启用OIDC登录
|
EnableOIDCLogin?: boolean; // 启用OIDC登录
|
||||||
EnableOIDCRegistration?: boolean; // 启用OIDC注册
|
EnableOIDCRegistration?: boolean; // 启用OIDC注册
|
||||||
@@ -106,7 +109,8 @@ export interface AdminConfig {
|
|||||||
URL: string; // OpenList 服务器地址
|
URL: string; // OpenList 服务器地址
|
||||||
Username: string; // 账号(用于登录获取Token)
|
Username: string; // 账号(用于登录获取Token)
|
||||||
Password: string; // 密码(用于登录获取Token)
|
Password: string; // 密码(用于登录获取Token)
|
||||||
RootPath: string; // 根目录路径,默认 "/"
|
RootPath?: string; // 旧字段:根目录路径(向后兼容,迁移后删除)
|
||||||
|
RootPaths?: string[]; // 新字段:多根目录路径列表
|
||||||
OfflineDownloadPath: string; // 离线下载目录,默认 "/"
|
OfflineDownloadPath: string; // 离线下载目录,默认 "/"
|
||||||
LastRefreshTime?: number; // 上次刷新时间戳
|
LastRefreshTime?: number; // 上次刷新时间戳
|
||||||
ResourceCount?: number; // 资源数量
|
ResourceCount?: number; // 资源数量
|
||||||
@@ -192,6 +196,13 @@ export interface AdminConfig {
|
|||||||
LastSyncTime?: number;
|
LastSyncTime?: number;
|
||||||
ItemCount?: number;
|
ItemCount?: number;
|
||||||
};
|
};
|
||||||
|
XiaoyaConfig?: {
|
||||||
|
Enabled: boolean; // 是否启用
|
||||||
|
ServerURL: string; // Alist 服务器地址
|
||||||
|
Token?: string; // Token 认证(推荐)
|
||||||
|
Username?: string; // 用户名认证(备选)
|
||||||
|
Password?: string; // 密码认证(备选)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AdminConfigResult {
|
export interface AdminConfigResult {
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
import { parseString } from 'xml2js';
|
||||||
|
|
||||||
|
export interface NFOMetadata {
|
||||||
|
tmdbId?: number;
|
||||||
|
title?: string;
|
||||||
|
originalTitle?: string;
|
||||||
|
year?: number;
|
||||||
|
plot?: string;
|
||||||
|
rating?: number;
|
||||||
|
genres?: string[];
|
||||||
|
mediaType: 'movie' | 'tv';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 NFO 文件(XML 格式)
|
||||||
|
*/
|
||||||
|
export async function parseNFO(xmlContent: string): Promise<NFOMetadata | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
parseString(xmlContent, { explicitArray: false }, (err, result) => {
|
||||||
|
if (err || !result) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = result.movie || result.tvshow;
|
||||||
|
if (!data) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata: NFOMetadata = {
|
||||||
|
tmdbId: data.tmdbid ? parseInt(data.tmdbid) : undefined,
|
||||||
|
title: data.title,
|
||||||
|
originalTitle: data.originaltitle,
|
||||||
|
year: data.year ? parseInt(data.year) : undefined,
|
||||||
|
plot: data.plot,
|
||||||
|
rating: data.rating ? parseFloat(data.rating) : undefined,
|
||||||
|
genres: Array.isArray(data.genre) ? data.genre : data.genre ? [data.genre] : [],
|
||||||
|
mediaType: result.movie ? 'movie' : 'tv',
|
||||||
|
};
|
||||||
|
|
||||||
|
resolve(metadata);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -49,28 +49,30 @@ export interface VideoInfo {
|
|||||||
last_updated: number;
|
last_updated: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MetaInfo 缓存操作
|
// MetaInfo 缓存操作(使用固定键)
|
||||||
export function getCachedMetaInfo(rootPath: string): MetaInfo | null {
|
const METAINFO_CACHE_KEY = 'openlist_meta';
|
||||||
const entry = METAINFO_CACHE.get(rootPath);
|
|
||||||
|
export function getCachedMetaInfo(): MetaInfo | null {
|
||||||
|
const entry = METAINFO_CACHE.get(METAINFO_CACHE_KEY);
|
||||||
if (!entry) return null;
|
if (!entry) return null;
|
||||||
|
|
||||||
if (entry.expiresAt <= Date.now()) {
|
if (entry.expiresAt <= Date.now()) {
|
||||||
METAINFO_CACHE.delete(rootPath);
|
METAINFO_CACHE.delete(METAINFO_CACHE_KEY);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return entry.data;
|
return entry.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setCachedMetaInfo(rootPath: string, data: MetaInfo): void {
|
export function setCachedMetaInfo(data: MetaInfo): void {
|
||||||
METAINFO_CACHE.set(rootPath, {
|
METAINFO_CACHE.set(METAINFO_CACHE_KEY, {
|
||||||
expiresAt: Date.now() + METAINFO_CACHE_TTL_MS,
|
expiresAt: Date.now() + METAINFO_CACHE_TTL_MS,
|
||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function invalidateMetaInfoCache(rootPath: string): void {
|
export function invalidateMetaInfoCache(): void {
|
||||||
METAINFO_CACHE.delete(rootPath);
|
METAINFO_CACHE.delete(METAINFO_CACHE_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
// VideoInfo 缓存操作
|
// VideoInfo 缓存操作
|
||||||
|
|||||||
+120
-10
@@ -20,6 +20,65 @@ import {
|
|||||||
import { parseSeasonFromTitle } from '@/lib/season-parser';
|
import { parseSeasonFromTitle } from '@/lib/season-parser';
|
||||||
import { searchTMDB, getTVSeasonDetails } from '@/lib/tmdb.search';
|
import { searchTMDB, getTVSeasonDetails } from '@/lib/tmdb.search';
|
||||||
import parseTorrentName from 'parse-torrent-name';
|
import parseTorrentName from 'parse-torrent-name';
|
||||||
|
import type { AdminConfig } from '@/lib/admin.types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取根目录列表(兼容新旧配置)
|
||||||
|
*/
|
||||||
|
function getRootPaths(openListConfig: AdminConfig['OpenListConfig']): string[] {
|
||||||
|
if (!openListConfig) {
|
||||||
|
return ['/'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有新字段 RootPaths,直接使用
|
||||||
|
if (openListConfig.RootPaths && openListConfig.RootPaths.length > 0) {
|
||||||
|
return openListConfig.RootPaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果只tPath,返回单元素数组
|
||||||
|
if (openListConfig.RootPath) {
|
||||||
|
return [openListConfig.RootPath];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 默认值
|
||||||
|
return ['/'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 迁移旧版单根目录配置到多根目录
|
||||||
|
*/
|
||||||
|
async function migrateToMultiRoot(openListConfig: NonNullable<AdminConfig['OpenListConfig']>): Promise<void> {
|
||||||
|
const oldRootPath = openListConfig.RootPath!;
|
||||||
|
|
||||||
|
console.log('[OpenList Migration] 检测到旧版配置,开始迁移...');
|
||||||
|
|
||||||
|
// 1. 读取现有 metainfo
|
||||||
|
const metainfoContent = await db.getGlobalValue('video.metainfo');
|
||||||
|
if (metainfoContent) {
|
||||||
|
const metaInfo: MetaInfo = JSON.parse(metainfoContent);
|
||||||
|
|
||||||
|
// 2. 迁移 folderName:加上原根路径前缀
|
||||||
|
for (const [key, info] of Object.entries(metaInfo.folders)) {
|
||||||
|
const oldFolderName = info.folderName;
|
||||||
|
const newFolderName = `${oldRootPath}${oldRootPath.endsWith('/') ? '' : '/'}${oldFolderName}`;
|
||||||
|
info.folderName = newFolderName;
|
||||||
|
|
||||||
|
console.log(`[Migration] ${oldFolderName} -> ${newFolderName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 保存迁移后的 metainfo
|
||||||
|
await db.setGlobalValue('video.metainfo', JSON.stringify(metaInfo));
|
||||||
|
console.log('[OpenList Migration] MetaInfo 迁移完成');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 更新配置:RootPath -> RootPaths
|
||||||
|
const config = await getConfig();
|
||||||
|
config.OpenListConfig!.RootPaths = [oldRootPath];
|
||||||
|
delete config.OpenListConfig!.RootPath;
|
||||||
|
await db.saveAdminConfig(config);
|
||||||
|
|
||||||
|
console.log('[OpenList Migration] 配置迁移完成');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动 OpenList 刷新任务
|
* 启动 OpenList 刷新任务
|
||||||
@@ -45,13 +104,24 @@ export async function startOpenListRefresh(clearMetaInfo: boolean = false): Prom
|
|||||||
throw new Error('TMDB API Key 未配置');
|
throw new Error('TMDB API Key 未配置');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检测是否需要迁移
|
||||||
|
if (openListConfig.RootPath && !openListConfig.RootPaths) {
|
||||||
|
await migrateToMultiRoot(openListConfig);
|
||||||
|
// 重新加载配置
|
||||||
|
const newConfig = await getConfig();
|
||||||
|
Object.assign(openListConfig, newConfig.OpenListConfig);
|
||||||
|
}
|
||||||
|
|
||||||
cleanupOldTasks();
|
cleanupOldTasks();
|
||||||
const taskId = createScanTask();
|
const taskId = createScanTask();
|
||||||
|
|
||||||
performScan(
|
const rootPaths = getRootPaths(openListConfig);
|
||||||
|
|
||||||
|
// 顺序扫描多个根目录
|
||||||
|
performMultiRootScan(
|
||||||
taskId,
|
taskId,
|
||||||
openListConfig.URL,
|
openListConfig.URL,
|
||||||
openListConfig.RootPath || '/',
|
rootPaths,
|
||||||
tmdbApiKey,
|
tmdbApiKey,
|
||||||
tmdbProxy,
|
tmdbProxy,
|
||||||
openListConfig.Username,
|
openListConfig.Username,
|
||||||
@@ -66,6 +136,43 @@ export async function startOpenListRefresh(clearMetaInfo: boolean = false): Prom
|
|||||||
return { taskId };
|
return { taskId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描多个根目录
|
||||||
|
*/
|
||||||
|
async function performMultiRootScan(
|
||||||
|
taskId: string,
|
||||||
|
url: string,
|
||||||
|
rootPaths: string[],
|
||||||
|
tmdbApiKey: string,
|
||||||
|
tmdbProxy: string | undefined,
|
||||||
|
username: string,
|
||||||
|
password: string,
|
||||||
|
clearMetaInfo: boolean,
|
||||||
|
scanMode: 'torrent' | 'name' | 'hybrid'
|
||||||
|
): Promise<void> {
|
||||||
|
for (let i = 0; i < rootPaths.length; i++) {
|
||||||
|
const rootPath = rootPaths[i];
|
||||||
|
console.log(`[OpenList Refresh] 扫描根目录 (${i + 1}/${rootPaths.length}): ${rootPath}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await performScan(
|
||||||
|
taskId,
|
||||||
|
url,
|
||||||
|
rootPath,
|
||||||
|
tmdbApiKey,
|
||||||
|
tmdbProxy,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
clearMetaInfo && i === 0, // 只在第一个根目录时清除
|
||||||
|
scanMode
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[OpenList Refresh] 根目录 ${rootPath} 扫描失败:`, error);
|
||||||
|
// 继续扫描其他根目录
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行扫描任务
|
* 执行扫描任务
|
||||||
*/
|
*/
|
||||||
@@ -112,7 +219,7 @@ async function performScan(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
invalidateMetaInfoCache(rootPath);
|
invalidateMetaInfoCache();
|
||||||
|
|
||||||
const folders: any[] = [];
|
const folders: any[] = [];
|
||||||
let currentPage = 1;
|
let currentPage = 1;
|
||||||
@@ -155,12 +262,15 @@ async function performScan(
|
|||||||
|
|
||||||
updateScanTaskProgress(taskId, i + 1, folders.length, folder.name);
|
updateScanTaskProgress(taskId, i + 1, folders.length, folder.name);
|
||||||
|
|
||||||
if (!clearMetaInfo && folderNameToKey.has(folder.name)) {
|
// folderName 存储完整路径(包含根目录)
|
||||||
|
const fullFolderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folder.name}`;
|
||||||
|
|
||||||
|
if (!clearMetaInfo && folderNameToKey.has(fullFolderPath)) {
|
||||||
existingCount++;
|
existingCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const folderKey = generateFolderKey(folder.name, existingKeys);
|
const folderKey = generateFolderKey(fullFolderPath, existingKeys);
|
||||||
existingKeys.add(folderKey);
|
existingKeys.add(folderKey);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -197,7 +307,7 @@ async function performScan(
|
|||||||
const result = searchResult.result;
|
const result = searchResult.result;
|
||||||
|
|
||||||
const folderInfo: any = {
|
const folderInfo: any = {
|
||||||
folderName: folder.name,
|
folderName: fullFolderPath,
|
||||||
tmdb_id: result.id,
|
tmdb_id: result.id,
|
||||||
title: result.title || result.name || folder.name,
|
title: result.title || result.name || folder.name,
|
||||||
poster_path: result.poster_path,
|
poster_path: result.poster_path,
|
||||||
@@ -249,7 +359,7 @@ async function performScan(
|
|||||||
newCount++;
|
newCount++;
|
||||||
} else {
|
} else {
|
||||||
metaInfo.folders[folderKey] = {
|
metaInfo.folders[folderKey] = {
|
||||||
folderName: folder.name,
|
folderName: fullFolderPath,
|
||||||
tmdb_id: 0,
|
tmdb_id: 0,
|
||||||
title: folder.name,
|
title: folder.name,
|
||||||
poster_path: null,
|
poster_path: null,
|
||||||
@@ -267,7 +377,7 @@ async function performScan(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[OpenList Refresh] 处理文件夹失败: ${folder.name}`, error);
|
console.error(`[OpenList Refresh] 处理文件夹失败: ${folder.name}`, error);
|
||||||
metaInfo.folders[folderKey] = {
|
metaInfo.folders[folderKey] = {
|
||||||
folderName: folder.name,
|
folderName: fullFolderPath,
|
||||||
tmdb_id: 0,
|
tmdb_id: 0,
|
||||||
title: folder.name,
|
title: folder.name,
|
||||||
poster_path: null,
|
poster_path: null,
|
||||||
@@ -287,8 +397,8 @@ async function performScan(
|
|||||||
const metainfoContent = JSON.stringify(metaInfo);
|
const metainfoContent = JSON.stringify(metaInfo);
|
||||||
await db.setGlobalValue('video.metainfo', metainfoContent);
|
await db.setGlobalValue('video.metainfo', metainfoContent);
|
||||||
|
|
||||||
invalidateMetaInfoCache(rootPath);
|
invalidateMetaInfoCache();
|
||||||
setCachedMetaInfo(rootPath, metaInfo);
|
setCachedMetaInfo(metaInfo);
|
||||||
|
|
||||||
const config = await getConfig();
|
const config = await getConfig();
|
||||||
config.OpenListConfig!.LastRefreshTime = Date.now();
|
config.OpenListConfig!.LastRefreshTime = Date.now();
|
||||||
|
|||||||
@@ -280,6 +280,31 @@ export class OpenListClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取视频预览流
|
||||||
|
async getVideoPreview(path: string): Promise<any> {
|
||||||
|
const response = await this.fetchWithRetry(`${this.baseURL}/api/fs/other`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: await this.getHeaders(),
|
||||||
|
body: JSON.stringify({
|
||||||
|
path: path,
|
||||||
|
method: 'video_preview',
|
||||||
|
password: '',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`视频预览请求失败: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.code !== 200) {
|
||||||
|
throw new Error(`视频预览失败: ${data.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
// 检查连通性
|
// 检查连通性
|
||||||
async checkConnectivity(): Promise<{ success: boolean; message: string }> {
|
async checkConnectivity(): Promise<{ success: boolean; message: string }> {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -659,6 +659,7 @@ export abstract class BaseRedisStorage implements IStorage {
|
|||||||
playrecord_migrated?: boolean;
|
playrecord_migrated?: boolean;
|
||||||
favorite_migrated?: boolean;
|
favorite_migrated?: boolean;
|
||||||
skip_migrated?: boolean;
|
skip_migrated?: boolean;
|
||||||
|
last_movie_request_time?: number;
|
||||||
} | null> {
|
} | null> {
|
||||||
const userInfo = await this.withRetry(() =>
|
const userInfo = await this.withRetry(() =>
|
||||||
this.client.hGetAll(this.userInfoKey(userName))
|
this.client.hGetAll(this.userInfoKey(userName))
|
||||||
@@ -678,6 +679,7 @@ export abstract class BaseRedisStorage implements IStorage {
|
|||||||
playrecord_migrated: userInfo.playrecord_migrated === 'true',
|
playrecord_migrated: userInfo.playrecord_migrated === 'true',
|
||||||
favorite_migrated: userInfo.favorite_migrated === 'true',
|
favorite_migrated: userInfo.favorite_migrated === 'true',
|
||||||
skip_migrated: userInfo.skip_migrated === 'true',
|
skip_migrated: userInfo.skip_migrated === 'true',
|
||||||
|
last_movie_request_time: userInfo.last_movie_request_time ? parseInt(userInfo.last_movie_request_time, 10) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1240,4 +1242,52 @@ export abstract class BaseRedisStorage implements IStorage {
|
|||||||
this.client.set(this.lastFavoriteCheckKey(userName), timestamp.toString())
|
this.client.set(this.lastFavoriteCheckKey(userName), timestamp.toString())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 求片相关 ----------
|
||||||
|
private movieRequestsKey() {
|
||||||
|
return 'movie_requests:all';
|
||||||
|
}
|
||||||
|
|
||||||
|
private userMovieRequestsKey(userName: string) {
|
||||||
|
return `u:${userName}:mr`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllMovieRequests(): Promise<import('./types').MovieRequest[]> {
|
||||||
|
const data = await this.withRetry(() => this.client.hGetAll(this.movieRequestsKey()));
|
||||||
|
if (!data || Object.keys(data).length === 0) return [];
|
||||||
|
return Object.values(data).map(v => JSON.parse(v) as import('./types').MovieRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMovieRequest(requestId: string): Promise<import('./types').MovieRequest | null> {
|
||||||
|
const val = await this.withRetry(() => this.client.hGet(this.movieRequestsKey(), requestId));
|
||||||
|
return val ? (JSON.parse(val) as import('./types').MovieRequest) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMovieRequest(request: import('./types').MovieRequest): Promise<void> {
|
||||||
|
await this.withRetry(() => this.client.hSet(this.movieRequestsKey(), request.id, JSON.stringify(request)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMovieRequest(requestId: string, updates: Partial<import('./types').MovieRequest>): Promise<void> {
|
||||||
|
const existing = await this.getMovieRequest(requestId);
|
||||||
|
if (!existing) throw new Error('Movie request not found');
|
||||||
|
const updated = { ...existing, ...updates };
|
||||||
|
await this.withRetry(() => this.client.hSet(this.movieRequestsKey(), requestId, JSON.stringify(updated)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMovieRequest(requestId: string): Promise<void> {
|
||||||
|
await this.withRetry(() => this.client.hDel(this.movieRequestsKey(), requestId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserMovieRequests(userName: string): Promise<string[]> {
|
||||||
|
const val = await this.withRetry(() => this.client.sMembers(this.userMovieRequestsKey(userName)));
|
||||||
|
return val ? ensureStringArray(val) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async addUserMovieRequest(userName: string, requestId: string): Promise<void> {
|
||||||
|
await this.withRetry(() => this.client.sAdd(this.userMovieRequestsKey(userName), requestId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeUserMovieRequest(userName: string, requestId: string): Promise<void> {
|
||||||
|
await this.withRetry(() => this.client.sRem(this.userMovieRequestsKey(userName), requestId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+51
-1
@@ -117,6 +117,30 @@ export interface IStorage {
|
|||||||
// 收藏更新检查相关
|
// 收藏更新检查相关
|
||||||
getLastFavoriteCheckTime(userName: string): Promise<number>;
|
getLastFavoriteCheckTime(userName: string): Promise<number>;
|
||||||
setLastFavoriteCheckTime(userName: string, timestamp: number): Promise<void>;
|
setLastFavoriteCheckTime(userName: string, timestamp: number): Promise<void>;
|
||||||
|
|
||||||
|
// 求片相关
|
||||||
|
getAllMovieRequests(): Promise<MovieRequest[]>;
|
||||||
|
getMovieRequest(requestId: string): Promise<MovieRequest | null>;
|
||||||
|
createMovieRequest(request: MovieRequest): Promise<void>;
|
||||||
|
updateMovieRequest(requestId: string, updates: Partial<MovieRequest>): Promise<void>;
|
||||||
|
deleteMovieRequest(requestId: string): Promise<void>;
|
||||||
|
getUserMovieRequests(userName: string): Promise<string[]>;
|
||||||
|
addUserMovieRequest(userName: string, requestId: string): Promise<void>;
|
||||||
|
removeUserMovieRequest(userName: string, requestId: string): Promise<void>;
|
||||||
|
|
||||||
|
// 新版用户存储(V2)- 可选方法
|
||||||
|
getUserInfoV2?(userName: string): Promise<{
|
||||||
|
role: 'owner' | 'admin' | 'user';
|
||||||
|
banned: boolean;
|
||||||
|
tags?: string[];
|
||||||
|
oidcSub?: string;
|
||||||
|
enabledApis?: string[];
|
||||||
|
created_at: number;
|
||||||
|
playrecord_migrated?: boolean;
|
||||||
|
favorite_migrated?: boolean;
|
||||||
|
skip_migrated?: boolean;
|
||||||
|
last_movie_request_time?: number;
|
||||||
|
} | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 搜索结果数据结构
|
// 搜索结果数据结构
|
||||||
@@ -137,6 +161,10 @@ export interface SearchResult {
|
|||||||
vod_total?: number; // 总集数
|
vod_total?: number; // 总集数
|
||||||
proxyMode?: boolean; // 代理模式:启用后由服务器代理m3u8和ts分片
|
proxyMode?: boolean; // 代理模式:启用后由服务器代理m3u8和ts分片
|
||||||
subtitles?: Array<Array<{ label: string; url: string }>>; // 字幕列表(按集数索引)
|
subtitles?: Array<Array<{ label: string; url: string }>>; // 字幕列表(按集数索引)
|
||||||
|
tmdb_id?: number; // TMDB ID
|
||||||
|
rating?: number; // 评分
|
||||||
|
initialEpisodeIndex?: number; // 初始集数索引(用于小雅源从文件点击进入时指定集数)
|
||||||
|
metadataSource?: 'folder' | 'nfo' | 'tmdb' | 'file'; // 元数据来源(用于小雅源判断是否保留fileName)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 豆瓣数据结构
|
// 豆瓣数据结构
|
||||||
@@ -191,7 +219,9 @@ export interface EpisodeFilterConfig {
|
|||||||
export type NotificationType =
|
export type NotificationType =
|
||||||
| 'favorite_update' // 收藏更新
|
| 'favorite_update' // 收藏更新
|
||||||
| 'system' // 系统通知
|
| 'system' // 系统通知
|
||||||
| 'announcement'; // 公告
|
| 'announcement' // 公告
|
||||||
|
| 'movie_request' // 新求片通知(给管理员)
|
||||||
|
| 'request_fulfilled'; // 求片已上架通知(给求片用户)
|
||||||
|
|
||||||
// 通知数据结构
|
// 通知数据结构
|
||||||
export interface Notification {
|
export interface Notification {
|
||||||
@@ -215,3 +245,23 @@ export interface FavoriteUpdateCheck {
|
|||||||
new_episodes: number;
|
new_episodes: number;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 求片请求数据结构
|
||||||
|
export interface MovieRequest {
|
||||||
|
id: string;
|
||||||
|
tmdbId?: number;
|
||||||
|
title: string;
|
||||||
|
year?: string;
|
||||||
|
mediaType: 'movie' | 'tv';
|
||||||
|
season?: number; // 季度(仅剧集)
|
||||||
|
poster?: string;
|
||||||
|
overview?: string;
|
||||||
|
requestedBy: string[];
|
||||||
|
requestCount: number;
|
||||||
|
status: 'pending' | 'fulfilled';
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
fulfilledAt?: number;
|
||||||
|
fulfilledSource?: string;
|
||||||
|
fulfilledId?: string;
|
||||||
|
}
|
||||||
|
|||||||
+165
-90
@@ -58,10 +58,31 @@ async function withRetry<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class UpstashRedisStorage implements IStorage {
|
export class UpstashRedisStorage implements IStorage {
|
||||||
private client: Redis;
|
private _client: Redis;
|
||||||
|
client: any;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.client = getUpstashRedisClient();
|
this._client = getUpstashRedisClient();
|
||||||
|
// 创建兼容Redis API的client包装器(支持camelCase和lowercase)
|
||||||
|
this.client = {
|
||||||
|
hSet: (key: string, field: string | Record<string, any>, value?: string) => {
|
||||||
|
if (typeof field === 'string' && value !== undefined) {
|
||||||
|
return this._client.hset(key, { [field]: value });
|
||||||
|
}
|
||||||
|
return this._client.hset(key, field as Record<string, any>);
|
||||||
|
},
|
||||||
|
hset: (key: string, data: Record<string, any>) => this._client.hset(key, data),
|
||||||
|
zAdd: (key: string, member: { score: number; value: string }) => this._client.zadd(key, { score: member.score, member: member.value }),
|
||||||
|
zadd: (key: string, member: { score: number; value: string }) => this._client.zadd(key, { score: member.score, member: member.value }),
|
||||||
|
set: (key: string, value: string) => this._client.set(key, value),
|
||||||
|
hGetAll: (key: string) => this._client.hgetall(key),
|
||||||
|
hgetall: (key: string) => this._client.hgetall(key),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 公开withRetry方法供外部使用
|
||||||
|
withRetry<T>(operation: () => Promise<T>, maxRetries = 3): Promise<T> {
|
||||||
|
return withRetry(operation, maxRetries);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 播放记录 ----------
|
// ---------- 播放记录 ----------
|
||||||
@@ -79,7 +100,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
key: string
|
key: string
|
||||||
): Promise<PlayRecord | null> {
|
): Promise<PlayRecord | null> {
|
||||||
const val = await withRetry(() =>
|
const val = await withRetry(() =>
|
||||||
this.client.hget(this.prHashKey(userName), key)
|
this._client.hget(this.prHashKey(userName), key)
|
||||||
);
|
);
|
||||||
return val ? (val as PlayRecord) : null;
|
return val ? (val as PlayRecord) : null;
|
||||||
}
|
}
|
||||||
@@ -89,14 +110,14 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
key: string,
|
key: string,
|
||||||
record: PlayRecord
|
record: PlayRecord
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await withRetry(() => this.client.hset(this.prHashKey(userName), { [key]: record }));
|
await withRetry(() => this._client.hset(this.prHashKey(userName), { [key]: record }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllPlayRecords(
|
async getAllPlayRecords(
|
||||||
userName: string
|
userName: string
|
||||||
): Promise<Record<string, PlayRecord>> {
|
): Promise<Record<string, PlayRecord>> {
|
||||||
const hashData = await withRetry(() =>
|
const hashData = await withRetry(() =>
|
||||||
this.client.hgetall(this.prHashKey(userName))
|
this._client.hgetall(this.prHashKey(userName))
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!hashData || Object.keys(hashData).length === 0) return {};
|
if (!hashData || Object.keys(hashData).length === 0) return {};
|
||||||
@@ -111,7 +132,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deletePlayRecord(userName: string, key: string): Promise<void> {
|
async deletePlayRecord(userName: string, key: string): Promise<void> {
|
||||||
await withRetry(() => this.client.hdel(this.prHashKey(userName), key));
|
await withRetry(() => this._client.hdel(this.prHashKey(userName), key));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清理超出限制的旧播放记录
|
// 清理超出限制的旧播放记录
|
||||||
@@ -210,13 +231,13 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
|
|
||||||
// 2. 获取旧结构的所有播放记录key
|
// 2. 获取旧结构的所有播放记录key
|
||||||
const pattern = `u:${userName}:pr:*`;
|
const pattern = `u:${userName}:pr:*`;
|
||||||
const oldKeys: string[] = await withRetry(() => this.client.keys(pattern));
|
const oldKeys: string[] = await withRetry(() => this._client.keys(pattern));
|
||||||
|
|
||||||
if (oldKeys.length === 0) {
|
if (oldKeys.length === 0) {
|
||||||
console.log(`用户 ${userName} 没有旧的播放记录,标记为已迁移`);
|
console.log(`用户 ${userName} 没有旧的播放记录,标记为已迁移`);
|
||||||
// 即使没有数据也标记为已迁移
|
// 即使没有数据也标记为已迁移
|
||||||
await withRetry(() =>
|
await withRetry(() =>
|
||||||
this.client.hset(this.userInfoKey(userName), { playrecord_migrated: true })
|
this._client.hset(this.userInfoKey(userName), { playrecord_migrated: true })
|
||||||
);
|
);
|
||||||
// 清除用户信息缓存
|
// 清除用户信息缓存
|
||||||
userInfoCache?.delete(userName);
|
userInfoCache?.delete(userName);
|
||||||
@@ -228,7 +249,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
// 3. 批量获取旧数据并转换为hash格式
|
// 3. 批量获取旧数据并转换为hash格式
|
||||||
const hashData: Record<string, any> = {};
|
const hashData: Record<string, any> = {};
|
||||||
for (const fullKey of oldKeys) {
|
for (const fullKey of oldKeys) {
|
||||||
const value = await withRetry(() => this.client.get(fullKey));
|
const value = await withRetry(() => this._client.get(fullKey));
|
||||||
if (value) {
|
if (value) {
|
||||||
// 提取 source+id 部分作为hash的field
|
// 提取 source+id 部分作为hash的field
|
||||||
const keyPart = ensureString(fullKey.replace(`u:${userName}:pr:`, ''));
|
const keyPart = ensureString(fullKey.replace(`u:${userName}:pr:`, ''));
|
||||||
@@ -239,18 +260,18 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
// 4. 写入新的hash结构
|
// 4. 写入新的hash结构
|
||||||
if (Object.keys(hashData).length > 0) {
|
if (Object.keys(hashData).length > 0) {
|
||||||
await withRetry(() =>
|
await withRetry(() =>
|
||||||
this.client.hset(this.prHashKey(userName), hashData)
|
this._client.hset(this.prHashKey(userName), hashData)
|
||||||
);
|
);
|
||||||
console.log(`成功迁移 ${Object.keys(hashData).length} 条播放记录到hash结构`);
|
console.log(`成功迁移 ${Object.keys(hashData).length} 条播放记录到hash结构`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 删除旧的key
|
// 5. 删除旧的key
|
||||||
await withRetry(() => this.client.del(...oldKeys));
|
await withRetry(() => this._client.del(...oldKeys));
|
||||||
console.log(`删除了 ${oldKeys.length} 个旧的播放记录key`);
|
console.log(`删除了 ${oldKeys.length} 个旧的播放记录key`);
|
||||||
|
|
||||||
// 6. 标记迁移完成
|
// 6. 标记迁移完成
|
||||||
await withRetry(() =>
|
await withRetry(() =>
|
||||||
this.client.hset(this.userInfoKey(userName), { playrecord_migrated: true })
|
this._client.hset(this.userInfoKey(userName), { playrecord_migrated: true })
|
||||||
);
|
);
|
||||||
|
|
||||||
// 7. 清除用户信息缓存,确保下次获取时能读取到最新的迁移标识
|
// 7. 清除用户信息缓存,确保下次获取时能读取到最新的迁移标识
|
||||||
@@ -271,7 +292,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
|
|
||||||
async getFavorite(userName: string, key: string): Promise<Favorite | null> {
|
async getFavorite(userName: string, key: string): Promise<Favorite | null> {
|
||||||
const val = await withRetry(() =>
|
const val = await withRetry(() =>
|
||||||
this.client.hget(this.favHashKey(userName), key)
|
this._client.hget(this.favHashKey(userName), key)
|
||||||
);
|
);
|
||||||
return val ? (val as Favorite) : null;
|
return val ? (val as Favorite) : null;
|
||||||
}
|
}
|
||||||
@@ -281,12 +302,12 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
key: string,
|
key: string,
|
||||||
favorite: Favorite
|
favorite: Favorite
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await withRetry(() => this.client.hset(this.favHashKey(userName), { [key]: favorite }));
|
await withRetry(() => this._client.hset(this.favHashKey(userName), { [key]: favorite }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAllFavorites(userName: string): Promise<Record<string, Favorite>> {
|
async getAllFavorites(userName: string): Promise<Record<string, Favorite>> {
|
||||||
const hashData = await withRetry(() =>
|
const hashData = await withRetry(() =>
|
||||||
this.client.hgetall(this.favHashKey(userName))
|
this._client.hgetall(this.favHashKey(userName))
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!hashData || Object.keys(hashData).length === 0) return {};
|
if (!hashData || Object.keys(hashData).length === 0) return {};
|
||||||
@@ -301,7 +322,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async deleteFavorite(userName: string, key: string): Promise<void> {
|
async deleteFavorite(userName: string, key: string): Promise<void> {
|
||||||
await withRetry(() => this.client.hdel(this.favHashKey(userName), key));
|
await withRetry(() => this._client.hdel(this.favHashKey(userName), key));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 迁移收藏:从旧的多key结构迁移到新的hash结构
|
// 迁移收藏:从旧的多key结构迁移到新的hash结构
|
||||||
@@ -339,13 +360,13 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
|
|
||||||
// 2. 获取旧结构的所有收藏key
|
// 2. 获取旧结构的所有收藏key
|
||||||
const pattern = `u:${userName}:fav:*`;
|
const pattern = `u:${userName}:fav:*`;
|
||||||
const oldKeys: string[] = await withRetry(() => this.client.keys(pattern));
|
const oldKeys: string[] = await withRetry(() => this._client.keys(pattern));
|
||||||
|
|
||||||
if (oldKeys.length === 0) {
|
if (oldKeys.length === 0) {
|
||||||
console.log(`用户 ${userName} 没有旧的收藏,标记为已迁移`);
|
console.log(`用户 ${userName} 没有旧的收藏,标记为已迁移`);
|
||||||
// 即使没有数据也标记为已迁移
|
// 即使没有数据也标记为已迁移
|
||||||
await withRetry(() =>
|
await withRetry(() =>
|
||||||
this.client.hset(this.userInfoKey(userName), { favorite_migrated: true })
|
this._client.hset(this.userInfoKey(userName), { favorite_migrated: true })
|
||||||
);
|
);
|
||||||
// 清除用户信息缓存
|
// 清除用户信息缓存
|
||||||
userInfoCache?.delete(userName);
|
userInfoCache?.delete(userName);
|
||||||
@@ -357,7 +378,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
// 3. 批量获取旧数据并转换为hash格式
|
// 3. 批量获取旧数据并转换为hash格式
|
||||||
const hashData: Record<string, any> = {};
|
const hashData: Record<string, any> = {};
|
||||||
for (const fullKey of oldKeys) {
|
for (const fullKey of oldKeys) {
|
||||||
const value = await withRetry(() => this.client.get(fullKey));
|
const value = await withRetry(() => this._client.get(fullKey));
|
||||||
if (value) {
|
if (value) {
|
||||||
// 提取 source+id 部分作为hash的field
|
// 提取 source+id 部分作为hash的field
|
||||||
const keyPart = ensureString(fullKey.replace(`u:${userName}:fav:`, ''));
|
const keyPart = ensureString(fullKey.replace(`u:${userName}:fav:`, ''));
|
||||||
@@ -368,18 +389,18 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
// 4. 写入新的hash结构
|
// 4. 写入新的hash结构
|
||||||
if (Object.keys(hashData).length > 0) {
|
if (Object.keys(hashData).length > 0) {
|
||||||
await withRetry(() =>
|
await withRetry(() =>
|
||||||
this.client.hset(this.favHashKey(userName), hashData)
|
this._client.hset(this.favHashKey(userName), hashData)
|
||||||
);
|
);
|
||||||
console.log(`成功迁移 ${Object.keys(hashData).length} 条收藏到hash结构`);
|
console.log(`成功迁移 ${Object.keys(hashData).length} 条收藏到hash结构`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 删除旧的key
|
// 5. 删除旧的key
|
||||||
await withRetry(() => this.client.del(...oldKeys));
|
await withRetry(() => this._client.del(...oldKeys));
|
||||||
console.log(`删除了 ${oldKeys.length} 个旧的收藏key`);
|
console.log(`删除了 ${oldKeys.length} 个旧的收藏key`);
|
||||||
|
|
||||||
// 6. 标记迁移完成
|
// 6. 标记迁移完成
|
||||||
await withRetry(() =>
|
await withRetry(() =>
|
||||||
this.client.hset(this.userInfoKey(userName), { favorite_migrated: true })
|
this._client.hset(this.userInfoKey(userName), { favorite_migrated: true })
|
||||||
);
|
);
|
||||||
|
|
||||||
// 7. 清除用户信息缓存,确保下次获取时能读取到最新的迁移标识
|
// 7. 清除用户信息缓存,确保下次获取时能读取到最新的迁移标识
|
||||||
@@ -395,7 +416,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
|
|
||||||
async verifyUser(userName: string, password: string): Promise<boolean> {
|
async verifyUser(userName: string, password: string): Promise<boolean> {
|
||||||
const stored = await withRetry(() =>
|
const stored = await withRetry(() =>
|
||||||
this.client.get(this.userPwdKey(userName))
|
this._client.get(this.userPwdKey(userName))
|
||||||
);
|
);
|
||||||
if (stored === null) return false;
|
if (stored === null) return false;
|
||||||
// 确保比较时都是字符串类型
|
// 确保比较时都是字符串类型
|
||||||
@@ -406,7 +427,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
async checkUserExist(userName: string): Promise<boolean> {
|
async checkUserExist(userName: string): Promise<boolean> {
|
||||||
// 使用 EXISTS 判断 key 是否存在
|
// 使用 EXISTS 判断 key 是否存在
|
||||||
const exists = await withRetry(() =>
|
const exists = await withRetry(() =>
|
||||||
this.client.exists(this.userPwdKey(userName))
|
this._client.exists(this.userPwdKey(userName))
|
||||||
);
|
);
|
||||||
return exists === 1;
|
return exists === 1;
|
||||||
}
|
}
|
||||||
@@ -415,52 +436,52 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
async changePassword(userName: string, newPassword: string): Promise<void> {
|
async changePassword(userName: string, newPassword: string): Promise<void> {
|
||||||
// 简单存储明文密码,生产环境应加密
|
// 简单存储明文密码,生产环境应加密
|
||||||
await withRetry(() =>
|
await withRetry(() =>
|
||||||
this.client.set(this.userPwdKey(userName), newPassword)
|
this._client.set(this.userPwdKey(userName), newPassword)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除用户及其所有数据
|
// 删除用户及其所有数据
|
||||||
async deleteUser(userName: string): Promise<void> {
|
async deleteUser(userName: string): Promise<void> {
|
||||||
// 删除用户密码
|
// 删除用户密码
|
||||||
await withRetry(() => this.client.del(this.userPwdKey(userName)));
|
await withRetry(() => this._client.del(this.userPwdKey(userName)));
|
||||||
|
|
||||||
// 删除搜索历史
|
// 删除搜索历史
|
||||||
await withRetry(() => this.client.del(this.shKey(userName)));
|
await withRetry(() => this._client.del(this.shKey(userName)));
|
||||||
|
|
||||||
// 删除播放记录(新hash结构)
|
// 删除播放记录(新hash结构)
|
||||||
await withRetry(() => this.client.del(this.prHashKey(userName)));
|
await withRetry(() => this._client.del(this.prHashKey(userName)));
|
||||||
|
|
||||||
// 删除旧的播放记录key(如果有)
|
// 删除旧的播放记录key(如果有)
|
||||||
const playRecordPattern = `u:${userName}:pr:*`;
|
const playRecordPattern = `u:${userName}:pr:*`;
|
||||||
const playRecordKeys = await withRetry(() =>
|
const playRecordKeys = await withRetry(() =>
|
||||||
this.client.keys(playRecordPattern)
|
this._client.keys(playRecordPattern)
|
||||||
);
|
);
|
||||||
if (playRecordKeys.length > 0) {
|
if (playRecordKeys.length > 0) {
|
||||||
await withRetry(() => this.client.del(...playRecordKeys));
|
await withRetry(() => this._client.del(...playRecordKeys));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除收藏夹(新hash结构)
|
// 删除收藏夹(新hash结构)
|
||||||
await withRetry(() => this.client.del(this.favHashKey(userName)));
|
await withRetry(() => this._client.del(this.favHashKey(userName)));
|
||||||
|
|
||||||
// 删除旧的收藏key(如果有)
|
// 删除旧的收藏key(如果有)
|
||||||
const favoritePattern = `u:${userName}:fav:*`;
|
const favoritePattern = `u:${userName}:fav:*`;
|
||||||
const favoriteKeys = await withRetry(() =>
|
const favoriteKeys = await withRetry(() =>
|
||||||
this.client.keys(favoritePattern)
|
this._client.keys(favoritePattern)
|
||||||
);
|
);
|
||||||
if (favoriteKeys.length > 0) {
|
if (favoriteKeys.length > 0) {
|
||||||
await withRetry(() => this.client.del(...favoriteKeys));
|
await withRetry(() => this._client.del(...favoriteKeys));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除跳过片头片尾配置(新hash结构)
|
// 删除跳过片头片尾配置(新hash结构)
|
||||||
await withRetry(() => this.client.del(this.skipHashKey(userName)));
|
await withRetry(() => this._client.del(this.skipHashKey(userName)));
|
||||||
|
|
||||||
// 删除旧的跳过配置key(如果有)
|
// 删除旧的跳过配置key(如果有)
|
||||||
const skipConfigPattern = `u:${userName}:skip:*`;
|
const skipConfigPattern = `u:${userName}:skip:*`;
|
||||||
const skipConfigKeys = await withRetry(() =>
|
const skipConfigKeys = await withRetry(() =>
|
||||||
this.client.keys(skipConfigPattern)
|
this._client.keys(skipConfigPattern)
|
||||||
);
|
);
|
||||||
if (skipConfigKeys.length > 0) {
|
if (skipConfigKeys.length > 0) {
|
||||||
await withRetry(() => this.client.del(...skipConfigKeys));
|
await withRetry(() => this._client.del(...skipConfigKeys));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -497,7 +518,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// 先检查用户是否已存在(原子性检查)
|
// 先检查用户是否已存在(原子性检查)
|
||||||
const exists = await withRetry(() =>
|
const exists = await withRetry(() =>
|
||||||
this.client.exists(this.userInfoKey(userName))
|
this._client.exists(this.userInfoKey(userName))
|
||||||
);
|
);
|
||||||
if (exists === 1) {
|
if (exists === 1) {
|
||||||
throw new Error('用户已存在');
|
throw new Error('用户已存在');
|
||||||
@@ -521,17 +542,17 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
if (oidcSub) {
|
if (oidcSub) {
|
||||||
userInfo.oidcSub = oidcSub;
|
userInfo.oidcSub = oidcSub;
|
||||||
// 创建OIDC映射
|
// 创建OIDC映射
|
||||||
await withRetry(() => this.client.set(this.oidcSubKey(oidcSub), userName));
|
await withRetry(() => this._client.set(this.oidcSubKey(oidcSub), userName));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enabledApis && enabledApis.length > 0) {
|
if (enabledApis && enabledApis.length > 0) {
|
||||||
userInfo.enabledApis = JSON.stringify(enabledApis);
|
userInfo.enabledApis = JSON.stringify(enabledApis);
|
||||||
}
|
}
|
||||||
|
|
||||||
await withRetry(() => this.client.hset(this.userInfoKey(userName), userInfo));
|
await withRetry(() => this._client.hset(this.userInfoKey(userName), userInfo));
|
||||||
|
|
||||||
// 添加到用户列表(Sorted Set,按注册时间排序)
|
// 添加到用户列表(Sorted Set,按注册时间排序)
|
||||||
await withRetry(() => this.client.zadd(this.userListKey(), {
|
await withRetry(() => this._client.zadd(this.userListKey(), {
|
||||||
score: createdAt,
|
score: createdAt,
|
||||||
member: userName,
|
member: userName,
|
||||||
}));
|
}));
|
||||||
@@ -546,7 +567,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
// 验证用户密码(新版本)
|
// 验证用户密码(新版本)
|
||||||
async verifyUserV2(userName: string, password: string): Promise<boolean> {
|
async verifyUserV2(userName: string, password: string): Promise<boolean> {
|
||||||
const userInfo = await withRetry(() =>
|
const userInfo = await withRetry(() =>
|
||||||
this.client.hgetall(this.userInfoKey(userName))
|
this._client.hgetall(this.userInfoKey(userName))
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!userInfo || !userInfo.password) {
|
if (!userInfo || !userInfo.password) {
|
||||||
@@ -568,6 +589,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
playrecord_migrated?: boolean;
|
playrecord_migrated?: boolean;
|
||||||
favorite_migrated?: boolean;
|
favorite_migrated?: boolean;
|
||||||
skip_migrated?: boolean;
|
skip_migrated?: boolean;
|
||||||
|
last_movie_request_time?: number;
|
||||||
} | null> {
|
} | null> {
|
||||||
// 先从缓存获取
|
// 先从缓存获取
|
||||||
const cached = userInfoCache?.get(userName);
|
const cached = userInfoCache?.get(userName);
|
||||||
@@ -576,7 +598,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const userInfo = await withRetry(() =>
|
const userInfo = await withRetry(() =>
|
||||||
this.client.hgetall(this.userInfoKey(userName))
|
this._client.hgetall(this.userInfoKey(userName))
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!userInfo || Object.keys(userInfo).length === 0) {
|
if (!userInfo || Object.keys(userInfo).length === 0) {
|
||||||
@@ -661,6 +683,11 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
playrecord_migrated,
|
playrecord_migrated,
|
||||||
favorite_migrated,
|
favorite_migrated,
|
||||||
skip_migrated,
|
skip_migrated,
|
||||||
|
last_movie_request_time: userInfo.last_movie_request_time
|
||||||
|
? (typeof userInfo.last_movie_request_time === 'number'
|
||||||
|
? userInfo.last_movie_request_time
|
||||||
|
: parseInt(userInfo.last_movie_request_time as string, 10))
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 存入缓存
|
// 存入缓存
|
||||||
@@ -696,7 +723,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
userInfo.tags = JSON.stringify(updates.tags);
|
userInfo.tags = JSON.stringify(updates.tags);
|
||||||
} else {
|
} else {
|
||||||
// 删除tags字段
|
// 删除tags字段
|
||||||
await withRetry(() => this.client.hdel(this.userInfoKey(userName), 'tags'));
|
await withRetry(() => this._client.hdel(this.userInfoKey(userName), 'tags'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -705,7 +732,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
userInfo.enabledApis = JSON.stringify(updates.enabledApis);
|
userInfo.enabledApis = JSON.stringify(updates.enabledApis);
|
||||||
} else {
|
} else {
|
||||||
// 删除enabledApis字段
|
// 删除enabledApis字段
|
||||||
await withRetry(() => this.client.hdel(this.userInfoKey(userName), 'enabledApis'));
|
await withRetry(() => this._client.hdel(this.userInfoKey(userName), 'enabledApis'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -713,15 +740,15 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
const oldInfo = await this.getUserInfoV2(userName);
|
const oldInfo = await this.getUserInfoV2(userName);
|
||||||
if (oldInfo?.oidcSub && oldInfo.oidcSub !== updates.oidcSub) {
|
if (oldInfo?.oidcSub && oldInfo.oidcSub !== updates.oidcSub) {
|
||||||
// 删除旧的OIDC映射
|
// 删除旧的OIDC映射
|
||||||
await withRetry(() => this.client.del(this.oidcSubKey(oldInfo.oidcSub!)));
|
await withRetry(() => this._client.del(this.oidcSubKey(oldInfo.oidcSub!)));
|
||||||
}
|
}
|
||||||
userInfo.oidcSub = updates.oidcSub;
|
userInfo.oidcSub = updates.oidcSub;
|
||||||
// 创建新的OIDC映射
|
// 创建新的OIDC映射
|
||||||
await withRetry(() => this.client.set(this.oidcSubKey(updates.oidcSub!), userName));
|
await withRetry(() => this._client.set(this.oidcSubKey(updates.oidcSub!), userName));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(userInfo).length > 0) {
|
if (Object.keys(userInfo).length > 0) {
|
||||||
await withRetry(() => this.client.hset(this.userInfoKey(userName), userInfo));
|
await withRetry(() => this._client.hset(this.userInfoKey(userName), userInfo));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清除缓存
|
// 清除缓存
|
||||||
@@ -732,7 +759,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
async changePasswordV2(userName: string, newPassword: string): Promise<void> {
|
async changePasswordV2(userName: string, newPassword: string): Promise<void> {
|
||||||
const hashedPassword = await this.hashPassword(newPassword);
|
const hashedPassword = await this.hashPassword(newPassword);
|
||||||
await withRetry(() =>
|
await withRetry(() =>
|
||||||
this.client.hset(this.userInfoKey(userName), { password: hashedPassword })
|
this._client.hset(this.userInfoKey(userName), { password: hashedPassword })
|
||||||
);
|
);
|
||||||
|
|
||||||
// 清除缓存
|
// 清除缓存
|
||||||
@@ -742,7 +769,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
// 检查用户是否存在(新版本)
|
// 检查用户是否存在(新版本)
|
||||||
async checkUserExistV2(userName: string): Promise<boolean> {
|
async checkUserExistV2(userName: string): Promise<boolean> {
|
||||||
const exists = await withRetry(() =>
|
const exists = await withRetry(() =>
|
||||||
this.client.exists(this.userInfoKey(userName))
|
this._client.exists(this.userInfoKey(userName))
|
||||||
);
|
);
|
||||||
return exists === 1;
|
return exists === 1;
|
||||||
}
|
}
|
||||||
@@ -750,7 +777,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
// 通过OIDC Sub查找用户名
|
// 通过OIDC Sub查找用户名
|
||||||
async getUserByOidcSub(oidcSub: string): Promise<string | null> {
|
async getUserByOidcSub(oidcSub: string): Promise<string | null> {
|
||||||
const userName = await withRetry(() =>
|
const userName = await withRetry(() =>
|
||||||
this.client.get(this.oidcSubKey(oidcSub))
|
this._client.get(this.oidcSubKey(oidcSub))
|
||||||
);
|
);
|
||||||
return userName ? ensureString(userName) : null;
|
return userName ? ensureString(userName) : null;
|
||||||
}
|
}
|
||||||
@@ -763,7 +790,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
let cursor: number | string = 0;
|
let cursor: number | string = 0;
|
||||||
do {
|
do {
|
||||||
const result = await withRetry(() =>
|
const result = await withRetry(() =>
|
||||||
this.client.scan(cursor as number, { match: 'user:*:info', count: 100 })
|
this._client.scan(cursor as number, { match: 'user:*:info', count: 100 })
|
||||||
);
|
);
|
||||||
|
|
||||||
cursor = result[0];
|
cursor = result[0];
|
||||||
@@ -771,7 +798,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
|
|
||||||
// 检查每个用户的 tags
|
// 检查每个用户的 tags
|
||||||
for (const key of keys) {
|
for (const key of keys) {
|
||||||
const userInfo = await withRetry(() => this.client.hgetall(key));
|
const userInfo = await withRetry(() => this._client.hgetall(key));
|
||||||
if (userInfo && userInfo.tags) {
|
if (userInfo && userInfo.tags) {
|
||||||
const tags = JSON.parse(userInfo.tags as string);
|
const tags = JSON.parse(userInfo.tags as string);
|
||||||
if (tags.includes(tagName)) {
|
if (tags.includes(tagName)) {
|
||||||
@@ -804,7 +831,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
total: number;
|
total: number;
|
||||||
}> {
|
}> {
|
||||||
// 获取总数
|
// 获取总数
|
||||||
let total = await withRetry(() => this.client.zcard(this.userListKey()));
|
let total = await withRetry(() => this._client.zcard(this.userListKey()));
|
||||||
|
|
||||||
// 检查站长是否在数据库中(使用缓存)
|
// 检查站长是否在数据库中(使用缓存)
|
||||||
let ownerInfo = null;
|
let ownerInfo = null;
|
||||||
@@ -851,7 +878,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
|
|
||||||
// 获取用户列表(按注册时间升序)
|
// 获取用户列表(按注册时间升序)
|
||||||
const usernames = await withRetry(() =>
|
const usernames = await withRetry(() =>
|
||||||
this.client.zrange(this.userListKey(), actualOffset, actualOffset + actualLimit - 1)
|
this._client.zrange(this.userListKey(), actualOffset, actualOffset + actualLimit - 1)
|
||||||
);
|
);
|
||||||
|
|
||||||
const users = [];
|
const users = [];
|
||||||
@@ -902,14 +929,14 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
|
|
||||||
// 删除OIDC映射
|
// 删除OIDC映射
|
||||||
if (userInfo?.oidcSub) {
|
if (userInfo?.oidcSub) {
|
||||||
await withRetry(() => this.client.del(this.oidcSubKey(userInfo.oidcSub!)));
|
await withRetry(() => this._client.del(this.oidcSubKey(userInfo.oidcSub!)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除用户信息Hash
|
// 删除用户信息Hash
|
||||||
await withRetry(() => this.client.del(this.userInfoKey(userName)));
|
await withRetry(() => this._client.del(this.userInfoKey(userName)));
|
||||||
|
|
||||||
// 从用户列表中移除
|
// 从用户列表中移除
|
||||||
await withRetry(() => this.client.zrem(this.userListKey(), userName));
|
await withRetry(() => this._client.zrem(this.userListKey(), userName));
|
||||||
|
|
||||||
// 删除用户的其他数据(播放记录、收藏等)
|
// 删除用户的其他数据(播放记录、收藏等)
|
||||||
await this.deleteUser(userName);
|
await this.deleteUser(userName);
|
||||||
@@ -925,7 +952,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
|
|
||||||
async getSearchHistory(userName: string): Promise<string[]> {
|
async getSearchHistory(userName: string): Promise<string[]> {
|
||||||
const result = await withRetry(() =>
|
const result = await withRetry(() =>
|
||||||
this.client.lrange(this.shKey(userName), 0, -1)
|
this._client.lrange(this.shKey(userName), 0, -1)
|
||||||
);
|
);
|
||||||
// 确保返回的都是字符串类型
|
// 确保返回的都是字符串类型
|
||||||
return ensureStringArray(result as any[]);
|
return ensureStringArray(result as any[]);
|
||||||
@@ -934,19 +961,19 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
async addSearchHistory(userName: string, keyword: string): Promise<void> {
|
async addSearchHistory(userName: string, keyword: string): Promise<void> {
|
||||||
const key = this.shKey(userName);
|
const key = this.shKey(userName);
|
||||||
// 先去重
|
// 先去重
|
||||||
await withRetry(() => this.client.lrem(key, 0, ensureString(keyword)));
|
await withRetry(() => this._client.lrem(key, 0, ensureString(keyword)));
|
||||||
// 插入到最前
|
// 插入到最前
|
||||||
await withRetry(() => this.client.lpush(key, ensureString(keyword)));
|
await withRetry(() => this._client.lpush(key, ensureString(keyword)));
|
||||||
// 限制最大长度
|
// 限制最大长度
|
||||||
await withRetry(() => this.client.ltrim(key, 0, SEARCH_HISTORY_LIMIT - 1));
|
await withRetry(() => this._client.ltrim(key, 0, SEARCH_HISTORY_LIMIT - 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteSearchHistory(userName: string, keyword?: string): Promise<void> {
|
async deleteSearchHistory(userName: string, keyword?: string): Promise<void> {
|
||||||
const key = this.shKey(userName);
|
const key = this.shKey(userName);
|
||||||
if (keyword) {
|
if (keyword) {
|
||||||
await withRetry(() => this.client.lrem(key, 0, ensureString(keyword)));
|
await withRetry(() => this._client.lrem(key, 0, ensureString(keyword)));
|
||||||
} else {
|
} else {
|
||||||
await withRetry(() => this.client.del(key));
|
await withRetry(() => this._client.del(key));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -955,7 +982,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
// 从新版用户列表获取
|
// 从新版用户列表获取
|
||||||
const userListKey = this.userListKey();
|
const userListKey = this.userListKey();
|
||||||
const users = await withRetry(() =>
|
const users = await withRetry(() =>
|
||||||
this.client.zrange(userListKey, 0, -1)
|
this._client.zrange(userListKey, 0, -1)
|
||||||
);
|
);
|
||||||
const userList = users.map(u => ensureString(u));
|
const userList = users.map(u => ensureString(u));
|
||||||
|
|
||||||
@@ -974,12 +1001,12 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getAdminConfig(): Promise<AdminConfig | null> {
|
async getAdminConfig(): Promise<AdminConfig | null> {
|
||||||
const val = await withRetry(() => this.client.get(this.adminConfigKey()));
|
const val = await withRetry(() => this._client.get(this.adminConfigKey()));
|
||||||
return val ? (val as AdminConfig) : null;
|
return val ? (val as AdminConfig) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setAdminConfig(config: AdminConfig): Promise<void> {
|
async setAdminConfig(config: AdminConfig): Promise<void> {
|
||||||
await withRetry(() => this.client.set(this.adminConfigKey(), config));
|
await withRetry(() => this._client.set(this.adminConfigKey(), config));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 跳过片头片尾配置 ----------
|
// ---------- 跳过片头片尾配置 ----------
|
||||||
@@ -998,7 +1025,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
): Promise<SkipConfig | null> {
|
): Promise<SkipConfig | null> {
|
||||||
const key = `${source}+${id}`;
|
const key = `${source}+${id}`;
|
||||||
const val = await withRetry(() =>
|
const val = await withRetry(() =>
|
||||||
this.client.hget(this.skipHashKey(userName), key)
|
this._client.hget(this.skipHashKey(userName), key)
|
||||||
);
|
);
|
||||||
return val ? (val as SkipConfig) : null;
|
return val ? (val as SkipConfig) : null;
|
||||||
}
|
}
|
||||||
@@ -1011,7 +1038,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const key = `${source}+${id}`;
|
const key = `${source}+${id}`;
|
||||||
await withRetry(() =>
|
await withRetry(() =>
|
||||||
this.client.hset(this.skipHashKey(userName), { [key]: config })
|
this._client.hset(this.skipHashKey(userName), { [key]: config })
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1022,7 +1049,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const key = `${source}+${id}`;
|
const key = `${source}+${id}`;
|
||||||
await withRetry(() =>
|
await withRetry(() =>
|
||||||
this.client.hdel(this.skipHashKey(userName), key)
|
this._client.hdel(this.skipHashKey(userName), key)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1030,7 +1057,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
userName: string
|
userName: string
|
||||||
): Promise<{ [key: string]: SkipConfig }> {
|
): Promise<{ [key: string]: SkipConfig }> {
|
||||||
const hashData = await withRetry(() =>
|
const hashData = await withRetry(() =>
|
||||||
this.client.hgetall<Record<string, SkipConfig>>(this.skipHashKey(userName))
|
this._client.hgetall<Record<string, SkipConfig>>(this.skipHashKey(userName))
|
||||||
);
|
);
|
||||||
|
|
||||||
return hashData || {};
|
return hashData || {};
|
||||||
@@ -1065,18 +1092,18 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const pattern = `u:${userName}:skip:*`;
|
const pattern = `u:${userName}:skip:*`;
|
||||||
const oldKeys: string[] = await withRetry(() => this.client.keys(pattern));
|
const oldKeys: string[] = await withRetry(() => this._client.keys(pattern));
|
||||||
|
|
||||||
if (oldKeys.length === 0) {
|
if (oldKeys.length === 0) {
|
||||||
console.log(`用户 ${userName} 没有旧的跳过配置,标记为已迁移`);
|
console.log(`用户 ${userName} 没有旧的跳过配置,标记为已迁移`);
|
||||||
await withRetry(() =>
|
await withRetry(() =>
|
||||||
this.client.hset(this.userInfoKey(userName), { skip_migrated: 'true' })
|
this._client.hset(this.userInfoKey(userName), { skip_migrated: 'true' })
|
||||||
);
|
);
|
||||||
userInfoCache?.delete(userName);
|
userInfoCache?.delete(userName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const values = await withRetry(() => this.client.mget(oldKeys));
|
const values = await withRetry(() => this._client.mget(oldKeys));
|
||||||
|
|
||||||
const hashData: Record<string, SkipConfig> = {};
|
const hashData: Record<string, SkipConfig> = {};
|
||||||
oldKeys.forEach((key, index) => {
|
oldKeys.forEach((key, index) => {
|
||||||
@@ -1092,16 +1119,16 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
|
|
||||||
if (Object.keys(hashData).length > 0) {
|
if (Object.keys(hashData).length > 0) {
|
||||||
await withRetry(() =>
|
await withRetry(() =>
|
||||||
this.client.hset(this.skipHashKey(userName), hashData)
|
this._client.hset(this.skipHashKey(userName), hashData)
|
||||||
);
|
);
|
||||||
console.log(`成功迁移 ${Object.keys(hashData).length} 条跳过配置到hash结构`);
|
console.log(`成功迁移 ${Object.keys(hashData).length} 条跳过配置到hash结构`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await withRetry(() => this.client.del(...oldKeys));
|
await withRetry(() => this._client.del(...oldKeys));
|
||||||
console.log(`删除了 ${oldKeys.length} 个旧的跳过配置key`);
|
console.log(`删除了 ${oldKeys.length} 个旧的跳过配置key`);
|
||||||
|
|
||||||
await withRetry(() =>
|
await withRetry(() =>
|
||||||
this.client.hset(this.userInfoKey(userName), { skip_migrated: 'true' })
|
this._client.hset(this.userInfoKey(userName), { skip_migrated: 'true' })
|
||||||
);
|
);
|
||||||
userInfoCache?.delete(userName);
|
userInfoCache?.delete(userName);
|
||||||
|
|
||||||
@@ -1113,7 +1140,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
userName: string
|
userName: string
|
||||||
): Promise<import('./types').DanmakuFilterConfig | null> {
|
): Promise<import('./types').DanmakuFilterConfig | null> {
|
||||||
const val = await withRetry(() =>
|
const val = await withRetry(() =>
|
||||||
this.client.get(this.danmakuFilterConfigKey(userName))
|
this._client.get(this.danmakuFilterConfigKey(userName))
|
||||||
);
|
);
|
||||||
return val ? (val as import('./types').DanmakuFilterConfig) : null;
|
return val ? (val as import('./types').DanmakuFilterConfig) : null;
|
||||||
}
|
}
|
||||||
@@ -1123,13 +1150,13 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
config: import('./types').DanmakuFilterConfig
|
config: import('./types').DanmakuFilterConfig
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await withRetry(() =>
|
await withRetry(() =>
|
||||||
this.client.set(this.danmakuFilterConfigKey(userName), config)
|
this._client.set(this.danmakuFilterConfigKey(userName), config)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteDanmakuFilterConfig(userName: string): Promise<void> {
|
async deleteDanmakuFilterConfig(userName: string): Promise<void> {
|
||||||
await withRetry(() =>
|
await withRetry(() =>
|
||||||
this.client.del(this.danmakuFilterConfigKey(userName))
|
this._client.del(this.danmakuFilterConfigKey(userName))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1145,7 +1172,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 删除管理员配置
|
// 删除管理员配置
|
||||||
await withRetry(() => this.client.del(this.adminConfigKey()));
|
await withRetry(() => this._client.del(this.adminConfigKey()));
|
||||||
|
|
||||||
console.log('所有数据已清空');
|
console.log('所有数据已清空');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1161,7 +1188,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
|
|
||||||
async getGlobalValue(key: string): Promise<string | null> {
|
async getGlobalValue(key: string): Promise<string | null> {
|
||||||
const val = await withRetry(() =>
|
const val = await withRetry(() =>
|
||||||
this.client.get(this.globalValueKey(key))
|
this._client.get(this.globalValueKey(key))
|
||||||
);
|
);
|
||||||
// Upstash 会自动反序列化 JSON,如果值是对象,需要重新序列化为字符串
|
// Upstash 会自动反序列化 JSON,如果值是对象,需要重新序列化为字符串
|
||||||
if (val === null) return null;
|
if (val === null) return null;
|
||||||
@@ -1172,12 +1199,12 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
|
|
||||||
async setGlobalValue(key: string, value: string): Promise<void> {
|
async setGlobalValue(key: string, value: string): Promise<void> {
|
||||||
await withRetry(() =>
|
await withRetry(() =>
|
||||||
this.client.set(this.globalValueKey(key), value)
|
this._client.set(this.globalValueKey(key), value)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteGlobalValue(key: string): Promise<void> {
|
async deleteGlobalValue(key: string): Promise<void> {
|
||||||
await withRetry(() => this.client.del(this.globalValueKey(key)));
|
await withRetry(() => this._client.del(this.globalValueKey(key)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- 通知相关 ----------
|
// ---------- 通知相关 ----------
|
||||||
@@ -1191,7 +1218,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
|
|
||||||
async getNotifications(userName: string): Promise<import('./types').Notification[]> {
|
async getNotifications(userName: string): Promise<import('./types').Notification[]> {
|
||||||
const val = await withRetry(() =>
|
const val = await withRetry(() =>
|
||||||
this.client.get(this.notificationsKey(userName))
|
this._client.get(this.notificationsKey(userName))
|
||||||
);
|
);
|
||||||
return val ? (val as import('./types').Notification[]) : [];
|
return val ? (val as import('./types').Notification[]) : [];
|
||||||
}
|
}
|
||||||
@@ -1207,7 +1234,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
notifications.splice(100);
|
notifications.splice(100);
|
||||||
}
|
}
|
||||||
await withRetry(() =>
|
await withRetry(() =>
|
||||||
this.client.set(this.notificationsKey(userName), notifications)
|
this._client.set(this.notificationsKey(userName), notifications)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1220,7 +1247,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
if (notification) {
|
if (notification) {
|
||||||
notification.read = true;
|
notification.read = true;
|
||||||
await withRetry(() =>
|
await withRetry(() =>
|
||||||
this.client.set(this.notificationsKey(userName), notifications)
|
this._client.set(this.notificationsKey(userName), notifications)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1232,12 +1259,12 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
const notifications = await this.getNotifications(userName);
|
const notifications = await this.getNotifications(userName);
|
||||||
const filtered = notifications.filter((n) => n.id !== notificationId);
|
const filtered = notifications.filter((n) => n.id !== notificationId);
|
||||||
await withRetry(() =>
|
await withRetry(() =>
|
||||||
this.client.set(this.notificationsKey(userName), filtered)
|
this._client.set(this.notificationsKey(userName), filtered)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearAllNotifications(userName: string): Promise<void> {
|
async clearAllNotifications(userName: string): Promise<void> {
|
||||||
await withRetry(() => this.client.del(this.notificationsKey(userName)));
|
await withRetry(() => this._client.del(this.notificationsKey(userName)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getUnreadNotificationCount(userName: string): Promise<number> {
|
async getUnreadNotificationCount(userName: string): Promise<number> {
|
||||||
@@ -1247,7 +1274,7 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
|
|
||||||
async getLastFavoriteCheckTime(userName: string): Promise<number> {
|
async getLastFavoriteCheckTime(userName: string): Promise<number> {
|
||||||
const val = await withRetry(() =>
|
const val = await withRetry(() =>
|
||||||
this.client.get(this.lastFavoriteCheckKey(userName))
|
this._client.get(this.lastFavoriteCheckKey(userName))
|
||||||
);
|
);
|
||||||
return val ? (val as number) : 0;
|
return val ? (val as number) : 0;
|
||||||
}
|
}
|
||||||
@@ -1257,9 +1284,57 @@ export class UpstashRedisStorage implements IStorage {
|
|||||||
timestamp: number
|
timestamp: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await withRetry(() =>
|
await withRetry(() =>
|
||||||
this.client.set(this.lastFavoriteCheckKey(userName), timestamp)
|
this._client.set(this.lastFavoriteCheckKey(userName), timestamp)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 求片相关 ----------
|
||||||
|
private movieRequestsKey() {
|
||||||
|
return 'movie_requests:all';
|
||||||
|
}
|
||||||
|
|
||||||
|
private userMovieRequestsKey(userName: string) {
|
||||||
|
return `u:${userName}:mr`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getAllMovieRequests(): Promise<import('./types').MovieRequest[]> {
|
||||||
|
const data = await withRetry(() => this._client.hgetall(this.movieRequestsKey()));
|
||||||
|
if (!data) return [];
|
||||||
|
return Object.values(data) as import('./types').MovieRequest[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMovieRequest(requestId: string): Promise<import('./types').MovieRequest | null> {
|
||||||
|
const val = await withRetry(() => this._client.hget(this.movieRequestsKey(), requestId));
|
||||||
|
return val ? (val as import('./types').MovieRequest) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createMovieRequest(request: import('./types').MovieRequest): Promise<void> {
|
||||||
|
await withRetry(() => this._client.hset(this.movieRequestsKey(), { [request.id]: request }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateMovieRequest(requestId: string, updates: Partial<import('./types').MovieRequest>): Promise<void> {
|
||||||
|
const existing = await this.getMovieRequest(requestId);
|
||||||
|
if (!existing) throw new Error('Movie request not found');
|
||||||
|
const updated = { ...existing, ...updates };
|
||||||
|
await withRetry(() => this._client.hset(this.movieRequestsKey(), { [requestId]: updated }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteMovieRequest(requestId: string): Promise<void> {
|
||||||
|
await withRetry(() => this._client.hdel(this.movieRequestsKey(), requestId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getUserMovieRequests(userName: string): Promise<string[]> {
|
||||||
|
const val = await withRetry(() => this._client.smembers(this.userMovieRequestsKey(userName)));
|
||||||
|
return val ? ensureStringArray(val) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async addUserMovieRequest(userName: string, requestId: string): Promise<void> {
|
||||||
|
await withRetry(() => this._client.sadd(this.userMovieRequestsKey(userName), requestId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeUserMovieRequest(userName: string, requestId: string): Promise<void> {
|
||||||
|
await withRetry(() => this._client.srem(this.userMovieRequestsKey(userName), requestId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 单例 Upstash Redis 客户端
|
// 单例 Upstash Redis 客户端
|
||||||
|
|||||||
+71
-1
@@ -1,6 +1,7 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||||
import he from 'he';
|
import he from 'he';
|
||||||
import Hls from 'hls.js';
|
import Hls from 'hls.js';
|
||||||
|
import bs58 from 'bs58';
|
||||||
|
|
||||||
function getDoubanImageProxyConfig(): {
|
function getDoubanImageProxyConfig(): {
|
||||||
proxyType:
|
proxyType:
|
||||||
@@ -135,12 +136,13 @@ export function processVideoUrl(originalUrl: string): string {
|
|||||||
/**
|
/**
|
||||||
* 从m3u8地址获取视频质量等级和网络信息
|
* 从m3u8地址获取视频质量等级和网络信息
|
||||||
* @param m3u8Url m3u8播放列表的URL
|
* @param m3u8Url m3u8播放列表的URL
|
||||||
* @returns Promise<{quality: string, loadSpeed: string, pingTime: number}> 视频质量等级和网络信息
|
* @returns Promise<{quality: string, loadSpeed: string, pingTime: number, bitrate: string}> 视频质量等级和网络信息
|
||||||
*/
|
*/
|
||||||
export async function getVideoResolutionFromM3u8(m3u8Url: string): Promise<{
|
export async function getVideoResolutionFromM3u8(m3u8Url: string): Promise<{
|
||||||
quality: string; // 如720p、1080p等
|
quality: string; // 如720p、1080p等
|
||||||
loadSpeed: string; // 自动转换为KB/s或MB/s
|
loadSpeed: string; // 自动转换为KB/s或MB/s
|
||||||
pingTime: number; // 网络延迟(毫秒)
|
pingTime: number; // 网络延迟(毫秒)
|
||||||
|
bitrate: string; // 视频码率(如 "2.5 Mbps")
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
// 直接使用m3u8 URL作为视频源,避免CORS问题
|
// 直接使用m3u8 URL作为视频源,避免CORS问题
|
||||||
@@ -182,6 +184,7 @@ export async function getVideoResolutionFromM3u8(m3u8Url: string): Promise<{
|
|||||||
let actualLoadSpeed = '未知';
|
let actualLoadSpeed = '未知';
|
||||||
let hasSpeedCalculated = false;
|
let hasSpeedCalculated = false;
|
||||||
let hasMetadataLoaded = false;
|
let hasMetadataLoaded = false;
|
||||||
|
let estimatedBitrate = 0; // 估算的码率(bps)
|
||||||
|
|
||||||
let fragmentStartTime = 0;
|
let fragmentStartTime = 0;
|
||||||
|
|
||||||
@@ -211,17 +214,32 @@ export async function getVideoResolutionFromM3u8(m3u8Url: string): Promise<{
|
|||||||
? '480p'
|
? '480p'
|
||||||
: 'SD'; // 480p: 854x480
|
: 'SD'; // 480p: 854x480
|
||||||
|
|
||||||
|
// 格式化码率
|
||||||
|
const bitrateStr = estimatedBitrate > 0
|
||||||
|
? estimatedBitrate >= 1000000
|
||||||
|
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
|
||||||
|
: `${Math.round(estimatedBitrate / 1000)} Kbps`
|
||||||
|
: '未知';
|
||||||
|
|
||||||
resolve({
|
resolve({
|
||||||
quality,
|
quality,
|
||||||
loadSpeed: actualLoadSpeed,
|
loadSpeed: actualLoadSpeed,
|
||||||
pingTime: Math.round(pingTime),
|
pingTime: Math.round(pingTime),
|
||||||
|
bitrate: bitrateStr,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// webkit 无法获取尺寸,直接返回
|
// webkit 无法获取尺寸,直接返回
|
||||||
|
const bitrateStr = estimatedBitrate > 0
|
||||||
|
? estimatedBitrate >= 1000000
|
||||||
|
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
|
||||||
|
: `${Math.round(estimatedBitrate / 1000)} Kbps`
|
||||||
|
: '未知';
|
||||||
|
|
||||||
resolve({
|
resolve({
|
||||||
quality: '未知',
|
quality: '未知',
|
||||||
loadSpeed: actualLoadSpeed,
|
loadSpeed: actualLoadSpeed,
|
||||||
pingTime: Math.round(pingTime),
|
pingTime: Math.round(pingTime),
|
||||||
|
bitrate: bitrateStr,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,6 +273,18 @@ export async function getVideoResolutionFromM3u8(m3u8Url: string): Promise<{
|
|||||||
actualLoadSpeed = `${avgSpeedKBps.toFixed(1)} KB/s`;
|
actualLoadSpeed = `${avgSpeedKBps.toFixed(1)} KB/s`;
|
||||||
}
|
}
|
||||||
hasSpeedCalculated = true;
|
hasSpeedCalculated = true;
|
||||||
|
|
||||||
|
// 从分片估算码率
|
||||||
|
if (data.frag && data.frag.duration > 0) {
|
||||||
|
const fragmentDuration = data.frag.duration; // 分片时长(秒)
|
||||||
|
const fragmentSize = size; // 分片大小(字节)
|
||||||
|
|
||||||
|
// 码率 = (分片大小 × 8 bits) / 分片时长
|
||||||
|
estimatedBitrate = Math.round((fragmentSize * 8) / fragmentDuration);
|
||||||
|
|
||||||
|
console.log(`[测速] 估算码率: ${(estimatedBitrate / 1000000).toFixed(2)} Mbps (分片: ${(fragmentSize / 1024 / 1024).toFixed(2)} MB, 时长: ${fragmentDuration.toFixed(1)}s)`);
|
||||||
|
}
|
||||||
|
|
||||||
checkAndResolve(); // 尝试返回结果
|
checkAndResolve(); // 尝试返回结果
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -301,3 +331,43 @@ export function cleanHtmlTags(text: string): string {
|
|||||||
// 使用 he 库解码 HTML 实体
|
// 使用 he 库解码 HTML 实体
|
||||||
return he.decode(cleanedText);
|
return he.decode(cleanedText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将字符串编码为 Base58
|
||||||
|
* @param str 要编码的字符串
|
||||||
|
* @returns Base58 编码后的字符串
|
||||||
|
*/
|
||||||
|
export function base58Encode(str: string): string {
|
||||||
|
if (!str) return '';
|
||||||
|
|
||||||
|
// 在浏览器环境中使用 TextEncoder
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const bytes = encoder.encode(str);
|
||||||
|
return bs58.encode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在 Node.js 环境中使用 Buffer
|
||||||
|
const buffer = Buffer.from(str, 'utf-8');
|
||||||
|
return bs58.encode(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 Base58 字符串解码为原始字符串
|
||||||
|
* @param encoded Base58 编码的字符串
|
||||||
|
* @returns 解码后的原始字符串
|
||||||
|
*/
|
||||||
|
export function base58Decode(encoded: string): string {
|
||||||
|
if (!encoded) return '';
|
||||||
|
|
||||||
|
const bytes = bs58.decode(encoded);
|
||||||
|
|
||||||
|
// 在浏览器环境中使用 TextDecoder
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
return decoder.decode(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在 Node.js 环境中使用 Buffer
|
||||||
|
return Buffer.from(bytes).toString('utf-8');
|
||||||
|
}
|
||||||
|
|||||||
+17
-7
@@ -28,11 +28,11 @@ export function parseVideoFileName(fileName: string): ParsedVideoInfo {
|
|||||||
|
|
||||||
// 降级方案:使用多种正则模式提取集数
|
// 降级方案:使用多种正则模式提取集数
|
||||||
// 按优先级排序:更具体的模式优先
|
// 按优先级排序:更具体的模式优先
|
||||||
const patterns: Array<{ pattern: RegExp; isOVA?: boolean }> = [
|
const patterns: Array<{ pattern: RegExp; isOVA?: boolean; extractSeason?: boolean }> = [
|
||||||
// OVA01, OVA 01, ova01, ova 01 (OVA特殊处理) - 最优先
|
// OVA01, OVA 01, ova01, ova 01 (OVA特殊处理) - 最优先
|
||||||
{ pattern: /OVA\s*(\d+(?:\.\d+)?)/i, isOVA: true },
|
{ pattern: /OVA\s*(\d+(?:\.\d+)?)/i, isOVA: true },
|
||||||
// S01E01, s01e01, S01E01.5 (支持小数) - 最具体
|
// S01E01, s01e01, S01E1234, S01E01.5 (支持1-4位数字和小数) - 最具体
|
||||||
{ pattern: /[Ss]\d+[Ee](\d+(?:\.\d+)?)/ },
|
{ pattern: /[Ss](\d+)[Ee](\d{1,4}(?:\.\d+)?)/, extractSeason: true },
|
||||||
// [01], (01), [01.5], (01.5) (支持小数,但要排除中文括号内容) - 很具体
|
// [01], (01), [01.5], (01.5) (支持小数,但要排除中文括号内容) - 很具体
|
||||||
{ pattern: /[\[\(](\d+(?:\.\d+)?)[\]\)]/ },
|
{ pattern: /[\[\(](\d+(?:\.\d+)?)[\]\)]/ },
|
||||||
// E01, E1, e01, e1, E01.5 (支持小数)
|
// E01, E1, e01, e1, E01.5 (支持小数)
|
||||||
@@ -45,12 +45,22 @@ export function parseVideoFileName(fileName: string): ParsedVideoInfo {
|
|||||||
{ pattern: /^(\d+(?:\.\d+)?)[^\d.]/ },
|
{ pattern: /^(\d+(?:\.\d+)?)[^\d.]/ },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const { pattern, isOVA } of patterns) {
|
for (const { pattern, isOVA, extractSeason } of patterns) {
|
||||||
const match = fileName.match(pattern);
|
const match = fileName.match(pattern);
|
||||||
if (match && match[1]) {
|
if (match && match[1]) {
|
||||||
const episode = parseFloat(match[1]);
|
if (extractSeason && match[2]) {
|
||||||
if (episode > 0 && episode < 10000) { // 合理的集数范围
|
// 同时提取 season 和 episode
|
||||||
return { episode, isOVA };
|
const season = parseInt(match[1]);
|
||||||
|
const episode = parseFloat(match[2]);
|
||||||
|
if (season > 0 && season < 100 && episode > 0 && episode < 10000) {
|
||||||
|
return { season, episode };
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 只提取 episode
|
||||||
|
const episode = parseFloat(match[1]);
|
||||||
|
if (episode > 0 && episode < 10000) {
|
||||||
|
return { episode, isOVA };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
import { XiaoyaClient } from './xiaoya.client';
|
||||||
|
import { parseNFO, NFOMetadata } from './nfo-parser';
|
||||||
|
import { parseVideoFileName } from './video-parser';
|
||||||
|
|
||||||
|
export interface XiaoyaMetadata {
|
||||||
|
tmdbId?: number;
|
||||||
|
title: string;
|
||||||
|
year?: string;
|
||||||
|
rating?: number;
|
||||||
|
genres?: string[];
|
||||||
|
plot?: string;
|
||||||
|
poster?: string;
|
||||||
|
background?: string;
|
||||||
|
mediaType: 'movie' | 'tv';
|
||||||
|
source: 'folder' | 'nfo' | 'tmdb' | 'file';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文件夹名提取 TMDb ID 和年份
|
||||||
|
* 格式: "标题 (年份) {tmdb-id}"
|
||||||
|
*/
|
||||||
|
function parseFolderName(folderName: string | undefined): {
|
||||||
|
title?: string;
|
||||||
|
year?: string;
|
||||||
|
tmdbId?: number;
|
||||||
|
} {
|
||||||
|
if (!folderName || typeof folderName !== 'string') {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
const match = folderName.match(/^(.+?)\s*\((\d{4})\)\s*\{tmdb-(\d+)\}$/);
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
title: match[1].trim(),
|
||||||
|
year: match[2],
|
||||||
|
tmdbId: parseInt(match[3]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查找 NFO 文件并解析
|
||||||
|
*/
|
||||||
|
async function findNFO(
|
||||||
|
xiaoyaClient: XiaoyaClient,
|
||||||
|
videoPath: string
|
||||||
|
): Promise<NFOMetadata | null> {
|
||||||
|
const pathParts = videoPath.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
// 判断是否为文件路径
|
||||||
|
const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm'];
|
||||||
|
const isFilePath = videoExtensions.some(ext => videoPath.toLowerCase().endsWith(ext));
|
||||||
|
|
||||||
|
let isInSeasonDir = false;
|
||||||
|
|
||||||
|
if (isFilePath) {
|
||||||
|
// 文件路径:判断父目录是否为季度目录
|
||||||
|
const parentDir = pathParts[pathParts.length - 2];
|
||||||
|
isInSeasonDir = /(season\s*\d+|s\d+)/i.test(parentDir);
|
||||||
|
} else {
|
||||||
|
// 目录路径:判断当前目录是否为季度目录
|
||||||
|
const currentDir = pathParts[pathParts.length - 1];
|
||||||
|
isInSeasonDir = /(season\s*\d+|s\d+)/i.test(currentDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nfoSearchPaths: string[] = [];
|
||||||
|
|
||||||
|
if (isInSeasonDir) {
|
||||||
|
// 电视剧:查父级的 tvshow.nfo
|
||||||
|
const grandParentDir = pathParts.slice(0, isFilePath ? -2 : -1).join('/');
|
||||||
|
nfoSearchPaths.push(`/${grandParentDir}/tvshow.nfo`);
|
||||||
|
} else {
|
||||||
|
// 电影:查同级的 movie.nfo
|
||||||
|
const parentDir = pathParts.slice(0, isFilePath ? -1 : pathParts.length).join('/');
|
||||||
|
nfoSearchPaths.push(`/${parentDir}/movie.nfo`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const nfoPath of nfoSearchPaths) {
|
||||||
|
try {
|
||||||
|
const content = await xiaoyaClient.getFileContent(nfoPath);
|
||||||
|
const metadata = await parseNFO(content);
|
||||||
|
if (metadata) {
|
||||||
|
return metadata;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取小雅视频的元数据
|
||||||
|
*/
|
||||||
|
export async function getXiaoyaMetadata(
|
||||||
|
xiaoyaClient: XiaoyaClient,
|
||||||
|
videoPath: string,
|
||||||
|
tmdbApiKey?: string,
|
||||||
|
tmdbProxy?: string
|
||||||
|
): Promise<XiaoyaMetadata> {
|
||||||
|
const pathParts = videoPath.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
// 验证路径格式
|
||||||
|
if (pathParts.length < 1) {
|
||||||
|
throw new Error(`无效的视频路径格式: ${videoPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 判断是否为文件路径(包含视频扩展名)
|
||||||
|
const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm'];
|
||||||
|
const isFilePath = videoExtensions.some(ext => videoPath.toLowerCase().endsWith(ext));
|
||||||
|
|
||||||
|
// 如果是文件路径,检查是否在季度目录中
|
||||||
|
const isInSeasonDir = isFilePath && pathParts.length >= 2 && /(season\s*\d+|s\d+)/i.test(pathParts[pathParts.length - 2]);
|
||||||
|
|
||||||
|
// 验证路径长度是否足够
|
||||||
|
if (isInSeasonDir && pathParts.length < 3) {
|
||||||
|
throw new Error(`Season目录路径格式不正确: ${videoPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确定元数据目录和文件夹名
|
||||||
|
let metadataDir: string;
|
||||||
|
let folderName: string;
|
||||||
|
|
||||||
|
if (isFilePath) {
|
||||||
|
// 文件路径
|
||||||
|
metadataDir = isInSeasonDir
|
||||||
|
? pathParts.slice(0, -2).join('/')
|
||||||
|
: pathParts.slice(0, -1).join('/');
|
||||||
|
folderName = pathParts[isInSeasonDir ? pathParts.length - 3 : pathParts.length - 2];
|
||||||
|
} else {
|
||||||
|
// 目录路径
|
||||||
|
if (pathParts.length === 1) {
|
||||||
|
// 只有一级目录
|
||||||
|
metadataDir = '';
|
||||||
|
folderName = pathParts[0];
|
||||||
|
} else {
|
||||||
|
// 判断当前目录是否为季度目录
|
||||||
|
const currentDirName = pathParts[pathParts.length - 1];
|
||||||
|
const isSeasonDir = /(season\s*\d+|s\d+)/i.test(currentDirName);
|
||||||
|
|
||||||
|
if (isSeasonDir && pathParts.length >= 2) {
|
||||||
|
// 季度目录:使用父级目录名
|
||||||
|
metadataDir = pathParts.slice(0, -2).join('/');
|
||||||
|
folderName = pathParts[pathParts.length - 2];
|
||||||
|
} else {
|
||||||
|
metadataDir = pathParts.slice(0, -1).join('/');
|
||||||
|
folderName = pathParts[pathParts.length - 1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证 folderName 是否有效
|
||||||
|
if (!folderName) {
|
||||||
|
throw new Error(`无法从路径中提取文件夹名: ${videoPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先级 1: 从文件夹名提取 TMDb ID
|
||||||
|
const folderInfo = parseFolderName(folderName);
|
||||||
|
if (folderInfo.tmdbId) {
|
||||||
|
const baseUrl = xiaoyaClient.getBaseURL();
|
||||||
|
const posterUrl = `${baseUrl}/d/${metadataDir}/poster.jpg`;
|
||||||
|
const backgroundUrl = `${baseUrl}/d/${metadataDir}/background.jpg`;
|
||||||
|
|
||||||
|
// 尝试读取 NFO 获取详细信息
|
||||||
|
const nfoData = await findNFO(xiaoyaClient, videoPath);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tmdbId: folderInfo.tmdbId,
|
||||||
|
title: nfoData?.title || folderInfo.title || folderName,
|
||||||
|
year: folderInfo.year,
|
||||||
|
rating: nfoData?.rating,
|
||||||
|
genres: nfoData?.genres,
|
||||||
|
plot: nfoData?.plot,
|
||||||
|
poster: posterUrl,
|
||||||
|
background: backgroundUrl,
|
||||||
|
mediaType: isInSeasonDir ? 'tv' : 'movie',
|
||||||
|
source: nfoData ? 'nfo' : (isFilePath ? 'file' : 'folder'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先级 2: 读取 NFO 文件
|
||||||
|
const nfoData = await findNFO(xiaoyaClient, videoPath);
|
||||||
|
if (nfoData && nfoData.tmdbId) {
|
||||||
|
const baseUrl = xiaoyaClient.getBaseURL();
|
||||||
|
const posterUrl = `${baseUrl}/d/${metadataDir}/poster.jpg`;
|
||||||
|
const backgroundUrl = `${baseUrl}/d/${metadataDir}/background.jpg`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
tmdbId: nfoData.tmdbId,
|
||||||
|
title: nfoData.title || folderName,
|
||||||
|
year: nfoData.year?.toString(),
|
||||||
|
rating: nfoData.rating,
|
||||||
|
genres: nfoData.genres,
|
||||||
|
plot: nfoData.plot,
|
||||||
|
poster: posterUrl,
|
||||||
|
background: backgroundUrl,
|
||||||
|
mediaType: nfoData.mediaType,
|
||||||
|
source: 'nfo',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先级 3: 实时搜索 TMDb(使用文件名)
|
||||||
|
if (tmdbApiKey) {
|
||||||
|
const fileName = pathParts[pathParts.length - 1];
|
||||||
|
const searchQuery = fileName
|
||||||
|
.replace(/\.(mp4|mkv|avi|m3u8|flv|ts)$/i, '')
|
||||||
|
.replace(/[\[\]()]/g, ' ')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
// 如果文件名是纯数字(可能带小数点)或者是 SxxExx 格式,跳过文件名搜索,直接使用文件夹名
|
||||||
|
const isPureNumber = /^[\d.]+$/.test(searchQuery);
|
||||||
|
const isSeasonEpisode = /^S\d+E\d+/i.test(searchQuery);
|
||||||
|
|
||||||
|
if (!isPureNumber && !isSeasonEpisode) {
|
||||||
|
const { searchTMDB, getTMDBImageUrl } = await import('./tmdb.search');
|
||||||
|
const tmdbResult = await searchTMDB(tmdbApiKey, searchQuery, tmdbProxy);
|
||||||
|
|
||||||
|
if (tmdbResult.code === 200 && tmdbResult.result) {
|
||||||
|
return {
|
||||||
|
tmdbId: tmdbResult.result.id,
|
||||||
|
title: tmdbResult.result.title || tmdbResult.result.name || folderName,
|
||||||
|
year: tmdbResult.result.release_date?.substring(0, 4) ||
|
||||||
|
tmdbResult.result.first_air_date?.substring(0, 4),
|
||||||
|
rating: tmdbResult.result.vote_average,
|
||||||
|
plot: tmdbResult.result.overview,
|
||||||
|
poster: tmdbResult.result.poster_path
|
||||||
|
? getTMDBImageUrl(tmdbResult.result.poster_path)
|
||||||
|
: undefined,
|
||||||
|
mediaType: tmdbResult.result.media_type,
|
||||||
|
source: isFilePath ? 'file' : 'tmdb',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先级 4: 实时搜索 TMDb(使用文件夹名)
|
||||||
|
if (tmdbApiKey) {
|
||||||
|
const searchQuery = folderName
|
||||||
|
.replace(/[\[\](){}]/g, ' ')
|
||||||
|
.replace(/\d{4}/g, '')
|
||||||
|
.trim();
|
||||||
|
|
||||||
|
const { searchTMDB, getTMDBImageUrl } = await import('./tmdb.search');
|
||||||
|
const tmdbResult = await searchTMDB(tmdbApiKey, searchQuery, tmdbProxy);
|
||||||
|
|
||||||
|
if (tmdbResult.code === 200 && tmdbResult.result) {
|
||||||
|
return {
|
||||||
|
tmdbId: tmdbResult.result.id,
|
||||||
|
title: tmdbResult.result.title || tmdbResult.result.name || folderName,
|
||||||
|
year: tmdbResult.result.release_date?.substring(0, 4) ||
|
||||||
|
tmdbResult.result.first_air_date?.substring(0, 4),
|
||||||
|
rating: tmdbResult.result.vote_average,
|
||||||
|
plot: tmdbResult.result.overview,
|
||||||
|
poster: tmdbResult.result.poster_path
|
||||||
|
? getTMDBImageUrl(tmdbResult.result.poster_path)
|
||||||
|
: undefined,
|
||||||
|
mediaType: tmdbResult.result.media_type,
|
||||||
|
source: 'tmdb',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 降级:只返回文件夹名
|
||||||
|
return {
|
||||||
|
title: folderName,
|
||||||
|
mediaType: isInSeasonDir ? 'tv' : 'movie',
|
||||||
|
source: 'folder',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取视频集数列表
|
||||||
|
*/
|
||||||
|
export async function getXiaoyaEpisodes(
|
||||||
|
xiaoyaClient: XiaoyaClient,
|
||||||
|
videoPath: string
|
||||||
|
): Promise<Array<{ path: string; title: string }>> {
|
||||||
|
const pathParts = videoPath.split('/').filter(Boolean);
|
||||||
|
|
||||||
|
// 判断是否为文件路径(包含视频扩展名)
|
||||||
|
const videoExtensions = ['.mp4', '.mkv', '.avi', '.m3u8', '.flv', '.ts', '.mov', '.wmv', '.webm'];
|
||||||
|
const isFilePath = videoExtensions.some(ext => videoPath.toLowerCase().endsWith(ext));
|
||||||
|
|
||||||
|
// 如果是文件路径,检查是否在季度目录中
|
||||||
|
const isInSeasonDir = isFilePath && /(season\s*\d+|s\d+)/i.test(pathParts[pathParts.length - 2]);
|
||||||
|
|
||||||
|
if (isInSeasonDir) {
|
||||||
|
// 电视剧:列出当前季的所有集
|
||||||
|
const seasonDir = pathParts.slice(0, -1).join('/');
|
||||||
|
const listResponse = await xiaoyaClient.listDirectory(`/${seasonDir}`);
|
||||||
|
|
||||||
|
const videoFiles = listResponse.content
|
||||||
|
.filter(item =>
|
||||||
|
!item.is_dir &&
|
||||||
|
videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext))
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
return videoFiles.map(file => {
|
||||||
|
const parsed = parseVideoFileName(file.name);
|
||||||
|
console.log('[xiaoya-metadata] 解析文件名:', file.name, '结果:', parsed);
|
||||||
|
let title = file.name;
|
||||||
|
|
||||||
|
if (parsed.season && parsed.episode) {
|
||||||
|
title = `S${parsed.season.toString().padStart(2, '0')}E${parsed.episode.toString().padStart(2, '0')}`;
|
||||||
|
} else if (parsed.episode) {
|
||||||
|
title = parsed.isOVA ? `OVA ${parsed.episode}` : `第${parsed.episode}集`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
path: `/${seasonDir}/${file.name}`,
|
||||||
|
title,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 目录路径或电影文件路径:列出该目录下的所有视频
|
||||||
|
const targetDir = isFilePath ? pathParts.slice(0, -1).join('/') : pathParts.join('/');
|
||||||
|
const listResponse = await xiaoyaClient.listDirectory(`/${targetDir}`);
|
||||||
|
|
||||||
|
const videoFiles = listResponse.content
|
||||||
|
.filter(item =>
|
||||||
|
!item.is_dir &&
|
||||||
|
videoExtensions.some(ext => item.name.toLowerCase().endsWith(ext))
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
|
||||||
|
return videoFiles.map(file => ({
|
||||||
|
path: `/${targetDir}/${file.name}`,
|
||||||
|
title: file.name,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
|
||||||
|
// Token 内存缓存
|
||||||
|
const tokenCache = new Map<string, { token: string; expiresAt: number }>();
|
||||||
|
|
||||||
|
export interface XiaoyaFile {
|
||||||
|
name: string;
|
||||||
|
size: number;
|
||||||
|
is_dir: boolean;
|
||||||
|
modified: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface XiaoyaListResponse {
|
||||||
|
content: XiaoyaFile[];
|
||||||
|
total: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class XiaoyaClient {
|
||||||
|
private token: string = '';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private baseURL: string,
|
||||||
|
private username?: string,
|
||||||
|
private password?: string,
|
||||||
|
private configToken?: string
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用账号密码登录获取Token
|
||||||
|
*/
|
||||||
|
static async login(
|
||||||
|
baseURL: string,
|
||||||
|
username: string,
|
||||||
|
password: string
|
||||||
|
): Promise<string> {
|
||||||
|
const response = await fetch(`${baseURL}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`小雅登录失败: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.code !== 200 || !data.data?.token) {
|
||||||
|
throw new Error('小雅登录失败: 未获取到Token');
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存的 Token 或重新登录
|
||||||
|
*/
|
||||||
|
async getToken(): Promise<string> {
|
||||||
|
// 如果配置了 Token,直接使用
|
||||||
|
if (this.configToken) {
|
||||||
|
return this.configToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有配置用户名密码,返回空字符串(guest 模式)
|
||||||
|
if (!this.username || !this.password) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = `${this.baseURL}:${this.username}`;
|
||||||
|
const cached = tokenCache.get(cacheKey);
|
||||||
|
|
||||||
|
// 如果有缓存且未过期,直接返回
|
||||||
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
|
this.token = cached.token;
|
||||||
|
return this.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 否则重新登录
|
||||||
|
console.log('[XiaoyaClient] Token 不存在或已过期,重新登录');
|
||||||
|
this.token = await XiaoyaClient.login(
|
||||||
|
this.baseURL,
|
||||||
|
this.username,
|
||||||
|
this.password
|
||||||
|
);
|
||||||
|
|
||||||
|
// 缓存 Token,设置 1 小时过期
|
||||||
|
tokenCache.set(cacheKey, {
|
||||||
|
token: this.token,
|
||||||
|
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取基础 URL
|
||||||
|
*/
|
||||||
|
getBaseURL(): string {
|
||||||
|
return this.baseURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出目录内容
|
||||||
|
*/
|
||||||
|
async listDirectory(path: string, page = 1, perPage = 100): Promise<XiaoyaListResponse> {
|
||||||
|
const token = await this.getToken();
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseURL}/api/fs/list`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': token,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
path,
|
||||||
|
page,
|
||||||
|
per_page: perPage,
|
||||||
|
refresh: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`小雅列表获取失败: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.code !== 200) {
|
||||||
|
throw new Error(`小雅列表获取失败: ${data.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: data.data.content || [],
|
||||||
|
total: data.data.total || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索文件
|
||||||
|
*/
|
||||||
|
async search(keyword: string, page = 1, perPage = 100): Promise<XiaoyaListResponse> {
|
||||||
|
const token = await this.getToken();
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseURL}/api/fs/search`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': token,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
parent: '/',
|
||||||
|
keywords: keyword,
|
||||||
|
scope: 1, // 递归搜索
|
||||||
|
page,
|
||||||
|
per_page: perPage,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`小雅搜索失败: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.code !== 200) {
|
||||||
|
throw new Error(`小雅搜索失败: ${data.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: data.data.content || [],
|
||||||
|
total: data.data.total || 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件信息
|
||||||
|
*/
|
||||||
|
async getFileInfo(path: string): Promise<XiaoyaFile> {
|
||||||
|
const token = await this.getToken();
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseURL}/api/fs/get`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': token,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
path,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`小雅文件信息获取失败: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
if (data.code !== 200) {
|
||||||
|
throw new Error(`小雅文件信息获取失败: ${data.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件下载链接
|
||||||
|
*/
|
||||||
|
async getDownloadUrl(path: string): Promise<string> {
|
||||||
|
// Alist 的直接下载链接格式
|
||||||
|
return `${this.baseURL}/d${path}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文件内容(用于读取 NFO 等文本文件)
|
||||||
|
*/
|
||||||
|
async getFileContent(path: string): Promise<string> {
|
||||||
|
const downloadUrl = await this.getDownloadUrl(path);
|
||||||
|
|
||||||
|
const response = await fetch(downloadUrl, {
|
||||||
|
headers: {
|
||||||
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`文件读取失败: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await response.text();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查文件是否存在
|
||||||
|
*/
|
||||||
|
async fileExists(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await this.getFileInfo(path);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -133,6 +133,6 @@ function shouldSkipAuth(pathname: string): boolean {
|
|||||||
// 配置middleware匹配规则
|
// 配置middleware匹配规则
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
'/((?!_next/static|_next/image|favicon.ico|login|register|oidc-register|warning|api/login|api/register|api/logout|api/auth/oidc|api/cron/|api/server-config|api/proxy-m3u8|api/cms-proxy|api/tvbox/subscribe|api/theme/css|api/openlist/cms-proxy|api/openlist/play|api/emby/cms-proxy|api/emby/play).*)',
|
'/((?!_next/static|_next/image|favicon.ico|login|register|oidc-register|warning|api/login|api/register|api/logout|api/auth/oidc|api/cron/|api/server-config|api/proxy-m3u8|api/cms-proxy|api/tvbox/subscribe|api/theme/css|api/openlist/cms-proxy|api/openlist/play|api/emby/cms-proxy|api/emby/play|api/emby/sources).*)',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
declare module 'opencc-js' {
|
||||||
|
interface ConverterOptions {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Converter(options: ConverterOptions): (text: string) => string;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user