mirror of https://github.com/lidarr/Lidarr
New: Unmapped files view (#888)
* New: Unmapped files view Displays all trackfiles that haven't been matched to a track. Generalised the file details component and adds it to the album details screen. * Add sorting by quality * New: MediaServiceTests & MediaRepoTests
This commit is contained in:
parent
74cb2a6f52
commit
4413c7e46c
|
@ -1,34 +1,109 @@
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import React, { Component } from 'react';
|
import React, { Component } from 'react';
|
||||||
import { icons } from 'Helpers/Props';
|
import { icons, kinds } from 'Helpers/Props';
|
||||||
import IconButton from 'Components/Link/IconButton';
|
import IconButton from 'Components/Link/IconButton';
|
||||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||||
|
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||||
|
import FileDetailsModal from 'TrackFile/FileDetailsModal';
|
||||||
import styles from './TrackActionsCell.css';
|
import styles from './TrackActionsCell.css';
|
||||||
|
|
||||||
class TrackActionsCell extends Component {
|
class TrackActionsCell extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
isDetailsModalOpen: false,
|
||||||
|
isConfirmDeleteModalOpen: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onDetailsPress = () => {
|
||||||
|
this.setState({ isDetailsModalOpen: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
onDetailsModalClose = () => {
|
||||||
|
this.setState({ isDetailsModalOpen: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
onDeleteFilePress = () => {
|
||||||
|
this.setState({ isConfirmDeleteModalOpen: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
onConfirmDelete = () => {
|
||||||
|
this.setState({ isConfirmDeleteModalOpen: false });
|
||||||
|
this.props.deleteTrackFile({ id: this.props.trackFileId });
|
||||||
|
}
|
||||||
|
|
||||||
|
onConfirmDeleteModalClose = () => {
|
||||||
|
this.setState({ isConfirmDeleteModalOpen: false });
|
||||||
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
// Render
|
// Render
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
|
||||||
return (
|
const {
|
||||||
|
trackFileId,
|
||||||
|
trackFilePath
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
// TODO: Placeholder until we figure out what to show here.
|
const {
|
||||||
|
isDetailsModalOpen,
|
||||||
|
isConfirmDeleteModalOpen
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
<TableRowCell className={styles.TrackActionsCell}>
|
<TableRowCell className={styles.TrackActionsCell}>
|
||||||
<IconButton
|
{
|
||||||
name={icons.DELETE}
|
trackFilePath &&
|
||||||
title="Delete Track"
|
<IconButton
|
||||||
|
name={icons.INFO}
|
||||||
|
onPress={this.onDetailsPress}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
trackFilePath &&
|
||||||
|
<IconButton
|
||||||
|
name={icons.DELETE}
|
||||||
|
onPress={this.onDeleteFilePress}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
|
||||||
|
<FileDetailsModal
|
||||||
|
isOpen={isDetailsModalOpen}
|
||||||
|
onModalClose={this.onDetailsModalClose}
|
||||||
|
id={trackFileId}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={isConfirmDeleteModalOpen}
|
||||||
|
kind={kinds.DANGER}
|
||||||
|
title="Delete Track File"
|
||||||
|
message={`Are you sure you want to delete ${trackFilePath}?`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={this.onConfirmDelete}
|
||||||
|
onCancel={this.onConfirmDeleteModalClose}
|
||||||
|
/>
|
||||||
</TableRowCell>
|
</TableRowCell>
|
||||||
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TrackActionsCell.propTypes = {
|
TrackActionsCell.propTypes = {
|
||||||
id: PropTypes.number.isRequired,
|
id: PropTypes.number.isRequired,
|
||||||
albumId: PropTypes.number.isRequired
|
albumId: PropTypes.number.isRequired,
|
||||||
|
trackFilePath: PropTypes.string,
|
||||||
|
trackFileId: PropTypes.number.isRequired,
|
||||||
|
deleteTrackFile: PropTypes.func.isRequired
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TrackActionsCell;
|
export default TrackActionsCell;
|
||||||
|
|
|
@ -12,24 +12,6 @@ import styles from './TrackRow.css';
|
||||||
|
|
||||||
class TrackRow extends Component {
|
class TrackRow extends Component {
|
||||||
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
constructor(props, context) {
|
|
||||||
super(props, context);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
isDetailsModalOpen: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onDetailsModalClose = () => {
|
|
||||||
this.setState({ isDetailsModalOpen: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
//
|
||||||
// Render
|
// Render
|
||||||
|
|
||||||
|
@ -44,7 +26,8 @@ class TrackRow extends Component {
|
||||||
duration,
|
duration,
|
||||||
trackFilePath,
|
trackFilePath,
|
||||||
trackFileRelativePath,
|
trackFileRelativePath,
|
||||||
columns
|
columns,
|
||||||
|
deleteTrackFile
|
||||||
} = this.props;
|
} = this.props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -160,6 +143,9 @@ class TrackRow extends Component {
|
||||||
key={name}
|
key={name}
|
||||||
albumId={albumId}
|
albumId={albumId}
|
||||||
id={id}
|
id={id}
|
||||||
|
trackFilePath={trackFilePath}
|
||||||
|
trackFileId={trackFileId}
|
||||||
|
deleteTrackFile={deleteTrackFile}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -173,6 +159,7 @@ class TrackRow extends Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
TrackRow.propTypes = {
|
TrackRow.propTypes = {
|
||||||
|
deleteTrackFile: PropTypes.func.isRequired,
|
||||||
id: PropTypes.number.isRequired,
|
id: PropTypes.number.isRequired,
|
||||||
albumId: PropTypes.number.isRequired,
|
albumId: PropTypes.number.isRequired,
|
||||||
trackFileId: PropTypes.number,
|
trackFileId: PropTypes.number,
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import createTrackFileSelector from 'Store/Selectors/createTrackFileSelector';
|
import createTrackFileSelector from 'Store/Selectors/createTrackFileSelector';
|
||||||
|
import { deleteTrackFile } from 'Store/Actions/trackFileActions';
|
||||||
import TrackRow from './TrackRow';
|
import TrackRow from './TrackRow';
|
||||||
|
|
||||||
function createMapStateToProps() {
|
function createMapStateToProps() {
|
||||||
|
@ -15,4 +16,9 @@ function createMapStateToProps() {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
export default connect(createMapStateToProps)(TrackRow);
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
deleteTrackFile
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(createMapStateToProps, mapDispatchToProps)(TrackRow);
|
||||||
|
|
|
@ -9,6 +9,7 @@ import AddNewArtistConnector from 'AddArtist/AddNewArtist/AddNewArtistConnector'
|
||||||
import ImportArtist from 'AddArtist/ImportArtist/ImportArtist';
|
import ImportArtist from 'AddArtist/ImportArtist/ImportArtist';
|
||||||
import ArtistEditorConnector from 'Artist/Editor/ArtistEditorConnector';
|
import ArtistEditorConnector from 'Artist/Editor/ArtistEditorConnector';
|
||||||
import AlbumStudioConnector from 'AlbumStudio/AlbumStudioConnector';
|
import AlbumStudioConnector from 'AlbumStudio/AlbumStudioConnector';
|
||||||
|
import UnmappedFilesTableConnector from 'UnmappedFiles/UnmappedFilesTableConnector';
|
||||||
import ArtistDetailsPageConnector from 'Artist/Details/ArtistDetailsPageConnector';
|
import ArtistDetailsPageConnector from 'Artist/Details/ArtistDetailsPageConnector';
|
||||||
import AlbumDetailsPageConnector from 'Album/Details/AlbumDetailsPageConnector';
|
import AlbumDetailsPageConnector from 'Album/Details/AlbumDetailsPageConnector';
|
||||||
import CalendarPageConnector from 'Calendar/CalendarPageConnector';
|
import CalendarPageConnector from 'Calendar/CalendarPageConnector';
|
||||||
|
@ -90,6 +91,11 @@ function AppRoutes(props) {
|
||||||
component={AlbumStudioConnector}
|
component={AlbumStudioConnector}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/unmapped"
|
||||||
|
component={UnmappedFilesTableConnector}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="/artist/:foreignArtistId"
|
path="/artist/:foreignArtistId"
|
||||||
component={ArtistDetailsPageConnector}
|
component={ArtistDetailsPageConnector}
|
||||||
|
|
|
@ -20,7 +20,7 @@ const SIDEBAR_WIDTH = parseInt(dimensions.sidebarWidth);
|
||||||
const links = [
|
const links = [
|
||||||
{
|
{
|
||||||
iconName: icons.ARTIST_CONTINUING,
|
iconName: icons.ARTIST_CONTINUING,
|
||||||
title: 'Artist',
|
title: 'Library',
|
||||||
to: '/',
|
to: '/',
|
||||||
alias: '/artist',
|
alias: '/artist',
|
||||||
children: [
|
children: [
|
||||||
|
@ -39,6 +39,10 @@ const links = [
|
||||||
{
|
{
|
||||||
title: 'Album Studio',
|
title: 'Album Studio',
|
||||||
to: '/albumstudio'
|
to: '/albumstudio'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Unmapped Files',
|
||||||
|
to: '/unmapped'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
@ -208,12 +208,12 @@ class SignalRConnector extends Component {
|
||||||
|
|
||||||
if (body.action === 'updated') {
|
if (body.action === 'updated') {
|
||||||
this.props.dispatchUpdateItem({ section, ...body.resource });
|
this.props.dispatchUpdateItem({ section, ...body.resource });
|
||||||
|
|
||||||
// Repopulate the page to handle recently imported file
|
|
||||||
repopulatePage('trackFileUpdated');
|
|
||||||
} else if (body.action === 'deleted') {
|
} else if (body.action === 'deleted') {
|
||||||
this.props.dispatchRemoveItem({ section, id: body.resource.id });
|
this.props.dispatchRemoveItem({ section, id: body.resource.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Repopulate the page to handle recently imported file
|
||||||
|
repopulatePage('trackFileUpdated');
|
||||||
}
|
}
|
||||||
|
|
||||||
handleHealth = () => {
|
handleHealth = () => {
|
||||||
|
|
|
@ -1,258 +0,0 @@
|
||||||
import _ from 'lodash';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import { icons } from 'Helpers/Props';
|
|
||||||
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
|
|
||||||
import Icon from 'Components/Icon';
|
|
||||||
import Link from 'Components/Link/Link';
|
|
||||||
import DescriptionList from 'Components/DescriptionList/DescriptionList';
|
|
||||||
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
|
|
||||||
import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
|
|
||||||
import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
|
|
||||||
import styles from './FileDetails.css';
|
|
||||||
|
|
||||||
class FileDetails extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
constructor(props, context) {
|
|
||||||
super(props, context);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
isExpanded: props.isExpanded
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onExpandPress = () => {
|
|
||||||
const {
|
|
||||||
isExpanded
|
|
||||||
} = this.state;
|
|
||||||
this.setState({ isExpanded: !isExpanded });
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
renderRejections() {
|
|
||||||
const {
|
|
||||||
rejections
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<span>
|
|
||||||
<DescriptionListItemTitle>
|
|
||||||
Rejections
|
|
||||||
</DescriptionListItemTitle>
|
|
||||||
{
|
|
||||||
_.map(rejections, (item, key) => {
|
|
||||||
return (
|
|
||||||
<DescriptionListItemDescription key={key}>
|
|
||||||
{item.reason}
|
|
||||||
</DescriptionListItemDescription>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
filename,
|
|
||||||
audioTags,
|
|
||||||
rejections
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
const {
|
|
||||||
isExpanded
|
|
||||||
} = this.state;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={styles.fileDetails}
|
|
||||||
>
|
|
||||||
<div className={styles.header} onClick={this.onExpandPress}>
|
|
||||||
<div className={styles.filename}>
|
|
||||||
{filename}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.expandButton}>
|
|
||||||
<Icon
|
|
||||||
className={styles.expandButtonIcon}
|
|
||||||
name={isExpanded ? icons.COLLAPSE : icons.EXPAND}
|
|
||||||
title={isExpanded ? 'Hide file info' : 'Show file info'}
|
|
||||||
size={24}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{
|
|
||||||
isExpanded &&
|
|
||||||
<div className={styles.audioTags}>
|
|
||||||
|
|
||||||
<DescriptionList>
|
|
||||||
{
|
|
||||||
audioTags.title !== undefined &&
|
|
||||||
<DescriptionListItem
|
|
||||||
title="Track Title"
|
|
||||||
data={audioTags.title}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
audioTags.trackNumbers[0] > 0 &&
|
|
||||||
<DescriptionListItem
|
|
||||||
title="Track Number"
|
|
||||||
data={audioTags.trackNumbers[0]}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
audioTags.discNumber > 0 &&
|
|
||||||
<DescriptionListItem
|
|
||||||
title="Disc Number"
|
|
||||||
data={audioTags.discNumber}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
audioTags.discCount > 0 &&
|
|
||||||
<DescriptionListItem
|
|
||||||
title="Disc Count"
|
|
||||||
data={audioTags.discCount}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
audioTags.albumTitle !== undefined &&
|
|
||||||
<DescriptionListItem
|
|
||||||
title="Album"
|
|
||||||
data={audioTags.albumTitle}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
audioTags.artistTitle !== undefined &&
|
|
||||||
<DescriptionListItem
|
|
||||||
title="Artist"
|
|
||||||
data={audioTags.artistTitle}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
audioTags.country !== undefined &&
|
|
||||||
<DescriptionListItem
|
|
||||||
title="Country"
|
|
||||||
data={audioTags.country.name}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
audioTags.year > 0 &&
|
|
||||||
<DescriptionListItem
|
|
||||||
title="Year"
|
|
||||||
data={audioTags.year}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
audioTags.label !== undefined &&
|
|
||||||
<DescriptionListItem
|
|
||||||
title="Label"
|
|
||||||
data={audioTags.label}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
audioTags.catalogNumber !== undefined &&
|
|
||||||
<DescriptionListItem
|
|
||||||
title="Catalog Number"
|
|
||||||
data={audioTags.catalogNumber}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
audioTags.disambiguation !== undefined &&
|
|
||||||
<DescriptionListItem
|
|
||||||
title="Disambiguation"
|
|
||||||
data={audioTags.disambiguation}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
audioTags.duration !== undefined &&
|
|
||||||
<DescriptionListItem
|
|
||||||
title="Duration"
|
|
||||||
data={formatTimeSpan(audioTags.duration)}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
audioTags.artistMBId !== undefined &&
|
|
||||||
<Link
|
|
||||||
to={`https://musicbrainz.org/artist/${audioTags.artistMBId}`}
|
|
||||||
>
|
|
||||||
<DescriptionListItem
|
|
||||||
title="MusicBrainz Artist ID"
|
|
||||||
data={audioTags.artistMBId}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
audioTags.albumMBId !== undefined &&
|
|
||||||
<Link
|
|
||||||
to={`https://musicbrainz.org/release-group/${audioTags.albumMBId}`}
|
|
||||||
>
|
|
||||||
<DescriptionListItem
|
|
||||||
title="MusicBrainz Album ID"
|
|
||||||
data={audioTags.albumMBId}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
audioTags.releaseMBId !== undefined &&
|
|
||||||
<Link
|
|
||||||
to={`https://musicbrainz.org/release/${audioTags.releaseMBId}`}
|
|
||||||
>
|
|
||||||
<DescriptionListItem
|
|
||||||
title="MusicBrainz Release ID"
|
|
||||||
data={audioTags.releaseMBId}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
audioTags.recordingMBId !== undefined &&
|
|
||||||
<Link
|
|
||||||
to={`https://musicbrainz.org/recording/${audioTags.recordingMBId}`}
|
|
||||||
>
|
|
||||||
<DescriptionListItem
|
|
||||||
title="MusicBrainz Recording ID"
|
|
||||||
data={audioTags.recordingMBId}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
audioTags.trackMBId !== undefined &&
|
|
||||||
<Link
|
|
||||||
to={`https://musicbrainz.org/track/${audioTags.trackMBId}`}
|
|
||||||
>
|
|
||||||
<DescriptionListItem
|
|
||||||
title="MusicBrainz Track ID"
|
|
||||||
data={audioTags.trackMBId}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
{
|
|
||||||
rejections.length > 0 &&
|
|
||||||
this.renderRejections()
|
|
||||||
}
|
|
||||||
</DescriptionList>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FileDetails.propTypes = {
|
|
||||||
audioTags: PropTypes.object.isRequired,
|
|
||||||
filename: PropTypes.string.isRequired,
|
|
||||||
rejections: PropTypes.arrayOf(PropTypes.object).isRequired,
|
|
||||||
isExpanded: PropTypes.bool
|
|
||||||
};
|
|
||||||
|
|
||||||
export default FileDetails;
|
|
|
@ -15,7 +15,7 @@ import ModalFooter from 'Components/Modal/ModalFooter';
|
||||||
import Table from 'Components/Table/Table';
|
import Table from 'Components/Table/Table';
|
||||||
import TableBody from 'Components/Table/TableBody';
|
import TableBody from 'Components/Table/TableBody';
|
||||||
import SelectTrackRow from './SelectTrackRow';
|
import SelectTrackRow from './SelectTrackRow';
|
||||||
import FileDetails from '../FileDetails';
|
import ExpandingFileDetails from 'TrackFile/ExpandingFileDetails';
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
|
@ -151,7 +151,7 @@ class SelectTrackModalContent extends Component {
|
||||||
<div>{errorMessage}</div>
|
<div>{errorMessage}</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<FileDetails
|
<ExpandingFileDetails
|
||||||
audioTags={audioTags}
|
audioTags={audioTags}
|
||||||
filename={filename}
|
filename={filename}
|
||||||
rejections={rejections}
|
rejections={rejections}
|
||||||
|
|
|
@ -47,7 +47,7 @@ export const defaultState = {
|
||||||
},
|
},
|
||||||
|
|
||||||
quality: function(item, direction) {
|
quality: function(item, direction) {
|
||||||
return item.quality ? item.quality.qualityWeight : 0;
|
return item.quality ? item.qualityWeight : 0;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -2,7 +2,11 @@ import _ from 'lodash';
|
||||||
import { createAction } from 'redux-actions';
|
import { createAction } from 'redux-actions';
|
||||||
import { batchActions } from 'redux-batched-actions';
|
import { batchActions } from 'redux-batched-actions';
|
||||||
import createAjaxRequest from 'Utilities/createAjaxRequest';
|
import createAjaxRequest from 'Utilities/createAjaxRequest';
|
||||||
|
import { sortDirections } from 'Helpers/Props';
|
||||||
import { createThunk, handleThunks } from 'Store/thunks';
|
import { createThunk, handleThunks } from 'Store/thunks';
|
||||||
|
import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer';
|
||||||
|
import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer';
|
||||||
|
import createClearReducer from './Creators/Reducers/createClearReducer';
|
||||||
import albumEntities from 'Album/albumEntities';
|
import albumEntities from 'Album/albumEntities';
|
||||||
import createFetchHandler from './Creators/createFetchHandler';
|
import createFetchHandler from './Creators/createFetchHandler';
|
||||||
import createHandleActions from './Creators/createHandleActions';
|
import createHandleActions from './Creators/createHandleActions';
|
||||||
|
@ -20,14 +24,62 @@ export const section = 'trackFiles';
|
||||||
export const defaultState = {
|
export const defaultState = {
|
||||||
isFetching: false,
|
isFetching: false,
|
||||||
isPopulated: false,
|
isPopulated: false,
|
||||||
|
sortKey: 'path',
|
||||||
|
sortDirection: sortDirections.ASCENDING,
|
||||||
|
|
||||||
error: null,
|
error: null,
|
||||||
isDeleting: false,
|
isDeleting: false,
|
||||||
deleteError: null,
|
deleteError: null,
|
||||||
isSaving: false,
|
isSaving: false,
|
||||||
saveError: null,
|
saveError: null,
|
||||||
items: []
|
items: [],
|
||||||
|
|
||||||
|
sortPredicates: {
|
||||||
|
quality: function(item, direction) {
|
||||||
|
return item.quality ? item.qualityWeight : 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
name: 'path',
|
||||||
|
label: 'Path',
|
||||||
|
isSortable: true,
|
||||||
|
isVisible: true,
|
||||||
|
isModifiable: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'size',
|
||||||
|
label: 'Size',
|
||||||
|
isSortable: true,
|
||||||
|
isVisible: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'dateAdded',
|
||||||
|
label: 'Date Added',
|
||||||
|
isSortable: true,
|
||||||
|
isVisible: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'quality',
|
||||||
|
label: 'Quality',
|
||||||
|
isSortable: true,
|
||||||
|
isVisible: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'actions',
|
||||||
|
columnLabel: 'Actions',
|
||||||
|
isVisible: true,
|
||||||
|
isModifiable: false
|
||||||
|
}
|
||||||
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const persistState = [
|
||||||
|
'trackFiles.sortKey',
|
||||||
|
'trackFiles.sortDirection'
|
||||||
|
];
|
||||||
|
|
||||||
//
|
//
|
||||||
// Actions Types
|
// Actions Types
|
||||||
|
|
||||||
|
@ -35,6 +87,8 @@ export const FETCH_TRACK_FILES = 'trackFiles/fetchTrackFiles';
|
||||||
export const DELETE_TRACK_FILE = 'trackFiles/deleteTrackFile';
|
export const DELETE_TRACK_FILE = 'trackFiles/deleteTrackFile';
|
||||||
export const DELETE_TRACK_FILES = 'trackFiles/deleteTrackFiles';
|
export const DELETE_TRACK_FILES = 'trackFiles/deleteTrackFiles';
|
||||||
export const UPDATE_TRACK_FILES = 'trackFiles/updateTrackFiles';
|
export const UPDATE_TRACK_FILES = 'trackFiles/updateTrackFiles';
|
||||||
|
export const SET_TRACK_FILES_SORT = 'trackFiles/setTrackFilesSort';
|
||||||
|
export const SET_TRACK_FILES_TABLE_OPTION = 'trackFiles/setTrackFilesTableOption';
|
||||||
export const CLEAR_TRACK_FILES = 'trackFiles/clearTrackFiles';
|
export const CLEAR_TRACK_FILES = 'trackFiles/clearTrackFiles';
|
||||||
|
|
||||||
//
|
//
|
||||||
|
@ -44,6 +98,8 @@ export const fetchTrackFiles = createThunk(FETCH_TRACK_FILES);
|
||||||
export const deleteTrackFile = createThunk(DELETE_TRACK_FILE);
|
export const deleteTrackFile = createThunk(DELETE_TRACK_FILE);
|
||||||
export const deleteTrackFiles = createThunk(DELETE_TRACK_FILES);
|
export const deleteTrackFiles = createThunk(DELETE_TRACK_FILES);
|
||||||
export const updateTrackFiles = createThunk(UPDATE_TRACK_FILES);
|
export const updateTrackFiles = createThunk(UPDATE_TRACK_FILES);
|
||||||
|
export const setTrackFilesSort = createAction(SET_TRACK_FILES_SORT);
|
||||||
|
export const setTrackFilesTableOption = createAction(SET_TRACK_FILES_TABLE_OPTION);
|
||||||
export const clearTrackFiles = createAction(CLEAR_TRACK_FILES);
|
export const clearTrackFiles = createAction(CLEAR_TRACK_FILES);
|
||||||
|
|
||||||
//
|
//
|
||||||
|
@ -193,9 +249,14 @@ export const actionHandlers = handleThunks({
|
||||||
// Reducers
|
// Reducers
|
||||||
|
|
||||||
export const reducers = createHandleActions({
|
export const reducers = createHandleActions({
|
||||||
|
[SET_TRACK_FILES_SORT]: createSetClientSideCollectionSortReducer(section),
|
||||||
|
[SET_TRACK_FILES_TABLE_OPTION]: createSetTableOptionReducer(section),
|
||||||
|
|
||||||
[CLEAR_TRACK_FILES]: (state) => {
|
[CLEAR_TRACK_FILES]: createClearReducer(section, {
|
||||||
return Object.assign({}, state, defaultState);
|
isFetching: false,
|
||||||
}
|
isPopulated: false,
|
||||||
|
error: null,
|
||||||
|
items: []
|
||||||
|
})
|
||||||
|
|
||||||
}, defaultState, section);
|
}, defaultState, section);
|
||||||
|
|
|
@ -9,6 +9,14 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.filename {
|
||||||
|
flex-grow: 1;
|
||||||
|
margin-right: 10px;
|
||||||
|
margin-left: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: $monoSpaceFontFamily;
|
||||||
|
}
|
||||||
|
|
||||||
.header {
|
.header {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -17,12 +25,6 @@
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filename {
|
|
||||||
flex-grow: 1;
|
|
||||||
margin-right: 10px;
|
|
||||||
margin-left: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expandButton {
|
.expandButton {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 60px;
|
width: 60px;
|
||||||
|
@ -35,12 +37,6 @@
|
||||||
width: 30px;
|
width: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.audioTags {
|
|
||||||
padding-top: 15px;
|
|
||||||
padding-bottom: 15px;
|
|
||||||
border-top: 1px solid $borderColor;
|
|
||||||
}
|
|
||||||
|
|
||||||
.expandButtonIcon {
|
.expandButtonIcon {
|
||||||
composes: actionButton;
|
composes: actionButton;
|
||||||
|
|
|
@ -0,0 +1,83 @@
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { icons } from 'Helpers/Props';
|
||||||
|
import Icon from 'Components/Icon';
|
||||||
|
import FileDetails from './FileDetails';
|
||||||
|
import styles from './ExpandingFileDetails.css';
|
||||||
|
|
||||||
|
class ExpandingFileDetails extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
isExpanded: props.isExpanded
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onExpandPress = () => {
|
||||||
|
const {
|
||||||
|
isExpanded
|
||||||
|
} = this.state;
|
||||||
|
this.setState({ isExpanded: !isExpanded });
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
filename,
|
||||||
|
audioTags,
|
||||||
|
rejections
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
isExpanded
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={styles.fileDetails}
|
||||||
|
>
|
||||||
|
<div className={styles.header} onClick={this.onExpandPress}>
|
||||||
|
<div className={styles.filename}>
|
||||||
|
{filename}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.expandButton}>
|
||||||
|
<Icon
|
||||||
|
className={styles.expandButtonIcon}
|
||||||
|
name={isExpanded ? icons.COLLAPSE : icons.EXPAND}
|
||||||
|
title={isExpanded ? 'Hide file info' : 'Show file info'}
|
||||||
|
size={24}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{
|
||||||
|
isExpanded &&
|
||||||
|
<FileDetails
|
||||||
|
audioTags={audioTags}
|
||||||
|
rejections={rejections}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ExpandingFileDetails.propTypes = {
|
||||||
|
audioTags: PropTypes.object.isRequired,
|
||||||
|
filename: PropTypes.string.isRequired,
|
||||||
|
rejections: PropTypes.arrayOf(PropTypes.object),
|
||||||
|
isExpanded: PropTypes.bool
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ExpandingFileDetails;
|
|
@ -0,0 +1,11 @@
|
||||||
|
.audioTags {
|
||||||
|
padding-top: 15px;
|
||||||
|
padding-bottom: 15px;
|
||||||
|
/* border-top: 1px solid $borderColor; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.filename {
|
||||||
|
composes: description from '~Components/DescriptionList/DescriptionListItemDescription.css';
|
||||||
|
|
||||||
|
font-family: $monoSpaceFontFamily;
|
||||||
|
}
|
|
@ -0,0 +1,206 @@
|
||||||
|
import _ from 'lodash';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Fragment } from 'react';
|
||||||
|
import formatTimeSpan from 'Utilities/Date/formatTimeSpan';
|
||||||
|
import Link from 'Components/Link/Link';
|
||||||
|
import DescriptionList from 'Components/DescriptionList/DescriptionList';
|
||||||
|
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
|
||||||
|
import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
|
||||||
|
import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
|
||||||
|
import styles from './FileDetails.css';
|
||||||
|
|
||||||
|
function renderRejections(rejections) {
|
||||||
|
return (
|
||||||
|
<span>
|
||||||
|
<DescriptionListItemTitle>
|
||||||
|
Rejections
|
||||||
|
</DescriptionListItemTitle>
|
||||||
|
{
|
||||||
|
_.map(rejections, (item, key) => {
|
||||||
|
return (
|
||||||
|
<DescriptionListItemDescription key={key}>
|
||||||
|
{item.reason}
|
||||||
|
</DescriptionListItemDescription>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FileDetails(props) {
|
||||||
|
|
||||||
|
const {
|
||||||
|
filename,
|
||||||
|
audioTags,
|
||||||
|
rejections
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fragment>
|
||||||
|
<div className={styles.audioTags}>
|
||||||
|
<DescriptionList>
|
||||||
|
{
|
||||||
|
filename &&
|
||||||
|
<DescriptionListItem
|
||||||
|
title="Filename"
|
||||||
|
data={filename}
|
||||||
|
descriptionClassName={styles.filename}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
audioTags.title !== undefined &&
|
||||||
|
<DescriptionListItem
|
||||||
|
title="Track Title"
|
||||||
|
data={audioTags.title}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
audioTags.trackNumbers[0] > 0 &&
|
||||||
|
<DescriptionListItem
|
||||||
|
title="Track Number"
|
||||||
|
data={audioTags.trackNumbers[0]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
audioTags.discNumber > 0 &&
|
||||||
|
<DescriptionListItem
|
||||||
|
title="Disc Number"
|
||||||
|
data={audioTags.discNumber}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
audioTags.discCount > 0 &&
|
||||||
|
<DescriptionListItem
|
||||||
|
title="Disc Count"
|
||||||
|
data={audioTags.discCount}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
audioTags.albumTitle !== undefined &&
|
||||||
|
<DescriptionListItem
|
||||||
|
title="Album"
|
||||||
|
data={audioTags.albumTitle}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
audioTags.artistTitle !== undefined &&
|
||||||
|
<DescriptionListItem
|
||||||
|
title="Artist"
|
||||||
|
data={audioTags.artistTitle}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
audioTags.country !== undefined &&
|
||||||
|
<DescriptionListItem
|
||||||
|
title="Country"
|
||||||
|
data={audioTags.country.name}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
audioTags.year > 0 &&
|
||||||
|
<DescriptionListItem
|
||||||
|
title="Year"
|
||||||
|
data={audioTags.year}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
audioTags.label !== undefined &&
|
||||||
|
<DescriptionListItem
|
||||||
|
title="Label"
|
||||||
|
data={audioTags.label}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
audioTags.catalogNumber !== undefined &&
|
||||||
|
<DescriptionListItem
|
||||||
|
title="Catalog Number"
|
||||||
|
data={audioTags.catalogNumber}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
audioTags.disambiguation !== undefined &&
|
||||||
|
<DescriptionListItem
|
||||||
|
title="Disambiguation"
|
||||||
|
data={audioTags.disambiguation}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
audioTags.duration !== undefined &&
|
||||||
|
<DescriptionListItem
|
||||||
|
title="Duration"
|
||||||
|
data={formatTimeSpan(audioTags.duration)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
audioTags.artistMBId !== undefined &&
|
||||||
|
<Link
|
||||||
|
to={`https://musicbrainz.org/artist/${audioTags.artistMBId}`}
|
||||||
|
>
|
||||||
|
<DescriptionListItem
|
||||||
|
title="MusicBrainz Artist ID"
|
||||||
|
data={audioTags.artistMBId}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
audioTags.albumMBId !== undefined &&
|
||||||
|
<Link
|
||||||
|
to={`https://musicbrainz.org/release-group/${audioTags.albumMBId}`}
|
||||||
|
>
|
||||||
|
<DescriptionListItem
|
||||||
|
title="MusicBrainz Album ID"
|
||||||
|
data={audioTags.albumMBId}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
audioTags.releaseMBId !== undefined &&
|
||||||
|
<Link
|
||||||
|
to={`https://musicbrainz.org/release/${audioTags.releaseMBId}`}
|
||||||
|
>
|
||||||
|
<DescriptionListItem
|
||||||
|
title="MusicBrainz Release ID"
|
||||||
|
data={audioTags.releaseMBId}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
audioTags.recordingMBId !== undefined &&
|
||||||
|
<Link
|
||||||
|
to={`https://musicbrainz.org/recording/${audioTags.recordingMBId}`}
|
||||||
|
>
|
||||||
|
<DescriptionListItem
|
||||||
|
title="MusicBrainz Recording ID"
|
||||||
|
data={audioTags.recordingMBId}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
audioTags.trackMBId !== undefined &&
|
||||||
|
<Link
|
||||||
|
to={`https://musicbrainz.org/track/${audioTags.trackMBId}`}
|
||||||
|
>
|
||||||
|
<DescriptionListItem
|
||||||
|
title="MusicBrainz Track ID"
|
||||||
|
data={audioTags.trackMBId}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
!!rejections && rejections.length > 0 &&
|
||||||
|
renderRejections(rejections)
|
||||||
|
}
|
||||||
|
</DescriptionList>
|
||||||
|
</div>
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
FileDetails.propTypes = {
|
||||||
|
filename: PropTypes.string,
|
||||||
|
audioTags: PropTypes.object.isRequired,
|
||||||
|
rejections: PropTypes.arrayOf(PropTypes.object)
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileDetails;
|
|
@ -0,0 +1,77 @@
|
||||||
|
import _ from 'lodash';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import getErrorMessage from 'Utilities/Object/getErrorMessage';
|
||||||
|
import { fetchTrackFiles } from 'Store/Actions/trackFileActions';
|
||||||
|
import FileDetails from './FileDetails';
|
||||||
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
|
|
||||||
|
function createMapStateToProps() {
|
||||||
|
return createSelector(
|
||||||
|
(state) => state.trackFiles,
|
||||||
|
(trackFiles) => {
|
||||||
|
return {
|
||||||
|
...trackFiles
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
fetchTrackFiles
|
||||||
|
};
|
||||||
|
|
||||||
|
class FileDetailsConnector extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.props.fetchTrackFiles({ id: this.props.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
id,
|
||||||
|
isFetching,
|
||||||
|
error
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const item = _.find(items, { id });
|
||||||
|
const errorMessage = getErrorMessage(error, 'Unable to load manual import items');
|
||||||
|
|
||||||
|
if (isFetching || !item.audioTags) {
|
||||||
|
return (
|
||||||
|
<LoadingIndicator />
|
||||||
|
);
|
||||||
|
} else if (error) {
|
||||||
|
return (
|
||||||
|
<div>{errorMessage}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FileDetails
|
||||||
|
audioTags={item.audioTags}
|
||||||
|
filename={item.path}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FileDetailsConnector.propTypes = {
|
||||||
|
fetchTrackFiles: PropTypes.func.isRequired,
|
||||||
|
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
id: PropTypes.number.isRequired,
|
||||||
|
isFetching: PropTypes.bool.isRequired,
|
||||||
|
error: PropTypes.object
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(createMapStateToProps, mapDispatchToProps)(FileDetailsConnector);
|
|
@ -0,0 +1,52 @@
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React from 'react';
|
||||||
|
import FileDetailsConnector from './FileDetailsConnector';
|
||||||
|
import Button from 'Components/Link/Button';
|
||||||
|
import Modal from 'Components/Modal/Modal';
|
||||||
|
import ModalContent from 'Components/Modal/ModalContent';
|
||||||
|
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||||
|
import ModalBody from 'Components/Modal/ModalBody';
|
||||||
|
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||||
|
|
||||||
|
function FileDetailsModal(props) {
|
||||||
|
const {
|
||||||
|
isOpen,
|
||||||
|
onModalClose,
|
||||||
|
id
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
>
|
||||||
|
<ModalContent
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
>
|
||||||
|
<ModalHeader>
|
||||||
|
Details
|
||||||
|
</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody>
|
||||||
|
<FileDetailsConnector
|
||||||
|
id={id}
|
||||||
|
/>
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Button onPress={onModalClose}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
FileDetailsModal.propTypes = {
|
||||||
|
isOpen: PropTypes.bool.isRequired,
|
||||||
|
onModalClose: PropTypes.func.isRequired,
|
||||||
|
id: PropTypes.number.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileDetailsModal;
|
|
@ -0,0 +1,161 @@
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { align, icons, sortDirections } from 'Helpers/Props';
|
||||||
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
|
import VirtualTable from 'Components/Table/VirtualTable';
|
||||||
|
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
||||||
|
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 PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||||
|
import UnmappedFilesTableRow from './UnmappedFilesTableRow';
|
||||||
|
import UnmappedFilesTableHeader from './UnmappedFilesTableHeader';
|
||||||
|
|
||||||
|
class UnmappedFilesTable extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
contentBody: null,
|
||||||
|
scrollTop: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Control
|
||||||
|
|
||||||
|
setContentBodyRef = (ref) => {
|
||||||
|
this.setState({ contentBody: ref });
|
||||||
|
}
|
||||||
|
|
||||||
|
rowRenderer = ({ key, rowIndex, style }) => {
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
columns,
|
||||||
|
deleteUnmappedFile
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const item = items[rowIndex];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UnmappedFilesTableRow
|
||||||
|
style={style}
|
||||||
|
key={key}
|
||||||
|
columns={columns}
|
||||||
|
deleteUnmappedFile={deleteUnmappedFile}
|
||||||
|
{...item}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onScroll = ({ scrollTop }) => {
|
||||||
|
this.setState({ scrollTop });
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
|
||||||
|
const {
|
||||||
|
isFetching,
|
||||||
|
isPopulated,
|
||||||
|
error,
|
||||||
|
items,
|
||||||
|
columns,
|
||||||
|
sortKey,
|
||||||
|
sortDirection,
|
||||||
|
onTableOptionChange,
|
||||||
|
onSortPress,
|
||||||
|
deleteUnmappedFile,
|
||||||
|
...otherProps
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const {
|
||||||
|
scrollTop,
|
||||||
|
contentBody
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContent title="UnmappedFiles">
|
||||||
|
<PageToolbar>
|
||||||
|
<PageToolbarSection alignContent={align.RIGHT}>
|
||||||
|
<TableOptionsModalWrapper
|
||||||
|
{...otherProps}
|
||||||
|
columns={columns}
|
||||||
|
onTableOptionChange={onTableOptionChange}
|
||||||
|
>
|
||||||
|
<PageToolbarButton
|
||||||
|
label="Options"
|
||||||
|
iconName={icons.TABLE}
|
||||||
|
/>
|
||||||
|
</TableOptionsModalWrapper>
|
||||||
|
|
||||||
|
</PageToolbarSection>
|
||||||
|
</PageToolbar>
|
||||||
|
|
||||||
|
<PageContentBodyConnector
|
||||||
|
ref={this.setContentBodyRef}
|
||||||
|
onScroll={this.onScroll}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
isFetching && !isPopulated &&
|
||||||
|
<LoadingIndicator />
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
isPopulated && !error && !items.length &&
|
||||||
|
<div>
|
||||||
|
Success! My work is done, all files on disk are matched to known tracks.
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
isPopulated && !error && !!items.length && contentBody &&
|
||||||
|
<VirtualTable
|
||||||
|
items={items}
|
||||||
|
columns={columns}
|
||||||
|
contentBody={contentBody}
|
||||||
|
isSmallScreen={false}
|
||||||
|
scrollTop={scrollTop}
|
||||||
|
onScroll={this.onScroll}
|
||||||
|
overscanRowCount={10}
|
||||||
|
rowRenderer={this.rowRenderer}
|
||||||
|
header={
|
||||||
|
<UnmappedFilesTableHeader
|
||||||
|
columns={columns}
|
||||||
|
sortKey={sortKey}
|
||||||
|
sortDirection={sortDirection}
|
||||||
|
onTableOptionChange={onTableOptionChange}
|
||||||
|
onSortPress={onSortPress}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
sortKey={sortKey}
|
||||||
|
sortDirection={sortDirection}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</PageContentBodyConnector>
|
||||||
|
</PageContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
UnmappedFilesTable.propTypes = {
|
||||||
|
isFetching: PropTypes.bool.isRequired,
|
||||||
|
isPopulated: PropTypes.bool.isRequired,
|
||||||
|
error: PropTypes.object,
|
||||||
|
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
sortKey: PropTypes.string,
|
||||||
|
sortDirection: PropTypes.oneOf(sortDirections.all),
|
||||||
|
onTableOptionChange: PropTypes.func.isRequired,
|
||||||
|
onSortPress: PropTypes.func.isRequired,
|
||||||
|
deleteUnmappedFile: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UnmappedFilesTable;
|
|
@ -0,0 +1,100 @@
|
||||||
|
import _ from 'lodash';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { createSelector } from 'reselect';
|
||||||
|
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
|
||||||
|
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
|
||||||
|
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||||
|
import { fetchTrackFiles, deleteTrackFile, setTrackFilesSort, setTrackFilesTableOption } from 'Store/Actions/trackFileActions';
|
||||||
|
import withCurrentPage from 'Components/withCurrentPage';
|
||||||
|
import UnmappedFilesTable from './UnmappedFilesTable';
|
||||||
|
|
||||||
|
function createMapStateToProps() {
|
||||||
|
return createSelector(
|
||||||
|
createClientSideCollectionSelector('trackFiles'),
|
||||||
|
createDimensionsSelector(),
|
||||||
|
(
|
||||||
|
trackFiles,
|
||||||
|
dimensionsState
|
||||||
|
) => {
|
||||||
|
// trackFiles could pick up mapped entries via signalR so filter again here
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
...otherProps
|
||||||
|
} = trackFiles;
|
||||||
|
const unmappedFiles = _.filter(items, { albumId: 0 });
|
||||||
|
return {
|
||||||
|
items: unmappedFiles,
|
||||||
|
...otherProps,
|
||||||
|
isSmallScreen: dimensionsState.isSmallScreen
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMapDispatchToProps(dispatch, props) {
|
||||||
|
return {
|
||||||
|
onTableOptionChange(payload) {
|
||||||
|
dispatch(setTrackFilesTableOption(payload));
|
||||||
|
},
|
||||||
|
|
||||||
|
onSortPress(sortKey) {
|
||||||
|
dispatch(setTrackFilesSort({ sortKey }));
|
||||||
|
},
|
||||||
|
|
||||||
|
fetchUnmappedFiles() {
|
||||||
|
dispatch(fetchTrackFiles({ unmapped: true }));
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteUnmappedFile(id) {
|
||||||
|
dispatch(deleteTrackFile({ id }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class UnmappedFilesTableConnector extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
registerPagePopulator(this.repopulate, ['trackFileUpdated']);
|
||||||
|
|
||||||
|
this.repopulate();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
unregisterPagePopulator(this.repopulate);
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Control
|
||||||
|
|
||||||
|
repopulate = () => {
|
||||||
|
this.props.fetchUnmappedFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<UnmappedFilesTable
|
||||||
|
{...this.props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
UnmappedFilesTableConnector.propTypes = {
|
||||||
|
isSmallScreen: PropTypes.bool.isRequired,
|
||||||
|
onSortPress: PropTypes.func.isRequired,
|
||||||
|
onTableOptionChange: PropTypes.func.isRequired,
|
||||||
|
fetchUnmappedFiles: PropTypes.func.isRequired,
|
||||||
|
deleteUnmappedFile: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default withCurrentPage(
|
||||||
|
connect(createMapStateToProps, createMapDispatchToProps)(UnmappedFilesTableConnector)
|
||||||
|
);
|
|
@ -0,0 +1,19 @@
|
||||||
|
.quality,
|
||||||
|
.size,
|
||||||
|
.dateAdded {
|
||||||
|
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
|
||||||
|
|
||||||
|
flex: 0 0 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.path {
|
||||||
|
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
|
||||||
|
|
||||||
|
flex: 4 0 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css';
|
||||||
|
|
||||||
|
flex: 0 1 90px;
|
||||||
|
}
|
|
@ -0,0 +1,77 @@
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React from 'react';
|
||||||
|
import { icons } from 'Helpers/Props';
|
||||||
|
import IconButton from 'Components/Link/IconButton';
|
||||||
|
import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
|
||||||
|
import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
|
||||||
|
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
||||||
|
// import hasGrowableColumns from './hasGrowableColumns';
|
||||||
|
import styles from './UnmappedFilesTableHeader.css';
|
||||||
|
|
||||||
|
function UnmappedFilesTableHeader(props) {
|
||||||
|
const {
|
||||||
|
columns,
|
||||||
|
onTableOptionChange,
|
||||||
|
...otherProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VirtualTableHeader>
|
||||||
|
{
|
||||||
|
columns.map((column) => {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
isSortable,
|
||||||
|
isVisible
|
||||||
|
} = column;
|
||||||
|
|
||||||
|
if (!isVisible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'actions') {
|
||||||
|
return (
|
||||||
|
<VirtualTableHeaderCell
|
||||||
|
key={name}
|
||||||
|
className={styles[name]}
|
||||||
|
name={name}
|
||||||
|
isSortable={false}
|
||||||
|
{...otherProps}
|
||||||
|
>
|
||||||
|
|
||||||
|
<TableOptionsModalWrapper
|
||||||
|
columns={columns}
|
||||||
|
onTableOptionChange={onTableOptionChange}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
name={icons.ADVANCED_SETTINGS}
|
||||||
|
/>
|
||||||
|
</TableOptionsModalWrapper>
|
||||||
|
</VirtualTableHeaderCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VirtualTableHeaderCell
|
||||||
|
key={name}
|
||||||
|
className={styles[name]}
|
||||||
|
name={name}
|
||||||
|
isSortable={isSortable}
|
||||||
|
{...otherProps}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</VirtualTableHeaderCell>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</VirtualTableHeader>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
UnmappedFilesTableHeader.propTypes = {
|
||||||
|
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
onTableOptionChange: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UnmappedFilesTableHeader;
|
|
@ -0,0 +1,22 @@
|
||||||
|
.path {
|
||||||
|
composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
|
||||||
|
|
||||||
|
flex: 4 0 400px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: $monoSpaceFontFamily;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality,
|
||||||
|
.dateAdded,
|
||||||
|
.size {
|
||||||
|
composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
|
||||||
|
|
||||||
|
flex: 0 0 120px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css';
|
||||||
|
|
||||||
|
flex: 0 0 90px;
|
||||||
|
}
|
|
@ -0,0 +1,218 @@
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import { icons, kinds } from 'Helpers/Props';
|
||||||
|
import formatBytes from 'Utilities/Number/formatBytes';
|
||||||
|
import IconButton from 'Components/Link/IconButton';
|
||||||
|
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||||
|
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
|
||||||
|
import VirtualTableRow from 'Components/Table/VirtualTableRow';
|
||||||
|
import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell';
|
||||||
|
import TrackQuality from 'Album/TrackQuality';
|
||||||
|
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
|
||||||
|
import FileDetailsModal from 'TrackFile/FileDetailsModal';
|
||||||
|
import styles from './UnmappedFilesTableRow.css';
|
||||||
|
|
||||||
|
class UnmappedFilesTableRow extends Component {
|
||||||
|
|
||||||
|
//
|
||||||
|
// Lifecycle
|
||||||
|
|
||||||
|
constructor(props, context) {
|
||||||
|
super(props, context);
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
isDetailsModalOpen: false,
|
||||||
|
isInteractiveImportModalOpen: false,
|
||||||
|
isConfirmDeleteModalOpen: false
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Listeners
|
||||||
|
|
||||||
|
onDetailsPress = () => {
|
||||||
|
this.setState({ isDetailsModalOpen: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
onDetailsModalClose = () => {
|
||||||
|
this.setState({ isDetailsModalOpen: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
onInteractiveImportPress = () => {
|
||||||
|
this.setState({ isInteractiveImportModalOpen: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
onInteractiveImportModalClose = () => {
|
||||||
|
this.setState({ isInteractiveImportModalOpen: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
onDeleteFilePress = () => {
|
||||||
|
this.setState({ isConfirmDeleteModalOpen: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
onConfirmDelete = () => {
|
||||||
|
this.setState({ isConfirmDeleteModalOpen: false });
|
||||||
|
this.props.deleteUnmappedFile(this.props.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
onConfirmDeleteModalClose = () => {
|
||||||
|
this.setState({ isConfirmDeleteModalOpen: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Render
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const {
|
||||||
|
style,
|
||||||
|
id,
|
||||||
|
path,
|
||||||
|
size,
|
||||||
|
dateAdded,
|
||||||
|
quality,
|
||||||
|
columns
|
||||||
|
} = this.props;
|
||||||
|
|
||||||
|
const folder = path.substring(0, Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')));
|
||||||
|
|
||||||
|
const {
|
||||||
|
isInteractiveImportModalOpen,
|
||||||
|
isDetailsModalOpen,
|
||||||
|
isConfirmDeleteModalOpen
|
||||||
|
} = this.state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<VirtualTableRow
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
{
|
||||||
|
columns.map((column) => {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
isVisible
|
||||||
|
} = column;
|
||||||
|
|
||||||
|
if (!isVisible) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'path') {
|
||||||
|
return (
|
||||||
|
<VirtualTableRowCell
|
||||||
|
key={name}
|
||||||
|
className={styles[name]}
|
||||||
|
>
|
||||||
|
{path}
|
||||||
|
</VirtualTableRowCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'size') {
|
||||||
|
return (
|
||||||
|
<VirtualTableRowCell
|
||||||
|
key={name}
|
||||||
|
className={styles[name]}
|
||||||
|
>
|
||||||
|
{formatBytes(size)}
|
||||||
|
</VirtualTableRowCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'dateAdded') {
|
||||||
|
return (
|
||||||
|
<RelativeDateCellConnector
|
||||||
|
key={name}
|
||||||
|
className={styles[name]}
|
||||||
|
date={dateAdded}
|
||||||
|
component={VirtualTableRowCell}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'quality') {
|
||||||
|
return (
|
||||||
|
<VirtualTableRowCell
|
||||||
|
key={name}
|
||||||
|
className={styles[name]}
|
||||||
|
>
|
||||||
|
<TrackQuality
|
||||||
|
quality={quality}
|
||||||
|
/>
|
||||||
|
</VirtualTableRowCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === 'actions') {
|
||||||
|
return (
|
||||||
|
<VirtualTableRowCell
|
||||||
|
key={name}
|
||||||
|
className={styles[name]}
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
name={icons.INFO}
|
||||||
|
onPress={this.onDetailsPress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
name={icons.INTERACTIVE}
|
||||||
|
onPress={this.onInteractiveImportPress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
name={icons.DELETE}
|
||||||
|
onPress={this.onDeleteFilePress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</VirtualTableRowCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
<InteractiveImportModal
|
||||||
|
isOpen={isInteractiveImportModalOpen}
|
||||||
|
folder={folder}
|
||||||
|
showFilterExistingFiles={true}
|
||||||
|
filterExistingFiles={false}
|
||||||
|
showImportMode={false}
|
||||||
|
showReplaceExistingFiles={false}
|
||||||
|
replaceExistingFiles={false}
|
||||||
|
onModalClose={this.onInteractiveImportModalClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FileDetailsModal
|
||||||
|
isOpen={isDetailsModalOpen}
|
||||||
|
onModalClose={this.onDetailsModalClose}
|
||||||
|
id={id}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmModal
|
||||||
|
isOpen={isConfirmDeleteModalOpen}
|
||||||
|
kind={kinds.DANGER}
|
||||||
|
title="Delete Track File"
|
||||||
|
message={`Are you sure you want to delete ${path}?`}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onConfirm={this.onConfirmDelete}
|
||||||
|
onCancel={this.onConfirmDeleteModalClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
</VirtualTableRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
UnmappedFilesTableRow.propTypes = {
|
||||||
|
style: PropTypes.object.isRequired,
|
||||||
|
id: PropTypes.number.isRequired,
|
||||||
|
path: PropTypes.string.isRequired,
|
||||||
|
size: PropTypes.number.isRequired,
|
||||||
|
quality: PropTypes.object.isRequired,
|
||||||
|
dateAdded: PropTypes.string.isRequired,
|
||||||
|
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||||
|
deleteUnmappedFile: PropTypes.func.isRequired
|
||||||
|
};
|
||||||
|
|
||||||
|
export default UnmappedFilesTableRow;
|
|
@ -1,6 +1,5 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using Nancy;
|
using Nancy;
|
||||||
using NzbDrone.Core.Datastore.Events;
|
using NzbDrone.Core.Datastore.Events;
|
||||||
|
@ -23,20 +22,23 @@ namespace Lidarr.Api.V1.TrackFiles
|
||||||
{
|
{
|
||||||
private readonly IMediaFileService _mediaFileService;
|
private readonly IMediaFileService _mediaFileService;
|
||||||
private readonly IDeleteMediaFiles _mediaFileDeletionService;
|
private readonly IDeleteMediaFiles _mediaFileDeletionService;
|
||||||
|
private readonly IAudioTagService _audioTagService;
|
||||||
private readonly IArtistService _artistService;
|
private readonly IArtistService _artistService;
|
||||||
private readonly IAlbumService _albumService;
|
private readonly IAlbumService _albumService;
|
||||||
private readonly IUpgradableSpecification _upgradableSpecification;
|
private readonly IUpgradableSpecification _upgradableSpecification;
|
||||||
|
|
||||||
public TrackFileModule(IBroadcastSignalRMessage signalRBroadcaster,
|
public TrackFileModule(IBroadcastSignalRMessage signalRBroadcaster,
|
||||||
IMediaFileService mediaFileService,
|
IMediaFileService mediaFileService,
|
||||||
IDeleteMediaFiles mediaFileDeletionService,
|
IDeleteMediaFiles mediaFileDeletionService,
|
||||||
IArtistService artistService,
|
IAudioTagService audioTagService,
|
||||||
IAlbumService albumService,
|
IArtistService artistService,
|
||||||
IUpgradableSpecification upgradableSpecification)
|
IAlbumService albumService,
|
||||||
|
IUpgradableSpecification upgradableSpecification)
|
||||||
: base(signalRBroadcaster)
|
: base(signalRBroadcaster)
|
||||||
{
|
{
|
||||||
_mediaFileService = mediaFileService;
|
_mediaFileService = mediaFileService;
|
||||||
_mediaFileDeletionService = mediaFileDeletionService;
|
_mediaFileDeletionService = mediaFileDeletionService;
|
||||||
|
_audioTagService = audioTagService;
|
||||||
_artistService = artistService;
|
_artistService = artistService;
|
||||||
_albumService = albumService;
|
_albumService = albumService;
|
||||||
_upgradableSpecification = upgradableSpecification;
|
_upgradableSpecification = upgradableSpecification;
|
||||||
|
@ -50,11 +52,23 @@ namespace Lidarr.Api.V1.TrackFiles
|
||||||
Delete["/bulk"] = trackFiles => DeleteTrackFiles();
|
Delete["/bulk"] = trackFiles => DeleteTrackFiles();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private TrackFileResource MapToResource(TrackFile trackFile)
|
||||||
|
{
|
||||||
|
if (trackFile.AlbumId > 0 && trackFile.Artist != null && trackFile.Artist.Value != null)
|
||||||
|
{
|
||||||
|
return trackFile.ToResource(trackFile.Artist.Value, _upgradableSpecification);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return trackFile.ToResource();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private TrackFileResource GetTrackFile(int id)
|
private TrackFileResource GetTrackFile(int id)
|
||||||
{
|
{
|
||||||
var trackFile = _mediaFileService.Get(id);
|
var resource = MapToResource(_mediaFileService.Get(id));
|
||||||
|
resource.AudioTags = _audioTagService.ReadTags(resource.Path);
|
||||||
return trackFile.ToResource(trackFile.Artist.Value, _upgradableSpecification);
|
return resource;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<TrackFileResource> GetTrackFiles()
|
private List<TrackFileResource> GetTrackFiles()
|
||||||
|
@ -62,10 +76,17 @@ namespace Lidarr.Api.V1.TrackFiles
|
||||||
var artistIdQuery = Request.Query.ArtistId;
|
var artistIdQuery = Request.Query.ArtistId;
|
||||||
var trackFileIdsQuery = Request.Query.TrackFileIds;
|
var trackFileIdsQuery = Request.Query.TrackFileIds;
|
||||||
var albumIdQuery = Request.Query.AlbumId;
|
var albumIdQuery = Request.Query.AlbumId;
|
||||||
|
var unmappedQuery = Request.Query.Unmapped;
|
||||||
|
|
||||||
if (!artistIdQuery.HasValue && !trackFileIdsQuery.HasValue && !albumIdQuery.HasValue)
|
if (!artistIdQuery.HasValue && !trackFileIdsQuery.HasValue && !albumIdQuery.HasValue && !unmappedQuery.HasValue)
|
||||||
{
|
{
|
||||||
throw new Lidarr.Http.REST.BadRequestException("artistId, albumId, or trackFileIds must be provided");
|
throw new Lidarr.Http.REST.BadRequestException("artistId, albumId, trackFileIds or unmapped must be provided");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unmappedQuery.HasValue && Convert.ToBoolean(unmappedQuery.Value))
|
||||||
|
{
|
||||||
|
var files = _mediaFileService.GetUnmappedFiles();
|
||||||
|
return files.ConvertAll(f => MapToResource(f));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (artistIdQuery.HasValue && !albumIdQuery.HasValue)
|
if (artistIdQuery.HasValue && !albumIdQuery.HasValue)
|
||||||
|
@ -105,7 +126,7 @@ namespace Lidarr.Api.V1.TrackFiles
|
||||||
|
|
||||||
// trackfiles will come back with the artist already populated
|
// trackfiles will come back with the artist already populated
|
||||||
var trackFiles = _mediaFileService.Get(trackFileIds);
|
var trackFiles = _mediaFileService.Get(trackFileIds);
|
||||||
return trackFiles.ConvertAll(e => e.ToResource(e.Artist.Value, _upgradableSpecification));
|
return trackFiles.ConvertAll(e => MapToResource(e));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -144,9 +165,14 @@ namespace Lidarr.Api.V1.TrackFiles
|
||||||
throw new NzbDroneClientException(HttpStatusCode.NotFound, "Track file not found");
|
throw new NzbDroneClientException(HttpStatusCode.NotFound, "Track file not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
var artist = trackFile.Artist.Value;
|
if (trackFile.AlbumId > 0 && trackFile.Artist != null && trackFile.Artist.Value != null)
|
||||||
|
{
|
||||||
_mediaFileDeletionService.DeleteTrackFile(artist, trackFile);
|
_mediaFileDeletionService.DeleteTrackFile(trackFile.Artist.Value, trackFile);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_mediaFileDeletionService.DeleteTrackFile(trackFile, "Unmapped_Files");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Response DeleteTrackFiles()
|
private Response DeleteTrackFiles()
|
||||||
|
@ -165,19 +191,12 @@ namespace Lidarr.Api.V1.TrackFiles
|
||||||
|
|
||||||
public void Handle(TrackFileAddedEvent message)
|
public void Handle(TrackFileAddedEvent message)
|
||||||
{
|
{
|
||||||
// don't process files that are added but not matched
|
BroadcastResourceChange(ModelAction.Updated, MapToResource(message.TrackFile));
|
||||||
if (message.TrackFile.AlbumId == 0)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
BroadcastResourceChange(ModelAction.Updated, message.TrackFile.ToResource(message.TrackFile.Artist.Value, _upgradableSpecification));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Handle(TrackFileDeletedEvent message)
|
public void Handle(TrackFileDeletedEvent message)
|
||||||
{
|
{
|
||||||
BroadcastResourceChange(ModelAction.Deleted, message.TrackFile.ToResource(message.TrackFile.Artist.Value, _upgradableSpecification));
|
BroadcastResourceChange(ModelAction.Deleted, MapToResource(message.TrackFile));
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,8 @@ using NzbDrone.Core.MediaFiles;
|
||||||
using NzbDrone.Core.Qualities;
|
using NzbDrone.Core.Qualities;
|
||||||
using Lidarr.Http.REST;
|
using Lidarr.Http.REST;
|
||||||
using NzbDrone.Common.Extensions;
|
using NzbDrone.Common.Extensions;
|
||||||
|
using NzbDrone.Core.Parser.Model;
|
||||||
|
using System.Linq;
|
||||||
|
|
||||||
namespace Lidarr.Api.V1.TrackFiles
|
namespace Lidarr.Api.V1.TrackFiles
|
||||||
{
|
{
|
||||||
|
@ -16,34 +18,43 @@ namespace Lidarr.Api.V1.TrackFiles
|
||||||
public long Size { get; set; }
|
public long Size { get; set; }
|
||||||
public DateTime DateAdded { get; set; }
|
public DateTime DateAdded { get; set; }
|
||||||
public QualityModel Quality { get; set; }
|
public QualityModel Quality { get; set; }
|
||||||
|
public int QualityWeight { get; set; }
|
||||||
public MediaInfoResource MediaInfo { get; set; }
|
public MediaInfoResource MediaInfo { get; set; }
|
||||||
|
|
||||||
public bool QualityCutoffNotMet { get; set; }
|
public bool QualityCutoffNotMet { get; set; }
|
||||||
|
public ParsedTrackInfo AudioTags { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public static class TrackFileResourceMapper
|
public static class TrackFileResourceMapper
|
||||||
{
|
{
|
||||||
private static TrackFileResource ToResource(this TrackFile model)
|
private static int QualityWeight(QualityModel quality)
|
||||||
|
{
|
||||||
|
if (quality == null)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int qualityWeight = Quality.DefaultQualityDefinitions.Single(q => q.Quality == quality.Quality).Weight;
|
||||||
|
qualityWeight += quality.Revision.Real * 10;
|
||||||
|
qualityWeight += quality.Revision.Version;
|
||||||
|
return qualityWeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TrackFileResource ToResource(this TrackFile model)
|
||||||
{
|
{
|
||||||
if (model == null) return null;
|
if (model == null) return null;
|
||||||
|
|
||||||
return new TrackFileResource
|
return new TrackFileResource
|
||||||
{
|
{
|
||||||
Id = model.Id,
|
Id = model.Id,
|
||||||
|
|
||||||
ArtistId = model.Artist.Value.Id,
|
|
||||||
AlbumId = model.AlbumId,
|
AlbumId = model.AlbumId,
|
||||||
RelativePath = model.Artist.Value.Path.GetRelativePath(model.Path),
|
|
||||||
Path = model.Path,
|
Path = model.Path,
|
||||||
Size = model.Size,
|
Size = model.Size,
|
||||||
DateAdded = model.DateAdded,
|
DateAdded = model.DateAdded,
|
||||||
// SceneName = model.SceneName,
|
|
||||||
Quality = model.Quality,
|
Quality = model.Quality,
|
||||||
|
QualityWeight = QualityWeight(model.Quality),
|
||||||
MediaInfo = model.MediaInfo.ToResource()
|
MediaInfo = model.MediaInfo.ToResource()
|
||||||
//QualityCutoffNotMet
|
|
||||||
};
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public static TrackFileResource ToResource(this TrackFile model, NzbDrone.Core.Music.Artist artist, IUpgradableSpecification upgradableSpecification)
|
public static TrackFileResource ToResource(this TrackFile model, NzbDrone.Core.Music.Artist artist, IUpgradableSpecification upgradableSpecification)
|
||||||
|
@ -60,8 +71,8 @@ namespace Lidarr.Api.V1.TrackFiles
|
||||||
RelativePath = artist.Path.GetRelativePath(model.Path),
|
RelativePath = artist.Path.GetRelativePath(model.Path),
|
||||||
Size = model.Size,
|
Size = model.Size,
|
||||||
DateAdded = model.DateAdded,
|
DateAdded = model.DateAdded,
|
||||||
//SceneName = model.SceneName,
|
|
||||||
Quality = model.Quality,
|
Quality = model.Quality,
|
||||||
|
QualityWeight = QualityWeight(model.Quality),
|
||||||
MediaInfo = model.MediaInfo.ToResource(),
|
MediaInfo = model.MediaInfo.ToResource(),
|
||||||
QualityCutoffNotMet = upgradableSpecification.QualityCutoffNotMet(artist.QualityProfile.Value, model.Quality)
|
QualityCutoffNotMet = upgradableSpecification.QualityCutoffNotMet(artist.QualityProfile.Value, model.Quality)
|
||||||
};
|
};
|
||||||
|
|
|
@ -19,7 +19,7 @@ namespace NzbDrone.Automation.Test
|
||||||
[Test]
|
[Test]
|
||||||
public void artist_page()
|
public void artist_page()
|
||||||
{
|
{
|
||||||
page.ArtistNavIcon.Click();
|
page.LibraryNavIcon.Click();
|
||||||
page.WaitForNoSpinner();
|
page.WaitForNoSpinner();
|
||||||
page.Find(By.CssSelector("div[class*='ArtistIndex']")).Should().NotBeNull();
|
page.Find(By.CssSelector("div[class*='ArtistIndex']")).Should().NotBeNull();
|
||||||
}
|
}
|
||||||
|
@ -66,7 +66,7 @@ namespace NzbDrone.Automation.Test
|
||||||
[Test]
|
[Test]
|
||||||
public void add_artist_page()
|
public void add_artist_page()
|
||||||
{
|
{
|
||||||
page.ArtistNavIcon.Click();
|
page.LibraryNavIcon.Click();
|
||||||
page.WaitForNoSpinner();
|
page.WaitForNoSpinner();
|
||||||
|
|
||||||
page.Find(By.LinkText("Add New")).Click();
|
page.Find(By.LinkText("Add New")).Click();
|
||||||
|
|
|
@ -47,7 +47,7 @@ namespace NzbDrone.Automation.Test.PageModel
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public IWebElement ArtistNavIcon => Find(By.LinkText("Artist"));
|
public IWebElement LibraryNavIcon => Find(By.LinkText("Library"));
|
||||||
|
|
||||||
public IWebElement CalendarNavIcon => Find(By.LinkText("Calendar"));
|
public IWebElement CalendarNavIcon => Find(By.LinkText("Calendar"));
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,7 @@ namespace NzbDrone.Core.Test.MediaFiles
|
||||||
{
|
{
|
||||||
private Artist artist;
|
private Artist artist;
|
||||||
private Album album;
|
private Album album;
|
||||||
|
private List<AlbumRelease> releases;
|
||||||
|
|
||||||
[SetUp]
|
[SetUp]
|
||||||
public void Setup()
|
public void Setup()
|
||||||
|
@ -36,7 +37,7 @@ namespace NzbDrone.Core.Test.MediaFiles
|
||||||
.Build();
|
.Build();
|
||||||
Db.Insert(album);
|
Db.Insert(album);
|
||||||
|
|
||||||
var releases = Builder<AlbumRelease>.CreateListOfSize(2)
|
releases = Builder<AlbumRelease>.CreateListOfSize(2)
|
||||||
.All()
|
.All()
|
||||||
.With(a => a.Id = 0)
|
.With(a => a.Id = 0)
|
||||||
.With(a => a.AlbumId = album.Id)
|
.With(a => a.AlbumId = album.Id)
|
||||||
|
@ -44,7 +45,7 @@ namespace NzbDrone.Core.Test.MediaFiles
|
||||||
.With(a => a.Monitored = true)
|
.With(a => a.Monitored = true)
|
||||||
.TheNext(1)
|
.TheNext(1)
|
||||||
.With(a => a.Monitored = false)
|
.With(a => a.Monitored = false)
|
||||||
.Build();
|
.Build().ToList();
|
||||||
Db.InsertMany(releases);
|
Db.InsertMany(releases);
|
||||||
|
|
||||||
var files = Builder<TrackFile>.CreateListOfSize(10)
|
var files = Builder<TrackFile>.CreateListOfSize(10)
|
||||||
|
@ -53,6 +54,10 @@ namespace NzbDrone.Core.Test.MediaFiles
|
||||||
.With(c => c.Quality =new QualityModel(Quality.MP3_192))
|
.With(c => c.Quality =new QualityModel(Quality.MP3_192))
|
||||||
.TheFirst(5)
|
.TheFirst(5)
|
||||||
.With(c => c.AlbumId = album.Id)
|
.With(c => c.AlbumId = album.Id)
|
||||||
|
.TheFirst(1)
|
||||||
|
.With(c => c.Path = "/Test/Path/Artist/somefile1.flac")
|
||||||
|
.TheNext(1)
|
||||||
|
.With(c => c.Path = "/Test/Path/Artist/somefile2.flac")
|
||||||
.BuildListOfNew();
|
.BuildListOfNew();
|
||||||
Db.InsertMany(files);
|
Db.InsertMany(files);
|
||||||
|
|
||||||
|
@ -88,6 +93,55 @@ namespace NzbDrone.Core.Test.MediaFiles
|
||||||
artistFiles.Should().OnlyContain(c => c.Artist.Value.Id == artist.Id);
|
artistFiles.Should().OnlyContain(c => c.Artist.Value.Id == artist.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void get_unmapped_files()
|
||||||
|
{
|
||||||
|
VerifyData();
|
||||||
|
var unmappedfiles = Subject.GetUnmappedFiles();
|
||||||
|
VerifyUnmapped(unmappedfiles);
|
||||||
|
|
||||||
|
unmappedfiles.Should().HaveCount(5);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void get_files_by_release()
|
||||||
|
{
|
||||||
|
VerifyData();
|
||||||
|
var firstReleaseFiles = Subject.GetFilesByRelease(releases[0].Id);
|
||||||
|
var secondReleaseFiles = Subject.GetFilesByRelease(releases[1].Id);
|
||||||
|
VerifyEagerLoaded(firstReleaseFiles);
|
||||||
|
VerifyEagerLoaded(secondReleaseFiles);
|
||||||
|
|
||||||
|
firstReleaseFiles.Should().HaveCount(4);
|
||||||
|
secondReleaseFiles.Should().HaveCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void get_files_by_base_path()
|
||||||
|
{
|
||||||
|
VerifyData();
|
||||||
|
var firstReleaseFiles = Subject.GetFilesWithBasePath("/Test/Path");
|
||||||
|
VerifyEagerLoaded(firstReleaseFiles);
|
||||||
|
|
||||||
|
firstReleaseFiles.Should().HaveCount(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void get_file_by_path()
|
||||||
|
{
|
||||||
|
VerifyData();
|
||||||
|
var file = Subject.GetFileWithPath("/Test/Path/Artist/somefile2.flac");
|
||||||
|
|
||||||
|
file.Should().NotBeNull();
|
||||||
|
file.Tracks.IsLoaded.Should().BeTrue();
|
||||||
|
file.Tracks.Value.Should().NotBeNull();
|
||||||
|
file.Tracks.Value.Should().NotBeEmpty();
|
||||||
|
file.Album.IsLoaded.Should().BeTrue();
|
||||||
|
file.Album.Value.Should().NotBeNull();
|
||||||
|
file.Artist.IsLoaded.Should().BeTrue();
|
||||||
|
file.Artist.Value.Should().NotBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void get_files_by_artist_should_only_return_tracks_for_monitored_releases()
|
public void get_files_by_artist_should_only_return_tracks_for_monitored_releases()
|
||||||
{
|
{
|
||||||
|
@ -142,6 +196,20 @@ namespace NzbDrone.Core.Test.MediaFiles
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void VerifyUnmapped(List<TrackFile> files)
|
||||||
|
{
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
file.Tracks.IsLoaded.Should().BeFalse();
|
||||||
|
file.Tracks.Value.Should().NotBeNull();
|
||||||
|
file.Tracks.Value.Should().BeEmpty();
|
||||||
|
file.Album.IsLoaded.Should().BeFalse();
|
||||||
|
file.Album.Value.Should().BeNull();
|
||||||
|
file.Artist.IsLoaded.Should().BeFalse();
|
||||||
|
file.Artist.Value.Should().BeNull();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void delete_files_by_album_should_work_if_join_fails()
|
public void delete_files_by_album_should_work_if_join_fails()
|
||||||
{
|
{
|
||||||
|
|
|
@ -0,0 +1,66 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using FizzWare.NBuilder;
|
||||||
|
using Moq;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using NzbDrone.Core.MediaFiles;
|
||||||
|
using NzbDrone.Core.MediaFiles.Events;
|
||||||
|
using NzbDrone.Core.Test.Framework;
|
||||||
|
using NzbDrone.Core.Music;
|
||||||
|
|
||||||
|
namespace NzbDrone.Core.Test.MediaFiles.TrackFileMovingServiceTests
|
||||||
|
{
|
||||||
|
[TestFixture]
|
||||||
|
public class MediaFileServiceFixture : CoreTest<MediaFileService>
|
||||||
|
{
|
||||||
|
private Album _album;
|
||||||
|
private List<TrackFile> _trackFiles;
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public void Setup()
|
||||||
|
{
|
||||||
|
_album = Builder<Album>.CreateNew()
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
_trackFiles = Builder<TrackFile>.CreateListOfSize(3)
|
||||||
|
.TheFirst(2)
|
||||||
|
.With(f => f.AlbumId = _album.Id)
|
||||||
|
.TheNext(1)
|
||||||
|
.With(f => f.AlbumId = 0)
|
||||||
|
.Build().ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_throw_trackFileDeletedEvent_for_each_mapped_track_on_deletemany()
|
||||||
|
{
|
||||||
|
Subject.DeleteMany(_trackFiles, DeleteMediaFileReason.Manual);
|
||||||
|
|
||||||
|
VerifyEventPublished<TrackFileDeletedEvent>(Times.Exactly(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_throw_trackFileDeletedEvent_for_mapped_track_on_delete()
|
||||||
|
{
|
||||||
|
Subject.Delete(_trackFiles[0], DeleteMediaFileReason.Manual);
|
||||||
|
|
||||||
|
VerifyEventPublished<TrackFileDeletedEvent>(Times.Once());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_throw_trackFileAddedEvent_for_each_track_added_on_addmany()
|
||||||
|
{
|
||||||
|
Subject.AddMany(_trackFiles);
|
||||||
|
|
||||||
|
VerifyEventPublished<TrackFileAddedEvent>(Times.Exactly(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void should_throw_trackFileAddedEvent_for_track_added()
|
||||||
|
{
|
||||||
|
Subject.Add(_trackFiles[0]);
|
||||||
|
|
||||||
|
VerifyEventPublished<TrackFileAddedEvent>(Times.Once());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,7 +14,7 @@ namespace NzbDrone.Core.Test.MediaFiles
|
||||||
{
|
{
|
||||||
public class MediaFileTableCleanupServiceFixture : CoreTest<MediaFileTableCleanupService>
|
public class MediaFileTableCleanupServiceFixture : CoreTest<MediaFileTableCleanupService>
|
||||||
{
|
{
|
||||||
private readonly string DELETED_PATH = @"c:\ANY FILE WITH THIS PATH IS CONSIDERED DELETED!".AsOsAgnostic();
|
private readonly string DELETED_PATH = @"c:\ANY FILE STARTING WITH THIS PATH IS CONSIDERED DELETED!".AsOsAgnostic();
|
||||||
private List<Track> _tracks;
|
private List<Track> _tracks;
|
||||||
private Artist _artist;
|
private Artist _artist;
|
||||||
|
|
||||||
|
@ -29,29 +29,23 @@ namespace NzbDrone.Core.Test.MediaFiles
|
||||||
.With(s => s.Path = @"C:\Test\Music\Artist".AsOsAgnostic())
|
.With(s => s.Path = @"C:\Test\Music\Artist".AsOsAgnostic())
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
Mocker.GetMock<IDiskProvider>()
|
|
||||||
.Setup(e => e.FileExists(It.Is<string>(c => !c.Contains(DELETED_PATH))))
|
|
||||||
.Returns(true);
|
|
||||||
|
|
||||||
Mocker.GetMock<ITrackService>()
|
Mocker.GetMock<ITrackService>()
|
||||||
.Setup(c => c.GetTracksByArtist(It.IsAny<int>()))
|
.Setup(c => c.GetTracksByFileId(It.IsAny<IEnumerable<int>>()))
|
||||||
.Returns(_tracks);
|
.Returns((IEnumerable<int> ids) => _tracks.Where(y => ids.Contains(y.TrackFileId)).ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void GivenTrackFiles(IEnumerable<TrackFile> trackFiles)
|
private void GivenTrackFiles(IEnumerable<TrackFile> trackFiles)
|
||||||
{
|
{
|
||||||
Mocker.GetMock<IMediaFileService>()
|
Mocker.GetMock<IMediaFileService>()
|
||||||
.Setup(c => c.GetFilesByArtist(It.IsAny<int>()))
|
.Setup(c => c.GetFilesWithBasePath(It.IsAny<string>()))
|
||||||
.Returns(trackFiles.ToList());
|
.Returns(trackFiles.ToList());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void GivenFilesAreNotAttachedToTrack()
|
private void GivenFilesAreNotAttachedToTrack()
|
||||||
{
|
{
|
||||||
_tracks.ForEach(e => e.TrackFileId = 0);
|
|
||||||
|
|
||||||
Mocker.GetMock<ITrackService>()
|
Mocker.GetMock<ITrackService>()
|
||||||
.Setup(c => c.GetTracksByArtist(It.IsAny<int>()))
|
.Setup(c => c.GetTracksByFileId(It.IsAny<int>()))
|
||||||
.Returns(_tracks);
|
.Returns(new List<Track>());
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<string> FilesOnDisk(IEnumerable<TrackFile> trackFiles)
|
private List<string> FilesOnDisk(IEnumerable<TrackFile> trackFiles)
|
||||||
|
@ -71,7 +65,8 @@ namespace NzbDrone.Core.Test.MediaFiles
|
||||||
|
|
||||||
Subject.Clean(_artist, FilesOnDisk(trackFiles));
|
Subject.Clean(_artist, FilesOnDisk(trackFiles));
|
||||||
|
|
||||||
Mocker.GetMock<ITrackService>().Verify(c => c.UpdateTrack(It.IsAny<Track>()), Times.Never());
|
Mocker.GetMock<IMediaFileService>()
|
||||||
|
.Verify(c => c.DeleteMany(It.Is<List<TrackFile>>(x => x.Count == 0), DeleteMediaFileReason.MissingFromDisk), Times.Once());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
@ -81,24 +76,31 @@ namespace NzbDrone.Core.Test.MediaFiles
|
||||||
.All()
|
.All()
|
||||||
.With(x => x.Path = Path.Combine(@"c:\test".AsOsAgnostic(), Path.GetRandomFileName()))
|
.With(x => x.Path = Path.Combine(@"c:\test".AsOsAgnostic(), Path.GetRandomFileName()))
|
||||||
.Random(2)
|
.Random(2)
|
||||||
.With(c => c.Path = DELETED_PATH)
|
.With(c => c.Path = Path.Combine(DELETED_PATH, Path.GetRandomFileName()))
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
GivenTrackFiles(trackFiles);
|
GivenTrackFiles(trackFiles);
|
||||||
|
|
||||||
Subject.Clean(_artist, FilesOnDisk(trackFiles.Where(e => e.Path != DELETED_PATH)));
|
Subject.Clean(_artist, FilesOnDisk(trackFiles.Where(e => !e.Path.StartsWith(DELETED_PATH))));
|
||||||
|
|
||||||
Mocker.GetMock<IMediaFileService>().Verify(c => c.Delete(It.Is<TrackFile>(e => e.Path == DELETED_PATH), DeleteMediaFileReason.MissingFromDisk), Times.Exactly(2));
|
Mocker.GetMock<IMediaFileService>()
|
||||||
|
.Verify(c => c.DeleteMany(It.Is<List<TrackFile>>(e => e.Count == 2 && e.All(y => y.Path.StartsWith(DELETED_PATH))), DeleteMediaFileReason.MissingFromDisk), Times.Once());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public void should_unlink_track_when_trackFile_does_not_exist()
|
public void should_unlink_track_when_trackFile_does_not_exist()
|
||||||
{
|
{
|
||||||
GivenTrackFiles(new List<TrackFile>());
|
var trackFiles = Builder<TrackFile>.CreateListOfSize(10)
|
||||||
|
.Random(10)
|
||||||
|
.With(c => c.Path = Path.Combine(@"c:\test".AsOsAgnostic(), Path.GetRandomFileName()))
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
GivenTrackFiles(trackFiles);
|
||||||
|
|
||||||
Subject.Clean(_artist, new List<string>());
|
Subject.Clean(_artist, new List<string>());
|
||||||
|
|
||||||
Mocker.GetMock<ITrackService>().Verify(c => c.UpdateTrack(It.Is<Track>(e => e.TrackFileId == 0)), Times.Exactly(10));
|
Mocker.GetMock<ITrackService>()
|
||||||
|
.Verify(c => c.SetFileIds(It.Is<List<Track>>(e => e.Count == 10 && e.All(y => y.TrackFileId == 0))), Times.Once());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
|
@ -113,7 +115,7 @@ namespace NzbDrone.Core.Test.MediaFiles
|
||||||
|
|
||||||
Subject.Clean(_artist, FilesOnDisk(trackFiles));
|
Subject.Clean(_artist, FilesOnDisk(trackFiles));
|
||||||
|
|
||||||
Mocker.GetMock<ITrackService>().Verify(c => c.UpdateTrack(It.IsAny<Track>()), Times.Never());
|
Mocker.GetMock<ITrackService>().Verify(c => c.SetFileIds(It.Is<List<Track>>(x => x.Count == 0)), Times.Once());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -17,6 +17,7 @@ namespace NzbDrone.Core.MediaFiles
|
||||||
public interface IDeleteMediaFiles
|
public interface IDeleteMediaFiles
|
||||||
{
|
{
|
||||||
void DeleteTrackFile(Artist artist, TrackFile trackFile);
|
void DeleteTrackFile(Artist artist, TrackFile trackFile);
|
||||||
|
void DeleteTrackFile(TrackFile trackFile, string subfolder = "");
|
||||||
}
|
}
|
||||||
|
|
||||||
public class MediaFileDeletionService : IDeleteMediaFiles,
|
public class MediaFileDeletionService : IDeleteMediaFiles,
|
||||||
|
@ -62,12 +63,26 @@ namespace NzbDrone.Core.MediaFiles
|
||||||
throw new NzbDroneClientException(HttpStatusCode.Conflict, "Artist's root folder ({0}) is empty.", rootFolder);
|
throw new NzbDroneClientException(HttpStatusCode.Conflict, "Artist's root folder ({0}) is empty.", rootFolder);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_diskProvider.FolderExists(artist.Path) && _diskProvider.FileExists(fullPath))
|
if (_diskProvider.FolderExists(artist.Path))
|
||||||
|
{
|
||||||
|
var subfolder = _diskProvider.GetParentFolder(artist.Path).GetRelativePath(_diskProvider.GetParentFolder(fullPath));
|
||||||
|
DeleteTrackFile(trackFile, subfolder);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// delete from db even if the artist folder is missing
|
||||||
|
_mediaFileService.Delete(trackFile, DeleteMediaFileReason.Manual);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void DeleteTrackFile(TrackFile trackFile, string subfolder = "")
|
||||||
|
{
|
||||||
|
var fullPath = trackFile.Path;
|
||||||
|
|
||||||
|
if (_diskProvider.FileExists(fullPath))
|
||||||
{
|
{
|
||||||
_logger.Info("Deleting track file: {0}", fullPath);
|
_logger.Info("Deleting track file: {0}", fullPath);
|
||||||
|
|
||||||
var subfolder = _diskProvider.GetParentFolder(artist.Path).GetRelativePath(_diskProvider.GetParentFolder(fullPath));
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_recycleBinProvider.DeleteFile(fullPath, subfolder);
|
_recycleBinProvider.DeleteFile(fullPath, subfolder);
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using Marr.Data;
|
||||||
using Marr.Data.QGen;
|
using Marr.Data.QGen;
|
||||||
using NzbDrone.Core.Datastore;
|
using NzbDrone.Core.Datastore;
|
||||||
|
using NzbDrone.Core.Datastore.Extensions;
|
||||||
using NzbDrone.Core.Messaging.Events;
|
using NzbDrone.Core.Messaging.Events;
|
||||||
using NzbDrone.Core.Music;
|
using NzbDrone.Core.Music;
|
||||||
|
|
||||||
|
@ -12,6 +14,7 @@ namespace NzbDrone.Core.MediaFiles
|
||||||
List<TrackFile> GetFilesByArtist(int artistId);
|
List<TrackFile> GetFilesByArtist(int artistId);
|
||||||
List<TrackFile> GetFilesByAlbum(int albumId);
|
List<TrackFile> GetFilesByAlbum(int albumId);
|
||||||
List<TrackFile> GetFilesByRelease(int releaseId);
|
List<TrackFile> GetFilesByRelease(int releaseId);
|
||||||
|
List<TrackFile> GetUnmappedFiles();
|
||||||
List<TrackFile> GetFilesWithBasePath(string path);
|
List<TrackFile> GetFilesWithBasePath(string path);
|
||||||
TrackFile GetFileWithPath(string path);
|
TrackFile GetFileWithPath(string path);
|
||||||
void DeleteFilesByAlbum(int albumId);
|
void DeleteFilesByAlbum(int albumId);
|
||||||
|
@ -52,6 +55,16 @@ namespace NzbDrone.Core.MediaFiles
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<TrackFile> GetUnmappedFiles()
|
||||||
|
{
|
||||||
|
var query = "SELECT TrackFiles.* " +
|
||||||
|
"FROM TrackFiles " +
|
||||||
|
"LEFT JOIN Tracks ON Tracks.TrackFileId = TrackFiles.Id " +
|
||||||
|
"WHERE Tracks.Id IS NULL ";
|
||||||
|
|
||||||
|
return DataMapper.Query<TrackFile>().QueryText(query).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
public void DeleteFilesByAlbum(int albumId)
|
public void DeleteFilesByAlbum(int albumId)
|
||||||
{
|
{
|
||||||
var ids = DataMapper.Query<TrackFile>().Where(x => x.AlbumId == albumId);
|
var ids = DataMapper.Query<TrackFile>().Where(x => x.AlbumId == albumId);
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
|
||||||
using System.IO.Abstractions;
|
using System.IO.Abstractions;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using NLog;
|
using NLog;
|
||||||
|
@ -20,9 +19,11 @@ namespace NzbDrone.Core.MediaFiles
|
||||||
void Update(TrackFile trackFile);
|
void Update(TrackFile trackFile);
|
||||||
void Update(List<TrackFile> trackFile);
|
void Update(List<TrackFile> trackFile);
|
||||||
void Delete(TrackFile trackFile, DeleteMediaFileReason reason);
|
void Delete(TrackFile trackFile, DeleteMediaFileReason reason);
|
||||||
|
void DeleteMany(List<TrackFile> trackFiles, DeleteMediaFileReason reason);
|
||||||
List<TrackFile> GetFilesByArtist(int artistId);
|
List<TrackFile> GetFilesByArtist(int artistId);
|
||||||
List<TrackFile> GetFilesByAlbum(int albumId);
|
List<TrackFile> GetFilesByAlbum(int albumId);
|
||||||
List<TrackFile> GetFilesByRelease(int releaseId);
|
List<TrackFile> GetFilesByRelease(int releaseId);
|
||||||
|
List<TrackFile> GetUnmappedFiles();
|
||||||
List<IFileInfo> FilterUnchangedFiles(List<IFileInfo> files, Artist artist, FilterFilesType filter);
|
List<IFileInfo> FilterUnchangedFiles(List<IFileInfo> files, Artist artist, FilterFilesType filter);
|
||||||
TrackFile Get(int id);
|
TrackFile Get(int id);
|
||||||
List<TrackFile> Get(IEnumerable<int> ids);
|
List<TrackFile> Get(IEnumerable<int> ids);
|
||||||
|
@ -81,6 +82,17 @@ namespace NzbDrone.Core.MediaFiles
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void DeleteMany(List<TrackFile> trackFiles, DeleteMediaFileReason reason)
|
||||||
|
{
|
||||||
|
_mediaFileRepository.DeleteMany(trackFiles);
|
||||||
|
|
||||||
|
// publish events where trackfile was mapped to a track
|
||||||
|
foreach (var trackFile in trackFiles.Where(x => x.AlbumId > 0))
|
||||||
|
{
|
||||||
|
_eventAggregator.PublishEvent(new TrackFileDeletedEvent(trackFile, reason));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public List<IFileInfo> FilterUnchangedFiles(List<IFileInfo> files, Artist artist, FilterFilesType filter)
|
public List<IFileInfo> FilterUnchangedFiles(List<IFileInfo> files, Artist artist, FilterFilesType filter)
|
||||||
{
|
{
|
||||||
_logger.Debug($"Filtering {files.Count} files for unchanged files");
|
_logger.Debug($"Filtering {files.Count} files for unchanged files");
|
||||||
|
@ -166,6 +178,11 @@ namespace NzbDrone.Core.MediaFiles
|
||||||
return _mediaFileRepository.GetFilesByRelease(releaseId);
|
return _mediaFileRepository.GetFilesByRelease(releaseId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<TrackFile> GetUnmappedFiles()
|
||||||
|
{
|
||||||
|
return _mediaFileRepository.GetUnmappedFiles();
|
||||||
|
}
|
||||||
|
|
||||||
public void UpdateMediaInfo(List<TrackFile> trackFiles)
|
public void UpdateMediaInfo(List<TrackFile> trackFiles)
|
||||||
{
|
{
|
||||||
_mediaFileRepository.SetFields(trackFiles, t => t.MediaInfo);
|
_mediaFileRepository.SetFields(trackFiles, t => t.MediaInfo);
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.Linq;
|
||||||
using NLog;
|
using NLog;
|
||||||
using NzbDrone.Common;
|
using NzbDrone.Common;
|
||||||
using NzbDrone.Common.Extensions;
|
using NzbDrone.Common.Extensions;
|
||||||
|
@ -30,42 +29,20 @@ namespace NzbDrone.Core.MediaFiles
|
||||||
|
|
||||||
public void Clean(Artist artist, List<string> filesOnDisk)
|
public void Clean(Artist artist, List<string> filesOnDisk)
|
||||||
{
|
{
|
||||||
var artistFiles = _mediaFileService.GetFilesByArtist(artist.Id);
|
var dbFiles = _mediaFileService.GetFilesWithBasePath(artist.Path);
|
||||||
var tracks = _trackService.GetTracksByArtist(artist.Id);
|
|
||||||
|
|
||||||
|
// get files in database that are missing on disk and remove from database
|
||||||
|
var missingFiles = dbFiles.ExceptBy(x => x.Path, filesOnDisk, x => x, PathEqualityComparer.Instance).ToList();
|
||||||
|
|
||||||
var filesOnDiskKeys = new HashSet<string>(filesOnDisk, PathEqualityComparer.Instance);
|
_logger.Debug("The following files no longer exist on disk, removing from db:\n{0}",
|
||||||
|
string.Join("\n", missingFiles.Select(x => x.Path)));
|
||||||
foreach (var artistFile in artistFiles)
|
|
||||||
{
|
|
||||||
var trackFile = artistFile;
|
|
||||||
var trackFilePath = trackFile.Path;
|
|
||||||
|
|
||||||
try
|
_mediaFileService.DeleteMany(missingFiles, DeleteMediaFileReason.MissingFromDisk);
|
||||||
{
|
|
||||||
if (!filesOnDiskKeys.Contains(trackFilePath))
|
|
||||||
{
|
|
||||||
_logger.Debug("File [{0}] no longer exists on disk, removing from db", trackFilePath);
|
|
||||||
_mediaFileService.Delete(artistFile, DeleteMediaFileReason.MissingFromDisk);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.Error(ex, "Unable to cleanup TrackFile in DB: {0}", trackFile.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var t in tracks)
|
// get any tracks matched to these trackfiles and unlink them
|
||||||
{
|
var orphanedTracks = _trackService.GetTracksByFileId(missingFiles.Select(x => x.Id));
|
||||||
var track = t;
|
orphanedTracks.ForEach(x => x.TrackFileId = 0);
|
||||||
|
_trackService.SetFileIds(orphanedTracks);
|
||||||
if (track.TrackFileId > 0 && artistFiles.None(f => f.Id == track.TrackFileId))
|
|
||||||
{
|
|
||||||
track.TrackFileId = 0;
|
|
||||||
_trackService.UpdateTrack(track);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,6 +13,7 @@ namespace NzbDrone.Core.Music
|
||||||
List<Track> GetTracksByReleases(List<int> albumReleaseId);
|
List<Track> GetTracksByReleases(List<int> albumReleaseId);
|
||||||
List<Track> GetTracksForRefresh(int albumReleaseId, IEnumerable<string> foreignTrackIds);
|
List<Track> GetTracksForRefresh(int albumReleaseId, IEnumerable<string> foreignTrackIds);
|
||||||
List<Track> GetTracksByFileId(int fileId);
|
List<Track> GetTracksByFileId(int fileId);
|
||||||
|
List<Track> GetTracksByFileId(IEnumerable<int> ids);
|
||||||
List<Track> TracksWithFiles(int artistId);
|
List<Track> TracksWithFiles(int artistId);
|
||||||
List<Track> TracksWithoutFiles(int albumId);
|
List<Track> TracksWithoutFiles(int albumId);
|
||||||
void SetFileId(List<Track> tracks);
|
void SetFileId(List<Track> tracks);
|
||||||
|
@ -85,6 +86,11 @@ namespace NzbDrone.Core.Music
|
||||||
return Query.Where(e => e.TrackFileId == fileId).ToList();
|
return Query.Where(e => e.TrackFileId == fileId).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Track> GetTracksByFileId(IEnumerable<int> ids)
|
||||||
|
{
|
||||||
|
return Query.Where($"[TrackFileId] IN ({string.Join(", ", ids)})").ToList();
|
||||||
|
}
|
||||||
|
|
||||||
public List<Track> TracksWithFiles(int artistId)
|
public List<Track> TracksWithFiles(int artistId)
|
||||||
{
|
{
|
||||||
string query = string.Format("SELECT Tracks.* " +
|
string query = string.Format("SELECT Tracks.* " +
|
||||||
|
|
|
@ -20,6 +20,7 @@ namespace NzbDrone.Core.Music
|
||||||
List<Track> TracksWithFiles(int artistId);
|
List<Track> TracksWithFiles(int artistId);
|
||||||
List<Track> TracksWithoutFiles(int albumId);
|
List<Track> TracksWithoutFiles(int albumId);
|
||||||
List<Track> GetTracksByFileId(int trackFileId);
|
List<Track> GetTracksByFileId(int trackFileId);
|
||||||
|
List<Track> GetTracksByFileId(IEnumerable<int> trackFileIds);
|
||||||
void UpdateTrack(Track track);
|
void UpdateTrack(Track track);
|
||||||
void InsertMany(List<Track> tracks);
|
void InsertMany(List<Track> tracks);
|
||||||
void UpdateMany(List<Track> tracks);
|
void UpdateMany(List<Track> tracks);
|
||||||
|
@ -93,6 +94,11 @@ namespace NzbDrone.Core.Music
|
||||||
return _trackRepository.GetTracksByFileId(trackFileId);
|
return _trackRepository.GetTracksByFileId(trackFileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Track> GetTracksByFileId(IEnumerable<int> trackFileIds)
|
||||||
|
{
|
||||||
|
return _trackRepository.GetTracksByFileId(trackFileIds);
|
||||||
|
}
|
||||||
|
|
||||||
public void UpdateTrack(Track track)
|
public void UpdateTrack(Track track)
|
||||||
{
|
{
|
||||||
_trackRepository.Update(track);
|
_trackRepository.Update(track);
|
||||||
|
|
2
test.sh
2
test.sh
|
@ -70,7 +70,7 @@ if [ "$COVERAGE" = "Coverage" ]; then
|
||||||
dotnet tool install coverlet.console --tool-path="$TEST_DIR/coverlet/"
|
dotnet tool install coverlet.console --tool-path="$TEST_DIR/coverlet/"
|
||||||
mkdir $COVERAGE_RESULT_DIRECTORY
|
mkdir $COVERAGE_RESULT_DIRECTORY
|
||||||
OPEN_COVER="$TEST_DIR/coverlet/coverlet"
|
OPEN_COVER="$TEST_DIR/coverlet/coverlet"
|
||||||
$OPEN_COVER "$TEST_DIR/" --verbosity "detailed" --format "cobertura" --format "opencover" --output "$COVERAGE_RESULT_DIRECTORY" --exclude "[Lidarr.*.Test]*" --exclude "[Lidarr.Test.*]*" --exclude "[Marr.Data]*" --exclude "[MonoTorrent]*" --exclude "[CurlSharp]*" --target "$NUNIT" --targetargs "$NUNIT_PARAMS --where=\"$WHERE\" $ASSEMBLIES";
|
$OPEN_COVER "$TEST_DIR/" --verbosity "detailed" --format "cobertura" --format "opencover" --output "$COVERAGE_RESULT_DIRECTORY" --exclude "[Lidarr.*.Test]*" --exclude "[Lidarr.Test.*]*" --exclude "[Lidarr.Api.V1]*" --exclude "[Marr.Data]*" --exclude "[MonoTorrent]*" --exclude "[CurlSharp]*" --target "$NUNIT" --targetargs "$NUNIT_PARAMS --where=\"$WHERE\" $ASSEMBLIES";
|
||||||
EXIT_CODE=$?
|
EXIT_CODE=$?
|
||||||
else
|
else
|
||||||
echo "Coverage only supported on Windows and Linux"
|
echo "Coverage only supported on Windows and Linux"
|
||||||
|
|
Loading…
Reference in New Issue