From 68e085482c7b58174400aef20248c92d59979d46 Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Tue, 20 Oct 2026 04:41:03 +0000 Subject: [PATCH] =?UTF-8?q?feat(media):=20library=20management=20states=20?= =?UTF-8?q?and=20=E6=89=AB=E6=8F=8F=E4=B8=8E=E5=85=83=E6=95=B0=E6=8D=AE=20?= =?UTF-8?q?(prototype=2020)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server: - Scan jobs record the number of files listed (total), so a running scan can report a real percentage. - New 扫描设置 business settings, shown as the page's 扫描与元数据 card: 自动扫描新文件 (hourly rescan of every library, default on), 刮削封面与简介 (after a completed scan, scrape items that have no online metadata and were never attempted, with the first configured provider; default off), 读取内嵌字幕与歌词 (default on; off hides embedded subtitle streams and lyrics tags), 扫描隐藏文件夹 (default off: names starting with '.' are skipped, e.g. ._film.mp4 resource forks). - MediaAutomation runs the scheduled scans and post-scan scraping as the first active administrator; MediaScanner gains onFinished/isScanning. Client: - Each library card shows its latest scan: 上次扫描 今天 09:12 beside 重新扫描; 正在扫描 · 已处理 62% with a progress bar and 取消; or the failure (路径不可访问…) with 重试 and 查看详情 (the tasks page). Starting or cancelling a scan now stays on the page; scan-jobs is polled. - 扫描与元数据 card beside the list saves each switch with the settings revision, and hides itself on servers without these settings. --- server/src/http/routes/media.ts | 10 +- server/src/media/automation.ts | 93 +++++++++++++++++++ server/src/media/lyrics.ts | 4 +- server/src/media/scanner.ts | 31 +++++-- server/src/media/subtitles.ts | 4 +- server/src/services/business-settings.ts | 8 +- server/test/media-automation.test.ts | 88 ++++++++++++++++++ web/src/features/media/api/media-api.ts | 2 +- .../media/components/library-list.tsx | 40 +++++++- .../media/components/scan-settings.tsx | 55 +++++++++++ .../media/pages/MediaLibrariesPage.tsx | 23 +++-- .../features/media/styles/presentation.css | 29 ++++++ web/test/media-library-scan-state.test.tsx | 67 +++++++++++++ 13 files changed, 429 insertions(+), 25 deletions(-) create mode 100644 server/src/media/automation.ts create mode 100644 server/test/media-automation.test.ts create mode 100644 web/src/features/media/components/scan-settings.tsx create mode 100644 web/test/media-library-scan-state.test.tsx diff --git a/server/src/http/routes/media.ts b/server/src/http/routes/media.ts index 45e9e62..1e4d55c 100644 --- a/server/src/http/routes/media.ts +++ b/server/src/http/routes/media.ts @@ -25,6 +25,7 @@ import {MediaDirectoryRules} from '../../media/directory-rules.ts'; import {MediaRecognitionReview} from '../../media/recognition-review.ts'; import type {VideoRule} from '../../media/video-recognition.ts'; import { MediaScrapeJobs,SCRAPE_RESULT_STATES } from '../../media/scrape-jobs.ts'; +import { MediaAutomation } from '../../media/automation.ts'; import { MediaBackgroundGrants } from '../../media/background-grants.ts'; import { DatabaseMediaAccounts, MediaAccountReferences } from '../../media/accounts.ts'; import type { MediaDatabase } from '../../media/read-database.ts'; @@ -50,8 +51,11 @@ export function registerMediaRoutes(app:FastifyInstance,ctx:MediaRouteContext,op const hierarchy=new MediaHierarchy(database,scanner.catalog); const scrapeJobs=new MediaScrapeJobs(database,scanner.catalog,scraping,accounts); const artwork=new MediaArtwork(database,libraries); - const subtitles=new MediaSubtitles(database,libraries); - const lyrics=new MediaLyrics(database,libraries); + const embeddedEnabled=()=>settings.read('scanning').mediaEmbedded; + const subtitles=new MediaSubtitles(database,libraries,undefined,embeddedEnabled); + const lyrics=new MediaLyrics(database,libraries,embeddedEnabled); + // 扫描与元数据 (业务配置 · 扫描设置): hourly automatic scans, and scraping after a scan. + const automation=new MediaAutomation(ctx.db,database,libraries,scanner,scraping,scrapeJobs,()=>settings.read('scanning'),app.log); const folders=new MediaFolders(database,libraries,scanner.catalog); const cleanup=new MediaCleanup(database,libraries,scanner.catalog); const directoryRules=new MediaDirectoryRules(database),recognition=new MediaRecognitionReview(database,libraries,scanner.catalog,directoryRules); @@ -134,7 +138,7 @@ export function registerMediaRoutes(app:FastifyInstance,ctx:MediaRouteContext,op preHandler:scrapeAdmin,schema:{body:{type:'object',additionalProperties:false,required:['candidateId'],properties:{candidateId:{type:'string',minLength:1,maxLength:100}}}}, },async request=>scraping.confirm(currentUser(request),request.params.id,request.body.candidateId)); app.delete<{Params:{id:string}}>('/api/v1/media/items/:id/match',{preHandler:scrapeAdmin},async request=>scraping.clear(currentUser(request),request.params.id)); - app.addHook('onClose',async()=>{await ai.close();await catalogReader?.close();await scrapeJobs.close();await scanner.close();}); + app.addHook('onClose',async()=>{automation.stop();await ai.close();await catalogReader?.close();await scrapeJobs.close();await scanner.close();}); const pagination={type:'object',properties:{offset:{type:'integer',minimum:0,default:0},limit:{type:'integer',minimum:1,maximum:200,default:100}}}; app.get('/api/v1/media/libraries',{preHandler:auth},async request=>({items:libraries.list(currentUser(request))})); app.get('/api/v1/media/library-summaries',{preHandler:scrapeAdmin},async(request,reply)=>{reply.header('cache-control','private, no-store');return queryCatalog(request,reply,{method:'librarySummaries',args:[currentUser(request)]});}); diff --git a/server/src/media/automation.ts b/server/src/media/automation.ts new file mode 100644 index 0000000..9402ab2 --- /dev/null +++ b/server/src/media/automation.ts @@ -0,0 +1,93 @@ +import type { MediaDatabase } from './read-database.ts'; +import type { MediaActor, MediaLibraries, MediaLibraryKind } from './libraries.ts'; +import type { MediaScanner, ScanFinished } from './scanner.ts'; +import type { MediaScraping } from './scraping.ts'; +import type { MediaScrapeJobs } from './scrape-jobs.ts'; +import type { MediaItemKind } from './catalog.ts'; + +export const AUTO_SCAN_INTERVAL_MS = 60 * 60 * 1000; +/** The items a library is scraped by: the top of each hierarchy, never episodes or tracks. */ +const scrapeKinds: Record = { video: ['movie', 'series'], music: ['album'], audiobook: ['audiobook'] }; + +interface Settings { mediaAutoScan: boolean; mediaScrape: boolean } +interface Log { info(data: object, message: string): void; warn(data: object, message: string): void } + +/** + * 扫描与元数据 on the media library page: + * - 自动扫描新文件: every library is rescanned hourly (unchanged files reuse their probe); + * - 刮削封面与简介: after a scan completes, items that have neither online metadata + * nor an earlier scrape attempt are scraped with the first configured provider that + * supports them. One attempt per item, so an unmatched film is not retried hourly. + * + * Automatic work runs as the first active administrator; with none, it does nothing. + */ +export class MediaAutomation { + private timer: ReturnType | undefined; + constructor( + private readonly accounts: Pick, + private readonly db: MediaDatabase, + private readonly libraries: MediaLibraries, + private readonly scanner: MediaScanner, + private readonly scraping: MediaScraping, + private readonly scrapeJobs: MediaScrapeJobs, + private readonly settings: () => Settings, + private readonly log?: Log, + interval = AUTO_SCAN_INTERVAL_MS, + ) { + scanner.onFinished = event => this.afterScan(event); + if (interval > 0) { this.timer = setInterval(() => this.scanAll(), interval); this.timer.unref?.(); } + } + + stop() { if (this.timer) clearInterval(this.timer); this.timer = undefined; } + + private systemActor(): MediaActor | null { + const row = this.accounts.get<{ id: string }>("SELECT id FROM users WHERE role='admin' AND disabled=0 ORDER BY created_at,id LIMIT 1"); + return row ? { id: row.id, role: 'admin' } : null; + } + + /** Starts a scan of every library not already scanning. Returns the libraries started. */ + scanAll(): string[] { + if (!this.settings().mediaAutoScan) return []; + const actor = this.systemActor(); + if (!actor) return []; + const started: string[] = []; + for (const library of this.libraries.list(actor)) { + if (this.scanner.isScanning(library.id)) continue; + try { this.scanner.start(actor, library.id); started.push(library.id); } + catch (error) { this.log?.warn({ err: error, libraryId: library.id }, 'automatic media scan skipped'); } + } + if (started.length) this.log?.info({ libraries: started.length }, 'automatic media scan started'); + return started; + } + + /** Items in a library that were never scraped and have no online metadata. */ + unscraped(libraryId: string, kinds: readonly MediaItemKind[], limit = 500): string[] { + if (!kinds.length) return []; + return this.db.all<{ id: string }>(`SELECT i.id FROM media_items i WHERE i.library_id=? AND i.kind IN (${kinds.map(() => '?').join(',')}) + AND NOT EXISTS (SELECT 1 FROM media_online_metadata m WHERE m.item_id=i.id) + AND NOT EXISTS (SELECT 1 FROM media_scrape_job_items j WHERE j.item_id=i.id) + ORDER BY i.rowid DESC LIMIT ?`, libraryId, ...kinds, limit).map(row => row.id); + } + + /** Returns the started scrape job's id, or null when there was nothing to do. */ + afterScan(event: ScanFinished): string | null { + if (event.state !== 'complete' || !this.settings().mediaScrape) return null; + const actor: MediaActor = event.actor.role === 'admin' ? event.actor : this.systemActor() ?? event.actor; + try { + const library = this.libraries.get(actor, event.libraryId); + const wanted = scrapeKinds[library.kind]; + for (const provider of this.scraping.status(actor).items) { + if (!provider.configured) continue; + const ids = this.unscraped(library.id, wanted.filter(kind => provider.kinds.includes(kind))); + if (!ids.length) continue; + const job = this.scrapeJobs.start(actor, provider.id, ids, 'strong'); + this.log?.info({ libraryId: library.id, provider: provider.id, items: ids.length }, 'automatic scrape started'); + return job.id; + } + } catch (error) { + // Typically another scrape job is running; the next scan tries again. + this.log?.warn({ err: error, libraryId: event.libraryId }, 'automatic scrape skipped'); + } + return null; + } +} diff --git a/server/src/media/lyrics.ts b/server/src/media/lyrics.ts index 840203a..e440c14 100644 --- a/server/src/media/lyrics.ts +++ b/server/src/media/lyrics.ts @@ -36,7 +36,7 @@ export function parseLyrics(bytes:Uint8Array):Omit { /** Part IDs resolve through the catalog; the client never supplies a file path. */ export class MediaLyrics { - constructor(private readonly db:MediaDatabase,private readonly libraries:MediaLibraries){} + constructor(private readonly db:MediaDatabase,private readonly libraries:MediaLibraries,private readonly embeddedEnabled:()=>boolean=()=>true){} private asset(actor:MediaActor,partId:string){ const asset=this.db.get<{id:string;library_id:string;ref:string;available:number;technical_json:string|null}>(`SELECT a.id,a.library_id,a.ref,a.available,a.technical_json FROM media_assets a JOIN media_parts p ON p.asset_id=a.id WHERE p.id=? AND p.active=1`,partId); if(!asset)throw notFound('media part not found'); @@ -54,7 +54,7 @@ export class MediaLyrics { const {stream}=await storage.open(sidecar.ref);const chunks:Buffer[]=[];let size=0; for await(const chunk of stream){size+=chunk.length;if(size>MAX_BYTES){stream.destroy();throw invalid();}chunks.push(Buffer.from(chunk));} bytes=Buffer.concat(chunks);source='sidecar'; - }else{ + }else if(this.embeddedEnabled()){ const info:MediaTechnicalInfo|null=asset.technical_json?JSON.parse(asset.technical_json):null; const tag=Object.entries(info?.tags??{}).find(([key])=>/^(?:lyrics|unsyncedlyrics|unsynced_lyrics)(?:-|$)/i.test(key))?.[1]; if(tag){bytes=Buffer.from(tag);source='embedded';} diff --git a/server/src/media/scanner.ts b/server/src/media/scanner.ts index eaab765..41a8dc6 100644 --- a/server/src/media/scanner.ts +++ b/server/src/media/scanner.ts @@ -27,10 +27,15 @@ export interface MediaAsset { } export interface MediaScanJob { id:string; libraryId:string; state:'queued'|'running'|'complete'|'failed'|'cancelled'; - inspected:number; error:string|null; startedAt:number; finishedAt:number|null; + /** Files checked so far, and (once the listing finished) how many there are in all. */ + inspected:number; total:number|null; error:string|null; startedAt:number; finishedAt:number|null; diagnostics?:ScanDiagnostics; } -interface JobRow { id:string;library_id:string;state:MediaScanJob['state'];inspected:number;error:string|null;started_at:number;finished_at:number|null;diagnostics_json:string|null } +interface JobRow { id:string;library_id:string;state:MediaScanJob['state'];inspected:number;total:number|null;error:string|null;started_at:number;finished_at:number|null;diagnostics_json:string|null } +/** `hidden`: also scan names starting with "." (the default here keeps a bare scanner's old behaviour). */ +export interface ScanSettings { libraries:number; files:number; mediaHidden?:boolean } +export interface ScanFinished { actor:MediaActor; libraryId:string; jobId:string; state:'complete'|'failed'|'cancelled' } +const hiddenRef=(ref:string)=>ref.split('/').some(segment=>segment.startsWith('.')); /** Stage a complete snapshot before publishing it; failed scans never mark missing. */ export class MediaScanner { @@ -40,7 +45,9 @@ export class MediaScanner { private running=0; private pending:Array<()=>void>=[]; private readonly traces=new Map(); - constructor(db:MediaDatabase & Pick,private readonly libraries:MediaLibraries,private readonly probe:MediaProbe=probeMedia,private readonly logger?:ScanLogger,private readonly settings=()=>({libraries:2,files:4})) { + /** Called once per scan when it ends, e.g. to start automatic scraping. Errors are the caller's. */ + onFinished?:(event:ScanFinished)=>void; + constructor(db:MediaDatabase & Pick,private readonly libraries:MediaLibraries,private readonly probe:MediaProbe=probeMedia,private readonly logger?:ScanLogger,private readonly settings:()=>ScanSettings=()=>({libraries:2,files:4,mediaHidden:true})) { this.db=new MediaStatementDatabase(db); db.transaction(()=>{ db.run(`CREATE TABLE IF NOT EXISTS media_assets ( @@ -57,6 +64,8 @@ export class MediaScanner { db.run('CREATE INDEX IF NOT EXISTS media_scan_jobs_library_time ON media_scan_jobs(library_id,started_at)'); if(!db.all<{name:string}>('PRAGMA table_info(media_scan_jobs)').some(column=>column.name==='diagnostics_json')) db.run('ALTER TABLE media_scan_jobs ADD COLUMN diagnostics_json TEXT'); + if(!db.all<{name:string}>('PRAGMA table_info(media_scan_jobs)').some(column=>column.name==='total')) + db.run('ALTER TABLE media_scan_jobs ADD COLUMN total INTEGER'); db.run(`CREATE TABLE IF NOT EXISTS media_scan_stage ( job_id TEXT NOT NULL REFERENCES media_scan_jobs(id) ON DELETE CASCADE,ref TEXT NOT NULL,payload TEXT NOT NULL, PRIMARY KEY(job_id,ref))`); @@ -77,7 +86,7 @@ export class MediaScanner { 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,options.files).finally(()=>{this.active.delete(libraryId);this.running--;finish();this.drain();}); + void this.scan(actor,libraryId,id,controller.signal,options.files,options.mediaHidden!==false).finally(()=>{this.active.delete(libraryId);this.running--;finish();this.drain();this.finished(actor,libraryId,id);}); }; const entry:{controller:AbortController;done:Promise;cancelQueued:(()=>void)|undefined}={controller,done,cancelQueued:()=>{ this.pending=this.pending.filter(task=>task!==execute);this.active.delete(libraryId); @@ -88,6 +97,13 @@ export class MediaScanner { } private drain(){while(this.running('SELECT state FROM media_scan_jobs WHERE id=?',jobId)?.state; + if(state!=='complete'&&state!=='failed'&&state!=='cancelled')return; + try{this.onFinished?.({actor,libraryId,jobId,state});}catch(error){this.logger?.warn?.({err:error,libraryId},'scan completion hook failed');} + } + /** Libraries with a scan queued or running right now. */ + isScanning(libraryId:string):boolean{return this.active.has(libraryId);} startAll(actor:MediaActor){ if(actor.role!=='admin')throw forbidden('admin role required','ADMIN_REQUIRED'); const items:MediaScanJob[]=[],skipped:string[]=[]; @@ -129,7 +145,7 @@ export class MediaScanner { } private jobDto(row:JobRow):MediaScanJob { - return {id:row.id,libraryId:row.library_id,state:row.state,inspected:row.inspected,error:row.error,startedAt:row.started_at,finishedAt:row.finished_at, + return {id:row.id,libraryId:row.library_id,state:row.state,inspected:row.inspected,total:row.total??null,error:row.error,startedAt:row.started_at,finishedAt:row.finished_at, diagnostics:this.traces.get(row.id)?.snapshot()??(row.diagnostics_json?JSON.parse(row.diagnostics_json):undefined)}; } @@ -154,7 +170,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,files=4):Promise { + private async scan(actor:MediaActor,libraryId:string,jobId:string,signal:AbortSignal,files=4,hidden=true):Promise { const trace=new ScanTrace(jobId,libraryId,this.logger);this.traces.set(jobId,trace);trace.note('开始扫描'); const heartbeat=setInterval(()=>{ trace.note('扫描进度'); @@ -204,7 +220,8 @@ export class MediaScanner { // every batch before failure/cleanup so no worker writes after staging is removed. const concurrency=storage.filePath?1:files; const entries:StorageEntry[]=[];const iterator=storage.list(signal)[Symbol.asyncIterator](); - try{for(;;){const next=await trace.measure('list',undefined,()=>iterator.next());if(next.done)break;if(supported.has(extname(next.value.ref).toLowerCase()))entries.push(next.value);}}finally{await iterator.return?.();} + try{for(;;){const next=await trace.measure('list',undefined,()=>iterator.next());if(next.done)break;if(!hidden&&hiddenRef(next.value.ref))continue;if(supported.has(extname(next.value.ref).toLowerCase()))entries.push(next.value);}}finally{await iterator.return?.();} + this.db.run('UPDATE media_scan_jobs SET total=? WHERE id=?',entries.length,jobId); if(library.kind==='video'){ const groups=new Map(); for(const entry of entries){const rule=rules.filter(value=>withinDirectory(entry.ref,value.path)).sort((a,b)=>b.path.length-a.path.length)[0];if(!rule||!['series','season'].includes(rule.mode))continue;const key=rule.path+'\0'+String(rule.season??'正片');const group=groups.get(key)??[];group.push(entry);groups.set(key,group);} diff --git a/server/src/media/subtitles.ts b/server/src/media/subtitles.ts index ad04cac..bac9c16 100644 --- a/server/src/media/subtitles.ts +++ b/server/src/media/subtitles.ts @@ -74,7 +74,8 @@ export function subtitleToVtt(bytes: Uint8Array, format: string): string { /** Subtitle IDs are derived from an authorized asset's matching sidecars, not arbitrary paths. */ export class MediaSubtitles { - constructor(private readonly db:MediaDatabase, private readonly libraries: MediaLibraries, private readonly extract=extractEmbeddedSubtitle) {} + /** `embedded`: whether streams inside the video are offered (业务配置 · 读取内嵌字幕与歌词). */ + constructor(private readonly db:MediaDatabase, private readonly libraries: MediaLibraries, private readonly extract=extractEmbeddedSubtitle, private readonly embeddedEnabled:()=>boolean=()=>true) {} private asset(actor: MediaActor, id: string): Asset { const asset = this.db.get('SELECT id,library_id,ref,available,technical_json FROM media_assets WHERE id=?', id); if (!asset) throw notFound('media asset not found'); @@ -83,6 +84,7 @@ export class MediaSubtitles { return asset; } private embedded(asset:Asset) { + if(!this.embeddedEnabled())return []; const info:MediaTechnicalInfo|null=asset.technical_json?JSON.parse(asset.technical_json):null; if(!['.mp4','.m4v','.mov','.mkv','.webm'].includes(posix.extname(asset.ref).toLowerCase()))return []; return (info?.streams??[]).filter(stream=>stream.type==='subtitle'&&Number.isSafeInteger(stream.index)&&stream.index>=0&& diff --git a/server/src/services/business-settings.ts b/server/src/services/business-settings.ts index 0648e1d..09b0be9 100644 --- a/server/src/services/business-settings.ts +++ b/server/src/services/business-settings.ts @@ -8,7 +8,7 @@ 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}; + scanning:{interval:number;libraries:number;files:number;mediaAutoScan:boolean;mediaScrape:boolean;mediaEmbedded:boolean;mediaHidden:boolean}; scraping:{timeoutMs:number;retries:number}; access:{publicUrl:string;corsOrigins:string;accessTokenTtl:number;refreshTokenTtl:number}; webdav:{url:string;username:string;password:string;maxBytes:number}; @@ -51,6 +51,10 @@ export const SETTINGS_SCHEMA:Record {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}, + {key:'mediaAutoScan',label:'自动扫描新文件',type:'checkbox',default:true,help:'影音库每小时检查一次新增和变化的文件'}, + {key:'mediaScrape',label:'刮削封面与简介',type:'checkbox',default:false,help:'扫描完成后,用已配置的 TMDB 或 MusicBrainz 为还没有在线资料的作品刮削'}, + {key:'mediaEmbedded',label:'读取内嵌字幕与歌词',type:'checkbox',default:true}, + {key:'mediaHidden',label:'扫描隐藏文件夹',type:'checkbox',default:false,help:'名称以“.”开头的文件夹和文件;关闭后它们在下次扫描时视为不存在'}, ]}, scraping:{label:'刮削高级设置',fields:[ {key:'timeoutMs',label:'请求超时(毫秒)',type:'number',default:12000,min:1000,max:120000}, @@ -158,7 +162,7 @@ function legacySettings(group:K,config:AppConfig):Busin 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}, + scanning:{interval:intervals.length?Math.min(...intervals):0,libraries:2,files:4,mediaAutoScan:true,mediaScrape:false,mediaEmbedded:true,mediaHidden:false}, 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)}, }; diff --git a/server/test/media-automation.test.ts b/server/test/media-automation.test.ts new file mode 100644 index 0000000..45e0fd9 --- /dev/null +++ b/server/test/media-automation.test.ts @@ -0,0 +1,88 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Db } from '../src/db/index.ts'; +import { MediaLibraries } from '../src/media/libraries.ts'; +import { MediaScanner, type ScanFinished } from '../src/media/scanner.ts'; +import { MediaScraping } from '../src/media/scraping.ts'; +import { MediaScrapeJobs } from '../src/media/scrape-jobs.ts'; +import { MediaAutomation } from '../src/media/automation.ts'; +import { MediaSubtitles } from '../src/media/subtitles.ts'; +import { BusinessSettingsReader, settingsDefaults } from '../src/services/business-settings.ts'; + +const admin = { id: 'admin', role: 'admin' } as const; +const probe = async () => ({ status: 'unavailable' as const, info: null }); + +test('scan jobs report a total, and hidden names are skipped unless the setting allows them', async t => { + const root = await mkdtemp(join(tmpdir(), 'media-hidden-')), db = new Db(':memory:'), libraries = new MediaLibraries(db); + let hidden = false; + const finished: ScanFinished[] = []; + const scanner = new MediaScanner(db, libraries, probe, undefined, () => ({ libraries: 1, files: 1, mediaHidden: hidden })); + scanner.onFinished = event => finished.push(event); + t.after(async () => { await scanner.close(); db.close(); await rm(root, { recursive: true, force: true }); }); + await mkdir(join(root, '.thumbs')); await writeFile(join(root, '.thumbs', 'clip.mp4'), 'x'); + await writeFile(join(root, '._film.mp4'), 'x'); await writeFile(join(root, 'film.mp4'), 'x'); + const library = await libraries.create(admin, { name: 'Films', kind: 'video', root, access: 'all' }); + const scan = async () => { const job = scanner.start(admin, library.id); await scanner.wait(library.id); return scanner.job(admin, job.id); }; + const first = await scan(); + assert.equal(first.state, 'complete'); + assert.deepEqual([first.inspected, first.total], [1, 1]); + assert.deepEqual(scanner.assets(admin, library.id).items.map(asset => asset.ref), ['film.mp4']); + hidden = true; + const second = await scan(); + assert.deepEqual([second.inspected, second.total], [3, 3]); + assert.deepEqual(finished.map(event => [event.libraryId, event.state]), [[library.id, 'complete'], [library.id, 'complete']]); + // A bare scanner (no settings) keeps the old behaviour of scanning hidden names. + assert.equal(new MediaScanner(db, libraries).isScanning(library.id), false); +}); + +test('automation scans every idle library, and after a scan scrapes only never-attempted items once', async t => { + const root = await mkdtemp(join(tmpdir(), 'media-auto-')), db = new Db(':memory:'), libraries = new MediaLibraries(db); + db.run("INSERT INTO users(id,username,password_hash,role,created_at,updated_at) VALUES('admin','admin','x','admin',0,0)"); + const scanner = new MediaScanner(db, libraries, probe); + const scraping = new MediaScraping(db, scanner.catalog, [{ id: 'tmdb', label: 'test', configured: true, kinds: ['movie'], async search() { return []; }, async detail() { throw Error('no candidates'); } }]); + const jobs = new MediaScrapeJobs(db, scanner.catalog, scraping); + const settings = { mediaAutoScan: true, mediaScrape: true }; + const automation = new MediaAutomation(db, db, libraries, scanner, scraping, jobs, () => settings, undefined, 0); + t.after(async () => { automation.stop(); await jobs.close(); await scanner.close(); db.close(); await rm(root, { recursive: true, force: true }); }); + await writeFile(join(root, 'Film (2020).mp4'), 'x'); + const library = await libraries.create(admin, { name: 'Films', kind: 'video', root, access: 'all' }); + assert.deepEqual(automation.scanAll(), [library.id]); + assert.deepEqual(automation.scanAll(), [], 'a library already scanning is skipped'); + await scanner.wait(library.id); + const scraped = db.all<{ job_id: string }>('SELECT job_id FROM media_scrape_job_items'); + assert.equal(scraped.length, 1, 'the completed scan started one scrape for the new film'); + await jobs.wait(scraped[0]!.job_id); + // Unmatched once is not retried by the next scan. + assert.deepEqual(automation.unscraped(library.id, ['movie']), []); + assert.equal(automation.afterScan({ actor: admin, libraryId: library.id, jobId: 'x', state: 'complete' }), null); + settings.mediaAutoScan = false; + assert.deepEqual(automation.scanAll(), []); +}); + +test('embedded subtitle streams can be turned off, and the new scanning settings have defaults', async () => { + const db = new Db(':memory:'), libraries = new MediaLibraries(db); + db.run("INSERT INTO media_libraries(id,name,kind,root,access,created_at,updated_at) VALUES('lib','Films','video',?,'all',0,0)", tmpdir()); + new MediaScanner(db, libraries, probe); + const technical = JSON.stringify({ streams: [{ index: 2, type: 'subtitle', codec: 'subrip', language: 'en' }] }); + db.run("INSERT INTO media_assets(id,library_id,ref,size,modified_at,available,probe_status,technical_json) VALUES('a','lib','missing-dir/film.mkv',1,0,1,'ready',?)", technical); + let embedded = true; + const subtitles = new MediaSubtitles(db, libraries, undefined, () => embedded); + const ids = async () => (await subtitles.list(admin, 'a').catch(() => ({ items: [] as Array<{ id: string }> }))).items.map(item => item.id); + // Sidecar lookup may fail for the fake path; the embedded list is what matters. + const withEmbedded = (subtitles as unknown as { embedded(asset: { ref: string; technical_json: string }): unknown[] }).embedded({ ref: 'film.mkv', technical_json: technical }); + assert.equal(withEmbedded.length, 1); + embedded = false; + assert.equal((subtitles as unknown as { embedded(asset: { ref: string; technical_json: string }): unknown[] }).embedded({ ref: 'film.mkv', technical_json: technical }).length, 0); + assert.ok(!(await ids()).includes('stream:2')); + assert.deepEqual( + (({ mediaAutoScan, mediaScrape, mediaEmbedded, mediaHidden }) => ({ mediaAutoScan, mediaScrape, mediaEmbedded, mediaHidden }))(settingsDefaults('scanning')), + { mediaAutoScan: true, mediaScrape: false, mediaEmbedded: true, mediaHidden: false }); + // Rows saved before these fields existed read them as defaults. + db.run(`CREATE TABLE IF NOT EXISTS business_settings(name TEXT PRIMARY KEY,value TEXT NOT NULL,revision INTEGER NOT NULL)`); + db.run("INSERT OR REPLACE INTO business_settings VALUES('scanning','{\"interval\":60,\"libraries\":2,\"files\":4}',1)"); + assert.equal(new BusinessSettingsReader(db).read('scanning').mediaEmbedded, true); + db.close(); +}); diff --git a/web/src/features/media/api/media-api.ts b/web/src/features/media/api/media-api.ts index 70416e2..d5e34c1 100644 --- a/web/src/features/media/api/media-api.ts +++ b/web/src/features/media/api/media-api.ts @@ -13,7 +13,7 @@ export interface ScanDiagnostics { elapsedMs:number;active:Array<{phase:string;ref?:string;elapsedMs:number}>; timings:Record;logs:Array<{at:number;message:string}>; } -export interface ScanJob {id:string;libraryId?:string;state:string;inspected:number;error:string|null;diagnostics?:ScanDiagnostics} +export interface ScanJob {id:string;libraryId?:string;state:string;inspected:number;/** Files in the listing; null until it is known (older servers: absent). */total?:number|null;error:string|null;startedAt?:number;finishedAt?:number|null;diagnostics?:ScanDiagnostics} export interface AiScanJob {id:string;libraryId:string;path:string;state:'queued'|'running'|'complete'|'failed';totalPaths:number;processedPaths:number;batchCount:number;resultCount:number;error:string|null;startedAt:number;finishedAt:number|null} export interface AiScanBatch {id:string;jobId:string;batch:number;pathCount:number;paths:string[];raw:string|null;items:unknown[];state:'running'|'complete'|'failed';error:string|null;startedAt:number;finishedAt:number|null} export interface Playback {id:string;itemId?:string;partId:string;streamUrl:string;contentType:string;expiresAt:number;position:number;start:number;end:number|null;revision:number;playbackMode?:'auto'|'direct'|'proxy'} diff --git a/web/src/features/media/components/library-list.tsx b/web/src/features/media/components/library-list.tsx index 4130ddf..31d8473 100644 --- a/web/src/features/media/components/library-list.tsx +++ b/web/src/features/media/components/library-list.tsx @@ -1,12 +1,46 @@ import { useEffect, useState } from 'react'; import { Ellipsis,Folder } from 'lucide-react'; -import type { Library,MediaApi } from '../api/media-api.ts'; +import type { Library,MediaApi,ScanJob } from '../api/media-api.ts'; import { FloatingNotice } from '../../../ui/floating-notice.tsx'; +import { scanFeedback } from './scan-feedback.ts'; interface Summary {id:string;root:string;counts:Record;chapters:number;missingFiles:number} export interface LibraryListPosition {query:string;page:number} const labels={video:'影视',music:'音乐',audiobook:'有声书'}; const count=(library:Library,summary:Summary)=>library.kind==='video'?`${summary.counts.movie||0} 部电影 · ${summary.counts.series||0} 部剧集`:library.kind==='music'?`${summary.counts.album||0} 张专辑 · ${summary.counts.track||0} 首曲目`:`${summary.counts.audiobook||0} 部作品 · ${summary.chapters} 章(全部版本)`; -export function MediaLibraryList({api,libraries,busy,position,onPosition,onEdit,onScan,onPermissions}:{api:MediaApi;libraries:Library[];busy:boolean;position:LibraryListPosition;onPosition:(position:LibraryListPosition)=>void;onEdit:(library:Library)=>void;onScan:(id:string)=>void;onPermissions:(library:Library)=>void;onJobs?: (id:string)=>void}){ +/** "今天 09:12", "昨天 21:40", or "3月8日 09:12": when the last scan finished. */ +export function scanTime(at:number,now=Date.now()):string{ + const date=new Date(at),day=(value:Date)=>new Date(value.getFullYear(),value.getMonth(),value.getDate()).getTime(); + const clock=`${String(date.getHours()).padStart(2,'0')}:${String(date.getMinutes()).padStart(2,'0')}`; + const days=Math.round((day(new Date(now))-day(date))/86400000); + return days===0?'今天 '+clock:days===1?'昨天 '+clock:`${date.getMonth()+1}月${date.getDate()}日 ${clock}`; +} +/** + * The latest scan of one library, as prototype 20 draws it: a running scan with its + * share of files processed and 取消; a failed one with what went wrong, 重试 and 查看详情; + * otherwise when it last finished, beside 重新扫描. + */ +function ScanState({job,busy,onScan,onCancel,onDetails}:{job:ScanJob|undefined;busy:boolean;onScan:()=>void;onCancel?:(job:ScanJob)=>void;onDetails?:()=>void}){ + if(job&&(job.state==='running'||job.state==='queued')){ + const percent=job.total?Math.min(100,Math.round(job.inspected/job.total*100)):null; + return
+ {job.state==='queued'?'排队等待扫描':percent!==null?`正在扫描 · 已处理 ${percent}%`:`正在扫描 · 已检查 ${job.inspected} 个文件`} + + {onCancel&&} +
; + } + if(job?.state==='failed')return
+ {scanFeedback(job.error)} + {onDetails&&} +
; + return
+ {job?.finishedAt?{job.state==='cancelled'?'上次扫描已取消 · ':'上次扫描 '}{scanTime(job.finishedAt)}:job?null:尚未扫描} + +
; +} +export function MediaLibraryList({api,libraries,busy,position,onPosition,onEdit,onScan,onPermissions,jobs,onCancel,onDetails}:{api:MediaApi;libraries:Library[];busy:boolean;position:LibraryListPosition;onPosition:(position:LibraryListPosition)=>void;onEdit:(library:Library)=>void;onScan:(id:string)=>void;onPermissions:(library:Library)=>void;onJobs?: (id:string)=>void; + /** Latest scan per library (GET scan-jobs); without it the row only offers 重新扫描. */ + jobs?:ScanJob[];onCancel?:(job:ScanJob)=>void;onDetails?:(library:Library)=>void}){ + const latest=new Map((jobs??[]).filter(job=>job.libraryId).map(job=>[job.libraryId!,job])); const [summaries,setSummaries]=useState([]),[status,setStatus]=useState<'loading'|'ready'|'error'>('loading'),[retry,setRetry]=useState(0); const ids=libraries.map(row=>row.id).join(','); useEffect(()=>{const abort=new AbortController();setStatus('loading');setSummaries([]);void api.request<{items:Summary[]}>('library-summaries','GET',undefined,abort.signal).then(result=>{if(!abort.signal.aborted){setSummaries(result.items);setStatus('ready');}}).catch(()=>{if(!abort.signal.aborted)setStatus('error');});return ()=>abort.abort();},[api,ids,retry]); @@ -17,7 +51,7 @@ export function MediaLibraryList({api,libraries,busy,position,onPosition,onEdit, {(libraries.length>10||position.query)&&
{filtered.length} 个
} {status==='loading'&&} {status==='error'&&

媒体库摘要读取失败,仍可管理媒体库。

} -
{filtered.slice(page*30,page*30+30).map(library=>{const summary=summariesById.get(library.id);return
;})}
+
{filtered.slice(page*30,page*30+30).map(library=>{const summary=summariesById.get(library.id);return
;})}
{!filtered.length&&!!libraries.length&&

没有符合查找条件的媒体库。

} {last>0&&} ; diff --git a/web/src/features/media/components/scan-settings.tsx b/web/src/features/media/components/scan-settings.tsx new file mode 100644 index 0000000..e372b64 --- /dev/null +++ b/web/src/features/media/components/scan-settings.tsx @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; +import type { MediaApi } from '../api/media-api.ts'; + +interface SettingsView { + group: string; revision: number; values: Record; + fields: Array<{ key: string; label: string; type: string; help?: string }>; +} +/** The 扫描设置 business-settings fields this card owns, in the prototype's order. */ +export const scanSettingKeys = ['mediaAutoScan', 'mediaScrape', 'mediaEmbedded', 'mediaHidden'] as const; + +/** + * 扫描与元数据 (prototype 20): four switches for every media library. They are the + * server's 扫描设置 business settings, so the same values appear in 系统设置 too; + * a switch saves on its own, with the settings revision guarding concurrent edits. + */ +export function MediaScanSettings({ api }: { api: Pick }) { + const [view, setView] = useState(null); + const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading'); + const [error, setError] = useState(''), [busy, setBusy] = useState(false), [retry, setRetry] = useState(0); + useEffect(() => { + const abort = new AbortController(); + setStatus('loading'); + void api.businessSettingsRequest<{ groups: SettingsView[] }>('', 'GET', undefined, abort.signal).then(result => { + if (abort.signal.aborted) return; + setView(result.groups.find(group => group.group === 'scanning') ?? null); setStatus('ready'); + }).catch(() => { if (!abort.signal.aborted) setStatus('error'); }); + return () => abort.abort(); + }, [api, retry]); + const fields = scanSettingKeys.map(key => view?.fields.find(field => field.key === key)).filter(field => field !== undefined); + // An older server without these settings: nothing to show. + if (status === 'ready' && !fields.length) return null; + async function toggle(key: string) { + if (!view || busy) return; + setBusy(true); setError(''); + try { + setView(await api.businessSettingsRequest('scanning', 'PATCH', { values: { [key]: !view.values[key] }, revision: view.revision })); + } catch (cause) { + setError(cause instanceof Error ? cause.message : '设置保存失败'); + } finally { setBusy(false); } + } + return