// ==UserScript== // @name OPTDOWNS // @namespace video-direct-downloader // @version 2.13.0 // @description OPTDOWNS: vídeo (HD), fotos, áudio e MP3 de TikTok/Instagram, vídeos e Shorts do YouTube (via seu Worker + Cobalt), vídeo e áudio HLS locais, seleção de faixas, páginas com vários vídeos, botão e painel móveis (arraste) e painel para celular. // @description:en OPTDOWNS: video (HD), photos, audio and MP3 from TikTok/Instagram, YouTube videos and Shorts (via your own Worker and Cobalt), local HLS video and audio, track selection, multi-video pages and a draggable mobile-first panel. // @author jmita2288-debug // @license MIT // @homepageURL https://github.com/jmita2288-debug/optdowns // @supportURL https://github.com/jmita2288-debug/optdowns/issues // @downloadURL https://raw.githubusercontent.com/jmita2288-debug/optdowns/main/optdowns.user.js // @updateURL https://raw.githubusercontent.com/jmita2288-debug/optdowns/main/optdowns.user.js // @match http://*/* // @match https://*/* // @run-at document-start // @grant GM_download // @grant GM.download // @grant GM_xmlhttpRequest // @grant GM.xmlHttpRequest // @grant GM_openInTab // @grant GM_getValue // @grant GM_setValue // @grant GM.getValue // @grant GM.setValue // @grant unsafeWindow // @connect * // @require https://unpkg.com/mux.js@6.3.0/dist/mux.js // @require https://unpkg.com/lamejs@1.2.1/lame.min.js // ==/UserScript== (() => { 'use strict'; // --------------------------------------------------------------------------- // State // Shared runtime state: detected media, HLS caches and controllers for the link and download flows. // --------------------------------------------------------------------------- const APP = { hlsUrls: new Set(), hlsMasters: new Map(), hlsResources: new Set(), linkController: null, socialScanAt: 0, socialScanPage: "", dashUrls: new Set(), directUrls: new Set(), mediaInfo: new Map(), mediaIdentityUrls: new Map(), videoIds: new WeakMap(), nextVideoId: 1, nextMediaOrder: 1, activeTab: 'page', linkResults: [], linkBusy: false, panelOpen: false, working: false, observer: null, scanTimer: null, renderTimer: null, toastTimer: null, networkHooksInstalled: false, requestReferrer: '', recentNetworkMedia: [], recentBinaryCandidates: [], recentNetworkRequests: [], siteProbeKeys: new Set(), hlsJob: null, hlsController: null, }; Object.assign(APP, { navGen: 0, navAt: 0, perfSeen: 0, dropped: new Map(), activeVideoKey: '', activeAt: 0, expandedGroups: new Set(), directController: null, eventScanTimer: null }); // --------------------------------------------------------------------------- // Configuration // Optional Worker settings. See worker/README.md. With LINK_PROXY empty, everything stays in the browser. // --------------------------------------------------------------------------- // Address of your own Cloudflare Worker. Empty disables it. const LINK_PROXY = ''; // Same value as the Worker's SECRET, only if you set one. const LINK_PROXY_KEY = ''; // Let non-TikTok sites use the Worker as a last resort. false keeps other sites off the Worker. const LINK_PROXY_GENERIC = true; // YouTube videos and Shorts through your Worker and your own Cobalt instance. false disables YouTube. const LINK_PROXY_YOUTUBE = true; // true asks Cobalt for each option's size while searching (more requests to Cobalt). const LINK_PROXY_YOUTUBE_SIZES = false; const SCRIPT_VERSION = '2.13.0'; // Network timeouts, retry count and the Range chunk size. const NET = Object.freeze({ pageIdleTimeout: 90000, requestTimeout: 90000, fullRequestTimeout: 10 * 60 * 1000, retries: 3, rangeChunkSize: 4 * 1024 * 1024, maxUnknownRangeChunks: 4096, saveTimeout: 3 * 60 * 1000, manualUrlLifetime: 10 * 60 * 1000, linkRequestTimeout: 45000, maxLinkHtmlSize: 12 * 1024 * 1024, }); // --------------------------------------------------------------------------- // Styles // Panel CSS, injected once into the page. // --------------------------------------------------------------------------- const CSS = ` #vdd-root { all: initial; -webkit-text-size-adjust: 100%; text-size-adjust: 100%; color-scheme: dark; line-height: 1.45; text-align: left; --canvas: #090a0b; --surface: #111314; --surface-raised: #1a1c1d; --surface-high: #252829; --line: rgba(255, 255, 255, .09); --text: #f4f5f2; --muted: #a7ada3; --quiet: #727970; --accent: #a8f500; --accent-ink: #121606; --danger: #e9847a; --ease-out: cubic-bezier(.22, 1, .36, 1); --ic-dl: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 4v11'/%3E%3Cpath d='m7 11 5 5 5-5'/%3E%3Cpath d='M5 20h14'/%3E%3C/svg%3E"); --ic-spin: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.6' stroke-linecap='round'%3E%3Cpath d='M12 3a9 9 0 1 0 9 9'/%3E%3C/svg%3E"); --ic-vid: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='black'%3E%3Cpath d='M5 6.5A2.5 2.5 0 0 1 7.5 4h9A2.5 2.5 0 0 1 19 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-9A2.5 2.5 0 0 1 5 17.5zm5.2 2.1v6.8a.6.6 0 0 0 .9.5l5.2-3.4a.6.6 0 0 0 0-1l-5.2-3.4a.6.6 0 0 0-.9.5z' fill-rule='evenodd'/%3E%3C/svg%3E"); --ic-note: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 18V6l10-2v12'/%3E%3Ccircle cx='6.5' cy='18' r='2.5'/%3E%3Ccircle cx='16.5' cy='16' r='2.5'/%3E%3C/svg%3E"); --ic-img: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.3' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3.5' y='4.5' width='17' height='15' rx='3.2'/%3E%3Ccircle cx='9' cy='10' r='1.6'/%3E%3Cpath d='m4.5 17.5 4.7-4.6 3.3 3.2 2.6-2.5 4.4 3.9'/%3E%3C/svg%3E"); --ic-zip: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 8.5 12 4l8 4.5v7L12 20l-8-4.5z'/%3E%3Cpath d='m4 8.5 8 4.5 8-4.5'/%3E%3Cpath d='M12 13v7'/%3E%3C/svg%3E"); --ic-check: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 12.5 4.5 4.5L19 7.5'/%3E%3C/svg%3E"); --ic-chev: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23ebebf5' stroke-opacity='.6' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m7 10 5 5 5-5'/%3E%3C/svg%3E"); } #vdd-root, #vdd-root * { box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", "Segoe UI", sans-serif; -webkit-font-smoothing: antialiased; } #vdd-root [hidden]:not(#vdd-boot) { display: none !important; } #vdd-root :is(button, input, select) { margin: 0; color: inherit; font: inherit; letter-spacing: normal; text-shadow: none; text-indent: 0; text-transform: none; } #vdd-root button { cursor: pointer; touch-action: manipulation; -webkit-appearance: none; appearance: none; } #vdd-root button:disabled, #vdd-root select:disabled { cursor: not-allowed; opacity: .56; } #vdd-root button:active:not(:disabled) { transform: scale(.985); } #vdd-root :is(button, input, select):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } #vdd-open { position: fixed; right: max(16px, env(safe-area-inset-right)); bottom: calc(16px + env(safe-area-inset-bottom)); z-index: 2147483646; display: grid; place-items: center; width: 56px; height: 56px; padding: 0; overflow: hidden; isolation: isolate; border: 1px solid rgba(168, 245, 0, .55); border-radius: 18px; color: var(--accent); background: radial-gradient(120% 120% at 20% 0%, rgba(168, 245, 0, .20), transparent 55%), linear-gradient(160deg, #161b0c 0%, #090b06 100%); box-shadow: 0 10px 28px rgba(0, 0, 0, .46), 0 0 0 1px rgba(168, 245, 0, .08), inset 0 1px 0 rgba(255, 255, 255, .10); cursor: grab; touch-action: none; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none; -webkit-tap-highlight-color: transparent; transition: transform .22s var(--ease-out), border-color .2s, background .2s, box-shadow .25s; } #vdd-open::before { position: absolute; inset: 0; z-index: 0; border-radius: inherit; background: conic-gradient(from 210deg, transparent 0 55%, rgba(168, 245, 0, .30) 80%, transparent); content: ""; opacity: 0; pointer-events: none; transition: opacity .3s ease; } #vdd-open > svg { position: relative; z-index: 1; display: block; width: 34px; height: 34px; overflow: visible; pointer-events: none; } #vdd-open.vdd-dragging { cursor: grabbing; transform: scale(1.1); border-color: var(--accent); box-shadow: 0 18px 40px rgba(0, 0, 0, .55), 0 0 0 3px rgba(168, 245, 0, .18); transition: transform .18s var(--ease-out), box-shadow .2s; } #vdd-open.vdd-snapping { transition: left .34s var(--ease-out), top .34s var(--ease-out), transform .22s var(--ease-out), border-color .2s, box-shadow .25s; } #vdd-open:active:not(.vdd-dragging) { transform: scale(.94); } #vdd-root .vdd-ring { opacity: 0; color: var(--accent); transform-origin: 16px 16px; transition: opacity .25s ease; } #vdd-root .vdd-arrow, #vdd-root .vdd-tray { color: var(--accent); } #vdd-root .vdd-arrow { transition: transform .3s var(--ease-out); } #vdd-root:has(.vdd-btn:disabled, #vdd-link-submit:disabled) #vdd-open { border-color: var(--accent); box-shadow: 0 10px 30px rgba(0, 0, 0, .5), 0 0 0 3px rgba(168, 245, 0, .16), 0 0 26px rgba(168, 245, 0, .22); } #vdd-root:has(.vdd-btn:disabled, #vdd-link-submit:disabled) #vdd-open::before { opacity: 1; animation: vdd-orbit 1.6s linear infinite; } #vdd-root:has(.vdd-btn:disabled, #vdd-link-submit:disabled) #vdd-open .vdd-ring { opacity: 1; animation: vdd-orbit 1.15s linear infinite; } #vdd-root:has(.vdd-btn:disabled, #vdd-link-submit:disabled) #vdd-open .vdd-arrow { animation: vdd-bob 1.1s ease-in-out infinite; } #vdd-panel { position: fixed; z-index: 2147483647; top: var(--vdd-pt, max(8px, var(--vdd-ct, 72px))); left: var(--vdd-pl, max(8px, var(--vdd-cl, 8px))); display: none; flex-direction: column; width: min(var(--vdd-cw, 480px), calc(100vw - 16px)); height: min(var(--vdd-ch, 810px), calc(100dvh - 16px)); overflow: hidden; overscroll-behavior: contain; border: 1px solid var(--line); border-radius: 17px; color: var(--text); background: var(--canvas); box-shadow: 0 18px 52px rgba(0, 0, 0, .42), 0 2px 8px rgba(0, 0, 0, .24); transform: translateY(10px) scale(.99); transform-origin: bottom right; visibility: hidden; opacity: 0; pointer-events: none; transition: opacity .2s ease, transform .26s var(--ease-out), visibility 0s linear .26s; } #vdd-panel.show, #vdd-panel.vdd-closing { display: flex; } #vdd-panel.show { visibility: visible; opacity: 1; pointer-events: auto; transform: none; transition: opacity .2s ease, transform .26s var(--ease-out), visibility 0s; animation: vdd-panel-in .22s ease both; } #vdd-panel.vdd-full { top: 0; left: 0; width: var(--vdd-w, 100vw); height: var(--vdd-h, 100dvh); max-width: 100vw; max-height: 100dvh; padding: var(--vdd-sat, env(safe-area-inset-top, 0px)) var(--vdd-sar, env(safe-area-inset-right, 0px)) var(--vdd-sab, env(safe-area-inset-bottom, 0px)) var(--vdd-sal, env(safe-area-inset-left, 0px)); border: 0; border-radius: 0; box-shadow: none; transform: translateY(10px) scale(.99); transform-origin: center; } #vdd-panel.vdd-full.show { transform: none; } #vdd-root.vdd-resizing #vdd-panel { transition: none !important; } #vdd-panel::before, #vdd-panel::after, #vdd-root .vdd-aurora { display: none !important; } #vdd-root .vdd-head, #vdd-root .vdd-tabs, #vdd-root .vdd-body, #vdd-root .vdd-footer { position: relative; z-index: 1; } #vdd-root .vdd-head { position: relative; display: flex; flex: 0 0 auto; align-items: center; gap: clamp(7px, 1vw, 11px); padding: 17px clamp(16px, 2vw, 24px) 14px; } #vdd-root .vdd-grip { position: absolute; top: 6px; left: 50%; display: none; width: 38px; height: 4px; border-radius: 99px; background: rgba(255, 255, 255, .16); transform: translateX(-50%); pointer-events: none; } #vdd-panel:not(.vdd-full) .vdd-grip { display: block; } #vdd-panel:not(.vdd-full) .vdd-head { cursor: grab; touch-action: none; user-select: none; -webkit-user-select: none; padding-top: 21px; } #vdd-panel:not(.vdd-full) .vdd-head button { cursor: pointer; touch-action: manipulation; } #vdd-panel.vdd-dragging { cursor: grabbing; transition: none !important; animation: none !important; box-shadow: 0 30px 70px rgba(0, 0, 0, .55), 0 0 0 1px rgba(168, 245, 0, .22); } #vdd-panel.vdd-dragging .vdd-head { cursor: grabbing; } #vdd-root .vdd-logo { display: grid; flex: 0 0 auto; place-items: center; width: 42px; height: 42px; overflow: hidden; border: 1px solid var(--line); border-radius: 12px; color: var(--accent); background: radial-gradient(120% 120% at 20% 0%, rgba(168, 245, 0, .20), transparent 55%), linear-gradient(160deg, #161b0c 0%, #090b06 100%); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .08); } #vdd-root .vdd-logo svg { display: block; width: 28px; height: 28px; overflow: visible; } #vdd-root .vdd-head-main { flex: 1 1 auto; min-width: 0; } #vdd-root .vdd-eyebrow { margin-bottom: 3px; color: var(--quiet); font-size: 9px; font-weight: 700; letter-spacing: .14em; } #vdd-root .vdd-title { color: var(--text); font-size: clamp(18px, 2.4vw, 22px); font-weight: 760; letter-spacing: -.045em; line-height: 1.1; white-space: nowrap; } #vdd-root .vdd-title span { display: inline-flex; align-items: center; margin-left: 5px; padding: 3px 6px; border: 1px solid var(--line); border-radius: 5px; color: var(--muted); background: var(--surface-raised); font-size: 9px; font-weight: 650; letter-spacing: .03em; vertical-align: 3px; } #vdd-root .vdd-sub { margin-top: 4px; overflow: hidden; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } #vdd-root .vdd-icon { position: relative; display: grid; flex: 0 0 auto; place-items: center; width: 38px; height: 38px; padding: 0; border: 1px solid transparent; border-radius: 10px; color: var(--muted); background: transparent; transition: color .18s ease, background .18s ease, border-color .18s ease, transform .18s var(--ease-out); } #vdd-root .vdd-icon svg { display: block; width: 18px; height: 18px; } #vdd-root .vdd-icon:active { transform: scale(.94); color: var(--text); background: var(--surface-raised); } #vdd-root #vdd-refresh svg { transition: transform .36s var(--ease-out); } #vdd-root #vdd-refresh.vdd-spinning svg { animation: vdd-refresh .48s var(--ease-out); } #vdd-root .vdd-expand svg { position: absolute; inset: 0; margin: auto; transition: opacity .18s ease, transform .2s var(--ease-out); } #vdd-root .vdd-expand .vdd-ico-out { opacity: 1; transform: none; } #vdd-root .vdd-expand .vdd-ico-in { opacity: 0; transform: scale(.8); } #vdd-panel.vdd-full .vdd-expand .vdd-ico-out { opacity: 0; transform: scale(.8); } #vdd-panel.vdd-full .vdd-expand .vdd-ico-in { opacity: 1; transform: none; } #vdd-root .vdd-tabs { position: relative; display: grid; flex: 0 0 auto; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0; margin: 0 clamp(16px, 2vw, 24px) 13px; padding: 3px; border: 1px solid var(--line); border-radius: 10px; background: #141617; } #vdd-root .vdd-tab { position: relative; z-index: 1; display: flex; min-width: 0; min-height: 44px; align-items: center; justify-content: center; gap: 7px; padding: 0 8px; border: 0; border-radius: 7px; color: var(--muted); background: transparent; font-size: 12.5px; font-weight: 620; line-height: 1.15; text-align: center; transition: color .18s ease, background .18s ease, transform .16s var(--ease-out); } #vdd-root .vdd-tab svg { display: block; flex: 0 0 auto; width: 16px; height: 16px; } #vdd-root .vdd-tab span { min-width: 0; } #vdd-root .vdd-tab.active, #vdd-root .vdd-tab[aria-selected="true"] { color: var(--text); background: var(--surface-raised); } #vdd-root .vdd-tab:active { transform: scale(.97); } @supports selector(:has(*)) { #vdd-root .vdd-tabs::before { position: absolute; top: 3px; bottom: 3px; left: 3px; width: calc((100% - 6px) / 3); border: 1px solid var(--line); border-radius: 7px; background: var(--surface-raised); content: ""; pointer-events: none; transition: transform .26s var(--ease-out); } #vdd-root .vdd-tabs:has(#vdd-tab-link[aria-selected="true"])::before { transform: translateX(100%); } #vdd-root .vdd-tabs:has(#vdd-tab-sites[aria-selected="true"])::before { transform: translateX(200%); } #vdd-root .vdd-tab.active, #vdd-root .vdd-tab[aria-selected="true"] { background: transparent; } } #vdd-root .vdd-body { display: flex; flex: 1 1 0; flex-direction: column; min-height: 0; overflow-x: hidden; overflow-y: auto; border-top: 1px solid var(--line); overscroll-behavior: contain; scrollbar-color: rgba(255, 255, 255, .22) transparent; scrollbar-gutter: stable; scrollbar-width: thin; -webkit-overflow-scrolling: touch; touch-action: pan-y; } #vdd-root .vdd-view { min-height: 0; } #vdd-root .vdd-view:not([hidden]) { animation: vdd-view-in .18s ease both; } #vdd-list, #vdd-link-results, #vdd-root .vdd-entries, #vdd-root .vdd-entry-items { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 25rem), 1fr)); align-items: start; gap: 12px; } #vdd-list { padding: 18px clamp(16px, 2vw, 24px) 28px; } #vdd-link-results { margin-top: 16px; } #vdd-link-view { padding: 20px clamp(16px, 2vw, 24px) 28px; } #vdd-link-results { padding: 0; } #vdd-link-listing { padding-top: 12px; } #vdd-root .vdd-empty { grid-column: 1 / -1; padding: clamp(42px, 8vh, 72px) 20px; color: var(--muted); font-size: 13px; line-height: 1.6; text-align: center; } #vdd-root .vdd-empty::before { display: block; width: 42px; height: 42px; margin: 0 auto 14px; background: var(--quiet); content: ""; -webkit-mask: var(--ic-vid) center / contain no-repeat; mask: var(--ic-vid) center / contain no-repeat; } #vdd-root .vdd-link-form { display: flex; gap: 8px; min-width: 0; } #vdd-root .vdd-input-wrap { position: relative; display: flex; flex: 1 1 auto; min-width: 0; } #vdd-link-input, #vdd-sites-input { width: 100%; min-width: 0; height: 48px; padding: 0 42px 0 13px; border: 1px solid var(--line); border-radius: 9px; color: var(--text); background: var(--surface-raised); font-size: 15px; transition: border-color .16s ease, background .16s ease, box-shadow .16s ease; } #vdd-link-input::placeholder, #vdd-sites-input::placeholder { color: var(--quiet); opacity: 1; } #vdd-link-input:focus, #vdd-sites-input:focus { border-color: rgba(168, 245, 0, .65); outline: none; box-shadow: 0 0 0 3px rgba(168, 245, 0, .12); } #vdd-root .vdd-link-btn { flex: 0 0 auto; min-width: 78px; min-height: 48px; padding: 0 16px; border: 1px solid transparent; border-radius: 9px; color: var(--accent-ink); background: var(--accent); font-size: 13px; font-weight: 760; position: relative; isolation: isolate; overflow: hidden; background: linear-gradient(180deg, #c9ff45 0%, var(--accent) 55%, #8dd400 100%); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .45), 0 8px 18px -10px rgba(168, 245, 0, .6); transition: transform .2s var(--ease-out), background .2s ease, opacity .2s ease, box-shadow .2s ease; } #vdd-root .vdd-link-btn:active:not(:disabled) { transform: scale(.97); } #vdd-root .vdd-link-btn:disabled { opacity: .85; cursor: progress; } #vdd-root .vdd-link-btn:disabled::before { display: inline-block; width: 13px; height: 13px; margin-right: 7px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; content: ""; vertical-align: -2px; animation: vdd-button-spin .7s linear infinite; } #vdd-root .vdd-input-clear { position: absolute; top: 50%; right: 5px; display: grid; width: 34px; height: 34px; place-items: center; padding: 0; border: 0; border-radius: 7px; color: var(--muted); background: transparent; transform: translateY(-50%); } #vdd-root .vdd-input-clear svg { display: block; width: 15px; height: 15px; } #vdd-root .vdd-link-tools { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 4px 14px; margin-top: 2px; } #vdd-root .vdd-tool-group { display: flex; flex-wrap: wrap; gap: 4px 12px; align-items: center; } #vdd-root .vdd-text-btn { min-height: 44px; padding: 7px 4px; border: 0; border-radius: 6px; color: var(--accent); background: transparent; font-size: 12px; font-weight: 630; text-align: left; transition: color .16s ease, background .16s ease, transform .16s var(--ease-out); } #vdd-root .vdd-text-btn:active { transform: scale(.98); } #vdd-root .vdd-link-note, #vdd-root .vdd-link-status, #vdd-root .vdd-progress-text, #vdd-root .vdd-quality-note { color: var(--muted); font-size: 12px; line-height: 1.55; overflow-wrap: anywhere; } #vdd-root .vdd-link-note { margin: 8px 0 0; } #vdd-root .vdd-link-status { margin-top: 12px; } #vdd-root .vdd-link-status:empty { display: none; } #vdd-root .vdd-link-status[data-state="loading"] { display: flex; align-items: center; gap: 10px; padding: 11px 13px; border: 1px solid rgba(168, 245, 0, .22); border-radius: 11px; color: var(--text); background: linear-gradient(90deg, rgba(168, 245, 0, .08), rgba(168, 245, 0, .02)); } #vdd-root .vdd-link-status[data-state="loading"]::before { flex: 0 0 auto; width: 16px; height: 16px; border: 2.5px solid rgba(168, 245, 0, .28); border-top-color: var(--accent); border-radius: 50%; content: ""; animation: vdd-button-spin .7s linear infinite; } #vdd-root .vdd-link-status[data-state="complete"], #vdd-root .vdd-link-status[data-state="error"] { animation: vdd-rise .3s var(--ease-out) both; } #vdd-root [data-state="error"] { color: var(--danger); } #vdd-root [data-state="complete"] { color: var(--accent); } #vdd-root .vdd-link-seg { display: flex; gap: 3px; margin-top: 14px; padding: 3px; border: 1px solid var(--line); border-radius: 9px; background: #141617; } #vdd-root .vdd-link-seg button { flex: 1 1 0; min-width: 0; min-height: 40px; padding: 4px 8px; border: 0; border-radius: 6px; color: var(--muted); background: transparent; font-size: 12px; font-weight: 620; transition: background .16s ease, color .16s ease; } #vdd-root .vdd-link-seg button.on { color: var(--text); background: var(--surface-raised); } #vdd-root .vdd-link-seg button span { margin-left: 4px; color: var(--quiet); font-weight: 550; } #vdd-root .vdd-card { min-width: 0; overflow: hidden; border: 1px solid var(--line); border-radius: 12px; background: var(--surface); contain-intrinsic-size: auto 15rem; content-visibility: auto; container-type: inline-size; transition: border-color .18s ease, background .18s ease, transform .18s var(--ease-out); } #vdd-root .vdd-card:last-child { margin-bottom: 0; } #vdd-root .vdd-card[data-dl="queued"] { border-color: rgba(255, 255, 255, .26); } #vdd-root .vdd-card[data-dl="running"] { border-color: rgba(168, 245, 0, .6); } #vdd-root .vdd-card[data-dl="done"] { border-color: rgba(168, 245, 0, .32); } #vdd-root .vdd-card[data-dl="pending"] { border-color: rgba(168, 245, 0, .45); } #vdd-root .vdd-card[data-dl="error"] { border-color: rgba(233, 132, 122, .55); } #vdd-root .vdd-card.vdd-enter, #vdd-root .vdd-card[data-dl] { content-visibility: visible; contain-intrinsic-size: none; } @supports selector(:has(*)) { #vdd-root .vdd-card:has(.vdd-progress.show) { content-visibility: visible; contain-intrinsic-size: none; } } #vdd-root .vdd-fileinfo { margin: 8px 12px 12px; color: var(--muted); font-size: 12px; line-height: 1.4; overflow-wrap: anywhere; } #vdd-root .vdd-progress-text[data-state="queued"] { color: var(--muted); } #vdd-root .vdd-card.vdd-variant { margin-left: 8px; border-left: 2px solid rgba(168, 245, 0, .42); } #vdd-root .vdd-card-top { display: grid; grid-template-columns: minmax(6rem, .9fr) minmax(0, 1.1fr); align-items: start; gap: 14px; padding: 14px; } @supports (width: 1cqi) { #vdd-root .vdd-card-top { gap: clamp(11px, 2.2cqi, 17px); padding: clamp(12px, 3.5cqi, 17px); } } #vdd-root .vdd-thumb { position: relative; width: 100%; min-width: 0; aspect-ratio: 16 / 10; overflow: hidden; border: 1px solid rgba(255, 255, 255, .07); border-radius: 9px; color: var(--quiet); background: #202223; } #vdd-root .vdd-thumb img, #vdd-root .vdd-thumb canvas.vdd-thumb-canvas { position: absolute; inset: 0; display: block; width: 100%; height: 100%; object-fit: cover; opacity: 0; transform: scale(1.025); transition: opacity .3s ease, transform .38s var(--ease-out); } #vdd-root .vdd-thumb.has-image img, #vdd-root .vdd-thumb.has-image canvas.vdd-thumb-canvas { opacity: 1; transform: none; } #vdd-root .vdd-thumb.has-image .vdd-thumb-fallback { display: none; } #vdd-root .vdd-thumb-fallback { position: absolute; inset: 0; display: grid; place-items: center; color: var(--quiet); } #vdd-root .vdd-thumb-fallback svg { display: block; width: 28px; height: 28px; } #vdd-root .vdd-duration { position: absolute; right: 6px; bottom: 6px; padding: 3px 6px; border: 1px solid rgba(255, 255, 255, .12); border-radius: 5px; color: #fff; background: rgba(14, 15, 15, .82); font-size: 10px; font-variant-numeric: tabular-nums; font-weight: 650; line-height: 1; } #vdd-root .vdd-main { min-width: 0; align-self: center; } @container (max-width: 350px) { #vdd-root .vdd-card-top { grid-template-columns: minmax(0, 1fr); gap: 12px; } #vdd-root .vdd-thumb { aspect-ratio: 16 / 9; } #vdd-root .vdd-main { align-self: auto; } } #vdd-root .vdd-name { display: -webkit-box; overflow: hidden; color: var(--text); font-size: 15px; font-weight: 690; letter-spacing: -.025em; line-height: 1.3; -webkit-box-orient: vertical; -webkit-line-clamp: 2; } @supports (font-size: 1cqi) { #vdd-root .vdd-name { font-size: clamp(14px, 4cqi, 16px); } } #vdd-root .vdd-source { display: flex; min-width: 0; align-items: center; gap: 7px; margin-top: 7px; color: var(--muted); font-size: 11.5px; line-height: 1.3; } #vdd-root .vdd-source > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } #vdd-root .vdd-site { display: block; flex: 0 0 auto; width: 17px; height: 17px; min-width: 17px; max-width: 17px; min-height: 17px; max-height: 17px; border-radius: 5px; object-fit: contain; } #vdd-root .vdd-site.big { width: 30px; height: 30px; min-width: 30px; max-width: 30px; min-height: 30px; max-height: 30px; border-radius: 9px; } #vdd-root canvas.vdd-site-logo { object-fit: contain; } #vdd-root .vdd-meta-row { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 10px; } #vdd-root .vdd-badge, #vdd-root .vdd-meta-chip { max-width: 100%; padding: 4px 7px; overflow-wrap: anywhere; border: 1px solid var(--line); border-radius: 5px; color: var(--muted); background: rgba(255, 255, 255, .035); font-size: 9.5px; font-weight: 650; line-height: 1.15; } #vdd-root .vdd-badge.preferred, #vdd-root .vdd-badge.vdd-chip-active { border-color: rgba(168, 245, 0, .25); color: var(--accent); background: rgba(168, 245, 0, .09); } #vdd-root .vdd-res { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 15px 12px; } #vdd-root .vdd-res .vdd-chip { min-height: 30px; padding: 5px 9px; border: 1px solid var(--line); border-radius: 6px; color: var(--muted); background: var(--surface-raised); font-size: 11px; font-weight: 650; line-height: 1.2; transition: border-color .16s ease, color .16s ease, background .16s ease, transform .16s var(--ease-out); } #vdd-root button.vdd-chip { cursor: pointer; } #vdd-root button.vdd-chip:active { transform: scale(.96); } #vdd-root .vdd-chip.on { border-color: rgba(168, 245, 0, .48); color: var(--accent); background: rgba(168, 245, 0, .1); } #vdd-root .vdd-variants-toggle { display: flex; width: calc(100% - 30px); min-height: 38px; align-items: center; gap: 8px; margin: 0 15px 12px; padding: 7px 9px; border: 1px solid var(--line); border-radius: 7px; color: var(--muted); background: var(--surface-raised); font-size: 11.5px; font-weight: 620; text-align: left; transition: background .16s ease, color .16s ease, transform .16s var(--ease-out); } #vdd-root .vdd-variants-toggle svg { flex: 0 0 auto; width: 15px; height: 15px; transition: transform .2s var(--ease-out); } #vdd-root .vdd-variants-toggle.open svg { transform: rotate(180deg); } #vdd-root .vdd-variant[hidden] { display: none !important; } #vdd-list:has(.vdd-variant:not([hidden])), #vdd-link-results:has(.vdd-variant:not([hidden])) { grid-template-columns: minmax(0, 1fr); } #vdd-root .vdd-quality-wrap { padding: 0 15px 12px; } #vdd-root .vdd-field-label { display: block; margin: 0 0 10px; color: var(--muted); font-size: 11px; font-weight: 650; letter-spacing: .01em; } #vdd-root .vdd-quality, #vdd-root .vdd-yt-quality, #vdd-root .vdd-audio { display: block; width: 100%; min-width: 0; min-height: 44px; margin-top: 6px; padding: 9px 36px 9px 11px; border: 1px solid var(--line); border-radius: 8px; color: var(--text); background: var(--surface-raised) var(--ic-chev) no-repeat right 10px center / 16px; font-size: 13px; -webkit-appearance: none; appearance: none; } #vdd-root .vdd-silent-label { display: flex; min-height: 42px; align-items: center; gap: 9px; color: var(--muted); font-size: 11.5px; cursor: pointer; } #vdd-root .vdd-allow-silent { width: 17px; height: 17px; flex: 0 0 auto; margin: 0; accent-color: var(--accent); } #vdd-root .vdd-quality-note { margin: 6px 0; } #vdd-root .vdd-yt-quality:disabled { opacity: .55; cursor: progress; } #vdd-root .vdd-yt-quality optgroup { color: var(--muted); font-style: normal; } #vdd-root .vdd-yt-note { margin: 8px 0 0; font-size: 11.5px; } #vdd-root .vdd-yt-skel { display: flex; gap: 12px; padding: 15px; border: 1px solid var(--line); border-radius: 14px; background: var(--surface-raised); animation: vdd-rise .3s var(--ease-out) both; } #vdd-root .vdd-yt-skel-thumb { flex: 0 0 38%; max-width: 168px; aspect-ratio: 16 / 9; border-radius: 9px; } #vdd-root .vdd-yt-skel-lines { flex: 1 1 auto; display: flex; flex-direction: column; gap: 10px; padding-top: 4px; } #vdd-root .vdd-yt-skel-lines i { display: block; height: 12px; border-radius: 6px; } #vdd-root .vdd-yt-skel-lines i:nth-child(1) { width: 92%; } #vdd-root .vdd-yt-skel-lines i:nth-child(2) { width: 64%; } #vdd-root .vdd-yt-skel-lines i:nth-child(3) { width: 38%; height: 20px; } #vdd-root .vdd-yt-skel-thumb, #vdd-root .vdd-yt-skel-lines i { background: linear-gradient(100deg, var(--surface-high) 30%, rgba(255, 255, 255, .11) 50%, var(--surface-high) 70%) 0 0 / 220% 100%; animation: vdd-yt-shimmer 1.25s linear infinite; } @keyframes vdd-yt-shimmer { from { background-position: 120% 0; } to { background-position: -120% 0; } } @media (prefers-reduced-motion: reduce) { #vdd-root .vdd-yt-skel-thumb, #vdd-root .vdd-yt-skel-lines i { animation: none; } } #vdd-root .vdd-quality-refresh, #vdd-root .vdd-cancel, #vdd-root .vdd-ghost { min-height: 42px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 8px; color: var(--muted); background: var(--surface-raised); font-size: 12px; font-weight: 620; transition: border-color .16s ease, color .16s ease, background .16s ease, transform .16s var(--ease-out); } #vdd-root .vdd-quality-refresh { width: 100%; margin-top: 8px; } #vdd-root :is(.vdd-btn, .vdd-link-btn, .vdd-primary, .vdd-tile, .vdd-chip, .vdd-quality-refresh) { position: relative; overflow: hidden; } #vdd-root .vdd-ripple { position: absolute; z-index: 0; border-radius: 50%; background: currentColor; opacity: .26; pointer-events: none; transform: scale(0); animation: vdd-ripple .65s var(--ease-out) forwards; } #vdd-root .vdd-cancel { width: calc(100% - 30px); margin: 0 15px 12px; border-color: rgba(233, 132, 122, .25); color: var(--danger); background: rgba(233, 132, 122, .08); } #vdd-root .vdd-actions { display: flex; padding: 0 15px 15px; } #vdd-root .vdd-btn { position: relative; isolation: isolate; display: flex; width: 100%; min-height: 46px; align-items: center; justify-content: center; gap: 9px; padding: 10px 14px; overflow: hidden; border: 1px solid rgba(255, 255, 255, .16); border-radius: 12px; color: var(--accent-ink); background: linear-gradient(180deg, #c9ff45 0%, var(--accent) 50%, #8dd400 100%); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .5), inset 0 -1px 0 rgba(0, 0, 0, .16), 0 8px 18px -8px rgba(168, 245, 0, .55); font-size: 13px; font-weight: 760; letter-spacing: -.01em; line-height: 1.2; transition: transform .2s var(--ease-out), box-shadow .24s ease, filter .2s ease, opacity .2s ease, background .24s ease, color .2s ease; } #vdd-root .vdd-btn::before { position: relative; z-index: 1; display: block; width: 17px; height: 17px; flex: 0 0 auto; background: currentColor; content: ""; -webkit-mask: var(--ic-dl) center / contain no-repeat; mask: var(--ic-dl) center / contain no-repeat; transition: transform .25s var(--ease-out); } #vdd-root .vdd-btn::after { position: absolute; inset: 0; z-index: 0; background: linear-gradient(105deg, transparent 30%, rgba(255, 255, 255, .55) 50%, transparent 70%); content: ""; opacity: 0; pointer-events: none; transform: translateX(-120%); } #vdd-root .vdd-btn:active:not(:disabled) { transform: scale(.975); box-shadow: inset 0 2px 6px rgba(0, 0, 0, .22), 0 3px 10px -6px rgba(168, 245, 0, .5); } #vdd-root .vdd-btn:disabled { color: var(--accent); border-color: rgba(168, 245, 0, .32); background: linear-gradient(180deg, rgba(168, 245, 0, .16), rgba(168, 245, 0, .08)); box-shadow: inset 0 0 0 1px rgba(168, 245, 0, .06); opacity: 1; cursor: progress; } #vdd-root .vdd-btn:disabled::before { -webkit-mask-image: var(--ic-spin); mask-image: var(--ic-spin); transform-origin: center; animation: vdd-button-spin .75s linear infinite; } #vdd-root .vdd-btn:disabled::after { opacity: .7; animation: vdd-sheen 1.8s ease-in-out infinite; } #vdd-root .vdd-btn.vdd-btn-ghost { color: var(--text); border-color: var(--line); background: var(--surface-raised); box-shadow: none; font-weight: 680; } #vdd-root .vdd-btn.vdd-btn-ghost:disabled { color: var(--accent); background: rgba(168, 245, 0, .08); } #vdd-root .vdd-btn.vdd-btn-mp3::before { -webkit-mask-image: var(--ic-note); mask-image: var(--ic-note); } #vdd-root .vdd-btn.vdd-btn-mp3:disabled::before { -webkit-mask-image: var(--ic-spin); mask-image: var(--ic-spin); } #vdd-root .vdd-btn.vdd-btn-img::before { -webkit-mask-image: var(--ic-img); mask-image: var(--ic-img); } #vdd-root .vdd-btn.vdd-btn-img:disabled::before { -webkit-mask-image: var(--ic-spin); mask-image: var(--ic-spin); } #vdd-root .vdd-btn.vdd-btn-zip::before { -webkit-mask-image: var(--ic-zip); mask-image: var(--ic-zip); } #vdd-root .vdd-btn.vdd-btn-zip:disabled::before { -webkit-mask-image: var(--ic-spin); mask-image: var(--ic-spin); } #vdd-root .vdd-card:has(.vdd-btn:disabled) { border-color: rgba(168, 245, 0, .45); box-shadow: 0 0 0 1px rgba(168, 245, 0, .12), 0 14px 34px -18px rgba(168, 245, 0, .4); } #vdd-root .vdd-progress { display: none; padding: 0 15px 15px; } #vdd-root .vdd-progress.show { display: block; animation: vdd-rise .28s var(--ease-out) both; } #vdd-root .vdd-progress-line { position: relative; height: 7px; overflow: hidden; border-radius: 99px; background: rgba(255, 255, 255, .07); box-shadow: inset 0 1px 2px rgba(0, 0, 0, .35); contain: paint; } #vdd-root .vdd-progress-bar { position: relative; display: block; width: 0; height: 100%; overflow: hidden; border-radius: inherit; background: linear-gradient(90deg, #79c800, var(--accent) 55%, #dcff85); box-shadow: 0 0 14px rgba(168, 245, 0, .55); transform: translate3d(0, 0, 0); transition: width .4s var(--ease-out); } #vdd-root .vdd-progress-bar::after { position: absolute; inset: 0; background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .6), transparent); content: ""; transform: translateX(-100%); animation: vdd-shimmer 1.5s ease-in-out infinite; } #vdd-root .vdd-progress-line[aria-valuenow="100"] .vdd-progress-bar::after { animation: none; opacity: 0; } #vdd-root .vdd-progress-line.indeterminate .vdd-progress-bar { width: 38% !important; transition: none; will-change: transform; animation: vdd-progress 1.25s cubic-bezier(.65, 0, .35, 1) infinite alternate; } #vdd-root.vdd-paused .vdd-progress-line.indeterminate .vdd-progress-bar, #vdd-root.vdd-paused .vdd-progress-bar::after { animation-play-state: paused; } #vdd-root .vdd-progress-text { display: flex; align-items: flex-start; gap: 8px; min-height: 1.45em; margin-top: 9px; line-height: 1.45; overflow-wrap: anywhere; } #vdd-root .vdd-progress-text::before { flex: 0 0 auto; width: 8px; height: 8px; margin-top: .42em; border-radius: 50%; background: var(--quiet); content: ""; } #vdd-root .vdd-card[data-dl="running"] .vdd-progress-text:not([data-state])::before, #vdd-root .vdd-card[data-dl="running"] .vdd-progress-text[data-state=""]::before { background: var(--accent); box-shadow: 0 0 0 0 rgba(168, 245, 0, .55); animation: vdd-ping 1.4s ease-out infinite; } #vdd-root .vdd-progress-text[data-state="queued"]::before { background: #7fb2ff; } #vdd-root .vdd-progress-text[data-state="error"]::before { background: var(--danger); } #vdd-root .vdd-progress-text[data-state="complete"]::before { width: 14px; height: 14px; margin-top: .16em; border-radius: 0; background: var(--accent); -webkit-mask: var(--ic-check) center / contain no-repeat; mask: var(--ic-check) center / contain no-repeat; animation: vdd-check .34s var(--ease-out) both; } #vdd-root .vdd-entry { contain-intrinsic-size: auto 17rem; } #vdd-root .vdd-entry-items { grid-template-columns: repeat(auto-fit, minmax(min(100%, 23rem), 1fr)); gap: 9px; padding: 0 12px 12px; border-top: 1px solid var(--line); background: rgba(0, 0, 0, .12); } #vdd-root .vdd-entry-items[hidden] { display: none !important; } #vdd-root .vdd-entry-items .vdd-card { border-radius: 8px; background: var(--surface); box-shadow: none; } #vdd-root .vdd-entries:empty { display: none; } #vdd-root .vdd-sentinel { grid-column: 1 / -1; height: 2px; } #vdd-root .vdd-has-more .vdd-sentinel { height: 52px; margin: 2px 0 8px; border: 1px solid var(--line); border-radius: 9px; background: linear-gradient(90deg, var(--surface), var(--surface-raised), var(--surface)); } #vdd-root .vdd-more { display: block; grid-column: 1 / -1; margin: 2px auto 16px; } #vdd-root .vdd-more[hidden] { display: none !important; } #vdd-root .vdd-trace { margin: 8px 0 0; overflow-wrap: anywhere; color: var(--muted); font: 11px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; user-select: text; -webkit-user-select: text; white-space: pre-wrap; } #vdd-root .vdd-trace[hidden] { display: none !important; } #vdd-root .vdd-open-page { display: block; margin: 7px 0 2px; text-align: left; } #vdd-sites-view, #vdd-diag-view { padding: 20px clamp(16px, 2vw, 24px) 28px; } #vdd-root .vdd-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 11rem), 1fr)); gap: 9px; } #vdd-root .vdd-tile { position: relative; isolation: isolate; display: grid; min-width: 0; min-height: 96px; align-content: start; justify-items: start; gap: 8px; overflow: hidden; padding: 13px; border: 1px solid var(--line); border-radius: 13px; color: var(--text); background: linear-gradient(180deg, rgba(255, 255, 255, .035), transparent 70%), var(--surface); text-align: left; transition: transform .2s var(--ease-out), border-color .18s ease, background .18s ease, box-shadow .22s ease; } #vdd-root .vdd-tile .vdd-site, #vdd-root .vdd-tile .vdd-glyph { width: 30px; height: 30px; min-width: 30px; max-width: 30px; min-height: 30px; max-height: 30px; border-radius: 9px; } #vdd-root .vdd-glyph { display: grid; place-items: center; color: var(--muted); background: var(--surface-raised); } #vdd-root .vdd-glyph svg { display: block; width: 18px; height: 18px; } #vdd-root .vdd-tile b { font-size: 13px; font-weight: 680; letter-spacing: -.01em; } #vdd-root .vdd-tile small { color: var(--muted); font-size: 11px; line-height: 1.4; } #vdd-root .vdd-tile.on { border-color: rgba(168, 245, 0, .42); background: rgba(168, 245, 0, .055); } #vdd-root .vdd-recent { display: flex; width: 100%; min-width: 0; min-height: 44px; align-items: center; gap: 10px; margin: 0 0 6px; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; color: var(--text); background: var(--surface); text-align: left; transition: background .16s ease, border-color .16s ease, transform .16s var(--ease-out); } #vdd-root .vdd-recent span { flex: 1; min-width: 0; overflow: hidden; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } #vdd-root .vdd-row-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; } #vdd-root .vdd-section { margin: 20px 0 9px; color: var(--quiet); font-size: 10px; font-weight: 720; letter-spacing: .12em; text-transform: uppercase; } #vdd-root .vdd-diag-intro { margin: 0; color: var(--muted); font-size: 12px; line-height: 1.6; } #vdd-root .vdd-diag-actions { display: flex; flex-wrap: wrap; gap: 8px; } #vdd-root .vdd-diag-actions button { display: flex; flex: 1 1 140px; min-height: 44px; align-items: center; justify-content: center; gap: 8px; padding: 7px 12px; color: var(--text); font-size: 12px; font-weight: 650; } #vdd-root .vdd-diag-actions button.vdd-primary { border: 1px solid transparent; border-radius: 8px; color: var(--accent-ink); background: var(--accent); } #vdd-root .vdd-diag-actions svg { width: 16px; height: 16px; flex: 0 0 auto; } #vdd-root .vdd-check { display: grid; grid-template-columns: 22px minmax(0, 1fr); gap: 10px; padding: 12px; border: 1px solid var(--line); border-radius: 9px; background: var(--surface); } #vdd-root .vdd-check > svg { width: 20px; height: 20px; margin-top: 1px; } #vdd-root .vdd-check b { display: block; color: var(--text); font-size: 12.5px; font-weight: 680; } #vdd-root .vdd-check p { margin: 4px 0 0; color: var(--muted); font-size: 11.5px; line-height: 1.5; overflow-wrap: anywhere; } #vdd-root .vdd-check p.fix { color: var(--accent); } #vdd-root .vdd-check[data-s="ok"] > svg, #vdd-root .vdd-check[data-s="warn"] > svg { color: var(--accent); } #vdd-root .vdd-check[data-s="bad"] > svg { color: var(--danger); } #vdd-root .vdd-check[data-s="bad"] { border-color: rgba(233, 132, 122, .28); } #vdd-root .vdd-check[data-s="warn"] { border-color: rgba(168, 245, 0, .24); } #vdd-root #vdd-boot { position: absolute; inset: 0; z-index: 6; display: grid; place-items: center; align-content: center; gap: 15px; padding: 24px; background: var(--canvas); text-align: center; transition: opacity .22s ease, visibility 0s linear .22s; } #vdd-root #vdd-boot[hidden] { display: grid !important; visibility: hidden; opacity: 0; pointer-events: none; } #vdd-root #vdd-boot .vdd-boot-ring { position: relative; display: grid; width: 72px; height: 72px; place-items: center; border: 1px solid var(--line); border-radius: 50%; color: var(--accent); background: var(--surface); } #vdd-root #vdd-boot .vdd-boot-ring::before { position: absolute; inset: -5px; border: 2px solid transparent; border-top-color: var(--accent); border-right-color: rgba(168, 245, 0, .35); border-radius: 50%; content: ""; animation: vdd-button-spin 1s linear infinite; } #vdd-root #vdd-boot .vdd-boot-ring::after { position: absolute; inset: -14px; border-radius: 50%; background: radial-gradient(closest-side, rgba(168, 245, 0, .16), transparent); content: ""; animation: vdd-breathe 2.2s ease-in-out infinite; } #vdd-root #vdd-boot .vdd-boot-ring svg { animation: vdd-bob 1.4s ease-in-out infinite; } #vdd-root #vdd-boot .vdd-boot-ring svg { position: relative; z-index: 1; width: 32px; height: 32px; } #vdd-root #vdd-boot h2 { margin: 0; color: var(--text); font-size: 19px; font-weight: 720; letter-spacing: -.03em; } #vdd-root #vdd-boot p { min-height: 1.5em; margin: 0; color: var(--muted); font-size: 12px; } #vdd-root #vdd-boot .vdd-boot-bar { width: min(240px, 72%); height: 3px; overflow: hidden; border-radius: 9px; background: var(--surface-high); } #vdd-root #vdd-boot .vdd-boot-bar i { display: block; width: 0; height: 100%; border-radius: inherit; background: var(--accent); transition: width .4s var(--ease-out); } #vdd-root .vdd-footer { flex: 0 0 auto; padding: 11px 16px; border-top: 1px solid var(--line); color: var(--quiet); font-size: 10.5px; text-align: center; } #vdd-root .vdd-footer span { padding: 0 5px; color: var(--accent); } #vdd-toast { position: fixed; right: max(16px, env(safe-area-inset-right)); bottom: calc(18px + env(safe-area-inset-bottom)); left: max(16px, env(safe-area-inset-left)); z-index: 2147483647; width: fit-content; max-width: min(440px, calc(100vw - 32px)); margin-inline: auto; padding: 11px 15px; border: 1px solid var(--line); border-radius: 9px; color: var(--text); background: var(--surface-high); box-shadow: 0 10px 28px rgba(0, 0, 0, .3); font-size: 12px; font-weight: 550; text-align: center; opacity: 0; pointer-events: none; transform: translateY(8px); transition: opacity .18s ease, transform .2s var(--ease-out); } #vdd-toast.show { opacity: 1; transform: none; } @keyframes vdd-panel-in { from { opacity: 0; } to { opacity: 1; } } @keyframes vdd-card-in { from { opacity: 0; transform: translate3d(0, 10px, 0) scale(.985); } to { opacity: 1; transform: translate3d(0, 0, 0) scale(1); } } @keyframes vdd-fade-in { from { opacity: 0; } to { opacity: 1; } } @keyframes vdd-progress { from { transform: translate3d(0, 0, 0); } to { transform: translate3d(163%, 0, 0); } } @keyframes vdd-shimmer { to { transform: translateX(100%); } } @keyframes vdd-sheen { 0% { transform: translateX(-120%); } 60%, 100% { transform: translateX(120%); } } @keyframes vdd-ripple { to { transform: scale(1); opacity: 0; } } @keyframes vdd-orbit { to { transform: rotate(360deg); } } @keyframes vdd-bob { 0%, 100% { transform: translateY(-1px); } 50% { transform: translateY(2.2px); } } @keyframes vdd-ping { 0% { box-shadow: 0 0 0 0 rgba(168, 245, 0, .55); } 80%, 100% { box-shadow: 0 0 0 7px rgba(168, 245, 0, 0); } } @keyframes vdd-rise { from { opacity: 0; transform: translate3d(0, 6px, 0); } to { opacity: 1; transform: translate3d(0, 0, 0); } } @keyframes vdd-breathe { 0%, 100% { opacity: .5; transform: scale(.94); } 50% { opacity: 1; transform: scale(1.06); } } @keyframes vdd-button-spin { to { transform: rotate(360deg); } } @keyframes vdd-refresh { to { transform: rotate(180deg); } } @keyframes vdd-view-in { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: none; } } @keyframes vdd-check { from { transform: scale(.4) rotate(-20deg); opacity: 0; } to { transform: scale(1) rotate(0); opacity: 1; } } #vdd-root .vdd-card.vdd-enter { animation: vdd-card-in .42s var(--ease-out) both; animation-delay: calc(var(--i, 0) * 55ms); } @media (hover: hover) { #vdd-open:hover { border-color: var(--accent); background: #11170a; } #vdd-open:hover::before { transform: scale(1.04); } #vdd-root .vdd-icon:hover { border-color: var(--line); color: var(--text); background: var(--surface-raised); } #vdd-root .vdd-tab:hover:not([aria-selected="true"]) { color: var(--text); } #vdd-root .vdd-text-btn:hover { color: var(--text); background: rgba(255, 255, 255, .045); } #vdd-root .vdd-link-btn:hover:not(:disabled), #vdd-root .vdd-diag-actions button.vdd-primary:hover:not(:disabled) { background: #c4ff50; transform: translateY(-1px); } #vdd-root .vdd-btn:hover:not(:disabled) { filter: brightness(1.06); box-shadow: inset 0 1px 0 rgba(255, 255, 255, .55), inset 0 -1px 0 rgba(0, 0, 0, .16), 0 12px 24px -10px rgba(168, 245, 0, .7); } #vdd-root .vdd-btn:hover:not(:disabled)::after { opacity: 1; animation: vdd-sheen .9s ease-out; } #vdd-root .vdd-btn:hover:not(:disabled)::before { transform: translateY(1.5px); } #vdd-root .vdd-btn.vdd-btn-ghost:hover:not(:disabled) { background: var(--surface-high); box-shadow: none; filter: none; } #vdd-root .vdd-quality-refresh:hover:not(:disabled), #vdd-root .vdd-ghost:hover, #vdd-root .vdd-cancel:hover { border-color: rgba(255, 255, 255, .18); background: var(--surface-high); } #vdd-root .vdd-card:hover { border-color: rgba(255, 255, 255, .16); } #vdd-root .vdd-tile:hover { border-color: rgba(168, 245, 0, .38); transform: translateY(-1px); } #vdd-root .vdd-recent:hover { border-color: rgba(255, 255, 255, .16); background: var(--surface-raised); } } @media (min-width: 761px) { #vdd-panel.vdd-full { top: 50%; left: 50%; width: min(calc(var(--vdd-w, 100vw) - 2rem), 1040px); height: min(calc(var(--vdd-h, 100dvh) - 2rem), 860px); padding: 0; border: 1px solid var(--line); border-radius: 18px; box-shadow: 0 18px 52px rgba(0, 0, 0, .42), 0 2px 8px rgba(0, 0, 0, .24); transform: translate(-50%, calc(-50% + 10px)) scale(.99); transform-origin: center; } #vdd-panel.vdd-full.show { transform: translate(-50%, -50%) scale(1); } #vdd-panel.vdd-full .vdd-head, #vdd-panel.vdd-full .vdd-tabs, #vdd-panel.vdd-full .vdd-body, #vdd-panel.vdd-full .vdd-footer { width: min(100%, 1000px); margin-inline: auto; } #vdd-panel.vdd-full .vdd-head { padding-inline: clamp(20px, 2.2vw, 28px); } #vdd-panel.vdd-full .vdd-tabs { margin-bottom: 15px; } #vdd-panel.vdd-full .vdd-footer { padding-bottom: 12px; } } @media (max-width: 520px) { #vdd-root .vdd-head { padding-top: 14px; padding-bottom: 12px; } #vdd-root .vdd-logo { width: 39px; height: 39px; } #vdd-root .vdd-icon { width: 44px; height: 44px; } #vdd-root .vdd-tabs { margin-bottom: 10px; } } @media (max-width: 420px) { #vdd-root .vdd-head { gap: 6px; padding-inline: 12px; } #vdd-root .vdd-eyebrow, #vdd-root .vdd-title span { display: none; } #vdd-root .vdd-title { font-size: clamp(17px, 5.3vw, 20px); } #vdd-root .vdd-sub { margin-top: 3px; font-size: 11px; } #vdd-root .vdd-icon { width: clamp(40px, 11vw, 44px); height: clamp(40px, 11vw, 44px); } #vdd-root .vdd-tab { gap: 5px; padding-inline: 4px; font-size: clamp(11px, 3.3vw, 12px); } #vdd-root .vdd-tab svg { width: 15px; height: 15px; } #vdd-root .vdd-link-view, #vdd-sites-view, #vdd-diag-view { padding-inline: 13px; } } @media (max-width: 360px) { #vdd-root .vdd-head { display: grid; grid-template-columns: auto minmax(0, 1fr) auto auto; grid-template-areas: "logo main diag close" ". . expand refresh"; gap: 5px; padding: 11px 10px; } #vdd-root .vdd-logo { grid-area: logo; width: 35px; height: 35px; } #vdd-root .vdd-head-main { grid-area: main; } #vdd-root #vdd-diag { grid-area: diag; } #vdd-root #vdd-close { grid-area: close; } #vdd-root #vdd-expand { grid-area: expand; } #vdd-root #vdd-refresh { grid-area: refresh; } #vdd-root .vdd-icon { width: 38px; height: 38px; } #vdd-root .vdd-tabs { margin-inline: 10px; } #vdd-root .vdd-tab { min-height: 47px; flex-direction: column; gap: 2px; font-size: 10px; line-height: 1.05; } #vdd-root .vdd-tab svg { width: 15px; height: 15px; } #vdd-list { padding-inline: 12px; } } @media (max-height: 560px) and (max-width: 760px) { #vdd-root .vdd-head { padding-top: 7px; padding-bottom: 7px; } #vdd-root .vdd-eyebrow, #vdd-root .vdd-footer { display: none; } #vdd-root .vdd-tabs { margin-bottom: 7px; } #vdd-root .vdd-tab { min-height: 38px; } #vdd-root .vdd-logo { width: 34px; height: 34px; } } @media (prefers-reduced-motion: reduce) { #vdd-root *, #vdd-root *::before, #vdd-root *::after, #vdd-open::after { animation-duration: .01ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; transition-duration: .01ms !important; } } `; const CSS2 = ` /* ---- 2.12.0: HD, imagens, áudio, ZIP e MP3 ---- */ #vdd-root .vdd-badge.vdd-chip-hd { border-color: rgba(168, 245, 0, .4); color: var(--accent-ink); background: linear-gradient(180deg, #c9ff45, var(--accent)); font-weight: 780; } #vdd-root .vdd-actions.vdd-actions-sub { gap: 8px; margin-top: -3px; align-items: stretch; } #vdd-root .vdd-actions-sub .vdd-btn { flex: 1 1 auto; min-height: 42px; font-size: 12.5px; } #vdd-root .vdd-mp3q { flex: 0 0 auto; min-width: 0; min-height: 42px; margin: 0; padding: 8px 30px 8px 11px; border: 1px solid var(--line); border-radius: 11px; color: var(--text); background: var(--surface-raised) var(--ic-chev) no-repeat right 8px center / 15px; font-size: 12px; -webkit-appearance: none; appearance: none; } #vdd-root .vdd-mp3q-label { display: flex; width: 100%; align-items: center; justify-content: space-between; gap: 10px; color: var(--muted); font-size: 11.5px; } #vdd-root .vdd-mp3q-label .vdd-mp3q { flex: 1 1 auto; max-width: 12rem; } #vdd-root .vdd-btn.vdd-wait:disabled { color: var(--quiet); border-color: var(--line); background: var(--surface-raised); box-shadow: none; opacity: .55; cursor: not-allowed; } #vdd-root .vdd-btn.vdd-wait:disabled::before { display: none; } #vdd-root .vdd-btn.vdd-wait:disabled::after { display: none; } #vdd-root .vdd-card[data-media="image"] .vdd-card-top, #vdd-root .vdd-card[data-media="zip"] .vdd-card-top { grid-template-columns: minmax(5.5rem, .7fr) minmax(0, 1.3fr); } #vdd-root .vdd-card[data-media="image"] .vdd-thumb, #vdd-root .vdd-card[data-media="zip"] .vdd-thumb { aspect-ratio: 4 / 5; max-height: 15rem; } #vdd-root .vdd-card[data-media="audio"] .vdd-thumb { aspect-ratio: 1 / 1; max-width: 9rem; } #vdd-root .vdd-card[data-media="zip"] { border-color: rgba(168, 245, 0, .3); background: linear-gradient(180deg, rgba(168, 245, 0, .05), transparent 55%), var(--surface); } @container (max-width: 350px) { #vdd-root .vdd-card[data-media="image"] .vdd-thumb, #vdd-root .vdd-card[data-media="zip"] .vdd-thumb { aspect-ratio: 16 / 10; max-height: 12rem; } #vdd-root .vdd-card[data-media="audio"] .vdd-thumb { aspect-ratio: 16 / 7; max-width: none; } } `; // --------------------------------------------------------------------------- // Panel layout // Style injection, page scroll lock, panel sizing and open/close state. // --------------------------------------------------------------------------- function addStyle() { if (document.getElementById('vdd-style')) return; const style = document.createElement('style'); style.id = 'vdd-style'; style.textContent = CSS + CSS2; (document.head || document.documentElement).appendChild(style); } const SCROLL_HOLD = { active: false, pending: false, timer: 0 }; for (const type of ['touchstart', 'touchmove', 'touchend', 'touchcancel', 'wheel']) { window.addEventListener(type, event => { const target = event.target; if (target && target.closest && target.closest('#vdd-root')) event.stopImmediatePropagation(); }, { capture: true, passive: true }); } const PAGE_LOCK = { on: false, y: 0, saved: null }; function syncPageLock() { const panel = document.getElementById('vdd-panel'); const want = Boolean(panel && panel.classList.contains('show') && panel.classList.contains('vdd-full') && window.matchMedia('(pointer: coarse)').matches); const body = document.body, html = document.documentElement; if (!body || want === PAGE_LOCK.on) return; if (want) { PAGE_LOCK.y = window.pageYOffset || html.scrollTop || 0; const keys = ['position', 'top', 'left', 'right', 'width', 'overflow']; PAGE_LOCK.saved = { body: keys.map(k => [k, body.style.getPropertyValue(k), body.style.getPropertyPriority(k)]), html: [html.style.getPropertyValue('overflow'), html.style.getPropertyPriority('overflow')] }; const set = (k, v) => body.style.setProperty(k, v, 'important'); set('position', 'fixed'); set('top', `${-PAGE_LOCK.y}px`); set('left', '0'); set('right', '0'); set('width', '100%'); set('overflow', 'hidden'); html.style.setProperty('overflow', 'hidden', 'important'); PAGE_LOCK.on = true; } else { const saved = PAGE_LOCK.saved; PAGE_LOCK.on = false; PAGE_LOCK.saved = null; if (saved) { for (const [k, v, pr] of saved.body) { if (v) body.style.setProperty(k, v, pr); else body.style.removeProperty(k); } if (saved.html[0]) html.style.setProperty('overflow', saved.html[0], saved.html[1]); else html.style.removeProperty('overflow'); } try { window.scrollTo({ top: PAGE_LOCK.y, left: 0, behavior: 'instant' }); } catch { window.scrollTo(0, PAGE_LOCK.y); } } } function layoutPanel() { const root = document.getElementById('vdd-root'); if (!root) return; const de = document.documentElement, std = document.compatMode === 'CSS1Compat'; const w = Math.round((std && de.clientWidth) || window.innerWidth), h = Math.round((std && de.clientHeight) || window.innerHeight); const probe = document.createElement('div'); probe.style.cssText = 'position:fixed;left:0;top:0;visibility:hidden;pointer-events:none;padding:env(safe-area-inset-top,0px) env(safe-area-inset-right,0px) env(safe-area-inset-bottom,0px) env(safe-area-inset-left,0px)'; root.appendChild(probe); const cs = getComputedStyle(probe); const [sat, sar, sab, sal] = [cs.paddingTop, cs.paddingRight, cs.paddingBottom, cs.paddingLeft].map(v => parseFloat(v) || 0); probe.remove(); const margin = w <= 520 ? 8 : 16, bottom = (w <= 520 ? 82 : 86) + sab; const cw = Math.max(240, Math.min(480, w - 2 * margin - sal - sar)); const ch = Math.max(220, Math.min(810, h - bottom - sat - 12)); const vars = { w, h, sat, sar, sab, sal, cw, ch, cl: w - cw - margin - sar, ct: h - ch - bottom, d: Math.ceil(Math.hypot(w, h)) + 4 }; for (const [key, value] of Object.entries(vars)) root.style.setProperty(`--vdd-${key}`, `${value}px`); Object.assign(VIEW, { w, h, sat, sar, sab, sal, cw, ch }); loadPositions(); placeButton(); placePanel(); } const PANEL_STATE = { shown: false, timer: 0 }; function syncPanelClosing() { const panel = document.getElementById('vdd-panel'); if (!panel) return; if (panel.classList.contains('show')) { clearTimeout(PANEL_STATE.timer); PANEL_STATE.shown = true; if (panel.classList.contains('vdd-closing')) panel.classList.remove('vdd-closing'); } else if (PANEL_STATE.shown) { PANEL_STATE.shown = false; panel.classList.add('vdd-closing'); PANEL_STATE.timer = setTimeout(() => { if (panel.classList.contains('vdd-closing')) panel.classList.remove('vdd-closing'); }, 500); } } const LOGO_SVG = ''; // --------------------------------------------------------------------------- // Preferences // Saved settings through GM_setValue, with localStorage as a fallback. // --------------------------------------------------------------------------- const PREFS = { get(key, fallback = null) { try { if (typeof GM_getValue === 'function') { const v = GM_getValue(`optdowns.${key}`); if (v !== undefined && v !== null && v !== '') return typeof v === 'string' ? JSON.parse(v) : v; } } catch {} try { const raw = localStorage.getItem(`optdowns.${key}`); if (raw != null) return JSON.parse(raw); } catch {} return fallback; }, set(key, value) { const raw = JSON.stringify(value); try { if (typeof GM_setValue === 'function') GM_setValue(`optdowns.${key}`, raw); else if (typeof GM !== 'undefined' && GM.setValue) GM.setValue(`optdowns.${key}`, raw); } catch {} try { localStorage.setItem(`optdowns.${key}`, raw); } catch {} }, }; const VIEW = { w: 390, h: 800, sat: 0, sar: 0, sab: 0, sal: 0, cw: 480, ch: 810 }; // --------------------------------------------------------------------------- // Floating button and dragging // Saved positions for the button and the panel, plus the drag handlers. // --------------------------------------------------------------------------- const POS = { btn: null, panel: null, suppressClick: false, loaded: false }; const clampNum = (value, lo, hi) => Math.min(Math.max(value, lo), Math.max(lo, hi)); const finiteFrac = value => Number.isFinite(Number(value)) ? clampNum(Number(value), 0, 1) : 0; function loadPositions() { if (POS.loaded) return; POS.loaded = true; for (const key of ['btn', 'panel']) { const v = PREFS.get(`pos.${key}`, null); POS[key] = v && typeof v === 'object' && 'fx' in v && 'fy' in v ? { fx: finiteFrac(v.fx), fy: finiteFrac(v.fy) } : null; } } function buttonBounds(btn) { const w = btn.offsetWidth || 56, h = btn.offsetHeight || 56; return { w, h, minX: 8 + VIEW.sal, maxX: VIEW.w - w - 8 - VIEW.sar, minY: 8 + VIEW.sat, maxY: VIEW.h - h - 8 - VIEW.sab }; } function setButtonXY(btn, x, y) { btn.style.setProperty('left', `${Math.round(x)}px`); btn.style.setProperty('top', `${Math.round(y)}px`); btn.style.setProperty('right', 'auto'); btn.style.setProperty('bottom', 'auto'); } function placeButton() { const btn = document.getElementById('vdd-open'); if (!btn) return; if (!POS.btn) { for (const k of ['left', 'top', 'right', 'bottom']) btn.style.removeProperty(k); return; } const b = buttonBounds(btn); setButtonXY(btn, clampNum(b.minX + POS.btn.fx * (b.maxX - b.minX), b.minX, b.maxX), clampNum(b.minY + POS.btn.fy * (b.maxY - b.minY), b.minY, b.maxY)); } function panelBounds(width, height) { return { minX: 8 + VIEW.sal, maxX: VIEW.w - width - 8 - VIEW.sar, minY: 8 + VIEW.sat, maxY: VIEW.h - height - 8 - VIEW.sab }; } function placePanel() { const panel = document.getElementById('vdd-panel'), btn = document.getElementById('vdd-open'); if (!panel) return; if (panel.classList.contains('vdd-full')) { panel.style.removeProperty('--vdd-pl'); panel.style.removeProperty('--vdd-pt'); panel.style.removeProperty('transform-origin'); return; } const width = Math.min(VIEW.cw, VIEW.w - 16), height = Math.min(VIEW.ch, VIEW.h - 16), b = panelBounds(width, height); let x = null, y = null; if (POS.panel) { x = b.minX + POS.panel.fx * (b.maxX - b.minX); y = b.minY + POS.panel.fy * (b.maxY - b.minY); } else if (POS.btn && btn) { const r = btn.getBoundingClientRect(), gap = 10; x = r.left + r.width / 2 > VIEW.w / 2 ? r.right - width : r.left; y = r.top + r.height / 2 > VIEW.h / 2 ? r.top - height - gap : r.bottom + gap; } if (x == null) { panel.style.removeProperty('--vdd-pl'); panel.style.removeProperty('--vdd-pt'); } else { x = clampNum(x, b.minX, b.maxX); y = clampNum(y, b.minY, b.maxY); panel.style.setProperty('--vdd-pl', `${Math.round(x)}px`); panel.style.setProperty('--vdd-pt', `${Math.round(y)}px`); } if (btn) { const r = btn.getBoundingClientRect(), px = x == null ? Math.max(8, VIEW.w - width - 16) : x, py = y == null ? Math.max(8, VIEW.h - height - 82) : y; panel.style.setProperty('transform-origin', `${Math.round(r.left + r.width / 2 - px)}px ${Math.round(r.top + r.height / 2 - py)}px`); } } function installFloatingDrag(root) { const btn = root.querySelector('#vdd-open'); if (!btn) return; let drag = null; btn.addEventListener('pointerdown', event => { if (event.pointerType === 'mouse' && event.button !== 0) return; const r = btn.getBoundingClientRect(); drag = { id: event.pointerId, sx: event.clientX, sy: event.clientY, ox: r.left, oy: r.top, moved: false }; try { btn.setPointerCapture(event.pointerId); } catch {} }); btn.addEventListener('pointermove', event => { if (!drag || event.pointerId !== drag.id) return; const dx = event.clientX - drag.sx, dy = event.clientY - drag.sy; if (!drag.moved) { if (Math.hypot(dx, dy) < 7) return; drag.moved = true; btn.classList.remove('vdd-snapping'); btn.classList.add('vdd-dragging'); } const b = buttonBounds(btn); setButtonXY(btn, clampNum(drag.ox + dx, b.minX, b.maxX), clampNum(drag.oy + dy, b.minY, b.maxY)); event.preventDefault(); }); const finish = event => { if (!drag || (event && event.pointerId !== drag.id)) return; const was = drag; drag = null; try { btn.releasePointerCapture(was.id); } catch {} btn.classList.remove('vdd-dragging'); if (!was.moved) return; POS.suppressClick = true; setTimeout(() => { POS.suppressClick = false; }, 380); const b = buttonBounds(btn), r = btn.getBoundingClientRect(); let x = clampNum(r.left, b.minX, b.maxX), y = clampNum(r.top, b.minY, b.maxY); const magnet = 26; if (x - b.minX < magnet) x = b.minX; else if (b.maxX - x < magnet) x = b.maxX; if (y - b.minY < magnet) y = b.minY; else if (b.maxY - y < magnet) y = b.maxY; btn.classList.add('vdd-snapping'); setButtonXY(btn, x, y); setTimeout(() => btn.classList.remove('vdd-snapping'), 420); POS.btn = { fx: finiteFrac((x - b.minX) / ((b.maxX - b.minX) || 1)), fy: finiteFrac((y - b.minY) / ((b.maxY - b.minY) || 1)) }; PREFS.set('pos.btn', POS.btn); if (!POS.panel) placePanel(); }; btn.addEventListener('pointerup', finish); btn.addEventListener('pointercancel', finish); btn.addEventListener('lostpointercapture', finish); btn.addEventListener('dblclick', event => { if (event.shiftKey) { POS.btn = null; PREFS.set('pos.btn', null); placeButton(); placePanel(); } }); } function installPanelDrag(root) { const panel = root.querySelector('#vdd-panel'), head = root.querySelector('.vdd-head'); if (!panel || !head) return; let drag = null; head.addEventListener('pointerdown', event => { if (panel.classList.contains('vdd-full')) return; if (event.pointerType === 'mouse' && event.button !== 0) return; if (event.target.closest('button, a, input, select, textarea')) return; const r = panel.getBoundingClientRect(); drag = { id: event.pointerId, sx: event.clientX, sy: event.clientY, ox: r.left, oy: r.top, w: r.width, h: r.height, moved: false }; try { head.setPointerCapture(event.pointerId); } catch {} }); head.addEventListener('pointermove', event => { if (!drag || event.pointerId !== drag.id) return; const dx = event.clientX - drag.sx, dy = event.clientY - drag.sy; if (!drag.moved) { if (Math.hypot(dx, dy) < 5) return; drag.moved = true; panel.classList.add('vdd-dragging'); } const b = panelBounds(drag.w, drag.h); panel.style.setProperty('--vdd-pl', `${Math.round(clampNum(drag.ox + dx, b.minX, b.maxX))}px`); panel.style.setProperty('--vdd-pt', `${Math.round(clampNum(drag.oy + dy, b.minY, b.maxY))}px`); event.preventDefault(); }); const finish = event => { if (!drag || (event && event.pointerId !== drag.id)) return; const was = drag; drag = null; try { head.releasePointerCapture(was.id); } catch {} panel.classList.remove('vdd-dragging'); if (!was.moved) return; const b = panelBounds(was.w, was.h), r = panel.getBoundingClientRect(); POS.panel = { fx: finiteFrac((r.left - b.minX) / ((b.maxX - b.minX) || 1)), fy: finiteFrac((r.top - b.minY) / ((b.maxY - b.minY) || 1)) }; PREFS.set('pos.panel', POS.panel); }; head.addEventListener('pointerup', finish); head.addEventListener('pointercancel', finish); head.addEventListener('lostpointercapture', finish); head.addEventListener('dblclick', event => { if (panel.classList.contains('vdd-full') || event.target.closest('button')) return; POS.panel = null; PREFS.set('pos.panel', null); placePanel(); }); } // --------------------------------------------------------------------------- // Panel UI // Builds the floating button, the panel, its tabs and toasts. // --------------------------------------------------------------------------- function buildUi() { if (document.getElementById('vdd-root')) return; addStyle(); const root = document.createElement('div'); root.id = 'vdd-root'; root.innerHTML = `
`; document.documentElement.appendChild(root); new MutationObserver(() => { syncPageLock(); syncPanelClosing(); }).observe(document.getElementById('vdd-panel'), { attributes: true, attributeFilter: ['class'] }); layoutPanel(); let layoutFrame = 0; const scheduleLayout = () => { if (layoutFrame) return; layoutFrame = requestAnimationFrame(() => { layoutFrame = 0; root.classList.add('vdd-resizing'); layoutPanel(); requestAnimationFrame(() => requestAnimationFrame(() => root.classList.remove('vdd-resizing'))); }); }; window.addEventListener('resize', scheduleLayout, { passive: true }); window.addEventListener('orientationchange', () => { scheduleLayout(); setTimeout(scheduleLayout, 350); }, { passive: true }); window.addEventListener('pagehide', () => { if (PAGE_LOCK.on) { document.getElementById('vdd-panel')?.classList.remove('show'); syncPageLock(); } }); for (const type of ['touchmove', 'wheel']) root.addEventListener(type, event => event.stopPropagation(), { passive: true }); document.getElementById('vdd-expand').addEventListener('click', event => { const panel = document.getElementById('vdd-panel'); const full = panel.classList.toggle('vdd-full'); event.currentTarget.setAttribute('aria-pressed', String(full)); event.currentTarget.setAttribute('aria-label', full ? 'Recolher painel' : 'Expandir em tela cheia'); placePanel(); }); root.querySelector('.vdd-body').addEventListener('scroll', () => { SCROLL_HOLD.active = true; clearTimeout(SCROLL_HOLD.timer); SCROLL_HOLD.timer = setTimeout(() => { SCROLL_HOLD.active = false; if (SCROLL_HOLD.pending) { SCROLL_HOLD.pending = false; render(); renderLinkResults(); } }, 220); }, { passive: true }); document.getElementById('vdd-open').addEventListener('click', () => { if (POS.suppressClick) return; APP.panelOpen = !APP.panelOpen; if (APP.panelOpen) layoutPanel(); document.getElementById('vdd-panel').classList.toggle('show', APP.panelOpen); if (APP.panelOpen) { scanAll(); runBootScreen(); } }); document.getElementById('vdd-close').addEventListener('click', () => { APP.panelOpen = false; document.getElementById('vdd-panel').classList.remove('show'); }); document.getElementById('vdd-refresh').addEventListener('click', event => { const refreshButton = event.currentTarget; refreshButton.classList.remove('vdd-spinning'); void refreshButton.offsetWidth; refreshButton.classList.add('vdd-spinning'); setTimeout(() => refreshButton.classList.remove('vdd-spinning'), 900); scanAll(); toast('Lista atualizada'); }); document.getElementById('vdd-link-cancel').addEventListener('click', () => APP.linkController?.abort()); for (const id of ['vdd-link-input', 'vdd-sites-input']) { const field = document.getElementById(id); if (!field) continue; const clear = field.parentElement.querySelector('.vdd-input-clear'); field.addEventListener('input', () => syncInputClear(field)); field.addEventListener('focus', () => { if (field.value) setTimeout(() => { try { field.select(); } catch {} }, 0); }); clear?.addEventListener('click', () => { field.value = ''; syncInputClear(field); field.focus(); }); } document.getElementById('vdd-link-clear-all')?.addEventListener('click', clearLinkAll); document.getElementById('vdd-link-seg')?.addEventListener('click', event => { const b = event.target.closest('button[data-pane]'); if (b) setLinkPane(b.dataset.pane); }); document.getElementById('vdd-paste').addEventListener('click', async () => { const input = document.getElementById('vdd-link-input'); try { input.value = await navigator.clipboard.readText(); syncInputClear(input); input.focus(); } catch { input.focus(); toast('Toque e segure no campo para colar.'); } }); root.addEventListener('keydown', event => { if (event.key === 'Escape') { APP.panelOpen = false; document.getElementById('vdd-panel').classList.remove('show'); document.getElementById('vdd-open').focus(); } if (event.target.matches('.vdd-tab') && ['ArrowLeft', 'ArrowRight'].includes(event.key)) { event.preventDefault(); const order = ['page', 'link', 'sites'], at = Math.max(0, order.indexOf(APP.activeTab)), step = event.key === 'ArrowRight' ? 1 : -1; switchTab(order[(at + step + order.length) % order.length]); document.getElementById(`vdd-tab-${APP.activeTab}`)?.focus(); } }); document.getElementById('vdd-tab-page').addEventListener('click', () => switchTab('page')); document.getElementById('vdd-tab-link').addEventListener('click', () => switchTab('link')); document.getElementById('vdd-link-form').addEventListener('submit', event => { event.preventDefault(); rememberLink(document.getElementById('vdd-link-input')?.value); handleLinkSearch(); }); document.getElementById('vdd-tab-sites').addEventListener('click', () => switchTab('sites')); document.getElementById('vdd-diag').addEventListener('click', () => switchTab(APP.activeTab === 'diag' ? 'page' : 'diag')); document.getElementById('vdd-diag-run').addEventListener('click', runDiagnostics); document.getElementById('vdd-diag-fix').addEventListener('click', applySafeFixes); document.getElementById('vdd-diag-copy').addEventListener('click', copyDiagnostics); const tiles = document.getElementById('vdd-tiles'), sitesInput = document.getElementById('vdd-sites-input'), sitesHint = document.getElementById('vdd-sites-hint'); tiles.innerHTML = SOURCE_TILES.map(t => ``).join(''); tiles.addEventListener('click', event => { const tile = event.target.closest('.vdd-tile'); if (!tile) return; const info = SOURCE_TILES.find(t => t.id === tile.dataset.id); if (!info) return; for (const other of tiles.children) other.classList.toggle('on', other === tile); sitesInput.placeholder = info.example; sitesHint.textContent = `${info.title}: ${info.hint.toLowerCase()}. A disponibilidade depende da fonte.`; sitesInput.focus(); }); document.getElementById('vdd-sites-form').addEventListener('submit', event => { event.preventDefault(); let url; try { url = pastedPublicUrl(sitesInput.value); } catch (error) { sitesHint.textContent = error.message || 'Cole um link público válido.'; return; } rememberLink(url); searchFromSites(url); }); document.getElementById('vdd-recents').addEventListener('click', event => { if (event.target.closest('#vdd-recents-clear')) { try { localStorage.removeItem(RECENT_KEY); } catch {} renderRecents(); return; } const item = event.target.closest('.vdd-recent'); if (item) { const url = readRecents()[Number(item.dataset.i)]; if (url) searchFromSites(url); } }); renderRecents(); installInteractionFx(root); installFloatingDrag(root); installPanelDrag(root); } function switchTab(tab) { const name = ['page', 'link', 'sites', 'diag'].includes(tab) ? tab : 'page'; APP.activeTab = name; const views = { page: 'vdd-page-view', link: 'vdd-link-view', sites: 'vdd-sites-view', diag: 'vdd-diag-view' }; for (const [key, id] of Object.entries(views)) { const view = document.getElementById(id); if (view) view.hidden = key !== name; } for (const key of ['page', 'link', 'sites']) { const element = document.getElementById(`vdd-tab-${key}`), on = key === name; element?.classList.toggle('active', on); element?.setAttribute('aria-selected', String(on)); } const diagButton = document.getElementById('vdd-diag'); diagButton?.classList.toggle('on', name === 'diag'); diagButton?.setAttribute('aria-pressed', String(name === 'diag')); const refresh = document.getElementById('vdd-refresh'); if (refresh) refresh.hidden = name !== 'page'; const scroller = document.querySelector('#vdd-root .vdd-body'); if (scroller && name !== 'page') scroller.scrollTop = 0; if (name === 'link') { updateHeaderCount(APP.linkResults.length ? `${APP.linkResults.length} ${APP.linkResults.length === 1 ? 'opção' : 'opções'} por link` : 'Baixar por link'); setTimeout(() => document.getElementById('vdd-link-input')?.focus(), 0); } else if (name === 'sites') { updateHeaderCount('Fontes e links recentes'); renderRecents(); } else if (name === 'diag') { updateHeaderCount('Diagnóstico e correção'); if (!APP.lastDiag) runDiagnostics(); } else { render(); } } function updateHeaderCount(text) { const count = document.getElementById('vdd-count'); if (count) count.textContent = text || ''; } function toast(message, ms = 2300) { const el = document.getElementById('vdd-toast'); if (!el) return; el.textContent = message; el.classList.add('show'); clearTimeout(APP.toastTimer); APP.toastTimer = setTimeout(() => el.classList.remove('show'), ms); } // --------------------------------------------------------------------------- // URL and media helpers // Small helpers to classify URLs and read the GM request functions. // --------------------------------------------------------------------------- function isHttp(url) { return /^https?:\/\//i.test(String(url || '')); } function absUrl(url, base = location.href) { try { return new URL(url, base).href; } catch { return String(url || ''); } } function isHls(url, type = '') { return /\.m3u8(?:$|[?#&])/i.test(String(url || '')) || /mpegurl/i.test(String(type || '')); } function isDash(url, type = '') { return /\.mpd(?:$|[?#&])/i.test(String(url || '')) || /application\/dash\+xml/i.test(String(type || '')); } function isMp4(url, type = '') { const raw = String(url || ''); let decoded = raw; try { decoded = decodeURIComponent(raw); } catch {} return /\.(?:mp4|m4v)(?:$|[?#&])/i.test(decoded) || /video\/(?:mp4|x-m4v)/i.test(String(type || '')); } function isKnownNonMp4Video(url, type = '') { return /\.(?:webm|ogv|ogg|mov|mkv|flv|ts|m2ts)(?:$|[?#&])/i.test(String(url || '')) || /video\/(?:webm|ogg|quicktime|x-matroska|x-flv|mp2t)/i.test(String(type || '')); } function isBlobUrl(url) { return /^blob:/i.test(String(url || '')); } function isUsableDirectUrl(url) { return isHttp(url) || isBlobUrl(url); } function getGmXhr() { if (typeof GM_xmlhttpRequest === 'function') return GM_xmlhttpRequest; try { if (typeof GM === 'object' && typeof GM?.xmlHttpRequest === 'function') return GM.xmlHttpRequest.bind(GM); } catch {} return null; } function getGmDownload() { if (typeof GM_download === 'function') return GM_download; try { if (typeof GM === 'object' && typeof GM?.download === 'function') return GM.download.bind(GM); } catch {} return null; } function currentRequestReferrer() { const explicit = safeHttpUrl(APP.requestReferrer || ''); return explicit || location.href; } // --------------------------------------------------------------------------- // Network memory // Remembers media URLs seen in requests so they can be offered later. // --------------------------------------------------------------------------- function rememberNetworkMedia(url, type = '', metadata = {}) { const full = absUrl(url); if (!isHttp(full)) return false; const normalizedType = mediaContentType(type); const hls = isHls(full, normalizedType); const directHint = knownDirectFormatHint(full, normalizedType); if (!hls && !directHint && !metadata.trusted) return false; const now = Date.now(); APP.recentNetworkMedia = [ { url: full, type: normalizedType, kind: hls ? 'HLS' : (directHint?.label || 'VÍDEO'), time: now, trusted: Boolean(metadata.trusted), }, ...APP.recentNetworkMedia.filter(item => item.url !== full) ].slice(0, 80); return registerMedia(full, normalizedType, false, { ...networkMetadata(), ...metadata, trusted: Boolean(metadata.trusted || hls || directHint), }); } function rememberBinaryCandidate(url, type = '') { const full = safeHttpUrl(url || ''); if (!full || restrictedPlatformNotice(full)) return false; const now = Date.now(); const normalizedType = mediaContentType(type); APP.recentBinaryCandidates = [ { url: full, type: normalizedType, time: now }, ...APP.recentBinaryCandidates.filter(item => item.url !== full) ].slice(0, 60); return true; } function rememberNetworkRequest(url, type = '', metadata = {}) { const full = safeHttpUrl(url || ''); if (!full || restrictedPlatformNotice(full)) return false; const parsed = (() => { try { return new URL(full); } catch { return null; } })(); if (!parsed) return false; const path = parsed.pathname.toLowerCase(); if (/\.(?:css|js|mjs|map|png|jpe?g|gif|webp|svg|ico|woff2?|ttf|otf)(?:$|[?#])/i.test(path)) return false; const normalizedType = mediaContentType(type); const now = Date.now(); APP.recentNetworkRequests = [ { url: full, type: normalizedType, time: now, status: Number(metadata.status || 0), source: String(metadata.source || ''), }, ...APP.recentNetworkRequests.filter(item => item.url !== full) ].slice(0, 160); return true; } // --------------------------------------------------------------------------- // Formats and file names // Format detection from hints and file bytes, and file name cleanup. // --------------------------------------------------------------------------- function knownDirectFormatHint(url, type = '') { const formats = [ ['mp4', 'video/mp4', 'MP4', /^(?:video\/(?:mp4|x-m4v)|application\/mp4)$/, /\.(?:mp4|m4v)(?:$|[?#&])/i], ['mov', 'video/quicktime', 'MOV', /^video\/quicktime$/, /\.mov(?:$|[?#&])/i], ['webm', 'video/webm', 'WEBM', /^video\/webm$/, /\.webm(?:$|[?#&])/i], ['mkv', 'video/x-matroska', 'MKV', /^(?:video|application)\/x-matroska$/, /\.mkv(?:$|[?#&])/i], ['ogv', 'video/ogg', 'OGV', /^(?:video|application)\/ogg$/, /\.(?:ogv|ogg)(?:$|[?#&])/i], ['ts', 'video/mp2t', 'TS', /^video\/mp2t$/, /\.(?:ts|m2ts)(?:$|[?#&])/i], ['flv', 'video/x-flv', 'FLV', /^video\/x-flv$/, /\.flv(?:$|[?#&])/i], ['avi', 'video/x-msvideo', 'AVI', /^video\/(?:x-msvideo|avi)$/, /\.avi(?:$|[?#&])/i], ]; const mime = mediaContentType(type); let raw = String(url || ''); try { raw = decodeURIComponent(raw); } catch {} const format = formats.find(item => item[3].test(mime)) || formats.find(item => item[4].test(raw)); return format ? { ext: format[0], mime: format[1], label: format[2] } : null; } function isLikelyDirectVideoUrl(url, type = '') { return Boolean(knownDirectFormatHint(url, type)) || isBlobUrl(url); } function directKindFromHint(url, type = '') { return knownDirectFormatHint(url, type)?.label || 'VÍDEO'; } function cleanFilenameBase(value) { let base = cleanName(value || 'video') .replace(/(?:\.(?:mp4|m4v|mov|webm|mkv|ogv|ogg|ts|m2ts|flv|avi|m3u8|bin|dat|download|file))+$/i, '') .replace(/[.\s]+$/g, '') || 'video'; return base.slice(0, 115).replace(/[.\s]+$/g, '') || 'video'; } function filenameForFormat(value, format) { const ext = String(format?.ext || '').toLowerCase().replace(/[^a-z0-9]/g, '') || 'bin'; return `${cleanFilenameBase(value)}.${ext}`; } function ensureBlobMime(blob, mime) { if (!blob?.size) return blob; const wanted = String(mime || '').split(';', 1)[0].trim().toLowerCase(); const current = String(blob.type || '').split(';', 1)[0].trim().toLowerCase(); if (!wanted || current === wanted) return blob; return new Blob([blob], { type: wanted }); } function isoBrand(bytes, ftypStart) { if (!bytes || ftypStart < 0 || bytes.byteLength < ftypStart + 12) return ''; return String.fromCharCode( bytes[ftypStart + 8], bytes[ftypStart + 9], bytes[ftypStart + 10], bytes[ftypStart + 11] ); } function detectMediaFormatFromBytes(bytes, contentType = '', url = '') { if (!bytes?.byteLength) return null; const mime = mediaContentType(contentType); const hint = knownDirectFormatHint(url, mime); const ftypStart = findMp4Start(bytes); if (ftypStart >= 0) { const brand = isoBrand(bytes, ftypStart); if (/^qt\s\s$/i.test(brand)) return { ext: 'mov', mime: 'video/quicktime', label: 'MOV', confirmed: true }; return { ext: 'mp4', mime: 'video/mp4', label: 'MP4', confirmed: true }; } if (bytes.byteLength >= 4 && bytes[0] === 0x1a && bytes[1] === 0x45 && bytes[2] === 0xdf && bytes[3] === 0xa3) { const header = new TextDecoder().decode(bytes.subarray(0, Math.min(4096, bytes.length))); if (/matroska/.test(header)) return { ext: 'mkv', mime: 'video/x-matroska', label: 'MKV', confirmed: true }; if (/webm/.test(header)) return { ext: 'webm', mime: 'video/webm', label: 'WEBM', confirmed: true }; return hint ? { ...hint, confirmed: false } : null; } if (bytes.byteLength >= 4 && bytes[0] === 0x4f && bytes[1] === 0x67 && bytes[2] === 0x67 && bytes[3] === 0x53) { return { ext: 'ogv', mime: 'video/ogg', label: 'OGV', confirmed: true }; } if (looksLikeTs(bytes)) { return { ext: 'ts', mime: 'video/mp2t', label: 'TS', confirmed: true }; } try { const sample = new TextDecoder('utf-8').decode(bytes.slice(0, Math.min(bytes.byteLength, 256))); if (/^\s*#EXTM3U/i.test(sample)) { return { ext: 'm3u8', mime: 'application/vnd.apple.mpegurl', label: 'HLS', confirmed: true, hls: true }; } } catch {} if (bytes.length >= 12 && fourCc(bytes, 0) === 'RIFF' && fourCc(bytes, 8) === 'AVI ') { return { ext: 'avi', mime: 'video/x-msvideo', label: 'AVI', confirmed: true }; } if (bytes.length >= 9 && bytes[0] === 70 && bytes[1] === 76 && bytes[2] === 86 && bytes[3] === 1) { return { ext: 'flv', mime: 'video/x-flv', label: 'FLV', confirmed: true }; } return hint ? { ...hint, confirmed: false } : null; } async function detectBlobFormat(blob, urlOrName = '') { if (!blob?.size) return null; const probeSize = Math.min(blob.size, 1024 * 1024); const bytes = new Uint8Array(await blob.slice(0, probeSize).arrayBuffer()); return detectMediaFormatFromBytes(bytes, blob.type, urlOrName); } function contentDispositionFilename(value) { const raw = String(value || ''); let match = raw.match(/filename\*=UTF-8''([^;]+)/i); if (match) { try { return cleanName(decodeURIComponent(match[1].trim().replace(/^"|"$/g, ''))); } catch {} } match = raw.match(/filename\s*=\s*"([^"]+)"|filename\s*=\s*([^;]+)/i); return cleanName(match?.[1] || match?.[2] || ''); } function cleanName(text) { return String(text || 'video') .replace(/[<>:"/\\|?*\x00-\x1F]/g, '') .replace(/\s+/g, ' ') .trim() .slice(0, 120) || 'video'; } function forceMp4Filename(value) { return filenameForFormat(value, { ext: 'mp4' }); } // --------------------------------------------------------------------------- // Page metadata // Titles, thumbnails, durations and identity keys for media items. // --------------------------------------------------------------------------- function pageTitle() { const og = document.querySelector('meta[property="og:title"]')?.content; return cleanName(og || document.title || 'video'); } function cleanDisplayText(value, maxLength = 180) { return String(value || '') .replace(/[\x00-\x1F\x7F]/g, ' ') .replace(/\s+/g, ' ') .trim() .slice(0, maxLength); } function safeHttpUrl(value, base = location.href) { if (!String(value || '').trim()) return ''; const full = absUrl(value, base); return isHttp(full) ? full : ''; } function pageThumbnail(root = document, base = location.href) { const selectors = [ 'meta[property="og:image"]', 'meta[property="og:image:secure_url"]', 'meta[name="twitter:image"]', 'meta[name="twitter:image:src"]', ]; for (const selector of selectors) { const value = root.querySelector?.(selector)?.getAttribute('content'); const full = safeHttpUrl(value, base); if (full) return full; } return ''; } function sourceHost(url) { try { return new URL(url).hostname.replace(/^www\./i, ''); } catch { return ''; } } function formatDuration(seconds) { const total = Math.round(Number(seconds || 0)); if (!Number.isFinite(total) || total <= 0 || total > 7 * 24 * 60 * 60) return ''; const hours = Math.floor(total / 3600); const minutes = Math.floor((total % 3600) / 60); const secs = total % 60; return hours ? `${hours}:${String(minutes).padStart(2, '0')}:${String(secs).padStart(2, '0')}` : `${minutes}:${String(secs).padStart(2, '0')}`; } function formatResolution(width, height) { const w = Math.round(Number(width || 0)); const h = Math.round(Number(height || 0)); if (w <= 0 || h <= 0) return ''; return `${w}×${h}`; } function mediaTitleFromUrl(url) { try { const raw = decodeURIComponent(new URL(url).pathname.split('/').filter(Boolean).pop() || ''); const name = cleanDisplayText(raw.replace(/\.(?:mp4|m4v|m3u8|mp3|m4a|aac|ogg|oga|opus|wav|flac)(?:$|[?#])/i, '').replace(/[-_]+/g, ' '), 90); if (name && !/^[a-f0-9-]{18,}$/i.test(name) && !/^\d{8,}$/.test(name)) return name; } catch {} return ''; } function mediaIdentityKey(url, kind = '') { try { const parsed = new URL(url); parsed.hash = ''; const volatile = /^(?:token|sig|signature|expires?|expiry|policy|key-pair-id|hdnts|auth|authorization|jwt|session|x-amz-.+)$/i; [...parsed.searchParams.keys()].forEach(key => { if (volatile.test(key)) parsed.searchParams.delete(key); }); parsed.searchParams.sort?.(); return `${kind}:${parsed.origin}${parsed.pathname}${parsed.search}`; } catch { return `${kind}:${String(url || '')}`; } } function videoElementId(video) { if (!APP.videoIds.has(video)) APP.videoIds.set(video, APP.nextVideoId++); return APP.videoIds.get(video); } function videoMetadata(video, index = 0) { const ownerSelector = 'article, figure, li, [role="listitem"], [data-video-id], [data-e2e*="video"], [class*="video"]'; let owner = video.closest?.(ownerSelector); if (owner === video) owner = video.parentElement?.closest?.(ownerSelector) || video.parentElement; const scopedOwner = owner && owner !== document.body && owner !== document.documentElement ? owner : null; const titleNode = scopedOwner?.querySelector?.('h1, h2, h3, figcaption, [data-e2e*="desc"], [class*="caption"], [class*="title"]'); const image = scopedOwner?.querySelector?.('img'); const explicitTitle = [ video.getAttribute?.('title'), video.getAttribute?.('aria-label'), video.dataset?.title, titleNode?.textContent, image?.getAttribute?.('alt'), ].map(value => cleanDisplayText(value)).find(value => value && value.length >= 2); const poster = safeHttpUrl( video.poster || video.getAttribute?.('poster') || image?.currentSrc || image?.src || image?.getAttribute?.('src') ); return { title: explicitTitle || pageTitle(), titlePriority: explicitTitle ? 4 : 1, thumbnail: poster || pageThumbnail(), thumbnailPriority: poster ? 4 : 1, duration: Number.isFinite(Number(video.duration)) ? Number(video.duration) : 0, width: Number(video.videoWidth || 0), height: Number(video.videoHeight || 0), videoKey: `video-${videoElementId(video)}`, videoIndex: index, fromDom: true, }; } function removeMediaEntry(url, info) { APP.directUrls.delete(url); APP.hlsUrls.delete(url); APP.mediaInfo.delete(url); if (info?.identity && APP.mediaIdentityUrls.get(info.identity) === url) APP.mediaIdentityUrls.delete(info.identity); } function dropStaleDomMedia(live) { let dropped = false; for (const [url, info] of [...APP.mediaInfo]) { if (!/^video-\d+$/.test(info.videoKey || '') || live.has(info.videoKey)) continue; removeMediaEntry(url, info); if (info.identity) APP.dropped.set(info.identity, Date.now()); dropped = true; } return dropped; } // --------------------------------------------------------------------------- // Media detection // Collects candidates from the DOM, performance entries and page metadata, then registers them. // --------------------------------------------------------------------------- function collectFromDom() { let changed = false; const live = new Set(); document.querySelectorAll('video').forEach((video, index) => { const metadata = videoMetadata(video, index); live.add(metadata.videoKey); const items = [ { url: video.currentSrc, type: video.getAttribute('type') || '' }, { url: video.src, type: video.getAttribute('type') || '' }, ]; video.querySelectorAll('source').forEach(source => { items.push({ url: source.src || source.getAttribute('src'), type: source.type || '' }); }); for (const item of items) { if (registerMedia(item.url, item.type, true, metadata)) changed = true; } }); if (dropStaleDomMedia(live)) changed = true; return changed; } function collectFromPerformance() { let changed = false; try { const defaults = { title: pageTitle(), titlePriority: 1, thumbnail: pageThumbnail(), thumbnailPriority: 1, }; const allEntries = performance.getEntriesByType('resource'); if (allEntries.length < APP.perfSeen) APP.perfSeen = 0; const newEntries = allEntries.slice(APP.perfSeen); APP.perfSeen = allEntries.length; newEntries.forEach(entry => { if (APP.navAt && entry.startTime < APP.navAt) return; const initiator = String(entry?.initiatorType || '').toLowerCase(); const mediaInitiator = /^(?:video|audio|media)$/.test(initiator); if (/^(?:fetch|xmlhttprequest|video|audio|media)$/.test(initiator)) { rememberNetworkRequest(entry.name, '', { source: `performance:${initiator}` }); } if (mediaInitiator) { if (rememberNetworkMedia(entry.name, '', { ...defaults, trusted: true, label: 'Recurso carregado pelo player' })) changed = true; } else if (registerMedia(entry.name, '', false, defaults)) { changed = true; } }); } catch {} return changed; } function collectFromPageMetadata() { let changed = false; try { const candidates = extractPublicMediaCandidates(document, location.href, { scanRawScripts: false }); if (publicPlatform(location.href) && (APP.socialScanPage !== location.href || Date.now() - APP.socialScanAt > 5000)) { APP.socialScanPage = location.href; APP.socialScanAt = Date.now(); candidates.push(...extractSocialCandidates(document, location.href)); } for (const candidate of candidates) { if (registerMedia(candidate.url, candidate.type, true, candidate)) changed = true; } } catch (error) { console.info('[VDD] Metadados públicos da página não puderam ser lidos:', error); } return changed; } function registerMedia(url, type = '', fromVideoElement = false, metadata = {}) { if (!url) return false; const full = absUrl(url); if (!isUsableDirectUrl(full)) return false; if (!isHls(full, type) && APP.hlsResources.has(mediaIdentityKey(full, 'DIRECT'))) return false; if (responseLooksLikeHtml(type)) return false; if (isHttp(full) && (restrictedPlatformNotice(full) || restrictedPlatformNotice(location.href))) return false; if (/\/(?:blank|empty|transparent|spacer)\.(?:mp4|webm)(?:$|[?#])/i.test(full)) return false; let kind = ''; if (isHls(full, type)) { kind = 'HLS'; } else if (isLikelyDirectVideoUrl(full, type) || fromVideoElement || metadata.trusted) { kind = directKindFromHint(full, type); } if (!kind) return false; const identityKind = kind === 'HLS' ? 'HLS' : 'DIRECT'; const identity = mediaIdentityKey(full, identityKind); if (fromVideoElement) APP.dropped.delete(identity); else if (APP.dropped.has(identity)) return false; const previousUrl = APP.mediaIdentityUrls.get(identity); const previous = APP.mediaInfo.get(previousUrl || full); const targetSet = kind === 'HLS' ? APP.hlsUrls : APP.directUrls; const otherSet = kind === 'HLS' ? APP.directUrls : APP.hlsUrls; if (previousUrl && previousUrl !== full) { targetSet.delete(previousUrl); otherSet.delete(previousUrl); APP.mediaInfo.delete(previousUrl); } const incomingTitle = cleanDisplayText(metadata.title) || mediaTitleFromUrl(full) || pageTitle(); const incomingTitlePriority = Number(metadata.titlePriority || (metadata.title ? 2 : 0)); const incomingThumbnail = safeHttpUrl(metadata.thumbnail || ''); const incomingThumbnailPriority = Number(metadata.thumbnailPriority || (incomingThumbnail ? 2 : 0)); const keepPreviousTitle = previous && Number(previous.titlePriority || 0) > incomingTitlePriority; const keepPreviousThumbnail = previous && Number(previous.thumbnailPriority || 0) > incomingThumbnailPriority; const info = { ...(previous || {}), kind, url: full, identity, order: previous?.order || APP.nextMediaOrder++, title: keepPreviousTitle ? previous.title : incomingTitle, titlePriority: keepPreviousTitle ? previous.titlePriority : incomingTitlePriority, thumbnail: keepPreviousThumbnail ? previous.thumbnail : (incomingThumbnail || previous?.thumbnail || ''), thumbnailPriority: keepPreviousThumbnail ? previous.thumbnailPriority : incomingThumbnailPriority, duration: Number(metadata.duration || previous?.duration || 0), width: Number(metadata.width || previous?.width || 0), height: Number(metadata.height || previous?.height || 0) || Number((full.match(/[/_.=-](144|240|360|480|540|576|720|1080|1440|2160|4320)p?(?=$|[/_.?&=-])/i) || [])[1] || 0), noWatermark: metadata.noWatermark === true || previous?.noWatermark === true, bandwidth: Number(metadata.bandwidth || previous?.bandwidth || 0), gen: APP.navGen, lastSeen: Date.now(), ownerKey: previous?.ownerKey || metadata.ownerKey || ((!metadata.videoKey && !fromVideoElement && APP.activeVideoKey && Date.now() - APP.activeAt < 20000) ? APP.activeVideoKey : ''), videoKey: metadata.videoKey || previous?.videoKey || '', videoIndex: Number.isFinite(Number(metadata.videoIndex)) ? Number(metadata.videoIndex) : previous?.videoIndex, preferred: Boolean(metadata.preferred || previous?.preferred), label: cleanDisplayText(metadata.label || previous?.label || '', 60), pageUrl: safeHttpUrl(metadata.pageUrl || previous?.pageUrl || location.href), type: mediaContentType(type || metadata.type || previous?.type || ''), ...Object.fromEntries(['platform', 'postId', 'sourceRole', 'sourceKey', 'alternatives', 'detectedAt', 'hasAudio', 'codec'].filter(key => metadata[key] !== undefined).map(key => [key, metadata[key]])), }; const changed = !previous || previousUrl !== full || previous.title !== info.title || previous.thumbnail !== info.thumbnail || previous.duration !== info.duration || previous.width !== info.width || previous.height !== info.height || previous.preferred !== info.preferred || previous.kind !== info.kind || previous.type !== info.type; APP.mediaIdentityUrls.set(identity, full); APP.mediaInfo.set(full, info); targetSet.add(full); otherSet.delete(full); return changed; } function queueRender() { if (!APP.panelOpen || APP.activeTab !== 'page' || APP.working) return; clearTimeout(APP.renderTimer); APP.renderTimer = setTimeout(render, 120); } function scanAll() { collectFromDom(); collectFromPerformance(); collectFromPageMetadata(); collectKnownPublicSiteMedia(location.href).catch(() => {}); if (APP.mediaInfo.size > 400) { const oldest = [...APP.mediaInfo].sort((a, b) => (a[1].lastSeen || 0) - (b[1].lastSeen || 0)); oldest.slice(0, oldest.length - 300).forEach(([url, info]) => removeMediaEntry(url, info)); } if (APP.panelOpen) render(); } function onNavigated() { APP.navGen++; APP.navAt = performance.now(); APP.perfSeen = 0; APP.dropped.clear(); APP.activeVideoKey = ''; APP.expandedGroups.clear(); collectFromDom(); for (const [url, info] of [...APP.mediaInfo]) if ((info.gen || 0) < APP.navGen) removeMediaEntry(url, info); scanAll(); if (APP.panelOpen) render(); } function watchNavigation() { let last = location.href; const routeKey = href => { try { const u = new URL(href); return `${u.origin}${u.pathname}${u.search}${/^#[/!]/.test(u.hash) ? u.hash : ''}`; } catch { return href; } }; const check = () => { if (location.href === last) return; const changed = routeKey(location.href) !== routeKey(last); last = location.href; if (changed) onNavigated(); }; window.addEventListener('popstate', check); window.addEventListener('hashchange', check); window.addEventListener('pageshow', check); try { window.navigation?.addEventListener?.('navigatesuccess', check); } catch {} setInterval(() => { if (!document.hidden) check(); }, 1000); } function onVideoEvent(event) { const video = event.target; if (event.type === 'play' && video?.tagName === 'VIDEO') { APP.activeVideoKey = `video-${videoElementId(video)}`; APP.activeAt = Date.now(); } clearTimeout(APP.eventScanTimer); APP.eventScanTimer = setTimeout(scanAll, 250); } // --------------------------------------------------------------------------- // Grouping // Groups variants of the same video and ranks them by quality. // --------------------------------------------------------------------------- function decorateDuplicateTitles(items) { const totals = new Map(); const seen = new Map(); items.forEach(item => { const key = String(item.title || '').toLocaleLowerCase(); totals.set(key, (totals.get(key) || 0) + 1); }); return items.map(item => { const key = String(item.title || '').toLocaleLowerCase(); if ((totals.get(key) || 0) <= 1) return { ...item, displayTitle: item.title }; const position = (seen.get(key) || 0) + 1; seen.set(key, position); return { ...item, displayTitle: `${item.title} · Opção ${position}` }; }); } function variantStem(url) { try { const parsed = new URL(url); if (parsed.protocol === 'blob:') return ''; const quality = '(?:144|240|360|426|480|540|576|640|720|960|1080|1280|1440|1920|2160|2560|3840|4320)p?|\\d{3,4}x\\d{3,4}|uhd|fhd|qhd|hd|sd|lq|hq|low|mid|medium|high|orig|original|source|master|index|playlist|manifest|main|stream|video|mp4|hls|avc1?|hevc|h26[45]|vp9|av1'; let path = decodeURIComponent(parsed.pathname).toLowerCase().replace(/\.[a-z0-9]{2,5}$/, ''); path = path.replace(new RegExp(`(^|[/_.\\-])(?:${quality})(?=$|[/_.\\-])`, 'g'), '$1').replace(/\/{2,}/g, '/').replace(/[/_.\-]+$/g, ''); let host = parsed.hostname; while (/^(?:www|cdn\d*|v\d*|s\d*|video\d*)\./.test(host)) host = host.replace(/^[^.]+\./, ''); return path.length >= 4 ? `${host}${path}` : ''; } catch { return ''; } } function compareVariants(a, b) { return Number(b.noWatermark === true) - Number(a.noWatermark === true) || Number(b.height || 0) - Number(a.height || 0) || Number(b.width || 0) - Number(a.width || 0) || Number(b.bandwidth || 0) - Number(a.bandwidth || 0) || Number(Boolean(b.preferred)) - Number(Boolean(a.preferred)) || Number(a.order || 0) - Number(b.order || 0); } function groupMediaItems(items) { const live = new Set(), playing = []; document.querySelectorAll('video').forEach(video => { const key = `video-${videoElementId(video)}`; live.add(key); if (!video.paused && !video.ended && video.readyState > 2) playing.push(key); }); const activeKey = playing.includes(APP.activeVideoKey) ? APP.activeVideoKey : (playing[0] || ''); const ownerOf = item => live.has(item.videoKey) ? item.videoKey : (live.has(item.ownerKey) ? item.ownerKey : ''); const stemOwner = new Map(); for (const item of items) { const owner = ownerOf(item), stem = owner ? variantStem(item.url) : ''; if (stem && !stemOwner.has(stem)) stemOwner.set(stem, owner); } const keyOf = item => { const owner = ownerOf(item); if (owner) return `v:${owner}`; if (item.platform && item.postId) return `p:${item.platform}:${item.postId}`; const stem = variantStem(item.url); if (stem) return stemOwner.has(stem) ? `v:${stemOwner.get(stem)}` : `s:${stem}`; return `u:${item.identity || item.url}`; }; const groups = new Map(); for (const item of items) { const key = keyOf(item); if (!groups.has(key)) groups.set(key, []); groups.get(key).push(item); } const ordered = [...groups].map(([key, members]) => { members.sort(compareVariants); const index = Math.min(...members.map(m => Number.isFinite(Number(m.videoIndex)) ? Number(m.videoIndex) : 999999)); return { key, members, index, order: Math.min(...members.map(m => Number(m.order || 0))), active: Boolean(activeKey) && key === `v:${activeKey}` }; }).sort((a, b) => Number(b.active) - Number(a.active) || a.index - b.index || a.order - b.order).slice(0, 60); const titles = decorateDuplicateTitles(ordered.map(group => group.members[0])); return ordered.flatMap((group, i) => group.members.slice(0, 8).map((member, rank, list) => ({ ...member, displayTitle: titles[i].displayTitle, groupKey: group.key, groupIndex: rank, groupSize: list.length, groupActive: group.active }))); } function pageMediaItems() { return groupMediaItems([ ...[...APP.directUrls].map(url => APP.mediaInfo.get(url) || { kind: directKindFromHint(url), url, title: pageTitle() }), ...[...APP.hlsUrls].map(url => APP.mediaInfo.get(url) || { kind: 'HLS', url, title: pageTitle() }), ]); } // --------------------------------------------------------------------------- // Rendering // Draws the media list, groups and result cards. // --------------------------------------------------------------------------- function render() { if (APP.working) return; if (SCROLL_HOLD.active) { SCROLL_HOLD.pending = true; return; } const list = document.getElementById('vdd-list'); if (!list) return; const items = pageMediaItems(); if (APP.activeTab === 'page') { const videos = new Set(items.map(item => item.groupKey)).size; updateHeaderCount(`${videos} vídeo${videos === 1 ? '' : 's'}${items.length > videos ? ` · ${items.length} fontes` : ' encontrado' + (videos === 1 ? '' : 's')}`); } renderMediaList( list, items, 'Nenhum vídeo encontrado ainda.

Inicie a reprodução e toque em atualizar.', 'page' ); } function renderLinkResults() { const list = document.getElementById('vdd-link-results'); if (!list) return; if (LISTING.state && !LISTING.state.related) return; if (SCROLL_HOLD.active) { SCROLL_HOLD.pending = true; return; } const items = decorateDuplicateTitles(APP.linkResults); renderMediaList(list, items, '', 'link'); if (APP.activeTab === 'link') { updateHeaderCount(items.length ? `${items.length} opção${items.length === 1 ? '' : 'ões'} encontrada${items.length === 1 ? '' : 's'}` : 'Baixar por link'); } } function renderMediaList(list, items, emptyMessage, sourceScope = 'page') { const signature = JSON.stringify(items.map(item => [item.url, item.kind, item.title, item.displayTitle, item.height, item.noWatermark, item.groupKey, item.groupIndex, item.groupSize, item.groupActive, item.mediaType, item.imageIndex, item.qualityLabel, item.via])); if (list.dataset.signature === signature) return; const children = [...list.children]; const prepared = children.filter(card => card.dataset.prepared === 'true'); const preparedIds = new Set(prepared.map(card => card.dataset.identity)); const reusable = new Map(children.filter(card => card.dataset.identity && card.dataset.prepared !== 'true').map(card => [card.dataset.identity, card])); const scroller = list.closest('.vdd-body'); const savedTop = scroller ? scroller.scrollTop : 0; list.dataset.signature = signature; const entries = [], seen = new Set(); items.forEach((item, index) => { const identity = mediaIdentityKey(item.url, item.kind); if (preparedIds.has(identity) || seen.has(identity)) return; seen.add(identity); const cardSignature = JSON.stringify([item.url, item.kind, item.title, item.displayTitle, item.thumbnail, item.duration, item.width, item.height, item.noWatermark, item.preferred, item.label, item.hasAudio, item.mediaType, item.imageIndex, item.qualityLabel, item.via]); let card = reusable.get(identity); if (!card || card.dataset.sig !== cardSignature) { card = createMediaCard(item, index, sourceScope); card.dataset.identity = identity; card.dataset.sig = cardSignature; card.style.setProperty('--i', String(Math.min(index, 8))); card.classList.add('vdd-enter'); const doneEnter = event => { if (event.target !== card) return; card.classList.remove('vdd-enter'); card.removeEventListener('animationend', doneEnter); }; card.addEventListener('animationend', doneEnter); } entries.push([card, item]); }); list.replaceChildren(...prepared, ...entries.map(([card]) => card)); if (!entries.length && !prepared.length && emptyMessage) list.innerHTML = `
${emptyMessage}
`; if (sourceScope === 'page') entries.forEach(([card, item]) => syncGroupUi(list, card, item)); if (scroller && savedTop && scroller.scrollTop !== savedTop) scroller.scrollTop = savedTop; } function syncGroupUi(list, card, item) { const primary = !item.groupIndex, expanded = APP.expandedGroups.has(item.groupKey); card.dataset.group = item.groupKey || ''; card.dataset.groupIndex = String(item.groupIndex || 0); card.classList.toggle('vdd-variant', !primary); card.hidden = !primary && !expanded; card.querySelector('.vdd-variants-toggle')?.remove(); card.querySelector('.vdd-chip-active')?.remove(); if (!primary) { card.querySelector('.vdd-badge.preferred')?.remove(); return; } if (item.groupActive) card.querySelector('.vdd-meta-row')?.insertAdjacentHTML('afterbegin', 'Reproduzindo'); if (item.groupSize > 1) { const count = item.groupSize - 1; const toggle = document.createElement('button'); toggle.type = 'button'; toggle.className = `vdd-variants-toggle${expanded ? ' open' : ''}`; toggle.setAttribute('aria-expanded', String(expanded)); toggle.innerHTML = `${count} outra${count === 1 ? '' : 's'} variante${count === 1 ? '' : 's'} (qualidade/formato)`; toggle.addEventListener('click', () => { const open = !APP.expandedGroups.has(item.groupKey); if (open) APP.expandedGroups.add(item.groupKey); else APP.expandedGroups.delete(item.groupKey); toggle.classList.toggle('open', open); toggle.setAttribute('aria-expanded', String(open)); for (const other of list.children) if (other.dataset.group === item.groupKey && other.dataset.groupIndex !== '0') other.hidden = !open; }); card.querySelector('.vdd-card-top')?.after(toggle); } } const NOTE_SVG = ''; const IMAGE_SVG = ''; const VIDEO_SVG = ''; function mp3QualitySelect() { return ``; } // Builds one media card: thumbnail, title, quality choices and the download button. function createMediaCard(item, index, sourceScope = 'page') { if (item.mediaType === 'youtube') return createYouTubeCard(item, index, sourceScope); const card = document.createElement('article'); card.className = 'vdd-card'; card.dataset.media = item.mediaType || 'video'; const title = cleanDisplayText(item.displayTitle || item.title) || (item.mediaType === 'image' ? `Foto ${index + 1}` : item.mediaType === 'audio' ? 'Áudio' : `Vídeo ${index + 1}`); const resolution = formatResolution(item.width, item.height) || (item.height ? `${item.height}p` : ''); const duration = formatDuration(item.duration); const host = sourceHost(item.pageUrl || item.url) || sourceHost(item.url) || 'Fonte de mídia'; const sourceLabel = item.label ? `${host} · ${item.label}` : host; const hdClass = /^(?:HD|Full HD|2K|4K|Original)/.test(item.qualityLabel || '') ? ' vdd-chip-hd' : ''; const chips = [ `${escapeHtml(item.kind)}`, item.qualityLabel && item.mediaType !== 'audio' ? `${escapeHtml(item.qualityLabel)}` : '', resolution && !(item.height && !item.width) && item.mediaType !== 'zip' ? `${escapeHtml(resolution)}` : '', item.mediaType === 'zip' ? `${Number(item.imageCount || item.images?.length || 0)} imagens` : '', item.hasAudio === false ? 'Fonte informa: sem áudio' : '', item.noWatermark === true ? 'Sem marca d’água · fonte' : '', item.preferred ? 'Melhor opção' : '', ].filter(Boolean).join(''); const hdWord = /^(?:HD|Full HD|2K|4K|Original)/.test(item.qualityLabel || ''); const mainLabel = item.mediaType === 'zip' ? `Baixar as ${Number(item.imageCount || item.images?.length || 0)} fotos · ZIP` : item.mediaType === 'image' ? `Baixar imagem${hdWord ? ' em HD' : ''}` : item.mediaType === 'audio' ? 'Baixar MP3' : item.kind === 'HLS' ? 'Ver qualidades' : `Baixar ${item.qualityLabel && item.qualityLabel !== 'SD' ? `${escapeHtml(item.qualityLabel)} · ` : ''}${escapeHtml(item.kind || 'vídeo')}`; const mainClass = item.mediaType === 'zip' ? ' vdd-btn-zip' : item.mediaType === 'image' ? ' vdd-btn-img' : item.mediaType === 'audio' ? ' vdd-btn-mp3' : ''; const fallbackIcon = item.mediaType === 'audio' ? NOTE_SVG : item.mediaType === 'image' || item.mediaType === 'zip' ? IMAGE_SVG : VIDEO_SVG; const canMp3 = !item.mediaType && item.hasAudio !== false; const subRow = canMp3 ? `
${mp3QualitySelect()}
` : item.mediaType === 'audio' ? `
` : ''; card.innerHTML = `
${duration ? `${escapeHtml(duration)}` : ''}
${escapeHtml(title)}
${siteIcon(item.pageUrl || item.url)}${escapeHtml(sourceLabel)}
${chips}
${item.kind === 'HLS' ? '
' : ''}
${subRow}
Preparando...
`; const thumb = card.querySelector('.vdd-thumb'); const thumbnail = normalizeThumbUrl(item.thumbnail || (item.mediaType === 'image' ? item.url : '')); if (thumbnail) loadThumbInto(thumb, thumbnail, item.pageUrl || ''); if (item.height && !item.mediaType) setResolutionChips(card, [item.height], Number(item.height)); attachDownloadHandler(card, { ...item, sourceScope }, item.fileBase || title); return card; } // --------------------------------------------------------------------------- // Download queue // Runs one download at a time and queues the rest. // --------------------------------------------------------------------------- const DL_QUEUE = []; function dlQueueRefresh() { DL_QUEUE.forEach((entry, i) => entry.show(i + 1, DL_QUEUE.length)); } function dlQueuePush(entry) { DL_QUEUE.push(entry); dlQueueRefresh(); return DL_QUEUE.length; } function dlQueueRemove(entry) { const i = DL_QUEUE.indexOf(entry); if (i < 0) return false; DL_QUEUE.splice(i, 1); dlQueueRefresh(); return true; } function dlQueueNext() { if (APP.working || APP.linkBusy) return false; while (DL_QUEUE.length) { const entry = DL_QUEUE.shift(); dlQueueRefresh(); if (entry.card && !entry.card.isConnected) continue; entry.start(); return true; } return false; } // --------------------------------------------------------------------------- // Download handler // Connects a card to the right download path, with progress, cancel and queueing. // --------------------------------------------------------------------------- function attachDownloadHandler(card, item, titleArg) { const titleNow = () => (typeof titleArg === 'function' ? titleArg() : titleArg); const button = card.querySelector('.vdd-btn'); const mp3Button = card.querySelector('.vdd-btn-mp3-sub'); const mp3Select = card.querySelector('.vdd-mp3q'); const mp3Default = mp3Button?.textContent || ''; let mode = 'video', queuedMode = 'video'; let pendingSave = null; let pendingOpen = null; const labelBtn = () => (mode === 'mp3' && mp3Button ? mp3Button : button); const setBusy = busy => { button.disabled = busy; if (mp3Button) mp3Button.disabled = busy || Boolean(pendingSave) || Boolean(pendingOpen); button.classList.toggle('vdd-wait', busy && labelBtn() !== button); mp3Button?.classList.toggle('vdd-wait', busy && labelBtn() !== mp3Button); }; const progress = card.querySelector('.vdd-progress'); const bar = card.querySelector('.vdd-progress-bar'); const text = card.querySelector('.vdd-progress-text'); const defaultButtonText = button.textContent; let hlsInfo = null; const qualitySelect = card.querySelector('.vdd-quality'); const qualityNote = card.querySelector('.vdd-quality-note'); const refreshQuality = card.querySelector('.vdd-quality-refresh'); const cancel = card.querySelector('.vdd-cancel'); const audioSelect = card.querySelector('.vdd-audio'); let audioOptions = []; const lockChoices = locked => { for (const element of card.querySelectorAll('.vdd-quality, .vdd-audio, .vdd-allow-silent, .vdd-quality-refresh, .vdd-res button, .vdd-mp3q')) element.disabled = locked; }; const updateAudioOptions = () => { const chosen = qualitySelect.value === 'auto' ? hlsInfo.variants[0] : hlsInfo.variants[Number(qualitySelect.value)]; audioOptions = orderedAudioChoices(chosen?.audioGroups.get(chosen.audioGroup) || []); audioSelect.innerHTML = ''; audioOptions.forEach((audio, i) => { const option = document.createElement('option'); option.value = String(i); option.textContent = audioChoiceLabel(audio); audioSelect.appendChild(option); }); card.querySelector('.vdd-audio-label').hidden = !audioOptions.length; }; let chipSync = null; let qualityBase = ''; const updateCodecNote = () => { const chosen = qualitySelect.value === 'auto' ? hlsInfo?.variants[0] : hlsInfo?.variants[Number(qualitySelect.value)]; const warn = chosen ? hlsCodecNote(chosen) : ''; qualityNote.textContent = warn ? `${qualityBase} · Atenção: ${warn}` : qualityBase; }; qualitySelect?.addEventListener('change', updateAudioOptions); qualitySelect?.addEventListener('change', () => updateCodecNote()); qualitySelect?.addEventListener('change', () => chipSync?.()); const inspectWithRenewal = async () => { const inspect = () => inspectHlsChoices(item.url, { referrer: item.pageUrl || location.href, publicOnly: item.sourceScope === 'link', sessionLink: isSessionLinkItem(item) }); try { return await inspect(); } catch (error) { if (!canRenewLinkItem(item, error)) throw error; const renewal = await renewLinkItem(item, directErrorStatus(error), APP.hlsController?.signal); if (renewal.same) throw mediaError(`A página de origem ainda publica esta playlist, mas o servidor responde HTTP ${directErrorStatus(error)}: ${linkStatusExplanation(directErrorStatus(error))}.${error.hlsDiagnosis ? ` Diagnóstico: ${error.hlsDiagnosis}.` : ''}`, 'MEDIA_RENEW_SAME', directErrorStatus(error)); const before = item.url; item.url = renewal.url; try { return await inspect(); } catch (second) { throw renewedFailure(before, item.url, second, error); } } }; const loadQualities = async () => { hlsInfo = await inspectWithRenewal(); qualitySelect.innerHTML = ''; for (let i = 0; i < hlsInfo.variants.length; i++) { const option = document.createElement('option'); option.value = String(i); option.textContent = hlsChoiceLabel(hlsInfo.variants[i]); qualitySelect.appendChild(option); } if (hlsInfo.variants.length) qualitySelect.options[0].textContent = `Melhor disponível · ${hlsChoiceLabel(hlsInfo.variants[0])}`; qualitySelect.hidden = false; qualitySelect.parentElement.hidden = false; refreshQuality.hidden = false; const heights = hlsInfo.variants.map(v => v.height); chipSync = () => { const index = qualitySelect.value === 'auto' ? 0 : Number(qualitySelect.value); setResolutionChips(card, heights, hlsInfo.variants[index]?.height || null, height => { const at = hlsInfo.variants.findIndex(v => v.height === height); if (at >= 0 && !qualitySelect.disabled) { qualitySelect.value = String(at); qualitySelect.dispatchEvent(new Event('change')); } }); }; chipSync(); card.querySelector('.vdd-silent-label').hidden = false; updateAudioOptions(); qualityBase = hlsInfo.variants.length ? `${hlsInfo.variants.length} qualidades publicadas · vídeo e áudio copiados sem recompressão e conferidos antes de salvar${hlsInfo.selectedUrl ? ' · playlist principal recuperada' : ''}` : `${hlsInfo.media.endList ? 'Vídeo' : 'Trecho ao vivo disponível'} · ${formatDuration(hlsInfo.media.duration)} · resolução não informada na playlist`; updateCodecNote(); button.textContent = 'Baixar'; card.querySelector('.vdd-actions-sub')?.removeAttribute('hidden'); }; refreshQuality?.addEventListener('click', async () => { if (APP.working || APP.linkBusy) return; APP.working = true; refreshQuality.disabled = true; try { await loadQualities(); } catch (error) { qualityNote.textContent = error.message; } finally { APP.working = false; refreshQuality.disabled = false; dlQueueNext(); } }); let queued = false; const queueEntry = { card, show(position, total) { bar.style.width = '0%'; bar.parentElement.classList.remove('indeterminate'); text.textContent = `Na fila · posição ${position} de ${total}`; text.dataset.state = 'queued'; }, start() { onDownloadClick(true); }, }; const leaveQueue = () => { queued = false; delete card.dataset.dl; mode = 'video'; setBusy(false); button.textContent = item.kind === 'HLS' && hlsInfo ? 'Baixar' : defaultButtonText; if (mp3Button) mp3Button.textContent = mp3Default; lockChoices(Boolean(pendingSave)); progress.classList.remove('show'); text.dataset.state = ''; if (cancel) cancel.hidden = true; }; cancel?.addEventListener('click', () => { if (queued) { dlQueueRemove(queueEntry); leaveQueue(); toast('Removido da fila'); return; } APP.hlsController?.abort(); APP.directController?.abort(); }); const onDownloadClick = async (fromQueue = false, requestedMode = null) => { const wanted = fromQueue ? queuedMode : (requestedMode || 'video'); if (queued && !fromQueue) { toast('Este item já está na fila'); return; } if (fromQueue) queued = false; if (APP.working || APP.linkBusy) { const ready = !pendingOpen && !pendingSave && (item.kind !== 'HLS' || Boolean(hlsInfo)); if (ready && !APP.linkBusy && !fromQueue) { queued = true; queuedMode = wanted; mode = wanted; card.dataset.dl = 'queued'; setBusy(true); labelBtn().textContent = 'Na fila'; progress.classList.add('show'); lockChoices(true); if (cancel) cancel.hidden = false; toast(`Adicionado à fila · posição ${dlQueuePush(queueEntry)}`); return; } toast('Já existe um download sendo preparado'); return; } if (item.kind === 'HLS' && !hlsInfo && !pendingSave) { APP.working = true; setBusy(true); button.textContent = 'Lendo qualidades...'; try { await loadQualities(); } catch (error) { qualityNote.textContent = error.message; button.textContent = defaultButtonText; } finally { APP.working = false; setBusy(false); dlQueueNext(); } return; } mode = wanted; APP.working = true; setBusy(true); progress.classList.add('show'); lockChoices(true); card.dataset.dl = 'running'; if (pendingOpen) { try { setProgress(bar, text, 99, 'Abrindo a fonte no navegador...'); openDirectUrlFromGesture(pendingOpen); pendingOpen.attempts++; button.textContent = 'Abrir novamente'; setProgress(bar, text, 100, 'Use o salvamento do navegador para concluir.'); toast('Fonte aberta no navegador para salvar', 4200); } catch (error) { console.error('[VDD] Abertura nativa falhou:', error); const msg = error?.message || 'Não foi possível abrir a fonte'; setProgress(bar, text, 0, msg); toast(msg, 4200); } finally { APP.working = false; setBusy(false); lockChoices(Boolean(pendingSave)); dlQueueNext(); } return; } if (pendingSave) { try { setProgress(bar, text, 99, 'Abrindo o salvamento...'); const saveMethod = await saveManualBlobFromGesture(pendingSave); const saveCompleted = saveMethod === 'picker' || saveMethod === 'share'; pendingSave.attempts++; button.textContent = saveCompleted ? (item.kind === 'HLS' && hlsInfo ? 'Baixar' : defaultButtonText) : 'Salvar novamente'; setProgress( bar, text, 100, saveCompleted ? 'Arquivo enviado para o salvamento.' : 'Solicitação enviada. Confira a pasta Downloads.' ); toast(saveCompleted ? 'Vídeo enviado sem alterar os dados' : 'Solicitação enviada ao navegador', 3600); if (saveCompleted) { if (pendingSave.objectUrl) URL.revokeObjectURL(pendingSave.objectUrl); pendingSave = null; delete card.dataset.prepared; return; } if (!pendingSave.cleanupTimer) { const current = pendingSave; current.cleanupTimer = setTimeout(() => { if (current.objectUrl) URL.revokeObjectURL(current.objectUrl); if (pendingSave === current) { pendingSave = null; delete card.dataset.prepared; lockChoices(false); button.textContent = item.kind === 'HLS' && hlsInfo ? 'Baixar' : defaultButtonText; } }, NET.manualUrlLifetime); } } catch (error) { console.error('[VDD] Salvamento manual falhou:', error); const msg = error?.message || 'O navegador não abriu o salvamento'; setProgress(bar, text, 0, msg); toast(msg, 4200); } finally { APP.working = false; setBusy(false); lockChoices(Boolean(pendingSave)); dlQueueNext(); } return; } const previousRequestReferrer = APP.requestReferrer; APP.requestReferrer = safeHttpUrl(item.pageUrl || '') || location.href; OUTPUT.mode = mode === 'mp3' ? 'mp3' : 'video'; OUTPUT.kbps = Number(mp3Select?.value) || OUTPUT.kbps; OUTPUT.title = item.audioTitle || ''; OUTPUT.artist = item.audioArtist || ''; OUTPUT.signal = null; try { if (item.mediaType) { setProgress(bar, text, 8, item.mediaType === 'zip' ? 'Preparando as fotos...' : 'Preparando...'); APP.directController = new AbortController(); OUTPUT.signal = APP.directController.signal; if (cancel) cancel.hidden = false; await downloadAssetItem(item, titleNow(), { signal: APP.directController.signal, onProgress(percent, label, detail) { setProgress(bar, text, percent, label, detail); } }); } else if (item.kind !== 'HLS') { setProgress(bar, text, 8, 'Validando a fonte...'); APP.directController = new AbortController(); OUTPUT.signal = APP.directController.signal; if (cancel) cancel.hidden = false; await downloadPublicItem(item, titleNow(), { signal: APP.directController.signal, allowPageFallback: item.sourceScope !== 'link', sessionLink: isSessionLinkItem(item), typeHint: item.type || '', kindHint: item.kind || '', onProgress(percent, label, detail) { setProgress(bar, text, percent, label, detail); } }); } else { if (cancel) cancel.hidden = false; const hlsOptions = () => ({ filenameBase: titleNow(), audioSelection: audioSelect.value === 'auto' ? null : audioOptions[Number(audioSelect.value)], allowSilent: card.querySelector('.vdd-allow-silent').checked, pageUrl: item.pageUrl, selection: qualitySelect.value === 'auto' ? null : hlsInfo.variants[Number(qualitySelect.value)], allowPageFallback: item.sourceScope !== 'link', sessionLink: isSessionLinkItem(item), onTrace: trace => showTraceToggle(card, trace), onProgress(percent, label, detail) { setProgress(bar, text, percent, label, detail); } }); try { await downloadHlsAsMp4(hlsInfo.finalUrl, hlsOptions()); } catch (hlsError) { if (!canRenewLinkItem(item, hlsError)) throw hlsError; const chosen = qualitySelect.value === 'auto' ? null : hlsInfo.variants[Number(qualitySelect.value)]; const status = directErrorStatus(hlsError); setProgress(bar, text, null, `O servidor respondeu HTTP ${status}. Relendo a página para renovar o endereço...`, { phase: 'retry' }); const renewal = await renewLinkItem(item, status, APP.hlsController?.signal); if (renewal.same) throw mediaError(`A página de origem ainda publica esta playlist, mas o servidor responde HTTP ${status}: ${linkStatusExplanation(status)}.${hlsError.hlsDiagnosis ? ` Diagnóstico: ${hlsError.hlsDiagnosis}.` : ''} Nenhum arquivo foi salvo.`, 'MEDIA_RENEW_SAME', status); const before = item.url; item.url = renewal.url; try { await loadQualities(); if (chosen) { const index = hlsInfo.variants.findIndex(v => v.height === chosen.height && v.bandwidth === chosen.bandwidth); qualitySelect.value = index >= 0 ? String(index) : 'auto'; updateAudioOptions(); } await downloadHlsAsMp4(hlsInfo.finalUrl, hlsOptions()); } catch (second) { throw renewedFailure(before, item.url, second, hlsError); } } } card.dataset.dl = 'done'; } catch (error) { card.dataset.dl = isManualOpenRequired(error) || isManualSaveRequired(error) ? 'pending' : 'error'; if (isManualOpenRequired(error)) { pendingOpen = { url: error.url, filename: error.filename, attempts: 0, }; button.textContent = 'Abrir vídeo'; setProgress(bar, text, 98, 'Toque em “Abrir vídeo” para usar o salvamento do navegador.'); toast('Toque em “Abrir vídeo” para continuar', 5000); return; } if (isManualSaveRequired(error)) { card.dataset.prepared = 'true'; pendingSave = { blob: error.blob, filename: error.filename, objectUrl: '', attempts: 0, cleanupTimer: null, }; button.textContent = 'Salvar arquivo'; setProgress(bar, text, 100, 'Arquivo pronto. Toque em “Salvar arquivo”.'); toast('Toque em “Salvar arquivo” para concluir', 5000); return; } console.error('[VDD]', error); const msg = error?.message || 'Falha ao preparar o vídeo'; setProgress(bar, text, 0, msg); if (error?.hlsTrace) { text.style.whiteSpace = 'pre-wrap'; text.textContent += `\n\n${error.hlsTrace}`; console.info('[VDD][diag]', error.hlsTrace); } if (isSessionLinkItem(item) && item.pageUrl && ([401, 403, 404, 410, 412].includes(directErrorStatus(error)) || error?.hlsRefusedAll)) appendOpenPageHint(text, item.pageUrl); toast(msg, 3600); } finally { OUTPUT.mode = 'video'; OUTPUT.signal = null; mode = 'video'; APP.requestReferrer = previousRequestReferrer; APP.directController = null; if (cancel) cancel.hidden = true; APP.working = false; setBusy(false); lockChoices(Boolean(pendingSave)); dlQueueNext(); } }; button.addEventListener('click', () => onDownloadClick(false)); mp3Button?.addEventListener('click', () => onDownloadClick(false, 'mp3')); mp3Select?.addEventListener('change', () => { const value = Number(mp3Select.value); if (!MP3_BITRATES.includes(value)) return; OUTPUT.kbps = value; PREFS.set('mp3.kbps', value); for (const other of document.querySelectorAll('#vdd-root .vdd-mp3q')) if (other !== mp3Select) other.value = String(value); }); } // --------------------------------------------------------------------------- // Public link extraction // Reads media candidates out of public page HTML and embedded JSON. // --------------------------------------------------------------------------- function decodeEmbeddedUrl(value) { const raw = String(value || '').trim(); if (!raw) return ''; let decoded = raw; try { decoded = JSON.parse(`"${raw.replace(/"/g, '\\"')}"`); } catch {} decoded = String(decoded) .replace(/\\u003[aA]/g, ':') .replace(/\\u002[fF]/g, '/') .replace(/\\u0026/g, '&') .replace(/\\u003[fF]/g, '?') .replace(/\\u003[dD]/g, '=') .replace(/\\x2[fF]/g, '/') .replace(/\\\//g, '/') .replace(/&/gi, '&') .replace(/&/gi, '&') .replace(/&/gi, '&') .trim(); return decoded; } function parseIsoDuration(value) { const match = String(value || '').match(/^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?$/i); if (!match) return 0; return Number(match[1] || 0) * 3600 + Number(match[2] || 0) * 60 + Number(match[3] || 0); } function extractPublicMediaCandidates(root, baseUrl, options = {}) { if (!root?.querySelectorAll) return []; const results = new Map(); const metaContent = selector => cleanDisplayText(root.querySelector(selector)?.getAttribute('content'), 300); const defaultTitle = metaContent('meta[property="og:title"]') || metaContent('meta[name="twitter:title"]') || cleanDisplayText(root.title) || pageTitle(); const defaultThumbnail = pageThumbnail(root, baseUrl); const defaultWidth = Number(metaContent('meta[property="og:video:width"]') || 0); const defaultHeight = Number(metaContent('meta[property="og:video:height"]') || 0); const ogType = metaContent('meta[property="og:video:type"]'); const add = (rawUrl, type = '', metadata = {}) => { const url = safeHttpUrl(decodeEmbeddedUrl(rawUrl), baseUrl); if (!url) return; if (restrictedPlatformNotice(url) || isPrivateNetworkUrl(url)) return; if (responseLooksLikeHtml(type)) return; const hls = isHls(url, type); const hint = knownDirectFormatHint(url, type); if (!hls && !hint && !metadata.trusted) return; const kind = hls ? 'HLS' : (hint?.label || 'VÍDEO'); let score = Number(metadata.score || 0); const identity = mediaIdentityKey(url, hls ? 'HLS' : 'DIRECT'); const candidate = { kind, url, identity, title: cleanDisplayText(metadata.title) || defaultTitle || mediaTitleFromUrl(url) || 'Vídeo', titlePriority: Number(metadata.titlePriority || 5), thumbnail: safeHttpUrl(metadata.thumbnail || defaultThumbnail, baseUrl), thumbnailPriority: Number(metadata.thumbnailPriority || 5), duration: Number(metadata.duration || 0), width: Number(metadata.width || defaultWidth || 0), height: Number(metadata.height || defaultHeight || 0), label: cleanDisplayText(metadata.label || '', 60), pageUrl: safeHttpUrl(metadata.pageUrl || baseUrl, baseUrl), score, preferred: Boolean(metadata.preferred), noWatermark: metadata.noWatermark === true, bandwidth: Number(metadata.bandwidth || 0), type: mediaContentType(type), verified: metadata.verified === true, }; const previous = results.get(identity); if (!previous || candidate.score > previous.score) results.set(identity, candidate); else results.set(identity, { ...previous, width: candidate.width || previous.width, height: candidate.height || previous.height, noWatermark: previous.noWatermark || candidate.noWatermark, bandwidth: candidate.bandwidth || previous.bandwidth }); }; const publicMeta = [ ['meta[property="og:video:secure_url"]', 100, 'Vídeo publicado'], ['meta[property="og:video:url"]', 98, 'Vídeo publicado'], ['meta[property="og:video"]', 96, 'Vídeo publicado'], ['meta[name="twitter:player:stream"]', 92, 'Reprodução pública'], ]; for (const [selector, score, label] of publicMeta) { root.querySelectorAll(selector).forEach(node => { add(node.getAttribute('content'), node.getAttribute('type') || ogType, { trusted: true, score, label }); }); } root.querySelectorAll('video').forEach((video, index) => { const metadata = { trusted: true, score: 90, label: 'Player da página', title: video.getAttribute('title') || video.getAttribute('aria-label') || defaultTitle, titlePriority: (video.getAttribute('title') || video.getAttribute('aria-label')) ? 5 : 1, thumbnail: video.poster || video.getAttribute('poster') || defaultThumbnail, thumbnailPriority: (video.poster || video.getAttribute('poster')) ? 5 : 1, duration: Number(video.duration || 0), width: Number(video.videoWidth || 0), height: Number(video.videoHeight || 0), videoIndex: index, }; add(video.currentSrc || video.src || video.getAttribute('src'), video.getAttribute('type') || '', metadata); video.querySelectorAll('source').forEach(source => { add(source.src || source.getAttribute('src'), source.type || '', { ...metadata, height: Number(source.getAttribute('data-height') || source.getAttribute('res') || 0) || Number((source.getAttribute('label') || '').match(/(\d+)p/)?.[1] || 0) }); }); }); root.querySelectorAll('source[src], link[rel="preload"][as="video"], [data-video-src], [data-video-url]').forEach(node => { const raw = node.getAttribute('src') || node.getAttribute('href') || node.getAttribute('data-video-src') || node.getAttribute('data-video-url'); add(raw, node.getAttribute('type') || '', { trusted: node.matches?.('source[src], link[rel="preload"][as="video"]'), score: 82, label: 'Fonte publicada', }); }); root.querySelectorAll('[itemprop="contentUrl"], [itemprop="embedUrl"], [data-src], [data-file], [data-url], [data-stream], [data-hls], [data-mp4]').forEach(node => { for (const attr of [...(node.attributes || [])]) { const name = String(attr.name || '').toLowerCase(); const raw = decodeEmbeddedUrl(attr.value || ''); const mediaishName = /(?:video|media|stream|file|source|src|hls|mp4|contenturl|embedurl)/i.test(name); const hinted = isHls(raw) || Boolean(knownDirectFormatHint(raw)); if (!raw || (!mediaishName && !hinted)) continue; add(raw, node.getAttribute('type') || '', { trusted: hinted || /(?:contenturl|video|media|stream|file|hls|mp4)/i.test(name), score: hinted ? 78 : 64, label: 'Fonte declarada na página', }); } }); const walkJsonLd = (value, depth = 0) => { if (depth > 10 || value == null) return; if (Array.isArray(value)) { value.forEach(item => walkJsonLd(item, depth + 1)); return; } if (typeof value !== 'object') return; const thumbnail = Array.isArray(value.thumbnailUrl) ? value.thumbnailUrl[0] : value.thumbnailUrl; for (const key of ['contentUrl', 'embedUrl']) { if (typeof value[key] === 'string') { add(value[key], value.encodingFormat || '', { trusted: key === 'contentUrl', score: key === 'contentUrl' ? 94 : 72, label: key === 'contentUrl' ? 'Conteúdo publicado' : 'Player publicado', title: value.name || value.headline || defaultTitle, thumbnail, duration: parseIsoDuration(value.duration), width: Number(value.width || 0), height: Number(value.height || 0), }); } } Object.values(value).forEach(child => walkJsonLd(child, depth + 1)); }; root.querySelectorAll('script[type="application/ld+json"]').forEach(script => { try { walkJsonLd(JSON.parse(script.textContent || '')); } catch {} }); if (options.scanRawScripts) { const keyInfo = { noWatermarkUrl: { score: 102, label: 'Original publicado', noWatermark: true }, no_watermark_url: { score: 102, label: 'Original publicado', noWatermark: true }, playAddrNoWatermark: { score: 102, label: 'Original publicado', noWatermark: true }, downloadAddr: { score: 100, label: 'Download publicado' }, download_addr: { score: 100, label: 'Download publicado' }, downloadUrl: { score: 100, label: 'Download publicado' }, download_url: { score: 100, label: 'Download publicado' }, videoUrl: { score: 98, label: 'Vídeo publicado' }, video_url: { score: 98, label: 'Vídeo publicado' }, videoSrc: { score: 96, label: 'Vídeo publicado' }, video_src: { score: 96, label: 'Vídeo publicado' }, contentUrl: { score: 94, label: 'Conteúdo publicado' }, content_url: { score: 94, label: 'Conteúdo publicado' }, playbackUrl: { score: 90, label: 'Reprodução pública' }, playback_url: { score: 90, label: 'Reprodução pública' }, playAddr: { score: 88, label: 'Reprodução pública' }, play_addr: { score: 88, label: 'Reprodução pública' }, mediaUrl: { score: 88, label: 'Mídia publicada' }, media_url: { score: 88, label: 'Mídia publicada' }, streamUrl: { score: 88, label: 'Stream publicado' }, stream_url: { score: 88, label: 'Stream publicado' }, hlsUrl: { score: 92, label: 'HLS publicado' }, hls_url: { score: 92, label: 'HLS publicado' }, manifestUrl: { score: 86, label: 'Manifesto publicado' }, manifest_url: { score: 86, label: 'Manifesto publicado' }, file: { score: 82, label: 'Arquivo publicado' }, fileUrl: { score: 84, label: 'Arquivo publicado' }, file_url: { score: 84, label: 'Arquivo publicado' }, source: { score: 72, label: 'Fonte encontrada' }, src: { score: 55, label: 'Fonte encontrada' }, url: { score: 45, label: 'Fonte encontrada' }, }; const keyNames = Object.keys(keyInfo).join('|'); const keyedPattern = new RegExp(`["'](${keyNames})["']\\s*[:=]\\s*["']((?:\\\\.|[^"'\\\\])*)["']`, 'gi'); const mediaUrlPattern = /(?:https?(?::|\\u003a)(?:\/|\\\/|\\u002f){2}|\/\/)[^"'<>\\\s]+?(?:\.m3u8|\.mpd|\.mp4|\.m4v|\.webm|\.mov|\.ogv|\.mkv|\.ts)(?:[^"'<>\\\s]*)/gi; const scanText = rawText => { const text = String(rawText || ''); if (!text) return; let match; while ((match = keyedPattern.exec(text))) { const key = match[1]; const info = keyInfo[key] || { score: 40, label: 'Fonte encontrada' }; const decoded = decodeEmbeddedUrl(match[2]); const strongKey = Number(info.score || 0) >= 80 && !/^(?:src|url|source)$/i.test(key); if (strongKey || isHls(decoded) || knownDirectFormatHint(decoded)) { add(decoded, '', { trusted: strongKey, ...info }); } } keyedPattern.lastIndex = 0; while ((match = mediaUrlPattern.exec(text))) { const decoded = decodeEmbeddedUrl(match[0]); add(decoded, '', { score: 52, label: 'URL de mídia' }); } mediaUrlPattern.lastIndex = 0; const listPattern = /["'](?:mediaDefinitions|media_definitions|video_sources|sources|formats)["']\s*:\s*\[/gi; let listMatch; while ((listMatch = listPattern.exec(text))) { const raw = balancedJsonAt(text, listMatch.index + listMatch[0].length - 1); if (!raw) continue; try { for (const found of jsonMediaCandidates(JSON.parse(raw), baseUrl, { allowApi: true })) { add(found.url, found.type, { trusted: true, score: found.score, label: found.label, height: found.height, width: found.width, verified: found.verified }); } } catch {} } }; let scanned = 0; root.querySelectorAll('script:not([src])').forEach(script => { if (scanned >= NET.maxLinkHtmlSize) return; const rawText = String(script.textContent || '').slice(0, NET.maxLinkHtmlSize - scanned); scanned += rawText.length; scanText(rawText); const type = String(script.getAttribute('type') || '').toLowerCase(); if (/json/.test(type) || /^\s*[\[{]/.test(rawText)) { try { const walk = (value, key = '', depth = 0, metadata = {}) => { if (depth > 14 || value == null) return; if (typeof value === 'string') { const info = keyInfo[key] || null; const decoded = decodeEmbeddedUrl(value); if (info && Number(info.score || 0) >= 80) { add(decoded, metadata.type || '', { ...metadata, trusted: true, ...info }); } else if (isHls(decoded, metadata.type) || knownDirectFormatHint(decoded, metadata.type)) { add(decoded, metadata.type || '', { ...metadata, score: 60, label: 'Mídia em dados da página' }); } return; } if (Array.isArray(value)) { value.forEach(item => walk(item, key, depth + 1, metadata)); return; } if (typeof value === 'object') { const quality = String(value.quality || value.label || value.resolution || ''); const dimensions = quality.match(/(\d+)x(\d+)/); const own = { ...metadata, width: Number(value.width || dimensions?.[1] || metadata.width || 0), height: Number(value.height || dimensions?.[2] || quality.match(/(\d+)p/)?.[1] || metadata.height || 0), bandwidth: Number(value.bitrate || value.bandwidth || metadata.bandwidth || 0), type: value.mimeType || value.mime_type || value.type || metadata.type || '', noWatermark: value.hasWatermark === false || value.watermarked === false || metadata.noWatermark === true }; Object.entries(value).forEach(([childKey, child]) => walk(child, childKey, depth + 1, own)); } }; walk(JSON.parse(rawText)); } catch {} } }); if (options.rawText) scanText(String(options.rawText).slice(0, NET.maxLinkHtmlSize)); } const sorted = [...results.values()] .sort(compareMediaQuality) .slice(0, 40); if (sorted.length > 1) sorted[0].preferred = true; return sorted; } function beegVideoId(url) { try { const parsed = new URL(url); if (!/(?:^|\.)beeg\.com$/i.test(parsed.hostname)) return ''; const match = parsed.pathname.match(/\/?(?:video\/)?-?(\d{6,20})(?:\/|$)/i); return match?.[1] || ''; } catch { return ''; } } async function requestJsonPublic(url, pageUrl = location.href) { const response = await mediaRequest(url, { context: { referrer: pageUrl, publicOnly: true }, text: true, maxBytes: NET.maxLinkHtmlSize, timeout: NET.linkRequestTimeout, headers: { Accept: 'application/json,*/*;q=0.5' } }); try { return JSON.parse(response.text); } catch { throw mediaError('A API pública devolveu uma resposta que não é JSON.'); } } async function extractKnownPublicSiteCandidates(pageUrl) { const results = []; const id = beegVideoId(pageUrl); if (id) { const data = await requestJsonPublic(`https://store.externulls.com/facts/file/${encodeURIComponent(id)}`, pageUrl); const facts = Array.isArray(data?.fc_facts) ? data.fc_facts : []; const firstFact = [...facts].sort((a, b) => Number(a?.id || 0) - Number(b?.id || 0))[0] || {}; const hlsResources = data?.file?.hls_resources || firstFact?.hls_resources || {}; const directResources = data?.file?.resources || firstFact?.resources || {}; const title = cleanDisplayText(data?.file?.stuff?.sf_name) || pageTitle(); const seen = new Set(); const pushResource = (key, raw, forceHls = false) => { if (!raw || /^(?:set_id|id)$/i.test(String(key))) return; const url = /^https?:\/\//i.test(String(raw)) ? String(raw) : `https://video.beeg.com/${String(raw).replace(/^\/+/, '')}`; if (seen.has(url)) return; seen.add(url); const height = Number(String(key).match(/(\d{3,4})/)?.[1] || 0); const hls = forceHls || isHls(url) || /hls/i.test(String(key)); const hint = knownDirectFormatHint(url); results.push({ kind: hls ? 'HLS' : (hint?.label || 'VÍDEO'), url, type: hls ? 'application/vnd.apple.mpegurl' : (hint?.mime || ''), title, thumbnail: '', pageUrl, trusted: true, preferred: height >= 720, height, label: height ? `${hls ? 'HLS' : 'Fonte'} pública ${height}p` : (hls ? 'HLS público' : 'Fonte pública'), score: (hls ? 100 : 92) + height, }); }; Object.entries(hlsResources || {}).forEach(([key, raw]) => pushResource(key, raw, true)); Object.entries(directResources || {}).forEach(([key, raw]) => pushResource(key, raw, false)); } return results.sort((a, b) => Number(b.score || 0) - Number(a.score || 0)); } async function collectKnownPublicSiteMedia(pageUrl = location.href) { const key = `known:${pageUrl}`; if (APP.siteProbeKeys.has(key)) return false; APP.siteProbeKeys.add(key); try { const candidates = await extractKnownPublicSiteCandidates(pageUrl); let changed = false; for (const candidate of candidates) { if (registerMedia(candidate.url, candidate.type, false, candidate)) changed = true; } if (changed) queueRender(); return changed; } catch (error) { console.info('[VDD] Endpoint público específico indisponível:', error); return false; } } function restrictedPlatformNotice(url) { let host = ''; try { host = new URL(url).hostname.toLowerCase(); } catch {} if (/(?:^|\.)(?:youtube\.com|youtu\.be|youtube-nocookie\.com|googlevideo\.com)$/.test(host)) { return 'YouTube: na aba “Por link”, cole o link do vídeo ou Short (usa o seu Worker + Cobalt). A detecção automática de mídia dentro do site do YouTube continua desligada; para conteúdo próprio, considere também o YouTube Studio e o Google Takeout.'; } if (/(?:^|\.)(?:spotify\.com|deezer\.com|tidal\.com|music\.apple\.com|music\.amazon\.[a-z.]+|music\.youtube\.com|napster\.com|qobuz\.com)$/.test(host)) { return 'Serviços de streaming de música usam proteção (DRM) e licenças próprias: baixe apenas pelas opções oficiais do serviço. Links de arquivos MP3/M4A públicos e músicas de TikTok funcionam normalmente.'; } return ''; } // --------------------------------------------------------------------------- // Link probing // YouTube link detection, private-network guard, and fetching of pasted links and pages. // --------------------------------------------------------------------------- const YT_LINK_HOST = /^(?:www\.|m\.)?(?:youtube\.com|youtube-nocookie\.com)$/i; function youtubeLink(url) { let u; try { u = new URL(url); } catch { return null; } if (u.protocol !== 'https:') return null; const host = u.hostname.toLowerCase(); let id = '', short = false; if (host === 'youtu.be') id = u.pathname.split('/')[1] || ''; else if (YT_LINK_HOST.test(host)) { const m = u.pathname.match(/^\/(shorts|embed|live|v)\/([\w-]{11})(?:\/|$)/); if (m) { id = m[2]; short = m[1] === 'shorts'; } else if (u.pathname === '/watch') id = u.searchParams.get('v') || ''; } else return null; return /^[\w-]{11}$/.test(id) ? { id, short, name: 'YouTube', kind: 'youtube' } : null; } const youtubeEnabled = () => LINK_PROXY_YOUTUBE && Boolean(String(LINK_PROXY || '').trim()); function isPrivateNetworkUrl(url) { try { const parsed = new URL(url); if (parsed.username || parsed.password) return true; if (parsed.origin === location.origin) return false; const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, ''); if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || host === '::1') return true; if (/^127\./.test(host) || /^10\./.test(host) || /^192\.168\./.test(host) || /^169\.254\./.test(host)) return true; const match = host.match(/^172\.(\d+)\./); return Boolean(match && Number(match[1]) >= 16 && Number(match[1]) <= 31); } catch { return true; } } async function fetchLinkProbe(url, maxBytes = 65536) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 15000); try { const sameOrigin = new URL(url).origin === location.origin; const response = await fetch(url, { method: 'GET', credentials: sameOrigin ? 'include' : 'omit', redirect: 'follow', cache: 'no-store', headers: { Range: `bytes=0-${maxBytes - 1}`, Accept: 'video/*,application/octet-stream,text/plain,*/*;q=0.5' }, signal: controller.signal, }); if (!response.ok && response.status !== 206) { throw makeHttpError(`Sondagem respondeu ${response.status}`, response.status); } const chunks = []; let received = 0; if (response.body?.getReader) { const reader = response.body.getReader(); while (received < maxBytes) { const { done, value } = await reader.read(); if (done) break; if (!value?.byteLength) continue; const remaining = maxBytes - received; chunks.push(value.byteLength > remaining ? value.slice(0, remaining) : value); received += Math.min(value.byteLength, remaining); } try { Promise.resolve(reader.cancel()).catch(() => {}); } catch {} } else { const buffer = new Uint8Array(await response.arrayBuffer()); chunks.push(buffer.slice(0, maxBytes)); received = Math.min(buffer.byteLength, maxBytes); } const bytes = new Uint8Array(received); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } return { status: response.status, finalUrl: response.url || url, contentType: response.headers.get('content-type') || '', bytes, }; } finally { clearTimeout(timer); } } async function probeLinkTarget(url, context = { referrer: url, publicOnly: true }) { let response; try { response = await mediaRequest(url, { context, prefix: true, maxBytes: 65536, timeout: 15000, headers: { Range: 'bytes=0-65535' } }); } catch (error) { if (error.status === 416 || error.status === 400 || error.status === 405) { response = await mediaRequest(url, { context, prefix: true, maxBytes: 65536, timeout: 15000 }); } else if (error.code === 'MEDIA_LIMIT') { const head = await gmHeadRequest(url, 12000).catch(() => null); const finalUrl = head?.finalUrl || url, contentType = head?.headers?.['content-type'] || ''; const hint = knownDirectFormatHint(finalUrl, contentType); if (hint && !responseLooksLikeHtml(contentType)) return { kind: hint.label, url: finalUrl, contentType, confirmed: false }; return { page: true, url: finalUrl, contentType }; } else throw error; } const finalUrl = response.finalUrl, contentType = response.contentType; const sample = new TextDecoder().decode(response.bytes.subarray(0, 2048)); if (/^\s*#EXTM3U(?:\s|$)/.test(sample)) return { kind: 'HLS', url: finalUrl, contentType: 'application/vnd.apple.mpegurl', confirmed: true }; const format = detectMediaFormatFromBytes(response.bytes, contentType, finalUrl); if (format?.confirmed && !format.hls && !isAudioUrl(finalUrl, contentType)) return { kind: format.label, url: finalUrl, contentType: format.mime, confirmed: true }; const audioFormat = sniffAudio(response.bytes, contentType); if (audioFormat && !looksLikeHtmlBytes(response.bytes) && (!audioFormat.container || isAudioUrl(finalUrl, contentType))) return { audio: true, kind: 'MP3', url: finalUrl, contentType: audioFormat.mime, confirmed: true }; if (looksLikeHtmlBytes(response.bytes) || responseLooksLikeHtml(contentType) || /^\s*[\[{<]/.test(sample)) return { page: true, url: finalUrl, contentType }; if (isHls(finalUrl, contentType)) return { kind: 'HLS', url: finalUrl, contentType, confirmed: false }; const disposition = contentDispositionFilename(response.headers['content-disposition']); const hint = knownDirectFormatHint(disposition || finalUrl, contentType); if (hint) return { kind: hint.label, url: finalUrl, contentType: hint.mime, confirmed: false }; return { page: true, url: finalUrl, contentType }; } const linkPageCache = new Map(); function requestLinkPage(url, context = { referrer: url, publicOnly: true }) { const fetchPage = () => mediaRequest(url, { context, text: true, maxBytes: NET.maxLinkHtmlSize, timeout: NET.linkRequestTimeout, headers: { Accept: 'text/html,application/xhtml+xml,application/json,application/vnd.apple.mpegurl,*/*;q=0.5' } }); if (!context.pageCache) return fetchPage(); const key = `${url}|${wantsSession(context)}`, hit = linkPageCache.get(key); if (hit && Date.now() - hit.at < 60000) return hit.promise; const promise = fetchPage(); linkPageCache.set(key, { at: Date.now(), promise }); while (linkPageCache.size > 3) linkPageCache.delete(linkPageCache.keys().next().value); promise.then(result => { if (String(result.text || '').length > 3 * 1024 * 1024) linkPageCache.delete(key); }, () => linkPageCache.delete(key)); return promise; } // --------------------------------------------------------------------------- // Social links // TikTok and Instagram: URL parsing and extraction of video, photo and audio candidates. // --------------------------------------------------------------------------- const TIKTOK_HOST = /(?:^|\.)(?:tiktok\.com|tiktokv\.com|tiktokv\.us|tiktokv\.eu)$/; const INSTAGRAM_HOST = /(?:^|\.)(?:instagram\.com|instagr\.am)$/; function publicPlatform(url) { let parsed; try { parsed = new URL(url); } catch { return null; } const host = parsed.hostname.toLowerCase(), path = parsed.pathname; if (TIKTOK_HOST.test(host)) { const media = path.match(/\/(video|photo)\/(\d{6,})/) || path.match(/\/(?:player\/v1|embed\/v2|embed|v|share\/video|share\/photo|share\/slideshow|share\/item)\/(\d{6,})/); const music = path.match(/\/music\/(?:[^/]*?-)?(\d{6,})\/?$/); const query = parsed.searchParams.get('item_id') || parsed.searchParams.get('share_item_id') || parsed.searchParams.get('aweme_id') || ''; const id = media ? media[media.length - 1] : (/^\d{6,}$/.test(query) ? query : ''); const short = !id && !music && (/^(?:vm|vt|vn|link)\.tiktok\.com$/.test(host) || /^\/t\/[\w-]{4,}\/?$/.test(path)); return { name: 'TikTok', kind: 'tiktok', id: music ? music[1] : id, post: media && media.length === 3 ? media[1] : '', music: Boolean(music), short }; } if (INSTAGRAM_HOST.test(host)) { const media = path.match(/\/(?:p|reels?|tv)\/([\w-]{5,})/) || path.match(/\/share\/(?:p|reels?|tv)\/([\w-]{5,})/); const story = /^\/stories\//.test(path); return { name: 'Instagram', kind: 'instagram', id: media ? media[1] : '', story, short: !media && /^\/share\/[\w-]{5,}\/?$/.test(path) }; } return null; } const BARE_PLATFORM_LINK = /(?:^|[\s(<"“])((?:[a-z0-9-]+\.)*(?:tiktok\.com|tiktokv\.com|instagram\.com|instagr\.am|youtube\.com|youtube-nocookie\.com|youtu\.be)\/[^\s<>"“”]*)/i; function pastedPublicUrl(value) { const decoded = decodeEmbeddedUrl(String(value || '').trim()); let matches = decoded.match(/https?:\/\/[^\s<>"“”]+/gi) || []; if (!matches.length) { const bare = decoded.match(BARE_PLATFORM_LINK); if (bare) matches = [`https://${bare[1]}`]; } if (matches.length > 1) { const supported = matches.filter(link => publicPlatform(link) || youtubeLink(link)); if (supported.length === 1) matches = supported; } if (matches.length !== 1) throw mediaError(matches.length ? 'Cole apenas um link por vez.' : 'Cole um link público (https://…) de vídeo, foto, música ou página.', 'MEDIA_INPUT'); const url = matches[0]; return assertMediaUrl(new URL(decoded === url || url.includes('?') ? url : url.replace(/[),.;!]+$/, '')).href); } function jsonDocuments(doc, rawText = '') { const out = []; let budget = 0; const parse = raw => { if (!raw || budget >= NET.maxLinkHtmlSize || out.length >= 80) return; const text = String(raw).trim(); budget += text.length; try { const data = JSON.parse(text); if (data && typeof data === 'object') out.push(data); } catch {} }; doc.querySelectorAll('script:not([src])').forEach(script => { const raw = String(script.textContent || ''); if (/^\s*[\[{]/.test(raw)) { parse(raw); return; } const markers = /(?:window\._sharedData\s*=|window\.__additionalDataLoaded\s*\([^,]+,|window\.__INITIAL_STATE__\s*=)\s*/g; let match; while ((match = markers.exec(raw))) { let start = match.index + match[0].length, depth = 0, quote = false, escape = false; if (raw[start] !== '{' && raw[start] !== '[') continue; for (let i = start; i < Math.min(raw.length, start + NET.maxLinkHtmlSize); i++) { const ch = raw[i]; if (quote) { if (escape) escape = false; else if (ch === '\\') escape = true; else if (ch === '"') quote = false; continue; } if (ch === '"') quote = true; else if (ch === '{' || ch === '[') depth++; else if (ch === '}' || ch === ']') { if (--depth === 0) { parse(raw.slice(start, i + 1)); break; } } } } }); if (!out.length && /^\s*[\[{]/.test(rawText)) parse(rawText); return out; } function walkPublicData(roots, visit) { const stack = roots.map(value => ({ value, depth: 0 })); let count = 0; while (stack.length && count++ < 18000) { const { value, depth } = stack.pop(); if (!value || typeof value !== 'object' || depth > 22) continue; if (visit(value) === false) continue; const children = Array.isArray(value) ? value : Object.values(value); for (let i = children.length - 1; i >= 0; i--) if (children[i] && typeof children[i] === 'object') stack.push({ value: children[i], depth: depth + 1 }); } } function sourceUrlList(value) { if (typeof value === 'string') return [value]; if (Array.isArray(value)) return value.filter(item => typeof item === 'string'); if (!value || typeof value !== 'object') return []; return sourceUrlList(value.UrlList || value.urlList || value.url_list || value.src || value.url || value.Url || []); } function hdLabel(width, height) { const w = Number(width || 0), h = Number(height || 0), side = w && h ? Math.min(w, h) : (w || h); if (!side) return ''; return side >= 2160 ? '4K' : side >= 1440 ? '2K' : side >= 1080 ? 'Full HD' : side >= 720 ? 'HD' : 'SD'; } function imageQualityLabel(width, height) { const longest = Math.max(Number(width || 0), Number(height || 0)); return longest >= 2000 ? 'Original · alta' : longest >= 1080 ? 'HD' : ''; } function bestImageUrls(urls) { const score = url => /\.jpe?g(?:$|[?~#&])|image\.jpeg/i.test(url) ? 3 : /\.png(?:$|[?~#&])/i.test(url) ? 2.5 : /\.webp(?:$|[?~#&])|image\.webp/i.test(url) ? 2 : /\.(?:heic|avif)(?:$|[?~#&])/i.test(url) ? 1 : 1.5; return urls.map((url, index) => [url, index]).sort((x, y) => score(y[0]) - score(x[0]) || x[1] - y[1]).map(entry => entry[0]); } function extractSocialCandidates(doc, pageUrl, rawText = '', expected = publicPlatform(pageUrl), options = {}) { if (!expected) return []; const wantAssets = options.assets === true; const documents = jsonDocuments(doc, rawText), items = [], results = [], images = [], audios = []; const title = doc.querySelector('meta[property="og:title"]')?.content || doc.title || expected.name; const add = (value, metadata, quality = {}) => { const urls = [...new Set(sourceUrlList(value).map(raw => safeHttpUrl(decodeEmbeddedUrl(raw), pageUrl)).filter(url => url && !isPrivateNetworkUrl(url)))]; if (!urls.length) return; const width = Number(quality.Width || quality.width || metadata.width || 0); const height = Number(quality.Height || quality.height || metadata.height || 0); const codec = String(quality.CodecType || quality.codec || ''); if (/bytevc2|h266|vvc/i.test(codec)) return; const bandwidth = Number(quality.Bitrate || quality.bitrate || 0); const sourceKey = `${metadata.postId}:${metadata.role}:${width}x${height}:${bandwidth}:${codec}:${metadata.noWatermark === true ? 'clean' : 'published'}${!width && !height && !bandwidth ? ':' + mediaIdentityKey(urls[0]) : ''}`; for (let i = 0; i < urls.length; i++) { const hint = knownDirectFormatHint(urls[i], quality.mime_type || quality.mimeType || ''); results.push({ kind: isHls(urls[i]) ? 'HLS' : hint?.label || 'VÍDEO', type: isHls(urls[i]) ? 'application/vnd.apple.mpegurl' : hint?.mime || '', url: urls[i], pageUrl, title: cleanDisplayText(metadata.title || title), thumbnail: safeHttpUrl(metadata.thumbnail, pageUrl), width, height, duration: Number(metadata.duration || 0), bandwidth, codec, sourceKey, postId: metadata.postId, platform: expected.kind, sourceRole: metadata.role, trusted: true, sourceScope: 'link', noWatermark: metadata.noWatermark === true, hasAudio: metadata.hasAudio, qualityLabel: hdLabel(width, height), label: metadata.noWatermark ? 'Original sem marca publicado' : (metadata.role === 'playback' ? 'Reprodução publicada' : 'Arquivo publicado'), score: metadata.role === 'playback' ? 120 : 110, alternatives: urls.filter(url => url !== urls[i]), detectedAt: Date.now() }); } }; const addImage = (urlsRaw, metadata, width = 0, height = 0) => { const urls = bestImageUrls([...new Set(urlsRaw.map(raw => safeHttpUrl(decodeEmbeddedUrl(raw), pageUrl)).filter(url => url && !isPrivateNetworkUrl(url)))]); if (!urls.length) return; images.push({ kind: 'IMAGEM', mediaType: 'image', type: 'image/jpeg', url: urls[0], alternatives: urls.slice(1, 3), pageUrl, title: cleanDisplayText(metadata.title || title), thumbnail: urls[0], width: Number(width || 0), height: Number(height || 0), postId: metadata.postId, platform: expected.kind, trusted: true, sourceScope: 'link', detectedAt: Date.now(), qualityLabel: imageQualityLabel(width, height) || 'Melhor qualidade', label: 'Imagem publicada', score: 100 }); }; const addAudio = (music, metadata) => { const urls = [...new Set(sourceUrlList(music.playUrl || music.play_url || music.playAddr || music.audioUrl).map(raw => safeHttpUrl(decodeEmbeddedUrl(raw), pageUrl)).filter(url => url && !isPrivateNetworkUrl(url)))]; if (!urls.length) return; const name = cleanDisplayText([music.title, music.authorName || music.author].filter(Boolean).join(' — ')) || 'Áudio'; audios.push({ kind: 'MP3', mediaType: 'audio', type: 'audio/mpeg', url: urls[0], alternatives: urls.slice(1, 3), pageUrl, title: name, audioTitle: cleanDisplayText(music.title || ''), audioArtist: cleanDisplayText(music.authorName || music.author || ''), thumbnail: safeHttpUrl(sourceUrlList(music.coverLarge || music.coverMedium || music.coverThumb || music.cover_large)[0] || '', pageUrl), duration: Number(music.duration || metadata.duration || 0), postId: metadata.postId || String(music.id || ''), platform: expected.kind, trusted: true, sourceScope: 'link', detectedAt: Date.now(), qualityLabel: 'MP3', label: music.original ? 'Som original do vídeo' : 'Música', score: 90, sourceKey: `${metadata.postId || music.id}:audio` }); }; if (expected.kind === 'tiktok') { walkPublicData(documents, value => { const id = String(value.id || value.aweme_id || ''); if ((value.video || value.imagePost) && id && (!expected.id || id === expected.id)) items.push(value); }); if (!expected.id && new Set(items.map(item => String(item.id || item.aweme_id))).size !== 1) return []; for (const item of items) { const video = item.video || {}, id = String(item.id || item.aweme_id); const common = { postId: id, title: item.desc || item.imagePost?.title || title, width: video.width, height: video.height, duration: video.duration, thumbnail: sourceUrlList(video.cover || video.originCover || item.imagePost?.cover?.imageURL || item.imagePost?.shareCover)[0], hasAudio: typeof video.hasAudio === 'boolean' ? video.hasAudio : undefined }; add(video.playAddr || video.play_addr, { ...common, role: 'playback' }); add(video.downloadAddr || video.download_addr, { ...common, role: 'download' }); for (const rate of video.bitrateInfo || video.bit_rate || []) { const addr = rate.PlayAddr || rate.play_addr; add(addr, { ...common, width: 0, height: 0, role: 'playback' }, { ...rate, ...(addr && typeof addr === 'object' ? addr : {}) }); } add(video.noWatermarkUrl || video.no_watermark_url || video.playAddrNoWatermark, { ...common, role: 'playback', noWatermark: true }); if (wantAssets) { for (const photo of item.imagePost?.images || item.image_post_info?.images || []) { addImage(sourceUrlList(photo.imageURL || photo.image_url || photo.display_image || photo.imageUrl), { ...common, postId: id }, photo.imageWidth || photo.width, photo.imageHeight || photo.height); } if (item.music) addAudio(item.music, common); } } if (wantAssets && expected.music) { const seen = new Set(); walkPublicData(documents, value => { const music = value.musicInfo?.music || (value.playUrl && value.title && (value.authorName || value.author) && String(value.id || '') === expected.id ? value : null); if (music && !seen.has(music)) { seen.add(music); addAudio(music, { postId: expected.id }); } }); } } else { const all = []; walkPublicData(documents, value => { if (value.video_url || value.video_versions || value.video_resources || value.carousel_media || value.edge_sidecar_to_children || (wantAssets && (value.image_versions2 || value.display_url || value.display_resources) && (value.shortcode || value.code))) all.push(value); }); const exact = expected.id ? all.filter(value => String(value.shortcode || value.code || '') === expected.id) : []; const unidentified = all.filter(value => !value.shortcode && !value.code); const roots = exact.length ? exact : !expected.id ? (all.length === 1 ? all : []) : unidentified.length === 1 ? unidentified : []; const visited = new Set(); const consume = (value, parentId = expected.id) => { if (!value || visited.has(value)) return; visited.add(value); const id = String(value.shortcode || value.code || value.pk || value.id || parentId || 'post'); const common = { postId: id, title: value.title || value.caption?.text || title, width: value.dimensions?.width, height: value.dimensions?.height, duration: value.video_duration, thumbnail: value.display_url || value.thumbnail_src || value.image_versions2?.candidates?.[0]?.url, hasAudio: typeof value.has_audio === 'boolean' ? value.has_audio : undefined, role: 'playback' }; const hasVideo = value.is_video === true || Boolean(value.video_url) || Boolean(value.video_versions?.length) || Boolean(value.video_resources?.length); if (value.is_video !== false) { add(value.video_url, common); for (const version of value.video_versions || value.video_resources || []) add(version.url || version.src, common, version); } if (wantAssets && !hasVideo) { const sizes = []; for (const c of value.image_versions2?.candidates || []) sizes.push({ url: c.url, width: c.width, height: c.height }); for (const c of value.display_resources || []) sizes.push({ url: c.src || c.url, width: c.config_width || c.width, height: c.config_height || c.height }); if (value.display_url) sizes.push({ url: value.display_url, width: value.dimensions?.width, height: value.dimensions?.height }); sizes.sort((x, y) => (Number(y.width || 0) * Number(y.height || 0)) - (Number(x.width || 0) * Number(x.height || 0))); const usable = sizes.filter(size => size.url); if (usable.length) addImage(usable.map(size => size.url), common, usable[0].width, usable[0].height); } for (const child of value.carousel_media || []) consume(child, id); for (const child of value.edge_sidecar_to_children?.edges || []) consume(child.node, id); }; roots.forEach(value => consume(value)); if (wantAssets && !results.length && !images.length && expected.id && !/\/(?:reels?|tv)\//.test(new URL(pageUrl).pathname) && !doc.querySelector('meta[property^="og:video"]') && !/"is_video"\s*:\s*true|"video_versions"/.test(rawText || '')) { const og = doc.querySelector('meta[property="og:image"]')?.content; if (og) addImage([og], { postId: expected.id, title }, 0, 0); } } const unique = new Map(); for (const candidate of results.sort(compareMediaQuality)) { const identity = mediaIdentityKey(candidate.url, candidate.kind); if (!unique.has(identity)) unique.set(identity, candidate); } const qualities = new Map(); for (const candidate of unique.values()) { const previous = qualities.get(candidate.sourceKey); if (previous) previous.alternatives = [...new Set([...previous.alternatives, candidate.url, ...candidate.alternatives])].filter(url => url !== previous.url); else qualities.set(candidate.sourceKey, candidate); } const videos = [...qualities.values()].sort(compareMediaQuality); if (!wantAssets) return videos; const seenImage = new Set(), photos = []; for (const image of images) { const key = mediaIdentityKey(image.url, 'IMAGEM'); if (!seenImage.has(key)) { seenImage.add(key); photos.push(image); } } photos.forEach((image, index) => { image.imageIndex = index + 1; image.imageCount = photos.length; image.sourceKey = `${image.postId}:img:${index + 1}`; if (photos.length > 1) { image.title = `Foto ${index + 1} de ${photos.length} · ${image.title}`; image.label = `Imagem ${index + 1}/${photos.length}`; } image.fileBase = `${expected.kind}-${image.postId}-${String(index + 1).padStart(2, '0')}`; }); const out = []; if (photos.length > 1) { const first = photos[0]; out.push({ kind: 'ZIP', mediaType: 'zip', url: `${String(pageUrl).split('#')[0]}#optdowns-zip`, pageUrl, title: `Todas as ${photos.length} fotos · ${cleanDisplayText(title)}`, thumbnail: first.thumbnail, platform: expected.kind, postId: first.postId, trusted: true, sourceScope: 'link', detectedAt: Date.now(), imageCount: photos.length, images: photos.map(image => ({ url: image.url, alternatives: image.alternatives, imageIndex: image.imageIndex, width: image.width, height: image.height })), qualityLabel: 'Melhor qualidade', label: 'Todas as fotos em um arquivo', score: 130, sourceKey: `${first.postId}:zip`, fileBase: `${expected.kind}-${first.postId}-fotos` }); } out.push(...videos, ...photos); for (const audio of audios.slice(0, 1)) { audio.fileBase = cleanFilenameBase(audio.title) || `${expected.kind}-${audio.postId}-audio`; out.push(audio); } return out; } // Resolves a TikTok or Instagram link into video, photo and audio candidates. async function resolveSocialLink(url, context = {}) { const initial = publicPlatform(url), visited = new Set(); if (initial.story) throw mediaError('Stories do Instagram exigem login e não são públicos. Abra o story no navegador, reproduza e use “Nesta página”.', 'MEDIA_LOGIN'); let expected = initial, firstError = null, login = false, pagesRead = 0; const queue = [url]; const all = []; while (queue.length && visited.size < 3) { checkMediaAbort(context); const target = queue.shift(); if (visited.has(target)) continue; visited.add(target); try { context.onStage?.(`Lendo a fonte pública do ${initial.name}...`); const response = await requestLinkPage(target, { ...context, referrer: url, publicOnly: true }); pagesRead++; const platform = publicPlatform(response.finalUrl); if (!platform || platform.kind !== initial.kind) throw mediaError('O link redirecionou para uma fonte diferente.', 'MEDIA_REDIRECT'); if (platform.id) expected = platform; const doc = new DOMParser().parseFromString(response.text, 'text/html'); const canonical = safeHttpUrl(doc.querySelector('link[rel="canonical"]')?.getAttribute('href') || '', response.finalUrl); const canonicalPlatform = publicPlatform(canonical); if (canonicalPlatform?.kind === initial.kind && canonicalPlatform.id && (!expected.id || canonicalPlatform.id === expected.id)) expected = canonicalPlatform; login ||= /\/(?:accounts\/login|login|challenge)(?:\/|\?|$)/.test(response.finalUrl); const pageUrl = canonicalPlatform?.id === expected.id ? canonical : response.finalUrl; const media = extractSocialCandidates(doc, pageUrl, response.text, expected, { assets: true }); if (media.length) return media; const generic = extractPublicMediaCandidates(doc, response.finalUrl, { scanRawScripts: false }); all.push(...generic.filter(item => !publicPlatform(item.url) && (isHls(item.url, item.type) || knownDirectFormatHint(item.url, item.type))) .map(item => ({ ...item, platform: initial.kind, postId: expected.id, pageUrl: url, detectedAt: Date.now() }))); if (all.length) return all.sort(compareMediaQuality); if (expected.id && !expected.music) { const embed = initial.kind === 'tiktok' ? `https://www.tiktok.com/player/v1/${expected.id}` : `https://www.instagram.com/p/${expected.id}/embed/`; if (!visited.has(embed)) queue.push(embed); } } catch (error) { checkMediaAbort(context); firstError ||= error; if (error.status === 429 || error.code === 'MEDIA_CANCELLED') throw error; if (expected.id && !expected.music && visited.size === 1) queue.push(initial.kind === 'tiktok' ? `https://www.tiktok.com/player/v1/${expected.id}` : `https://www.instagram.com/p/${expected.id}/embed/`); } } if (initial.kind === 'tiktok' && !initial.music && workerBase()) { const viaWorker = await workerTikTokCandidates(url, context.signal).catch(error => { if (error.code === 'MEDIA_CANCELLED' || error.proxyFinal) throw error; return []; }); if (viaWorker.length) return viaWorker; } if (firstError && !pagesRead && ![401, 403, 404].includes(firstError.status)) throw firstError; const reason = [401, 403, 404, 410].includes(firstError?.status) && !pagesRead ? ` Motivo: ${firstError.status === 404 || firstError.status === 410 ? 'o vídeo não existe mais ou o link é privado' : `a plataforma bloqueou a leitura automática (HTTP ${firstError.status})`}.` : ''; const error = mediaError(login ? `${initial.name} exigiu login e não publicou uma mídia acessível nesse link.` : `${initial.name} não disponibilizou uma mídia legível (vídeo, foto ou áudio) nesta consulta.${reason} Abra o post no navegador, reproduza e use “Nesta página”.`, 'MEDIA_PUBLIC_UNAVAILABLE', firstError?.status || 0); error.cause = firstError; throw error; } async function refreshPublicItem(item, options = {}) { if (!item.platform || !item.pageUrl || !item.postId) return item; const candidates = await resolveSocialLink(item.pageUrl, options.keepSession ? { keepSession: true } : {}); const fresh = candidates.find(candidate => candidate.postId === item.postId && candidate.sourceKey && candidate.sourceKey === item.sourceKey) || candidates.find(candidate => candidate.postId === item.postId && candidate.width === item.width && candidate.height === item.height && candidate.codec === item.codec && candidate.noWatermark === item.noWatermark && candidate.sourceRole === item.sourceRole); if (!fresh) throw mediaError('A fonte não publicou novamente a qualidade escolhida. Faça uma nova busca.', 'MEDIA_EXPIRED'); return fresh; } // --------------------------------------------------------------------------- // Link renewal // Re-reads the source page when a signed URL expires. // --------------------------------------------------------------------------- const RENEWABLE_STATUS = Object.freeze([403, 404, 410, 412]); const isRenewableLinkError = error => RENEWABLE_STATUS.includes(directErrorStatus(error)); const canRenewLinkItem = (item, error) => !item.platform && item.sourceScope === 'link' && Boolean(item.pageUrl) && isRenewableLinkError(error); function renewalPath(url) { try { return decodeURIComponent(new URL(url).pathname).replace(/\/+$/, ''); } catch { return String(url || ''); } } function pickRenewedCandidate(found, item) { const cls = candidate => candidate.kind === 'HLS' ? 'HLS' : 'DIRECT'; const same = found.filter(candidate => cls(candidate) === cls(item)); const exact = same.find(candidate => mediaIdentityKey(candidate.url, cls(candidate)) === mediaIdentityKey(item.url, cls(item))); if (exact) return exact; const sameQuality = candidate => Number(candidate.height || 0) === Number(item.height || 0) && Number(candidate.bandwidth || 0) === Number(item.bandwidth || 0); const byPath = same.filter(candidate => renewalPath(candidate.url) === renewalPath(item.url)); if (byPath.length) return byPath.find(sameQuality) || byPath[0]; if (same.length === 1 && (!item.height || !same[0].height || Number(same[0].height) === Number(item.height))) return same[0]; return null; } function linkStatusExplanation(status) { if (status === 404 || status === 410) return 'o arquivo não existe mais no servidor (vídeo removido) ou o endereço expirou'; if (status === 412) return 'o servidor recusa o acesso porque uma condição exigida (Referer/Origin ou sessão) não foi atendida'; return 'o servidor recusa este acesso (login, região ou proteção contra download direto)'; } async function renewLinkItem(item, status, signal) { let found; try { found = await resolvePublicLink(item.pageUrl, new Set(), 0, { signal }); } catch (error) { if (error.code === 'MEDIA_CANCELLED') throw error; const failure = mediaError(`O endereço do arquivo respondeu HTTP ${status} e não consegui reler a página de origem para renová-lo (${error.status ? `HTTP ${error.status}` : error.message}). Nenhum arquivo foi salvo.`, 'MEDIA_RENEW_PAGE', status); failure.cause = error; throw failure; } const candidate = pickRenewedCandidate(found, item); if (!candidate) { throw mediaError(`O endereço do arquivo respondeu HTTP ${status} e a página de origem, lida de novo, não publica mais este arquivo (${linkStatusExplanation(status)}). Nenhum arquivo foi salvo.`, 'MEDIA_RENEW_GONE', status); } return { url: candidate.url, candidate, same: candidate.url === item.url }; } function renewedFailure(beforeUrl, afterUrl, error, first = null) { const status = directErrorStatus(error); if (!status || error?.code === 'MEDIA_CANCELLED') return error; const from = sourceHost(beforeUrl), to = sourceHost(afterUrl); const failure = mediaError(`O endereço foi renovado pela página${from && to && from !== to ? ` (${from} → ${to})` : ''}, mas a origem também recusou com HTTP ${status}: ${error.message}`, 'MEDIA_RENEW_FAILED', status); failure.cause = error; failure.hlsDiagnosis = error.hlsDiagnosis; failure.hlsRefusedAll = error.hlsRefusedAll; if (error.hlsTrace || first?.hlsTrace) failure.hlsTrace = `${first?.hlsTrace ? `ANTES da renovação:\n${first.hlsTrace}\n\n` : ''}${error.hlsTrace ? `DEPOIS da renovação:\n${error.hlsTrace}` : ''}`.trim(); return failure; } // --------------------------------------------------------------------------- // Public item downloads // Downloads resolved social items straight from the browser. // --------------------------------------------------------------------------- async function downloadPublicItemDirect(item, title, options) { if (item.via === 'worker') return downloadDirectSmart(item.url, title, { ...options, retryAttempts: 2, allowPageFallback: false, typeHint: item.type || 'video/mp4', kindHint: item.kind || 'MP4' }); let current = item; if (!item.platform && needsClassification(item)) { const found = await classifyMediaUrl(item.url, { referrer: item.pageUrl || item.url, publicOnly: true, sessionLink: true, signal: options.signal }); const hlsOptions = url => downloadHlsAsMp4(url, { filenameBase: title, pageUrl: item.pageUrl, allowPageFallback: false, sessionLink: true, onProgress: options.onProgress }); if (found.type === 'html') throw mediaError(`O endereço desta opção é uma página HTML${found.title ? ` (“${found.title}”)` : ''}, não um arquivo de vídeo. Nada foi salvo. Faça uma nova busca pelo link.`, 'MEDIA_HTML'); if (found.type === 'hls') return hlsOptions(found.url); if (found.type === 'media') current = { ...item, url: found.url, kind: found.format.label, type: found.format.mime }; if (found.type === 'json') { const list = found.candidates; const pick = list.find(c => item.height && c.height === item.height) || [...list].sort((a, b) => Number(b.height || 0) - Number(a.height || 0) || Number(b.score || 0) - Number(a.score || 0))[0]; if (!pick) throw mediaError(`A fonte devolve dados JSON em vez de vídeo (${found.summary}). Nada foi salvo.`, 'MEDIA_JSON'); if (pick.kind === 'HLS') return hlsOptions(pick.url); current = { ...item, ...pick, pageUrl: item.pageUrl, sourceScope: item.sourceScope }; } if (found.type === 'error' && [404, 410].includes(Number(found.error?.status))) { throw mediaError(`O endereço desta opção respondeu HTTP ${found.error.status} (${sourceHost(item.url) || 'origem'}): expirou ou o arquivo foi removido. Nada foi salvo.`, 'MEDIA_FILE_GONE', found.error.status); } } if (item.platform && Date.now() - Number(item.detectedAt || 0) > 45000) { options.onProgress?.(null, 'Atualizando a URL pública...'); try { current = await refreshPublicItem(item); } catch {} } let firstError; const canProxyTikTok = current.platform === 'tiktok' && Boolean(LINK_PROXY) && Boolean(current.pageUrl); const candidates = [current.url, ...(current.alternatives || []).slice(0, 2)]; for (const [candidateIndex, url] of candidates.entries()) { try { return await downloadDirectSmart(url, title, candidateIndex ? { ...options, retryAttempts: 2 } : options); } catch (error) { if (error.code === 'MEDIA_CANCELLED') throw error; if (error.status === 429 || error.cause?.status === 429) throw error; if (isManualSaveRequired(error)) throw error; if (isManualOpenRequired(error) && !Number(error.cause?.status) && !(canProxyTikTok && isNetworkFailure(error))) throw error; firstError ||= error; if (!(isManualOpenRequired(error) || isTransientError(error) || isRenewableLinkError(error))) throw error; } } if (canRenewLinkItem(current, firstError)) { const status = directErrorStatus(firstError); options.onProgress?.(null, `O servidor respondeu HTTP ${status}. Relendo a página de origem para renovar o endereço...`, { phase: 'retry' }); const renewal = await renewLinkItem(current, status, options.signal); if (renewal.same) { throw mediaError(`A página de origem ainda publica este mesmo endereço, mas o servidor responde HTTP ${status}: ${linkStatusExplanation(status)}.${firstError?.hlsDiagnosis ? ` Diagnóstico: ${firstError.hlsDiagnosis}.` : ''} Nenhum arquivo foi salvo.`, 'MEDIA_RENEW_SAME', status); } options.onProgress?.(null, 'Endereço renovado pela página. Tentando de novo...', { phase: 'retry' }); try { return await downloadDirectSmart(renewal.url, title, { ...options, retryAttempts: 2 }); } catch (error) { if (error.code === 'MEDIA_CANCELLED' || isManualSaveRequired(error) || isManualOpenRequired(error)) throw error; const status2 = directErrorStatus(error); const failure = mediaError(`O endereço foi renovado pela página, mas o servidor também respondeu${status2 ? ` HTTP ${status2}` : ' com erro'}: ${error.message}`, 'MEDIA_RENEW_FAILED', status2); failure.cause = error; throw failure; } } if (current.platform) { const fresh = await refreshPublicItem(current, { keepSession: true }).catch(() => null); if (fresh) { try { return await downloadDirectSmart(fresh.url, title, { ...options, retryAttempts: 2 }); } catch (error) { if (error.code === 'MEDIA_CANCELLED' || isManualSaveRequired(error) || isManualOpenRequired(error)) throw error; firstError = error; } } } if (canProxyTikTok && ([401, 403, 404, 410].includes(directErrorStatus(firstError)) || isNetworkFailure(firstError))) { const manualOpen = isManualOpenRequired(firstError) ? firstError : null; const proxied = `${LINK_PROXY.replace(/\/+$/, '')}/dl?url=${encodeURIComponent(current.pageUrl)}${LINK_PROXY_KEY ? `&key=${encodeURIComponent(LINK_PROXY_KEY)}` : ''}`; await proxyPreflight(proxied, options.signal); try { return await downloadDirectSmart(proxied, title, { ...options, retryAttempts: 2 }); } catch (error) { if (error.code === 'MEDIA_CANCELLED' || isManualSaveRequired(error) || isManualOpenRequired(error)) throw error; firstError = manualOpen || error; } } throw platformDownloadError(current, firstError); } // --------------------------------------------------------------------------- // Worker client // Talks to your optional Worker for TikTok and other sites and maps its errors to messages. // --------------------------------------------------------------------------- const WORKER_REASONS = Object.freeze({ HOST_NOT_ALLOWED: 'este site não está em ALLOWED_HOSTS do seu Worker (ou o Worker está só para TikTok)', NOT_CONFIGURED: 'o Worker não tem TOKEN_KEY definido', BAD_KEY: 'o Worker recusou a chave (LINK_PROXY_KEY)', RATE_LIMIT: 'o Worker limitou as requisições deste IP', DRM_PROTECTED: 'a transmissão usa proteção DRM, que o Worker não baixa', ORIGIN_BLOCKED: 'o site também bloqueou o servidor do Worker', ORIGIN_CHALLENGE: 'o site pediu verificação anti-robô ao servidor do Worker', NO_MEDIA_URLS: 'a página não publica um arquivo/playlist acessível ao Worker (o player pode montar a URL por script ou exigir login)', VIDEO_NOT_FOUND: 'o site informa que a página não existe mais', CDN_REFUSED: 'o servidor de vídeo também recusou o Worker', CDN_NOT_FOUND: 'o servidor de vídeo não tem mais o arquivo', WORKER_UNREACHABLE: 'não consegui falar com o Worker (rede ou CORS)', }); const WORKER_RETRY_CODES = new Set(['MEDIA_SEGMENT_REFUSED', 'MEDIA_RENEW_SAME', 'MEDIA_RENEW_FAILED', 'MEDIA_RENEW_GONE', 'MEDIA_RENEW_PAGE', 'MEDIA_FILE_GONE']); function workerBase() { return String(LINK_PROXY || '').replace(/\/+$/, ''); } function canTryLinkWorker(item, error) { if (!LINK_PROXY || !LINK_PROXY_GENERIC || !item || item.platform) return false; const target = item.pageUrl || item.url || ''; if (!/^https:\/\//i.test(target) || isPrivateNetworkUrl(target)) return false; if (!error || error.code === 'MEDIA_CANCELLED' || isManualSaveRequired(error) || isManualOpenRequired(error)) return false; return [401, 403, 404, 410].includes(directErrorStatus(error)) || WORKER_RETRY_CODES.has(error.code) || isNetworkFailure(error); } async function fetchWorkerInfo(pageUrl, signal) { const url = `${workerBase()}/info?url=${encodeURIComponent(pageUrl)}${LINK_PROXY_KEY ? `&key=${encodeURIComponent(LINK_PROXY_KEY)}` : ''}`; let response; try { response = await fetch(url, { cache: 'no-store', credentials: 'omit', signal }); } catch (error) { if (signal?.aborted) throw mediaError('Download cancelado.', 'MEDIA_CANCELLED'); throw Object.assign(new Error(WORKER_REASONS.WORKER_UNREACHABLE), { workerCode: 'WORKER_UNREACHABLE' }); } let body = null; try { body = await response.json(); } catch {} if (!response.ok || !body?.ok) { const code = body?.code || `HTTP_${response.status}`; const note = WORKER_REASONS[code] || body?.error || `o Worker respondeu HTTP ${response.status}`; throw Object.assign(new Error(note), { workerCode: code, workerStatus: Number(body?.upstreamStatus || response.status) || 0 }); } const origin = new URL(workerBase()).origin; body.candidates = (Array.isArray(body.candidates) ? body.candidates : []).filter(c => { try { return ['mp4', 'hls'].includes(c.kind) && new URL(c.url).origin === origin; } catch { return false; } }); return body; } function workerFailureMessage(error) { if (!error) return 'sem detalhes'; const status = directErrorStatus(error); const note = error.workerCode ? (WORKER_REASONS[error.workerCode] || error.message) : ''; return `${note || error.message || 'falha'}${status && !note ? ` (HTTP ${status})` : ''}`; } async function workerTikTokCandidates(pageUrl, signal) { const base = workerBase(); if (!base) return []; const url = `${base}/tt?url=${encodeURIComponent(pageUrl)}${LINK_PROXY_KEY ? `&key=${encodeURIComponent(LINK_PROXY_KEY)}` : ''}`; let response; try { response = await fetch(url, { cache: 'no-store', credentials: 'omit', signal }); } catch (error) { if (signal?.aborted) throw mediaError('Busca cancelada.', 'MEDIA_CANCELLED'); return []; } let body = null; try { body = await response.json(); } catch {} if (!response.ok || !body?.ok) { const final = PROXY_FINAL_CODES[body?.code]; if (final && body.code !== 'UNSUPPORTED_POST') { const error = mediaError(final, `PROXY_${body.code}`, response.status); error.proxyFinal = true; throw error; } return []; } const origin = new URL(base).origin; const own = raw => { try { const u = new URL(raw, `${base}/`); return u.origin === origin ? u.href : ''; } catch { return ''; } }; const common = { pageUrl, platform: 'tiktok', via: 'worker', trusted: true, sourceScope: 'link', detectedAt: Date.now(), postId: String(body.id || '') }; const cover = own(body.cover || ''); const title = cleanDisplayText(body.title || '') || 'TikTok'; const out = []; const images = (Array.isArray(body.images) ? body.images : []).map((img, index) => ({ ...img, index: Number(img.index ?? index), url: own(img.url) })).filter(img => img.url); if (images.length > 1) out.push({ ...common, kind: 'ZIP', mediaType: 'zip', url: `${String(pageUrl).split('#')[0]}#optdowns-zip`, title: `Todas as ${images.length} fotos · ${title}`, thumbnail: images[0].url, imageCount: images.length, images: images.map(img => ({ url: img.url, alternatives: [], imageIndex: img.index + 1, width: img.width, height: img.height })), qualityLabel: 'Melhor qualidade', label: 'Todas as fotos em um arquivo', score: 130, sourceKey: `${common.postId}:zip`, fileBase: `tiktok-${common.postId}-fotos` }); for (const video of Array.isArray(body.videos) ? body.videos : []) { const href = own(video.url); if (!href) continue; out.push({ ...common, kind: 'MP4', type: 'video/mp4', url: href, title, thumbnail: cover, width: Number(video.width || 0), height: Number(video.height || 0), bandwidth: Number(video.bitrate || 0), duration: Number(body.duration || 0), codec: String(video.codec || ''), qualityLabel: video.label || hdLabel(video.width, video.height), label: `Arquivo publicado · pelo Worker${video.codec ? ` · ${String(video.codec).toUpperCase()}` : ''}`, score: 100, sourceKey: `${common.postId}:w:${video.height || 0}:${video.bitrate || 0}` }); } for (const img of images) out.push({ ...common, kind: 'IMAGEM', mediaType: 'image', type: 'image/jpeg', url: img.url, title: images.length > 1 ? `Foto ${img.index + 1} de ${images.length} · ${title}` : title, thumbnail: img.url, width: Number(img.width || 0), height: Number(img.height || 0), imageIndex: img.index + 1, imageCount: images.length, qualityLabel: imageQualityLabel(img.width, img.height) || 'Melhor qualidade', label: images.length > 1 ? `Imagem ${img.index + 1}/${images.length}` : 'Imagem publicada', score: 100, sourceKey: `${common.postId}:img:${img.index + 1}`, fileBase: `tiktok-${common.postId}-${String(img.index + 1).padStart(2, '0')}` }); const audioUrl = own(body.audio?.url || ''); if (audioUrl) out.push({ ...common, kind: 'MP3', mediaType: 'audio', type: 'audio/mpeg', url: audioUrl, title: cleanDisplayText([body.audio.title, body.audio.author].filter(Boolean).join(' — ')) || 'Áudio', audioTitle: cleanDisplayText(body.audio.title || ''), audioArtist: cleanDisplayText(body.audio.author || ''), thumbnail: cover, duration: Number(body.audio.duration || 0), qualityLabel: 'MP3', label: body.audio.original ? 'Som original do vídeo' : 'Música', score: 90, sourceKey: `${common.postId}:audio`, fileBase: cleanFilenameBase(`${body.audio.title || 'tiktok'}-${body.audio.author || ''}`) }); return out; } // --------------------------------------------------------------------------- // YouTube // Option lists, cards and downloads for YouTube through your Worker. // --------------------------------------------------------------------------- function ytSizeText(option) { const bytes = Number(option?.sizeBytes || 0); if (!(bytes > 0)) return ''; const text = bytes >= 1073741824 ? `${(bytes / 1073741824).toFixed(2)} GB` : `${(bytes / 1048576).toFixed(1)} MB`; return option.sizeKind === 'estimated' ? `≈ ${text} (estimado pela fonte)` : text; } async function workerYouTubeCandidates(pageUrl, signal, onStage) { const base = workerBase(), parsed = youtubeLink(pageUrl); if (!youtubeEnabled() || !parsed) throw mediaError('O YouTube precisa do Worker próprio: preencha LINK_PROXY no script (e LINK_PROXY_YOUTUBE = true).', 'YT_NO_WORKER'); onStage?.('Consultando título, miniatura e duração...'); const key = LINK_PROXY_KEY ? `&key=${encodeURIComponent(LINK_PROXY_KEY)}` : ''; const url = `${base}/yt/info?url=${encodeURIComponent(pageUrl)}${LINK_PROXY_YOUTUBE_SIZES ? '&sizes=1' : ''}${key}`; const slow = setTimeout(() => onStage?.('Confirmando as qualidades realmente disponíveis...'), 2200); let response; try { response = await fetch(url, { cache: 'no-store', credentials: 'omit', signal }); } catch (error) { if (signal?.aborted) throw mediaError('Busca cancelada.', 'MEDIA_CANCELLED'); throw mediaError('Não consegui falar com o seu Worker (rede ou CORS). Confira LINK_PROXY e se o Worker está publicado.', 'YT_WORKER_UNREACHABLE'); } finally { clearTimeout(slow); } let body = null; try { body = await response.json(); } catch {} if (!response.ok || !body?.ok) { if (body?.code === 'NO_ROUTE') throw mediaError('O seu Worker é uma versão antiga, sem suporte ao YouTube. Publique o worker.js atualizado.', 'YT_OLD_WORKER', response.status); const message = `${body?.error || `O Worker respondeu HTTP ${response.status}.`}${body?.hint ? ` ${body.hint}` : ''}`; throw mediaError(message, `YT_${body?.code || `HTTP_${response.status}`}`, response.status); } const origin = new URL(base).origin; const own = raw => { try { const x = new URL(raw, `${base}/`); return x.origin === origin ? x.href : ''; } catch { return ''; } }; const title = cleanDisplayText(body.title || '') || 'Vídeo do YouTube'; const author = cleanDisplayText(body.author || ''); const thumbnail = /^https:\/\/i\d?\.ytimg\.com\//i.test(body.thumbnail || '') ? body.thumbnail : ''; const duration = Number(body.duration || 0) > 0 ? Number(body.duration) : 0; const common = { pageUrl, platform: 'youtube', via: 'worker', trusted: true, sourceScope: 'link', detectedAt: Date.now(), postId: String(body.id || parsed.id) }; const options = []; for (const o of Array.isArray(body.options) ? body.options : []) { const href = own(o.url); if (!href || !['video', 'audio'].includes(o.type)) continue; const sizeBytes = Number(o.size?.bytes) > 0 ? Number(o.size.bytes) : 0; const shared = { ...common, url: href, title, thumbnail, duration, sizeBytes, sizeKind: sizeBytes ? String(o.size.kind || '') : '', optionId: String(o.id || ''), fileBase: cleanFilenameBase(o.fileBase || title), sourceKey: `${common.postId}:yt:${o.id}` }; if (o.type === 'audio') options.push({ ...shared, kind: 'MP3', mediaType: 'audio', type: 'audio/mpeg', qualityLabel: 'MP3', label: 'Áudio separado · MP3', audioTitle: title, audioArtist: author, score: 90 }); else options.push({ ...shared, kind: String(o.container || 'mp4').toUpperCase(), type: String(o.mime || 'video/mp4'), width: Number(o.width || 0), height: Number(o.height || 0), codec: String(o.codec || ''), qualityLabel: o.quality ? `${o.quality}p` : `até ${o.requestedQuality}p`, qualityConfirmed: Boolean(o.quality), label: String(o.label || ''), score: 100 + Number(o.quality || 0) }); } if (!options.length) throw mediaError('O Worker não devolveu nenhuma qualidade baixável para este vídeo.', 'YT_NO_OPTIONS'); return [{ ...common, kind: 'YOUTUBE', mediaType: 'youtube', url: `https://www.youtube.com/watch?v=${parsed.id}#optdowns-yt`, title, author, thumbnail, duration, isShort: body.kind === 'short', options, warnings: (Array.isArray(body.warnings) ? body.warnings : []).slice(0, 4).map(w => cleanDisplayText(String(w), 220)), sourceKey: `${common.postId}:yt` }]; } // Asks the Worker for confirmed YouTube options: title, thumbnail and qualities. async function resolveYouTubeLink(url, context = {}) { checkMediaAbort(context); const results = document.getElementById('vdd-link-results'); let skeleton = null; if (results && !context.noSkeleton) { skeleton = document.createElement('div'); skeleton.className = 'vdd-yt-skel'; skeleton.setAttribute('aria-hidden', 'true'); skeleton.innerHTML = '
'; results.replaceChildren(skeleton); delete results.dataset.signature; } try { return await workerYouTubeCandidates(url, context.signal, context.onStage); } finally { if (skeleton?.isConnected) skeleton.remove(); } } function createYouTubeCard(item, index, sourceScope = 'link') { const card = document.createElement('article'); card.className = 'vdd-card vdd-yt'; card.dataset.media = 'video'; const title = cleanDisplayText(item.displayTitle || item.title) || 'Vídeo do YouTube'; const options = item.options || []; const byId = new Map(options.map(option => [option.sourceKey, option])); const videos = options.filter(option => !option.mediaType), audios = options.filter(option => option.mediaType === 'audio'); const mp4 = videos.filter(option => option.kind === 'MP4'), other = videos.filter(option => option.kind !== 'MP4'); const first = mp4[0] || videos[0] || audios[0]; const live = { ...first, sourceScope }; const duration = formatDuration(item.duration); const optionText = option => [option.mediaType === 'audio' ? 'Somente áudio · MP3' : option.label || option.qualityLabel, ytSizeText(option)].filter(Boolean).join(' · '); const group = (label, list) => list.length ? `${list.map(option => ``).join('')}` : ''; card.innerHTML = `
${duration ? `${escapeHtml(duration)}` : ''}
${escapeHtml(title)}
${siteIcon('https://www.youtube.com/')}youtube.com${item.isShort ? ' · Short' : ''}${item.author ? ` · ${escapeHtml(item.author)}` : ''}
Preparando...
`; const select = card.querySelector('.vdd-yt-quality'), chips = card.querySelector('.vdd-meta-row'), note = card.querySelector('.vdd-yt-note'); const render = () => { const o = live, size = ytSizeText(o); chips.innerHTML = [ `${escapeHtml(o.kind)}`, o.mediaType !== 'audio' ? `${escapeHtml(o.qualityLabel)}` : '', o.width && o.height ? `${o.width}×${o.height}` : '', o.codec ? `${escapeHtml(o.codec.toUpperCase())}` : '', size ? `${escapeHtml(size)}` : '', item.isShort ? 'Short' : '', ].filter(Boolean).join(''); const lines = []; lines.push(o.mediaType === 'audio' ? 'MP3 gerado pela sua instância Cobalt a partir do áudio do vídeo.' : o.qualityConfirmed === false ? 'A instância não informou a resolução final: “até” é o teto pedido.' : 'Qualidade confirmada pela sua instância Cobalt.'); lines.push(size ? `Tamanho informado pela fonte: ${size}.` : 'Tamanho não informado: o total só aparece se a fonte enviá-lo durante o download.'); if (item.warnings?.length) lines.push(`Avisos: ${item.warnings.join(' ')}`); note.textContent = lines.join(' '); }; select.value = first.sourceKey; render(); select.addEventListener('change', () => { const option = byId.get(select.value); if (!option) return; for (const key of Object.keys(live)) delete live[key]; Object.assign(live, option, { sourceScope }); render(); }); const thumb = card.querySelector('.vdd-thumb'), thumbnail = normalizeThumbUrl(item.thumbnail || ''); if (thumbnail) loadThumbInto(thumb, thumbnail, item.pageUrl || ''); attachDownloadHandler(card, live, () => live.fileBase || title); new MutationObserver(() => { select.disabled = ['running', 'queued'].includes(card.dataset.dl || ''); }).observe(card, { attributes: true, attributeFilter: ['data-dl'] }); return card; } // Downloads through your Worker and keeps the real file type (WebM is not labeled MP4). async function downloadViaWorker(item, title, options, firstError) { const target = item.pageUrl || item.url; options.onProgress?.(null, 'O download direto foi recusado. Pedindo ao seu Worker para tentar este link...', { phase: 'retry' }); let info; try { info = await fetchWorkerInfo(target, options.signal); } catch (error) { if (error.code === 'MEDIA_CANCELLED') throw error; firstError.message += ` O seu Worker também foi consultado e não ajudou: ${workerFailureMessage(error)}.`; firstError.workerCode = error.workerCode; throw firstError; } const wantHls = /hls/i.test(String(item.kind || '')) || isHls(item.url, item.type) || firstError.code === 'MEDIA_SEGMENT_REFUSED'; const usable = info.candidates.filter(c => !c.preview); const ordered = [...usable.filter(c => (c.kind === 'hls') === wantHls), ...usable.filter(c => (c.kind === 'hls') !== wantHls)].slice(0, 2); if (!ordered.length) { firstError.message += ' O seu Worker leu a página, mas não achou arquivo ou playlist para baixar.'; throw firstError; } let lastError = null; for (const candidate of ordered) { try { options.onProgress?.(null, candidate.kind === 'hls' ? 'Baixando os segmentos HLS pelo Worker...' : 'Baixando o arquivo pelo Worker...', { phase: 'retry' }); if (candidate.kind === 'hls') { return await downloadHlsAsMp4(candidate.url, { filenameBase: title, pageUrl: target, allowPageFallback: false, sessionLink: false, onProgress: options.onProgress, onTrace: options.onTrace }); } return await downloadDirectSmart(candidate.url, title, { ...options, retryAttempts: 2, allowPageFallback: false, typeHint: 'video/mp4', kindHint: 'MP4' }); } catch (error) { if (error.code === 'MEDIA_CANCELLED' || isManualSaveRequired(error) || isManualOpenRequired(error)) throw error; lastError = error; } } firstError.message += ` O seu Worker também tentou e falhou: ${workerFailureMessage(lastError)}.`; firstError.workerError = lastError; throw firstError; } async function downloadPublicItem(item, title, options) { try { return await downloadPublicItemDirect(item, title, options); } catch (error) { if (!canTryLinkWorker(item, error)) throw error; return downloadViaWorker(item, title, options, error); } } // --------------------------------------------------------------------------- // Link resolution // Chooses between the direct and Worker paths for a pasted link and explains failures. // --------------------------------------------------------------------------- const PROXY_FINAL_CODES = Object.freeze({ VIDEO_NOT_FOUND: 'O TikTok informa que este vídeo não existe mais ou o link é inválido.', VIDEO_UNAVAILABLE: 'O vídeo está indisponível no TikTok (privado, removido ou restrito). Não há arquivo para baixar.', UNSUPPORTED_POST: 'Este link é uma publicação de fotos, não um vídeo.', CDN_NOT_FOUND: 'O servidor de vídeo do TikTok não tem mais o arquivo (expirado ou removido), mesmo após reler a página.', BAD_KEY: 'O Worker recusou a chave (LINK_PROXY_KEY).', HOST_NOT_ALLOWED: 'O Worker só aceita links públicos do TikTok.', }); async function proxyPreflight(proxiedUrl, signal) { let response; try { response = await fetch(proxiedUrl, { method: 'GET', headers: { Range: 'bytes=0-0' }, cache: 'no-store', credentials: 'omit', signal }); } catch (error) { if (signal?.aborted) throw mediaError('Download cancelado.', 'MEDIA_CANCELLED'); return; } try { if (response.ok) return; let info = null; if (/json/i.test(response.headers.get('content-type') || '')) { try { info = await response.json(); } catch {} } if (!info?.code) return; const final = PROXY_FINAL_CODES[info.code]; const stage = info.stage ? ` [etapa: ${info.stage}${info.upstreamStatus ? `, HTTP ${info.upstreamStatus} da origem` : ''}]` : ''; const error = mediaError(`${final || info.error || 'O Worker não conseguiu obter o vídeo.'}${stage}${final ? '' : (info.hint ? ` ${info.hint}` : '')}`, `PROXY_${info.code}`, response.status); error.proxyFinal = Boolean(final); throw error; } finally { try { await response.body?.cancel(); } catch {} } } function platformDownloadError(item, error) { const status = Number(error?.status || error?.cause?.status || 0); if (!item?.platform || ![401, 403].includes(status)) return error; const site = item.platform === 'tiktok' ? 'tiktok.com' : 'instagram.com'; const failure = new Error(`${item.platform === 'tiktok' ? 'O TikTok' : 'O Instagram'} recusou o arquivo (HTTP ${status}). Essas URLs só funcionam com os cookies da sessão que as gerou e o seu gerenciador de userscripts não os repassou ao servidor de vídeo. O script já renovou a URL e tentou de novo. Alternativa: abra o vídeo em ${site}, reproduza por alguns segundos e use “Nesta página”.`); failure.status = status; failure.cause = error; return failure; } function directAudioCandidate(url, pageUrl) { const name = mediaTitleFromUrl(url) || `Áudio de ${sourceHost(url)}`; return { kind: 'MP3', mediaType: 'audio', type: 'audio/mpeg', url, pageUrl, title: name, audioTitle: name, trusted: true, sourceScope: 'link', qualityLabel: 'MP3', label: /\.mp3(?:$|[?#])/i.test(url) ? 'MP3 original · sem recompressão' : 'Áudio · será convertido em MP3', score: 100, detectedAt: Date.now(), fileBase: cleanFilenameBase(name) }; } async function resolvePublicLink(url, visited = new Set(), depth = 0, operation = {}) { assertMediaUrl(url); if (visited.has(url) || depth > 2 || visited.size >= 8) return []; visited.add(url); const context = { ...operation, referrer: url, publicOnly: true, sessionLink: operation.sessionLink !== false }; checkMediaAbort(context); if (youtubeEnabled() && youtubeLink(url)) return resolveYouTubeLink(url, { ...context, sessionLink: false }); if (publicPlatform(url)) return resolveSocialLink(url, { ...context, sessionLink: false }); if (isAudioUrl(url)) return [directAudioCandidate(url, url)]; const known = await extractKnownPublicSiteCandidates(url).catch(() => []); if (known.length) return known; const probed = await probeLinkTarget(url, context); if (probed?.audio) return [directAudioCandidate(probed.url, url)]; if (probed?.page && publicPlatform(probed.url)) return resolveSocialLink(probed.url, { ...context, sessionLink: false }); if (probed && !probed.page) return [{ kind: probed.kind, type: probed.contentType, url: probed.url, title: mediaTitleFromUrl(probed.url) || `Vídeo de ${sourceHost(probed.url)}`, pageUrl: url, label: probed.confirmed ? 'Formato confirmado' : 'Formato informado pela fonte', trusted: true }]; const response = await requestLinkPage(probed?.url || url, context); const finalUrl = response.finalUrl; if (/^\s*#EXTM3U(?:\s|$)/.test(response.text)) return [{ kind: 'HLS', url: finalUrl, pageUrl: url, type: 'application/vnd.apple.mpegurl', title: mediaTitleFromUrl(finalUrl) }]; if (/ ({ ...item, pageUrl: finalUrl })); const doc = new DOMParser().parseFromString(response.text, 'text/html'); if (/^\s*[\[{]/.test(response.text)) { try { JSON.parse(response.text); const json = doc.createElement('script'); json.type = 'application/json'; json.textContent = response.text; doc.body.appendChild(json); } catch {} } const declaredBase = safeHttpUrl(doc.querySelector('base[href]')?.getAttribute('href') || '', finalUrl); const base = declaredBase || finalUrl; const candidates = extractPublicMediaCandidates(doc, base, { scanRawScripts: true, rawText: response.text }).map(item => ({ ...item, pageUrl: finalUrl })); const embeds = [...doc.querySelectorAll('iframe[src], meta[name="twitter:player"], meta[property="og:video"], meta[property="og:video:url"], meta[property="og:video:secure_url"]')] .map(node => safeHttpUrl(node.getAttribute('src') || node.getAttribute('content'), base)) .filter(link => link && !isPrivateNetworkUrl(link) && !restrictedPlatformNotice(link) && !isHls(link) && !knownDirectFormatHint(link)); const settled = await Promise.allSettled([...new Set(embeds)].slice(0, 3).map(link => resolvePublicLink(link, visited, depth + 1, context))); for (const result of settled) if (result.status === 'fulfilled') candidates.push(...result.value); const notes = context.notes || []; const pending = candidates.filter(needsClassification).sort((a, b) => Number(b.score || 0) - Number(a.score || 0)).slice(0, 6); const classified = new Map(); let recursed = 0; for (const candidate of pending) { checkMediaAbort(context); const result = await classifyMediaUrl(candidate.url, { ...context, referrer: finalUrl }); const inherit = item => ({ title: candidate.title, thumbnail: candidate.thumbnail, ...item, pageUrl: finalUrl }); let list = [candidate]; if (result.type === 'media') list = [{ ...candidate, url: result.url, kind: result.format.label, type: result.format.mime, verified: true }]; else if (result.type === 'hls') list = [{ ...candidate, url: result.url, kind: 'HLS', type: 'application/vnd.apple.mpegurl', verified: true }]; else if (result.type === 'json') { list = result.candidates.map(inherit); if (!list.length) notes.push(`Uma URL do player devolveu JSON sem endereços de vídeo (${result.summary}).`); } else if (result.type === 'html') { list = []; notes.push(`Uma URL sugerida pela página era uma página HTML${result.title ? ` (“${result.title}”)` : ''}, não um vídeo; foi descartada.`); if (depth < 2 && recursed < 2 && !visited.has(result.url) && !isPrivateNetworkUrl(result.url)) { recursed++; try { list = await resolvePublicLink(result.url, visited, depth + 1, context); } catch (error) { if (error.code === 'MEDIA_CANCELLED') throw error; } } } else if (result.type === 'error' && [404, 410].includes(Number(result.error?.status))) { list = []; notes.push(`Uma URL de mídia sugerida pela página respondeu HTTP ${result.error.status} (${sourceHost(candidate.url) || 'origem'}) e foi descartada.`); } classified.set(candidate, list); } const expanded = candidates.flatMap(candidate => classified.has(candidate) ? classified.get(candidate) : [candidate]); const unique = new Map(); for (const candidate of expanded) { if (embeds.includes(candidate.url) && candidate.kind !== 'HLS' && !knownDirectFormatHint(candidate.url, candidate.type)) continue; const key = mediaIdentityKey(candidate.url, candidate.kind === 'HLS' ? 'HLS' : 'DIRECT'); if (!unique.has(key) || candidate.score > unique.get(key).score) unique.set(key, candidate); } if (!unique.size) { const hint = pageBlockHint(response.text, doc.title || ''); if (hint) notes.push(hint); } return [...unique.values()].sort(compareMediaQuality); } // --------------------------------------------------------------------------- // Response analysis // Describes bad responses (HTML pages, JSON errors) and finds media inside JSON. // --------------------------------------------------------------------------- function sanitizeForMessage(value, max = 140) { return String(value ?? '').replace(/https?:\/\/[^\s"'<>]+/gi, '[URL]').replace(/\b[A-Za-z0-9_\-+=\/]{28,}\b/g, '[…]') .replace(/\s+/g, ' ').trim().slice(0, max); } function htmlTitleOf(bytes) { try { return cleanDisplayText(new TextDecoder('utf-8').decode(bytes.slice(0, Math.min(bytes.byteLength, 16384))).match(/]*>([\s\S]{1,160}?)<\/title>/i)?.[1] || '').slice(0, 80); } catch { return ''; } } function summarizeJsonBody(bytes) { try { const value = JSON.parse(new TextDecoder('utf-8').decode(bytes)); if (Array.isArray(value)) return `lista com ${value.length} item(ns)`; if (value && typeof value === 'object') { const message = value.error?.message || value.message || (typeof value.error === 'string' ? value.error : '') || value.reason || value.detail || value.status; if (message) return `mensagem da fonte: “${sanitizeForMessage(typeof message === 'object' ? JSON.stringify(message) : message)}”`; return `campos: ${Object.keys(value).slice(0, 6).join(', ') || 'nenhum'}`; } return `valor ${typeof value}`; } catch { return 'JSON incompleto ou ilegível'; } } function describeBodyProblem(bytes, contentType = '') { const head = (() => { try { return new TextDecoder('utf-8').decode(bytes.subarray(0, 64)); } catch { return ''; } })(); if (responseLooksLikeHtml(contentType) || looksLikeHtmlBytes(bytes)) { const title = htmlTitleOf(bytes); return `uma página HTML${title ? ` (título: “${title}”)` : ''}`; } if (/^\s*[\[{]/.test(head) || /json/i.test(String(contentType))) return `dados JSON (${summarizeJsonBody(bytes)})`; if (/^\s*#EXTM3U/.test(head)) return 'uma playlist HLS'; return ''; } function jsonMediaCandidates(root, baseUrl, { allowApi = false } = {}) { const out = [], seen = new Set(); const heightOf = obj => { const raw = obj.height ?? obj.quality ?? obj.label ?? obj.resolution ?? obj.res; const m = String(Array.isArray(raw) ? raw[0] : (raw ?? '')).match(/(\d{3,4})\s*p?/i); return m ? Number(m[1]) : 0; }; const visit = (value, depth) => { if (!value || typeof value !== 'object' || depth > 8) return; if (Array.isArray(value)) { value.slice(0, 200).forEach(item => visit(item, depth + 1)); return; } for (const key of ['videoUrl', 'video_url', 'url', 'src', 'file', 'hls', 'mp4', 'contentUrl', 'streamUrl', 'stream_url']) { const raw = value[key]; if (typeof raw !== 'string' || !raw) continue; const url = safeHttpUrl(decodeEmbeddedUrl(raw), baseUrl); if (!url || seen.has(url) || isPrivateNetworkUrl(url) || restrictedPlatformNotice(url)) continue; const format = String(value.format || value.type || value.mimeType || '').toLowerCase(); const hls = isHls(url, format) || /hls|m3u8|mpegurl/.test(format); const hint = knownDirectFormatHint(url, format); const api = allowApi && !hls && !hint && (/mp4/.test(format) || value.remote === true); if (!hls && !hint && !api) continue; seen.add(url); const height = heightOf(value); out.push({ kind: hls ? 'HLS' : (hint?.label || 'VÍDEO'), type: hls ? 'application/vnd.apple.mpegurl' : (hint?.mime || ''), url, height, width: Number(value.width || 0), trusted: true, verified: !api, label: `${hls ? 'HLS' : 'Arquivo'} publicado pelo player${height ? ` · ${height}p` : ''}`, score: (hls ? 100 : 112) + height / 100 }); } Object.values(value).forEach(item => visit(item, depth + 1)); }; visit(root, 0); return out; } function balancedJsonAt(text, from) { const open = text[from], close = open === '[' ? ']' : open === '{' ? '}' : ''; if (!close) return ''; let depth = 0, inString = false, escaped = false; const limit = Math.min(text.length, from + 262144); for (let i = from; i < limit; i++) { const ch = text[i]; if (inString) { if (escaped) escaped = false; else if (ch === '\\') escaped = true; else if (ch === '"') inString = false; continue; } if (ch === '"') inString = true; else if (ch === open) depth++; else if (ch === close && --depth === 0) return text.slice(from, i + 1); } return ''; } const needsClassification = candidate => Boolean(candidate) && candidate.kind === 'VÍDEO' && !candidate.verified && !candidate.platform && !isBlobUrl(candidate.url) && !knownDirectFormatHint(candidate.url, candidate.type); async function classifyMediaUrl(url, context) { let response; try { const probe = await readPrefix(url, { context, maxBytes: 1048576, timeout: 15000, kind: 'sondagem do formato', range: 'bytes=0-1048575', headers: { Accept: 'application/json,video/*;q=0.9,text/html;q=0.8,*/*;q=0.5' } }); if (probe.limited || !probe.result) return { type: 'large' }; response = probe.result; } catch (error) { if (error.code === 'MEDIA_LIMIT') return { type: 'large' }; return { type: 'error', error }; } const { bytes, contentType, finalUrl } = response; const head = new TextDecoder().decode(bytes.subarray(0, 4096)); if (/^\s*#EXTM3U(?:\s|$)/.test(head)) return { type: 'hls', url: finalUrl }; const format = detectMediaFormatFromBytes(bytes, contentType, finalUrl); if (format?.confirmed && !format.hls) return { type: 'media', url: finalUrl, format }; if (/json/i.test(contentType) || /^\s*[\[{]/.test(head)) { let value = null; try { value = JSON.parse(new TextDecoder().decode(bytes)); } catch {} return { type: 'json', url: finalUrl, summary: summarizeJsonBody(bytes), candidates: value ? jsonMediaCandidates(value, finalUrl) : [] }; } if (responseLooksLikeHtml(contentType) || looksLikeHtmlBytes(bytes) || /]*>[^<]*(?:log ?in|sign ?in|entrar)/i.test(text) || /access denied|forbidden/i.test(title)) return 'A página exigiu login ou negou o acesso sem sessão. O script não usa credenciais: abra o link logado no navegador e use “Nesta página”.'; return ''; } // --------------------------------------------------------------------------- // HLS diagnostics // Token expiry checks, segment probes and request traces for HLS. // --------------------------------------------------------------------------- const HLS_AUTH_PARAM = /^(?:hash|token|sig|signature|expires?|expiry|validto|validfrom|policy|key-pair-id|hdnts|hdl|ipa|ip|auth|exp|st|md5|secure)$/i; const authParamNames = url => { try { return [...new URL(url).searchParams.keys()].filter(key => HLS_AUTH_PARAM.test(key)); } catch { return []; } }; function inheritedQueryUrl(resource) { try { if (!resource.playlistUrl) return ''; const target = new URL(resource.url), playlist = new URL(resource.playlistUrl); if (target.search || !playlist.search || target.origin !== playlist.origin || !authParamNames(playlist.href).length) return ''; target.search = playlist.search; return target.href; } catch { return ''; } } function tokenSecondsLeft(url) { try { const params = new URL(url).searchParams; for (const key of ['validto', 'expires', 'expire', 'exp', 'expiry']) { const value = Number(params.get(key)); if (value > 1e9 && value < 1e10) return value - Date.now() / 1000; } } catch {} return null; } function hlsDiagnosis(resource, tried) { let host = '?', file = '', params = [], playlistParams = []; try { const u = new URL(resource.url); host = u.hostname; file = u.pathname.split('/').pop().slice(0, 60); params = [...u.searchParams.keys()]; } catch {} try { playlistParams = [...new URL(resource.playlistUrl).searchParams.keys()]; } catch {} const parts = [`host ${host}`, `arquivo ${file || '?'}`, `parâmetros da URL: ${params.length ? params.join(', ') : 'nenhum'}`]; try { const playlistHost = new URL(resource.playlistUrl).hostname; if (playlistHost !== host) parts.push(`a playlist veio de ${playlistHost} e o segmento de ${host}`); } catch {} if (!params.length && playlistParams.length) parts.push(`a playlist tinha parâmetros (${playlistParams.join(', ')}) que o segmento não repetiu`); const left = tokenSecondsLeft(resource.url) ?? tokenSecondsLeft(resource.playlistUrl); if (left !== null) parts.push(left < 0 ? `token vencido há ${Math.max(1, Math.round(-left / 60))} min` : `token ainda válido por ${Math.round(left / 60)} min`); if (tried?.inherited) parts.push('repetir os parâmetros da playlist no segmento também foi recusado'); return parts.join(' · '); } const RANGE_REFUSALS = Object.freeze([403, 404, 410, 416]); async function readPrefix(url, { context, maxBytes, timeout = 15000, kind = 'verificação de segmento', headers = {}, range = '' }) { const base = { context, prefix: true, kind, timeout }; let rangeError = null; try { return { result: await mediaRequest(url, { ...base, maxBytes, headers: { ...headers, Range: range || `bytes=0-${maxBytes - 1}` } }), via: 'range', rangeError }; } catch (error) { if (error.code === 'MEDIA_LIMIT') return { result: null, via: 'range', limited: true, rangeError }; if (!RANGE_REFUSALS.includes(Number(error.status))) throw error; rangeError = error; } try { return { result: await mediaRequest(url, { ...base, maxBytes: Math.max(maxBytes, 65536), headers }), via: 'plain', rangeError }; } catch (error) { if (error.code === 'MEDIA_LIMIT') return { result: null, via: 'plain', limited: true, rangeError }; error.rangeError = rangeError; error.probedPlain = true; throw error; } } async function probeSegmentExists(url, context, range = null) { const wanted = range ? `bytes=${range.start}-${range.start + 15}` : ''; const probe = await readPrefix(url, { context, maxBytes: 16, range: wanted }); if (probe.result) { const problem = describeBodyProblem(probe.result.bytes, probe.result.contentType); if (problem) throw mediaError(`A fonte devolveu ${problem} em vez do segmento.`, 'MEDIA_RESPONSE'); } return { via: probe.via, rangeRejected: probe.via === 'plain' && Boolean(probe.rangeError) }; } function hlsTraceOf(context, resource, error) { const hostOf = value => { try { return new URL(value).hostname; } catch { return '?'; } }; const lines = []; const chain = (context.hlsChain || []).slice(-3); chain.forEach(item => { const same = (() => { try { const a = new URL(item.url), b = new URL(item.finalUrl); return a.origin === b.origin && a.pathname === b.pathname; } catch { return true; } })(); lines.push(`• Playlist ${item.kind === 'master' ? 'principal' : 'de mídia'}: pedida a ${hostOf(item.url)}, respondida por ${hostOf(item.finalUrl)} (${same ? 'sem redirecionamento' : 'COM redirecionamento'}) · ${item.type || 'sem content-type'} · ${item.bytes} B${item.segments ? ` · ${item.segments} segmentos` : ''}`); }); let segUrl, playlistUrl = resource.playlistUrl || chain.filter(item => item.kind === 'media').slice(-1)[0]?.finalUrl || ''; try { segUrl = new URL(resource.url); } catch {} if (segUrl) { const segParams = [...segUrl.searchParams.entries()]; let shared = 'sem playlist para comparar'; try { const pl = new URL(playlistUrl).searchParams, names = segParams.map(([k]) => k).filter(k => pl.has(k)); shared = names.length ? `${names.filter(k => pl.get(k) === segUrl.searchParams.get(k)).length}/${names.length} parâmetros em comum têm o mesmo valor da playlist` : 'nenhum parâmetro em comum com a playlist'; } catch {} lines.push(`• Segmento: host ${segUrl.hostname} · arquivo ${segUrl.pathname.split('/').pop().replace(/\d+/g, 'N').slice(0, 40)} · parâmetros: ${segParams.length ? segParams.map(([k, v]) => `${k}(${v.length})`).join(' ') : 'nenhum'}${segParams.some(([, v]) => v.length <= 3) ? ` · valores curtos: ${segParams.filter(([, v]) => v.length <= 3).map(([k, v]) => `${k}=${v}`).join(' ')}` : ''} · caminho com ${segUrl.pathname.split('/').filter(Boolean).length} níveis · ${shared}`); if (playlistUrl) { try { lines.push(`• Host da playlist de mídia: ${new URL(playlistUrl).hostname}${new URL(playlistUrl).hostname === segUrl.hostname ? ' (igual ao do segmento)' : ' (DIFERENTE do segmento)'}`); } catch {} } const now = Date.now() / 1000, from = Number(segUrl.searchParams.get('validfrom')), to = Number(segUrl.searchParams.get('validto')); if (from > 1e9 && to > 1e9) lines.push(`• Janela do token: começou há ${Math.round((now - from) / 60)} min${from > now ? ' (NO FUTURO: relógio do aparelho x servidor?)' : ''}; termina em ${Math.round((to - now) / 60)} min`); } const panelOrigin = location.origin, videoOrigin = originOf(context.referrer || ''); if (videoOrigin) lines.push(`• Painel aberto em ${hostOf(panelOrigin)} · página do vídeo em ${hostOf(videoOrigin)} (${panelOrigin === videoOrigin ? 'MESMA origem: o fetch da página pode usar a sessão do site' : 'origem DIFERENTE: Origin/cookies reais do site só existem em “Nesta página”'})`); const anonymous = isAnonymousContext(context); lines.push(`• Pedido: Referer ${context.referrer ? `enviado (site ${hostOf(context.referrer)})` : 'não enviado'} · cookies do navegador: ${anonymous ? 'NÃO enviados (modo anônimo do link)' : 'enviados quando o gerenciador permite'} · Origin só no perfil “origin”`); const attempts = error?.attempts || []; if (attempts.length) lines.push(`• Tentativas: ${attempts.map(a => `${a.mode}→${a.status || a.code || 'sem status'}${a.info?.['content-type'] ? ` ${String(a.info['content-type']).split(';')[0]}` : ''} [${a.via === 'gm' ? (a.anonymous ? 'gerenciador, anônimo' : 'gerenciador, com cookies') : `fetch da página, credenciais ${a.credentials}`}]`).join(' · ')}`); const info = attempts.map(a => a.info).find(Boolean) || error?.respInfo; if (info) lines.push(`• Resposta da origem: ${Object.entries(info).filter(([k]) => k !== 'sample').map(([k, v]) => `${k}=${v}`).join(' · ') || 'sem cabeçalhos úteis'}${info.sample ? ` · amostra: “${info.sample}”` : ''}`); if (chain.length) lines.push(`• Playlist lida há ${Math.round((Date.now() - chain[chain.length - 1].at) / 1000)} s`); return `Rastro técnico (sem tokens, cookies ou URLs completas):\n${lines.join('\n')}`; } async function requestHlsResource(resource, context, isMap = false, probe = false) { const origin = originOf(resource.url); const inherited = inheritedQueryUrl(resource); const preferInherited = Boolean(inherited) && context.hlsInherit?.has(origin); const fetcher = target => probe ? probeSegmentExists(target, context, resource.range) : requestBytes(target, resource.range, context); const tried = { inherited: false }; try { return await fetcher(preferInherited ? inherited : resource.url); } catch (error) { if (![403, 404, 410].includes(error.status)) throw error; if (inherited && !preferInherited) { tried.inherited = true; try { const result = await fetcher(inherited); (context.hlsInherit ||= new Set()).add(origin); return result; } catch (second) { if (![403, 404, 410].includes(second.status)) throw second; } } error.hlsDiagnosis = hlsDiagnosis(resource, tried); try { error.hlsTrace = hlsTraceOf(context, resource, error); } catch {} error.message = `${error.message} ${isMap ? 'Inicialização' : 'Segmento'} HLS recusado — ${error.hlsDiagnosis}.`; throw error; } } async function preflightHlsSegments(resolved, context) { const segments = resolved.media.segments; if (segments.length < 3) return; const last = segments.length - 1; const picks = [...new Set([0, Math.floor(segments.length / 2), last])]; const ok = [], failed = []; for (const index of picks) { checkMediaAbort(context); context.onProgress?.(null, `Verificando os segmentos (${index + 1}/${segments.length})...`); try { const info = await requestHlsResource(segments[index], context, false, true); ok.push(index); if (info?.rangeRejected) context.hlsRangeRejected = true; } catch (error) { if (![403, 404, 410].includes(error.status)) throw error; failed.push({ index, error }); } } if (context.hlsRangeRejected) console.info('[VDD] A origem recusa requisições Range para segmentos, mas serve o GET simples: o download usa GET simples.'); if (!failed.length) return; const first = failed[0].error, status = first.status, list = failed.map(item => item.index + 1); const numbers = indexes => indexes.map(i => i + 1).join(', '); const error = mediaError('', 'MEDIA_SEGMENT_REFUSED', status); error.status = status; error.hlsDiagnosis = first.hlsDiagnosis || ''; error.hlsTrace = `${first.hlsTrace || ''}${first.hlsTrace ? '\n' : ''}• Amostras testadas: ${picks.map(i => `${i + 1}${ok.includes(i) ? ' ok' : ` HTTP ${failed.find(f => f.index === i)?.error.status}`}`).join(' · ')}`; if (ok.length) { error.code = 'MEDIA_SEGMENT_MISSING'; error.message = `${list.length === 1 ? `O segmento ${list[0]}` : `Os segmentos ${list.join(', ')}`} de ${segments.length} não ${list.length === 1 ? 'está disponível' : 'estão disponíveis'} na origem (HTTP ${status}), enquanto ${ok.length === 1 ? 'o segmento' : 'os segmentos'} ${numbers(ok)} ${ok.length === 1 ? 'responde' : 'respondem'}. Nada foi baixado, porque o vídeo ficaria incompleto. ${first.hlsDiagnosis ? `Diagnóstico: ${first.hlsDiagnosis}.` : ''}`.trim(); } else { const params = authParamNames(segments[picks[0]].url); error.hlsRefusedAll = true; error.message = `A origem recusou todos os segmentos testados (${numbers(picks)} de ${segments.length}) com HTTP ${status}${first.probedPlain ? ', inclusive em pedido simples sem Range' : ''}. ` + `Isso indica recusa de acesso (e não um segmento ausente): URL assinada${params.length ? ` (${params.join(', ')})` : ''} ` + `${params.some(name => /^(?:ipa|ip)$/i.test(name)) ? 'possivelmente vinculada ao endereço IP, ' : ''}sessão, Referer ou região. ` + `O script não contorna essa proteção. Nada foi baixado. ${first.hlsDiagnosis ? `Diagnóstico: ${first.hlsDiagnosis}.` : ''}`.trim(); } throw error; } function compareMediaQuality(a, b) { return Number(b.noWatermark === true) - Number(a.noWatermark === true) || Number(b.height || 0) - Number(a.height || 0) || Number(b.width || 0) - Number(a.width || 0) || Number(b.bandwidth || 0) - Number(a.bandwidth || 0) || Number(b.score || 0) - Number(a.score || 0); } // --------------------------------------------------------------------------- // Link tab // Search box, result panes and the link search flow. // --------------------------------------------------------------------------- function syncInputClear(input) { const button = input?.parentElement?.querySelector?.('.vdd-input-clear'); if (button) button.hidden = !input.value; } function setLinkPane(pane) { const name = pane === 'site' ? 'site' : 'video'; APP.linkPane = name; const results = document.getElementById('vdd-link-results'), listing = document.getElementById('vdd-link-listing'), seg = document.getElementById('vdd-link-seg'); if (results) results.hidden = name !== 'video'; if (listing) listing.hidden = name !== 'site'; for (const button of seg?.querySelectorAll('button') || []) { const on = button.dataset.pane === name; button.classList.toggle('on', on); button.setAttribute('aria-selected', String(on)); } const scroller = document.querySelector('#vdd-root .vdd-body'); if (scroller) scroller.scrollTop = 0; if (name === 'site') requestAnimationFrame(() => LISTING.state?.onScroll?.()); } function setLinkPanes({ video = 0, site = 0, show = '' } = {}) { const seg = document.getElementById('vdd-link-seg'), both = video > 0 && site > 0; if (seg) seg.hidden = !both; const v = document.getElementById('vdd-seg-video'), t = document.getElementById('vdd-seg-site'); if (v) v.textContent = video ? `(${video})` : ''; if (t) t.textContent = site ? `(${site})` : ''; setLinkPane(both ? (show || 'video') : (site > 0 && video === 0 ? 'site' : 'video')); } function clearLinkAll() { if (APP.working) { toast('Aguarde o download atual terminar para limpar.'); return; } if (APP.linkBusy) APP.linkController?.abort(); const input = document.getElementById('vdd-link-input'), status = document.getElementById('vdd-link-status'), results = document.getElementById('vdd-link-results'); if (input) { input.value = ''; syncInputClear(input); } teardownLinkListing(); APP.linkResults = []; if (results) { results.replaceChildren(); delete results.dataset.signature; } if (status) { status.textContent = ''; status.dataset.state = ''; } setLinkPanes({ video: 0, site: 0 }); updateHeaderCount('Baixar por link'); input?.focus(); } async function handleLinkSearch() { const input = document.getElementById('vdd-link-input'), button = document.getElementById('vdd-link-submit'), status = document.getElementById('vdd-link-status'); const cancel = document.getElementById('vdd-link-cancel'); if (APP.linkBusy || APP.working) { toast('Aguarde a operação atual.'); return; } let url; try { url = pastedPublicUrl(input?.value); } catch (error) { status.textContent = error.message || 'Cole um link público válido.'; status.dataset.state = 'error'; return; } APP.linkBusy = true; button.disabled = true; input.blur(); APP.linkController = new AbortController(); cancel.hidden = false; status.dataset.state = 'loading'; status.textContent = 'Analisando o link público...'; try { const notes = []; const outcome = await resolveLinkOrListing(url, { signal: APP.linkController.signal, notes, onStage: label => { status.textContent = label; } }); if (notes.length) console.info('[VDD] Notas da extração:', [...new Set(notes)]); checkMediaAbort({ signal: APP.linkController.signal }); if (outcome.listing) { APP.linkResults = []; const stale = document.getElementById('vdd-link-results'); if (stale) { stale.replaceChildren(); delete stale.dataset.signature; } mountLinkListing(outcome.listing); setLinkPanes({ video: 0, site: outcome.listing.entries.length }); return; } const related = outcome.related?.entries?.length ? outcome.related : null; if (related) mountLinkListing(related, { related: true }); else teardownLinkListing(); const candidates = outcome.candidates; APP.linkResults = orderLinkCandidates(candidates); const list = document.getElementById('vdd-link-results'); if (!list.querySelector('[data-prepared="true"]')) delete list.dataset.signature; renderLinkResults(); status.dataset.state = candidates.length ? 'complete' : ''; const watermarkNote = candidates.some(item => item.platform && !item.mediaType) && !candidates.some(item => item.noWatermark === true) ? ' A fonte não publicou uma versão sem marca d’água; se houver marca incorporada ao vídeo, ela permanece no arquivo.' : ''; status.textContent = candidates.length ? `${describeLinkResults(candidates)} encontrado${candidates.length === 1 ? '' : 's'}. Escolha a qualidade e baixe.${watermarkNote}` : `Nenhuma mídia baixável foi encontrada. ${[...new Set(notes)].slice(0, 3).join(' ') || 'O HTML não publicou uma mídia acessível. Abra o post, reproduza o vídeo e use “Nesta página”.'}`; if (outcome.singleError && !candidates.length) status.textContent = `${outcome.singleError.message || 'Não foi possível ler o vídeo deste link.'}${related ? ' Veja os outros vídeos em “Vídeos da página”.' : ''}`; else if (related && candidates.length) status.textContent += ` Esta página também lista ${related.entries.length} vídeo${related.entries.length === 1 ? '' : 's'} em “Vídeos da página”.`; setLinkPanes({ video: candidates.length, site: related ? related.entries.length : 0, show: candidates.length ? 'video' : 'site' }); } catch (error) { status.dataset.state = error.code === 'MEDIA_CANCELLED' ? '' : 'error'; status.textContent = error.code === 'MEDIA_CANCELLED' ? 'Busca cancelada.' : error.message || 'Não foi possível ler o link.'; } finally { APP.linkBusy = false; APP.linkController = null; button.disabled = false; cancel.hidden = true; } } // --------------------------------------------------------------------------- // Page listings // Finds video entries on listing pages (anchors, JSON-LD) and shows them as cards. // --------------------------------------------------------------------------- const DISCOVERY = Object.freeze({ minListing: 3, maxEntries: 600, firstBatch: 12, batch: 12, frameChunk: 4, anchorChunk: 250, maxAnchors: 8000, cacheTtl: 5 * 60 * 1000, cacheSize: 6, loadMargin: '700px 0px', keepMargin: '2600px 0px' }); const AD_HOST = /(?:^|[.-])(?:doubleclick|googlesyndication|googleadservices|adservice|adnxs|exoclick|exosrv|trafficjunky|juicyads|popads|popcash|propellerads|adsterra|clickadu|hilltopads|taboola|outbrain|mgid|revcontent|criteo|ads?|adv|banners?)(?:[.-]|$)/i; const AD_NODE = 'ins, [data-ad], [data-ad-slot], [data-advertisement], [rel~="sponsored"], [class~="ad"], [class~="ads"], [class~="adv"], [class~="advert"], [class*="advert"], [class*="sponsor"], [class*="banner"], [id^="ad-"], [id^="ads-"], [id*="advert"], [id*="sponsor"]'; const VIDEO_PATH = /(?:^|\/)(?:videos?|watch|view_video(?:\.php)?|viewkey|embed|player|play|clips?|movies?|films?|reels?|shorts?|episodes?|v|vid|e|stream)(?:\/|[.?]|$)/i; const VIDEO_ROOT = /^(?:videos?|watch|clips?|movies?|films?|reels?|shorts?|embed|player|play|stream|e|v|vid)$/i; const NAV_PATH = /(?:^|\/)(?:categor(?:y|ies)|tags?|search|login|signin|sign-in|signup|register|account|contact|about|dmca|terms|privacy|faq|support|help|rss|sitemap|upload|settings|pornstars?|models?|users?|profiles?|members?|channels?|playlists?|albums?|photos?|gifs?|blogs?|news|forum|page\/\d+)(?:\/|$)/i; const ID_PARAM = /^(?:v|vid|video|viewkey|id|watch|key)$/i; const BLANK_THUMB = /(?:blank|spacer|pixel|placeholder|transparent|1x1|loading|lazy)[^/]*\.(?:gif|png|svg)(?:$|\?)/i; const DURATION_TEXT = /(?:^|[\s(\[])((?:\d{1,2}:)?\d{1,2}:\d{2})(?=$|[\s)\]])/; function siteKeyOf(host) { const parts = String(host || '').toLowerCase().replace(/^www\./, '').split('.').filter(Boolean); if (parts.length <= 2) return parts.join('.'); const sld = parts[parts.length - 2]; return (parts[parts.length - 1].length === 2 && sld.length <= 3 ? parts.slice(-3) : parts.slice(-2)).join('.'); } function entryUrlKey(raw) { try { const url = new URL(raw); const keep = [...url.searchParams.entries()].filter(([name]) => ID_PARAM.test(name)).sort(([a], [b]) => a.localeCompare(b)); return `${url.hostname.toLowerCase().replace(/^www\./, '')}${url.pathname.replace(/\/+$/, '')}${keep.length ? `?${keep.map(([k, v]) => `${k}=${v}`).join('&')}` : ''}`; } catch { return ''; } } function stripTrackingParams(raw) { try { const url = new URL(raw); let changed = false; for (const name of [...url.searchParams.keys()]) if (/^(?:utm_[a-z]+|fbclid|gclid|mc_eid|igshid)$/i.test(name)) { url.searchParams.delete(name); changed = true; } url.hash = ''; return changed || raw.includes('#') ? url.href : raw; } catch { return raw; } } function durationFromText(text) { const match = String(text || '').match(DURATION_TEXT); if (!match) return 0; const parts = match[1].split(':').map(Number); if (parts.some(part => !Number.isFinite(part))) return 0; const seconds = parts.reduce((total, part) => total * 60 + part, 0); return seconds > 0 && seconds < 24 * 3600 ? seconds : 0; } function entryThumbnail(node, base) { const media = node.querySelector?.('img, video[poster], picture source'); if (!media) return ''; const candidates = ['data-src', 'data-thumb_url', 'data-original', 'data-lazy-src', 'data-mediumthumb', 'data-thumb', 'data-image', 'poster', 'src'] .map(name => media.getAttribute?.(name)); const srcset = media.getAttribute?.('data-srcset') || media.getAttribute?.('srcset'); if (srcset) candidates.push(srcset.split(',')[0].trim().split(/\s+/)[0]); for (const raw of candidates) { const value = String(raw || '').trim(); if (!value || /^data:/i.test(value) || BLANK_THUMB.test(value)) continue; const url = safeHttpUrl(value, base); if (!url || isPrivateNetworkUrl(url)) continue; try { if (AD_HOST.test(new URL(url).hostname)) continue; } catch { continue; } return url; } return ''; } function classifyVideoAnchor(anchor, base, pageSite) { const href = anchor.getAttribute('href') || ''; if (!href || /^(?:#|javascript:|mailto:|tel:|data:)/i.test(href.trim())) return null; const url = safeHttpUrl(href, base); if (!url || isPrivateNetworkUrl(url) || restrictedPlatformNotice(url)) return null; let parsed; try { parsed = new URL(url); } catch { return null; } if (siteKeyOf(parsed.hostname) !== pageSite || AD_HOST.test(parsed.hostname)) return null; if (/\.(?:jpe?g|png|gif|webp|svg|css|js|json|xml|txt|zip|pdf)(?:$|\?)/i.test(parsed.pathname)) return null; if (anchor.closest?.(AD_NODE) || /sponsored|nofollow\s+sponsored/i.test(anchor.getAttribute('rel') || '')) return null; if (parsed.searchParams.has('utm_source') && /(?:^|\.)adclick|click/i.test(parsed.hostname)) return null; const hasId = [...parsed.searchParams.keys()].some(name => ID_PARAM.test(name)); const segments = parsed.pathname.split('/').filter(Boolean); const videoPath = VIDEO_PATH.test(parsed.pathname) || hasId; if (!segments.length && !hasId) return null; if (segments.length === 1 && VIDEO_ROOT.test(segments[0]) && !hasId) return null; if (!videoPath && NAV_PATH.test(parsed.pathname)) return null; if (/(?:^|\/)page\/\d+|[?&](?:page|p|pg)=\d+(?:&|$)/i.test(parsed.pathname + parsed.search) && !hasId) return null; const thumbnail = entryThumbnail(anchor, base); const card = anchor.closest?.('article, li, [class*="video"], [class*="thumb"], [class*="item"], [class*="card"]') || anchor.parentElement; const schema = Boolean(anchor.closest?.('[itemtype*="VideoObject"]') || card?.matches?.('[itemtype*="VideoObject"]')); const nearText = cleanDisplayText(`${anchor.textContent || ''} ${card && card !== anchor ? card.textContent || '' : ''}`, 400); const duration = Number(anchor.getAttribute('data-duration') || card?.getAttribute?.('data-duration') || 0) || parseIsoDuration(anchor.querySelector('time[datetime]')?.getAttribute('datetime') || card?.querySelector?.('time[datetime]')?.getAttribute('datetime') || '') || durationFromText(nearText); const media = anchor.querySelector('img'); const titleRaw = anchor.getAttribute('title') || media?.getAttribute('alt') || anchor.getAttribute('aria-label') || cleanDisplayText(anchor.textContent, 200).replace(DURATION_TEXT, ' '); const title = cleanDisplayText(titleRaw, 140); const usableTitle = title.length >= 4 && !/^[\d:\s]+$/.test(title) ? title : ''; let score = 0; if (videoPath) score += 3; if (thumbnail) score += 2; if (duration) score += 1; if (usableTitle.length >= 8) score += 1; if (schema) score += 2; if (score < 4 || (!thumbnail && !schema)) return null; return { url: stripTrackingParams(url), key: entryUrlKey(url), title: usableTitle || cleanName(mediaTitleFromUrl(url) || ''), thumbnail, duration, score }; } function jsonLdVideoEntries(doc, base, pageSite) { const out = []; let visited = 0; const visit = (value, depth = 0) => { if (!value || typeof value !== 'object' || depth > 9 || visited++ > 6000) return; if (Array.isArray(value)) { value.forEach(item => visit(item, depth + 1)); return; } const types = [].concat(value['@type'] || []).map(String); if (types.includes('VideoObject')) { const page = safeHttpUrl(value.url || value['@id'] || '', base), thumb = Array.isArray(value.thumbnailUrl) ? value.thumbnailUrl[0] : value.thumbnailUrl; const direct = safeHttpUrl(value.contentUrl || '', base); const target = page || ''; let sameSite = false; try { sameSite = Boolean(target) && siteKeyOf(new URL(target).hostname) === pageSite; } catch {} if (sameSite || (direct && !isPrivateNetworkUrl(direct) && (isHls(direct) || knownDirectFormatHint(direct)))) { out.push({ url: target || direct, key: entryUrlKey(target || direct), title: cleanDisplayText(value.name || value.headline || '', 140), thumbnail: safeHttpUrl(typeof thumb === 'string' ? thumb : thumb?.url || '', base), duration: parseIsoDuration(value.duration), score: 9, direct: !sameSite && direct ? { kind: isHls(direct) ? 'HLS' : (knownDirectFormatHint(direct)?.label || 'VÍDEO'), url: direct, type: isHls(direct) ? 'application/vnd.apple.mpegurl' : (knownDirectFormatHint(direct)?.mime || ''), trusted: true, verified: false } : null }); } } Object.values(value).forEach(child => visit(child, depth + 1)); }; doc.querySelectorAll('script[type="application/ld+json"]').forEach(script => { try { visit(JSON.parse(script.textContent || '')); } catch {} }); return out; } async function extractPageVideoEntries(doc, base, { onProgress, signal } = {}) { let pageSite = '', pageKey = ''; try { pageSite = siteKeyOf(new URL(base).hostname); pageKey = entryUrlKey(base); } catch { return { entries: [], truncated: false, anchors: 0 }; } const byKey = new Map(); let truncated = false; const add = entry => { if (!entry.key || entry.key === pageKey) return; const previous = byKey.get(entry.key); if (previous) { previous.title = previous.title && previous.title.length >= (entry.title || '').length ? previous.title : entry.title || previous.title; previous.thumbnail ||= entry.thumbnail; previous.duration ||= entry.duration; previous.direct ||= entry.direct; return; } if (byKey.size >= DISCOVERY.maxEntries) { truncated = true; return; } byKey.set(entry.key, { ...entry }); }; jsonLdVideoEntries(doc, base, pageSite).forEach(add); const anchors = [...doc.querySelectorAll('a[href]')].slice(0, DISCOVERY.maxAnchors); for (let i = 0; i < anchors.length; i++) { if (i && i % DISCOVERY.anchorChunk === 0) { checkMediaAbort({ signal }); onProgress?.(i, anchors.length, byKey.size); await yieldUi(0); } let entry = null; try { entry = classifyVideoAnchor(anchors[i], base, pageSite); } catch {} if (entry) add(entry); } return { entries: [...byKey.values()], truncated, anchors: anchors.length }; } const listingCache = new Map(); function listingCacheGet(url) { const hit = listingCache.get(url); if (!hit) return null; if (Date.now() - hit.at > DISCOVERY.cacheTtl) { listingCache.delete(url); return null; } return hit; } function listingCacheSet(url, listing) { listingCache.delete(url); while (listingCache.size >= DISCOVERY.cacheSize) listingCache.delete(listingCache.keys().next().value); listingCache.set(url, listing); } async function resolveLinkOrListing(url, operation = {}) { const op = { ...operation, pageCache: true }; const single = async () => ({ candidates: await resolvePublicLink(url, new Set(), 0, op) }); if (publicPlatform(url) || beegVideoId(url) || (youtubeEnabled() && youtubeLink(url))) return single(); const context = { ...op, referrer: url, publicOnly: true, sessionLink: true }; checkMediaAbort(context); const cached = listingCacheGet(url); if (cached) return { listing: cached, cached: true }; let probed = null; try { probed = await probeLinkTarget(url, context); } catch (error) { if (error.code === 'MEDIA_CANCELLED') throw error; } if (!probed || !probed.page || publicPlatform(probed.url)) return single(); operation.onStage?.('Lendo a página e procurando vídeos...'); const response = await requestLinkPage(probed.url || url, context); if (/^\s*#EXTM3U(?:\s|$)/.test(response.text) || / operation.onStage?.(`Analisando a página… ${count} vídeo${count === 1 ? '' : 's'} encontrado${count === 1 ? '' : 's'} (${done}/${total} links)`) }); const buildListing = () => ({ url, finalUrl: response.finalUrl, title: cleanDisplayText(doc.title, 120), entries: found.entries, truncated: found.truncated, anchors: found.anchors, at: Date.now() }); const withRelated = async () => { try { const out = await single(); return found.entries.length ? { ...out, related: buildListing() } : out; } catch (error) { if (error?.code === 'MEDIA_CANCELLED' || !found.entries.length) throw error; return { candidates: [], related: buildListing(), singleError: error }; } }; if (found.entries.length < DISCOVERY.minListing) return withRelated(); const ogType = doc.querySelector('meta[property="og:type"]')?.getAttribute('content') || ''; if (/^(?:video|movie)(?:[.:]|$)/i.test(ogType)) return withRelated(); const own = extractPublicMediaCandidates(doc, base, { scanRawScripts: true, rawText: response.text }).filter(item => Number(item.score || 0) >= 88 && !item.platform); if (own.length) return withRelated(); const listing = buildListing(); listingCacheSet(url, listing); return { listing }; } async function resolveListingEntry(entry, operation = {}) { if (entry.direct) return [{ ...entry.direct, title: entry.title, thumbnail: entry.thumbnail, duration: entry.duration, pageUrl: entry.url, sourceScope: 'link', preferred: true }]; const found = await resolvePublicLink(entry.url, new Set(), 0, { ...operation, pageCache: true }); return found.slice(0, 8).map((item, index) => ({ ...item, title: entry.title || item.title, thumbnail: item.thumbnail || entry.thumbnail, duration: item.duration || entry.duration, pageUrl: item.pageUrl || entry.url, sourceScope: 'link', preferred: index === 0 })); } const LISTING = { state: null }; function teardownLinkListing() { const state = LISTING.state; if (!state) return; LISTING.state = null; state.loadIo?.disconnect(); state.keepIo?.disconnect(); state.moreIo?.disconnect(); state.scroller?.removeEventListener('scroll', state.onScroll); state.more?.removeEventListener('click', state.onMore); if (state.frame) cancelAnimationFrame(state.frame); for (const img of state.container.querySelectorAll('img')) img.removeAttribute('src'); state.host.replaceChildren(); } function loadEntryThumb(thumb) { if (thumb.dataset.state === 'loading' || thumb.dataset.state === 'loaded' || thumb.dataset.state === 'failed' || !thumb.dataset.src) return; loadThumbInto(thumb, thumb.dataset.src, thumb.dataset.page || ''); } function unloadEntryThumb(thumb) { if (thumb.dataset.state !== 'loaded' && thumb.dataset.state !== 'loading') return; resetThumb(thumb); } function updateListingStatus(state) { const status = document.getElementById('vdd-link-status'); const total = state.entries.length; const summary = `${total} vídeo${total === 1 ? '' : 's'} encontrado${total === 1 ? '' : 's'}${state.listing.truncated ? ` (limite de ${DISCOVERY.maxEntries}; há mais na página)` : ''} · mostrando ${state.container.children.length}. ` + (state.rendered < total ? 'Role para carregar mais. ' : '') + 'Toque em “Buscar vídeo” no que quiser baixar.'; if (state.related) { if (state.note && LISTING.state === state) state.note.textContent = summary; } else if (status && LISTING.state === state && status.dataset.state !== 'error') { status.dataset.state = 'complete'; status.textContent = summary; } state.host?.classList.toggle('vdd-has-more', state.rendered < total); if (state.more) { state.more.hidden = state.rendered >= total; state.more.textContent = `Carregar mais (${total - state.rendered} restantes)`; } if (APP.activeTab === 'link' && !state.related) updateHeaderCount(`${total} vídeo${total === 1 ? '' : 's'} na página`); } function renderListingBatch(state, count = DISCOVERY.batch) { if (LISTING.state !== state || state.loading || state.rendered >= state.entries.length) return; state.loading = true; const end = Math.min(state.entries.length, state.rendered + count); const step = () => { state.frame = 0; if (LISTING.state !== state) return; const fragment = document.createDocumentFragment(), stop = Math.min(end, state.rendered + DISCOVERY.frameChunk), thumbs = []; while (state.rendered < stop) { const entry = state.entries[state.rendered++]; if (state.keys.has(entry.key)) continue; state.keys.add(entry.key); const card = createEntryCard(entry, state); thumbs.push(card.querySelector('.vdd-thumb')); fragment.appendChild(card); } state.container.appendChild(fragment); for (const thumb of thumbs) { state.loadIo?.observe(thumb); state.keepIo?.observe(thumb); } if (state.rendered < end) { state.frame = requestAnimationFrame(step); return; } state.loading = false; updateListingStatus(state); if (state.moreIo) { state.moreIo.unobserve(state.sentinel); state.moreIo.observe(state.sentinel); } else state.onScroll(); }; state.frame = requestAnimationFrame(step); } function createEntryCard(entry, state) { const card = document.createElement('article'); card.className = 'vdd-card vdd-entry'; card.dataset.key = entry.key; const title = cleanDisplayText(entry.title) || `Vídeo ${state.rendered}`; const duration = formatDuration(entry.duration); card.innerHTML = `
${duration ? `${escapeHtml(duration)}` : ''}
${escapeHtml(title)}
${escapeHtml(sourceHost(entry.url) || 'Página de vídeo')}
${entry.direct ? escapeHtml(entry.direct.kind) : 'PÁGINA'}
`; if (entry.thumbnail) { const holder = card.querySelector('.vdd-thumb'); holder.dataset.src = entry.thumbnail; holder.dataset.page = entry.url || ''; } card.querySelector('.vdd-btn').addEventListener('click', () => openListingEntry(card, entry)); return card; } async function openListingEntry(card, entry) { const button = card.querySelector('.vdd-btn'), box = card.querySelector('.vdd-entry-items'), progress = card.querySelector('.vdd-progress'); const bar = card.querySelector('.vdd-progress-bar'), text = card.querySelector('.vdd-progress-text'); if (entry.items) { box.hidden = !box.hidden; button.textContent = box.hidden ? 'Mostrar opções' : 'Ocultar opções'; return; } if (APP.linkBusy || APP.working) { toast('Aguarde a operação atual.'); return; } const cancel = document.getElementById('vdd-link-cancel'); APP.linkBusy = true; button.disabled = true; progress.classList.add('show'); APP.linkController = new AbortController(); if (cancel) cancel.hidden = false; try { setProgress(bar, text, null, 'Procurando o vídeo nesta página...'); const notes = []; const items = await resolveListingEntry(entry, { signal: APP.linkController.signal, notes, onStage: label => setProgress(bar, text, null, label) }); checkMediaAbort({ signal: APP.linkController.signal }); if (!items.length) { setProgress(bar, text, 0, `Nenhuma mídia baixável foi encontrada nesta página. ${[...new Set(notes)].slice(0, 2).join(' ') || 'O site pode exigir login, verificação de idade ou reprodução no navegador: abra a página e use “Nesta página”.'}`); appendOpenPageHint(text, entry.url); button.textContent = 'Tentar de novo'; return; } entry.items = items; box.replaceChildren(...items.map((item, index) => createMediaCard(item, index, 'link'))); box.hidden = false; progress.classList.remove('show'); button.textContent = 'Ocultar opções'; } catch (error) { const cancelled = error.code === 'MEDIA_CANCELLED'; setProgress(bar, text, 0, cancelled ? 'Busca cancelada.' : error.message || 'Não foi possível ler esta página.'); if (!cancelled) appendOpenPageHint(text, entry.url); button.textContent = 'Tentar de novo'; } finally { APP.linkBusy = false; APP.linkController = null; button.disabled = false; if (cancel) cancel.hidden = true; } } function showTraceToggle(card, trace) { const progress = card.querySelector('.vdd-progress'); if (!progress) return; let button = card.querySelector('.vdd-trace-btn'), pre = card.querySelector('.vdd-trace'); if (!button) { button = document.createElement('button'); button.type = 'button'; button.className = 'vdd-text-btn vdd-trace-btn'; button.textContent = 'Ver rastro técnico'; pre = document.createElement('pre'); pre.className = 'vdd-trace'; pre.hidden = true; button.addEventListener('click', () => { pre.hidden = !pre.hidden; button.textContent = pre.hidden ? 'Ver rastro técnico' : 'Ocultar rastro técnico'; }); progress.append(button, pre); } pre.textContent = `Rastro do fluxo que passou na verificação dos segmentos:\n${trace}`; } // --------------------------------------------------------------------------- // Open-page handoff // Opens a page with the panel ready, for players that only work inside their own page. // --------------------------------------------------------------------------- const HANDOFF_HASH = 'optdowns-handoff'; function handoffUrl(pageUrl) { try { const url = new URL(pageUrl); if (!url.hash) url.hash = HANDOFF_HASH; return url.href; } catch { return pageUrl; } } function runHandoff() { if (window.top !== window || !new RegExp(`(?:^#|&)${HANDOFF_HASH}(?:&|$)`).test(location.hash)) return; try { history.replaceState(history.state, '', location.pathname + location.search); } catch {} APP.panelOpen = true; document.getElementById('vdd-panel')?.classList.add('show'); layoutPanel(); switchTab('page'); toast('Painel aberto. Aguardando o player; toque em Baixar quando o vídeo aparecer.', 6000); let tries = 0; const poll = () => { scanAll(); if (pageMediaItems().length || ++tries > 16) return; setTimeout(poll, 1500); }; poll(); } function appendOpenPageHint(textElement, pageUrl) { const open = typeof GM_openInTab === 'function' ? GM_openInTab : globalThis.GM?.openInTab; if (!open || !safeHttpUrl(pageUrl) || textElement.parentElement?.querySelector('.vdd-open-page')) return; const link = document.createElement('button'); link.type = 'button'; link.className = 'vdd-text-btn vdd-open-page'; link.textContent = 'Abrir a página e usar “Nesta página”'; link.addEventListener('click', () => { const target = handoffUrl(pageUrl); try { open(target, { active: true }); } catch { window.open(target, '_blank', 'noopener'); } }); textElement.after(link); } function mountLinkListing(listing, opts = {}) { teardownLinkListing(); const host = document.getElementById('vdd-link-listing'); if (!host) return null; host.replaceChildren(); delete host.dataset.signature; const container = document.createElement('div'); container.className = 'vdd-entries'; const sentinel = document.createElement('div'); sentinel.className = 'vdd-sentinel'; sentinel.setAttribute('aria-hidden', 'true'); const more = document.createElement('button'); more.type = 'button'; more.className = 'vdd-text-btn vdd-more'; more.hidden = true; const note = opts.related ? document.createElement('div') : null; if (note) { note.className = 'vdd-link-note vdd-listing-note'; note.style.cssText = 'padding:0 16px 10px'; } host.append(...(note ? [note] : []), container, sentinel, more); const scroller = host.closest('.vdd-body'); const state = { listing, entries: listing.entries, rendered: 0, keys: new Set(), host, container, sentinel, more, scroller, note, related: Boolean(opts.related), loadIo: null, keepIo: null, moreIo: null, frame: 0, loading: false, onScroll: null, onMore: null }; state.onMore = () => renderListingBatch(state); state.onScroll = () => { if (state.scrollFrame) return; state.scrollFrame = requestAnimationFrame(() => { state.scrollFrame = 0; if (LISTING.state !== state || !scroller) return; const view = scroller.getBoundingClientRect(), margin = 700; if (state.rendered < state.entries.length && sentinel.getBoundingClientRect().top < view.bottom + margin) renderListingBatch(state); for (const thumb of container.querySelectorAll('.vdd-thumb')) { const rect = thumb.getBoundingClientRect(); if (rect.bottom > view.top - margin && rect.top < view.bottom + margin) loadEntryThumb(thumb); else if (rect.bottom < view.top - 2600 || rect.top > view.bottom + 2600) unloadEntryThumb(thumb); } }); }; if (typeof IntersectionObserver === 'function') { state.moreIo = new IntersectionObserver(records => { if (records.some(record => record.isIntersecting)) renderListingBatch(state); }, { root: scroller, rootMargin: DISCOVERY.loadMargin }); state.loadIo = new IntersectionObserver(records => { for (const record of records) if (record.isIntersecting) loadEntryThumb(record.target); }, { root: scroller, rootMargin: DISCOVERY.loadMargin }); state.keepIo = new IntersectionObserver(records => { for (const record of records) if (!record.isIntersecting) unloadEntryThumb(record.target); }, { root: scroller, rootMargin: DISCOVERY.keepMargin }); state.moreIo.observe(sentinel); } else scroller?.addEventListener('scroll', state.onScroll, { passive: true }); more.addEventListener('click', state.onMore); LISTING.state = state; renderListingBatch(state, DISCOVERY.firstBatch); return state; } // --------------------------------------------------------------------------- // Network requests // Progress reporting, retries and the GM-based request layer for media. // --------------------------------------------------------------------------- function reportByteProgress(callback, loaded, total = 0) { const measured = total > 0 && loaded <= total; callback?.(measured ? loaded / total * 100 : null, `Recebidos ${(loaded / 1048576).toFixed(1)} MB${measured ? ` de ${(total / 1048576).toFixed(1)} MB` : ' · tamanho total não informado'}`, { measured, phase: 'transfer', bytes: loaded, totalBytes: total }); } function setProgress(bar, text, percent, label, detail = {}) { const line = bar.parentElement; const measured = detail.measured && typeof percent === 'number' && Number.isFinite(percent); const complete = !detail.phase && percent === 100; const failed = !detail.phase && percent === 0; const amount = measured ? Math.max(0, Math.min(100, percent)) : complete ? 100 : 0; bar.style.width = `${amount}%`; line.classList.toggle('indeterminate', !measured && !complete && !failed); line.setAttribute('role', 'progressbar'); line.setAttribute('aria-label', 'Transferência de mídia'); if (measured || complete) line.setAttribute('aria-valuenow', String(Math.round(amount))); else line.removeAttribute('aria-valuenow'); text.textContent = `${measured ? `${Math.floor(amount)}% · ` : ''}${label || ''}`; text.dataset.state = failed ? 'error' : complete ? 'complete' : ''; if (detail.info) showFileInfo(bar.closest?.('.vdd-card'), detail.info, detail.size); } function makeHttpError(message, status = 0) { const error = new Error(message); error.status = Number(status || 0); return error; } function isTransientError(error) { const status = Number(error?.status || 0); if (status) { return status === 408 || status === 425 || status === 429 || status >= 500; } return /network|rede|timeout|tempo esgotado|abort|load failed|failed to fetch|sem status|sem responder/i .test(String(error?.message || error || '')); } async function withNetworkRetries(operation, options = {}) { const attempts = Math.max(1, Number(options.attempts || NET.retries)); let lastError = null; for (let attempt = 1; attempt <= attempts; attempt++) { try { return await operation(attempt); } catch (error) { lastError = error; if (attempt >= attempts || !isTransientError(error)) throw error; options.onRetry?.(attempt + 1, error); await yieldUi(450 * attempt + 250); } } throw lastError || new Error('Falha de rede'); } const HLS_LIMITS = Object.freeze({ manifest: 8 * 1024 * 1024, segment: 96 * 1024 * 1024, output: 384 * 1024 * 1024, segments: 30000, depth: 5 }); const mediaTransportModes = new Map(); const wantsSession = context => context?.keepSession === true || context?.sessionLink === true; const isAnonymousContext = context => context?.publicOnly === true && !wantsSession(context); const isSessionLinkItem = item => item?.sourceScope === 'link' && !item.platform; function fetchCredentialsFor(url, context) { let same = false; try { same = new URL(url).origin === location.origin; } catch {} if (!context?.publicOnly) return same ? 'include' : 'omit'; return same && context.sessionLink === true ? 'same-origin' : 'omit'; } function mediaError(message, code = 'MEDIA_INVALID', status = 0) { const error = makeHttpError(message, status); error.code = code; recordIssue(error); return error; } function checkMediaAbort(context = {}) { if (context.signal?.aborted) throw mediaError('Download cancelado.', 'MEDIA_CANCELLED'); } function httpErrorWhere(url, kind = '') { let host = ''; try { host = new URL(url).hostname; } catch {} return { host, kind }; } const SAFE_RESPONSE_HEADERS = Object.freeze(['content-type', 'content-length', 'content-encoding', 'server', 'via', 'x-cache', 'age', 'cache-control', 'x-served-by']); function errorResponseInfo(headers = {}, body = null) { const info = {}; for (const key of SAFE_RESPONSE_HEADERS) if (headers[key]) info[key] = String(headers[key]).slice(0, 80); if (headers.location) { try { info.locationHost = new URL(headers.location, 'https://x.invalid').hostname; } catch {} } try { let bytes = null; if (typeof body === 'string') bytes = new TextEncoder().encode(body.slice(0, 400)); else if (body instanceof ArrayBuffer) bytes = new Uint8Array(body.slice(0, 400)); else if (ArrayBuffer.isView(body)) bytes = new Uint8Array(body.buffer, body.byteOffset, Math.min(body.byteLength, 400)); if (bytes?.length) { const text = new TextDecoder('utf-8').decode(bytes); info.sample = /[\u0000-\u0008\u000e-\u001f]/.test(text) ? '(conteúdo binário)' : sanitizeForMessage(text.replace(/<[^>]+>/g, ' '), 120); } } catch {} return info; } function mediaHttpError(status, headers = {}, where = null) { const descriptions = { 400: 'A fonte não entendeu o pedido', 401: 'A fonte exige autenticação', 403: 'A fonte recusou o acesso ou a URL expirou', 404: 'O recurso não está mais disponível', 412: 'A fonte recusou o pedido porque uma condição exigida não foi atendida (Precondition Failed: normalmente Referer/Origin ou sessão)', 451: 'A fonte bloqueou o conteúdo por restrição legal ou regional', 408: 'A fonte demorou demais para responder', 410: 'A URL expirou', 429: 'A fonte limitou as requisições', 500: 'Erro interno no servidor da fonte', 502: 'O CDN/proxy recebeu uma resposta inválida da origem', 503: 'O servidor da fonte está indisponível ou sobrecarregado', 504: 'O CDN/proxy não recebeu resposta da origem a tempo (Gateway Timeout)' }; const place = where?.host ? ` Origem: ${where.host}${where.kind ? ` (${where.kind})` : ''}.` : ''; const error = mediaError(`${descriptions[status] || 'A fonte respondeu com erro'} (HTTP ${status || 'sem status'}).${place}`, 'MEDIA_HTTP', status); if (where?.host) { error.host = where.host; error.requestKind = where.kind || ''; } const raw = headers['retry-after']; if (raw) error.retryAfterMs = /^\d+(?:\.\d+)?$/.test(raw) ? Number(raw) * 1000 : Math.max(0, Date.parse(raw) - Date.now()); return error; } function originOf(url) { try { return new URL(url).origin; } catch { return ''; } } function mediaHeaders(mode, extra, context) { const headers = mode === 'bare' ? {} : { Accept: '*/*' }; if ((mode === 'session' || mode === 'origin') && context.referrer) headers.Referer = context.referrer; if (mode === 'origin') { const origin = originOf(context.referrer); if (origin) headers.Origin = origin; } return { ...headers, ...extra }; } function assertMediaUrl(url) { if (!isHttp(url) || isPrivateNetworkUrl(url)) throw mediaError('A fonte não publicou uma URL HTTP pública compatível.'); const restriction = restrictedPlatformNotice(url); if (restriction && !(youtubeEnabled() && youtubeLink(url))) throw mediaError(restriction, 'MEDIA_UNSUPPORTED'); return url; } function mediaRequestKind(options = {}) { if (options.text) return 'página/playlist'; if (options.kind) return options.kind; if (options.prefix) return 'sondagem do formato'; return 'arquivo/segmento'; } // One GM_xmlhttpRequest call with timeouts, abort handling and classified errors. async function mediaRequestOnce(url, options, mode) { const { context = {}, maxBytes = HLS_LIMITS.segment, prefix = false, text = false, headers = {}, timeout = NET.requestTimeout } = options; checkMediaAbort(context); assertMediaUrl(url); let lastProgressAt = 0; const report = (loaded, total = 0) => { const now = Date.now(); if (now - lastProgressAt < 180) return; lastProgressAt = now; options.onBytes?.(loaded, total); const transfer = context.transfer; if (!text && transfer) context.onProgress?.(100 * transfer.completed / transfer.total, `${transfer.completed}/${transfer.total} segmentos · ${transfer.rendition || 'Mídia'}: ${(loaded / 1048576).toFixed(1)} MB recebidos`, { ...transfer, measured: true }); }; const gmXhr = getGmXhr(); if (mode !== 'fetch' && gmXhr) { return new Promise((resolve, reject) => { let done = false, handle, timer; const finish = (fn, value, abort = false) => { if (done) return; done = true; clearTimeout(timer); context.signal?.removeEventListener('abort', cancel); fn(value); if (abort) { try { handle?.abort?.(); } catch {} } }; const cancel = () => finish(reject, mediaError('Download cancelado.', 'MEDIA_CANCELLED'), true); const arm = () => { clearTimeout(timer); timer = setTimeout(() => finish(reject, mediaError('Tempo esgotado ao ler a fonte.', 'MEDIA_NETWORK', 408), true), timeout); }; context.signal?.addEventListener('abort', cancel, { once: true }); arm(); const receive = async response => { if (done) return; try { const responseHeaders = parseResponseHeaders(response.responseHeaders); const status = Number(response.status || 0); if (status < 200 || status >= 300) { const failure = mediaHttpError(status, responseHeaders, httpErrorWhere(response.finalUrl || url, mediaRequestKind(options))); let body = response.responseText || response.response; try { if (typeof Blob === 'function' && body instanceof Blob) body = await body.slice(0, 400).arrayBuffer(); } catch { body = null; } failure.respInfo = errorResponseInfo(responseHeaders, body); throw failure; } const finalUrl = assertMediaUrl(response.finalUrl || url); if (context.publicOnly) { const stored = rememberLinkCookies(finalUrl, response.responseHeaders); if (stored) console.info(`[VDD] Sessão pública: ${stored} cookie(s) da plataforma guardado(s) para o download.`); } let bytes; if (text) { const body = String(response.responseText ?? response.response ?? ''); bytes = new TextEncoder().encode(body); } else bytes = await extractGmResponseBytes(response); if (bytes.byteLength > maxBytes && !prefix) throw mediaError('A resposta excedeu o limite de memória desta operação.', 'MEDIA_LIMIT'); const result = { status, finalUrl, headers: responseHeaders, contentType: responseHeaders['content-type'] || '', bytes: bytes.slice(0, maxBytes) }; if (text) result.text = new TextDecoder().decode(result.bytes); finish(resolve, result); } catch (error) { finish(reject, error); } }; try { handle = gmXhr({ method: 'GET', url, headers: { ...mediaHeaders(mode, headers, context), ...(text ? {} : platformRequestHeaders(url, mode)) }, responseType: text ? 'text' : (isAppleMobileBrowser() ? 'blob' : 'arraybuffer'), anonymous: isAnonymousContext(context) || mode !== 'session', timeout, onload: receive, onprogress(event) { arm(); report(Number(event?.loaded || 0), Number(event?.total || 0)); const loadedNow = Number(event?.loaded || 0); if (loadedNow > maxBytes) { const progressStatus = Number(event?.status || 0); const known2xx = progressStatus >= 200 && progressStatus < 300, knownError = progressStatus > 0 && !known2xx; if (!prefix || (!knownError && (known2xx || loadedNow > Math.max(maxBytes * 4, 262144)))) { finish(reject, mediaError(prefix ? 'A fonte ignorou a leitura parcial.' : 'A resposta excedeu o limite de memória.', 'MEDIA_LIMIT'), true); } } }, onerror: response => finish(reject, Number(response?.status) ? Object.assign(mediaHttpError(Number(response.status), parseResponseHeaders(response.responseHeaders), httpErrorWhere(url, mediaRequestKind(options))), { respInfo: errorResponseInfo(parseResponseHeaders(response.responseHeaders), response.responseText) }) : mediaError('Falha de rede ao acessar a fonte.', 'MEDIA_NETWORK')), ontimeout: () => finish(reject, mediaError('Tempo esgotado ao ler a fonte.', 'MEDIA_NETWORK', 408)), onabort: () => finish(reject, mediaError('Leitura interrompida.', 'MEDIA_NETWORK')) }); if (handle?.then) handle.then(receive, error => finish(reject, error)); } catch (error) { finish(reject, error); } }); } const controller = new AbortController(); let timer; const cancel = () => controller.abort(); const arm = () => { clearTimeout(timer); timer = setTimeout(cancel, timeout); }; context.signal?.addEventListener('abort', cancel, { once: true }); arm(); try { const referrer = context.referrer && new URL(context.referrer).origin === location.origin ? context.referrer : undefined; const response = await fetch(url, { headers, redirect: 'follow', signal: controller.signal, credentials: fetchCredentialsFor(url, context), ...(referrer ? { referrer } : {}) }); const responseHeaders = Object.fromEntries(response.headers.entries()); if (!response.ok) { const failure = mediaHttpError(response.status, responseHeaders, httpErrorWhere(response.url || url, mediaRequestKind(options))); let body = ''; try { body = (await response.text()).slice(0, 400); } catch {} failure.respInfo = errorResponseInfo(responseHeaders, body); throw failure; } const finalUrl = assertMediaUrl(response.url || url); const chunks = []; let count = 0; if (response.body?.getReader) { const reader = response.body.getReader(); try { while (true) { const { done, value } = await reader.read(); if (done) break; arm(); if (!value?.byteLength) continue; if (!prefix && count + value.byteLength > maxBytes) throw mediaError('A resposta excedeu o limite de memória.', 'MEDIA_LIMIT'); const part = value.subarray(0, Math.max(0, maxBytes - count)); chunks.push(part); count += part.byteLength; report(count, Number(responseHeaders['content-length'] || 0)); if (prefix && count >= maxBytes) break; } } finally { try { Promise.resolve(reader.cancel()).catch(() => {}); } catch {} } } else { if (Number(response.headers.get('content-length')) > maxBytes) throw mediaError('A fonte não permite uma leitura limitada neste navegador.', 'MEDIA_LIMIT'); const bytes = new Uint8Array(await response.arrayBuffer()); if (!prefix && bytes.byteLength > maxBytes) throw mediaError('A resposta excedeu o limite de memória.', 'MEDIA_LIMIT'); chunks.push(bytes.subarray(0, maxBytes)); count = chunks[0].byteLength; } const bytes = new Uint8Array(count); let offset = 0; for (const part of chunks) { bytes.set(part, offset); offset += part.byteLength; } return { status: response.status, finalUrl, headers: responseHeaders, contentType: response.headers.get('content-type') || '', bytes, ...(text ? { text: new TextDecoder().decode(bytes) } : {}) }; } catch (error) { checkMediaAbort(context); if (error.name === 'AbortError') throw mediaError('Tempo esgotado ao ler a fonte.', 'MEDIA_NETWORK', 408); throw error; } finally { clearTimeout(timer); context.signal?.removeEventListener('abort', cancel); } } async function mediaRequest(url, options = {}) { const context = options.context || APP.hlsJob || { referrer: currentRequestReferrer() }; const cacheKey = `${new URL(url).origin}|${Boolean(context.publicOnly)}|${wantsSession(context)}|${sourceHost(context.referrer || '')}`; const cached = mediaTransportModes.get(cacheKey); const modes = [...new Set([cached, ...(getGmXhr() ? ['session', 'origin', 'minimal', 'bare'] : []), 'fetch'].filter(Boolean))]; let lastError; const attempts = []; for (let attempt = 1; attempt <= NET.retries; attempt++) { checkMediaAbort(context); for (const mode of modes) { try { const result = await mediaRequestOnce(url, { ...options, context }, mode); if (options.validate) options.validate(result); if (mediaTransportModes.size >= 80) mediaTransportModes.delete(mediaTransportModes.keys().next().value); mediaTransportModes.set(cacheKey, mode); return result; } catch (error) { checkMediaAbort(context); if (error.code === 'MEDIA_UNSUPPORTED' || error.code === 'MEDIA_CANCELLED') throw error; if (options.prefix && error.code === 'MEDIA_LIMIT') throw error; const viaGm = mode !== 'fetch' && Boolean(getGmXhr()); attempts.push({ mode, status: Number(error.status || 0), code: error.code || '', info: error.respInfo || null, via: viaGm ? 'gm' : 'fetch', ...(viaGm ? { anonymous: isAnonymousContext(context) || mode !== 'session' } : { credentials: fetchCredentialsFor(url, context) }) }); if (!lastError || error.status || !lastError.status || ['MEDIA_TRUNCATED', 'MEDIA_RESPONSE', 'MEDIA_LIMIT'].includes(error.code)) lastError = error; if (error.status === 404 || error.status === 410) continue; if (isTransientError(error) && error.status) break; } } if ([404, 410].includes(lastError?.status)) break; if (attempt === NET.retries || !(isTransientError(lastError) || lastError?.code === 'MEDIA_NETWORK' || lastError?.code === 'MEDIA_TRUNCATED')) break; const delay = Math.max(Number(lastError.retryAfterMs || 0), 600 * 2 ** (attempt - 1) + Math.random() * 250); if (delay > 30000) { lastError.message += ' Aguarde o prazo indicado pela fonte e tente novamente.'; break; } options.onRetry?.(attempt + 1, lastError); context.onProgress?.(null, `Nova tentativa ${attempt + 1}/${NET.retries}${lastError.status ? ` · HTTP ${lastError.status}` : ''}. Aguardando a fonte...`, { phase: 'retry' }); await new Promise((resolve, reject) => { const abort = () => { clearTimeout(timer); reject(mediaError('Download cancelado.', 'MEDIA_CANCELLED')); }; const timer = setTimeout(() => { context.signal?.removeEventListener('abort', abort); resolve(); }, delay); context.signal?.addEventListener('abort', abort, { once: true }); }); } if (lastError) lastError.attempts = attempts; throw lastError || mediaError('A fonte não pôde ser lida.', 'MEDIA_NETWORK'); } function normalizeRangedBytes(result, range) { const bytes = result.bytes; const cr = parseContentRange(result.headers?.['content-range']); const fail = () => { throw mediaError('A fonte entregou um segmento incompleto ou um intervalo diferente do solicitado.', 'MEDIA_TRUNCATED'); }; if (!bytes?.byteLength) return fail(); const length = Number(result.headers?.['content-length'] || 0); if (!result.headers?.['content-encoding'] && length > 0 && bytes.byteLength !== length) return fail(); if (!range) { if (result.status === 206 && (!cr || cr.start !== 0 || !cr.total || cr.end + 1 !== cr.total || bytes.length !== cr.total)) return fail(); return bytes; } const wanted = range.end - range.start + 1; if (result.status === 206) { if (!cr || cr.start > range.start || cr.end < range.end || bytes.length !== cr.end - cr.start + 1) return fail(); return bytes.slice(range.start - cr.start, range.start - cr.start + wanted); } if (result.status !== 200 || bytes.length < range.end + 1) return fail(); return bytes.slice(range.start, range.end + 1); } function requestTextDetailed(url, onRetry = null, context = null) { return mediaRequest(url, { text: true, maxBytes: HLS_LIMITS.manifest, onRetry, ...(context ? { context } : {}), validate(result) { if (!/^\s*#EXTM3U(?:\s|$)/.test(result.text)) { const problem = describeBodyProblem(result.bytes, result.contentType); throw mediaError(problem ? `A fonte devolveu ${problem} em vez da playlist.` : 'A resposta não é uma playlist HLS válida.', 'MEDIA_RESPONSE'); } rememberHlsMaster(result.text, result.finalUrl, context?.referrer || currentRequestReferrer()); } }); } async function requestBytes(url, range = null, context = null) { let normalized; await mediaRequest(url, { ...(context ? { context } : {}), headers: range ? { Range: `bytes=${range.start}-${range.end}` } : {}, validate(result) { const problem = describeBodyProblem(result.bytes, result.contentType); if (problem) throw mediaError(`A fonte devolveu ${problem} em vez do segmento.`, 'MEDIA_RESPONSE'); normalized = normalizeRangedBytes(result, range); } }); return normalized; } // --------------------------------------------------------------------------- // HLS parsing // Parsers for master and media playlists, byte ranges and attribute lists. // --------------------------------------------------------------------------- function resolveHlsUri(uri, baseUrl) { if (/\{\$/.test(uri)) throw mediaError('Esta playlist usa variáveis HLS que ainda não são compatíveis.', 'MEDIA_UNSUPPORTED'); return assertMediaUrl(new URL(uri, baseUrl).href); } function parseMaster(text, baseUrl) { const lines = String(text).split(/\r?\n/).map(line => line.trim()); const audioGroups = new Map(), variants = []; for (const line of lines) { if (!line.startsWith('#EXT-X-MEDIA:')) continue; const a = parseAttrs(line); if (a.TYPE !== 'AUDIO' || !a['GROUP-ID']) continue; const group = audioGroups.get(a['GROUP-ID']) || []; group.push({ ...a, url: a.URI ? resolveHlsUri(a.URI, baseUrl) : '' }); audioGroups.set(a['GROUP-ID'], group); } for (let i = 0; i < lines.length; i++) { if (!lines[i].startsWith('#EXT-X-STREAM-INF:')) continue; const a = parseAttrs(lines[i]); let uri = ''; for (let j = i + 1; j < lines.length; j++) { if (lines[j].startsWith('#EXT-X-STREAM-INF:')) break; if (lines[j] && !lines[j].startsWith('#')) { uri = lines[j]; break; } } if (!uri) throw mediaError('A playlist publicou uma qualidade sem URL.'); const [width, height] = (a.RESOLUTION || '').split('x').map(Number); variants.push({ url: resolveHlsUri(uri, baseUrl), width: width || 0, height: height || 0, bandwidth: Number(a['AVERAGE-BANDWIDTH'] || a.BANDWIDTH || 0), codecs: a.CODECS || '', frameRate: Number(a['FRAME-RATE'] || 0), audioGroup: a.AUDIO || '', audioGroups, masterUrl: baseUrl }); } return variants; } function rankVariants(variants) { return [...variants].sort((a, b) => (b.width * b.height - a.width * a.height) || (b.height - a.height) || (b.bandwidth - a.bandwidth)); } function parseByteRange(value, previousEnd = -1) { if (!value) return null; const match = String(value).match(/^(\d+)(?:@(\d+))?$/); if (!match) throw mediaError('A playlist publicou um intervalo inválido.'); const length = Number(match[1]), start = match[2] === undefined ? previousEnd + 1 : Number(match[2]); if (!Number.isSafeInteger(length) || length <= 0 || !Number.isSafeInteger(start) || start < 0 || (match[2] === undefined && previousEnd < 0) || !Number.isSafeInteger(start + length)) throw mediaError('O intervalo HLS não tem um offset válido.'); return { start, end: start + length - 1 }; } function parseMediaPlaylist(text, baseUrl) { const segments = []; let map = null, key = null, pendingRange = '', previous = null, duration = null, gap = false; let sequence = 0, discontinuity = 0, start = 0, endList = false, programTime = null; let encrypted = false, keyMethod = '', targetDuration = 0; for (const raw of String(text).split(/\r?\n/)) { const line = raw.trim(); if (!line) continue; if (line.startsWith('#EXT-X-MEDIA-SEQUENCE:')) sequence = Number(line.split(':')[1]); else if (line.startsWith('#EXT-X-DISCONTINUITY-SEQUENCE:')) discontinuity = Number(line.split(':')[1]); else if (line === '#EXT-X-DISCONTINUITY') discontinuity++; else if (line.startsWith('#EXT-X-TARGETDURATION:')) targetDuration = Number(line.split(':')[1]); else if (line.startsWith('#EXTINF:')) duration = Number(line.slice(8).split(',')[0]); else if (line.startsWith('#EXT-X-BYTERANGE:')) pendingRange = line.slice(17); else if (line === '#EXT-X-GAP') gap = true; else if (line === '#EXT-X-ENDLIST') endList = true; else if (line.startsWith('#EXT-X-PROGRAM-DATE-TIME:')) programTime = Date.parse(line.slice(25)); else if (line.startsWith('#EXT-X-KEY:')) { const a = parseAttrs(line), method = String(a.METHOD || '').toUpperCase(); key = method === 'NONE' ? null : { method, url: a.URI ? resolveHlsUri(a.URI, baseUrl) : '', iv: a.IV || '', keyFormat: a.KEYFORMAT || 'identity' }; if (key) { encrypted = true; keyMethod = method; } } else if (line.startsWith('#EXT-X-MAP:')) { const a = parseAttrs(line); if (!a.URI) throw mediaError('A inicialização HLS não tem URL.'); const url = resolveHlsUri(a.URI, baseUrl); map = { url, range: parseByteRange(a.BYTERANGE, map?.url === url ? (map.range?.end ?? -1) : -1), key, playlistUrl: baseUrl }; } else if (line.startsWith('#EXT-X-SKIP:') || line === '#EXT-X-I-FRAMES-ONLY') { throw mediaError('A fonte publicou apenas uma playlist parcial, sem o vídeo completo.', 'MEDIA_UNSUPPORTED'); } else if (!line.startsWith('#')) { if (!Number.isFinite(duration) || duration < 0) throw mediaError('Um segmento HLS não tem duração válida.'); if (!Number.isSafeInteger(sequence) || sequence < 0 || !Number.isSafeInteger(discontinuity)) throw mediaError('A sequência HLS é inválida.'); const url = resolveHlsUri(line, baseUrl); const range = parseByteRange(pendingRange, previous?.url === url ? (previous.range?.end ?? -1) : -1); const segment = { url, range, map, key, gap, duration, sequence: sequence++, discontinuity, start, playlistUrl: baseUrl, programTime: Number.isFinite(programTime) ? programTime : null }; segments.push(segment); previous = segment; start += duration; if (Number.isFinite(programTime)) programTime += duration * 1000; duration = null; pendingRange = ''; gap = false; if (segments.length > HLS_LIMITS.segments) throw mediaError('A playlist excedeu o limite de segmentos.', 'MEDIA_LIMIT'); } } return { segments, map: segments[0]?.map || null, encrypted, keyMethod, endList, duration: start, targetDuration, url: baseUrl }; } function parseAttrs(line) { const result = {}; const raw = line.slice(line.indexOf(':') + 1); const re = /([A-Z0-9-]+)=((?:"[^"]*")|[^,]*)/gi; let m; while ((m = re.exec(raw))) { result[m[1].toUpperCase()] = m[2].replace(/^"|"$/g, ''); } return result; } function looksLikeTs(bytes) { if (!bytes || bytes.byteLength < 188) return false; const limit = Math.min(bytes.byteLength - 188 * 2, 4096); for (let i = 0; i <= limit; i++) { if (bytes[i] === 0x47 && bytes[i + 188] === 0x47 && bytes[i + 376] === 0x47) return true; } return false; } // --------------------------------------------------------------------------- // Saving files // Creates files and saves them through the manager, with manual fallbacks for iOS. // --------------------------------------------------------------------------- function isManualSaveRequired(error) { return error?.code === 'VDD_MANUAL_SAVE_REQUIRED' && error.blob instanceof Blob; } function forceMp4Mime(blob) { return ensureBlobMime(blob, 'video/mp4'); } function createMp4File(blob, filename) { const safeFilename = forceMp4Filename(filename); const typedBlob = ensureBlobMime(blob, 'video/mp4'); if (typeof File === 'function') { try { return new File([typedBlob], safeFilename, { type: 'video/mp4', lastModified: Date.now(), }); } catch {} } return typedBlob; } function createNamedMediaFile(blob, filename) { const safeFilename = cleanName(filename || 'video'); const mime = mediaContentType(blob?.type) || 'application/octet-stream'; const typedBlob = ensureBlobMime(blob, mime); if (typeof File === 'function') { try { return new File([typedBlob], safeFilename, { type: mime, lastModified: Date.now() }); } catch {} } return typedBlob; } function manualSaveRequired(blob, filename, cause = null) { const error = new Error('O navegador exige um novo toque para salvar o arquivo'); error.code = 'VDD_MANUAL_SAVE_REQUIRED'; error.blob = blob; error.filename = cleanName(filename || 'video'); error.cause = cause; return error; } function gmSaveErrorMessage(details) { const reason = String(details?.error || details?.message || details || 'not_succeeded'); const labels = { not_enabled: 'Downloads do gerenciador de scripts estão desativados', not_whitelisted: 'A extensão do vídeo não está liberada no gerenciador de scripts', not_permitted: 'O gerenciador de scripts não recebeu permissão para downloads', not_supported: 'Este navegador não suporta o salvamento pelo gerenciador de scripts', not_succeeded: 'O gerenciador de scripts não iniciou o salvamento', }; return labels[reason] || reason; } function isManualOpenRequired(error) { return error?.code === 'VDD_MANUAL_OPEN_REQUIRED' && isHttp(error.url); } function manualOpenRequired(url, filename, cause = null) { const error = new Error('A fonte deve ser aberta pelo navegador'); error.code = 'VDD_MANUAL_OPEN_REQUIRED'; error.url = url; error.filename = filename; error.cause = cause; return error; } function formatFromKindHint(kind = '') { const value = String(kind || '').trim().toUpperCase(); if (value === 'MP4') return { ext: 'mp4', mime: 'video/mp4', label: 'MP4' }; if (value === 'MOV') return { ext: 'mov', mime: 'video/quicktime', label: 'MOV' }; if (value === 'WEBM') return { ext: 'webm', mime: 'video/webm', label: 'WEBM' }; if (value === 'OGV' || value === 'OGG') return { ext: 'ogv', mime: 'video/ogg', label: 'OGV' }; if (value === 'TS') return { ext: 'ts', mime: 'video/mp2t', label: 'TS' }; return null; } async function resolveNativeDirectFormat(url, typeHint = '', kindHint = '') { try { const probe = await probeLinkTarget(url, { referrer: currentRequestReferrer(), publicOnly: false }); if (probe?.page) return null; if (probe?.kind === 'HLS') return { hls: true, finalUrl: probe.url }; const format = knownDirectFormatHint(probe?.url, probe?.contentType); if (format && format.ext !== 'ts') return { format, finalUrl: probe.url, headers: {} }; } catch (error) { if (error.status === 401 || error.status === 403 || error.status === 404 || error.status === 410) return null; } const hint = knownDirectFormatHint(url, typeHint) || formatFromKindHint(kindHint); return hint && hint.ext !== 'ts' ? { format: hint, finalUrl: url, headers: {} } : null; } function nativeDownloadHeaderModes() { return [ { Referer: currentRequestReferrer(), Accept: 'video/mp4,video/webm,video/quicktime,video/*;q=0.9,*/*;q=0.6', }, { Accept: 'video/*;q=0.9,*/*;q=0.6' }, {}, ]; } async function downloadDirectUrlNatively(url, filename, onProgress = null) { const gmDownload = getGmDownload(); if (!gmDownload) throw new Error('Download nativo indisponível'); const modes = nativeDownloadHeaderModes(); let lastError = null; for (let modeIndex = 0; modeIndex < modes.length; modeIndex++) { const headers = { ...modes[modeIndex], ...(modeIndex < 2 ? platformRequestHeaders(url, modeIndex === 0 ? 'session' : 'minimal') : {}) }; try { await new Promise((resolve, reject) => { let settled = false; let handle = null; let timer = null; const finish = (fn, value) => { if (settled) return; settled = true; clearTimeout(timer); fn(value); }; const details = { url, name: filename, saveAs: true, timeout: NET.fullRequestTimeout, onprogress(event) { const loaded = Number(event?.loaded || 0); const total = Number(event?.total || 0); if (loaded <= 0) return; if (total > 0) { reportByteProgress(onProgress, loaded, total); } else { reportByteProgress(onProgress, loaded); } }, onload(event) { const status = Number(event?.status || 0); if (status >= 400) return finish(reject, mediaHttpError(status, {}, httpErrorWhere(url, 'download nativo'))); finish(resolve, event || true); }, onerror(error) { const status = Number(error?.status || 0); finish(reject, makeHttpError(gmSaveErrorMessage(error), status)); }, ontimeout() { finish(reject, makeHttpError('O download nativo demorou demais', 408)); }, }; if (Object.keys(headers).length) details.headers = headers; timer = setTimeout(() => { try { handle?.abort?.(); } catch {} finish(reject, makeHttpError('O navegador não confirmou o download nativo', 408)); }, NET.fullRequestTimeout + 5000); try { handle = gmDownload(details); if (handle && typeof handle.then === 'function') { handle.then(event => details.onload(event), error => details.onerror(error)); } } catch (error) { finish(reject, error); } }); return true; } catch (error) { lastError = error; if (Number(error?.status || 0) === 408 || /demorou|não confirmou/i.test(String(error?.message || ''))) break; if (error.status === 429 || error.status >= 500) throw error; console.info(`[VDD] Download nativo modo ${modeIndex + 1}/${modes.length} recusado:`, error); } } throw lastError || new Error('O download nativo foi recusado'); } function openDirectUrlFromGesture(job) { if (!job || !isHttp(job.url)) throw new Error('URL direta inválida'); if (typeof GM_openInTab === 'function') { try { const tab = GM_openInTab(job.url, { active: true, insert: true, setParent: true }); if (tab) return 'tab'; } catch (error) { console.info('[VDD] GM_openInTab indisponível; usando link direto:', error); } } const a = document.createElement('a'); a.href = job.url; if (job.filename) a.download = job.filename; a.target = '_blank'; a.rel = 'noopener'; a.style.display = 'none'; (document.body || document.documentElement).appendChild(a); a.click(); a.remove(); return 'link'; } function saveBlobWithManager(blob, filename, onProgress = null) { const gmDownload = getGmDownload(); if (!gmDownload) { return Promise.reject(new Error('Gerenciador de downloads do userscript indisponível')); } filename = cleanName(filename || 'video'); const payload = createNamedMediaFile(blob, filename); return new Promise((resolve, reject) => { let settled = false; let handle = null; let timer = null; const finish = (fn, value) => { if (settled) return; settled = true; clearTimeout(timer); fn(value); }; const details = { url: payload, name: filename, saveAs: false, conflictAction: 'uniquify', onprogress(event) { const loaded = Number(event?.loaded || 0); const total = Number(event?.total || payload.size || 0); if (loaded > 0 && total > 0) { onProgress?.(null, `Salvando ${(loaded / 1048576).toFixed(1)} MB...`, { phase: 'save' }); } }, onload() { finish(resolve, 'manager'); }, onerror(error) { finish(reject, new Error(gmSaveErrorMessage(error))); }, ontimeout() { finish(reject, new Error('O salvamento demorou demais')); }, }; timer = setTimeout(() => { try { handle?.abort?.(); } catch {} finish(reject, new Error('O gerenciador não confirmou o salvamento')); }, NET.saveTimeout); try { handle = gmDownload(details); if (handle && typeof handle.then === 'function') { handle.then( () => finish(resolve, 'manager'), error => finish(reject, new Error(gmSaveErrorMessage(error))) ); } } catch (error) { finish(reject, error); } }); } function triggerManualBlobDownload(job) { if (!job?.blob?.size) throw new Error('O vídeo preparado está vazio'); job.filename = cleanName(job.filename || 'video'); if (!job.objectUrl) { job.blob = createNamedMediaFile(job.blob, job.filename); job.objectUrl = URL.createObjectURL(job.blob); } const a = document.createElement('a'); a.href = job.objectUrl; a.download = job.filename; a.rel = 'noopener'; a.style.display = 'none'; (document.body || document.documentElement).appendChild(a); a.click(); a.remove(); } async function saveManualBlobFromGesture(job) { job.filename = cleanName(job.filename || 'video'); job.blob = createNamedMediaFile(job.blob, job.filename); const mime = mediaContentType(job.blob.type) || 'application/octet-stream'; const ext = `.${String(job.filename).split('.').pop().toLowerCase()}`; if (isAppleMobileBrowser() && typeof File === 'function' && typeof navigator?.share === 'function') { const file = job.blob instanceof File ? job.blob : new File([job.blob], job.filename, { type: mime, lastModified: Date.now() }); let canShare = true; try { if (typeof navigator.canShare === 'function') canShare = navigator.canShare({ files: [file] }); } catch { canShare = false; } if (canShare) { try { await navigator.share({ files: [file], title: job.filename }); return 'share'; } catch (error) { if (error?.name === 'AbortError') throw new Error('Salvamento cancelado'); console.info('[VDD] Compartilhamento de arquivo indisponível:', error); } } } if (typeof window.showSaveFilePicker === 'function' && window.isSecureContext) { let writable = null; try { const handle = await window.showSaveFilePicker({ suggestedName: job.filename, types: [{ description: mime.startsWith('video/') ? 'Arquivo de vídeo' : mime.startsWith('audio/') ? 'Arquivo de áudio' : mime.startsWith('image/') ? 'Imagem' : 'Arquivo', accept: { [mime]: [ext] }, }], }); writable = await handle.createWritable(); await writable.write(job.blob); await writable.close(); return 'picker'; } catch (error) { try { await writable?.abort?.(); } catch {} if (error?.name === 'AbortError') throw new Error('Salvamento cancelado'); console.info('[VDD] Seletor nativo indisponível; usando link de download:', error); } } triggerManualBlobDownload(job); return 'anchor'; } function isAppleMobileBrowser() { if (typeof navigator === 'undefined') return false; const ua = String(navigator.userAgent || ''); const platform = String(navigator.platform || ''); return /iPhone|iPad|iPod/i.test(ua) || (/Mac/i.test(platform) && Number(navigator.maxTouchPoints || 0) > 1); } // --------------------------------------------------------------------------- // MP4 inspection // ISO BMFF box parsing to validate output and describe codecs. // --------------------------------------------------------------------------- function readUint32BE(bytes, offset = 0) { return bytes[offset] * 0x1000000 + bytes[offset + 1] * 0x10000 + bytes[offset + 2] * 0x100 + bytes[offset + 3]; } function readUint64BE(bytes, offset = 0) { const high = readUint32BE(bytes, offset); const low = readUint32BE(bytes, offset + 4); const value = high * 0x100000000 + low; return Number.isSafeInteger(value) ? value : 0; } function fourCc(bytes, offset = 0) { return String.fromCharCode( bytes[offset] || 0, bytes[offset + 1] || 0, bytes[offset + 2] || 0, bytes[offset + 3] || 0 ); } function containsFourCc(bytes, expected) { const target = [...expected].map(char => char.charCodeAt(0)); for (let i = 0; i <= bytes.byteLength - 4; i++) { if (bytes[i] === target[0] && bytes[i + 1] === target[1] && bytes[i + 2] === target[2] && bytes[i + 3] === target[3]) return true; } return false; } function invalidMp4(message) { const error = new Error(message); error.code = 'VDD_INVALID_MP4'; return error; } function readMp4Descriptors(bytes, start, end) { const out = []; for (let o = start; o < end;) { const tag = bytes[o++]; let length = 0, n = 0, b; do { b = bytes[o++]; length = (length << 7) | (b & 0x7f); n++; } while ((b & 0x80) && n < 4 && o < end); out.push({ tag, start: o, end: Math.min(end, o + length) }); o += length; } return out; } function describeEsdsAudio(bytes, esds) { try { const es = readMp4Descriptors(bytes, esds.data + 4, esds.end).find(d => d.tag === 3); if (!es) return null; let o = es.start + 3; const flags = bytes[es.start + 2]; if (flags & 0x80) o += 2; if (flags & 0x40) o += 1 + bytes[o]; if (flags & 0x20) o += 2; const dec = readMp4Descriptors(bytes, o, es.end).find(d => d.tag === 4); if (!dec) return null; const objectType = bytes[dec.start]; if (objectType === 0x6b || objectType === 0x69) return { id: 'mp3', codec: 'MP3' }; if (![0x40, 0x66, 0x67, 0x68].includes(objectType)) return null; const spec = readMp4Descriptors(bytes, dec.start + 13, dec.end).find(d => d.tag === 5), aot = spec ? bytes[spec.start] >> 3 : 0; return { id: 'aac', codec: aot === 2 ? 'AAC-LC' : aot === 5 ? 'HE-AAC' : aot === 29 ? 'HE-AAC v2' : 'AAC' }; } catch { return null; } } function describeSampleEntry(bytes, entry, handler) { const u16 = o => (bytes[o] << 8) | bytes[o + 1], type = entry.type; if (handler === 'vide') { const info = { fourcc: type, width: u16(entry.data + 24), height: u16(entry.data + 26) }; const kids = (() => { try { return isoBoxes(bytes, entry.data + 78, entry.end); } catch { return []; } })(); const avcC = kids.find(b => b.type === 'avcC'); if (/^avc[13]$/.test(type) && avcC) { const d = describeVideoCodec(`avc1.${[1, 2, 3].map(k => bytes[avcC.data + k].toString(16).padStart(2, '0')).join('')}`); if (d) Object.assign(info, { codec: d.codec, profile: d.profile, level: d.level }); } else if (/^(?:hvc1|hev1)$/.test(type)) { const hvcC = kids.find(b => b.type === 'hvcC'); Object.assign(info, describeVideoCodec(`hvc1.${hvcC ? bytes[hvcC.data + 1] & 0x1f : 0}`) || { codec: 'H.265 (HEVC)' }); } else if (type === 'av01') info.codec = 'AV1'; else if (type === 'vp09') info.codec = 'VP9'; else if (type === 'mp4v') info.codec = 'MPEG-4 Visual'; return info; } const info = { fourcc: type, channels: u16(entry.data + 16), sampleRate: ((bytes[entry.data + 24] << 8) | bytes[entry.data + 25]) }; const kids = (() => { try { return isoBoxes(bytes, entry.data + 28, entry.end); } catch { return []; } })(); if (type === 'mp4a') { const esds = kids.find(b => b.type === 'esds'); info.codec = (esds && describeEsdsAudio(bytes, esds)?.codec) || 'AAC'; } else info.codec = { 'ac-3': 'Dolby Digital (AC-3)', 'ec-3': 'Dolby Digital Plus (E-AC-3)', Opus: 'Opus', fLaC: 'FLAC', '.mp3': 'MP3' }[type] || ''; return info; } function describeMp4Moov(bytes) { try { const moov = isoBoxes(bytes).find(b => b.type === 'moov'); if (!moov) return null; const info = { video: null, audio: null, duration: 0 }; const mvhd = isoBoxes(bytes, moov.data, moov.end).find(b => b.type === 'mvhd'); if (mvhd) { const v1 = bytes[mvhd.data] === 1, scale = readUint32BE(bytes, mvhd.data + (v1 ? 20 : 12)); info.duration = scale ? (v1 ? readUint64BE(bytes, mvhd.data + 24) : readUint32BE(bytes, mvhd.data + 16)) / scale : 0; } for (const trak of isoBoxes(bytes, moov.data, moov.end).filter(b => b.type === 'trak')) { const mdia = isoChild(bytes, trak, 'mdia'), hdlr = isoChild(bytes, mdia, 'hdlr'), mdhd = isoChild(bytes, mdia, 'mdhd'); const handler = fourCc(bytes, hdlr.data + 8); if (handler !== 'vide' && handler !== 'soun') continue; const stbl = isoChild(bytes, isoChild(bytes, mdia, 'minf'), 'stbl'), stsd = isoChild(bytes, stbl, 'stsd'); const entry = isoBoxes(bytes, stsd.data + 8, stsd.end)[0]; if (!entry) continue; const detail = describeSampleEntry(bytes, entry, handler); if (handler === 'vide' && !info.video) { const v1 = bytes[mdhd.data] === 1, scale = readUint32BE(bytes, mdhd.data + (v1 ? 20 : 12)); const stts = isoChild(bytes, stbl, 'stts', false); let samples = 0, ticks = 0; if (stts) for (let k = 0, n = readUint32BE(bytes, stts.data + 4); k < n && stts.data + 8 + k * 8 + 8 <= stts.end; k++) { const c = readUint32BE(bytes, stts.data + 8 + k * 8), d = readUint32BE(bytes, stts.data + 12 + k * 8); samples += c; ticks += c * d; } info.video = { ...detail, fps: samples > 0 && ticks > 0 && scale ? samples / (ticks / scale) : 0 }; } else if (handler === 'soun' && !info.audio) info.audio = detail; } return info.video || info.audio ? info : null; } catch { return null; } } function formatFileInfo(info, size = 0) { if (!info) return ''; const v = info.video, a = info.audio, parts = ['MP4']; if (v?.width && v?.height) parts.push(`${v.width}×${v.height}`); if (v?.fps) parts.push(`${formatFps(v.fps)} fps`); if (v?.codec) parts.push([v.codec, v.profile, v.level].filter(Boolean).join(' ')); if (a) parts.push(`${a.codec || 'áudio'}${a.channels ? ` ${a.channels === 1 ? 'mono' : a.channels === 2 ? 'estéreo' : `${a.channels} canais`}` : ''}${a.sampleRate ? ` ${Math.round(a.sampleRate / 100) / 10} kHz` : ''}`); else if (v) parts.push('sem áudio'); const duration = formatDuration(info.duration); if (duration) parts.push(duration); if (size > 0) parts.push(`${(size / 1048576).toFixed(1)} MB`); return parts.join(' · '); } function mergeOutputStats(info, stats) { if (!info || !stats) return info; if (info.video && !info.video.fps && stats.fps > 0) info.video.fps = stats.fps; if (!info.duration && stats.duration > 0) info.duration = stats.duration; return info; } function showFileInfo(card, info, size = 0) { const text = formatFileInfo(info, size); if (!card || !text) return; let line = card.querySelector('.vdd-fileinfo'); if (!line) { line = document.createElement('div'); line.className = 'vdd-fileinfo'; card.querySelector('.vdd-progress')?.insertAdjacentElement('afterend', line); } line.textContent = text; } async function inspectMp4Blob(blob) { const summary = { ftyp: false, moov: false, mdat: false, moof: false, videoTrack: false, boxes: 0, }; let offset = 0; let moovStart = 0; let moovSize = 0; while (offset < blob.size) { if (summary.boxes >= 100000) throw invalidMp4('O MP4 contém blocos demais para ser validado'); if (blob.size - offset < 8) throw invalidMp4('O final do MP4 chegou incompleto'); const header = new Uint8Array(await blob.slice(offset, Math.min(blob.size, offset + 16)).arrayBuffer()); const size32 = readUint32BE(header, 0); const type = fourCc(header, 4); if (!/^[\x20-\x7e]{4}$/.test(type)) throw invalidMp4('A estrutura interna do MP4 está corrompida'); let headerSize = 8; let boxSize = size32; if (size32 === 1) { if (header.byteLength < 16) throw invalidMp4(`O bloco ${type} chegou incompleto`); headerSize = 16; boxSize = readUint64BE(header, 8); } else if (size32 === 0) { boxSize = blob.size - offset; } if (!boxSize || boxSize < headerSize) throw invalidMp4(`O bloco ${type} possui tamanho inválido`); if (boxSize > blob.size - offset) throw invalidMp4(`O vídeo terminou antes de completar o bloco ${type}`); summary.boxes++; if (type === 'ftyp') summary.ftyp = true; if (type === 'mdat') summary.mdat = true; if (type === 'moof') summary.moof = true; if (type === 'moov') { summary.moov = true; moovStart = offset; moovSize = boxSize; } offset += boxSize; if (size32 === 0) break; } if (offset !== blob.size) throw invalidMp4('O MP4 possui dados incompletos no final'); if (!summary.ftyp || !summary.moov || !summary.mdat) { throw invalidMp4('O arquivo recebido não contém um vídeo MP4 completo'); } const metadataLimit = Math.min(moovSize, 8 * 1024 * 1024); const moovBytes = new Uint8Array(await blob.slice(moovStart, moovStart + metadataLimit).arrayBuffer()); summary.videoTrack = containsFourCc(moovBytes, 'vide'); if (moovSize <= metadataLimit) summary.info = describeMp4Moov(moovBytes); if (!summary.videoTrack && moovSize <= metadataLimit) { throw invalidMp4('O MP4 recebido não possui uma faixa de vídeo'); } return summary; } async function probeMp4WithBrowser(blob) { if (typeof document?.createElement !== 'function' || typeof URL?.createObjectURL !== 'function') return; const video = document.createElement('video'); if (!video || typeof video.load !== 'function') return; const objectUrl = URL.createObjectURL(ensureBlobMime(blob, 'video/mp4')); video.preload = 'auto'; video.muted = true; video.playsInline = true; try { await new Promise((resolve, reject) => { let settled = false; const finish = (fn, value) => { if (settled) return; settled = true; clearTimeout(timer); video.onloadedmetadata = null; video.onloadeddata = null; video.onerror = null; fn(value); }; const timer = setTimeout( () => finish(reject, invalidMp4('O navegador não confirmou a reprodução do MP4')), 12000 ); const verify = () => { if (Number(video.videoWidth || 0) <= 0 || Number(video.videoHeight || 0) <= 0) { finish(reject, invalidMp4('O navegador não encontrou imagem dentro do MP4')); return; } if (Number(video.readyState || 0) >= 2) finish(resolve); }; video.onloadedmetadata = verify; video.onloadeddata = () => { verify(); if (!settled) finish(resolve); }; video.onerror = () => finish(reject, invalidMp4('O navegador não conseguiu abrir o MP4 preparado')); video.src = objectUrl; video.load(); }); } finally { try { video.removeAttribute('src'); video.load(); } catch {} URL.revokeObjectURL(objectUrl); } } // --------------------------------------------------------------------------- // Output validation // Recovers altered files and confirms the browser can read them before saving. // --------------------------------------------------------------------------- function findMp4Start(bytes) { if (!bytes?.byteLength || bytes.byteLength < 12) return -1; const limit = Math.min(bytes.byteLength - 4, 1024 * 1024); for (let typeOffset = 4; typeOffset <= limit; typeOffset++) { if (bytes[typeOffset] !== 0x66 || bytes[typeOffset + 1] !== 0x74 || bytes[typeOffset + 2] !== 0x79 || bytes[typeOffset + 3] !== 0x70) continue; const start = typeOffset - 4; const boxSize = readUint32BE(bytes, start); if (boxSize >= 8 && boxSize <= bytes.byteLength - start) return start; } return -1; } function collapseAlternatingBytes(bytes, dataIndex) { const length = Math.floor((bytes.byteLength - dataIndex + 1) / 2); const out = new Uint8Array(Math.max(0, length)); let write = 0; for (let read = dataIndex; read < bytes.byteLength; read += 2) out[write++] = bytes[read]; return write === out.byteLength ? out : out.slice(0, write); } function decodeBase64Bytes(text) { const cleaned = String(text || '') .replace(/^data:[^;,]+;base64,/i, '') .replace(/\s+/g, ''); if (!cleaned || cleaned.length % 4 === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(cleaned)) return null; try { const binary = atob(cleaned); return binaryStringToBytes(binary); } catch { return null; } } async function validatedRecoveryCandidate(bytes, type = 'video/mp4') { if (!bytes?.byteLength) return null; let candidate = bytes; const start = findMp4Start(candidate.subarray(0, Math.min(candidate.byteLength, 1024 * 1024))); if (start > 0) candidate = candidate.slice(start); if (findMp4Start(candidate.subarray(0, Math.min(candidate.byteLength, 1024 * 1024))) !== 0) return null; const blob = new Blob([candidate], { type }); try { await inspectMp4Blob(blob); return blob; } catch { return null; } } async function recoverAlteredMp4Blob(blob, onProgress = null) { onProgress?.(null, 'Corrigindo a leitura binária do navegador...'); const head = new Uint8Array(await blob.slice(0, Math.min(blob.size, 1024 * 1024)).arrayBuffer()); const directStart = findMp4Start(head); if (directStart > 0) { const trimmed = new Blob([blob.slice(directStart)], { type: 'video/mp4' }); try { await inspectMp4Blob(trimmed); return trimmed; } catch {} } if (blob.size > 96 * 1024 * 1024) return null; const bytes = new Uint8Array(await blob.arrayBuffer()); for (const dataIndex of [0, 1]) { const recovered = await validatedRecoveryCandidate(collapseAlternatingBytes(bytes, dataIndex)); if (recovered) return recovered; } try { const decoded = new TextDecoder('utf-8', { fatal: true }).decode(bytes); const recovered = await validatedRecoveryCandidate(binaryStringToBytes(decoded)); if (recovered) return recovered; if (decoded.length <= 64 * 1024 * 1024) { const base64 = decodeBase64Bytes(decoded); const base64Recovered = await validatedRecoveryCandidate(base64); if (base64Recovered) return base64Recovered; } if (decoded.length <= 32 * 1024 * 1024 && /^\s*\[/.test(decoded)) { try { const values = JSON.parse(decoded); if (Array.isArray(values)) { const jsonRecovered = await validatedRecoveryCandidate(Uint8Array.from(values)); if (jsonRecovered) return jsonRecovered; } } catch {} } } catch {} return null; } async function probeVideoBlobWithBrowser(blob, format) { if (typeof document?.createElement !== 'function' || typeof URL?.createObjectURL !== 'function') return; const video = document.createElement('video'); if (!video || typeof video.load !== 'function') return; const objectUrl = URL.createObjectURL(ensureBlobMime(blob, format?.mime || blob.type || 'application/octet-stream')); video.preload = 'auto'; video.muted = true; video.playsInline = true; try { await new Promise((resolve, reject) => { let settled = false; const finish = (fn, value) => { if (settled) return; settled = true; clearTimeout(timer); video.onloadedmetadata = null; video.onloadeddata = null; video.onerror = null; fn(value); }; const timer = setTimeout(() => finish(reject, new Error('O navegador não confirmou que este arquivo de vídeo pode ser reproduzido')), 12000); const verify = () => { if (Number(video.videoWidth || 0) <= 0 || Number(video.videoHeight || 0) <= 0) { finish(reject, new Error('O arquivo não contém uma faixa de vídeo reproduzível')); return; } if (Number(video.readyState || 0) >= 2) finish(resolve); }; video.onloadedmetadata = verify; video.onloadeddata = () => { verify(); if (!settled) finish(resolve); }; video.onerror = () => finish(reject, new Error('O navegador não conseguiu reproduzir o arquivo preparado')); video.src = objectUrl; video.load(); }); } finally { try { video.removeAttribute('src'); video.load(); } catch {} URL.revokeObjectURL(objectUrl); } } async function validatePreparedMp4(blob, onProgress = null) { onProgress?.(null, 'Validando a estrutura MP4...'); let validated = blob; let structuralError = null; let structuralOk = false; let fileInfo = null; try { fileInfo = (await inspectMp4Blob(validated)).info || null; structuralOk = true; } catch (error) { if (error?.code !== 'VDD_INVALID_MP4') throw error; structuralError = error; const recovered = await recoverAlteredMp4Blob(validated, onProgress); if (recovered) { validated = recovered; structuralError = null; try { fileInfo = (await inspectMp4Blob(validated)).info || null; structuralOk = true; } catch (recoveredError) { structuralError = recoveredError; } console.info('[VDD] Transporte binário móvel corrigido antes do salvamento.'); } } try { const metaBytes = new Uint8Array(await validated.slice(0, Math.min(validated.size, 8 * 1024 * 1024)).arrayBuffer()); if (containsFourCc(metaBytes, 'pssh') || containsFourCc(metaBytes, 'encv') || containsFourCc(metaBytes, 'enca')) { throw invalidMp4('O MP4 usa uma faixa protegida/criptografada e não pode ser baixado diretamente'); } } catch (error) { if (error?.code === 'VDD_INVALID_MP4') throw error; } if (structuralOk) { if (fileInfo) onProgress?.(null, `Arquivo conferido · ${formatFileInfo(fileInfo, validated.size)}`, { phase: 'validate', info: fileInfo, size: validated.size }); return validated; } try { await probeMp4WithBrowser(validated); return validated; } catch (browserError) { throw structuralError || browserError; } } // --------------------------------------------------------------------------- // Delivery // The final save step. // --------------------------------------------------------------------------- const OUTPUT = { mode: 'video', kbps: 192 }; // Saves a Blob through the manager, with browser and manual fallbacks. async function saveBlob(blob, filename, onProgress = null, source = {}) { if (!blob?.size) throw new Error('O vídeo preparado está vazio'); if (OUTPUT.mode === 'mp3') return saveBlobAsMp3(blob, filename, onProgress, source); const sourceUrl = String(source?.url || ''); const sourceType = mediaContentType(source?.contentType || blob.type || ''); const probeSize = Math.min(blob.size, 1024 * 1024); const probeBytes = new Uint8Array(await blob.slice(0, probeSize).arrayBuffer()); let format = detectMediaFormatFromBytes(probeBytes, sourceType || blob.type, sourceUrl || filename); if (!format || format.hls) { const hint = knownDirectFormatHint(sourceUrl, sourceType || blob.type); if (hint && !looksLikeHtmlBytes(probeBytes)) { format = { ...hint, confirmed: false }; } } if (!format || format.hls) { throw new Error('O conteúdo recebido não é um arquivo de vídeo direto reconhecido'); } if (format.ext === 'mp4') { blob = ensureBlobMime(blob, 'video/mp4'); blob = await validatePreparedMp4(blob, onProgress); format = { ext: 'mp4', mime: 'video/mp4', label: 'MP4', confirmed: true }; } else { onProgress?.(null, `Validando ${format.label || 'vídeo'}...`); blob = ensureBlobMime(blob, format.mime); if (!format.confirmed) await probeVideoBlobWithBrowser(blob, format); } filename = filenameForFormat(filename, format); blob = ensureBlobMime(blob, format.mime); return deliverBlob(blob, filename, onProgress); } async function deliverBlob(blob, filename, onProgress = null) { if (isAppleMobileBrowser()) { throw manualSaveRequired(blob, filename, new Error('Salvamento binário seguro exigido no iPhone')); } try { return await saveBlobWithManager(blob, filename, onProgress); } catch (managerError) { console.info('[VDD] Gerenciador de downloads indisponível; restaurando fallback por Blob validado:', managerError); const job = { blob, filename, objectUrl: '', attempts: 0, cleanupTimer: null }; try { triggerManualBlobDownload(job); if (job.objectUrl) setTimeout(() => { try { URL.revokeObjectURL(job.objectUrl); } catch {} }, 60000); return 'anchor'; } catch (anchorError) { console.info('[VDD] Fallback por Blob também falhou:', anchorError); throw manualSaveRequired(blob, filename, managerError); } } } function saveBlobParts(parts, filename, onProgress = null) { return saveBlob( new Blob(parts, { type: 'video/mp4' }), filename, onProgress, { contentType: 'video/mp4', url: `${cleanFilenameBase(filename)}.mp4` } ); } // --------------------------------------------------------------------------- // Response checks // Guards against saving an HTML page or other non-media response as a video. // --------------------------------------------------------------------------- function mediaContentType(value) { const type = String(value || '').toLowerCase(); if (!type) return ''; return type.split(';', 1)[0].trim(); } function responseLooksLikeMedia(contentType, url = '') { const type = mediaContentType(contentType); if (type.startsWith('video/')) return true; if (type === 'application/mp4') return true; if (type === 'application/octet-stream' || type === 'binary/octet-stream') return true; if (isHls(url, type)) return true; if (knownDirectFormatHint(url, type)) return true; return false; } function responseLooksLikeHtml(contentType) { const type = mediaContentType(contentType); return type === 'text/html' || type === 'application/xhtml+xml'; } function looksLikeMp4Bytes(bytes) { return findMp4Start(bytes) >= 0; } function looksLikeHtmlBytes(bytes) { if (!bytes?.byteLength) return false; const sample = bytes.slice(0, Math.min(bytes.byteLength, 512)); let text = ''; try { text = new TextDecoder('utf-8').decode(sample); } catch { text = String.fromCharCode(...sample.slice(0, 160)); } return /^\s*(?:]*>([\s\S]{1,120}?)<\/title>/i)?.[1]; return title ? ` (título da página: “${cleanDisplayText(title).slice(0, 80)}”)` : ''; } catch { return ''; } } function validateDirectBytes(bytes, contentType, url, status = 0) { if (status >= 300) throw makeHttpError(`Servidor respondeu HTTP ${status}`, status); if (!bytes?.byteLength) throw makeHttpError('O servidor retornou um arquivo vazio', status); if (responseLooksLikeHtml(contentType) || looksLikeHtmlBytes(bytes)) { throw makeHttpError(`O servidor devolveu uma página HTML (login, bloqueio ou erro) em vez do vídeo${htmlPageHint(bytes)}`, status); } const format = detectMediaFormatFromBytes(bytes, contentType, url); if (!format && (/json/i.test(String(contentType)) || /^\s*[\[{]/.test(new TextDecoder().decode(bytes.subarray(0, 32))))) { const error = makeHttpError(`A fonte devolveu dados JSON, não vídeo (${summarizeJsonBody(bytes)}). Nada foi salvo.`, status); error.code = 'MEDIA_JSON'; try { error.jsonCandidates = jsonMediaCandidates(JSON.parse(new TextDecoder().decode(bytes)), url); } catch { error.jsonCandidates = []; } throw error; } if (format?.hls) { throw makeHttpError('A fonte direta respondeu uma playlist HLS; use a opção HLS detectada', status); } if (!format && !responseLooksLikeMedia(contentType, url)) { const type = mediaContentType(contentType); if (type && type !== 'application/octet-stream' && type !== 'binary/octet-stream') { throw makeHttpError(`A fonte respondeu um tipo que não é vídeo: ${type}`, status); } } return format; } // --------------------------------------------------------------------------- // Fresh URLs // Finds a newer signed URL or HLS playlist for the same media. // --------------------------------------------------------------------------- function latestPerformanceUrls(test) { try { return performance.getEntriesByType('resource') .map((entry, index) => ({ url: entry.name, index, time: Number(entry.startTime || 0) })) .filter(entry => isHttp(entry.url) && test(entry.url)) .sort((a, b) => (b.time - a.time) || (b.index - a.index)) .map(entry => entry.url); } catch { return []; } } function sameMediaPath(a, b) { try { const ua = new URL(a); const ub = new URL(b); if (ua.hostname !== ub.hostname) return false; return mediaIdentityKey(a) === mediaIdentityKey(b); } catch { return false; } } function getFreshDirectUrl(originalUrl, allowAlternate = true) { if (isBlobUrl(originalUrl) || !allowAlternate) return originalUrl; const domUrls = []; document.querySelectorAll('video').forEach(video => { const candidates = [video.currentSrc, video.src]; video.querySelectorAll('source').forEach(source => candidates.push(source.src || source.getAttribute('src'))); candidates.forEach(raw => { if (!raw) return; const url = absUrl(raw); if (isUsableDirectUrl(url) && !isHls(url) && (isLikelyDirectVideoUrl(url) || APP.directUrls.has(url))) domUrls.push(url); }); }); const exact = domUrls.find(url => sameMediaPath(url, originalUrl)); if (exact) return exact; const perf = latestPerformanceUrls(url => isLikelyDirectVideoUrl(url) && !isHls(url)); const perfExact = perf.find(url => sameMediaPath(url, originalUrl)); if (perfExact) return perfExact; return originalUrl; } function recentUnderlyingDirectUrls() { const seen = new Set(); const out = []; const push = url => { const full = absUrl(url); if (!isHttp(full) || isHls(full) || seen.has(full)) return; seen.add(full); out.push(full); }; [...APP.recentNetworkMedia] .sort((a, b) => Number(b.time || 0) - Number(a.time || 0)) .forEach(item => { if (item.kind !== 'HLS' && (item.trusted || knownDirectFormatHint(item.url, item.type))) push(item.url); }); [...APP.recentBinaryCandidates] .sort((a, b) => Number(b.time || 0) - Number(a.time || 0)) .forEach(item => push(item.url)); latestPerformanceUrls(url => isLikelyDirectVideoUrl(url) && !isHls(url)).forEach(push); [...APP.directUrls].reverse().forEach(url => { if (!isBlobUrl(url)) push(url); }); return out; } function recentUnderlyingHlsUrls() { const seen = new Set(); const out = []; const push = url => { const full = absUrl(url); if (!isHttp(full) || seen.has(full)) return; seen.add(full); out.push(full); }; [...APP.recentNetworkMedia] .sort((a, b) => Number(b.time || 0) - Number(a.time || 0)) .forEach(item => { if (item.kind === 'HLS' || isHls(item.url, item.type)) push(item.url); }); latestPerformanceUrls(url => isHls(url)).forEach(push); [...APP.hlsUrls].reverse().forEach(push); return out; } function getFreshHlsFallback() { collectFromDom(); collectFromPerformance(); const recent = recentUnderlyingHlsUrls(); return recent[0] || ''; } function networkCandidateScore(item) { const raw = String(item?.url || '').toLowerCase(); const type = mediaContentType(item?.type || ''); let score = Number(item?.time || 0) / 1e13; if (/mpegurl|dash\+xml|video\//i.test(type)) score += 100; if (/\.(?:m3u8|mpd|mp4|m4v|webm|mov)(?:$|[?#&])/i.test(raw)) score += 90; if (/(?:master|manifest|playlist|stream|playback|video|media|hls|source|file)/i.test(raw)) score += 45; if (/\.(?:ts|m4s)(?:$|[?#&])/i.test(raw) || /(?:segment|chunk|frag)[-_]?\d/i.test(raw)) score -= 80; return score; } async function resolveObservedNetworkSource(onProgress = null) { const direct = recentUnderlyingDirectUrls(); const hls = recentUnderlyingHlsUrls(); if (direct.length || hls.length) return { direct, hls }; const candidates = [...APP.recentNetworkRequests] .filter(item => isHttp(item.url)) .sort((a, b) => networkCandidateScore(b) - networkCandidateScore(a)) .slice(0, 12); const foundDirect = []; const foundHls = []; for (let i = 0; i < candidates.length; i++) { const item = candidates[i]; if (/(?:\.ts|\.m4s)(?:$|[?#&])/i.test(item.url)) continue; onProgress?.(null, `Verificando fonte observada ${i + 1}/${candidates.length}...`); try { const probe = await probeLinkTarget(item.url); if (!probe || probe.page || !probe.url) continue; if (probe.kind === 'HLS' || isHls(probe.url, probe.contentType)) { if (!foundHls.includes(probe.url)) foundHls.push(probe.url); } else if (knownDirectFormatHint(probe.url, probe.contentType) || mediaContentType(probe.contentType).startsWith('video/')) { if (!foundDirect.includes(probe.url)) foundDirect.push(probe.url); } if (foundDirect.length || foundHls.length) break; } catch {} await yieldUi(0); } return { direct: foundDirect, hls: foundHls }; } function getFreshHlsUrl(originalUrl, allowAlternate = true) { collectFromDom(); collectFromPerformance(); const recent = latestPerformanceUrls(url => isHls(url)); const exact = recent.find(url => sameMediaPath(url, originalUrl)); if (exact) return exact; const stored = [...APP.hlsUrls].reverse(); const storedExact = stored.find(url => sameMediaPath(url, originalUrl)); return storedExact || originalUrl; } // --------------------------------------------------------------------------- // In-page fetching // Fetches inside the page or its main world when cookies or origin matter. // --------------------------------------------------------------------------- async function fetchBlobInPage(url, onProgress) { const controller = new AbortController(); let idleTimer = null; const resetIdleTimer = () => { clearTimeout(idleTimer); idleTimer = setTimeout(() => controller.abort(), NET.pageIdleTimeout); }; resetIdleTimer(); try { const requestInit = isBlobUrl(url) ? { method: 'GET', cache: 'no-store', signal: controller.signal } : { method: 'GET', credentials: 'include', cache: 'no-store', redirect: 'follow', referrer: currentRequestReferrer(), referrerPolicy: 'strict-origin-when-cross-origin', signal: controller.signal, }; const response = await fetch(url, requestInit); const contentType = response.headers.get('content-type') || ''; if (!response.ok) { const err = new Error(`Servidor respondeu ${response.status}`); err.status = response.status; err.contentType = contentType; throw err; } if (responseLooksLikeHtml(contentType)) { const err = new Error('O servidor devolveu uma página HTML em vez do vídeo'); err.status = response.status; err.contentType = contentType; throw err; } const contentRange = parseContentRange(response.headers.get('content-range')); const total = response.status === 206 ? Number(contentRange?.total || 0) : Number(response.headers.get('content-length') || 0); if (!response.body || typeof response.body.getReader !== 'function') { clearTimeout(idleTimer); idleTimer = setTimeout(() => controller.abort(), NET.fullRequestTimeout); const blob = await response.blob(); const probe = new Uint8Array(await blob.slice(0, 4096).arrayBuffer()); validateDirectBytes(probe, contentType, response.url || url, response.status); if (response.status === 206 && !(contentRange?.start === 0 && contentRange.total > 0 && blob.size >= contentRange.total)) { throw makeHttpError('O servidor entregou apenas uma parte do vídeo', 206); } return blob; } const reader = response.body.getReader(); const chunks = []; let received = 0; let lastUi = 0; while (true) { const { done, value } = await reader.read(); if (done) break; if (!value?.byteLength) continue; chunks.push(value); received += value.byteLength; resetIdleTimer(); const now = performance.now(); if (now - lastUi > 250) { if (total > 0) { reportByteProgress(onProgress, received, total); } else { const mb = received / 1024 / 1024; reportByteProgress(onProgress, mb * 1048576); } lastUi = now; await yieldUi(0); } } if (!received) throw new Error('O servidor retornou um arquivo vazio'); const blob = new Blob(chunks, { type: mediaContentType(contentType) || 'application/octet-stream' }); const probe = new Uint8Array(await blob.slice(0, 4096).arrayBuffer()); validateDirectBytes(probe, contentType, response.url || url, response.status); if (response.status === 206 && !(contentRange?.start === 0 && contentRange.total > 0 && received >= contentRange.total)) { throw makeHttpError('O servidor entregou apenas uma parte do vídeo', 206); } return blob; } catch (error) { if (error?.name === 'AbortError') { throw makeHttpError('A fonte ficou sem responder. Tentando outro modo...'); } throw error; } finally { clearTimeout(idleTimer); } } async function fetchBlobInPageWorld(url, onProgress = null) { if (!isBlobUrl(url)) throw new Error('Fallback do contexto da página só é usado para Blob'); let pageWindow = window; try { if (typeof unsafeWindow === 'object' && unsafeWindow) pageWindow = unsafeWindow; } catch {} const pageFetch = pageWindow?.fetch; if (typeof pageFetch !== 'function') throw new Error('Fetch do contexto da página indisponível'); onProgress?.(null, 'Tentando ler o Blob no contexto real da página...'); let response; try { response = await Reflect.apply(pageFetch, pageWindow, [url, { method: 'GET', cache: 'no-store' }]); } catch (error) { throw makeHttpError(`O Blob da página não pôde ser lido: ${error?.message || 'falha do navegador'}`); } const contentType = response?.headers?.get?.('content-type') || ''; if (!response?.ok) { const error = makeHttpError(`O Blob respondeu ${response?.status || 'sem status'}`, Number(response?.status || 0)); error.contentType = contentType; throw error; } let pageBlob; try { pageBlob = await response.blob(); } catch (error) { throw new Error(`O navegador não liberou os bytes do Blob: ${error?.message || 'falha de leitura'}`); } if (!pageBlob?.size) throw new Error('O Blob temporário está vazio'); const bytes = await binaryValueToBytes(pageBlob); if (!bytes.byteLength) throw new Error('O Blob foi aberto, mas os bytes não puderam ser transferidos para o userscript'); validateDirectBytes(bytes.slice(0, Math.min(bytes.byteLength, 1024 * 1024)), contentType || pageBlob.type, url, 200); return new Blob([bytes], { type: mediaContentType(contentType || pageBlob.type) || 'application/octet-stream' }); } function parseResponseHeaders(raw) { const out = {}; String(raw || '').split(/\r?\n/).forEach(line => { const i = line.indexOf(':'); if (i <= 0) return; out[line.slice(0, i).trim().toLowerCase()] = line.slice(i + 1).trim(); }); return out; } // --------------------------------------------------------------------------- // GM binary requests // Platform cookies and headers, and binary handling for GM_xmlhttpRequest. // --------------------------------------------------------------------------- const PLATFORM_HOSTS = [ { re: /(?:^|\.)(?:tiktok\.com|tiktokcdn\.com|tiktokcdn-us\.com|tiktokv\.com|tiktokv\.us|byteoversea\.com)$/i, referer: 'https://www.tiktok.com/' }, { re: /(?:^|\.)(?:instagram\.com|cdninstagram\.com|fbcdn\.net)$/i, referer: 'https://www.instagram.com/' }, ]; const linkCookieJar = new Map(); function platformHostInfo(url) { try { const host = new URL(url).hostname.toLowerCase(); const platform = PLATFORM_HOSTS.find(item => item.re.test(host)); return platform ? { host, referer: platform.referer } : null; } catch { return null; } } function rememberLinkCookies(url, rawHeaders) { const info = platformHostInfo(url); if (!info) return 0; let stored = 0; for (const line of String(rawHeaders || '').split(/\r?\n/).filter(item => /^set-cookie\s*:/i.test(item))) { for (const cookie of line.slice(line.indexOf(':') + 1).trim().split(/,(?=\s*[^;,\s=]+=)/)) { const parts = cookie.split(';').map(part => part.trim()); const eq = parts[0].indexOf('='); if (eq <= 0) continue; const attrs = Object.fromEntries(parts.slice(1).map(part => { const i = part.indexOf('='); return i < 0 ? [part.toLowerCase(), ''] : [part.slice(0, i).toLowerCase(), part.slice(i + 1)]; })); const jar = linkCookieJar.get(info.referer) || new Map(); linkCookieJar.set(info.referer, jar); const name = parts[0].slice(0, eq); if (attrs['max-age'] === '0' || (attrs.expires && Date.parse(attrs.expires) < Date.now())) jar.delete(name); else { jar.set(name, parts[0].slice(eq + 1)); stored++; } } } return stored; } function platformRequestHeaders(url, mode = 'session') { const info = platformHostInfo(url); if (!info || mode === 'bare') return {}; const cookies = []; for (const [name, value] of linkCookieJar.get(info.referer) || []) cookies.push(`${name}=${value}`); return { ...(mode === 'session' ? { Referer: info.referer } : {}), ...(cookies.length ? { Cookie: cookies.join('; ') } : {}) }; } function binaryStringToBytes(value) { const text = String(value || ''); const bytes = new Uint8Array(text.length); for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i) & 0xff; return bytes; } async function binaryValueToBytes(value) { if (value == null) return new Uint8Array(0); if (ArrayBuffer.isView(value)) { return new Uint8Array(value.buffer, value.byteOffset, value.byteLength).slice(); } const tag = Object.prototype.toString.call(value); if (tag === '[object ArrayBuffer]' || tag === '[object SharedArrayBuffer]') { try { return new Uint8Array(value).slice(); } catch {} } if (typeof value?.arrayBuffer === 'function') { try { const buffer = await value.arrayBuffer(); return new Uint8Array(buffer).slice(); } catch {} } if (tag === '[object Blob]' && typeof FileReader === 'function') { try { const buffer = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = () => reject(reader.error || new Error('Falha ao ler Blob')); reader.readAsArrayBuffer(value); }); return new Uint8Array(buffer).slice(); } catch {} } if (value?.data != null && value.data !== value) { const nested = await binaryValueToBytes(value.data); if (nested.byteLength) return nested; } if (Array.isArray(value)) { try { return Uint8Array.from(value); } catch {} } if (typeof value === 'string') return binaryStringToBytes(value); if (Number.isFinite(Number(value?.byteLength)) && Number(value.byteLength) > 0) { try { return new Uint8Array(value).slice(); } catch {} } return new Uint8Array(0); } async function extractGmResponseBytes(response) { let value; try { value = response?.response; } catch { value = null; } let bytes = await binaryValueToBytes(value); if (bytes.byteLength) return bytes; try { if (typeof response?.responseText === 'string' && response.responseText.length) { bytes = binaryStringToBytes(response.responseText); } } catch {} return bytes; } function sameSessionHeaders() { return { Accept: 'video/*,application/octet-stream;q=0.9,*/*;q=0.7', 'Cache-Control': 'no-cache', Pragma: 'no-cache', Referer: currentRequestReferrer(), }; } function gmHeadersForMode(extraHeaders = {}, mode = 'session') { const extra = { ...extraHeaders }; if (mode === 'bare') return extra; if (mode === 'minimal') { return { Accept: 'video/*,application/octet-stream;q=0.9,*/*;q=0.7', ...extra, }; } if (mode === 'origin') { const origin = originOf(currentRequestReferrer()); return { ...sameSessionHeaders(), ...(origin ? { Origin: origin } : {}), ...extra }; } return { ...sameSessionHeaders(), ...extra }; } function gmArrayBufferRequest(url, extraHeaders = {}, timeout = NET.requestTimeout, onProgress = null, responseType = 'arraybuffer', headerMode = 'session') { const gmXhr = getGmXhr(); if (!gmXhr) { return Promise.reject(new Error('Modo de compatibilidade indisponível')); } return new Promise((resolve, reject) => { let settled = false; let idleTimer = null; let requestHandle = null; const settle = (fn, value) => { if (settled) return; settled = true; clearTimeout(idleTimer); fn(value); }; const armIdleTimer = () => { clearTimeout(idleTimer); idleTimer = setTimeout(() => { try { requestHandle?.abort?.(); } catch {} settle(reject, makeHttpError('A fonte ficou sem responder')); }, Math.min(Number(timeout || NET.requestTimeout), NET.pageIdleTimeout)); }; armIdleTimer(); try { requestHandle = gmXhr({ method: 'GET', url, headers: gmHeadersForMode({ ...platformRequestHeaders(url, headerMode), ...extraHeaders }, headerMode), responseType, timeout, anonymous: false, onprogress: event => { armIdleTimer(); onProgress?.(event); }, onload: async response => { try { const headers = parseResponseHeaders(response.responseHeaders); const bytes = await extractGmResponseBytes(response); settle(resolve, { status: Number(response.status || 0), finalUrl: response.finalUrl || url, headers, contentType: headers['content-type'] || '', bytes, responseType, headerMode, }); } catch (error) { settle(reject, error); } }, onerror: details => { const error = makeHttpError('A requisição alternativa foi bloqueada pelo navegador ou pelo servidor'); error.details = details; error.headerMode = headerMode; settle(reject, error); }, ontimeout: () => settle(reject, makeHttpError('Tempo esgotado ao baixar o arquivo')), onabort: () => settle(reject, makeHttpError('Requisição cancelada')), }); } catch (error) { settle(reject, error); } }); } async function gmBinaryRequestCompatible(url, extraHeaders = {}, timeout = NET.requestTimeout, onProgress = null) { const range = String(extraHeaders?.Range || extraHeaders?.range || ''); const expectsMp4Header = isMp4(url) && (!range || /^bytes=0-/i.test(range)); const preferredType = isAppleMobileBrowser() ? 'blob' : 'arraybuffer'; const responseTypes = [preferredType, preferredType === 'blob' ? 'arraybuffer' : 'blob']; const headerModes = originOf(url) !== originOf(currentRequestReferrer()) ? ['session', 'origin', 'minimal', 'bare'] : ['session', 'minimal', 'bare']; let best = null; let firstError = null; const usable = result => { if (!result || (result.status !== 200 && result.status !== 206)) return false; if (!result.bytes.byteLength) return false; if (expectsMp4Header && !looksLikeMp4Bytes(result.bytes.slice(0, 1024 * 1024))) { const type = mediaContentType(result.contentType); if (!(type.startsWith('video/') || type === 'application/mp4')) return false; } return true; }; for (const headerMode of headerModes) { for (const responseType of responseTypes) { try { const result = await gmArrayBufferRequest( url, extraHeaders, timeout, onProgress, responseType, headerMode ); if (result.status === 429 || result.status >= 500) throw mediaHttpError(result.status, result.headers); if (!best || (result.status >= 200 && result.status < 300)) best = result; if (usable(result)) return result; } catch (error) { if (error.status === 429 || error.status >= 500) throw error; if (!firstError) firstError = error; console.info(`[VDD] GM ${responseType}/${headerMode} falhou:`, error); } } } if (best) return best; throw firstError || new Error('A requisição alternativa não pôde ser concluída'); } async function requestBlobWithManager(url, onProgress) { const result = await withNetworkRetries(async () => { const response = await gmBinaryRequestCompatible(url, {}, NET.fullRequestTimeout, event => { if (event.lengthComputable && event.total > 0) { reportByteProgress(onProgress, event.loaded, event.total); } else if (event.loaded > 0) { const mb = event.loaded / 1024 / 1024; reportByteProgress(onProgress, mb * 1048576); } }); if ((response.status === 200 || response.status === 206) && !response.bytes.byteLength) { throw makeHttpError('O servidor enviou uma resposta vazia temporária', 503); } return response; }, { attempts: 2, onRetry(attempt, error) { onProgress?.(null, error?.status >= 500 && error.status !== 503 ? `Servidor respondeu HTTP ${error.status}. Tentando novamente (${attempt}/2)...` : 'Resposta vazia ou conexão interrompida. Tentando novamente...'); } }); if (result.status < 200 || result.status >= 400) { const err = new Error(`Servidor respondeu ${result.status}`); err.status = result.status; err.contentType = result.contentType; throw err; } if (result.status === 206) { const range = parseContentRange(result.headers['content-range']); const isComplete = range?.start === 0 && range.total > 0 && result.bytes.byteLength >= range.total; if (!isComplete) { throw makeHttpError('O servidor entregou apenas uma parte do vídeo', 206); } } validateDirectBytes(result.bytes.slice(0, 4096), result.contentType, result.finalUrl || url, result.status); return new Blob([result.bytes], { type: mediaContentType(result.contentType) || 'application/octet-stream' }); } // --------------------------------------------------------------------------- // Direct downloads // Range probing, chunked downloads and retries for direct files. // --------------------------------------------------------------------------- function parseContentRange(value) { const match = String(value || '').match(/bytes\s+(\d+)-(\d+)\/(\d+|\*)/i); if (!match) return null; return { start: Number(match[1]), end: Number(match[2]), total: match[3] === '*' ? 0 : Number(match[3]), }; } function parseUnsatisfiedRangeTotal(value) { const match = String(value || '').match(/bytes\s+\*\/(\d+)/i); return match ? Number(match[1]) : 0; } async function gmHeadRequest(url, timeout = 12000) { const gmXhr = getGmXhr(); if (!gmXhr) throw new Error('Modo HEAD indisponível'); const modes = ['session', 'minimal', 'bare']; let firstError = null; for (const mode of modes) { try { const result = await new Promise((resolve, reject) => { let handle = null; try { handle = gmXhr({ method: 'HEAD', url, headers: gmHeadersForMode({}, mode), timeout, anonymous: false, onload: response => resolve({ status: Number(response.status || 0), finalUrl: response.finalUrl || url, headers: parseResponseHeaders(response.responseHeaders), headerMode: mode, }), onerror: () => reject(makeHttpError('A consulta HEAD foi bloqueada')), ontimeout: () => reject(makeHttpError('A consulta HEAD demorou demais')), onabort: () => reject(makeHttpError('A consulta HEAD foi cancelada')), }); } catch (error) { reject(error); } }); if (result.status) return result; } catch (error) { if (!firstError) firstError = error; } } throw firstError || new Error('A consulta HEAD falhou'); } async function probeDirectMetadata(url) { try { const head = await gmHeadRequest(url); const size = Number(head.headers['content-length'] || 0); if (head.status >= 200 && head.status < 400 && !responseLooksLikeHtml(head.headers['content-type']) && Number.isFinite(size) && size > 0) { return { size, finalUrl: head.finalUrl || url, headers: head.headers }; } } catch (error) { console.info('[VDD] HEAD alternativo indisponível:', error); } const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 12000); try { const response = await fetch(url, { method: 'HEAD', credentials: 'include', cache: 'no-store', redirect: 'follow', referrer: currentRequestReferrer(), referrerPolicy: 'strict-origin-when-cross-origin', signal: controller.signal, }); const size = Number(response.headers.get('content-length') || 0); if (response.ok && !responseLooksLikeHtml(response.headers.get('content-type')) && Number.isFinite(size) && size > 0) { return { size, finalUrl: response.url || url, headers: Object.fromEntries(response.headers.entries()), }; } } catch (error) { console.info('[VDD] HEAD da página indisponível:', error); } finally { clearTimeout(timer); } return { size: 0, finalUrl: url, headers: {} }; } function gmArrayBufferRequestWithRetry(url, headers, timeout, onProgress = null, onRetry = null) { return withNetworkRetries(async () => { const result = await gmBinaryRequestCompatible(url, headers, timeout, onProgress); if (!result.status) { throw makeHttpError('Falha de rede: servidor sem status'); } if ((result.status === 200 || result.status === 206) && !result.bytes.byteLength) { throw makeHttpError('O servidor enviou um bloco vazio temporário', 503); } if (isTransientError({ status: result.status })) { throw makeHttpError(`Servidor respondeu ${result.status}`, result.status); } return result; }, { onRetry }); } async function finishDirectParts(parts, filename, contentType, onProgress, sourceUrl = '') { onProgress?.(null, 'Montando arquivo...'); const blob = new Blob(parts, { type: mediaContentType(contentType) || 'application/octet-stream' }); await saveBlob(blob, filename, onProgress, { url: sourceUrl, contentType }); onProgress?.(100, 'Arquivo salvo'); toast('Arquivo enviado para Downloads'); return true; } // Chunked download with Range requests and retries. async function downloadDirectByRanges(url, filename, onProgress) { const CHUNK = NET.rangeChunkSize; onProgress?.(null, 'Tentando modo por blocos...'); const first = await gmArrayBufferRequestWithRetry( url, { Range: `bytes=0-${CHUNK - 1}` }, NET.requestTimeout, null, (attempt, error) => onProgress?.(null, `${error?.status ? `HTTP ${error.status}. ` : ''}Repetindo primeiro bloco (${attempt}/${NET.retries})...`) ); if (first.status === 200) { validateDirectBytes(first.bytes.slice(0, 4096), first.contentType, first.finalUrl || url, first.status); const probe = await probeDirectMetadata(first.finalUrl || url); if (probe.size > first.bytes.byteLength) { throw makeHttpError('O servidor ignorou os blocos e devolveu um arquivo incompleto', 200); } await saveBlob( new Blob([first.bytes], { type: mediaContentType(first.contentType) || 'application/octet-stream' }), filename, onProgress, { url: first.finalUrl || url, contentType: first.contentType } ); onProgress?.(100, 'Arquivo salvo'); toast('Arquivo enviado para Downloads'); return true; } if (first.status !== 206) { throw makeHttpError(`Servidor respondeu ${first.status || 'sem status'}`, first.status); } validateDirectBytes(first.bytes.slice(0, 4096), first.contentType, first.finalUrl || url, first.status); const firstRange = parseContentRange(first.headers['content-range']); if (firstRange && firstRange.start !== 0) { throw makeHttpError('O servidor iniciou o vídeo em um ponto incorreto', 206); } let firstBytes = first.bytes; if (firstRange) { const announced = firstRange.end - firstRange.start + 1; if (firstBytes.byteLength < announced) { throw makeHttpError('O primeiro bloco chegou incompleto', 206); } if (firstBytes.byteLength > announced) firstBytes = firstBytes.slice(0, announced); } let activeUrl = first.finalUrl || url; let total = Number(firstRange?.total || 0); let metadata = { size: 0, finalUrl: activeUrl, headers: {} }; if (!total) { metadata = await probeDirectMetadata(activeUrl); if (metadata.size >= firstBytes.byteLength) total = metadata.size; if (metadata.finalUrl) activeUrl = metadata.finalUrl; } if (total > 0 && total <= firstBytes.byteLength) { return finishDirectParts([firstBytes.slice(0, total)], filename, first.contentType, onProgress, activeUrl || url); } const parts = [firstBytes]; let received = firstBytes.byteLength; let start = firstRange ? firstRange.end + 1 : firstBytes.byteLength; let chunkCount = 1; const rawEtag = first.headers.etag || metadata.headers?.etag || ''; const etag = /^W\//i.test(rawEtag) ? '' : rawEtag; const lastModified = first.headers['last-modified'] || metadata.headers?.['last-modified'] || ''; const ifRange = etag || lastModified; while (!total || start < total) { if (chunkCount >= NET.maxUnknownRangeChunks) { throw new Error('O download excedeu o limite de segurança do modo por blocos'); } const end = total ? Math.min(total - 1, start + CHUNK - 1) : start + CHUNK - 1; const requestHeaders = { Range: `bytes=${start}-${end}` }; if (ifRange) requestHeaders['If-Range'] = ifRange; const part = await gmArrayBufferRequestWithRetry( activeUrl, requestHeaders, NET.requestTimeout, null, attempt => onProgress?.( total ? Math.min(94, 18 + Math.round((received / total) * 76)) : 35, `Conexão interrompida. Repetindo bloco (${attempt}/${NET.retries})...` ) ); if (part.status === 416) { const reportedTotal = parseUnsatisfiedRangeTotal(part.headers['content-range']); if (total && start < total && (!reportedTotal || start < reportedTotal)) { throw makeHttpError('O servidor encerrou o vídeo antes do tamanho informado', 416); } if (reportedTotal && received < reportedTotal) { throw makeHttpError('Faltou uma parte do vídeo antes do fim do arquivo', 416); } break; } if (part.status === 200) { validateDirectBytes(part.bytes.slice(0, 4096), part.contentType, part.finalUrl || activeUrl, part.status); const probe = await probeDirectMetadata(part.finalUrl || activeUrl); if (probe.size && part.bytes.byteLength < probe.size) { throw makeHttpError('O servidor deixou de respeitar os blocos durante o download', 200); } return finishDirectParts([part.bytes], filename, part.contentType || first.contentType, onProgress, part.finalUrl || activeUrl || url); } if (part.status !== 206) { throw makeHttpError(`Bloco respondeu ${part.status || 'sem status'}`, part.status); } if (!part.bytes.byteLength) { if (!total) break; throw new Error('Um bloco do vídeo veio vazio'); } if (responseLooksLikeHtml(part.contentType) || looksLikeHtmlBytes(part.bytes)) { throw makeHttpError('Um bloco devolveu HTML em vez do vídeo', part.status); } if (etag && part.headers.etag && part.headers.etag !== etag) { throw new Error('A fonte do vídeo mudou durante o download. Atualize a lista e tente novamente.'); } const partRange = parseContentRange(part.headers['content-range']); let bytes = part.bytes; if (partRange) { if (partRange.total > 0) total = partRange.total; if (partRange.start > start) { throw new Error('O servidor deixou uma lacuna entre os blocos do vídeo'); } if (partRange.start < start) { const skip = start - partRange.start; if (skip >= bytes.byteLength) throw new Error('O servidor repetiu um bloco sem avançar'); bytes = bytes.slice(skip); } const availableThroughHeader = Math.max(0, partRange.end - start + 1); const maxUsable = Math.min(end - start + 1, availableThroughHeader || end - start + 1); if (bytes.byteLength > maxUsable) { bytes = bytes.slice(0, maxUsable); } } else if (bytes.byteLength > end - start + 1) { bytes = bytes.slice(0, end - start + 1); } if (!bytes.byteLength) throw new Error('O servidor repetiu um bloco vazio'); parts.push(bytes); received += bytes.byteLength; chunkCount++; const previousStart = start; start += bytes.byteLength; if (start <= previousStart) throw new Error('O modo por blocos não conseguiu avançar'); if (total) { reportByteProgress(onProgress, received, total); } else { const mb = received / 1024 / 1024; reportByteProgress(onProgress, mb * 1048576); } await yieldUi(8); } return finishDirectParts(parts, filename, first.contentType, onProgress, activeUrl || url); } function friendlyDirectError(error, host = '') { const status = Number(error?.status || 0); const at = host ? ` em ${host}` : ''; if (status === 401 || status === 403) { return `A fonte recusou o acesso direto${at} (HTTP ${status}). Reproduza o vídeo por alguns segundos e toque em atualizar.`; } if (status === 404 || status === 410) { return `O servidor${at} respondeu HTTP ${status}: o arquivo não existe mais nesse endereço ou o endereço temporário expirou. Reproduza o vídeo novamente, toque em atualizar e tente de novo.`; } if (status === 412) { return `O servidor${at} recusou o pedido (HTTP 412 Precondition Failed). Ele exige uma condição que o navegador não enviou, normalmente o Referer/Origin do próprio site ou uma sessão válida. O script já testou os perfis de cabeçalhos; abra o vídeo no site, reproduza e use “Nesta página”.`; } if (status === 408 || status === 425 || status === 429 || status >= 500) { const causes = { 408: 'A fonte demorou demais para responder (HTTP 408).', 429: 'A fonte limitou as requisições (HTTP 429).', 500: 'Erro interno no servidor da fonte (HTTP 500).', 502: 'O CDN/proxy recebeu uma resposta inválida da origem (HTTP 502 Bad Gateway).', 503: 'O servidor da fonte está indisponível ou sobrecarregado (HTTP 503).', 504: 'O CDN/proxy não recebeu resposta da origem a tempo (HTTP 504 Gateway Timeout).' }; return `${causes[status] || `O servidor da fonte respondeu com erro (HTTP ${status}).`} O problema está no servidor, não no script, e as novas tentativas automáticas não resolveram. Tente de novo mais tarde.`; } const raw = String(error?.message || ''); if (/requisição alternativa foi bloqueada|modo de compatibilidade indisponível/i.test(raw)) { return 'A fonte foi localizada, mas o navegador/servidor recusou a leitura binária direta. O script já tentou o contexto da página, o gerenciador e pedidos Range.'; } if (/load failed|failed to fetch|networkerror|network request failed/i.test(raw)) { return 'O navegador bloqueou a leitura direta dessa fonte. Tente novamente após iniciar o vídeo.'; } return raw || 'Não foi possível baixar esta fonte'; } const DIRECT_RETRY = Object.freeze({ attempts: 3, baseDelay: 2500, maxDelay: 30000 }); function directErrorStatus(error) { return Number(error?.status || error?.cause?.status || 0); } function isNetworkFailure(error, depth = 0) { if (!error || depth > 3 || error.code === 'MEDIA_CANCELLED' || directErrorStatus(error)) return false; if (error.code === 'MEDIA_NETWORK') return true; if (/tempo esgotado|timeout|falha de rede|network error|failed to fetch|load failed|sem status/i.test(String(error.message || ''))) return true; return error.cause ? isNetworkFailure(error.cause, depth + 1) : false; } function isRetryableDirectError(error) { const status = directErrorStatus(error); if (status) return status === 408 || status === 425 || status === 429 || status >= 500; return error?.code === 'MEDIA_NETWORK' || /tempo esgotado|timeout|falha de rede|network error|sem status/i.test(String(error?.message || '')); } function waitWithCountdown(ms, signal, onTick) { return new Promise((resolve, reject) => { const end = Date.now() + ms; let timer; const stop = () => { clearTimeout(timer); signal?.removeEventListener('abort', onAbort); }; const onAbort = () => { stop(); reject(mediaError('Download cancelado.', 'MEDIA_CANCELLED')); }; const tick = () => { const left = end - Date.now(); if (left <= 0) { stop(); resolve(); return; } onTick?.(Math.ceil(left / 1000)); timer = setTimeout(tick, Math.min(1000, left)); }; if (signal?.aborted) { onAbort(); return; } signal?.addEventListener('abort', onAbort, { once: true }); tick(); }); } async function downloadDirectSmart(originalUrl, filename, opts = {}) { const progress = opts.onProgress || (() => {}); const signal = opts.signal; const maxAttempts = Math.max(1, Number(opts.retryAttempts || DIRECT_RETRY.attempts)); const guarded = (...args) => { checkMediaAbort({ signal }); return progress(...args); }; let renewed = false; for (let attempt = 1; ; attempt++) { checkMediaAbort({ signal }); const usedUrl = getFreshDirectUrl(originalUrl, opts.allowPageFallback !== false); try { return await downloadDirectSmartOnce(originalUrl, filename, { ...opts, onProgress: guarded }); } catch (error) { if (signal?.aborted || error?.code === 'MEDIA_CANCELLED') throw mediaError('Download cancelado.', 'MEDIA_CANCELLED'); if (isManualSaveRequired(error) || isManualOpenRequired(error)) throw error; const status = directErrorStatus(error); if ([401, 403, 410].includes(status) && !renewed && opts.allowPageFallback !== false) { collectFromDom(); collectFromPerformance(); const renewedUrl = getFreshDirectUrl(originalUrl, true); if (renewedUrl && renewedUrl !== usedUrl) { renewed = true; progress(null, 'A URL expirou. Usando a URL renovada pelo player...', { phase: 'retry' }); continue; } } if (attempt >= maxAttempts || !isRetryableDirectError(error)) throw error; const retryAfter = Number(error.retryAfterMs || error.cause?.retryAfterMs || 0); if (retryAfter > DIRECT_RETRY.maxDelay) { error.message += ` Aguarde cerca de ${Math.ceil(retryAfter / 1000)} s antes de tentar novamente.`; throw error; } const delay = Math.max(retryAfter, Math.min(DIRECT_RETRY.baseDelay * 2 ** (attempt - 1), DIRECT_RETRY.maxDelay) + Math.random() * 600); const reason = status ? `HTTP ${status}` : 'falha de rede'; await waitWithCountdown(delay, signal, seconds => progress(null, `Falha temporária (${reason}). Nova tentativa ${attempt + 1}/${maxAttempts} em ${seconds} s...`, { phase: 'retry' })); } } } async function downloadDirectSmartOnce(originalUrl, filename, opts = {}) { const progress = opts.onProgress || (() => {}); const allowPageFallback = opts.allowPageFallback !== false; collectFromDom(); collectFromPerformance(); let freshUrl = getFreshDirectUrl(originalUrl, allowPageFallback); const startedAsBlob = isBlobUrl(freshUrl); progress(null, freshUrl !== originalUrl ? 'URL renovada. Iniciando...' : 'Usando a sessão da página...'); let nativeRefusal = null; if (OUTPUT.mode === 'video' && (isAppleMobileBrowser() || getGmDownload()) && isHttp(freshUrl)) { const nativeTarget = await resolveNativeDirectFormat( freshUrl, opts.typeHint || '', opts.kindHint || '' ); if (nativeTarget?.hls) return downloadHlsAsMp4(nativeTarget.finalUrl, { ...opts, filenameBase: filename }); if (nativeTarget?.format && nativeTarget.format.ext !== 'ts') { const nativeFilename = filenameForFormat(filename, nativeTarget.format); try { freshUrl = nativeTarget.finalUrl || freshUrl; progress(null, `Iniciando download ${nativeTarget.format.label} pelo navegador...`); await downloadDirectUrlNatively(freshUrl, nativeFilename, progress); progress(100, 'Download concluído pelo navegador'); toast('Download concluído pelo navegador', 3600); return; } catch (error) { console.info('[VDD] Download nativo recusado; seguindo para baixar o arquivo e salvá-lo:', error); if (error.status === 429) throw error; nativeRefusal = { error, url: freshUrl, filename: nativeFilename }; } } } let firstError = null; let managerError = null; let rangeError = null; try { const blob = await fetchBlobInPage(freshUrl, progress); progress(null, 'Validando o arquivo recebido...'); await saveBlob(blob, filename, progress, { url: freshUrl, contentType: blob.type }); progress(100, 'Arquivo salvo'); toast('Arquivo enviado para Downloads'); return; } catch (error) { if (isManualSaveRequired(error)) throw error; firstError = error; console.info('[VDD] Download no contexto do userscript falhou:', error); } if (startedAsBlob) { try { const blob = await fetchBlobInPageWorld(freshUrl, progress); progress(null, 'Validando o Blob da página...'); await saveBlob(blob, filename, progress, { url: freshUrl, contentType: blob.type }); progress(100, 'Arquivo salvo'); toast('Arquivo enviado para Downloads'); return; } catch (error) { if (isManualSaveRequired(error)) throw error; firstError = error; console.info('[VDD] Blob também não pôde ser lido no contexto real da página:', error); } collectFromDom(); collectFromPerformance(); let underlyingDirect = recentUnderlyingDirectUrls().slice(0, 4); let underlyingHls = recentUnderlyingHlsUrls(); if (!underlyingDirect.length && !underlyingHls.length) { const resolved = await resolveObservedNetworkSource(progress); underlyingDirect = resolved.direct.slice(0, 4); underlyingHls = resolved.hls; } if (!underlyingDirect.length && allowPageFallback && underlyingHls.length) { progress(null, 'Blob é um player temporário. Usando a playlist real encontrada...'); await downloadHlsAsMp4(underlyingHls[0], { filenameBase: cleanFilenameBase(filename), onProgress: progress, }); return; } if (!underlyingDirect.length) { const dash = [...APP.dashUrls].reverse()[0] || ''; if (dash) { throw new Error( 'O player usa DASH segmentado. O manifesto foi detectado, mas não expôs um MP4 completo que possa ser salvo com segurança. ' + 'O script não concatena faixas DASH separadas nem contorna DRM.' ); } throw new Error( 'O player usa um Blob/MediaSource temporário e a página ainda não expôs a URL real. ' + 'Inicie o vídeo por alguns segundos, toque em atualizar e tente novamente.' ); } freshUrl = underlyingDirect[0]; progress(null, 'Blob resolvido para uma fonte HTTP observada no player...'); } const directCandidates = startedAsBlob ? [...new Set([freshUrl, ...recentUnderlyingDirectUrls()].filter(isHttp))].slice(0, 4) : [getFreshDirectUrl(originalUrl, allowPageFallback)]; for (let i = 0; i < directCandidates.length; i++) { const candidate = directCandidates[i]; if (!isHttp(candidate)) continue; try { progress(null, directCandidates.length > 1 ? `Tentando fonte real ${i + 1}/${directCandidates.length}...` : 'Tentando modo compatibilidade...'); const blob = await requestBlobWithManager(candidate, progress); progress(null, 'Validando o arquivo recebido...'); await saveBlob(blob, filename, progress, { url: candidate, contentType: blob.type }); progress(100, 'Arquivo salvo'); toast('Arquivo enviado para Downloads'); return; } catch (error) { if (isManualSaveRequired(error)) throw error; managerError = error; console.info('[VDD] Download alternativo falhou:', candidate, error); } try { await downloadDirectByRanges(candidate, filename, progress); return; } catch (error) { if (isManualSaveRequired(error)) throw error; rangeError = error; console.info('[VDD] Download por blocos falhou:', candidate, error); } } const hls = allowPageFallback ? getFreshHlsFallback() : ''; if (hls) { progress(null, 'Fonte direta recusada. Tentando HLS disponível...'); await downloadHlsAsMp4(hls, { filenameBase: cleanFilenameBase(filename), onProgress: progress, }); return; } const bestError = rangeError || managerError || firstError; if (nativeRefusal && isAppleMobileBrowser() && !directErrorStatus(bestError) && !directErrorStatus(nativeRefusal.error)) { throw manualOpenRequired(nativeRefusal.url, nativeRefusal.filename, bestError || nativeRefusal.error); } const finalError = new Error(friendlyDirectError(bestError, sourceHost(freshUrl))); finalError.status = Number(bestError?.status || bestError?.cause?.status || 0); finalError.retryAfterMs = Number(bestError?.retryAfterMs || 0); finalError.cause = bestError; throw finalError; } function yieldUi(ms = 12) { return new Promise(resolve => setTimeout(resolve, ms)); } // --------------------------------------------------------------------------- // HLS codecs and choices // Describes video and audio codecs and builds labels for the quality and audio choices. // --------------------------------------------------------------------------- function looksLikeSelfInitializingFmp4(bytes) { if (findMp4Start(bytes) < 0) return false; try { const boxes = isoBoxes(bytes); return boxes.some(box => box.type === 'moov') && boxes.some(box => box.type === 'moof'); } catch { return false; } } const AVC_PROFILES = Object.freeze({ 44: 'CAVLC 4:4:4', 66: 'Baseline', 77: 'Main', 88: 'Extended', 100: 'High', 110: 'High 10', 122: 'High 4:2:2', 244: 'High 4:4:4' }); function formatFps(value) { const n = Number(value); return Number.isFinite(n) && n > 0 ? String(Number(n.toFixed(2))) : ''; } function describeVideoCodec(token) { const raw = String(token || '').trim(), t = raw.toLowerCase(); let m; if ((m = t.match(/^avc[13]\.([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/))) { const profile = AVC_PROFILES[parseInt(m[1], 16)] || '', level = parseInt(m[3], 16); return { id: 'h264', codec: 'H.264', profile, level: level ? (level / 10).toFixed(1) : '', label: `H.264${profile ? ` ${profile}` : ''}${level ? ` ${(level / 10).toFixed(1)}` : ''}` }; } if (/^(?:avc[13]|mp4v)/.test(t)) return t.startsWith('mp4v') ? { id: 'mpeg4', codec: 'MPEG-4 Visual', label: 'MPEG-4 Visual' } : { id: 'h264', codec: 'H.264', label: 'H.264' }; if ((m = t.match(/^(?:hvc1|hev1)\.(\d+)/))) { const p = { 1: 'Main', 2: 'Main 10' }[m[1]] || ''; return { id: 'h265', codec: 'H.265 (HEVC)', profile: p, label: `H.265 (HEVC)${p ? ` ${p}` : ''}` }; } if (/^(?:dvh1|dvhe)/.test(t)) return { id: 'dolby-vision', codec: 'Dolby Vision', label: 'Dolby Vision' }; if (t.startsWith('av01')) return { id: 'av1', codec: 'AV1', label: 'AV1' }; if (/^vp0?9/.test(t)) return { id: 'vp9', codec: 'VP9', label: 'VP9' }; if (/^vp0?8/.test(t)) return { id: 'vp8', codec: 'VP8', label: 'VP8' }; return null; } function describeAudioCodec(token) { const t = String(token || '').trim().toLowerCase(); if (t === 'mp4a.40.2') return { id: 'aac', codec: 'AAC-LC', label: 'AAC-LC' }; if (t === 'mp4a.40.5') return { id: 'aac', codec: 'HE-AAC', label: 'HE-AAC' }; if (t === 'mp4a.40.29') return { id: 'aac', codec: 'HE-AAC v2', label: 'HE-AAC v2' }; if (/^mp4a\.40\.(?:34)$|^mp4a\.(?:69|6b)$|^mp3$/.test(t)) return { id: 'mp3', codec: 'MP3', label: 'MP3' }; if (t.startsWith('mp4a.40.')) return { id: 'aac', codec: 'AAC', label: 'AAC' }; if (t === 'ac-3' || t === 'mp4a.a5') return { id: 'ac3', codec: 'Dolby Digital (AC-3)', label: 'Dolby Digital (AC-3)' }; if (t === 'ec-3' || t === 'mp4a.a6') return { id: 'eac3', codec: 'Dolby Digital Plus (E-AC-3)', label: 'Dolby Digital Plus (E-AC-3)' }; if (t === 'opus') return { id: 'opus', codec: 'Opus', label: 'Opus' }; if (t === 'flac') return { id: 'flac', codec: 'FLAC', label: 'FLAC' }; return null; } function describeCodecs(codecs) { const result = { video: null, audio: null, others: [] }; for (const token of String(codecs || '').split(',').map(x => x.trim()).filter(Boolean)) { const v = !result.video && describeVideoCodec(token), a = !result.audio && describeAudioCodec(token); if (v) result.video = v; else if (a) result.audio = a; else result.others.push(token); } return result; } function hlsCodecNote(choice) { const c = describeCodecs(choice?.codecs); if (c.audio && (c.audio.id === 'ac3' || c.audio.id === 'eac3')) return `áudio ${c.audio.label}: o conversor local não suporta este áudio (suporta AAC e MP3).`; if (c.video && ['h265', 'av1', 'vp9', 'dolby-vision'].includes(c.video.id)) return `vídeo ${c.video.codec}: só é convertido se os segmentos forem fMP4; em MPEG-TS o conversor local aceita apenas H.264.`; return ''; } function hlsChoiceLabel(choice) { const resolution = choice.height ? `${choice.height}p${choice.width ? ` (${choice.width}×${choice.height})` : ''}` : 'Resolução não informada'; const rate = choice.bandwidth ? ` · ${(choice.bandwidth / 1000000).toFixed(2)} Mb/s` : ''; const c = describeCodecs(choice.codecs); const codecs = [c.video?.label, c.audio?.label, ...c.others].filter(Boolean).join(' + '); return `${resolution}${choice.frameRate ? ` · ${formatFps(choice.frameRate)} fps` : ''}${rate}${codecs ? ` · ${codecs}` : ''}`; } async function inspectHlsChoices(url, context = {}) { let response = await requestTextDetailed(url, null, context); let variants = rankVariants(parseMaster(response.text, response.finalUrl)); let selectedUrl = ''; if (!variants.length) { const parent = await recoverHlsParent(response.finalUrl, context); if (parent) { selectedUrl = response.finalUrl; response = await requestTextDetailed(parent.url, null, context); variants = rankVariants(parseMaster(response.text, response.finalUrl)); } } const media = variants.length ? null : parseMediaPlaylist(response.text, response.finalUrl); return { url, finalUrl: response.finalUrl, variants, media, selectedUrl, time: Date.now() }; } function assertHlsKeys(text) { for (const line of String(text).split(/\r?\n/)) { if (!/^#EXT-X-(?:SESSION-)?KEY:/.test(line.trim())) continue; const a = parseAttrs(line); if (a.METHOD === 'NONE') continue; if (a.METHOD !== 'AES-128' || (a.KEYFORMAT && a.KEYFORMAT !== 'identity')) { throw mediaError('Esta fonte usa DRM ou uma criptografia não compatível. Nenhuma proteção será contornada.', 'MEDIA_UNSUPPORTED'); } } } async function resolveHlsPlaylist(url, context, selection = null, depth = 0, visited = new Set()) { if (depth >= HLS_LIMITS.depth || visited.has(url)) throw mediaError('A playlist contém um ciclo ou níveis demais.', 'MEDIA_UNSUPPORTED'); visited.add(url); const response = await requestTextDetailed(url, null, context); assertHlsKeys(response.text); const variants = rankVariants(parseMaster(response.text, response.finalUrl)); (context.hlsChain ||= []).push({ level: depth, kind: variants.length ? 'master' : 'media', url, finalUrl: response.finalUrl, type: response.contentType || '', bytes: response.bytes?.length || 0, at: Date.now(), segments: variants.length ? 0 : (response.text.match(/^#EXTINF:/gm) || []).length }); if (!variants.length) { const media = parseMediaPlaylist(response.text, response.finalUrl); if (!media.segments.length) throw mediaError('A playlist não contém segmentos completos disponíveis.'); if (media.segments.some(segment => segment.gap)) throw mediaError('A própria playlist declara segmentos ausentes (EXT-X-GAP). Não é possível montar um arquivo completo.'); return { media, choice: selection, root: url }; } let candidates = variants; const selected = Array.isArray(selection) ? selection[0] : selection; if (selected) { const exact = variants.find(v => mediaIdentityKey(v.url, 'HLS') === mediaIdentityKey(selected.url, 'HLS')); candidates = exact ? [exact] : variants.filter(v => v.height === selected.height && v.width === selected.width && v.codecs === selected.codecs && v.bandwidth === selected.bandwidth); if (!candidates.length) throw mediaError('A qualidade escolhida mudou na fonte. Toque em “Atualizar qualidades”.'); } let last; for (const candidate of candidates) { try { const nextSelection = Array.isArray(selection) && selection.length > 1 ? selection.slice(1) : null; const result = await resolveHlsPlaylist(candidate.url, context, nextSelection, depth + 1, new Set(visited)); return { ...result, choice: result.choice || candidate, root: url, path: [candidate, ...(result.path || [])], audioChoices: result.audioChoices?.length ? result.audioChoices : candidate.audioGroups.get(candidate.audioGroup) || [] }; } catch (error) { checkMediaAbort(context); last = error; if (selection || error.code === 'MEDIA_UNSUPPORTED' || error.status === 429) throw error; context.onProgress?.(null, 'Qualidade indisponível; verificando a próxima opção publicada...'); } } throw last || mediaError('Nenhuma qualidade pôde ser aberta.'); } // --------------------------------------------------------------------------- // fMP4 builder // Writes MP4 init segments and fragments from HLS tracks. // --------------------------------------------------------------------------- function isoBoxes(bytes, start = 0, end = bytes.length) { const result = []; while (start < end) { if (end - start < 8) throw mediaError('Um bloco MP4 chegou incompleto.', 'MEDIA_TRUNCATED'); let size = readUint32BE(bytes, start), header = 8; if (size === 1) { if (end - start < 16) throw mediaError('Cabeçalho MP4 incompleto.'); size = readUint64BE(bytes, start + 8); header = 16; } else if (size === 0) size = end - start; if (!Number.isSafeInteger(size) || size < header || start + size > end) throw mediaError('Tamanho de bloco MP4 inválido.', 'MEDIA_TRUNCATED'); result.push({ type: fourCc(bytes, start + 4), start, end: start + size, data: start + header }); start += size; } return result; } function isoChild(bytes, parent, type, required = true) { const child = isoBoxes(bytes, parent.data, parent.end).find(box => box.type === type); if (!child && required) throw mediaError(`A fonte não publicou o bloco MP4 ${type}.`, 'MEDIA_UNSUPPORTED'); return child; } function write32(bytes, offset, value) { if (!Number.isSafeInteger(value) || value < 0 || value > 0xffffffff || offset + 4 > bytes.length) throw mediaError('Valor MP4 fora do intervalo compatível.', 'MEDIA_UNSUPPORTED'); new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).setUint32(offset, value); } function writeTime(bytes, offset, version, value) { value = Math.round(value); if (version === 1) { write32(bytes, offset, Math.floor(value / 0x100000000)); write32(bytes, offset + 4, value % 0x100000000); } else write32(bytes, offset, value); } function joinBytes(parts) { const output = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0)); let offset = 0; for (const part of parts) { output.set(part, offset); offset += part.length; } return output; } function isoBox(type, parts) { const body = joinBytes(parts), result = new Uint8Array(body.length + 8); write32(result, 0, result.length); result.set([...type].map(char => char.charCodeAt(0)), 4); result.set(body, 8); return result; } function inspectHlsInit(bytes) { const top = isoBoxes(bytes), moov = top.find(box => box.type === 'moov'), ftyp = top.find(box => box.type === 'ftyp'); if (!moov || !ftyp) throw mediaError('O segmento de inicialização não contém ftyp/moov.', 'MEDIA_UNSUPPORTED'); const tracks = []; const mvex = isoChild(bytes, moov, 'mvex'); for (const trak of isoBoxes(bytes, moov.data, moov.end).filter(box => box.type === 'trak')) { const tkhd = isoChild(bytes, trak, 'tkhd'), mdia = isoChild(bytes, trak, 'mdia'); const mdhd = isoChild(bytes, mdia, 'mdhd'), hdlr = isoChild(bytes, mdia, 'hdlr'); const stsd = isoChild(bytes, isoChild(bytes, isoChild(bytes, mdia, 'minf'), 'stbl'), 'stsd'); const id = readUint32BE(bytes, tkhd.data + (bytes[tkhd.data] === 1 ? 20 : 12)); const timescale = readUint32BE(bytes, mdhd.data + (bytes[mdhd.data] === 1 ? 20 : 12)); const trex = isoBoxes(bytes, mvex.data, mvex.end).find(box => box.type === 'trex' && readUint32BE(bytes, box.data + 4) === id); const handler = fourCc(bytes, hdlr.data + 8); if (!timescale || !trex || !['vide', 'soun'].includes(handler)) throw mediaError('Esta faixa MP4 ainda não é compatível com a montagem local.', 'MEDIA_UNSUPPORTED'); const config = bytes.slice(stsd.start, stsd.end); if (containsFourCc(config, 'encv') || containsFourCc(config, 'enca') || containsFourCc(config, 'sinf')) throw mediaError('A fonte publicou uma faixa protegida por DRM.', 'MEDIA_UNSUPPORTED'); tracks.push({ id, timescale, handler, config, trak: bytes.slice(trak.start, trak.end), trex: bytes.slice(trex.start, trex.end), defaultDuration: readUint32BE(bytes, trex.data + 12), defaultSize: readUint32BE(bytes, trex.data + 16) }); } if (!tracks.length) throw mediaError('A inicialização MP4 não contém faixas.'); const mvhd = isoChild(bytes, moov, 'mvhd'); return { tracks, ftyp: bytes.slice(ftyp.start, ftyp.end), mvhd: bytes.slice(mvhd.start, mvhd.end) }; } function matchingTrack(a, b) { return a.handler === b.handler && a.timescale === b.timescale && a.config.length === b.config.length && a.config.every((value, i) => value === b.config[i]); } function fragmentRecords(bytes, init, convertedDuration = 0) { const top = isoBoxes(bytes), records = []; for (let i = 0; i < top.length; i++) { const moof = top[i]; if (moof.type !== 'moof') continue; let endIndex = i + 1; while (endIndex < top.length && top[endIndex].type !== 'moof' && !['sidx', 'mfra', 'ftyp', 'moov'].includes(top[endIndex].type)) endIndex++; const mdats = top.slice(i + 1, endIndex).filter(box => box.type === 'mdat'); if (!mdats.length) throw mediaError('Fragmento MP4 sem dados de mídia.', 'MEDIA_TRUNCATED'); const buffer = bytes.slice(moof.start, top[endIndex - 1].end); const localMoof = isoBoxes(buffer)[0]; const tracks = []; for (const traf of isoBoxes(buffer, localMoof.data, localMoof.end).filter(box => box.type === 'traf')) { const tfhd = isoChild(buffer, traf, 'tfhd'), tfdt = isoChild(buffer, traf, 'tfdt'); const flags = readUint32BE(buffer, tfhd.data) & 0xffffff; if (flags & 1) throw mediaError('O fragmento usa offsets absolutos não compatíveis.', 'MEDIA_UNSUPPORTED'); const track = init.tracks.find(item => item.id === readUint32BE(buffer, tfhd.data + 4)); if (!track) throw mediaError('Um fragmento referencia uma faixa ausente.'); let pos = tfhd.data + 8; if (flags & 2) pos += 4; let defaultDuration = track.defaultDuration, defaultSize = track.defaultSize; if (flags & 8) { defaultDuration = readUint32BE(buffer, pos); pos += 4; } if (flags & 16) { defaultSize = readUint32BE(buffer, pos); pos += 4; } if (flags & 32) pos += 4; if (pos > tfhd.end) throw mediaError('Cabeçalho de faixa incompleto.', 'MEDIA_TRUNCATED'); const version = buffer[tfdt.data]; if (version > 1 || tfdt.end - tfdt.data < (version === 1 ? 12 : 8)) throw mediaError('Timestamp MP4 inválido.'); const time = version === 1 ? readUint64BE(buffer, tfdt.data + 4) : readUint32BE(buffer, tfdt.data + 4); let duration = 0, totalSampleCount = 0, totalSampleBytes = 0; for (const trun of isoBoxes(buffer, traf.data, traf.end).filter(box => box.type === 'trun')) { const trunFlags = readUint32BE(buffer, trun.data) & 0xffffff, count = readUint32BE(buffer, trun.data + 4); if (count > 1000000) throw mediaError('Número de amostras MP4 inválido.'); let at = trun.data + 8, dataOffset = null, sampleBytes = 0; if (trunFlags & 1) { dataOffset = new DataView(buffer.buffer).getInt32(at); at += 4; } if (trunFlags & 4) at += 4; for (let sample = 0; sample < count; sample++) { let d = trunFlags & 0x100 ? readUint32BE(buffer, at) : defaultDuration; if (!d && count === 1 && track.handler === 'vide' && convertedDuration > 0 && (trunFlags & 0x100)) { d = Math.round(convertedDuration * track.timescale); write32(buffer, at, d); } if (trunFlags & 0x100) at += 4; const size = trunFlags & 0x200 ? readUint32BE(buffer, at) : defaultSize; if (trunFlags & 0x200) at += 4; if (trunFlags & 0x400) at += 4; if (trunFlags & 0x800) at += 4; if (at > trun.end || !d || !size) throw mediaError('Amostras MP4 incompletas.', 'MEDIA_TRUNCATED'); duration += d; sampleBytes += size; } totalSampleCount += count; totalSampleBytes += sampleBytes; if (dataOffset === null || !mdats.some(mdat => dataOffset >= mdat.data - moof.start && dataOffset + sampleBytes <= mdat.end - moof.start)) { throw mediaError('Os offsets MP4 não apontam para amostras completas.', 'MEDIA_UNSUPPORTED'); } } tracks.push({ track, tfhd, tfdt, time, duration, version, sampleCount: totalSampleCount, sampleBytes: totalSampleBytes }); } if (!tracks.length) throw mediaError('Fragmento MP4 sem faixas.'); records.push({ bytes: buffer, tracks }); } if (!records.length) throw mediaError('O segmento não contém fragmentos MP4 completos.'); return records; } function buildHlsInit(init, tracks) { const mvhd = init.mvhd.slice(), mvhdBox = isoBoxes(mvhd)[0]; const version = mvhd[mvhdBox.data], scalePos = mvhdBox.data + (version === 1 ? 20 : 12); const scale = readUint32BE(mvhd, scalePos); const duration = Math.max(...tracks.map(track => track.end / track.timescale)); writeTime(mvhd, scalePos + 4, version, duration * scale); write32(mvhd, mvhd.length - 4, tracks.length + 1); const traks = [], trexs = []; for (const track of tracks) { const rawTrak = track.trak.slice(), root = isoBoxes(rawTrak)[0]; const trak = isoBox('trak', isoBoxes(rawTrak, root.data, root.end).filter(box => box.type !== 'edts').map(box => rawTrak.slice(box.start, box.end))); const tr = isoBoxes(trak)[0], tkhd = isoChild(trak, tr, 'tkhd'); const mdhd = isoChild(trak, isoChild(trak, tr, 'mdia'), 'mdhd'); const tkVersion = trak[tkhd.data], mdVersion = trak[mdhd.data]; write32(trak, tkhd.data + (tkVersion === 1 ? 20 : 12), track.outputId); writeTime(trak, tkhd.data + (tkVersion === 1 ? 28 : 20), tkVersion, track.end / track.timescale * scale); writeTime(trak, mdhd.data + (mdVersion === 1 ? 24 : 16), mdVersion, track.end); const trex = track.trex.slice(); write32(trex, isoBoxes(trex)[0].data + 4, track.outputId); traks.push(trak); trexs.push(trex); } return joinBytes([init.ftyp, isoBox('moov', [mvhd, ...traks, isoBox('mvex', trexs)])]); } // --------------------------------------------------------------------------- // MP3 in HLS // Extracts MP3 frames from transport streams and wraps them in fMP4. // --------------------------------------------------------------------------- const MP3_BR_V1_L3 = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320]; const MP3_BR_V1_L2 = [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384]; const MP3_BR_V2 = [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160]; const MP3_RATES = [[11025, 12000, 8000], null, [22050, 24000, 16000], [44100, 48000, 32000]]; const mpU32 = n => { const a = new Uint8Array(4); new DataView(a.buffer).setUint32(0, n >>> 0); return a; }; const mpU16 = n => Uint8Array.of((n >> 8) & 255, n & 255); const mpZero = n => new Uint8Array(n); const mpAscii = text => Uint8Array.from([...text].map(char => char.charCodeAt(0))); const mpFull = (type, version, flags, parts) => isoBox(type, [Uint8Array.of(version, (flags >> 16) & 255, (flags >> 8) & 255, flags & 255), ...parts]); const mpMatrix = () => joinBytes([mpU32(0x10000), mpU32(0), mpU32(0), mpU32(0), mpU32(0x10000), mpU32(0), mpU32(0), mpU32(0), mpU32(0x40000000)]); function mp3FrameInfo(b, i) { if (i + 4 > b.length || b[i] !== 0xff || (b[i + 1] & 0xe0) !== 0xe0) return null; const ver = (b[i + 1] >> 3) & 3, layer = (b[i + 1] >> 1) & 3, br = (b[i + 2] >> 4) & 15, sri = (b[i + 2] >> 2) & 3, pad = (b[i + 2] >> 1) & 1; if (ver === 1 || (layer !== 1 && layer !== 2) || !br || br === 15 || sri === 3) return null; const v1 = ver === 3, kbps = (v1 ? (layer === 1 ? MP3_BR_V1_L3 : MP3_BR_V1_L2) : MP3_BR_V2)[br]; const sampleRate = MP3_RATES[ver][sri], samples = layer === 1 && !v1 ? 576 : 1152; const length = Math.floor((samples / 8) * kbps * 1000 / sampleRate) + pad; if (length < 24) return null; return { length, sampleRate, samples, kbps, channels: ((b[i + 3] >> 6) & 3) === 3 ? 1 : 2 }; } function extractTsMp3(b) { try { if (!b || b.length < 400) return null; let start = -1; for (let i = 0; i < 188 && i + 376 < b.length; i++) if (b[i] === 0x47 && b[i + 188] === 0x47 && b[i + 376] === 0x47) { start = i; break; } if (start < 0) return null; let pmtPid = -1, audioPid = -1, pmtDone = false, current = null; const pes = []; for (let p = start; p + 188 <= b.length; p += 188) { if (b[p] !== 0x47) break; const pusi = (b[p + 1] & 0x40) !== 0, pid = ((b[p + 1] & 0x1f) << 8) | b[p + 2], afc = (b[p + 3] >> 4) & 3; if (!(afc & 1)) continue; let o = p + 4; if (afc & 2) o += 1 + b[o]; if (o >= p + 188) continue; if (pid === 0 && pmtPid < 0 && pusi) { o += 1 + b[o]; const len = ((b[o + 1] & 0x0f) << 8) | b[o + 2], end = Math.min(o + 3 + len - 4, p + 188); for (let q = o + 8; q + 4 <= end; q += 4) if (((b[q] << 8) | b[q + 1]) !== 0) { pmtPid = ((b[q + 2] & 0x1f) << 8) | b[q + 3]; break; } } else if (pid === pmtPid && pusi && !pmtDone) { o += 1 + b[o]; if (b[o] !== 2) continue; const len = ((b[o + 1] & 0x0f) << 8) | b[o + 2], end = Math.min(o + 3 + len - 4, p + 188); const info = ((b[o + 10] & 0x0f) << 8) | b[o + 11]; for (let q = o + 12 + info; q + 5 <= end;) { if ((b[q] === 0x03 || b[q] === 0x04) && audioPid < 0) audioPid = ((b[q + 1] & 0x1f) << 8) | b[q + 2]; q += 5 + (((b[q + 3] & 0x0f) << 8) | b[q + 4]); } pmtDone = true; } else if (audioPid >= 0 && pid === audioPid) { const payload = b.subarray(o, p + 188); if (pusi) { current = null; if (payload.length < 9 || payload[0] !== 0 || payload[1] !== 0 || payload[2] !== 1) continue; let pts = NaN; if ((payload[7] & 0x80) && payload.length >= 14) { pts = (payload[9] & 0x0e) * 536870912 + payload[10] * 4194304 + (payload[11] & 0xfe) * 16384 + payload[12] * 128 + (payload[13] >> 1); } current = { pts, chunks: [payload.subarray(9 + payload[8])] }; pes.push(current); } else if (current) current.chunks.push(payload); } } if (!pes.length) return null; const data = joinBytes(pes.flatMap(item => item.chunks)); const frames = []; let first = null, i = 0; while (i + 4 <= data.length) { const f = mp3FrameInfo(data, i); if (f && i + f.length > data.length) break; if (f) { const next = i + f.length; const nextOk = next + 4 > data.length || Boolean(mp3FrameInfo(data, next)); if (nextOk && (!first || (f.sampleRate === first.sampleRate && f.channels === first.channels && f.samples === first.samples))) { first ||= f; frames.push({ off: i, len: f.length }); i = next; continue; } } i++; } if (!frames.length) return null; const pts = (pes.find(item => Number.isFinite(item.pts)) || {}).pts; return { data, frames, pts: pts === undefined ? NaN : pts, sampleRate: first.sampleRate, channels: first.channels, samples: first.samples }; } catch { return null; } } function buildMp3Init(mp3, baseInit) { const sr = mp3.sampleRate; let ftyp, mvhd, traks = [], trexs = [], maxId = 0; if (baseInit?.length) { const top = isoBoxes(baseInit), moov = top.find(box => box.type === 'moov'), ftypBox = top.find(box => box.type === 'ftyp'); const cut = box => baseInit.slice(box.start, box.end); ftyp = cut(ftypBox); mvhd = cut(isoChild(baseInit, moov, 'mvhd')); traks = isoBoxes(baseInit, moov.data, moov.end).filter(box => box.type === 'trak').map(cut); const mvex = isoChild(baseInit, moov, 'mvex'); trexs = isoBoxes(baseInit, mvex.data, mvex.end).filter(box => box.type === 'trex').map(cut); maxId = Math.max(0, ...inspectHlsInit(baseInit).tracks.map(track => track.id)); } else { ftyp = isoBox('ftyp', [mpAscii('isom'), mpU32(512), mpAscii('isom'), mpAscii('iso6'), mpAscii('mp41')]); mvhd = mpFull('mvhd', 0, 0, [mpU32(0), mpU32(0), mpU32(1000), mpU32(0), mpU32(0x10000), mpU16(0x0100), mpZero(10), mpMatrix(), mpZero(24), mpU32(2)]); } const id = maxId + 1; const esds = mpFull('esds', 0, 0, [Uint8Array.of(0x03, 21, 0, 0, 0, 0x04, 15, 0x6b, 0x15, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x06, 1, 0x02)]); const mp4a = isoBox('mp4a', [mpZero(6), mpU16(1), mpZero(8), mpU16(mp3.channels), mpU16(16), mpU16(0), mpU16(0), mpU32(sr * 65536), esds]); const stbl = isoBox('stbl', [mpFull('stsd', 0, 0, [mpU32(1), mp4a]), mpFull('stts', 0, 0, [mpU32(0)]), mpFull('stsc', 0, 0, [mpU32(0)]), mpFull('stsz', 0, 0, [mpU32(0), mpU32(0)]), mpFull('stco', 0, 0, [mpU32(0)])]); const minf = isoBox('minf', [mpFull('smhd', 0, 0, [mpU16(0), mpU16(0)]), isoBox('dinf', [mpFull('dref', 0, 0, [mpU32(1), mpFull('url ', 0, 1, [])])]), stbl]); const tkhd = mpFull('tkhd', 0, 7, [mpU32(0), mpU32(0), mpU32(id), mpU32(0), mpU32(0), mpZero(8), mpU16(0), mpU16(0), mpU16(0x0100), mpU16(0), mpMatrix(), mpU32(0), mpU32(0)]); const mdhd = mpFull('mdhd', 0, 0, [mpU32(0), mpU32(0), mpU32(sr), mpU32(0), mpU16(0x55c4), mpU16(0)]); const hdlr = mpFull('hdlr', 0, 0, [mpU32(0), mpAscii('soun'), mpZero(12), mpAscii('SoundHandler\0')]); const trak = isoBox('trak', [tkhd, isoBox('mdia', [mdhd, hdlr, minf])]); const trex = mpFull('trex', 0, 0, [mpU32(id), mpU32(1), mpU32(mp3.samples), mpU32(0), mpU32(0)]); return { id, init: joinBytes([ftyp, isoBox('moov', [mvhd, ...traks, trak, isoBox('mvex', [...trexs, trex])])]) }; } function buildMp3Fragment(mp3, trackId, baseTime) { const n = mp3.frames.length, table = new Uint8Array(n * 8), view = new DataView(table.buffer); let total = 0; mp3.frames.forEach((frame, i) => { view.setUint32(i * 8, mp3.samples); view.setUint32(i * 8 + 4, frame.len); total += frame.len; }); const payload = new Uint8Array(total); let at = 0; for (const frame of mp3.frames) { payload.set(mp3.data.subarray(frame.off, frame.off + frame.len), at); at += frame.len; } const moofFor = offset => isoBox('moof', [mpFull('mfhd', 0, 0, [mpU32(1)]), isoBox('traf', [ mpFull('tfhd', 0, 0x020000, [mpU32(trackId)]), mpFull('tfdt', 1, 0, [mpU32(Math.floor(baseTime / 4294967296)), mpU32(baseTime % 4294967296)]), mpFull('trun', 0, 0x301, [mpU32(n), mpU32(offset), table])])]); const moof = moofFor(moofFor(0).length + 8); return joinBytes([moof, isoBox('mdat', [payload])]); } // --------------------------------------------------------------------------- // HLS transmux and segments // mux.js in a Web Worker, AES-128 decryption and segment prefetching. // --------------------------------------------------------------------------- async function createHlsTransmuxer(context) { const workerSource = `let muxer; self.onmessage = e => { const m = e.data; try { if (m.type === 'start') { importScripts('https://unpkg.com/mux.js@6.3.0/dist/mux.js'); } if (m.type === 'start' || m.type === 'reset') { if (muxer) muxer.dispose(); muxer = new muxjs.mp4.Transmuxer({keepOriginalTimestamps:true, remux:true}); } const out = []; const data = s => out.push({init:s.initSegment.slice(), data:s.data.slice()}); if (m.bytes) { muxer.on('data', data); muxer.push(new Uint8Array(m.bytes)); muxer.flush(); muxer.off('data', data); } self.postMessage({out}, out.flatMap(s => [s.init.buffer,s.data.buffer])); } catch (error) { self.postMessage({error:error.message}); } };`; let worker, objectUrl, pending; const call = (type, bytes) => new Promise((resolve, reject) => { checkMediaAbort(context); const timer = setTimeout(() => finish(mediaError('O conversor local demorou demais.', 'MEDIA_CONVERSION')), 20000); const abort = () => finish(mediaError('Download cancelado.', 'MEDIA_CANCELLED')); const finish = (error, value) => { clearTimeout(timer); context.signal?.removeEventListener('abort', abort); pending = null; error ? reject(error) : resolve(value); }; pending = finish; context.signal?.addEventListener('abort', abort, { once: true }); const copy = bytes?.slice().buffer; try { worker.postMessage({ type, bytes: copy }, copy ? [copy] : []); } catch (error) { finish(error); } }); try { if (typeof Worker !== 'function') throw new Error('Worker indisponível'); objectUrl = URL.createObjectURL(new Blob([workerSource], { type: 'text/javascript' })); worker = new Worker(objectUrl); worker.onmessage = event => pending?.(event.data.error ? mediaError(event.data.error, 'MEDIA_CONVERSION') : null, event.data.out); worker.onerror = () => pending?.(mediaError('O conversor local não pôde iniciar.', 'MEDIA_CONVERSION')); await call('start'); return { push: (bytes, reset) => call(reset ? 'reset' : 'push', bytes), dispose() { worker.terminate(); URL.revokeObjectURL(objectUrl); } }; } catch (error) { worker?.terminate(); if (objectUrl) URL.revokeObjectURL(objectUrl); checkMediaAbort(context); } const mux = globalThis.muxjs || window.muxjs; if (!mux?.mp4?.Transmuxer) throw mediaError('O conversor local mux.js não carregou. Recarregue a página e tente novamente.', 'MEDIA_CONVERSION'); let transmuxer; return { async push(bytes, reset) { checkMediaAbort(context); await yieldUi(12); if (!transmuxer || reset) { transmuxer?.dispose(); transmuxer = new mux.mp4.Transmuxer({ keepOriginalTimestamps: true, remux: true }); } const out = [], data = segment => out.push({ init: segment.initSegment.slice(), data: segment.data.slice() }); transmuxer.on('data', data); try { transmuxer.push(bytes); transmuxer.flush(); } finally { transmuxer.off('data', data); } await yieldUi(12); return out; }, dispose() { transmuxer?.dispose(); } }; } function hlsIv(raw, sequence) { const bytes = new Uint8Array(16); if (raw) { if (!/^0x[\da-f]{1,32}$/i.test(raw)) throw mediaError('IV AES-128 inválido.'); const hex = raw.slice(2).padStart(32, '0'); for (let i = 0; i < 16; i++) bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); } else { write32(bytes, 8, Math.floor(sequence / 0x100000000)); write32(bytes, 12, sequence % 0x100000000); } return bytes; } async function readHlsResource(resource, sequence, context, isMap = false) { const bytes = await requestHlsResource(resource, context, isMap); if (!resource.key) return bytes; const key = resource.key; if (key.method !== 'AES-128' || key.keyFormat !== 'identity' || !key.url) throw mediaError('Criptografia HLS não compatível.', 'MEDIA_UNSUPPORTED'); if (isMap && !key.iv) throw mediaError('A inicialização criptografada não publicou IV.'); if (!globalThis.crypto?.subtle) throw mediaError('Este navegador não oferece AES-128 local nesta página.', 'MEDIA_UNSUPPORTED'); context.keys ||= new Map(); let imported = context.keys.get(key.url); if (!imported) { const response = await mediaRequest(key.url, { context: { ...context, publicOnly: true }, maxBytes: 16 }); if (response.bytes.length !== 16) throw mediaError('A chave pública AES-128 não tem 16 bytes.'); imported = await crypto.subtle.importKey('raw', response.bytes, 'AES-CBC', false, ['decrypt']); if (context.keys.size >= 64) context.keys.delete(context.keys.keys().next().value); context.keys.set(key.url, imported); } try { return new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-CBC', iv: hlsIv(key.iv, sequence) }, imported, bytes)); } catch { throw mediaError('Não foi possível validar o segmento AES-128 com a chave publicada.'); } } const HLS_PREFETCH = Object.freeze({ ahead: 4, aheadMobile: 2, budget: 48 * 1024 * 1024, budgetMobile: 24 * 1024 * 1024 }); function hlsPrefetchWindow(rendition) { const mobile = isAppleMobileBrowser(); const cap = mobile ? HLS_PREFETCH.aheadMobile : HLS_PREFETCH.ahead, budget = mobile ? HLS_PREFETCH.budgetMobile : HLS_PREFETCH.budget; const size = Number(rendition.lastSegmentBytes || 0); return size > 0 ? Math.max(1, Math.min(cap, Math.floor(budget / size))) : 1; } function scheduleHlsPrefetch(rendition, index, context) { if (rendition.prefetchOff || context.signal?.aborted) return; const pending = rendition.prefetch ||= new Map(); const last = Math.min(rendition.media.segments.length - 1, index + hlsPrefetchWindow(rendition)); for (let i = index + 1; i <= last; i++) { const next = rendition.media.segments[i]; if (pending.has(i) || !next || next.gap) continue; pending.set(i, readHlsResource(next, next.sequence, context).then(bytes => ({ bytes }), error => ({ error }))); } } async function readSegmentBytesPrefetched(rendition, index, segment, context) { let bytes = null; const ahead = rendition.prefetch?.get(index); if (ahead) { rendition.prefetch.delete(index); const result = await ahead; if (result.bytes) bytes = result.bytes; else { rendition.prefetchOff = true; rendition.prefetch.clear(); } } if (!bytes) bytes = await readHlsResource(segment, segment.sequence, context); rendition.lastSegmentBytes = Number(bytes.byteLength ?? bytes.length ?? 0); scheduleHlsPrefetch(rendition, index, context); return bytes; } async function readRenditionSegment(rendition, index, context) { let segment = rendition.media.segments[index]; try { return { bytes: await readSegmentBytesPrefetched(rendition, index, segment, context), segment }; } catch (error) { if (![401, 403, 404, 410].includes(error.status)) throw error; context.onProgress?.(null, `Atualizando a URL do segmento ${index + 1}...`); const fresh = await resolveHlsPlaylist(rendition.root, context, rendition.path || rendition.choice); const bySequence = new Map(fresh.media.segments.map(s => [`${s.sequence}:${s.discontinuity}`, s])); const replacement = bySequence.get(`${segment.sequence}:${segment.discontinuity}`); if (replacement && replacement.url === segment.url) { error.message += ' A playlist, lida de novo, publica exatamente o mesmo endereço: renovar não resolve, o servidor recusa este acesso.'; throw error; } if (!replacement || replacement.gap || Math.abs(replacement.duration - segment.duration) >= 0.001) throw mediaError('O segmento expirou e a fonte não o publica mais. Atualize as qualidades e tente novamente.'); for (let i = index; i < rendition.media.segments.length; i++) { const old = rendition.media.segments[i]; const updated = bySequence.get(`${old.sequence}:${old.discontinuity}`); if (updated && !updated.gap && Math.abs(updated.duration - old.duration) < 0.001) rendition.media.segments[i] = { ...updated, start: old.start }; } segment = rendition.media.segments[index]; rendition.prefetch?.clear(); return { bytes: await readSegmentBytesPrefetched(rendition, index, segment, context), segment }; } } async function prepareRenditionChunk(rendition, index, context) { const { bytes, segment } = await readRenditionSegment(rendition, index, context); let initBytes, data = bytes, convertedDuration = 0; if (segment.map) { const mapKey = JSON.stringify(segment.map); if (rendition.mapKey !== mapKey) { try { rendition.mapBytes = await readHlsResource(segment.map, segment.sequence, context, true); } catch (error) { if (![401, 403, 404, 410].includes(error.status)) throw error; const refreshed = await resolveHlsPlaylist(rendition.root, context, rendition.path || rendition.choice); const same = refreshed.media.segments.find(s => s.sequence === segment.sequence && s.discontinuity === segment.discontinuity && Math.abs(s.duration - segment.duration) < 0.001); if (!same?.map || same.gap) throw mediaError('A inicialização expirou e a fonte não publicou uma substituição compatível.'); rendition.mapBytes = await readHlsResource(same.map, segment.sequence, context, true); } rendition.mapKey = mapKey; } initBytes = rendition.mapBytes; } else if (looksLikeSelfInitializingFmp4(bytes)) initBytes = bytes; else { if (!rendition.tsInfo) rendition.tsInfo = describeTsStreams(bytes); rendition.converter ||= await createHlsTransmuxer(context); const discontinuity = rendition.period !== undefined && rendition.period !== segment.discontinuity; const converted = await rendition.converter.push(bytes, discontinuity); const wantMp3 = /áudio MP3/.test(rendition.tsInfo) && !/áudio AAC(?!-)/.test(rendition.tsInfo); const mp3 = wantMp3 ? extractTsMp3(bytes) : null; if (converted.some(part => !part.init?.length || !part.data?.length) || (!converted.length && !mp3)) throw mediaError(`O conversor não gerou todas as faixas do segmento ${index + 1}. O codec pode não ser compatível.`, 'MEDIA_CONVERSION'); initBytes = converted.length ? converted[0].init : null; data = joinBytes(converted.map(part => part.data)); if (mp3) { if (discontinuity) rendition.mp3Next = null; let base = Number.isFinite(mp3.pts) ? Math.round(mp3.pts * mp3.sampleRate / 90000) : (rendition.mp3Next ?? 0); if (rendition.mp3Next != null && Math.abs(base - rendition.mp3Next) <= mp3.sampleRate * 0.03) base = rendition.mp3Next; rendition.mp3Next = base + mp3.frames.length * mp3.samples; const audioInit = buildMp3Init(mp3, initBytes); initBytes = audioInit.init; data = joinBytes([data, buildMp3Fragment(mp3, audioInit.id, base)]); } convertedDuration = segment.duration; } rendition.period = segment.discontinuity; const init = inspectHlsInit(initBytes); reconcileHlsTracks(rendition, init); return { records: fragmentRecords(data, init, convertedDuration), segment }; } // --------------------------------------------------------------------------- // HLS memory // Remembers master playlists and resources, and recovers a parent playlist. // --------------------------------------------------------------------------- function rememberHlsMaster(text, url, referrer = location.href) { if (!/^\s*#EXTM3U/.test(text) || !isHttp(url)) return; try { const variants = parseMaster(text, url); if (!variants.length) { const media = parseMediaPlaylist(text, url); for (const segment of media.segments) { if (!segment.range) rememberHlsResource(segment.url); if (segment.map && !segment.map.range) rememberHlsResource(segment.map.url); } return; } const key = mediaIdentityKey(url, 'HLS'); if (APP.hlsMasters.size >= 48) APP.hlsMasters.delete(APP.hlsMasters.keys().next().value); APP.hlsMasters.set(key, { url, variants, referrer, time: Date.now() }); } catch {} } function rememberHlsResource(url) { const key = mediaIdentityKey(url, 'DIRECT'); if (APP.hlsResources.size >= 6000) APP.hlsResources.delete(APP.hlsResources.values().next().value); APP.hlsResources.add(key); const previous = APP.mediaIdentityUrls.get(key); if (previous) { APP.directUrls.delete(previous); APP.mediaInfo.delete(previous); APP.mediaIdentityUrls.delete(key); } } function knownHlsParent(url) { const identity = mediaIdentityKey(url, 'HLS'); for (const master of [...APP.hlsMasters.values()].reverse()) { const variant = master.variants.find(item => mediaIdentityKey(item.url, 'HLS') === identity); if (variant && Date.now() - master.time < 30 * 60 * 1000) return { ...master, variant }; } return null; } async function recoverHlsParent(url, context) { const known = knownHlsParent(url); if (known) return known; if (context.publicOnly) return null; for (const candidate of [...APP.hlsUrls].reverse().filter(value => value !== url).slice(0, 6)) { checkMediaAbort(context); try { await requestTextDetailed(candidate, null, context); } catch {} const found = knownHlsParent(url); if (found) return found; } return null; } // --------------------------------------------------------------------------- // HLS audio and tracks // Audio choices, stream description and track reconciliation. // --------------------------------------------------------------------------- function audioChoiceKey(audio) { return [audio['GROUP-ID'], audio.NAME, audio.LANGUAGE || '', audio.url ? mediaIdentityKey(audio.url) : 'inband'].join('|'); } function audioChoiceLabel(audio) { return `${audio.NAME || audio.LANGUAGE || 'Áudio'}${audio.LANGUAGE && audio.NAME !== audio.LANGUAGE ? ` · ${audio.LANGUAGE}` : ''}${audio.CHANNELS ? ` · ${audio.CHANNELS} canais` : ''}${audio.DEFAULT === 'YES' ? ' · padrão' : ''}`; } function orderedAudioChoices(choices) { return [...choices].sort((a, b) => Number(b.DEFAULT === 'YES') - Number(a.DEFAULT === 'YES') || Number(b.AUTOSELECT === 'YES') - Number(a.AUTOSELECT === 'YES')); } function describeTsStreams(b) { try { if (!b || b.length < 400) return ''; if (b[0] === 0x49 && b[1] === 0x44 && b[2] === 0x33) return 'segmento de áudio empacotado (ID3/ADTS), não é MPEG-TS'; let start = -1; for (let i = 0; i < 188 && i + 376 < b.length; i++) if (b[i] === 0x47 && b[i + 188] === 0x47 && b[i + 376] === 0x47) { start = i; break; } if (start < 0) return 'formato do segmento não reconhecido como MPEG-TS'; let pmtPid = -1; const types = []; for (let p = start; p + 188 <= b.length && p < start + 188 * 600; p += 188) { if (b[p] !== 0x47) break; const pusi = (b[p + 1] & 0x40) !== 0, pid = ((b[p + 1] & 0x1f) << 8) | b[p + 2], afc = (b[p + 3] >> 4) & 3; if (!pusi || !(afc & 1) || (pid !== 0 && pid !== pmtPid)) continue; let o = p + 4; if (afc & 2) o += 1 + b[o]; o += 1 + b[o]; const tid = b[o], len = ((b[o + 1] & 0x0f) << 8) | b[o + 2], end = Math.min(o + 3 + len - 4, p + 188); if (pid === 0 && tid === 0 && pmtPid < 0) { for (let q = o + 8; q + 4 <= end; q += 4) if (((b[q] << 8) | b[q + 1]) !== 0) { pmtPid = ((b[q + 2] & 0x1f) << 8) | b[q + 3]; break; } } else if (pid === pmtPid && tid === 2) { const info = ((b[o + 10] & 0x0f) << 8) | b[o + 11]; for (let q = o + 12 + info; q + 5 <= end;) { types.push(b[q]); q += 5 + (((b[q + 3] & 0x0f) << 8) | b[q + 4]); } break; } } if (!types.length) return 'tabela de programas (PMT) não encontrada no segmento'; const names = { 0x1b: 'vídeo H.264', 0x24: 'vídeo HEVC', 0x02: 'vídeo MPEG-2', 0x0f: 'áudio AAC', 0x11: 'áudio AAC-LATM', 0x03: 'áudio MP3', 0x04: 'áudio MP3', 0x81: 'áudio AC-3', 0x87: 'áudio E-AC-3', 0x06: 'dados privados (pode ser AC-3/Opus)', 0x15: 'metadados' }; return types.map(t => `${names[t] || `tipo 0x${t.toString(16)}`}`).join(' + '); } catch { return ''; } } function audioMissingDetail(resolved) { const parts = []; if (resolved?.tsInfo) parts.push(`o stream contém: ${resolved.tsInfo}`); const declared = resolved?.audioChoices?.length || 0; if (declared) parts.push(`a playlist declara ${declared} faixa(s) de áudio separada(s)`); const info = resolved?.tsInfo || ''; let hint = ''; if (/AC-3|E-AC-3|LATM|privados/.test(info) && !/áudio (?:AAC(?!-)|MP3)/.test(info)) hint = ' O áudio está num codec que o conversor local não sabe extrair; só AAC e MP3 são suportados.'; else if (/áudio MP3/.test(info)) hint = ' O stream tem áudio MP3, então a falha parece de conversão; use “Copiar relatório” na aba Diagnóstico.'; else if (/áudio AAC(?!-)/.test(info)) hint = ' O stream tem áudio AAC, então a falha parece de conversão; use “Copiar relatório” na aba Diagnóstico.'; else if (info && !/áudio/.test(info)) hint = ' O próprio stream não traz áudio: ele deve vir de outra playlist (outra qualidade ou a playlist principal).'; return parts.length ? ` Detalhe técnico: ${parts.join('; ')}.${hint}` : ''; } function hasActualAudio(chunk) { return Boolean(chunk?.records.some(record => record.tracks.some(t => t.track.handler === 'soun' && t.sampleCount > 0 && t.sampleBytes > 0))); } function filterFragmentTracks(bytes, allowedIds) { const boxes = isoBoxes(bytes), moof = boxes.find(box => box.type === 'moof'); if (!moof) throw mediaError('Fragmento de mídia sem moof.'); const children = isoBoxes(bytes, moof.data, moof.end); const kept = children.filter(box => box.type !== 'traf' || allowedIds.has(readUint32BE(bytes, isoChild(bytes, box, 'tfhd').data + 4))); if (!kept.some(box => box.type === 'traf')) return null; if (kept.length === children.length) return bytes; const newMoof = isoBox('moof', kept.map(box => bytes.slice(box.start, box.end))); const delta = newMoof.length - (moof.end - moof.start), root = isoBoxes(newMoof)[0]; for (const traf of isoBoxes(newMoof, root.data, root.end).filter(box => box.type === 'traf')) { for (const trun of isoBoxes(newMoof, traf.data, traf.end).filter(box => box.type === 'trun')) { if (!(readUint32BE(newMoof, trun.data) & 1)) throw mediaError('O fragmento não permite trocar a faixa de áudio com segurança.', 'MEDIA_UNSUPPORTED'); const view = new DataView(newMoof.buffer); view.setInt32(trun.data + 8, view.getInt32(trun.data + 8) + delta); } } return joinBytes([newMoof, bytes.slice(moof.end)]); } function keepVideoOnly(chunk, rendition) { const allowed = new Set(rendition.currentInit.tracks.filter(track => track.handler === 'vide').map(track => track.id)); const records = []; for (const record of chunk.records) { const bytes = filterFragmentTracks(record.bytes, allowed); if (bytes) records.push(...fragmentRecords(bytes, rendition.currentInit)); } return { ...chunk, records }; } function reconcileHlsTracks(rendition, incoming) { if (!rendition.init) rendition.init = { ...incoming, tracks: [...incoming.tracks] }; for (const track of incoming.tracks) { let existing = rendition.init.tracks.find(previous => previous.id === track.id && matchingTrack(previous, track)); existing ||= rendition.init.tracks.find(previous => matchingTrack(previous, track)); if (!existing) { if (rendition.init.tracks.some(previous => previous.id === track.id || previous.handler === track.handler)) { throw mediaError('A fonte mudou o codec de uma faixa durante o vídeo.', 'MEDIA_UNSUPPORTED'); } rendition.init.tracks.push(track); existing = track; } track.output = existing; } rendition.currentInit = incoming; } async function chooseHlsAudio(resolved, context) { const choices = orderedAudioChoices(resolved.audioChoices || []); const selected = context.audioSelection; const selectedChoice = selected ? choices.find(audio => audioChoiceKey(audio) === audioChoiceKey(selected) || audio.NAME === selected.NAME && audio.LANGUAGE === selected.LANGUAGE && audio['GROUP-ID'] === selected['GROUP-ID']) : null; if (selected && !selectedChoice) throw mediaError('A faixa de áudio escolhida não está disponível nessa qualidade. Atualize as opções.', 'MEDIA_AUDIO_MISSING'); const queue = selectedChoice ? [selectedChoice] : choices; const present = hasActualAudio(resolved.pending); if (!queue.length) return null; let failure; for (const audio of queue) { if (!audio.url) { if (present) return null; if (selectedChoice) throw mediaError('A faixa escolhida indica áudio no vídeo, mas não forneceu amostras. Escolha outra faixa.', 'MEDIA_AUDIO_MISSING'); continue; } let companion; try { context.onProgress?.(null, `Preparando áudio: ${audio.NAME || audio.LANGUAGE || 'padrão'}...`, { phase: 'prepare' }); companion = await resolveHlsPlaylist(audio.url, context); companion.pending = await prepareRenditionChunk(companion, 0, context); if (!hasActualAudio(companion.pending) || companion.init.tracks.some(track => track.handler !== 'soun')) throw mediaError('A faixa publicada não contém amostras de áudio compatíveis.', 'MEDIA_AUDIO_MISSING'); const a = companion.media, v = resolved.media; if (Math.abs(a.duration - v.duration) > Math.max(0.75, Math.min(a.targetDuration, v.targetDuration, 2))) throw mediaError('Áudio e vídeo publicados têm durações incompatíveis. Atualize as qualidades.', 'MEDIA_AUDIO_SYNC'); const at = a.segments[0].programTime, vt = v.segments[0].programTime; if (Number.isFinite(at) && Number.isFinite(vt) && Math.abs(at - vt) > 750) throw mediaError('As janelas de áudio e vídeo não começam juntas. Atualize a playlist.', 'MEDIA_AUDIO_SYNC'); resolved.externalAudio = true; companion.audioName = audio.NAME || audio.LANGUAGE || ''; return companion; } catch (error) { companion?.converter?.dispose(); checkMediaAbort(context); failure = error; if (selectedChoice || error.status === 429 || error.code === 'MEDIA_UNSUPPORTED') throw error; } } if (present && !selectedChoice) return null; throw failure || mediaError('A playlist declarou áudio, mas nenhuma faixa forneceu amostras. Tente outra qualidade.', 'MEDIA_AUDIO_MISSING'); } // --------------------------------------------------------------------------- // HLS to MP4 // Assembles the final MP4 and runs the HLS download. // --------------------------------------------------------------------------- async function assembleHlsMp4(resolved, context) { const renditions = [resolved], tracks = [], parts = []; let size = 0, sequence = 1; try { resolved.pending = await prepareRenditionChunk(resolved, 0, context); if (!resolved.init.tracks.some(track => track.handler === 'vide')) throw mediaError('A opção selecionada é apenas áudio. Escolha a playlist principal.', 'MEDIA_VIDEO_MISSING'); const companion = await chooseHlsAudio(resolved, context); if (companion) renditions.push(companion); for (const r of renditions) r.index = 0; const rawOrigin = Math.min(...renditions.flatMap(r => r.pending.records.flatMap(record => record.tracks.map(t => t.time / t.track.timescale)))); const useDates = renditions.length > 1 && renditions.every(r => Number.isFinite(r.media.segments[0].programTime)); const dateOrigin = useDates ? Math.min(...renditions.map(r => r.media.segments[0].programTime)) : 0; const total = renditions.reduce((sum, r) => sum + r.media.segments.length, 0); let completed = 0; for (const r of renditions) r.timelineOrigin = Math.min(...r.pending.records.flatMap(record => record.tracks.map(t => t.time / t.track.timescale))); while (true) { checkMediaAbort(context); const r = renditions.filter(r => r.index < r.media.segments.length).sort((a, b) => a.media.segments[a.index].start - b.media.segments[b.index].start)[0]; if (!r) break; context.transfer = { completed, total, bytes: size, phase: 'transfer', rendition: r === resolved ? 'Vídeo' : 'Áudio' }; let chunk = r.pending || await prepareRenditionChunk(r, r.index, context); r.pending = null; if (r === resolved && r.externalAudio) chunk = keepVideoOnly(chunk, r); for (const record of chunk.records) { const moof = isoBoxes(record.bytes)[0], mfhd = isoChild(record.bytes, moof, 'mfhd'); write32(record.bytes, mfhd.data + 4, sequence++); for (const t of record.tracks) { const output = t.track.output; if (!output.outputId) { output.outputId = tracks.length + 1; output.end = 0; output.first = Infinity; output.sampleBytes = 0; output.samples = 0; output.maxGap = 0; tracks.push(output); } if (output.period === undefined) { output.offset = useDates ? ((r.media.segments[0].programTime - dateOrigin) / 1000 - r.timelineOrigin) * output.timescale : -rawOrigin * output.timescale; output.period = chunk.segment.discontinuity; } else if (output.period !== chunk.segment.discontinuity) { output.offset = Math.max(output.end, chunk.segment.start * output.timescale) - t.time; output.period = chunk.segment.discontinuity; } const timestamp = Math.round(t.time + output.offset); if (timestamp < 0 || timestamp + 2 < output.end || !Number.isSafeInteger(timestamp + t.duration)) throw mediaError('Os timestamps da fonte são inconsistentes; o arquivo não foi salvo.', 'MEDIA_AUDIO_SYNC'); if (output.samples > 0 && t.sampleCount > 0) output.maxGap = Math.max(output.maxGap, (timestamp - output.end) / output.timescale); write32(record.bytes, t.tfhd.data + 4, output.outputId); writeTime(record.bytes, t.tfdt.data + 4, t.version, timestamp); output.end = timestamp + t.duration; if (t.sampleCount > 0) output.first = Math.min(output.first, timestamp); output.sampleBytes += t.sampleBytes; output.samples += t.sampleCount; } size += record.bytes.length; if (size > HLS_LIMITS.output) throw mediaError('O vídeo excedeu 384 MB de montagem local. Escolha uma qualidade menor ou arquivo direto.', 'MEDIA_LIMIT'); parts.push(new Blob([record.bytes])); } r.index++; completed++; context.transfer = { phase: 'transfer', completed, total, bytes: size }; context.onProgress?.(100 * completed / total, `${completed}/${total} segmentos · ${(size / 1048576).toFixed(1)} MB`, { ...context.transfer, measured: true }); await yieldUi(4); } const audible = tracks.filter(t => t.handler === 'soun' && t.samples > 0 && t.sampleBytes > 0); if (!audible.length && !context.allowSilent) throw mediaError(`Nenhuma amostra de áudio foi encontrada.${audioMissingDetail(resolved)} Use a playlist principal ou outra qualidade. Se a fonte for muda, marque “Permitir vídeo sem áudio”.`, 'MEDIA_AUDIO_MISSING'); const video = tracks.find(t => t.handler === 'vide' && t.samples > 0 && t.sampleBytes > 0); if (!video) throw mediaError('A playlist não entregou amostras de vídeo.', 'MEDIA_VIDEO_MISSING'); if (audible.length) { const good = audible.some(audio => audio.maxGap <= 0.75 && Math.abs(audio.first / audio.timescale - video.first / video.timescale) <= 0.75 && Math.abs(audio.end / audio.timescale - video.end / video.timescale) <= 0.75); if (!good) throw mediaError('A faixa de áudio não cobre o vídeo completo ou está fora de sincronia. Tente outra faixa ou qualidade.', 'MEDIA_AUDIO_SYNC'); } context.transfer = null; { const span = (video.end - video.first) / video.timescale; context.outputStats = span > 0 ? { fps: video.samples / span, duration: span } : null; } context.onProgress?.(null, audible.length ? 'Conferindo vídeo e áudio...' : 'Conferindo vídeo sem áudio...', { phase: 'validate' }); return new Blob([buildHlsInit(resolved.init, tracks), ...parts], { type: 'video/mp4' }); } finally { context.transfer = null; for (const r of renditions) r.converter?.dispose(); } } async function downloadHlsAsMp4(masterUrl, opts = {}) { const controller = new AbortController(); const context = { referrer: opts.pageUrl || currentRequestReferrer(), publicOnly: opts.allowPageFallback === false, sessionLink: opts.sessionLink === true, signal: controller.signal, audioSelection: opts.audioSelection || null, allowSilent: opts.allowSilent === true, onProgress: opts.onProgress || (() => {}) }; APP.hlsJob = context; APP.hlsController = controller; try { context.onProgress(null, 'Lendo as URLs atuais da playlist...'); let source = opts.allowPageFallback === false ? masterUrl : getFreshHlsUrl(masterUrl, false); let selection = opts.selection || null; const parent = await recoverHlsParent(source, context); if (parent) { source = parent.url; selection ||= parent.variant; } const resolved = await resolveHlsPlaylist(source, context, selection); await preflightHlsSegments(resolved, context); try { opts.onTrace?.(hlsTraceOf(context, resolved.media.segments[0], null)); } catch {} const suffix = resolved.choice?.height ? `-${resolved.choice.height}p` : ''; const live = !resolved.media.endList; context.onProgress(null, `${resolved.media.segments.length} segmentos${live ? ' · trecho ao vivo disponível' : ''}${suffix ? ` · ${resolved.choice.height}p` : ''}`); const blob = await assembleHlsMp4(resolved, context); checkMediaAbort(context); const filename = `${cleanDisplayText(opts.filenameBase) || pageTitle()}${suffix}${live ? '-trecho' : ''}`; await inspectMp4Blob(blob); const progress = (percent, label, detail) => { if (detail?.info) mergeOutputStats(detail.info, context.outputStats); context.onProgress?.(percent, label, detail); }; await saveBlob(blob, filename, progress, { contentType: 'video/mp4', url: `${filename}.mp4` }); context.onProgress(100, 'Arquivo enviado para o salvamento.'); } finally { context.keys?.clear(); APP.hlsJob = null; APP.hlsController = null; } } // --------------------------------------------------------------------------- // Network capture // DASH manifests, text scanning, and hooks for fetch, XHR, performance entries and the DOM. // --------------------------------------------------------------------------- function escapeHtml(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } function networkMetadata() { return { title: pageTitle(), titlePriority: 1, thumbnail: pageThumbnail(), thumbnailPriority: 1, }; } function rememberDashManifest(url) { const full = safeHttpUrl(url || ''); if (!full) return false; const before = APP.dashUrls.size; APP.dashUrls.add(full); return APP.dashUrls.size !== before; } function extractDashDirectCandidates(text, manifestUrl) { const raw = String(text || '').slice(0, 2 * 1024 * 1024); if (!raw || !/ { const full = safeHttpUrl(decodeEmbeddedUrl(value), manifestUrl || location.href); if (!full || seen.has(full)) return; const hint = knownDirectFormatHint(full, type); if (!hint || hint.ext !== 'mp4') return; seen.add(full); out.push({ url: full, type: hint.mime, kind: hint.label }); }; try { const xml = new DOMParser().parseFromString(raw, 'application/xml'); if (!xml.querySelector('parsererror')) { xml.querySelectorAll('Representation, AdaptationSet').forEach(node => { const rep = node.matches?.('Representation') ? node : null; const adapt = rep?.closest?.('AdaptationSet') || (node.matches?.('AdaptationSet') ? node : null); const mime = rep?.getAttribute?.('mimeType') || adapt?.getAttribute?.('mimeType') || ''; const contentType = rep?.getAttribute?.('contentType') || adapt?.getAttribute?.('contentType') || ''; if (contentType && contentType !== 'video' && !/video\/mp4/i.test(mime)) return; if (mime && !/video\/mp4|application\/mp4/i.test(mime)) return; const segmented = Boolean(rep?.querySelector?.(':scope > SegmentTemplate, :scope > SegmentList')) || Boolean(adapt?.querySelector?.(':scope > SegmentTemplate, :scope > SegmentList')); if (segmented) return; const base = rep?.querySelector?.(':scope > BaseURL') || adapt?.querySelector?.(':scope > BaseURL'); if (base?.textContent) push(base.textContent.trim(), mime || 'video/mp4'); }); } } catch {} if (!out.length && !/]*)?>([^<]+)<\/BaseURL>/gi; let match; while ((match = basePattern.exec(raw))) push(match[1], ''); } return out; } function scanNetworkTextForMedia(text, responseUrl = '') { const raw = String(text || '').slice(0, 2 * 1024 * 1024); if (!raw) return false; let changed = false; if (isDash(responseUrl) || /', 'text/html'); for (const candidate of extractSocialCandidates(doc, location.href, raw)) changed = registerMedia(candidate.url, candidate.type, true, candidate) || changed; return changed; } const keyPattern = /["'](?:download(?:Addr|_addr|Url|_url)|video(?:Url|_url|Src|_src)|content(?:Url|_url)|playback(?:Url|_url)|play(?:Addr|_addr)|media(?:Url|_url)|stream(?:Url|_url)|hls(?:Url|_url)|manifest(?:Url|_url)|file(?:Url|_url)?)["']\s*[:=]\s*["']((?:\\.|[^"'\\])*)["']/gi; let match; while ((match = keyPattern.exec(raw))) { const url = safeHttpUrl(decodeEmbeddedUrl(match[1]), responseUrl || location.href); if (!url) continue; if (isDash(url)) { changed = rememberDashManifest(url) || changed; continue; } const type = isHls(url) ? 'application/vnd.apple.mpegurl' : ''; changed = rememberNetworkMedia(url, type, { trusted: true, label: 'Fonte publicada por API' }) || changed; } const urlPattern = /(?:https?(?::|\\u003a)(?:\/|\\\/|\\u002f){2}|\/\/)[^"'<>\\\s]+?(?:\.m3u8|\.mpd|\.mp4|\.m4v|\.webm|\.mov|\.ogv|\.mkv|\.ts)(?:[^"'<>\\\s]*)/gi; while ((match = urlPattern.exec(raw))) { const url = safeHttpUrl(decodeEmbeddedUrl(match[0]), responseUrl || location.href); if (!url) continue; if (isDash(url)) { changed = rememberDashManifest(url) || changed; continue; } changed = rememberNetworkMedia(url, isHls(url) ? 'application/vnd.apple.mpegurl' : '', { trusted: true, label: 'URL observada em resposta da API', }) || changed; } return changed; } function mainWorldNetworkBridgeBootstrap(eventName) { try { if (window.__VDD_MAIN_WORLD_NETWORK_BRIDGE__) return; Object.defineProperty(window, '__VDD_MAIN_WORLD_NETWORK_BRIDGE__', { value: true, configurable: true, }); const emit = detail => { try { document.dispatchEvent(new CustomEvent(eventName, { detail })); } catch {} }; const normalizedType = value => String(value || '').toLowerCase().split(';', 1)[0].trim(); const shouldReadText = (type, length, url) => { const mime = normalizedType(type); if (/mpegurl|dash\+xml|json|javascript|text\//i.test(mime)) return true; if (/\.(?:m3u8|mpd)(?:$|[?#&])/i.test(String(url || ''))) return true; const size = Number(length || 0); return size > 0 && size <= 2 * 1024 * 1024 && !mime.startsWith('video/') && !mime.startsWith('audio/') && mime !== 'application/mp4'; }; const readPrefix = async (response, maxBytes = 131072) => { try { const copy = response?.clone?.(); const reader = copy?.body?.getReader?.(); if (!reader) return ''; const chunks = []; let total = 0; while (total < maxBytes) { const { done, value } = await reader.read(); if (done) break; if (!value?.byteLength) continue; const remaining = maxBytes - total; const part = value.byteLength > remaining ? value.slice(0, remaining) : value; chunks.push(part); total += part.byteLength; } try { Promise.resolve(reader.cancel()).catch(() => {}); } catch {} const bytes = new Uint8Array(total); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } return new TextDecoder('utf-8').decode(bytes); } catch { return ''; } }; try { const nativeFetch = window.fetch; if (typeof nativeFetch === 'function') { const wrappedFetch = function(...args) { const pending = Reflect.apply(nativeFetch, this, args); Promise.resolve(pending).then(response => { try { const type = response?.headers?.get?.('content-type') || ''; const length = response?.headers?.get?.('content-length') || ''; const contentRange = response?.headers?.get?.('content-range') || ''; const status = Number(response?.status || 0); const url = response?.url || String(args?.[0]?.url || args?.[0] || ''); emit({ url, type, status, contentRange, source: 'fetch' }); const mime = normalizedType(type); if (response?.clone && shouldReadText(type, length, url)) { readPrefix(response, 2 * 1024 * 1024).then(text => { if (text && text.length <= 2 * 1024 * 1024) emit({ url, type, status, contentRange, text, source: 'fetch-text' }); }).catch(() => {}); } else if (response?.clone && !length && !mime.startsWith('video/') && !mime.startsWith('audio/') && mime !== 'application/mp4') { readPrefix(response).then(text => { if (text) emit({ url, type, status, contentRange, text, source: 'fetch-prefix' }); }).catch(() => {}); } } catch {} }, () => {}); return pending; }; window.fetch = typeof Proxy === 'function' ? new Proxy(nativeFetch, { apply: (target, thisArg, args) => Reflect.apply(wrappedFetch, thisArg, args) }) : wrappedFetch; } } catch {} try { const proto = window.XMLHttpRequest?.prototype; const nativeOpen = proto?.open; const nativeSend = proto?.send; if (typeof nativeOpen === 'function') { proto.open = typeof Proxy === 'function' ? new Proxy(nativeOpen, { apply(target, thisArg, args) { try { thisArg.__vddRequestUrl = String(args?.[1] || ''); } catch {} return Reflect.apply(target, thisArg, args); } }) : function(...args) { try { this.__vddRequestUrl = String(args?.[1] || ''); } catch {} return Reflect.apply(nativeOpen, this, args); }; } if (typeof nativeSend === 'function') { const wrappedSend = function(...args) { try { this.addEventListener('loadend', () => { try { const type = this.getResponseHeader?.('content-type') || ''; const length = this.getResponseHeader?.('content-length') || ''; const contentRange = this.getResponseHeader?.('content-range') || ''; const status = Number(this.status || 0); const url = this.responseURL || this.__vddRequestUrl || ''; const detail = { url, type, status, contentRange, source: 'xhr' }; const responseType = String(this.responseType || ''); if ((!responseType || responseType === 'text') && shouldReadText(type, length, url)) { try { const text = String(this.responseText || ''); if (text && text.length <= 2 * 1024 * 1024) detail.text = text; } catch {} } emit(detail); } catch {} }, { once: true }); } catch {} return Reflect.apply(nativeSend, this, args); }; proto.send = typeof Proxy === 'function' ? new Proxy(nativeSend, { apply: (target, thisArg, args) => Reflect.apply(wrappedSend, thisArg, args) }) : wrappedSend; } } catch {} emit({ ready: true, source: 'bridge' }); } catch {} } // Injects a bridge into the page's own JavaScript context to see requests the userscript sandbox cannot. function installMainWorldNetworkBridge() { const eventName = 'vdd-network-bridge-2-7-4'; if (APP.mainWorldBridgeInstalled) return; APP.mainWorldBridgeInstalled = true; document.addEventListener(eventName, event => { try { const detail = event?.detail || {}; if (detail.ready) { APP.mainWorldBridgeReady = true; return; } const url = safeHttpUrl(detail.url || ''); const type = mediaContentType(detail.type || ''); let changed = false; if (url) rememberNetworkRequest(url, type, { status: detail.status, source: detail.source }); if (url && (Number(detail.status || 0) === 206 || detail.contentRange)) rememberBinaryCandidate(url, type); if (url && (isHls(url, type) || knownDirectFormatHint(url, type) || type.startsWith('video/') || type === 'application/mp4')) { changed = rememberNetworkMedia(url, type, { trusted: true, label: 'Fonte observada no contexto da página', }) || changed; } if (detail.text && scanNetworkTextForMedia(String(detail.text), url || location.href)) changed = true; if (changed) queueRender(); } catch {} }, true); const inject = () => { const root = document.documentElement || document.head; if (!root) return false; try { const script = document.createElement('script'); script.textContent = `;(${mainWorldNetworkBridgeBootstrap.toString()})(${JSON.stringify(eventName)});`; root.appendChild(script); script.remove(); return true; } catch (error) { console.info('[VDD] Ponte de rede do contexto principal indisponível:', error); return false; } }; if (!inject()) { let attempts = 0; const retry = () => { if (APP.mainWorldBridgeReady || attempts++ >= 40) return; if (!inject()) setTimeout(retry, 25); }; setTimeout(retry, 0); document.addEventListener('DOMContentLoaded', inject, { once: true, capture: true }); } } async function readResponsePrefixText(response, maxBytes = 131072) { if (!response?.clone) return ''; const copy = response.clone(); try { if (copy.body?.getReader) { const reader = copy.body.getReader(); const chunks = []; let total = 0; while (total < maxBytes) { const { done, value } = await reader.read(); if (done) break; if (!value?.byteLength) continue; const remaining = maxBytes - total; const part = value.byteLength > remaining ? value.slice(0, remaining) : value; chunks.push(part); total += part.byteLength; } try { Promise.resolve(reader.cancel()).catch(() => {}); } catch {} const bytes = new Uint8Array(total); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } try { return new TextDecoder('utf-8').decode(bytes); } catch { return ''; } } const text = await copy.text(); return String(text || '').slice(0, maxBytes); } catch { return ''; } } // Wraps fetch and XMLHttpRequest to remember media URLs and manifests. function installNetworkHooks() { if (APP.networkHooksInstalled) return; APP.networkHooksInstalled = true; let pageWindow = window; try { if (typeof unsafeWindow === 'object' && unsafeWindow) pageWindow = unsafeWindow; } catch {} try { const nativeFetch = pageWindow.fetch; if (typeof nativeFetch === 'function') { const monitoredFetch = function(target, thisArg, args) { const request = Reflect.apply(target, thisArg, args); Promise.resolve(request).then(response => { try { const type = response?.headers?.get?.('content-type') || ''; const url = response?.url || ''; const contentRange = response?.headers?.get?.('content-range') || ''; const statusCode = Number(response?.status || 0); let changed = false; if (url) rememberNetworkRequest(url, type, { status: statusCode, source: 'fetch' }); if (url && (statusCode === 206 || contentRange)) rememberBinaryCandidate(url, type); if (url && (isHls(url, type) || knownDirectFormatHint(url, type) || mediaContentType(type).startsWith('video/'))) { changed = rememberNetworkMedia(url, type, { trusted: true, label: 'Resposta observada no player', }) || changed; } const length = Number(response?.headers?.get?.('content-length') || 0); const normalized = mediaContentType(type); const textLike = /mpegurl|dash\+xml|json|javascript|text\//i.test(normalized); const smallUnknown = length > 0 && length <= 2 * 1024 * 1024 && !normalized.startsWith('video/') && normalized !== 'application/mp4'; const unknownPrefix = !length && !normalized.startsWith('video/') && !normalized.startsWith('audio/') && normalized !== 'application/mp4'; if (response?.clone && (textLike || smallUnknown || unknownPrefix || isHls(url, type) || isDash(url, type))) { const task = textLike || smallUnknown || isHls(url, type) || isDash(url, type) ? readResponsePrefixText(response, 2 * 1024 * 1024) : readResponsePrefixText(response, 131072); task.then(text => { if (text && scanNetworkTextForMedia(text, url)) queueRender(); }).catch(() => {}); } if (changed) queueRender(); } catch {} }, () => {}); return request; }; pageWindow.fetch = typeof Proxy === 'function' ? new Proxy(nativeFetch, { apply: monitoredFetch }) : function(...args) { return monitoredFetch(nativeFetch, this, args); }; } } catch (error) { console.info('[VDD] Monitoramento de fetch indisponível:', error); } try { const proto = pageWindow.XMLHttpRequest?.prototype; const nativeOpen = proto?.open; const nativeSend = proto?.send; if (typeof nativeOpen === 'function' && !proto.__vddOpenWrapped) { try { Object.defineProperty(proto, '__vddOpenWrapped', { value: true, configurable: true }); } catch {} proto.open = typeof Proxy === 'function' ? new Proxy(nativeOpen, { apply(target, thisArg, args) { try { thisArg.__vddRequestUrl = String(args?.[1] || ''); } catch {} return Reflect.apply(target, thisArg, args); } }) : function(...args) { try { this.__vddRequestUrl = String(args?.[1] || ''); } catch {} return Reflect.apply(nativeOpen, this, args); }; } if (typeof nativeSend === 'function') { const monitoredSend = function(target, thisArg, args) { thisArg.addEventListener('loadend', () => { try { const type = thisArg.getResponseHeader?.('content-type') || ''; const url = thisArg.responseURL || thisArg.__vddRequestUrl || ''; const contentRange = thisArg.getResponseHeader?.('content-range') || ''; const statusCode = Number(thisArg.status || 0); let changed = false; if (url) rememberNetworkRequest(url, type, { status: statusCode, source: 'xhr' }); if (url && (statusCode === 206 || contentRange)) rememberBinaryCandidate(url, type); if (url && (isHls(url, type) || knownDirectFormatHint(url, type) || mediaContentType(type).startsWith('video/'))) { changed = rememberNetworkMedia(url, type, { trusted: true, label: 'Resposta observada no player', }) || changed; } let text = ''; try { const responseType = String(thisArg.responseType || ''); if (!responseType || responseType === 'text') text = String(thisArg.responseText || ''); } catch {} if (text && scanNetworkTextForMedia(text, url)) changed = true; if (changed) queueRender(); } catch {} }, { once: true }); return Reflect.apply(target, thisArg, args); }; proto.send = typeof Proxy === 'function' ? new Proxy(nativeSend, { apply: monitoredSend }) : function(...args) { return monitoredSend(nativeSend, this, args); }; } } catch (error) { console.info('[VDD] Monitoramento de XHR indisponível:', error); } } function installPerformanceObserver() { if (!('PerformanceObserver' in window)) return; try { const po = new PerformanceObserver(list => { let changed = false; for (const entry of list.getEntries()) { if (APP.navAt && entry.startTime < APP.navAt) continue; const initiator = String(entry?.initiatorType || '').toLowerCase(); if (/^(?:fetch|xmlhttprequest|video|audio|media)$/.test(initiator)) { rememberNetworkRequest(entry.name, '', { source: `observer:${initiator}` }); if (/^(?:video|audio|media)$/.test(initiator) || Number(entry?.transferSize || 0) >= 256 * 1024) { rememberBinaryCandidate(entry.name, ''); } } if (/^(?:video|audio|media)$/.test(initiator)) { if (rememberNetworkMedia(entry.name, '', { ...networkMetadata(), trusted: true, label: 'Recurso carregado pelo player' })) changed = true; } else if (registerMedia(entry.name, '', false, networkMetadata())) changed = true; } if (changed) queueRender(); }); po.observe({ type: 'resource', buffered: true }); } catch {} } function installDomObserver() { APP.observer = new MutationObserver(mutations => { if (mutations.every(mutation => mutation.target?.closest?.('#vdd-root'))) return; clearTimeout(APP.scanTimer); APP.scanTimer = setTimeout(() => { if (collectFromDom()) queueRender(); }, 350); }); APP.observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['src', 'poster'], }); } // --------------------------------------------------------------------------- // Icons and site logos // Inline icons and site logos from favicon services, cached locally. // --------------------------------------------------------------------------- const ICON_PATHS = Object.freeze({ download: '', refresh: '', close: '', expand: '', collapse: '', page: '', link: '', sites: '', pulse: '', copy: '', trash: '', check: '', alert: '', x: '', search: '', paste: '', film: '', stream: '', doc: '', spark: '', broom: '', }); function ic(name, cls = '') { return `