360 lines
21 KiB
JavaScript
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(() => {});
|
|
});
|