New: Bulk Grab Releases and Parameter Search

pull/627/head
Qstick 3 years ago
parent f69f96695b
commit 5d32bcf8b9

@ -22,10 +22,12 @@ import {
import {
faArrowCircleLeft as fasArrowCircleLeft,
faArrowCircleRight as fasArrowCircleRight,
faAsterisk as fasAsterisk,
faBackward as fasBackward,
faBan as fasBan,
faBars as fasBars,
faBolt as fasBolt,
faBook as fasBook,
faBookmark as fasBookmark,
faBookReader as fasBookReader,
faBroadcastTower as fasBroadcastTower,
@ -74,6 +76,7 @@ import {
faLock as fasLock,
faMedkit as fasMedkit,
faMinus as fasMinus,
faMusic as fasMusic,
faPause as fasPause,
faPlay as fasPlay,
faPlus as fasPlus,
@ -104,6 +107,7 @@ import {
faTimes as fasTimes,
faTimesCircle as fasTimesCircle,
faTrashAlt as fasTrashAlt,
faTv as fasTv,
faUser as fasUser,
faUserPlus as fasUserPlus,
faVial as fasVial,
@ -121,7 +125,9 @@ export const ADVANCED_SETTINGS = fasCog;
export const ANNOUNCED = fasBullhorn;
export const ARROW_LEFT = fasArrowCircleLeft;
export const ARROW_RIGHT = fasArrowCircleRight;
export const AUDIO = fasMusic;
export const BACKUP = farFileArchive;
export const BOOK = fasBook;
export const BUG = fasBug;
export const CALENDAR = fasCalendarAlt;
export const CALENDAR_O = farCalendar;
@ -158,6 +164,7 @@ export const FILTER = fasFilter;
export const FLAG = fasFlag;
export const FOLDER = farFolder;
export const FOLDER_OPEN = fasFolderOpen;
export const FOOTNOTE = fasAsterisk;
export const GENRE = fasTheaterMasks;
export const GROUP = farObjectGroup;
export const HEALTH = fasMedkit;
@ -220,6 +227,7 @@ export const TAGS = fasTags;
export const TBA = fasQuestionCircle;
export const TEST = fasVial;
export const TRANSLATE = fasLanguage;
export const TV = fasTv;
export const UNGROUP = farObjectUngroup;
export const UNKNOWN = fasQuestion;
export const UNMONITORED = farBookmark;

@ -0,0 +1,35 @@
.groups {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
margin-bottom: 20px;
}
.namingSelectContainer {
display: flex;
justify-content: flex-end;
}
.namingSelect {
composes: select from '~Components/Form/SelectInput.css';
margin-left: 10px;
width: 200px;
}
.footNote {
display: flex;
color: $helpTextColor;
.icon {
margin-top: 3px;
margin-right: 5px;
padding: 2px;
}
code {
padding: 0 1px;
border: 1px solid $borderColor;
background-color: #f7f7f7;
}
}

@ -0,0 +1,270 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FieldSet from 'Components/FieldSet';
import SelectInput from 'Components/Form/SelectInput';
import TextInput from 'Components/Form/TextInput';
import Button from 'Components/Link/Button';
import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import translate from 'Utilities/String/translate';
import QueryParameterOption from './QueryParameterOption';
import styles from './QueryParameterModal.css';
const searchOptions = [
{ key: 'search', value: 'Basic Search' },
{ key: 'tvsearch', value: 'TV Search' },
{ key: 'movie', value: 'Movie Search' },
{ key: 'music', value: 'Audio Search' },
{ key: 'book', value: 'Book Search' }
];
const seriesTokens = [
{ token: '{ImdbId:tt1234567}', example: 'tt12345' },
{ token: '{TvdbId:12345}', example: '12345' },
{ token: '{TvMazeId:12345}', example: '54321' },
{ token: '{Season:00}', example: '01' },
{ token: '{Episode:00}', example: '01' }
];
const movieTokens = [
{ token: '{ImdbId:tt1234567}', example: 'tt12345' },
{ token: '{TmdbId:12345}', example: '12345' },
{ token: '{Year:2000}', example: '2005' }
];
const audioTokens = [
{ token: '{Artist:Some Body}', example: 'Nirvana' },
{ token: '{Album:Some Album}', example: 'Nevermind' },
{ token: '{Label:Some Label}', example: 'Geffen' }
];
const bookTokens = [
{ token: '{Author:Some Author}', example: 'J. R. R. Tolkien' },
{ token: '{Title:Some Book}', example: 'Lord of the Rings' }
];
class QueryParameterModal extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._selectionStart = null;
this._selectionEnd = null;
this.state = {
separator: ' '
};
}
//
// Listeners
onInputSelectionChange = (selectionStart, selectionEnd) => {
this._selectionStart = selectionStart;
this._selectionEnd = selectionEnd;
}
onOptionPress = ({ isFullFilename, tokenValue }) => {
const {
name,
value,
onSearchInputChange
} = this.props;
const selectionStart = this._selectionStart;
const selectionEnd = this._selectionEnd;
if (isFullFilename) {
onSearchInputChange({ name, value: tokenValue });
} else if (selectionStart == null) {
onSearchInputChange({
name,
value: `${value}${tokenValue}`
});
} else {
const start = value.substring(0, selectionStart);
const end = value.substring(selectionEnd);
const newValue = `${start}${tokenValue}${end}`;
onSearchInputChange({ name, value: newValue });
this._selectionStart = newValue.length - 1;
this._selectionEnd = newValue.length - 1;
}
}
onInputChange = ({ name, value }) => {
this.props.onSearchInputChange({ value: '' });
this.props.onInputChange({ name, value });
}
//
// Render
render() {
const {
name,
value,
searchType,
isOpen,
onSearchInputChange,
onModalClose
} = this.props;
const {
separator: tokenSeparator
} = this.state;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('QueryOptions')}
</ModalHeader>
<ModalBody>
<FieldSet legend={translate('SearchType')}>
<div className={styles.groups}>
<SelectInput
className={styles.namingSelect}
name="searchType"
value={searchType}
values={searchOptions}
onChange={this.onInputChange}
/>
</div>
</FieldSet>
{
searchType === 'tvsearch' &&
<FieldSet legend={translate('TvSearch')}>
<div className={styles.groups}>
{
seriesTokens.map(({ token, example }) => {
return (
<QueryParameterOption
key={token}
name={name}
value={value}
token={token}
example={example}
tokenSeparator={tokenSeparator}
onPress={this.onOptionPress}
/>
);
}
)
}
</div>
</FieldSet>
}
{
searchType === 'movie' &&
<FieldSet legend={translate('MovieSearch')}>
<div className={styles.groups}>
{
movieTokens.map(({ token, example }) => {
return (
<QueryParameterOption
key={token}
name={name}
value={value}
token={token}
example={example}
tokenSeparator={tokenSeparator}
onPress={this.onOptionPress}
/>
);
}
)
}
</div>
</FieldSet>
}
{
searchType === 'music' &&
<FieldSet legend={translate('AudioSearch')}>
<div className={styles.groups}>
{
audioTokens.map(({ token, example }) => {
return (
<QueryParameterOption
key={token}
name={name}
value={value}
token={token}
example={example}
tokenSeparator={tokenSeparator}
onPress={this.onOptionPress}
/>
);
}
)
}
</div>
</FieldSet>
}
{
searchType === 'book' &&
<FieldSet legend={translate('BookSearch')}>
<div className={styles.groups}>
{
bookTokens.map(({ token, example }) => {
return (
<QueryParameterOption
key={token}
name={name}
value={value}
token={token}
example={example}
tokenSeparator={tokenSeparator}
onPress={this.onOptionPress}
/>
);
}
)
}
</div>
</FieldSet>
}
</ModalBody>
<ModalFooter>
<TextInput
name={name}
value={value}
onChange={onSearchInputChange}
onSelectionChange={this.onInputSelectionChange}
/>
<Button onPress={onModalClose}>
Close
</Button>
</ModalFooter>
</ModalContent>
</Modal>
);
}
}
QueryParameterModal.propTypes = {
name: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
searchType: PropTypes.string.isRequired,
isOpen: PropTypes.bool.isRequired,
onSearchInputChange: PropTypes.func.isRequired,
onInputChange: PropTypes.func.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default QueryParameterModal;

@ -0,0 +1,66 @@
.option {
display: flex;
align-items: stretch;
flex-wrap: wrap;
margin: 3px;
border: 1px solid $borderColor;
&:hover {
.token {
background-color: #ddd;
}
.example {
background-color: #ccc;
}
}
}
.small {
width: 480px;
}
.large {
width: 100%;
}
.token {
flex: 0 0 50%;
padding: 6px 16px;
background-color: #eee;
font-family: $monoSpaceFontFamily;
}
.example {
display: flex;
align-items: center;
justify-content: space-between;
flex: 0 0 50%;
padding: 6px 16px;
background-color: #ddd;
.footNote {
padding: 2px;
color: #aaa;
}
}
.isFullFilename {
.token,
.example {
flex: 1 0 auto;
}
}
@media only screen and (max-width: $breakpointSmall) {
.option.small {
width: 100%;
}
}
@media only screen and (max-width: $breakpointExtraSmall) {
.token,
.example {
flex: 1 0 auto;
}
}

@ -0,0 +1,83 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import { icons, sizes } from 'Helpers/Props';
import styles from './QueryParameterOption.css';
class QueryParameterOption extends Component {
//
// Listeners
onPress = () => {
const {
token,
tokenSeparator,
isFullFilename,
onPress
} = this.props;
let tokenValue = token;
tokenValue = tokenValue.replace(/ /g, tokenSeparator);
onPress({ isFullFilename, tokenValue });
}
//
// Render
render() {
const {
token,
tokenSeparator,
example,
footNote,
isFullFilename,
size
} = this.props;
return (
<Link
className={classNames(
styles.option,
styles[size],
isFullFilename && styles.isFullFilename
)}
onPress={this.onPress}
>
<div className={styles.token}>
{token.replace(/ /g, tokenSeparator)}
</div>
<div className={styles.example}>
{example.replace(/ /g, tokenSeparator)}
{
footNote !== 0 &&
<Icon className={styles.footNote} name={icons.FOOTNOTE} />
}
</div>
</Link>
);
}
}
QueryParameterOption.propTypes = {
token: PropTypes.string.isRequired,
example: PropTypes.string.isRequired,
footNote: PropTypes.number.isRequired,
tokenSeparator: PropTypes.string.isRequired,
isFullFilename: PropTypes.bool.isRequired,
size: PropTypes.oneOf([sizes.SMALL, sizes.LARGE]),
onPress: PropTypes.func.isRequired
};
QueryParameterOption.defaultProps = {
footNote: 0,
size: sizes.SMALL,
isFullFilename: false
};
export default QueryParameterOption;

@ -31,6 +31,12 @@
height: 35px;
}
.selectedReleasesLabel {
margin-bottom: 3px;
text-align: right;
font-weight: bold;
}
@media only screen and (max-width: $breakpointSmall) {
.inputContainer {
margin-right: 0;
@ -47,8 +53,4 @@
.buttons {
justify-content: space-between;
}
.selectedMovieLabel {
text-align: left;
}
}

@ -1,13 +1,17 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import FormInputButton from 'Components/Form/FormInputButton';
import FormInputGroup from 'Components/Form/FormInputGroup';
import IndexersSelectInputConnector from 'Components/Form/IndexersSelectInputConnector';
import NewznabCategorySelectInputConnector from 'Components/Form/NewznabCategorySelectInputConnector';
import TextInput from 'Components/Form/TextInput';
import Icon from 'Components/Icon';
import keyboardShortcuts from 'Components/keyboardShortcuts';
import SpinnerButton from 'Components/Link/SpinnerButton';
import PageContentFooter from 'Components/Page/PageContentFooter';
import { icons, inputTypes, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import QueryParameterModal from './QueryParameterModal';
import SearchFooterLabel from './SearchFooterLabel';
import styles from './SearchFooter.css';
@ -22,10 +26,14 @@ class SearchFooter extends Component {
const {
defaultIndexerIds,
defaultCategories,
defaultSearchQuery
defaultSearchQuery,
defaultSearchType
} = props;
this.state = {
isQueryParameterModalOpen: false,
queryModalOptions: null,
searchType: defaultSearchType,
searchingReleases: false,
searchQuery: defaultSearchQuery || '',
searchIndexerIds: defaultIndexerIds,
@ -53,12 +61,14 @@ class SearchFooter extends Component {
defaultIndexerIds,
defaultCategories,
defaultSearchQuery,
defaultSearchType,
searchError
} = this.props;
const {
searchIndexerIds,
searchCategories
searchCategories,
searchType
} = this.state;
const newState = {};
@ -67,6 +77,10 @@ class SearchFooter extends Component {
newState.searchQuery = defaultSearchQuery;
}
if (searchType !== defaultSearchType) {
newState.searchType = defaultSearchType;
}
if (searchIndexerIds !== defaultIndexerIds) {
newState.searchIndexerIds = defaultIndexerIds;
}
@ -87,8 +101,21 @@ class SearchFooter extends Component {
//
// Listeners
onQueryParameterModalOpenClick = () => {
this.setState({
queryModalOptions: {
name: 'queryParameters'
},
isQueryParameterModalOpen: true
});
}
onQueryParameterModalClose = () => {
this.setState({ isQueryParameterModalOpen: false });
}
onSearchPress = () => {
this.props.onSearchPress(this.state.searchQuery, this.state.searchIndexerIds, this.state.searchCategories);
this.props.onSearchPress(this.state.searchQuery, this.state.searchIndexerIds, this.state.searchCategories, this.state.searchType);
}
onSearchInputChange = ({ value }) => {
@ -101,36 +128,77 @@ class SearchFooter extends Component {
render() {
const {
isFetching,
isPopulated,
isGrabbing,
hasIndexers,
onInputChange
onInputChange,
onBulkGrabPress,
itemCount,
selectedCount
} = this.props;
const {
searchQuery,
searchIndexerIds,
searchCategories
searchCategories,
isQueryParameterModalOpen,
queryModalOptions,
searchType
} = this.state;
let icon = icons.SEARCH;
switch (searchType) {
case 'book':
icon = icons.BOOK;
break;
case 'tvsearch':
icon = icons.TV;
break;
case 'movie':
icon = icons.FILM;
break;
case 'music':
icon = icons.AUDIO;
break;
default:
icon = icons.SEARCH;
}
let footerLabel = `Search ${searchIndexerIds.length === 0 ? 'all' : searchIndexerIds.length} Indexers`;
if (isPopulated) {
footerLabel = selectedCount === 0 ? `Found ${itemCount} releases` : `Selected ${selectedCount} of ${itemCount} releases`;
}
return (
<PageContentFooter>
<div className={styles.inputContainer}>
<SearchFooterLabel
label={'Query'}
label={translate('Query')}
isSaving={false}
/>
<TextInput
name='searchQuery'
autoFocus={true}
<FormInputGroup
type={inputTypes.TEXT}
name="searchQuery"
value={searchQuery}
isDisabled={isFetching}
buttons={
<FormInputButton onPress={this.onQueryParameterModalOpenClick}>
<Icon
name={icon}
/>
</FormInputButton>}
onChange={this.onSearchInputChange}
onFocus={this.onApikeyFocus}
isDisabled={isFetching}
{...searchQuery}
/>
</div>
<div className={styles.indexerContainer}>
<SearchFooterLabel
label={'Indexers'}
label={translate('Indexers')}
isSaving={false}
/>
@ -144,7 +212,7 @@ class SearchFooter extends Component {
<div className={styles.indexerContainer}>
<SearchFooterLabel
label={'Categories'}
label={translate('Categories')}
isSaving={false}
/>
@ -159,12 +227,26 @@ class SearchFooter extends Component {
<div className={styles.buttonContainer}>
<div className={styles.buttonContainerContent}>
<SearchFooterLabel
label={`Search ${searchIndexerIds.length === 0 ? 'all' : searchIndexerIds.length} Indexers`}
className={styles.selectedReleasesLabel}
label={footerLabel}
isSaving={false}
/>
<div className={styles.buttons}>
{
isPopulated &&
<SpinnerButton
className={styles.searchButton}
kind={kinds.SUCCESS}
isSpinning={isGrabbing}
isDisabled={isFetching || !hasIndexers || selectedCount === 0}
onPress={onBulkGrabPress}
>
{translate('Grab Releases')}
</SpinnerButton>
}
<SpinnerButton
className={styles.searchButton}
isSpinning={isFetching}
@ -176,6 +258,17 @@ class SearchFooter extends Component {
</div>
</div>
</div>
<QueryParameterModal
isOpen={isQueryParameterModalOpen}
{...queryModalOptions}
name='queryParameters'
value={searchQuery}
searchType={searchType}
onSearchInputChange={this.onSearchInputChange}
onInputChange={onInputChange}
onModalClose={this.onQueryParameterModalClose}
/>
</PageContentFooter>
);
}
@ -185,8 +278,14 @@ SearchFooter.propTypes = {
defaultIndexerIds: PropTypes.arrayOf(PropTypes.number).isRequired,
defaultCategories: PropTypes.arrayOf(PropTypes.number).isRequired,
defaultSearchQuery: PropTypes.string.isRequired,
defaultSearchType: PropTypes.string.isRequired,
selectedCount: PropTypes.number.isRequired,
itemCount: PropTypes.number.isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
isGrabbing: PropTypes.bool.isRequired,
onSearchPress: PropTypes.func.isRequired,
onBulkGrabPress: PropTypes.func.isRequired,
hasIndexers: PropTypes.bool.isRequired,
onInputChange: PropTypes.func.isRequired,
searchError: PropTypes.object,

@ -12,13 +12,15 @@ function createMapStateToProps() {
const {
searchQuery: defaultSearchQuery,
searchIndexerIds: defaultIndexerIds,
searchCategories: defaultCategories
searchCategories: defaultCategories,
searchType: defaultSearchType
} = releases.defaults;
return {
defaultSearchQuery,
defaultIndexerIds,
defaultCategories
defaultCategories,
defaultSearchType
};
}
);

@ -18,6 +18,9 @@ import * as keyCodes from 'Utilities/Constants/keyCodes';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder';
import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import SearchIndexFilterMenu from './Menus/SearchIndexFilterMenu';
import SearchIndexSortMenu from './Menus/SearchIndexSortMenu';
import NoSearchResults from './NoSearchResults';
@ -44,12 +47,16 @@ class SearchIndex extends Component {
isAddIndexerModalOpen: false,
isEditIndexerModalOpen: false,
searchType: null,
lastToggled: null
lastToggled: null,
allSelected: false,
allUnselected: false,
selectedState: {}
};
}
componentDidMount() {
this.setJumpBarItems();
this.setSelectedState();
window.addEventListener('keyup', this.onKeyUp);
}
@ -66,6 +73,7 @@ class SearchIndex extends Component {
hasDifferentItemsOrOrder(prevProps.items, items)
) {
this.setJumpBarItems();
this.setSelectedState();
}
if (this.state.jumpToCharacter != null) {
@ -80,6 +88,48 @@ class SearchIndex extends Component {
this.setState({ scroller: ref });
}
getSelectedIds = () => {
if (this.state.allUnselected) {
return [];
}
return getSelectedIds(this.state.selectedState, { parseIds: false });
}
setSelectedState() {
const {
items
} = this.props;
const {
selectedState
} = this.state;
const newSelectedState = {};
items.forEach((release) => {
const isItemSelected = selectedState[release.guid];
if (isItemSelected) {
newSelectedState[release.guid] = isItemSelected;
} else {
newSelectedState[release.guid] = false;
}
});
const selectedCount = getSelectedIds(newSelectedState).length;
const newStateCount = Object.keys(newSelectedState).length;
let isAllSelected = false;
let isAllUnselected = false;
if (selectedCount === 0) {
isAllUnselected = true;
} else if (selectedCount === newStateCount) {
isAllSelected = true;
}
this.setState({ selectedState: newSelectedState, allSelected: isAllSelected, allUnselected: isAllUnselected });
}
setJumpBarItems() {
const {
items,
@ -146,8 +196,14 @@ class SearchIndex extends Component {
this.setState({ jumpToCharacter });
}
onSearchPress = (query, indexerIds, categories) => {
this.props.onSearchPress({ query, indexerIds, categories });
onSearchPress = (query, indexerIds, categories, type) => {
this.props.onSearchPress({ query, indexerIds, categories, type });
}
onBulkGrabPress = () => {
const selectedIds = this.getSelectedIds();
const result = _.filter(this.props.items, (release) => _.indexOf(selectedIds, release.guid) !== -1);
this.props.onBulkGrabPress(result);
}
onKeyUp = (event) => {
@ -162,6 +218,20 @@ class SearchIndex extends Component {
}
}
onSelectAllChange = ({ value }) => {
this.setState(selectAll(this.state.selectedState, value));
}
onSelectAllPress = () => {
this.onSelectAllChange({ value: !this.state.allSelected });
}
onSelectedChange = ({ id, value, shiftKey = false }) => {
this.setState((state) => {
return toggleSelected(state, this.props.items, id, value, shiftKey);
});
}
//
// Render
@ -169,7 +239,9 @@ class SearchIndex extends Component {
const {
isFetching,
isPopulated,
isGrabbing,
error,
grabError,
totalItems,
items,
columns,
@ -190,9 +262,14 @@ class SearchIndex extends Component {
jumpBarItems,
isAddIndexerModalOpen,
isEditIndexerModalOpen,
jumpToCharacter
jumpToCharacter,
selectedState,
allSelected,
allUnselected
} = this.state;
const selectedIndexerIds = this.getSelectedIds();
const ViewComponent = getViewComponent();
const isLoaded = !!(!error && isPopulated && items.length && scroller);
const hasNoIndexer = !totalItems;
@ -263,6 +340,11 @@ class SearchIndex extends Component {
sortDirection={sortDirection}
columns={columns}
jumpToCharacter={jumpToCharacter}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectedChange={this.onSelectedChange}
onSelectAllChange={this.onSelectAllChange}
selectedState={selectedState}
{...otherProps}
/>
</div>
@ -293,8 +375,14 @@ class SearchIndex extends Component {
<SearchFooterConnector
isFetching={isFetching}
isPopulated={isPopulated}
isGrabbing={isGrabbing}
grabError={grabError}
selectedCount={selectedIndexerIds.length}
itemCount={items.length}
hasIndexers={hasIndexers}
onSearchPress={this.onSearchPress}
onBulkGrabPress={this.onBulkGrabPress}
/>
<AddIndexerModal
@ -314,7 +402,9 @@ class SearchIndex extends Component {
SearchIndex.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
isGrabbing: PropTypes.bool.isRequired,
error: PropTypes.object,
grabError: PropTypes.object,
totalItems: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
@ -327,6 +417,7 @@ SearchIndex.propTypes = {
onSortSelect: PropTypes.func.isRequired,
onFilterSelect: PropTypes.func.isRequired,
onSearchPress: PropTypes.func.isRequired,
onBulkGrabPress: PropTypes.func.isRequired,
onScroll: PropTypes.func.isRequired,
hasIndexers: PropTypes.bool.isRequired
};

@ -3,7 +3,7 @@ import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import withScrollPosition from 'Components/withScrollPosition';
import { cancelFetchReleases, clearReleases, fetchReleases, setReleasesFilter, setReleasesSort, setReleasesTableOption } from 'Store/Actions/releaseActions';
import { bulkGrabReleases, cancelFetchReleases, clearReleases, fetchReleases, setReleasesFilter, setReleasesSort, setReleasesTableOption } from 'Store/Actions/releaseActions';
import scrollPositions from 'Store/scrollPositions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createReleaseClientSideCollectionItemsSelector from 'Store/Selectors/createReleaseClientSideCollectionItemsSelector';
@ -46,6 +46,10 @@ function createMapDispatchToProps(dispatch, props) {
dispatch(fetchReleases(payload));
},
onBulkGrabPress(payload) {
dispatch(bulkGrabReleases(payload));
},
dispatchCancelFetchReleases() {
dispatch(cancelFetchReleases());
},
@ -83,6 +87,7 @@ class SearchIndexConnector extends Component {
SearchIndexConnector.propTypes = {
isSmallScreen: PropTypes.bool.isRequired,
onSearchPress: PropTypes.func.isRequired,
onBulkGrabPress: PropTypes.func.isRequired,
dispatchCancelFetchReleases: PropTypes.func.isRequired,
dispatchClearReleases: PropTypes.func.isRequired,
items: PropTypes.arrayOf(PropTypes.object)

@ -10,18 +10,13 @@
flex: 4 0 110px;
}
.indexer,
.category {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 110px;
}
.indexer {
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
flex: 0 0 85px;
}
.age,
.size,
.files,

@ -4,6 +4,7 @@ import IconButton from 'Components/Link/IconButton';
import TableOptionsModal from 'Components/Table/TableOptions/TableOptionsModal';
import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell';
import { icons } from 'Helpers/Props';
import styles from './SearchIndexHeader.css';
@ -38,6 +39,9 @@ class SearchIndexHeader extends Component {
const {
columns,
onTableOptionChange,
allSelected,
allUnselected,
onSelectAllChange,
...otherProps
} = this.props;
@ -56,6 +60,17 @@ class SearchIndexHeader extends Component {
return null;
}
if (name === 'select') {
return (
<VirtualTableSelectAllHeaderCell
key={name}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
/>
);
}
if (name === 'actions') {
return (
<VirtualTableHeaderCell
@ -100,6 +115,9 @@ class SearchIndexHeader extends Component {
SearchIndexHeader.propTypes = {
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
allSelected: PropTypes.bool.isRequired,
allUnselected: PropTypes.bool.isRequired,
onSelectAllChange: PropTypes.func.isRequired,
onTableOptionChange: PropTypes.func.isRequired
};

@ -17,18 +17,13 @@
flex: 4 0 110px;
}
.indexer,
.category {
composes: cell;
flex: 0 0 110px;
}
.indexer {
composes: cell;
flex: 0 0 85px;
}
.age,
.size,
.files,

@ -5,6 +5,7 @@ import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
import VirtualTableSelectCell from 'Components/Table/Cells/VirtualTableSelectCell';
import Popover from 'Components/Tooltip/Popover';
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
import formatDateTime from 'Utilities/Date/formatDateTime';
@ -75,6 +76,7 @@ class SearchIndexRow extends Component {
render() {
const {
guid,
protocol,
categories,
age,
@ -96,7 +98,9 @@ class SearchIndexRow extends Component {
isGrabbed,
grabError,
longDateFormat,
timeFormat
timeFormat,
isSelected,
onSelectedChange
} = this.props;
return (
@ -111,6 +115,19 @@ class SearchIndexRow extends Component {
return null;
}
if (column.name === 'select') {
return (
<VirtualTableSelectCell
inputClassName={styles.checkInput}
id={guid}
key={column.name}
isSelected={isSelected}
isDisabled={false}
onSelectedChange={onSelectedChange}
/>
);
}
if (column.name === 'protocol') {
return (
<VirtualTableRowCell
@ -322,7 +339,9 @@ SearchIndexRow.propTypes = {
isGrabbed: PropTypes.bool.isRequired,
grabError: PropTypes.string,
longDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired
timeFormat: PropTypes.string.isRequired,
isSelected: PropTypes.bool,
onSelectedChange: PropTypes.func.isRequired
};
SearchIndexRow.defaultProps = {

@ -49,6 +49,8 @@ class SearchIndexTable extends Component {
columns,
longDateFormat,
timeFormat,
selectedState,
onSelectedChange,
onGrabPress
} = this.props;
@ -64,6 +66,8 @@ class SearchIndexTable extends Component {
component={SearchIndexRow}
columns={columns}
guid={release.guid}
isSelected={selectedState[release.guid]}
onSelectedChange={onSelectedChange}
longDateFormat={longDateFormat}
timeFormat={timeFormat}
onGrabPress={onGrabPress}
@ -83,7 +87,11 @@ class SearchIndexTable extends Component {
sortDirection,
isSmallScreen,
onSortPress,
scroller
scroller,
allSelected,
allUnselected,
onSelectAllChange,
selectedState
} = this.props;
return (
@ -102,8 +110,12 @@ class SearchIndexTable extends Component {
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectAllChange={onSelectAllChange}
/>
}
selectedState={selectedState}
columns={columns}
/>
);
@ -121,7 +133,12 @@ SearchIndexTable.propTypes = {
longDateFormat: PropTypes.string.isRequired,
timeFormat: PropTypes.string.isRequired,
onSortPress: PropTypes.func.isRequired,
onGrabPress: PropTypes.func.isRequired
onGrabPress: PropTypes.func.isRequired,
allSelected: PropTypes.bool.isRequired,
allUnselected: PropTypes.bool.isRequired,
selectedState: PropTypes.object.isRequired,
onSelectedChange: PropTypes.func.isRequired,
onSelectAllChange: PropTypes.func.isRequired
};
export default SearchIndexTable;

@ -1,10 +1,12 @@
import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import getSectionState from 'Utilities/State/getSectionState';
import updateSectionState from 'Utilities/State/updateSectionState';
import translate from 'Utilities/String/translate';
import { set } from './baseActions';
import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions';
import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer';
@ -24,7 +26,9 @@ let abortCurrentRequest = null;
export const defaultState = {
isFetching: false,
isPopulated: false,
isGrabbing: false,
error: null,
grabError: null,
items: [],
sortKey: 'title',
sortDirection: sortDirections.ASCENDING,
@ -32,12 +36,21 @@ export const defaultState = {
secondarySortDirection: sortDirections.ASCENDING,
defaults: {
searchType: 'basic',
searchQuery: '',
searchIndexerIds: [],
searchCategories: []
},
columns: [
{
name: 'select',
columnLabel: 'Select',
isSortable: false,
isVisible: true,
isModifiable: false,
isHidden: true
},
{
name: 'protocol',
label: translate('Protocol'),
@ -201,6 +214,8 @@ export const defaultState = {
};
export const persistState = [
'releases.sortKey',
'releases.sortDirection',
'releases.customFilters',
'releases.selectedFilterKey',
'releases.columns'
@ -214,6 +229,7 @@ export const CANCEL_FETCH_RELEASES = 'releases/cancelFetchReleases';
export const SET_RELEASES_SORT = 'releases/setReleasesSort';
export const CLEAR_RELEASES = 'releases/clearReleases';
export const GRAB_RELEASE = 'releases/grabRelease';
export const BULK_GRAB_RELEASES = 'release/bulkGrabReleases';
export const UPDATE_RELEASE = 'releases/updateRelease';
export const SET_RELEASES_FILTER = 'releases/setReleasesFilter';
export const SET_RELEASES_TABLE_OPTION = 'releases/setReleasesTableOption';
@ -227,6 +243,7 @@ export const cancelFetchReleases = createThunk(CANCEL_FETCH_RELEASES);
export const setReleasesSort = createAction(SET_RELEASES_SORT);
export const clearReleases = createAction(CLEAR_RELEASES);
export const grabRelease = createThunk(GRAB_RELEASE);
export const bulkGrabReleases = createThunk(BULK_GRAB_RELEASES);
export const updateRelease = createAction(UPDATE_RELEASE);
export const setReleasesFilter = createAction(SET_RELEASES_FILTER);
export const setReleasesTableOption = createAction(SET_RELEASES_TABLE_OPTION);
@ -285,6 +302,47 @@ export const actionHandlers = handleThunks({
grabError
}));
});
},
[BULK_GRAB_RELEASES]: function(getState, payload, dispatch) {
dispatch(set({
section,
isGrabbing: true
}));
console.log(payload);
const promise = createAjaxRequest({
url: '/search/bulk',
method: 'POST',
contentType: 'application/json',
data: JSON.stringify(payload)
}).request;
promise.done((data) => {
dispatch(batchActions([
...data.map((release) => {
return updateRelease({
isGrabbing: false,
isGrabbed: true,
grabError: null
});
}),
set({
section,
isGrabbing: false
})
]));
});
promise.fail((xhr) => {
dispatch(set({
section,
isGrabbing: false,
grabError: xhr
}));
});
}
});

@ -9,12 +9,14 @@ function createUnoptimizedSelector(uiSection) {
const items = releases.items.map((s) => {
const {
guid,
title
title,
indexerId
} = s;
return {
guid,
sortTitle: title
sortTitle: title,
indexerId
};
});

@ -1,7 +1,14 @@
using System.Text.RegularExpressions;
namespace NzbDrone.Core.IndexerSearch
{
public class NewznabRequest
{
private static readonly Regex TvRegex = new Regex(@"\{((?:imdbid\:)(?<imdbid>[^{]+)|(?:tvdbid\:)(?<tvdbid>[^{]+)|(?:season\:)(?<season>[^{]+)|(?:episode\:)(?<episode>[^{]+))\}", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex MovieRegex = new Regex(@"\{((?:imdbid\:)(?<imdbid>[^{]+)|(?:tmdbid\:)(?<tmdbid>[^{]+))\}", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex MusicRegex = new Regex(@"\{((?:artist\:)(?<artist>[^{]+)|(?:album\:)(?<album>[^{]+)|(?:label\:)(?<label>[^{]+))\}", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex BookRegex = new Regex(@"\{((?:author\:)(?<author>[^{]+)|(?:title\:)(?<title>[^{]+))\}", RegexOptions.Compiled | RegexOptions.IgnoreCase);
public string t { get; set; }
public string q { get; set; }
public string cat { get; set; }
@ -28,5 +35,103 @@ namespace NzbDrone.Core.IndexerSearch
public string source { get; set; }
public string host { get; set; }
public string server { get; set; }
public void QueryToParams()
{
if (t == "tvsearch")
{
var matches = TvRegex.Matches(q);
foreach (Match match in matches)
{
if (match.Groups["tvdbid"].Success)
{
tvdbid = int.TryParse(match.Groups["tvdbid"].Value, out var tvdb) ? tvdb : null;
}
if (match.Groups["season"].Success)
{
season = int.TryParse(match.Groups["season"].Value, out var seasonParsed) ? seasonParsed : null;
}
if (match.Groups["imdbid"].Success)
{
imdbid = match.Groups["imdbid"].Value;
}
if (match.Groups["episode"].Success)
{
ep = match.Groups["episode"].Value;
}
q = q.Replace(match.Value, "");
}
}
if (t == "movie")
{
var matches = MovieRegex.Matches(q);
foreach (Match match in matches)
{
if (match.Groups["tmdbid"].Success)
{
tmdbid = int.TryParse(match.Groups["tmdbid"].Value, out var tmdb) ? tmdb : null;
}
if (match.Groups["imdbid"].Success)
{
imdbid = match.Groups["imdbid"].Value;
}
q = q.Replace(match.Value, "").Trim();
}
}
if (t == "music")
{
var matches = MusicRegex.Matches(q);
foreach (Match match in matches)
{
if (match.Groups["artist"].Success)
{
artist = match.Groups["artist"].Value;
}
if (match.Groups["album"].Success)
{
album = match.Groups["album"].Value;
}
if (match.Groups["label"].Success)
{
label = match.Groups["label"].Value;
}
q = q.Replace(match.Value, "").Trim();
}
}
if (t == "book")
{
var matches = BookRegex.Matches(q);
foreach (Match match in matches)
{
if (match.Groups["author"].Success)
{
author = match.Groups["author"].Value;
}
if (match.Groups["title"].Success)
{
title = match.Groups["title"].Value;
}
q = q.Replace(match.Value, "").Trim();
}
}
}
}
}

@ -39,6 +39,7 @@
"Apps": "Apps",
"AppSettingsSummary": "Applications and settings to configure how Prowlarr interacts with your PVR programs",
"AreYouSureYouWantToResetYourAPIKey": "Are you sure you want to reset your API Key?",
"AudioSearch": "Audio Search",
"Auth": "Auth",
"Authentication": "Authentication",
"AuthenticationMethodHelpText": "Require Username and Password to access Prowlarr",
@ -53,6 +54,7 @@
"BeforeUpdate": "Before update",
"BindAddress": "Bind Address",
"BindAddressHelpText": "Valid IP4 address or '*' for all interfaces",
"BookSearch": "Book Search",
"Branch": "Branch",
"BranchUpdate": "Branch to use to update Prowlarr",
"BranchUpdateMechanism": "Branch used by external update mechanism",
@ -239,6 +241,7 @@
"MovieIndexScrollBottom": "Movie Index: Scroll Bottom",
"MovieIndexScrollTop": "Movie Index: Scroll Top",
"Movies": "Movies",
"MovieSearch": "Movie Search",
"Name": "Name",
"NetCore": ".NET",
"New": "New",
@ -295,6 +298,7 @@
"QualityDefinitions": "Quality Definitions",
"QualitySettings": "Quality Settings",
"Query": "Query",
"QueryOptions": "Query Options",
"Queue": "Queue",
"ReadTheWikiForMoreInformation": "Read the Wiki for more information",
"Reddit": "Reddit",
@ -329,6 +333,7 @@
"ScriptPath": "Script Path",
"Search": "Search",
"SearchIndexers": "Search Indexers",
"SearchType": "Search Type",
"Security": "Security",
"Seeders": "Seeders",
"SelectAll": "Select All",
@ -394,6 +399,7 @@
"Tomorrow": "Tomorrow",
"Torrent": "Torrent",
"Torrents": "Torrents",
"TvSearch": "TV Search",
"Type": "Type",
"UI": "UI",
"UILanguage": "UI Language",

@ -52,7 +52,7 @@ namespace Prowlarr.Api.V1.Search
}
[HttpPost]
public ActionResult<SearchResource> Create(SearchResource release)
public ActionResult<SearchResource> GrabRelease(SearchResource release)
{
ValidateResource(release);
@ -75,32 +75,67 @@ namespace Prowlarr.Api.V1.Search
return Ok(release);
}
[HttpPost("bulk")]
public ActionResult<SearchResource> GrabReleases(List<SearchResource> releases)
{
var source = UserAgentParser.ParseSource(Request.Headers["User-Agent"]);
var host = Request.GetHostName();
var groupedReleases = releases.GroupBy(r => r.IndexerId);
foreach (var indexerReleases in groupedReleases)
{
var indexerDef = _indexerFactory.Get(indexerReleases.Key);
foreach (var release in indexerReleases)
{
ValidateResource(release);
var releaseInfo = _remoteReleaseCache.Find(GetCacheKey(release));
try
{
_downloadService.SendReportToClient(releaseInfo, source, host, indexerDef.Redirect);
}
catch (ReleaseDownloadException ex)
{
_logger.Error(ex, "Getting release from indexer failed");
}
}
}
return Ok(releases);
}
[HttpGet]
public Task<List<SearchResource>> GetAll(string query, [FromQuery] List<int> indexerIds, [FromQuery] List<int> categories)
public Task<List<SearchResource>> GetAll(string query, [FromQuery] List<int> indexerIds, [FromQuery] List<int> categories, [FromQuery] string type = "search")
{
if (indexerIds.Any())
{
return GetSearchReleases(query, indexerIds, categories);
return GetSearchReleases(query, type, indexerIds, categories);
}
else
{
return GetSearchReleases(query, null, categories);
return GetSearchReleases(query, type, null, categories);
}
}
private async Task<List<SearchResource>> GetSearchReleases(string query, List<int> indexerIds, List<int> categories)
private async Task<List<SearchResource>> GetSearchReleases(string query, string type, List<int> indexerIds, List<int> categories)
{
try
{
var request = new NewznabRequest
{
q = query, source = "Prowlarr",
t = "search",
q = query,
t = type,
source = "Prowlarr",
cat = string.Join(",", categories),
server = Request.GetServerUrl(),
host = Request.GetHostName()
};
request.QueryToParams();
var result = await _nzbSearhService.Search(request, indexerIds, true);
var decisions = result.Releases;

Loading…
Cancel
Save