// ==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
// ==/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 = `
<button id="hlsdl-btn">⬇ Download HLS</button>
<div id="hlsdl-log"><div id="hlsdl-lines"></div><div id="hlsdl-progress"><div id="hlsdl-bar"></div></div></div>
`;
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<lines.length;i++) {
if (lines[i].startsWith('#EXT-X-STREAM-INF')) {
const bw = /BANDWIDTH=(\d+)/.exec(lines[i]);
const next = lines[i+1] && lines[i+1].trim();
if (next && !next.startsWith('#')) {
const cand = resolveURL(baseURL, next);
const bwi = bw ? parseInt(bw[1],10) : 0;
if (bwi > 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<lines.length;i++) {
const L = lines[i].trim();
if (!L) continue;
if (L.startsWith('#EXT-X-KEY') && !/METHOD=NONE/.test(L)) encrypted = true;
if (L.startsWith('#EXT-X-MAP')) {
const m = /URI="([^"]+)"/.exec(L);
if (m) initURI = resolveURL(baseURL, m[1]);
}
if (L.startsWith('#')) continue;
segs.push(resolveURL(baseURL, L));
}
return { segs, initURI, encrypted };
}
async function downloadHLS(m3u8Url) {
try {
log(`Fetching playlist…`);
const hdrs = { 'Referer': location.href, 'Origin': location.origin };
const masterTxt = await gmText(m3u8Url, hdrs);
const base = m3u8Url.replace(/[^/?#]+(\?.*)?$/,''); // directory
// Master or media?
let mediaURL = m3u8Url;
if (/^#EXTM3U/.test(masterTxt) && /#EXT-X-STREAM-INF/.test(masterTxt)) {
mediaURL = parseMaster(masterTxt, base);
if (!mediaURL) throw new Error('Could not find a variant in master playlist.');
}
const mediaTxt = mediaURL === m3u8Url ? masterTxt : await gmText(mediaURL, hdrs);
const { segs, initURI, encrypted } = parseMedia(mediaTxt, mediaURL.replace(/[^/?#]+(\?.*)?$/,''));
if (!segs.length) throw new Error('No segments found.');
if (encrypted) {
log('Detected encrypted HLS (EXT-X-KEY). Using ffmpeg fallback…', true);
const ff = `ffmpeg -y -headers "Referer: ${location.href}\\r\\nOrigin: ${location.origin}\\r\\n" -i "${mediaURL}" -c copy "${pickFileName(mediaURL, 'mp4')}"`;
await navigator.clipboard.writeText(ff);
alert('Stream appears encrypted.\nI copied an ffmpeg command to your clipboard.\nPaste it in a terminal with ffmpeg installed.');
return;
}
const isFmp4 = /#EXT-X-MAP/.test(mediaTxt) || /\.m4s(\b|[?#])/.test(segs[0]);
const suggested = pickFileName(mediaURL, isFmp4 ? 'mp4' : 'ts');
if (!('showSaveFilePicker' in window)) {
alert('Your browser is missing showSaveFilePicker().\nUse Chrome/Brave/Edge ≥ 86, or use the ffmpeg command fallback.');
return;
}
const fh = await window.showSaveFilePicker({
suggestedName: suggested,
types: [{ description: isFmp4 ? 'MP4' : 'MPEG-TS', accept: { 'video/*': [`.${isFmp4 ? 'mp4' : 'ts'}`] } }]
});
const ws = await fh.createWritable();
let done = 0;
const total = segs.length + (initURI ? 1 : 0);
log(`Saving ${total} part(s) to ${suggested}…`);
if (initURI) {
const ab = await gmAB(initURI, hdrs);
await ws.write(new Uint8Array(ab));
done++; setProgress((done / total) * 100);
}
for (let i = 0; i < segs.length; i++) {
const ab = await gmAB(segs[i], hdrs);
await ws.write(new Uint8Array(ab));
done++;
if (i % 5 === 0) log(`Segment ${i+1}/${segs.length}`);
setProgress((done / total) * 100);
}
await ws.close();
log('✅ Done. File saved.');
setProgress(100);
} catch (err) {
console.error(err);
log(`Error: ${err.message || err}`, true);
alert(`HLS download error:\n${err.message || err}`);
}
}
// ---------- Button click ----------
panel.querySelector('#hlsdl-btn').addEventListener('click', async () => {
// 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());
});
})();