diff --git a/.env.example b/.env.example index d60e5d0..0a66641 100644 --- a/.env.example +++ b/.env.example @@ -47,6 +47,14 @@ PORT=4000 # AMULE_SHARED_FILES_RELOAD_INTERVAL_HOURS=3 # AMULE_SHARED_DIR_DAT=/home/amule/.aMule/shareddir.dat +# slskd Configuration (Optional) +# SLSKD_ENABLED=true +# SLSKD_HOST= +# SLSKD_PORT=5030 +# SLSKD_PATH= +# SLSKD_API_KEY= +# SLSKD_USE_SSL=false + # rTorrent Configuration (Optional) # Connect to rTorrent for BitTorrent downloads via XML-RPC or SCGI # At least one download client (aMule, rTorrent, or qBittorrent) must be enabled diff --git a/README.md b/README.md index 9c93c0d..0729da2 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@

aMuTorrent

-A unified download manager for aMule, rTorrent, qBittorrent, Deluge, and Transmission. Manage ED2K and BitTorrent downloads from a single modern web interface. Features multi-instance support, user management with SSO, Prowlarr integration for torrent search, Torznab indexer and qBittorrent-compatible API for aMule (Sonarr/Radarr integration), push notifications via Apprise, and GeoIP peer location display. Built with Node.js, WebSockets, and React. +A unified download manager for aMule, rTorrent, qBittorrent, Deluge, Transmission, and slskd. Manage ED2K, BitTorrent, and Soulseek downloads from a single modern web interface. Features multi-instance support, user management with SSO, Prowlarr integration for torrent search, Torznab indexer and qBittorrent-compatible API for aMule (*arr integration), push notifications via Apprise, and GeoIP peer location display. Built with Node.js, WebSockets, and React. ![aMuTorrent](./docs/screenshots/home-desktop.png) @@ -16,6 +16,7 @@ A unified download manager for aMule, rTorrent, qBittorrent, Deluge, and Transmi - **qBittorrent Integration** - Connect to qBittorrent via WebUI API - **Deluge Integration** - Connect to Deluge via WebUI JSON-RPC - **Transmission Integration** - Connect to Transmission via HTTP RPC +- **Soulseek Integration** - Connect to slskd via HTTP API - **Multi-Instance** - Run multiple instances of the same client type - **Unified Interface** - Manage all clients from a single dashboard diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index f0263c3..51e5c4a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -32,7 +32,7 @@ When you first access the web interface (or if no configuration exists), an inte 1. **Welcome** - Introduction to the setup process 2. **Security** - Configure web interface authentication (password protection) -3. **Download Clients** - Configure aMule, rTorrent, qBittorrent, Deluge, and/or Transmission connections (with testing) +3. **Download Clients** - Configure aMule, rTorrent, qBittorrent, Deluge, Transmission, and/or slskd connections (with testing) 4. **Directories** - Set data, logs, and GeoIP directories 5. **Integrations** - Optionally enable Prowlarr, Sonarr, and Radarr 6. **Review & Save** - Test all settings and save configuration @@ -91,6 +91,8 @@ Sensitive fields include: - `QBITTORRENT_PASSWORD` - qBittorrent WebUI password - `DELUGE_PASSWORD` - Deluge WebUI password - `TRANSMISSION_PASSWORD` - Transmission RPC password + - `SLSKD_API_KEY` - slskd API key + - `SLSKD_PASSWORD` - slskd password (when not using API key) - `PROWLARR_API_KEY` - Prowlarr API key - `SONARR_API_KEY` - Sonarr API key - `RADARR_API_KEY` - Radarr API key @@ -196,6 +198,16 @@ services: - TRANSMISSION_PASSWORD=pass # Locks UI editing - TRANSMISSION_USE_SSL=false + # slskd Connection (optional) + - SLSKD_ENABLED=false + - SLSKD_HOST=slskd + - SLSKD_PORT=5030 + - SLSKD_PATH= + - SLSKD_API_KEY=your_api_key # Locks UI editing + - SLSKD_USERNAME= + - SLSKD_PASSWORD= # Locks UI editing + - SLSKD_USE_SSL=false + # Prowlarr Integration (optional - requires a BitTorrent client) - PROWLARR_ENABLED=true - PROWLARR_URL=http://prowlarr:9696 @@ -302,6 +314,19 @@ services: | `TRANSMISSION_PASSWORD` | - | RPC auth password (locks UI editing) | | `TRANSMISSION_USE_SSL` | `false` | Use HTTPS for RPC connection | +#### slskd Connection + +| Variable | Default | Description | +|----------|---------|-------------| +| `SLSKD_ENABLED` | `false` | Enable slskd integration | +| `SLSKD_HOST` | `localhost` | slskd API hostname | +| `SLSKD_PORT` | `5030` | slskd API port | +| `SLSKD_PATH` | - | URL base path for reverse proxy (e.g., `/slskd`) | +| `SLSKD_API_KEY` | - | API key (recommended, locks UI editing) | +| `SLSKD_USERNAME` | - | Username fallback when API key is not configured | +| `SLSKD_PASSWORD` | - | Password fallback when API key is not configured (locks UI editing) | +| `SLSKD_USE_SSL` | `false` | Use HTTPS for API connection | + #### Prowlarr Integration | Variable | Default | Description | diff --git a/server/lib/clientMeta.js b/server/lib/clientMeta.js index e76cd48..0ffc4f7 100644 --- a/server/lib/clientMeta.js +++ b/server/lib/clientMeta.js @@ -297,6 +297,62 @@ const CLIENT_TYPES = { customSavePath: true // can set download directory per torrent }, seedingStatuses: ['Seeding', 'Seed Pending'] + }, + slskd: { + networkType: 'soulseek', + displayName: 'Soulseek (slskd)', + metricsPrefix: 'slskd_', + hashLength: 36, + statusField: 'statusText', + statusMap: { + 'Requested': 'active', + 'Queued': 'active', + 'Queued, Remotely': 'active', + 'Queued, Locally': 'active', + 'Initializing': 'active', + 'InProgress': 'active', + 'Succeeded': 'completed', + 'Completed, Succeeded': 'completed', + 'Cancelled': 'stopped', + 'Aborted': 'stopped', + 'Completed, Cancelled': 'stopped', + 'Completed, Aborted': 'stopped', + 'Completed, Aborted, Locally': 'stopped', + 'Completed, Aborted, Remotely': 'stopped', + 'Completed, TimedOut': 'error', + 'Completed, Errored': 'error', + 'Completed, Rejected': 'error' + }, + connectionDefaults: { + host: '', port: 5030, path: '', apiKey: '', username: '', password: '', useSsl: false + }, + defaults: { + message: null, + directory: null, + addedAt: null + }, + capabilities: { + nativeMove: false, + categoryChangeAutoMoves: false, + stopReplacesPause: true, + multiFile: false, + sharedFiles: true, + sharedMeansComplete: true, + removeSharedMustDeleteFiles: false, + moveSharedForCategoryChange: false, + refreshSharedAfterMove: false, + moveActiveDownloads: false, + pauseBeforeMove: false, + trackers: false, + search: true, + cancelDeletesFiles: false, + apiDeletesFiles: false, + refreshSharedAfterDelete: false, + categories: false, + logs: true, + fileRatingComment: false, + customSavePath: false + } } }; @@ -321,7 +377,7 @@ function get(type) { /** * Get the network type for a client type. * @param {string} type - Client type key - * @returns {'ed2k'|'bittorrent'} + * @returns {'ed2k'|'bittorrent'|'soulseek'} */ function getNetworkType(type) { return get(type).networkType; @@ -345,9 +401,18 @@ function isEd2k(type) { return CLIENT_TYPES[type]?.networkType === 'ed2k'; } +/** + * Check if a client type is a Soulseek client. + * @param {string} type - Client type key + * @returns {boolean} + */ +function isSoulseek(type) { + return CLIENT_TYPES[type]?.networkType === 'soulseek'; +} + /** * Get all client type keys that belong to a given network type. - * @param {string} networkType - 'ed2k' or 'bittorrent' + * @param {string} networkType - 'ed2k' | 'bittorrent' | 'soulseek' * @returns {string[]} Array of client type keys */ function getByNetworkType(networkType) { @@ -460,6 +525,7 @@ module.exports = { getNetworkType, isBittorrent, isEd2k, + isSoulseek, getByNetworkType, getAllTypes, hasCapability, diff --git a/server/lib/configTester.js b/server/lib/configTester.js index 71dacb4..48fd7ef 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 SlskdClient = require('./slskd/SlskdClient'); const ProwlarrHandler = require('./prowlarr/ProwlarrHandler'); const { checkDirectoryAccess } = require('./pathUtils'); const logger = require('./logger'); @@ -408,37 +409,46 @@ async function testArrAPI(serviceName, url, apiKey) { try { // Remove trailing slash from URL const baseUrl = url.replace(/\/$/, ''); - const endpoint = `${baseUrl}/api/v3/system/status`; - - const response = await fetch(endpoint, { - method: 'GET', - headers: { - 'X-Api-Key': apiKey - }, - signal: AbortSignal.timeout(10000) // 10 second timeout - }); - - result.reachable = true; + const endpoints = [ + `${baseUrl}/api/v3/system/status`, + `${baseUrl}/api/v1/system/status` + ]; + + let lastHttpError = null; + for (const endpoint of endpoints) { + const response = await fetch(endpoint, { + method: 'GET', + headers: { + 'X-Api-Key': apiKey + }, + signal: AbortSignal.timeout(10000) // 10 second timeout + }); + + result.reachable = true; + + if (response.status === 401 || response.status === 403) { + result.error = 'Authentication failed - invalid API key'; + return result; + } - if (response.status === 401 || response.status === 403) { - result.error = 'Authentication failed - invalid API key'; - return result; - } + if (!response.ok) { + const text = await response.text(); + lastHttpError = `HTTP ${response.status}: ${text}`; + continue; + } - if (!response.ok) { - const text = await response.text(); - result.error = `HTTP ${response.status}: ${text}`; - return result; - } + const data = await response.json(); + result.authenticated = true; - const data = await response.json(); - result.authenticated = true; + if (data.version) { + result.version = data.version; + } - if (data.version) { - result.version = data.version; + result.success = true; + return result; } - result.success = true; + result.error = lastHttpError || `${serviceName} API did not return a supported /system/status response`; return result; } catch (err) { result.error = classifyNetworkError(err); @@ -466,6 +476,26 @@ async function testRadarrAPI(url, apiKey) { return testArrAPI('Radarr', url, apiKey); } +/** + * Test Lidarr API connection + * @param {string} url - Lidarr URL + * @param {string} apiKey - Lidarr API key + * @returns {Promise<{success: boolean, reachable: boolean, authenticated: boolean, version: string|null, error: string|null}>} + */ +async function testLidarrAPI(url, apiKey) { + return testArrAPI('Lidarr', url, apiKey); +} + +/** + * Test Readarr API connection + * @param {string} url - Readarr URL + * @param {string} apiKey - Readarr API key + * @returns {Promise<{success: boolean, reachable: boolean, authenticated: boolean, version: string|null, error: string|null}>} + */ +async function testReadarrAPI(url, apiKey) { + return testArrAPI('Readarr', url, apiKey); +} + /** * Test Prowlarr API connection * @param {string} url - Prowlarr URL @@ -671,6 +701,75 @@ async function testTransmissionConnection(host, port, username, password, useSsl } } +/** + * Test slskd API connection + * @param {string} host - slskd host + * @param {number} port - slskd port + * @param {string} pathPrefix - Optional path prefix behind reverse proxy + * @param {string} apiKey - API key (preferred) + * @param {string} username - Optional username for session login fallback + * @param {string} password - Optional password for session login fallback + * @param {boolean} useSsl - Whether to use HTTPS + * @returns {Promise<{success: boolean, connected: boolean, version: string|null, error: string|null}>} + */ +async function testSlskdConnection(host, port, pathPrefix, apiKey, username, password, useSsl) { + const result = { + success: false, + connected: false, + version: null, + error: null + }; + + if (!host) { + result.error = 'Host is required'; + return result; + } + + let client = null; + + try { + client = new SlskdClient({ + host, + port: port || 5030, + path: pathPrefix || '', + useSsl: useSsl || false, + apiKey: apiKey || '', + username: username || '', + password: password || '' + }); + + const testPromise = client.testConnection(); + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Connection timeout after 10 seconds')), 10000); + }); + + const testResult = await Promise.race([testPromise, timeoutPromise]); + if (testResult.success) { + result.connected = true; + result.version = testResult.version || null; + result.success = true; + result.message = `Connected to slskd${testResult.version ? ` ${testResult.version}` : ''}`; + } else { + result.error = testResult.error || 'Connection failed'; + } + + await client.disconnect(); + return result; + } catch (err) { + result.error = classifyNetworkError(err); + + if (client) { + try { + await client.disconnect(); + } catch (cleanupErr) { + // Ignore cleanup errors + } + } + + return result; + } +} + module.exports = { testDirectoryAccess, testGeoIPDatabase, @@ -679,7 +778,10 @@ module.exports = { testQbittorrentConnection, testDelugeConnection, testTransmissionConnection, + testSlskdConnection, testSonarrAPI, testRadarrAPI, + testLidarrAPI, + testReadarrAPI, testProwlarrAPI }; diff --git a/server/lib/downloadNormalizer.js b/server/lib/downloadNormalizer.js index 8039b53..4d7ecee 100644 --- a/server/lib/downloadNormalizer.js +++ b/server/lib/downloadNormalizer.js @@ -636,6 +636,156 @@ function normalizeTransmissionDownload(torrent) { }; } +// ============================================================================ +// SLSKD NORMALIZERS +// ============================================================================ + +const SLSKD_ACTIVE_STATES = new Set([ + 'Requested', + 'Queued', + 'Queued, Remotely', + 'Queued, Locally', + 'Initializing', + 'InProgress' +]); + +const SLSKD_ERROR_STATES = new Set([ + 'Completed, TimedOut', + 'Completed, Errored', + 'Completed, Rejected', + 'Completed, Aborted', + 'Completed, Aborted, Locally', + 'Completed, Aborted, Remotely' +]); + +/** + * Normalize slskd transfer to unified format + * @param {Object} transfer - slskd Transfer DTO + * @param {Object} context - instance metadata + * @returns {Object} Normalized download + */ +function normalizeSlskdDownload(transfer, context = {}) { + const rawId = transfer.id || transfer.Id || ''; + const hash = String(rawId).toLowerCase(); + const state = transfer.state || transfer.State || 'Unknown'; + const filename = transfer.filename || transfer.Filename || ''; + const size = Number(transfer.size || transfer.Size || 0) || 0; + const downloaded = Number(transfer.bytesTransferred || transfer.BytesTransferred || 0) || 0; + const computedProgress = size > 0 ? (downloaded / size) * 100 : 0; + const progress = Math.max( + 0, + Math.min(100, Number(transfer.percentComplete || transfer.PercentComplete || computedProgress) || 0) + ); + + const pathParts = filename.split(/[\\/]/g).filter(Boolean); + const name = pathParts[pathParts.length - 1] || filename || hash; + + return { + clientType: 'slskd', + instanceId: context.instanceId, + instanceName: context.displayName, + hash, + name, + rawName: name, + size, + downloaded, + progress: parseFloat(progress.toFixed(2)), + speed: Number(transfer.averageSpeed || transfer.AverageSpeed || 0) || 0, + statusText: state, + + category: '', + label: '', + // transfer.directory is the remote virtual share path (e.g. @@ilmto\MOVIES) injected by + // _flattenGroupedTransfers — it is NOT a local filesystem path. Use the configured + // downloadDirectory as the local base so resolveItemPath can construct a real path. + directory: context.downloadDirectory || '', + uploadTotal: 0, + ratio: 0, + trackers: [], + trackersDetailed: [], + trackerDomain: '', + peersDetailed: [], + peerCounts: { total: 0, connected: 0, seeders: 0 }, + + isComplete: state === 'Completed, Succeeded' || state === 'Succeeded' || progress >= 100, + isActive: SLSKD_ACTIVE_STATES.has(state), + isMultiFile: false, + message: transfer.exception || transfer.Exception || (SLSKD_ERROR_STATES.has(state) ? state : ''), + + raw: { + clientType: 'slskd', + username: transfer.username || transfer.Username || '', + ...transfer + }, + + creationDate: transfer.startTime || transfer.StartTime || transfer.requestedAt || transfer.RequestedAt || null, + startedTime: transfer.startTime || transfer.StartTime || transfer.startedAt || transfer.StartedAt || null, + finishedTime: transfer.endTime || transfer.EndTime || transfer.endedAt || transfer.EndedAt || null, + addedAt: transfer.enqueuedAt || transfer.EnqueuedAt || null + }; +} + +function normalizeSlskdSharedFile(file, context = {}) { + const share = context.share || {}; + const directory = context.directory || {}; + const filename = file.filename || file.Filename || ''; + const size = Number(file.size || file.Size || 0) || 0; + const directoryName = directory.name || directory.Name || ''; + const localPathBase = share.localPath || ''; + const remotePathBase = share.remotePath || share.alias || ''; + const directoryParts = String(directoryName).split(/[\\/]/g).filter(Boolean); + const buildPath = (base, name) => [base, ...directoryParts, name].filter(Boolean).join('/'); + const localFilePath = buildPath(localPathBase, filename); + const remoteFilePath = buildPath(remotePathBase, filename); + const hash = String(`${share.id || share.alias || remotePathBase}|${directoryName}|${filename}`).toLowerCase(); + + return { + clientType: 'slskd', + instanceId: context.instanceId, + instanceName: context.displayName, + hash, + name: filename || remoteFilePath || hash, + rawName: filename || remoteFilePath || hash, + size, + downloaded: size, + progress: 100, + speed: 0, + statusText: 'Completed, Succeeded', + + category: '', + label: '', + directory: directoryName || share.alias || share.remotePath || '', + path: localFilePath || remoteFilePath, + filePath: localFilePath || remoteFilePath, + uploadTotal: 0, + ratio: 0, + trackers: [], + trackersDetailed: [], + trackerDomain: '', + peersDetailed: [], + peerCounts: { total: 0, connected: 0, seeders: 0 }, + canMutate: false, + locked: !!share.isExcluded, + message: share.isExcluded ? 'Share excluded' : '', + + isComplete: true, + isActive: false, + isMultiFile: false, + + raw: { + clientType: 'slskd', + share, + directory, + file + }, + + creationDate: null, + startedTime: null, + finishedTime: null, + addedAt: null + }; +} + module.exports = { normalizeAmuleDownload, normalizeAmuleSharedFile, @@ -645,5 +795,7 @@ module.exports = { normalizeQBittorrentDownload, normalizeDelugeDownload, normalizeTransmissionDownload, + normalizeSlskdDownload, + normalizeSlskdSharedFile, extractTrackerDomain }; diff --git a/server/lib/linkConverter.js b/server/lib/linkConverter.js index 41bb9cc..990d4f9 100644 --- a/server/lib/linkConverter.js +++ b/server/lib/linkConverter.js @@ -124,8 +124,71 @@ function parseEd2kLink(ed2kLink) { }; } +// ============================================================================ +// SLSKD (SOULSEEK) MAGNET ENCODING +// ============================================================================ + +const crypto = require('crypto'); + +/** + * Encode a slskd file identifier into a Torznab-compatible magnet link. + * + * Strategy: use MD5(fileHash) as the 32-char hex btih base, then append + * 'ffffffff' (8 chars) to reach 40 chars. The 'ffffffff' suffix is the + * slskd marker — distinct from ED2K's '00000000' suffix. + * The original fileHash is preserved losslessly in the x.slskd parameter. + * + * @param {string} fileHash - slskd internal key (id|username|filename|size) + * @param {string} fileName - Display file name + * @param {number} fileSize - File size in bytes + * @returns {{ magnetLink: string, btih: string }} + */ +function encodeSlskdToMagnet(fileHash, fileName = 'unknown', fileSize = 0) { + const md5 = crypto.createHash('md5').update(String(fileHash)).digest('hex'); // 32 hex chars + const btih = md5 + 'ffffffff'; // 40 hex chars — slskd marker + const dn = encodeURIComponent(fileName || 'unknown'); + const xSlskd = encodeURIComponent(String(fileHash)); + return { + magnetLink: `magnet:?xt=urn:btih:${btih}&dn=${dn}&xl=${fileSize}&x.slskd=${xSlskd}`, + btih + }; +} + +/** + * Check whether a magnet link was produced by encodeSlskdToMagnet. + * Identified by the 'ffffffff' suffix on the btih hash. + * + * @param {string} magnetLink + * @returns {boolean} + */ +function isSlskdMagnet(magnetLink) { + if (!magnetLink || !magnetLink.startsWith('magnet:')) return false; + const params = new URLSearchParams(magnetLink.split('?')[1] || ''); + const xt = params.get('xt') || ''; + const btihMatch = xt.match(/urn:btih:([a-f0-9]{40})/i); + return !!(btihMatch && btihMatch[1].toLowerCase().endsWith('ffffffff')); +} + +/** + * Decode the slskd fileHash embedded in a magnet link produced by encodeSlskdToMagnet. + * Returns null if the link is not a slskd magnet. + * + * @param {string} magnetLink + * @returns {{ fileHash: string } | null} + */ +function decodeSlskdFromMagnet(magnetLink) { + if (!isSlskdMagnet(magnetLink)) return null; + const params = new URLSearchParams(magnetLink.split('?')[1] || ''); + const fileHash = decodeURIComponent(params.get('x.slskd') || ''); + if (!fileHash) return null; + return { fileHash }; +} + module.exports = { convertEd2kToMagnet, convertMagnetToEd2k, - parseEd2kLink + parseEd2kLink, + encodeSlskdToMagnet, + isSlskdMagnet, + decodeSlskdFromMagnet }; diff --git a/server/lib/networkUtils.js b/server/lib/networkUtils.js index ade8501..2c38936 100644 --- a/server/lib/networkUtils.js +++ b/server/lib/networkUtils.js @@ -55,7 +55,7 @@ const CLIENT_SOFTWARE_LABELS = { */ function getClientSoftwareName(item) { // For rtorrent, use the client string directly - if (clientMeta.isBittorrent(item.clientType) || item.EC_TAG_CLIENT_SOFTWARE === -1) { + if (clientMeta.isBittorrent(item.clientType) || clientMeta.isSoulseek(item.clientType) || item.EC_TAG_CLIENT_SOFTWARE === -1) { return item.EC_TAG_CLIENT_SOFT_VER_STR || 'Unknown'; } const baseName = CLIENT_SOFTWARE_LABELS[item.EC_TAG_CLIENT_SOFTWARE] || 'Unknown'; diff --git a/server/lib/qbittorrent/QBittorrentHandler.js b/server/lib/qbittorrent/QBittorrentHandler.js index c41d5ca..6d4b2aa 100644 --- a/server/lib/qbittorrent/QBittorrentHandler.js +++ b/server/lib/qbittorrent/QBittorrentHandler.js @@ -12,7 +12,7 @@ const response = require('../responseFormatter'); const { minutesToMs } = require('../timeRange'); const { verifyPassword } = require('../authUtils'); const { convertToQBittorrentInfo } = require('./stateMapping'); -const { convertMagnetToEd2k } = require('../linkConverter'); +const { convertMagnetToEd2k, isSlskdMagnet, decodeSlskdFromMagnet } = require('../linkConverter'); const { itemKey } = require('../itemKey'); const preferences = require('./preferences.json'); @@ -49,7 +49,7 @@ class QBittorrentHandler { /** * Set all dependencies at once */ - setDependencies({ getAmuleClient, getAmuleInstanceId, hashStore, config, registry, isFirstRun, userManager, createSession, destroySession }) { + setDependencies({ getAmuleClient, getAmuleInstanceId, hashStore, config, registry, isFirstRun, userManager, createSession, destroySession, getSlskdManager }) { this.getAmuleClient = getAmuleClient; this.getAmuleInstanceId = getAmuleInstanceId; this.hashStore = hashStore; @@ -58,6 +58,7 @@ class QBittorrentHandler { this.userManager = userManager; this.createSession = createSession || null; this.destroySession = destroySession || null; + this.getSlskdManager = getSlskdManager || null; if (isFirstRun) this.isFirstRun = isFirstRun; // Start category initialization and periodic refresh @@ -452,11 +453,6 @@ class QBittorrentHandler { return response.badRequest(res, 'Missing urls parameter'); } - const amuleClient = this.getAmuleClient?.(); - if (!amuleClient) { - return response.serviceUnavailable(res, 'aMule not connected'); - } - const magnetLinks = urls .split(/[\n\r]+/) .map(s => s.trim()) @@ -480,6 +476,40 @@ class QBittorrentHandler { try { logger.log('[qBittorrent] Processing magnet link:', magnetLink); + // ── Soulseek (slskd) download ───────────────────────────────────── + if (isSlskdMagnet(magnetLink)) { + const decoded = decodeSlskdFromMagnet(magnetLink); + if (!decoded) { + logger.error('[qBittorrent] Failed to decode slskd magnet link'); + results.push({ magnetLink, success: false }); + continue; + } + + const slskdMgr = this.getSlskdManager?.(); + if (!slskdMgr || !slskdMgr.isConnected?.()) { + logger.error('[qBittorrent] slskd manager not available for download'); + results.push({ magnetLink, success: false }); + continue; + } + + logger.log('[qBittorrent] Routing to slskd for fileHash:', decoded.fileHash); + const success = await slskdMgr.addSearchResult( + decoded.fileHash, + categoryId || 0, + req.apiUser?.username || null + ); + results.push({ magnetLink, success: !!success }); + continue; + } + + // ── ED2K / aMule download ───────────────────────────────────────── + const amuleClient = this.getAmuleClient?.(); + if (!amuleClient) { + logger.error('[qBittorrent] aMule not connected for ED2K download'); + results.push({ magnetLink, success: false }); + continue; + } + const { ed2kLink, ed2kHash, magnetHash, fileName, fileSize } = convertMagnetToEd2k(magnetLink); logger.log('[qBittorrent] Converted to ED2K:', { ed2kHash, magnetHash, fileName, fileSize }); diff --git a/server/lib/slskd/SlskdClient.js b/server/lib/slskd/SlskdClient.js new file mode 100644 index 0000000..55002c9 --- /dev/null +++ b/server/lib/slskd/SlskdClient.js @@ -0,0 +1,376 @@ +/** + * SlskdClient - HTTP API client for slskd (Soulseek daemon) + * + * Supports authentication via: + * 1) X-API-Key header (recommended for machine integrations) + * 2) JWT token from /api/v0/session (username/password fallback) + */ + +'use strict'; + +class SlskdClient { + constructor(options = {}) { + this.host = options.host || 'localhost'; + this.port = options.port || 5030; + this.path = (options.path || '').replace(/\/+$/, ''); + this.useSsl = options.useSsl || false; + + this.apiKey = options.apiKey || ''; + this.username = options.username || ''; + this.password = options.password || ''; + + this.baseUrl = `${this.useSsl ? 'https' : 'http'}://${this.host}:${this.port}${this.path}`; + this.apiBase = `${this.baseUrl}/api/v0`; + + this.token = null; + this.connected = false; + } + + _buildHeaders() { + const headers = { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }; + + if (this.apiKey) { + headers['X-API-Key'] = this.apiKey; + } + if (this.token) { + headers['Authorization'] = `Bearer ${this.token}`; + } + + return headers; + } + + async _call(method, route, { body, timeout = 30000, retryAuth = true } = {}) { + const url = `${this.apiBase}${route}`; + const init = { + method, + headers: this._buildHeaders(), + signal: AbortSignal.timeout(timeout) + }; + + if (body !== undefined) { + init.body = JSON.stringify(body); + } + + let response; + try { + response = await fetch(url, init); + } catch (err) { + if (err.name === 'AbortError' || (err.message && err.message.includes('timeout'))) { + throw new Error(`Connection timeout to ${this.host}:${this.port}`); + } + throw err; + } + + if (response.status === 401 && retryAuth && !this.apiKey && this.username && this.password) { + await this.login(); + return this._call(method, route, { body, timeout, retryAuth: false }); + } + + if (!response.ok) { + const text = await response.text(); + throw new Error(`HTTP ${response.status}: ${text || response.statusText}`); + } + + if (response.status === 204) { + return null; + } + + const contentType = response.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + const text = await response.text(); + return text; + } + + return await response.json(); + } + + async login() { + if (!this.username || !this.password) { + throw new Error('Username/password are required for JWT login'); + } + + const json = await this._call('POST', '/session', { + body: { username: this.username, password: this.password }, + retryAuth: false + }); + + this.token = json?.token || null; + if (!this.token) { + throw new Error('No token returned by slskd session endpoint'); + } + + this.connected = true; + return true; + } + + async testConnection() { + try { + const app = await this._call('GET', '/application', { retryAuth: true }); + this.connected = true; + return { + success: true, + version: app?.version || app?.Version || 'unknown' + }; + } catch (err) { + this.connected = false; + return { + success: false, + error: err.message || 'Connection failed' + }; + } + } + + async disconnect() { + this.token = null; + this.connected = false; + } + + isConnected() { + return this.connected; + } + + async getDownloads(includeRemoved = false) { + return await this._call('GET', `/transfers/downloads?includeRemoved=${includeRemoved ? 'true' : 'false'}`); + } + + async getUploads(includeRemoved = false) { + return await this._call('GET', `/transfers/uploads?includeRemoved=${includeRemoved ? 'true' : 'false'}`); + } + + async getLogs() { + return await this._call('GET', '/logs'); + } + + async getShares() { + return await this._call('GET', '/shares'); + } + + async getShareContents(id) { + const shareId = encodeURIComponent(String(id)); + return await this._call('GET', `/shares/${shareId}/contents`); + } + + async getUserDirectoryContents(username, directory) { + const user = encodeURIComponent(String(username)); + return await this._call('POST', `/users/${user}/directory`, { + body: { directory } + }); + } + + async cancelDownload(username, id, remove = false) { + const u = encodeURIComponent(username); + const t = encodeURIComponent(id); + await this._call('DELETE', `/transfers/downloads/${u}/${t}?remove=${remove ? 'true' : 'false'}`); + } + + async enqueueDownloads(username, files) { + const u = encodeURIComponent(username); + return await this._call('POST', `/transfers/downloads/${u}`, { body: files || [] }); + } + + _extractSearchResults(payload) { + const results = []; + const visit = (node, inheritedUser = null) => { + if (!node) return; + if (Array.isArray(node)) { + for (const entry of node) visit(entry, inheritedUser); + return; + } + if (typeof node !== 'object') return; + + const username = node.username || node.Username || inheritedUser; + + const listKeys = ['files', 'Files', 'results', 'Results', 'responses', 'Responses', 'items', 'Items']; + for (const key of listKeys) { + if (Array.isArray(node[key])) { + visit(node[key], username); + } + } + + // Some slskd responses can be dictionaries keyed by username or other + // nested wrappers. Walk unknown object children too so we don't miss hits. + for (const [key, value] of Object.entries(node)) { + if (listKeys.includes(key)) continue; + if (!value || typeof value !== 'object') continue; + const keyedUsername = !username && typeof key === 'string' ? key : username; + visit(value, keyedUsername || username); + } + + const filename = node.filename || node.fileName || node.name || node.Filename || node.FileName || node.Name; + const size = Number(node.size || node.Size || node.fileSize || node.FileSize || 0) || 0; + if (filename && username) { + const id = node.id || node.Id || node.token || node.Token || null; + const bitrate = Number(node.bitrate || node.Bitrate || 0) || null; + const length = Number(node.length || node.Length || node.duration || node.Duration || 0) || null; + results.push({ + id: id ? String(id) : null, + username: String(username), + filename: String(filename), + size, + bitrate, + length, + raw: node + }); + } + }; + + visit(payload, null); + + const dedup = new Map(); + for (const item of results) { + const key = `${item.id || ''}|${item.username}|${item.filename}|${item.size}`; + if (!dedup.has(key)) dedup.set(key, item); + } + + return Array.from(dedup.values()); + } + + _extractSearchId(payload) { + if (!payload || typeof payload !== 'object') return null; + return payload.id || payload.Id || payload.searchId || payload.SearchId || payload.token || payload.Token || null; + } + + async _createSearch(query) { + const payloadCandidates = [ + { searchText: query }, + { query }, + { text: query }, + { term: query } + ]; + + let lastErr = null; + for (const body of payloadCandidates) { + try { + return await this._call('POST', '/searches', { body }); + } catch (err) { + lastErr = err; + } + } + throw lastErr || new Error('Failed to create slskd search'); + } + + async getSearchResults(searchId) { + const sid = encodeURIComponent(String(searchId)); + // Newer slskd versions expose result rows via /responses; older builds may + // only embed them in /searches/{id}. Try both for compatibility. + try { + const responsePayload = await this._call('GET', `/searches/${sid}/responses`); + const responseResults = this._extractSearchResults(responsePayload); + if (responseResults.length > 0 || Array.isArray(responsePayload)) { + return responseResults; + } + } catch (_err) { + // Fall back to the legacy endpoint below. + } + + const payload = await this._call('GET', `/searches/${sid}`); + return this._extractSearchResults(payload); + } + + async getEvents() { + return await this._call('GET', '/events'); + } + + async getTelemetrySummary() { + return await this._call('GET', '/telemetry/reports/transfers/summary'); + } + + async searchText(query, { maxWaitMs = 45000, pollIntervalMs = 1500 } = {}) { + const trimmed = String(query || '').trim(); + if (!trimmed) { + return { searchId: null, results: [] }; + } + + const created = await this._createSearch(trimmed); + const searchId = this._extractSearchId(created); + + // Some slskd versions may return immediate results directly. + const immediate = this._extractSearchResults(created); + if (!searchId) { + return { searchId: null, results: immediate }; + } + + const start = Date.now(); + let best = immediate; + + while (Date.now() - start < maxWaitMs) { + const current = await this.getSearchResults(searchId); + if (current.length > best.length) { + best = current; + } + if (current.length > 0 && Date.now() - start >= 4000) { + break; + } + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + return { searchId: String(searchId), results: best }; + } + + // ============================================================================ + // CONVERSATIONS (Private Messages) + // ============================================================================ + + async getConversations() { + return await this._call('GET', '/conversations'); + } + + async getConversation(username) { + const u = encodeURIComponent(String(username)); + return await this._call('GET', `/conversations/${u}`); + } + + async sendConversationMessage(username, message) { + const u = encodeURIComponent(String(username)); + return await this._call('POST', `/conversations/${u}`, { body: message }); + } + + async acknowledgeConversationMessage(username, messageId) { + const u = encodeURIComponent(String(username)); + const mid = encodeURIComponent(String(messageId)); + // slskd API: PUT /api/v0/conversations/{username}/{id} + return await this._call('PUT', `/conversations/${u}/${mid}`); + } + + async deleteConversation(username) { + const u = encodeURIComponent(String(username)); + return await this._call('DELETE', `/conversations/${u}`); + } + + // ============================================================================ + // ROOMS + // ============================================================================ + + async getRooms() { + return await this._call('GET', '/rooms/joined'); + } + + async joinRoom(roomName) { + return await this._call('POST', '/rooms/joined', { body: String(roomName) }); + } + + async leaveRoom(roomName) { + const r = encodeURIComponent(String(roomName)); + return await this._call('DELETE', `/rooms/joined/${r}`); + } + + async sendRoomMessage(roomName, message) { + const r = encodeURIComponent(String(roomName)); + return await this._call('POST', `/rooms/joined/${r}/messages`, { body: String(message) }); + } + + async getRoomByName(roomName) { + const r = encodeURIComponent(String(roomName)); + return await this._call('GET', `/rooms/joined/${r}`); + } + + async getUserInfo(username) { + const u = encodeURIComponent(String(username)); + return await this._call('GET', `/users/${u}`); + } +} + +module.exports = SlskdClient; \ No newline at end of file diff --git a/server/lib/torznab/SoulseekTorznabHandler.js b/server/lib/torznab/SoulseekTorznabHandler.js new file mode 100644 index 0000000..69eed3b --- /dev/null +++ b/server/lib/torznab/SoulseekTorznabHandler.js @@ -0,0 +1,176 @@ +/** + * SoulseekTorznabHandler - Torznab indexer for Soulseek (slskd) + * + * Provides Sonarr/Radarr compatible Torznab endpoints by translating + * search requests to slskd Soulseek network searches. + * + * Download links use the slskd magnet encoding (ffffffff suffix) so they + * round-trip cleanly through the qBittorrent-compat API back to slskd. + */ + +const logger = require('../logger'); +const { generateCapabilities } = require('./capabilities'); +const { convertToSoulseekTorznabFeed } = require('./search'); + +class SoulseekTorznabHandler { + constructor() { + this.getSlskdManager = null; + + // Result cache (same TTL logic as TorznabHandler) + this.cacheTtlMs = parseInt(process.env.SLSKD_CACHE_TTL_MS || '300000', 10); + this.searchCache = new Map(); + + this.handleRequest = this.handleRequest.bind(this); + } + + setDependencies({ getSlskdManager }) { + this.getSlskdManager = getSlskdManager || null; + } + + // ============================================================================ + // CACHE + // ============================================================================ + + _cacheKey(t, q, season, ep) { + return [t, q || '', season || '', ep || ''].join(':'); + } + + _getCached(key) { + const cached = this.searchCache.get(key); + if (!cached) return null; + if (Date.now() - cached.timestamp > this.cacheTtlMs) { + this.searchCache.delete(key); + return null; + } + logger.log(`[SoulseekTorznab] Cache hit for key: ${key} (${cached.results.length} results)`); + return cached.results; + } + + _setCache(key, results) { + this.searchCache.set(key, { results, timestamp: Date.now() }); + logger.log(`[SoulseekTorznab] Cached ${results.length} results for key: ${key}`); + } + + // ============================================================================ + // REQUEST HANDLER + // ============================================================================ + + async handleRequest(req, res) { + const { t, q, cat = '' } = req.query; + + try { + if (t === 'caps') { + const xml = generateCapabilities(); + res.set('Content-Type', 'application/xml'); + return res.send(xml); + } + + if (t === 'search' || t === 'tvsearch' || t === 'movie') { + return await this._handleSearch(req, res); + } + + res.status(400).send('Invalid t parameter (expected: caps, search, tvsearch, or movie)'); + } catch (error) { + logger.error('[SoulseekTorznab] Error:', error); + const emptyFeed = convertToSoulseekTorznabFeed([], q || '', cat || ''); + res.set('Content-Type', 'application/xml'); + res.status(500).send(emptyFeed); + } + } + + async _handleSearch(req, res) { + const { t, q, limit = 100, offset = 0, cat = '', season, ep } = req.query; + + logger.log(`[SoulseekTorznab] Search: t=${t}, q=${q || '(empty)'}, season=${season || '-'}, ep=${ep || '-'}, offset=${offset}`); + + // No search params — return a sample result for indexer validation + const hasSearchParams = q || season || ep; + if (!hasSearchParams) { + logger.log('[SoulseekTorznab] No search parameters, returning sample result for validation'); + const sampleResult = [{ + fileName: 'Sample.Test.File.flac', + fileHash: 'sample|soulseek|/Music/Sample.Test.File.flac|1073741824', + fileSize: 1073741824, + sourceCount: 1 + }]; + const xml = convertToSoulseekTorznabFeed(sampleResult, 'test', cat); + res.set('Content-Type', 'application/xml'); + return res.send(xml); + } + + if (!q) { + logger.warn('[SoulseekTorznab] Search has metadata params but no text query'); + const xml = convertToSoulseekTorznabFeed([], 'no-query', cat); + res.set('Content-Type', 'application/xml'); + return res.send(xml); + } + + const slskdMgr = this.getSlskdManager?.(); + if (!slskdMgr || !slskdMgr.isConnected?.()) { + logger.log('[SoulseekTorznab] slskd not connected, returning empty feed'); + const xml = convertToSoulseekTorznabFeed([], q, cat); + res.set('Content-Type', 'application/xml'); + return res.send(xml); + } + + // Build search queries (TV episodes get two format variants) + const searchQueries = this._buildSearchQueries(t, q, season, ep); + const cacheKey = this._cacheKey(t, q, season, ep); + + let allResults = this._getCached(cacheKey); + + if (!allResults) { + allResults = []; + const seenHashes = new Set(); + + for (const searchQuery of searchQueries) { + logger.log(`[SoulseekTorznab] Searching slskd for: "${searchQuery}"`); + try { + const { results } = await slskdMgr.search(searchQuery); + for (const file of (results || [])) { + if (!seenHashes.has(file.fileHash)) { + seenHashes.add(file.fileHash); + allResults.push(file); + } + } + } catch (err) { + logger.error(`[SoulseekTorznab] Search error for "${searchQuery}":`, err.message); + } + } + + logger.log(`[SoulseekTorznab] Total unique results: ${allResults.length}`); + this._setCache(cacheKey, allResults); + } + + const offsetNum = parseInt(offset, 10) || 0; + const limitNum = parseInt(limit, 10) || 100; + const paginated = allResults.slice(offsetNum, offsetNum + limitNum); + + logger.log(`[SoulseekTorznab] Returning ${paginated.length} results (offset=${offsetNum}, total=${allResults.length})`); + + const xml = convertToSoulseekTorznabFeed(paginated, q, cat); + res.set('Content-Type', 'application/xml'); + return res.send(xml); + } + + _buildSearchQueries(t, q, season, ep) { + if (t !== 'tvsearch' || !season) return [q]; + + const seasonNum = parseInt(season, 10); + const normalizedQuery = q.replace(/[\[\(]?\b(19|20)\d{2}\b[\]\)]?/g, '').replace(/\s+/g, ' ').trim(); + + if (ep) { + const episodeNum = parseInt(ep, 10); + return [ + `${normalizedQuery} ${seasonNum}x${episodeNum.toString().padStart(2, '0')}`, + `${normalizedQuery} S${seasonNum.toString().padStart(2, '0')}E${episodeNum.toString().padStart(2, '0')}` + ]; + } + return [ + `${normalizedQuery} S${seasonNum.toString().padStart(2, '0')}`, + `${normalizedQuery} Season ${seasonNum}` + ]; + } +} + +module.exports = SoulseekTorznabHandler; diff --git a/server/lib/torznab/TorznabHandler.js b/server/lib/torznab/TorznabHandler.js index 0408e79..61d1257 100644 --- a/server/lib/torznab/TorznabHandler.js +++ b/server/lib/torznab/TorznabHandler.js @@ -18,6 +18,7 @@ class TorznabHandler { constructor() { // Dependencies this.getAmuleClient = null; + this.getSearchProviderClient = null; // Rate limiting state this.searchDelayMs = parseInt(process.env.ED2K_SEARCH_DELAY_MS || '10000', 10); @@ -33,9 +34,22 @@ class TorznabHandler { /** * Set dependencies + * Accepts either `getSearchProviderClient` (preferred, provider-agnostic) or + * `getAmuleClient` (legacy, backward compat). */ - setDependencies({ getAmuleClient }) { - this.getAmuleClient = getAmuleClient; + setDependencies({ getSearchProviderClient, getAmuleClient }) { + this.getSearchProviderClient = getSearchProviderClient || null; + // Keep legacy alias so existing call sites that set getAmuleClient still work + this.getAmuleClient = getAmuleClient || null; + } + + /** + * Resolve the active search client. + * Prefers getSearchProviderClient; falls back to getAmuleClient. + * @returns {Object|null} + */ + _resolveSearchClient() { + return this.getSearchProviderClient?.() || this.getAmuleClient?.() || null; } // ============================================================================ @@ -210,9 +224,9 @@ class TorznabHandler { return res.send(emptyFeed); } - const amuleClient = this.getAmuleClient?.(); + const amuleClient = this._resolveSearchClient(); if (!amuleClient) { - logger.log('[Torznab] aMule not connected, returning empty feed'); + logger.log('[Torznab] No search provider connected, returning empty feed'); const emptyFeed = convertToTorznabFeed([], q, cat); res.set('Content-Type', 'application/xml'); return res.send(emptyFeed); diff --git a/server/lib/torznab/search.js b/server/lib/torznab/search.js index 9797718..3f49ae4 100644 --- a/server/lib/torznab/search.js +++ b/server/lib/torznab/search.js @@ -1,5 +1,5 @@ const { create } = require('xmlbuilder2'); -const { convertEd2kToMagnet } = require('../linkConverter'); +const { convertEd2kToMagnet, encodeSlskdToMagnet } = require('../linkConverter'); /** * Convert aMule search results to Torznab RSS feed @@ -103,3 +103,88 @@ function convertToTorznabFeed(amuleResults, query, requestedCategories = '') { } module.exports = { convertToTorznabFeed }; + +// ============================================================================ +// SOULSEEK (SLSKD) TORZNAB FEED +// ============================================================================ + +/** + * Convert slskd search results to a Torznab-compatible RSS feed. + * Uses magnet links with the slskd encoding scheme (ffffffff suffix). + * + * @param {Array} slskdResults - Normalized slskd search results (fileHash, fileName, fileSize, sourceCount) + * @param {string} query - Original search query + * @param {string} requestedCategories - Comma-separated category IDs from request + * @returns {string} XML RSS feed + */ +function convertToSoulseekTorznabFeed(slskdResults, query, requestedCategories = '') { + const root = create({ version: '1.0', encoding: 'UTF-8' }); + const rss = root.ele('rss', { + version: '1.0', + 'xmlns:atom': 'http://www.w3.org/2005/Atom', + 'xmlns:torznab': 'http://torznab.com/schemas/2015/feed' + }); + + const channel = rss.ele('channel'); + channel.ele('title').txt('Soulseek Indexer').up(); + channel.ele('description').txt('Soulseek Network Search Results').up(); + channel.ele('link').txt('http://localhost').up(); + channel.ele('language').txt('en-us').up(); + channel.ele('atom:link', { + href: 'http://localhost/indexer/soulseek/api', + rel: 'self', + type: 'application/rss+xml' + }).up(); + + const allMovieCategories = ['2000', '2010', '2020', '2030', '2040', '2045', '2050', '2060', '2070', '2080', '2090']; + const allTVCategories = ['5000', '5010', '5020', '5030', '5040', '5045', '5050', '5060', '5070', '5080', '5090']; + + slskdResults.forEach((result) => { + const item = channel.ele('item'); + + const fileName = result.fileName || 'Unknown'; + const fileHash = result.fileHash || ''; + const fileSize = result.fileSize || 0; + const sourceCount = result.sourceCount || 1; + + const { magnetLink } = encodeSlskdToMagnet(fileHash, fileName, fileSize); + + item.ele('title').txt(fileName).up(); + item.ele('guid').txt(fileHash || magnetLink).up(); + item.ele('pubDate').txt(new Date().toUTCString()).up(); + item.ele('size').txt(String(fileSize)).up(); + item.ele('link').txt(magnetLink).up(); + item.ele('enclosure', { + url: magnetLink, + length: String(fileSize), + type: 'application/x-bittorrent' + }).up(); + + item.ele('torznab:attr', { name: 'seeders', value: String(sourceCount) }).up(); + item.ele('torznab:attr', { name: 'peers', value: String(sourceCount) }).up(); + item.ele('torznab:attr', { name: 'size', value: String(fileSize) }).up(); + item.ele('torznab:attr', { name: 'grabs', value: '0' }).up(); + + const requestedCats = requestedCategories.split(',').filter(Boolean); + const categoriesToAdd = new Set(); + + if (requestedCats.length === 0) { + allMovieCategories.forEach(cat => categoriesToAdd.add(cat)); + allTVCategories.forEach(cat => categoriesToAdd.add(cat)); + } else { + requestedCats.forEach(cat => { + categoriesToAdd.add(cat); + if (cat.startsWith('2') && cat !== '2000') categoriesToAdd.add('2000'); + else if (cat.startsWith('5') && cat !== '5000') categoriesToAdd.add('5000'); + }); + } + + Array.from(categoriesToAdd).forEach(cat => { + item.ele('torznab:attr', { name: 'category', value: cat }).up(); + }); + }); + + return root.end({ prettyPrint: true }); +} + +module.exports = { convertToTorznabFeed, convertToSoulseekTorznabFeed }; diff --git a/server/lib/unifiedItemBuilder.js b/server/lib/unifiedItemBuilder.js index 1a588d1..9fdaeff 100644 --- a/server/lib/unifiedItemBuilder.js +++ b/server/lib/unifiedItemBuilder.js @@ -169,9 +169,11 @@ function applyDownloadData(item, download, categoryManager = null) { // Links item.ed2kLink = download.ed2kLink || item.ed2kLink; - } else if (clientMeta.isBittorrent(download.clientType)) { + } else if (clientMeta.isBittorrent(download.clientType) || clientMeta.isSoulseek(download.clientType)) { + const isBittorrent = clientMeta.isBittorrent(download.clientType); + // BitTorrent clients (rtorrent, qbittorrent) — all items are always shared/seeding - item.shared = true; + item.shared = isBittorrent; // Determine seeding status from clientMeta const seedingStatuses = clientMeta.get(download.clientType).seedingStatuses; @@ -214,7 +216,9 @@ function applyDownloadData(item, download, categoryManager = null) { } // Links - item.magnetLink = generateMagnetLink(download); + if (isBittorrent) { + item.magnetLink = generateMagnetLink(download); + } // Timestamps - use startedTime (when torrent was first started) // Treat 0 as null (0 = epoch time 1970, not a real timestamp) @@ -241,6 +245,20 @@ function applySharedData(item, sharedFile) { item.name = item.name || sharedFile.name || ''; item.rawName = item.rawName || sharedFile.rawName; item.size = item.size || sharedFile.size || 0; + item.directory = item.directory || sharedFile.directory || ''; + item.filePath = item.filePath || sharedFile.filePath || sharedFile.path || ''; + item.message = item.message || sharedFile.message || ''; + item.locked = item.locked || sharedFile.locked || false; + if (sharedFile.canMutate === false) { + item.canMutate = false; + } + + if (clientMeta.hasCapability(sharedFile.clientType, 'sharedMeansComplete') && !item.downloading) { + item.progress = 100; + item.complete = true; + item.seeding = true; + item.sizeDownloaded = item.size; + } // Upload speed from aggregated aMule uploads or rtorrent stats if (sharedFile.uploadSpeed > 0) { @@ -248,14 +266,6 @@ function applySharedData(item, sharedFile) { } if (clientMeta.isEd2k(sharedFile.clientType)) { - // aMule shared files are completed downloads - mark them as such - // (unless already set by applyDownloadData for files still downloading) - if (!item.downloading) { - item.progress = 100; - item.complete = true; - item.seeding = true; - item.sizeDownloaded = item.size; - } // Organization — shared file may have path-derived category // Only update category if item doesn't already have one (from download data) // or if shared file has a non-default category (path-based match) @@ -285,11 +295,6 @@ function applySharedData(item, sharedFile) { // Links item.ed2kLink = sharedFile.ed2kLink || item.ed2kLink; - // Store file path for aMule shared files (needed for delete permission checks) - if (sharedFile.path) { - item.filePath = sharedFile.path; - } - // Raw data: merge shared file's EC_TAG fields into item.raw // For files that are both downloading and shared, this adds KNOWNFILE fields // (upload stats, upload priority) alongside the existing PARTFILE fields diff --git a/server/modules/arrManager.js b/server/modules/arrManager.js index 835e6ba..a545e00 100644 --- a/server/modules/arrManager.js +++ b/server/modules/arrManager.js @@ -11,6 +11,7 @@ const { hoursToMs, minutesToMs, MS_PER_HOUR } = require('../lib/timeRange'); // Client registry - replaces direct singleton manager imports const registry = require('../lib/ClientRegistry'); +const clientMeta = require('../lib/clientMeta'); // Debug mode - set to true to see detailed search decisions const DEBUG = true; @@ -214,6 +215,26 @@ class ArrManager extends BaseModule { return fileQualityIndex !== -1 && cutoffQualityIndex !== -1 && fileQualityIndex < cutoffQualityIndex; } + /** + * Resolve the manager to use for arr-triggered searches. + * Priority: integrations.arrDownloadInstanceId -> integrations.amuleInstanceId (legacy) + * -> first connected instance with search capability. + * @returns {Object|null} Manager instance or null + */ + _resolveSearchManager() { + const integrations = config.getConfig()?.integrations || {}; + const configuredId = integrations.arrDownloadInstanceId || integrations.amuleInstanceId; + if (configuredId) { + const mgr = registry.get(configuredId); + if (mgr) return mgr; + this.warn(`⚠️ Configured search provider "${configuredId}" not found in registry, falling back to capability search`); + } + // Fallback: first connected instance that supports search + return registry.getAll().find( + m => m.isConnected?.() && clientMeta.hasCapability(m.clientType, 'search') + ) || null; + } + /** * Acquire search lock with timeout */ @@ -222,15 +243,12 @@ class ArrManager extends BaseModule { const pollInterval = 10000; // 10 seconds const startTime = Date.now(); - const configuredId = config.getConfig()?.integrations?.amuleInstanceId; - const amuleMgr = configuredId - ? registry.get(configuredId) - : registry.getByType('amule').find(m => m.isConnected()); - if (!amuleMgr) { - this.warn(`⚠️ No aMule instance connected, skipping ${service} automatic search`); + const searchMgr = this._resolveSearchManager(); + if (!searchMgr) { + this.warn(`⚠️ No search-capable instance connected, skipping ${service} automatic search`); return false; } - while (!amuleMgr.acquireSearchLock()) { + while (!searchMgr.acquireSearchLock()) { if (Date.now() - startTime > maxWaitTime) { this.warn(`⚠️ Timeout waiting for search lock, skipping ${service} automatic search`); return false; @@ -239,11 +257,8 @@ class ArrManager extends BaseModule { await new Promise(resolve => setTimeout(resolve, pollInterval)); } - amuleMgr.searchInProgress = true; - this.broadcast({ type: 'search-lock', locked: true }, { - filter: u => u?.isAdmin || u?.capabilities?.includes('search') - }); - this.log(`🔒 Search lock acquired for ${service} automatic search (${amuleMgr.instanceId})`); + searchMgr.searchInProgress = true; + this.log(`🔒 Search lock acquired for ${service} automatic search (${searchMgr.instanceId})`); return true; } @@ -251,14 +266,8 @@ class ArrManager extends BaseModule { * Release search lock */ releaseSearchLock(service) { - const configuredId = config.getConfig()?.integrations?.amuleInstanceId; - const amuleMgr = configuredId - ? registry.get(configuredId) - : registry.getByType('amule').find(m => m.isConnected()); - if (amuleMgr) amuleMgr.releaseSearchLock(); - this.broadcast({ type: 'search-lock', locked: false }, { - filter: u => u?.isAdmin || u?.capabilities?.includes('search') - }); + const searchMgr = this._resolveSearchManager(); + if (searchMgr) searchMgr.releaseSearchLock(); this.log(`🔓 Search lock released after ${service} automatic search`); } @@ -346,7 +355,8 @@ class ArrManager extends BaseModule { this.log(`🔄 Triggering ${service} ${svcCfg.refreshCommand}...`); // 1️⃣ Trigger Refresh - const refreshResult = await this.fetchJson(`${cfg.url}/api/v3/command`, { + const apiBase = this.getApiBase(service, cfg.url); + const refreshResult = await this.fetchJson(`${apiBase}/command`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/server/modules/config.js b/server/modules/config.js index 6c8798e..7ab7c45 100644 --- a/server/modules/config.js +++ b/server/modules/config.js @@ -75,7 +75,8 @@ const CLIENT_ENV_PREFIX = { rtorrent: 'RTORRENT', qbittorrent: 'QBITTORRENT', deluge: 'DELUGE', - transmission: 'TRANSMISSION' + transmission: 'TRANSMISSION', + slskd: 'SLSKD' }; /** @@ -140,6 +141,19 @@ const CLIENT_ENV_FIELDS = { PATH: { field: 'path', type: 'string' }, ID: { field: 'id', type: 'string' }, NAME: { field: 'name', type: 'string' } + }, + slskd: { + ENABLED: { field: 'enabled', type: 'boolean' }, + HOST: { field: 'host', type: 'string' }, + PORT: { field: 'port', type: 'int' }, + PATH: { field: 'path', type: 'string' }, + API_KEY: { field: 'apiKey', type: 'string', sensitive: true }, + USERNAME: { field: 'username', type: 'string' }, + PASSWORD: { field: 'password', type: 'string', sensitive: true }, + USE_SSL: { field: 'useSsl', type: 'boolean' }, + ID: { field: 'id', type: 'string' }, + NAME: { field: 'name', type: 'string' }, + DOWNLOAD_DIRECTORY: { field: 'downloadDirectory', type: 'string' } } }; @@ -709,7 +723,7 @@ class Config extends BaseModule { // At least one download client must be enabled 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..6406bd2 100644 --- a/server/modules/configAPI.js +++ b/server/modules/configAPI.js @@ -84,6 +84,14 @@ class ConfigAPI extends BaseModule { transmissionPassword: config.isFromEnv('transmission.password'), transmissionUseSsl: config.isFromEnv('transmission.useSsl'), transmissionPath: config.isFromEnv('transmission.path'), + slskdEnabled: config.isFromEnv('slskd.enabled'), + slskdHost: config.isFromEnv('slskd.host'), + slskdPort: config.isFromEnv('slskd.port'), + slskdPath: config.isFromEnv('slskd.path'), + slskdApiKey: config.isFromEnv('slskd.apiKey'), + slskdUsername: config.isFromEnv('slskd.username'), + slskdPassword: config.isFromEnv('slskd.password'), + slskdUseSsl: config.isFromEnv('slskd.useSsl'), sonarrUrl: config.isFromEnv('integrations.sonarr.url'), sonarrApiKey: config.isFromEnv('integrations.sonarr.apiKey'), sonarrSearchInterval: config.isFromEnv('integrations.sonarr.searchIntervalHours'), @@ -283,7 +291,7 @@ class ConfigAPI extends BaseModule { */ async testConfig(req, res) { try { - const { amule, rtorrent, directories, sonarr, radarr, prowlarr } = req.body; + const { amule, rtorrent, directories, sonarr, radarr, prowlarr, slskd } = req.body; const results = {}; const currentConfig = config.getConfig(); @@ -353,6 +361,24 @@ class ConfigAPI extends BaseModule { this.logTestResult('Transmission connection', results.transmission); } + // Test slskd connection if provided and enabled + if (slskd && slskd.enabled) { + const apiKey = slskd.apiKey || (slskd.instanceId ? config.getClientConfig(slskd.instanceId)?.apiKey : null); + const username = slskd.username || (slskd.instanceId ? config.getClientConfig(slskd.instanceId)?.username : null); + const password = slskd.password || (slskd.instanceId ? config.getClientConfig(slskd.instanceId)?.password : null); + this.log(`🧪 Testing slskd connection to ${slskd.host}:${slskd.port}...`); + results.slskd = await configTester.testSlskdConnection( + slskd.host, + slskd.port, + slskd.path, + apiKey, + username, + password, + slskd.useSsl + ); + this.logTestResult('slskd connection', results.slskd); + } + // Test directories if provided if (directories) { results.directories = {}; @@ -507,7 +533,11 @@ class ConfigAPI extends BaseModule { for (const mgr of registry.getAll()) { this.log(`🔄 Closing existing ${mgr.displayName} connection (${mgr.instanceId})...`); try { - await mgr.shutdown(); + if (typeof mgr.shutdown === 'function') { + await mgr.shutdown(); + } else if (typeof mgr.cleanup === 'function') { + await mgr.cleanup(); + } } catch (err) { this.warn(`⚠️ Error shutting down ${mgr.instanceId}:`, err.message); } diff --git a/server/modules/qbittorrentAPI.js b/server/modules/qbittorrentAPI.js index 2be9d2f..4aef402 100644 --- a/server/modules/qbittorrentAPI.js +++ b/server/modules/qbittorrentAPI.js @@ -170,14 +170,18 @@ class QBittorrentAPI extends BaseModule { */ updateHandler() { if (this.hashStore) { + // Resolves the download/search provider manager. + // Priority: integrations.arrDownloadInstanceId -> integrations.amuleInstanceId (legacy) + // -> first connected aMule instance (backward compat). const resolveAmuleManager = () => { - const configuredId = config.getConfig()?.integrations?.amuleInstanceId; + const integrations = config.getConfig()?.integrations || {}; + const configuredId = integrations.arrDownloadInstanceId || integrations.amuleInstanceId; let amuleMgr; if (configuredId) { amuleMgr = registry.get(configuredId); if (!amuleMgr) { amuleMgr = registry.getByType('amule').find(m => m.isConnected()); - if (amuleMgr) this.warn(`⚠️ [QBittorrentAPI.getAmuleClient] Configured amuleInstanceId "${configuredId}" not found, falling back to "${amuleMgr.instanceId}"`); + if (amuleMgr) this.warn(`⚠️ [QBittorrentAPI] Configured provider "${configuredId}" not found, falling back to "${amuleMgr.instanceId}"`); } } else { amuleMgr = registry.getByType('amule').find(m => m.isConnected()); @@ -194,7 +198,11 @@ class QBittorrentAPI extends BaseModule { isFirstRun: () => config.isFirstRun(), userManager: this.userManager, createSession: (user) => this.createSession(user), - destroySession: (sid) => this.destroySession(sid) + destroySession: (sid) => this.destroySession(sid), + getSlskdManager: () => { + const mgrs = registry.getByType('slskd').filter(m => m.isConnected?.()); + return mgrs[0] || null; + } }); } } diff --git a/server/modules/slskdChatAPI.js b/server/modules/slskdChatAPI.js new file mode 100644 index 0000000..04ba4f3 --- /dev/null +++ b/server/modules/slskdChatAPI.js @@ -0,0 +1,227 @@ +/** + * Soulseek Chat API + * + * REST endpoints for slskd private message conversations. + * Proxies requests to the connected slskd instance. + */ + +'use strict'; + +const express = require('express'); +const BaseModule = require('../lib/BaseModule'); +const registry = require('../lib/ClientRegistry'); +const response = require('../lib/responseFormatter'); +const { requireCapability } = require('../middleware/capabilities'); +const logger = require('../lib/logger'); + +class SlskdChatAPI extends BaseModule { + + /** + * Get the first connected slskd manager, or the one matching instanceId. + */ + _getManager(instanceId) { + if (instanceId) { + const mgr = registry.get(instanceId); + if (mgr && mgr.clientType === 'slskd') return mgr; + } + const all = registry.getByType('slskd'); + return all.find(m => m.isConnected?.()) || all[0] || null; + } + + /** + * GET /api/slskd/conversations/:username + * Returns the full conversation (with message history) for a single user. + */ + async getConversation(req, res) { + try { + const { username } = req.params; + const instanceId = req.query.instanceId || null; + const mgr = this._getManager(instanceId); + if (!mgr) return response.serviceUnavailable(res, 'No Soulseek instance available'); + + let data = null; + try { + data = await mgr.client.getConversation(username); + } catch (err) { + // slskd returns 404 when no conversation exists yet — return an empty one + if (err.message && /HTTP 404/i.test(err.message)) { + return res.json({ success: true, conversation: { username, hasUnread: false, messages: [] } }); + } + throw err; + } + if (!data) return res.json({ success: true, conversation: { username, hasUnread: false, messages: [] } }); + + const normaliseDir = (m) => { + const d = m.direction ?? m.Direction; + return (d === 'Outgoing' || d === 'Out' || d === 1) ? 'Outgoing' : 'Incoming'; + }; + + const conv = { + username: data.username || data.Username || username, + hasUnread: !!(data.hasUnacknowledgedMessages || data.HasUnacknowledgedMessages), + messages: (data.messages || data.Messages || []).map(m => ({ + id: m.id ?? m.Id ?? 0, + timestamp: m.timestamp || m.Timestamp || m.sentAt || '', + username: m.username || m.Username || '', + message: m.message || m.Message || m.text || '', + isAcknowledged: !!(m.isAcknowledged || m.IsAcknowledged), + direction: normaliseDir(m) + })) + }; + + res.json({ success: true, conversation: conv }); + } catch (err) { + logger.error('[SlskdChatAPI] getConversation:', err.message); + response.serverError(res, err.message); + } + } + + /** + * GET /api/slskd/conversations + * Returns all private conversations from the slskd instance. + */ + async getConversations(req, res) { + try { + const instanceId = req.query.instanceId || null; + const mgr = this._getManager(instanceId); + if (!mgr) return response.serviceUnavailable(res, 'No Soulseek instance available'); + + const data = await mgr.client.getConversations(); + const conversations = Array.isArray(data) ? data : []; + + // Normalise shape: ensure hasUnacknowledgedMessages + sorted by latest message + const normalised = conversations + .map(c => ({ + username: c.username || c.Username || c.id || '', + hasUnread: !!(c.hasUnacknowledgedMessages || c.HasUnacknowledgedMessages), + messages: (c.messages || c.Messages || []).map(m => ({ + id: m.id || m.Id || 0, + timestamp: m.timestamp || m.Timestamp || m.sentAt || '', + username: m.username || m.Username || '', + message: m.message || m.Message || m.text || '', + isAcknowledged: !!(m.isAcknowledged || m.IsAcknowledged), + direction: (() => { const d = m.direction ?? m.Direction; return (d === 'Outgoing' || d === 'Out' || d === 1) ? 'Outgoing' : 'Incoming'; })() + })) + })) + .filter(c => c.username) + .sort((a, b) => { + const aLast = a.messages[a.messages.length - 1]?.timestamp || ''; + const bLast = b.messages[b.messages.length - 1]?.timestamp || ''; + return bLast.localeCompare(aLast); + }); + + res.json({ success: true, conversations: normalised, instanceId: mgr.instanceId }); + } catch (err) { + logger.error('[SlskdChatAPI] getConversations:', err.message); + response.serverError(res, err.message); + } + } + + /** + * POST /api/slskd/conversations/:username + * Send a private message to a user. + * Body: { message: "text" } + */ + async sendMessage(req, res) { + try { + const { username } = req.params; + const { message, instanceId } = req.body || {}; + + if (!username || !message || !String(message).trim()) { + return response.badRequest(res, 'username and message are required'); + } + + const mgr = this._getManager(instanceId || null); + if (!mgr) return response.serviceUnavailable(res, 'No Soulseek instance available'); + + await mgr.client.sendConversationMessage(username, String(message).trim()); + res.json({ success: true }); + } catch (err) { + logger.error('[SlskdChatAPI] sendMessage:', err.message); + response.serverError(res, err.message); + } + } + + /** + * PUT /api/slskd/conversations/:username/messages/:id/acknowledge + * Acknowledge (mark as read) a single message. + */ + async acknowledgeMessage(req, res) { + try { + const { username, id } = req.params; + const instanceId = req.body?.instanceId || req.query.instanceId || null; + + const mgr = this._getManager(instanceId); + if (!mgr) return response.serviceUnavailable(res, 'No Soulseek instance available'); + + await mgr.client.acknowledgeConversationMessage(username, id); + res.json({ success: true }); + } catch (err) { + // Acknowledge failures are non-critical — log but don't surface + logger.warn('[SlskdChatAPI] acknowledgeMessage:', err.message); + res.json({ success: false, error: err.message }); + } + } + + /** + * DELETE /api/slskd/conversations/:username + * Delete an entire conversation. + */ + async deleteConversation(req, res) { + try { + const { username } = req.params; + const instanceId = req.query.instanceId || null; + + const mgr = this._getManager(instanceId); + if (!mgr) return response.serviceUnavailable(res, 'No Soulseek instance available'); + + await mgr.client.deleteConversation(username); + res.json({ success: true }); + } catch (err) { + logger.error('[SlskdChatAPI] deleteConversation:', err.message); + response.serverError(res, err.message); + } + } + + /** + * GET /api/slskd/users/:username + * Returns basic user info including presence status from slskd. + */ + async getUserStatus(req, res) { + try { + const { username } = req.params; + const instanceId = req.query.instanceId || null; + const mgr = this._getManager(instanceId); + if (!mgr) return response.serviceUnavailable(res, 'No Soulseek instance available'); + + const data = await mgr.client.getUserInfo(username); + const raw = data?.status || data?.Status || data?.presence || data?.Presence || 'none'; + res.json({ success: true, username, status: String(raw).toLowerCase() }); + } catch (_err) { + // User not found / not online — return neutral status + res.json({ success: true, username: req.params.username, status: 'none' }); + } + } + + registerRoutes(app) { + const router = express.Router(); + router.use(express.json()); + router.use(requireCapability('search')); // reuse 'search' cap — chat requires slskd access + + router.get('/', this.getConversations.bind(this)); + router.get('/:username', this.getConversation.bind(this)); + router.post('/:username', this.sendMessage.bind(this)); + router.put('/:username/messages/:id/acknowledge', this.acknowledgeMessage.bind(this)); + router.delete('/:username', this.deleteConversation.bind(this)); + + app.use('/api/slskd/conversations', router); + + const usersRouter = express.Router(); + usersRouter.use(express.json()); + usersRouter.use(requireCapability('search')); + usersRouter.get('/:username', this.getUserStatus.bind(this)); + app.use('/api/slskd/users', usersRouter); + } +} + +module.exports = new SlskdChatAPI(); diff --git a/server/modules/slskdManager.js b/server/modules/slskdManager.js new file mode 100644 index 0000000..c95e24a --- /dev/null +++ b/server/modules/slskdManager.js @@ -0,0 +1,643 @@ +'use strict'; + +const BaseClientManager = require('../lib/BaseClientManager'); +const SlskdClient = require('../lib/slskd/SlskdClient'); +const { normalizeSlskdDownload, normalizeSlskdSharedFile } = require('../lib/downloadNormalizer'); +const logger = require('../lib/logger'); +const eventScriptingManager = require('../lib/EventScriptingManager'); + +class SlskdManager extends BaseClientManager { + constructor() { + super(); + this.clientType = 'slskd'; + this.client = null; + this.searchInProgress = false; + + this.lastDownloadsById = new Map(); + this.lastSearchResults = []; + this.lastSearchByKey = new Map(); + this.lastSearchTimestamp = 0; + this._seenEventIds = new Set(); + this.lastStats = { + downloadSpeed: 0, + uploadSpeed: 0, + totalDownloaded: 0, + totalUploaded: 0 + }; + } + + async initClient() { + // Match the same contract used by other managers: read from _clientConfig. + if (!this._clientConfig || !this._clientConfig.enabled) { + this.log(' slskd integration is disabled'); + return false; + } + + if (!this._clientConfig.host) { + this.log(' slskd host not configured'); + return false; + } + + this.client = new SlskdClient({ + host: this._clientConfig.host, + port: this._clientConfig.port, + path: this._clientConfig.path, + useSsl: !!this._clientConfig.useSsl, + apiKey: this._clientConfig.apiKey, + username: this._clientConfig.username, + password: this._clientConfig.password + }); + + this._downloadDirectory = this._clientConfig.downloadDirectory || ''; + + return true; + } + + async startConnection() { + if (!this._clientConfig || !this._clientConfig.enabled) { + this.log('ℹ️ slskd integration is disabled, skipping connection'); + return; + } + + try { + if (!this.client) { + const ready = await this.initClient(); + if (!ready) { + return; + } + } + + const test = await this.client.testConnection(); + if (!test.success) { + throw new Error(test.error || 'Failed to connect to slskd'); + } + + this._clearConnectionError(); + this.clearReconnect(); + this.log('Connected to slskd successfully'); + // Notify onConnect listeners (e.g. category sync) + this._onConnectCallbacks.forEach(cb => cb()); + } catch (err) { + this.error('❌ Failed to connect to slskd:', logger.errorDetail(err)); + this._setConnectionError(err); + if (this.client) { + await this.client.disconnect(); + } + this.client = null; + this.scheduleReconnect(30000); + } + } + + isConnected() { + return !!this.client && this.client.isConnected(); + } + + /** + * Register the slskd download directory with the CategoryManager on connect. + * Called by server.js via the onConnect/onConnectSync pattern. + */ + async onConnectSync(categoryManager) { + if (this._downloadDirectory) { + categoryManager.setClientDefaultPath(this.instanceId, this._downloadDirectory); + } + } + + acquireSearchLock() { + if (this.searchInProgress) return false; + this.searchInProgress = true; + return true; + } + + releaseSearchLock() { + this.searchInProgress = false; + } + + isSearchInProgress() { + return this.searchInProgress; + } + + _buildSearchKey(entry) { + const id = entry?.id ? String(entry.id) : ''; + const username = String(entry?.username || '').toLowerCase(); + const filename = String(entry?.filename || '').toLowerCase(); + const size = Number(entry?.size || 0); + return `${id}|${username}|${filename}|${size}`; + } + + _extractDirectoryPath(filename) { + const parts = String(filename || '').split(/[\\/]/g).filter(Boolean); + if (parts.length <= 1) return ''; + return parts.slice(0, -1).join('/'); + } + + _normalizeSearchResult(result) { + const key = this._buildSearchKey(result); + const directoryPath = this._extractDirectoryPath(result.filename); + return { + fileHash: key, + fileName: result.filename, + fileSize: result.size || 0, + sourceCount: 1, + username: result.username, + bitrate: result.bitrate, + length: result.length, + directoryPath, + canBrowseDirectory: !!directoryPath, + isSlskd: true, + raw: result.raw || result + }; + } + + hasSearchResult(fileHash) { + return this.lastSearchByKey.has(String(fileHash || '')); + } + + async search(query) { + if (!this.client) { + const ready = await this.initClient(); + if (!ready || !this.client) throw new Error('slskd not connected'); + } + + const response = await this.client.searchText(query, { maxWaitMs: 45000, pollIntervalMs: 1500 }); + const normalized = (response.results || []).map((result) => this._normalizeSearchResult(result)); + + this.lastSearchByKey.clear(); + for (let i = 0; i < normalized.length; i++) { + this.lastSearchByKey.set(normalized[i].fileHash, response.results[i]); + } + + this.lastSearchResults = normalized; + this.lastSearchTimestamp = Date.now(); + + return { + results: normalized, + resultsLength: normalized.length, + searchId: response.searchId || null + }; + } + + async getSearchResults() { + return { results: this.lastSearchResults || [] }; + } + + async addSearchResult(fileHash, _categoryId = 0, username = null) { + if (!this.client) throw new Error('slskd not connected'); + + const entry = this.lastSearchByKey.get(String(fileHash || '')); + if (!entry) throw new Error(`Search result not found: ${fileHash}`); + + await this.client.enqueueDownloads(entry.username, [{ + filename: entry.filename, + size: Number(entry.size) || 0 + }]); + + this.trackDownload(fileHash, entry.filename || 'Unknown', Number(entry.size) || null, username, null); + return true; + } + + async getDirectoryContents(username, directory) { + if (!this.client) { + const ready = await this.initClient(); + if (!ready || !this.client) throw new Error('slskd not connected'); + } + + const payload = await this.client.getUserDirectoryContents(username, directory); + + // The API returns the Directory DTO { name, fileCount, files: [...] } + // or an array of Directory DTOs. Normalize to a flat file list. + const extractFiles = (node, parentDir = '') => { + if (!node) return []; + if (Array.isArray(node)) { + return node.flatMap((n) => extractFiles(n, parentDir)); + } + if (typeof node === 'object') { + const dirName = node.name || node.Name || parentDir; + const rawFiles = node.files || node.Files || []; + return rawFiles.map((f) => { + const filename = f.filename || f.Filename || f.name || f.Name || ''; + const key = this._buildSearchKey({ + id: f.token || f.Token || null, + username, + filename: `${dirName}/${filename}`.replace(/\/+/g, '/'), + size: f.size || f.Size || 0 + }); + return { + fileHash: key, + fileName: filename, + fileSize: Number(f.size || f.Size || 0), + sourceCount: 1, + username, + bitrate: f.bitrate || f.Bitrate || null, + length: f.length || f.Length || null, + directoryPath: dirName, + canBrowseDirectory: false, + isSlskd: true, + raw: { ...f, username, directory: dirName } + }; + }); + } + return []; + }; + + const files = extractFiles(payload); + + // Register expanded files in the search key map so they can be downloaded + for (const file of files) { + if (!this.lastSearchByKey.has(file.fileHash)) { + const entry = file.raw; + const fullFilename = `${entry.directory || ''}/${entry.filename || entry.name || file.fileName}`.replace(/\/+/g, '/'); + this.lastSearchByKey.set(file.fileHash, { + username, + filename: fullFilename, + size: file.fileSize + }); + } + } + + return files; + } + + _flattenGroupedTransfers(grouped = []) { + if (!Array.isArray(grouped)) { + return []; + } + + const files = []; + for (const userGroup of grouped) { + const username = userGroup?.username || userGroup?.Username || 'unknown'; + const directories = userGroup?.directories || userGroup?.Directories || []; + for (const directoryGroup of directories) { + const directory = directoryGroup?.directory || directoryGroup?.Directory || ''; + const entries = directoryGroup?.files || directoryGroup?.Files || []; + for (const file of entries) { + files.push({ + ...file, + username, + directory + }); + } + } + } + + return files; + } + + _refreshTransferCache(downloads = []) { + this.lastDownloadsById.clear(); + for (const transfer of downloads) { + const id = String(transfer.id || transfer.Id || '').toLowerCase(); + if (id) { + this.lastDownloadsById.set(id, transfer); + } + } + } + + _extractShares(payload) { + const collected = []; + const visit = (node) => { + if (!node) return; + if (Array.isArray(node)) { + for (const entry of node) visit(entry); + return; + } + if (typeof node !== 'object') return; + + if (node.id || node.localPath || node.remotePath || node.alias) { + collected.push(node); + return; + } + + for (const value of Object.values(node)) { + visit(value); + } + }; + + visit(payload); + + const unique = new Map(); + for (const share of collected) { + const key = String(share.id || share.alias || share.remotePath || share.localPath || '').toLowerCase(); + if (key && !unique.has(key)) { + unique.set(key, share); + } + } + return Array.from(unique.values()); + } + + async _fetchSharedFiles() { + const shares = this._extractShares(await this.client.getShares()); + if (shares.length === 0) { + return []; + } + + const sharedFiles = []; + for (const share of shares) { + const shareId = share.id || share.Id; + if (!shareId) continue; + + let directories = []; + try { + directories = await this.client.getShareContents(shareId); + } catch (err) { + this.warn(`Failed to fetch slskd share contents for ${share.alias || shareId}:`, logger.errorDetail(err)); + continue; + } + + for (const directory of (Array.isArray(directories) ? directories : [])) { + const files = Array.isArray(directory?.files || directory?.Files) ? (directory.files || directory.Files) : []; + for (const file of files) { + sharedFiles.push(normalizeSlskdSharedFile(file, { + instanceId: this.instanceId, + displayName: this.displayName, + share, + directory + })); + } + } + } + + return sharedFiles; + } + + _deriveStats(downloads = [], uploads = []) { + const inProgress = (state) => String(state || '') === 'InProgress'; + const toNumber = (v) => Number(v) || 0; + + const downloadSpeed = downloads + .filter((d) => inProgress(d.state || d.State)) + .reduce((sum, d) => sum + toNumber(d.averageSpeed || d.AverageSpeed), 0); + + const totalDownloaded = downloads.reduce((sum, d) => sum + toNumber(d.bytesTransferred || d.BytesTransferred), 0); + + const uploadSpeed = uploads + .filter((u) => inProgress(u.state || u.State)) + .reduce((sum, u) => sum + toNumber(u.averageSpeed || u.AverageSpeed), 0); + + const totalUploaded = uploads + .filter((u) => { + const state = String(u.state || u.State || ''); + return state === 'Completed' || state === 'Succeeded' || state.startsWith('Completed,'); + }) + .reduce((sum, u) => sum + toNumber(u.bytesTransferred || u.BytesTransferred || u.size || u.Size), 0); + + this.lastStats = { + downloadSpeed, + uploadSpeed, + totalDownloaded, + totalUploaded + }; + + return this.lastStats; + } + + async _pollUploadEvents() { + if (!this.client) return; + try { + const events = await this.client.getEvents(); + if (!Array.isArray(events)) return; + for (const event of events) { + const id = event?.id ?? event?.Id; + if (id == null || this._seenEventIds.has(String(id))) continue; + this._seenEventIds.add(String(id)); + const type = String(event?.type || event?.Type || ''); + if (type !== 'UploadFileComplete') continue; + // EventRecord.data is a JSON string per the API spec + let parsedData = null; + try { if (event.data) parsedData = JSON.parse(event.data); } catch (_) {} + const filename = + parsedData?.filename || parsedData?.Filename || + parsedData?.fileName || parsedData?.FileName || + event.data || 'Unknown'; + const username = parsedData?.username || parsedData?.Username || ''; + eventScriptingManager.emit('uploadFinished', { + filename, + name: filename, + username, + instanceId: this.instanceId, + instanceName: this.displayName, + clientType: this.clientType + }); + } + // Bound the seen-set to avoid unbounded growth + if (this._seenEventIds.size > 1000) this._seenEventIds.clear(); + } catch (_err) { + // Non-critical — upload event polling failure should not affect data fetch + } + } + + async fetchData() { + if (!this.client) { + const ready = await this.initClient(); + if (!ready || !this.client) { + return { downloads: [], sharedFiles: [] }; + } + } + + try { + const [grouped, groupedUploads, sharedFiles] = await Promise.all([ + this.client.getDownloads(false), + this.client.getUploads(false), + this._fetchSharedFiles() + ]); + const downloads = this._flattenGroupedTransfers(grouped); + const uploads = this._flattenGroupedTransfers(groupedUploads); + this._refreshTransferCache(downloads); + this._deriveStats(downloads, uploads); + await this._pollUploadEvents(); + + const items = downloads.map((transfer) => normalizeSlskdDownload(transfer, { + instanceId: this.instanceId, + displayName: this.displayName, + clientType: this.clientType, + downloadDirectory: this._downloadDirectory || '' + })); + + return { + downloads: items, + sharedFiles + }; + } catch (err) { + this.error('❌ Error fetching slskd downloads:', logger.errorDetail(err)); + this._setConnectionError(err); + if (this.client) { + await this.client.disconnect(); + } + this.client = null; + this.scheduleReconnect(30000); + return { downloads: [], sharedFiles: [] }; + } + } + + async getLog() { + if (!this.client) { + const ready = await this.initClient(); + if (!ready || !this.client) { + throw new Error('slskd not connected'); + } + } + + const logs = await this.client.getLogs(); + if (Array.isArray(logs)) { + return logs.map((entry) => { + if (typeof entry === 'string') return entry; + if (entry && typeof entry === 'object') return JSON.stringify(entry); + return String(entry ?? ''); + }).join('\n'); + } + + if (logs && typeof logs === 'object') { + return JSON.stringify(logs, null, 2); + } + + return String(logs ?? ''); + } + + async getGlobalStats() { + let telemetry = null; + try { + if (this.client) { + telemetry = await this.client.getTelemetrySummary(); + } + } catch (_) { + // Non-critical — telemetry unavailable on older slskd versions + } + return { + downloadSpeed: this.lastStats.downloadSpeed || 0, + uploadSpeed: this.lastStats.uploadSpeed || 0, + downloadTotal: telemetry?.Download?.Succeeded?.totalBytes ?? this.lastStats.totalDownloaded ?? 0, + uploadTotal: telemetry?.Upload?.Succeeded?.totalBytes ?? this.lastStats.totalUploaded ?? 0, + activeConnections: 0, + listenPort: 0, + isConnected: this.isConnected(), + networkStatus: this.getNetworkStatus() + }; + } + + async getStats() { + return await this.getGlobalStats(); + } + + extractMetrics(rawStats = {}) { + return { + uploadSpeed: rawStats.uploadSpeed || 0, + downloadSpeed: rawStats.downloadSpeed || 0, + uploadTotal: rawStats.uploadTotal || 0, + downloadTotal: rawStats.downloadTotal || 0 + }; + } + + getNetworkStatus() { + const connected = this.isConnected(); + return { + status: connected ? 'green' : 'red', + text: connected ? 'Connected' : 'Disconnected', + listenPort: this._clientConfig?.port || null + }; + } + + _getCachedTransferByHash(hash) { + const key = String(hash || '').toLowerCase(); + return this.lastDownloadsById.get(key); + } + + async pause(fileHash) { + const transfer = this._getCachedTransferByHash(fileHash); + if (!transfer) { + throw new Error(`Transfer not found: ${fileHash}`); + } + + await this.client.cancelDownload(transfer.username, transfer.id, false); + return true; + } + + async stop(fileHash) { + return this.pause(fileHash); + } + + async resume(fileHash) { + const transfer = this._getCachedTransferByHash(fileHash); + if (!transfer) { + throw new Error(`Transfer not found: ${fileHash}`); + } + + await this.client.enqueueDownloads(transfer.username, [{ + filename: transfer.filename, + size: Number(transfer.size) || 0 + }]); + + return true; + } + + async deleteItem(fileHash) { + const transfer = this._getCachedTransferByHash(fileHash); + if (!transfer) { + return { + success: false, + error: `Transfer not found: ${fileHash}` + }; + } + + await this.client.cancelDownload(transfer.username, transfer.id, true); + return { + success: true, + pathsToDelete: [] + }; + } + + async addMagnet() { + throw new Error('Adding magnet links is not supported by slskd integration'); + } + + async addTorrentRaw() { + throw new Error('Adding torrent files is not supported by slskd integration'); + } + + async setCategoryOrLabel() { + return true; + } + + /** + * Extract normalized history metadata from a normalized slskd download item + * @param {Object} item - Normalized slskd download data + * @returns {Object} Normalized metadata for history DB + */ + extractHistoryMetadata(item) { + const size = item?.size || 0; + const downloaded = item?.isComplete ? size : (item?.downloaded || 0); + const uploaded = item?.uploadTotal || 0; + const ratio = downloaded > 0 ? uploaded / downloaded : 0; + + return { + hash: item?.hash?.toLowerCase(), + instanceId: item?.instanceId || this.instanceId, + size, + name: item?.name || item?.rawName || item?.raw?.filename || 'Unknown', + downloaded, + uploaded, + ratio, + trackerDomain: null, + directory: item?.directory || item?.raw?.directory || null, + multiFile: false, + category: item?.category || null + }; + } + + // Backward-compatible alias for older call sites. + async getHistoryMetadata(normalizedItem) { + return this.extractHistoryMetadata(normalizedItem); + } + + async cleanup() { + if (this.client) { + await this.client.disconnect(); + this.client = null; + } + this.releaseSearchLock(); + } + + async shutdown() { + return this.cleanup(); + } +} + +module.exports = SlskdManager; \ No newline at end of file diff --git a/server/modules/slskdRoomsAPI.js b/server/modules/slskdRoomsAPI.js new file mode 100644 index 0000000..5c30688 --- /dev/null +++ b/server/modules/slskdRoomsAPI.js @@ -0,0 +1,195 @@ +/** + * Soulseek Rooms API + * + * REST endpoints for slskd chat rooms. + * Proxies requests to the connected slskd instance. + */ + +'use strict'; + +const express = require('express'); +const BaseModule = require('../lib/BaseModule'); +const registry = require('../lib/ClientRegistry'); +const response = require('../lib/responseFormatter'); +const { requireCapability } = require('../middleware/capabilities'); +const logger = require('../lib/logger'); + +class SlskdRoomsAPI extends BaseModule { + + _getManager(instanceId) { + if (instanceId) { + const mgr = registry.get(instanceId); + if (mgr && mgr.clientType === 'slskd') return mgr; + } + const all = registry.getByType('slskd'); + return all.find(m => m.isConnected?.()) || all[0] || null; + } + + /** + * GET /api/slskd/rooms + * Returns all joined rooms, each with normalised message list. + * Also includes ownUsername so the frontend can highlight own messages. + */ + async getRooms(req, res) { + try { + const instanceId = req.query.instanceId || null; + const mgr = this._getManager(instanceId); + if (!mgr) return response.serviceUnavailable(res, 'No Soulseek instance available'); + + // slskd GET /rooms/joined returns an array of room name strings, NOT Room objects. + const joined = await mgr.client.getRooms(); + const roomNames = Array.isArray(joined) ? joined.filter(n => typeof n === 'string') : []; + + // Fetch full room data for each joined room in parallel + const results = await Promise.allSettled( + roomNames.map(name => mgr.client.getRoomByName(name)) + ); + + const rooms = results.map((result, i) => { + const name = roomNames[i]; + if (result.status !== 'fulfilled' || !result.value) { + return { name, messages: [], users: [], userCount: 0, isPrivate: false }; + } + return this._normaliseRoom(result.value, name); + }).filter(r => r.name); + + res.json({ + success: true, + rooms, + ownUsername: mgr.client.username || '', + instanceId: mgr.instanceId + }); + } catch (err) { + logger.error('[SlskdRoomsAPI] getRooms:', err.message); + response.serverError(res, err.message); + } + } + + /** + * GET /api/slskd/rooms/:roomName + * Returns a single joined room with its messages and users. + */ + async getRoom(req, res) { + try { + const roomName = decodeURIComponent(req.params.roomName); + const instanceId = req.query.instanceId || null; + const mgr = this._getManager(instanceId); + if (!mgr) return response.serviceUnavailable(res, 'No Soulseek instance available'); + + const d = await mgr.client.getRoomByName(roomName); + res.json({ success: true, room: this._normaliseRoom(d, roomName), instanceId: mgr.instanceId }); + } catch (err) { + logger.error('[SlskdRoomsAPI] getRoom:', err.message); + if (err.message && /HTTP 404/i.test(err.message)) { + return response.notFound(res, 'Room not found'); + } + response.serverError(res, err.message); + } + } + + /** Normalise a raw slskd Room object into our API shape. */ + _normaliseRoom(d, fallbackName) { + const users = (d.users || d.Users || []).map(u => ({ + username: u.username || u.Username || '', + status: String(u.status || u.Status || 'none').toLowerCase(), + countryCode: u.countryCode || u.CountryCode || '', + self: !!(u.self || u.Self), + })); + return { + name: d.name || d.Name || fallbackName, + isPrivate: !!(d.isPrivate || d.IsPrivate), + userCount: users.length, + users, + messages: (d.messages || d.Messages || []).map(m => ({ + username: m.username || m.Username || '', + message: m.message || m.Message || '', + timestamp: m.timestamp || m.Timestamp || '', + self: !!(m.self || m.Self), + })), + }; + } + + /** + * POST /api/slskd/rooms + * Join a room. + * Body: { roomName, instanceId? } + */ + async joinRoom(req, res) { + try { + const { roomName, instanceId } = req.body || {}; + if (!roomName || !String(roomName).trim()) { + return response.badRequest(res, 'roomName is required'); + } + + const mgr = this._getManager(instanceId || null); + if (!mgr) return response.serviceUnavailable(res, 'No Soulseek instance available'); + + await mgr.client.joinRoom(String(roomName).trim()); + res.json({ success: true }); + } catch (err) { + logger.error('[SlskdRoomsAPI] joinRoom:', err.message); + response.serverError(res, err.message); + } + } + + /** + * DELETE /api/slskd/rooms/:roomName + * Leave a room. + */ + async leaveRoom(req, res) { + try { + const { roomName } = req.params; + const instanceId = req.query.instanceId || null; + + const mgr = this._getManager(instanceId); + if (!mgr) return response.serviceUnavailable(res, 'No Soulseek instance available'); + + await mgr.client.leaveRoom(roomName); + res.json({ success: true }); + } catch (err) { + logger.error('[SlskdRoomsAPI] leaveRoom:', err.message); + response.serverError(res, err.message); + } + } + + /** + * POST /api/slskd/rooms/:roomName/messages + * Send a message to a room. + * Body: { message, instanceId? } + */ + async sendMessage(req, res) { + try { + const { roomName } = req.params; + const { message, instanceId } = req.body || {}; + + if (!message || !String(message).trim()) { + return response.badRequest(res, 'message is required'); + } + + const mgr = this._getManager(instanceId || null); + if (!mgr) return response.serviceUnavailable(res, 'No Soulseek instance available'); + + await mgr.client.sendRoomMessage(roomName, String(message).trim()); + res.json({ success: true }); + } catch (err) { + logger.error('[SlskdRoomsAPI] sendMessage:', err.message); + response.serverError(res, err.message); + } + } + + registerRoutes(app) { + const router = express.Router(); + router.use(express.json()); + router.use(requireCapability('search')); + + router.get('/', this.getRooms.bind(this)); + router.get('/:roomName', this.getRoom.bind(this)); + router.post('/', this.joinRoom.bind(this)); + router.delete('/:roomName', this.leaveRoom.bind(this)); + router.post('/:roomName/messages', this.sendMessage.bind(this)); + + app.use('/api/slskd/rooms', router); + } +} + +module.exports = new SlskdRoomsAPI(); diff --git a/server/modules/torznabAPI.js b/server/modules/torznabAPI.js index 5742c1e..8a7fae3 100644 --- a/server/modules/torznabAPI.js +++ b/server/modules/torznabAPI.js @@ -5,7 +5,9 @@ const BaseModule = require('../lib/BaseModule'); const TorznabHandler = require('../lib/torznab/TorznabHandler'); +const SoulseekTorznabHandler = require('../lib/torznab/SoulseekTorznabHandler'); const config = require('./config'); +const clientMeta = require('../lib/clientMeta'); const response = require('../lib/responseFormatter'); // Client registry - replaces direct singleton manager imports @@ -15,21 +17,41 @@ class TorznabAPI extends BaseModule { constructor() { super(); this.handler = new TorznabHandler(); - // Initialize handler dependencies (uses configured or first aMule instance) + this.soulseekHandler = new SoulseekTorznabHandler(); + // Initialize handler dependencies. + // Uses arrDownloadInstanceId (new generic key) -> amuleInstanceId (legacy) -> first + // connected instance with search capability. Returns raw aMule client for backward + // compat with TorznabHandler; non-aMule providers are gated with a warning. this.handler.setDependencies({ - getAmuleClient: () => { - const configuredId = config.getConfig()?.integrations?.amuleInstanceId; - let amuleMgr; + getSearchProviderClient: () => { + const integrations = config.getConfig()?.integrations || {}; + const configuredId = integrations.arrDownloadInstanceId || integrations.amuleInstanceId; + let mgr; if (configuredId) { - amuleMgr = registry.get(configuredId); - if (!amuleMgr) { - amuleMgr = registry.getByType('amule').find(m => m.isConnected()); - if (amuleMgr) this.warn(`⚠️ [TorznabAPI.getAmuleClient] Configured amuleInstanceId "${configuredId}" not found, falling back to "${amuleMgr.instanceId}"`); + mgr = registry.get(configuredId); + if (!mgr) { + mgr = registry.getByType('amule').find(m => m.isConnected()); + if (mgr) this.warn(`⚠️ [TorznabAPI] Configured provider "${configuredId}" not found, falling back to "${mgr.instanceId}"`); } } else { - amuleMgr = registry.getByType('amule').find(m => m.isConnected()); + mgr = registry.getByType('amule').find(m => m.isConnected()); } - return amuleMgr?.getClient() || null; + if (!mgr) return null; + if (!clientMeta.hasCapability(mgr.clientType, 'search')) { + this.warn(`⚠️ [TorznabAPI] Provider "${mgr.instanceId}" (${mgr.clientType}) does not support search`); + return null; + } + // Only aMule exposes a raw client with searchAndWaitResults; other types + // (e.g. slskd) will be routed here once Phase 4 indexer support is added. + return mgr.getClient?.() || null; + } + }); + + // Soulseek indexer — resolves the first connected slskd instance + this.soulseekHandler.setDependencies({ + getSlskdManager: () => { + const mgrs = registry.getByType('slskd').filter(m => m.isConnected?.()); + return mgrs[0] || null; } }); } @@ -72,6 +94,7 @@ class TorznabAPI extends BaseModule { */ registerRoutes(app) { app.get('/indexer/amule/api', this.checkApiKey.bind(this), this.handler.handleRequest); + app.get('/indexer/soulseek/api', this.checkApiKey.bind(this), this.soulseekHandler.handleRequest); this.log('🔍 Torznab API routes registered with authentication'); } diff --git a/server/modules/webSocketHandlers.js b/server/modules/webSocketHandlers.js index c571cf3..2f3fed1 100644 --- a/server/modules/webSocketHandlers.js +++ b/server/modules/webSocketHandlers.js @@ -49,8 +49,10 @@ const ACTION_CAPABILITIES = { getLog: ['view_logs'], getAppLog: ['view_logs'], getQbittorrentLog: ['view_logs'], + getSlskdLog: ['view_logs'], getStatsTree: ['view_statistics'], refreshSharedFiles: ['view_shared'], + browseSlskdDirectory: ['search'], renameFile: ['rename_files'], setFileRatingComment: ['set_comment'], checkDeletePermissions: ['remove_downloads'], @@ -60,9 +62,15 @@ const ACTION_CAPABILITIES = { class WebSocketHandlers extends BaseModule { constructor() { super(); - // Track when the last aMule search was performed - this.lastAmuleSearchTimestamp = 0; - this.lastAmuleSearchInstanceId = null; + // Track latest search result timestamps per provider + this.lastSearchTimestamp = { + amule: 0, + slskd: 0 + }; + this.lastSearchInstanceId = { + amule: null, + slskd: null + }; } /** @@ -85,6 +93,25 @@ class WebSocketHandlers extends BaseModule { return null; } + _resolveSearchManager(data) { + const explicitSoulseek = data?.provider === 'soulseek' || data?.type === 'soulseek'; + if (explicitSoulseek) { + return this._getManager(null, 'slskd'); + } + + if (data?.instanceId) { + const byId = registry.get(data.instanceId); + // Only use the explicit instance for aMule-type searches — never route + // a kad/global search to a slskd instance even if its instanceId was sent. + if (byId?.clientType !== 'slskd' && byId?.isConnected?.() && clientMeta.hasCapability(byId.clientType, 'search')) { + return byId; + } + } + + // Default provider remains aMule for legacy search types (global/kad/local) + return this._getManager(null, 'amule'); + } + /** * Parse cookies from cookie header * @param {string} cookieHeader - Cookie header string @@ -244,7 +271,10 @@ class WebSocketHandlers extends BaseModule { context.log(`New WebSocket connection from ${clientIp}${locationInfo}`); context.send({ type: 'connected', message: 'Connected to aMule Controller' }); - context.send({ type: 'search-lock', locked: registry.getByType('amule').some(m => m.isSearchInProgress()) }); + context.send({ + type: 'search-lock', + locked: [...registry.getByType('amule'), ...registry.getByType('slskd')].some(m => m.isSearchInProgress?.()) + }); // Send cached batch update to newly connected client (if available), filtered by ownership // Always sends full snapshot (items array), never delta, for new connections @@ -315,6 +345,8 @@ class WebSocketHandlers extends BaseModule { case 'getLog': await this.handleGetLog(data, context); break; case 'getAppLog': await this.handleGetAppLog(data, context); break; case 'getQbittorrentLog': await this.handleGetQbittorrentLog(data, context); break; + case 'getSlskdLog': await this.handleGetSlskdLog(data, context); break; + case 'browseSlskdDirectory': await this.handleBrowseSlskdDirectory(data, context); break; case 'batchDownloadSearchResults': await this.handleBatchDownloadSearchResults(data, context); break; case 'addEd2kLinks': await this.handleAddEd2kLinks(data, context); break; case 'addMagnetLinks': await this.handleAddMagnetLinks(data, context); break; @@ -349,13 +381,15 @@ class WebSocketHandlers extends BaseModule { // Handler implementations async handleSearch(data, context) { - const manager = this._getManager(data.instanceId, 'amule'); + const manager = this._resolveSearchManager(data); if (!manager) { - context.send({ type: 'error', message: 'No aMule instance available' }); + context.send({ type: 'error', message: 'No search-capable instance available' }); return; } - if (!manager.acquireSearchLock()) { - context.send({ type: 'error', message: 'Another search is running on this instance' }); + if (!manager.acquireSearchLock?.()) { + // Unlock the frontend immediately — it set searchLocked=true optimistically + context.send({ type: 'search-lock', locked: false }); + context.send({ type: 'error', message: 'Another search is already running on this instance' }); return; } @@ -364,16 +398,22 @@ class WebSocketHandlers extends BaseModule { try { const result = await manager.search(data.query, data.type, data.extension); - // Track timestamp and instance for comparison with Prowlarr results - this.lastAmuleSearchTimestamp = Date.now(); - this.lastAmuleSearchInstanceId = manager.instanceId; - context.broadcast({ type: 'search-results', data: result.results || [], instanceId: manager.instanceId }, searchFilter); - context.log(`Search completed on ${manager.displayName}: ${result.resultsLength || 0} results found`); + const provider = manager.clientType === 'slskd' ? 'slskd' : 'amule'; + this.lastSearchTimestamp[provider] = Date.now(); + this.lastSearchInstanceId[provider] = manager.instanceId; + const results = Array.isArray(result?.results) ? result.results : []; + context.broadcast({ + type: 'search-results', + data: results, + instanceId: manager.instanceId, + clientType: manager.clientType + }, searchFilter); + context.log(`Search completed on ${manager.displayName}: ${result?.resultsLength || results.length || 0} results found`); } catch (err) { context.error('Search error:', err); context.send({ type: 'error', message: 'Search failed: ' + err.message }); } finally { - manager.releaseSearchLock(); + manager.releaseSearchLock?.(); context.broadcast({ type: 'search-lock', locked: false }, searchFilter); } } @@ -383,28 +423,63 @@ class WebSocketHandlers extends BaseModule { // Get Prowlarr cached results (already transformed) const prowlarrCache = prowlarrAPI.getCachedResults(); - // Get aMule cached results from the specified or last-searched instance - const instanceId = data?.instanceId || this.lastAmuleSearchInstanceId; - const manager = this._getManager(instanceId, 'amule'); - let amuleResults = []; - try { - if (manager) { - const result = await manager.getSearchResults(); - amuleResults = result.results || []; + const candidates = []; + + const amuleInstanceId = data?.instanceId || this.lastSearchInstanceId.amule; + const amuleMgr = this._getManager(amuleInstanceId, 'amule'); + if (amuleMgr?.getSearchResults) { + try { + const result = await amuleMgr.getSearchResults(); + candidates.push({ + provider: 'amule', + ts: this.lastSearchTimestamp.amule, + results: result.results || [], + instanceId: amuleMgr.instanceId, + clientType: amuleMgr.clientType + }); + } catch (err) { + context.log('aMule search results not available:', err.message); } - } catch (err) { - // aMule might not be connected, that's ok - context.log('aMule search results not available:', err.message); } - // Compare timestamps and return the most recent - if (prowlarrCache.timestamp > this.lastAmuleSearchTimestamp && prowlarrCache.results.length > 0) { - context.send({ type: 'previous-search-results', data: prowlarrCache.results }); - context.log(`Previous search results: ${prowlarrCache.results.length} Prowlarr results (more recent)`); - } else { - context.send({ type: 'previous-search-results', data: amuleResults, instanceId: manager?.instanceId }); - context.log(`Previous search results: ${amuleResults.length} aMule results`); + const slskdInstanceId = data?.instanceId || this.lastSearchInstanceId.slskd; + const slskdMgr = this._getManager(slskdInstanceId, 'slskd'); + if (slskdMgr?.getSearchResults) { + try { + const result = await slskdMgr.getSearchResults(); + candidates.push({ + provider: 'slskd', + ts: this.lastSearchTimestamp.slskd, + results: result.results || [], + instanceId: slskdMgr.instanceId, + clientType: slskdMgr.clientType + }); + } catch (err) { + context.log('slskd search results not available:', err.message); + } } + + if (prowlarrCache.results.length > 0) { + candidates.push({ + provider: 'prowlarr', + ts: prowlarrCache.timestamp, + results: prowlarrCache.results, + instanceId: null, + clientType: 'prowlarr' + }); + } + + const latest = candidates.sort((a, b) => b.ts - a.ts)[0] || { + provider: 'none', ts: 0, results: [], instanceId: null, clientType: null + }; + + context.send({ + type: 'previous-search-results', + data: latest.results, + instanceId: latest.instanceId, + clientType: latest.clientType + }); + context.log(`Previous search results: ${latest.results.length} ${latest.provider} result(s)`); } catch (err) { context.error('Get previous search results error:', err); context.send({ type: 'previous-search-results', data: [] }); @@ -572,6 +647,56 @@ class WebSocketHandlers extends BaseModule { } } + async handleGetSlskdLog(data, context) { + try { + const slskdMgr = this._getManager(data?.instanceId, 'slskd'); + if (!slskdMgr) { + context.send({ type: 'error', message: 'No slskd instance registered' }); + return; + } + const log = await slskdMgr.getLog(); + context.send({ type: 'slskd-log-update', data: log, instanceId: slskdMgr.instanceId }); + } catch (err) { + context.error('Get slskd log error:', err); + context.send({ type: 'error', message: 'Failed to fetch slskd log: ' + err.message }); + } + } + + async handleBrowseSlskdDirectory(data, context) { + const { username, directory, requestId, instanceId } = data || {}; + if (!username || !directory) { + context.send({ type: 'error', message: 'username and directory are required for directory browse' }); + return; + } + + try { + const slskdMgr = this._getManager(instanceId, 'slskd'); + if (!slskdMgr) { + context.send({ type: 'error', message: 'No slskd instance available' }); + return; + } + + const files = await slskdMgr.getDirectoryContents(username, directory); + context.send({ + type: 'slskd-directory-contents', + requestId: requestId || null, + username, + directory, + files, + instanceId: slskdMgr.instanceId + }); + } catch (err) { + context.error('Browse slskd directory error:', err); + context.send({ + type: 'slskd-directory-error', + requestId: requestId || null, + username, + directory, + error: err.message + }); + } + } + async handleBatchDownloadSearchResults(data, context) { try { const { fileHashes, categoryId: rawCategoryId, categoryName } = data; @@ -580,8 +705,39 @@ class WebSocketHandlers extends BaseModule { throw new Error('No file hashes provided for batch download'); } - const manager = this._getManager(data.instanceId, 'amule'); - if (!manager) { throw new Error('No aMule instance available'); } + let manager = this._getManager(data.instanceId, 'amule'); + if (!manager) { + const slskdManagers = registry.getByType('slskd').filter(m => m.isConnected?.()); + manager = slskdManagers.find((m) => fileHashes.every((hash) => m.hasSearchResult?.(hash))) || null; + } + if (!manager) { throw new Error('No compatible client instance available for selected search results'); } + + // Soulseek search downloads don't use categories. + if (manager.clientType === 'slskd') { + const username = context.clientInfo.username !== 'unknown' ? context.clientInfo.username : null; + const results = []; + for (const fileHash of fileHashes) { + try { + const success = await manager.addSearchResult(fileHash, 0, username); + results.push({ fileHash, success }); + if (success && context.clientInfo.userId && this.userManager) { + this.userManager.recordOwnership(itemKey(manager.instanceId, fileHash), context.clientInfo.userId); + } + context.log(`Soulseek download ${success ? 'started' : 'failed'} for: ${fileHash}`); + } catch (err) { + results.push({ fileHash, success: false, error: err.message }); + } + } + + await this.broadcastItemsUpdate(context); + const successCount = results.filter(r => r.success).length; + context.send({ + type: 'batch-download-complete', + results, + message: `Downloaded ${successCount}/${fileHashes.length} files` + }); + return; + } // Support both legacy categoryId and new categoryName let categoryId = 0; diff --git a/server/server.js b/server/server.js index 9d5ef74..b950f91 100644 --- a/server/server.js +++ b/server/server.js @@ -31,6 +31,7 @@ const MANAGER_CLASSES = { qbittorrent: require('./modules/qbittorrentManager').QbittorrentManager, deluge: require('./modules/delugeManager').DelugeManager, transmission: require('./modules/transmissionManager').TransmissionManager, + slskd: require('./modules/slskdManager'), }; const geoIPManager = require('./modules/geoIPManager'); const arrManager = require('./modules/arrManager'); @@ -57,6 +58,8 @@ const eventScriptingManager = require('./lib/EventScriptingManager'); const notificationManager = require('./lib/NotificationManager'); const notificationsAPI = require('./modules/notificationsAPI'); const userAPI = require('./modules/userAPI'); +const slskdChatAPI = require('./modules/slskdChatAPI'); +const slskdRoomsAPI = require('./modules/slskdRoomsAPI'); // Middleware const requireAuth = require('./middleware/auth'); @@ -285,6 +288,8 @@ app.get('/api/item/detail/:hash', (req, res) => { }); notificationsAPI.registerRoutes(app); // Notifications API userAPI.registerRoutes(app); // User management API (admin only) +slskdChatAPI.registerRoutes(app); // Soulseek private chat API +slskdRoomsAPI.registerRoutes(app); // Soulseek rooms API versionAPI.registerProtectedRoutes(app); // Version seen tracking (protected) // Debug API — only when NODE_INSPECT=true diff --git a/src/input.css b/src/input.css index b9e0563..7e5a8a1 100644 --- a/src/input.css +++ b/src/input.css @@ -78,6 +78,29 @@ html, body { padding-bottom: env(safe-area-inset-bottom, 0); } +/* Scroll on hover: hides scrollbar at rest, shows a thin one on pointer hover */ +.scroll-hover { + overflow-y: auto; + scrollbar-width: none; /* Firefox */ +} +.scroll-hover::-webkit-scrollbar { + width: 0; + background: transparent; +} +.scroll-hover:hover { + scrollbar-width: thin; /* Firefox */ +} +.scroll-hover:hover::-webkit-scrollbar { + width: 5px; +} +.scroll-hover:hover::-webkit-scrollbar-thumb { + background: rgba(156, 163, 175, 0.65); + border-radius: 999px; +} +.dark .scroll-hover:hover::-webkit-scrollbar-thumb { + background: rgba(107, 114, 128, 0.65); +} + /* Font size system */ :root { --font-size-base: 14px; diff --git a/static/components/common/ClientIcon.js b/static/components/common/ClientIcon.js index 5f568e6..3ffeb70 100644 --- a/static/components/common/ClientIcon.js +++ b/static/components/common/ClientIcon.js @@ -50,6 +50,14 @@ const ClientIcon = ({ client, clientType, size = 16, float = false, className = defaultTitle = 'BitTorrent (Transmission)'; alt = 'Tr'; src = '/static/logo-transmission.svg'; + } else if (clientValue === 'slskd') { + defaultTitle = 'slskd'; + alt = 'Sl'; + src = '/static/slskd.svg'; + } else if (clientValue === 'soulseek') { + defaultTitle = 'Soulseek'; + alt = 'Ss'; + src = '/static/soulseek.png'; } else if (clientValue === 'amule' || clientValue === 'ed2k') { defaultTitle = 'ED2K (aMule)'; alt = 'ED2K'; diff --git a/static/components/common/Icon.js b/static/components/common/Icon.js index b0545ee..a770b72 100644 --- a/static/components/common/Icon.js +++ b/static/components/common/Icon.js @@ -79,6 +79,7 @@ const Icon = ({ name, size = 20, className = '' }) => { type: '', mapPin: '', user: '', + users: '', copy: '', slash: '', file: '', @@ -86,6 +87,9 @@ const Icon = ({ name, size = 20, className = '' }) => { loader: '', gripVertical: '', columns: '', + messageSquare: '', + send: '', + hash: '', tableConfig: '' }; diff --git a/static/components/common/SearchResultsList.js b/static/components/common/SearchResultsList.js index 112e75e..cccad80 100644 --- a/static/components/common/SearchResultsList.js +++ b/static/components/common/SearchResultsList.js @@ -155,6 +155,7 @@ const SearchResultsList = ({ connectedClientIds = [], selectedFiles, onToggleSelection, + onToggleFolderExpand = null, loadedCount, totalCount, hasMore, @@ -185,7 +186,16 @@ const SearchResultsList = ({ baseColumns.map(col => col.key === 'fileName' ? { ...col, render: (item) => { + // Folder rows: delegate to custom render (has its own expand button) + if (item._isFolder && col.render) return col.render(item); const { onAllInstances } = getDownloadStatus(item.fileHash); + // Child rows: wrap custom render with selection onClick + if (item._isChild && col.render) { + return h('div', { + className: onAllInstances ? '' : 'cursor-pointer', + onClick: onAllInstances ? undefined : () => onToggleSelection(item.fileHash) + }, col.render(item)); + } return h('div', { className: `font-medium text-xs break-words whitespace-normal ${onAllInstances ? '' : 'cursor-pointer hover:underline decoration-dotted'}`, style: { wordBreak: 'break-all', overflowWrap: 'anywhere' }, @@ -199,6 +209,99 @@ const SearchResultsList = ({ // Mobile card renderer using MobileCardHeader const renderMobileCard = useCallback((item, idx) => { + // Folder row: custom layout with expand button and child-count info + if (item._isFolder) { + const childHashes = (item._files || []).map(f => f.fileHash); + const selCount = childHashes.filter(h => selectedFiles.has(h)).length; + const allSel = childHashes.length > 0 && selCount === childHashes.length; + const someSel = selCount > 0 && !allSel; + return h('div', { + className: `${getMobileCardRowClass(idx)} flex items-start gap-2 px-3 py-2.5 bg-gray-50 dark:bg-gray-800/50${ + selCount > 0 ? ' !bg-purple-50 dark:!bg-purple-900/20' : ''}` + }, + h('div', { className: 'flex-1 min-w-0' }, + h('div', { className: 'flex items-center gap-1.5 mb-1' }, + onToggleFolderExpand && h('button', { + type: 'button', + className: `shrink-0 w-5 h-5 flex items-center justify-center transition-colors ${ + item._expanded ? 'text-purple-500 dark:text-purple-400' : 'text-gray-400 dark:text-gray-500'}`, + onClick: () => onToggleFolderExpand(item._folderKey) + }, h(Icon, { name: item._expanded ? 'chevronDown' : 'chevronRight', size: 13 })), + h(Icon, { name: 'folder', size: 14, className: 'shrink-0 text-amber-400 dark:text-amber-500' }), + h('span', { + className: 'font-semibold text-sm text-gray-900 dark:text-gray-100', + style: { wordBreak: 'break-all', lineHeight: '1.4' } + }, item.folderName) + ), + h('div', { className: 'flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 flex-wrap' }, + h('span', { className: 'truncate max-w-[120px]', title: item.username }, item.username), + h('span', { className: 'text-gray-300 dark:text-gray-600' }, '·'), + h('span', null, formatBytes(item.fileSize || 0)), + h('span', { className: 'text-gray-300 dark:text-gray-600' }, '·'), + h('span', null, `${item.fileCount} file${item.fileCount !== 1 ? 's' : ''}`) + ) + ), + h('div', { className: 'flex-shrink-0 mt-0.5' }, + h('input', { + type: 'checkbox', + checked: allSel, + ref: el => { if (el) el.indeterminate = someSel; }, + onChange: () => onToggleSelection(item.fileHash), + className: 'w-5 h-5 text-purple-600 border-gray-300 rounded cursor-pointer' + }) + ) + ); + } + + // Child row: indented file entry inside an expanded folder + if (item._isChild) { + const { downloaded, onActiveInstance, onAllInstances } = getDownloadStatus(item.fileHash); + const isSelected = selectedFiles.has(item.fileHash); + const baseName = String(item.fileName || '').split(/[\/\\]/).pop() || item.fileName || ''; + return h('div', { + className: `${getMobileCardRowClass(idx)} pl-6 border-l-2 border-gray-200 dark:border-gray-700${ + isSelected ? ' !bg-purple-100 dark:!bg-purple-900/40' : ''}` + }, + h(MobileCardHeader, { + showBadge: false, + fileName: baseName, + onNameClick: onAllInstances ? undefined : () => onToggleSelection(item.fileHash), + actions: onActiveInstance + ? h(Tooltip, { content: onAllInstances ? 'Downloaded on all clients' : 'Already downloaded — tap to select for another client' }, + h('div', { + className: `flex items-center justify-center w-8 h-8 ${onAllInstances ? '' : 'cursor-pointer'}`, + onClick: onAllInstances ? undefined : () => onToggleSelection(item.fileHash) + }, + h(Icon, { name: 'check', size: 18, className: onAllInstances ? 'text-green-400 opacity-60' : 'text-green-500' }) + ) + ) + : h('div', { className: 'relative' }, + h('input', { + type: 'checkbox', + checked: isSelected, + onChange: () => onToggleSelection(item.fileHash), + className: 'w-5 h-5 text-purple-600 border-gray-300 rounded cursor-pointer' + }), + downloaded && h(Tooltip, { content: 'Downloaded on another client' }, + h('div', { className: 'absolute -top-1 -right-1 w-3 h-3 bg-green-500 rounded-full border border-white dark:border-gray-800' }) + ) + ) + }, + h('div', { className: 'flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400 flex-wrap' }, + h(Icon, { name: 'harddrive', size: 12, className: 'text-gray-500 dark:text-gray-400' }), + h('span', null, formatBytes(item.fileSize || 0)), + (item.bitrate || item.length) && [ + h('span', { key: 'sep', className: 'text-gray-400' }, '·'), + h('span', { key: 'info', className: 'text-gray-500 dark:text-gray-400' }, + [item.bitrate ? `${item.bitrate}kbps` : '', item.length ? `${Math.floor(item.length / 60)}:${String(item.length % 60).padStart(2, '0')}` : ''].filter(Boolean).join(' ') + ) + ] + ) + ) + ); + } + + // Regular item (aMule, Prowlarr) const { downloaded, onActiveInstance, onAllInstances } = getDownloadStatus(item.fileHash); const isSelected = selectedFiles.has(item.fileHash); return h('div', { @@ -258,10 +361,26 @@ const SearchResultsList = ({ ) ) ); - }, [getDownloadStatus, selectedFiles, onToggleSelection, isProwlarr]); + }, [getDownloadStatus, selectedFiles, onToggleSelection, onToggleFolderExpand, isProwlarr]); - // Desktop actions renderer — checkbox or green check icon + // Desktop actions renderer — folder: indeterminate checkbox; others: checkbox or green check icon const renderActions = useCallback((item) => { + // Folder row: indeterminate/checked checkbox based on child selection state + if (item._isFolder) { + const childHashes = (item._files || []).map(f => f.fileHash); + const selCount = childHashes.filter(h => selectedFiles.has(h)).length; + const allSel = childHashes.length > 0 && selCount === childHashes.length; + const someSel = selCount > 0 && !allSel; + return h('div', { className: 'flex items-center justify-center' }, + h('input', { + type: 'checkbox', + checked: allSel, + ref: el => { if (el) el.indeterminate = someSel; }, + onChange: () => onToggleSelection(item.fileHash), + className: 'w-4 h-4 text-purple-600 border-gray-300 rounded cursor-pointer' + }) + ); + } const { downloaded, onActiveInstance, onAllInstances } = getDownloadStatus(item.fileHash); if (onActiveInstance) { const tooltipMsg = onAllInstances ? 'Downloaded on all clients' : 'Already downloaded — click to select for another client'; @@ -287,8 +406,12 @@ const SearchResultsList = ({ ); }, [getDownloadStatus, selectedFiles, onToggleSelection]); - // Row highlight for selected items + // Row highlight for selected items; folder rows get a distinct background const getRowClassName = useCallback((item) => { + if (item._isFolder) { + const someSelected = (item._files || []).some(f => selectedFiles.has(f.fileHash)); + return `bg-gray-50 dark:bg-gray-800/50${someSelected ? ' !bg-purple-50 dark:!bg-purple-900/20' : ''}`; + } return selectedFiles.has(item.fileHash) ? '!bg-purple-100 dark:!bg-purple-900/40' : ''; }, [selectedFiles]); diff --git a/static/components/common/SearchResultsSection.js b/static/components/common/SearchResultsSection.js index 44f83ba..b29ac0d 100644 --- a/static/components/common/SearchResultsSection.js +++ b/static/components/common/SearchResultsSection.js @@ -8,7 +8,7 @@ import React from 'https://esm.sh/react@18.2.0'; import { SearchResultsList, SEARCH_RESULTS_COLUMNS, PROWLARR_RESULTS_COLUMNS, FilterInput, MobileSortButton, ExpandableSearch, Select, Button, Icon, SelectionModeSection, ClientIcon, MobileFilterSheet, MobileFilterPills, MobileFilterButton, LoadingSpinner, Tooltip } from './index.js'; -import { DEFAULT_SORT_CONFIG, sortFiles, calculateLoadMore, VIEW_TITLE_STYLES, makeFilterHeaderRender, createIndexerFilter } from '../../utils/index.js'; +import { DEFAULT_SORT_CONFIG, sortFiles, calculateLoadMore, VIEW_TITLE_STYLES, makeFilterHeaderRender, createIndexerFilter, formatBytes } from '../../utils/index.js'; import { useAppState } from '../../contexts/AppStateContext.js'; import { useStaticData } from '../../contexts/StaticDataContext.js'; import { useSearch } from '../../contexts/SearchContext.js'; @@ -180,26 +180,96 @@ const SearchResultsSection = ({ [filteredResults, sortConfig.sortBy, sortConfig.sortDirection] ); + // Detect if results are from slskd + const isSlskd = useMemo(() => results.length > 0 && results[0].isSlskd, [results]); + + // Track which slskd folder groups are expanded + const [expandedFolders, setExpandedFolders] = useState(new Set()); + + // Reset expanded folders when results change + useEffect(() => { setExpandedFolders(new Set()); }, [results]); + + const toggleFolderExpand = useCallback((folderKey) => { + setExpandedFolders(prev => { + const next = new Set(prev); + if (next.has(folderKey)) next.delete(folderKey); + else next.add(folderKey); + return next; + }); + }, []); + + // Number of unique folder groups (for header count) + const slskdFolderCount = useMemo(() => { + if (!isSlskd) return 0; + return new Set(filteredResults.map(r => `${r.username || ''}|${r.directoryPath || ''}`)).size; + }, [isSlskd, filteredResults]); + + // For slskd: group files into folder rows; expand/collapse shows individual files + const displayedResults = useMemo(() => { + if (!isSlskd) return sortedResults; + + // Build insertion-order groups (order follows first occurrence in sortedResults) + const groupMap = new Map(); + for (const item of sortedResults) { + const key = `${item.username || ''}|${item.directoryPath || ''}`; + if (!groupMap.has(key)) { + const dirParts = (item.directoryPath || '').split('/').filter(Boolean); + const folderName = dirParts[dirParts.length - 1] || item.directoryPath || item.username || 'Unknown'; + groupMap.set(key, { + _isFolder: true, + _folderKey: key, + fileHash: `folder:${key}`, + username: item.username || '', + directoryPath: item.directoryPath || '', + folderName, + fileName: folderName, + fileCount: 0, + fileSize: 0, + sourceCount: 0, + _files: [], + isSlskd: true + }); + } + const g = groupMap.get(key); + g.fileCount++; + g.fileSize += item.fileSize || 0; + g._files.push(item); + } + + const out = []; + for (const folder of groupMap.values()) { + folder._expanded = expandedFolders.has(folder._folderKey); + out.push(folder); + if (folder._expanded) { + for (const file of folder._files) { + out.push({ ...file, _isChild: true, _parentKey: folder._folderKey }); + } + } + } + return out; + }, [isSlskd, sortedResults, expandedFolders]); + // Load-more pagination (cumulative) - used for mobile in hybrid scrollable mode const loadedPages = appPage + 1; const { loadedData, loadedCount, hasMore, remaining } = useMemo(() => - calculateLoadMore(sortedResults, loadedPages, appPageSize), - [sortedResults, loadedPages, appPageSize] + calculateLoadMore(displayedResults, loadedPages, appPageSize), + [displayedResults, loadedPages, appPageSize] ); const loadMore = useCallback(() => setAppPage(prev => prev + 1), [setAppPage]); // Load all handler - sets page to load everything const loadAll = useCallback(() => { - const totalPages = Math.ceil(sortedResults.length / appPageSize); + const totalPages = Math.ceil(displayedResults.length / appPageSize); setAppPage(totalPages - 1); - }, [sortedResults.length, appPageSize, setAppPage]); + }, [displayedResults.length, appPageSize, setAppPage]); // Gmail-style selection const { shownFullySelected, allItemsSelected, hasMoreToLoad, handleSelectShown, handleSelectAll, shownCount, totalCount } = usePageSelection({ - shownData: loadedData, - allData: sortedResults, + // For slskd folder grouping: use raw file results (not grouped) for selection tracking + shownData: isSlskd ? sortedResults : loadedData, + allData: isSlskd ? sortedResults : displayedResults, selectedCount, selectShown, selectAll, @@ -208,7 +278,82 @@ const SearchResultsSection = ({ }); // Build columns with indexer filter dropdown in header (for Prowlarr) + // and folder grouping for slskd results const columnsWithIndexerFilter = useMemo(() => { + if (isSlskd) { + return [ + { + key: 'fileName', + label: 'Name / File', + sortable: true, + width: 'auto', + render: (item) => { + if (item._isFolder) { + const expanded = expandedFolders.has(item._folderKey); + return h('div', { className: 'flex items-center gap-1.5 min-w-0' }, + h('button', { + type: 'button', + className: `shrink-0 w-4 h-4 flex items-center justify-center transition-colors + ${expanded ? 'text-purple-500 dark:text-purple-400' : 'text-gray-400 dark:text-gray-500 hover:text-purple-500 dark:hover:text-purple-400'}`, + title: expanded ? `Collapse: ${item.directoryPath}` : `Expand: ${item.directoryPath}`, + onClick: (e) => { e.stopPropagation(); toggleFolderExpand(item._folderKey); } + }, h(Icon, { name: expanded ? 'chevronDown' : 'chevronRight', size: 12 })), + h(Icon, { name: 'folder', size: 13, className: 'shrink-0 text-amber-400 dark:text-amber-500' }), + h('span', { + className: 'font-semibold text-xs text-gray-900 dark:text-gray-100 truncate ml-0.5', + title: `${item.username}: ${item.directoryPath}` + }, item.folderName) + ); + } + // Child row: indented with file icon + const baseName = String(item.fileName || '').split(/[\/\\]/).pop() || item.fileName || ''; + return h('div', { className: 'flex items-center gap-1.5 min-w-0 pl-5' }, + h(Icon, { name: 'file', size: 11, className: 'shrink-0 text-gray-400 dark:text-gray-500' }), + h('span', { + className: 'text-xs break-words min-w-0', + style: { wordBreak: 'break-all', overflowWrap: 'anywhere' } + }, baseName) + ); + } + }, + { + key: 'username', + label: 'User', + sortable: true, + width: '120px', + render: (item) => item._isFolder + ? h('span', { className: 'text-xs text-gray-500 dark:text-gray-400 truncate block', title: item.username }, item.username) + : null + }, + { + key: 'fileSize', + label: 'Size', + sortable: true, + width: '90px', + render: (item) => h('span', { className: 'text-xs' }, formatBytes(item.fileSize || 0)) + }, + { + key: 'sourceCount', + label: 'Files / Info', + sortable: false, + width: '100px', + render: (item) => { + if (item._isFolder) { + return h('span', { className: 'text-xs text-gray-500 dark:text-gray-400' }, + `${item.fileCount} file${item.fileCount !== 1 ? 's' : ''}` + ); + } + const parts = []; + if (item.bitrate) parts.push(`${item.bitrate}kbps`); + if (item.length) { + parts.push(`${Math.floor(item.length / 60)}:${String(item.length % 60).padStart(2, '0')}`); + } + return h('span', { className: 'text-xs text-gray-400 dark:text-gray-500' }, parts.join(' ') || '—'); + } + } + ]; + } + if (!isProwlarr) return null; // Modify the indexer column to use filter header dropdown @@ -226,10 +371,30 @@ const SearchResultsSection = ({ } return col; }); - }, [isProwlarr, indexerFilter, indexerOptions, resetLoaded]); + }, [isProwlarr, isSlskd, indexerFilter, indexerOptions, resetLoaded, expandedFolders, toggleFolderExpand]); + + // Folder-aware selection toggle: clicking a folder hash selects/deselects all its children + const handleToggleSelection = useCallback((fileHash) => { + if (typeof fileHash === 'string' && fileHash.startsWith('folder:')) { + const folderKey = fileHash.slice(7); + const folderItem = displayedResults.find(item => item._isFolder && item._folderKey === folderKey); + if (!folderItem?._files?.length) return; + const childHashes = folderItem._files.map(f => f.fileHash); + const allSelected = childHashes.every(h => selectedFiles.has(h)); + const current = Array.from(selectedFiles); + if (allSelected) { + const toRemove = new Set(childHashes); + selectAll(current.filter(h => !toRemove.has(h))); + } else { + selectAll(Array.from(new Set([...current, ...childHashes]))); + } + return; + } + toggleFileSelection(fileHash); + }, [displayedResults, selectedFiles, selectAll, toggleFileSelection]); // Count of downloadable (not already downloaded on the selected client) selected items - const activeInstanceId = isProwlarr ? selectedClientId : (searchInstanceId || 'amule'); + const activeInstanceId = isProwlarr ? selectedClientId : searchInstanceId; const downloadableCount = useMemo(() => Array.from(selectedFiles).filter(hash => { const instances = dataDownloadedFiles.get(hash); @@ -304,7 +469,7 @@ const SearchResultsSection = ({ // MOBILE HEADER CONTENT (shared between sticky toolbar and in-page header) // ============================================================================ // Determine client type for icon (only show if results exist) - const clientType = isProwlarr ? 'prowlarr' : 'amule'; + const clientType = isProwlarr ? 'prowlarr' : isSlskd ? 'slskd' : 'amule'; // Show filter button only for Prowlarr with multiple indexers const showMobileFilterButton = isProwlarr && indexerOptions.length > 2; @@ -313,7 +478,11 @@ const SearchResultsSection = ({ h('div', { className: 'flex items-center gap-2' }, results.length > 0 && h(ClientIcon, { client: clientType, size: 18 }), h('h2', { className: VIEW_TITLE_STYLES.mobile }, mobileTitle), - h('span', { className: 'text-sm text-gray-500 dark:text-gray-400' }, `(${filteredResults.length})`), + h('span', { className: 'text-sm text-gray-500 dark:text-gray-400' }, + isSlskd + ? `(${slskdFolderCount} folder${slskdFolderCount !== 1 ? 's' : ''}, ${filteredResults.length} files)` + : `(${filteredResults.length})` + ), h('div', { className: 'flex-1' }), results.length > 0 && h(ExpandableSearch, { value: filterText, @@ -362,7 +531,11 @@ const SearchResultsSection = ({ h('div', { className: 'flex items-center gap-3' }, results.length > 0 && h(ClientIcon, { client: clientType, size: 20 }), h('h2', { className: VIEW_TITLE_STYLES.desktop }, title), - h('span', { className: 'text-sm text-gray-500 dark:text-gray-400' }, `(${filteredResults.length})`) + h('span', { className: 'text-sm text-gray-500 dark:text-gray-400' }, + isSlskd + ? `(${slskdFolderCount} folder${slskdFolderCount !== 1 ? 's' : ''}, ${filteredResults.length} files)` + : `(${filteredResults.length})` + ) ), h('div', { className: 'flex items-center gap-2' }, results.length > 0 && h(FilterInput, { @@ -379,7 +552,7 @@ const SearchResultsSection = ({ // Search results list with checkboxes // Hybrid scrollable mode: desktop shows all items, mobile uses load-more h(SearchResultsList, { - results: sortedResults, + results: displayedResults, loadedData, sortConfig, onSortChange: handleSortChange, @@ -387,10 +560,11 @@ const SearchResultsSection = ({ activeInstanceId, connectedClientIds: isProwlarr ? connectedClients.map(c => c.id) : [activeInstanceId], selectedFiles, - onToggleSelection: toggleFileSelection, + onToggleSelection: handleToggleSelection, + onToggleFolderExpand: toggleFolderExpand, // Load-more props for mobile in hybrid scrollable mode loadedCount, - totalCount: sortedResults.length, + totalCount: displayedResults.length, hasMore, remaining, onLoadMore: loadMore, @@ -400,7 +574,7 @@ const SearchResultsSection = ({ emptyMessage: filterText ? filterEmptyMessage : emptyMessage, isProwlarr, scrollHeight, - // Custom columns with indexer filter dropdown (for Prowlarr) + // Custom columns with indexer filter dropdown (for Prowlarr) / directory expand (for slskd) customColumns: columnsWithIndexerFilter }), diff --git a/static/components/dashboard/DashboardChartWidget.js b/static/components/dashboard/DashboardChartWidget.js index 07883dc..96379ff 100644 --- a/static/components/dashboard/DashboardChartWidget.js +++ b/static/components/dashboard/DashboardChartWidget.js @@ -13,14 +13,16 @@ const { createElement: h } = React; * @param {string} title - Widget title * @param {ReactNode} children - Chart component * @param {string} height - Chart height (default: '200px') + * @param {ReactNode} overlay - Optional absolutely-positioned element rendered at card level */ -const DashboardChartWidget = ({ title, children, height = '200px' }) => { +const DashboardChartWidget = ({ title, children, height = '200px', overlay }) => { return h('div', { - className: 'bg-white dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-700 overflow-hidden' + className: 'relative bg-white dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-700 overflow-hidden' }, h('h3', { className: 'text-sm font-semibold mb-2 text-gray-700 dark:text-gray-300' }, title), + overlay || null, h('div', { style: { height, position: 'relative' } }, children) ); }; diff --git a/static/components/dashboard/MobileSpeedWidget.js b/static/components/dashboard/MobileSpeedWidget.js index 52eaaa8..7b285b4 100644 --- a/static/components/dashboard/MobileSpeedWidget.js +++ b/static/components/dashboard/MobileSpeedWidget.js @@ -3,7 +3,7 @@ * * Compact speed chart with current speeds and network status for mobile view * Shows 24h speed history with simplified data points for performance - * Supports switching between aMule and BitTorrent (rTorrent + qBittorrent) when both are active + * Supports switching between aMule, BitTorrent (rTorrent + qBittorrent), and Soulseek when active * Multi-instance mode: per-instance network status dots with instance names */ @@ -17,11 +17,18 @@ import { useStaticData } from '../../contexts/StaticDataContext.js'; const { createElement: h, useEffect, useRef, useState } = React; +const NETWORK_ORDER = ['ed2k', 'bittorrent', 'soulseek']; +const NETWORK_LABELS = { + ed2k: 'aMule', + bittorrent: 'BitTorrent', + soulseek: 'Soulseek' +}; + /** * Downsample data for mobile performance * 288 points = 1 data point every 5 minutes for 24 hours * @param {Array} data - Original data array - * @param {string} networkType - 'ed2k' or 'bittorrent' + * @param {string} networkType - 'ed2k', 'bittorrent', or 'soulseek' * @param {number} targetPoints - Target number of data points * @returns {Array} Downsampled data */ @@ -112,25 +119,28 @@ const MobileSpeedWidget = ({ speedData, stats, theme }) => { }); // Get client connection status from context - const { ed2kConnected, bittorrentConnected } = useClientFilter(); + const { ed2kConnected, bittorrentConnected, soulseekConnected } = useClientFilter(); const { instances } = useStaticData(); - // Show toggle when both aMule and BitTorrent clients are connected - const showBothClients = ed2kConnected && bittorrentConnected; + const activeNetworkTypes = NETWORK_ORDER.filter((networkType) => { + if (networkType === 'ed2k') return ed2kConnected; + if (networkType === 'bittorrent') return bittorrentConnected; + return soulseekConnected; + }); - // State for selected network type (when both are available) - const [selectedNetwork, setSelectedNetwork] = useState('ed2k'); + // Show toggle when multiple networks are connected + const showMultipleClients = activeNetworkTypes.length > 1; + + // State for selected network type (when multiple are available) + const [selectedNetwork, setSelectedNetwork] = useState(activeNetworkTypes[0] || 'ed2k'); // Auto-select the available network when only one is connected useEffect(() => { - if (!showBothClients) { - if (ed2kConnected) { - setSelectedNetwork('ed2k'); - } else if (bittorrentConnected) { - setSelectedNetwork('bittorrent'); - } + if (activeNetworkTypes.length === 0) return; + if (!activeNetworkTypes.includes(selectedNetwork)) { + setSelectedNetwork(activeNetworkTypes[0]); } - }, [showBothClients, ed2kConnected, bittorrentConnected]); + }, [activeNetworkTypes, selectedNetwork]); // Load Chart.js library on mount useEffect(() => { @@ -301,6 +311,19 @@ const MobileSpeedWidget = ({ speedData, stats, theme }) => { }).filter(Boolean) ); } + } else if (selectedNetwork === 'soulseek') { + networkStatus = h(React.Fragment, null, + ...tabInstances.map(inst => { + const ns = inst.networkStatus; + if (!ns) return null; + return h('div', { key: inst.id, className: 'flex items-center gap-1.5' }, + h('div', { className: `w-2 h-2 rounded-full ${getStatusDotClass(ns.status)}` }), + h('span', { className: 'text-xs font-medium text-gray-600 dark:text-gray-400' }, + `${inst.name}: ${ns.text}` + ) + ); + }).filter(Boolean) + ); } else { // BitTorrent: per-instance status networkStatus = h(React.Fragment, null, @@ -332,23 +355,17 @@ const MobileSpeedWidget = ({ speedData, stats, theme }) => { } // Network toggle button component - const networkToggle = showBothClients && h('div', { + const networkToggle = showMultipleClients && 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' + ...activeNetworkTypes.map((networkType, index) => h('button', { + key: networkType, + onClick: () => setSelectedNetwork(networkType), + className: `p-1.5 ${index > 0 ? 'border-l border-gray-300 dark:border-gray-600' : ''} ${selectedNetwork === networkType ? '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 })) + title: `Show ${NETWORK_LABELS[networkType]}` + }, h(ClientIcon, { clientType: networkType === 'soulseek' ? 'soulseek' : networkType, 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..4d542f2 100644 --- a/static/components/dashboard/QuickSearchWidget.js +++ b/static/components/dashboard/QuickSearchWidget.js @@ -19,10 +19,12 @@ const { createElement: h } = React; * @param {function} onSearch - Search submit handler * @param {boolean} searchLocked - Whether search is in progress * @param {boolean} noBorder - Whether to hide the outer border/padding (default: false) - * @param {string} searchInstanceId - Selected aMule instance ID for search + * @param {string} searchInstanceId - Selected provider instance ID for search * @param {function} onSearchInstanceChange - Instance selection change handler - * @param {Array} amuleInstances - Connected aMule instances from useAmuleInstanceSelector - * @param {boolean} showAmuleSelector - Whether to show aMule instance selector + * @param {Array} providerInstances - Connected provider instances (ED2K or Soulseek) from useSearchProviderSelector + * @param {boolean} showProviderSelector - Whether to show provider instance selector + * @param {Array} [amuleInstances] - Alias for providerInstances (backward compat, ignored if providerInstances provided) + * @param {boolean} [showAmuleSelector] - Alias for showProviderSelector (backward compat) */ const QuickSearchWidget = ({ searchType, @@ -34,14 +36,22 @@ const QuickSearchWidget = ({ noBorder = false, searchInstanceId, onSearchInstanceChange, - amuleInstances = [], - showAmuleSelector = false + // Preferred: provider-agnostic props + providerInstances, + showProviderSelector, + // Backward-compat aliases (used when providerInstances is not supplied) + amuleInstances, + showAmuleSelector }) => { + // Resolve to the provided props, falling back to backward-compat aliases + const resolvedInstances = providerInstances !== undefined ? providerInstances : (amuleInstances || []); + const resolvedShowSelector = providerInstances !== undefined ? showProviderSelector : (showAmuleSelector || false); const { isNetworkTypeConnected, prowlarrEnabled } = useStaticData(); // Check client connection and configuration status const amuleConnected = isNetworkTypeConnected('ed2k'); const bittorrentConnected = isNetworkTypeConnected('bittorrent'); + const soulseekConnected = isNetworkTypeConnected('soulseek'); const handleSubmit = (e) => { e.preventDefault(); @@ -57,6 +67,7 @@ const QuickSearchWidget = ({ { 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: 'soulseek', label: 'Soulseek', icon: '/static/soulseek.png', disabled: !soulseekConnected }, { value: 'prowlarr', label: 'Prowlarr', icon: '/static/prowlarr.svg', disabled: !prowlarrEnabled || !bittorrentConnected } ]; @@ -70,7 +81,7 @@ const QuickSearchWidget = ({ onSearchTypeChange(firstAvailable.value); } } - }, [selectedTypeDisabled, amuleConnected, bittorrentConnected, prowlarrEnabled]); + }, [selectedTypeDisabled, amuleConnected, bittorrentConnected, soulseekConnected, prowlarrEnabled]); return h('div', { className: noBorder ? '' : 'bg-white dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-700' @@ -114,12 +125,12 @@ 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, + // Instance selector for multi-instance ED2K/Kad or Soulseek searches + (searchType === 'global' || searchType === 'kad' || searchType === 'soulseek') && h(AmuleInstanceSelector, { + connectedInstances: resolvedInstances, selectedId: searchInstanceId, onSelect: onSearchInstanceChange, - showSelector: showAmuleSelector, + showSelector: resolvedShowSelector, variant: 'dropdown', disabled: searchLocked }), diff --git a/static/components/dashboard/StatsWidget.js b/static/components/dashboard/StatsWidget.js index cbf2b36..bb269d4 100644 --- a/static/components/dashboard/StatsWidget.js +++ b/static/components/dashboard/StatsWidget.js @@ -15,6 +15,16 @@ import ClientIcon from '../common/ClientIcon.js'; const { createElement: h } = React; +const NETWORK_ORDER = ['ed2k', 'bittorrent', 'soulseek']; + +const getNetworkClient = (networkType) => { + if (networkType === 'soulseek') return 'soulseek'; + if (networkType === 'bittorrent') return 'bittorrent'; + return 'ed2k'; +}; + +const getStatValue = (statsByNetwork, networkType, metric) => Number(statsByNetwork[networkType]?.[metric] || 0); + /** * Loading placeholder for stat card * @param {boolean} compact - Use compact styling for mobile @@ -30,89 +40,63 @@ const StatCardSkeleton = ({ compact = false }) => { /** * Helper component for displaying per-network-type breakdown values - * Desktop (xl+): icon value · icon value (inline with dot separator) - * Tablet/Mobile ( v }) => { +const ClientBreakdownValue = ({ metric, statsByNetwork, activeNetworkTypes, showClientIcons, compact = false, formatter = (v) => v }) => { + const totalValue = activeNetworkTypes.reduce((sum, networkType) => sum + getStatValue(statsByNetwork, networkType, metric), 0); + if (!showClientIcons) { - // Only one client configured - show plain value - return h('span', null, formatter(ed2kValue + bittorrentValue)); + return h('span', null, formatter(totalValue)); } - // Compact mode (mobile dashboard): always two lines with smaller text + const renderLine = (networkType, iconSize) => h('span', { className: 'flex items-center gap-1' }, + h(ClientIcon, { clientType: getNetworkClient(networkType), size: iconSize }), + h('span', null, formatter(getStatValue(statsByNetwork, networkType, metric))) + ); + 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)) - ) + ...activeNetworkTypes.map((networkType) => renderLine(networkType, 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)) - ) + ...activeNetworkTypes.map((networkType) => renderLine(networkType, 14)) ); - // Inline layout with dot separator (shown at 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)) - ) - ); - } - const inlineLayout = h('span', { className: 'hidden xl:flex items-center gap-1 flex-wrap' }, inlineParts); + activeNetworkTypes.forEach((networkType, index) => { + if (index > 0) { + inlineParts.push(h('span', { key: `${networkType}-dot`, className: 'text-gray-400 mx-1' }, '·')); + } + inlineParts.push(renderLine(networkType, 14)); + }); - return h(React.Fragment, null, twoRowsLayout, inlineLayout); + return h(React.Fragment, null, + twoRowsLayout, + h('span', { className: 'hidden xl:flex items-center gap-1 flex-wrap' }, inlineParts) + ); }; /** * 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) + * Shows: icon total · avg (one line per network if multiple are visible) */ -const CompactCombinedValue = ({ ed2kTotal, bittorrentTotal, ed2kAvg, bittorrentAvg, showClientIcons, showEd2k, showBittorrent }) => { - const renderClientLine = (clientType, total, avg) => ( +const CompactCombinedValue = ({ statsByNetwork, activeNetworkTypes, showClientIcons }) => { + const renderClientLine = (networkType) => ( h('span', { className: 'flex items-center gap-1' }, - showClientIcons && h(ClientIcon, { clientType, size: 12 }), - h('span', null, formatBytes(total)), + showClientIcons && h(ClientIcon, { clientType: getNetworkClient(networkType), size: 12 }), + h('span', null, formatBytes(getStatValue(statsByNetwork, networkType, 'total'))), h('span', { className: 'text-gray-400' }, '·'), - h('span', null, formatSpeed(avg)) + h('span', null, formatSpeed(getStatValue(statsByNetwork, networkType, 'avg'))) ) ); 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 = activeNetworkTypes.reduce((sum, networkType) => sum + getStatValue(statsByNetwork, networkType, 'total'), 0); + const avg = activeNetworkTypes.reduce((sum, networkType) => sum + getStatValue(statsByNetwork, networkType, 'avg'), 0); return h('span', { className: 'flex items-center gap-1' }, h('span', null, formatBytes(total)), h('span', { className: 'text-gray-400' }, '·'), @@ -120,10 +104,8 @@ 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) + ...activeNetworkTypes.map((networkType) => renderClientLine(networkType)) ); }; @@ -135,33 +117,57 @@ const CompactCombinedValue = ({ ed2kTotal, bittorrentTotal, ed2kAvg, bittorrentA * @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 { isEd2kEnabled, isBittorrentEnabled, isSoulseekEnabled, ed2kConnected, bittorrentConnected, soulseekConnected } = 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; + const activeNetworkTypes = NETWORK_ORDER.filter((networkType) => { + if (networkType === 'ed2k') return isEd2kEnabled; + if (networkType === 'bittorrent') return isBittorrentEnabled; + return isSoulseekEnabled; + }); - // Which clients to show (isXEnabled includes connection check) - const showEd2k = isEd2kEnabled; - const showBittorrent = isBittorrentEnabled; + // Show client icons when more than one network is visible + const showClientIcons = activeNetworkTypes.length > 1; + + const statsByNetwork = { + ed2k: stats?.ed2k || { totalUploaded: 0, totalDownloaded: 0, avgUploadSpeed: 0, avgDownloadSpeed: 0, peakUploadSpeed: 0, peakDownloadSpeed: 0 }, + bittorrent: stats?.bittorrent || { totalUploaded: 0, totalDownloaded: 0, avgUploadSpeed: 0, avgDownloadSpeed: 0, peakUploadSpeed: 0, peakDownloadSpeed: 0 }, + soulseek: stats?.soulseek || { totalUploaded: 0, totalDownloaded: 0, avgUploadSpeed: 0, avgDownloadSpeed: 0, peakUploadSpeed: 0, peakDownloadSpeed: 0 } + }; + + const networkStats = { + ed2k: { + totalUploaded: statsByNetwork.ed2k.totalUploaded, + totalDownloaded: statsByNetwork.ed2k.totalDownloaded, + avgUploadSpeed: statsByNetwork.ed2k.avgUploadSpeed, + avgDownloadSpeed: statsByNetwork.ed2k.avgDownloadSpeed, + peakUploadSpeed: statsByNetwork.ed2k.peakUploadSpeed, + peakDownloadSpeed: statsByNetwork.ed2k.peakDownloadSpeed + }, + bittorrent: { + totalUploaded: statsByNetwork.bittorrent.totalUploaded, + totalDownloaded: statsByNetwork.bittorrent.totalDownloaded, + avgUploadSpeed: statsByNetwork.bittorrent.avgUploadSpeed, + avgDownloadSpeed: statsByNetwork.bittorrent.avgDownloadSpeed, + peakUploadSpeed: statsByNetwork.bittorrent.peakUploadSpeed, + peakDownloadSpeed: statsByNetwork.bittorrent.peakDownloadSpeed + }, + soulseek: { + totalUploaded: statsByNetwork.soulseek.totalUploaded, + totalDownloaded: statsByNetwork.soulseek.totalDownloaded, + avgUploadSpeed: statsByNetwork.soulseek.avgUploadSpeed, + avgDownloadSpeed: statsByNetwork.soulseek.avgDownloadSpeed, + peakUploadSpeed: statsByNetwork.soulseek.peakUploadSpeed, + peakDownloadSpeed: statsByNetwork.soulseek.peakDownloadSpeed + } + }; // Show loading skeleton if either data source is missing: // - stats: historical data from API // - liveStats: WebSocket data needed for client connection status 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; - }; + const getFilteredValue = (metric) => activeNetworkTypes.reduce((sum, networkType) => sum + getStatValue(networkStats, networkType, metric), 0); // Compact mode: 2 combined cards (Downloaded, Uploaded) if (compact) { @@ -171,13 +177,13 @@ const StatsWidget = ({ stats, showPeakSpeeds = true, compact = false, timeRange ? h(StatCard, { label: `Downloaded · Avg (${timeRange})`, value: h(CompactCombinedValue, { - ed2kTotal: ed2kStats.totalDownloaded, - bittorrentTotal: btStats.totalDownloaded, - ed2kAvg: ed2kStats.avgDownloadSpeed, - bittorrentAvg: btStats.avgDownloadSpeed, + statsByNetwork: { + ed2k: { total: networkStats.ed2k.totalDownloaded, avg: networkStats.ed2k.avgDownloadSpeed }, + bittorrent: { total: networkStats.bittorrent.totalDownloaded, avg: networkStats.bittorrent.avgDownloadSpeed }, + soulseek: { total: networkStats.soulseek.totalDownloaded, avg: networkStats.soulseek.avgDownloadSpeed } + }, + activeNetworkTypes, showClientIcons, - showEd2k, - showBittorrent }), icon: 'download', iconColor: 'text-blue-600 dark:text-blue-400', @@ -190,13 +196,13 @@ const StatsWidget = ({ stats, showPeakSpeeds = true, compact = false, timeRange ? h(StatCard, { label: `Uploaded · Avg (${timeRange})`, value: h(CompactCombinedValue, { - ed2kTotal: ed2kStats.totalUploaded, - bittorrentTotal: btStats.totalUploaded, - ed2kAvg: ed2kStats.avgUploadSpeed, - bittorrentAvg: btStats.avgUploadSpeed, + statsByNetwork: { + ed2k: { total: networkStats.ed2k.totalUploaded, avg: networkStats.ed2k.avgUploadSpeed }, + bittorrent: { total: networkStats.bittorrent.totalUploaded, avg: networkStats.bittorrent.avgUploadSpeed }, + soulseek: { total: networkStats.soulseek.totalUploaded, avg: networkStats.soulseek.avgUploadSpeed } + }, + activeNetworkTypes, showClientIcons, - showEd2k, - showBittorrent }), icon: 'upload', iconColor: 'text-green-600 dark:text-green-400', @@ -218,14 +224,13 @@ const StatsWidget = ({ stats, showPeakSpeeds = true, compact = false, timeRange label: `Total Uploaded (${timeRange})`, value: showClientIcons ? h(ClientBreakdownValue, { - ed2kValue: ed2kStats.totalUploaded, - bittorrentValue: btStats.totalUploaded, + metric: 'totalUploaded', + statsByNetwork: networkStats, + activeNetworkTypes, showClientIcons, - showEd2k, - showBittorrent, formatter: formatBytes }) - : formatBytes(getFilteredValue(ed2kStats.totalUploaded, btStats.totalUploaded)), + : formatBytes(getFilteredValue('totalUploaded')), icon: 'upload', iconColor: 'text-green-600 dark:text-green-400' }) @@ -237,14 +242,13 @@ const StatsWidget = ({ stats, showPeakSpeeds = true, compact = false, timeRange label: `Avg Upload Speed (${timeRange})`, value: showClientIcons ? h(ClientBreakdownValue, { - ed2kValue: ed2kStats.avgUploadSpeed, - bittorrentValue: btStats.avgUploadSpeed, + metric: 'avgUploadSpeed', + statsByNetwork: networkStats, + activeNetworkTypes, showClientIcons, - showEd2k, - showBittorrent, formatter: formatSpeed }) - : formatSpeed(getFilteredValue(ed2kStats.avgUploadSpeed, btStats.avgUploadSpeed)), + : formatSpeed(getFilteredValue('avgUploadSpeed')), icon: 'trendingUp', iconColor: 'text-green-600 dark:text-green-400' }) @@ -256,14 +260,13 @@ const StatsWidget = ({ stats, showPeakSpeeds = true, compact = false, timeRange label: `Peak Upload Speed (${timeRange})`, value: showClientIcons ? h(ClientBreakdownValue, { - ed2kValue: ed2kStats.peakUploadSpeed, - bittorrentValue: btStats.peakUploadSpeed, + metric: 'peakUploadSpeed', + statsByNetwork: networkStats, + activeNetworkTypes, showClientIcons, - showEd2k, - showBittorrent, formatter: formatSpeed }) - : formatSpeed(getFilteredValue(ed2kStats.peakUploadSpeed, btStats.peakUploadSpeed)), + : formatSpeed(getFilteredValue('peakUploadSpeed')), icon: 'zap', iconColor: 'text-green-600 dark:text-green-400' }) @@ -275,14 +278,13 @@ const StatsWidget = ({ stats, showPeakSpeeds = true, compact = false, timeRange label: `Total Downloaded (${timeRange})`, value: showClientIcons ? h(ClientBreakdownValue, { - ed2kValue: ed2kStats.totalDownloaded, - bittorrentValue: btStats.totalDownloaded, + metric: 'totalDownloaded', + statsByNetwork: networkStats, + activeNetworkTypes, showClientIcons, - showEd2k, - showBittorrent, formatter: formatBytes }) - : formatBytes(getFilteredValue(ed2kStats.totalDownloaded, btStats.totalDownloaded)), + : formatBytes(getFilteredValue('totalDownloaded')), icon: 'download', iconColor: 'text-blue-600 dark:text-blue-400' }) @@ -294,14 +296,13 @@ const StatsWidget = ({ stats, showPeakSpeeds = true, compact = false, timeRange label: `Avg Download Speed (${timeRange})`, value: showClientIcons ? h(ClientBreakdownValue, { - ed2kValue: ed2kStats.avgDownloadSpeed, - bittorrentValue: btStats.avgDownloadSpeed, + metric: 'avgDownloadSpeed', + statsByNetwork: networkStats, + activeNetworkTypes, showClientIcons, - showEd2k, - showBittorrent, formatter: formatSpeed }) - : formatSpeed(getFilteredValue(ed2kStats.avgDownloadSpeed, btStats.avgDownloadSpeed)), + : formatSpeed(getFilteredValue('avgDownloadSpeed')), icon: 'trendingUp', iconColor: 'text-blue-600 dark:text-blue-400' }) @@ -313,14 +314,13 @@ const StatsWidget = ({ stats, showPeakSpeeds = true, compact = false, timeRange label: `Peak Download Speed (${timeRange})`, value: showClientIcons ? h(ClientBreakdownValue, { - ed2kValue: ed2kStats.peakDownloadSpeed, - bittorrentValue: btStats.peakDownloadSpeed, + metric: 'peakDownloadSpeed', + statsByNetwork: networkStats, + activeNetworkTypes, showClientIcons, - showEd2k, - showBittorrent, formatter: formatSpeed }) - : formatSpeed(getFilteredValue(ed2kStats.peakDownloadSpeed, btStats.peakDownloadSpeed)), + : formatSpeed(getFilteredValue('peakDownloadSpeed')), icon: 'zap', iconColor: 'text-blue-600 dark:text-blue-400' }) diff --git a/static/components/layout/Footer.js b/static/components/layout/Footer.js index 5736052..7e579c2 100644 --- a/static/components/layout/Footer.js +++ b/static/components/layout/Footer.js @@ -18,6 +18,12 @@ import ClientIcon from '../common/ClientIcon.js'; const { createElement: h } = React; +const NETWORK_FILTERS = { + ed2k: 'ed2k', + bittorrent: 'bittorrent', + soulseek: 'soulseek' +}; + // Status priority for worst-of computation (lower = worse) const STATUS_PRIORITY = { red: 0, yellow: 1, green: 2 }; @@ -52,7 +58,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, soulseekConnected } = 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' }, @@ -154,7 +160,11 @@ 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; + const isEnabled = inst.networkType === NETWORK_FILTERS.ed2k + ? ed2kConnected + : inst.networkType === NETWORK_FILTERS.soulseek + ? soulseekConnected + : bittorrentConnected; if (!isEnabled) continue; const speeds = instanceSpeeds[inst.id]; if (speeds) { @@ -208,19 +218,24 @@ const Footer = ({ currentView, onOpenAbout }) => { renderBadge(kad.status, kad.text, kadTooltip) ) ), - // 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' }), - // BitTorrent client statuses (dynamic — works for rtorrent, qbittorrent, deluge, etc.) - ...btTypes.map(type => { + // Divider between aMule and BitTorrent/Soulseek status + ed2kConnected && (bittorrentConnected || soulseekConnected) && h('div', { className: 'w-px h-4 bg-gray-300 dark:bg-gray-600 flex-shrink-0' }), + // BitTorrent + Soulseek client statuses; a pipe separator is inserted before the slskd entry + ...btTypes.flatMap(type => { const { status: st, tooltip } = btStatusMap[type]; const names = CLIENT_NAMES[type] || { name: type, shortName: type.slice(0, 3) }; - return h('div', { key: type, className: 'flex items-center gap-1.5 flex-shrink-0' }, + const statusItem = h('div', { key: type, className: 'flex items-center gap-1.5 flex-shrink-0' }, h('span', { className: 'font-semibold text-gray-700 dark:text-gray-300' }, h('span', { className: 'lg:hidden' }, `${names.shortName}:`), h('span', { className: 'hidden lg:inline' }, `${names.name}:`) ), renderBadge(st.status, st.text, tooltip || (st.listenPort ? `Port ${st.listenPort}` : null)) ); + // Separator before Soulseek when other BT clients are also present + if (type === 'slskd' && btTypes.some(t => t !== 'slskd')) { + return [h('div', { key: 'sep-slskd', className: 'w-px h-4 bg-gray-300 dark:bg-gray-600 flex-shrink-0' }), statusItem]; + } + return [statusItem]; }) ), // Right: System indicators + Speeds (fixed, never compressed) diff --git a/static/components/layout/Header.js b/static/components/layout/Header.js index 3b4832c..278ffb6 100644 --- a/static/components/layout/Header.js +++ b/static/components/layout/Header.js @@ -16,6 +16,12 @@ import ProfileModal from '../modals/ProfileModal.js'; const { createElement: h, useState, useRef, useEffect, useCallback } = React; +const NETWORK_CONFIGS = [ + { type: 'ed2k', label: 'ED2K', client: 'amule', mobileTitle: 'ED2K data', hiddenTitle: 'all ED2K', color: '#3b82f6' }, + { type: 'bittorrent', label: 'BT', client: 'bittorrent', mobileTitle: 'BT data', hiddenTitle: 'all BT', color: '#f97316' }, + { type: 'soulseek', label: 'Soulseek', client: 'soulseek', mobileTitle: 'Soulseek data', hiddenTitle: 'all Soulseek', color: '#0ea5e9' } +]; + /** * UserMenu dropdown component */ @@ -106,7 +112,7 @@ 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 { isEd2kEnabled, isBittorrentEnabled, isSoulseekEnabled, toggleNetworkType, toggleInstance, isInstanceEnabled } = useClientFilter(); const { multipleClientsConnected, instances } = useStaticData(); // Profile modal state @@ -126,7 +132,7 @@ const Header = ({ theme, onToggleTheme, isLandscape, onNavigateHome, onOpenAbout // Group connected instances by network type for per-instance filter chips const instanceGroups = React.useMemo(() => { - const groups = { ed2k: [], bittorrent: [] }; + const groups = { ed2k: [], bittorrent: [], soulseek: [] }; for (const [id, inst] of Object.entries(instances)) { if (inst.connected && groups[inst.networkType]) { groups[inst.networkType].push({ id, ...inst }); @@ -134,9 +140,65 @@ const Header = ({ theme, onToggleTheme, isLandscape, onNavigateHome, onOpenAbout } groups.ed2k.sort((a, b) => a.order - b.order); groups.bittorrent.sort((a, b) => a.order - b.order); + groups.soulseek.sort((a, b) => a.order - b.order); return groups; }, [instances]); + const networkEnabled = { + ed2k: isEd2kEnabled, + bittorrent: isBittorrentEnabled, + soulseek: isSoulseekEnabled + }; + + const renderNetworkToggleButton = (config, mobile = false) => { + const enabled = networkEnabled[config.type]; + const hasInstances = instanceGroups[config.type]?.length > 0; + if (!hasInstances) return null; + + const title = enabled ? `Hide ${config.mobileTitle}` : `Show ${config.mobileTitle}`; + return h(Tooltip, { + content: enabled ? `Hide ${config.mobileTitle}` : `Show ${config.mobileTitle}`, + position: 'bottom', + showOnMobile: false + }, + h('button', { + onClick: () => toggleNetworkType(config.type), + 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 ${enabled + ? 'text-white' + : 'bg-gray-200 dark:bg-gray-700 text-gray-400 dark:text-gray-500'}`, + title, + style: enabled ? { backgroundColor: config.color } : undefined + }, + h(ClientIcon, { client: config.client, size: 14, title: '' }), + config.label + ) + ); + }; + + const renderInstanceGroup = (config) => { + const group = instanceGroups[config.type] || []; + if (group.length === 0) return null; + + return h(React.Fragment, null, + h('button', { + onClick: () => toggleNetworkType(config.type), + className: `flex-shrink-0 p-0.5 rounded transition-all ${networkEnabled[config.type] ? 'opacity-100' : 'opacity-40 grayscale'}`, + title: networkEnabled[config.type] ? `Hide ${config.hiddenTitle}` : `Show ${config.hiddenTitle}` + }, h(ClientIcon, { client: config.client, size: 14, title: '' })), + ...group.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 || config.color, textShadow: '0 1px 2px rgba(0,0,0,0.3)' } : undefined, + title: `${inst.name} (${isInstanceEnabled(inst.id) ? 'visible' : 'hidden'})` + }, inst.name)) + ); + }; + const { headerHidden } = useStickyHeader(); return h('header', { @@ -155,102 +217,12 @@ 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' - }, - 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' - ) - ) + h('div', { className: 'flex items-center md:hidden flex-wrap gap-1 justify-center' }, + NETWORK_CONFIGS.map(config => renderNetworkToggleButton(config, true)).filter(Boolean) ), // 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) - ? '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) - ) - ), - // 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 => - 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 || '#f97316', textShadow: '0 1px 2px rgba(0,0,0,0.3)' } : undefined, - title: `${inst.name} (${isInstanceEnabled(inst.id) ? 'visible' : 'hidden'})` - }, inst.name) - ) - ) + NETWORK_CONFIGS.map(config => renderInstanceGroup(config)).filter(Boolean) ) ) ), diff --git a/static/components/layout/MobileNavFooter.js b/static/components/layout/MobileNavFooter.js index 3f3af25..51ed20c 100644 --- a/static/components/layout/MobileNavFooter.js +++ b/static/components/layout/MobileNavFooter.js @@ -65,6 +65,7 @@ const MobileNavFooter = ({ currentView, onNavigate }) => { const { hasType, hasCategoryPathWarnings, hasClientConnectionWarnings } = useStaticData(); const { hasCap, isAdmin } = useCapabilities(); const amuleEnabled = hasType('amule'); + const slskdEnabled = hasType('slskd'); // Count active downloads for badge const activeDownloadCount = useMemo(() => { @@ -126,6 +127,7 @@ const MobileNavFooter = ({ currentView, onNavigate }) => { { icon: 'share', label: 'Shared Files', view: 'shared', cap: 'view_shared' }, { icon: 'folder', label: 'Categories', view: 'categories', warning: hasCategoryPathWarnings, cap: 'manage_categories' }, ...(amuleEnabled ? [{ icon: 'server', label: 'ED2K Servers', view: 'servers', cap: 'view_servers' }] : []), + ...(slskdEnabled ? [{ icon: 'messageSquare', label: 'Soulseek Chat', view: 'chat', cap: 'search' }] : []), { icon: 'fileText', label: 'Logs', view: 'logs', cap: 'view_logs' }, { icon: 'chartBar', label: 'Statistics', view: 'statistics', cap: 'view_statistics' }, { icon: 'bell', label: 'Notifications', view: 'notifications', adminOnly: true }, diff --git a/static/components/layout/Sidebar.js b/static/components/layout/Sidebar.js index 5476015..1e5aaf7 100644 --- a/static/components/layout/Sidebar.js +++ b/static/components/layout/Sidebar.js @@ -47,6 +47,7 @@ const Sidebar = ({ currentView, onNavigate, isLandscape }) => { const { hasType, hasCategoryPathWarnings, hasClientConnectionWarnings } = useStaticData(); const { hasCap, isAdmin } = useCapabilities(); const amuleEnabled = hasType('amule'); + const slskdEnabled = hasType('slskd'); return h('aside', { className: 'hidden md:flex md:flex-col w-56 bg-white dark:bg-gray-800 p-3 rounded-lg shadow border border-gray-200 dark:border-gray-700' @@ -60,6 +61,7 @@ const Sidebar = ({ currentView, onNavigate, isLandscape }) => { hasCap('view_uploads') && h(NavButton, { icon: 'upload', label: 'Uploads', view: 'uploads', active: currentView === 'uploads', onNavigate }), hasCap('manage_categories') && h(WarningNavButton, { currentView, onNavigate, icon: 'folder', label: 'Categories', view: 'categories', hasWarning: hasCategoryPathWarnings }), amuleEnabled && hasCap('view_servers') && h(NavButton, { icon: 'server', label: 'ED2K Servers', shortLabel: 'Servers', view: 'servers', active: currentView === 'servers', onNavigate }), + slskdEnabled && hasCap('search') && h(NavButton, { icon: 'messageSquare', label: 'Soulseek Chat', shortLabel: 'Chat', view: 'chat', active: currentView === 'chat', onNavigate }), hasCap('view_logs') && h(NavButton, { icon: 'fileText', label: 'Logs', view: 'logs', active: currentView === 'logs', onNavigate }), hasCap('view_statistics') && h(NavButton, { icon: 'chartBar', label: 'Statistics', view: 'statistics', active: currentView === 'statistics', onNavigate }), isAdmin && h(NavButton, { icon: 'bell', label: 'Notifications', view: 'notifications', active: currentView === 'notifications', onNavigate }), diff --git a/static/components/settings/ClientInstanceModal.js b/static/components/settings/ClientInstanceModal.js index 79f4bd1..4f9648e 100644 --- a/static/components/settings/ClientInstanceModal.js +++ b/static/components/settings/ClientInstanceModal.js @@ -61,6 +61,16 @@ const CLIENT_FIELDS = { { field: 'username', label: 'Username', description: 'Transmission RPC username', placeholder: 'Enter username' }, { field: 'password', label: 'Password', description: 'Transmission RPC password', placeholder: 'Enter Transmission password', sensitive: true }, { field: 'useSsl', label: 'Use SSL (HTTPS)', description: 'Connect to Transmission using HTTPS', toggle: true } + ], + slskd: [ + { field: 'host', label: 'Host', description: 'slskd API host address', placeholder: '127.0.0.1', defaultValue: '127.0.0.1', required: true }, + { field: 'port', label: 'Port', description: 'slskd API port (default: 5030)', placeholder: '5030', defaultValue: 5030, type: 'number', required: true, parseValue: v => parseInt(v, 10) || 5030 }, + { field: 'path', label: 'URL Path (Optional)', description: 'Base path when behind a reverse proxy (e.g., /slskd)', placeholder: 'Leave empty if not using a reverse proxy' }, + { field: 'apiKey', label: 'API Key (Recommended)', description: 'slskd API key (preferred for integrations)', placeholder: 'Enter API key', sensitive: true }, + { field: 'username', label: 'Username (Optional)', description: 'Used only when API key is not configured', placeholder: 'slskd username' }, + { field: 'password', label: 'Password (Optional)', description: 'Used only when API key is not configured', placeholder: 'slskd password', sensitive: true }, + { field: 'useSsl', label: 'Use SSL (HTTPS)', description: 'Connect to slskd using HTTPS', toggle: true }, + { field: 'downloadDirectory', label: 'Download Directory (Optional)', description: 'Local path where slskd saves completed files. Used for arr import path mapping (e.g. /downloads/slskd)', placeholder: '/downloads/slskd' } ] }; @@ -69,7 +79,8 @@ const TYPE_LABELS = { rtorrent: 'rTorrent', qbittorrent: 'qBittorrent', deluge: 'Deluge', - transmission: 'Transmission' + transmission: 'Transmission', + slskd: 'slskd' }; @@ -91,7 +102,8 @@ const TYPE_DESCRIPTIONS = { rtorrent: 'BitTorrent via XML-RPC / SCGI', qbittorrent: 'BitTorrent via WebUI API', deluge: 'BitTorrent via WebUI JSON-RPC', - transmission: 'BitTorrent via HTTP RPC' + transmission: 'BitTorrent via HTTP RPC', + slskd: 'Soulseek via slskd API' }; /** diff --git a/static/components/views/ChatView.js b/static/components/views/ChatView.js new file mode 100644 index 0000000..18c1d89 --- /dev/null +++ b/static/components/views/ChatView.js @@ -0,0 +1,806 @@ +/** + * ChatView Component + * + * Soulseek chat: private messages (DMs) + rooms. + * Only visible when an slskd instance is enabled/connected. + */ + +import React from 'https://esm.sh/react@18.2.0'; +const { createElement: h, useState, useEffect, useRef, useCallback } = React; + +import { Icon, EmptyState } from '../common/index.js'; +import { useStaticData } from '../../contexts/StaticDataContext.js'; +import { VIEW_TITLE_STYLES } from '../../utils/index.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function formatTimestamp(ts) { + if (!ts) return ''; + try { + const d = new Date(ts); + const now = new Date(); + const sameDay = d.toDateString() === now.toDateString(); + if (sameDay) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + + ' ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } catch { return ''; } +} + +function apiParams(instanceId) { + return instanceId ? `?instanceId=${encodeURIComponent(instanceId)}` : ''; +} + +// --------------------------------------------------------------------------- +// MessageInput — shared textarea + send button, manages its own state +// --------------------------------------------------------------------------- +const MessageInput = ({ placeholder, onSend, disabled = false }) => { + const [input, setInput] = useState(''); + const [sending, setSending] = useState(false); + const [err, setErr] = useState(null); + const inputRef = useRef(null); + + const handleSend = async () => { + const text = input.trim(); + if (!text || sending || disabled) return; + setSending(true); + setErr(null); + try { + await onSend(text); + setInput(''); + } catch (e) { + setErr(e.message); + } finally { + setSending(false); + inputRef.current?.focus(); + } + }; + + const handleKeyDown = (e) => { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } + }; + + return h('div', { className: 'px-3 py-2 border-t border-gray-200 dark:border-gray-700 shrink-0' }, + err && h('p', { className: 'text-xs text-red-500 mb-1' }, err), + h('div', { className: 'flex gap-2 items-end' }, + h('textarea', { + ref: inputRef, + value: input, + onChange: e => setInput(e.target.value), + onKeyDown: handleKeyDown, + placeholder, + rows: 1, + disabled: disabled || sending, + className: [ + 'flex-1 resize-none rounded-lg border border-gray-200 dark:border-gray-600', + 'bg-white dark:bg-gray-700 text-sm px-3 py-2', + 'text-gray-900 dark:text-gray-100 placeholder-gray-400', + 'focus:outline-none focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-600', + 'disabled:opacity-50 max-h-28 overflow-y-auto' + ].join(' ') + }), + h('button', { + onClick: handleSend, + disabled: !input.trim() || disabled || sending, + className: [ + 'shrink-0 p-2 rounded-lg transition-colors', + input.trim() && !disabled && !sending + ? 'bg-blue-500 hover:bg-blue-600 text-white' + : 'bg-gray-200 dark:bg-gray-600 text-gray-400 cursor-not-allowed' + ].join(' ') + }, + sending + ? h(Icon, { name: 'loader', size: 18, className: 'animate-spin' }) + : h(Icon, { name: 'send', size: 18 }) + ) + ) + ); +}; + +// --------------------------------------------------------------------------- +// DmBubble — private message bubble, aligned by direction +// --------------------------------------------------------------------------- +const DmBubble = ({ msg }) => { + const isOutgoing = msg.direction === 'Outgoing'; + return h('div', { className: `flex ${isOutgoing ? 'justify-end' : 'justify-start'} mb-1` }, + h('div', { + className: [ + 'max-w-[75%] px-3 py-1.5 rounded-2xl text-sm', + isOutgoing + ? 'bg-blue-500 text-white rounded-br-sm' + : 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-bl-sm' + ].join(' ') + }, + h('p', { className: 'break-words' }, msg.message), + h('p', { + className: `text-[10px] mt-0.5 ${isOutgoing ? 'text-blue-100' : 'text-gray-400 dark:text-gray-500'}` + }, formatTimestamp(msg.timestamp)) + ) + ); +}; + +// --------------------------------------------------------------------------- +// RoomBubble — room message bubble, shows sender username for others +// --------------------------------------------------------------------------- +const RoomBubble = ({ msg, ownUsername }) => { + const isOwn = msg.self || msg.username === ownUsername; + return h('div', { className: `flex ${isOwn ? 'justify-end' : 'justify-start'} mb-2` }, + h('div', { className: 'max-w-[78%]' }, + !isOwn && h('p', { + className: 'text-[11px] font-medium text-blue-600 dark:text-blue-400 mb-0.5 px-1' + }, msg.username), + h('div', { + className: [ + 'px-3 py-1.5 rounded-2xl text-sm', + isOwn + ? 'bg-blue-500 text-white rounded-br-sm' + : 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded-bl-sm' + ].join(' ') + }, + h('p', { className: 'break-words' }, msg.message), + h('p', { + className: `text-[10px] mt-0.5 ${isOwn ? 'text-blue-100' : 'text-gray-400 dark:text-gray-500'}` + }, formatTimestamp(msg.timestamp)) + ) + ) + ); +}; + +// --------------------------------------------------------------------------- +// StatusDot — colored presence indicator +// --------------------------------------------------------------------------- +const StatusDot = ({ status, className = '' }) => { + const color = status === 'online' + ? 'bg-green-400' + : status === 'away' + ? 'bg-amber-400' + : 'bg-gray-300 dark:bg-gray-600'; + return h('span', { + className: `inline-block w-2 h-2 rounded-full shrink-0 ${color} ${className}`, + title: status !== 'none' ? status : undefined + }); +}; + +// --------------------------------------------------------------------------- +// ConversationItem — DM list row +// --------------------------------------------------------------------------- +const ConversationItem = ({ conv, active, onClick, status = 'none' }) => ( + h('button', { + onClick, + className: [ + 'w-full text-left px-2 py-2 rounded-lg transition-colors', + active + ? 'bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700' + : 'hover:bg-gray-50 dark:hover:bg-gray-700/50' + ].join(' ') + }, + h('div', { className: 'flex items-center gap-2' }, + h('div', { className: 'relative shrink-0' }, + h('div', { className: 'w-7 h-7 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center' }, + h('span', { className: 'text-xs font-semibold text-blue-600 dark:text-blue-300' }, + (conv.username[0] || '?').toUpperCase() + ) + ), + status !== 'none' && h('span', { + className: `absolute -bottom-0.5 -right-0.5 w-2 h-2 rounded-full border border-white dark:border-gray-800 ${ + status === 'online' ? 'bg-green-400' : status === 'away' ? 'bg-amber-400' : 'bg-gray-400' + }` + }) + ), + h('div', { className: 'flex-1 min-w-0' }, + h('div', { className: 'flex items-center justify-between gap-1' }, + h('span', { + className: [ + 'text-sm font-medium truncate', + conv.hasUnread ? 'text-gray-900 dark:text-white' : 'text-gray-600 dark:text-gray-300' + ].join(' ') + }, conv.username), + conv.hasUnread && h('span', { className: 'shrink-0 w-2 h-2 rounded-full bg-blue-500' }) + ) + ) + ) + ) +); + +// --------------------------------------------------------------------------- +// RoomItem — room list row with inline leave button +// --------------------------------------------------------------------------- +const RoomItem = ({ room, active, onClick, onLeave }) => ( + h('button', { + onClick, + className: [ + 'w-full text-left px-2 py-2 rounded-lg transition-colors group', + active + ? 'bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-700' + : 'hover:bg-gray-50 dark:hover:bg-gray-700/50' + ].join(' ') + }, + h('div', { className: 'flex items-center gap-2' }, + h('div', { className: 'w-7 h-7 rounded-md bg-purple-100 dark:bg-purple-900/40 flex items-center justify-center shrink-0' }, + h(Icon, { name: 'hash', size: 13, className: 'text-purple-600 dark:text-purple-400' }) + ), + h('div', { className: 'flex-1 min-w-0' }, + h('span', { className: 'text-sm font-medium truncate block text-gray-700 dark:text-gray-300' }, room.name), + room.userCount > 0 && h('span', { className: 'text-[11px] text-gray-400 dark:text-gray-500' }, `${room.userCount} users`) + ), + h('button', { + onClick: (e) => { e.stopPropagation(); onLeave(); }, + title: 'Leave room', + className: 'shrink-0 opacity-0 group-hover:opacity-100 p-0.5 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all' + }, h(Icon, { name: 'x', size: 13 })) + ) + ) +); + +// --------------------------------------------------------------------------- +// InlineInput — expandable add/join row at bottom of each list section +// --------------------------------------------------------------------------- +const InlineInput = ({ placeholder, submitLabel, onSubmit, onCancel }) => { + const [value, setValue] = useState(''); + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(null); + const inputRef = useRef(null); + + useEffect(() => { inputRef.current?.focus(); }, []); + + const handleSubmit = async () => { + const v = value.trim(); + if (!v || busy) return; + setBusy(true); + setErr(null); + try { + await onSubmit(v); + } catch (e) { + setErr(e.message); + setBusy(false); + } + }; + + const handleKeyDown = (e) => { + if (e.key === 'Enter') { e.preventDefault(); handleSubmit(); } + if (e.key === 'Escape') onCancel(); + }; + + return h('div', { className: 'px-2 py-2' }, + err && h('p', { className: 'text-xs text-red-500 mb-1 px-1' }, err), + h('div', { className: 'flex gap-1' }, + h('input', { + ref: inputRef, + value, + onChange: e => setValue(e.target.value), + onKeyDown: handleKeyDown, + placeholder, + disabled: busy, + className: [ + 'flex-1 text-sm rounded-md border border-gray-200 dark:border-gray-600', + 'bg-white dark:bg-gray-700 px-2 py-1.5', + 'text-gray-900 dark:text-gray-100 placeholder-gray-400', + 'focus:outline-none focus:ring-2 focus:ring-blue-300 dark:focus:ring-blue-600', + 'disabled:opacity-50' + ].join(' ') + }), + h('button', { + onClick: handleSubmit, + disabled: !value.trim() || busy, + className: [ + 'shrink-0 px-2 py-1 rounded-md text-xs font-medium transition-colors', + value.trim() && !busy + ? 'bg-blue-500 hover:bg-blue-600 text-white' + : 'bg-gray-200 dark:bg-gray-600 text-gray-400 cursor-not-allowed' + ].join(' ') + }, busy ? h(Icon, { name: 'loader', size: 12, className: 'animate-spin' }) : submitLabel), + h('button', { + onClick: onCancel, + title: 'Cancel', + className: 'shrink-0 p-1 rounded-md text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors' + }, h(Icon, { name: 'x', size: 14 })) + ) + ); +}; + +// --------------------------------------------------------------------------- +// PanelHeader — shared header bar for DM and room panels +// --------------------------------------------------------------------------- +const PanelHeader = ({ onBack, avatar, title, subtitle }) => ( + h('div', { className: 'flex items-center gap-2.5 px-3 py-2.5 border-b border-gray-200 dark:border-gray-700 shrink-0' }, + onBack && h('button', { + onClick: onBack, + className: 'p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors mr-0.5' + }, h(Icon, { name: 'chevronLeft', size: 18 })), + avatar, + h('div', { className: 'flex-1 min-w-0' }, + h('p', { className: 'font-semibold text-sm text-gray-900 dark:text-white truncate' }, title), + subtitle && h('p', { className: 'text-[11px] text-gray-400 dark:text-gray-500' }, subtitle) + ) + ) +); + +// --------------------------------------------------------------------------- +// ThreadPanel — private message thread, self-manages message fetching +// --------------------------------------------------------------------------- +const ThreadPanel = ({ username, instanceId, onBack, onConversationUpdated }) => { + const [messages, setMessages] = useState([]); + const [msgLoading, setMsgLoading] = useState(false); + const [userStatus, setUserStatus] = useState('none'); + const bottomRef = useRef(null); + + const fetchMessages = useCallback(async () => { + if (!username) return; + try { + const res = await fetch(`/api/slskd/conversations/${encodeURIComponent(username)}${apiParams(instanceId)}`); + const data = await res.json(); + if (data.success && data.conversation) setMessages(data.conversation.messages || []); + } catch (_) {} + }, [username, instanceId]); + + useEffect(() => { + if (!username) { setMessages([]); return; } + setMsgLoading(true); + setMessages([]); + fetchMessages().finally(() => setMsgLoading(false)); + const id = setInterval(fetchMessages, 15000); + return () => clearInterval(id); + }, [fetchMessages]); + + useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages.length]); + + // Auto-acknowledge unread incoming messages + useEffect(() => { + const unread = messages.filter(m => !m.isAcknowledged && m.direction === 'Incoming'); + if (unread.length === 0) return; + Promise.all(unread.map(m => + fetch(`/api/slskd/conversations/${encodeURIComponent(username)}/messages/${m.id}/acknowledge${apiParams(instanceId)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ instanceId }) + }).catch(() => {}) + )).then(() => onConversationUpdated?.()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [messages]); + + // Fetch user presence status when DM thread opens + useEffect(() => { + if (!username) { setUserStatus('none'); return; } + fetch(`/api/slskd/users/${encodeURIComponent(username)}${apiParams(instanceId)}`) + .then(r => r.json()) + .then(d => { if (d.success) setUserStatus(d.status || 'none'); }) + .catch(() => {}); + }, [username, instanceId]); + + const handleSend = async (text) => { + const res = await fetch(`/api/slskd/conversations/${encodeURIComponent(username)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: text, instanceId }) + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Send failed'); + fetchMessages(); + onConversationUpdated?.(); + }; + + if (!username) return null; + + return h('div', { className: 'flex flex-col h-full' }, + h(PanelHeader, { + onBack, + avatar: h('div', { className: 'relative shrink-0' }, + h('div', { className: 'w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center' }, + h('span', { className: 'text-sm font-semibold text-blue-600 dark:text-blue-300' }, + (username[0] || '?').toUpperCase() + ) + ), + userStatus !== 'none' && h('span', { + className: `absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-white dark:border-gray-800 ${userStatus === 'online' ? 'bg-green-400' : userStatus === 'away' ? 'bg-amber-400' : 'bg-gray-400'}` + }) + ), + title: username + }), + h('div', { className: 'flex-1 overflow-y-auto px-3 py-3 min-h-0' }, + msgLoading + ? h('div', { className: 'flex items-center justify-center h-full' }, + h(Icon, { name: 'loader', size: 20, className: 'animate-spin text-gray-400 dark:text-gray-500' })) + : messages.length === 0 + ? h('div', { className: 'flex items-center justify-center h-full text-gray-400 dark:text-gray-500 text-sm' }, 'No messages yet') + : messages.map(msg => h(DmBubble, { key: msg.id || msg.timestamp, msg })), + h('div', { ref: bottomRef }) + ), + h(MessageInput, { placeholder: `Message ${username}…`, onSend: handleSend }) + ); +}; + +// --------------------------------------------------------------------------- +// RoomPanel — room thread, polls per-room endpoint, shows users with status +// --------------------------------------------------------------------------- +const RoomPanel = ({ roomName, instanceId, ownUsername, onBack, onRoomsUpdated }) => { + const [messages, setMessages] = useState([]); + const [users, setUsers] = useState([]); + const [userCount, setUserCount] = useState(0); + const [msgLoading, setMsgLoading] = useState(false); + const [showUsers, setShowUsers] = useState(true); + const bottomRef = useRef(null); + + const fetchRoom = useCallback(async () => { + if (!roomName) return; + try { + const res = await fetch(`/api/slskd/rooms/${encodeURIComponent(roomName)}${apiParams(instanceId)}`); + const data = await res.json(); + if (data.success && data.room) { + setMessages(data.room.messages || []); + setUsers(data.room.users || []); + setUserCount(data.room.userCount || 0); + } + } catch (_) {} + }, [roomName, instanceId]); + + useEffect(() => { + if (!roomName) { setMessages([]); setUsers([]); return; } + setMsgLoading(true); + setMessages([]); + fetchRoom().finally(() => setMsgLoading(false)); + const id = setInterval(fetchRoom, 10000); + return () => clearInterval(id); + }, [fetchRoom]); + + useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages.length]); + + const handleSend = async (text) => { + const res = await fetch(`/api/slskd/rooms/${encodeURIComponent(roomName)}/messages${apiParams(instanceId)}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: text, instanceId }) + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Send failed'); + setTimeout(fetchRoom, 600); + onRoomsUpdated?.(); + }; + + if (!roomName) return null; + + const onlineCount = users.filter(u => u.status === 'online').length; + + return h('div', { className: 'flex flex-col h-full' }, + // Header with users toggle button + h('div', { className: 'flex items-center gap-2 px-3 py-2.5 border-b border-gray-200 dark:border-gray-700 shrink-0' }, + onBack && h('button', { + onClick: onBack, + className: 'p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors mr-0.5' + }, h(Icon, { name: 'chevronLeft', size: 18 })), + h('div', { className: 'w-8 h-8 rounded-md bg-purple-100 dark:bg-purple-900/40 flex items-center justify-center shrink-0' }, + h(Icon, { name: 'hash', size: 16, className: 'text-purple-600 dark:text-purple-400' }) + ), + h('div', { className: 'flex-1 min-w-0' }, + h('p', { className: 'font-semibold text-sm text-gray-900 dark:text-white truncate' }, roomName), + userCount > 0 && h('p', { className: 'text-[11px] text-gray-400 dark:text-gray-500' }, + onlineCount > 0 ? `${onlineCount} online · ${userCount} total` : `${userCount} users`) + ), + h('button', { + onClick: () => setShowUsers(v => !v), + title: showUsers ? 'Hide users' : 'Show users', + className: [ + 'shrink-0 p-1.5 rounded-lg transition-colors', + showUsers + ? 'bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-400' + : 'text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700' + ].join(' ') + }, h(Icon, { name: 'users', size: 15 })) + ), + h('div', { className: 'flex flex-1 min-h-0' }, + // Messages column + h('div', { className: 'flex flex-col flex-1 min-h-0 min-w-0' }, + h('div', { className: 'flex-1 scroll-hover px-3 py-3 min-h-0' }, + msgLoading + ? h('div', { className: 'flex items-center justify-center h-full' }, + h(Icon, { name: 'loader', size: 20, className: 'animate-spin text-gray-400 dark:text-gray-500' })) + : messages.length === 0 + ? h('div', { className: 'flex flex-col items-center justify-center h-full gap-2 text-gray-400 dark:text-gray-500' }, + h(Icon, { name: 'hash', size: 28, className: 'opacity-25' }), + h('span', { className: 'text-sm' }, 'No messages yet')) + : messages.map((msg, i) => h(RoomBubble, { key: `${msg.timestamp}-${i}`, msg, ownUsername })), + h('div', { ref: bottomRef }) + ), + h(MessageInput, { placeholder: `Message #${roomName}…`, onSend: handleSend }) + ), + // Users sidebar (collapsible) + showUsers && h('div', { + className: 'w-40 shrink-0 border-l border-gray-200 dark:border-gray-700 scroll-hover bg-gray-50 dark:bg-gray-800/50' + }, + h('p', { className: 'text-[11px] font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide px-3 py-2 border-b border-gray-100 dark:border-gray-700' }, + `Users (${userCount})`), + users.map(u => + h('div', { key: u.username, className: 'flex items-center gap-2 px-3 py-1.5' }, + h(StatusDot, { status: u.status }), + h('span', { + className: [ + 'text-xs truncate', + u.self ? 'font-semibold text-blue-600 dark:text-blue-400' : 'text-gray-700 dark:text-gray-300' + ].join(' '), + title: u.username + }, u.username) + ) + ) + ) + ) + ); +}; + +// --------------------------------------------------------------------------- +// ChatView — main component +// --------------------------------------------------------------------------- +const ChatView = () => { + const { instances } = useStaticData(); + const slskdInstance = Object.values(instances).find(i => i.type === 'slskd' && i.connected) + || Object.values(instances).find(i => i.type === 'slskd'); + const instanceId = slskdInstance?.instanceId || null; + + const [activeTab, setActiveTab] = useState('dms'); // 'dms' | 'rooms' + + // DMs + const [conversations, setConversations] = useState([]); + const [convsLoading, setConvsLoading] = useState(true); + const [convsError, setConvsError] = useState(null); + const [showNewDm, setShowNewDm] = useState(false); + const [userStatuses, setUserStatuses] = useState({}); + + // Rooms + const [rooms, setRooms] = useState([]); + const [ownUsername, setOwnUsername] = useState(''); + const [roomsLoading, setRoomsLoading] = useState(true); + const [roomsError, setRoomsError] = useState(null); + const [showJoinRoom, setShowJoinRoom] = useState(false); + + // Selected panel: null | { type: 'dm', username } | { type: 'room', name } + const [selectedPanel, setSelectedPanel] = useState(null); + const [showThread, setShowThread] = useState(false); // mobile: show thread pane + + const fetchConversations = useCallback(async () => { + try { + const res = await fetch(`/api/slskd/conversations${apiParams(instanceId)}`); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to load conversations'); + const convs = data.conversations || []; + setConversations(convs); + setConvsError(null); + // Async: batch-fetch presence status for all conversation partners + if (convs.length > 0) { + Promise.allSettled(convs.map(c => + fetch(`/api/slskd/users/${encodeURIComponent(c.username)}${apiParams(instanceId)}`) + .then(r => r.json()) + .then(d => d?.success ? [c.username, d.status || 'none'] : null) + .catch(() => null) + )).then(results => { + const map = {}; + results.forEach(r => { if (r.status === 'fulfilled' && r.value) map[r.value[0]] = r.value[1]; }); + setUserStatuses(prev => ({ ...prev, ...map })); + }); + } + } catch (err) { + setConvsError(err.message); + } finally { + setConvsLoading(false); + } + }, [instanceId]); + + const fetchRooms = useCallback(async () => { + try { + const res = await fetch(`/api/slskd/rooms${apiParams(instanceId)}`); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to load rooms'); + setRooms(data.rooms || []); + if (data.ownUsername) setOwnUsername(data.ownUsername); + setRoomsError(null); + } catch (err) { + setRoomsError(err.message); + } finally { + setRoomsLoading(false); + } + }, [instanceId]); + + useEffect(() => { + fetchConversations(); + const id = setInterval(fetchConversations, 30000); + return () => clearInterval(id); + }, [fetchConversations]); + + useEffect(() => { + fetchRooms(); + const id = setInterval(fetchRooms, 15000); + return () => clearInterval(id); + }, [fetchRooms]); + + const handleSelectDm = (username) => { setSelectedPanel({ type: 'dm', username }); setShowThread(true); }; + const handleSelectRoom = (name) => { setSelectedPanel({ type: 'room', name }); setShowThread(true); }; + const handleBack = () => setShowThread(false); + + const handleNewDm = async (username) => { + setShowNewDm(false); + handleSelectDm(username.trim()); + }; + + const handleJoinRoom = async (roomName) => { + const res = await fetch('/api/slskd/rooms', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ roomName: roomName.trim(), instanceId }) + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Failed to join room'); + setShowJoinRoom(false); + await fetchRooms(); + setActiveTab('rooms'); + handleSelectRoom(roomName.trim()); + }; + + const handleLeaveRoom = async (roomName) => { + try { + await fetch(`/api/slskd/rooms/${encodeURIComponent(roomName)}${apiParams(instanceId)}`, { method: 'DELETE' }); + if (selectedPanel?.type === 'room' && selectedPanel.name === roomName) { + setSelectedPanel(null); + setShowThread(false); + } + fetchRooms(); + } catch (_) {} + }; + + if (!slskdInstance) { + return h('div', { className: 'w-full lg:w-5/6 mx-auto px-2 py-4 sm:px-4' }, + h(EmptyState, { + icon: 'messageSquare', + title: 'Soulseek not enabled', + description: 'Add and enable a Soulseek (slskd) client instance in Settings to use chat.' + }) + ); + } + + // Sidebar list renderers + const renderDmList = () => { + if (convsLoading) return h('div', { className: 'flex justify-center py-8' }, + h(Icon, { name: 'loader', size: 18, className: 'animate-spin text-gray-400' })); + if (convsError) return h('p', { className: 'text-xs text-red-500 px-3 py-4 text-center' }, convsError); + if (conversations.length === 0) return h('div', { className: 'px-3 py-8 text-center' }, + h('p', { className: 'text-xs text-gray-400 dark:text-gray-500' }, 'No conversations yet')); + return h('div', { className: 'py-1.5 space-y-0.5' }, + conversations.map(conv => + h(ConversationItem, { + key: conv.username, + conv, + status: userStatuses[conv.username] || 'none', + active: selectedPanel?.type === 'dm' && selectedPanel.username === conv.username, + onClick: () => handleSelectDm(conv.username) + }) + ) + ); + }; + + const renderRoomList = () => { + if (roomsLoading) return h('div', { className: 'flex justify-center py-8' }, + h(Icon, { name: 'loader', size: 18, className: 'animate-spin text-gray-400' })); + if (roomsError) return h('p', { className: 'text-xs text-red-500 px-3 py-4 text-center' }, roomsError); + if (rooms.length === 0) return h('div', { className: 'px-3 py-8 text-center' }, + h('p', { className: 'text-xs text-gray-400 dark:text-gray-500' }, 'No rooms joined yet')); + return h('div', { className: 'py-1.5 space-y-0.5' }, + rooms.map(room => + h(RoomItem, { + key: room.name, + room, + active: selectedPanel?.type === 'room' && selectedPanel.name === room.name, + onClick: () => handleSelectRoom(room.name), + onLeave: () => handleLeaveRoom(room.name) + }) + ) + ); + }; + + return h('div', { className: 'w-full lg:w-5/6 mx-auto px-2 py-4 sm:px-4 flex flex-col h-full' }, + h('div', { className: 'flex items-center gap-2 mb-4 shrink-0' }, + h('h1', { className: VIEW_TITLE_STYLES }, 'Soulseek Chat') + ), + + h('div', { + className: 'flex flex-1 rounded-xl border border-gray-200 dark:border-gray-700 overflow-hidden min-h-0', + style: { height: 'calc(100vh - 180px)' } + }, + + // ---- Left sidebar ---- + h('div', { + className: [ + 'flex flex-col w-full md:w-60 shrink-0', + 'border-r border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800', + showThread ? 'hidden md:flex' : 'flex' + ].join(' ') + }, + + // Tab bar + h('div', { className: 'flex shrink-0 border-b border-gray-200 dark:border-gray-700' }, + h('button', { + onClick: () => setActiveTab('dms'), + className: [ + 'flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors border-b-2', + activeTab === 'dms' + ? 'text-blue-600 dark:text-blue-400 border-blue-500' + : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 border-transparent' + ].join(' ') + }, h(Icon, { name: 'messageSquare', size: 13 }), 'Messages'), + h('button', { + onClick: () => setActiveTab('rooms'), + className: [ + 'flex-1 flex items-center justify-center gap-1.5 py-2.5 text-xs font-medium transition-colors border-b-2', + activeTab === 'rooms' + ? 'text-blue-600 dark:text-blue-400 border-blue-500' + : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 border-transparent' + ].join(' ') + }, h(Icon, { name: 'hash', size: 13 }), 'Rooms') + ), + + // List + h('div', { className: 'flex-1 overflow-y-auto px-2' }, + activeTab === 'dms' ? renderDmList() : renderRoomList() + ), + + // Footer action + h('div', { className: 'shrink-0 border-t border-gray-200 dark:border-gray-700' }, + activeTab === 'dms' && ( + showNewDm + ? h(InlineInput, { + placeholder: 'Enter username…', + submitLabel: 'Open', + onSubmit: handleNewDm, + onCancel: () => setShowNewDm(false) + }) + : h('button', { + onClick: () => { setShowNewDm(true); setShowJoinRoom(false); }, + className: 'w-full flex items-center gap-2 px-3 py-2.5 text-xs text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:text-blue-600 dark:hover:text-blue-400 transition-colors' + }, h(Icon, { name: 'plus', size: 13 }), 'New message') + ), + activeTab === 'rooms' && ( + showJoinRoom + ? h(InlineInput, { + placeholder: 'Room name…', + submitLabel: 'Join', + onSubmit: handleJoinRoom, + onCancel: () => setShowJoinRoom(false) + }) + : h('button', { + onClick: () => { setShowJoinRoom(true); setShowNewDm(false); }, + className: 'w-full flex items-center gap-2 px-3 py-2.5 text-xs text-gray-500 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700/50 hover:text-blue-600 dark:hover:text-blue-400 transition-colors' + }, h(Icon, { name: 'plus', size: 13 }), 'Join a room') + ) + ) + ), + + // ---- Right panel ---- + h('div', { + className: [ + 'flex-1 bg-white dark:bg-gray-800', + !showThread ? 'hidden md:flex md:flex-col' : 'flex flex-col' + ].join(' ') + }, + selectedPanel?.type === 'dm' + ? h(ThreadPanel, { + username: selectedPanel.username, + instanceId, + onBack: handleBack, + onConversationUpdated: fetchConversations + }) + : selectedPanel?.type === 'room' + ? h(RoomPanel, { + roomName: selectedPanel.name, + instanceId, + ownUsername, + onBack: handleBack, + onRoomsUpdated: fetchRooms + }) + : h('div', { + className: 'hidden md:flex flex-col items-center justify-center h-full gap-3 text-gray-400 dark:text-gray-500' + }, + h(Icon, { name: 'messageSquare', size: 36, className: 'opacity-25' }), + h('p', { className: 'text-sm' }, 'Select a conversation or room') + ) + ) + ) + ); +}; + +export default ChatView; diff --git a/static/components/views/HomeView.js b/static/components/views/HomeView.js index adbe620..574a6ff 100644 --- a/static/components/views/HomeView.js +++ b/static/components/views/HomeView.js @@ -26,9 +26,13 @@ import { useStaticData } from '../../contexts/StaticDataContext.js'; import { useClientChartConfig } from '../../hooks/useClientChartConfig.js'; import { useCapabilities } from '../../hooks/useCapabilities.js'; import { useResponsiveLayout } from '../../hooks/useResponsiveLayout.js'; +import { useDashboardPrefs } from '../../hooks/useDashboardPrefs.js'; const { createElement: h, useState, useEffect, useRef, useCallback, useMemo, lazy, Suspense } = React; +// Default tint colors per network type (used when a client has no custom color set) +const NETWORK_COLOR_DEFAULTS = { ed2k: '#8b5cf6', bittorrent: '#3b82f6', soulseek: '#06b6d4' }; + // Lazy load chart components for better initial page load performance const ClientSpeedChart = lazy(() => import('../common/ClientSpeedChart.js')); const ClientTransferChart = lazy(() => import('../common/ClientTransferChart.js')); @@ -47,6 +51,9 @@ const HomeView = () => { const { instances } = useStaticData(); const { isMobile } = useResponsiveLayout(); + // Dashboard display preferences (client-side localStorage) + const { combinedGraph } = useDashboardPrefs(); + // Compute instanceIds filter param — empty string means all connected (no filter) const instanceIdsParam = useMemo(() => { const connected = Object.entries(instances) @@ -104,7 +111,16 @@ const HomeView = () => { let url = '/api/metrics/dashboard?range=24h'; if (!isMobile && instanceIdsParam) url += `&instanceIds=${instanceIdsParam}`; const response = await fetch(url); - const { speedData, historicalData, historicalStats } = await response.json(); + if (!response.ok) { + setDashboardState(prev => ({ ...prev, loading: false })); + return; + } + const text = await response.text(); + if (!text) { + setDashboardState(prev => ({ ...prev, loading: false })); + return; + } + const { speedData, historicalData, historicalStats } = JSON.parse(text); setDashboardState({ speedData, historicalData, historicalStats, loading: false }); @@ -136,13 +152,45 @@ const HomeView = () => { // Get client chart configuration from hook const { isLoading: clientConfigLoading, - showBothCharts, - showSingleClient, - singleNetworkType, - singleNetworkName, + visibleNetworkInfo, shouldRenderCharts } = useClientChartConfig(); + // Representative color per network type: use the first enabled instance's color + const networkColorMap = useMemo(() => { + const result = { ...NETWORK_COLOR_DEFAULTS }; + const seen = new Set(); + Object.entries(instances).forEach(([id, inst]) => { + const nt = inst.networkType; + if (inst.color && nt && !disabledInstances.has(id) && !seen.has(nt)) { + result[nt] = inst.color; + seen.add(nt); + } + }); + return result; + }, [instances, disabledInstances]); // eslint-disable-line react-hooks/exhaustive-deps + + // Enrich visibleNetworkInfo with the tint color for each network + const networksWithColors = useMemo( + () => visibleNetworkInfo.map(info => ({ ...info, color: networkColorMap[info.type] })), + [visibleNetworkInfo, networkColorMap] + ); + + // Whether to show the network selector (setting is on AND 2+ networks are visible) + const showCombined = combinedGraph && networksWithColors.length >= 2; + + // Which network is selected in the selector view + const [selectedChartNetwork, setSelectedChartNetwork] = useState(null); + + // Auto-reset selection when visible networks change + useEffect(() => { + if (visibleNetworkInfo.length === 0) return; + const types = visibleNetworkInfo.map(n => n.type); + if (!selectedChartNetwork || !types.includes(selectedChartNetwork)) { + setSelectedChartNetwork(types[0]); + } + }, [visibleNetworkInfo, selectedChartNetwork]); + // Aliases for readability const stats = dataStats; const downloads = useMemo(() => dataItems.filter(i => i.downloading), [dataItems]); @@ -151,6 +199,95 @@ const HomeView = () => { const onSearch = actions.search.perform; const loadingDashboard = dashboardState.loading; + const chartColClass = visibleNetworkInfo.length >= 3 ? 'col-span-6 md:col-span-2' : visibleNetworkInfo.length === 2 ? 'col-span-6 md:col-span-3' : 'col-span-6'; + + const chartFallback = h('div', { className: 'h-full flex items-center justify-center' }, h(LoadingSpinner, { size: 'sm' })); + + const renderDesktopCharts = (kind) => visibleNetworkInfo.map((info) => { + const title = kind === 'speed' ? `${info.label} Speed (24h)` : `${info.label} Data Transferred (24h)`; + const chart = kind === 'speed' + ? h(Suspense, { fallback: chartFallback }, + h(ClientSpeedChart, { speedData: dashboardState.speedData, networkType: info.type, theme, historicalRange: '24h' })) + : h(Suspense, { fallback: chartFallback }, + h(ClientTransferChart, { historicalData: dashboardState.historicalData, networkType: info.type, theme, historicalRange: '24h' })); + + return h('div', { key: `${kind}-${info.type}`, className: chartColClass }, + h(DashboardChartWidget, { + title: h('span', { className: 'flex items-center gap-2' }, + h(ClientIcon, { clientType: info.client, size: 16 }), + title + ), + height: '200px' + }, + shouldRenderCharts && (kind === 'speed' ? dashboardState.speedData : dashboardState.historicalData) + ? chart + : h('div', { className: 'h-full' }) + ) + ); + }); + + // Renders a network icon-toggle (floating, identical to mobile) + speed/transfer charts + const renderNetworkSelectorRow = () => { + if (networksWithColors.length === 0) return null; + const selectedNet = networksWithColors.find(n => n.type === selectedChartNetwork) || networksWithColors[0]; + if (!selectedNet) return null; + const hasSpeedData = shouldRenderCharts && dashboardState.speedData; + const hasHistData = shouldRenderCharts && dashboardState.historicalData; + + // Icon-only toggle floating in the top-left corner of the speed chart, + // matching the mobile MobileSpeedWidget style exactly. + const networkToggle = networksWithColors.length >= 2 && h('div', { + className: 'absolute top-2 left-2 z-10 flex rounded-md overflow-hidden border border-gray-300 dark:border-gray-600' + }, + ...networksWithColors.map((net, index) => + h('button', { + key: net.type, + onClick: () => setSelectedChartNetwork(net.type), + title: net.label, + className: `p-1.5 ${index > 0 ? 'border-l border-gray-300 dark:border-gray-600' : ''} ${ + selectedNet.type === net.type + ? 'bg-blue-100 dark:bg-blue-900/50' + : 'bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700' + }` + }, + h(ClientIcon, { clientType: net.client, size: 16 }) + ) + ) + ); + + return h(React.Fragment, null, + h('div', { key: 'sel-speed', className: 'col-span-6 md:col-span-3' }, + h(DashboardChartWidget, { + title: h('span', { className: 'flex items-center justify-center gap-2' }, + h(ClientIcon, { clientType: selectedNet.client, size: 16 }), + `${selectedNet.label} Speed (24h)` + ), + height: '200px', + overlay: networkToggle + }, + hasSpeedData + ? h(Suspense, { fallback: chartFallback }, + h(ClientSpeedChart, { speedData: dashboardState.speedData, networkType: selectedNet.type, theme, historicalRange: '24h' })) + : h('div', { className: 'h-full' }) + ) + ), + h('div', { key: 'sel-transfer', className: 'col-span-6 md:col-span-3' }, + h(DashboardChartWidget, { + title: h('span', { className: 'flex items-center justify-center gap-2' }, + h(ClientIcon, { clientType: selectedNet.client, size: 16 }), + `${selectedNet.label} Data Transferred (24h)` + ), + height: '200px' + }, + hasHistData + ? h(Suspense, { fallback: chartFallback }, + h(ClientTransferChart, { historicalData: dashboardState.historicalData, networkType: selectedNet.type, theme, historicalRange: '24h' })) + : h('div', { className: 'h-full' }) + ) + ) + ); + }; + return h('div', { className: 'flex-1 flex flex-col py-0 px-2 sm:px-0' }, // Desktop: Dashboard layout (shown when sidebar is visible at md+) h('div', { className: 'hidden md:block' }, @@ -188,8 +325,7 @@ const HomeView = () => { ), h('div', { className: `grid grid-cols-6 gap-4${loadingDashboard && !clientConfigLoading ? ' opacity-50 pointer-events-none' : ''}` }, - // Loading skeleton charts (shown while waiting for WebSocket data) - clientConfigLoading && h('div', { className: 'col-span-6 md:col-span-3' }, + clientConfigLoading && visibleNetworkInfo.length === 0 && h('div', { className: 'col-span-6 md:col-span-3' }, h('div', { className: 'bg-white dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-700 animate-pulse' }, @@ -197,126 +333,9 @@ const HomeView = () => { h('div', { style: { height: '200px' } }) ) ), - clientConfigLoading && h('div', { className: 'col-span-6 md:col-span-3' }, - h('div', { - className: 'bg-white dark:bg-gray-800 rounded-lg p-3 border border-gray-200 dark:border-gray-700 animate-pulse' - }, - h('div', { className: 'h-4 w-32 bg-gray-200 dark:bg-gray-700 rounded mb-3' }), - h('div', { style: { height: '200px' } }) - ) - ), - - // 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' - }, - h(LoadingSpinner, { size: 'sm' }) - ) - }, - h(ClientSpeedChart, { - speedData: dashboardState.speedData, - networkType: 'bittorrent', - theme, - historicalRange: '24h' - }) - ) - : h('div', { className: 'h-full' }) - ) - ), - - // SINGLE CLIENT: Speed Chart - showSingleClient && h('div', { className: 'col-span-6 md:col-span-3' }, - h(DashboardChartWidget, { - title: h('span', { className: 'flex items-center gap-2' }, - h(ClientIcon, { clientType: singleNetworkType, size: 16 }), - `${singleNetworkName} 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: singleNetworkType, - theme, - historicalRange: '24h' - }) - ) - : h('div', { className: 'h-full' }) - ) - ), - - // SINGLE CLIENT: Data Transferred Chart - showSingleClient && h('div', { className: 'col-span-6 md:col-span-3' }, - h(DashboardChartWidget, { - title: h('span', { className: 'flex items-center gap-2' }, - h(ClientIcon, { clientType: singleNetworkType, size: 16 }), - `${singleNetworkName} Data Transferred (24h)` - ), - height: '200px' - }, - shouldRenderCharts && dashboardState.historicalData - ? h(Suspense, { - fallback: h('div', { - className: 'h-full flex items-center justify-center' - }, - h(LoadingSpinner, { size: 'sm' }) - ) - }, - h(ClientTransferChart, { - historicalData: dashboardState.historicalData, - networkType: singleNetworkType, - theme, - historicalRange: '24h' - }) - ) - : h('div', { className: 'h-full' }) - ) - ), + visibleNetworkInfo.length > 0 && showCombined && renderNetworkSelectorRow(), + visibleNetworkInfo.length > 0 && !showCombined && renderDesktopCharts('speed'), + visibleNetworkInfo.length > 0 && !showCombined && renderDesktopCharts('transfer'), // 24h Stats Widget (full width) h('div', { className: 'col-span-6' }, diff --git a/static/components/views/LogsView.js b/static/components/views/LogsView.js index 9e00f7d..ef2766b 100644 --- a/static/components/views/LogsView.js +++ b/static/components/views/LogsView.js @@ -37,6 +37,9 @@ const CLIENT_LOG_SECTIONS = { qbittorrent: [ { key: 'qbittorrentLogs', title: 'qBittorrent Logs', dataKey: 'dataQbittorrentLogs', loadedKey: 'qbittorrentLogs', fetchKey: 'fetchQbittorrentLogs' } ], + slskd: [ + { key: 'slskdLogs', title: 'Soulseek Logs', dataKey: 'dataSlskdLogs', loadedKey: 'slskdLogs', fetchKey: 'fetchSlskdLogs' } + ], amule: [ { key: 'logs', title: 'aMule Logs', dataKey: 'dataLogs', loadedKey: 'logs', fetchKey: 'fetchLogs' }, { key: 'serverInfo', title: 'ED2K Server Info', dataKey: 'dataServerInfo', loadedKey: 'serverInfo', fetchKey: 'fetchServerInfo' } @@ -444,15 +447,15 @@ const AppLogSection = ({ records, sources, instances, fetchAppLogs, loaded, maxH * Logs view component */ const LogsView = () => { - const { dataLogs, dataServerInfo, dataAppLogs, dataAppLogSources, dataQbittorrentLogs, dataLoaded, instances } = useStaticData(); - const { fetchLogs, fetchServerInfo, fetchAppLogs, fetchQbittorrentLogs } = useDataFetch(); + const { dataLogs, dataServerInfo, dataAppLogs, dataAppLogSources, dataQbittorrentLogs, dataSlskdLogs, dataLoaded, instances } = useStaticData(); + const { fetchLogs, fetchServerInfo, fetchAppLogs, fetchQbittorrentLogs, fetchSlskdLogs } = useDataFetch(); const { fontSize } = useFontSize(); // Lookup tables for dynamic access by config keys - const dataByKey = { dataLogs, dataServerInfo, dataQbittorrentLogs }; + const dataByKey = { dataLogs, dataServerInfo, dataQbittorrentLogs, dataSlskdLogs }; const fetchByKey = useMemo( - () => ({ fetchLogs, fetchServerInfo, fetchQbittorrentLogs }), - [fetchLogs, fetchServerInfo, fetchQbittorrentLogs] + () => ({ fetchLogs, fetchServerInfo, fetchQbittorrentLogs, fetchSlskdLogs }), + [fetchLogs, fetchServerInfo, fetchQbittorrentLogs, fetchSlskdLogs] ); // Group connected log-capable instances by type (capability-driven) diff --git a/static/components/views/SearchResultsView.js b/static/components/views/SearchResultsView.js index c7ab52d..31f3773 100644 --- a/static/components/views/SearchResultsView.js +++ b/static/components/views/SearchResultsView.js @@ -22,9 +22,11 @@ const SearchResultsView = () => { const { setAppCurrentView } = useAppState(); const { instances, hasMultiInstance } = useStaticData(); - // Instance badge for multi-instance ED2K/Kad searches + // Instance badge for multi-instance searches (ED2K/Kad or Soulseek) const isAmuleSearch = searchType === 'global' || searchType === 'kad'; - const instanceInfo = isAmuleSearch && hasMultiInstance && searchInstanceId && instances?.[searchInstanceId]; + const isSoulseekSearch = searchType === 'soulseek'; + const showInstanceBadge = (isAmuleSearch || isSoulseekSearch) && hasMultiInstance && searchInstanceId; + const instanceInfo = showInstanceBadge && instances?.[searchInstanceId]; const instanceName = instanceInfo ? (instanceInfo.name || searchInstanceId) : null; // Handler for new search button diff --git a/static/components/views/SearchView.js b/static/components/views/SearchView.js index af1b914..789ae74 100644 --- a/static/components/views/SearchView.js +++ b/static/components/views/SearchView.js @@ -11,7 +11,7 @@ import QuickSearchWidget from '../dashboard/QuickSearchWidget.js'; import { useSearch } from '../../contexts/SearchContext.js'; import { useActions } from '../../contexts/ActionsContext.js'; import { useDataFetch } from '../../contexts/DataFetchContext.js'; -import { useAmuleInstanceSelector } from '../../hooks/useAmuleInstanceSelector.js'; +import { useSearchProviderSelector } from '../../hooks/useSearchProviderSelector.js'; const { createElement: h, useEffect } = React; @@ -36,17 +36,17 @@ const SearchView = () => { const actions = useActions(); const { fetchPreviousSearchResults } = useDataFetch(); const { - connectedInstances: amuleInstances, - showSelector: showAmuleSelector, - selectedId: effectiveAmuleInstance, - selectInstance: selectAmuleInstance - } = useAmuleInstanceSelector({ selectedId: searchInstanceId, onSelect: setSearchInstanceId }); + connectedInstances: providerInstances, + showSelector: showProviderSelector, + selectedId: effectiveProviderInstance, + selectInstance: selectProviderInstance + } = useSearchProviderSelector({ searchType, selectedId: searchInstanceId, onSelect: setSearchInstanceId }); // Fetch previous search results on mount (always fetch fresh from backend) useEffect(() => { setSearchPreviousResultsLoaded(false); - fetchPreviousSearchResults(effectiveAmuleInstance); - }, [fetchPreviousSearchResults, setSearchPreviousResultsLoaded, effectiveAmuleInstance]); + fetchPreviousSearchResults(effectiveProviderInstance); + }, [fetchPreviousSearchResults, setSearchPreviousResultsLoaded, effectiveProviderInstance]); return h('div', { className: 'space-y-2 sm:space-y-3 px-2 sm:px-0' }, // Search form (reusing QuickSearchWidget without border) @@ -59,10 +59,10 @@ const SearchView = () => { onSearch: actions.search.perform, searchLocked, noBorder: true, - searchInstanceId: effectiveAmuleInstance, - onSearchInstanceChange: selectAmuleInstance, - amuleInstances, - showAmuleSelector + searchInstanceId: effectiveProviderInstance, + onSearchInstanceChange: selectProviderInstance, + providerInstances, + showProviderSelector }) ), diff --git a/static/components/views/SettingsView.js b/static/components/views/SettingsView.js index 7040f45..461b29b 100644 --- a/static/components/views/SettingsView.js +++ b/static/components/views/SettingsView.js @@ -12,6 +12,7 @@ const { createElement: h, useState, useEffect, useCallback } = React; import { useConfig } from '../../hooks/index.js'; import { useSettingsFormData } from '../../hooks/useSettingsFormData.js'; import { useClientManagement } from '../../hooks/useClientManagement.js'; +import { useDashboardPrefs } from '../../hooks/useDashboardPrefs.js'; import { useAppState } from '../../contexts/AppStateContext.js'; import { useStaticData } from '../../contexts/StaticDataContext.js'; import { LoadingSpinner, AlertBox, IconButton, Input, Select, Button, Icon, Portal } from '../common/index.js'; @@ -74,13 +75,16 @@ const SettingsView = () => { const [isTesting, setIsTesting] = useState(false); const [scriptTestResult, setScriptTestResult] = useState(null); const [openSections, setOpenSections] = useState({ - server: false, users: false, clients: false, + server: false, users: false, interface: false, clients: false, integrations: false, directories: false, history: false, eventScripting: false }); const closeAllSections = () => setOpenSections({ - server: false, users: false, clients: false, + server: false, users: false, interface: false, clients: false, integrations: false, directories: false, history: false, eventScripting: false }); + + // Client-side display preferences (instant-save, no server round-trip) + const { combinedGraph, setCombinedGraph } = useDashboardPrefs(); // Accordion toggle: opening one section closes all others const toggleSection = (key, value) => { if (value) { @@ -579,6 +583,23 @@ const SettingsView = () => { h(UserManagement, { currentUsername, onApiKeyChange: setAdminApiKey }) ), + // Interface Preferences — client-side only, instant-save to localStorage + h(ConfigSection, { + title: 'Interface Preferences', + description: 'Dashboard display options (saved locally in your browser)', + defaultOpen: false, + open: openSections.interface, + onToggle: (value) => toggleSection('interface', value), + icon: 'activity' + }, + h(EnableToggle, { + enabled: combinedGraph, + onChange: setCombinedGraph, + label: 'Network graph selector', + description: 'When multiple networks are active, show a tab selector to switch between per-network speed and transfer charts. When disabled, all networks are shown side by side simultaneously.' + }) + ), + // Download Clients — unified section with card grid h(ConfigSection, { title: 'Download Clients', diff --git a/static/components/views/SetupWizardView.js b/static/components/views/SetupWizardView.js index 87f871b..4c24af5 100644 --- a/static/components/views/SetupWizardView.js +++ b/static/components/views/SetupWizardView.js @@ -101,6 +101,11 @@ const SetupWizardView = ({ onComplete }) => { // Disabled by default unless explicitly enabled via env var enabled: meta?.fromEnv?.transmissionEnabled ? defaults.transmission.enabled : false }, + slskd: { + ...defaults.slskd, + // Disabled by default unless explicitly enabled via env var + enabled: meta?.fromEnv?.slskdEnabled ? defaults.slskd.enabled : false + }, directories: { ...defaults.directories }, integrations: { sonarr: { ...defaults.integrations.sonarr }, @@ -234,14 +239,20 @@ const SetupWizardView = ({ onComplete }) => { if (!formData.transmission.port && !meta?.fromEnv.transmissionPort) errors.push('Transmission port is required'); } + // Validate slskd if enabled + if (formData.slskd?.enabled) { + if (!formData.slskd.host && !meta?.fromEnv.slskdHost) errors.push('slskd host is required'); + if (!formData.slskd.port && !meta?.fromEnv.slskdPort) errors.push('slskd port is required'); + } + if (errors.length > 0) { setStepValidationError(errors.join(', ')); return; } // 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.rtorrent.enabled && !formData.qbittorrent?.enabled && !formData.deluge?.enabled && !formData.transmission?.enabled && !formData.slskd?.enabled) { + setStepValidationError('At least one download client (aMule, rTorrent, qBittorrent, Deluge, Transmission, or slskd) must be enabled'); return; } setStepValidationError(null); @@ -333,6 +344,9 @@ const SetupWizardView = ({ onComplete }) => { if (formData.transmission?.enabled) { testPayload.transmission = formData.transmission; } + if (formData.slskd?.enabled) { + testPayload.slskd = formData.slskd; + } if (Object.keys(testPayload).length > 0) { const data = await testConfig(testPayload); const newResults = {}; @@ -348,6 +362,9 @@ const SetupWizardView = ({ onComplete }) => { if (data?.results?.transmission) { newResults.transmission = { ...data.results.transmission, _label: 'Transmission Connection' }; } + if (data?.results?.slskd) { + newResults.slskd = { ...data.results.slskd, _label: 'slskd Connection' }; + } setClientTestResults(prev => ({ ...prev, ...newResults })); } } else if (currentStep === 4) { @@ -392,6 +409,7 @@ const SetupWizardView = ({ onComplete }) => { if (data?.results?.qbittorrent) newClientResults.qbittorrent = { ...data.results.qbittorrent, _label: 'qBittorrent Connection' }; if (data?.results?.deluge) newClientResults.deluge = { ...data.results.deluge, _label: 'Deluge Connection' }; if (data?.results?.transmission) newClientResults.transmission = { ...data.results.transmission, _label: 'Transmission Connection' }; + if (data?.results?.slskd) newClientResults.slskd = { ...data.results.slskd, _label: 'slskd Connection' }; setClientTestResults(newClientResults); } catch (err) { // Error handled by useConfig @@ -436,6 +454,7 @@ const SetupWizardView = ({ onComplete }) => { if (results?.results?.qbittorrent) newClientResults.qbittorrent = { ...results.results.qbittorrent, _label: 'qBittorrent Connection' }; if (results?.results?.deluge) newClientResults.deluge = { ...results.results.deluge, _label: 'Deluge Connection' }; if (results?.results?.transmission) newClientResults.transmission = { ...results.results.transmission, _label: 'Transmission Connection' }; + if (results?.results?.slskd) newClientResults.slskd = { ...results.results.slskd, _label: 'slskd Connection' }; setClientTestResults(newClientResults); // Check results directly from the return value @@ -530,6 +549,12 @@ const SetupWizardView = ({ onComplete }) => { if (meta?.fromEnv.transmissionHost) entry.source = 'env'; clients.push(entry); } + if (formData.slskd?.enabled) { + const { enabled, ...fields } = formData.slskd; + const entry = { type: 'slskd', enabled, ...fields }; + if (meta?.fromEnv.slskdHost) entry.source = 'env'; + clients.push(entry); + } return clients; }; @@ -1184,6 +1209,101 @@ const SetupWizardView = ({ onComplete }) => { ) ), + // slskd Section + h('div', { className: 'bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700 mb-6' }, + h('h3', { className: 'text-lg font-semibold text-gray-900 dark:text-gray-100 mb-4' }, 'Soulseek (slskd API)'), + + h(EnableToggle, { + label: 'Enable slskd', + description: 'Connect to slskd for managing Soulseek downloads via HTTP API', + enabled: formData.slskd?.enabled || false, + onChange: (enabled) => updateField('slskd', 'enabled', enabled) + }), + + formData.slskd?.enabled && h('div', { className: 'mt-4 space-y-4' }, + h(ConfigField, { + label: 'Host', + description: 'slskd API host address', + value: formData.slskd?.host || '', + onChange: (value) => updateField('slskd', 'host', value), + placeholder: '127.0.0.1', + required: formData.slskd?.enabled, + fromEnv: meta?.fromEnv.slskdHost + }), + + h(ConfigField, { + label: 'Port', + description: 'slskd API port (default: 5030)', + value: formData.slskd?.port || 5030, + onChange: (value) => updateField('slskd', 'port', parseInt(value, 10) || 5030), + type: 'number', + placeholder: '5030', + required: formData.slskd?.enabled, + fromEnv: meta?.fromEnv.slskdPort + }), + + h(ConfigField, { + label: 'URL Path (Optional)', + description: 'Base path when behind a reverse proxy (e.g., /slskd)', + value: formData.slskd?.path || '', + onChange: (value) => updateField('slskd', 'path', value), + placeholder: 'Leave empty if not using a reverse proxy', + fromEnv: meta?.fromEnv.slskdPath + }), + + !meta?.fromEnv.slskdApiKey && h(ConfigField, { + label: 'API Key (Recommended)', + description: 'slskd API key (preferred for integrations)', + fromEnv: meta?.fromEnv.slskdApiKey + }, + h(PasswordField, { + value: formData.slskd?.apiKey || '', + onChange: (value) => updateField('slskd', 'apiKey', value), + placeholder: 'Enter API key', + disabled: meta?.fromEnv.slskdApiKey + }) + ), + + meta?.fromEnv.slskdApiKey && h(AlertBox, { type: 'warning' }, + h('p', {}, 'slskd API key is set via SLSKD_API_KEY environment variable.') + ), + + !meta?.fromEnv.slskdUsername && h(ConfigField, { + label: 'Username (Optional)', + description: 'Used only when API key is not configured', + value: formData.slskd?.username || '', + onChange: (value) => updateField('slskd', 'username', value), + placeholder: 'slskd username', + fromEnv: meta?.fromEnv.slskdUsername + }), + + !meta?.fromEnv.slskdPassword && h(ConfigField, { + label: 'Password (Optional)', + description: 'Used only when API key is not configured', + fromEnv: meta?.fromEnv.slskdPassword + }, + h(PasswordField, { + value: formData.slskd?.password || '', + onChange: (value) => updateField('slskd', 'password', value), + placeholder: 'slskd password', + disabled: meta?.fromEnv.slskdPassword + }) + ), + + h(EnableToggle, { + label: 'Use SSL (HTTPS)', + description: 'Connect to slskd using HTTPS', + enabled: formData.slskd?.useSsl || false, + onChange: (enabled) => updateField('slskd', 'useSsl', enabled) + }), + + clientTestResults.slskd && h(TestResultIndicator, { + result: clientTestResults.slskd, + label: 'slskd Connection Test' + }) + ) + ), + // Test button for BitTorrent clients hasAnyBitTorrentClient && h('div', { className: 'mb-6' }, h(TestButton, { @@ -1192,7 +1312,8 @@ const SetupWizardView = ({ onComplete }) => { disabled: (formData.rtorrent.enabled && (formData.rtorrent.mode === 'scgi-socket' ? !formData.rtorrent.socketPath : (!formData.rtorrent.host || !formData.rtorrent.port))) || (formData.qbittorrent?.enabled && (!formData.qbittorrent.host || !formData.qbittorrent.port)) || (formData.deluge?.enabled && (!formData.deluge.host || !formData.deluge.port)) || - (formData.transmission?.enabled && (!formData.transmission.host || !formData.transmission.port)) + (formData.transmission?.enabled && (!formData.transmission.host || !formData.transmission.port)) || + (formData.slskd?.enabled && (!formData.slskd.host || !formData.slskd.port)) }, 'Test BitTorrent Connections') ), diff --git a/static/components/views/SharedView.js b/static/components/views/SharedView.js index bfcc622..b934b15 100644 --- a/static/components/views/SharedView.js +++ b/static/components/views/SharedView.js @@ -242,7 +242,23 @@ const SharedView = () => { const columns = useMemo(() => [ buildAddedAtColumn(), - buildFileNameColumn({ onClick: handleShowInfo, disabled: selectionMode }), + { + label: 'File Name', + key: 'name', + sortable: true, + width: 'auto', + render: (item) => h('div', { className: 'flex items-center gap-2 min-w-0' }, + h('span', { + className: `min-w-0 font-medium text-xs${selectionMode ? '' : ' cursor-pointer hover:underline decoration-dotted'}`, + style: { wordBreak: 'break-all', overflowWrap: 'anywhere' }, + onClick: selectionMode ? undefined : () => handleShowInfo(item) + }, item.name || 'Unknown'), + item.locked && h('span', { + className: 'shrink-0 rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-800 dark:bg-amber-900/40 dark:text-amber-300', + title: 'Excluded share' + }, 'Locked') + ) + }, buildStatusColumn({ statusFilter, setStatusFilter, @@ -471,6 +487,10 @@ const SharedView = () => { h('div', { className: 'space-y-1 text-xs' }, // Row 1: Uploaded - Session - Ratio - Tracker h('div', { className: 'flex items-center gap-1 text-gray-700 dark:text-gray-300 flex-wrap' }, + item.locked && h('span', { + className: 'rounded-full bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-800 dark:bg-amber-900/40 dark:text-amber-300' + }, 'Locked'), + item.locked && h('span', { className: 'text-gray-400' }, '·'), h(Icon, { name: 'upload', size: 12, className: 'text-gray-500 dark:text-gray-400' }), h('span', { className: 'text-gray-900 dark:text-gray-100' }, formatBytes(item.uploadTotal) + (item.requestsAcceptedTotal != null ? ` (${item.requestsAcceptedTotal})` : '') diff --git a/static/components/views/StatisticsView.js b/static/components/views/StatisticsView.js index 36456a8..6e5fdcf 100644 --- a/static/components/views/StatisticsView.js +++ b/static/components/views/StatisticsView.js @@ -55,8 +55,7 @@ const StatisticsView = () => { // Get client chart configuration from hook const { - ed2kConnected, - isEd2kEnabled, + visibleNetworkInfo, showBothCharts, showSingleClient, singleNetworkType, @@ -68,7 +67,7 @@ const StatisticsView = () => { const amuleConfigEnabled = hasType('amule'); // Show ED2K stats tree button only when aMule is enabled in config, connected, and enabled in filter - const showAmuleStatsTree = amuleConfigEnabled && ed2kConnected && isEd2kEnabled; + const showAmuleStatsTree = amuleConfigEnabled && visibleNetworkInfo.some(info => info.type === 'ed2k'); // State for stats tree modal const [showStatsTreeModal, setShowStatsTreeModal] = useState(false); @@ -170,6 +169,22 @@ const StatisticsView = () => { title ); + const chartColClass = visibleNetworkInfo.length >= 3 ? 'col-span-6 md:col-span-2' : visibleNetworkInfo.length === 2 ? 'col-span-6 md:col-span-3' : 'col-span-6'; + + const renderChartCards = (kind) => visibleNetworkInfo.map((info) => { + const title = kind === 'speed' ? `${info.label} Speed` : `${info.label} Data Transferred`; + const chart = kind === 'speed' + ? renderSpeedChart(info.type) + : renderTransferChart(info.type); + + return h('div', { key: `${kind}-${info.type}`, className: chartColClass }, + h(DashboardChartWidget, { + title: chartTitle(title, info.client), + height: '225px' + }, chart) + ); + }); + return h('div', { className: 'space-y-2 sm:space-y-3 px-2 sm:px-0' }, // Header h('div', { className: 'flex justify-between items-center gap-2' }, @@ -243,42 +258,10 @@ const StatisticsView = () => { // Charts content (always rendered, dimmed when loading) 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, - h(DashboardChartWidget, { - title: chartTitle('aMule Speed', 'ed2k'), - height: '225px' - }, renderSpeedChart('ed2k')), - h(DashboardChartWidget, { - title: chartTitle('BitTorrent Speed', 'bittorrent'), - height: '225px' - }, renderSpeedChart('bittorrent')) - ), - // Transfer charts (when chartMode === 'transfer') - chartMode === 'transfer' && h(React.Fragment, null, - h(DashboardChartWidget, { - title: chartTitle('aMule Data Transferred', 'ed2k'), - height: '225px' - }, renderTransferChart('ed2k')), - h(DashboardChartWidget, { - title: chartTitle('BitTorrent Data Transferred', 'bittorrent'), - height: '225px' - }, renderTransferChart('bittorrent')) - ) - ), - - // SINGLE CLIENT: Show both chart types (no toggle needed) - showSingleClient && h(React.Fragment, null, - h(DashboardChartWidget, { - title: chartTitle(`${singleNetworkName} Speed`, singleNetworkType), - height: '225px' - }, renderSpeedChart(singleNetworkType)), - h(DashboardChartWidget, { - title: chartTitle(`${singleNetworkName} Data Transferred`, singleNetworkType), - height: '225px' - }, renderTransferChart(singleNetworkType)) + // Charts for the visible network types + visibleNetworkInfo.length > 0 && h(React.Fragment, null, + chartMode === 'speed' && renderChartCards('speed'), + chartMode === 'transfer' && renderChartCards('transfer') ) ) ), diff --git a/static/components/views/index.js b/static/components/views/index.js index 9b8fb3b..72f0990 100644 --- a/static/components/views/index.js +++ b/static/components/views/index.js @@ -19,3 +19,4 @@ export { default as SetupWizardView } from './SetupWizardView.js'; export { default as LoginView } from './LoginView.js'; export { default as HistoryView } from './HistoryView.js'; export { default as NotificationsView } from './NotificationsView.js'; +export { default as ChatView } from './ChatView.js'; diff --git a/static/contexts/ActionsContext.js b/static/contexts/ActionsContext.js index 11b49cc..20d6c95 100644 --- a/static/contexts/ActionsContext.js +++ b/static/contexts/ActionsContext.js @@ -159,6 +159,7 @@ const useWebSocketActions = () => { action: 'search', query: searchQuery, type: searchType, + ...(searchType === 'soulseek' ? { provider: 'soulseek' } : {}), extension: null, ...(searchInstanceId && { instanceId: searchInstanceId }) }); diff --git a/static/contexts/ClientFilterContext.js b/static/contexts/ClientFilterContext.js index e7b3f65..559ddc0 100644 --- a/static/contexts/ClientFilterContext.js +++ b/static/contexts/ClientFilterContext.js @@ -10,7 +10,7 @@ * - Individual instance chips toggle a single instance * - When all instances of a network type are disabled, clicking one instance enables only that one * - * Derived convenience booleans (isEd2kEnabled, isBittorrentEnabled) combine: + * Derived convenience booleans (isEd2kEnabled, isBittorrentEnabled, isSoulseekEnabled) combine: * - User preference (not in disabledInstances) * - Connection status (instance.connected) */ @@ -81,6 +81,7 @@ export const ClientFilterProvider = ({ children }) => { // Pure connection status (not affected by user filter preference) const ed2kConnected = isNetworkTypeConnected('ed2k'); const bittorrentConnected = isNetworkTypeConnected('bittorrent'); + const soulseekConnected = isNetworkTypeConnected('soulseek'); // Single source of truth: Set of disabled instance IDs const [disabledInstances, setDisabledInstances] = useState(() => { @@ -143,11 +144,16 @@ export const ClientFilterProvider = ({ children }) => { .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); + const otherTypes = [...new Set(Object.values(instances) + .filter((inst) => inst.connected && inst.networkType !== networkType) + .map((inst) => inst.networkType))]; + const fallbackType = otherTypes[0]; + if (!fallbackType) return prev; + for (const [id, inst] of Object.entries(instances)) { + if (inst.connected && inst.networkType === fallbackType) { + next.delete(id); + } + } } } else { // Enable all of this type @@ -208,6 +214,23 @@ export const ClientFilterProvider = ({ children }) => { ); }, [instances, disabledInstances]); + const isSoulseekEnabled = useMemo(() => { + return Object.entries(instances).some(([id, inst]) => + inst.networkType === 'soulseek' && inst.connected && !disabledInstances.has(id) + ); + }, [instances, disabledInstances]); + + const isNetworkTypeEnabled = useCallback((networkType) => { + return Object.entries(instances).some(([id, inst]) => + inst.networkType === networkType && inst.connected && !disabledInstances.has(id) + ); + }, [instances, disabledInstances]); + + const enabledNetworkTypes = useMemo(() => { + const order = ['ed2k', 'bittorrent', 'soulseek']; + return order.filter(isNetworkTypeEnabled); + }, [isNetworkTypeEnabled]); + // Memoize context value const value = useMemo(() => ({ // Network type batch toggle @@ -222,14 +245,20 @@ export const ClientFilterProvider = ({ children }) => { // Connection state (pure, not affected by filter preference) ed2kConnected, bittorrentConnected, + soulseekConnected, // Convenience booleans: user preference AND connected isEd2kEnabled, isBittorrentEnabled, - allClientsEnabled: isEd2kEnabled && isBittorrentEnabled + isSoulseekEnabled, + isNetworkTypeEnabled, + enabledNetworkTypes, + allClientsEnabled: isEd2kEnabled && isBittorrentEnabled && isSoulseekEnabled }), [toggleNetworkType, filterByEnabledClients, disabledInstances, toggleInstance, isInstanceEnabled, - ed2kConnected, bittorrentConnected, isEd2kEnabled, isBittorrentEnabled]); + ed2kConnected, bittorrentConnected, soulseekConnected, + isEd2kEnabled, isBittorrentEnabled, isSoulseekEnabled, + isNetworkTypeEnabled, enabledNetworkTypes]); return h(ClientFilterContext.Provider, { value }, children); }; diff --git a/static/contexts/DataFetchContext.js b/static/contexts/DataFetchContext.js index b1db7d9..def5461 100644 --- a/static/contexts/DataFetchContext.js +++ b/static/contexts/DataFetchContext.js @@ -110,6 +110,11 @@ export const DataFetchProvider = ({ children }) => { sendMessage({ action: 'getQbittorrentLog', ...(instanceId && { instanceId }) }); }, [sendMessage, resetStaticDataLoaded]); + const fetchSlskdLogs = useCallback((instanceId) => { + resetStaticDataLoaded('slskdLogs'); + sendMessage({ action: 'getSlskdLog', ...(instanceId && { instanceId }) }); + }, [sendMessage, resetStaticDataLoaded]); + const fetchStatsTree = useCallback((instanceId) => { sendMessage({ action: 'getStatsTree', ...(instanceId && { instanceId }) }); }, [sendMessage]); @@ -132,6 +137,7 @@ export const DataFetchProvider = ({ children }) => { fetchServerInfo, fetchAppLogs, fetchQbittorrentLogs, + fetchSlskdLogs, fetchStatsTree, fetchServers, fetchCategories, @@ -140,7 +146,7 @@ export const DataFetchProvider = ({ children }) => { stopHistoryRefresh }), [ fetchPreviousSearchResults, refreshSharedFiles, - fetchLogs, fetchServerInfo, fetchAppLogs, fetchQbittorrentLogs, fetchStatsTree, + fetchLogs, fetchServerInfo, fetchAppLogs, fetchQbittorrentLogs, fetchSlskdLogs, fetchStatsTree, fetchServers, fetchCategories, fetchHistory, startHistoryRefresh, stopHistoryRefresh ]); diff --git a/static/contexts/StaticDataContext.js b/static/contexts/StaticDataContext.js index e7d97cf..704fc5b 100644 --- a/static/contexts/StaticDataContext.js +++ b/static/contexts/StaticDataContext.js @@ -36,6 +36,7 @@ export const StaticDataProvider = ({ children }) => { const [dataAppLogs, setDataAppLogs] = useState([]); const [dataAppLogSources, setDataAppLogSources] = useState([]); const [dataQbittorrentLogs, setDataQbittorrentLogs] = useState(''); + const [dataSlskdLogs, setDataSlskdLogs] = useState(''); const [dataStatsTree, setDataStatsTree] = useState(null); // Map> — tracks which instances have each download const [dataDownloadedFiles, setDataDownloadedFiles] = useState(new Map()); @@ -50,7 +51,8 @@ export const StaticDataProvider = ({ children }) => { logs: false, serverInfo: false, appLogs: false, - qbittorrentLogs: false + qbittorrentLogs: false, + slskdLogs: false }); // Helper to mark a data type as loaded @@ -161,6 +163,7 @@ export const StaticDataProvider = ({ children }) => { dataAppLogs, dataAppLogSources, dataQbittorrentLogs, + dataSlskdLogs, dataStatsTree, dataDownloadedFiles, downloadedAliasRef, @@ -182,6 +185,7 @@ export const StaticDataProvider = ({ children }) => { setDataAppLogs, setDataAppLogSources, setDataQbittorrentLogs, + setDataSlskdLogs, setDataStatsTree, setDataDownloadedFiles, setDataServersEd2kLinks, @@ -191,7 +195,7 @@ export const StaticDataProvider = ({ children }) => { dataServers, dataCategories, clientDefaultPaths, prowlarrEnabled, knownTrackers, historyTrackUsername, hasCategoryPathWarnings, instances, multiInstanceTypes, hasMultiInstance, isTypeConnected, isNetworkTypeConnected, hasType, getCapabilities, hasClientConnectionWarnings, multipleClientsConnected, - dataLogs, dataServerInfo, dataAppLogs, dataQbittorrentLogs, dataStatsTree, dataDownloadedFiles, dataServersEd2kLinks, + dataLogs, dataServerInfo, dataAppLogs, dataQbittorrentLogs, dataSlskdLogs, dataStatsTree, dataDownloadedFiles, dataServersEd2kLinks, dataLoaded, markDataLoaded, resetDataLoaded ]); diff --git a/static/contexts/WebSocketContext.js b/static/contexts/WebSocketContext.js index f9e7c2d..22da295 100644 --- a/static/contexts/WebSocketContext.js +++ b/static/contexts/WebSocketContext.js @@ -74,6 +74,7 @@ export const WebSocketProvider = ({ children }) => { setDataAppLogs, setDataAppLogSources, setDataQbittorrentLogs, + setDataSlskdLogs, setDataStatsTree, setDataServersEd2kLinks, markDataLoaded: markStaticDataLoaded, @@ -387,6 +388,10 @@ export const WebSocketProvider = ({ children }) => { setDataQbittorrentLogs(data.data || ''); markStaticDataLoaded('qbittorrentLogs'); }, + 'slskd-log-update': () => { + setDataSlskdLogs(data.data || ''); + markStaticDataLoaded('slskdLogs'); + }, 'stats-tree-update': () => { setDataStatsTree(data.data); }, @@ -483,7 +488,7 @@ export const WebSocketProvider = ({ children }) => { markLiveDataLoaded, // Static data setters setDataServers, setDataCategories, setClientDefaultPaths, setProwlarrEnabled, - setKnownTrackers, setHistoryTrackUsername, setInstances, setDataLogs, setDataServerInfo, setDataAppLogs, setDataAppLogSources, setDataQbittorrentLogs, + setKnownTrackers, setHistoryTrackUsername, setInstances, setDataLogs, setDataServerInfo, setDataAppLogs, setDataAppLogSources, setDataQbittorrentLogs, setDataSlskdLogs, setDataStatsTree, setDataServersEd2kLinks, markStaticDataLoaded, resetStaticDataLoaded, // Search setters @@ -534,9 +539,10 @@ export const WebSocketProvider = ({ children }) => { }, 2000); }; - wsRef.current.onerror = (error) => { - console.error('WebSocket error:', error); - wsRef.current?.close(); + wsRef.current.onerror = () => { + // Browsers intentionally omit error details for WebSocket failures. + // onclose always fires after onerror and schedules the reconnect. + console.warn('WebSocket connection error — will reconnect'); }; wsRef.current.onmessage = (event) => { diff --git a/static/hooks/index.js b/static/hooks/index.js index 95f9da3..78f3b29 100644 --- a/static/hooks/index.js +++ b/static/hooks/index.js @@ -38,8 +38,10 @@ export { useFileRatingCommentModal } from './useFileRatingCommentModal.js'; export { useNotifications } from './useNotifications.js'; export { useBitTorrentClientSelector } from './useBitTorrentClientSelector.js'; export { useAmuleInstanceSelector } from './useAmuleInstanceSelector.js'; +export { useSearchProviderSelector } from './useSearchProviderSelector.js'; export { useCapabilities } from './useCapabilities.js'; export { useDebouncedValue } from './useDebouncedValue.js'; export { useSettingsFormData } from './useSettingsFormData.js'; export { useClientManagement } from './useClientManagement.js'; +export { useSlskdDirectoryBrowse } from './useSlskdDirectoryBrowse.js'; // Note: useClientFilter is now in contexts/ClientFilterContext.js for global client filtering diff --git a/static/hooks/useClientChartConfig.js b/static/hooks/useClientChartConfig.js index 87efc14..e7ee29e 100644 --- a/static/hooks/useClientChartConfig.js +++ b/static/hooks/useClientChartConfig.js @@ -7,6 +7,7 @@ * Charts display by network type: * - aMule (ED2K/Kademlia) * - BitTorrent (rtorrent + qBittorrent combined) + * - Soulseek (slskd) */ import React from 'https://esm.sh/react@18.2.0'; @@ -15,6 +16,12 @@ import { useLiveData } from '../contexts/LiveDataContext.js'; const { useState, useEffect } = React; +const NETWORK_INFO = { + ed2k: { type: 'ed2k', client: 'ed2k', label: 'aMule' }, + bittorrent: { type: 'bittorrent', client: 'bittorrent', label: 'BitTorrent' }, + soulseek: { type: 'soulseek', client: 'soulseek', label: 'Soulseek' } +}; + /** * Hook that computes chart display configuration based on client connection * state and filter settings @@ -22,29 +29,44 @@ const { useState, useEffect } = React; * @returns {object} Chart configuration object with: * - ed2kConnected: boolean - whether ED2K network client is connected * - bittorrentConnected: boolean - whether any BitTorrent client is connected + * - soulseekConnected: boolean - whether any Soulseek client is connected * - isEd2kEnabled: boolean - whether ED2K network is enabled in filter * - isBittorrentEnabled: boolean - whether BitTorrent is enabled in filter - * - showBothCharts: boolean - show side-by-side charts for both network types + * - isSoulseekEnabled: boolean - whether Soulseek network is enabled in filter + * - visibleNetworkTypes: string[] - enabled network types in display order + * - visibleNetworkInfo: object[] - metadata for the visible network types + * - showBothCharts: boolean - show multi-network charts * - showSingleClient: boolean - show single network type charts (full width) - * - singleNetworkType: 'ed2k' | 'bittorrent' - which network to show when single - * - singleNetworkName: 'aMule' | 'BitTorrent' - display name for single network + * - singleNetworkType: 'ed2k' | 'bittorrent' | 'soulseek' | null - which network to show when single + * - singleNetworkName: 'aMule' | 'BitTorrent' | 'Soulseek' | null - display name for single network * - shouldRenderCharts: boolean - deferred rendering state for performance */ export const useClientChartConfig = () => { - const { isEd2kEnabled, isBittorrentEnabled, ed2kConnected, bittorrentConnected } = useClientFilter(); + const { + isEd2kEnabled, + isBittorrentEnabled, + isSoulseekEnabled, + ed2kConnected, + bittorrentConnected, + soulseekConnected + } = 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'; + const visibleNetworkTypes = ['ed2k', 'bittorrent', 'soulseek'].filter((type) => { + if (type === 'ed2k') return isEd2kEnabled; + if (type === 'bittorrent') return isBittorrentEnabled; + return isSoulseekEnabled; + }); + + const visibleNetworkInfo = visibleNetworkTypes.map(type => NETWORK_INFO[type]).filter(Boolean); + const showBothCharts = visibleNetworkInfo.length > 1; + const showSingleClient = visibleNetworkInfo.length === 1; + const singleNetworkType = showSingleClient ? visibleNetworkInfo[0].type : null; + const singleNetworkName = showSingleClient ? visibleNetworkInfo[0].label : null; // Defer chart rendering until after initial paint for better performance const [shouldRenderCharts, setShouldRenderCharts] = useState(false); @@ -60,8 +82,12 @@ export const useClientChartConfig = () => { isLoading, ed2kConnected, bittorrentConnected, + soulseekConnected, isEd2kEnabled, isBittorrentEnabled, + isSoulseekEnabled, + visibleNetworkTypes, + visibleNetworkInfo, showBothCharts, showSingleClient, singleNetworkType, diff --git a/static/hooks/useClientFilterPageReset.js b/static/hooks/useClientFilterPageReset.js index e13fb2e..b549d6b 100644 --- a/static/hooks/useClientFilterPageReset.js +++ b/static/hooks/useClientFilterPageReset.js @@ -5,7 +5,7 @@ * but skips the initial render to avoid unnecessary reset on mount. * * Usage: - * useClientFilterPageReset(onPageChange, isEd2kEnabled, isBittorrentEnabled, disabledInstances); + * useClientFilterPageReset(onPageChange, isEd2kEnabled, isBittorrentEnabled, isSoulseekEnabled, disabledInstances); */ import React from 'https://esm.sh/react@18.2.0'; @@ -16,9 +16,10 @@ const { useRef, useEffect } = React; * @param {function} onPageChange - Callback to reset page (called with 0) * @param {boolean} isEd2kEnabled - Whether ED2K network type is enabled * @param {boolean} isBittorrentEnabled - Whether BitTorrent network type is enabled + * @param {boolean} isSoulseekEnabled - Whether Soulseek network type is enabled * @param {Set} disabledInstances - Set of disabled instance IDs (new ref on each change) */ -export const useClientFilterPageReset = (onPageChange, isEd2kEnabled, isBittorrentEnabled, disabledInstances) => { +export const useClientFilterPageReset = (onPageChange, isEd2kEnabled, isBittorrentEnabled, isSoulseekEnabled, disabledInstances) => { const isFirstRender = useRef(true); const onPageChangeRef = useRef(onPageChange); onPageChangeRef.current = onPageChange; @@ -29,7 +30,7 @@ export const useClientFilterPageReset = (onPageChange, isEd2kEnabled, isBittorre return; } onPageChangeRef.current(0); - }, [isEd2kEnabled, isBittorrentEnabled, disabledInstances]); + }, [isEd2kEnabled, isBittorrentEnabled, isSoulseekEnabled, disabledInstances]); }; export default useClientFilterPageReset; diff --git a/static/hooks/useClientFilteredData.js b/static/hooks/useClientFilteredData.js index 913d5c7..0cb9e81 100644 --- a/static/hooks/useClientFilteredData.js +++ b/static/hooks/useClientFilteredData.js @@ -17,7 +17,7 @@ import { filterByUnifiedFilter, hasBittorrentItems, hasAmuleItems } from '../uti */ export const useClientFilteredData = ({ data }) => { // Global client filter from context (toggle in header) - const { filterByEnabledClients, isEd2kEnabled, isBittorrentEnabled, disabledInstances } = useClientFilter(); + const { filterByEnabledClients, isEd2kEnabled, isBittorrentEnabled, isSoulseekEnabled, disabledInstances } = useClientFilter(); // Local category/label filter state (view-specific) const [unifiedFilter, setUnifiedFilter] = useState('all'); @@ -58,6 +58,7 @@ export const useClientFilteredData = ({ data }) => { // Client filter state (for conditional rendering and page reset) isEd2kEnabled, isBittorrentEnabled, + isSoulseekEnabled, disabledInstances }; }; diff --git a/static/hooks/useDashboardPrefs.js b/static/hooks/useDashboardPrefs.js new file mode 100644 index 0000000..92a909f --- /dev/null +++ b/static/hooks/useDashboardPrefs.js @@ -0,0 +1,53 @@ +/** + * useDashboardPrefs Hook + * + * Manages dashboard display preferences in localStorage. + * These are client-side preferences — they take effect immediately + * and do NOT go through the server config save flow. + */ + +import React from 'https://esm.sh/react@18.2.0'; + +const { useState } = React; + +const STORAGE_KEY = 'amule-dashboard-prefs'; + +function loadPrefs() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + return JSON.parse(raw); + } catch { + return {}; + } +} + +function savePrefs(prefs) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(prefs)); + } catch { + // Ignore storage errors + } +} + +/** + * Hook for dashboard display preferences. + * + * @returns {{ combinedGraph: boolean, setCombinedGraph: function }} + */ +export const useDashboardPrefs = () => { + const [prefs, setPrefs] = useState(loadPrefs); + + const setCombinedGraph = (val) => { + setPrefs(prev => { + const next = { ...prev, combinedGraph: val }; + savePrefs(next); + return next; + }); + }; + + // Default: true — show combined graph when multiple networks are visible + const combinedGraph = prefs.combinedGraph ?? true; + + return { combinedGraph, setCombinedGraph }; +}; diff --git a/static/hooks/useSearchProviderSelector.js b/static/hooks/useSearchProviderSelector.js new file mode 100644 index 0000000..909b79a --- /dev/null +++ b/static/hooks/useSearchProviderSelector.js @@ -0,0 +1,73 @@ +/** + * useSearchProviderSelector Hook + * + * Generalizes useAmuleInstanceSelector to support any search network type. + * Filters connected instances by networkType derived from searchType: + * - 'soulseek' -> networkType 'soulseek' (slskd instances) + * - everything else -> networkType 'ed2k' (aMule instances) + * + * Shows instance selector only when 2+ instances of that network are connected. + */ + +import { useState, useMemo, useCallback } from 'https://esm.sh/react@18.2.0'; +import { useStaticData } from '../contexts/StaticDataContext.js'; + +/** + * Hook for search provider instance selection. + * @param {Object} [options] + * @param {string} [options.searchType] - Active search type ('global','kad','soulseek',...) + * @param {string} [options.selectedId] - Externally controlled selected ID + * @param {Function} [options.onSelect] - External selection handler + * @returns {Object} Instance selection state and helpers + */ +export function useSearchProviderSelector(options = {}) { + const { instances } = useStaticData(); + const { searchType, selectedId: externalSelectedId, onSelect } = options; + + const networkType = searchType === 'soulseek' ? 'soulseek' : 'ed2k'; + + const connectedInstances = useMemo(() => { + return Object.entries(instances || {}) + .filter(([, inst]) => inst.connected && inst.networkType === networkType) + .map(([id, inst]) => ({ + id, + type: inst.type, + name: inst.name || inst.type, + color: inst.color, + order: inst.order + })) + .sort((a, b) => a.order - b.order); + }, [instances, networkType]); + + const showSelector = connectedInstances.length >= 2; + + const [internalSelectedId, setInternalSelectedId] = useState(null); + + const selectedId = externalSelectedId !== undefined ? externalSelectedId : internalSelectedId; + const setSelectedId = onSelect || setInternalSelectedId; + + const effectiveId = useMemo(() => { + if (selectedId && connectedInstances.some(c => c.id === selectedId)) { + return selectedId; + } + return connectedInstances[0]?.id || null; + }, [selectedId, connectedInstances]); + + const selectedInstance = useMemo(() => { + return connectedInstances.find(c => c.id === effectiveId) || null; + }, [connectedInstances, effectiveId]); + + const selectInstance = useCallback((id) => { + setSelectedId(id); + }, [setSelectedId]); + + return { + connectedInstances, + showSelector, + selectedId: effectiveId, + selectedInstance, + selectInstance + }; +} + +export default useSearchProviderSelector; diff --git a/static/hooks/useSlskdDirectoryBrowse.js b/static/hooks/useSlskdDirectoryBrowse.js new file mode 100644 index 0000000..506af8d --- /dev/null +++ b/static/hooks/useSlskdDirectoryBrowse.js @@ -0,0 +1,94 @@ +/** + * useSlskdDirectoryBrowse Hook + * + * Provides directory expansion for slskd search results. + * Uses the dynamic WS message handler so it doesn't disturb the global + * batch-update / search-results pipeline. + * + * Usage: + * const { expandDirectory, isExpanding, expandedFiles, expandError } = + * useSlskdDirectoryBrowse(instanceId); + * + * expandDirectory(username, directory) — fires a WS action and waits for + * a 'slskd-directory-contents' or 'slskd-directory-error' reply. + * Results are keyed by `${username}|${directory}`. + */ +import React from 'https://esm.sh/react@18.2.0'; +import { useWebSocketConnection } from '../contexts/WebSocketContext.js'; + +const { useCallback, useEffect, useRef, useState } = React; + +function makeKey(username, directory) { + return `${username}|${directory}`; +} + +export function useSlskdDirectoryBrowse(instanceId) { + const { sendMessage, addMessageHandler, removeMessageHandler } = useWebSocketConnection(); + + // Map + const [expandedFiles, setExpandedFiles] = useState({}); + // Set + const [expandingKeys, setExpandingKeys] = useState(new Set()); + // Map + const [expandErrors, setExpandErrors] = useState({}); + + // Stable ref so the WS handler sees latest state without re-subscribing + const pendingRef = useRef(new Map()); // key → requestId + + const handleMessage = useCallback((msg) => { + if (msg.type === 'slskd-directory-contents') { + const key = makeKey(msg.username, msg.directory); + pendingRef.current.delete(key); + setExpandedFiles(prev => ({ ...prev, [key]: msg.files || [] })); + setExpandingKeys(prev => { const s = new Set(prev); s.delete(key); return s; }); + } else if (msg.type === 'slskd-directory-error') { + const key = makeKey(msg.username, msg.directory); + pendingRef.current.delete(key); + setExpandErrors(prev => ({ ...prev, [key]: msg.error || 'Failed to browse directory' })); + setExpandingKeys(prev => { const s = new Set(prev); s.delete(key); return s; }); + } + }, []); + + useEffect(() => { + addMessageHandler(handleMessage); + return () => removeMessageHandler(handleMessage); + }, [addMessageHandler, removeMessageHandler, handleMessage]); + + const expandDirectory = useCallback((username, directory) => { + const key = makeKey(username, directory); + if (expandingKeys.has(key) || key in expandedFiles) return; + + const requestId = `browse-${Date.now()}-${Math.random().toString(36).slice(2)}`; + pendingRef.current.set(key, requestId); + setExpandingKeys(prev => new Set([...prev, key])); + setExpandErrors(prev => { const n = { ...prev }; delete n[key]; return n; }); + + sendMessage({ + action: 'browseSlskdDirectory', + username, + directory, + requestId, + ...(instanceId && { instanceId }) + }); + }, [expandingKeys, expandedFiles, sendMessage, instanceId]); + + const collapseDirectory = useCallback((username, directory) => { + const key = makeKey(username, directory); + setExpandedFiles(prev => { const n = { ...prev }; delete n[key]; return n; }); + setExpandErrors(prev => { const n = { ...prev }; delete n[key]; return n; }); + }, []); + + const isExpanding = useCallback((username, directory) => + expandingKeys.has(makeKey(username, directory)), [expandingKeys]); + + const isExpanded = useCallback((username, directory) => + makeKey(username, directory) in expandedFiles, [expandedFiles]); + + const getFiles = useCallback((username, directory) => + expandedFiles[makeKey(username, directory)] || [], [expandedFiles]); + + const getError = useCallback((username, directory) => + expandErrors[makeKey(username, directory)] || null, [expandErrors]); + + return { expandDirectory, collapseDirectory, isExpanding, isExpanded, getFiles, getError }; +} diff --git a/static/hooks/useViewFilters.js b/static/hooks/useViewFilters.js index a4776e8..eebbee7 100644 --- a/static/hooks/useViewFilters.js +++ b/static/hooks/useViewFilters.js @@ -58,6 +58,7 @@ export const useViewFilters = ({ hasAmule, isEd2kEnabled, isBittorrentEnabled, + isSoulseekEnabled, disabledInstances } = useClientFilteredData({ data }); @@ -169,7 +170,7 @@ export const useViewFilters = ({ sortedDataRef.current = sortedData; // 11. Reset loaded items when client filter changes (header ED2K/BT toggles) - useClientFilterPageReset(resetLoaded, isEd2kEnabled, isBittorrentEnabled, disabledInstances); + useClientFilterPageReset(resetLoaded, isEd2kEnabled, isBittorrentEnabled, isSoulseekEnabled, disabledInstances); // 12. Reset loaded items when status filter changes (only if status filter is enabled) useEffect(() => { @@ -195,6 +196,7 @@ export const useViewFilters = ({ hasAmule, isEd2kEnabled, isBittorrentEnabled, + isSoulseekEnabled, // Tracker filter (array-based multi-select) trackerFilters, diff --git a/static/slskd.png b/static/slskd.png new file mode 100644 index 0000000..945d1df Binary files /dev/null and b/static/slskd.png differ diff --git a/static/slskd.svg b/static/slskd.svg new file mode 100644 index 0000000..93557ba --- /dev/null +++ b/static/slskd.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/static/soulseek.png b/static/soulseek.png new file mode 100644 index 0000000..f0d4374 Binary files /dev/null and b/static/soulseek.png differ diff --git a/static/utils/constants.js b/static/utils/constants.js index 22d783e..210e763 100644 --- a/static/utils/constants.js +++ b/static/utils/constants.js @@ -243,7 +243,8 @@ export const ICON_SIZES = { // Network type display labels export const NETWORK_TYPE_LABELS = { ed2k: 'ED2K', - bittorrent: 'BitTorrent' + bittorrent: 'BitTorrent', + soulseek: 'Soulseek' }; // Client display names (single source of truth for UI labels) @@ -252,7 +253,8 @@ export const CLIENT_NAMES = { rtorrent: { name: 'rTorrent', shortName: 'rTor' }, qbittorrent: { name: 'qBittorrent', shortName: 'qBit' }, deluge: { name: 'Deluge', shortName: 'Dlg' }, - transmission: { name: 'Transmission', shortName: 'Trn' } + transmission: { name: 'Transmission', shortName: 'Trn' }, + slskd: { name: 'slskd', shortName: 'slskd' } }; // Client software types (for uploads view) diff --git a/static/utils/downloadHelpers.js b/static/utils/downloadHelpers.js index 182ac50..03b2f96 100644 --- a/static/utils/downloadHelpers.js +++ b/static/utils/downloadHelpers.js @@ -178,6 +178,15 @@ export const isBittorrentClient = (item) => { return item.networkType === 'bittorrent'; }; +/** + * Check if item is from a Soulseek client + * @param {Object} item - Download/shared item + * @returns {boolean} True if Soulseek client + */ +export const isSoulseekClient = (item) => { + return item.networkType === 'soulseek'; +}; + /** * Format source count display with detailed breakdown * Handles both aMule and BitTorrent formats via unified sources object @@ -327,6 +336,9 @@ export const getExportLink = (item) => { if (isBittorrentClient(item)) { return generateMagnetLink(item); } + if (isSoulseekClient(item)) { + return null; + } // ED2K: use unified ed2kLink field return item.ed2kLink || null; }; @@ -337,7 +349,9 @@ export const getExportLink = (item) => { * @returns {string} Label for the export link */ export const getExportLinkLabel = (item) => { - return isBittorrentClient(item) ? 'Magnet Link' : 'ED2K Link'; + if (isBittorrentClient(item)) return 'Magnet Link'; + if (isSoulseekClient(item)) return 'Link'; + return 'ED2K Link'; }; /** diff --git a/static/utils/viewHelpers.js b/static/utils/viewHelpers.js index 17853f3..bc89737 100644 --- a/static/utils/viewHelpers.js +++ b/static/utils/viewHelpers.js @@ -18,7 +18,8 @@ import { StatisticsView, SettingsView, HistoryView, - NotificationsView + NotificationsView, + ChatView } from '../components/views/index.js'; /** @@ -37,5 +38,6 @@ export const VIEW_COMPONENTS = { 'logs': LogsView, 'statistics': StatisticsView, 'notifications': NotificationsView, - 'settings': SettingsView + 'settings': SettingsView, + 'chat': ChatView };