@@ -159,12 +227,26 @@ class SearchFooter extends Component {
+ {
+ isPopulated &&
+
+ {translate('Grab Releases')}
+
+ }
+
+
+
);
}
@@ -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,
diff --git a/frontend/src/Search/SearchFooterConnector.js b/frontend/src/Search/SearchFooterConnector.js
index c4afc5979..0478ec329 100644
--- a/frontend/src/Search/SearchFooterConnector.js
+++ b/frontend/src/Search/SearchFooterConnector.js
@@ -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
};
}
);
diff --git a/frontend/src/Search/SearchIndex.js b/frontend/src/Search/SearchIndex.js
index 716aa6910..d629224de 100644
--- a/frontend/src/Search/SearchIndex.js
+++ b/frontend/src/Search/SearchIndex.js
@@ -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}
/>
@@ -293,8 +375,14 @@ class SearchIndex extends Component {
+ );
+ }
+
if (name === 'actions') {
return (
+ );
+ }
+
if (column.name === 'protocol') {
return (
}
+ 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;
diff --git a/frontend/src/Store/Actions/releaseActions.js b/frontend/src/Store/Actions/releaseActions.js
index 6bbabc69b..89ea9ffda 100644
--- a/frontend/src/Store/Actions/releaseActions.js
+++ b/frontend/src/Store/Actions/releaseActions.js
@@ -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
+ }));
+ });
}
});
diff --git a/frontend/src/Store/Selectors/createReleaseClientSideCollectionItemsSelector.js b/frontend/src/Store/Selectors/createReleaseClientSideCollectionItemsSelector.js
index 9e6f3721c..c76ba4236 100644
--- a/frontend/src/Store/Selectors/createReleaseClientSideCollectionItemsSelector.js
+++ b/frontend/src/Store/Selectors/createReleaseClientSideCollectionItemsSelector.js
@@ -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
};
});
diff --git a/src/NzbDrone.Core/IndexerSearch/NewznabRequest.cs b/src/NzbDrone.Core/IndexerSearch/NewznabRequest.cs
index cb66ef969..912c108fb 100644
--- a/src/NzbDrone.Core/IndexerSearch/NewznabRequest.cs
+++ b/src/NzbDrone.Core/IndexerSearch/NewznabRequest.cs
@@ -1,7 +1,14 @@
+using System.Text.RegularExpressions;
+
namespace NzbDrone.Core.IndexerSearch
{
public class NewznabRequest
{
+ private static readonly Regex TvRegex = new Regex(@"\{((?:imdbid\:)(?
[^{]+)|(?:tvdbid\:)(?[^{]+)|(?:season\:)(?[^{]+)|(?:episode\:)(?[^{]+))\}", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+ private static readonly Regex MovieRegex = new Regex(@"\{((?:imdbid\:)(?[^{]+)|(?:tmdbid\:)(?[^{]+))\}", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+ private static readonly Regex MusicRegex = new Regex(@"\{((?:artist\:)(?[^{]+)|(?:album\:)(?[^{]+)|(?:label\:)(?