Files

560 lines
20 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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;
});