diff --git a/.env.example b/.env.example
index d60e5d0..0a66641 100644
--- a/.env.example
+++ b/.env.example
@@ -47,6 +47,14 @@ PORT=4000
# AMULE_SHARED_FILES_RELOAD_INTERVAL_HOURS=3
# AMULE_SHARED_DIR_DAT=/home/amule/.aMule/shareddir.dat
+# slskd Configuration (Optional)
+# SLSKD_ENABLED=true
+# SLSKD_HOST=
+# SLSKD_PORT=5030
+# SLSKD_PATH=
+# SLSKD_API_KEY=
+# SLSKD_USE_SSL=false
+
# rTorrent Configuration (Optional)
# Connect to rTorrent for BitTorrent downloads via XML-RPC or SCGI
# At least one download client (aMule, rTorrent, or qBittorrent) must be enabled
diff --git a/README.md b/README.md
index 9c93c0d..0729da2 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
aMuTorrent
-A unified download manager for aMule, rTorrent, qBittorrent, Deluge, and Transmission. Manage ED2K and BitTorrent downloads from a single modern web interface. Features multi-instance support, user management with SSO, Prowlarr integration for torrent search, Torznab indexer and qBittorrent-compatible API for aMule (Sonarr/Radarr integration), push notifications via Apprise, and GeoIP peer location display. Built with Node.js, WebSockets, and React.
+A unified download manager for aMule, rTorrent, qBittorrent, Deluge, Transmission, and slskd. Manage ED2K, BitTorrent, and Soulseek downloads from a single modern web interface. Features multi-instance support, user management with SSO, Prowlarr integration for torrent search, Torznab indexer and qBittorrent-compatible API for aMule (*arr integration), push notifications via Apprise, and GeoIP peer location display. Built with Node.js, WebSockets, and React.

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