New: Bulk Grab Releases and Parameter Search

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

@ -22,10 +22,12 @@ import {
import { import {
faArrowCircleLeft as fasArrowCircleLeft, faArrowCircleLeft as fasArrowCircleLeft,
faArrowCircleRight as fasArrowCircleRight, faArrowCircleRight as fasArrowCircleRight,
faAsterisk as fasAsterisk,
faBackward as fasBackward, faBackward as fasBackward,
faBan as fasBan, faBan as fasBan,
faBars as fasBars, faBars as fasBars,
faBolt as fasBolt, faBolt as fasBolt,
faBook as fasBook,
faBookmark as fasBookmark, faBookmark as fasBookmark,
faBookReader as fasBookReader, faBookReader as fasBookReader,
faBroadcastTower as fasBroadcastTower, faBroadcastTower as fasBroadcastTower,
@ -74,6 +76,7 @@ import {
faLock as fasLock, faLock as fasLock,
faMedkit as fasMedkit, faMedkit as fasMedkit,
faMinus as fasMinus, faMinus as fasMinus,
faMusic as fasMusic,
faPause as fasPause, faPause as fasPause,
faPlay as fasPlay, faPlay as fasPlay,
faPlus as fasPlus, faPlus as fasPlus,
@ -104,6 +107,7 @@ import {
faTimes as fasTimes, faTimes as fasTimes,
faTimesCircle as fasTimesCircle, faTimesCircle as fasTimesCircle,
faTrashAlt as fasTrashAlt, faTrashAlt as fasTrashAlt,
faTv as fasTv,
faUser as fasUser, faUser as fasUser,
faUserPlus as fasUserPlus, faUserPlus as fasUserPlus,
faVial as fasVial, faVial as fasVial,
@ -121,7 +125,9 @@ export const ADVANCED_SETTINGS = fasCog;
export const ANNOUNCED = fasBullhorn; export const ANNOUNCED = fasBullhorn;
export const ARROW_LEFT = fasArrowCircleLeft; export const ARROW_LEFT = fasArrowCircleLeft;
export const ARROW_RIGHT = fasArrowCircleRight; export const ARROW_RIGHT = fasArrowCircleRight;
export const AUDIO = fasMusic;
export const BACKUP = farFileArchive; export const BACKUP = farFileArchive;
export const BOOK = fasBook;
export const BUG = fasBug; export const BUG = fasBug;
export const CALENDAR = fasCalendarAlt; export const CALENDAR = fasCalendarAlt;
export const CALENDAR_O = farCalendar; export const CALENDAR_O = farCalendar;
@ -158,6 +164,7 @@ export const FILTER = fasFilter;
export const FLAG = fasFlag; export const FLAG = fasFlag;
export const FOLDER = farFolder; export const FOLDER = farFolder;
export const FOLDER_OPEN = fasFolderOpen; export const FOLDER_OPEN = fasFolderOpen;
export const FOOTNOTE = fasAsterisk;
export const GENRE = fasTheaterMasks; export const GENRE = fasTheaterMasks;
export const GROUP = farObjectGroup; export const GROUP = farObjectGroup;
export const HEALTH = fasMedkit; export const HEALTH = fasMedkit;
@ -220,6 +227,7 @@ export const TAGS = fasTags;
export const TBA = fasQuestionCircle; export const TBA = fasQuestionCircle;
export const TEST = fasVial; export const TEST = fasVial;
export const TRANSLATE = fasLanguage; export const TRANSLATE = fasLanguage;
export const TV = fasTv;
export const UNGROUP = farObjectUngroup; export const UNGROUP = farObjectUngroup;
export const UNKNOWN = fasQuestion; export const UNKNOWN = fasQuestion;
export const UNMONITORED = farBookmark; 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; height: 35px;
} }
.selectedReleasesLabel {
margin-bottom: 3px;
text-align: right;
font-weight: bold;
}
@media only screen and (max-width: $breakpointSmall) { @media only screen and (max-width: $breakpointSmall) {
.inputContainer { .inputContainer {
margin-right: 0; margin-right: 0;
@ -47,8 +53,4 @@
.buttons { .buttons {
justify-content: space-between; justify-content: space-between;
} }
.selectedMovieLabel {
text-align: left;
}
} }

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

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

@ -18,6 +18,9 @@ import * as keyCodes from 'Utilities/Constants/keyCodes';
import getErrorMessage from 'Utilities/Object/getErrorMessage'; import getErrorMessage from 'Utilities/Object/getErrorMessage';
import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder';
import translate from 'Utilities/String/translate'; 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 SearchIndexFilterMenu from './Menus/SearchIndexFilterMenu';
import SearchIndexSortMenu from './Menus/SearchIndexSortMenu'; import SearchIndexSortMenu from './Menus/SearchIndexSortMenu';
import NoSearchResults from './NoSearchResults'; import NoSearchResults from './NoSearchResults';
@ -44,12 +47,16 @@ class SearchIndex extends Component {
isAddIndexerModalOpen: false, isAddIndexerModalOpen: false,
isEditIndexerModalOpen: false, isEditIndexerModalOpen: false,
searchType: null, searchType: null,
lastToggled: null lastToggled: null,
allSelected: false,
allUnselected: false,
selectedState: {}
}; };
} }
componentDidMount() { componentDidMount() {
this.setJumpBarItems(); this.setJumpBarItems();
this.setSelectedState();
window.addEventListener('keyup', this.onKeyUp); window.addEventListener('keyup', this.onKeyUp);
} }
@ -66,6 +73,7 @@ class SearchIndex extends Component {
hasDifferentItemsOrOrder(prevProps.items, items) hasDifferentItemsOrOrder(prevProps.items, items)
) { ) {
this.setJumpBarItems(); this.setJumpBarItems();
this.setSelectedState();
} }
if (this.state.jumpToCharacter != null) { if (this.state.jumpToCharacter != null) {
@ -80,6 +88,48 @@ class SearchIndex extends Component {
this.setState({ scroller: ref }); 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() { setJumpBarItems() {
const { const {
items, items,
@ -146,8 +196,14 @@ class SearchIndex extends Component {
this.setState({ jumpToCharacter }); this.setState({ jumpToCharacter });
} }
onSearchPress = (query, indexerIds, categories) => { onSearchPress = (query, indexerIds, categories, type) => {
this.props.onSearchPress({ query, indexerIds, categories }); 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) => { 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 // Render
@ -169,7 +239,9 @@ class SearchIndex extends Component {
const { const {
isFetching, isFetching,
isPopulated, isPopulated,
isGrabbing,
error, error,
grabError,
totalItems, totalItems,
items, items,
columns, columns,
@ -190,9 +262,14 @@ class SearchIndex extends Component {
jumpBarItems, jumpBarItems,
isAddIndexerModalOpen, isAddIndexerModalOpen,
isEditIndexerModalOpen, isEditIndexerModalOpen,
jumpToCharacter jumpToCharacter,
selectedState,
allSelected,
allUnselected
} = this.state; } = this.state;
const selectedIndexerIds = this.getSelectedIds();
const ViewComponent = getViewComponent(); const ViewComponent = getViewComponent();
const isLoaded = !!(!error && isPopulated && items.length && scroller); const isLoaded = !!(!error && isPopulated && items.length && scroller);
const hasNoIndexer = !totalItems; const hasNoIndexer = !totalItems;
@ -263,6 +340,11 @@ class SearchIndex extends Component {
sortDirection={sortDirection} sortDirection={sortDirection}
columns={columns} columns={columns}
jumpToCharacter={jumpToCharacter} jumpToCharacter={jumpToCharacter}
allSelected={allSelected}
allUnselected={allUnselected}
onSelectedChange={this.onSelectedChange}
onSelectAllChange={this.onSelectAllChange}
selectedState={selectedState}
{...otherProps} {...otherProps}
/> />
</div> </div>
@ -293,8 +375,14 @@ class SearchIndex extends Component {
<SearchFooterConnector <SearchFooterConnector
isFetching={isFetching} isFetching={isFetching}
isPopulated={isPopulated}
isGrabbing={isGrabbing}
grabError={grabError}
selectedCount={selectedIndexerIds.length}
itemCount={items.length}
hasIndexers={hasIndexers} hasIndexers={hasIndexers}
onSearchPress={this.onSearchPress} onSearchPress={this.onSearchPress}
onBulkGrabPress={this.onBulkGrabPress}
/> />
<AddIndexerModal <AddIndexerModal
@ -314,7 +402,9 @@ class SearchIndex extends Component {
SearchIndex.propTypes = { SearchIndex.propTypes = {
isFetching: PropTypes.bool.isRequired, isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired, isPopulated: PropTypes.bool.isRequired,
isGrabbing: PropTypes.bool.isRequired,
error: PropTypes.object, error: PropTypes.object,
grabError: PropTypes.object,
totalItems: PropTypes.number.isRequired, totalItems: PropTypes.number.isRequired,
items: PropTypes.arrayOf(PropTypes.object).isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired,
@ -327,6 +417,7 @@ SearchIndex.propTypes = {
onSortSelect: PropTypes.func.isRequired, onSortSelect: PropTypes.func.isRequired,
onFilterSelect: PropTypes.func.isRequired, onFilterSelect: PropTypes.func.isRequired,
onSearchPress: PropTypes.func.isRequired, onSearchPress: PropTypes.func.isRequired,
onBulkGrabPress: PropTypes.func.isRequired,
onScroll: PropTypes.func.isRequired, onScroll: PropTypes.func.isRequired,
hasIndexers: PropTypes.bool.isRequired hasIndexers: PropTypes.bool.isRequired
}; };

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

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

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

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

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

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

@ -1,10 +1,12 @@
import { createAction } from 'redux-actions'; import { createAction } from 'redux-actions';
import { batchActions } from 'redux-batched-actions';
import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props';
import { createThunk, handleThunks } from 'Store/thunks'; import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest'; import createAjaxRequest from 'Utilities/createAjaxRequest';
import getSectionState from 'Utilities/State/getSectionState'; import getSectionState from 'Utilities/State/getSectionState';
import updateSectionState from 'Utilities/State/updateSectionState'; import updateSectionState from 'Utilities/State/updateSectionState';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import { set } from './baseActions';
import createFetchHandler from './Creators/createFetchHandler'; import createFetchHandler from './Creators/createFetchHandler';
import createHandleActions from './Creators/createHandleActions'; import createHandleActions from './Creators/createHandleActions';
import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer';
@ -24,7 +26,9 @@ let abortCurrentRequest = null;
export const defaultState = { export const defaultState = {
isFetching: false, isFetching: false,
isPopulated: false, isPopulated: false,
isGrabbing: false,
error: null, error: null,
grabError: null,
items: [], items: [],
sortKey: 'title', sortKey: 'title',
sortDirection: sortDirections.ASCENDING, sortDirection: sortDirections.ASCENDING,
@ -32,12 +36,21 @@ export const defaultState = {
secondarySortDirection: sortDirections.ASCENDING, secondarySortDirection: sortDirections.ASCENDING,
defaults: { defaults: {
searchType: 'basic',
searchQuery: '', searchQuery: '',
searchIndexerIds: [], searchIndexerIds: [],
searchCategories: [] searchCategories: []
}, },
columns: [ columns: [
{
name: 'select',
columnLabel: 'Select',
isSortable: false,
isVisible: true,
isModifiable: false,
isHidden: true
},
{ {
name: 'protocol', name: 'protocol',
label: translate('Protocol'), label: translate('Protocol'),
@ -201,6 +214,8 @@ export const defaultState = {
}; };
export const persistState = [ export const persistState = [
'releases.sortKey',
'releases.sortDirection',
'releases.customFilters', 'releases.customFilters',
'releases.selectedFilterKey', 'releases.selectedFilterKey',
'releases.columns' 'releases.columns'
@ -214,6 +229,7 @@ export const CANCEL_FETCH_RELEASES = 'releases/cancelFetchReleases';
export const SET_RELEASES_SORT = 'releases/setReleasesSort'; export const SET_RELEASES_SORT = 'releases/setReleasesSort';
export const CLEAR_RELEASES = 'releases/clearReleases'; export const CLEAR_RELEASES = 'releases/clearReleases';
export const GRAB_RELEASE = 'releases/grabRelease'; export const GRAB_RELEASE = 'releases/grabRelease';
export const BULK_GRAB_RELEASES = 'release/bulkGrabReleases';
export const UPDATE_RELEASE = 'releases/updateRelease'; export const UPDATE_RELEASE = 'releases/updateRelease';
export const SET_RELEASES_FILTER = 'releases/setReleasesFilter'; export const SET_RELEASES_FILTER = 'releases/setReleasesFilter';
export const SET_RELEASES_TABLE_OPTION = 'releases/setReleasesTableOption'; 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 setReleasesSort = createAction(SET_RELEASES_SORT);
export const clearReleases = createAction(CLEAR_RELEASES); export const clearReleases = createAction(CLEAR_RELEASES);
export const grabRelease = createThunk(GRAB_RELEASE); export const grabRelease = createThunk(GRAB_RELEASE);
export const bulkGrabReleases = createThunk(BULK_GRAB_RELEASES);
export const updateRelease = createAction(UPDATE_RELEASE); export const updateRelease = createAction(UPDATE_RELEASE);
export const setReleasesFilter = createAction(SET_RELEASES_FILTER); export const setReleasesFilter = createAction(SET_RELEASES_FILTER);
export const setReleasesTableOption = createAction(SET_RELEASES_TABLE_OPTION); export const setReleasesTableOption = createAction(SET_RELEASES_TABLE_OPTION);
@ -285,6 +302,47 @@ export const actionHandlers = handleThunks({
grabError 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 items = releases.items.map((s) => {
const { const {
guid, guid,
title title,
indexerId
} = s; } = s;
return { return {
guid, guid,
sortTitle: title sortTitle: title,
indexerId
}; };
}); });

@ -1,7 +1,14 @@
using System.Text.RegularExpressions;
namespace NzbDrone.Core.IndexerSearch namespace NzbDrone.Core.IndexerSearch
{ {
public class NewznabRequest 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 t { get; set; }
public string q { get; set; } public string q { get; set; }
public string cat { get; set; } public string cat { get; set; }
@ -28,5 +35,103 @@ namespace NzbDrone.Core.IndexerSearch
public string source { get; set; } public string source { get; set; }
public string host { get; set; } public string host { get; set; }
public string server { 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", "Apps": "Apps",
"AppSettingsSummary": "Applications and settings to configure how Prowlarr interacts with your PVR programs", "AppSettingsSummary": "Applications and settings to configure how Prowlarr interacts with your PVR programs",
"AreYouSureYouWantToResetYourAPIKey": "Are you sure you want to reset your API Key?", "AreYouSureYouWantToResetYourAPIKey": "Are you sure you want to reset your API Key?",
"AudioSearch": "Audio Search",
"Auth": "Auth", "Auth": "Auth",
"Authentication": "Authentication", "Authentication": "Authentication",
"AuthenticationMethodHelpText": "Require Username and Password to access Prowlarr", "AuthenticationMethodHelpText": "Require Username and Password to access Prowlarr",
@ -53,6 +54,7 @@
"BeforeUpdate": "Before update", "BeforeUpdate": "Before update",
"BindAddress": "Bind Address", "BindAddress": "Bind Address",
"BindAddressHelpText": "Valid IP4 address or '*' for all interfaces", "BindAddressHelpText": "Valid IP4 address or '*' for all interfaces",
"BookSearch": "Book Search",
"Branch": "Branch", "Branch": "Branch",
"BranchUpdate": "Branch to use to update Prowlarr", "BranchUpdate": "Branch to use to update Prowlarr",
"BranchUpdateMechanism": "Branch used by external update mechanism", "BranchUpdateMechanism": "Branch used by external update mechanism",
@ -239,6 +241,7 @@
"MovieIndexScrollBottom": "Movie Index: Scroll Bottom", "MovieIndexScrollBottom": "Movie Index: Scroll Bottom",
"MovieIndexScrollTop": "Movie Index: Scroll Top", "MovieIndexScrollTop": "Movie Index: Scroll Top",
"Movies": "Movies", "Movies": "Movies",
"MovieSearch": "Movie Search",
"Name": "Name", "Name": "Name",
"NetCore": ".NET", "NetCore": ".NET",
"New": "New", "New": "New",
@ -295,6 +298,7 @@
"QualityDefinitions": "Quality Definitions", "QualityDefinitions": "Quality Definitions",
"QualitySettings": "Quality Settings", "QualitySettings": "Quality Settings",
"Query": "Query", "Query": "Query",
"QueryOptions": "Query Options",
"Queue": "Queue", "Queue": "Queue",
"ReadTheWikiForMoreInformation": "Read the Wiki for more information", "ReadTheWikiForMoreInformation": "Read the Wiki for more information",
"Reddit": "Reddit", "Reddit": "Reddit",
@ -329,6 +333,7 @@
"ScriptPath": "Script Path", "ScriptPath": "Script Path",
"Search": "Search", "Search": "Search",
"SearchIndexers": "Search Indexers", "SearchIndexers": "Search Indexers",
"SearchType": "Search Type",
"Security": "Security", "Security": "Security",
"Seeders": "Seeders", "Seeders": "Seeders",
"SelectAll": "Select All", "SelectAll": "Select All",
@ -394,6 +399,7 @@
"Tomorrow": "Tomorrow", "Tomorrow": "Tomorrow",
"Torrent": "Torrent", "Torrent": "Torrent",
"Torrents": "Torrents", "Torrents": "Torrents",
"TvSearch": "TV Search",
"Type": "Type", "Type": "Type",
"UI": "UI", "UI": "UI",
"UILanguage": "UI Language", "UILanguage": "UI Language",

@ -52,7 +52,7 @@ namespace Prowlarr.Api.V1.Search
} }
[HttpPost] [HttpPost]
public ActionResult<SearchResource> Create(SearchResource release) public ActionResult<SearchResource> GrabRelease(SearchResource release)
{ {
ValidateResource(release); ValidateResource(release);
@ -75,32 +75,67 @@ namespace Prowlarr.Api.V1.Search
return Ok(release); 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] [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()) if (indexerIds.Any())
{ {
return GetSearchReleases(query, indexerIds, categories); return GetSearchReleases(query, type, indexerIds, categories);
} }
else 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 try
{ {
var request = new NewznabRequest var request = new NewznabRequest
{ {
q = query, source = "Prowlarr", q = query,
t = "search", t = type,
source = "Prowlarr",
cat = string.Join(",", categories), cat = string.Join(",", categories),
server = Request.GetServerUrl(), server = Request.GetServerUrl(),
host = Request.GetHostName() host = Request.GetHostName()
}; };
request.QueryToParams();
var result = await _nzbSearhService.Search(request, indexerIds, true); var result = await _nzbSearhService.Search(request, indexerIds, true);
var decisions = result.Releases; var decisions = result.Releases;

Loading…
Cancel
Save