@@ -38,8 +38,12 @@ const SUBTITLE_DIR_KEYWORDS = [
3838 '歌词' ,
3939]
4040const MAX_SUBTITLE_SEARCH_DEPTH = 2
41+ const MAX_SCAN_SUBTITLE_VALIDATION_CANDIDATES = 3
42+ const SCAN_YIELD_INTERVAL = 25
4143
4244let mainWindow : BrowserWindow | null = null
45+ const subtitleCandidateCache = new Map < string , string [ ] > ( )
46+ const subtitleAnalysisCache = new Map < string , { content : string ; hasCues : boolean } | null > ( )
4347
4448function createWindow ( ) {
4549 mainWindow = new BrowserWindow ( {
@@ -142,24 +146,35 @@ ipcMain.handle('dialog:openSubtitleFile', async () => {
142146ipcMain . handle ( 'fs:readDir' , async ( _event , dirPath : string ) => {
143147 try {
144148 const files : Array < { name : string ; path : string ; type : 'video' | 'audio' ; hasScript : boolean ; hasSubtitles : boolean ; relativePath : string } > = [ ]
149+ let scannedEntries = 0
145150
146- function scanDir ( dir : string , prefix : string ) {
151+ const maybeYieldDuringScan = async ( ) => {
152+ scannedEntries += 1
153+ if ( scannedEntries % SCAN_YIELD_INTERVAL === 0 ) {
154+ await new Promise < void > ( ( resolve ) => setImmediate ( resolve ) )
155+ }
156+ }
157+
158+ const scanDir = async ( dir : string , prefix : string ) : Promise < void > => {
147159 let entries : fs . Dirent [ ]
148160 try {
149- entries = fs . readdirSync ( dir , { withFileTypes : true } )
161+ entries = await fs . promises . readdir ( dir , { withFileTypes : true } )
150162 } catch {
151163 return
152164 }
165+
153166 for ( const entry of entries ) {
167+ await maybeYieldDuringScan ( )
168+
154169 const fullPath = path . join ( dir , entry . name )
155170 if ( entry . isDirectory ( ) ) {
156- scanDir ( fullPath , prefix ? prefix + '/' + entry . name : entry . name )
171+ await scanDir ( fullPath , prefix ? prefix + '/' + entry . name : entry . name )
157172 } else if ( entry . isFile ( ) ) {
158173 const ext = path . extname ( entry . name ) . toLowerCase ( )
159174 if ( MEDIA_EXTS . includes ( ext ) ) {
160175 const baseName = path . basename ( entry . name , ext )
161176 const scriptPath = path . join ( dir , baseName + '.funscript' )
162- const hasSubtitles = findSubtitleFilesForMedia ( fullPath ) . length > 0
177+ const hasSubtitles = hasSubtitlesForMediaScan ( fullPath )
163178 files . push ( {
164179 name : entry . name ,
165180 path : fullPath ,
@@ -173,7 +188,7 @@ ipcMain.handle('fs:readDir', async (_event, dirPath: string) => {
173188 }
174189 }
175190
176- scanDir ( dirPath , '' )
191+ await scanDir ( dirPath , '' )
177192 return files . sort ( ( a , b ) => a . relativePath . localeCompare ( b . relativePath ) )
178193 } catch {
179194 return [ ]
@@ -319,37 +334,63 @@ function findArtworkForMedia(mediaPath: string): string | null {
319334}
320335
321336function findSubtitleFilesForMedia ( mediaPath : string ) : string [ ] {
337+ return findSubtitleMatches ( mediaPath , 'full' )
338+ }
339+
340+ function hasSubtitlesForMediaScan ( mediaPath : string ) : boolean {
341+ return findSubtitleMatches ( mediaPath , 'scan' ) . length > 0
342+ }
343+
344+ function findSubtitleMatches ( mediaPath : string , mode : 'scan' | 'full' ) : string [ ] {
322345 const mediaDir = path . dirname ( mediaPath )
323346 const ext = path . extname ( mediaPath )
324347 const baseName = path . basename ( mediaPath , ext ) . toLowerCase ( )
325348 const mediaType = VIDEO_EXTS . includes ( ext . toLowerCase ( ) ) ? 'video' : 'audio'
326349
327- return collectSubtitleCandidates ( mediaDir )
350+ const rankedCandidates = collectSubtitleCandidates ( mediaDir )
328351 . map ( ( filePath ) => {
329- const content = readSubtitleContent ( filePath )
330- const cues = parseSubtitleFile ( content , filePath )
331- if ( cues . length === 0 ) return null
332-
333- const fileScore = scoreSubtitleCandidate ( filePath , mediaDir , baseName )
334- const videoScore = mediaType === 'video'
335- ? getVideoSubtitleMatchScore ( mediaPath , { path : filePath , content } )
336- : 0
337-
338- if ( mediaType === 'video' && videoScore < 0 ) {
339- return null
340- }
341-
342352 return {
343353 filePath,
344- score : fileScore + videoScore ,
354+ score : scoreSubtitleCandidate ( filePath , mediaDir , baseName ) ,
345355 }
346356 } )
347- . filter ( ( entry ) : entry is { filePath : string ; score : number } => entry !== null )
357+ . sort ( ( a , b ) => b . score - a . score || a . filePath . localeCompare ( b . filePath ) )
358+ const candidatesToValidate = mode === 'scan'
359+ ? rankedCandidates . slice ( 0 , MAX_SCAN_SUBTITLE_VALIDATION_CANDIDATES )
360+ : rankedCandidates
361+ const matches : Array < { filePath : string ; score : number } > = [ ]
362+
363+ for ( const candidate of candidatesToValidate ) {
364+ const analysis = readSubtitleAnalysis ( candidate . filePath )
365+ if ( ! analysis ?. hasCues ) continue
366+
367+ let score = candidate . score
368+ if ( mediaType === 'video' ) {
369+ const videoScore = getVideoSubtitleMatchScore ( mediaPath , {
370+ path : candidate . filePath ,
371+ content : analysis . content ,
372+ } )
373+ if ( videoScore < 0 ) continue
374+ score += videoScore
375+ }
376+
377+ matches . push ( {
378+ filePath : candidate . filePath ,
379+ score,
380+ } )
381+ }
382+
383+ return matches
348384 . sort ( ( a , b ) => b . score - a . score || a . filePath . localeCompare ( b . filePath ) )
349385 . map ( ( { filePath } ) => filePath )
350386}
351387
352388function collectSubtitleCandidates ( rootDir : string ) : string [ ] {
389+ const cached = subtitleCandidateCache . get ( rootDir )
390+ if ( cached ) {
391+ return cached
392+ }
393+
353394 const results = new Set < string > ( )
354395 const visited = new Set < string > ( )
355396
@@ -384,7 +425,9 @@ function collectSubtitleCandidates(rootDir: string): string[] {
384425 }
385426
386427 walk ( rootDir , 0 , false )
387- return Array . from ( results )
428+ const collected = Array . from ( results )
429+ subtitleCandidateCache . set ( rootDir , collected )
430+ return collected
388431}
389432
390433function directoryLooksLikeSubtitle ( name : string ) : boolean {
@@ -486,6 +529,23 @@ function readSubtitleContent(filePath: string): string {
486529 return utf8
487530}
488531
532+ function readSubtitleAnalysis ( filePath : string ) : { content : string ; hasCues : boolean } | null {
533+ if ( subtitleAnalysisCache . has ( filePath ) ) {
534+ return subtitleAnalysisCache . get ( filePath ) ?? null
535+ }
536+
537+ try {
538+ const content = readSubtitleContent ( filePath )
539+ const hasCues = parseSubtitleFile ( content , filePath ) . length > 0
540+ const analysis = { content, hasCues }
541+ subtitleAnalysisCache . set ( filePath , analysis )
542+ return analysis
543+ } catch {
544+ subtitleAnalysisCache . set ( filePath , null )
545+ return null
546+ }
547+ }
548+
489549function countReplacementChars ( value : string ) : number {
490550 return ( value . match ( / \uFFFD / g) ?? [ ] ) . length
491551}
0 commit comments