// ==UserScript== // @name YouTube Download Helper / YouTube 下载助手 // @namespace local.youtube.download.helper // @version 0.1.0 // @description Send public YouTube videos, Shorts, and playlist entries to a local download helper. // @description:zh-CN 将公开的 YouTube 视频、Shorts 和播放列表项目发送到本地下载助手。 // @author Local // @match https://www.youtube.com/* // @match https://youtube.com/* // @match https://m.youtube.com/* // @grant GM_xmlhttpRequest // @grant GM_getValue // @grant GM_setValue // @grant GM_registerMenuCommand // @connect 127.0.0.1 // @connect localhost // @run-at document-idle // ==/UserScript== (function () { 'use strict'; const API_BASE = 'http://127.0.0.1:17863/api/v1'; const API_KEY = 'youtube-helper-local-v1'; const ROOT_ID = 'ytdh-scriptcat-root'; const BUTTON_CLASS = 'ytdh-page-button'; const copy = { 'zh-CN': { app: 'YouTube 下载助手', download: '下载', current: '当前页面', source: '视频链接', analyze: '解析', analyzing: '正在解析...', helperReady: '本地助手已连接', helperMissing: '本地助手未运行', toolsMissing: '下载组件不完整', video: '视频', audio: '音频', quality: '清晰度', highest: '最高可用', format: '格式', subtitles: '字幕', thumbnail: '封面图', saveTo: '保存位置', choose: '选择', setDefault: '设为默认', folderSelected: '已切换本次保存目录', addQueue: '加入下载队列', playlist: '播放列表', selectAll: '全选', selectNone: '全不选', selected: '已选择 {count} 项', tasks: '下载任务', clearDone: '清除记录', noTasks: '暂无下载任务', queued: '等待中', downloading: '下载中', paused: '已暂停', completed: '已完成', failed: '失败', canceled: '已取消', pause: '暂停', resume: '继续', cancel: '取消', retry: '重试', openFolder: '打开文件夹', close: '关闭', invalidUrl: '请输入有效的 YouTube 链接', noSelection: '请至少选择一个视频', queuedCount: '已加入 {count} 个任务', requestFailed: '请求失败', duration: '时长', language: 'English', standardVideo: '普通视频', playlistCount: '{count} 个视频', reconnect: '重新连接', settingsSaved: '设置已保存', chooseFailed: '目录选择失败', unknown: '未知状态' }, en: { app: 'YouTube Download Helper', download: 'Download', current: 'Current page', source: 'Video URL', analyze: 'Analyze', analyzing: 'Analyzing...', helperReady: 'Local helper connected', helperMissing: 'Local helper is not running', toolsMissing: 'Download components are incomplete', video: 'Video', audio: 'Audio', quality: 'Quality', highest: 'Best available', format: 'Format', subtitles: 'Subtitles', thumbnail: 'Thumbnail', saveTo: 'Save to', choose: 'Choose', setDefault: 'Set default', folderSelected: 'Temporary folder selected', addQueue: 'Add to queue', playlist: 'Playlist', selectAll: 'Select all', selectNone: 'Select none', selected: '{count} selected', tasks: 'Download tasks', clearDone: 'Clear history', noTasks: 'No tasks yet', queued: 'Queued', downloading: 'Downloading', paused: 'Paused', completed: 'Completed', failed: 'Failed', canceled: 'Canceled', pause: 'Pause', resume: 'Resume', cancel: 'Cancel', retry: 'Retry', openFolder: 'Open folder', close: 'Close', invalidUrl: 'Enter a valid YouTube URL', noSelection: 'Select at least one video', queuedCount: '{count} task(s) added', requestFailed: 'Request failed', duration: 'Duration', language: '中文', standardVideo: 'Video', playlistCount: '{count} videos', reconnect: 'Reconnect', settingsSaved: 'Settings saved', chooseFailed: 'Folder selection failed', unknown: 'Unknown' } }; const state = { language: GM_getValue('language', 'zh-CN'), metadata: null, settings: null, health: null, tasks: [], mode: 'video', outputDir: null, open: false, poller: null, lastUrl: location.href }; function t(key, values = {}) { let text = (copy[state.language] && copy[state.language][key]) || copy.en[key] || key; Object.entries(values).forEach(([name, value]) => { text = text.replace(`{${name}}`, String(value)); }); return text; } function escapeHtml(value) { return String(value == null ? '' : value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function api(path, options = {}) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: options.method || 'GET', url: `${API_BASE}${path}`, headers: { 'Content-Type': 'application/json', 'X-Helper-Key': API_KEY }, data: options.body ? JSON.stringify(options.body) : undefined, timeout: options.timeout || 90000, onload(response) { let payload = {}; try { payload = response.responseText ? JSON.parse(response.responseText) : {}; } catch (_) { reject(new Error(`${t('requestFailed')} (${response.status})`)); return; } if (response.status >= 200 && response.status < 300) { resolve(payload); } else { reject(new Error(payload.error || `${t('requestFailed')} (${response.status})`)); } }, onerror() { reject(new Error(t('helperMissing'))); }, ontimeout() { reject(new Error(`${t('requestFailed')}: timeout`)); } }); }); } function currentYouTubeUrl() { const url = new URL(location.href); if (url.pathname === '/watch' && url.searchParams.get('v')) return url.href; if (url.pathname.startsWith('/shorts/')) return url.href; if (url.pathname === '/playlist' && url.searchParams.get('list')) return url.href; return ''; } function createPageButton() { const button = document.createElement('button'); button.type = 'button'; button.className = BUTTON_CLASS; button.textContent = t('download'); button.title = t('app'); Object.assign(button.style, { border: '0', borderRadius: '18px', padding: '0 16px', height: '36px', margin: '0 8px', background: '#cc1f2f', color: '#fff', font: '600 14px Arial, sans-serif', cursor: 'pointer', flex: '0 0 auto' }); button.addEventListener('click', openPanel); return button; } function injectPageButtons() { const standardTargets = document.querySelectorAll('#top-level-buttons-computed'); standardTargets.forEach((target) => { if (!target.querySelector(`.${BUTTON_CLASS}`)) target.prepend(createPageButton()); }); const activeShort = document.querySelector('ytd-reel-video-renderer[is-active] #actions'); if (activeShort && !activeShort.querySelector(`.${BUTTON_CLASS}`)) { const button = createPageButton(); Object.assign(button.style, { width: '64px', height: '40px', padding: '0 6px', margin: '8px 0' }); activeShort.appendChild(button); } } function ensureRoot() { let host = document.getElementById(ROOT_ID); if (!host) { host = document.createElement('div'); host.id = ROOT_ID; document.documentElement.appendChild(host); host.attachShadow({ mode: 'open' }); } return host.shadowRoot; } function styles() { return ` :host { all: initial; } * { box-sizing: border-box; letter-spacing: 0; } .backdrop { position: fixed; inset: 0; z-index: 2147483646; background: rgba(20, 22, 26, .55); display: grid; place-items: center; padding: 12px; } .panel { width: min(560px, calc(100vw - 24px)); max-height: min(780px, calc(100vh - 24px)); overflow: hidden; display: flex; flex-direction: column; background: #f7f8fa; color: #17191d; border: 1px solid #d9dde3; border-radius: 8px; box-shadow: 0 18px 55px rgba(0,0,0,.32); font: 14px/1.45 Arial, "Microsoft YaHei", sans-serif; } .header { height: 56px; padding: 0 16px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid #dfe2e7; background: #fff; flex: 0 0 auto; } .brand { font-size: 17px; font-weight: 700; flex: 1; } .header-btn, .icon-btn { border: 0; background: transparent; color: #4e5561; cursor: pointer; padding: 7px 8px; border-radius: 5px; font: inherit; } .header-btn:hover, .icon-btn:hover { background: #edf0f3; color: #17191d; } .close { width: 34px; height: 34px; font-size: 22px; line-height: 20px; } .scroll { overflow-y: auto; padding: 0 16px 16px; } .connection { margin: 12px 0 0; min-height: 36px; display: flex; align-items: center; gap: 9px; padding: 8px 10px; border-left: 3px solid #138a74; background: #edf7f4; color: #155d50; } .connection.bad { border-color: #c72b3b; background: #fff0f1; color: #8f1e2a; } .dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; flex: 0 0 auto; } .connection span:nth-child(2) { flex: 1; } .section { padding: 16px 0; border-bottom: 1px solid #dfe2e7; } .section:last-child { border-bottom: 0; } .section-title { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; font-size: 14px; font-weight: 700; } .section-title .spacer { flex: 1; } .input-row { display: grid; grid-template-columns: 1fr auto; gap: 8px; } input[type="url"], select, .path { min-width: 0; height: 38px; border: 1px solid #bfc5cd; background: #fff; color: #17191d; border-radius: 5px; padding: 0 10px; font: inherit; outline: none; } input[type="url"]:focus, select:focus { border-color: #087d6b; box-shadow: 0 0 0 2px rgba(8,125,107,.14); } button { font: inherit; letter-spacing: 0; } .primary, .secondary, .danger, .small { min-height: 38px; border: 0; border-radius: 5px; padding: 0 14px; cursor: pointer; font-weight: 700; } .primary { background: #cc1f2f; color: #fff; } .primary:hover { background: #ad1826; } .primary:disabled { background: #b9bdc3; cursor: default; } .secondary, .small { background: #e7eaee; color: #20242a; } .secondary:hover, .small:hover { background: #d9dde2; } .danger { background: #fff0f1; color: #a91e2c; } .small { min-height: 30px; padding: 0 10px; font-size: 12px; font-weight: 600; } .link-btn { border: 0; padding: 3px 5px; background: transparent; color: #087d6b; cursor: pointer; font: inherit; } .preview { display: grid; grid-template-columns: 112px minmax(0, 1fr); gap: 12px; margin-top: 12px; } .preview img { width: 112px; aspect-ratio: 16/9; object-fit: cover; border-radius: 5px; background: #dadde2; } .preview-title { font-weight: 700; overflow-wrap: anywhere; max-height: 44px; overflow: hidden; } .muted { color: #69717d; font-size: 12px; margin-top: 4px; } .controls { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } .field label { display: block; margin-bottom: 6px; color: #555d68; font-size: 12px; font-weight: 700; } .field select { width: 100%; } .segments { display: grid; grid-template-columns: 1fr 1fr; border: 1px solid #bfc5cd; border-radius: 5px; overflow: hidden; } .segments button { height: 36px; border: 0; background: #fff; color: #4b535e; cursor: pointer; } .segments button.active { background: #18202a; color: #fff; } .checks { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 13px; } .check { display: inline-flex; align-items: center; gap: 7px; cursor: pointer; } .check input { width: 17px; height: 17px; accent-color: #087d6b; } .path-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 8px; margin-top: 13px; } .path { display: flex; align-items: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: #555d68; } .playlist-head { display: flex; align-items: center; gap: 6px; margin: 14px 0 8px; } .playlist-head strong { flex: 1; } .playlist-list { max-height: 240px; overflow: auto; border: 1px solid #d8dce2; background: #fff; } .playlist-item { min-height: 48px; display: grid; grid-template-columns: 24px 1fr auto; align-items: center; gap: 8px; padding: 7px 10px; border-bottom: 1px solid #eceef1; cursor: pointer; } .playlist-item:last-child { border-bottom: 0; } .playlist-item:hover { background: #f4f6f8; } .playlist-item input { width: 16px; height: 16px; accent-color: #087d6b; } .playlist-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .submit-row { display: flex; align-items: center; gap: 10px; margin-top: 14px; } .submit-row .primary { flex: 1; } .notice { min-height: 18px; margin-top: 8px; color: #087d6b; font-size: 12px; overflow-wrap: anywhere; } .notice.error { color: #b32433; } .tasks { display: grid; gap: 8px; } .task { padding: 10px; border: 1px solid #d8dce2; border-radius: 6px; background: #fff; } .task-top { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 10px; align-items: start; } .task-title { min-width: 0; font-weight: 700; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .badge { border-radius: 4px; padding: 2px 6px; background: #edf0f3; color: #4d5560; font-size: 11px; } .badge.downloading { background: #e7f5f2; color: #08715f; } .badge.failed, .badge.canceled { background: #fff0f1; color: #a51f2d; } .badge.completed { background: #edf6e9; color: #39722d; } .bar { height: 5px; background: #e3e6ea; margin: 9px 0 6px; overflow: hidden; } .bar > span { display: block; height: 100%; background: #087d6b; transition: width .2s ease; } .task-foot { display: flex; align-items: center; gap: 8px; min-height: 26px; color: #68717c; font-size: 11px; } .task-foot .message { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .empty { padding: 18px 0; text-align: center; color: #78808a; } @media (max-width: 480px) { .panel { max-height: calc(100vh - 12px); width: calc(100vw - 12px); } .header, .scroll { padding-left: 12px; padding-right: 12px; } .controls { grid-template-columns: 1fr; } .preview { grid-template-columns: 92px minmax(0,1fr); } .preview img { width: 92px; } } `; } function renderPanel() { const root = ensureRoot(); if (!state.open) { root.innerHTML = ''; return; } root.innerHTML = `
${escapeHtml(t('app'))}

${escapeHtml(t('source'))}

${escapeHtml(state.outputDir || '...')}

${escapeHtml(t('tasks'))}

`; bindPanel(root); renderConnection(root); renderFormatField(root); renderMetadata(root); renderTasks(root); } function bindPanel(root) { root.querySelector('[data-action="close"]').addEventListener('click', closePanel); root.querySelector('.backdrop').addEventListener('click', (event) => { if (event.target.classList.contains('backdrop')) closePanel(); }); root.querySelector('[data-action="language"]').addEventListener('click', async () => { state.language = state.language === 'zh-CN' ? 'en' : 'zh-CN'; GM_setValue('language', state.language); document.querySelectorAll(`.${BUTTON_CLASS}`).forEach((button) => { button.textContent = t('download'); }); if (state.settings) { state.settings.language = state.language; api('/settings', { method: 'PUT', body: { language: state.language } }).catch(() => {}); } renderPanel(); }); root.querySelector('[data-action="analyze"]').addEventListener('click', analyzeInput); root.querySelector('#url').addEventListener('keydown', (event) => { if (event.key === 'Enter') analyzeInput(); }); root.querySelectorAll('[data-mode]').forEach((button) => { button.addEventListener('click', () => { state.mode = button.dataset.mode; root.querySelectorAll('[data-mode]').forEach((item) => item.classList.toggle('active', item === button)); renderFormatField(root); }); }); root.querySelector('[data-action="choose-directory"]').addEventListener('click', chooseDirectory); root.querySelector('[data-action="set-default"]').addEventListener('click', saveDefaultDirectory); root.querySelector('[data-action="queue"]').addEventListener('click', queueDownloads); root.querySelector('[data-action="clear"]').addEventListener('click', clearCompleted); root.querySelector('[data-action="open-folder"]').addEventListener('click', () => api('/open-folder', { method: 'POST', body: {} }).catch(showError)); } function renderConnection(root = ensureRoot()) { const container = root.querySelector('#connection'); if (!container) return; const toolsReady = state.health && Object.values(state.health.tools || {}).every(Boolean); const ready = state.health && state.health.ok && toolsReady; const text = !state.health ? t('helperMissing') : (toolsReady ? t('helperReady') : t('toolsMissing')); container.innerHTML = `
${escapeHtml(text)}${ready ? '' : ``}
`; const reconnect = container.querySelector('[data-action="reconnect"]'); if (reconnect) reconnect.addEventListener('click', connectHelper); } function renderFormatField(root = ensureRoot()) { const field = root.querySelector('#format-field'); if (!field) return; if (state.mode === 'audio') { field.innerHTML = ``; return; } const found = state.metadata && state.metadata.qualities ? state.metadata.qualities : []; const standard = [360, 480, 720, 1080, 1440, 2160, 4320]; const qualities = [...new Set([...standard, ...found])].sort((a, b) => a - b); field.innerHTML = ``; } function renderMetadata(root = ensureRoot()) { const container = root.querySelector('#metadata'); const playlist = root.querySelector('#playlist'); if (!container || !playlist) return; if (!state.metadata) { container.innerHTML = ''; playlist.innerHTML = ''; return; } const data = state.metadata; const detail = data.is_playlist ? t('playlistCount', { count: data.entries.length }) : t('standardVideo'); container.innerHTML = `
${data.thumbnail ? `` : '
'}
${escapeHtml(data.title)}
${escapeHtml(detail)}${data.uploader ? ` · ${escapeHtml(data.uploader)}` : ''}${data.duration ? ` · ${escapeHtml(formatDuration(data.duration))}` : ''}
`; renderFormatField(root); if (!data.is_playlist) { playlist.innerHTML = ''; return; } playlist.innerHTML = `
${escapeHtml(t('playlist'))}
${data.entries.map((entry, index) => ``).join('')}
`; const updateCount = () => { const count = playlist.querySelectorAll('[data-entry]:checked').length; playlist.querySelector('#selected-count').textContent = t('selected', { count }); }; playlist.querySelectorAll('[data-entry]').forEach((box) => box.addEventListener('change', updateCount)); playlist.querySelector('[data-select="all"]').addEventListener('click', () => { playlist.querySelectorAll('[data-entry]').forEach((box) => { box.checked = true; }); updateCount(); }); playlist.querySelector('[data-select="none"]').addEventListener('click', () => { playlist.querySelectorAll('[data-entry]').forEach((box) => { box.checked = false; }); updateCount(); }); updateCount(); } function renderTasks(root = ensureRoot()) { const container = root.querySelector('#tasks'); if (!container) return; if (!state.tasks.length) { container.innerHTML = `
${escapeHtml(t('noTasks'))}
`; return; } container.innerHTML = state.tasks.map((task) => { const progress = Number(task.progress || 0); const actions = []; if (task.status === 'downloading') actions.push(``); if (task.status === 'paused') actions.push(``); if (['queued', 'downloading', 'paused'].includes(task.status)) actions.push(``); if (['failed', 'canceled'].includes(task.status)) actions.push(``); return `
${escapeHtml(task.title)}
${escapeHtml(t(task.status) || t('unknown'))}
${progress.toFixed(1)}%${task.speed ? `${escapeHtml(task.speed)}` : ''}${task.eta ? `ETA ${escapeHtml(task.eta)}` : ''}${escapeHtml(task.error || task.message || '')}${actions.join('')}
`; }).join(''); container.querySelectorAll('[data-task-action]').forEach((button) => { button.addEventListener('click', () => controlTask(button.dataset.task, button.dataset.taskAction)); }); } async function connectHelper() { try { state.health = await api('/health', { timeout: 4000 }); state.settings = await api('/settings', { timeout: 4000 }); state.outputDir = state.settings.download_dir; if (!GM_getValue('language', null) && state.settings.language) state.language = state.settings.language; await pollTasks(); } catch (_) { state.health = null; } renderConnection(); const path = ensureRoot().querySelector('#output-path'); if (path && state.outputDir) { path.textContent = state.outputDir; path.title = state.outputDir; } } async function analyzeInput() { const root = ensureRoot(); const input = root.querySelector('#url'); const button = root.querySelector('[data-action="analyze"]'); const url = input.value.trim(); if (!/^https?:\/\/(?:www\.|m\.)?(?:youtube\.com|youtu\.be)\//i.test(url)) { showError(new Error(t('invalidUrl'))); return; } button.disabled = true; button.textContent = t('analyzing'); setNotice(''); try { state.metadata = await api('/analyze', { method: 'POST', body: { url } }); renderMetadata(root); root.querySelector('[data-action="queue"]').disabled = false; } catch (error) { showError(error); } finally { button.disabled = false; button.textContent = t('analyze'); } } async function chooseDirectory() { try { const result = await api('/settings/choose-directory', { method: 'POST', body: {}, timeout: 120000 }); if (!result.path) return; state.outputDir = result.path; const path = ensureRoot().querySelector('#output-path'); path.textContent = state.outputDir; path.title = state.outputDir; setNotice(t('folderSelected')); } catch (error) { showError(error); } } async function saveDefaultDirectory() { if (!state.outputDir) return; try { state.settings = await api('/settings', { method: 'PUT', body: { download_dir: state.outputDir } }); setNotice(t('settingsSaved')); } catch (error) { showError(error); } } async function queueDownloads() { const root = ensureRoot(); if (!state.metadata) return; let sources; if (state.metadata.is_playlist) { sources = [...root.querySelectorAll('[data-entry]:checked')] .map((box) => state.metadata.entries[Number(box.dataset.entry)]) .filter((entry) => entry && entry.url); } else { sources = [{ url: state.metadata.url || root.querySelector('#url').value, title: state.metadata.title, thumbnail: state.metadata.thumbnail }]; } if (!sources.length) { showError(new Error(t('noSelection'))); return; } const quality = root.querySelector('#quality'); const audioFormat = root.querySelector('#audio-format'); const items = sources.map((entry) => ({ url: entry.url, title: entry.title, thumbnail_url: entry.thumbnail, mode: state.mode, quality: quality ? quality.value : 'best', audio_format: audioFormat ? audioFormat.value : 'mp3', subtitles: root.querySelector('#subtitles').checked, thumbnail: root.querySelector('#thumbnail').checked, output_dir: state.outputDir || (state.settings ? state.settings.download_dir : undefined) })); const button = root.querySelector('[data-action="queue"]'); button.disabled = true; try { await api('/tasks', { method: 'POST', body: { items } }); setNotice(t('queuedCount', { count: items.length })); await pollTasks(); } catch (error) { showError(error); } finally { button.disabled = false; } } async function pollTasks() { if (!state.open) return; try { const result = await api('/tasks', { timeout: 4000 }); state.tasks = result.tasks || []; renderTasks(); } catch (_) { state.health = null; renderConnection(); } } async function controlTask(taskId, action) { try { await api(`/tasks/${taskId}/${action}`, { method: 'POST', body: {} }); await pollTasks(); } catch (error) { showError(error); } } async function clearCompleted() { try { await api('/tasks/clear-completed', { method: 'POST', body: {} }); await pollTasks(); } catch (error) { showError(error); } } function setNotice(message, isError = false) { const notice = ensureRoot().querySelector('#notice'); if (!notice) return; notice.textContent = message || ''; notice.classList.toggle('error', isError); } function showError(error) { setNotice(error && error.message ? error.message : t('requestFailed'), true); } function formatDuration(seconds) { if (!Number.isFinite(Number(seconds))) return ''; const value = Math.max(0, Math.floor(Number(seconds))); const hours = Math.floor(value / 3600); const minutes = Math.floor((value % 3600) / 60); const secs = value % 60; return hours ? `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}` : `${minutes}:${String(secs).padStart(2, '0')}`; } async function openPanel() { state.open = true; state.metadata = null; renderPanel(); await connectHelper(); if (currentYouTubeUrl() && state.health && Object.values(state.health.tools || {}).every(Boolean)) analyzeInput(); clearInterval(state.poller); state.poller = setInterval(pollTasks, 1200); } function closePanel() { state.open = false; clearInterval(state.poller); state.poller = null; renderPanel(); } function handleNavigation() { if (location.href !== state.lastUrl) { state.lastUrl = location.href; state.metadata = null; if (state.open) renderPanel(); } injectPageButtons(); } GM_registerMenuCommand('YouTube Download Helper', openPanel); GM_registerMenuCommand('中文 / English', () => { state.language = state.language === 'zh-CN' ? 'en' : 'zh-CN'; GM_setValue('language', state.language); handleNavigation(); }); document.addEventListener('yt-navigate-finish', handleNavigation); new MutationObserver(handleNavigation).observe(document.documentElement, { childList: true, subtree: true }); setInterval(injectPageButtons, 2000); handleNavigation(); })();