userscript to save youtube video for watch later in firefox

8 min read Original article ↗
// ==UserScript== // @name youtube-save-watch-later // @namespace http://tampermonkey.net/ // @version 2025-01-27 // @description Save YouTube video to Watch Later playlist // @author beenotung // @match https://www.youtube.com/* // @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com // @grant none // ==/UserScript== /** * YouTube Watch Later Userscript - Simplified Passive Version * 1. Click button → save video ID + title to localStorage * 2. On every page visit → check if videos in pending list appear in sidebar * 3. If found → save it → remove from pending list */ ;(function () { 'use strict' function getCurrentVideoId() { let url = new URL(window.location.href) return url.searchParams.get('v') } function getCurrentVideoTitle() { let titleEl = document.querySelector( 'h1.ytd-watch-metadata yt-formatted-string, h1.ytd-video-primary-info-renderer', ) return titleEl ? titleEl.textContent.trim() : null } // Pending videos: { videoId: { title: "..." } } let pendingVideos = (() => { let data try { // Migrate from old key name let oldData = localStorage.getItem('_ytWatchLaterStack') if (oldData) { data = JSON.parse(oldData) localStorage.setItem('_ytWatchLaterPending', oldData) localStorage.removeItem('_ytWatchLaterStack') } else { data = JSON.parse( localStorage.getItem('_ytWatchLaterPending') || '{}', ) } } catch (e) { data = {} } return { has: videoId => videoId in data, get: () => data, save: () => localStorage.setItem( '_ytWatchLaterPending', JSON.stringify(data), ), add: (videoId, title) => { data[videoId] = { title: title || null } pendingVideos.save() console.log( `➕ Added ${videoId} to pending list: ${ title || 'no title' }`, ) }, remove: videoId => { let title = data[videoId]?.title || 'unknown' delete data[videoId] pendingVideos.save() console.log(`➖ Removed ${videoId} from pending list: ${title}`) }, getAll: () => Object.keys(data), } })() // Check if video is in sidebar and save it async function checkAndSaveFromSidebar(targetVideoId) { // Skip if we're on the target video's own page let currentVideoId = getCurrentVideoId() if (currentVideoId === targetVideoId) { console.log( `⏭️ Skipping ${targetVideoId} - currently on its own page`, ) return false } console.log(`🔍 Looking for ${targetVideoId} in sidebar...`) // Wait for sidebar links to appear let links = await waitFor('sidebar links', () => { let found = document.querySelectorAll( 'yt-lockup-view-model a[href*="/watch?v="], ytd-compact-video-renderer a[href*="/watch?v="]', ) return found.length > 0 ? found : null }) console.log(`📺 Found ${links.length} sidebar videos`) for (let link of links) { try { let url = new URL(link.href, window.location.origin) // Skip playlists FIRST (before checking video ID) // RD = Radio/Mix playlist (includes RDMM), PL = User playlist let listParam = url.searchParams.get('list') if ( listParam && (listParam.startsWith('RD') || listParam.startsWith('PL')) ) { continue } let videoId = url.searchParams.get('v') if (videoId !== targetVideoId) continue let container = link.closest( 'yt-lockup-view-model, ytd-compact-video-renderer', ) if (!container) continue let title = pendingVideos.get()[targetVideoId]?.title || 'unknown title' console.log( `✅ Found ${targetVideoId} (${title}) in sidebar, attempting to save...`, ) container.scrollIntoView({ behavior: 'smooth', block: 'center', }) await sleep(300) // Try multiple hover targets and events let thumbnail = container.querySelector( 'a.yt-lockup-view-model__content-image, a#thumbnail', ) || link // Hover on both container and thumbnail let hoverTargets = [container, thumbnail, link] for (let target of hoverTargets) { target.dispatchEvent( new MouseEvent('mouseenter', { bubbles: true, cancelable: true, view: window, }), ) target.dispatchEvent( new MouseEvent('mouseover', { bubbles: true, cancelable: true, view: window, }), ) } await sleep(200) // Wait for Watch Later button to appear after hover let watchLaterBtn = await waitFor( `Watch Later button for ${targetVideoId}`, () => { // Check in container first let btn = container.querySelector( 'button[aria-label="Watch Later"]', ) if (btn) return btn // Check in hover overlay let overlay = container.querySelector( 'yt-thumbnail-hover-overlay-toggle-actions-view-model', ) if (overlay) { btn = overlay.querySelector( 'button[aria-label="Watch Later"]', ) if (btn) return btn } // Check all overlays and match by position let overlays = document.querySelectorAll( 'yt-thumbnail-hover-overlay-toggle-actions-view-model', ) let containerRect = container.getBoundingClientRect() for (let ov of overlays) { let rect = ov.getBoundingClientRect() if ( Math.abs(rect.top - containerRect.top) < 50 && Math.abs(rect.left - containerRect.left) < 50 ) { btn = ov.querySelector( 'button[aria-label="Watch Later"]', ) if (btn) return btn } } return null }, 3000, ).catch(() => null) if (!watchLaterBtn) { console.log( `⚠️ Watch Later button not found for ${targetVideoId}`, ) continue } console.log( `🖱️ Clicking Watch Later button for ${targetVideoId}`, ) watchLaterBtn.click() // Wait for success toast try { let toast = await waitFor('success toast', () => { let toasts = document.querySelectorAll( 'ytd-snackbar, yt-snackbar-renderer', ) for (let t of toasts) { let text = (t.textContent || '').toLowerCase() if ( text.includes('watch later') && (text.includes('added') || text.includes('saved')) ) { return t } } return null }) console.log(`✅ Success toast found for ${targetVideoId}`) pendingVideos.remove(targetVideoId) return true } catch (e) { // Timeout or error - assume success console.log( `✅ Assuming success for ${targetVideoId} (no toast found)`, ) pendingVideos.remove(targetVideoId) return true } } catch (e) { console.error(`⚠️ Error processing ${targetVideoId}:`, e) } } console.log(`❌ ${targetVideoId} not found in sidebar`) return false } // Process pending videos on page visit let isProcessing = false let processingVideoId = null async function processPendingVideos() { if (isProcessing) { console.log('⏳ Already processing pending videos, skipping...') return } isProcessing = true try { let videoIds = pendingVideos.getAll() console.log(`📋 Processing ${videoIds.length} pending video(s)`) if (videoIds.length === 0) return for (let videoId of videoIds) { // Skip if we just tried this video (prevent immediate retry loop) if (processingVideoId === videoId) { console.log(`⏭️ Skipping ${videoId} - just processed`) continue } processingVideoId = videoId await checkAndSaveFromSidebar(videoId) processingVideoId = null } } catch (error) { console.error('❌ Error processing pending videos:', error) } finally { isProcessing = false processingVideoId = null } } // Add current video to pending list function addToWatchLater() { let videoId = getCurrentVideoId() if (!videoId) { console.error('❌ Video ID not found') return } if (pendingVideos.has(videoId)) { console.log(`ℹ️ ${videoId} already in pending list`) return } let title = getCurrentVideoTitle() pendingVideos.add(videoId, title) } // Create button (matching YouTube's button structure) function createWatchLaterButton() { let container = document.createElement('div') container.innerHTML = ` <yt-button-view-model class="ytd-menu-renderer"> <button-view-model class="ytSpecButtonViewModelHost"> <button class="yt-spec-button-shape-next yt-spec-button-shape-next--tonal yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--icon-leading" aria-label="Save to Watch Later" title="Save to Watch Later"> <div aria-hidden="true" class="yt-spec-button-shape-next__icon"> <span class="ytIconWrapperHost" style="width: 24px; height: 24px;"> <span class="yt-icon-shape ytSpecIconShapeHost"> <div style="width: 100%; height: 100%; display: block; fill: currentcolor;"> <svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24" focusable="false" aria-hidden="true"> <path d="M14.97 16.95 10 13.87V7h2v5.76l4.03 2.49-1.06 1.7zM12 3c-4.96 0-9 4.04-9 9s4.04 9 9 9 9-4.04 9-9-4.04-9-9-9m0-1c5.52 0 10 4.48 10 10s-4.48 10-10 10S2 17.52 2 12 6.48 2 12 2z"></path> </svg> </div> </span> </span> </div> <div class="yt-spec-button-shape-next__button-text-content">Watch Later</div> </button> </button-view-model> </yt-button-view-model> ` let button = container.querySelector('button') button.addEventListener('click', addToWatchLater) return container.firstElementChild } // Add button to menu async function addButtonToMenu() { try { let menu = await waitFor('menu', () => document.querySelector('#menu ytd-menu-renderer'), ) let flexibleButtons = await waitFor('flexible buttons', () => menu.querySelector('#flexible-item-buttons'), ) // Remove existing button if present (for hot-reload support) let existingBtn = flexibleButtons.querySelector( '[aria-label="Save to Watch Later"], [aria-label*="Watch Later"]', ) if (existingBtn) { existingBtn.closest('yt-button-view-model')?.remove() || existingBtn.remove() console.log('🗑️ Removed existing Watch Later button') } // Insert as first button (horizontal layout) let firstButton = flexibleButtons.querySelector( 'yt-button-view-model', ) if (firstButton) { flexibleButtons.insertBefore( createWatchLaterButton(), firstButton, ) } else { flexibleButtons.appendChild(createWatchLaterButton()) } console.log('✅ Added Watch Later button to menu') } catch (e) { console.log(`⚠️ Could not add button: ${e.message}`) } } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)) } async function waitFor(name, fn, timeout = 5000) { console.log(`🔍 Waiting for ${name}...`) let start = Date.now() for (;;) { let match = fn() if (match) { console.log(`✅ Found ${name}`) return match } if (Date.now() - start > timeout) { throw new Error(`Timeout waiting for ${name}`) } await sleep(33) } } // Check if we're on Watch Later playlist page function isWatchLaterPage() { let url = new URL(window.location.href) return ( url.pathname === '/playlist' && url.searchParams.get('list') === 'WL' ) } // Add pending videos to Watch Later playlist page let isRenderingPending = false function addPendingVideosToPlaylist() { if (!isWatchLaterPage()) return if (isRenderingPending) return // Prevent loop let contents = document.querySelector( '#contents.ytd-playlist-video-list-renderer', ) if (!contents) return isRenderingPending = true // Remove ALL existing pending sections let existingSections = contents.querySelectorAll( '[data-pending-videos-section]', ) existingSections.forEach(el => el.remove()) let pending = pendingVideos.getAll() if (pending.length === 0) { isRenderingPending = false return } console.log( `📋 Adding ${pending.length} pending video(s) to Watch Later page`, ) // Create simple header let header = document.createElement('div') header.setAttribute('data-pending-videos-section', 'header') header.style.cssText = 'padding: 16px; border-bottom: 2px solid #ff9800; background: #1f1f1f; margin-bottom: 8px;' header.innerHTML = ` <h2 style="margin: 0; font-size: 16px; font-weight: 500; color: #ffffff;"> ⏳ Pending Videos (Not Saved Yet) </h2> <p style="margin: 8px 0 0 0; font-size: 14px; color: #aaaaaa;"> These videos are waiting to be saved </p> ` // Create simple container let pendingContainer = document.createElement('div') pendingContainer.setAttribute( 'data-pending-videos-section', 'container', ) // Add each pending video with simple DOM for (let videoId of pending) { let videoData = pendingVideos.get()[videoId] let title = videoData?.title || 'Unknown Title' let escapedTitle = title .replace(/</g, '&lt;') .replace(/>/g, '&gt;') .replace(/"/g, '&quot;') let item = document.createElement('div') item.setAttribute('data-pending-video-id', videoId) item.setAttribute('data-pending-videos-section', 'item') item.style.cssText = 'display: flex; align-items: center; padding: 12px 16px; border-left: 4px solid #ff9800; background: #181818; margin-bottom: 4px; opacity: 0.8;' item.innerHTML = ` <div style="margin-right: 12px; color: #ff9800; font-size: 20px;">⏳</div> <div style="flex: 1;"> <a href="/watch?v=${videoId}" style="color: #ffffff; text-decoration: none; font-size: 14px; font-weight: 500; display: block;" title="${escapedTitle}"> ${escapedTitle} </a> <div style="margin-top: 4px; font-size: 12px; color: #aaaaaa;"> <span style="color: #ff9800;">Pending</span> • <a href="/watch?v=${videoId}" style="color: #3ea6ff; text-decoration: none;">View Video</a> </div> </div> ` pendingContainer.appendChild(item) } // Insert at the top if (contents.firstChild) { contents.insertBefore(header, contents.firstChild) contents.insertBefore(pendingContainer, header.nextSibling) } else { contents.appendChild(header) contents.appendChild(pendingContainer) } // Reset flag after a short delay to allow DOM to settle setTimeout(() => { isRenderingPending = false }, 100) } // Handle DOM changes let debounceTimer async function handleDOMChange() { clearTimeout(debounceTimer) debounceTimer = setTimeout(async () => { console.log('🔄 DOM changed') await sleep(1000) addButtonToMenu() processPendingVideos() addPendingVideosToPlaylist() }, 2000) } // Initialize function init() { console.log('🚀 Initializing YouTube Watch Later script...') handleDOMChange() } if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init) } else { init() } // Watch for all DOM changes new MutationObserver(handleDOMChange).observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['href', 'class'], }) })()