From 8c595650049f1947fc41a4ee025355957730acda Mon Sep 17 00:00:00 2001 From: Paulo Roger Date: Mon, 1 Jun 2026 17:40:43 -0300 Subject: [PATCH 1/2] added slskd integration --- README.md | 3 +- docs/CONFIGURATION.md | 27 +- server/lib/clientMeta.js | 70 +- server/lib/configTester.js | 152 +++- server/lib/downloadNormalizer.js | 152 ++++ server/lib/linkConverter.js | 65 +- server/lib/networkUtils.js | 2 +- server/lib/qbittorrent/QBittorrentHandler.js | 44 +- server/lib/slskd/SlskdClient.js | 376 ++++++++ server/lib/torznab/SoulseekTorznabHandler.js | 176 ++++ server/lib/torznab/TorznabHandler.js | 22 +- server/lib/torznab/search.js | 87 +- server/lib/unifiedItemBuilder.js | 37 +- server/modules/arrManager.js | 52 +- server/modules/config.js | 18 +- server/modules/configAPI.js | 34 +- server/modules/qbittorrentAPI.js | 14 +- server/modules/slskdChatAPI.js | 227 +++++ server/modules/slskdManager.js | 643 ++++++++++++++ server/modules/slskdRoomsAPI.js | 195 +++++ server/modules/torznabAPI.js | 43 +- server/modules/webSocketHandlers.js | 224 ++++- server/server.js | 5 + src/input.css | 23 + static/components/common/ClientIcon.js | 8 + static/components/common/Icon.js | 4 + static/components/common/SearchResultsList.js | 129 ++- .../components/common/SearchResultsSection.js | 206 ++++- .../dashboard/DashboardChartWidget.js | 6 +- .../components/dashboard/MobileSpeedWidget.js | 71 +- .../components/dashboard/QuickSearchWidget.js | 31 +- static/components/dashboard/StatsWidget.js | 242 +++--- static/components/layout/Footer.js | 29 +- static/components/layout/Header.js | 162 ++-- static/components/layout/MobileNavFooter.js | 2 + static/components/layout/Sidebar.js | 2 + .../settings/ClientInstanceModal.js | 16 +- static/components/views/ChatView.js | 806 ++++++++++++++++++ static/components/views/HomeView.js | 273 +++--- static/components/views/LogsView.js | 13 +- static/components/views/SearchResultsView.js | 6 +- static/components/views/SearchView.js | 24 +- static/components/views/SettingsView.js | 25 +- static/components/views/SetupWizardView.js | 127 ++- static/components/views/SharedView.js | 22 +- static/components/views/StatisticsView.js | 61 +- static/components/views/index.js | 1 + static/contexts/ActionsContext.js | 1 + static/contexts/ClientFilterContext.js | 45 +- static/contexts/DataFetchContext.js | 8 +- static/contexts/StaticDataContext.js | 8 +- static/contexts/WebSocketContext.js | 14 +- static/hooks/index.js | 2 + static/hooks/useClientChartConfig.js | 48 +- static/hooks/useClientFilterPageReset.js | 7 +- static/hooks/useClientFilteredData.js | 3 +- static/hooks/useDashboardPrefs.js | 53 ++ static/hooks/useSearchProviderSelector.js | 73 ++ static/hooks/useSlskdDirectoryBrowse.js | 94 ++ static/hooks/useViewFilters.js | 4 +- static/slskd.png | Bin 0 -> 24744 bytes static/slskd.svg | 1 + static/soulseek.png | Bin 0 -> 65437 bytes static/utils/constants.js | 6 +- static/utils/downloadHelpers.js | 16 +- static/utils/viewHelpers.js | 6 +- 66 files changed, 4705 insertions(+), 641 deletions(-) create mode 100644 server/lib/slskd/SlskdClient.js create mode 100644 server/lib/torznab/SoulseekTorznabHandler.js create mode 100644 server/modules/slskdChatAPI.js create mode 100644 server/modules/slskdManager.js create mode 100644 server/modules/slskdRoomsAPI.js create mode 100644 static/components/views/ChatView.js create mode 100644 static/hooks/useDashboardPrefs.js create mode 100644 static/hooks/useSearchProviderSelector.js create mode 100644 static/hooks/useSlskdDirectoryBrowse.js create mode 100644 static/slskd.png create mode 100644 static/slskd.svg create mode 100644 static/soulseek.png 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 0000000000000000000000000000000000000000..945d1df755b47d7dee5c972c842e230fa74eae6d GIT binary patch literal 24744 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 U{TEt{0TCv{Yo>6Vu6^YH0K_`|L;wH) literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..f0d4374eb5f9a9f3d30c7468cf698550207fd797 GIT binary patch literal 65437 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

3Hcw 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 { 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 }; From 23ec4398c8ae495171c97b40c71373ca0f98ddba Mon Sep 17 00:00:00 2001 From: Paulo Roger Date: Mon, 1 Jun 2026 17:44:48 -0300 Subject: [PATCH 2/2] env example --- .env.example | 8 ++++++++ 1 file changed, 8 insertions(+) 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