Skip to content

Instantly share code, notes, and snippets.

@kaiser62
Created July 24, 2026 22:07
Show Gist options
  • Select an option

  • Save kaiser62/7ce7f7ff20a7cf2e29c9cab77699dd9d to your computer and use it in GitHub Desktop.

Select an option

Save kaiser62/7ce7f7ff20a7cf2e29c9cab77699dd9d to your computer and use it in GitHub Desktop.
TorBox Magnet Helper - send magnet links to TorBox with one click
// ==UserScript==
// @name TorBox Magnet Helper
// @namespace https://github.com/torbox-magnet-helper
// @version 2.1.0
// @description Detect magnet links (and raw BTIH hashes) on any site and send them to TorBox with one click. Cache checks, duplicate detection, live status polling.
// @author TorBox Magnet Helper contributors
// @license MIT
// @match *://*/*
// @run-at document-idle
// @noframes
// @connect api.torbox.app
// @grant GM_xmlhttpRequest
// @grant GM.xmlHttpRequest
// @grant GM_getValue
// @grant GM.getValue
// @grant GM_setValue
// @grant GM.setValue
// @grant GM_registerMenuCommand
// @grant GM.registerMenuCommand
// @homepageURL https://github.com/torbox-magnet-helper
// @supportURL https://github.com/torbox-magnet-helper/issues
// ==/UserScript==
/*
* TorBox Magnet Helper
* ====================
* A single-file Tampermonkey/Violentmonkey userscript that behaves like a
* lightweight browser extension. It is organised as a set of small, single-
* responsibility modules wrapped in one IIFE:
*
* Config – every tunable value and endpoint, centralised.
* Logger – leveled, opt-in debug logging (never logs secrets).
* Storage – async wrapper over GM storage (TM + VM compatible).
* Settings – typed, persistent user preferences.
* Utils – pure helpers: magnet/hash parsing, base32, debounce…
* Api – all TorBox HTTP traffic; no DOM, no UI.
* ToastManager – accessible, stackable, queued notifications.
* ButtonManager – builds/updates the per-link TorBox button + states.
* Scanner – finds magnet links & hashes in a given root node.
* MutationManager – one efficient observer feeding the Scanner.
* Main – wiring, menu commands, bootstrap.
*
* No external dependencies, no frameworks, no network resources beyond the
* TorBox API. Everything is namespaced and injected exactly once.
*/
'use strict';
(function torboxMagnetHelper() {
/* ======================================================================
* Config β€” the single source of truth. Change behaviour here, not inline.
* ==================================================================== */
const Config = Object.freeze({
/** Storage keys (kept short; namespaced with a stable prefix). */
KEYS: Object.freeze({
API_KEY: 'tb_api_key',
SETTINGS: 'tb_settings',
}),
/** TorBox REST API. Every URL is derived from these β€” never hardcode. */
API: Object.freeze({
BASE: 'https://api.torbox.app',
VERSION: 'v1',
ENDPOINTS: Object.freeze({
CREATE_TORRENT: '/api/torrents/createtorrent',
CHECK_CACHED: '/api/torrents/checkcached',
MY_LIST: '/api/torrents/mylist',
USER_ME: '/api/user/me',
}),
DASHBOARD_URL: 'https://torbox.app/subscription',
}),
/** Network behaviour. */
NET: Object.freeze({
TIMEOUT_MS: 20000,
RETRIES: 2,
RETRY_BASE_DELAY_MS: 800,
}),
/** Polling behaviour for torrent status. */
POLL: Object.freeze({
INTERVAL_MS: 4000,
MAX_ATTEMPTS: 60, // ~4 minutes ceiling, then stop gracefully
}),
/** DOM markers & prefixes. */
DOM: Object.freeze({
PROCESSED_ATTR: 'data-tb-helper',
BUTTON_CLASS: 'tb-btn',
CLASS_PREFIX: 'tb-',
STYLE_ID: 'tb-styles',
}),
/** Button visual states β†’ glyph + accessible label + tooltip. */
STATES: Object.freeze({
IDLE: { icon: 'πŸ“¦', label: 'Send to TorBox', tone: 'idle' },
CHECKING: { icon: 'πŸ”', label: 'Checking cache…', tone: 'busy' },
LOADING: { icon: '⏳', label: 'Sending to TorBox…', tone: 'busy' },
CACHED: { icon: '⚑', label: 'Cached on TorBox β€” click to add', tone: 'cached' },
NOT_CACHED: { icon: 'πŸ“¦', label: 'Not cached β€” click to add', tone: 'idle' },
SUCCESS: { icon: 'βœ…', label: 'Added to TorBox', tone: 'ok' },
EXISTS: { icon: 'βœ”', label: 'Already in your TorBox', tone: 'ok' },
FAILURE: { icon: '❌', label: 'Failed β€” click to retry', tone: 'err' },
}),
/** Toast tuning. */
TOAST: Object.freeze({
MAX_VISIBLE: 4,
DEFAULT_DURATION_MS: 4000,
}),
/** Scanner tuning. */
SCAN: Object.freeze({
DEBOUNCE_MS: 150,
BATCH_ATTR_QUERY: 'a[href^="magnet:"]',
}),
/** Default user settings (see Settings for the typed schema). */
DEFAULTS: Object.freeze({
autoCacheCheck: false,
cachedOnlyMode: false,
openDashboardAfterAdd: false,
pollingEnabled: true,
detectHashes: true,
seedPreference: 1, // 1 auto, 2 always, 3 never
allowZip: false,
toastDurationMs: 4000,
buttonSize: 22,
iconStyle: 'emoji', // 'emoji' | 'svg'
enableAnimations: true,
debugLogging: false,
showFloatingMenu: true, // in-page menu for mobile (no manager GUI)
}),
LOG_PREFIX: '[TorBox]',
});
/* ======================================================================
* Logger β€” opt-in, leveled, never prints the API key.
* ==================================================================== */
class Logger {
constructor() {
this._enabled = false;
}
setEnabled(enabled) {
this._enabled = Boolean(enabled);
}
/** @param {...unknown} args */
info(...args) {
if (this._enabled) console.info(Config.LOG_PREFIX, ...args);
}
warn(...args) {
// Warnings are always surfaced β€” they indicate recoverable problems.
console.warn(Config.LOG_PREFIX, ...args);
}
error(...args) {
// Errors are always surfaced.
console.error(Config.LOG_PREFIX, ...args);
}
debug(...args) {
if (this._enabled) console.debug(Config.LOG_PREFIX, ...args);
}
}
const logger = new Logger();
/* ======================================================================
* Storage β€” async GM storage that works on both Tampermonkey (GM_*) and
* Violentmonkey (GM.*). All methods return promises.
* ==================================================================== */
const Storage = {
/**
* @param {string} key
* @param {*} fallback
* @returns {Promise<*>}
*/
async get(key, fallback) {
try {
if (typeof GM !== 'undefined' && GM && typeof GM.getValue === 'function') {
return await GM.getValue(key, fallback);
}
if (typeof GM_getValue === 'function') {
return GM_getValue(key, fallback);
}
} catch (err) {
logger.error('Storage.get failed', err);
}
return fallback;
},
/**
* @param {string} key
* @param {*} value
* @returns {Promise<void>}
*/
async set(key, value) {
try {
if (typeof GM !== 'undefined' && GM && typeof GM.setValue === 'function') {
await GM.setValue(key, value);
return;
}
if (typeof GM_setValue === 'function') {
GM_setValue(key, value);
return;
}
} catch (err) {
logger.error('Storage.set failed', err);
}
},
};
/**
* Register a menu command across TM (GM_registerMenuCommand) and VM (GM.*).
* @param {string} label
* @param {() => void} fn
*/
function registerMenu(label, fn) {
try {
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand(label, fn);
} else if (typeof GM !== 'undefined' && GM && typeof GM.registerMenuCommand === 'function') {
GM.registerMenuCommand(label, fn);
}
} catch (err) {
logger.warn('registerMenu failed for', label, err);
}
}
/* ======================================================================
* Utils β€” pure, dependency-free helpers.
* ==================================================================== */
const Utils = {
/** RFC 4648 Base32 alphabet (used by 32-char BTIH magnets). */
_BASE32: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567',
/**
* Decode an RFC 4648 Base32 string into a lowercase hex string.
* Used to normalise 32-char BTIH hashes into the 40-char hex form the
* TorBox cache endpoint expects.
* @param {string} input
* @returns {string|null} 40-char hex, or null if invalid.
*/
base32ToHex(input) {
const clean = String(input).toUpperCase().replace(/=+$/, '');
let bits = 0;
let value = 0;
let hex = '';
for (const char of clean) {
const idx = Utils._BASE32.indexOf(char);
if (idx === -1) return null;
value = (value << 5) | idx;
bits += 5;
if (bits >= 8) {
bits -= 8;
hex += ((value >>> bits) & 0xff).toString(16).padStart(2, '0');
}
}
return hex.length === 40 ? hex : null;
},
/**
* Extract a normalised lowercase-hex BTIH info hash from a magnet URI.
* @param {string} magnet
* @returns {string|null}
*/
hashFromMagnet(magnet) {
if (typeof magnet !== 'string') return null;
const match = magnet.match(/xt=urn:btih:([a-z0-9]+)/i);
if (!match) return null;
return Utils.normaliseHash(match[1]);
},
/**
* Normalise a raw BTIH token (40-hex or 32-base32) into 40-char hex.
* @param {string} raw
* @returns {string|null}
*/
normaliseHash(raw) {
const token = String(raw).trim();
if (/^[a-f0-9]{40}$/i.test(token)) return token.toLowerCase();
if (/^[a-z2-7]{32}$/i.test(token)) return Utils.base32ToHex(token);
return null;
},
/**
* Validate a magnet URI is well-formed and contains a usable BTIH.
* @param {string} magnet
* @returns {boolean}
*/
isValidMagnet(magnet) {
return typeof magnet === 'string'
&& magnet.startsWith('magnet:')
&& Utils.hashFromMagnet(magnet) !== null;
},
/**
* Best-effort display name from a magnet's dn= parameter.
* @param {string} magnet
* @returns {string}
*/
nameFromMagnet(magnet) {
const match = /[?&]dn=([^&]+)/.exec(magnet);
if (!match) return '';
try {
return decodeURIComponent(match[1].replace(/\+/g, ' ')).slice(0, 200);
} catch {
return '';
}
},
/**
* Build a canonical magnet URI from a bare info hash.
* @param {string} hexHash 40-char hex
* @returns {string}
*/
magnetFromHash(hexHash) {
return `magnet:?xt=urn:btih:${hexHash}`;
},
/**
* Trailing-edge debounce.
* @template {(...args: any[]) => void} F
* @param {F} fn
* @param {number} wait
* @returns {F}
*/
debounce(fn, wait) {
let timer = null;
return /** @type {F} */ (function debounced(...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
fn.apply(this, args);
}, wait);
});
},
/**
* Run work when the browser is idle, falling back to a timeout.
* @param {() => void} fn
*/
onIdle(fn) {
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(() => fn(), { timeout: 500 });
} else {
setTimeout(fn, 16);
}
},
/** @param {number} ms @returns {Promise<void>} */
sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
},
};
/* ======================================================================
* Settings β€” typed, persistent preferences with change notification.
* ==================================================================== */
class Settings {
constructor() {
/** @type {typeof Config.DEFAULTS} */
this._values = { ...Config.DEFAULTS };
/** @type {Set<(s: Settings) => void>} */
this._listeners = new Set();
}
async load() {
const stored = await Storage.get(Config.KEYS.SETTINGS, null);
if (stored && typeof stored === 'object') {
// Merge so newly-added defaults survive upgrades of older configs.
this._values = { ...Config.DEFAULTS, ...stored };
}
logger.setEnabled(this._values.debugLogging);
return this._values;
}
/** @template {keyof typeof Config.DEFAULTS} K @param {K} key */
get(key) {
return this._values[key];
}
all() {
return { ...this._values };
}
/**
* @param {Partial<typeof Config.DEFAULTS>} patch
*/
async update(patch) {
this._values = { ...this._values, ...patch };
logger.setEnabled(this._values.debugLogging);
await Storage.set(Config.KEYS.SETTINGS, this._values);
this._listeners.forEach((fn) => {
try {
fn(this);
} catch (err) {
logger.error('settings listener failed', err);
}
});
}
/** @param {(s: Settings) => void} fn */
onChange(fn) {
this._listeners.add(fn);
}
/** Export current settings (excludes the API key by design). */
export() {
return JSON.stringify(this._values, null, 2);
}
/** @param {string} json @returns {Promise<boolean>} */
async import(json) {
try {
const parsed = JSON.parse(json);
if (!parsed || typeof parsed !== 'object') return false;
const clean = {};
for (const key of Object.keys(Config.DEFAULTS)) {
if (key in parsed) clean[key] = parsed[key];
}
await this.update(clean);
return true;
} catch (err) {
logger.error('settings import failed', err);
return false;
}
}
}
/* ======================================================================
* ApiError β€” typed error carrying an HTTP status for precise handling.
* ==================================================================== */
class ApiError extends Error {
/** @param {string} message @param {number} status @param {string} [code] */
constructor(message, status, code) {
super(message);
this.name = 'ApiError';
this.status = status;
this.code = code || '';
}
}
/* ======================================================================
* Api β€” all TorBox traffic. No DOM. No UI. Retries transient failures.
* ==================================================================== */
class Api {
/** @param {() => Promise<string>} apiKeyProvider */
constructor(apiKeyProvider) {
this._getApiKey = apiKeyProvider;
/** @type {Array<{id:number,hash:string,name:string}>|null} */
this._listCache = null;
this._listCacheAt = 0;
}
/** @param {string} endpoint @returns {string} */
_url(endpoint) {
return `${Config.API.BASE}/${Config.API.VERSION}${endpoint}`;
}
/**
* Low-level GM_xmlhttpRequest wrapped in a promise, TM + VM compatible.
* @param {object} opts
* @returns {Promise<{status:number, responseText:string}>}
*/
_rawRequest(opts) {
const xhr = (typeof GM !== 'undefined' && GM && typeof GM.xmlHttpRequest === 'function')
? GM.xmlHttpRequest.bind(GM)
: (typeof GM_xmlhttpRequest === 'function' ? GM_xmlhttpRequest : null);
if (!xhr) {
return Promise.reject(new ApiError('GM_xmlhttpRequest is unavailable', 0, 'NO_GM_XHR'));
}
return new Promise((resolve, reject) => {
let settled = false;
const finish = (fn, arg) => {
if (settled) return;
settled = true;
fn(arg);
};
xhr({
method: opts.method,
url: opts.url,
headers: opts.headers,
data: opts.data,
timeout: Config.NET.TIMEOUT_MS,
onload: (res) => finish(resolve, { status: res.status, responseText: res.responseText }),
onerror: () => finish(reject, new ApiError('Network error', 0, 'NETWORK')),
ontimeout: () => finish(reject, new ApiError('Request timed out', 0, 'TIMEOUT')),
onabort: () => finish(reject, new ApiError('Request aborted', 0, 'ABORT')),
});
});
}
/**
* Authenticated JSON request with retry/backoff on transient failures.
* @param {object} opts { method, endpoint, query, form, auth }
* @returns {Promise<object>} parsed `data` payload
*/
async _request(opts) {
const { method = 'GET', endpoint, query, form, auth = true } = opts;
const headers = { Accept: 'application/json' };
if (auth) {
const key = await this._getApiKey();
if (!key) throw new ApiError('No API key configured', 401, 'NO_KEY');
headers.Authorization = `Bearer ${key}`;
}
let url = this._url(endpoint);
if (query) {
const qs = new URLSearchParams(query).toString();
if (qs) url += `?${qs}`;
}
let data;
if (form) {
// Build multipart form data for POSTs (createtorrent expects this).
const fd = new FormData();
for (const [k, v] of Object.entries(form)) {
if (v !== undefined && v !== null) fd.append(k, String(v));
}
data = fd;
}
let lastError = null;
for (let attempt = 0; attempt <= Config.NET.RETRIES; attempt += 1) {
try {
const res = await this._rawRequest({ method, url, headers, data });
return Api._parse(res);
} catch (err) {
lastError = err;
const status = err instanceof ApiError ? err.status : 0;
const transient = status === 0 || status === 429 || status >= 500;
if (!transient || attempt === Config.NET.RETRIES) break;
const delay = Config.NET.RETRY_BASE_DELAY_MS * 2 ** attempt;
logger.warn(`retry ${attempt + 1} after ${delay}ms (status ${status})`);
await Utils.sleep(delay);
}
}
throw lastError || new ApiError('Request failed', 0, 'UNKNOWN');
}
/**
* Parse a TorBox response envelope and map HTTP errors to ApiError.
* @param {{status:number, responseText:string}} res
* @returns {object}
*/
static _parse(res) {
let body = null;
try {
body = res.responseText ? JSON.parse(res.responseText) : null;
} catch {
body = null;
}
if (res.status >= 200 && res.status < 300) {
if (body && body.success === false) {
throw new ApiError(body.detail || 'TorBox reported failure', res.status, body.error || '');
}
return body ? (body.data !== undefined ? body.data : body) : {};
}
const detail = (body && (body.detail || body.error)) || Api._statusMessage(res.status);
throw new ApiError(detail, res.status, (body && body.error) || '');
}
/** @param {number} status @returns {string} */
static _statusMessage(status) {
switch (status) {
case 401: return 'Unauthorized β€” check your API key';
case 403: return 'Forbidden β€” your plan may not allow this';
case 404: return 'Not found';
case 429: return 'Rate limited β€” slow down';
case 500: return 'TorBox server error';
default: return `Request failed (HTTP ${status})`;
}
}
/** Verify the configured key. @returns {Promise<object>} user info */
async testConnection() {
return this._request({ method: 'GET', endpoint: Config.API.ENDPOINTS.USER_ME });
}
/**
* Check whether a hash is cached on TorBox.
* @param {string} hexHash
* @returns {Promise<boolean>}
*/
async isCached(hexHash) {
const data = await this._request({
method: 'GET',
endpoint: Config.API.ENDPOINTS.CHECK_CACHED,
query: { hash: hexHash, format: 'object', list_files: 'false' },
});
if (!data) return false;
// `object` format returns a map keyed by hash for cached items only.
if (Array.isArray(data)) return data.length > 0;
if (typeof data === 'object') {
return Boolean(data[hexHash]) || Object.keys(data).length > 0;
}
return false;
}
/**
* Fetch (and briefly cache) the user's torrent list.
* @param {boolean} [force]
* @returns {Promise<Array<object>>}
*/
async getList(force = false) {
const fresh = Date.now() - this._listCacheAt < 5000;
if (this._listCache && fresh && !force) return this._listCache;
const data = await this._request({
method: 'GET',
endpoint: Config.API.ENDPOINTS.MY_LIST,
query: { bypass_cache: force ? 'true' : 'false' },
});
this._listCache = Array.isArray(data) ? data : [];
this._listCacheAt = Date.now();
return this._listCache;
}
/**
* Find an existing torrent by info hash.
* @param {string} hexHash
* @returns {Promise<object|null>}
*/
async findExisting(hexHash) {
const list = await this.getList(false);
return list.find((t) => (t.hash || '').toLowerCase() === hexHash) || null;
}
/**
* Poll a single torrent's live status by id.
* @param {number|string} id
* @returns {Promise<object|null>}
*/
async getTorrent(id) {
const data = await this._request({
method: 'GET',
endpoint: Config.API.ENDPOINTS.MY_LIST,
query: { id: String(id), bypass_cache: 'true' },
});
if (Array.isArray(data)) return data[0] || null;
return data || null;
}
/**
* Submit a magnet to TorBox.
* @param {string} magnet
* @param {object} opts { seed, allowZip, name }
* @returns {Promise<object>} torrent data (torrent_id, hash, queued_id…)
*/
async createTorrent(magnet, opts) {
// Invalidate the list cache so duplicate checks reflect the new add.
this._listCache = null;
return this._request({
method: 'POST',
endpoint: Config.API.ENDPOINTS.CREATE_TORRENT,
form: {
magnet,
seed: opts.seed,
allow_zip: opts.allowZip ? 'true' : 'false',
name: opts.name || undefined,
},
});
}
}
/* ======================================================================
* ToastManager β€” accessible, stackable, queued notifications.
* ==================================================================== */
class ToastManager {
/** @param {Settings} settings */
constructor(settings) {
this._settings = settings;
/** @type {HTMLElement|null} */
this._root = null;
/** @type {Array<{message:string,type:string,duration:number}>} */
this._queue = [];
this._visible = 0;
}
_ensureRoot() {
if (this._root && document.body.contains(this._root)) return this._root;
const root = document.createElement('div');
root.className = `${Config.DOM.CLASS_PREFIX}toast-root`;
root.setAttribute('role', 'region');
root.setAttribute('aria-label', 'TorBox notifications');
root.setAttribute('aria-live', 'polite');
document.body.appendChild(root);
this._root = root;
return root;
}
/**
* @param {string} message
* @param {'success'|'error'|'warning'|'info'} [type]
* @param {number} [duration]
*/
show(message, type = 'info', duration) {
const dur = typeof duration === 'number'
? duration
: this._settings.get('toastDurationMs') || Config.TOAST.DEFAULT_DURATION_MS;
this._queue.push({ message, type, duration: dur });
this._drain();
}
_drain() {
while (this._visible < Config.TOAST.MAX_VISIBLE && this._queue.length > 0) {
const item = this._queue.shift();
this._render(item);
}
}
/** @param {{message:string,type:string,duration:number}} item */
_render(item) {
const root = this._ensureRoot();
const animate = this._settings.get('enableAnimations');
const toast = document.createElement('div');
toast.className = `${Config.DOM.CLASS_PREFIX}toast ${Config.DOM.CLASS_PREFIX}toast-${item.type}`;
if (!animate) toast.classList.add(`${Config.DOM.CLASS_PREFIX}toast-noanim`);
toast.setAttribute('role', item.type === 'error' ? 'alert' : 'status');
const text = document.createElement('span');
text.className = `${Config.DOM.CLASS_PREFIX}toast-text`;
text.textContent = item.message; // textContent β€” never innerHTML.
const close = document.createElement('button');
close.type = 'button';
close.className = `${Config.DOM.CLASS_PREFIX}toast-close`;
close.setAttribute('aria-label', 'Dismiss notification');
close.textContent = 'Γ—';
toast.appendChild(text);
toast.appendChild(close);
root.appendChild(toast);
this._visible += 1;
let removed = false;
const remove = () => {
if (removed) return;
removed = true;
clearTimeout(timer);
toast.classList.add(`${Config.DOM.CLASS_PREFIX}toast-out`);
const done = () => {
toast.remove();
this._visible -= 1;
this._drain();
};
if (animate) {
toast.addEventListener('animationend', done, { once: true });
setTimeout(done, 400); // safety net if animationend never fires
} else {
done();
}
};
close.addEventListener('click', remove);
const timer = setTimeout(remove, item.duration);
}
}
/* ======================================================================
* ButtonManager β€” builds one button per magnet/hash and drives its state
* machine through the add/cache/poll lifecycle.
* ==================================================================== */
class ButtonManager {
/**
* @param {Api} api
* @param {Settings} settings
* @param {ToastManager} toasts
*/
constructor(api, settings, toasts) {
this._api = api;
this._settings = settings;
this._toasts = toasts;
/** Track buttons already mid-flight to avoid double submits. */
this._busy = new WeakSet();
}
/**
* Build a TorBox button bound to a magnet URI.
* @param {string} magnet
* @returns {HTMLButtonElement}
*/
create(magnet) {
const size = Number(this._settings.get('buttonSize')) || 22;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = Config.DOM.BUTTON_CLASS;
btn.style.setProperty('--tb-size', `${size}px`);
btn.dataset.tbMagnet = magnet;
// Screen-reader-only text label (visual label is the glyph).
const sr = document.createElement('span');
sr.className = `${Config.DOM.CLASS_PREFIX}sr-only`;
btn.appendChild(sr);
const glyph = document.createElement('span');
glyph.className = `${Config.DOM.CLASS_PREFIX}glyph`;
glyph.setAttribute('aria-hidden', 'true');
btn.appendChild(glyph);
btn.addEventListener('click', (evt) => {
evt.preventDefault();
evt.stopPropagation();
this._onClick(btn);
});
this._setState(btn, 'IDLE');
if (this._settings.get('autoCacheCheck')) {
Utils.onIdle(() => this._runCacheCheck(btn));
}
return btn;
}
/**
* Apply a named state to a button (glyph, label, tooltip, tone class).
* @param {HTMLButtonElement} btn
* @param {keyof typeof Config.STATES} stateName
* @param {string} [detail] extra tooltip context
*/
_setState(btn, stateName, detail) {
const state = Config.STATES[stateName];
if (!state) return;
btn.dataset.tbState = stateName;
const tip = detail ? `${state.label} β€” ${detail}` : state.label;
btn.title = tip;
btn.setAttribute('aria-label', tip);
const glyph = btn.querySelector(`.${Config.DOM.CLASS_PREFIX}glyph`);
const sr = btn.querySelector(`.${Config.DOM.CLASS_PREFIX}sr-only`);
if (glyph) {
glyph.textContent = this._settings.get('iconStyle') === 'svg' && stateName === 'IDLE'
? ''
: state.icon;
glyph.classList.toggle(`${Config.DOM.CLASS_PREFIX}svg-icon`,
this._settings.get('iconStyle') === 'svg' && stateName === 'IDLE');
}
if (sr) sr.textContent = tip;
// Tone drives colour via CSS; keep exactly one tone class.
for (const tone of ['idle', 'busy', 'ok', 'err', 'cached']) {
btn.classList.toggle(`${Config.DOM.CLASS_PREFIX}tone-${tone}`, tone === state.tone);
}
if (this._settings.get('enableAnimations') && state.tone === 'busy') {
btn.classList.add(`${Config.DOM.CLASS_PREFIX}spin`);
} else {
btn.classList.remove(`${Config.DOM.CLASS_PREFIX}spin`);
}
}
/** @param {HTMLButtonElement} btn */
async _runCacheCheck(btn) {
const magnet = btn.dataset.tbMagnet;
const hash = Utils.hashFromMagnet(magnet);
if (!hash) return;
const hasKey = await this._api._getApiKey();
if (!hasKey) return; // silent β€” cache check is opportunistic
try {
this._setState(btn, 'CHECKING');
const cached = await this._api.isCached(hash);
this._setState(btn, cached ? 'CACHED' : 'NOT_CACHED');
} catch (err) {
logger.warn('cache check failed', err);
this._setState(btn, 'IDLE');
}
}
/** @param {HTMLButtonElement} btn */
async _onClick(btn) {
if (this._busy.has(btn)) return;
const magnet = btn.dataset.tbMagnet;
if (!Utils.isValidMagnet(magnet)) {
this._toasts.show('Invalid magnet link β€” cannot send.', 'error');
this._setState(btn, 'FAILURE', 'invalid magnet');
return;
}
const key = await this._api._getApiKey();
if (!key) {
this._toasts.show('Set your TorBox API key first (userscript menu).', 'warning');
return;
}
const hash = Utils.hashFromMagnet(magnet);
this._busy.add(btn);
try {
// 1) Duplicate detection β€” never resubmit what already exists.
const existing = await this._api.findExisting(hash);
if (existing) {
this._setState(btn, 'EXISTS', existing.name || '');
this._toasts.show(`Already in your TorBox: ${existing.name || hash}`, 'info');
this._maybePoll(btn, existing.id);
return;
}
// 2) Optional cache gating.
if (this._settings.get('cachedOnlyMode')) {
this._setState(btn, 'CHECKING');
const cached = await this._api.isCached(hash);
if (!cached) {
this._setState(btn, 'NOT_CACHED', 'skipped (cached-only mode)');
this._toasts.show('Not cached β€” skipped (cached-only mode is on).', 'warning');
return;
}
}
// 3) Submit.
this._setState(btn, 'LOADING');
const result = await this._api.createTorrent(magnet, {
seed: this._settings.get('seedPreference'),
allowZip: this._settings.get('allowZip'),
name: Utils.nameFromMagnet(magnet),
});
this._setState(btn, 'SUCCESS', result && result.name ? result.name : '');
this._toasts.show('Sent to TorBox βœ…', 'success');
if (this._settings.get('openDashboardAfterAdd')) {
window.open(Config.API.DASHBOARD_URL, '_blank', 'noopener');
}
const id = result && (result.torrent_id || result.id || result.queued_id);
this._maybePoll(btn, id);
} catch (err) {
this._handleError(btn, err);
} finally {
this._busy.delete(btn);
}
}
/**
* Begin live status polling if enabled and we have an id.
* @param {HTMLButtonElement} btn
* @param {number|string|undefined} id
*/
_maybePoll(btn, id) {
if (!id || !this._settings.get('pollingEnabled')) return;
this._poll(btn, id, 0);
}
/**
* Poll torrent status, updating the tooltip, until terminal or capped.
* @param {HTMLButtonElement} btn
* @param {number|string} id
* @param {number} attempt
*/
async _poll(btn, id, attempt) {
if (attempt >= Config.POLL.MAX_ATTEMPTS) {
logger.info('polling stopped (max attempts)', id);
return;
}
try {
const t = await this._api.getTorrent(id);
if (t) {
const state = String(t.download_state || t.state || '').toLowerCase();
const pct = typeof t.progress === 'number' ? Math.round(t.progress * 100) : null;
const label = pct !== null ? `${state || 'status'} ${pct}%` : (state || 'status');
if (t.download_finished || t.cached || state === 'completed' || state === 'cached' || pct === 100) {
this._setState(btn, 'SUCCESS', label);
return; // terminal β€” stop polling.
}
this._setState(btn, 'SUCCESS', label);
}
} catch (err) {
logger.warn('poll error', err);
}
setTimeout(() => this._poll(btn, id, attempt + 1), Config.POLL.INTERVAL_MS);
}
/**
* Map an error to a user-facing toast + button state.
* @param {HTMLButtonElement} btn
* @param {unknown} err
*/
_handleError(btn, err) {
const status = err instanceof ApiError ? err.status : 0;
let message;
switch (status) {
case 401: message = 'Unauthorized β€” check your TorBox API key.'; break;
case 403: message = 'Forbidden β€” your plan may not permit this action.'; break;
case 404: message = 'TorBox endpoint not found.'; break;
case 429: message = 'Rate limited by TorBox β€” try again shortly.'; break;
case 500: message = 'TorBox server error β€” try again later.'; break;
default:
message = err instanceof Error && err.message
? err.message
: 'Unexpected error sending to TorBox.';
}
logger.error('add failed', status, err);
this._setState(btn, 'FAILURE', message);
this._toasts.show(message, 'error');
}
}
/* ======================================================================
* Scanner β€” locate magnet links (and optional raw hashes) inside a root
* node and inject exactly one button per target.
* ==================================================================== */
class Scanner {
/**
* @param {ButtonManager} buttons
* @param {Settings} settings
*/
constructor(buttons, settings) {
this._buttons = buttons;
this._settings = settings;
/** Nodes we have already processed (GC-friendly). */
this._seen = new WeakSet();
}
/**
* Scan a subtree and inject buttons. Safe to call repeatedly; processed
* links are skipped via both the WeakSet and the marker attribute.
* @param {ParentNode} root
*/
scan(root) {
if (!root || typeof root.querySelectorAll !== 'function') return;
this._scanMagnets(root);
if (this._settings.get('detectHashes')) this._scanHashes(root);
}
/** @param {ParentNode} root */
_scanMagnets(root) {
/** @type {NodeListOf<HTMLAnchorElement>} */
const anchors = root.querySelectorAll(Config.SCAN.BATCH_ATTR_QUERY);
if (!anchors.length) return;
const fragmentInserts = [];
anchors.forEach((anchor) => {
if (this._isProcessed(anchor)) return;
const magnet = anchor.getAttribute('href');
if (!Utils.isValidMagnet(magnet)) {
this._mark(anchor); // mark invalid too, so we don't re-test it.
return;
}
this._mark(anchor);
fragmentInserts.push([anchor, this._buttons.create(magnet)]);
});
// Batch DOM writes to minimise reflow on link-heavy pages.
for (const [anchor, btn] of fragmentInserts) {
anchor.insertAdjacentElement('afterend', btn);
}
}
/**
* Detect bare 40-hex / 32-base32 BTIH hashes in text and offer an add
* button beside them (converted into a magnet on the fly).
* @param {ParentNode} root
*/
_scanHashes(root) {
const rootEl = root.nodeType === Node.ELEMENT_NODE ? /** @type {Element} */ (root) : document.body;
if (!rootEl) return;
const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
const parent = node.parentElement;
if (!parent) return NodeFilter.FILTER_REJECT;
const tag = parent.tagName;
// Skip editable/script/style/existing-button contexts.
if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'TEXTAREA' || tag === 'A') {
return NodeFilter.FILTER_REJECT;
}
if (parent.closest(`.${Config.DOM.BUTTON_CLASS}`)) return NodeFilter.FILTER_REJECT;
if (parent.isContentEditable) return NodeFilter.FILTER_REJECT;
return /\b[a-fA-F0-9]{40}\b|\b[A-Za-z2-7]{32}\b/.test(node.nodeValue || '')
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
},
});
/** @type {Text[]} */
const targets = [];
let current = walker.nextNode();
while (current) {
if (!this._seen.has(current)) targets.push(/** @type {Text} */ (current));
current = walker.nextNode();
}
for (const textNode of targets) {
this._seen.add(textNode);
const parent = textNode.parentElement;
if (!parent || parent.querySelector(`.${Config.DOM.BUTTON_CLASS}`)) continue;
const match = /\b([a-fA-F0-9]{40})\b|\b([A-Za-z2-7]{32})\b/.exec(textNode.nodeValue || '');
if (!match) continue;
const hex = Utils.normaliseHash(match[1] || match[2]);
if (!hex) continue;
const btn = this._buttons.create(Utils.magnetFromHash(hex));
btn.classList.add(`${Config.DOM.CLASS_PREFIX}from-hash`);
parent.insertBefore(btn, textNode.nextSibling);
}
}
/** @param {Element} el @returns {boolean} */
_isProcessed(el) {
return this._seen.has(el) || el.hasAttribute(Config.DOM.PROCESSED_ATTR);
}
/** @param {Element} el */
_mark(el) {
this._seen.add(el);
el.setAttribute(Config.DOM.PROCESSED_ATTR, '1');
}
}
/* ======================================================================
* MutationManager β€” one observer, processing only added subtrees, with a
* debounced flush so bursts (infinite scroll, framework re-renders) don't
* thrash the scanner.
* ==================================================================== */
class MutationManager {
/** @param {Scanner} scanner */
constructor(scanner) {
this._scanner = scanner;
/** @type {Set<Node>} */
this._pending = new Set();
this._observer = null;
this._flush = Utils.debounce(() => this._process(), Config.SCAN.DEBOUNCE_MS);
}
start() {
// Initial full pass (once).
Utils.onIdle(() => this._scanner.scan(document.body || document));
this._observer = new MutationObserver((mutations) => {
for (const m of mutations) {
if (m.type !== 'childList') continue;
m.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) this._pending.add(node);
});
}
if (this._pending.size) this._flush();
});
this._observer.observe(document.documentElement, {
childList: true,
subtree: true,
});
logger.info('observer started');
}
_process() {
if (!this._pending.size) return;
const nodes = Array.from(this._pending);
this._pending.clear();
Utils.onIdle(() => {
for (const node of nodes) {
if (node.isConnected) this._scanner.scan(node);
}
});
}
stop() {
if (this._observer) {
this._observer.disconnect();
this._observer = null;
}
}
}
/* ======================================================================
* FloatingMenu β€” an in-page action menu that mirrors the userscript
* manager's dropdown. Essential on mobile browsers where Tampermonkey /
* Violentmonkey expose no per-page menu GUI. A small FAB toggles a panel
* of actions; each action reuses the exact same handlers as the native
* menu commands. Visibility is controlled by the `showFloatingMenu`
* setting and can be toggled live.
* ==================================================================== */
class FloatingMenu {
/**
* @param {Settings} settings
* @param {Array<{icon:string,label:string,onClick:() => void}>} actions
*/
constructor(settings, actions) {
this._settings = settings;
this._actions = actions;
/** @type {HTMLElement|null} */ this._fab = null;
/** @type {HTMLElement|null} */ this._panel = null;
this._open = false;
this._onDocClick = this._onDocClick.bind(this);
this._onKeyDown = this._onKeyDown.bind(this);
}
/** Build the FAB + panel and honour the current setting. */
mount() {
if (this._fab) return;
const p = Config.DOM.CLASS_PREFIX;
const fab = document.createElement('button');
fab.type = 'button';
fab.className = `${p}fab`;
fab.setAttribute('aria-haspopup', 'menu');
fab.setAttribute('aria-expanded', 'false');
fab.setAttribute('aria-label', 'TorBox menu');
fab.title = 'TorBox menu';
fab.textContent = 'πŸ“¦';
fab.addEventListener('click', (e) => {
e.stopPropagation();
this.toggle();
});
const panel = document.createElement('div');
panel.className = `${p}menu-panel`;
panel.setAttribute('role', 'menu');
panel.setAttribute('aria-label', 'TorBox actions');
panel.hidden = true;
const header = document.createElement('div');
header.className = `${p}menu-header`;
header.textContent = 'TorBox Magnet Helper';
panel.appendChild(header);
for (const action of this._actions) {
const item = document.createElement('button');
item.type = 'button';
item.className = `${p}menu-item`;
item.setAttribute('role', 'menuitem');
const icon = document.createElement('span');
icon.className = `${p}menu-icon`;
icon.setAttribute('aria-hidden', 'true');
icon.textContent = action.icon;
const label = document.createElement('span');
label.className = `${p}menu-label`;
label.textContent = action.label;
item.appendChild(icon);
item.appendChild(label);
item.addEventListener('click', (e) => {
e.stopPropagation();
this.close();
// Defer so the panel is gone before any prompt() blocks the thread.
setTimeout(() => {
try {
action.onClick();
} catch (err) {
logger.error('menu action failed', action.label, err);
}
}, 0);
});
panel.appendChild(item);
}
(document.body || document.documentElement).appendChild(panel);
(document.body || document.documentElement).appendChild(fab);
this._fab = fab;
this._panel = panel;
this.applyVisibility();
}
/** Show/hide the whole widget per the `showFloatingMenu` setting. */
applyVisibility() {
if (!this._fab) return;
const show = Boolean(this._settings.get('showFloatingMenu'));
this._fab.style.display = show ? '' : 'none';
if (!show) this.close();
}
toggle() {
if (this._open) this.close();
else this.openMenu();
}
openMenu() {
if (!this._panel || this._open) return;
this._open = true;
this._panel.hidden = false;
this._fab.setAttribute('aria-expanded', 'true');
this._fab.classList.add(`${Config.DOM.CLASS_PREFIX}fab-open`);
// Close on outside click / Escape.
document.addEventListener('click', this._onDocClick, true);
document.addEventListener('keydown', this._onKeyDown, true);
const first = this._panel.querySelector(`.${Config.DOM.CLASS_PREFIX}menu-item`);
if (first) first.focus();
}
close() {
if (!this._panel || !this._open) return;
this._open = false;
this._panel.hidden = true;
this._fab.setAttribute('aria-expanded', 'false');
this._fab.classList.remove(`${Config.DOM.CLASS_PREFIX}fab-open`);
document.removeEventListener('click', this._onDocClick, true);
document.removeEventListener('keydown', this._onKeyDown, true);
}
/** @param {MouseEvent} e */
_onDocClick(e) {
if (!this._panel.contains(e.target) && e.target !== this._fab) this.close();
}
/** @param {KeyboardEvent} e */
_onKeyDown(e) {
if (e.key === 'Escape') {
this.close();
this._fab.focus();
}
}
}
/* ======================================================================
* Styles β€” injected exactly once. All classes prefixed `tb-`. Uses CSS
* variables so the button adapts to light/dark hosts without hardcoding.
* ==================================================================== */
function injectStyles() {
if (document.getElementById(Config.DOM.STYLE_ID)) return;
const style = document.createElement('style');
style.id = Config.DOM.STYLE_ID;
style.textContent = `
:root {
--tb-accent: #6c5ce7;
--tb-accent-fg: #ffffff;
--tb-ok: #2ecc71;
--tb-err: #e74c3c;
--tb-cached: #f1c40f;
--tb-busy: #3498db;
--tb-shadow: rgba(0,0,0,0.25);
}
.${Config.DOM.BUTTON_CLASS} {
--tb-size: 22px;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--tb-size);
height: var(--tb-size);
min-width: var(--tb-size);
margin: 0 4px;
padding: 0;
font: inherit;
font-size: calc(var(--tb-size) * 0.62);
line-height: 1;
vertical-align: middle;
border: 1px solid color-mix(in srgb, var(--tb-accent) 55%, transparent);
border-radius: 6px;
background: color-mix(in srgb, var(--tb-accent) 16%, transparent);
color: inherit;
cursor: pointer;
box-shadow: 0 1px 3px var(--tb-shadow);
transition: transform .12s ease, background .12s ease, box-shadow .12s ease;
-webkit-user-select: none;
user-select: none;
box-sizing: border-box;
}
.${Config.DOM.BUTTON_CLASS}:hover { transform: translateY(-1px); box-shadow: 0 2px 6px var(--tb-shadow); }
.${Config.DOM.BUTTON_CLASS}:active { transform: translateY(0); }
.${Config.DOM.BUTTON_CLASS}:focus-visible {
outline: 2px solid var(--tb-accent);
outline-offset: 2px;
}
.${Config.DOM.CLASS_PREFIX}tone-ok { border-color: var(--tb-ok); background: color-mix(in srgb, var(--tb-ok) 20%, transparent); }
.${Config.DOM.CLASS_PREFIX}tone-err { border-color: var(--tb-err); background: color-mix(in srgb, var(--tb-err) 20%, transparent); }
.${Config.DOM.CLASS_PREFIX}tone-cached{ border-color: var(--tb-cached);background: color-mix(in srgb, var(--tb-cached) 25%, transparent); }
.${Config.DOM.CLASS_PREFIX}tone-busy { border-color: var(--tb-busy); background: color-mix(in srgb, var(--tb-busy) 20%, transparent); }
.${Config.DOM.CLASS_PREFIX}glyph { pointer-events: none; }
.${Config.DOM.CLASS_PREFIX}svg-icon {
width: 62%; height: 62%;
background: currentColor;
-webkit-mask: var(--tb-svg) center/contain no-repeat;
mask: var(--tb-svg) center/contain no-repeat;
}
.${Config.DOM.BUTTON_CLASS}.${Config.DOM.CLASS_PREFIX}spin .${Config.DOM.CLASS_PREFIX}glyph {
animation: tb-spin 0.9s linear infinite;
}
@keyframes tb-spin { to { transform: rotate(360deg); } }
.${Config.DOM.CLASS_PREFIX}sr-only {
position: absolute !important;
width: 1px; height: 1px;
padding: 0; margin: -1px;
overflow: hidden; clip: rect(0,0,0,0);
white-space: nowrap; border: 0;
}
.${Config.DOM.CLASS_PREFIX}toast-root {
position: fixed;
right: 16px; bottom: 16px;
z-index: 2147483647;
display: flex; flex-direction: column; gap: 8px;
max-width: min(360px, 90vw);
pointer-events: none;
font: 13px/1.4 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
}
.${Config.DOM.CLASS_PREFIX}toast {
pointer-events: auto;
display: flex; align-items: center; gap: 10px;
padding: 10px 12px;
border-radius: 8px;
color: #fff;
background: #333;
box-shadow: 0 4px 14px var(--tb-shadow);
animation: tb-toast-in .22s ease;
}
.${Config.DOM.CLASS_PREFIX}toast-noanim { animation: none; }
.${Config.DOM.CLASS_PREFIX}toast-out { animation: tb-toast-out .3s ease forwards; }
.${Config.DOM.CLASS_PREFIX}toast-success { background: var(--tb-ok); }
.${Config.DOM.CLASS_PREFIX}toast-error { background: var(--tb-err); }
.${Config.DOM.CLASS_PREFIX}toast-warning { background: #e67e22; }
.${Config.DOM.CLASS_PREFIX}toast-info { background: var(--tb-accent); }
.${Config.DOM.CLASS_PREFIX}toast-text { flex: 1; word-break: break-word; }
.${Config.DOM.CLASS_PREFIX}toast-close {
all: unset;
cursor: pointer;
font-size: 18px; line-height: 1;
padding: 0 2px;
opacity: .85;
}
.${Config.DOM.CLASS_PREFIX}toast-close:hover { opacity: 1; }
.${Config.DOM.CLASS_PREFIX}toast-close:focus-visible { outline: 2px solid #fff; outline-offset: 2px; }
@keyframes tb-toast-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
@keyframes tb-toast-out { from { opacity: 1; } to { opacity: 0; transform: translateY(8px); } }
/* ---- Floating menu (mobile-friendly manager-dropdown mirror) ---- */
.${Config.DOM.CLASS_PREFIX}fab {
position: fixed;
left: 16px; bottom: 16px;
z-index: 2147483646;
width: 48px; height: 48px;
display: inline-flex; align-items: center; justify-content: center;
font-size: 22px; line-height: 1;
border: none; border-radius: 50%;
background: var(--tb-accent); color: var(--tb-accent-fg);
box-shadow: 0 3px 10px var(--tb-shadow);
cursor: pointer;
-webkit-user-select: none; user-select: none;
transition: transform .15s ease, box-shadow .15s ease;
}
.${Config.DOM.CLASS_PREFIX}fab:hover { box-shadow: 0 5px 16px var(--tb-shadow); }
.${Config.DOM.CLASS_PREFIX}fab:active { transform: scale(0.94); }
.${Config.DOM.CLASS_PREFIX}fab:focus-visible { outline: 3px solid var(--tb-accent); outline-offset: 3px; }
.${Config.DOM.CLASS_PREFIX}fab-open { transform: rotate(8deg) scale(1.03); }
.${Config.DOM.CLASS_PREFIX}menu-panel {
position: fixed;
left: 16px; bottom: 74px;
z-index: 2147483646;
min-width: 220px; max-width: 82vw;
padding: 6px;
border-radius: 12px;
background: Canvas, #1f1f27;
color: CanvasText, #f2f2f2;
border: 1px solid rgba(128,128,128,.3);
box-shadow: 0 8px 28px var(--tb-shadow);
font: 14px/1.3 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
animation: tb-toast-in .16s ease;
}
.${Config.DOM.CLASS_PREFIX}menu-panel[hidden] { display: none; }
.${Config.DOM.CLASS_PREFIX}menu-header {
padding: 8px 10px 10px;
font-weight: 600; font-size: 12px;
opacity: .7; letter-spacing: .02em;
border-bottom: 1px solid rgba(128,128,128,.25);
margin-bottom: 4px;
}
.${Config.DOM.CLASS_PREFIX}menu-item {
all: unset;
box-sizing: border-box;
display: flex; align-items: center; gap: 12px;
width: 100%;
padding: 11px 10px;
border-radius: 8px;
cursor: pointer;
color: inherit;
}
.${Config.DOM.CLASS_PREFIX}menu-item:hover,
.${Config.DOM.CLASS_PREFIX}menu-item:focus-visible {
background: color-mix(in srgb, var(--tb-accent) 22%, transparent);
outline: none;
}
.${Config.DOM.CLASS_PREFIX}menu-icon { font-size: 17px; width: 20px; text-align: center; }
.${Config.DOM.CLASS_PREFIX}menu-label { flex: 1; }
@media (prefers-reduced-motion: reduce) {
.${Config.DOM.BUTTON_CLASS}, .${Config.DOM.CLASS_PREFIX}toast { transition: none; animation: none; }
.${Config.DOM.BUTTON_CLASS}.${Config.DOM.CLASS_PREFIX}spin .${Config.DOM.CLASS_PREFIX}glyph { animation: none; }
.${Config.DOM.CLASS_PREFIX}fab { transition: none; }
.${Config.DOM.CLASS_PREFIX}menu-panel { animation: none; }
}
`;
(document.head || document.documentElement).appendChild(style);
// Provide the optional inline SVG TorBox glyph as a CSS mask.
const svg = encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">'
+ '<path fill="black" d="M12 2 3 7v10l9 5 9-5V7l-9-5zm0 2.3 6.5 3.6L12 11.5 5.5 7.9 12 4.3zM5 9.4l6 3.3v6.6l-6-3.3V9.4zm14 0v6.6l-6 3.3v-6.6l6-3.3z"/>'
+ '</svg>',
);
style.sheet && document.documentElement.style.setProperty('--tb-svg', `url("data:image/svg+xml,${svg}")`);
}
/* ======================================================================
* Main β€” bootstrap, menu commands, wiring.
* ==================================================================== */
class Main {
constructor() {
this._settings = new Settings();
this._api = new Api(() => Storage.get(Config.KEYS.API_KEY, ''));
/** @type {ToastManager} */ this._toasts = null;
/** @type {ButtonManager} */ this._buttons = null;
/** @type {Scanner} */ this._scanner = null;
/** @type {MutationManager} */ this._mutations = null;
/** @type {FloatingMenu} */ this._menu = null;
}
async start() {
await this._settings.load();
injectStyles();
this._toasts = new ToastManager(this._settings);
this._buttons = new ButtonManager(this._api, this._settings, this._toasts);
this._scanner = new Scanner(this._buttons, this._settings);
this._mutations = new MutationManager(this._scanner);
this._menu = new FloatingMenu(this._settings, this._menuActions());
this._registerMenus();
this._menu.mount();
// Keep the floating menu's visibility in sync with the setting live.
this._settings.onChange(() => this._menu.applyVisibility());
this._mutations.start();
logger.info(`v2 ready β€” settings:`, this._settings.all());
}
/**
* The shared action list used by BOTH the native manager menu and the
* in-page floating menu, so behaviour is identical on desktop and mobile.
* @returns {Array<{icon:string,label:string,onClick:() => void}>}
*/
_menuActions() {
return [
{ icon: 'πŸ”‘', label: 'Set API Key', onClick: () => this._setApiKey() },
{ icon: 'πŸ—‘', label: 'Clear API Key', onClick: () => this._clearApiKey() },
{ icon: 'πŸ”Œ', label: 'Test Connection', onClick: () => this._testConnection() },
{ icon: 'βš™', label: 'Settings', onClick: () => this._openSettings() },
{ icon: 'πŸ“€', label: 'Export Settings', onClick: () => this._exportSettings() },
{ icon: 'πŸ“₯', label: 'Import Settings', onClick: () => this._importSettings() },
{ icon: '🌐', label: 'Open TorBox Dashboard', onClick: () => {
window.open(Config.API.DASHBOARD_URL, '_blank', 'noopener');
} },
];
}
_registerMenus() {
for (const action of this._menuActions()) {
registerMenu(`${action.icon} ${action.label}`, action.onClick);
}
registerMenu('πŸ“± Toggle Floating Menu', () => this._toggleFloatingMenu());
}
async _toggleFloatingMenu() {
const next = !this._settings.get('showFloatingMenu');
await this._settings.update({ showFloatingMenu: next });
this._toasts.show(`Floating menu ${next ? 'shown' : 'hidden'}.`, 'info');
}
async _setApiKey() {
// prompt is the only viable input primitive available to a userscript
// menu command; the key is stored via GM storage and never logged.
const current = await Storage.get(Config.KEYS.API_KEY, '');
const masked = current ? '(a key is already set)' : '(none set)';
const input = window.prompt(`Enter your TorBox API key ${masked}:`, '');
if (input === null) return;
const trimmed = input.trim();
if (!trimmed) {
this._toasts.show('No key entered.', 'warning');
return;
}
await Storage.set(Config.KEYS.API_KEY, trimmed);
this._toasts.show('API key saved. Testing…', 'info');
this._testConnection();
}
async _clearApiKey() {
await Storage.set(Config.KEYS.API_KEY, '');
this._toasts.show('API key cleared.', 'info');
}
async _testConnection() {
const key = await Storage.get(Config.KEYS.API_KEY, '');
if (!key) {
this._toasts.show('No API key set β€” add one first.', 'warning');
return;
}
try {
const user = await this._api.testConnection();
const email = user && (user.email || user.customer || 'account');
this._toasts.show(`Connected to TorBox as ${email} βœ…`, 'success');
} catch (err) {
const msg = err instanceof Error ? err.message : 'Connection failed';
this._toasts.show(`Connection failed: ${msg}`, 'error');
}
}
_openSettings() {
// Compact, dependency-free settings via sequential prompts keeps the
// footprint tiny while remaining fully functional and predictable.
const s = this._settings.all();
const toggles = [
['autoCacheCheck', 'Auto cache check on page load (y/n)'],
['cachedOnlyMode', 'Cached-only mode β€” skip uncached (y/n)'],
['openDashboardAfterAdd', 'Open dashboard after add (y/n)'],
['pollingEnabled', 'Live status polling (y/n)'],
['detectHashes', 'Detect raw BTIH hashes (y/n)'],
['showFloatingMenu', 'Show in-page floating menu (y/n)'],
['enableAnimations', 'Enable animations (y/n)'],
['debugLogging', 'Debug logging (y/n)'],
];
const patch = {};
for (const [key, label] of toggles) {
const ans = window.prompt(`${label}\n[current: ${s[key] ? 'y' : 'n'}]`, s[key] ? 'y' : 'n');
if (ans === null) return; // cancelled β€” abort without saving.
patch[key] = /^y(es)?$/i.test(ans.trim());
}
const seed = window.prompt('Seed preference: 1=auto, 2=always, 3=never', String(s.seedPreference));
if (seed === null) return;
const seedNum = Number(seed);
if ([1, 2, 3].includes(seedNum)) patch.seedPreference = seedNum;
const size = window.prompt('Button size in px (16–32)', String(s.buttonSize));
if (size !== null) {
const px = Math.max(16, Math.min(32, Number(size) || s.buttonSize));
patch.buttonSize = px;
}
const dur = window.prompt('Toast duration in ms (1000–15000)', String(s.toastDurationMs));
if (dur !== null) {
const ms = Math.max(1000, Math.min(15000, Number(dur) || s.toastDurationMs));
patch.toastDurationMs = ms;
}
const icon = window.prompt('Icon style: emoji or svg', s.iconStyle);
if (icon !== null && /^(emoji|svg)$/i.test(icon.trim())) {
patch.iconStyle = icon.trim().toLowerCase();
}
this._settings.update(patch).then(() => {
this._toasts.show('Settings saved. Reload the page to fully apply.', 'success');
});
}
_exportSettings() {
const json = this._settings.export();
window.prompt('Copy your settings JSON (API key excluded):', json);
}
async _importSettings() {
const json = window.prompt('Paste settings JSON to import:', '');
if (json === null || !json.trim()) return;
const ok = await this._settings.import(json.trim());
this._toasts.show(
ok ? 'Settings imported. Reload to fully apply.' : 'Import failed β€” invalid JSON.',
ok ? 'success' : 'error',
);
}
}
// ---- Bootstrap ---------------------------------------------------------
const main = new Main();
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => main.start(), { once: true });
} else {
main.start();
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment