Skip to content

Commit

Permalink
Remember last torrent ID
Browse files Browse the repository at this point in the history
- Implement remembering last torrent ID for the specified directory. Setting `rememberLastTorrent: true` makes `torrent-clean` save last torrent ID value to `lastTorrent` property inside config file. `lastTorrent` value is used when `--torrent` argument is not set.
- Make `--torrent` and `torrentId` optional. Throw error when `torrentId` cannot be received from `lastTorrent` property
- Add some spacing in CLI output
- Return "Parsed TORRENT_NAME" CLI output
- Add `nyc` to collect coverage
- Update dependencies
  • Loading branch information
nikolay-borzov committed Apr 20, 2020
1 parent 86f16e2 commit d9b6fd8
Show file tree
Hide file tree
Showing 15 changed files with 1,897 additions and 388 deletions.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@ node_modules/

# Optional eslint cache
.eslintcache

# nyc test coverage
.nyc_output
coverage
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ gets files' paths from `nature-pack.torrent` and compares them with files from `

## Arguments

`--torrent` (or `-t`) - Torrent id (as described in [webtorrent api](https://github.com/webtorrent/webtorrent/blob/master/docs/api.md#clientaddtorrentid-opts-function-ontorrent-torrent-))
`--torrent` (or `-t`) - Torrent ID (as described in [webtorrent api](https://github.com/webtorrent/webtorrent/blob/master/docs/api.md#clientaddtorrentid-opts-function-ontorrent-torrent-))
- Magnet URI (e.g. `magnet:?xt=urn:btih:d2474e86c95b19b8bcfdb92bc12c9d44667cfa36`)
- Info Hash (e.g. `d2474e86c95b19b8bcfdb92bc12c9d44667cfa36`)
- http/https URL to a torrent file
Expand All @@ -52,22 +52,25 @@ gets files' paths from `nature-pack.torrent` and compares them with files from `

## Config files

`torrent-clean` allows specifying some parameters via config file (`.torrent-cleanrc`, `.torrent-cleanrc.json`, `.torrent-cleanrc.yaml`, `.torrent-cleanrc.yml` or `.torrent-cleanrc.js`). There are might be many files - `torrent-clean` will collect and merge all files up to root directory. The closer config to the directory is, the higher its priority
`torrent-clean` allows specifying some parameters via config file (`.torrent-cleanrc`, `.torrent-cleanrc.json`, `.torrent-cleanrc.yaml`, `.torrent-cleanrc.yml`). There might be many files - `torrent-clean` will collect and merge all files up to root directory. The closer config to the directory is, the higher its priority.

Parameter are:
- `ignore` - an array of globs (picomatch) or filenames that will be excluded from the list of extra files.
- `ignore: string[]` - an array of globs (picomatch) or filenames that will be excluded from the list of extra files.
- `rememberLastTorrent: boolean` - Enable remembering last specified torrent ID for specified directory to `lastTorrent` config parameter. Only string values are saved. `lastTorrent` is used when `--torrent` argument is not set.

## API
`cleanTorrentDir` accepts options object:
```javascript
{
torrentId: '6a9759bffd5c0af65319979fb7832189f4f3c35d',
// Directory to clean
directoryPath: 'C:/Downloads/wallpapers/nature',
dirPath: 'C:/Downloads/wallpapers/nature',
// Do not delete files immediately. Instead return `deleteFiles` function
dryRun: true,
// Config with highest priority
customConfig: { ignore: ['**/*\(edited\)*'] }
customConfig: { ignore: ['**/*\(edited\)*'] },
// Called when config is loaded
onConfigLoaded({ torrent }) { console.log(`Parsed ${torrent}`) }
}
```

Expand All @@ -77,7 +80,7 @@ const cleanTorrentDir = require('torrent-clean')

const { extraFiles } = await cleanTorrentDir({
torrentId: 'C:/Downloads/torrents/nature wallpapers.torrent',
directoryPath: 'C:/Downloads/wallpapers/nature'
dirPath: 'C:/Downloads/wallpapers/nature'
})

console.log('Removed', extraFiles)
Expand All @@ -88,7 +91,7 @@ const cleanTorrentDir = require('torrent-clean')

const { extraFiles, deleteFiles } = await cleanTorrentDir({
torrentId: '6a9759bffd5c0af65319979fb7832189f4f3c35d',
directoryPath: 'C:/Downloads/wallpapers/nature',
dirPath: 'C:/Downloads/wallpapers/nature',
dryRun: true,
customConfig: { ignore: ['**/*\(edited\)*'] }
})
Expand Down
36 changes: 20 additions & 16 deletions bin/torrent-clean.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,30 +20,34 @@ const argv = require('minimist')(process.argv.slice(2), {
if (argv.version) {
console.log(packageJson.version)
} else {
console.log(logColor.info.bold('dir:'.padEnd(10)), argv.dir)
console.log(logColor.info.bold('torrent:'.padEnd(10)), argv.torrent, os.EOL)

if (!argv.torrent) {
throw new Error(
logColor.error(`${chalk.bold('torrent')} argument is required`)
)
}

const torrentId = argv.torrent
const directoryPath = path.resolve(argv.dir)

console.log(logColor.info('Parsing torrent file...'))
const dirPath = path.resolve(argv.dir)

cleanTorrentDir({
torrentId,
dirPath,
dryRun: true,
onConfigLoaded(torrentId) {
console.log(logColor.info.bold('directory:'.padEnd(10)), argv.dir)
console.log(logColor.info.bold('torrent:'.padEnd(10)), torrentId, os.EOL)

console.log(logColor.info('Parsing torrent file...'), os.EOL)
},
})
.then(async ({ torrentName, extraFiles, deleteFiles }) => {
console.log(`Parsed ${chalk.bold(torrentName)}`)

cleanTorrentDir({ torrentId, directoryPath, dryRun: true })
.then(async ({ extraFiles, deleteFiles }) => {
if (extraFiles.length === 0) {
console.log('No extra files found!')
return
}

console.log(`Found ${chalk.bold(extraFiles.length)} extra file(s).`)
console.log(
`Found ${chalk.bold(extraFiles.length)} extra file(s).`,
os.EOL
)

const dirRoot = `${directoryPath}${path.sep}`
const dirRoot = `${dirPath}${path.sep}`

const filesChoices = extraFiles.map((filename) => ({
name: filename.replace(dirRoot, ''),
Expand Down
69 changes: 52 additions & 17 deletions lib/api.js
Original file line number Diff line number Diff line change
@@ -1,84 +1,119 @@
const path = require('path')

const { loadConfig } = require('./load-config')
const { loadConfig, saveConfig } = require('./config')
const { getTorrentMetadata } = require('./parse-torrent')
const { readDir } = require('./read-dir')
const { deleteFilesAndEmptyDirs } = require('./delete-files')

/**
* @typedef {import('./load-config').TorrentCleanConfig} TorrentCleanConfig
* @typedef {import('./config').TorrentCleanConfig} TorrentCleanConfig
* @typedef {import('./config').LoadConfigResult} LoadConfigResult
* @typedef {import('./parse-torrent').TorrentId} TorrentId
*/

/**
* @typedef {object} TorrentCleanOptions
* @property {TorrentId} torrentId Torrent id
* (as described in {@link https://github.com/webtorrent/webtorrent/blob/master/docs/api.md#clientaddtorrentid-opts-function-ontorrent-torrent-|webtorrent api})
* @property {string} directoryPath Path to directory with downloaded files
* @property {TorrentId} [torrentId] Torrent ID
* (as described in {@link https://github.com/webtorrent/webtorrent/blob/master/docs/api.md#clientaddtorrentid-opts-function-ontorrent-torrent-|webtorrent api}).
* If empty `lastTorrent` value from [config]{@link TorrentCleanConfig} is used
* (only when `rememberLastTorrent` is `true`)
* @property {string} dirPath Path to directory with downloaded files
* @property {boolean} [dryRun] Postpone deleting files until confirm function
* is called
* @property {TorrentCleanConfig} [customConfig] Config that will be merged with
* configs collected from files
* @property {(torrentId: TorrentId) => void} [onConfigLoaded] Called when
* config is parsed
*/

/**
* @typedef {object} TorrentCleanResult
* @property {string} torrentName Parsed torrent name
* @property {string[]} extraFiles Extra files' paths
* @property {(files: string[]) => Promise<void>} [deleteFiles] Function to
* delete files in the directory. Only set if `dryRun` is set to `true`
*/

/**
* Parses `torrentId` and finds files inside `directoryPath` not listed in the torrent.
* Parses `torrentId`, finds and deletes files inside `dirPath` not listed
* in the torrent.
*
* @param {TorrentCleanOptions} options
* @returns {Promise<TorrentCleanResult>}
*/
async function cleanTorrentDir({
torrentId,
directoryPath,
dirPath,
customConfig,
dryRun,
onConfigLoaded,
}) {
/** @type {TorrentCleanConfig} */
let config
/** @type {LoadConfigResult} */
let loadConfigResult

try {
config = loadConfig(directoryPath, customConfig)
loadConfigResult = await loadConfig(dirPath, customConfig)
} catch (error) {
error.message = `Cannot parse config file ${error.message}`
throw error
}

const { config, searchFromCosmiconfigResult } = loadConfigResult

// Use `lastTorrent` only if `rememberLastTorrent` is enabled
if (!torrentId && config.rememberLastTorrent) {
torrentId = config.lastTorrent
}

if (!torrentId) {
throw new Error(`'torrentId' is required`)
}

if (onConfigLoaded) {
onConfigLoaded(torrentId)
}

return Promise.all([
getTorrentMetadata(torrentId),
readDir(directoryPath, config.ignore),
readDir(dirPath, config.ignore),
]).then(async ([parseResult, dirFiles]) => {
if (!parseResult) {
throw new Error('Unable to parse torrent')
}

const { name, files } = parseResult

const rootDir = `${name}${path.sep}`
// Get absolute paths of torrent files
const torrentFiles = files.map((file) =>
path.join(directoryPath, file.replace(rootDir, ''))
)
const torrentFiles = files.map((file) => path.join(dirPath, file))

// Get files not listed in the torrent
const extraFiles = dirFiles.filter(
(filename) => torrentFiles.indexOf(filename) === -1
)

if (!dryRun) {
await deleteFilesAndEmptyDirs(directoryPath, extraFiles)
await deleteFilesAndEmptyDirs(dirPath, extraFiles)
}

if (config.rememberLastTorrent && typeof torrentId === 'string') {
const { config: searchFromConfig = {}, filepath } =
searchFromCosmiconfigResult || {}

saveConfig({
config: {
...searchFromConfig,
lastTorrent: torrentId,
},
saveDirPath: dirPath,
...(filepath && { existingConfigPath: filepath }),
})
}

return {
torrentName: name,
extraFiles,
...(dryRun && {
deleteFiles: deleteFilesAndEmptyDirs.bind(null, directoryPath),
deleteFiles: deleteFilesAndEmptyDirs.bind(null, dirPath),
}),
}
})
Expand Down
145 changes: 145 additions & 0 deletions lib/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
const path = require('path')
const fs = require('fs')
const { cosmiconfig } = require('cosmiconfig')
const YAML = require('yaml')

const { merge } = require('./helpers')

/**
* @typedef {import('cosmiconfig/dist/types').CosmiconfigResult} CosmiconfigResult
*/

/**
* @typedef {object} TorrentCleanConfig
* @property {string[]} [ignore] Globs to ignore files
* @property {boolean} [rememberLastTorrent] Save last specified torrent ID to config
* @property {string} [lastTorrent] Last specified torrent ID
*/

/**
* @typedef {object} LoadConfigResult
* @property {TorrentCleanConfig} config Merged config
* @property {CosmiconfigResult} [searchFromCosmiconfigResult] `cosmiconfig`
* search result inside `searchFrom`
*/

/** @typedef {'json' | 'yaml'} FileType */

const MODULE_NAME = 'torrent-clean'

const COSMICONFIG_SEARCH_PLACES = [
`.${MODULE_NAME}rc`,
`.${MODULE_NAME}rc.json`,
`.${MODULE_NAME}rc.yaml`,
`.${MODULE_NAME}rc.yml`,
]
/** Default ignore patterns */
const IGNORE_GLOBS = ['~uTorrentPartFile*', `.${MODULE_NAME}rc*`]

/**
* Loads and merges configs starting from `searchFrom` directory
*
* @param {string} searchFrom Config search start directory
* @param {TorrentCleanConfig} [customConfig] Config with highest priority
* @returns {Promise<LoadConfigResult>}
*/
exports.loadConfig = async function loadConfig(searchFrom, customConfig) {
const explorer = cosmiconfig(MODULE_NAME, {
searchPlaces: COSMICONFIG_SEARCH_PLACES,
})

/** @type {CosmiconfigResult} */
let searchFromCosmiconfigResult

/** @type {CosmiconfigResult[]} */
const results = []
/** @type {CosmiconfigResult} */
let result = await explorer.search(searchFrom)

// Remember `startFrom` search config result
if (result && path.dirname(result.filepath) === searchFrom) {
searchFromCosmiconfigResult = result
}

// Collect all configs up to root directory
while (result) {
results.push(result)

const configDirName = path.dirname(result.filepath)
const parentDir = path.resolve(configDirName, '..')

result = await explorer.search(parentDir)
}

/** @type {TorrentCleanConfig} */
const mergedConfig = [
// The closer config to `searchFrom` directory is, the higher its priority
...results.map((result) => result.config).reverse(),
customConfig,
]
.filter(Boolean)
.reduce(merge, {})

if (mergedConfig.ignore) {
mergedConfig.ignore = [...IGNORE_GLOBS, ...mergedConfig.ignore]
} else {
mergedConfig.ignore = IGNORE_GLOBS
}

return {
config: mergedConfig,
...(searchFromCosmiconfigResult && { searchFromCosmiconfigResult }),
}
}

/**
* Detects file type by file path
*
* @param {string} filepath
* @returns {FileType}
*/
function getFileType(filepath) {
const extension = path.extname(filepath)

if (extension === '.json') {
return 'json'
} else if (['.yaml', '.yml'].includes(extension)) {
return 'yaml'
} else {
const content = fs.readFileSync(filepath).toString()

try {
JSON.parse(content)
return 'json'
} catch {
return 'yaml'
}
}
}

/**
* Saves config
*
* @param {object} params
* @param {TorrentCleanConfig} params.config
* @param {string} params.saveDirPath
* @param {string} [params.existingConfigPath] Path to existing config file
*/
exports.saveConfig = function saveConfig({
config,
saveDirPath,
existingConfigPath,
}) {
/** @type {FileType} */
const fileType = existingConfigPath ? getFileType(existingConfigPath) : 'yaml'

const fileContent =
fileType === 'json'
? JSON.stringify(config, null, 2)
: YAML.stringify(config)

const filename =
existingConfigPath || path.join(saveDirPath, `.${MODULE_NAME}rc`)

fs.writeFileSync(filename, fileContent)
}
Loading

0 comments on commit d9b6fd8

Please sign in to comment.