1172 lines
42 KiB
JavaScript
1172 lines
42 KiB
JavaScript
"use strict";
|
|
|
|
const REDGIFS_API = "https://api.redgifs.com";
|
|
const TOKEN_TTL_MS = 50 * 60 * 1000;
|
|
const DOWNLOAD_HISTORY_KEY = "downloadHistoryV1";
|
|
const DOWNLOAD_HISTORY_LIMIT = 5000;
|
|
const DIAGNOSTIC_LOG_KEY = "diagnosticLogV1";
|
|
const DIAGNOSTICS_SETTING_KEY = "diagnosticsEnabled";
|
|
const DIAGNOSTIC_LOG_LIMIT = 500;
|
|
const DIAGNOSTIC_REPORT_MAX_CHARS = 6 * 1024 * 1024;
|
|
const tokenCache = { value: null, expiresAt: 0 };
|
|
const jobs = new Map();
|
|
const downloadJobIds = new Map();
|
|
let creatingOffscreen = null;
|
|
let downloadHistoryCache = null;
|
|
let downloadHistoryWrite = Promise.resolve();
|
|
let diagnosticLogCache = null;
|
|
let diagnosticLogWrite = Promise.resolve();
|
|
let diagnosticsEnabled = false;
|
|
|
|
function applyDiagnosticsState(enabled) {
|
|
diagnosticsEnabled = Boolean(enabled);
|
|
if (diagnosticsEnabled) return;
|
|
diagnosticLogWrite = diagnosticLogWrite.catch(() => {}).then(async () => {
|
|
diagnosticLogCache = [];
|
|
try {
|
|
await chrome.storage.session.remove(DIAGNOSTIC_LOG_KEY);
|
|
} catch {
|
|
// Diagnostics are already disabled in memory.
|
|
}
|
|
});
|
|
}
|
|
|
|
try {
|
|
chrome.storage.local.get(DIAGNOSTICS_SETTING_KEY).then((stored) => {
|
|
applyDiagnosticsState(Boolean(stored && stored[DIAGNOSTICS_SETTING_KEY]));
|
|
}).catch(() => {});
|
|
chrome.storage.onChanged.addListener((changes, areaName) => {
|
|
if (areaName === "local" && changes[DIAGNOSTICS_SETTING_KEY]) {
|
|
applyDiagnosticsState(Boolean(changes[DIAGNOSTICS_SETTING_KEY].newValue));
|
|
}
|
|
});
|
|
} catch {
|
|
applyDiagnosticsState(false);
|
|
}
|
|
|
|
function diagnosticUrl(value) {
|
|
try {
|
|
const parsed = new URL(String(value || ""));
|
|
if (parsed.protocol === "blob:") return "blob:" + parsed.origin + "/[local-object]";
|
|
parsed.username = "";
|
|
parsed.password = "";
|
|
parsed.search = "";
|
|
parsed.hash = "";
|
|
return parsed.toString();
|
|
} catch {
|
|
return String(value || "").slice(0, 300);
|
|
}
|
|
}
|
|
|
|
function diagnosticValue(value, key, depth) {
|
|
if (depth > 5) return "[max-depth]";
|
|
const name = String(key || "").toLowerCase();
|
|
if (/authorization|cookie|token|credential|password|secret/.test(name)) return "[redacted]";
|
|
if (value == null || typeof value === "boolean" || typeof value === "number") return value;
|
|
if (value instanceof Error) {
|
|
return {
|
|
name: String(value.name || "Error").slice(0, 100),
|
|
message: String(value.message || value).slice(0, 1000)
|
|
};
|
|
}
|
|
if (typeof value === "string") {
|
|
if (/url|uri|source|playlist|media/i.test(name) || /^https?:|^blob:/i.test(value)) return diagnosticUrl(value);
|
|
return value.slice(0, 1000);
|
|
}
|
|
if (Array.isArray(value)) return value.slice(0, 50).map((item) => diagnosticValue(item, key, depth + 1));
|
|
if (typeof value === "object") {
|
|
const result = {};
|
|
for (const [childKey, childValue] of Object.entries(value).slice(0, 80)) {
|
|
result[childKey] = diagnosticValue(childValue, childKey, depth + 1);
|
|
}
|
|
return result;
|
|
}
|
|
return String(value).slice(0, 500);
|
|
}
|
|
|
|
async function loadDiagnosticLog() {
|
|
if (!diagnosticsEnabled) return [];
|
|
if (diagnosticLogCache) return diagnosticLogCache;
|
|
try {
|
|
const stored = await chrome.storage.session.get(DIAGNOSTIC_LOG_KEY);
|
|
diagnosticLogCache = Array.isArray(stored && stored[DIAGNOSTIC_LOG_KEY])
|
|
? stored[DIAGNOSTIC_LOG_KEY].slice(-DIAGNOSTIC_LOG_LIMIT)
|
|
: [];
|
|
} catch {
|
|
diagnosticLogCache = [];
|
|
}
|
|
return diagnosticLogCache;
|
|
}
|
|
|
|
function recordDiagnostic(level, area, event, details) {
|
|
if (!diagnosticsEnabled) return null;
|
|
const entry = {
|
|
at: new Date().toISOString(),
|
|
level: ["debug", "info", "warn", "error"].includes(level) ? level : "info",
|
|
area: String(area || "runtime").slice(0, 80),
|
|
event: String(event || "event").slice(0, 120),
|
|
details: diagnosticValue(details || {}, "details", 0)
|
|
};
|
|
diagnosticLogWrite = diagnosticLogWrite.catch(() => {}).then(async () => {
|
|
const log = await loadDiagnosticLog();
|
|
log.push(entry);
|
|
if (log.length > DIAGNOSTIC_LOG_LIMIT) log.splice(0, log.length - DIAGNOSTIC_LOG_LIMIT);
|
|
try {
|
|
await chrome.storage.session.set({ [DIAGNOSTIC_LOG_KEY]: log });
|
|
} catch {
|
|
// In-memory diagnostics remain available until the worker is stopped.
|
|
}
|
|
});
|
|
return entry;
|
|
}
|
|
|
|
async function diagnosticRuntimeBundle() {
|
|
if (!diagnosticsEnabled) {
|
|
return {
|
|
generatedAt: new Date().toISOString(),
|
|
extensionVersion: chrome.runtime.getManifest ? chrome.runtime.getManifest().version : null,
|
|
diagnosticsEnabled: false,
|
|
events: []
|
|
};
|
|
}
|
|
await diagnosticLogWrite.catch(() => {});
|
|
const log = await loadDiagnosticLog();
|
|
const settings = await getDownloadSettings();
|
|
return {
|
|
generatedAt: new Date().toISOString(),
|
|
extensionVersion: chrome.runtime.getManifest ? chrome.runtime.getManifest().version : null,
|
|
diagnosticsEnabled: true,
|
|
platform: typeof navigator !== "undefined" ? {
|
|
userAgent: navigator.userAgent || null,
|
|
language: navigator.language || null,
|
|
online: navigator.onLine
|
|
} : null,
|
|
tokenCache: { hasToken: Boolean(tokenCache.value), expiresAt: tokenCache.expiresAt || 0 },
|
|
downloadSettings: diagnosticValue(settings, "downloadSettings", 0),
|
|
activeJobs: Array.from(jobs.values()).slice(-50).map((job) => diagnosticValue(job, "job", 0)),
|
|
events: log.slice()
|
|
};
|
|
}
|
|
|
|
function diagnosticReportJson(report) {
|
|
const json = JSON.stringify(report, null, 2);
|
|
if (json.length > DIAGNOSTIC_REPORT_MAX_CHARS) {
|
|
throw new Error("Диагностический лог слишком большой для сохранения.");
|
|
}
|
|
return json;
|
|
}
|
|
|
|
function diagnosticReportDataUrl(report) {
|
|
const json = diagnosticReportJson(report);
|
|
const bytes = new TextEncoder().encode(json);
|
|
let binary = "";
|
|
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
|
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
|
}
|
|
return "data:application/json;base64," + btoa(binary);
|
|
}
|
|
|
|
async function downloadDiagnosticReport(report) {
|
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
const filename = "redgifs-diagnostic-log-" + stamp + ".json";
|
|
const text = diagnosticReportJson(report);
|
|
try {
|
|
await ensureOffscreenDocument();
|
|
const response = await chrome.runtime.sendMessage({
|
|
target: "offscreen",
|
|
action: "downloadTextFile",
|
|
text,
|
|
filename,
|
|
mimeType: "application/json;charset=utf-8",
|
|
saveAs: true
|
|
});
|
|
if (!response || !response.ok || !response.result) {
|
|
throw new Error(response && response.error ? response.error : "Offscreen-процесс не сохранил лог.");
|
|
}
|
|
recordDiagnostic("info", "diagnostic", "report-download-started", {
|
|
downloadId: response.result.downloadId,
|
|
filename,
|
|
mode: "offscreen-blob",
|
|
textLength: text.length
|
|
});
|
|
return Object.assign({ filename, mode: "offscreen-blob" }, response.result);
|
|
} catch (offscreenError) {
|
|
recordDiagnostic("warn", "diagnostic", "offscreen-report-download-failed", {
|
|
filename,
|
|
error: offscreenError
|
|
});
|
|
const url = diagnosticReportDataUrl(report);
|
|
const downloadId = await chrome.downloads.download({
|
|
url,
|
|
filename,
|
|
saveAs: true,
|
|
conflictAction: "uniquify"
|
|
});
|
|
recordDiagnostic("info", "diagnostic", "report-download-started", {
|
|
downloadId,
|
|
filename,
|
|
mode: "data-url-fallback",
|
|
encodedBytes: url.length
|
|
});
|
|
return { downloadId, filename, mode: "data-url-fallback" };
|
|
}
|
|
}
|
|
|
|
function isRedgifsHost(hostname) {
|
|
const host = String(hostname || "").toLowerCase();
|
|
return host === "redgifs.com" || host.endsWith(".redgifs.com");
|
|
}
|
|
|
|
function parseTrustedUrl(value) {
|
|
let parsed;
|
|
try {
|
|
parsed = new URL(String(value || "").trim());
|
|
} catch {
|
|
throw new Error("Введите корректную ссылку RedGifs.");
|
|
}
|
|
if (parsed.protocol !== "https:" || !isRedgifsHost(parsed.hostname)) {
|
|
throw new Error("Поддерживаются только HTTPS-ссылки RedGifs.");
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function extractId(parsedUrl) {
|
|
const match = parsedUrl.pathname.match(/\/(?:watch|ifr)\/([a-z0-9_-]+)/i);
|
|
return match && match[1] ? match[1] : null;
|
|
}
|
|
|
|
function normalizeMediaId(value) {
|
|
const id = String(value || "").trim().toLowerCase();
|
|
return /^[a-z0-9_-]{2,160}$/i.test(id) ? id : null;
|
|
}
|
|
|
|
function decodeHtml(value) {
|
|
return String(value || "")
|
|
.replace(/&/gi, "&")
|
|
.replace(///gi, "/")
|
|
.replace(/"/gi, "\"")
|
|
.replace(/'/gi, "'");
|
|
}
|
|
|
|
function extensionFromUrl(url, fallback) {
|
|
try {
|
|
const match = new URL(url).pathname.match(/\.([a-z0-9]{2,5})$/i);
|
|
return match ? match[1].toLowerCase() : fallback;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function sanitizeFilename(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);
|
|
}
|
|
|
|
const DEFAULT_DOWNLOAD_SETTINGS = {
|
|
baseFolder: "RedGifs",
|
|
groupByProfile: true,
|
|
saveAs: false
|
|
};
|
|
|
|
function sanitizeFolderPath(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 profileFromUrl(value) {
|
|
try {
|
|
const parsed = parseTrustedUrl(value);
|
|
const match = parsed.pathname.match(/\/users\/([^/?#]+)/i);
|
|
return match && match[1] ? decodeURIComponent(match[1]) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function profileFromGif(gif) {
|
|
if (!gif || typeof gif !== "object") return null;
|
|
const nestedUser = gif.user && typeof gif.user === "object" ? gif.user : null;
|
|
const value = gif.userName
|
|
|| gif.username
|
|
|| gif.user_name
|
|
|| (nestedUser && (nestedUser.userName || nestedUser.username || nestedUser.name));
|
|
return sanitizeFolderPath(String(value || "").trim().replace(/^@+/, "")) || null;
|
|
}
|
|
|
|
async function getDownloadSettings() {
|
|
try {
|
|
const stored = await chrome.storage.local.get("downloadSettings");
|
|
return Object.assign({}, DEFAULT_DOWNLOAD_SETTINGS, stored.downloadSettings || {});
|
|
} catch {
|
|
return Object.assign({}, DEFAULT_DOWNLOAD_SETTINGS);
|
|
}
|
|
}
|
|
|
|
async function readDownloadHistory() {
|
|
if (downloadHistoryCache) return downloadHistoryCache;
|
|
try {
|
|
const stored = await chrome.storage.local.get(DOWNLOAD_HISTORY_KEY);
|
|
const value = stored && stored[DOWNLOAD_HISTORY_KEY];
|
|
downloadHistoryCache = value && typeof value === "object" ? value : {};
|
|
} catch {
|
|
downloadHistoryCache = {};
|
|
}
|
|
return downloadHistoryCache;
|
|
}
|
|
|
|
async function getDownloadedRecords(ids) {
|
|
await downloadHistoryWrite.catch(() => {});
|
|
const history = await readDownloadHistory();
|
|
const result = {};
|
|
for (const value of Array.isArray(ids) ? ids.slice(0, 500) : []) {
|
|
const id = normalizeMediaId(value);
|
|
if (id && history[id]) result[id] = history[id];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function rememberDownloaded(job) {
|
|
const mediaId = normalizeMediaId(job && job.mediaId);
|
|
if (!mediaId) return Promise.resolve();
|
|
downloadHistoryWrite = downloadHistoryWrite.catch(() => {}).then(async () => {
|
|
const history = Object.assign({}, await readDownloadHistory());
|
|
history[mediaId] = {
|
|
downloadedAt: Date.now(),
|
|
filename: sanitizeFilename(job.filename || job.title || mediaId),
|
|
profileSlug: sanitizeFolderPath(job.profileSlug || "") || null
|
|
};
|
|
const keys = Object.keys(history);
|
|
if (keys.length > DOWNLOAD_HISTORY_LIMIT) {
|
|
keys.sort((left, right) => {
|
|
return Number(history[left] && history[left].downloadedAt || 0)
|
|
- Number(history[right] && history[right].downloadedAt || 0);
|
|
});
|
|
for (const key of keys.slice(0, keys.length - DOWNLOAD_HISTORY_LIMIT)) delete history[key];
|
|
}
|
|
downloadHistoryCache = history;
|
|
try {
|
|
await chrome.storage.local.set({ [DOWNLOAD_HISTORY_KEY]: history });
|
|
} catch {
|
|
// The current session still remembers the completed download.
|
|
}
|
|
});
|
|
return downloadHistoryWrite;
|
|
}
|
|
|
|
function buildOutputFolder(settings, profileSlug) {
|
|
const parts = [];
|
|
const base = sanitizeFolderPath(settings.baseFolder);
|
|
if (base) parts.push(base);
|
|
if (settings.groupByProfile) {
|
|
const profile = sanitizeFolderPath(profileSlug);
|
|
if (profile) parts.push(profile);
|
|
}
|
|
return parts.join("/");
|
|
}
|
|
|
|
function addFormat(list, seen, label, url, kind, quality, extension) {
|
|
if (!url) return;
|
|
let parsed;
|
|
try {
|
|
parsed = parseTrustedUrl(url);
|
|
} catch {
|
|
return;
|
|
}
|
|
const normalized = parsed.toString();
|
|
if (seen.has(normalized)) return;
|
|
seen.add(normalized);
|
|
list.push({
|
|
label,
|
|
url: normalized,
|
|
kind,
|
|
quality: quality || null,
|
|
extension: extension || extensionFromUrl(normalized, kind === "hls" ? "m3u8" : "mp4")
|
|
});
|
|
}
|
|
|
|
function formatsFromGif(gif) {
|
|
const formats = [];
|
|
const seen = new Set();
|
|
const urls = gif && gif.urls ? gif.urls : {};
|
|
const height = Number(gif && gif.height) || null;
|
|
const sdHeight = height && height >= 720 ? 480 : 360;
|
|
|
|
addFormat(formats, seen, height ? "HD MP4 · " + height + "p" : "HD MP4", urls.hd, "direct", height, "mp4");
|
|
addFormat(formats, seen, "SD MP4 · " + sdHeight + "p", urls.sd, "direct", sdHeight, "mp4");
|
|
addFormat(formats, seen, "HLS · локальная сборка", urls.hls, "hls", height, "m3u8");
|
|
addFormat(formats, seen, "Без звука", urls.silent, String(urls.silent || "").includes(".m3u8") ? "hls" : "direct", height, extensionFromUrl(urls.silent, "mp4"));
|
|
addFormat(formats, seen, "GIF", urls.gif, "direct", null, extensionFromUrl(urls.gif, "gif"));
|
|
return formats;
|
|
}
|
|
|
|
async function getTemporaryToken(forceRefresh) {
|
|
if (!forceRefresh && tokenCache.value && tokenCache.expiresAt > Date.now()) {
|
|
recordDiagnostic("debug", "network.auth", "token-cache-hit", { expiresAt: tokenCache.expiresAt });
|
|
return tokenCache.value;
|
|
}
|
|
let lastError = null;
|
|
for (const method of ["GET", "POST"]) {
|
|
const startedAt = Date.now();
|
|
try {
|
|
recordDiagnostic("info", "network.auth", "request", { method, url: REDGIFS_API + "/v2/auth/temporary" });
|
|
const response = await fetch(REDGIFS_API + "/v2/auth/temporary", {
|
|
method,
|
|
headers: { Accept: "application/json, text/plain, */*" },
|
|
cache: "no-store"
|
|
});
|
|
recordDiagnostic(response.ok ? "info" : "warn", "network.auth", "response", {
|
|
method,
|
|
url: REDGIFS_API + "/v2/auth/temporary",
|
|
status: response.status,
|
|
ok: response.ok,
|
|
durationMs: Date.now() - startedAt
|
|
});
|
|
if (!response.ok) throw new Error("HTTP " + response.status);
|
|
const payload = await response.json();
|
|
if (!payload || !payload.token) throw new Error("RedGifs не вернул временный токен.");
|
|
tokenCache.value = payload.token;
|
|
tokenCache.expiresAt = Date.now() + TOKEN_TTL_MS;
|
|
return tokenCache.value;
|
|
} catch (error) {
|
|
lastError = error;
|
|
recordDiagnostic("error", "network.auth", "request-failed", {
|
|
method,
|
|
url: REDGIFS_API + "/v2/auth/temporary",
|
|
durationMs: Date.now() - startedAt,
|
|
error
|
|
});
|
|
}
|
|
}
|
|
throw new Error("Не удалось получить временный токен RedGifs: " + (lastError && lastError.message ? lastError.message : "unknown error"));
|
|
}
|
|
|
|
async function fetchGifMetadata(id, forceTokenRefresh) {
|
|
const token = await getTemporaryToken(Boolean(forceTokenRefresh));
|
|
const watchUrl = "https://www.redgifs.com/watch/" + encodeURIComponent(id);
|
|
const apiUrl = REDGIFS_API + "/v2/gifs/" + encodeURIComponent(id);
|
|
const startedAt = Date.now();
|
|
let response;
|
|
try {
|
|
recordDiagnostic("info", "network.metadata", "request", { id, url: apiUrl, forceTokenRefresh: Boolean(forceTokenRefresh) });
|
|
response = await fetch(apiUrl, {
|
|
headers: {
|
|
Accept: "application/json, text/plain, */*",
|
|
Authorization: "Bearer " + token,
|
|
"x-customheader": watchUrl
|
|
},
|
|
cache: "no-store",
|
|
referrer: watchUrl,
|
|
referrerPolicy: "no-referrer-when-downgrade"
|
|
});
|
|
recordDiagnostic(response.ok ? "info" : "warn", "network.metadata", "response", {
|
|
id,
|
|
url: apiUrl,
|
|
status: response.status,
|
|
ok: response.ok,
|
|
durationMs: Date.now() - startedAt
|
|
});
|
|
} catch (error) {
|
|
recordDiagnostic("error", "network.metadata", "request-failed", {
|
|
id,
|
|
url: apiUrl,
|
|
durationMs: Date.now() - startedAt,
|
|
error
|
|
});
|
|
throw error;
|
|
}
|
|
if (response.status === 401 && !forceTokenRefresh) {
|
|
tokenCache.value = null;
|
|
tokenCache.expiresAt = 0;
|
|
return fetchGifMetadata(id, true);
|
|
}
|
|
if (!response.ok) throw new Error("RedGifs API: HTTP " + response.status);
|
|
const payload = await response.json();
|
|
if (!payload || !payload.gif) throw new Error("RedGifs API не вернул данные видео.");
|
|
return payload.gif;
|
|
}
|
|
|
|
function metaContent(html, key) {
|
|
const escaped = key.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
|
|
const first = new RegExp("<meta[^>]+(?:property|name)=[\"']" + escaped + "[\"'][^>]+content=[\"']([^\"']+)[\"']", "i");
|
|
const second = new RegExp("<meta[^>]+content=[\"']([^\"']+)[\"'][^>]+(?:property|name)=[\"']" + escaped + "[\"']", "i");
|
|
const match = html.match(first) || html.match(second);
|
|
return match && match[1] ? decodeHtml(match[1]) : null;
|
|
}
|
|
|
|
async function pageFallback(parsedUrl, knownId) {
|
|
const startedAt = Date.now();
|
|
let response;
|
|
try {
|
|
recordDiagnostic("info", "network.page", "request", { url: parsedUrl.toString(), knownId });
|
|
response = await fetch(parsedUrl.toString(), {
|
|
headers: { Accept: "text/html,application/xhtml+xml" },
|
|
cache: "no-store"
|
|
});
|
|
recordDiagnostic(response.ok ? "info" : "warn", "network.page", "response", {
|
|
url: parsedUrl.toString(),
|
|
status: response.status,
|
|
ok: response.ok,
|
|
durationMs: Date.now() - startedAt
|
|
});
|
|
} catch (error) {
|
|
recordDiagnostic("error", "network.page", "request-failed", {
|
|
url: parsedUrl.toString(),
|
|
durationMs: Date.now() - startedAt,
|
|
error
|
|
});
|
|
throw error;
|
|
}
|
|
if (!response.ok) throw new Error("Страница RedGifs недоступна: HTTP " + response.status);
|
|
const html = await response.text();
|
|
const idMatch = html.match(/https:\/\/(?:www\.)?redgifs\.com\/(?:watch|ifr)\/([a-z0-9_-]+)/i);
|
|
const id = knownId || (idMatch && idMatch[1] ? idMatch[1] : null);
|
|
const title = metaContent(html, "og:title") || metaContent(html, "twitter:title") || id || "RedGifs video";
|
|
const thumbnail = metaContent(html, "og:image") || metaContent(html, "twitter:image");
|
|
const candidates = [
|
|
metaContent(html, "og:video"),
|
|
metaContent(html, "og:video:url"),
|
|
metaContent(html, "twitter:player:stream")
|
|
];
|
|
const embedded = html.match(/https:\/\/media\.redgifs\.com\/[^"'\\\s<>]+?\.(?:mp4|m3u8|gif|webm)(?:\?[^"'\\\s<>]*)?/gi) || [];
|
|
candidates.push.apply(candidates, embedded);
|
|
const formats = [];
|
|
const seen = new Set();
|
|
for (const candidate of candidates) {
|
|
if (!candidate) continue;
|
|
const value = decodeHtml(candidate);
|
|
const hls = /\.m3u8(?:$|\?)/i.test(value);
|
|
addFormat(formats, seen, hls ? "HLS · найдено на странице" : "Видео · найдено на странице", value, hls ? "hls" : "direct", null, hls ? "m3u8" : extensionFromUrl(value, "mp4"));
|
|
}
|
|
return { id, title, thumbnail, formats };
|
|
}
|
|
|
|
async function getRedgifsTab(tabId) {
|
|
let tab = null;
|
|
if (Number.isInteger(tabId)) {
|
|
try {
|
|
tab = await chrome.tabs.get(tabId);
|
|
} catch {
|
|
tab = null;
|
|
}
|
|
}
|
|
if (!tab) {
|
|
const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
|
|
tab = tabs && tabs[0] ? tabs[0] : null;
|
|
}
|
|
if (!tab || !tab.id || !tab.url) throw new Error("Не найдена открытая вкладка RedGifs.");
|
|
const parsed = parseTrustedUrl(tab.url);
|
|
return { tab, parsed };
|
|
}
|
|
|
|
function collectionTitleNeedsMetadata(item) {
|
|
const id = normalizeMediaId(item && item.id);
|
|
const title = String(item && item.title || "").trim();
|
|
if (!title) return true;
|
|
const withoutPrefix = title.replace(/^redgifs\s*[·:—-]?\s*/i, "").trim();
|
|
return Boolean(id && normalizeMediaId(withoutPrefix) === id);
|
|
}
|
|
|
|
function mergeCollectionItemMetadata(item, gif) {
|
|
if (!item || !gif) return item;
|
|
const tags = Array.isArray(gif.tags) ? gif.tags.slice(0, 8).join(" ") : "";
|
|
const metadataTitle = String(gif.title || gif.description || tags || "").trim();
|
|
const rawThumbnail = gif.urls && (gif.urls.poster || gif.urls.thumbnail) || null;
|
|
let thumbnail = item.thumbnail || null;
|
|
if (rawThumbnail) {
|
|
try {
|
|
thumbnail = parseTrustedUrl(rawThumbnail).toString();
|
|
} catch {
|
|
// Keep the thumbnail captured from the page.
|
|
}
|
|
}
|
|
return Object.assign({}, item, {
|
|
title: collectionTitleNeedsMetadata(item) && metadataTitle
|
|
? metadataTitle.slice(0, 160)
|
|
: item.title,
|
|
thumbnail,
|
|
profileSlug: profileFromGif(gif) || item.profileSlug || null
|
|
});
|
|
}
|
|
|
|
async function hydrateCollectionItems(items) {
|
|
const result = Array.isArray(items) ? items.slice() : [];
|
|
const pendingIndexes = [];
|
|
for (let index = 0; index < result.length; index += 1) {
|
|
const item = result[index];
|
|
if (normalizeMediaId(item && item.id)
|
|
&& (!item.thumbnail || !item.profileSlug || collectionTitleNeedsMetadata(item))) {
|
|
pendingIndexes.push(index);
|
|
}
|
|
}
|
|
if (!pendingIndexes.length) return result;
|
|
|
|
try {
|
|
await getTemporaryToken(false);
|
|
} catch (error) {
|
|
recordDiagnostic("warn", "content.scan", "metadata-hydration-auth-failed", {
|
|
itemCount: pendingIndexes.length,
|
|
error
|
|
});
|
|
return result;
|
|
}
|
|
|
|
let cursor = 0;
|
|
let hydratedCount = 0;
|
|
let failedCount = 0;
|
|
async function worker() {
|
|
while (cursor < pendingIndexes.length) {
|
|
const pendingIndex = pendingIndexes[cursor];
|
|
cursor += 1;
|
|
const item = result[pendingIndex];
|
|
try {
|
|
const gif = await fetchGifMetadata(item.id, false);
|
|
result[pendingIndex] = mergeCollectionItemMetadata(item, gif);
|
|
hydratedCount += 1;
|
|
} catch (error) {
|
|
failedCount += 1;
|
|
recordDiagnostic("warn", "content.scan", "metadata-hydration-item-failed", {
|
|
id: item.id,
|
|
error
|
|
});
|
|
}
|
|
}
|
|
}
|
|
const concurrency = Math.min(6, pendingIndexes.length);
|
|
await Promise.all(Array.from({ length: concurrency }, () => worker()));
|
|
recordDiagnostic("info", "content.scan", "metadata-hydration-complete", {
|
|
requestedCount: pendingIndexes.length,
|
|
hydratedCount,
|
|
failedCount
|
|
});
|
|
return result;
|
|
}
|
|
|
|
async function scanOpenRedgifsPage(tabId, requestedUrl) {
|
|
const current = await getRedgifsTab(tabId);
|
|
if (requestedUrl && current.parsed.origin + current.parsed.pathname !== requestedUrl.origin + requestedUrl.pathname) {
|
|
throw new Error("Откройте указанный профиль RedGifs в текущей вкладке, затем нажмите «Сканировать».");
|
|
}
|
|
|
|
let response = null;
|
|
try {
|
|
response = await chrome.tabs.sendMessage(current.tab.id, { action: "scanRedgifsPage" });
|
|
} catch (error) {
|
|
recordDiagnostic("warn", "content.scan", "content-script-unavailable", { tabId: current.tab.id, error });
|
|
await chrome.scripting.executeScript({
|
|
target: { tabId: current.tab.id },
|
|
files: ["content.js"]
|
|
});
|
|
response = await chrome.tabs.sendMessage(current.tab.id, { action: "scanRedgifsPage" });
|
|
}
|
|
if (!response || !response.ok) {
|
|
recordDiagnostic("error", "content.scan", "scan-failed", { tabId: current.tab.id, response });
|
|
throw new Error(response && response.error ? response.error : "Не удалось просканировать страницу RedGifs.");
|
|
}
|
|
|
|
const rawItems = response.result && Array.isArray(response.result.items) ? response.result.items : [];
|
|
recordDiagnostic("info", "content.scan", "scan-complete", {
|
|
tabId: current.tab.id,
|
|
pageUrl: response.result && response.result.pageUrl,
|
|
rawItemCount: rawItems.length
|
|
});
|
|
const items = [];
|
|
const seen = new Set();
|
|
for (const raw of rawItems) {
|
|
if (items.length >= 60) break;
|
|
const id = raw && raw.id ? String(raw.id) : null;
|
|
let watchUrl = null;
|
|
let directVideoUrl = null;
|
|
let thumbnail = null;
|
|
try {
|
|
if (raw && raw.watchUrl) watchUrl = parseTrustedUrl(raw.watchUrl).toString();
|
|
} catch {
|
|
watchUrl = null;
|
|
}
|
|
try {
|
|
if (raw && raw.directVideoUrl) directVideoUrl = parseTrustedUrl(raw.directVideoUrl).toString();
|
|
} catch {
|
|
directVideoUrl = null;
|
|
}
|
|
try {
|
|
if (raw && raw.thumbnail) thumbnail = parseTrustedUrl(raw.thumbnail).toString();
|
|
} catch {
|
|
thumbnail = null;
|
|
}
|
|
if (!id && !directVideoUrl) continue;
|
|
const key = id ? "id:" + id.toLowerCase() : "url:" + directVideoUrl;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
items.push({
|
|
id,
|
|
watchUrl: watchUrl || (id ? "https://www.redgifs.com/watch/" + encodeURIComponent(id) : null),
|
|
directVideoUrl,
|
|
thumbnail,
|
|
title: String(raw.title || (id ? "RedGifs · " + id : "RedGifs video")).slice(0, 160),
|
|
profileSlug: sanitizeFolderPath(raw.profileSlug || profileFromUrl(current.parsed.toString()))
|
|
});
|
|
}
|
|
const hydratedItems = await hydrateCollectionItems(items);
|
|
return {
|
|
type: "collection",
|
|
title: response.result && response.result.pageTitle || "Видимые ролики RedGifs",
|
|
sourceUrl: current.parsed.toString(),
|
|
items: hydratedItems
|
|
};
|
|
}
|
|
|
|
async function resolveMedia(input, tabId) {
|
|
const parsedUrl = parseTrustedUrl(input);
|
|
const id = extractId(parsedUrl);
|
|
const directExtension = extensionFromUrl(parsedUrl.toString(), "");
|
|
if (["mp4", "webm", "gif", "m3u8"].includes(directExtension)) {
|
|
return {
|
|
id: null,
|
|
title: sanitizeFilename(parsedUrl.pathname.split("/").pop() || "redgifs-video"),
|
|
thumbnail: null,
|
|
sourceUrl: parsedUrl.toString(),
|
|
formats: [{
|
|
label: directExtension === "m3u8" ? "HLS · прямая ссылка" : directExtension.toUpperCase() + " · прямая ссылка",
|
|
url: parsedUrl.toString(),
|
|
kind: directExtension === "m3u8" ? "hls" : "direct",
|
|
quality: null,
|
|
extension: directExtension
|
|
}]
|
|
};
|
|
}
|
|
|
|
if (!id) {
|
|
const collection = await scanOpenRedgifsPage(tabId, parsedUrl);
|
|
if (collection.items.length) return collection;
|
|
throw new Error("На открытой странице не найдено видимых роликов. Прокрутите профиль до плиток и снова нажмите «Сканировать».");
|
|
}
|
|
|
|
let apiError = null;
|
|
if (id) {
|
|
try {
|
|
const gif = await fetchGifMetadata(id, false);
|
|
const formats = formatsFromGif(gif);
|
|
if (formats.length) {
|
|
const tagTitle = Array.isArray(gif.tags) ? gif.tags.slice(0, 8).join(" ") : "";
|
|
return {
|
|
id,
|
|
title: gif.title || gif.description || tagTitle || id,
|
|
profileSlug: profileFromGif(gif),
|
|
thumbnail: gif.urls && (gif.urls.poster || gif.urls.thumbnail) || null,
|
|
sourceUrl: parsedUrl.toString(),
|
|
formats
|
|
};
|
|
}
|
|
} catch (error) {
|
|
apiError = error;
|
|
}
|
|
}
|
|
|
|
try {
|
|
const fallback = await pageFallback(parsedUrl, id);
|
|
if (fallback.formats.length) {
|
|
return {
|
|
id: fallback.id,
|
|
title: fallback.title,
|
|
thumbnail: fallback.thumbnail,
|
|
sourceUrl: parsedUrl.toString(),
|
|
formats: fallback.formats
|
|
};
|
|
}
|
|
} catch (fallbackError) {
|
|
if (!apiError) apiError = fallbackError;
|
|
}
|
|
|
|
if (!id) throw new Error("Не найден ID видео. Вставьте ссылку вида redgifs.com/watch/...");
|
|
throw new Error("Не удалось найти адрес видео. " + (apiError && apiError.message ? apiError.message : ""));
|
|
}
|
|
|
|
async function hasOffscreenDocument() {
|
|
const offscreenUrl = chrome.runtime.getURL("offscreen.html");
|
|
if (chrome.runtime.getContexts) {
|
|
const contexts = await chrome.runtime.getContexts({
|
|
contextTypes: ["OFFSCREEN_DOCUMENT"],
|
|
documentUrls: [offscreenUrl]
|
|
});
|
|
return contexts.length > 0;
|
|
}
|
|
const clients = await self.clients.matchAll();
|
|
return clients.some((client) => client.url === offscreenUrl);
|
|
}
|
|
|
|
async function ensureOffscreenDocument() {
|
|
if (await hasOffscreenDocument()) return;
|
|
if (!creatingOffscreen) {
|
|
creatingOffscreen = chrome.offscreen.createDocument({
|
|
url: "offscreen.html",
|
|
reasons: ["BLOBS"],
|
|
justification: "Download HLS segments and assemble a local media Blob."
|
|
}).finally(() => {
|
|
creatingOffscreen = null;
|
|
});
|
|
}
|
|
await creatingOffscreen;
|
|
}
|
|
|
|
async function persistJob(job) {
|
|
jobs.set(job.id, job);
|
|
try {
|
|
await chrome.storage.session.set({ ["job:" + job.id]: job });
|
|
} catch {
|
|
// In-memory state is enough while the service worker remains alive.
|
|
}
|
|
}
|
|
|
|
async function readJob(jobId) {
|
|
if (jobs.has(jobId)) return jobs.get(jobId);
|
|
try {
|
|
const key = "job:" + jobId;
|
|
const stored = await chrome.storage.session.get(key);
|
|
if (stored && stored[key]) {
|
|
jobs.set(jobId, stored[key]);
|
|
return stored[key];
|
|
}
|
|
} catch {
|
|
// Ignore unavailable session storage.
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function startDownload(message) {
|
|
const format = message && message.format;
|
|
if (!format || !format.url) throw new Error("Формат не выбран.");
|
|
const url = parseTrustedUrl(format.url).toString();
|
|
const filenameBase = sanitizeFilename(message.title || message.id || "redgifs-video");
|
|
const settings = await getDownloadSettings();
|
|
const profileSlug = sanitizeFolderPath(message.profileSlug || profileFromUrl(message.sourceUrl));
|
|
const outputFolder = buildOutputFolder(settings, profileSlug);
|
|
const saveAs = message.saveAs == null ? Boolean(settings.saveAs) : Boolean(message.saveAs);
|
|
await ensureOffscreenDocument();
|
|
const jobId = crypto.randomUUID();
|
|
const isHls = format.kind === "hls" || /\.m3u8(?:$|\?)/i.test(url);
|
|
const extension = String(format.extension || extensionFromUrl(url, "mp4")).replace(/[^a-z0-9]/gi, "") || "mp4";
|
|
let mediaId = normalizeMediaId(message.id);
|
|
if (!mediaId && message.sourceUrl) {
|
|
try {
|
|
mediaId = normalizeMediaId(extractId(parseTrustedUrl(message.sourceUrl)));
|
|
} catch {
|
|
mediaId = null;
|
|
}
|
|
}
|
|
const job = {
|
|
id: jobId,
|
|
state: "queued",
|
|
progress: 0,
|
|
status: isHls ? "Подготовка HLS…" : "Подготовка загрузки…",
|
|
filename: filenameBase,
|
|
title: filenameBase,
|
|
mediaId,
|
|
clientRequestId: String(message.clientRequestId || "").slice(0, 180) || null,
|
|
tabId: Number.isInteger(message.tabId) ? message.tabId : null,
|
|
profileSlug: profileSlug || null,
|
|
outputFolder,
|
|
createdAt: Date.now()
|
|
};
|
|
recordDiagnostic("info", "download", "job-created", {
|
|
jobId,
|
|
mediaId,
|
|
mode: isHls ? "hls" : "direct-local",
|
|
mediaUrl: url,
|
|
extension,
|
|
outputFolder,
|
|
saveAs
|
|
});
|
|
await persistJob(job);
|
|
await notifyJob(job);
|
|
const accepted = await chrome.runtime.sendMessage({
|
|
target: "offscreen",
|
|
action: isHls ? "downloadHls" : "downloadDirect",
|
|
jobId,
|
|
playlistUrl: isHls ? url : null,
|
|
mediaUrl: isHls ? null : url,
|
|
sourceUrl: message.sourceUrl || "https://www.redgifs.com/",
|
|
filenameBase,
|
|
outputFolder,
|
|
extension,
|
|
saveAs
|
|
});
|
|
if (!accepted || !accepted.ok) {
|
|
recordDiagnostic("error", "offscreen", "job-rejected", { jobId, accepted });
|
|
throw new Error(accepted && accepted.error ? accepted.error : "Offscreen-процесс не принял загрузку.");
|
|
}
|
|
recordDiagnostic("info", "offscreen", "job-accepted", { jobId, mode: isHls ? "hls" : "direct-local" });
|
|
return { mode: isHls ? "hls" : "direct-local", jobId };
|
|
}
|
|
|
|
function chooseBestFormat(formats) {
|
|
const list = Array.isArray(formats) ? formats.filter((format) => format && format.url) : [];
|
|
return list.find((format) => format.kind !== "hls" && /hd|1080|720/i.test(String(format.label || "")))
|
|
|| list.find((format) => format.kind !== "hls" && String(format.extension || "").toLowerCase() === "mp4")
|
|
|| list.find((format) => format.kind !== "hls")
|
|
|| list[0]
|
|
|| null;
|
|
}
|
|
|
|
function chooseDownloadTitle(item, media) {
|
|
const mediaId = normalizeMediaId(media && media.id || item && item.id);
|
|
const resolvedTitle = String(media && media.title || "").trim();
|
|
const normalizedResolved = resolvedTitle.toLowerCase()
|
|
.replace(/^redgifs\s*[·:-]?\s*/i, "")
|
|
.trim();
|
|
const resolvedIsFallback = !resolvedTitle
|
|
|| (mediaId && normalizeMediaId(normalizedResolved) === mediaId);
|
|
if (!resolvedIsFallback) return resolvedTitle;
|
|
let fallbackTitle = sanitizeFilename(item && item.title || resolvedTitle || mediaId || "RedGifs video");
|
|
if (mediaId) {
|
|
const escapedId = mediaId.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&");
|
|
fallbackTitle = fallbackTitle.replace(new RegExp("\\s+" + escapedId + "$", "i"), "").trim();
|
|
}
|
|
return fallbackTitle || mediaId || "RedGifs video";
|
|
}
|
|
|
|
async function downloadCapturedItem(item, tabId) {
|
|
if (!item || typeof item !== "object") throw new Error("Данные плитки не получены.");
|
|
const target = item.watchUrl || item.directVideoUrl;
|
|
if (!target) throw new Error("У плитки не найден ID или адрес видео.");
|
|
const media = await resolveMedia(target, tabId);
|
|
if (!media || media.type === "collection") throw new Error("Не удалось определить отдельный ролик.");
|
|
const format = chooseBestFormat(media.formats);
|
|
if (!format) throw new Error("RedGifs не вернул доступных форматов.");
|
|
return startDownload({
|
|
format,
|
|
title: chooseDownloadTitle(item, media),
|
|
id: item.id || media.id,
|
|
clientRequestId: item.clientRequestId || null,
|
|
sourceUrl: media.sourceUrl || target,
|
|
profileSlug: media.profileSlug || item.profileSlug || null,
|
|
tabId
|
|
});
|
|
}
|
|
|
|
async function notifyJob(job) {
|
|
if (!job || !Number.isInteger(job.tabId)) return;
|
|
try {
|
|
await chrome.tabs.sendMessage(job.tabId, {
|
|
action: "downloadProgress",
|
|
job
|
|
});
|
|
} catch {
|
|
// The originating tab may have been closed or navigated away.
|
|
}
|
|
}
|
|
|
|
async function registerDownloadJob(downloadId, jobId) {
|
|
if (!Number.isInteger(downloadId) || !jobId) return;
|
|
downloadJobIds.set(downloadId, jobId);
|
|
try {
|
|
await chrome.storage.session.set({ ["downloadJob:" + downloadId]: jobId });
|
|
} catch {
|
|
// The in-memory mapping normally remains available.
|
|
}
|
|
}
|
|
|
|
async function findJobIdForDownload(downloadId) {
|
|
if (downloadJobIds.has(downloadId)) return downloadJobIds.get(downloadId);
|
|
try {
|
|
const key = "downloadJob:" + downloadId;
|
|
const stored = await chrome.storage.session.get(key);
|
|
if (stored && stored[key]) {
|
|
downloadJobIds.set(downloadId, stored[key]);
|
|
return stored[key];
|
|
}
|
|
} catch {
|
|
// Ignore unavailable session storage.
|
|
}
|
|
return null;
|
|
}
|
|
|
|
async function updateJob(message) {
|
|
const previous = await readJob(message.jobId) || { id: message.jobId, createdAt: Date.now() };
|
|
const next = Object.assign({}, previous, {
|
|
state: message.state || previous.state || "running",
|
|
progress: Number.isFinite(message.progress) ? message.progress : previous.progress || 0,
|
|
status: message.status || previous.status || "",
|
|
filename: message.filename || previous.filename || null,
|
|
downloadId: message.downloadId == null ? previous.downloadId : message.downloadId,
|
|
error: message.error || null,
|
|
updatedAt: Date.now()
|
|
});
|
|
if (next.state !== previous.state || next.state === "error") {
|
|
recordDiagnostic(next.state === "error" ? "error" : "info", "download", "job-state", {
|
|
jobId: next.id,
|
|
previousState: previous.state || null,
|
|
state: next.state,
|
|
progress: next.progress,
|
|
status: next.status,
|
|
error: next.error,
|
|
downloadId: next.downloadId
|
|
});
|
|
}
|
|
await persistJob(next);
|
|
if (Number.isInteger(next.downloadId)) {
|
|
await registerDownloadJob(next.downloadId, next.id);
|
|
try {
|
|
const matches = await chrome.downloads.search({ id: next.downloadId });
|
|
const current = matches && matches[0];
|
|
if (current && current.state === "complete") {
|
|
await applyDownloadState(next.downloadId, { state: { current: "complete" } });
|
|
return await readJob(next.id);
|
|
}
|
|
if (current && current.state === "interrupted") {
|
|
await applyDownloadState(next.downloadId, {
|
|
state: { current: "interrupted" },
|
|
error: { current: current.error || "DOWNLOAD_INTERRUPTED" }
|
|
});
|
|
return await readJob(next.id);
|
|
}
|
|
} catch {
|
|
// The normal downloads.onChanged listener will report the result.
|
|
}
|
|
}
|
|
await notifyJob(next);
|
|
return next;
|
|
}
|
|
|
|
async function applyDownloadState(downloadId, change) {
|
|
const jobId = await findJobIdForDownload(downloadId);
|
|
if (!jobId) return;
|
|
const previous = await readJob(jobId);
|
|
if (!previous) return;
|
|
|
|
const incomingState = change && change.state && change.state.current;
|
|
const isTerminal = previous.state === "complete" || previous.state === "error";
|
|
if (isTerminal) return;
|
|
|
|
const hasByteUpdate = Boolean(change && (change.bytesReceived || change.totalBytes));
|
|
const hasProgressState = incomingState === "in_progress";
|
|
if (!incomingState && !hasByteUpdate) return;
|
|
|
|
const bytesReceived = change.bytesReceived && Number(change.bytesReceived.current);
|
|
const totalBytes = change.totalBytes && Number(change.totalBytes.current);
|
|
const next = Object.assign({}, previous);
|
|
if (Number.isFinite(bytesReceived)) next.bytesReceived = bytesReceived;
|
|
if (Number.isFinite(totalBytes)) next.totalBytes = totalBytes;
|
|
|
|
if (incomingState === "complete") {
|
|
next.state = "complete";
|
|
next.progress = 100;
|
|
next.status = "Скачивание завершено";
|
|
next.error = null;
|
|
} else if (incomingState === "interrupted") {
|
|
next.state = "error";
|
|
next.status = "Загрузка прервана Chrome";
|
|
next.error = change.error && change.error.current || "DOWNLOAD_INTERRUPTED";
|
|
} else if (hasByteUpdate || hasProgressState) {
|
|
next.state = "saving";
|
|
if (next.totalBytes > 0 && next.bytesReceived >= 0) {
|
|
next.progress = Math.min(99, 98 + Math.round((next.bytesReceived / next.totalBytes) * 1));
|
|
} else {
|
|
next.progress = Math.max(98, Number(next.progress) || 0);
|
|
}
|
|
next.status = "Сохранение файла…";
|
|
} else {
|
|
return;
|
|
}
|
|
next.updatedAt = Date.now();
|
|
if (next.state === "complete" || next.state === "error") {
|
|
recordDiagnostic(next.state === "error" ? "error" : "info", "chrome.downloads", "terminal-state", {
|
|
jobId: next.id,
|
|
downloadId,
|
|
state: next.state,
|
|
error: next.error,
|
|
filename: next.filename,
|
|
bytesReceived: next.bytesReceived,
|
|
totalBytes: next.totalBytes
|
|
});
|
|
}
|
|
await persistJob(next);
|
|
if (next.state === "complete") await rememberDownloaded(next);
|
|
await notifyJob(next);
|
|
}
|
|
|
|
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
|
if (message && message.target === "offscreen") return false;
|
|
(async () => {
|
|
switch (message && message.action) {
|
|
case "resolve":
|
|
return { ok: true, media: await resolveMedia(message.url, message.tabId) };
|
|
case "download":
|
|
return {
|
|
ok: true,
|
|
result: await startDownload(Object.assign({}, message, {
|
|
tabId: Number.isInteger(message.tabId)
|
|
? message.tabId
|
|
: (sender && sender.tab && Number.isInteger(sender.tab.id) ? sender.tab.id : null)
|
|
}))
|
|
};
|
|
case "downloadCapturedItem":
|
|
return { ok: true, result: await downloadCapturedItem(message.item, sender && sender.tab ? sender.tab.id : null) };
|
|
case "getJob":
|
|
return { ok: true, job: await readJob(message.jobId) };
|
|
case "getDownloadedMedia":
|
|
return { ok: true, downloaded: await getDownloadedRecords(message.ids) };
|
|
case "diagnosticEvent":
|
|
recordDiagnostic(message.level, message.area, message.event, Object.assign({}, message.details || {}, {
|
|
tabId: sender && sender.tab && Number.isInteger(sender.tab.id) ? sender.tab.id : null,
|
|
source: sender && sender.tab ? "content" : "extension-page"
|
|
}));
|
|
return { ok: true, ignored: !diagnosticsEnabled };
|
|
case "getDiagnosticLog":
|
|
if (!diagnosticsEnabled) return { ok: false, error: "Включите диагностику в настройках расширения." };
|
|
return { ok: true, diagnostics: await diagnosticRuntimeBundle() };
|
|
case "downloadDiagnosticReport":
|
|
if (!diagnosticsEnabled) return { ok: false, error: "Включите диагностику в настройках расширения." };
|
|
return { ok: true, result: await downloadDiagnosticReport(message.report) };
|
|
case "hlsProgress":
|
|
return { ok: true, job: await updateJob(message) };
|
|
case "downloadBlobUrl": {
|
|
const prefix = "blob:chrome-extension://" + chrome.runtime.id + "/";
|
|
if (!String(message.blobUrl || "").startsWith(prefix)) {
|
|
throw new Error("Отклонён недоверенный Blob URL.");
|
|
}
|
|
const downloadId = await chrome.downloads.download({
|
|
url: message.blobUrl,
|
|
filename: message.filename,
|
|
saveAs: Boolean(message.saveAs)
|
|
});
|
|
return { ok: true, downloadId };
|
|
}
|
|
default:
|
|
return { ok: false, error: "Unknown action" };
|
|
}
|
|
})().then(sendResponse).catch((error) => {
|
|
recordDiagnostic("error", "runtime.message", "action-failed", {
|
|
action: message && message.action,
|
|
error,
|
|
tabId: sender && sender.tab && Number.isInteger(sender.tab.id) ? sender.tab.id : null
|
|
});
|
|
sendResponse({ ok: false, error: error && error.message ? error.message : String(error) });
|
|
});
|
|
return true;
|
|
});
|
|
|
|
chrome.downloads.onChanged.addListener((delta) => {
|
|
if (!delta || !Number.isInteger(delta.id)) return;
|
|
applyDownloadState(delta.id, delta).catch((error) => {
|
|
recordDiagnostic("error", "chrome.downloads", "state-handler-failed", {
|
|
downloadId: delta.id,
|
|
error
|
|
});
|
|
});
|
|
});
|
|
|
|
recordDiagnostic("info", "runtime", "service-worker-active", {
|
|
extensionVersion: chrome.runtime.getManifest ? chrome.runtime.getManifest().version : null
|
|
});
|