Merge branch 'dev'
This commit is contained in:
@@ -44,11 +44,13 @@
|
||||
"lucide-react": "^0.438.0",
|
||||
"media-icons": "^1.1.5",
|
||||
"mux.js": "^6.3.0",
|
||||
"nanoid": "^5.1.6",
|
||||
"next": "^14.2.33",
|
||||
"next-pwa": "^5.6.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"node-fetch": "^2.7.0",
|
||||
"nprogress": "^0.2.0",
|
||||
"opencc-js": "^1.0.5",
|
||||
"parse-torrent-name": "^0.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
|
||||
18
pnpm-lock.yaml
generated
18
pnpm-lock.yaml
generated
@@ -80,6 +80,9 @@ importers:
|
||||
mux.js:
|
||||
specifier: ^6.3.0
|
||||
version: 6.3.0
|
||||
nanoid:
|
||||
specifier: ^5.1.6
|
||||
version: 5.1.6
|
||||
next:
|
||||
specifier: ^14.2.33
|
||||
version: 14.2.35(@babel/core@7.28.5)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
@@ -95,6 +98,9 @@ importers:
|
||||
nprogress:
|
||||
specifier: ^0.2.0
|
||||
version: 0.2.0
|
||||
opencc-js:
|
||||
specifier: ^1.0.5
|
||||
version: 1.0.5
|
||||
parse-torrent-name:
|
||||
specifier: ^0.5.4
|
||||
version: 0.5.4
|
||||
@@ -4420,6 +4426,11 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nanoid@5.1.6:
|
||||
resolution: {integrity: sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg==}
|
||||
engines: {node: ^18 || >=20}
|
||||
hasBin: true
|
||||
|
||||
napi-postinstall@0.3.4:
|
||||
resolution: {integrity: sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==}
|
||||
engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0}
|
||||
@@ -4562,6 +4573,9 @@ packages:
|
||||
resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
opencc-js@1.0.5:
|
||||
resolution: {integrity: sha512-LD+1SoNnZdlRwtYTjnQdFrSVCAaYpuDqL5CkmOaHOkKoKh7mFxUicLTRVNLU5C+Jmi1vXQ3QL4jWdgSaa4sKjg==}
|
||||
|
||||
option-validator@2.0.6:
|
||||
resolution: {integrity: sha512-tmZDan2LRIRQyhUGvkff68/O0R8UmF+Btmiiz0SmSw2ng3CfPZB9wJlIjHpe/MKUZqyIZkVIXCrwr1tIN+0Dzg==}
|
||||
|
||||
@@ -11386,6 +11400,8 @@ snapshots:
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
nanoid@5.1.6: {}
|
||||
|
||||
napi-postinstall@0.3.4: {}
|
||||
|
||||
natural-compare-lite@1.4.0: {}
|
||||
@@ -11547,6 +11563,8 @@ snapshots:
|
||||
dependencies:
|
||||
mimic-fn: 2.1.0
|
||||
|
||||
opencc-js@1.0.5: {}
|
||||
|
||||
option-validator@2.0.6:
|
||||
dependencies:
|
||||
kind-of: 6.0.3
|
||||
|
||||
@@ -390,6 +390,7 @@ interface CollapsibleTabProps {
|
||||
isExpanded: boolean;
|
||||
onToggle: () => void;
|
||||
children: React.ReactNode;
|
||||
isParent?: boolean;
|
||||
}
|
||||
|
||||
const CollapsibleTab = ({
|
||||
@@ -398,25 +399,38 @@ const CollapsibleTab = ({
|
||||
isExpanded,
|
||||
onToggle,
|
||||
children,
|
||||
isParent = false,
|
||||
}: CollapsibleTabProps) => {
|
||||
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
|
||||
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'>
|
||||
{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}
|
||||
</h3>
|
||||
</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} />}
|
||||
</div>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
@@ -2763,7 +2777,7 @@ const OpenListConfigComponent = ({
|
||||
const [url, setUrl] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [rootPath, setRootPath] = useState('/');
|
||||
const [rootPaths, setRootPaths] = useState<string[]>(['/']);
|
||||
const [offlineDownloadPath, setOfflineDownloadPath] = useState('/');
|
||||
const [scanInterval, setScanInterval] = useState(0);
|
||||
const [scanMode, setScanMode] = useState<'torrent' | 'name' | 'hybrid'>('hybrid');
|
||||
@@ -2783,7 +2797,7 @@ const OpenListConfigComponent = ({
|
||||
setUrl(config.OpenListConfig.URL || '');
|
||||
setUsername(config.OpenListConfig.Username || '');
|
||||
setPassword(config.OpenListConfig.Password || '');
|
||||
setRootPath(config.OpenListConfig.RootPath || '/');
|
||||
setRootPaths(config.OpenListConfig.RootPaths || (config.OpenListConfig.RootPath ? [config.OpenListConfig.RootPath] : ['/']));
|
||||
setOfflineDownloadPath(config.OpenListConfig.OfflineDownloadPath || '/');
|
||||
setScanInterval(config.OpenListConfig.ScanInterval || 0);
|
||||
setScanMode(config.OpenListConfig.ScanMode || 'hybrid');
|
||||
@@ -2824,7 +2838,7 @@ const OpenListConfigComponent = ({
|
||||
URL: url,
|
||||
Username: username,
|
||||
Password: password,
|
||||
RootPath: rootPath,
|
||||
RootPaths: rootPaths,
|
||||
OfflineDownloadPath: offlineDownloadPath,
|
||||
ScanInterval: scanInterval,
|
||||
ScanMode: scanMode,
|
||||
@@ -3104,18 +3118,49 @@ const OpenListConfigComponent = ({
|
||||
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
根目录
|
||||
根目录列表
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={rootPath}
|
||||
onChange={(e) => setRootPath(e.target.value)}
|
||||
disabled={!enabled}
|
||||
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 focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
/>
|
||||
<div className='space-y-2'>
|
||||
{rootPaths.map((path, index) => (
|
||||
<div key={index} className='flex gap-2'>
|
||||
<input
|
||||
type='text'
|
||||
value={path}
|
||||
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'>
|
||||
OpenList 中的视频文件夹路径,默认为根目录 /
|
||||
OpenList 中的视频文件夹路径,可以配置多个根目录
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -3414,6 +3459,7 @@ const EmbyConfigComponent = ({
|
||||
const [sources, setSources] = useState<any[]>([]);
|
||||
const [editingSource, setEditingSource] = useState<any | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [selectedSources, setSelectedSources] = useState<Set<string>>(new Set());
|
||||
|
||||
// 表单状态
|
||||
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 (
|
||||
<div className='space-y-6'>
|
||||
<AlertModal
|
||||
@@ -3682,14 +3803,51 @@ const EmbyConfigComponent = ({
|
||||
<h3 className='text-lg font-medium text-gray-900 dark:text-gray-100'>
|
||||
Emby 源列表 ({sources.length})
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className={buttonStyles.success}
|
||||
>
|
||||
添加新源
|
||||
</button>
|
||||
<div className='flex gap-2'>
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
className={buttonStyles.success}
|
||||
>
|
||||
添加新源
|
||||
</button>
|
||||
</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 ? (
|
||||
<div className='text-center py-8 text-gray-500 dark:text-gray-400'>
|
||||
暂无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'
|
||||
>
|
||||
<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-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'>
|
||||
默认
|
||||
<div className='flex items-center gap-3 flex-1'>
|
||||
<input
|
||||
type='checkbox'
|
||||
checked={selectedSources.has(source.key)}
|
||||
onChange={(e) => {
|
||||
const newSelected = new Set(selectedSources);
|
||||
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
|
||||
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 && (
|
||||
</div>
|
||||
<p className='mt-1 text-sm text-gray-600 dark:text-gray-400'>
|
||||
用户ID: {source.UserId}
|
||||
标识符: {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'>
|
||||
用户ID: {source.UserId}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-2 flex-wrap sm:flex-nowrap'>
|
||||
<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配置组件
|
||||
const AIConfigComponent = ({
|
||||
config,
|
||||
@@ -9504,8 +10155,10 @@ function AdminPageClient() {
|
||||
const [expandedTabs, setExpandedTabs] = useState<{ [key: string]: boolean }>({
|
||||
userConfig: false,
|
||||
videoSource: false,
|
||||
mediaLibrary: false,
|
||||
openListConfig: false,
|
||||
embyConfig: false,
|
||||
xiaoyaConfig: false,
|
||||
aiConfig: false,
|
||||
liveSource: false,
|
||||
siteConfig: false,
|
||||
@@ -9807,28 +10460,65 @@ function AdminPageClient() {
|
||||
<LiveSourceConfig config={config} refreshConfig={fetchConfig} />
|
||||
</CollapsibleTab>
|
||||
|
||||
{/* 私人影库配置标签 */}
|
||||
{/* 私人影库大类 */}
|
||||
<CollapsibleTab
|
||||
title='私人影库'
|
||||
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}
|
||||
onToggle={() => toggleTab('openListConfig')}
|
||||
isExpanded={expandedTabs.mediaLibrary}
|
||||
onToggle={() => toggleTab('mediaLibrary')}
|
||||
isParent={true}
|
||||
>
|
||||
<OpenListConfigComponent config={config} refreshConfig={fetchConfig} />
|
||||
</CollapsibleTab>
|
||||
<div className='space-y-4'>
|
||||
{/* 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 媒体库标签 */}
|
||||
<CollapsibleTab
|
||||
title='Emby 媒体库'
|
||||
icon={
|
||||
<FolderOpen size={20} className='text-gray-600 dark:text-gray-400' />
|
||||
}
|
||||
isExpanded={expandedTabs.embyConfig}
|
||||
onToggle={() => toggleTab('embyConfig')}
|
||||
>
|
||||
<EmbyConfigComponent config={config} refreshConfig={fetchConfig} />
|
||||
{/* Emby 媒体库子标签 */}
|
||||
<CollapsibleTab
|
||||
title='Emby 媒体库'
|
||||
icon={
|
||||
<FolderOpen size={20} className='text-gray-600 dark:text-gray-400' />
|
||||
}
|
||||
isExpanded={expandedTabs.embyConfig}
|
||||
onToggle={() => toggleTab('embyConfig')}
|
||||
>
|
||||
<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>
|
||||
|
||||
{/* AI配置标签 */}
|
||||
|
||||
@@ -155,8 +155,8 @@ async function getUserPasswordV2(username: string): Promise<string | null> {
|
||||
// 直接调用hGetAll获取完整用户信息(包括密码)
|
||||
const userInfoKey = `user:${username}:info`;
|
||||
|
||||
if (typeof storage.withRetry === 'function' && storage.client?.hGetAll) {
|
||||
const userInfo = await storage.withRetry(() => storage.client.hGetAll(userInfoKey));
|
||||
if (typeof storage.withRetry === 'function' && storage.client?.hgetall) {
|
||||
const userInfo = await storage.withRetry(() => storage.client.hgetall(userInfoKey));
|
||||
if (userInfo && userInfo.password) {
|
||||
return userInfo.password;
|
||||
}
|
||||
|
||||
@@ -149,10 +149,10 @@ export async function POST(req: NextRequest) {
|
||||
}
|
||||
|
||||
// 使用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,
|
||||
value: username,
|
||||
}));
|
||||
|
||||
@@ -26,7 +26,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
try {
|
||||
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);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
@@ -53,7 +53,7 @@ export async function POST(request: NextRequest) {
|
||||
URL: URL || '',
|
||||
Username: Username || '',
|
||||
Password: Password || '',
|
||||
RootPath: RootPath || '/',
|
||||
RootPaths: RootPaths || ['/'],
|
||||
OfflineDownloadPath: OfflineDownloadPath || '/',
|
||||
LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime,
|
||||
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;
|
||||
if (scanInterval > 0 && scanInterval < 60) {
|
||||
@@ -104,7 +112,7 @@ export async function POST(request: NextRequest) {
|
||||
URL,
|
||||
Username,
|
||||
Password,
|
||||
RootPath: RootPath || '/',
|
||||
RootPaths,
|
||||
OfflineDownloadPath: OfflineDownloadPath || '/',
|
||||
LastRefreshTime: adminConfig.OpenListConfig?.LastRefreshTime,
|
||||
ResourceCount: adminConfig.OpenListConfig?.ResourceCount,
|
||||
|
||||
@@ -65,16 +65,16 @@ export async function POST(request: NextRequest) {
|
||||
if (!key || !name || !api) {
|
||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||
}
|
||||
// 禁止添加 openlist 和 emby 保留关键字
|
||||
if (key === 'openlist') {
|
||||
// 禁止添加保留关键字
|
||||
if (key === 'openlist' || key === 'xiaoya') {
|
||||
return NextResponse.json(
|
||||
{ error: 'openlist 是保留关键字,不能作为视频源 key' },
|
||||
{ error: `${key} 是保留关键字,不能作为视频源 key` },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
if (key === 'emby') {
|
||||
if (key.startsWith('emby')) {
|
||||
return NextResponse.json(
|
||||
{ error: 'emby 是保留关键字,不能作为视频源 key' },
|
||||
{ error: 'emby 开头的 key 是保留关键字,不能作为视频源 key' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
72
src/app/api/admin/xiaoya/route.ts
Normal file
72
src/app/api/admin/xiaoya/route.ts
Normal file
@@ -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 (从数据库或缓存)
|
||||
let metaInfo: MetaInfo | null = getCachedMetaInfo(rootPath);
|
||||
let metaInfo: MetaInfo | null = getCachedMetaInfo();
|
||||
|
||||
if (!metaInfo) {
|
||||
try {
|
||||
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||||
if (metainfoJson) {
|
||||
metaInfo = JSON.parse(metainfoJson) as MetaInfo;
|
||||
setCachedMetaInfo(rootPath, metaInfo);
|
||||
setCachedMetaInfo(metaInfo);
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -45,13 +45,13 @@ export async function GET(request: NextRequest) {
|
||||
const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache');
|
||||
const { db } = await import('@/lib/db');
|
||||
|
||||
metaInfo = getCachedMetaInfo(rootPath);
|
||||
metaInfo = getCachedMetaInfo();
|
||||
|
||||
if (!metaInfo) {
|
||||
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||||
if (metainfoJson) {
|
||||
metaInfo = JSON.parse(metainfoJson);
|
||||
setCachedMetaInfo(rootPath, metaInfo);
|
||||
setCachedMetaInfo(metaInfo);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
50
src/app/api/douban/search/route.ts
Normal file
50
src/app/api/douban/search/route.ts
Normal file
@@ -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';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic'; // 禁用缓存
|
||||
|
||||
/**
|
||||
* 获取所有启用的Emby源列表
|
||||
|
||||
161
src/app/api/movie-requests/[id]/route.ts
Normal file
161
src/app/api/movie-requests/[id]/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
218
src/app/api/movie-requests/route.ts
Normal file
218
src/app/api/movie-requests/route.ts
Normal file
@@ -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 { db } = await import('@/lib/db');
|
||||
|
||||
let metaInfo = getCachedMetaInfo(rootPath);
|
||||
let metaInfo = getCachedMetaInfo();
|
||||
|
||||
if (!metaInfo) {
|
||||
try {
|
||||
@@ -84,7 +84,7 @@ export async function GET(
|
||||
if (metainfoJson) {
|
||||
metaInfo = JSON.parse(metainfoJson);
|
||||
if (metaInfo) {
|
||||
setCachedMetaInfo(rootPath, metaInfo);
|
||||
setCachedMetaInfo(metaInfo);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -65,7 +65,6 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const rootPath = openListConfig.RootPath || '/';
|
||||
const client = new OpenListClient(
|
||||
openListConfig.URL,
|
||||
openListConfig.Username,
|
||||
@@ -73,7 +72,7 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
|
||||
// 读取现有 metainfo (从数据库或缓存)
|
||||
let metaInfo: MetaInfo | null = getCachedMetaInfo(rootPath);
|
||||
let metaInfo: MetaInfo | null = getCachedMetaInfo();
|
||||
|
||||
if (!metaInfo) {
|
||||
try {
|
||||
@@ -132,8 +131,8 @@ export async function POST(request: NextRequest) {
|
||||
await db.setGlobalValue('video.metainfo', metainfoContent);
|
||||
|
||||
// 更新缓存
|
||||
invalidateMetaInfoCache(rootPath);
|
||||
setCachedMetaInfo(rootPath, metaInfo);
|
||||
invalidateMetaInfoCache();
|
||||
setCachedMetaInfo(metaInfo);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
@@ -49,8 +49,6 @@ export async function POST(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const rootPath = openListConfig.RootPath || '/';
|
||||
|
||||
// 从数据库读取 metainfo
|
||||
const metainfoContent = await db.getGlobalValue('video.metainfo');
|
||||
if (!metainfoContent) {
|
||||
@@ -78,8 +76,8 @@ export async function POST(request: NextRequest) {
|
||||
await db.setGlobalValue('video.metainfo', updatedMetainfoContent);
|
||||
|
||||
// 更新缓存
|
||||
invalidateMetaInfoCache(rootPath);
|
||||
setCachedMetaInfo(rootPath, metaInfo);
|
||||
invalidateMetaInfoCache();
|
||||
setCachedMetaInfo(metaInfo);
|
||||
|
||||
// 更新配置中的资源数量
|
||||
if (config.OpenListConfig) {
|
||||
|
||||
@@ -45,8 +45,8 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 });
|
||||
}
|
||||
|
||||
const rootPath = openListConfig.RootPath || '/';
|
||||
const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folderName}`;
|
||||
// folderName 已经是完整路径,直接使用
|
||||
const folderPath = folderName;
|
||||
const client = new OpenListClient(
|
||||
openListConfig.URL,
|
||||
openListConfig.Username,
|
||||
|
||||
@@ -48,7 +48,6 @@ export async function GET(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
const rootPath = openListConfig.RootPath || '/';
|
||||
const client = new OpenListClient(
|
||||
openListConfig.URL,
|
||||
openListConfig.Username,
|
||||
@@ -62,7 +61,7 @@ export async function GET(request: NextRequest) {
|
||||
if (noCache) {
|
||||
// noCache 模式:跳过缓存
|
||||
} else {
|
||||
metaInfo = getCachedMetaInfo(rootPath);
|
||||
metaInfo = getCachedMetaInfo();
|
||||
}
|
||||
|
||||
if (!metaInfo) {
|
||||
@@ -83,7 +82,7 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
// 只有在不是 noCache 模式时才更新缓存
|
||||
if (!noCache) {
|
||||
setCachedMetaInfo(rootPath, metaInfo);
|
||||
setCachedMetaInfo(metaInfo);
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.error('[OpenList List] JSON 解析或验证失败:', parseError);
|
||||
|
||||
@@ -9,9 +9,59 @@ import { OpenListClient } from '@/lib/openlist.client';
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* GET /api/openlist/play?folder=xxx&fileName=xxx
|
||||
* 获取单个视频文件的播放链接(懒加载)
|
||||
* 返回重定向到真实播放 URL
|
||||
* 使用 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('[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) {
|
||||
try {
|
||||
@@ -23,6 +73,7 @@ export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const folderName = searchParams.get('folder');
|
||||
const fileName = searchParams.get('fileName');
|
||||
const format = searchParams.get('format'); // 新增 format 参数
|
||||
|
||||
if (!folderName || !fileName) {
|
||||
return NextResponse.json({ error: '缺少参数' }, { status: 400 });
|
||||
@@ -41,8 +92,8 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 });
|
||||
}
|
||||
|
||||
const rootPath = openListConfig.RootPath || '/';
|
||||
const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folderName}`;
|
||||
// folderName 已经是完整路径,直接使用
|
||||
const folderPath = folderName;
|
||||
const filePath = `${folderPath}/${fileName}`;
|
||||
|
||||
const client = new OpenListClient(
|
||||
@@ -51,23 +102,71 @@ export async function GET(request: NextRequest) {
|
||||
openListConfig.Password
|
||||
);
|
||||
|
||||
// 获取文件的播放链接
|
||||
const fileResponse = await client.getFile(filePath);
|
||||
// 优先尝试视频预览流方法
|
||||
try {
|
||||
const data = await client.getVideoPreview(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 }
|
||||
);
|
||||
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('[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) {
|
||||
console.error('获取播放链接失败:', error);
|
||||
return NextResponse.json(
|
||||
|
||||
@@ -40,8 +40,8 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'OpenList 未配置或未启用' }, { status: 400 });
|
||||
}
|
||||
|
||||
const rootPath = openListConfig.RootPath || '/';
|
||||
const folderPath = `${rootPath}${rootPath.endsWith('/') ? '' : '/'}${folder}`;
|
||||
// folder 已经是完整路径,直接使用
|
||||
const folderPath = folder;
|
||||
const client = new OpenListClient(
|
||||
openListConfig.URL,
|
||||
openListConfig.Username,
|
||||
|
||||
@@ -105,15 +105,14 @@ export async function GET(request: NextRequest) {
|
||||
const { getTMDBImageUrl } = await import('@/lib/tmdb.search');
|
||||
const { db } = await import('@/lib/db');
|
||||
|
||||
const rootPath = config.OpenListConfig!.RootPath || '/';
|
||||
let metaInfo = getCachedMetaInfo(rootPath);
|
||||
let metaInfo = getCachedMetaInfo();
|
||||
|
||||
if (!metaInfo) {
|
||||
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||||
if (metainfoJson) {
|
||||
metaInfo = JSON.parse(metainfoJson);
|
||||
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 { db } = await import('@/lib/db');
|
||||
|
||||
const rootPath = config.OpenListConfig!.RootPath || '/';
|
||||
let metaInfo = getCachedMetaInfo(rootPath);
|
||||
let metaInfo = getCachedMetaInfo();
|
||||
|
||||
if (!metaInfo) {
|
||||
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||||
if (metainfoJson) {
|
||||
metaInfo = JSON.parse(metainfoJson);
|
||||
if (metaInfo) {
|
||||
setCachedMetaInfo(rootPath, metaInfo);
|
||||
setCachedMetaInfo(metaInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getConfig } from '@/lib/config';
|
||||
import { CURRENT_VERSION } from '@/lib/version'
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic'; // 禁用缓存
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
console.log('server-config called: ', request.url);
|
||||
|
||||
@@ -22,6 +22,7 @@ export async function GET(request: NextRequest) {
|
||||
const id = searchParams.get('id');
|
||||
const sourceCode = searchParams.get('source');
|
||||
const title = searchParams.get('title'); // 用于搜索的标题
|
||||
const fileName = searchParams.get('fileName'); // 小雅源:用户点击的文件名
|
||||
|
||||
if (!id || !sourceCode || !title) {
|
||||
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
|
||||
if (sourceCode === 'openlist') {
|
||||
try {
|
||||
@@ -149,13 +246,13 @@ export async function GET(request: NextRequest) {
|
||||
const { getCachedMetaInfo, setCachedMetaInfo } = await import('@/lib/openlist-cache');
|
||||
const { db } = await import('@/lib/db');
|
||||
|
||||
metaInfo = getCachedMetaInfo(rootPath);
|
||||
metaInfo = getCachedMetaInfo();
|
||||
|
||||
if (!metaInfo) {
|
||||
const metainfoJson = await db.getGlobalValue('video.metainfo');
|
||||
if (metainfoJson) {
|
||||
metaInfo = JSON.parse(metainfoJson);
|
||||
setCachedMetaInfo(rootPath, metaInfo);
|
||||
setCachedMetaInfo(metaInfo);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getConfig } from '@/lib/config';
|
||||
import { getThemeCSS } from '@/styles/themes';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic'; // 禁用缓存
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
|
||||
@@ -162,6 +162,7 @@ export async function GET(request: NextRequest) {
|
||||
overview: details.overview || '',
|
||||
rating: details.vote_average ? details.vote_average.toFixed(1) : '',
|
||||
releaseDate: details.release_date || details.first_air_date || '',
|
||||
genres: details.genres || [], // 添加类型标签
|
||||
};
|
||||
|
||||
return NextResponse.json(responseData, {
|
||||
|
||||
76
src/app/api/xiaoya/browse/route.ts
Normal file
76
src/app/api/xiaoya/browse/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
186
src/app/api/xiaoya/play/route.ts
Normal file
186
src/app/api/xiaoya/play/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
98
src/app/api/xiaoya/search/route.ts
Normal file
98
src/app/api/xiaoya/search/route.ts
Normal file
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,7 @@ export default async function RootLayout({
|
||||
let tmdbApiKey = '';
|
||||
let openListEnabled = false;
|
||||
let embyEnabled = false;
|
||||
let xiaoyaEnabled = false;
|
||||
let loginBackgroundImage = '';
|
||||
let registerBackgroundImage = '';
|
||||
let enableRegistration = false;
|
||||
@@ -81,6 +82,7 @@ export default async function RootLayout({
|
||||
let aiEnablePlayPageEntry = false;
|
||||
let aiDefaultMessageNoVideo = '';
|
||||
let aiDefaultMessageWithVideo = '';
|
||||
let enableMovieRequest = true;
|
||||
let customCategories = [] as {
|
||||
name: string;
|
||||
type: 'movie' | 'tv';
|
||||
@@ -123,6 +125,8 @@ export default async function RootLayout({
|
||||
aiEnablePlayPageEntry = config.AIConfig?.EnablePlayPageEntry || false;
|
||||
aiDefaultMessageNoVideo = config.AIConfig?.DefaultMessageNoVideo || '';
|
||||
aiDefaultMessageWithVideo = config.AIConfig?.DefaultMessageWithVideo || '';
|
||||
// 求片功能配置
|
||||
enableMovieRequest = config.SiteConfig.EnableMovieRequest ?? true;
|
||||
// 检查是否启用了 OpenList 功能
|
||||
openListEnabled = !!(
|
||||
config.OpenListConfig?.Enabled &&
|
||||
@@ -136,6 +140,11 @@ export default async function RootLayout({
|
||||
config.EmbyConfig.Sources.length > 0 &&
|
||||
config.EmbyConfig.Sources.some(s => s.enabled && s.ServerURL)
|
||||
);
|
||||
// 检查是否启用了小雅功能
|
||||
xiaoyaEnabled = !!(
|
||||
config.XiaoyaConfig?.Enabled &&
|
||||
config.XiaoyaConfig?.ServerURL
|
||||
);
|
||||
}
|
||||
|
||||
// 将运行时配置注入到全局 window 对象,供客户端在运行时读取
|
||||
@@ -155,7 +164,8 @@ export default async function RootLayout({
|
||||
VOICE_CHAT_STRATEGY: process.env.NEXT_PUBLIC_VOICE_CHAT_STRATEGY || 'webrtc-fallback',
|
||||
OPENLIST_ENABLED: openListEnabled,
|
||||
EMBY_ENABLED: embyEnabled,
|
||||
PRIVATE_LIBRARY_ENABLED: openListEnabled || embyEnabled,
|
||||
XIAOYA_ENABLED: xiaoyaEnabled,
|
||||
PRIVATE_LIBRARY_ENABLED: openListEnabled || embyEnabled || xiaoyaEnabled,
|
||||
LOGIN_BACKGROUND_IMAGE: loginBackgroundImage,
|
||||
REGISTER_BACKGROUND_IMAGE: registerBackgroundImage,
|
||||
ENABLE_REGISTRATION: enableRegistration,
|
||||
@@ -171,7 +181,7 @@ export default async function RootLayout({
|
||||
AI_ENABLE_PLAYPAGE_ENTRY: aiEnablePlayPageEntry,
|
||||
AI_DEFAULT_MESSAGE_NO_VIDEO: aiDefaultMessageNoVideo,
|
||||
AI_DEFAULT_MESSAGE_WITH_VIDEO: aiDefaultMessageWithVideo,
|
||||
ENABLE_SOURCE_SEARCH: process.env.NEXT_PUBLIC_ENABLE_SOURCE_SEARCH !== 'false',
|
||||
ENABLE_MOVIE_REQUEST: enableMovieRequest,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
430
src/app/movie-request/page.tsx
Normal file
430
src/app/movie-request/page.tsx
Normal file
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
'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 { Suspense, useEffect, useRef, useState } from 'react';
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
import type { DanmakuAnime, DanmakuSelection, DanmakuSettings, DanmakuComment } from '@/lib/danmaku/types';
|
||||
import { SearchResult, DanmakuFilterConfig, EpisodeFilterConfig } from '@/lib/types';
|
||||
import { getVideoResolutionFromM3u8, processImageUrl } from '@/lib/utils';
|
||||
import { getTMDBImageUrl } from '@/lib/tmdb.search';
|
||||
|
||||
import EpisodeSelector from '@/components/EpisodeSelector';
|
||||
import DownloadEpisodeSelector from '@/components/DownloadEpisodeSelector';
|
||||
@@ -63,6 +64,7 @@ import AIChatPanel from '@/components/AIChatPanel';
|
||||
import { useEnableComments } from '@/hooks/useEnableComments';
|
||||
import PansouSearch from '@/components/PansouSearch';
|
||||
import CustomHeatmap from '@/components/CustomHeatmap';
|
||||
import CorrectDialog from '@/components/CorrectDialog';
|
||||
|
||||
// 扩展 HTMLVideoElement 类型以支持 hls 属性
|
||||
declare global {
|
||||
@@ -122,6 +124,9 @@ function PlayPageClient() {
|
||||
const [aiEnabled, setAiEnabled] = useState(false);
|
||||
const [aiDefaultMessageWithVideo, setAiDefaultMessageWithVideo] = useState('');
|
||||
|
||||
// 纠错弹窗状态
|
||||
const [showCorrectDialog, setShowCorrectDialog] = useState(false);
|
||||
|
||||
// 检查AI功能是否启用
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
@@ -464,9 +469,13 @@ function PlayPageClient() {
|
||||
const [doubanAka, setDoubanAka] = useState<string[]>([]);
|
||||
const [doubanYear, setDoubanYear] = useState<string>(''); // 从 pubdate 提取的年份
|
||||
|
||||
// 纠错后的描述信息(用于显示,不触发 detail 更新)
|
||||
const [correctedDesc, setCorrectedDesc] = useState<string>('');
|
||||
|
||||
// 当前源和ID - source 直接存储完整格式(如 'emby_wumei' 或 'emby')
|
||||
const [currentSource, setCurrentSource] = useState(searchParams.get('source') || '');
|
||||
const [currentId, setCurrentId] = useState(searchParams.get('id') || '');
|
||||
const [fileName] = useState(searchParams.get('fileName') || ''); // 小雅源:用户点击的文件名
|
||||
|
||||
// 解析 source 参数以获取 embyKey(仅用于 API 调用)
|
||||
const parseSourceForApi = (source: string): { source: string; embyKey?: string } => {
|
||||
@@ -993,6 +1002,11 @@ function PlayPageClient() {
|
||||
if (detCacheAge < detCacheMaxAge && data && data.backdrop) {
|
||||
console.log('使用缓存的TMDB详情数据');
|
||||
setTmdbBackdrop(data.backdrop);
|
||||
|
||||
// 如果没有豆瓣ID,使用TMDb数据补充
|
||||
if (!videoDoubanId || videoDoubanId === 0) {
|
||||
populateDoubanFieldsFromTMDB(data);
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1023,6 +1037,11 @@ function PlayPageClient() {
|
||||
if (result.backdrop) {
|
||||
setTmdbBackdrop(result.backdrop);
|
||||
|
||||
// 如果没有豆瓣ID,使用TMDb数据补充
|
||||
if (!videoDoubanId || videoDoubanId === 0) {
|
||||
populateDoubanFieldsFromTMDB(result);
|
||||
}
|
||||
|
||||
// 保存title到tmdbId的映射到localStorage(1个月)
|
||||
if (result.tmdbId) {
|
||||
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();
|
||||
}, [videoTitle]);
|
||||
}, [videoTitle, videoDoubanId]);
|
||||
|
||||
|
||||
// 视频播放地址
|
||||
const [videoUrl, setVideoUrl] = useState('');
|
||||
|
||||
// 视频清晰度列表
|
||||
const [videoQualities, setVideoQualities] = useState<Array<{name: string, url: string}>>([]);
|
||||
|
||||
// 视频源代理模式状态
|
||||
const [sourceProxyMode, setSourceProxyMode] = useState(false);
|
||||
|
||||
@@ -1101,7 +1153,7 @@ function PlayPageClient() {
|
||||
|
||||
// 保存优选时的测速结果,避免EpisodeSelector重复测速
|
||||
const [precomputedVideoInfo, setPrecomputedVideoInfo] = useState<
|
||||
Map<string, { quality: string; loadSpeed: string; pingTime: number }>
|
||||
Map<string, { quality: string; loadSpeed: string; pingTime: number; bitrate: string }>
|
||||
>(new Map());
|
||||
|
||||
// 折叠状态(仅在 lg 及以上屏幕有效)
|
||||
@@ -1199,7 +1251,7 @@ function PlayPageClient() {
|
||||
const batchSize = Math.ceil(sources.length / 2);
|
||||
const allResults: Array<{
|
||||
source: SearchResult;
|
||||
testResult: { quality: string; loadSpeed: string; pingTime: number };
|
||||
testResult: { quality: string; loadSpeed: string; pingTime: number; bitrate: string };
|
||||
} | null> = [];
|
||||
|
||||
for (let start = 0; start < sources.length; start += batchSize) {
|
||||
@@ -1239,6 +1291,7 @@ function PlayPageClient() {
|
||||
quality: string;
|
||||
loadSpeed: string;
|
||||
pingTime: number;
|
||||
bitrate: string;
|
||||
hasError?: boolean;
|
||||
}
|
||||
>();
|
||||
@@ -1255,7 +1308,7 @@ function PlayPageClient() {
|
||||
// 过滤出成功的结果用于优选计算
|
||||
const successfulResults = allResults.filter(Boolean) as Array<{
|
||||
source: SearchResult;
|
||||
testResult: { quality: string; loadSpeed: string; pingTime: number };
|
||||
testResult: { quality: string; loadSpeed: string; pingTime: number; bitrate: string };
|
||||
}>;
|
||||
|
||||
setPrecomputedVideoInfo(newVideoInfoMap);
|
||||
@@ -1419,6 +1472,21 @@ function PlayPageClient() {
|
||||
detailData: SearchResult | null,
|
||||
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 (
|
||||
!detailData ||
|
||||
!detailData.episodes ||
|
||||
@@ -1434,6 +1502,33 @@ function PlayPageClient() {
|
||||
|
||||
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);
|
||||
|
||||
@@ -2267,18 +2362,23 @@ function PlayPageClient() {
|
||||
const fetchSourceDetail = async (
|
||||
source: string,
|
||||
id: string,
|
||||
title: string
|
||||
title: string,
|
||||
fileNameParam?: string
|
||||
): Promise<SearchResult[]> => {
|
||||
try {
|
||||
const detailResponse = await fetch(
|
||||
`/api/source-detail?source=${source}&id=${id}&title=${encodeURIComponent(title)}`
|
||||
);
|
||||
let url = `/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) {
|
||||
throw new Error('获取视频详情失败');
|
||||
}
|
||||
const detailData = (await detailResponse.json()) as SearchResult;
|
||||
setAvailableSources([detailData]);
|
||||
return [detailData];
|
||||
const sourcesWithCorrections = applyCorrectionsToSources([detailData]);
|
||||
setAvailableSources(sourcesWithCorrections);
|
||||
return sourcesWithCorrections;
|
||||
} catch (err) {
|
||||
console.error('获取视频详情失败:', err);
|
||||
return [];
|
||||
@@ -2361,7 +2461,7 @@ function PlayPageClient() {
|
||||
: true)
|
||||
);
|
||||
|
||||
setAvailableSources(results);
|
||||
setAvailableSources(applyCorrectionsToSources(results));
|
||||
return results;
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -2395,7 +2495,7 @@ function PlayPageClient() {
|
||||
? getType(result) === searchType
|
||||
: true)
|
||||
);
|
||||
setAvailableSources(results);
|
||||
setAvailableSources(applyCorrectionsToSources(results));
|
||||
return results;
|
||||
} catch (err) {
|
||||
setSourceSearchError(err instanceof Error ? err.message : '搜索失败');
|
||||
@@ -2428,7 +2528,13 @@ function PlayPageClient() {
|
||||
// 先快速获取当前源的详情
|
||||
try {
|
||||
// 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) {
|
||||
detailData = currentSourceDetail[0];
|
||||
sourcesInfo = currentSourceDetail;
|
||||
@@ -2448,7 +2554,7 @@ function PlayPageClient() {
|
||||
allSources.push(source);
|
||||
}
|
||||
});
|
||||
setAvailableSources(allSources);
|
||||
setAvailableSources(applyCorrectionsToSources(allSources));
|
||||
setBackgroundSourcesLoading(false);
|
||||
}).catch((err) => {
|
||||
console.error('异步获取其他源失败:', err);
|
||||
@@ -2500,7 +2606,7 @@ function PlayPageClient() {
|
||||
setLoadingStage('preferring');
|
||||
setLoadingMessage('⚡ 正在优选最佳播放源...');
|
||||
|
||||
// 过滤掉 openlist 和所有 emby 源,它们不参与测速
|
||||
// 过滤掉 openlist、所有 emby 源和 xiaoya 源,它们不参与测速
|
||||
const sourcesToTest = sourcesInfo.filter(s => {
|
||||
// 检查是否为 openlist
|
||||
if (s.source === 'openlist') return false;
|
||||
@@ -2508,19 +2614,23 @@ function PlayPageClient() {
|
||||
// 检查是否为 emby 源(包括 emby 和 emby_xxx 格式)
|
||||
if (s.source === 'emby' || s.source.startsWith('emby_')) return false;
|
||||
|
||||
// 检查是否为 xiaoya 源
|
||||
if (s.source === 'xiaoya') return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const excludedSources = sourcesInfo.filter(s =>
|
||||
s.source === 'openlist' ||
|
||||
s.source === 'emby' ||
|
||||
s.source.startsWith('emby_')
|
||||
s.source.startsWith('emby_') ||
|
||||
s.source === 'xiaoya'
|
||||
);
|
||||
|
||||
if (sourcesToTest.length > 0) {
|
||||
detailData = await preferBestSource(sourcesToTest);
|
||||
} else if (excludedSources.length > 0) {
|
||||
// 如果只有 openlist/emby 源,直接使用第一个
|
||||
// 如果只有 openlist/emby/xiaoya 源,直接使用第一个
|
||||
detailData = excludedSources[0];
|
||||
} else {
|
||||
detailData = sourcesInfo[0];
|
||||
@@ -2542,23 +2652,43 @@ function PlayPageClient() {
|
||||
// 直接使用 detailData.source(已经是完整格式)
|
||||
setCurrentSource(detailData.source);
|
||||
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);
|
||||
setVideoTitle(detailData.title || videoTitleRef.current);
|
||||
setVideoCover(detailData.poster);
|
||||
setVideoDoubanId(detailData.douban_id || 0);
|
||||
|
||||
setDetail(detailData);
|
||||
setSourceProxyMode(detailData.proxyMode || false); // 从 detail 数据中读取代理模式
|
||||
if (currentEpisodeIndex >= detailData.episodes.length) {
|
||||
setCurrentEpisodeIndex(0);
|
||||
}
|
||||
|
||||
// 规范URL参数(不更新title,避免循环刷新)
|
||||
// 规范URL参数
|
||||
const newUrl = new URL(window.location.href);
|
||||
newUrl.searchParams.set('source', detailData.source);
|
||||
newUrl.searchParams.set('id', detailData.id);
|
||||
newUrl.searchParams.set('year', detailData.year);
|
||||
// 保持原有的 title,不更新
|
||||
newUrl.searchParams.set('title', detailData.title);
|
||||
newUrl.searchParams.delete('prefer');
|
||||
// 只有当元数据不是从文件获取时,才删除fileName参数
|
||||
if (detailData.metadataSource !== 'file') {
|
||||
newUrl.searchParams.delete('fileName');
|
||||
}
|
||||
window.history.replaceState({}, '', newUrl.toString());
|
||||
|
||||
setLoadingStage('ready');
|
||||
@@ -2570,18 +2700,52 @@ function PlayPageClient() {
|
||||
const key = generateStorageKey(detailData.source, detailData.id);
|
||||
const record = allRecords[key];
|
||||
|
||||
// 确定初始集数索引
|
||||
let initialIndex = 0;
|
||||
let shouldResumeTime = false;
|
||||
|
||||
if (record) {
|
||||
const targetIndex = record.index - 1;
|
||||
const targetTime = record.play_time;
|
||||
// 有播放记录
|
||||
const recordIndex = record.index - 1;
|
||||
const recordTime = record.play_time;
|
||||
|
||||
// 更新当前选集索引
|
||||
if (targetIndex < detailData.episodes.length && targetIndex >= 0) {
|
||||
setCurrentEpisodeIndex(targetIndex);
|
||||
currentEpisodeIndexRef.current = targetIndex;
|
||||
// 如果有initialEpisodeIndex(用户从文件点击进入)
|
||||
if (detailData.initialEpisodeIndex !== undefined) {
|
||||
// 如果播放记录的集数和点击的文件集数一致,则使用播放记录的时间
|
||||
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) {
|
||||
console.error('读取播放记录失败:', err);
|
||||
@@ -2836,9 +3000,33 @@ function PlayPageClient() {
|
||||
newUrl.searchParams.set('title', newDetail.title || newTitle);
|
||||
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);
|
||||
setVideoCover(newDetail.poster);
|
||||
setVideoCover(finalCover);
|
||||
setCorrectedDesc(finalDesc);
|
||||
setVideoDoubanId(newDetail.douban_id || 0);
|
||||
// newSource 已经是完整格式
|
||||
setCurrentSource(newSource);
|
||||
@@ -3164,6 +3352,18 @@ function PlayPageClient() {
|
||||
setDanmakuCount(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));
|
||||
} 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(() => {
|
||||
if (
|
||||
!videoUrl ||
|
||||
@@ -3947,7 +4186,7 @@ function PlayPageClient() {
|
||||
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';
|
||||
|
||||
artPlayerRef.current = new Artplayer({
|
||||
@@ -3992,10 +4231,17 @@ function PlayPageClient() {
|
||||
fastForward: true,
|
||||
autoOrientation: true,
|
||||
lock: true,
|
||||
...(videoQualities.length > 0 ? {
|
||||
quality: videoQualities.map((q, index) => ({
|
||||
default: index === 0,
|
||||
html: q.name,
|
||||
url: q.url,
|
||||
})),
|
||||
} : {}),
|
||||
moreVideoAttr: {
|
||||
crossOrigin: 'anonymous',
|
||||
playsInline: true,
|
||||
'webkit-playsinline': 'true',
|
||||
...(detail?.source === 'xiaoya' ? { referrerpolicy: 'no-referrer' } : {}),
|
||||
} as any,
|
||||
// HLS 支持配置
|
||||
customType: {
|
||||
@@ -4901,7 +5147,7 @@ function PlayPageClient() {
|
||||
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) {
|
||||
const subtitleOptions = [
|
||||
{
|
||||
@@ -5824,7 +6070,7 @@ function PlayPageClient() {
|
||||
|
||||
// 调用异步初始化函数
|
||||
initPlayer();
|
||||
}, [videoUrl, loading, blockAdEnabled, currentEpisodeIndex, detail]);
|
||||
}, [videoUrl, loading, blockAdEnabled, currentEpisodeIndex]);
|
||||
|
||||
// 当组件卸载时清理定时器、Wake Lock 和播放器资源
|
||||
useEffect(() => {
|
||||
@@ -6777,6 +7023,19 @@ function PlayPageClient() {
|
||||
<Sparkles className='h-6 w-6 text-gray-700 dark:text-gray-300' />
|
||||
</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 && (
|
||||
<div className='flex items-center gap-2 text-base font-normal'>
|
||||
@@ -6857,14 +7116,16 @@ function PlayPageClient() {
|
||||
<span>{doubanYear || detail?.year || videoYear}</span>
|
||||
)}
|
||||
{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}
|
||||
</span>
|
||||
)}
|
||||
{detail?.type_name && <span>{detail.type_name}</span>}
|
||||
</div>
|
||||
{/* 剧情简介 */}
|
||||
{(doubanCardSubtitle || detail?.desc) && (
|
||||
{(doubanCardSubtitle || correctedDesc || detail?.desc) && (
|
||||
<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' : ''}`}
|
||||
style={{ whiteSpace: 'pre-line' }}
|
||||
@@ -6875,7 +7136,7 @@ function PlayPageClient() {
|
||||
{doubanCardSubtitle}
|
||||
</div>
|
||||
)}
|
||||
{detail?.desc}
|
||||
{correctedDesc || detail?.desc}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -7049,10 +7310,76 @@ function PlayPageClient() {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
// 从 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 组件
|
||||
const FavoriteIcon = ({ filled }: { filled: boolean }) => {
|
||||
if (filled) {
|
||||
|
||||
@@ -4,12 +4,14 @@
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { Film } from 'lucide-react';
|
||||
|
||||
import CapsuleSwitch from '@/components/CapsuleSwitch';
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
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 {
|
||||
key: string;
|
||||
@@ -45,7 +47,7 @@ export default function PrivateLibraryPage() {
|
||||
if (typeof window !== 'undefined' && (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 格式)
|
||||
@@ -72,6 +74,14 @@ export default function PrivateLibraryPage() {
|
||||
const [embyViews, setEmbyViews] = useState<EmbyView[]>([]);
|
||||
const [selectedView, setSelectedView] = useState<string>('all');
|
||||
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 observerTarget = useRef<HTMLDivElement>(null);
|
||||
const isFetchingRef = useRef(false);
|
||||
@@ -84,6 +94,38 @@ export default function PrivateLibraryPage() {
|
||||
const isInitializedRef = 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初始化状态,并检查配置自动跳转
|
||||
useEffect(() => {
|
||||
const urlSourceParam = searchParams.get('source');
|
||||
@@ -270,6 +312,12 @@ export default function PrivateLibraryPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果选择了 xiaoya 但未配置,不发起请求
|
||||
if (sourceType === 'xiaoya' && !runtimeConfig.XIAOYA_ENABLED) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建新的 AbortController
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
@@ -285,6 +333,8 @@ export default function PrivateLibraryPage() {
|
||||
|
||||
const endpoint = sourceType === 'openlist'
|
||||
? `/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}`;
|
||||
|
||||
const response = await fetch(endpoint, { signal: abortController.signal });
|
||||
@@ -301,19 +351,27 @@ export default function PrivateLibraryPage() {
|
||||
setVideos([]);
|
||||
}
|
||||
} else {
|
||||
const newVideos = data.list || [];
|
||||
|
||||
if (isInitial) {
|
||||
setVideos(newVideos);
|
||||
// 小雅返回的是文件夹和文件列表
|
||||
if (sourceType === 'xiaoya') {
|
||||
setXiaoyaFolders(data.folders || []);
|
||||
setXiaoyaFiles(data.files || []);
|
||||
setVideos([]); // 小雅不使用 videos 状态
|
||||
setHasMore(false); // 小雅不需要分页
|
||||
} else {
|
||||
setVideos((prev) => [...prev, ...newVideos]);
|
||||
}
|
||||
const newVideos = data.list || [];
|
||||
|
||||
// 检查是否还有更多数据
|
||||
const currentPage = data.page || page;
|
||||
const totalPages = data.totalPages || 1;
|
||||
const hasMoreData = currentPage < totalPages;
|
||||
setHasMore(hasMoreData);
|
||||
if (isInitial) {
|
||||
setVideos(newVideos);
|
||||
} else {
|
||||
setVideos((prev) => [...prev, ...newVideos]);
|
||||
}
|
||||
|
||||
// 检查是否还有更多数据
|
||||
const currentPage = data.page || page;
|
||||
const totalPages = data.totalPages || 1;
|
||||
const hasMoreData = currentPage < totalPages;
|
||||
setHasMore(hasMoreData);
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
// 忽略取消请求的错误
|
||||
@@ -346,7 +404,7 @@ export default function PrivateLibraryPage() {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, [sourceType, embyKey, page, selectedView, runtimeConfig]);
|
||||
}, [sourceType, embyKey, page, selectedView, xiaoyaPath, runtimeConfig]);
|
||||
|
||||
const handleVideoClick = (video: Video) => {
|
||||
// 构建source参数
|
||||
@@ -390,26 +448,40 @@ export default function PrivateLibraryPage() {
|
||||
return (
|
||||
<PageLayout activePath='/private-library'>
|
||||
<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'>
|
||||
观看自我收藏的高清视频吧
|
||||
</p>
|
||||
<div className='mb-6 flex justify-between items-start'>
|
||||
<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'>
|
||||
观看自我收藏的高清视频吧
|
||||
</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>
|
||||
|
||||
{/* 第一级:源类型选择(OpenList / Emby) */}
|
||||
<div className='mb-6 flex justify-center'>
|
||||
<CapsuleSwitch
|
||||
options={[
|
||||
{ label: 'OpenList', value: 'openlist' },
|
||||
{ label: 'Emby', value: 'emby' },
|
||||
]}
|
||||
active={sourceType}
|
||||
onChange={(value) => setSourceType(value as LibrarySourceType)}
|
||||
/>
|
||||
</div>
|
||||
{/* 第一级:源类型选择(OpenList / Emby / 小雅) */}
|
||||
{mounted && (
|
||||
<div className='mb-6 flex justify-center'>
|
||||
<CapsuleSwitch
|
||||
options={[
|
||||
...(runtimeConfig.OPENLIST_ENABLED ? [{ label: 'OpenList', value: 'openlist' }] : []),
|
||||
...(runtimeConfig.EMBY_ENABLED ? [{ label: 'Emby', value: 'emby' }] : []),
|
||||
...(runtimeConfig.XIAOYA_ENABLED ? [{ label: '小雅', value: 'xiaoya' }] : []),
|
||||
]}
|
||||
active={sourceType}
|
||||
onChange={(value) => setSourceType(value as LibrarySourceType)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 第二级:Emby源选择(仅当选择Emby且有多个源时显示) */}
|
||||
{sourceType === 'emby' && embySourceOptions.length > 1 && (
|
||||
@@ -527,13 +599,241 @@ export default function PrivateLibraryPage() {
|
||||
)}
|
||||
|
||||
{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'>
|
||||
{Array.from({ length: pageSize }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className='animate-pulse bg-gray-200 dark:bg-gray-700 rounded-lg aspect-[2/3]'
|
||||
/>
|
||||
))}
|
||||
sourceType === 'xiaoya' ? (
|
||||
// 小雅加载骨架屏 - 文件夹列表样式
|
||||
<div className='space-y-4'>
|
||||
{/* 文件夹骨架屏 */}
|
||||
<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>
|
||||
) : videos.length === 0 ? (
|
||||
<div className='text-center py-12'>
|
||||
|
||||
@@ -36,6 +36,10 @@ function SearchPageClient() {
|
||||
const [triggerAcgSearch, setTriggerAcgSearch] = useState(false);
|
||||
// 用户权限
|
||||
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 searchParams = useSearchParams();
|
||||
@@ -538,6 +542,26 @@ function SearchPageClient() {
|
||||
const authInfo = getAuthInfoFromBrowserCookie();
|
||||
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);
|
||||
|
||||
@@ -600,13 +624,41 @@ function SearchPageClient() {
|
||||
}, []);
|
||||
|
||||
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();
|
||||
|
||||
if (query) {
|
||||
setSearchQuery(query);
|
||||
|
||||
|
||||
const trimmed = query.trim();
|
||||
|
||||
// 检查是否有缓存且不是强制刷新
|
||||
@@ -799,7 +851,7 @@ function SearchPageClient() {
|
||||
setShowResults(false);
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
}, [searchParams, forceRefresh]);
|
||||
}, [searchParams, forceRefresh, converterReady]);
|
||||
|
||||
// 组件卸载时,关闭可能存在的连接
|
||||
useEffect(() => {
|
||||
@@ -838,9 +890,21 @@ function SearchPageClient() {
|
||||
// 搜索表单提交时触发,处理搜索逻辑
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const trimmed = searchQuery.trim().replace(/\s+/g, ' ');
|
||||
let trimmed = searchQuery.trim().replace(/\s+/g, ' ');
|
||||
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);
|
||||
setShowResults(true);
|
||||
@@ -863,7 +927,21 @@ function SearchPageClient() {
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
// 自动执行搜索
|
||||
@@ -872,15 +950,15 @@ function SearchPageClient() {
|
||||
// 根据当前选项卡执行不同的搜索
|
||||
if (activeTab === 'video') {
|
||||
// 影视搜索
|
||||
router.push(`/search?q=${encodeURIComponent(suggestion)}&type=video`);
|
||||
router.push(`/search?q=${encodeURIComponent(processedSuggestion)}&type=video`);
|
||||
// 其余由 searchParams 变化的 effect 处理
|
||||
} else if (activeTab === 'pansou') {
|
||||
// 网盘搜索 - 触发搜索
|
||||
router.push(`/search?q=${encodeURIComponent(suggestion)}&type=pansou`);
|
||||
router.push(`/search?q=${encodeURIComponent(processedSuggestion)}&type=pansou`);
|
||||
setTriggerPansouSearch(prev => !prev);
|
||||
} else if (activeTab === 'acg') {
|
||||
// ACG 磁力搜索 - 触发搜索
|
||||
router.push(`/search?q=${encodeURIComponent(suggestion)}&type=acg`);
|
||||
router.push(`/search?q=${encodeURIComponent(processedSuggestion)}&type=acg`);
|
||||
setTriggerAcgSearch(prev => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -49,6 +49,7 @@ interface CorrectDialogProps {
|
||||
seasonName?: string;
|
||||
};
|
||||
onCorrect: () => void;
|
||||
source?: string; // 新增:视频源类型(xiaoya, openlist等)
|
||||
}
|
||||
|
||||
export default function CorrectDialog({
|
||||
@@ -58,6 +59,7 @@ export default function CorrectDialog({
|
||||
currentTitle,
|
||||
currentVideo,
|
||||
onCorrect,
|
||||
source = 'openlist', // 默认为 openlist
|
||||
}: CorrectDialogProps) {
|
||||
const [searchQuery, setSearchQuery] = useState(currentTitle);
|
||||
const [searching, setSearching] = useState(false);
|
||||
@@ -229,8 +231,7 @@ export default function CorrectDialog({
|
||||
finalTitle = `${finalTitle} ${season.name}`;
|
||||
}
|
||||
|
||||
const body: any = {
|
||||
key: videoKey,
|
||||
const correctionData: any = {
|
||||
tmdbId: finalTmdbId,
|
||||
title: finalTitle,
|
||||
posterPath: season?.poster_path || result.poster_path,
|
||||
@@ -240,20 +241,38 @@ export default function CorrectDialog({
|
||||
mediaType: result.media_type,
|
||||
};
|
||||
|
||||
// 如果有季度信息,添加到请求中
|
||||
// 如果有季度信息,添加到数据中
|
||||
if (season) {
|
||||
body.seasonNumber = season.season_number;
|
||||
body.seasonName = season.name;
|
||||
correctionData.seasonNumber = season.season_number;
|
||||
correctionData.seasonName = season.name;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/openlist/correct', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
// 根据源类型选择不同的存储方式
|
||||
if (source === 'xiaoya') {
|
||||
// 小雅源:存储到 localStorage
|
||||
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) {
|
||||
throw new Error('纠错失败');
|
||||
const response = await fetch('/api/openlist/correct', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('纠错失败');
|
||||
}
|
||||
}
|
||||
|
||||
onCorrect();
|
||||
@@ -313,8 +332,7 @@ export default function CorrectDialog({
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const body: any = {
|
||||
key: videoKey,
|
||||
const correctionData: any = {
|
||||
title: manualData.title.trim(),
|
||||
posterPath: manualData.posterPath.trim() || null,
|
||||
releaseDate: manualData.releaseDate.trim() || '',
|
||||
@@ -325,28 +343,46 @@ export default function CorrectDialog({
|
||||
|
||||
// 添加 TMDB ID(如果提供)
|
||||
if (manualData.tmdbId.trim()) {
|
||||
body.tmdbId = Number(manualData.tmdbId);
|
||||
correctionData.tmdbId = Number(manualData.tmdbId);
|
||||
}
|
||||
|
||||
// 添加豆瓣 ID(如果提供)
|
||||
if (manualData.doubanId.trim()) {
|
||||
body.doubanId = manualData.doubanId.trim();
|
||||
correctionData.doubanId = manualData.doubanId.trim();
|
||||
}
|
||||
|
||||
// 如果是电视剧且有季度信息
|
||||
if (manualData.mediaType === 'tv' && manualData.seasonNumber) {
|
||||
body.seasonNumber = Number(manualData.seasonNumber);
|
||||
body.seasonName = manualData.seasonName.trim() || `第 ${manualData.seasonNumber} 季`;
|
||||
correctionData.seasonNumber = Number(manualData.seasonNumber);
|
||||
correctionData.seasonName = manualData.seasonName.trim() || `第 ${manualData.seasonNumber} 季`;
|
||||
}
|
||||
|
||||
const response = await fetch('/api/openlist/correct', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
// 根据源类型选择不同的存储方式
|
||||
if (source === 'xiaoya') {
|
||||
// 小雅源:存储到 localStorage
|
||||
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) {
|
||||
throw new Error('纠错失败');
|
||||
const response = await fetch('/api/openlist/correct', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('纠错失败');
|
||||
}
|
||||
}
|
||||
|
||||
onCorrect();
|
||||
|
||||
@@ -21,6 +21,7 @@ interface VideoInfo {
|
||||
quality: string;
|
||||
loadSpeed: string;
|
||||
pingTime: number;
|
||||
bitrate: string; // 视频码率
|
||||
hasError?: boolean; // 添加错误状态标识
|
||||
}
|
||||
|
||||
@@ -213,6 +214,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
quality: '错误',
|
||||
loadSpeed: '未知',
|
||||
pingTime: 0,
|
||||
bitrate: '未知',
|
||||
hasError: true,
|
||||
})
|
||||
);
|
||||
@@ -271,16 +273,18 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
if (
|
||||
!optimizationEnabled || // 若关闭测速则直接退出
|
||||
activeTab !== 'sources' ||
|
||||
availableSources.length === 0 ||
|
||||
currentSource === 'openlist' || // 私人影库不进行测速
|
||||
currentSource === 'emby' // Emby 不进行测速
|
||||
availableSources.length === 0
|
||||
)
|
||||
return;
|
||||
|
||||
// 筛选出尚未测速的播放源
|
||||
// 筛选出尚未测速的播放源,并排除不需要测速的源(openlist/emby/xiaoya)
|
||||
const pendingSources = availableSources.filter((source) => {
|
||||
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;
|
||||
@@ -308,11 +312,15 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
// 当后台加载从 true 变为 false 时(即加载完成)
|
||||
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 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) {
|
||||
@@ -683,6 +691,13 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
if (title.match(/^OVA\s+\d+/i)) {
|
||||
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话"格式,提取中间的数字(支持小数)
|
||||
const match = title.match(/(?:第)?(\d+(?:\.\d+)?)(?:集|话)/);
|
||||
if (match) {
|
||||
@@ -856,7 +871,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
{/* 源名称和集数信息 - 垂直居中 */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<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-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'>
|
||||
{videoInfo.pingTime}ms
|
||||
</div>
|
||||
{videoInfo.bitrate && videoInfo.bitrate !== '未知' && (
|
||||
<div className='text-purple-600 dark:text-purple-400 font-medium text-xs'>
|
||||
{videoInfo.bitrate}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
@@ -900,8 +920,8 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
</div>
|
||||
{/* 重新测试按钮 */}
|
||||
{(() => {
|
||||
// 私人影库和 Emby 不显示重新测试按钮
|
||||
if (source.source === 'openlist' || source.source === 'emby' || source.source.startsWith('emby_')) {
|
||||
// 私人影库、Emby 和小雅不显示重新测试按钮
|
||||
if (source.source === 'openlist' || source.source === 'emby' || source.source.startsWith('emby_') || source.source === 'xiaoya') {
|
||||
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[] = [
|
||||
...(contentType !== 'anime-tv' && contentType !== 'anime-movie'
|
||||
@@ -284,24 +303,7 @@ const MultiLevelSelector: React.FC<MultiLevelSelectorProps> = ({
|
||||
{
|
||||
key: 'year',
|
||||
label: '年代',
|
||||
options: [
|
||||
{ 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' },
|
||||
],
|
||||
options: yearOptions,
|
||||
},
|
||||
// 只在电视剧和综艺时显示平台选项
|
||||
...(contentType === 'tv' ||
|
||||
|
||||
@@ -106,6 +106,7 @@ export const UserMenu: React.FC = () => {
|
||||
const [bufferStrategy, setBufferStrategy] = useState('medium');
|
||||
const [nextEpisodePreCache, setNextEpisodePreCache] = useState(true);
|
||||
const [isBufferStrategyDropdownOpen, setIsBufferStrategyDropdownOpen] = useState(false);
|
||||
const [searchTraditionalToSimplified, setSearchTraditionalToSimplified] = useState(false);
|
||||
|
||||
// 折叠面板状态
|
||||
const [isDoubanSectionOpen, setIsDoubanSectionOpen] = useState(true);
|
||||
@@ -378,6 +379,12 @@ export const UserMenu: React.FC = () => {
|
||||
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 updatedModules = homeModules.map(module =>
|
||||
@@ -732,6 +746,7 @@ export const UserMenu: React.FC = () => {
|
||||
setBufferStrategy('medium');
|
||||
setNextEpisodePreCache(true);
|
||||
setHomeModules(defaultHomeModules);
|
||||
setSearchTraditionalToSimplified(false);
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('defaultAggregateSearch', JSON.stringify(true));
|
||||
@@ -747,6 +762,7 @@ export const UserMenu: React.FC = () => {
|
||||
localStorage.setItem('bufferStrategy', 'medium');
|
||||
localStorage.setItem('nextEpisodePreCache', 'true');
|
||||
localStorage.setItem('homeModules', JSON.stringify(defaultHomeModules));
|
||||
localStorage.setItem('searchTraditionalToSimplified', 'false');
|
||||
window.dispatchEvent(new CustomEvent('homeModulesUpdated'));
|
||||
}
|
||||
};
|
||||
@@ -1423,6 +1439,30 @@ export const UserMenu: React.FC = () => {
|
||||
</div>
|
||||
</label>
|
||||
</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>
|
||||
|
||||
@@ -1246,7 +1246,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
{config.showSourceName && source_name && !cmsData && (
|
||||
<span
|
||||
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={{
|
||||
WebkitUserSelect: 'none',
|
||||
|
||||
@@ -40,6 +40,9 @@ export interface AdminConfig {
|
||||
TurnstileSiteKey?: string; // Cloudflare Turnstile Site Key
|
||||
TurnstileSecretKey?: string; // Cloudflare Turnstile Secret Key
|
||||
DefaultUserTags?: string[]; // 新注册用户的默认用户组
|
||||
// 求片功能配置
|
||||
EnableMovieRequest?: boolean; // 启用求片功能
|
||||
MovieRequestCooldown?: number; // 求片冷却时间(秒),默认3600
|
||||
// OIDC配置
|
||||
EnableOIDCLogin?: boolean; // 启用OIDC登录
|
||||
EnableOIDCRegistration?: boolean; // 启用OIDC注册
|
||||
@@ -106,7 +109,8 @@ export interface AdminConfig {
|
||||
URL: string; // OpenList 服务器地址
|
||||
Username: string; // 账号(用于登录获取Token)
|
||||
Password: string; // 密码(用于登录获取Token)
|
||||
RootPath: string; // 根目录路径,默认 "/"
|
||||
RootPath?: string; // 旧字段:根目录路径(向后兼容,迁移后删除)
|
||||
RootPaths?: string[]; // 新字段:多根目录路径列表
|
||||
OfflineDownloadPath: string; // 离线下载目录,默认 "/"
|
||||
LastRefreshTime?: number; // 上次刷新时间戳
|
||||
ResourceCount?: number; // 资源数量
|
||||
@@ -192,6 +196,13 @@ export interface AdminConfig {
|
||||
LastSyncTime?: number;
|
||||
ItemCount?: number;
|
||||
};
|
||||
XiaoyaConfig?: {
|
||||
Enabled: boolean; // 是否启用
|
||||
ServerURL: string; // Alist 服务器地址
|
||||
Token?: string; // Token 认证(推荐)
|
||||
Username?: string; // 用户名认证(备选)
|
||||
Password?: string; // 密码认证(备选)
|
||||
};
|
||||
}
|
||||
|
||||
export interface AdminConfigResult {
|
||||
|
||||
47
src/lib/nfo-parser.ts
Normal file
47
src/lib/nfo-parser.ts
Normal file
@@ -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;
|
||||
}
|
||||
|
||||
// MetaInfo 缓存操作
|
||||
export function getCachedMetaInfo(rootPath: string): MetaInfo | null {
|
||||
const entry = METAINFO_CACHE.get(rootPath);
|
||||
// MetaInfo 缓存操作(使用固定键)
|
||||
const METAINFO_CACHE_KEY = 'openlist_meta';
|
||||
|
||||
export function getCachedMetaInfo(): MetaInfo | null {
|
||||
const entry = METAINFO_CACHE.get(METAINFO_CACHE_KEY);
|
||||
if (!entry) return null;
|
||||
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
METAINFO_CACHE.delete(rootPath);
|
||||
METAINFO_CACHE.delete(METAINFO_CACHE_KEY);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
}
|
||||
|
||||
export function setCachedMetaInfo(rootPath: string, data: MetaInfo): void {
|
||||
METAINFO_CACHE.set(rootPath, {
|
||||
export function setCachedMetaInfo(data: MetaInfo): void {
|
||||
METAINFO_CACHE.set(METAINFO_CACHE_KEY, {
|
||||
expiresAt: Date.now() + METAINFO_CACHE_TTL_MS,
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateMetaInfoCache(rootPath: string): void {
|
||||
METAINFO_CACHE.delete(rootPath);
|
||||
export function invalidateMetaInfoCache(): void {
|
||||
METAINFO_CACHE.delete(METAINFO_CACHE_KEY);
|
||||
}
|
||||
|
||||
// VideoInfo 缓存操作
|
||||
|
||||
@@ -20,6 +20,65 @@ import {
|
||||
import { parseSeasonFromTitle } from '@/lib/season-parser';
|
||||
import { searchTMDB, getTVSeasonDetails } from '@/lib/tmdb.search';
|
||||
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 刷新任务
|
||||
@@ -45,13 +104,24 @@ export async function startOpenListRefresh(clearMetaInfo: boolean = false): Prom
|
||||
throw new Error('TMDB API Key 未配置');
|
||||
}
|
||||
|
||||
// 检测是否需要迁移
|
||||
if (openListConfig.RootPath && !openListConfig.RootPaths) {
|
||||
await migrateToMultiRoot(openListConfig);
|
||||
// 重新加载配置
|
||||
const newConfig = await getConfig();
|
||||
Object.assign(openListConfig, newConfig.OpenListConfig);
|
||||
}
|
||||
|
||||
cleanupOldTasks();
|
||||
const taskId = createScanTask();
|
||||
|
||||
performScan(
|
||||
const rootPaths = getRootPaths(openListConfig);
|
||||
|
||||
// 顺序扫描多个根目录
|
||||
performMultiRootScan(
|
||||
taskId,
|
||||
openListConfig.URL,
|
||||
openListConfig.RootPath || '/',
|
||||
rootPaths,
|
||||
tmdbApiKey,
|
||||
tmdbProxy,
|
||||
openListConfig.Username,
|
||||
@@ -66,6 +136,43 @@ export async function startOpenListRefresh(clearMetaInfo: boolean = false): Prom
|
||||
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[] = [];
|
||||
let currentPage = 1;
|
||||
@@ -155,12 +262,15 @@ async function performScan(
|
||||
|
||||
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++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const folderKey = generateFolderKey(folder.name, existingKeys);
|
||||
const folderKey = generateFolderKey(fullFolderPath, existingKeys);
|
||||
existingKeys.add(folderKey);
|
||||
|
||||
try {
|
||||
@@ -197,7 +307,7 @@ async function performScan(
|
||||
const result = searchResult.result;
|
||||
|
||||
const folderInfo: any = {
|
||||
folderName: folder.name,
|
||||
folderName: fullFolderPath,
|
||||
tmdb_id: result.id,
|
||||
title: result.title || result.name || folder.name,
|
||||
poster_path: result.poster_path,
|
||||
@@ -249,7 +359,7 @@ async function performScan(
|
||||
newCount++;
|
||||
} else {
|
||||
metaInfo.folders[folderKey] = {
|
||||
folderName: folder.name,
|
||||
folderName: fullFolderPath,
|
||||
tmdb_id: 0,
|
||||
title: folder.name,
|
||||
poster_path: null,
|
||||
@@ -267,7 +377,7 @@ async function performScan(
|
||||
} catch (error) {
|
||||
console.error(`[OpenList Refresh] 处理文件夹失败: ${folder.name}`, error);
|
||||
metaInfo.folders[folderKey] = {
|
||||
folderName: folder.name,
|
||||
folderName: fullFolderPath,
|
||||
tmdb_id: 0,
|
||||
title: folder.name,
|
||||
poster_path: null,
|
||||
@@ -287,8 +397,8 @@ async function performScan(
|
||||
const metainfoContent = JSON.stringify(metaInfo);
|
||||
await db.setGlobalValue('video.metainfo', metainfoContent);
|
||||
|
||||
invalidateMetaInfoCache(rootPath);
|
||||
setCachedMetaInfo(rootPath, metaInfo);
|
||||
invalidateMetaInfoCache();
|
||||
setCachedMetaInfo(metaInfo);
|
||||
|
||||
const config = await getConfig();
|
||||
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 }> {
|
||||
try {
|
||||
|
||||
@@ -659,6 +659,7 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
playrecord_migrated?: boolean;
|
||||
favorite_migrated?: boolean;
|
||||
skip_migrated?: boolean;
|
||||
last_movie_request_time?: number;
|
||||
} | null> {
|
||||
const userInfo = await this.withRetry(() =>
|
||||
this.client.hGetAll(this.userInfoKey(userName))
|
||||
@@ -678,6 +679,7 @@ export abstract class BaseRedisStorage implements IStorage {
|
||||
playrecord_migrated: userInfo.playrecord_migrated === 'true',
|
||||
favorite_migrated: userInfo.favorite_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())
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- 求片相关 ----------
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,6 +117,30 @@ export interface IStorage {
|
||||
// 收藏更新检查相关
|
||||
getLastFavoriteCheckTime(userName: string): Promise<number>;
|
||||
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; // 总集数
|
||||
proxyMode?: boolean; // 代理模式:启用后由服务器代理m3u8和ts分片
|
||||
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 =
|
||||
| 'favorite_update' // 收藏更新
|
||||
| 'system' // 系统通知
|
||||
| 'announcement'; // 公告
|
||||
| 'announcement' // 公告
|
||||
| 'movie_request' // 新求片通知(给管理员)
|
||||
| 'request_fulfilled'; // 求片已上架通知(给求片用户)
|
||||
|
||||
// 通知数据结构
|
||||
export interface Notification {
|
||||
@@ -215,3 +245,23 @@ export interface FavoriteUpdateCheck {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -58,10 +58,31 @@ async function withRetry<T>(
|
||||
}
|
||||
|
||||
export class UpstashRedisStorage implements IStorage {
|
||||
private client: Redis;
|
||||
private _client: Redis;
|
||||
client: any;
|
||||
|
||||
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
|
||||
): Promise<PlayRecord | null> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.hget(this.prHashKey(userName), key)
|
||||
this._client.hget(this.prHashKey(userName), key)
|
||||
);
|
||||
return val ? (val as PlayRecord) : null;
|
||||
}
|
||||
@@ -89,14 +110,14 @@ export class UpstashRedisStorage implements IStorage {
|
||||
key: string,
|
||||
record: PlayRecord
|
||||
): Promise<void> {
|
||||
await withRetry(() => this.client.hset(this.prHashKey(userName), { [key]: record }));
|
||||
await withRetry(() => this._client.hset(this.prHashKey(userName), { [key]: record }));
|
||||
}
|
||||
|
||||
async getAllPlayRecords(
|
||||
userName: string
|
||||
): Promise<Record<string, PlayRecord>> {
|
||||
const hashData = await withRetry(() =>
|
||||
this.client.hgetall(this.prHashKey(userName))
|
||||
this._client.hgetall(this.prHashKey(userName))
|
||||
);
|
||||
|
||||
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> {
|
||||
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
|
||||
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) {
|
||||
console.log(`用户 ${userName} 没有旧的播放记录,标记为已迁移`);
|
||||
// 即使没有数据也标记为已迁移
|
||||
await withRetry(() =>
|
||||
this.client.hset(this.userInfoKey(userName), { playrecord_migrated: true })
|
||||
this._client.hset(this.userInfoKey(userName), { playrecord_migrated: true })
|
||||
);
|
||||
// 清除用户信息缓存
|
||||
userInfoCache?.delete(userName);
|
||||
@@ -228,7 +249,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
// 3. 批量获取旧数据并转换为hash格式
|
||||
const hashData: Record<string, any> = {};
|
||||
for (const fullKey of oldKeys) {
|
||||
const value = await withRetry(() => this.client.get(fullKey));
|
||||
const value = await withRetry(() => this._client.get(fullKey));
|
||||
if (value) {
|
||||
// 提取 source+id 部分作为hash的field
|
||||
const keyPart = ensureString(fullKey.replace(`u:${userName}:pr:`, ''));
|
||||
@@ -239,18 +260,18 @@ export class UpstashRedisStorage implements IStorage {
|
||||
// 4. 写入新的hash结构
|
||||
if (Object.keys(hashData).length > 0) {
|
||||
await withRetry(() =>
|
||||
this.client.hset(this.prHashKey(userName), hashData)
|
||||
this._client.hset(this.prHashKey(userName), hashData)
|
||||
);
|
||||
console.log(`成功迁移 ${Object.keys(hashData).length} 条播放记录到hash结构`);
|
||||
}
|
||||
|
||||
// 5. 删除旧的key
|
||||
await withRetry(() => this.client.del(...oldKeys));
|
||||
await withRetry(() => this._client.del(...oldKeys));
|
||||
console.log(`删除了 ${oldKeys.length} 个旧的播放记录key`);
|
||||
|
||||
// 6. 标记迁移完成
|
||||
await withRetry(() =>
|
||||
this.client.hset(this.userInfoKey(userName), { playrecord_migrated: true })
|
||||
this._client.hset(this.userInfoKey(userName), { playrecord_migrated: true })
|
||||
);
|
||||
|
||||
// 7. 清除用户信息缓存,确保下次获取时能读取到最新的迁移标识
|
||||
@@ -271,7 +292,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
|
||||
async getFavorite(userName: string, key: string): Promise<Favorite | null> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.hget(this.favHashKey(userName), key)
|
||||
this._client.hget(this.favHashKey(userName), key)
|
||||
);
|
||||
return val ? (val as Favorite) : null;
|
||||
}
|
||||
@@ -281,12 +302,12 @@ export class UpstashRedisStorage implements IStorage {
|
||||
key: string,
|
||||
favorite: Favorite
|
||||
): 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>> {
|
||||
const hashData = await withRetry(() =>
|
||||
this.client.hgetall(this.favHashKey(userName))
|
||||
this._client.hgetall(this.favHashKey(userName))
|
||||
);
|
||||
|
||||
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> {
|
||||
await withRetry(() => this.client.hdel(this.favHashKey(userName), key));
|
||||
await withRetry(() => this._client.hdel(this.favHashKey(userName), key));
|
||||
}
|
||||
|
||||
// 迁移收藏:从旧的多key结构迁移到新的hash结构
|
||||
@@ -339,13 +360,13 @@ export class UpstashRedisStorage implements IStorage {
|
||||
|
||||
// 2. 获取旧结构的所有收藏key
|
||||
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) {
|
||||
console.log(`用户 ${userName} 没有旧的收藏,标记为已迁移`);
|
||||
// 即使没有数据也标记为已迁移
|
||||
await withRetry(() =>
|
||||
this.client.hset(this.userInfoKey(userName), { favorite_migrated: true })
|
||||
this._client.hset(this.userInfoKey(userName), { favorite_migrated: true })
|
||||
);
|
||||
// 清除用户信息缓存
|
||||
userInfoCache?.delete(userName);
|
||||
@@ -357,7 +378,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
// 3. 批量获取旧数据并转换为hash格式
|
||||
const hashData: Record<string, any> = {};
|
||||
for (const fullKey of oldKeys) {
|
||||
const value = await withRetry(() => this.client.get(fullKey));
|
||||
const value = await withRetry(() => this._client.get(fullKey));
|
||||
if (value) {
|
||||
// 提取 source+id 部分作为hash的field
|
||||
const keyPart = ensureString(fullKey.replace(`u:${userName}:fav:`, ''));
|
||||
@@ -368,18 +389,18 @@ export class UpstashRedisStorage implements IStorage {
|
||||
// 4. 写入新的hash结构
|
||||
if (Object.keys(hashData).length > 0) {
|
||||
await withRetry(() =>
|
||||
this.client.hset(this.favHashKey(userName), hashData)
|
||||
this._client.hset(this.favHashKey(userName), hashData)
|
||||
);
|
||||
console.log(`成功迁移 ${Object.keys(hashData).length} 条收藏到hash结构`);
|
||||
}
|
||||
|
||||
// 5. 删除旧的key
|
||||
await withRetry(() => this.client.del(...oldKeys));
|
||||
await withRetry(() => this._client.del(...oldKeys));
|
||||
console.log(`删除了 ${oldKeys.length} 个旧的收藏key`);
|
||||
|
||||
// 6. 标记迁移完成
|
||||
await withRetry(() =>
|
||||
this.client.hset(this.userInfoKey(userName), { favorite_migrated: true })
|
||||
this._client.hset(this.userInfoKey(userName), { favorite_migrated: true })
|
||||
);
|
||||
|
||||
// 7. 清除用户信息缓存,确保下次获取时能读取到最新的迁移标识
|
||||
@@ -395,7 +416,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
|
||||
async verifyUser(userName: string, password: string): Promise<boolean> {
|
||||
const stored = await withRetry(() =>
|
||||
this.client.get(this.userPwdKey(userName))
|
||||
this._client.get(this.userPwdKey(userName))
|
||||
);
|
||||
if (stored === null) return false;
|
||||
// 确保比较时都是字符串类型
|
||||
@@ -406,7 +427,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
async checkUserExist(userName: string): Promise<boolean> {
|
||||
// 使用 EXISTS 判断 key 是否存在
|
||||
const exists = await withRetry(() =>
|
||||
this.client.exists(this.userPwdKey(userName))
|
||||
this._client.exists(this.userPwdKey(userName))
|
||||
);
|
||||
return exists === 1;
|
||||
}
|
||||
@@ -415,52 +436,52 @@ export class UpstashRedisStorage implements IStorage {
|
||||
async changePassword(userName: string, newPassword: string): Promise<void> {
|
||||
// 简单存储明文密码,生产环境应加密
|
||||
await withRetry(() =>
|
||||
this.client.set(this.userPwdKey(userName), newPassword)
|
||||
this._client.set(this.userPwdKey(userName), newPassword)
|
||||
);
|
||||
}
|
||||
|
||||
// 删除用户及其所有数据
|
||||
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结构)
|
||||
await withRetry(() => this.client.del(this.prHashKey(userName)));
|
||||
await withRetry(() => this._client.del(this.prHashKey(userName)));
|
||||
|
||||
// 删除旧的播放记录key(如果有)
|
||||
const playRecordPattern = `u:${userName}:pr:*`;
|
||||
const playRecordKeys = await withRetry(() =>
|
||||
this.client.keys(playRecordPattern)
|
||||
this._client.keys(playRecordPattern)
|
||||
);
|
||||
if (playRecordKeys.length > 0) {
|
||||
await withRetry(() => this.client.del(...playRecordKeys));
|
||||
await withRetry(() => this._client.del(...playRecordKeys));
|
||||
}
|
||||
|
||||
// 删除收藏夹(新hash结构)
|
||||
await withRetry(() => this.client.del(this.favHashKey(userName)));
|
||||
await withRetry(() => this._client.del(this.favHashKey(userName)));
|
||||
|
||||
// 删除旧的收藏key(如果有)
|
||||
const favoritePattern = `u:${userName}:fav:*`;
|
||||
const favoriteKeys = await withRetry(() =>
|
||||
this.client.keys(favoritePattern)
|
||||
this._client.keys(favoritePattern)
|
||||
);
|
||||
if (favoriteKeys.length > 0) {
|
||||
await withRetry(() => this.client.del(...favoriteKeys));
|
||||
await withRetry(() => this._client.del(...favoriteKeys));
|
||||
}
|
||||
|
||||
// 删除跳过片头片尾配置(新hash结构)
|
||||
await withRetry(() => this.client.del(this.skipHashKey(userName)));
|
||||
await withRetry(() => this._client.del(this.skipHashKey(userName)));
|
||||
|
||||
// 删除旧的跳过配置key(如果有)
|
||||
const skipConfigPattern = `u:${userName}:skip:*`;
|
||||
const skipConfigKeys = await withRetry(() =>
|
||||
this.client.keys(skipConfigPattern)
|
||||
this._client.keys(skipConfigPattern)
|
||||
);
|
||||
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> {
|
||||
// 先检查用户是否已存在(原子性检查)
|
||||
const exists = await withRetry(() =>
|
||||
this.client.exists(this.userInfoKey(userName))
|
||||
this._client.exists(this.userInfoKey(userName))
|
||||
);
|
||||
if (exists === 1) {
|
||||
throw new Error('用户已存在');
|
||||
@@ -521,17 +542,17 @@ export class UpstashRedisStorage implements IStorage {
|
||||
if (oidcSub) {
|
||||
userInfo.oidcSub = oidcSub;
|
||||
// 创建OIDC映射
|
||||
await withRetry(() => this.client.set(this.oidcSubKey(oidcSub), userName));
|
||||
await withRetry(() => this._client.set(this.oidcSubKey(oidcSub), userName));
|
||||
}
|
||||
|
||||
if (enabledApis && enabledApis.length > 0) {
|
||||
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,按注册时间排序)
|
||||
await withRetry(() => this.client.zadd(this.userListKey(), {
|
||||
await withRetry(() => this._client.zadd(this.userListKey(), {
|
||||
score: createdAt,
|
||||
member: userName,
|
||||
}));
|
||||
@@ -546,7 +567,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
// 验证用户密码(新版本)
|
||||
async verifyUserV2(userName: string, password: string): Promise<boolean> {
|
||||
const userInfo = await withRetry(() =>
|
||||
this.client.hgetall(this.userInfoKey(userName))
|
||||
this._client.hgetall(this.userInfoKey(userName))
|
||||
);
|
||||
|
||||
if (!userInfo || !userInfo.password) {
|
||||
@@ -568,6 +589,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
playrecord_migrated?: boolean;
|
||||
favorite_migrated?: boolean;
|
||||
skip_migrated?: boolean;
|
||||
last_movie_request_time?: number;
|
||||
} | null> {
|
||||
// 先从缓存获取
|
||||
const cached = userInfoCache?.get(userName);
|
||||
@@ -576,7 +598,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
}
|
||||
|
||||
const userInfo = await withRetry(() =>
|
||||
this.client.hgetall(this.userInfoKey(userName))
|
||||
this._client.hgetall(this.userInfoKey(userName))
|
||||
);
|
||||
|
||||
if (!userInfo || Object.keys(userInfo).length === 0) {
|
||||
@@ -661,6 +683,11 @@ export class UpstashRedisStorage implements IStorage {
|
||||
playrecord_migrated,
|
||||
favorite_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);
|
||||
} else {
|
||||
// 删除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);
|
||||
} else {
|
||||
// 删除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);
|
||||
if (oldInfo?.oidcSub && oldInfo.oidcSub !== updates.oidcSub) {
|
||||
// 删除旧的OIDC映射
|
||||
await withRetry(() => this.client.del(this.oidcSubKey(oldInfo.oidcSub!)));
|
||||
await withRetry(() => this._client.del(this.oidcSubKey(oldInfo.oidcSub!)));
|
||||
}
|
||||
userInfo.oidcSub = updates.oidcSub;
|
||||
// 创建新的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) {
|
||||
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> {
|
||||
const hashedPassword = await this.hashPassword(newPassword);
|
||||
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> {
|
||||
const exists = await withRetry(() =>
|
||||
this.client.exists(this.userInfoKey(userName))
|
||||
this._client.exists(this.userInfoKey(userName))
|
||||
);
|
||||
return exists === 1;
|
||||
}
|
||||
@@ -750,7 +777,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
// 通过OIDC Sub查找用户名
|
||||
async getUserByOidcSub(oidcSub: string): Promise<string | null> {
|
||||
const userName = await withRetry(() =>
|
||||
this.client.get(this.oidcSubKey(oidcSub))
|
||||
this._client.get(this.oidcSubKey(oidcSub))
|
||||
);
|
||||
return userName ? ensureString(userName) : null;
|
||||
}
|
||||
@@ -763,7 +790,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
let cursor: number | string = 0;
|
||||
do {
|
||||
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];
|
||||
@@ -771,7 +798,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
|
||||
// 检查每个用户的 tags
|
||||
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) {
|
||||
const tags = JSON.parse(userInfo.tags as string);
|
||||
if (tags.includes(tagName)) {
|
||||
@@ -804,7 +831,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
total: number;
|
||||
}> {
|
||||
// 获取总数
|
||||
let total = await withRetry(() => this.client.zcard(this.userListKey()));
|
||||
let total = await withRetry(() => this._client.zcard(this.userListKey()));
|
||||
|
||||
// 检查站长是否在数据库中(使用缓存)
|
||||
let ownerInfo = null;
|
||||
@@ -851,7 +878,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
|
||||
// 获取用户列表(按注册时间升序)
|
||||
const usernames = await withRetry(() =>
|
||||
this.client.zrange(this.userListKey(), actualOffset, actualOffset + actualLimit - 1)
|
||||
this._client.zrange(this.userListKey(), actualOffset, actualOffset + actualLimit - 1)
|
||||
);
|
||||
|
||||
const users = [];
|
||||
@@ -902,14 +929,14 @@ export class UpstashRedisStorage implements IStorage {
|
||||
|
||||
// 删除OIDC映射
|
||||
if (userInfo?.oidcSub) {
|
||||
await withRetry(() => this.client.del(this.oidcSubKey(userInfo.oidcSub!)));
|
||||
await withRetry(() => this._client.del(this.oidcSubKey(userInfo.oidcSub!)));
|
||||
}
|
||||
|
||||
// 删除用户信息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);
|
||||
@@ -925,7 +952,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
|
||||
async getSearchHistory(userName: string): Promise<string[]> {
|
||||
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[]);
|
||||
@@ -934,19 +961,19 @@ export class UpstashRedisStorage implements IStorage {
|
||||
async addSearchHistory(userName: string, keyword: string): Promise<void> {
|
||||
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> {
|
||||
const key = this.shKey(userName);
|
||||
if (keyword) {
|
||||
await withRetry(() => this.client.lrem(key, 0, ensureString(keyword)));
|
||||
await withRetry(() => this._client.lrem(key, 0, ensureString(keyword)));
|
||||
} 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 users = await withRetry(() =>
|
||||
this.client.zrange(userListKey, 0, -1)
|
||||
this._client.zrange(userListKey, 0, -1)
|
||||
);
|
||||
const userList = users.map(u => ensureString(u));
|
||||
|
||||
@@ -974,12 +1001,12 @@ export class UpstashRedisStorage implements IStorage {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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> {
|
||||
const key = `${source}+${id}`;
|
||||
const val = await withRetry(() =>
|
||||
this.client.hget(this.skipHashKey(userName), key)
|
||||
this._client.hget(this.skipHashKey(userName), key)
|
||||
);
|
||||
return val ? (val as SkipConfig) : null;
|
||||
}
|
||||
@@ -1011,7 +1038,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
): Promise<void> {
|
||||
const key = `${source}+${id}`;
|
||||
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> {
|
||||
const key = `${source}+${id}`;
|
||||
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
|
||||
): Promise<{ [key: string]: SkipConfig }> {
|
||||
const hashData = await withRetry(() =>
|
||||
this.client.hgetall<Record<string, SkipConfig>>(this.skipHashKey(userName))
|
||||
this._client.hgetall<Record<string, SkipConfig>>(this.skipHashKey(userName))
|
||||
);
|
||||
|
||||
return hashData || {};
|
||||
@@ -1065,18 +1092,18 @@ export class UpstashRedisStorage implements IStorage {
|
||||
}
|
||||
|
||||
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) {
|
||||
console.log(`用户 ${userName} 没有旧的跳过配置,标记为已迁移`);
|
||||
await withRetry(() =>
|
||||
this.client.hset(this.userInfoKey(userName), { skip_migrated: 'true' })
|
||||
this._client.hset(this.userInfoKey(userName), { skip_migrated: 'true' })
|
||||
);
|
||||
userInfoCache?.delete(userName);
|
||||
return;
|
||||
}
|
||||
|
||||
const values = await withRetry(() => this.client.mget(oldKeys));
|
||||
const values = await withRetry(() => this._client.mget(oldKeys));
|
||||
|
||||
const hashData: Record<string, SkipConfig> = {};
|
||||
oldKeys.forEach((key, index) => {
|
||||
@@ -1092,16 +1119,16 @@ export class UpstashRedisStorage implements IStorage {
|
||||
|
||||
if (Object.keys(hashData).length > 0) {
|
||||
await withRetry(() =>
|
||||
this.client.hset(this.skipHashKey(userName), hashData)
|
||||
this._client.hset(this.skipHashKey(userName), hashData)
|
||||
);
|
||||
console.log(`成功迁移 ${Object.keys(hashData).length} 条跳过配置到hash结构`);
|
||||
}
|
||||
|
||||
await withRetry(() => this.client.del(...oldKeys));
|
||||
await withRetry(() => this._client.del(...oldKeys));
|
||||
console.log(`删除了 ${oldKeys.length} 个旧的跳过配置key`);
|
||||
|
||||
await withRetry(() =>
|
||||
this.client.hset(this.userInfoKey(userName), { skip_migrated: 'true' })
|
||||
this._client.hset(this.userInfoKey(userName), { skip_migrated: 'true' })
|
||||
);
|
||||
userInfoCache?.delete(userName);
|
||||
|
||||
@@ -1113,7 +1140,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
userName: string
|
||||
): Promise<import('./types').DanmakuFilterConfig | null> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.get(this.danmakuFilterConfigKey(userName))
|
||||
this._client.get(this.danmakuFilterConfigKey(userName))
|
||||
);
|
||||
return val ? (val as import('./types').DanmakuFilterConfig) : null;
|
||||
}
|
||||
@@ -1123,13 +1150,13 @@ export class UpstashRedisStorage implements IStorage {
|
||||
config: import('./types').DanmakuFilterConfig
|
||||
): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this.client.set(this.danmakuFilterConfigKey(userName), config)
|
||||
this._client.set(this.danmakuFilterConfigKey(userName), config)
|
||||
);
|
||||
}
|
||||
|
||||
async deleteDanmakuFilterConfig(userName: string): Promise<void> {
|
||||
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('所有数据已清空');
|
||||
} catch (error) {
|
||||
@@ -1161,7 +1188,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
|
||||
async getGlobalValue(key: string): Promise<string | null> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.get(this.globalValueKey(key))
|
||||
this._client.get(this.globalValueKey(key))
|
||||
);
|
||||
// Upstash 会自动反序列化 JSON,如果值是对象,需要重新序列化为字符串
|
||||
if (val === null) return null;
|
||||
@@ -1172,12 +1199,12 @@ export class UpstashRedisStorage implements IStorage {
|
||||
|
||||
async setGlobalValue(key: string, value: string): Promise<void> {
|
||||
await withRetry(() =>
|
||||
this.client.set(this.globalValueKey(key), value)
|
||||
this._client.set(this.globalValueKey(key), value)
|
||||
);
|
||||
}
|
||||
|
||||
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[]> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.get(this.notificationsKey(userName))
|
||||
this._client.get(this.notificationsKey(userName))
|
||||
);
|
||||
return val ? (val as import('./types').Notification[]) : [];
|
||||
}
|
||||
@@ -1207,7 +1234,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
notifications.splice(100);
|
||||
}
|
||||
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) {
|
||||
notification.read = true;
|
||||
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 filtered = notifications.filter((n) => n.id !== notificationId);
|
||||
await withRetry(() =>
|
||||
this.client.set(this.notificationsKey(userName), filtered)
|
||||
this._client.set(this.notificationsKey(userName), filtered)
|
||||
);
|
||||
}
|
||||
|
||||
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> {
|
||||
@@ -1247,7 +1274,7 @@ export class UpstashRedisStorage implements IStorage {
|
||||
|
||||
async getLastFavoriteCheckTime(userName: string): Promise<number> {
|
||||
const val = await withRetry(() =>
|
||||
this.client.get(this.lastFavoriteCheckKey(userName))
|
||||
this._client.get(this.lastFavoriteCheckKey(userName))
|
||||
);
|
||||
return val ? (val as number) : 0;
|
||||
}
|
||||
@@ -1257,9 +1284,57 @@ export class UpstashRedisStorage implements IStorage {
|
||||
timestamp: number
|
||||
): Promise<void> {
|
||||
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 客户端
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any,no-console */
|
||||
import he from 'he';
|
||||
import Hls from 'hls.js';
|
||||
import bs58 from 'bs58';
|
||||
|
||||
function getDoubanImageProxyConfig(): {
|
||||
proxyType:
|
||||
@@ -135,12 +136,13 @@ export function processVideoUrl(originalUrl: string): string {
|
||||
/**
|
||||
* 从m3u8地址获取视频质量等级和网络信息
|
||||
* @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<{
|
||||
quality: string; // 如720p、1080p等
|
||||
loadSpeed: string; // 自动转换为KB/s或MB/s
|
||||
pingTime: number; // 网络延迟(毫秒)
|
||||
bitrate: string; // 视频码率(如 "2.5 Mbps")
|
||||
}> {
|
||||
try {
|
||||
// 直接使用m3u8 URL作为视频源,避免CORS问题
|
||||
@@ -182,6 +184,7 @@ export async function getVideoResolutionFromM3u8(m3u8Url: string): Promise<{
|
||||
let actualLoadSpeed = '未知';
|
||||
let hasSpeedCalculated = false;
|
||||
let hasMetadataLoaded = false;
|
||||
let estimatedBitrate = 0; // 估算的码率(bps)
|
||||
|
||||
let fragmentStartTime = 0;
|
||||
|
||||
@@ -211,17 +214,32 @@ export async function getVideoResolutionFromM3u8(m3u8Url: string): Promise<{
|
||||
? '480p'
|
||||
: 'SD'; // 480p: 854x480
|
||||
|
||||
// 格式化码率
|
||||
const bitrateStr = estimatedBitrate > 0
|
||||
? estimatedBitrate >= 1000000
|
||||
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
|
||||
: `${Math.round(estimatedBitrate / 1000)} Kbps`
|
||||
: '未知';
|
||||
|
||||
resolve({
|
||||
quality,
|
||||
loadSpeed: actualLoadSpeed,
|
||||
pingTime: Math.round(pingTime),
|
||||
bitrate: bitrateStr,
|
||||
});
|
||||
} else {
|
||||
// webkit 无法获取尺寸,直接返回
|
||||
const bitrateStr = estimatedBitrate > 0
|
||||
? estimatedBitrate >= 1000000
|
||||
? `${(estimatedBitrate / 1000000).toFixed(1)} Mbps`
|
||||
: `${Math.round(estimatedBitrate / 1000)} Kbps`
|
||||
: '未知';
|
||||
|
||||
resolve({
|
||||
quality: '未知',
|
||||
loadSpeed: actualLoadSpeed,
|
||||
pingTime: Math.round(pingTime),
|
||||
bitrate: bitrateStr,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -255,6 +273,18 @@ export async function getVideoResolutionFromM3u8(m3u8Url: string): Promise<{
|
||||
actualLoadSpeed = `${avgSpeedKBps.toFixed(1)} KB/s`;
|
||||
}
|
||||
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(); // 尝试返回结果
|
||||
}
|
||||
}
|
||||
@@ -301,3 +331,43 @@ export function cleanHtmlTags(text: string): string {
|
||||
// 使用 he 库解码 HTML 实体
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -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特殊处理) - 最优先
|
||||
{ pattern: /OVA\s*(\d+(?:\.\d+)?)/i, isOVA: true },
|
||||
// S01E01, s01e01, S01E01.5 (支持小数) - 最具体
|
||||
{ pattern: /[Ss]\d+[Ee](\d+(?:\.\d+)?)/ },
|
||||
// S01E01, s01e01, S01E1234, S01E01.5 (支持1-4位数字和小数) - 最具体
|
||||
{ pattern: /[Ss](\d+)[Ee](\d{1,4}(?:\.\d+)?)/, extractSeason: true },
|
||||
// [01], (01), [01.5], (01.5) (支持小数,但要排除中文括号内容) - 很具体
|
||||
{ pattern: /[\[\(](\d+(?:\.\d+)?)[\]\)]/ },
|
||||
// E01, E1, e01, e1, E01.5 (支持小数)
|
||||
@@ -45,12 +45,22 @@ export function parseVideoFileName(fileName: string): ParsedVideoInfo {
|
||||
{ pattern: /^(\d+(?:\.\d+)?)[^\d.]/ },
|
||||
];
|
||||
|
||||
for (const { pattern, isOVA } of patterns) {
|
||||
for (const { pattern, isOVA, extractSeason } of patterns) {
|
||||
const match = fileName.match(pattern);
|
||||
if (match && match[1]) {
|
||||
const episode = parseFloat(match[1]);
|
||||
if (episode > 0 && episode < 10000) { // 合理的集数范围
|
||||
return { episode, isOVA };
|
||||
if (extractSeason && match[2]) {
|
||||
// 同时提取 season 和 episode
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
335
src/lib/xiaoya-metadata.ts
Normal file
335
src/lib/xiaoya-metadata.ts
Normal file
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
244
src/lib/xiaoya.client.ts
Normal file
244
src/lib/xiaoya.client.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,6 @@ function shouldSkipAuth(pathname: string): boolean {
|
||||
// 配置middleware匹配规则
|
||||
export const config = {
|
||||
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).*)',
|
||||
],
|
||||
};
|
||||
|
||||
8
src/types/opencc-js.d.ts
vendored
Normal file
8
src/types/opencc-js.d.ts
vendored
Normal file
@@ -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