// ==UserScript== // @name YouTube Download Helper / YouTube 下载助手 // @namespace local.youtube.download.helper // @version 0.2.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 SCRIPT_VERSION = '0.2.0'; const ROOT_ID = 'ytdh-scriptcat-root'; const BUTTON_CLASS = 'ytdh-page-button'; let injectionScheduled = false; const copy = { 'zh-CN': { app: 'YouTube 下载助手', download: '下载', current: '当前页面', source: '视频链接', analyze: '解析', analyzePlaylist: '解析完整列表', analyzing: '正在解析...', helperReady: '本地助手已连接', helperMissing: '本地助手未运行', toolsMissing: '下载组件不完整', versionMismatch: '脚本与本地助手版本不一致', 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', analyzePlaylist: 'Analyze playlist', analyzing: 'Analyzing...', helperReady: 'Local helper connected', helperMissing: 'Local helper is not running', toolsMissing: 'Download components are incomplete', versionMismatch: 'Userscript and helper versions do not match', 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); const base = 'https://www.youtube.com'; const videoId = url.searchParams.get('v'); if (url.pathname === '/watch' && videoId) return `${base}/watch?v=${encodeURIComponent(videoId)}`; if (url.pathname.startsWith('/shorts/')) { const shortId = url.pathname.slice('/shorts/'.length).split('/')[0]; if (shortId) return `${base}/shorts/${encodeURIComponent(shortId)}`; } if (url.pathname === '/playlist' && url.searchParams.get('list')) return currentPlaylistUrl(); return ''; } function currentPlaylistUrl() { const listId = new URL(location.href).searchParams.get('list'); return listId ? `https://www.youtube.com/playlist?list=${encodeURIComponent(listId)}` : ''; } 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' }); return button; } function injectPageButtons() { const validTargets = []; if (location.pathname === '/watch') { const standardTarget = document.querySelector('ytd-watch-metadata #top-level-buttons-computed'); if (standardTarget) validTargets.push(standardTarget); } if (location.pathname.startsWith('/shorts/')) { const activeShort = document.querySelector('ytd-reel-video-renderer[is-active] #actions'); if (activeShort) validTargets.push(activeShort); } if (location.pathname === '/playlist') { const playlistTarget = document.querySelector('ytd-playlist-header-renderer #buttons') || document.querySelector('ytd-playlist-header-renderer #menu'); if (playlistTarget) validTargets.push(playlistTarget); } document.querySelectorAll(`.${BUTTON_CLASS}`).forEach((button) => { if (!validTargets.some((target) => target.contains(button))) button.remove(); }); validTargets.forEach((target) => { if (target.querySelector(`:scope > .${BUTTON_CLASS}`)) return; const button = createPageButton(); if (target.matches('ytd-reel-video-renderer[is-active] #actions')) { Object.assign(button.style, { width: '64px', height: '40px', padding: '0 6px', margin: '8px 0' }); target.appendChild(button); } else if (target.closest('ytd-playlist-header-renderer')) { Object.assign(button.style, { margin: '8px 0 0 8px' }); target.appendChild(button); } else { target.prepend(button); } }); } function scheduleInjection() { if (injectionScheduled) return; injectionScheduled = true; requestAnimationFrame(() => { injectionScheduled = false; injectPageButtons(); }); } function ensureRoot() { let host = document.getElementById(ROOT_ID); if (host && !host.shadowRoot) { host.remove(); host = null; } if (!host) { host = document.createElement('div'); host.id = ROOT_ID; (document.body || document.documentElement).appendChild(host); host.attachShadow({ mode: 'open' }); } return host.shadowRoot; } function styles() { return ` :host { all: initial; position: fixed; inset: 0; z-index: 2147483647; pointer-events: none; } * { box-sizing: border-box; letter-spacing: 0; } .backdrop { position: fixed; inset: 0; z-index: 2147483647; pointer-events: auto; 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 = `