From 0f06d6e9f3dcf60824c237cdfc469b1cf9e2ffbe Mon Sep 17 00:00:00 2001 From: hectorqin Date: Mon, 28 Sep 2026 22:33:32 +0800 Subject: [PATCH] fix(media): restore recognition scrolling and add bulk selection --- server/src/media/video-recognition.ts | 2 +- server/test/media-recognition.test.ts | 2 ++ web/src/media/folder-recognition.css | 9 +++++++-- web/src/media/folder-recognition.tsx | 10 +++++++++- web/test/media-folder-recognition.test.tsx | 12 ++++++++++++ 5 files changed, 31 insertions(+), 4 deletions(-) diff --git a/server/src/media/video-recognition.ts b/server/src/media/video-recognition.ts index b13dfda..a6f9744 100644 --- a/server/src/media/video-recognition.ts +++ b/server/src/media/video-recognition.ts @@ -14,7 +14,7 @@ function number(text:string):number|undefined{ return text?result+current:undefined; } export function seasonDirectory(name:string):number|undefined{ - const match=/^(?:season[ ._-]*|s)(\d{1,3})$/i.exec(name)||/^第([零〇一二两三四五六七八九十百\d]+)季$/.exec(name); + const match=/^(?:season[ ._-]*|s)(\d{1,3})$/i.exec(name)||/第([零〇一二两三四五六七八九十百\d]+)季$/.exec(name); if(match)return number(match[1]!); if(/^(specials?|特别篇|特別篇)$/i.test(name))return 0; return undefined; diff --git a/server/test/media-recognition.test.ts b/server/test/media-recognition.test.ts index fae06ad..a3e4fc4 100644 --- a/server/test/media-recognition.test.ts +++ b/server/test/media-recognition.test.ts @@ -19,6 +19,8 @@ test('video parsing separates release tokens from meaningful numerals and resolv assert.equal(cleanVideoTitle('001. Film').title,'001 Film'); assert.equal(cleanVideoTitle('001. Film',true).title,'Film'); assert.equal(seasonDirectory('第二十一季'),21);assert.equal(seasonDirectory('Specials'),0); + const peppa=recognizeVideo('动画/小猪佩奇/小猪佩奇第1季/小猪佩奇第一季.Peppa.Pig.Season.1.E01.4K.WEB-DL.H265.AAC-OurTV.mp4',raw(),[{path:'动画/小猪佩奇',mode:'series',title:'小猪佩奇'}]); + assert.equal(peppa.kind,'episode');assert.equal(peppa.metadata.season,1);assert.equal(peppa.metadata.episode,1); for(const [ref,season,episode] of [ ['Drama/Drama_S01E01_1080p.mp4',1,1],['Drama/Drama.1x02.mkv',1,2], ['Drama/Season 02/EP03.mp4',2,3],['Drama/第三季/004.mp4',3,4], diff --git a/web/src/media/folder-recognition.css b/web/src/media/folder-recognition.css index b62f703..0651ef6 100644 --- a/web/src/media/folder-recognition.css +++ b/web/src/media/folder-recognition.css @@ -1,7 +1,12 @@ -.media-recognition-form{max-width:100%;padding:16px;box-sizing:border-box} +.media-recognition-form{max-width:100%;padding:16px;box-sizing:border-box;min-height:0;overflow-y:auto;overscroll-behavior:contain;margin:0;display:block} +.media-recognition-form>label,.media-recognition-form>.media-toolbar{margin:14px 0} .media-recognition-form .media-toolbar{flex-wrap:wrap;gap:8px} .media-recognition-form .media-recognition-check{display:flex;align-items:flex-start;gap:8px} -.media-recognition-form input[type=checkbox]{width:18px;min-width:18px;height:18px;accent-color:var(--media-accent)} +.media-recognition-form input[type=checkbox]{width:18px;min-width:18px;height:18px;min-height:18px;max-height:18px;padding:0;margin:3px 0 0;flex:0 0 18px;box-sizing:border-box;accent-color:var(--media-accent)} +.media-recognition-form .media-recognition-check{line-height:24px} +.media-recognition-bulk{position:sticky;top:-16px;z-index:2;background:var(--reader-paper);padding:10px 0;border-bottom:1px solid var(--border)} +.media-recognition-bulk p{margin:0;font-size:13px} +.media-recognition-row strong{line-height:24px} .media-recognition-preview{border-top:1px solid var(--border);padding-top:16px} .media-recognition-preview>p{font-size:13px;line-height:1.6;opacity:.8} .media-recognition-form .media-recognition-row{display:flex;flex-direction:row;align-items:flex-start;gap:10px;padding:12px 0;border-bottom:1px solid var(--border)} diff --git a/web/src/media/folder-recognition.tsx b/web/src/media/folder-recognition.tsx index 90b67ef..58f2394 100644 --- a/web/src/media/folder-recognition.tsx +++ b/web/src/media/folder-recognition.tsx @@ -36,6 +36,9 @@ export function FolderRecognition({api,libraryId,path,disabled,onBusy,onApplied} if(!signal.aborted){setRevision(result.revision);setNotice('目录规则已保存。已有作品尚未更改。');} } const description=(row:Proposal)=>row.after.kind==='ignore'?'跳过扫描':row.after.kind==='episode'?`${row.after.metadata.show} · 第 ${row.after.metadata.season} 季第 ${row.after.metadata.episode} 集`:`电影 · ${row.after.metadata.title}`; + const selectable=(row:Proposal)=>['ready','review'].includes(row.status); + const visible=preview?.items.slice(page*30,page*30+30)??[]; + const selectRows=(rows:Proposal[])=>setSelected(current=>[...new Set([...current,...rows.filter(selectable).map(row=>row.assetId)])]); return <>
影视识别
{open&&setOpen(false)}>
@@ -54,10 +57,15 @@ export function FolderRecognition({api,libraryId,path,disabled,onBusy,onApplied} {mode==='ignore'&&

后续扫描跳过该目录的新文件。已有作品和文件保留,不会因此被标记缺失。

}
{preview&&

识别结果 · {preview.items.length} 个文件

待确认结果默认不选;已人工整理、在线确认或共享多版本的作品保留。确认后保留资源、版本和播放片段 ID,已有播放进度与队列继续有效。

+

已选 {selected.length} / {preview.items.filter(selectable).length} 项(跨页保留)

+ + + + +

全选包含待确认结果,请先核对;受保护与忽略项不会选中。

{!preview.items.length&&

没有已扫描且可用的资源,请先扫描媒体库。

} {preview.items.slice(page*30,page*30+30).map(row=>)} {preview.items.length>30&&} -
}
} {confirm&&setConfirm(false)} onConfirm={()=>{setConfirm(false);void work(async signal=>{await api.request(base+'/recognition-apply','POST',{path,previewId:preview!.id,assetIds:selected},signal);if(!signal.aborted){setOpen(false);onApplied();}});}}/>} diff --git a/web/test/media-folder-recognition.test.tsx b/web/test/media-folder-recognition.test.tsx index f10b229..e3ba07e 100644 --- a/web/test/media-folder-recognition.test.tsx +++ b/web/test/media-folder-recognition.test.tsx @@ -28,3 +28,15 @@ it('retains saved-rule notice if preview fails and reports no successful applica act(()=>render(,root)); await click('识别规则与预览');await vi.waitFor(()=>expect(root.querySelector('.media-primary')?.disabled).toBe(false));await click('保存并预览');await vi.waitFor(()=>expect(root.textContent).toContain('500'));expect(root.textContent).toContain('目录规则已保存');expect(onApplied).not.toHaveBeenCalled(); }); +it('batch selection spans pages, excludes protected resources, and can be cleared',async()=>{ + const items=Array.from({length:65},(_,i)=>({...preview.items[i%4]!,assetId:String(i),ref:`Show/${i}.mp4`})); + const request=vi.fn().mockResolvedValueOnce(rule).mockResolvedValueOnce({revision:'next'}).mockResolvedValueOnce({id:'many',items}); + act(()=>render(,root)); + await click('识别规则与预览');await vi.waitFor(()=>expect(root.querySelector('.media-primary')?.disabled).toBe(false));await click('保存并预览'); + await vi.waitFor(()=>expect(root.querySelectorAll('.media-recognition-row')).toHaveLength(30)); + await click('清空选择');await click('全选本页');expect(root.textContent).toContain('已选 16 / 33 项'); + await click('下一页');expect(root.textContent).toContain('已选 16 / 33 项'); + await click('全选可应用项');expect(root.textContent).toContain('已选 33 / 33 项'); + expect(root.querySelectorAll('.media-recognition-row input:disabled:checked')).toHaveLength(0); + await click('清空选择');expect(root.textContent).toContain('已选 0 / 33 项'); +});