add vtt support

This commit is contained in:
Kyush 2026-06-06 21:22:20 +09:00
commit 22b8ee2fa0
5 changed files with 169 additions and 40 deletions

View file

@ -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);
}

View file

@ -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;

View file

@ -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') {

View file

@ -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) {

View file

@ -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';
}