mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-13 22:41:06 +08:00
feat(media): clean missing resources by directory
This commit is contained in:
1 parent
71eef28e16
commit
f583b32e46
12 files changed
+341
-5
No files matched your search
@@ -848,6 +848,8 @@ Reader 对外提供的只读 OPDS 服务与 WebDAV 备份上传见[接入指南]
|
||||
| PATCH /libraries/:id | 修改名称或 OpenList 凭据 |
|
||||
| PUT /libraries/:id/access | 设置访问范围与用户列表 |
|
||||
| POST /libraries/:id/scan | 提交扫描任务 |
|
||||
| GET /libraries/:id/missing-resources?path=库内相对目录 | 管理员预览当前目录及子目录的缺失资源清理范围;path 空字符串表示根目录 |
|
||||
| POST /libraries/:id/missing-resources/cleanup | 管理员提交 {path,revision};revision 来自预览,范围变化返回 MEDIA_CLEANUP_CHANGED |
|
||||
| GET /libraries/:id/jobs、GET /jobs/:id | 查看任务状态 |
|
||||
| GET /browse、GET /libraries/:id/items | 按频道、分类或库浏览 |
|
||||
| GET /items/:id | 作品、版本与章节详情 |
|
||||
|
||||
@@ -13,6 +13,38 @@
|
||||
|
||||
扫描只读源文件,不移动、改名或写回标签。目录或远端读取失败会保留已发布目录和资料。缺失文件可在详情查看状态;恢复文件后重扫。不同播放版本需要明确选择,不能按文件名推断为同一版。
|
||||
|
||||
## 剧集与多季命名
|
||||
|
||||
影视库仅在同时取得剧名(show)、季号和集号时识别为单集,否则按独立电影导入。文件名支持大小写不敏感的 S01E01 形式,前后的分隔符使用空格、点、下划线或短横线;季号最多三位,集号最多四位。
|
||||
|
||||
推荐目录结构:
|
||||
|
||||
```text
|
||||
剧集/
|
||||
远山之路/
|
||||
Season 01/
|
||||
远山之路 S01E01.mkv
|
||||
远山之路 S01E02.mkv
|
||||
Season 02/
|
||||
远山之路 S02E01.mkv
|
||||
```
|
||||
|
||||
同一库中按“剧目录+剧名”归组;父目录名为 Season 01、S01 或“第一季”等季目录形式时,使用其上一级作为剧目录。各季、各集按数字排序,同季同集的不同文件作为不同版本。不同目录下的同名剧不会自动合并。
|
||||
|
||||
仅有季目录、01.mp4、1x01 或“第01集”等名字不足以识别季集。S01E01.mp4 没有剧名前缀时会回退使用当前目录名;放在 Season 01 下可能把季目录名当作剧名,建议保留文件名中的剧名或提供 NFO。
|
||||
|
||||
同目录 NFO 查找顺序为“同名 .nfo → movie.nfo → tvshow.nfo → album.nfo → audiobook.nfo”,采用首份有效记录,NFO 字段优先于标签和文件名。单集 NFO 可用 showtitle、season、episode 明确归属;不向上查找季目录之外的 tvshow.nfo。已有文件重扫会保留作品/版本归属,修正命名后不一定自动重建层级,可在详情中人工调整。
|
||||
|
||||
## 按目录清理失效资源
|
||||
|
||||
管理员打开频道的「文件夹」,选择媒体库并进入目标目录,点击「清理失效资源」。范围包含当前目录及所有子目录;在库内根目录操作则覆盖整个库。
|
||||
|
||||
先成功完成媒体库扫描,再预览清理数量。仅清理扫描已标记缺失的文件记录,不处理仅因编码不支持、探测失败、鉴权或网络错误而暂时无法播放的资源。扫描或批量刮削中不能清理;最近扫描失败或取消时需先重扫成功。
|
||||
|
||||
确认框会列出资源、播放片段、空版本、空作品/分类,以及所有用户受影响的收藏、播放进度和队列数量。确认后原子清理这些数据库记录及相关播放会话;仍有资源的版本、作品和上级分类保留,原始本地和 OpenList 文件不删除。预览后目录或关联范围变化时,需要重新预览。
|
||||
|
||||
清理不可撤销,文件恢复后可重新扫描入库,但已移除的进度与人工资料不会恢复。临时卸载目录或网盘挂载异常时应先恢复并重扫,不应清理。清理后当前目录变空会返回最近仍有记录的上级目录。
|
||||
|
||||
## 资料、刮削和整理
|
||||
|
||||
本地库优先读取文件标签、NFO 和同目录封面;ffprobe 用于编码、时长及章节探测,ffmpeg 用于部分内嵌资源提取,两者不等于转码功能。OpenList 支持外置 NFO、封面、歌词和字幕,暂不读取远端内嵌标签、封面及章节。
|
||||
|
||||
@@ -20,6 +20,7 @@ import { MediaArtwork } from '../../media/artwork.ts';
|
||||
import { MediaSubtitles } from '../../media/subtitles.ts';
|
||||
import { MediaLyrics } from '../../media/lyrics.ts';
|
||||
import { MediaFolders } from '../../media/folders.ts';
|
||||
import { MediaCleanup } from '../../media/cleanup.ts';
|
||||
import { MediaScrapeJobs,SCRAPE_RESULT_STATES } from '../../media/scrape-jobs.ts';
|
||||
import { MediaBackgroundGrants } from '../../media/background-grants.ts';
|
||||
import { DatabaseMediaAccounts, MediaAccountReferences } from '../../media/accounts.ts';
|
||||
@@ -46,6 +47,7 @@ export function registerMediaRoutes(app:FastifyInstance,ctx:MediaRouteContext,op
|
||||
const subtitles=new MediaSubtitles(database,libraries);
|
||||
const lyrics=new MediaLyrics(database,libraries);
|
||||
const folders=new MediaFolders(database,libraries,scanner.catalog);
|
||||
const cleanup=new MediaCleanup(database,libraries,scanner.catalog);
|
||||
const authenticateAccount=authenticate(ctx);
|
||||
const auth=async(request:import('fastify').FastifyRequest,reply:import('fastify').FastifyReply)=>{
|
||||
await authenticateAccount(request,reply);
|
||||
@@ -65,6 +67,8 @@ export function registerMediaRoutes(app:FastifyInstance,ctx:MediaRouteContext,op
|
||||
return result.result;
|
||||
};
|
||||
app.get<{Params:{id:string};Querystring:{path?:string;offset?:number;limit?:number}}>('/api/v1/media/libraries/:id/folders',{preHandler:auth,schema:{querystring:{type:'object',properties:{path:{type:'string',maxLength:4000},offset:{type:'integer',minimum:0,default:0},limit:{type:'integer',minimum:1,maximum:200,default:60}}}}},async (request,reply)=>queryCatalog(request,reply,{method:'folders',args:[currentUser(request),request.params.id,request.query.path,request.query.offset,request.query.limit]}));
|
||||
app.get<{Params:{id:string};Querystring:{path:string}}>('/api/v1/media/libraries/:id/missing-resources',{preHandler:[auth,async request=>requireAdmin(request)],schema:{querystring:{type:'object',additionalProperties:false,required:['path'],properties:{path:{type:'string',maxLength:4000}}}}},async(request,reply)=>reply.header('cache-control','no-store').send(cleanup.preview(currentUser(request),request.params.id,request.query.path)));
|
||||
app.post<{Params:{id:string};Body:{path:string;revision:string}}>('/api/v1/media/libraries/:id/missing-resources/cleanup',{preHandler:[auth,async request=>requireAdmin(request)],schema:{body:{type:'object',additionalProperties:false,required:['path','revision'],properties:{path:{type:'string',maxLength:4000},revision:{type:'string',pattern:'^[a-f0-9]{64}$'}}}}},async(request,reply)=>reply.header('cache-control','no-store').send(cleanup.remove(currentUser(request),request.params.id,request.body.path,request.body.revision)));
|
||||
app.get<{Params:{id:string}}>('/api/v1/media/assets/:id/catalog',{preHandler:auth},async (request,reply)=>queryCatalog(request,reply,{method:'file',args:[currentUser(request),request.params.id]}));
|
||||
app.get<{Params:{id:string}}>('/api/v1/media/parts/:id/lyrics',{preHandler:auth},async(request,reply)=>reply.header('cache-control','private, no-store').send(await lyrics.read(currentUser(request),request.params.id)));
|
||||
const backgroundToken=(request:import('fastify').FastifyRequest)=>typeof request.headers['x-media-background']==='string'?request.headers['x-media-background']:'';
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import type { MediaDatabase } from './read-database.ts';
|
||||
import type { MediaActor } from './libraries.ts';
|
||||
import { MediaLibraries } from './libraries.ts';
|
||||
import { MediaCatalog } from './catalog.ts';
|
||||
import { validateMediaFolderPath } from './folders.ts';
|
||||
import { conflict, forbidden } from '../lib/errors.ts';
|
||||
|
||||
interface Asset {id:string;ref:string}
|
||||
interface Item {id:string;parent_id:string|null}
|
||||
interface Edition {id:string;item_id:string}
|
||||
interface Part {id:string;asset_id:string;edition_id:string}
|
||||
|
||||
/** Explicit retirement of missing records. Never opens or deletes storage files. */
|
||||
export class MediaCleanup {
|
||||
constructor(private readonly db:MediaDatabase,private readonly libraries:MediaLibraries,private readonly catalog:MediaCatalog){}
|
||||
|
||||
preview(actor:MediaActor,libraryId:string,path:string){
|
||||
return this.db.transaction(()=>this.plan(actor,libraryId,path).summary);
|
||||
}
|
||||
|
||||
remove(actor:MediaActor,libraryId:string,path:string,revision:string){
|
||||
return this.db.transaction(()=>{
|
||||
const plan=this.plan(actor,libraryId,path);
|
||||
if(plan.summary.revision!==revision)throw conflict('目录或关联记录已变化,请重新预览清理范围','MEDIA_CLEANUP_CHANGED');
|
||||
const parts=JSON.stringify(plan.parts),items=JSON.stringify(plan.items),editions=JSON.stringify(plan.editions);
|
||||
// Remove dependent user state before parts; a failure rolls the entire operation back.
|
||||
for(const table of ['media_queue','media_progress','media_playback_sessions'])
|
||||
this.db.run(`DELETE FROM ${table} WHERE part_id IN (SELECT value FROM json_each(?))`,parts);
|
||||
const removedParts=new Set(plan.parts);
|
||||
for(const grant of this.db.all<{id:string;parts_json:string}>(`SELECT id,parts_json FROM media_background_grants
|
||||
WHERE EXISTS (SELECT 1 FROM json_each(parts_json) WHERE value IN (SELECT value FROM json_each(?)))`,parts)){
|
||||
const remaining=(JSON.parse(grant.parts_json) as string[]).filter(id=>!removedParts.has(id));
|
||||
this.db.run('UPDATE media_background_grants SET parts_json=?,revoked=CASE WHEN ?=0 THEN 1 ELSE revoked END WHERE id=?',JSON.stringify(remaining),remaining.length,grant.id);
|
||||
}
|
||||
this.db.run('DELETE FROM media_parts WHERE id IN (SELECT value FROM json_each(?))',parts);
|
||||
this.db.run('DELETE FROM media_editions WHERE id IN (SELECT value FROM json_each(?))',editions);
|
||||
// Children precede parents. Cascades remove only metadata/favorites of retired works.
|
||||
for(const id of plan.items)this.db.run('DELETE FROM media_items WHERE id=?',id);
|
||||
this.db.run('DELETE FROM media_scrape_job_items WHERE item_id IN (SELECT value FROM json_each(?))',items);
|
||||
this.db.run('DELETE FROM media_assets WHERE id IN (SELECT value FROM json_each(?))',JSON.stringify(plan.assets.map(asset=>asset.id)));
|
||||
if(plan.assets.length)this.catalog.refreshEmbeddedCovers(libraryId);
|
||||
let returnPath=path;
|
||||
while(returnPath){
|
||||
const prefix=returnPath+'/';
|
||||
if(this.db.get('SELECT 1 FROM media_assets WHERE library_id=? AND ref>=? AND ref<? LIMIT 1',libraryId,prefix,returnPath+'0'))break;
|
||||
returnPath=returnPath.split('/').slice(0,-1).join('/');
|
||||
}
|
||||
return {...plan.summary,returnPath};
|
||||
});
|
||||
}
|
||||
|
||||
private plan(actor:MediaActor,libraryId:string,path:string){
|
||||
if(actor.role!=='admin')throw forbidden('admin role required','ADMIN_REQUIRED');
|
||||
this.libraries.get(actor,libraryId);validateMediaFolderPath(path);
|
||||
if(this.db.get("SELECT 1 FROM media_scan_jobs WHERE library_id=? AND state IN ('queued','running')",libraryId))
|
||||
throw conflict('媒体库正在扫描,请等待完成后再清理','SCAN_RUNNING');
|
||||
const latest=this.db.get<{id:string;state:string}>(`SELECT id,state FROM media_scan_jobs WHERE library_id=? ORDER BY started_at DESC,rowid DESC LIMIT 1`,libraryId);
|
||||
if(latest?.state!=='complete')throw conflict('请先成功完成一次媒体库扫描,再清理失效资源','MEDIA_CLEANUP_SCAN_REQUIRED');
|
||||
if(this.db.get(`SELECT 1 FROM media_scrape_jobs j JOIN media_scrape_job_items r ON r.job_id=j.id
|
||||
JOIN media_items i ON i.id=r.item_id WHERE i.library_id=? AND j.state='running'`,libraryId))
|
||||
throw conflict('媒体库正在刮削,请等待完成后再清理','SCRAPE_RUNNING');
|
||||
const assets=this.db.all<Asset>(`SELECT id,ref FROM media_assets WHERE library_id=? AND available=0${path?' AND ref>=? AND ref<?':''} ORDER BY id`,libraryId,...(path?[path+'/',path+'0']:[]));
|
||||
const assetIds=new Set(assets.map(asset=>asset.id));
|
||||
const allItems=this.db.all<Item>('SELECT id,parent_id FROM media_items WHERE library_id=?',libraryId);
|
||||
const allEditions=this.db.all<Edition>('SELECT e.id,e.item_id FROM media_editions e JOIN media_items i ON i.id=e.item_id WHERE i.library_id=?',libraryId);
|
||||
const allParts=this.db.all<Part>('SELECT p.id,p.asset_id,p.edition_id FROM media_parts p JOIN media_assets a ON a.id=p.asset_id WHERE a.library_id=?',libraryId);
|
||||
const parts=allParts.filter(part=>assetIds.has(part.asset_id)).map(part=>part.id).sort();
|
||||
const touchedEditions=new Set(allParts.filter(part=>assetIds.has(part.asset_id)).map(part=>part.edition_id));
|
||||
const retainedEditions=new Set(allParts.filter(part=>!assetIds.has(part.asset_id)).map(part=>part.edition_id));
|
||||
const editions=allEditions.filter(edition=>touchedEditions.has(edition.id)&&!retainedEditions.has(edition.id)).map(edition=>edition.id).sort();
|
||||
const removedEditions=new Set(editions),byId=new Map(allItems.map(item=>[item.id,item]));
|
||||
const candidates=new Set<string>();
|
||||
for(const edition of allEditions.filter(edition=>removedEditions.has(edition.id))){
|
||||
let id:string|null=edition.item_id;
|
||||
while(id&&!candidates.has(id)){candidates.add(id);id=byId.get(id)?.parent_id??null;}
|
||||
}
|
||||
const hasEdition=new Set(allEditions.filter(edition=>!removedEditions.has(edition.id)).map(edition=>edition.item_id));
|
||||
const children=new Map<string,number>();
|
||||
for(const item of allItems)if(item.parent_id)children.set(item.parent_id,(children.get(item.parent_id)??0)+1);
|
||||
const pending=[...candidates].filter(id=>!hasEdition.has(id)&&!children.get(id)).sort(),items:string[]=[];
|
||||
for(let index=0;index<pending.length;index++){
|
||||
const id=pending[index]!;items.push(id);
|
||||
const parent=byId.get(id)?.parent_id;
|
||||
if(parent){children.set(parent,children.get(parent)!-1);if(candidates.has(parent)&&!hasEdition.has(parent)&&!children.get(parent))pending.push(parent);}
|
||||
}
|
||||
const count=(table:string,column:string,ids:string[])=>this.db.get<{n:number}>(`SELECT count(*) n FROM ${table} WHERE ${column} IN (SELECT value FROM json_each(?))`,JSON.stringify(ids))!.n;
|
||||
const counts={assets:assets.length,parts:parts.length,editions:editions.length,items:items.length,
|
||||
favorites:count('media_favorites','item_id',items),progress:count('media_progress','part_id',parts),queue:count('media_queue','part_id',parts)};
|
||||
const revision=createHash('sha256').update(JSON.stringify([libraryId,path,latest.id,assets,parts,editions,items,counts])).digest('hex');
|
||||
return {assets,parts,editions,items,summary:{libraryId,path,revision,...counts}};
|
||||
}
|
||||
}
|
||||
@@ -4,12 +4,16 @@ import type { MediaActor } from './libraries.ts';
|
||||
import { MediaLibraries } from './libraries.ts';
|
||||
import { MediaCatalog } from './catalog.ts';
|
||||
|
||||
export function validateMediaFolderPath(path:string){
|
||||
if(path.length>4000||path.includes('\\')||path.includes(':')||path.includes('\0')||(path&&path.split('/').some(part=>!part||part==='.'||part==='..')))throw badRequest('无效的媒体库内目录','MEDIA_FOLDER_PATH');
|
||||
}
|
||||
|
||||
/** Browse the published media snapshot, without exposing server paths or unrelated files. */
|
||||
export class MediaFolders {
|
||||
constructor(private readonly db:MediaDatabase,private readonly libraries:MediaLibraries,private readonly catalog:MediaCatalog){}
|
||||
list(actor:MediaActor,libraryId:string,path='',offset=0,limit=60){
|
||||
this.libraries.get(actor,libraryId);
|
||||
if(path.length>4000||path.includes('\\')||path.includes(':')||path.includes('\0')||(path&&path.split('/').some(part=>!part||part==='.'||part==='..')))throw badRequest('无效的媒体库内目录','MEDIA_FOLDER_PATH');
|
||||
validateMediaFolderPath(path);
|
||||
const prefix=path?path+'/':'';
|
||||
// Binary range ['path/', 'path0') covers exactly the descendants and uses (library_id,ref).
|
||||
// Keep the literal prefix check too; unlike LIKE, names containing % or _ are not patterns.
|
||||
|
||||
@@ -398,4 +398,18 @@ for(const isolated of [false,true])test(`media HTTP enforces authentication, gra
|
||||
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'),[]);
|
||||
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
import {test} from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {mkdtemp,mkdir,writeFile,rm,readFile} from 'node:fs/promises';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {Db} from '../src/db/index.ts';
|
||||
import {MediaLibraries} from '../src/media/libraries.ts';
|
||||
import {MediaScanner} from '../src/media/scanner.ts';
|
||||
import {MediaCleanup} from '../src/media/cleanup.ts';
|
||||
import {MediaPlayback} from '../src/media/playback.ts';
|
||||
import {MediaUserState} from '../src/media/user-state.ts';
|
||||
import {MediaBackgroundGrants} from '../src/media/background-grants.ts';
|
||||
import {MediaScraping} from '../src/media/scraping.ts';
|
||||
import {MediaScrapeJobs} from '../src/media/scrape-jobs.ts';
|
||||
|
||||
test('directory cleanup is scoped, transactional and removes only orphaned catalog/user records',async t=>{
|
||||
const root=await mkdtemp(join(tmpdir(),'media-cleanup-')),db=new Db(':memory:'),libraries=new MediaLibraries(db),actor={id:'admin',role:'admin'} as const;
|
||||
const scanner=new MediaScanner(db,libraries,async()=>({status:'ready',info:{format:'mp4',duration:20,streams:[],tags:{},chapters:[]}}));
|
||||
new MediaPlayback(db,libraries);new MediaUserState(db,libraries,scanner.catalog);new MediaBackgroundGrants(db,libraries);
|
||||
new MediaScrapeJobs(db,scanner.catalog,new MediaScraping(db,scanner.catalog,[]));
|
||||
const cleanup=new MediaCleanup(db,libraries,scanner.catalog);
|
||||
t.after(async()=>{await scanner.close();db.close();await rm(root,{recursive:true,force:true});});
|
||||
for(const id of ['admin','member'])db.run('INSERT INTO users(id,username,password_hash,role,created_at,updated_at) VALUES(?,?,?,?,0,0)',id,id,'unused',id==='admin'?'admin':'member');
|
||||
const lost='🎬100%_/Show/Season 01/Show S01E01.mp4',kept='🎬100%_/Show/Season 02/Show S02E01.mp4',sibling='🎬100%_0/other.mp4';
|
||||
for(const ref of [lost,kept,sibling]){await mkdir(join(root,ref,'..'),{recursive:true});await writeFile(join(root,ref),'fixture');}
|
||||
const library=await libraries.create(actor,{name:'video',kind:'video',root,access:'all'});
|
||||
assert.throws(()=>cleanup.preview(actor,library.id,''),{code:'MEDIA_CLEANUP_SCAN_REQUIRED'});
|
||||
const scan=async()=>{scanner.start(actor,library.id);await scanner.wait(library.id);};await scan();
|
||||
const part=db.get<{id:string;item_id:string;parent_id:string}>(`SELECT p.id,e.item_id,i.parent_id FROM media_parts p JOIN media_assets a ON a.id=p.asset_id JOIN media_editions e ON e.id=p.edition_id JOIN media_items i ON i.id=e.item_id WHERE a.ref=?`,lost)!;
|
||||
const keptPart=db.get<{id:string}>(`SELECT p.id FROM media_parts p JOIN media_assets a ON a.id=p.asset_id WHERE a.ref=?`,kept)!.id;
|
||||
for(const id of ['admin','member']){
|
||||
db.run('INSERT INTO media_favorites VALUES(?,?,0)',id,part.item_id);
|
||||
db.run('INSERT INTO media_queue VALUES(?,?,?,0,0)',id,id,part.id);
|
||||
db.run('INSERT INTO media_progress VALUES(?,?,5,0,1,?,0)',id,part.id,'session');
|
||||
}
|
||||
db.run("INSERT INTO media_playback_sessions(id,user_id,part_id,token_hash,expires_at,auth_version,created_at) VALUES('session','admin',?,'hash',9999999999999,0,0)",part.id);
|
||||
db.run("INSERT INTO media_background_sessions VALUES('session','grant',9999999999999)");
|
||||
db.run("INSERT INTO media_background_grants(id,user_id,token_hash,auth_version,expires_at,parts_json) VALUES('grant','admin','grant-hash',0,9999999999999,?)",JSON.stringify([part.id,keptPart]));
|
||||
await rm(join(root,lost));await rm(join(root,sibling));await scan();
|
||||
assert.throws(()=>cleanup.preview({id:'member',role:'member'},library.id,''),{code:'ADMIN_REQUIRED'});
|
||||
for(const path of ['../','/','🎬100%_/../','x\\y','x//y','C:/x'])assert.throws(()=>cleanup.preview(actor,library.id,path),{code:'MEDIA_FOLDER_PATH'});
|
||||
const preview=cleanup.preview(actor,library.id,'🎬100%_/Show/Season 01');
|
||||
assert.equal(preview.assets,1);assert.equal(preview.items,2);assert.equal(preview.favorites,2);assert.equal(preview.progress,2);assert.equal(preview.queue,2);
|
||||
db.run("UPDATE media_assets SET available=1 WHERE ref=?",lost);
|
||||
assert.throws(()=>cleanup.remove(actor,library.id,preview.path,preview.revision),{code:'MEDIA_CLEANUP_CHANGED'});
|
||||
db.run("UPDATE media_assets SET available=0 WHERE ref=?",lost);
|
||||
// Simulate a late database failure to verify dependent state is also rolled back.
|
||||
db.run("CREATE TRIGGER reject_cleanup BEFORE DELETE ON media_assets BEGIN SELECT RAISE(ABORT,'fixture failure'); END");
|
||||
assert.throws(()=>cleanup.remove(actor,library.id,preview.path,preview.revision),/fixture failure/);
|
||||
assert.equal(db.get<{n:number}>('SELECT count(*) n FROM media_queue')!.n,2);
|
||||
assert.ok(db.get('SELECT id FROM media_playback_sessions WHERE id=?','session'));
|
||||
db.run('DROP TRIGGER reject_cleanup');
|
||||
const result=cleanup.remove(actor,library.id,preview.path,preview.revision);
|
||||
assert.equal(result.returnPath,'🎬100%_/Show');
|
||||
assert.equal(db.get('SELECT id FROM media_items WHERE id=?',part.item_id),undefined);
|
||||
assert.equal(db.get('SELECT id FROM media_items WHERE id=?',part.parent_id),undefined);
|
||||
assert.equal(db.get<{n:number}>("SELECT count(*) n FROM media_items WHERE kind='series'")!.n,1);
|
||||
assert.ok(db.get('SELECT id FROM media_assets WHERE ref=?',sibling),'prefix sibling must survive');
|
||||
for(const table of ['media_progress','media_queue','media_playback_sessions','media_background_sessions','media_favorites'])assert.equal(db.get<{n:number}>(`SELECT count(*) n FROM ${table}`)!.n,0,table);
|
||||
assert.equal(db.get<{parts_json:string}>("SELECT parts_json FROM media_background_grants WHERE id='grant'")!.parts_json,JSON.stringify([keptPart]));
|
||||
assert.equal(await readFile(join(root,kept),'utf8'),'fixture');
|
||||
assert.deepEqual(db.all('PRAGMA foreign_key_check'),[]);
|
||||
assert.throws(()=>cleanup.remove(actor,library.id,preview.path,preview.revision),{code:'MEDIA_CLEANUP_CHANGED'});
|
||||
const empty=cleanup.preview(actor,library.id,preview.path);assert.equal(empty.assets,0);
|
||||
assert.equal(cleanup.remove(actor,library.id,preview.path,empty.revision).assets,0);
|
||||
db.run("UPDATE media_scan_jobs SET state='failed' WHERE id=(SELECT id FROM media_scan_jobs ORDER BY rowid DESC LIMIT 1)");
|
||||
assert.throws(()=>cleanup.preview(actor,library.id,''),{code:'MEDIA_CLEANUP_SCAN_REQUIRED'});
|
||||
db.run("UPDATE media_scan_jobs SET state='running' WHERE id=(SELECT id FROM media_scan_jobs ORDER BY rowid DESC LIMIT 1)");
|
||||
assert.throws(()=>cleanup.preview(actor,library.id,''),{code:'SCAN_RUNNING'});
|
||||
});
|
||||
|
||||
test('cleanup retains a shared audiobook edition, other libraries and records outside the literal directory',async t=>{
|
||||
const root=await mkdtemp(join(tmpdir(),'media-cleanup-shared-')),db=new Db(':memory:'),libraries=new MediaLibraries(db),actor={id:'admin',role:'admin'} as const;
|
||||
const scanner=new MediaScanner(db,libraries,async()=>({status:'unavailable',info:null}));
|
||||
new MediaPlayback(db,libraries);new MediaUserState(db,libraries,scanner.catalog);new MediaBackgroundGrants(db,libraries);
|
||||
new MediaScrapeJobs(db,scanner.catalog,new MediaScraping(db,scanner.catalog,[]));
|
||||
const cleanup=new MediaCleanup(db,libraries,scanner.catalog);
|
||||
t.after(async()=>{await scanner.close();db.close();await rm(root,{recursive:true,force:true});});
|
||||
db.run("INSERT INTO users(id,username,password_hash,role,created_at,updated_at) VALUES('admin','admin','unused','admin',0,0)");
|
||||
for(const ref of ['100%_/one.mp3','100%_/two.mp3','100xx/other.mp3']){await mkdir(join(root,ref,'..'),{recursive:true});await writeFile(join(root,ref),'fixture');}
|
||||
const library=await libraries.create(actor,{name:'books',kind:'audiobook',root,access:'all'});
|
||||
const other=await libraries.create(actor,{name:'other',kind:'audiobook',root,access:'all'});
|
||||
const scan=async(id:string)=>{scanner.start(actor,id);await scanner.wait(id);};await scan(library.id);await scan(other.id);
|
||||
const original=scanner.catalog.list(actor,library.id,{kind:'audiobook'}).items.find(item=>item.title==='100%_')!;
|
||||
const detail=scanner.catalog.detail(actor,original.id);assert.equal(detail.editions[0]!.parts.length,2);
|
||||
scanner.catalog.override(actor,original.id,{title:'人工书名'});
|
||||
db.run('INSERT INTO media_favorites VALUES(?,?,0)',actor.id,original.id);
|
||||
const otherCount=db.get<{n:number}>('SELECT count(*) n FROM media_assets WHERE library_id=?',other.id)!.n;
|
||||
await rm(join(root,'100%_/one.mp3'));await rm(join(root,'100xx/other.mp3'));await scan(library.id);
|
||||
const preview=cleanup.preview(actor,library.id,'100%_');assert.equal(preview.assets,1);assert.equal(preview.editions,0);assert.equal(preview.items,0);
|
||||
db.run("INSERT INTO media_scrape_jobs VALUES('busy','admin','tmdb','running',0)");
|
||||
db.run("INSERT INTO media_scrape_job_items VALUES('busy',?,0,'running',NULL)",original.id);
|
||||
assert.throws(()=>cleanup.remove(actor,library.id,'100%_',preview.revision),{code:'SCRAPE_RUNNING'});
|
||||
db.run("UPDATE media_scrape_jobs SET state='complete' WHERE id='busy'");
|
||||
cleanup.remove(actor,library.id,'100%_',preview.revision);
|
||||
assert.equal(scanner.catalog.detail(actor,original.id).editions[0]!.parts.length,1);
|
||||
assert.equal(scanner.catalog.detail(actor,original.id).title,'人工书名');
|
||||
assert.equal(db.get<{n:number}>('SELECT count(*) n FROM media_assets WHERE library_id=?',other.id)!.n,otherCount);
|
||||
assert.ok(db.get('SELECT item_id FROM media_favorites WHERE item_id=?',original.id));
|
||||
assert.ok(db.get("SELECT id FROM media_assets WHERE library_id=? AND ref='100xx/other.mp3'",library.id));
|
||||
await writeFile(join(root,'100%_/one.mp3'),'restored');await scan(library.id);
|
||||
assert.equal(scanner.catalog.detail(actor,original.id).editions[0]!.parts.length,2,'restored file can be imported again');
|
||||
const entire=cleanup.preview(actor,library.id,'');assert.equal(entire.assets,1);
|
||||
cleanup.remove(actor,library.id,'',entire.revision);
|
||||
assert.equal(db.get<{n:number}>('SELECT count(*) n FROM media_assets WHERE library_id=?',library.id)!.n,2);
|
||||
assert.deepEqual(db.all('PRAGMA foreign_key_check'),[]);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import {useEffect,useRef,useState} from '../ui/vendor/preact.ts';
|
||||
import {Trash2} from 'lucide-preact';
|
||||
import {FloatingConfirm} from '../ui/floating-confirm.tsx';
|
||||
import type {MediaApi} from './api.ts';
|
||||
|
||||
export interface CleanupPreview {
|
||||
path:string;revision:string;assets:number;parts:number;editions:number;items:number;favorites:number;progress:number;queue:number;
|
||||
}
|
||||
export interface CleanupResult extends CleanupPreview {returnPath:string}
|
||||
|
||||
export function FolderCleanup({api,libraryId,path,disabled,onBusy,onCleaned}:{
|
||||
api:MediaApi;libraryId:string;path:string;disabled:boolean;onBusy:(busy:boolean)=>void;onCleaned:(result:CleanupResult)=>void;
|
||||
}){
|
||||
const [preview,setPreview]=useState<CleanupPreview|null>(null),[message,setMessage]=useState(''),[error,setError]=useState('');
|
||||
const request=useRef<AbortController|null>(null);
|
||||
useEffect(()=>()=>{request.current?.abort();},[]);
|
||||
async function run(remove=false){
|
||||
if(disabled||request.current)return;
|
||||
const abort=new AbortController();request.current=abort;onBusy(true);setError('');setMessage('');setPreview(null);
|
||||
try{
|
||||
const base='libraries/'+encodeURIComponent(libraryId)+'/missing-resources';
|
||||
if(remove&&preview){
|
||||
const result=await api.request<CleanupResult>(base+'/cleanup','POST',{path,revision:preview.revision},abort.signal);
|
||||
if(!abort.signal.aborted)onCleaned(result);
|
||||
}else{
|
||||
const result=await api.request<CleanupPreview>(base+'?'+new URLSearchParams({path}),'GET',undefined,abort.signal);
|
||||
if(!abort.signal.aborted){if(result.assets)setPreview(result);else setMessage('当前目录及子目录没有已标记缺失的资源。');}
|
||||
}
|
||||
}catch(error){if(!abort.signal.aborted)setError((error instanceof Error?error.message:'清理失败')+(remove?'。请重新预览,核对当前记录后再操作。':''));}
|
||||
finally{if(!abort.signal.aborted){request.current=null;onBusy(false);}}
|
||||
}
|
||||
return <>
|
||||
<div className="media-toolbar"><span>当前目录及子目录</span><button disabled={disabled} onClick={()=>void run()}><Trash2 size={16} aria-hidden="true"/>清理失效资源</button></div>
|
||||
{message&&<p role="status">{message}</p>}{error&&<p className="media-error" role="alert">{error}</p>}
|
||||
{preview&&<FloatingConfirm title="清理失效资源" confirmText="确认清理" cancelText="取消" onCancel={()=>setPreview(null)} onConfirm={()=>void run(true)} text={`清理「${path||'库内根目录'}」及其子目录中的 ${preview.assets} 个缺失文件记录。将移除 ${preview.parts} 个播放片段、${preview.editions} 个空版本和 ${preview.items} 个空作品/分类,以及所有用户关联的 ${preview.favorites} 条收藏、${preview.progress} 条播放进度和 ${preview.queue} 条队列记录。不会删除原始文件或仍有资源的作品。此操作不可撤销;如目录暂未挂载,请取消并在恢复后重新扫描。`}/>}
|
||||
</>;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import {FolderCleanup} from './folder-cleanup.tsx';
|
||||
import {MediaSelect} from './select.tsx';
|
||||
import {useEffect,useRef,useState} from '../ui/vendor/preact.ts';
|
||||
import {Folder,FolderOpen,File,ChevronRight,ChevronLeft,ArrowUpRight} from 'lucide-preact';
|
||||
@@ -18,11 +19,12 @@ function FileWork({api,item,busy,selected,onSelect,positions,onPosition,onPlay,o
|
||||
{edition&&<EditionDetails key={edition.id} api={api} item={item} edition={edition} busy={busy} showTools={false} position={positions[edition.id]??{query:'',page:0}} onPositionChange={position=>onPosition(edition.id,position)} onPlay={onPlay} onQueue={onQueue} onRefresh={onRefresh} onChooseVersion={onDetail}/>}
|
||||
</section>;
|
||||
}
|
||||
export function MediaFolders({api,libraryId,initialLocation,onLocationChange,onBack,onPlay,onQueue,onDetail}:{api:MediaApi;libraryId:string;initialLocation?:FolderLocation|undefined;onLocationChange?:(location:FolderLocation)=>void;onBack?:()=>void;onPlay:(parts:Part[],index:number,title:string)=>Promise<void>;onQueue:(ids:string[])=>Promise<void>;onDetail:(id:string,location:FolderLocation)=>void}){
|
||||
export function MediaFolders({api,admin=false,libraryId,initialLocation,onLocationChange,onBack,onPlay,onQueue,onDetail}:{api:MediaApi;admin?:boolean;libraryId:string;initialLocation?:FolderLocation|undefined;onLocationChange?:(location:FolderLocation)=>void;onBack?:()=>void;onPlay:(parts:Part[],index:number,title:string)=>Promise<void>;onQueue:(ids:string[])=>Promise<void>;onDetail:(id:string,location:FolderLocation)=>void}){
|
||||
const [path,setPath]=useState(initialLocation?.path??''),[offset,setOffset]=useState(initialLocation?.offset??0),[assetId,setAssetId]=useState<string|null>(initialLocation?.assetId??null),[retry,setRetry]=useState(0);
|
||||
const [editions,setEditions]=useState(initialLocation?.editions??{}),[chapters,setChapters]=useState(initialLocation?.chapters??{});
|
||||
const [listing,setListing]=useState<Listing|null>(null),[file,setFile]=useState<FileDetail|null>(null),[error,setError]=useState(''),[busy,setBusy]=useState(false),[notice,setNotice]=useState('');
|
||||
const [loading,setLoading]=useState(true),[cause,setCause]=useState<unknown>(),[actionError,setActionError]=useState('');
|
||||
const [cleanupNotice,setCleanupNotice]=useState('');
|
||||
const generation=useRef(0);
|
||||
useEffect(()=>{
|
||||
const abort=new AbortController();++generation.current;setLoading(true);setListing(null);setFile(null);setError('');setCause(undefined);setActionError('');setNotice('');setBusy(false);
|
||||
@@ -33,7 +35,7 @@ export function MediaFolders({api,libraryId,initialLocation,onLocationChange,onB
|
||||
return ()=>{abort.abort();++generation.current;};
|
||||
},[api,libraryId,path,offset,assetId,retry]);
|
||||
useEffect(()=>{onLocationChange?.({path,offset,assetId,editions,chapters});},[path,offset,assetId]);
|
||||
function open(next:string){setPath(next);setOffset(0);setAssetId(null);}
|
||||
function open(next:string){setCleanupNotice('');setPath(next);setOffset(0);setAssetId(null);}
|
||||
async function action(run:()=>Promise<void>,queue=false){if(busy)return;const current=generation.current;setBusy(true);setActionError('');setNotice('');try{await run();if(current===generation.current)setNotice(queue?'已加入队列':'');}catch(error){if(current===generation.current)setActionError((error instanceof ApiError&&error.kind==='offline'?'无法连接服务器':error instanceof Error?error.message:'操作失败')+(queue?'。加入结果尚未确认,请先到待播队列核对,再决定是否重新添加。':'。可以重新选择章节播放。'));}finally{if(current===generation.current)setBusy(false);}}
|
||||
const segments=path?path.split('/'):[];
|
||||
return <section className="media-folders" aria-label="影音文件夹">
|
||||
@@ -41,7 +43,8 @@ export function MediaFolders({api,libraryId,initialLocation,onLocationChange,onB
|
||||
{(path||assetId)&&<button className="media-folder-back" disabled={busy} aria-label={'← '+(assetId?'返回文件列表':'上级目录')} onClick={()=>onBack?onBack():assetId?setAssetId(null):open(segments.slice(0,-1).join('/'))}><ChevronLeft size={16} aria-hidden="true"/>{assetId?'返回文件列表':'上级目录'}</button>}
|
||||
{error&&<MediaScreenError fullPage error={cause} message={error} busy={loading} retryLabel="重新加载" onRetry={()=>setRetry(value=>value+1)}/>}{actionError&&<div className="media-error" role="alert">{actionError}</div>}{notice&&<p role="status">{notice}</p>}
|
||||
{loading&&<MediaLoading layout={assetId?'tracks':'list'} square label={assetId?'正在读取文件与章节…':'正在读取目录…'}/>}
|
||||
{listing&&<>{listing.items.map(entry=><div className="media-row media-folder-row" key={entry.path}><button onClick={()=>entry.kind==='folder'?open(entry.path):setAssetId(entry.assetId)}>{entry.kind==='folder'?<Folder size={21} strokeWidth={1.5} aria-hidden="true"/>:<File size={21} strokeWidth={1.5} aria-hidden="true"/>}<span><strong>{entry.name}</strong><small>{entry.kind==='folder'?`${entry.files} 个媒体文件 · ${entry.availableFiles} 个可用`:`${(entry.size/1024/1024).toFixed(1)} MB · ${entry.availableFiles?'文件可用':'文件缺失'}`}</small></span><ChevronRight size={17} aria-hidden="true"/></button></div>)}{!listing.total&&<p className="media-empty">暂无已扫描媒体文件,请先扫描媒体库。</p>}{listing.total>60&&<nav className="media-toolbar" aria-label="文件分页"><button disabled={!offset} onClick={()=>setOffset(Math.max(0,offset-60))}>上一页</button><span>{Math.floor(offset/60)+1} / {Math.ceil(listing.total/60)}</span><button disabled={offset+60>=listing.total} onClick={()=>setOffset(offset+60)}>下一页</button></nav>}<p className="media-folder-note">按最近扫描结果显示媒体文件;不包含空目录及其他文件。原始目录只读。</p></>}
|
||||
{cleanupNotice&&<p role="status">{cleanupNotice}</p>}
|
||||
{listing&&<>{admin&&<FolderCleanup key={libraryId+'-'+path} api={api} libraryId={libraryId} path={path} disabled={busy||loading} onBusy={setBusy} onCleaned={result=>{setCleanupNotice('已清理 '+result.assets+' 个失效资源记录'+(result.returnPath!==path?',已返回上级有效目录。':'。'));setPath(result.returnPath);setOffset(0);setRetry(value=>value+1);}}/>}{listing.items.map(entry=><div className="media-row media-folder-row" key={entry.path}><button disabled={busy} onClick={()=>entry.kind==='folder'?open(entry.path):setAssetId(entry.assetId)}>{entry.kind==='folder'?<Folder size={21} strokeWidth={1.5} aria-hidden="true"/>:<File size={21} strokeWidth={1.5} aria-hidden="true"/>}<span><strong>{entry.name}</strong><small>{entry.kind==='folder'?`${entry.files} 个媒体文件 · ${entry.availableFiles} 个可用`:`${(entry.size/1024/1024).toFixed(1)} MB · ${entry.availableFiles?'文件可用':'文件缺失'}`}</small></span><ChevronRight size={17} aria-hidden="true"/></button></div>)}{!listing.total&&<p className="media-empty">暂无已扫描媒体文件,请先扫描媒体库。</p>}{listing.total>60&&<nav className="media-toolbar" aria-label="文件分页"><button disabled={busy||!offset} onClick={()=>setOffset(Math.max(0,offset-60))}>上一页</button><span>{Math.floor(offset/60)+1} / {Math.ceil(listing.total/60)}</span><button disabled={busy||offset+60>=listing.total} onClick={()=>setOffset(offset+60)}>下一页</button></nav>}<p className="media-folder-note">按最近扫描结果显示媒体文件;不包含空目录及其他文件。原始目录只读。</p></>}
|
||||
{file&&<><header className="media-folder-file-heading"><File size={27} strokeWidth={1.4} aria-hidden="true"/><div><h2>{file.path.split('/').at(-1)}</h2><small>{file.available?'文件可用':'文件已缺失'} · {file.items.length} 部关联作品</small></div></header>
|
||||
<div className="media-folder-file-layout"><div>{file.items.map(item=><FileWork key={file.assetId+'-'+item.id} api={api} item={item} busy={busy} selected={editions[item.id]??''} onSelect={id=>setEditions({...editions,[item.id]:id})} positions={chapters} onPosition={(id,position)=>setChapters({...chapters,[id]:position})} onDetail={()=>onDetail(item.id,{path,offset,assetId,editions,chapters})} onRefresh={()=>setRetry(value=>value+1)} onPlay={(parts,index)=>void action(()=>onPlay(parts,index,item.title))} onQueue={ids=>void action(()=>onQueue(ids),true)}/>)}{!file.items.length&&<div className="media-folder-unlinked"><FolderOpen size={30} strokeWidth={1.4} aria-hidden="true"/><h3>暂未关联作品</h3><p>{file.available?'请管理员重新扫描媒体库,读取文件资料。':'恢复文件并重新扫描后可播放。'}</p></div>}</div><aside className="media-folder-file-resources"><h2>文件信息</h2><ResourceInfo key={file.assetId} api={api} assetId={file.assetId}/><p className="media-folder-note">仅显示此文件关联的版本片段。完整章节和其他文件中的版本,请打开作品详情。原始文件只读。</p></aside></div></>}
|
||||
</section>;
|
||||
|
||||
@@ -533,7 +533,7 @@ export class MediaScreen {
|
||||
<details className={this.kind==='track'?'media-browse-filters media-track-filters':'media-browse-filters'}><summary aria-label="筛选与排序" title="筛选与排序"><SlidersHorizontal size={18} aria-hidden="true"/>{(this.trackSort!=='default'||this.kind==='track'&&(this.trackFilters.artist||this.trackFilters.album))&&<i aria-label="已应用筛选"/>}</summary><div className="media-browse-filter-panel"><label>{this.kind==='track'?'曲目排序':'排序'}<MediaSelect aria-label={this.kind==='track'?'曲目排序':'内容排序'} value={this.trackSort} disabled={this.busy} onChange={event=>{this.trackSort=event.currentTarget.value;this.offset=0;void this.run(()=>this.load());}}><option value="default">默认顺序</option><option value="title-asc">名称升序</option><option value="title-desc">名称降序</option></MediaSelect></label>
|
||||
{this.kind==='track'&&<form className="media-form" onSubmit={event=>{event.preventDefault();const data=new FormData(event.currentTarget);this.trackFilters={artist:String(data.get('artist')||'').trim(),album:String(data.get('album')||'').trim()};this.offset=0;void this.run(()=>this.load());}}><label>艺人包含<input name="artist" maxLength={200} defaultValue={this.trackFilters.artist} disabled={this.busy}/></label><label>专辑包含<input name="album" maxLength={200} defaultValue={this.trackFilters.album} disabled={this.busy}/></label><div><button disabled={this.busy}>应用筛选</button><button type="button" disabled={this.busy} onClick={event=>{this.trackFilters={artist:'',album:''};const form=event.currentTarget.form;if(form){(form.elements.namedItem('artist') as HTMLInputElement).value='';(form.elements.namedItem('album') as HTMLInputElement).value='';}this.offset=0;void this.run(()=>this.load());}}>清除筛选</button></div></form>}</div></details>
|
||||
</>}</div></div>}
|
||||
{this.foldersOpen&&this.libraryId?<MediaFolders {...(this.routing?.back?{onBack:this.routing.back}:{})} key={this.location?routeHash(this.location):this.libraryId} onLocationChange={location=>{this.folderLocation=location;this.syncLocation(location.assetId?'file':'folders',{library:this.libraryId,path:location.path,offset:String(location.offset),asset:location.assetId??''});}} api={this.api} libraryId={this.libraryId} initialLocation={this.folderLocation} onDetail={(id,location)=>this.openFolderDetail(id,location)} onPlay={(parts,index,title)=>this.player.play(parts.map(part=>({part,title:title+' · '+part.title,video:this.channel==='video'})),index)} onQueue={async ids=>{await this.api.request('queue','POST',{partIds:ids});}}/>:this.foldersOpen?<section aria-label="选择文件夹媒体库"><p>选择要浏览的媒体库</p>{this.libraries.map(l=><div className="media-row" key={l.id}><button onClick={()=>{this.libraryId=l.id;if(this.routing?.navigateRoute){this.syncLocation('folders',{library:l.id});return;}this.draw();}}>{l.name}</button></div>)}</section>:this.kind==='narrator'&&this.libraries.length>0?<Narrators {...(this.routing?.back?{onBack:this.routing.back}:{})} key={this.location?routeHash(this.location):this.libraryId} {...(this.narratorLocation?{initialLocation:this.narratorLocation}:{})} onLocationChange={location=>{this.narratorLocation=location;this.syncLocation(location.workId?'narrator-work':location.name?'narrator':'narrators',{library:location.libraryId,narrator:location.name??'',q:location.search,offset:String(location.offset),work:location.workId,edition:location.editionId});}} libraryControl={this.librarySelector()} showLibraryName={this.preferences.showLibraryName} api={this.api} libraryId={this.libraryId} onDetail={id=>this.openNarratorDetail(id)} onPlay={(parts,index,title)=>this.player.play(parts.map(part=>({part,title:title+' · '+part.title,video:false})),index)} onQueue={async ids=>{await this.api.request('queue','POST',{partIds:ids});}}/>:<>{this.catalogLoading?<MediaLoading layout={this.kind==='track'?'list':'grid'} square={this.channel==='music'}/>:this.catalogFailed?null:this.items.length?(this.channel=== 'music' &&this.kind=== 'track' ?this.trackList(this.items):this.grid(this.items)):!this.busy&&!this.error&&this.emptyLibrary()}
|
||||
{this.foldersOpen&&this.libraryId?<MediaFolders admin={this.admin} {...(this.routing?.back?{onBack:this.routing.back}:{})} key={this.location?routeHash(this.location):this.libraryId} onLocationChange={location=>{this.folderLocation=location;this.syncLocation(location.assetId?'file':'folders',{library:this.libraryId,path:location.path,offset:String(location.offset),asset:location.assetId??''});}} api={this.api} libraryId={this.libraryId} initialLocation={this.folderLocation} onDetail={(id,location)=>this.openFolderDetail(id,location)} onPlay={(parts,index,title)=>this.player.play(parts.map(part=>({part,title:title+' · '+part.title,video:this.channel==='video'})),index)} onQueue={async ids=>{await this.api.request('queue','POST',{partIds:ids});}}/>:this.foldersOpen?<section aria-label="选择文件夹媒体库"><p>选择要浏览的媒体库</p>{this.libraries.map(l=><div className="media-row" key={l.id}><button onClick={()=>{this.libraryId=l.id;if(this.routing?.navigateRoute){this.syncLocation('folders',{library:l.id});return;}this.draw();}}>{l.name}</button></div>)}</section>:this.kind==='narrator'&&this.libraries.length>0?<Narrators {...(this.routing?.back?{onBack:this.routing.back}:{})} key={this.location?routeHash(this.location):this.libraryId} {...(this.narratorLocation?{initialLocation:this.narratorLocation}:{})} onLocationChange={location=>{this.narratorLocation=location;this.syncLocation(location.workId?'narrator-work':location.name?'narrator':'narrators',{library:location.libraryId,narrator:location.name??'',q:location.search,offset:String(location.offset),work:location.workId,edition:location.editionId});}} libraryControl={this.librarySelector()} showLibraryName={this.preferences.showLibraryName} api={this.api} libraryId={this.libraryId} onDetail={id=>this.openNarratorDetail(id)} onPlay={(parts,index,title)=>this.player.play(parts.map(part=>({part,title:title+' · '+part.title,video:false})),index)} onQueue={async ids=>{await this.api.request('queue','POST',{partIds:ids});}}/>:<>{this.catalogLoading?<MediaLoading layout={this.kind==='track'?'list':'grid'} square={this.channel==='music'}/>:this.catalogFailed?null:this.items.length?(this.channel=== 'music' &&this.kind=== 'track' ?this.trackList(this.items):this.grid(this.items)):!this.busy&&!this.error&&this.emptyLibrary()}
|
||||
{!this.catalogLoading&&!this.catalogFailed&&this.total>60&&<div className="media-toolbar"><button disabled={this.offset===0||this.busy} onClick={()=>{this.offset=Math.max(0,this.offset-60);void this.run(()=>this.load());}}>上一页</button><span>{Math.floor(this.offset/60)+1} / {Math.ceil(this.total/60)}</span><button disabled={this.offset+60>=this.total||this.busy} onClick={()=>{this.offset+=60;void this.run(()=>this.load());}}>下一页</button></div>}
|
||||
</>}
|
||||
</>;}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
// @vitest-environment jsdom
|
||||
import {afterEach,expect,it,vi} from 'vitest';
|
||||
import {act} from 'preact/test-utils';
|
||||
import {render} from '../src/ui/vendor/preact.ts';
|
||||
import {FolderCleanup} from '../src/media/folder-cleanup.tsx';
|
||||
import type {MediaApi} from '../src/media/api.ts';
|
||||
vi.mock('../src/ui/floating-confirm.tsx',()=>({FloatingConfirm:({text,onConfirm,onCancel}:any)=><div role="dialog"><p>{text}</p><button onClick={onConfirm}>确认清理</button><button onClick={onCancel}>取消</button></div>}));
|
||||
const root=document.createElement('div');document.body.append(root);
|
||||
afterEach(()=>{act(()=>render(null,root));vi.clearAllMocks();});
|
||||
const click=async(text:string)=>act(async()=>Array.from(root.querySelectorAll('button')).find(b=>b.textContent?.includes(text))!.click());
|
||||
const preview={path:'Show/Season 01',revision:'abc',assets:2,parts:2,editions:2,items:3,favorites:1,progress:2,queue:0};
|
||||
it('previews the exact directory and requires confirmation before cleanup',async()=>{
|
||||
const request=vi.fn().mockResolvedValueOnce(preview).mockResolvedValueOnce({...preview,returnPath:'Show'}),onCleaned=vi.fn();
|
||||
act(()=>render(<FolderCleanup api={{request} as unknown as MediaApi} libraryId="lib" path={preview.path} disabled={false} onBusy={vi.fn()} onCleaned={onCleaned}/>,root));
|
||||
await click('清理失效资源');await vi.waitFor(()=>expect(root.querySelector('[role=dialog]')).not.toBeNull());
|
||||
expect(request).toHaveBeenCalledTimes(1);expect(request.mock.calls[0]![0]).toContain('path=Show%2FSeason+01');
|
||||
expect(root.textContent).toContain('所有用户');expect(root.textContent).toContain('不会删除原始文件');
|
||||
await click('确认清理');await vi.waitFor(()=>expect(onCleaned).toHaveBeenCalledWith({...preview,returnPath:'Show'}));
|
||||
expect(request.mock.calls[1]!.slice(0,3)).toEqual(['libraries/lib/missing-resources/cleanup','POST',{path:preview.path,revision:'abc'}]);
|
||||
});
|
||||
it('cancel leaves records untouched; an empty preview does not offer deletion',async()=>{
|
||||
const request=vi.fn().mockResolvedValueOnce(preview).mockResolvedValueOnce({...preview,assets:0});
|
||||
act(()=>render(<FolderCleanup api={{request} as unknown as MediaApi} libraryId="lib" path="" disabled={false} onBusy={vi.fn()} onCleaned={vi.fn()}/>,root));
|
||||
await click('清理失效资源');await click('取消');expect(request).toHaveBeenCalledTimes(1);
|
||||
await click('清理失效资源');expect(root.textContent).toContain('没有已标记缺失');expect(root.querySelector('[role=dialog]')).toBeNull();
|
||||
});
|
||||
it('failed or stale cleanup requires a fresh preview and never reports success',async()=>{
|
||||
const request=vi.fn().mockResolvedValueOnce(preview).mockRejectedValueOnce(new Error('目录已变化')),onCleaned=vi.fn();
|
||||
act(()=>render(<FolderCleanup api={{request} as unknown as MediaApi} libraryId="lib" path="Show" disabled={false} onBusy={vi.fn()} onCleaned={onCleaned}/>,root));
|
||||
await click('清理失效资源');await click('确认清理');expect(root.textContent).toContain('重新预览');expect(onCleaned).not.toHaveBeenCalled();expect(root.querySelector('[role=dialog]')).toBeNull();
|
||||
});
|
||||
@@ -8,6 +8,15 @@ import {ApiError} from '../src/api/errors.ts';
|
||||
const root=document.createElement('div');document.body.append(root);
|
||||
afterEach(()=>act(()=>render(null,root)));
|
||||
const button=(text:string)=>Array.from(root.querySelectorAll('button')).find(button=>button.textContent?.includes(text))!;
|
||||
it('only administrators see directory cleanup',async()=>{
|
||||
const request=vi.fn().mockResolvedValue({total:0,items:[]});
|
||||
const props={api:{request} as unknown as MediaApi,libraryId:'lib',onPlay:vi.fn(),onQueue:vi.fn(),onDetail:vi.fn()};
|
||||
await act(async()=>render(<MediaFolders {...props}/>,root));
|
||||
await vi.waitFor(()=>expect(root.textContent).toContain('暂无已扫描'));
|
||||
expect(button('清理失效资源')).toBeUndefined();
|
||||
await act(async()=>render(<MediaFolders {...props} admin/>,root));
|
||||
expect(button('清理失效资源')).toBeDefined();
|
||||
});
|
||||
it('opens a folder and file, plays its chapters and returns to the same directory',async()=>{
|
||||
const parts=[{id:'p',assetId:'a',title:'第一章',start:0,end:10,available:true}];
|
||||
const request=vi.fn(async(path:string)=>path.startsWith('assets/')?{assetId:'a',path:'Book/one.m4b',available:true,items:[{id:'item',title:'作品',editions:[{id:'e',label:'版本',parts}]}]}:new URLSearchParams(path.split('?')[1]).get('path')==='Book'?{total:1,items:[{name:'one.m4b',path:'Book/one.m4b',kind:'file',assetId:'a',files:1,availableFiles:1,size:100}]}:{total:1,items:[{name:'Book',path:'Book',kind:'folder',files:1,availableFiles:1,size:100}]});
|
||||
|
||||
Reference in new issue
Block a user