add vtt support
This commit is contained in:
parent
2f9fa8f76e
commit
22b8ee2fa0
5 changed files with 169 additions and 40 deletions
|
|
@ -6,6 +6,15 @@ function isM3U8Url(url: string): boolean {
|
|||
lower.includes('application/x-mpegurl') || lower.includes('application/vnd.apple.mpegurl');
|
||||
}
|
||||
|
||||
function isVttUrl(url: string): boolean {
|
||||
const lower = url.toLowerCase();
|
||||
return lower.includes('.vtt');
|
||||
}
|
||||
|
||||
function isTargetUrl(url: string): boolean {
|
||||
return isM3U8Url(url) || isVttUrl(url);
|
||||
}
|
||||
|
||||
export function monitorDOM(onDetected: (url: string, headers?: Record<string, string>) => void): void {
|
||||
const target = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window) as any;
|
||||
const doc = target.document || document;
|
||||
|
|
@ -28,8 +37,8 @@ export function monitorDOM(onDetected: (url: string, headers?: Record<string, st
|
|||
|
||||
const checkSource = (el: HTMLMediaElement) => {
|
||||
const src = el.currentSrc || el.src || '';
|
||||
if (src && isM3U8Url(src)) {
|
||||
log.info(`DOM monitor: found m3u8 in <${el.tagName.toLowerCase()}> src: ${src}`);
|
||||
if (src && isTargetUrl(src)) {
|
||||
log.info(`DOM monitor: found in <${el.tagName.toLowerCase()}> src: ${src}`);
|
||||
const referer = getReferer(src);
|
||||
onDetected(src, referer ? { Referer: referer } : undefined);
|
||||
}
|
||||
|
|
@ -38,8 +47,8 @@ export function monitorDOM(onDetected: (url: string, headers?: Record<string, st
|
|||
for (let i = 0; i < el.children.length; i++) {
|
||||
const source = el.children[i] as HTMLSourceElement;
|
||||
const sourceSrc = source.src || '';
|
||||
if (sourceSrc && isM3U8Url(sourceSrc)) {
|
||||
log.info(`DOM monitor: found m3u8 in <source> src: ${sourceSrc}`);
|
||||
if (sourceSrc && isTargetUrl(sourceSrc)) {
|
||||
log.info(`DOM monitor: found in <source> src: ${sourceSrc}`);
|
||||
const referer = getReferer(sourceSrc);
|
||||
onDetected(sourceSrc, referer ? { Referer: referer } : undefined);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
import { log } from '../logger';
|
||||
|
||||
const M3U8_PATTERNS = ['.m3u8', '.m3u', 'application/x-mpegurl', 'application/vnd.apple.mpegurl'];
|
||||
const VTT_PATTERNS = ['.vtt'];
|
||||
|
||||
function isM3U8Url(url: string): boolean {
|
||||
const lower = url.toLowerCase();
|
||||
return M3U8_PATTERNS.some(p => lower.includes(p));
|
||||
}
|
||||
|
||||
function isVttUrl(url: string): boolean {
|
||||
const lower = url.toLowerCase();
|
||||
return VTT_PATTERNS.some(p => lower.includes(p));
|
||||
}
|
||||
|
||||
export function interceptFetch(onDetected: (url: string, headers?: Record<string, string>) => void): void {
|
||||
const target = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window) as any;
|
||||
const origFetch = target.fetch;
|
||||
|
|
@ -14,19 +20,21 @@ export function interceptFetch(onDetected: (url: string, headers?: Record<string
|
|||
|
||||
target.fetch = function(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const url = typeof input === 'string' ? input : (input as Request).url;
|
||||
const isTarget = isM3U8Url(url) || isVttUrl(url);
|
||||
|
||||
if (isM3U8Url(url)) {
|
||||
if (isTarget) {
|
||||
log.info(`Fetch request: ${url}`);
|
||||
}
|
||||
|
||||
return origFetch.apply(this, arguments as [RequestInfo | URL, RequestInit?]).then((response: Response) => {
|
||||
if (isM3U8Url(url)) {
|
||||
if (isTarget) {
|
||||
const contentType = response.headers.get('Content-Type') || '';
|
||||
log.info(`Fetch response: ${url} status=${response.status} contentType=${contentType}`);
|
||||
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
const isM3U8Response = isM3U8Url(url) || contentType.toLowerCase().includes('mpegurl');
|
||||
if (isM3U8Response) {
|
||||
const isVttResponse = isVttUrl(url) || contentType.toLowerCase().includes('vtt') || contentType.toLowerCase().includes('text/vtt');
|
||||
if (isM3U8Response || isVttResponse) {
|
||||
try {
|
||||
let referer = location.href;
|
||||
const urlObj = new URL(url);
|
||||
|
|
@ -40,7 +48,7 @@ export function interceptFetch(onDetected: (url: string, headers?: Record<string
|
|||
}
|
||||
return response;
|
||||
}).catch((error: unknown) => {
|
||||
if (isM3U8Url(url)) {
|
||||
if (isTarget) {
|
||||
log.error(`Fetch error: ${url} - ${error}`);
|
||||
}
|
||||
throw error;
|
||||
|
|
|
|||
|
|
@ -1,12 +1,18 @@
|
|||
import { log } from '../logger';
|
||||
|
||||
const M3U8_PATTERNS = ['.m3u8', '.m3u', 'application/x-mpegurl', 'application/vnd.apple.mpegurl'];
|
||||
const VTT_PATTERNS = ['.vtt'];
|
||||
|
||||
function isM3U8Url(url: string): boolean {
|
||||
const lower = url.toLowerCase();
|
||||
return M3U8_PATTERNS.some(p => lower.includes(p));
|
||||
}
|
||||
|
||||
function isVttUrl(url: string): boolean {
|
||||
const lower = url.toLowerCase();
|
||||
return VTT_PATTERNS.some(p => lower.includes(p));
|
||||
}
|
||||
|
||||
export function interceptXHR(onDetected: (url: string, headers?: Record<string, string>) => void): void {
|
||||
const target = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window) as any;
|
||||
const XHR = target.XMLHttpRequest;
|
||||
|
|
@ -19,7 +25,8 @@ export function interceptXHR(onDetected: (url: string, headers?: Record<string,
|
|||
XHR.prototype.open = function(method: string, url: string, ...rest: unknown[]): void {
|
||||
const requestUrl = String(url);
|
||||
const headers: Record<string, string> = {};
|
||||
if (isM3U8Url(requestUrl)) {
|
||||
const isTarget = isM3U8Url(requestUrl) || isVttUrl(requestUrl);
|
||||
if (isTarget) {
|
||||
log.info(`XHR open: ${method} ${requestUrl}`);
|
||||
}
|
||||
|
||||
|
|
@ -32,7 +39,7 @@ export function interceptXHR(onDetected: (url: string, headers?: Record<string,
|
|||
|
||||
const origSendFn = origSend.bind(this);
|
||||
this.send = function(body?: unknown): void {
|
||||
if (isM3U8Url(requestUrl)) {
|
||||
if (isTarget) {
|
||||
this.addEventListener('load', function() {
|
||||
try {
|
||||
const status = (this as any).status;
|
||||
|
|
@ -41,7 +48,8 @@ export function interceptXHR(onDetected: (url: string, headers?: Record<string,
|
|||
log.info(`XHR load: ${requestUrl} status=${status} contentType=${contentType} bodyLen=${responseText.length}`);
|
||||
if (status >= 200 && status < 300) {
|
||||
const isM3U8Response = isM3U8Url(requestUrl) || contentType.toLowerCase().includes('mpegurl') || responseText.startsWith('#EXTM3U');
|
||||
if (isM3U8Response) {
|
||||
const isVttResponse = isVttUrl(requestUrl) || contentType.toLowerCase().includes('vtt') || contentType.toLowerCase().includes('text/vtt');
|
||||
if (isM3U8Response || isVttResponse) {
|
||||
const capturedHeaders: Record<string, string> = {};
|
||||
for (const [k, v] of Object.entries(headers)) {
|
||||
if (k.toLowerCase() !== 'referer') {
|
||||
|
|
|
|||
132
src/index.ts
132
src/index.ts
|
|
@ -10,8 +10,9 @@ import { createPanel } from './ui/panel';
|
|||
import { showStatus, hideProgress, resetProgress } from './ui/progress';
|
||||
import { showPlaylistSelector } from './ui/selector';
|
||||
import { DownloadProgress } from './downloader/segment-fetch';
|
||||
import { guessFilename } from './utils/filename';
|
||||
|
||||
const detectedM3U8s = new Map<string, { url: string; headers?: Record<string, string>; parsed?: ParsedPlaylist }>();
|
||||
const detectedStreams = new Map<string, { url: string; type: 'm3u8' | 'vtt'; headers?: Record<string, string>; parsed?: ParsedPlaylist }>();
|
||||
let panel: HTMLDivElement | null = null;
|
||||
let isDownloading = false;
|
||||
let downloadAborted = false;
|
||||
|
|
@ -24,6 +25,10 @@ function isTopFrame(): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
function isVttUrl(url: string): boolean {
|
||||
return url.toLowerCase().includes('.vtt');
|
||||
}
|
||||
|
||||
function notifyTopFrame(url: string, headers?: Record<string, string>): void {
|
||||
if (!isTopFrame()) {
|
||||
try {
|
||||
|
|
@ -58,16 +63,28 @@ function addStreamToUI(url: string, parsed?: ParsedPlaylist): void {
|
|||
item.className = 'm3u8-dl-stream-item';
|
||||
item.setAttribute('data-url', url);
|
||||
|
||||
const segmentCount = parsed && !parsed.isMaster ? parsed.segments.length : '--';
|
||||
const encrypted = parsed && !parsed.isMaster ? parsed.segments.some(s => s.key !== null) : 'unknown';
|
||||
const entry = detectedStreams.get(url);
|
||||
const isVtt = entry?.type === 'vtt';
|
||||
|
||||
item.innerHTML = `
|
||||
<div class="m3u8-dl-stream-url">${url}</div>
|
||||
<div class="m3u8-dl-stream-meta">Segments: ${segmentCount} | Encrypted: ${encrypted ? 'AES-128' : 'No'}</div>
|
||||
<div class="m3u8-dl-stream-actions">
|
||||
<button class="m3u8-dl-download-btn" data-url="${url}">Download</button>
|
||||
</div>
|
||||
`;
|
||||
if (isVtt) {
|
||||
item.innerHTML = `
|
||||
<div class="m3u8-dl-stream-url">${url}</div>
|
||||
<div class="m3u8-dl-stream-meta">Subtitle (VTT)</div>
|
||||
<div class="m3u8-dl-stream-actions">
|
||||
<button class="m3u8-dl-download-btn" data-url="${url}">Download</button>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
const segmentCount = parsed && !parsed.isMaster ? parsed.segments.length : '--';
|
||||
const encrypted = parsed && !parsed.isMaster ? parsed.segments.some(s => s.key !== null) : 'unknown';
|
||||
item.innerHTML = `
|
||||
<div class="m3u8-dl-stream-url">${url}</div>
|
||||
<div class="m3u8-dl-stream-meta">Segments: ${segmentCount} | Encrypted: ${encrypted ? 'AES-128' : 'No'}</div>
|
||||
<div class="m3u8-dl-stream-actions">
|
||||
<button class="m3u8-dl-download-btn" data-url="${url}">Download</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
streamsContainer.appendChild(item);
|
||||
|
||||
|
|
@ -78,6 +95,26 @@ function addStreamToUI(url: string, parsed?: ParsedPlaylist): void {
|
|||
});
|
||||
}
|
||||
|
||||
function downloadVtt(blob: Blob, url: string): void {
|
||||
const baseName = guessFilename(url, 'text/vtt');
|
||||
const filename = baseName.replace(/\.(mp4|ts|m4a)$/, '.vtt');
|
||||
log.info(`downloadVtt: ${filename} (${blob.size} bytes)`);
|
||||
const urlObj = URL.createObjectURL(blob);
|
||||
const target = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window) as any;
|
||||
const doc = target.document || document;
|
||||
const a = doc.createElement('a');
|
||||
a.href = urlObj;
|
||||
a.download = filename;
|
||||
doc.body.appendChild(a);
|
||||
a.click();
|
||||
setTimeout(() => {
|
||||
try {
|
||||
doc.body.removeChild(a);
|
||||
URL.revokeObjectURL(urlObj);
|
||||
} catch { /* ignore */ }
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
async function startDownload(url: string): Promise<void> {
|
||||
if (isDownloading) {
|
||||
log.warn('startDownload: already downloading');
|
||||
|
|
@ -87,8 +124,14 @@ async function startDownload(url: string): Promise<void> {
|
|||
isDownloading = true;
|
||||
downloadAborted = false;
|
||||
|
||||
const entry = detectedM3U8s.get(url);
|
||||
log.info(`startDownload: ${url} entryHeaders=${JSON.stringify(entry?.headers)}`);
|
||||
const entry = detectedStreams.get(url);
|
||||
log.info(`startDownload: ${url} type=${entry?.type} entryHeaders=${JSON.stringify(entry?.headers)}`);
|
||||
|
||||
if (entry?.type === 'vtt') {
|
||||
await downloadVttStream(url, entry);
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed = entry?.parsed;
|
||||
|
||||
if (!parsed) {
|
||||
|
|
@ -97,6 +140,7 @@ async function startDownload(url: string): Promise<void> {
|
|||
if (entry) entry.parsed = parsed;
|
||||
} catch (e) {
|
||||
log.error(`startDownload: parse failed for ${url}: ${e}`);
|
||||
isDownloading = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -160,11 +204,47 @@ async function startDownload(url: string): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
function onM3U8Detected(url: string, headers?: Record<string, string>): void {
|
||||
if (detectedM3U8s.has(url)) return;
|
||||
async function downloadVttStream(url: string, entry: { url: string; type: 'vtt'; headers?: Record<string, string> }): Promise<void> {
|
||||
log.info(`downloadVttStream: ${url}`);
|
||||
isDownloading = true;
|
||||
try {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
(GM_xmlhttpRequest as any)({
|
||||
method: 'GET',
|
||||
url: url,
|
||||
responseType: 'text',
|
||||
headers: {
|
||||
'Referer': entry.headers?.Referer || document.location.href,
|
||||
'Origin': entry.headers?.Origin || new URL(document.location.href).origin,
|
||||
},
|
||||
onload: (response: any) => {
|
||||
if (response.status >= 200 && response.status < 300) {
|
||||
const text = response.responseText || response.response || '';
|
||||
const blob = new Blob([text], { type: 'text/vtt' });
|
||||
downloadVtt(blob, url);
|
||||
resolve();
|
||||
} else {
|
||||
log.error(`downloadVttStream: HTTP ${response.status} for ${url}`);
|
||||
reject(new Error(`VTT download failed: HTTP ${response.status}`));
|
||||
}
|
||||
},
|
||||
onerror: (error: any) => {
|
||||
log.error(`downloadVttStream: error for ${url}: ${error.error}`);
|
||||
reject(new Error(`VTT download error: ${error.error}`));
|
||||
},
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
isDownloading = false;
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`onM3U8Detected: ${url} headers=${JSON.stringify(headers)}`);
|
||||
detectedM3U8s.set(url, { url, headers });
|
||||
function onStreamDetected(url: string, headers?: Record<string, string>): void {
|
||||
if (detectedStreams.has(url)) return;
|
||||
|
||||
const type = isVttUrl(url) ? 'vtt' : 'm3u8';
|
||||
log.info(`onStreamDetected: ${url} type=${type} headers=${JSON.stringify(headers)}`);
|
||||
detectedStreams.set(url, { url, type, headers });
|
||||
|
||||
notifyTopFrame(url, headers);
|
||||
|
||||
|
|
@ -172,14 +252,14 @@ function onM3U8Detected(url: string, headers?: Record<string, string>): void {
|
|||
addStreamToUI(url);
|
||||
}
|
||||
|
||||
if (isTopFrame()) {
|
||||
if (isTopFrame() && type === 'm3u8') {
|
||||
parseM3U8(url, headers?.Referer, headers?.Origin).then(parsed => {
|
||||
detectedM3U8s.get(url)!.parsed = parsed;
|
||||
detectedStreams.get(url)!.parsed = parsed;
|
||||
if (panel) {
|
||||
addStreamToUI(url, parsed);
|
||||
}
|
||||
}).catch(e => {
|
||||
log.error(`onM3U8Detected: pre-parse failed for ${url}: ${e}`);
|
||||
log.error(`onStreamDetected: pre-parse failed for ${url}: ${e}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -189,9 +269,9 @@ if (typeof window === 'undefined') {
|
|||
} else {
|
||||
log.info(`Script initialized in ${isTopFrame() ? 'top frame' : `iframe (${location.origin})`}`);
|
||||
|
||||
interceptXHR(onM3U8Detected);
|
||||
interceptFetch(onM3U8Detected);
|
||||
monitorDOM(onM3U8Detected);
|
||||
interceptXHR(onStreamDetected);
|
||||
interceptFetch(onStreamDetected);
|
||||
monitorDOM(onStreamDetected);
|
||||
|
||||
if (isTopFrame()) {
|
||||
window.addEventListener('message', (e: MessageEvent) => {
|
||||
|
|
@ -201,7 +281,7 @@ if (typeof window === 'undefined') {
|
|||
const headers: Record<string, string> = {};
|
||||
if (referer) headers['Referer'] = referer;
|
||||
if (origin) headers['Origin'] = origin;
|
||||
onM3U8Detected(e.data.url as string, Object.keys(headers).length ? headers : undefined);
|
||||
onStreamDetected(e.data.url as string, Object.keys(headers).length ? headers : undefined);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -224,7 +304,7 @@ if (typeof window === 'undefined') {
|
|||
});
|
||||
}
|
||||
|
||||
for (const entry of detectedM3U8s.values()) {
|
||||
for (const entry of detectedStreams.values()) {
|
||||
addStreamToUI(entry.url, entry.parsed);
|
||||
}
|
||||
});
|
||||
|
|
@ -239,10 +319,10 @@ if (typeof window === 'undefined') {
|
|||
if (iframeWin && iframeWin.location.origin === window.location.origin) {
|
||||
log.info(`iframe observer: injecting hooks into same-origin iframe`);
|
||||
interceptXHR.call(null, (url: string) => {
|
||||
onM3U8Detected(url);
|
||||
onStreamDetected(url);
|
||||
});
|
||||
interceptFetch.call(null, (url: string) => {
|
||||
onM3U8Detected(url);
|
||||
onStreamDetected(url);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,14 @@
|
|||
export function guessFilename(url: string, mime?: string): string {
|
||||
const titleMatch = extractTitleParts();
|
||||
const ext = mime ? mimeToExt(mime) : null;
|
||||
|
||||
if (titleMatch) {
|
||||
if (ext === '.vtt' || mime?.includes('vtt')) {
|
||||
return `${titleMatch.name} ${titleMatch.volume}.vtt`;
|
||||
}
|
||||
return `${titleMatch.name} ${titleMatch.volume}.mp4`;
|
||||
}
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
let name = urlObj.pathname.replace(/[^\/\\]+$/, '') || '/';
|
||||
|
|
@ -8,13 +18,24 @@ export function guessFilename(url: string, mime?: string): string {
|
|||
return name;
|
||||
}
|
||||
|
||||
const ext = mime ? mimeToExt(mime) : '.mp4';
|
||||
return name + ext;
|
||||
const fallbackExt = mime ? mimeToExt(mime) : '.mp4';
|
||||
return name + fallbackExt;
|
||||
} catch {
|
||||
return 'download' + (mime === 'video/mp4' || mime?.includes('mp4') ? '.mp4' : '.ts');
|
||||
}
|
||||
}
|
||||
|
||||
function extractTitleParts(): { name: string; volume: string } | null {
|
||||
try {
|
||||
const title = document.title || '';
|
||||
const match = title.match(/^(.+?)\s+😜\s+(\d+)\s+-\s+Anime\s+-\s+Linkkf/);
|
||||
if (match) {
|
||||
return { name: match[1].trim(), volume: match[2].trim() };
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
function mimeToExt(mime: string): string {
|
||||
switch (mime) {
|
||||
case 'video/mp4':
|
||||
|
|
@ -25,6 +46,9 @@ function mimeToExt(mime: string): string {
|
|||
return '.ts';
|
||||
case 'audio/mp4':
|
||||
return '.m4a';
|
||||
case 'text/vtt':
|
||||
case 'text/plain':
|
||||
return '.vtt';
|
||||
default:
|
||||
return '.ts';
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue