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 | downloads's destination | | -q --quality | show only mp3 with a defined quality | | -m --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 | show only mp3 with a defined quality | | -m --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 ', 'downloads\'s destination') .option('-q, --quality ', 'show only mp3 with a defined quality') .option('-m, --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 ', 'show only mp3 with a defined quality') .option('-m, --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 -- '); 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); }; }