diff --git a/README.md b/README.md index 9c93c0d..fb9cc58 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ A unified download manager for aMule, rTorrent, qBittorrent, Deluge, and Transmi ### Multi-Client Support - **aMule Integration** - Control aMule via the EC (External Connection) protocol +- **Rucio Integration** - Connect to the Rucio P2P daemon (libp2p + eMule/Kad) via its REST API - **rTorrent Integration** - Connect to rTorrent via XML-RPC (HTTP proxy, SCGI TCP, or Unix socket) - **qBittorrent Integration** - Connect to qBittorrent via WebUI API - **Deluge Integration** - Connect to Deluge via WebUI JSON-RPC @@ -124,6 +125,7 @@ Open `http://localhost:4000` and complete the setup wizard. |----------|-------------| | [Configuration Guide](./docs/CONFIGURATION.md) | Setup wizard, settings, environment variables | | [aMule Integration](./docs/AMULE.md) | Connect to aMule via EC protocol | +| [Rucio Integration](./docs/RUCIO.md) | Connect to Rucio via its REST API (P2P, libp2p + eMule/Kad) | | [rTorrent Integration](./docs/RTORRENT.md) | Connect to rTorrent via XML-RPC (HTTP, SCGI, or Unix socket) | | [qBittorrent Integration](./docs/QBITTORRENT.md) | Connect to qBittorrent via WebUI API | | [Deluge Integration](./docs/DELUGE.md) | Connect to Deluge via WebUI JSON-RPC | diff --git a/docs/RUCIO.md b/docs/RUCIO.md new file mode 100644 index 0000000..5eaeee9 --- /dev/null +++ b/docs/RUCIO.md @@ -0,0 +1,168 @@ +# Rucio Integration + +aMuTorrent connects to [Rucio](https://github.com/ogarcia/rucio) over its REST API, letting you search the network, manage downloads and shared files, and organize everything with categories — all from the same dashboard you use for your other clients. + +Rucio is a P2P daemon built on libp2p and BLAKE3, with eMule/Kad compatibility. It is treated as its own network in aMuTorrent (separate from aMule), with its own filter toggle, chart series and status badge. + +## Requirements + +- A running Rucio daemon (`ruciod`) with its REST API reachable from aMuTorrent +- The API port (default: `3003`) + +> **No authentication:** Rucio's API has no built-in authentication — access control is meant to be delegated to a reverse proxy. If you put Rucio behind HTTP basic auth, aMuTorrent can pass credentials (see Username/Password below). + +## Rucio API Setup + +The REST API is always served by the daemon; you only need to make sure it listens on an address aMuTorrent can reach. + +By default the daemon binds to `127.0.0.1:3003`, which is **only reachable from the same host**. If aMuTorrent runs in a container or on another machine, bind the API to all interfaces: + +```bash +RUCIOD_API_LISTEN=0.0.0.0:3003 ruciod +``` + +Or in Rucio's `config.toml`: + +```toml +[api] +listen = "0.0.0.0:3003" +``` + +> When exposing the API beyond localhost, put Rucio behind a reverse proxy and restrict access there — the daemon itself does not authenticate requests. + +## Configuration + +### Via Settings UI + +1. Go to **Settings** → **Download Clients** and click **Add Client** +2. Choose **Rucio** +3. Configure connection settings: + - **Host**: Rucio daemon hostname or IP (e.g., `localhost`, `rucio`, or `host.docker.internal`) + - **Port**: API port (default: `3003`) + - **Base Path** *(optional)*: set this only when the daemon is served under a sub-path behind a reverse proxy (e.g., `/rucio`) + - **Username / Password** *(optional)*: only if the daemon is behind HTTP basic auth + - **Use SSL (HTTPS)**: enable when connecting through an HTTPS reverse proxy + +### Via Environment Variables + +```bash +RUCIO_ENABLED=true +RUCIO_HOST=localhost +RUCIO_PORT=3003 +RUCIO_USE_SSL=false +RUCIO_BASE_PATH= +RUCIO_USERNAME= +RUCIO_PASSWORD= +``` + +### Via config.json + +```json +{ + "rucio": { + "enabled": true, + "host": "localhost", + "port": 3003, + "useSsl": false + } +} +``` + +## Docker Compose Example + +### Rucio on Host Machine + +```yaml +services: + amutorrent: + image: g0t3nks/amutorrent:latest + environment: + - RUCIO_ENABLED=true + - RUCIO_HOST=host.docker.internal + - RUCIO_PORT=3003 + extra_hosts: + - "host.docker.internal:host-gateway" + ports: + - "4000:4000" +``` + +Make sure the daemon on the host binds to a reachable address (`RUCIOD_API_LISTEN=0.0.0.0:3003`). + +### Rucio in Docker Container + +```yaml +services: + rucio: + image: ghcr.io/ogarcia/rucio:latest + container_name: rucio + environment: + - RUCIOD_API_LISTEN=0.0.0.0:3003 + ports: + - "4321:4321" # P2P (libp2p) + - "4321:4321/udp" + volumes: + - ./data/rucio:/data + restart: unless-stopped + + amutorrent: + image: g0t3nks/amutorrent:latest + environment: + - RUCIO_ENABLED=true + - RUCIO_HOST=rucio + - RUCIO_PORT=3003 + ports: + - "4000:4000" + restart: unless-stopped +``` + +> The Rucio API port (`3003`) does **not** need to be published to the host — aMuTorrent reaches it over the Docker network. Only publish it if you also want to access Rucio's own web UI directly. + +## Features + +### Search + +Searches against Rucio run on the **unified search** alongside your other clients and surface in the same results view. Pick Rucio as the search target from the search widget; results can be queued straight to Rucio. + +### Downloads + +Add downloads via `rucio:` magnets or ED2K links, and pause, resume, rename, cancel and delete them from the UI like any other client. + +### Shared Files + +Rucio's shared files appear in the **Shared Files** view. Unsharing a file in aMuTorrent removes it from Rucio's share list without deleting the file on disk. + +### Categories + +Categories created in aMuTorrent are synced to Rucio, including their **color** and **download directory**. Editing a category in aMuTorrent updates it in Rucio, and downloads can be assigned to a category from the UI. + +## Reverse Proxy / Sub-path + +To serve Rucio under a sub-path (e.g., `https://example.com/rucio`), set `RUCIOD_BASE_PATH=/rucio/` on the daemon and enter `/rucio` as the **Base Path** in aMuTorrent. Enable **Use SSL** if the proxy terminates HTTPS. + +## Troubleshooting + +### Connection Failed + +- Verify the daemon is running and the API is listening: `curl http://host:3003/health` +- If aMuTorrent runs in a container, the daemon must bind to `0.0.0.0` (not the default `127.0.0.1`) — set `RUCIOD_API_LISTEN=0.0.0.0:3003` +- Check the host/port and any firewall rules between aMuTorrent and Rucio + +### Docker: Can't reach Rucio on host + +- Add `extra_hosts` to your docker-compose.yml: + ```yaml + extra_hosts: + - "host.docker.internal:host-gateway" + ``` +- Use `host.docker.internal` as the Rucio host + +### 401 / Authentication errors + +- Rucio has no built-in auth — a 401 comes from a reverse proxy in front of it +- Enter the proxy's basic-auth Username/Password in the client settings + +### Downloads or Shared Files Not Appearing + +- Ensure the Rucio client is enabled in Settings +- Check the aMuTorrent logs for connection errors +- Confirm Rucio has active downloads or shared files of its own diff --git a/server/lib/clientMeta.js b/server/lib/clientMeta.js index e76cd48..308cf54 100644 --- a/server/lib/clientMeta.js +++ b/server/lib/clientMeta.js @@ -64,6 +64,67 @@ const CLIENT_TYPES = { customSavePath: false // ed2k uses category paths only } }, + rucio: { + // Rucio is its own P2P network (libp2p, BLAKE3) that also bridges eMule/Kad. + // It gets its own networkType so it is a first-class entity everywhere + // (charts, filters, metrics, footer) rather than being conflated with aMule. + // Its capability profile still matches aMule (search + shared files + + // categories, no trackers, single-file); the unified item builder applies + // the source-based (non-BitTorrent) shape to it the same as ed2k. + networkType: 'rucio', + displayName: 'Rucio', + metricsPrefix: 'ru_', // ru_upload_speed, ru_total_uploaded + hashLength: 64, // BLAKE3 root hash (eMule MD4 results are 32; only used by demo data) + statusField: 'state', // resolveStatus reads the daemon's `state` + // Accept both casings: Rucio ≤0.32 serialized DownloadState in PascalCase + // (a missing serde rename_all, since fixed to snake_case to match the rest + // of its API). Keeping both keys makes the integration version-agnostic. + statusMap: { + 'finding_providers': 'active', 'FindingProviders': 'active', + 'queued': 'active', 'Queued': 'active', + 'downloading': 'active', 'Downloading': 'active', + 'stalled': 'active', 'Stalled': 'active', + 'paused': 'paused', 'Paused': 'paused', + 'completed': 'seeding', 'Completed': 'seeding', // completed files are seeded back + 'failed': 'error', 'Failed': 'error', + 'cancelled': 'stopped', 'Cancelled': 'stopped' + }, + connectionDefaults: { + host: '', port: 3003, useSsl: false, basePath: '', username: '', password: '' + }, + defaults: { + downloadPriority: null, + partStatus: null, + gapStatus: null, + reqStatus: null, + lastSeenComplete: 0, + ed2kLink: null, + addedAt: null + }, + capabilities: { + nativeMove: false, // dir is category-driven, no move-by-path API + categoryChangeAutoMoves: false, + multiFile: false, // one file per root hash + sharedFiles: true, // has a shared-files concept (GET /shares/files) + sharedMeansComplete: true, // a shared file is a complete file + removeSharedMustDeleteFiles: false, // can un-share via API without deleting the file + moveSharedForCategoryChange: false, + refreshSharedAfterMove: false, + moveActiveDownloads: false, + pauseBeforeMove: false, + trackers: false, // DHT/libp2p, no trackers + search: true, // unified rucio + eMule/Kad search + cancelDeletesFiles: true, // cancel discards the partial download + apiDeletesFiles: false, // deleting from the list never wipes the on-disk file + refreshSharedAfterDelete: false, + categories: true, // full category CRUD in the daemon + logs: false, + renameFile: true, // can rename a not-yet-complete download + fileRatingComment: false, + customSavePath: false // path follows the category, not per-download + }, + seedingStatuses: ['completed', 'Completed'] + }, rtorrent: { networkType: 'bittorrent', displayName: 'rTorrent', diff --git a/server/lib/configTester.js b/server/lib/configTester.js index 71dacb4..827b7b1 100644 --- a/server/lib/configTester.js +++ b/server/lib/configTester.js @@ -10,6 +10,7 @@ const RtorrentHandler = require('./rtorrent/RtorrentHandler'); const QBittorrentClient = require('./qbittorrent/QBittorrentClient'); const DelugeClient = require('./deluge/DelugeClient'); const TransmissionClient = require('./transmission/TransmissionClient'); +const RucioClient = require('./rucio/RucioClient'); const ProwlarrHandler = require('./prowlarr/ProwlarrHandler'); const { checkDirectoryAccess } = require('./pathUtils'); const logger = require('./logger'); @@ -671,10 +672,42 @@ async function testTransmissionConnection(host, port, username, password, useSsl } } +/** + * Test a Rucio daemon connection via its /health endpoint. + * @param {string} host + * @param {number} port + * @param {boolean} [useSsl] + * @param {string} [basePath] - sub-path behind a reverse proxy + * @param {string} [username] - optional HTTP basic auth + * @param {string} [password] - optional HTTP basic auth + * @returns {Promise<{success, connected, version, error, message}>} + */ +async function testRucioConnection(host, port, useSsl, basePath, username, password) { + const result = { success: false, connected: false, version: null, error: null }; + try { + const client = new RucioClient({ host, port, useSsl, basePath, username, password, timeoutMs: 10000 }); + const r = await client.testConnection(); + if (r.success) { + result.connected = true; + result.success = true; + result.version = r.version; + result.message = `Connected to Rucio ${r.version}`; + } else { + result.error = r.error; + result.message = `Connection failed: ${r.error}`; + } + } catch (err) { + result.error = err.message; + result.message = `Connection failed: ${err.message}`; + } + return result; +} + module.exports = { testDirectoryAccess, testGeoIPDatabase, testAmuleConnection, + testRucioConnection, testRtorrentConnection, testQbittorrentConnection, testDelugeConnection, diff --git a/server/lib/downloadNormalizer.js b/server/lib/downloadNormalizer.js index 8039b53..d203ba5 100644 --- a/server/lib/downloadNormalizer.js +++ b/server/lib/downloadNormalizer.js @@ -636,6 +636,81 @@ function normalizeTransmissionDownload(torrent) { }; } +// ============================================================================ +// RUCIO NORMALIZERS +// ============================================================================ + +// Rucio is modelled under the ed2k networkType, so these emit the field names +// the ed2k branch of unifiedItemBuilder reads (category id + categoryName, +// sourceCount/sourceCountXfer, ed2kLink, state for the status map). + +/** + * Normalize a Rucio download (GET /api/v1/downloads item) to unified format. + * @param {Object} d - raw Rucio download + * @param {Function} resolveCategoryName - (categoryId) => category name string + * @returns {Object} Normalized download + */ +function normalizeRucioDownload(d, resolveCategoryName = () => 'Default') { + const size = d.size || 0; + const downloaded = d.bytes_done || 0; + return { + clientType: 'rucio', + hash: d.root_hash, + name: d.name || '', + rawName: d.name || '', + size, + downloaded, + progress: size > 0 ? Math.round((downloaded / size) * 100) : 0, + speed: d.speed_bps || 0, + // Accept both casings (see statusMap note in clientMeta.js) + isComplete: d.state === 'completed' || d.state === 'Completed', + // Drives resolveStatus() via clientMeta statusField: 'state' + state: d.state, + + // Organization (ed2k branch reads `category` as the id + `categoryName`) + category: d.category_id ?? null, + categoryName: resolveCategoryName(d.category_id), + + // Sources + sourceCount: d.sources_total || 0, + sourceCountXfer: d.sources_active || 0, + sourceCountA4AF: 0, + sourceCountNotCurrent: 0, + + // No segment/part visualization data from the daemon (yet) + priority: null, + partStatus: null, + gapStatus: null, + reqStatus: null, + + // The "copy link" value — rucio: or ed2k: depending on the source network + ed2kLink: d.link || null, + + raw: { clientType: 'rucio', ...d } + }; +} + +/** + * Normalize a Rucio shared file (GET /api/v1/shares/files item) to unified + * format. Shared files are complete files the node is providing. + * @param {Object} s - raw Rucio shared file + * @returns {Object} Normalized shared file + */ +function normalizeRucioSharedFile(s) { + return { + clientType: 'rucio', + hash: s.root_hash, + name: s.name || '', + rawName: s.name || '', + size: s.size || 0, + uploadSpeed: 0, + // ed2k branch marks shared files complete/seeding and uses these + ed2kLink: s.magnet || null, + path: s.path || null, + raw: { clientType: 'rucio', ...s } + }; +} + module.exports = { normalizeAmuleDownload, normalizeAmuleSharedFile, @@ -645,5 +720,7 @@ module.exports = { normalizeQBittorrentDownload, normalizeDelugeDownload, normalizeTransmissionDownload, + normalizeRucioDownload, + normalizeRucioSharedFile, extractTrackerDomain }; diff --git a/server/lib/rucio/RucioClient.js b/server/lib/rucio/RucioClient.js new file mode 100644 index 0000000..60cc8bb --- /dev/null +++ b/server/lib/rucio/RucioClient.js @@ -0,0 +1,208 @@ +/** + * RucioClient - thin REST wrapper over the Rucio daemon HTTP API + * + * Rucio (github.com/ogarcia/rucio) is a P2P file-sharing daemon: a native + * libp2p network (BLAKE3/bao-tree verified streaming) plus eMule/Kad2 compat. + * Its daemon exposes a clean JSON REST API under /api/v1 — this class is a + * stateless wrapper around it using the global fetch(). + * + * The daemon has no built-in auth (access control is delegated to a reverse + * proxy). We therefore connect with just a base URL; optional username/password + * are sent as HTTP Basic for setups that put nginx basic-auth in front. A + * basePath is supported for daemons served under a sub-path (RUCIOD_BASE_PATH). + * + * Downloads are addressed by a signed integer id (positive = rucio, negative = + * eMule); shares and search results are addressed by hash. The manager layer + * owns the hash→id mapping; this client speaks the API verbatim. + */ + +'use strict'; + +const DEFAULT_TIMEOUT_MS = 15000; + +class RucioClient { + /** + * @param {Object} opts + * @param {string} opts.host + * @param {number} opts.port + * @param {boolean} [opts.useSsl=false] + * @param {string} [opts.basePath=''] - sub-path the daemon is served under (e.g. '/rucio') + * @param {string} [opts.username] - optional, for reverse-proxy basic auth + * @param {string} [opts.password] - optional, for reverse-proxy basic auth + * @param {number} [opts.timeoutMs] + */ + constructor({ host, port, useSsl = false, basePath = '', username = '', password = '', timeoutMs = DEFAULT_TIMEOUT_MS } = {}) { + const scheme = useSsl ? 'https' : 'http'; + // Normalize basePath to '' or '/segment' (no trailing slash). + const trimmed = String(basePath || '').trim().replace(/\/+$/, ''); + const normBase = trimmed && !trimmed.startsWith('/') ? `/${trimmed}` : trimmed; + this.origin = `${scheme}://${host}:${port}${normBase}`; + this.username = username || ''; + this.password = password || ''; + this.timeoutMs = timeoutMs; + this.connected = false; + } + + isConnected() { + return this.connected; + } + + _headers(hasBody = false) { + const headers = {}; + if (hasBody) headers['Content-Type'] = 'application/json'; + if (this.username || this.password) { + const token = Buffer.from(`${this.username}:${this.password}`).toString('base64'); + headers['Authorization'] = `Basic ${token}`; + } + return headers; + } + + /** + * Perform a request. Returns parsed JSON (or null for empty/204 bodies). + * Throws on network error, timeout, or non-2xx status. + * @param {string} method + * @param {string} path - absolute API path beginning with '/' (e.g. '/api/v1/status') + * @param {Object|null} [body] + * @returns {Promise<*>} + */ + async _request(method, path, body = null) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + let res; + try { + res = await fetch(`${this.origin}${path}`, { + method, + headers: this._headers(body != null), + body: body != null ? JSON.stringify(body) : undefined, + signal: controller.signal + }); + } catch (err) { + clearTimeout(timer); + if (err.name === 'AbortError') { + throw new Error(`Rucio request timed out after ${this.timeoutMs}ms: ${method} ${path}`); + } + throw new Error(`Rucio request failed: ${method} ${path} — ${err.message}`); + } + clearTimeout(timer); + + if (!res.ok) { + let detail = ''; + try { detail = (await res.text()).slice(0, 300); } catch { /* ignore */ } + throw new Error(`Rucio ${method} ${path} → HTTP ${res.status}${detail ? `: ${detail}` : ''}`); + } + + if (res.status === 204) return null; + const text = await res.text(); + if (!text) return null; + const ct = res.headers.get('content-type') || ''; + if (ct.includes('application/json')) return JSON.parse(text); + return text; // plain-text endpoints (e.g. /shares/{hash}/magnet) + } + + // ── Status / health ──────────────────────────────────────────────────── + + /** + * @returns {Promise<{success: boolean, version?: string, error?: string}>} + */ + async testConnection() { + try { + const health = await this._request('GET', '/health'); + this.connected = true; + return { success: true, version: health?.version || 'unknown' }; + } catch (err) { + this.connected = false; + return { success: false, error: err.message }; + } + } + + getStatus() { return this._request('GET', '/api/v1/status'); } + getMetrics() { return this._request('GET', '/api/v1/metrics'); } + getUploads() { return this._request('GET', '/api/v1/uploads'); } + getEmuleStatus() { return this._request('GET', '/api/v1/emule/status'); } + + // ── Downloads ───────────────────────────────────────────────────────── + + async getDownloads() { + const data = await this._request('GET', '/api/v1/downloads'); + return data?.downloads || []; + } + + /** + * Start a rucio download from a `rucio:` magnet link. + * @param {string} magnet + * @param {{providers?: string[], category_id?: number|null}} [opts] + */ + addMagnet(magnet, { providers = [], category_id = null } = {}) { + return this._request('POST', '/api/v1/downloads', { magnet, providers, category_id }); + } + + /** + * Start an eMule download from an `ed2k://` link. + * @param {string} link + * @param {{category_id?: number|null}} [opts] + */ + addEd2k(link, { category_id = null } = {}) { + return this._request('POST', '/api/v1/downloads/ed2k', { link, category_id }); + } + + pauseDownload(id) { return this._request('POST', `/api/v1/downloads/${id}/pause`); } + resumeDownload(id) { return this._request('POST', `/api/v1/downloads/${id}/resume`); } + cancelDownload(id) { return this._request('POST', `/api/v1/downloads/${id}/cancel`); } + removeDownload(id) { return this._request('DELETE', `/api/v1/downloads/${id}`); } + renameDownload(id, name) { return this._request('POST', `/api/v1/downloads/${id}/rename`, { name }); } + setDownloadCategory(id, category_id) { return this._request('PUT', `/api/v1/downloads/${id}/category`, { category_id }); } + + // ── Shares ─────────────────────────────────────────────────────────── + + /** + * @param {{q?: string, dir?: string, limit?: number, offset?: number}} [opts] + * @returns {Promise<{shares: Array, total: number}>} + */ + async getShares({ q, dir, limit = 1000, offset = 0 } = {}) { + const params = new URLSearchParams(); + if (q) params.set('q', q); + if (dir) params.set('dir', dir); + params.set('limit', String(limit)); + params.set('offset', String(offset)); + const data = await this._request('GET', `/api/v1/shares/files?${params.toString()}`); + return { shares: data?.shares || [], total: data?.total || 0 }; + } + + unshare(hash) { return this._request('DELETE', `/api/v1/shares/${hash}`); } + + // ── Search ─────────────────────────────────────────────────────────── + + /** + * @param {string[]} keywords + * @param {'rucio'|'emule'|'both'} [network='both'] + * @returns {Promise<{id: number}>} + */ + startSearch(keywords, network = 'both') { + return this._request('POST', '/api/v1/searches', { keywords, network }); + } + + getSearch(id) { return this._request('GET', `/api/v1/searches/${id}`); } + listSearches() { return this._request('GET', '/api/v1/searches'); } + cancelSearch(id) { return this._request('DELETE', `/api/v1/searches/${id}`); } + + // ── Categories ─────────────────────────────────────────────────────── + + async getCategories() { + const data = await this._request('GET', '/api/v1/categories'); + return data?.categories || []; + } + + createCategory(body) { return this._request('POST', '/api/v1/categories', body); } + updateCategory(id, body) { return this._request('PUT', `/api/v1/categories/${id}`, body); } + deleteCategory(id) { return this._request('DELETE', `/api/v1/categories/${id}`); } + + // ── Lifecycle ──────────────────────────────────────────────────────── + + // No persistent connection/session to tear down; flip the flag so callers + // that poll isConnected() see the client as down. + async disconnect() { + this.connected = false; + } +} + +module.exports = RucioClient; diff --git a/server/lib/unifiedItemBuilder.js b/server/lib/unifiedItemBuilder.js index 1a588d1..7f7bb53 100644 --- a/server/lib/unifiedItemBuilder.js +++ b/server/lib/unifiedItemBuilder.js @@ -123,7 +123,9 @@ function applyDownloadData(item, download, categoryManager = null) { item.eta = null; } - if (clientMeta.isEd2k(download.clientType)) { + if (!clientMeta.isBittorrent(download.clientType)) { + // Source-based networks (ed2k/aMule and rucio): sources, category id + + // name, no trackers, single-file, ed2k-style link. // Organization item.categoryId = download.category ?? item.categoryId; item.category = download.categoryName || item.category; @@ -247,7 +249,8 @@ function applySharedData(item, sharedFile) { item.uploadSpeed = sharedFile.uploadSpeed; } - if (clientMeta.isEd2k(sharedFile.clientType)) { + if (!clientMeta.isBittorrent(sharedFile.clientType)) { + // Source-based networks (ed2k/aMule and rucio) share completed files. // aMule shared files are completed downloads - mark them as such // (unless already set by applyDownloadData for files still downloading) if (!item.downloading) { diff --git a/server/modules/config.js b/server/modules/config.js index 6c8798e..83b2562 100644 --- a/server/modules/config.js +++ b/server/modules/config.js @@ -72,6 +72,7 @@ const SENSITIVE_ENV_VARS = Object.entries(ENV_VAR_MAP) */ const CLIENT_ENV_PREFIX = { amule: 'AMULE', + rucio: 'RUCIO', rtorrent: 'RTORRENT', qbittorrent: 'QBITTORRENT', deluge: 'DELUGE', @@ -96,6 +97,17 @@ const CLIENT_ENV_FIELDS = { ID: { field: 'id', type: 'string' }, NAME: { field: 'name', type: 'string' } }, + rucio: { + ENABLED: { field: 'enabled', type: 'boolean' }, + HOST: { field: 'host', type: 'string' }, + PORT: { field: 'port', type: 'int' }, + USE_SSL: { field: 'useSsl', type: 'boolean' }, + BASE_PATH: { field: 'basePath', type: 'string' }, + USERNAME: { field: 'username', type: 'string' }, + PASSWORD: { field: 'password', type: 'string', sensitive: true }, + ID: { field: 'id', type: 'string' }, + NAME: { field: 'name', type: 'string' } + }, rtorrent: { ENABLED: { field: 'enabled', type: 'boolean' }, MODE: { field: 'mode', type: 'string' }, @@ -706,10 +718,10 @@ class Config extends BaseModule { errors.push('Invalid server port (must be between 1 and 65535)'); } - // At least one download client must be enabled + // At least one download client must be enabled (type-agnostic) const hasEnabledClient = Array.isArray(config.clients) && config.clients.some(c => c.enabled !== false); if (!hasEnabledClient) { - errors.push('At least one download client (aMule, rTorrent, or qBittorrent) must be enabled'); + errors.push('At least one download client must be enabled'); } // Validate clients array entries diff --git a/server/modules/configAPI.js b/server/modules/configAPI.js index 7e4f682..ee9f4e6 100644 --- a/server/modules/configAPI.js +++ b/server/modules/configAPI.js @@ -353,6 +353,15 @@ class ConfigAPI extends BaseModule { this.logTestResult('Transmission connection', results.transmission); } + // Test Rucio connection if provided and enabled + const { rucio } = req.body; + if (rucio && rucio.enabled) { + const password = rucio.password || (rucio.instanceId ? config.getClientConfig(rucio.instanceId)?.password : null); + this.log(`🧪 Testing Rucio connection to ${rucio.host}:${rucio.port}...`); + results.rucio = await configTester.testRucioConnection(rucio.host, rucio.port, rucio.useSsl, rucio.basePath, rucio.username, password); + this.logTestResult('Rucio connection', results.rucio); + } + // Test directories if provided if (directories) { results.directories = {}; diff --git a/server/modules/rucioManager.js b/server/modules/rucioManager.js new file mode 100644 index 0000000..16052e3 --- /dev/null +++ b/server/modules/rucioManager.js @@ -0,0 +1,608 @@ +/** + * RucioManager - lifecycle wrapper for a Rucio daemon instance + * + * Extends BaseClientManager. Rucio is modelled under the 'ed2k' networkType + * (its capability profile matches aMule: search + shared files + categories, + * no trackers, single-file), so the unified pipeline and the search UI light + * up with no frontend branching. See clientMeta.js → CLIENT_TYPES.rucio. + * + * Key structural difference from the other clients: Rucio addresses downloads + * by a signed integer id (positive = rucio, negative = eMule), while the rest + * of the app keys everything off the file hash. This manager owns the + * hash→id map (rebuilt every fetchData) and translates the hash-based control + * methods (pause/resume/stop/delete/category) into id-based REST calls. + */ + +'use strict'; + +const RucioClient = require('../lib/rucio/RucioClient'); +const BaseClientManager = require('../lib/BaseClientManager'); +const logger = require('../lib/logger'); +const { normalizeRucioDownload, normalizeRucioSharedFile } = require('../lib/downloadNormalizer'); + +// Pull the BLAKE3 (rucio) or MD4 (ed2k) hash out of a download link so search +// results can be keyed by hash like aMule's, and looked up again on download. +function hashFromLink(link) { + if (!link) return null; + const ed2k = link.match(/\|([a-fA-F0-9]{32})\|/); // ed2k://|file|name|size||/ + if (ed2k) return ed2k[1].toLowerCase(); + const rucio = link.match(/^rucio:([a-fA-F0-9]{64})/i); // rucio:?... + if (rucio) return rucio[1].toLowerCase(); + return null; +} + +// Normalize a category colour to the '#rrggbb' hex the daemon expects. The +// CategoryManager hands the per-client sync an aMule-style BGR integer (see its +// hexColorToAmule); the on-demand path hands us the stored hex string. Accept +// both. Returns undefined for an unset colour (so the field is omitted). +function toHexColor(color) { + if (color == null) return undefined; + if (typeof color === 'string') { + const c = color.trim(); + if (!c) return undefined; + return c.startsWith('#') ? c : `#${c}`; + } + if (typeof color === 'number') { + const r = color & 0xff; + const g = (color >> 8) & 0xff; + const b = (color >> 16) & 0xff; + return '#' + [r, g, b].map(x => x.toString(16).padStart(2, '0')).join(''); + } + return undefined; +} + +class RucioManager extends BaseClientManager { + constructor() { + super(); + this.lastDownloads = []; + this.lastSharedFiles = []; + // hash (lowercase) → signed integer download id, rebuilt each fetchData. + this.hashToId = new Map(); + // Search state. Rucio search is async (own id, polled); we mirror aMule's + // blocking search() surface and keep a hash→download_link map so a result + // can be queued by hash later. + this._searchInProgress = false; + this._lastSearch = { id: null, results: [], links: new Map() }; + this._version = null; + } + + // ── Lifecycle ──────────────────────────────────────────────────────── + + async initClient() { + if (this.connectionInProgress) { + this.log('Connection attempt already in progress, skipping...'); + return false; + } + if (!this._clientConfig || !this._clientConfig.enabled) return false; + if (!this._clientConfig.host) return false; + + this.connectionInProgress = true; + try { + if (this.client) { + await this.client.disconnect(); + this.client = null; + } + + const cfg = this._clientConfig; + this.log(`Connecting to Rucio (${cfg.host}:${cfg.port}${cfg.basePath || ''})...`); + + const client = new RucioClient({ + host: cfg.host, + port: cfg.port || 3003, + useSsl: cfg.useSsl || false, + basePath: cfg.basePath || '', + username: cfg.username || '', + password: cfg.password || '' + }); + + const result = await client.testConnection(); + if (!result.success) { + throw new Error(result.error || 'Connection test failed'); + } + + this.client = client; + this._version = result.version; + this._clearConnectionError(); + this.log(`Connected to Rucio ${result.version} successfully`); + this.clearReconnect(); + this._onConnectCallbacks.forEach(cb => cb()); + return true; + } catch (err) { + this.error('Failed to connect:', logger.errorDetail(err)); + this._setConnectionError(err); + this.client = null; + return false; + } finally { + this.connectionInProgress = false; + } + } + + async startConnection() { + if (!this._clientConfig || !this._clientConfig.enabled) return; + const connected = await this.initClient(); + if (!connected) { + this.scheduleReconnect(30000); + } + } + + isConnected() { + return !!this.client && this.client.isConnected(); + } + + // ── Search lock (mirrors aMule; Rucio runs one search at a time per UI) ── + + acquireSearchLock() { + if (this._searchInProgress) return false; + this._searchInProgress = true; + return true; + } + + releaseSearchLock() { + this._searchInProgress = false; + } + + isSearchInProgress() { + return this._searchInProgress; + } + + // ── Data fetch ─────────────────────────────────────────────────────── + + async fetchData(_categories = []) { + if (!this.client) { + return { downloads: [], sharedFiles: [] }; + } + + const triggerReconnect = (err) => { + this.error(`❌ fetchData failed: ${err.message} — reconnecting`); + const failed = this.client; + this.client = null; + this._setConnectionError(err); + if (failed && typeof failed.disconnect === 'function') { + Promise.resolve(failed.disconnect()).catch(() => {}); + } + this.scheduleReconnect(5000); + }; + + let rawDownloads, sharesResult, rucioCategories; + try { + [rawDownloads, sharesResult, rucioCategories] = await Promise.all([ + this.client.getDownloads(), + this.client.getShares({ limit: 1000 }), + this.client.getCategories().catch(() => []) + ]); + } catch (err) { + triggerReconnect(err); + // Reuse the last-known frame so the UI doesn't flash empty during reconnect. + return { downloads: this.lastDownloads, sharedFiles: this.lastSharedFiles }; + } + + // Resolve category_id → name from the daemon's own category list. + const catNameById = new Map((rucioCategories || []).map(c => [c.id, c.name])); + const resolveCategoryName = (id) => (id == null ? 'Default' : (catNameById.get(id) || 'Default')); + + // ── Downloads + hash→id map ────────────────────────────────────────── + this.hashToId.clear(); + const downloads = []; + for (const d of rawDownloads) { + if (d.root_hash) this.hashToId.set(String(d.root_hash).toLowerCase(), d.id); + downloads.push(normalizeRucioDownload(d, resolveCategoryName)); + } + + // ── Shared files ──────────────────────────────────────────────────── + const sharedFiles = (sharesResult.shares || []).map(normalizeRucioSharedFile); + + // Stamp instanceId on every item so the unified pipeline and batch + // operations (pause/resume/cancel/delete/category) can resolve this + // manager from the registry. Without it the UI reports "Client instance + // not found" on any action. + const instanceId = this.instanceId; + downloads.forEach(d => { d.instanceId = instanceId; }); + sharedFiles.forEach(f => { f.instanceId = instanceId; }); + + this.lastDownloads = downloads; + this.lastSharedFiles = sharedFiles; + return { downloads, sharedFiles }; + } + + // ── Stats / metrics / network status ───────────────────────────────── + + async getStats() { + if (!this.client) return {}; + try { + const [status, metrics, emule] = await Promise.all([ + this.client.getStatus(), + this.client.getMetrics(), + this.client.getEmuleStatus().catch(() => null) + ]); + return { status: status || {}, metrics: metrics || {}, emule }; + } catch (err) { + this.error('❌ Error fetching Rucio stats:', logger.errorDetail(err)); + return {}; + } + } + + extractMetrics(rawStats) { + const session = rawStats?.metrics?.session || {}; + const total = rawStats?.metrics?.total || {}; + return { + uploadSpeed: session.upload_speed || 0, + downloadSpeed: session.download_speed || 0, + uploadTotal: total.uploaded_bytes || 0, + downloadTotal: total.downloaded_bytes || 0 + }; + } + + /** + * Flat network status, as the footer's non-aMule (per-client badge) section + * expects: { status, text, connected }. Rucio is its own network, so it + * shows as a single "Rucio" badge rather than under the aMule ED2K/KAD + * headers. Derived from libp2p reachability: HighId = publicly reachable, + * LowId = reachable but behind NAT. + */ + getNetworkStatus(rawStats) { + const status = rawStats?.status || {}; + const peers = status.connected_peers || 0; + if (peers === 0) { + return { status: 'red', text: 'Disconnected', connected: false }; + } + const highId = status.class === 'HighId'; + return { + status: highId ? 'green' : 'yellow', + text: highId ? 'Connected' : 'Limited', + connected: true + }; + } + + // ── Download control (hash → id translation) ────────────────────────── + + _idForHash(hash) { + const id = this.hashToId.get(String(hash).toLowerCase()); + if (id === undefined) { + throw new Error(`Unknown download hash: ${hash}`); + } + return id; + } + + async pause(hash) { + if (!this.client) throw new Error('Rucio not connected'); + await this.client.pauseDownload(this._idForHash(hash)); + } + + async resume(hash) { + if (!this.client) throw new Error('Rucio not connected'); + await this.client.resumeDownload(this._idForHash(hash)); + } + + // Rucio has no separate stop; pausing preserves progress (stopReplacesPause + // is false in clientMeta, so the UI uses pause/resume — this is a fallback). + async stop(hash) { + return this.pause(hash); + } + + async renameFile(hash, newName) { + if (!this.client) throw new Error('Rucio not connected'); + await this.client.renameDownload(this._idForHash(hash), newName); + return { success: true }; + } + + /** + * Delete an item. + * Shared file → un-share via the API (the on-disk file is left intact; + * Rucio's removeSharedMustDeleteFiles capability is false). + * Active/terminal download → cancel then remove from the daemon's history. + */ + async deleteItem(hash, { deleteFiles, isShared, filePath } = {}) { + if (!this.client) throw new Error('Rucio not connected'); + + if (isShared) { + await this.client.unshare(String(hash).toLowerCase()); + this.trackDeletion(hash); + // Only hand a path back to the caller if the user explicitly asked to + // also wipe the file from disk. + return { success: true, pathsToDelete: deleteFiles && filePath ? [filePath] : [] }; + } + + const id = this._idForHash(hash); + // Cancel first (no-op/expected-fail if already terminal), then drop it from + // the list. Rucio never deletes the completed file from disk via the API. + await this.client.cancelDownload(id).catch(() => {}); + await this.client.removeDownload(id).catch(() => {}); + this.trackDeletion(hash); + return { success: true, pathsToDelete: [] }; + } + + async setCategoryOrLabel(hash, { categoryName } = {}) { + if (!this.client) throw new Error('Rucio not connected'); + const id = this._idForHash(hash); + const categoryId = await this.ensureAmuleCategoryId(categoryName); + await this.client.setDownloadCategory(id, categoryId); + return { success: true }; + } + + // ── Adding downloads ───────────────────────────────────────────────── + + /** + * Resolve an aMuTorrent category name to a Rucio category id, creating the + * category in the daemon if it doesn't exist yet. Returns null for the + * default/global category. Named to match the contract the search/add + * handlers call (they were written for aMule). Only the name is synced — + * category download paths live in different filesystems on each side. + */ + async ensureAmuleCategoryId(categoryName) { + if (!this.client) throw new Error('Rucio not connected'); + if (!categoryName || categoryName === 'Default') return null; + // Carry over the colour and download dir from the app's category so a + // category created on demand (adding/recategorizing) isn't name-only. + const appCat = require('../lib/CategoryManager').getByName?.(categoryName); + return this._resolveOrCreateCategoryId(categoryName, { color: appCat?.color, path: appCat?.path }); + } + + // Find a daemon category by name (case-insensitive), creating it with the + // given colour/dir if missing. Returns its id, or null for Default/none. + async _resolveOrCreateCategoryId(name, { color, path } = {}) { + if (!name || name === 'Default') return null; + const cats = await this.client.getCategories(); + const found = cats.find(c => c.name.toLowerCase() === String(name).toLowerCase()); + if (found) return found.id; + const created = await this._createCategoryRaw({ name, color, path }); + return created?.id ?? null; + } + + // Create with colour + download_dir, retrying without the dir if the daemon + // rejects it (e.g. the path doesn't exist on the daemon host) so a category + // is still created rather than failing outright. + async _createCategoryRaw({ name, color, path }) { + const body = { name, color: toHexColor(color), download_dir: path || undefined }; + try { + return await this.client.createCategory(body); + } catch (err) { + if (body.download_dir && /HTTP 400/.test(err.message)) { + this.warn(`Rucio rejected download_dir for category "${name}" (${err.message}); creating without it`); + return await this.client.createCategory({ name, color: body.color }); + } + throw err; + } + } + + async _updateCategoryRaw(id, { name, color, path }) { + const body = { name, color: toHexColor(color), download_dir: path || undefined }; + try { + return await this.client.updateCategory(id, body); + } catch (err) { + if (body.download_dir && /HTTP 400/.test(err.message)) { + this.warn(`Rucio rejected download_dir for category "${name}" (${err.message}); updating without it`); + return await this.client.updateCategory(id, { name, color: body.color }); + } + throw err; + } + } + + // categoryId comes from ensureAmuleCategoryId() (or the legacy `?? 0` in the + // handlers); normalize anything non-positive to null = global category. + _normalizeCategoryId(categoryId) { + return categoryId && categoryId > 0 ? categoryId : null; + } + + /** + * Queue a previously-found search result by its hash. Routes to the right + * endpoint by link scheme (rucio: → libp2p, ed2k:// → eMule). + */ + async addSearchResult(fileHash, categoryId = 0, username = null, fileInfoCallback = null) { + if (!this.client) throw new Error('Rucio not connected'); + const link = this._lastSearch.links.get(String(fileHash).toLowerCase()); + if (!link) throw new Error(`No search result link for hash ${fileHash}`); + + const category_id = this._normalizeCategoryId(categoryId); + if (link.toLowerCase().startsWith('ed2k://')) { + await this.client.addEd2k(link, { category_id }); + } else { + await this.client.addMagnet(link, { category_id }); + } + + let filename = 'Unknown'; + let size = null; + if (fileInfoCallback) { + try { + const info = await fileInfoCallback(fileHash); + filename = info?.filename || 'Unknown'; + size = info?.size || null; + } catch { /* use defaults */ } + } + this.trackDownload(fileHash, filename, size, username, categoryId ? String(categoryId) : null); + return true; + } + + /** + * Add an ed2k:// link (called by the ED2K-links handler). + */ + async addEd2kLink(link, categoryId = 0, username = null) { + if (!this.client) throw new Error('Rucio not connected'); + await this.client.addEd2k(link, { category_id: this._normalizeCategoryId(categoryId) }); + const md4 = (link.match(/\|([a-fA-F0-9]{32})\|/) || [])[1]; + if (md4) this.trackDownload(md4.toLowerCase(), 'Unknown', null, username, null); + return true; + } + + /** + * Add a magnet/link (called by the magnet handler). Accepts both rucio: and + * ed2k:// — routed by scheme. `opts` mirrors the BitTorrent shape + * { categoryName, savePath, priority, start, username }; only categoryName + * is meaningful for Rucio (dir is category-driven daemon-side). + */ + async addMagnet(link, { categoryName, username } = {}) { + if (!this.client) throw new Error('Rucio not connected'); + const category_id = await this.ensureAmuleCategoryId(categoryName); + if (String(link).toLowerCase().startsWith('ed2k://')) { + await this.client.addEd2k(link, { category_id }); + } else { + await this.client.addMagnet(link, { category_id }); + } + const hash = hashFromLink(link); + if (hash) this.trackDownload(hash, 'Unknown', null, username, categoryName || null); + return { success: true }; + } + + // ── Search ─────────────────────────────────────────────────────────── + + /** + * Run a search and wait (bounded) for results, mirroring aMule's blocking + * search() surface: returns { results, resultsLength }. `type`/`extension` + * are ignored — Rucio searches both its own network and eMule/Kad. + * @returns {Promise<{results: Array, resultsLength: number}>} + */ + async search(query, _type, _extension) { + if (!this.client) throw new Error('Rucio not connected'); + const keywords = String(query || '').trim().split(/\s+/).filter(Boolean); + if (keywords.length === 0) return { results: [], resultsLength: 0 }; + + const { id } = await this.client.startSearch(keywords, 'both'); + + // Poll until done or a ~60s budget elapses (Gossipsub ~30s, Kad2 ~60s). + const deadline = Date.now() + 62000; + let detail; + /* eslint-disable no-await-in-loop */ + do { + await new Promise(r => setTimeout(r, 2000)); + detail = await this.client.getSearch(id); + } while (detail.state === 'running' && Date.now() < deadline); + /* eslint-enable no-await-in-loop */ + + return this._mapSearchResults(id, detail); + } + + /** + * Return the most recent search's results (used for the "previous results" + * panel and as the file-info lookup during batch download). + */ + async getSearchResults() { + if (!this.client) throw new Error('Rucio not connected'); + if (this._lastSearch.id == null) return { results: [] }; + const detail = await this.client.getSearch(this._lastSearch.id).catch(() => null); + if (!detail) return { results: this._lastSearch.results }; + return this._mapSearchResults(this._lastSearch.id, detail); + } + + // Map Rucio search detail → the result row shape the frontend renders + // (fileHash/fileName/fileSize/sourceCount/ed2kLink), and refresh the + // hash→link map used by addSearchResult(). + _mapSearchResults(id, detail) { + const links = new Map(); + const results = []; + for (const r of (detail.results || [])) { + const link = r.download_link; + const fileHash = hashFromLink(link); + if (!fileHash) continue; // can't be queued without a hash; skip + links.set(fileHash, link); + results.push({ + fileHash, + fileName: r.name, + fileSize: r.size, + sourceCount: r.peer_count || 0, + ed2kLink: link, + source: r.source, + rating: 0, + categories: [] + }); + } + this._lastSearch = { id, results, links }; + return { results, resultsLength: results.length }; + } + + // ── Category CRUD (synced to the daemon: name, colour, download dir) ── + + async getCategories() { + if (!this.client) return null; + return this.client.getCategories(); + } + + // CategoryManager passes { name, path, color (aMule BGR int), comment, priority }. + async createCategory({ name, path, color } = {}) { + if (!this.client || !name) return null; + const created = await this._createCategoryRaw({ name, color, path }); + return created ? { id: created.id, name } : null; + } + + async deleteCategory({ id, name } = {}) { + if (!this.client) return; + let catId = id; + if (catId == null && name) { + const cats = await this.client.getCategories(); + catId = cats.find(c => c.name.toLowerCase() === String(name).toLowerCase())?.id; + } + if (catId != null) await this.client.deleteCategory(catId); + } + + // Update colour/dir (and name) of an existing category. `id` is the daemon + // category id we returned earlier as `amuleId`; fall back to lookup by name, + // and create it if it isn't in the daemon yet. + async editCategory({ id, name, path, color } = {}) { + if (!this.client || !name) return null; + let catId = id; + if (catId == null) { + const cats = await this.client.getCategories(); + catId = cats.find(c => c.name.toLowerCase() === String(name).toLowerCase())?.id; + } + if (catId == null) { + const created = await this._createCategoryRaw({ name, color, path }); + return created ? { success: true, verified: true, amuleId: created.id } : null; + } + await this._updateCategoryRaw(catId, { name, color, path }); + return { success: true, verified: true }; + } + + async renameCategory({ id, oldName, newName, path, color } = {}) { + if (!this.client || !newName) return null; + let catId = id; + if (catId == null && oldName) { + const cats = await this.client.getCategories(); + catId = cats.find(c => c.name.toLowerCase() === String(oldName).toLowerCase())?.id; + } + if (catId == null) return null; + await this._updateCategoryRaw(catId, { name: newName, color, path }); + return { success: true }; + } + + // CategoryManager keys off `amuleId` to record the per-instance category id; + // return the Rucio id under that name so later edits can target it. + async ensureCategoryExists({ name, path, color } = {}) { + const id = await this._resolveOrCreateCategoryId(name, { color, path }); + return id != null ? { name, amuleId: id } : null; + } + + async ensureCategoriesBatch(categories = []) { + const out = []; + for (const cat of categories) { + try { + // eslint-disable-next-line no-await-in-loop + const id = await this._resolveOrCreateCategoryId(cat.name, { color: cat.color, path: cat.path }); + if (id != null) out.push({ name: cat.name, amuleId: id }); + } catch (err) { + this.warn(`Failed to ensure category "${cat.name}": ${err.message}`); + } + } + return out; + } + + // ── Shutdown ───────────────────────────────────────────────────────── + + async shutdown() { + this.log('Shutting down...'); + this.clearReconnect(); + let waited = 0; + while (this.connectionInProgress && waited < 50) { + // eslint-disable-next-line no-await-in-loop + await new Promise(r => setTimeout(r, 100)); + waited++; + } + if (this.client) { + try { + await this.client.disconnect(); + } catch (err) { + this.error('Error during shutdown:', logger.errorDetail(err)); + } + this.client = null; + } + } +} + +module.exports = { RucioManager }; diff --git a/server/server.js b/server/server.js index 9d5ef74..6d76b31 100644 --- a/server/server.js +++ b/server/server.js @@ -27,6 +27,7 @@ const registry = require('./lib/ClientRegistry'); // Manager class registry — adding a new client type requires one entry here + clientMeta.js const MANAGER_CLASSES = { amule: require('./modules/amuleManager').AmuleManager, + rucio: require('./modules/rucioManager').RucioManager, rtorrent: require('./modules/rtorrentManager').RtorrentManager, qbittorrent: require('./modules/qbittorrentManager').QbittorrentManager, deluge: require('./modules/delugeManager').DelugeManager, diff --git a/static/components/common/ClientIcon.js b/static/components/common/ClientIcon.js index 5f568e6..3e67c23 100644 --- a/static/components/common/ClientIcon.js +++ b/static/components/common/ClientIcon.js @@ -50,6 +50,10 @@ const ClientIcon = ({ client, clientType, size = 16, float = false, className = defaultTitle = 'BitTorrent (Transmission)'; alt = 'Tr'; src = '/static/logo-transmission.svg'; + } else if (clientValue === 'rucio') { + defaultTitle = 'Rucio'; + alt = 'Rucio'; + src = '/static/logo-rucio.svg'; } else if (clientValue === 'amule' || clientValue === 'ed2k') { defaultTitle = 'ED2K (aMule)'; alt = 'ED2K'; diff --git a/static/components/dashboard/MobileSpeedWidget.js b/static/components/dashboard/MobileSpeedWidget.js index 52eaaa8..0d9bfa1 100644 --- a/static/components/dashboard/MobileSpeedWidget.js +++ b/static/components/dashboard/MobileSpeedWidget.js @@ -11,8 +11,8 @@ import React from 'https://esm.sh/react@18.2.0'; import { formatSpeed } from '../../utils/index.js'; import { loadChartJs } from '../../utils/chartLoader.js'; import { getStatusDotClass } from '../../utils/networkStatus.js'; +import { NETWORK_ORDER, NETWORK_NAMES } from '../../utils/constants.js'; import ClientIcon from '../common/ClientIcon.js'; -import { useClientFilter } from '../../contexts/ClientFilterContext.js'; import { useStaticData } from '../../contexts/StaticDataContext.js'; const { createElement: h, useEffect, useRef, useState } = React; @@ -111,26 +111,26 @@ const MobileSpeedWidget = ({ speedData, stats, theme }) => { } }); - // Get client connection status from context - const { ed2kConnected, bittorrentConnected } = useClientFilter(); const { instances } = useStaticData(); - // Show toggle when both aMule and BitTorrent clients are connected - const showBothClients = ed2kConnected && bittorrentConnected; + // Connected network types, in display order (aMule / Rucio / BitTorrent / ...) + const connectedNetworks = NETWORK_ORDER.filter(nt => + Object.values(instances).some(i => i.connected && i.networkType === nt) + ); + const connectedKey = connectedNetworks.join(','); + // Show the network toggle when more than one network is connected. + const showNetworkToggle = connectedNetworks.length > 1; - // State for selected network type (when both are available) - const [selectedNetwork, setSelectedNetwork] = useState('ed2k'); + // Selected network for the chart; defaults to the first connected one. + const [selectedNetwork, setSelectedNetwork] = useState(connectedNetworks[0] || 'ed2k'); - // Auto-select the available network when only one is connected + // Keep the selection valid as connections change. useEffect(() => { - if (!showBothClients) { - if (ed2kConnected) { - setSelectedNetwork('ed2k'); - } else if (bittorrentConnected) { - setSelectedNetwork('bittorrent'); - } + if (connectedNetworks.length && !connectedNetworks.includes(selectedNetwork)) { + setSelectedNetwork(connectedNetworks[0]); } - }, [showBothClients, ed2kConnected, bittorrentConnected]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [connectedKey, selectedNetwork]); // Load Chart.js library on mount useEffect(() => { @@ -331,24 +331,20 @@ const MobileSpeedWidget = ({ speedData, stats, theme }) => { } } - // Network toggle button component - const networkToggle = showBothClients && h('div', { + // Network toggle: one button per connected network (aMule / Rucio / BitTorrent) + const networkToggle = showNetworkToggle && h('div', { className: 'absolute top-2 left-2 z-10 flex rounded-md overflow-hidden border border-gray-300 dark:border-gray-600' }, - h('button', { - onClick: () => setSelectedNetwork('ed2k'), - className: `p-1.5 ${selectedNetwork === 'ed2k' - ? 'bg-blue-100 dark:bg-blue-900/50' - : 'bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700'}`, - title: 'Show aMule' - }, h(ClientIcon, { clientType: 'ed2k', size: 16 })), - h('button', { - onClick: () => setSelectedNetwork('bittorrent'), - className: `p-1.5 border-l border-gray-300 dark:border-gray-600 ${selectedNetwork === 'bittorrent' - ? 'bg-blue-100 dark:bg-blue-900/50' - : 'bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700'}`, - title: 'Show BitTorrent' - }, h(ClientIcon, { clientType: 'bittorrent', size: 16 })) + ...connectedNetworks.map((nt, i) => + h('button', { + key: nt, + onClick: () => setSelectedNetwork(nt), + className: `p-1.5 ${i > 0 ? 'border-l border-gray-300 dark:border-gray-600 ' : ''}${selectedNetwork === nt + ? 'bg-blue-100 dark:bg-blue-900/50' + : 'bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700'}`, + title: `Show ${NETWORK_NAMES[nt] || nt}` + }, h(ClientIcon, { clientType: nt, size: 16 })) + ) ); // Determine displayed speeds: hovered historical point or live current diff --git a/static/components/dashboard/QuickSearchWidget.js b/static/components/dashboard/QuickSearchWidget.js index b90d670..c3578ce 100644 --- a/static/components/dashboard/QuickSearchWidget.js +++ b/static/components/dashboard/QuickSearchWidget.js @@ -37,10 +37,20 @@ const QuickSearchWidget = ({ amuleInstances = [], showAmuleSelector = false }) => { - const { isNetworkTypeConnected, prowlarrEnabled } = useStaticData(); + const { isNetworkTypeConnected, prowlarrEnabled, instances } = useStaticData(); - // Check client connection and configuration status - const amuleConnected = isNetworkTypeConnected('ed2k'); + // Connected instances grouped by client type. ED2K Server / Kad are aMule + // search methods; Rucio is its own source (a single unified rucio + eMule/Kad + // query). Check by client TYPE rather than networkType, since Rucio shares the + // 'ed2k' networkType but must not enable the aMule-specific buttons on its own. + const byType = (t) => Object.entries(instances || {}) + .filter(([, i]) => i.connected && i.type === t) + .map(([id, i]) => ({ id, type: i.type, name: i.name || t, color: i.color, order: i.order })) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + const amuleInsts = byType('amule'); + const rucioInsts = byType('rucio'); + const amuleConnected = amuleInsts.length > 0; + const rucioConnected = rucioInsts.length > 0; const bittorrentConnected = isNetworkTypeConnected('bittorrent'); const handleSubmit = (e) => { @@ -51,15 +61,37 @@ const QuickSearchWidget = ({ }; // Search types with availability based on client status - // - ED2K and Kad require aMule to be connected + // - ED2K and Kad require an aMule instance + // - Rucio requires a Rucio instance // - Prowlarr requires prowlarr enabled AND any BitTorrent client connected const searchTypes = [ { value: 'global', label: 'ED2K Server', icon: '/static/logo-brax.png', disabled: !amuleConnected }, // { value: 'local', label: 'Local', icon: '/static/logo-brax.png', disabled: !amuleConnected }, // Hidden temporarily { value: 'kad', label: 'Kad', icon: '/static/logo-brax.png', disabled: !amuleConnected }, + { value: 'rucio', label: 'Rucio', icon: '/static/logo-rucio.svg', disabled: !rucioConnected }, { value: 'prowlarr', label: 'Prowlarr', icon: '/static/prowlarr.svg', disabled: !prowlarrEnabled || !bittorrentConnected } ]; + // Keep the targeted instance consistent with the selected source: a Rucio + // search must hit a Rucio instance, an ED2K/Kad search an aMule instance. + const amuleIds = amuleInsts.map(i => i.id).join(','); + const rucioIds = rucioInsts.map(i => i.id).join(','); + useEffect(() => { + // Only views that manage instance selection (e.g. SearchView) pass this; + // the dashboard quick-search omits it and lets the dispatcher resolve it. + if (typeof onSearchInstanceChange !== 'function') return; + if (searchType === 'rucio') { + if (rucioInsts.length && !rucioInsts.some(i => i.id === searchInstanceId)) { + onSearchInstanceChange(rucioInsts[0].id); + } + } else if (searchType === 'global' || searchType === 'kad') { + if (amuleInsts.length && !amuleInsts.some(i => i.id === searchInstanceId)) { + onSearchInstanceChange(amuleInsts[0].id); + } + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchType, searchInstanceId, amuleIds, rucioIds]); + const selectedTypeDisabled = searchTypes.find(t => t.value === searchType)?.disabled; // Auto-select first available search type when current selection is disabled @@ -114,15 +146,21 @@ const QuickSearchWidget = ({ className: 'flex-1 min-w-0' }), - // Instance selector (only when multi-aMule + ED2K/Kad type) - (searchType === 'global' || searchType === 'kad') && h(AmuleInstanceSelector, { - connectedInstances: amuleInstances, - selectedId: searchInstanceId, - onSelect: onSearchInstanceChange, - showSelector: showAmuleSelector, - variant: 'dropdown', - disabled: searchLocked - }), + // Instance selector — only when 2+ instances of the selected source. + // ED2K/Kad pick among aMule instances; Rucio among Rucio instances. + (() => { + const list = searchType === 'rucio' + ? rucioInsts + : (searchType === 'global' || searchType === 'kad') ? amuleInsts : []; + return typeof onSearchInstanceChange === 'function' && list.length > 1 && h(AmuleInstanceSelector, { + connectedInstances: list, + selectedId: searchInstanceId, + onSelect: onSearchInstanceChange, + showSelector: true, + variant: 'dropdown', + disabled: searchLocked + }); + })(), // Search button h(Button, { diff --git a/static/components/dashboard/StatsWidget.js b/static/components/dashboard/StatsWidget.js index cbf2b36..b914092 100644 --- a/static/components/dashboard/StatsWidget.js +++ b/static/components/dashboard/StatsWidget.js @@ -1,20 +1,27 @@ /** * StatsWidget Component * - * Displays statistics in a grid of stat cards for a configurable time range - * Can optionally show/hide peak speeds - * Shows per-network-type breakdown when both network types are active + * Displays statistics in a grid of stat cards for a configurable time range. + * Can optionally show/hide peak speeds. Shows a per-network-type breakdown + * when more than one network type is connected (aMule / Rucio / BitTorrent). */ import React from 'https://esm.sh/react@18.2.0'; import { StatCard } from '../common/index.js'; import { formatSpeed, formatBytes } from '../../utils/index.js'; +import { NETWORK_NAMES, NETWORK_ORDER } from '../../utils/constants.js'; import { useClientFilter } from '../../contexts/ClientFilterContext.js'; import { useLiveData } from '../../contexts/LiveDataContext.js'; import ClientIcon from '../common/ClientIcon.js'; const { createElement: h } = React; +const EMPTY_STATS = { + totalUploaded: 0, totalDownloaded: 0, + avgUploadSpeed: 0, avgDownloadSpeed: 0, + peakUploadSpeed: 0, peakDownloadSpeed: 0 +}; + /** * Loading placeholder for stat card * @param {boolean} compact - Use compact styling for mobile @@ -29,90 +36,48 @@ const StatCardSkeleton = ({ compact = false }) => { }; /** - * Helper component for displaying per-network-type breakdown values + * Per-network-type breakdown value. + * @param {Array<{type, value}>} entries - one per enabled network type * Desktop (xl+): icon value · icon value (inline with dot separator) - * Tablet/Mobile ( v }) => { +const ClientBreakdownValue = ({ entries, showClientIcons, compact = false, formatter = (v) => v }) => { if (!showClientIcons) { - // Only one client configured - show plain value - return h('span', null, formatter(ed2kValue + bittorrentValue)); + // Only one network configured/connected — show plain combined value + return h('span', null, formatter(entries.reduce((s, e) => s + e.value, 0))); } - // Compact mode (mobile dashboard): always two lines with smaller text + const line = (e, size) => h('span', { key: e.type, className: 'flex items-center gap-1' }, + h(ClientIcon, { clientType: e.type, size }), + h('span', null, formatter(e.value)) + ); + if (compact) { - return h('div', { className: 'flex flex-col gap-0.5 text-xs' }, - showEd2k && h('span', { key: 'ed2k', className: 'flex items-center gap-1' }, - h(ClientIcon, { clientType: 'ed2k', size: 12 }), - h('span', null, formatter(ed2kValue)) - ), - showBittorrent && h('span', { key: 'bittorrent', className: 'flex items-center gap-1' }, - h(ClientIcon, { clientType: 'bittorrent', size: 12 }), - h('span', null, formatter(bittorrentValue)) - ) - ); + return h('div', { className: 'flex flex-col gap-0.5 text-xs' }, entries.map(e => line(e, 12))); } - // Non-compact: render both layouts and use responsive classes to toggle - // Two rows layout (shown below xl) - const twoRowsLayout = h('div', { className: 'flex flex-col gap-0.5 xl:hidden' }, - showEd2k && h('span', { key: 'ed2k', className: 'flex items-center gap-1' }, - h(ClientIcon, { clientType: 'ed2k', size: 14 }), - h('span', null, formatter(ed2kValue)) - ), - showBittorrent && h('span', { key: 'bittorrent', className: 'flex items-center gap-1' }, - h(ClientIcon, { clientType: 'bittorrent', size: 14 }), - h('span', null, formatter(bittorrentValue)) - ) - ); + // Two rows layout (below xl) + const twoRowsLayout = h('div', { className: 'flex flex-col gap-0.5 xl:hidden' }, entries.map(e => line(e, 14))); - // Inline layout with dot separator (shown at xl+) + // Inline layout with dot separators (xl+) const inlineParts = []; - if (showEd2k) { - inlineParts.push( - h('span', { key: 'ed2k', className: 'flex items-center gap-1' }, - h(ClientIcon, { clientType: 'ed2k', size: 14 }), - h('span', null, formatter(ed2kValue)) - ) - ); - } - if (showEd2k && showBittorrent) { - inlineParts.push(h('span', { key: 'dot', className: 'text-gray-400 mx-1' }, '·')); - } - if (showBittorrent) { - inlineParts.push( - h('span', { key: 'bittorrent', className: 'flex items-center gap-1' }, - h(ClientIcon, { clientType: 'bittorrent', size: 14 }), - h('span', null, formatter(bittorrentValue)) - ) - ); - } + entries.forEach((e, i) => { + if (i > 0) inlineParts.push(h('span', { key: `dot-${e.type}`, className: 'text-gray-400 mx-1' }, '·')); + inlineParts.push(line(e, 14)); + }); const inlineLayout = h('span', { className: 'hidden xl:flex items-center gap-1 flex-wrap' }, inlineParts); return h(React.Fragment, null, twoRowsLayout, inlineLayout); }; /** - * Helper component for compact mode combined stats (total · avg speed per client) - * Shows: icon total · avg (one line per network if both connected, or single line if one) - * Shows ED2K vs BitTorrent (aggregated rtorrent + qbittorrent) + * Compact mode combined stats (total · avg speed per network). + * @param {Array<{type, total, avg}>} entries */ -const CompactCombinedValue = ({ ed2kTotal, bittorrentTotal, ed2kAvg, bittorrentAvg, showClientIcons, showEd2k, showBittorrent }) => { - const renderClientLine = (clientType, total, avg) => ( - h('span', { className: 'flex items-center gap-1' }, - showClientIcons && h(ClientIcon, { clientType, size: 12 }), - h('span', null, formatBytes(total)), - h('span', { className: 'text-gray-400' }, '·'), - h('span', null, formatSpeed(avg)) - ) - ); - +const CompactCombinedValue = ({ entries, showClientIcons }) => { if (!showClientIcons) { - // Single client - show combined values inline - const total = (showEd2k ? ed2kTotal : 0) + (showBittorrent ? bittorrentTotal : 0); - const avg = (showEd2k ? ed2kAvg : 0) + (showBittorrent ? bittorrentAvg : 0); + const total = entries.reduce((s, e) => s + e.total, 0); + const avg = entries.reduce((s, e) => s + e.avg, 0); return h('span', { className: 'flex items-center gap-1' }, h('span', null, formatBytes(total)), h('span', { className: 'text-gray-400' }, '·'), @@ -120,89 +85,70 @@ const CompactCombinedValue = ({ ed2kTotal, bittorrentTotal, ed2kAvg, bittorrentA ); } - // Both clients - show one line per client return h('div', { className: 'flex flex-col gap-0.5 text-xs' }, - showEd2k && renderClientLine('ed2k', ed2kTotal, ed2kAvg), - showBittorrent && renderClientLine('bittorrent', bittorrentTotal, bittorrentAvg) + entries.map(e => h('span', { key: e.type, className: 'flex items-center gap-1' }, + h(ClientIcon, { clientType: e.type, size: 12 }), + h('span', null, formatBytes(e.total)), + h('span', { className: 'text-gray-400' }, '·'), + h('span', null, formatSpeed(e.avg)) + )) ); }; /** * StatsWidget component - * @param {object} stats - Historical stats object with totals and speeds (includes ed2k/bittorrent sub-objects) + * @param {object} stats - Historical stats with per-networkType sub-objects (ed2k/rucio/bittorrent) * @param {boolean} showPeakSpeeds - Whether to show peak speed cards (default: true) * @param {boolean} compact - Use compact layout for mobile (default: false) * @param {string} timeRange - Time range label to display (default: '24h') */ const StatsWidget = ({ stats, showPeakSpeeds = true, compact = false, timeRange = '24h' }) => { - const { isEd2kEnabled, isBittorrentEnabled, ed2kConnected, bittorrentConnected } = useClientFilter(); + const { isNetworkTypeEnabled, ed2kConnected, bittorrentConnected, rucioConnected } = useClientFilter(); const { dataStats: liveStats } = useLiveData(); - // Show client icons if both network types are connected (regardless of user filter) - // bittorrentConnected = rtorrent OR qbittorrent - const showClientIcons = ed2kConnected && bittorrentConnected; + // Show per-network icons/breakdown when more than one network is connected. + const connectedCount = [ed2kConnected, rucioConnected, bittorrentConnected].filter(Boolean).length; + const showClientIcons = connectedCount > 1; - // Which clients to show (isXEnabled includes connection check) - const showEd2k = isEd2kEnabled; - const showBittorrent = isBittorrentEnabled; + // Network types to include (connected AND enabled in the filter), in order. + const enabledNetworks = NETWORK_ORDER.filter(nt => isNetworkTypeEnabled(nt)); - // Show loading skeleton if either data source is missing: - // - stats: historical data from API - // - liveStats: WebSocket data needed for client connection status + // Loading until both historical (stats) and live (connection status) arrive. const isLoading = !stats || !liveStats; - // Get per-network-type stats (with fallbacks) - const ed2kStats = stats?.ed2k || { totalUploaded: 0, totalDownloaded: 0, avgUploadSpeed: 0, avgDownloadSpeed: 0, peakUploadSpeed: 0, peakDownloadSpeed: 0 }; - const btStats = stats?.bittorrent || { totalUploaded: 0, totalDownloaded: 0, avgUploadSpeed: 0, avgDownloadSpeed: 0, peakUploadSpeed: 0, peakDownloadSpeed: 0 }; - - // Calculate displayed values based on filter - const getFilteredValue = (ed2kVal, btVal) => { - let total = 0; - if (showEd2k) total += ed2kVal; - if (showBittorrent) total += btVal; - return total; - }; + // Build per-network entries for a given stat field. + const entriesFor = (field) => enabledNetworks.map(nt => ({ + type: nt, + value: (stats?.[nt] || EMPTY_STATS)[field] + })); + // Compact: total + avg pair per network. + const compactEntries = (totalField, avgField) => enabledNetworks.map(nt => ({ + type: nt, + total: (stats?.[nt] || EMPTY_STATS)[totalField], + avg: (stats?.[nt] || EMPTY_STATS)[avgField] + })); // Compact mode: 2 combined cards (Downloaded, Uploaded) if (compact) { return h('div', { className: 'grid grid-cols-2 gap-2' }, - // Downloaded card (total · avg speed) - !isLoading - ? h(StatCard, { - label: `Downloaded · Avg (${timeRange})`, - value: h(CompactCombinedValue, { - ed2kTotal: ed2kStats.totalDownloaded, - bittorrentTotal: btStats.totalDownloaded, - ed2kAvg: ed2kStats.avgDownloadSpeed, - bittorrentAvg: btStats.avgDownloadSpeed, - showClientIcons, - showEd2k, - showBittorrent - }), - icon: 'download', - iconColor: 'text-blue-600 dark:text-blue-400', - compact - }) - : h(StatCardSkeleton, { compact }), - - // Uploaded card (total · avg speed) - !isLoading - ? h(StatCard, { - label: `Uploaded · Avg (${timeRange})`, - value: h(CompactCombinedValue, { - ed2kTotal: ed2kStats.totalUploaded, - bittorrentTotal: btStats.totalUploaded, - ed2kAvg: ed2kStats.avgUploadSpeed, - bittorrentAvg: btStats.avgUploadSpeed, - showClientIcons, - showEd2k, - showBittorrent - }), - icon: 'upload', - iconColor: 'text-green-600 dark:text-green-400', - compact - }) - : h(StatCardSkeleton, { compact }) + !isLoading + ? h(StatCard, { + label: `Downloaded · Avg (${timeRange})`, + value: h(CompactCombinedValue, { entries: compactEntries('totalDownloaded', 'avgDownloadSpeed'), showClientIcons }), + icon: 'download', + iconColor: 'text-blue-600 dark:text-blue-400', + compact + }) + : h(StatCardSkeleton, { compact }), + !isLoading + ? h(StatCard, { + label: `Uploaded · Avg (${timeRange})`, + value: h(CompactCombinedValue, { entries: compactEntries('totalUploaded', 'avgUploadSpeed'), showClientIcons }), + icon: 'upload', + iconColor: 'text-green-600 dark:text-green-400', + compact + }) + : h(StatCardSkeleton, { compact }) ); } @@ -211,120 +157,25 @@ const StatsWidget = ({ stats, showPeakSpeeds = true, compact = false, timeRange ? 'grid grid-cols-2 sm:grid-cols-3 gap-3' : 'grid grid-cols-2 sm:grid-cols-4 gap-3'; - return h('div', { className: gridClass }, - // Total Uploaded - !isLoading - ? h(StatCard, { - label: `Total Uploaded (${timeRange})`, - value: showClientIcons - ? h(ClientBreakdownValue, { - ed2kValue: ed2kStats.totalUploaded, - bittorrentValue: btStats.totalUploaded, - showClientIcons, - showEd2k, - showBittorrent, - formatter: formatBytes - }) - : formatBytes(getFilteredValue(ed2kStats.totalUploaded, btStats.totalUploaded)), - icon: 'upload', - iconColor: 'text-green-600 dark:text-green-400' - }) - : h(StatCardSkeleton), - - // Avg Upload Speed - !isLoading - ? h(StatCard, { - label: `Avg Upload Speed (${timeRange})`, - value: showClientIcons - ? h(ClientBreakdownValue, { - ed2kValue: ed2kStats.avgUploadSpeed, - bittorrentValue: btStats.avgUploadSpeed, - showClientIcons, - showEd2k, - showBittorrent, - formatter: formatSpeed - }) - : formatSpeed(getFilteredValue(ed2kStats.avgUploadSpeed, btStats.avgUploadSpeed)), - icon: 'trendingUp', - iconColor: 'text-green-600 dark:text-green-400' - }) - : h(StatCardSkeleton), - - // Peak Upload Speed (optional) - showPeakSpeeds && (!isLoading - ? h(StatCard, { - label: `Peak Upload Speed (${timeRange})`, - value: showClientIcons - ? h(ClientBreakdownValue, { - ed2kValue: ed2kStats.peakUploadSpeed, - bittorrentValue: btStats.peakUploadSpeed, - showClientIcons, - showEd2k, - showBittorrent, - formatter: formatSpeed - }) - : formatSpeed(getFilteredValue(ed2kStats.peakUploadSpeed, btStats.peakUploadSpeed)), - icon: 'zap', - iconColor: 'text-green-600 dark:text-green-400' - }) - : h(StatCardSkeleton)), + // Card factory: per-network breakdown when multiple networks, else plain sum. + const card = (label, field, formatter, icon, iconColor) => (!isLoading + ? h(StatCard, { + label: `${label} (${timeRange})`, + value: showClientIcons + ? h(ClientBreakdownValue, { entries: entriesFor(field), showClientIcons, formatter }) + : formatter(entriesFor(field).reduce((s, e) => s + e.value, 0)), + icon, + iconColor + }) + : h(StatCardSkeleton)); - // Total Downloaded - !isLoading - ? h(StatCard, { - label: `Total Downloaded (${timeRange})`, - value: showClientIcons - ? h(ClientBreakdownValue, { - ed2kValue: ed2kStats.totalDownloaded, - bittorrentValue: btStats.totalDownloaded, - showClientIcons, - showEd2k, - showBittorrent, - formatter: formatBytes - }) - : formatBytes(getFilteredValue(ed2kStats.totalDownloaded, btStats.totalDownloaded)), - icon: 'download', - iconColor: 'text-blue-600 dark:text-blue-400' - }) - : h(StatCardSkeleton), - - // Avg Download Speed - !isLoading - ? h(StatCard, { - label: `Avg Download Speed (${timeRange})`, - value: showClientIcons - ? h(ClientBreakdownValue, { - ed2kValue: ed2kStats.avgDownloadSpeed, - bittorrentValue: btStats.avgDownloadSpeed, - showClientIcons, - showEd2k, - showBittorrent, - formatter: formatSpeed - }) - : formatSpeed(getFilteredValue(ed2kStats.avgDownloadSpeed, btStats.avgDownloadSpeed)), - icon: 'trendingUp', - iconColor: 'text-blue-600 dark:text-blue-400' - }) - : h(StatCardSkeleton), - - // Peak Download Speed (optional) - showPeakSpeeds && (!isLoading - ? h(StatCard, { - label: `Peak Download Speed (${timeRange})`, - value: showClientIcons - ? h(ClientBreakdownValue, { - ed2kValue: ed2kStats.peakDownloadSpeed, - bittorrentValue: btStats.peakDownloadSpeed, - showClientIcons, - showEd2k, - showBittorrent, - formatter: formatSpeed - }) - : formatSpeed(getFilteredValue(ed2kStats.peakDownloadSpeed, btStats.peakDownloadSpeed)), - icon: 'zap', - iconColor: 'text-blue-600 dark:text-blue-400' - }) - : h(StatCardSkeleton)) + return h('div', { className: gridClass }, + card('Total Uploaded', 'totalUploaded', formatBytes, 'upload', 'text-green-600 dark:text-green-400'), + card('Avg Upload Speed', 'avgUploadSpeed', formatSpeed, 'trendingUp', 'text-green-600 dark:text-green-400'), + showPeakSpeeds && card('Peak Upload Speed', 'peakUploadSpeed', formatSpeed, 'zap', 'text-green-600 dark:text-green-400'), + card('Total Downloaded', 'totalDownloaded', formatBytes, 'download', 'text-blue-600 dark:text-blue-400'), + card('Avg Download Speed', 'avgDownloadSpeed', formatSpeed, 'trendingUp', 'text-blue-600 dark:text-blue-400'), + showPeakSpeeds && card('Peak Download Speed', 'peakDownloadSpeed', formatSpeed, 'zap', 'text-blue-600 dark:text-blue-400') ); }; diff --git a/static/components/layout/Footer.js b/static/components/layout/Footer.js index 4aca0f5..95f84d3 100644 --- a/static/components/layout/Footer.js +++ b/static/components/layout/Footer.js @@ -67,7 +67,7 @@ const renderBadge = (status, text, tooltip) => { const Footer = ({ currentView, onOpenAbout }) => { const { dataStats: stats } = useLiveData(); const { updateAvailable, latestVersion } = useVersion(); - const { ed2kConnected, bittorrentConnected } = useClientFilter(); + const { ed2kConnected, bittorrentConnected, isNetworkTypeEnabled } = useClientFilter(); const { instances, hasMultiInstance } = useStaticData(); if (!stats) { return h('footer', { className: 'hidden md:block bg-white dark:bg-gray-800 border-t border-gray-200 dark:border-gray-700 py-4 text-center text-sm text-gray-500 dark:text-gray-400' }, @@ -173,8 +173,9 @@ const Footer = ({ currentView, onOpenAbout }) => { .sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name)); for (const inst of connectedInsts) { - const isEnabled = inst.networkType === 'ed2k' ? ed2kConnected : bittorrentConnected; - if (!isEnabled) continue; + // Count this instance's speed if its network type is enabled in the filter + // (generic over ed2k / rucio / bittorrent / ...). + if (!isNetworkTypeEnabled(inst.networkType)) continue; const speeds = instanceSpeeds[inst.id]; if (speeds) { totalUploadSpeed += speeds.uploadSpeed || 0; @@ -214,8 +215,10 @@ const Footer = ({ currentView, onOpenAbout }) => { h('div', { className: 'flex justify-between items-center text-xs gap-4' }, // Left: Connection status (scrollable when many clients configured) h('div', { className: 'flex items-center gap-1.5 lg:gap-3 min-w-0 overflow-x-auto flex-nowrap', style: { scrollbarWidth: 'none' } }, - // aMule: ED2K and KAD status - ed2kConnected && h(React.Fragment, null, + // aMule: ED2K and KAD status (only when an actual aMule instance is + // connected — other ed2k-networkType clients, e.g. Rucio, render as + // their own badge below rather than under the ED2K/KAD headers) + amuleInsts.length > 0 && h(React.Fragment, null, h('div', { className: 'flex items-center gap-1.5 flex-shrink-0' }, h('span', { className: 'font-semibold text-gray-700 dark:text-gray-300' }, 'ED2K:'), renderBadge(ed2k.status, ed2k.text, ed2kTooltip || (ed2k.listenPort ? `Port ${ed2k.listenPort}` : null)), @@ -227,8 +230,8 @@ const Footer = ({ currentView, onOpenAbout }) => { renderBadge(kad.status, kad.text, kadTooltip || buildKadTooltip(kad)) ) ), - // Divider between aMule and BitTorrent status - ed2kConnected && bittorrentConnected && h('div', { className: 'w-px h-4 bg-gray-300 dark:bg-gray-600 flex-shrink-0' }), + // Divider between aMule and other per-client status badges + amuleInsts.length > 0 && btTypes.length > 0 && h('div', { className: 'w-px h-4 bg-gray-300 dark:bg-gray-600 flex-shrink-0' }), // BitTorrent client statuses (dynamic — works for rtorrent, qbittorrent, deluge, etc.) ...btTypes.map(type => { const { status: st, tooltip } = btStatusMap[type]; diff --git a/static/components/layout/Header.js b/static/components/layout/Header.js index 3b4832c..24b4564 100644 --- a/static/components/layout/Header.js +++ b/static/components/layout/Header.js @@ -8,6 +8,7 @@ import React from 'https://esm.sh/react@18.2.0'; import { Icon, Tooltip, VersionBadge, ClientIcon, Portal } from '../common/index.js'; +import { NETWORK_ORDER, NETWORK_NAMES } from '../../utils/constants.js'; import { useFontSize } from '../../contexts/FontSizeContext.js'; import { useClientFilter } from '../../contexts/ClientFilterContext.js'; import { useStaticData } from '../../contexts/StaticDataContext.js'; @@ -106,9 +107,14 @@ const UserMenu = ({ username, onOpenProfile, onLogout }) => { */ const Header = ({ theme, onToggleTheme, isLandscape, onNavigateHome, onOpenAbout, authEnabled = false, username, onLogout, isSso = false }) => { const { fontSize, fontSizeConfig, cycleFontSize } = useFontSize(); - const { isEd2kEnabled, isBittorrentEnabled, toggleNetworkType, toggleInstance, isInstanceEnabled } = useClientFilter(); + const { isNetworkTypeEnabled, toggleNetworkType, toggleInstance, isInstanceEnabled } = useClientFilter(); const { multipleClientsConnected, instances } = useStaticData(); + // Default chip colour per network type (instance.color overrides it). + const NET_COLOR = { ed2k: '#3b82f6', rucio: '#4f6ef7', bittorrent: '#f97316' }; + // Icon key per network type (ed2k → aMule's icon). + const netIcon = (nt) => (nt === 'ed2k' ? 'amule' : nt); + // Profile modal state const [profileOpen, setProfileOpen] = useState(false); @@ -124,18 +130,21 @@ const Header = ({ theme, onToggleTheme, isLandscape, onNavigateHome, onOpenAbout } }, []); - // Group connected instances by network type for per-instance filter chips + // Group connected instances by network type for per-instance filter chips. + // Dynamic over all known network types (aMule / Rucio / BitTorrent / ...). const instanceGroups = React.useMemo(() => { - const groups = { ed2k: [], bittorrent: [] }; + const groups = {}; + for (const nt of NETWORK_ORDER) groups[nt] = []; for (const [id, inst] of Object.entries(instances)) { if (inst.connected && groups[inst.networkType]) { groups[inst.networkType].push({ id, ...inst }); } } - groups.ed2k.sort((a, b) => a.order - b.order); - groups.bittorrent.sort((a, b) => a.order - b.order); + for (const nt of NETWORK_ORDER) groups[nt].sort((a, b) => a.order - b.order); return groups; }, [instances]); + // Network types that currently have at least one connected instance. + const activeGroups = NETWORK_ORDER.filter(nt => instanceGroups[nt]?.length > 0); const { headerHidden } = useStickyHeader(); @@ -155,102 +164,64 @@ const Header = ({ theme, onToggleTheme, isLandscape, onNavigateHome, onOpenAbout // Middle column: Client filter toggles (centered) - only show when multiple clients are connected h('div', { className: 'flex-1 flex justify-center' }, multipleClientsConnected && h(React.Fragment, null, - // Simple ED2K / BT network toggle buttons (small screens only, requires both network types) - instanceGroups.ed2k.length > 0 && instanceGroups.bittorrent.length > 0 && h('div', { className: 'flex items-center md:hidden' }, - // aMule/ED2K toggle - h(Tooltip, { - content: isEd2kEnabled ? 'Hide ED2K data' : 'Show ED2K data', - position: 'bottom', - showOnMobile: false - }, - h('button', { - onClick: () => toggleNetworkType('ed2k'), - className: `px-1.5 sm:px-2 py-0.5 sm:py-1 text-[10px] sm:text-xs font-bold rounded-l transition-all flex items-center gap-1 ${ - isEd2kEnabled - ? 'bg-blue-500 text-white' - : 'bg-gray-200 dark:bg-gray-700 text-gray-400 dark:text-gray-500' - }`, - title: isEd2kEnabled ? 'ED2K enabled' : 'ED2K disabled' + // Network toggle buttons (small screens) — one per connected network + // (aMule / Rucio / BitTorrent / ...), shown when 2+ are connected. + activeGroups.length >= 2 && h('div', { className: 'flex items-center md:hidden' }, + ...activeGroups.map((nt, i) => + h(Tooltip, { + key: nt, + content: isNetworkTypeEnabled(nt) ? `Hide ${NETWORK_NAMES[nt]} data` : `Show ${NETWORK_NAMES[nt]} data`, + position: 'bottom', + showOnMobile: false }, - h(ClientIcon, { client: 'amule', size: 14, title: '' }), - 'ED2K' - ) - ), - // rtorrent/BT toggle - h(Tooltip, { - content: isBittorrentEnabled ? 'Hide BT data' : 'Show BT data', - position: 'bottom', - showOnMobile: false - }, - h('button', { - onClick: () => toggleNetworkType('bittorrent'), - className: `px-1.5 sm:px-2 py-0.5 sm:py-1 text-[10px] sm:text-xs font-bold rounded-r transition-all flex items-center gap-1 ${ - isBittorrentEnabled - ? 'bg-orange-500 text-white' - : 'bg-gray-200 dark:bg-gray-700 text-gray-400 dark:text-gray-500' - }`, - title: isBittorrentEnabled ? 'BT enabled' : 'BT disabled' - }, - h(ClientIcon, { client: 'bittorrent', size: 14, title: '' }), - 'BT' - ) - ) - ), - // Per-instance filter chips (md+ viewports — scrollable when many instances) - h('div', { className: 'hidden md:flex items-center gap-1 overflow-x-auto max-w-[50vw] flex-nowrap', style: { scrollbarWidth: 'none' } }, - // ED2K group - instanceGroups?.ed2k?.length > 0 && h(React.Fragment, null, - h('button', { - onClick: () => toggleNetworkType('ed2k'), - className: `flex-shrink-0 p-0.5 rounded transition-all ${ - isEd2kEnabled - ? 'opacity-100' - : 'opacity-40 grayscale' - }`, - title: isEd2kEnabled ? 'Hide all ED2K' : 'Show all ED2K' - }, h(ClientIcon, { client: 'amule', size: 14, title: '' })), - ...instanceGroups.ed2k.map(inst => h('button', { - key: inst.id, - onClick: () => toggleInstance(inst.id), - className: `flex-shrink-0 px-1.5 py-0.5 text-[10px] font-medium rounded transition-all truncate max-w-[80px] ${ - isInstanceEnabled(inst.id) + onClick: () => toggleNetworkType(nt), + className: `px-1.5 sm:px-2 py-0.5 sm:py-1 text-[10px] sm:text-xs font-bold transition-all flex items-center gap-1${i === 0 ? ' rounded-l' : ''}${i === activeGroups.length - 1 ? ' rounded-r' : ''} ${ + isNetworkTypeEnabled(nt) ? 'text-white' : 'bg-gray-200 dark:bg-gray-700 text-gray-400 dark:text-gray-500' }`, - style: isInstanceEnabled(inst.id) ? { backgroundColor: inst.color || '#3b82f6', textShadow: '0 1px 2px rgba(0,0,0,0.3)' } : undefined, - title: `${inst.name} (${isInstanceEnabled(inst.id) ? 'visible' : 'hidden'})` - }, inst.name) + style: isNetworkTypeEnabled(nt) ? { backgroundColor: NET_COLOR[nt] || '#3b82f6' } : undefined, + title: `${NETWORK_NAMES[nt]} ${isNetworkTypeEnabled(nt) ? 'enabled' : 'disabled'}` + }, + h(ClientIcon, { client: netIcon(nt), size: 14, title: '' }), + NETWORK_NAMES[nt] + ) ) - ), - // Separator - instanceGroups?.ed2k?.length > 0 && instanceGroups?.bittorrent?.length > 0 && - h('div', { className: 'flex-shrink-0 w-px h-4 bg-gray-300 dark:bg-gray-600 mx-0.5' }), - // BT group - instanceGroups?.bittorrent?.length > 0 && h(React.Fragment, null, - h('button', { - onClick: () => toggleNetworkType('bittorrent'), - className: `flex-shrink-0 p-0.5 rounded transition-all ${ - isBittorrentEnabled - ? 'opacity-100' - : 'opacity-40 grayscale' - }`, - title: isBittorrentEnabled ? 'Hide all BT' : 'Show all BT' - }, h(ClientIcon, { client: 'bittorrent', size: 14, title: '' })), - ...instanceGroups.bittorrent.map(inst => + ) + ), + // Per-instance filter chips (md+ viewports — scrollable when many + // instances). One group per connected network, separators between. + h('div', { className: 'hidden md:flex items-center gap-1 overflow-x-auto max-w-[50vw] flex-nowrap', style: { scrollbarWidth: 'none' } }, + ...activeGroups.flatMap((nt, gi) => { + const chips = [ h('button', { - key: inst.id, - onClick: () => toggleInstance(inst.id), - className: `flex-shrink-0 px-1.5 py-0.5 text-[10px] font-medium rounded transition-all truncate max-w-[80px] ${ - isInstanceEnabled(inst.id) - ? 'text-white' - : 'bg-gray-200 dark:bg-gray-700 text-gray-400 dark:text-gray-500' + key: `nt-${nt}`, + onClick: () => toggleNetworkType(nt), + className: `flex-shrink-0 p-0.5 rounded transition-all ${ + isNetworkTypeEnabled(nt) ? 'opacity-100' : 'opacity-40 grayscale' }`, - style: isInstanceEnabled(inst.id) ? { backgroundColor: inst.color || '#f97316', textShadow: '0 1px 2px rgba(0,0,0,0.3)' } : undefined, - title: `${inst.name} (${isInstanceEnabled(inst.id) ? 'visible' : 'hidden'})` - }, inst.name) - ) - ) + title: isNetworkTypeEnabled(nt) ? `Hide all ${NETWORK_NAMES[nt]}` : `Show all ${NETWORK_NAMES[nt]}` + }, h(ClientIcon, { client: netIcon(nt), size: 14, title: '' })), + ...instanceGroups[nt].map(inst => + h('button', { + key: inst.id, + onClick: () => toggleInstance(inst.id), + className: `flex-shrink-0 px-1.5 py-0.5 text-[10px] font-medium rounded transition-all truncate max-w-[80px] ${ + isInstanceEnabled(inst.id) + ? 'text-white' + : 'bg-gray-200 dark:bg-gray-700 text-gray-400 dark:text-gray-500' + }`, + style: isInstanceEnabled(inst.id) ? { backgroundColor: inst.color || NET_COLOR[nt] || '#3b82f6', textShadow: '0 1px 2px rgba(0,0,0,0.3)' } : undefined, + title: `${inst.name} (${isInstanceEnabled(inst.id) ? 'visible' : 'hidden'})` + }, inst.name) + ) + ]; + if (gi > 0) { + chips.unshift(h('div', { key: `sep-${nt}`, className: 'flex-shrink-0 w-px h-4 bg-gray-300 dark:bg-gray-600 mx-0.5' })); + } + return chips; + }) ) ) ), diff --git a/static/components/settings/ClientInstanceModal.js b/static/components/settings/ClientInstanceModal.js index 79f4bd1..0cc89db 100644 --- a/static/components/settings/ClientInstanceModal.js +++ b/static/components/settings/ClientInstanceModal.js @@ -29,6 +29,14 @@ const CLIENT_FIELDS = { { field: 'password', label: 'Password', description: 'aMule EC password (set in aMule preferences)', placeholder: 'Enter aMule EC password', required: true, sensitive: true }, { field: 'sharedFilesReloadIntervalHours', label: 'Shared Files Auto-Reload Interval (hours)', description: 'Hours between automatic shared files reload (0 = disabled, default: 3). This makes aMule rescan shared directories periodically.', placeholder: '3', type: 'number', parseValue: v => parseInt(v) || 0, defaultValue: 3 } ], + rucio: [ + { field: 'host', label: 'Host', description: 'Rucio daemon host address', placeholder: '127.0.0.1', defaultValue: '127.0.0.1', required: true }, + { field: 'port', label: 'Port', description: 'Rucio daemon API port (default: 3003)', placeholder: '3003', defaultValue: 3003, type: 'number', required: true, parseValue: v => parseInt(v, 10) || 3003 }, + { field: 'basePath', label: 'Base Path (Optional)', description: 'Base path when the daemon is served under a sub-path behind a reverse proxy (e.g., /rucio)', placeholder: 'Leave empty if not using a reverse proxy' }, + { field: 'username', label: 'Username (Optional)', description: 'Only if the daemon is behind HTTP basic auth — Rucio itself has no authentication', placeholder: 'Leave empty if not required' }, + { field: 'password', label: 'Password (Optional)', description: 'Only if the daemon is behind HTTP basic auth', placeholder: 'Leave empty if not required', sensitive: true }, + { field: 'useSsl', label: 'Use SSL (HTTPS)', description: 'Connect to the Rucio daemon using HTTPS', toggle: true } + ], rtorrent: [ { field: 'mode', label: 'Connection Mode', description: 'HTTP: Connect via XML-RPC HTTP proxy (nginx/ruTorrent). SCGI: Connect directly to rTorrent via SCGI TCP. SCGI Socket: Connect via Unix domain socket.', select: true, options: [{ value: 'http', label: 'HTTP (XML-RPC proxy)' }, { value: 'scgi', label: 'SCGI (direct TCP)' }, { value: 'scgi-socket', label: 'SCGI (Unix socket)' }], defaultValue: 'http' }, { field: 'host', label: 'Host', description: 'rTorrent host address', placeholder: '127.0.0.1', defaultValue: '127.0.0.1', required: true, hideWhen: form => (form.mode || 'http') === 'scgi-socket' }, @@ -66,6 +74,7 @@ const CLIENT_FIELDS = { const TYPE_LABELS = { amule: 'aMule', + rucio: 'Rucio', rtorrent: 'rTorrent', qbittorrent: 'qBittorrent', deluge: 'Deluge', @@ -88,6 +97,7 @@ const INSTANCE_COLORS = ['#e74c3c', '#3498db', '#2ecc71', '#9b59b6', '#e67e22', const TYPE_DESCRIPTIONS = { amule: 'ED2K / Kademlia downloads', + rucio: 'P2P (libp2p + eMule/Kad)', rtorrent: 'BitTorrent via XML-RPC / SCGI', qbittorrent: 'BitTorrent via WebUI API', deluge: 'BitTorrent via WebUI JSON-RPC', diff --git a/static/components/views/HomeView.js b/static/components/views/HomeView.js index adbe620..6b5bb87 100644 --- a/static/components/views/HomeView.js +++ b/static/components/views/HomeView.js @@ -136,6 +136,7 @@ const HomeView = () => { // Get client chart configuration from hook const { isLoading: clientConfigLoading, + networks, showBothCharts, showSingleClient, singleNetworkType, @@ -206,61 +207,36 @@ const HomeView = () => { ) ), - // BOTH CLIENTS: aMule Speed Chart - showBothCharts && h('div', { className: 'col-span-6 md:col-span-3' }, - h(DashboardChartWidget, { - title: h('span', { className: 'flex items-center gap-2' }, - h(ClientIcon, { clientType: 'ed2k', size: 16 }), - 'aMule Speed (24h)' - ), - height: '200px' - }, - shouldRenderCharts && dashboardState.speedData - ? h(Suspense, { - fallback: h('div', { - className: 'h-full flex items-center justify-center' - }, - h(LoadingSpinner, { size: 'sm' }) - ) - }, - h(ClientSpeedChart, { - speedData: dashboardState.speedData, - networkType: 'ed2k', - theme, - historicalRange: '24h' - }) - ) - : h('div', { className: 'h-full' }) - ) - ), - - // BOTH CLIENTS: BitTorrent Speed Chart (aggregated rtorrent + qbittorrent) - showBothCharts && h('div', { className: 'col-span-6 md:col-span-3' }, - h(DashboardChartWidget, { - title: h('span', { className: 'flex items-center gap-2' }, - h(ClientIcon, { clientType: 'bittorrent', size: 16 }), - 'BitTorrent Speed (24h)' - ), - height: '200px' - }, - shouldRenderCharts && dashboardState.speedData - ? h(Suspense, { - fallback: h('div', { - className: 'h-full flex items-center justify-center' + // BOTH/MULTI NETWORK: one speed chart per connected network + // (aMule / Rucio / BitTorrent). Half-width tiles wrap as needed. + ...(showBothCharts ? networks.map(n => + h('div', { key: `home-speed-${n.type}`, className: 'col-span-6 md:col-span-3' }, + h(DashboardChartWidget, { + title: h('span', { className: 'flex items-center gap-2' }, + h(ClientIcon, { clientType: n.type, size: 16 }), + `${n.name} Speed (24h)` + ), + height: '200px' + }, + shouldRenderCharts && dashboardState.speedData + ? h(Suspense, { + fallback: h('div', { + className: 'h-full flex items-center justify-center' + }, + h(LoadingSpinner, { size: 'sm' }) + ) }, - h(LoadingSpinner, { size: 'sm' }) + h(ClientSpeedChart, { + speedData: dashboardState.speedData, + networkType: n.type, + theme, + historicalRange: '24h' + }) ) - }, - h(ClientSpeedChart, { - speedData: dashboardState.speedData, - networkType: 'bittorrent', - theme, - historicalRange: '24h' - }) - ) - : h('div', { className: 'h-full' }) + : h('div', { className: 'h-full' }) + ) ) - ), + ) : []), // SINGLE CLIENT: Speed Chart showSingleClient && h('div', { className: 'col-span-6 md:col-span-3' }, diff --git a/static/components/views/SetupWizardView.js b/static/components/views/SetupWizardView.js index 87f871b..d671aec 100644 --- a/static/components/views/SetupWizardView.js +++ b/static/components/views/SetupWizardView.js @@ -54,7 +54,7 @@ const SetupWizardView = ({ onComplete }) => { const debouncedAuthPassword = useDebouncedValue(formData?.server?.auth?.password || ''); const debouncedPasswordConfirm = useDebouncedValue(passwordConfirm); - const steps = ['Welcome', 'Security', 'aMule', 'BitTorrent', 'Directories', 'Integrations', 'Review']; + const steps = ['Welcome', 'Security', 'aMule & Rucio', 'BitTorrent', 'Directories', 'Integrations', 'Review']; // Load defaults on mount useEffect(() => { @@ -80,6 +80,11 @@ const SetupWizardView = ({ onComplete }) => { // Disabled by default unless explicitly enabled via env var enabled: meta?.fromEnv?.amuleEnabled ? defaults.amule.enabled : false }, + rucio: { + ...defaults.rucio, + // Disabled by default unless explicitly enabled via env var + enabled: meta?.fromEnv?.rucioEnabled ? defaults.rucio.enabled : false + }, rtorrent: { mode: 'http', ...defaults.rtorrent, @@ -188,16 +193,19 @@ const SetupWizardView = ({ onComplete }) => { // Validate aMule step (step 2) - only if enabled if (currentStep === 2) { + const errors = []; if (formData.amule.enabled !== false) { - const errors = []; - if (!formData.amule.host) errors.push('Host is required'); - if (!formData.amule.port) errors.push('Port is required'); - if (!formData.amule.password && !meta?.fromEnv.amulePassword) errors.push('Password is required'); - - if (errors.length > 0) { - setStepValidationError(errors.join(', ')); - return; - } + if (!formData.amule.host) errors.push('aMule host is required'); + if (!formData.amule.port) errors.push('aMule port is required'); + if (!formData.amule.password && !meta?.fromEnv.amulePassword) errors.push('aMule password is required'); + } + if (formData.rucio?.enabled) { + if (!formData.rucio.host) errors.push('Rucio host is required'); + if (!formData.rucio.port) errors.push('Rucio port is required'); + } + if (errors.length > 0) { + setStepValidationError(errors.join(', ')); + return; } setStepValidationError(null); } @@ -240,8 +248,8 @@ const SetupWizardView = ({ onComplete }) => { } // Cross-validation: at least one client must be enabled - if (formData.amule.enabled === false && !formData.rtorrent.enabled && !formData.qbittorrent?.enabled && !formData.deluge?.enabled && !formData.transmission?.enabled) { - setStepValidationError('At least one download client (aMule, rTorrent, qBittorrent, Deluge, or Transmission) must be enabled'); + if (formData.amule.enabled === false && !formData.rucio?.enabled && !formData.rtorrent.enabled && !formData.qbittorrent?.enabled && !formData.deluge?.enabled && !formData.transmission?.enabled) { + setStepValidationError('At least one download client (aMule, Rucio, rTorrent, qBittorrent, Deluge, or Transmission) must be enabled'); return; } setStepValidationError(null); @@ -308,7 +316,7 @@ const SetupWizardView = ({ onComplete }) => { setIsTesting(true); try { if (currentStep === 2) { - // Test aMule (step 2) - only if enabled + // Test aMule and/or Rucio (step 2) - only the enabled ones if (formData.amule.enabled !== false) { const data = await testConfig({ amule: formData.amule }); if (data?.results?.amule) { @@ -318,6 +326,15 @@ const SetupWizardView = ({ onComplete }) => { })); } } + if (formData.rucio?.enabled) { + const data = await testConfig({ rucio: formData.rucio }); + if (data?.results?.rucio) { + setClientTestResults(prev => ({ + ...prev, + rucio: { ...data.results.rucio, _label: 'Rucio Connection' } + })); + } + } } else if (currentStep === 3) { // Test BitTorrent clients (step 3) - test enabled clients const testPayload = {}; @@ -506,6 +523,12 @@ const SetupWizardView = ({ onComplete }) => { if (meta?.fromEnv.amuleHost) entry.source = 'env'; clients.push(entry); } + if (formData.rucio?.enabled) { + const { enabled, ...fields } = formData.rucio; + const entry = { type: 'rucio', enabled, ...fields }; + if (meta?.fromEnv.rucioHost) entry.source = 'env'; + clients.push(entry); + } if (formData.rtorrent.enabled) { const { enabled, ...fields } = formData.rtorrent; const entry = { type: 'rtorrent', enabled, ...fields }; @@ -730,8 +753,8 @@ const SetupWizardView = ({ onComplete }) => { }; const AmuleStep = () => h('div', {}, - h('h2', { className: 'text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2' }, 'aMule Integration'), - h('p', { className: 'text-gray-600 dark:text-gray-400 mb-6' }, 'Optionally configure connection to your aMule daemon for ed2k/Kademlia downloads'), + h('h2', { className: 'text-2xl font-bold text-gray-900 dark:text-gray-100 mb-2' }, 'aMule & Rucio'), + h('p', { className: 'text-gray-600 dark:text-gray-400 mb-6' }, 'Optionally configure connection to your aMule daemon (ed2k/Kademlia) and/or a Rucio daemon (P2P + eMule/Kad)'), h(EnableToggle, { label: 'Enable aMule Integration', @@ -814,6 +837,71 @@ const SetupWizardView = ({ onComplete }) => { h('p', {}, 'aMule integration is optional. You can skip this step if you only want to use other clients.') ), + // ── Rucio ─────────────────────────────────────────────────────────── + h('div', { className: 'mt-8 pt-6 border-t border-gray-200 dark:border-gray-700' }, + h('h3', { className: 'text-lg font-semibold text-gray-900 dark:text-gray-100 mb-1' }, 'Rucio'), + h('p', { className: 'text-sm text-gray-600 dark:text-gray-400 mb-4' }, + 'Optionally connect to a Rucio daemon (P2P libp2p network + eMule/Kad compatibility).'), + + h(EnableToggle, { + label: 'Enable Rucio Integration', + description: 'Connect to a Rucio daemon for managing downloads, shares and search', + enabled: formData.rucio?.enabled === true, + onChange: (enabled) => updateField('rucio', 'enabled', enabled) + }), + + formData.rucio?.enabled && h('div', { className: 'mt-6 space-y-4' }, + h(ConfigField, { + label: 'Host', description: 'Rucio daemon host address', + value: formData.rucio.host, onChange: (v) => updateField('rucio', 'host', v), + placeholder: '127.0.0.1', required: true, fromEnv: meta?.fromEnv.rucioHost + }), + h(ConfigField, { + label: 'Port', description: 'Rucio daemon API port (default: 3003)', + value: formData.rucio.port, onChange: (v) => updateField('rucio', 'port', parseInt(v, 10) || 3003), + type: 'number', placeholder: '3003', required: true, fromEnv: meta?.fromEnv.rucioPort + }), + h(ConfigField, { + label: 'Base Path (Optional)', + description: 'Base path when the daemon is served under a sub-path behind a reverse proxy (e.g., /rucio)', + value: formData.rucio.basePath || '', onChange: (v) => updateField('rucio', 'basePath', v), + placeholder: 'Leave empty if not using a reverse proxy' + }), + h(ConfigField, { + label: 'Username (Optional)', + description: 'Only if the daemon is behind HTTP basic auth — Rucio itself has no authentication', + value: formData.rucio.username || '', onChange: (v) => updateField('rucio', 'username', v), + placeholder: 'Leave empty if not required' + }), + h(ConfigField, { + label: 'Password (Optional)', description: 'Only if the daemon is behind HTTP basic auth', + value: formData.rucio.password || '', onChange: (v) => updateField('rucio', 'password', v) + }, + h(PasswordField, { + value: formData.rucio.password || '', onChange: (v) => updateField('rucio', 'password', v), + placeholder: 'Leave empty if not required' + }) + ), + h(EnableToggle, { + label: 'Use SSL (HTTPS)', description: 'Connect to the Rucio daemon using HTTPS', + enabled: formData.rucio?.useSsl || false, + onChange: (enabled) => updateField('rucio', 'useSsl', enabled) + }), + + h('div', { className: 'mt-6' }, + h(TestButton, { + onClick: handleTestCurrentStep, loading: isTesting, + disabled: !formData.rucio.host || !formData.rucio.port + }, 'Test Rucio Connection') + ), + + clientTestResults.rucio && h(TestResultIndicator, { + result: clientTestResults.rucio, + label: 'Rucio Connection Test' + }) + ) + ), + // Validation error message stepValidationError && currentStep === 2 && h(AlertBox, { type: 'error', className: 'mt-4' }, h('p', {}, stepValidationError) @@ -1494,6 +1582,15 @@ const SetupWizardView = ({ onComplete }) => { : h('p', { className: 'text-sm text-gray-500 dark:text-gray-500 italic' }, 'Disabled') ), + // Rucio + formData.rucio?.enabled && h('div', { className: 'bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700' }, + h('h3', { className: 'font-semibold text-gray-900 dark:text-gray-100 mb-2' }, 'Rucio Connection'), + h('p', { className: 'text-sm text-gray-600 dark:text-gray-400' }, `Host: ${formData.rucio.host}`), + h('p', { className: 'text-sm text-gray-600 dark:text-gray-400' }, `Port: ${formData.rucio.port}`), + formData.rucio.basePath && h('p', { className: 'text-sm text-gray-600 dark:text-gray-400' }, `Base path: ${formData.rucio.basePath}`), + formData.rucio.useSsl && h('p', { className: 'text-sm text-gray-600 dark:text-gray-400' }, 'SSL: enabled') + ), + // rtorrent formData.rtorrent.enabled && h('div', { className: 'bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700' }, h('h3', { className: 'font-semibold text-gray-900 dark:text-gray-100 mb-2' }, 'rTorrent Connection'), diff --git a/static/components/views/StatisticsView.js b/static/components/views/StatisticsView.js index 36456a8..b2c2aa4 100644 --- a/static/components/views/StatisticsView.js +++ b/static/components/views/StatisticsView.js @@ -55,6 +55,7 @@ const StatisticsView = () => { // Get client chart configuration from hook const { + networks, ed2kConnected, isEd2kEnabled, showBothCharts, @@ -241,35 +242,28 @@ const StatisticsView = () => { h('p', { className: 'text-sm text-gray-500 dark:text-gray-400 mt-2' }, 'Loading historical data...') ), - // Charts content (always rendered, dimmed when loading) + // Charts content (always rendered, dimmed when loading). One chart per + // connected network (aMule / Rucio / BitTorrent / ...). With 2+ networks + // a Speed/Transferred toggle keeps it compact; with one, both are shown. h('div', { className: `space-y-2 sm:space-y-3${loadingHistory ? ' opacity-50 pointer-events-none' : ''}` }, - // BOTH CLIENTS: Show toggle-controlled charts showBothCharts && h(React.Fragment, null, - // Speed charts (when chartMode === 'speed') - chartMode === 'speed' && h(React.Fragment, null, + chartMode === 'speed' && networks.map(n => h(DashboardChartWidget, { - title: chartTitle('aMule Speed', 'ed2k'), + key: `speed-${n.type}`, + title: chartTitle(`${n.name} Speed`, n.type), height: '225px' - }, renderSpeedChart('ed2k')), - h(DashboardChartWidget, { - title: chartTitle('BitTorrent Speed', 'bittorrent'), - height: '225px' - }, renderSpeedChart('bittorrent')) + }, renderSpeedChart(n.type)) ), - // Transfer charts (when chartMode === 'transfer') - chartMode === 'transfer' && h(React.Fragment, null, - h(DashboardChartWidget, { - title: chartTitle('aMule Data Transferred', 'ed2k'), - height: '225px' - }, renderTransferChart('ed2k')), + chartMode === 'transfer' && networks.map(n => h(DashboardChartWidget, { - title: chartTitle('BitTorrent Data Transferred', 'bittorrent'), + key: `transfer-${n.type}`, + title: chartTitle(`${n.name} Data Transferred`, n.type), height: '225px' - }, renderTransferChart('bittorrent')) + }, renderTransferChart(n.type)) ) ), - // SINGLE CLIENT: Show both chart types (no toggle needed) + // SINGLE NETWORK: Show both chart types (no toggle needed) showSingleClient && h(React.Fragment, null, h(DashboardChartWidget, { title: chartTitle(`${singleNetworkName} Speed`, singleNetworkType), diff --git a/static/contexts/ActionsContext.js b/static/contexts/ActionsContext.js index 11b49cc..7f35817 100644 --- a/static/contexts/ActionsContext.js +++ b/static/contexts/ActionsContext.js @@ -34,9 +34,24 @@ const useWebSocketActions = () => { setSearchLocked, setSearchResults, setSearchPreviousResults, - setSearchError + setSearchError, + setSearchInstanceId } = useSearch(); - const { setDataDownloadedFiles, lastEd2kWasServerListRef } = useStaticData(); + const { setDataDownloadedFiles, lastEd2kWasServerListRef, instances } = useStaticData(); + + // Resolve which instance a search/download should target, by the selected + // source's client TYPE: 'rucio' → a Rucio instance, ED2K/Kad → an aMule + // instance. Keeps the current selection if it already matches the type, + // otherwise falls back to the first connected instance of that type. Returns + // null when none is connected (the handler then surfaces a clear error). + const resolveSearchInstanceId = () => { + const wantType = searchType === 'rucio' ? 'rucio' : 'amule'; + const ofType = Object.entries(instances || {}) + .filter(([, i]) => i.connected && i.type === wantType) + .map(([id]) => id); + if (ofType.includes(searchInstanceId)) return searchInstanceId; + return ofType[0] || null; + }; // ============================================================================ // CATEGORY MANAGEMENT @@ -155,12 +170,18 @@ const useWebSocketActions = () => { return; } + // Target the instance matching the selected source's client type, and + // persist it so the follow-up batch download routes to the same instance. + const targetInstanceId = resolveSearchInstanceId(); + if (targetInstanceId && targetInstanceId !== searchInstanceId) { + setSearchInstanceId(targetInstanceId); + } sendMessage({ action: 'search', query: searchQuery, type: searchType, extension: null, - ...(searchInstanceId && { instanceId: searchInstanceId }) + ...(targetInstanceId && { instanceId: targetInstanceId }) }); }; diff --git a/static/contexts/ClientFilterContext.js b/static/contexts/ClientFilterContext.js index e7b3f65..3ee4a24 100644 --- a/static/contexts/ClientFilterContext.js +++ b/static/contexts/ClientFilterContext.js @@ -138,16 +138,14 @@ export const ClientFilterProvider = ({ children }) => { // Disable all of this type for (const id of typeIds) next.add(id); - // Safety: don't disable ALL connected instances — enable the other type + // Safety: never disable ALL connected instances. If this toggle would, + // keep this network type enabled (no-op). Works for any number of + // network types, not just two. const allConnectedIds = Object.entries(instances) .filter(([, inst]) => inst.connected) .map(([id]) => id); if (allConnectedIds.every(id => next.has(id))) { - const otherType = networkType === 'ed2k' ? 'bittorrent' : 'ed2k'; - const otherIds = Object.entries(instances) - .filter(([, inst]) => inst.networkType === otherType && inst.connected) - .map(([id]) => id); - for (const id of otherIds) next.delete(id); + return prev; } } else { // Enable all of this type @@ -195,18 +193,20 @@ export const ClientFilterProvider = ({ children }) => { }); }, [disabledInstances]); - // Derived: is network type enabled (any connected instance of that type is not disabled) - const isEd2kEnabled = useMemo(() => { + // Derived: is a network type enabled (any connected instance of that type is + // not disabled). Generic over network type so it works for ed2k, rucio, + // bittorrent, or any future network. + const isNetworkTypeEnabled = useCallback((networkType) => { return Object.entries(instances).some(([id, inst]) => - inst.networkType === 'ed2k' && inst.connected && !disabledInstances.has(id) + inst.networkType === networkType && inst.connected && !disabledInstances.has(id) ); }, [instances, disabledInstances]); - const isBittorrentEnabled = useMemo(() => { - return Object.entries(instances).some(([id, inst]) => - inst.networkType === 'bittorrent' && inst.connected && !disabledInstances.has(id) - ); - }, [instances, disabledInstances]); + // Back-compat convenience booleans (still consumed across the UI). + const isEd2kEnabled = useMemo(() => isNetworkTypeEnabled('ed2k'), [isNetworkTypeEnabled]); + const isBittorrentEnabled = useMemo(() => isNetworkTypeEnabled('bittorrent'), [isNetworkTypeEnabled]); + const isRucioEnabled = useMemo(() => isNetworkTypeEnabled('rucio'), [isNetworkTypeEnabled]); + const rucioConnected = isNetworkTypeConnected('rucio'); // Memoize context value const value = useMemo(() => ({ @@ -222,14 +222,18 @@ export const ClientFilterProvider = ({ children }) => { // Connection state (pure, not affected by filter preference) ed2kConnected, bittorrentConnected, + rucioConnected, // Convenience booleans: user preference AND connected isEd2kEnabled, isBittorrentEnabled, - allClientsEnabled: isEd2kEnabled && isBittorrentEnabled + isRucioEnabled, + isNetworkTypeEnabled, + allClientsEnabled: isEd2kEnabled && isBittorrentEnabled && (rucioConnected ? isRucioEnabled : true) }), [toggleNetworkType, filterByEnabledClients, disabledInstances, toggleInstance, isInstanceEnabled, - ed2kConnected, bittorrentConnected, isEd2kEnabled, isBittorrentEnabled]); + ed2kConnected, bittorrentConnected, rucioConnected, + isEd2kEnabled, isBittorrentEnabled, isRucioEnabled, isNetworkTypeEnabled]); return h(ClientFilterContext.Provider, { value }, children); }; diff --git a/static/hooks/useClientChartConfig.js b/static/hooks/useClientChartConfig.js index 87efc14..196136f 100644 --- a/static/hooks/useClientChartConfig.js +++ b/static/hooks/useClientChartConfig.js @@ -12,6 +12,7 @@ import React from 'https://esm.sh/react@18.2.0'; import { useClientFilter } from '../contexts/ClientFilterContext.js'; import { useLiveData } from '../contexts/LiveDataContext.js'; +import { NETWORK_NAMES, NETWORK_ORDER } from '../utils/constants.js'; const { useState, useEffect } = React; @@ -31,20 +32,25 @@ const { useState, useEffect } = React; * - shouldRenderCharts: boolean - deferred rendering state for performance */ export const useClientChartConfig = () => { - const { isEd2kEnabled, isBittorrentEnabled, ed2kConnected, bittorrentConnected } = useClientFilter(); + const { isNetworkTypeEnabled, isEd2kEnabled, isBittorrentEnabled, ed2kConnected, bittorrentConnected } = useClientFilter(); const { dataStats } = useLiveData(); // Check if we're still waiting for WebSocket data const isLoading = !dataStats; - // Determine chart display mode (isXEnabled includes connection check) - const showBothCharts = isEd2kEnabled && isBittorrentEnabled; - const showSingleAmule = isEd2kEnabled && !isBittorrentEnabled; - const showSingleBittorrent = isBittorrentEnabled && !isEd2kEnabled; - const showSingleClient = showSingleAmule || showSingleBittorrent; - // Network type for chart data keys (e.g. 'ed2kUploadSpeed', 'bittorrentUploadSpeed') - const singleNetworkType = showSingleAmule ? 'ed2k' : 'bittorrent'; - const singleNetworkName = showSingleAmule ? 'aMule' : 'BitTorrent'; + // Networks to chart: every connected+enabled network type, in display order. + // Each chart reads keys like `${type}UploadSpeed` (built per-networkType by + // the metrics API), so this scales to any number of networks (ed2k, rucio, + // bittorrent, ...). Views map over this list rather than branching on 2. + const networks = NETWORK_ORDER + .filter(t => isNetworkTypeEnabled(t)) + .map(t => ({ type: t, name: NETWORK_NAMES[t] || t })); + + // Back-compat fields (still consumed by some views) — derived from the list. + const showBothCharts = networks.length > 1; + const showSingleClient = networks.length === 1; + const singleNetworkType = networks[0]?.type || 'ed2k'; + const singleNetworkName = networks[0]?.name || NETWORK_NAMES.ed2k; // Defer chart rendering until after initial paint for better performance const [shouldRenderCharts, setShouldRenderCharts] = useState(false); @@ -58,6 +64,7 @@ export const useClientChartConfig = () => { return { isLoading, + networks, ed2kConnected, bittorrentConnected, isEd2kEnabled, diff --git a/static/logo-rucio.svg b/static/logo-rucio.svg new file mode 100644 index 0000000..a84caf9 --- /dev/null +++ b/static/logo-rucio.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/static/utils/constants.js b/static/utils/constants.js index 22d783e..b7911ff 100644 --- a/static/utils/constants.js +++ b/static/utils/constants.js @@ -247,8 +247,18 @@ export const NETWORK_TYPE_LABELS = { }; // Client display names (single source of truth for UI labels) +// Display name + stable order for each network type (used by charts, filters, +// stats). Order here drives the order networks appear in the UI. +export const NETWORK_NAMES = { + ed2k: 'aMule', + rucio: 'Rucio', + bittorrent: 'BitTorrent' +}; +export const NETWORK_ORDER = ['ed2k', 'rucio', 'bittorrent']; + export const CLIENT_NAMES = { amule: { name: 'aMule', shortName: 'aMu' }, + rucio: { name: 'Rucio', shortName: 'Ruc' }, rtorrent: { name: 'rTorrent', shortName: 'rTor' }, qbittorrent: { name: 'qBittorrent', shortName: 'qBit' }, deluge: { name: 'Deluge', shortName: 'Dlg' },