From f583b32e46f53eefe74ec8856cafc3477986bfa2 Mon Sep 17 00:00:00 2001 From: hectorqin Date: Mon, 28 Sep 2026 17:24:04 +0800 Subject: [PATCH] feat(media): clean missing resources by directory --- docs/api.md | 2 + docs/media-user-guide.zh-CN.md | 32 ++++++++ server/src/http/routes/media.ts | 4 + server/src/media/cleanup.ts | 93 +++++++++++++++++++++ server/src/media/folders.ts | 6 +- server/test/media-api.test.ts | 14 ++++ server/test/media-cleanup.test.ts | 107 +++++++++++++++++++++++++ web/src/media/folder-cleanup.tsx | 37 +++++++++ web/src/media/folders.tsx | 9 ++- web/src/media/screen.tsx | 2 +- web/test/media-folder-cleanup.test.tsx | 31 +++++++ web/test/media-folders.test.tsx | 9 +++ 12 files changed, 341 insertions(+), 5 deletions(-) create mode 100644 server/src/media/cleanup.ts create mode 100644 server/test/media-cleanup.test.ts create mode 100644 web/src/media/folder-cleanup.tsx create mode 100644 web/test/media-folder-cleanup.test.tsx diff --git a/docs/api.md b/docs/api.md index aeceb46..43248db 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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 | 作品、版本与章节详情 | diff --git a/docs/media-user-guide.zh-CN.md b/docs/media-user-guide.zh-CN.md index 4b166d0..f159608 100644 --- a/docs/media-user-guide.zh-CN.md +++ b/docs/media-user-guide.zh-CN.md @@ -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、封面、歌词和字幕,暂不读取远端内嵌标签、封面及章节。 diff --git a/server/src/http/routes/media.ts b/server/src/http/routes/media.ts index 00a7e6a..9420ead 100644 --- a/server/src/http/routes/media.ts +++ b/server/src/http/routes/media.ts @@ -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']:''; diff --git a/server/src/media/cleanup.ts b/server/src/media/cleanup.ts new file mode 100644 index 0000000..44ce3c0 --- /dev/null +++ b/server/src/media/cleanup.ts @@ -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(`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(`SELECT id,ref FROM media_assets WHERE library_id=? AND available=0${path?' AND ref>=? AND refasset.id)); + const allItems=this.db.all('SELECT id,parent_id FROM media_items WHERE library_id=?',libraryId); + const allEditions=this.db.all('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('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(); + 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(); + 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;indexthis.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}}; + } +} diff --git a/server/src/media/folders.ts b/server/src/media/folders.ts index ccecb71..3c498f7 100644 --- a/server/src/media/folders.ts +++ b/server/src/media/folders.ts @@ -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. diff --git a/server/test/media-api.test.ts b/server/test/media-api.test.ts index 20f824f..d75239e 100644 --- a/server/test/media-api.test.ts +++ b/server/test/media-api.test.ts @@ -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'),[]); + }); diff --git a/server/test/media-cleanup.test.ts b/server/test/media-cleanup.test.ts new file mode 100644 index 0000000..99bb007 --- /dev/null +++ b/server/test/media-cleanup.test.ts @@ -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'),[]); +}); diff --git a/web/src/media/folder-cleanup.tsx b/web/src/media/folder-cleanup.tsx new file mode 100644 index 0000000..3d391e5 --- /dev/null +++ b/web/src/media/folder-cleanup.tsx @@ -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(null),[message,setMessage]=useState(''),[error,setError]=useState(''); + const request=useRef(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(base+'/cleanup','POST',{path,revision:preview.revision},abort.signal); + if(!abort.signal.aborted)onCleaned(result); + }else{ + const result=await api.request(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 <> +
当前目录及子目录
+ {message&&

{message}

}{error&&

{error}

} + {preview&&setPreview(null)} onConfirm={()=>void run(true)} text={`清理「${path||'库内根目录'}」及其子目录中的 ${preview.assets} 个缺失文件记录。将移除 ${preview.parts} 个播放片段、${preview.editions} 个空版本和 ${preview.items} 个空作品/分类,以及所有用户关联的 ${preview.favorites} 条收藏、${preview.progress} 条播放进度和 ${preview.queue} 条队列记录。不会删除原始文件或仍有资源的作品。此操作不可撤销;如目录暂未挂载,请取消并在恢复后重新扫描。`}/>} + ; +} diff --git a/web/src/media/folders.tsx b/web/src/media/folders.tsx index 7399446..127bc59 100644 --- a/web/src/media/folders.tsx +++ b/web/src/media/folders.tsx @@ -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&&onPosition(edition.id,position)} onPlay={onPlay} onQueue={onQueue} onRefresh={onRefresh} onChooseVersion={onDetail}/>} ; } -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;onQueue:(ids:string[])=>Promise;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;onQueue:(ids:string[])=>Promise;onDetail:(id:string,location:FolderLocation)=>void}){ const [path,setPath]=useState(initialLocation?.path??''),[offset,setOffset]=useState(initialLocation?.offset??0),[assetId,setAssetId]=useState(initialLocation?.assetId??null),[retry,setRetry]=useState(0); const [editions,setEditions]=useState(initialLocation?.editions??{}),[chapters,setChapters]=useState(initialLocation?.chapters??{}); const [listing,setListing]=useState(null),[file,setFile]=useState(null),[error,setError]=useState(''),[busy,setBusy]=useState(false),[notice,setNotice]=useState(''); const [loading,setLoading]=useState(true),[cause,setCause]=useState(),[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,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
@@ -41,7 +43,8 @@ export function MediaFolders({api,libraryId,initialLocation,onLocationChange,onB {(path||assetId)&&} {error&&setRetry(value=>value+1)}/>}{actionError&&
{actionError}
}{notice&&

{notice}

} {loading&&} - {listing&&<>{listing.items.map(entry=>
)}{!listing.total&&

暂无已扫描媒体文件,请先扫描媒体库。

}{listing.total>60&&}

按最近扫描结果显示媒体文件;不包含空目录及其他文件。原始目录只读。

} + {cleanupNotice&&

{cleanupNotice}

} + {listing&&<>{admin&&{setCleanupNotice('已清理 '+result.assets+' 个失效资源记录'+(result.returnPath!==path?',已返回上级有效目录。':'。'));setPath(result.returnPath);setOffset(0);setRetry(value=>value+1);}}/>}{listing.items.map(entry=>
)}{!listing.total&&

暂无已扫描媒体文件,请先扫描媒体库。

}{listing.total>60&&}

按最近扫描结果显示媒体文件;不包含空目录及其他文件。原始目录只读。

} {file&&<>
{file.items.map(item=>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&&
}
}
; diff --git a/web/src/media/screen.tsx b/web/src/media/screen.tsx index f37ec1e..e647e33 100644 --- a/web/src/media/screen.tsx +++ b/web/src/media/screen.tsx @@ -533,7 +533,7 @@ export class MediaScreen {
{this.kind==='track'&&
{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());}}>
}
}} - {this.foldersOpen&&this.libraryId?{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?

选择要浏览的媒体库

{this.libraries.map(l=>
)}
:this.kind==='narrator'&&this.libraries.length>0?{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?: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?{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?

选择要浏览的媒体库

{this.libraries.map(l=>
)}
:this.kind==='narrator'&&this.libraries.length>0?{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?: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&&
{Math.floor(this.offset/60)+1} / {Math.ceil(this.total/60)}
} } ;} diff --git a/web/test/media-folder-cleanup.test.tsx b/web/test/media-folder-cleanup.test.tsx new file mode 100644 index 0000000..9ef7ab7 --- /dev/null +++ b/web/test/media-folder-cleanup.test.tsx @@ -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)=>

{text}

})); +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(,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(,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(,root)); + await click('清理失效资源');await click('确认清理');expect(root.textContent).toContain('重新预览');expect(onCleaned).not.toHaveBeenCalled();expect(root.querySelector('[role=dialog]')).toBeNull(); +}); diff --git a/web/test/media-folders.test.tsx b/web/test/media-folders.test.tsx index d66bd53..ad7f12f 100644 --- a/web/test/media-folders.test.tsx +++ b/web/test/media-folders.test.tsx @@ -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(,root)); + await vi.waitFor(()=>expect(root.textContent).toContain('暂无已扫描')); + expect(button('清理失效资源')).toBeUndefined(); + await act(async()=>render(,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}]});