Created
December 27, 2025 14:12
-
-
Save beenotung/6cfb46bd5f4f800ac5393317536714fe to your computer and use it in GitHub Desktop.
userscript to save youtube video for watch later in firefox
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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, '<') | |
| .replace(/>/g, '>') | |
| .replace(/"/g, '"') | |
| 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'], | |
| }) | |
| })() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment