Extract HLS/m3u8/MP4 video URLs with pagination, quality selection, and batch download support
Size
43.1 KB
Version
1.1.3
Created
Nov 14, 2025
Updated
28 days ago
1// ==UserScript==
2// @name Adult Time Video URL Extractor & Downloader
3// @description Extract HLS/m3u8/MP4 video URLs with pagination, quality selection, and batch download support
4// @version 1.1.3
5// @match https://*.members.adulttime.com/*
6// @icon https://static03-cms-fame.gammacdn.com/adulttime/m/9tylqmdgtn8cgc0g/favicon-16x16.png
7// @grant GM.getValue
8// @grant GM.setValue
9// @grant GM.xmlHttpRequest
10// @grant GM.setClipboard
11// @grant GM.openInTab
12// ==/UserScript==
13(function() {
14 'use strict';
15
16 // ============================================
17 // STATE MANAGEMENT
18 // ============================================
19 let state = {
20 isRunning: false,
21 isPaused: false,
22 currentVideoIndex: 0,
23 totalVideosToProcess: 0,
24 processedVideos: 0,
25 extractedURLs: [],
26 currentPage: 1,
27 failedVideos: [],
28 videoQueue: [],
29 returnURL: null
30 };
31
32 let capturedURLs = [];
33 let isCapturing = false;
34
35 // ============================================
36 // UTILITY FUNCTIONS
37 // ============================================
38
39 function sleep(ms) {
40 return new Promise(resolve => setTimeout(resolve, ms));
41 }
42
43 async function saveState() {
44 await GM.setValue('videoExtractorState', JSON.stringify(state));
45 console.log('[State] Saved:', state);
46 }
47
48 async function loadState() {
49 try {
50 const savedState = await GM.getValue('videoExtractorState', null);
51 if (savedState) {
52 const parsed = JSON.parse(savedState);
53 state = { ...state, ...parsed, isRunning: false, isPaused: false };
54 console.log('[State] Loaded:', state);
55 return true;
56 }
57 } catch (e) {
58 console.error('[State] Error loading:', e);
59 }
60 return false;
61 }
62
63 async function clearState() {
64 state = {
65 isRunning: false,
66 isPaused: false,
67 currentVideoIndex: 0,
68 totalVideosToProcess: 0,
69 processedVideos: 0,
70 extractedURLs: [],
71 currentPage: 1,
72 failedVideos: [],
73 videoQueue: [],
74 returnURL: null
75 };
76 await saveState();
77 console.log('[State] Cleared');
78 }
79
80 // ============================================
81 // NETWORK INTERCEPTION
82 // ============================================
83
84 function setupNetworkInterceptor() {
85 console.log('[Interceptor] Setting up network capture...');
86
87 // Intercept fetch requests
88 const originalFetch = window.fetch;
89 window.fetch = function(...args) {
90 const url = typeof args[0] === 'string' ? args[0] : args[0]?.url;
91
92 if (isCapturing && url) {
93 if (url.includes('.m3u8') || url.includes('.mp4') || url.includes('.mpd') ||
94 url.includes('streaming') || url.includes('gammacdn')) {
95 console.log('[Interceptor] Captured fetch URL:', url);
96 capturedURLs.push({ url, type: 'fetch', timestamp: Date.now() });
97 }
98 }
99
100 return originalFetch.apply(this, args);
101 };
102
103 // Intercept XMLHttpRequest
104 const originalXHROpen = XMLHttpRequest.prototype.open;
105 XMLHttpRequest.prototype.open = function(method, url) {
106 if (isCapturing && url) {
107 if (url.includes('.m3u8') || url.includes('.mp4') || url.includes('.mpd') ||
108 url.includes('streaming') || url.includes('gammacdn')) {
109 console.log('[Interceptor] Captured XHR URL:', url);
110 capturedURLs.push({ url, type: 'xhr', timestamp: Date.now() });
111 }
112 }
113
114 return originalXHROpen.apply(this, arguments);
115 };
116
117 console.log('[Interceptor] Network capture ready');
118 }
119
120 // ============================================
121 // UI CREATION
122 // ============================================
123
124 function createControlPanel() {
125 // Remove existing panel if any
126 const existing = document.getElementById('video-url-extractor-panel');
127 if (existing) existing.remove();
128
129 const panel = document.createElement('div');
130 panel.id = 'video-url-extractor-panel';
131 panel.innerHTML = `
132 <div class="vex-header">
133 <h3>🎬 Video URL Extractor</h3>
134 <button id="vex-minimize" title="Minimize">−</button>
135 </div>
136 <div class="vex-content">
137 <div class="vex-section">
138 <label for="vex-video-count">Number of videos to extract:</label>
139 <input type="number" id="vex-video-count" min="1" value="10" />
140 </div>
141
142 <div class="vex-section">
143 <label for="vex-quality">Preferred Quality:</label>
144 <select id="vex-quality">
145 <option value="1080">1080p (Preferred)</option>
146 <option value="720">720p</option>
147 <option value="480">480p</option>
148 <option value="highest">Highest Available</option>
149 </select>
150 </div>
151
152 <div class="vex-section">
153 <label>
154 <input type="checkbox" id="vex-auto-pagination" checked />
155 Auto-paginate to find videos
156 </label>
157 </div>
158
159 <div class="vex-section">
160 <label>
161 <input type="checkbox" id="vex-skip-processed" checked />
162 Skip already processed videos
163 </label>
164 </div>
165
166 <div class="vex-controls">
167 <button id="vex-start" class="vex-btn vex-btn-primary">▶ Start Extraction</button>
168 <button id="vex-pause" class="vex-btn vex-btn-warning" disabled>⏸ Pause</button>
169 <button id="vex-stop" class="vex-btn vex-btn-danger" disabled>⏹ Stop</button>
170 <button id="vex-resume" class="vex-btn vex-btn-success" style="display:none;">▶ Resume</button>
171 </div>
172
173 <div class="vex-progress-section">
174 <div class="vex-progress-info">
175 <span id="vex-status">Ready</span>
176 <span id="vex-progress-text">0/0</span>
177 </div>
178 <div class="vex-progress-bar">
179 <div id="vex-progress-fill"></div>
180 </div>
181 <div id="vex-current-video" class="vex-current-video"></div>
182 </div>
183
184 <div class="vex-results-section">
185 <div class="vex-results-header">
186 <h4>Extracted URLs (<span id="vex-url-count">0</span>)</h4>
187 <div class="vex-results-actions">
188 <button id="vex-copy-all" class="vex-btn vex-btn-small" disabled>📋 Copy All</button>
189 <button id="vex-copy-m3u8" class="vex-btn vex-btn-small" disabled>📋 Copy M3U8</button>
190 <button id="vex-export-txt" class="vex-btn vex-btn-small" disabled>💾 Export TXT</button>
191 <button id="vex-export-json" class="vex-btn vex-btn-small" disabled>💾 Export JSON</button>
192 <button id="vex-clear-urls" class="vex-btn vex-btn-small vex-btn-danger" disabled>🗑 Clear</button>
193 </div>
194 </div>
195 <div id="vex-url-list" class="vex-url-list"></div>
196 </div>
197
198 <div class="vex-stats">
199 <div class="vex-stat">
200 <span class="vex-stat-label">Success:</span>
201 <span id="vex-stat-success" class="vex-stat-value">0</span>
202 </div>
203 <div class="vex-stat">
204 <span class="vex-stat-label">Failed:</span>
205 <span id="vex-stat-failed" class="vex-stat-value">0</span>
206 </div>
207 <div class="vex-stat">
208 <span class="vex-stat-label">Current Page:</span>
209 <span id="vex-stat-page" class="vex-stat-value">1</span>
210 </div>
211 </div>
212 </div>
213 `;
214
215 document.body.appendChild(panel);
216 addStyles();
217 attachEventListeners();
218 }
219
220 function addStyles() {
221 if (document.getElementById('vex-styles')) return;
222
223 const styleSheet = document.createElement('style');
224 styleSheet.id = 'vex-styles';
225 styleSheet.textContent = `
226 #video-url-extractor-panel {
227 position: fixed;
228 top: 20px;
229 right: 20px;
230 width: 450px;
231 max-height: 90vh;
232 background: linear-gradient(135deg, #1e1e1e 0%, #2d2d2d 100%);
233 border: 2px solid #444;
234 border-radius: 12px;
235 box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
236 z-index: 999999;
237 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
238 color: #fff;
239 overflow: hidden;
240 backdrop-filter: blur(10px);
241 }
242
243 .vex-header {
244 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
245 padding: 15px 20px;
246 display: flex;
247 justify-content: space-between;
248 align-items: center;
249 cursor: move;
250 user-select: none;
251 }
252
253 .vex-header h3 {
254 margin: 0;
255 font-size: 18px;
256 font-weight: 600;
257 }
258
259 #vex-minimize {
260 background: rgba(255, 255, 255, 0.2);
261 border: none;
262 color: white;
263 width: 30px;
264 height: 30px;
265 border-radius: 50%;
266 cursor: pointer;
267 font-size: 20px;
268 display: flex;
269 align-items: center;
270 justify-content: center;
271 transition: all 0.3s;
272 }
273
274 #vex-minimize:hover {
275 background: rgba(255, 255, 255, 0.3);
276 transform: scale(1.1);
277 }
278
279 .vex-content {
280 padding: 20px;
281 max-height: calc(90vh - 70px);
282 overflow-y: auto;
283 }
284
285 .vex-content::-webkit-scrollbar {
286 width: 8px;
287 }
288
289 .vex-content::-webkit-scrollbar-track {
290 background: #2d2d2d;
291 }
292
293 .vex-content::-webkit-scrollbar-thumb {
294 background: #667eea;
295 border-radius: 4px;
296 }
297
298 .vex-section {
299 margin-bottom: 15px;
300 }
301
302 .vex-section label {
303 display: block;
304 margin-bottom: 5px;
305 font-size: 13px;
306 color: #ccc;
307 }
308
309 .vex-section input[type="number"],
310 .vex-section select {
311 width: 100%;
312 padding: 10px;
313 background: #3a3a3a;
314 border: 1px solid #555;
315 border-radius: 6px;
316 color: #fff;
317 font-size: 14px;
318 transition: all 0.3s;
319 }
320
321 .vex-section input[type="number"]:focus,
322 .vex-section select:focus {
323 outline: none;
324 border-color: #667eea;
325 box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2);
326 }
327
328 .vex-section input[type="checkbox"] {
329 margin-right: 8px;
330 cursor: pointer;
331 }
332
333 .vex-controls {
334 display: flex;
335 gap: 8px;
336 margin: 20px 0;
337 flex-wrap: wrap;
338 }
339
340 .vex-btn {
341 flex: 1;
342 padding: 10px 15px;
343 border: none;
344 border-radius: 6px;
345 cursor: pointer;
346 font-size: 13px;
347 font-weight: 600;
348 transition: all 0.3s;
349 min-width: 80px;
350 }
351
352 .vex-btn:disabled {
353 opacity: 0.5;
354 cursor: not-allowed;
355 }
356
357 .vex-btn-primary {
358 background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
359 color: white;
360 }
361
362 .vex-btn-primary:hover:not(:disabled) {
363 transform: translateY(-2px);
364 box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4);
365 }
366
367 .vex-btn-warning {
368 background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
369 color: white;
370 }
371
372 .vex-btn-danger {
373 background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
374 color: white;
375 }
376
377 .vex-btn-success {
378 background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
379 color: white;
380 }
381
382 .vex-btn-small {
383 padding: 6px 10px;
384 font-size: 11px;
385 min-width: auto;
386 }
387
388 .vex-progress-section {
389 background: #2a2a2a;
390 padding: 15px;
391 border-radius: 8px;
392 margin: 15px 0;
393 }
394
395 .vex-progress-info {
396 display: flex;
397 justify-content: space-between;
398 margin-bottom: 8px;
399 font-size: 13px;
400 }
401
402 #vex-status {
403 color: #4facfe;
404 font-weight: 600;
405 }
406
407 .vex-progress-bar {
408 height: 8px;
409 background: #3a3a3a;
410 border-radius: 4px;
411 overflow: hidden;
412 }
413
414 #vex-progress-fill {
415 height: 100%;
416 background: linear-gradient(90deg, #667eea 0%, #764ba2 100%);
417 width: 0%;
418 transition: width 0.3s;
419 }
420
421 .vex-current-video {
422 margin-top: 10px;
423 font-size: 12px;
424 color: #aaa;
425 font-style: italic;
426 min-height: 18px;
427 }
428
429 .vex-results-section {
430 margin: 20px 0;
431 }
432
433 .vex-results-header {
434 display: flex;
435 justify-content: space-between;
436 align-items: center;
437 margin-bottom: 10px;
438 flex-wrap: wrap;
439 gap: 10px;
440 }
441
442 .vex-results-header h4 {
443 margin: 0;
444 font-size: 15px;
445 }
446
447 .vex-results-actions {
448 display: flex;
449 gap: 5px;
450 flex-wrap: wrap;
451 }
452
453 .vex-url-list {
454 background: #2a2a2a;
455 border-radius: 8px;
456 max-height: 300px;
457 overflow-y: auto;
458 padding: 10px;
459 }
460
461 .vex-url-list::-webkit-scrollbar {
462 width: 6px;
463 }
464
465 .vex-url-list::-webkit-scrollbar-thumb {
466 background: #667eea;
467 border-radius: 3px;
468 }
469
470 .vex-url-item {
471 background: #3a3a3a;
472 padding: 10px;
473 margin-bottom: 8px;
474 border-radius: 6px;
475 font-size: 12px;
476 border-left: 3px solid #667eea;
477 }
478
479 .vex-url-item-title {
480 font-weight: 600;
481 color: #fff;
482 margin-bottom: 5px;
483 display: flex;
484 justify-content: space-between;
485 align-items: center;
486 }
487
488 .vex-url-item-quality {
489 background: #667eea;
490 padding: 2px 8px;
491 border-radius: 4px;
492 font-size: 10px;
493 font-weight: 600;
494 }
495
496 .vex-url-item-url {
497 color: #4facfe;
498 word-break: break-all;
499 font-family: monospace;
500 font-size: 11px;
501 }
502
503 .vex-url-item-actions {
504 margin-top: 8px;
505 display: flex;
506 gap: 5px;
507 }
508
509 .vex-stats {
510 display: flex;
511 justify-content: space-around;
512 background: #2a2a2a;
513 padding: 15px;
514 border-radius: 8px;
515 margin-top: 15px;
516 }
517
518 .vex-stat {
519 text-align: center;
520 }
521
522 .vex-stat-label {
523 display: block;
524 font-size: 11px;
525 color: #aaa;
526 margin-bottom: 5px;
527 }
528
529 .vex-stat-value {
530 display: block;
531 font-size: 20px;
532 font-weight: 700;
533 color: #667eea;
534 }
535
536 .vex-minimized .vex-content {
537 display: none;
538 }
539
540 .vex-minimized {
541 width: 250px;
542 }
543
544 @keyframes pulse {
545 0%, 100% { opacity: 1; }
546 50% { opacity: 0.5; }
547 }
548
549 .vex-pulse {
550 animation: pulse 2s infinite;
551 }
552 `;
553 document.head.appendChild(styleSheet);
554 }
555
556 function attachEventListeners() {
557 // Minimize/Maximize
558 document.getElementById('vex-minimize').addEventListener('click', () => {
559 const panel = document.getElementById('video-url-extractor-panel');
560 panel.classList.toggle('vex-minimized');
561 const btn = document.getElementById('vex-minimize');
562 btn.textContent = panel.classList.contains('vex-minimized') ? '+' : '−';
563 });
564
565 // Make panel draggable
566 makeDraggable(document.getElementById('video-url-extractor-panel'));
567
568 // Control buttons
569 document.getElementById('vex-start').addEventListener('click', startExtraction);
570 document.getElementById('vex-pause').addEventListener('click', pauseExtraction);
571 document.getElementById('vex-stop').addEventListener('click', stopExtraction);
572 document.getElementById('vex-resume').addEventListener('click', resumeExtraction);
573
574 // Export buttons
575 document.getElementById('vex-copy-all').addEventListener('click', () => copyURLs('all'));
576 document.getElementById('vex-copy-m3u8').addEventListener('click', () => copyURLs('m3u8'));
577 document.getElementById('vex-export-txt').addEventListener('click', () => exportURLs('txt'));
578 document.getElementById('vex-export-json').addEventListener('click', () => exportURLs('json'));
579 document.getElementById('vex-clear-urls').addEventListener('click', clearURLs);
580 }
581
582 function makeDraggable(element) {
583 let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
584 const header = element.querySelector('.vex-header');
585
586 header.onmousedown = dragMouseDown;
587
588 function dragMouseDown(e) {
589 e.preventDefault();
590 pos3 = e.clientX;
591 pos4 = e.clientY;
592 document.onmouseup = closeDragElement;
593 document.onmousemove = elementDrag;
594 }
595
596 function elementDrag(e) {
597 e.preventDefault();
598 pos1 = pos3 - e.clientX;
599 pos2 = pos4 - e.clientY;
600 pos3 = e.clientX;
601 pos4 = e.clientY;
602 element.style.top = (element.offsetTop - pos2) + 'px';
603 element.style.left = (element.offsetLeft - pos1) + 'px';
604 element.style.right = 'auto';
605 }
606
607 function closeDragElement() {
608 document.onmouseup = null;
609 document.onmousemove = null;
610 }
611 }
612
613 // ============================================
614 // EXTRACTION LOGIC - IN-PAGE METHOD
615 // ============================================
616
617 async function startExtraction() {
618 const videoCount = parseInt(document.getElementById('vex-video-count').value);
619 if (videoCount < 1) {
620 alert('Please enter a valid number of videos (minimum 1)');
621 return;
622 }
623
624 // Check if we're on a video listing page
625 const isListingPage = window.location.href.includes('/videos') ||
626 document.querySelectorAll('a.SceneThumb-SceneImageLink-Link[href*="/video/"]').length > 0;
627
628 if (!isListingPage) {
629 alert('Please start from a video listing page (e.g., /videos)');
630 return;
631 }
632
633 // Build video queue from current page
634 const videoLinks = getVideoLinksOnPage();
635 state.videoQueue = videoLinks.slice(0, videoCount).map(link => ({
636 url: link.href,
637 title: link.getAttribute('title') || 'Unknown Video'
638 }));
639
640 state.isRunning = true;
641 state.isPaused = false;
642 state.totalVideosToProcess = Math.min(videoCount, state.videoQueue.length);
643 state.processedVideos = 0;
644 state.currentVideoIndex = 0;
645 state.currentPage = 1;
646 state.returnURL = window.location.href;
647
648 await saveState();
649 updateUI();
650 updateStatus('Starting extraction...', 'info');
651
652 document.getElementById('vex-start').disabled = true;
653 document.getElementById('vex-pause').disabled = false;
654 document.getElementById('vex-stop').disabled = false;
655
656 console.log(`[Extractor] Starting extraction of ${state.totalVideosToProcess} videos`);
657 console.log('[Extractor] Video queue:', state.videoQueue);
658
659 // Navigate to first video
660 if (state.videoQueue.length > 0) {
661 const firstVideo = state.videoQueue[0];
662 console.log(`[Extractor] Navigating to first video: ${firstVideo.title}`);
663 window.location.href = firstVideo.url;
664 }
665 }
666
667 async function pauseExtraction() {
668 state.isPaused = true;
669 await saveState();
670 updateStatus('Paused', 'warning');
671
672 document.getElementById('vex-pause').style.display = 'none';
673 document.getElementById('vex-resume').style.display = 'block';
674 }
675
676 async function resumeExtraction() {
677 state.isPaused = false;
678 await saveState();
679 updateStatus('Resuming...', 'info');
680
681 document.getElementById('vex-pause').style.display = 'block';
682 document.getElementById('vex-resume').style.display = 'none';
683
684 // Continue processing
685 await processCurrentVideoPage();
686 }
687
688 async function stopExtraction() {
689 state.isRunning = false;
690 state.isPaused = false;
691 await saveState();
692 updateStatus('Stopped', 'danger');
693
694 document.getElementById('vex-start').disabled = false;
695 document.getElementById('vex-pause').disabled = true;
696 document.getElementById('vex-stop').disabled = true;
697 document.getElementById('vex-resume').style.display = 'none';
698
699 // Return to listing page
700 if (state.returnURL && !window.location.href.includes('/videos')) {
701 console.log('[Extractor] Returning to listing page...');
702 window.location.href = state.returnURL;
703 }
704 }
705
706 function getVideoLinksOnPage() {
707 const links = document.querySelectorAll('a.SceneThumb-SceneImageLink-Link[href*="/video/"]');
708 return Array.from(links);
709 }
710
711 // ============================================
712 // VIDEO PAGE PROCESSING
713 // ============================================
714
715 async function processCurrentVideoPage() {
716 if (!state.isRunning || state.isPaused) {
717 console.log('[Processor] Not running or paused, skipping');
718 return;
719 }
720
721 const currentVideo = state.videoQueue[state.currentVideoIndex];
722 if (!currentVideo) {
723 console.log('[Processor] No current video, finishing');
724 await finishExtraction();
725 return;
726 }
727
728 console.log(`[Processor] Processing video ${state.currentVideoIndex + 1}/${state.totalVideosToProcess}: ${currentVideo.title}`);
729 updateCurrentVideo(currentVideo.title);
730 updateStatus(`Processing ${state.currentVideoIndex + 1}/${state.totalVideosToProcess}`, 'info');
731
732 // Start capturing network requests
733 capturedURLs = [];
734 isCapturing = true;
735 console.log('[Processor] Started capturing network requests');
736
737 // Wait for page to be fully loaded
738 await sleep(2000);
739
740 // Try to find and click the Watch Episode button
741 const watchButton = document.querySelector('button.ScenePlayerHeaderPlus-WatchEpisodeButton-Button');
742
743 if (watchButton) {
744 console.log('[Processor] Found Watch Episode button, clicking...');
745 watchButton.click();
746
747 // Wait for video to start loading
748 await sleep(5000);
749
750 // Stop capturing
751 isCapturing = false;
752 console.log(`[Processor] Stopped capturing. Found ${capturedURLs.length} URLs`);
753
754 // Also check Performance API for additional URLs
755 const performanceURLs = getPerformanceVideoURLs();
756 console.log(`[Processor] Found ${performanceURLs.length} URLs from Performance API`);
757
758 // Combine all captured URLs
759 const allCapturedURLs = [...capturedURLs, ...performanceURLs];
760 console.log(`[Processor] Total captured URLs: ${allCapturedURLs.length}`);
761
762 if (allCapturedURLs.length > 0) {
763 // Process and select best URL
764 const videoData = selectBestCapturedURL(allCapturedURLs, currentVideo);
765
766 if (videoData) {
767 state.extractedURLs.push(videoData);
768 state.processedVideos++;
769 console.log(`[Processor] ✓ Successfully extracted URL for: ${currentVideo.title}`);
770 } else {
771 state.failedVideos.push(currentVideo);
772 console.warn(`[Processor] ✗ Failed to extract URL for: ${currentVideo.title}`);
773 }
774 } else {
775 state.failedVideos.push(currentVideo);
776 console.warn(`[Processor] ✗ No URLs captured for: ${currentVideo.title}`);
777 }
778 } else {
779 console.warn('[Processor] Watch Episode button not found');
780 state.failedVideos.push(currentVideo);
781 }
782
783 // Update UI
784 updateProgress();
785 updateStats();
786 updateURLList();
787 await saveState();
788
789 // Move to next video
790 state.currentVideoIndex++;
791
792 if (state.currentVideoIndex < state.videoQueue.length && state.processedVideos < state.totalVideosToProcess) {
793 const nextVideo = state.videoQueue[state.currentVideoIndex];
794 console.log(`[Processor] Moving to next video: ${nextVideo.title}`);
795 await sleep(1000);
796 window.location.href = nextVideo.url;
797 } else {
798 await finishExtraction();
799 }
800 }
801
802 function getPerformanceVideoURLs() {
803 const entries = performance.getEntriesByType('resource');
804 const videoURLs = [];
805
806 entries.forEach(entry => {
807 const url = entry.name;
808 if (url.includes('.m3u8') || url.includes('.mp4') || url.includes('.mpd') ||
809 url.includes('streaming-hls.gammacdn.com') || url.includes('m3u8.gammacdn.com')) {
810 videoURLs.push({ url, type: 'performance', timestamp: Date.now() });
811 console.log('[Performance] Found URL:', url.substring(0, 100) + '...');
812 }
813 });
814
815 return videoURLs;
816 }
817
818 function selectBestCapturedURL(capturedURLs, videoInfo) {
819 const preferredQuality = document.getElementById('vex-quality').value;
820
821 console.log('[Selector] Processing captured URLs...');
822
823 // Parse and categorize URLs
824 const sources = [];
825
826 capturedURLs.forEach(item => {
827 let url = item.url;
828
829 // Decode if it's an encoded gammacdn URL
830 if (url.includes('m3u8.gammacdn.com/?u=')) {
831 try {
832 const match = url.match(/\?u=([^&]+)/);
833 if (match) {
834 const decoded = decodeURIComponent(match[1]);
835 console.log('[Selector] Decoded URL:', decoded.substring(0, 100) + '...');
836 url = decoded;
837 }
838 } catch (e) {
839 console.warn('[Selector] Failed to decode URL:', e.message);
840 }
841 }
842
843 // Skip .ts segment files
844 if (url.includes('.ts?') || url.endsWith('.ts')) {
845 return;
846 }
847
848 // Only keep master m3u8 files (not quality-specific segments)
849 if (url.includes('.m3u8')) {
850 const quality = extractQualityFromURL(url);
851 sources.push({
852 url: url,
853 type: 'm3u8',
854 quality: quality,
855 method: item.type
856 });
857 console.log(`[Selector] Added m3u8 source: ${quality} - ${url.substring(0, 80)}...`);
858 } else if (url.includes('.mp4')) {
859 const quality = extractQualityFromURL(url);
860 sources.push({
861 url: url,
862 type: 'mp4',
863 quality: quality,
864 method: item.type
865 });
866 console.log(`[Selector] Added mp4 source: ${quality} - ${url.substring(0, 80)}...`);
867 } else if (url.includes('.mpd')) {
868 const quality = extractQualityFromURL(url);
869 sources.push({
870 url: url,
871 type: 'dash',
872 quality: quality,
873 method: item.type
874 });
875 console.log(`[Selector] Added dash source: ${quality} - ${url.substring(0, 80)}...`);
876 }
877 });
878
879 // Remove duplicates
880 const uniqueSources = sources.filter((source, index, self) =>
881 index === self.findIndex(s => s.url === source.url)
882 );
883
884 console.log(`[Selector] Found ${uniqueSources.length} unique sources`);
885
886 if (uniqueSources.length === 0) {
887 return null;
888 }
889
890 // Select best source
891 const selectedSource = selectBestSource(uniqueSources, preferredQuality);
892 console.log(`[Selector] Selected: ${selectedSource.quality} ${selectedSource.type}`);
893
894 return {
895 title: videoInfo.title,
896 pageURL: videoInfo.url,
897 videoURL: selectedSource.url,
898 type: selectedSource.type,
899 quality: selectedSource.quality,
900 method: selectedSource.method,
901 allSources: uniqueSources,
902 timestamp: new Date().toISOString()
903 };
904 }
905
906 function extractQualityFromURL(url) {
907 // Try to extract quality from URL
908 const qualityMatch = url.match(/[_-](\d{3,4})[pP]/);
909 if (qualityMatch) {
910 return qualityMatch[1] + 'p';
911 }
912
913 // Check for quality indicators in URL
914 if (url.includes('2160') || url.includes('4k') || url.includes('uhd')) return '2160p';
915 if (url.includes('1080') || url.includes('fhd')) return '1080p';
916 if (url.includes('720') || url.includes('hd')) return '720p';
917 if (url.includes('576')) return '576p';
918 if (url.includes('480') || url.includes('sd')) return '480p';
919 if (url.includes('360')) return '360p';
920 if (url.includes('240')) return '240p';
921
922 return 'unknown';
923 }
924
925 function getVideoType(url) {
926 if (url.includes('.m3u8')) return 'm3u8';
927 if (url.includes('.mp4')) return 'mp4';
928 if (url.includes('.mpd')) return 'dash';
929 if (url.includes('.webm')) return 'webm';
930 if (url.includes('.mkv')) return 'mkv';
931 return 'unknown';
932 }
933
934 function selectBestSource(sources, preferredQuality) {
935 console.log(`[Selector] Selecting best source from ${sources.length} options, preferred quality: ${preferredQuality}`);
936
937 // Prioritize by type: m3u8 > mp4 > dash > others
938 const m3u8Sources = sources.filter(s => s.type === 'm3u8');
939 const mp4Sources = sources.filter(s => s.type === 'mp4');
940 const dashSources = sources.filter(s => s.type === 'dash');
941 const otherSources = sources.filter(s => !['m3u8', 'mp4', 'dash'].includes(s.type));
942
943 let prioritizedSources = [...m3u8Sources, ...mp4Sources, ...dashSources, ...otherSources];
944
945 if (preferredQuality === 'highest') {
946 // Find highest quality
947 const qualityOrder = ['2160p', '1080p', '720p', '576p', '480p', '360p', '240p', 'unknown'];
948 for (const quality of qualityOrder) {
949 const found = prioritizedSources.find(s => s.quality === quality);
950 if (found) {
951 console.log(`[Selector] Selected highest quality: ${quality}`);
952 return found;
953 }
954 }
955 } else {
956 // Find preferred quality or closest
957 const targetQuality = preferredQuality + 'p';
958 const found = prioritizedSources.find(s => s.quality === targetQuality);
959 if (found) {
960 console.log(`[Selector] Found exact match for ${targetQuality}`);
961 return found;
962 }
963
964 // Find closest quality (prefer higher)
965 const qualityValue = parseInt(preferredQuality);
966 const sorted = prioritizedSources
967 .filter(s => s.quality !== 'unknown')
968 .sort((a, b) => {
969 const aVal = parseInt(a.quality);
970 const bVal = parseInt(b.quality);
971 const aDiff = Math.abs(aVal - qualityValue);
972 const bDiff = Math.abs(bVal - qualityValue);
973 if (aDiff === bDiff) return bVal - aVal; // Prefer higher
974 return aDiff - bDiff;
975 });
976
977 if (sorted.length > 0) {
978 console.log(`[Selector] Selected closest quality: ${sorted[0].quality}`);
979 return sorted[0];
980 }
981 }
982
983 // Fallback to first available
984 console.log('[Selector] Using fallback: first available source');
985 return prioritizedSources[0] || sources[0];
986 }
987
988 async function finishExtraction() {
989 console.log('[Extractor] Finishing extraction...');
990
991 state.isRunning = false;
992 updateStatus('Extraction complete!', 'success');
993
994 document.getElementById('vex-start').disabled = false;
995 document.getElementById('vex-pause').disabled = true;
996 document.getElementById('vex-stop').disabled = true;
997 document.getElementById('vex-resume').style.display = 'none';
998
999 await saveState();
1000
1001 // Return to listing page
1002 if (state.returnURL && !window.location.href.includes('/videos')) {
1003 console.log('[Extractor] Returning to listing page...');
1004 await sleep(2000);
1005 window.location.href = state.returnURL;
1006 }
1007 }
1008
1009 // ============================================
1010 // UI UPDATE FUNCTIONS
1011 // ============================================
1012
1013 function updateUI() {
1014 updateProgress();
1015 updateStats();
1016 updateURLList();
1017 }
1018
1019 function updateStatus(message) {
1020 const statusEl = document.getElementById('vex-status');
1021 if (statusEl) {
1022 statusEl.textContent = message;
1023 statusEl.className = 'vex-pulse';
1024
1025 setTimeout(() => {
1026 statusEl.classList.remove('vex-pulse');
1027 }, 2000);
1028 }
1029 }
1030
1031 function updateProgress() {
1032 const progressText = document.getElementById('vex-progress-text');
1033 const progressFill = document.getElementById('vex-progress-fill');
1034
1035 if (progressText) {
1036 progressText.textContent = `${state.processedVideos}/${state.totalVideosToProcess}`;
1037 }
1038
1039 if (progressFill) {
1040 const percentage = state.totalVideosToProcess > 0
1041 ? (state.processedVideos / state.totalVideosToProcess) * 100
1042 : 0;
1043 progressFill.style.width = percentage + '%';
1044 }
1045 }
1046
1047 function updateCurrentVideo(title) {
1048 const currentVideoEl = document.getElementById('vex-current-video');
1049 if (currentVideoEl) {
1050 currentVideoEl.textContent = `Current: ${title}`;
1051 }
1052 }
1053
1054 function updateStats() {
1055 const successEl = document.getElementById('vex-stat-success');
1056 const failedEl = document.getElementById('vex-stat-failed');
1057 const pageEl = document.getElementById('vex-stat-page');
1058
1059 if (successEl) successEl.textContent = state.extractedURLs.length;
1060 if (failedEl) failedEl.textContent = state.failedVideos.length;
1061 if (pageEl) pageEl.textContent = state.currentPage;
1062 }
1063
1064 function updateURLList() {
1065 const urlList = document.getElementById('vex-url-list');
1066 const urlCount = document.getElementById('vex-url-count');
1067
1068 if (!urlList || !urlCount) return;
1069
1070 urlCount.textContent = state.extractedURLs.length;
1071
1072 if (state.extractedURLs.length === 0) {
1073 urlList.innerHTML = '<div style="text-align: center; color: #888; padding: 20px;">No URLs extracted yet</div>';
1074 disableExportButtons();
1075 return;
1076 }
1077
1078 enableExportButtons();
1079
1080 urlList.innerHTML = state.extractedURLs.map((video, index) => `
1081 <div class="vex-url-item">
1082 <div class="vex-url-item-title">
1083 <span>${index + 1}. ${video.title}</span>
1084 <span class="vex-url-item-quality">${video.quality} • ${video.type.toUpperCase()}</span>
1085 </div>
1086 <div class="vex-url-item-url">${video.videoURL}</div>
1087 <div class="vex-url-item-actions">
1088 <button class="vex-btn vex-btn-small vex-btn-primary" onclick="navigator.clipboard.writeText('${video.videoURL.replace(/'/g, '\\\'')}')">📋 Copy</button>
1089 <button class="vex-btn vex-btn-small" onclick="window.open('${video.pageURL}', '_blank')">🔗 Open Page</button>
1090 </div>
1091 </div>
1092 `).join('');
1093 }
1094
1095 function enableExportButtons() {
1096 const buttons = ['vex-copy-all', 'vex-copy-m3u8', 'vex-export-txt', 'vex-export-json', 'vex-clear-urls'];
1097 buttons.forEach(id => {
1098 const btn = document.getElementById(id);
1099 if (btn) btn.disabled = false;
1100 });
1101 }
1102
1103 function disableExportButtons() {
1104 const buttons = ['vex-copy-all', 'vex-copy-m3u8', 'vex-export-txt', 'vex-export-json', 'vex-clear-urls'];
1105 buttons.forEach(id => {
1106 const btn = document.getElementById(id);
1107 if (btn) btn.disabled = true;
1108 });
1109 }
1110
1111 // ============================================
1112 // EXPORT FUNCTIONS
1113 // ============================================
1114
1115 async function copyURLs(type) {
1116 let urls = [];
1117
1118 if (type === 'all') {
1119 urls = state.extractedURLs.map(v => v.videoURL);
1120 } else if (type === 'm3u8') {
1121 urls = state.extractedURLs.filter(v => v.type === 'm3u8').map(v => v.videoURL);
1122 }
1123
1124 if (urls.length === 0) {
1125 alert('No URLs to copy!');
1126 return;
1127 }
1128
1129 const text = urls.join('\n');
1130 await GM.setClipboard(text);
1131 updateStatus(`Copied ${urls.length} URLs to clipboard!`, 'success');
1132 }
1133
1134 function exportURLs(format) {
1135 if (state.extractedURLs.length === 0) {
1136 alert('No URLs to export!');
1137 return;
1138 }
1139
1140 let content, filename, mimeType;
1141
1142 if (format === 'txt') {
1143 content = state.extractedURLs.map((v, i) =>
1144 `${i + 1}. ${v.title}\n URL: ${v.videoURL}\n Quality: ${v.quality}\n Type: ${v.type}\n Method: ${v.method || 'unknown'}\n Page: ${v.pageURL}\n`
1145 ).join('\n');
1146 filename = `adult-time-urls-${Date.now()}.txt`;
1147 mimeType = 'text/plain';
1148 } else if (format === 'json') {
1149 content = JSON.stringify(state.extractedURLs, null, 2);
1150 filename = `adult-time-urls-${Date.now()}.json`;
1151 mimeType = 'application/json';
1152 }
1153
1154 const blob = new Blob([content], { type: mimeType });
1155 const url = URL.createObjectURL(blob);
1156 const a = document.createElement('a');
1157 a.href = url;
1158 a.download = filename;
1159 a.click();
1160 URL.revokeObjectURL(url);
1161
1162 updateStatus(`Exported ${state.extractedURLs.length} URLs as ${format.toUpperCase()}!`, 'success');
1163 }
1164
1165 async function clearURLs() {
1166 if (!confirm('Are you sure you want to clear all extracted URLs? This cannot be undone.')) {
1167 return;
1168 }
1169
1170 await clearState();
1171 updateUI();
1172 updateStatus('All URLs cleared', 'info');
1173 }
1174
1175 // ============================================
1176 // PAGE DETECTION AND AUTO-PROCESSING
1177 // ============================================
1178
1179 function detectPageType() {
1180 const url = window.location.href;
1181
1182 if (url.includes('/videos') || document.querySelectorAll('a.SceneThumb-SceneImageLink-Link[href*="/video/"]').length > 0) {
1183 return 'listing';
1184 } else if (url.includes('/video/') && document.querySelector('button.ScenePlayerHeaderPlus-WatchEpisodeButton-Button')) {
1185 return 'video';
1186 }
1187
1188 return 'unknown';
1189 }
1190
1191 // ============================================
1192 // INITIALIZATION
1193 // ============================================
1194
1195 async function init() {
1196 console.log('[Init] Adult Time Video URL Extractor initialized');
1197
1198 // Setup network interceptor first
1199 setupNetworkInterceptor();
1200
1201 // Load saved state
1202 await loadState();
1203
1204 // Detect page type
1205 const pageType = detectPageType();
1206 console.log(`[Init] Page type detected: ${pageType}`);
1207
1208 // Create UI
1209 createControlPanel();
1210
1211 // Update UI with loaded state
1212 updateUI();
1213
1214 // If we're on a video page and extraction is running, process it
1215 if (pageType === 'video' && state.isRunning && !state.isPaused) {
1216 console.log('[Init] On video page with active extraction, processing...');
1217 await sleep(1000);
1218 await processCurrentVideoPage();
1219 }
1220
1221 console.log('[Init] Extension ready!');
1222 }
1223
1224 // Wait for page to load
1225 if (document.readyState === 'loading') {
1226 document.addEventListener('DOMContentLoaded', init);
1227 } else {
1228 init();
1229 }
1230
1231})();