Enhanced YouTube subtitle downloader with smart selection and improved code structure
// ==UserScript== // @name YouTube Smart Subtitle Downloader // @namespace http://tampermonkey.net/ // @version 1.1 // @description Enhanced YouTube subtitle downloader with smart selection and improved code structure // @author anassk // @match https://www.youtube.com/* // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // ==/UserScript== (function() { 'use strict'; // Core configuration const CONFIG = { MESSAGES: { NO_SUBTITLE: 'No Subtitles Available', HAVE_SUBTITLE: 'Available Subtitles', LOADING: 'Loading Subtitles...', COPY_SUCCESS: '✓ Copied!', ERROR: { COPY: 'Failed to copy to clipboard', FETCH: 'Failed to fetch subtitles', NO_VIDEO: 'No video found' } }, FORMATS: { SRT: 'srt', TEXT: 'txt' }, TIMINGS: { DOWNLOAD_DELAY: 500, }, SELECTORS: { VIDEO_ELEMENTS: [ 'ytd-playlist-panel-video-renderer', 'ytd-playlist-video-renderer', 'yt-lockup-view-model', 'ytd-rich-item-renderer', 'ytd-video-renderer', 'ytd-compact-video-renderer', 'ytd-grid-video-renderer' ].join(','), TITLE_SELECTORS: [ '#video-title', 'a#video-title', 'span#video-title', '[title]' ] }, STYLES: { CHECKBOX_WRAPPER: ` position: absolute; left: 5px; top: 0; bottom: 0; width: 20px; display: flex; align-items: center; z-index: 1; `, CHECKBOX: ` width: 16px; height: 16px; cursor: pointer; margin: 0; `, DIALOG: ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0, 0, 0, 0.7); display: flex; justify-content: center; align-items: center; z-index: 9999; `, DIALOG_CONTENT: ` background: white; padding: 20px; border-radius: 8px; min-width: 300px; max-width: 600px; max-height: 80vh; overflow-y: auto; color: black; ` } }; // Utility functions const Utils = { createError: (message, code, originalError = null) => { const error = new Error(message); error.code = code; error.originalError = originalError; return error; }, safeJSONParse: (str, fallback = null) => { try { return JSON.parse(str); } catch (e) { console.error('JSON Parse Error:', e); return fallback; } }, sanitizeFileName: (name) => { return name.replace(/[<>:"/\\|?*\x00-\x1F]/g, '_').substring(0, 100); }, delay: (ms) => new Promise(resolve => setTimeout(resolve, ms)) }; // Subtitle Service - New centralized service for subtitle operations class SubtitleService { static async fetchSubtitleTracks(videoId) { try { const response = await fetch(`https://www.youtube.com/watch?v=${videoId}`); const html = await response.text(); const playerDataMatch = html.match(/ytInitialPlayerResponse\s*=\s*({.+?});/); if (!playerDataMatch) return null; const playerData = Utils.safeJSONParse(playerDataMatch[1]); const captionTracks = playerData?.captions?.playerCaptionsTracklistRenderer?.captionTracks; if (!captionTracks?.length) return null; return captionTracks.map(track => ({ languageCode: track.languageCode, languageName: track.name.simpleText, baseUrl: track.baseUrl })); } catch (error) { throw Utils.createError('Failed to fetch subtitles', 'SUBTITLE_FETCH_ERROR', error); } } static async getSubtitleContent(track, format = CONFIG.FORMATS.SRT) { try { const response = await fetch(track.baseUrl); const xml = await response.text(); return format === CONFIG.FORMATS.SRT ? this.convertToSRT(xml) : this.convertToText(xml); } catch (error) { throw Utils.createError('Failed to fetch subtitle content', 'CONTENT_FETCH_ERROR', error); } } static convertToSRT(xml) { try { const parser = new DOMParser(); const doc = parser.parseFromString(xml, "text/xml"); const textNodes = doc.getElementsByTagName('text'); let srt = ''; Array.from(textNodes).forEach((node, index) => { const start = parseFloat(node.getAttribute('start')); const duration = parseFloat(node.getAttribute('dur') || '0'); const end = start + duration; srt += `${index + 1}\n`; srt += `${this.formatTime(start)} --> ${this.formatTime(end)}\n`; srt += `${node.textContent}\n\n`; }); return srt; } catch (error) { throw Utils.createError('Failed to convert to SRT', 'SRT_CONVERSION_ERROR', error); } } static convertToText(xml) { try { const parser = new DOMParser(); const doc = parser.parseFromString(xml, "text/xml"); const textNodes = doc.getElementsByTagName('text'); return Array.from(textNodes) .map(node => node.textContent.trim()) .filter(text => text) .join('\n'); } catch (error) { throw Utils.createError('Failed to convert to text', 'TEXT_CONVERSION_ERROR', error); } } static formatTime(seconds) { const pad = num => String(num).padStart(2, '0'); const ms = String(Math.floor((seconds % 1) * 1000)).padStart(3, '0'); seconds = Math.floor(seconds); const hours = Math.floor(seconds / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = seconds % 60; return `${pad(hours)}:${pad(minutes)}:${pad(secs)},${ms}`; } static async downloadSubtitles(tracks, format) { const loading = UIComponents.showLoading('Downloading subtitles...'); try { for (const track of tracks) { const content = await this.getSubtitleContent(track, format); const filename = `${Utils.sanitizeFileName(track.videoTitle)}_${track.languageCode}.${format}`; const blob = new Blob(['\ufeff' + content], { type: 'text/plain;charset=utf-8' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); await Utils.delay(CONFIG.TIMINGS.DOWNLOAD_DELAY); } } catch (error) { throw Utils.createError('Failed to download subtitles', 'DOWNLOAD_ERROR', error); } finally { loading.remove(); } } static async copySubtitles(tracks, format, videoTitle = '') { const loading = UIComponents.showLoading('Copying subtitles...'); try { let content = ''; for (const track of tracks) { const subtitleContent = await this.getSubtitleContent(track, format); const title = videoTitle ? `${videoTitle} - ` : ''; content += `=== ${title}${track.languageName} ===\n${subtitleContent}\n\n`; } await navigator.clipboard.writeText(content); UIComponents.showToast(CONFIG.MESSAGES.COPY_SUCCESS); } catch (error) { throw Utils.createError('Failed to copy subtitles', 'COPY_ERROR', error); } finally { loading.remove(); } } } // UI Components class UIComponents { static showDialog(title, content, onClose) { const dialog = document.createElement('div'); dialog.style.cssText = CONFIG.STYLES.DIALOG; const dialogContent = document.createElement('div'); dialogContent.style.cssText = CONFIG.STYLES.DIALOG_CONTENT; const header = document.createElement('div'); header.style.cssText = ` display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; `; const titleElem = document.createElement('h2'); titleElem.style.margin = '0'; titleElem.textContent = title; const closeButton = document.createElement('button'); closeButton.textContent = '×'; closeButton.style.cssText = ` background: none; border: none; font-size: 24px; cursor: pointer; padding: 0 5px; `; closeButton.onclick = () => { dialog.remove(); if (onClose) onClose(); }; header.appendChild(titleElem); header.appendChild(closeButton); dialogContent.appendChild(header); dialogContent.appendChild(content); dialog.appendChild(dialogContent); document.body.appendChild(dialog); return dialog; } static showToast(message, duration = 3000) { const toast = document.createElement('div'); toast.style.cssText = ` position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background: rgba(0, 0, 0, 0.8); color: white; padding: 10px 20px; border-radius: 4px; z-index: 10001; `; toast.textContent = message; document.body.appendChild(toast); setTimeout(() => toast.remove(), duration); } static showLoading(message = CONFIG.MESSAGES.LOADING) { const overlay = document.createElement('div'); overlay.style.cssText = CONFIG.STYLES.DIALOG; const content = document.createElement('div'); content.style.textAlign = 'center'; content.style.color = 'white'; const spinner = document.createElement('div'); spinner.style.cssText = ` width: 40px; height: 40px; border: 4px solid #f3f3f3; border-top: 4px solid #3498db; border-radius: 50%; margin: 0 auto 10px; animation: spin 1s linear infinite; `; if (!document.getElementById('spinner-style')) { const style = document.createElement('style'); style.id = 'spinner-style'; style.textContent = '@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }'; document.head.appendChild(style); } content.appendChild(spinner); content.appendChild(document.createTextNode(message)); overlay.appendChild(content); document.body.appendChild(overlay); return overlay; } static creat###btitleDialog(tracks, format = CONFIG.FORMATS.SRT, onDownload, onCopy) { const content = document.createElement('div'); // Format selector const formatDiv = document.createElement('div'); formatDiv.innerHTML = ` <div style="margin-bottom: 15px;"> <label style="margin-right: 10px;"> <input type="radio" name="format" value="srt" ${format === 'srt' ? 'checked' : ''}> SRT </label> <label> <input type="radio" name="format" value="txt" ${format === 'txt' ? 'checked' : ''}> Plain Text </label> </div> `; content.appendChild(formatDiv); //batch select if (tracks.length > 0) { const selectAllDiv = document.createElement('div'); selectAllDiv.style.marginBottom = '15px'; const selectAllBtn = document.createElement('button'); selectAllBtn.textContent = 'Select All Subtitles'; selectAllBtn.style.cssText = 'background: #065fd4; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;'; selectAllBtn.onclick = () => { const checkboxes = content.querySelectorAll('input[type="checkbox"][data-lang]'); checkboxes.forEach(checkbox => { checkbox.checked = true; }); }; selectAllDiv.appendChild(selectAllBtn); content.appendChild(selectAllDiv); } // Tracks list if (tracks.length > 0) { tracks.forEach(track => { const trackDiv = document.createElement('div'); trackDiv.style.margin = '5px 0'; trackDiv.innerHTML = ` <label> <input type="checkbox" data-lang="${track.languageCode}"> ${track.languageName} </label> `; content.appendChild(trackDiv); }); } else { const noSubs = document.createElement('p'); noSubs.style.color = '#c00'; noSubs.textContent = CONFIG.MESSAGES.NO_SUBTITLE; content.appendChild(noSubs); } // Action buttons const actions = document.createElement('div'); actions.style.cssText = 'display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px;'; if (tracks.length > 0) { const downloadBtn = document.createElement('button'); downloadBtn.textContent = 'Download Selected'; downloadBtn.onclick = onDownload; actions.appendChild(downloadBtn); const copyBtn = document.createElement('button'); copyBtn.textContent = 'Copy Selected'; copyBtn.onclick = onCopy; actions.appendChild(copyBtn); } content.appendChild(actions); return content; } } // Video Selector with improved structure class VideoSelector { constructor() { this.selectedVideos = new Map(); this.selectionActive = false; } toggleVideoSelection() { if (!this.selectionActive) { this.activateSelection(); } else { this.deactivateSelection(); } } activateSelection() { this.selectionActive = true; this.selectedVideos.clear(); this.addSpacingStyle(); const videos = document.querySelectorAll(CONFIG.SELECTORS.VIDEO_ELEMENTS); videos.forEach(video => { // Skip shorts if (video.closest('ytd-reel-shelf-renderer') || video.closest('ytd-shorts') || video.closest('ytm-shorts-lockup-view-model')) { return; } video.classList.add('yt-sub-video-padding'); this.addCheckbox(video); }); this.addSelectionUI(); } addSpacingStyle() { if (!document.getElementById('yt-sub-styles')) { const style = document.createElement('style'); style.id = 'yt-sub-styles'; style.textContent = ` .yt-sub-video-padding { padding-left: 30px !important; position: relative !important; } `; document.head.appendChild(style); } } addCheckbox(videoElement) { if (videoElement.querySelector('.yt-sub-checkbox-wrapper')) return; const wrapper = document.createElement('div'); wrapper.className = 'yt-sub-checkbox-wrapper'; wrapper.style.cssText = CONFIG.STYLES.CHECKBOX_WRAPPER; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.className = 'yt-sub-checkbox'; checkbox.style.cssText = CONFIG.STYLES.CHECKBOX; wrapper.appendChild(checkbox); videoElement.insertBefore(wrapper, videoElement.firstChild); checkbox.addEventListener('change', (e) => { const videoId = this.extractVideoId(videoElement); const title = this.extractTitle(videoElement); if (videoId && title) { if (e.target.checked) { this.selectedVideos.set(videoId, { title }); } else { this.selectedVideos.delete(videoId); } this.updateSelectionCount(); } }); } extractVideoId(video) { const thumbnail = video.querySelector('a#thumbnail[href*="/watch?v="]'); if (thumbnail?.href) { const url = new URL(thumbnail.href); return url.searchParams.get('v'); } const links = video.querySelectorAll('a[href*="/watch?v="]'); for (const link of links) { try { const url = new URL(link.href); const videoId = url.searchParams.get('v'); if (videoId) return videoId; } catch { continue; } } return null; } extractTitle(video) { for (const selector of CONFIG.SELECTORS.TITLE_SELECTORS) { const element = video.querySelector(selector); if (element) { const title = element.textContent?.trim() || element.getAttribute('title')?.trim(); if (title) return title; } } const videoId = this.extractVideoId(video); return videoId ? `Video_${videoId}` : 'Untitled Video'; } addSelectionUI() { const ui = document.createElement('div'); ui.id = 'yt-sub-selection-ui'; ui.style.cssText = ` position: fixed; top: 10px; right: 10px; background: rgba(0, 0, 0, 0.8); color: white; padding: 10px 20px; border-radius: 4px; z-index: 9999; font-size: 14px; `; ui.innerHTML = ` <div style="margin-bottom: 10px;"> Selected: <span id="yt-sub-count">0</span> videos </div> <div style="display: flex; gap: 10px; margin-bottom: 10px;"> <button id="yt-sub-select-all" style="background: #065fd4; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">Select All</button> <button id="yt-sub-select-x" style="background: #065fd4; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">Select First X</button> </div> <div style="display: flex; gap: 10px;"> <button id="yt-sub-download" style="background: #065fd4; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">Download</button> <button id="yt-sub-cancel" style="background: #909090; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;">Cancel</button> </div> `; document.body.appendChild(ui); document.getElementById('yt-sub-download').onclick = () => { if (this.selectedVideos.size > 0) { this.processSelectedVideos(); } else { UIComponents.showToast('Please select at least one video'); } }; document.getElementById('yt-sub-cancel').onclick = () => { this.deactivateSelection(); }; document.getElementById('yt-sub-select-all').onclick = () => { const checkboxes = document.querySelectorAll('.yt-sub-checkbox'); checkboxes.forEach(checkbox => { if (!checkbox.checked) { checkbox.click(); } }); }; document.getElementById('yt-sub-select-x').onclick = () => { const input = prompt('How many videos would you like to select?'); const number = parseInt(input); if (!isNaN(number) && number > 0) { const checkboxes = document.querySelectorAll('.yt-sub-checkbox'); checkboxes.forEach((checkbox, index) => { if (index < number) { if (!checkbox.checked) { checkbox.click(); } } else if (checkbox.checked) { checkbox.click(); } }); } else if (input !== null) { UIComponents.showToast('Please enter a valid number'); } }; } updateSelectionCount() { const countElement = document.getElementById('yt-sub-count'); if (countElement) { countElement.textContent = this.selectedVideos.size.toString(); } } deactivateSelection() { this.selectionActive = false; this.selectedVideos.clear(); document.querySelectorAll('.yt-sub-video-padding').forEach(video => { video.classList.remove('yt-sub-video-padding'); }); document.querySelectorAll('.yt-sub-checkbox-wrapper').forEach(cb => cb.remove()); document.getElementById('yt-sub-selection-ui')?.remove(); document.getElementById('yt-sub-styles')?.remove(); } async processSelectedVideos() { if (this.selectedVideos.size === 0) { UIComponents.showToast('Please select at least one video'); return; } const loading = UIComponents.showLoading('Fetching subtitles...'); try { const videos = Array.from(this.selectedVideos.entries()); const r###lts = await Promise.all( videos.map(async ([videoId, data]) => { try { const tracks = await SubtitleService.fetchSubtitleTracks(videoId); return { ...data, videoId, subtitles: tracks || [] }; } catch (error) { console.error(`Failed to fetch subtitles for ${videoId}:`, error); return { ...data, videoId, subtitles: [] }; } }) ); this.showSubtitleDialog(r###lts); } catch (error) { UIComponents.showToast(CONFIG.MESSAGES.ERROR.FETCH); console.error('Failed to process videos:', error); } finally { loading.remove(); } } showSubtitleDialog(videos) { const getSelectedTracks = () => { return Array.from(document.querySelectorAll('input[type="checkbox"]:checked')) .map(cb => { const videoId = cb.dataset.videoId; const langCode = cb.dataset.lang; const video = videos.find(v => v.videoId === videoId); const track = video?.subtitles.find(t => t.languageCode === langCode); return track ? { ...track, videoTitle: video.title } : null; }) .filter(Boolean); }; const content = document.createElement('div'); // Format selector const formatDiv = document.createElement('div'); formatDiv.innerHTML = ` <div style="margin-bottom: 15px;"> <label style="margin-right: 10px;"> <input type="radio" name="format" value="srt" checked> SRT </label> <label> <input type="radio" name="format" value="txt"> Plain Text </label> </div> `; content.appendChild(formatDiv); //batch Select const selectAllDiv = document.createElement('div'); selectAllDiv.style.marginBottom = '15px'; const selectAllBtn = document.createElement('button'); selectAllBtn.textContent = 'Select All Subtitles'; selectAllBtn.style.cssText = 'background: #065fd4; color: white; border: none; padding: 5px 10px; border-radius: 4px; cursor: pointer;'; selectAllBtn.onclick = () => { const checkboxes = content.querySelectorAll('input[type="checkbox"][data-video-id]'); checkboxes.forEach(checkbox => { checkbox.checked = true; }); }; selectAllDiv.appendChild(selectAllBtn); content.appendChild(selectAllDiv); // Videos and their subtitles videos.forEach(video => { const videoDiv = document.createElement('div'); videoDiv.style.cssText = 'margin-bottom: 20px; padding: 10px; border: 1px solid #ddd; border-radius: 4px;'; const title = document.createElement('h3'); title.style.margin = '0 0 10px 0'; title.textContent = video.title; videoDiv.appendChild(title); if (video.subtitles.length > 0) { video.subtitles.forEach(track => { const trackDiv = document.createElement('div'); trackDiv.style.margin = '5px 0'; trackDiv.innerHTML = ` <label> <input type="checkbox" data-video-id="${video.videoId}" data-lang="${track.languageCode}"> ${track.languageName} </label> `; videoDiv.appendChild(trackDiv); }); } else { const noSubs = document.createElement('p'); noSubs.style.color = '#c00'; noSubs.textContent = CONFIG.MESSAGES.NO_SUBTITLE; videoDiv.appendChild(noSubs); } content.appendChild(videoDiv); }); // Action buttons const actions = document.createElement('div'); actions.style.cssText = 'display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px;'; const downloadBtn = document.createElement('button'); downloadBtn.textContent = 'Download Selected'; downloadBtn.onclick = async () => { const tracks = getSelectedTracks(); const format = document.querySelector('input[name="format"]:checked').value; if (tracks.length === 0) { UIComponents.showToast('Please select at least one subtitle'); return; } try { const selectedTracks = tracks.map(track => ({ ...track, baseUrl: track.baseUrl })); await SubtitleService.downloadSubtitles(selectedTracks, format); UIComponents.showToast('Download complete!'); } catch (error) { UIComponents.showToast(CONFIG.MESSAGES.ERROR.FETCH); console.error('Download error:', error); } }; actions.appendChild(downloadBtn); const copyBtn = document.createElement('button'); copyBtn.textContent = 'Copy Selected'; copyBtn.onclick = async () => { const tracks = getSelectedTracks(); const format = document.querySelector('input[name="format"]:checked').value; if (tracks.length === 0) { UIComponents.showToast('Please select at least one subtitle'); return; } try { await SubtitleService.copySubtitles(tracks, format); } catch (error) { UIComponents.showToast(CONFIG.MESSAGES.ERROR.COPY); console.error('Copy error:', error); } }; actions.appendChild(copyBtn); content.appendChild(actions); UIComponents.showDialog('Select Subtitles to Download', content, () => { this.deactivateSelection(); }); } } // Single Video Downloader class SingleVideoDownloader { async downloadCurrentVideo() { const videoId = this.getCurrentVideoId(); if (!videoId) { UIComponents.showToast(CONFIG.MESSAGES.ERROR.NO_VIDEO); return; } const loading = UIComponents.showLoading(); try { const tracks = await SubtitleService.fetchSubtitleTracks(videoId); if (!tracks?.length) { UIComponents.showToast(CONFIG.MESSAGES.NO_SUBTITLE); return; } const videoTitle = document.title.split(' - YouTube')[0] || `Video_${videoId}`; this.showSubtitleDialog(tracks, videoTitle); } catch (error) { UIComponents.showToast(CONFIG.MESSAGES.ERROR.FETCH); console.error('Failed to fetch subtitles:', error); } finally { loading.remove(); } } getCurrentVideoId() { const urlParams = new URLSearchParams(window.location.search); return urlParams.get('v'); } showSubtitleDialog(tracks, videoTitle) { const getSelectedTracks = () => { return Array.from(document.querySelectorAll('input[type="checkbox"]:checked')) .map(cb => { const track = tracks.find(t => t.languageCode === cb.dataset.lang); return track ? { ...track, videoTitle } : null; }) .filter(Boolean); }; const content = UIComponents.creat###btitleDialog( tracks, CONFIG.FORMATS.SRT, async () => { const selectedTracks = getSelectedTracks(); const format = document.querySelector('input[name="format"]:checked').value; if (selectedTracks.length === 0) { UIComponents.showToast('Please select at least one subtitle'); return; } try { const tracksWithTitle = selectedTracks.map(track => ({ ...track, videoTitle })); await SubtitleService.downloadSubtitles(tracksWithTitle, format); UIComponents.showToast('Download complete!'); } catch (error) { UIComponents.showToast(CONFIG.MESSAGES.ERROR.FETCH); console.error('Download error:', error); } }, async () => { const selectedTracks = getSelectedTracks(); const format = document.querySelector('input[name="format"]:checked').value; if (selectedTracks.length === 0) { UIComponents.showToast('Please select at least one subtitle'); return; } try { await SubtitleService.copySubtitles(selectedTracks, format, videoTitle); } catch (error) { UIComponents.showToast(CONFIG.MESSAGES.ERROR.COPY); console.error('Copy error:', error); } } ); UIComponents.showDialog('Select Subtitles to Download', content); } } // Main manager class class YouTub###btitleManager { constructor() { this.singleMode = new SingleVideoDownloader(); this.bulkMode = new VideoSelector(); this.registerCommands(); this.setupNavigationHandler(); } registerCommands() { GM_registerMenuCommand('Download Current Video Subtitles', () => this.singleMode.downloadCurrentVideo()); GM_registerMenuCommand('Select Videos for Subtitles', () => this.bulkMode.toggleVideoSelection()); } setupNavigationHandler() { document.addEventListener('yt-navigate-finish', () => { if (this.bulkMode.selectionActive) { this.bulkMode.deactivateSelection(); this.bulkMode.activateSelection(); } }); } } // Initialize the manager new YouTub###btitleManager(); })();