@@ -42,17 +42,58 @@ const SUBTITLE_DIR_KEYWORDS = [
4242]
4343const MAX_SUBTITLE_SEARCH_DEPTH = 2
4444const MAX_SCAN_SUBTITLE_VALIDATION_CANDIDATES = 3
45+ const MIN_SCAN_SUBTITLE_SCORE = 900
4546const SCAN_YIELD_INTERVAL = 25
4647const NATURAL_SORTER = new Intl . Collator ( undefined , { numeric : true , sensitivity : 'base' } )
4748
4849let mainWindow : BrowserWindow | null = null
4950const subtitleCandidateCache = new Map < string , string [ ] > ( )
5051const subtitleAnalysisCache = new Map < string , { content : string ; hasCues : boolean } | null > ( )
52+ const directoryEntryNameCache = new Map < string , Set < string > > ( )
5153const FUNSCRIPT_EXTS = [ '.funscript' , '.json' ]
5254const osrSerialManager = new OsrSerialManager ( ( state ) => {
5355 mainWindow ?. webContents . send ( 'osrSerial:stateChanged' , state )
5456} )
5557
58+ function normalizePathKey ( targetPath : string ) : string {
59+ return process . platform === 'win32' ? targetPath . toLowerCase ( ) : targetPath
60+ }
61+
62+ async function getDirectoryRealPathKey ( dirPath : string ) : Promise < string > {
63+ try {
64+ return normalizePathKey ( await fs . promises . realpath ( dirPath ) )
65+ } catch {
66+ return normalizePathKey ( dirPath )
67+ }
68+ }
69+
70+ function getDirectoryRealPathKeySync ( dirPath : string ) : string {
71+ try {
72+ return normalizePathKey ( fs . realpathSync . native ( dirPath ) )
73+ } catch {
74+ return normalizePathKey ( dirPath )
75+ }
76+ }
77+
78+ function getDirectoryEntryNameSet ( dirPath : string ) : Set < string > {
79+ const cacheKey = normalizePathKey ( dirPath )
80+ const cached = directoryEntryNameCache . get ( cacheKey )
81+ if ( cached ) {
82+ return cached
83+ }
84+
85+ let names : string [ ]
86+ try {
87+ names = fs . readdirSync ( dirPath )
88+ } catch {
89+ names = [ ]
90+ }
91+
92+ const collected = new Set ( names . map ( ( name ) => name . toLowerCase ( ) ) )
93+ directoryEntryNameCache . set ( cacheKey , collected )
94+ return collected
95+ }
96+
5697function createWindow ( ) {
5798 mainWindow = new BrowserWindow ( {
5899 width : 1280 ,
@@ -159,6 +200,7 @@ ipcMain.handle('fs:readDir', async (_event, dirPath: string) => {
159200 try {
160201 const files : Array < { name : string ; path : string ; type : 'video' | 'audio' ; hasScript : boolean ; hasSubtitles : boolean ; relativePath : string } > = [ ]
161202 let scannedEntries = 0
203+ const visitedDirectories = new Set < string > ( )
162204
163205 const maybeYieldDuringScan = async ( ) => {
164206 scannedEntries += 1
@@ -168,6 +210,12 @@ ipcMain.handle('fs:readDir', async (_event, dirPath: string) => {
168210 }
169211
170212 const scanDir = async ( dir : string , prefix : string ) : Promise < void > => {
213+ const visitKey = await getDirectoryRealPathKey ( dir )
214+ if ( visitedDirectories . has ( visitKey ) ) {
215+ return
216+ }
217+ visitedDirectories . add ( visitKey )
218+
171219 let entries : fs . Dirent [ ]
172220 try {
173221 entries = await fs . promises . readdir ( dir , { withFileTypes : true } )
@@ -179,6 +227,10 @@ ipcMain.handle('fs:readDir', async (_event, dirPath: string) => {
179227 await maybeYieldDuringScan ( )
180228
181229 const fullPath = path . join ( dir , entry . name )
230+ if ( entry . isSymbolicLink ( ) ) {
231+ continue
232+ }
233+
182234 if ( entry . isDirectory ( ) ) {
183235 await scanDir ( fullPath , prefix ? prefix + '/' + entry . name : entry . name )
184236 } else if ( entry . isFile ( ) ) {
@@ -306,13 +358,14 @@ const NAS_EXTS = [...MEDIA_EXTS, '.funscript']
306358function hasBundledFunscriptsForMediaScan ( mediaPath : string ) : boolean {
307359 const mediaDir = path . dirname ( mediaPath )
308360 const mediaBaseName = path . basename ( mediaPath , path . extname ( mediaPath ) )
361+ const entryNames = getDirectoryEntryNameSet ( mediaDir )
309362
310363 for ( const definition of SCRIPT_AXIS_DEFINITIONS ) {
311364 for ( const suffix of definition . suffixes ) {
312365 const fileName = suffix
313366 ? `${ mediaBaseName } .${ suffix } .funscript`
314367 : `${ mediaBaseName } .funscript`
315- if ( fs . existsSync ( path . join ( mediaDir , fileName ) ) ) {
368+ if ( entryNames . has ( fileName . toLowerCase ( ) ) ) {
316369 return true
317370 }
318371 }
@@ -501,6 +554,12 @@ function findSubtitleMatches(mediaPath: string, mode: 'scan' | 'full'): string[]
501554 : rankedCandidates
502555 const matches : Array < { filePath : string ; score : number } > = [ ]
503556
557+ if ( mode === 'scan' ) {
558+ return rankedCandidates
559+ . filter ( ( candidate ) => candidate . score >= MIN_SCAN_SUBTITLE_SCORE )
560+ . map ( ( { filePath } ) => filePath )
561+ }
562+
504563 for ( const candidate of candidatesToValidate ) {
505564 const analysis = readSubtitleAnalysis ( candidate . filePath )
506565 if ( ! analysis ?. hasCues ) continue
@@ -527,7 +586,8 @@ function findSubtitleMatches(mediaPath: string, mode: 'scan' | 'full'): string[]
527586}
528587
529588function collectSubtitleCandidates ( rootDir : string ) : string [ ] {
530- const cached = subtitleCandidateCache . get ( rootDir )
589+ const cacheKey = normalizePathKey ( rootDir )
590+ const cached = subtitleCandidateCache . get ( cacheKey )
531591 if ( cached ) {
532592 return cached
533593 }
@@ -536,8 +596,9 @@ function collectSubtitleCandidates(rootDir: string): string[] {
536596 const visited = new Set < string > ( )
537597
538598 const walk = ( currentDir : string , depth : number , matchedKeyword : boolean ) => {
539- if ( visited . has ( currentDir ) ) return
540- visited . add ( currentDir )
599+ const visitKey = getDirectoryRealPathKeySync ( currentDir )
600+ if ( visited . has ( visitKey ) ) return
601+ visited . add ( visitKey )
541602
542603 let entries : fs . Dirent [ ]
543604 try {
@@ -557,7 +618,7 @@ function collectSubtitleCandidates(rootDir: string): string[] {
557618 if ( depth >= MAX_SUBTITLE_SEARCH_DEPTH ) return
558619
559620 for ( const entry of entries ) {
560- if ( ! entry . isDirectory ( ) ) continue
621+ if ( ! entry . isDirectory ( ) || entry . isSymbolicLink ( ) ) continue
561622 const nextMatchedKeyword = matchedKeyword || directoryLooksLikeSubtitle ( entry . name )
562623 const shouldDescend = depth === 0 || nextMatchedKeyword
563624 if ( ! shouldDescend ) continue
@@ -567,7 +628,7 @@ function collectSubtitleCandidates(rootDir: string): string[] {
567628
568629 walk ( rootDir , 0 , false )
569630 const collected = Array . from ( results )
570- subtitleCandidateCache . set ( rootDir , collected )
631+ subtitleCandidateCache . set ( cacheKey , collected )
571632 return collected
572633}
573634
0 commit comments