Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG
Original file line number Diff line number Diff line change
@@ -1,3 +1,15 @@
## [214.1.0] - 2026-03-10
### Changed
- emby兼容jellyfin
- emby支持无密码登录
- ai问片移除think块
- ai问片可点击标题播放
- 优化ai问片流式处理,系统提示词增加日期,优化决策json提取

### Fixed
- 修复filesystem模式取消下载不删除文件
- 修复未配置TVBOX_SUBSCRIBE_TOKEN不代理emby图片

## [214.0.0] - 2026-03-04
### Added
- 下载新增File System Api模式,实现浏览器本地播放
Expand Down
2 changes: 1 addition & 1 deletion VERSION.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
214.0.0
214.1.0

183 changes: 129 additions & 54 deletions src/app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3514,6 +3514,7 @@ const EmbyConfigComponent = ({
proxyPlay: false,
customUserAgent: '',
});
const [authMode, setAuthMode] = useState<'apikey' | 'password'>('apikey');

// 从配置加载源列表
useEffect(() => {
Expand Down Expand Up @@ -3554,13 +3555,22 @@ const EmbyConfigComponent = ({
proxyPlay: false,
customUserAgent: '',
});
setAuthMode('apikey');
setEditingSource(null);
setShowAddForm(false);
};

// 开始编辑
const handleEdit = (source: any) => {
setFormData({ ...source });
// 根据现有配置判断认证方式
if (source.ApiKey) {
setAuthMode('apikey');
} else if (source.Username) {
setAuthMode('password');
} else {
setAuthMode('apikey');
}
setEditingSource(source);
setShowAddForm(false);
};
Expand All @@ -3579,6 +3589,19 @@ const EmbyConfigComponent = ({
return;
}

// 根据认证方式验证必填字段
if (authMode === 'apikey') {
if (!formData.ApiKey || !formData.UserId) {
showError('使用密钥认证时,API Key 和用户 ID 为必填项', showAlert);
return;
}
} else if (authMode === 'password') {
if (!formData.Username) {
showError('使用账号认证时,用户名为必填项', showAlert);
return;
}
}

// 验证key唯一性
if (!editingSource && sources.some(s => s.key === formData.key)) {
showError('标识符已存在,请使用其他标识符', showAlert);
Expand Down Expand Up @@ -4088,67 +4111,119 @@ const EmbyConfigComponent = ({
/>
</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 className='flex gap-2 mb-4'>
<button
type='button'
onClick={() => {
setAuthMode('apikey');
// 切换到密钥认证时,清空用户名密码
setFormData({ ...formData, Username: '', Password: '' });
}}
className={`flex-1 px-4 py-2 rounded-lg font-medium transition-colors ${
authMode === 'apikey'
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
密钥认证
</button>
<button
type='button'
onClick={() => {
setAuthMode('password');
// 切换到账号认证时,清空 API Key 和 UserId
setFormData({ ...formData, ApiKey: '', UserId: '' });
}}
className={`flex-1 px-4 py-2 rounded-lg font-medium transition-colors ${
authMode === 'password'
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
账号认证
</button>
</div>
</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>
{/* 密钥认证模式 */}
{authMode === 'apikey' && (
<>
{/* 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'>
在 Emby 控制台的 API 密钥页面生成
</p>
</div>

{/* 密码 */}
<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 *
</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>
</>
)}

{/* 用户 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>
{/* 账号认证模式 */}
{authMode === 'password' && (
<>
{/* 用户名 */}
<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>
<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'
/>
<p className='mt-1 text-xs text-gray-500 dark:text-gray-400'>
如果 Emby 账号没有设置密码,可以留空
</p>
</div>
</>
)}

{/* 启用开关 */}
<div className='flex items-center justify-between'>
Expand Down
8 changes: 4 additions & 4 deletions src/app/api/admin/emby/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: '请填写 Emby 服务器地址' }, { status: 400 });
}

if (!ApiKey && (!Username || !Password)) {
if (!ApiKey && !Username) {
return NextResponse.json(
{ error: '请填写 API Key 或用户名密码' },
{ error: '请填写 API Key 或用户名' },
{ status: 400 }
);
}
Expand All @@ -69,9 +69,9 @@ export async function POST(request: NextRequest) {
const client = new EmbyClient(testConfig);

// 如果使用用户名密码,先认证
if (!ApiKey && Username && Password) {
if (!ApiKey && Username) {
try {
await client.authenticate(Username, Password);
await client.authenticate(Username, Password || '');
} catch (error) {
return NextResponse.json(
{ success: false, message: 'Emby 认证失败: ' + (error as Error).message },
Expand Down
81 changes: 76 additions & 5 deletions src/app/api/ai/chat/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,13 +73,25 @@ function transformToSSE(

return new ReadableStream({
async start(controller) {
let buffer = ''; // 缓冲区,用于保存不完整的行
let contentBuffer = ''; // 累积的内容,用于处理跨chunk的thinking标签
let inThinkingBlock = false; // 是否在thinking块内

try {
while (true) {
const { done, value } = await reader.read();
if (done) break;

const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n').filter((line) => line.trim() !== '');
// 将新chunk与缓冲区拼接
const text = buffer + chunk;
// 按换行符分割,最后一个元素可能是不完整的行
const parts = text.split('\n');
// 保存最后一个不完整的行到缓冲区
buffer = parts.pop() || '';

// 处理完整的行
const lines = parts.filter((line) => line.trim() !== '');

for (const line of lines) {
if (line.startsWith('data: ')) {
Expand Down Expand Up @@ -113,9 +125,32 @@ function transformToSSE(
}

if (text) {
controller.enqueue(
new TextEncoder().encode(`data: ${JSON.stringify({ text })}\n\n`)
);
// 累积内容并处理thinking标签
contentBuffer += text;

// 检查是否进入thinking块
if (contentBuffer.includes('<think>')) {
inThinkingBlock = true;
}

// 检查是否退出thinking块
if (inThinkingBlock && contentBuffer.includes('</think>')) {
// 移除thinking块内容
contentBuffer = contentBuffer.replace(/<think>[\s\S]*?<\/think>/g, '');
inThinkingBlock = false;
}

// 只有在不在thinking块内时才输出内容
if (!inThinkingBlock) {
// 输出非thinking部分的内容
const outputText = contentBuffer;
if (outputText) {
controller.enqueue(
new TextEncoder().encode(`data: ${JSON.stringify({ text: outputText })}\n\n`)
);
contentBuffer = ''; // 清空已输出的内容
}
}
}
} catch (e) {
// 只在非空数据解析失败时打印错误
Expand All @@ -126,6 +161,39 @@ function transformToSSE(
}
}
}

// 处理缓冲区中剩余的数据
if (buffer.trim()) {
const line = buffer.trim();
if (line.startsWith('data: ')) {
const data = line.slice(6).trim();
if (data && data !== '[DONE]') {
try {
const json = JSON.parse(data);
let text = '';
if (provider === 'claude') {
if (json.type === 'content_block_delta') {
text = json.delta?.text || '';
}
} else {
text = json.choices?.[0]?.delta?.content || '';
}
if (text) {
contentBuffer += text;
// 最后清理一次thinking标签
contentBuffer = contentBuffer.replace(/<think>[\s\S]*?<\/think>/g, '');
if (contentBuffer) {
controller.enqueue(
new TextEncoder().encode(`data: ${JSON.stringify({ text: contentBuffer })}\n\n`)
);
}
}
} catch (e) {
console.error('Parse final buffer error:', e);
}
}
}
}
} catch (error) {
console.error('Stream error:', error);
controller.error(error);
Expand Down Expand Up @@ -261,7 +329,10 @@ export async function POST(request: NextRequest) {
// 非流式响应:等待完整响应后返回JSON
const response = result as Response;
const data = await response.json();
const content = data.choices?.[0]?.message?.content || '';
let content = data.choices?.[0]?.message?.content || '';

// 移除thinking标签内容
content = content.replace(/<think>[\s\S]*?<\/think>/g, '');

return NextResponse.json({ content });
}
Expand Down
Loading