Single + batch downloads with per-video quality and format (MP4, or MKV with subtitles, metadata and cover art), resume and auto cleanup.
// ==UserScript==
// @name xHamster Downloader
// @namespace xh-dl
// @version 5.2.0
// @description Single + batch downloads with per-video quality and format (MP4, or MKV with subtitles, metadata and cover art), resume and auto cleanup.
// @license GPLv2
// @match https://xhamster.com/*
// @match https://*.xhamster.com/*
// @run-at document-start
// @grant none
// ==/UserScript==
(() => {
'use strict';
const log = (...a) => console.info('%c[XH-DL]', 'color:#f90;font-weight:bold', ...a);
// ======================= tunables =======================
const PREFER_CODEC = 'h264'; // tie-breaker between streams of the same resolution
const CONCURRENCY = 4; // parallel segment fetches per video
const SAVED_TTL_MS = 10 * 60 * 1000; // safety net for finished temp files left behind (tab closed early)
const STALE_MS = 7 * 24 * 60 * 60 * 1000; // unfinished (resumable) downloads untouched this long are removed
const INFO_TTL_MS = 20 * 60 * 1000; // stream links expire; re-read a video page older than this
const PREPARE_AT = 0.95; // next video locks and shows "Preparing…" at this progress
const SUB_LANGS = ['en']; // MKV subtitle tracks: original language + these
// ======================= settings (stored in this site's localStorage) =======================
const QUALITY_OPTIONS = [
['highest', 'Highest available'], ['2160p', '2160p (4K)'], ['1440p', '1440p'], ['1080p', '1080p'],
['720p', '720p'], ['480p', '480p'], ['240p', '240p'], ['144p', '144p'],
];
const DEFAULTS = { quality: 'highest', format: 'mkv' };
const SETTINGS_KEY = 'xhdl:settings';
function loadSettings() {
try { return { ...DEFAULTS, ...(JSON.parse(localStorage.getItem(SETTINGS_KEY) || 'null') || {}) }; } catch { return { ...DEFAULTS }; }
}
function saveSettings(s) {
try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(s)); } catch (e) { log('could not save settings', e); }
}
let settings = loadSettings();
// ======================= 1. capture playlist URLs the player requests =======================
const seen = new Set();
const note = (u) => {
try {
const s = String(u);
if (/\.m3u8(\?|$)/i.test(s)) seen.add(new URL(s, location.href).href);
} catch {}
};
const IS_VIDEO_PAGE = /^\/videos\//.test(location.pathname);
const nativeFetch = window.fetch.bind(window);
// Only video pages need the playlist capture; list pages (home, search) are left untouched.
if (IS_VIDEO_PAGE) {
window.fetch = function (input) {
note(typeof input === 'string' ? input : input && input.url);
return nativeFetch.apply(this, arguments);
};
const nativeOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url) {
note(url);
return nativeOpen.apply(this, arguments);
};
}
// ======================= 2. helpers =======================
const abortErr = () => new DOMException('Cancelled', 'AbortError');
const sleep = (ms, signal) => new Promise((res, rej) => {
const t = setTimeout(res, ms);
signal?.addEventListener('abort', () => { clearTimeout(t); rej(abortErr()); }, { once: true });
});
const escHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const fmtSize = (b) => (!b ? '—' : b < 1024 ** 3 ? `${Math.max(1, Math.round(b / 1048576))} MB` : `${(b / 1024 ** 3).toFixed(2)} GB`);
async function getText(u, signal) {
const r = await nativeFetch(u, { credentials: 'omit', signal });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.text();
}
async function fetchBuffer(url, signal) {
for (let attempt = 0; ; attempt++) {
try {
const r = await nativeFetch(url, { credentials: 'omit', signal });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return await r.arrayBuffer();
} catch (e) {
if (e.name === 'AbortError' || attempt >= 3) throw e;
await sleep(800 * (attempt + 1), signal);
}
}
}
function parseMaster(txt, base) {
const lines = txt.split(/\r?\n/);
const out = [];
for (let i = 0; i < lines.length; i++) {
const l = lines[i];
if (!l.startsWith('#EXT-X-STREAM-INF')) continue;
const bw = +((/[:,]BANDWIDTH=(\d+)/.exec(l) || [])[1] || 0);
const avg = +((/AVERAGE-BANDWIDTH=(\d+)/.exec(l) || [])[1] || 0);
const res = /RESOLUTION=(\d+)x(\d+)/.exec(l);
const name = /NAME="([^"]+)"/.exec(l);
let j = i + 1;
while (j < lines.length && (!lines[j].trim() || lines[j].startsWith('#'))) j++;
if (lines[j]) {
const h = res ? +res[2] : +((/(\d{3,4})p/.exec(name?.[1] || '') || [])[1] || 0);
out.push({ url: new URL(lines[j].trim(), base).href, bw, avg, h, label: name ? name[1] : h ? `${h}p` : 'best' });
}
}
return out;
}
function parseMedia(txt, base) {
let init = null;
let encrypted = false;
const segs = [];
for (const raw of txt.split(/\r?\n/)) {
const l = raw.trim();
if (!l) continue;
if (l.startsWith('#EXT-X-MAP')) {
const m = /URI="([^"]+)"/.exec(l);
if (m) init = new URL(m[1], base).href;
} else if (l.startsWith('#EXT-X-KEY') && !/METHOD=NONE/i.test(l)) {
encrypted = true;
} else if (!l.startsWith('#')) {
segs.push(new URL(l, base).href);
}
}
return { init, segs, encrypted };
}
const codecScore = (url) => (url.includes(`.${PREFER_CODEC}.`) ? 1 : 0);
async function getVariants(candidates, signal) {
const uniq = [...new Set(candidates)];
if (!uniq.length) throw new Error('No stream playlist found. Start the video for a second, then retry.');
const all = [];
for (const url of uniq) {
try {
const txt = await getText(url, signal);
if (txt.includes('#EXT-X-STREAM-INF')) {
all.push(...parseMaster(txt, url));
} else if (txt.includes('#EXTINF')) {
const h = +((/(\d{3,4})p/.exec(url) || [])[1] || 0);
all.push({ url, bw: 0, avg: 0, h, label: h ? `${h}p` : 'best', mediaText: txt });
}
} catch (e) {
if (e.name === 'AbortError') throw e;
log('skipping playlist', url, e.message);
}
}
if (!all.length) throw new Error('Could not read any stream playlist.');
all.sort((a, b) => b.h - a.h || codecScore(b.url) - codecScore(a.url) || b.bw - a.bw);
const labels = new Set();
return all.filter((v) => !labels.has(v.label) && labels.add(v.label));
}
function chooseVariant(variants, pref) {
if (!variants?.length) throw new Error('No qualities available.');
if (!pref || pref === 'highest') return variants[0];
const exact = variants.find((v) => v.label === pref);
if (exact) return exact;
const want = parseInt(pref, 10) || 0;
return variants.find((v) => v.h && v.h <= want) || variants[variants.length - 1];
}
// Estimated file size from the stream's bitrate and the video's duration.
function estSize(variants, label, duration) {
const v = variants?.find((x) => x.label === label);
const bw = v ? v.avg || v.bw : 0;
return bw && duration ? Math.round((bw * duration) / 8) : 0;
}
const cleanName = (t) =>
(t || 'video').replace(/[\\/:*?"<>|\u0000-\u001f]+/g, '').replace(/\s+/g, ' ').trim().slice(0, 120) || 'video';
function vidFromUrl(u) {
const m = /-(xh[A-Za-z0-9]+)(?:[/?#]|$)/.exec(u);
if (m) return m[1];
try { return new URL(u, location.href).pathname.split('/').filter(Boolean).pop(); } catch { return 'video'; }
}
let busyCount = 0;
const onBeforeUnload = (e) => { e.preventDefault(); e.returnValue = ''; };
function guardUnload(delta) {
busyCount = Math.max(0, busyCount + delta);
if (busyCount) window.addEventListener('beforeunload', onBeforeUnload);
else window.removeEventListener('beforeunload', onBeforeUnload);
}
// ======================= 3. video page info =======================
function parseInitials(html) {
const m = /window\.initials\s*=\s*(\{[\s\S]*?\});\s*<\/script>/.exec(html);
if (!m) return null;
try { return JSON.parse(m[1]); } catch { return null; }
}
function metaFromInitials(init, url) {
const vm = init?.videoModel || {};
const ve = init?.videoEntity || {};
const rm = init?.ratingComponent?.ratingModel || ve.rating || {};
const tagList = init?.videoTagsComponent?.tags || [];
const names = (f) => tagList.filter(f).map((t) => t.name).filter(Boolean);
const likes = +rm.likes || 0;
const dislikes = +rm.dislikes || 0;
const people = [...new Set([
...names((t) => t.isPornstar || t.isCreator || t.isCeleb),
...(ve.pornstarModels || []).map((p) => p.nameNormalized || p.name),
].filter(Boolean))];
// Subtitles live under the player *plugin* settings on this site.
const subsSrc = init?.xplayerPluginSettings?.subtitles?.tracks || init?.xplayerSettings?.subtitles?.tracks || [];
return {
id: vm.id || ve.id || '',
idHash: vm.idHash || ve.idHashSlug || '',
title: vm.title || ve.title || '',
uploader: vm.author?.name || '',
uploaderUrl: vm.author?.pageURL || '',
creator: vm.landing?.name || '',
creatorUrl: vm.landing?.link || '',
created: vm.created || 0,
duration: vm.duration || ve.duration || 0,
description: vm.description || ve.description || '',
categories: names((t) => t.isCategory),
tags: names((t) => t.isTag),
channels: names((t) => t.isChannel || t.isBrand),
people,
views: vm.views ?? ve.views ?? null,
comments: vm.comments ?? ve.commentsCount ?? null,
likes,
dislikes,
stars: likes + dislikes ? Math.round((likes / (likes + dislikes)) * 50) / 10 : null,
likePercent: likes + dislikes ? Math.round((likes / (likes + dislikes)) * 100) : null,
language: ve.titleLocalized?.language || '',
orientation: ve.orientation || '',
resolution: Array.isArray(vm.resolution) ? vm.resolution.join('x') : '',
subs: subsSrc
.map((t) => ({ label: t.label, lang: t.lang, url: t.urls?.vtt, original: !!t.isOriginal }))
.filter((t) => t.url),
thumb: vm.thumbURL || '',
url: vm.pageURL || url,
};
}
async function fetchVideoInfo(url, signal) {
const r = await nativeFetch(url, { credentials: 'include', signal });
if (!r.ok) throw new Error(`Video page HTTP ${r.status}`);
const html = await r.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const candidates = new Set();
doc.querySelectorAll('link[rel="preload"][href*=".m3u8"]').forEach((l) => {
candidates.add(new URL(l.getAttribute('href'), url).href);
});
for (const m of html.matchAll(/https?:(?:\\?\/){2}[^"'\s<>\\]+?\.m3u8[^"'\s<>\\]*/g)) {
candidates.add(m[0].replace(/\\\//g, '/').replace(/&/g, '&'));
}
const meta = metaFromInitials(parseInitials(html), url);
return {
url,
vid: vidFromUrl(url),
title: meta.title || doc.querySelector('h1')?.textContent?.trim() || 'video',
thumb: meta.thumb || doc.querySelector('meta[property="og:image"]')?.getAttribute('content') || '',
candidates: [...candidates],
meta,
fetched: Date.now(),
};
}
function currentPageInfo() {
const meta = metaFromInitials(window.initials, location.href);
return {
url: location.href,
vid: vidFromUrl(location.href),
title: meta.title || document.querySelector('h1')?.textContent?.trim() || document.title,
thumb: meta.thumb || document.querySelector('meta[property="og:image"]')?.content || '',
candidates: [...seen, ...[...document.querySelectorAll('link[rel="preload"][href*=".m3u8"]')].map((l) => l.href)],
meta,
fetched: Date.now(),
};
}
// ======================= 4. temp storage (only this script's own "xhdl" folder) =======================
const Store = (() => {
let probe = null;
function dir() {
if (!probe) {
probe = (async () => {
try {
if (!navigator.storage?.getDirectory) return null;
const root = await navigator.storage.getDirectory();
const d = await root.getDirectoryHandle('xhdl', { create: true });
const h = await d.getFileHandle('.probe', { create: true });
if (typeof h.createWritable !== 'function') return null;
const w = await h.createWritable();
await w.write(new Uint8Array([1]));
await w.close();
await d.removeEntry('.probe');
return d;
} catch (e) {
log('disk storage unavailable, using memory:', e.message);
return null;
}
})();
}
return probe;
}
async function job(id) {
const d = await dir();
return d ? d.getDirectoryHandle(id, { create: true }) : null;
}
async function readManifest(jd) {
try { return JSON.parse(await (await (await jd.getFileHandle('manifest.json')).getFile()).text()); } catch { return null; }
}
async function writeManifest(jd, m) {
const w = await (await jd.getFileHandle('manifest.json', { create: true })).createWritable();
await w.write(JSON.stringify(m));
await w.close();
}
async function remove(id) {
const d = await dir();
if (!d || !id) return;
try { await d.removeEntry(id, { recursive: true }); } catch {}
}
async function list() {
const d = await dir();
const out = [];
if (!d) return out;
for await (const [name, h] of d.entries()) {
if (h.kind === 'directory') out.push({ name, handle: h, manifest: await readManifest(h) });
}
return out;
}
async function size(h) {
let n = 0;
for await (const [, c] of h.entries()) n += c.kind === 'file' ? (await c.getFile()).size : await size(c);
return n;
}
return { dir, job, readManifest, writeManifest, remove, list, size };
})();
const segName = (i) => `s${String(i).padStart(6, '0')}`;
const isSegName = (n) => /^s\d{6}$/.test(n);
const readFile = async (jd, name) => (await (await jd.getFileHandle(name)).getFile()).arrayBuffer();
async function janitor() {
const now = Date.now();
for (const it of await Store.list()) {
const m = it.manifest;
const leftover = m && m.state === 'saved' && now - (m.savedAt || 0) > SAVED_TTL_MS;
const stale = !m || (m.state !== 'saved' && now - (m.updated || 0) > STALE_MS);
if (leftover || stale) {
await Store.remove(it.name);
log('removed temp files:', it.name);
}
}
}
async function cacheInfo() {
let bytes = 0;
const items = await Store.list();
for (const it of items) bytes += await Store.size(it.handle);
return { jobs: items.length, bytes, mb: +(bytes / 1048576).toFixed(1) };
}
async function clearCache() {
for (const it of await Store.list()) await Store.remove(it.name);
}
async function partialFor(vid) {
for (const it of await Store.list()) {
const m = it.manifest;
if (!m || m.vid !== vid || m.state === 'saved') continue;
if (m.mode !== 'segments') return null;
let n = 0;
for await (const [name, h] of it.handle.entries()) {
if (h.kind === 'file' && isSegName(name) && (await h.getFile()).size > 0) n++;
}
return n / m.total;
}
return undefined;
}
// ======================= 5. MKV muxer (fragmented MP4 in, Matroska out, no re-encoding) =======================
const enc = new TextEncoder();
const str4 = (v, p) => String.fromCharCode(v.getUint8(p), v.getUint8(p + 1), v.getUint8(p + 2), v.getUint8(p + 3));
function* boxes(v, start, end) {
let p = start;
while (p + 8 <= end) {
let size = v.getUint32(p);
const type = str4(v, p + 4);
let hdr = 8;
if (size === 1) { size = Number(v.getBigUint64(p + 8)); hdr = 16; } else if (size === 0) size = end - p;
if (size < hdr || p + size > end) return;
yield { type, start: p, body: p + hdr, end: p + size };
p += size;
}
}
const findChild = (v, start, end, type) => { for (const b of boxes(v, start, end)) if (b.type === type) return b; return null; };
const child = (v, box, type) => findChild(v, box.body, box.end, type);
const boxPath = (v, box, ...types) => { let b = box; for (const t of types) b = b && child(v, b, t); return b; };
function parseEsds(v, box) {
let p = box.body + 4;
const desc = () => {
const tag = v.getUint8(p++);
let len = 0;
for (let i = 0; i < 4; i++) { const b = v.getUint8(p++); len = (len << 7) | (b & 0x7f); if (!(b & 0x80)) break; }
return { tag, len };
};
let d = desc();
if (d.tag !== 3) return null;
p += 2;
const flags = v.getUint8(p++);
if (flags & 0x80) p += 2;
if (flags & 0x40) p += v.getUint8(p) + 1;
if (flags & 0x20) p += 2;
d = desc();
if (d.tag !== 4) return null;
p += 13;
d = desc();
if (d.tag !== 5) return null;
return new Uint8Array(v.buffer.slice(p, p + d.len));
}
const VIDEO_CODECS = {
av01: ['V_AV1', 'av1C'], avc1: ['V_MPEG4/ISO/AVC', 'avcC'], avc3: ['V_MPEG4/ISO/AVC', 'avcC'],
hvc1: ['V_MPEGH/ISO/HEVC', 'hvcC'], hev1: ['V_MPEGH/ISO/HEVC', 'hvcC'], vp09: ['V_VP9', null],
};
function parseInit(buf) {
const v = new DataView(buf);
const moov = findChild(v, 0, buf.byteLength, 'moov');
if (!moov) throw new Error('init segment has no moov box');
const trex = new Map();
const mvex = child(v, moov, 'mvex');
if (mvex) {
for (const b of boxes(v, mvex.body, mvex.end)) {
if (b.type === 'trex') trex.set(v.getUint32(b.body + 4), { dur: v.getUint32(b.body + 12), size: v.getUint32(b.body + 16), flags: v.getUint32(b.body + 20) });
}
}
const list = [];
for (const trak of boxes(v, moov.body, moov.end)) {
if (trak.type !== 'trak') continue;
const tkhd = child(v, trak, 'tkhd');
const mdia = child(v, trak, 'mdia');
if (!tkhd || !mdia) continue;
const id = v.getUint32(tkhd.body + (v.getUint8(tkhd.body) === 1 ? 20 : 12));
const mdhd = child(v, mdia, 'mdhd');
const timescale = v.getUint32(mdhd.body + (v.getUint8(mdhd.body) === 1 ? 20 : 12));
const handler = str4(v, child(v, mdia, 'hdlr').body + 8);
const stsd = boxPath(v, mdia, 'minf', 'stbl', 'stsd');
if (!stsd) continue;
const entry = boxes(v, stsd.body + 8, stsd.end).next().value;
if (!entry) continue;
const d = trex.get(id) || { dur: 0, size: 0, flags: 0 };
const t = { id, timescale, defDur: d.dur, defSize: d.size, defFlags: d.flags };
if (handler === 'vide') {
const map = VIDEO_CODECS[entry.type];
if (!map) throw new Error(`unsupported video codec ${entry.type}`);
t.kind = 'video';
t.codecId = map[0];
t.width = v.getUint16(entry.body + 24);
t.height = v.getUint16(entry.body + 26);
if (map[1]) {
const c = findChild(v, entry.body + 78, entry.end, map[1]);
if (!c) throw new Error(`missing ${map[1]}`);
t.priv = new Uint8Array(buf.slice(c.body, c.end));
}
} else if (handler === 'soun') {
t.kind = 'audio';
const ver = v.getUint16(entry.body + 8);
t.channels = v.getUint16(entry.body + 16);
t.rate = v.getUint32(entry.body + 24) >>> 16;
const childStart = entry.body + 28 + (ver === 1 ? 16 : ver === 2 ? 36 : 0);
if (entry.type === 'mp4a') {
const esds = findChild(v, childStart, entry.end, 'esds');
const asc = esds && parseEsds(v, esds);
if (!asc) throw new Error('AAC config missing');
t.codecId = 'A_AAC';
t.priv = asc;
} else if (entry.type === 'ac-3') t.codecId = 'A_AC3';
else if (entry.type === 'ec-3') t.codecId = 'A_EAC3';
else throw new Error(`unsupported audio codec ${entry.type}`);
} else continue;
list.push(t);
}
if (!list.length) throw new Error('no audio/video tracks found');
return { list, byId: new Map(list.map((t) => [t.id, t])) };
}
function parseFragment(buf, byId) {
const v = new DataView(buf);
const out = [];
for (const moof of boxes(v, 0, buf.byteLength)) {
if (moof.type !== 'moof') continue;
for (const traf of boxes(v, moof.body, moof.end)) {
if (traf.type !== 'traf') continue;
const tfhd = child(v, traf, 'tfhd');
if (!tfhd) continue;
const tf = v.getUint32(tfhd.body) & 0xffffff;
let p = tfhd.body + 4;
const t = byId.get(v.getUint32(p));
p += 4;
if (!t) continue;
let base = moof.start;
if (tf & 0x1) { base = Number(v.getBigUint64(p)); p += 8; }
if (tf & 0x2) p += 4;
let dDur = t.defDur; let dSize = t.defSize; let dFlags = t.defFlags;
if (tf & 0x8) { dDur = v.getUint32(p); p += 4; }
if (tf & 0x10) { dSize = v.getUint32(p); p += 4; }
if (tf & 0x20) { dFlags = v.getUint32(p); p += 4; }
const tfdt = child(v, traf, 'tfdt');
let dts = 0;
if (tfdt) dts = v.getUint8(tfdt.body) === 1 ? Number(v.getBigUint64(tfdt.body + 4)) : v.getUint32(tfdt.body + 4);
let cursor = base;
for (const trun of boxes(v, traf.body, traf.end)) {
if (trun.type !== 'trun') continue;
const ver = v.getUint8(trun.body);
const fl = v.getUint32(trun.body) & 0xffffff;
let q = trun.body + 4;
const count = v.getUint32(q); q += 4;
if (fl & 0x1) { cursor = base + v.getInt32(q); q += 4; }
let firstFlags = null;
if (fl & 0x4) { firstFlags = v.getUint32(q); q += 4; }
for (let i = 0; i < count; i++) {
let dur = dDur; let size = dSize; let flags = dFlags; let cto = 0;
if (fl & 0x100) { dur = v.getUint32(q); q += 4; }
if (fl & 0x200) { size = v.getUint32(q); q += 4; }
if (fl & 0x400) { flags = v.getUint32(q); q += 4; } else if (i === 0 && firstFlags !== null) flags = firstFlags;
if (fl & 0x800) { cto = ver === 0 ? v.getUint32(q) : v.getInt32(q); q += 4; }
if (cursor + size <= buf.byteLength) {
out.push({ track: t, dts, pts: dts + cto, dur, offset: cursor, size, key: t.kind !== 'video' || !(flags & 0x10000) });
}
dts += dur;
cursor += size;
}
}
}
}
return out;
}
function cat(parts) {
let n = 0;
for (const p of parts) n += p.length;
const out = new Uint8Array(n);
let o = 0;
for (const p of parts) { out.set(p, o); o += p.length; }
return out;
}
function idB(id) { const a = []; let x = id; while (x > 0) { a.unshift(x & 0xff); x = Math.floor(x / 256); } return Uint8Array.from(a); }
function sizeB(n, len) {
if (!len) { len = 1; while (n >= 2 ** (7 * len) - 1) len++; }
const b = new Uint8Array(len);
let x = n;
for (let i = len - 1; i >= 0; i--) { b[i] = x % 256; x = Math.floor(x / 256); }
b[0] |= 0x80 >> (len - 1);
return b;
}
function uintB(v, len) {
if (!len) { len = 1; while (v >= 2 ** (8 * len)) len++; }
const b = new Uint8Array(len);
let x = v;
for (let i = len - 1; i >= 0; i--) { b[i] = x % 256; x = Math.floor(x / 256); }
return b;
}
const el = (id, ...parts) => { const body = cat(parts.filter(Boolean)); return cat([idB(id), sizeB(body.length), body]); };
const U = (id, v, len) => el(id, uintB(v, len));
const F = (id, v) => { const b = new Uint8Array(8); new DataView(b.buffer).setFloat64(0, v); return el(id, b); };
const S = (id, s) => el(id, enc.encode(String(s)));
// Accepts "hh:mm:ss.mmm", "mm:ss.mmm" and comma decimals; honours X-TIMESTAMP-MAP if present.
function parseVtt(txt) {
const toMs = (t) => {
const p = t.replace(',', '.').split(':').map(Number);
const sec = p.length === 3 ? p[0] * 3600 + p[1] * 60 + p[2] : p.length === 2 ? p[0] * 60 + p[1] : p[0];
return Math.round(sec * 1000);
};
const clean = txt.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
let mapOffset = null;
const map = /X-TIMESTAMP-MAP=([^\n]+)/.exec(clean);
if (map) {
const mpegts = +((/MPEGTS:(\d+)/.exec(map[1]) || [])[1] || 0);
const local = (/LOCAL:([\d:.,]+)/.exec(map[1]) || [])[1];
mapOffset = Math.round(mpegts / 90) - (local ? toMs(local) : 0);
}
const cues = [];
for (const block of clean.split(/\n{2,}/)) {
const lines = block.split('\n');
const i = lines.findIndex((l) => l.includes('-->'));
if (i < 0) continue;
const [a, b] = lines[i].split('-->').map((s) => s.trim().split(/\s+/)[0]);
const start = toMs(a);
const end = toMs(b);
const text = lines.slice(i + 1).join('\n').replace(/<[^>]+>/g, '').trim();
if (Number.isFinite(start) && Number.isFinite(end) && text && end > start) cues.push({ start, end, text });
}
return { cues, mapOffset };
}
async function getTextRetry(url, signal) {
for (let attempt = 0; ; attempt++) {
try { return await getText(url, signal); } catch (e) {
if (e.name === 'AbortError' || attempt >= 2) throw e;
await sleep(700 * (attempt + 1), signal);
}
}
}
// Last resort when the site offers no English track: the browser's built-in, on-device
// Translator API (Chrome/Edge 138+). Firefox does not provide it to web pages.
async function translateTrack(src, signal) {
const T = window.Translator;
if (!T?.create || !src?.lang) return null;
try {
const opts = { sourceLanguage: src.lang.split(/[-_]/)[0], targetLanguage: 'en' };
if (T.availability && (await T.availability(opts)) === 'unavailable') return null;
const tr = await T.create(opts);
const cues = [];
for (const c of src.cues) {
if (signal?.aborted) throw abortErr();
cues.push({ ...c, text: await tr.translate(c.text) });
}
tr.destroy?.();
return { label: 'English (translated on this device)', lang: 'en', original: false, isEnglish: true, cues, mapOffset: src.mapOffset };
} catch (e) {
if (e.name === 'AbortError') throw e;
log('on-device translation unavailable:', e.message);
return null;
}
}
// English first (always, when it exists or can be made), then the original language, then SUB_LANGS extras.
const isEnglish = (t) => /^en(?:$|[-_])/i.test(t.lang || '') || /english/i.test(t.label || '');
async function loadSubs(info, signal) {
const tracks = info.meta?.subs || [];
const want = [];
const english = tracks.filter(isEnglish).sort((a, b) => (b.original ? 1 : 0) - (a.original ? 1 : 0))[0];
if (english) want.push(english);
const orig = tracks.find((t) => t.original);
if (orig && !want.includes(orig)) want.push(orig);
for (const lang of SUB_LANGS) {
const t = tracks.find((x) => x.lang === lang);
if (t && !want.includes(t)) want.push(t);
}
const out = [];
for (const t of want) {
try {
const { cues, mapOffset } = parseVtt(await getTextRetry(t.url, signal));
if (cues.length) out.push({ ...t, cues, mapOffset, isEnglish: isEnglish(t) });
else log('subtitle track had no readable cues:', t.label);
} catch (e) {
if (e.name === 'AbortError') throw e;
log('subtitle track skipped:', t.label, e.message);
}
}
if (!out.some((s) => s.isEnglish)) {
const src = out.find((s) => s.original) || out[0];
const translated = await translateTrack(src, signal);
if (translated) out.unshift(translated);
else if (src) log('no English subtitles from the site, and on-device translation is not available in this browser');
}
out.sort((a, b) => (b.isEnglish ? 1 : 0) - (a.isEnglish ? 1 : 0));
log(`subtitles added: ${out.map((s) => s.label).join(', ') || 'none'} (site offers ${tracks.length})`);
return out;
}
// Thumbnail as MKV cover art (converted to JPEG where the browser allows).
async function loadCover(info, signal) {
const src = info.meta?.thumb || info.thumb;
if (!src) return null;
try {
const r = await nativeFetch(src, { credentials: 'omit', signal });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
let blob = await r.blob();
let mime = blob.type || 'image/jpeg';
if (mime !== 'image/jpeg' && typeof OffscreenCanvas !== 'undefined') {
try {
const bmp = await createImageBitmap(blob);
const c = new OffscreenCanvas(bmp.width, bmp.height);
c.getContext('2d').drawImage(bmp, 0, 0);
blob = await c.convertToBlob({ type: 'image/jpeg', quality: 0.92 });
mime = 'image/jpeg';
} catch {}
}
const ext = mime === 'image/jpeg' ? 'jpg' : mime === 'image/png' ? 'png' : 'webp';
return { data: new Uint8Array(await blob.arrayBuffer()), mime, name: `cover.${ext}` };
} catch (e) {
if (e.name === 'AbortError') throw e;
log('cover art skipped:', e.message);
return null;
}
}
function buildTags(info, title) {
const m = info.meta || {};
const out = [];
const add = (name, value) => {
if (value === null || value === undefined || value === '') return;
out.push(el(0x67C8, S(0x45A3, name), S(0x4487, String(value))));
};
add('TITLE', title);
add('ARTIST', m.uploader);
add('UPLOADER_URL', m.uploaderUrl);
if (m.creator && m.creator !== m.uploader) add('CREATOR', m.creator);
add('URL', m.creatorUrl || m.uploaderUrl); // creator page
add('PROMOTION_URL', m.url || info.url); // the page the video is hosted on
(m.people || []).forEach((p) => add('ACTOR', p));
(m.channels || []).forEach((c) => add('CHANNEL', c));
add('PUBLISHER', 'xHamster');
add('DATE_RELEASED', m.created ? new Date(m.created * 1000).toISOString().slice(0, 10) : '');
add('DESCRIPTION', m.description);
add('COMMENT', m.description);
add('GENRE', (m.categories || []).join('; '));
add('KEYWORDS', (m.tags || []).join(', '));
add('RATING', m.stars); // 0–5, 5 = 100% likes
add('LIKES', m.likes || null);
add('DISLIKES', m.dislikes || null);
add('LIKE_PERCENT', m.likePercent);
add('VIEWS', m.views);
add('COMMENT_COUNT', m.comments);
add('ORIGINAL_LANGUAGE', m.language);
add('ORIENTATION', m.orientation);
add('SOURCE_RESOLUTION', m.resolution);
add('VIDEO_ID', m.idHash || m.id);
return el(0x1254C367, el(0x7373, el(0x63C0, U(0x68CA, 50)), ...out));
}
async function muxMKV({ sink, initBuf, segCount, segmentSource, subs, cover, info, signal, onProgress }) {
const trk = parseInit(initBuf);
let num = 1;
for (const t of trk.list) t.num = num++;
const subTracks = subs.map((s) => ({ ...s, num: num++ }));
const videoNum = trk.list.find((t) => t.kind === 'video')?.num || 1;
const uid = () => Math.floor(Math.random() * 0xfffffff0) + 1;
const title = info.title || info.meta?.title || 'video';
await sink.write(el(0x1A45DFA3, U(0x4286, 1), U(0x42F7, 1), U(0x42F2, 4), U(0x42F3, 8), S(0x4282, 'matroska'), U(0x4287, 4), U(0x4285, 2)));
await sink.write(idB(0x18538067));
const segSizePos = sink.pos;
await sink.write(sizeB(0, 8));
const segStart = sink.pos;
const infoBody = cat([F(0x4489, 0), U(0x2AD7B1, 1000000), S(0x4D80, 'xhdl'), S(0x5741, 'xhdl userscript'), S(0x7BA9, title)]);
const infoHead = cat([idB(0x1549A966), sizeB(infoBody.length)]);
const infoEl = cat([infoHead, infoBody]);
const durOffset = infoHead.length + 3;
const trackEls = trk.list.map((t) => el(0xAE,
U(0xD7, t.num), U(0x73C5, uid()), U(0x83, t.kind === 'video' ? 1 : 2), U(0x9C, 0), S(0x86, t.codecId),
t.priv ? el(0x63A2, t.priv) : null, S(0x22B59C, 'und'),
t.kind === 'video' ? el(0xE0, U(0xB0, t.width), U(0xBA, t.height)) : el(0xE1, F(0xB5, t.rate || 48000), U(0x9F, t.channels || 2))));
const subEls = subTracks.map((s) => el(0xAE,
U(0xD7, s.num), U(0x73C5, uid()), U(0x83, 17), U(0x9C, 0), S(0x86, 'S_TEXT/UTF8'),
S(0x22B59C, s.isEnglish ? 'eng' : 'und'), S(0x22B59D, s.isEnglish ? 'en' : s.lang || 'und'), S(0x536E, s.label || s.lang || 'Subtitles'),
U(0x88, s.isEnglish ? 1 : 0)));
const tracksEl = el(0x1654AE6B, ...trackEls, ...subEls);
const tagsEl = buildTags(info, title);
const attEl = cover
? el(0x1941A469, el(0x61A7, S(0x467E, 'Thumbnail'), S(0x466E, cover.name), S(0x4660, cover.mime), el(0x465C, cover.data), U(0x46AE, uid())))
: null;
const heads = [['info', 0x1549A966, infoEl], ['tracks', 0x1654AE6B, tracksEl], ['tags', 0x1254C367, tagsEl]];
if (attEl) heads.push(['att', 0x1941A469, attEl]);
const seekEntry = (id, p) => el(0x4DBB, el(0x53AB, idB(id)), U(0x53AC, p, 8));
const seekHead = (pos, cuesPos) => el(0x114D9B74, ...heads.map(([k, id]) => seekEntry(id, pos[k] || 0)), seekEntry(0x1C53BB6B, cuesPos));
const seekLen = seekHead({}, 0).length;
const pos = {};
let at = seekLen;
for (const [k, , bytes] of heads) { pos[k] = at; at += bytes.length; }
const headerEnd = at;
const infoPos = sink.pos + seekLen;
await sink.write(seekHead(pos, 0));
for (const [, , bytes] of heads) await sink.write(bytes);
// Subtitle times share the video's zero point. Tracks with an X-TIMESTAMP-MAP are shifted onto the
// media clock first; the list is built once the first video timestamp (t0) is known.
let allCues = [];
const buildCues = () => {
allCues = subTracks
.flatMap((s) => s.cues.map((c) => {
const shift = s.mapOffset != null ? s.mapOffset - t0 : 0;
return { start: c.start + shift, end: c.end + shift, text: c.text, num: s.num };
}))
.filter((c) => c.end > 0)
.map((c) => ({ ...c, start: Math.max(0, c.start) }))
.sort((a, b) => a.start - b.start || a.num - b.num);
};
let subIdx = 0;
const cuePoints = [];
let cluster = null;
let t0 = null;
let endMs = 0;
const flush = async () => {
if (!cluster || !cluster.parts.length) { cluster = null; return; }
const clusterPos = sink.pos - segStart;
const tcEl = U(0xE7, cluster.tc);
await sink.write([idB(0x1F43B675), sizeB(tcEl.length + cluster.len), tcEl, ...cluster.parts]);
if (cluster.cue || !cuePoints.length) cuePoints.push({ tc: cluster.tc, pos: clusterPos });
cluster = null;
};
const addBlock = async (abs, make) => {
if (!cluster || abs - cluster.tc > 32000) {
await flush();
cluster = { tc: Math.max(0, abs), parts: [], len: 0, cue: false };
}
const b = make(Math.max(-32768, Math.min(32767, abs - cluster.tc)));
cluster.parts.push(...b.parts);
cluster.len += b.len;
};
const simpleBlock = (n, key, data) => (rel) => {
const head = cat([idB(0xA3), sizeB(4 + data.byteLength), Uint8Array.of(0x80 | n, (rel >> 8) & 0xff, rel & 0xff, key ? 0x80 : 0)]);
return { parts: [head, data], len: head.length + data.byteLength };
};
const subBlock = (c) => (rel) => {
const g = el(0xA0, el(0xA1, Uint8Array.of(0x80 | c.num, (rel >> 8) & 0xff, rel & 0xff, 0), enc.encode(c.text)), U(0x9B, Math.max(1, c.end - c.start)));
return { parts: [g], len: g.length };
};
for (let i = 0; i < segCount; i++) {
if (signal?.aborted) throw abortErr();
const buf = await segmentSource(i);
const samples = parseFragment(buf, trk.byId);
if (samples.length) {
for (const s of samples) {
const ts = s.track.timescale;
s.ms = Math.round((s.pts * 1000) / ts);
s.dms = Math.round((s.dts * 1000) / ts);
s.durMs = Math.round((s.dur * 1000) / ts);
}
if (t0 === null) { t0 = Math.min(...samples.map((s) => s.ms)); buildCues(); }
samples.sort((a, b) => a.dms - b.dms);
await flush();
const firstVideo = samples.find((s) => s.track.kind === 'video');
cluster = { tc: Math.max(0, Math.min(...samples.map((s) => s.ms)) - t0), parts: [], len: 0, cue: !firstVideo || firstVideo.key };
for (const s of samples) {
const abs = Math.max(0, s.ms - t0);
while (subIdx < allCues.length && allCues[subIdx].start <= abs) {
const c = allCues[subIdx++];
await addBlock(c.start, subBlock(c));
}
await addBlock(abs, simpleBlock(s.track.num, s.key, new Uint8Array(buf, s.offset, s.size)));
endMs = Math.max(endMs, abs + s.durMs);
}
}
onProgress?.((i + 1) / segCount);
}
while (subIdx < allCues.length) {
const c = allCues[subIdx++];
await addBlock(c.start, subBlock(c));
}
await flush();
const cuesPos = sink.pos - segStart;
if (!cuePoints.length) cuePoints.push({ tc: 0, pos: headerEnd });
await sink.write(el(0x1C53BB6B, ...cuePoints.map((c) => el(0xBB, U(0xB3, c.tc), el(0xB7, U(0xF7, videoNum), U(0xF1, c.pos))))));
const durBytes = new Uint8Array(8);
new DataView(durBytes.buffer).setFloat64(0, endMs || 1);
await sink.patch(infoPos + durOffset, durBytes);
await sink.patch(segStart + seekLen - 8, uintB(cuesPos, 8));
await sink.patch(segSizePos, sizeB(sink.pos - segStart, 8));
}
async function opfsSink(fh) {
const w = await fh.createWritable();
let pos = 0;
return {
get pos() { return pos; },
async write(data) {
const blob = Array.isArray(data) ? new Blob(data) : data;
const len = blob instanceof Blob ? blob.size : blob.byteLength;
await w.write({ type: 'write', position: pos, data: blob });
pos += len;
},
async patch(at, u8) { await w.write({ type: 'write', position: at, data: u8 }); },
close: () => w.close(),
async abort() { try { await w.abort(); } catch {} },
};
}
function memorySink() {
const parts = [];
const starts = [];
let pos = 0;
return {
get pos() { return pos; },
async write(data) {
for (const u of Array.isArray(data) ? data : [data]) { starts.push(pos); parts.push(u); pos += u.byteLength; }
},
async patch(at, u8) {
for (let i = parts.length - 1; i >= 0; i--) {
if (starts[i] <= at) { parts[i].set(u8, at - starts[i]); return; }
}
},
blob() { return new Blob(parts, { type: 'video/x-matroska' }); },
};
}
// ======================= 6. download engine =======================
async function opfsSegments(jd, list, signal, onProgress, onBytes) {
const have = new Set();
let bytes = 0;
for await (const [name, h] of jd.entries()) {
if (h.kind !== 'file' || !isSegName(name)) continue;
const size = (await h.getFile()).size;
if (size > 0) { have.add(name); bytes += size; }
}
const todo = list.map((_, i) => i).filter((i) => !have.has(segName(i)));
let done = list.length - todo.length;
if (done) log(`resuming: ${done}/${list.length} segments already on disk`);
onProgress(done / list.length);
onBytes(bytes);
let next = 0;
let stop = false;
const worker = async () => {
while (!stop && next < todo.length) {
if (signal.aborted) throw abortErr();
const i = todo[next++];
try {
const buf = await fetchBuffer(list[i], signal);
const w = await (await jd.getFileHandle(segName(i), { create: true })).createWritable();
try { await w.write(buf); await w.close(); } catch (e) { try { await w.abort(); } catch {} throw e; }
bytes += buf.byteLength;
} catch (e) { stop = true; throw e; }
onProgress(++done / list.length);
onBytes(bytes);
}
};
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, todo.length) }, worker));
}
async function segmentsBlob(jd, count) {
const files = [];
for (let i = 0; i < count; i++) files.push(await (await jd.getFileHandle(segName(i))).getFile());
return new Blob(files, { type: 'video/mp4' });
}
async function opfsSingle(jd, url, signal, onProgress, onBytes) {
const h = await jd.getFileHandle('whole', { create: true });
let have = (await h.getFile()).size;
const r = await nativeFetch(url, { credentials: 'omit', signal, headers: have ? { Range: `bytes=${have}-` } : {} });
if (r.status === 416) return h.getFile();
if (!r.ok) throw new Error(`HTTP ${r.status}`);
if (have && r.status !== 206) have = 0;
const total = r.status === 206
? +((/\/(\d+)\s*$/.exec(r.headers.get('content-range') || '') || [])[1] || 0)
: +r.headers.get('content-length') || 0;
const w = await h.createWritable({ keepExistingData: have > 0 });
if (have) await w.seek(have);
let got = have;
try {
const reader = r.body.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
await w.write(value);
got += value.length;
onBytes(got);
if (total) onProgress(got / total);
}
await w.close();
} catch (e) {
try { await w.close(); } catch {}
throw e;
}
return h.getFile();
}
async function memSegments(list, signal, onProgress, onBytes) {
const out = new Array(list.length);
let next = 0;
let done = 0;
let bytes = 0;
let stop = false;
const worker = async () => {
while (!stop && next < list.length) {
const i = next++;
try { out[i] = await fetchBuffer(list[i], signal); } catch (e) { stop = true; throw e; }
bytes += out[i].byteLength;
onProgress(++done / list.length);
onBytes(bytes);
}
};
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, list.length) }, worker));
return out;
}
async function memSingle(url, signal, onProgress, onBytes) {
const r = await nativeFetch(url, { credentials: 'omit', signal });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const total = +r.headers.get('content-length') || 0;
const reader = r.body.getReader();
const chunks = [];
let got = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
got += value.length;
onBytes(got);
if (total) onProgress(got / total);
}
return new Blob(chunks, { type: 'video/mp4' });
}
async function downloadVideo({ info, variant, format, signal, onStatus = () => {}, onProgress = () => {}, onBytes = () => {}, onJob = () => {} }) {
onStatus('Downloading');
const codec = /\.av1\./.test(variant.url) ? 'av1' : /\.h264\./.test(variant.url) ? 'h264' : 'std';
const mediaTxt = variant.mediaText || (await getText(variant.url, signal));
const { init, segs, encrypted } = parseMedia(mediaTxt, variant.url);
if (encrypted) throw new Error('This stream is encrypted; download is not supported.');
if (!segs.length) throw new Error('Playlist contained no segments.');
const list = init ? [init, ...segs] : segs;
const single = new Set(list).size === 1;
let wantMkv = format === 'mkv';
let noteText = '';
if (wantMkv && (single || !init)) { wantMkv = false; noteText = 'MKV not possible for this stream, saved as MP4'; }
const share = wantMkv ? 0.9 : 1;
const dlProgress = (p) => onProgress(p * share);
const convProgress = (p) => onProgress(0.9 + 0.1 * p);
const jobId = `${info.vid}_${variant.label}_${codec}`.replace(/[^\w.-]+/g, '_');
onJob(jobId);
let jd = await Store.job(jobId);
let blob = null;
let ext = 'mp4';
let subsInfo = '';
const mux = async (sink, initBuf, source) => {
onStatus('Converting');
const [subs, cover] = await Promise.all([loadSubs(info, signal), loadCover(info, signal)]);
subsInfo = subs.length ? `Subtitles: ${subs.map((x) => x.label).join(', ')}` : 'No subtitles available';
await muxMKV({ sink, initBuf, segCount: list.length - 1, segmentSource: source, subs, cover, info, signal, onProgress: convProgress });
};
const mkvFailed = (e) => {
if (e.name === 'AbortError') throw e;
log('MKV conversion failed, saving MP4:', e);
noteText = `MKV conversion failed (${e.message}), saved as MP4`;
};
if (!jd) {
if (single) {
blob = await memSingle(list[0], signal, dlProgress, onBytes);
} else {
const bufs = await memSegments(list, signal, dlProgress, onBytes);
if (wantMkv) {
try {
const sink = memorySink();
await mux(sink, bufs[0], async (i) => bufs[i + 1]);
blob = sink.blob();
ext = 'mkv';
} catch (e) { mkvFailed(e); }
}
if (!blob) blob = new Blob(bufs, { type: 'video/mp4' });
}
} else {
const mode = single ? 'single' : 'segments';
const old = await Store.readManifest(jd);
if (old && (old.mode !== mode || old.total !== list.length)) {
await Store.remove(jobId);
jd = await Store.job(jobId);
}
const manifest = { jobId, vid: info.vid, title: info.title, label: variant.label, codec, mode, total: list.length, state: 'partial', updated: Date.now() };
await Store.writeManifest(jd, manifest);
if (single) {
blob = await opfsSingle(jd, list[0], signal, dlProgress, onBytes);
} else {
await opfsSegments(jd, list, signal, dlProgress, onBytes);
if (wantMkv) {
let sink = null;
try {
const fh = await jd.getFileHandle('out.mkv', { create: true });
sink = await opfsSink(fh);
await mux(sink, await readFile(jd, segName(0)), (i) => readFile(jd, segName(i + 1)));
await sink.close();
sink = null;
for (let i = 0; i < list.length; i++) { try { await jd.removeEntry(segName(i)); } catch {} }
blob = await fh.getFile();
ext = 'mkv';
} catch (e) {
if (sink) await sink.abort();
mkvFailed(e);
}
}
if (!blob) blob = await segmentsBlob(jd, list.length);
}
await Store.writeManifest(jd, { ...manifest, state: 'saved', savedAt: Date.now(), updated: Date.now() });
}
onProgress(1);
return { blob, filename: `${cleanName(info.title)} [${variant.label}].${ext}`, jobId: jd ? jobId : null, note: noteText, subsInfo: ext === 'mkv' ? subsInfo : '' };
}
// Normal browser download, then remove the temp copy automatically
// (about 30 s plus 1 s per 25 MB, since a page can't see when the browser finishes writing).
function saveAndCleanup(blob, filename, jobId) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
const delay = 30000 + Math.ceil((blob.size || 0) / (25 * 1048576)) * 1000;
setTimeout(() => {
URL.revokeObjectURL(url);
Store.remove(jobId);
}, delay);
}
async function prepare(q, signal) {
if (!q.info || Date.now() - q.info.fetched > INFO_TTL_MS) {
q.info = await fetchVideoInfo(q.url, signal);
q.variants = null;
}
if (!q.variants) q.variants = await getVariants(q.info.candidates, signal);
q.variant = chooseVariant(q.variants, q.quality);
q.quality = q.variant.label;
q.sizeEst = estSize(q.variants, q.quality, q.info.meta?.duration);
if (q.info.title) q.title = q.info.title;
if (!q.thumb) q.thumb = q.info.thumb;
}
async function runItem(q, panel) {
const signal = q.ctrl.signal;
await prepare(q, signal);
panel.update(q);
const res = await downloadVideo({
info: q.info, variant: q.variant, format: q.format, signal,
onJob: (id) => { q.jobId = id; },
onStatus: (s) => { q.status = s; panel.update(q); },
onProgress: (p) => { q.pct = p; panel.update(q); q.onProgress?.(p); },
onBytes: (b) => { q.bytes = b; },
});
q.status = 'Saving';
panel.update(q);
saveAndCleanup(res.blob, res.filename, res.jobId);
q.sizeFinal = res.blob.size;
q.note = res.note;
q.subsInfo = res.subsInfo;
q.status = res.note ? 'Done (MP4)' : 'Done';
q.pct = 1;
q.phase = 'done';
panel.update(q);
}
// ======================= 7. site-styled buttons =======================
const ICON_DOWNLOAD = 'M9 3h2v8.17l2.59-2.58L15 10l-5 5-5-5 1.41-1.41L9 11.17V3zM4 15h12v2H4z';
const ICON_LIST = 'M3 4h2v2H3zm4 0h10v2H7zM3 9h2v2H3zm4 0h10v2H7zm-4 5h2v2H3zm4 0h10v2H7z';
const ICON_CLOSE = 'M5.3 4.3 10 9l4.7-4.7 1 1L11 10l4.7 4.7-1 1L10 11l-4.7 4.7-1-1L9 10 4.3 5.3z';
const ICON_GEAR = 'M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58a.49.49 0 0 0 .12-.61l-1.92-3.32a.49.49 0 0 0-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54a.48.48 0 0 0-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96a.48.48 0 0 0-.59.22L2.74 8.87a.47.47 0 0 0 .12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58a.49.49 0 0 0-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32a.48.48 0 0 0-.12-.61l-2.01-1.58zM12 15.6A3.6 3.6 0 1 1 12 8.4a3.6 3.6 0 0 1 0 7.2z';
function makeSiteButton(iconPath, viewBox = '0 0 20 20') {
const tpl = document.querySelector('[data-role="video-share-button"] button');
let btn;
let labelEl;
if (tpl) {
btn = tpl.cloneNode(true);
btn.removeAttribute('__xid');
const svg = btn.querySelector('svg');
if (svg) { svg.setAttribute('viewBox', viewBox); svg.innerHTML = `<path fill="currentColor" d="${iconPath}"/>`; }
labelEl = btn.querySelector('[class*="text-"]') || btn.querySelector('span:last-of-type');
} else {
btn = document.createElement('button');
btn.className = 'xh-button button gray large';
btn.innerHTML = `<svg viewBox="${viewBox}" width="20"><path fill="currentColor" d="${iconPath}"/></svg> <span></span>`;
labelEl = btn.querySelector('span');
}
btn.type = 'button';
return {
btn,
setText: (t) => {
if (!labelEl || labelEl.textContent === t) return;
labelEl.textContent = t;
labelEl.style.display = t ? '' : 'none';
},
};
}
const chipHtml = (group, value, text, on) =>
`<span class="xhdl-chip${on ? ' on' : ''}" data-chip="${group}" data-v="${escHtml(value)}">${escHtml(text)}</span>`;
// ======================= 8. progress popup (single + batch share one layout) =======================
let previewEl = null;
function showPreview(src, anchor) {
if (!src) return;
if (!previewEl) { previewEl = document.createElement('img'); previewEl.className = 'xhdl-preview'; document.body.appendChild(previewEl); }
previewEl.src = src;
const r = anchor.getBoundingClientRect();
previewEl.style.left = `${Math.min(innerWidth - 330, r.right + 10)}px`;
previewEl.style.top = `${Math.max(8, Math.min(innerHeight - 190, r.top - 70))}px`;
previewEl.style.display = 'block';
}
const hidePreview = () => { if (previewEl) previewEl.style.display = 'none'; };
function createPanel({ queue, numbered, heading }) {
const wrap = document.createElement('div');
wrap.className = 'xhdl-backdrop';
wrap.innerHTML = `
<div class="xhdl-modal" role="dialog" aria-modal="true">
<h3 class="xhdl-title"></h3>
<p class="xhdl-warn">⚠️ Keep this tab open until the download${queue.length > 1 ? 's finish' : ' finishes'}. Leaving the page stops it; finished segments are kept so the next attempt resumes.</p>
<div class="xhdl-tablewrap">
<table class="xhdl-table">
<thead><tr>${numbered ? '<th>#</th>' : ''}<th>Thumbnail</th><th>Link</th><th>Video</th><th>Status</th><th>Quality</th><th>Format</th><th>Size</th><th>Progress</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div class="xhdl-confirm" hidden>
<p></p>
<div class="xhdl-confirm-btns">
<button type="button" class="xhdl-btn" data-act="continue">Continue download</button>
<button type="button" class="xhdl-btn xhdl-danger" data-act="delete">Cancel & delete partial</button>
</div>
</div>
<div class="xhdl-foot">
<span class="xhdl-cache"></span>
<button type="button" class="xhdl-btn xhdl-clear" hidden>Clear unfinished downloads</button>
<button type="button" class="xhdl-btn xhdl-main">Cancel</button>
</div>
</div>`;
document.body.appendChild(wrap);
const modal = wrap.firstElementChild;
const $ = (s) => modal.querySelector(s);
const title = $('.xhdl-title');
title.textContent = heading;
const tbody = $('tbody');
const mainBtn = $('.xhdl-main');
const confirmBox = $('.xhdl-confirm');
let closed = false;
for (const q of queue) {
const tr = document.createElement('tr');
tr.innerHTML = `${numbered ? '<td class="c-n"></td>' : ''}
<td class="c-thumb"><div class="xhdl-hover">Hover</div></td>
<td class="c-link"><a class="xhdl-link" target="_blank" rel="noopener">Open Page</a></td>
<td class="c-name"></td><td class="c-status"></td>
<td class="c-q"><select class="xhdl-sel" data-k="quality"></select></td>
<td class="c-f"><select class="xhdl-sel" data-k="format"><option value="mkv">MKV</option><option value="native">Native</option></select></td>
<td class="c-size"></td>
<td class="c-prog"><div class="xhdl-progwrap"><div class="xhdl-bar"><i></i></div><span></span></div></td>`;
if (numbered) tr.querySelector('.c-n').textContent = q.n;
tr.querySelector('.xhdl-link').href = q.url;
tr._q = q;
q.row = tr;
tbody.appendChild(tr);
}
tbody.addEventListener('change', (e) => {
const s = e.target.closest('.xhdl-sel');
const q = s?.closest('tr')?._q;
if (!q || q.locked) return;
q[s.dataset.k] = s.value;
ui.update(q);
});
tbody.addEventListener('mouseover', (e) => {
const h = e.target.closest('.xhdl-hover');
if (h) showPreview(h.closest('tr')._q.thumb, h);
});
tbody.addEventListener('mouseout', (e) => { if (e.target.closest('.xhdl-hover')) hidePreview(); });
// Green fill, left to right. Each video counts by its size, and its share fills with its own
// progress, which covers downloading AND converting, so the fill only reaches the end when the
// last video has finished converting. Failed or cancelled videos drop out of the total.
const updateFill = () => {
const live = queue.filter((x) => x.phase !== 'idle' || !/^(Failed|Cancelled)/.test(x.status || ''));
const size = (x) => x.sizeFinal || x.sizeEst || 0;
const total = live.reduce((s, x) => s + size(x), 0);
const prog = (x) => (x.phase === 'done' ? 1 : Math.min(1, x.pct || 0));
let f;
if (!live.length) f = 0;
else if (total > 0) f = live.reduce((s, x) => s + size(x) * prog(x), 0) / total;
else f = live.reduce((s, x) => s + prog(x), 0) / live.length;
modal.style.setProperty('--fill', `${Math.min(100, f * 100).toFixed(1)}%`);
};
const ui = {
onMain: null,
update(q) {
if (closed || !q.row) return;
const r = q.row;
const name = r.querySelector('.c-name');
if (name.textContent !== q.title) { name.textContent = q.title; name.title = q.title; }
const pct = Math.floor((q.pct || 0) * 100);
const st = r.querySelector('.c-status');
st.textContent = q.status === 'Converting' ? 'Converting to MKV' : q.status;
st.title = q.error || q.note || q.subsInfo || '';
st.dataset.state = q.status.split(/[\s–(]/)[0].toLowerCase();
const qs = r.querySelector('[data-k="quality"]');
const labels = (q.variants || []).map((v) => v.label);
const chosen = q.variants ? chooseVariant(q.variants, q.quality).label : '';
const sig = `${labels.join(',')}|${chosen}|${q.infoError ? 1 : 0}`;
if (qs.dataset.sig !== sig) {
qs.dataset.sig = sig;
qs.innerHTML = labels.length
? labels.map((l) => `<option value="${escHtml(l)}"${l === chosen ? ' selected' : ''}>${escHtml(l)}</option>`).join('')
: `<option>${q.infoError ? 'Unavailable' : 'Loading…'}</option>`;
}
qs.disabled = !!q.locked || !labels.length;
const fs = r.querySelector('[data-k="format"]');
fs.value = q.format;
fs.disabled = !!q.locked;
if (!q.locked && q.variants) q.sizeEst = estSize(q.variants, chosen, q.info?.meta?.duration);
r.querySelector('.c-size').textContent = q.sizeFinal ? fmtSize(q.sizeFinal) : q.sizeEst ? `≈ ${fmtSize(q.sizeEst)}` : '—';
r.querySelector('.xhdl-bar i').style.width = `${pct}%`;
r.querySelector('.c-prog span').textContent = `${pct}%`;
updateFill();
},
setMain(text, disabled = false) { mainBtn.textContent = text; mainBtn.disabled = disabled; },
ask(q) {
confirmBox.hidden = false;
mainBtn.disabled = true;
confirmBox.querySelector('p').textContent =
`${numbered ? `#${q.n} ` : ''}“${q.title}” is ${Math.floor(q.pct * 100)}% downloaded. Keep downloading, or cancel and delete its partial files?`;
return new Promise((resolve) => {
confirmBox.onclick = (e) => {
const act = e.target.closest('[data-act]')?.dataset.act;
if (!act) return;
confirmBox.hidden = true;
mainBtn.disabled = false;
resolve(act);
};
});
},
async finish(text) {
title.textContent = text;
$('.xhdl-warn').hidden = true;
confirmBox.hidden = true;
ui.setMain('Close');
updateFill();
await showCache();
},
close() {
if (closed) return;
closed = true;
hidePreview();
wrap.remove();
},
};
async function showCache() {
if (!(await Store.dir())) return;
const partial = (await Store.list()).filter((it) => it.manifest?.state !== 'saved');
let bytes = 0;
for (const it of partial) bytes += await Store.size(it.handle);
$('.xhdl-cache').textContent = partial.length ? `Unfinished downloads kept for resume: ${fmtSize(bytes)}` : '';
$('.xhdl-clear').hidden = !partial.length;
}
$('.xhdl-clear').addEventListener('click', async () => {
for (const it of await Store.list()) if (it.manifest?.state !== 'saved') await Store.remove(it.name);
await showCache();
});
mainBtn.addEventListener('click', () => ui.onMain && ui.onMain());
queue.forEach((q) => ui.update(q));
return ui;
}
// ======================= 9. single-video button + its option boxes =======================
const single = {
setText: null, running: false, ctrl: null, q: null, rest: 'Download',
opts: { quality: settings.quality, format: settings.format }, variants: null, varError: '', varLoading: false,
optsEl: null, sizeEl: null,
};
function renderSingle() {
if (!single.setText) return;
if (!single.running) return single.setText(single.rest);
const q = single.q;
if (q?.status === 'Downloading') return single.setText(`Downloading ${Math.floor((q.pct || 0) * 100)}%`);
single.setText(q?.status === 'Converting' ? `Converting ${Math.floor((q.pct || 0) * 100)}%` : 'Preparing…');
}
function renderSingleOpts() {
if (!single.optsEl) return;
const qs = single.variants ? [...single.variants].sort((a, b) => a.h - b.h).map((v) => v.label) : null;
const sig = JSON.stringify([qs, single.opts, single.varError, single.varLoading]);
if (single.optsEl.dataset.sig !== sig) {
single.optsEl.dataset.sig = sig;
single.optsEl.innerHTML =
`<div class="xhdl-qrow">${qs ? qs.map((l) => chipHtml('quality', l, l, l === single.opts.quality)).join('')
: `<span class="xhdl-chip muted">${single.varError ? 'Qualities unavailable' : 'Loading…'}</span>`}</div>` +
`<div class="xhdl-frow">${chipHtml('format', 'native', 'Native', single.opts.format === 'native')}${chipHtml('format', 'mkv', 'MKV', single.opts.format === 'mkv')}</div>`;
}
const est = estSize(single.variants, single.opts.quality, window.initials?.videoModel?.duration);
single.sizeEl.textContent = est ? `≈ ${fmtSize(est)}` : '';
}
// Reads this page's own quality list (the same playlist the player loads).
async function loadSingleVariants() {
if (single.varLoading) return;
single.varLoading = true;
renderSingleOpts();
try {
single.variants = await getVariants(currentPageInfo().candidates);
single.varError = '';
single.opts.quality = chooseVariant(single.variants, single.opts.quality).label;
} catch (e) {
single.varError = e.message;
}
single.varLoading = false;
renderSingleOpts();
}
async function startSingle() {
const info = currentPageInfo();
const q = {
n: 1, key: info.vid, url: info.url, title: info.title, thumb: info.thumb, info,
variants: single.variants, quality: single.opts.quality, format: single.opts.format,
status: 'Preparing…', pct: 0, phase: 'busy', locked: true, ctrl: new AbortController(),
};
const panel = createPanel({ queue: [q], numbered: false, heading: 'Downloading video' });
const baseUpdate = panel.update;
panel.update = (item) => { baseUpdate(item); renderSingle(); };
Object.assign(single, { q, ctrl: q.ctrl, running: true });
guardUnload(1);
renderSingle();
let finished = false;
panel.onMain = async () => {
if (finished) { panel.close(); return; }
if (q.status === 'Downloading' && q.pct < 1) {
const choice = await panel.ask(q);
if (choice !== 'delete' || finished) return;
q.deleteAfter = true;
}
panel.setMain('Cancelling…', true);
q.ctrl.abort();
};
let rest = 'Download';
let ok = false;
try {
await runItem(q, panel);
ok = true;
rest = 'Saved ✓';
} catch (e) {
q.phase = 'idle';
if (e.name === 'AbortError') {
if (q.deleteAfter) {
await Store.remove(q.jobId);
q.status = 'Cancelled – deleted';
} else {
q.status = 'Cancelled – can resume';
rest = (await Store.dir()) ? `Resume (${Math.floor((q.pct || 0) * 100)}%)` : 'Download';
}
} else {
q.status = 'Failed';
q.error = e.message;
rest = 'Failed – retry';
console.error('[XH-DL]', e);
}
panel.update(q);
} finally {
single.running = false;
single.rest = rest;
finished = true;
guardUnload(-1);
renderSingle();
await panel.finish(ok ? 'Download complete' : q.status);
if (ok) setTimeout(() => { if (!single.running) { single.rest = 'Download'; renderSingle(); } }, 4000);
}
}
function buildSingleButton() {
const host = document.querySelector('.controls__main-actions');
if (!host || host.querySelector('[data-role="xh-dl"]')) return;
const { btn, setText } = makeSiteButton(ICON_DOWNLOAD);
single.setText = setText;
btn.setAttribute('aria-label', 'Download video');
btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (!single.running) startSingle(); // while running the button only shows progress
});
const wrap = document.createElement('div');
wrap.className = 'controls__main-action';
wrap.dataset.role = 'xh-dl';
wrap.innerHTML = '<div class="xhdl-single"><div class="xhdl-single-left"><span class="xhdl-single-size"></span></div><div class="xhdl-single-opts"></div></div>';
wrap.querySelector('.xhdl-single-left').prepend(btn);
single.sizeEl = wrap.querySelector('.xhdl-single-size');
single.optsEl = wrap.querySelector('.xhdl-single-opts');
single.optsEl.addEventListener('click', (e) => {
const chip = e.target.closest('.xhdl-chip[data-chip]');
if (!chip || single.running) return;
single.opts[chip.dataset.chip] = chip.dataset.v;
renderSingleOpts();
});
const share = host.querySelector('[data-role="video-share-button"]');
if (share) share.after(wrap); else host.appendChild(wrap);
renderSingle();
loadSingleVariants();
partialFor(vidFromUrl(location.href)).then((p) => {
if (p === undefined || single.running) return;
single.rest = p === null ? 'Resume' : `Resume (${Math.floor(p * 100)}%)`;
renderSingle();
});
}
// Hide the site's own download control so only ours remains.
function hideNativeDownload() {
document.querySelectorAll('.controls a, .controls button, .player-container a, .player-container button, [data-role*="download" i]').forEach((n) => {
if (n.closest('[data-role^="xh-dl"]') || n.dataset.xhdlHidden) return;
const role = n.getAttribute('data-role') || '';
const href = n.getAttribute('href') || '';
const text = (n.textContent || '').trim().toLowerCase();
if (/download/i.test(role) || /\/download(\/|$)/i.test(href) || text === 'download') {
const target = n.closest('.controls__main-action') || n;
if (target.closest('[data-role^="xh-dl"]')) return;
target.style.display = 'none';
n.dataset.xhdlHidden = '1';
}
});
}
// ======================= 10. batch selection =======================
const sel = { mode: false, order: [], items: new Map() };
const batch = { running: false, setText: null, cancelBtn: null, mainBtn: null };
const RELATED = '[data-role="related-tabs-container"]';
const listHasVideos = (el) => !!el?.querySelector('.thumb-list__item[data-video-type="video"] a[data-role="thumb-link"][href*="/videos/"]');
// Where tiles can be picked: related videos on a video page, the main video list elsewhere (home, search, …).
function batchScope() {
if (IS_VIDEO_PAGE) return document.querySelector(RELATED);
return [...document.querySelectorAll('[data-role="thumb-list-block"]')].find(listHasVideos) || null;
}
// Thumbnail URLs the page already lists (home and search keep them in window.initials).
function listedThumb(id) {
const i = window.initials || {};
const lists = [i.searchResult?.videoThumbProps, i.layoutPage?.videoListProps?.videoThumbProps, i.videoListProps?.videoThumbProps];
for (const l of lists) {
const v = Array.isArray(l) && l.find((x) => String(x.id) === String(id));
if (v) return v.imageURL || v.thumbURL || '';
}
return '';
}
function updateBatchLabel() {
if (!batch.setText) return;
const n = sel.order.length;
// Cancel exists only while selecting. style.display is used because the site's button class overrides [hidden].
if (batch.cancelBtn) batch.cancelBtn.style.display = sel.mode && !batch.running ? '' : 'none';
if (batch.mainBtn) batch.mainBtn.classList.toggle('xhdl-dim', sel.mode && !n && !batch.running);
if (batch.running) return batch.setText('Downloading…');
if (sel.mode) return batch.setText(n === 1 ? 'Start Download (1)' : n ? `Start Downloads (${n})` : 'Start Downloads');
batch.setText('Batch Download');
}
function setSelectMode(on) {
sel.mode = on;
if (!on) { sel.order = []; sel.items.clear(); }
document.documentElement.classList.toggle('xhdl-select', on);
decorate();
refreshMarks();
updateBatchLabel();
}
function decorate() {
const root = batchScope();
if (!root) return;
let added = false;
root.querySelectorAll('.thumb-list__item[data-video-id]').forEach((item) => {
if (item.querySelector(':scope > .xhdl-check')) return;
const a = item.querySelector('a[data-role="thumb-link"][href*="/videos/"]');
if (!a) return;
item.classList.add('xhdl-item');
item.dataset.xhdlKey = vidFromUrl(a.href);
const ring = document.createElement('div');
ring.className = 'xhdl-ring';
const chk = document.createElement('div');
chk.className = 'xhdl-check';
chk.innerHTML = '<span class="xhdl-box"></span><span class="xhdl-num"></span>';
item.append(ring, chk);
added = true;
});
if (added) refreshMarks();
}
function renderChips(el, it) {
let box = el.querySelector(':scope > .xhdl-chips');
if (!it) { box?.remove(); return; }
if (!box) { box = document.createElement('div'); box.className = 'xhdl-chips'; el.appendChild(box); }
const thumb = el.querySelector('a[data-role="thumb-link"]');
const h = `${thumb?.offsetHeight || 0}px`;
if (thumb && box.style.height !== h) box.style.height = h;
const qs = it.variants ? [...it.variants].sort((a, b) => a.h - b.h).map((v) => v.label) : null;
const size = estSize(it.variants, it.quality, it.info?.meta?.duration);
const sig = JSON.stringify([qs, it.quality, it.format, it.error || '', size]);
if (box.dataset.sig === sig) return;
box.dataset.sig = sig;
box.innerHTML =
(size ? `<span class="xhdl-sizechip">≈ ${fmtSize(size)}</span>` : '') +
`<div class="xhdl-frow">${chipHtml('format', 'native', 'Native', it.format === 'native')}${chipHtml('format', 'mkv', 'MKV', it.format === 'mkv')}</div>` +
`<div class="xhdl-qrow">${qs ? qs.map((l) => chipHtml('quality', l, l, l === it.quality)).join('') : `<span class="xhdl-chip muted">${it.error ? 'Unavailable' : 'Loading…'}</span>`}</div>`;
}
function refreshMarks() {
document.querySelectorAll('.xhdl-item').forEach((el) => {
const key = el.dataset.xhdlKey;
const i = sel.order.indexOf(key);
el.classList.toggle('xhdl-on', i >= 0);
const num = el.querySelector('.xhdl-num');
const text = i >= 0 ? String(i + 1) : '';
if (num && num.textContent !== text) num.textContent = text;
renderChips(el, i >= 0 ? sel.items.get(key) : null);
});
}
function toggleItem(item) {
const key = item.dataset.xhdlKey;
if (sel.items.has(key)) {
sel.items.delete(key);
sel.order = sel.order.filter((k) => k !== key);
} else {
const a = item.querySelector('a[data-role="thumb-link"]');
const img = item.querySelector('img');
const it = {
key, url: a.href,
title: a.getAttribute('aria-label') || a.title || img?.alt || key,
thumb: img?.currentSrc || img?.src || listedThumb(item.dataset.videoId),
quality: settings.quality, format: settings.format,
};
// Only network activity during selection: this video's page + its quality list.
it.loadPromise = (async () => {
try {
it.info = await fetchVideoInfo(it.url);
it.variants = await getVariants(it.info.candidates);
it.quality = chooseVariant(it.variants, it.quality).label;
return it;
} catch (e) {
it.error = e.message;
log('could not read qualities for', it.url, e.message);
return null;
} finally {
refreshMarks();
}
})();
sel.items.set(key, it);
sel.order.push(key);
}
refreshMarks();
updateBatchLabel();
}
document.addEventListener('click', (e) => {
if (!sel.mode || !(e.target instanceof Element) || e.button !== 0) return;
const item = e.target.closest('.thumb-list__item');
if (!item || !batchScope()?.contains(item)) return;
e.preventDefault();
e.stopImmediatePropagation();
if (!item.classList.contains('xhdl-item')) return;
const chip = e.target.closest('.xhdl-chip[data-chip]');
if (chip) {
const it = sel.items.get(item.dataset.xhdlKey);
if (it) { it[chip.dataset.chip] = chip.dataset.v; refreshMarks(); }
return;
}
toggleItem(item);
}, true);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && sel.mode && !batch.running) setSelectMode(false);
});
// ======================= 11. batch runner =======================
async function runBatch() {
const queue = sel.order.map((key, i) => {
const it = sel.items.get(key);
return {
n: i + 1, key, url: it.url, title: it.title, thumb: it.thumb,
info: it.info, variants: it.variants, infoError: !!it.error,
quality: it.quality, format: it.format, loadPromise: it.loadPromise,
status: 'Queued', pct: 0, bytes: 0, phase: 'idle', locked: false,
};
});
setSelectMode(false);
batch.running = true;
updateBatchLabel();
guardUnload(1);
const panel = createPanel({ queue, numbered: true, heading: `Downloading ${queue.length} video${queue.length > 1 ? 's' : ''}` });
for (const q of queue) {
if (!q.variants && q.loadPromise) {
q.loadPromise.then((it) => {
if (q.locked) return;
if (it) { q.info = it.info; q.variants = it.variants; q.quality = it.quality; } else q.infoError = true;
panel.update(q);
});
}
}
let cancelled = false;
let finished = false;
let current = null;
const startPreparing = (q) => {
if (!q || q.locked || cancelled) return;
q.locked = true;
q.status = 'Preparing…';
q.phase = 'busy';
q.ctrl = new AbortController();
panel.update(q);
q.prep = prepare(q, q.ctrl.signal).then(() => panel.update(q)).catch((e) => { q.prepErr = e; });
};
panel.onMain = async () => {
if (finished) { panel.close(); return; }
if (cancelled) return;
if (current && current.status === 'Downloading' && current.pct < 1) {
const choice = await panel.ask(current);
if (choice !== 'delete' || finished) return;
if (current && current.status === 'Downloading') current.deleteAfter = true;
}
cancelled = true;
panel.setMain('Cancelling…', true);
for (const q of queue) if (q.ctrl && q.phase === 'busy') q.ctrl.abort();
};
for (let i = 0; i < queue.length; i++) {
const q = queue[i];
if (cancelled) {
q.ctrl?.abort();
q.status = 'Cancelled';
q.phase = 'idle';
panel.update(q);
continue;
}
current = q;
const next = queue[i + 1];
startPreparing(q);
q.onProgress = (p) => { if (p >= PREPARE_AT) startPreparing(next); };
try {
await q.prep;
if (q.prepErr) throw q.prepErr;
await runItem(q, panel);
startPreparing(next);
} catch (e) {
q.phase = 'idle';
if (e.name === 'AbortError') {
q.status = q.deleteAfter ? 'Cancelled – deleted' : 'Cancelled';
if (q.deleteAfter) await Store.remove(q.jobId);
} else {
q.status = 'Failed';
q.error = e.message;
console.error('[XH-DL]', q.url, e);
}
}
panel.update(q);
}
current = null;
finished = true;
batch.running = false;
guardUnload(-1);
updateBatchLabel();
const ok = queue.filter((q) => q.phase === 'done').length;
await panel.finish(ok === queue.length ? 'All downloads complete' : `Finished – ${ok} of ${queue.length} saved`);
}
// Keeps the batch controls on screen once their original spot scrolls above the top edge.
function makeSticky(wrap) {
const ph = document.createElement('div');
ph.className = 'xhdl-batch-ph';
wrap.before(ph);
const update = () => {
if (!ph.isConnected) return;
const r = ph.getBoundingClientRect();
const pinned = wrap.classList.contains('xhdl-pinned');
if (r.top < 8) {
if (!pinned) {
ph.style.width = `${wrap.offsetWidth}px`;
ph.style.height = `${wrap.offsetHeight}px`;
wrap.classList.add('xhdl-pinned');
}
wrap.style.left = `${r.left}px`;
} else if (pinned) {
wrap.classList.remove('xhdl-pinned');
wrap.style.left = '';
ph.style.width = ph.style.height = '0px';
}
};
window.addEventListener('scroll', update, { passive: true });
window.addEventListener('resize', update);
update();
}
function buildBatchButtons() {
if (document.querySelector('[data-role="xh-dl-batch"]')) return;
let place;
if (IS_VIDEO_PAGE) {
const tablist = document.querySelector('[data-role="related-with-tabs-container"] [role="tablist"]');
if (!tablist) return;
place = (wrap) => tablist.after(wrap);
} else {
// List pages: wait until the page has fully loaded and settled, then add a bar at the top
// right of the video list (outside the list itself).
if (document.readyState !== 'complete') return;
const list = batchScope();
if (!list) return;
place = (wrap) => {
const bar = document.createElement('div');
bar.className = 'xhdl-listbar';
bar.dataset.role = 'xh-dl-listbar';
bar.appendChild(wrap);
list.before(bar);
};
}
const main = makeSiteButton(ICON_LIST);
batch.setText = main.setText;
main.btn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
if (batch.running) return;
if (!sel.mode) return setSelectMode(true);
if (!sel.order.length) return; // nothing selected yet; use Cancel to leave selection
runBatch();
});
batch.mainBtn = main.btn;
const cancel = makeSiteButton(ICON_CLOSE);
cancel.setText('Cancel');
cancel.btn.style.display = 'none';
batch.cancelBtn = cancel.btn;
cancel.btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); setSelectMode(false); });
const gear = makeSiteButton(ICON_GEAR, '0 0 24 24');
gear.setText('');
gear.btn.setAttribute('aria-label', 'Download settings');
gear.btn.title = 'Download settings';
gear.btn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); openSettings(); });
const wrap = document.createElement('div');
wrap.className = 'xhdl-batch-wrap';
wrap.dataset.role = 'xh-dl-batch';
wrap.append(main.btn, cancel.btn, gear.btn);
place(wrap);
makeSticky(wrap);
updateBatchLabel();
}
// ======================= 12. settings dialog =======================
function openSettings() {
const wrap = document.createElement('div');
wrap.className = 'xhdl-backdrop';
wrap.innerHTML = `
<div class="xhdl-modal xhdl-settings" role="dialog" aria-modal="true">
<h3 class="xhdl-title">Download settings</h3>
<label class="xhdl-field"><span>Default quality</span>
<select class="xhdl-sel" data-k="quality">${QUALITY_OPTIONS.map(([v, t]) => `<option value="${v}">${t}</option>`).join('')}</select></label>
<p class="xhdl-sub">If the chosen quality isn't offered for a video, the next lower one is used.</p>
<label class="xhdl-field"><span>Default file type</span>
<select class="xhdl-sel" data-k="format"><option value="mkv">MKV (recommended)</option><option value="native">Native (MP4)</option></select></label>
<div class="xhdl-info">
<b>Why MKV?</b> An MKV file keeps everything for a video together in one file:
<ul>
<li><b>Subtitles</b>: English (always listed first and on by default) plus the original-language track, which you can switch between in your player.</li>
<li><b>Metadata</b>: title, uploader, performers, categories, tags, description, upload date, views, likes, a 0–5 star rating, the creator's page (URL) and the video's page (Promotion URL).</li>
<li><b>Cover art</b>: the video's thumbnail, shown by players and media servers (Plex, Jellyfin, Kodi).</li>
<li>The same video and audio quality as Native; nothing is re-encoded.</li>
</ul>
MKV plays in VLC, mpv, MPC-HC, Kodi and most TV and media-server apps. <b>Native</b> saves the stream as a plain MP4 with no subtitles or tags; choose it if you need to play files in a web browser or a phone's gallery app.
</div>
<div class="xhdl-foot">
<button type="button" class="xhdl-btn" data-act="cancel">Cancel</button>
<button type="button" class="xhdl-btn xhdl-main" data-act="save">Save</button>
</div>
</div>`;
document.body.appendChild(wrap);
wrap.querySelector('[data-k="quality"]').value = settings.quality;
wrap.querySelector('[data-k="format"]').value = settings.format;
wrap.addEventListener('click', (e) => {
const act = e.target.closest('[data-act]')?.dataset.act;
if (e.target === wrap || act === 'cancel') wrap.remove();
if (act === 'save') {
settings = {
quality: wrap.querySelector('[data-k="quality"]').value,
format: wrap.querySelector('[data-k="format"]').value,
};
saveSettings(settings);
if (!single.running) {
single.opts = { quality: settings.quality, format: settings.format };
if (single.variants) single.opts.quality = chooseVariant(single.variants, settings.quality).label;
renderSingleOpts();
}
wrap.remove();
}
});
}
// ======================= 13. styles =======================
const CSS = `
html.xhdl-select .xhdl-item { cursor: pointer; }
.xhdl-listbar { display: flex; justify-content: flex-end; align-items: center; margin: 0 0 10px; }
.xhdl-listbar .xhdl-batch-wrap { margin-left: 0; }
.xhdl-item { position: relative; }
.xhdl-check { position: absolute; top: 8px; left: 8px; z-index: 31; display: none; gap: 6px; align-items: center; pointer-events: none; }
html.xhdl-select .xhdl-check { display: flex; }
.xhdl-box { width: 22px; height: 22px; box-sizing: border-box; border: 2px solid #fff; border-radius: 5px; background: rgba(0,0,0,.45);
box-shadow: 0 0 0 1px rgba(0,0,0,.55); display: flex; align-items: center; justify-content: center; transition: background .15s; }
.xhdl-on .xhdl-box { background: #22c55e; }
.xhdl-on .xhdl-box::after { content: ""; width: 5px; height: 10px; border: solid #fff; border-width: 0 2px 2px 0; transform: translateY(-1px) rotate(45deg); }
.xhdl-num { display: none; min-width: 22px; height: 22px; box-sizing: border-box; padding: 0 5px; background: #fff; color: #000;
border: 2px solid #000; border-radius: 3px; font: 700 12px/18px system-ui, sans-serif; text-align: center; }
.xhdl-on .xhdl-num { display: block; }
.xhdl-ring { display: none; position: absolute; inset: -4px; border: 3px solid #fff; border-radius: 10px; pointer-events: none; z-index: 29;
box-shadow: 0 0 12px 2px rgba(255,255,255,.7), 0 0 0 1px rgba(0,0,0,.35); animation: xhdl-pulse 1.6s ease-in-out infinite; }
html.xhdl-select .xhdl-on .xhdl-ring { display: block; }
@keyframes xhdl-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .3; } }
.xhdl-chips { position: absolute; top: 0; left: 0; right: 0; z-index: 30; pointer-events: none; display: none; }
html.xhdl-select .xhdl-chips { display: block; }
.xhdl-chips .xhdl-frow { position: absolute; top: 8px; right: 8px; display: flex; gap: 4px; }
.xhdl-chips .xhdl-qrow { position: absolute; left: 6px; right: 6px; bottom: 6px; display: flex; flex-wrap: wrap; gap: 4px; }
.xhdl-chip, .xhdl-sizechip { padding: 1px 6px; background: #fff; color: #000; border: 2px solid #000; border-radius: 3px;
font: 700 11px/16px system-ui, sans-serif; white-space: nowrap; }
.xhdl-chip { pointer-events: auto; cursor: pointer; }
.xhdl-chip.on { background: #22c55e; }
.xhdl-chip.muted { cursor: default; font-weight: 600; }
.xhdl-sizechip { position: absolute; top: 8px; left: 50%; transform: translateX(-50%); pointer-events: none; }
.xhdl-single { display: flex; align-items: flex-start; gap: 10px; }
.xhdl-single-left { display: flex; flex-direction: column; align-items: center; gap: 3px; }
.xhdl-single-size { font: 600 12px/1.2 system-ui, sans-serif; opacity: .85; min-height: 14px; }
.xhdl-single-opts { display: flex; flex-direction: column; gap: 4px; }
.xhdl-single-opts .xhdl-qrow, .xhdl-single-opts .xhdl-frow { display: flex; flex-wrap: wrap; gap: 4px; }
.xhdl-batch-wrap { display: flex; align-items: center; gap: 6px; margin-left: 12px; }
.xhdl-batch-ph { flex: none; width: 0; height: 0; }
.xhdl-dim { opacity: .55; cursor: default; }
.xhdl-batch-wrap.xhdl-pinned { position: fixed; top: 8px; z-index: 2147482000; margin: 0; padding: 6px; border-radius: 10px;
background: rgba(0,0,0,.6); box-shadow: 0 6px 20px rgba(0,0,0,.35); }
.xhdl-backdrop { position: fixed; inset: 0; z-index: 2147483000; background: rgba(0,0,0,.6); display: flex; align-items: center; justify-content: center; }
.xhdl-modal { --bg: #fff; --fg: #141414; --muted: #666; --line: #d9d9d9; --glass: rgba(255,255,255,.72); --fill: 0%;
width: min(1060px, 96vw); max-height: 86vh; display: flex; flex-direction: column; gap: 12px; box-sizing: border-box; padding: 20px;
color: var(--fg); border-radius: 12px; box-shadow: 0 20px 60px rgba(0,0,0,.45);
background: linear-gradient(90deg, #22c55e 0%, #16a34a var(--fill), transparent var(--fill)), var(--bg);
font: 14px/1.45 system-ui, -apple-system, "Segoe UI", sans-serif; }
html.dark .xhdl-modal { --bg: #1e1e1e; --fg: #f1f1f1; --muted: #b0b0b0; --line: #444; --glass: rgba(24,24,24,.72); }
.xhdl-title { margin: 0; font-size: 18px; text-align: center; padding: 6px 12px; border-radius: 8px; background: var(--glass); align-self: center; }
.xhdl-warn { margin: 0; padding: 8px 12px; border-radius: 8px; background: var(--glass); border-left: 4px solid #f59e0b; }
.xhdl-tablewrap { overflow: auto; border: 1px solid var(--line); border-radius: 8px; background: var(--glass); }
.xhdl-table { width: 100%; border-collapse: collapse; }
.xhdl-table th, .xhdl-table td { padding: 8px 10px; border-bottom: 1px solid var(--line); vertical-align: middle; text-align: center; }
.xhdl-table th { font-weight: 600; color: var(--muted); }
.xhdl-table tr:last-child td { border-bottom: 0; }
.xhdl-table .c-n { font-weight: 700; width: 28px; }
.xhdl-table .c-name { text-align: left; max-width: 260px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.xhdl-table .c-status, .xhdl-table .c-link, .xhdl-table .c-size { white-space: nowrap; }
.xhdl-link { color: inherit; text-decoration: underline; font-weight: 600; }
.c-status[data-state="done"] { color: #15803d; font-weight: 700; }
html.dark .c-status[data-state="done"] { color: #4ade80; }
.c-status[data-state="failed"] { color: #dc2626; font-weight: 600; }
.c-status[data-state="cancelled"] { color: var(--muted); }
.xhdl-hover { display: inline-block; padding: 6px 12px; border: 1px dashed var(--muted); border-radius: 6px; color: var(--muted); font-size: 12px; cursor: default; }
.xhdl-preview { position: fixed; z-index: 2147483600; display: none; width: 320px; height: auto; border-radius: 8px; border: 2px solid #fff;
box-shadow: 0 10px 30px rgba(0,0,0,.5); pointer-events: none; background: #000; }
.xhdl-sel { background: var(--bg); color: var(--fg); border: 1px solid var(--line); border-radius: 6px; padding: 3px 6px; font: inherit; }
.xhdl-sel:disabled { opacity: .7; }
.c-prog { white-space: nowrap; }
.xhdl-progwrap { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; }
.xhdl-progwrap span { font-size: 12px; line-height: 1; }
.xhdl-bar { display: block; width: 90px; height: 6px; border-radius: 3px; background: var(--line); overflow: hidden; }
.xhdl-bar i { display: block; height: 100%; width: 0; background: #22c55e; transition: width .2s; }
.xhdl-confirm { padding: 12px; border: 1px solid #dc2626; border-radius: 8px; background: var(--bg); }
.xhdl-confirm p { margin: 0 0 10px; }
.xhdl-confirm-btns { display: flex; gap: 8px; justify-content: flex-end; }
.xhdl-foot { display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
.xhdl-cache { margin-right: auto; font-size: 13px; padding: 2px 8px; border-radius: 6px; background: var(--glass); }
.xhdl-cache:empty { display: none; }
.xhdl-btn { padding: 8px 16px; border-radius: 8px; border: 1px solid var(--line); background: var(--bg); color: var(--fg);
font: 600 14px system-ui, sans-serif; cursor: pointer; }
.xhdl-btn:hover:not(:disabled) { border-color: var(--fg); }
.xhdl-btn:disabled { opacity: .5; cursor: default; }
.xhdl-main { background: var(--fg); color: var(--bg); border-color: var(--fg); }
.xhdl-danger { border-color: #dc2626; color: #dc2626; }
.xhdl-settings { width: min(560px, 94vw); background: var(--bg); }
.xhdl-settings .xhdl-title { background: none; }
.xhdl-field { display: flex; align-items: center; justify-content: space-between; gap: 12px; font-weight: 600; }
.xhdl-sub { margin: -6px 0 0; color: var(--muted); font-size: 12px; }
.xhdl-info { padding: 10px 14px; border-radius: 8px; background: rgba(34,197,94,.12); border: 1px solid rgba(34,197,94,.45); font-size: 13px; }
.xhdl-info ul { margin: 6px 0; padding-left: 18px; }
`;
// ======================= 14. bootstrap =======================
let scheduled = false;
function tick() {
scheduled = false;
if (IS_VIDEO_PAGE) { buildSingleButton(); hideNativeDownload(); }
buildBatchButtons();
if (sel.mode) decorate();
if (single.optsEl && !single.variants && !single.varLoading && single.varError && seen.size) loadSingleVariants();
}
function start() {
const style = document.createElement('style');
style.textContent = CSS;
document.head.appendChild(style);
tick();
new MutationObserver(() => {
if (!scheduled) { scheduled = true; requestAnimationFrame(tick); }
}).observe(document.body, { childList: true, subtree: true });
janitor().catch((e) => log('cleanup error', e));
}
window.addEventListener('load', () => setTimeout(() => { if (!scheduled) { scheduled = true; requestAnimationFrame(tick); } }, 300));
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
else start();
window.__xhdl = { cacheInfo, clearCache, janitor, settings: () => settings };
})();