Files
reader/server/test/media-api.test.ts

432 lines
42 KiB
TypeScript

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 Fastify from 'fastify';
import { Db } from '../src/db/index.ts';
import { loadConfig } from '../src/config/index.ts';
import { UserService } from '../src/services/users.ts';
import { signAccessToken } from '../src/services/tokens.ts';
import type { AppContext } from '../src/http/context.ts';
import { registerMediaRoutes } from '../src/http/routes/media.ts';
import { registerErrorHandler } from '../src/http/errors.ts';
import { setTimeout as delay } from 'node:timers/promises';
import { MetadataHttp, MusicBrainzProvider } from '../src/media/metadata-providers.ts';
import {MediaLibraries} from '../src/media/libraries.ts';
import {MediaScanner} from '../src/media/scanner.ts';
import {MediaStoreDatabase} from '../src/media/store-database.ts';
import {activateMediaDatabase} from '../src/media/migrate-database.ts';
import {MediaAccountCleanup} from '../src/media/account-cleanup.ts';
import {DatabaseMediaAccounts} from '../src/media/accounts.ts';
for(const isolated of [false,true])test(`media HTTP enforces authentication, grant changes, configuration privacy and scan lifecycle (isolated=${isolated})`,async t=>{
const base=await mkdtemp(join(tmpdir(),'media-api-')),root=join(base,'assets');await mkdir(root);
await mkdir(join(base,'books'));
process.env.BOOKS_DIR=join(base,'books');process.env.DATA_DIR=join(base,'data');process.env.READER_TOKEN_SECRET='media-api-test-secret';
const config=loadConfig(),db=new Db(isolated?join(base,'reader.db'):':memory:');
const users=new UserService(db,config),app=Fastify({logger:false});
for(const [id,role]of [['admin','admin'],['member','member']])db.run('INSERT INTO users(id,username,password_hash,role,created_at,updated_at) VALUES(?,?,?,?,0,0)',id!,id!,'unused',role!);
let store:MediaStoreDatabase|undefined;
if(isolated){
const scanner=new MediaScanner(db,new MediaLibraries(db));await scanner.close();
activateMediaDatabase(join(base,'reader.db'),join(base,'media.db'));
store=new MediaStoreDatabase(join(base,'media.db'));
}
const mediaDb=store||db;
const ctx={db,config,users} as AppContext;
registerErrorHandler(app);registerMediaRoutes(app,ctx,{database:mediaDb,metadataProviders:[{
id:'tmdb',label:'TMDB test fixture',kinds:['movie'],configured:true,
async search(){return [{externalId:'42',title:'Matched movie',year:2024}];},
async detail(){return {externalId:'42',fields:{title:'Matched movie',year:2024},sourceUrl:'https://www.themoviedb.org/movie/42'};},
},new MusicBrainzProvider(new MetadataHttp(),'')]});
t.after(async()=>{await app.close();if(store){assert.equal(db.all('SELECT id FROM media_assets').length,0);assert.equal(store.get("SELECT name FROM sqlite_master WHERE name='books'"),undefined);}store?.close();db.close();await rm(base,{recursive:true,force:true});});
const admin={authorization:'Bearer '+signAccessToken(config,{id:'admin',role:'admin'}).token};
const member={authorization:'Bearer '+signAccessToken(config,{id:'member',role:'member'}).token};
for(const [method,url] of [['GET','library-summaries'],['GET','scan-jobs'],['POST','scan-jobs']] as const){
const path='/api/v1/media/'+url;
assert.equal((await app.inject({method,url:path})).statusCode,401);
assert.equal((await app.inject({method,url:path,headers:member})).statusCode,403);
const empty=await app.inject({method,url:path,headers:admin});
assert.equal(empty.statusCode,method==='POST'?202:200);
assert.deepEqual(empty.json(),method==='POST'?{items:[],skipped:[]}:{items:[]});
}
assert.deepEqual((await app.inject({url:'/api/v1/media/scrape-jobs?summary=true',headers:admin})).json(),{items:[]});
for(const suffix of ['results','retry']){
const method=suffix==='retry'?'POST' as const:'GET' as const;
assert.equal((await app.inject({method,url:'/api/v1/media/scrape-jobs/missing/'+suffix,headers:member})).statusCode,403);
assert.equal((await app.inject({method,url:'/api/v1/media/scrape-jobs/missing/'+suffix,headers:admin})).statusCode,404);
}
for(const query of ['limit=51','offset=-1','state=complete'])assert.equal((await app.inject({url:'/api/v1/media/scrape-jobs/missing/results?'+query,headers:admin})).statusCode,400);
const newItemRequest={method:'POST' as const,url:'/api/v1/media/editions/missing/new-item',payload:{title:'新作品',expectedItemId:'source'}};
assert.equal((await app.inject(newItemRequest)).statusCode,401);
assert.equal((await app.inject({...newItemRequest,headers:member})).statusCode,403);
assert.equal((await app.inject({...newItemRequest,headers:admin,payload:{title:'',expectedItemId:'source'}})).statusCode,400);
assert.equal((await app.inject({...newItemRequest,headers:admin})).statusCode,404);
assert.equal((await app.inject({url:'/api/v1/media/search?query=test'})).statusCode,401);
assert.equal((await app.inject({url:'/api/v1/media/browse?channel=music&kind=album'})).statusCode,401);
assert.equal((await app.inject({url:'/api/v1/media/browse?channel=music&kind=movie',headers:member})).statusCode,400);
assert.equal((await app.inject({url:'/api/v1/media/browse?channel=music&kind=album&limit=201',headers:member})).statusCode,400);
assert.deepEqual((await app.inject({url:'/api/v1/media/browse?channel=music&kind=album',headers:member})).json(),{items:[],total:0});
assert.deepEqual((await app.inject({url:'/api/v1/media/browse?channel=video&kind=video',headers:member})).json(),{items:[],total:0});
assert.equal((await app.inject({url:'/api/v1/media/browse?channel=music&kind=video',headers:member})).statusCode,400);
assert.equal((await app.inject({url:'/api/v1/media/narrators'})).statusCode,401);
for(const query of ['query=','query=%20%20','query=test&channel=reading','query=test&kind=book','query=test&limit=201'])
assert.equal((await app.inject({url:'/api/v1/media/search?'+query,headers:member})).statusCode,400);
const emptySearch=await app.inject({url:'/api/v1/media/search?query=test&channel=video&kind=movie',headers:member});
assert.equal(emptySearch.statusCode,200);assert.deepEqual(emptySearch.json(),{items:[],total:0});
assert.equal((await app.inject({url:'/api/v1/media/metadata/providers',headers:member})).statusCode,403);
const providers=await app.inject({url:'/api/v1/media/metadata/providers',headers:admin});
assert.equal(providers.statusCode,200);
assert.equal(providers.json().items.length,2);
for(const provider of providers.json().items)assert.deepEqual(Object.keys(provider).sort(),['configured','id','kinds','label']);
assert.equal((await app.inject({method:'POST',url:'/api/v1/media/items/missing/matches',headers:admin,payload:{provider:'custom-url',query:'x'}})).statusCode,400);
assert.equal((await app.inject({method:'PUT',url:'/api/v1/media/items/missing/match',headers:member,payload:{candidateId:'candidate'}})).statusCode,403);
const url='/api/v1/media/libraries';
assert.equal((await app.inject({url})).statusCode,401);
assert.equal((await app.inject({method:'POST',url,headers:member,payload:{name:'x',kind:'video',root,access:'all'}})).statusCode,403);
assert.equal((await app.inject({method:'POST',url,headers:admin,payload:{name:'x',kind:'unknown',root,access:'all'}})).statusCode,400);
const created=await app.inject({method:'POST',url,headers:admin,payload:{name:'Films',kind:'video',root,access:'restricted'}});
assert.equal(created.statusCode,201);const id=created.json().id;
assert.equal((await app.inject({url:url+'/'+id,headers:member})).statusCode,404);
assert.equal((await app.inject({method:'PUT',url:`${url}/${id}/access`,headers:admin,payload:{access:'restricted',userIds:['member']}})).statusCode,204);
const visible=await app.inject({url:url+'/'+id,headers:member});assert.equal(visible.statusCode,200);assert.equal('root'in visible.json(),false);
assert.equal((await app.inject({url:`${url}/${id}/configuration`,headers:member})).statusCode,403);
await writeFile(join(root,'sample.mp4'),'not-a-real-media-file');
await writeFile(join(root,'sample.zh-CN.srt'),'1\n00:00:00,000 --> 00:00:05,000\n字幕测试');
await writeFile(join(root,'unrelated.srt'),'1\n00:00:00,000 --> 00:00:05,000\nunrelated');
const coverPng=Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aD1sAAAAASUVORK5CYII=','base64');
await writeFile(join(root,'cover.png'),coverPng);
const scan=await app.inject({method:'POST',url:`${url}/${id}/scan`,headers:admin});assert.equal(scan.statusCode,202);
let job=scan.json();const deadline=Date.now()+35000;
while(job.state==='running'&&Date.now()<deadline){await delay(25);job=(await app.inject({url:'/api/v1/media/jobs/'+job.id,headers:admin})).json();}
assert.equal(job.state,'complete');
const summaries=await app.inject({url:'/api/v1/media/library-summaries',headers:admin});
assert.equal(summaries.statusCode,200);
assert.equal(summaries.headers['cache-control'],'private, no-store');
assert.deepEqual(summaries.json().items,[{id,root,counts:{movie:1},chapters:0,missingFiles:0}]);
const latest=await app.inject({url:'/api/v1/media/scan-jobs',headers:admin});
assert.equal(latest.statusCode,200);assert.equal(latest.json().items[0].id,job.id);
const batch=await app.inject({method:'POST',url:'/api/v1/media/scan-jobs',headers:admin});
assert.equal(batch.statusCode,202);assert.equal(batch.json().items.length,1);
let batchJob=batch.json().items[0];
while(['running','queued'].includes(batchJob.state)&&Date.now()<deadline){await delay(25);batchJob=(await app.inject({url:'/api/v1/media/jobs/'+batchJob.id,headers:admin})).json();}
assert.equal(batchJob.state,'complete');
assert.equal((await app.inject({method:'POST',url:'/api/v1/media/jobs/'+job.id+'/cancel',headers:member})).statusCode,403);
const finishedCancel=await app.inject({method:'POST',url:'/api/v1/media/jobs/'+job.id+'/cancel',headers:admin});
assert.equal(finishedCancel.statusCode,202);assert.equal(finishedCancel.json().state,'complete');
const assets=await app.inject({url:`${url}/${id}/assets`,headers:member});assert.equal(assets.statusCode,200);assert.equal(assets.json().total,1);
const asset=assets.json().items[0];assert.equal(asset.available,true);assert.ok(['failed','unavailable'].includes(asset.probe.status));
const subtitleListUrl=`/api/v1/media/assets/${asset.id}/subtitles`;
assert.equal((await app.inject({url:subtitleListUrl})).statusCode,401);
const subtitleList=await app.inject({url:subtitleListUrl,headers:member});
assert.equal(subtitleList.statusCode,200);assert.equal(subtitleList.json().items.length,1);
const subtitle=subtitleList.json().items[0];assert.equal(subtitle.language,'zh-CN');assert.equal(subtitle.ref,undefined);
const subtitleUrl=subtitleListUrl+'/'+subtitle.id;
const subtitleContent=await app.inject({url:subtitleUrl,headers:member});
assert.equal(subtitleContent.statusCode,200);assert.ok(subtitleContent.json().webvtt.includes('字幕测试'));
assert.equal((await app.inject({url:subtitleListUrl+'/forged',headers:member})).statusCode,404);
const catalog=await app.inject({url:`${url}/${id}/items?kind=movie`,headers:member});
assert.equal(catalog.statusCode,200);assert.equal(catalog.json().total,1);
const itemId=catalog.json().items[0].id;
const originalDetail=(await app.inject({url:`/api/v1/media/items/${itemId}`,headers:admin})).json();
const assignmentUrl=`/api/v1/media/editions/${originalDetail.editions[0].id}/item`;
const assignment={targetItemId:itemId,expectedItemId:itemId};
assert.equal((await app.inject({method:'PUT',url:assignmentUrl,payload:assignment})).statusCode,401);
assert.equal((await app.inject({method:'PUT',url:assignmentUrl,headers:member,payload:assignment})).statusCode,403);
assert.equal((await app.inject({method:'PUT',url:assignmentUrl,headers:admin,payload:{targetItemId:itemId}})).statusCode,400);
assert.equal((await app.inject({method:'PUT',url:assignmentUrl,headers:admin,payload:{...assignment,expectedItemId:'stale'}})).statusCode,409);
const unchanged=await app.inject({method:'PUT',url:assignmentUrl,headers:admin,payload:assignment});
assert.equal(unchanged.statusCode,200);assert.equal(unchanged.json().editions[0].id,originalDetail.editions[0].id);
const editionSnapshot=originalDetail.editions[0];
const orderUrl=`/api/v1/media/editions/${editionSnapshot.id}/order`,orderBody={expectedRevision:editionSnapshot.revision,partIds:editionSnapshot.parts.map((part:{id:string})=>part.id)};
assert.equal((await app.inject({method:'PUT',url:orderUrl,payload:orderBody})).statusCode,401);
assert.equal((await app.inject({method:'PUT',url:orderUrl,headers:member,payload:orderBody})).statusCode,403);
assert.equal((await app.inject({method:'PUT',url:orderUrl,headers:admin,payload:{...orderBody,partIds:[]}})).statusCode,400);
assert.equal((await app.inject({method:'PUT',url:orderUrl,headers:admin,payload:{...orderBody,expectedRevision:'0'.repeat(64)}})).statusCode,409);
const orderSaved=await app.inject({method:'PUT',url:orderUrl,headers:admin,payload:orderBody});assert.equal(orderSaved.statusCode,200);
const orderReset=await app.inject({method:'PUT',url:orderUrl,headers:admin,payload:{expectedRevision:orderSaved.json().editions[0].revision,partIds:null}});assert.equal(orderReset.statusCode,200);
for(const [action,payload] of [
['split',{expectedRevision:editionSnapshot.revision,assetIds:[editionSnapshot.parts[0].assetId],label:'新版本'}],
['merge',{expectedRevision:editionSnapshot.revision,targetEditionId:editionSnapshot.id,targetRevision:editionSnapshot.revision}],
] as const){
const path=`/api/v1/media/editions/${editionSnapshot.id}/${action}`;
assert.equal((await app.inject({method:'POST',url:path,payload})).statusCode,401);
assert.equal((await app.inject({method:'POST',url:path,headers:member,payload})).statusCode,403);
assert.equal((await app.inject({method:'POST',url:path,headers:admin,payload:{}})).statusCode,400);
assert.equal((await app.inject({method:'POST',url:path,headers:admin,payload:{...payload,expectedRevision:'0'.repeat(64)}})).statusCode,409);
assert.equal((await app.inject({method:'POST',url:path,headers:admin,payload})).statusCode,400);
}
const coverUrl=`/api/v1/media/items/${itemId}/cover`;
assert.equal((await app.inject({url:coverUrl})).statusCode,401);
const cover=await app.inject({url:coverUrl,headers:member});
assert.equal(cover.statusCode,200);assert.equal(cover.headers['content-type'],'image/png');
assert.deepEqual(cover.rawPayload,coverPng);assert.equal(cover.headers['cache-control'],'private, no-store');
await writeFile(join(root,'cover.png'),'<svg onload="alert(1)"></svg>');
assert.equal((await app.inject({url:coverUrl,headers:member})).statusCode,404,'active content disguised as PNG is rejected');
await writeFile(join(root,'cover.png'),coverPng);
assert.equal((await app.inject({method:'PATCH',url:`/api/v1/media/items/${itemId}/metadata`,headers:member,payload:{title:'Forbidden'}})).statusCode,403);
const edited=await app.inject({method:'PATCH',url:`/api/v1/media/items/${itemId}/metadata`,headers:admin,payload:{title:'My movie'}});
assert.equal(edited.statusCode,200);assert.equal(edited.json().title,'My movie');
assert.equal(edited.json().editions[0].parts[0].assetId,asset.id);
const matches=await app.inject({method:'POST',url:`/api/v1/media/items/${itemId}/matches`,headers:admin,payload:{provider:'tmdb',query:'sample'}});
assert.equal(matches.statusCode,200);
const previewUrl=`/api/v1/media/items/${itemId}/matches`;
assert.equal((await app.inject({url:previewUrl})).statusCode,401);
assert.equal((await app.inject({url:previewUrl,headers:member})).statusCode,403);
const preview=await app.inject({url:previewUrl,headers:admin});
assert.equal(preview.statusCode,200);assert.equal(preview.headers['cache-control'],'private, no-store');
assert.equal(preview.json().items[0].candidateId,matches.json().items[0].candidateId);
assert.equal(preview.json().items[0].title,'Matched movie');
const matchUrl=`/api/v1/media/items/${itemId}/match`;
const matched=await app.inject({method:'PUT',url:matchUrl,headers:admin,payload:{candidateId:matches.json().items[0].candidateId}});
assert.equal(matched.statusCode,200);assert.equal(matched.json().title,'My movie','manual title remains authoritative');
assert.equal(matched.json().metadata.year,2024);
assert.equal((await app.inject({url:'/api/v1/media/items/'+itemId,headers:member})).json().metadata.onlineMatch.externalId,'42');
assert.equal((await app.inject({method:'DELETE',url:matchUrl,headers:member})).statusCode,403);
const unmatched=await app.inject({method:'DELETE',url:matchUrl,headers:admin});
assert.equal(unmatched.statusCode,200);assert.equal(unmatched.json().metadata.onlineMatch,undefined);assert.equal(unmatched.json().title,'My movie');
const partId=edited.json().editions[0].parts[0].id;
assert.equal((await app.inject({url:`/api/v1/media/items/${itemId}/album-playback`})).statusCode,401);
assert.equal((await app.inject({url:`/api/v1/media/items/${itemId}/album-playback`,headers:member})).statusCode,400);
assert.equal((await app.inject({url:`/api/v1/media/libraries/${id}/folders`})).statusCode,401);
const files=await app.inject({url:`/api/v1/media/libraries/${id}/folders`,headers:member});
assert.equal(files.statusCode,200);assert.equal(files.json().items[0].name,'sample.mp4');
assert.equal((await app.inject({url:`/api/v1/media/libraries/${id}/folders?path=..`,headers:member})).statusCode,400);
assert.equal((await app.inject({url:`/api/v1/media/assets/${asset.id}/catalog`})).statusCode,401);
const fileCatalog=await app.inject({url:`/api/v1/media/assets/${asset.id}/catalog`,headers:member});
assert.equal(fileCatalog.statusCode,200);assert.equal(fileCatalog.json().items[0].editions[0].parts[0].id,partId);
assert.equal((await app.inject({url:`/api/v1/media/parts/${partId}/lyrics`})).statusCode,401);
assert.equal((await app.inject({url:`/api/v1/media/parts/${partId}/lyrics`,headers:admin})).statusCode,404,'video assets do not expose music lyrics');
const grantResponse=await app.inject({method:'POST',url:'/api/v1/media/background-grants',headers:admin,payload:{partIds:[partId]}});
assert.equal(grantResponse.statusCode,201);const grant=grantResponse.json();
assert.ok(grant.expiresAt>Date.now());const grantHeaders={'x-media-background':grant.token};
assert.equal((await app.inject({url:'/api/v1/media/libraries',headers:grantHeaders})).statusCode,401);
assert.equal((await app.inject({method:'POST',url:'/api/v1/media/background-grants',headers:grantHeaders,payload:{partIds:[partId]}})).statusCode,401);
assert.equal((await app.inject({method:'POST',url:'/api/v1/media/background/playback',headers:grantHeaders,payload:{partId:'outside'}})).statusCode,401);
const backgroundPlay=await app.inject({method:'POST',url:'/api/v1/media/background/playback',headers:grantHeaders,payload:{partId}});
assert.equal(backgroundPlay.statusCode,201);const bg=backgroundPlay.json();
const bgSubtitleUrl=`/api/v1/media/background/playback/${bg.id}/subtitles`;
assert.equal((await app.inject({url:bgSubtitleUrl})).statusCode,401);
assert.equal((await app.inject({url:bgSubtitleUrl,headers:admin})).statusCode,401);
const bgSubtitles=await app.inject({url:bgSubtitleUrl,headers:grantHeaders});
assert.equal(bgSubtitles.statusCode,200);assert.equal(bgSubtitles.headers['cache-control'],'private, no-store');
assert.equal(bgSubtitles.json().items.length,1);
const bgText=await app.inject({url:bgSubtitleUrl+'/'+bgSubtitles.json().items[0].id,headers:grantHeaders});
assert.equal(bgText.statusCode,200);assert.ok(bgText.json().webvtt.includes('字幕测试'));
assert.equal((await app.inject({url:bgSubtitleUrl+'/forged',headers:grantHeaders})).statusCode,404);
assert.equal((await app.inject({method:'PUT',url:`/api/v1/media/background/playback/${bg.id}/progress`,headers:grantHeaders,payload:{sequence:0,revision:bg.revision,position:bg.start,completed:false}})).statusCode,200);
assert.equal((await app.inject({method:'POST',url:`/api/v1/media/background/playback/${bg.id}/renew`,headers:grantHeaders})).statusCode,200);
assert.equal((await app.inject({method:'DELETE',url:'/api/v1/media/background-grants/current',headers:grantHeaders})).statusCode,204);
assert.equal((await app.inject({url:bg.streamUrl})).statusCode,401);
assert.equal((await app.inject({url:bgSubtitleUrl,headers:grantHeaders})).statusCode,401);
assert.equal((await app.inject({method:'POST',url:`/api/v1/media/background/playback/${bg.id}/renew`,headers:grantHeaders})).statusCode,401);
const memberGrant=(await app.inject({method:'POST',url:'/api/v1/media/background-grants',headers:member,payload:{partIds:[partId]}})).json();
const memberBgHeaders={'x-media-background':memberGrant.token};
const memberBgCreate=()=>app.inject({method:'POST',url:'/api/v1/media/background/playback',headers:memberBgHeaders,payload:{partId}});
db.run("UPDATE users SET disabled=1 WHERE id='member'");assert.equal((await memberBgCreate()).statusCode,401);db.run("UPDATE users SET disabled=0 WHERE id='member'");
db.run("UPDATE users SET auth_version=auth_version+1 WHERE id='member'");assert.equal((await memberBgCreate()).statusCode,401);db.run("UPDATE users SET auth_version=auth_version-1 WHERE id='member'");
const memberBg=(await memberBgCreate()).json();
assert.equal((await app.inject({method:'POST',url:`/api/v1/media/background/playback/${memberBg.id}/renew`,headers:grantHeaders})).statusCode,401);
mediaDb.run('UPDATE media_background_grants SET expires_at=0 WHERE id=?',memberGrant.id);
mediaDb.run('UPDATE media_background_sessions SET expires_at=0 WHERE grant_id=?',memberGrant.id);
assert.equal((await memberBgCreate()).statusCode,401);assert.equal((await app.inject({url:memberBg.streamUrl})).statusCode,401);
const version=edited.json().editions[0];
const renameVersion=(authorization:typeof admin,label:string,expectedLabel:string)=>app.inject({method:'PATCH',url:'/api/v1/media/editions/'+version.id,headers:authorization,payload:{label,expectedLabel}});
assert.equal((await renameVersion(member,'会员版本',version.label)).statusCode,403);
assert.equal((await renameVersion(admin,' ',version.label)).statusCode,400);
assert.equal((await renameVersion(admin,'国语版',version.label)).statusCode,200);
assert.equal((await renameVersion(admin,'旧页面覆盖',version.label)).statusCode,409);
assert.equal((await app.inject({method:'PUT',url:`/api/v1/media/items/${itemId}/favorite`,headers:member,payload:{favorite:true}})).statusCode,200);
assert.equal((await app.inject({url:'/api/v1/media/favorites',headers:member})).json().items.length,1);
assert.equal((await app.inject({url:'/api/v1/media/favorites',headers:admin})).json().items.length,0);
const queue=await app.inject({method:'POST',url:'/api/v1/media/queue',headers:member,payload:{partIds:[partId,partId]}});
assert.equal(queue.json().items.length,2);
const [firstEntry,secondEntry]=queue.json().items;
const moveUrl='/api/v1/media/queue/'+secondEntry.id+'/move';
assert.equal((await app.inject({method:'POST',url:moveUrl,headers:admin,payload:{direction:'up',neighborId:firstEntry.id}})).statusCode,404);
const moved=await app.inject({method:'POST',url:moveUrl,headers:member,payload:{direction:'up',neighborId:firstEntry.id}});
assert.equal(moved.statusCode,200);assert.deepEqual(moved.json().items.map((row:{id:string})=>row.id),[secondEntry.id,firstEntry.id]);
assert.equal((await app.inject({method:'POST',url:moveUrl,headers:member,payload:{direction:'up',neighborId:firstEntry.id}})).statusCode,409);
assert.equal((await app.inject({method:'POST',url:moveUrl,headers:member,payload:{direction:'down',neighborId:'foreign'}})).statusCode,409);
await app.inject({method:'DELETE',url:'/api/v1/media/queue/'+queue.json().items[0].id,headers:admin});
assert.equal((await app.inject({url:'/api/v1/media/queue',headers:member})).json().items.length,2);
const clearUrl='/api/v1/media/queue/clear';
const snapshot=[firstEntry.id,secondEntry.id];
assert.equal((await app.inject({method:'POST',url:clearUrl,headers:member,payload:{channel:'reading',entryIds:snapshot}})).statusCode,400);
await app.inject({method:'POST',url:clearUrl,headers:admin,payload:{channel:'video',entryIds:snapshot}});
const wrongChannel=await app.inject({method:'POST',url:clearUrl,headers:member,payload:{channel:'music',entryIds:snapshot}});
assert.equal(wrongChannel.json().items.length,2);
const added=await app.inject({method:'POST',url:'/api/v1/media/queue',headers:member,payload:{partIds:[partId]}});
const newEntry=added.json().items.find((row:{id:string})=>!snapshot.includes(row.id));
const cleared=await app.inject({method:'POST',url:clearUrl,headers:member,payload:{channel:'video',entryIds:snapshot}});
assert.deepEqual(cleared.json().items.map((row:{id:string})=>row.id),[newEntry.id]);
const queuePreviewUrl='/api/v1/media/queue/preview',saveQueueUrl='/api/v1/media/queue/snapshot';
assert.equal((await app.inject({method:'POST',url:queuePreviewUrl,payload:{partIds:[partId]}})).statusCode,401);
const beforeSave=await app.inject({method:'POST',url:queuePreviewUrl,headers:member,payload:{partIds:[partId,partId]}});
assert.equal(beforeSave.statusCode,200);assert.equal(beforeSave.json().channel,'video');
await app.inject({method:'POST',url:'/api/v1/media/queue',headers:member,payload:{partIds:[partId]}});
assert.equal((await app.inject({method:'PUT',url:saveQueueUrl,headers:member,payload:{partIds:[partId,partId],expectedRevision:beforeSave.json().revision}})).statusCode,409);
const latestPreview=(await app.inject({method:'POST',url:queuePreviewUrl,headers:member,payload:{partIds:[partId,partId]}})).json();
const savedQueue=await app.inject({method:'PUT',url:saveQueueUrl,headers:member,payload:{partIds:[partId,partId],expectedRevision:latestPreview.revision}});
assert.equal(savedQueue.statusCode,200);assert.deepEqual(savedQueue.json().items.map((entry:{partId:string})=>entry.partId),[partId,partId]);
assert.equal((await app.inject({method:'POST',url:queuePreviewUrl,headers:member,payload:{partIds:['missing']}})).statusCode,404);
const sessionResponse=await app.inject({method:'POST',url:'/api/v1/media/playback',headers:member,payload:{partId}});
assert.equal(sessionResponse.statusCode,201);const session=sessionResponse.json();
const renewUrl=`/api/v1/media/playback/${session.id}/renew`;
assert.equal((await app.inject({method:'POST',url:renewUrl})).statusCode,401);
assert.equal((await app.inject({method:'POST',url:renewUrl,headers:admin})).statusCode,404);
mediaDb.run('UPDATE media_playback_sessions SET expires_at=? WHERE id=?',Date.now()-1000,session.id);
assert.equal((await app.inject({url:session.streamUrl})).statusCode,401);
const renewed=await app.inject({method:'POST',url:renewUrl,headers:member});
assert.equal(renewed.statusCode,200);assert.ok(renewed.json().expiresAt>Date.now());
assert.equal((await app.inject({url:`/api/v1/media/parts/${partId}/progress`,headers:member})).json().revision,session.revision);
const bytes=await app.inject({url:session.streamUrl,headers:{range:'bytes=2-5'}});
assert.equal(bytes.statusCode,206);assert.equal(bytes.body,'t-a-');assert.equal(bytes.headers['content-range'],'bytes 2-5/21');
const suffix=await app.inject({url:session.streamUrl,headers:{range:'bytes=-4'}});
assert.equal(suffix.statusCode,206);assert.equal(suffix.body,'file');
assert.equal((await app.inject({url:session.streamUrl,headers:{range:'bytes=500-'}})).statusCode,416);
assert.equal((await app.inject({url:session.streamUrl.replace(/ticket=.*/, 'ticket='+'x'.repeat(43))})).statusCode,401);
const progressUrl=`/api/v1/media/playback/${session.id}/progress`;
const progress=await app.inject({method:'PUT',url:progressUrl,headers:member,payload:{sequence:0,revision:session.revision,position:15}});
assert.equal(progress.statusCode,200);assert.equal(progress.json().position,15);
const history=await app.inject({url:'/api/v1/media/history',headers:member});
assert.equal(history.json().items[0].title,'My movie');
assert.equal(history.json().items[0].assetId,asset.id);assert.equal(history.json().items[0].start,0);
assert.equal((await app.inject({url:'/api/v1/media/queue',headers:member})).json().items[0].title,'My movie');
mediaDb.run('UPDATE media_parts SET active=0 WHERE id=?',partId);
assert.equal((await app.inject({url:'/api/v1/media/history',headers:member})).json().items[0].available,0);
mediaDb.run('UPDATE media_parts SET active=1 WHERE id=?',partId);
const repeated=await app.inject({method:'PUT',url:progressUrl,headers:member,payload:{sequence:0,revision:session.revision,position:15}});
assert.equal(repeated.statusCode,200);assert.equal(repeated.json().revision,progress.json().revision);
assert.equal(repeated.json().updatedAt,progress.json().updatedAt,'retries do not create new history timestamps');
assert.equal((await app.inject({method:'PUT',url:progressUrl,headers:member,payload:{sequence:0,revision:progress.json().revision,position:3}})).statusCode,409);
const rewind=await app.inject({method:'PUT',url:progressUrl,headers:member,payload:{sequence:1,revision:progress.json().revision,position:3}});
assert.equal(rewind.statusCode,200);assert.equal(rewind.json().position,3);
const takeover=await app.inject({method:'POST',url:'/api/v1/media/playback',headers:member,payload:{partId}});
assert.equal(takeover.json().position,3);
assert.equal((await app.inject({method:'POST',url:'/api/v1/media/playback/recover',headers:member,payload:{partId,previousSessionId:session.id}})).statusCode,409);
assert.equal((await app.inject({method:'POST',url:renewUrl,headers:member})).statusCode,409);
const currentRenewUrl=`/api/v1/media/playback/${takeover.json().id}/renew`;
mediaDb.run('UPDATE media_playback_sessions SET auth_version=auth_version+1 WHERE id=?',takeover.json().id);
assert.equal((await app.inject({method:'POST',url:currentRenewUrl,headers:member})).statusCode,401);
mediaDb.run('UPDATE media_playback_sessions SET auth_version=auth_version-1,expires_at=? WHERE id=?',Date.now()-25*60*60*1000,takeover.json().id);
assert.equal((await app.inject({method:'POST',url:currentRenewUrl,headers:member})).statusCode,404);
mediaDb.run('UPDATE media_playback_sessions SET expires_at=? WHERE id=?',Date.now()+6*60*60*1000,takeover.json().id);
assert.equal((await app.inject({method:'PUT',url:progressUrl,headers:member,payload:{sequence:2,revision:rewind.json().revision,position:10}})).statusCode,409);
assert.equal((await app.inject({url:`${url}/${id}/assets?limit=999`,headers:member})).statusCode,400);
const finished=await app.inject({method:'PUT',url:`/api/v1/media/playback/${takeover.json().id}/progress`,headers:member,payload:{sequence:0,revision:takeover.json().revision,position:30,completed:true}});
assert.equal(finished.statusCode,200);
assert.equal((await app.inject({method:'POST',url:'/api/v1/media/playback/recover',headers:member,payload:{partId,previousSessionId:takeover.json().id}})).statusCode,409);
const replay=await app.inject({method:'POST',url:'/api/v1/media/playback',headers:member,payload:{partId}});
assert.equal(replay.statusCode,201);assert.equal(replay.json().position,replay.json().start);
const recoverBody={partId,previousSessionId:replay.json().id};
assert.equal((await app.inject({method:'POST',url:'/api/v1/media/playback/recover',payload:recoverBody})).statusCode,401);
const recovered=await app.inject({method:'POST',url:'/api/v1/media/playback/recover',headers:member,payload:recoverBody});
assert.equal(recovered.statusCode,201);assert.equal(recovered.headers['cache-control'],'no-store');
assert.notEqual(recovered.json().id,replay.json().id);assert.equal(recovered.json().position,replay.json().position);
assert.equal((await app.inject({method:'POST',url:'/api/v1/media/playback/recover',headers:member,payload:recoverBody})).statusCode,409);
assert.equal((await app.inject({url:`/api/v1/media/parts/${partId}/progress`,headers:member})).json().completed,false);
mediaDb.run('UPDATE media_parts SET start_seconds=10,end_seconds=20 WHERE id=?',partId);
mediaDb.run('UPDATE media_progress SET position=50 WHERE user_id=? AND part_id=?','member',partId);
const clippedEnd=await app.inject({method:'POST',url:'/api/v1/media/playback',headers:member,payload:{partId}});
assert.equal(clippedEnd.json().position,20);
mediaDb.run('UPDATE media_progress SET position=1 WHERE user_id=? AND part_id=?','member',partId);
const clippedStart=await app.inject({method:'POST',url:'/api/v1/media/playback',headers:member,payload:{partId}});
assert.equal(clippedStart.json().position,10);
mediaDb.run('UPDATE media_parts SET start_seconds=0,end_seconds=NULL WHERE id=?',partId);
await app.inject({method:'PUT',url:`${url}/${id}/access`,headers:admin,payload:{access:'restricted',userIds:[]}});
assert.equal((await app.inject({url:subtitleListUrl,headers:member})).statusCode,404);
assert.equal((await app.inject({url:subtitleUrl,headers:member})).statusCode,404);
assert.equal((await app.inject({url:coverUrl,headers:member})).statusCode,404);
assert.equal((await app.inject({method:'POST',url:`/api/v1/media/playback/${takeover.json().id}/renew`,headers:member})).statusCode,404);
assert.equal((await app.inject({url:'/api/v1/media/assets/'+asset.id,headers:member})).statusCode,404);
assert.equal((await app.inject({url:'/api/v1/media/jobs/'+job.id,headers:member})).statusCode,404);
assert.equal((await app.inject({url:'/api/v1/media/items/'+itemId,headers:member})).statusCode,404);
assert.equal((await app.inject({url:session.streamUrl})).statusCode,404);
for(const path of ['queue','history','favorites'])assert.equal((await app.inject({url:'/api/v1/media/'+path,headers:member})).json().items.length,0);
// Exercise the public hierarchy schemas and admin boundary, not just catalog methods.
mediaDb.run("INSERT INTO media_items(id,library_id,kind,local_key,title,metadata_json) VALUES('hierarchy-show',?,'series','hierarchy-show','原剧集','{}')",id);
mediaDb.run("INSERT INTO media_items(id,library_id,kind,parent_id,local_key,title,ordinal,metadata_json) VALUES('hierarchy-season',?,'season','hierarchy-show','hierarchy-season','第一季',1,'{}')",id);
mediaDb.run("INSERT INTO media_items(id,library_id,kind,parent_id,local_key,title,ordinal,metadata_json) VALUES('hierarchy-episode',?,'episode','hierarchy-season','hierarchy-episode','第一集',1,'{}')",id);
const hierarchyUrl='/api/v1/media/items/hierarchy-episode/hierarchy';
const newHierarchy={seriesTitle:'新剧集',seasonOrdinal:0,ordinal:1,expectedParentId:'hierarchy-season',expectedOrdinal:1};
const hierarchyCount=()=>mediaDb.get<{n:number}>('SELECT count(*) n FROM media_items')!.n;
const originalCount=hierarchyCount();
assert.equal((await app.inject({method:'POST',url:hierarchyUrl,payload:newHierarchy})).statusCode,401);
assert.equal((await app.inject({method:'POST',url:hierarchyUrl,headers:member,payload:newHierarchy})).statusCode,403);
for(const payload of [{...newHierarchy,seasonOrdinal:-1},{...newHierarchy,ordinal:1.5},{...newHierarchy,seriesTitle:' '},{...newHierarchy,targetSeriesId:'hierarchy-show'},{...newHierarchy,ordinal:0}]){
assert.equal((await app.inject({method:'POST',url:hierarchyUrl,headers:admin,payload})).statusCode,400);
assert.equal(hierarchyCount(),originalCount,'rejected creation must not leave parent records');
}
const creation=await app.inject({method:'POST',url:hierarchyUrl,headers:admin,payload:newHierarchy});
assert.equal(creation.statusCode,201);assert.equal(hierarchyCount(),originalCount+2);
assert.equal((await app.inject({method:'POST',url:hierarchyUrl,headers:admin,payload:newHierarchy})).statusCode,409);
assert.equal(hierarchyCount(),originalCount+2);
const moveHierarchy={targetParentId:'hierarchy-season',ordinal:2,expectedParentId:creation.json().parentId,expectedOrdinal:1};
assert.equal((await app.inject({method:'PUT',url:hierarchyUrl,headers:member,payload:moveHierarchy})).statusCode,403);
assert.equal((await app.inject({method:'PUT',url:hierarchyUrl,headers:admin,payload:{...moveHierarchy,ordinal:-1}})).statusCode,400);
const movedHierarchy=await app.inject({method:'PUT',url:hierarchyUrl,headers:admin,payload:moveHierarchy});
assert.equal(movedHierarchy.statusCode,200);assert.equal(movedHierarchy.json().parentId,'hierarchy-season');assert.equal(movedHierarchy.json().ordinal,2);
// These accounts did not exist when the independent database was migrated.
for(const account of ['late','grant-only','disabled-late'])db.run('INSERT INTO users(id,username,password_hash,role,created_at,updated_at) VALUES(?,?,?,\'member\',0,0)',account,account,'private');
const late={authorization:'Bearer '+signAccessToken(config,{id:'late',role:'member'}).token};
assert.equal((await app.inject({method:'PUT',url:`${url}/${id}/access`,headers:admin,payload:{access:'all',userIds:[]}})).statusCode,204);
const favoriteUrl='/api/v1/media/items/hierarchy-show/favorite';
assert.equal((await app.inject({method:'PUT',url:favoriteUrl,headers:late,payload:{favorite:true}})).statusCode,200);
assert.ok(mediaDb.get("SELECT user_id FROM media_favorites WHERE user_id='late'"));
assert.equal((await app.inject({method:'PUT',url:`${url}/${id}/access`,headers:admin,payload:{access:'restricted',userIds:['late','grant-only']}})).statusCode,204);
assert.ok(mediaDb.get("SELECT id FROM users WHERE id='grant-only'"));
db.run("UPDATE users SET disabled=1 WHERE id='disabled-late'");
const disabledLate={authorization:'Bearer '+signAccessToken(config,{id:'disabled-late',role:'member'}).token};
assert.equal((await app.inject({method:'PUT',url:favoriteUrl,headers:disabledLate,payload:{favorite:true}})).statusCode,403);
assert.equal((await app.inject({method:'PUT',url:`${url}/${id}/access`,headers:admin,payload:{access:'restricted',userIds:['disabled-late']}})).statusCode,400);
if(store){
assert.deepEqual(store.all<{name:string}>('PRAGMA table_info(users)').map(row=>row.name),['id']);
assert.equal(store.get("SELECT id FROM users WHERE id='disabled-late'"),undefined);
}
db.run("DELETE FROM users WHERE id='late'");
assert.equal((await app.inject({method:'PUT',url:favoriteUrl,headers:late,payload:{favorite:false}})).statusCode,401);
if(store){
assert.ok(store.get("SELECT user_id FROM media_favorites WHERE user_id='late'"),'cleanup is asynchronous; authentication already denies access');
const cleanup=new MediaAccountCleanup(store,new DatabaseMediaAccounts(db));
assert.equal(cleanup.sweep(),1);
assert.equal(store.get("SELECT id FROM users WHERE id='late'"),undefined);
assert.equal(store.get("SELECT user_id FROM media_favorites WHERE user_id='late'"),undefined);
assert.equal(store.get("SELECT user_id FROM media_library_users WHERE user_id='late'"),undefined);
db.run("UPDATE users SET disabled=1 WHERE id='grant-only'");
assert.equal(cleanup.sweep(),0);assert.ok(store.get("SELECT id FROM users WHERE id='grant-only'"));
assert.ok(store.get("SELECT id FROM media_items WHERE id='hierarchy-show'"),'shared catalog survives user cleanup');
}
const cleanupUrl=`${url}/${id}/missing-resources`;
assert.equal((await app.inject({url:cleanupUrl+'?path='})).statusCode,401);
assert.equal((await app.inject({url:cleanupUrl+'?path=',headers:member})).statusCode,403);
assert.equal((await app.inject({url:cleanupUrl,headers:admin})).statusCode,400);
assert.equal((await app.inject({url:cleanupUrl+'?path=..',headers:admin})).statusCode,400);
const absent=await app.inject({url:cleanupUrl+'?path=',headers:admin});assert.equal(absent.statusCode,200);assert.equal(absent.headers['cache-control'],'no-store');
const cleanupRequest={method:'POST' as const,url:cleanupUrl+'/cleanup',payload:{path:'',revision:absent.json().revision}};
assert.equal((await app.inject(cleanupRequest)).statusCode,401);
assert.equal((await app.inject({...cleanupRequest,headers:member})).statusCode,403);
assert.equal((await app.inject({...cleanupRequest,headers:admin,payload:{path:''}})).statusCode,400);
assert.equal((await app.inject({...cleanupRequest,headers:admin,payload:{path:'',revision:'0'.repeat(64)}})).statusCode,409);
const cleaned=await app.inject({...cleanupRequest,headers:admin});assert.equal(cleaned.statusCode,200,cleaned.body);
assert.deepEqual(mediaDb.all('PRAGMA foreign_key_check'),[]);
const ruleUrl=`${url}/${id}/recognition-rule`;
assert.equal((await app.inject({url:ruleUrl+'?path='})).statusCode,401);
assert.equal((await app.inject({url:ruleUrl+'?path=',headers:member})).statusCode,403);
const ruleResponse=await app.inject({url:ruleUrl+'?path=',headers:admin});assert.equal(ruleResponse.statusCode,200);
const ruleWrite={method:'PUT' as const,url:ruleUrl,headers:admin,payload:{path:'Review',revision:ruleResponse.json().revision,rule:{path:'Review',mode:'season',season:2,title:'测试剧集'}}};
const savedRule=await app.inject(ruleWrite);assert.equal(savedRule.statusCode,200,savedRule.body);
const loadedRule=await app.inject({url:ruleUrl+'?path=Review',headers:admin});assert.deepEqual(loadedRule.json().rule,ruleWrite.payload.rule);
assert.equal((await app.inject(ruleWrite)).statusCode,409);
assert.equal((await app.inject({...ruleWrite,payload:{...ruleWrite.payload,revision:savedRule.json().revision,rule:{path:'Review',mode:'season',season:-1,title:'invalid'}}})).statusCode,400);
const previewRequest={method:'POST' as const,url:`${url}/${id}/recognition-preview`,payload:{path:'Review'}};
assert.equal((await app.inject({...previewRequest,headers:member})).statusCode,403);
const recognitionPreview=await app.inject({...previewRequest,headers:admin});assert.equal(recognitionPreview.statusCode,200,recognitionPreview.body);assert.deepEqual(recognitionPreview.json().items,[]);
const applyRequest={method:'POST' as const,url:`${url}/${id}/recognition-apply`,payload:{path:'Review',previewId:recognitionPreview.json().id,assetIds:['missing']}};
assert.equal((await app.inject({...applyRequest,headers:member})).statusCode,403);
assert.equal((await app.inject({...applyRequest,headers:admin})).statusCode,400);
});