// ==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 = `
`;
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'))}
`;
}).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();
})();