// ==UserScript== // @name YouTube 影片頁面播放清單檢查器 // @namespace https://github.com/downwarjers/WebTweaks // @version 29.11.0 // @description 在 YouTube 影片頁面顯示當前影片是否已加入使用者的任何自訂播放清單。透過呼叫 YouTube 內部 API (`get_add_to_playlist`) 檢查狀態,並在影片標題上方顯示結果。 // @author downwarjers // @license MIT // @match https://www.youtube.com/* // @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com // @grant none // @run-at document-idle // @downloadURL https://raw.githubusercontent.com/downwarjers/WebTweaks/main/UserScripts/youtube-viewpage-playlist-checker/youtube-viewpage-playlist-checker.user.js // @updateURL https://raw.githubusercontent.com/downwarjers/WebTweaks/main/UserScripts/youtube-viewpage-playlist-checker/youtube-viewpage-playlist-checker.user.js // ==/UserScript== (function () { 'use strict'; // --- CSS 設定 --- function addStyle(css) { const id = 'my-playlist-checker-style'; if (document.getElementById(id)) { return; } // 已經有了就跳過 const style = document.createElement('style'); style.id = id; // 設定 ID style.textContent = css; (document.head || document.documentElement).appendChild(style); } addStyle(` #my-playlist-status { margin-bottom: 20px; padding: 6px 12px; background-color: rgba(255, 255, 255, 0.05); border-radius: 6px; font-size: 1.4rem; color: #e1e1e1; border-left: 3px solid #3ea6ff; width: fit-content; display: block !important; font-family: Roboto, Arial, sans-serif; transition: all 0.2s ease; } /* 同步中狀態 (黃色/灰色) */ #my-playlist-status.syncing { border-left-color: #f1c40f; color: #ddd; background-color: rgba(241, 196, 15, 0.1); } /* 錯誤狀態 (紅色) */ #my-playlist-status.error { border-left-color: #ff4e45; background-color: rgba(255, 78, 69, 0.1); color: #ff4e45; } `); let currentVideoId = null; let snackbarObserver = null; let popupObserver = null; let isChecking = false; // 防止重複執行的鎖 // 🌟 新增:輪詢專用變數與控制器 let pollTimer = null; let pollAttempts = 0; const MAX_POLLS = 6; // 總共檢查 6 次 const POLL_INTERVAL = 4000; // 每次間隔 4 秒 (總共約 24 秒的監控期) function startPlaylistPolling() { if (pollTimer) { clearTimeout(pollTimer); } pollAttempts = 0; showStatus('⏳ 伺服器同步中...', 'syncing'); const poll = async () => { pollAttempts++; await checkPlaylists(); // 抓取並更新畫面 if (pollAttempts < MAX_POLLS) { pollTimer = setTimeout(poll, POLL_INTERVAL); } else { pollAttempts = 0; // 結束輪詢 } }; // 關閉選單後,先等 2 秒發動第一次檢查 pollTimer = setTimeout(poll, 2000); } // ========================================== // 1. 介面控制 // ========================================== function showStatus(htmlContent, className = '') { let div = document.getElementById('my-playlist-status'); const targetContainer = document.querySelector('#secondary #secondary-inner'); if (!targetContainer) { return; } if (!div) { div = document.createElement('div'); div.id = 'my-playlist-status'; targetContainer.prepend(div); } else { // 如果 div 已存在但因為頁面切換脫離了原本位置,將其抓回並重新置頂 if (div.parentNode !== targetContainer) { targetContainer.prepend(div); } } if (div.innerHTML !== htmlContent) { div.innerHTML = htmlContent; } div.className = className; } // ========================================== // 2. 核心工具:驗證與設定 // ========================================== function waitForConfig(timeout = 5000) { return new Promise((resolve) => { if (window.ytcfg && window.ytcfg.get) { return resolve(window.ytcfg); } const start = Date.now(); const interval = setInterval(() => { if (window.ytcfg && window.ytcfg.get) { clearInterval(interval); resolve(window.ytcfg); } else if (Date.now() - start > timeout) { clearInterval(interval); resolve(null); } }, 100); }); } function getCookie(name) { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) { return parts.pop().split(';').shift(); } } async function generateSAPISIDHASH() { const sapisid = getCookie('SAPISID'); if (!sapisid) { return null; } const timestamp = Math.floor(Date.now() / 1000); const origin = window.location.origin; const str = `${timestamp} ${sapisid} ${origin}`; const buffer = new TextEncoder().encode(str); const hashBuffer = await crypto.subtle.digest('SHA-1', buffer); const hashHex = Array.from(new Uint8Array(hashBuffer)) .map((b) => { return b.toString(16).padStart(2, '0'); }) .join(''); return `SAPISIDHASH ${timestamp}_${hashHex}`; } // ========================================== // 3. 搜尋邏輯 // ========================================== function findButtonByText(obj, targetTexts, visited = new Set()) { if (!obj || typeof obj !== 'object') { return null; } if (visited.has(obj)) { return null; } visited.add(obj); let foundText = null; if (obj.simpleText) { foundText = obj.simpleText; } else if (obj.runs && obj.runs[0] && obj.runs[0].text) { foundText = obj.runs[0].text; } if (foundText && targetTexts.includes(foundText.trim())) { return { found: true, text: foundText }; } for (let k in obj) { if ( k === 'secondaryResults' || k === 'frameworkUpdates' || k === 'loggingContext' || k === 'playerOverlays' ) { continue; } const result = findButtonByText(obj[k], targetTexts, visited); if (result) { if (result.found) { const keys = [ 'addToPlaylistServiceEndpoint', 'serviceEndpoint', 'command', 'navigationEndpoint', 'showSheetCommand', ]; for (let key of keys) { if (obj[key]) { return obj[key]; } } return result; } return result; } } return null; } // ========================================== // 4. 主功能:背景檢查 API // ========================================== async function checkPlaylists() { if (isChecking) { return; } isChecking = true; try { const ytConfig = await waitForConfig(); if (!ytConfig) { isChecking = false; return; } const app = document.querySelector('ytd-app'); const rawData = app?.data?.response || window.ytInitialData; const mainVideoScope = rawData?.contents?.twoColumnWatchNextResults?.results?.results?.contents; const searchTargets = mainVideoScope ? [mainVideoScope] : [rawData, window.ytInitialPlayerResponse]; let params = null; let videoIdFromEndpoint = null; for (let source of searchTargets) { let candidate = findButtonByText(source, ['儲存', 'Save', '保存']); if (candidate) { let ep = candidate; if (candidate.addToPlaylistServiceEndpoint) { ep = candidate.addToPlaylistServiceEndpoint; } else if (candidate.command && candidate.command.addToPlaylistServiceEndpoint) { ep = candidate.command.addToPlaylistServiceEndpoint; } else if ( candidate.showSheetCommand && candidate.showSheetCommand.panelLoadingStrategy ) { ep = candidate.showSheetCommand.panelLoadingStrategy.requestTemplate; } else if (candidate.panelLoadingStrategy) { ep = candidate.panelLoadingStrategy.requestTemplate; } if (ep && ep.params) { params = ep.params; if (ep.videoId) { videoIdFromEndpoint = ep.videoId; } break; } } } if (!params) { const menuRenderer = document.querySelector( 'ytd-menu-renderer[class*="ytd-watch-metadata"]', ); if (menuRenderer && menuRenderer.data) { const buttons = menuRenderer.data.topLevelButtons || []; for (let btn of buttons) { const icon = btn.buttonRenderer?.icon?.iconType || btn.flexibleActionsViewModel?.iconName; if (icon === 'PLAYLIST_ADD' || icon === 'SAVE') { let ep = btn.buttonRenderer?.serviceEndpoint || btn.buttonRenderer?.command || btn.flexibleActionsViewModel?.onTap?.command; if (ep) { if (ep.addToPlaylistServiceEndpoint) { params = ep.addToPlaylistServiceEndpoint.params; } else if (ep.showSheetCommand) { params = ep.showSheetCommand.panelLoadingStrategy?.requestTemplate?.params; } else if (ep.params) { params = ep.params; } } if (params) { break; } } } } } // if (!params) { // throw new Error('API Params Not Found'); // } const currentUrlId = new URLSearchParams(window.location.search).get('v'); const finalVideoId = videoIdFromEndpoint || currentUrlId; const apiKey = ytConfig.get('INNERTUBE_API_KEY'); const context = JSON.parse(JSON.stringify(ytConfig.get('INNERTUBE_CONTEXT'))); if (!context.client) { context.client = {}; } context.client.clientMessageId = 'ytpc-' + Math.random().toString(36).substring(2, 10); const sessionIndex = ytConfig.get('SESSION_INDEX') || '0'; const authHeader = await generateSAPISIDHASH(); if (!authHeader || !apiKey) { throw new Error('Auth Failed'); } const response = await fetch( `https://www.youtube.com/youtubei/v1/playlist/get_add_to_playlist?key=${apiKey}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: authHeader, 'X-Origin': window.location.origin, 'X-Goog-AuthUser': sessionIndex, }, credentials: 'include', cache: 'no-store', // 🌟 加入這行確保瀏覽器不快取 body: JSON.stringify({ context: context, videoIds: [finalVideoId], }), }, ); if (!response.ok) { throw new Error(`API ${response.status}`); } const json = await response.json(); function findPlaylistsRecursive(obj) { let results = []; if (!obj || typeof obj !== 'object') { return results; } if (obj.playlistAddToOptionRenderer) { results.push(obj.playlistAddToOptionRenderer); } for (let k in obj) { results = results.concat(findPlaylistsRecursive(obj[k])); } return results; } const playlists = findPlaylistsRecursive(json); const added = []; playlists.forEach((p) => { const title = p.title.simpleText || p.title.runs?.[0]?.text; const rawStatus = p.containsSelectedVideos || p.containsSelectedVideo; const isAdded = rawStatus === 'ALL' || rawStatus === 'TRUE' || rawStatus === true; if (isAdded) { added.push(title); } }); const isPolling = pollAttempts > 0 && pollAttempts < MAX_POLLS; const pollText = isPolling ? `(🔄 多次確認中 ${pollAttempts}/${MAX_POLLS})` : ''; const html = added.length > 0 ? `✅ 本影片已存在於:${added.join('、 ')}${pollText}` : `⚪ 未加入任何自訂清單${pollText}`; showStatus(html, ''); } catch (e) { console.error('[YT-Checker]', e); showStatus(`❌ 錯誤: ${e.message}`, 'error'); } finally { isChecking = false; } } // ========================================== // 5. 觸發與監聽 // ========================================== window.addEventListener('yt-navigate-finish', function () { if (pollTimer) { clearTimeout(pollTimer); pollAttempts = 0; } const newVideoId = new URLSearchParams(window.location.search).get('v'); const statusEl = document.getElementById('my-playlist-status'); if (statusEl) { statusEl.remove(); } if (!location.href.includes('/watch')) { return; } if (currentVideoId !== newVideoId) { currentVideoId = newVideoId; initSnackbarObserver(); initPopupContainerObserver(); if (document.hidden) { document.addEventListener('visibilitychange', onVisibilityChange, { once: true }); } else { // 初始載入 setTimeout(checkPlaylists, 1500); } } }); function onVisibilityChange() { if (!document.hidden) { setTimeout(checkPlaylists, 1000); } } function initSnackbarObserver() { if (snackbarObserver) { return; } const container = document.querySelector('snackbar-container'); if (!container) { setTimeout(initSnackbarObserver, 2000); return; } snackbarObserver = new MutationObserver((mutations) => { const hasToast = container.childElementCount > 0; if (hasToast) { if (pollTimer) { clearTimeout(pollTimer); } pollAttempts = 0; showStatus('⏳ 準備同步...', 'syncing'); } else { startPlaylistPolling(); // 🌟 呼叫輪詢函數 } }); snackbarObserver.observe(container, { childList: true, subtree: true }); } function initPopupContainerObserver() { if (popupObserver) { return; } const popupContainer = document.querySelector('ytd-popup-container'); if (!popupContainer) { setTimeout(initPopupContainerObserver, 2000); return; } let wasVisible = false; const checkState = () => { const toasts = popupContainer.querySelectorAll( 'tp-yt-paper-toast, yt-notification-action-renderer', ); let isVisibleNow = false; toasts.forEach((toast) => { const style = window.getComputedStyle(toast); const isHidden = style.display === 'none' || (toast.hasAttribute('aria-hidden') && toast.getAttribute('aria-hidden') === 'true') || style.opacity === '0'; if (!isHidden && toast.innerText.trim().length > 0) { isVisibleNow = true; } }); if (isVisibleNow && !wasVisible) { if (pollTimer) { clearTimeout(pollTimer); } pollAttempts = 0; showStatus('⏳ 準備同步...', 'syncing'); } else if (!isVisibleNow && wasVisible) { startPlaylistPolling(); // 🌟 呼叫輪詢函數 } wasVisible = isVisibleNow; }; popupObserver = new MutationObserver(() => { return checkState(); }); popupObserver.observe(popupContainer, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class', 'aria-hidden'], }); } })();