// ==UserScript==
// @name HLS Download Button (no-DRM)
// @namespace hls-dl-btn
// @version 1.2
// @author sharmanhall
// @description Adds a Download button for HLS (.m3u8) streams; streams segments to disk. Falls back to ffmpeg cmd if encrypted.
// @match *://*/*
// @match *://*.tnmr.org/*
// @match *://tnmr.org/*
// @grant GM_addStyle
// @grant GM_xmlhttpRequest
// @connect *
// @license MIT
// @downloadURL https://update.greasyfork.icu/scripts/550536/HLS%20Download%20Button%20%28no-DRM%29.user.js
// @updateURL https://update.greasyfork.icu/scripts/550536/HLS%20Download%20Button%20%28no-DRM%29.meta.js
// ==/UserScript==
(function () {
'use strict';
// ---------- UI ----------
GM_addStyle(`
#hlsdl-panel{position:fixed;right:16px;bottom:16px;z-index:999999;font-family:system-ui,Segoe UI,Arial,sans-serif}
#hlsdl-btn{background:#1bd760;color:#000;border:0;border-radius:999px;padding:10px 14px;
font-weight:700;box-shadow:0 6px 16px rgba(0,0,0,.25);cursor:pointer}
#hlsdl-btn:hover{filter:brightness(0.95)}
#hlsdl-log{position:fixed;right:16px;bottom:64px;width:340px;max-height:40vh;overflow:auto;
background:#111;color:#0f0;border:1px solid #333;border-radius:10px;padding:10px;font:12px/1.35 ui-monospace,Menlo,monospace;display:none;white-space:pre-wrap}
#hlsdl-progress{height:8px;background:#2a2a2a;border-radius:6px;overflow:hidden;margin-top:8px}
#hlsdl-bar{height:100%;width:0%;background:linear-gradient(90deg,#1bd760,#15b34c)}
`);
const panel = document.createElement('div');
panel.id = 'hlsdl-panel';
panel.innerHTML = `
`;
document.documentElement.appendChild(panel);
const logBox = panel.querySelector('#hlsdl-log');
const lines = panel.querySelector('#hlsdl-lines');
const bar = panel.querySelector('#hlsdl-bar');
function log(msg, isErr = false) {
logBox.style.display = 'block';
const p = document.createElement('div');
p.textContent = `[${new Date().toLocaleTimeString()}] ${msg}`;
if (isErr) p.style.color = '#f55';
lines.appendChild(p);
lines.scrollTop = lines.scrollHeight;
}
function setProgress(pct) { bar.style.width = `${Math.max(0, Math.min(100, pct))}%`; }
// ---------- Capture m3u8 URLs seen on the page ----------
const seen = new Set();
let lastM3U8 = '';
// 1) anchors in DOM
const scanDOM = () => {
document.querySelectorAll('a[href*=".m3u8"]').forEach(a => {
try {
const u = new URL(a.href, location.href).href;
if (!seen.has(u)) { seen.add(u); lastM3U8 = u; }
} catch {}
});
};
const mo = new MutationObserver(scanDOM);
mo.observe(document.documentElement, { childList: true, subtree: true });
scanDOM();
// 2) intercept fetch
const origFetch = window.fetch;
window.fetch = async function(input, init) {
const url = typeof input === 'string' ? input : (input && input.url);
if (url && /\.m3u8(\b|[?#])/i.test(url)) { lastM3U8 = new URL(url, location.href).href; seen.add(lastM3U8); }
return origFetch.apply(this, arguments);
};
// 3) intercept XHR
const origOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url) {
try {
if (url && /\.m3u8(\b|[?#])/i.test(url)) {
const u = new URL(url, location.href).href;
lastM3U8 = u; seen.add(u);
}
} catch {}
return origOpen.apply(this, arguments);
};
// ---------- Helpers ----------
const gmText = (url, headers = {}) => new Promise((res, rej) => {
GM_xmlhttpRequest({ method: 'GET', url, headers, onload: r => r.status >= 200 && r.status < 300 ? res(r.responseText) : rej(new Error(`HTTP ${r.status}`)), onerror: e => rej(e) });
});
const gmAB = (url, headers = {}) => new Promise((res, rej) => {
GM_xmlhttpRequest({ method: 'GET', url, headers, responseType: 'arraybuffer', onload: r => r.status >= 200 && r.status < 300 ? res(r.response) : rej(new Error(`HTTP ${r.status}`)), onerror: e => rej(e) });
});
const resolveURL = (base, rel) => new URL(rel, base).href;
function pickFileName(m3u8Url, ext = 'ts') {
try {
const u = new URL(m3u8Url);
const host = u.hostname.replace(/^www\./,'').replace(/[^a-z0-9.-]/gi,'_');
const stem = (u.pathname.split('/').pop() || 'stream').replace(/\.m3u8.*$/i,'');
return `${host}_${stem}.${ext}`;
} catch { return `hls_${Date.now()}.${ext}`; }
}
function parseMaster(playlist, baseURL) {
// returns highest BANDWIDTH variant URL
const lines = playlist.split(/\r?\n/);
let best = { bw: -1, url: '' };
for (let i=0;i best.bw) best = { bw: bwi, url: cand };
}
}
}
return best.url;
}
function parseMedia(playlist, baseURL) {
const lines = playlist.split(/\r?\n/);
const segs = [];
let initURI = null;
let encrypted = false;
for (let i=0;i {
// Try to prefill with the most recently seen .m3u8
scanDOM();
const prefill = lastM3U8 || '';
const url = prompt('HLS .m3u8 URL to download:', prefill);
if (!url) return;
logBox.style.display = 'block';
lines.innerHTML = ''; setProgress(0);
await downloadHLS(url.trim());
});
})();