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()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'),''); 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); });