@@ -4,6 +4,7 @@ import fs from 'fs'
44import http from 'http'
55import https from 'https'
66import { URL } from 'url'
7+ import { getVideoSubtitleMatchScore , parseSubtitleFile } from '../src/services/subtitles'
78
89const isMac = process . platform === 'darwin'
910const VIDEO_EXTS = [ '.mp4' , '.mkv' , '.avi' , '.webm' , '.mov' , '.wmv' ]
@@ -115,10 +116,32 @@ ipcMain.handle('dialog:openFolder', async () => {
115116 return result . filePaths [ 0 ]
116117} )
117118
119+ ipcMain . handle ( 'dialog:openScriptFile' , async ( ) => {
120+ const result = await dialog . showOpenDialog ( mainWindow ! , {
121+ properties : [ 'openFile' ] ,
122+ filters : [
123+ { name : 'Funscript' , extensions : [ 'funscript' , 'json' ] } ,
124+ ] ,
125+ } )
126+ if ( result . canceled ) return null
127+ return result . filePaths [ 0 ]
128+ } )
129+
130+ ipcMain . handle ( 'dialog:openSubtitleFile' , async ( ) => {
131+ const result = await dialog . showOpenDialog ( mainWindow ! , {
132+ properties : [ 'openFile' ] ,
133+ filters : [
134+ { name : 'Subtitles' , extensions : SUBTITLE_EXTS . map ( ( ext ) => ext . slice ( 1 ) ) } ,
135+ ] ,
136+ } )
137+ if ( result . canceled ) return null
138+ return result . filePaths [ 0 ]
139+ } )
140+
118141// File system operations
119142ipcMain . handle ( 'fs:readDir' , async ( _event , dirPath : string ) => {
120143 try {
121- const files : Array < { name : string ; path : string ; type : 'video' | 'audio' ; hasScript : boolean ; relativePath : string } > = [ ]
144+ const files : Array < { name : string ; path : string ; type : 'video' | 'audio' ; hasScript : boolean ; hasSubtitles : boolean ; relativePath : string } > = [ ]
122145
123146 function scanDir ( dir : string , prefix : string ) {
124147 let entries : fs . Dirent [ ]
@@ -136,11 +159,13 @@ ipcMain.handle('fs:readDir', async (_event, dirPath: string) => {
136159 if ( MEDIA_EXTS . includes ( ext ) ) {
137160 const baseName = path . basename ( entry . name , ext )
138161 const scriptPath = path . join ( dir , baseName + '.funscript' )
162+ const hasSubtitles = findSubtitleFilesForMedia ( fullPath ) . length > 0
139163 files . push ( {
140164 name : entry . name ,
141165 path : fullPath ,
142166 type : VIDEO_EXTS . includes ( ext ) ? 'video' : 'audio' ,
143167 hasScript : fs . existsSync ( scriptPath ) ,
168+ hasSubtitles,
144169 relativePath : prefix ? prefix + '/' + entry . name : entry . name ,
145170 } )
146171 }
@@ -222,6 +247,26 @@ ipcMain.handle('fs:readSubtitles', async (_event, mediaPath: string) => {
222247 }
223248} )
224249
250+ ipcMain . handle ( 'fs:readFunscriptFile' , async ( _event , filePath : string ) => {
251+ try {
252+ const content = fs . readFileSync ( filePath , 'utf-8' )
253+ return JSON . parse ( content )
254+ } catch {
255+ return null
256+ }
257+ } )
258+
259+ ipcMain . handle ( 'fs:readSubtitleFile' , async ( _event , filePath : string ) => {
260+ try {
261+ return {
262+ path : filePath ,
263+ content : readSubtitleContent ( filePath ) ,
264+ }
265+ } catch {
266+ return null
267+ }
268+ } )
269+
225270// ============================================================
226271// NAS (WebDAV / FTP) Service
227272// ============================================================
@@ -277,12 +322,29 @@ function findSubtitleFilesForMedia(mediaPath: string): string[] {
277322 const mediaDir = path . dirname ( mediaPath )
278323 const ext = path . extname ( mediaPath )
279324 const baseName = path . basename ( mediaPath , ext ) . toLowerCase ( )
325+ const mediaType = VIDEO_EXTS . includes ( ext . toLowerCase ( ) ) ? 'video' : 'audio'
280326
281327 return collectSubtitleCandidates ( mediaDir )
282- . map ( ( filePath ) => ( {
283- filePath,
284- score : scoreSubtitleCandidate ( filePath , mediaDir , baseName ) ,
285- } ) )
328+ . 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+
342+ return {
343+ filePath,
344+ score : fileScore + videoScore ,
345+ }
346+ } )
347+ . filter ( ( entry ) : entry is { filePath : string ; score : number } => entry !== null )
286348 . sort ( ( a , b ) => b . score - a . score || a . filePath . localeCompare ( b . filePath ) )
287349 . map ( ( { filePath } ) => filePath )
288350}
@@ -335,25 +397,76 @@ function scoreSubtitleCandidate(filePath: string, mediaDir: string, baseName: st
335397 const stem = path . basename ( filePath , ext ) . toLowerCase ( )
336398 const fileName = path . basename ( filePath ) . toLowerCase ( )
337399 const relativeDir = path . relative ( mediaDir , path . dirname ( filePath ) ) . toLowerCase ( )
400+ const normalizedBaseName = normalizeSubtitleMatchName ( baseName )
401+ const normalizedStem = normalizeSubtitleMatchName ( stem )
402+ const mediaTokens = tokenizeSubtitleMatchName ( normalizedBaseName )
403+ const subtitleTokens = tokenizeSubtitleMatchName ( normalizedStem )
404+ const sharedTokenCount = countSharedTokens ( mediaTokens , subtitleTokens )
405+ const hasDirectNameMatch = stem === baseName
406+ || normalizedStem === normalizedBaseName
407+ || normalizedStem . startsWith ( `${ normalizedBaseName } .` )
408+ || normalizedStem . startsWith ( normalizedBaseName )
409+ || normalizedBaseName . startsWith ( normalizedStem )
410+ || normalizedStem . includes ( normalizedBaseName )
411+ || normalizedBaseName . includes ( normalizedStem )
412+ const hasKeywordHint = directoryLooksLikeSubtitle ( relativeDir )
413+ || fileName . includes ( 'subtitle' )
414+ || fileName . includes ( 'caption' )
415+ || fileName . includes ( 'lyrics' )
416+ || fileName . includes ( '자막' )
417+ || fileName . includes ( '대본' )
418+ || fileName . includes ( '번역' )
338419
339420 let score = 0
340421
341- if ( ext === '.vtt' ) score += 400
342- else if ( ext === '.srt' ) score += 320
343- else if ( ext === '.txt' ) score += 240
422+ if ( ext === '.vtt' ) score += 120
423+ else if ( ext === '.srt' ) score += 90
424+ else if ( ext === '.txt' ) score += 60
425+
426+ if ( path . dirname ( filePath ) === mediaDir ) score += 40
427+ if ( stem === baseName ) score += 1600
428+ else if ( normalizedStem === normalizedBaseName && normalizedStem ) score += 1350
429+ else if ( stem . startsWith ( `${ baseName } .` ) || normalizedStem . startsWith ( `${ normalizedBaseName } .` ) ) score += 1200
430+ else if ( normalizedStem . startsWith ( normalizedBaseName ) || normalizedBaseName . startsWith ( normalizedStem ) ) score += 950
431+ else if ( normalizedStem . includes ( normalizedBaseName ) || normalizedBaseName . includes ( normalizedStem ) ) score += 700
344432
345- if ( path . dirname ( filePath ) === mediaDir ) score += 120
346- if ( stem === baseName ) score += 1200
347- else if ( stem . startsWith ( `${ baseName } .` ) ) score += 950
348- else if ( stem . includes ( baseName ) ) score += 700
433+ if ( sharedTokenCount > 0 ) {
434+ score += sharedTokenCount * 180
435+ }
349436
350437 if ( directoryLooksLikeSubtitle ( relativeDir ) ) score += 180
351438 if ( fileName . includes ( 'subtitle' ) || fileName . includes ( 'caption' ) || fileName . includes ( 'lyrics' ) ) score += 80
352439 if ( fileName . includes ( '자막' ) || fileName . includes ( '대본' ) || fileName . includes ( '번역' ) ) score += 80
353440
441+ if ( ! hasDirectNameMatch && sharedTokenCount === 0 ) {
442+ score -= hasKeywordHint ? 120 : 600
443+ }
444+
354445 return score
355446}
356447
448+ function normalizeSubtitleMatchName ( value : string ) : string {
449+ return value
450+ . toLowerCase ( )
451+ . replace ( / \[ [ ^ \] ] * ] / g, ' ' )
452+ . replace ( / \( [ ^ ) ] * \) / g, ' ' )
453+ . replace ( / \b ( 1 9 | 2 0 ) \d { 2 } \b / g, ' ' )
454+ . replace ( / \b ( 2 1 6 0 p | 1 4 4 0 p | 1 0 8 0 p | 7 2 0 p | 4 8 0 p | x 2 6 4 | x 2 6 5 | h 2 6 4 | h 2 6 5 | h e v c | a v 1 | w e b [ - ] ? d l | b l u [ - ] ? r a y | b d r i p | w e b r i p | h d r | u h d | 1 0 b i t | 8 b i t | a a c | f l a c | o p u s ) \b / gi, ' ' )
455+ . replace ( / [ . _ - ] + / g, ' ' )
456+ . replace ( / \s + / g, ' ' )
457+ . trim ( )
458+ }
459+
460+ function tokenizeSubtitleMatchName ( value : string ) : string [ ] {
461+ return value . match ( / [ a - z 0 - 9 \u3131 - \u318E \uAC00 - \uD7A3 \u4E00 - \u9FFF ] + / gi) ?? [ ]
462+ }
463+
464+ function countSharedTokens ( left : string [ ] , right : string [ ] ) : number {
465+ if ( left . length === 0 || right . length === 0 ) return 0
466+ const rightSet = new Set ( right )
467+ return left . filter ( ( token , index ) => token . length > 1 && left . indexOf ( token ) === index && rightSet . has ( token ) ) . length
468+ }
469+
357470function readSubtitleContent ( filePath : string ) : string {
358471 const buffer = fs . readFileSync ( filePath )
359472 const utf8 = buffer . toString ( 'utf-8' )
0 commit comments