emby支持多源
This commit is contained in:
@@ -3399,7 +3399,7 @@ const OpenListConfigComponent = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Emby 媒体库配置组件
|
||||
// Emby 媒体库配置组件 - 多源管理版本
|
||||
const EmbyConfigComponent = ({
|
||||
config,
|
||||
refreshConfig,
|
||||
@@ -3409,67 +3409,209 @@ const EmbyConfigComponent = ({
|
||||
}) => {
|
||||
const { alertModal, showAlert, hideAlert } = useAlertModal();
|
||||
const { isLoading, withLoading } = useLoadingState();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [serverURL, setServerURL] = useState('');
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [userId, setUserId] = useState('');
|
||||
|
||||
// 源列表状态
|
||||
const [sources, setSources] = useState<any[]>([]);
|
||||
const [editingSource, setEditingSource] = useState<any | null>(null);
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
|
||||
// 表单状态
|
||||
const [formData, setFormData] = useState({
|
||||
key: '',
|
||||
name: '',
|
||||
enabled: true,
|
||||
ServerURL: '',
|
||||
ApiKey: '',
|
||||
Username: '',
|
||||
Password: '',
|
||||
UserId: '',
|
||||
isDefault: false,
|
||||
});
|
||||
|
||||
// 从配置加载源列表
|
||||
useEffect(() => {
|
||||
if (config?.EmbyConfig) {
|
||||
setEnabled(config.EmbyConfig.Enabled || false);
|
||||
setServerURL(config.EmbyConfig.ServerURL || '');
|
||||
setApiKey(config.EmbyConfig.ApiKey || '');
|
||||
setUsername(config.EmbyConfig.Username || '');
|
||||
setPassword(config.EmbyConfig.Password || '');
|
||||
setUserId(config.EmbyConfig.UserId || '');
|
||||
if (config?.EmbyConfig?.Sources) {
|
||||
setSources(config.EmbyConfig.Sources);
|
||||
} else if (config?.EmbyConfig?.ServerURL) {
|
||||
// 兼容旧格式
|
||||
setSources([{
|
||||
key: 'default',
|
||||
name: 'Emby',
|
||||
enabled: config.EmbyConfig.Enabled || false,
|
||||
ServerURL: config.EmbyConfig.ServerURL,
|
||||
ApiKey: config.EmbyConfig.ApiKey,
|
||||
Username: config.EmbyConfig.Username,
|
||||
Password: config.EmbyConfig.Password,
|
||||
UserId: config.EmbyConfig.UserId,
|
||||
isDefault: true,
|
||||
}]);
|
||||
}
|
||||
}, [config]);
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
key: '',
|
||||
name: '',
|
||||
enabled: true,
|
||||
ServerURL: '',
|
||||
ApiKey: '',
|
||||
Username: '',
|
||||
Password: '',
|
||||
UserId: '',
|
||||
isDefault: false,
|
||||
});
|
||||
setEditingSource(null);
|
||||
setShowAddForm(false);
|
||||
};
|
||||
|
||||
// 开始编辑
|
||||
const handleEdit = (source: any) => {
|
||||
setFormData({ ...source });
|
||||
setEditingSource(source);
|
||||
setShowAddForm(false);
|
||||
};
|
||||
|
||||
// 开始添加
|
||||
const handleAdd = () => {
|
||||
resetForm();
|
||||
setShowAddForm(true);
|
||||
};
|
||||
|
||||
// 保存源(添加或更新)
|
||||
const handleSave = async () => {
|
||||
await withLoading('saveEmby', async () => {
|
||||
// 验证必填字段
|
||||
if (!formData.key || !formData.name || !formData.ServerURL) {
|
||||
showError('请填写必填字段:标识符、名称、服务器地址', showAlert);
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证key唯一性
|
||||
if (!editingSource && sources.some(s => s.key === formData.key)) {
|
||||
showError('标识符已存在,请使用其他标识符', showAlert);
|
||||
return;
|
||||
}
|
||||
|
||||
await withLoading('saveEmbySource', async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/emby', {
|
||||
let newSources;
|
||||
if (editingSource) {
|
||||
// 更新现有源
|
||||
newSources = sources.map(s =>
|
||||
s.key === editingSource.key ? formData : s
|
||||
);
|
||||
} else {
|
||||
// 添加新源
|
||||
newSources = [...sources, formData];
|
||||
}
|
||||
|
||||
// 保存到配置
|
||||
const response = await fetch('/api/admin/config', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'save',
|
||||
Enabled: enabled,
|
||||
ServerURL: serverURL,
|
||||
ApiKey: apiKey,
|
||||
Username: username,
|
||||
Password: password,
|
||||
UserId: userId,
|
||||
...config,
|
||||
EmbyConfig: {
|
||||
Sources: newSources,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || '保存失败');
|
||||
throw new Error('保存失败');
|
||||
}
|
||||
|
||||
await refreshConfig();
|
||||
showSuccess('保存成功', showAlert);
|
||||
resetForm();
|
||||
showSuccess(editingSource ? '更新成功' : '添加成功', showAlert);
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '保存失败', showAlert);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
await withLoading('testEmby', async () => {
|
||||
// 删除源
|
||||
const handleDelete = async (source: any) => {
|
||||
if (sources.length === 1) {
|
||||
showError('至少需要保留一个Emby源', showAlert);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`确定要删除 "${source.name}" 吗?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await withLoading('deleteEmbySource', async () => {
|
||||
try {
|
||||
const newSources = sources.filter(s => s.key !== source.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();
|
||||
showSuccess('删除成功', showAlert);
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '删除失败', showAlert);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 切换启用状态
|
||||
const handleToggleEnabled = async (source: any) => {
|
||||
await withLoading('toggleEmbySource', async () => {
|
||||
try {
|
||||
const newSources = sources.map(s =>
|
||||
s.key === source.key ? { ...s, enabled: !s.enabled } : 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();
|
||||
showSuccess(source.enabled ? '已禁用' : '已启用', showAlert);
|
||||
} catch (error) {
|
||||
showError(error instanceof Error ? error.message : '更新失败', showAlert);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// 测试连接
|
||||
const handleTest = async (source: any) => {
|
||||
await withLoading('testEmbySource', async () => {
|
||||
try {
|
||||
const response = await fetch('/api/admin/emby', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: 'test',
|
||||
ServerURL: serverURL,
|
||||
ApiKey: apiKey,
|
||||
Username: username,
|
||||
Password: password,
|
||||
ServerURL: source.ServerURL,
|
||||
ApiKey: source.ApiKey,
|
||||
Username: source.Username,
|
||||
Password: source.Password,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -3486,6 +3628,7 @@ const EmbyConfigComponent = ({
|
||||
});
|
||||
};
|
||||
|
||||
// 清除缓存
|
||||
const handleClearCache = async () => {
|
||||
await withLoading('clearEmbyCache', async () => {
|
||||
try {
|
||||
@@ -3523,127 +3666,280 @@ const EmbyConfigComponent = ({
|
||||
onConfirm={alertModal.onConfirm}
|
||||
/>
|
||||
|
||||
{/* 启用开关 */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<label className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
启用 Emby 媒体库
|
||||
</label>
|
||||
<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 className='space-y-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<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>
|
||||
|
||||
{sources.length === 0 ? (
|
||||
<div className='text-center py-8 text-gray-500 dark:text-gray-400'>
|
||||
暂无Emby源,点击"添加新源"开始配置
|
||||
</div>
|
||||
) : (
|
||||
sources.map((source) => (
|
||||
<div
|
||||
key={source.key}
|
||||
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'>
|
||||
默认
|
||||
</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 && (
|
||||
<p className='mt-1 text-sm text-gray-600 dark:text-gray-400'>
|
||||
用户ID: {source.UserId}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex gap-2 flex-wrap sm:flex-nowrap'>
|
||||
<button
|
||||
onClick={() => handleToggleEnabled(source)}
|
||||
disabled={isLoading('toggleEmbySource')}
|
||||
className={source.enabled ? buttonStyles.warningSmall : buttonStyles.successSmall}
|
||||
>
|
||||
{source.enabled ? '禁用' : '启用'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleTest(source)}
|
||||
disabled={isLoading('testEmbySource')}
|
||||
className={buttonStyles.primarySmall}
|
||||
>
|
||||
测试
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(source)}
|
||||
className={buttonStyles.primarySmall}
|
||||
>
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(source)}
|
||||
disabled={isLoading('deleteEmbySource')}
|
||||
className={buttonStyles.dangerSmall}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 服务器地址 */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
Emby 服务器地址
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={serverURL}
|
||||
onChange={(e) => setServerURL(e.target.value)}
|
||||
placeholder='http://192.168.1.100:8096'
|
||||
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>
|
||||
{/* 添加/编辑表单 */}
|
||||
{(showAddForm || editingSource) && (
|
||||
<div className='border border-gray-200 dark:border-gray-700 rounded-lg p-6 bg-gray-50 dark:bg-gray-800/50'>
|
||||
<h3 className='text-lg font-medium text-gray-900 dark:text-gray-100 mb-4'>
|
||||
{editingSource ? '编辑 Emby 源' : '添加新的 Emby 源'}
|
||||
</h3>
|
||||
|
||||
{/* API Key */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
API Key(推荐)
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder='输入 Emby API Key'
|
||||
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'>
|
||||
推荐使用 API Key 认证。如果不使用 API Key,请填写下方的用户名和密码。
|
||||
</p>
|
||||
</div>
|
||||
<div className='space-y-4'>
|
||||
{/* 标识符 */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
标识符 *
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={formData.key}
|
||||
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
|
||||
disabled={!!editingSource}
|
||||
placeholder='home, office, etc.'
|
||||
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 disabled:bg-gray-100 dark:disabled:bg-gray-700'
|
||||
/>
|
||||
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
|
||||
唯一标识符,只能包含字母、数字、下划线,创建后不可修改
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 用户名 */}
|
||||
<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='Emby 用户名'
|
||||
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='text'
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder='家庭Emby, 公司Emby, etc.'
|
||||
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='Emby 密码'
|
||||
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'>
|
||||
Emby 服务器地址 *
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={formData.ServerURL}
|
||||
onChange={(e) => setFormData({ ...formData, ServerURL: e.target.value })}
|
||||
placeholder='http://192.168.1.100:8096'
|
||||
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>
|
||||
|
||||
{/* 用户 ID */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
用户 ID(使用 API Key 时必填)
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={userId}
|
||||
onChange={(e) => setUserId(e.target.value)}
|
||||
placeholder='aab507c58e874de6a9bd12388d72f4d2'
|
||||
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'>
|
||||
从你的 Emby 抓包数据中获取用户 ID,通常在 URL 中如 /Users/[userId]/...
|
||||
</p>
|
||||
</div>
|
||||
{/* API Key */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
API Key(推荐)
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
value={formData.ApiKey}
|
||||
onChange={(e) => setFormData({ ...formData, ApiKey: e.target.value })}
|
||||
placeholder='输入 Emby API Key'
|
||||
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'>
|
||||
推荐使用 API Key 认证。如果不使用 API Key,请填写下方的用户名和密码。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className='flex gap-3'>
|
||||
<button
|
||||
onClick={handleTest}
|
||||
disabled={isLoading('testEmby')}
|
||||
className='px-4 py-2 bg-gray-600 hover:bg-gray-700 disabled:bg-gray-400 text-white rounded-lg transition-colors'
|
||||
>
|
||||
{isLoading('testEmby') ? '测试中...' : '测试连接'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isLoading('saveEmby')}
|
||||
className='px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400 text-white rounded-lg transition-colors'
|
||||
>
|
||||
{isLoading('saveEmby') ? '保存中...' : '保存配置'}
|
||||
</button>
|
||||
</div>
|
||||
{/* 用户名 */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
用户名(可选)
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={formData.Username}
|
||||
onChange={(e) => setFormData({ ...formData, Username: e.target.value })}
|
||||
placeholder='Emby 用户名'
|
||||
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='flex gap-3'>
|
||||
{/* 密码 */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
密码(可选)
|
||||
</label>
|
||||
<input
|
||||
type='password'
|
||||
value={formData.Password}
|
||||
onChange={(e) => setFormData({ ...formData, Password: e.target.value })}
|
||||
placeholder='Emby 密码'
|
||||
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>
|
||||
|
||||
{/* 用户 ID */}
|
||||
<div>
|
||||
<label className='block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2'>
|
||||
用户 ID(使用 API Key 时必填)
|
||||
</label>
|
||||
<input
|
||||
type='text'
|
||||
value={formData.UserId}
|
||||
onChange={(e) => setFormData({ ...formData, UserId: e.target.value })}
|
||||
placeholder='aab507c58e874de6a9bd12388d72f4d2'
|
||||
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'>
|
||||
从你的 Emby 抓包数据中获取用户 ID,通常在 URL 中如 /Users/[userId]/...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 启用开关 */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<label className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
启用此源
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setFormData({ ...formData, enabled: !formData.enabled })}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
formData.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 ${
|
||||
formData.enabled ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 默认源开关 */}
|
||||
<div className='flex items-center justify-between'>
|
||||
<label className='text-sm font-medium text-gray-700 dark:text-gray-300'>
|
||||
设为默认源
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setFormData({ ...formData, isDefault: !formData.isDefault })}
|
||||
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
||||
formData.isDefault ? '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 ${
|
||||
formData.isDefault ? 'translate-x-6' : 'translate-x-1'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className='flex gap-3 pt-4'>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isLoading('saveEmbySource')}
|
||||
className={buttonStyles.success}
|
||||
>
|
||||
{isLoading('saveEmbySource') ? '保存中...' : '保存'}
|
||||
</button>
|
||||
<button
|
||||
onClick={resetForm}
|
||||
className={buttonStyles.secondary}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 全局操作 */}
|
||||
<div className='flex gap-3 pt-4 border-t border-gray-200 dark:border-gray-700'>
|
||||
<button
|
||||
onClick={handleClearCache}
|
||||
disabled={isLoading('clearEmbyCache')}
|
||||
className='px-4 py-2 bg-orange-600 hover:bg-orange-700 disabled:bg-orange-400 text-white rounded-lg transition-colors'
|
||||
className={buttonStyles.warning}
|
||||
>
|
||||
{isLoading('clearEmbyCache') ? '清除中...' : '清除缓存'}
|
||||
{isLoading('clearEmbyCache') ? '清除中...' : '清除所有缓存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -78,3 +78,54 @@ export async function GET(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const storageType = process.env.NEXT_PUBLIC_STORAGE_TYPE || 'localstorage';
|
||||
if (storageType === 'localstorage') {
|
||||
return NextResponse.json(
|
||||
{ error: '不支持本地存储进行管理员配置' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const authInfo = getAuthInfoFromCookie(request);
|
||||
if (!authInfo || !authInfo.username) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
const username = authInfo.username;
|
||||
|
||||
try {
|
||||
const newConfig = await request.json();
|
||||
|
||||
// 权限检查
|
||||
if (username !== process.env.USERNAME) {
|
||||
const { db } = await import('@/lib/db');
|
||||
const userInfoV2 = await db.getUserInfoV2(username);
|
||||
|
||||
if (!userInfoV2 || (userInfoV2.role !== 'admin' && userInfoV2.role !== 'owner') || userInfoV2.banned) {
|
||||
return NextResponse.json({ error: '权限不足' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
const { db } = await import('@/lib/db');
|
||||
const { configSelfCheck, setCachedConfig } = await import('@/lib/config');
|
||||
|
||||
// 自检配置
|
||||
const checkedConfig = configSelfCheck(newConfig);
|
||||
|
||||
// 保存到数据库
|
||||
await db.saveAdminConfig(checkedConfig);
|
||||
|
||||
// 更新缓存
|
||||
await setCachedConfig(checkedConfig);
|
||||
|
||||
return NextResponse.json({ success: true, message: '配置已保存' });
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '保存配置失败: ' + (error as Error).message },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,10 +47,9 @@ export async function GET(
|
||||
|
||||
try {
|
||||
const config = await getConfig();
|
||||
const embyConfig = config.EmbyConfig;
|
||||
|
||||
// 验证 Emby 配置
|
||||
if (!embyConfig?.Enabled || !embyConfig.ServerURL) {
|
||||
// 验证 Emby 配置(多源)
|
||||
if (!config.EmbyConfig?.Sources || config.EmbyConfig.Sources.length === 0) {
|
||||
return NextResponse.json({
|
||||
code: 0,
|
||||
msg: 'Emby 未配置或未启用',
|
||||
@@ -62,31 +61,18 @@ export async function GET(
|
||||
});
|
||||
}
|
||||
|
||||
const client = new EmbyClient(embyConfig);
|
||||
// 获取 embyKey 参数
|
||||
const embyKey = searchParams.get('embyKey') || undefined;
|
||||
|
||||
// 如果没有 UserId,需要先认证
|
||||
if (!embyConfig.UserId && embyConfig.Username && embyConfig.Password) {
|
||||
const authResult = await client.authenticate(embyConfig.Username, embyConfig.Password);
|
||||
embyConfig.UserId = authResult.User.Id;
|
||||
}
|
||||
|
||||
if (!embyConfig.UserId) {
|
||||
return NextResponse.json({
|
||||
code: 0,
|
||||
msg: 'Emby 认证失败',
|
||||
page: 1,
|
||||
pagecount: 0,
|
||||
limit: 0,
|
||||
total: 0,
|
||||
list: [],
|
||||
});
|
||||
}
|
||||
// 使用 EmbyManager 获取客户端
|
||||
const { embyManager } = await import('@/lib/emby-manager');
|
||||
const client = await embyManager.getClient(embyKey);
|
||||
|
||||
// 路由处理
|
||||
if (wd) {
|
||||
// 搜索模式
|
||||
if (ac === 'detail') {
|
||||
return await handleDetailBySearch(client, wd, requestToken, request);
|
||||
return await handleDetailBySearch(client, wd, requestToken, embyKey, request);
|
||||
}
|
||||
return await handleSearch(client, wd);
|
||||
} else if (ids || ac === 'detail') {
|
||||
@@ -102,7 +88,7 @@ export async function GET(
|
||||
list: [],
|
||||
});
|
||||
}
|
||||
return await handleDetail(client, ids, requestToken, request);
|
||||
return await handleDetail(client, ids, requestToken, embyKey, request);
|
||||
} else {
|
||||
// 列表模式
|
||||
return await handleSearch(client, '');
|
||||
@@ -161,6 +147,7 @@ async function handleDetailBySearch(
|
||||
client: EmbyClient,
|
||||
query: string,
|
||||
token: string,
|
||||
embyKey: string | undefined,
|
||||
request: NextRequest
|
||||
) {
|
||||
const result = await client.getItems({
|
||||
@@ -183,7 +170,7 @@ async function handleDetailBySearch(
|
||||
});
|
||||
}
|
||||
|
||||
return await handleDetail(client, result.Items[0].Id, token, request);
|
||||
return await handleDetail(client, result.Items[0].Id, token, embyKey, request);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,6 +180,7 @@ async function handleDetail(
|
||||
client: EmbyClient,
|
||||
itemId: string,
|
||||
token: string,
|
||||
embyKey: string | undefined,
|
||||
request: NextRequest
|
||||
) {
|
||||
const item = await client.getItem(itemId);
|
||||
@@ -203,11 +191,12 @@ async function handleDetail(
|
||||
(host?.includes('localhost') || host?.includes('127.0.0.1') ? 'http' : 'https');
|
||||
const baseUrl = process.env.SITE_BASE || `${proto}://${host}`;
|
||||
|
||||
const embyKeyParam = embyKey ? `&embyKey=${embyKey}` : '';
|
||||
let vodPlayUrl = '';
|
||||
|
||||
if (item.Type === 'Movie') {
|
||||
// 电影:单个播放链接(使用代理,添加 .mp4 扩展名)
|
||||
const proxyUrl = `${baseUrl}/api/emby/play/${encodeURIComponent(token)}/video.mp4?itemId=${item.Id}`;
|
||||
const proxyUrl = `${baseUrl}/api/emby/play/${encodeURIComponent(token)}/video.mp4?itemId=${item.Id}${embyKeyParam}`;
|
||||
vodPlayUrl = `正片$${proxyUrl}`;
|
||||
} else if (item.Type === 'Series') {
|
||||
// 剧集:获取所有集
|
||||
@@ -222,7 +211,7 @@ async function handleDetail(
|
||||
})
|
||||
.map((ep) => {
|
||||
const title = `第${ep.IndexNumber}集`;
|
||||
const proxyUrl = `${baseUrl}/api/emby/play/${encodeURIComponent(token)}/video.mp4?itemId=${ep.Id}`;
|
||||
const proxyUrl = `${baseUrl}/api/emby/play/${encodeURIComponent(token)}/video.mp4?itemId=${ep.Id}${embyKeyParam}`;
|
||||
return `${title}$${proxyUrl}`;
|
||||
});
|
||||
|
||||
|
||||
@@ -2,38 +2,22 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { EmbyClient } from '@/lib/emby.client';
|
||||
import { embyManager } from '@/lib/emby-manager';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const itemId = searchParams.get('id');
|
||||
const embyKey = searchParams.get('embyKey') || undefined;
|
||||
|
||||
if (!itemId) {
|
||||
return NextResponse.json({ error: '缺少媒体ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await getConfig();
|
||||
const embyConfig = config.EmbyConfig;
|
||||
|
||||
if (!embyConfig?.Enabled || !embyConfig.ServerURL) {
|
||||
return NextResponse.json({ error: 'Emby 未配置或未启用' }, { status: 400 });
|
||||
}
|
||||
|
||||
const client = new EmbyClient(embyConfig);
|
||||
|
||||
// 如果没有 UserId,需要先认证
|
||||
if (!embyConfig.UserId && embyConfig.Username && embyConfig.Password) {
|
||||
const authResult = await client.authenticate(embyConfig.Username, embyConfig.Password);
|
||||
embyConfig.UserId = authResult.User.Id;
|
||||
}
|
||||
|
||||
if (!embyConfig.UserId) {
|
||||
return NextResponse.json({ error: 'Emby 认证失败' }, { status: 401 });
|
||||
}
|
||||
// 获取Emby客户端
|
||||
const client = await embyManager.getClient(embyKey);
|
||||
|
||||
// 获取媒体详情
|
||||
const item = await client.getItem(itemId);
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { EmbyClient } from '@/lib/emby.client';
|
||||
import { embyManager } from '@/lib/emby-manager';
|
||||
import { getCachedEmbyList, setCachedEmbyList } from '@/lib/emby-cache';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
@@ -13,56 +12,17 @@ export async function GET(request: NextRequest) {
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const pageSize = parseInt(searchParams.get('pageSize') || '20');
|
||||
const parentId = searchParams.get('parentId') || undefined;
|
||||
const embyKey = searchParams.get('embyKey') || undefined;
|
||||
|
||||
try {
|
||||
// 检查缓存
|
||||
const cached = getCachedEmbyList(page, pageSize, parentId);
|
||||
const cached = getCachedEmbyList(page, pageSize, parentId, embyKey);
|
||||
if (cached) {
|
||||
return NextResponse.json(cached);
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const embyConfig = config.EmbyConfig;
|
||||
|
||||
if (!embyConfig?.Enabled || !embyConfig.ServerURL) {
|
||||
return NextResponse.json({
|
||||
error: 'Emby 未配置或未启用',
|
||||
list: [],
|
||||
totalPages: 0,
|
||||
currentPage: page,
|
||||
total: 0,
|
||||
});
|
||||
}
|
||||
|
||||
// 创建 Emby 客户端
|
||||
const client = new EmbyClient(embyConfig);
|
||||
|
||||
// 如果使用用户名密码且没有 UserId,需要先认证
|
||||
if (!embyConfig.ApiKey && !embyConfig.UserId && embyConfig.Username && embyConfig.Password) {
|
||||
try {
|
||||
const authResult = await client.authenticate(embyConfig.Username, embyConfig.Password);
|
||||
embyConfig.UserId = authResult.User.Id;
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
error: 'Emby 认证失败: ' + (error as Error).message,
|
||||
list: [],
|
||||
totalPages: 0,
|
||||
currentPage: page,
|
||||
total: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 验证认证信息:必须有 ApiKey 或 UserId
|
||||
if (!embyConfig.ApiKey && !embyConfig.UserId) {
|
||||
return NextResponse.json({
|
||||
error: 'Emby 认证失败,请检查配置',
|
||||
list: [],
|
||||
totalPages: 0,
|
||||
currentPage: page,
|
||||
total: 0,
|
||||
});
|
||||
}
|
||||
// 获取Emby客户端
|
||||
const client = await embyManager.getClient(embyKey);
|
||||
|
||||
// 获取媒体列表
|
||||
const result = await client.getItems({
|
||||
@@ -96,7 +56,7 @@ export async function GET(request: NextRequest) {
|
||||
};
|
||||
|
||||
// 缓存结果
|
||||
setCachedEmbyList(page, pageSize, response, parentId);
|
||||
setCachedEmbyList(page, pageSize, response, parentId, embyKey);
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
|
||||
@@ -7,51 +7,18 @@ import { getConfig } from '@/lib/config';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
// 内存缓存 Emby 配置,避免每次请求都读取配置
|
||||
let cachedEmbyConfig: {
|
||||
serverURL: string;
|
||||
apiKey: string;
|
||||
timestamp: number;
|
||||
} | null = null;
|
||||
|
||||
const CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存
|
||||
|
||||
/**
|
||||
* 获取缓存的 Emby 配置
|
||||
* 获取 Emby 客户端
|
||||
*/
|
||||
async function getCachedEmbyConfig() {
|
||||
const now = Date.now();
|
||||
|
||||
// 如果缓存存在且未过期,直接返回
|
||||
if (cachedEmbyConfig && (now - cachedEmbyConfig.timestamp) < CACHE_TTL) {
|
||||
return cachedEmbyConfig;
|
||||
}
|
||||
|
||||
// 否则重新获取配置
|
||||
async function getEmbyClient(embyKey?: string) {
|
||||
const config = await getConfig();
|
||||
const embyConfig = config.EmbyConfig;
|
||||
|
||||
if (
|
||||
!embyConfig ||
|
||||
!embyConfig.Enabled ||
|
||||
!embyConfig.ServerURL
|
||||
) {
|
||||
if (!config.EmbyConfig?.Sources || config.EmbyConfig.Sources.length === 0) {
|
||||
throw new Error('Emby 未配置或未启用');
|
||||
}
|
||||
|
||||
const apiKey = embyConfig.ApiKey || embyConfig.AuthToken;
|
||||
if (!apiKey) {
|
||||
throw new Error('Emby 认证信息缺失');
|
||||
}
|
||||
|
||||
// 更新缓存
|
||||
cachedEmbyConfig = {
|
||||
serverURL: embyConfig.ServerURL,
|
||||
apiKey,
|
||||
timestamp: now,
|
||||
};
|
||||
|
||||
return cachedEmbyConfig;
|
||||
const { embyManager } = await import('@/lib/emby-manager');
|
||||
return await embyManager.getClient(embyKey);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -84,16 +51,17 @@ export async function GET(
|
||||
}
|
||||
|
||||
const itemId = searchParams.get('itemId');
|
||||
const embyKey = searchParams.get('embyKey') || undefined;
|
||||
|
||||
if (!itemId) {
|
||||
return NextResponse.json({ error: '缺少 itemId 参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 使用缓存的配置
|
||||
const embyConfig = await getCachedEmbyConfig();
|
||||
// 获取 Emby 客户端
|
||||
let client = await getEmbyClient(embyKey);
|
||||
|
||||
// 构建 Emby 原始播放链接
|
||||
const embyStreamUrl = `${embyConfig.serverURL}/Videos/${itemId}/stream?Static=true&api_key=${embyConfig.apiKey}`;
|
||||
let embyStreamUrl = client.getStreamUrl(itemId);
|
||||
|
||||
// 构建请求头,转发 Range 请求
|
||||
const requestHeaders: HeadersInit = {};
|
||||
@@ -103,10 +71,22 @@ export async function GET(
|
||||
}
|
||||
|
||||
// 流式代理视频内容
|
||||
const videoResponse = await fetch(embyStreamUrl, {
|
||||
let videoResponse = await fetch(embyStreamUrl, {
|
||||
headers: requestHeaders,
|
||||
});
|
||||
|
||||
// 如果返回 401,尝试重新认证并重试
|
||||
if (videoResponse.status === 401) {
|
||||
console.log('[Emby Play] 收到 401 错误,尝试重新认证');
|
||||
const { embyManager } = await import('@/lib/emby-manager');
|
||||
embyManager.clearCache();
|
||||
client = await getEmbyClient(embyKey);
|
||||
embyStreamUrl = client.getStreamUrl(itemId);
|
||||
videoResponse = await fetch(embyStreamUrl, {
|
||||
headers: requestHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
if (!videoResponse.ok) {
|
||||
console.error('[Emby Play] 获取视频流失败:', {
|
||||
itemId,
|
||||
|
||||
27
src/app/api/emby/sources/route.ts
Normal file
27
src/app/api/emby/sources/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { embyManager } from '@/lib/emby-manager';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
/**
|
||||
* 获取所有启用的Emby源列表
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const sources = await embyManager.getEnabledSources();
|
||||
|
||||
return NextResponse.json({
|
||||
sources: sources.map(s => ({
|
||||
key: s.key,
|
||||
name: s.name,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Emby Sources] 获取Emby源列表失败:', error);
|
||||
return NextResponse.json(
|
||||
{ error: '获取Emby源列表失败', sources: [] },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +1,26 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
import { EmbyClient } from '@/lib/emby.client';
|
||||
import { embyManager } from '@/lib/emby-manager';
|
||||
import { getCachedEmbyViews, setCachedEmbyViews } from '@/lib/emby-cache';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
|
||||
export async function GET() {
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
// 检查缓存
|
||||
const cached = getCachedEmbyViews();
|
||||
const { searchParams } = new URL(request.url);
|
||||
const embyKey = searchParams.get('embyKey') || undefined;
|
||||
|
||||
// 检查缓存(按embyKey缓存)
|
||||
const cacheKey = embyKey || 'default';
|
||||
const cached = getCachedEmbyViews(cacheKey);
|
||||
if (cached) {
|
||||
return NextResponse.json(cached);
|
||||
}
|
||||
|
||||
const config = await getConfig();
|
||||
const embyConfig = config.EmbyConfig;
|
||||
|
||||
if (!embyConfig?.Enabled || !embyConfig.ServerURL) {
|
||||
return NextResponse.json({
|
||||
error: 'Emby 未配置或未启用',
|
||||
views: [],
|
||||
});
|
||||
}
|
||||
|
||||
// 创建 Emby 客户端
|
||||
const client = new EmbyClient(embyConfig);
|
||||
|
||||
// 如果使用用户名密码且没有 UserId,需要先认证
|
||||
if (!embyConfig.ApiKey && !embyConfig.UserId && embyConfig.Username && embyConfig.Password) {
|
||||
try {
|
||||
const authResult = await client.authenticate(embyConfig.Username, embyConfig.Password);
|
||||
embyConfig.UserId = authResult.User.Id;
|
||||
} catch (error) {
|
||||
return NextResponse.json({
|
||||
error: 'Emby 认证失败: ' + (error as Error).message,
|
||||
views: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 验证认证信息:必须有 ApiKey 或 UserId
|
||||
if (!embyConfig.ApiKey && !embyConfig.UserId) {
|
||||
return NextResponse.json({
|
||||
error: 'Emby 认证失败,请检查配置',
|
||||
views: [],
|
||||
});
|
||||
}
|
||||
// 获取Emby客户端
|
||||
const client = await embyManager.getClient(embyKey);
|
||||
|
||||
// 获取媒体库列表
|
||||
const views = await client.getUserViews();
|
||||
@@ -68,7 +40,7 @@ export async function GET() {
|
||||
};
|
||||
|
||||
// 缓存结果
|
||||
setCachedEmbyViews(response);
|
||||
setCachedEmbyViews(cacheKey, response);
|
||||
|
||||
return NextResponse.json(response);
|
||||
} catch (error) {
|
||||
|
||||
@@ -44,53 +44,57 @@ export async function GET(request: NextRequest) {
|
||||
config.OpenListConfig?.Password
|
||||
);
|
||||
|
||||
// 检查是否配置了 Emby
|
||||
const hasEmby = !!(
|
||||
config.EmbyConfig?.Enabled &&
|
||||
config.EmbyConfig?.ServerURL &&
|
||||
config.EmbyConfig?.UserId
|
||||
);
|
||||
// 获取所有启用的 Emby 源
|
||||
const { embyManager } = await import('@/lib/emby-manager');
|
||||
const embySourcesMap = await embyManager.getAllClients();
|
||||
const embySources = Array.from(embySourcesMap.values());
|
||||
|
||||
// 搜索 Emby(如果配置了)- 异步带超时
|
||||
const embyPromise = hasEmby
|
||||
? Promise.race([
|
||||
(async () => {
|
||||
try {
|
||||
const { EmbyClient } = await import('@/lib/emby.client');
|
||||
const client = new EmbyClient(config.EmbyConfig!);
|
||||
const searchResult = await client.getItems({
|
||||
searchTerm: query,
|
||||
IncludeItemTypes: 'Movie,Series',
|
||||
Recursive: true,
|
||||
Fields: 'Overview,ProductionYear',
|
||||
Limit: 50,
|
||||
});
|
||||
return searchResult.Items.map((item) => ({
|
||||
id: item.Id,
|
||||
source: 'emby',
|
||||
source_name: 'Emby',
|
||||
title: item.Name,
|
||||
poster: client.getImageUrl(item.Id, 'Primary'),
|
||||
episodes: [],
|
||||
episodes_titles: [],
|
||||
year: item.ProductionYear?.toString() || '',
|
||||
desc: item.Overview || '',
|
||||
type_name: item.Type === 'Movie' ? '电影' : '电视剧',
|
||||
douban_id: 0,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('[Search] 搜索 Emby 失败:', error);
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
new Promise<any[]>((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Emby timeout')), 20000)
|
||||
),
|
||||
]).catch((error) => {
|
||||
console.error('[Search] 搜索 Emby 超时:', error);
|
||||
return [];
|
||||
})
|
||||
: Promise.resolve([]);
|
||||
console.log('[Search] Emby sources count:', embySources.length);
|
||||
console.log('[Search] Emby sources:', embySources.map(s => ({ key: s.config.key, name: s.config.name })));
|
||||
|
||||
// 为每个 Emby 源创建搜索 Promise(全部并发,无限制)
|
||||
const embyPromises = embySources.map(({ client, config: embyConfig }) =>
|
||||
Promise.race([
|
||||
(async () => {
|
||||
try {
|
||||
const searchResult = await client.getItems({
|
||||
searchTerm: query,
|
||||
IncludeItemTypes: 'Movie,Series',
|
||||
Recursive: true,
|
||||
Fields: 'Overview,ProductionYear',
|
||||
Limit: 50,
|
||||
});
|
||||
|
||||
// 如果只有一个Emby源,保持旧格式(向后兼容)
|
||||
const sourceValue = embySources.length === 1 ? 'emby' : `emby_${embyConfig.key}`;
|
||||
const sourceName = embySources.length === 1 ? 'Emby' : embyConfig.name;
|
||||
|
||||
return searchResult.Items.map((item) => ({
|
||||
id: item.Id,
|
||||
source: sourceValue,
|
||||
source_name: sourceName,
|
||||
title: item.Name,
|
||||
poster: client.getImageUrl(item.Id, 'Primary'),
|
||||
episodes: [],
|
||||
episodes_titles: [],
|
||||
year: item.ProductionYear?.toString() || '',
|
||||
desc: item.Overview || '',
|
||||
type_name: item.Type === 'Movie' ? '电影' : '电视剧',
|
||||
douban_id: 0,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error(`[Search] 搜索 ${embyConfig.name} 失败:`, error);
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
new Promise<any[]>((_, reject) =>
|
||||
setTimeout(() => reject(new Error(`${embyConfig.name} timeout`)), 20000)
|
||||
),
|
||||
]).catch((error) => {
|
||||
console.error(`[Search] 搜索 ${embyConfig.name} 超时:`, error);
|
||||
return [];
|
||||
})
|
||||
);
|
||||
|
||||
// 搜索 OpenList(如果配置了)- 异步带超时
|
||||
const openlistPromise = hasOpenList
|
||||
@@ -164,12 +168,20 @@ export async function GET(request: NextRequest) {
|
||||
);
|
||||
|
||||
try {
|
||||
const [embyResults, openlistResults, ...apiResults] = await Promise.all([
|
||||
embyPromise,
|
||||
const allResults = await Promise.all([
|
||||
openlistPromise,
|
||||
...embyPromises,
|
||||
...searchPromises,
|
||||
]);
|
||||
let flattenedResults = [...embyResults, ...openlistResults, ...apiResults.flat()];
|
||||
|
||||
// 分离结果:第一个是 openlist,接下来是 emby 结果,最后是 api 结果
|
||||
const openlistResults = allResults[0];
|
||||
const embyResultsArray = allResults.slice(1, 1 + embyPromises.length);
|
||||
const apiResults = allResults.slice(1 + embyPromises.length);
|
||||
|
||||
// 合并所有 Emby 结果
|
||||
const embyResults = embyResultsArray.flat();
|
||||
let flattenedResults = [...openlistResults, ...embyResults, ...apiResults.flat()];
|
||||
if (!config.SiteConfig.DisableYellowFilter) {
|
||||
flattenedResults = flattenedResults.filter((result) => {
|
||||
const typeName = result.type_name || '';
|
||||
|
||||
@@ -41,11 +41,11 @@ export async function GET(request: NextRequest) {
|
||||
config.OpenListConfig?.Password
|
||||
);
|
||||
|
||||
// 检查是否配置了 Emby
|
||||
// 检查是否配置了 Emby(支持多源)
|
||||
const hasEmby = !!(
|
||||
config.EmbyConfig?.Enabled &&
|
||||
config.EmbyConfig?.ServerURL &&
|
||||
config.EmbyConfig?.UserId
|
||||
config.EmbyConfig?.Sources &&
|
||||
config.EmbyConfig.Sources.length > 0 &&
|
||||
config.EmbyConfig.Sources.some(s => s.enabled && s.ServerURL)
|
||||
);
|
||||
|
||||
// 共享状态
|
||||
@@ -73,11 +73,23 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
};
|
||||
|
||||
// 获取 Emby 源数量
|
||||
let embySourcesCount = 0;
|
||||
if (hasEmby) {
|
||||
try {
|
||||
const { embyManager } = await import('@/lib/emby-manager');
|
||||
const embySourcesMap = await embyManager.getAllClients();
|
||||
embySourcesCount = embySourcesMap.size;
|
||||
} catch (error) {
|
||||
console.error('[Search WS] 获取 Emby 源数量失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 发送开始事件
|
||||
const startEvent = `data: ${JSON.stringify({
|
||||
type: 'start',
|
||||
query,
|
||||
totalSources: apiSites.length + (hasOpenList ? 1 : 0) + (hasEmby ? 1 : 0),
|
||||
totalSources: apiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount,
|
||||
timestamp: Date.now()
|
||||
})}\n\n`;
|
||||
|
||||
@@ -89,75 +101,105 @@ export async function GET(request: NextRequest) {
|
||||
let completedSources = 0;
|
||||
const allResults: any[] = [];
|
||||
|
||||
// 搜索 Emby(如果配置了)- 异步带超时
|
||||
// 搜索 Emby(如果配置了)- 异步带超时,支持多源
|
||||
if (hasEmby) {
|
||||
Promise.race([
|
||||
(async () => {
|
||||
try {
|
||||
const { EmbyClient } = await import('@/lib/emby.client');
|
||||
const client = new EmbyClient(config.EmbyConfig!);
|
||||
const searchResult = await client.getItems({
|
||||
searchTerm: query,
|
||||
IncludeItemTypes: 'Movie,Series',
|
||||
Recursive: true,
|
||||
Fields: 'Overview,ProductionYear',
|
||||
Limit: 50,
|
||||
});
|
||||
return searchResult.Items.map((item) => ({
|
||||
id: item.Id,
|
||||
source: 'emby',
|
||||
source_name: 'Emby',
|
||||
title: item.Name,
|
||||
poster: client.getImageUrl(item.Id, 'Primary'),
|
||||
episodes: [],
|
||||
episodes_titles: [],
|
||||
year: item.ProductionYear?.toString() || '',
|
||||
desc: item.Overview || '',
|
||||
type_name: item.Type === 'Movie' ? '电影' : '电视剧',
|
||||
douban_id: 0,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('[Search WS] 搜索 Emby 失败:', error);
|
||||
return [];
|
||||
}
|
||||
})(),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Emby timeout')), 20000)
|
||||
),
|
||||
])
|
||||
.then((embyResults: any) => {
|
||||
completedSources++;
|
||||
if (!streamClosed) {
|
||||
const sourceEvent = `data: ${JSON.stringify({
|
||||
type: 'source_result',
|
||||
source: 'emby',
|
||||
sourceName: 'Emby',
|
||||
results: embyResults,
|
||||
timestamp: Date.now()
|
||||
})}\n\n`;
|
||||
if (!safeEnqueue(encoder.encode(sourceEvent))) {
|
||||
streamClosed = true;
|
||||
return;
|
||||
(async () => {
|
||||
let embyCompletedCount = 0;
|
||||
try {
|
||||
const { embyManager } = await import('@/lib/emby-manager');
|
||||
const embySourcesMap = await embyManager.getAllClients();
|
||||
const embySources = Array.from(embySourcesMap.values());
|
||||
|
||||
// 为每个 Emby 源并发搜索,并单独发送结果
|
||||
const embySearchPromises = embySources.map(async ({ client, config: embyConfig }) => {
|
||||
try {
|
||||
const searchResult = await client.getItems({
|
||||
searchTerm: query,
|
||||
IncludeItemTypes: 'Movie,Series',
|
||||
Recursive: true,
|
||||
Fields: 'Overview,ProductionYear',
|
||||
Limit: 50,
|
||||
});
|
||||
|
||||
const sourceValue = embySources.length === 1 ? 'emby' : `emby_${embyConfig.key}`;
|
||||
const sourceName = embySources.length === 1 ? 'Emby' : embyConfig.name;
|
||||
|
||||
const results = searchResult.Items.map((item) => ({
|
||||
id: item.Id,
|
||||
source: sourceValue,
|
||||
source_name: sourceName,
|
||||
title: item.Name,
|
||||
poster: client.getImageUrl(item.Id, 'Primary'),
|
||||
episodes: [],
|
||||
episodes_titles: [],
|
||||
year: item.ProductionYear?.toString() || '',
|
||||
desc: item.Overview || '',
|
||||
type_name: item.Type === 'Movie' ? '电影' : '电视剧',
|
||||
douban_id: 0,
|
||||
}));
|
||||
|
||||
// 单独发送每个源的结果
|
||||
embyCompletedCount++;
|
||||
completedSources++;
|
||||
if (!streamClosed) {
|
||||
const sourceEvent = `data: ${JSON.stringify({
|
||||
type: 'source_result',
|
||||
source: sourceValue,
|
||||
sourceName: sourceName,
|
||||
results: results,
|
||||
timestamp: Date.now()
|
||||
})}\n\n`;
|
||||
if (safeEnqueue(encoder.encode(sourceEvent))) {
|
||||
if (results.length > 0) {
|
||||
allResults.push(...results);
|
||||
}
|
||||
} else {
|
||||
streamClosed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error(`[Search WS] 搜索 ${embyConfig.name} 失败:`, error);
|
||||
embyCompletedCount++;
|
||||
completedSources++;
|
||||
// 发送空结果
|
||||
if (!streamClosed) {
|
||||
const sourceValue = embySources.length === 1 ? 'emby' : `emby_${embyConfig.key}`;
|
||||
const sourceName = embySources.length === 1 ? 'Emby' : embyConfig.name;
|
||||
const sourceEvent = `data: ${JSON.stringify({
|
||||
type: 'source_result',
|
||||
source: sourceValue,
|
||||
sourceName: sourceName,
|
||||
results: [],
|
||||
timestamp: Date.now()
|
||||
})}\n\n`;
|
||||
safeEnqueue(encoder.encode(sourceEvent));
|
||||
}
|
||||
return [];
|
||||
}
|
||||
if (embyResults.length > 0) {
|
||||
allResults.push(...embyResults);
|
||||
});
|
||||
|
||||
await Promise.all(embySearchPromises);
|
||||
} catch (error) {
|
||||
console.error('[Search WS] 搜索 Emby 整体失败:', error);
|
||||
// 如果整个 emby 搜索失败,需要补齐未完成的源
|
||||
const remainingSources = embySourcesCount - embyCompletedCount;
|
||||
for (let i = 0; i < remainingSources; i++) {
|
||||
completedSources++;
|
||||
if (!streamClosed) {
|
||||
const sourceEvent = `data: ${JSON.stringify({
|
||||
type: 'source_result',
|
||||
source: 'emby',
|
||||
sourceName: 'Emby',
|
||||
results: [],
|
||||
timestamp: Date.now()
|
||||
})}\n\n`;
|
||||
safeEnqueue(encoder.encode(sourceEvent));
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[Search WS] 搜索 Emby 超时:', error);
|
||||
completedSources++;
|
||||
if (!streamClosed) {
|
||||
const sourceEvent = `data: ${JSON.stringify({
|
||||
type: 'source_result',
|
||||
source: 'emby',
|
||||
sourceName: 'Emby',
|
||||
results: [],
|
||||
timestamp: Date.now()
|
||||
})}\n\n`;
|
||||
safeEnqueue(encoder.encode(sourceEvent));
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// 搜索 OpenList(如果配置了)- 异步带超时
|
||||
@@ -315,7 +357,7 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
// 检查是否所有源都已完成
|
||||
if (completedSources === apiSites.length + (hasOpenList ? 1 : 0) + (hasEmby ? 1 : 0)) {
|
||||
if (completedSources === apiSites.length + (hasOpenList ? 1 : 0) + embySourcesCount) {
|
||||
if (!streamClosed) {
|
||||
// 发送最终完成事件
|
||||
const completeEvent = `data: ${JSON.stringify({
|
||||
|
||||
@@ -27,18 +27,29 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ error: '缺少必要参数' }, { status: 400 });
|
||||
}
|
||||
|
||||
// 特殊处理 emby 源
|
||||
if (sourceCode === 'emby') {
|
||||
// 特殊处理 emby 源(支持多源)
|
||||
if (sourceCode === 'emby' || sourceCode.startsWith('emby_')) {
|
||||
try {
|
||||
const config = await getConfig();
|
||||
const embyConfig = config.EmbyConfig;
|
||||
|
||||
if (!embyConfig || !embyConfig.Enabled || !embyConfig.ServerURL) {
|
||||
// 检查是否有启用的 Emby 源
|
||||
if (!config.EmbyConfig?.Sources || config.EmbyConfig.Sources.length === 0) {
|
||||
throw new Error('Emby 未配置或未启用');
|
||||
}
|
||||
|
||||
const { EmbyClient } = await import('@/lib/emby.client');
|
||||
const client = new EmbyClient(embyConfig);
|
||||
// 解析 embyKey
|
||||
let embyKey: string | undefined;
|
||||
if (sourceCode.startsWith('emby_')) {
|
||||
embyKey = sourceCode.substring(5); // 'emby_'.length = 5
|
||||
}
|
||||
|
||||
// 使用 EmbyManager 获取客户端和配置
|
||||
const { embyManager } = await import('@/lib/emby-manager');
|
||||
const sources = await embyManager.getEnabledSources();
|
||||
const sourceConfig = sources.find(s => s.key === embyKey);
|
||||
const sourceName = sourceConfig?.name || 'Emby';
|
||||
|
||||
const client = await embyManager.getClient(embyKey);
|
||||
|
||||
// 获取媒体详情
|
||||
const item = await client.getItem(id);
|
||||
@@ -49,8 +60,8 @@ export async function GET(request: NextRequest) {
|
||||
const subtitles = client.getSubtitles(item);
|
||||
|
||||
const result = {
|
||||
source: 'emby',
|
||||
source_name: 'Emby',
|
||||
source: sourceCode, // 保持与请求一致(emby 或 emby_key)
|
||||
source_name: sourceName,
|
||||
id: item.Id,
|
||||
title: item.Name,
|
||||
poster: client.getImageUrl(item.Id, 'Primary'),
|
||||
@@ -83,8 +94,8 @@ export async function GET(request: NextRequest) {
|
||||
});
|
||||
|
||||
const result = {
|
||||
source: 'emby',
|
||||
source_name: 'Emby',
|
||||
source: sourceCode, // 保持与请求一致(emby 或 emby_key)
|
||||
source_name: sourceName,
|
||||
id: item.Id,
|
||||
title: item.Name,
|
||||
poster: client.getImageUrl(item.Id, 'Primary'),
|
||||
|
||||
@@ -71,12 +71,9 @@ export async function GET(request: NextRequest) {
|
||||
config.OpenListConfig?.Password
|
||||
);
|
||||
|
||||
// 检查是否配置了 Emby
|
||||
const hasEmby = !!(
|
||||
config.EmbyConfig?.Enabled &&
|
||||
config.EmbyConfig?.ServerURL &&
|
||||
(config.EmbyConfig?.ApiKey || (config.EmbyConfig?.Username && config.EmbyConfig?.Password))
|
||||
);
|
||||
// 获取所有启用的 Emby 源
|
||||
const { embyManager } = await import('@/lib/emby-manager');
|
||||
const embySources = await embyManager.getEnabledSources();
|
||||
|
||||
// 构建 OpenList 站点配置
|
||||
const openlistSites = hasOpenList ? [{
|
||||
@@ -90,17 +87,17 @@ export async function GET(request: NextRequest) {
|
||||
ext: '',
|
||||
}] : [];
|
||||
|
||||
// 构建 Emby 站点配置
|
||||
const embySites = hasEmby ? [{
|
||||
key: 'emby',
|
||||
name: 'Emby媒体库',
|
||||
// 构建 Emby 站点配置(为每个启用的Emby源生成独立站点)
|
||||
const embySites = embySources.map(source => ({
|
||||
key: `emby_${source.key}`,
|
||||
name: source.name || 'Emby媒体库',
|
||||
type: 1,
|
||||
api: `${baseUrl}/api/emby/cms-proxy/${encodeURIComponent(subscribeToken)}`,
|
||||
api: `${baseUrl}/api/emby/cms-proxy/${encodeURIComponent(subscribeToken)}?embyKey=${source.key}`,
|
||||
searchable: 1,
|
||||
quickSearch: 1,
|
||||
filterable: 1,
|
||||
ext: '',
|
||||
}] : [];
|
||||
}));
|
||||
|
||||
// 构建TVBOX订阅数据
|
||||
const tvboxSubscription = {
|
||||
|
||||
@@ -126,11 +126,11 @@ export default async function RootLayout({
|
||||
config.OpenListConfig?.Username &&
|
||||
config.OpenListConfig?.Password
|
||||
);
|
||||
// 检查是否启用了 Emby 功能
|
||||
// 检查是否启用了 Emby 功能(支持多源)
|
||||
embyEnabled = !!(
|
||||
config.EmbyConfig?.Enabled &&
|
||||
config.EmbyConfig?.ServerURL &&
|
||||
(config.EmbyConfig?.ApiKey || (config.EmbyConfig?.Username && config.EmbyConfig?.Password))
|
||||
config.EmbyConfig?.Sources &&
|
||||
config.EmbyConfig.Sources.length > 0 &&
|
||||
config.EmbyConfig.Sources.some(s => s.enabled && s.ServerURL)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -456,12 +456,20 @@ function PlayPageClient() {
|
||||
const [doubanCardSubtitle, setDoubanCardSubtitle] = useState<string>('');
|
||||
const [doubanAka, setDoubanAka] = useState<string[]>([]);
|
||||
const [doubanYear, setDoubanYear] = useState<string>(''); // 从 pubdate 提取的年份
|
||||
// 当前源和ID
|
||||
const [currentSource, setCurrentSource] = useState(
|
||||
searchParams.get('source') || ''
|
||||
);
|
||||
|
||||
// 当前源和ID - source 直接存储完整格式(如 'emby_wumei' 或 'emby')
|
||||
const [currentSource, setCurrentSource] = useState(searchParams.get('source') || '');
|
||||
const [currentId, setCurrentId] = useState(searchParams.get('id') || '');
|
||||
|
||||
// 解析 source 参数以获取 embyKey(仅用于 API 调用)
|
||||
const parseSourceForApi = (source: string): { source: string; embyKey?: string } => {
|
||||
if (source.startsWith('emby_')) {
|
||||
const key = source.substring(5);
|
||||
return { source: 'emby', embyKey: key };
|
||||
}
|
||||
return { source };
|
||||
};
|
||||
|
||||
// 搜索所需信息
|
||||
const [searchTitle] = useState(searchParams.get('stitle') || '');
|
||||
const [searchType] = useState(searchParams.get('stype') || '');
|
||||
@@ -501,8 +509,6 @@ function PlayPageClient() {
|
||||
// 监听 URL 参数变化,当切换到不同视频时重新加载页面
|
||||
useEffect(() => {
|
||||
const urlTitle = searchParams.get('title') || '';
|
||||
const urlSource = searchParams.get('source') || '';
|
||||
const urlId = searchParams.get('id') || '';
|
||||
|
||||
// 只在切换到不同视频时重新加载页面(title变化)
|
||||
// 换源(source/id变化)由播放器自己处理,不需要刷新页面
|
||||
@@ -2366,6 +2372,7 @@ function PlayPageClient() {
|
||||
if (currentSource && currentId) {
|
||||
// 先快速获取当前源的详情
|
||||
try {
|
||||
// currentSource 已经是完整格式(如 'emby_wumei')
|
||||
const currentSourceDetail = await fetchSourceDetail(currentSource, currentId, searchTitle || videoTitle);
|
||||
if (currentSourceDetail.length > 0) {
|
||||
detailData = currentSourceDetail[0];
|
||||
@@ -2415,8 +2422,9 @@ function PlayPageClient() {
|
||||
detailData = target;
|
||||
|
||||
// 如果是 openlist 或 emby 源且 episodes 为空,需要调用 detail 接口获取完整信息
|
||||
if ((detailData.source === 'openlist' || detailData.source === 'emby') && (!detailData.episodes || detailData.episodes.length === 0)) {
|
||||
if ((detailData.source === 'openlist' || detailData.source === 'emby' || detailData.source.startsWith('emby_')) && (!detailData.episodes || detailData.episodes.length === 0)) {
|
||||
console.log('[Play] OpenList/Emby source has no episodes, fetching detail...');
|
||||
// currentSource 已经是完整格式
|
||||
const detailSources = await fetchSourceDetail(currentSource, currentId, searchTitle || videoTitle);
|
||||
if (detailSources.length > 0) {
|
||||
detailData = detailSources[0];
|
||||
@@ -2437,9 +2445,22 @@ function PlayPageClient() {
|
||||
setLoadingStage('preferring');
|
||||
setLoadingMessage('⚡ 正在优选最佳播放源...');
|
||||
|
||||
// 过滤掉 openlist 和 emby 源,它们不参与测速
|
||||
const sourcesToTest = sourcesInfo.filter(s => s.source !== 'openlist' && s.source !== 'emby');
|
||||
const excludedSources = sourcesInfo.filter(s => s.source === 'openlist' || s.source === 'emby');
|
||||
// 过滤掉 openlist 和所有 emby 源,它们不参与测速
|
||||
const sourcesToTest = sourcesInfo.filter(s => {
|
||||
// 检查是否为 openlist
|
||||
if (s.source === 'openlist') return false;
|
||||
|
||||
// 检查是否为 emby 源(包括 emby 和 emby_xxx 格式)
|
||||
if (s.source === 'emby' || s.source.startsWith('emby_')) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const excludedSources = sourcesInfo.filter(s =>
|
||||
s.source === 'openlist' ||
|
||||
s.source === 'emby' ||
|
||||
s.source.startsWith('emby_')
|
||||
);
|
||||
|
||||
if (sourcesToTest.length > 0) {
|
||||
detailData = await preferBestSource(sourcesToTest);
|
||||
@@ -2463,6 +2484,7 @@ function PlayPageClient() {
|
||||
}
|
||||
|
||||
setNeedPrefer(false);
|
||||
// 直接使用 detailData.source(已经是完整格式)
|
||||
setCurrentSource(detailData.source);
|
||||
setCurrentId(detailData.id);
|
||||
setVideoYear(detailData.year);
|
||||
@@ -2475,12 +2497,12 @@ function PlayPageClient() {
|
||||
setCurrentEpisodeIndex(0);
|
||||
}
|
||||
|
||||
// 规范URL参数
|
||||
// 规范URL参数(不更新title,避免循环刷新)
|
||||
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);
|
||||
newUrl.searchParams.set('title', detailData.title);
|
||||
// 保持原有的 title,不更新
|
||||
newUrl.searchParams.delete('prefer');
|
||||
window.history.replaceState({}, '', newUrl.toString());
|
||||
|
||||
@@ -2545,21 +2567,17 @@ function PlayPageClient() {
|
||||
|
||||
// 只在URL参数存在且与当前状态不同时才处理
|
||||
if (urlSource && urlId && (urlSource !== currentSource || urlId !== currentId)) {
|
||||
console.log('[PlayPage] Detected source/id change from URL:', {
|
||||
urlSource,
|
||||
urlId,
|
||||
currentSource,
|
||||
currentId
|
||||
});
|
||||
|
||||
// 检查新的source和id是否在可用源列表中
|
||||
// 如果 availableSources 还是空的,说明数据还在加载中,不做处理
|
||||
if (availableSources.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetSource = availableSources.find(
|
||||
(source) => source.source === urlSource && source.id === urlId
|
||||
);
|
||||
|
||||
if (targetSource) {
|
||||
console.log('[PlayPage] Found matching source in available sources, updating...');
|
||||
|
||||
// 记录当前播放进度
|
||||
const currentPlayTime = artPlayerRef.current?.currentTime || 0;
|
||||
|
||||
@@ -2567,7 +2585,7 @@ function PlayPageClient() {
|
||||
const episodeParam = searchParams.get('episode');
|
||||
const targetEpisode = episodeParam ? parseInt(episodeParam, 10) - 1 : 0;
|
||||
|
||||
// 更新视频源信息
|
||||
// 更新视频源信息(urlSource 已经是完整格式)
|
||||
setCurrentSource(urlSource);
|
||||
setCurrentId(urlId);
|
||||
setVideoTitle(targetSource.title);
|
||||
@@ -2589,7 +2607,6 @@ function PlayPageClient() {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log('[PlayPage] Source not found in available sources, reloading page...');
|
||||
// 如果新源不在可用列表中,强制刷新页面重新加载
|
||||
window.location.reload();
|
||||
}
|
||||
@@ -2712,7 +2729,7 @@ function PlayPageClient() {
|
||||
}
|
||||
|
||||
// 如果是 openlist 或 emby 源且 episodes 为空,需要调用 detail 接口获取完整信息
|
||||
if ((newDetail.source === 'openlist' || newDetail.source === 'emby') && (!newDetail.episodes || newDetail.episodes.length === 0)) {
|
||||
if ((newDetail.source === 'openlist' || newDetail.source === 'emby' || newDetail.source.startsWith('emby_')) && (!newDetail.episodes || newDetail.episodes.length === 0)) {
|
||||
try {
|
||||
const detailResponse = await fetch(`/api/source-detail?source=${newSource}&id=${newId}&title=${encodeURIComponent(newTitle)}`);
|
||||
if (detailResponse.ok) {
|
||||
@@ -2768,6 +2785,7 @@ function PlayPageClient() {
|
||||
setVideoYear(newDetail.year);
|
||||
setVideoCover(newDetail.poster);
|
||||
setVideoDoubanId(newDetail.douban_id || 0);
|
||||
// newSource 已经是完整格式
|
||||
setCurrentSource(newSource);
|
||||
setCurrentId(newId);
|
||||
setDetail(newDetail);
|
||||
|
||||
@@ -9,7 +9,12 @@ import CapsuleSwitch from '@/components/CapsuleSwitch';
|
||||
import PageLayout from '@/components/PageLayout';
|
||||
import VideoCard from '@/components/VideoCard';
|
||||
|
||||
type LibrarySource = 'openlist' | 'emby';
|
||||
type LibrarySourceType = 'openlist' | 'emby';
|
||||
|
||||
interface EmbySourceOption {
|
||||
key: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface Video {
|
||||
id: string;
|
||||
@@ -43,7 +48,21 @@ export default function PrivateLibraryPage() {
|
||||
return { OPENLIST_ENABLED: false, EMBY_ENABLED: false };
|
||||
}, []);
|
||||
|
||||
const [source, setSource] = useState<LibrarySource>('openlist');
|
||||
// 解析URL中的source参数(支持 emby:emby1 格式)
|
||||
const parseSourceParam = (sourceParam: string | null): { sourceType: LibrarySourceType; embyKey?: string } => {
|
||||
if (!sourceParam) return { sourceType: 'openlist' };
|
||||
|
||||
if (sourceParam.includes(':')) {
|
||||
const [type, key] = sourceParam.split(':');
|
||||
return { sourceType: type as LibrarySourceType, embyKey: key };
|
||||
}
|
||||
|
||||
return { sourceType: sourceParam as LibrarySourceType };
|
||||
};
|
||||
|
||||
const [sourceType, setSourceType] = useState<LibrarySourceType>('openlist');
|
||||
const [embyKey, setEmbyKey] = useState<string | undefined>();
|
||||
const [embySourceOptions, setEmbySourceOptions] = useState<EmbySourceOption[]>([]);
|
||||
const [videos, setVideos] = useState<Video[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
@@ -57,6 +76,7 @@ export default function PrivateLibraryPage() {
|
||||
const observerTarget = useRef<HTMLDivElement>(null);
|
||||
const isFetchingRef = useRef(false);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const embyScrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const isDraggingRef = useRef(false);
|
||||
const startXRef = useRef(0);
|
||||
const scrollLeftRef = useRef(0);
|
||||
@@ -64,14 +84,20 @@ export default function PrivateLibraryPage() {
|
||||
|
||||
// 从URL初始化状态,并检查配置自动跳转
|
||||
useEffect(() => {
|
||||
const urlSource = searchParams.get('source') as LibrarySource;
|
||||
const urlSourceParam = searchParams.get('source');
|
||||
const urlView = searchParams.get('view');
|
||||
|
||||
// 解析source参数
|
||||
const parsed = parseSourceParam(urlSourceParam);
|
||||
|
||||
// 如果 OpenList 未配置但 Emby 已配置,强制使用 Emby
|
||||
if (!runtimeConfig.OPENLIST_ENABLED && runtimeConfig.EMBY_ENABLED) {
|
||||
setSource('emby');
|
||||
} else if (urlSource && (urlSource === 'openlist' || urlSource === 'emby')) {
|
||||
setSource(urlSource);
|
||||
setSourceType('emby');
|
||||
} else if (parsed.sourceType) {
|
||||
setSourceType(parsed.sourceType);
|
||||
if (parsed.embyKey) {
|
||||
setEmbyKey(parsed.embyKey);
|
||||
}
|
||||
}
|
||||
|
||||
if (urlView) {
|
||||
@@ -81,19 +107,51 @@ export default function PrivateLibraryPage() {
|
||||
isInitializedRef.current = true;
|
||||
}, [searchParams, runtimeConfig]);
|
||||
|
||||
// 获取Emby源列表
|
||||
useEffect(() => {
|
||||
const fetchEmbySources = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/emby/sources');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setEmbySourceOptions(data.sources || []);
|
||||
|
||||
// 如果没有设置embyKey,使用第一个源
|
||||
if (!embyKey && data.sources && data.sources.length > 0) {
|
||||
setEmbyKey(data.sources[0].key);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取Emby源列表失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
if (sourceType === 'emby') {
|
||||
fetchEmbySources();
|
||||
}
|
||||
}, [sourceType]);
|
||||
|
||||
// 更新URL参数
|
||||
useEffect(() => {
|
||||
if (!isInitializedRef.current) return;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
params.set('source', source);
|
||||
if (source === 'emby' && selectedView !== 'all') {
|
||||
|
||||
// 构建source参数
|
||||
if (sourceType === 'emby' && embyKey && embySourceOptions.length > 1) {
|
||||
params.set('source', `emby:${embyKey}`);
|
||||
} else {
|
||||
params.set('source', sourceType);
|
||||
}
|
||||
|
||||
if (sourceType === 'emby' && selectedView !== 'all') {
|
||||
params.set('view', selectedView);
|
||||
}
|
||||
router.replace(`/private-library?${params.toString()}`, { scroll: false });
|
||||
}, [source, selectedView, router]);
|
||||
|
||||
// 切换源时重置所有状态(但不在初始化时执行)
|
||||
router.replace(`/private-library?${params.toString()}`, { scroll: false });
|
||||
}, [sourceType, embyKey, selectedView, router, embySourceOptions.length]);
|
||||
|
||||
// 切换源类型时重置所有状态(但不在初始化时执行)
|
||||
useEffect(() => {
|
||||
if (!isInitializedRef.current) return;
|
||||
|
||||
@@ -103,7 +161,7 @@ export default function PrivateLibraryPage() {
|
||||
setError('');
|
||||
setSelectedView('all');
|
||||
isFetchingRef.current = false;
|
||||
}, [source]);
|
||||
}, [sourceType, embyKey]);
|
||||
|
||||
// 切换分类时重置状态(但不在初始化时执行)
|
||||
useEffect(() => {
|
||||
@@ -118,12 +176,13 @@ export default function PrivateLibraryPage() {
|
||||
|
||||
// 获取 Emby 媒体库列表
|
||||
useEffect(() => {
|
||||
if (source !== 'emby') return;
|
||||
if (sourceType !== 'emby' || !embyKey) return;
|
||||
|
||||
const fetchEmbyViews = async () => {
|
||||
setLoadingViews(true);
|
||||
try {
|
||||
const response = await fetch('/api/emby/views');
|
||||
const params = new URLSearchParams({ embyKey });
|
||||
const response = await fetch(`/api/emby/views?${params.toString()}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.error) {
|
||||
@@ -151,7 +210,7 @@ export default function PrivateLibraryPage() {
|
||||
};
|
||||
|
||||
fetchEmbyViews();
|
||||
}, [source]);
|
||||
}, [sourceType, embyKey, searchParams]);
|
||||
|
||||
// 鼠标拖动滚动
|
||||
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
@@ -196,13 +255,13 @@ export default function PrivateLibraryPage() {
|
||||
}
|
||||
|
||||
// 如果选择了 openlist 但未配置,不发起请求
|
||||
if (source === 'openlist' && !runtimeConfig.OPENLIST_ENABLED) {
|
||||
if (sourceType === 'openlist' && !runtimeConfig.OPENLIST_ENABLED) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果选择了 emby 但未配置,不发起请求
|
||||
if (source === 'emby' && !runtimeConfig.EMBY_ENABLED) {
|
||||
// 如果选择了 emby 但未配置或没有embyKey,不发起请求
|
||||
if (sourceType === 'emby' && (!runtimeConfig.EMBY_ENABLED || !embyKey)) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
@@ -217,9 +276,9 @@ export default function PrivateLibraryPage() {
|
||||
}
|
||||
setError('');
|
||||
|
||||
const endpoint = source === 'openlist'
|
||||
const endpoint = sourceType === 'openlist'
|
||||
? `/api/openlist/list?page=${page}&pageSize=${pageSize}`
|
||||
: `/api/emby/list?page=${page}&pageSize=${pageSize}${selectedView !== 'all' ? `&parentId=${selectedView}` : ''}`;
|
||||
: `/api/emby/list?page=${page}&pageSize=${pageSize}${selectedView !== 'all' ? `&parentId=${selectedView}` : ''}&embyKey=${embyKey}`;
|
||||
|
||||
const response = await fetch(endpoint);
|
||||
|
||||
@@ -266,11 +325,17 @@ export default function PrivateLibraryPage() {
|
||||
};
|
||||
|
||||
fetchVideos();
|
||||
}, [source, page, selectedView, runtimeConfig]);
|
||||
}, [sourceType, embyKey, page, selectedView, runtimeConfig]);
|
||||
|
||||
const handleVideoClick = (video: Video) => {
|
||||
// 构建source参数
|
||||
let sourceParam = sourceType;
|
||||
if (sourceType === 'emby' && embyKey && embySourceOptions.length > 1) {
|
||||
sourceParam = `emby:${embyKey}`;
|
||||
}
|
||||
|
||||
// 跳转到播放页面
|
||||
router.push(`/play?source=${source}&id=${encodeURIComponent(video.id)}`);
|
||||
router.push(`/play?source=${sourceParam}&id=${encodeURIComponent(video.id)}`);
|
||||
};
|
||||
|
||||
// 使用 Intersection Observer 监听滚动
|
||||
@@ -313,20 +378,89 @@ export default function PrivateLibraryPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 源切换器 */}
|
||||
{/* 第一级:源类型选择(OpenList / Emby) */}
|
||||
<div className='mb-6 flex justify-center'>
|
||||
<CapsuleSwitch
|
||||
options={[
|
||||
{ label: 'OpenList', value: 'openlist' },
|
||||
{ label: 'Emby', value: 'emby' }
|
||||
]}
|
||||
active={source}
|
||||
onChange={(value) => setSource(value as LibrarySource)}
|
||||
/>
|
||||
<div className='inline-flex gap-2 bg-gray-100 dark:bg-gray-800 rounded-lg p-1'>
|
||||
<button
|
||||
onClick={() => setSourceType('openlist')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
sourceType === 'openlist'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
OpenList
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSourceType('emby')}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||
sourceType === 'emby'
|
||||
? 'bg-white dark:bg-gray-700 text-gray-900 dark:text-white shadow'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Emby
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Emby 分类选择器 */}
|
||||
{source === 'emby' && (
|
||||
{/* 第二级:Emby源选择(仅当选择Emby且有多个源时显示) */}
|
||||
{sourceType === 'emby' && embySourceOptions.length > 1 && (
|
||||
<div className='mb-6'>
|
||||
<div className='relative'>
|
||||
<div
|
||||
ref={embyScrollContainerRef}
|
||||
className='overflow-x-auto scrollbar-hide cursor-grab active:cursor-grabbing'
|
||||
onMouseDown={(e) => {
|
||||
if (!embyScrollContainerRef.current) return;
|
||||
isDraggingRef.current = true;
|
||||
startXRef.current = e.pageX - embyScrollContainerRef.current.offsetLeft;
|
||||
scrollLeftRef.current = embyScrollContainerRef.current.scrollLeft;
|
||||
embyScrollContainerRef.current.style.cursor = 'grabbing';
|
||||
embyScrollContainerRef.current.style.userSelect = 'none';
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (!embyScrollContainerRef.current) return;
|
||||
isDraggingRef.current = false;
|
||||
embyScrollContainerRef.current.style.cursor = 'grab';
|
||||
embyScrollContainerRef.current.style.userSelect = 'auto';
|
||||
}}
|
||||
onMouseUp={() => {
|
||||
if (!embyScrollContainerRef.current) return;
|
||||
isDraggingRef.current = false;
|
||||
embyScrollContainerRef.current.style.cursor = 'grab';
|
||||
embyScrollContainerRef.current.style.userSelect = 'auto';
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
if (!isDraggingRef.current || !embyScrollContainerRef.current) return;
|
||||
e.preventDefault();
|
||||
const x = e.pageX - embyScrollContainerRef.current.offsetLeft;
|
||||
const walk = (x - startXRef.current) * 2;
|
||||
embyScrollContainerRef.current.scrollLeft = scrollLeftRef.current - walk;
|
||||
}}
|
||||
>
|
||||
<div className='flex gap-2 px-4 min-w-min'>
|
||||
{embySourceOptions.map((option) => (
|
||||
<button
|
||||
key={option.key}
|
||||
onClick={() => setEmbyKey(option.key)}
|
||||
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0 ${
|
||||
embyKey === option.key
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{option.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 第三级:Emby 媒体库分类选择器 */}
|
||||
{sourceType === 'emby' && (
|
||||
<div className='mb-6'>
|
||||
{loadingViews ? (
|
||||
<div className='flex justify-center'>
|
||||
@@ -391,7 +525,7 @@ export default function PrivateLibraryPage() {
|
||||
) : videos.length === 0 ? (
|
||||
<div className='text-center py-12'>
|
||||
<p className='text-gray-500 dark:text-gray-400'>
|
||||
{source === 'openlist'
|
||||
{sourceType === 'openlist'
|
||||
? '暂无视频,请在管理面板配置 OpenList 并刷新'
|
||||
: '暂无视频,请在管理面板配置 Emby'}
|
||||
</p>
|
||||
@@ -399,24 +533,33 @@ export default function PrivateLibraryPage() {
|
||||
) : (
|
||||
<>
|
||||
<div className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4'>
|
||||
{videos.map((video) => (
|
||||
<VideoCard
|
||||
key={video.id}
|
||||
id={video.id}
|
||||
source={source}
|
||||
title={video.title}
|
||||
poster={video.poster}
|
||||
year={video.year || (video.releaseDate ? video.releaseDate.split('-')[0] : '')}
|
||||
rate={
|
||||
video.rating
|
||||
? video.rating.toFixed(1)
|
||||
: video.voteAverage && video.voteAverage > 0
|
||||
? video.voteAverage.toFixed(1)
|
||||
: ''
|
||||
}
|
||||
from='search'
|
||||
/>
|
||||
))}
|
||||
{videos.map((video) => {
|
||||
// 构建source参数用于VideoCard
|
||||
// 如果是emby源且有embyKey,使用下划线格式
|
||||
let sourceParam = sourceType;
|
||||
if (sourceType === 'emby' && embyKey) {
|
||||
sourceParam = `emby_${embyKey}`;
|
||||
}
|
||||
|
||||
return (
|
||||
<VideoCard
|
||||
key={video.id}
|
||||
id={video.id}
|
||||
source={sourceParam}
|
||||
title={video.title}
|
||||
poster={video.poster}
|
||||
year={video.year || (video.releaseDate ? video.releaseDate.split('-')[0] : '')}
|
||||
rate={
|
||||
video.rating
|
||||
? video.rating.toFixed(1)
|
||||
: video.voteAverage && video.voteAverage > 0
|
||||
? video.voteAverage.toFixed(1)
|
||||
: ''
|
||||
}
|
||||
from='search'
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 滚动加载指示器 - 始终渲染以便 observer 可以监听 */}
|
||||
|
||||
@@ -292,19 +292,23 @@ function SearchPageClient() {
|
||||
{ label: '全部来源', value: 'all' },
|
||||
...Array.from(sourcesSet.entries())
|
||||
.sort((a, b) => {
|
||||
// 优先排序:emby 和 openlist 置于最前
|
||||
const prioritySources = ['emby', 'openlist'];
|
||||
const aIsPriority = prioritySources.includes(a[0]);
|
||||
const bIsPriority = prioritySources.includes(b[0]);
|
||||
// 判断是否为 openlist
|
||||
const aIsOpenList = a[0] === 'openlist';
|
||||
const bIsOpenList = b[0] === 'openlist';
|
||||
|
||||
if (aIsPriority && !bIsPriority) return -1;
|
||||
if (!aIsPriority && bIsPriority) return 1;
|
||||
if (aIsPriority && bIsPriority) {
|
||||
// 两者都是优先源,按照 prioritySources 数组顺序排列
|
||||
return prioritySources.indexOf(a[0]) - prioritySources.indexOf(b[0]);
|
||||
// 判断是否为 emby 源(包括 emby 和 emby:xxx 格式)
|
||||
const aIsEmby = a[0] === 'emby' || a[0].startsWith('emby:');
|
||||
const bIsEmby = b[0] === 'emby' || b[0].startsWith('emby:');
|
||||
|
||||
// 优先级:OpenList(100) > Emby(90) > 其他(0)
|
||||
const aPriority = aIsOpenList ? 100 : aIsEmby ? 90 : 0;
|
||||
const bPriority = bIsOpenList ? 100 : bIsEmby ? 90 : 0;
|
||||
|
||||
if (aPriority !== bPriority) {
|
||||
return bPriority - aPriority; // 降序排列
|
||||
}
|
||||
|
||||
// 其他来源按字母顺序排列
|
||||
// 同优先级内按名称排序
|
||||
return a[1].localeCompare(b[1]);
|
||||
})
|
||||
.map(([value, label]) => ({ label, value })),
|
||||
|
||||
@@ -897,7 +897,7 @@ const EpisodeSelector: React.FC<EpisodeSelectorProps> = ({
|
||||
{/* 重新测试按钮 */}
|
||||
{(() => {
|
||||
// 私人影库和 Emby 不显示重新测试按钮
|
||||
if (source.source === 'openlist' || source.source === 'emby') {
|
||||
if (source.source === 'openlist' || source.source === 'emby' || source.source.startsWith('emby_')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -1219,7 +1219,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' ? 'border-yellow-500' : 'border-white/60'
|
||||
actualSource === 'openlist' || actualSource === 'emby' || actualSource.startsWith('emby_') ? 'border-yellow-500' : 'border-white/60'
|
||||
}`}
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
@@ -1269,7 +1269,7 @@ const VideoCard = forwardRef<VideoCardHandle, VideoCardProps>(function VideoCard
|
||||
<div className='flex items-center justify-end'>
|
||||
<span
|
||||
className={`inline-block border rounded px-1 py-0.5 text-[8px] text-white/90 bg-black/30 backdrop-blur-sm ${
|
||||
origin === 'live' ? 'border-red-500' : actualSource === 'openlist' || actualSource === 'emby' ? 'border-yellow-500' : 'border-white/60'
|
||||
origin === 'live' ? 'border-red-500' : actualSource === 'openlist' || actualSource === 'emby' || actualSource.startsWith('emby_') ? 'border-yellow-500' : 'border-white/60'
|
||||
}`}
|
||||
style={{
|
||||
WebkitUserSelect: 'none',
|
||||
|
||||
@@ -156,16 +156,33 @@ export interface AdminConfig {
|
||||
SystemPrompt?: string; // 自定义系统提示词
|
||||
};
|
||||
EmbyConfig?: {
|
||||
Enabled: boolean; // 是否启用Emby媒体库功能
|
||||
ServerURL: string; // Emby服务器地址
|
||||
ApiKey?: string; // API Key(推荐方式)
|
||||
Username?: string; // 用户名(或使用API Key)
|
||||
Password?: string; // 密码
|
||||
UserId?: string; // 用户ID(登录后获取)
|
||||
AuthToken?: string; // 认证令牌(用户名密码登录后获取)
|
||||
Libraries?: string[]; // 要显示的媒体库ID(可选,默认全部)
|
||||
LastSyncTime?: number; // 最后同步时间戳
|
||||
ItemCount?: number; // 媒体项数量
|
||||
// 新格式:多源配置(推荐)
|
||||
Sources?: Array<{
|
||||
key: string; // 唯一标识,如 'emby1', 'emby2'
|
||||
name: string; // 显示名称,如 '家庭Emby', '公司Emby'
|
||||
enabled: boolean; // 是否启用
|
||||
ServerURL: string; // Emby服务器地址
|
||||
ApiKey?: string; // API Key(推荐方式)
|
||||
Username?: string; // 用户名(或使用API Key)
|
||||
Password?: string; // 密码
|
||||
UserId?: string; // 用户ID(登录后获取)
|
||||
AuthToken?: string; // 认证令牌(用户名密码登录后获取)
|
||||
Libraries?: string[]; // 要显示的媒体库ID(可选,默认全部)
|
||||
LastSyncTime?: number; // 最后同步时间戳
|
||||
ItemCount?: number; // 媒体项数量
|
||||
isDefault?: boolean; // 是否为默认源(用于向后兼容)
|
||||
}>;
|
||||
// 旧格式:单源配置(向后兼容)
|
||||
Enabled?: boolean;
|
||||
ServerURL?: string;
|
||||
ApiKey?: string;
|
||||
Username?: string;
|
||||
Password?: string;
|
||||
UserId?: string;
|
||||
AuthToken?: string;
|
||||
Libraries?: string[];
|
||||
LastSyncTime?: number;
|
||||
ItemCount?: number;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -335,9 +335,25 @@ export async function getConfig(): Promise<AdminConfig> {
|
||||
await db.saveAdminConfig(adminConfig);
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否有旧格式Emby配置需要迁移
|
||||
const needsEmbyMigration = adminConfig.EmbyConfig &&
|
||||
adminConfig.EmbyConfig.ServerURL &&
|
||||
!adminConfig.EmbyConfig.Sources;
|
||||
|
||||
adminConfig = configSelfCheck(adminConfig);
|
||||
cachedConfig = adminConfig;
|
||||
|
||||
// 如果进行了Emby配置迁移,保存到数据库
|
||||
if (!dbReadFailed && needsEmbyMigration) {
|
||||
try {
|
||||
await db.saveAdminConfig(adminConfig);
|
||||
console.log('[Config] Emby配置迁移已保存到数据库');
|
||||
} catch (error) {
|
||||
console.error('[Config] 保存迁移后的配置失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 自动迁移用户(如果配置中有用户且V2存储支持)
|
||||
// 过滤掉站长后检查是否有需要迁移的用户
|
||||
const nonOwnerUsers = adminConfig.UserConfig.Users.filter(
|
||||
@@ -476,6 +492,44 @@ export function configSelfCheck(adminConfig: AdminConfig): AdminConfig {
|
||||
return true;
|
||||
});
|
||||
|
||||
// Emby配置迁移:将旧格式迁移到新格式
|
||||
if (adminConfig.EmbyConfig) {
|
||||
// 如果是旧格式(有ServerURL但没有Sources)
|
||||
if (adminConfig.EmbyConfig.ServerURL && !adminConfig.EmbyConfig.Sources) {
|
||||
console.log('[Config] 检测到旧格式Emby配置,自动迁移到新格式');
|
||||
const oldConfig = adminConfig.EmbyConfig;
|
||||
adminConfig.EmbyConfig = {
|
||||
Sources: [{
|
||||
key: 'default',
|
||||
name: 'Emby',
|
||||
enabled: oldConfig.Enabled ?? false,
|
||||
ServerURL: oldConfig.ServerURL,
|
||||
ApiKey: oldConfig.ApiKey,
|
||||
Username: oldConfig.Username,
|
||||
Password: oldConfig.Password,
|
||||
UserId: oldConfig.UserId,
|
||||
AuthToken: oldConfig.AuthToken,
|
||||
Libraries: oldConfig.Libraries,
|
||||
LastSyncTime: oldConfig.LastSyncTime,
|
||||
ItemCount: oldConfig.ItemCount,
|
||||
isDefault: true,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
// Emby源去重
|
||||
if (adminConfig.EmbyConfig.Sources) {
|
||||
const seenEmbyKeys = new Set<string>();
|
||||
adminConfig.EmbyConfig.Sources = adminConfig.EmbyConfig.Sources.filter((source) => {
|
||||
if (seenEmbyKeys.has(source.key)) {
|
||||
return false;
|
||||
}
|
||||
seenEmbyKeys.add(source.key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return adminConfig;
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,9 @@ const EMBY_VIEWS_CACHE_KEY = 'emby:views';
|
||||
/**
|
||||
* 生成 Emby 列表缓存键
|
||||
*/
|
||||
function makeListCacheKey(page: number, pageSize: number, parentId?: string): string {
|
||||
return parentId ? `emby:list:${page}:${pageSize}:${parentId}` : `emby:list:${page}:${pageSize}`;
|
||||
function makeListCacheKey(page: number, pageSize: number, parentId?: string, embyKey?: string): string {
|
||||
const keyPrefix = embyKey ? `emby:${embyKey}` : 'emby';
|
||||
return parentId ? `${keyPrefix}:list:${page}:${pageSize}:${parentId}` : `${keyPrefix}:list:${page}:${pageSize}`;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -25,9 +26,10 @@ function makeListCacheKey(page: number, pageSize: number, parentId?: string): st
|
||||
export function getCachedEmbyList(
|
||||
page: number,
|
||||
pageSize: number,
|
||||
parentId?: string
|
||||
parentId?: string,
|
||||
embyKey?: string
|
||||
): any | null {
|
||||
const key = makeListCacheKey(page, pageSize, parentId);
|
||||
const key = makeListCacheKey(page, pageSize, parentId, embyKey);
|
||||
const entry = EMBY_CACHE.get(key);
|
||||
if (!entry) return null;
|
||||
|
||||
@@ -47,10 +49,11 @@ export function setCachedEmbyList(
|
||||
page: number,
|
||||
pageSize: number,
|
||||
data: any,
|
||||
parentId?: string
|
||||
parentId?: string,
|
||||
embyKey?: string
|
||||
): void {
|
||||
const now = Date.now();
|
||||
const key = makeListCacheKey(page, pageSize, parentId);
|
||||
const key = makeListCacheKey(page, pageSize, parentId, embyKey);
|
||||
EMBY_CACHE.set(key, {
|
||||
expiresAt: now + EMBY_CACHE_TTL_MS,
|
||||
data,
|
||||
@@ -69,13 +72,14 @@ export function clearEmbyCache(): { cleared: number } {
|
||||
/**
|
||||
* 获取缓存的 Emby 媒体库列表
|
||||
*/
|
||||
export function getCachedEmbyViews(): any | null {
|
||||
const entry = EMBY_CACHE.get(EMBY_VIEWS_CACHE_KEY);
|
||||
export function getCachedEmbyViews(embyKey: string = 'default'): any | null {
|
||||
const cacheKey = `${EMBY_VIEWS_CACHE_KEY}:${embyKey}`;
|
||||
const entry = EMBY_CACHE.get(cacheKey);
|
||||
if (!entry) return null;
|
||||
|
||||
// 检查是否过期
|
||||
if (entry.expiresAt <= Date.now()) {
|
||||
EMBY_CACHE.delete(EMBY_VIEWS_CACHE_KEY);
|
||||
EMBY_CACHE.delete(cacheKey);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -85,9 +89,10 @@ export function getCachedEmbyViews(): any | null {
|
||||
/**
|
||||
* 设置缓存的 Emby 媒体库列表
|
||||
*/
|
||||
export function setCachedEmbyViews(data: any): void {
|
||||
export function setCachedEmbyViews(embyKey: string = 'default', data: any): void {
|
||||
const now = Date.now();
|
||||
EMBY_CACHE.set(EMBY_VIEWS_CACHE_KEY, {
|
||||
const cacheKey = `${EMBY_VIEWS_CACHE_KEY}:${embyKey}`;
|
||||
EMBY_CACHE.set(cacheKey, {
|
||||
expiresAt: now + EMBY_VIEWS_CACHE_TTL_MS,
|
||||
data,
|
||||
});
|
||||
|
||||
182
src/lib/emby-manager.ts
Normal file
182
src/lib/emby-manager.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
|
||||
import { EmbyClient } from './emby.client';
|
||||
import { getConfig } from './config';
|
||||
import { AdminConfig } from './admin.types';
|
||||
|
||||
interface EmbySourceConfig {
|
||||
key: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
ServerURL: string;
|
||||
ApiKey?: string;
|
||||
Username?: string;
|
||||
Password?: string;
|
||||
UserId?: string;
|
||||
AuthToken?: string;
|
||||
Libraries?: string[];
|
||||
LastSyncTime?: number;
|
||||
ItemCount?: number;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
class EmbyManager {
|
||||
private static instance: EmbyManager;
|
||||
private clients: Map<string, EmbyClient> = new Map();
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): EmbyManager {
|
||||
if (!EmbyManager.instance) {
|
||||
EmbyManager.instance = new EmbyManager();
|
||||
}
|
||||
return EmbyManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从配置中获取所有Emby源(支持新旧格式)
|
||||
*/
|
||||
private async getSources(): Promise<EmbySourceConfig[]> {
|
||||
const config = await getConfig();
|
||||
|
||||
// 如果是新格式(Sources数组)
|
||||
if (config.EmbyConfig?.Sources && Array.isArray(config.EmbyConfig.Sources)) {
|
||||
return config.EmbyConfig.Sources;
|
||||
}
|
||||
|
||||
// 如果是旧格式(单源配置),转换为数组格式
|
||||
if (config.EmbyConfig?.ServerURL) {
|
||||
return [{
|
||||
key: 'default',
|
||||
name: 'Emby',
|
||||
enabled: config.EmbyConfig.Enabled ?? false,
|
||||
ServerURL: config.EmbyConfig.ServerURL,
|
||||
ApiKey: config.EmbyConfig.ApiKey,
|
||||
Username: config.EmbyConfig.Username,
|
||||
Password: config.EmbyConfig.Password,
|
||||
UserId: config.EmbyConfig.UserId,
|
||||
AuthToken: config.EmbyConfig.AuthToken,
|
||||
Libraries: config.EmbyConfig.Libraries,
|
||||
LastSyncTime: config.EmbyConfig.LastSyncTime,
|
||||
ItemCount: config.EmbyConfig.ItemCount,
|
||||
isDefault: true,
|
||||
}];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定key的EmbyClient
|
||||
* @param key Emby源的key,如果不指定则使用默认源
|
||||
*/
|
||||
async getClient(key?: string): Promise<EmbyClient> {
|
||||
const sources = await this.getSources();
|
||||
|
||||
if (sources.length === 0) {
|
||||
throw new Error('未配置 Emby 源');
|
||||
}
|
||||
|
||||
// 如果没有指定key,使用默认源(第一个或标记为default的)
|
||||
if (!key) {
|
||||
const defaultSource = sources.find(s => s.isDefault) || sources[0];
|
||||
key = defaultSource.key;
|
||||
}
|
||||
|
||||
// 从缓存获取或创建新实例
|
||||
if (!this.clients.has(key)) {
|
||||
const sourceConfig = sources.find(s => s.key === key);
|
||||
if (!sourceConfig) {
|
||||
throw new Error(`未找到 Emby 源: ${key}`);
|
||||
}
|
||||
|
||||
if (!sourceConfig.enabled) {
|
||||
throw new Error(`Emby 源已禁用: ${sourceConfig.name}`);
|
||||
}
|
||||
|
||||
this.clients.set(key, new EmbyClient(sourceConfig));
|
||||
}
|
||||
|
||||
return this.clients.get(key)!;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有启用的EmbyClient
|
||||
*/
|
||||
async getAllClients(): Promise<Map<string, { client: EmbyClient; config: EmbySourceConfig }>> {
|
||||
const sources = await this.getSources();
|
||||
const enabledSources = sources.filter(s => s.enabled);
|
||||
const result = new Map<string, { client: EmbyClient; config: EmbySourceConfig }>();
|
||||
|
||||
for (const source of enabledSources) {
|
||||
if (!this.clients.has(source.key)) {
|
||||
this.clients.set(source.key, new EmbyClient(source));
|
||||
}
|
||||
result.set(source.key, {
|
||||
client: this.clients.get(source.key)!,
|
||||
config: source,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有启用的Emby源配置
|
||||
*/
|
||||
async getEnabledSources(): Promise<EmbySourceConfig[]> {
|
||||
const sources = await this.getSources();
|
||||
return sources.filter(s => s.enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否配置了Emby
|
||||
*/
|
||||
async hasEmby(): Promise<boolean> {
|
||||
const sources = await this.getSources();
|
||||
return sources.some(s => s.enabled && s.ServerURL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除缓存的客户端实例
|
||||
*/
|
||||
clearCache() {
|
||||
this.clients.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export const embyManager = EmbyManager.getInstance();
|
||||
|
||||
/**
|
||||
* 配置迁移函数:将旧格式配置迁移到新格式
|
||||
*/
|
||||
export function migrateEmbyConfig(config: AdminConfig): AdminConfig {
|
||||
// 如果已经是新格式,直接返回
|
||||
if (config.EmbyConfig?.Sources) {
|
||||
return config;
|
||||
}
|
||||
|
||||
// 如果是旧格式,迁移到新格式
|
||||
if (config.EmbyConfig && config.EmbyConfig.ServerURL) {
|
||||
const oldConfig = config.EmbyConfig;
|
||||
config.EmbyConfig = {
|
||||
Sources: [{
|
||||
key: 'default',
|
||||
name: 'Emby',
|
||||
enabled: oldConfig.Enabled ?? false,
|
||||
ServerURL: oldConfig.ServerURL,
|
||||
ApiKey: oldConfig.ApiKey,
|
||||
Username: oldConfig.Username,
|
||||
Password: oldConfig.Password,
|
||||
UserId: oldConfig.UserId,
|
||||
AuthToken: oldConfig.AuthToken,
|
||||
Libraries: oldConfig.Libraries,
|
||||
LastSyncTime: oldConfig.LastSyncTime,
|
||||
ItemCount: oldConfig.ItemCount,
|
||||
isDefault: true,
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
Reference in New Issue
Block a user