"use strict"; const activeJobs = new Set(); function reportDiagnostic(level, area, event, details) { try { const pending = chrome.runtime.sendMessage({ target: "background", action: "diagnosticEvent", level, area, event, details: details || {} }); if (pending && typeof pending.catch === "function") pending.catch(() => {}); } catch { // Diagnostics must never interrupt a download. } } function shouldLogNetworkResponse(url, response) { if (!response || !response.ok) return true; try { return /\.(?:m3u8|mp4|webm|gif)$/i.test(new URL(url).pathname); } catch { return false; } } function isRedgifsHost(hostname) { const host = String(hostname || "").toLowerCase(); return host === "redgifs.com" || host.endsWith(".redgifs.com"); } function trustedUrl(value, baseUrl) { const parsed = new URL(String(value || "").trim(), baseUrl); if (parsed.protocol !== "https:" || !isRedgifsHost(parsed.hostname)) { throw new Error("HLS содержит ссылку за пределами RedGifs: " + parsed.hostname); } return parsed.toString(); } function safeFilename(value) { const cleaned = String(value || "redgifs-video") .replace(/^Poster for\s+/i, "") .replace(/\s+(?:uploaded|posted)\s+by\s+.+$/i, "") .replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_") .replace(/\s+/g, " ") .replace(/[. ]+$/g, "") .trim(); return (cleaned || "redgifs-video").slice(0, 120); } function safeFolderPath(value) { return String(value || "") .replace(/\\/g, "/") .split("/") .map((part) => part.replace(/[<>:"|?*\u0000-\u001f]/g, "_").replace(/[. ]+$/g, "").trim()) .filter((part) => part && part !== "." && part !== "..") .slice(0, 8) .map((part) => part.slice(0, 80)) .join("/"); } function outputFilename(folder, base, extension) { const filename = safeFilename(base) + "." + extension; const safeFolder = safeFolderPath(folder); return safeFolder ? safeFolder + "/" + filename : filename; } async function report(jobId, state, progress, status, extra) { try { await chrome.runtime.sendMessage(Object.assign({ target: "background", action: "hlsProgress", jobId, state, progress, status }, extra || {})); } catch { // The service worker will be woken by the next progress update. } } function validReferrer(value) { try { const parsed = new URL(value); return parsed.protocol === "https:" && isRedgifsHost(parsed.hostname) ? parsed.toString() : undefined; } catch { return undefined; } } async function fetchMedia(url, init, sourceUrl) { const options = Object.assign({ cache: "no-store", credentials: "omit" }, init || {}); const referrer = validReferrer(sourceUrl); if (referrer) { options.referrer = referrer; options.referrerPolicy = "no-referrer-when-downgrade"; } const startedAt = Date.now(); try { const response = await fetch(url, options); if (shouldLogNetworkResponse(url, response)) { reportDiagnostic(response.ok ? "info" : "warn", "network.media", "response", { url, status: response.status, ok: response.ok, usedReferrer: Boolean(referrer), durationMs: Date.now() - startedAt }); } return response; } catch (firstError) { reportDiagnostic(referrer ? "warn" : "error", "network.media", "request-failed", { url, usedReferrer: Boolean(referrer), durationMs: Date.now() - startedAt, willRetryWithoutReferrer: Boolean(referrer), error: { name: firstError && firstError.name, message: firstError && firstError.message } }); if (!referrer) throw firstError; delete options.referrer; delete options.referrerPolicy; const retryStartedAt = Date.now(); try { const response = await fetch(url, options); if (shouldLogNetworkResponse(url, response)) { reportDiagnostic(response.ok ? "info" : "warn", "network.media", "retry-response", { url, status: response.status, ok: response.ok, durationMs: Date.now() - retryStartedAt }); } return response; } catch (retryError) { reportDiagnostic("error", "network.media", "retry-failed", { url, durationMs: Date.now() - retryStartedAt, error: { name: retryError && retryError.name, message: retryError && retryError.message } }); throw retryError; } } } async function fetchText(url, sourceUrl) { const response = await fetchMedia(url, { headers: { Accept: "application/vnd.apple.mpegurl, application/x-mpegURL, text/plain, */*" } }, sourceUrl); if (!response.ok) throw new Error("Не удалось получить HLS playlist: HTTP " + response.status); return response.text(); } function parseAttributes(value) { const result = {}; const matcher = /([A-Z0-9-]+)=("[^"]*"|[^,]*)/gi; let match; while ((match = matcher.exec(value)) !== null) { let item = match[2] || ""; if (item.startsWith("\"") && item.endsWith("\"")) item = item.slice(1, -1); result[match[1].toUpperCase()] = item; } return result; } function playlistLines(text) { return String(text || "") .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); } function masterVariants(text, baseUrl) { const lines = playlistLines(text); const variants = []; for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; if (!line.startsWith("#EXT-X-STREAM-INF:")) continue; const attributes = parseAttributes(line.slice("#EXT-X-STREAM-INF:".length)); let next = index + 1; while (next < lines.length && lines[next].startsWith("#")) next += 1; if (next >= lines.length) continue; const resolution = String(attributes.RESOLUTION || "").match(/(\d+)x(\d+)/i); variants.push({ url: trustedUrl(lines[next], baseUrl), bandwidth: Number(attributes.BANDWIDTH) || 0, height: resolution ? Number(resolution[2]) : 0 }); } return variants.sort((left, right) => { if (right.height !== left.height) return right.height - left.height; return right.bandwidth - left.bandwidth; }); } function parseByteRange(specification, url, offsets) { if (!specification) return null; const match = String(specification).match(/^(\d+)(?:@(\d+))?$/); if (!match) throw new Error("Неподдерживаемый EXT-X-BYTERANGE: " + specification); const length = Number(match[1]); const start = match[2] == null ? (offsets.get(url) || 0) : Number(match[2]); const end = start + length - 1; offsets.set(url, end + 1); return { start, end }; } function parseMediaPlaylist(text, baseUrl) { const lines = playlistLines(text); const segments = []; const offsets = new Map(); let mediaSequence = 0; let pendingDuration = null; let pendingRange = null; let currentKey = null; let currentMap = null; for (const line of lines) { if (line.startsWith("#EXT-X-MEDIA-SEQUENCE:")) { mediaSequence = Number(line.slice("#EXT-X-MEDIA-SEQUENCE:".length)) || 0; continue; } if (line.startsWith("#EXTINF:")) { const match = line.match(/^#EXTINF:([\d.]+)/); pendingDuration = match ? Number(match[1]) : null; continue; } if (line.startsWith("#EXT-X-BYTERANGE:")) { pendingRange = line.slice("#EXT-X-BYTERANGE:".length).trim(); continue; } if (line.startsWith("#EXT-X-KEY:")) { const attributes = parseAttributes(line.slice("#EXT-X-KEY:".length)); if (String(attributes.METHOD || "").toUpperCase() === "NONE") { currentKey = null; } else { currentKey = { method: String(attributes.METHOD || "").toUpperCase(), url: attributes.URI ? trustedUrl(attributes.URI, baseUrl) : null, iv: attributes.IV || null }; } continue; } if (line.startsWith("#EXT-X-MAP:")) { const attributes = parseAttributes(line.slice("#EXT-X-MAP:".length)); const mapUrl = trustedUrl(attributes.URI, baseUrl); currentMap = { url: mapUrl, range: parseByteRange(attributes.BYTERANGE, mapUrl, offsets) }; continue; } if (line.startsWith("#")) continue; const segmentUrl = trustedUrl(line, baseUrl); segments.push({ url: segmentUrl, duration: pendingDuration, range: parseByteRange(pendingRange, segmentUrl, offsets), sequence: mediaSequence + segments.length, key: currentKey ? Object.assign({}, currentKey) : null, map: currentMap ? { url: currentMap.url, range: currentMap.range ? Object.assign({}, currentMap.range) : null } : null }); pendingDuration = null; pendingRange = null; } return segments; } async function fetchBytes(url, range, sourceUrl) { const headers = { Accept: "*/*" }; if (range) headers.Range = "bytes=" + range.start + "-" + range.end; const response = await fetchMedia(url, { headers }, sourceUrl); if (!response.ok) throw new Error("Сегмент HLS: HTTP " + response.status); let bytes = new Uint8Array(await response.arrayBuffer()); if (range && response.status !== 206) { const expected = range.end - range.start + 1; if (bytes.byteLength >= range.end + 1) { bytes = bytes.slice(range.start, range.end + 1); } else if (bytes.byteLength !== expected) { throw new Error("Сервер проигнорировал byte-range сегмента."); } } return bytes; } function makeIv(explicitIv, sequence) { const iv = new Uint8Array(16); if (explicitIv) { let hex = String(explicitIv).replace(/^0x/i, "").replace(/[^0-9a-f]/gi, ""); hex = hex.padStart(32, "0").slice(-32); for (let index = 0; index < 16; index += 1) { iv[index] = Number.parseInt(hex.slice(index * 2, index * 2 + 2), 16); } return iv; } let value = BigInt(sequence); for (let index = 15; index >= 0; index -= 1) { iv[index] = Number(value & 255n); value >>= 8n; } return iv; } async function decryptSegment(bytes, keyDescriptor, sequence, sourceUrl, keyCache) { if (!keyDescriptor) return bytes; if (keyDescriptor.method !== "AES-128" || !keyDescriptor.url) { throw new Error("Неподдерживаемое шифрование HLS: " + (keyDescriptor.method || "unknown")); } let cryptoKey = keyCache.get(keyDescriptor.url); if (!cryptoKey) { const rawKey = await fetchBytes(keyDescriptor.url, null, sourceUrl); if (rawKey.byteLength !== 16) throw new Error("Некорректный AES-128 ключ HLS."); cryptoKey = await crypto.subtle.importKey("raw", rawKey, { name: "AES-CBC" }, false, ["decrypt"]); keyCache.set(keyDescriptor.url, cryptoKey); } const decrypted = await crypto.subtle.decrypt({ name: "AES-CBC", iv: makeIv(keyDescriptor.iv, sequence) }, cryptoKey, bytes); return new Uint8Array(decrypted); } function mapIdentity(map, key) { if (!map) return ""; const range = map.range ? map.range.start + "-" + map.range.end : "all"; const keyPart = key ? key.url + ":" + (key.iv || "") : "plain"; return map.url + "|" + range + "|" + keyPart; } function looksLikeFragmentedMp4(chunks, segments) { if (segments.some((segment) => Boolean(segment.map))) return true; if (segments.some((segment) => /\.(?:m4s|mp4)(?:$|\?)/i.test(segment.url))) return true; const first = chunks[0]; if (!first || first.length < 8) return false; const box = String.fromCharCode(first[4], first[5], first[6], first[7]); return ["ftyp", "styp", "moof"].includes(box); } async function downloadBlob(blobUrl, filename, saveAs) { try { if (chrome.downloads && chrome.downloads.download) { return await chrome.downloads.download({ url: blobUrl, filename, saveAs }); } } catch { // Fall back to the service worker, which has the downloads permission. } const response = await chrome.runtime.sendMessage({ target: "background", action: "downloadBlobUrl", blobUrl, filename, saveAs }); if (!response || !response.ok) { throw new Error(response && response.error ? response.error : "Chrome не принял локальный Blob."); } return response.downloadId; } async function downloadTextFile(message) { const text = String(message && message.text || ""); if (!text || text.length > 6 * 1024 * 1024) { throw new Error(text ? "Диагностический лог слишком большой." : "Диагностический лог пуст."); } const filename = String(message.filename || "redgifs-diagnostic-log.json") .replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_") .replace(/[. ]+$/g, "") .slice(0, 180) || "redgifs-diagnostic-log.json"; const blob = new Blob([text], { type: message.mimeType || "application/json;charset=utf-8" }); const blobUrl = URL.createObjectURL(blob); try { const downloadId = await downloadBlob(blobUrl, filename, message.saveAs !== false); return { downloadId, filename }; } finally { setTimeout(() => URL.revokeObjectURL(blobUrl), 60000); } } async function runDirect(message) { const jobId = message.jobId; activeJobs.add(jobId); try { const mediaUrl = trustedUrl(message.mediaUrl); await report(jobId, "running", 1, "Подключение к CDN RedGifs…"); const response = await fetchMedia(mediaUrl, { headers: { Accept: "video/webm,video/ogg,video/*;q=0.9,image/gif;q=0.8,*/*;q=0.5" } }, message.sourceUrl); if (!response.ok) throw new Error("Загрузка видео: HTTP " + response.status); const total = Number(response.headers.get("content-length")) || 0; const chunks = []; let received = 0; let lastReportedProgress = -1; if (response.body && response.body.getReader) { const reader = response.body.getReader(); for (;;) { const part = await reader.read(); if (part.done) break; chunks.push(part.value); received += part.value.byteLength; const progress = total ? Math.min(94, Math.round((received / total) * 94)) : Math.min(90, 2 + Math.round(chunks.length / 2)); if (progress !== lastReportedProgress) { lastReportedProgress = progress; await report(jobId, "running", progress, total ? "Загрузка · " + Math.round(received / 1048576) + "/" + Math.round(total / 1048576) + " МБ" : "Загружено · " + Math.round(received / 1048576) + " МБ"); } } } else { const bytes = new Uint8Array(await response.arrayBuffer()); chunks.push(bytes); received = bytes.byteLength; } if (!received) throw new Error("RedGifs вернул пустой файл."); await report(jobId, "running", 96, "Подготовка локального файла…"); const extension = String(message.extension || "mp4").replace(/[^a-z0-9]/gi, "") || "mp4"; const filename = outputFilename(message.outputFolder, message.filenameBase, extension); const mimeType = response.headers.get("content-type") || (extension === "gif" ? "image/gif" : "video/mp4"); const blob = new Blob(chunks, { type: mimeType }); const blobUrl = URL.createObjectURL(blob); try { const downloadId = await downloadBlob(blobUrl, filename, Boolean(message.saveAs)); await report(jobId, "saving", 98, "Сохранение файла…", { filename, downloadId }); } finally { setTimeout(() => URL.revokeObjectURL(blobUrl), 60000); } } catch (error) { reportDiagnostic("error", "offscreen.direct", "job-failed", { jobId, mediaUrl: message.mediaUrl, error: { name: error && error.name, message: error && error.message } }); await report(jobId, "error", 0, "Ошибка загрузки", { error: error && error.message ? error.message : String(error) }); } finally { activeJobs.delete(jobId); } } async function runHls(message) { const jobId = message.jobId; activeJobs.add(jobId); try { let playlistUrl = trustedUrl(message.playlistUrl); let playlistText = null; let variants = []; await report(jobId, "running", 1, "Получение HLS playlist…"); for (let depth = 0; depth < 3; depth += 1) { playlistText = await fetchText(playlistUrl, message.sourceUrl); variants = masterVariants(playlistText, playlistUrl); if (!variants.length) break; playlistUrl = variants[0].url; await report(jobId, "running", 3, "Выбран лучший HLS-поток" + (variants[0].height ? " · " + variants[0].height + "p" : "")); } const segments = parseMediaPlaylist(playlistText, playlistUrl); if (!segments.length) throw new Error("В HLS playlist не найдено сегментов."); await report(jobId, "running", 5, "Сегментов: " + segments.length); const chunks = []; const downloadedMaps = new Set(); const keyCache = new Map(); for (let index = 0; index < segments.length; index += 1) { const segment = segments[index]; if (segment.map) { const identity = mapIdentity(segment.map, segment.key); if (!downloadedMaps.has(identity)) { let mapBytes = await fetchBytes(segment.map.url, segment.map.range, message.sourceUrl); mapBytes = await decryptSegment(mapBytes, segment.key, segment.sequence, message.sourceUrl, keyCache); chunks.push(mapBytes); downloadedMaps.add(identity); } } let segmentBytes = await fetchBytes(segment.url, segment.range, message.sourceUrl); segmentBytes = await decryptSegment(segmentBytes, segment.key, segment.sequence, message.sourceUrl, keyCache); chunks.push(segmentBytes); const progress = 5 + Math.round(((index + 1) / segments.length) * 86); await report(jobId, "running", progress, "Загрузка сегментов · " + (index + 1) + "/" + segments.length); } await report(jobId, "running", 94, "Сборка локального файла…"); const fragmentedMp4 = looksLikeFragmentedMp4(chunks, segments); const extension = fragmentedMp4 ? "mp4" : "ts"; const mimeType = fragmentedMp4 ? "video/mp4" : "video/mp2t"; const filename = outputFilename(message.outputFolder, message.filenameBase, extension); const blob = new Blob(chunks, { type: mimeType }); if (!blob.size) throw new Error("Собранный файл пуст."); const blobUrl = URL.createObjectURL(blob); try { const downloadId = await downloadBlob(blobUrl, filename, Boolean(message.saveAs)); await report(jobId, "saving", 98, "Сохранение файла…", { filename, downloadId }); } finally { setTimeout(() => URL.revokeObjectURL(blobUrl), 60000); } } catch (error) { reportDiagnostic("error", "offscreen.hls", "job-failed", { jobId, playlistUrl: message.playlistUrl, error: { name: error && error.name, message: error && error.message } }); await report(jobId, "error", 0, "Ошибка HLS", { error: error && error.message ? error.message : String(error) }); } finally { activeJobs.delete(jobId); } } chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (!message || message.target !== "offscreen" || !["downloadHls", "downloadDirect", "downloadTextFile"].includes(message.action)) return false; if (message.action === "downloadTextFile") { downloadTextFile(message).then((result) => { sendResponse({ ok: true, result }); }).catch((error) => { reportDiagnostic("error", "offscreen.diagnostic", "download-failed", { filename: message.filename, error: { name: error && error.name, message: error && error.message } }); sendResponse({ ok: false, error: error && error.message ? error.message : String(error) }); }); return true; } if (activeJobs.has(message.jobId)) { sendResponse({ ok: false, error: "Эта задача уже выполняется." }); return false; } sendResponse({ ok: true }); setTimeout(() => { if (message.action === "downloadHls") { runHls(message); } else { runDirect(message); } }, 0); return false; });