New: Bulk Grab Releases and Parameter Search

This commit is contained in:
Qstick
2021-11-20 02:52:26 -06:00
parent f69f96695b
commit 5d32bcf8b9
20 changed files with 961 additions and 50 deletions

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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,

View File

@@ -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
};
}
);

View File

@@ -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
};

View File

@@ -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)

View File

@@ -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,

View File

@@ -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
};

View File

@@ -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,

View File

@@ -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 = {

View File

@@ -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;

View File

@@ -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
}));
});
}
});

View File

@@ -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
};
});

View File

@@ -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();
}
}
}
}
}

View File

@@ -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",

View File

@@ -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);
}
[HttpGet]
public Task<List<SearchResource>> GetAll(string query, [FromQuery] List<int> indexerIds, [FromQuery] List<int> categories)
[HttpPost("bulk")]
public ActionResult<SearchResource> GrabReleases(List<SearchResource> releases)
{
if (indexerIds.Any())
var source = UserAgentParser.ParseSource(Request.Headers["User-Agent"]);
var host = Request.GetHostName();
var groupedReleases = releases.GroupBy(r => r.IndexerId);
foreach (var indexerReleases in groupedReleases)
{
return GetSearchReleases(query, indexerIds, categories);
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);
}
else
catch (ReleaseDownloadException ex)
{
return GetSearchReleases(query, null, categories);
_logger.Error(ex, "Getting release from indexer failed");
}
}
}
private async Task<List<SearchResource>> GetSearchReleases(string query, List<int> indexerIds, List<int> categories)
return Ok(releases);
}
[HttpGet]
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, type, indexerIds, categories);
}
else
{
return GetSearchReleases(query, type, null, 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;