// ==UserScript== // @name YouTube Download Helper / YouTube 下载助手 // @namespace local.youtube.download.helper // @version 0.2.1 // @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 // @grant GM_notification // @grant GM_setClipboard // @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.1'; 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: '封面图', compatibility: '编码模式', qualityFirst: '画质优先', compatible: '兼容优先 H.264', subtitleMode: '字幕类型', subtitleLanguages: '字幕语言', manual: '人工字幕', automatic: '自动字幕', both: '人工和自动', conflict: '同名文件', skip: '跳过已有文件', overwrite: '覆盖已有文件', rename: '自动重命名', allowDuplicate: '允许重复下载', saveTo: '保存位置', choose: '选择', setDefault: '设为默认', folderSelected: '已切换本次保存目录', addQueue: '加入下载队列', playlist: '播放列表', selectAll: '全选', selectNone: '全不选', selected: '已选择 {count} 项', tasks: '下载任务', clearDone: '清除记录', noTasks: '暂无下载任务', queued: '等待中', downloading: '下载中', clearFailed: '清除失败/取消', searchPlaylist: '搜索播放列表', noMatches: '没有匹配项目', copyError: '复制错误', merging: '正在合并', extracting_audio: '正在提取音频', remuxing: '正在封装', fixing: '正在修复', converting_subtitles: '正在转换字幕', converting_thumbnail: '正在转换封面', writing_metadata: '正在写入信息', starting: '正在启动', paused: '已暂停', completed: '已完成', failed: '失败', canceled: '已取消', pause: '暂停', resume: '继续', cancel: '取消', retry: '重试', openFolder: '打开文件夹', close: '关闭', invalidUrl: '请输入有效的 YouTube 链接', noSelection: '请至少选择一个视频', cancelAnalyze: '取消解析', temporaryFolder: '本次临时目录', diagnostics: '诊断', diagnosticsCopied: '诊断信息已复制', 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', compatibility: 'Codec mode', qualityFirst: 'Best quality', compatible: 'Compatible H.264', subtitleMode: 'Subtitle type', subtitleLanguages: 'Subtitle languages', manual: 'Manual', automatic: 'Automatic', both: 'Manual and automatic', conflict: 'Existing file', skip: 'Skip existing', overwrite: 'Overwrite', rename: 'Auto rename', allowDuplicate: 'Allow duplicate download', 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', clearFailed: 'Clear failed/canceled', searchPlaylist: 'Search playlist', noMatches: 'No matching items', copyError: 'Copy error', merging: 'Merging', extracting_audio: 'Extracting audio', remuxing: 'Remuxing', fixing: 'Fixing', converting_subtitles: 'Converting subtitles', converting_thumbnail: 'Converting thumbnail', writing_metadata: 'Writing metadata', starting: 'Starting', 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', cancelAnalyze: 'Cancel analysis', temporaryFolder: 'Temporary folder', diagnostics: 'Diagnostics', diagnosticsCopied: 'Diagnostics copied', 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: [], taskStates: new Map(), playlistSelection: new Set(), analysisCache: new Map(), analyzeRequest: null, lastFocused: null, 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 = {}) { let requestHandle = null; const promise = new Promise((resolve, reject) => { requestHandle = 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`)); }, onabort() { const error = new Error('Request canceled'); error.canceled = true; reject(error); } }); }); promise.abort = () => { if (requestHandle && typeof requestHandle.abort === 'function') requestHandle.abort(); }; return promise; } 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' }); button.addEventListener('click', handlePageButtonClick); return button; } function handlePageButtonClick(event) { if (event && event.__ytdhHandled) return; if (event) { event.__ytdhHandled = true; event.preventDefault(); event.stopImmediatePropagation(); } void openPanelSafely(); } 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"], input[type="text"], 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, input[type="text"]: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%; } .field input { width: 100%; } .hidden { display: none !important; } .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; } .theme-dark .panel { background: #181a1e; color: #f2f3f5; border-color: #3b3e44; } .theme-dark .header, .theme-dark input[type="url"], .theme-dark input[type="text"], .theme-dark select, .theme-dark .segments button, .theme-dark .playlist-list, .theme-dark .task { background: #22252a; color: #f2f3f5; border-color: #484c54; } .theme-dark .section, .theme-dark .header, .theme-dark .playlist-item { border-color: #383b41; } .theme-dark .playlist-item:hover, .theme-dark .header-btn:hover, .theme-dark .icon-btn:hover { background: #30343a; color: #fff; } .theme-dark .secondary, .theme-dark .small { background: #343840; color: #f4f5f6; } .theme-dark .muted, .theme-dark .field label, .theme-dark .path, .theme-dark .task-foot { color: #adb3bd; } .theme-dark .bar { background: #3c4047; } @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; } const darkTheme = document.documentElement.hasAttribute('dark') || window.matchMedia('(prefers-color-scheme: dark)').matches; root.innerHTML = `
${escapeHtml(t('app'))}

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

${location.pathname === '/watch' && currentPlaylistUrl() ? `` : ''}
${escapeHtml(state.outputDir || '...')}

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

`; bindPanel(root); renderConnection(root); renderFormatField(root); renderMetadata(root); renderAdvancedOptions(root); renderTasks(root); } function bindPanel(root) { root.addEventListener('keydown', handlePanelKeydown); 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="diagnostics"]').addEventListener('click', copyDiagnostics); root.querySelector('[data-action="analyze"]').addEventListener('click', analyzeInput); root.querySelector('[data-action="cancel-analyze"]').addEventListener('click', cancelAnalyze); const analyzePlaylist = root.querySelector('[data-action="analyze-playlist"]'); if (analyzePlaylist) { analyzePlaylist.addEventListener('click', () => { root.querySelector('#url').value = currentPlaylistUrl(); 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); renderAdvancedOptions(root); }); }); root.querySelector('#subtitles').addEventListener('change', () => renderAdvancedOptions(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="clear-failed"]').addEventListener('click', clearFailed); root.querySelector('[data-action="open-folder"]').addEventListener('click', () => api('/open-folder', { method: 'POST', body: {} }).catch(showError)); updatePathMode(root); requestAnimationFrame(() => { const input = root.querySelector('#url'); if (input) input.focus(); }); } function handlePanelKeydown(event) { if (event.key === 'Escape') { event.preventDefault(); closePanel(); return; } if (event.key !== 'Tab') return; const root = ensureRoot(); const focusable = [...root.querySelectorAll('button:not([disabled]):not(.hidden), input:not([disabled]):not(.hidden), select:not([disabled]):not(.hidden), [tabindex="0"]')] .filter((item) => item.offsetParent !== null); if (!focusable.length) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; if (event.shiftKey && root.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && root.activeElement === last) { event.preventDefault(); first.focus(); } } function updatePathMode(root = ensureRoot()) { const mode = root.querySelector('#path-mode'); if (!mode) return; const temporary = state.settings && state.outputDir && state.outputDir !== state.settings.download_dir; mode.textContent = temporary ? t('temporaryFolder') : ''; } function renderConnection(root = ensureRoot()) { const container = root.querySelector('#connection'); if (!container) return; const toolsReady = state.health && Object.values(state.health.tools || {}).every(Boolean); const versionReady = state.health && state.health.version === SCRIPT_VERSION; const ready = state.health && state.health.ok && toolsReady && versionReady; const text = !state.health ? t('helperMissing') : (!toolsReady ? t('toolsMissing') : (!versionReady ? `${t('versionMismatch')} (${SCRIPT_VERSION} / ${state.health.version})` : t('helperReady'))); 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 formats = state.metadata && state.metadata.video_formats ? state.metadata.video_formats : []; const bestBytes = formats.reduce((largest, item) => Math.max(largest, Number(item.filesize || 0)), 0); field.innerHTML = ``; } function renderAdvancedOptions(root = ensureRoot()) { const compatibility = root.querySelector('#compatibility-field'); if (compatibility) compatibility.classList.toggle('hidden', state.mode !== 'video'); const subtitleOptions = root.querySelector('#subtitle-options'); const subtitles = root.querySelector('#subtitles'); if (subtitleOptions && subtitles) subtitleOptions.classList.toggle('hidden', !subtitles.checked); const list = root.querySelector('#subtitle-track-list'); if (list) { const tracks = state.metadata && state.metadata.subtitle_tracks ? state.metadata.subtitle_tracks : []; list.innerHTML = tracks.slice(0, 100).map((track) => ``).join(''); } } 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); renderAdvancedOptions(root); if (!data.is_playlist) { playlist.innerHTML = ''; return; } playlist.innerHTML = `
${escapeHtml(t('playlist'))}
`; const updateCount = () => { playlist.querySelector('#selected-count').textContent = t('selected', { count: state.playlistSelection.size }); }; const renderItems = () => renderPlaylistItems(playlist, playlist.querySelector('#playlist-search').value, updateCount); playlist.querySelector('#playlist-search').addEventListener('input', renderItems); playlist.querySelector('[data-select="all"]').addEventListener('click', () => { state.playlistSelection = new Set(data.entries.map((_entry, index) => index)); renderItems(); updateCount(); }); playlist.querySelector('[data-select="none"]').addEventListener('click', () => { state.playlistSelection.clear(); renderItems(); updateCount(); }); renderItems(); updateCount(); } function renderPlaylistItems(container, query, updateCount) { const list = container.querySelector('#playlist-items'); const normalized = String(query || '').trim().toLocaleLowerCase(); const matches = state.metadata.entries .map((entry, index) => ({ entry, index })) .filter(({ entry }) => !normalized || String(entry.title || '').toLocaleLowerCase().includes(normalized)) .slice(0, 300); list.innerHTML = matches.length ? matches.map(({ entry, index }) => ``).join('') : `
${escapeHtml(t('noMatches'))}
`; list.querySelectorAll('[data-entry]').forEach((box) => box.addEventListener('change', () => { const index = Number(box.dataset.entry); if (box.checked) state.playlistSelection.add(index); else state.playlistSelection.delete(index); 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(``); if (task.error) actions.push(``); const stage = task.stage && t(task.stage) !== task.stage ? t(task.stage) : ''; const bytes = task.total_bytes ? `${formatBytes(task.downloaded_bytes)} / ${formatBytes(task.total_bytes)}` : ''; return `
${escapeHtml(task.title)}
${escapeHtml(t(task.status) || t('unknown'))}
${progress.toFixed(1)}%${stage ? `${escapeHtml(stage)}` : ''}${bytes ? `${escapeHtml(bytes)}` : ''}${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)); }); container.querySelectorAll('[data-copy-error]').forEach((button) => { button.addEventListener('click', () => { const task = state.tasks.find((item) => item.id === button.dataset.copyError); if (task && task.error) GM_setClipboard(task.error, 'text'); }); }); } 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; } updatePathMode(); } async function analyzeInput() { const root = ensureRoot(); const input = root.querySelector('#url'); const url = input.value.trim(); if (!/^https?:\/\/(?:www\.|m\.)?(?:youtube\.com|youtu\.be)\//i.test(url)) { showError(new Error(t('invalidUrl'))); return; } if (state.analyzeRequest && typeof state.analyzeRequest.abort === 'function') state.analyzeRequest.abort(); const cached = state.analysisCache.get(url); if (cached && Date.now() - cached.at < 10 * 60 * 1000) { applyAnalyzedMetadata(cached.data, root); return; } setAnalyzing(root, true); setNotice(''); const request = api('/analyze', { method: 'POST', body: { url } }); state.analyzeRequest = request; try { const metadata = await request; if (state.analyzeRequest !== request) return; state.analysisCache.set(url, { at: Date.now(), data: metadata }); applyAnalyzedMetadata(metadata, root); } catch (error) { if (!error.canceled) showError(error); } finally { if (state.analyzeRequest === request) { state.analyzeRequest = null; setAnalyzing(root, false); } } } function applyAnalyzedMetadata(metadata, root = ensureRoot()) { state.metadata = metadata; state.playlistSelection = metadata.is_playlist ? new Set(metadata.entries.map((_entry, index) => index)) : new Set(); renderMetadata(root); const queue = root.querySelector('[data-action="queue"]'); if (queue) queue.disabled = false; } function setAnalyzing(root, analyzing) { const button = root.querySelector('[data-action="analyze"]'); const cancel = root.querySelector('[data-action="cancel-analyze"]'); if (button) { button.disabled = analyzing; button.textContent = analyzing ? t('analyzing') : t('analyze'); } if (cancel) cancel.classList.toggle('hidden', !analyzing); } function cancelAnalyze() { if (state.analyzeRequest && typeof state.analyzeRequest.abort === 'function') state.analyzeRequest.abort(); } 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; updatePathMode(); 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 } }); updatePathMode(); 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 = [...state.playlistSelection] .sort((a, b) => a - b) .map((index) => state.metadata.entries[index]) .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', compatibility: root.querySelector('#compatibility') ? root.querySelector('#compatibility').value : 'quality', audio_format: audioFormat ? audioFormat.value : 'mp3', subtitles: root.querySelector('#subtitles').checked, subtitle_mode: root.querySelector('#subtitle-mode').value, subtitle_languages: root.querySelector('#subtitle-languages').value.trim(), thumbnail: root.querySelector('#thumbnail').checked, conflict_action: root.querySelector('#conflict-action').value, allow_duplicate: root.querySelector('#allow-duplicate').checked, estimated_bytes: quality && quality.selectedOptions.length ? Number(quality.selectedOptions[0].dataset.bytes || 0) : 0, 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() { try { const result = await api('/tasks', { timeout: 4000 }); const tasks = result.tasks || []; notifyTaskTransitions(tasks); state.tasks = tasks; if (state.open) renderTasks(); } catch (_) { state.health = null; if (state.open) renderConnection(); } } function notifyTaskTransitions(tasks) { const initialized = state.taskStates.size > 0; tasks.forEach((task) => { const previous = state.taskStates.get(task.id); if (initialized && previous && previous !== task.status && ['completed', 'failed'].includes(task.status)) { try { GM_notification({ title: `${t('app')} · ${t(task.status)}`, text: task.error || task.title, timeout: 6000 }); } catch (_) {} } state.taskStates.set(task.id, task.status); }); } 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: { statuses: ['completed'] } }); await pollTasks(); } catch (error) { showError(error); } } async function clearFailed() { try { await api('/tasks/clear-completed', { method: 'POST', body: { statuses: ['failed', 'canceled'] } }); await pollTasks(); } catch (error) { showError(error); } } async function copyDiagnostics() { try { const [diagnostics, logs] = await Promise.all([api('/diagnostics'), api('/logs')]); GM_setClipboard(JSON.stringify({ diagnostics, recent_logs: logs.lines || [] }, null, 2), 'text'); setNotice(t('diagnosticsCopied')); } 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')}`; } function formatBytes(bytes) { const value = Number(bytes || 0); if (!value) return ''; const units = ['B', 'KB', 'MB', 'GB', 'TB']; const index = Math.min(units.length - 1, Math.floor(Math.log(value) / Math.log(1024))); return `${(value / (1024 ** index)).toFixed(index > 1 ? 1 : 0)} ${units[index]}`; } async function openPanel() { state.lastFocused = document.activeElement; state.open = true; state.metadata = null; renderPanel(); await connectHelper(); if (currentYouTubeUrl() && state.health && Object.values(state.health.tools || {}).every(Boolean)) analyzeInput(); setPolling(1200); } async function openPanelSafely() { try { await openPanel(); } catch (error) { console.error('[YouTube Download Helper]', error); window.alert(`${t('app')}: ${error && error.message ? error.message : t('requestFailed')}`); } } function closePanel() { if (state.analyzeRequest && typeof state.analyzeRequest.abort === 'function') state.analyzeRequest.abort(); state.open = false; setPolling(5000); renderPanel(); if (state.lastFocused && typeof state.lastFocused.focus === 'function') state.lastFocused.focus(); } function setPolling(interval) { clearInterval(state.poller); state.poller = setInterval(pollTasks, interval); } function handleNavigation() { if (location.href !== state.lastUrl) { state.lastUrl = location.href; state.metadata = null; if (state.analyzeRequest && typeof state.analyzeRequest.abort === 'function') state.analyzeRequest.abort(); if (state.open) renderPanel(); } scheduleInjection(); } GM_registerMenuCommand('YouTube Download Helper', openPanelSafely); GM_registerMenuCommand('中文 / English', () => { state.language = state.language === 'zh-CN' ? 'en' : 'zh-CN'; GM_setValue('language', state.language); document.querySelectorAll(`.${BUTTON_CLASS}`).forEach((button) => { button.textContent = t('download'); }); handleNavigation(); }); document.addEventListener('yt-navigate-finish', handleNavigation); document.addEventListener('click', (event) => { const path = typeof event.composedPath === 'function' ? event.composedPath() : [event.target]; const target = path.find((node) => node && node.classList && node.classList.contains(BUTTON_CLASS)); if (!target) return; handlePageButtonClick(event); }, true); new MutationObserver(scheduleInjection).observe(document.documentElement, { childList: true, subtree: true }); setInterval(scheduleInjection, 2000); setPolling(5000); void pollTasks(); handleNavigation(); })();