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();