api: torrents: simplify contents API by returning an Array of TorrentContent

This commit is contained in:
Jesse Chan
2020-10-15 13:50:26 +08:00
parent 1f76f320c5
commit dcfcce3456
17 changed files with 321 additions and 331 deletions

View File

@@ -1,5 +1,4 @@
import classnames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import type {TorrentContent, TorrentContentSelection, TorrentContentSelectionTree} from '@shared/types/TorrentContent';
@@ -14,8 +13,7 @@ import TorrentActions from '../../../actions/TorrentActions';
interface DirectoryFilesProps {
depth: number;
hash: TorrentProperties['hash'];
fileList: Array<TorrentContent>;
selectedItems: TorrentContentSelectionTree['files'];
items: TorrentContentSelectionTree['files'];
path: Array<string>;
onPriorityChange: () => void;
onItemSelect: (selection: TorrentContentSelection) => void;
@@ -24,14 +22,9 @@ interface DirectoryFilesProps {
const METHODS_TO_BIND = ['handlePriorityChange'] as const;
class DirectoryFiles extends React.Component<DirectoryFilesProps> {
static propTypes = {
path: PropTypes.array,
selectedItems: PropTypes.object,
};
static defaultProps = {
path: [],
selectedItems: {},
items: {},
};
constructor(props: DirectoryFilesProps) {
@@ -82,49 +75,48 @@ class DirectoryFiles extends React.Component<DirectoryFilesProps> {
}
render() {
const branch = [...this.props.fileList];
if (this.props.items == null) {
return null;
}
branch.sort((a, b) => a.filename.localeCompare(b.filename));
const files = Object.values(this.props.items)
.sort((a, b) => a.filename.localeCompare(b.filename))
.map((file) => {
const isSelected =
(this.props.items && this.props.items[file.filename] && this.props.items[file.filename].isSelected) || false;
const classes = classnames(
'directory-tree__node file',
'directory-tree__node--file directory-tree__node--selectable',
{
'directory-tree__node--selected': isSelected,
},
);
const files = branch.map((file) => {
const isSelected =
(this.props.selectedItems &&
this.props.selectedItems[file.filename] &&
this.props.selectedItems[file.filename].isSelected) ||
false;
const classes = classnames(
'directory-tree__node file',
'directory-tree__node--file directory-tree__node--selectable',
{
'directory-tree__node--selected': isSelected,
},
);
return (
<div className={classes} key={file.filename} title={file.filename}>
<div className="file__label file__detail">
{this.getIcon(file, isSelected)}
<div className="file__name">{file.filename}</div>
</div>
<div className="file__detail file__detail--secondary">
<Size value={file.sizeBytes} precision={1} />
</div>
<div className="file__detail file__detail--secondary">{file.percentComplete}%</div>
<div
className="file__detail file__detail--secondary
return (
<div className={classes} key={file.filename} title={file.filename}>
<div className="file__label file__detail">
{this.getIcon(file, isSelected)}
<div className="file__name">{file.filename}</div>
</div>
<div className="file__detail file__detail--secondary">
<Size value={file.sizeBytes} precision={1} />
</div>
<div className="file__detail file__detail--secondary">{file.percentComplete}%</div>
<div
className="file__detail file__detail--secondary
file__detail--priority">
<PriorityMeter
key={`${file.index}-${file.filename}`}
level={file.priority}
id={file.index}
maxLevel={2}
onChange={this.handlePriorityChange}
priorityType="file"
/>
<PriorityMeter
key={`${file.index}-${file.filename}`}
level={file.priority}
id={file.index}
maxLevel={2}
onChange={this.handlePriorityChange}
priorityType="file"
/>
</div>
</div>
</div>
);
});
);
});
return <div className="directory-tree__node directory-tree__node--file-list">{files}</div>;
}

View File

@@ -1,8 +1,7 @@
import PropTypes from 'prop-types';
import React from 'react';
import type {TorrentContentSelection, TorrentContentSelectionTree} from '@shared/types/TorrentContent';
import type {TorrentDetails, TorrentProperties} from '@shared/types/Torrent';
import type {TorrentProperties} from '@shared/types/Torrent';
import DirectoryFileList from './DirectoryFileList';
// TODO: Fix this circular dependency
@@ -13,8 +12,7 @@ interface DirectoryTreeProps {
depth?: number;
path: Array<string>;
hash: TorrentProperties['hash'];
tree: TorrentDetails['fileTree'];
selectedItems: TorrentContentSelectionTree;
itemsTree: TorrentContentSelectionTree;
onPriorityChange: () => void;
onItemSelect: (selection: TorrentContentSelection) => void;
}
@@ -22,14 +20,9 @@ interface DirectoryTreeProps {
const METHODS_TO_BIND = ['getDirectoryTreeDomNodes'] as const;
class DirectoryTree extends React.Component<DirectoryTreeProps> {
static propTypes = {
path: PropTypes.array,
selectedItems: PropTypes.object,
};
static defaultProps = {
path: [],
selectedItems: {},
itemsTree: {},
};
constructor(props: DirectoryTreeProps) {
@@ -40,9 +33,9 @@ class DirectoryTree extends React.Component<DirectoryTreeProps> {
});
}
getDirectoryTreeDomNodes(tree: TorrentDetails['fileTree'], depth = 0) {
getDirectoryTreeDomNodes(itemsTree: TorrentContentSelectionTree, depth = 0) {
const {hash} = this.props;
const {files, directories} = tree;
const {files, directories} = itemsTree;
const childDepth = depth + 1;
const directoryNodes: Array<React.ReactNode> =
@@ -52,12 +45,15 @@ class DirectoryTree extends React.Component<DirectoryTreeProps> {
.map(
(directoryName, index): React.ReactNode => {
const subSelectedItems =
this.props.selectedItems.directories && this.props.selectedItems.directories[directoryName];
this.props.itemsTree.directories && this.props.itemsTree.directories[directoryName];
const subTree = directories[directoryName];
const id = `${index}${childDepth}${directoryName}`;
const isSelected = (subSelectedItems && subSelectedItems.isSelected) || false;
if (subSelectedItems == null) {
return null;
}
return (
<DirectoryTreeNode
depth={childDepth}
@@ -66,11 +62,10 @@ class DirectoryTree extends React.Component<DirectoryTreeProps> {
id={id}
isSelected={isSelected}
key={id}
selectedItems={subSelectedItems}
itemsTree={subSelectedItems}
onItemSelect={this.props.onItemSelect}
onPriorityChange={this.props.onPriorityChange}
path={this.props.path}
subTree={subTree}
/>
);
},
@@ -78,16 +73,15 @@ class DirectoryTree extends React.Component<DirectoryTreeProps> {
: [];
const fileList: React.ReactNode =
files != null && files.length > 0 ? (
files != null && Object.keys(files).length > 0 ? (
<DirectoryFileList
depth={childDepth}
fileList={files}
hash={hash}
key={`files-${childDepth}`}
onItemSelect={this.props.onItemSelect}
onPriorityChange={this.props.onPriorityChange}
path={this.props.path}
selectedItems={this.props.selectedItems.files}
items={this.props.itemsTree.files}
/>
) : null;
@@ -96,7 +90,9 @@ class DirectoryTree extends React.Component<DirectoryTreeProps> {
render() {
return (
<div className="directory-tree__tree">{this.getDirectoryTreeDomNodes(this.props.tree, this.props.depth)}</div>
<div className="directory-tree__tree">
{this.getDirectoryTreeDomNodes(this.props.itemsTree, this.props.depth)}
</div>
);
}
}

View File

@@ -2,11 +2,7 @@ import classnames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import type {
TorrentContentSelection,
TorrentContentSelectionTree,
TorrentContentTree,
} from '@shared/types/TorrentContent';
import type {TorrentContentSelection, TorrentContentSelectionTree} from '@shared/types/TorrentContent';
import type {TorrentProperties} from '@shared/types/Torrent';
import {Checkbox} from '../../../ui';
@@ -22,8 +18,7 @@ interface DirectoryTreeNodeProps {
hash: TorrentProperties['hash'];
path: Array<string>;
directoryName: string;
selectedItems: TorrentContentSelectionTree;
subTree: TorrentContentTree;
itemsTree: TorrentContentSelectionTree;
isSelected: boolean;
onPriorityChange: () => void;
onItemSelect: (selection: TorrentContentSelection) => void;
@@ -97,14 +92,13 @@ class DirectoryTreeNode extends React.Component<DirectoryTreeNodeProps, Director
return (
<div className="directory-tree__node directory-tree__node--group">
<DirectoryTree
tree={this.props.subTree}
depth={this.props.depth}
hash={this.props.hash}
key={`${this.state.expanded}-${this.props.depth}`}
onPriorityChange={this.props.onPriorityChange}
onItemSelect={this.props.onItemSelect}
path={this.getCurrentPath()}
selectedItems={this.props.selectedItems}
itemsTree={this.props.itemsTree}
/>
</div>
);

View File

@@ -1,30 +1,27 @@
import classnames from 'classnames';
import isEqual from 'lodash/isEqual';
import {deepEqual} from 'fast-equals';
import {FormattedMessage, injectIntl, WrappedComponentProps} from 'react-intl';
import React from 'react';
import type {
TorrentContentSelection,
TorrentContentSelectionTree,
TorrentContentTree,
} from '@shared/types/TorrentContent';
import type {TorrentContent, TorrentContentSelection, TorrentContentSelectionTree} from '@shared/types/TorrentContent';
import type {TorrentProperties} from '@shared/types/Torrent';
import {Button, Checkbox, Form, FormRow, FormRowItem, Select, SelectItem} from '../../../ui';
import ConfigStore from '../../../stores/ConfigStore';
import Disk from '../../icons/Disk';
import DirectoryTree from '../../general/filesystem/DirectoryTree';
import selectionTree from '../../../util/selectionTree';
import TorrentActions from '../../../actions/TorrentActions';
interface TorrentFilesProps extends WrappedComponentProps {
fileTree: TorrentContentTree;
contents: Array<TorrentContent>;
torrent: TorrentProperties;
}
interface TorrentFilesStates {
allSelected: boolean;
selectedItems: TorrentContentSelectionTree;
selectedFiles: Array<number>;
itemsTree: TorrentContentSelectionTree;
selectedIndices: Array<number>;
}
const TORRENT_PROPS_TO_CHECK = ['bytesDone'] as const;
@@ -39,8 +36,8 @@ class TorrentFiles extends React.Component<TorrentFilesProps, TorrentFilesStates
this.state = {
allSelected: false,
selectedItems: this.selectAll(this.props.fileTree, false),
selectedFiles: [],
itemsTree: selectionTree.getSelectionTree(this.props.contents, false),
selectedIndices: [],
};
METHODS_TO_BIND.forEach(<T extends typeof METHODS_TO_BIND[number]>(methodName: T) => {
@@ -57,7 +54,7 @@ class TorrentFiles extends React.Component<TorrentFilesProps, TorrentFilesStates
// If we know that the user changed a file's priority, we deeply check the
// file tree to render when the priority change is detected.
if (this.hasPriorityChanged) {
const shouldUpdate = !isEqual(nextProps.fileTree, this.props.fileTree);
const shouldUpdate = !deepEqual(nextProps.contents, this.props.contents);
// Reset the flag so we don't deeply check the next file tree.
if (shouldUpdate) {
@@ -68,7 +65,7 @@ class TorrentFiles extends React.Component<TorrentFilesProps, TorrentFilesStates
}
// Update when the previous props weren't defined and the next are.
if ((!this.props.torrent && nextProps.torrent) || (!this.props.fileTree && nextProps.fileTree)) {
if ((!this.props.torrent && nextProps.torrent) || (!this.props.contents && nextProps.contents)) {
return true;
}
@@ -80,11 +77,11 @@ class TorrentFiles extends React.Component<TorrentFilesProps, TorrentFilesStates
return true;
}
getSelectedFiles(selectionTree: TorrentContentSelectionTree) {
getSelectedFiles(tree: TorrentContentSelectionTree) {
const indices: Array<number> = [];
if (selectionTree.files != null) {
const {files} = selectionTree;
if (tree.files != null) {
const {files} = tree;
Object.keys(files).forEach((fileName) => {
const file = files[fileName];
@@ -94,8 +91,8 @@ class TorrentFiles extends React.Component<TorrentFilesProps, TorrentFilesStates
});
}
if (selectionTree.directories != null) {
const {directories} = selectionTree;
if (tree.directories != null) {
const {directories} = tree;
Object.keys(directories).forEach((directoryName) => {
indices.push(...this.getSelectedFiles(directories[directoryName]));
});
@@ -109,7 +106,9 @@ class TorrentFiles extends React.Component<TorrentFilesProps, TorrentFilesStates
const baseURI = ConfigStore.getBaseURI();
const link = document.createElement('a');
link.download = `${this.props.torrent.name}.tar`;
link.href = `${baseURI}api/torrents/${this.props.torrent.hash}/contents/${this.state.selectedFiles.join(',')}/data`;
link.href = `${baseURI}api/torrents/${this.props.torrent.hash}/contents/${this.state.selectedIndices.join(
',',
)}/data`;
link.style.display = 'none';
document.body.appendChild(link); // Fix for Firefox 58+
link.click();
@@ -121,7 +120,7 @@ class TorrentFiles extends React.Component<TorrentFilesProps, TorrentFilesStates
if (inputElement.name === 'file-priority') {
this.handlePriorityChange();
TorrentActions.setFilePriority(this.props.torrent.hash, {
indices: this.state.selectedFiles,
indices: this.state.selectedIndices,
priority: Number(inputElement.value),
});
}
@@ -131,13 +130,13 @@ class TorrentFiles extends React.Component<TorrentFilesProps, TorrentFilesStates
handleItemSelect(selectedItem: TorrentContentSelection) {
this.hasSelectionChanged = true;
this.setState((state) => {
const selectedItems = this.mergeSelection(selectedItem, 0, state.selectedItems, this.props.fileTree);
const selectedItems = selectionTree.applySelection(state.itemsTree, selectedItem);
const selectedFiles = this.getSelectedFiles(selectedItems);
return {
selectedItems,
itemsTree: selectedItems,
allSelected: false,
selectedFiles,
selectedIndices: selectedFiles,
};
});
}
@@ -150,123 +149,23 @@ class TorrentFiles extends React.Component<TorrentFilesProps, TorrentFilesStates
this.hasSelectionChanged = true;
this.setState((state, props) => {
const selectedItems = this.selectAll(props.fileTree, state.allSelected);
const selectedItems = selectionTree.getSelectionTree(props.contents, state.allSelected);
const selectedFiles = this.getSelectedFiles(selectedItems);
return {
selectedItems,
itemsTree: selectedItems,
allSelected: !state.allSelected,
selectedFiles,
selectedIndices: selectedFiles,
};
});
}
isLoaded() {
return this.props.fileTree != null;
}
mergeSelection(
item: TorrentContentSelection,
currentDepth: number,
tree: TorrentContentSelectionTree,
fileTree: TorrentContentTree = {},
): TorrentContentSelectionTree {
const {depth, path, select, type} = item;
const currentPath = path[currentDepth];
// Change happens
if (currentDepth === depth - 1) {
if (type === 'file' && tree.files != null && tree.files[currentPath] != null) {
const files = {
...tree.files,
[currentPath]: {
...tree.files[currentPath],
isSelected: select,
},
};
return {
...tree,
files,
isSelected:
Object.values(files).every(({isSelected}) => isSelected) &&
(tree.directories != null ? Object.values(tree.directories).every(({isSelected}) => isSelected) : true),
};
}
if (
type === 'directory' &&
tree.directories != null &&
fileTree.directories != null &&
fileTree.directories[currentPath] != null
) {
const directories = {
...tree.directories,
[currentPath]: this.selectAll(fileTree.directories[currentPath], select),
};
return {
...tree,
directories,
isSelected:
Object.values(directories).every(({isSelected}) => isSelected) &&
(tree.files != null ? Object.values(tree.files).every(({isSelected}) => isSelected) : true),
};
}
return tree;
}
// Recursive call till we reach the target
if (tree.directories != null && fileTree.directories != null) {
const selectionSubTree = tree.directories;
const fileSubTree = fileTree.directories;
Object.keys(selectionSubTree).forEach((directory) => {
if (directory === currentPath) {
selectionSubTree[directory] = this.mergeSelection(
item,
currentDepth + 1,
selectionSubTree[directory],
fileSubTree[directory],
);
}
});
return {
...tree,
directories: selectionSubTree,
isSelected: Object.values(selectionSubTree).every(({isSelected}) => isSelected),
};
}
return tree;
}
selectAll(fileTree: TorrentContentTree, isSelected = true): TorrentContentSelectionTree {
const {files, directories} = fileTree;
const selectionTree: TorrentContentSelectionTree = {};
if (files) {
const selectedFiles: Exclude<TorrentContentSelectionTree['files'], undefined> = {};
files.forEach((file) => {
selectedFiles[file.filename] = {...file, isSelected};
});
selectionTree.files = selectedFiles;
}
if (directories) {
const selectedDirectories: Exclude<TorrentContentSelectionTree['directories'], undefined> = {};
Object.keys(directories).forEach((directory) => {
selectedDirectories[directory] = this.selectAll(directories[directory], isSelected);
});
selectionTree.directories = selectedDirectories;
}
selectionTree.isSelected = isSelected;
return selectionTree;
return this.props.contents != null;
}
render() {
const {fileTree, torrent} = this.props;
const {torrent} = this.props;
let directoryHeadingIconContent = null;
let fileDetailContent = null;
@@ -293,8 +192,7 @@ class TorrentFiles extends React.Component<TorrentFilesProps, TorrentFilesStates
onItemSelect={this.handleItemSelect}
onPriorityChange={this.handlePriorityChange}
hash={this.props.torrent.hash}
selectedItems={this.state.selectedItems}
tree={fileTree}
itemsTree={this.state.itemsTree}
/>
);
} else {
@@ -324,7 +222,7 @@ class TorrentFiles extends React.Component<TorrentFilesProps, TorrentFilesStates
);
const wrapperClasses = classnames('inverse directory-tree__wrapper', {
'directory-tree__wrapper--toolbar-visible': this.state.selectedFiles.length > 0,
'directory-tree__wrapper--toolbar-visible': this.state.selectedIndices.length > 0,
});
return (
@@ -335,10 +233,10 @@ class TorrentFiles extends React.Component<TorrentFilesProps, TorrentFilesStates
<FormattedMessage
id="torrents.details.selected.files"
values={{
count: this.state.selectedFiles.length,
count: this.state.selectedIndices.length,
countElement: (
<span className="directory-tree__selection-toolbar__item-count">
{this.state.selectedFiles.length}
{this.state.selectedIndices.length}
</span>
),
}}
@@ -348,7 +246,7 @@ class TorrentFiles extends React.Component<TorrentFilesProps, TorrentFilesStates
<FormattedMessage
id="torrents.details.files.download.file"
values={{
count: this.state.selectedFiles.length,
count: this.state.selectedIndices.length,
}}
/>
</Button>

View File

@@ -0,0 +1,141 @@
import type {TorrentContent, TorrentContentSelection, TorrentContentSelectionTree} from '@shared/types/TorrentContent';
const selectAll = (tree: TorrentContentSelectionTree, isSelected: boolean): TorrentContentSelectionTree => {
return {
...tree,
...(tree.directories != null
? {
directories: Object.assign(
{},
...Object.keys(tree.directories).map((directory) => {
return tree.directories != null
? {
[directory]: selectAll(tree.directories[directory], isSelected),
}
: {};
}),
),
}
: {}),
...(tree.files != null
? {
files: Object.assign(
{},
...Object.keys(tree.files).map((file) => {
return tree.files != null
? {
[file]: {
...tree.files[file],
isSelected,
},
}
: {};
}),
),
}
: {}),
isSelected,
};
};
const applySelection = (
tree: TorrentContentSelectionTree,
item: TorrentContentSelection,
recursiveDepth = 0,
): TorrentContentSelectionTree => {
const {depth, path, select, type} = item;
const currentPath = path[recursiveDepth];
// Change happens
if (recursiveDepth === depth - 1) {
if (type === 'file' && tree.files != null && tree.files[currentPath] != null) {
const files = {
...tree.files,
[currentPath]: {
...tree.files[currentPath],
isSelected: select,
},
};
return {
...tree,
files,
isSelected:
Object.values(files).every(({isSelected}) => isSelected) &&
(tree.directories != null ? Object.values(tree.directories).every(({isSelected}) => isSelected) : true),
};
}
if (type === 'directory' && tree.directories != null) {
const directories = {
...tree.directories,
[currentPath]: selectAll(tree.directories[currentPath], select),
};
return {
...tree,
directories,
isSelected:
Object.values(directories).every(({isSelected}) => isSelected) &&
(tree.files != null ? Object.values(tree.files).every(({isSelected}) => isSelected) : true),
};
}
return tree;
}
// Recursive call till we reach the target
if (tree.directories != null) {
const selectionSubTree = tree.directories;
Object.keys(selectionSubTree).forEach((directory) => {
if (directory === currentPath) {
selectionSubTree[directory] = applySelection(selectionSubTree[directory], item, recursiveDepth + 1);
}
});
return {
...tree,
directories: selectionSubTree,
isSelected: Object.values(selectionSubTree).every(({isSelected}) => isSelected),
};
}
return tree;
};
const getSelectionTree = (contents: Array<TorrentContent>, isSelected = true): TorrentContentSelectionTree => {
const tree: TorrentContentSelectionTree = {isSelected};
contents.forEach((content) => {
const pathComponents = content.path.split('/');
let currentDirectory = tree;
while (pathComponents.length - 1) {
const pathComponent = pathComponents.shift() as string;
if (currentDirectory.directories == null) {
currentDirectory.directories = {[pathComponent]: {isSelected}};
} else {
if (currentDirectory.directories[pathComponent] == null) {
currentDirectory.directories[pathComponent] = {isSelected};
}
currentDirectory = currentDirectory.directories[pathComponent];
}
}
if (currentDirectory.files == null) {
currentDirectory.files = {[content.filename]: {...content, isSelected}};
} else {
currentDirectory.files[content.filename] = content;
}
});
return tree;
};
const selectionTree = {
selectAll,
applySelection,
getSelectionTree,
};
export default selectionTree;

View File

@@ -4,21 +4,21 @@ import readline from 'readline';
import stream from 'stream';
import supertest from 'supertest';
import app from '../../app';
import {getAuthToken} from './auth';
import {getTempPath} from '../../models/TemporaryStorage';
import type {AddTorrentByURLOptions, SetTorrentsTrackersOptions} from '../../../shared/types/api/torrents';
import type {TorrentContent} from '../../../shared/types/TorrentContent';
import type {TorrentList, TorrentProperties} from '../../../shared/types/Torrent';
import type {TorrentStatus} from '../../../shared/constants/torrentStatusMap';
import type {TorrentTracker} from '../../../shared/types/TorrentTracker';
import app from '../../app';
import {getAuthToken} from './auth';
import {getTempPath} from '../../models/TemporaryStorage';
const request = supertest(app);
const authToken = `jwt=${getAuthToken('_config')}`;
const tempDirectory = getTempPath('rtorrent');
const tempDirectory = getTempPath('download');
fs.mkdirSync(tempDirectory, {recursive: true});
@@ -162,3 +162,24 @@ describe('PATCH /api/torrents/trackers', () => {
});
});
});
describe('GET /api/torrents/{hash}/contents', () => {
it('Gets contents of torrents', (done) => {
request
.get(`/api/torrents/${torrentHash}/contents`)
.send()
.set('Cookie', [authToken])
.set('Accept', 'application/json')
.expect(200)
.expect('Content-Type', /json/)
.end((err, res) => {
if (err) done(err);
const contents: Array<TorrentContent> = res.body;
expect(Array.isArray(contents)).toBe(true);
done();
});
});
});

View File

@@ -20,7 +20,7 @@ import {
StopTorrentsOptions,
} from '@shared/types/api/torrents';
import {accessDeniedError, findFilesByIndices, isAllowedPath, sanitizePath} from '../../util/fileUtil';
import {accessDeniedError, isAllowedPath, sanitizePath} from '../../util/fileUtil';
import ajaxUtil from '../../util/ajaxUtil';
import {getTempPath} from '../../models/TemporaryStorage';
import mediainfo from '../../util/mediainfo';
@@ -418,18 +418,21 @@ router.get('/:hash/contents/:indices/data', (req, res) => {
if (!selectedTorrent) return res.status(404).json({error: 'Torrent not found.'});
return req.services?.clientGatewayService?.getTorrentContents(hash).then((contents) => {
if (!contents || !contents.files) return res.status(404).json({error: 'Torrent contents not found'});
if (!contents) return res.status(404).json({error: 'Torrent contents not found'});
let indices: Array<number>;
if (!stringIndices || stringIndices === 'all') {
indices = contents.files.map((x) => x.index);
indices = contents.map((x) => x.index);
} else {
indices = stringIndices.split(',').map((value) => Number(value));
}
const filePathsToDownload = findFilesByIndices(indices, contents).map((file) =>
path.join(selectedTorrent.directory, file.path),
);
const filePathsToDownload = contents
.filter((content) => indices.includes(content.index))
.map((content) => {
return sanitizePath(path.join(selectedTorrent.directory, content.path));
})
.filter((filePath) => isAllowedPath(filePath));
if (filePathsToDownload.length === 1) {
const file = filePathsToDownload[0];
@@ -464,7 +467,7 @@ router.get('/:hash/details', async (req, res) => {
const trackers = req.services?.clientGatewayService?.getTorrentTrackers(req.params.hash);
callback({
fileTree: await contents,
contents: await contents,
peers: await peers,
trackers: await trackers,
});

View File

@@ -1,6 +1,6 @@
import type {ClientConnectionSettings} from '@shared/schema/ClientConnectionSettings';
import type {ClientSettings} from '@shared/types/ClientSettings';
import type {TorrentContentTree} from '@shared/types/TorrentContent';
import type {TorrentContent} from '@shared/types/TorrentContent';
import type {TorrentListSummary, TorrentProperties} from '@shared/types/Torrent';
import type {TorrentPeer} from '@shared/types/TorrentPeer';
import type {TorrentTracker} from '@shared/types/TorrentTracker';
@@ -70,7 +70,7 @@ abstract class ClientGatewayService extends BaseService<ClientGatewayServiceEven
* @param {string} hash - Hash of torrent
* @return {Promise<TorrentContentTree>} - Resolves with TorrentContentTree or rejects with error.
*/
abstract getTorrentContents(hash: TorrentProperties['hash']): Promise<TorrentContentTree>;
abstract getTorrentContents(hash: TorrentProperties['hash']): Promise<Array<TorrentContent>>;
/**
* Gets the list of peers of a torrent.

View File

@@ -6,7 +6,7 @@ import sanitize from 'sanitize-filename';
import type {ClientSettings} from '@shared/types/ClientSettings';
import type {ClientConnectionSettings, RTorrentConnectionSettings} from '@shared/schema/ClientConnectionSettings';
import type {TorrentContentTree} from '@shared/types/TorrentContent';
import type {TorrentContent} from '@shared/types/TorrentContent';
import type {TorrentList, TorrentListSummary, TorrentProperties} from '@shared/types/Torrent';
import type {TorrentPeer} from '@shared/types/TorrentPeer';
import type {TorrentTracker} from '@shared/types/TorrentTracker';
@@ -29,7 +29,6 @@ import type {SetClientSettingsOptions} from '@shared/types/api/client';
import {accessDeniedError, createDirectory, isAllowedPath, sanitizePath} from '../../util/fileUtil';
import ClientGatewayService from '../interfaces/clientGatewayService';
import ClientRequestManager from './clientRequestManager';
import {getFileTreeFromPathsArr} from './util/fileTreeUtil';
import scgiUtil from './util/scgiUtil';
import {getMethodCalls, processMethodCallResponse} from './util/rTorrentMethodCallUtil';
import torrentFileUtil from '../../util/torrentFileUtil';
@@ -149,7 +148,7 @@ class RTorrentClientGatewayService extends ClientGatewayService {
);
}
async getTorrentContents(hash: TorrentProperties['hash']): Promise<TorrentContentTree> {
async getTorrentContents(hash: TorrentProperties['hash']): Promise<Array<TorrentContent>> {
const configs = torrentContentMethodCallConfigs;
return (
this.clientRequestManager
@@ -159,10 +158,16 @@ class RTorrentClientGatewayService extends ClientGatewayService {
return Promise.all(responses.map((response) => processMethodCallResponse(response, configs)));
})
.then((processedResponses) => {
return processedResponses.reduce(
(memo, content, index) => getFileTreeFromPathsArr(memo, content.pathComponents[0], {index, ...content}),
{},
);
return processedResponses.map((content, index) => {
return {
index,
path: content.path,
filename: content.path.split('/').pop() || '',
percentComplete: Math.trunc((content.completedChunks / content.sizeChunks) * 100),
priority: content.priority,
sizeBytes: content.sizeBytes,
};
});
}) || Promise.reject()
);
}

View File

@@ -11,7 +11,7 @@ const torrentContentMethodCallConfigs = {
},
priority: {
methodCall: 'f.priority=',
transformValue: stringTransformer,
transformValue: numberTransformer,
},
sizeBytes: {
methodCall: 'f.size_bytes=',

View File

@@ -1,45 +0,0 @@
import truncateTo from './numberUtils';
const processFile = (file) => {
file.filename = file.pathComponents[file.pathComponents.length - 1];
file.percentComplete = truncateTo((file.completedChunks / file.sizeChunks) * 100);
file.priority = Number(file.priority);
file.sizeBytes = Number(file.sizeBytes);
delete file.completedChunks;
delete file.pathComponents;
delete file.sizeChunks;
return file;
};
export const getFileTreeFromPathsArr = (tree, directory, file, depth) => {
if (depth == null) {
depth = 0;
}
if (tree == null) {
tree = {};
}
if (depth++ < file.pathComponents.length - 1) {
if (!tree.directories) {
tree.directories = {};
}
tree.directories[directory] = getFileTreeFromPathsArr(
tree.directories[directory],
file.pathComponents[depth],
file,
depth,
);
} else {
if (!tree.files) {
tree.files = [];
}
tree.files.push(processFile(file));
}
return tree;
};

View File

@@ -2,8 +2,6 @@ import fs from 'fs';
import {homedir} from 'os';
import path from 'path';
import {TorrentContent, TorrentContentTree} from '@shared/types/TorrentContent';
import config from '../../config';
export const accessDeniedError = () => {
@@ -50,24 +48,6 @@ export const createDirectory = (directoryPath: string) => {
}
};
export const findFilesByIndices = (indices: Array<number>, fileTree: TorrentContentTree): TorrentContent[] => {
const {directories, files = []} = fileTree;
let selectedFiles = files.filter((file) => indices.includes(file.index));
if (directories != null) {
selectedFiles = selectedFiles.concat(
Object.keys(directories).reduce(
(accumulator: TorrentContent[], directory) =>
accumulator.concat(findFilesByIndices(indices, directories[directory])),
[],
),
);
}
return selectedFiles;
};
export const getDirectoryList = async (inputPath: string) => {
if (typeof inputPath !== 'string') {
throw fileNotFoundError();

View File

@@ -1,4 +1,4 @@
import type {TorrentContentTree} from './TorrentContent';
import type {TorrentContent} from './TorrentContent';
import type {TorrentPeer} from './TorrentPeer';
import type {TorrentStatus} from '../constants/torrentStatusMap';
import type {TorrentTracker} from './TorrentTracker';
@@ -14,13 +14,18 @@ export interface Duration {
}
export interface TorrentDetails {
contents: Array<TorrentContent>;
peers: Array<TorrentPeer>;
trackers: Array<TorrentTracker>;
fileTree: TorrentContentTree;
}
// TODO: Rampant over-fetching of torrent properties. Need to remove unused items.
// TODO: Unite with torrentListPropMap when it is TS.
export enum TorrentPriority {
DO_NOT_DOWNLOAD = 0,
LOW = 1,
NORMAL = 2,
HIGH = 3,
}
export interface TorrentProperties {
baseDirectory: string;
baseFilename: string;
@@ -34,23 +39,18 @@ export interface TorrentProperties {
downTotal: number;
eta: -1 | Duration;
hash: string;
isActive: boolean;
isComplete: boolean;
isHashing: boolean;
isMultiFile: boolean;
isOpen: boolean;
isPrivate: boolean;
message: string;
name: string;
peersConnected: number;
peersTotal: number;
percentComplete: number;
priority: number;
priority: TorrentPriority;
ratio: number;
seedsConnected: number;
seedsTotal: number;
sizeBytes: number;
state: string;
status: Array<TorrentStatus>;
tags: Array<string>;
trackerURIs: Array<string>;

View File

@@ -1,19 +1,18 @@
export enum TorrentContentPriority {
DO_NOT_DOWNLOAD = 0,
NORMAL = 1,
HIGH = 2,
}
export interface TorrentContent {
index: number;
path: string;
filename: string;
percentComplete: number;
priority: number;
priority: TorrentContentPriority;
sizeBytes: number;
}
export interface TorrentContentTree {
files?: Array<TorrentContent>;
directories?: {
[directoryName: string]: TorrentContentTree;
};
}
export interface TorrentContentSelection {
type: 'file' | 'directory';
depth: number;
@@ -24,7 +23,7 @@ export interface TorrentContentSelection {
export interface TorrentContentSelectionTree {
isSelected?: boolean;
files?: {
[fileName: string]: TorrentContent & {isSelected: boolean};
[fileName: string]: TorrentContent & {isSelected?: boolean};
};
directories?: {
[directoryName: string]: TorrentContentSelectionTree;

View File

@@ -1,8 +1,14 @@
export enum TorrentTrackerType {
HTTP = 1,
UDP = 2,
DHT = 3,
}
export interface TorrentTracker {
index: number;
id: string;
url: string;
type: number;
type: TorrentTrackerType;
group: number;
minInterval: number;
normalInterval: number;

View File

@@ -1,11 +1,17 @@
import {DiffAction} from '@shared/constants/diffActionTypes';
export interface TransferSummary {
// Global download rate
downRate: number;
// Download rate limit
downThrottle: number;
// Data downloaded this session
downTotal: number;
// Global upload rate
upRate: number;
// Upload rate limit
upThrottle: number;
// Data uploaded this session
upTotal: number;
}

View File

@@ -1,4 +1,5 @@
import {TorrentProperties} from '../Torrent';
import type {TorrentPriority, TorrentProperties} from '../Torrent';
import type {TorrentContentPriority} from '../TorrentContent';
// POST /api/torrents/add-urls
export interface AddTorrentByURLOptions {
@@ -94,12 +95,8 @@ export interface StopTorrentsOptions {
export interface SetTorrentsPriorityOptions {
// An array of string representing hashes of torrents to operate on
hashes: Array<TorrentProperties['hash']>;
// Number representing priority:
// 0 - DON'T_DOWNLOAD
// 1 - LOW
// 2 - NORMAL
// 3 - HIGH
priority: number;
// Number representing priority
priority: TorrentPriority;
}
// PATCH /api/torrents/tags
@@ -122,9 +119,6 @@ export interface SetTorrentsTrackersOptions {
export interface SetTorrentContentsPropertiesOptions {
// An array of number representing indices of contents of a torrent
indices: Array<number>;
// Number representing priority:
// 0 - DON'T_DOWNLOAD
// 1 - NORMAL
// 2 - HIGH
priority: number;
// Number representing priority
priority: TorrentContentPriority;
}