Merge branch 'dev'

This commit is contained in:
mtvpls
2026-01-13 20:05:30 +08:00
54 changed files with 4442 additions and 389 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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.jpgbackground.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'>
36001
</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配置标签 */}

View File

@@ -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;
}

View File

@@ -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,
}));

View File

@@ -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,

View File

@@ -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 }
);
}

View 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 }
);
}
}

View File

@@ -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(

View File

@@ -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);
}
}

View 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 }
);
}
}

View File

@@ -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源列表

View 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 }
);
}
}

View 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 }
);
}
}

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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,

View File

@@ -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);

View File

@@ -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(

View File

@@ -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,

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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 {

View File

@@ -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, {

View 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 }
);
}
}

View 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 }
);
}
}

View 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 }
);
}
}

View File

@@ -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 (

View 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>
);
}

View File

@@ -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的映射到localStorage1个月
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) {

View File

@@ -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'>

View File

@@ -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);
}
};

View File

@@ -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();

View File

@@ -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;
}

View File

@@ -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' ||

View File

@@ -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>

View File

@@ -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',

View File

@@ -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
View 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);
});
});
}

View File

@@ -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 缓存操作

View File

@@ -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();

View File

@@ -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 {

View File

@@ -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));
}
}

View File

@@ -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;
}

View File

@@ -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 客户端

View File

@@ -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');
}

View File

@@ -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
View 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
View 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;
}
}
}

View File

@@ -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
View File

@@ -0,0 +1,8 @@
declare module 'opencc-js' {
interface ConverterOptions {
from: string;
to: string;
}
export function Converter(options: ConverterOptions): (text: string) => string;
}