UNPKG

search_vid

Version:

CLI tool for searching subtitles and playing videos at specific timestamps

212 lines (211 loc) 8.28 kB
import fs from 'fs/promises'; import path from 'path'; import glob from 'fast-glob'; import Fuse from 'fuse.js'; export async function findSubtitleFiles(verbose = false) { try { if (verbose) { console.log('Looking for subtitle files...'); console.log('Current working directory:', process.cwd()); } const subtitleFiles = await glob(['**/*.srt', '**/*.vtt'], { ignore: ['**/node_modules/**'], absolute: true, }); if (verbose) { console.log(`Found ${subtitleFiles.length} subtitle files:`, subtitleFiles); } else { console.log(`Found ${subtitleFiles.length} subtitle files`); } if (subtitleFiles.length === 0) { console.log('No subtitle files found in this directory. Make sure you have .srt or .vtt files in your current directory or its subdirectories.'); } return subtitleFiles; } catch (err) { console.error('Error finding subtitle files:', err.message); console.error('Stack trace:', err.stack); return []; } } export async function findMatchingVideoFile(subtitlePath, verbose = false) { const subtitleDir = path.dirname(subtitlePath); let subtitleBasename = path.basename(subtitlePath, path.extname(subtitlePath)); // Handle any language code format (.en, .eng, .fr, .de, etc.) subtitleBasename = subtitleBasename.replace(/\.([a-z]{2,3})$/, ''); if (verbose) { console.log(`Looking for video matching subtitle: ${subtitlePath}`); console.log(`Using base name: ${subtitleBasename}`); } const videoExtensions = ['.mp4', '.mkv', '.avi', '.mov', '.wmv', '.flv', '.webm']; // First, try exact match for (const ext of videoExtensions) { const videoPath = path.join(subtitleDir, `${subtitleBasename}${ext}`); try { await fs.access(videoPath); if (verbose) console.log(`Found matching video: ${videoPath}`); return videoPath; } catch { // File doesn't exist, continue to next extension } } // If no exact match, try to find similar files try { const dirEntries = await fs.readdir(subtitleDir); const videoFiles = dirEntries.filter(file => videoExtensions.includes(path.extname(file).toLowerCase())); if (videoFiles.length > 0) { if (verbose) { console.log(`Found ${videoFiles.length} videos in directory. Checking for partial matches...`); } const normalizedSubName = subtitleBasename.toLowerCase().replace(/[^a-z0-9]/g, ''); let bestMatch = null; let bestMatchScore = 0; for (const videoFile of videoFiles) { const videoBasename = path.basename(videoFile, path.extname(videoFile)) .toLowerCase() .replace(/[^a-z0-9]/g, ''); let score = 0; if (videoBasename.includes(normalizedSubName) || normalizedSubName.includes(videoBasename)) { const maxLength = Math.max(normalizedSubName.length, videoBasename.length); const minLength = Math.min(normalizedSubName.length, videoBasename.length); score = minLength / maxLength; } if (score > bestMatchScore) { bestMatchScore = score; bestMatch = videoFile; } } if (bestMatch && bestMatchScore > 0.5) { const bestMatchPath = path.join(subtitleDir, bestMatch); if (verbose) { console.log(`Found similar video file: ${bestMatchPath} (similarity: ${bestMatchScore.toFixed(2)})`); } return bestMatchPath; } } } catch (err) { if (verbose) { console.error(`Error reading directory ${subtitleDir}:`, err.message); } } if (verbose) { console.log(`No matching video found for subtitle: ${subtitlePath}`); } return null; } function parseSRT(content) { const blocks = content.split(/\r?\n\r?\n/).filter(block => block.trim() !== ''); const subtitles = []; for (const block of blocks) { const lines = block.split(/\r?\n/); if (lines.length < 3) continue; const timeLine = lines[1]; if (!timeLine) continue; const timeMatch = timeLine.match(/(\d{2}:\d{2}:\d{2},\d{3}) --> (\d{2}:\d{2}:\d{2},\d{3})/); if (!timeMatch || !timeMatch[1] || !timeMatch[2]) continue; const [, startTime, endTime] = timeMatch; const text = lines.slice(2).join('\n'); subtitles.push({ id: subtitles.length + 1, startTime, endTime, text, startTimeMs: timeToMilliseconds(startTime), endTimeMs: timeToMilliseconds(endTime) }); } return subtitles; } function timeToMilliseconds(timeStr) { const [time, ms] = timeStr.split(','); if (!time || !ms) return 0; const [hoursStr, minutesStr, secondsStr] = time.split(':'); if (!hoursStr || !minutesStr || !secondsStr) return 0; const hours = Number(hoursStr); const minutes = Number(minutesStr); const seconds = Number(secondsStr); return (hours * 3600 + minutes * 60 + seconds) * 1000 + Number(ms); } async function parseSubtitleFile(filePath, verbose = false) { try { const content = await fs.readFile(filePath, 'utf-8'); if (verbose) { console.log(`Parsing subtitle file: ${filePath}`); } return parseSRT(content); } catch (err) { console.error(`Error parsing subtitle file ${filePath}:`, err.message); return []; } } export function formatTime(timeInSeconds) { const hours = Math.floor(timeInSeconds / 3600); const minutes = Math.floor((timeInSeconds % 3600) / 60); const seconds = Math.floor(timeInSeconds % 60); const ms = Math.floor((timeInSeconds % 1) * 1000); return `${hours.toString().padStart(2, '0')}:${minutes .toString() .padStart(2, '0')}:${seconds.toString().padStart(2, '0')},${ms .toString() .padStart(3, '0')}`; } export async function searchSubtitles({ query, exactMatch = false, verbose = false, directory = process.cwd(), subtitleExt = 'srt', recursive = true }) { const results = []; try { const pattern = recursive ? `**/*.${subtitleExt}` : `*.${subtitleExt}`; const subtitleFiles = await glob([pattern], { cwd: directory, absolute: true, ignore: ['**/node_modules/**'] }); if (verbose) { console.log(`Found ${subtitleFiles.length} subtitle files to search`); } for (const subtitleFile of subtitleFiles) { const subtitles = await parseSubtitleFile(subtitleFile, verbose); const videoPath = await findMatchingVideoFile(subtitleFile, verbose); let matches = []; if (exactMatch) { matches = subtitles .filter(sub => sub.text.toLowerCase().includes(query.toLowerCase())) .map(subtitle => ({ subtitle })); } else { // @ts-ignore - Fuse.js types are not working correctly const fuseInstance = new Fuse(subtitles, { keys: ['text'], includeScore: true, threshold: 0.4 }); matches = fuseInstance .search(query) .map((result) => ({ subtitle: result.item, score: result.score ? 1 - result.score : 1 })); } if (matches.length > 0) { results.push({ filePath: subtitleFile, videoPath, matches }); } } return results; } catch (err) { console.error('Error during subtitle search:', err.message); return []; } }