feat(media): library management states and 扫描与元数据 (prototype 20)

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.
This commit is contained in:
Kiro Agent committed 2026-10-20 04:41:03 +00:00
1 parent b71a2ddd4e
commit 68e085482c
13 files changed
+429 -25

No files matched your search

+7 -3
View File
@@ -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)]});});
+93
View File
@@ -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<MediaLibraryKind, MediaItemKind[]> = { 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<typeof setInterval> | undefined;
constructor(
private readonly accounts: Pick<MediaDatabase, 'get'>,
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;
}
}
+2 -2
View File
@@ -36,7 +36,7 @@ export function parseLyrics(bytes:Uint8Array):Omit<Lyrics,'source'> {
/** 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';}
+24 -7
View File
@@ -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<string,ScanTrace>();
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})) {
/** 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<Db,'prepare'>,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<void>;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<this.settings().libraries&&this.pending.length)this.pending.shift()!();}
private finished(actor:MediaActor,libraryId:string,jobId:string){
const state=this.db.get<{state:MediaScanJob['state']}>('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<void> {
private async scan(actor:MediaActor,libraryId:string,jobId:string,signal:AbortSignal,files=4,hidden=true):Promise<void> {
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<string,StorageEntry[]>();
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);}
+3 -1
View File
@@ -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<Asset>('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&&
+6 -2
View File
@@ -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<SettingsGroup,{label:string;fields:Field[]}>
{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<K extends SettingsGroup>(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)},
};
+88
View File
@@ -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();
});
+1 -1
View File
@@ -13,7 +13,7 @@ export interface ScanDiagnostics {
elapsedMs:number;active:Array<{phase:string;ref?:string;elapsedMs:number}>;
timings:Record<string,number>;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'}
@@ -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<string,number>;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 <div className="media-library-scan" data-state="running">
<span role="status">{job.state==='queued'?'排队等待扫描':percent!==null?`正在扫描 · 已处理 ${percent}%`:`正在扫描 · 已检查 ${job.inspected} 个文件`}</span>
<progress className="media-library-progress" max={100} {...(percent!==null?{value:percent}:{})} aria-label="扫描进度"/>
{onCancel&&<button disabled={busy} onClick={()=>onCancel(job)}>取消</button>}
</div>;
}
if(job?.state==='failed')return <div className="media-library-scan" data-state="failed">
<span className="media-library-scan-error" role="alert">{scanFeedback(job.error)}</span>
<span className="media-library-scan-actions">{onDetails&&<button className="media-library-details" onClick={onDetails}>查看详情</button>}<button disabled={busy} onClick={onScan}>重试</button></span>
</div>;
return <div className="media-library-scan" data-state="idle">
{job?.finishedAt?<span className="media-library-scan-time">{job.state==='cancelled'?'上次扫描已取消 · ':'上次扫描 '}{scanTime(job.finishedAt)}</span>:job?null:<span className="media-library-scan-time">尚未扫描</span>}
<button disabled={busy} onClick={onScan}>重新扫描</button>
</div>;
}
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<Summary[]>([]),[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)&&<div className="media-library-find"><label>查找媒体库<input type="search" value={position.query} placeholder="名称、类型或目录" onInput={event=>onPosition({query:event.currentTarget.value,page:0})}/></label><small>{filtered.length} 个</small></div>}
{status==='loading'&&<FloatingNotice message="正在读取目录与索引数量…" busy />}
{status==='error'&&<div className="media-error" role="alert"><p>媒体库摘要读取失败,仍可管理媒体库。</p><button onClick={()=>setRetry(value=>value+1)}>重试读取摘要</button></div>}
<div className="media-library-list">{filtered.slice(page*30,page*30+30).map(library=>{const summary=summariesById.get(library.id);return <div className="media-library-row" key={library.id}><Folder size={22} strokeWidth={1.5} aria-hidden="true"/><span><strong>{library.name} <em className="media-library-kind" title={library.access==='all'?'所有用户可见':'指定用户可见'}>{labels[library.kind]}</em></strong>{summary?<><small>{count(library,summary)}</small><small className="media-library-directory" title={summary.root}>{library.storage==='openlist'?'OpenList · ':''}{summary.root}</small></>:status==='ready'?<small>目录与数量暂不可用</small>:null}</span><details className="media-library-actions"><summary aria-label={'媒体库操作 '+library.name}><Ellipsis size={20} aria-hidden="true"/></summary><nav aria-label={library.name+'的操作'}><button disabled={busy} onClick={()=>onEdit(library)}>编辑媒体库</button><button onClick={()=>onPermissions(library)}>权限</button></nav></details><footer className="media-library-footer"><span>{summary?summary.missingFiles>0?summary.missingFiles+' 个文件缺失':'无缺失文件记录':'摘要暂不可用'}</span><button disabled={busy} onClick={()=>onScan(library.id)}>重新扫描</button></footer></div>;})}</div>
<div className="media-library-list">{filtered.slice(page*30,page*30+30).map(library=>{const summary=summariesById.get(library.id);return <div className="media-library-row" key={library.id}><Folder size={22} strokeWidth={1.5} aria-hidden="true"/><span><strong>{library.name} <em className="media-library-kind" title={library.access==='all'?'所有用户可见':'指定用户可见'}>{labels[library.kind]}</em></strong>{summary?<><small>{count(library,summary)}</small><small className="media-library-directory" title={summary.root}>{library.storage==='openlist'?'OpenList · ':''}{summary.root}</small></>:status==='ready'?<small>目录与数量暂不可用</small>:null}</span><details className="media-library-actions"><summary aria-label={'媒体库操作 '+library.name}><Ellipsis size={20} aria-hidden="true"/></summary><nav aria-label={library.name+'的操作'}><button disabled={busy} onClick={()=>onEdit(library)}>编辑媒体库</button><button onClick={()=>onPermissions(library)}>权限</button></nav></details><footer className="media-library-footer"><span>{summary?summary.missingFiles>0?summary.missingFiles+' 个文件缺失':'无缺失文件记录':'摘要暂不可用'}</span>{jobs?<ScanState job={latest.get(library.id)} busy={busy} onScan={()=>onScan(library.id)} {...(onCancel?{onCancel}:{})} {...(onDetails?{onDetails:()=>onDetails(library)}:{})}/>:<button disabled={busy} onClick={()=>onScan(library.id)}>重新扫描</button>}</footer></div>;})}</div>
{!filtered.length&&!!libraries.length&&<p className="media-manager-note">没有符合查找条件的媒体库。<button onClick={()=>onPosition({query:'',page:0})}>清除查找</button></p>}
{last>0&&<nav className="media-toolbar" aria-label="媒体库分页"><button disabled={!page} onClick={()=>onPosition({...position,page:page-1})}>上一页</button><span>{page+1} / {last+1}</span><button disabled={page===last} onClick={()=>onPosition({...position,page:page+1})}>下一页</button></nav>}
</section>;
@@ -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<string, string | number | boolean>;
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<MediaApi, 'businessSettingsRequest'> }) {
const [view, setView] = useState<SettingsView | null>(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<SettingsView>('scanning', 'PATCH', { values: { [key]: !view.values[key] }, revision: view.revision }));
} catch (cause) {
setError(cause instanceof Error ? cause.message : '设置保存失败');
} finally { setBusy(false); }
}
return <aside className="media-scan-settings" aria-label="扫描与元数据">
<h2>扫描与元数据</h2>
{status === 'loading' && <p className="media-scan-settings-note" role="status">正在读取设置…</p>}
{status === 'error' && <div className="media-error" role="alert"><p>扫描设置读取失败。</p><button onClick={() => setRetry(value => value + 1)}>重试</button></div>}
{view && <div className="media-scan-settings-list">{fields.map(field => {
const on = view.values[field.key] === true;
return <button type="button" role="switch" key={field.key} aria-checked={on} disabled={busy} onClick={() => void toggle(field.key)}>
<span><strong>{field.label}</strong>{field.help && <small>{field.help}</small>}</span>
<i className="media-scan-switch" aria-hidden="true" />
</button>;
})}</div>}
{error && <p className="media-error" role="alert">{error}</p>}
<p className="media-scan-settings-note">元数据优先使用本地 NFO 与内嵌标签,缺失时再联网刮削。</p>
</aside>;
}
@@ -5,11 +5,12 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useRuntime } from '../../../app/providers/runtime-context.tsx';
import { useAuthStore } from '../../../shared/stores/auth.store.ts';
import { librariesQuery } from '../queries/media.queries.ts';
import { jobsQuery, librariesQuery } from '../queries/media.queries.ts';
import { MediaPageFrame, QueryFeedback } from '../components/MediaPageFrame.tsx';
import { MediaLibraryList } from '../components/library-list.tsx';
import { MediaLibraryCreate } from '../components/library-create.tsx';
import type { Library, MediaChannel } from '../api/media-api.ts';
import { MediaScanSettings } from '../components/scan-settings.tsx';
import type { Library, MediaChannel, ScanJob } from '../api/media-api.ts';
const channels: MediaChannel[] = ['video', 'music', 'audiobook'];
function safeChannel(value: string | undefined): MediaChannel {
@@ -27,22 +28,27 @@ export function MediaLibrariesPage() {
const [busyId, setBusyId] = useState<string | null>(null);
const [actionError, setActionError] = useState('');
const query = useQuery(librariesQuery(runtime));
// The latest scan of every library, polled so a running scan's progress moves on its card.
const jobs = useQuery({ ...jobsQuery(runtime), enabled: role === 'admin' });
if (role !== 'admin') return <MediaPageFrame title="媒体库管理"><Alert color="red">需要管理员权限。</Alert></MediaPageFrame>;
// The legacy manager is a shared workspace: opening it from any channel
// shows every media library, while the channel only controls the return path
// and the default type when creating a new library.
const libraries = query.data?.items ?? [];
const scan = async (id: string) => {
const tasks = (id: string) => navigate(`/media/${channel}/settings/tasks?library=${encodeURIComponent(id)}`, { state: { returnTo: `/media/${channel}/settings/libraries` } });
// Progress is shown on the library's own card, so starting or cancelling stays on this page.
const act = async (id: string, action: () => Promise<unknown>, fallback: string) => {
if (busyId) return;
setBusyId(id); setActionError('');
try {
await runtime.mediaApi.request(`libraries/${encodeURIComponent(id)}/scan`, 'POST');
await action();
await queryClient.invalidateQueries({ queryKey: ['media'] });
navigate(`/media/${channel}/settings/tasks?library=${encodeURIComponent(id)}`, { state: { returnTo: `/media/${channel}/settings/libraries` } });
} catch (error) {
setActionError(error instanceof Error ? error.message : '扫描请求失败,请稍后重试。');
setActionError(error instanceof Error ? error.message : fallback);
} finally { setBusyId(null); }
};
const scan = (id: string) => act(id, () => runtime.mediaApi.request(`libraries/${encodeURIComponent(id)}/scan`, 'POST'), '扫描请求失败,请稍后重试。');
const cancel = (job: ScanJob) => act(job.libraryId ?? job.id, () => runtime.mediaApi.request(`jobs/${encodeURIComponent(job.id)}/cancel`, 'POST'), '取消扫描失败,请稍后重试。');
return <MediaPageFrame className="media-manager-workspace" title="媒体库管理" backTo={`/media/${channel}/settings`} backLabel="返回影音设置" actions={<details className="media-actions media-library-management-actions">
<summary aria-label="媒体库管理操作" title="媒体库管理操作"><MoreHorizontal size={20} aria-hidden="true" /></summary>
<nav aria-label="媒体库管理操作">
@@ -52,6 +58,7 @@ export function MediaLibrariesPage() {
</details>}>
{actionError && <Alert color="red" title="操作失败">{actionError}</Alert>}
<QueryFeedback pending={query.isPending} error={query.error} retry={() => { void query.refetch(); }} />
<div className="media-libraries-layout"><div>
{!query.isPending && !query.error && <MediaLibraryList
api={runtime.mediaApi}
libraries={libraries}
@@ -61,8 +68,12 @@ export function MediaLibrariesPage() {
onEdit={(library: Library) => navigate(`/media/${channel}/settings/libraries/${encodeURIComponent(library.id)}/edit`)}
onPermissions={(library: Library) => navigate(`/media/${channel}/settings/libraries/${encodeURIComponent(library.id)}/permissions`)}
onScan={id => { void scan(id); }}
jobs={jobs.data?.items ?? []}
onCancel={job => { void cancel(job); }}
onDetails={library => tasks(library.id)}
/>}
{!query.isPending && !query.error && libraries.length === 0 && <Alert>还没有媒体库。请先创建一个媒体库并扫描目录。</Alert>}
</div><MediaScanSettings api={runtime.mediaApi} /></div>
</MediaPageFrame>;
}
@@ -1068,3 +1068,32 @@ body:has(>#app>.media-screen) { background: var(--color-bg); }
/* 适应屏幕: fill the screen, cropping the edges, instead of letterboxing. */
.media-player[data-fit='cover'] .media-video-stage video,.media-player[data-fit='cover'] .media-video-stage .dplayer-host video {object-fit:cover}
@media (prefers-reduced-motion: reduce) {.media-video-landscape {transition:none}}
/* ---- 媒体库管理 (prototype 20): scan state on each card, 扫描与元数据 beside the list ---- */
.media-libraries-layout {display:grid;gap:20px;align-items:start}
@media (min-width:1100px) {.media-libraries-layout {grid-template-columns:minmax(0,1fr) 320px}}
.media-library-footer {flex-wrap:wrap;row-gap:8px}
.media-library-scan {display:flex;align-items:center;flex-wrap:wrap;gap:8px 12px;margin-left:auto;font-size:12px;color:var(--muted)}
.media-library-scan[data-state='running'] {flex:1 1 100%;color:var(--text)}
.media-library-scan[data-state='running']>span {flex:none}
.media-library-progress {flex:1;min-width:120px;height:6px;appearance:none;border:0;border-radius:3px;background:var(--surface);overflow:hidden}
.media-library-progress::-webkit-progress-bar {background:var(--surface);border-radius:3px}
.media-library-progress::-webkit-progress-value {background:var(--media-fill);border-radius:3px}
.media-library-progress::-moz-progress-bar {background:var(--media-fill);border-radius:3px}
.media-library-progress:indeterminate {background:linear-gradient(90deg,var(--surface),var(--media-fill),var(--surface));background-size:200% 100%;animation:media-scan-progress 2s linear infinite}
.media-library-scan[data-state='failed'] {flex:1 1 100%}
.media-library-scan-error {flex:1 1 14rem;color:var(--media-error-ink)}
.media-library-scan-actions {display:flex;gap:8px;margin-left:auto}
.media-screen .media-library-details {border:0;background:transparent;color:var(--media-accent);padding-inline:4px}
.media-scan-settings {padding:16px;border:1px solid var(--border);border-radius:18px;background:var(--media-paper)}
.media-screen .media-scan-settings h2 {margin:0 0 8px;font-size:15px}
.media-scan-settings-list {display:grid}
.media-screen .media-scan-settings-list button {display:flex;align-items:center;justify-content:space-between;gap:12px;width:100%;min-height:52px;padding:8px 0;border:0;border-top:1px solid var(--border);border-radius:0;background:transparent;text-align:left;color:var(--text)}
.media-screen .media-scan-settings-list button:first-child {border-top:0}
.media-scan-settings-list strong {display:block;font-size:14px;font-weight:500}
.media-scan-settings-list small {display:block;margin-top:2px;font-size:11px;line-height:1.5;color:var(--muted)}
.media-scan-switch {position:relative;flex:none;width:44px;height:26px;border-radius:13px;background:var(--surface);box-shadow:inset 0 0 0 1px var(--border)}
.media-scan-switch::after {content:'';position:absolute;top:3px;left:3px;width:20px;height:20px;border-radius:50%;background:var(--media-paper);box-shadow:0 1px 2px color-mix(in srgb,var(--color-ink) 25%,transparent);transition:left var(--motion-duration) var(--motion-ease)}
[aria-checked=true]>.media-scan-switch {background:var(--media-fill);box-shadow:none}
[aria-checked=true]>.media-scan-switch::after {left:21px}
.media-scan-settings-note {margin:10px 0 0;font-size:12px;line-height:1.6;color:var(--muted)}
@@ -0,0 +1,67 @@
import { render } from '../src/shared/ui/render-root.ts';
// @vitest-environment jsdom
import { afterEach, expect, it, vi } from 'vitest';
import { act } from 'react';
import { MediaLibraryList, scanTime } from '../src/features/media/components/library-list.tsx';
import { MediaScanSettings } from '../src/features/media/components/scan-settings.tsx';
import type { Library, MediaApi, ScanJob } from '../src/features/media/api/media-api.ts';
/** Prototype 20: each card says what its latest scan is doing, and 扫描与元数据 saves switches. */
const root = document.createElement('div'); document.body.append(root);
afterEach(() => act(() => render(null, root)));
const libraries: Library[] = [{ id: 'films', name: '电影', kind: 'video', access: 'all' }, { id: 'music', name: '音乐', kind: 'music', access: 'all' }, { id: 'books', name: '有声书', kind: 'audiobook', access: 'all' }];
const today = new Date(); today.setHours(9, 12, 0, 0);
const jobs: ScanJob[] = [
{ id: 'j1', libraryId: 'films', state: 'complete', inspected: 128, total: 128, error: null, finishedAt: today.getTime() },
{ id: 'j2', libraryId: 'music', state: 'running', inspected: 1432, total: 2310, error: null, finishedAt: null },
{ id: 'j3', libraryId: 'books', state: 'failed', inspected: 0, total: null, error: 'directory-unavailable', finishedAt: today.getTime() },
];
const button = (label: string) => [...root.querySelectorAll('button')].find(node => node.textContent === label);
it('shows the last scan, a running scan with its share and 取消, and a failure with 重试 and 查看详情', async () => {
const request = vi.fn().mockResolvedValue({ items: [] }), scan = vi.fn(), cancel = vi.fn(), details = vi.fn();
await act(async () => render(<MediaLibraryList api={{ request } as unknown as MediaApi} libraries={libraries} busy={false} position={{ query: '', page: 0 }} onPosition={() => {}} onEdit={() => {}} onScan={scan} onPermissions={() => {}} jobs={jobs} onCancel={cancel} onDetails={details} />, root));
const rows = [...root.querySelectorAll('.media-library-row')];
expect(rows[0]!.textContent).toContain('上次扫描 今天 09:12');
expect(rows[1]!.textContent).toContain('正在扫描 · 已处理 62%');
expect(rows[1]!.querySelector('progress')?.getAttribute('value')).toBe('62');
expect(rows[2]!.querySelector('[role=alert]')?.textContent).toContain('媒体目录不可访问');
await act(async () => button('取消')!.click()); expect(cancel).toHaveBeenCalledWith(jobs[1]);
await act(async () => button('重试')!.click()); expect(scan).toHaveBeenCalledWith('books');
await act(async () => button('查看详情')!.click()); expect(details).toHaveBeenCalledWith(libraries[2]);
await act(async () => button('重新扫描')!.click()); expect(scan).toHaveBeenCalledWith('films');
// Without a total (older server), the count of files checked is shown instead.
await act(async () => render(<MediaLibraryList api={{ request } as unknown as MediaApi} libraries={libraries.slice(1, 2)} busy={false} position={{ query: '', page: 0 }} onPosition={() => {}} onEdit={() => {}} onScan={scan} onPermissions={() => {}} jobs={[{ ...jobs[1]!, total: null }]} />, root));
expect(root.textContent).toContain('正在扫描 · 已检查 1432 个文件');
expect(button('取消')).toBeUndefined();
});
it('words the scan time relative to today', () => {
const now = new Date(2026, 9, 20, 15, 0).getTime();
expect(scanTime(new Date(2026, 9, 20, 9, 12).getTime(), now)).toBe('今天 09:12');
expect(scanTime(new Date(2026, 9, 19, 21, 40).getTime(), now)).toBe('昨天 21:40');
expect(scanTime(new Date(2026, 2, 8, 9, 5).getTime(), now)).toBe('3月8日 09:05');
});
it('saves one switch at a time with the settings revision, and hides itself on an older server', async () => {
const view = (values: Record<string, boolean>, revision: number) => ({ group: 'scanning', revision, values, fields: [
{ key: 'interval', label: '间隔', type: 'number' },
{ key: 'mediaAutoScan', label: '自动扫描新文件', type: 'checkbox' }, { key: 'mediaScrape', label: '刮削封面与简介', type: 'checkbox' },
{ key: 'mediaEmbedded', label: '读取内嵌字幕与歌词', type: 'checkbox' }, { key: 'mediaHidden', label: '扫描隐藏文件夹', type: 'checkbox' },
] });
const businessSettingsRequest = vi.fn(async (_path: string, method: string) => method === 'GET'
? { groups: [view({ mediaAutoScan: true, mediaScrape: false, mediaEmbedded: true, mediaHidden: false }, 3)] }
: view({ mediaAutoScan: true, mediaScrape: true, mediaEmbedded: true, mediaHidden: false }, 4));
await act(async () => render(<MediaScanSettings api={{ businessSettingsRequest } as never} />, root));
await vi.waitFor(() => expect(root.querySelectorAll('[role=switch]')).toHaveLength(4));
const switches = () => [...root.querySelectorAll('[role=switch]')].map(node => [node.querySelector('strong')?.textContent, node.getAttribute('aria-checked')]);
expect(switches()).toEqual([['自动扫描新文件', 'true'], ['刮削封面与简介', 'false'], ['读取内嵌字幕与歌词', 'true'], ['扫描隐藏文件夹', 'false']]);
await act(async () => root.querySelectorAll<HTMLButtonElement>('[role=switch]')[1]!.click());
expect(businessSettingsRequest).toHaveBeenLastCalledWith('scanning', 'PATCH', { values: { mediaScrape: true }, revision: 3 });
expect(switches()[1]).toEqual(['刮削封面与简介', 'true']);
act(() => render(null, root));
const old = vi.fn(async () => ({ groups: [{ group: 'scanning', revision: 1, values: {}, fields: [{ key: 'interval', label: '间隔', type: 'number' }] }] }));
await act(async () => render(<MediaScanSettings api={{ businessSettingsRequest: old } as never} />, root));
await vi.waitFor(() => expect(old).toHaveBeenCalled());
expect(root.querySelector('.media-scan-settings')).toBeNull();
});