// ==UserScript==
// @name Rou Video Downloader
// @namespace http://tampermonkey.net/
// @version 2.5
// @description Finds HLS playlists, decrypts, remuxes to MP4, and gets the video title from the page tab.
// @author Gemini
// @match *://*/*
// @resource CRYPTOJS https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.2.0/crypto-js.min.js
// @resource MUXJS https://cdn.jsdelivr.net/npm/[email protected]/dist/mux.min.js
// @grant unsafeWindow
// @grant GM_xmlhttpRequest
// @grant GM_getResourceText
// @connect *
// @run-at document-start
// @all-frames true
// @license MIT
// ==/UserScript==
(function() {
'use strict';
let isCryptoLoaded = false;
let isMuxLoaded = false;
try {
eval(GM_getResourceText('CRYPTOJS'));
isCryptoLoaded = true;
console.log('CryptoJS library loaded successfully.');
} catch (e) {
console.error('FATAL: Could not load CryptoJS library. Decryption will fail.', e);
}
try {
eval(GM_getResourceText('MUXJS'));
isMuxLoaded = true;
console.log('Mux.js library loaded successfully.');
} catch (e) {
console.error('FATAL: Could not load Mux.js library. Remuxing will fail.', e);
}
console.log('Playlist Hunter Script v2.5 (with Remuxer) Loaded in frame:', window.location.href);
let masterPlaylistUrl = null;
let totalSegments = 0;
let decryptionKey = null;
let iv = null;
const originalFetch = unsafeWindow.fetch;
unsafeWindow.fetch = async function(...args) {
const url = args[0] instanceof Request ? args[0].url : args[0];
checkForPlaylist(url);
return originalFetch.apply(this, args);
};
const originalXhrOpen = unsafeWindow.XMLHttpRequest.prototype.open;
unsafeWindow.XMLHttpRequest.prototype.open = function(...args) {
const url = args[1];
checkForPlaylist(url);
return originalXhrOpen.apply(this, args);
};
function checkForPlaylist(url) {
if (masterPlaylistUrl) return;
if (typeof url === 'string' && url.toLowerCase().includes('.m3u8')) {
console.log(`SUCCESS: Playlist HLS ditemukan! URL: ${url}`);
masterPlaylistUrl = url;
if (downloadButton) {
updateButtonState('found');
}
}
}
let downloadButton = null;
let statusText = null;
function createButtons() {
if (document.getElementById('playlist-download-container-v2-5')) return;
const container = document.createElement('div');
container.id = 'playlist-download-container-v2-5';
Object.assign(container.style, {
position: 'fixed',
top: '15px',
right: '15px',
zIndex: '99999',
display: 'flex',
flexDirection: 'column',
gap: '8px',
backgroundColor: 'rgba(0, 0, 0, 0.7)',
padding: '10px',
borderRadius: '8px'
});
downloadButton = document.createElement('button');
Object.assign(downloadButton.style, {
color: 'white',
border: 'none',
padding: '12px 22px',
borderRadius: '8px',
cursor: 'pointer',
fontSize: '16px',
fontWeight: 'bold',
transition: 'background-color 0.3s'
});
downloadButton.addEventListener('click', downloadFullStream);
statusText = document.createElement('p');
Object.assign(statusText.style, {
color: 'white',
margin: '0',
textAlign: 'center',
fontSize: '12px',
fontWeight: 'normal'
});
container.appendChild(downloadButton);
container.appendChild(statusText);
document.body.appendChild(container);
updateButtonState(masterPlaylistUrl ? 'found' : 'initial');
}
function updateButtonState(state, progress = {}) {
if (!downloadButton) return;
switch (state) {
case 'initial':
downloadButton.innerHTML = '⏳ Mencari Playlist...';
downloadButton.style.backgroundColor = '#6c757d';
downloadButton.disabled = true;
statusText.textContent = 'Putar video untuk mendeteksi.';
break;
case 'found':
downloadButton.innerHTML = '⬇️ Unduh Video Lengkap';
downloadButton.style.backgroundColor = '#28a745';
downloadButton.disabled = false;
statusText.textContent = 'Playlist ditemukan! Siap unduh.';
break;
case 'downloading':
downloadButton.innerHTML = '📥 Mengunduh...';
downloadButton.style.backgroundColor = '#fd7e14';
downloadButton.disabled = true;
statusText.textContent = `Segmen: ${progress.downloaded}/${progress.total}`;
break;
case 'decrypting':
downloadButton.innerHTML = '🔑 Mendekripsi...';
downloadButton.style.backgroundColor = '#6f42c1';
downloadButton.disabled = true;
statusText.textContent = `Segmen: ${progress.decrypted}/${progress.total}`;
break;
case 'remuxing':
downloadButton.innerHTML = '⚙️ Remuxing...';
downloadButton.style.backgroundColor = '#ffc107';
downloadButton.disabled = true;
statusText.textContent = `Memproses segmen ke MP4...`;
break;
case 'error':
downloadButton.innerHTML = '❌ Gagal';
downloadButton.style.backgroundColor = '#dc3545';
downloadButton.disabled = false;
statusText.textContent = 'Terjadi kesalahan. Coba lagi.';
break;
}
}
async function downloadFullStream() {
if (!masterPlaylistUrl) {
alert('URL Playlist tidak ditemukan. Coba putar video sebentar.');
return;
}
updateButtonState('downloading', { downloaded: 0, total: '??' });
try {
let mediaPlaylistUrl = masterPlaylistUrl;
let playlistContent = await fetchUrl(masterPlaylistUrl, 'text');
if (playlistContent.includes('#EXT-X-STREAM-INF')) {
console.log('Master playlist terdeteksi. Mencari playlist media...');
const mediaPlaylistPath = playlistContent.split('\n').find(line => line.trim() && !line.startsWith('#'));
if (!mediaPlaylistPath) throw new Error('Master playlist tidak berisi link ke media playlist.');
mediaPlaylistUrl = new URL(mediaPlaylistPath, masterPlaylistUrl).toString();
console.log('Menggunakan media playlist:', mediaPlaylistUrl);
playlistContent = await fetchUrl(mediaPlaylistUrl, 'text');
}
const baseUrl = new URL(mediaPlaylistUrl);
baseUrl.pathname = baseUrl.pathname.split('/').slice(0, -1).join('/');
const keyTag = playlistContent.split('\n').find(line => line.startsWith('#EXT-X-KEY'));
if (keyTag) {
if (!isCryptoLoaded) throw new Error('Gagal mendekripsi: Library kripto tidak dapat dimuat.');
const uriMatch = keyTag.match(/URI="([^"]+)"/);
if (uriMatch) {
const keyUrl = new URL(uriMatch[1], baseUrl.toString()).toString();
decryptionKey = await fetchUrl(keyUrl, 'arraybuffer');
}
const ivMatch = keyTag.match(/IV=0x([0-9a-fA-F]+)/);
if (ivMatch) iv = CryptoJS.enc.Hex.parse(ivMatch[1]);
}
const segmentUrls = playlistContent.split('\n').filter(line => line.trim() && !line.startsWith('#'));
totalSegments = segmentUrls.length;
if (totalSegments === 0) throw new Error('Playlist tidak berisi segmen.');
const fullSegmentUrls = segmentUrls.map(segment => new URL(segment, baseUrl.toString()).toString());
const allChunks = [];
for (let i = 0; i < fullSegmentUrls.length; i++) {
updateButtonState('downloading', { downloaded: i + 1, total: totalSegments });
let chunk = await fetchUrl(fullSegmentUrls[i], 'arraybuffer');
if (decryptionKey) {
updateButtonState('decrypting', { decrypted: i + 1, total: totalSegments });
chunk = decryptChunk(chunk, decryptionKey, iv || i);
}
allChunks.push(new Uint8Array(chunk));
}
updateButtonState('remuxing');
if (!isMuxLoaded) throw new Error('Gagal remuxing: Library Mux.js tidak dapat dimuat.');
const remuxedSegments = [];
const transmuxer = new muxjs.mp4.Transmuxer();
transmuxer.on('data', segment => {
remuxedSegments.push(segment.initSegment);
remuxedSegments.push(segment.data);
});
await new Promise((resolve, reject) => {
transmuxer.on('done', resolve);
transmuxer.on('error', reject);
allChunks.forEach(chunk => transmuxer.push(chunk));
transmuxer.flush();
});
// --- PERUBAHAN KUNCI: Mengambil Judul Video dari Tag <title> ---
let videoTitle = `video_lengkap_${new Date().getTime()}`;
if (document.title) {
// Ambil bagian pertama dari judul tab, sebelum tanda " - "
const mainTitle = document.title.split(' - ')[0];
// Membersihkan judul dari karakter yang tidak valid untuk nama file
videoTitle = mainTitle.trim().replace(/[\\?%*|:"<>]/g, '-');
console.log(`Judul video ditemukan: "${videoTitle}"`);
}
const mediaBlob = new Blob(remuxedSegments, { type: 'video/mp4' });
const anchor = document.createElement('a');
anchor.href = URL.createObjectURL(mediaBlob);
anchor.download = `${videoTitle}.mp4`;
anchor.click();
URL.revokeObjectURL(anchor.href);
updateButtonState('found');
} catch (error) {
console.error('Gagal mengunduh stream lengkap:', error);
alert(`Terjadi kesalahan: ${error.message}`);
updateButtonState('error');
}
}
function fetchUrl(url, responseType) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: url,
responseType: responseType === 'arraybuffer' ? 'arraybuffer' : undefined,
onload: (res) => {
if (res.status === 200) resolve(responseType === 'arraybuffer' ? res.response : res.responseText);
else reject(`Gagal mengambil: ${url} - Status: ${res.status}`);
},
onerror: (err) => reject(`Kesalahan jaringan saat mengambil: ${url}`)
});
});
}
function decryptChunk(encryptedData, key, iv) {
const encryptedWords = CryptoJS.lib.WordArray.create(encryptedData);
const keyWords = CryptoJS.lib.WordArray.create(key);
let ivWords = iv;
if (typeof iv === 'number') {
const ivArray = new Uint8Array(16);
for (let i = 15; i >= 0; i--) {
ivArray[i] = iv & 0xff;
iv >>= 8;
}
ivWords = CryptoJS.lib.WordArray.create(ivArray.buffer);
}
const decrypted = CryptoJS.AES.decrypt({ ciphertext: encryptedWords }, keyWords, { iv: ivWords, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.NoPadding });
const latin1String = CryptoJS.enc.Latin1.stringify(decrypted);
const decryptedUint8Array = new Uint8Array(latin1String.length);
for (let i = 0; i < latin1String.length; i++) {
decryptedUint8Array[i] = latin1String.charCodeAt(i);
}
return decryptedUint8Array.buffer;
}
function setupObserver() {
const observer = new MutationObserver((_, obs) => {
if (document.querySelector('video')) {
createButtons();
obs.disconnect();
}
});
if (document.body) {
observer.observe(document.body, { childList: true, subtree: true });
} else {
new MutationObserver((_, obs) => {
if (document.body) {
observer.observe(document.body, { childList: true, subtree: true });
obs.disconnect();
}
}).observe(document.documentElement, { childList: true });
}
}
setupObserver();
})();