diff --git a/frontend/gulp/start.js b/frontend/gulp/start.js index e2f65660b..f6760bc25 100644 --- a/frontend/gulp/start.js +++ b/frontend/gulp/start.js @@ -1,4 +1,4 @@ -// will download and run sonarr (server) in a non-windows enviroment +// will download and run lidarr (server) in a non-windows enviroment // you can use this if you don't care about the server code and just want to work // with the web code. diff --git a/frontend/src/Activity/Blacklist/BlacklistRow.js b/frontend/src/Activity/Blacklist/BlacklistRow.js index f766ca1ad..366245825 100644 --- a/frontend/src/Activity/Blacklist/BlacklistRow.js +++ b/frontend/src/Activity/Blacklist/BlacklistRow.js @@ -6,7 +6,7 @@ import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellCo import TableRow from 'Components/Table/TableRow'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import EpisodeLanguage from 'Album/EpisodeLanguage'; -import EpisodeQuality from 'Album/EpisodeQuality'; +import TrackQuality from 'Album/TrackQuality'; import ArtistNameLink from 'Artist/ArtistNameLink'; import BlacklistDetailsModal from './BlacklistDetailsModal'; import styles from './BlacklistRow.css'; @@ -103,7 +103,7 @@ class BlacklistRow extends Component { key={name} className={styles.quality} > - diff --git a/frontend/src/Activity/History/HistoryRow.js b/frontend/src/Activity/History/HistoryRow.js index 5976d1da1..f09537c01 100644 --- a/frontend/src/Activity/History/HistoryRow.js +++ b/frontend/src/Activity/History/HistoryRow.js @@ -7,7 +7,7 @@ import TableRow from 'Components/Table/TableRow'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import AlbumTitleLink from 'Album/AlbumTitleLink'; import EpisodeLanguage from 'Album/EpisodeLanguage'; -import EpisodeQuality from 'Album/EpisodeQuality'; +import TrackQuality from 'Album/TrackQuality'; import ArtistNameLink from 'Artist/ArtistNameLink'; import HistoryEventTypeCell from './HistoryEventTypeCell'; import HistoryDetailsModal from './Details/HistoryDetailsModal'; @@ -142,7 +142,7 @@ class HistoryRow extends Component { if (name === 'quality') { return ( - diff --git a/frontend/src/Activity/Queue/QueueRow.js b/frontend/src/Activity/Queue/QueueRow.js index d7d116333..be7e7c911 100644 --- a/frontend/src/Activity/Queue/QueueRow.js +++ b/frontend/src/Activity/Queue/QueueRow.js @@ -10,7 +10,7 @@ import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; import AlbumTitleLink from 'Album/AlbumTitleLink'; -import EpisodeQuality from 'Album/EpisodeQuality'; +import TrackQuality from 'Album/TrackQuality'; import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal'; import ArtistNameLink from 'Artist/ArtistNameLink'; import QueueStatusCell from './QueueStatusCell'; @@ -177,7 +177,7 @@ class QueueRow extends Component { if (name === 'quality') { return ( - diff --git a/frontend/src/Album/AlbumSearchCell.js b/frontend/src/Album/AlbumSearchCell.js index 83b7c2f23..093924798 100644 --- a/frontend/src/Album/AlbumSearchCell.js +++ b/frontend/src/Album/AlbumSearchCell.js @@ -4,7 +4,7 @@ import { icons } from 'Helpers/Props'; import IconButton from 'Components/Link/IconButton'; import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; -import InteractiveAlbumSearchModal from './Search/InteractiveAlbumSearchModal'; +import InteractiveSearchModal from 'InteractiveSearch/InteractiveSearchModal'; import styles from './AlbumSearchCell.css'; class AlbumSearchCell extends Component { @@ -55,7 +55,7 @@ class AlbumSearchCell extends Component { onPress={this.onManualSearchPress} /> - - - @@ -202,7 +204,7 @@ AlbumStudio.propTypes = { items: PropTypes.arrayOf(PropTypes.object).isRequired, sortKey: PropTypes.string, sortDirection: PropTypes.oneOf(sortDirections.all), - selectedFilterKey: PropTypes.string.isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, filters: PropTypes.arrayOf(PropTypes.object).isRequired, customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, isSaving: PropTypes.bool.isRequired, diff --git a/frontend/src/AlbumStudio/AlbumStudioFilterModalConnector.js b/frontend/src/AlbumStudio/AlbumStudioFilterModalConnector.js new file mode 100644 index 000000000..655601cca --- /dev/null +++ b/frontend/src/AlbumStudio/AlbumStudioFilterModalConnector.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setAlbumStudioFilter } from 'Store/Actions/albumStudioActions'; +import FilterModal from 'Components/Filter/FilterModal'; + +function createMapStateToProps() { + return createSelector( + (state) => state.artist.items, + (state) => state.albumStudio.filterBuilderProps, + (sectionItems, filterBuilderProps) => { + return { + sectionItems, + filterBuilderProps, + customFilterType: 'albumStudio' + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetFilter: setAlbumStudioFilter +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal); diff --git a/frontend/src/Artist/Details/AlbumRowConnector.js b/frontend/src/Artist/Details/AlbumRowConnector.js index 1c48af992..6e92fb1d4 100644 --- a/frontend/src/Artist/Details/AlbumRowConnector.js +++ b/frontend/src/Artist/Details/AlbumRowConnector.js @@ -9,7 +9,7 @@ function createMapStateToProps() { return createSelector( createArtistSelector(), createTrackFileSelector(), - (artist, trackFile) => { + (artist = {}, trackFile) => { return { foreignArtistId: artist.foreignArtistId, artistMonitored: artist.monitored, diff --git a/frontend/src/Artist/Details/ArtistDetails.js b/frontend/src/Artist/Details/ArtistDetails.js index 140f87e43..28e6d3eee 100644 --- a/frontend/src/Artist/Details/ArtistDetails.js +++ b/frontend/src/Artist/Details/ArtistDetails.js @@ -11,6 +11,7 @@ import HeartRating from 'Components/HeartRating'; import Icon from 'Components/Icon'; import IconButton from 'Components/Link/IconButton'; import Label from 'Components/Label'; +import Measure from 'Components/Measure'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; import PageContent from 'Components/Page/PageContent'; import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; diff --git a/frontend/src/Artist/Editor/ArtistEditor.js b/frontend/src/Artist/Editor/ArtistEditor.js index 714041600..d61c42dec 100644 --- a/frontend/src/Artist/Editor/ArtistEditor.js +++ b/frontend/src/Artist/Editor/ArtistEditor.js @@ -273,7 +273,7 @@ ArtistEditor.propTypes = { items: PropTypes.arrayOf(PropTypes.object).isRequired, sortKey: PropTypes.string, sortDirection: PropTypes.oneOf(sortDirections.all), - selectedFilterKey: PropTypes.string.isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, filters: PropTypes.arrayOf(PropTypes.object).isRequired, customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, isSaving: PropTypes.bool.isRequired, diff --git a/frontend/src/Artist/Editor/ArtistEditorFilterModalConnector.js b/frontend/src/Artist/Editor/ArtistEditorFilterModalConnector.js index 8b36fa2d2..4aff2df06 100644 --- a/frontend/src/Artist/Editor/ArtistEditorFilterModalConnector.js +++ b/frontend/src/Artist/Editor/ArtistEditorFilterModalConnector.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import * as artistEditorActions from 'Store/Actions/artistEditorActions'; +import { setArtistEditorFilter } from 'Store/Actions/artistEditorActions'; import FilterModal from 'Components/Filter/FilterModal'; function createMapStateToProps() { @@ -10,22 +10,15 @@ function createMapStateToProps() { (sectionItems, filterBuilderProps) => { return { sectionItems, - filterBuilderProps + filterBuilderProps, + customFilterType: 'artistEditor' }; } ); } -function createMapDispatchToProps(dispatch, props) { - return { - onRemoveCustomFilterPress(payload) { - dispatch(artistEditorActions.removeArtistEditorCustomFilter(payload)); - }, +const mapDispatchToProps = { + dispatchSetFilter: setArtistEditorFilter +}; - onSaveCustomFilterPress(payload) { - dispatch(artistEditorActions.saveArtistEditorCustomFilter(payload)); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(FilterModal); +export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal); diff --git a/frontend/src/Artist/Editor/ArtistEditorRow.js b/frontend/src/Artist/Editor/ArtistEditorRow.js index 2126be3e8..ae95c465f 100644 --- a/frontend/src/Artist/Editor/ArtistEditorRow.js +++ b/frontend/src/Artist/Editor/ArtistEditorRow.js @@ -119,4 +119,8 @@ ArtistEditorRow.propTypes = { onSelectedChange: PropTypes.func.isRequired }; +ArtistEditorRow.defaultProps = { + tags: [] +}; + export default ArtistEditorRow; diff --git a/frontend/src/Artist/History/ArtistHistoryRow.js b/frontend/src/Artist/History/ArtistHistoryRow.js index 7c9544a49..38e7c75a6 100644 --- a/frontend/src/Artist/History/ArtistHistoryRow.js +++ b/frontend/src/Artist/History/ArtistHistoryRow.js @@ -9,7 +9,7 @@ import TableRow from 'Components/Table/TableRow'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import Popover from 'Components/Tooltip/Popover'; import EpisodeLanguage from 'Album/EpisodeLanguage'; -import EpisodeQuality from 'Album/EpisodeQuality'; +import TrackQuality from 'Album/TrackQuality'; import HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector'; import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell'; import styles from './ArtistHistoryRow.css'; @@ -100,7 +100,7 @@ class ArtistHistoryRow extends Component { - diff --git a/frontend/src/Artist/Index/ArtistIndex.js b/frontend/src/Artist/Index/ArtistIndex.js index 4bfe3996f..710d898e0 100644 --- a/frontend/src/Artist/Index/ArtistIndex.js +++ b/frontend/src/Artist/Index/ArtistIndex.js @@ -215,7 +215,7 @@ class ArtistIndex extends Component { } = this.state; const ViewComponent = getViewComponent(view); - const isLoaded = !error && isPopulated && !!items.length && contentBody; + const isLoaded = !!(!error && isPopulated && items.length && contentBody); const hasNoArtist = !totalItems; return ( @@ -382,7 +382,7 @@ ArtistIndex.propTypes = { error: PropTypes.object, totalItems: PropTypes.number.isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired, - selectedFilterKey: PropTypes.string.isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, filters: PropTypes.arrayOf(PropTypes.object).isRequired, customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, sortKey: PropTypes.string, diff --git a/frontend/src/Artist/Index/ArtistIndexFilterModalConnector.js b/frontend/src/Artist/Index/ArtistIndexFilterModalConnector.js index 0f77138b2..412f3df34 100644 --- a/frontend/src/Artist/Index/ArtistIndexFilterModalConnector.js +++ b/frontend/src/Artist/Index/ArtistIndexFilterModalConnector.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import * as artistIndexActions from 'Store/Actions/artistIndexActions'; +import { setArtistFilter } from 'Store/Actions/artistIndexActions'; import FilterModal from 'Components/Filter/FilterModal'; function createMapStateToProps() { @@ -10,22 +10,15 @@ function createMapStateToProps() { (sectionItems, filterBuilderProps) => { return { sectionItems, - filterBuilderProps + filterBuilderProps, + customFilterType: 'artistIndex' }; } ); } -function createMapDispatchToProps(dispatch, props) { - return { - onRemoveCustomFilterPress(payload) { - dispatch(artistIndexActions.removeArtistCustomFilter(payload)); - }, +const mapDispatchToProps = { + dispatchSetFilter: setArtistFilter +}; - onSaveCustomFilterPress(payload) { - dispatch(artistIndexActions.saveArtistCustomFilter(payload)); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(FilterModal); +export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal); diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js index b05332b28..f1c02f88f 100644 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js @@ -1,12 +1,12 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import ReactDOM from 'react-dom'; -import Measure from 'react-measure'; import { Grid, WindowScroller } from 'react-virtualized'; import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import dimensions from 'Styles/Variables/dimensions'; import { sortDirections } from 'Helpers/Props'; +import Measure from 'Components/Measure'; import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector'; import ArtistIndexBanner from './ArtistIndexBanner'; import styles from './ArtistIndexBanners.css'; diff --git a/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js b/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js index dc5540410..818e83311 100644 --- a/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js +++ b/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js @@ -27,7 +27,7 @@ function ArtistIndexFilterMenu(props) { } ArtistIndexFilterMenu.propTypes = { - selectedFilterKey: PropTypes.string.isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, filters: PropTypes.arrayOf(PropTypes.object).isRequired, customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, isDisabled: PropTypes.bool.isRequired, diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js index 93c2c9764..de13a9de8 100644 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js @@ -2,12 +2,12 @@ import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; import ReactDOM from 'react-dom'; -import Measure from 'react-measure'; import { Grid, WindowScroller } from 'react-virtualized'; import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import dimensions from 'Styles/Variables/dimensions'; import { sortDirections } from 'Helpers/Props'; +import Measure from 'Components/Measure'; import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector'; import ArtistIndexOverview from './ArtistIndexOverview'; import styles from './ArtistIndexOverviews.css'; diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js index 000b14887..d02aeaefd 100644 --- a/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js +++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js @@ -1,12 +1,12 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import ReactDOM from 'react-dom'; -import Measure from 'react-measure'; import { Grid, WindowScroller } from 'react-virtualized'; import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; import dimensions from 'Styles/Variables/dimensions'; import { sortDirections } from 'Helpers/Props'; +import Measure from 'Components/Measure'; import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector'; import ArtistIndexPoster from './ArtistIndexPoster'; import styles from './ArtistIndexPosters.css'; diff --git a/frontend/src/Calendar/CalendarPage.js b/frontend/src/Calendar/CalendarPage.js index 5ce161d91..d33b1387b 100644 --- a/frontend/src/Calendar/CalendarPage.js +++ b/frontend/src/Calendar/CalendarPage.js @@ -1,8 +1,8 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import Measure from 'react-measure'; import { align, icons } from 'Helpers/Props'; import PageContent from 'Components/Page/PageContent'; +import Measure from 'Components/Measure'; import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; diff --git a/frontend/src/Calendar/Events/CalendarEvent.js b/frontend/src/Calendar/Events/CalendarEvent.js index 0189fce3d..e73b30c93 100644 --- a/frontend/src/Calendar/Events/CalendarEvent.js +++ b/frontend/src/Calendar/Events/CalendarEvent.js @@ -56,6 +56,10 @@ class CalendarEvent extends Component { colorImpairedMode } = this.props; + if (!artist) { + return null; + } + const startTime = moment(releaseDate); // const endTime = startTime.add(artist.runtime, 'minutes'); const downloading = !!(queueItem || grabbed); diff --git a/frontend/src/Components/Error/ErrorBoundary.js b/frontend/src/Components/Error/ErrorBoundary.js new file mode 100644 index 000000000..50fcf98b5 --- /dev/null +++ b/frontend/src/Components/Error/ErrorBoundary.js @@ -0,0 +1,62 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import * as sentry from '@sentry/browser'; + +class ErrorBoundary extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + error: null, + info: null + }; + } + + componentDidCatch(error, info) { + this.setState({ + error, + info + }); + + sentry.captureException(error); + } + + // + // Render + + render() { + const { + children, + errorComponent: ErrorComponent, + ...otherProps + } = this.props; + + const { + error, + info + } = this.state; + + if (error) { + return ( + + ); + } + + return children; + } +} + +ErrorBoundary.propTypes = { + children: PropTypes.node.isRequired, + errorComponent: PropTypes.func.isRequired +}; + +export default ErrorBoundary; diff --git a/frontend/src/Components/Error/ErrorBoundaryError.css b/frontend/src/Components/Error/ErrorBoundaryError.css new file mode 100644 index 000000000..b6d1f917e --- /dev/null +++ b/frontend/src/Components/Error/ErrorBoundaryError.css @@ -0,0 +1,38 @@ +.container { + text-align: center; +} + +.message { + margin: 50px 0; + text-align: center; + font-weight: 300; + font-size: 36px; +} + +.imageContainer { + display: flex; + justify-content: center; + flex: 0 0 auto; +} + +.image { + height: 350px; +} + +.details { + margin: 20px; + text-align: left; + white-space: pre-wrap; +} + +@media only screen and (max-width: $breakpointMedium) { + .image { + height: 250px; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .image { + height: 150px; + } +} diff --git a/frontend/src/Components/Error/ErrorBoundaryError.js b/frontend/src/Components/Error/ErrorBoundaryError.js new file mode 100644 index 000000000..f99930437 --- /dev/null +++ b/frontend/src/Components/Error/ErrorBoundaryError.js @@ -0,0 +1,60 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import styles from './ErrorBoundaryError.css'; + +function ErrorBoundaryError(props) { + const { + className, + messageClassName, + detailsClassName, + message, + error, + info + } = props; + + return ( +
+
+ {message} +
+ +
+ +
+ +
+ { + error && +
+ {error.toString()} +
+ } + +
+ {info.componentStack} +
+
+
+ ); +} + +ErrorBoundaryError.propTypes = { + className: PropTypes.string.isRequired, + messageClassName: PropTypes.string.isRequired, + detailsClassName: PropTypes.string.isRequired, + message: PropTypes.string.isRequired, + error: PropTypes.object.isRequired, + info: PropTypes.object.isRequired +}; + +ErrorBoundaryError.defaultProps = { + className: styles.container, + messageClassName: styles.message, + detailsClassName: styles.details, + message: 'There was an error loading this content' +}; + +export default ErrorBoundaryError; diff --git a/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js b/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js index 71b463292..fe577b896 100644 --- a/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js +++ b/frontend/src/Components/FileBrowser/FileBrowserModalContentConnector.js @@ -35,7 +35,7 @@ function createMapStateToProps() { directories, files, paths: filteredPaths, - isWindowsService: true || systemStatus.isWindows && systemStatus.mode === 'service' + isWindowsService: systemStatus.isWindows && systemStatus.mode === 'service' }; } ); diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js index d2a72b67c..ed3bc2409 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContent.js @@ -3,6 +3,7 @@ import React, { Component } from 'react'; import { inputTypes } from 'Helpers/Props'; import FormInputGroup from 'Components/Form/FormInputGroup'; import Button from 'Components/Link/Button'; +import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; import ModalContent from 'Components/Modal/ModalContent'; import ModalHeader from 'Components/Modal/ModalHeader'; import ModalBody from 'Components/Modal/ModalBody'; @@ -34,6 +35,28 @@ class FilterBuilderModalContent extends Component { }; } + componentDidUpdate(prevProps) { + const { + id, + customFilters, + isSaving, + saveError, + dispatchSetFilter, + onModalClose + } = this.props; + + if (prevProps.isSaving && !isSaving && !saveError) { + if (id) { + dispatchSetFilter({ selectedFilterKey: id }); + } else { + const last = customFilters[customFilters.length -1]; + dispatchSetFilter({ selectedFilterKey: last.id }); + } + + onModalClose(); + } + } + // // Listeners @@ -70,9 +93,9 @@ class FilterBuilderModalContent extends Component { onSaveFilterPress = () => { const { - customFilterKey: key, - onSaveCustomFilterPress, - onModalClose + id, + customFilterType, + onSaveCustomFilterPress } = this.props; const { @@ -92,8 +115,12 @@ class FilterBuilderModalContent extends Component { return; } - onSaveCustomFilterPress({ key, label, filters }); - onModalClose(); + onSaveCustomFilterPress({ + id, + type: customFilterType, + label, + filters + }); } // @@ -103,6 +130,8 @@ class FilterBuilderModalContent extends Component { const { sectionItems, filterBuilderProps, + isSaving, + saveError, onModalClose } = this.props; @@ -161,17 +190,17 @@ class FilterBuilderModalContent extends Component { - - + Save + ); @@ -179,13 +208,18 @@ class FilterBuilderModalContent extends Component { } FilterBuilderModalContent.propTypes = { - customFilterKey: PropTypes.string, + id: PropTypes.number, label: PropTypes.string.isRequired, + customFilterType: PropTypes.string.isRequired, sectionItems: PropTypes.arrayOf(PropTypes.object).isRequired, filters: PropTypes.arrayOf(PropTypes.object).isRequired, filterBuilderProps: PropTypes.arrayOf(PropTypes.object).isRequired, - onRemoveCustomFilterPress: PropTypes.func.isRequired, + customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + dispatchDeleteCustomFilter: PropTypes.func.isRequired, onSaveCustomFilterPress: PropTypes.func.isRequired, + dispatchSetFilter: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderModalContentConnector.js b/frontend/src/Components/Filter/Builder/FilterBuilderModalContentConnector.js index e7f237793..c94db9925 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderModalContentConnector.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderModalContentConnector.js @@ -1,28 +1,42 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; +import { saveCustomFilter, deleteCustomFilter } from 'Store/Actions/customFilterActions'; import FilterBuilderModalContent from './FilterBuilderModalContent'; function createMapStateToProps() { return createSelector( (state, { customFilters }) => customFilters, - (state, { customFilterKey }) => customFilterKey, - (customFilters, customFilterKey) => { - if (customFilterKey) { - const customFilter = customFilters.find((c) => c.key === customFilterKey); + (state, { id }) => id, + (state) => state.customFilters.isSaving, + (state) => state.customFilters.saveError, + (customFilters, id, isSaving, saveError) => { + if (id) { + const customFilter = customFilters.find((c) => c.id === id); return { - customFilterKey: customFilter.key, + id: customFilter.id, label: customFilter.label, - filters: customFilter.filters + filters: customFilter.filters, + customFilters, + isSaving, + saveError }; } return { label: '', - filters: [] + filters: [], + customFilters, + isSaving, + saveError }; } ); } -export default connect(createMapStateToProps)(FilterBuilderModalContent); +const mapDispatchToProps = { + onSaveCustomFilterPress: saveCustomFilter, + dispatchDeleteCustomFilter: deleteCustomFilter +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(FilterBuilderModalContent); diff --git a/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js index 62b56da9f..758e12691 100644 --- a/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js +++ b/frontend/src/Components/Filter/Builder/FilterBuilderRowValue.js @@ -1,11 +1,64 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import { kinds, filterBuilderTypes } from 'Helpers/Props'; +import convertToBytes from 'Utilities/Number/convertToBytes'; +import formatBytes from 'Utilities/Number/formatBytes'; +import { kinds, filterBuilderTypes, filterBuilderValueTypes } from 'Helpers/Props'; import TagInput, { tagShape } from 'Components/Form/TagInput'; import FilterBuilderRowValueTag from './FilterBuilderRowValueTag'; export const NAME = 'value'; +function getTagDisplayValue(value, selectedFilterBuilderProp) { + if (selectedFilterBuilderProp.valueType === filterBuilderValueTypes.BYTES) { + return formatBytes(value); + } + + return value; +} + +function getValue(input, selectedFilterBuilderProp) { + if (selectedFilterBuilderProp.valueType === filterBuilderValueTypes.BYTES) { + const match = input.match(/^(\d+)([kmgt](i?b)?)$/i); + if (match && match.length > 1) { + const [, value, unit] = input.match(/^(\d+)([kmgt](i?b)?)$/i); + switch (unit.toLowerCase()) { + case 'k': + return convertToBytes(value, 1, true); + case 'm': + return convertToBytes(value, 2, true); + case 'g': + return convertToBytes(value, 3, true); + case 't': + return convertToBytes(value, 4, true); + case 'kb': + return convertToBytes(value, 1, true); + case 'mb': + return convertToBytes(value, 2, true); + case 'gb': + return convertToBytes(value, 3, true); + case 'tb': + return convertToBytes(value, 4, true); + case 'kib': + return convertToBytes(value, 1, true); + case 'mib': + return convertToBytes(value, 2, true); + case 'gib': + return convertToBytes(value, 3, true); + case 'tib': + return convertToBytes(value, 4, true); + default: + return parseInt(value); + } + } + } + + if (selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER) { + return parseInt(input); + } + + return input; +} + class FilterBuilderRowValue extends Component { // @@ -18,17 +71,15 @@ class FilterBuilderRowValue extends Component { onChange } = this.props; - let id = tag.id; + let value = tag.id; - if (id == null) { - id = selectedFilterBuilderProp.type === filterBuilderTypes.NUMBER ? - parseInt(tag.name) : - tag.name; + if (value == null) { + value = getValue(tag.name, selectedFilterBuilderProp); } onChange({ name: NAME, - value: [...filterValue, id] + value: [...filterValue, value] }); } @@ -52,6 +103,7 @@ class FilterBuilderRowValue extends Component { render() { const { filterValue, + selectedFilterBuilderProp, tagList } = this.props; @@ -68,7 +120,7 @@ class FilterBuilderRowValue extends Component { } return { id, - name: id + name: getTagDisplayValue(id, selectedFilterBuilderProp) }; }); diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFilter.js b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js index 62551978b..ec9ff7cf4 100644 --- a/frontend/src/Components/Filter/CustomFilters/CustomFilter.js +++ b/frontend/src/Components/Filter/CustomFilters/CustomFilter.js @@ -2,29 +2,70 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { icons } from 'Helpers/Props'; import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; import styles from './CustomFilter.css'; class CustomFilter extends Component { + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDeleting: false + }; + } + + componentDidUpdate(prevProps) { + const { + isDeleting, + deleteError + } = this.props; + + if (prevProps.isDeleting && !isDeleting && this.state.isDeleting && deleteError) { + this.setState({ isDeleting: false }); + } + } + + componentWillUnmount() { + const { + id, + selectedFilterKey, + dispatchSetFilter + } = this.props; + + // Assume that delete and then unmounting means the delete was successful. + // Moving this check to a ancestor would be more accurate, but would have + // more boilerplate. + if (this.state.isDeleting && id === selectedFilterKey) { + dispatchSetFilter({ selectedFilterKey: 'all' }); + } + } + // // Listeners onEditPress = () => { const { - customFilterKey, + id, onEditPress } = this.props; - onEditPress(customFilterKey); + onEditPress(id); } onRemovePress = () => { const { - customFilterKey, - onRemovePress + id, + dispatchDeleteCustomFilter } = this.props; - onRemovePress({ key: customFilterKey }); + this.setState({ isDeleting: true }, () => { + dispatchDeleteCustomFilter({ id }); + }); + } // @@ -47,8 +88,9 @@ class CustomFilter extends Component { onPress={this.onEditPress} /> - @@ -58,10 +100,14 @@ class CustomFilter extends Component { } CustomFilter.propTypes = { - customFilterKey: PropTypes.string.isRequired, + id: PropTypes.number.isRequired, label: PropTypes.string.isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + isDeleting: PropTypes.bool.isRequired, + deleteError: PropTypes.object, + dispatchSetFilter: PropTypes.func.isRequired, onEditPress: PropTypes.func.isRequired, - onRemovePress: PropTypes.func.isRequired + dispatchDeleteCustomFilter: PropTypes.func.isRequired }; export default CustomFilter; diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js index ac27bdd23..1a7168fca 100644 --- a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js +++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContent.js @@ -10,9 +10,13 @@ import styles from './CustomFiltersModalContent.css'; function CustomFiltersModalContent(props) { const { + selectedFilterKey, customFilters, + isDeleting, + deleteError, + dispatchDeleteCustomFilter, + dispatchSetFilter, onAddCustomFilter, - onRemoveCustomFilterPress, onEditCustomFilter, onModalClose } = props; @@ -29,10 +33,14 @@ function CustomFiltersModalContent(props) { return ( ); @@ -58,9 +66,13 @@ function CustomFiltersModalContent(props) { } CustomFiltersModalContent.propTypes = { + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, + isDeleting: PropTypes.bool.isRequired, + deleteError: PropTypes.object, + dispatchDeleteCustomFilter: PropTypes.func.isRequired, + dispatchSetFilter: PropTypes.func.isRequired, onAddCustomFilter: PropTypes.func.isRequired, - onRemoveCustomFilterPress: PropTypes.func.isRequired, onEditCustomFilter: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContentConnector.js b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContentConnector.js new file mode 100644 index 000000000..32425d766 --- /dev/null +++ b/frontend/src/Components/Filter/CustomFilters/CustomFiltersModalContentConnector.js @@ -0,0 +1,23 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { deleteCustomFilter } from 'Store/Actions/customFilterActions'; +import CustomFiltersModalContent from './CustomFiltersModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.customFilters.isDeleting, + (state) => state.customFilters.deleteError, + (isDeleting, deleteError) => { + return { + isDeleting, + deleteError + }; + } + ); +} + +const mapDispatchToProps = { + dispatchDeleteCustomFilter: deleteCustomFilter +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(CustomFiltersModalContent); diff --git a/frontend/src/Components/Filter/FilterModal.js b/frontend/src/Components/Filter/FilterModal.js index 944caf070..750d1ed48 100644 --- a/frontend/src/Components/Filter/FilterModal.js +++ b/frontend/src/Components/Filter/FilterModal.js @@ -2,7 +2,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import Modal from 'Components/Modal/Modal'; import FilterBuilderModalContentConnector from './Builder/FilterBuilderModalContentConnector'; -import CustomFiltersModalContent from './CustomFilters/CustomFiltersModalContent'; +import CustomFiltersModalContentConnector from './CustomFilters/CustomFiltersModalContentConnector'; class FilterModal extends Component { @@ -14,7 +14,7 @@ class FilterModal extends Component { this.state = { filterBuilder: !props.customFilters.length, - customFilterKey: null + id: null }; } @@ -27,17 +27,17 @@ class FilterModal extends Component { }); } - onEditCustomFilter = (customFilterKey) => { + onEditCustomFilter = (id) => { this.setState({ filterBuilder: true, - customFilterKey + id }); } onModalClose = () => { this.setState({ filterBuilder: false, - customFilterKey: null + id: null }, () => { this.props.onModalClose(); }); @@ -54,7 +54,7 @@ class FilterModal extends Component { const { filterBuilder, - customFilterKey + id } = this.state; return ( @@ -66,10 +66,10 @@ class FilterModal extends Component { filterBuilder ? : - { + this.props.onMeasure(payload); + }, 250, { leading: true, trailing: false }) + + // + // Render + + render() { + return ( + + ); + } +} + +Measure.propTypes = { + onMeasure: PropTypes.func.isRequired +}; + +export default Measure; diff --git a/frontend/src/Components/Menu/FilterMenu.js b/frontend/src/Components/Menu/FilterMenu.js index 765824479..d37876c22 100644 --- a/frontend/src/Components/Menu/FilterMenu.js +++ b/frontend/src/Components/Menu/FilterMenu.js @@ -42,6 +42,7 @@ class FilterMenu extends Component { customFilters, buttonComponent: ButtonComponent, filterModalConnectorComponent: FilterModalConnectorComponent, + filterModalConnectorComponentProps, onFilterSelect, ...otherProps } = this.props; @@ -74,6 +75,7 @@ class FilterMenu extends Component { { showCustomFilters && { return ( @@ -70,7 +70,7 @@ class FilterMenuContent extends Component { } FilterMenuContent.propTypes = { - selectedFilterKey: PropTypes.string.isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, filters: PropTypes.arrayOf(PropTypes.object).isRequired, customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, showCustomFilters: PropTypes.bool.isRequired, diff --git a/frontend/src/Components/Menu/FilterMenuItem.js b/frontend/src/Components/Menu/FilterMenuItem.js index f8afb8364..d2c495187 100644 --- a/frontend/src/Components/Menu/FilterMenuItem.js +++ b/frontend/src/Components/Menu/FilterMenuItem.js @@ -37,8 +37,8 @@ class FilterMenuItem extends Component { } FilterMenuItem.propTypes = { - filterKey: PropTypes.string.isRequired, - selectedFilterKey: PropTypes.string.isRequired, + filterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, onPress: PropTypes.func.isRequired }; diff --git a/frontend/src/Components/Modal/Modal.js b/frontend/src/Components/Modal/Modal.js index bbca184ec..a9de82c6d 100644 --- a/frontend/src/Components/Modal/Modal.js +++ b/frontend/src/Components/Modal/Modal.js @@ -6,6 +6,8 @@ import elementClass from 'element-class'; import getUniqueElememtId from 'Utilities/getUniqueElementId'; import * as keyCodes from 'Utilities/Constants/keyCodes'; import { sizes } from 'Helpers/Props'; +import ErrorBoundary from 'Components/Error/ErrorBoundary'; +import ModalError from './ModalError'; import styles from './Modal.css'; const openModals = []; @@ -153,7 +155,8 @@ class Modal extends Component { backdropClassName, size, children, - isOpen + isOpen, + onModalClose } = this.props; if (!isOpen) { @@ -177,7 +180,12 @@ class Modal extends Component { )} style={style} > - {children} + + {children} + , diff --git a/frontend/src/Components/Modal/ModalError.css b/frontend/src/Components/Modal/ModalError.css new file mode 100644 index 000000000..54dbdbc63 --- /dev/null +++ b/frontend/src/Components/Modal/ModalError.css @@ -0,0 +1,15 @@ +.message { + composes: message from 'Components/Error/ErrorBoundaryError.css'; + + margin: 0; + margin-bottom: 30px; + font-weight: normal; + font-size: 26px; +} + +.details { + composes: details from 'Components/Error/ErrorBoundaryError.css'; + + margin: 0; + margin-top: 20px; +} diff --git a/frontend/src/Components/Modal/ModalError.js b/frontend/src/Components/Modal/ModalError.js new file mode 100644 index 000000000..df99a5b32 --- /dev/null +++ b/frontend/src/Components/Modal/ModalError.js @@ -0,0 +1,46 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import ErrorBoundaryError from 'Components/Error/ErrorBoundaryError'; +import Button from 'Components/Link/Button'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import styles from './ModalError.css'; + +function ModalError(props) { + const { + onModalClose, + ...otherProps + } = props; + + return ( + + + Error + + + + + + + + + + ); +} + +ModalError.propTypes = { + onModalClose: PropTypes.func.isRequired +}; + +export default ModalError; diff --git a/frontend/src/Components/Page/ErrorPage.js b/frontend/src/Components/Page/ErrorPage.js index 325575ff0..6cdd08af1 100644 --- a/frontend/src/Components/Page/ErrorPage.js +++ b/frontend/src/Components/Page/ErrorPage.js @@ -8,8 +8,11 @@ function ErrorPage(props) { version, isLocalStorageSupported, artistError, + customFiltersError, tagsError, qualityProfilesError, + languageProfilesError, + metadataProfilesError, uiSettingsError } = props; @@ -19,10 +22,16 @@ function ErrorPage(props) { errorMessage = 'Local Storage is not supported or disabled. A plugin or private browsing may have disabled it.'; } else if (artistError) { errorMessage = getErrorMessage(artistError, 'Failed to load artist from API'); + } else if (customFiltersError) { + errorMessage = getErrorMessage(customFiltersError, 'Failed to load custom filters from API'); } else if (tagsError) { - errorMessage = getErrorMessage(artistError, 'Failed to load artist from API'); + errorMessage = getErrorMessage(tagsError, 'Failed to load tags from API'); } else if (qualityProfilesError) { errorMessage = getErrorMessage(qualityProfilesError, 'Failed to load quality profiles from API'); + } else if (languageProfilesError) { + errorMessage = getErrorMessage(languageProfilesError, 'Failed to load language profiles from API'); + } else if (metadataProfilesError) { + errorMessage = getErrorMessage(metadataProfilesError, 'Failed to load metadata profiles from API'); } else if (uiSettingsError) { errorMessage = getErrorMessage(uiSettingsError, 'Failed to load UI settings from API'); } @@ -44,8 +53,11 @@ ErrorPage.propTypes = { version: PropTypes.string.isRequired, isLocalStorageSupported: PropTypes.bool.isRequired, artistError: PropTypes.object, + customFiltersError: PropTypes.object, tagsError: PropTypes.object, qualityProfilesError: PropTypes.object, + languageProfilesError: PropTypes.object, + metadataProfilesError: PropTypes.object, uiSettingsError: PropTypes.object }; diff --git a/frontend/src/Components/Page/PageConnector.js b/frontend/src/Components/Page/PageConnector.js index 2b3adfd48..bdb00ccec 100644 --- a/frontend/src/Components/Page/PageConnector.js +++ b/frontend/src/Components/Page/PageConnector.js @@ -6,6 +6,7 @@ import { withRouter } from 'react-router-dom'; import { createSelector } from 'reselect'; import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; import { saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions'; +import { fetchCustomFilters } from 'Store/Actions/customFilterActions'; import { fetchArtist } from 'Store/Actions/artistActions'; import { fetchTags } from 'Store/Actions/tagActions'; import { fetchQualityProfiles, fetchLanguageProfiles, fetchMetadataProfiles, fetchUISettings, fetchImportLists } from 'Store/Actions/settingsActions'; @@ -30,13 +31,15 @@ function testLocalStorage() { function createMapStateToProps() { return createSelector( (state) => state.artist, + (state) => state.customFilters, (state) => state.tags, (state) => state.settings, (state) => state.app, createDimensionsSelector(), - (artist, tags, settings, app, dimensions) => { + (artist, customFilters, tags, settings, app, dimensions) => { const isPopulated = ( artist.isPopulated && + customFilters.isPopulated && tags.isPopulated && settings.qualityProfiles.isPopulated && settings.languageProfiles.isPopulated && @@ -47,6 +50,7 @@ function createMapStateToProps() { const hasError = !!( artist.error || + customFilters.error || tags.error || settings.qualityProfiles.error || settings.languageProfiles.error || @@ -59,6 +63,7 @@ function createMapStateToProps() { isPopulated, hasError, artistError: artist.error, + customFiltersError: tags.error, tagsError: tags.error, qualityProfilesError: settings.qualityProfiles.error, languageProfilesError: settings.languageProfiles.error, @@ -80,6 +85,9 @@ function createMapDispatchToProps(dispatch, props) { dispatchFetchArtist() { dispatch(fetchArtist()); }, + dispatchFetchCustomFilters() { + dispatch(fetchCustomFilters()); + }, dispatchFetchTags() { dispatch(fetchTags()); }, @@ -126,6 +134,7 @@ class PageConnector extends Component { componentDidMount() { if (!this.props.isPopulated) { this.props.dispatchFetchArtist(); + this.props.dispatchFetchCustomFilters(); this.props.dispatchFetchTags(); this.props.dispatchFetchQualityProfiles(); this.props.dispatchFetchLanguageProfiles(); @@ -190,6 +199,7 @@ PageConnector.propTypes = { hasError: PropTypes.bool.isRequired, isSidebarVisible: PropTypes.bool.isRequired, dispatchFetchArtist: PropTypes.func.isRequired, + dispatchFetchCustomFilters: PropTypes.func.isRequired, dispatchFetchTags: PropTypes.func.isRequired, dispatchFetchQualityProfiles: PropTypes.func.isRequired, dispatchFetchLanguageProfiles: PropTypes.func.isRequired, diff --git a/frontend/src/Components/Page/PageContent.js b/frontend/src/Components/Page/PageContent.js index a416d268c..e7a650bb4 100644 --- a/frontend/src/Components/Page/PageContent.js +++ b/frontend/src/Components/Page/PageContent.js @@ -1,6 +1,8 @@ import PropTypes from 'prop-types'; import React from 'react'; import DocumentTitle from 'react-document-title'; +import ErrorBoundary from 'Components/Error/ErrorBoundary'; +import PageContentError from './PageContentError'; import styles from './PageContent.css'; function PageContent(props) { @@ -11,11 +13,13 @@ function PageContent(props) { } = props; return ( - -
- {children} -
-
+ + +
+ {children} +
+
+
); } diff --git a/frontend/src/Components/Page/PageContentError.css b/frontend/src/Components/Page/PageContentError.css new file mode 100644 index 000000000..7b1f7a6db --- /dev/null +++ b/frontend/src/Components/Page/PageContentError.css @@ -0,0 +1,3 @@ +.content { + composes: content from './PageContent.css'; +} diff --git a/frontend/src/Components/Page/PageContentError.js b/frontend/src/Components/Page/PageContentError.js new file mode 100644 index 000000000..5ae41a936 --- /dev/null +++ b/frontend/src/Components/Page/PageContentError.js @@ -0,0 +1,19 @@ +import React from 'react'; +import ErrorBoundaryError from 'Components/Error/ErrorBoundaryError'; +import PageContentBodyConnector from './PageContentBodyConnector'; +import styles from './PageContentError.css'; + +function PageContentError(props) { + return ( +
+ + + +
+ ); +} + +export default PageContentError; diff --git a/frontend/src/Components/Page/PageJumpBar.js b/frontend/src/Components/Page/PageJumpBar.js index 25844ec94..f7d44ae9a 100644 --- a/frontend/src/Components/Page/PageJumpBar.js +++ b/frontend/src/Components/Page/PageJumpBar.js @@ -1,8 +1,8 @@ import _ from 'lodash'; import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import Measure from 'react-measure'; import dimensions from 'Styles/Variables/dimensions'; +import Measure from 'Components/Measure'; import PageJumpBarItem from './PageJumpBarItem'; import styles from './PageJumpBar.css'; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarSection.js b/frontend/src/Components/Page/Toolbar/PageToolbarSection.js index 82e30f6eb..57b53ff4e 100644 --- a/frontend/src/Components/Page/Toolbar/PageToolbarSection.js +++ b/frontend/src/Components/Page/Toolbar/PageToolbarSection.js @@ -1,11 +1,11 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import Measure from 'react-measure'; import classNames from 'classnames'; import { forEach } from 'Helpers/elementChildren'; import { align, icons } from 'Helpers/Props'; import dimensions from 'Styles/Variables/dimensions'; import SpinnerIcon from 'Components/SpinnerIcon'; +import Measure from 'Components/Measure'; import Menu from 'Components/Menu/Menu'; import MenuContent from 'Components/Menu/MenuContent'; import MenuItem from 'Components/Menu/MenuItem'; diff --git a/frontend/src/Components/Table/VirtualTable.js b/frontend/src/Components/Table/VirtualTable.js index 6d9af2382..a13807647 100644 --- a/frontend/src/Components/Table/VirtualTable.js +++ b/frontend/src/Components/Table/VirtualTable.js @@ -1,9 +1,9 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import ReactDOM from 'react-dom'; -import Measure from 'react-measure'; import { WindowScroller } from 'react-virtualized'; import { scrollDirections } from 'Helpers/Props'; +import Measure from 'Components/Measure'; import Scroller from 'Components/Scroller/Scroller'; import VirtualTableBody from './VirtualTableBody'; import styles from './VirtualTable.css'; diff --git a/frontend/src/Content/Images/error.png b/frontend/src/Content/Images/error.png new file mode 100644 index 000000000..9b1ae7746 Binary files /dev/null and b/frontend/src/Content/Images/error.png differ diff --git a/frontend/src/Helpers/Props/filterBuilderValueTypes.js b/frontend/src/Helpers/Props/filterBuilderValueTypes.js index 3d097055a..37bc6d2ad 100644 --- a/frontend/src/Helpers/Props/filterBuilderValueTypes.js +++ b/frontend/src/Helpers/Props/filterBuilderValueTypes.js @@ -1,4 +1,5 @@ export const BOOL = 'bool'; +export const BYTES = 'bytes'; export const DATE = 'date'; export const DEFAULT = 'default'; export const INDEXER = 'indexer'; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js index ae8a76391..2d061e4c5 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js @@ -9,7 +9,7 @@ import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRowCellButton from 'Components/Table/Cells/TableRowCellButton'; import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; import Popover from 'Components/Tooltip/Popover'; -import EpisodeQuality from 'Album/EpisodeQuality'; +import TrackQuality from 'Album/TrackQuality'; import EpisodeLanguage from 'Album/EpisodeLanguage'; import SelectArtistModal from 'InteractiveImport/Artist/SelectArtistModal'; import SelectAlbumModal from 'InteractiveImport/Album/SelectAlbumModal'; @@ -248,7 +248,7 @@ class InteractiveImportRow extends Component { { !showQualityPlaceholder && !!quality && - diff --git a/frontend/src/Album/Search/InteractiveSearchFilterModalConnector.js b/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js similarity index 64% rename from frontend/src/Album/Search/InteractiveSearchFilterModalConnector.js rename to frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js index 65f9a2d45..6c40023d1 100644 --- a/frontend/src/Album/Search/InteractiveSearchFilterModalConnector.js +++ b/frontend/src/InteractiveSearch/InteractiveSearchFilterModalConnector.js @@ -1,6 +1,6 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import * as releaseActions from 'Store/Actions/releaseActions'; +import { setReleasesFilter } from 'Store/Actions/releaseActions'; import FilterModal from 'Components/Filter/FilterModal'; function createMapStateToProps() { @@ -10,7 +10,8 @@ function createMapStateToProps() { (sectionItems, filterBuilderProps) => { return { sectionItems, - filterBuilderProps + filterBuilderProps, + customFilterType: 'releases' }; } ); @@ -18,12 +19,10 @@ function createMapStateToProps() { function createMapDispatchToProps(dispatch, props) { return { - onRemoveCustomFilterPress(payload) { - dispatch(releaseActions.removeReleasesCustomFilter(payload)); - }, + dispatchSetFilter(payload) { + const action = setReleasesFilter; - onSaveCustomFilterPress(payload) { - dispatch(releaseActions.saveReleasesCustomFilter(payload)); + dispatch(action(payload)); } }; } diff --git a/frontend/src/Album/Search/InteractiveAlbumSearchModal.js b/frontend/src/InteractiveSearch/InteractiveSearchModal.js similarity index 60% rename from frontend/src/Album/Search/InteractiveAlbumSearchModal.js rename to frontend/src/InteractiveSearch/InteractiveSearchModal.js index 7b4081fbf..7b4b9ffdb 100644 --- a/frontend/src/Album/Search/InteractiveAlbumSearchModal.js +++ b/frontend/src/InteractiveSearch/InteractiveSearchModal.js @@ -1,9 +1,9 @@ import PropTypes from 'prop-types'; import React from 'react'; import Modal from 'Components/Modal/Modal'; -import InteractiveAlbumSearchModalContentConnector from './InteractiveAlbumSearchModalContentConnector'; +import InteractiveSearchModalContentConnector from './InteractiveSearchModalContentConnector'; -function InteractiveAlbumSearchModal(props) { +function InteractiveSearchModal(props) { const { isOpen, onModalClose, @@ -15,7 +15,7 @@ function InteractiveAlbumSearchModal(props) { isOpen={isOpen} onModalClose={onModalClose} > - @@ -23,9 +23,9 @@ function InteractiveAlbumSearchModal(props) { ); } -InteractiveAlbumSearchModal.propTypes = { +InteractiveSearchModal.propTypes = { isOpen: PropTypes.bool.isRequired, onModalClose: PropTypes.func.isRequired }; -export default InteractiveAlbumSearchModal; +export default InteractiveSearchModal; diff --git a/frontend/src/Album/Search/InteractiveAlbumSearchModalContent.css b/frontend/src/InteractiveSearch/InteractiveSearchModalContent.css similarity index 100% rename from frontend/src/Album/Search/InteractiveAlbumSearchModalContent.css rename to frontend/src/InteractiveSearch/InteractiveSearchModalContent.css diff --git a/frontend/src/Album/Search/InteractiveAlbumSearchModalContent.js b/frontend/src/InteractiveSearch/InteractiveSearchModalContent.js similarity index 93% rename from frontend/src/Album/Search/InteractiveAlbumSearchModalContent.js rename to frontend/src/InteractiveSearch/InteractiveSearchModalContent.js index 10bb77556..36a6b670a 100644 --- a/frontend/src/Album/Search/InteractiveAlbumSearchModalContent.js +++ b/frontend/src/InteractiveSearch/InteractiveSearchModalContent.js @@ -13,8 +13,8 @@ import ModalFooter from 'Components/Modal/ModalFooter'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import InteractiveSearchFilterModalConnector from './InteractiveSearchFilterModalConnector'; -import InteractiveAlbumSearchRow from './InteractiveAlbumSearchRow'; -import styles from './InteractiveAlbumSearchModalContent.css'; +import InteractiveSearchRow from './InteractiveSearchRow'; +import styles from './InteractiveSearchModalContent.css'; const columns = [ { @@ -75,7 +75,7 @@ const columns = [ } ]; -class InteractiveAlbumSearchModalContent extends Component { +class InteractiveSearchModalContent extends Component { // // Render @@ -161,7 +161,7 @@ class InteractiveAlbumSearchModalContent extends Component { { items.map((item) => { return ( - ); } } -InteractiveAlbumSearchModalContentConnector.propTypes = { +InteractiveSearchModalContentConnector.propTypes = { albumId: PropTypes.number, dispatchFetchReleases: PropTypes.func.isRequired, dispatchClearReleases: PropTypes.func.isRequired, dispatchCancelFetchReleases: PropTypes.func.isRequired }; -export default connect(createMapStateToProps, createMapDispatchToProps)(InteractiveAlbumSearchModalContentConnector); +export default connect(createMapStateToProps, createMapDispatchToProps)(InteractiveSearchModalContentConnector); diff --git a/frontend/src/Album/Search/InteractiveAlbumSearchRow.css b/frontend/src/InteractiveSearch/InteractiveSearchRow.css similarity index 100% rename from frontend/src/Album/Search/InteractiveAlbumSearchRow.css rename to frontend/src/InteractiveSearch/InteractiveSearchRow.css diff --git a/frontend/src/Album/Search/InteractiveAlbumSearchRow.js b/frontend/src/InteractiveSearch/InteractiveSearchRow.js similarity index 94% rename from frontend/src/Album/Search/InteractiveAlbumSearchRow.js rename to frontend/src/InteractiveSearch/InteractiveSearchRow.js index 2aadc3207..d12fabd52 100644 --- a/frontend/src/Album/Search/InteractiveAlbumSearchRow.js +++ b/frontend/src/InteractiveSearch/InteractiveSearchRow.js @@ -10,10 +10,10 @@ import Link from 'Components/Link/Link'; import TableRow from 'Components/Table/TableRow'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import Popover from 'Components/Tooltip/Popover'; -import EpisodeQuality from 'Album/EpisodeQuality'; +import TrackQuality from 'Album/TrackQuality'; import ProtocolLabel from 'Activity/Queue/ProtocolLabel'; import Peers from './Peers'; -import styles from './InteractiveAlbumSearchRow.css'; +import styles from './InteractiveSearchRow.css'; function getDownloadIcon(isGrabbing, isGrabbed, grabError) { if (isGrabbing) { @@ -39,7 +39,7 @@ function getDownloadTooltip(isGrabbing, isGrabbed, grabError) { return 'Add to downloaded queue'; } -class InteractiveAlbumSearchRow extends Component { +class InteractiveSearchRow extends Component { // // Listeners @@ -120,7 +120,7 @@ class InteractiveAlbumSearchRow extends Component {
- @@ -171,7 +171,7 @@ class InteractiveAlbumSearchRow extends Component { } } -InteractiveAlbumSearchRow.propTypes = { +InteractiveSearchRow.propTypes = { guid: PropTypes.string.isRequired, protocol: PropTypes.string.isRequired, age: PropTypes.number.isRequired, @@ -196,9 +196,9 @@ InteractiveAlbumSearchRow.propTypes = { onGrabPress: PropTypes.func.isRequired }; -InteractiveAlbumSearchRow.defaultProps = { +InteractiveSearchRow.defaultProps = { isGrabbing: false, isGrabbed: false }; -export default InteractiveAlbumSearchRow; +export default InteractiveSearchRow; diff --git a/frontend/src/Album/Search/Peers.js b/frontend/src/InteractiveSearch/Peers.js similarity index 100% rename from frontend/src/Album/Search/Peers.js rename to frontend/src/InteractiveSearch/Peers.js diff --git a/frontend/src/Settings/Profiles/Delay/DelayProfiles.js b/frontend/src/Settings/Profiles/Delay/DelayProfiles.js index a8ae63379..a745da9d4 100644 --- a/frontend/src/Settings/Profiles/Delay/DelayProfiles.js +++ b/frontend/src/Settings/Profiles/Delay/DelayProfiles.js @@ -1,10 +1,10 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import Measure from 'react-measure'; import { icons } from 'Helpers/Props'; import FieldSet from 'Components/FieldSet'; import Icon from 'Components/Icon'; import Link from 'Components/Link/Link'; +import Measure from 'Components/Measure'; import PageSectionContent from 'Components/Page/PageSectionContent'; import DelayProfileDragSource from './DelayProfileDragSource'; import DelayProfileDragPreview from './DelayProfileDragPreview'; diff --git a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js index c14359877..ece3df17c 100644 --- a/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js +++ b/frontend/src/Settings/Profiles/Quality/EditQualityProfileModalContent.js @@ -1,11 +1,11 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import Measure from 'react-measure'; import { inputTypes, kinds, sizes } from 'Helpers/Props'; import dimensions from 'Styles/Variables/dimensions'; import Button from 'Components/Link/Button'; import SpinnerErrorButton from 'Components/Link/SpinnerErrorButton'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Measure from 'Components/Measure'; import ModalContent from 'Components/Modal/ModalContent'; import ModalHeader from 'Components/Modal/ModalHeader'; import ModalBody from 'Components/Modal/ModalBody'; diff --git a/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js index 66bd25e7f..c41d4b77d 100644 --- a/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js +++ b/frontend/src/Settings/Profiles/Quality/QualityProfileItems.js @@ -1,12 +1,12 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; -import Measure from 'react-measure'; import { icons, kinds, sizes } from 'Helpers/Props'; import Icon from 'Components/Icon'; import Button from 'Components/Link/Button'; import FormGroup from 'Components/Form/FormGroup'; import FormLabel from 'Components/Form/FormLabel'; import FormInputHelpText from 'Components/Form/FormInputHelpText'; +import Measure from 'Components/Measure'; import QualityProfileItemDragSource from './QualityProfileItemDragSource'; import QualityProfileItemDragPreview from './QualityProfileItemDragPreview'; import styles from './QualityProfileItems.css'; diff --git a/frontend/src/Store/Actions/Creators/Reducers/createCustomFilterReducers.js b/frontend/src/Store/Actions/Creators/Reducers/createCustomFilterReducers.js deleted file mode 100644 index 8bec20cfc..000000000 --- a/frontend/src/Store/Actions/Creators/Reducers/createCustomFilterReducers.js +++ /dev/null @@ -1,65 +0,0 @@ -import customFilterHandlers from 'Utilities/customFilterHandlers'; -import getSectionState from 'Utilities/State/getSectionState'; -import updateSectionState from 'Utilities/State/updateSectionState'; -import generateUUIDv4 from 'Utilities/String/generateUUIDv4'; - -function createRemoveCustomFilterReducer(section) { - return (state, { payload }) => { - const newState = getSectionState(state, section); - const index = newState.customFilters.findIndex((c) => c.key === payload.key); - - newState.customFilters = [...newState.customFilters]; - newState.customFilters.splice(index, 1); - - // Reset the selected filter to the first filter if the selected filter - // is being deleted. - // TODO: Server side collections need to have their collections refetched - - if (newState.selectedFilterKey === payload.key) { - newState.selectedFilterKey = newState.filters[0].key; - } - - return updateSectionState(state, section, newState); - }; -} - -function createSaveCustomFilterReducer(section) { - return (state, { payload }) => { - const newState = getSectionState(state, section); - - const { - label, - filters - } = payload; - - let key = payload.key; - - newState.customFilters = [...newState.customFilters]; - - if (key) { - const index = newState.customFilters.findIndex((c) => c.key === key); - - newState.customFilters.splice(index, 1, { key, label, filters }); - } else { - key = generateUUIDv4(); - - newState.customFilters.push({ - key, - label, - filters - }); - } - - // TODO: Server side collections need to have their collections refetched - newState.selectedFilterKey = key; - - return updateSectionState(state, section, newState); - }; -} - -export default function createCustomFilterReducers(section, handlers) { - return { - [handlers[customFilterHandlers.REMOVE]]: createRemoveCustomFilterReducer(section), - [handlers[customFilterHandlers.SAVE]]: createSaveCustomFilterReducer(section) - }; -} diff --git a/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js index 9190c9a62..6f353ed17 100644 --- a/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js +++ b/frontend/src/Store/Actions/Creators/createRemoveItemHandler.js @@ -20,13 +20,13 @@ function createRemoveItemHandler(section, url) { promise.done((data) => { dispatch(batchActions([ - removeItem({ section, id }), - set({ section, isDeleting: false, deleteError: null - }) + }), + + removeItem({ section, id }) ])); }); diff --git a/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js b/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js index 6533b5b0c..6d555bf23 100644 --- a/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js +++ b/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js @@ -21,10 +21,11 @@ function createSaveProviderHandler(section, url, options = {}) { const { id, - queryParams = {} + queryParams = {}, + ...otherPayload } = payload; - const saveData = getProviderState(payload, getState, section); + const saveData = getProviderState({ id, ...otherPayload }, getState, section); const ajaxOptions = { url: `${url}?${$.param(queryParams, true)}`, diff --git a/frontend/src/Store/Actions/albumStudioActions.js b/frontend/src/Store/Actions/albumStudioActions.js index 9221e8ba5..11302ccbd 100644 --- a/frontend/src/Store/Actions/albumStudioActions.js +++ b/frontend/src/Store/Actions/albumStudioActions.js @@ -2,7 +2,7 @@ import _ from 'lodash'; import $ from 'jquery'; import { createAction } from 'redux-actions'; import getMonitoringOptions from 'Utilities/Artist/getMonitoringOptions'; -import { sortDirections } from 'Helpers/Props'; +import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; @@ -29,7 +29,55 @@ export const defaultState = { selectedFilterKey: 'all', filters, filterPredicates, - customFilters: [] + + filterBuilderProps: [ + { + name: 'monitored', + label: 'Monitored', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.BOOL + }, + { + name: 'status', + label: 'Status', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.ARTIST_STATUS + }, + { + name: 'artistType', + label: 'Artist Type', + type: filterBuilderTypes.EXACT + }, + { + name: 'qualityProfileId', + label: 'Quality Profile', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.QUALITY_PROFILE + }, + { + name: 'languageProfileId', + label: 'Language Profile', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.LANGUAGE_PROFILE + }, + { + name: 'metadataProfileId', + label: 'Metadata Profile', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.METADATA_PROFILE + }, + { + name: 'rootFolderPath', + label: 'Root Folder Path', + type: filterBuilderTypes.EXACT + }, + { + name: 'tags', + label: 'Tags', + type: filterBuilderTypes.ARRAY, + valueType: filterBuilderValueTypes.TAG + } + ] }; export const persistState = [ diff --git a/frontend/src/Store/Actions/artistActions.js b/frontend/src/Store/Actions/artistActions.js index d96fb6370..5b82ae622 100644 --- a/frontend/src/Store/Actions/artistActions.js +++ b/frontend/src/Store/Actions/artistActions.js @@ -103,6 +103,20 @@ export const filterPredicates = { const predicate = filterTypePredicates[type]; return predicate(item.ratings.value * 10, filterValue); + }, + + albumCount: function(item, filterValue, type) { + const predicate = filterTypePredicates[type]; + const albumCount = item.statistics ? item.statistics.albumCount : 0; + + return predicate(albumCount, filterValue); + }, + + sizeOnDisk: function(item, filterValue, type) { + const predicate = filterTypePredicates[type]; + const sizeOnDisk = item.statistics ? item.statistics.sizeOnDisk : 0; + + return predicate(sizeOnDisk, filterValue); } }; diff --git a/frontend/src/Store/Actions/artistEditorActions.js b/frontend/src/Store/Actions/artistEditorActions.js index af786b1b4..34cb0ef19 100644 --- a/frontend/src/Store/Actions/artistEditorActions.js +++ b/frontend/src/Store/Actions/artistEditorActions.js @@ -1,12 +1,10 @@ import $ from 'jquery'; import { createAction } from 'redux-actions'; import { batchActions } from 'redux-batched-actions'; -import customFilterHandlers from 'Utilities/customFilterHandlers'; import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; -import createCustomFilterReducers from './Creators/Reducers/createCustomFilterReducers'; import createHandleActions from './Creators/createHandleActions'; import { set, updateItem } from './baseActions'; import { filters, filterPredicates } from './artistActions'; @@ -79,8 +77,7 @@ export const defaultState = { type: filterBuilderTypes.ARRAY, valueType: filterBuilderValueTypes.TAG } - ], - customFilters: [] + ] }; export const persistState = [ @@ -97,8 +94,6 @@ export const SET_ARTIST_EDITOR_SORT = 'artistEditor/setArtistEditorSort'; export const SET_ARTIST_EDITOR_FILTER = 'artistEditor/setArtistEditorFilter'; export const SAVE_ARTIST_EDITOR = 'artistEditor/saveArtistEditor'; export const BULK_DELETE_ARTIST = 'artistEditor/bulkDeleteArtist'; -export const REMOVE_ARTIST_EDITOR_CUSTOM_FILTER = 'artistEditor/removeArtistEditorCustomFilter'; -export const SAVE_ARTIST_EDITOR_CUSTOM_FILTER = 'artistEditor/saveArtistEditorCustomFilter'; // // Action Creators @@ -107,8 +102,6 @@ export const setArtistEditorSort = createAction(SET_ARTIST_EDITOR_SORT); export const setArtistEditorFilter = createAction(SET_ARTIST_EDITOR_FILTER); export const saveArtistEditor = createThunk(SAVE_ARTIST_EDITOR); export const bulkDeleteArtist = createThunk(BULK_DELETE_ARTIST); -export const removeArtistEditorCustomFilter = createAction(REMOVE_ARTIST_EDITOR_CUSTOM_FILTER); -export const saveArtistEditorCustomFilter = createAction(SAVE_ARTIST_EDITOR_CUSTOM_FILTER); // // Action Handlers @@ -193,11 +186,6 @@ export const actionHandlers = handleThunks({ export const reducers = createHandleActions({ [SET_ARTIST_EDITOR_SORT]: createSetClientSideCollectionSortReducer(section), - [SET_ARTIST_EDITOR_FILTER]: createSetClientSideCollectionFilterReducer(section), - - ...createCustomFilterReducers(section, { - [customFilterHandlers.REMOVE]: REMOVE_ARTIST_EDITOR_CUSTOM_FILTER, - [customFilterHandlers.SAVE]: SAVE_ARTIST_EDITOR_CUSTOM_FILTER - }) + [SET_ARTIST_EDITOR_FILTER]: createSetClientSideCollectionFilterReducer(section) }, defaultState, section); diff --git a/frontend/src/Store/Actions/artistIndexActions.js b/frontend/src/Store/Actions/artistIndexActions.js index 9c081f3ed..efb787601 100644 --- a/frontend/src/Store/Actions/artistIndexActions.js +++ b/frontend/src/Store/Actions/artistIndexActions.js @@ -1,12 +1,10 @@ import moment from 'moment'; import { createAction } from 'redux-actions'; -import customFilterHandlers from 'Utilities/customFilterHandlers'; import sortByName from 'Utilities/Array/sortByName'; import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; -import createCustomFilterReducers from './Creators/Reducers/createCustomFilterReducers'; import createHandleActions from './Creators/createHandleActions'; import { filters, filterPredicates } from './artistActions'; @@ -292,7 +290,8 @@ export const defaultState = { { name: 'sizeOnDisk', label: 'Size on Disk', - type: filterBuilderTypes.NUMBER + type: filterBuilderTypes.NUMBER, + valueType: filterBuilderValueTypes.BYTES }, { name: 'genres', @@ -324,8 +323,7 @@ export const defaultState = { type: filterBuilderTypes.ARRAY, valueType: filterBuilderValueTypes.TAG } - ], - customFilters: [] + ] }; export const persistState = [ @@ -350,8 +348,6 @@ export const SET_ARTIST_TABLE_OPTION = 'artistIndex/setArtistTableOption'; export const SET_ARTIST_POSTER_OPTION = 'artistIndex/setArtistPosterOption'; export const SET_ARTIST_BANNER_OPTION = 'artistIndex/setArtistBannerOption'; export const SET_ARTIST_OVERVIEW_OPTION = 'artistIndex/setArtistOverviewOption'; -export const REMOVE_ARTIST_CUSTOM_FILTER = 'artistIndex/removeArtistCustomFilter'; -export const SAVE_ARTIST_CUSTOM_FILTER = 'artistIndex/saveArtistCustomFilter'; // // Action Creators @@ -363,8 +359,7 @@ export const setArtistTableOption = createAction(SET_ARTIST_TABLE_OPTION); export const setArtistPosterOption = createAction(SET_ARTIST_POSTER_OPTION); export const setArtistBannerOption = createAction(SET_ARTIST_BANNER_OPTION); export const setArtistOverviewOption = createAction(SET_ARTIST_OVERVIEW_OPTION); -export const removeArtistCustomFilter = createAction(REMOVE_ARTIST_CUSTOM_FILTER); -export const saveArtistCustomFilter = createAction(SAVE_ARTIST_CUSTOM_FILTER); + // // Reducers @@ -413,11 +408,6 @@ export const reducers = createHandleActions({ ...payload } }; - }, - - ...createCustomFilterReducers(section, { - [customFilterHandlers.REMOVE]: REMOVE_ARTIST_CUSTOM_FILTER, - [customFilterHandlers.SAVE]: SAVE_ARTIST_CUSTOM_FILTER - }) + } }, defaultState, section); diff --git a/frontend/src/Store/Actions/customFilterActions.js b/frontend/src/Store/Actions/customFilterActions.js new file mode 100644 index 000000000..750c3ef6f --- /dev/null +++ b/frontend/src/Store/Actions/customFilterActions.js @@ -0,0 +1,55 @@ +import { createThunk, handleThunks } from 'Store/thunks'; +import createFetchHandler from './Creators/createFetchHandler'; +import createRemoveItemHandler from './Creators/createRemoveItemHandler'; +import createSaveProviderHandler from './Creators/createSaveProviderHandler'; +import createHandleActions from './Creators/createHandleActions'; + +// +// Variables + +export const section = 'customFilters'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + isSaving: false, + saveError: null, + isDeleting: false, + deleteError: null, + items: [], + pendingChanges: {} +}; + +// +// Actions Types + +export const FETCH_CUSTOM_FILTERS = 'customFilters/fetchCustomFilters'; +export const SAVE_CUSTOM_FILTER = 'customFilters/saveCustomFilter'; +export const DELETE_CUSTOM_FILTER = 'customFilters/deleteCustomFilter'; + +// +// Action Creators + +export const fetchCustomFilters = createThunk(FETCH_CUSTOM_FILTERS); +export const saveCustomFilter = createThunk(SAVE_CUSTOM_FILTER); +export const deleteCustomFilter = createThunk(DELETE_CUSTOM_FILTER); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + [FETCH_CUSTOM_FILTERS]: createFetchHandler(section, '/customFilter'), + + [SAVE_CUSTOM_FILTER]: createSaveProviderHandler(section, '/customFilter'), + + [DELETE_CUSTOM_FILTER]: createRemoveItemHandler(section, '/customFilter') + +}); + +// +// Reducers +export const reducers = createHandleActions({}, defaultState, section); diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index 9f8e162bd..3a8e5ebc0 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -2,6 +2,7 @@ import * as addArtist from './addArtistActions'; import * as app from './appActions'; import * as blacklist from './blacklistActions'; import * as captcha from './captchaActions'; +import * as customFilters from './customFilterActions'; import * as devices from './deviceActions'; import * as calendar from './calendarActions'; import * as commands from './commandActions'; @@ -35,6 +36,7 @@ export default [ captcha, calendar, commands, + customFilters, devices, albums, trackFiles, diff --git a/frontend/src/Store/Actions/oAuthActions.js b/frontend/src/Store/Actions/oAuthActions.js index d0b3bb930..8a8d2c596 100644 --- a/frontend/src/Store/Actions/oAuthActions.js +++ b/frontend/src/Store/Actions/oAuthActions.js @@ -10,6 +10,7 @@ import createHandleActions from './Creators/createHandleActions'; // Variables export const section = 'oAuth'; +const callbackUrl = `${window.location.origin}${window.Lidarr.urlBase}/oauth.html`; // // State @@ -64,6 +65,19 @@ function showOAuthWindow(url) { return deferred.promise(); } +function executeIntermediateRequest(payload, ajaxOptions) { + return $.ajax(ajaxOptions).then((data) => { + return requestAction({ + action: 'continueOAuth', + queryParams: { + ...data, + callbackUrl + }, + ...payload + }); + }); +} + // // Action Handlers @@ -72,7 +86,7 @@ export const actionHandlers = handleThunks({ [START_OAUTH]: function(getState, payload, dispatch) { const actionPayload = { action: 'startOAuth', - queryParams: { callbackUrl: `${window.location.origin}${window.Lidarr.urlBase}/oauth.html` }, + queryParams: { callbackUrl }, ...payload }; @@ -85,7 +99,16 @@ export const actionHandlers = handleThunks({ const promise = requestAction(actionPayload) .then((response) => { startResponse = response; - return showOAuthWindow(response.oauthUrl); + + if (response.oauthUrl) { + return showOAuthWindow(response.oauthUrl); + } + + return executeIntermediateRequest(payload, response).then((intermediateResponse) => { + startResponse = intermediateResponse; + + return showOAuthWindow(intermediateResponse.oauthUrl); + }); }) .then((queryParams) => { return requestAction({ diff --git a/frontend/src/Store/Actions/releaseActions.js b/frontend/src/Store/Actions/releaseActions.js index 62a1bbc4b..9d9ec2161 100644 --- a/frontend/src/Store/Actions/releaseActions.js +++ b/frontend/src/Store/Actions/releaseActions.js @@ -1,11 +1,9 @@ import $ from 'jquery'; import { createAction } from 'redux-actions'; -import customFilterHandlers from 'Utilities/customFilterHandlers'; import { filterBuilderTypes, filterBuilderValueTypes, filterTypes, sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; -import createCustomFilterReducers from './Creators/Reducers/createCustomFilterReducers'; import createFetchHandler from './Creators/createFetchHandler'; import createHandleActions from './Creators/createHandleActions'; @@ -45,8 +43,6 @@ export const defaultState = { } }, - selectedFilterKey: 'all', - filters: [ { key: 'all', @@ -143,9 +139,7 @@ export const defaultState = { label: 'Rejections', type: filterBuilderTypes.NUMBER } - ], - - customFilters: [] + ] }; export const persistState = [ @@ -163,9 +157,6 @@ export const CLEAR_RELEASES = 'releases/clearReleases'; export const GRAB_RELEASE = 'releases/grabRelease'; export const UPDATE_RELEASE = 'releases/updateRelease'; export const SET_RELEASES_FILTER = 'releases/setReleasesFilter'; -export const ADD_RELEASES_CUSTOM_FILTER = 'releases/addReleasesCustomFilter'; -export const REMOVE_RELEASES_CUSTOM_FILTER = 'releases/removeReleasesCustomFilter'; -export const SAVE_RELEASES_CUSTOM_FILTER = 'releases/saveReleasesCustomFilter'; // // Action Creators @@ -177,9 +168,6 @@ export const clearReleases = createAction(CLEAR_RELEASES); export const grabRelease = createThunk(GRAB_RELEASE); export const updateRelease = createAction(UPDATE_RELEASE); export const setReleasesFilter = createAction(SET_RELEASES_FILTER); -export const addReleasesCustomFilter = createAction(ADD_RELEASES_CUSTOM_FILTER); -export const removeReleasesCustomFilter = createAction(REMOVE_RELEASES_CUSTOM_FILTER); -export const saveReleasesCustomFilter = createAction(SAVE_RELEASES_CUSTOM_FILTER); // // Helpers @@ -266,11 +254,6 @@ export const reducers = createHandleActions({ }, [SET_RELEASES_SORT]: createSetClientSideCollectionSortReducer(section), - [SET_RELEASES_FILTER]: createSetClientSideCollectionFilterReducer(section), - - ...createCustomFilterReducers(section, { - [customFilterHandlers.REMOVE]: REMOVE_RELEASES_CUSTOM_FILTER, - [customFilterHandlers.SAVE]: SAVE_RELEASES_CUSTOM_FILTER - }) + [SET_RELEASES_FILTER]: createSetClientSideCollectionFilterReducer(section) }, defaultState, section); diff --git a/frontend/src/Store/Middleware/createSentryMiddleware.js b/frontend/src/Store/Middleware/createSentryMiddleware.js new file mode 100644 index 000000000..41109a0c7 --- /dev/null +++ b/frontend/src/Store/Middleware/createSentryMiddleware.js @@ -0,0 +1,91 @@ +import _ from 'lodash'; +import * as sentry from '@sentry/browser'; +import parseUrl from 'Utilities/String/parseUrl'; + +function cleanseUrl(url) { + const properties = parseUrl(url); + + return `${properties.pathname}${properties.search}`; +} + +function cleanseData(data) { + const result = _.cloneDeep(data); + + result.transaction = cleanseUrl(result.transaction); + + if (result.exception) { + result.exception.values.forEach((exception) => { + const stacktrace = exception.stacktrace; + + if (stacktrace) { + stacktrace.frames.forEach((frame) => { + frame.filename = cleanseUrl(frame.filename); + }); + } + }); + } + + result.request.url = cleanseUrl(result.request.url); + + return result; +} + +function identity(stuff) { + return stuff; +} + +function createMiddleware() { + return (store) => (next) => (action) => { + try { + // Adds a breadcrumb for reporting later (if necessary). + sentry.addBreadcrumb({ + category: 'redux', + message: action.type + }); + + return next(action); + } catch (err) { + console.error(`[sentry] Reporting error to Sentry: ${err}`); + + // Send the report including breadcrumbs. + sentry.captureException(err, { + extra: { + action: identity(action), + state: identity(store.getState()) + } + }); + } + }; +} + +export default function createSentryMiddleware() { + const { + analytics, + branch, + version, + release, + isProduction + } = window.Lidarr; + + if (!analytics) { + return; + } + + const dsn = isProduction ? 'https://c3a5b33e08de4e18b7d0505e942dbc95@sentry.io/216290' : + 'https://baede6f14da54cf48ff431479e400adf@sentry.io/1249427'; + + sentry.init({ + dsn, + environment: isProduction ? 'production' : 'development', + release, + sendDefaultPii: true, + beforeSend: cleanseData + }); + + sentry.configureScope((scope) => { + scope.setTag('branch', branch); + scope.setTag('version', version); + }); + + return createMiddleware(); +} diff --git a/frontend/src/Store/Middleware/middlewares.js b/frontend/src/Store/Middleware/middlewares.js index 5583c21ef..a1c400bfe 100644 --- a/frontend/src/Store/Middleware/middlewares.js +++ b/frontend/src/Store/Middleware/middlewares.js @@ -1,15 +1,15 @@ import { applyMiddleware, compose } from 'redux'; import thunk from 'redux-thunk'; import { routerMiddleware } from 'react-router-redux'; -import sentryMiddleware from './sentryMiddleware'; +import createSentryMiddleware from './createSentryMiddleware'; import persistState from './persistState'; export default function(history) { const middlewares = []; - const ravenMiddleware = sentryMiddleware(); + const sentryMiddleware = createSentryMiddleware(); - if (ravenMiddleware) { - middlewares.push(ravenMiddleware); + if (sentryMiddleware) { + middlewares.push(sentryMiddleware); } middlewares.push(routerMiddleware(history)); diff --git a/frontend/src/Store/Middleware/sentryMiddleware.js b/frontend/src/Store/Middleware/sentryMiddleware.js deleted file mode 100644 index 454e1fd24..000000000 --- a/frontend/src/Store/Middleware/sentryMiddleware.js +++ /dev/null @@ -1,51 +0,0 @@ -import _ from 'lodash'; -import Raven from 'raven-js'; -import createRavenMiddleware from 'raven-for-redux'; -import parseUrl from 'Utilities/String/parseUrl'; - -function cleanseUrl(url) { - const properties = parseUrl(url); - - return `${properties.pathname}${properties.search}`; -} - -function cleanseData(data) { - const result = _.cloneDeep(data); - - result.culprit = cleanseUrl(result.culprit); - result.request.url = cleanseUrl(result.request.url); - - return result; -} - -export default function sentryMiddleware() { - const { - analytics, - branch, - version, - release, - isProduction - } = window.Lidarr; - - if (!analytics) { - return; - } - - const dsn = isProduction ? 'https://c3a5b33e08de4e18b7d0505e942dbc95@sentry.io/216290' : - 'https://baede6f14da54cf48ff431479e400adf@sentry.io/1249427'; - - Raven.config( - dsn, - { - environment: isProduction ? 'production' : 'development', - release, - tags: { - branch, - version - }, - dataCallback: cleanseData - } - ).install(); - - return createRavenMiddleware(Raven); -} diff --git a/frontend/src/Store/Selectors/createClientSideCollectionSelector.js b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js index c00adc159..929b0afe0 100644 --- a/frontend/src/Store/Selectors/createClientSideCollectionSelector.js +++ b/frontend/src/Store/Selectors/createClientSideCollectionSelector.js @@ -94,12 +94,24 @@ function sort(items, state) { return _.orderBy(items, clauses, orders); } +function createCustomFiltersSelector(type, alternateType) { + return createSelector( + (state) => state.customFilters.items, + (customFilters) => { + return customFilters.filter((customFilter) => { + return customFilter.type === type || customFilter.type === alternateType; + }); + } + ); +} + function createClientSideCollectionSelector(section, uiSection) { return createSelector( (state) => _.get(state, section), (state) => _.get(state, uiSection), - (sectionState, uiSectionState = {}) => { - const state = Object.assign({}, sectionState, uiSectionState); + createCustomFiltersSelector(section, uiSection), + (sectionState, uiSectionState = {}, customFilters) => { + const state = Object.assign({}, sectionState, uiSectionState, { customFilters }); const filtered = filter(state.items, state); const sorted = sort(filtered, state); @@ -107,6 +119,7 @@ function createClientSideCollectionSelector(section, uiSection) { return { ...sectionState, ...uiSectionState, + customFilters, items: sorted, totalItems: state.items.length }; diff --git a/frontend/src/System/Status/About/About.js b/frontend/src/System/Status/About/About.js index a177913c2..a48ef1fed 100644 --- a/frontend/src/System/Status/About/About.js +++ b/frontend/src/System/Status/About/About.js @@ -4,6 +4,7 @@ import titleCase from 'Utilities/String/titleCase'; import FieldSet from 'Components/FieldSet'; import DescriptionList from 'Components/DescriptionList/DescriptionList'; import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import StartTime from './StartTime'; import styles from './About.css'; class About extends Component { @@ -19,7 +20,10 @@ class About extends Component { migrationVersion, appData, startupPath, - mode + mode, + startTime, + timeFormat, + longDateFormat } = this.props; return ( @@ -57,6 +61,17 @@ class About extends Component { title="Mode" data={titleCase(mode)} /> + + + } + /> ); @@ -65,13 +80,16 @@ class About extends Component { } About.propTypes = { - version: PropTypes.string, - isMonoRuntime: PropTypes.bool, - runtimeVersion: PropTypes.string, - migrationVersion: PropTypes.number, - appData: PropTypes.string, - startupPath: PropTypes.string, - mode: PropTypes.string + version: PropTypes.string.isRequired, + isMonoRuntime: PropTypes.bool.isRequired, + runtimeVersion: PropTypes.string.isRequired, + migrationVersion: PropTypes.number.isRequired, + appData: PropTypes.string.isRequired, + startupPath: PropTypes.string.isRequired, + mode: PropTypes.string.isRequired, + startTime: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired }; export default About; diff --git a/frontend/src/System/Status/About/AboutConnector.js b/frontend/src/System/Status/About/AboutConnector.js index 8d5c2ce0f..475d9778b 100644 --- a/frontend/src/System/Status/About/AboutConnector.js +++ b/frontend/src/System/Status/About/AboutConnector.js @@ -3,14 +3,18 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import { fetchStatus } from 'Store/Actions/systemActions'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import About from './About'; function createMapStateToProps() { return createSelector( (state) => state.system.status, - (status) => { + createUISettingsSelector(), + (status, uiSettings) => { return { - ...status.item + ...status.item, + timeFormat: uiSettings.timeFormat, + longDateFormat: uiSettings.longDateFormat }; } ); diff --git a/frontend/src/System/Status/About/StartTime.js b/frontend/src/System/Status/About/StartTime.js new file mode 100644 index 000000000..94b4322d5 --- /dev/null +++ b/frontend/src/System/Status/About/StartTime.js @@ -0,0 +1,93 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; + +function getUptime(startTime) { + return formatTimeSpan(moment().diff(startTime)); +} + +class StartTime extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const { + startTime, + timeFormat, + longDateFormat + } = props; + + this._timeoutId = null; + + this.state = { + uptime: getUptime(startTime), + startTime: formatDateTime(startTime, longDateFormat, timeFormat, { includeSeconds: true }) + }; + } + + componentDidMount() { + this._timeoutId = setTimeout(this.onTimeout, 1000); + } + + componentDidUpdate(prevProps) { + const { + startTime, + timeFormat, + longDateFormat + } = this.props; + + if ( + startTime !== prevProps.startTime || + timeFormat !== prevProps.timeFormat || + longDateFormat !== prevProps.longDateFormat + ) { + this.setState({ + uptime: getUptime(startTime), + startTime: formatDateTime(startTime, longDateFormat, timeFormat, { includeSeconds: true }) + }); + } + } + + componentWillUnmount() { + if (this._timeoutId) { + this._timeoutId = clearTimeout(this._timeoutId); + } + } + + // + // Listeners + + onTimeout = () => { + this.setState({ uptime: getUptime(this.props.startTime) }); + this._timeoutId = setTimeout(this.onTimeout, 1000); + } + + // + // Render + + render() { + const { + uptime, + startTime + } = this.state; + + return ( + + {uptime} + + ); + } +} + +StartTime.propTypes = { + startTime: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired +}; + +export default StartTime; diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js b/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js index a1365dc99..ac34d70fd 100644 --- a/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js +++ b/frontend/src/TrackFile/Editor/TrackFileEditorModalContent.js @@ -70,9 +70,15 @@ class TrackFileEditorModalContent extends Component { getSelectedIds = () => { const selectedIds = getSelectedIds(this.state.selectedState); - return _.uniq(_.map(selectedIds, (id) => { - return _.find(this.props.items, { id }).trackFileId; - })); + return selectedIds.reduce((acc, id) => { + const matchingItem = this.props.items.find((item) => item.id === id); + + if (matchingItem && !acc.includes(matchingItem.trackFileID)) { + acc.push(matchingItem.trackFileID); + } + + return acc; + }, []); } // diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorRow.js b/frontend/src/TrackFile/Editor/TrackFileEditorRow.js index 19628a2f8..2bfbaea57 100644 --- a/frontend/src/TrackFile/Editor/TrackFileEditorRow.js +++ b/frontend/src/TrackFile/Editor/TrackFileEditorRow.js @@ -5,7 +5,7 @@ import Label from 'Components/Label'; import TableRow from 'Components/Table/TableRow'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; -import EpisodeQuality from 'Album/EpisodeQuality'; +import TrackQuality from 'Album/TrackQuality'; import styles from './TrackFileEditorRow'; function TrackFileEditorRow(props) { @@ -42,7 +42,7 @@ function TrackFileEditorRow(props) { - diff --git a/frontend/src/Utilities/Filter/findSelectedFilters.js b/frontend/src/Utilities/Filter/findSelectedFilters.js index 4306f9439..1c104073c 100644 --- a/frontend/src/Utilities/Filter/findSelectedFilters.js +++ b/frontend/src/Utilities/Filter/findSelectedFilters.js @@ -3,7 +3,11 @@ export default function findSelectedFilters(selectedFilterKey, filters = [], cus return []; } - const selectedFilter = [...filters, ...customFilters].find((f) => f.key === selectedFilterKey); + let selectedFilter = filters.find((f) => f.key === selectedFilterKey); + + if (!selectedFilter) { + selectedFilter = customFilters.find((f) => f.id === selectedFilterKey); + } if (!selectedFilter) { // TODO: throw in dev diff --git a/frontend/src/Utilities/Number/convertToBytes.js b/frontend/src/Utilities/Number/convertToBytes.js new file mode 100644 index 000000000..88357944f --- /dev/null +++ b/frontend/src/Utilities/Number/convertToBytes.js @@ -0,0 +1,15 @@ +function convertToBytes(input, power, binaryPrefix) { + const size = Number(input); + + if (isNaN(size)) { + return ''; + } + + const prefix = binaryPrefix ? 1024 : 1000; + const multiplier = Math.pow(prefix, power); + const result = size * multiplier; + + return Math.round(result); +} + +export default convertToBytes; diff --git a/frontend/src/Utilities/State/getProviderState.js b/frontend/src/Utilities/State/getProviderState.js index cc18af711..039429c10 100644 --- a/frontend/src/Utilities/State/getProviderState.js +++ b/frontend/src/Utilities/State/getProviderState.js @@ -2,9 +2,12 @@ import _ from 'lodash'; import getSectionState from 'Utilities/State/getSectionState'; function getProviderState(payload, getState, section) { - const id = payload.id; + const { + id, + ...otherPayload + } = payload; const state = getSectionState(getState(), section, true); - const pendingChanges = Object.assign({}, state.pendingChanges); + const pendingChanges = Object.assign({}, state.pendingChanges, otherPayload); const pendingFields = state.pendingChanges.fields || {}; delete pendingChanges.fields; diff --git a/frontend/src/Utilities/customFilterHandlers.js b/frontend/src/Utilities/customFilterHandlers.js deleted file mode 100644 index 19be120ac..000000000 --- a/frontend/src/Utilities/customFilterHandlers.js +++ /dev/null @@ -1,6 +0,0 @@ -const customFilterHandlers = { - REMOVE: 'remove', - SAVE: 'save' -}; - -export default customFilterHandlers; diff --git a/frontend/src/Wanted/Missing/MissingRow.js b/frontend/src/Wanted/Missing/MissingRow.js index f54a5c82b..25a400545 100644 --- a/frontend/src/Wanted/Missing/MissingRow.js +++ b/frontend/src/Wanted/Missing/MissingRow.js @@ -26,6 +26,10 @@ function MissingRow(props) { onSelectedChange } = props; + if (!artist) { + return null; + } + return ( + { + private readonly ICustomFilterService _customFilterService; + + public CustomFilterModule(ICustomFilterService customFilterService) + { + _customFilterService = customFilterService; + + GetResourceById = GetCustomFilter; + GetResourceAll = GetCustomFilters; + CreateResource = AddCustomFilter; + UpdateResource = UpdateCustomFilter; + DeleteResource = DeleteCustomResource; + } + + private CustomFilterResource GetCustomFilter(int id) + { + return _customFilterService.Get(id).ToResource(); + } + + private List GetCustomFilters() + { + return _customFilterService.All().ToResource(); + } + + private int AddCustomFilter(CustomFilterResource resource) + { + var customFilter = _customFilterService.Add(resource.ToModel()); + + return customFilter.Id; + } + + private void UpdateCustomFilter(CustomFilterResource resource) + { + _customFilterService.Update(resource.ToModel()); + } + + private void DeleteCustomResource(int id) + { + _customFilterService.Delete(id); + } + } +} diff --git a/src/Lidarr.Api.V1/CustomFilters/CustomFilterResource.cs b/src/Lidarr.Api.V1/CustomFilters/CustomFilterResource.cs new file mode 100644 index 000000000..ee3cc9ea0 --- /dev/null +++ b/src/Lidarr.Api.V1/CustomFilters/CustomFilterResource.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.CustomFilters; +using Lidarr.Http.REST; + +namespace Lidarr.Api.V1.CustomFilters +{ + public class CustomFilterResource : RestResource + { + public string Type { get; set; } + public string Label { get; set; } + public List Filters { get; set; } + } + + public static class CustomFilterResourceMapper + { + public static CustomFilterResource ToResource(this CustomFilter model) + { + if (model == null) return null; + + return new CustomFilterResource + { + Id = model.Id, + Type = model.Type, + Label = model.Label, + Filters = Json.Deserialize>(model.Filters) + }; + } + + public static CustomFilter ToModel(this CustomFilterResource resource) + { + if (resource == null) return null; + + return new CustomFilter + { + Id = resource.Id, + Type = resource.Type, + Label = resource.Label, + Filters = Json.ToJson(resource.Filters) + }; + } + + public static List ToResource(this IEnumerable filters) + { + return filters.Select(ToResource).ToList(); + } + } +} diff --git a/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj b/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj index f20eb8a8b..1fae358bb 100644 --- a/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj +++ b/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj @@ -97,6 +97,8 @@ + + diff --git a/src/NzbDrone.Common/NzbDrone.Common.csproj b/src/NzbDrone.Common/NzbDrone.Common.csproj index dd53eb5e1..08fd5c764 100644 --- a/src/NzbDrone.Common/NzbDrone.Common.csproj +++ b/src/NzbDrone.Common/NzbDrone.Common.csproj @@ -54,8 +54,8 @@ ..\packages\DotNet4.SocksProxy.1.3.4.0\lib\net40\Org.Mentalis.dll True - - ..\packages\SharpRaven.2.2.0\lib\net45\SharpRaven.dll + + ..\packages\SharpRaven.2.4.0\lib\net45\SharpRaven.dll ..\packages\DotNet4.SocksProxy.1.3.4.0\lib\net40\SocksWebProxy.dll @@ -66,6 +66,7 @@ + diff --git a/src/NzbDrone.Common/packages.config b/src/NzbDrone.Common/packages.config index 4f26eba16..d2e511546 100644 --- a/src/NzbDrone.Common/packages.config +++ b/src/NzbDrone.Common/packages.config @@ -4,5 +4,5 @@ - + \ No newline at end of file diff --git a/src/NzbDrone.Core/CustomFilters/CustomFilter.cs b/src/NzbDrone.Core/CustomFilters/CustomFilter.cs new file mode 100644 index 000000000..1c6e3e9b9 --- /dev/null +++ b/src/NzbDrone.Core/CustomFilters/CustomFilter.cs @@ -0,0 +1,11 @@ +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.CustomFilters +{ + public class CustomFilter : ModelBase + { + public string Type { get; set; } + public string Label { get; set; } + public string Filters { get; set; } + } +} diff --git a/src/NzbDrone.Core/CustomFilters/CustomFilterRepository.cs b/src/NzbDrone.Core/CustomFilters/CustomFilterRepository.cs new file mode 100644 index 000000000..9bdb8fd07 --- /dev/null +++ b/src/NzbDrone.Core/CustomFilters/CustomFilterRepository.cs @@ -0,0 +1,17 @@ +using NzbDrone.Core.Datastore; +using NzbDrone.Core.Messaging.Events; + +namespace NzbDrone.Core.CustomFilters +{ + public interface ICustomFilterRepository : IBasicRepository + { + } + + public class CustomFilterRepository : BasicRepository, ICustomFilterRepository + { + public CustomFilterRepository(IMainDatabase database, IEventAggregator eventAggregator) + : base(database, eventAggregator) + { + } + } +} diff --git a/src/NzbDrone.Core/CustomFilters/CustomFilterService.cs b/src/NzbDrone.Core/CustomFilters/CustomFilterService.cs new file mode 100644 index 000000000..9ef98f8be --- /dev/null +++ b/src/NzbDrone.Core/CustomFilters/CustomFilterService.cs @@ -0,0 +1,49 @@ +using System.Collections.Generic; +using System.Linq; + +namespace NzbDrone.Core.CustomFilters +{ + public interface ICustomFilterService + { + CustomFilter Add(CustomFilter customFilter); + List All(); + void Delete(int id); + CustomFilter Get(int id); + CustomFilter Update(CustomFilter customFilter); + } + + public class CustomFilterService : ICustomFilterService + { + private readonly ICustomFilterRepository _repo; + + public CustomFilterService(ICustomFilterRepository repo) + { + _repo = repo; + } + + public CustomFilter Add(CustomFilter customFilter) + { + return _repo.Insert(customFilter); + } + + public CustomFilter Update(CustomFilter customFilter) + { + return _repo.Update(customFilter); + } + + public void Delete(int id) + { + _repo.Delete(id); + } + + public CustomFilter Get(int id) + { + return _repo.Get(id); + } + + public List All() + { + return _repo.All().ToList(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/Migration/021_add_custom_filters.cs b/src/NzbDrone.Core/Datastore/Migration/021_add_custom_filters.cs new file mode 100644 index 000000000..15b9004eb --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/021_add_custom_filters.cs @@ -0,0 +1,17 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(021)] + public class add_custom_filters : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Create.TableForModel("CustomFilters") + .WithColumn("Type").AsString().NotNullable() + .WithColumn("Label").AsString().NotNullable() + .WithColumn("Filters").AsString().NotNullable(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index 45b734219..d10f04896 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -31,6 +31,7 @@ using NzbDrone.Core.ThingiProvider; using NzbDrone.Common.Disk; using NzbDrone.Common.Serializer; using NzbDrone.Core.Authentication; +using NzbDrone.Core.CustomFilters; using NzbDrone.Core.Extras.Metadata; using NzbDrone.Core.Extras.Metadata.Files; using NzbDrone.Core.Extras.Others; @@ -141,6 +142,8 @@ namespace NzbDrone.Core.Datastore Mapper.Entity().RegisterModel("IndexerStatus"); Mapper.Entity().RegisterModel("DownloadClientStatus"); Mapper.Entity().RegisterModel("ImportListStatus"); + + Mapper.Entity().RegisterModel("CustomFilters"); } private static void RegisterMappers() diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvPinUrlResponse.cs b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvPinUrlResponse.cs new file mode 100644 index 000000000..4dace5645 --- /dev/null +++ b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvPinUrlResponse.cs @@ -0,0 +1,11 @@ +using System.Collections.Generic; + +namespace NzbDrone.Core.Notifications.Plex.PlexTv +{ + public class PlexTvPinUrlResponse + { + public string Url { get; set; } + public string Method => "POST"; + public Dictionary Headers { get; set; } + } +} diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvProxy.cs b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvProxy.cs index 2179bf058..f9913613e 100644 --- a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvProxy.cs +++ b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvProxy.cs @@ -4,46 +4,39 @@ using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Http; using NzbDrone.Common.Serializer; using NzbDrone.Core.Exceptions; + namespace NzbDrone.Core.Notifications.Plex.PlexTv { public interface IPlexTvProxy { - PlexTvPinResponse GetPinCode(string clientIdentifier); string GetAuthToken(string clientIdentifier, int pinId); } public class PlexTvProxy : IPlexTvProxy { private readonly IHttpClient _httpClient; private readonly Logger _logger; + public PlexTvProxy(IHttpClient httpClient, Logger logger) { _httpClient = httpClient; _logger = logger; } - public PlexTvPinResponse GetPinCode(string clientIdentifier) - { - var request = BuildRequest(clientIdentifier); - request.Method = HttpMethod.POST; - request.ResourceUrl = "/api/v2/pins"; - request.AddQueryParam("strong", true); - PlexTvPinResponse response; - if (!Json.TryDeserialize(ProcessRequest(request), out response)) - { - response = new PlexTvPinResponse(); - } - return response; - } + public string GetAuthToken(string clientIdentifier, int pinId) { var request = BuildRequest(clientIdentifier); request.ResourceUrl = $"/api/v2/pins/{pinId}"; + PlexTvPinResponse response; + if (!Json.TryDeserialize(ProcessRequest(request), out response)) { response = new PlexTvPinResponse(); } + return response.AuthToken; } + private HttpRequestBuilder BuildRequest(string clientIdentifier) { var requestBuilder = new HttpRequestBuilder("https://plex.tv") @@ -54,13 +47,18 @@ namespace NzbDrone.Core.Notifications.Plex.PlexTv .AddQueryParam("X-Plex-Platform-Version", "7") .AddQueryParam("X-Plex-Device-Name", "Lidarr") .AddQueryParam("X-Plex-Version", BuildInfo.Version.ToString()); + return requestBuilder; } + private string ProcessRequest(HttpRequestBuilder requestBuilder) { var httpRequest = requestBuilder.Build(); + HttpResponse response; + _logger.Debug("Url: {0}", httpRequest.Url); + try { response = _httpClient.Execute(httpRequest); @@ -73,6 +71,7 @@ namespace NzbDrone.Core.Notifications.Plex.PlexTv { throw new NzbDroneClientException(HttpStatusCode.BadRequest, "Unable to connect to plex.tv"); } + return response.Content; } } diff --git a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs index 80f7b629c..51b9aece6 100644 --- a/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs +++ b/src/NzbDrone.Core/Notifications/Plex/PlexTv/PlexTvService.cs @@ -1,39 +1,76 @@ +using System.Linq; using System.Text; using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Http; using NzbDrone.Core.Configuration; + namespace NzbDrone.Core.Notifications.Plex.PlexTv { public interface IPlexTvService { - PlexTvSignInUrlResponse GetSignInUrl(string callbackUrl); + PlexTvPinUrlResponse GetPinUrl(); + PlexTvSignInUrlResponse GetSignInUrl(string callbackUrl, int pinId, string pinCode); string GetAuthToken(int pinId); } + public class PlexTvService : IPlexTvService { private readonly IPlexTvProxy _proxy; private readonly IConfigService _configService; + public PlexTvService(IPlexTvProxy proxy, IConfigService configService) { _proxy = proxy; _configService = configService; } - public PlexTvSignInUrlResponse GetSignInUrl(string callbackUrl) + + public PlexTvPinUrlResponse GetPinUrl() { var clientIdentifier = _configService.PlexClientIdentifier; - var pin = _proxy.GetPinCode(clientIdentifier); - var url = new StringBuilder(); - url.Append("https://app.plex.tv/auth/#!"); - url.Append($"?clientID={clientIdentifier}"); - url.Append($"&forwardUrl={callbackUrl}"); - url.Append($"&code={pin.Code}"); - url.Append($"&context[device][version]=${BuildInfo.Version.ToString()}"); - url.Append("&context[device][product]=Lidarr"); - url.Append("&context[device][platform]=Windows"); - url.Append("&context[device][platformVersion]=7"); + + var requestBuilder = new HttpRequestBuilder("https://plex.tv/api/v2/pins") + .Accept(HttpAccept.Json) + .AddQueryParam("X-Plex-Client-Identifier", clientIdentifier) + .AddQueryParam("X-Plex-Product", "Lidarr") + .AddQueryParam("X-Plex-Platform", "Windows") + .AddQueryParam("X-Plex-Platform-Version", "7") + .AddQueryParam("X-Plex-Device-Name", "Lidarr") + .AddQueryParam("X-Plex-Version", BuildInfo.Version.ToString()) + .AddQueryParam("strong", true); + + requestBuilder.Method = HttpMethod.POST; + + var request = requestBuilder.Build(); + + return new PlexTvPinUrlResponse + { + Url = request.Url.ToString(), + Headers = request.Headers.ToDictionary(h => h.Key, h => h.Value) + }; + } + + public PlexTvSignInUrlResponse GetSignInUrl(string callbackUrl, int pinId, string pinCode) + { + var clientIdentifier = _configService.PlexClientIdentifier; + + var requestBuilder = new HttpRequestBuilder("https://app.plex.tv/auth/hashBang") + .AddQueryParam("clientID", clientIdentifier) + .AddQueryParam("forwardUrl", callbackUrl) + .AddQueryParam("code", pinCode) + .AddQueryParam("context[device][product]", "Lidarr") + .AddQueryParam("context[device][platform]", "Windows") + .AddQueryParam("context[device][platformVersion]", "7") + .AddQueryParam("context[device][version", BuildInfo.Version.ToString()); + + // #! is stripped out of the URL when building, this works around it. + requestBuilder.Segments.Add("hashBang", "#!"); + + var request = requestBuilder.Build(); + return new PlexTvSignInUrlResponse { - OauthUrl = url.ToString(), - PinId = pin.Id + OauthUrl = request.Url.ToString(), + PinId = pinId }; } public string GetAuthToken(int pinId) diff --git a/src/NzbDrone.Core/Notifications/Plex/Server/PlexServer.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServer.cs index e3e9ff5ac..3be451a7d 100644 --- a/src/NzbDrone.Core/Notifications/Plex/Server/PlexServer.cs +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServer.cs @@ -6,31 +6,38 @@ using NzbDrone.Core.Exceptions; using NzbDrone.Core.Notifications.Plex.PlexTv; using NzbDrone.Core.Music; using NzbDrone.Core.Validation; + namespace NzbDrone.Core.Notifications.Plex.Server { public class PlexServer : NotificationBase { private readonly IPlexServerService _plexServerService; private readonly IPlexTvService _plexTvService; + public PlexServer(IPlexServerService plexServerService, IPlexTvService plexTvService) { _plexServerService = plexServerService; _plexTvService = plexTvService; } + public override string Link => "https://www.plex.tv/"; public override string Name => "Plex Media Server"; + public override void OnDownload(TrackDownloadMessage message) { UpdateIfEnabled(message.Artist); } + public override void OnAlbumDownload(AlbumDownloadMessage message) { UpdateIfEnabled(message.Artist); } + public override void OnRename(Artist artist) { UpdateIfEnabled(artist); } + private void UpdateIfEnabled(Artist artist) { if (Settings.UpdateLibrary) @@ -38,36 +45,62 @@ namespace NzbDrone.Core.Notifications.Plex.Server _plexServerService.UpdateLibrary(artist, Settings); } } + public override ValidationResult Test() { var failures = new List(); + failures.AddIfNotNull(_plexServerService.Test(Settings)); + return new ValidationResult(failures); } + public override object RequestAction(string action, IDictionary query) { if (action == "startOAuth") { Settings.Validate().Filter("ConsumerKey", "ConsumerSecret").ThrowOnError(); + + return _plexTvService.GetPinUrl(); + } + else if (action == "continueOAuth") + { + Settings.Validate().Filter("ConsumerKey", "ConsumerSecret").ThrowOnError(); + if (query["callbackUrl"].IsNullOrWhiteSpace()) { throw new BadRequestException("QueryParam callbackUrl invalid."); } - return _plexTvService.GetSignInUrl(query["callbackUrl"]); + + if (query["id"].IsNullOrWhiteSpace()) + { + throw new BadRequestException("QueryParam id invalid."); + } + + if (query["code"].IsNullOrWhiteSpace()) + { + throw new BadRequestException("QueryParam code invalid."); + } + + return _plexTvService.GetSignInUrl(query["callbackUrl"], Convert.ToInt32(query["id"]), query["code"]); } else if (action == "getOAuthToken") { Settings.Validate().Filter("ConsumerKey", "ConsumerSecret").ThrowOnError(); + if (query["pinId"].IsNullOrWhiteSpace()) { throw new BadRequestException("QueryParam pinId invalid."); } + var authToken = _plexTvService.GetAuthToken(Convert.ToInt32(query["pinId"])); + return new { authToken }; } + return new { }; } } diff --git a/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerProxy.cs b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerProxy.cs index 8e604c9bd..b82ba57a6 100644 --- a/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerProxy.cs +++ b/src/NzbDrone.Core/Notifications/Plex/Server/PlexServerProxy.cs @@ -7,6 +7,7 @@ using NzbDrone.Common.Extensions; using NzbDrone.Common.Http; using NzbDrone.Common.Serializer; using NzbDrone.Core.Configuration; + namespace NzbDrone.Core.Notifications.Plex.Server { public interface IPlexServerProxy @@ -18,22 +19,27 @@ namespace NzbDrone.Core.Notifications.Plex.Server List Preferences(PlexServerSettings settings); int? GetMetadataId(int sectionId, string mbId, string language, PlexServerSettings settings); } + public class PlexServerProxy : IPlexServerProxy { private readonly IHttpClient _httpClient; private readonly IConfigService _configService; private readonly Logger _logger; + public PlexServerProxy(IHttpClient httpClient, IConfigService configService, Logger logger) { _httpClient = httpClient; _configService = configService; _logger = logger; } + public List GetArtistSections(PlexServerSettings settings) { var request = BuildRequest("library/sections", HttpMethod.GET, settings); var response = ProcessRequest(request); + CheckForError(response); + if (response.Contains("_children")) { return Json.Deserialize(response) @@ -48,36 +54,45 @@ namespace NzbDrone.Core.Notifications.Plex.Server }) .ToList(); } + return Json.Deserialize>(response) .MediaContainer .Sections .Where(d => d.Type == "artist") .ToList(); } + public void Update(int sectionId, PlexServerSettings settings) { var resource = $"library/sections/{sectionId}/refresh"; var request = BuildRequest(resource, HttpMethod.GET, settings); var response = ProcessRequest(request); + CheckForError(response); } + public void UpdateArtist(int metadataId, PlexServerSettings settings) { var resource = $"library/metadata/{metadataId}/refresh"; var request = BuildRequest(resource, HttpMethod.PUT, settings); var response = ProcessRequest(request); + CheckForError(response); } + public string Version(PlexServerSettings settings) { var request = BuildRequest("identity", HttpMethod.GET, settings); var response = ProcessRequest(request); + CheckForError(response); + if (response.Contains("_children")) { return Json.Deserialize(response) .Version; } + return Json.Deserialize>(response) .MediaContainer .Version; @@ -86,24 +101,31 @@ namespace NzbDrone.Core.Notifications.Plex.Server { var request = BuildRequest(":/prefs", HttpMethod.GET, settings); var response = ProcessRequest(request); + CheckForError(response); + if (response.Contains("_children")) { return Json.Deserialize(response) .Preferences; } + return Json.Deserialize>(response) .MediaContainer .Preferences; } + public int? GetMetadataId(int sectionId, string mbId, string language, PlexServerSettings settings) { var guid = string.Format("com.plexapp.agents.lastfm://{0}?lang={1}", mbId, language); // TODO Plex Route for MB? LastFM? var resource = $"library/sections/{sectionId}/all?guid={System.Web.HttpUtility.UrlEncode(guid)}"; var request = BuildRequest(resource, HttpMethod.GET, settings); var response = ProcessRequest(request); + CheckForError(response); + List items; + if (response.Contains("_children")) { items = Json.Deserialize(response) @@ -115,12 +137,15 @@ namespace NzbDrone.Core.Notifications.Plex.Server .MediaContainer .Items; } + if (items == null || items.Empty()) { return null; } + return items.First().Id; } + private HttpRequestBuilder BuildRequest(string resource, HttpMethod method, PlexServerSettings settings) { var scheme = settings.UseSsl ? "https" : "http"; @@ -132,19 +157,26 @@ namespace NzbDrone.Core.Notifications.Plex.Server .AddQueryParam("X-Plex-Platform-Version", "7") .AddQueryParam("X-Plex-Device-Name", "Lidarr") .AddQueryParam("X-Plex-Version", BuildInfo.Version.ToString()); + if (settings.AuthToken.IsNotNullOrWhiteSpace()) { requestBuilder.AddQueryParam("X-Plex-Token", settings.AuthToken); } + requestBuilder.ResourceUrl = resource; requestBuilder.Method = method; + return requestBuilder; } + private string ProcessRequest(HttpRequestBuilder requestBuilder) { var httpRequest = requestBuilder.Build(); + HttpResponse response; + _logger.Debug("Url: {0}", httpRequest.Url); + try { response = _httpClient.Execute(httpRequest); @@ -161,23 +193,29 @@ namespace NzbDrone.Core.Notifications.Plex.Server { throw new PlexException("Unable to connect to Plex Media Server"); } + return response.Content; } + private void CheckForError(string response) { _logger.Trace("Checking for error"); + if (response.IsNullOrWhiteSpace()) { _logger.Trace("No response body returned, no error detected"); return; } + var error = response.Contains("_children") ? Json.Deserialize(response) : Json.Deserialize>(response).MediaContainer; + if (error != null && !error.Error.IsNullOrWhiteSpace()) { throw new PlexException(error.Error); } + _logger.Trace("No error detected"); } } diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 1fa799b4b..3d951e32f 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -143,6 +143,9 @@ + + + @@ -192,6 +195,7 @@ + @@ -891,6 +895,7 @@ + diff --git a/yarn.lock b/yarn.lock index 22ebe8566..4acdd8c73 100644 --- a/yarn.lock +++ b/yarn.lock @@ -168,6 +168,52 @@ version "1.1.0" resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.0.tgz#50c1e2260ac0ed9439a181de3725a0168d59c48a" +"@sentry/browser@4.0.4": + version "4.0.4" + resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-4.0.4.tgz#6dfe05099d9ce23d7e9becaa905af5406520d448" + dependencies: + "@sentry/core" "4.0.3" + "@sentry/types" "4.0.1" + "@sentry/utils" "4.0.1" + md5 "2.2.1" + +"@sentry/core@4.0.3": + version "4.0.3" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-4.0.3.tgz#4f8fd67888f1cf0f1a984c5fa362122b60e8bd08" + dependencies: + "@sentry/hub" "4.0.1" + "@sentry/minimal" "4.0.1" + "@sentry/types" "4.0.1" + "@sentry/utils" "4.0.1" + +"@sentry/hub@4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@sentry/hub/-/hub-4.0.1.tgz#01870cede195029ae32d763199ff6c3e4edf99d1" + dependencies: + "@sentry/types" "4.0.0" + "@sentry/utils" "4.0.1" + +"@sentry/minimal@4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@sentry/minimal/-/minimal-4.0.1.tgz#c51a2af81eba48977fb54ab187e0c0eb0ad12c15" + dependencies: + "@sentry/hub" "4.0.1" + "@sentry/types" "4.0.0" + +"@sentry/types@4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-4.0.0.tgz#9dd46a7b05004871fe0cea0b0423098d9d91a173" + +"@sentry/types@4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@sentry/types/-/types-4.0.1.tgz#f9342e905ce2aee71975574589d915b6fb691fb0" + +"@sentry/utils@4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-4.0.1.tgz#5690058fb030c23d46ea056aa3e8ebebb8105d45" + dependencies: + "@sentry/types" "4.0.0" + abbrev@1: version "1.1.0" resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.0.tgz#d0554c2256636e2f56e7c2e5ad183f859428d81f" @@ -1642,6 +1688,10 @@ chardet@^0.7.0: version "0.7.0" resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" +charenc@~0.0.1: + version "0.0.2" + resolved "https://registry.yarnpkg.com/charenc/-/charenc-0.0.2.tgz#c0a1d2f3a7092e03774bfa83f14c0fc5790a8667" + chokidar@^1.7.0: version "1.7.0" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-1.7.0.tgz#798e689778151c8076b4b360e5edd28cda2bb468" @@ -2032,6 +2082,10 @@ cross-spawn@^6.0.5: shebang-command "^1.2.0" which "^1.2.9" +crypt@~0.0.1: + version "0.0.2" + resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" + cryptiles@3.x.x: version "3.1.2" resolved "https://registry.yarnpkg.com/cryptiles/-/cryptiles-3.1.2.tgz#a89fbb220f5ce25ec56e8c4aa8a4fd7b5b0d29fe" @@ -4067,7 +4121,7 @@ is-binary-path@^1.0.0: dependencies: binary-extensions "^1.0.0" -is-buffer@^1.1.4: +is-buffer@^1.1.4, is-buffer@~1.1.1: version "1.1.6" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" @@ -4861,6 +4915,14 @@ md5.js@^1.3.4: hash-base "^3.0.0" inherits "^2.0.1" +md5@2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/md5/-/md5-2.2.1.tgz#53ab38d5fe3c8891ba465329ea23fac0540126f9" + dependencies: + charenc "~0.0.1" + crypt "~0.0.1" + is-buffer "~1.1.1" + mdast-util-compact@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/mdast-util-compact/-/mdast-util-compact-1.0.1.tgz#cdb5f84e2b6a2d3114df33bd05d9cb32e3c4083a" @@ -6292,14 +6354,6 @@ randombytes@^2.0.0, randombytes@^2.0.1: dependencies: safe-buffer "^5.1.0" -raven-for-redux@1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/raven-for-redux/-/raven-for-redux-1.3.1.tgz#865f0056ec1706073c1b3a33164640453ed4fed2" - -raven-js@3.27.0: - version "3.27.0" - resolved "https://registry.yarnpkg.com/raven-js/-/raven-js-3.27.0.tgz#9f47c03e17933ce756e189f3669d49c441c1ba6e" - raw-body@~1.1.0: version "1.1.7" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-1.1.7.tgz#1d027c2bfa116acc6623bca8f00016572a87d425" @@ -6330,9 +6384,9 @@ react-async-script@1.0.0, react-async-script@^1.0.0: hoist-non-react-statics "^3.0.1" prop-types "^15.5.0" -react-autosuggest@9.4.1: - version "9.4.1" - resolved "https://registry.yarnpkg.com/react-autosuggest/-/react-autosuggest-9.4.1.tgz#fe636b196eaffaf1d29283c2fc55f8a93cf3666d" +react-autosuggest@9.4.2: + version "9.4.2" + resolved "https://registry.yarnpkg.com/react-autosuggest/-/react-autosuggest-9.4.2.tgz#18cc0bebeebda3d24328e3da301f061a444ae223" dependencies: prop-types "^15.5.10" react-autowhatever "^10.1.2" @@ -6381,14 +6435,14 @@ react-document-title@2.0.3: prop-types "^15.5.6" react-side-effect "^1.0.2" -react-dom@16.5.1: - version "16.5.1" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.5.1.tgz#29d0c5a01ed3b6b4c14309aa91af6ec4eb4f292c" +react-dom@16.5.2: + version "16.5.2" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.5.2.tgz#b69ee47aa20bab5327b2b9d7c1fe2a30f2cfa9d7" dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" prop-types "^15.6.2" - schedule "^0.4.0" + schedule "^0.5.0" react-google-recaptcha@1.0.2: version "1.0.2" @@ -6519,14 +6573,14 @@ react-virtualized@9.20.1: prop-types "^15.6.0" react-lifecycles-compat "^3.0.4" -react@16.5.1: - version "16.5.1" - resolved "https://registry.yarnpkg.com/react/-/react-16.5.1.tgz#8cb8e9f8cdcb4bde41c9a138bfbf907e66132372" +react@16.5.2: + version "16.5.2" + resolved "https://registry.yarnpkg.com/react/-/react-16.5.2.tgz#19f6b444ed139baa45609eee6dc3d318b3895d42" dependencies: loose-envify "^1.1.0" object-assign "^4.1.1" prop-types "^15.6.2" - schedule "^0.4.0" + schedule "^0.5.0" read-pkg-up@^2.0.0: version "2.0.0" @@ -7048,9 +7102,9 @@ sax@~1.2.1: version "1.2.4" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" -schedule@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/schedule/-/schedule-0.4.0.tgz#fa20cfd0bfbf91c47d02272fd7096780d3170bbb" +schedule@^0.5.0: + version "0.5.0" + resolved "https://registry.yarnpkg.com/schedule/-/schedule-0.5.0.tgz#c128fffa0b402488b08b55ae74bb9df55cc29cc8" dependencies: object-assign "^4.1.1"