Greasy Fork is available in English.
Unified yt-dlp downloader - generates cross-platform Python scripts for video, audio, subtitles
// ==UserScript==
// @name yt-dlp Python-Downloader
// @namespace https://greasyfork.org/en/users/1462137-piknockyou
// @version 9.2
// @author Piknockyou (vibe-coded)
// @license AGPL-3.0
// @description Unified yt-dlp downloader - generates cross-platform Python scripts for video, audio, subtitles
//
// ═══════════════════════════════════════════════════════════════
// CHANGELOG
// ═══════════════════════════════════════════════════════════════
// v9.2
// - Fixed: Submenu no longer closes when moving mouse from row to submenu (bridged hover gap)
// - Fixed: Selected buttons (Merge/Sep) no longer turn gray on hover
//
// v9.1
// - Fixed: JavaScript booleans (true/false) now correctly converted to Python (True/False)
// - Fixed: Generated scripts now execute properly on all platforms
//
// v9.0
// - Complete rewrite: now generates Python scripts instead of batch files
// - Cross-platform: works on Windows, Mac, and Linux
// - Same functionality as v8.4 batch version
// - Python 3 required (pre-installed on Mac/Linux, install on Windows)
// - Uses only Python standard library (no pip packages needed)
//
// v8.4
// - Refactored: Sites now default to full mkvmerge flow (supports Separate)
// - Added: COMBINED_STREAM_SITES blacklist for sites without separate streams
// - Fixed: Reddit, Twitter, etc. now properly support Merge+Separate
// - Note: Add sites to COMBINED_STREAM_SITES if they fail with "format not available"
//
// v8.3
// - Fixed: Non-YouTube sites now use format with /best fallback
// - Fixed: Livestorm and other HLS sites work again (combined streams)
// - Fixed: Non-YouTube sites skip complex merge flow, use yt-dlp native merge
// - Note: Merge/Separate options still work for YouTube; non-YouTube gets best available
//
// v8.2
// - Changed: Error prompts now require Enter key (not any key) to close
// - Uses "set /p" instead of "pause" for consistent behavior
//
// v8.1
// - Fixed: Removed invalid %(lang)s placeholder from subtitle template
// - Fixed: Subtitle files now correctly named as filename.en.srt (not filename.NA.en.srt)
// - Note: yt-dlp automatically inserts language code before extension
//
// v8.0
// - Complete rewrite of batch generation for all merge/separate combinations
// - Fixed: Subtitle glob patterns now match actual downloaded files
// - Fixed: Separate file naming (no temp prefix in output names)
// - Fixed: Video/audio detection using ffprobe for ambiguous formats
// - Fixed: Selective cleanup - only deletes temp files, preserves separate copies
// - Fixed: All 64 combinations of V/A/S × None/Merge/Sep/Both now work correctly
// - Added: Proper language preservation in subtitle filenames
// - Added: Robust file identification with subroutine-based processing
//
// v7.10
// - Fixed: Merge ON now sets ALL components to Merge (user deselects unwanted)
// - Fixed: Merge OFF only removes from clicked component
// - Fixed: Auto-cleanup when only 1 merge remains
//
// v7.9
// - Fixed: Merge OFF now only removes merge from clicked component
// - Fixed: Other components keep their merge when one is deselected
// - Fixed: Auto-cleanup only when going from 2 merges to 1 (can't merge alone)
// - Fixed: Merge ON adds to clicked + other enabled components if first merge
//
// v7.8
// - Fixed: Merge is now a global toggle - on for all or off for all
// - Fixed: Clicking Merge OFF on any component removes merge from ALL
// - Fixed: Auto-removes merge when only 1 component remains with merge
// - Fixed: Audio was incorrectly included in merge when set to Separate only
//
// v7.7
// - Fixed: Merge button always clickable - clicking sets ALL to merge
// - Fixed: Merge persists on remaining components when one is set to None/Sep
// - Fixed: Merge auto-removes only when single component left with merge
//
// v7.6
// - Fixed: Subtitles no longer downloaded twice in separate mode
// - Fixed: Deactivating Merge on one deactivates Merge on ALL
// - Fixed: Merge button disabled when only one component is enabled
// - Fixed: Submenus now available even when None is selected
// - Fixed: Panel width fits content
//
// v7.5
// - Redesigned output modes: Merge and Separate are now independent toggles
// - Removed "Both" button - now click Merge AND Sep to get both behaviors
// - Clicking Merge auto-activates Merge for all other enabled components
// - Fixed: No longer crashes when only subs want merge but no media to merge into
// - Simplified case logic in batch generation
//
// v7.4
// - Fixed: Crash when subs=Both but no media being merged
// - Fixed: Single yt-dlp call for media+subs (no duplicate metadata fetch)
// - Improved needsMerge logic to require actual media track to merge
//
// v7.3
// - Fixed: Batch window now waits for user confirmation after mkvmerge
// - Fixed: Merge with subtitles no longer crashes
// - Fixed: "Both" mode now correctly creates merged + separate files
// - Restructured batch flow to always reach pause at end
//
// v7.2
// - Fixed: Subtitle-only mode now uses --skip-download correctly
// - Fixed: Merge/Both buttons disabled when only one component selected
// - Reordered output buttons: Merge/Separate/Both/None
// - Improved stability and error handling
//
// v7.1
// - Restructured menu: Video/Audio/Subtitle rows with inline output modes
// - Detail submenus open on hover (disabled if output=None)
// - Menu stays open until clicking outside (even after download)
// - Removed error message - just grays out download button
// - Original subtitle format now default and first in list
//
// v7.0
// - Complete redesign: unified Media Download menu
// - New output modes: None/Merge/Separate/Both for each component
// - Removed interactive console prompts (D/E/ALL/ALLS quick-picks)
// - Removed subtitle source selection (always fetches all available)
// - Subtitle language selection remains in .bat file
// - DRY: shared subtitle format options across all modes
//
// ═══════════════════════════════════════════════════════════════
// SIMPLE SITES - Just add @match lines here, nothing else needed!
// ═══════════════════════════════════════════════════════════════
// @match *://www.arte.tv/*/videos/*
// @match *://www.dailymotion.com/*
// @match *://www.facebook.com/*
// @match *://www.instagram.com/*
// @match *://www.reddit.com/*
// @match *://soundcloud.com/*
// @match *://www.tagesschau.de/*
// @match *://www.tiktok.com/*
// @match *://www.twitch.tv/*
// @match *://twitter.com/*
// @match *://vimeo.com/*
// @match *://www.youtube.com/*
// @match *://x.com/*
//
// ═══════════════════════════════════════════════════════════════
// COMPLEX SITES - These need special extractors (defined below)
// ═══════════════════════════════════════════════════════════════
// @match *://app.livestorm.co/*
// @icon https://raw.githubusercontent.com/yt-dlp/yt-dlp/refs/heads/master/devscripts/logo.ico
// @grant GM_setClipboard
// @grant GM_setValue
// @grant GM_getValue
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// ═══════════════════════════════════════════════════════════════
// SITE-SPECIFIC URL CONDITIONS (SPA Support)
// ═══════════════════════════════════════════════════════════════
const SITE_CONDITIONS = {
'www.reddit.com': (url) => /\/r\/[^/]+\/comments\/[^/]+/.test(url),
};
// ═══════════════════════════════════════════════════════════════
// COMPLEX SITES - Only define sites that need special URL extraction
// ═══════════════════════════════════════════════════════════════
const COMPLEX_SITES = {
'app.livestorm.co': {
cookieFile: 'app.livestorm.co_cookies.txt',
extractUrl: () => {
const match = document.documentElement.innerHTML.match(/https:\\?\/\\?\/cdn\.livestorm\.co\\?\/[^"'\s]+\.m3u8[^"'\s]*/);
return match ? { url: match[0].replace(/\\/g, '') } : { error: 'Video URL not found. Make sure the video is loaded.' };
}
}
};
// ═══════════════════════════════════════════════════════════════
// COMBINED STREAM SITES - Sites that only have muxed video+audio
// These sites don't support Separate mode (no individual streams)
// Add hostname here if download fails with "format not available"
// ═══════════════════════════════════════════════════════════════
const COMBINED_STREAM_SITES = [
'app.livestorm.co',
'cdn.livestorm.co',
// Add more sites here as needed, e.g.:
// 'some-hls-only-site.com',
];
// Helper to check if current site has combined streams only
const isCombinedStreamSite = () => COMBINED_STREAM_SITES.some(site =>
window.location.hostname === site ||
window.location.hostname.endsWith('.' + site)
);
//================================================================================
// CONFIGURATION
//================================================================================
// ═══════════════════════════════════════════════════════════════
// OUTPUT MODES - Merge and Separate are independent toggles
// Internal values: 'none', 'merge', 'separate', 'merge-separate'
// ═══════════════════════════════════════════════════════════════
const OUTPUT_MODES = [
{ id: 'none', label: 'None', desc: 'Do not download this component' },
{ id: 'merge', label: 'Merge', desc: 'Include in merged file' },
{ id: 'separate', label: 'Separate', desc: 'Keep as standalone file' },
{ id: 'merge-separate', label: 'Merge+Sep', desc: 'Merged AND separate file' },
];
// Helper to check if output includes merge
const hasMergeFlag = (output) => output === 'merge' || output === 'merge-separate';
const hasSeparateFlag = (output) => output === 'separate' || output === 'merge-separate';
const isEnabled = (output) => output !== 'none';
// ═══════════════════════════════════════════════════════════════
// VIDEO OPTIONS
// ═══════════════════════════════════════════════════════════════
const VIDEO_QUALITIES = [
{ id: 'best', label: 'Best', format: 'bestvideo' },
{ id: '1080p', label: '1080p', format: 'bestvideo[height<=1080]' },
{ id: '720p', label: '720p', format: 'bestvideo[height<=720]' },
{ id: '480p', label: '480p', format: 'bestvideo[height<=480]' },
{ id: '360p', label: '360p', format: 'bestvideo[height<=360]' },
];
const VIDEO_CODECS = [
{ id: 'default', label: 'Auto', sortArg: '', desc: 'Let yt-dlp choose best available' },
{ id: 'av1', label: 'AV1', sortArg: '-S "vcodec:av01"', desc: 'Most efficient, needs modern hardware' },
{ id: 'vp9', label: 'VP9', sortArg: '-S "vcodec:vp9"', desc: 'Good efficiency, wide support' },
{ id: 'h264', label: 'H.264', sortArg: '-S "+vcodec:avc"', desc: 'Maximum compatibility' },
];
// ═══════════════════════════════════════════════════════════════
// AUDIO OPTIONS
// ═══════════════════════════════════════════════════════════════
const AUDIO_QUALITIES = [
{ id: 'best', label: 'Best', format: 'bestaudio' },
{ id: 'worst', label: 'Smallest', format: 'worstaudio' },
];
// ═══════════════════════════════════════════════════════════════
// SUBTITLE OPTIONS (Original first)
// ═══════════════════════════════════════════════════════════════
const SUBTITLE_FORMATS = [
{ id: 'original', label: 'Original', convertArg: '', desc: 'Keep original format' },
{ id: 'srt', label: 'SRT', convertArg: '--convert-subs srt', desc: 'Universal, widely supported' },
{ id: 'vtt', label: 'VTT', convertArg: '--convert-subs vtt', desc: 'Web standard format' },
{ id: 'ass', label: 'ASS', convertArg: '--convert-subs ass', desc: 'Advanced styling support' },
];
// ═══════════════════════════════════════════════════════════════
// STORAGE KEYS
// ═══════════════════════════════════════════════════════════════
const STORAGE_KEYS = {
VIDEO_QUALITY: 'ytdlp_video_quality',
VIDEO_CODEC: 'ytdlp_video_codec',
VIDEO_OUTPUT: 'ytdlp_video_output',
AUDIO_QUALITY: 'ytdlp_audio_quality',
AUDIO_OUTPUT: 'ytdlp_audio_output',
SUBS_FORMAT: 'ytdlp_subs_format',
SUBS_OUTPUT: 'ytdlp_subs_output',
};
// ═══════════════════════════════════════════════════════════════
// DEFAULTS
// ═══════════════════════════════════════════════════════════════
const DEFAULTS = {
VIDEO_QUALITY: 'best',
VIDEO_CODEC: 'default',
VIDEO_OUTPUT: 'merge',
AUDIO_QUALITY: 'best',
AUDIO_OUTPUT: 'merge',
SUBS_FORMAT: 'original',
SUBS_OUTPUT: 'none',
};
// ═══════════════════════════════════════════════════════════════
// OVERWRITE EXISTING FILES
// ═══════════════════════════════════════════════════════════════
const FORCE_OVERWRITE = true;
//================================================================================
// STORAGE HELPERS
//================================================================================
function getStoredValue(key, defaultValue, validOptions = null) {
try {
const stored = GM_getValue(key, defaultValue);
if (validOptions && !validOptions.some(v => v.id === stored)) {
return defaultValue;
}
return stored;
} catch (e) {
return defaultValue;
}
}
function setStoredValue(key, value) {
try {
GM_setValue(key, value);
} catch (e) {
console.warn('[yt-dlp] Failed to save setting:', e);
}
}
// Getters
const getVideoQuality = () => getStoredValue(STORAGE_KEYS.VIDEO_QUALITY, DEFAULTS.VIDEO_QUALITY, VIDEO_QUALITIES);
const getVideoCodec = () => getStoredValue(STORAGE_KEYS.VIDEO_CODEC, DEFAULTS.VIDEO_CODEC, VIDEO_CODECS);
const getVideoOutput = () => getStoredValue(STORAGE_KEYS.VIDEO_OUTPUT, DEFAULTS.VIDEO_OUTPUT, OUTPUT_MODES);
const getAudioQuality = () => getStoredValue(STORAGE_KEYS.AUDIO_QUALITY, DEFAULTS.AUDIO_QUALITY, AUDIO_QUALITIES);
const getAudioOutput = () => getStoredValue(STORAGE_KEYS.AUDIO_OUTPUT, DEFAULTS.AUDIO_OUTPUT, OUTPUT_MODES);
const getSubsFormat = () => getStoredValue(STORAGE_KEYS.SUBS_FORMAT, DEFAULTS.SUBS_FORMAT, SUBTITLE_FORMATS);
const getSubsOutput = () => getStoredValue(STORAGE_KEYS.SUBS_OUTPUT, DEFAULTS.SUBS_OUTPUT, OUTPUT_MODES);
// Setters
const setVideoQuality = (v) => setStoredValue(STORAGE_KEYS.VIDEO_QUALITY, v);
const setVideoCodec = (v) => setStoredValue(STORAGE_KEYS.VIDEO_CODEC, v);
const setVideoOutput = (v) => setStoredValue(STORAGE_KEYS.VIDEO_OUTPUT, v);
const setAudioQuality = (v) => setStoredValue(STORAGE_KEYS.AUDIO_QUALITY, v);
const setAudioOutput = (v) => setStoredValue(STORAGE_KEYS.AUDIO_OUTPUT, v);
const setSubsFormat = (v) => setStoredValue(STORAGE_KEYS.SUBS_FORMAT, v);
const setSubsOutput = (v) => setStoredValue(STORAGE_KEYS.SUBS_OUTPUT, v);
// Helpers
const findOption = (options, id) => options.find(o => o.id === id);
const getLabel = (options, id) => findOption(options, id)?.label || options[0].label;
const isYouTubeDomain = () => ['www.youtube.com', 'youtube.com', 'm.youtube.com'].includes(window.location.hostname);
//================================================================================
// VALIDATION & STATE HELPERS
//================================================================================
function getComponentStates() {
const videoOut = getVideoOutput();
const audioOut = getAudioOutput();
const subsOut = getSubsOutput();
const hasVideo = isEnabled(videoOut);
const hasAudio = isEnabled(audioOut);
const hasSubs = isEnabled(subsOut);
const videoMerge = hasMergeFlag(videoOut);
const audioMerge = hasMergeFlag(audioOut);
const subsMerge = hasMergeFlag(subsOut);
const videoSeparate = hasSeparateFlag(videoOut);
const audioSeparate = hasSeparateFlag(audioOut);
const subsSeparate = hasSeparateFlag(subsOut);
const activeCount = [hasVideo, hasAudio, hasSubs].filter(Boolean).length;
// Merge requires at least one MEDIA track (video or audio) to be merged
// Subs alone cannot be merged - they need something to embed into
const hasMediaMerge = (hasVideo && videoMerge) || (hasAudio && audioMerge);
const needsMerge = hasMediaMerge;
return {
videoOut, audioOut, subsOut,
hasVideo, hasAudio, hasSubs,
videoMerge, audioMerge, subsMerge,
videoSeparate, audioSeparate, subsSeparate,
activeCount, needsMerge
};
}
// Count how many components have merge flag
function getMergeCount() {
const videoOut = getVideoOutput();
const audioOut = getAudioOutput();
const subsOut = getSubsOutput();
return [hasMergeFlag(videoOut), hasMergeFlag(audioOut), hasMergeFlag(subsOut)].filter(Boolean).length;
}
// Set ALL components to merge (preserving separate flags)
function setAllToMerge() {
const videoOut = getVideoOutput();
const audioOut = getAudioOutput();
const subsOut = getSubsOutput();
setVideoOutput(hasSeparateFlag(videoOut) ? 'merge-separate' : 'merge');
setAudioOutput(hasSeparateFlag(audioOut) ? 'merge-separate' : 'merge');
setSubsOutput(hasSeparateFlag(subsOut) ? 'merge-separate' : 'merge');
}
// If only one component has merge, remove it (can't merge alone)
function cleanupLoneMerge() {
if (getMergeCount() === 1) {
const videoOut = getVideoOutput();
const audioOut = getAudioOutput();
const subsOut = getSubsOutput();
if (hasMergeFlag(videoOut)) {
setVideoOutput(hasSeparateFlag(videoOut) ? 'separate' : 'none');
}
if (hasMergeFlag(audioOut)) {
setAudioOutput(hasSeparateFlag(audioOut) ? 'separate' : 'none');
}
if (hasMergeFlag(subsOut)) {
setSubsOutput(hasSeparateFlag(subsOut) ? 'separate' : 'none');
}
}
}
// Toggle merge for a component
function toggleMerge(componentType, currentOutput, setter) {
const hadMerge = hasMergeFlag(currentOutput);
if (hadMerge) {
// Clicking merge OFF: remove merge from THIS component only
setter(hasSeparateFlag(currentOutput) ? 'separate' : 'none');
// If only 1 merge remains after this, remove it too (can't merge alone)
cleanupLoneMerge();
} else {
// Clicking merge ON: set ALL components to merge
// User can deselect the ones they don't want
setAllToMerge();
}
}
// Toggle separate for a component
function toggleSeparate(componentType, currentOutput, setter) {
const hadMerge = hasMergeFlag(currentOutput);
const hadSeparate = hasSeparateFlag(currentOutput);
if (hadSeparate) {
// Remove separate
setter(hadMerge ? 'merge' : 'none');
} else {
// Add separate
if (currentOutput === 'none') {
setter('separate');
} else {
setter('merge-separate');
}
}
// No need to cleanup here - separate doesn't affect merge count
}
// Set component to None
function setToNone(setter) {
setter('none');
// After setting to none, check if only 1 merge left
cleanupLoneMerge();
}
function validateSettings() {
const state = getComponentStates();
// Rule 1: At least one component must be non-None
if (state.activeCount === 0) {
return { valid: false };
}
return { valid: true };
}
//================================================================================
// CONTEXT MENU
//================================================================================
const ContextMenu = {
shadowHost: null,
shadowRoot: null,
element: null,
isOpen: false,
closeHandler: null,
updateFunctions: [], // Store update functions for dynamic state
getStyles() {
return `
:host { all: initial; }
* { box-sizing: border-box; }
.ytdlp-context-menu {
position: fixed;
background: #2d2d2d;
border: 1px solid #444;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
padding: 8px;
z-index: 2147483647;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 13px;
color: #e0e0e0;
user-select: none;
pointer-events: auto;
display: none;
width: fit-content;
}
.ytdlp-context-menu.visible { display: block; }
/* Component rows (Video, Audio, Subtitle) */
.ytdlp-component-row {
padding: 8px 12px;
border-radius: 4px;
position: relative;
transition: background 0.15s;
}
.ytdlp-component-row:not(:last-child) {
border-bottom: 1px solid #3a3a3a;
}
.ytdlp-component-row:hover {
background: #3a3a3a;
}
.ytdlp-component-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.ytdlp-component-title {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #aaa;
}
.ytdlp-component-arrow {
font-size: 9px;
opacity: 0.5;
transition: opacity 0.15s;
}
.ytdlp-component-row:hover .ytdlp-component-arrow {
opacity: 1;
}
/* Output mode buttons */
.ytdlp-output-modes {
display: flex;
gap: 4px;
}
.ytdlp-output-btn {
padding: 4px 8px;
font-size: 11px;
border-radius: 4px;
cursor: pointer;
background: #3a3a3a;
color: #999;
transition: all 0.15s;
border: 1px solid transparent;
}
.ytdlp-output-btn:hover:not(.disabled):not(.selected) {
background: #444;
color: #ccc;
}
.ytdlp-output-btn.selected {
background: #4a6da7;
color: #fff;
border-color: #5a7db7;
}
.ytdlp-output-btn.selected:hover {
background: #5a7db7;
border-color: #6a8dc7;
}
.ytdlp-output-btn.disabled {
opacity: 0.35;
cursor: not-allowed;
pointer-events: none;
}
.ytdlp-output-btn[data-tooltip] {
position: relative;
}
.ytdlp-output-btn[data-tooltip]:hover::before {
content: attr(data-tooltip);
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.9);
color: #fff;
padding: 4px 8px;
border-radius: 4px;
font-size: 10px;
white-space: nowrap;
z-index: 100;
pointer-events: none;
}
/* Detail submenu */
.ytdlp-detail-submenu {
display: none;
position: absolute;
right: calc(100% + 4px);
top: 0;
background: #2d2d2d;
border: 1px solid #444;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
padding: 8px;
min-width: 140px;
}
/* Invisible bridge to prevent hover loss when moving to submenu */
.ytdlp-detail-submenu::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
right: -8px;
width: 12px;
}
.ytdlp-component-row.submenu-open .ytdlp-detail-submenu {
display: block;
}
.ytdlp-detail-section {
padding: 4px 0;
}
.ytdlp-detail-section:not(:last-child) {
border-bottom: 1px solid #3a3a3a;
margin-bottom: 4px;
padding-bottom: 8px;
}
.ytdlp-detail-header {
padding: 4px 10px;
font-size: 10px;
text-transform: uppercase;
color: #888;
letter-spacing: 0.5px;
}
.ytdlp-detail-item {
padding: 6px 10px;
font-size: 12px;
cursor: pointer;
border-radius: 4px;
transition: background 0.15s;
display: flex;
align-items: center;
gap: 8px;
}
.ytdlp-detail-item:hover {
background: #3a3a3a;
}
.ytdlp-detail-item.selected::before {
content: '✓';
font-size: 10px;
color: #4CAF50;
width: 12px;
}
.ytdlp-detail-item:not(.selected)::before {
content: '';
width: 12px;
display: inline-block;
}
/* Simple menu row (Comments) */
.ytdlp-menu-row {
display: flex;
align-items: center;
padding: 10px 12px;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
}
.ytdlp-menu-row:hover {
background: #3a3a3a;
}
.ytdlp-menu-row:not(:last-child) {
border-bottom: 1px solid #3a3a3a;
}
/* Download button */
.ytdlp-download-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 12px;
margin-top: 4px;
background: #4CAF50;
color: #fff;
font-weight: 500;
font-size: 13px;
border-radius: 4px;
cursor: pointer;
transition: background 0.15s;
}
.ytdlp-download-btn:hover:not(.disabled) {
background: #43a047;
}
.ytdlp-download-btn.disabled {
background: #555;
cursor: not-allowed;
opacity: 0.6;
}
`;
},
init() {
if (this.shadowHost) return;
try {
this.shadowHost = document.createElement('div');
this.shadowHost.id = 'ytdlp-context-menu-host';
Object.assign(this.shadowHost.style, {
position: 'fixed',
top: '0',
left: '0',
width: '0',
height: '0',
overflow: 'visible',
zIndex: '2147483647',
pointerEvents: 'none'
});
this.shadowRoot = this.shadowHost.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = this.getStyles();
this.shadowRoot.appendChild(style);
this.element = document.createElement('div');
this.element.className = 'ytdlp-context-menu';
this.shadowRoot.appendChild(this.element);
document.body.appendChild(this.shadowHost);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.isOpen) this.hide();
}, true);
} catch (e) {
console.error('[yt-dlp] Failed to init context menu:', e);
}
},
show(x, y, onDownload) {
if (!this.shadowHost) this.init();
if (!this.element) return;
this.isOpen = true;
this.element.textContent = '';
this.updateFunctions = [];
const showCodecOption = isYouTubeDomain();
let downloadBtn = null;
// Master update function - updates all dynamic states
const updateAllStates = () => {
this.updateFunctions.forEach(fn => {
try { fn(); } catch (e) { console.warn('[yt-dlp] Update error:', e); }
});
updateDownloadState();
};
// Helper to update download button state
const updateDownloadState = () => {
if (downloadBtn) {
const valid = validateSettings().valid;
downloadBtn.classList.toggle('disabled', !valid);
}
};
// Helper to build a component row (Video, Audio, Subtitle)
const buildComponentRow = (title, componentType, outputGetter, outputSetter, detailBuilder) => {
const row = document.createElement('div');
row.className = 'ytdlp-component-row';
// Header with title and arrow
const header = document.createElement('div');
header.className = 'ytdlp-component-header';
const titleEl = document.createElement('span');
titleEl.className = 'ytdlp-component-title';
titleEl.textContent = title;
header.appendChild(titleEl);
const arrow = document.createElement('span');
arrow.className = 'ytdlp-component-arrow';
arrow.textContent = '◀';
header.appendChild(arrow);
row.appendChild(header);
// Output mode buttons: [Merge] [Sep] [None]
const modesContainer = document.createElement('div');
modesContainer.className = 'ytdlp-output-modes';
const mergeBtn = document.createElement('div');
mergeBtn.className = 'ytdlp-output-btn';
mergeBtn.textContent = 'Merge';
mergeBtn.setAttribute('data-tooltip', 'Include in merged file (syncs with other components)');
const sepBtn = document.createElement('div');
sepBtn.className = 'ytdlp-output-btn';
sepBtn.textContent = 'Sep';
sepBtn.setAttribute('data-tooltip', 'Keep as standalone file');
const noneBtn = document.createElement('div');
noneBtn.className = 'ytdlp-output-btn';
noneBtn.textContent = 'None';
noneBtn.setAttribute('data-tooltip', 'Do not download this component');
mergeBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
toggleMerge(componentType, outputGetter(), outputSetter);
updateAllStates();
};
sepBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
toggleSeparate(componentType, outputGetter(), outputSetter);
updateAllStates();
};
noneBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
setToNone(outputSetter);
updateAllStates();
};
modesContainer.appendChild(mergeBtn);
modesContainer.appendChild(sepBtn);
modesContainer.appendChild(noneBtn);
row.appendChild(modesContainer);
// Detail submenu
const submenu = document.createElement('div');
submenu.className = 'ytdlp-detail-submenu';
detailBuilder(submenu);
row.appendChild(submenu);
// Update function for this row
const updateRowState = () => {
const currentOutput = outputGetter();
const isMerge = hasMergeFlag(currentOutput);
const isSeparate = hasSeparateFlag(currentOutput);
const isNone = currentOutput === 'none';
// Update button states - Merge and Sep can both be selected
// Merge is ALWAYS clickable (clicking sets all to merge)
mergeBtn.classList.toggle('selected', isMerge);
sepBtn.classList.toggle('selected', isSeparate);
noneBtn.classList.toggle('selected', isNone);
};
// Register update function
this.updateFunctions.push(updateRowState);
updateRowState();
// Hover to open submenu (always available)
row.addEventListener('mouseenter', () => {
row.classList.add('submenu-open');
requestAnimationFrame(() => {
try {
const rowRect = row.getBoundingClientRect();
const submenuRect = submenu.getBoundingClientRect();
submenu.style.top = '0';
submenu.style.bottom = '';
if (rowRect.top + submenuRect.height > window.innerHeight - 10) {
submenu.style.top = 'auto';
submenu.style.bottom = '0';
}
} catch (e) { /* ignore */ }
});
});
row.addEventListener('mouseleave', () => {
row.classList.remove('submenu-open');
});
return row;
};
// Helper to build detail items
const buildDetailSection = (container, headerText, options, currentValueGetter, onSelect) => {
const section = document.createElement('div');
section.className = 'ytdlp-detail-section';
const header = document.createElement('div');
header.className = 'ytdlp-detail-header';
header.textContent = headerText;
section.appendChild(header);
const items = [];
options.forEach(opt => {
const item = document.createElement('div');
item.className = 'ytdlp-detail-item';
item.textContent = opt.label;
item.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
items.forEach(el => el.classList.remove('selected'));
item.classList.add('selected');
onSelect(opt.id);
};
items.push(item);
section.appendChild(item);
});
// Update selection state
const updateSelection = () => {
const currentValue = currentValueGetter();
items.forEach((item, index) => {
item.classList.toggle('selected', options[index].id === currentValue);
});
};
updateSelection();
container.appendChild(section);
};
// ─────────────────────────────────────────────────────────────
// VIDEO ROW
// ─────────────────────────────────────────────────────────────
const videoRow = buildComponentRow('VIDEO', 'video', getVideoOutput, setVideoOutput, (submenu) => {
buildDetailSection(submenu, 'Quality', VIDEO_QUALITIES, getVideoQuality, setVideoQuality);
if (showCodecOption) {
buildDetailSection(submenu, 'Codec', VIDEO_CODECS, getVideoCodec, setVideoCodec);
}
});
this.element.appendChild(videoRow);
// ─────────────────────────────────────────────────────────────
// AUDIO ROW
// ─────────────────────────────────────────────────────────────
const audioRow = buildComponentRow('AUDIO', 'audio', getAudioOutput, setAudioOutput, (submenu) => {
buildDetailSection(submenu, 'Quality', AUDIO_QUALITIES, getAudioQuality, setAudioQuality);
});
this.element.appendChild(audioRow);
// ─────────────────────────────────────────────────────────────
// SUBTITLE ROW
// ─────────────────────────────────────────────────────────────
const subtitleRow = buildComponentRow('SUBTITLE', 'subs', getSubsOutput, setSubsOutput, (submenu) => {
buildDetailSection(submenu, 'Format', SUBTITLE_FORMATS, getSubsFormat, setSubsFormat);
});
this.element.appendChild(subtitleRow);
// ─────────────────────────────────────────────────────────────
// COMMENTS ROW
// ─────────────────────────────────────────────────────────────
const commentsRow = document.createElement('div');
commentsRow.className = 'ytdlp-menu-row';
commentsRow.textContent = '💬 Comments Only';
commentsRow.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
onDownload('comments');
};
this.element.appendChild(commentsRow);
// ─────────────────────────────────────────────────────────────
// DOWNLOAD BUTTON
// ─────────────────────────────────────────────────────────────
downloadBtn = document.createElement('div');
downloadBtn.className = 'ytdlp-download-btn';
downloadBtn.textContent = '⬇ Download';
downloadBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
if (downloadBtn.classList.contains('disabled')) return;
if (!validateSettings().valid) return;
onDownload('media');
};
this.element.appendChild(downloadBtn);
updateDownloadState();
// Initial update of all states
updateAllStates();
// Position menu
this.element.classList.add('visible');
requestAnimationFrame(() => {
try {
const rect = this.element.getBoundingClientRect();
let left = x - rect.width - 10;
let top = y;
if (left < 10) left = 10;
if (top + rect.height > window.innerHeight - 10) top = window.innerHeight - rect.height - 10;
if (top < 10) top = 10;
this.element.style.left = left + 'px';
this.element.style.top = top + 'px';
} catch (e) {
this.element.style.left = '10px';
this.element.style.top = '10px';
}
});
// Outside click handler (only way to close)
if (this.closeHandler) {
document.removeEventListener('mousedown', this.closeHandler, true);
}
const self = this;
this.closeHandler = (e) => {
if (!self.isOpen) return;
try {
if (e.composedPath().includes(self.shadowHost)) return;
} catch (err) {
// composedPath might fail in some edge cases
if (self.shadowHost.contains(e.target)) return;
}
self.hide();
};
setTimeout(() => document.addEventListener('mousedown', this.closeHandler, true), 100);
},
hide() {
if (this.element) {
this.element.classList.remove('visible');
}
this.isOpen = false;
this.updateFunctions = [];
if (this.closeHandler) {
document.removeEventListener('mousedown', this.closeHandler, true);
this.closeHandler = null;
}
},
isVisible() { return this.isOpen; }
};
//================================================================================
// PYTHON SCRIPT GENERATION
//================================================================================
const CONFIG_UI = {
button: {
size: 24,
iconStyle: {
shadow: { enabled: true, blur: 2, color: 'rgba(255, 255, 255, 0.8)' },
background: { enabled: true, color: 'rgba(128, 128, 128, 0.25)', borderRadius: '50%' }
},
position: { vertical: 'bottom', horizontal: 'right', offsetX: 1, offsetY: 29 },
opacity: { default: 0.15, hover: 1, active: 0.7 },
scale: { default: 1, hover: 1.1, active: 0.95 },
zIndex: 2147483646
},
timing: {
doubleClickThreshold: 350,
hideTemporarilyDuration: 5000,
hoverCheckInterval: 100
}
};
//================================================================================
// GLOBAL STATE
//================================================================================
const hostname = window.location.hostname;
const complex = COMPLEX_SITES[hostname];
const siteCondition = SITE_CONDITIONS[hostname];
const COOKIE_FILE = complex?.cookieFile || `${hostname}_cookies.txt`;
let shadowRoot = null;
let notificationElement = null;
let containerElement = null;
const STATE = {
hidden: false,
lastUrl: window.location.href,
checkInterval: null
};
//================================================================================
// HELPER FUNCTIONS
//================================================================================
const sanitize = (str, max = 80) =>
(str || 'video').replace(/[^a-zA-Z0-9]/g, '_').replace(/_+/g, '_').substring(0, max);
function getVideoInfo() {
if (complex?.extractUrl) {
const result = complex.extractUrl();
if (result.error) return result;
return { url: result.url, filename: sanitize(document.title), cookieFile: COOKIE_FILE };
}
return { url: window.location.href, filename: sanitize(document.title), cookieFile: COOKIE_FILE };
}
const getOverwriteFlag = () => FORCE_OVERWRITE ? '--force-overwrites' : '';
// Helper to convert JS boolean to Python boolean string
const pyBool = (val) => val ? 'True' : 'False';
// ═══════════════════════════════════════════════════════════════
// Python script header template
// ═══════════════════════════════════════════════════════════════
function getPythonHeader() {
return `#!/usr/bin/env python3
"""
yt-dlp Media Downloader
Generated by yt-dlp Python-Downloader userscript v9.0
Cross-platform: Works on Windows, Mac, and Linux
Requires: Python 3.6+, yt-dlp
Optional: mkvmerge (for advanced merging), ffmpeg/ffprobe
"""
import subprocess
import sys
import os
import shutil
import random
from pathlib import Path
from glob import glob
# ════════════════════════════════════════════════════════════════════════════════
# HELPER FUNCTIONS
# ════════════════════════════════════════════════════════════════════════════════
def print_header(title):
"""Print a formatted header."""
print()
print("=" * 60)
print(f" {title}")
print("=" * 60)
print()
def print_status(status, message):
"""Print a status message."""
print(f"[{status}] {message}")
def check_command(cmd):
"""Check if a command is available in PATH."""
return shutil.which(cmd) is not None
def run_command(args, check=False):
"""Run a command and return the result."""
try:
result = subprocess.run(args, capture_output=True, text=True)
if check and result.returncode != 0:
return None
return result
except Exception as e:
print_status("ERROR", f"Command failed: {e}")
return None
def wait_for_exit(error=False):
"""Wait for user to press Enter before exiting."""
print()
if error:
print("Press Enter to exit...")
else:
print("Press Enter to exit...")
try:
input()
except:
pass
def identify_file(filepath, has_ffprobe=False):
"""
Identify if a file is video, audio, or subtitle based on extension.
For ambiguous formats like .webm, uses ffprobe if available.
Returns: 'video', 'audio', 'subtitle', or 'unknown'
"""
path = Path(filepath)
name = path.stem.lower()
ext = path.suffix.lower()
# Check for subtitle marker in filename
if "_sub" in name:
return "subtitle"
# Audio-only extensions
audio_exts = {".m4a", ".mp3", ".opus", ".ogg", ".aac", ".flac", ".wav"}
if ext in audio_exts:
return "audio"
# Video extensions
video_exts = {".mp4", ".mkv", ".avi", ".mov", ".flv", ".ts", ".3gp"}
if ext in video_exts:
return "video"
# Ambiguous extension (.webm can be video or audio)
if ext == ".webm":
if has_ffprobe:
try:
result = subprocess.run(
["ffprobe", "-v", "error", "-select_streams", "v:0",
"-show_entries", "stream=codec_type", "-of", "csv=p=0", filepath],
capture_output=True, text=True, timeout=10
)
if "video" in result.stdout.lower():
return "video"
else:
return "audio"
except:
pass
# Fallback: assume video if we can't determine
return "video"
return "unknown"
`;
}
function generatePythonScript(url, filename, cookieFile, mode) {
const overwriteFlag = getOverwriteFlag();
// ═══════════════════════════════════════════════════════════════
// COMMENTS MODE
// ═══════════════════════════════════════════════════════════════
if (mode === 'comments') {
return `${getPythonHeader()}
# ════════════════════════════════════════════════════════════════════════════════
# CONFIGURATION
# ════════════════════════════════════════════════════════════════════════════════
URL = "${url}"
FILENAME = "${filename}"
COOKIE_FILE = "${cookieFile}"
OVERWRITE_FLAG = "${overwriteFlag}"
# ════════════════════════════════════════════════════════════════════════════════
# MAIN - Comments Download
# ════════════════════════════════════════════════════════════════════════════════
def main():
# Change to script directory
os.chdir(Path(__file__).parent)
print_header("yt-dlp Comments Downloader")
# Check for yt-dlp
if not check_command("yt-dlp"):
print_status("ERROR", "yt-dlp not found")
print("Install: pip install yt-dlp")
print(" or: brew install yt-dlp (Mac)")
print(" or: winget install yt-dlp (Windows)")
wait_for_exit(error=True)
sys.exit(1)
print_status("OK", "yt-dlp found")
# Build command
cmd = ["yt-dlp"]
if OVERWRITE_FLAG:
cmd.append(OVERWRITE_FLAG)
# Add cookies if file exists
if Path(COOKIE_FILE).exists():
print_status("OK", "Cookie file found")
cmd.extend(["--cookies", COOKIE_FILE])
else:
print_status("INFO", "No cookie file")
print()
print(f"URL: {URL}")
print()
# Add comment extraction arguments
cmd.extend([
URL,
"--skip-download",
"--write-comments",
"--no-playlist",
"--extractor-args", "youtube:comment_sort=top;max_comments=all,all,all,all",
"-o", f"{FILENAME}.%(ext)s"
])
# Run yt-dlp
result = subprocess.run(cmd)
print()
if result.returncode != 0:
print_status("ERROR", "Download failed")
wait_for_exit(error=True)
sys.exit(1)
else:
print_status("SUCCESS", "Comments downloaded!")
wait_for_exit()
if __name__ == "__main__":
main()
`;
}
// ═══════════════════════════════════════════════════════════════
// MEDIA MODE
// ═══════════════════════════════════════════════════════════════
const videoQuality = getVideoQuality();
const videoCodec = getVideoCodec();
const videoOutput = getVideoOutput();
const audioQuality = getAudioQuality();
const audioOutput = getAudioOutput();
const subsFormat = getSubsFormat();
const subsOutput = getSubsOutput();
const hasVideo = isEnabled(videoOutput);
const hasAudio = isEnabled(audioOutput);
const hasSubs = isEnabled(subsOutput);
const videoMerge = hasMergeFlag(videoOutput);
const videoSeparate = hasSeparateFlag(videoOutput);
const audioMerge = hasMergeFlag(audioOutput);
const audioSeparate = hasSeparateFlag(audioOutput);
const subsMerge = hasMergeFlag(subsOutput);
const subsSeparate = hasSeparateFlag(subsOutput);
// needsMerge: at least 2 components have merge flag
const mergeCount = [videoMerge, audioMerge, subsMerge].filter(Boolean).length;
const needsMerge = mergeCount >= 2;
// Build format string
const videoFormat = findOption(VIDEO_QUALITIES, videoQuality)?.format || 'bestvideo';
const audioFormat = findOption(AUDIO_QUALITIES, audioQuality)?.format || 'bestaudio';
const codecArg = (hasVideo && isYouTubeDomain() && videoCodec !== 'default')
? findOption(VIDEO_CODECS, videoCodec)?.sortArg || ''
: '';
const subsConvertArg = findOption(SUBTITLE_FORMATS, subsFormat)?.convertArg || '';
// Generate summary labels
const videoLabel = hasVideo ? `${getLabel(VIDEO_QUALITIES, videoQuality)}${videoCodec !== 'default' ? ` [${getLabel(VIDEO_CODECS, videoCodec)}]` : ''}` : 'None';
const audioLabel = hasAudio ? getLabel(AUDIO_QUALITIES, audioQuality) : 'None';
const subsLabel = hasSubs ? getLabel(SUBTITLE_FORMATS, subsFormat) : 'None';
const getOutputLabel = (output) => getLabel(OUTPUT_MODES, output);
// Determine merged output extension
const mergedExt = (hasVideo && videoMerge) ? 'mkv' : 'mka';
// Check if combined stream site
const isCombined = isCombinedStreamSite();
return `${getPythonHeader()}
# ════════════════════════════════════════════════════════════════════════════════
# CONFIGURATION
# ════════════════════════════════════════════════════════════════════════════════
URL = "${url}"
FILENAME = "${filename}"
COOKIE_FILE = "${cookieFile}"
OVERWRITE_FLAG = "${overwriteFlag}"
# Component settings
HAS_VIDEO = ${pyBool(hasVideo)}
HAS_AUDIO = ${pyBool(hasAudio)}
HAS_SUBS = ${pyBool(hasSubs)}
VIDEO_MERGE = ${pyBool(videoMerge)}
VIDEO_SEPARATE = ${pyBool(videoSeparate)}
AUDIO_MERGE = ${pyBool(audioMerge)}
AUDIO_SEPARATE = ${pyBool(audioSeparate)}
SUBS_MERGE = ${pyBool(subsMerge)}
SUBS_SEPARATE = ${pyBool(subsSeparate)}
NEEDS_MERGE = ${pyBool(needsMerge)}
MERGED_EXT = "${mergedExt}"
VIDEO_FORMAT = "${videoFormat}"
AUDIO_FORMAT = "${audioFormat}"
CODEC_ARG = "${codecArg}"
SUBS_CONVERT_ARG = "${subsConvertArg}"
IS_COMBINED_STREAM_SITE = ${pyBool(isCombined)}
# Display labels
VIDEO_LABEL = "${videoLabel}"
AUDIO_LABEL = "${audioLabel}"
SUBS_LABEL = "${subsLabel}"
VIDEO_OUTPUT_LABEL = "${getOutputLabel(videoOutput)}"
AUDIO_OUTPUT_LABEL = "${getOutputLabel(audioOutput)}"
SUBS_OUTPUT_LABEL = "${getOutputLabel(subsOutput)}"
${generateDownloadFunction(url, filename, {
hasVideo, hasAudio, hasSubs,
videoMerge, videoSeparate, audioMerge, audioSeparate, subsMerge, subsSeparate,
needsMerge,
videoFormat, audioFormat, codecArg, subsConvertArg,
mergedExt, isCombined
})}
if __name__ == "__main__":
main()
`;
}
function generateDownloadFunction(url, filename, opts) {
const {
hasVideo, hasAudio, hasSubs,
videoMerge, videoSeparate, audioMerge, audioSeparate, subsMerge, subsSeparate,
needsMerge,
videoFormat, audioFormat, codecArg, subsConvertArg,
mergedExt, isCombined
} = opts;
// ═══════════════════════════════════════════════════════════════
// CASE 1: Subtitles ONLY (no video, no audio)
// ═══════════════════════════════════════════════════════════════
if (!hasVideo && !hasAudio && hasSubs) {
return `
# ════════════════════════════════════════════════════════════════════════════════
# MAIN - Subtitle Only Download
# ════════════════════════════════════════════════════════════════════════════════
def main():
# Change to script directory
os.chdir(Path(__file__).parent)
print_header("yt-dlp Subtitle Downloader")
# Check for yt-dlp
if not check_command("yt-dlp"):
print_status("ERROR", "yt-dlp not found")
print("Install: pip install yt-dlp")
wait_for_exit(error=True)
sys.exit(1)
print_status("OK", "yt-dlp found")
# Check for cookies
cookies_arg = []
if Path(COOKIE_FILE).exists():
print_status("OK", "Cookie file found")
cookies_arg = ["--cookies", COOKIE_FILE]
else:
print_status("INFO", "No cookie file")
print()
print(f"URL: {URL}")
print()
# List available subtitles
print_header("Available Subtitles")
subprocess.run(["yt-dlp", "--list-subs"] + cookies_arg + [URL])
print()
print("=" * 60)
print("Enter language code (e.g., en, de, ja)")
print("Or type 'all' for all languages")
print("Or press Enter to skip subtitles")
print("=" * 60)
print()
sub_lang = input("Language code: ").strip()
if not sub_lang:
print()
print("No subtitles requested. Nothing to download.")
wait_for_exit()
return
print()
print_header("Starting Download...")
# Build command
cmd = ["yt-dlp"]
if OVERWRITE_FLAG:
cmd.append(OVERWRITE_FLAG)
cmd.extend(cookies_arg)
cmd.extend([
URL,
"--skip-download",
"--write-subs",
"--write-auto-subs",
"--sub-langs", sub_lang
])
# Add subtitle conversion if specified
if SUBS_CONVERT_ARG:
cmd.extend(SUBS_CONVERT_ARG.split())
cmd.extend(["-o", f"{FILENAME}.%(ext)s"])
# Run download
result = subprocess.run(cmd)
print()
if result.returncode != 0:
print_status("ERROR", "Subtitle download failed")
wait_for_exit(error=True)
sys.exit(1)
else:
print_status("OK", "Subtitles downloaded")
wait_for_exit()
`;
}
// ═══════════════════════════════════════════════════════════════
// CASE 2: Combined stream sites (simpler approach)
// ═══════════════════════════════════════════════════════════════
if (isCombined) {
let formatStr = 'bestvideo+bestaudio/best';
if (hasVideo && !hasAudio) {
formatStr = 'bestvideo/best';
} else if (!hasVideo && hasAudio) {
formatStr = 'bestaudio/best';
}
return `
# ════════════════════════════════════════════════════════════════════════════════
# MAIN - Combined Stream Site Download
# ════════════════════════════════════════════════════════════════════════════════
def main():
# Change to script directory
os.chdir(Path(__file__).parent)
print_header("yt-dlp Media Downloader")
print(f"URL: {URL}")
print()
print("Configuration:")
print(f" Video: {VIDEO_LABEL} [{VIDEO_OUTPUT_LABEL}]")
print(f" Audio: {AUDIO_LABEL} [{AUDIO_OUTPUT_LABEL}]")
print(f" Subtitles: {SUBS_LABEL} [{SUBS_OUTPUT_LABEL}]")
print()
print("=" * 60)
# Check for yt-dlp
if not check_command("yt-dlp"):
print_status("ERROR", "yt-dlp not found")
print("Install: pip install yt-dlp")
wait_for_exit(error=True)
sys.exit(1)
print_status("OK", "yt-dlp found")
# Check for cookies
cookies_arg = []
if Path(COOKIE_FILE).exists():
print_status("OK", "Cookie file found")
cookies_arg = ["--cookies", COOKIE_FILE]
else:
print_status("INFO", "No cookie file")
# Handle subtitles
sub_args = []
do_subs = False
if HAS_SUBS:
print()
print_header("Available Subtitles")
subprocess.run(["yt-dlp", "--list-subs"] + cookies_arg + [URL])
print()
print("=" * 60)
print("Enter language code (e.g., en, de, ja)")
print("Or type 'all' for all languages")
print("Or press Enter to skip subtitles")
print("=" * 60)
print()
sub_lang = input("Language code: ").strip()
if sub_lang:
do_subs = True
sub_args = ["--write-subs", "--write-auto-subs", "--sub-langs", sub_lang]
if SUBS_CONVERT_ARG:
sub_args.extend(SUBS_CONVERT_ARG.split())
else:
print()
print("Skipping subtitles...")
print()
print_header("Starting Download...")
print("Downloading from combined-stream site...")
# Build command
cmd = ["yt-dlp"]
if OVERWRITE_FLAG:
cmd.append(OVERWRITE_FLAG)
cmd.extend(cookies_arg)
cmd.extend([URL, "-f", "${formatStr}"])
${hasVideo ? 'cmd.extend(["--merge-output-format", "mkv"])' : '# No video, skip merge format'}
if do_subs:
cmd.extend(sub_args)
cmd.append("--embed-subs")
cmd.extend(["-o", f"{FILENAME}.%(ext)s"])
# Run download
result = subprocess.run(cmd)
print()
if result.returncode != 0:
print_status("ERROR", "Download failed")
wait_for_exit(error=True)
sys.exit(1)
else:
print_status("OK", "Download complete")
print()
print(f"Saved to: {Path.cwd()}")
wait_for_exit()
`;
}
// Build format list for non-combined sites
const formats = [];
if (hasVideo) formats.push(videoFormat);
if (hasAudio) formats.push(audioFormat);
const formatStr = formats.join(',');
// ═══════════════════════════════════════════════════════════════
// CASE 3: No merging needed - direct download
// ═══════════════════════════════════════════════════════════════
if (!needsMerge) {
return `
# ════════════════════════════════════════════════════════════════════════════════
# MAIN - Direct Download (No Merge)
# ════════════════════════════════════════════════════════════════════════════════
def main():
# Change to script directory
os.chdir(Path(__file__).parent)
print_header("yt-dlp Media Downloader")
print(f"URL: {URL}")
print()
print("Configuration:")
print(f" Video: {VIDEO_LABEL} [{VIDEO_OUTPUT_LABEL}]")
print(f" Audio: {AUDIO_LABEL} [{AUDIO_OUTPUT_LABEL}]")
print(f" Subtitles: {SUBS_LABEL} [{SUBS_OUTPUT_LABEL}]")
print()
print("=" * 60)
# Check for yt-dlp
if not check_command("yt-dlp"):
print_status("ERROR", "yt-dlp not found")
print("Install: pip install yt-dlp")
wait_for_exit(error=True)
sys.exit(1)
print_status("OK", "yt-dlp found")
# Check for cookies
cookies_arg = []
if Path(COOKIE_FILE).exists():
print_status("OK", "Cookie file found")
cookies_arg = ["--cookies", COOKIE_FILE]
else:
print_status("INFO", "No cookie file")
# Handle subtitles
sub_args = []
do_subs = False
if HAS_SUBS:
print()
print_header("Available Subtitles")
subprocess.run(["yt-dlp", "--list-subs"] + cookies_arg + [URL])
print()
print("=" * 60)
print("Enter language code (e.g., en, de, ja)")
print("Or type 'all' for all languages")
print("Or press Enter to skip subtitles")
print("=" * 60)
print()
sub_lang = input("Language code: ").strip()
if sub_lang:
do_subs = True
sub_args = ["--write-subs", "--write-auto-subs", "--sub-langs", sub_lang]
if SUBS_CONVERT_ARG:
sub_args.extend(SUBS_CONVERT_ARG.split())
else:
print()
print("Skipping subtitles...")
print()
print_header("Starting Download...")
components = []
if HAS_VIDEO:
components.append("video")
if HAS_AUDIO:
components.append("audio")
if do_subs:
components.append("subtitles")
print(f"Downloading {' + '.join(components)}...")
# Build command
cmd = ["yt-dlp"]
if OVERWRITE_FLAG:
cmd.append(OVERWRITE_FLAG)
cmd.extend(cookies_arg)
cmd.extend([URL, "-f", "${formatStr}"])
# Add codec sorting if specified
if CODEC_ARG:
cmd.extend(CODEC_ARG.split())
# Output templates based on what we're downloading
${hasVideo && hasAudio ? `
# Both video and audio - use descriptive suffixes
cmd.extend(["-o", f"{FILENAME}.video.%(ext)s", "-o", f"audio:{FILENAME}.audio.%(ext)s"])
` : `
# Single component
cmd.extend(["-o", f"{FILENAME}.%(ext)s"])
`}
# Add subtitle args if enabled
if do_subs:
cmd.extend(sub_args)
cmd.extend(["-o", f"subtitle:{FILENAME}.%(ext)s"])
# Run download
result = subprocess.run(cmd)
print()
if result.returncode != 0:
print_status("ERROR", "Download failed")
wait_for_exit(error=True)
sys.exit(1)
else:
print_status("OK", "Download complete")
print()
print(f"Saved to: {Path.cwd()}")
wait_for_exit()
`;
}
// ═══════════════════════════════════════════════════════════════
// CASE 4: Merging required - complex multi-phase download
// ═══════════════════════════════════════════════════════════════
return `
# ════════════════════════════════════════════════════════════════════════════════
# MAIN - Multi-Phase Download with Merge
# ════════════════════════════════════════════════════════════════════════════════
def main():
# Change to script directory
os.chdir(Path(__file__).parent)
print_header("yt-dlp Media Downloader")
print(f"URL: {URL}")
print()
print("Configuration:")
print(f" Video: {VIDEO_LABEL} [{VIDEO_OUTPUT_LABEL}]")
print(f" Audio: {AUDIO_LABEL} [{AUDIO_OUTPUT_LABEL}]")
print(f" Subtitles: {SUBS_LABEL} [{SUBS_OUTPUT_LABEL}]")
print()
print("=" * 60)
# === Check tools ===
if not check_command("yt-dlp"):
print_status("ERROR", "yt-dlp not found")
print("Install: pip install yt-dlp")
wait_for_exit(error=True)
sys.exit(1)
print_status("OK", "yt-dlp found")
# Check for cookies
cookies_arg = []
if Path(COOKIE_FILE).exists():
print_status("OK", "Cookie file found")
cookies_arg = ["--cookies", COOKIE_FILE]
else:
print_status("INFO", "No cookie file")
# Check for mkvmerge
has_mkvmerge = check_command("mkvmerge")
if has_mkvmerge:
print_status("OK", "mkvmerge found")
else:
print_status("INFO", "mkvmerge not found - using FFmpeg")
# Check for ffprobe (for file identification)
has_ffprobe = check_command("ffprobe")
# Handle subtitles
sub_args = []
do_subs = False
if HAS_SUBS:
print()
print_header("Available Subtitles")
subprocess.run(["yt-dlp", "--list-subs"] + cookies_arg + [URL])
print()
print("=" * 60)
print("Enter language code (e.g., en, de, ja)")
print("Or type 'all' for all languages")
print("Or press Enter to skip subtitles")
print("=" * 60)
print()
sub_lang = input("Language code: ").strip()
if sub_lang:
do_subs = True
sub_args = ["--write-subs", "--write-auto-subs", "--sub-langs", sub_lang]
if SUBS_CONVERT_ARG:
sub_args.extend(SUBS_CONVERT_ARG.split())
else:
print()
print("Skipping subtitles...")
print()
print_header("Starting Download...")
# Generate unique temp prefix
temp_base = f"_ytdlp_tmp_{random.randint(10000, 99999)}"
dl_error = False
# ════════════════════════════════════════════════════════════════════════════
# PHASE 1: Download all components in ONE call (single metadata fetch)
# ════════════════════════════════════════════════════════════════════════════
print()
print("[PHASE 1] Downloading all components...")
cmd = ["yt-dlp"]
if OVERWRITE_FLAG:
cmd.append(OVERWRITE_FLAG)
cmd.extend(cookies_arg)
cmd.extend([URL, "-f", "${formatStr}"])
# Add codec sorting if specified
if CODEC_ARG:
cmd.extend(CODEC_ARG.split())
# Add subtitle args if enabled
if do_subs:
cmd.extend(sub_args)
cmd.extend(["-o", f"{temp_base}.%(format_id)s.%(ext)s", "-o", f"subtitle:{temp_base}_sub.%(ext)s"])
else:
cmd.extend(["-o", f"{temp_base}.%(format_id)s.%(ext)s"])
result = subprocess.run(cmd)
if result.returncode != 0:
dl_error = True
print()
print_status("ERROR", "Download failed")
# Cleanup any temp files
for f in glob(f"{temp_base}*.*"):
try:
os.remove(f)
except:
pass
wait_for_exit(error=True)
sys.exit(1)
# ════════════════════════════════════════════════════════════════════════════
# PHASE 2: Identify downloaded files
# ════════════════════════════════════════════════════════════════════════════
print()
print("[PHASE 2] Identifying downloaded files...")
video_file = None
audio_file = None
subtitle_files = []
for filepath in glob(f"{temp_base}*.*"):
file_type = identify_file(filepath, has_ffprobe)
basename = os.path.basename(filepath)
if file_type == "subtitle":
subtitle_files.append(filepath)
print(f" [SUB {len(subtitle_files)}] {basename}")
elif file_type == "video":
video_file = filepath
print(f" [VIDEO] {basename}")
elif file_type == "audio":
audio_file = filepath
print(f" [AUDIO] {basename}")
else:
# Unknown - try to categorize
if video_file is None:
video_file = filepath
print(f" [VIDEO] {basename} (assumed)")
else:
audio_file = filepath
print(f" [AUDIO] {basename} (assumed)")
# Verify we found expected files
${hasVideo ? `
if not video_file:
print_status("WARNING", "Expected video file not found")
` : ''}
${hasAudio ? `
if not audio_file:
print_status("WARNING", "Expected audio file not found")
` : ''}
# ════════════════════════════════════════════════════════════════════════════
# PHASE 3: Merge files (only components with Merge flag)
# ════════════════════════════════════════════════════════════════════════════
print()
print("[PHASE 3] Merging selected components...")
if has_mkvmerge:
mkv_inputs = []
${videoMerge && hasVideo ? `
if video_file:
mkv_inputs.append(video_file)
print(" Adding to merge: VIDEO")
` : '# Video not included in merge'}
${audioMerge && hasAudio ? `
if audio_file:
mkv_inputs.append(audio_file)
print(" Adding to merge: AUDIO")
` : '# Audio not included in merge'}
${subsMerge && hasSubs ? `
if do_subs:
for sub_file in subtitle_files:
mkv_inputs.append(sub_file)
print(f" Adding to merge: {os.path.basename(sub_file)}")
` : '# Subtitles not included in merge'}
if len(mkv_inputs) >= 2:
print()
print(" Running mkvmerge...")
merge_cmd = ["mkvmerge", "-o", f"{FILENAME}.{MERGED_EXT}"] + mkv_inputs
merge_result = subprocess.run(merge_cmd)
if merge_result.returncode != 0:
print_status("ERROR", "mkvmerge failed")
dl_error = True
else:
print_status("OK", f"Created: {FILENAME}.{MERGED_EXT}")
elif len(mkv_inputs) == 1:
print_status("WARNING", "Only 1 component for merge - copying instead")
shutil.copy(mkv_inputs[0], f"{FILENAME}.{MERGED_EXT}")
print_status("OK", f"Created: {FILENAME}.{MERGED_EXT}")
else:
print_status("WARNING", "No components to merge")
dl_error = True
else:
# FFmpeg fallback
print(" mkvmerge not found - using FFmpeg fallback...")
${(videoMerge && hasVideo && audioMerge && hasAudio) ? `
# Re-download with FFmpeg merge
ffmpeg_cmd = ["yt-dlp"]
if OVERWRITE_FLAG:
ffmpeg_cmd.append(OVERWRITE_FLAG)
ffmpeg_cmd.extend(cookies_arg)
ffmpeg_cmd.extend([
URL,
"-f", "${videoFormat}+${audioFormat}",
"--merge-output-format", "mkv"
])
if CODEC_ARG:
ffmpeg_cmd.extend(CODEC_ARG.split())
${subsMerge && hasSubs ? `
if do_subs:
ffmpeg_cmd.extend(sub_args)
ffmpeg_cmd.append("--embed-subs")
` : ''}
ffmpeg_cmd.extend(["-o", f"{FILENAME}.%(ext)s"])
ffmpeg_result = subprocess.run(ffmpeg_cmd)
if ffmpeg_result.returncode != 0:
print_status("ERROR", "FFmpeg merge failed")
dl_error = True
else:
print_status("OK", f"Created: {FILENAME}.mkv")
` : `
print_status("WARNING", "Complex merge not supported without mkvmerge")
print(" Please install mkvmerge: https://mkvtoolnix.download/")
dl_error = True
`}
# ════════════════════════════════════════════════════════════════════════════
# PHASE 4: Copy files that need to be kept separately
# ════════════════════════════════════════════════════════════════════════════
print()
print("[PHASE 4] Creating separate copies...")
${videoSeparate && hasVideo ? `
if video_file:
ext = Path(video_file).suffix
dest = f"{FILENAME}.video{ext}"
try:
shutil.copy(video_file, dest)
print_status("OK", f"Created: {dest}")
except Exception as e:
print_status("ERROR", f"Failed to copy video: {e}")
` : '# Video separate not requested'}
${audioSeparate && hasAudio ? `
if audio_file:
ext = Path(audio_file).suffix
dest = f"{FILENAME}.audio{ext}"
try:
shutil.copy(audio_file, dest)
print_status("OK", f"Created: {dest}")
except Exception as e:
print_status("ERROR", f"Failed to copy audio: {e}")
` : '# Audio separate not requested'}
${subsSeparate && hasSubs ? `
if do_subs:
for sub_file in subtitle_files:
# Extract lang.ext from temp_sub.lang.ext
basename = os.path.basename(sub_file)
# Remove the temp prefix to get lang.ext
if "_sub." in basename:
lang_ext = basename.split("_sub.", 1)[1]
dest = f"{FILENAME}.{lang_ext}"
try:
shutil.copy(sub_file, dest)
print_status("OK", f"Created: {dest}")
except Exception as e:
print_status("ERROR", f"Failed to copy subtitle: {e}")
` : '# Subtitle separate not requested'}
# ════════════════════════════════════════════════════════════════════════════
# PHASE 5: Cleanup temporary files
# ════════════════════════════════════════════════════════════════════════════
print()
print("[PHASE 5] Cleaning up temporary files...")
cleaned = 0
for f in glob(f"{temp_base}*.*"):
try:
os.remove(f)
cleaned += 1
except:
pass
print(f" Removed {cleaned} temporary file(s)")
# Final status
if dl_error:
print()
print("=" * 60)
print(" [WARNING] Some operations may have failed - check messages above")
print("=" * 60)
wait_for_exit(error=True)
else:
print()
print_status("SUCCESS", "Download complete!")
print()
print(f"Saved to: {Path.cwd()}")
wait_for_exit()
`;
}
//================================================================================
// NOTIFICATIONS
//================================================================================
function showNotification(message, type = 'info') {
if (!shadowRoot || !notificationElement) return;
try {
notificationElement.classList.remove('show');
notificationElement.textContent = message;
notificationElement.setAttribute('data-type', type);
void notificationElement.offsetWidth;
notificationElement.classList.add('show');
setTimeout(() => notificationElement.classList.remove('show'), 2500);
} catch (e) {
console.warn('[yt-dlp] Notification error:', e);
}
}
//================================================================================
// MAIN FUNCTIONS
//================================================================================
function executeDownload(mode) {
const info = getVideoInfo();
if (info.error) {
showNotification(info.error, 'error');
return;
}
if (mode === 'media') {
const validation = validateSettings();
if (!validation.valid) {
showNotification('Select at least one component', 'error');
return;
}
}
if (window._ytdlpUpdateTooltip) window._ytdlpUpdateTooltip();
const content = generatePythonScript(info.url, info.filename, info.cookieFile, mode);
const scriptFilename = `dl_${info.filename}.py`;
try {
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
const blobUrl = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), { href: blobUrl, download: scriptFilename });
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
showNotification('⬇ Downloading .py', 'info');
} catch (e) {
console.error("[yt-dlp] Download failed:", e);
showNotification('Download failed', 'error');
}
}
//================================================================================
// VISIBILITY & SPA LOGIC
//================================================================================
function shouldBeVisible() {
return !STATE.hidden && (!siteCondition || siteCondition(window.location.href));
}
function updateVisibility() {
if (containerElement) {
containerElement.classList.toggle('hidden', !shouldBeVisible());
}
}
function handleUrlChange() {
if (window.location.href !== STATE.lastUrl) {
STATE.hidden = false;
STATE.lastUrl = window.location.href;
updateVisibility();
}
}
function startSPAMonitoring() {
['pushState', 'replaceState'].forEach(method => {
const original = history[method];
history[method] = function(...args) {
const result = original.apply(this, args);
setTimeout(handleUrlChange, 0);
return result;
};
});
['popstate', 'hashchange'].forEach(event =>
window.addEventListener(event, () => setTimeout(handleUrlChange, 0))
);
STATE.checkInterval = setInterval(handleUrlChange, 500);
}
//================================================================================
// STYLES
//================================================================================
function getStyles() {
const { size, iconStyle, position: pos, opacity, scale, zIndex } = CONFIG_UI.button;
const iconSize = size - 4;
return `
:host { all: initial; }
* { box-sizing: border-box; }
.ytdlp-container {
position: fixed;
${pos.vertical}: ${pos.offsetY}px;
${pos.horizontal}: ${pos.offsetX}px;
z-index: ${zIndex};
pointer-events: auto;
user-select: none;
}
.ytdlp-container.hidden { display: none !important; }
.ytdlp-btn {
position: relative;
width: ${size}px;
height: ${size}px;
background: transparent;
border: none;
cursor: pointer;
opacity: ${opacity.default};
display: flex;
align-items: center;
justify-content: center;
padding: 0;
margin: 0;
transform: scale(${scale.default});
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.ytdlp-btn[data-hover="true"] { opacity: ${opacity.hover}; transform: scale(${scale.hover}); }
.ytdlp-icon-container {
display: flex;
align-items: center;
justify-content: center;
width: ${size}px;
height: ${size}px;
border-radius: ${iconStyle.background.enabled ? iconStyle.background.borderRadius : '0'};
background-color: ${iconStyle.background.enabled ? iconStyle.background.color : 'transparent'};
pointer-events: none;
}
.ytdlp-icon {
width: ${iconSize}px;
height: ${iconSize}px;
display: block;
pointer-events: none;
${iconStyle.shadow.enabled ? `filter: drop-shadow(0 0 ${iconStyle.shadow.blur}px ${iconStyle.shadow.color}) drop-shadow(0 0 ${iconStyle.shadow.blur * 0.5}px ${iconStyle.shadow.color});` : ''}
}
.ytdlp-notification {
position: fixed;
${pos.vertical}: ${pos.offsetY + size + 10}px;
${pos.horizontal}: ${pos.offsetX}px;
padding: 8px 16px;
color: white;
border-radius: 6px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 13px;
font-weight: 500;
z-index: ${zIndex - 1};
box-shadow: 0 3px 12px rgba(0,0,0,0.25);
opacity: 0;
transform: translateY(10px);
transition: opacity 0.3s, transform 0.3s;
pointer-events: none;
}
.ytdlp-notification.show { opacity: 1; transform: translateY(0); }
.ytdlp-notification[data-type="success"] { background: #4CAF50; border: 1px solid #388E3C; }
.ytdlp-notification[data-type="error"] { background: #f44336; border: 1px solid #c62828; }
.ytdlp-notification[data-type="info"] { background: #2196F3; border: 1px solid #1565C0; }
.ytdlp-notification[data-type="warning"] { background: #FF9800; border: 1px solid #EF6C00; }
.ytdlp-btn::after {
content: attr(data-tooltip);
position: absolute;
${pos.horizontal === 'right' ? 'right' : 'left'}: ${size + 8}px;
${pos.vertical === 'bottom' ? 'bottom' : 'top'}: 0;
background: rgba(30, 30, 30, 0.95);
color: #fff;
padding: 8px 12px;
border-radius: 6px;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 12px;
line-height: 1.5;
white-space: pre;
pointer-events: none;
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, visibility 0.15s ease;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
z-index: ${zIndex + 1};
}
.ytdlp-btn[data-hover="true"]::after { opacity: 1; visibility: visible; }
`;
}
//================================================================================
// FLOATING BUTTON
//================================================================================
function addFloatingDownloadButton() {
if (!document.body) return;
try {
const shadowHost = document.createElement('div');
shadowHost.id = 'ytdlp-downloader-host';
Object.assign(shadowHost.style, {
position: 'fixed', top: '0', left: '0', width: '0', height: '0',
overflow: 'visible', zIndex: CONFIG_UI.button.zIndex.toString(),
pointerEvents: 'none', userSelect: 'none'
});
shadowRoot = shadowHost.attachShadow({ mode: 'closed' });
shadowRoot.appendChild(Object.assign(document.createElement('style'), { textContent: getStyles() }));
containerElement = Object.assign(document.createElement('div'), { className: 'ytdlp-container' });
const btn = Object.assign(document.createElement('div'), { className: 'ytdlp-btn' });
const updateTooltip = () => {
const videoOut = getVideoOutput();
const audioOut = getAudioOutput();
const subsOut = getSubsOutput();
const parts = [];
if (videoOut !== 'none') {
parts.push(`Video: ${getLabel(VIDEO_QUALITIES, getVideoQuality())} [${getLabel(OUTPUT_MODES, videoOut)}]`);
}
if (audioOut !== 'none') {
parts.push(`Audio: ${getLabel(AUDIO_QUALITIES, getAudioQuality())} [${getLabel(OUTPUT_MODES, audioOut)}]`);
}
if (subsOut !== 'none') {
parts.push(`Subs: ${getLabel(SUBTITLE_FORMATS, getSubsFormat())} [${getLabel(OUTPUT_MODES, subsOut)}]`);
}
if (parts.length === 0) {
parts.push('(Nothing selected)');
}
btn.setAttribute('data-tooltip', [
'𝘆𝘁-𝗱𝗹𝗽 𝗗𝗼𝘄𝗻𝗹𝗼𝗮𝗱𝗲𝗿',
'',
...parts,
'',
'Click: Download',
'Right-click: Options',
'Double-click: Hide 5s'
].join('\n'));
};
updateTooltip();
window._ytdlpUpdateTooltip = updateTooltip;
const iconContainer = Object.assign(document.createElement('div'), { className: 'ytdlp-icon-container' });
const iconSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
['class', 'ytdlp-icon', 'viewBox', '0 0 24 24', 'fill', 'none', 'stroke', '#555', 'stroke-width', '2', 'stroke-linecap', 'round', 'stroke-linejoin', 'round']
.reduce((acc, val, i, arr) => (i % 2 === 0 && iconSvg.setAttribute(val, arr[i + 1]), acc), null);
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
[['x', '2'], ['y', '6'], ['width', '14'], ['height', '12'], ['rx', '2'], ['ry', '2']].forEach(([k, v]) => rect.setAttribute(k, v));
const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon');
polygon.setAttribute('points', '22 8 16 12 22 16 22 8');
iconSvg.append(rect, polygon);
iconContainer.appendChild(iconSvg);
btn.appendChild(iconContainer);
containerElement.appendChild(btn);
ContextMenu.init();
shadowRoot.appendChild(containerElement);
notificationElement = Object.assign(document.createElement('div'), { className: 'ytdlp-notification' });
notificationElement.setAttribute('data-type', 'info');
shadowRoot.appendChild(notificationElement);
document.body.appendChild(shadowHost);
//================================================================================
// INTERACTION HANDLERS
//================================================================================
let lastClickTime = 0, hideTimeout = null;
let hoverCheckInterval = null, lastMouseX = 0, lastMouseY = 0, isHovering = false;
const setAttr = (attr, val) => btn.setAttribute(`data-${attr}`, val ? 'true' : 'false');
const setHover = (val) => { if (isHovering !== val) { isHovering = val; setAttr('hover', val); } };
const isInsideButton = (x, y) => {
try {
const r = btn.getBoundingClientRect();
return x >= r.left - 2 && x <= r.right + 2 && y >= r.top - 2 && y <= r.bottom + 2;
} catch (e) {
return false;
}
};
const onGlobalMouseMove = (e) => { lastMouseX = e.clientX; lastMouseY = e.clientY; };
const startHoverTracking = () => {
if (hoverCheckInterval) return;
document.addEventListener('mousemove', onGlobalMouseMove, { passive: true });
hoverCheckInterval = setInterval(() => {
if (!isInsideButton(lastMouseX, lastMouseY)) { setHover(false); stopHoverTracking(); }
}, CONFIG_UI.timing.hoverCheckInterval);
};
const stopHoverTracking = () => {
if (hoverCheckInterval) { clearInterval(hoverCheckInterval); hoverCheckInterval = null; }
document.removeEventListener('mousemove', onGlobalMouseMove);
};
btn.addEventListener('mouseenter', (e) => { lastMouseX = e.clientX; lastMouseY = e.clientY; setHover(true); startHoverTracking(); });
btn.addEventListener('mouseleave', () => {});
btn.addEventListener('click', (e) => {
e.preventDefault();
const now = Date.now();
const timeSinceLastClick = now - lastClickTime;
lastClickTime = now;
if (ContextMenu.isVisible()) {
ContextMenu.hide();
return;
}
// Double-click to hide
if (timeSinceLastClick < CONFIG_UI.timing.doubleClickThreshold) {
window.getSelection?.().removeAllRanges();
STATE.hidden = true;
stopHoverTracking();
setHover(false);
updateVisibility();
clearTimeout(hideTimeout);
hideTimeout = setTimeout(() => { STATE.hidden = false; updateVisibility(); }, CONFIG_UI.timing.hideTemporarilyDuration);
return;
}
// Single click - download with current settings
executeDownload('media');
});
btn.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
ContextMenu.show(e.clientX, e.clientY, (mode) => executeDownload(mode));
});
window.addEventListener('beforeunload', stopHoverTracking);
updateVisibility();
startSPAMonitoring();
console.log('[yt-dlp Downloader v9.2] Initialized - Python edition');
} catch (e) {
console.error('[yt-dlp] Failed to initialize:', e);
}
}
//================================================================================
// INIT
//================================================================================
if (document.body) addFloatingDownloadButton();
else document.addEventListener('DOMContentLoaded', addFloatingDownloadButton);
})();