[
  {
    "path": ".editorconfig",
    "content": "# EditorConfig is awesome: http://EditorConfig.org\n\n# top-most EditorConfig file\nroot = true\n\n# Unix-style newlines with a newline ending every file\n[*]\nend_of_line = lf\ninsert_final_newline = true\nindent_style = space\nindent_size = 2\n"
  },
  {
    "path": ".eslintrc.json",
    "content": "{\n  \"env\": {\n    \"node\": true,\n    \"amd\": true,\n    \"commonjs\": true,\n    \"es2021\": true\n  },\n  \"extends\": [\n    \"eslint:recommended\"\n  ],\n  \"parserOptions\": {\n    \"ecmaVersion\": 2020,\n    \"sourceType\": \"module\"\n  },\n  \"rules\": {\n  }\n}\n"
  },
  {
    "path": ".github/workflows/node.js.yml",
    "content": "# This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node\n# For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions\n\nname: Node.js CI\n\non:\n  push:\n    branches: [ develop ]\n  pull_request:\n    branches: [ develop ]\n\njobs:\n  build:\n\n    runs-on: ubuntu-latest\n\n    strategy:\n      matrix:\n        node-version: [16.x]\n        # See supported Node.js release schedule at https://nodejs.org/en/about/releases/\n\n    steps:\n    - uses: actions/checkout@v2\n    - name: Use Node.js ${{ matrix.node-version }}\n      uses: actions/setup-node@v1\n      with:\n        node-version: ${{ matrix.node-version }}\n    - run: npm ci\n    - run: npm run build --if-present\n    - run: npm test\n"
  },
  {
    "path": ".github/workflows/stale.yml",
    "content": "name: 'Close stale issues and PRs'\non:\n  schedule:\n    - cron: '30 1 * * *'\n\njobs:\n  stale:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/stale@v4.1.1\n        with:\n          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.'\n          days-before-stale: 30\n          days-before-close: 5\n"
  },
  {
    "path": ".gitignore",
    "content": "node_modules\n"
  },
  {
    "path": ".npmignore",
    "content": ".idea\n.vscode\n.github\n"
  },
  {
    "path": ".travis.yml",
    "content": "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\naddons:\n  apt:\n    packages:\n      - libsecret-1-dev\n\nbefore_install:\n  - npm i -g prettier\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing to soulseek-cli\n\nEvery 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.\n\nPlease 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):\n\n```\nprettier --single-quote --trailing-comma=es5 --print-width=120 --write src/*/*.js\n```\n\nHow to develop?\n---\n\nFirst, fork the repository and clone it to a local folder.\n\nThen, install the dependencies:\n\n```\nnpm install\n```\n\nTo launch the cli in development mode:\n\n```\nnode ./cli.js search ...\n```\n"
  },
  {
    "path": "LICENCE",
    "content": "Copyright 2019 Jean-Philippe Bidegain and contributors\n\nPermission 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:\n\nThe above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.\n\nTHE 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.\n"
  },
  {
    "path": "README.md",
    "content": "# Soulseek CLI\n\n[![Build Status](https://travis-ci.org/aeyoll/soulseek-cli.svg?branch=develop)](https://travis-ci.org/aeyoll/soulseek-cli)\n\nA Soulseek Cli client.\n\nRequirements\n---\n\nNodeJS >= 24\n\nInstallation\n---\n\n```sh\nnpm install -g soulseek-cli\n```\n\n### On Linux\n\nOne of soulseek-cli dependencies ([node-keytar](https://github.com/atom/node-keytar)) uses libsecret, so you need to install it before running `npm install`.\n\nDepending on your distribution, you will need to run the following command:\n\n- Debian/Ubuntu: `sudo apt-get install libsecret-1-dev`\n- Red Hat-based: `sudo yum install libsecret-devel`\n- Arch Linux: `sudo pacman -S libsecret`\n\n### On Headless Linux\n\nOn 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):\n\n#### Install dependencies\n\n```sh\napt install gnome-keyring --no-install-recommends # Install the GNOME Keyring daemon. \"no-install-recommends\" prevents X11 install\n```\n\n#### Usage\n\n```sh\ndbus-run-session -- $SHELL # Start a D-Bus session\necho 'root' | /usr/bin/gnome-keyring-daemon -r -d --unlock # Unlock GNOME Keyring\nsoulseek ... # Use soulseek-cli normally\n```\n\nCommands\n---\n\n### Login\n\nBefore being able to search, you need to be logged in.\n\nUsage:\n```\nsoulseek login\n```\n\nYou will be prompted your Soulseek login and password. Credentials are stored and encrypted in your system keychain.\n\nAlternatively, you can also login by setting environment variables:\n\n```sh\nexport SOULSEEK_ACCOUNT=youraccount\nexport SOULSEEK_PASSWORD=yourpassword\nsoulseek download \"...\"\n```\n\n### Download\n\nDownload with required query.\n\nUsage:\n```\nsoulseek download|d [options] [query...]\n```\n\n:warning: This command used to be called `search` in versions prior to 0.1.0.\n\nOptions:\n\n| Option                    | Description                                                                   |\n| ------------------------- | ----------------------------------------------------------------------------- |\n| -d --destination <folder> | downloads's destination                                                       |\n| -q --quality <quality>    | show only mp3 with a defined quality                                          |\n| -m --mode <mode>          | filter the kind of files you want (available: \"mp3\", \"flac\", default: \"mp3\")  |\n| -h --help                 | display help for command                                                      |\n\nExamples:\n\n```sh\nsoulseek download \"Your query\" # Download in the current folder\nsoulseek download \"Your query\" --destination=/path/to/directory # Download in a defined folder (relative or absolute)\nsoulseek download \"Your query\" --quality=320 # Filter by quality\nsoulseek download \"Your query\" --mode=flac # Filter by file type\n```\n\n### Query\n\nSearch with required query, but don't download anything. If a result is found, the return code will be 0. Otherwise,\nthe return code will be 1 (useful for scripting)\n\nUsage:\n\n```\nsoulseek query|q [options] [query...]\n```\n\nOptions:\n\n| Option                 | Description                                                                  |\n| ---------------------- | ---------------------------------------------------------------------------- |\n| -q --quality <quality> | show only mp3 with a defined quality                                         |\n| -m --mode <mode>       | filter the kind of files you want (available: \"mp3\", \"flac\", default: \"mp3\") |\n| -h --help              | display help for command                                                     |\n\n\n\nContribution\n---\n\nSee [CONTRIBUTING.md](CONTRIBUTING.md).\n"
  },
  {
    "path": "cli.js",
    "content": "#!/usr/bin/env node\n\nconst VERSION = '0.4.0';\nimport { Command } from 'commander';\nimport DownloadCommand from './src/commands/download.js';\nimport QueryCommand from './src/commands/query.js';\nimport LoginCommand from './src/commands/login.js';\n\nconst program = new Command();\nprogram.version(VERSION);\n\nprogram\n  .command('download [query...]')\n  .description('Download with required query')\n  .option('-d, --destination <folder>', 'downloads\\'s destination')\n  .option('-q, --quality <quality>', 'show only mp3 with a defined quality')\n  .option('-m, --mode <mode>', 'filter the kind of files you want (available: \"mp3\", \"flac\", default: \"mp3\")', 'mp3')\n  .alias('d')\n  .action((queries, options) => {\n    new DownloadCommand(queries, options);\n  });\n\nprogram\n  .command('query [query...]')\n  .description('Search with required query, but don\\'t download anything')\n  .option('-q, --quality <quality>', 'show only mp3 with a defined quality')\n  .option('-m, --mode <mode>', 'filter the kind of files you want (available: \"mp3\", \"flac\", default: \"mp3\")', 'mp3')\n  .alias('q')\n  .action((queries, options) => {\n    new QueryCommand(queries, options);\n  });\n\nprogram\n  .command('login')\n  .alias('l')\n  .action(() => {\n    new LoginCommand();\n  });\n\nprogram.parse(process.argv);\n"
  },
  {
    "path": "mise.toml",
    "content": "[tools]\nnode = \"24\"\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"soulseek-cli\",\n  \"description\": \"A Soulseek Cli client.\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/aeyoll/soulseek-cli.git\"\n  },\n  \"engines\": {\n    \"node\": \">=24\"\n  },\n  \"type\": \"module\",\n  \"version\": \"0.4.0\",\n  \"main\": \"cli.js\",\n  \"dependencies\": {\n    \"chalk\": \"^5.4.0\",\n    \"commander\": \"^12.1.0\",\n    \"fs\": \"0.0.2\",\n    \"inquirer\": \"^12.3.0\",\n    \"keytar\": \"^7.9.0\",\n    \"lodash\": \"^4.17.21\",\n    \"path\": \"^0.12.7\",\n    \"slsk-client\": \"^1.1.0\"\n  },\n  \"devDependencies\": {\n    \"editorconfig-checker\": \"6.0.0\",\n    \"eslint\": \"^8.56.0\",\n    \"eslint-config-standard\": \"^17.1.0\",\n    \"eslint-plugin-import\": \"^2.29.1\",\n    \"eslint-plugin-node\": \"^11.1.0\",\n    \"eslint-plugin-promise\": \"^6.1.1\",\n    \"prettier\": \"^3.4.2\"\n  },\n  \"scripts\": {\n    \"bump\": \"node scripts/bump.js\",\n    \"lint:editorconfig\": \"editorconfig-checker\",\n    \"lint:eslint\": \"eslint src/** cli.js\",\n    \"lint:prettier\": \"prettier --single-quote --trailing-comma=es5 --print-width=120 --check src/*/*.js\",\n    \"test\": \"npm run lint:editorconfig && npm run lint:eslint && npm run lint:prettier\"\n  },\n  \"bin\": {\n    \"soulseek\": \"cli.js\"\n  },\n  \"author\": \"Jean-Philippe Bidegain\",\n  \"license\": \"MIT\",\n  \"volta\": {\n    \"node\": \"20.18.1\"\n  }\n}\n"
  },
  {
    "path": "scripts/bump.js",
    "content": "#!/usr/bin/env node\n\nimport { execSync } from 'child_process';\nimport { readFileSync, writeFileSync } from 'fs';\nimport { resolve, dirname } from 'path';\nimport { fileURLToPath } from 'url';\n\nconst __dirname = dirname(fileURLToPath(import.meta.url));\nconst root = resolve(__dirname, '..');\n\nconst releaseType = process.argv[2];\n\nif (!['major', 'minor', 'patch'].includes(releaseType)) {\n  console.error('Usage: npm run bump -- <major|minor|patch>');\n  process.exit(1);\n}\n\nexecSync(`npm version ${releaseType} --no-git-tag-version`, { cwd: root, stdio: 'inherit' });\n\nconst pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8'));\nconst newVersion = pkg.version;\n\nconst cliPath = resolve(root, 'cli.js');\nconst cli = readFileSync(cliPath, 'utf8');\nconst updated = cli.replace(/^const VERSION = '.*';$/m, `const VERSION = '${newVersion}';`);\nwriteFileSync(cliPath, updated);\n\nexecSync(`git add package.json package-lock.json cli.js`, { cwd: root, stdio: 'inherit' });\nexecSync(`git commit -m \"chore: bump version to ${newVersion}\"`, { cwd: root, stdio: 'inherit' });\nexecSync(`git tag ${newVersion}`, { cwd: root, stdio: 'inherit' });\n\nconsole.log(`Bumped to ${newVersion}`);\n"
  },
  {
    "path": "src/commands/download.js",
    "content": "import chalk from 'chalk';\nimport Search from '../modules/Search.js';\nimport SearchService from '../services/SearchService.js';\nimport DownloadService from '../services/DownloadService.js';\nimport CredentialsService from '../services/CredentialsService.js';\nconst log = console.log;\n\n// Available file modes\nconst modes = ['mp3', 'flac'];\n\nclass DownloadCommand {\n  constructor(queries, options) {\n    if (queries.length === 0) {\n      log(chalk.red('Please add a search query'));\n      process.exit(1);\n    }\n\n    if (!modes.includes(options.mode)) {\n      log(chalk.red(`--mode is invalid. Valid values: ${modes.join(', ')})`));\n      process.exit(1);\n    }\n\n    if (options.mode === 'flac' && options.quality) {\n      log(chalk.red('--quality is incompatible with the \"flac\" mode. Please remove this option.'));\n      process.exit(1);\n    }\n\n    this.options = options;\n    this.searchService = new SearchService(queries);\n    this.downloadService = new DownloadService(this.searchService);\n    this.search = null;\n\n    this.credentialsService = new CredentialsService();\n    this.credentialsService.connect(this.onConnected.bind(this));\n  }\n\n  /**\n   * @param {SlskClient} client\n   */\n  onConnected(client) {\n    this.search = new Search(this.searchService, this.downloadService, this.options, client);\n    this.search.search();\n  }\n}\n\nexport default DownloadCommand;\n"
  },
  {
    "path": "src/commands/login.js",
    "content": "import inquirer from 'inquirer';\nimport CredentialsService from '../services/CredentialsService.js';\n\nclass LoginCommand {\n  constructor() {\n    this.credentialsService = new CredentialsService();\n    this.askCredentials();\n  }\n\n  /**\n   * Ask for credentials\n   */\n  askCredentials() {\n    const loginQuestion = {\n      type: 'input',\n      name: 'login',\n      message: 'Login',\n    };\n    const pwdQuestion = {\n      type: 'password',\n      name: 'pwd',\n      message: 'Password',\n    };\n    inquirer.prompt(loginQuestion).then((loginAnswer) => {\n      inquirer.prompt(pwdQuestion).then((pwdAnswer) => {\n        this.credentialsService.storeCredentials(loginAnswer.login, pwdAnswer.pwd);\n      });\n    });\n  }\n}\n\nexport default LoginCommand;\n"
  },
  {
    "path": "src/commands/query.js",
    "content": "import DownloadCommand from './download.js';\nimport Search from '../modules/Search.js';\n\nclass QueryCommand extends DownloadCommand {\n  /**\n   * @param {SlskClient} client\n   */\n  onConnected(client) {\n    const queryOptions = {\n      showPrompt: false,\n    };\n    this.search = new Search(this.searchService, this.downloadService, { ...this.options, ...queryOptions }, client);\n    this.search.search();\n  }\n}\n\nexport default QueryCommand;\n"
  },
  {
    "path": "src/modules/DestinationDirectory.js",
    "content": "import path from 'path';\nimport fs from 'fs';\nimport process from 'process';\n\nexport default function (destination) {\n  this.destination = destination;\n\n  /**\n   * Compute the final destination repository, depending on the \"destination\" option\n   * @param  {string} directory\n   * @return {string}\n   */\n  this.getDestinationDirectory = (directory) => {\n    let dir;\n\n    if (this.destination) {\n      if (path.isAbsolute(this.destination)) {\n        dir = this.destination + path.sep + directory;\n      } else {\n        dir = process.cwd() + path.sep + this.destination + path.sep + directory;\n      }\n    } else {\n      dir = process.cwd() + path.sep + directory;\n    }\n\n    createIfNotExist(dir);\n\n    return dir;\n  };\n}\n\n/**\n * Create a directory if it doesn't exist\n * @param {string} dir\n */\nlet createIfNotExist = (dir) => {\n  const dirList = dir.split(path.sep);\n  let buildPath = '';\n\n  for (let i = 0; i < dirList.length; i++) {\n    buildPath += dirList[i] + path.sep;\n\n    if (!fs.existsSync(buildPath)) {\n      fs.mkdirSync(buildPath);\n    }\n  }\n};\n"
  },
  {
    "path": "src/modules/Download.js",
    "content": "import fs from 'fs';\nimport process from 'process';\nimport chalk from 'chalk';\nimport DestinationDirectory from './DestinationDirectory.js';\nconst log = console.log;\n\nexport default function (downloadService, searchService, options, client) {\n  this.destinationDirectory = new DestinationDirectory(options.destination);\n  this.downloadService = downloadService;\n  this.searchService = searchService;\n  this.client = client;\n\n  /**\n   * Call prepare download method,\n   * then launch the download of each files in the list\n   *\n   * @param {array} files\n   */\n  this.startDownloads = (files) => {\n    this.downloadService.prepareDownload(files);\n    files.forEach((file) => this.downloadFile(file));\n  };\n\n  /**\n   * Download a single file from the selected answer\n   *\n   * @param file\n   */\n  this.downloadFile = (file) => {\n    const fileStructure = file.file.split('\\\\');\n    const directory = fileStructure[fileStructure.length - 2];\n    const filename = fileStructure[fileStructure.length - 1];\n\n    const data = {\n      file,\n      path: this.destinationDirectory.getDestinationDirectory(directory) + '/' + filename,\n    };\n\n    if (this.checkFileExists(data.path, filename)) {\n      return;\n    }\n\n    log(filename + chalk.yellow(' [downloading...]'));\n\n    this.client.download(data, (err, down) => {\n      if (err) {\n        log(chalk.red(err));\n        process.exit();\n      }\n\n      this.downloadService.downloadComplete(down.path);\n    });\n  };\n\n  this.checkFileExists = (path, filename) => {\n    let fileExists = false;\n\n    if (fs.existsSync(path)) {\n      log(filename + chalk.green(' [already downloaded: skipping]'));\n      this.downloadService.decrementFileCount();\n\n      if (this.searchService.allSearchesCompleted() && this.downloadService.getFileCount() === 0) {\n        log('No file to download.');\n        process.exit();\n      }\n\n      fileExists = true;\n    }\n\n    return fileExists;\n  };\n}\n"
  },
  {
    "path": "src/modules/DownloadLogger.js",
    "content": "import chalk from 'chalk';\nconst log = console.log;\n\nexport default function (searchService, downloadService) {\n  this.searchService = searchService;\n  this.downloadService = downloadService;\n  this.logBuffer = '';\n  this.fileIndex = 0;\n\n  /**\n   * 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.\n   * @param  {string} path Path of the downloaded file\n   */\n  this.downloadComplete = (path) => {\n    this.fileIndex++;\n    let logInfo = '(' + this.fileIndex + '/{{totalFileCount}}) Received: ' + path;\n\n    if (this.searchService.allSearchesCompleted()) {\n      logInfo = logInfo.replace(/{{totalFileCount}}/g, this.downloadService.getFileCount());\n      log(logInfo);\n    } else {\n      this.logBuffer += logInfo + '\\n';\n    }\n  };\n\n  /**\n   * Write in the terminal every lines stored in the buffer, then reset it to empty string.\n   */\n  this.flush = () => {\n    if (this.logBuffer.length > 0) {\n      this.logBuffer = this.logBuffer.replace(/{{totalFileCount}}/g, this.downloadService.getFileCount()).slice(0, -1);\n      log(this.logBuffer);\n      this.logBuffer = '';\n    }\n  };\n\n  /**\n   * Write a line summing the number of file starting to download.\n   * @param  {number} fileCount Number of files\n   */\n  this.startDownload = (fileCount) => {\n    log(chalk.green('Starting download of ' + fileCount + ' file' + (fileCount > 1 ? 's' : '') + '...'));\n  };\n}\n"
  },
  {
    "path": "src/modules/FilterResult.js",
    "content": "import path from 'path';\nimport _ from 'lodash';\nconst pluralize = (noun, count, suffix = 's') => `${count} ${noun}${count !== 1 ? suffix : ''}`;\n\nexport default function (qualityFilter, mode) {\n  this.qualityFilter = qualityFilter;\n  this.mode = mode;\n\n  /**\n   * From the query results, only get mp3 with free slots.\n   * The fastest results are going to be first.\n   *\n   * @param res\n   * @return {array}\n   */\n  this.filter = (res) => {\n    res = filterByFreeSlot(res);\n\n    if (this.mode === 'mp3') {\n      res = keepOnlyMp3(res);\n    }\n\n    if (this.mode === 'flac') {\n      res = keepOnlyFlac(res);\n    }\n\n    if (this.qualityFilter) {\n      res = filterByQuality(this.qualityFilter, res);\n    }\n\n    res = sortBySpeed(res);\n    return getFilesByUser(res);\n  };\n}\n\n/**\n * Discard all results without free slots\n * @param {array} res\n * @returns {array}\n */\nlet filterByFreeSlot = (res) => res.filter((r) => r.slots === true && r.speed > 0);\n\n/**\n * Remove everything that is not a mp3\n * @param {array} res\n * @returns {array}\n */\nlet keepOnlyMp3 = (res) => res.filter((r) => path.extname(r.file) === '.mp3');\n\n/**\n * Remove everything that is not a flac\n * @param {array} res\n * @returns {array}\n */\nlet keepOnlyFlac = (res) => res.filter((r) => path.extname(r.file) === '.flac');\n\n/**\n * If a quality filter is defined, keep only the folders with the defined bitrate\n * @param {string} qualityFilter\n * @param {array} res\n * @returns {array}\n */\nlet filterByQuality = (qualityFilter, res) => res.filter((r) => r.bitrate === parseInt(qualityFilter, 10));\n\n/**\n * Display the fastest results first\n * @param {array} res\n */\nlet sortBySpeed = (res) => res.sort((a, b) => b.speed - a.speed);\n\n/**\n * Compute the average bitrate of a folder\n * @param {array} files\n * @returns {Number}\n */\nlet getAverageBitrate = (files) => {\n  let averageBitrate = 0;\n\n  if (files.length > 0) {\n    const sum = files.reduce((a, b) => a + b.bitrate, 0);\n    averageBitrate = Math.round(sum / files.length);\n  }\n\n  return averageBitrate;\n};\n\n/**\n * Compute the size of a folder in megabytes\n * @param {array} files\n * @returns {Number}\n */\nlet getFolderSize = (files) => {\n  let size = 0;\n\n  if (files.length > 0) {\n    size = Math.round(files.reduce((a, b) => a + b.size, 0) / 1024 / 1024);\n  }\n\n  return size;\n};\n\n/**\n * Get the speed of the remote peer\n * @param {array} files\n * @returns {Number}\n */\nlet getSpeed = (files) => {\n  let speed = 0;\n\n  if (files.length > 0) {\n    speed = Math.round(files[0].speed / 1024);\n  }\n\n  return speed;\n};\n\n/**\n * Build the result list\n * @param {array} res\n * @returns {object}\n */\nlet getFilesByUser = (res) => {\n  let filesByUser = {};\n\n  const rawFilesByUser = _.groupBy(res, (r) => {\n    const resFileStructure = r.file.split('\\\\');\n    const resDirectory = resFileStructure[resFileStructure.length - 2];\n    return resDirectory + ' - ' + r.user;\n  });\n\n  for (const prop in rawFilesByUser) {\n    let extraInfo = [];\n\n    // Number of files\n    extraInfo.push(`${pluralize('file', rawFilesByUser[prop].length)}`);\n\n    // Bitrate\n    const bitrate = getAverageBitrate(rawFilesByUser[prop]);\n    if (bitrate) {\n      extraInfo.push(`bitrate: ${bitrate}kbps`);\n    }\n\n    // Size\n    extraInfo.push(`size: ${getFolderSize(rawFilesByUser[prop])}mb`);\n\n    // Speed\n    extraInfo.push(`speed: ~${getSpeed(rawFilesByUser[prop])}kb/s`);\n\n    filesByUser[`${prop} (${extraInfo.join(', ')})`] = rawFilesByUser[prop];\n  }\n\n  return filesByUser;\n};\n"
  },
  {
    "path": "src/modules/Search.js",
    "content": "import inquirer from 'inquirer';\nimport _ from 'lodash';\nimport chalk from 'chalk';\nimport FilterResult from './FilterResult.js';\nimport Download from './Download.js';\nconst log = console.log;\n\nexport default function (searchService, downloadService, options, client) {\n  this.download = new Download(downloadService, searchService, options, client);\n  this.filterResult = new FilterResult(options.quality, options.mode);\n  this.searchService = searchService;\n  this.downloadService = downloadService;\n  this.client = client;\n  this.timeout = options.timeout ?? 2000;\n  this.showPrompt = options.showPrompt ?? true;\n\n  /**\n   * Launch search query, then call a callback\n   */\n  this.search = () => {\n    const query = this.searchService.getNextQuery();\n    log(chalk.green(\"Searching for '%s'\"), query);\n    const searchParam = {\n      req: query,\n      timeout: this.timeout,\n    };\n    const afterSearch = (err, res) => this.onSearchFinished(err, res);\n    this.client.search(searchParam, afterSearch);\n  };\n\n  /**\n   * Callback called when the search query get back\n   */\n  this.onSearchFinished = (err, res) => {\n    if (err) {\n      return log(chalk.red(err));\n    }\n\n    const filesByUser = this.filterResult.filter(res);\n    this.checkEmptyResult(filesByUser);\n\n    if (this.showPrompt) {\n      this.showResults(filesByUser);\n    } else {\n      this.showTopResult(filesByUser);\n      process.exit(0);\n    }\n  };\n\n  /**\n   * If the result set is empty and there is no pending searches quit the process.\n   * If there is pending searches, launch the next search.\n   * If the result set is not empty just log success message.\n   */\n  this.checkEmptyResult = (filesByUser) => {\n    if (_.isEmpty(filesByUser)) {\n      log(chalk.red('Nothing found'));\n      this.searchService.consumeQuery();\n\n      if (this.searchService.allSearchesCompleted()) {\n        process.exit(1);\n      }\n\n      this.search();\n    } else {\n      log(chalk.green('Search finished'));\n    }\n  };\n\n  /**\n   * Display the top result\n   *\n   * @param {array} filesByUser\n   */\n  this.showTopResult = (filesByUser) => {\n    const numResults = Object.keys(filesByUser).length;\n\n    if (numResults > 0) {\n      const topResult = String(_.keys(filesByUser)[0]);\n      log(chalk.green('Search returned ' + numResults + ' results'));\n      log(chalk.blue('Top result: %s'), topResult);\n    }\n  };\n\n  /**\n   * Display a list of choices that the user can choose from.\n   *\n   * @param {array} filesByUser\n   */\n  this.showResults = (filesByUser) => {\n    const numResults = Object.keys(filesByUser).length;\n\n    log(chalk.green('Displaying ' + numResults + ' search results'));\n\n    const options = {\n      type: 'rawlist',\n      name: 'user',\n      pageSize: 10,\n      message: 'Choose a folder to download',\n      choices: _.keys(filesByUser),\n    };\n    inquirer.prompt([options]).then((answers) => this.processChosenAnswers(answers, filesByUser));\n  };\n\n  /**\n   * From the user answer, trigger the download of the folder\n   * If there is pending search, launch the next search query\n   *\n   * @param {array} answers\n   * @param filesByUser\n   */\n  this.processChosenAnswers = (answers, filesByUser) => {\n    this.searchService.consumeQuery();\n    this.download.startDownloads(filesByUser[answers.user]);\n\n    if (this.searchService.allSearchesCompleted()) {\n      this.downloadService.downloadLogger.flush();\n      this.downloadService.everyDownloadCompleted();\n    } else {\n      this.search();\n    }\n  };\n}\n"
  },
  {
    "path": "src/services/CredentialsService.js",
    "content": "import slsk from 'slsk-client';\nimport keytar from 'keytar';\nimport chalk from 'chalk';\nconst err = console.error;\nconst log = console.log;\n\nexport default function () {\n  this.serviceName = 'soulseek-cli';\n\n  /**\n   * Store credential in the OS keychain\n   *\n   * @param {string} login\n   * @param {string} pwd\n   */\n  this.storeCredentials = (login, pwd) => {\n    keytar.findCredentials(this.serviceName).then((oldCredentials) => {\n      if (oldCredentials.length === 0) {\n        keytar.setPassword(this.serviceName, login, pwd);\n        return;\n      }\n\n      keytar.deletePassword(this.serviceName, oldCredentials[0].account).then(() => {\n        keytar.setPassword(this.serviceName, login, pwd);\n      });\n    });\n  };\n\n  /**\n   * Fetch credential from OS keychain\n   *\n   * @return  {Promise<{account: string; password: string;}>}\n   */\n  this.getCredentials = () => {\n    return new Promise((resolve) => {\n      const account = process.env.SOULSEEK_ACCOUNT;\n      const password = process.env.SOULSEEK_PASSWORD;\n\n      if (account !== undefined && password !== undefined) {\n        resolve({\n          account,\n          password,\n        });\n      } else {\n        keytar.findCredentials(this.serviceName).then((credentials) => {\n          if (credentials.length === 0) {\n            err(chalk.red('No credential found for soulseek-cli, please login.'));\n            process.exit();\n          }\n\n          resolve(credentials[0]);\n        });\n      }\n    });\n  };\n\n  /**\n   * Connect to the Soulseek client\n   */\n  this.connect = (callback) => {\n    log(chalk.green('Connecting to soulseek'));\n    this.getCredentials().then((credentials) => {\n      slsk.connect(\n        {\n          user: credentials.account,\n          pass: credentials.password,\n        },\n        (err, client) => {\n          if (err) {\n            return log(chalk.red(err));\n          }\n\n          log(chalk.green('Connected to soulseek'));\n          callback(client);\n        }\n      );\n    });\n  };\n}\n"
  },
  {
    "path": "src/services/DownloadService.js",
    "content": "import DownloadLogger from '../modules/DownloadLogger.js';\nconst log = console.log;\n\nexport default function (searchService) {\n  this.searchService = searchService;\n  this.downloadLogger = new DownloadLogger(searchService, this);\n  this.downloadingFilesCount = 0;\n  this.downloadCompleteCount = 0;\n\n  this.prepareDownload = (files) => {\n    this.downloadLogger.startDownload(files.length);\n    this.downloadingFilesCount += files.length;\n  };\n\n  this.downloadComplete = (downloadPath) => {\n    this.downloadLogger.downloadComplete(downloadPath);\n    this.downloadCompleteCount++;\n    this.everyDownloadCompleted();\n  };\n\n  this.everyDownloadCompleted = () => {\n    if (this.downloadCompleteCount === this.downloadingFilesCount && this.searchService.allSearchesCompleted()) {\n      log(this.downloadingFilesCount + ' file' + (this.downloadingFilesCount > 1 ? 's' : '') + ' downloaded.');\n      process.exit();\n    }\n  };\n\n  this.decrementFileCount = () => {\n    this.downloadingFilesCount--;\n  };\n\n  this.getFileCount = () => {\n    return this.downloadingFilesCount;\n  };\n}\n"
  },
  {
    "path": "src/services/SearchService.js",
    "content": "export default function (queries) {\n  this.queries = queries;\n\n  /**\n   * Return the next query to process\n   *\n   * @return {Object}\n   */\n  this.getNextQuery = () => {\n    return this.queries[0];\n  };\n\n  /**\n   * Return true if there is no pending searches\n   *\n   * @return {boolean}\n   */\n  this.allSearchesCompleted = () => {\n    return this.queries.length === 0;\n  };\n\n  /**\n   * Remove the first query of the query list\n   */\n  this.consumeQuery = () => {\n    return this.queries.splice(0, 1);\n  };\n}\n"
  }
]