// ==UserScript== // @name IMDb to OpenSubtitles - Episode Exporter // @namespace https://github.com/MankeyDoodle // @version 1.0 // @description Extract TV show episode lists from IMDb with direct OpenSubtitles links. Export to RTF, HTML, CSV, JSON, or interactive checklist. Includes a Subtitles Launcher to batch-open subtitle pages. // @author MankeyDoodle // @license MIT // @match https://www.imdb.com/title/tt* // @grant GM_setClipboard // @grant GM_addStyle // @grant GM_xmlhttpRequest // @connect www.imdb.com // @homepageURL https://github.com/MankeyDoodle/IMDb-to-OpenSubtitles-Episode-Exporter // @supportURL https://github.com/MankeyDoodle/IMDb-to-OpenSubtitles-Episode-Exporter/issues // ==/UserScript== (function() { 'use strict'; // Icons: Phosphor Icons (https://phosphoricons.com) // License: MIT - Copyright (c) 2020 Phosphor Icons const ICONS = { clipboard: '', fileText: '', fileCsv: '', bracketsCurly: '', checkSquare: '', globe: '', spinner: '', check: '', xCircle: '', export: '', // Additional icons for launcher pause: '', play: '', stop: '', television: '', arrowClockwise: '' }; // Add styles GM_addStyle(` #imdb-extractor-container { position: fixed; top: 80px; right: 20px; z-index: 9999; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; } #imdb-extract-btn { padding: 12px 20px; background: #f5c518; color: #000; border: none; border-radius: 4px; font-weight: bold; font-size: 14px; cursor: pointer; box-shadow: 0 2px 8px rgba(0,0,0,0.3); transition: all 0.2s; } #imdb-extract-btn:hover { background: #e6b800; transform: scale(1.02); } #imdb-extract-btn:disabled { background: #888; cursor: wait; transform: none; } #imdb-extract-btn.success { background: #4caf50; color: white; } #imdb-extract-btn.ready { background: #2196f3; color: white; } #imdb-extract-btn.ready:hover { background: #1976d2; } #imdb-extract-btn.error { background: #f44336; color: white; } #imdb-export-menu { display: none; position: absolute; top: 100%; right: 0; margin-top: 8px; background: #1f1f1f; border-radius: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.4); overflow: hidden; min-width: 200px; } #imdb-export-menu.show { display: block; } .imdb-export-option { display: block; width: 100%; padding: 12px 16px; background: none; border: none; color: #fff; font-size: 14px; text-align: left; cursor: pointer; transition: background 0.2s; } .imdb-export-option:hover { background: #333; } .imdb-export-option .icon { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; margin-right: 10px; vertical-align: middle; } .imdb-export-option .icon svg { width: 16px; height: 16px; } #imdb-extract-btn .btn-icon { display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; margin-right: 6px; vertical-align: middle; } #imdb-extract-btn .btn-icon svg { width: 16px; height: 16px; } @keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } #imdb-extract-btn .btn-icon.spinning svg { animation: spin 1s linear infinite; } .imdb-menu-divider { height: 1px; background: #333; margin: 4px 0; } `); // Check if this is a TV show if (!checkIfTVShow()) return; // Create UI const container = document.createElement('div'); container.id = 'imdb-extractor-container'; const btn = document.createElement('button'); btn.id = 'imdb-extract-btn'; btn.innerHTML = `${ICONS.export}Extract Episodes`; const menu = document.createElement('div'); menu.id = 'imdb-export-menu'; menu.innerHTML = `
`; container.appendChild(btn); container.appendChild(menu); document.body.appendChild(container); // State let extractedData = null; let isExtracting = false; // Event listeners btn.addEventListener('click', async () => { if (isExtracting) return; if (!extractedData) { await extractAllEpisodes(); } if (extractedData) { menu.classList.toggle('show'); } }); document.addEventListener('click', (e) => { if (!container.contains(e.target)) { menu.classList.remove('show'); } }); menu.querySelectorAll('.imdb-export-option').forEach(option => { option.addEventListener('click', () => { const format = option.dataset.format; exportData(format); menu.classList.remove('show'); }); }); function checkIfTVShow() { const pageTitle = document.title || ''; const ogType = document.querySelector('meta[property="og:type"]')?.content || ''; const schemaType = document.querySelector('script[type="application/ld+json"]')?.textContent || ''; return pageTitle.includes('TV Series') || pageTitle.includes('TV Mini Series') || ogType.includes('tv_show') || schemaType.includes('TVSeries'); } function getShowInfo() { const showIdMatch = window.location.pathname.match(/\/title\/(tt\d+)/); const showId = showIdMatch ? showIdMatch[1] : ''; let showTitle = document.querySelector('[data-testid="hero__pageTitle"]')?.textContent?.trim() || document.title.replace(/ \(TV Series.*/, '').replace(/ \(TV Mini Series.*/, '') || 'Unknown Show'; return { showId, showTitle }; } async function fetchSeasonData(showId, season) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: `https://www.imdb.com/title/${showId}/episodes/?season=${season}`, onload: function(response) { try { const parser = new DOMParser(); const doc = parser.parseFromString(response.responseText, 'text/html'); const nextDataScript = doc.getElementById('__NEXT_DATA__'); let jsonData = null; if (nextDataScript) { jsonData = JSON.parse(nextDataScript.textContent); } const htmlEpisodes = []; const seenIds = new Set(); // Parse from HTML const titleElements = doc.querySelectorAll('.ipc-title__text'); titleElements.forEach(titleEl => { const text = titleEl.textContent?.trim() || ''; const match = text.match(/^S(\d+)\.E(\d+)\s*[∙·•\-]\s*(.+)$/i); if (!match) return; const parent = titleEl.closest('article, [class*="episode"]') || titleEl.parentElement?.parentElement?.parentElement; const link = parent?.querySelector('a[href*="/title/tt"]') || titleEl.closest('a[href*="/title/tt"]'); if (!link) return; const href = link.getAttribute('href') || ''; const idMatch = href.match(/\/title\/(tt\d+)/); if (!idMatch || seenIds.has(idMatch[1])) return; seenIds.add(idMatch[1]); htmlEpisodes.push({ id: idMatch[1], title: match[3].trim() || 'Unknown', seasonNumber: parseInt(match[1]), episodeNumber: parseInt(match[2]) }); }); resolve({ jsonData, htmlEpisodes, season, truncated: htmlEpisodes.length >= 50 }); } catch (e) { reject(e); } }, onerror: reject }); }); } function extractEpisodesFromData(fetchResult, seasonNum) { const { jsonData, htmlEpisodes } = fetchResult; const episodes = []; const seenIds = new Set(); // Try HTML parsing first (more reliable structure) if (htmlEpisodes && htmlEpisodes.length > 0) { htmlEpisodes.forEach(ep => { if (!seenIds.has(ep.id)) { seenIds.add(ep.id); episodes.push({ season: ep.seasonNumber || seasonNum, episode: ep.episodeNumber || 0, title: ep.title, imdbId: ep.id }); } }); // Check if HTML data has valid info (titles and episode numbers) const validCount = episodes.filter(ep => ep.title !== 'Unknown' && ep.episode > 0).length; if (validCount > 0) { return episodes; } } // Fall back to JSON if HTML didn't have good data if (jsonData) { const jsonEpisodes = []; const jsonSeenIds = new Set(); const pageProps = jsonData?.props?.pageProps; const episodesData = pageProps?.contentData?.section?.episodes?.items || pageProps?.mainColumnData?.episodes?.episodes?.edges || []; episodesData.forEach(item => { const ep = item.node || item; const epId = ep.id || ep.tconst; const epTitle = ep.titleText?.text || ep.originalTitleText?.text || ep.title || 'Unknown'; const epNum = ep.series?.episodeNumber?.episodeNumber || ep.episodeNumber || 0; const sNum = ep.series?.episodeNumber?.seasonNumber || ep.seasonNumber || seasonNum; if (epId && !jsonSeenIds.has(epId)) { jsonSeenIds.add(epId); jsonEpisodes.push({ season: parseInt(sNum) || seasonNum, episode: parseInt(epNum) || 0, title: epTitle, imdbId: epId }); } }); // Check if JSON has better data const jsonValidCount = jsonEpisodes.filter(ep => ep.title !== 'Unknown' && ep.episode > 0).length; if (jsonValidCount > episodes.filter(ep => ep.title !== 'Unknown' && ep.episode > 0).length) { return jsonEpisodes; } } return episodes; } async function extractAllEpisodes() { const { showId, showTitle } = getShowInfo(); if (!showId) { showError('Could not find show ID'); return; } isExtracting = true; btn.disabled = true; btn.innerHTML = `${ICONS.spinner}Fetching...`; try { // Fetch season 1 to get season list const season1Data = await fetchSeasonData(showId, 1); let seasonNumbers = []; if (season1Data) { const pageProps = season1Data.jsonData?.props?.pageProps; const seasons = pageProps?.contentData?.section?.seasons || pageProps?.mainColumnData?.episodes?.seasons || []; seasonNumbers = seasons .map(s => parseInt(s.value || s.number || s)) .filter(n => !isNaN(n) && n > 0) .sort((a, b) => a - b); } if (seasonNumbers.length === 0) seasonNumbers = [1]; const totalSeasons = seasonNumbers.length; const maxSeason = Math.max(...seasonNumbers); // Warning for shows with many seasons if (totalSeasons > 15) { const estimatedEpisodes = totalSeasons * 50; const warningMsg = `⚠️ This show has ${totalSeasons} seasons!\n\n` + `Due to IMDb limitations, only the first 50 episodes per season can be extracted.\n` + `Estimated: up to ${estimatedEpisodes} episodes.\n\n` + `Continue?`; if (!confirm(warningMsg)) { isExtracting = false; btn.disabled = false; btn.innerHTML = `${ICONS.export}Extract Episodes`; return; } } const allEpisodes = []; const startTime = Date.now(); let truncatedSeasons = 0; for (let i = 0; i < seasonNumbers.length; i++) { const seasonNum = seasonNumbers[i]; const episodeCount = allEpisodes.length > 0 ? ` | ${allEpisodes.length} eps` : ''; btn.innerHTML = `${ICONS.spinner}S${seasonNum}/${maxSeason}${episodeCount}`; try { const data = i === 0 ? season1Data : await fetchSeasonData(showId, seasonNum); if (data) { if (data.truncated) truncatedSeasons++; allEpisodes.push(...extractEpisodesFromData(data, seasonNum)); } } catch (e) { console.log(`Failed to fetch season ${seasonNum}:`, e); } if (i < seasonNumbers.length - 1) { await new Promise(r => setTimeout(r, 300)); } } if (allEpisodes.length === 0) { throw new Error('No episodes found'); } allEpisodes.sort((a, b) => { if (a.season !== b.season) return a.season - b.season; return a.episode - b.episode; }); // Add URLs allEpisodes.forEach(ep => { ep.imdbUrl = `https://www.imdb.com/title/${ep.imdbId}/`; ep.openSubtitlesUrl = `https://www.opensubtitles.org/en/search/imdbid-${ep.imdbId.replace('tt', '')}`; }); extractedData = { showTitle, showId, showUrl: `https://www.imdb.com/title/${showId}/`, totalSeasons, totalEpisodes: allEpisodes.length, episodes: allEpisodes }; btn.disabled = false; const elapsed = Math.round((Date.now() - startTime) / 1000); const timeText = elapsed > 10 ? ` (${elapsed}s)` : ''; btn.innerHTML = `${ICONS.check}${allEpisodes.length} episodes${timeText}`; btn.classList.add('success'); // Show truncation warning if any seasons had 50+ episodes if (truncatedSeasons > 0) { setTimeout(() => { alert(`Note: ${truncatedSeasons} season(s) may have been truncated.\n\n` + `IMDb only shows the first 50 episodes per season on their episode pages.\n\n` + `Extracted ${allEpisodes.length} episodes total.`); }, 100); } setTimeout(() => { btn.innerHTML = `${ICONS.export}${allEpisodes.length} Episodes Ready`; btn.classList.remove('success'); btn.classList.add('ready'); }, 2000); } catch (err) { showError(err.message); } isExtracting = false; } function showError(message) { btn.disabled = false; btn.innerHTML = `${ICONS.xCircle}${message}`; btn.classList.add('error'); setTimeout(() => { btn.innerHTML = `${ICONS.export}Extract Episodes`; btn.classList.remove('error'); }, 3000); } function exportData(format) { if (!extractedData) return; const { showTitle, showId, totalSeasons, totalEpisodes, episodes } = extractedData; const safeTitle = showTitle.replace(/[^a-z0-9]/gi, '_'); switch (format) { case 'clipboard': copyToClipboard(); break; case 'rtf': downloadFile(generateRTF(), `${safeTitle}_episodes.rtf`, 'application/rtf'); break; case 'html': downloadFile(generateHTML(), `${safeTitle}_episodes.html`, 'text/html'); break; case 'csv': downloadFile(generateCSV(), `${safeTitle}_episodes.csv`, 'text/csv'); break; case 'json': downloadFile(JSON.stringify(extractedData, null, 2), `${safeTitle}_episodes.json`, 'application/json'); break; case 'checklist': downloadFile(generateChecklist(), `${safeTitle}_checklist.html`, 'text/html'); break; case 'open-subs': openAllSubtitles(); break; } } // Dark mode season colors for launcher const DARK_SEASON_COLORS = [ { bg: '#1a237e20', border: '#5c6bc0', header: '#7986cb' }, // Indigo { bg: '#4a148c20', border: '#ab47bc', header: '#ce93d8' }, // Purple { bg: '#1b5e2020', border: '#66bb6a', header: '#81c784' }, // Green { bg: '#e6510020', border: '#ff9800', header: '#ffb74d' }, // Orange { bg: '#88006420', border: '#f06292', header: '#f48fb1' }, // Pink { bg: '#00696320', border: '#4dd0e1', header: '#80deea' }, // Cyan { bg: '#ff6f0020', border: '#ffca28', header: '#ffd54f' }, // Amber { bg: '#33691e20', border: '#9ccc65', header: '#aed581' }, // Light Green { bg: '#311b9220', border: '#7e57c2', header: '#b39ddb' }, // Deep Purple { bg: '#b7180020', border: '#ef5350', header: '#e57373' }, // Red ]; function getDarkSeasonColor(seasonNum) { return DARK_SEASON_COLORS[(seasonNum - 1) % DARK_SEASON_COLORS.length]; } async function openAllSubtitles() { const { episodes, showTitle, showId } = extractedData; const total = episodes.length; // Get unique seasons for TOC const seasons = [...new Set(episodes.map(ep => ep.season))].sort((a, b) => a - b); // Build launcher HTML page const launcherHTML = ` ${showTitle} - OpenSubtitles Links

${showTitle}

${total} episodes · OpenSubtitles links
IMPORTANT: Enable Pop-ups First!
Before clicking any "Open" button: Make sure pop-ups are allowed for this page.
In most browsers: click the pop-up blocked icon in the address bar → "Always allow pop-ups from this site"

BATCH MODE: Links open in batches (configurable above). After each batch, you'll be prompted to Continue or Stop.
SEASON MODE: Opens one complete season, then pauses before the next season.

This prevents your browser from freezing! You can also click individual links below.
⚡ Photosensitivity Warning
Opening multiple tabs rapidly may cause screen flashing as pages load with white backgrounds.
If you have photosensitive epilepsy or are sensitive to flashing lights, please use caution or have someone else operate this tool.
Batch size:
Opening...
↑ Back to top'; currentSeason = ep.season; const color = getDarkSeasonColor(currentSeason); const range = seasonRanges[currentSeason]; html += '
Season ' + currentSeason + '
↑ Back to top
'; return html; })()} `; // Download launcher as HTML file const safeTitle = showTitle.replace(/[^a-z0-9]/gi, '_'); const blob = new Blob([launcherHTML], { type: 'text/html' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `${safeTitle}_OpenSubtitles_Launcher.html`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); btn.innerHTML = `${ICONS.check}Launcher downloaded!`; btn.classList.add('success'); alert('Launcher downloaded!\n\nOpen the downloaded HTML file in your browser, then click "Open All" to open all subtitle links.\n\nOpening from a local file avoids popup blocking.'); setTimeout(() => { btn.innerHTML = `${ICONS.export}Export Episodes`; btn.classList.remove('success'); }, 2000); } // Season colors for visual distinction const SEASON_COLORS = [ { bg: '#e3f2fd', border: '#2196f3', header: '#1565c0' }, // Blue { bg: '#f3e5f5', border: '#9c27b0', header: '#7b1fa2' }, // Purple { bg: '#e8f5e9', border: '#4caf50', header: '#2e7d32' }, // Green { bg: '#fff3e0', border: '#ff9800', header: '#e65100' }, // Orange { bg: '#fce4ec', border: '#e91e63', header: '#c2185b' }, // Pink { bg: '#e0f7fa', border: '#00bcd4', header: '#00838f' }, // Cyan { bg: '#fff8e1', border: '#ffc107', header: '#ff8f00' }, // Amber { bg: '#f1f8e9', border: '#8bc34a', header: '#558b2f' }, // Light Green { bg: '#ede7f6', border: '#673ab7', header: '#512da8' }, // Deep Purple { bg: '#ffebee', border: '#f44336', header: '#c62828' }, // Red ]; function getSeasonColor(seasonNum) { return SEASON_COLORS[(seasonNum - 1) % SEASON_COLORS.length]; } function generateHTML() { const { showTitle, showId, totalSeasons, totalEpisodes, episodes } = extractedData; const showUrl = `https://www.imdb.com/title/${showId}/`; // Get unique seasons for TOC const seasons = [...new Set(episodes.map(ep => ep.season))].sort((a, b) => a - b); let html = ` ${showTitle} - Episode List

${showTitle}

IMDb ID: ${showId}
Seasons: ${totalSeasons}
Episodes: ${totalEpisodes}
`; let currentSeason = null; episodes.forEach((ep, idx) => { if (ep.season !== currentSeason) { if (currentSeason !== null) { html += `\n ↑ Back to top\n `; } currentSeason = ep.season; const color = getSeasonColor(currentSeason); html += `\n

Season ${currentSeason}

`; } html += `
E${ep.episode} – ${ep.title}
`; }); if (currentSeason !== null) { html += `\n ↑ Back to top\n
`; } html += ` `; return html; } function generateRTF() { const { showTitle, showId, totalSeasons, totalEpisodes, episodes } = extractedData; const showUrl = `https://www.imdb.com/title/${showId}/`; // RTF helper: create clickable hyperlink const rtfLink = (url, text) => `{\\field{\\*\\fldinst{HYPERLINK "${url}"}}{\\fldrslt\\cf1\\ul ${text}}}`; // Escape special RTF characters const escapeRTF = (str) => str.replace(/\\/g, '\\\\').replace(/\{/g, '\\{').replace(/\}/g, '\\}'); // RTF header with font and color table (blue for links) let rtf = `{\\rtf1\\ansi\\deff0\n`; rtf += `{\\fonttbl{\\f0\\fswiss Arial;}}\n`; rtf += `{\\colortbl;\\red0\\green102\\blue204;}\n`; // cf1 = blue for links rtf += `\\f0\\fs24\n`; // Default font: Arial 12pt // Title as clickable link (bold, larger) rtf += `{\\fs36\\b ${rtfLink(showUrl, escapeRTF(showTitle))}}\\par\n`; rtf += `${rtfLink(showUrl, showUrl)}\\par\n`; rtf += `\\par\n`; // Show info rtf += `{\\b IMDb ID:} ${rtfLink(showUrl, showId)}\\par\n`; rtf += `{\\b Seasons:} ${totalSeasons}\\par\n`; rtf += `{\\b Episodes:} ${totalEpisodes}\\par\n`; rtf += `\\par\n`; rtf += `\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\emdash\\par\n`; rtf += `\\par\n`; let currentSeason = null; episodes.forEach(ep => { if (ep.season !== currentSeason) { if (currentSeason !== null) { rtf += `\\par\n`; } currentSeason = ep.season; rtf += `{\\fs28\\b SEASON ${currentSeason}}\\par\n`; rtf += `\\par\n`; } rtf += `{\\b E${ep.episode}} \\endash ${escapeRTF(ep.title)}\\par\n`; rtf += `${rtfLink(ep.imdbUrl, 'IMDb (' + ep.imdbId + ')')} \\bullet ${rtfLink(ep.openSubtitlesUrl, 'Subtitles')}\\par\n`; rtf += `\\par\n`; }); rtf += `}`; // Close RTF document return rtf; } function generateCSV() { const { showTitle, showId, totalSeasons, totalEpisodes, episodes } = extractedData; const showUrl = `https://www.imdb.com/title/${showId}/`; // Header with show info let csv = `"${showTitle.replace(/"/g, '""')}"\n`; csv += `"IMDb ID:",${showId},${showUrl}\n`; csv += `"Seasons:",${totalSeasons}\n`; csv += `"Episodes:",${totalEpisodes}\n`; csv += '\n'; // Column headers csv += 'Season,Episode,Title,IMDb ID,IMDb URL,OpenSubtitles URL\n'; let currentSeason = null; episodes.forEach(ep => { // Add blank row and season header before each new season if (ep.season !== currentSeason) { if (currentSeason !== null) { csv += '\n'; // Blank row between seasons } currentSeason = ep.season; csv += `"=== SEASON ${currentSeason} ===","","","","",""\n`; } const title = ep.title.replace(/"/g, '""'); csv += `${ep.season},${ep.episode},"${title}",${ep.imdbId},${ep.imdbUrl},${ep.openSubtitlesUrl}\n`; }); return csv; } function generateChecklist() { const { showTitle, showId, showUrl, totalSeasons, episodes } = extractedData; const storageKey = `checklist_${showId}`; // Group episodes by season first let seasonEpisodes = {}; episodes.forEach(ep => { if (!seasonEpisodes[ep.season]) seasonEpisodes[ep.season] = []; seasonEpisodes[ep.season].push(ep); }); const seasons = Object.keys(seasonEpisodes).sort((a, b) => a - b); let html = ` ${showTitle} - Watch Checklist

${showTitle}

${showId} · ${totalSeasons} Seasons · ${episodes.length} Episodes
0 / ${episodes.length} watched (0%)
`; seasons.forEach(season => { const seasonEps = seasonEpisodes[season]; const color = getSeasonColor(parseInt(season)); html += `
Season ${season} 0/${seasonEps.length}
`; seasonEps.forEach(ep => { const epCode = `S${String(ep.season).padStart(2, '0')}E${String(ep.episode).padStart(2, '0')}`; html += `
${epCode}${ep.title}
`; }); html += ` ↑ Back to top
`; }); html += ` `; return html; } async function copyToClipboard() { const { showTitle, showId, totalSeasons, totalEpisodes, episodes } = extractedData; const showUrl = `https://www.imdb.com/title/${showId}/`; // Plain text version with URLs let plainText = `${showTitle}\n`; plainText += `${showUrl}\n\n`; plainText += `IMDb ID: ${showId}\n`; plainText += `Seasons: ${totalSeasons}\n`; plainText += `Episodes: ${totalEpisodes}\n\n`; plainText += `${'─'.repeat(40)}\n\n`; let currentSeason = null; episodes.forEach(ep => { if (ep.season !== currentSeason) { currentSeason = ep.season; plainText += `SEASON ${currentSeason}\n\n`; } plainText += `E${ep.episode} – ${ep.title}\n`; plainText += `IMDb: ${ep.imdbUrl}\n`; plainText += `Subs: ${ep.openSubtitlesUrl}\n\n`; }); // HTML version with clickable links let html = ''; html += `

${showTitle}

`; html += `

IMDb ID: ${showId}
`; html += `Seasons: ${totalSeasons}
`; html += `Episodes: ${totalEpisodes}


`; currentSeason = null; episodes.forEach(ep => { if (ep.season !== currentSeason) { currentSeason = ep.season; html += `

Season ${currentSeason}

`; } html += `

E${ep.episode} – ${ep.title}
`; html += `IMDb (${ep.imdbId}) · Subtitles

`; }); html += ''; try { await navigator.clipboard.write([ new ClipboardItem({ 'text/html': new Blob([html], { type: 'text/html' }), 'text/plain': new Blob([plainText], { type: 'text/plain' }) }) ]); } catch (e) { GM_setClipboard(plainText, 'text'); } btn.innerHTML = `${ICONS.check}Copied!`; btn.classList.add('success'); setTimeout(() => { btn.innerHTML = `${ICONS.export}Export Episodes`; btn.classList.remove('success'); }, 2000); } function downloadFile(content, filename, mimeType) { const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); btn.innerHTML = `${ICONS.check}Downloaded!`; btn.classList.add('success'); setTimeout(() => { btn.innerHTML = `${ICONS.export}Export Episodes`; btn.classList.remove('success'); }, 2000); } })();