diff --git a/frontend/src/Album/SeasonEpisodeNumber.css b/frontend/src/Album/SeasonEpisodeNumber.css deleted file mode 100644 index f86e1de6b..000000000 --- a/frontend/src/Album/SeasonEpisodeNumber.css +++ /dev/null @@ -1,3 +0,0 @@ -.absoluteEpisodeNumber { - margin-left: 5px; -} diff --git a/frontend/src/Album/SeasonEpisodeNumber.js b/frontend/src/Album/SeasonEpisodeNumber.js index 5055e1a26..7242ebfcc 100644 --- a/frontend/src/Album/SeasonEpisodeNumber.js +++ b/frontend/src/Album/SeasonEpisodeNumber.js @@ -1,15 +1,12 @@ import PropTypes from 'prop-types'; import React from 'react'; -import padNumber from 'Utilities/Number/padNumber'; -import styles from './SeasonEpisodeNumber.css'; +import EpisodeNumber from './EpisodeNumber'; function SeasonEpisodeNumber(props) { const { - seasonNumber, - episodeNumber, - absoluteEpisodeNumber, airDate, - artistType + artistType, + ...otherProps } = props; if (artistType === 'daily' && airDate) { @@ -18,32 +15,16 @@ function SeasonEpisodeNumber(props) { ); } - if (artistType === 'anime') { - return ( - - {seasonNumber}x{padNumber(episodeNumber, 2)} - - { - absoluteEpisodeNumber && - - ({absoluteEpisodeNumber}) - - } - - ); - } - return ( - - {seasonNumber}x{padNumber(episodeNumber, 2)} - + ); } SeasonEpisodeNumber.propTypes = { - seasonNumber: PropTypes.number.isRequired, - episodeNumber: PropTypes.number.isRequired, - absoluteEpisodeNumber: PropTypes.number, airDate: PropTypes.string, artistType: PropTypes.string }; diff --git a/frontend/src/Artist/Details/ArtistDetails.js b/frontend/src/Artist/Details/ArtistDetails.js index e8dd42f8e..d35b068ef 100644 --- a/frontend/src/Artist/Details/ArtistDetails.js +++ b/frontend/src/Artist/Details/ArtistDetails.js @@ -26,6 +26,7 @@ import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfil import ArtistPoster from 'Artist/ArtistPoster'; import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; +import ArtistHistoryModal from 'Artist/History/ArtistHistoryModal'; import ArtistAlternateTitles from './ArtistAlternateTitles'; import ArtistDetailsSeasonConnector from './ArtistDetailsSeasonConnector'; import ArtistTagsConnector from './ArtistTagsConnector'; @@ -92,6 +93,7 @@ class ArtistDetails extends Component { isManageEpisodesOpen: false, isEditArtistModalOpen: false, isDeleteArtistModalOpen: false, + isArtistHistoryModalOpen: false, allExpanded: false, allCollapsed: false, expandedState: {} @@ -136,6 +138,14 @@ class ArtistDetails extends Component { this.setState({ isDeleteArtistModalOpen: false }); } + onArtistHistoryPress = () => { + this.setState({ isArtistHistoryModalOpen: true }); + } + + onArtistHistoryModalClose = () => { + this.setState({ isArtistHistoryModalOpen: false }); + } + onExpandAllPress = () => { const { allExpanded, @@ -197,6 +207,7 @@ class ArtistDetails extends Component { isManageEpisodesOpen, isEditArtistModalOpen, isDeleteArtistModalOpen, + isArtistHistoryModalOpen, allExpanded, allCollapsed, expandedState @@ -254,6 +265,12 @@ class ArtistDetails extends Component { onPress={this.onManageEpisodesPress} /> + + + + - { - isSmallScreen ? - - - - - - - - - - Search - - - - - - Preview Rename - - - - - - Manage Tracks - - - : - -
- - -
- } -
diff --git a/frontend/src/Artist/History/ArtistHistoryModal.js b/frontend/src/Artist/History/ArtistHistoryModal.js new file mode 100644 index 000000000..7139d7633 --- /dev/null +++ b/frontend/src/Artist/History/ArtistHistoryModal.js @@ -0,0 +1,31 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ArtistHistoryModalContentConnector from './ArtistHistoryModalContentConnector'; + +function ArtistHistoryModal(props) { + const { + isOpen, + onModalClose, + ...otherProps + } = props; + + return ( + + + + ); +} + +ArtistHistoryModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default ArtistHistoryModal; diff --git a/frontend/src/Artist/History/ArtistHistoryModalContent.js b/frontend/src/Artist/History/ArtistHistoryModalContent.js new file mode 100644 index 000000000..9be74ba40 --- /dev/null +++ b/frontend/src/Artist/History/ArtistHistoryModalContent.js @@ -0,0 +1,132 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +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 Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import ArtistHistoryRowConnector from './ArtistHistoryRowConnector'; + +const columns = [ + { + name: 'eventType', + isVisible: true + }, + { + name: 'album', + label: 'Album', + isVisible: true + }, + { + name: 'sourceTitle', + label: 'Source Title', + isVisible: true + }, + { + name: 'quality', + label: 'Quality', + isVisible: true + }, + { + name: 'date', + label: 'Date', + isVisible: true + }, + { + name: 'details', + label: 'Details', + isVisible: true + }, + { + name: 'actions', + label: 'Actions', + isVisible: true + } +]; + +class ArtistHistoryModalContent extends Component { + + // + // Render + + render() { + const { + albumId, + isFetching, + isPopulated, + error, + items, + onMarkAsFailedPress, + onModalClose + } = this.props; + + const fullArtist = albumId == null; + const hasItems = !!items.length; + + return ( + + + History + + + + { + isFetching && + + } + + { + !isFetching && !!error && +
Unable to load history.
+ } + + { + isPopulated && !hasItems && !error && +
No history.
+ } + + { + isPopulated && hasItems && !error && + + + { + items.map((item) => { + return ( + + ); + }) + } + +
+ } +
+ + + + +
+ ); + } +} + +ArtistHistoryModalContent.propTypes = { + albumId: PropTypes.number, + isFetching: PropTypes.bool.isRequired, + isPopulated: PropTypes.bool.isRequired, + error: PropTypes.object, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + onMarkAsFailedPress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default ArtistHistoryModalContent; diff --git a/frontend/src/Artist/History/ArtistHistoryModalContentConnector.js b/frontend/src/Artist/History/ArtistHistoryModalContentConnector.js new file mode 100644 index 000000000..a989361f5 --- /dev/null +++ b/frontend/src/Artist/History/ArtistHistoryModalContentConnector.js @@ -0,0 +1,81 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchArtistHistory, clearArtistHistory, artistHistoryMarkAsFailed } from 'Store/Actions/artistHistoryActions'; +import ArtistHistoryModalContent from './ArtistHistoryModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.artistHistory, + (artistHistory) => { + return artistHistory; + } + ); +} + +const mapDispatchToProps = { + fetchArtistHistory, + clearArtistHistory, + artistHistoryMarkAsFailed +}; + +class ArtistHistoryModalContentConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + const { + artistId, + albumId + } = this.props; + + this.props.fetchArtistHistory({ + artistId, + albumId + }); + } + + componentWillUnmount() { + this.props.clearArtistHistory(); + } + + // + // Listeners + + onMarkAsFailedPress = (historyId) => { + const { + artistId, + albumId + } = this.props; + + this.props.artistHistoryMarkAsFailed({ + historyId, + artistId, + albumId + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +ArtistHistoryModalContentConnector.propTypes = { + artistId: PropTypes.number.isRequired, + albumId: PropTypes.number, + fetchArtistHistory: PropTypes.func.isRequired, + clearArtistHistory: PropTypes.func.isRequired, + artistHistoryMarkAsFailed: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ArtistHistoryModalContentConnector); diff --git a/frontend/src/Artist/History/ArtistHistoryRow.css b/frontend/src/Artist/History/ArtistHistoryRow.css new file mode 100644 index 000000000..8c3fb8272 --- /dev/null +++ b/frontend/src/Artist/History/ArtistHistoryRow.css @@ -0,0 +1,6 @@ +.details, +.actions { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 65px; +} diff --git a/frontend/src/Artist/History/ArtistHistoryRow.js b/frontend/src/Artist/History/ArtistHistoryRow.js new file mode 100644 index 000000000..54688fce8 --- /dev/null +++ b/frontend/src/Artist/History/ArtistHistoryRow.js @@ -0,0 +1,160 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import ConfirmModal from 'Components/Modal/ConfirmModal'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +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 HistoryDetailsConnector from 'Activity/History/Details/HistoryDetailsConnector'; +import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell'; +import styles from './ArtistHistoryRow.css'; + +function getTitle(eventType) { + switch (eventType) { + case 'grabbed': return 'Grabbed'; + case 'artistFolderImported': return 'Artist Folder Imported'; + case 'downloadFolderImported': return 'Download Folder Imported'; + case 'downloadFailed': return 'Download Failed'; + case 'trackFileDeleted': return 'Track File Deleted'; + case 'trackFileRenamed': return 'Track File Renamed'; + default: return 'Unknown'; + } +} + +class ArtistHistoryRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isMarkAsFailedModalOpen: false + }; + } + + // + // Listeners + + onMarkAsFailedPress = () => { + this.setState({ isMarkAsFailedModalOpen: true }); + } + + onConfirmMarkAsFailed = () => { + this.props.onMarkAsFailedPress(this.props.id); + this.setState({ isMarkAsFailedModalOpen: false }); + } + + onMarkAsFailedModalClose = () => { + this.setState({ isMarkAsFailedModalOpen: false }); + } + + // + // Render + + render() { + const { + eventType, + sourceTitle, + quality, + qualityCutoffNotMet, + date, + data, + fullArtist, + artist, + album + } = this.props; + + const { + isMarkAsFailedModalOpen + } = this.state; + + return ( + + + + + {album.title} + + + + {sourceTitle} + + + + + + + + + + + } + title={getTitle(eventType)} + body={ + + } + position={tooltipPositions.LEFT} + /> + + + + { + eventType === 'grabbed' && + + } + + + + + ); + } +} + +ArtistHistoryRow.propTypes = { + id: PropTypes.number.isRequired, + eventType: PropTypes.string.isRequired, + sourceTitle: PropTypes.string.isRequired, + quality: PropTypes.object.isRequired, + qualityCutoffNotMet: PropTypes.bool.isRequired, + date: PropTypes.string.isRequired, + data: PropTypes.object.isRequired, + fullArtist: PropTypes.bool.isRequired, + artist: PropTypes.object.isRequired, + album: PropTypes.object.isRequired, + onMarkAsFailedPress: PropTypes.func.isRequired +}; + +export default ArtistHistoryRow; diff --git a/frontend/src/Artist/History/ArtistHistoryRowConnector.js b/frontend/src/Artist/History/ArtistHistoryRowConnector.js new file mode 100644 index 000000000..9d8b077ab --- /dev/null +++ b/frontend/src/Artist/History/ArtistHistoryRowConnector.js @@ -0,0 +1,26 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions'; +import createArtistSelector from 'Store/Selectors/createArtistSelector'; +import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector'; +import ArtistHistoryRow from './ArtistHistoryRow'; + +function createMapStateToProps() { + return createSelector( + createArtistSelector(), + createEpisodeSelector(), + (artist, album) => { + return { + artist, + album + }; + } + ); +} + +const mapDispatchToProps = { + fetchHistory, + markAsFailed +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(ArtistHistoryRow); diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js index 4086646de..003f71749 100644 --- a/frontend/src/Helpers/Props/icons.js +++ b/frontend/src/Helpers/Props/icons.js @@ -39,6 +39,7 @@ export const FOLDER_OPEN = 'fa fa-folder-open'; export const GROUP = 'fa fa-object-group'; export const HEALTH = 'fa fa-medkit'; export const HEART = 'fa fa-heart'; +export const HISTORY = 'fa fa-history'; export const HOUSEKEEPING = 'fa fa-home'; export const INFO = 'fa fa-info-circle'; export const INTERACTIVE = 'fa fa-user'; diff --git a/frontend/src/Store/Actions/artistHistoryActions.js b/frontend/src/Store/Actions/artistHistoryActions.js new file mode 100644 index 000000000..49369b6c6 --- /dev/null +++ b/frontend/src/Store/Actions/artistHistoryActions.js @@ -0,0 +1,104 @@ +import $ from 'jquery'; +import { createAction } from 'redux-actions'; +import { batchActions } from 'redux-batched-actions'; +import { createThunk, handleThunks } from 'Store/thunks'; +import createHandleActions from './Creators/createHandleActions'; +import { set, update } from './baseActions'; + +// +// Variables + +export const section = 'artistHistory'; + +// +// State + +export const defaultState = { + isFetching: false, + isPopulated: false, + error: null, + items: [] +}; + +// +// Actions Types + +export const FETCH_ARTIST_HISTORY = 'artistHistory/fetchArtistHistory'; +export const CLEAR_ARTIST_HISTORY = 'artistHistory/clearArtistHistory'; +export const ARTIST_HISTORY_MARK_AS_FAILED = 'artistHistory/artistHistoryMarkAsFailed'; + +// +// Action Creators + +export const fetchArtistHistory = createThunk(FETCH_ARTIST_HISTORY); +export const clearArtistHistory = createAction(CLEAR_ARTIST_HISTORY); +export const artistHistoryMarkAsFailed = createThunk(ARTIST_HISTORY_MARK_AS_FAILED); + +// +// Action Handlers + +export const actionHandlers = handleThunks({ + + [FETCH_ARTIST_HISTORY]: function(getState, payload, dispatch) { + dispatch(set({ section, isFetching: true })); + + const promise = $.ajax({ + url: '/history/artist', + data: payload + }); + + promise.done((data) => { + dispatch(batchActions([ + update({ section, data }), + + set({ + section, + isFetching: false, + isPopulated: true, + error: null + }) + ])); + }); + + promise.fail((xhr) => { + dispatch(set({ + section, + isFetching: false, + isPopulated: false, + error: xhr + })); + }); + }, + + [ARTIST_HISTORY_MARK_AS_FAILED]: function(getState, payload, dispatch) { + const { + historyId, + artistId, + albumId + } = payload; + + const promise = $.ajax({ + url: '/history/failed', + method: 'POST', + data: { + id: historyId + } + }); + + promise.done(() => { + dispatch(fetchArtistHistory({ artistId, albumId })); + }); + } +}); + +// +// Reducers + +export const reducers = createHandleActions({ + + [CLEAR_ARTIST_HISTORY]: (state) => { + return Object.assign({}, state, defaultState); + } + +}, defaultState, section); + diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index 46a7eff65..a98311bcc 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -19,6 +19,7 @@ import * as rootFolders from './rootFolderActions'; import * as albumStudio from './albumStudioActions'; import * as artist from './artistActions'; import * as artistEditor from './artistEditorActions'; +import * as artistHistory from './artistHistoryActions'; import * as artistIndex from './artistIndexActions'; import * as settings from './settingsActions'; import * as system from './systemActions'; @@ -48,6 +49,7 @@ export default [ albumStudio, artist, artistEditor, + artistHistory, artistIndex, settings, system, diff --git a/frontend/src/Store/Actions/interactiveImportActions.js b/frontend/src/Store/Actions/interactiveImportActions.js index eb1d8064a..30f376876 100644 --- a/frontend/src/Store/Actions/interactiveImportActions.js +++ b/frontend/src/Store/Actions/interactiveImportActions.js @@ -31,6 +31,12 @@ export const defaultState = { recentFolders: [], importMode: 'move', sortPredicates: { + relativePath: function(item, direction) { + const relativePath = item.relativePath; + + return relativePath.toLowerCase(); + }, + artist: function(item, direction) { const artist = item.artist; diff --git a/src/Lidarr.Api.V1/History/HistoryModule.cs b/src/Lidarr.Api.V1/History/HistoryModule.cs index fa11e63c4..e750349e2 100644 --- a/src/Lidarr.Api.V1/History/HistoryModule.cs +++ b/src/Lidarr.Api.V1/History/HistoryModule.cs @@ -31,6 +31,7 @@ namespace Lidarr.Api.V1.History GetResourcePaged = GetHistory; Get["/since"] = x => GetHistorySince(); + Get["/artist"] = x => GetArtistHistory(); Post["/failed"] = x => MarkAsFailed(); } @@ -109,6 +110,38 @@ namespace Lidarr.Api.V1.History return _historyService.Since(date, eventType).Select(h => MapToResource(h, includeArtist, includeAlbum, includeTrack)).ToList(); } + private List GetArtistHistory() + { + var queryArtistId = Request.Query.ArtistId; + var queryAlbumId = Request.Query.AlbumId; + var queryEventType = Request.Query.EventType; + + if (!queryArtistId.HasValue) + { + throw new BadRequestException("artistId is missing"); + } + + int artistId = Convert.ToInt32(queryArtistId.Value); + HistoryEventType? eventType = null; + var includeArtist = Request.GetBooleanQueryParameter("includeArtist"); + var includeAlbum = Request.GetBooleanQueryParameter("includeAlbum"); + var includeTrack = Request.GetBooleanQueryParameter("includeTrack"); + + if (queryEventType.HasValue) + { + eventType = (HistoryEventType)Convert.ToInt32(queryEventType.Value); + } + + if (queryAlbumId.HasValue) + { + int albumId = Convert.ToInt32(queryAlbumId.Value); + + return _historyService.GetByAlbum(artistId, albumId, eventType).Select(h => MapToResource(h, includeArtist, includeAlbum, includeTrack)).ToList(); + } + + return _historyService.GetByArtist(artistId, eventType).Select(h => MapToResource(h, includeArtist, includeAlbum, includeTrack)).ToList(); + } + private Response MarkAsFailed() { var id = (int)Request.Form.Id; diff --git a/src/NzbDrone.Core/History/HistoryRepository.cs b/src/NzbDrone.Core/History/HistoryRepository.cs index 157e91e6a..618e98360 100644 --- a/src/NzbDrone.Core/History/HistoryRepository.cs +++ b/src/NzbDrone.Core/History/HistoryRepository.cs @@ -14,6 +14,8 @@ namespace NzbDrone.Core.History History MostRecentForAlbum(int albumId); History MostRecentForDownloadId(string downloadId); List FindByDownloadId(string downloadId); + List GetByArtist(int artistId, HistoryEventType? eventType); + List GetByAlbum(int artistId, int albumId, HistoryEventType? eventType); List FindDownloadHistory(int idArtistId, QualityModel quality); void DeleteForArtist(int artistId); List Since(DateTime date, HistoryEventType? eventType); @@ -48,6 +50,36 @@ namespace NzbDrone.Core.History return Query.Where(h => h.DownloadId == downloadId); } + public List GetByArtist(int artistId, HistoryEventType? eventType) + { + var query = Query.Where(h => h.ArtistId == artistId); + + if (eventType.HasValue) + { + query.AndWhere(h => h.EventType == eventType); + } + + query.OrderByDescending(h => h.Date); + + return query; + } + + public List GetByAlbum(int artistId, int albumId, HistoryEventType? eventType) + { + var query = Query.Join(JoinType.Inner, h => h.Album, (h, e) => h.AlbumId == e.Id) + .Where(h => h.ArtistId == artistId) + .AndWhere(h => h.AlbumId == albumId); + + if (eventType.HasValue) + { + query.AndWhere(h => h.EventType == eventType); + } + + query.OrderByDescending(h => h.Date); + + return query; + } + public List FindDownloadHistory(int idArtistId, QualityModel quality) { return Query.Where(h => diff --git a/src/NzbDrone.Core/History/HistoryService.cs b/src/NzbDrone.Core/History/HistoryService.cs index a84fa60f4..d698f6da1 100644 --- a/src/NzbDrone.Core/History/HistoryService.cs +++ b/src/NzbDrone.Core/History/HistoryService.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; +using Marr.Data.QGen; using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Core.Datastore; @@ -13,7 +14,7 @@ using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Profiles.Languages; using NzbDrone.Core.Languages; -using NzbDrone.Core.Qualities; +using NzbDrone.Core.Music; using NzbDrone.Core.Music.Events; namespace NzbDrone.Core.History @@ -24,6 +25,8 @@ namespace NzbDrone.Core.History History MostRecentForAlbum(int episodeId); History MostRecentForDownloadId(string downloadId); History Get(int historyId); + List GetByArtist(int artistId, HistoryEventType? eventType); + List GetByAlbum(int artistId, int albumId, HistoryEventType? eventType); List Find(string downloadId, HistoryEventType eventType); List FindByDownloadId(string downloadId); List Since(DateTime date, HistoryEventType? eventType); @@ -66,6 +69,16 @@ namespace NzbDrone.Core.History return _historyRepository.Get(historyId); } + public List GetByArtist(int artistId, HistoryEventType? eventType) + { + return _historyRepository.GetByArtist(artistId, eventType); + } + + public List GetByAlbum(int artistId, int albumId, HistoryEventType? eventType) + { + return _historyRepository.GetByAlbum(artistId, albumId, eventType); + } + public List Find(string downloadId, HistoryEventType eventType) { return _historyRepository.FindByDownloadId(downloadId).Where(c => c.EventType == eventType).ToList();