Files
YouTubeDL/extension/background.js
T
DepressedCat 5f2aad00f4
Check extension and helper / check (push) Canceled after 0s
Publish YouTubeDL 1.4.5 and website on Gitea
2026-10-10 23:53:17 +03:00

360 lines
21 KiB
JavaScript

import { HOST_NAME, canonicalUrl, progressText, downloadSizeText, processingText, preferenceOptions, errorText, stabilizeDownloadSpeed, checkHelperCompatibility } from "./shared.js";
import { readSession, SESSION_PERMISSION } from "./session.js";
let nativePort = null;
const pending = new Map();
const clients = new Set();
const jobContexts = new Map();
let jobs = {};
let helper = { status: "idle", message: "Помощник ещё не подключён.", hardware: null };
let helperStartTask = null;
const notificationTimes = new Map();
const inspectionCache = new Map();
const ready = chrome.storage.local.get(["jobs"]).then(saved => {
jobs = saved.jobs || {};
for (const [id, job] of Object.entries(jobs)) {
jobContexts.set(id, { tabId: job.tabId, title: job.title });
if (job.status === "running") {
jobs[id] = { ...job, status: "error", stage: "error", error: "Браузер или помощник был закрыт. Запустите загрузку снова." };
}
}
});
function broadcast(message) {
for (const port of clients) {
try { port.postMessage(message); } catch { clients.delete(port); }
}
}
function helperState(status, message, hardware = null, compatibility = null) {
helper = { status, message, hardware, compatibility, checkedAt: Date.now() };
broadcast({ event: "helper", helper });
return helper;
}
function saveJobs() {
const recent = Object.values(jobs).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)).slice(0, 30);
jobs = Object.fromEntries(recent.map(job => [job.id, job]));
chrome.storage.local.set({ jobs: Object.fromEntries(recent.filter(job => !job.transient).map(job => [job.id, job])) });
}
function disconnect(error, status = "error") {
inspectionCache.clear();
nativePort = null;
helperState(status, error);
for (const [id, request] of pending) {
clearTimeout(request.timeout);
request.reject(new Error(error));
pending.delete(id);
}
for (const job of Object.values(jobs)) {
if (job.status === "running") onJob({ ...job, status: "error", stage: "error", error });
}
}
function connectHost() {
if (nativePort) return nativePort;
helperState("checking", "Запускаю и проверяю локальный помощник…");
nativePort = chrome.runtime.connectNative(HOST_NAME);
const port = nativePort;
port.onMessage.addListener(message => {
if (message.event === "job") { onJob(message.job); return; }
const request = pending.get(message.id);
if (!request) return;
pending.delete(message.id);
clearTimeout(request.timeout);
message.ok ? request.resolve(message.result) : request.reject(new Error(errorText(message.error || "Ошибка помощника")));
});
port.onDisconnect.addListener(() => {
const detail = chrome.runtime.lastError?.message;
if (nativePort === port) {
const missing = /host.*not found|not.*registered|cannot find.*host/i.test(detail || "");
disconnect(missing ? "Помощник не установлен. Скачайте и запустите установщик, затем нажмите «Запустить»."
: detail ? `Не удалось подключиться к помощнику: ${detail}` : "Помощник отключён. Нажмите «Запустить».", missing ? "missing" : "offline");
}
});
return port;
}
function nativeRequest(action, payload = {}) {
return new Promise((resolve, reject) => {
const id = crypto.randomUUID();
const timeout = setTimeout(() => {
pending.delete(id);
reject(new Error(action === "pick_folder" ? "Диалог выбора папки закрыт по тайм-ауту." : "Помощник отвечает слишком долго. Попробуйте снова."));
}, action === "ping" ? 4000 : action === "pick_folder" ? 600_000 : action === "inspect" ? 150_000 : 90_000);
pending.set(id, { resolve, reject, timeout });
let port;
try { port = connectHost(); port.postMessage({ id, action, payload }); }
catch (error) {
clearTimeout(timeout); pending.delete(id);
if (/disconnected port/i.test(error.message)) {
const failure = new Error("Связь с помощником прервана. Подключаюсь заново…");
failure.code = "NATIVE_PORT_DISCONNECTED";
if (nativePort === port) {
disconnect(failure.message, "offline");
try { port.disconnect(); } catch { /* Already disconnected. */ }
}
reject(failure);
} else reject(error);
}
});
}
function startHelper() {
if (!helperStartTask) helperStartTask = startHelperNow().finally(() => { helperStartTask = null; });
return helperStartTask;
}
async function startHelperNow() {
if (nativePort && helper.status !== "running" && !Object.values(jobs).some(job => job.status === "running")) {
const stale = nativePort; nativePort = null;
try { stale.disconnect(); } catch { /* Already disconnected. */ }
}
try {
for (let attempt = 0; attempt < 2; attempt++) {
try {
const hardware = await nativeRequest("hello");
const ping = await nativeRequest("ping");
if (!ping.alive) throw new Error("Помощник не подтвердил связь.");
const compatibility = checkHelperCompatibility(hardware, chrome.runtime.getManifest().version);
if (!compatibility.compatible) {
helperState("incompatible", compatibility.message, hardware, compatibility);
throw new Error(compatibility.message);
}
helperState("running", "Помощник отвечает. Загрузка и обработка доступны.", hardware, compatibility);
return hardware;
} catch (error) {
if (attempt === 0 && error.code === "NATIVE_PORT_DISCONNECTED") continue;
throw error;
}
}
} catch (error) {
if (helper.status === "checking" || helper.status === "running") helperState("error", error.message);
throw error;
}
}
async function checkHelper() {
if (helperStartTask) {
try { await helperStartTask; } catch { /* The connection publishes its status. */ }
return helper;
}
if (nativePort && helper.status === "running") {
try {
const ping = await nativeRequest("ping");
if (!ping.alive) throw new Error("Помощник не подтвердил связь.");
helperState("running", "Помощник отвечает. Загрузка и обработка доступны.", helper.hardware, helper.compatibility);
} catch (error) { if (helper.status === "running") helperState("error", error.message); }
}
if (!nativePort || helper.status !== "running") {
try { await startHelper(); } catch { /* Missing/offline status is returned below. */ }
}
return helper;
}
async function notify(job) {
const previous = notificationTimes.get(job.id) || 0;
if (job.status === "running" && Date.now() - previous < 1500) return;
notificationTimes.set(job.id, Date.now());
const id = `ytdl-${job.id}`;
try {
if (job.status === "running") {
const options = { type: "progress", iconUrl: "icons/128.png", title: job.title || "YouTubeDL",
message: [progressText(job), downloadSizeText(job) || processingText(job)].filter(Boolean).join("\n"), progress: Number.isFinite(job.percent) ? Math.round(job.percent) : 0,
silent: true };
if (!await chrome.notifications.update(id, options)) await chrome.notifications.create(id, options);
} else {
await chrome.notifications.clear(id);
if (job.status !== "cancelled") await chrome.notifications.create(id, {
type: "basic", iconUrl: "icons/128.png", title: job.status === "complete" ? job.kind === "update" ? "Обновление компонентов" : job.kind === "dependency" ? "Установка завершена" : "Файл сохранён" : "Ошибка",
message: job.result?.filename || job.result?.title || job.error || "Готово", silent: job.status !== "error",
...(job.status === "complete" && job.result?.path ? { buttons: [{ title: "Показать файл" }] } : {}),
});
}
} catch { /* The on-page toast remains available if OS notifications are disabled. */ }
}
async function onJob(update) {
await ready;
if (update.error) update = { ...update, error: errorText(update.error) };
const context = jobContexts.get(update.id) || {};
const job = stabilizeDownloadSpeed({ ...jobs[update.id], ...update, speed: update.speed,
title: update.result?.title || context.title || update.title || jobs[update.id]?.title,
tabId: context.tabId ?? jobs[update.id]?.tabId }, jobs[update.id]);
jobs[job.id] = job;
broadcast({ event: "job", job });
if (Number.isInteger(job.tabId)) chrome.tabs.sendMessage(job.tabId, { type: "JOB_PROGRESS", job }).catch(() => {});
if (job.status !== "running" || !job.savedAt || Date.now() - job.savedAt > 1000) {
job.savedAt = Date.now(); saveJobs();
}
notify(job);
if (job.kind === "dependency" && job.status !== "running" && nativePort) startHelper().catch(() => {});
if (job.kind === "update" && job.status === "complete" && job.result?.restartRequired && nativePort) {
const previous = nativePort;
disconnect("Перезапускаю помощник с обновлёнными компонентами…", "checking");
previous.disconnect();
startHelper().catch(() => {});
}
chrome.action.setBadgeText({ text: Object.values(jobs).some(item => item.status === "running") ? "↓" : "" });
chrome.action.setBadgeBackgroundColor({ color: "#20a9de" });
}
async function handleUi(action, payload) {
await ready;
if (action === "session_status") return readSession(Number.isInteger(payload.tabId) ? payload.tabId : null);
if (action === "session_enable") {
if (payload.enabled && !await chrome.permissions.contains(SESSION_PERMISSION)) throw new Error("Разрешите использовать вход в YouTube в окне браузера.");
await chrome.storage.local.set({ youtubeSession: payload.enabled === true });
inspectionCache.clear();
if (!payload.enabled) await chrome.permissions.remove(SESSION_PERMISSION);
return readSession(Number.isInteger(payload.tabId) ? payload.tabId : null);
}
if (action === "state") return { jobs: Object.values(jobs), helper };
if (action === "open_history") {
const theme = ["light", "dark"].includes(payload.theme) ? payload.theme : "auto";
await chrome.tabs.create({ url: chrome.runtime.getURL(`history.html?theme=${theme}`) });
return { opened: true };
}
if (action === "helper_status") return checkHelper();
if (action === "hello" || action === "helper_start") return startHelper();
if (action === "update_settings") {
const settings = await nativeRequest(action, { enabled: payload.enabled });
if (helper.hardware) helper.hardware.updateSettings = settings;
helperState(helper.status, helper.message, helper.hardware, helper.compatibility);
return settings;
}
if (action === "helper_remove") {
if (!nativePort || helper.status !== "running") throw new Error("Сначала подключите помощник.");
if (Object.values(jobs).some(job => job.status === "running")) throw new Error("Дождитесь завершения загрузок перед удалением помощника.");
const result = await nativeRequest("helper_uninstall");
const old = nativePort;
disconnect("Помощник удалён. Для установки скачайте и запустите установщик.", "missing");
old?.disconnect();
return result;
}
if (!["hello", "inspect", "pick_folder", "download", "dependency_install", "helper_update", "cancel", "reveal", "jobs"].includes(action)) throw new Error("Неизвестная операция");
if (["inspect", "download"].includes(action) && !canonicalUrl(payload.url)) throw new Error("Нужна ссылка на видео YouTube");
if (["inspect", "download", "dependency_install", "helper_update"].includes(action) && (!nativePort || helper.status !== "running")) await startHelper();
if (["inspect", "download"].includes(action)) payload = await mediaPayload(payload);
if (action === "inspect") return inspectVideo(payload);
if (action === "download" && payload.workFolder) {
if (!nativePort || helper.status !== "running") await startHelper();
if (helper.hardware?.supportsWorkFolder !== true) throw new Error("Для отдельной рабочей папки установите помощник 1.3.1 или новее.");
}
if (["download", "dependency_install", "helper_update"].includes(action)) {
const title = String(payload.title || (action === "helper_update" ? "Обновление компонентов" : "Видео")).slice(0, 300);
const tabId = Number.isInteger(payload.tabId) ? payload.tabId : null;
const result = await nativeRequest(action, payload);
jobContexts.set(result.jobId, { title, tabId });
// Worker events may arrive immediately after the response, before this microtask.
onJob({ ...jobs[result.jobId], id: result.jobId, title, tabId, url: payload.url,
status: jobs[result.jobId]?.status || "running", stage: jobs[result.jobId]?.stage || "preparing",
kind: action === "helper_update" ? "update" : action === "dependency_install" ? "dependency" : "download",
...(action === "helper_update" ? { canCancel: false } : {}),
createdAt: jobs[result.jobId]?.createdAt || Date.now() / 1000 });
return result;
}
return nativeRequest(action, payload);
}
async function mediaPayload(payload) {
// Ignore cookies supplied by callers; only the browser API is a session source.
const { browserCookies: ignoredCookies, ...options } = payload;
const session = await readSession(Number.isInteger(payload.tabId) ? payload.tabId : null, true);
if (session.status === "connected" && !helper.hardware?.supportsBrowserCookies) {
throw new Error("Обновите помощник для подключения входа: нажмите «Установить», запустите скачанный установщик и обновите расширение.");
}
return { ...options, ...(session.status === "connected" ? { browserCookies: session.cookies } : {}) };
}
async function inspectVideo(payload) {
// Only the summary is cached; URLs for downloading are refreshed by the worker.
const fingerprint = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(payload.browserCookies || [])));
const key = `${canonicalUrl(payload.url)}|${payload.tabId || ""}|${[...new Uint8Array(fingerprint)].map(byte => byte.toString(16).padStart(2, "0")).join("")}`;
const cached = inspectionCache.get(key);
if (cached && cached.expires > Date.now() && !payload.refresh) return cached.task;
const entry = { expires: Date.now() + 45000, task: nativeRequest("inspect", payload) };
inspectionCache.delete(key); inspectionCache.set(key, entry);
if (inspectionCache.size > 20) inspectionCache.delete(inspectionCache.keys().next().value);
try { return await entry.task; }
catch (error) { if (inspectionCache.get(key) === entry) inspectionCache.delete(key); throw error; }
}
async function quickDownload(url, tabId) {
url = canonicalUrl(url);
if (!url) throw new Error("Нужна ссылка на видео YouTube.");
const { preferences = {} } = await chrome.storage.local.get("preferences");
if (!preferences.folder) return { needSetup: true, error: "Сначала выберите папку и сохраните настройки скачивания." };
if (Object.values(jobs).filter(job => job.status === "running").length >= 2) {
return { needSetup: true, error: "Уже выполняются две операции. Дождитесь завершения одной." };
}
const temporaryId = `preparing-${crypto.randomUUID()}`;
jobContexts.set(temporaryId, { tabId, title: "Подготовка скачивания" });
await onJob({ id: temporaryId, url, status: "running", stage: "preparing", percent: null, canCancel: false, transient: true, createdAt: Date.now() / 1000 });
try {
await startHelper();
if (helper.hardware?.ready === false) throw new Error("Установите недостающие компоненты через настройки скачивания.");
const metadata = await handleUi("inspect", { url, tabId });
const result = await handleUi("download", { ...preferenceOptions(metadata, preferences), url, title: metadata.title, tabId });
delete jobs[temporaryId]; jobContexts.delete(temporaryId);
broadcast({ event: "remove_job", jobId: temporaryId });
chrome.tabs.sendMessage(tabId, { type: "REMOVE_JOB", jobId: temporaryId }).catch(() => {});
chrome.notifications.clear(`ytdl-${temporaryId}`).catch(() => {});
saveJobs();
return { ok: true, jobId: result.jobId };
} catch (error) {
await onJob({ id: temporaryId, status: "error", stage: "error", error: error.message, transient: false });
return { ok: false, needSetup: helper.status !== "running" || /папк|компонент|вход|авторизац|бот|помощник|проверку/i.test(error.message), error: error.message };
}
}
chrome.runtime.onConnect.addListener(port => {
const pageUrl = port.sender?.url?.split(/[?#]/)[0];
if (port.name !== "ytdl-ui" || port.sender?.id !== chrome.runtime.id ||
!["downloader.html", "history.html"].some(page => pageUrl === chrome.runtime.getURL(page))) return;
clients.add(port);
ready.then(() => port.postMessage({ event: "state", jobs: Object.values(jobs), helper })).catch(() => {});
port.onDisconnect.addListener(() => clients.delete(port));
port.onMessage.addListener(async message => {
try { port.postMessage({ id: message.id, ok: true, result: await handleUi(message.action, message.payload || {}) }); }
catch (error) {
// Save failures before a worker was created; worker failures arrive through onJob.
const titles = { inspect: "Получение качества видео", download: "Подготовка скачивания", dependency_install: "Установка компонента", helper_update: "Обновление компонентов" };
if (titles[message.action]) {
const payload = message.payload || {};
await onJob({ id: `error-${crypto.randomUUID()}`, title: String(payload.title || titles[message.action]).slice(0, 300),
url: canonicalUrl(payload.url), status: "error", stage: "error", error: error.message,
kind: message.action === "inspect" ? "inspection" : message.action === "helper_update" ? "update" : message.action === "dependency_install" ? "dependency" : "download",
createdAt: Date.now() / 1000 }).catch(() => {});
}
try { port.postMessage({ id: message.id, ok: false, error: error.message }); } catch {}
}
});
});
async function openWindow(url, tabId, theme = "auto") {
const query = new URLSearchParams({ url: canonicalUrl(url) || "", tabId: String(tabId || ""), theme });
await chrome.windows.create({ url: chrome.runtime.getURL(`downloader.html?${query}`), type: "popup", width: 520, height: 820 });
}
chrome.action.onClicked.addListener(tab => openWindow(tab.url, tab.id));
chrome.runtime.onMessage.addListener((message, sender, respond) => {
if (message.type === "CLOSE_MENU" && sender.id === chrome.runtime.id && sender.tab &&
sender.url?.startsWith(chrome.runtime.getURL("downloader.html"))) {
chrome.tabs.sendMessage(sender.tab.id, { type: "CLOSE_MENU" })
.then(() => respond({ ok: true }), () => respond({ ok: true }));
return true;
}
let isYouTube = false;
try { const origin = new URL(sender.url); isYouTube = origin.protocol === "https:" && ["www.youtube.com", "m.youtube.com"].includes(origin.hostname); } catch {}
if (sender.id !== chrome.runtime.id || !sender.tab || !isYouTube) return false;
const tabId = sender.tab.id;
if (message.type === "QUICK_DOWNLOAD") {
quickDownload(message.url, tabId).then(respond, error => respond({ needSetup: true, error: error.message }));
return true;
}
if (message.type === "GET_CONTEXT") { respond({ tabId, jobs: Object.values(jobs).filter(job => job.tabId === tabId) }); return false; }
if (message.type === "OPEN_WINDOW") {
openWindow(sender.url, tabId).then(() => respond({ ok: true }), error => respond({ ok: false, error: error.message })); return true;
}
if (["CANCEL_JOB", "REVEAL_JOB"].includes(message.type) && jobs[message.jobId]?.tabId === tabId) {
nativeRequest(message.type === "CANCEL_JOB" ? "cancel" : "reveal", { jobId: message.jobId })
.then(result => respond({ ok: true, result }), error => respond({ ok: false, error: error.message }));
return true;
}
return false;
});
chrome.notifications.onButtonClicked.addListener((id, index) => {
if (index === 0 && id.startsWith("ytdl-")) nativeRequest("reveal", { jobId: id.slice(5) }).catch(() => {});
});