Full Code of aeyoll/soulseek-cli for AI

main 6ac7ab0bf5b4 cached
25 files
28.1 KB
7.8k tokens
9 symbols
1 requests
Download .txt
Repository: aeyoll/soulseek-cli
Branch: main
Commit: 6ac7ab0bf5b4
Files: 25
Total size: 28.1 KB

Directory structure:
gitextract_vj1rdvvc/

├── .editorconfig
├── .eslintrc.json
├── .github/
│   └── workflows/
│       ├── node.js.yml
│       └── stale.yml
├── .gitignore
├── .npmignore
├── .travis.yml
├── CONTRIBUTING.md
├── LICENCE
├── README.md
├── cli.js
├── mise.toml
├── package.json
├── scripts/
│   └── bump.js
└── src/
    ├── commands/
    │   ├── download.js
    │   ├── login.js
    │   └── query.js
    ├── modules/
    │   ├── DestinationDirectory.js
    │   ├── Download.js
    │   ├── DownloadLogger.js
    │   ├── FilterResult.js
    │   └── Search.js
    └── services/
        ├── CredentialsService.js
        ├── DownloadService.js
        └── SearchService.js

================================================
FILE CONTENTS
================================================

================================================
FILE: .editorconfig
================================================
# EditorConfig is awesome: http://EditorConfig.org

# top-most EditorConfig file
root = true

# Unix-style newlines with a newline ending every file
[*]
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 2


================================================
FILE: .eslintrc.json
================================================
{
  "env": {
    "node": true,
    "amd": true,
    "commonjs": true,
    "es2021": true
  },
  "extends": [
    "eslint:recommended"
  ],
  "parserOptions": {
    "ecmaVersion": 2020,
    "sourceType": "module"
  },
  "rules": {
  }
}


================================================
FILE: .github/workflows/node.js.yml
================================================
# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node
# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions

name: Node.js CI

on:
  push:
    branches: [ develop ]
  pull_request:
    branches: [ develop ]

jobs:
  build:

    runs-on: ubuntu-latest

    strategy:
      matrix:
        node-version: [16.x]
        # See supported Node.js release schedule at https://nodejs.org/en/about/releases/

    steps:
    - uses: actions/checkout@v2
    - name: Use Node.js ${{ matrix.node-version }}
      uses: actions/setup-node@v1
      with:
        node-version: ${{ matrix.node-version }}
    - run: npm ci
    - run: npm run build --if-present
    - run: npm test


================================================
FILE: .github/workflows/stale.yml
================================================
name: 'Close stale issues and PRs'
on:
  schedule:
    - cron: '30 1 * * *'

jobs:
  stale:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/stale@v4.1.1
        with:
          stale-issue-message: 'This issue is stale because it has been open 30 days with no activity. Remove stale label or comment or this will be closed in 5 days.'
          days-before-stale: 30
          days-before-close: 5


================================================
FILE: .gitignore
================================================
node_modules


================================================
FILE: .npmignore
================================================
.idea
.vscode
.github


================================================
FILE: .travis.yml
================================================
language: node_js

sudo: required

dist: trusty

node_js:
  - lts/*

env:
  - CC=clang CXX=clang++ npm_config_clang=1

addons:
  apt:
    packages:
      - libsecret-1-dev

before_install:
  - npm i -g prettier


================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to soulseek-cli

Every contribution is welcomed! If you have an idea for improvements or new features, please submit a PR and I'll be happy to review it.

Please make sure you got [editorconfig](https://editorconfig.org/) installed for your editor, so we have the same indentation. Also, the source code is formatted using [prettier](https://github.com/prettier/prettier):

```
prettier --single-quote --trailing-comma=es5 --print-width=120 --write src/*/*.js
```

How to develop?
---

First, fork the repository and clone it to a local folder.

Then, install the dependencies:

```
npm install
```

To launch the cli in development mode:

```
node ./cli.js search ...
```


================================================
FILE: LICENCE
================================================
Copyright 2019 Jean-Philippe Bidegain and contributors

Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.


================================================
FILE: README.md
================================================
# Soulseek CLI

[![Build Status](https://travis-ci.org/aeyoll/soulseek-cli.svg?branch=develop)](https://travis-ci.org/aeyoll/soulseek-cli)

A Soulseek Cli client.

Requirements
---

NodeJS >= 24

Installation
---

```sh
npm install -g soulseek-cli
```

### On Linux

One of soulseek-cli dependencies ([node-keytar](https://github.com/atom/node-keytar)) uses libsecret, so you need to install it before running `npm install`.

Depending on your distribution, you will need to run the following command:

- Debian/Ubuntu: `sudo apt-get install libsecret-1-dev`
- Red Hat-based: `sudo yum install libsecret-devel`
- Arch Linux: `sudo pacman -S libsecret`

### On Headless Linux

On Linux, [node-keytar](https://github.com/atom/node-keytar) uses the Linux SecretService API. It is possible to use the SecretService backend on Linux systems without X11 server available (only D-Bus is required). In this case, you can do the following (exemple is on a Debian environment):

#### Install dependencies

```sh
apt install gnome-keyring --no-install-recommends # Install the GNOME Keyring daemon. "no-install-recommends" prevents X11 install
```

#### Usage

```sh
dbus-run-session -- $SHELL # Start a D-Bus session
echo 'root' | /usr/bin/gnome-keyring-daemon -r -d --unlock # Unlock GNOME Keyring
soulseek ... # Use soulseek-cli normally
```

Commands
---

### Login

Before being able to search, you need to be logged in.

Usage:
```
soulseek login
```

You will be prompted your Soulseek login and password. Credentials are stored and encrypted in your system keychain.

Alternatively, you can also login by setting environment variables:

```sh
export SOULSEEK_ACCOUNT=youraccount
export SOULSEEK_PASSWORD=yourpassword
soulseek download "..."
```

### Download

Download with required query.

Usage:
```
soulseek download|d [options] [query...]
```

:warning: This command used to be called `search` in versions prior to 0.1.0.

Options:

| Option                    | Description                                                                   |
| ------------------------- | ----------------------------------------------------------------------------- |
| -d --destination <folder> | downloads's destination                                                       |
| -q --quality <quality>    | show only mp3 with a defined quality                                          |
| -m --mode <mode>          | filter the kind of files you want (available: "mp3", "flac", default: "mp3")  |
| -h --help                 | display help for command                                                      |

Examples:

```sh
soulseek download "Your query" # Download in the current folder
soulseek download "Your query" --destination=/path/to/directory # Download in a defined folder (relative or absolute)
soulseek download "Your query" --quality=320 # Filter by quality
soulseek download "Your query" --mode=flac # Filter by file type
```

### Query

Search with required query, but don't download anything. If a result is found, the return code will be 0. Otherwise,
the return code will be 1 (useful for scripting)

Usage:

```
soulseek query|q [options] [query...]
```

Options:

| Option                 | Description                                                                  |
| ---------------------- | ---------------------------------------------------------------------------- |
| -q --quality <quality> | show only mp3 with a defined quality                                         |
| -m --mode <mode>       | filter the kind of files you want (available: "mp3", "flac", default: "mp3") |
| -h --help              | display help for command                                                     |



Contribution
---

See [CONTRIBUTING.md](CONTRIBUTING.md).


================================================
FILE: cli.js
================================================
#!/usr/bin/env node

const VERSION = '0.4.0';
import { Command } from 'commander';
import DownloadCommand from './src/commands/download.js';
import QueryCommand from './src/commands/query.js';
import LoginCommand from './src/commands/login.js';

const program = new Command();
program.version(VERSION);

program
  .command('download [query...]')
  .description('Download with required query')
  .option('-d, --destination <folder>', 'downloads\'s destination')
  .option('-q, --quality <quality>', 'show only mp3 with a defined quality')
  .option('-m, --mode <mode>', 'filter the kind of files you want (available: "mp3", "flac", default: "mp3")', 'mp3')
  .alias('d')
  .action((queries, options) => {
    new DownloadCommand(queries, options);
  });

program
  .command('query [query...]')
  .description('Search with required query, but don\'t download anything')
  .option('-q, --quality <quality>', 'show only mp3 with a defined quality')
  .option('-m, --mode <mode>', 'filter the kind of files you want (available: "mp3", "flac", default: "mp3")', 'mp3')
  .alias('q')
  .action((queries, options) => {
    new QueryCommand(queries, options);
  });

program
  .command('login')
  .alias('l')
  .action(() => {
    new LoginCommand();
  });

program.parse(process.argv);


================================================
FILE: mise.toml
================================================
[tools]
node = "24"


================================================
FILE: package.json
================================================
{
  "name": "soulseek-cli",
  "description": "A Soulseek Cli client.",
  "repository": {
    "type": "git",
    "url": "git+https://github.com/aeyoll/soulseek-cli.git"
  },
  "engines": {
    "node": ">=24"
  },
  "type": "module",
  "version": "0.4.0",
  "main": "cli.js",
  "dependencies": {
    "chalk": "^5.4.0",
    "commander": "^12.1.0",
    "fs": "0.0.2",
    "inquirer": "^12.3.0",
    "keytar": "^7.9.0",
    "lodash": "^4.17.21",
    "path": "^0.12.7",
    "slsk-client": "^1.1.0"
  },
  "devDependencies": {
    "editorconfig-checker": "6.0.0",
    "eslint": "^8.56.0",
    "eslint-config-standard": "^17.1.0",
    "eslint-plugin-import": "^2.29.1",
    "eslint-plugin-node": "^11.1.0",
    "eslint-plugin-promise": "^6.1.1",
    "prettier": "^3.4.2"
  },
  "scripts": {
    "bump": "node scripts/bump.js",
    "lint:editorconfig": "editorconfig-checker",
    "lint:eslint": "eslint src/** cli.js",
    "lint:prettier": "prettier --single-quote --trailing-comma=es5 --print-width=120 --check src/*/*.js",
    "test": "npm run lint:editorconfig && npm run lint:eslint && npm run lint:prettier"
  },
  "bin": {
    "soulseek": "cli.js"
  },
  "author": "Jean-Philippe Bidegain",
  "license": "MIT",
  "volta": {
    "node": "20.18.1"
  }
}


================================================
FILE: scripts/bump.js
================================================
#!/usr/bin/env node

import { execSync } from 'child_process';
import { readFileSync, writeFileSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';

const __dirname = dirname(fileURLToPath(import.meta.url));
const root = resolve(__dirname, '..');

const releaseType = process.argv[2];

if (!['major', 'minor', 'patch'].includes(releaseType)) {
  console.error('Usage: npm run bump -- <major|minor|patch>');
  process.exit(1);
}

execSync(`npm version ${releaseType} --no-git-tag-version`, { cwd: root, stdio: 'inherit' });

const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8'));
const newVersion = pkg.version;

const cliPath = resolve(root, 'cli.js');
const cli = readFileSync(cliPath, 'utf8');
const updated = cli.replace(/^const VERSION = '.*';$/m, `const VERSION = '${newVersion}';`);
writeFileSync(cliPath, updated);

execSync(`git add package.json package-lock.json cli.js`, { cwd: root, stdio: 'inherit' });
execSync(`git commit -m "chore: bump version to ${newVersion}"`, { cwd: root, stdio: 'inherit' });
execSync(`git tag ${newVersion}`, { cwd: root, stdio: 'inherit' });

console.log(`Bumped to ${newVersion}`);


================================================
FILE: src/commands/download.js
================================================
import chalk from 'chalk';
import Search from '../modules/Search.js';
import SearchService from '../services/SearchService.js';
import DownloadService from '../services/DownloadService.js';
import CredentialsService from '../services/CredentialsService.js';
const log = console.log;

// Available file modes
const modes = ['mp3', 'flac'];

class DownloadCommand {
  constructor(queries, options) {
    if (queries.length === 0) {
      log(chalk.red('Please add a search query'));
      process.exit(1);
    }

    if (!modes.includes(options.mode)) {
      log(chalk.red(`--mode is invalid. Valid values: ${modes.join(', ')})`));
      process.exit(1);
    }

    if (options.mode === 'flac' && options.quality) {
      log(chalk.red('--quality is incompatible with the "flac" mode. Please remove this option.'));
      process.exit(1);
    }

    this.options = options;
    this.searchService = new SearchService(queries);
    this.downloadService = new DownloadService(this.searchService);
    this.search = null;

    this.credentialsService = new CredentialsService();
    this.credentialsService.connect(this.onConnected.bind(this));
  }

  /**
   * @param {SlskClient} client
   */
  onConnected(client) {
    this.search = new Search(this.searchService, this.downloadService, this.options, client);
    this.search.search();
  }
}

export default DownloadCommand;


================================================
FILE: src/commands/login.js
================================================
import inquirer from 'inquirer';
import CredentialsService from '../services/CredentialsService.js';

class LoginCommand {
  constructor() {
    this.credentialsService = new CredentialsService();
    this.askCredentials();
  }

  /**
   * Ask for credentials
   */
  askCredentials() {
    const loginQuestion = {
      type: 'input',
      name: 'login',
      message: 'Login',
    };
    const pwdQuestion = {
      type: 'password',
      name: 'pwd',
      message: 'Password',
    };
    inquirer.prompt(loginQuestion).then((loginAnswer) => {
      inquirer.prompt(pwdQuestion).then((pwdAnswer) => {
        this.credentialsService.storeCredentials(loginAnswer.login, pwdAnswer.pwd);
      });
    });
  }
}

export default LoginCommand;


================================================
FILE: src/commands/query.js
================================================
import DownloadCommand from './download.js';
import Search from '../modules/Search.js';

class QueryCommand extends DownloadCommand {
  /**
   * @param {SlskClient} client
   */
  onConnected(client) {
    const queryOptions = {
      showPrompt: false,
    };
    this.search = new Search(this.searchService, this.downloadService, { ...this.options, ...queryOptions }, client);
    this.search.search();
  }
}

export default QueryCommand;


================================================
FILE: src/modules/DestinationDirectory.js
================================================
import path from 'path';
import fs from 'fs';
import process from 'process';

export default function (destination) {
  this.destination = destination;

  /**
   * Compute the final destination repository, depending on the "destination" option
   * @param  {string} directory
   * @return {string}
   */
  this.getDestinationDirectory = (directory) => {
    let dir;

    if (this.destination) {
      if (path.isAbsolute(this.destination)) {
        dir = this.destination + path.sep + directory;
      } else {
        dir = process.cwd() + path.sep + this.destination + path.sep + directory;
      }
    } else {
      dir = process.cwd() + path.sep + directory;
    }

    createIfNotExist(dir);

    return dir;
  };
}

/**
 * Create a directory if it doesn't exist
 * @param {string} dir
 */
let createIfNotExist = (dir) => {
  const dirList = dir.split(path.sep);
  let buildPath = '';

  for (let i = 0; i < dirList.length; i++) {
    buildPath += dirList[i] + path.sep;

    if (!fs.existsSync(buildPath)) {
      fs.mkdirSync(buildPath);
    }
  }
};


================================================
FILE: src/modules/Download.js
================================================
import fs from 'fs';
import process from 'process';
import chalk from 'chalk';
import DestinationDirectory from './DestinationDirectory.js';
const log = console.log;

export default function (downloadService, searchService, options, client) {
  this.destinationDirectory = new DestinationDirectory(options.destination);
  this.downloadService = downloadService;
  this.searchService = searchService;
  this.client = client;

  /**
   * Call prepare download method,
   * then launch the download of each files in the list
   *
   * @param {array} files
   */
  this.startDownloads = (files) => {
    this.downloadService.prepareDownload(files);
    files.forEach((file) => this.downloadFile(file));
  };

  /**
   * Download a single file from the selected answer
   *
   * @param file
   */
  this.downloadFile = (file) => {
    const fileStructure = file.file.split('\\');
    const directory = fileStructure[fileStructure.length - 2];
    const filename = fileStructure[fileStructure.length - 1];

    const data = {
      file,
      path: this.destinationDirectory.getDestinationDirectory(directory) + '/' + filename,
    };

    if (this.checkFileExists(data.path, filename)) {
      return;
    }

    log(filename + chalk.yellow(' [downloading...]'));

    this.client.download(data, (err, down) => {
      if (err) {
        log(chalk.red(err));
        process.exit();
      }

      this.downloadService.downloadComplete(down.path);
    });
  };

  this.checkFileExists = (path, filename) => {
    let fileExists = false;

    if (fs.existsSync(path)) {
      log(filename + chalk.green(' [already downloaded: skipping]'));
      this.downloadService.decrementFileCount();

      if (this.searchService.allSearchesCompleted() && this.downloadService.getFileCount() === 0) {
        log('No file to download.');
        process.exit();
      }

      fileExists = true;
    }

    return fileExists;
  };
}


================================================
FILE: src/modules/DownloadLogger.js
================================================
import chalk from 'chalk';
const log = console.log;

export default function (searchService, downloadService) {
  this.searchService = searchService;
  this.downloadService = downloadService;
  this.logBuffer = '';
  this.fileIndex = 0;

  /**
   * Display a line in the terminal showing the number of the downloaded file, the total number of file to download and the path to the downloaded file.
   * @param  {string} path Path of the downloaded file
   */
  this.downloadComplete = (path) => {
    this.fileIndex++;
    let logInfo = '(' + this.fileIndex + '/{{totalFileCount}}) Received: ' + path;

    if (this.searchService.allSearchesCompleted()) {
      logInfo = logInfo.replace(/{{totalFileCount}}/g, this.downloadService.getFileCount());
      log(logInfo);
    } else {
      this.logBuffer += logInfo + '\n';
    }
  };

  /**
   * Write in the terminal every lines stored in the buffer, then reset it to empty string.
   */
  this.flush = () => {
    if (this.logBuffer.length > 0) {
      this.logBuffer = this.logBuffer.replace(/{{totalFileCount}}/g, this.downloadService.getFileCount()).slice(0, -1);
      log(this.logBuffer);
      this.logBuffer = '';
    }
  };

  /**
   * Write a line summing the number of file starting to download.
   * @param  {number} fileCount Number of files
   */
  this.startDownload = (fileCount) => {
    log(chalk.green('Starting download of ' + fileCount + ' file' + (fileCount > 1 ? 's' : '') + '...'));
  };
}


================================================
FILE: src/modules/FilterResult.js
================================================
import path from 'path';
import _ from 'lodash';
const pluralize = (noun, count, suffix = 's') => `${count} ${noun}${count !== 1 ? suffix : ''}`;

export default function (qualityFilter, mode) {
  this.qualityFilter = qualityFilter;
  this.mode = mode;

  /**
   * From the query results, only get mp3 with free slots.
   * The fastest results are going to be first.
   *
   * @param res
   * @return {array}
   */
  this.filter = (res) => {
    res = filterByFreeSlot(res);

    if (this.mode === 'mp3') {
      res = keepOnlyMp3(res);
    }

    if (this.mode === 'flac') {
      res = keepOnlyFlac(res);
    }

    if (this.qualityFilter) {
      res = filterByQuality(this.qualityFilter, res);
    }

    res = sortBySpeed(res);
    return getFilesByUser(res);
  };
}

/**
 * Discard all results without free slots
 * @param {array} res
 * @returns {array}
 */
let filterByFreeSlot = (res) => res.filter((r) => r.slots === true && r.speed > 0);

/**
 * Remove everything that is not a mp3
 * @param {array} res
 * @returns {array}
 */
let keepOnlyMp3 = (res) => res.filter((r) => path.extname(r.file) === '.mp3');

/**
 * Remove everything that is not a flac
 * @param {array} res
 * @returns {array}
 */
let keepOnlyFlac = (res) => res.filter((r) => path.extname(r.file) === '.flac');

/**
 * If a quality filter is defined, keep only the folders with the defined bitrate
 * @param {string} qualityFilter
 * @param {array} res
 * @returns {array}
 */
let filterByQuality = (qualityFilter, res) => res.filter((r) => r.bitrate === parseInt(qualityFilter, 10));

/**
 * Display the fastest results first
 * @param {array} res
 */
let sortBySpeed = (res) => res.sort((a, b) => b.speed - a.speed);

/**
 * Compute the average bitrate of a folder
 * @param {array} files
 * @returns {Number}
 */
let getAverageBitrate = (files) => {
  let averageBitrate = 0;

  if (files.length > 0) {
    const sum = files.reduce((a, b) => a + b.bitrate, 0);
    averageBitrate = Math.round(sum / files.length);
  }

  return averageBitrate;
};

/**
 * Compute the size of a folder in megabytes
 * @param {array} files
 * @returns {Number}
 */
let getFolderSize = (files) => {
  let size = 0;

  if (files.length > 0) {
    size = Math.round(files.reduce((a, b) => a + b.size, 0) / 1024 / 1024);
  }

  return size;
};

/**
 * Get the speed of the remote peer
 * @param {array} files
 * @returns {Number}
 */
let getSpeed = (files) => {
  let speed = 0;

  if (files.length > 0) {
    speed = Math.round(files[0].speed / 1024);
  }

  return speed;
};

/**
 * Build the result list
 * @param {array} res
 * @returns {object}
 */
let getFilesByUser = (res) => {
  let filesByUser = {};

  const rawFilesByUser = _.groupBy(res, (r) => {
    const resFileStructure = r.file.split('\\');
    const resDirectory = resFileStructure[resFileStructure.length - 2];
    return resDirectory + ' - ' + r.user;
  });

  for (const prop in rawFilesByUser) {
    let extraInfo = [];

    // Number of files
    extraInfo.push(`${pluralize('file', rawFilesByUser[prop].length)}`);

    // Bitrate
    const bitrate = getAverageBitrate(rawFilesByUser[prop]);
    if (bitrate) {
      extraInfo.push(`bitrate: ${bitrate}kbps`);
    }

    // Size
    extraInfo.push(`size: ${getFolderSize(rawFilesByUser[prop])}mb`);

    // Speed
    extraInfo.push(`speed: ~${getSpeed(rawFilesByUser[prop])}kb/s`);

    filesByUser[`${prop} (${extraInfo.join(', ')})`] = rawFilesByUser[prop];
  }

  return filesByUser;
};


================================================
FILE: src/modules/Search.js
================================================
import inquirer from 'inquirer';
import _ from 'lodash';
import chalk from 'chalk';
import FilterResult from './FilterResult.js';
import Download from './Download.js';
const log = console.log;

export default function (searchService, downloadService, options, client) {
  this.download = new Download(downloadService, searchService, options, client);
  this.filterResult = new FilterResult(options.quality, options.mode);
  this.searchService = searchService;
  this.downloadService = downloadService;
  this.client = client;
  this.timeout = options.timeout ?? 2000;
  this.showPrompt = options.showPrompt ?? true;

  /**
   * Launch search query, then call a callback
   */
  this.search = () => {
    const query = this.searchService.getNextQuery();
    log(chalk.green("Searching for '%s'"), query);
    const searchParam = {
      req: query,
      timeout: this.timeout,
    };
    const afterSearch = (err, res) => this.onSearchFinished(err, res);
    this.client.search(searchParam, afterSearch);
  };

  /**
   * Callback called when the search query get back
   */
  this.onSearchFinished = (err, res) => {
    if (err) {
      return log(chalk.red(err));
    }

    const filesByUser = this.filterResult.filter(res);
    this.checkEmptyResult(filesByUser);

    if (this.showPrompt) {
      this.showResults(filesByUser);
    } else {
      this.showTopResult(filesByUser);
      process.exit(0);
    }
  };

  /**
   * If the result set is empty and there is no pending searches quit the process.
   * If there is pending searches, launch the next search.
   * If the result set is not empty just log success message.
   */
  this.checkEmptyResult = (filesByUser) => {
    if (_.isEmpty(filesByUser)) {
      log(chalk.red('Nothing found'));
      this.searchService.consumeQuery();

      if (this.searchService.allSearchesCompleted()) {
        process.exit(1);
      }

      this.search();
    } else {
      log(chalk.green('Search finished'));
    }
  };

  /**
   * Display the top result
   *
   * @param {array} filesByUser
   */
  this.showTopResult = (filesByUser) => {
    const numResults = Object.keys(filesByUser).length;

    if (numResults > 0) {
      const topResult = String(_.keys(filesByUser)[0]);
      log(chalk.green('Search returned ' + numResults + ' results'));
      log(chalk.blue('Top result: %s'), topResult);
    }
  };

  /**
   * Display a list of choices that the user can choose from.
   *
   * @param {array} filesByUser
   */
  this.showResults = (filesByUser) => {
    const numResults = Object.keys(filesByUser).length;

    log(chalk.green('Displaying ' + numResults + ' search results'));

    const options = {
      type: 'rawlist',
      name: 'user',
      pageSize: 10,
      message: 'Choose a folder to download',
      choices: _.keys(filesByUser),
    };
    inquirer.prompt([options]).then((answers) => this.processChosenAnswers(answers, filesByUser));
  };

  /**
   * From the user answer, trigger the download of the folder
   * If there is pending search, launch the next search query
   *
   * @param {array} answers
   * @param filesByUser
   */
  this.processChosenAnswers = (answers, filesByUser) => {
    this.searchService.consumeQuery();
    this.download.startDownloads(filesByUser[answers.user]);

    if (this.searchService.allSearchesCompleted()) {
      this.downloadService.downloadLogger.flush();
      this.downloadService.everyDownloadCompleted();
    } else {
      this.search();
    }
  };
}


================================================
FILE: src/services/CredentialsService.js
================================================
import slsk from 'slsk-client';
import keytar from 'keytar';
import chalk from 'chalk';
const err = console.error;
const log = console.log;

export default function () {
  this.serviceName = 'soulseek-cli';

  /**
   * Store credential in the OS keychain
   *
   * @param {string} login
   * @param {string} pwd
   */
  this.storeCredentials = (login, pwd) => {
    keytar.findCredentials(this.serviceName).then((oldCredentials) => {
      if (oldCredentials.length === 0) {
        keytar.setPassword(this.serviceName, login, pwd);
        return;
      }

      keytar.deletePassword(this.serviceName, oldCredentials[0].account).then(() => {
        keytar.setPassword(this.serviceName, login, pwd);
      });
    });
  };

  /**
   * Fetch credential from OS keychain
   *
   * @return  {Promise<{account: string; password: string;}>}
   */
  this.getCredentials = () => {
    return new Promise((resolve) => {
      const account = process.env.SOULSEEK_ACCOUNT;
      const password = process.env.SOULSEEK_PASSWORD;

      if (account !== undefined && password !== undefined) {
        resolve({
          account,
          password,
        });
      } else {
        keytar.findCredentials(this.serviceName).then((credentials) => {
          if (credentials.length === 0) {
            err(chalk.red('No credential found for soulseek-cli, please login.'));
            process.exit();
          }

          resolve(credentials[0]);
        });
      }
    });
  };

  /**
   * Connect to the Soulseek client
   */
  this.connect = (callback) => {
    log(chalk.green('Connecting to soulseek'));
    this.getCredentials().then((credentials) => {
      slsk.connect(
        {
          user: credentials.account,
          pass: credentials.password,
        },
        (err, client) => {
          if (err) {
            return log(chalk.red(err));
          }

          log(chalk.green('Connected to soulseek'));
          callback(client);
        }
      );
    });
  };
}


================================================
FILE: src/services/DownloadService.js
================================================
import DownloadLogger from '../modules/DownloadLogger.js';
const log = console.log;

export default function (searchService) {
  this.searchService = searchService;
  this.downloadLogger = new DownloadLogger(searchService, this);
  this.downloadingFilesCount = 0;
  this.downloadCompleteCount = 0;

  this.prepareDownload = (files) => {
    this.downloadLogger.startDownload(files.length);
    this.downloadingFilesCount += files.length;
  };

  this.downloadComplete = (downloadPath) => {
    this.downloadLogger.downloadComplete(downloadPath);
    this.downloadCompleteCount++;
    this.everyDownloadCompleted();
  };

  this.everyDownloadCompleted = () => {
    if (this.downloadCompleteCount === this.downloadingFilesCount && this.searchService.allSearchesCompleted()) {
      log(this.downloadingFilesCount + ' file' + (this.downloadingFilesCount > 1 ? 's' : '') + ' downloaded.');
      process.exit();
    }
  };

  this.decrementFileCount = () => {
    this.downloadingFilesCount--;
  };

  this.getFileCount = () => {
    return this.downloadingFilesCount;
  };
}


================================================
FILE: src/services/SearchService.js
================================================
export default function (queries) {
  this.queries = queries;

  /**
   * Return the next query to process
   *
   * @return {Object}
   */
  this.getNextQuery = () => {
    return this.queries[0];
  };

  /**
   * Return true if there is no pending searches
   *
   * @return {boolean}
   */
  this.allSearchesCompleted = () => {
    return this.queries.length === 0;
  };

  /**
   * Remove the first query of the query list
   */
  this.consumeQuery = () => {
    return this.queries.splice(0, 1);
  };
}
Download .txt
gitextract_vj1rdvvc/

├── .editorconfig
├── .eslintrc.json
├── .github/
│   └── workflows/
│       ├── node.js.yml
│       └── stale.yml
├── .gitignore
├── .npmignore
├── .travis.yml
├── CONTRIBUTING.md
├── LICENCE
├── README.md
├── cli.js
├── mise.toml
├── package.json
├── scripts/
│   └── bump.js
└── src/
    ├── commands/
    │   ├── download.js
    │   ├── login.js
    │   └── query.js
    ├── modules/
    │   ├── DestinationDirectory.js
    │   ├── Download.js
    │   ├── DownloadLogger.js
    │   ├── FilterResult.js
    │   └── Search.js
    └── services/
        ├── CredentialsService.js
        ├── DownloadService.js
        └── SearchService.js
Download .txt
SYMBOL INDEX (9 symbols across 4 files)

FILE: cli.js
  constant VERSION (line 3) | const VERSION = '0.4.0';

FILE: src/commands/download.js
  class DownloadCommand (line 11) | class DownloadCommand {
    method constructor (line 12) | constructor(queries, options) {
    method onConnected (line 40) | onConnected(client) {

FILE: src/commands/login.js
  class LoginCommand (line 4) | class LoginCommand {
    method constructor (line 5) | constructor() {
    method askCredentials (line 13) | askCredentials() {

FILE: src/commands/query.js
  class QueryCommand (line 4) | class QueryCommand extends DownloadCommand {
    method onConnected (line 8) | onConnected(client) {
Condensed preview — 25 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (31K chars).
[
  {
    "path": ".editorconfig",
    "chars": 235,
    "preview": "# EditorConfig is awesome: http://EditorConfig.org\n\n# top-most EditorConfig file\nroot = true\n\n# Unix-style newlines with"
  },
  {
    "path": ".eslintrc.json",
    "chars": 236,
    "preview": "{\n  \"env\": {\n    \"node\": true,\n    \"amd\": true,\n    \"commonjs\": true,\n    \"es2021\": true\n  },\n  \"extends\": [\n    \"eslint"
  },
  {
    "path": ".github/workflows/node.js.yml",
    "chars": 812,
    "preview": "# This workflow will do a clean install of node dependencies, build the source code and run tests across different versi"
  },
  {
    "path": ".github/workflows/stale.yml",
    "chars": 410,
    "preview": "name: 'Close stale issues and PRs'\non:\n  schedule:\n    - cron: '30 1 * * *'\n\njobs:\n  stale:\n    runs-on: ubuntu-latest\n "
  },
  {
    "path": ".gitignore",
    "chars": 13,
    "preview": "node_modules\n"
  },
  {
    "path": ".npmignore",
    "chars": 22,
    "preview": ".idea\n.vscode\n.github\n"
  },
  {
    "path": ".travis.yml",
    "chars": 211,
    "preview": "language: node_js\n\nsudo: required\n\ndist: trusty\n\nnode_js:\n  - lts/*\n\nenv:\n  - CC=clang CXX=clang++ npm_config_clang=1\n\na"
  },
  {
    "path": "CONTRIBUTING.md",
    "chars": 688,
    "preview": "# Contributing to soulseek-cli\n\nEvery contribution is welcomed! If you have an idea for improvements or new features, pl"
  },
  {
    "path": "LICENCE",
    "chars": 1079,
    "preview": "Copyright 2019 Jean-Philippe Bidegain and contributors\n\nPermission is hereby granted, free of charge, to any person obta"
  },
  {
    "path": "README.md",
    "chars": 3763,
    "preview": "# Soulseek CLI\n\n[![Build Status](https://travis-ci.org/aeyoll/soulseek-cli.svg?branch=develop)](https://travis-ci.org/ae"
  },
  {
    "path": "cli.js",
    "chars": 1278,
    "preview": "#!/usr/bin/env node\n\nconst VERSION = '0.4.0';\nimport { Command } from 'commander';\nimport DownloadCommand from './src/co"
  },
  {
    "path": "mise.toml",
    "chars": 20,
    "preview": "[tools]\nnode = \"24\"\n"
  },
  {
    "path": "package.json",
    "chars": 1250,
    "preview": "{\n  \"name\": \"soulseek-cli\",\n  \"description\": \"A Soulseek Cli client.\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \""
  },
  {
    "path": "scripts/bump.js",
    "chars": 1188,
    "preview": "#!/usr/bin/env node\n\nimport { execSync } from 'child_process';\nimport { readFileSync, writeFileSync } from 'fs';\nimport "
  },
  {
    "path": "src/commands/download.js",
    "chars": 1373,
    "preview": "import chalk from 'chalk';\nimport Search from '../modules/Search.js';\nimport SearchService from '../services/SearchServi"
  },
  {
    "path": "src/commands/login.js",
    "chars": 745,
    "preview": "import inquirer from 'inquirer';\nimport CredentialsService from '../services/CredentialsService.js';\n\nclass LoginCommand"
  },
  {
    "path": "src/commands/query.js",
    "chars": 441,
    "preview": "import DownloadCommand from './download.js';\nimport Search from '../modules/Search.js';\n\nclass QueryCommand extends Down"
  },
  {
    "path": "src/modules/DestinationDirectory.js",
    "chars": 1061,
    "preview": "import path from 'path';\nimport fs from 'fs';\nimport process from 'process';\n\nexport default function (destination) {\n  "
  },
  {
    "path": "src/modules/Download.js",
    "chars": 1917,
    "preview": "import fs from 'fs';\nimport process from 'process';\nimport chalk from 'chalk';\nimport DestinationDirectory from './Desti"
  },
  {
    "path": "src/modules/DownloadLogger.js",
    "chars": 1463,
    "preview": "import chalk from 'chalk';\nconst log = console.log;\n\nexport default function (searchService, downloadService) {\n  this.s"
  },
  {
    "path": "src/modules/FilterResult.js",
    "chars": 3474,
    "preview": "import path from 'path';\nimport _ from 'lodash';\nconst pluralize = (noun, count, suffix = 's') => `${count} ${noun}${cou"
  },
  {
    "path": "src/modules/Search.js",
    "chars": 3480,
    "preview": "import inquirer from 'inquirer';\nimport _ from 'lodash';\nimport chalk from 'chalk';\nimport FilterResult from './FilterRe"
  },
  {
    "path": "src/services/CredentialsService.js",
    "chars": 1985,
    "preview": "import slsk from 'slsk-client';\nimport keytar from 'keytar';\nimport chalk from 'chalk';\nconst err = console.error;\nconst"
  },
  {
    "path": "src/services/DownloadService.js",
    "chars": 1073,
    "preview": "import DownloadLogger from '../modules/DownloadLogger.js';\nconst log = console.log;\n\nexport default function (searchServ"
  },
  {
    "path": "src/services/SearchService.js",
    "chars": 508,
    "preview": "export default function (queries) {\n  this.queries = queries;\n\n  /**\n   * Return the next query to process\n   *\n   * @re"
  }
]

About this extraction

This page contains the full source code of the aeyoll/soulseek-cli GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 25 files (28.1 KB), approximately 7.8k tokens, and a symbol index with 9 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!