emby支持多源

This commit is contained in:
mtvpls
2026-01-08 00:14:07 +08:00
parent b61db02478
commit 2736c9e845
22 changed files with 1316 additions and 572 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 可以监听 */}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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