Greasy Fork is available in English.
Unified yt-dlp downloader - configure video, audio, subtitles with flexible output modes (None/Merge/Separate/Both)
当前为
// ==UserScript==
// @name yt-dlp BAT-Downloader
// @namespace http://greasyfork.icu/en/users/1462137-piknockyou
// @version 8.4
// @author Piknockyou (vibe-coded)
// @license AGPL-3.0
// @description Unified yt-dlp downloader - configure video, audio, subtitles with flexible output modes (None/Merge/Separate/Both)
//
// ═══════════════════════════════════════════════════════════════
// CHANGELOG
// ═══════════════════════════════════════════════════════════════
// 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) {
background: #444;
color: #ccc;
}
.ytdlp-output-btn.selected {
background: #4a6da7;
color: #fff;
border-color: #5a7db7;
}
.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;
}
.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; }
};
//================================================================================
// BATCH FILE 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 ' : '';
function generateBatch(url, filename, cookieFile, mode) {
const overwriteFlag = getOverwriteFlag();
// ═══════════════════════════════════════════════════════════════
// COMMENTS MODE
// ═══════════════════════════════════════════════════════════════
if (mode === 'comments') {
return `@echo off
setlocal EnableDelayedExpansion
cd /d "%~dp0"
title Downloading Comments: ${filename}
echo ============================================
echo yt-dlp Comments Downloader
echo ============================================
echo.
where yt-dlp >nul 2>nul
if %errorlevel% neq 0 (
echo [ERROR] yt-dlp not found
echo Install: winget install yt-dlp
pause
exit /b 1
)
if exist "${cookieFile}" (
set "COOKIES=--cookies ${cookieFile}"
) else (
set "COOKIES="
)
echo URL: ${url}
echo.
yt-dlp ${overwriteFlag}!COOKIES! "${url}" --skip-download --write-comments --no-playlist --extractor-args "youtube:comment_sort=top;max_comments=all,all,all,all" -o "${filename}.%%(ext)s"
echo.
if !errorlevel! neq 0 (
echo [ERROR] Download failed
) else (
echo [SUCCESS] Comments downloaded!
)
pause
`;
}
// ═══════════════════════════════════════════════════════════════
// 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
// (If only 1 has merge, cleanupLoneMerge should have removed it)
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);
return `@echo off
setlocal EnableDelayedExpansion
cd /d "%~dp0"
title Downloading: ${filename}
echo ============================================
echo yt-dlp Media Downloader
echo ============================================
echo.
echo URL: ${url}
echo.
echo Configuration:
echo Video: ${videoLabel} [${getOutputLabel(videoOutput)}]
echo Audio: ${audioLabel} [${getOutputLabel(audioOutput)}]
echo Subtitles: ${subsLabel} [${getOutputLabel(subsOutput)}]
echo.
echo ============================================
REM === Check tools ===
where yt-dlp >nul 2>nul
if %errorlevel% neq 0 (
echo [ERROR] yt-dlp not found
echo Install: winget install yt-dlp
set /p "=Press Enter to exit..." <nul & pause >nul
exit /b 1
)
echo [OK] yt-dlp found
if exist "${cookieFile}" (
echo [OK] Cookie file found
set "COOKIES=--cookies ${cookieFile}"
) else (
echo [INFO] No cookie file
set "COOKIES="
)
${needsMerge ? `
set "HAS_MKVMERGE=0"
where mkvmerge >nul 2>nul
if %errorlevel% equ 0 (
set "HAS_MKVMERGE=1"
echo [OK] mkvmerge found
) else (
echo [INFO] mkvmerge not found - using FFmpeg
)
` : ''}
${hasSubs ? `
echo.
echo ============================================
echo Available Subtitles
echo ============================================
echo.
yt-dlp --list-subs !COOKIES! "${url}"
echo.
echo ============================================
echo Enter language code (e.g., en, de, ja)
echo Or type 'all' for all languages
echo Or press Enter to skip subtitles
echo ============================================
echo.
set "SUB_LANG="
set /p "SUB_LANG=Language code: "
if "!SUB_LANG!"=="" (
echo.
echo Skipping subtitles...
set "DO_SUBS=0"
) else (
set "DO_SUBS=1"
set "SUB_ARGS=--write-subs --write-auto-subs --sub-langs !SUB_LANG!${subsConvertArg ? ` ${subsConvertArg}` : ''}"
)
` : 'set "DO_SUBS=0"'}
echo.
echo ============================================
echo Starting Download...
echo ============================================
echo.
${generateDownloadCommands(url, filename, {
hasVideo, hasAudio, hasSubs,
videoMerge, videoSeparate, audioMerge, audioSeparate, subsMerge, subsSeparate,
needsMerge,
videoFormat, audioFormat, codecArg,
overwriteFlag
})}
echo.
if !errorlevel! neq 0 (
echo ============================================
echo [ERROR] Download failed
echo ============================================
echo.
echo Press Enter to exit...
set /p "="
) else (
echo ============================================
echo [SUCCESS] Download complete!
echo ============================================
echo.
echo Saved to: %~dp0
echo.
echo Press Enter to exit...
set /p "="
)
`;
}
function generateDownloadCommands(url, filename, opts) {
const {
hasVideo, hasAudio, hasSubs,
videoMerge, videoSeparate, audioMerge, audioSeparate, subsMerge, subsSeparate,
needsMerge,
videoFormat, audioFormat, codecArg,
overwriteFlag
} = opts;
// Check if this is YouTube - affects format string strategy
const isYouTube = isYouTubeDomain();
// Determine merged output extension
const mergedExt = (hasVideo && videoMerge) ? 'mkv' : 'mka';
// ═══════════════════════════════════════════════════════════════
// CASE 1: Subtitles ONLY (no video, no audio)
// ═══════════════════════════════════════════════════════════════
if (!hasVideo && !hasAudio && hasSubs) {
return `
if "!DO_SUBS!"=="1" (
echo Downloading subtitles only...
yt-dlp ${overwriteFlag}!COOKIES! "${url}" --skip-download !SUB_ARGS! -o "${filename}.%%(ext)s"
if !errorlevel! neq 0 (
echo [ERROR] Subtitle download failed
) else (
echo [OK] Subtitles downloaded
)
) else (
echo No subtitles requested. Nothing to download.
)`;
}
// ═══════════════════════════════════════════════════════════════
// COMBINED STREAM SITES: Use simpler approach with /best fallback
// These sites don't have separate video/audio streams
// ═══════════════════════════════════════════════════════════════
if (isCombinedStreamSite()) {
// Build format string with fallback for combined streams
let formatStr = 'bestvideo+bestaudio/best';
if (hasVideo && !hasAudio) {
formatStr = 'bestvideo/best';
} else if (!hasVideo && hasAudio) {
formatStr = 'bestaudio/best';
}
const formatArg = `-f "${formatStr}"`;
const mergeArg = hasVideo ? '--merge-output-format mkv' : '';
if (hasSubs) {
return `echo Downloading from combined-stream site...
if "!DO_SUBS!"=="1" (
yt-dlp ${overwriteFlag}!COOKIES! "${url}" ${formatArg} ${mergeArg} !SUB_ARGS! --embed-subs -o "${filename}.%%(ext)s"
) else (
yt-dlp ${overwriteFlag}!COOKIES! "${url}" ${formatArg} ${mergeArg} -o "${filename}.%%(ext)s"
)
if !errorlevel! neq 0 (
echo [ERROR] Download failed
) else (
echo [OK] Download complete
)`;
} else {
return `echo Downloading from combined-stream site...
yt-dlp ${overwriteFlag}!COOKIES! "${url}" ${formatArg} ${mergeArg} -o "${filename}.%%(ext)s"
if !errorlevel! neq 0 (
echo [ERROR] Download failed
) else (
echo [OK] Download complete
)`;
}
}
// ═══════════════════════════════════════════════════════════════
// YOUTUBE: Build format string for separate streams
// ═══════════════════════════════════════════════════════════════
const formats = [];
if (hasVideo) formats.push(videoFormat);
if (hasAudio) formats.push(audioFormat);
const formatArg = formats.length > 0 ? `-f "${formats.join(',')}"` : '';
// ═══════════════════════════════════════════════════════════════
// CASE 2: No merging needed - direct download with clean names
// ═══════════════════════════════════════════════════════════════
if (!needsMerge) {
// All components are either None or Separate - no merge step needed
// Download directly to final filenames
let downloadCmd = '';
if (hasVideo && hasAudio) {
// Both video and audio - use descriptive suffixes
downloadCmd = `yt-dlp ${overwriteFlag}!COOKIES! "${url}" ${formatArg}${codecArg ? ` ${codecArg}` : ''} -o "${filename}.video.%%(ext)s" -o "audio:${filename}.audio.%%(ext)s"`;
} else if (hasVideo) {
downloadCmd = `yt-dlp ${overwriteFlag}!COOKIES! "${url}" ${formatArg}${codecArg ? ` ${codecArg}` : ''} -o "${filename}.%%(ext)s"`;
} else if (hasAudio) {
downloadCmd = `yt-dlp ${overwriteFlag}!COOKIES! "${url}" ${formatArg}${codecArg ? ` ${codecArg}` : ''} -o "${filename}.%%(ext)s"`;
}
if (hasSubs) {
// Add subtitle args if subs enabled
return `echo Downloading${hasVideo ? ' video' : ''}${hasAudio ? (hasVideo ? ' + audio' : ' audio') : ''}${hasSubs ? ' + subtitles' : ''}...
if "!DO_SUBS!"=="1" (
${downloadCmd} !SUB_ARGS! -o "subtitle:${filename}.%%(ext)s"
) else (
${downloadCmd}
)
if !errorlevel! neq 0 (
echo [ERROR] Download failed
) else (
echo [OK] Download complete
)`;
} else {
return `echo Downloading${hasVideo ? ' video' : ''}${hasAudio ? (hasVideo ? ' + audio' : ' audio') : ''}...
${downloadCmd}
if !errorlevel! neq 0 (
echo [ERROR] Download failed
) else (
echo [OK] Download complete
)`;
}
}
// ═══════════════════════════════════════════════════════════════
// CASE 3: YouTube merging required - complex post-processing
// Download to temp files, identify, merge selectively, copy separates, cleanup
// ═══════════════════════════════════════════════════════════════
return `
set "TEMP_BASE=_ytdlp_tmp_%RANDOM%%RANDOM%"
set "DL_ERROR=0"
REM ════════════════════════════════════════════════════════════════════════════
REM PHASE 1: Download all components in ONE call (single metadata fetch)
REM ════════════════════════════════════════════════════════════════════════════
echo.
echo [PHASE 1] Downloading all components...
${hasSubs ? `if "!DO_SUBS!"=="1" (
yt-dlp ${overwriteFlag}!COOKIES! "${url}" ${formatArg}${codecArg ? ` ${codecArg}` : ''} !SUB_ARGS! -o "!TEMP_BASE!.%%(format_id)s.%%(ext)s" -o "subtitle:!TEMP_BASE!_sub.%%(ext)s"
if !errorlevel! neq 0 set "DL_ERROR=1"
) else (
yt-dlp ${overwriteFlag}!COOKIES! "${url}" ${formatArg}${codecArg ? ` ${codecArg}` : ''} -o "!TEMP_BASE!.%%(format_id)s.%%(ext)s"
if !errorlevel! neq 0 set "DL_ERROR=1"
)` : `yt-dlp ${overwriteFlag}!COOKIES! "${url}" ${formatArg}${codecArg ? ` ${codecArg}` : ''} -o "!TEMP_BASE!.%%(format_id)s.%%(ext)s"
if !errorlevel! neq 0 set "DL_ERROR=1"`}
if "!DL_ERROR!"=="1" (
echo.
echo [ERROR] Download failed
del /Q "!TEMP_BASE!*.*" 2>nul
goto :script_end
)
REM ════════════════════════════════════════════════════════════════════════════
REM PHASE 2: Identify downloaded files using subroutine
REM ════════════════════════════════════════════════════════════════════════════
echo.
echo [PHASE 2] Identifying downloaded files...
set "VIDEO_FILE="
set "AUDIO_FILE="
set "SUB_COUNT=0"
REM Check for ffprobe availability (for ambiguous formats)
set "HAS_FFPROBE=0"
where ffprobe >nul 2>nul && set "HAS_FFPROBE=1"
REM Process each downloaded file
for %%f in ("!TEMP_BASE!*.*") do call :identify_file "%%f"
goto :after_identify
:identify_file
set "fpath=%~1"
set "fname=%~n1"
set "fext=%~x1"
REM Check if this is a subtitle file (filename contains _sub)
echo !fname! | findstr /i "_sub" >nul
if !errorlevel! equ 0 (
set /a "SUB_COUNT+=1"
echo [SUB !SUB_COUNT!] %~nx1
goto :eof
)
REM === Determine if VIDEO or AUDIO based on extension ===
REM Audio-only extensions
if /i "!fext!"==".m4a" (
set "AUDIO_FILE=!fpath!"
echo [AUDIO] %~nx1
goto :eof
)
if /i "!fext!"==".mp3" (
set "AUDIO_FILE=!fpath!"
echo [AUDIO] %~nx1
goto :eof
)
if /i "!fext!"==".opus" (
set "AUDIO_FILE=!fpath!"
echo [AUDIO] %~nx1
goto :eof
)
if /i "!fext!"==".ogg" (
set "AUDIO_FILE=!fpath!"
echo [AUDIO] %~nx1
goto :eof
)
if /i "!fext!"==".aac" (
set "AUDIO_FILE=!fpath!"
echo [AUDIO] %~nx1
goto :eof
)
if /i "!fext!"==".flac" (
set "AUDIO_FILE=!fpath!"
echo [AUDIO] %~nx1
goto :eof
)
if /i "!fext!"==".wav" (
set "AUDIO_FILE=!fpath!"
echo [AUDIO] %~nx1
goto :eof
)
REM Video-only extensions
if /i "!fext!"==".mp4" (
set "VIDEO_FILE=!fpath!"
echo [VIDEO] %~nx1
goto :eof
)
if /i "!fext!"==".mkv" (
set "VIDEO_FILE=!fpath!"
echo [VIDEO] %~nx1
goto :eof
)
if /i "!fext!"==".avi" (
set "VIDEO_FILE=!fpath!"
echo [VIDEO] %~nx1
goto :eof
)
if /i "!fext!"==".mov" (
set "VIDEO_FILE=!fpath!"
echo [VIDEO] %~nx1
goto :eof
)
if /i "!fext!"==".flv" (
set "VIDEO_FILE=!fpath!"
echo [VIDEO] %~nx1
goto :eof
)
if /i "!fext!"==".ts" (
set "VIDEO_FILE=!fpath!"
echo [VIDEO] %~nx1
goto :eof
)
if /i "!fext!"==".3gp" (
set "VIDEO_FILE=!fpath!"
echo [VIDEO] %~nx1
goto :eof
)
REM === Ambiguous extension (.webm can be video or audio) ===
REM Try ffprobe first if available
if "!HAS_FFPROBE!"=="1" (
ffprobe -v error -select_streams v:0 -show_entries stream=codec_type -of csv=p=0 "!fpath!" 2>nul | findstr /i "video" >nul
if !errorlevel! equ 0 (
set "VIDEO_FILE=!fpath!"
echo [VIDEO] %~nx1 ^(detected via ffprobe^)
) else (
set "AUDIO_FILE=!fpath!"
echo [AUDIO] %~nx1 ^(detected via ffprobe^)
)
goto :eof
)
REM Fallback: if VIDEO_FILE not set, assume this is video
if not defined VIDEO_FILE (
set "VIDEO_FILE=!fpath!"
echo [VIDEO] %~nx1 ^(assumed^)
) else (
set "AUDIO_FILE=!fpath!"
echo [AUDIO] %~nx1 ^(assumed^)
)
goto :eof
:after_identify
REM Verify we found expected files
${hasVideo ? `if not defined VIDEO_FILE (
echo [WARNING] Expected video file not found
)` : ''}
${hasAudio ? `if not defined AUDIO_FILE (
echo [WARNING] Expected audio file not found
)` : ''}
REM ════════════════════════════════════════════════════════════════════════════
REM PHASE 3: Merge files (only components with Merge flag)
REM ════════════════════════════════════════════════════════════════════════════
echo.
echo [PHASE 3] Merging selected components...
if "!HAS_MKVMERGE!"=="1" (
set "MKV_INPUTS="
set "MKV_COUNT=0"
${videoMerge && hasVideo ? `
if defined VIDEO_FILE (
set "MKV_INPUTS=!MKV_INPUTS! "!VIDEO_FILE!""
set /a "MKV_COUNT+=1"
echo Adding to merge: VIDEO
)` : `REM Video not included in merge`}
${audioMerge && hasAudio ? `
if defined AUDIO_FILE (
set "MKV_INPUTS=!MKV_INPUTS! "!AUDIO_FILE!""
set /a "MKV_COUNT+=1"
echo Adding to merge: AUDIO
)` : `REM Audio not included in merge`}
${subsMerge && hasSubs ? `
if "!DO_SUBS!"=="1" (
for %%s in ("!TEMP_BASE!_sub.*") do (
if exist "%%s" (
set "MKV_INPUTS=!MKV_INPUTS! "%%s""
set /a "MKV_COUNT+=1"
echo Adding to merge: %%~nxs
)
)
)` : `REM Subtitles not included in merge`}
if !MKV_COUNT! geq 2 (
echo.
echo Running mkvmerge...
mkvmerge -o "${filename}.${mergedExt}" !MKV_INPUTS!
if !errorlevel! neq 0 (
echo [ERROR] mkvmerge failed
set "DL_ERROR=1"
) else (
echo [OK] Created: ${filename}.${mergedExt}
)
) else if !MKV_COUNT! equ 1 (
echo [WARNING] Only 1 component for merge - copying instead
for %%f in (!MKV_INPUTS!) do copy "%%~f" "${filename}.${mergedExt}" >nul
echo [OK] Created: ${filename}.${mergedExt}
) else (
echo [WARNING] No components to merge
set "DL_ERROR=1"
)
) else (
echo mkvmerge not found - using FFmpeg fallback...
${(videoMerge && hasVideo && audioMerge && hasAudio) ? `
REM Re-download with FFmpeg merge (still single metadata fetch from cache)
yt-dlp ${overwriteFlag}!COOKIES! "${url}" -f "${videoFormat}+${audioFormat}"${codecArg ? ` ${codecArg}` : ''} --merge-output-format mkv${subsMerge && hasSubs ? ` --embed-subs !SUB_ARGS!` : ''} -o "${filename}.%%(ext)s"
if !errorlevel! neq 0 (
echo [ERROR] FFmpeg merge failed
set "DL_ERROR=1"
) else (
echo [OK] Created: ${filename}.mkv
)` : `
REM FFmpeg fallback for non-standard merge combinations
echo [WARNING] Complex merge not supported without mkvmerge
echo Please install mkvmerge: https://mkvtoolnix.download/
set "DL_ERROR=1"`}
)
REM ════════════════════════════════════════════════════════════════════════════
REM PHASE 4: Copy files that need to be kept separately
REM ════════════════════════════════════════════════════════════════════════════
echo.
echo [PHASE 4] Creating separate copies...
${videoSeparate && hasVideo ? `
if defined VIDEO_FILE (
for %%f in ("!VIDEO_FILE!") do (
set "vext=%%~xf"
copy "!VIDEO_FILE!" "${filename}.video!vext!" >nul
if !errorlevel! equ 0 (
echo [OK] Created: ${filename}.video!vext!
) else (
echo [ERROR] Failed to copy video
)
)
)` : `REM Video separate not requested`}
${audioSeparate && hasAudio ? `
if defined AUDIO_FILE (
for %%f in ("!AUDIO_FILE!") do (
set "aext=%%~xf"
copy "!AUDIO_FILE!" "${filename}.audio!aext!" >nul
if !errorlevel! equ 0 (
echo [OK] Created: ${filename}.audio!aext!
) else (
echo [ERROR] Failed to copy audio
)
)
)` : `REM Audio separate not requested`}
${subsSeparate && hasSubs ? `
if "!DO_SUBS!"=="1" (
for %%f in ("!TEMP_BASE!_sub.*") do (
if exist "%%f" (
REM Extract language.ext from TEMP_sub.lang.ext
set "subname=%%~nxf"
REM Remove the TEMP_sub. prefix to get lang.ext
call set "langext=%%subname:*_sub.=%%"
copy "%%f" "${filename}.!langext!" >nul
if !errorlevel! equ 0 (
echo [OK] Created: ${filename}.!langext!
) else (
echo [ERROR] Failed to copy subtitle
)
)
)
)` : `REM Subtitle separate not requested`}
REM ════════════════════════════════════════════════════════════════════════════
REM PHASE 5: Cleanup temporary files
REM ════════════════════════════════════════════════════════════════════════════
echo.
echo [PHASE 5] Cleaning up temporary files...
set "CLEANED=0"
for %%f in ("!TEMP_BASE!*.*") do (
del /Q "%%f" 2>nul
set /a "CLEANED+=1"
)
echo Removed !CLEANED! temporary file(s)
:script_end
if "!DL_ERROR!"=="1" (
echo.
echo ════════════════════════════════════════════════════════════════════════════
echo [WARNING] Some operations may have failed - check messages above
echo ════════════════════════════════════════════════════════════════════════════
)`;
}
//================================================================================
// 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 = generateBatch(info.url, info.filename, info.cookieFile, mode);
const batchFilename = `dl_${info.filename}.bat`;
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: batchFilename });
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(blobUrl);
showNotification('⬇ Downloading .bat', '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 v7.8] Initialized');
} catch (e) {
console.error('[yt-dlp] Failed to initialize:', e);
}
}
//================================================================================
// INIT
//================================================================================
if (document.body) addFloatingDownloadButton();
else document.addEventListener('DOMContentLoaded', addFloatingDownloadButton);
})();