Files
reader/server/test/media-catalog.test.ts
hectorqin 73f9eb6e20 feat(media): add isolated media libraries and playback
Support local and OpenList libraries, metadata, independent media navigation, themes, and audio/video playback. Preserve approved v2 prototypes and current documentation; archive superseded review artifacts and keep generated validation output outside version control.
2026-09-27 18:22:51 +08:00

306 lines
27 KiB
TypeScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, writeFile, rm, rename } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, basename } from 'node:path';
import { Db } from '../src/db/index.ts';
import { MediaLibraries } from '../src/media/libraries.ts';
import { MediaScanner } from '../src/media/scanner.ts';
import type { MediaProbe } from '../src/media/probe.ts';
import { LocalMediaStorage } from '../src/media/storage/local.ts';
import { readLocalMetadata } from '../src/media/local-metadata.ts';
import { MediaPlayback } from '../src/media/playback.ts';
test('artist identity never inherits album biography, year, IDs or artwork; rescanning repairs legacy rows without losing curated data',async t=>{
const root=await mkdtemp(join(tmpdir(),'media-artist-identity-'));
const db=new Db(':memory:'),libraries=new MediaLibraries(db),actor={id:'admin',role:'admin'} as const;
db.run("INSERT INTO users(id,username,password_hash,role,created_at,updated_at) VALUES('admin','admin','unused','admin',0,0)");
await writeFile(join(root,'track.mp3'),'fixture');await writeFile(join(root,'cover.jpg'),'cover');
const scanner=new MediaScanner(db,libraries,async()=>({status:'ready',info:{duration:20,format:'mp3',streams:[{index:1,type:'video',codec:'mjpeg',attachedPicture:true}],tags:{artist:'歌手',album:'专辑',description:'这是一张专辑',date:'2024'},chapters:[]}}));
t.after(async()=>{await scanner.close();db.close();await rm(root,{recursive:true,force:true});});
const library=await libraries.create(actor,{name:'音乐',kind:'music',root,access:'all'});
const scan=async()=>{const job=scanner.start(actor,library.id);await scanner.wait(library.id);assert.equal(scanner.job(actor,job.id).state,'complete');};
await scan();
const catalog=scanner.catalog,artist=catalog.list(actor,library.id,{kind:'artist'}).items[0]!,album=catalog.list(actor,library.id,{kind:'album'}).items[0]!;
assert.equal(artist.title,'歌手');assert.equal(artist.metadata.plot,undefined);assert.equal(artist.metadata.year,undefined);
assert.equal(artist.metadata.coverRef,undefined);assert.equal(artist.metadata.embeddedCoverAssetId,undefined);
assert.equal(album.metadata.coverRef,'cover.jpg');assert.equal(typeof album.metadata.embeddedCoverAssetId,'string');
// Simulate an existing library from before this fix, with independently curated artist fields.
db.run('UPDATE media_items SET metadata_json=? WHERE id=?',JSON.stringify({...album.metadata,externalIds:{musicbrainz:'album-id'}}),artist.id);
catalog.override(actor,artist.id,{plot:'人工填写的艺人简介'});
db.run('INSERT INTO media_online_metadata(item_id,provider,external_id,source_url,fields_json,confirmed_by,confirmed_at) VALUES(?,?,?,?,?,?,?)',artist.id,'musicbrainz','artist-id','https://musicbrainz.org/artist/artist-id',JSON.stringify({title:'艺人名',artistType:'Person',artistArea:'China'}),actor.id,0);
await scan();
const repaired=catalog.detail(actor,artist.id);
assert.equal(repaired.title,'艺人名');assert.equal(repaired.metadata.artistType,'Person');assert.equal(repaired.metadata.artistArea,'China');
assert.equal(repaired.overrides.plot,'人工填写的艺人简介');assert.equal(repaired.metadata.plot,undefined);
assert.equal(repaired.metadata.coverRef,undefined);assert.equal(repaired.metadata.embeddedCoverAssetId,undefined);
assert.deepEqual(repaired.metadata.externalIds,{musicbrainz:'artist-id'});
assert.equal(catalog.detail(actor,album.id).metadata.coverRef,'cover.jpg');
});
import { MediaUserState } from '../src/media/user-state.ts';
test('splitting and merging whole files preserve chapters, progress and rescanned grouping',async t=>{
const root=await mkdtemp(join(tmpdir(),'media-organize-')),folder=join(root,'Book');await mkdir(folder);
const db=new Db(':memory:'),libraries=new MediaLibraries(db),actor={id:'admin',role:'admin'} as const;
db.run("INSERT INTO users(id,username,password_hash,role,created_at,updated_at) VALUES('admin','admin','unused','admin',0,0)");
let extra=false;
const scanner=new MediaScanner(db,libraries,async file=>({status:'ready',info:{duration:100,format:'m4b',streams:[],tags:{album:'作品',narrator:'演播者',track:basename(file).startsWith('a')?'1':'2'},chapters:[{title:'第一章',start:0,end:30},{title:'第二章',start:30,end:60},...(extra?[{title:'新增章',start:60,end:100}]:[])]}}));
t.after(async()=>{await scanner.close();db.close();await rm(root,{recursive:true,force:true});});
for(const name of ['a.m4b','b.m4b'])await writeFile(join(folder,name),name);
const library=await libraries.create(actor,{name:'有声',kind:'audiobook',root,access:'all'});
const scan=async()=>{const job=scanner.start(actor,library.id);await scanner.wait(library.id);assert.equal(scanner.job(actor,job.id).state,'complete');};
await scan();const catalog=scanner.catalog,work=catalog.list(actor,library.id,{kind:'audiobook'}).items[0]!;
const original=catalog.detail(actor,work.id).editions[0]!,assetIds=[...new Set(original.parts.map(part=>part.assetId))];
assert.equal(original.parts.length,4);
const playback=new MediaPlayback(db,libraries),session=playback.create(actor,original.parts[0]!.id);
playback.update(actor,session.id,{sequence:0,revision:session.revision,position:15});
const progress=playback.progress(actor,original.parts[0]!.id);
const reversed=original.parts.map(part=>part.id).reverse();
assert.throws(()=>catalog.orderEdition({id:'member',role:'member'},original.id,{expectedRevision:original.revision,partIds:reversed}),{statusCode:403});
assert.throws(()=>catalog.orderEdition(actor,original.id,{expectedRevision:original.revision,partIds:reversed.slice(1)}),{code:'MEDIA_EDITION_ORDER'});
const ordered=catalog.orderEdition(actor,original.id,{expectedRevision:original.revision,partIds:reversed}).editions[0]!;
assert.deepEqual(ordered.parts.map(part=>part.id),reversed);
await scan();assert.deepEqual(catalog.detail(actor,work.id).editions[0]!.parts.map(part=>part.id),reversed);
assert.deepEqual(playback.progress(actor,original.parts[0]!.id),progress);
const resetOrder=catalog.orderEdition(actor,original.id,{expectedRevision:ordered.revision,partIds:null}).editions[0]!;
assert.deepEqual(resetOrder.parts,original.parts);
assert.equal(resetOrder.revision,original.revision);
const input={expectedRevision:original.revision,assetIds:[assetIds[0]!],label:'拆出的版本'};
assert.throws(()=>catalog.splitEdition({id:'member',role:'member'},original.id,input),{statusCode:403});
assert.throws(()=>catalog.splitEdition(actor,original.id,{...input,assetIds}),{code:'MEDIA_EDITION_SPLIT'});
assert.throws(()=>catalog.splitEdition(actor,original.id,{...input,assetIds:['not-owned']}),{code:'MEDIA_EDITION_SPLIT'});
assert.throws(()=>catalog.splitEdition(actor,original.id,{...input,expectedRevision:'stale'}),{code:'MEDIA_EDITION_CHANGED'});
const split=catalog.splitEdition(actor,original.id,input);
const created=split.editions.find(edition=>edition.id!==original.id)!,retained=split.editions.find(edition=>edition.id===original.id)!;
assert.equal(created.label,'拆出的版本');assert.equal(created.parts.length,2);assert.equal(retained.parts.length,2);
assert.deepEqual(created.parts,original.parts.filter(part=>part.assetId===assetIds[0]));
assert.deepEqual(playback.progress(actor,original.parts[0]!.id),progress);
assert.throws(()=>catalog.splitEdition(actor,original.id,input),{code:'MEDIA_EDITION_CHANGED'});
const targetOrder=created.parts.map(part=>part.id).reverse();
catalog.orderEdition(actor,created.id,{expectedRevision:created.revision,partIds:targetOrder});
extra=true;for(const name of ['a.m4b','b.m4b'])await writeFile(join(folder,name),name+'new chapters');await scan();
const rescanned=catalog.detail(actor,work.id),source=rescanned.editions.find(edition=>edition.id===original.id)!,target=rescanned.editions.find(edition=>edition.id===created.id)!;
assert.equal(source.parts.length,3);assert.equal(target.parts.length,3);
assert.deepEqual(target.parts.slice(0,2).map(part=>part.id),targetOrder);
assert.equal(target.parts[2]!.title,'新增章');
assert.equal(target.parts.every(part=>part.assetId===assetIds[0]),true);
assert.throws(()=>catalog.mergeEditions(actor,source.id,{expectedRevision:source.revision,targetEditionId:target.id,targetRevision:created.revision}),{code:'MEDIA_EDITION_CHANGED'});
const merge={expectedRevision:source.revision,targetEditionId:target.id,targetRevision:target.revision};
assert.throws(()=>catalog.mergeEditions({id:'member',role:'member'},source.id,merge),{statusCode:403});
assert.throws(()=>catalog.mergeEditions(actor,source.id,{...merge,targetEditionId:source.id,targetRevision:source.revision}),{code:'MEDIA_EDITION_MERGE'});
const merged=catalog.mergeEditions(actor,source.id,merge);
assert.equal(merged.editions.length,1);assert.equal(merged.editions[0]!.id,target.id);assert.equal(merged.editions[0]!.parts.length,6);
assert.deepEqual(merged.editions[0]!.parts.map(part=>part.id),[...target.parts,...source.parts].map(part=>part.id),'manual order is retained, source chapters are appended');
assert.deepEqual(new Set(merged.editions[0]!.parts.map(part=>part.id)),new Set([...source.parts,...target.parts].map(part=>part.id)));
assert.deepEqual(playback.progress(actor,original.parts[0]!.id),progress);
await scan();assert.deepEqual(catalog.detail(actor,work.id).editions,merged.editions);
});
test('manual edition assignment preserves playback identity, rejects stale edits and survives rescanning',async t=>{
const base=await mkdtemp(join(tmpdir(),'media-reassign-')),root=join(base,'video'),other=join(base,'other');
await mkdir(root);await mkdir(other);
const db=new Db(':memory:'),libraries=new MediaLibraries(db),actor={id:'admin',role:'admin'} as const;
db.run("INSERT INTO users(id,username,password_hash,role,created_at,updated_at) VALUES('admin','admin','unused','admin',0,0)");
const scanner=new MediaScanner(db,libraries,async()=>({status:'ready',info:{duration:100,format:'mp4',streams:[],chapters:[],tags:{}}}));
t.after(async()=>{await scanner.close();db.close();await rm(base,{recursive:true,force:true});});
await writeFile(join(root,'Source.mp4'),'source');await writeFile(join(root,'Target.mp4'),'target file');
await writeFile(join(root,'Drama.S01E01.mp4'),'episode');await writeFile(join(other,'Other.mp4'),'other');
const lib=await libraries.create(actor,{name:'video',kind:'video',root,access:'all'});
const otherLib=await libraries.create(actor,{name:'other',kind:'video',root:other,access:'all'});
for(const library of [lib,otherLib]){scanner.start(actor,library.id);await scanner.wait(library.id);}
const catalog=scanner.catalog,movies=catalog.list(actor,lib.id,{kind:'movie'}).items;
const source=movies.find(item=>item.title==='Source')!,target=movies.find(item=>item.title==='Target')!;
const edition=catalog.detail(actor,source.id).editions[0]!,parts=edition.parts;
const playback=new MediaPlayback(db,libraries),session=playback.create(actor,parts[0]!.id);
playback.update(actor,session.id,{sequence:0,revision:session.revision,position:23});
const progress=playback.progress(actor,parts[0]!.id);
const userState=new MediaUserState(db,libraries,catalog);
userState.favorite(actor,source.id,true);
const queued=userState.enqueue(actor,[parts[0]!.id]).items[0]!;
catalog.override(actor,source.id,{title:'保留的原作品'});
assert.throws(()=>catalog.reassignEdition({id:'member',role:'member'},edition.id,target.id,source.id),{statusCode:403});
assert.throws(()=>catalog.reassignEdition(actor,edition.id,catalog.list(actor,otherLib.id,{kind:'movie'}).items[0]!.id,source.id),{code:'MEDIA_EDITION_TARGET'});
assert.throws(()=>catalog.reassignEdition(actor,edition.id,catalog.list(actor,lib.id,{kind:'episode'}).items[0]!.id,source.id),{code:'MEDIA_EDITION_TARGET'});
const assigned=catalog.reassignEdition(actor,edition.id,target.id,source.id);
assert.equal(assigned.editions.length,2);
assert.deepEqual(assigned.editions.find(entry=>entry.id===edition.id)!.parts,parts);
assert.equal(catalog.detail(actor,source.id).editions.length,0);
assert.equal(catalog.detail(actor,source.id).title,'保留的原作品');
assert.deepEqual(playback.progress(actor,parts[0]!.id),progress);
assert.equal(userState.isFavorite(actor,source.id).favorite,true);
assert.equal(userState.isFavorite(actor,target.id).favorite,false);
assert.equal(userState.queue(actor).items[0]!.id,queued.id);
assert.equal(userState.queue(actor).items[0]!.itemId,target.id);
assert.equal(userState.history(actor).items[0]!.itemId,target.id);
assert.throws(()=>catalog.reassignEdition(actor,edition.id,source.id,source.id),{code:'MEDIA_EDITION_CHANGED'});
await writeFile(join(root,'Source.mp4'),'changed source file to force a fresh probe');
const scan=scanner.start(actor,lib.id);await scanner.wait(lib.id);assert.equal(scanner.job(actor,scan.id).state,'complete');
const rescanned=catalog.detail(actor,target.id);
assert.equal(rescanned.title,'Target','source file tags must not overwrite the manually assigned target');
assert.deepEqual(rescanned.editions.find(entry=>entry.id===edition.id)!.parts,parts);
assert.deepEqual(playback.progress(actor,parts[0]!.id),progress);
assert.equal(playback.create(actor,parts[0]!.id).itemId,target.id);
assert.equal(catalog.reassignEdition(actor,edition.id,source.id,target.id).editions[0]!.id,edition.id,'assignment is reversible');
const count=catalog.list(actor,lib.id,{kind:'movie'}).total;
assert.throws(()=>catalog.createEditionItem({id:'member',role:'member'},edition.id,'新作品',source.id),{statusCode:403});
assert.throws(()=>catalog.createEditionItem(actor,edition.id,' ',source.id),{statusCode:400});
assert.throws(()=>catalog.createEditionItem(actor,edition.id,'新作品',target.id),{code:'MEDIA_EDITION_CHANGED'});
assert.equal(catalog.list(actor,lib.id,{kind:'movie'}).total,count);
const beforeCreateProgress=playback.progress(actor,parts[0]!.id);
const created=catalog.createEditionItem(actor,edition.id,' 新作品 ',source.id);
assert.equal(created.title,'新作品');assert.deepEqual(created.overrides,{});
assert.equal(created.libraryId,source.libraryId);assert.equal(created.parentId,source.parentId);
assert.deepEqual(created.editions[0]!.parts,parts);
assert.deepEqual(playback.progress(actor,parts[0]!.id),beforeCreateProgress);
assert.equal(userState.queue(actor).items[0]!.itemId,created.id);
assert.throws(()=>catalog.createEditionItem(actor,edition.id,'重复请求',source.id),{code:'MEDIA_EDITION_CHANGED'});
assert.equal(catalog.list(actor,lib.id,{kind:'movie'}).total,count+1);
await writeFile(join(root,'Source.mp4'),'changed once more');scanner.start(actor,lib.id);await scanner.wait(lib.id);
assert.equal(catalog.detail(actor,created.id).title,'新作品');
assert.deepEqual(catalog.detail(actor,created.id).editions[0]!.parts,parts);
const episode=catalog.list(actor,lib.id,{kind:'episode'}).items[0]!;
const newEpisode=catalog.createEditionItem(actor,catalog.detail(actor,episode.id).editions[0]!.id,'修正单集',episode.id);
assert.equal(newEpisode.parentId,episode.parentId);assert.equal(newEpisode.kind,'episode');
assert.equal(newEpisode.metadata.season,episode.metadata.season);assert.equal(newEpisode.metadata.episode,episode.metadata.episode);
const plan=catalog.seasonPlayback(actor,episode.parentId!);
assert.ok(plan.episodes.some(entry=>entry.id===newEpisode.id));
assert.ok(!plan.episodes.some(entry=>entry.id===episode.id));
});
for(const kind of ['music','audiobook'] as const)test(`${kind} keeps artwork from sibling files and replaces missing artwork only after a complete scan`,async t=>{
const root=await mkdtemp(join(tmpdir(),'media-group-cover-')),folder=join(root,'work');await mkdir(folder);
const db=new Db(':memory:'),libraries=new MediaLibraries(db),actor={id:'admin',role:'admin'} as const;
let fail=false;
const probe:MediaProbe=async path=>{if(fail)throw new Error('scan failed');return {status:'ready',info:{duration:10,format:'test',tags:{album:'作品',artist:'歌手'},chapters:[],streams:basename(path).startsWith('z')?[]:[{index:1,type:'video',codec:'png',attachedPicture:true}]}};};
const scanner=new MediaScanner(db,libraries,probe);t.after(async()=>{await scanner.close();db.close();await rm(root,{recursive:true,force:true});});
for(const name of ['a.mp3','b.mp3','z.mp3'])await writeFile(join(folder,name),name);
const library=await libraries.create(actor,{name:kind,kind,root,access:'all'});
const scan=async()=>{const job=scanner.start(actor,library.id);await scanner.wait(library.id);return scanner.job(actor,job.id).state;};
assert.equal(await scan(),'complete');
const item=scanner.catalog.list(actor,library.id,{kind:kind==='music'?'album':'audiobook'}).items[0]!;
const cover=()=>scanner.catalog.detail(actor,item.id).metadata.embeddedCoverAssetId;
assert.equal(typeof cover(),'string');const original=cover();
const ref=db.get<{ref:string}>('SELECT ref FROM media_assets WHERE id=?',original as string)!.ref;
await rm(join(root,ref));
await writeFile(join(folder,'z.mp3'),'changed to trigger probe');fail=true;
assert.equal(await scan(),'failed');assert.equal(cover(),original);
fail=false;assert.equal(await scan(),'complete');assert.equal(typeof cover(),'string');assert.notEqual(cover(),original);
const replacement=cover();assert.equal(await scan(),'complete');assert.equal(cover(),replacement);
const otherRef=db.get<{ref:string}>('SELECT ref FROM media_assets WHERE id=?',replacement as string)!.ref;
await rm(join(root,otherRef));assert.equal(await scan(),'complete');assert.equal(cover(),undefined);
});
test('four media types create domain relationships, independent versions and timed chapters',async t=>{
const base=await mkdtemp(join(tmpdir(),'media-catalog-')),db=new Db(':memory:');
const libraries=new MediaLibraries(db),actor={id:'admin',role:'admin'} as const;
const probe:MediaProbe=async file=>({status:'ready',info:{duration:120,format:'test',streams:[],
tags:file.endsWith('.flac')?{title:basename(file),album:'同一张专辑',artist:'歌手甲',album_artist:'歌手甲',track:basename(file).slice(0,2)}:file.endsWith('.m4b')?{album:'有声作品',author:'作者',narrator:basename(file).startsWith('a')?'演播甲':'演播乙'}:{},
chapters:file.endsWith('.m4b')?[{start:0,end:60,title:'第一章'},{start:60,end:120,title:'第二章'}]:[]}});
const scanner=new MediaScanner(db,libraries,probe),catalog=scanner.catalog;
t.after(async()=>{await scanner.close();db.close();await rm(base,{recursive:true,force:true});});
const roots={video:join(base,'video'),music:join(base,'music'),audiobook:join(base,'audio')};
for(const root of Object.values(roots))await mkdir(root);
await mkdir(join(roots.video,'Drama'));await mkdir(join(roots.music,'Album'));await mkdir(join(roots.audiobook,'Book'));
for(const ref of ['Film.mp4','Other.mp4','Drama/Drama_S01E01_1080p.mp4','Drama/Drama.S01E02.mp4','Drama/Drama.S02E01.mp4'])await writeFile(join(roots.video,ref),'file');
await writeFile(join(roots.video,'Film.nfo'),'<movie><title>海岸线</title><year>2024</year><uniqueid type="tmdb">123</uniqueid></movie>');
await writeFile(join(roots.video,'Other.nfo'),'<movie><title>海岸线</title><year>2024</year></movie>');
for(const ref of ['01.flac','02.flac'])await writeFile(join(roots.music,'Album',ref),'file');
for(const ref of ['a.m4b','b.m4b'])await writeFile(join(roots.audiobook,'Book',ref),'file');
const ids:Record<string,string>={};
for(const kind of ['video','music','audiobook'] as const){
const lib=await libraries.create(actor,{name:kind,kind,root:roots[kind],access:'all'});ids[kind]=lib.id;
const job=scanner.start(actor,lib.id);await scanner.wait(lib.id);assert.equal(scanner.job(actor,job.id).state,'complete');
}
const movies=catalog.list(actor,ids.video!,{kind:'movie'}).items;
assert.equal(movies.length,2,'same name must not merge unrelated movies');
assert.equal(movies.filter(m=>m.title==='海岸线').length,2);
const show=catalog.list(actor,ids.video!,{kind:'series'}).items[0]!;
const season=catalog.detail(actor,show.id).children[0]!;
assert.equal(catalog.detail(actor,season.id).children.length,2);
const seasonPlan=catalog.seasonPlayback(actor,season.id);
assert.deepEqual(seasonPlan.episodes.map(e=>e.id),catalog.detail(actor,season.id).children.map(e=>e.id));
assert.ok(seasonPlan.episodes.every(e=>e.editions.length===1&&e.editions[0]!.parts.length===1));
assert.throws(()=>catalog.seasonPlayback(actor,show.id));
const wholeSeries=catalog.seriesPlayback(actor,show.id);
assert.equal(wholeSeries.episodes.length,3);
assert.deepEqual(wholeSeries.episodes.slice(0,2).map(e=>e.id),seasonPlan.episodes.map(e=>e.id));
assert.equal(wholeSeries.episodes[2]!.id,catalog.detail(actor,catalog.detail(actor,show.id).children[1]!.id).children[0]!.id);
assert.throws(()=>catalog.seriesPlayback(actor,season.id));
libraries.setAccess(actor,ids.video!,'restricted',[]);
assert.throws(()=>catalog.seasonPlayback({id:'outsider',role:'member'},season.id));
assert.throws(()=>catalog.seriesPlayback({id:'outsider',role:'member'},show.id));
libraries.setAccess(actor,ids.video!,'all',[]);
assert.equal(catalog.list(actor,ids.music!,{kind:'artist'}).total,1);
const album=catalog.list(actor,ids.music!,{kind:'album'}).items[0]!;
assert.equal(catalog.detail(actor,album.id).children.length,2);
const book=catalog.list(actor,ids.audiobook!,{kind:'audiobook'}).items[0]!;
const detail=catalog.detail(actor,book.id);
assert.equal(detail.editions.length,2);assert.equal(detail.editions[0]!.parts.length,2);
assert.equal(detail.editions[0]!.parts[1]!.start,60);
const firstMovie=movies.find(m=>(m.metadata.externalIds as Record<string,string>).tmdb==='123')!;
const originalEdition=catalog.detail(actor,firstMovie.id).editions[0]!;
assert.throws(()=>catalog.renameEdition({id:'member',role:'member'},originalEdition.id,'会员修改',originalEdition.label),{statusCode:403});
assert.throws(()=>catalog.renameEdition(actor,originalEdition.id,' ',originalEdition.label),{statusCode:400});
assert.equal(catalog.renameEdition(actor,originalEdition.id,' 导演剪辑版 ',originalEdition.label).label,'导演剪辑版');
assert.throws(()=>catalog.renameEdition(actor,originalEdition.id,'旧页覆盖',originalEdition.label),{code:'MEDIA_EDITION_CHANGED'});
catalog.override(actor,firstMovie.id,{title:'人工标题',year:2025});
await rename(join(roots.video,'Film.mp4'),join(roots.video,'Renamed.mp4'));
scanner.start(actor,ids.video!);await scanner.wait(ids.video!);
assert.equal(catalog.detail(actor,firstMovie.id).editions[0]!.label,'导演剪辑版');
assert.deepEqual(catalog.detail(actor,firstMovie.id).editions[0]!.parts.map(part=>part.id),originalEdition.parts.map(part=>part.id));
assert.equal(catalog.detail(actor,firstMovie.id).title,'人工标题');
assert.equal(catalog.list(actor,ids.video!,{search:'人工标题'}).total,1);
catalog.override(actor,firstMovie.id,{title:null});
assert.equal(catalog.detail(actor,firstMovie.id).title,'Renamed');
assert.throws(()=>catalog.override({id:'someone',role:'member'},firstMovie.id,{title:'x'}),{statusCode:403});
assert.throws(()=>catalog.override(actor,firstMovie.id,{unrecognized:'x'}),{statusCode:400});
db.run("INSERT INTO users(id,username,password_hash,role,created_at,updated_at) VALUES('admin','admin','unused','admin',0,0)");
const userState=new MediaUserState(db,libraries,catalog);
const moviePart=catalog.detail(actor,firstMovie.id).editions[0]!.parts[0]!.id;
const tracks=catalog.detail(actor,album.id).children;
const trackParts=tracks.map(track=>catalog.detail(actor,track.id).editions[0]!.parts[0]!.id);
const queued=userState.enqueue(actor,[moviePart,...trackParts]).items as Array<{id:string;partId:string}>;
const musicSnapshot=userState.previewQueue(actor,trackParts.slice().reverse());
assert.equal(musicSnapshot.channel,'music');assert.equal(musicSnapshot.existingCount,2);
assert.throws(()=>userState.previewQueue(actor,[moviePart,trackParts[0]!]),{statusCode:400});
const replaced=userState.replaceQueue(actor,[trackParts[1]!,trackParts[0]!,trackParts[1]!],musicSnapshot.revision).items as Array<{id:string;partId:string}>;
assert.equal(replaced[0]!.id,queued[0]!.id,'other channel identity is untouched');
assert.deepEqual(replaced.map(row=>row.partId),[moviePart,trackParts[1],trackParts[0],trackParts[1]]);
});
test('NFO overrides tags, external entities are rejected and unreadable metadata does not hide files',async t=>{
const root=await mkdtemp(join(tmpdir(),'media-nfo-'));t.after(()=>rm(root,{recursive:true,force:true}));
await writeFile(join(root,'book.m4b'),'file');await writeFile(join(root,'cover.jpg'),'image');
await writeFile(join(root,'book.nfo'),'<audiobook><title>NFO title</title><author>Writer</author></audiobook>');
const storage=await LocalMediaStorage.create(root);
const result=await readLocalMetadata(storage,'book.m4b',{status:'ready',info:{duration:1,format:'',streams:[],chapters:[],tags:{title:'Tag title'}}});
assert.equal(result.title,'NFO title');assert.equal(result.sources.title,'nfo');assert.equal(result.coverRef,'cover.jpg');
await writeFile(join(root,'book.nfo'),'<!DOCTYPE foo [<!ENTITY secret SYSTEM "file:///secret">]><audiobook><title>&secret;</title></audiobook>');
const rejected=await readLocalMetadata(storage,'book.m4b',{status:'unavailable',info:null});
assert.equal(rejected.title,'book');assert.ok(rejected.warnings.includes('nfo-unreadable'));
});
test('sidecar discovery observes additions and removals and falls back when directory listing fails',async t=>{
const root=await mkdtemp(join(tmpdir(),'media-sidecars-'));t.after(()=>rm(root,{recursive:true,force:true}));
await writeFile(join(root,'track.mp3'),'file');
const storage=await LocalMediaStorage.create(root),probe={status:'unavailable' as const,info:null};
const initial=await readLocalMetadata(storage,'track.mp3',probe);
assert.equal(initial.title,'track');assert.equal(initial.coverRef,undefined);
await writeFile(join(root,'track.nfo'),'<album><title>Added later</title></album>');
await writeFile(join(root,'cover.jpg'),'image');
const added=await readLocalMetadata(storage,'track.mp3',probe);
assert.equal(added.title,'Added later');assert.equal(added.coverRef,'cover.jpg');
const listing=storage.siblingNames.bind(storage);
storage.siblingNames=async()=>{throw Object.assign(Error('no listing permission'),{code:'EACCES'});};
const fallback=await readLocalMetadata(storage,'track.mp3',probe);
assert.equal(fallback.title,'Added later');assert.equal(fallback.coverRef,'cover.jpg');
storage.siblingNames=listing;
await rm(join(root,'track.nfo'));await rm(join(root,'cover.jpg'));
const removed=await readLocalMetadata(storage,'track.mp3',probe);
assert.equal(removed.title,'track');assert.equal(removed.coverRef,undefined);
await Promise.all(Array.from({length:257},(_,i)=>writeFile(join(root,`extra-${i}.txt`),'x')));
await writeFile(join(root,'track.nfo'),'<album><title>Large directory</title></album>');
assert.equal(await storage.siblingNames('track.mp3'),undefined,'large directory avoids scanning every sibling for every media file');
assert.equal((await readLocalMetadata(storage,'track.mp3',probe)).title,'Large directory');
});