Created
July 24, 2026 22:07
-
-
Save kaiser62/7ce7f7ff20a7cf2e29c9cab77699dd9d to your computer and use it in GitHub Desktop.
TorBox Magnet Helper - send magnet links to TorBox with one click
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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