// ==UserScript== // @name All-in-One Video Downloader – YouTube, TikTok, Instagram, X, Facebook & 15 more (MP4/MP3) // @name:ro NoBsDL Video Downloader – descarcă MP4 și MP3 de pe YouTube, TikTok, Instagram, X, Facebook // @name:es NoBsDL Video Downloader – descarga MP4 y MP3 de YouTube, TikTok, Instagram, X, Facebook // @name:pt-BR NoBsDL Video Downloader – baixe MP4 e MP3 do YouTube, TikTok, Instagram, X, Facebook // @name:fr NoBsDL Video Downloader – téléchargez en MP4 et MP3 depuis YouTube, TikTok, Instagram, X, Facebook // @name:de NoBsDL Video Downloader – MP4 & MP3 von YouTube, TikTok, Instagram, X, Facebook herunterladen // @name:it NoBsDL Video Downloader – scarica MP4 e MP3 da YouTube, TikTok, Instagram, X, Facebook // @name:ru NoBsDL Video Downloader – скачать MP4 и MP3 с YouTube, TikTok, Instagram, X, Facebook // @name:uk NoBsDL Video Downloader – завантажити MP4 і MP3 з YouTube, TikTok, Instagram, X, Facebook // @name:pl NoBsDL Video Downloader – pobieraj MP4 i MP3 z YouTube, TikTok, Instagram, X, Facebook // @name:tr NoBsDL Video Downloader – YouTube, TikTok, Instagram, X, Facebook'tan MP4 ve MP3 indir // @name:id NoBsDL Video Downloader – unduh MP4 dan MP3 dari YouTube, TikTok, Instagram, X, Facebook // @name:vi NoBsDL Video Downloader – tải MP4 và MP3 từ YouTube, TikTok, Instagram, X, Facebook // @name:ar NoBsDL Video Downloader – تنزيل MP4 وMP3 من YouTube وTikTok وInstagram وX وFacebook // @name:ja NoBsDL Video Downloader – YouTube・TikTok・Instagram・X・Facebook から MP4/MP3 をダウンロード // @name:ko NoBsDL Video Downloader – YouTube, TikTok, Instagram, X, Facebook에서 MP4·MP3 다운로드 // @name:zh-CN NoBsDL 视频下载器 – 从 YouTube、TikTok、Instagram、X、Facebook 下载 MP4 和 MP3 // @namespace https://nobsdl.com/ // @version 2.1.0 // @description Download videos as MP4 and audio as MP3 without leaving the page: pick a real quality with its file size, watch live progress, and the file goes straight to your downloads. YouTube (incl. Shorts), TikTok, Instagram, X/Twitter, Facebook, Reddit, Vimeo, Twitch clips, Dailymotion, Pinterest, Bluesky, Streamable, Imgur, 9GAG, Odysee, Rutube, OK.ru, SoundCloud, Bandcamp and Mixcloud. By NoBsDL. // @description:ro Descarcă video MP4 și audio MP3 fără să părăsești pagina. Alegi o calitate reală cu mărimea fișierului, vezi progresul live de pe server, iar fișierul ajunge direct în descărcările browserului. YouTube (inclusiv Shorts), TikTok, Instagram, X, Facebook, Reddit, Vimeo, SoundCloud. // @description:es Descarga vídeos en MP4 y audio en MP3 sin salir de la página. Elige una calidad real con su tamaño, mira el progreso en vivo y el archivo llega directo a tus descargas. YouTube (incl. Shorts), TikTok, Instagram, X, Facebook, Reddit, Vimeo, SoundCloud. // @description:pt-BR Baixe vídeos em MP4 e áudio em MP3 sem sair da página. Escolha uma qualidade real com o tamanho do arquivo, acompanhe o progresso ao vivo e o arquivo vai direto para seus downloads. YouTube (incl. Shorts), TikTok, Instagram, X, Facebook, Reddit, Vimeo, SoundCloud. // @description:fr Téléchargez des vidéos en MP4 et l'audio en MP3 sans quitter la page. Choisissez une vraie qualité avec sa taille, suivez la progression en direct, le fichier arrive dans vos téléchargements. YouTube (Shorts inclus), TikTok, Instagram, X, Facebook, Reddit, Vimeo, SoundCloud. // @description:de Videos als MP4 und Audio als MP3 herunterladen, ohne die Seite zu verlassen. Echte Qualität mit Dateigröße wählen, Live-Fortschritt sehen, die Datei landet direkt in den Downloads. YouTube (inkl. Shorts), TikTok, Instagram, X, Facebook, Reddit, Vimeo, SoundCloud. // @description:it Scarica video in MP4 e audio in MP3 senza lasciare la pagina. Scegli una qualità reale con la dimensione del file, segui l'avanzamento dal vivo e il file finisce nei tuoi download. YouTube (Shorts inclusi), TikTok, Instagram, X, Facebook, Reddit, Vimeo, SoundCloud. // @description:ru Скачивайте видео в MP4 и аудио в MP3, не уходя со страницы. Выберите реальное качество с размером файла, следите за прогрессом, файл сохранится в загрузки браузера. YouTube (вкл. Shorts), TikTok, Instagram, X, Facebook, Reddit, Vimeo, SoundCloud. // @description:uk Завантажуйте відео в MP4 і аудіо в MP3, не залишаючи сторінку. Обирайте реальну якість із розміром файлу, стежте за прогресом, файл збережеться в завантаження. YouTube (з Shorts), TikTok, Instagram, X, Facebook, Reddit, Vimeo, SoundCloud. // @description:pl Pobieraj wideo w MP4 i dźwięk w MP3 bez opuszczania strony. Wybierz prawdziwą jakość z rozmiarem pliku, obserwuj postęp na żywo, plik trafi prosto do pobranych. YouTube (z Shorts), TikTok, Instagram, X, Facebook, Reddit, Vimeo, SoundCloud. // @description:tr Sayfadan ayrılmadan videoları MP4, sesi MP3 olarak indirin. Dosya boyutuyla gerçek bir kalite seçin, canlı ilerlemeyi izleyin; dosya doğrudan indirilenlere gelir. YouTube (Shorts dahil), TikTok, Instagram, X, Facebook, Reddit, Vimeo, SoundCloud. // @description:id Unduh video MP4 dan audio MP3 tanpa meninggalkan halaman. Pilih kualitas asli lengkap dengan ukuran file, lihat progres langsung, dan file masuk ke unduhan browser. YouTube (termasuk Shorts), TikTok, Instagram, X, Facebook, Reddit, Vimeo, SoundCloud. // @description:vi Tải video MP4 và âm thanh MP3 ngay trên trang. Chọn chất lượng thật kèm dung lượng tệp, xem tiến trình trực tiếp, tệp được lưu thẳng vào mục tải xuống. YouTube (gồm Shorts), TikTok, Instagram, X, Facebook, Reddit, Vimeo, SoundCloud. // @description:ar نزّل الفيديو بصيغة MP4 والصوت بصيغة MP3 دون مغادرة الصفحة. اختر جودة حقيقية مع حجم الملف وتابع التقدم مباشرة، ويُحفظ الملف في تنزيلات المتصفح. YouTube وShorts وTikTok وInstagram وX وFacebook وReddit وVimeo وSoundCloud. // @description:ja ページを離れずに動画を MP4、音声を MP3 で保存。実際の画質とファイルサイズを選び、サーバーの進行状況をリアルタイムで確認。ファイルはブラウザのダウンロードへ。YouTube(ショート含む)・TikTok・Instagram・X・Facebook・Reddit・Vimeo・SoundCloud。 // @description:ko 페이지를 떠나지 않고 동영상은 MP4, 오디오는 MP3로 저장하세요. 실제 화질과 파일 크기를 고르고 실시간 진행률을 확인하면 파일이 브라우저 다운로드로 저장됩니다. YouTube(쇼츠 포함), TikTok, Instagram, X, Facebook, Reddit, Vimeo, SoundCloud. // @description:zh-CN 无需离开页面即可下载 MP4 视频和 MP3 音频。选择真实画质并查看文件大小,实时查看服务器进度,文件直接保存到浏览器下载。支持 YouTube(含 Shorts)、TikTok、Instagram、X、Facebook、Reddit、Vimeo、SoundCloud。 // @author NoBsDL // @icon https://nobsdl.com/static/images/icon-192x192.png // @homepageURL https://nobsdl.com/ // @supportURL https://nobsdl.com/contact // @license MIT // @match *://*.youtube.com/* // @match *://*.youtu.be/* // @match *://*.youtube-nocookie.com/* // @match *://*.tiktok.com/* // @match *://*.instagram.com/* // @match *://*.facebook.com/* // @match *://*.reddit.com/* // @match *://*.redd.it/* // @match *://*.x.com/* // @match *://*.twitter.com/* // @match *://*.vimeo.com/* // @match *://*.soundcloud.com/* // @match *://*.twitch.tv/* // @match *://*.dailymotion.com/* // @match *://*.pinterest.com/* // @match *://bsky.app/* // @match *://*.streamable.com/* // @match *://*.imgur.com/* // @match *://*.9gag.com/* // @match *://*.odysee.com/* // @match *://*.rutube.ru/* // @match *://*.ok.ru/* // @match *://*.bandcamp.com/* // @match *://*.mixcloud.com/* // @grant GM_xmlhttpRequest // @grant GM.xmlHttpRequest // @grant GM_getValue // @grant GM_setValue // @connect nobsdl.com // @antifeature payment Most downloads are free. Some higher resolutions and files over the free size limits (500 MB video, 100 MB MP3) need a paid NoBsDL Supporter key; those choices are shown with a lock. // @run-at document-idle // @noframes // ==/UserScript== // How it works (and what it sends): // - Nothing is requested until you open the panel. Then the current public // media URL is sent to https://nobsdl.com only, to list its real formats. // Choosing a format starts the download job on NoBsDL; the panel polls its // status and finally hands the finished file to your browser's downloads. // - Requests go to nobsdl.com only. When your userscript manager sends your // nobsdl.com cookies with them, a Supporter session activated on nobsdl.com // unlocks Supporter formats here too. The script stores no key, cookie or // history; only whether you collapsed the widget (GM_setValue). // - No analytics, ads or tracking code in this script. If your userscript // manager does not offer GM_xmlhttpRequest, the widget falls back to // opening the matching NoBsDL page in a new tab. (function () { "use strict"; const NOBSDL_ORIGIN = "https://nobsdl.com"; const WIDGET_ID = "nobsdl-download-widget"; const VIDEO_AUDIO = Object.freeze(["mp4", "mp3"]); const AUDIO_ONLY = Object.freeze(["mp3"]); const PLAYLIST_ONLY = Object.freeze(["playlist"]); const POLL_INTERVAL_MS = 1000; const POLL_DEADLINE_MS = 20 * 60 * 1000; function hostIs(host, suffix) { return host === suffix || host.endsWith("." + suffix); } function pathParts(url) { return url.pathname.split("/").filter(Boolean); } function realYoutubePlaylist(value) { const id = String(value || "").trim(); return Boolean(id) && !/^(?:RD|UL)/i.test(id) && /^[A-Za-z0-9_-]{10,160}$/.test(id); } function validId(value) { return /^[A-Za-z0-9_-]{1,160}$/.test(String(value || "")); } function page(platform, name, workflow, route, outputs, extra) { return Object.assign({platform, name, workflow, route, outputs}, extra || {}); } function youtubePage(url, host) { const parts = pathParts(url); const listId = url.searchParams.get("list") || ""; const isPlaylist = realYoutubePlaylist(listId); if (hostIs(host, "youtu.be")) { if (!validId(parts[0])) return null; if (isPlaylist) { return page("youtube", "YouTube", "playlist", "/youtube-playlist-downloader", PLAYLIST_ONLY, {listId}); } return page("youtube", "YouTube", "single", "/youtube-downloader", VIDEO_AUDIO); } if (url.pathname.toLowerCase().startsWith("/shorts/") && validId(parts[1])) { return page("youtube", "YouTube Shorts", "short", "/youtube-shorts-downloader", VIDEO_AUDIO); } if ((url.pathname.toLowerCase() === "/playlist" || url.pathname.toLowerCase() === "/watch") && isPlaylist) { return page("youtube", "YouTube playlist", "playlist", "/youtube-playlist-downloader", PLAYLIST_ONLY, {listId}); } if (url.pathname.toLowerCase() === "/watch" && validId(url.searchParams.get("v"))) { return page("youtube", "YouTube", "single", "/youtube-downloader", VIDEO_AUDIO); } if (/^\/(?:embed|v|live)\/[A-Za-z0-9_-]+/i.test(url.pathname)) { return page("youtube", "YouTube", "single", "/youtube-downloader", VIDEO_AUDIO); } return null; } function soundcloudPage(url, host) { const parts = pathParts(url); if (host === "on.soundcloud.com") { return parts[0] ? page("soundcloud", "SoundCloud", "single", "/soundcloud-to-mp3", AUDIO_ONLY) : null; } const reservedFirst = new Set(["discover", "search", "stream", "you", "upload", "charts"]); const reservedSecond = new Set(["sets", "tracks", "likes", "reposts", "popular-tracks"]); if (parts.length < 2 || reservedFirst.has(parts[0].toLowerCase()) || reservedSecond.has(parts[1].toLowerCase())) { return null; } return page("soundcloud", "SoundCloud", "single", "/soundcloud-to-mp3", AUDIO_ONLY); } function pageFor(input) { let url; try { url = input instanceof URL ? input : new URL(String(input || "")); } catch (_) { return null; } if (url.protocol !== "http:" && url.protocol !== "https:") return null; const host = url.hostname.toLowerCase().replace(/\.$/, ""); const path = url.pathname; const parts = pathParts(url); if (hostIs(host, "youtube.com") || hostIs(host, "youtu.be") || hostIs(host, "youtube-nocookie.com")) { return youtubePage(url, host); } if (hostIs(host, "tiktok.com")) { const shortHost = host === "vm.tiktok.com" || host === "vt.tiktok.com"; if ((shortHost && parts[0]) || /^\/@[^/]+\/video\/\d+/i.test(path) || /^\/(?:t|v)\/[A-Za-z0-9_-]+/i.test(path)) { return page("tiktok", "TikTok", "single", "/tiktok-downloader", VIDEO_AUDIO); } return null; } if (hostIs(host, "instagram.com")) { return /^\/(?:reel|reels|p|tv)\/[A-Za-z0-9_-]+/i.test(path) ? page("instagram", "Instagram", "single", "/instagram-downloader", VIDEO_AUDIO) : null; } if (hostIs(host, "facebook.com")) { const isWatch = /^\/watch\/?$/i.test(path) && Boolean(url.searchParams.get("v")); const isVideo = /^\/(?:reel|share\/(?:v|r))\/[A-Za-z0-9._-]+/i.test(path) || /\/videos\/\d+/i.test(path) || /^\/video\.php$/i.test(path) && Boolean(url.searchParams.get("v")); return isWatch || isVideo ? page("facebook", "Facebook", "single", "/facebook-downloader", VIDEO_AUDIO) : null; } if (hostIs(host, "reddit.com") || hostIs(host, "redd.it")) { const direct = hostIs(host, "redd.it") && host !== "reddit.com" && Boolean(parts[0]); const post = /\/(?:r\/[^/]+\/)?comments\/[A-Za-z0-9]+/i.test(path); return direct || post ? page("reddit", "Reddit", "single", "/reddit-downloader", VIDEO_AUDIO) : null; } if (hostIs(host, "x.com") || hostIs(host, "twitter.com")) { return /^\/(?:i\/)?[^/]+\/status\/\d+/i.test(path) || /^\/i\/status\/\d+/i.test(path) ? page("twitter", "X / Twitter", "single", "/x-downloader", VIDEO_AUDIO) : null; } if (hostIs(host, "vimeo.com")) { const isVimeoVideo = /^\/\d+(?:\/|$)/.test(path) || /^\/video\/\d+(?:\/|$)/.test(path) || /\/(?:videos?|video)\/\d+(?:\/|$)/.test(path) || /\/\d+(?:\/|$)/.test(path) && /^(?:channels|groups|showcase)\//i.test(parts.slice(0, -1).join("/")); return isVimeoVideo ? page("vimeo", "Vimeo", "single", "/vimeo-downloader", VIDEO_AUDIO) : null; } if (hostIs(host, "soundcloud.com")) return soundcloudPage(url, host); return universalPage(url, host, parts); } // NOBSDL_USERSCRIPT_UNIVERSAL_V1 (2.1.0): further sites NoBsDL's universal // downloader handles. Each one was verified 2026-10-04 with a real // end-to-end download through the production API (metadata, job, file). // Only individual media pages match - never feeds, profiles or searches. function universalPage(url, host, parts) { const path = url.pathname; const universal = (platform, name, outputs) => page(platform, name, "single", "/universal-downloader", outputs || VIDEO_AUDIO); if (hostIs(host, "pinterest.com")) { return /^\/pin\/[A-Za-z0-9_-]+\/?$/.test(path) ? universal("pinterest", "Pinterest") : null; } if (hostIs(host, "streamable.com")) { return /^\/(?:[eo]\/)?[a-z0-9]{3,12}\/?$/i.test(path) && !/^\/(?:login|signup|pricing|about|terms|privacy|careers|upload|blog|community|support|jobs)\/?$/i.test(path) ? universal("streamable", "Streamable") : null; } if (hostIs(host, "twitch.tv")) { const clip = host === "clips.twitch.tv" ? Boolean(parts[0]) && parts[0] !== "embed" : /^\/[^/]+\/clip\/[A-Za-z0-9_-]+/.test(path); const vod = /^\/videos\/\d+\/?$/.test(path); return clip || vod ? universal("twitch", "Twitch") : null; } if (host === "bsky.app") { return /^\/profile\/[^/]+\/post\/[A-Za-z0-9]+\/?$/.test(path) ? universal("bluesky", "Bluesky") : null; } if (hostIs(host, "9gag.com")) { return /^\/gag\/[A-Za-z0-9]+\/?$/.test(path) ? universal("9gag", "9GAG") : null; } if (hostIs(host, "imgur.com")) { // Media ids always mix in digits or capitals; site pages (/upload, // /search, /signin...) are lowercase words. const id = (path.match(/^\/(?:gallery\/|t\/[^/]+\/)?([A-Za-z0-9]{5,8})(?:\.(?:mp4|gifv))?\/?$/) || [])[1] || ""; return /[A-Z0-9]/.test(id) ? universal("imgur", "Imgur") : null; } if (hostIs(host, "odysee.com")) { return /^\/@[^/]+\/[^/]+\/?$/.test(path) ? universal("odysee", "Odysee") : null; } if (hostIs(host, "ok.ru")) { return /^\/video\/\d+/.test(path) ? universal("okru", "OK.ru") : null; } if (hostIs(host, "rutube.ru")) { return /^\/video\/[a-f0-9]{32}\/?$/.test(path) ? universal("rutube", "Rutube") : null; } if (hostIs(host, "dailymotion.com")) { return /^\/video\/[A-Za-z0-9]+\/?$/.test(path) ? universal("dailymotion", "Dailymotion") : null; } if (hostIs(host, "dai.ly")) { return parts[0] ? universal("dailymotion", "Dailymotion") : null; } if (hostIs(host, "bandcamp.com")) { return /^\/track\/[^/]+\/?$/.test(path) && host !== "bandcamp.com" ? universal("bandcamp", "Bandcamp", AUDIO_ONLY) : null; } if (hostIs(host, "mixcloud.com")) { const reserved = new Set(["discover", "upload", "select", "pro", "search", "live", "categories", "about", "help", "jobs", "settings", "notifications", "dashboard"]); const reservedSecond = new Set(["favorites", "listens", "stream", "uploads", "playlists", "followers", "following", "reposts", "likes", "history", "tracks"]); return parts.length === 2 && !reserved.has(parts[0].toLowerCase()) && !reservedSecond.has(parts[1].toLowerCase()) ? universal("mixcloud", "Mixcloud", AUDIO_ONLY) : null; } return null; return null; } function sourceUrlFor(currentUrl, matchedPage) { if (matchedPage.workflow === "playlist" && matchedPage.listId) { const normalized = new URL("https://www.youtube.com/playlist"); normalized.searchParams.set("list", matchedPage.listId); return normalized.href; } return new URL(currentUrl).href; } function targetUrl(matchedPage, currentUrl, output) { if (!matchedPage || !matchedPage.outputs.includes(output)) return null; let route = matchedPage.route; if (matchedPage.platform === "youtube" && matchedPage.workflow === "single") { route = output === "mp3" ? "/youtube-to-mp3" : "/youtube-downloader"; } const target = new URL(route, NOBSDL_ORIGIN); target.searchParams.set("url", sourceUrlFor(currentUrl, matchedPage)); if (matchedPage.outputs === VIDEO_AUDIO && !(matchedPage.platform === "youtube" && matchedPage.workflow === "single")) { target.searchParams.set("prefer", output); } else if (matchedPage.route === "/universal-downloader" && output === "mp3") { target.searchParams.set("prefer", "mp3"); } return target.href; } // ── Formats ──────────────────────────────────────────────────────────── // One uniform list from /api/video-info (same contract as the NoBsDL // browser extension): YouTube exposes curated choice ids, other platforms // free/Supporter format dictionaries. `locked` = Supporter tier without an // active Supporter session. Sizes are NoBsDL's own estimates. function positiveNumber(value) { const n = Number(value); return Number.isFinite(n) && n > 0 ? n : null; } function heightFromLabel(label) { const m = String(label || "").match(/(\d{3,4})p/); return m ? Number(m[1]) : 0; } function normalizeChoices(data) { const premium = !!(data && data.is_supporter); const out = []; const yt = data && data.youtube_video_choices; if (yt && (Array.isArray(yt.free) || Array.isArray(yt.supporter))) { const addVideo = (c, supporterList) => { if (!c || !c.choice_id) return; out.push({ key: "v:" + c.choice_id, family: "video", format: "mp4", ytChoice: String(c.choice_id), title: c.height + "p", fps: Number(c.fps) > 30 ? Math.round(Number(c.fps)) : 0, height: Number(c.height) || 0, sizeMb: positiveNumber(c.size_mb), locked: !premium && (supporterList || c.tier === "supporter"), }); }; (yt.free || []).forEach((c) => addVideo(c, false)); (yt.supporter || []).forEach((c) => addVideo(c, true)); (data.youtube_mp3_choices || []).forEach((c) => { if (!c || !c.choice_id) return; out.push({ key: "a:" + c.choice_id, family: "audio", format: "mp3", ytChoice: String(c.choice_id), title: c.bitrate_kbps + " kbps", bitrate: Number(c.bitrate_kbps) || 0, sizeMb: positiveNumber(c.size_mb), locked: !premium && c.tier === "supporter", }); }); } else { const addVideos = (entries, supporterList) => Object.entries(entries || {}).forEach(([label, e]) => { if (!e || !e.format_id) return; const height = Number(e.quality_edge) || Number(e.height) || heightFromLabel(label); out.push({ key: "v:" + e.format_id, family: "video", format: "mp4", formatId: String(e.format_id), title: height ? height + "p" : "Original", fps: 0, height, sizeMb: positiveNumber(e.size_mb), locked: supporterList && !premium, }); }); addVideos(data && data.video_formats, false); addVideos(data && data.supporter_video_formats, true); Object.values((data && data.audio_formats) || {}).forEach((e) => { const bitrate = Number(e && e.bitrate); if (![128, 256, 320].includes(bitrate)) return; out.push({ key: "a:" + bitrate, family: "audio", format: "mp3", bitrate, title: bitrate + " kbps", sizeMb: positiveNumber(e.size_mb), locked: !premium && e.tier === "supporter", }); }); } const rank = (c) => (c.family === "video" ? c.height : c.bitrate) || 0; out.sort((a, b) => (a.family === b.family ? 0 : a.family === "video" ? -1 : 1) || (rank(a) - rank(b))); const seen = new Set(); return out.filter((c) => (seen.has(c.key) ? false : seen.add(c.key))); } function qualityBadge(choice) { if (choice.family !== "video") return ""; if (choice.height >= 2160) return "4K"; if (choice.height >= 1440) return "2K"; if (choice.height >= 1080) return "Full HD"; if (choice.height >= 720) return "HD"; return ""; } function downloadParams(choice, sourceUrl) { const params = new URLSearchParams({url: sourceUrl, format: choice.format, async: "1"}); if (choice.ytChoice) params.set("yt_choice", choice.ytChoice); if (choice.formatId) params.set("format_id", choice.formatId); if (choice.bitrate && !choice.ytChoice) params.set("audio_bitrate", String(choice.bitrate)); return "/download?" + params.toString(); } // ── Presentation helpers (measured numbers only) ─────────────────────── function formatBytes(bytes) { const mb = bytes / (1024 * 1024); if (mb >= 1024) return (mb / 1024).toFixed(2) + " GB"; return (mb >= 100 ? Math.round(mb) : mb.toFixed(1)) + " MB"; } function formatSize(mb) { if (!positiveNumber(mb)) return ""; return "~" + (mb >= 1024 ? (mb / 1024).toFixed(1) + " GB" : (mb < 10 ? mb.toFixed(1) : Math.round(mb)) + " MB"); } function formatSpeed(bps) { return bps >= 1024 * 1024 ? (bps / (1024 * 1024)).toFixed(1) + " MB/s" : Math.max(1, Math.round(bps / 1024)) + " KB/s"; } function formatEta(seconds) { return seconds < 60 ? "~" + Math.max(1, seconds) + "s left" : "~" + Math.round(seconds / 60) + " min left"; } function formatDuration(seconds) { const s = Math.floor(Number(seconds) || 0); if (s <= 0) return ""; const h = Math.floor(s / 3600), m = Math.floor((s % 3600) / 60), r = s % 60; return h ? h + ":" + String(m).padStart(2, "0") + ":" + String(r).padStart(2, "0") : m + ":" + String(r).padStart(2, "0"); } const PHASE_ORDER = ["requesting", "queued", "connecting", "downloading", "merging", "converting", "finalizing", "ready"]; const PHASE_STEP = {requesting: 0, queued: 0, connecting: 1, downloading: 1, merging: 2, converting: 2, finalizing: 2, ready: 3}; function phaseLabel(phase, platformName, format) { switch (phase) { case "queued": return "Waiting for a free download slot"; case "connecting": return "Connecting to " + platformName; case "downloading": return "Downloading on NoBsDL"; case "merging": return "Merging video and audio"; case "converting": return format === "mp3" ? "Converting to MP3" : "Processing the file"; case "finalizing": return "Finishing up"; case "ready": return "Ready"; default: return "Starting your download"; } } // The bar shows the CURRENT step's measured share: bytes while // downloading, our own MP3 encode while converting. Otherwise no number. function progressView(phase, progress, elapsedSeconds, platformName, format) { const p = progress || {}; let fraction = typeof p.fraction === "number" ? Math.max(0, Math.min(1, p.fraction)) : null; if (phase === "converting") fraction = typeof p.encode_fraction === "number" ? Math.max(0, Math.min(1, p.encode_fraction)) : null; let mode = "waiting"; if (phase === "ready") mode = "done"; else if ((phase === "downloading" || phase === "converting") && fraction !== null) mode = "measured"; else if (phase === "merging" || phase === "converting" || phase === "finalizing") mode = "working"; let text; if (phase === "ready") { text = "Ready — your browser is saving the file."; } else if (phase === "downloading" && p.downloaded_bytes) { const parts = []; if (fraction !== null) parts.push(Math.floor(fraction * 100) + "%"); parts.push(fraction !== null && p.total_bytes ? formatBytes(p.downloaded_bytes) + " of " + formatBytes(p.total_bytes) : formatBytes(p.downloaded_bytes) + " downloaded"); if (p.speed_bps) parts.push(formatSpeed(p.speed_bps)); if (typeof p.eta_seconds === "number" && p.eta_seconds > 0) parts.push(formatEta(p.eta_seconds)); text = parts.join(" · "); } else if (phase === "converting" && fraction !== null) { text = phaseLabel(phase, platformName, format) + "… " + Math.floor(fraction * 100) + "%"; } else { text = phaseLabel(phase, platformName, format) + "…" + (elapsedSeconds >= 2 ? " " + elapsedSeconds + "s" : ""); } return {mode, fraction, text, step: PHASE_STEP[phase] || 0}; } // ── Transport ────────────────────────────────────────────────────────── function gmRequestFunction() { try { if (typeof GM_xmlhttpRequest === "function") return GM_xmlhttpRequest; } catch (_) { /* not granted */ } try { if (typeof GM === "object" && GM && typeof GM.xmlHttpRequest === "function") return GM.xmlHttpRequest.bind(GM); } catch (_) { /* not granted */ } return null; } function humanMessage(data) { const texts = [data.message, data.detail, data.error].filter((v) => typeof v === "string" && v.trim()); return texts.find((v) => /\s/.test(v.trim())) || "NoBsDL couldn’t process this link."; } function apiGet(path, timeout) { const send = gmRequestFunction(); return new Promise((resolve, reject) => { if (!send) { reject(Object.assign(new Error("In-page downloads need a userscript manager with GM_xmlhttpRequest."), {status: 0})); return; } const fail = (message) => reject(Object.assign(new Error(message), {status: 0, retryable: true})); send({ method: "GET", url: NOBSDL_ORIGIN + path, headers: {Accept: "application/json"}, timeout: timeout || 45000, onload: (response) => { let data = {}; try { data = JSON.parse(response.responseText || "{}"); } catch (_) { data = {}; } if (!data || typeof data !== "object" || Array.isArray(data)) data = {}; const status = Number(response.status) || 0; if (status >= 200 && status < 300 && data.status !== "error" && data.ok !== false) { resolve(data); return; } if (status === 451 && !Object.keys(data).length) { reject(Object.assign(new Error("NoBsDL isn’t available in your country."), {status})); return; } reject(Object.assign(new Error(humanMessage(data).slice(0, 300)), { status, supporterCta: data.supporter_cta === true || data.locked_format === true, refreshFormats: /choose_another_quality|retry_format/.test(String(data.suggested_action || "")), retryable: status === 429 || status >= 500, })); }, onerror: () => fail("Can’t reach NoBsDL. Check your connection."), ontimeout: () => fail("NoBsDL took too long to answer. Try again."), }); }); } function safeFileUrl(directUrl) { const path = String(directUrl || ""); return /^\/api\/free-download\/file\/[A-Za-z0-9_-]{8,100}$/.test(path) ? NOBSDL_ORIGIN + path : null; } function safeStatusPath(statusUrl) { const path = String(statusUrl || ""); return /^\/api\/free-download\/status\/[A-Za-z0-9_-]{8,100}$/.test(path) ? path : null; } // ── Persistence (only the collapsed state) ───────────────────────────── function readCollapsed() { try { return typeof GM_getValue === "function" ? GM_getValue("collapsed", false) === true : false; } catch (_) { return false; } } function writeCollapsed(value) { try { if (typeof GM_setValue === "function") GM_setValue("collapsed", !!value); } catch (_) { /* optional */ } } // ── UI ───────────────────────────────────────────────────────────────── const STYLE = ` :host { all: initial; color-scheme: dark; } * { box-sizing: border-box; } .root { position: relative; font: 600 14px/1.35 system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; color: #f1f5f9; } .dock { display: flex; align-items: stretch; gap: 6px; max-width: calc(100vw - 32px); padding: 6px; border: 1px solid rgba(148,163,184,.28); border-radius: 16px; background: rgba(9,11,17,.96); box-shadow: 0 16px 38px rgba(0,0,0,.42), 0 0 24px rgba(99,102,241,.2); backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px); } .brand { display: flex; flex-direction: column; justify-content: center; min-width: 84px; padding: 4px 8px 4px 10px; white-space: nowrap; } .brand strong { font-size: 14px; letter-spacing: -.02em; background: linear-gradient(110deg,#f7f9ff,#7771ff 55%,#3dd6f5); -webkit-background-clip: text; background-clip: text; color: transparent; } .brand small { max-width: 116px; overflow: hidden; color: #94a3b8; font-size: 10px; font-weight: 600; text-overflow: ellipsis; } .actions { display: flex; align-items: stretch; gap: 5px; } .btn, .action { display: inline-flex; min-height: 44px; align-items: center; justify-content: center; gap: 6px; padding: 0 14px; border: 1px solid transparent; border-radius: 11px; color: #fff; font: inherit; font-weight: 800; letter-spacing: .01em; text-decoration: none; cursor: pointer; transition: filter .15s ease, transform .15s ease; -webkit-tap-highlight-color: transparent; } .btn-primary, .action-mp4, .action-playlist { background: linear-gradient(135deg,#6862f6,#497fee); } .action-mp3 { background: linear-gradient(135deg,#0891b2,#06b6d4); color: #03151a; } .btn-ghost { background: transparent; border-color: rgba(148,163,184,.3); color: #cbd5e1; } .btn-icon { min-width: 34px; padding: 0 8px; background: transparent; color: #94a3b8; } .btn:hover, .action:hover { filter: brightness(1.1); transform: translateY(-1px); } .btn:focus-visible, .action:focus-visible, .card:focus-visible, a:focus-visible { outline: 3px solid #f8b84e; outline-offset: 2px; } .collapsed-pill { width: 48px; height: 48px; border-radius: 50%; padding: 0; font-size: 20px; box-shadow: 0 10px 28px rgba(0,0,0,.45); } .panel { position: absolute; right: 0; bottom: calc(100% + 10px); width: min(360px, calc(100vw - 32px)); max-height: min(72vh, 620px); display: flex; flex-direction: column; border: 1px solid rgba(148,163,184,.28); border-radius: 18px; background: rgba(9,11,17,.98); box-shadow: 0 22px 48px rgba(0,0,0,.5); overflow: hidden; } .panel[hidden] { display: none; } .head { display: flex; align-items: flex-start; gap: 10px; padding: 14px 14px 10px; border-bottom: 1px solid rgba(148,163,184,.14); } .head .meta { flex: 1; min-width: 0; } .title { margin: 0; font-size: 14px; font-weight: 800; color: #f8fafc; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; } .sub { margin-top: 3px; font-size: 11px; font-weight: 600; color: #94a3b8; } .body { padding: 12px 14px; overflow-y: auto; } .section + .section { margin-top: 14px; } .section h4 { margin: 0 0 8px; font-size: 11px; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; color: #94a3b8; } .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: 7px; } .card { position: relative; display: flex; flex-direction: column; align-items: flex-start; gap: 2px; min-height: 58px; padding: 9px 10px; border: 1px solid rgba(148,163,184,.22); border-radius: 12px; background: rgba(30,41,59,.55); color: #f8fafc; font: inherit; text-align: left; cursor: pointer; transition: border-color .15s ease, background .15s ease, transform .15s ease; } .card:hover { border-color: #7771ff; background: rgba(99,102,241,.16); transform: translateY(-1px); } .card .q { font-size: 15px; font-weight: 850; } .card .s { font-size: 11px; font-weight: 600; color: #94a3b8; } .card .top { display: flex; flex-wrap: wrap; align-items: center; gap: 2px 6px; } .card .badge { padding: 1px 5px; border-radius: 6px; background: rgba(61,214,245,.16); color: #67e8f9; font-size: 9px; font-weight: 800; white-space: nowrap; } .card.locked { border-style: dashed; background: rgba(245,158,11,.06); } .card.locked .q { color: #e2e8f0; } .card.locked .s { color: #fbbf24; } .note { margin: 12px 0 0; font-size: 11px; color: #94a3b8; } .note a, .foot a { color: #a5b4fc; } .foot { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 10px 14px; border-top: 1px solid rgba(148,163,184,.14); font-size: 11px; color: #94a3b8; } .status-line { font-size: 12px; color: #cbd5e1; text-align: center; font-variant-numeric: tabular-nums; min-height: 1.4em; } .steps { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); list-style: none; margin: 4px 0 12px; padding: 0; font-size: 10.5px; font-weight: 750; } .steps li { position: relative; display: flex; flex-direction: column; align-items: center; gap: 5px; color: #64748b; } .steps li + li::after { content: ""; position: absolute; top: calc(5px - 1px); right: calc(50% + 9px); width: calc(100% - 18px); height: 2px; border-radius: 2px; background: currentColor; opacity: .5; } .steps i { display: block; width: 10px; height: 10px; border-radius: 50%; border: 2px solid currentColor; } .steps [data-state=done] { color: #cbd5e1; } .steps [data-state=done] i { background: currentColor; } .steps [data-state=current] { color: #8b87ff; } .steps [data-state=current] i { background: currentColor; animation: pulse 1.3s ease-in-out infinite; } .track { position: relative; height: 8px; margin: 0 0 9px; border-radius: 99px; background: rgba(148,163,184,.18); overflow: hidden; } .bar { position: relative; height: 100%; width: 0; border-radius: 99px; background: linear-gradient(90deg,#6862f6,#3dd6f5); transition: width .95s linear; overflow: hidden; } .bar.waiting { width: 36%; transition: none; animation: slide 1.35s cubic-bezier(.45,0,.55,1) infinite; } .bar.working::after, .bar.measured::after { content: ""; position: absolute; inset: 0; } .bar.working::after { background-image: repeating-linear-gradient(-45deg, rgba(255,255,255,.3) 0 8px, rgba(255,255,255,0) 8px 16px); background-size: 22.6px 22.6px; animation: stripes .7s linear infinite; } .bar.measured::after { background: linear-gradient(90deg, rgba(255,255,255,0), rgba(255,255,255,.38), rgba(255,255,255,0)); transform: translateX(-100%); animation: sheen 1.6s ease-in-out infinite; } .row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } .row .btn { flex: 1 1 auto; min-height: 40px; } .error { padding: 10px 12px; border: 1px solid rgba(248,113,113,.35); border-radius: 12px; background: rgba(127,29,29,.25); color: #fecaca; font-size: 12.5px; } .result-ok { padding: 10px 12px; border: 1px solid rgba(74,222,128,.32); border-radius: 12px; background: rgba(20,83,45,.25); color: #bbf7d0; font-size: 12.5px; } .spinner { width: 26px; height: 26px; margin: 10px auto; border: 3px solid rgba(148,163,184,.25); border-top-color: #7771ff; border-radius: 50%; animation: spin .8s linear infinite; } .center { text-align: center; color: #94a3b8; font-size: 12px; } @keyframes slide { 0% { transform: translateX(-105%); } 100% { transform: translateX(285%); } } @keyframes stripes { to { background-position: 22.6px 0; } } @keyframes sheen { to { transform: translateX(100%); } } @keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(139,135,255,.55); } 100% { box-shadow: 0 0 0 7px rgba(139,135,255,0); } } @keyframes spin { to { transform: rotate(360deg); } } @media (max-width: 380px) { .brand { min-width: 72px; } .brand small { max-width: 84px; } .btn, .action { padding: 0 11px; } } @media (prefers-reduced-motion: reduce) { *, *::before, *::after { animation: none !important; transition: none !important; } .bar.waiting { width: 100%; opacity: .45; } } `; function el(tag, attrs, children) { const node = document.createElement(tag); for (const [key, value] of Object.entries(attrs || {})) { if (value === undefined || value === null || value === false) continue; if (key === "class") node.className = value; else if (key === "text") node.textContent = value; else if (key.startsWith("on") && typeof value === "function") node.addEventListener(key.slice(2), value); else node.setAttribute(key, value === true ? "" : String(value)); } for (const child of [].concat(children || [])) if (child) node.append(child); return node; } function externalLink(href, text, className) { const a = el("a", {class: className, href, target: "_blank", rel: "noopener noreferrer", text}); a.referrerPolicy = "no-referrer"; return a; } function createAction(matchedPage, currentUrl, output) { const anchor = externalLink(targetUrl(matchedPage, currentUrl, output), output === "playlist" ? "Open playlist" : output.toUpperCase(), "action action-" + output); anchor.setAttribute("aria-label", output === "playlist" ? "Open this YouTube playlist in NoBsDL" : "Open this " + matchedPage.name + " page in NoBsDL with " + output.toUpperCase() + " first"); return anchor; } function makeWidget(matchedPage, currentUrl) { const host = el("div", {id: WIDGET_ID, "data-platform": matchedPage.platform}); for (const [prop, value] of [["position", "fixed"], ["right", "16px"], ["bottom", "16px"], ["display", "block"], ["z-index", "2147483647"]]) { host.style.setProperty(prop, value, "important"); } const shadow = host.attachShadow({mode: "open"}); const root = el("div", {class: "root"}); shadow.append(el("style", {text: STYLE}), root); const inPage = matchedPage.workflow !== "playlist" && !!gmRequestFunction(); const primaryOutput = matchedPage.outputs[0]; const state = {busy: false, keepView: false, open: false, collapsed: readCollapsed(), token: 0, info: null, choices: null, dockLabel: "", primaryButton: null}; const panel = el("section", {class: "panel", hidden: true, role: "dialog", "aria-label": "NoBsDL download for " + matchedPage.name}); const dock = el("div", {class: "dock", role: "group", "aria-label": "NoBsDL shortcuts for " + matchedPage.name}); root.append(panel, dock); function renderDock() { dock.replaceChildren(); if (state.collapsed) { dock.style.padding = "0"; dock.style.border = "0"; dock.style.background = "transparent"; dock.style.boxShadow = "none"; dock.append(el("button", { class: "btn btn-primary collapsed-pill", type: "button", "aria-label": "Show NoBsDL download button", text: "↓", onclick: () => { state.collapsed = false; writeCollapsed(false); renderDock(); }, })); return; } dock.removeAttribute("style"); const brand = el("div", {class: "brand"}, [el("strong", {text: "↓ NoBsDL"}), el("small", {text: matchedPage.name})]); const actions = el("div", {class: "actions"}); if (inPage) { state.primaryButton = el("button", { class: "btn btn-primary", type: "button", "aria-expanded": String(state.open), "aria-haspopup": "dialog", text: state.dockLabel || (matchedPage.outputs === AUDIO_ONLY ? "Download MP3" : "Download"), onclick: () => (state.open ? closePanel() : openPanel()), }); actions.append(state.primaryButton); } else { for (const output of matchedPage.outputs) actions.append(createAction(matchedPage, currentUrl, output)); } actions.append(el("button", { class: "btn btn-icon", type: "button", title: "Minimize", "aria-label": "Minimize the NoBsDL button", text: "–", onclick: () => { closePanel(); state.collapsed = true; writeCollapsed(true); renderDock(); }, })); dock.append(brand, actions); } function header(titleText, subText) { return el("div", {class: "head"}, [ el("div", {class: "meta"}, [el("p", {class: "title", text: titleText}), subText ? el("div", {class: "sub", text: subText}) : null]), el("button", {class: "btn btn-icon", type: "button", "aria-label": "Close", text: "✕", onclick: closePanel}), ]); } function footer() { return el("div", {class: "foot"}, [ el("span", {text: "No popups. No forced redirects."}), externalLink(targetUrl(matchedPage, currentUrl, primaryOutput), "Open on NoBsDL ↗"), ]); } function show(children) { panel.replaceChildren(...children); panel.hidden = !state.open; } function openPanel() { state.open = true; renderDock(); if (state.keepView) panel.hidden = false; // progress, result or error stays put else if (state.choices) showChoices(); else loadFormats(); } // Closing only hides the panel: a running job keeps going, the dock // button shows its progress, and the file is still saved when ready. function closePanel() { state.open = false; panel.hidden = true; renderDock(); } function setDockLabel(label) { state.dockLabel = label; if (state.primaryButton) state.primaryButton.textContent = label || (matchedPage.outputs === AUDIO_ONLY ? "Download MP3" : "Download"); } function showError(error, retry) { state.keepView = true; const actions = el("div", {class: "row"}); if (retry) actions.append(el("button", {class: "btn btn-primary", type: "button", text: error && error.refreshFormats ? "Refresh formats" : "Try again", onclick: retry})); if (error && error.supporterCta) actions.append(externalLink(NOBSDL_ORIGIN + "/supporter", "Supporter options", "btn btn-ghost")); show([header(state.info ? state.info.title || matchedPage.name : matchedPage.name), el("div", {class: "body"}, [el("div", {class: "error", role: "alert", text: (error && error.message) || "Something went wrong."}), actions]), footer()]); } async function loadFormats() { const token = ++state.token; state.keepView = false; show([header(matchedPage.name, "Finding the real formats…"), el("div", {class: "body"}, [el("div", {class: "spinner", "aria-hidden": "true"}), el("p", {class: "center", text: "Checking this link on NoBsDL"})]), footer()]); try { const data = await apiGet("/api/video-info?url=" + encodeURIComponent(sourceUrlFor(currentUrl, matchedPage)) + "&intent=" + primaryOutput, 60000); if (token !== state.token) return; state.info = data; state.choices = normalizeChoices(data); showChoices(); } catch (error) { if (token !== state.token) return; showError(error, loadFormats); } } function choiceCard(choice) { const badge = qualityBadge(choice); const sub = choice.locked ? "🔒 Supporter" : [formatSize(choice.sizeMb), choice.fps ? choice.fps + " fps" : ""].filter(Boolean).join(" · ") || (choice.family === "audio" ? "MP3" : "MP4"); const label = choice.title + " " + choice.format.toUpperCase() + (choice.sizeMb ? ", " + formatSize(choice.sizeMb) : "") + (choice.locked ? ", needs NoBsDL Supporter" : ""); return el("button", { class: "card" + (choice.locked ? " locked" : ""), type: "button", "aria-label": label, onclick: () => (choice.locked ? window.open(NOBSDL_ORIGIN + "/supporter", "_blank", "noopener,noreferrer") : startJob(choice)), }, [el("span", {class: "top"}, [el("span", {class: "q", text: choice.title}), badge ? el("span", {class: "badge", text: badge}) : null]), el("span", {class: "s", text: sub})]); } function showChoices() { state.keepView = false; const info = state.info || {}; const choices = state.choices || []; const families = [["video", "Video · MP4"], ["audio", "Audio · MP3"]]; if (primaryOutput === "mp3") families.reverse(); const sections = families .map(([family, heading]) => [heading, choices.filter((c) => c.family === family)]) .filter(([, list]) => list.length) .map(([heading, list]) => el("div", {class: "section"}, [el("h4", {text: heading}), el("div", {class: "grid"}, list.map(choiceCard))])); const body = el("div", {class: "body"}, sections.length ? sections : [el("p", {class: "center", text: "No downloadable format was found for this link."})]); if (choices.some((c) => c.locked)) { body.append(el("p", {class: "note"}, ["🔒 Locked qualities need a paid ", externalLink(NOBSDL_ORIGIN + "/supporter", "NoBsDL Supporter key"), ". Everything else is free."])); } else if (info.is_supporter) { body.append(el("p", {class: "note", text: "✓ Supporter active on this browser."})); } const sub = [matchedPage.name, formatDuration(info.duration)].filter(Boolean).join(" · "); show([header(info.title || matchedPage.name, sub), body, footer()]); const first = panel.querySelector(".card:not(.locked)"); if (first) first.focus({preventScroll: true}); } async function startJob(choice) { const token = ++state.token; state.busy = true; state.keepView = true; const title = (state.info && state.info.title) || matchedPage.name; const steps = el("ol", {class: "steps", "aria-label": "Download progress"}, ["Queue", "Download", "Process", "Ready"].map((label) => el("li", {}, [el("i", {"aria-hidden": "true"}), label]))); const bar = el("div", {class: "bar waiting"}); const track = el("div", {class: "track", role: "progressbar", "aria-label": "Download progress", "aria-valuemin": "0", "aria-valuemax": "100"}, [bar]); const line = el("p", {class: "status-line", "aria-live": "polite"}); const extra = el("div"); show([header(title, choice.title + " · " + choice.format.toUpperCase()), el("div", {class: "body"}, [steps, track, line, extra]), footer()]); let phase = "requesting"; let since = Date.now(); let progress = null; let renderedPhase = null; const render = () => { const view = progressView(phase, progress, Math.floor((Date.now() - since) / 1000), matchedPage.name.replace(/ (Shorts|playlist)$/, ""), choice.format); Array.from(steps.children).forEach((item, index) => { item.dataset.state = index < view.step || phase === "ready" ? "done" : index === view.step ? "current" : "pending"; }); if (renderedPhase !== phase) { bar.style.transition = "none"; void bar.offsetWidth; setTimeout(() => { bar.style.transition = ""; }, 50); } renderedPhase = phase; bar.className = "bar " + view.mode; bar.style.width = view.mode === "measured" ? (view.fraction * 100).toFixed(1) + "%" : view.mode === "waiting" ? "" : "100%"; if (view.mode === "measured" || view.mode === "done") track.setAttribute("aria-valuenow", String(view.mode === "done" ? 100 : Math.floor(view.fraction * 100))); else track.removeAttribute("aria-valuenow"); line.textContent = view.text; setDockLabel(phase === "ready" ? "✓ Saved" : view.mode === "measured" ? Math.floor(view.fraction * 100) + "%" : "…"); }; const setPhase = (next, nextProgress) => { if (PHASE_ORDER.indexOf(next) < PHASE_ORDER.indexOf(phase)) next = phase; if (next !== phase) { phase = next; since = Date.now(); } if (nextProgress && typeof nextProgress === "object") { const prev = progress && typeof progress.fraction === "number" ? progress.fraction : null; progress = Object.assign({}, nextProgress); if (prev !== null && typeof progress.fraction === "number" && progress.fraction < prev) progress.fraction = prev; } render(); }; render(); const ticker = setInterval(() => { if (token === state.token) render(); else clearInterval(ticker); }, 1000); try { const sourceUrl = sourceUrlFor(currentUrl, matchedPage); const job = await apiGet(downloadParams(choice, sourceUrl), 30000); if (token !== state.token) return; const statusPath = safeStatusPath(job.status_url); if (!statusPath) throw Object.assign(new Error("NoBsDL returned an unexpected answer. Try again."), {status: 0}); const deadline = Date.now() + POLL_DEADLINE_MS; let misses = 0; while (token === state.token && Date.now() < deadline) { let status; try { status = await apiGet(statusPath, 20000); misses = 0; } catch (error) { // A dropped poll is not a failed job; the server keeps working. if (!error.retryable || ++misses >= 5) throw error; await new Promise((r) => setTimeout(r, 1000 * misses)); continue; } if (token !== state.token) return; if (status.status === "ready") { const fileUrl = safeFileUrl(status.direct_url); if (!fileUrl) throw Object.assign(new Error("NoBsDL returned an unexpected file link."), {status: 0}); setPhase("ready", null); state.busy = false; saveFile(fileUrl, extra); return; } setPhase(status.phase || "queued", status.progress || null); await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); } if (token === state.token) throw Object.assign(new Error("This is taking unusually long. Try again or open it on NoBsDL."), {status: 0}); } catch (error) { clearInterval(ticker); if (token !== state.token) return; state.busy = false; setDockLabel(""); if (!state.open) { state.open = true; renderDock(); } // surface the failure showError(error, error && error.refreshFormats ? () => { state.choices = null; loadFormats(); } : () => startJob(choice)); } finally { if (phase === "ready") clearInterval(ticker); } } // The file response is Content-Disposition: attachment, so navigating to // it saves the file and leaves this page in place. function saveFile(fileUrl, container) { // Attached to the document (not the shadow root), so it still works if // the user already navigated this single-page app elsewhere. const anchor = el("a", {href: fileUrl, rel: "noopener noreferrer"}); anchor.referrerPolicy = "no-referrer"; anchor.style.display = "none"; document.documentElement.append(anchor); anchor.click(); anchor.remove(); container.replaceChildren( el("div", {class: "result-ok", role: "status", text: "✅ File ready. Your browser is saving it — check your downloads."}), el("div", {class: "row"}, [ el("button", {class: "btn btn-ghost", type: "button", text: "Download another format", onclick: () => { setDockLabel(""); showChoices(); }}), externalLink(fileUrl, "Didn’t start? Save again", "btn btn-ghost"), ]), ); } shadow.addEventListener("keydown", (event) => { if (event.key === "Escape" && state.open) closePanel(); }); renderDock(); return host; } function boot() { let widget = null; let signature = ""; function sync() { const currentUrl = location.href; const matchedPage = pageFor(currentUrl); if (!matchedPage) { if (widget && widget.isConnected) widget.remove(); signature = ""; return; } const nextSignature = currentUrl + "|" + matchedPage.platform + "|" + matchedPage.workflow; if (!widget || signature !== nextSignature) { if (widget && widget.isConnected) widget.remove(); widget = makeWidget(matchedPage, currentUrl); signature = nextSignature; } if (!widget.isConnected && document.body) document.body.appendChild(widget); } sync(); window.addEventListener("popstate", sync); window.addEventListener("hashchange", sync); setInterval(sync, 750); } const api = { NOBSDL_ORIGIN, VIDEO_AUDIO, pageFor, realYoutubePlaylist, sourceUrlFor, targetUrl, normalizeChoices, downloadParams, progressView, safeFileUrl, safeStatusPath, formatSize, }; if (typeof module === "object" && module.exports) { module.exports = api; return; } boot(); })();