feat(settings): manage business configuration from admin pages

This commit is contained in:
hectorqin committed 2026-09-28 12:38:54 +08:00
1 parent 996f0c555e
commit ff976a4cf6
41 files changed
+731 -192

No files matched your search

+3
View File
@@ -38,6 +38,9 @@ CREATE TABLE IF NOT EXISTS users (
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS business_settings (
name TEXT PRIMARY KEY,value TEXT NOT NULL,revision INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS registration_settings (
id INTEGER PRIMARY KEY CHECK(id = 1),
mode TEXT NOT NULL CHECK(mode IN ('closed','open','invite'))
+6
View File
@@ -14,8 +14,13 @@ import { registerMediaRoutes } from './routes/media.ts';
import {registerMediaProxy} from './routes/media-proxy.ts';
import type {MediaService} from '../media/start-runtime.ts';
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';
export function buildApp(ctx: AppContext,mediaRuntime?:MediaService): FastifyInstance {
ctx.settings??=new BusinessSettings(ctx.db,ctx.config);
ctx.tts=new TtsService(ctx.config,ctx.settings.read('tts'));
const app = Fastify({
logger: { level: ctx.config.logLevel },
// Book bodies are streamed straight from disk, so a JSON limit this small is
@@ -64,6 +69,7 @@ export function buildApp(ctx: AppContext,mediaRuntime?:MediaService): FastifyIns
app.options('/*', async (_request, reply) => reply.status(204).send());
registerAuthRoutes(app, ctx);
registerBusinessSettingsRoutes(app,ctx);
registerLibraryRoutes(app, ctx);
registerSyncRoutes(app, ctx);
registerTtsRoutes(app, ctx);
+3
View File
@@ -9,9 +9,12 @@ import type { TtsService } from '../services/tts.ts';
import type { BrowseService } from '../services/browse.ts';
import type { UploadService } from '../services/uploads.ts';
import type { SourceHost } from '../services/source-host.ts';
import type { BusinessSettings } from '../services/business-settings.ts';
export interface AppContext {
config: AppConfig;
settings?:BusinessSettings;
settingsChanged?:(group:string)=>void;
db: Db;
scanner: Scanner;
users: UserService;
@@ -0,0 +1,81 @@
import type {FastifyInstance} from 'fastify';
import type {AppContext} from '../context.ts';
import {authenticate,requireAdmin} from '../auth.ts';
import {badRequest,AppError} from '../../lib/errors.ts';
import {SETTINGS_SCHEMA,type SettingsGroup} from '../../services/business-settings.ts';
import {TtsService} from '../../services/tts.ts';
import {MetadataHttp,TmdbProvider,MusicBrainzProvider} from '../../media/metadata-providers.ts';
import {uploadBackup} from '../../maintenance/webdav.ts';
export function registerBusinessSettingsRoutes(app:FastifyInstance,ctx:AppContext){
const settings=ctx.settings!,auth=authenticate(ctx),active=new Set<string>();
const guard=async(request:Parameters<typeof auth>[0],reply:Parameters<typeof auth>[1])=>{await auth(request,reply);requireAdmin(request);reply.header('cache-control','no-store');};
const group=(params:unknown)=>{
const name=(params as {group?:string})?.group;
if(!name||!Object.hasOwn(SETTINGS_SCHEMA,name))throw badRequest('未知配置分类');return name as SettingsGroup;
};
app.get('/api/v1/admin/settings',{preHandler:guard},async()=>({groups:(Object.keys(SETTINGS_SCHEMA) as SettingsGroup[]).map(name=>settings.view(name))}));
app.patch('/api/v1/admin/settings/:group',{preHandler:guard},async request=>{
const name=group(request.params),body=request.body as {values?:unknown;revision?:unknown};
if(!body)throw badRequest('缺少配置');
// Do not let a CORS edit lock this browser out of administering the instance.
if(name==='access'&&request.headers.origin){
const next=settings.candidate(name,body.values),origins=next.corsOrigins.split(',').map(value=>value.trim()).filter(Boolean);
if(origins.length&&!origins.includes(request.headers.origin))throw badRequest('允许的来源必须包含当前管理页面的来源');
}
const result=settings.save(name,body.values,body.revision);
settings.apply(ctx.config);
if(name==='tts')ctx.tts=new TtsService(ctx.config,settings.read('tts'));
ctx.settingsChanged?.(name);
app.log.info({group:name,actor:request.currentUser!.id,revision:result.revision},'business settings updated');
return result;
});
app.post('/api/v1/admin/settings/:group/test',{preHandler:guard},async request=>{
const name=group(request.params),input=(request.body as {values?:unknown})?.values??{};
if(active.has(name))throw new AppError(409,'SETTINGS_TEST_BUSY','此配置正在检测,请稍候');
active.add(name);const started=Date.now();
try{
if(name==='tmdb'){
const value=settings.candidate('tmdb',input),http=new MetadataHttp(fetch,settings.read('scraping'));
await new TmdbProvider(http,value.token,value.apiKey,value.language).search('movie','test');
}else if(name==='musicbrainz'){
const value=settings.candidate('musicbrainz',input);
await new MusicBrainzProvider(new MetadataHttp(fetch,settings.read('scraping')),value.userAgent).search('artist','Mozart');
}else if(name==='tts'){
const service=new TtsService(ctx.config,settings.candidate('tts',input));
const audio=await service.synthesize({text:'这是阅读器的朗读连接测试。'});
return {ok:true,elapsedMs:Date.now()-started,bytes:audio.bytes.length,voices:await service.voices()};
}else if(name==='webdav'){
const value=settings.candidate('webdav',input);
if(!value.url||!value.username||!value.password)throw badRequest('请填写备份地址、账号和密码');
const response=await fetch(value.url,{method:'PROPFIND',redirect:'error',headers:{Depth:'0',Authorization:'Basic '+Buffer.from(value.username+':'+value.password).toString('base64')},signal:AbortSignal.timeout(15000)});
await response.body?.cancel();if(!response.ok)throw badRequest(`WebDAV 连接失败(HTTP ${response.status})`);
}else throw badRequest('此分类不需要连接测试');
return {ok:true,elapsedMs:Date.now()-started};
}catch(error){
if(error instanceof AppError)throw error;
throw new AppError(502,'SETTINGS_TEST_FAILED','连接测试失败,请检查服务地址、凭据及网络');
}finally{active.delete(name);}
});
app.post('/api/v1/admin/settings/tts/preview',{preHandler:guard},async(request,reply)=>{
const body=request.body as {values?:unknown;voice?:string};
if(body?.voice!==undefined&&(typeof body.voice!=='string'||body.voice.length>120))throw badRequest('无效的音色');
const service=new TtsService(ctx.config,settings.candidate('tts',body?.values??{}));
const audio=await service.synthesize({text:'这是阅读器朗读试听。愿你享受阅读的时光。',voice:body?.voice});
return reply.type(audio.contentType).send(Buffer.from(audio.bytes));
});
app.post('/api/v1/admin/settings/tts/clear-cache',{preHandler:guard},async()=>{
// Cache cleanup also works when HTTP speech has been disabled.
const value=settings.read('tts');new TtsService(ctx.config,{...value,enabled:true,url:value.url||'http://localhost'}).clearCache();
return {ok:true};
});
app.post('/api/v1/admin/settings/webdav/upload',{preHandler:guard},async request=>{
const body=request.body as {directory?:unknown;name?:unknown};
if(typeof body?.directory!=='string'||!body.directory||body.directory.length>4096||typeof body?.name!=='string')throw badRequest('请输入已验证备份目录和远端文件名');
if(active.has('upload'))throw new AppError(409,'BACKUP_BUSY','已有备份正在上传');
active.add('upload');
try{return await uploadBackup(body.directory,{...settings.read('webdav'),name:body.name});}
catch{throw badRequest('备份上传失败,请检查备份完整性、目标权限、配额及同名文件;不会覆盖已有备份');}
finally{active.delete('upload');}
});
}
+5 -2
View File
@@ -25,6 +25,8 @@ import { MediaBackgroundGrants } from '../../media/background-grants.ts';
import { DatabaseMediaAccounts, MediaAccountReferences } from '../../media/accounts.ts';
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';
export type MediaRouteContext=Pick<AppContext,'config'> & {db:MediaDatabase & Pick<Db,'prepare'>;users:Pick<AppContext['users'],'byId'>};
@@ -32,11 +34,12 @@ export function registerMediaRoutes(app:FastifyInstance,ctx:MediaRouteContext,op
const database=options.database||ctx.db;
const accounts=new DatabaseMediaAccounts(ctx.db);
const references=database!==ctx.db?new MediaAccountReferences(database,accounts):undefined;
const libraries=new MediaLibraries(database,true,accounts,references),scanner=new MediaScanner(database,libraries,undefined,app.log);
const settings=new BusinessSettingsReader(ctx.db);
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);
const background=new MediaBackgroundGrants(database,libraries,accounts);
const userState=new MediaUserState(database,libraries,scanner.catalog);
const scraping=new MediaScraping(database,scanner.catalog,options.metadataProviders);
const scraping=new MediaScraping(database,scanner.catalog,options.metadataProviders??configuredProviders(settings));
const hierarchy=new MediaHierarchy(database,scanner.catalog);
const scrapeJobs=new MediaScrapeJobs(database,scanner.catalog,scraping,accounts);
const artwork=new MediaArtwork(database,libraries);
+8 -8
View File
@@ -16,7 +16,7 @@ const feedType = 'application/atom+xml;profile=opds-catalog;kind=acquisition';
export function registerOpdsRoutes(app: FastifyInstance, ctx: AppContext): void {
const credentials = new OpdsCredentials(ctx.db), auth = authenticate(ctx);
const formats = allFileHandlers().map(handler => handler.format);
const root = `${ctx.config.publicUrl.replace(/\/$/, '')}/opds`;
const root = ()=>`${ctx.config.publicUrl.replace(/\/$/, '')}/opds`;
const access = async (request: FastifyRequest, reply: FastifyReply) => {
try {
const id = credentials.authenticate(request.headers.authorization);
@@ -30,15 +30,15 @@ export function registerOpdsRoutes(app: FastifyInstance, ctx: AppContext): void
}
return payload;
});
app.get('/api/v1/opds/credentials', { preHandler: auth }, async request => ({ credentials: credentials.list(currentUser(request).id), catalogUrl: root }));
app.get('/api/v1/opds/credentials', { preHandler: auth }, async request => ({ credentials: credentials.list(currentUser(request).id), catalogUrl: root() }));
app.post('/api/v1/opds/credentials', { preHandler: auth }, async (request, reply) => {
reply.code(201);
return { ...credentials.create(currentUser(request).id, (request.body as { name?: unknown } | null)?.name), catalogUrl: root };
return { ...credentials.create(currentUser(request).id, (request.body as { name?: unknown } | null)?.name), catalogUrl: root() };
});
app.delete('/api/v1/opds/credentials/:id', { preHandler: auth }, async request => {
credentials.revoke(currentUser(request).id, (request.params as { id: string }).id); return { ok: true };
});
app.get('/opds/', { preHandler: access }, async (request, reply) => reply.redirect(root + (request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : '')));
app.get('/opds/', { preHandler: access }, async (request, reply) => reply.redirect(root() + (request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : '')));
app.get('/opds', { preHandler: access }, async (request, reply) => {
const query = request.query as { page?: unknown; search?: unknown };
const page = query.page === undefined ? 1 : Number(query.page);
@@ -47,9 +47,9 @@ export function registerOpdsRoutes(app: FastifyInstance, ctx: AppContext): void
const search = query.search as string | undefined;
const result = ctx.shelf.list(currentUser(request).id, { page, pageSize: 50, fileFormats: formats, sort: 'title', ...(search ? { search } : {}) });
const link = (rel: string, href: string, type = feedType) => `<link rel="${rel}" href="${xml(href)}" ${type ? `type="${type}"` : ''}/>`;
const pageUrl = (number: number) => root + '?' + new URLSearchParams({ page: String(number), ...(search ? { search } : {}) });
const pageUrl = (number: number) => root() + '?' + new URLSearchParams({ page: String(number), ...(search ? { search } : {}) });
const entries = result.items.map(book => {
const href = `${root}/books/${encodeURIComponent(book.id)}`;
const href = `${root()}/books/${encodeURIComponent(book.id)}`;
const file = new FilePublications(ctx.db, ctx.config).context(book.id);
return `<entry><id>urn:reader:book:${xml(book.id)}</id><title>${xml(book.title)}</title><updated>${new Date(book.updatedAt).toISOString()}</updated>
<author><name>${xml(book.author)}</name></author><summary type="text">${xml(book.description)}</summary>
@@ -58,12 +58,12 @@ export function registerOpdsRoutes(app: FastifyInstance, ctx: AppContext): void
}).join('');
return reply.type(feedType).send(`<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xmlns:opensearch="http://a9.com/-/spec/opensearch/1.1/">
<id>urn:reader:opds:${xml(currentUser(request).id)}</id><title>Reader 个人书架</title><updated>${new Date().toISOString()}</updated>
${link('self', pageUrl(page))}${link('start', root)}${link('search', root + '/search.xml', 'application/opensearchdescription+xml')}
${link('self', pageUrl(page))}${link('start', root())}${link('search', root() + '/search.xml', 'application/opensearchdescription+xml')}
${page > 1 ? link('previous', pageUrl(page - 1)) : ''}${page * result.pageSize < result.total ? link('next', pageUrl(page + 1)) : ''}
<opensearch:totalResults>${result.total}</opensearch:totalResults><opensearch:startIndex>${(page - 1) * result.pageSize + 1}</opensearch:startIndex><opensearch:itemsPerPage>${result.pageSize}</opensearch:itemsPerPage>${entries}</feed>`);
});
app.get('/opds/search.xml', { preHandler: access }, async (_request, reply) => reply.type('application/opensearchdescription+xml').send(
`<?xml version="1.0" encoding="utf-8"?><OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/"><ShortName>Reader</ShortName><Description>搜索个人书架</Description><Url type="${feedType}" template="${xml(root + '?search={searchTerms}')}"/><InputEncoding>UTF-8</InputEncoding></OpenSearchDescription>`,
`<?xml version="1.0" encoding="utf-8"?><OpenSearchDescription xmlns="http://a9.com/-/spec/opensearch/1.1/"><ShortName>Reader</ShortName><Description>搜索个人书架</Description><Url type="${feedType}" template="${xml(root() + '?search={searchTerms}')}"/><InputEncoding>UTF-8</InputEncoding></OpenSearchDescription>`,
));
app.get('/opds/books/:id/content', { preHandler: access }, async (request, reply) => {
const book = ctx.shelf.get(currentUser(request).id, (request.params as { id: string }).id);
+3 -3
View File
@@ -64,9 +64,9 @@ export function registerTtsRoutes(app: FastifyInstance, ctx: AppContext): void {
reply.header('content-type', audio.contentType);
reply.header('content-length', String(audio.bytes.byteLength));
// Immutable per (text, voice, speed): the same sentence will always be the
// same audio, so a second play of it should not reach the server at all.
reply.header('cache-control', 'private, max-age=604800, immutable');
// The administrator can replace the upstream while the audio URL stays the
// same. Reuse the server cache, which includes the upstream configuration.
reply.header('cache-control', 'private, no-store');
reply.header('x-tts-cache', audio.cached ? 'hit' : 'miss');
return reply.send(Buffer.from(audio.bytes));
});
+13 -10
View File
@@ -53,13 +53,18 @@ async function main(): Promise<void> {
ctx.users = new UserService(db, config);
ctx.shelf = new ShelfService(db);
ctx.sync = new SyncService(db, ctx.shelf);
ctx.tts = new TtsService(config);
ctx.tts = new TtsService(config,ctx.settings!.read('tts'));
ctx.browse = new BrowseService(db, config, ctx.shelf);
ctx.uploads = new UploadService(db, config, ctx.browse, ctx.scanner);
app.log.info({ booksDir: config.booksDir, dataDir: config.dataDir }, 'starting reader server');
const scheduler = startSchedulers(ctx, app.log);
let scheduler = startSchedulers(ctx, app.log);
ctx.settingsChanged=group=>{
if(group!=='scanning')return;
if(scheduler.scanTimer)clearInterval(scheduler.scanTimer);
scheduler=startSchedulers(ctx,app.log,false);
};
const shutdown = async (signal: string): Promise<void> => {
app.log.info({ signal }, 'shutting down');
@@ -80,7 +85,7 @@ async function main(): Promise<void> {
}
}
function startSchedulers(ctx: AppContext, log: AppContext['log']): Schedulers {
function startSchedulers(ctx: AppContext, log: AppContext['log'],initial=true): Schedulers {
const runScan = async (): Promise<void> => {
try {
await ctx.scanner.scan();
@@ -90,19 +95,17 @@ function startSchedulers(ctx: AppContext, log: AppContext['log']): Schedulers {
};
// A scan at boot makes the library available immediately after a restart.
void runScan();
if(initial)void runScan();
// The watch poller is the same code path as the interval scan; the mtime+size
// fast path makes it cheap enough to run every minute (see Scanner.indexFile).
const scanTimer = ctx.config.scanInterval > 0
? setInterval(() => void runScan(), ctx.config.scanInterval * 1000)
const interval=ctx.settings!.read('scanning').interval;
const scanTimer = interval > 0
? setInterval(() => void runScan(), interval * 1000)
: undefined;
scanTimer?.unref();
const watchTimer = ctx.config.watchInterval > 0
? setInterval(() => void runScan(), ctx.config.watchInterval * 1000)
: undefined;
watchTimer?.unref();
const watchTimer = undefined;
return { scanTimer, watchTimer };
}
+8 -5
View File
@@ -1,17 +1,20 @@
import { uploadBackup } from './webdav.ts';
import {join} from 'node:path';
import {loadConfig} from '../config/index.ts';
import {MediaReadDatabase} from '../media/read-database.ts';
import {BusinessSettingsReader} from '../services/business-settings.ts';
const controller = new AbortController();
process.once('SIGINT', () => controller.abort());
process.once('SIGTERM', () => controller.abort());
const [directory, name, ...extra] = process.argv.slice(2);
if (!directory || !name || extra.length) {
console.error('用法:node dist/maintenance/webdav-cli.js <已验证备份目录> <远端名称.zip>\n环境变量:WEBDAV_URL、WEBDAV_USERNAME、WEBDAV_PASSWORD;可选 WEBDAV_MAX_BYTES。');
console.error('用法:node dist/maintenance/webdav-cli.js <已验证备份目录> <远端名称.zip>\n使用 DATA_DIR 对应实例在系统设置中保存的 WebDAV 配置。');
process.exitCode = 1;
} else {
uploadBackup(directory, { name, url: process.env.WEBDAV_URL ?? '', username: process.env.WEBDAV_USERNAME ?? '',
password: process.env.WEBDAV_PASSWORD ?? '', signal: controller.signal,
...(process.env.WEBDAV_MAX_BYTES ? { maxBytes: Number(process.env.WEBDAV_MAX_BYTES) } : {}),
}).then(result => console.log(JSON.stringify(result, null, 2))).catch(() => {
const db=new MediaReadDatabase(join(loadConfig().dataDir,'reader.db'));
const settings=new BusinessSettingsReader(db).read('webdav');db.close();
uploadBackup(directory, { name,...settings,signal:controller.signal }).then(result => console.log(JSON.stringify(result, null, 2))).catch(() => {
// Network exceptions may contain destination URLs; keep credentials and URLs out of logs.
console.error(controller.signal.aborted ? '备份上传已取消' : '备份上传失败,请检查备份校验、远端权限、配额、同名文件及 WebDAV 日志。');
process.exitCode = 1;
+17
View File
@@ -0,0 +1,17 @@
import type {BusinessSettingsReader,BusinessValues} from '../services/business-settings.ts';
import {MetadataHttp,TmdbProvider,MusicBrainzProvider,type MetadataProvider} from './metadata-providers.ts';
export function configuredProviders(settings:BusinessSettingsReader){
let key='',providers:MetadataProvider[]=[];
const limiter={next:0};
return ()=>{
const tmdb=settings.read('tmdb'),musicbrainz=settings.read('musicbrainz'),options=settings.read('scraping');
const next=JSON.stringify([tmdb,musicbrainz,options]);
if(next!==key){key=next;providers=createProviders(tmdb,musicbrainz,new MetadataHttp(fetch,options,limiter));}
return providers;
};
}
export function createProviders(tmdb:BusinessValues['tmdb'],musicbrainz:BusinessValues['musicbrainz'],http:MetadataHttp){
return [new TmdbProvider(http,tmdb.enabled?tmdb.token:'',tmdb.enabled?tmdb.apiKey:'',tmdb.language),
new MusicBrainzProvider(http,musicbrainz.enabled?musicbrainz.userAgent:'')];
}
+12 -13
View File
@@ -35,25 +35,24 @@ const unavailable = () => new AppError(502, 'MEDIA_PROVIDER_UNAVAILABLE', '刮
/** Only provider-owned fixed origins are accepted; never follow redirects with credentials. */
export class MetadataHttp {
private musicBrainzNext = 0;
constructor(private readonly fetcher: typeof fetch = fetch) {}
constructor(private readonly fetcher: typeof fetch = fetch,private readonly options={timeoutMs:12000,retries:2},private readonly limiter={next:0}) {}
async json(url: URL, headers: Record<string, string>, signal?:AbortSignal): Promise<JsonObject> {
if (!['https://api.themoviedb.org', 'https://musicbrainz.org'].includes(url.origin)) throw badRequest('invalid provider origin');
for (let attempt = 0; attempt < 3; attempt++) {
for (let attempt = 0; attempt <= this.options.retries; attempt++) {
signal?.throwIfAborted();
if (url.hostname === 'musicbrainz.org') {
// Reserve before awaiting so concurrent callers obey MusicBrainz's one-request/second limit.
const now = Date.now(), scheduled = Math.max(now, this.musicBrainzNext);
const now = Date.now(), scheduled = Math.max(now, this.limiter.next);
if (scheduled - now > 10_000) throw new AppError(429, 'MEDIA_PROVIDER_BUSY', '刮削请求较多,请稍后重试');
this.musicBrainzNext = scheduled + 1100;
this.limiter.next = scheduled + 1100;
if (scheduled > now) await delay(scheduled - now,undefined,{signal});
}
try {
const response = await this.fetcher(url, { headers, redirect: 'error', signal: signal?AbortSignal.any([signal,AbortSignal.timeout(12_000)]):AbortSignal.timeout(12_000) });
const response = await this.fetcher(url, { headers, redirect: 'error', signal: signal?AbortSignal.any([signal,AbortSignal.timeout(this.options.timeoutMs)]):AbortSignal.timeout(this.options.timeoutMs) });
if (response.status === 429 || response.status >= 500) {
await response.body?.cancel();
if (attempt === 2) throw unavailable();
if (attempt === this.options.retries) throw unavailable();
const retry = Number(response.headers.get('retry-after'));
if (Number.isFinite(retry) && retry > 5) throw unavailable();
await delay(Math.max((attempt + 1) * 500, Number.isFinite(retry) ? retry * 1000 : 0),undefined,{signal});
@@ -83,7 +82,7 @@ export class MetadataHttp {
} catch (error) {
signal?.throwIfAborted();
if (error instanceof AppError) throw error;
if (attempt === 2) throw unavailable();
if (attempt === this.options.retries) throw unavailable();
await delay((attempt + 1) * 500,undefined,{signal});
}
}
@@ -96,15 +95,15 @@ export class TmdbProvider implements MetadataProvider {
readonly label = 'TMDB';
readonly kinds: MediaItemKind[] = ['movie', 'series', 'season', 'episode'];
get configured() { return !!(this.token || this.apiKey); }
constructor(private readonly http: MetadataHttp, private readonly token = process.env.MEDIA_TMDB_TOKEN || '', private readonly apiKey = process.env.MEDIA_TMDB_API_KEY || '') {}
constructor(private readonly http: MetadataHttp, private readonly token = '', private readonly apiKey = '',private readonly language='zh-CN') {}
private endpoint(kind: MediaItemKind): string {
if (!this.configured) throw badRequest('请先配置 MEDIA_TMDB_TOKEN 或 MEDIA_TMDB_API_KEY', 'MEDIA_PROVIDER_NOT_CONFIGURED');
if (!this.configured) throw badRequest('请先在影音设置中配置 TMDB', 'MEDIA_PROVIDER_NOT_CONFIGURED');
if (!this.kinds.includes(kind)) throw badRequest('TMDB 不支持此条目类型');
return kind === 'movie' ? 'movie' : 'tv';
}
private request(path: string, query?: string, signal?:AbortSignal) {
const url = new URL('https://api.themoviedb.org/3/' + path);
url.searchParams.set('language', 'zh-CN');
url.searchParams.set('language', this.language);
if (query) url.searchParams.set('query', query);
const headers: Record<string, string> = { Accept: 'application/json' };
if (this.token) headers.Authorization = 'Bearer ' + this.token;
@@ -150,9 +149,9 @@ export class MusicBrainzProvider implements MetadataProvider {
readonly label = 'MusicBrainz';
readonly kinds: MediaItemKind[] = ['artist', 'album', 'track', 'audiobook'];
get configured() { return !!this.userAgent; }
constructor(private readonly http: MetadataHttp, private readonly userAgent = process.env.MEDIA_MUSICBRAINZ_USER_AGENT || '') {}
constructor(private readonly http: MetadataHttp, private readonly userAgent = '') {}
private entity(kind: MediaItemKind): string {
if (!this.configured) throw badRequest('请先配置包含联系地址的 MEDIA_MUSICBRAINZ_USER_AGENT', 'MEDIA_PROVIDER_NOT_CONFIGURED');
if (!this.configured) throw badRequest('请先在影音设置中配置 MusicBrainz 联系信息', 'MEDIA_PROVIDER_NOT_CONFIGURED');
if (!this.kinds.includes(kind)) throw badRequest('MusicBrainz 不支持此条目类型');
return kind === 'artist' ? 'artist' : kind === 'track' ? 'recording' : 'release-group';
}
+6 -6
View File
@@ -37,7 +37,7 @@ export class MediaScanner {
private running=0;
private pending:Array<()=>void>=[];
private readonly traces=new Map<string,ScanTrace>();
constructor(db:MediaDatabase & Pick<Db,'prepare'>,private readonly libraries:MediaLibraries,private readonly probe:MediaProbe=probeMedia,private readonly logger?:ScanLogger) {
constructor(db:MediaDatabase & Pick<Db,'prepare'>,private readonly libraries:MediaLibraries,private readonly probe:MediaProbe=probeMedia,private readonly logger?:ScanLogger,private readonly settings=()=>({libraries:2,files:4})) {
this.db=new MediaStatementDatabase(db);
db.transaction(()=>{
db.run(`CREATE TABLE IF NOT EXISTS media_assets (
@@ -67,13 +67,13 @@ export class MediaScanner {
if(actor.role!=='admin')throw forbidden('admin role required','ADMIN_REQUIRED');
this.libraries.get(actor,libraryId);
if(this.active.has(libraryId))throw conflict('media scan already running','SCAN_RUNNING');
const id=randomUUID(),controller=new AbortController();
const id=randomUUID(),controller=new AbortController(),options=this.settings();
this.db.run("INSERT INTO media_scan_jobs(id,library_id,state,started_at) VALUES(?,?,'queued',?)",id,libraryId,Date.now());
let finish!:()=>void;const done=new Promise<void>(resolve=>{finish=resolve;});
const execute=()=>{
entry.cancelQueued=undefined;this.running++;
this.db.run("UPDATE media_scan_jobs SET state='running' WHERE id=?",id);
void this.scan(actor,libraryId,id,controller.signal).finally(()=>{this.active.delete(libraryId);this.running--;finish();this.drain();});
void this.scan(actor,libraryId,id,controller.signal,options.files).finally(()=>{this.active.delete(libraryId);this.running--;finish();this.drain();});
};
const entry:{controller:AbortController;done:Promise<void>;cancelQueued:(()=>void)|undefined}={controller,done,cancelQueued:()=>{
this.pending=this.pending.filter(task=>task!==execute);this.active.delete(libraryId);
@@ -83,7 +83,7 @@ export class MediaScanner {
return this.job(actor,id);
}
private drain(){while(this.running<2&&this.pending.length)this.pending.shift()!();}
private drain(){while(this.running<this.settings().libraries&&this.pending.length)this.pending.shift()!();}
startAll(actor:MediaActor){
if(actor.role!=='admin')throw forbidden('admin role required','ADMIN_REQUIRED');
const items:MediaScanJob[]=[],skipped:string[]=[];
@@ -142,7 +142,7 @@ export class MediaScanner {
probe:{status:row.probe_status,info:row.technical_json?JSON.parse(row.technical_json):null}};
}
private async scan(actor:MediaActor,libraryId:string,jobId:string,signal:AbortSignal):Promise<void> {
private async scan(actor:MediaActor,libraryId:string,jobId:string,signal:AbortSignal,files=4):Promise<void> {
const trace=new ScanTrace(jobId,libraryId,this.logger);this.traces.set(jobId,trace);trace.note('开始扫描');
const heartbeat=setInterval(()=>{
trace.note('扫描进度');
@@ -180,7 +180,7 @@ export class MediaScanner {
};
// Remote sidecar reads are I/O bound. Keep local ffprobe sequential and drain
// every batch before failure/cleanup so no worker writes after staging is removed.
const concurrency=storage.filePath?1:4,iterator=storage.list(signal)[Symbol.asyncIterator]();
const concurrency=storage.filePath?1:files,iterator=storage.list(signal)[Symbol.asyncIterator]();
let batch:StorageEntry[]=[];
const flush=async()=>{
const results=await Promise.allSettled(batch.map(inspect));batch=[];
+2 -1
View File
@@ -79,13 +79,14 @@ export class MediaScrapeJobs {
async wait(id:string){await this.active.get(id)?.done;}
async close(){const jobs=[...this.active.values()];jobs.forEach(job=>job.controller.abort());await Promise.all(jobs.map(job=>job.done));}
private async run(actor:MediaActor,id:string,provider:string,items:string[],signal:AbortSignal){
const scraping=this.scraping.snapshot();
try{
for(const item of items){
signal.throwIfAborted();
const user=this.accounts.get(actor.id);
if(!user||user.disabled||user.role!=='admin')throw new Error('actor-unavailable');
this.db.run("UPDATE media_scrape_job_items SET state='running' WHERE job_id=? AND item_id=?",id,item);
try{const result=await this.scraping.autoMatch(actor,item,provider,signal,()=>{const current=this.accounts.get(actor.id);if(!current||current.disabled||current.role!=='admin')throw new Error('actor-unavailable');});signal.throwIfAborted();this.db.run('UPDATE media_scrape_job_items SET state=? WHERE job_id=? AND item_id=?',result.status,id,item);}
try{const result=await scraping.autoMatch(actor,item,provider,signal,()=>{const current=this.accounts.get(actor.id);if(!current||current.disabled||current.role!=='admin')throw new Error('actor-unavailable');});signal.throwIfAborted();this.db.run('UPDATE media_scrape_job_items SET state=? WHERE job_id=? AND item_id=?',result.status,id,item);}
catch(error){if(signal.aborted)throw error;this.db.run("UPDATE media_scrape_job_items SET state='failed',error=? WHERE job_id=? AND item_id=?",'匹配失败,请重新审阅或重试',id,item);}
}
this.db.run("UPDATE media_scrape_jobs SET state='complete' WHERE id=?",id);
+5 -3
View File
@@ -12,10 +12,12 @@ interface CandidateRow { id: string; item_id: string; actor_id: string; provider
/** Search stores reviewable candidates. Only a confirmed, item-bound candidate may publish metadata. */
export class MediaScraping {
private readonly providers: MetadataProvider[];
constructor(private readonly db:MediaDatabase, private readonly catalog: MediaCatalog, providers?: MetadataProvider[]) {
private readonly source:MetadataProvider[]|(()=>MetadataProvider[]);
private get providers(){return typeof this.source==='function'?this.source():this.source;}
snapshot(){return new MediaScraping(this.db,this.catalog,this.providers);}
constructor(private readonly db:MediaDatabase, private readonly catalog: MediaCatalog, providers?: MetadataProvider[]|(()=>MetadataProvider[])) {
const http = new MetadataHttp();
this.providers = providers ?? [new TmdbProvider(http), new MusicBrainzProvider(http)];
this.source = providers ?? [new TmdbProvider(http), new MusicBrainzProvider(http)];
db.run(`CREATE TABLE IF NOT EXISTS media_scrape_candidates (
id TEXT PRIMARY KEY,item_id TEXT NOT NULL REFERENCES media_items(id) ON DELETE CASCADE,
actor_id TEXT NOT NULL,provider TEXT NOT NULL,external_id TEXT NOT NULL,expires_at INTEGER NOT NULL)`);
+151
View File
@@ -0,0 +1,151 @@
import type { Db } from '../db/index.ts';
import type { AppConfig } from '../config/index.ts';
import { badRequest, conflict } from '../lib/errors.ts';
export interface BusinessValues {
tmdb:{enabled:boolean;token:string;apiKey:string;language:string};
musicbrainz:{enabled:boolean;userAgent:string};
tts:{enabled:boolean;url:string;token:string;voicesUrl:string;timeoutMs:number;cacheMaxBytes:number};
scanning:{interval:number;libraries:number;files:number};
scraping:{timeoutMs:number;retries:number};
access:{publicUrl:string;corsOrigins:string;accessTokenTtl:number;refreshTokenTtl:number};
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';default:string|number|boolean;min?:number;max?:number;help?:string};
export const SETTINGS_SCHEMA:Record<SettingsGroup,{label:string;fields:Field[]}>= {
tmdb:{label:'TMDB',fields:[
{key:'enabled',label:'启用 TMDB',type:'checkbox',default:false},
{key:'token',label:'读取令牌',type:'password',default:''},
{key:'apiKey',label:'API Key(未填令牌时使用)',type:'password',default:''},
{key:'language',label:'资料语言',type:'text',default:'zh-CN',help:'例如 zh-CN、zh-TW、en-US'},
]},
musicbrainz:{label:'MusicBrainz',fields:[
{key:'enabled',label:'启用 MusicBrainz',type:'checkbox',default:false},
{key:'userAgent',label:'应用标识与联系地址',type:'text',default:'',help:'例如 Reader/0.1 (mailto:admin@example.com)'},
]},
tts:{label:'HTTP 朗读',fields:[
{key:'enabled',label:'启用 HTTP 朗读',type:'checkbox',default:false},
{key:'url',label:'语音合成接口',type:'url',default:'',help:'兼容 text、voice、speed 查询参数;地址须能从服务器访问'},
{key:'token',label:'认证令牌',type:'password',default:''},
{key:'voicesUrl',label:'音色列表接口(可选)',type:'url',default:''},
{key:'timeoutMs',label:'请求超时(毫秒)',type:'number',default:20000,min:100,max:120000},
{key:'cacheMaxBytes',label:'音频缓存上限(字节,0 关闭)',type:'number',default:268435456,min:0,max:10737418240},
]},
scanning:{label:'扫描设置',fields:[
{key:'interval',label:'阅读书库自动检查间隔(秒,0 关闭)',type:'number',default:60,min:0,max:604800,help:'统一原定时扫描与变化检查;保存后重新计时'},
{key:'libraries',label:'同时扫描的媒体库数',type:'number',default:2,min:1,max:4},
{key:'files',label:'远端文件处理并发数',type:'number',default:4,min:1,max:8},
]},
scraping:{label:'刮削高级设置',fields:[
{key:'timeoutMs',label:'请求超时(毫秒)',type:'number',default:12000,min:1000,max:120000},
{key:'retries',label:'失败重试次数',type:'number',default:2,min:0,max:3},
]},
access:{label:'登录与服务访问',fields:[
{key:'publicUrl',label:'对外服务地址',type:'url',default:''},
{key:'corsOrigins',label:'允许的浏览器来源',type:'text',default:'',help:'多个来源以逗号分隔;留空允许任意来源'},
{key:'accessTokenTtl',label:'访问令牌有效期(秒)',type:'number',default:604800,min:300,max:31536000},
{key:'refreshTokenTtl',label:'登录续期有效期(秒)',type:'number',default:31536000,min:3600,max:157680000},
]},
webdav:{label:'WebDAV 备份',fields:[
{key:'url',label:'备份目录地址',type:'url',default:'',help:'使用 HTTPS;仅本机调试允许 HTTP'},
{key:'username',label:'账号',type:'text',default:''},
{key:'password',label:'密码',type:'password',default:''},
{key:'maxBytes',label:'单次上传上限(字节)',type:'number',default:2147483648,min:1,max:4294967295},
]},
};
export function settingsDefaults<K extends SettingsGroup>(group:K):BusinessValues[K]{
return Object.fromEntries(SETTINGS_SCHEMA[group].fields.map(field=>[field.key,field.default])) as BusinessValues[K];
}
export class BusinessSettingsReader {
constructor(protected readonly db:Pick<Db,'get'>){}
read<K extends SettingsGroup>(group:K):BusinessValues[K]{
const row=this.db.get<{value:string}>('SELECT value FROM business_settings WHERE name=?',group);
return {...settingsDefaults(group),...(row?JSON.parse(row.value):{})};
}
}
export class BusinessSettings extends BusinessSettingsReader {
constructor(private readonly database:Db,config:AppConfig){
super(database);
database.transaction(()=>{
for(const group of Object.keys(SETTINGS_SCHEMA) as SettingsGroup[]){
if(!database.get('SELECT name FROM business_settings WHERE name=?',group)){
const initial=legacySettings(group,config);
database.run('INSERT INTO business_settings(name,value,revision) VALUES(?,?,1)',group,JSON.stringify(initial));
}
}
});
this.apply(config);
}
apply(config:AppConfig){
const access=this.read('access');
Object.assign(config,{publicUrl:access.publicUrl,corsOrigins:access.corsOrigins.split(',').map(value=>value.trim()).filter(Boolean),accessTokenTtl:access.accessTokenTtl,refreshTokenTtl:access.refreshTokenTtl});
}
view(group:SettingsGroup){
const values={...this.read(group)} as Record<string,unknown>,secrets:Record<string,boolean>={};
for(const field of SETTINGS_SCHEMA[group].fields)if(field.type==='password'){secrets[field.key]=!!values[field.key];delete values[field.key];}
return {group,...SETTINGS_SCHEMA[group],values,secrets,revision:this.database.get<{revision:number}>('SELECT revision FROM business_settings WHERE name=?',group)!.revision};
}
candidate<K extends SettingsGroup>(group:K,input:unknown):BusinessValues[K]{
if(!input||typeof input!=='object'||Array.isArray(input))throw badRequest('配置必须是对象');
const patch=input as Record<string,unknown>,values={...this.read(group)} as Record<string,unknown>;
for(const [key,value] of Object.entries(patch)){
const field=SETTINGS_SCHEMA[group].fields.find(field=>field.key===key);
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}格式无效`);
values[key]=typeof value==='string'&&field.type!=='password'?value.trim():value;
if(field.type==='url'&&values[key])validateUrl(String(values[key]));
}
if(group==='tmdb'){
if(values.enabled&&!values.token&&!values.apiKey)throw badRequest('启用 TMDB 前请填写令牌或 API Key');
if(!/^[a-z]{2,3}(?:-[a-zA-Z]{2,4})?$/.test(String(values.language)))throw badRequest('无效的资料语言');
}
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==='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('登录续期有效期不能小于访问令牌有效期');
}
if(group==='webdav'&&values.url){
const url=validateUrl(String(values.url));
if(url.search||url.hash||(url.protocol!=='https:'&&!['127.0.0.1','[::1]','localhost'].includes(url.hostname)))throw badRequest('WebDAV 必须使用 HTTPS 目录地址,且不能包含查询参数');
if(String(values.username).includes(':'))throw badRequest('WebDAV 账号不能包含冒号');
}
return values as unknown as BusinessValues[K];
}
save(group:SettingsGroup,input:unknown,revision:unknown){
return this.database.transaction(()=>{
const current=this.view(group);
if(revision!==current.revision)throw conflict('配置已被其他管理员修改,请重新加载后保存','SETTINGS_CONFLICT');
const values=this.candidate(group,input);
this.database.run('UPDATE business_settings SET value=?,revision=revision+1 WHERE name=?',JSON.stringify(values),group);
return this.view(group);
});
}
}
function validateUrl(value:string){
let url:URL;try{url=new URL(value);}catch{throw badRequest('请输入有效的 HTTP 或 HTTPS 地址');}
if(!['http:','https:'].includes(url.protocol)||url.username||url.password||url.hash)throw badRequest('地址不能包含账号、密码、片段或其他协议');
return url;
}
/** Called only for missing database rows: clearing a page field never revives env values. */
function legacySettings<K extends SettingsGroup>(group:K,config:AppConfig):BusinessValues[K]{
const env=process.env,text=(name:string)=>env[name]?.trim()||'';
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>={
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)},
scanning:{interval:intervals.length?Math.min(...intervals):0,libraries:2,files:4},
access:{publicUrl:config.publicUrl,corsOrigins:config.corsOrigins.join(','),accessTokenTtl:config.accessTokenTtl,refreshTokenTtl:config.refreshTokenTtl},
webdav:{url:text('WEBDAV_URL'),username:text('WEBDAV_USERNAME'),password:text('WEBDAV_PASSWORD'),maxBytes:integer('WEBDAV_MAX_BYTES',2147483648)},
};
const result=(legacy[group]??settingsDefaults(group)) as unknown as Record<string,unknown>;
for(const field of SETTINGS_SCHEMA[group].fields){
if(field.type==='number'&&(!Number.isSafeInteger(result[field.key])||Number(result[field.key])<field.min!||Number(result[field.key])>field.max!))result[field.key]=field.default;
}
return result as unknown as BusinessValues[K];
}
+4 -2
View File
@@ -6,10 +6,12 @@ export type RegistrationMode = 'closed' | 'open' | 'invite';
const digest = (code: string) => createHash('sha256').update(code.trim()).digest('hex');
export class RegistrationService {
constructor(private readonly db: Db) {}
constructor(private readonly db: Db) {
db.run('INSERT OR IGNORE INTO registration_settings(id,mode) VALUES(1,?)',process.env.ALLOW_REGISTRATION==='true'?'open':'closed');
}
mode(): RegistrationMode {
return this.db.get<{mode:RegistrationMode}>('SELECT mode FROM registration_settings WHERE id = 1')?.mode
?? (process.env.ALLOW_REGISTRATION === 'true' ? 'open' : 'closed');
?? 'closed';
}
setMode(mode: unknown): void {
if (mode !== 'closed' && mode !== 'open' && mode !== 'invite') throw badRequest('无效的注册模式');
+21 -12
View File
@@ -13,6 +13,7 @@ import {
writeFileSync,
} from 'node:fs';
import { join } from 'node:path';
import {settingsDefaults,type BusinessValues} from './business-settings.ts';
/**
* Server-side speech synthesis, for a client that has no engine of its own.
@@ -45,6 +46,7 @@ const MAX_TEXT_LENGTH = 800;
/** What the client is told this instance supports. */
export interface TtsCapabilities {
timeoutMs:number;
/** True when `TTS_URL` is configured, i.e. this instance can synthesise. */
http: boolean;
/** Content types the proxy may answer with, for the client's own checks. */
@@ -86,19 +88,19 @@ export class TtsService {
private cacheBytes = 0;
private cacheFiles: string[] = [];
constructor(config: AppConfig) {
const url = (process.env.TTS_URL ?? '').trim().replace(/\/+$/, '');
this.config = url
constructor(config: AppConfig,input:BusinessValues['tts']=settingsDefaults('tts')) {
const url = input.url.trim().replace(/\/+$/, '');
this.config = input.enabled&&url
? {
url,
token: (process.env.TTS_TOKEN ?? '').trim(),
voicesUrl: (process.env.TTS_VOICES_URL ?? '').trim(),
timeoutMs: Number.parseInt(process.env.TTS_TIMEOUT_MS ?? '20000', 10) || 20_000,
token: input.token,
voicesUrl: input.voicesUrl,
timeoutMs: input.timeoutMs,
cacheDir: join(config.dataDir, 'tts-cache'),
cacheMaxBytes: Number.parseInt(process.env.TTS_CACHE_BYTES ?? String(256 * 1024 * 1024), 10) || 0,
cacheMaxBytes: input.cacheMaxBytes,
}
: null;
if (this.config) this.loadCacheIndex();
if (this.config) {this.loadCacheIndex();if(this.config.cacheMaxBytes>0)this.evict();}
}
/** Whether this instance can synthesise at all. */
@@ -108,6 +110,7 @@ export class TtsService {
capabilities(): TtsCapabilities {
return {
timeoutMs:this.config?.timeoutMs??20000,
http: this.enabled,
formats: ['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/mp4'],
maxLength: MAX_TEXT_LENGTH,
@@ -130,7 +133,7 @@ export class TtsService {
}): Promise<{ bytes: Uint8Array; contentType: string; cached: boolean }> {
const config = this.config;
if (!config) {
throw badRequest('this instance has no HTTP TTS engine configured (set TTS_URL)', 'TTS_DISABLED');
throw badRequest('请管理员在系统设置中配置 HTTP 朗读', 'TTS_DISABLED');
}
const text = (input.text ?? '').trim();
if (!text) throw badRequest('text is required');
@@ -142,7 +145,7 @@ export class TtsService {
const speed = input.speed === undefined ? 1 : clamp(input.speed, 0.25, 4);
const voice = (input.voice ?? '').slice(0, 120);
const key = hashKey({ text, voice, speed, format: input.format ?? '', url: config.url });
const key = hashKey({ text, voice, speed, format: input.format ?? '', url: config.url,credential:hashKey({token:config.token}) });
const cached = this.readCache(key);
if (cached) return { ...cached, cached: true };
@@ -247,7 +250,7 @@ export class TtsService {
private readCache(key: string): { bytes: Uint8Array; contentType: string } | null {
const config = this.config;
if (!config) return null;
if (!config||config.cacheMaxBytes<=0) return null;
const meta = join(config.cacheDir, `${key}.meta`);
const data = join(config.cacheDir, `${key}.bin`);
try {
@@ -303,6 +306,12 @@ export class TtsService {
}
}
}
clearCache():void {
if(!this.config)return;
for(const name of readdirSync(this.config.cacheDir))if(/^[a-f0-9]{32}\.(bin|meta)$/.test(name))unlinkSync(join(this.config.cacheDir,name));
this.loadCacheIndex();
}
}
function normaliseVoice(row: unknown): TtsVoice | null {
@@ -329,5 +338,5 @@ function clamp(value: number, min: number, max: number): number {
/** Wired in `main.ts`; kept out of `AppContext` so tests can build one freely. */
export function createTtsService(ctx: AppContext): TtsService {
return new TtsService(ctx.config);
return new TtsService(ctx.config,ctx.settings?.read('tts'));
}