Skip to content

Slskd integration#54

Open
paulo-roger wants to merge 2 commits into
got3nks:mainfrom
paulo-roger:slskd
Open

Slskd integration#54
paulo-roger wants to merge 2 commits into
got3nks:mainfrom
paulo-roger:slskd

Conversation

@paulo-roger

@paulo-roger paulo-roger commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Full Soulseek integration via slskd:

Backend

  • SlskdClient.js: new HTTP client for slskd's /api/v0/* endpoints; supports X-API-Key header auth (recommended) and JWT fallback via username/password; auto-retries on 401; robust nested-object search-result extraction to handle slskd's variable response shapes.
  • slskdManager.js: new SlskdManager extending BaseClientManager: connection lifecycle with 30s reconnect back-off, download/upload polling, search, shared-file tracking, stats aggregation, and onConnectSync to register the download directory with CategoryManager.
  • slskdChatAPI.js: REST endpoints for private message conversations (proxy to slskd; 404 → empty conversation).
  • slskdRoomsAPI.js: REST endpoints for joined chat rooms, normalising each room's message list.
  • clientMeta.js: added slskd client-type metadata entry.
  • downloadNormalizer.js: added normalizeSlskdDownload and normalizeSlskdSharedFile.

Frontend

  • ChatView.js: new view for Soulseek private DMs + rooms (only visible when an slskd instance is connected).
  • StaticDataContext.js / WebSocketContext.js: dataSlskdLogs state + slskd-log-update WS event handler.
  • SetupWizardView.js: slskd section added to the setup wizard (host, port, path, API key, SSL toggle); validated on submit.
  • SearchView.js: slskd appears as a selectable search provider via useSearchProviderSelector.

Some changes to better accommodate a new network type: Soulseek

These changes collectively remove hardcoded assumptions about exactly two network types (eD2K and BitTorrent) across the front‑end codebase. The original implementation duplicated logic, props, and markup for each network, making the addition of a third network (Soulseek) brittle and labour‑intensive.

The refactoring introduces a generic, data‑driven approach:

  • Network Configuration – A shared NETWORK_CONFIGS array defines all supported network types, their labels, icons, colors, and behaviours. UI elements that previously hardcoded “ED2K” and “BT” now iterate over this array.
  • Component Generality – Components like StatsWidget, Header, StatisticsView, and table columns have been rewritten to accept dynamic lists of active networks rather than fixed pairs of booleans or separate props. Helper functions (getStatValue, renderNetworkToggleButton, renderInstanceGroup, ClientBreakdownValue, etc.) use the generic configuration to render the correct set of controls and data for however many networks are enabled.
  • Extensibility – Adding a new network (e.g., Soulseek) now requires only an entry in NETWORK_CONFIGS and, where relevant, the corresponding API data. No further JSX or component logic changes are needed. The same pattern will accommodate future network types with zero duplication.

The immediate motivation is to integrate Soulseek support


Sorry if I am missing something, you can ask anything. I am without sleeping for too many hours already, took me a lot of time, really!
I really appreciate all you did, thanks!

@got3nks

got3nks commented Jun 2, 2026

Copy link
Copy Markdown
Owner

Thank you for your work @paulo-roger, I will review as soon as possible.

@got3nks

got3nks commented Jun 5, 2026

Copy link
Copy Markdown
Owner

@paulo-roger — first of all, thank you. This is a serious piece of work, and the architecture genuinely fits the data-driven seams the codebase was designed around (clientMeta, networkType, BaseClientManager, ACTION_CAPABILITIES). Landing a third network type via a clientMeta entry without touching the metrics DB, unified item builder, or chart components is exactly the design intent paying off — really nicely done.

I'd like to merge this once a few items are addressed. Splitting them by severity:

🚨 Blocking — security

1. Chat/room mutation routes are gated on the read-only search capability. All POST/DELETE chat & room endpoints currently share the same requireCapability('search') gate as the read endpoints:

  • server/modules/slskdChatAPI.jsPOST /api/slskd/conversations/:username (send DM), DELETE /api/slskd/conversations/:username (wipe DMs)
  • server/modules/slskdRoomsAPI.jsPOST /api/slskd/rooms (join), DELETE /api/slskd/rooms/:roomName (leave), POST /api/slskd/rooms/:roomName/messages (post)

Any logged-in user with search — including auto-provisioned SSO users (SSO_DEFAULT_CAPABILITIES) — can send messages as the slskd account owner and wipe conversations. Compare with prowlarrAPI which splits search (read) and add_downloads (mutation) for the same reason. Please introduce a new capability like manage_chat (added to ALL_CAPABILITIES in userManager.js) and gate the mutation routes on it. Reads can stay on search.

2. No per-user isolation on DMs. There's one slskd account per instance, and the API doesn't consult download_ownership or any user-scoped store — so every logged-in user (with search) can read every other web user's private messages through the shared slskd identity. I'd suggest making the DM read endpoints (GET /api/slskd/conversations, GET /api/slskd/conversations/:username) admin-only for now (requireAdmin). We can revisit multi-user chat semantics in a follow-up if you want.

🛠 Should fix — architecture conventions

3. Logger usage in the two new API classes. Both SlskdChatAPI and SlskdRoomsAPI extend BaseModule, which binds this.log/info/warn/error/debug and auto-tags the source. Right now every error path uses raw logger.error('[SlskdChatAPI] …') with a manual prefix:

  • server/modules/slskdChatAPI.js: lines 74, 115, 140, 161, 181
  • server/modules/slskdRoomsAPI.js: lines 63, 82, 130, 150, 175

Drop the bracket prefix and call this.error(err) instead — matches what qbittorrentAPI.js does (e.g. lines 145, 268). Mechanical change, ~10 lines total.

4. NETWORK_CONFIGS / NETWORK_INFO / NETWORK_ORDER is duplicated across three files, each hardcoding ['ed2k','bittorrent','soulseek']:

  • static/components/layout/Header.js:19-23
  • static/components/dashboard/StatsWidget.js:18
  • static/hooks/useClientChartConfig.js:19-23

The data-driven goal of the refactor is half-done — adding a 4th network later means edits in three places. Could you consolidate into a single static/utils/networkConfigs.js and have the three callers import from it?

5. StatsWidget.js:132-163 spells out statsByNetwork and networkStats per network type. Switching to a NETWORK_ORDER.reduce(...) (or .map(...)) over the shared config would eliminate the boilerplate and make future-proofing free.

📌 Nits — non-blocking, your call

  • server/modules/slskdManager.js:587-597setCategoryOrLabel() is dead code; BaseClientManager already provides the no-op default. Safe to remove.
  • server/modules/slskdManager.js:422_seenEventIds clears the entire Set when it exceeds 1000, which risks re-emitting old events right after the clear. A small LRU (Map with insertion-order eviction) would be more robust.
  • response.serverError(res, err.message) in the chat/rooms APIs forwards raw slskd error messages to the browser. Auth is required so impact is limited, but redacting (or logging full + sending a generic message) is a defense-in-depth improvement.

✅ What you got right (so you know what NOT to change)

  • server/lib/clientMeta.js:301-356 is exemplary — the full capability matrix is declared, 'soulseek' lands as a first-class networkType, and metrics DB / unified item builder / frontend filter / chart components all pick it up with zero file edits outside the new files. This is the abstraction paying off.
  • Complete manager interface: extractHistoryMetadata, extractMetrics, getStats, getNetworkStatus, pause, resume, deleteItem all present.
  • ACTION_CAPABILITIES entries getSlskdLog: ['view_logs'] and browseSlskdDirectory: ['search'] correctly added and gated through _hasCapability.
  • SoulseekTorznabHandler wired through the same userManager.getUserByApiKey admin-only middleware as the aMule indexer — no auth drift.
  • ClientFilterContext localStorage migration extended correctly (v1→v2→v3 with soulseek added) — existing user preferences survive the upgrade.
  • ChatView.js (806 lines) uses no dangerouslySetInnerHTML / innerHTML — all message rendering goes through Preact's automatic HTML escaping. No XSS surface.
  • Credentials (apiKey, password) properly flagged sensitive: true in CLIENT_ENV_FIELDS so they're masked by maskSensitiveFields() before any config-read response. Verified no leak path.

Suggested order

  1. Items 1 + 2 (security) — these are the only true blockers.
  2. Items 3 (logger) and 4 + 5 (network config consolidation) before merge — small mechanical changes.
  3. Nits at your discretion in this PR or a follow-up.

Happy to clarify any of this. Once 1–5 are in I'm ready to merge. Thanks again for the heavy lift on this!

@got3nks

got3nks commented Jun 5, 2026

Copy link
Copy Markdown
Owner

Two issues from manual testing — both have concrete fixes from existing patterns in the codebase.

1. Add-client modal: auth dropdown

Right now static/components/settings/ClientInstanceModal.js:65-74 shows API key, username, AND password fields all at once for slskd, with "(Optional)" / "(Recommended)" labels trying to clarify. That's confusing — users have to know slskd's auth precedence to fill the right pair.

The rtorrent entry at ClientInstanceModal.js:32-40 already has the pattern you want: a select: true field acts as a mode toggle, and the other fields use hideWhen: form => (form.mode || 'http') !== 'http' to show/hide based on the selection. Mirror that for slskd:

slskd: [
  { field: 'host', label: 'Host', ..., required: true },
  { field: 'port', label: 'Port', ..., required: true, parseValue: v => parseInt(v, 10) || 5030 },
  { field: 'path', label: 'URL Path (Optional)', ... },
  { field: 'authMode', label: 'Authentication', select: true,
    options: [
      { value: 'apiKey', label: 'API Key (Recommended)' },
      { value: 'userpass', label: 'Username & Password' }
    ],
    defaultValue: 'apiKey' },
  { field: 'apiKey', label: 'API Key', ..., required: true, sensitive: true,
    hideWhen: form => (form.authMode || 'apiKey') !== 'apiKey' },
  { field: 'username', label: 'Username', ..., required: true,
    hideWhen: form => (form.authMode || 'apiKey') !== 'userpass' },
  { field: 'password', label: 'Password', ..., required: true, sensitive: true,
    hideWhen: form => (form.authMode || 'apiKey') !== 'userpass' },
  { field: 'useSsl', ..., toggle: true },
  { field: 'downloadDirectory', ... }
]

The authMode field doesn't need to be persisted in _clientConfig (the backend already picks API key vs credentials based on which fields are populated), but persisting it makes the form re-open with the correct mode preselected.

2. Auto-reconnect not working + stale connection handler persists after disable/re-enable

Two interleaved bugs here:

2a. SlskdManager.cleanup() doesn't clear the reconnect interval (server/modules/slskdManager.js:630-636). When the user disables the client, configAPI calls mgr.cleanup() → which disconnects the client but leaves this.reconnectInterval running. The interval keeps firing every 30 s, calling initClient() repeatedly — that's the "old connection handler still showing up" symptom.

Fix: add one line to cleanup():

async cleanup() {
  this.clearReconnect();           // ← add this
  if (this.client) {
    await this.client.disconnect();
    this.client = null;
  }
  this.releaseSearchLock();
}

Compare to qbittorrentManager.js:845-872, which calls this.clearReconnect() first thing in its shutdown().

2b. initClient() doesn't actually verify the connection. This is why auto-reconnect doesn't recover when slskd comes back. BaseClientManager.scheduleReconnect() (line 286-305) drives reconnects by calling this.initClient() on each retry. But SlskdManager.initClient() (lines 29-54) only constructs the SlskdClient object — it doesn't call testConnection(), doesn't _clearConnectionError(), and doesn't clearReconnect() on success. So even when slskd comes back, the retry loop never realizes it.

Compare to qbittorrentManager.js:25 where initClient() does the full handshake: builds the client, calls testConnection(), on success calls _clearConnectionError() + clearReconnect() + the _onConnectCallbacks, and on failure leaves this.client = null and lets the existing retry interval fire again.

Refactor suggestion: move the connection-test + success-handling logic from startConnection() (lines 56-89) into initClient(), and have startConnection() just call initClient() and schedule a reconnect if it returns false. Roughly:

async initClient() {
  if (!this._clientConfig?.enabled) {
    this.log('slskd integration is disabled');
    return false;
  }
  if (!this._clientConfig.host) {
    this.log('slskd host not configured');
    return false;
  }

  const newClient = new SlskdClient({ /* ...config */ });

  try {
    const test = await newClient.testConnection();
    if (!test.success) throw new Error(test.error || 'Failed to connect');

    this.client = newClient;
    this._downloadDirectory = this._clientConfig.downloadDirectory || '';
    this._clearConnectionError();
    this.clearReconnect();
    this.log('Connected to slskd successfully');
    this._onConnectCallbacks.forEach(cb => cb());
    return true;
  } catch (err) {
    this.error('Failed to connect to slskd:', logger.errorDetail(err));
    this._setConnectionError(err);
    await newClient.disconnect().catch(() => {});
    this.client = null;
    return false;
  }
}

async startConnection() {
  const connected = await this.initClient();
  if (!connected && this._clientConfig?.enabled) {
    this.scheduleReconnect(30000);
  }
}

With this shape, the retry interval from BaseClientManager.scheduleReconnect() will keep calling initClient() every 30 s; once slskd is back the test succeeds and the loop self-terminates via clearReconnect(). Same lifecycle as qbittorrent/deluge.

getDownloads() (slskdManager.js:459-466) and the other call sites that currently call scheduleReconnect(30000) on failure can keep doing so — scheduleReconnect is idempotent (no-ops if already scheduled), so duplicate calls are harmless.

These two changes together should make slskd behave the same as qbittorrent for both the "remote crashed mid-session" and "user toggled in settings" flows.

@paulo-roger

Copy link
Copy Markdown
Contributor Author

Thank you very much for all the analysis and feedback, I will do as you say and correct everything. I still have a lot to learn so I really appreciate your feedback!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants