diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js index 01b4a3205..4c3328caa 100644 --- a/frontend/src/Helpers/Props/icons.js +++ b/frontend/src/Helpers/Props/icons.js @@ -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; diff --git a/frontend/src/Search/QueryParameterModal.css b/frontend/src/Search/QueryParameterModal.css new file mode 100644 index 000000000..66e9bd73a --- /dev/null +++ b/frontend/src/Search/QueryParameterModal.css @@ -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; + } +} diff --git a/frontend/src/Search/QueryParameterModal.js b/frontend/src/Search/QueryParameterModal.js new file mode 100644 index 000000000..237d5a6e6 --- /dev/null +++ b/frontend/src/Search/QueryParameterModal.js @@ -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 ( + + + + {translate('QueryOptions')} + + + +
+
+ +
+
+ + { + searchType === 'tvsearch' && +
+
+ { + seriesTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ } + + { + searchType === 'movie' && +
+
+ { + movieTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ } + + { + searchType === 'music' && +
+
+ { + audioTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ } + + { + searchType === 'book' && +
+
+ { + bookTokens.map(({ token, example }) => { + return ( + + ); + } + ) + } +
+
+ } +
+ + + + + +
+
+ ); + } +} + +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; diff --git a/frontend/src/Search/QueryParameterOption.css b/frontend/src/Search/QueryParameterOption.css new file mode 100644 index 000000000..cdfb186db --- /dev/null +++ b/frontend/src/Search/QueryParameterOption.css @@ -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; + } +} diff --git a/frontend/src/Search/QueryParameterOption.js b/frontend/src/Search/QueryParameterOption.js new file mode 100644 index 000000000..6a31a1efa --- /dev/null +++ b/frontend/src/Search/QueryParameterOption.js @@ -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 ( + +
+ {token.replace(/ /g, tokenSeparator)} +
+ +
+ {example.replace(/ /g, tokenSeparator)} + + { + footNote !== 0 && + + } +
+ + ); + } +} + +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; diff --git a/frontend/src/Search/SearchFooter.css b/frontend/src/Search/SearchFooter.css index 83eb700cd..54e68660b 100644 --- a/frontend/src/Search/SearchFooter.css +++ b/frontend/src/Search/SearchFooter.css @@ -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; - } } diff --git a/frontend/src/Search/SearchFooter.js b/frontend/src/Search/SearchFooter.js index 4468e80f8..f17fc8dca 100644 --- a/frontend/src/Search/SearchFooter.js +++ b/frontend/src/Search/SearchFooter.js @@ -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 (
- + + } onChange={this.onSearchInputChange} + onFocus={this.onApikeyFocus} + isDisabled={isFetching} + {...searchQuery} />
@@ -144,7 +212,7 @@ class SearchFooter extends Component {
@@ -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\:)(?