560 lines
20 KiB
JavaScript
560 lines
20 KiB
JavaScript
"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;
|
||
});
|