feat(ai): add configurable scanning and chapter summaries

This commit is contained in:
hectorqin committed 2026-09-29 11:57:45 +08:00
1 parent 713d1eb5aa
commit 61e70ec418
12 files changed
+115 -6

No files matched your search

+2
View File
@@ -17,6 +17,7 @@ import { isOriginAllowed, resolveCorsOrigin } from './cors.ts';
import {BusinessSettings} from '../services/business-settings.ts';
import {TtsService} from '../services/tts.ts';
import {registerBusinessSettingsRoutes} from './routes/business-settings.ts';
import {registerAiRoutes} from './routes/ai.ts';
export function buildApp(ctx: AppContext,mediaRuntime?:MediaService): FastifyInstance {
ctx.settings??=new BusinessSettings(ctx.db,ctx.config);
@@ -70,6 +71,7 @@ export function buildApp(ctx: AppContext,mediaRuntime?:MediaService): FastifyIns
registerAuthRoutes(app, ctx);
registerBusinessSettingsRoutes(app,ctx);
registerAiRoutes(app,ctx);
registerLibraryRoutes(app, ctx);
registerSyncRoutes(app, ctx);
registerTtsRoutes(app, ctx);
+11
View File
@@ -0,0 +1,11 @@
import type {FastifyInstance} from 'fastify';
import type {AppContext} from '../context.ts';
import {authenticate,currentUser,requireAdmin} from '../auth.ts';
import {badRequest} from '../../lib/errors.ts';
import {AiService} from '../../services/ai.ts';
import {BusinessSettingsReader} from '../../services/business-settings.ts';
export function registerAiRoutes(app:FastifyInstance,ctx:AppContext){
const service=new AiService(ctx.db,()=>new BusinessSettingsReader(ctx.db).read('ai')); const auth=authenticate(ctx), admin=async(req:any,reply:any)=>{await auth(req,reply);requireAdmin(req);};
app.post<{Body:{baseUrl?:string;apiKey?:string}}>('/api/v1/admin/ai/models',{preHandler:admin,schema:{body:{type:'object',additionalProperties:false,properties:{baseUrl:{type:'string',maxLength:4096},apiKey:{type:'string',maxLength:4096}}}}},async req=>({models:await service.models(req.body?.baseUrl,req.body?.apiKey)}));
app.post<{Body:{bookId:string;chapterId:string;content:string}}>('/api/v1/ai/summary',{preHandler:auth},async req=>{const body=req.body;if(!body||typeof body.bookId!=='string'||typeof body.chapterId!=='string'||typeof body.content!=='string')throw badRequest('缺少章节内容'); return service.summary(currentUser(req).id,body.bookId,body.chapterId,body.content);});
}
+10
View File
@@ -31,6 +31,7 @@ import type { MediaDatabase } from '../../media/read-database.ts';
import type { Db } from '../../db/index.ts';
import {BusinessSettingsReader} from '../../services/business-settings.ts';
import {configuredProviders} from '../../media/configured-providers.ts';
import {AiService} from '../../services/ai.ts';
export type MediaRouteContext=Pick<AppContext,'config'> & {db:MediaDatabase & Pick<Db,'prepare'>;users:Pick<AppContext['users'],'byId'>};
@@ -39,6 +40,7 @@ export function registerMediaRoutes(app:FastifyInstance,ctx:MediaRouteContext,op
const accounts=new DatabaseMediaAccounts(ctx.db);
const references=database!==ctx.db?new MediaAccountReferences(database,accounts):undefined;
const settings=new BusinessSettingsReader(ctx.db);
const ai=new AiService(database,()=>settings.read('ai'));
const libraries=new MediaLibraries(database,true,accounts,references),scanner=new MediaScanner(database,libraries,undefined,app.log,()=>settings.read('scanning'));
const playback=new MediaPlayback(database,libraries,accounts,()=>settings.read('playback').mode);
const background=new MediaBackgroundGrants(database,libraries,accounts);
@@ -71,6 +73,14 @@ export function registerMediaRoutes(app:FastifyInstance,ctx:MediaRouteContext,op
return result.result;
};
app.get<{Params:{id:string};Querystring:{path?:string;offset?:number;limit?:number}}>('/api/v1/media/libraries/:id/folders',{preHandler:auth,schema:{querystring:{type:'object',properties:{path:{type:'string',maxLength:4000},offset:{type:'integer',minimum:0,default:0},limit:{type:'integer',minimum:1,maximum:200,default:60}}}}},async (request,reply)=>queryCatalog(request,reply,{method:'folders',args:[currentUser(request),request.params.id,request.query.path,request.query.offset,request.query.limit]}));
app.post<{Params:{id:string};Body:{path?:string}}>('/api/v1/media/libraries/:id/folders/ai-scan',{preHandler:[auth,async request=>requireAdmin(request)],schema:{body:{type:'object',additionalProperties:false,properties:{path:{type:'string',maxLength:4000}}}}},async request=>{
libraries.get(currentUser(request),request.params.id);
const path=(request.body?.path??'').replace(/^\/+|\/+$/g,'');
if(path.split('/').includes('..')||path.includes('\\'))throw badRequest('目录路径无效');
const prefix=path?path+'/':'';
const rows=database.all<{ref:string}>('SELECT ref FROM media_assets WHERE library_id=? AND available=1 ORDER BY ref',request.params.id);
return ai.scanBatches(request.params.id,rows.map(row=>row.ref).filter(ref=>ref.startsWith(prefix)));
});
app.get<{Params:{id:string};Querystring:{path:string}}>('/api/v1/media/libraries/:id/missing-resources',{preHandler:[auth,async request=>requireAdmin(request)],schema:{querystring:{type:'object',additionalProperties:false,required:['path'],properties:{path:{type:'string',maxLength:4000}}}}},async(request,reply)=>reply.header('cache-control','no-store').send(cleanup.preview(currentUser(request),request.params.id,request.query.path)));
app.post<{Params:{id:string};Body:{path:string;revision:string}}>('/api/v1/media/libraries/:id/missing-resources/cleanup',{preHandler:[auth,async request=>requireAdmin(request)],schema:{body:{type:'object',additionalProperties:false,required:['path','revision'],properties:{path:{type:'string',maxLength:4000},revision:{type:'string',pattern:'^[a-f0-9]{64}$'}}}}},async(request,reply)=>reply.header('cache-control','no-store').send(cleanup.remove(currentUser(request),request.params.id,request.body.path,request.body.revision)));
app.get<{Params:{id:string};Querystring:{path:string}}>('/api/v1/media/libraries/:id/recognition-rule',{preHandler:[auth,async request=>requireAdmin(request)],schema:{querystring:{type:'object',additionalProperties:false,required:['path'],properties:{path:{type:'string',maxLength:4000}}}}},async(request,reply)=>reply.header('cache-control','no-store').send(directoryRules.get(currentUser(request),libraries,request.params.id,request.query.path)));
+35
View File
@@ -0,0 +1,35 @@
import {createHash, randomUUID} from 'node:crypto';
import type {Db} from '../db/index.ts';
import type {BusinessValues} from './business-settings.ts';
import {AppError, badRequest} from '../lib/errors.ts';
export type AiConfig=BusinessValues['ai'];
export class AiService {
constructor(private readonly db:Pick<Db,'run'|'get'|'transaction'>, private readonly config:()=>AiConfig){
db.run(`CREATE TABLE IF NOT EXISTS ai_scan_results(id TEXT PRIMARY KEY,library_id TEXT NOT NULL,path TEXT NOT NULL,category TEXT NOT NULL,payload_json TEXT NOT NULL,raw TEXT NOT NULL,created_at INTEGER NOT NULL,UNIQUE(library_id,path))`);
db.run(`CREATE TABLE IF NOT EXISTS ai_summaries(user_id TEXT NOT NULL,book_id TEXT NOT NULL,chapter_id TEXT NOT NULL,content_hash TEXT NOT NULL,prompt_hash TEXT NOT NULL,summary TEXT NOT NULL,created_at INTEGER NOT NULL,PRIMARY KEY(user_id,book_id,chapter_id,content_hash,prompt_hash))`);
}
private async fetch(url:string,init:RequestInit):Promise<Response>{
let parsed:URL;try{parsed=new URL(url);}catch{throw badRequest('请输入有效的 AI 服务地址');}
if(!['http:','https:'].includes(parsed.protocol)||parsed.username||parsed.password)throw badRequest('请输入有效的 HTTP 或 HTTPS 地址');
try{return await fetch(url,{...init,redirect:'error'});}catch{throw new AppError(502,'AI_CONNECTION_FAILED','AI 连接失败或超时,请检查服务地址及网络');}
}
async scanBatches(libraryId:string,paths:string[]){
const c=this.settings('scanEnabled'),groups=new Map<string,string[]>();
for(const path of paths){const index=path.lastIndexOf('/'),dir=index<0?'':path.slice(0,index);const group=groups.get(dir)??[];group.push(path);groups.set(dir,group);}
const batches:string[][]=[];let batch:string[]=[];
for(const group of groups.values()){if(batch.length&&batch.length+group.length>c.batchSize){batches.push(batch);batch=[];}batch.push(...group);}
if(batch.length)batches.push(batch);
const items:unknown[]=[];for(const part of batches)items.push(...(await this.scan(libraryId,part)).items);
return {items,total:paths.length,batches:batches.length};
}
private settings(required:'scanEnabled'|'summaryEnabled') { const c=this.config(); if(!c.enabled||!c[required]) throw new AppError(403,'AI_DISABLED','AI 功能未启用'); if(!c.baseUrl||!c.apiKey||!c.model) throw badRequest('AI 尚未完成配置'); return c; }
private async chat(prompt:string,system:string,c:AiConfig):Promise<string>{
const base=c.baseUrl.replace(/\/$/,''); const response=await this.fetch(`${base}/chat/completions`,{method:'POST',headers:{'content-type':'application/json',authorization:`Bearer ${c.apiKey}`},body:JSON.stringify({model:c.model,temperature:0.2,messages:[{role:'system',content:system},{role:'user',content:prompt}]}) ,signal:AbortSignal.timeout(120000)});
if(!response.ok) throw new AppError(502,'AI_REQUEST_FAILED',`AI 请求失败(HTTP ${response.status})`);
const body=await response.json() as {choices?:Array<{message?:{content?:string}}>}; const text=body.choices?.[0]?.message?.content; if(typeof text!=='string'||!text.trim()) throw new AppError(502,'AI_EMPTY_RESPONSE','AI 未返回有效内容'); return text.trim();
}
async models(baseUrl?:string,apiKey?:string){const c=this.config(),url=(baseUrl||c.baseUrl).trim(),key=apiKey||c.apiKey; if(!url||!key) throw badRequest('请先填写 Base URL 和 API Key'); const response=await this.fetch(`${url.replace(/\/$/,'')}/models`,{headers:{authorization:`Bearer ${key}`},signal:AbortSignal.timeout(20000)}); if(!response.ok) throw new AppError(502,'AI_MODELS_FAILED',`获取模型失败(HTTP ${response.status})`); const body=await response.json() as {data?:Array<{id?:string}>}; return (body.data||[]).map(x=>x.id).filter((x):x is string=>!!x);}
async scan(libraryId:string,paths:string[]){const c=this.settings('scanEnabled'); if(!paths.length)return {items:[]}; const prompt=`${c.scanPrompt}\n\n媒体路径列表:\n${paths.map(p=>JSON.stringify(p)).join('\n')}`; const raw=await this.chat(prompt,'你是媒体库分类器,只输出合法 JSON 数组。',c); let parsed:unknown; try{parsed=JSON.parse(raw.replace(/^```json\s*|\s*```$/g,''));}catch{throw new AppError(502,'AI_INVALID_JSON','AI 返回的扫描结果不是合法 JSON');} if(!Array.isArray(parsed))throw new AppError(502,'AI_INVALID_JSON','AI 扫描结果必须是数组'); const allowed=new Set(['movie','series','music','audiobook','other']); const items=parsed.flatMap((x:any)=>x!==null&&typeof x==='object'&&typeof x.path==='string'?[{...x,category:allowed.has(x.category)?x.category:'other'}]:[]); this.db.transaction(()=>{for(const item of items)this.db.run('INSERT INTO ai_scan_results(id,library_id,path,category,payload_json,raw,created_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(library_id,path) DO UPDATE SET category=excluded.category,payload_json=excluded.payload_json,raw=excluded.raw,created_at=excluded.created_at',randomUUID(),libraryId,item.path,item.category,JSON.stringify(item),raw,Date.now());}); return {items,raw};}
async summary(userId:string,bookId:string,chapterId:string,content:string){const c=this.settings('summaryEnabled'); const limited=content.slice(0,c.maxInputChars); const contentHash=createHash('sha256').update(limited).digest('hex'),promptHash=createHash('sha256').update(c.summaryPrompt).digest('hex'); const hit=this.db.get<{summary:string}>('SELECT summary FROM ai_summaries WHERE user_id=? AND book_id=? AND chapter_id=? AND content_hash=? AND prompt_hash=?',userId,bookId,chapterId,contentHash,promptHash); if(hit)return {summary:hit.summary,cached:true}; const summary=await this.chat(`${c.summaryPrompt}\n\n章节内容:\n${limited}`,'你是专业阅读助手。',c); this.db.run('INSERT INTO ai_summaries(user_id,book_id,chapter_id,content_hash,prompt_hash,summary,created_at) VALUES(?,?,?,?,?,?,?)',userId,bookId,chapterId,contentHash,promptHash,summary,Date.now()); return {summary,cached:false};}
}
+18 -2
View File
@@ -3,6 +3,7 @@ import type { AppConfig } from '../config/index.ts';
import { badRequest, conflict } from '../lib/errors.ts';
export interface BusinessValues {
ai:{enabled:boolean;scanEnabled:boolean;summaryEnabled:boolean;baseUrl:string;apiKey:string;model:string;scanPrompt:string;summaryPrompt:string;batchSize:number;maxInputChars:number};
playback:{mode:'auto'|'direct'|'proxy'};
tmdb:{enabled:boolean;token:string;apiKey:string;language:string};
musicbrainz:{enabled:boolean;userAgent:string};
@@ -13,8 +14,20 @@ export interface BusinessValues {
webdav:{url:string;username:string;password:string;maxBytes:number};
}
export type SettingsGroup=keyof BusinessValues;
type Field={key:string;label:string;type:'text'|'password'|'number'|'checkbox'|'url'|'select';default:string|number|boolean;min?:number;max?:number;help?:string;options?:Array<{value:string;label:string}>};
type Field={key:string;label:string;type:'text'|'textarea'|'password'|'number'|'checkbox'|'url'|'select';default:string|number|boolean;min?:number;max?:number;help?:string;options?:Array<{value:string;label:string}>};
export const SETTINGS_SCHEMA:Record<SettingsGroup,{label:string;fields:Field[]}>= {
ai:{label:'AI 功能',fields:[
{key:'enabled',label:'启用 AI',type:'checkbox',default:false},
{key:'scanEnabled',label:'启用 AI 扫描',type:'checkbox',default:false},
{key:'summaryEnabled',label:'启用 AI 总结',type:'checkbox',default:false},
{key:'baseUrl',label:'Base URL',type:'url',default:'',help:'OpenAI 兼容接口地址,例如 https://api.openai.com/v1'},
{key:'apiKey',label:'API Key',type:'password',default:''},
{key:'model',label:'模型',type:'text',default:'',help:'填写地址和密钥后即可获取模型,无需先保存'},
{key:'scanPrompt',label:'AI 扫描 Prompt',type:'textarea',default:'请根据以下媒体路径识别类型。必须返回 JSON 数组,每项包含 path、category(movie/series/music/audiobook/other)、title、year、season、artist、album。不要添加解释。'},
{key:'summaryPrompt',label:'AI 总结 Prompt',type:'textarea',default:'请用中文总结以下章节内容,提炼主要情节、人物和关键信息,输出简洁连贯的段落。'},
{key:'batchSize',label:'扫描批次大小',type:'number',default:500,min:20,max:1000},
{key:'maxInputChars',label:'总结最大输入字符数',type:'number',default:60000,min:1000,max:500000},
]},
playback:{label:'媒体播放',fields:[{key:'mode',label:'OpenList 播放方式',type:'select',default:'auto',options:[{value:'auto',label:'自动(直连优先,失败尝试代理)'},{value:'direct',label:'浏览器直连'},{value:'proxy',label:'服务器代理'}],help:'直连由浏览器访问临时下载地址,不经过 Reader 转发。仅服务器可访问的资源请选择代理;OpenList 自身的 Web 代理设置仍然有效。'}]},
tmdb:{label:'TMDB',fields:[
{key:'enabled',label:'启用 TMDB',type:'checkbox',default:false},
@@ -96,8 +109,9 @@ export class BusinessSettings extends BusinessSettingsReader {
if(!field)throw badRequest('未知配置项');
if(field.type==='checkbox'){if(typeof value!=='boolean')throw badRequest(`${field.label}格式无效`);}
else if(field.type==='number'){if(typeof value!=='number'||!Number.isSafeInteger(value)||value<field.min!||value>field.max!)throw badRequest(`${field.label}须在 ${field.min}–${field.max} 之间`);}
else if(typeof value!=='string'||value.length>4096||/[\u0000-\u001f\u007f]/.test(value))throw badRequest(`${field.label}格式无效`);
else if(typeof value!=='string'||value.length>4096||(field.type==='textarea'?/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/:/[\u0000-\u001f\u007f]/).test(value))throw badRequest(`${field.label}格式无效`);
if(field.type==='select'&&!field.options?.some(option=>option.value===value))throw badRequest(`${field.label}选项无效`);
if(group==='ai'&&key==='apiKey'&&field.type==='password'&&value==='') continue;
values[key]=typeof value==='string'&&field.type!=='password'?value.trim():value;
if(field.type==='url'&&values[key])validateUrl(String(values[key]));
}
@@ -107,6 +121,7 @@ export class BusinessSettings extends BusinessSettingsReader {
}
if(group==='musicbrainz'&&values.enabled&&!/\S+\/\S+.*(?:@|https?:\/\/)/.test(String(values.userAgent)))throw badRequest('请填写应用名称/版本及有效联系地址');
if(group==='tts'&&values.enabled&&!values.url)throw badRequest('启用 HTTP 朗读前请填写接口地址');
if(group==='ai'&&values.enabled&&(!values.baseUrl||!values.apiKey||!values.model))throw badRequest('启用 AI 前请填写 Base URL、API Key 和模型');
if(group==='access'){
for(const origin of String(values.corsOrigins).split(',').map(value=>value.trim()).filter(Boolean))if(origin!=='null'&&validateUrl(origin).origin!==origin)throw badRequest('浏览器来源须为协议与域名(可含端口),不能包含路径');
if(Number(values.refreshTokenTtl)<Number(values.accessTokenTtl))throw badRequest('登录续期有效期不能小于访问令牌有效期');
@@ -139,6 +154,7 @@ function legacySettings<K extends SettingsGroup>(group:K,config:AppConfig):Busin
const integer=(name:string,fallback:number)=>{const value=Number(env[name]);return env[name]&&Number.isSafeInteger(value)&&value>=0?value:fallback;};
const intervals=[config.scanInterval,config.watchInterval].filter(value=>value>0);
const legacy:Partial<BusinessValues>={
ai:{enabled:false,scanEnabled:false,summaryEnabled:false,baseUrl:text('AI_BASE_URL'),apiKey:text('AI_API_KEY'),model:text('AI_MODEL'),scanPrompt:'请根据以下媒体路径识别类型。必须返回 JSON 数组,每项包含 path、category(movie/series/music/audiobook/other)、title、year、season、artist、album。不要添加解释。',summaryPrompt:'请用中文总结以下章节内容,提炼主要情节、人物和关键信息,输出简洁连贯的段落。',batchSize:500,maxInputChars:60000},
tmdb:{enabled:!!(text('MEDIA_TMDB_TOKEN')||text('MEDIA_TMDB_API_KEY')),token:text('MEDIA_TMDB_TOKEN'),apiKey:text('MEDIA_TMDB_API_KEY'),language:'zh-CN'},
musicbrainz:{enabled:!!text('MEDIA_MUSICBRAINZ_USER_AGENT'),userAgent:text('MEDIA_MUSICBRAINZ_USER_AGENT')},
tts:{enabled:!!text('TTS_URL'),url:text('TTS_URL'),token:text('TTS_TOKEN'),voicesUrl:text('TTS_VOICES_URL'),timeoutMs:integer('TTS_TIMEOUT_MS',20000),cacheMaxBytes:integer('TTS_CACHE_BYTES',268435456)},
+3
View File
@@ -715,6 +715,9 @@ export class ReaderApi {
const result=await this.request('/api/v1/admin/settings/tts/preview','POST',{values,voice},{binary:true});
return new Blob([new Uint8Array(result.bytes??[]).buffer],{type:result.headers['content-type']||'audio/mpeg'});
}
async businessAiModels(values?:{baseUrl?:string;apiKey?:string}):Promise<string[]> { return (await this.call<{models:string[]}>('/api/v1/admin/ai/models','POST',values??{})).models; }
async aiSummary(bookId:string,chapterId:string,content:string):Promise<{summary:string;cached:boolean}>{ return this.call('/api/v1/ai/summary','POST',{bookId,chapterId,content}); }
async aiScan(libraryId:string):Promise<{total:number;items:unknown[];batches:number}>{ return this.call('/api/v1/media/libraries/'+encodeURIComponent(libraryId)+'/folders/ai-scan','POST',{path:''}); }
private async get<T>(path: string, options: RequestOptions = {}): Promise<T> {
const response = await this.request(path, 'GET', undefined, options);
+3
View File
@@ -22,6 +22,9 @@ export class MediaApi {
constructor(private readonly reader:ReaderApi) {}
businessSettingsRequest<T>(path='',method='GET',body?:unknown,signal?:AbortSignal){return this.reader.businessSettingsRequest<T>(path,method,body,signal);}
businessTtsPreview(values:Record<string,unknown>,voice:string){return this.reader.businessTtsPreview(values,voice);}
aiScan(libraryId:string){return this.reader.aiScan(libraryId);}
businessAiModels(values?:{baseUrl?:string;apiKey?:string}){return this.reader.businessAiModels(values);}
aiFolderScan(libraryId:string,path:string){return this.request<{total:number;items:unknown[];batches:number}>(`libraries/${encodeURIComponent(libraryId)}/folders/ai-scan`,'POST',{path});}
preferenceScope(){return JSON.stringify([this.reader.baseUrl,this.reader.currentSession()?.user.id??'anonymous']);}
accountInfo():MediaAccount|null {const user=this.reader.currentSession()?.user;return user?{username:user.username,displayName:user.displayName,role:user.role,server:this.reader.baseUrl}:null;}
request<T>(path:string,method='GET',body?:unknown,signal?:AbortSignal):Promise<T> {
+1
View File
@@ -44,6 +44,7 @@ export function MediaFolders({api,admin=false,video=false,libraryId,initialLocat
{(path||assetId)&&<button className="media-folder-back" disabled={busy} aria-label={'← '+(assetId?'返回文件列表':'上级目录')} onClick={()=>assetId?setAssetId(null):open(segments.slice(0,-1).join('/'))}><ChevronLeft size={16} aria-hidden="true"/>{assetId?'返回文件列表':'上级目录'}</button>}
{error&&<MediaScreenError fullPage error={cause} message={error} busy={loading} retryLabel="重新加载" onRetry={()=>setRetry(value=>value+1)}/>}{actionError&&<div className="media-error" role="alert">{actionError}</div>}{notice&&<p role="status">{notice}</p>}
{loading&&<MediaLoading layout={assetId?'tracks':'list'} square label={assetId?'正在读取文件与章节…':'正在读取目录…'}/>}
{listing&&admin&&<button disabled={busy||loading} onClick={()=>void action(async()=>{const result=await api.aiFolderScan(libraryId,path);setCleanupNotice('AI 扫描完成:处理 '+result.total+' 条路径,生成 '+result.items.length+' 条分类结果。');})}>{busy?'正在处理…':'AI 扫描当前目录'}</button>}
{cleanupNotice&&<p role="status">{cleanupNotice}</p>}
{listing&&<>{admin&&video&&<FolderRecognition key={libraryId+'-rules-'+path} api={api} libraryId={libraryId} path={path} disabled={busy||loading} onBusy={setBusy} onApplied={()=>{setCleanupNotice('已应用识别结果,原文件与播放进度保留。');setRetry(value=>value+1);}}/>}{admin&&<FolderCleanup key={libraryId+'-'+path} api={api} libraryId={libraryId} path={path} disabled={busy||loading} onBusy={setBusy} onCleaned={result=>{setCleanupNotice('已清理 '+result.assets+' 个失效资源记录'+(result.returnPath!==path?',已返回上级有效目录。':'。'));setPath(result.returnPath);setOffset(0);setRetry(value=>value+1);}}/>}{listing.items.map(entry=><div className="media-row media-folder-row" key={entry.path}><button disabled={busy} onClick={()=>entry.kind==='folder'?open(entry.path):setAssetId(entry.assetId)}>{entry.kind==='folder'?<Folder size={21} strokeWidth={1.5} aria-hidden="true"/>:<File size={21} strokeWidth={1.5} aria-hidden="true"/>}<span><strong>{entry.name}</strong><small>{entry.kind==='folder'?`${entry.files} 个媒体文件 · ${entry.availableFiles} 个可用`:`${(entry.size/1024/1024).toFixed(1)} MB · ${entry.availableFiles?'文件可用':'文件缺失'}`}</small></span><ChevronRight size={17} aria-hidden="true"/></button></div>)}{!listing.total&&<p className="media-empty">暂无已扫描媒体文件,请先扫描媒体库。</p>}{listing.total>60&&<nav className="media-toolbar" aria-label="文件分页"><button disabled={busy||!offset} onClick={()=>setOffset(Math.max(0,offset-60))}>上一页</button><span>{Math.floor(offset/60)+1} / {Math.ceil(listing.total/60)}</span><button disabled={busy||offset+60>=listing.total} onClick={()=>setOffset(offset+60)}>下一页</button></nav>}<p className="media-folder-note">按最近扫描结果显示媒体文件;不包含空目录及其他文件。原始目录只读。</p></>}
{file&&<><header className="media-folder-file-heading"><File size={27} strokeWidth={1.4} aria-hidden="true"/><div><h2>{file.path.split('/').at(-1)}</h2><small>{file.available?'文件可用':'文件已缺失'} · {file.items.length} 部关联作品</small></div></header>
+1 -1
View File
@@ -506,7 +506,7 @@ export class MediaScreen {
<p className="media-manager-note">扫描只读取原始目录。封面、索引和刮削缓存保存在应用数据目录。</p>
<h2>接入方式</h2><div className="media-connection-row"><Folder size={20} aria-hidden="true"/><span>服务器目录<small>包括服务器已挂载的 NAS</small></span><small>可用</small></div>
</>:<>
<div className="media-manager-toolbar"><span>扫描与刮削分开执行</span><button disabled={this.busy||!this.allLibraries.length||this.jobState!=='ready'} onClick={()=>void this.scanAll()}>扫描所有媒体库</button></div>{this.scanNotice&&<p className="media-manager-note" role="status">{this.scanNotice}</p>}
<div className="media-manager-toolbar"><span>扫描与刮削分开执行</span><button disabled={this.busy||!this.allLibraries.length||this.jobState!=='ready'} onClick={()=>void this.scanAll()}>扫描所有媒体库</button><button disabled={this.busy||!this.jobLibraryId} onClick={()=>void this.run(async()=>{const result=await this.api.aiScan(this.jobLibraryId);this.scanNotice=`AI 扫描完成:处理 ${result.total} 条路径,生成 ${result.items.length} 条分类结果。`;})}>AI 扫描</button></div>{this.scanNotice&&<p className="media-manager-note" role="status">{this.scanNotice}</p>}
{this.scanActionError&&<div className="media-error" role="alert"><p>{this.scanActionError}</p><button disabled={this.busy} onClick={()=>void this.run(async()=>{await this.loadJobs(this.jobLibraryId);this.scanActionError='';})}>重新核对任务</button></div>}
<div className="media-task-library"><label>扫描媒体库<MediaSelect aria-label="扫描媒体库" value={this.jobLibraryId} disabled={this.busy||!this.allLibraries.length} onChange={event=>this.openManagerTasks(event.currentTarget.value)}><option value="">全部媒体库 · 最新任务</option>{this.allLibraries.map(l=><option key={l.id} value={l.id}>{l.name}</option>)}</MediaSelect></label><button disabled={this.busy||!this.jobLibraryId} onClick={()=>void this.scan(this.jobLibraryId)}><RefreshCw size={16} aria-hidden="true"/>扫描</button><button disabled={this.busy} onClick={()=>void this.run(()=>this.loadJobs(this.jobLibraryId))} aria-label="刷新扫描任务" title="刷新扫描任务"><RefreshCw size={16} aria-hidden="true"/></button></div>
<h2 className="media-task-section-title">扫描任务{this.jobLibraryId?' · '+this.allLibraries.find(l=>l.id===this.jobLibraryId)?.name:''}</h2>
+9 -3
View File
@@ -6,15 +6,17 @@ import '../styles/business-settings.css';
export interface SettingsTransport {
businessSettingsRequest<T>(path?:string,method?:string,body?:unknown,signal?:AbortSignal):Promise<T>;
businessTtsPreview(values:Record<string,unknown>,voice:string):Promise<Blob>;
businessAiModels?(values?:{baseUrl?:string;apiKey?:string}):Promise<string[]>;
}
interface SettingsView {
group:string;label:string;revision:number;values:Record<string,string|number|boolean>;secrets:Record<string,boolean>;
fields:Array<{key:string;label:string;type:'text'|'password'|'number'|'checkbox'|'url'|'select';min?:number;max?:number;help?:string;options?:Array<{value:string;label:string}>}>;
fields:Array<{key:string;label:string;type:'text'|'textarea'|'password'|'number'|'checkbox'|'url'|'select';min?:number;max?:number;help?:string;options?:Array<{value:string;label:string}>}>;
}
export function BusinessSettings({api,group,onSaved}:{api:SettingsTransport;group?:string;onSaved?:()=>void}){
const [groups,setGroups]=useState<SettingsView[]>([]),[selected,setSelected]=useState(group||'tts');
const [values,setValues]=useState<Record<string,string|number|boolean>>({}),[busy,setBusy]=useState(false),[error,setError]=useState(''),[message,setMessage]=useState(''),[retry,setRetry]=useState(0);
const [voices,setVoices]=useState<Array<{id:string;name:string}>>([]),[voice,setVoice]=useState(''),[audio,setAudio]=useState('');
const [models,setModels]=useState<string[]>([]);
const [directory,setDirectory]=useState(''),[name,setName]=useState('reader-backup.zip'),[clear,setClear]=useState(false);
useEffect(()=>{
const controller=new AbortController();setBusy(true);setError('');
@@ -23,7 +25,7 @@ export function BusinessSettings({api,group,onSaved}:{api:SettingsTransport;grou
},[api,retry]);
useEffect(()=>{if(group)setSelected(group);},[group]);
const current=groups.find(item=>item.group===selected);
useLayoutEffect(()=>{setValues(current?.values||{});setVoices([]);setVoice('');setAudio('');},[current]);
useLayoutEffect(()=>{setValues(current?.values||{});setVoices([]);setVoice('');setAudio('');setModels([]);},[current]);
useEffect(()=>()=>{if(audio)URL.revokeObjectURL(audio);},[audio]);
async function work(action:()=>Promise<void>){if(busy)return;setBusy(true);setError('');setMessage('');try{await action();}catch(error){setError(error instanceof Error?error.message:'操作失败');}finally{setBusy(false);}}
return <section className="business-settings" aria-label={current?.label||'业务配置'}>
@@ -38,18 +40,22 @@ export function BusinessSettings({api,group,onSaved}:{api:SettingsTransport;grou
});}}>
{current.fields.map(field=><label key={field.key} className={field.type==='checkbox'?'business-setting-toggle':undefined}>{field.label}
{field.type==='checkbox'?<input type="checkbox" checked={!!values[field.key]} disabled={busy} onChange={event=>setValues({...values,[field.key]:event.currentTarget.checked})}/>:
selected==='ai'&&field.key==='model'?<select aria-label={field.label} value={String(values.model||'')} disabled={busy} onChange={event=>setValues({...values,model:event.currentTarget.value})}><option value="">请获取并选择模型</option>{Array.from(new Set([String(values.model||''),...models])).filter(Boolean).map(model=><option key={model} value={model}>{model}</option>)}</select>:
field.type==='select'?<select aria-label={field.label} value={String(values[field.key]??'')} disabled={busy} onChange={event=>setValues({...values,[field.key]:event.currentTarget.value})}>{field.options?.map(option=><option value={option.value}>{option.label}</option>)}</select>:
<input aria-label={field.label} type={field.type} value={String(values[field.key]??'')} disabled={busy} min={field.min} max={field.max} step={field.type==='number'?1:undefined} maxLength={4096} autoComplete={field.type==='password'?'new-password':'off'} placeholder={field.type==='password'&&current.secrets[field.key]?'已保存,留空不修改':undefined} onInput={event=>setValues({...values,[field.key]:field.type==='number'?Number(event.currentTarget.value):event.currentTarget.value})}/>}
field.type==='textarea'?<textarea aria-label={field.label} value={String(values[field.key]??'')} disabled={busy} maxLength={4096} rows={5} onInput={event=>setValues({...values,[field.key]:event.currentTarget.value})}/>:<input aria-label={field.label} list={selected==='ai'&&field.key==='model'?'ai-models':undefined} type={field.type} value={String(values[field.key]??'')} disabled={busy} min={field.min} max={field.max} step={field.type==='number'?1:undefined} maxLength={4096} autoComplete={field.type==='password'?'new-password':'off'} placeholder={field.type==='password'&&current.secrets[field.key]?'已保存,留空不修改':undefined} onInput={event=>setValues({...values,[field.key]:field.type==='number'?Number(event.currentTarget.value):event.currentTarget.value})}/>}
{field.help&&<small className="muted">{field.help}</small>}
{field.type==='password'&&<><small className="muted">{values[field.key]===''?'保存时将清除凭据':current.secrets[field.key]?'已有凭据,仅替换时填写':'尚未配置凭据'}</small><button className="button business-secret-clear" type="button" disabled={busy} onClick={()=>setValues({...values,[field.key]:''})}>清除{field.label}</button></>}
</label>)}
{selected==='ai'&&<datalist id="ai-models">{models.map(model=><option key={model} value={model}/>)}</datalist>}
<div className="user-manager-actions"><button className="button primary" disabled={busy}>保存配置</button>
{selected==='ai'&&api.businessAiModels&&<button type="button" className="button" disabled={busy} onClick={()=>void work(async()=>{const result=await api.businessAiModels!({baseUrl:String(values.baseUrl||''),apiKey:String(values.apiKey||'')});setModels(result);setMessage(`已获取 ${result.length} 个模型`);})}>获取模型</button>}
{['tmdb','musicbrainz','tts','webdav'].includes(selected)&&<button type="button" className="button" disabled={busy} onClick={()=>void work(async()=>{
const result=await api.businessSettingsRequest<{elapsedMs:number;voices?:Array<{id:string;name:string}>}>(selected+'/test','POST',{values});setVoices(result.voices||[]);setMessage(`连接成功,耗时 ${result.elapsedMs} 毫秒(尚未保存表单)`);
})}>{selected==='tts'?'检测连接并获取音色':'测试连接'}</button>}
<button type="button" className="button" disabled={busy} onClick={()=>setRetry(value=>value+1)}>重新加载</button>
</div>
</form>
{selected==='ai'&&models.length>0&&<p className="muted">可用模型:{models.join('、')}</p>}
{selected==='tts'&&<div className="user-manager-form"><label>试听音色<input list="business-tts-voices" value={voice} onInput={event=>setVoice(event.currentTarget.value)}/><datalist id="business-tts-voices">{voices.map(item=><option key={item.id} value={item.id}>{item.name}</option>)}</datalist></label>
<button className="button" disabled={busy} onClick={()=>void work(async()=>{setAudio(URL.createObjectURL(await api.businessTtsPreview(values,voice)));setMessage('试听音频已就绪');})}>生成试听音频</button>
{audio&&<audio controls src={audio} aria-label="HTTP 朗读试听"/>}
+6
View File
@@ -90,6 +90,8 @@ export interface ChromeState {
description?: string;
sourceName?: string;
chapterUrl?: string;
aiSummary?: string;
aiSummaryBusy?: boolean;
refreshingChapter?: boolean;
alternativeChapters?: Array<{ id: string; title: string }> | undefined;
alternativeChapter?: string;
@@ -152,6 +154,7 @@ export interface ChromeHandlers {
onTocEntry(ref: string): void;
onChapter(delta: 1 | -1): void;
onBookInfo?(): void;
onAiSummary?(): void;
onRefresh?(): void;
onRefreshChapter?(): void;
onAlternatives?(cursor?: string): void;
@@ -209,10 +212,13 @@ export function ReaderChrome({ state, stage, handlers, tools }: ReaderChromeProp
<div className="reader-top-actions">
{state.canSwitch && <IconButton label="切换书源" icon="sort" disabled={state.switching || state.refreshing || state.refreshingChapter || state.navigating} onClick={() => handlers.onAlternatives?.()} />}
{state.canRefresh && <IconButton label="刷新当前章节" icon="refresh" disabled={state.switching || state.refreshing || state.refreshingChapter || state.navigating} onClick={() => handlers.onRefreshChapter?.()} />}
<IconButton label="AI 总结" icon="file-text" disabled={state.aiSummaryBusy || state.navigating} onClick={() => handlers.onAiSummary?.()} />
</div>
{(state.sourceName || state.chapterUrl) && <div className="reader-source-info"><span>{state.sourceName || '当前书源'}</span>{state.chapterUrl && <a href={state.chapterUrl} target="_blank" rel="noopener noreferrer" title={state.chapterUrl}>{state.chapterUrl}</a>}</div>}
</div>
{state.bookInfoOpen && <Modal title="书籍信息" busy={false} onClose={() => handlers.onBookInfo?.()}><article className="book-detail source-modal-content"><h3>{state.title}</h3><p className="book-detail-author">{state.author || '作者未知'}</p><dl><dt>书源</dt><dd>{state.sourceName || (state.canRefresh ? '远程书源' : '本地书籍')}</dd><dt>当前章节</dt><dd>{state.chapterLabel || '暂无信息'}</dd></dl><h4>内容简介</h4><p className="book-detail-description">{state.description || '暂无简介'}</p></article></Modal>}
{state.aiSummaryBusy && <div className="floating-notice" role="status">正在生成章节总结…</div>}
{state.aiSummary && <Modal title="AI 章节总结" busy={false} onClose={() => handlers.onAiSummary?.()}><article className="book-detail source-modal-content"><p className="book-detail-description">{state.aiSummary}</p></article></Modal>}
<StageHost stage={stage} />
<ReaderIndicators state={state} />
<div className="reader-rail" role="toolbar" aria-label="阅读快捷操作">
+16
View File
@@ -261,6 +261,7 @@ export class ReaderScreen {
onTocEntry: (ref) => void this.goToChapterRef(ref),
onChapter: (delta) => void this.goToChapter(delta),
onBookInfo: () => this.patch({ bookInfoOpen: !this.chrome.bookInfoOpen }),
onAiSummary: () => void this.generateAiSummary(),
onRefresh: () => void this.refreshPublication(),
onRefreshChapter: () => void this.refreshChapter(),
onAlternatives: cursor => void this.loadAlternatives(cursor),
@@ -286,6 +287,21 @@ export class ReaderScreen {
this.bindSyncStatus();
}
private async generateAiSummary(): Promise<void> {
if (this.chrome.aiSummary) { this.patch({ aiSummary: '' }); return; }
if (!this.book || !this.chrome.currentSectionId || this.chrome.aiSummaryBusy) return;
const content = this.stage.textContent?.trim() || '';
if (!content) { this.flashStatus('当前章节没有可总结的正文', 2400); return; }
this.patch({ aiSummaryBusy: true, aiSummary: '' });
try {
const result = await this.options.api.aiSummary(this.book.id, this.chrome.currentSectionId, content);
this.patch({ aiSummary: result.summary, aiSummaryBusy: false });
} catch (error) {
this.patch({ aiSummaryBusy: false });
this.flashStatus(error instanceof Error ? error.message : 'AI 总结失败', 3200);
}
}
/** Opens a book: fetch bytes, parse, restore position, start reporting. */
async open(book: Book): Promise<void> {
this.closeAlternatives(); this.chapterRefresh?.abort(); this.chapterRefresh = null;