"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("]+(?:property|name)=[\"']" + escaped + "[\"'][^>]+content=[\"']([^\"']+)[\"']", "i"); const second = new RegExp("]+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 });