Skip to content

Commit a4391c9

Browse files
committed
Improve subtitle matching and manual media attachment
1 parent f7912a0 commit a4391c9

File tree

10 files changed

+681
-34
lines changed

10 files changed

+681
-34
lines changed

electron/main.ts

Lines changed: 125 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import fs from 'fs'
44
import http from 'http'
55
import https from 'https'
66
import { URL } from 'url'
7+
import { getVideoSubtitleMatchScore, parseSubtitleFile } from '../src/services/subtitles'
78

89
const isMac = process.platform === 'darwin'
910
const 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
119142
ipcMain.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(19|20)\d{2}\b/g, ' ')
454+
.replace(/\b(2160p|1440p|1080p|720p|480p|x264|x265|h264|h265|hevc|av1|web[- ]?dl|blu[- ]?ray|bdrip|webrip|hdr|uhd|10bit|8bit|aac|flac|opus)\b/gi, ' ')
455+
.replace(/[._-]+/g, ' ')
456+
.replace(/\s+/g, ' ')
457+
.trim()
458+
}
459+
460+
function tokenizeSubtitleMatchName(value: string): string[] {
461+
return value.match(/[a-z0-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+
357470
function readSubtitleContent(filePath: string): string {
358471
const buffer = fs.readFileSync(filePath)
359472
const utf8 = buffer.toString('utf-8')

electron/preload.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,18 @@ contextBridge.exposeInMainWorld('electronAPI', {
1111
// Dialogs
1212
openVideo: () => ipcRenderer.invoke('dialog:openVideo'),
1313
openFolder: () => ipcRenderer.invoke('dialog:openFolder'),
14+
openScriptFile: () => ipcRenderer.invoke('dialog:openScriptFile'),
15+
openSubtitleFile: () => ipcRenderer.invoke('dialog:openSubtitleFile'),
1416

1517
// File system
1618
readDir: (path: string) => ipcRenderer.invoke('fs:readDir', path),
1719
readFunscript: (videoPath: string, scriptFolder?: string) => ipcRenderer.invoke('fs:readFunscript', videoPath, scriptFolder),
20+
readFunscriptFile: (filePath: string) => ipcRenderer.invoke('fs:readFunscriptFile', filePath),
1821
saveFunscript: (videoPath: string, data: string) => ipcRenderer.invoke('fs:saveFunscript', videoPath, data),
1922
getVideoUrl: (filePath: string) => ipcRenderer.invoke('fs:getVideoUrl', filePath),
2023
findArtwork: (mediaPath: string) => ipcRenderer.invoke('fs:findArtwork', mediaPath),
2124
readSubtitles: (mediaPath: string) => ipcRenderer.invoke('fs:readSubtitles', mediaPath),
25+
readSubtitleFile: (filePath: string) => ipcRenderer.invoke('fs:readSubtitleFile', filePath),
2226

2327
// NAS operations
2428
nasWebdavConnect: (url: string, username: string, password: string) =>

0 commit comments

Comments
 (0)