mirror of
https://github.com/zoriya/flood.git
synced 2025-12-05 23:06:20 +00:00
api: torrents: simplify contents API by returning an Array of TorrentContent
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
141
client/src/javascript/util/selectionTree.ts
Normal file
141
client/src/javascript/util/selectionTree.ts
Normal 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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ const torrentContentMethodCallConfigs = {
|
||||
},
|
||||
priority: {
|
||||
methodCall: 'f.priority=',
|
||||
transformValue: stringTransformer,
|
||||
transformValue: numberTransformer,
|
||||
},
|
||||
sizeBytes: {
|
||||
methodCall: 'f.size_bytes=',
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user