diff --git a/frontend/src/Activity/Blacklist/BlacklistRow.js b/frontend/src/Activity/Blacklist/BlacklistRow.js index 1acbe5133..f67e69723 100644 --- a/frontend/src/Activity/Blacklist/BlacklistRow.js +++ b/frontend/src/Activity/Blacklist/BlacklistRow.js @@ -68,7 +68,7 @@ class BlacklistRow extends Component { return ( diff --git a/frontend/src/Activity/History/HistoryRow.js b/frontend/src/Activity/History/HistoryRow.js index 8823aacba..8f29fee8f 100644 --- a/frontend/src/Activity/History/HistoryRow.js +++ b/frontend/src/Activity/History/HistoryRow.js @@ -103,7 +103,7 @@ class HistoryRow extends Component { return ( diff --git a/frontend/src/Activity/Queue/QueueRow.js b/frontend/src/Activity/Queue/QueueRow.js index f5f766895..90622873c 100644 --- a/frontend/src/Activity/Queue/QueueRow.js +++ b/frontend/src/Activity/Queue/QueueRow.js @@ -134,7 +134,7 @@ class QueueRow extends Component { return ( @@ -145,7 +145,7 @@ class QueueRow extends Component { return ( diff --git a/frontend/src/AddArtist/AddNewArtist/AddNewArtist.js b/frontend/src/AddArtist/AddNewArtist/AddNewArtist.js index b6975d36b..677d7bce4 100644 --- a/frontend/src/AddArtist/AddNewArtist/AddNewArtist.js +++ b/frontend/src/AddArtist/AddNewArtist/AddNewArtist.js @@ -99,7 +99,7 @@ class AddNewArtist extends Component { className={styles.searchInput} name="artistLookup" value={term} - placeholder="eg. Breaking Benjamin, lidarr:####" + placeholder="eg. Breaking Benjamin, lidarr:854a1807-025b-42a8-ba8c-2a39717f1d25" onChange={this.onSearchInputChange} /> @@ -144,7 +144,7 @@ class AddNewArtist extends Component { !isFetching && !error && !items.length && !!term &&
Couldn't find any results for '{term}'
-
You can also search using MusicBrainz ID of a show. eg. lidarr:cc197bad-dc9c-440d-a5b5-d52ba2e14234
+
You can also search using MusicBrainz ID of an artist. eg. lidarr:cc197bad-dc9c-440d-a5b5-d52ba2e14234
Why can't I find my artist? @@ -157,7 +157,7 @@ class AddNewArtist extends Component { !term &&
It's easy to add a new artist, just start typing the name the artist you want to add.
-
You can also search using MusicBrainz ID of a show. eg. lidarr:cc197bad-dc9c-440d-a5b5-d52ba2e14234
+
You can also search using MusicBrainz ID of an artist. eg. lidarr:cc197bad-dc9c-440d-a5b5-d52ba2e14234
} diff --git a/frontend/src/Album/AlbumCover.js b/frontend/src/Album/AlbumCover.js new file mode 100644 index 000000000..a6f63c429 --- /dev/null +++ b/frontend/src/Album/AlbumCover.js @@ -0,0 +1,168 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import LazyLoad from 'react-lazyload'; + +const coverPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPcAAAD3AgMAAAC84irAAAAADFBMVEUyMjI7Ozs1NTU4ODjgOsZvAAAAAWJLR0QAiAUdSAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAAd0SU1FB+EJEBIzDdm9OfoAAAbkSURBVGje7Zq9b9s4FMBZFgUkBR27C3cw0MromL1jxwyVZASB67G4qWPgoSAyBdm9CwECKCp8nbIccGj/Ce/BTUb3Lh3aI997pCjnTnyyt0JcIif5+ZHvPZLvQ0KMYxzjGMc4xjGOcYxjHOP4JUfSfP7RVPvSH3MYX/eC5aecxne1v+w95WebFs/rwVO/8+h8PnT6t3ln/DFQuJ06/SyHiX9pxa7o5/lewkuLDxLvhM8tPki8g07dU8Gnj5zGlw7P79n4pDVYi8/YuHO4n03z0z6XXDom4G3TXDdN840+LobN/W1Ty2slHD8bNvevlUgutLmTj4NmT3pf6mMGcJGth+gefaZsDCjB2Wj65wN8ZmnAGnE6eFieI1FvcEISLjIUr9hm+w7PFeHiE9t0E7dyIatE48odXTPu0j/A3BMnXf7NXDxudTxbE2VxMWVu+sfwf3i1ZMLiaQLf+iWIP4VtjtTzFhc35vfveZrb4nPt4R95ulu1cxeVh8Psw7rzbgWp8dWHyr83WJpbgjypjS5XeZnqRxmJNUd3MS1d6ue/tOn0WuayNd2CoTlaeqwnIVeOgcWHdHdMS9cSN1vCy3bxZwzFm6VL7QA14WTudVj1sFvf4ReZNSCO0IvwngXFV3hkFcriuPokrPrYbYxjVAHiZ24zLYIeP7/E4xZUgHiZWt29D9ptGemHR7mPo9B10HLGbucRfs/Ww2f2CD4L2u0+wofKwwvrd0XoqCmr38CAZa1d58LesEpvgqtN4MCR1mVj2nZWOiweVB/CAXuyi59Y1auA2eekg6Xw8Tfm013A8LFV8mYXL61ZF4Hb8Zx8d9vBtbdG7s99XvOOZlF38QVtmlkAv0ffxTOjxU/o5p8FvKbSszw2ik87+Iz23Lwf134RiWf2tG3xN2T4oh8vDO4U33z+5qnefFnR77OA2wheh2WfbJBHeI/XgtNJEaHdtJNrvPn8E8eV/kW/2xn8FDc77LemOyq4J1XvSbds7SZ3cAV+86UXP283TGaFUk4ZwmNyugne8FaqxdHtFkH8GNewg2cc3PjsM7CbbNdMwQJ47aL3mP5H308ar5XOn2nUwpx+4hrx/z+qn5DBNqD4rMUpWACnPwnhkfa9SnZwvX1MnHLVi08cPle+0wBuAsykd8dO0KkS9L0dPCO37MVLxJc6nPHdTeNT/ZeLDQN/DEFpBzc33Bfckhx8K1q7IS5vuPgjbTf5AL97zcALxFUHN76QrF7heTHru54RN3bbxTeEn4Xx04f4NOfhSuPLncmnQk3z1yLlSE8fabtFHVyZyIQlXes8zrdSJR5ea7k3+asUooXg2mO4oDprT/XdHpROhouL/8A3edBw5DYxBhYdn08Q53jd0elDfApHbHjL6Hk/pvvNd1rEWdLl9iG+hpMgiMMdVEM64B8X5nq6ZBwX5rCSeK/4uInJROiwetLi0jtpG0yJBPOkTVQXryEPKqMQbq6JeyUTvUOkilq/EVGmo5NIpP3XRIzhXIafrjzF30JUIqecKxIjOpF6il9jbHTLxjs3rN5voPH+GxbDA1m7GrM9a4zdTigdCUUXD2MSSEAXQRxDo2QHl2iwV+h7gchqLrLrhmKxH/Z6nqLUQD5AYSHWAEwk+Z1Ck1vEAmEhBaVtufDtj8Zmv6U+PQNBqbDf/szVR5XNvQteSAzRyeQhzgnIKR2Invq43gQb4+oRaJCTTcRd6RkzGXlJQe3vDq8gsDB2S0QaSoViwKNW9Sh9zUzEMA2MWtU7nJUGYhIa4bnjcLthgkkopMAGj3dxXgoMCbg+laTFL8luSn9pFkrAMf031cmVJz0jXzsKFm6OSfVqYnEILPKZDjeicPFhQoaHbMhKX+NmZ5Q+ntr8n5obhGPVKlx48cs+FteKP3MlswWv6CSPHK4Dmntm0ckreW0snmxKbsnLFdyo4mrwjLYJo+Dmyn0k3uDTEpMRTrnPKza+IHy9wGSEU2yMvSrvHeJ/Qt2UV+p0hVacvsah0psKXqEVy7y2tPu3xhM1oMxLReY00tAlJG9JFZktzCwyU4lbuqQ7U22VN1zi9gvsIP05PjAL7H55H/C6rREzyvu41bbS4VXb1OV0FLG1YVsa1J1gtzaosVJbHO3Gb6z4bR2H89s61FRqCIcgL+E3lfyWlsaN3eR6QDP0pSdeKqOEZjOgoda285SUl5W+Jga181wz0WQFF2poM7FtZTZKXlXZ0Fam10htroY3Ug9s43pN5OJ2jyZy28Iu1nu0sNsGenGzRwO9bd8Xd/u0793LA8Vmn5cHnPhiH+Gt+HIv4Ye+tnHoSyMHvrJy6Aszh76uc+DLQuLQV5XGMY5xjGMc4xjHOMYxjnH80uNfW99BeoyzJCoAAAAASUVORK5CYII='; + +function findCover(images) { + return _.find(images, { coverType: 'cover' }); +} + +function getCoverUrl(cover, size) { + if (cover) { + if (cover.url.contains('lastWrite=') || (/^https?:/).test(cover.url)) { + // Remove protocol + let url = cover.url.replace(/^https?:/, ''); + url = url.replace('cover.jpg', `cover-${size}.jpg`); + + return url; + } + } +} + +class AlbumCover extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + const pixelRatio = Math.floor(window.devicePixelRatio); + + const { + images, + size + } = props; + + const cover = findCover(images); + + this.state = { + pixelRatio, + cover, + coverUrl: getCoverUrl(cover, pixelRatio * size), + isLoaded: false, + hasError: false + }; + } + + componentDidUpdate(prevProps) { + const { + images, + size + } = this.props; + + const { + cover, + pixelRatio + } = this.state; + + const nextcover = findCover(images); + + if (nextcover && (!cover || nextcover.url !== cover.url)) { + this.setState({ + cover: nextcover, + coverUrl: getCoverUrl(nextcover, pixelRatio * size), + hasError: false + // Don't reset isLoaded, as we want to immediately try to + // show the new image, whether an image was shown previously + // or the placeholder was shown. + }); + } + } + + // + // Listeners + + onError = () => { + this.setState({ hasError: true }); + } + + onLoad = () => { + this.setState({ + isLoaded: true, + hasError: false + }); + } + + // + // Render + + render() { + const { + className, + style, + size, + lazy, + overflow + } = this.props; + + const { + coverUrl, + hasError, + isLoaded + } = this.state; + + if (hasError || !coverUrl) { + return ( + + ); + } + + if (lazy) { + return ( + + } + > + + + ); + } + + return ( + + ); + } +} + +AlbumCover.propTypes = { + className: PropTypes.string, + style: PropTypes.object, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + size: PropTypes.number.isRequired, + lazy: PropTypes.bool.isRequired, + overflow: PropTypes.bool.isRequired +}; + +AlbumCover.defaultProps = { + size: 250, + lazy: true, + overflow: false +}; + +export default AlbumCover; diff --git a/frontend/src/Album/AlbumDetailsModalContent.css b/frontend/src/Album/AlbumDetailsModalContent.css index 9d428208d..b80b14cb9 100644 --- a/frontend/src/Album/AlbumDetailsModalContent.css +++ b/frontend/src/Album/AlbumDetailsModalContent.css @@ -37,7 +37,7 @@ margin-top: 20px; } -.openSeriesButton { +.openArtistButton { composes: button from 'Components/Link/Button.css'; margin-right: auto; diff --git a/frontend/src/Album/AlbumDetailsModalContent.js b/frontend/src/Album/AlbumDetailsModalContent.js index 42a8357d0..178be8086 100644 --- a/frontend/src/Album/AlbumDetailsModalContent.js +++ b/frontend/src/Album/AlbumDetailsModalContent.js @@ -8,13 +8,11 @@ import ModalHeader from 'Components/Modal/ModalHeader'; import ModalBody from 'Components/Modal/ModalBody'; import ModalFooter from 'Components/Modal/ModalFooter'; import MonitorToggleButton from 'Components/MonitorToggleButton'; -import AlbumSummaryConnector from './Summary/AlbumSummaryConnector'; import AlbumHistoryConnector from './History/AlbumHistoryConnector'; import AlbumSearchConnector from './Search/AlbumSearchConnector'; import styles from './AlbumDetailsModalContent.css'; const tabs = [ - 'details', 'history', 'search' ]; @@ -45,14 +43,10 @@ class AlbumDetailsModalContent extends Component { render() { const { albumId, - albumEntity, - artistId, artistName, - nameSlug, - albumLabel, + foreignArtistId, artistMonitored, albumTitle, - releaseDate, monitored, isSaving, showOpenArtistButton, @@ -61,7 +55,7 @@ class AlbumDetailsModalContent extends Component { onModalClose } = this.props; - const artistLink = `/artist/${nameSlug}`; + const artistLink = `/artist/${foreignArtistId}`; return ( - - Details - - - - - - @@ -172,7 +149,7 @@ AlbumDetailsModalContent.propTypes = { albumEntity: PropTypes.string.isRequired, artistId: PropTypes.number.isRequired, artistName: PropTypes.string.isRequired, - nameSlug: PropTypes.string.isRequired, + foreignArtistId: PropTypes.string.isRequired, artistMonitored: PropTypes.bool.isRequired, releaseDate: PropTypes.string.isRequired, albumLabel: PropTypes.arrayOf(PropTypes.string).isRequired, @@ -187,7 +164,7 @@ AlbumDetailsModalContent.propTypes = { }; AlbumDetailsModalContent.defaultProps = { - selectedTab: 'details', + selectedTab: 'history', albumLabel: ['Unknown'], albumEntity: albumEntities.ALBUMS, startInteractiveSearch: false diff --git a/frontend/src/Album/AlbumDetailsModalContentConnector.js b/frontend/src/Album/AlbumDetailsModalContentConnector.js index 23bfaeeea..5f2c52690 100644 --- a/frontend/src/Album/AlbumDetailsModalContentConnector.js +++ b/frontend/src/Album/AlbumDetailsModalContentConnector.js @@ -17,13 +17,13 @@ function createMapStateToProps() { (album, artist) => { const { artistName, - nameSlug, + foreignArtistId, monitored: artistMonitored } = artist; return { artistName, - nameSlug, + foreignArtistId, artistMonitored, ...album }; diff --git a/frontend/src/Album/AlbumSearchCell.js b/frontend/src/Album/AlbumSearchCell.js index e2ad58550..c1924aa16 100644 --- a/frontend/src/Album/AlbumSearchCell.js +++ b/frontend/src/Album/AlbumSearchCell.js @@ -5,6 +5,7 @@ import IconButton from 'Components/Link/IconButton'; import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import AlbumDetailsModal from './AlbumDetailsModal'; +import EditAlbumModalConnector from './Edit/EditAlbumModalConnector'; import styles from './AlbumSearchCell.css'; class AlbumSearchCell extends Component { @@ -16,7 +17,8 @@ class AlbumSearchCell extends Component { super(props, context); this.state = { - isDetailsModalOpen: false + isDetailsModalOpen: false, + isEditAlbumModalOpen: false }; } @@ -31,6 +33,14 @@ class AlbumSearchCell extends Component { this.setState({ isDetailsModalOpen: false }); } + onEditAlbumPress = () => { + this.setState({ isEditAlbumModalOpen: true }); + } + + onEditAlbumModalClose = () => { + this.setState({ isEditAlbumModalOpen: false }); + } + // // Render @@ -57,6 +67,12 @@ class AlbumSearchCell extends Component { onPress={this.onManualSearchPress} /> + + + + ); } diff --git a/frontend/src/Album/AlbumTitleDetailLink.js b/frontend/src/Album/AlbumTitleDetailLink.js new file mode 100644 index 000000000..f357e5a3f --- /dev/null +++ b/frontend/src/Album/AlbumTitleDetailLink.js @@ -0,0 +1,20 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Link from 'Components/Link/Link'; + +function AlbumTitleDetailLink({ foreignAlbumId, title }) { + const link = `/album/${foreignAlbumId}`; + + return ( + + {title} + + ); +} + +AlbumTitleDetailLink.propTypes = { + foreignAlbumId: PropTypes.string.isRequired, + title: PropTypes.string.isRequired +}; + +export default AlbumTitleDetailLink; diff --git a/frontend/src/Album/Details/AlbumDetails.css b/frontend/src/Album/Details/AlbumDetails.css new file mode 100644 index 000000000..8b5f5c770 --- /dev/null +++ b/frontend/src/Album/Details/AlbumDetails.css @@ -0,0 +1,137 @@ +.innerContentBody { + padding: 0; +} + +.header { + position: relative; + width: 100%; + height: 310px; +} + +.backdrop { + position: absolute; + z-index: -1; + width: 100%; + height: 100%; + background-size: cover; +} + +.backdropOverlay { + position: absolute; + width: 100%; + height: 100%; + background: $black; + opacity: 0.7; +} + +.headerContent { + display: flex; + padding: 30px; + width: 100%; + height: 100%; + color: $white; +} + +.logo { + flex-shrink: 0; + margin-right: 35px; + width: 250px; + height: 97px; +} + +.cover { + flex-shrink: 0; + margin-right: 35px; + width: 250px; + height: 250px; +} + +.info { + display: flex; + flex-direction: column; + flex-grow: 1; + overflow: hidden; +} + +.titleRow { + display: flex; + justify-content: space-between; + flex: 0 0 auto; +} + +.titleContainer { + display: flex; + justify-content: space-between; +} + +.title { + margin-bottom: 5px; + font-weight: 300; + font-size: 50px; + line-height: 50px; +} + +.alternateTitlesIconContainer { + margin-left: 20px; + line-height: 50px; +} + +.artistNavigationButtons { + white-space: no-wrap; +} + +.artistNavigationButton { + composes: button from 'Components/Link/IconButton.css'; + + margin-left: 5px; + color: #e1e2e3; + white-space: nowrap; +} + +.details { + font-weight: 300; + font-size: 20px; +} + +.runtime { + margin-right: 15px; +} + +.detailsLabel { + composes: label from 'Components/Label.css'; + + margin: 5px 10px 5px 0; +} + +.sizeOnDisk, +.qualityProfileName, +.tags { + margin-left: 8px; + font-weight: 300; + font-size: 17px; +} + +.overview { + flex: 1 0 auto; + min-height: 0; +} + +.contentContainer { + padding: 20px; +} + +@media only screen and (max-width: $breakpointSmall) { + .contentContainer { + padding: 20px 0; + } + + .headerContent { + padding: 15px; + } +} + +@media only screen and (max-width: $breakpointLarge) { + .cover { + display: none; + } +} diff --git a/frontend/src/Album/Details/AlbumDetails.js b/frontend/src/Album/Details/AlbumDetails.js new file mode 100644 index 000000000..b0d8f9d10 --- /dev/null +++ b/frontend/src/Album/Details/AlbumDetails.js @@ -0,0 +1,422 @@ +import _ from 'lodash'; +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import formatBytes from 'Utilities/Number/formatBytes'; +import selectAll from 'Utilities/Table/selectAll'; +import toggleSelected from 'Utilities/Table/toggleSelected'; +import { align, icons, sizes } from 'Helpers/Props'; +import HeartRating from 'Components/HeartRating'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import Label from 'Components/Label'; +import AlbumCover from 'Album/AlbumCover'; +import EditAlbumModalConnector from 'Album/Edit/EditAlbumModalConnector'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import AlbumDetailsMediumConnector from './AlbumDetailsMediumConnector'; +import ArtistHistoryModal from 'Artist/History/ArtistHistoryModal'; +import TrackFileEditorModal from 'TrackFile/Editor/TrackFileEditorModal'; + +import styles from './AlbumDetails.css'; + +function getFanartUrl(images) { + const fanartImage = _.find(images, { coverType: 'fanart' }); + if (fanartImage) { + // Remove protocol + return fanartImage.url.replace(/^https?:/, ''); + } +} + +function getExpandedState(newState) { + return { + allExpanded: newState.allSelected, + allCollapsed: newState.allUnselected, + expandedState: newState.selectedState + }; +} + +class AlbumDetails extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isArtistHistoryModalOpen: false, + isManageTracksOpen: false, + isEditAlbumModalOpen: false, + allExpanded: false, + allCollapsed: false, + expandedState: {} + }; + } + + // + // Listeners + + onEditAlbumPress = () => { + this.setState({ isEditAlbumModalOpen: true }); + } + + onEditAlbumModalClose = () => { + this.setState({ isEditAlbumModalOpen: false }); + } + + onManageTracksPress = () => { + this.setState({ isManageTracksOpen: true }); + } + + onManageTracksModalClose = () => { + this.setState({ isManageTracksOpen: false }); + } + + onArtistHistoryPress = () => { + this.setState({ isArtistHistoryModalOpen: true }); + } + + onArtistHistoryModalClose = () => { + this.setState({ isArtistHistoryModalOpen: false }); + } + + onExpandAllPress = () => { + const { + allExpanded, + expandedState + } = this.state; + + this.setState(getExpandedState(selectAll(expandedState, !allExpanded))); + } + + onExpandPress = (albumId, isExpanded) => { + this.setState((state) => { + const convertedState = { + allSelected: state.allExpanded, + allUnselected: state.allCollapsed, + selectedState: state.expandedState + }; + + const newState = toggleSelected(convertedState, [], albumId, isExpanded, false); + + return getExpandedState(newState); + }); + } + + // + // Render + + render() { + const { + id, + title, + albumType, + statistics, + monitored, + releaseDate, + images, + media, + isFetching, + isPopulated, + albumsError, + trackFilesError, + shortDateFormat, + artist, + previousAlbum, + nextAlbum, + isSearching, + onSearchPress + } = this.props; + + const { + isArtistHistoryModalOpen, + isEditAlbumModalOpen, + isManageTracksOpen, + allExpanded, + allCollapsed, + expandedState + } = this.state; + + let expandIcon = icons.EXPAND_INDETERMINATE; + + if (allExpanded) { + expandIcon = icons.COLLAPSE; + } else if (allCollapsed) { + expandIcon = icons.EXPAND; + } + + return ( + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ +
+ + +
+
+
+ {title} +
+ +
+ + + + + +
+
+ + {/*
+
+ +
+
*/} + +
+ + + + + + + + { + !!albumType && + + } + +
+
+
+
+ +
+ { + !isPopulated && !albumsError && !trackFilesError && + + } + + { + !isFetching && albumsError && +
Loading albums failed
+ } + + { + !isFetching && trackFilesError && +
Loading track files failed
+ } + + { + isPopulated && !!media.length && +
+ + { + media.slice(0).map((medium) => { + return ( + + ); + }) + } +
+ } + +
+ + + + + + + + + + ); + } +} + +AlbumDetails.propTypes = { + id: PropTypes.number.isRequired, + foreignAlbumId: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + albumType: PropTypes.string.isRequired, + statistics: PropTypes.object.isRequired, + releaseDate: PropTypes.string.isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + media: PropTypes.arrayOf(PropTypes.object).isRequired, + monitored: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + isSearching: PropTypes.bool, + isFetching: PropTypes.bool, + isPopulated: PropTypes.bool, + albumsError: PropTypes.object, + tracksError: PropTypes.object, + trackFilesError: PropTypes.object, + artist: PropTypes.object, + previousAlbum: PropTypes.object, + nextAlbum: PropTypes.object, + onRefreshPress: PropTypes.func, + onSearchPress: PropTypes.func.isRequired +}; + +AlbumDetails.defaultProps = { + isSaving: false +}; + +export default AlbumDetails; diff --git a/frontend/src/Album/Details/AlbumDetailsConnector.js b/frontend/src/Album/Details/AlbumDetailsConnector.js new file mode 100644 index 000000000..e10450745 --- /dev/null +++ b/frontend/src/Album/Details/AlbumDetailsConnector.js @@ -0,0 +1,146 @@ +/* eslint max-params: 0 */ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { findCommand } from 'Utilities/Command'; +import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import { fetchTracks, clearTracks } from 'Store/Actions/trackActions'; +import { fetchTrackFiles, clearTrackFiles } from 'Store/Actions/trackFileActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import * as commandNames from 'Commands/commandNames'; +import AlbumDetails from './AlbumDetails'; +import createAllArtistSelector from 'Store/Selectors/createAllArtistSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; + +function createMapStateToProps() { + return createSelector( + (state, { foreignAlbumId }) => foreignAlbumId, + (state) => state.tracks, + (state) => state.trackFiles, + (state) => state.albums, + createAllArtistSelector(), + createCommandsSelector(), + createUISettingsSelector(), + (foreignAlbumId, tracks, trackFiles, albums, artists, commands, uiSettings) => { + const sortedAlbums = _.orderBy(albums.items, 'releaseDate'); + const albumIndex = _.findIndex(sortedAlbums, { foreignAlbumId }); + const album = sortedAlbums[albumIndex]; + const artist = _.find(artists, { id: album.artistId }); + + if (!album) { + return {}; + } + + const previousAlbum = sortedAlbums[albumIndex - 1] || _.last(sortedAlbums); + const nextAlbum = sortedAlbums[albumIndex + 1] || _.first(sortedAlbums); + const isSearching = !!findCommand(commands, { name: commandNames.ALBUM_SEARCH }); + + const isFetching = tracks.isFetching || trackFiles.isFetching; + const isPopulated = tracks.isPopulated && trackFiles.isPopulated; + const tracksError = tracks.error; + const trackFilesError = trackFiles.error; + + return { + ...album, + shortDateFormat: uiSettings.shortDateFormat, + artist, + isSearching, + isFetching, + isPopulated, + tracksError, + trackFilesError, + previousAlbum, + nextAlbum + }; + } + ); +} + +const mapDispatchToProps = { + executeCommand, + fetchTracks, + clearTracks, + fetchTrackFiles, + clearTrackFiles +}; + +class AlbumDetailsConnector extends Component { + + componentDidMount() { + registerPagePopulator(this.populate); + this.populate(); + } + + componentDidUpdate(prevProps) { + const { + id + } = this.props; + + // If the id has changed we need to clear the tracks/track + // files and fetch from the server. + + if (prevProps.id !== id) { + this.unpopulate(); + this.populate(); + } + } + + componentWillUnmount() { + unregisterPagePopulator(this.populate); + this.unpopulate(); + } + + // + // Control + + populate = () => { + const albumId = this.props.id; + + this.props.fetchTracks({ albumId }); + this.props.fetchTrackFiles({ albumId }); + } + + unpopulate = () => { + this.props.clearTracks(); + this.props.clearTrackFiles(); + } + + // + // Listeners + + onSearchPress = () => { + this.props.executeCommand({ + name: commandNames.ALBUM_SEARCH, + albumIds: [this.props.id] + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AlbumDetailsConnector.propTypes = { + id: PropTypes.number, + isAlbumFetching: PropTypes.bool, + isAlbumPopulated: PropTypes.bool, + foreignAlbumId: PropTypes.string.isRequired, + fetchTracks: PropTypes.func.isRequired, + clearTracks: PropTypes.func.isRequired, + fetchTrackFiles: PropTypes.func.isRequired, + clearTrackFiles: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AlbumDetailsConnector); diff --git a/frontend/src/Album/Details/AlbumDetailsMedium.css b/frontend/src/Album/Details/AlbumDetailsMedium.css new file mode 100644 index 000000000..a89cca5f8 --- /dev/null +++ b/frontend/src/Album/Details/AlbumDetailsMedium.css @@ -0,0 +1,114 @@ +.medium { + margin-bottom: 20px; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; + + &:last-of-type { + margin-bottom: 0; + } +} + +.header { + position: relative; + display: flex; + align-items: center; + width: 100%; + font-size: 24px; +} + +.mediumNumber { + margin-right: 10px; + margin-left: 5px; +} + +.mediumFormat { + color: #8895aa; + font-style: italic; + font-size: 18px; +} + +.expandButton { + composes: link from 'Components/Link/Link.css'; + + flex-grow: 1; + margin: 0 20px; + text-align: center; +} + +.left { + display: flex; + align-items: center; + flex: 0 1 300px; +} + +.left, +.actions { + padding: 15px 10px; +} + +.actionsMenu { + composes: menu from 'Components/Menu/Menu.css'; + + flex: 0 0 45px; +} + +.actionsMenuContent { + composes: menuContent from 'Components/Menu/MenuContent.css'; + + white-space: nowrap; + font-size: 14px; +} + +.actionMenuIcon { + margin-right: 8px; +} + +.actionButton { + composes: button from 'Components/Link/IconButton.css'; + + width: 30px; +} + +.tracks { + padding-top: 15px; + border-top: 1px solid $borderColor; +} + +.collapseButtonContainer { + padding: 10px 15px; + width: 100%; + border-top: 1px solid $borderColor; + border-bottom-right-radius: 4px; + border-bottom-left-radius: 4px; + background-color: #fafafa; + text-align: center; +} + +.expandButtonIcon { + composes: actionButton; + + position: absolute; + top: 50%; + left: 50%; + margin-top: -12px; + margin-left: -15px; +} + +.noTracks { + margin-bottom: 15px; + text-align: center; +} + +@media only screen and (max-width: $breakpointSmall) { + .medium { + border-right: 0; + border-left: 0; + border-radius: 0; + } + + .expandButtonIcon { + position: static; + margin: 0; + } +} diff --git a/frontend/src/Album/Details/AlbumDetailsMedium.js b/frontend/src/Album/Details/AlbumDetailsMedium.js new file mode 100644 index 000000000..0a3d9c242 --- /dev/null +++ b/frontend/src/Album/Details/AlbumDetailsMedium.js @@ -0,0 +1,211 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import isAfter from 'Utilities/Date/isAfter'; +import isBefore from 'Utilities/Date/isBefore'; +import { icons, kinds, sizes } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import IconButton from 'Components/Link/IconButton'; +import Label from 'Components/Label'; +import Link from 'Components/Link/Link'; +import Table from 'Components/Table/Table'; +import TableBody from 'Components/Table/TableBody'; +import TrackRowConnector from './TrackRowConnector'; +import styles from './AlbumDetailsMedium.css'; + +function getMediumStatistics(tracks) { + let trackCount = 0; + let trackFileCount = 0; + let totalTrackCount = 0; + + tracks.forEach((track) => { + if (track.trackFileId) { + trackCount++; + trackFileCount++; + } + + totalTrackCount++; + }); + + return { + trackCount, + trackFileCount, + totalTrackCount + }; +} + +function getTrackCountKind(trackFileCount, trackCount) { + if (trackFileCount === trackCount && trackCount > 0) { + return kinds.SUCCESS; + } + + return kinds.DANGER; +} + +class AlbumDetailsMedium extends Component { + + // + // Lifecycle + + componentDidMount() { + this._expandByDefault(); + } + + componentDidUpdate(prevProps) { + if (prevProps.albumId !== this.props.albumId) { + this._expandByDefault(); + } + } + + // + // Control + + _expandByDefault() { + const { + mediumNumber, + onExpandPress, + items + } = this.props; + + const expand = _.some(items, (item) => { + return isAfter(item.airDateUtc) || + isAfter(item.airDateUtc, { days: -30 }); + }); + + onExpandPress(mediumNumber, expand && mediumNumber > 0); + } + + // + // Listeners + + onExpandPress = () => { + const { + mediumNumber, + isExpanded + } = this.props; + + this.props.onExpandPress(mediumNumber, !isExpanded); + } + + // + // Render + + render() { + const { + mediumNumber, + mediumFormat, + items, + columns, + onTableOptionChange, + isExpanded, + isSmallScreen + } = this.props; + + const { + trackCount, + trackFileCount, + totalTrackCount + } = getMediumStatistics(items); + + return ( +
+
+
+ { +
+ + {mediumFormat} {mediumNumber} + +
+ } + + +
+ + + + { + !isSmallScreen && +   + } + + +
+ +
+ { + isExpanded && +
+ { + items.length ? + + + { + items.map((item) => { + return ( + + ); + }) + } + +
: + +
+ No tracks in this medium +
+ } +
+ +
+
+ } +
+
+ ); + } +} + +AlbumDetailsMedium.propTypes = { + albumId: PropTypes.number.isRequired, + mediumNumber: PropTypes.number.isRequired, + mediumFormat: PropTypes.string.isRequired, + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + isSaving: PropTypes.bool, + isExpanded: PropTypes.bool, + isSmallScreen: PropTypes.bool.isRequired, + onTableOptionChange: PropTypes.func.isRequired, + onExpandPress: PropTypes.func.isRequired +}; + +export default AlbumDetailsMedium; diff --git a/frontend/src/Album/Details/AlbumDetailsMediumConnector.js b/frontend/src/Album/Details/AlbumDetailsMediumConnector.js new file mode 100644 index 000000000..32010a224 --- /dev/null +++ b/frontend/src/Album/Details/AlbumDetailsMediumConnector.js @@ -0,0 +1,64 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import { setTracksTableOption } from 'Store/Actions/trackActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import AlbumDetailsMedium from './AlbumDetailsMedium'; + +function createMapStateToProps() { + return createSelector( + (state, { mediumNumber }) => mediumNumber, + (state) => state.tracks, + createDimensionsSelector(), + (mediumNumber, tracks, dimensions) => { + + const tracksInMedium = _.filter(tracks.items, { mediumNumber }); + const sortedTracks = _.orderBy(tracksInMedium, ['absoluteTrackNumber'], ['asc']); + + return { + items: sortedTracks, + columns: tracks.columns, + isSmallScreen: dimensions.isSmallScreen + }; + } + ); +} + +const mapDispatchToProps = { + setTracksTableOption, + executeCommand +}; + +class AlbumDetailsMediumConnector extends Component { + + // + // Listeners + + onTableOptionChange = (payload) => { + this.props.setTracksTableOption(payload); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AlbumDetailsMediumConnector.propTypes = { + albumId: PropTypes.number.isRequired, + mediumNumber: PropTypes.number.isRequired, + setTracksTableOption: PropTypes.func.isRequired, + executeCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AlbumDetailsMediumConnector); diff --git a/frontend/src/Album/Details/AlbumDetailsPageConnector.js b/frontend/src/Album/Details/AlbumDetailsPageConnector.js new file mode 100644 index 000000000..158906939 --- /dev/null +++ b/frontend/src/Album/Details/AlbumDetailsPageConnector.js @@ -0,0 +1,110 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { push } from 'react-router-redux'; +import NotFound from 'Components/NotFound'; +import { fetchAlbums, clearAlbums } from 'Store/Actions/albumActions'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import AlbumDetailsConnector from './AlbumDetailsConnector'; + +function createMapStateToProps() { + return createSelector( + (state, { match }) => match, + (state) => state.albums, + (match, albums) => { + const foreignAlbumId = match.params.foreignAlbumId; + const isAlbumsFetching = albums.isFetching; + const isAlbumsPopulated = albums.isPopulated; + + return { + foreignAlbumId, + isAlbumsFetching, + isAlbumsPopulated + }; + } + ); +} + +const mapDispatchToProps = { + push, + fetchAlbums, + clearAlbums +}; + +class AlbumDetailsPageConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.populate(); + } + + componentWillUnmount() { + this.unpopulate(); + } + + // + // Control + + populate = () => { + const foreignAlbumId = this.props.foreignAlbumId; + this.props.fetchAlbums({ foreignAlbumId }); + } + + unpopulate = () => { + this.props.clearAlbums(); + } + + // + // Render + + render() { + const { + foreignAlbumId, + isAlbumsFetching, + isAlbumsPopulated + } = this.props; + + if (!foreignAlbumId) { + return ( + + ); + } + + if (isAlbumsFetching) { + return ( + + ); + } + + if (!isAlbumsFetching && !isAlbumsPopulated) { + return ( + + ); + } + + if (!isAlbumsFetching && isAlbumsPopulated) { + return ( + + ); + } + } +} + +AlbumDetailsPageConnector.propTypes = { + foreignAlbumId: PropTypes.string, + match: PropTypes.shape({ params: PropTypes.shape({ foreignAlbumId: PropTypes.string.isRequired }).isRequired }).isRequired, + push: PropTypes.func.isRequired, + fetchAlbums: PropTypes.func.isRequired, + clearAlbums: PropTypes.func.isRequired, + isAlbumsFetching: PropTypes.bool.isRequired, + isAlbumsPopulated: PropTypes.bool.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AlbumDetailsPageConnector); diff --git a/frontend/src/Album/Details/TrackRow.css b/frontend/src/Album/Details/TrackRow.css new file mode 100644 index 000000000..8497f92e1 --- /dev/null +++ b/frontend/src/Album/Details/TrackRow.css @@ -0,0 +1,31 @@ +.title { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + white-space: nowrap; +} + +.monitored { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 42px; +} + +.trackNumber { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 50px; +} + +.audio { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 200px; +} + +.language, +.duration, +.status { + composes: cell from 'Components/Table/Cells/TableRowCell.css'; + + width: 100px; +} diff --git a/frontend/src/Album/Details/TrackRow.js b/frontend/src/Album/Details/TrackRow.js new file mode 100644 index 000000000..d7cda1fb4 --- /dev/null +++ b/frontend/src/Album/Details/TrackRow.js @@ -0,0 +1,194 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import TableRow from 'Components/Table/TableRow'; +import TableRowCell from 'Components/Table/Cells/TableRowCell'; +import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; +import EpisodeStatusConnector from 'Album/EpisodeStatusConnector'; +import TrackFileLanguageConnector from 'TrackFile/TrackFileLanguageConnector'; +import MediaInfoConnector from 'TrackFile/MediaInfoConnector'; +import * as mediaInfoTypes from 'TrackFile/mediaInfoTypes'; + +import styles from './TrackRow.css'; + +class TrackRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isDetailsModalOpen: false + }; + } + + // + // Listeners + + onDetailsModalClose = () => { + this.setState({ isDetailsModalOpen: false }); + } + + // + // Render + + render() { + const { + id, + mediumNumber, + trackFileId, + absoluteTrackNumber, + title, + duration, + trackFilePath, + trackFileRelativePath, + columns + } = this.props; + + return ( + + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'medium') { + return ( + + {mediumNumber} + + ); + } + + if (name === 'absoluteTrackNumber') { + return ( + + {absoluteTrackNumber} + + ); + } + + if (name === 'title') { + return ( + + {title} + + ); + } + + if (name === 'path') { + return ( + + { + trackFilePath + } + + ); + } + + if (name === 'relativePath') { + return ( + + { + trackFileRelativePath + } + + ); + } + + if (name === 'duration') { + return ( + + { + formatTimeSpan(duration) + } + + ); + } + + if (name === 'language') { + return ( + + + + ); + } + + if (name === 'audioInfo') { + return ( + + + + ); + } + + if (name === 'status') { + return ( + + + + ); + } + + return null; + }) + } + + ); + } +} + +TrackRow.propTypes = { + id: PropTypes.number.isRequired, + albumId: PropTypes.number.isRequired, + trackFileId: PropTypes.number, + monitored: PropTypes.bool.isRequired, + mediumNumber: PropTypes.number.isRequired, + trackNumber: PropTypes.string.isRequired, + absoluteTrackNumber: PropTypes.number, + title: PropTypes.string.isRequired, + duration: PropTypes.number.isRequired, + isSaving: PropTypes.bool, + trackFilePath: PropTypes.string, + trackFileRelativePath: PropTypes.string, + mediaInfo: PropTypes.object, + columns: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default TrackRow; diff --git a/frontend/src/Album/Details/TrackRowConnector.js b/frontend/src/Album/Details/TrackRowConnector.js new file mode 100644 index 000000000..933fa32f5 --- /dev/null +++ b/frontend/src/Album/Details/TrackRowConnector.js @@ -0,0 +1,22 @@ +/* eslint max-params: 0 */ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createTrackFileSelector from 'Store/Selectors/createTrackFileSelector'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import TrackRow from './TrackRow'; + +function createMapStateToProps() { + return createSelector( + (state, { id }) => id, + (state, { mediumNumber }) => mediumNumber, + createTrackFileSelector(), + createCommandsSelector(), + (id, mediumNumber, trackFile, commands) => { + return { + trackFilePath: trackFile ? trackFile.path : null, + trackFileRelativePath: trackFile ? trackFile.relativePath : null + }; + } + ); +} +export default connect(createMapStateToProps)(TrackRow); diff --git a/frontend/src/Album/Edit/EditAlbumModal.js b/frontend/src/Album/Edit/EditAlbumModal.js new file mode 100644 index 000000000..d47bb284f --- /dev/null +++ b/frontend/src/Album/Edit/EditAlbumModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import EditAlbumModalContentConnector from './EditAlbumModalContentConnector'; + +function EditAlbumModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +EditAlbumModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditAlbumModal; diff --git a/frontend/src/Album/Edit/EditAlbumModalConnector.js b/frontend/src/Album/Edit/EditAlbumModalConnector.js new file mode 100644 index 000000000..7c2383f0f --- /dev/null +++ b/frontend/src/Album/Edit/EditAlbumModalConnector.js @@ -0,0 +1,39 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { clearPendingChanges } from 'Store/Actions/baseActions'; +import EditAlbumModal from './EditAlbumModal'; + +const mapDispatchToProps = { + clearPendingChanges +}; + +class EditAlbumModalConnector extends Component { + + // + // Listeners + + onModalClose = () => { + this.props.clearPendingChanges({ section: 'albums' }); + this.props.onModalClose(); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditAlbumModalConnector.propTypes = { + onModalClose: PropTypes.func.isRequired, + clearPendingChanges: PropTypes.func.isRequired +}; + +export default connect(undefined, mapDispatchToProps)(EditAlbumModalConnector); diff --git a/frontend/src/Album/Edit/EditAlbumModalContent.js b/frontend/src/Album/Edit/EditAlbumModalContent.js new file mode 100644 index 000000000..017f3158a --- /dev/null +++ b/frontend/src/Album/Edit/EditAlbumModalContent.js @@ -0,0 +1,119 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { inputTypes } from 'Helpers/Props'; +import Button from 'Components/Link/Button'; +import SpinnerButton from 'Components/Link/SpinnerButton'; +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 Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import FormInputGroup from 'Components/Form/FormInputGroup'; + +class EditAlbumModalContent extends Component { + + // + // Listeners + + onSavePress = () => { + const { + onSavePress + } = this.props; + + onSavePress(false); + + } + + // + // Render + + render() { + const { + title, + artistName, + albumType, + item, + isSaving, + onInputChange, + onModalClose, + ...otherProps + } = this.props; + + const { + monitored, + currentRelease, + releases + } = item; + + return ( + + + Edit - {artistName} - {title} [{albumType}] + + + +
+ + Monitored + + + + + + Release + + + + +
+
+ + + + + Save + + + +
+ ); + } +} + +EditAlbumModalContent.propTypes = { + albumId: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + artistName: PropTypes.string.isRequired, + albumType: PropTypes.string.isRequired, + item: PropTypes.object.isRequired, + isSaving: PropTypes.bool.isRequired, + onInputChange: PropTypes.func.isRequired, + onSavePress: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default EditAlbumModalContent; diff --git a/frontend/src/Album/Edit/EditAlbumModalContentConnector.js b/frontend/src/Album/Edit/EditAlbumModalContentConnector.js new file mode 100644 index 000000000..867e9c0c0 --- /dev/null +++ b/frontend/src/Album/Edit/EditAlbumModalContentConnector.js @@ -0,0 +1,97 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import selectSettings from 'Store/Selectors/selectSettings'; +import createAlbumSelector from 'Store/Selectors/createAlbumSelector'; +import createArtistSelector from 'Store/Selectors/createArtistSelector'; +import { setAlbumValue, saveAlbum } from 'Store/Actions/albumActions'; +import EditAlbumModalContent from './EditAlbumModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.albums, + createAlbumSelector(), + createArtistSelector(), + (albumState, album, artist) => { + const { + isSaving, + saveError, + pendingChanges + } = albumState; + + const albumSettings = _.pick(album, [ + 'monitored', + 'currentRelease', + 'releases' + ]); + + const settings = selectSettings(albumSettings, pendingChanges, saveError); + + return { + title: album.title, + artistName: artist.artistName, + albumType: album.albumType, + isSaving, + saveError, + item: settings.settings, + ...settings + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetAlbumValue: setAlbumValue, + dispatchSaveAlbum: saveAlbum +}; + +class EditAlbumModalContentConnector extends Component { + + // + // Lifecycle + + componentDidUpdate(prevProps, prevState) { + if (prevProps.isSaving && !this.props.isSaving && !this.props.saveError) { + this.props.onModalClose(); + } + } + + // + // Listeners + + onInputChange = ({ name, value }) => { + this.props.dispatchSetAlbumValue({ name, value }); + } + + onSavePress = () => { + this.props.dispatchSaveAlbum({ + id: this.props.albumId + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +EditAlbumModalContentConnector.propTypes = { + albumId: PropTypes.number, + isSaving: PropTypes.bool.isRequired, + saveError: PropTypes.object, + dispatchSetAlbumValue: PropTypes.func.isRequired, + dispatchSaveAlbum: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(EditAlbumModalContentConnector); diff --git a/frontend/src/AlbumStudio/AlbumStudioRow.js b/frontend/src/AlbumStudio/AlbumStudioRow.js index d63dc83ee..501d1c74e 100644 --- a/frontend/src/AlbumStudio/AlbumStudioRow.js +++ b/frontend/src/AlbumStudio/AlbumStudioRow.js @@ -20,6 +20,7 @@ class AlbumStudioRow extends Component { artistId, status, nameSlug, + foreignArtistId, artistName, monitored, albums, @@ -49,7 +50,7 @@ class AlbumStudioRow extends Component { @@ -84,6 +85,7 @@ AlbumStudioRow.propTypes = { artistId: PropTypes.number.isRequired, status: PropTypes.string.isRequired, nameSlug: PropTypes.string.isRequired, + foreignArtistId: PropTypes.string.isRequired, artistName: PropTypes.string.isRequired, monitored: PropTypes.bool.isRequired, albums: PropTypes.arrayOf(PropTypes.object).isRequired, diff --git a/frontend/src/AlbumStudio/AlbumStudioRowConnector.js b/frontend/src/AlbumStudio/AlbumStudioRowConnector.js index 392e9602c..6172d2c71 100644 --- a/frontend/src/AlbumStudio/AlbumStudioRowConnector.js +++ b/frontend/src/AlbumStudio/AlbumStudioRowConnector.js @@ -5,7 +5,7 @@ import { connect } from 'react-redux'; import { createSelector } from 'reselect'; import createArtistSelector from 'Store/Selectors/createArtistSelector'; import { toggleArtistMonitored } from 'Store/Actions/artistActions'; -import { toggleAlbumMonitored } from 'Store/Actions/albumActions'; +import { toggleAlbumsMonitored } from 'Store/Actions/albumActions'; import AlbumStudioRow from './AlbumStudioRow'; function createMapStateToProps() { @@ -32,7 +32,7 @@ function createMapStateToProps() { const mapDispatchToProps = { toggleArtistMonitored, - toggleAlbumMonitored + toggleAlbumsMonitored }; class AlbumStudioRowConnector extends Component { @@ -53,8 +53,9 @@ class AlbumStudioRowConnector extends Component { } onAlbumMonitoredPress = (albumId, monitored) => { - this.props.toggleAlbumMonitored({ - albumId, + const albumIds = [albumId]; + this.props.toggleAlbumsMonitored({ + albumIds, monitored }); } @@ -77,7 +78,7 @@ AlbumStudioRowConnector.propTypes = { artistId: PropTypes.number.isRequired, monitored: PropTypes.bool.isRequired, toggleArtistMonitored: PropTypes.func.isRequired, - toggleAlbumMonitored: PropTypes.func.isRequired + toggleAlbumsMonitored: PropTypes.func.isRequired }; export default connect(createMapStateToProps, mapDispatchToProps)(AlbumStudioRowConnector); diff --git a/frontend/src/App/App.js b/frontend/src/App/App.js index 5a8d6a95c..d7aec6f7a 100644 --- a/frontend/src/App/App.js +++ b/frontend/src/App/App.js @@ -14,6 +14,7 @@ import ImportArtist from 'AddArtist/ImportArtist/ImportArtist'; import ArtistEditorConnector from 'Artist/Editor/ArtistEditorConnector'; import AlbumStudioConnector from 'AlbumStudio/AlbumStudioConnector'; import ArtistDetailsPageConnector from 'Artist/Details/ArtistDetailsPageConnector'; +import AlbumDetailsPageConnector from 'Album/Details/AlbumDetailsPageConnector'; import CalendarPageConnector from 'Calendar/CalendarPageConnector'; import HistoryConnector from 'Activity/History/HistoryConnector'; import QueueConnector from 'Activity/Queue/QueueConnector'; @@ -92,10 +93,15 @@ function App({ store, history }) { /> + + {/* Calendar */} diff --git a/frontend/src/Artist/ArtistNameLink.js b/frontend/src/Artist/ArtistNameLink.js index aafc97912..fab1cb974 100644 --- a/frontend/src/Artist/ArtistNameLink.js +++ b/frontend/src/Artist/ArtistNameLink.js @@ -2,8 +2,8 @@ import PropTypes from 'prop-types'; import React from 'react'; import Link from 'Components/Link/Link'; -function ArtistNameLink({ nameSlug, artistName }) { - const link = `/artist/${nameSlug}`; +function ArtistNameLink({ foreignArtistId, artistName }) { + const link = `/artist/${foreignArtistId}`; return ( @@ -13,7 +13,7 @@ function ArtistNameLink({ nameSlug, artistName }) { } ArtistNameLink.propTypes = { - nameSlug: PropTypes.string.isRequired, + foreignArtistId: PropTypes.string.isRequired, artistName: PropTypes.string.isRequired }; diff --git a/frontend/src/Artist/Details/AlbumRow.js b/frontend/src/Artist/Details/AlbumRow.js index 973efcbc0..c781b179a 100644 --- a/frontend/src/Artist/Details/AlbumRow.js +++ b/frontend/src/Artist/Details/AlbumRow.js @@ -8,8 +8,7 @@ import Label from 'Components/Label'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import formatTimeSpan from 'Utilities/Date/formatTimeSpan'; import AlbumSearchCellConnector from 'Album/AlbumSearchCellConnector'; -import AlbumTitleLink from 'Album/AlbumTitleLink'; - +import AlbumTitleDetailLink from 'Album/AlbumTitleDetailLink'; import styles from './AlbumRow.css'; function getTrackCountKind(monitored, trackFileCount, trackCount) { @@ -33,7 +32,8 @@ class AlbumRow extends Component { super(props, context); this.state = { - isDetailsModalOpen: false + isDetailsModalOpen: false, + isEditAlbumModalOpen: false }; } @@ -48,6 +48,14 @@ class AlbumRow extends Component { this.setState({ isDetailsModalOpen: false }); } + onEditAlbumPress = () => { + this.setState({ isEditAlbumModalOpen: true }); + } + + onEditAlbumModalClose = () => { + this.setState({ isEditAlbumModalOpen: false }); + } + onMonitorAlbumPress = (monitored, options) => { this.props.onMonitorAlbumPress(this.props.id, monitored, options); } @@ -64,9 +72,11 @@ class AlbumRow extends Component { duration, releaseDate, mediumCount, + secondaryTypes, title, isSaving, artistMonitored, + foreignAlbumId, columns } = this.props; @@ -111,11 +121,9 @@ class AlbumRow extends Component { key={name} className={styles.title} > - ); @@ -131,6 +139,16 @@ class AlbumRow extends Component { ); } + if (name === 'secondaryTypes') { + return ( + + { + secondaryTypes + } + + ); + } + if (name === 'trackCount') { return ( @@ -189,7 +207,6 @@ class AlbumRow extends Component { /> ); } - return null; }) } @@ -206,6 +223,8 @@ AlbumRow.propTypes = { mediumCount: PropTypes.number.isRequired, duration: PropTypes.number.isRequired, title: PropTypes.string.isRequired, + secondaryTypes: PropTypes.arrayOf(PropTypes.string).isRequired, + foreignAlbumId: PropTypes.string.isRequired, isSaving: PropTypes.bool, unverifiedSceneNumbering: PropTypes.bool, artistMonitored: PropTypes.bool.isRequired, diff --git a/frontend/src/Artist/Details/AlbumRowConnector.js b/frontend/src/Artist/Details/AlbumRowConnector.js index c2260e773..05ae5042c 100644 --- a/frontend/src/Artist/Details/AlbumRowConnector.js +++ b/frontend/src/Artist/Details/AlbumRowConnector.js @@ -15,6 +15,7 @@ function createMapStateToProps() { createCommandsSelector(), (id, sceneSeasonNumber, artist, trackFile, commands) => { return { + foreignArtistId: artist.foreignArtistId, artistMonitored: artist.monitored, trackFilePath: trackFile ? trackFile.path : null, trackFileRelativePath: trackFile ? trackFile.relativePath : null diff --git a/frontend/src/Artist/Details/ArtistDetails.js b/frontend/src/Artist/Details/ArtistDetails.js index d9c877e2f..5793cbce2 100644 --- a/frontend/src/Artist/Details/ArtistDetails.js +++ b/frontend/src/Artist/Details/ArtistDetails.js @@ -34,34 +34,6 @@ import ArtistDetailsLinks from './ArtistDetailsLinks'; import styles from './ArtistDetails.css'; import InteractiveImportModal from '../../InteractiveImport/InteractiveImportModal'; -const albumTypes = [ - { - name: 'album', - label: 'Album', - isVisible: true - }, - { - name: 'ep', - label: 'EP', - isVisible: true - }, - { - name: 'single', - label: 'Single', - isVisible: true - }, - { - name: 'broadcast', - label: 'Broadcast', - isVisible: true - }, - { - name: 'other', - label: 'Other', - isVisible: true - } -]; - const defaultFontSize = parseInt(fonts.defaultFontSize); const lineHeight = parseFloat(fonts.lineHeight); @@ -193,6 +165,7 @@ class ArtistDetails extends Component { trackFileCount, qualityProfileId, monitored, + albumTypes, status, overview, links, @@ -359,7 +332,7 @@ class ArtistDetails extends Component { name={icons.ARROW_LEFT} size={30} title={`Go to ${previousArtist.artistName}`} - to={`/artist/${previousArtist.nameSlug}`} + to={`/artist/${previousArtist.foreignArtistId}`} />
@@ -545,12 +518,12 @@ class ArtistDetails extends Component { albumTypes.slice(0).map((albumType) => { return ( ); @@ -614,6 +587,7 @@ ArtistDetails.propTypes = { trackFileCount: PropTypes.number, qualityProfileId: PropTypes.number.isRequired, monitored: PropTypes.bool.isRequired, + albumTypes: PropTypes.arrayOf(PropTypes.string), status: PropTypes.string.isRequired, overview: PropTypes.string.isRequired, links: PropTypes.arrayOf(PropTypes.object).isRequired, diff --git a/frontend/src/Artist/Details/ArtistDetailsConnector.js b/frontend/src/Artist/Details/ArtistDetailsConnector.js index a402906bd..9c0b847d3 100644 --- a/frontend/src/Artist/Details/ArtistDetailsConnector.js +++ b/frontend/src/Artist/Details/ArtistDetailsConnector.js @@ -17,20 +17,30 @@ import ArtistDetails from './ArtistDetails'; function createMapStateToProps() { return createSelector( - (state, { nameSlug }) => nameSlug, + (state, { foreignArtistId }) => foreignArtistId, (state) => state.albums, (state) => state.trackFiles, + (state) => state.settings.metadataProfiles, createAllArtistSelector(), createCommandsSelector(), - (nameSlug, albums, trackFiles, allArtists, commands) => { + (foreignArtistId, albums, trackFiles, metadataProfiles, allArtists, commands) => { const sortedArtist = _.orderBy(allArtists, 'sortName'); - const artistIndex = _.findIndex(sortedArtist, { nameSlug }); + const artistIndex = _.findIndex(sortedArtist, { foreignArtistId }); const artist = sortedArtist[artistIndex]; + const metadataProfile = _.find(metadataProfiles.items, { id: artist.metadataProfileId }); + const albumTypes = _.reduce(metadataProfile.primaryAlbumTypes, (acc, primaryType) => { + if (primaryType.allowed) { + acc.push(primaryType.albumType.name); + } + return acc; + }, []); if (!artist) { return {}; } + const sortedAlbumTypes = _.orderBy(albumTypes); + const previousArtist = sortedArtist[artistIndex - 1] || _.last(sortedArtist); const nextArtist = sortedArtist[artistIndex + 1] || _.first(sortedArtist); const isArtistRefreshing = !!findCommand(commands, { name: commandNames.REFRESH_ARTIST, artistId: artist.id }); @@ -56,6 +66,7 @@ function createMapStateToProps() { return { ...artist, + albumTypes: sortedAlbumTypes, alternateTitles, isArtistRefreshing, allArtistRefreshing, @@ -176,7 +187,7 @@ class ArtistDetailsConnector extends Component { ArtistDetailsConnector.propTypes = { id: PropTypes.number.isRequired, - nameSlug: PropTypes.string.isRequired, + foreignArtistId: PropTypes.string.isRequired, isArtistRefreshing: PropTypes.bool.isRequired, allArtistRefreshing: PropTypes.bool.isRequired, isRefreshing: PropTypes.bool.isRequired, diff --git a/frontend/src/Artist/Details/ArtistDetailsPageConnector.js b/frontend/src/Artist/Details/ArtistDetailsPageConnector.js index 3219267bf..2bc4b4227 100644 --- a/frontend/src/Artist/Details/ArtistDetailsPageConnector.js +++ b/frontend/src/Artist/Details/ArtistDetailsPageConnector.js @@ -13,12 +13,12 @@ function createMapStateToProps() { (state, { match }) => match, createAllArtistSelector(), (match, allArtists) => { - const nameSlug = match.params.nameSlug; - const artistIndex = _.findIndex(allArtists, { nameSlug }); + const foreignArtistId = match.params.foreignArtistId; + const artistIndex = _.findIndex(allArtists, { foreignArtistId }); if (artistIndex > -1) { return { - nameSlug + foreignArtistId }; } @@ -37,7 +37,7 @@ class ArtistDetailsPageConnector extends Component { // Lifecycle componentDidUpdate(prevProps) { - if (!this.props.nameSlug) { + if (!this.props.foreignArtistId) { this.props.push(`${window.Sonarr.urlBase}/`); return; } @@ -48,10 +48,10 @@ class ArtistDetailsPageConnector extends Component { render() { const { - nameSlug + foreignArtistId } = this.props; - if (!nameSlug) { + if (!foreignArtistId) { return ( ); } } ArtistDetailsPageConnector.propTypes = { - nameSlug: PropTypes.string, - match: PropTypes.shape({ params: PropTypes.shape({ nameSlug: PropTypes.string.isRequired }).isRequired }).isRequired, + foreignArtistId: PropTypes.string, + match: PropTypes.shape({ params: PropTypes.shape({ foreignArtistId: PropTypes.string.isRequired }).isRequired }).isRequired, push: PropTypes.func.isRequired }; diff --git a/frontend/src/Artist/Details/ArtistDetailsSeason.css b/frontend/src/Artist/Details/ArtistDetailsSeason.css index 508701566..c2711969f 100644 --- a/frontend/src/Artist/Details/ArtistDetailsSeason.css +++ b/frontend/src/Artist/Details/ArtistDetailsSeason.css @@ -106,7 +106,7 @@ } @media only screen and (max-width: $breakpointSmall) { - .season { + .albumType { border-right: 0; border-left: 0; border-radius: 0; diff --git a/frontend/src/Artist/Editor/ArtistEditorRow.js b/frontend/src/Artist/Editor/ArtistEditorRow.js index 278802e40..2126be3e8 100644 --- a/frontend/src/Artist/Editor/ArtistEditorRow.js +++ b/frontend/src/Artist/Editor/ArtistEditorRow.js @@ -27,7 +27,7 @@ class ArtistEditorRow extends Component { const { id, status, - nameSlug, + foreignArtistId, artistName, monitored, languageProfile, @@ -56,7 +56,7 @@ class ArtistEditorRow extends Component { @@ -105,7 +105,7 @@ class ArtistEditorRow extends Component { ArtistEditorRow.propTypes = { id: PropTypes.number.isRequired, status: PropTypes.string.isRequired, - nameSlug: PropTypes.string.isRequired, + foreignArtistId: PropTypes.string.isRequired, artistName: PropTypes.string.isRequired, monitored: PropTypes.bool.isRequired, languageProfile: PropTypes.object.isRequired, diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.js b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.js index a10f97bc3..6fb4b72cf 100644 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.js +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.js @@ -59,7 +59,7 @@ class ArtistIndexBanner extends Component { artistName, monitored, status, - nameSlug, + foreignArtistId, nextAiring, trackCount, trackFileCount, @@ -84,7 +84,7 @@ class ArtistIndexBanner extends Component { isDeleteArtistModalOpen } = this.state; - const link = `/artist/${nameSlug}`; + const link = `/artist/${foreignArtistId}`; const elementStyle = { width: `${bannerWidth}px`, @@ -213,7 +213,7 @@ ArtistIndexBanner.propTypes = { artistName: PropTypes.string.isRequired, monitored: PropTypes.bool.isRequired, status: PropTypes.string.isRequired, - nameSlug: PropTypes.string.isRequired, + foreignArtistId: PropTypes.string.isRequired, nextAiring: PropTypes.string, trackCount: PropTypes.number, trackFileCount: PropTypes.number, diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js index 12b9fc04a..78ec3d360 100644 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js @@ -77,7 +77,7 @@ class ArtistIndexOverview extends Component { overview, monitored, status, - nameSlug, + foreignArtistId, nextAiring, trackCount, trackFileCount, @@ -101,7 +101,7 @@ class ArtistIndexOverview extends Component { isDeleteArtistModalOpen } = this.state; - const link = `/artist/${nameSlug}`; + const link = `/artist/${foreignArtistId}`; const elementStyle = { width: `${posterWidth}px`, @@ -226,7 +226,7 @@ ArtistIndexOverview.propTypes = { overview: PropTypes.string.isRequired, monitored: PropTypes.bool.isRequired, status: PropTypes.string.isRequired, - nameSlug: PropTypes.string.isRequired, + foreignArtistId: PropTypes.string.isRequired, nextAiring: PropTypes.string, trackCount: PropTypes.number, trackFileCount: PropTypes.number, diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js index d24316a75..a5b30a553 100644 --- a/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js +++ b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js @@ -58,8 +58,8 @@ class ArtistIndexPoster extends Component { id, artistName, monitored, + foreignArtistId, status, - nameSlug, nextAiring, trackCount, trackFileCount, @@ -84,7 +84,7 @@ class ArtistIndexPoster extends Component { isDeleteArtistModalOpen } = this.state; - const link = `/artist/${nameSlug}`; + const link = `/artist/${foreignArtistId}`; const elementStyle = { width: `${posterWidth}px`, @@ -213,7 +213,7 @@ ArtistIndexPoster.propTypes = { artistName: PropTypes.string.isRequired, monitored: PropTypes.bool.isRequired, status: PropTypes.string.isRequired, - nameSlug: PropTypes.string.isRequired, + foreignArtistId: PropTypes.string.isRequired, nextAiring: PropTypes.string, trackCount: PropTypes.number, trackFileCount: PropTypes.number, diff --git a/frontend/src/Artist/Index/Table/ArtistIndexHeader.css b/frontend/src/Artist/Index/Table/ArtistIndexHeader.css index f0c86d067..a42ee7a03 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexHeader.css +++ b/frontend/src/Artist/Index/Table/ArtistIndexHeader.css @@ -18,8 +18,8 @@ flex: 1 0 125px; } -.nextAiring, -.previousAiring, +.nextAlbum, +.lastAlbum, .added { composes: headerCell from 'Components/Table/VirtualTableHeaderCell.css'; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexRow.css b/frontend/src/Artist/Index/Table/ArtistIndexRow.css index 3bae2cfcf..0001750a8 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexRow.css +++ b/frontend/src/Artist/Index/Table/ArtistIndexRow.css @@ -18,8 +18,8 @@ flex: 1 0 125px; } -.nextAiring, -.previousAiring, +.nextAlbum, +.lastAlbum, .added { composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; @@ -32,8 +32,7 @@ flex: 0 0 100px; } -.trackProgress, -.latestAlbum { +.trackProgress { composes: cell from 'Components/Table/Cells/VirtualTableRowCell.css'; display: flex; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexRow.js b/frontend/src/Artist/Index/Table/ArtistIndexRow.js index 2471ddc0c..752db1180 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexRow.js +++ b/frontend/src/Artist/Index/Table/ArtistIndexRow.js @@ -12,6 +12,7 @@ import VirtualTableRow from 'Components/Table/VirtualTableRow'; import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; import ArtistNameLink from 'Artist/ArtistNameLink'; +import AlbumTitleDetailLink from 'Album/AlbumTitleDetailLink'; import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; import ArtistStatusCell from './ArtistStatusCell'; @@ -66,12 +67,13 @@ class ArtistIndexRow extends Component { status, artistName, nameSlug, + foreignArtistId, artistType, qualityProfile, languageProfile, metadataProfile, - nextAiring, - previousAiring, + nextAlbum, + lastAlbum, added, albumCount, trackCount, @@ -81,7 +83,6 @@ class ArtistIndexRow extends Component { path, sizeOnDisk, tags, - // useSceneNumbering, columns, isRefreshingArtist, onRefreshArtistPress @@ -124,7 +125,7 @@ class ArtistIndexRow extends Component { className={styles[name]} > @@ -175,25 +176,51 @@ class ArtistIndexRow extends Component { ); } - if (name === 'nextAiring') { + if (name === 'nextAlbum') { + if (nextAlbum) { + return ( + + + + ); + } return ( - + > + None + ); } - if (name === 'previousAiring') { + if (name === 'lastAlbum') { + if (lastAlbum) { + return ( + + + + ); + } return ( - + > + None + ); } @@ -239,27 +266,6 @@ class ArtistIndexRow extends Component { ); } - if (name === 'latestAlbum') { - const albumStatistics = latestAlbum.statistics; - const progress = albumStatistics.trackCount ? albumStatistics.trackFileCount / albumStatistics.trackCount * 100 : 100; - - return ( - - - - ); - } - if (name === 'trackCount') { return ( diff --git a/frontend/src/Components/Form/AlbumReleaseSelectInputConnector.js b/frontend/src/Components/Form/AlbumReleaseSelectInputConnector.js new file mode 100644 index 000000000..48f504dd9 --- /dev/null +++ b/frontend/src/Components/Form/AlbumReleaseSelectInputConnector.js @@ -0,0 +1,65 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import titleCase from 'Utilities/String/titleCase'; +import SelectInput from './SelectInput'; + +function createMapStateToProps() { + return createSelector( + (state, { albumReleases }) => albumReleases, + (state, { selectedRelease }) => selectedRelease, + (albumReleases, selectedRelease) => { + const values = _.map(albumReleases.value, (albumRelease) => { + + return { + key: albumRelease.id, + value: `${albumRelease.mediaCount} med, ${albumRelease.trackCount} tracks` + + `${albumRelease.country.length > 0 ? ', ' : ''}${albumRelease.country}` + + `${albumRelease.disambiguation ? ', ' : ''}${titleCase(albumRelease.disambiguation)}` + + `${albumRelease.format ? ', [' : ''}${albumRelease.format}${albumRelease.format ? ']' : ''}` + }; + }); + + const value = selectedRelease.value.id; + + return { + values, + value + }; + } + ); +} + +class AlbumReleaseSelectInputConnector extends Component { + + // + // Listeners + + onChange = ({ name, value }) => { + const { + albumReleases + } = this.props; + + this.props.onChange({ name, value: _.find(albumReleases.value, { id: value }) }); + } + + render() { + + return ( + + ); + } +} + +AlbumReleaseSelectInputConnector.propTypes = { + name: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + albumReleases: PropTypes.object +}; + +export default connect(createMapStateToProps)(AlbumReleaseSelectInputConnector); diff --git a/frontend/src/Components/Form/FormInputGroup.js b/frontend/src/Components/Form/FormInputGroup.js index 45a1cf97f..f1ed0db24 100644 --- a/frontend/src/Components/Form/FormInputGroup.js +++ b/frontend/src/Components/Form/FormInputGroup.js @@ -12,6 +12,7 @@ import PathInputConnector from './PathInputConnector'; import QualityProfileSelectInputConnector from './QualityProfileSelectInputConnector'; import LanguageProfileSelectInputConnector from './LanguageProfileSelectInputConnector'; import MetadataProfileSelectInputConnector from './MetadataProfileSelectInputConnector'; +import AlbumReleaseSelectInputConnector from './AlbumReleaseSelectInputConnector'; import RootFolderSelectInputConnector from './RootFolderSelectInputConnector'; import SeriesTypeSelectInput from './SeriesTypeSelectInput'; import SelectInput from './SelectInput'; @@ -53,6 +54,9 @@ function getComponent(type) { case inputTypes.METADATA_PROFILE_SELECT: return MetadataProfileSelectInputConnector; + case inputTypes.ALBUM_RELEASE_SELECT: + return AlbumReleaseSelectInputConnector; + case inputTypes.ROOT_FOLDER_SELECT: return RootFolderSelectInputConnector; diff --git a/frontend/src/Components/SignalRConnector.js b/frontend/src/Components/SignalRConnector.js index b8bf063d7..b1ce88c0e 100644 --- a/frontend/src/Components/SignalRConnector.js +++ b/frontend/src/Components/SignalRConnector.js @@ -218,12 +218,16 @@ class SignalRConnector extends Component { } handleTrack = (body) => { - if (body.action === 'updated') { + const action = body.action; + const section = 'tracks'; + + if (action === 'updated') { this.props.updateItem({ - section: 'tracks', - updateOnly: true, + section, ...body.resource }); + } else if (action === 'deleted') { + this.props.removeItem({ section, id: body.resource.id }); } } diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js index 2aca96375..48331d303 100644 --- a/frontend/src/Helpers/Props/icons.js +++ b/frontend/src/Helpers/Props/icons.js @@ -3,6 +3,7 @@ export const ACTIVITY = 'fa fa-clock-o'; export const ADD = 'fa fa-plus'; export const ALTERNATE_TITLES = 'fa fa-clone'; export const ADVANCED_SETTINGS = 'fa fa-cog'; +export const ARROW_UP = 'fa fa-arrow-circle-up'; export const ARROW_LEFT = 'fa fa-arrow-circle-left'; export const ARROW_RIGHT = 'fa fa-arrow-circle-right'; export const BACKUP = 'fa fa-file-archive-o'; diff --git a/frontend/src/Helpers/Props/inputTypes.js b/frontend/src/Helpers/Props/inputTypes.js index 492ce5417..e950ce459 100644 --- a/frontend/src/Helpers/Props/inputTypes.js +++ b/frontend/src/Helpers/Props/inputTypes.js @@ -8,6 +8,7 @@ export const PATH = 'path'; export const QUALITY_PROFILE_SELECT = 'qualityProfileSelect'; export const LANGUAGE_PROFILE_SELECT = 'languageProfileSelect'; export const METADATA_PROFILE_SELECT = 'metadataProfileSelect'; +export const ALBUM_RELEASE_SELECT = 'albumReleaseSelect'; export const ROOT_FOLDER_SELECT = 'rootFolderSelect'; export const SELECT = 'select'; export const SERIES_TYPE_SELECT = 'artistTypeSelect'; @@ -26,6 +27,7 @@ export const all = [ QUALITY_PROFILE_SELECT, LANGUAGE_PROFILE_SELECT, METADATA_PROFILE_SELECT, + ALBUM_RELEASE_SELECT, ROOT_FOLDER_SELECT, SELECT, SERIES_TYPE_SELECT, diff --git a/frontend/src/Store/Actions/albumActions.js b/frontend/src/Store/Actions/albumActions.js index 875932b4c..0dfeb87b2 100644 --- a/frontend/src/Store/Actions/albumActions.js +++ b/frontend/src/Store/Actions/albumActions.js @@ -5,7 +5,9 @@ import { batchActions } from 'redux-batched-actions'; import { sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createSetSettingValueReducer from './Creators/Reducers/createSetSettingValueReducer'; import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; +import createSaveProviderHandler from './Creators/createSaveProviderHandler'; import albumEntities from 'Album/albumEntities'; import createFetchHandler from './Creators/createFetchHandler'; import createHandleActions from './Creators/createHandleActions'; @@ -23,9 +25,12 @@ export const defaultState = { isFetching: false, isPopulated: false, error: null, + isSaving: false, + saveError: null, sortKey: 'releaseDate', sortDirection: sortDirections.DESCENDING, items: [], + pendingChanges: {}, columns: [ { @@ -44,6 +49,11 @@ export const defaultState = { label: 'Release Date', isVisible: true }, + { + name: 'secondaryTypes', + label: 'Secondary Types', + isVisible: false + }, { name: 'mediumCount', label: 'Media Count', @@ -84,6 +94,8 @@ export const FETCH_ALBUMS = 'albums/fetchAlbums'; export const SET_ALBUMS_SORT = 'albums/setAlbumsSort'; export const SET_ALBUMS_TABLE_OPTION = 'albums/setAlbumsTableOption'; export const CLEAR_ALBUMS = 'albums/clearAlbums'; +export const SET_ALBUM_VALUE = 'albums/setAlbumValue'; +export const SAVE_ALBUM = 'albums/saveAlbum'; export const TOGGLE_ALBUM_MONITORED = 'albums/toggleAlbumMonitored'; export const TOGGLE_ALBUMS_MONITORED = 'albums/toggleAlbumsMonitored'; @@ -97,11 +109,21 @@ export const clearAlbums = createAction(CLEAR_ALBUMS); export const toggleAlbumMonitored = createThunk(TOGGLE_ALBUM_MONITORED); export const toggleAlbumsMonitored = createThunk(TOGGLE_ALBUMS_MONITORED); +export const saveAlbum = createThunk(SAVE_ALBUM); + +export const setAlbumValue = createAction(SET_ALBUM_VALUE, (payload) => { + return { + section: 'albums', + ...payload + }; +}); + // // Action Handlers export const actionHandlers = handleThunks({ [FETCH_ALBUMS]: createFetchHandler(section, '/album'), + [SAVE_ALBUM]: createSaveProviderHandler(section, '/album'), [TOGGLE_ALBUM_MONITORED]: function(getState, payload, dispatch) { const { @@ -203,6 +225,8 @@ export const reducers = createHandleActions({ [SET_ALBUMS_TABLE_OPTION]: createSetTableOptionReducer(section), + [SET_ALBUM_VALUE]: createSetSettingValueReducer(section), + [CLEAR_ALBUMS]: (state) => { return Object.assign({}, state, { isFetching: false, diff --git a/frontend/src/Store/Actions/artistIndexActions.js b/frontend/src/Store/Actions/artistIndexActions.js index c8d978fae..429a0a587 100644 --- a/frontend/src/Store/Actions/artistIndexActions.js +++ b/frontend/src/Store/Actions/artistIndexActions.js @@ -93,14 +93,14 @@ export const defaultState = { isVisible: false }, { - name: 'nextAiring', - label: 'Next Airing', + name: 'nextAlbum', + label: 'Next Album', isSortable: true, isVisible: true }, { - name: 'previousAiring', - label: 'Previous Airing', + name: 'lastAlbum', + label: 'Last Album', isSortable: true, isVisible: false }, @@ -128,12 +128,6 @@ export const defaultState = { isSortable: true, isVisible: false }, - { - name: 'latestAlbum', - label: 'Latest Album', - isSortable: true, - isVisible: false - }, { name: 'path', label: 'Path', diff --git a/frontend/src/Store/Actions/trackActions.js b/frontend/src/Store/Actions/trackActions.js index a2b4fd570..fc5849a17 100644 --- a/frontend/src/Store/Actions/trackActions.js +++ b/frontend/src/Store/Actions/trackActions.js @@ -20,13 +20,15 @@ export const defaultState = { error: null, sortKey: 'mediumNumber', sortDirection: sortDirections.DESCENDING, + secondarySortKey: 'absoluteTrackNumber', + secondarySortDirection: sortDirections.ASCENDING, items: [], columns: [ { name: 'medium', label: 'Medium', - isVisible: true + isVisible: false }, { name: 'absoluteTrackNumber', @@ -38,11 +40,26 @@ export const defaultState = { label: 'Title', isVisible: true }, + { + name: 'path', + label: 'Path', + isVisible: false + }, + { + name: 'relativePath', + label: 'Relative Path', + isVisible: false + }, { name: 'duration', label: 'Duration', isVisible: true }, + { + name: 'language', + label: 'Language', + isVisible: false + }, { name: 'audioInfo', label: 'Audio Info', @@ -63,6 +80,8 @@ export const defaultState = { }; export const persistState = [ + 'tracks.sortKey', + 'tracks.sortDirection', 'tracks.columns' ]; diff --git a/frontend/src/TrackFile/Editor/TrackFileEditorModalContentConnector.js b/frontend/src/TrackFile/Editor/TrackFileEditorModalContentConnector.js index 0adf154e8..d8e1c6552 100644 --- a/frontend/src/TrackFile/Editor/TrackFileEditorModalContentConnector.js +++ b/frontend/src/TrackFile/Editor/TrackFileEditorModalContentConnector.js @@ -102,8 +102,9 @@ class TrackFileEditorModalContentConnector extends Component { componentDidMount() { const artistId = this.props.artistId; + const albumId = this.props.albumId; - this.props.dispatchFetchTracks({ artistId }); + this.props.dispatchFetchTracks({ artistId, albumId }); this.props.dispatchFetchLanguageProfileSchema(); this.props.dispatchFetchQualityProfileSchema(); diff --git a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js index e41711d18..ab148b5e3 100644 --- a/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js +++ b/frontend/src/Wanted/CutoffUnmet/CutoffUnmetRow.js @@ -48,7 +48,7 @@ function CutoffUnmetRow(props) { return ( diff --git a/frontend/src/Wanted/Missing/MissingRow.js b/frontend/src/Wanted/Missing/MissingRow.js index ff557c959..bc4598f19 100644 --- a/frontend/src/Wanted/Missing/MissingRow.js +++ b/frontend/src/Wanted/Missing/MissingRow.js @@ -47,7 +47,7 @@ function MissingRow(props) { return ( diff --git a/src/Lidarr.Api.V1/Albums/AlbumModule.cs b/src/Lidarr.Api.V1/Albums/AlbumModule.cs index c00c2ca41..aaf352e2d 100644 --- a/src/Lidarr.Api.V1/Albums/AlbumModule.cs +++ b/src/Lidarr.Api.V1/Albums/AlbumModule.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Linq; using Nancy; +using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Music; using NzbDrone.SignalR; @@ -21,7 +22,7 @@ namespace Lidarr.Api.V1.Albums : base(albumService, artistStatisticsService, artistService, upgradableSpecification, signalRBroadcaster) { GetResourceAll = GetAlbums; - Put[@"/(?[\d]{1,10})"] = x => SetAlbumMonitored(x.Id); + UpdateResource = UpdateAlbum; Put["/monitor"] = x => SetAlbumsMonitored(); } @@ -29,8 +30,9 @@ namespace Lidarr.Api.V1.Albums { var artistIdQuery = Request.Query.ArtistId; var albumIdsQuery = Request.Query.AlbumIds; + var foreignIdQuery = Request.Query.ForeignAlbumId; - if (!Request.Query.ArtistId.HasValue && !albumIdsQuery.HasValue) + if (!Request.Query.ArtistId.HasValue && !albumIdsQuery.HasValue && !foreignIdQuery.HasValue) { return MapToResource(_albumService.GetAllAlbums(), false); } @@ -42,6 +44,13 @@ namespace Lidarr.Api.V1.Albums return MapToResource(_albumService.GetAlbumsByArtist(artistId), false); } + if (foreignIdQuery.HasValue) + { + int artistId = _albumService.FindById(foreignIdQuery.Value).ArtistId; + + return MapToResource(_albumService.GetAlbumsByArtist(artistId), false); + } + string albumIdsValue = albumIdsQuery.Value.ToString(); var albumIds = albumIdsValue.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries) @@ -51,12 +60,15 @@ namespace Lidarr.Api.V1.Albums return MapToResource(_albumService.GetAlbums(albumIds), false); } - private Response SetAlbumMonitored(int id) + private void UpdateAlbum(AlbumResource albumResource) { - var resource = Request.Body.FromJson(); - _albumService.SetAlbumMonitored(id, resource.Monitored); + var album = _albumService.GetAlbum(albumResource.Id); + + var model = albumResource.ToModel(album); + + _albumService.UpdateAlbum(model); - return MapToResource(_albumService.GetAlbum(id), false).AsResponse(HttpStatusCode.Accepted); + BroadcastResourceChange(ModelAction.Updated, albumResource); } private Response SetAlbumsMonitored() diff --git a/src/Lidarr.Api.V1/Albums/AlbumModuleWithSignalR.cs b/src/Lidarr.Api.V1/Albums/AlbumModuleWithSignalR.cs index 830458024..10b500f93 100644 --- a/src/Lidarr.Api.V1/Albums/AlbumModuleWithSignalR.cs +++ b/src/Lidarr.Api.V1/Albums/AlbumModuleWithSignalR.cs @@ -2,6 +2,8 @@ using System; using System.Collections.Generic; using System.Linq; using FluentValidation; +using NzbDrone.Core.Datastore.Events; +using NzbDrone.Core.Messaging.Events; using NzbDrone.Common.Extensions; using Lidarr.Api.V1.Artist; using NzbDrone.Core.DecisionEngine; @@ -9,10 +11,16 @@ using NzbDrone.Core.Music; using NzbDrone.Core.ArtistStats; using NzbDrone.SignalR; using Lidarr.Http; +using NzbDrone.Core.Download; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Music.Events; namespace Lidarr.Api.V1.Albums { - public abstract class AlbumModuleWithSignalR : LidarrRestModuleWithSignalR + public abstract class AlbumModuleWithSignalR : LidarrRestModuleWithSignalR, + IHandle, + IHandle, + IHandle { protected readonly IAlbumService _albumService; protected readonly IArtistStatisticsService _artistStatisticsService; @@ -130,18 +138,26 @@ namespace Lidarr.Api.V1.Albums } } - //TODO: Implement Track or Album Grabbed/Dowloaded Events + public void Handle(AlbumGrabbedEvent message) + { + foreach (var album in message.Album.Albums) + { + var resource = album.ToResource(); + resource.Grabbed = true; - //public void Handle(TrackGrabbedEvent message) - //{ - // foreach (var track in message.Track.Tracks) - // { - // var resource = track.ToResource(); - // resource.Grabbed = true; + BroadcastResourceChange(ModelAction.Updated, resource); + } + } + + public void Handle(AlbumEditedEvent message) + { + BroadcastResourceChange(ModelAction.Updated, message.Album.Id); + } - // BroadcastResourceChange(ModelAction.Updated, resource); - // } - //} + public void Handle(TrackImportedEvent message) + { + BroadcastResourceChange(ModelAction.Updated, message.ImportedTrack.AlbumId); + } //public void Handle(TrackDownloadedEvent message) //{ diff --git a/src/Lidarr.Api.V1/Albums/AlbumReleaseResource.cs b/src/Lidarr.Api.V1/Albums/AlbumReleaseResource.cs new file mode 100644 index 000000000..a4f3db485 --- /dev/null +++ b/src/Lidarr.Api.V1/Albums/AlbumReleaseResource.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.Music; + +namespace Lidarr.Api.V1.Albums +{ + public class AlbumReleaseResource + { + public string Id { get; set; } + public DateTime? ReleaseDate { get; set; } + public int TrackCount { get; set; } + public int MediaCount { get; set; } + public string Disambiguation { get; set; } + public List Country { get; set; } + public List Label { get; set; } + public string Format { get; set; } + } + + public static class AlbumReleaseResourceMapper + { + public static AlbumReleaseResource ToResource(this AlbumRelease model) + { + if (model == null) + { + return null; + } + + return new AlbumReleaseResource + { + Id = model.Id, + ReleaseDate = model.ReleaseDate, + TrackCount = model.TrackCount, + MediaCount = model.MediaCount, + Disambiguation = model.Disambiguation, + Country = model.Country, + Label = model.Label, + Format = model.Format + }; + } + + public static AlbumRelease ToModel(this AlbumReleaseResource resource) + { + if (resource == null) + { + return null; + } + + return new AlbumRelease + { + Id = resource.Id, + ReleaseDate = resource.ReleaseDate, + TrackCount = resource.TrackCount, + MediaCount = resource.MediaCount, + Disambiguation = resource.Disambiguation, + Country = resource.Country, + Label = resource.Label, + Format = resource.Format + }; + } + + public static List ToResource(this IEnumerable models) + { + return models.Select(ToResource).ToList(); + } + + public static List ToModel(this IEnumerable resources) + { + return resources.Select(ToModel).ToList(); + } + } +} diff --git a/src/Lidarr.Api.V1/Albums/AlbumResource.cs b/src/Lidarr.Api.V1/Albums/AlbumResource.cs index 018e70997..a858dec1b 100644 --- a/src/Lidarr.Api.V1/Albums/AlbumResource.cs +++ b/src/Lidarr.Api.V1/Albums/AlbumResource.cs @@ -19,6 +19,7 @@ namespace Lidarr.Api.V1.Albums public int ProfileId { get; set; } public int Duration { get; set; } public string AlbumType { get; set; } + public List SecondaryTypes { get; set; } public int MediumCount { get @@ -33,6 +34,8 @@ namespace Lidarr.Api.V1.Albums } public Ratings Ratings { get; set; } public DateTime? ReleaseDate { get; set; } + public AlbumRelease CurrentRelease { get; set; } + public List Releases { get; set; } public List Genres { get; set; } public List Media { get; set; } public ArtistResource Artist { get; set; } @@ -65,15 +68,45 @@ namespace Lidarr.Api.V1.Albums Ratings = model.Ratings, Duration = model.Duration, AlbumType = model.AlbumType, + SecondaryTypes = model.SecondaryTypes.Select(s => s.Name).ToList(), Media = model.Media.ToResource(), + CurrentRelease = model.CurrentRelease, + Releases = model.Releases.ToResource(), }; } + public static Album ToModel(this AlbumResource resource) + { + if (resource == null) return null; + + return new Album + { + Id = resource.Id, + ForeignAlbumId = resource.ForeignAlbumId, + Title = resource.Title, + Images = resource.Images, + Monitored = resource.Monitored, + CurrentRelease = resource.CurrentRelease + }; + } + + public static Album ToModel(this AlbumResource resource, Album album) + { + var updatedAlbum = resource.ToModel(); + + album.ApplyChanges(updatedAlbum); + + return album; + } + public static List ToResource(this IEnumerable models) { - if (models == null) return null; + return models?.Select(ToResource).ToList(); + } - return models.Select(ToResource).ToList(); + public static List ToModel(this IEnumerable resources) + { + return resources.Select(ToModel).ToList(); } } } diff --git a/src/Lidarr.Api.V1/Albums/MediumResource.cs b/src/Lidarr.Api.V1/Albums/MediumResource.cs index 7627469d6..239cf02b1 100644 --- a/src/Lidarr.Api.V1/Albums/MediumResource.cs +++ b/src/Lidarr.Api.V1/Albums/MediumResource.cs @@ -11,7 +11,7 @@ namespace Lidarr.Api.V1.Albums public string MediumFormat { get; set; } } - public static class SeasonResourceMapper + public static class MediumResourceMapper { public static MediumResource ToResource(this Medium model) { diff --git a/src/Lidarr.Api.V1/Artist/ArtistModule.cs b/src/Lidarr.Api.V1/Artist/ArtistModule.cs index 596d08e05..8395a19c8 100644 --- a/src/Lidarr.Api.V1/Artist/ArtistModule.cs +++ b/src/Lidarr.Api.V1/Artist/ArtistModule.cs @@ -34,6 +34,7 @@ namespace Lidarr.Api.V1.Artist { private readonly IArtistService _artistService; + private readonly IAlbumService _albumService; private readonly IAddArtistService _addArtistService; private readonly IArtistStatisticsService _artistStatisticsService; private readonly IMapCoversToLocal _coverMapper; @@ -41,6 +42,7 @@ namespace Lidarr.Api.V1.Artist public ArtistModule(IBroadcastSignalRMessage signalRBroadcaster, IArtistService artistService, + IAlbumService albumService, IAddArtistService addArtistService, IArtistStatisticsService artistStatisticsService, IMapCoversToLocal coverMapper, @@ -57,6 +59,7 @@ namespace Lidarr.Api.V1.Artist : base(signalRBroadcaster) { _artistService = artistService; + _albumService = albumService; _addArtistService = addArtistService; _artistStatisticsService = artistStatisticsService; @@ -105,6 +108,7 @@ namespace Lidarr.Api.V1.Artist var resource = artist.ToResource(); MapCoversToLocal(resource); FetchAndLinkArtistStatistics(resource); + LinkNextPreviousAlbums(resource); //PopulateAlternateTitles(resource); return resource; @@ -116,7 +120,7 @@ namespace Lidarr.Api.V1.Artist var artistsResources = _artistService.GetAllArtists().ToResource(); MapCoversToLocal(artistsResources.ToArray()); - //MapAlbums(artistsResources.ToArray()); + LinkNextPreviousAlbums(artistsResources.ToArray()); LinkArtistStatistics(artistsResources, artistStats); //PopulateAlternateTitles(seriesResources); @@ -171,6 +175,16 @@ namespace Lidarr.Api.V1.Artist } } + private void LinkNextPreviousAlbums(params ArtistResource[] artists) + { + foreach (var artistResource in artists) + { + var artistAlbums = _albumService.GetAlbumsByArtist(artistResource.Id).OrderBy(s=>s.ReleaseDate); + artistResource.NextAlbum = artistAlbums.Where(s => s.ReleaseDate >= DateTime.UtcNow && s.Monitored).FirstOrDefault(); + artistResource.LastAlbum = artistAlbums.Where(s => s.ReleaseDate <= DateTime.UtcNow && s.Monitored).LastOrDefault(); + } + } + private void FetchAndLinkArtistStatistics(ArtistResource resource) { LinkArtistStatistics(resource, _artistStatisticsService.ArtistStatistics(resource.Id)); @@ -195,13 +209,6 @@ namespace Lidarr.Api.V1.Artist resource.SizeOnDisk = artistStatistics.SizeOnDisk; resource.AlbumCount = artistStatistics.AlbumCount; - if (artistStatistics.AlbumStatistics != null) - { - foreach (var album in resource.Albums) - { - album.Statistics = artistStatistics.AlbumStatistics.SingleOrDefault(s => s.AlbumId == album.Id).ToResource(); - } - } } //private void PopulateAlternateTitles(List resources) diff --git a/src/Lidarr.Api.V1/Artist/ArtistResource.cs b/src/Lidarr.Api.V1/Artist/ArtistResource.cs index d8b42579c..d8f53acb2 100644 --- a/src/Lidarr.Api.V1/Artist/ArtistResource.cs +++ b/src/Lidarr.Api.V1/Artist/ArtistResource.cs @@ -36,13 +36,13 @@ namespace Lidarr.Api.V1.Artist public int? TrackCount { get; set; } public int? TrackFileCount { get; set; } public long? SizeOnDisk { get; set; } - //public SeriesStatusType Status { get; set; } + public Album NextAlbum { get; set; } + public Album LastAlbum { get; set; } public List Images { get; set; } public List Members { get; set; } public string RemotePoster { get; set; } - public List Albums { get; set; } //View & Edit @@ -89,8 +89,6 @@ namespace Lidarr.Api.V1.Artist Images = model.Images, - Albums = model.Albums.ToResource(), - Path = model.Path, QualityProfileId = model.ProfileId, LanguageProfileId = model.LanguageProfileId, @@ -128,8 +126,6 @@ namespace Lidarr.Api.V1.Artist Status = resource.Status, Overview = resource.Overview, - //NextAiring - //PreviousAiring Images = resource.Images, diff --git a/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj b/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj index e69985c87..746a65e18 100644 --- a/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj +++ b/src/Lidarr.Api.V1/Lidarr.Api.V1.csproj @@ -82,6 +82,7 @@ + diff --git a/src/Lidarr.Api.V1/TrackFiles/MediaInfoResource.cs b/src/Lidarr.Api.V1/TrackFiles/MediaInfoResource.cs index 0608f62b1..a3b586585 100644 --- a/src/Lidarr.Api.V1/TrackFiles/MediaInfoResource.cs +++ b/src/Lidarr.Api.V1/TrackFiles/MediaInfoResource.cs @@ -12,7 +12,7 @@ namespace Lidarr.Api.V1.TrackFiles public static class MediaInfoResourceMapper { - public static MediaInfoResource ToResource(this MediaInfoModel model, string sceneName) + public static MediaInfoResource ToResource(this MediaInfoModel model) { if (model == null) { diff --git a/src/Lidarr.Api.V1/TrackFiles/TrackFileModule.cs b/src/Lidarr.Api.V1/TrackFiles/TrackFileModule.cs index bb0696843..97967ed02 100644 --- a/src/Lidarr.Api.V1/TrackFiles/TrackFileModule.cs +++ b/src/Lidarr.Api.V1/TrackFiles/TrackFileModule.cs @@ -80,8 +80,9 @@ namespace Lidarr.Api.V1.TrackFiles { int albumId = Convert.ToInt32(albumIdQuery.Value); var album = _albumService.GetAlbum(albumId); + var albumArtist = _artistService.GetArtist(album.ArtistId); - return _mediaFileService.GetFilesByAlbum(album.ArtistId, album.Id).ConvertAll(f => f.ToResource(album.Artist, _upgradableSpecification)); + return _mediaFileService.GetFilesByAlbum(album.Id).ConvertAll(f => f.ToResource(albumArtist, _upgradableSpecification)); } else diff --git a/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs b/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs index fb69f1e2d..320c23c7f 100644 --- a/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs +++ b/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs @@ -45,7 +45,7 @@ namespace Lidarr.Api.V1.TrackFiles // SceneName = model.SceneName, Language = model.Language, Quality = model.Quality, - MediaInfo = model.MediaInfo.ToResource(model.SceneName) + MediaInfo = model.MediaInfo.ToResource() //QualityCutoffNotMet }; @@ -68,7 +68,7 @@ namespace Lidarr.Api.V1.TrackFiles //SceneName = model.SceneName, Language = model.Language, Quality = model.Quality, - MediaInfo = model.MediaInfo.ToResource(model.SceneName), + MediaInfo = model.MediaInfo.ToResource(), QualityCutoffNotMet = upgradableSpecification.QualityCutoffNotMet(artist.Profile.Value, model.Quality), LanguageCutoffNotMet = upgradableSpecification.LanguageCutoffNotMet(artist.LanguageProfile.Value, model.Language) diff --git a/src/Lidarr.Api.V1/Tracks/TrackModuleWithSignalR.cs b/src/Lidarr.Api.V1/Tracks/TrackModuleWithSignalR.cs index 9d3795c99..13fb34f73 100644 --- a/src/Lidarr.Api.V1/Tracks/TrackModuleWithSignalR.cs +++ b/src/Lidarr.Api.V1/Tracks/TrackModuleWithSignalR.cs @@ -2,20 +2,20 @@ using System.Collections.Generic; using NzbDrone.Common.Extensions; using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Download; -using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Music; +using NzbDrone.Core.Music.Events; using NzbDrone.SignalR; using Lidarr.Api.V1.TrackFiles; using Lidarr.Api.V1.Artist; using Lidarr.Http; +using NzbDrone.Core.MediaFiles.Events; namespace Lidarr.Api.V1.Tracks { - public abstract class TrackModuleWithSignalR : LidarrRestModuleWithSignalR - //IHandle, - //IHandle + public abstract class TrackModuleWithSignalR : LidarrRestModuleWithSignalR, + IHandle, + IHandle { protected readonly ITrackService _trackService; protected readonly IArtistService _artistService; @@ -31,24 +31,24 @@ namespace Lidarr.Api.V1.Tracks _artistService = artistService; _upgradableSpecification = upgradableSpecification; - GetResourceById = GetEpisode; + GetResourceById = GetTrack; } - protected TrackModuleWithSignalR(ITrackService episodeService, - IArtistService seriesService, + protected TrackModuleWithSignalR(ITrackService trackService, + IArtistService artistService, IUpgradableSpecification upgradableSpecification, IBroadcastSignalRMessage signalRBroadcaster, string resource) : base(signalRBroadcaster, resource) { - _trackService = episodeService; - _artistService = seriesService; + _trackService = trackService; + _artistService = artistService; _upgradableSpecification = upgradableSpecification; - GetResourceById = GetEpisode; + GetResourceById = GetTrack; } - protected TrackResource GetEpisode(int id) + protected TrackResource GetTrack(int id) { var episode = _trackService.GetTrack(id); var resource = MapToResource(episode, true, true); @@ -76,28 +76,28 @@ namespace Lidarr.Api.V1.Tracks return resource; } - protected List MapToResource(List episodes, bool includeSeries, bool includeEpisodeFile) + protected List MapToResource(List tracks, bool includeArtist, bool includeTrackFile) { - var result = episodes.ToResource(); + var result = tracks.ToResource(); - if (includeSeries || includeEpisodeFile) + if (includeArtist || includeTrackFile) { - var seriesDict = new Dictionary(); - for (var i = 0; i < episodes.Count; i++) + var artistDict = new Dictionary(); + for (var i = 0; i < tracks.Count; i++) { - var episode = episodes[i]; + var track = tracks[i]; var resource = result[i]; - var series = episode.Artist ?? seriesDict.GetValueOrDefault(episodes[i].ArtistId) ?? _artistService.GetArtist(episodes[i].ArtistId); - seriesDict[series.Id] = series; + var series = track.Artist ?? artistDict.GetValueOrDefault(tracks[i].ArtistId) ?? _artistService.GetArtist(tracks[i].ArtistId); + artistDict[series.Id] = series; - if (includeSeries) + if (includeArtist) { resource.Artist = series.ToResource(); } - if (includeEpisodeFile && episodes[i].TrackFileId != 0) + if (includeTrackFile && tracks[i].TrackFileId != 0) { - resource.TrackFile = episodes[i].TrackFile.Value.ToResource(series, _upgradableSpecification); + resource.TrackFile = tracks[i].TrackFile.Value.ToResource(series, _upgradableSpecification); } } } @@ -105,28 +105,31 @@ namespace Lidarr.Api.V1.Tracks return result; } - //public void Handle(EpisodeGrabbedEvent message) - //{ - // foreach (var episode in message.Episode.Episodes) - // { - // var resource = episode.ToResource(); - // resource.Grabbed = true; - - // BroadcastResourceChange(ModelAction.Updated, resource); - // } - //} - - //public void Handle(EpisodeImportedEvent message) - //{ - // if (!message.NewDownload) - // { - // return; - // } - - // foreach (var episode in message.EpisodeInfo.Episodes) - // { - // BroadcastResourceChange(ModelAction.Updated, episode.Id); - // } - //} + public void Handle(TrackInfoRefreshedEvent message) + { + foreach (var track in message.Removed) + { + BroadcastResourceChange(ModelAction.Deleted, track.ToResource()); + } + + foreach (var track in message.Added) + { + BroadcastResourceChange(ModelAction.Updated, track.ToResource()); + } + + foreach (var track in message.Updated) + { + BroadcastResourceChange(ModelAction.Updated, track.Id); + } + } + + public void Handle(TrackImportedEvent message) + { + foreach (var track in message.TrackInfo.Tracks) + { + BroadcastResourceChange(ModelAction.Updated, track.Id); + } + } + } } diff --git a/src/NzbDrone.Common/Cloud/LidarrCloudRequestBuilder.cs b/src/NzbDrone.Common/Cloud/LidarrCloudRequestBuilder.cs index 9118822e1..a9e9d2069 100644 --- a/src/NzbDrone.Common/Cloud/LidarrCloudRequestBuilder.cs +++ b/src/NzbDrone.Common/Cloud/LidarrCloudRequestBuilder.cs @@ -16,7 +16,7 @@ namespace NzbDrone.Common.Cloud Services = new HttpRequestBuilder("https://services.lidarr.audio/v1/") .CreateFactory(); - Search = new HttpRequestBuilder("https://api.lidarr.audio/api/v0/{route}/") // TODO: Add {version} once LidarrAPI.Metadata is released. + Search = new HttpRequestBuilder("https://api.lidarr.audio/api/v0.3/{route}") .CreateFactory(); } diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DelaySpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DelaySpecificationFixture.cs index f16d98608..c486483ee 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DelaySpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DelaySpecificationFixture.cs @@ -71,7 +71,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync _remoteAlbum.Albums = Builder.CreateListOfSize(1).Build().ToList(); Mocker.GetMock() - .Setup(s => s.GetFilesByAlbum(It.IsAny(), It.IsAny())) + .Setup(s => s.GetFilesByAlbum(It.IsAny())) .Returns(new List { }); Mocker.GetMock() @@ -86,7 +86,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync private void GivenExistingFile(QualityModel quality, Language language) { Mocker.GetMock() - .Setup(s => s.GetFilesByAlbum(It.IsAny(), It.IsAny())) + .Setup(s => s.GetFilesByAlbum(It.IsAny())) .Returns(new List { new TrackFile { Quality = quality, Language = language diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DeletedTrackFileSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DeletedTrackFileSpecificationFixture.cs index 70542bff5..8c3c03b0b 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DeletedTrackFileSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/DeletedTrackFileSpecificationFixture.cs @@ -91,7 +91,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync private void SetupMediaFile(List files) { Mocker.GetMock() - .Setup(v => v.GetFilesByAlbum(It.IsAny(), It.IsAny())) + .Setup(v => v.GetFilesByAlbum(It.IsAny())) .Returns(files); } diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/ProperSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/ProperSpecificationFixture.cs index d69870d64..cfc8764d9 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/ProperSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/RssSync/ProperSpecificationFixture.cs @@ -44,7 +44,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests.RssSync .Build(); Mocker.GetMock() - .Setup(c => c.GetFilesByAlbum(It.IsAny(), It.IsAny())) + .Setup(c => c.GetFilesByAlbum(It.IsAny())) .Returns(new List { _firstFile, _secondFile }); _parseResultMulti = new RemoteAlbum diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeDiskSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeDiskSpecificationFixture.cs index e0150d327..5fc3f73e8 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeDiskSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeDiskSpecificationFixture.cs @@ -45,7 +45,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests .Build(); Mocker.GetMock() - .Setup(c => c.GetFilesByAlbum(It.IsAny(), It.IsAny())) + .Setup(c => c.GetFilesByAlbum(It.IsAny())) .Returns(new List { _firstFile, _secondFile }); _parseResultMulti = new RemoteAlbum @@ -78,7 +78,7 @@ namespace NzbDrone.Core.Test.DecisionEngineTests public void should_return_true_if_album_has_no_existing_file() { Mocker.GetMock() - .Setup(c => c.GetFilesByAlbum(It.IsAny(), It.IsAny())) + .Setup(c => c.GetFilesByAlbum(It.IsAny())) .Returns(new List { }); Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue(); diff --git a/src/NzbDrone.Core/Datastore/Migration/009_album_releases.cs b/src/NzbDrone.Core/Datastore/Migration/009_album_releases.cs new file mode 100644 index 000000000..7f93b587c --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/009_album_releases.cs @@ -0,0 +1,15 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(9)] + public class album_releases : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Alter.Table("Albums").AddColumn("Releases").AsString().WithDefaultValue("").Nullable(); + Alter.Table("Albums").AddColumn("CurrentRelease").AsString().WithDefaultValue("").Nullable(); + } + } +} diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/CutoffSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/CutoffSpecification.cs index 3e5feb73d..95f21b7ea 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/CutoffSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/CutoffSpecification.cs @@ -30,7 +30,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications foreach (var album in subject.Albums) { - var trackFiles = _mediaFileService.GetFilesByAlbum(album.ArtistId, album.Id); + var trackFiles = _mediaFileService.GetFilesByAlbum(album.Id); if (trackFiles.Any()) { diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DelaySpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DelaySpecification.cs index b5cfc52b4..db57c4bac 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DelaySpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DelaySpecification.cs @@ -61,7 +61,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync { foreach (var album in subject.Albums) { - var trackFiles = _mediaFileService.GetFilesByAlbum(album.ArtistId, album.Id); + var trackFiles = _mediaFileService.GetFilesByAlbum(album.Id); if (trackFiles.Any()) { diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DeletedTrackFileSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DeletedTrackFileSpecification.cs index 232753992..6c98a9ea9 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DeletedTrackFileSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/DeletedTrackFileSpecification.cs @@ -46,7 +46,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync } var missingTrackFiles = subject.Albums - .SelectMany(v => _albumService.GetFilesByAlbum(v.ArtistId, v.Id)) + .SelectMany(v => _albumService.GetFilesByAlbum(v.Id)) .DistinctBy(v => v.Id) .Where(v => IsTrackFileMissing(subject.Artist, v)) .ToArray(); diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/ProperSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/ProperSpecification.cs index 64418cfb6..ef3cefcd8 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/ProperSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/RssSync/ProperSpecification.cs @@ -35,7 +35,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications.RssSync foreach (var album in subject.Albums) { - var trackFiles = _mediaFileService.GetFilesByAlbum(album.ArtistId, album.Id); + var trackFiles = _mediaFileService.GetFilesByAlbum(album.Id); if (trackFiles.Any()) { diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradeDiskSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradeDiskSpecification.cs index 853a04a12..34ecfe2dd 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradeDiskSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradeDiskSpecification.cs @@ -28,7 +28,7 @@ namespace NzbDrone.Core.DecisionEngine.Specifications foreach (var album in subject.Albums) { - var trackFiles = _mediaFileService.GetFilesByAlbum(album.ArtistId, album.Id); + var trackFiles = _mediaFileService.GetFilesByAlbum(album.Id); if (trackFiles.Any()) { diff --git a/src/NzbDrone.Core/Exceptions/AlbumNotFoundException.cs b/src/NzbDrone.Core/Exceptions/AlbumNotFoundException.cs new file mode 100644 index 000000000..d9ac2729f --- /dev/null +++ b/src/NzbDrone.Core/Exceptions/AlbumNotFoundException.cs @@ -0,0 +1,31 @@ +using NzbDrone.Common.Exceptions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Exceptions +{ + public class AlbumNotFoundException : NzbDroneException + { + public string MusicBrainzId { get; set; } + + public AlbumNotFoundException(string musicbrainzId) + : base(string.Format("Album with MusicBrainz {0} was not found, it may have been removed from MusicBrainz.", musicbrainzId)) + { + MusicBrainzId = musicbrainzId; + } + + public AlbumNotFoundException(string musicbrainzId, string message, params object[] args) + : base(message, args) + { + MusicBrainzId = musicbrainzId; + } + + public AlbumNotFoundException(string musicbrainzId, string message) + : base(message) + { + MusicBrainzId = musicbrainzId; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileService.cs index d7b52422e..cd93d7f72 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileService.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileService.cs @@ -18,7 +18,7 @@ namespace NzbDrone.Core.MediaFiles void Update(List trackFile); void Delete(TrackFile trackFile, DeleteMediaFileReason reason); List GetFilesByArtist(int artistId); - List GetFilesByAlbum(int artistId, int albumId); + List GetFilesByAlbum(int albumId); List GetFiles(IEnumerable ids); List GetFilesWithoutMediaInfo(); List FilterExistingFiles(List files, Artist artist); @@ -27,7 +27,7 @@ namespace NzbDrone.Core.MediaFiles } - public class MediaFileService : IMediaFileService, IHandleAsync + public class MediaFileService : IMediaFileService, IHandleAsync, IHandleAsync { private readonly IEventAggregator _eventAggregator; private readonly IMediaFileRepository _mediaFileRepository; @@ -104,12 +104,18 @@ namespace NzbDrone.Core.MediaFiles _mediaFileRepository.DeleteMany(files); } + public void HandleAsync(AlbumDeletedEvent message) + { + var files = GetFilesByAlbum(message.Album.Id); + _mediaFileRepository.DeleteMany(files); + } + public List GetFilesByArtist(int artistId) { return _mediaFileRepository.GetFilesByArtist(artistId); } - public List GetFilesByAlbum(int artistId, int albumId) + public List GetFilesByAlbum(int albumId) { return _mediaFileRepository.GetFilesByAlbum(albumId); } diff --git a/src/NzbDrone.Core/MediaFiles/RenameTrackFileService.cs b/src/NzbDrone.Core/MediaFiles/RenameTrackFileService.cs index ec8c9a3b6..682b0468b 100644 --- a/src/NzbDrone.Core/MediaFiles/RenameTrackFileService.cs +++ b/src/NzbDrone.Core/MediaFiles/RenameTrackFileService.cs @@ -72,7 +72,7 @@ namespace NzbDrone.Core.MediaFiles var artist = _artistService.GetArtist(artistId); var tracks = _trackService.GetTracksByAlbum(albumId); - var files = _mediaFileService.GetFilesByAlbum(artistId, albumId); + var files = _mediaFileService.GetFilesByAlbum(albumId); return GetPreviews(artist, tracks, files) .OrderByDescending(e => e.TrackNumbers.First()).ToList(); diff --git a/src/NzbDrone.Core/MetadataSource/IProvideAlbumInfo.cs b/src/NzbDrone.Core/MetadataSource/IProvideAlbumInfo.cs new file mode 100644 index 000000000..865c4096b --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/IProvideAlbumInfo.cs @@ -0,0 +1,11 @@ +using NzbDrone.Core.Music; +using System; +using System.Collections.Generic; + +namespace NzbDrone.Core.MetadataSource +{ + public interface IProvideAlbumInfo + { + Tuple> GetAlbumInfo(string lidarrId, string releaseId); + } +} diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/AlbumResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/AlbumResource.cs index ababb59d0..9ad5375a5 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/AlbumResource.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/AlbumResource.cs @@ -25,6 +25,8 @@ namespace NzbDrone.Core.MetadataSource.SkyHook.Resource public List SecondaryTypes { get; set; } public List Media { get; set; } public List Tracks { get; set; } + public List Releases { get; set; } + public string SelectedRelease { get; set; } } diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/ReleaseResource.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/ReleaseResource.cs new file mode 100644 index 000000000..815aec43f --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/Resource/ReleaseResource.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; + +namespace NzbDrone.Core.MetadataSource.SkyHook.Resource +{ + public class ReleaseResource + { + public string Id { get; set; } + public DateTime ReleaseDate { get; set; } + public int MediaCount { get; set; } + public int TrackCount { get; set; } + public string Disambiguation { get; set; } + public List Label {get; set;} + public List Country { get; set; } + public string Format { get; set; } + } +} diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs index 31bd80c3d..4a92f2541 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs @@ -9,16 +9,13 @@ using NzbDrone.Common.Http; using NzbDrone.Core.Exceptions; using NzbDrone.Core.MediaCover; using NzbDrone.Core.MetadataSource.SkyHook.Resource; -using Newtonsoft.Json.Linq; using NzbDrone.Core.Music; -using Newtonsoft.Json; using NzbDrone.Core.Configuration; -using System.Text.RegularExpressions; using NzbDrone.Core.Profiles.Metadata; namespace NzbDrone.Core.MetadataSource.SkyHook { - public class SkyHookProxy : IProvideArtistInfo, ISearchForNewArtist + public class SkyHookProxy : IProvideArtistInfo, ISearchForNewArtist, IProvideAlbumInfo { private readonly IHttpClient _httpClient; private readonly Logger _logger; @@ -28,9 +25,13 @@ namespace NzbDrone.Core.MetadataSource.SkyHook private readonly IConfigService _configService; private readonly IMetadataProfileService _metadataProfileService; - private IHttpRequestBuilderFactory customerRequestBuilder; + private IHttpRequestBuilderFactory _customerRequestBuilder; - public SkyHookProxy(IHttpClient httpClient, ILidarrCloudRequestBuilder requestBuilder, IArtistService artistService, Logger logger, IConfigService configService, IMetadataProfileService metadataProfileService) + public SkyHookProxy(IHttpClient httpClient, + ILidarrCloudRequestBuilder requestBuilder, + IArtistService artistService, Logger logger, + IConfigService configService, + IMetadataProfileService metadataProfileService) { _httpClient = httpClient; _configService = configService; @@ -52,8 +53,8 @@ namespace NzbDrone.Core.MetadataSource.SkyHook var primaryTypes = metadataProfile.PrimaryAlbumTypes.Where(s => s.Allowed).Select(s => s.PrimaryAlbumType.Name); var secondaryTypes = metadataProfile.SecondaryAlbumTypes.Where(s => s.Allowed).Select(s => s.SecondaryAlbumType.Name); - var httpRequest = customerRequestBuilder.Create() - .SetSegment("route", "artists/" + foreignArtistId) + var httpRequest = _customerRequestBuilder.Create() + .SetSegment("route", "artist/" + foreignArtistId) .AddQueryParam("primTypes", string.Join("|", primaryTypes)) .AddQueryParam("secTypes", string.Join("|", secondaryTypes)) .Build(); @@ -86,16 +87,55 @@ namespace NzbDrone.Core.MetadataSource.SkyHook return new Tuple>(artist, albums.ToList()); } + public Tuple> GetAlbumInfo(string foreignAlbumId, string releaseId) + { + _logger.Debug("Getting Album with LidarrAPI.MetadataID of {0}", foreignAlbumId); + + SetCustomProvider(); + + var httpRequest = _customerRequestBuilder.Create() + .SetSegment("route", "album/" + foreignAlbumId) + .AddQueryParam("release", releaseId ?? string.Empty) + .Build(); + + httpRequest.AllowAutoRedirect = true; + httpRequest.SuppressHttpError = true; + + var httpResponse = _httpClient.Get(httpRequest); + + + if (httpResponse.HasHttpError) + { + if (httpResponse.StatusCode == HttpStatusCode.NotFound) + { + throw new AlbumNotFoundException(foreignAlbumId); + } + else if (httpResponse.StatusCode == HttpStatusCode.BadRequest) + { + throw new BadRequestException(foreignAlbumId); + } + else + { + throw new HttpException(httpRequest, httpResponse); + } + } + + var tracks = httpResponse.Resource.Tracks.Select(MapTrack); + var album = MapAlbum(httpResponse.Resource); + + return new Tuple>(album, tracks.ToList()); + } + public List SearchForNewArtist(string title) { try { var lowerTitle = title.ToLowerInvariant(); - Console.WriteLine("Searching for " + lowerTitle); if (lowerTitle.StartsWith("lidarr:") || lowerTitle.StartsWith("lidarrid:")) { var slug = lowerTitle.Split(':')[1].Trim(); + Guid searchGuid; bool isValid = Guid.TryParse(slug, out searchGuid); @@ -118,10 +158,12 @@ namespace NzbDrone.Core.MetadataSource.SkyHook SetCustomProvider(); - var httpRequest = customerRequestBuilder.Create() + var httpRequest = _customerRequestBuilder.Create() .SetSegment("route", "search") .AddQueryParam("type", "artist") .AddQueryParam("query", title.ToLower().Trim()) + //.AddQueryParam("images","false") // Should pass these on import search to avoid looking to fanart and wiki + //.AddQueryParam("overview","false") .Build(); @@ -143,12 +185,7 @@ namespace NzbDrone.Core.MetadataSource.SkyHook private Artist MapSearhResult(ArtistResource resource) { - var artist = _artistService.FindById(resource.Id); - - if (artist == null) - { - artist = MapArtist(resource); - } + var artist = _artistService.FindById(resource.Id) ?? MapArtist(resource); return artist; } @@ -161,37 +198,66 @@ namespace NzbDrone.Core.MetadataSource.SkyHook album.ReleaseDate = resource.ReleaseDate; album.CleanTitle = Parser.Parser.CleanArtistName(album.Title); album.AlbumType = resource.Type; - album.Images = resource.Images.Select(MapImage).ToList(); - album.Label = resource.Labels; + if (resource.Images != null) + { + album.Images = resource.Images.Select(MapImage).ToList(); + } + + album.Label = resource.Labels; album.Media = resource.Media.Select(MapMedium).ToList(); - album.Tracks = resource.Tracks.Select(MapTrack).ToList(); album.SecondaryTypes = resource.SecondaryTypes.Select(MapSecondaryTypes).ToList(); + if (resource.Releases != null) + { + album.Releases = resource.Releases.Select(MapAlbumRelease).ToList(); + album.CurrentRelease = album.Releases.FirstOrDefault(s => s.Id == resource.SelectedRelease); + } return album; } private static Medium MapMedium(MediumResource resource) { - Medium medium = new Medium(); - medium.Name = resource.Name; - medium.Number = resource.Position; - medium.Format = resource.Format; + Medium medium = new Medium + { + Name = resource.Name, + Number = resource.Position, + Format = resource.Format + }; return medium; } - private static Track MapTrack(TrackResource resource) + private static AlbumRelease MapAlbumRelease(ReleaseResource resource) { - Track track = new Track(); - track.Title = resource.TrackName; - track.ForeignTrackId = resource.Id; - track.TrackNumber = resource.TrackNumber; - track.AbsoluteTrackNumber = resource.TrackPosition; - track.Duration = resource.DurationMs; - track.MediumNumber = resource.MediumNumber; + AlbumRelease albumRelease = new AlbumRelease + { + Id = resource.Id, + ReleaseDate = resource.ReleaseDate, + TrackCount = resource.TrackCount, + Format = resource.Format, + MediaCount = resource.MediaCount, + Country = resource.Country, + Disambiguation = resource.Disambiguation, + Label = resource.Label + }; + return albumRelease; + } + + private static Track MapTrack(TrackResource resource) + { + Track track = new Track + { + Title = resource.TrackName, + ForeignTrackId = resource.Id, + TrackNumber = resource.TrackNumber, + AbsoluteTrackNumber = resource.TrackPosition, + Duration = resource.DurationMs, + MediumNumber = resource.MediumNumber + }; + return track; } @@ -213,7 +279,6 @@ namespace NzbDrone.Core.MetadataSource.SkyHook artist.Status = MapArtistStatus(resource.Status); artist.Ratings = MapRatings(resource.Rating); artist.Links = resource.Links.Select(MapLink).ToList(); - return artist; } @@ -251,14 +316,14 @@ namespace NzbDrone.Core.MetadataSource.SkyHook return ArtistStatusType.Continuing; } - private static Music.Ratings MapRatings(RatingResource rating) + private static Ratings MapRatings(RatingResource rating) { if (rating == null) { - return new Music.Ratings(); + return new Ratings(); } - return new Music.Ratings + return new Ratings { Votes = rating.Count, Value = rating.Value @@ -274,9 +339,9 @@ namespace NzbDrone.Core.MetadataSource.SkyHook }; } - private static Music.Links MapLink(LinkResource arg) + private static Links MapLink(LinkResource arg) { - return new Music.Links + return new Links { Url = arg.Target, Name = arg.Type @@ -337,11 +402,11 @@ namespace NzbDrone.Core.MetadataSource.SkyHook { if (_configService.MetadataSource.IsNotNullOrWhiteSpace()) { - customerRequestBuilder = new HttpRequestBuilder(_configService.MetadataSource.TrimEnd("/") + "/{route}/").CreateFactory(); + _customerRequestBuilder = new HttpRequestBuilder(_configService.MetadataSource.TrimEnd("/") + "/{route}").CreateFactory(); } else { - customerRequestBuilder = _requestBuilder; + _customerRequestBuilder = _requestBuilder; } } } diff --git a/src/NzbDrone.Core/Music/Album.cs b/src/NzbDrone.Core/Music/Album.cs index 2b83f6f92..2cf795036 100644 --- a/src/NzbDrone.Core/Music/Album.cs +++ b/src/NzbDrone.Core/Music/Album.cs @@ -39,10 +39,26 @@ namespace NzbDrone.Core.Music public AddArtistOptions AddOptions { get; set; } public Artist Artist { get; set; } public Ratings Ratings { get; set; } + public List Releases { get; set; } + public AlbumRelease CurrentRelease { get; set; } public override string ToString() { return string.Format("[{0}][{1}]", ForeignAlbumId, Title.NullSafe()); } + + public void ApplyChanges(Album otherAlbum) + { + + ForeignAlbumId = otherAlbum.ForeignAlbumId; + + Tracks = otherAlbum.Tracks; + + ProfileId = otherAlbum.ProfileId; + AddOptions = otherAlbum.AddOptions; + Monitored = otherAlbum.Monitored; + CurrentRelease = otherAlbum.CurrentRelease; + + } } } diff --git a/src/NzbDrone.Core/Music/AlbumEditedService.cs b/src/NzbDrone.Core/Music/AlbumEditedService.cs new file mode 100644 index 000000000..89acbc5de --- /dev/null +++ b/src/NzbDrone.Core/Music/AlbumEditedService.cs @@ -0,0 +1,25 @@ +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Music.Commands; +using NzbDrone.Core.Music.Events; + +namespace NzbDrone.Core.Music +{ + public class AlbumEditedService : IHandle + { + private readonly IManageCommandQueue _commandQueueManager; + + public AlbumEditedService(IManageCommandQueue commandQueueManager) + { + _commandQueueManager = commandQueueManager; + } + + public void Handle(AlbumEditedEvent message) + { + if (message.Album.CurrentRelease.Id != message.OldAlbum.CurrentRelease.Id) + { + _commandQueueManager.Push(new RefreshAlbumCommand(message.Album.Id)); + } + } + } +} diff --git a/src/NzbDrone.Core/Music/AlbumRelease.cs b/src/NzbDrone.Core/Music/AlbumRelease.cs new file mode 100644 index 000000000..361ca4796 --- /dev/null +++ b/src/NzbDrone.Core/Music/AlbumRelease.cs @@ -0,0 +1,18 @@ +using System; +using System.Collections.Generic; +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Music +{ + public class AlbumRelease : IEmbeddedDocument + { + public string Id { get; set; } + public DateTime? ReleaseDate { get; set; } + public int TrackCount { get; set; } + public int MediaCount { get; set; } + public string Disambiguation { get; set; } + public List Country { get; set; } + public string Format { get; set; } + public List Label { get; set; } + } +} diff --git a/src/NzbDrone.Core/Music/AlbumService.cs b/src/NzbDrone.Core/Music/AlbumService.cs index ea57b19b2..a7384da70 100644 --- a/src/NzbDrone.Core/Music/AlbumService.cs +++ b/src/NzbDrone.Core/Music/AlbumService.cs @@ -19,6 +19,7 @@ namespace NzbDrone.Core.Music List GetAlbums(IEnumerable albumIds); List GetAlbumsByArtist(int artistId); Album AddAlbum(Album newAlbum); + List AddAlbums(List newAlbums); Album FindById(string spotifyId); Album FindByTitle(int artistId, string title); Album FindByTitleInexact(string title); @@ -61,11 +62,19 @@ namespace NzbDrone.Core.Music public Album AddAlbum(Album newAlbum) { _albumRepository.Insert(newAlbum); - _eventAggregator.PublishEvent(new AlbumAddedEvent(GetAlbum(newAlbum.Id))); + //_eventAggregator.PublishEvent(new AlbumAddedEvent(GetAlbum(newAlbum.Id))); return newAlbum; } + public List AddAlbums(List newAlbums) + { + _albumRepository.InsertMany(newAlbums); + //_eventAggregator.PublishEvent(new AlbumsAddedEvent(newAlbums.Select(s => s.Id).ToList())); + + return newAlbums; + } + public void DeleteAlbum(int albumId, bool deleteFiles) { var album = _albumRepository.Get(albumId); @@ -140,11 +149,16 @@ namespace NzbDrone.Core.Music public void DeleteMany(List albums) { _albumRepository.DeleteMany(albums); + + foreach (var album in albums) + { + _eventAggregator.PublishEvent(new AlbumDeletedEvent(album, false)); + } } public Album UpdateAlbum(Album album) { - var storedAlbum = GetAlbum(album.Id); // Is it Id or iTunesId? + var storedAlbum = GetAlbum(album.Id); var updatedAlbum = _albumRepository.Update(album); _eventAggregator.PublishEvent(new AlbumEditedEvent(updatedAlbum, storedAlbum)); diff --git a/src/NzbDrone.Core/Music/Commands/RefreshAlbumCommand.cs b/src/NzbDrone.Core/Music/Commands/RefreshAlbumCommand.cs new file mode 100644 index 000000000..40652db6e --- /dev/null +++ b/src/NzbDrone.Core/Music/Commands/RefreshAlbumCommand.cs @@ -0,0 +1,22 @@ +using NzbDrone.Core.Messaging.Commands; + +namespace NzbDrone.Core.Music.Commands +{ + public class RefreshAlbumCommand : Command + { + public int? AlbumId { get; set; } + + public RefreshAlbumCommand() + { + } + + public RefreshAlbumCommand(int? albumId) + { + AlbumId = albumId; + } + + public override bool SendUpdatesToClient => true; + + public override bool UpdateScheduledTask => !AlbumId.HasValue; + } +} diff --git a/src/NzbDrone.Core/Music/Events/AlbumsAddedEvent.cs b/src/NzbDrone.Core/Music/Events/AlbumsAddedEvent.cs new file mode 100644 index 000000000..b782bfe82 --- /dev/null +++ b/src/NzbDrone.Core/Music/Events/AlbumsAddedEvent.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; +using NzbDrone.Common.Messaging; + +namespace NzbDrone.Core.Music.Events +{ + public class AlbumsAddedEvent : IEvent + { + public List AlbumIds { get; private set; } + + public AlbumsAddedEvent(List albumIds) + { + AlbumIds = albumIds; + } + } +} diff --git a/src/NzbDrone.Core/Music/Events/TrackInfoRefreshedEvent.cs b/src/NzbDrone.Core/Music/Events/TrackInfoRefreshedEvent.cs index ef6eb1a8b..a71afa345 100644 --- a/src/NzbDrone.Core/Music/Events/TrackInfoRefreshedEvent.cs +++ b/src/NzbDrone.Core/Music/Events/TrackInfoRefreshedEvent.cs @@ -1,4 +1,4 @@ -using NzbDrone.Common.Messaging; +using NzbDrone.Common.Messaging; using System; using System.Collections.Generic; using System.Collections.ObjectModel; @@ -12,12 +12,14 @@ namespace NzbDrone.Core.Music.Events public Album Album { get; set; } public ReadOnlyCollection Added { get; private set; } public ReadOnlyCollection Updated { get; private set; } + public ReadOnlyCollection Removed { get; private set; } - public TrackInfoRefreshedEvent(Album album, IList added, IList updated) + public TrackInfoRefreshedEvent(Album album, IList added, IList updated, IList removed) { Album = album; Added = new ReadOnlyCollection(added); Updated = new ReadOnlyCollection(updated); + Removed = new ReadOnlyCollection(removed); } } } diff --git a/src/NzbDrone.Core/Music/RefreshAlbumService.cs b/src/NzbDrone.Core/Music/RefreshAlbumService.cs index da8d63041..5d60b6c72 100644 --- a/src/NzbDrone.Core/Music/RefreshAlbumService.cs +++ b/src/NzbDrone.Core/Music/RefreshAlbumService.cs @@ -7,31 +7,104 @@ using System.Collections.Generic; using NzbDrone.Core.Organizer; using System.Linq; using System.Text; +using NzbDrone.Core.MetadataSource; + +using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Core.Exceptions; + + +using NzbDrone.Core.Messaging.Commands; + +using NzbDrone.Core.Music.Commands; + + namespace NzbDrone.Core.Music { public interface IRefreshAlbumService { void RefreshAlbumInfo(Artist artist, IEnumerable remoteAlbums); + void RefreshAlbumInfo(Album album); } - public class RefreshAlbumService : IRefreshAlbumService + public class RefreshAlbumService : IRefreshAlbumService, IExecute { private readonly IAlbumService _albumService; + private readonly IArtistService _artistService; + private readonly IProvideAlbumInfo _albumInfo; private readonly IRefreshTrackService _refreshTrackService; + private readonly ITrackService _trackService; private readonly IBuildFileNames _fileNameBuilder; private readonly IEventAggregator _eventAggregator; private readonly Logger _logger; - public RefreshAlbumService(IAlbumService albumService, IBuildFileNames fileNameBuilder, IRefreshTrackService refreshTrackService, IEventAggregator eventAggregator, Logger logger) + public RefreshAlbumService(IAlbumService albumService, + IArtistService artistService, + IProvideAlbumInfo albumInfo, + IRefreshTrackService refreshTrackService, + ITrackService trackService, + IBuildFileNames fileNameBuilder, + IEventAggregator eventAggregator, + Logger logger) { _albumService = albumService; - _fileNameBuilder = fileNameBuilder; + _artistService = artistService; + _albumInfo = albumInfo; _refreshTrackService = refreshTrackService; + _trackService = trackService; + _fileNameBuilder = fileNameBuilder; _eventAggregator = eventAggregator; _logger = logger; } + public void RefreshAlbumInfo(Album album) + { + _logger.ProgressInfo("Updating Info for {0}", album.Title); + + Tuple> tuple; + + try + { + tuple = _albumInfo.GetAlbumInfo(album.ForeignAlbumId, album.CurrentRelease.Id); + } + catch (AlbumNotFoundException) + { + _logger.Error( + "Album '{0}' (LidarrAPI {1}) was not found, it may have been removed from Metadata sources.", + album.Title, album.ForeignAlbumId); + return; + } + + var albumInfo = tuple.Item1; + + if (album.ForeignAlbumId != albumInfo.ForeignAlbumId) + { + _logger.Warn( + "Album '{0}' (Album {1}) was replaced with '{2}' (LidarrAPI {3}), because the original was a duplicate.", + album.Title, album.ForeignAlbumId, albumInfo.Title, albumInfo.ForeignAlbumId); + album.ForeignAlbumId = albumInfo.ForeignAlbumId; + } + + album.LastInfoSync = DateTime.UtcNow; + album.CleanTitle = albumInfo.CleanTitle; + album.Title = albumInfo.Title ?? "Unknown"; + album.CleanTitle = Parser.Parser.CleanArtistName(album.Title); + album.AlbumType = albumInfo.AlbumType; + album.SecondaryTypes = albumInfo.SecondaryTypes; + album.Genres = albumInfo.Genres; + album.Media = albumInfo.Media; + album.Label = albumInfo.Label; + album.Images = albumInfo.Images; + album.ReleaseDate = albumInfo.ReleaseDate; + album.Duration = tuple.Item2.Sum(track => track.Duration); + album.Releases = albumInfo.Releases; + + _refreshTrackService.RefreshTrackInfo(album, tuple.Item2); + + _albumService.UpdateAlbum(album); + + } + public void RefreshAlbumInfo(Artist artist, IEnumerable remoteAlbums) { _logger.Info("Starting album info refresh for: {0}", artist); @@ -42,67 +115,80 @@ namespace NzbDrone.Core.Music var updateList = new List(); var newList = new List(); - var dupeFreeRemoteAlbums = remoteAlbums.DistinctBy(m => new { m.ForeignAlbumId, m.ReleaseDate }).ToList(); + var dupeFreeRemoteAlbums = remoteAlbums.DistinctBy(m => new {m.ForeignAlbumId, m.ReleaseDate}).ToList(); foreach (var album in OrderAlbums(artist, dupeFreeRemoteAlbums)) { + try { var albumToUpdate = GetAlbumToUpdate(artist, album, existingAlbums); + Tuple> tuple; + var albumInfo = new Album(); + if (albumToUpdate != null) { + + tuple = _albumInfo.GetAlbumInfo(album.ForeignAlbumId, albumToUpdate.CurrentRelease.Id); + albumInfo = tuple.Item1; existingAlbums.Remove(albumToUpdate); updateList.Add(albumToUpdate); } else { - albumToUpdate = new Album(); - albumToUpdate.Monitored = artist.Monitored; - albumToUpdate.ProfileId = artist.ProfileId; - albumToUpdate.Added = DateTime.UtcNow; + tuple = _albumInfo.GetAlbumInfo(album.ForeignAlbumId, null); + albumInfo = tuple.Item1; + albumToUpdate = new Album + { + Monitored = artist.Monitored, + ProfileId = artist.ProfileId, + Added = DateTime.UtcNow + }; + + albumToUpdate.ArtistId = artist.Id; + albumToUpdate.CleanTitle = albumInfo.CleanTitle; + albumToUpdate.ForeignAlbumId = albumInfo.ForeignAlbumId; + albumToUpdate.Title = albumInfo.Title ?? "Unknown"; + albumToUpdate.AlbumType = albumInfo.AlbumType; + + _albumService.AddAlbum(albumToUpdate); newList.Add(albumToUpdate); } - - - albumToUpdate.ForeignAlbumId = album.ForeignAlbumId; + + albumToUpdate.LastInfoSync = DateTime.UtcNow; - albumToUpdate.CleanTitle = album.CleanTitle; - albumToUpdate.Title = album.Title ?? "Unknown"; + albumToUpdate.CleanTitle = albumInfo.CleanTitle; + albumToUpdate.Title = albumInfo.Title ?? "Unknown"; albumToUpdate.CleanTitle = Parser.Parser.CleanArtistName(albumToUpdate.Title); - albumToUpdate.ArtistId = artist.Id; - albumToUpdate.AlbumType = album.AlbumType; - albumToUpdate.SecondaryTypes = album.SecondaryTypes; - albumToUpdate.Genres = album.Genres; - albumToUpdate.Media = album.Media; - albumToUpdate.Label = album.Label; - albumToUpdate.Images = album.Images; - albumToUpdate.ReleaseDate = album.ReleaseDate; - albumToUpdate.Duration = album.Tracks.Sum(track => track.Duration); - + albumToUpdate.AlbumType = albumInfo.AlbumType; + albumToUpdate.SecondaryTypes = albumInfo.SecondaryTypes; + albumToUpdate.Genres = albumInfo.Genres; + albumToUpdate.Media = albumInfo.Media; + albumToUpdate.Label = albumInfo.Label; + albumToUpdate.Images = albumInfo.Images; + albumToUpdate.ReleaseDate = albumInfo.ReleaseDate; + albumToUpdate.Duration = tuple.Item2.Sum(track => track.Duration); + albumToUpdate.Releases = albumInfo.Releases; + albumToUpdate.CurrentRelease = albumInfo.CurrentRelease; + + _refreshTrackService.RefreshTrackInfo(albumToUpdate, tuple.Item2); successCount++; } catch (Exception e) { - _logger.Fatal(e, "An error has occurred while updating track info for artist {0}. {1}", artist, album); + _logger.Fatal(e, "An error has occurred while updating album info for artist {0}. {1}", artist, + album); failCount++; } } - var allAlbums = new List(); - allAlbums.AddRange(newList); - allAlbums.AddRange(updateList); - - // TODO: See if anything needs to be done here - //AdjustMultiEpisodeAirTime(artist, allTracks); - //AdjustDirectToDvdAirDate(artist, allTracks); - _albumService.DeleteMany(existingAlbums); _albumService.UpdateMany(updateList); - _albumService.InsertMany(newList); - + _albumService.UpdateMany(newList); + _eventAggregator.PublishEvent(new AlbumInfoRefreshedEvent(artist, newList, updateList)); if (failCount != 0) @@ -125,13 +211,26 @@ namespace NzbDrone.Core.Music private Album GetAlbumToUpdate(Artist artist, Album album, List existingAlbums) { - return existingAlbums.FirstOrDefault(e => e.ForeignAlbumId == album.ForeignAlbumId/* && e.ReleaseDate == album.ReleaseDate*/); + return existingAlbums.FirstOrDefault( + e => e.ForeignAlbumId == album.ForeignAlbumId /* && e.ReleaseDate == album.ReleaseDate*/); } private IEnumerable OrderAlbums(Artist artist, List albums) { return albums.OrderBy(e => e.ForeignAlbumId).ThenBy(e => e.ReleaseDate); } + + public void Execute(RefreshAlbumCommand message) + { + if (message.AlbumId.HasValue) + { + var album = _albumService.GetAlbum(message.AlbumId.Value); + var artist = _artistService.GetArtist(album.ArtistId); + RefreshAlbumInfo(album); + _eventAggregator.PublishEvent(new ArtistUpdatedEvent(artist)); + } + + } } } diff --git a/src/NzbDrone.Core/Music/RefreshArtistService.cs b/src/NzbDrone.Core/Music/RefreshArtistService.cs index 3c3e42790..615a6f380 100644 --- a/src/NzbDrone.Core/Music/RefreshArtistService.cs +++ b/src/NzbDrone.Core/Music/RefreshArtistService.cs @@ -20,6 +20,7 @@ namespace NzbDrone.Core.Music { private readonly IProvideArtistInfo _artistInfo; private readonly IArtistService _artistService; + private readonly IAlbumService _albumService; private readonly IRefreshAlbumService _refreshAlbumService; private readonly IRefreshTrackService _refreshTrackService; private readonly IEventAggregator _eventAggregator; @@ -29,6 +30,7 @@ namespace NzbDrone.Core.Music public RefreshArtistService(IProvideArtistInfo artistInfo, IArtistService artistService, + IAlbumService albumService, IRefreshAlbumService refreshAlbumService, IRefreshTrackService refreshTrackService, IEventAggregator eventAggregator, @@ -38,6 +40,7 @@ namespace NzbDrone.Core.Music { _artistInfo = artistInfo; _artistService = artistService; + _albumService = albumService; _refreshAlbumService = refreshAlbumService; _refreshTrackService = refreshTrackService; _eventAggregator = eventAggregator; @@ -92,52 +95,13 @@ namespace NzbDrone.Core.Music { _logger.Warn(e, "Couldn't update artist path for " + artist.Path); } - - //artist.Albums = UpdateAlbums(artist, artistInfo); # We don't need this since we don't store albums in artist table. - - _artistService.UpdateArtist(artist); - - _refreshAlbumService.RefreshAlbumInfo(artist, tuple.Item2); - foreach (var album in tuple.Item2) - { - _refreshTrackService.RefreshTrackInfo(album, album.Tracks); - } + _refreshAlbumService.RefreshAlbumInfo(artist, tuple.Item2); _logger.Debug("Finished artist refresh for {0}", artist.Name); _eventAggregator.PublishEvent(new ArtistUpdatedEvent(artist)); } - private List UpdateAlbums(Artist artist, Artist artistInfo) - { - var albums = artistInfo.Albums.DistinctBy(s => s.ForeignAlbumId).ToList(); - - foreach (var album in albums) - { - var existingAlbum = artist.Albums.FirstOrDefault(s => s.ForeignAlbumId == album.ForeignAlbumId); - - //Todo: Should this should use the previous season's monitored state? - if (existingAlbum == null) - { - //if (album.SeasonNumber == 0) - //{ - // album.Monitored = false; - // continue; - //} - - _logger.Debug("New album ({0}) for artist: [{1}] {2}, setting monitored to true", album.Title, artist.ForeignArtistId, artist.Name); - album.Monitored = true; - } - - else - { - album.Monitored = existingAlbum.Monitored; - } - } - - return albums; - } - public void Execute(RefreshArtistCommand message) { _eventAggregator.PublishEvent(new ArtistRefreshStartingEvent(message.Trigger == CommandTrigger.Manual)); diff --git a/src/NzbDrone.Core/Music/RefreshTrackService.cs b/src/NzbDrone.Core/Music/RefreshTrackService.cs index 12c1d19e7..feaf8c770 100644 --- a/src/NzbDrone.Core/Music/RefreshTrackService.cs +++ b/src/NzbDrone.Core/Music/RefreshTrackService.cs @@ -68,6 +68,7 @@ namespace NzbDrone.Core.Music trackToUpdate.AbsoluteTrackNumber = track.AbsoluteTrackNumber; trackToUpdate.Title = track.Title ?? "Unknown"; trackToUpdate.AlbumId = album.Id; + trackToUpdate.ArtistId = album.ArtistId; trackToUpdate.Album = track.Album ?? album; trackToUpdate.Explicit = track.Explicit; trackToUpdate.ArtistId = album.ArtistId; @@ -89,15 +90,11 @@ namespace NzbDrone.Core.Music allTracks.AddRange(newList); allTracks.AddRange(updateList); - // TODO: See if anything needs to be done here - //AdjustMultiEpisodeAirTime(artist, allTracks); - //AdjustDirectToDvdAirDate(artist, allTracks); - _trackService.DeleteMany(existingTracks); _trackService.UpdateMany(updateList); _trackService.InsertMany(newList); - _eventAggregator.PublishEvent(new TrackInfoRefreshedEvent(album, newList, updateList)); + _eventAggregator.PublishEvent(new TrackInfoRefreshedEvent(album, newList, updateList, existingTracks)); if (failCount != 0) { diff --git a/src/NzbDrone.Core/Music/TrackService.cs b/src/NzbDrone.Core/Music/TrackService.cs index 184bb68dc..59010c097 100644 --- a/src/NzbDrone.Core/Music/TrackService.cs +++ b/src/NzbDrone.Core/Music/TrackService.cs @@ -35,6 +35,7 @@ namespace NzbDrone.Core.Music public class TrackService : ITrackService, IHandleAsync, + IHandleAsync, IHandle, IHandle { @@ -163,6 +164,12 @@ namespace NzbDrone.Core.Music _trackRepository.DeleteMany(tracks); } + public void HandleAsync(AlbumDeletedEvent message) + { + var tracks = GetTracksByAlbum(message.Album.Id); + _trackRepository.DeleteMany(tracks); + } + public void Handle(TrackFileDeletedEvent message) { foreach (var track in GetTracksByFileId(message.TrackFile.Id)) diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index a2f382bcf..4fb98bbb1 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -181,6 +181,7 @@ + @@ -424,6 +425,7 @@ + @@ -730,6 +732,7 @@ + @@ -737,6 +740,7 @@ + @@ -761,9 +765,13 @@ + + + + diff --git a/src/NzbDrone.Integration.Test/IntegrationTestBase.cs b/src/NzbDrone.Integration.Test/IntegrationTestBase.cs index fd4c922fa..6400e2aee 100644 --- a/src/NzbDrone.Integration.Test/IntegrationTestBase.cs +++ b/src/NzbDrone.Integration.Test/IntegrationTestBase.cs @@ -237,15 +237,6 @@ namespace NzbDrone.Integration.Test changed = true; } - result.Albums.ForEach(season => - { - if (season.Monitored != monitored.Value) - { - season.Monitored = monitored.Value; - changed = true; - } - }); - if (changed) { Artist.Put(result);