diff --git a/.gitignore b/.gitignore index f25828171..46478856c 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,10 @@ _dotCover* # DevExpress CodeRush src/.cr/ +# Emacs +*~ +\#*\# + # NCrunch *.ncrunch* .*crunch*.local.xml diff --git a/.travis.yml b/.travis.yml index 943a90095..b57e34feb 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,17 +2,24 @@ language: csharp os: - linux - osx +addons: + apt: + packages: + - dos2unix + - nuget + - libchromaprint-tools + update: true + homebrew: + packages: + - yarn + - dos2unix + - nuget + update: true solution: src/Lidarr.sln before_install: - - if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then brew update; fi - - if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then brew install yarn; fi - nvm install 8 - nvm use 8 script: - ./build.sh - - chmod +x test.sh - - ./test.sh Linux Unit -after_success: - - chmod +x package.sh - - ./package.sh - + - if [[ "$TRAVIS_OS_NAME" == "osx" ]]; then ./test.sh Mac Unit; fi + - if [[ "$TRAVIS_OS_NAME" == "linux" ]]; then ./test.sh Linux Unit; fi diff --git a/build.sh b/build.sh index 0b823b594..8cb3d6c22 100755 --- a/build.sh +++ b/build.sh @@ -5,7 +5,7 @@ outputFolderLinux='./_output_linux' outputFolderMacOS='./_output_macos' outputFolderMacOSApp='./_output_macos_app' testPackageFolder='./_tests/' -testSearchPattern='*.Test/bin/x86/Release' +testSearchPattern='*.Test/bin/x86/Release/*' sourceFolder='./src' slnFile=$sourceFolder/Lidarr.sln updateFolder=$outputFolder/Lidarr.Update @@ -97,7 +97,11 @@ LintUI() ProgressEnd 'ESLint' ProgressStart 'Stylelint' - CheckExitCode yarn stylelint + if [ $runtime = "dotnet" ] ; then + CheckExitCode yarn stylelint-windows + else + CheckExitCode yarn stylelint-linux + fi ProgressEnd 'Stylelint' } @@ -171,12 +175,9 @@ PackageMono() rm -f $outputFolderLinux/ServiceUninstall.* rm -f $outputFolderLinux/ServiceInstall.* - echo "Removing native windows binaries Sqlite, MediaInfo" + echo "Removing native windows binaries Sqlite, fpcalc" rm -f $outputFolderLinux/sqlite3.* - rm -f $outputFolderLinux/MediaInfo.* - - echo "Adding Lidarr.Core.dll.config (for dllmap)" - cp $sourceFolder/NzbDrone.Core/Lidarr.Core.dll.config $outputFolderLinux + rm -f $outputFolderLinux/fpcalc* echo "Adding CurlSharp.dll.config (for dllmap)" cp $sourceFolder/NzbDrone.Common/CurlSharp.dll.config $outputFolderLinux @@ -209,13 +210,11 @@ PackageMacOS() echo "Copying Binaries" cp -r $outputFolderLinux/* $outputFolderMacOS + cp $outputFolder/fpcalc $outputFolderMacOS echo "Adding sqlite dylibs" cp $sourceFolder/Libraries/Sqlite/*.dylib $outputFolderMacOS - echo "Adding MediaInfo dylib" - cp $sourceFolder/Libraries/MediaInfo/*.dylib $outputFolderMacOS - ProgressEnd 'Creating MacOS Package' } @@ -234,13 +233,11 @@ PackageMacOSApp() echo "Copying Binaries" cp -r $outputFolderLinux/* $outputFolderMacOSApp/Lidarr.app/Contents/MacOS + cp $outputFolder/fpcalc $outputFolderMacOSApp/Lidarr.app/Contents/MacOS echo "Adding sqlite dylibs" cp $sourceFolder/Libraries/Sqlite/*.dylib $outputFolderMacOSApp/Lidarr.app/Contents/MacOS - echo "Adding MediaInfo dylib" - cp $sourceFolder/Libraries/MediaInfo/*.dylib $outputFolderMacOSApp/Lidarr.app/Contents/MacOS - echo "Removing Update Folder" rm -r $outputFolderMacOSApp/Lidarr.app/Contents/MacOS/Lidarr.Update @@ -254,15 +251,17 @@ PackageTests() rm -rf $testPackageFolder mkdir $testPackageFolder - find $sourceFolder -path $testSearchPattern -exec cp -r -u -T "{}" $testPackageFolder \; + find . -maxdepth 6 -path $testSearchPattern -exec cp -r "{}" $testPackageFolder \; if [ $runtime = "dotnet" ] ; then $nuget install NUnit.ConsoleRunner -Version 3.7.0 -Output $testPackageFolder else - mono $nuget install NUnit.ConsoleRunner -Version 3.7.0 -Output $testPackageFolder + nuget install NUnit.ConsoleRunner -Version 3.7.0 -Output $testPackageFolder fi cp $outputFolder/*.dll $testPackageFolder + cp $outputFolder/*.exe $testPackageFolder + cp $outputFolder/fpcalc $testPackageFolder cp ./*.sh $testPackageFolder echo "Creating MDBs for tests" @@ -281,6 +280,9 @@ PackageTests() echo "Copying CurlSharp libraries" cp $sourceFolder/ExternalModules/CurlSharp/libs/i386/* $testPackageFolder + echo "Copying dylibs" + cp -r $outputFolderMacOS/*.dylib $testPackageFolder + ProgressEnd 'Creating Test Package' } @@ -294,6 +296,9 @@ CleanupWindowsPackage() echo "Adding Lidarr.Windows to UpdatePackage" cp $outputFolder/Lidarr.Windows.* $updateFolder + echo "Removing MacOS fpcalc" + rm $outputFolder/fpcalc + ProgressEnd 'Cleaning Windows Package' } diff --git a/frontend/src/Activity/History/Details/HistoryDetails.js b/frontend/src/Activity/History/Details/HistoryDetails.js index b2adf1171..9eb94eca3 100644 --- a/frontend/src/Activity/History/Details/HistoryDetails.js +++ b/frontend/src/Activity/History/Details/HistoryDetails.js @@ -8,6 +8,33 @@ import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem' import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle'; import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription'; +function getDetailedList(statusMessages) { + return ( +
+ { + statusMessages.map(({ title, messages }) => { + return ( +
+ {title} + +
+ ); + }) + } +
+ ); +} + function HistoryDetails(props) { const { eventType, @@ -124,7 +151,7 @@ function HistoryDetails(props) { ); } - if (eventType === 'downloadFolderImported') { + if (eventType === 'trackFileImported') { const { droppedPath, importedPath @@ -224,6 +251,113 @@ function HistoryDetails(props) { ); } + + if (eventType === 'albumImportIncomplete') { + const { + statusMessages + } = data; + + return ( + + + + { + !!statusMessages && + + } + + ); + } + + if (eventType === 'downloadImported') { + const { + indexer, + releaseGroup, + nzbInfoUrl, + downloadClient, + downloadId, + age, + ageHours, + ageMinutes, + publishedDate + } = data; + + return ( + + + + { + !!indexer && + + } + + { + !!releaseGroup && + + } + + { + !!nzbInfoUrl && + + + Info URL + + + + {nzbInfoUrl} + + + } + + { + !!downloadClient && + + } + + { + !!downloadId && + + } + + { + !!indexer && + + } + + { + !!publishedDate && + + } + + ); + } } HistoryDetails.propTypes = { diff --git a/frontend/src/Activity/History/Details/HistoryDetailsModal.js b/frontend/src/Activity/History/Details/HistoryDetailsModal.js index ca8b9ca3a..0786cc821 100644 --- a/frontend/src/Activity/History/Details/HistoryDetailsModal.js +++ b/frontend/src/Activity/History/Details/HistoryDetailsModal.js @@ -17,12 +17,16 @@ function getHeaderTitle(eventType) { return 'Grabbed'; case 'downloadFailed': return 'Download Failed'; - case 'downloadFolderImported': + case 'trackFileImported': return 'Track Imported'; case 'trackFileDeleted': return 'Track File Deleted'; case 'trackFileRenamed': return 'Track File Renamed'; + case 'albumImportIncomplete': + return 'Album Import Incomplete'; + case 'downloadImported': + return 'Download Completed'; default: return 'Unknown'; } diff --git a/frontend/src/Activity/History/HistoryEventTypeCell.js b/frontend/src/Activity/History/HistoryEventTypeCell.js index 065ab0492..af4511f2e 100644 --- a/frontend/src/Activity/History/HistoryEventTypeCell.js +++ b/frontend/src/Activity/History/HistoryEventTypeCell.js @@ -11,7 +11,7 @@ function getIconName(eventType) { return icons.DOWNLOADING; case 'artistFolderImported': return icons.DRIVE; - case 'downloadFolderImported': + case 'trackFileImported': return icons.DOWNLOADED; case 'downloadFailed': return icons.DOWNLOADING; @@ -19,6 +19,10 @@ function getIconName(eventType) { return icons.DELETE; case 'trackFileRenamed': return icons.ORGANIZE; + case 'albumImportIncomplete': + return icons.DOWNLOADED; + case 'downloadImported': + return icons.DOWNLOADED; default: return icons.UNKNOWN; } @@ -28,6 +32,8 @@ function getIconKind(eventType) { switch (eventType) { case 'downloadFailed': return kinds.DANGER; + case 'albumImportIncomplete': + return kinds.WARNING; default: return kinds.DEFAULT; } @@ -39,7 +45,7 @@ function getTooltip(eventType, data) { return `Album grabbed from ${data.indexer} and sent to ${data.downloadClient}`; case 'artistFolderImported': return 'Track imported from artist folder'; - case 'downloadFolderImported': + case 'trackFileImported': return 'Track downloaded successfully and picked up from download client'; case 'downloadFailed': return 'Album download failed'; @@ -47,6 +53,10 @@ function getTooltip(eventType, data) { return 'Track file deleted'; case 'trackFileRenamed': return 'Track file renamed'; + case 'albumImportIncomplete': + return 'Files downloaded but not all could be imported'; + case 'downloadImported': + return 'Download completed and successfully imported'; default: return 'Unknown event'; } diff --git a/frontend/src/Artist/History/ArtistHistoryRow.js b/frontend/src/Artist/History/ArtistHistoryRow.js index c27604618..8e3ffebdb 100644 --- a/frontend/src/Artist/History/ArtistHistoryRow.js +++ b/frontend/src/Artist/History/ArtistHistoryRow.js @@ -18,7 +18,7 @@ function getTitle(eventType) { switch (eventType) { case 'grabbed': return 'Grabbed'; case 'artistFolderImported': return 'Artist Folder Imported'; - case 'downloadFolderImported': return 'Download Folder Imported'; + case 'trackFileImported': return 'Download Folder Imported'; case 'downloadFailed': return 'Download Failed'; case 'trackFileDeleted': return 'Track File Deleted'; case 'trackFileRenamed': return 'Track File Renamed'; diff --git a/frontend/src/Helpers/Props/icons.js b/frontend/src/Helpers/Props/icons.js index 3eff4f212..6797c7087 100644 --- a/frontend/src/Helpers/Props/icons.js +++ b/frontend/src/Helpers/Props/icons.js @@ -53,6 +53,7 @@ import { faEye as fasEye, faFastBackward as fasFastBackward, faFastForward as fasFastForward, + faFileImport as fasFileImport, faFilter as fasFilter, faFolderOpen as fasFolderOpen, faForward as fasForward, @@ -137,6 +138,7 @@ export const EXPAND_INDETERMINATE = fasChevronCircleRight; export const EXTERNAL_LINK = fasExternalLinkAlt; export const FATAL = fasTimesCircle; export const FILE = farFile; +export const FILEIMPORT = fasFileImport; export const FILTER = fasFilter; export const FOLDER = farFolder; export const FOLDER_OPEN = fasFolderOpen; diff --git a/frontend/src/InteractiveImport/Album/SelectAlbumModalContentConnector.js b/frontend/src/InteractiveImport/Album/SelectAlbumModalContentConnector.js index 494525a2d..b3c03342c 100644 --- a/frontend/src/InteractiveImport/Album/SelectAlbumModalContentConnector.js +++ b/frontend/src/InteractiveImport/Album/SelectAlbumModalContentConnector.js @@ -59,16 +59,19 @@ class SelectAlbumModalContentConnector extends Component { onAlbumSelect = (albumId) => { const album = _.find(this.props.items, { id: albumId }); - this.props.ids.forEach((id) => { + const ids = this.props.ids; + + ids.forEach((id) => { this.props.updateInteractiveImportItem({ id, album, tracks: [], rejections: [] }); - this.props.saveInteractiveImportItem({ id }); }); + this.props.saveInteractiveImportItem({ id: ids }); + this.props.onModalClose(true); } diff --git a/frontend/src/InteractiveImport/FileDetails.css b/frontend/src/InteractiveImport/FileDetails.css new file mode 100644 index 000000000..964670900 --- /dev/null +++ b/frontend/src/InteractiveImport/FileDetails.css @@ -0,0 +1,65 @@ +.fileDetails { + margin-bottom: 20px; + border: 1px solid $borderColor; + border-radius: 4px; + background-color: $white; + + &:last-of-type { + margin-bottom: 0; + } +} + +.header { + position: relative; + display: flex; + align-items: center; + width: 100%; + font-size: 18px; +} + +.filename { + flex-grow: 1; + margin-right: 10px; + margin-left: 10px; +} + +.expandButton { + position: relative; + width: 60px; + height: 60px; +} + +.actionButton { + composes: button from 'Components/Link/IconButton.css'; + + width: 30px; +} + +.audioTags { + padding-top: 15px; + padding-bottom: 15px; + border-top: 1px solid $borderColor; +} + +.expandButtonIcon { + composes: actionButton; + + position: absolute; + top: 50%; + left: 50%; + margin-top: -12px; + margin-left: -15px; +} + +@media only screen and (max-width: $breakpointSmall) { + .medium { + border-right: 0; + border-left: 0; + border-radius: 0; + } + + .expandButtonIcon { + position: static; + margin: 0; + } +} diff --git a/frontend/src/InteractiveImport/FileDetails.js b/frontend/src/InteractiveImport/FileDetails.js new file mode 100644 index 000000000..6a7383ca7 --- /dev/null +++ b/frontend/src/InteractiveImport/FileDetails.js @@ -0,0 +1,258 @@ +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 ( + + + Rejections + + { + _.map(rejections, (item, key) => { + return ( + + {item.reason} + + ); + }) + } + + ); + } + + render() { + const { + filename, + audioTags, + rejections + } = this.props; + + const { + isExpanded + } = this.state; + + return ( +
+
+
+ {filename} +
+ +
+ +
+
+ +
+ { + isExpanded && +
+ + + { + audioTags.title !== undefined && + + } + { + audioTags.trackNumbers[0] > 0 && + + } + { + audioTags.discNumber > 0 && + + } + { + audioTags.discCount > 0 && + + } + { + audioTags.albumTitle !== undefined && + + } + { + audioTags.artistTitle !== undefined && + + } + { + audioTags.country !== undefined && + + } + { + audioTags.year > 0 && + + } + { + audioTags.label !== undefined && + + } + { + audioTags.catalogNumber !== undefined && + + } + { + audioTags.disambiguation !== undefined && + + } + { + audioTags.duration !== undefined && + + } + { + audioTags.artistMBId !== undefined && + + + + } + { + audioTags.albumMBId !== undefined && + + + + } + { + audioTags.releaseMBId !== undefined && + + + + } + { + audioTags.recordingMBId !== undefined && + + + + } + { + audioTags.trackMBId !== undefined && + + + + } + { + rejections.length > 0 && + this.renderRejections() + } + +
+ } +
+
+ ); + } +} + +FileDetails.propTypes = { + audioTags: PropTypes.object.isRequired, + filename: PropTypes.string.isRequired, + rejections: PropTypes.arrayOf(PropTypes.object).isRequired, + isExpanded: PropTypes.bool +}; + +export default FileDetails; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css index 5bad6c050..a095e9874 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.css @@ -19,12 +19,13 @@ .centerButtons, .rightButtons { display: flex; - flex: 1 0 33%; + flex: 1 2 25%; flex-wrap: wrap; } .centerButtons { justify-content: center; + flex: 2 1 50%; } .rightButtons { diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js index b0f2b77a8..ce6a303fb 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContent.js @@ -155,6 +155,18 @@ class InteractiveImportModalContent extends Component { this.setState({ isSelectAlbumModalOpen: true }); } + onClearTrackMappingPress = () => { + const selectedIds = this.getSelectedIds(); + + selectedIds.forEach((id) => { + this.props.updateInteractiveImportItem({ + id, + tracks: [], + rejections: [] + }); + }); + } + onSelectArtistModalClose = () => { this.setState({ isSelectArtistModalOpen: false }); } @@ -328,6 +340,10 @@ class InteractiveImportModalContent extends Component { > Select Album + +
@@ -362,6 +378,7 @@ class InteractiveImportModalContent extends Component { artistId={selectedItem && selectedItem.artist && selectedItem.artist.id} onModalClose={this.onSelectAlbumModalClose} /> + ); } @@ -387,6 +404,7 @@ InteractiveImportModalContent.propTypes = { onFilterExistingFilesChange: PropTypes.func.isRequired, onImportModeChange: PropTypes.func.isRequired, onImportSelectedPress: PropTypes.func.isRequired, + updateInteractiveImportItem: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js index ef0758e60..8112072c0 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportModalContentConnector.js @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { createSelector } from 'reselect'; -import { fetchInteractiveImportItems, setInteractiveImportSort, clearInteractiveImport, setInteractiveImportMode } from 'Store/Actions/interactiveImportActions'; +import { fetchInteractiveImportItems, setInteractiveImportSort, clearInteractiveImport, setInteractiveImportMode, updateInteractiveImportItem } from 'Store/Actions/interactiveImportActions'; import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; import { executeCommand } from 'Store/Actions/commandActions'; import * as commandNames from 'Commands/commandNames'; @@ -23,6 +23,7 @@ const mapDispatchToProps = { setInteractiveImportSort, setInteractiveImportMode, clearInteractiveImport, + updateInteractiveImportItem, executeCommand }; @@ -195,6 +196,7 @@ InteractiveImportModalContentConnector.propTypes = { setInteractiveImportSort: PropTypes.func.isRequired, clearInteractiveImport: PropTypes.func.isRequired, setInteractiveImportMode: PropTypes.func.isRequired, + updateInteractiveImportItem: PropTypes.func.isRequired, executeCommand: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js index c40197767..e88d54ad1 100644 --- a/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js +++ b/frontend/src/InteractiveImport/Interactive/InteractiveImportRow.js @@ -2,7 +2,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import formatBytes from 'Utilities/Number/formatBytes'; import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; -import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import { icons, kinds, tooltipPositions, sortDirections } from 'Helpers/Props'; import Icon from 'Components/Icon'; import TableRow from 'Components/Table/TableRow'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; @@ -167,11 +167,13 @@ class InteractiveImportRow extends Component { relativePath, artist, album, + albumReleaseId, tracks, quality, language, size, rejections, + audioTags, isSelected, onSelectedChange } = this.props; @@ -327,6 +329,11 @@ class InteractiveImportRow extends Component { id={id} artistId={artist && artist.id} albumId={album && album.id} + albumReleaseId={albumReleaseId} + rejections={rejections} + audioTags={audioTags} + sortKey='mediumNumber' + sortDirection={sortDirections.ASCENDING} filename={relativePath} onModalClose={this.onSelectTrackModalClose} /> @@ -358,11 +365,13 @@ InteractiveImportRow.propTypes = { relativePath: PropTypes.string.isRequired, artist: PropTypes.object, album: PropTypes.object, + albumReleaseId: PropTypes.number, tracks: PropTypes.arrayOf(PropTypes.object).isRequired, quality: PropTypes.object, language: PropTypes.object, size: PropTypes.number.isRequired, rejections: PropTypes.arrayOf(PropTypes.object).isRequired, + audioTags: PropTypes.object.isRequired, isSelected: PropTypes.bool, onSelectedChange: PropTypes.func.isRequired, onValidRowChange: PropTypes.func.isRequired diff --git a/frontend/src/InteractiveImport/Track/SelectTrackModalContent.js b/frontend/src/InteractiveImport/Track/SelectTrackModalContent.js index cfec2c163..96f903bfa 100644 --- a/frontend/src/InteractiveImport/Track/SelectTrackModalContent.js +++ b/frontend/src/InteractiveImport/Track/SelectTrackModalContent.js @@ -1,5 +1,6 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; +import _ from 'lodash'; import getErrorMessage from 'Utilities/Object/getErrorMessage'; import getSelectedIds from 'Utilities/Table/getSelectedIds'; import selectAll from 'Utilities/Table/selectAll'; @@ -14,6 +15,7 @@ import ModalFooter from 'Components/Modal/ModalFooter'; import Table from 'Components/Table/Table'; import TableBody from 'Components/Table/TableBody'; import SelectTrackRow from './SelectTrackRow'; +import FileDetails from '../FileDetails'; const columns = [ { @@ -32,6 +34,19 @@ const columns = [ name: 'title', label: 'Title', isVisible: true + }, + { + name: 'trackStatus', + label: 'Status', + isVisible: true + } +]; + +const selectAllBlankColumn = [ + { + name: 'dummy', + label: ' ', + isVisible: true } ]; @@ -43,12 +58,17 @@ class SelectTrackModalContent extends Component { constructor(props, context) { super(props, context); + const selectedTracks = _.filter(props.selectedTracksByItem, ['id', props.id])[0].tracks; + const init = _.zipObject(selectedTracks, _.times(selectedTracks.length, _.constant(true))); + this.state = { allSelected: false, allUnselected: false, lastToggled: null, - selectedState: {} + selectedState: init }; + + props.onSortPress( props.sortKey, props.sortDirection ); } // @@ -80,6 +100,9 @@ class SelectTrackModalContent extends Component { render() { const { + id, + audioTags, + rejections, isFetching, isPopulated, error, @@ -88,6 +111,7 @@ class SelectTrackModalContent extends Component { sortDirection, onSortPress, onModalClose, + selectedTracksByItem, filename } = this.props; @@ -97,13 +121,23 @@ class SelectTrackModalContent extends Component { selectedState } = this.state; - const title = `Manual Import - Select Track(s): ${filename}`; const errorMessage = getErrorMessage(error, 'Unable to load tracks'); + // all tracks selected for other items + const otherSelected = _.map(_.filter(selectedTracksByItem, (item) => { + return item.id !== id; + }), (x) => { + return x.tracks; + }).flat(); + // tracks selected for the current file + const currentSelected = _.keys(_.pickBy(selectedState, _.identity)).map(Number); + // only enable selectAll if no other files have any tracks selected. + const selectAllEnabled = otherSelected.length === 0; + return ( - {title} + Manual Import - Select Track(s): @@ -117,11 +151,18 @@ class SelectTrackModalContent extends Component {
{errorMessage}
} + + { isPopulated && !!items.length && @@ -173,6 +217,9 @@ class SelectTrackModalContent extends Component { } SelectTrackModalContent.propTypes = { + id: PropTypes.number.isRequired, + rejections: PropTypes.arrayOf(PropTypes.object).isRequired, + audioTags: PropTypes.object.isRequired, isFetching: PropTypes.bool.isRequired, isPopulated: PropTypes.bool.isRequired, error: PropTypes.object, @@ -182,6 +229,7 @@ SelectTrackModalContent.propTypes = { onSortPress: PropTypes.func.isRequired, onTracksSelect: PropTypes.func.isRequired, onModalClose: PropTypes.func.isRequired, + selectedTracksByItem: PropTypes.arrayOf(PropTypes.object).isRequired, filename: PropTypes.string.isRequired }; diff --git a/frontend/src/InteractiveImport/Track/SelectTrackModalContentConnector.js b/frontend/src/InteractiveImport/Track/SelectTrackModalContentConnector.js index a1d6f454d..35b17ade5 100644 --- a/frontend/src/InteractiveImport/Track/SelectTrackModalContentConnector.js +++ b/frontend/src/InteractiveImport/Track/SelectTrackModalContentConnector.js @@ -11,8 +11,19 @@ import SelectTrackModalContent from './SelectTrackModalContent'; function createMapStateToProps() { return createSelector( createClientSideCollectionSelector('tracks'), - (tracks) => { - return tracks; + createClientSideCollectionSelector('interactiveImport'), + (tracks, interactiveImport) => { + + const selectedTracksByItem = _.map(interactiveImport.items, (item) => { + return { id: item.id, tracks: _.map(item.tracks, (track) => { + return track.id; + }) }; + }); + + return { + ...tracks, + selectedTracksByItem + }; } ); } @@ -32,10 +43,11 @@ class SelectTrackModalContentConnector extends Component { componentDidMount() { const { artistId, - albumId + albumId, + albumReleaseId } = this.props; - this.props.fetchTracks({ artistId, albumId }); + this.props.fetchTracks({ artistId, albumId, albumReleaseId }); } componentWillUnmount() { @@ -86,6 +98,9 @@ SelectTrackModalContentConnector.propTypes = { id: PropTypes.number.isRequired, artistId: PropTypes.number.isRequired, albumId: PropTypes.number.isRequired, + albumReleaseId: PropTypes.number.isRequired, + rejections: PropTypes.arrayOf(PropTypes.object).isRequired, + audioTags: PropTypes.object.isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired, fetchTracks: PropTypes.func.isRequired, setTracksSort: PropTypes.func.isRequired, diff --git a/frontend/src/InteractiveImport/Track/SelectTrackRow.js b/frontend/src/InteractiveImport/Track/SelectTrackRow.js index 476dda5ad..f7dea7af3 100644 --- a/frontend/src/InteractiveImport/Track/SelectTrackRow.js +++ b/frontend/src/InteractiveImport/Track/SelectTrackRow.js @@ -3,6 +3,9 @@ import React, { Component } from 'react'; import TableRowButton from 'Components/Table/TableRowButton'; import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableSelectCell from 'Components/Table/Cells/TableSelectCell'; +import { icons, kinds, tooltipPositions } from 'Helpers/Props'; +import Icon from 'Components/Icon'; +import Popover from 'Components/Tooltip/Popover'; class SelectTrackRow extends Component { @@ -27,16 +30,50 @@ class SelectTrackRow extends Component { mediumNumber, trackNumber, title, + hasFile, + importSelected, isSelected, + isDisabled, onSelectedChange } = this.props; + let iconName = icons.UNKNOWN; + let iconKind = kinds.DEFAULT; + let iconTip = ''; + + if (hasFile && !importSelected) { + iconName = icons.DOWNLOADED; + iconKind = kinds.DEFAULT; + iconTip = 'Track already in library.'; + } else if (!hasFile && !importSelected) { + iconName = icons.UNKNOWN; + iconKind = kinds.DEFAULT; + iconTip = 'Track missing from library and no import selected.'; + } else if (importSelected && hasFile) { + iconName = icons.FILEIMPORT; + iconKind = kinds.WARNING; + iconTip = 'Warning: Existing track will be replaced by download.'; + } else if (importSelected && !hasFile) { + iconName = icons.FILEIMPORT; + iconKind = kinds.DEFAULT; + iconTip = 'Track missing from library and selected for import.'; + } + + // isDisabled can only be true if importSelected is true + if (isDisabled) { + iconTip = `${iconTip}\nAnother file is selected to import for this track.`; + } + return ( - + @@ -51,6 +88,19 @@ class SelectTrackRow extends Component { {title} + + + } + title={'Track status'} + body={iconTip} + position={tooltipPositions.LEFT} + /> + ); } @@ -61,7 +111,10 @@ SelectTrackRow.propTypes = { mediumNumber: PropTypes.number.isRequired, trackNumber: PropTypes.number.isRequired, title: PropTypes.string.isRequired, + hasFile: PropTypes.bool.isRequired, + importSelected: PropTypes.bool.isRequired, isSelected: PropTypes.bool, + isDisabled: PropTypes.bool, onSelectedChange: PropTypes.func.isRequired }; diff --git a/frontend/src/Settings/MediaManagement/MediaManagement.js b/frontend/src/Settings/MediaManagement/MediaManagement.js index 9ff43b951..4aaf5540a 100644 --- a/frontend/src/Settings/MediaManagement/MediaManagement.js +++ b/frontend/src/Settings/MediaManagement/MediaManagement.js @@ -18,6 +18,12 @@ const rescanAfterRefreshOptions = [ { key: 'never', value: 'Never' } ]; +const allowFingerprintingOptions = [ + { key: 'allFiles', value: 'Always' }, + { key: 'newFiles', value: 'For new imports only' }, + { key: 'never', value: 'Never' } +]; + const fileDateOptions = [ { key: 'none', value: 'None' }, { key: 'albumReleaseDate', value: 'Album Release Date' } @@ -212,16 +218,17 @@ class MediaManagement extends Component { - Analyse audio files + Rescan Artist Folder after Refresh @@ -229,16 +236,16 @@ class MediaManagement extends Component { advancedSettings={advancedSettings} isAdvanced={true} > - Rescan Artist Folder after Refresh + Allow Fingerprinting diff --git a/frontend/src/Settings/MediaManagement/Naming/NamingModal.js b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js index c080e12b9..9570f23d9 100644 --- a/frontend/src/Settings/MediaManagement/Naming/NamingModal.js +++ b/frontend/src/Settings/MediaManagement/Naming/NamingModal.js @@ -168,11 +168,10 @@ class NamingModal extends Component { ]; const mediaInfoTokens = [ - { token: '{MediaInfo Simple}', example: 'x264 DTS' }, - { token: '{MediaInfo Full}', example: 'x264 DTS [EN+DE]' }, - { token: '{MediaInfo VideoCodec}', example: 'x264' }, - { token: '{MediaInfo AudioFormat}', example: 'DTS' }, - { token: '{MediaInfo AudioChannels}', example: '5.1' } + { token: '{MediaInfo AudioCodec}', example: 'FLAC' }, + { token: '{MediaInfo AudioChannels}', example: '2.0' }, + { token: '{MediaInfo AudioBitsPerSample}', example: '24bit' }, + { token: '{MediaInfo AudioSampleRate}', example: '44.1kHz' } ]; const releaseGroupTokens = [ diff --git a/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js b/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js index 6d555bf23..d6a62d618 100644 --- a/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js +++ b/frontend/src/Store/Actions/Creators/createSaveProviderHandler.js @@ -25,7 +25,7 @@ function createSaveProviderHandler(section, url, options = {}) { ...otherPayload } = payload; - const saveData = getProviderState({ id, ...otherPayload }, getState, section); + const saveData = Array.isArray(id) ? id.map((x) => getProviderState({ id: x, ...otherPayload }, getState, section)) : getProviderState({ id, ...otherPayload }, getState, section); const ajaxOptions = { url: `${url}?${$.param(queryParams, true)}`, @@ -36,8 +36,10 @@ function createSaveProviderHandler(section, url, options = {}) { }; if (id) { - ajaxOptions.url = `${url}/${id}?${$.param(queryParams, true)}`; ajaxOptions.method = 'PUT'; + if (!Array.isArray(id)) { + ajaxOptions.url = `${url}/${id}?${$.param(queryParams, true)}`; + } } const { request, abortRequest } = createAjaxRequest(ajaxOptions); @@ -45,16 +47,18 @@ function createSaveProviderHandler(section, url, options = {}) { abortCurrentRequests[section] = abortRequest; request.done((data) => { - dispatch(batchActions([ - updateItem({ section, ...data }), - - set({ - section, - isSaving: false, - saveError: null, - pendingChanges: {} - }) - ])); + if (!Array.isArray(data)) { + data = [data]; + } + dispatch(batchActions( + data.map((item) => updateItem({ section, ...item })).concat( + set({ + section, + isSaving: false, + saveError: null, + pendingChanges: {} + }) + ))); }); request.fail((xhr) => { diff --git a/frontend/src/Store/Actions/artistIndexActions.js b/frontend/src/Store/Actions/artistIndexActions.js index bf719f6c1..8c2232c0a 100644 --- a/frontend/src/Store/Actions/artistIndexActions.js +++ b/frontend/src/Store/Actions/artistIndexActions.js @@ -76,8 +76,7 @@ export const defaultState = { name: 'artistType', label: 'Type', isSortable: true, - isVisible: true, - isModifiable: false + isVisible: true }, { name: 'qualityProfileId', diff --git a/frontend/src/Store/Actions/historyActions.js b/frontend/src/Store/Actions/historyActions.js index 98c5203be..715e06562 100644 --- a/frontend/src/Store/Actions/historyActions.js +++ b/frontend/src/Store/Actions/historyActions.js @@ -109,8 +109,8 @@ export const defaultState = { ] }, { - key: 'imported', - label: 'Imported', + key: 'trackFileImported', + label: 'Track Imported', filters: [ { key: 'eventType', @@ -121,7 +121,7 @@ export const defaultState = { }, { key: 'failed', - label: 'Failed', + label: 'Download Failed', filters: [ { key: 'eventType', @@ -130,6 +130,28 @@ export const defaultState = { } ] }, + { + key: 'importFailed', + label: 'Import Failed', + filters: [ + { + key: 'eventType', + value: '7', + type: filterTypes.EQUAL + } + ] + }, + { + key: 'downloadImported', + label: 'Download Imported', + filters: [ + { + key: 'eventType', + value: '8', + type: filterTypes.EQUAL + } + ] + }, { key: 'deleted', label: 'Deleted', diff --git a/package.json b/package.json index 23ba78bc3..ad0d05c3d 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,9 @@ "start": "gulp watch", "clean": "rimraf ./_output/UI", "eslint": "esprint check", - "eslint-fix": "eslint start --fix", - "stylelint": "stylelint frontend/**/*.css --config frontend/.stylelintrc" + "eslint-fix": "eslint --fix frontend/** ", + "stylelint-linux": "stylelint $(find frontend -name '*.css') --config frontend/.stylelintrc", + "stylelint-windows": "stylelint frontend/**/*.css --config frontend/.stylelintrc" }, "repository": { "type": "git", diff --git a/src/Libraries/Fpcalc/chromaprint-fpcalc-1.4.3-macos-x86_64/fpcalc b/src/Libraries/Fpcalc/chromaprint-fpcalc-1.4.3-macos-x86_64/fpcalc new file mode 100755 index 000000000..e8b3ae314 Binary files /dev/null and b/src/Libraries/Fpcalc/chromaprint-fpcalc-1.4.3-macos-x86_64/fpcalc differ diff --git a/src/Libraries/Fpcalc/chromaprint-fpcalc-1.4.3-windows-x86_64/fpcalc.exe b/src/Libraries/Fpcalc/chromaprint-fpcalc-1.4.3-windows-x86_64/fpcalc.exe new file mode 100755 index 000000000..94d7c81ec Binary files /dev/null and b/src/Libraries/Fpcalc/chromaprint-fpcalc-1.4.3-windows-x86_64/fpcalc.exe differ diff --git a/src/Libraries/MediaInfo/MediaInfo.dll b/src/Libraries/MediaInfo/MediaInfo.dll deleted file mode 100644 index 24e6cb986..000000000 Binary files a/src/Libraries/MediaInfo/MediaInfo.dll and /dev/null differ diff --git a/src/Libraries/MediaInfo/libmediainfo.0.dylib b/src/Libraries/MediaInfo/libmediainfo.0.dylib deleted file mode 100644 index 5e5383ded..000000000 Binary files a/src/Libraries/MediaInfo/libmediainfo.0.dylib and /dev/null differ diff --git a/src/Lidarr.Api.V1/Albums/AlbumModule.cs b/src/Lidarr.Api.V1/Albums/AlbumModule.cs index 77ef981bf..1b19913f4 100644 --- a/src/Lidarr.Api.V1/Albums/AlbumModule.cs +++ b/src/Lidarr.Api.V1/Albums/AlbumModule.cs @@ -7,12 +7,20 @@ using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Music; using NzbDrone.SignalR; using Lidarr.Http.Extensions; -using Lidarr.Http.REST; using NzbDrone.Core.ArtistStats; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Download; +using NzbDrone.Core.Music.Events; +using NzbDrone.Core.MediaFiles.Events; namespace Lidarr.Api.V1.Albums { - public class AlbumModule : AlbumModuleWithSignalR + public class AlbumModule : AlbumModuleWithSignalR, + IHandle, + IHandle, + IHandle, + IHandle + { protected readonly IReleaseService _releaseService; @@ -83,5 +91,31 @@ namespace Lidarr.Api.V1.Albums return MapToResource(_albumService.GetAlbums(resource.AlbumIds), false).AsResponse(HttpStatusCode.Accepted); } + + public void Handle(AlbumGrabbedEvent message) + { + foreach (var album in message.Album.Albums) + { + var resource = album.ToResource(); + resource.Grabbed = true; + + BroadcastResourceChange(ModelAction.Updated, resource); + } + } + + public void Handle(AlbumEditedEvent message) + { + BroadcastResourceChange(ModelAction.Updated, MapToResource(message.Album, true)); + } + + public void Handle(AlbumImportedEvent message) + { + BroadcastResourceChange(ModelAction.Updated, MapToResource(message.Album, true)); + } + + public void Handle(TrackImportedEvent message) + { + BroadcastResourceChange(ModelAction.Updated, message.TrackInfo.Album.ToResource()); + } } } diff --git a/src/Lidarr.Api.V1/Albums/AlbumModuleWithSignalR.cs b/src/Lidarr.Api.V1/Albums/AlbumModuleWithSignalR.cs index e4a5f591b..48ffeb225 100644 --- a/src/Lidarr.Api.V1/Albums/AlbumModuleWithSignalR.cs +++ b/src/Lidarr.Api.V1/Albums/AlbumModuleWithSignalR.cs @@ -1,9 +1,5 @@ -using System; using System.Collections.Generic; using System.Linq; -using FluentValidation; -using NzbDrone.Core.Datastore.Events; -using NzbDrone.Core.Messaging.Events; using NzbDrone.Common.Extensions; using Lidarr.Api.V1.Artist; using NzbDrone.Core.DecisionEngine; @@ -11,16 +7,10 @@ using NzbDrone.Core.Music; using NzbDrone.Core.ArtistStats; using NzbDrone.SignalR; using Lidarr.Http; -using NzbDrone.Core.Download; -using NzbDrone.Core.MediaFiles.Events; -using NzbDrone.Core.Music.Events; namespace Lidarr.Api.V1.Albums { - public abstract class AlbumModuleWithSignalR : LidarrRestModuleWithSignalR, - IHandle, - IHandle, - IHandle + public abstract class AlbumModuleWithSignalR : LidarrRestModuleWithSignalR { protected readonly IAlbumService _albumService; protected readonly IArtistStatisticsService _artistStatisticsService; @@ -124,34 +114,5 @@ namespace Lidarr.Api.V1.Albums } } - - public void Handle(AlbumGrabbedEvent message) - { - foreach (var album in message.Album.Albums) - { - var resource = album.ToResource(); - resource.Grabbed = true; - - BroadcastResourceChange(ModelAction.Updated, resource); - } - } - - public void Handle(AlbumEditedEvent message) - { - BroadcastResourceChange(ModelAction.Updated, message.Album.Id); - } - - public void Handle(TrackImportedEvent message) - { - BroadcastResourceChange(ModelAction.Updated, message.ImportedTrack.AlbumId); - } - - //public void Handle(TrackDownloadedEvent message) - //{ - // foreach (var album in message.Album.Albums) - // { - // BroadcastResourceChange(ModelAction.Updated, album.Id); - // } - //} } } diff --git a/src/Lidarr.Api.V1/Artist/ArtistModule.cs b/src/Lidarr.Api.V1/Artist/ArtistModule.cs index 5a0905703..faeaf0b06 100644 --- a/src/Lidarr.Api.V1/Artist/ArtistModule.cs +++ b/src/Lidarr.Api.V1/Artist/ArtistModule.cs @@ -24,7 +24,7 @@ using Lidarr.Http.Extensions; namespace Lidarr.Api.V1.Artist { public class ArtistModule : LidarrRestModuleWithSignalR, - IHandle, + IHandle, IHandle, IHandle, IHandle, @@ -236,26 +236,26 @@ namespace Lidarr.Api.V1.Artist resource.RootFolderPath = _rootFolderService.GetBestRootFolderPath(resource.Path); } - public void Handle(TrackImportedEvent message) + public void Handle(AlbumImportedEvent message) { - BroadcastResourceChange(ModelAction.Updated, message.TrackInfo.Artist.ToResource()); + BroadcastResourceChange(ModelAction.Updated, GetArtistResource(message.Artist)); } public void Handle(TrackFileDeletedEvent message) { if (message.Reason == DeleteMediaFileReason.Upgrade) return; - BroadcastResourceChange(ModelAction.Updated, message.TrackFile.ArtistId); + BroadcastResourceChange(ModelAction.Updated, GetArtistResource(message.TrackFile.Artist.Value)); } public void Handle(ArtistUpdatedEvent message) { - BroadcastResourceChange(ModelAction.Updated, message.Artist.Id); + BroadcastResourceChange(ModelAction.Updated, GetArtistResource(message.Artist)); } public void Handle(ArtistEditedEvent message) { - BroadcastResourceChange(ModelAction.Updated, message.Artist.Id); + BroadcastResourceChange(ModelAction.Updated, GetArtistResource(message.Artist)); } public void Handle(ArtistDeletedEvent message) @@ -270,7 +270,7 @@ namespace Lidarr.Api.V1.Artist public void Handle(MediaCoversUpdatedEvent message) { - BroadcastResourceChange(ModelAction.Updated, message.Artist.Id); + BroadcastResourceChange(ModelAction.Updated, GetArtistResource(message.Artist)); } } } diff --git a/src/Lidarr.Api.V1/Artist/ArtistResource.cs b/src/Lidarr.Api.V1/Artist/ArtistResource.cs index 88c5cb7fe..da77685c5 100644 --- a/src/Lidarr.Api.V1/Artist/ArtistResource.cs +++ b/src/Lidarr.Api.V1/Artist/ArtistResource.cs @@ -81,7 +81,7 @@ namespace Lidarr.Api.V1.Artist ArtistType = model.Metadata.Value.Type, Disambiguation = model.Metadata.Value.Disambiguation, - Images = model.Metadata.Value.Images, + Images = model.Metadata.Value.Images.JsonClone(), Path = model.Path, QualityProfileId = model.ProfileId, diff --git a/src/Lidarr.Api.V1/Config/MediaManagementConfigResource.cs b/src/Lidarr.Api.V1/Config/MediaManagementConfigResource.cs index 28a6dc5b7..072f0f2bc 100644 --- a/src/Lidarr.Api.V1/Config/MediaManagementConfigResource.cs +++ b/src/Lidarr.Api.V1/Config/MediaManagementConfigResource.cs @@ -13,6 +13,7 @@ namespace Lidarr.Api.V1.Config public bool DeleteEmptyFolders { get; set; } public FileDateType FileDate { get; set; } public RescanAfterRefreshType RescanAfterRefresh { get; set; } + public AllowFingerprinting AllowFingerprinting { get; set; } public bool SetPermissionsLinux { get; set; } public string FileChmod { get; set; } @@ -24,7 +25,6 @@ namespace Lidarr.Api.V1.Config public bool CopyUsingHardlinks { get; set; } public bool ImportExtraFiles { get; set; } public string ExtraFileExtensions { get; set; } - public bool EnableMediaInfo { get; set; } } public static class MediaManagementConfigResourceMapper @@ -40,6 +40,7 @@ namespace Lidarr.Api.V1.Config DeleteEmptyFolders = model.DeleteEmptyFolders, FileDate = model.FileDate, RescanAfterRefresh = model.RescanAfterRefresh, + AllowFingerprinting = model.AllowFingerprinting, SetPermissionsLinux = model.SetPermissionsLinux, FileChmod = model.FileChmod, @@ -51,7 +52,6 @@ namespace Lidarr.Api.V1.Config CopyUsingHardlinks = model.CopyUsingHardlinks, ImportExtraFiles = model.ImportExtraFiles, ExtraFileExtensions = model.ExtraFileExtensions, - EnableMediaInfo = model.EnableMediaInfo }; } } diff --git a/src/Lidarr.Api.V1/ManualImport/ManualImportModule.cs b/src/Lidarr.Api.V1/ManualImport/ManualImportModule.cs index 3fc11ce3a..06b6e3f1c 100644 --- a/src/Lidarr.Api.V1/ManualImport/ManualImportModule.cs +++ b/src/Lidarr.Api.V1/ManualImport/ManualImportModule.cs @@ -2,12 +2,11 @@ using System.Collections.Generic; using System.Linq; using NzbDrone.Core.MediaFiles.TrackImport.Manual; using NzbDrone.Core.Qualities; -using Lidarr.Http; using Lidarr.Http.Extensions; using NzbDrone.SignalR; -using NzbDrone.Core.Datastore.Events; using NzbDrone.Core.Music; using NLog; +using Nancy; namespace Lidarr.Api.V1.ManualImport { @@ -30,7 +29,13 @@ namespace Lidarr.Api.V1.ManualImport _releaseService = releaseService; GetResourceAll = GetMediaFiles; - UpdateResource = UpdateImportItem; + + Put["/"] = options => + { + var resource = Request.Body.FromJson>(); + UpdateImportItems(resource); + return GetManualImportItems(resource.Select(x => x.Id)).AsResponse(HttpStatusCode.Accepted); + }; } private List GetMediaFiles() @@ -54,26 +59,29 @@ namespace Lidarr.Api.V1.ManualImport return item; } - private void UpdateImportItem(ManualImportResource resource) + private void UpdateImportItems(List resources) { - var item = new ManualImportItem{ - Id = resource.Id, - Path = resource.Path, - RelativePath = resource.RelativePath, - FolderName = resource.FolderName, - Name = resource.Name, - Size = resource.Size, - Artist = resource.Artist == null ? null : _artistService.GetArtist(resource.Artist.Id), - Album = resource.Album == null ? null : _albumService.GetAlbum(resource.Album.Id), - Release = resource.AlbumReleaseId == 0 ? null : _releaseService.GetRelease(resource.AlbumReleaseId), - Quality = resource.Quality, - Language = resource.Language, - DownloadId = resource.DownloadId - }; + var items = new List(); + foreach (var resource in resources) + { + items.Add(new ManualImportItem { + Id = resource.Id, + Path = resource.Path, + RelativePath = resource.RelativePath, + FolderName = resource.FolderName, + Name = resource.Name, + Size = resource.Size, + Artist = resource.Artist == null ? null : _artistService.GetArtist(resource.Artist.Id), + Album = resource.Album == null ? null : _albumService.GetAlbum(resource.Album.Id), + Release = resource.AlbumReleaseId == 0 ? null : _releaseService.GetRelease(resource.AlbumReleaseId), + Quality = resource.Quality, + Language = resource.Language, + DownloadId = resource.DownloadId + }); + } //recalculate import and broadcast - _manualImportService.UpdateItem(item); - BroadcastResourceChange(ModelAction.Updated, item.Id); + _manualImportService.UpdateItems(items); } } } diff --git a/src/Lidarr.Api.V1/ManualImport/ManualImportModuleWithSignalR.cs b/src/Lidarr.Api.V1/ManualImport/ManualImportModuleWithSignalR.cs index 12a9cd6f6..87210a163 100644 --- a/src/Lidarr.Api.V1/ManualImport/ManualImportModuleWithSignalR.cs +++ b/src/Lidarr.Api.V1/ManualImport/ManualImportModuleWithSignalR.cs @@ -41,5 +41,10 @@ namespace Lidarr.Api.V1.ManualImport { return _manualImportService.Find(id).ToResource(); } + + protected List GetManualImportItems(IEnumerable ids) + { + return ids.Select(x => _manualImportService.Find(x).ToResource()).ToList(); + } } } diff --git a/src/Lidarr.Api.V1/ManualImport/ManualImportResource.cs b/src/Lidarr.Api.V1/ManualImport/ManualImportResource.cs index cd3c60b17..52b07a183 100644 --- a/src/Lidarr.Api.V1/ManualImport/ManualImportResource.cs +++ b/src/Lidarr.Api.V1/ManualImport/ManualImportResource.cs @@ -10,6 +10,7 @@ using Lidarr.Http.REST; using System.Collections.Generic; using System.Linq; using NzbDrone.Core.Music; +using NzbDrone.Core.Parser.Model; namespace Lidarr.Api.V1.ManualImport { @@ -29,6 +30,7 @@ namespace Lidarr.Api.V1.ManualImport public int QualityWeight { get; set; } public string DownloadId { get; set; } public IEnumerable Rejections { get; set; } + public ParsedTrackInfo AudioTags { get; set; } } public static class ManualImportResourceMapper @@ -53,7 +55,8 @@ namespace Lidarr.Api.V1.ManualImport Language = model.Language, //QualityWeight DownloadId = model.DownloadId, - Rejections = model.Rejections + Rejections = model.Rejections, + AudioTags = model.Tags }; } diff --git a/src/Lidarr.Api.V1/TrackFiles/MediaInfoResource.cs b/src/Lidarr.Api.V1/TrackFiles/MediaInfoResource.cs index a3b586585..69c60d739 100644 --- a/src/Lidarr.Api.V1/TrackFiles/MediaInfoResource.cs +++ b/src/Lidarr.Api.V1/TrackFiles/MediaInfoResource.cs @@ -1,5 +1,6 @@ -using NzbDrone.Core.MediaFiles.MediaInfo; +using NzbDrone.Core.MediaFiles; using Lidarr.Http.REST; +using NzbDrone.Core.Parser.Model; namespace Lidarr.Api.V1.TrackFiles { diff --git a/src/Lidarr.Api.V1/TrackFiles/TrackFileModule.cs b/src/Lidarr.Api.V1/TrackFiles/TrackFileModule.cs index 3de1db477..7f3ad420c 100644 --- a/src/Lidarr.Api.V1/TrackFiles/TrackFileModule.cs +++ b/src/Lidarr.Api.V1/TrackFiles/TrackFileModule.cs @@ -53,9 +53,8 @@ namespace Lidarr.Api.V1.TrackFiles private TrackFileResource GetTrackFile(int id) { var trackFile = _mediaFileService.Get(id); - var artist = _artistService.GetArtist(trackFile.ArtistId); - return trackFile.ToResource(artist, _upgradableSpecification); + return trackFile.ToResource(trackFile.Artist.Value, _upgradableSpecification); } private List GetTrackFiles() @@ -94,12 +93,9 @@ namespace Lidarr.Api.V1.TrackFiles .Select(e => Convert.ToInt32(e)) .ToList(); + // trackfiles will come back with the artist already populated var trackFiles = _mediaFileService.Get(trackFileIds); - - return trackFiles.GroupBy(e => e.ArtistId) - .SelectMany(f => f.ToList() - .ConvertAll(e => e.ToResource(_artistService.GetArtist(f.Key), _upgradableSpecification))) - .ToList(); + return trackFiles.ConvertAll(e => e.ToResource(e.Artist.Value, _upgradableSpecification)); } } @@ -113,7 +109,7 @@ namespace Lidarr.Api.V1.TrackFiles private Response SetQuality() { var resource = Request.Body.FromJson(); - var trackFiles = _mediaFileService.GetFiles(resource.TrackFileIds); + var trackFiles = _mediaFileService.Get(resource.TrackFileIds); foreach (var trackFile in trackFiles) { @@ -130,9 +126,7 @@ namespace Lidarr.Api.V1.TrackFiles _mediaFileService.Update(trackFiles); - var artist = _artistService.GetArtist(trackFiles.First().ArtistId); - - return trackFiles.ConvertAll(f => f.ToResource(artist, _upgradableSpecification)) + return trackFiles.ConvertAll(f => f.ToResource(trackFiles.First().Artist.Value, _upgradableSpecification)) .AsResponse(Nancy.HttpStatusCode.Accepted); } @@ -145,7 +139,7 @@ namespace Lidarr.Api.V1.TrackFiles throw new NzbDroneClientException(HttpStatusCode.NotFound, "Track file not found"); } - var artist = _artistService.GetArtist(trackFile.ArtistId); + var artist = trackFile.Artist.Value; var fullPath = Path.Combine(artist.Path, trackFile.RelativePath); _mediaFileDeletionService.DeleteTrackFile(artist, trackFile); @@ -154,8 +148,8 @@ namespace Lidarr.Api.V1.TrackFiles private Response DeleteTrackFiles() { var resource = Request.Body.FromJson(); - var trackFiles = _mediaFileService.GetFiles(resource.TrackFileIds); - var artist = _artistService.GetArtist(trackFiles.First().ArtistId); + var trackFiles = _mediaFileService.Get(resource.TrackFileIds); + var artist = trackFiles.First().Artist.Value; foreach (var trackFile in trackFiles) { @@ -169,12 +163,12 @@ namespace Lidarr.Api.V1.TrackFiles public void Handle(TrackFileAddedEvent message) { - BroadcastResourceChange(ModelAction.Updated, message.TrackFile.Id); + BroadcastResourceChange(ModelAction.Updated, message.TrackFile.ToResource(message.TrackFile.Artist.Value, _upgradableSpecification)); } public void Handle(TrackFileDeletedEvent message) { - BroadcastResourceChange(ModelAction.Deleted, message.TrackFile.Id); + BroadcastResourceChange(ModelAction.Deleted, message.TrackFile.ToResource(message.TrackFile.Artist.Value, _upgradableSpecification)); } } diff --git a/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs b/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs index d0ecefa37..0f19e9b24 100644 --- a/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs +++ b/src/Lidarr.Api.V1/TrackFiles/TrackFileResource.cs @@ -36,7 +36,7 @@ namespace Lidarr.Api.V1.TrackFiles { Id = model.Id, - ArtistId = model.ArtistId, + ArtistId = model.Artist.Value.Id, AlbumId = model.AlbumId, RelativePath = model.RelativePath, //Path @@ -59,7 +59,7 @@ namespace Lidarr.Api.V1.TrackFiles { Id = model.Id, - ArtistId = model.ArtistId, + ArtistId = artist.Id, AlbumId = model.AlbumId, RelativePath = model.RelativePath, Path = Path.Combine(artist.Path, model.RelativePath), diff --git a/src/Lidarr.Api.V1/Tracks/TrackModule.cs b/src/Lidarr.Api.V1/Tracks/TrackModule.cs index 454ffefd1..62d4863e7 100644 --- a/src/Lidarr.Api.V1/Tracks/TrackModule.cs +++ b/src/Lidarr.Api.V1/Tracks/TrackModule.cs @@ -25,11 +25,12 @@ namespace Lidarr.Api.V1.Tracks { var artistIdQuery = Request.Query.ArtistId; var albumIdQuery = Request.Query.AlbumId; + var albumReleaseIdQuery = Request.Query.AlbumReleaseId; var trackIdsQuery = Request.Query.TrackIds; - if (!artistIdQuery.HasValue && !trackIdsQuery.HasValue && !albumIdQuery.HasValue) + if (!artistIdQuery.HasValue && !trackIdsQuery.HasValue && !albumIdQuery.HasValue && !albumReleaseIdQuery.HasValue) { - throw new BadRequestException("artistId or trackIds must be provided"); + throw new BadRequestException("One of artistId, albumId, albumReleaseId or trackIds must be provided"); } if (artistIdQuery.HasValue && !albumIdQuery.HasValue) @@ -39,6 +40,13 @@ namespace Lidarr.Api.V1.Tracks return MapToResource(_trackService.GetTracksByArtist(artistId), false, false); } + if (albumReleaseIdQuery.HasValue) + { + int releaseId = Convert.ToInt32(albumReleaseIdQuery.Value); + + return MapToResource(_trackService.GetTracksByRelease(releaseId), false, false); + } + if (albumIdQuery.HasValue) { int albumId = Convert.ToInt32(albumIdQuery.Value); diff --git a/src/Lidarr.Api.V1/Tracks/TrackModuleWithSignalR.cs b/src/Lidarr.Api.V1/Tracks/TrackModuleWithSignalR.cs index eefd9f1f7..cb94d5b60 100644 --- a/src/Lidarr.Api.V1/Tracks/TrackModuleWithSignalR.cs +++ b/src/Lidarr.Api.V1/Tracks/TrackModuleWithSignalR.cs @@ -107,7 +107,8 @@ namespace Lidarr.Api.V1.Tracks { foreach (var track in message.TrackInfo.Tracks) { - BroadcastResourceChange(ModelAction.Updated, track.Id); + track.TrackFile = message.ImportedTrack; + BroadcastResourceChange(ModelAction.Updated, MapToResource(track, true, true)); } } @@ -115,7 +116,8 @@ namespace Lidarr.Api.V1.Tracks { foreach (var track in message.TrackFile.Tracks.Value) { - BroadcastResourceChange(ModelAction.Updated, track.Id); + track.TrackFile = message.TrackFile; + BroadcastResourceChange(ModelAction.Updated, MapToResource(track, true, true)); } } diff --git a/src/Lidarr.Api.V1/Tracks/TrackResource.cs b/src/Lidarr.Api.V1/Tracks/TrackResource.cs index e07652d43..d1ebac96e 100644 --- a/src/Lidarr.Api.V1/Tracks/TrackResource.cs +++ b/src/Lidarr.Api.V1/Tracks/TrackResource.cs @@ -41,7 +41,7 @@ namespace Lidarr.Api.V1.Tracks { Id = model.Id, - ArtistId = model.ArtistId, + ArtistId = model.Artist.Value.Id, TrackFileId = model.TrackFileId, AlbumId = model.AlbumId, Explicit = model.Explicit, diff --git a/src/Marr.Data/EntityGraph.cs b/src/Marr.Data/EntityGraph.cs index 72d28dcdf..30d218d6f 100644 --- a/src/Marr.Data/EntityGraph.cs +++ b/src/Marr.Data/EntityGraph.cs @@ -165,7 +165,7 @@ namespace Marr.Data /// public void AddLazyRelationship(Relationship childRelationship) { - _children.Add(new EntityGraph(childRelationship.RelationshipInfo.EntityType.GetGenericArguments()[0], this, childRelationship)); + _children.Add(new EntityGraph(childRelationship.RelationshipInfo.EntityType, this, childRelationship)); } /// @@ -297,16 +297,30 @@ namespace Marr.Data private void InitOneToManyChildLists(EntityReference entityRef) { // Get a reference to the parent's the childrens' OwningLists to the parent entity - for (int i = 0; i < Relationships.Count; i++) + foreach (var child in _children) { - Relationship relationship = Relationships[i]; + Relationship relationship = child._relationship; if (relationship.RelationshipInfo.RelationType == RelationshipTypes.Many) { try { - IList list = (IList)_repos.ReflectionStrategy.CreateInstance(relationship.MemberType); - relationship.Setter(entityRef.Entity, list); - + var memberType = relationship.MemberType; + + object memberInstance; + object childList; + if (typeof(ILazyLoaded).IsAssignableFrom(memberType)) + { + childList = _repos.ReflectionStrategy.CreateInstance(memberType.GetGenericArguments()[0]); + memberInstance = Activator.CreateInstance(relationship.MemberType, childList); + } + else + { + childList = _repos.ReflectionStrategy.CreateInstance(memberType); + memberInstance = childList; + } + IList list = (IList) childList; + + relationship.Setter(entityRef.Entity, memberInstance); // Save a reference to each 1-M list entityRef.AddChildList(relationship.Member.Name, list); } diff --git a/src/Marr.Data/Mapping/RelationshipBuilder.cs b/src/Marr.Data/Mapping/RelationshipBuilder.cs index b4926633f..7c9607ca2 100644 --- a/src/Marr.Data/Mapping/RelationshipBuilder.cs +++ b/src/Marr.Data/Mapping/RelationshipBuilder.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Linq; using System.Linq.Expressions; using Marr.Data.Mapping.Strategies; @@ -65,8 +66,21 @@ namespace Marr.Data.Mapping public RelationshipBuilder LazyLoad(Func query, Func condition = null) { AssertCurrentPropertyIsSet(); + var relationship = Relationships[_currentPropertyName]; - Relationships[_currentPropertyName].LazyLoaded = new LazyLoaded(query, condition); + relationship.LazyLoaded = new LazyLoaded(query, condition); + + // work out if it's one to many or not + if (typeof(ICollection).IsAssignableFrom(typeof(TChild))) + { + relationship.RelationshipInfo.RelationType = RelationshipTypes.Many; + relationship.RelationshipInfo.EntityType = typeof(TChild).GetGenericArguments()[0]; + } + else + { + relationship.RelationshipInfo.EntityType = typeof(TChild); + } + return this; } diff --git a/src/Marr.Data/QGen/QueryBuilder.cs b/src/Marr.Data/QGen/QueryBuilder.cs index ba135ac07..d5e798e4f 100644 --- a/src/Marr.Data/QGen/QueryBuilder.cs +++ b/src/Marr.Data/QGen/QueryBuilder.cs @@ -568,6 +568,23 @@ namespace Marr.Data.QGen return Join(joinType, rightMember, filterExpression); } + public virtual QueryBuilder Join(JoinType joinType, Expression>>> rightEntity, Expression> filterExpression) + { + _isJoin = true; + MemberInfo rightMember = (rightEntity.Body as MemberExpression).Member; + + foreach (var item in EntGraph) + { + if (item.EntityType == typeof(TLeft)) + { + var relationship = item.Relationships.Single(v => v.Member == rightMember); + item.AddLazyRelationship(relationship); + } + } + + return Join(joinType, rightMember, filterExpression); + } + public virtual QueryBuilder Join(JoinType joinType, MemberInfo rightMember, Expression> filterExpression) { _isJoin = true; diff --git a/src/NzbDrone.Common.Test/CacheTests/CachedFixture.cs b/src/NzbDrone.Common.Test/CacheTests/CachedFixture.cs index 54b2a0e01..12b42a658 100644 --- a/src/NzbDrone.Common.Test/CacheTests/CachedFixture.cs +++ b/src/NzbDrone.Common.Test/CacheTests/CachedFixture.cs @@ -102,6 +102,32 @@ namespace NzbDrone.Common.Test.CacheTests hitCount.Should().BeInRange(3, 6); } + + [Test] + public void should_clear_expired_when_they_expire() + { + int hitCount = 0; + + Func testfunc = () => { + hitCount++; + return null; + }; + + _cachedString.Values.Should().HaveCount(0); + + _cachedString.Get("key", testfunc, TimeSpan.FromMilliseconds(300)); + + Thread.Sleep(100); + + _cachedString.Values.Should().HaveCount(1); + + _cachedString.Get("key", testfunc, TimeSpan.FromMilliseconds(300)); + + Thread.Sleep(1000); + + _cachedString.Values.Should().HaveCount(0); + hitCount.Should().Be(1); + } } public class Worker @@ -114,4 +140,4 @@ namespace NzbDrone.Common.Test.CacheTests return "Hit count is " + HitCount; } } -} \ No newline at end of file +} diff --git a/src/NzbDrone.Common/Cache/Cached.cs b/src/NzbDrone.Common/Cache/Cached.cs index 928809d30..572e1a560 100644 --- a/src/NzbDrone.Common/Cache/Cached.cs +++ b/src/NzbDrone.Common/Cache/Cached.cs @@ -40,6 +40,11 @@ namespace NzbDrone.Common.Cache { Ensure.That(key, () => key).IsNotNullOrWhiteSpace(); _store[key] = new CacheItem(value, lifetime); + + if (lifetime != null) + { + System.Threading.Tasks.Task.Delay(lifetime.Value).ContinueWith(t => _store.TryRemove(key, out var temp)); + } } public T Find(string key) diff --git a/src/NzbDrone.Common/Extensions/IEnumerableExtensions.cs b/src/NzbDrone.Common/Extensions/IEnumerableExtensions.cs index 2eeb2fe4e..751e3dfca 100644 --- a/src/NzbDrone.Common/Extensions/IEnumerableExtensions.cs +++ b/src/NzbDrone.Common/Extensions/IEnumerableExtensions.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Linq.Expressions; namespace NzbDrone.Common.Extensions { @@ -51,6 +52,34 @@ namespace NzbDrone.Common.Extensions } } + public static TSource ExclusiveOrDefault(this IEnumerable source) + { + if (source == null) + { + throw new ArgumentNullException("source"); + } + + var results = source.Take(2).ToArray(); + + return results.Length == 1 ? results[0] : default(TSource); + } + + public static TSource ExclusiveOrDefault(this IEnumerable source, Func predicate) + { + if (source == null) + { + throw new ArgumentNullException("source"); + } + if (predicate == null) + { + throw new ArgumentNullException("predicate"); + } + + var results = source.Where(predicate).Take(2).ToArray(); + + return results.Length == 1 ? results[0] : default(TSource); + } + public static Dictionary ToDictionaryIgnoreDuplicates(this IEnumerable src, Func keySelector) { var result = new Dictionary(); diff --git a/src/NzbDrone.Core.Test/Datastore/DatabaseRelationshipFixture.cs b/src/NzbDrone.Core.Test/Datastore/DatabaseRelationshipFixture.cs index dfdcb5e28..6ad4b6115 100644 --- a/src/NzbDrone.Core.Test/Datastore/DatabaseRelationshipFixture.cs +++ b/src/NzbDrone.Core.Test/Datastore/DatabaseRelationshipFixture.cs @@ -16,33 +16,27 @@ namespace NzbDrone.Core.Test.Datastore [Test] public void one_to_one() { - var trackFile = Builder.CreateNew() + var album = Builder.CreateNew() .With(c => c.Id = 0) - .With(c => c.Quality = new QualityModel()) - .With(c => c.Language = Language.English) .BuildNew(); + Db.Insert(album); - Db.Insert(trackFile); - - var track = Builder.CreateNew() + var albumRelease = Builder.CreateNew() .With(c => c.Id = 0) - .With(c => c.TrackFileId = trackFile.Id) + .With(c => c.AlbumId = album.Id) .BuildNew(); - - Db.Insert(track); - - var loadedTrackFile = Db.Single().TrackFile.Value; - - loadedTrackFile.Should().NotBeNull(); - loadedTrackFile.ShouldBeEquivalentTo(trackFile, - options => options - .IncludingAllRuntimeProperties() - .Excluding(c => c.DateAdded) - .Excluding(c => c.Path) - .Excluding(c => c.Artist) - .Excluding(c => c.Tracks) - .Excluding(c => c.Album) - .Excluding(c => c.ArtistId)); + Db.Insert(albumRelease); + + var loadedAlbum = Db.Single().Album.Value; + + loadedAlbum.Should().NotBeNull(); + loadedAlbum.ShouldBeEquivalentTo(album, + options => options + .IncludingAllRuntimeProperties() + .Excluding(c => c.Artist) + .Excluding(c => c.ArtistId) + .Excluding(c => c.ArtistMetadata) + .Excluding(c => c.AlbumReleases)); } [Test] diff --git a/src/NzbDrone.Core.Test/Datastore/MarrDataLazyLoadingFixture.cs b/src/NzbDrone.Core.Test/Datastore/MarrDataLazyLoadingFixture.cs index 106a16e45..65c1b69a8 100644 --- a/src/NzbDrone.Core.Test/Datastore/MarrDataLazyLoadingFixture.cs +++ b/src/NzbDrone.Core.Test/Datastore/MarrDataLazyLoadingFixture.cs @@ -11,6 +11,7 @@ using NzbDrone.Core.Profiles.Languages; using NzbDrone.Core.Test.Languages; using Marr.Data.QGen; using System.Collections.Generic; +using System.Linq; namespace NzbDrone.Core.Test.Datastore { @@ -78,6 +79,7 @@ namespace NzbDrone.Core.Test.Datastore var trackFiles = Builder.CreateListOfSize(1) .All() .With(v => v.Id = 0) + .With(v => v.AlbumId = albums[0].Id) .With(v => v.Quality = new QualityModel()) .BuildListOfNew(); @@ -149,6 +151,141 @@ namespace NzbDrone.Core.Test.Datastore } } + [Test] + public void should_lazy_load_artist_for_track() + { + var db = Mocker.Resolve(); + var DataMapper = db.GetDataMapper(); + + var tracks = DataMapper.Query() + .ToList(); + + Assert.IsNotEmpty(tracks); + foreach (var track in tracks) + { + Assert.IsFalse(track.Artist.IsLoaded); + Assert.IsNotNull(track.Artist.Value); + Assert.IsTrue(track.Artist.IsLoaded); + Assert.IsTrue(track.Artist.Value.Metadata.IsLoaded); + } + } + + [Test] + public void should_lazy_load_artist_for_trackfile() + { + var db = Mocker.Resolve(); + var DataMapper = db.GetDataMapper(); + + var tracks = DataMapper.Query() + .ToList(); + + Assert.IsNotEmpty(tracks); + foreach (var track in tracks) + { + Assert.IsFalse(track.Artist.IsLoaded); + Assert.IsNotNull(track.Artist.Value); + Assert.IsTrue(track.Artist.IsLoaded); + Assert.IsTrue(track.Artist.Value.Metadata.IsLoaded); + } + } + + [Test] + public void should_lazy_load_trackfile_if_not_joined() + { + var db = Mocker.Resolve(); + var DataMapper = db.GetDataMapper(); + + var tracks = DataMapper.Query() + .ToList(); + + foreach (var track in tracks) + { + Assert.IsFalse(track.TrackFile.IsLoaded); + Assert.IsNotNull(track.TrackFile.Value); + Assert.IsTrue(track.TrackFile.IsLoaded); + } + } + + [Test] + public void should_explicit_load_everything_if_joined() + { + var db = Mocker.Resolve(); + var DataMapper = db.GetDataMapper(); + + var files = DataMapper.Query() + .Join(JoinType.Inner, f => f.Tracks, (f, t) => f.Id == t.TrackFileId) + .Join(JoinType.Inner, t => t.Album, (t, a) => t.AlbumId == a.Id) + .Join(JoinType.Inner, t => t.Artist, (t, a) => t.Album.Value.ArtistMetadataId == a.ArtistMetadataId) + .Join(JoinType.Inner, a => a.Metadata, (a, m) => a.ArtistMetadataId == m.Id) + .ToList(); + + Assert.IsNotEmpty(files); + foreach (var file in files) + { + Assert.IsTrue(file.Tracks.IsLoaded); + Assert.IsNotEmpty(file.Tracks.Value); + Assert.IsTrue(file.Album.IsLoaded); + Assert.IsTrue(file.Artist.IsLoaded); + Assert.IsTrue(file.Artist.Value.Metadata.IsLoaded); + } + } + + [Test] + public void should_lazy_load_tracks_if_not_joined_to_trackfile() + { + var db = Mocker.Resolve(); + var DataMapper = db.GetDataMapper(); + + var files = DataMapper.Query() + .Join(JoinType.Inner, t => t.Album, (t, a) => t.AlbumId == a.Id) + .Join(JoinType.Inner, t => t.Artist, (t, a) => t.Album.Value.ArtistMetadataId == a.ArtistMetadataId) + .Join(JoinType.Inner, a => a.Metadata, (a, m) => a.ArtistMetadataId == m.Id) + .ToList(); + + Assert.IsNotEmpty(files); + foreach (var file in files) + { + Assert.IsFalse(file.Tracks.IsLoaded); + Assert.IsNotNull(file.Tracks.Value); + Assert.IsNotEmpty(file.Tracks.Value); + Assert.IsTrue(file.Tracks.IsLoaded); + Assert.IsTrue(file.Album.IsLoaded); + Assert.IsTrue(file.Artist.IsLoaded); + Assert.IsTrue(file.Artist.Value.Metadata.IsLoaded); + } + } + + [Test] + public void should_lazy_load_tracks_if_not_joined() + { + var db = Mocker.Resolve(); + var DataMapper = db.GetDataMapper(); + + var release = DataMapper.Query().Where(x => x.Id == 1).SingleOrDefault(); + + Assert.IsFalse(release.Tracks.IsLoaded); + Assert.IsNotNull(release.Tracks.Value); + Assert.IsNotEmpty(release.Tracks.Value); + Assert.IsTrue(release.Tracks.IsLoaded); + } + + [Test] + public void should_lazy_load_track_if_not_joined() + { + var db = Mocker.Resolve(); + var DataMapper = db.GetDataMapper(); + + var tracks = DataMapper.Query() + .ToList(); + + foreach (var track in tracks) + { + Assert.IsFalse(track.Tracks.IsLoaded); + Assert.IsNotNull(track.Tracks.Value); + Assert.IsTrue(track.Tracks.IsLoaded); + } + } + [Test] public void should_explicit_load_profile_if_joined() { diff --git a/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeDiskSpecificationFixture.cs b/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeDiskSpecificationFixture.cs index 5fc3f73e8..29a128d98 100644 --- a/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeDiskSpecificationFixture.cs +++ b/src/NzbDrone.Core.Test/DecisionEngineTests/UpgradeDiskSpecificationFixture.cs @@ -44,6 +44,10 @@ namespace NzbDrone.Core.Test.DecisionEngineTests .With(l => l.LanguageProfile = new LanguageProfile { Cutoff = Language.Spanish, Languages = languages }) .Build(); + Mocker.GetMock() + .Setup(c => c.TracksWithoutFiles(It.IsAny())) + .Returns(new List()); + Mocker.GetMock() .Setup(c => c.GetFilesByAlbum(It.IsAny())) .Returns(new List { _firstFile, _secondFile }); @@ -84,6 +88,26 @@ namespace NzbDrone.Core.Test.DecisionEngineTests Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue(); } + [Test] + public void should_return_true_if_track_is_missing() + { + Mocker.GetMock() + .Setup(c => c.TracksWithoutFiles(It.IsAny())) + .Returns(new List { new Track() }); + + Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeTrue(); + } + + [Test] + public void should_only_query_db_for_missing_tracks_once() + { + Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeFalse(); + Subject.IsSatisfiedBy(_parseResultSingle, null).Accepted.Should().BeFalse(); + + Mocker.GetMock() + .Verify(c => c.TracksWithoutFiles(It.IsAny()), Times.Once()); + } + [Test] public void should_return_true_if_single_album_doesnt_exist_on_disk() { diff --git a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceFixture.cs b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceFixture.cs index c971cb115..9cb666365 100644 --- a/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceFixture.cs +++ b/src/NzbDrone.Core.Test/Download/CompletedDownloadServiceFixture.cs @@ -17,6 +17,7 @@ using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Music; using NzbDrone.Test.Common; +using NzbDrone.Core.MediaFiles.Events; namespace NzbDrone.Core.Test.Download { @@ -61,12 +62,25 @@ namespace NzbDrone.Core.Test.Download } + private Album CreateAlbum(int id, int trackCount) + { + return new Album { + Id = id, + AlbumReleases = new List { + new AlbumRelease { + Monitored = true, + TrackCount = trackCount + } + } + }; + } + private RemoteAlbum BuildRemoteAlbum() { return new RemoteAlbum { Artist = new Artist(), - Albums = new List { new Album { Id = 1 } } + Albums = new List { CreateAlbum(1, 1) } }; } @@ -84,13 +98,14 @@ namespace NzbDrone.Core.Test.Download .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { - new ImportResult(new ImportDecision(new LocalTrack() { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic() })) + new ImportResult(new ImportDecision(new LocalTrack { Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic() })) }); } private void GivenABadlyNamedDownload() { + _trackedDownload.RemoteAlbum.Artist = null; _trackedDownload.DownloadItem.DownloadId = "1234"; _trackedDownload.DownloadItem.Title = "Droned Pilot"; // Set a badly named download Mocker.GetMock() @@ -162,18 +177,49 @@ namespace NzbDrone.Core.Test.Download } [Test] - public void should_mark_as_imported_if_all_episodes_were_imported() + public void should_mark_as_imported_if_all_tracks_were_imported() + { + _trackedDownload.RemoteAlbum.Albums = new List + { + CreateAlbum(1, 2) + }; + + Mocker.GetMock() + .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(new List + { + new ImportResult( + new ImportDecision( + new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()})), + + new ImportResult( + new ImportDecision( + new LocalTrack {Path = @"C:\TestPath\Droned.S01E02.mkv".AsOsAgnostic()})) + }); + + Subject.Process(_trackedDownload); + + AssertCompletedDownload(); + } + + [Test] + public void should_mark_as_imported_if_all_tracks_were_imported_but_album_incomplete() { + _trackedDownload.RemoteAlbum.Albums = new List + { + CreateAlbum(1, 3) + }; + Mocker.GetMock() .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { new ImportResult( - new ImportDecision( + new ImportDecision( new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()})), new ImportResult( - new ImportDecision( + new ImportDecision( new LocalTrack {Path = @"C:\TestPath\Droned.S01E02.mkv".AsOsAgnostic()})) }); @@ -190,11 +236,11 @@ namespace NzbDrone.Core.Test.Download .Returns(new List { new ImportResult( - new ImportDecision( + new ImportDecision( new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()}, new Rejection("Rejected!")), "Test Failure"), new ImportResult( - new ImportDecision( + new ImportDecision( new LocalTrack {Path = @"C:\TestPath\Droned.S01E02.mkv".AsOsAgnostic()},new Rejection("Rejected!")), "Test Failure") }); @@ -203,22 +249,22 @@ namespace NzbDrone.Core.Test.Download Mocker.GetMock() .Verify(v => v.PublishEvent(It.IsAny()), Times.Never()); - AssertNoCompletedDownload(); + AssertImportIncomplete(); } [Test] - public void should_not_mark_as_imported_if_no_episodes_were_parsed() + public void should_not_mark_as_imported_if_no_tracks_were_parsed() { Mocker.GetMock() .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { new ImportResult( - new ImportDecision( + new ImportDecision( new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()}, new Rejection("Rejected!")), "Test Failure"), new ImportResult( - new ImportDecision( + new ImportDecision( new LocalTrack {Path = @"C:\TestPath\Droned.S01E02.mkv".AsOsAgnostic()},new Rejection("Rejected!")), "Test Failure") }); @@ -226,7 +272,7 @@ namespace NzbDrone.Core.Test.Download Subject.Process(_trackedDownload); - AssertNoCompletedDownload(); + AssertImportIncomplete(); } [Test] @@ -236,32 +282,34 @@ namespace NzbDrone.Core.Test.Download .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { - new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()}),"Test Failure"), - new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()}),"Test Failure") + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()}),"Test Failure"), + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()}),"Test Failure") }); Subject.Process(_trackedDownload); - AssertNoCompletedDownload(); + AssertImportIncomplete(); } [Test] - public void should_mark_as_imported_if_all_episodes_were_imported_but_extra_files_were_not() + public void should_mark_as_imported_if_all_tracks_were_imported_but_extra_files_were_not() { GivenArtistMatch(); _trackedDownload.RemoteAlbum.Albums = new List { - new Album() + CreateAlbum(1, 3) }; Mocker.GetMock() .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { - new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()})), - new ImportResult(new ImportDecision(new LocalTrack{Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()}),"Test Failure") + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()})), + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()})), + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()})), + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()}), "Test Failure") }); Subject.Process(_trackedDownload); @@ -270,28 +318,30 @@ namespace NzbDrone.Core.Test.Download } [Test] - public void should_mark_as_failed_if_some_of_episodes_were_not_imported() + public void should_mark_as_failed_if_some_tracks_were_not_imported() { _trackedDownload.RemoteAlbum.Albums = new List { - new Album(), - new Album(), - new Album() + CreateAlbum(1, 1), + CreateAlbum(1, 2), + CreateAlbum(1, 1) }; Mocker.GetMock() .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { - new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()})), - new ImportResult(new ImportDecision(new LocalTrack{Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()}),"Test Failure"), - new ImportResult(new ImportDecision(new LocalTrack{Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()}),"Test Failure") + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()})), + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()})), + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()})), + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()}), "Test Failure"), + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()}), "Test Failure") }); Subject.Process(_trackedDownload); - AssertNoCompletedDownload(); + AssertImportIncomplete(); } [Test] @@ -303,7 +353,7 @@ namespace NzbDrone.Core.Test.Download .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { - new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()})) + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()})) }); Mocker.GetMock() @@ -324,7 +374,7 @@ namespace NzbDrone.Core.Test.Download .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { - new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()})) + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()})) }); Mocker.GetMock() @@ -338,6 +388,7 @@ namespace NzbDrone.Core.Test.Download [Test] public void should_not_import_when_there_is_a_title_mismatch() { + _trackedDownload.RemoteAlbum.Artist = null; Mocker.GetMock() .Setup(s => s.GetArtist("Drone.S01E01.HDTV")) .Returns((Artist)null); @@ -352,14 +403,14 @@ namespace NzbDrone.Core.Test.Download { _trackedDownload.RemoteAlbum.Albums = new List { - new Album() + CreateAlbum(0, 1) }; Mocker.GetMock() .Setup(v => v.ProcessPath(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(new List { - new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()})) + new ImportResult(new ImportDecision(new LocalTrack {Path = @"C:\TestPath\Droned.S01E01.mkv".AsOsAgnostic()})) }); Subject.Process(_trackedDownload, true); @@ -399,6 +450,14 @@ namespace NzbDrone.Core.Test.Download AssertNoCompletedDownload(); } + private void AssertImportIncomplete() + { + Mocker.GetMock() + .Verify(v => v.PublishEvent(It.IsAny()), Times.Once()); + + AssertNoCompletedDownload(); + } + private void AssertNoCompletedDownload() { Mocker.GetMock() diff --git a/src/NzbDrone.Core.Test/Files/Identification/FilesWithMBIds.json b/src/NzbDrone.Core.Test/Files/Identification/FilesWithMBIds.json new file mode 100644 index 000000000..2e32fea78 --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Identification/FilesWithMBIds.json @@ -0,0 +1,1534 @@ +{ + "expectedMusicBrainzReleaseIds": [ + "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "97189482-89ee-4d31-90c7-ba07b412d7f9", + "9105a5b3-eb68-3a03-9aa8-f3495e602a4f" + ], + "metadataProfile": { + "name": "Standard", + "primaryAlbumTypes": [ + { + "primaryAlbumType": { + "id": 2, + "name": "Single" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 4, + "name": "Other" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 1, + "name": "EP" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 3, + "name": "Broadcast" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 0, + "name": "Album" + }, + "allowed": true + } + ], + "secondaryAlbumTypes": [ + { + "secondaryAlbumType": { + "id": 0, + "name": "Studio" + }, + "allowed": true + }, + { + "secondaryAlbumType": { + "id": 3, + "name": "Spokenword" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 2, + "name": "Soundtrack" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 7, + "name": "Remix" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 9, + "name": "Mixtape/Street" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 6, + "name": "Live" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 4, + "name": "Interview" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 8, + "name": "DJ-mix" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 10, + "name": "Demo" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 1, + "name": "Compilation" + }, + "allowed": false + } + ], + "releaseStatuses": [ + { + "releaseStatus": { + "id": 3, + "name": "Pseudo" + }, + "allowed": false + }, + { + "releaseStatus": { + "id": 1, + "name": "Promotion" + }, + "allowed": false + }, + { + "releaseStatus": { + "id": 0, + "name": "Official" + }, + "allowed": true + }, + { + "releaseStatus": { + "id": 2, + "name": "Bootleg" + }, + "allowed": false + } + ], + "id": 1 + }, + "artist": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "newDownload": false, + "singleRelease": false, + "tracks": [ + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 01 - 21 - Rolling in the Deep.flac", + "fileTrackInfo": { + "title": "Rolling in the Deep", + "cleanTitle": "Rolling in the Deep", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "1a13c710-4b7e-4701-8968-cd61f2e58110", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:03:49.2930000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 943, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 1 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 02 - 21 - Rumour Has It.flac", + "fileTrackInfo": { + "title": "Rumour Has It", + "cleanTitle": "Rumour Has It", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "797ae656-81d4-4d89-bddb-eca56f77ba72", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:03:43.2670000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 924, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 2 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 03 - 21 - Turning Tables.flac", + "fileTrackInfo": { + "title": "Turning Tables", + "cleanTitle": "Turning Tables", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "5ac6c47f-bce8-4718-8bc6-f1de40693d14", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:04:10.1330000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 786, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 3 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 04 - 21 - Don’t You Remember.flac", + "fileTrackInfo": { + "title": "Don’t You Remember", + "cleanTitle": "Don’t You Remember", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "f5057d26-1aac-47fe-b766-18c5a28927b1", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:04:03.2000000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 854, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 4 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 05 - 21 - Set Fire to the Rain.flac", + "fileTrackInfo": { + "title": "Set Fire to the Rain", + "cleanTitle": "Set Fire to the Rain", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "d1e0a99e-1894-457b-ba6a-985eeef4d0c4", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:04:01.6930000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 970, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 5 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 06 - 21 - He Won’t Go.flac", + "fileTrackInfo": { + "title": "He Won’t Go", + "cleanTitle": "He Won’t Go", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "4dd209b9-80fd-4e11-8093-3bab2db810fc", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:04:37.9470000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 892, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 6 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 07 - 21 - Take It All.flac", + "fileTrackInfo": { + "title": "Take It All", + "cleanTitle": "Take It All", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "4f515654-052b-4631-8a78-57ea362cd18a", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:03:48.2130000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 701, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 7 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 08 - 21 - I’ll Be Waiting.flac", + "fileTrackInfo": { + "title": "I’ll Be Waiting", + "cleanTitle": "I’ll Be Waiting", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "dd2d2073-50c4-438a-91cc-a1fea1c81b12", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:04:01.6530000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 1002, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 8 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 09 - 21 - One and Only.flac", + "fileTrackInfo": { + "title": "One and Only", + "cleanTitle": "One and Only", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "04f96056-91ac-4b64-af89-24c596013f05", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:05:48.1600000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 873, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 9 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 10 - 21 - Lovesong.flac", + "fileTrackInfo": { + "title": "Lovesong", + "cleanTitle": "Lovesong", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "7932ba40-a0d3-4a7e-8d85-b351fd33317e", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:05:16.2800000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 788, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 10 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 11 - 21 - Someone Like You.flac", + "fileTrackInfo": { + "title": "Someone Like You", + "cleanTitle": "Someone Like You", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "028efe7f-cdfb-4135-846f-848f2fff15b1", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:04:55.3870000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 722, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 11 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 12 - 21 - I Found a Boy.flac", + "fileTrackInfo": { + "title": "I Found a Boy", + "cleanTitle": "I Found a Boy", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "a027bfd5-c002-4a85-906e-f2c613c45022", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:03:36.6000000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 636, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 12 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 13 - 21 - Turning Tables (live acoustic).flac", + "fileTrackInfo": { + "title": "Turning Tables (live acoustic)", + "cleanTitle": "Turning Tables (live acoustic)", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "b2e47e6d-b69a-420a-aa52-31d3f38978ed", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:04:22.0800000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 664, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 13 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 14 - 21 - Don’t You Remember (live acoustic).flac", + "fileTrackInfo": { + "title": "Don’t You Remember (live acoustic)", + "cleanTitle": "Don’t You Remember (live acoustic)", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "b19a4995-7fbf-406a-8ff1-db5b8896bd28", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:04:19.6670000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 689, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 14 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/21 (2011)/Adele - 15 - 21 - Someone Like You (live acoustic).flac", + "fileTrackInfo": { + "title": "Someone Like You (live acoustic)", + "cleanTitle": "Someone Like You (live acoustic)", + "artistTitle": "Adele", + "albumTitle": "21", + "artistTitleInfo": { + "title": "Adele", + "year": 2011 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "e4174758-d333-4a8e-a31f-dd0edd51518e", + "releaseMBId": "768bc7f7-6b91-4b57-8a7b-1508636719e6", + "recordingMBId": "c365d988-0f2d-4313-9c5f-a557c30f027b", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "JP", + "name": "Japan" + }, + "year": 2011, + "label": "XL", + "catalogNumber": "BGJ-10107", + "duration": "00:05:16.4270000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 681, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 15 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/25 (2015)/Adele - 01 - 25 - Hello.flac", + "fileTrackInfo": { + "title": "Hello", + "cleanTitle": "Hello", + "artistTitle": "Adele", + "albumTitle": "25", + "artistTitleInfo": { + "title": "Adele", + "year": 2015 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "5537624c-3d2f-4f5c-8099-df916082c85c", + "releaseMBId": "97189482-89ee-4d31-90c7-ba07b412d7f9", + "recordingMBId": "0a8e8d55-4b83-4f8a-9732-fbb5ded9f344", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "HK", + "name": "Hong Kong" + }, + "year": 2015, + "label": "Columbia", + "catalogNumber": "88875189832", + "duration": "00:04:55.4930000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 789, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 1 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/25 (2015)/Adele - 02 - 25 - Send My Love (to Your New Lover).flac", + "fileTrackInfo": { + "title": "Send My Love (to Your New Lover)", + "cleanTitle": "Send My Love (to Your New Lover)", + "artistTitle": "Adele", + "albumTitle": "25", + "artistTitleInfo": { + "title": "Adele", + "year": 2015 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "5537624c-3d2f-4f5c-8099-df916082c85c", + "releaseMBId": "97189482-89ee-4d31-90c7-ba07b412d7f9", + "recordingMBId": "1e74cd4c-cfa7-4bdb-99da-41869f5f1171", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "HK", + "name": "Hong Kong" + }, + "year": 2015, + "label": "Columbia", + "catalogNumber": "88875189832", + "duration": "00:03:43.0800000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 879, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 2 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/25 (2015)/Adele - 03 - 25 - I Miss You.flac", + "fileTrackInfo": { + "title": "I Miss You", + "cleanTitle": "I Miss You", + "artistTitle": "Adele", + "albumTitle": "25", + "artistTitleInfo": { + "title": "Adele", + "year": 2015 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "5537624c-3d2f-4f5c-8099-df916082c85c", + "releaseMBId": "97189482-89ee-4d31-90c7-ba07b412d7f9", + "recordingMBId": "20594682-fa10-43e8-80fa-b116c68f1b7f", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "HK", + "name": "Hong Kong" + }, + "year": 2015, + "label": "Columbia", + "catalogNumber": "88875189832", + "duration": "00:05:48.6270000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 900, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 3 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/25 (2015)/Adele - 04 - 25 - When We Were Young.flac", + "fileTrackInfo": { + "title": "When We Were Young", + "cleanTitle": "When We Were Young", + "artistTitle": "Adele", + "albumTitle": "25", + "artistTitleInfo": { + "title": "Adele", + "year": 2015 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "5537624c-3d2f-4f5c-8099-df916082c85c", + "releaseMBId": "97189482-89ee-4d31-90c7-ba07b412d7f9", + "recordingMBId": "c5ad2611-071b-4003-bb22-eee8b4f48fe9", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "HK", + "name": "Hong Kong" + }, + "year": 2015, + "label": "Columbia", + "catalogNumber": "88875189832", + "duration": "00:04:50.9070000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 776, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 4 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/25 (2015)/Adele - 05 - 25 - Remedy.flac", + "fileTrackInfo": { + "title": "Remedy", + "cleanTitle": "Remedy", + "artistTitle": "Adele", + "albumTitle": "25", + "artistTitleInfo": { + "title": "Adele", + "year": 2015 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "5537624c-3d2f-4f5c-8099-df916082c85c", + "releaseMBId": "97189482-89ee-4d31-90c7-ba07b412d7f9", + "recordingMBId": "cdc9f701-60b4-4e37-a94f-87d0e396f2bc", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "HK", + "name": "Hong Kong" + }, + "year": 2015, + "label": "Columbia", + "catalogNumber": "88875189832", + "duration": "00:04:05.4270000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 748, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 5 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/25 (2015)/Adele - 06 - 25 - Water Under the Bridge.flac", + "fileTrackInfo": { + "title": "Water Under the Bridge", + "cleanTitle": "Water Under the Bridge", + "artistTitle": "Adele", + "albumTitle": "25", + "artistTitleInfo": { + "title": "Adele", + "year": 2015 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "5537624c-3d2f-4f5c-8099-df916082c85c", + "releaseMBId": "97189482-89ee-4d31-90c7-ba07b412d7f9", + "recordingMBId": "ade2f0f3-39bf-46ad-a44d-7fc4a8069db7", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "HK", + "name": "Hong Kong" + }, + "year": 2015, + "label": "Columbia", + "catalogNumber": "88875189832", + "duration": "00:04:00.4270000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 938, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 6 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/25 (2015)/Adele - 07 - 25 - River Lea.flac", + "fileTrackInfo": { + "title": "River Lea", + "cleanTitle": "River Lea", + "artistTitle": "Adele", + "albumTitle": "25", + "artistTitleInfo": { + "title": "Adele", + "year": 2015 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "5537624c-3d2f-4f5c-8099-df916082c85c", + "releaseMBId": "97189482-89ee-4d31-90c7-ba07b412d7f9", + "recordingMBId": "b7c37d3d-feea-4a73-8346-9e2392a292e6", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "HK", + "name": "Hong Kong" + }, + "year": 2015, + "label": "Columbia", + "catalogNumber": "88875189832", + "duration": "00:03:45.4270000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 884, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 7 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/25 (2015)/Adele - 08 - 25 - Love in the Dark.flac", + "fileTrackInfo": { + "title": "Love in the Dark", + "cleanTitle": "Love in the Dark", + "artistTitle": "Adele", + "albumTitle": "25", + "artistTitleInfo": { + "title": "Adele", + "year": 2015 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "5537624c-3d2f-4f5c-8099-df916082c85c", + "releaseMBId": "97189482-89ee-4d31-90c7-ba07b412d7f9", + "recordingMBId": "dedf519d-8eca-4756-9f93-c390308e0c1b", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "HK", + "name": "Hong Kong" + }, + "year": 2015, + "label": "Columbia", + "catalogNumber": "88875189832", + "duration": "00:04:45.9470000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 825, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 8 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/25 (2015)/Adele - 09 - 25 - Million Years Ago.flac", + "fileTrackInfo": { + "title": "Million Years Ago", + "cleanTitle": "Million Years Ago", + "artistTitle": "Adele", + "albumTitle": "25", + "artistTitleInfo": { + "title": "Adele", + "year": 2015 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "5537624c-3d2f-4f5c-8099-df916082c85c", + "releaseMBId": "97189482-89ee-4d31-90c7-ba07b412d7f9", + "recordingMBId": "870c62b6-ba2c-4873-b962-6289128e4a90", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "HK", + "name": "Hong Kong" + }, + "year": 2015, + "label": "Columbia", + "catalogNumber": "88875189832", + "duration": "00:03:47.0670000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 736, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 9 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/25 (2015)/Adele - 10 - 25 - All I Ask.flac", + "fileTrackInfo": { + "title": "All I Ask", + "cleanTitle": "All I Ask", + "artistTitle": "Adele", + "albumTitle": "25", + "artistTitleInfo": { + "title": "Adele", + "year": 2015 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "5537624c-3d2f-4f5c-8099-df916082c85c", + "releaseMBId": "97189482-89ee-4d31-90c7-ba07b412d7f9", + "recordingMBId": "bfe7a94e-4161-4802-8916-efe57e611842", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "HK", + "name": "Hong Kong" + }, + "year": 2015, + "label": "Columbia", + "catalogNumber": "88875189832", + "duration": "00:04:31.8000000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 752, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 10 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/25 (2015)/Adele - 11 - 25 - Sweetest Devotion.flac", + "fileTrackInfo": { + "title": "Sweetest Devotion", + "cleanTitle": "Sweetest Devotion", + "artistTitle": "Adele", + "albumTitle": "25", + "artistTitleInfo": { + "title": "Adele", + "year": 2015 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "5537624c-3d2f-4f5c-8099-df916082c85c", + "releaseMBId": "97189482-89ee-4d31-90c7-ba07b412d7f9", + "recordingMBId": "b2c8aed1-777d-409b-941e-7d4c594697a2", + "discNumber": 1, + "discCount": 1, + "country": { + "twoLetterCode": "HK", + "name": "Hong Kong" + }, + "year": 2015, + "label": "Columbia", + "catalogNumber": "88875189832", + "duration": "00:04:11.6930000", + "quality": { + "quality": { + "id": 6, + "name": "FLAC" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "Flac Audio", + "audioBitrate": 887, + "audioChannels": 2, + "audioBits": 16, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 11 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Adele/Incomplete/Adele - 19 - 108 - Right as Rain.mp3", + "fileTrackInfo": { + "title": "Right as Rain", + "cleanTitle": "Right as Rain", + "artistTitle": "Adele", + "albumTitle": "19", + "artistTitleInfo": { + "title": "Adele", + "year": 2008 + }, + "artistMBId": "cc2c9c3c-b7bc-4b8b-84d8-4fbd8779e493", + "albumMBId": "9796da06-2d59-3176-8598-2105f31ee54a", + "releaseMBId": "9105a5b3-eb68-3a03-9aa8-f3495e602a4f", + "recordingMBId": "e5aa0386-15cc-43a8-a059-b14fc39b8301", + "trackMBId": "d98b4797-f47e-3acf-b334-54c71c9cb608", + "discNumber": 1, + "discCount": 2, + "country": { + "twoLetterCode": "FR", + "name": "France" + }, + "year": 2008, + "label": "XL Recordings", + "catalogNumber": "XLCD313X", + "disambiguation": "expanded edition", + "duration": "00:03:17.3810000", + "quality": { + "quality": { + "id": 2, + "name": "MP3-VBR-V0" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3 VBR", + "audioBitrate": 189, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 8 + ], + "language": { + "id": 1, + "name": "English" + } + } + } + ] +} diff --git a/src/NzbDrone.Core.Test/Files/Identification/InconsistentTyposInAlbum.json b/src/NzbDrone.Core.Test/Files/Identification/InconsistentTyposInAlbum.json new file mode 100644 index 000000000..08b024255 --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Identification/InconsistentTyposInAlbum.json @@ -0,0 +1,1424 @@ +{ + "expectedMusicBrainzReleaseIds": [ + "134f5f3e-8b5f-46ab-809d-8c0dbc794f3e" + ], + "metadataProfile": { + "name": "Standard", + "primaryAlbumTypes": [ + { + "primaryAlbumType": { + "id": 2, + "name": "Single" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 4, + "name": "Other" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 1, + "name": "EP" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 3, + "name": "Broadcast" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 0, + "name": "Album" + }, + "allowed": true + } + ], + "secondaryAlbumTypes": [ + { + "secondaryAlbumType": { + "id": 0, + "name": "Studio" + }, + "allowed": true + }, + { + "secondaryAlbumType": { + "id": 3, + "name": "Spokenword" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 2, + "name": "Soundtrack" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 7, + "name": "Remix" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 9, + "name": "Mixtape/Street" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 6, + "name": "Live" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 4, + "name": "Interview" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 8, + "name": "DJ-mix" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 10, + "name": "Demo" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 1, + "name": "Compilation" + }, + "allowed": false + } + ], + "releaseStatuses": [ + { + "releaseStatus": { + "id": 3, + "name": "Pseudo" + }, + "allowed": false + }, + { + "releaseStatus": { + "id": 1, + "name": "Promotion" + }, + "allowed": false + }, + { + "releaseStatus": { + "id": 0, + "name": "Official" + }, + "allowed": true + }, + { + "releaseStatus": { + "id": 2, + "name": "Bootleg" + }, + "allowed": false + } + ], + "id": 1 + }, + "artist": "c296e10c-110a-4103-9e77-47bfebb7fb2e", + "newDownload": false, + "singleRelease": false, + "tracks": [ + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-101-positive_vibration-rns.mp3", + "fileTrackInfo": { + "title": "Positive Vibration", + "cleanTitle": "Positive Vibration", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:03:33.9620000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 1 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-102-roots_rock_reggae-rns.mp3", + "fileTrackInfo": { + "title": "Roots Rock Reggae", + "cleanTitle": "Roots Rock Reggae", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:03:38.4480000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 2 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-103-johnny_was-rns.mp3", + "fileTrackInfo": { + "title": "Johnny Was", + "cleanTitle": "Johnny Was", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:03:47.8900000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 3 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-104-cry_to_me-rns.mp3", + "fileTrackInfo": { + "title": "Cry To Me", + "cleanTitle": "Cry To Me", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:02:36.1090000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 4 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-105-want_more-rns.mp3", + "fileTrackInfo": { + "title": "Want More", + "cleanTitle": "Want More", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:04:16.6600000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 5 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-106-crazy_baldhead-rns.mp3", + "fileTrackInfo": { + "title": "Crazy Baldhead", + "cleanTitle": "Crazy Baldhead", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:03:11.9210000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 6 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-107-who_the_cap_fit-rns.mp3", + "fileTrackInfo": { + "title": "Who The Cap Fit", + "cleanTitle": "Who The Cap Fit", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:04:43.1350000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 7 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-108-night_shift-rns.mp3", + "fileTrackInfo": { + "title": "Night Shift", + "cleanTitle": "Night Shift", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:03:11.0080000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 8 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-109-war-rns.mp3", + "fileTrackInfo": { + "title": "War", + "cleanTitle": "War", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:03:36.5180000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 9 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-110-rat_race-rns.mp3", + "fileTrackInfo": { + "title": "Rat Race", + "cleanTitle": "Rat Race", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:02:54.5240000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 10 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-111-jah_live_(original_mix)-rns.mp3", + "fileTrackInfo": { + "title": "Jah Live (Original Mix)", + "cleanTitle": "Jah Live (Original Mix)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:04:17.1030000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 11 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-112-concrete-rns.mp3", + "fileTrackInfo": { + "title": "Concrete", + "cleanTitle": "Concrete", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:04:23.6760000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 12 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-113-roots_rock_reggae-rns.mp3", + "fileTrackInfo": { + "title": "Roots Rock Reggae", + "cleanTitle": "Roots Rock Reggae", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:03:37.7700000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 13 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-114-roots_rock_dub-rns.mp3", + "fileTrackInfo": { + "title": "Roots Rock Dub", + "cleanTitle": "Roots Rock Dub", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:03:37.6390000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 14 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-115-want_more_(alternate_mix)-rns.mp3", + "fileTrackInfo": { + "title": "Want More (Alternate Mix)", + "cleanTitle": "Want More (Alternate Mix)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:05:10.2870000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 15 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-116-crazy_baldhead_(alternate_mix)-rns.mp3", + "fileTrackInfo": { + "title": "Crazy Baldhead (Alternate Mix)", + "cleanTitle": "Crazy Baldhead (Alternate Mix)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:03:08.1650000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 16 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-117-war_(alternate_mix)-rns.mp3", + "fileTrackInfo": { + "title": "War (Alternate Mix)", + "cleanTitle": "War (Alternate Mix)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:04:03.0180000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 17 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-118-johnny_was_(alternate_mix)-rns.mp3", + "fileTrackInfo": { + "title": "Johnny Was (Alternate Mix)", + "cleanTitle": "Johnny Was (Alternate Mix)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:03:41.0560000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 18 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-201-introduction-rns.mp3", + "fileTrackInfo": { + "title": "Introduction", + "cleanTitle": "Introduction", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:00:38.4210000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 1 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-202-trenchtown_rock_(live)-rns.mp3", + "fileTrackInfo": { + "title": "Trenchtown Rock (Live)", + "cleanTitle": "Trenchtown Rock (Live)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:04:55.7590000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 2 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-203-burnin_and_looting_(live)-rns.mp3", + "fileTrackInfo": { + "title": "Burnin And Looting (Live)", + "cleanTitle": "Burnin And Looting (Live)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:04:53.8030000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 3 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-204-them_belly_full_(live)-rns.mp3", + "fileTrackInfo": { + "title": "Them Belly Full (Live)", + "cleanTitle": "Them Belly Full (Live)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:04:12.8520000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 4 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-205-rebel_music_(live)-rns.mp3", + "fileTrackInfo": { + "title": "Rebel Music (Live)", + "cleanTitle": "Rebel Music (Live)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:06:07.9840000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 5 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-206-i_shot_the_sheriff_(live)-rns.mp3", + "fileTrackInfo": { + "title": "I Shot The Sheriff (Live)", + "cleanTitle": "I Shot The Sheriff (Live)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:06:33.6500000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 6 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-207-want_more_(live)-rns.mp3", + "fileTrackInfo": { + "title": "Want More (Live)", + "cleanTitle": "Want More (Live)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:07:02.2890000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 7 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-208-no_woman_no_cry_(live)-rns.mp3", + "fileTrackInfo": { + "title": "No Woman No Cry (Live)", + "cleanTitle": "No Woman No Cry (Live)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:05:18.9210000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 8 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-209-lively_up_yourself_(live)-rns.mp3", + "fileTrackInfo": { + "title": "Lively Up Yourself (Live)", + "cleanTitle": "Lively Up Yourself (Live)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:05:44.1700000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 9 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-210-roots_rock_reggae_(live)-rns.mp3", + "fileTrackInfo": { + "title": "Roots Rock Reggae (Live)", + "cleanTitle": "Roots Rock Reggae (Live)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:05:32.2230000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 10 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-211-rat_race_(live)-rns.mp3", + "fileTrackInfo": { + "title": "Rat Race (Live)", + "cleanTitle": "Rat Race (Live)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:07:53.4390000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 11 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-212-smile_jamica_(part_one_live)-rns.mp3", + "fileTrackInfo": { + "title": "Smile Jamica (Part One Live)", + "cleanTitle": "Smile Jamica (Part One Live)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:03:18.9900000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 12 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bob Marley & The Wailers/Bob Marley And The Wailers - Rastaman Vibration (Remastered)-2002-RNS/bob_marley_and_the_wailers-213-smile_jamica_(part_2_live)-rns.mp3", + "fileTrackInfo": { + "title": "Smile Jamica (Part 2 Live)", + "cleanTitle": "Smile Jamica (Part 2 Live)", + "artistTitle": "Bob Marley & The Wailers", + "albumTitle": "Rastaman Vibration (Remastered)", + "artistTitleInfo": { + "title": "Bob Marley & The Wailers", + "year": 2002 + }, + "discNumber": 0, + "discCount": 0, + "year": 2002, + "duration": "00:03:09.8870000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 13 + ], + "language": { + "id": 1, + "name": "English" + } + } + } + ] +} diff --git a/src/NzbDrone.Core.Test/Files/Identification/PenalizeUnknownMedia.json b/src/NzbDrone.Core.Test/Files/Identification/PenalizeUnknownMedia.json new file mode 100644 index 000000000..7b00b3b9d --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Identification/PenalizeUnknownMedia.json @@ -0,0 +1,194 @@ +{ + "expectedMusicBrainzReleaseIds": [ + "0ce2d66f-e871-415a-9a85-e564f99d4021" + ], + "metadataProfile": { + "name": "Standard", + "primaryAlbumTypes": [ + { + "primaryAlbumType": { + "id": 2, + "name": "Single" + }, + "allowed": true + }, + { + "primaryAlbumType": { + "id": 4, + "name": "Other" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 1, + "name": "EP" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 3, + "name": "Broadcast" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 0, + "name": "Album" + }, + "allowed": true + } + ], + "secondaryAlbumTypes": [ + { + "secondaryAlbumType": { + "id": 0, + "name": "Studio" + }, + "allowed": true + }, + { + "secondaryAlbumType": { + "id": 3, + "name": "Spokenword" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 2, + "name": "Soundtrack" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 7, + "name": "Remix" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 9, + "name": "Mixtape/Street" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 6, + "name": "Live" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 4, + "name": "Interview" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 8, + "name": "DJ-mix" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 10, + "name": "Demo" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 1, + "name": "Compilation" + }, + "allowed": false + } + ], + "releaseStatuses": [ + { + "releaseStatus": { + "id": 3, + "name": "Pseudo" + }, + "allowed": false + }, + { + "releaseStatus": { + "id": 1, + "name": "Promotion" + }, + "allowed": false + }, + { + "releaseStatus": { + "id": 0, + "name": "Official" + }, + "allowed": true + }, + { + "releaseStatus": { + "id": 2, + "name": "Bootleg" + }, + "allowed": false + } + ], + "id": 1 + }, + "artist": "7ac055fa-e357-4890-9098-010b8094a900", + "newDownload": false, + "singleRelease": false, + "tracks": [ + { + "path": "D:\\Test2\\Alabama\\The Touch\\06 Touch Me When We're Dancing.mp3", + "fileTrackInfo": { + "title": "Touch Me When We're Dancing", + "cleanTitle": "Touch Me When We're Dancing", + "artistTitle": "Alabama", + "albumTitle": "The Touch", + "artistTitleInfo": { + "title": "Alabama", + "year": 1986 + }, + "discNumber": 0, + "discCount": 0, + "year": 1986, + "duration": "00:03:43.2950000", + "quality": { + "quality": { + "id": 2, + "name": "MP3-VBR-V0" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3 VBR", + "audioBitrate": 161, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 6 + ], + "language": { + "id": 1, + "name": "English" + } + } + } + ] +} diff --git a/src/NzbDrone.Core.Test/Files/Identification/PreferMissingToBadMatch.json b/src/NzbDrone.Core.Test/Files/Identification/PreferMissingToBadMatch.json new file mode 100644 index 000000000..271dade67 --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Identification/PreferMissingToBadMatch.json @@ -0,0 +1,235 @@ +{ + "expectedMusicBrainzReleaseIds": [ + "25f0fa1b-ae04-479a-a182-18a655ff6040" + ], + "metadataProfile": { + "name": "Album+Single", + "primaryAlbumTypes": [ + { + "primaryAlbumType": { + "id": 4, + "name": "Other" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 3, + "name": "Broadcast" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 2, + "name": "Single" + }, + "allowed": true + }, + { + "primaryAlbumType": { + "id": 1, + "name": "EP" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 0, + "name": "Album" + }, + "allowed": true + } + ], + "secondaryAlbumTypes": [ + { + "secondaryAlbumType": { + "id": 10, + "name": "Demo" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 9, + "name": "Mixtape/Street" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 8, + "name": "DJ-mix" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 7, + "name": "Remix" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 6, + "name": "Live" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 4, + "name": "Interview" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 3, + "name": "Spokenword" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 2, + "name": "Soundtrack" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 1, + "name": "Compilation" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 0, + "name": "Studio" + }, + "allowed": true + } + ], + "releaseStatuses": [ + { + "releaseStatus": { + "id": 3, + "name": "Pseudo" + }, + "allowed": false + }, + { + "releaseStatus": { + "id": 2, + "name": "Bootleg" + }, + "allowed": false + }, + { + "releaseStatus": { + "id": 1, + "name": "Promotion" + }, + "allowed": false + }, + { + "releaseStatus": { + "id": 0, + "name": "Official" + }, + "allowed": true + } + ], + "id": 2 + }, + "artist": "70248960-cb53-4ea4-943a-edb18f7d336f", + "newDownload": true, + "singleRelease": false, + "tracks": [ + { + "path": "/mnt/data1/LidarrTest/Bruce Springsteen/Album/10_Glory_Days.mp3", + "fileTrackInfo": { + "title": "Glory Days", + "cleanTitle": "Glory Days", + "artistTitle": "Bruce Springsteen", + "albumTitle": "Born in the U.S.A.", + "artistTitleInfo": { + "title": "Bruce Springsteen", + "year": 1984 + }, + "discNumber": 0, + "discCount": 0, + "year": 1984, + "duration": "00:04:18.0680000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 10 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Bruce Springsteen/Album/11_Dancing_In_The_Dark.mp3", + "fileTrackInfo": { + "title": "Dancing In The Dark", + "cleanTitle": "Dancing In The Dark", + "artistTitle": "Bruce Springsteen", + "albumTitle": "Born in the U.S.A.", + "artistTitleInfo": { + "title": "Bruce Springsteen", + "year": 1984 + }, + "discNumber": 0, + "discCount": 0, + "year": 1984, + "duration": "00:04:03.0450000", + "quality": { + "quality": { + "id": 1, + "name": "MP3-192" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 192, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 11 + ], + "language": { + "id": 1, + "name": "English" + } + } + } + ] +} diff --git a/src/NzbDrone.Core.Test/Files/Identification/SucceedWhenManyAlbumsHaveSameTitle.json b/src/NzbDrone.Core.Test/Files/Identification/SucceedWhenManyAlbumsHaveSameTitle.json new file mode 100644 index 000000000..f85275dae --- /dev/null +++ b/src/NzbDrone.Core.Test/Files/Identification/SucceedWhenManyAlbumsHaveSameTitle.json @@ -0,0 +1,573 @@ +{ + "expectedMusicBrainzReleaseIds": [ + "4e2dd34f-53fe-4d54-b564-b14a2871505e" + ], + "metadataProfile": { + "name": "Standard", + "primaryAlbumTypes": [ + { + "primaryAlbumType": { + "id": 2, + "name": "Single" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 4, + "name": "Other" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 1, + "name": "EP" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 3, + "name": "Broadcast" + }, + "allowed": false + }, + { + "primaryAlbumType": { + "id": 0, + "name": "Album" + }, + "allowed": true + } + ], + "secondaryAlbumTypes": [ + { + "secondaryAlbumType": { + "id": 0, + "name": "Studio" + }, + "allowed": true + }, + { + "secondaryAlbumType": { + "id": 3, + "name": "Spokenword" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 2, + "name": "Soundtrack" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 7, + "name": "Remix" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 9, + "name": "Mixtape/Street" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 6, + "name": "Live" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 4, + "name": "Interview" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 8, + "name": "DJ-mix" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 10, + "name": "Demo" + }, + "allowed": false + }, + { + "secondaryAlbumType": { + "id": 1, + "name": "Compilation" + }, + "allowed": false + } + ], + "releaseStatuses": [ + { + "releaseStatus": { + "id": 3, + "name": "Pseudo" + }, + "allowed": false + }, + { + "releaseStatus": { + "id": 1, + "name": "Promotion" + }, + "allowed": false + }, + { + "releaseStatus": { + "id": 0, + "name": "Official" + }, + "allowed": true + }, + { + "releaseStatus": { + "id": 2, + "name": "Bootleg" + }, + "allowed": false + } + ], + "id": 1 + }, + "artist": "6fe07aa5-fec0-4eca-a456-f29bff451b04", + "newDownload": false, + "singleRelease": false, + "tracks": [ + { + "path": "/mnt/data1/LidarrTest/Weezer/Weezer (2019)/01-weezer-africa-0f640cbf.mp3", + "fileTrackInfo": { + "title": "Africa", + "cleanTitle": "Africa", + "artistTitle": "Weezer", + "albumTitle": "Weezer (Teal Album)", + "artistTitleInfo": { + "title": "Weezer", + "year": 2019 + }, + "discNumber": 0, + "discCount": 0, + "year": 2019, + "label": "Crush Music / Atlantic ", + "duration": "00:03:58.6850000", + "quality": { + "quality": { + "id": 4, + "name": "MP3-320" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 320, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 1 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Weezer/Weezer (2019)/02-weezer-everybody_wants_to_rule_the_world-efc2a5b4.mp3", + "fileTrackInfo": { + "title": "Everybody Wants To Rule The World", + "cleanTitle": "Everybody Wants To Rule The World", + "artistTitle": "Weezer", + "albumTitle": "Weezer (Teal Album)", + "artistTitleInfo": { + "title": "Weezer", + "year": 2019 + }, + "discNumber": 0, + "discCount": 0, + "year": 2019, + "label": "Crush Music / Atlantic ", + "duration": "00:04:04.8960000", + "quality": { + "quality": { + "id": 4, + "name": "MP3-320" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 320, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 2 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Weezer/Weezer (2019)/03-weezer-sweet_dreams_(are_made_of_this)-a8a934a6.mp3", + "fileTrackInfo": { + "title": "Sweet Dreams (Are Made Of This)", + "cleanTitle": "Sweet Dreams (Are Made Of This)", + "artistTitle": "Weezer", + "albumTitle": "Weezer (Teal Album)", + "artistTitleInfo": { + "title": "Weezer", + "year": 2019 + }, + "discNumber": 0, + "discCount": 0, + "year": 2019, + "label": "Crush Music / Atlantic ", + "duration": "00:03:34.8550000", + "quality": { + "quality": { + "id": 4, + "name": "MP3-320" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 320, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 3 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Weezer/Weezer (2019)/04-weezer-take_on_me-5698a04c.mp3", + "fileTrackInfo": { + "title": "Take On Me", + "cleanTitle": "Take On Me", + "artistTitle": "Weezer", + "albumTitle": "Weezer (Teal Album)", + "artistTitleInfo": { + "title": "Weezer", + "year": 2019 + }, + "discNumber": 0, + "discCount": 0, + "year": 2019, + "label": "Crush Music / Atlantic ", + "duration": "00:03:43.6510000", + "quality": { + "quality": { + "id": 4, + "name": "MP3-320" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 320, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 4 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Weezer/Weezer (2019)/05-weezer-happy_together-dd30d8d4.mp3", + "fileTrackInfo": { + "title": "Happy Together", + "cleanTitle": "Happy Together", + "artistTitle": "Weezer", + "albumTitle": "Weezer (Teal Album)", + "artistTitleInfo": { + "title": "Weezer", + "year": 2019 + }, + "discNumber": 0, + "discCount": 0, + "year": 2019, + "label": "Crush Music / Atlantic ", + "duration": "00:02:25.7160000", + "quality": { + "quality": { + "id": 4, + "name": "MP3-320" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 320, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 5 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Weezer/Weezer (2019)/06-weezer-paranoid-d0617671.mp3", + "fileTrackInfo": { + "title": "Paranoid", + "cleanTitle": "Paranoid", + "artistTitle": "Weezer", + "albumTitle": "Weezer (Teal Album)", + "artistTitleInfo": { + "title": "Weezer", + "year": 2019 + }, + "discNumber": 0, + "discCount": 0, + "year": 2019, + "label": "Crush Music / Atlantic ", + "duration": "00:02:44.9260000", + "quality": { + "quality": { + "id": 4, + "name": "MP3-320" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 320, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 6 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Weezer/Weezer (2019)/07-weezer-mr_blue_sky-e3e44f02.mp3", + "fileTrackInfo": { + "title": "Mr. Blue Sky", + "cleanTitle": "Mr. Blue Sky", + "artistTitle": "Weezer", + "albumTitle": "Weezer (Teal Album)", + "artistTitleInfo": { + "title": "Weezer", + "year": 2019 + }, + "discNumber": 0, + "discCount": 0, + "year": 2019, + "label": "Crush Music / Atlantic ", + "duration": "00:04:46.4210000", + "quality": { + "quality": { + "id": 4, + "name": "MP3-320" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 320, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 7 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Weezer/Weezer (2019)/08-weezer-no_scrubs-577fa9fa.mp3", + "fileTrackInfo": { + "title": "No Scrubs", + "cleanTitle": "No Scrubs", + "artistTitle": "Weezer", + "albumTitle": "Weezer (Teal Album)", + "artistTitleInfo": { + "title": "Weezer", + "year": 2019 + }, + "discNumber": 0, + "discCount": 0, + "year": 2019, + "label": "Crush Music / Atlantic ", + "duration": "00:03:10.3730000", + "quality": { + "quality": { + "id": 4, + "name": "MP3-320" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 320, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 8 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Weezer/Weezer (2019)/09-weezer-billie_jean-9c35bbda.mp3", + "fileTrackInfo": { + "title": "Billie Jean", + "cleanTitle": "Billie Jean", + "artistTitle": "Weezer", + "albumTitle": "Weezer (Teal Album)", + "artistTitleInfo": { + "title": "Weezer", + "year": 2019 + }, + "discNumber": 0, + "discCount": 0, + "year": 2019, + "label": "Crush Music / Atlantic ", + "duration": "00:04:54.1990000", + "quality": { + "quality": { + "id": 4, + "name": "MP3-320" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 320, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 9 + ], + "language": { + "id": 1, + "name": "English" + } + } + }, + { + "path": "/mnt/data1/LidarrTest/Weezer/Weezer (2019)/10-weezer-stand_by_me-396f336f.mp3", + "fileTrackInfo": { + "title": "Stand By Me", + "cleanTitle": "Stand By Me", + "artistTitle": "Weezer", + "albumTitle": "Weezer (Teal Album)", + "artistTitleInfo": { + "title": "Weezer", + "year": 2019 + }, + "discNumber": 0, + "discCount": 0, + "year": 2019, + "label": "Crush Music / Atlantic ", + "duration": "00:03:00.9770000", + "quality": { + "quality": { + "id": 4, + "name": "MP3-320" + }, + "revision": { + "version": 1, + "real": 0 + } + }, + "mediaInfo": { + "audioFormat": "MPEG Version 1 Audio, Layer 3", + "audioBitrate": 320, + "audioChannels": 2, + "audioBits": 0, + "audioSampleRate": 44100 + }, + "trackNumbers": [ + 10 + ], + "language": { + "id": 1, + "name": "English" + } + } + } + ] +} diff --git a/src/NzbDrone.Core.Test/Files/Media/nin.flac b/src/NzbDrone.Core.Test/Files/Media/nin.flac new file mode 100644 index 000000000..2cf1d5abd Binary files /dev/null and b/src/NzbDrone.Core.Test/Files/Media/nin.flac differ diff --git a/src/NzbDrone.Core.Test/Files/Media/nin.mp3 b/src/NzbDrone.Core.Test/Files/Media/nin.mp3 new file mode 100644 index 000000000..081b897f3 Binary files /dev/null and b/src/NzbDrone.Core.Test/Files/Media/nin.mp3 differ diff --git a/src/NzbDrone.Core.Test/MediaFiles/DiskScanServiceTests/ScanFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/DiskScanServiceTests/ScanFixture.cs index 1530731db..b94a28736 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/DiskScanServiceTests/ScanFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/DiskScanServiceTests/ScanFixture.cs @@ -12,6 +12,7 @@ using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Music; using NzbDrone.Core.RootFolders; using NzbDrone.Test.Common; +using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests { @@ -44,6 +45,14 @@ namespace NzbDrone.Core.Test.MediaFiles.DiskScanServiceTests Mocker.GetMock() .Setup(s => s.GetBestRootFolderPath(It.IsAny())) .Returns(_rootFolder); + + Mocker.GetMock() + .Setup(v => v.GetImportDecisions(It.IsAny>(), It.IsAny())) + .Returns(new List>()); + + Mocker.GetMock() + .Setup(v => v.GetFilesByArtist(It.IsAny())) + .Returns(new List()); } private void GivenRootFolder(params string[] subfolders) diff --git a/src/NzbDrone.Core.Test/MediaFiles/DownloadedTracksImportServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/DownloadedTracksImportServiceFixture.cs index c4952b308..134e354ab 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/DownloadedTracksImportServiceFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/DownloadedTracksImportServiceFixture.cs @@ -44,7 +44,7 @@ namespace NzbDrone.Core.Test.MediaFiles .Returns(true); Mocker.GetMock() - .Setup(s => s.Import(It.IsAny>(), true, null, ImportMode.Auto)) + .Setup(s => s.Import(It.IsAny>>(), true, null, ImportMode.Auto)) .Returns(new List()); var downloadItem = Builder.CreateNew() @@ -76,15 +76,15 @@ namespace NzbDrone.Core.Test.MediaFiles { var localTrack = new LocalTrack(); - var imported = new List(); - imported.Add(new ImportDecision(localTrack)); + var imported = new List>(); + imported.Add(new ImportDecision(localTrack)); Mocker.GetMock() .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), null)) .Returns(imported); Mocker.GetMock() - .Setup(s => s.Import(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny())) + .Setup(s => s.Import(It.IsAny>>(), It.IsAny(), It.IsAny(), It.IsAny())) .Returns(imported.Select(i => new ImportResult(i)).ToList()) .Callback(() => WasImportedResponse()); } @@ -155,7 +155,7 @@ namespace NzbDrone.Core.Test.MediaFiles public void should_not_delete_folder_if_no_files_were_imported() { Mocker.GetMock() - .Setup(s => s.Import(It.IsAny>(), false, null, ImportMode.Auto)) + .Setup(s => s.Import(It.IsAny>>(), false, null, ImportMode.Auto)) .Returns(new List()); Subject.ProcessRootFolder(new DirectoryInfo(_droneFactory)); @@ -171,15 +171,15 @@ namespace NzbDrone.Core.Test.MediaFiles var localTrack = new LocalTrack(); - var imported = new List(); - imported.Add(new ImportDecision(localTrack)); + var imported = new List>(); + imported.Add(new ImportDecision(localTrack)); Mocker.GetMock() .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), null)) .Returns(imported); Mocker.GetMock() - .Setup(s => s.Import(It.IsAny>(), true, null, ImportMode.Auto)) + .Setup(s => s.Import(It.IsAny>>(), true, null, ImportMode.Auto)) .Returns(imported.Select(i => new ImportResult(i)).ToList()); Subject.ProcessRootFolder(new DirectoryInfo(_droneFactory)); @@ -225,8 +225,8 @@ namespace NzbDrone.Core.Test.MediaFiles result.Should().HaveCount(1); result.First().ImportDecision.Should().NotBeNull(); - result.First().ImportDecision.LocalTrack.Should().NotBeNull(); - result.First().ImportDecision.LocalTrack.Path.Should().Be(fileName); + result.First().ImportDecision.Item.Should().NotBeNull(); + result.First().ImportDecision.Item.Path.Should().Be(fileName); result.First().Result.Should().Be(ImportResultType.Rejected); } @@ -237,15 +237,15 @@ namespace NzbDrone.Core.Test.MediaFiles var localTrack = new LocalTrack(); - var imported = new List(); - imported.Add(new ImportDecision(localTrack)); + var imported = new List>(); + imported.Add(new ImportDecision(localTrack)); Mocker.GetMock() .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), null)) .Returns(imported); Mocker.GetMock() - .Setup(s => s.Import(It.IsAny>(), true, null, ImportMode.Auto)) + .Setup(s => s.Import(It.IsAny>>(), true, null, ImportMode.Auto)) .Returns(imported.Select(i => new ImportResult(i)).ToList()); //Mocker.GetMock() @@ -272,56 +272,6 @@ namespace NzbDrone.Core.Test.MediaFiles ExceptionVerification.ExpectedWarns(1); } - [Test] - public void should_use_folder_if_folder_import() - { - GivenValidArtist(); - - var folderName = @"C:\media\ba09030e-1234-1234-1234-123456789abc\[HorribleSubs] Maria the Virgin Witch - 09 [720p]".AsOsAgnostic(); - var fileName = @"C:\media\ba09030e-1234-1234-1234-123456789abc\[HorribleSubs] Maria the Virgin Witch - 09 [720p]\[HorribleSubs] Maria the Virgin Witch - 09 [720p].mkv".AsOsAgnostic(); - - Mocker.GetMock().Setup(c => c.FolderExists(folderName)) - .Returns(true); - - Mocker.GetMock().Setup(c => c.GetFiles(folderName, SearchOption.TopDirectoryOnly)) - .Returns(new[] { fileName }); - - var localTrack = new LocalTrack(); - - var imported = new List(); - imported.Add(new ImportDecision(localTrack)); - - - Subject.ProcessPath(fileName); - - Mocker.GetMock() - .Verify(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), It.Is(v => v.TrackNumbers.First() == 9)), Times.Once()); - } - - [Test] - public void should_not_use_folder_if_file_import() - { - GivenValidArtist(); - - var fileName = @"C:\media\ba09030e-1234-1234-1234-123456789abc\Torrents\[HorribleSubs] Maria the Virgin Witch - 09 [720p].mkv".AsOsAgnostic(); - - Mocker.GetMock().Setup(c => c.FolderExists(fileName)) - .Returns(false); - - Mocker.GetMock().Setup(c => c.FileExists(fileName)) - .Returns(true); - - var localTrack = new LocalTrack(); - - var imported = new List(); - imported.Add(new ImportDecision(localTrack)); - - var result = Subject.ProcessPath(fileName); - - Mocker.GetMock() - .Verify(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), null), Times.Once()); - } - [Test] public void should_not_process_if_file_and_folder_do_not_exist() { @@ -348,15 +298,15 @@ namespace NzbDrone.Core.Test.MediaFiles var localTrack = new LocalTrack(); - var imported = new List(); - imported.Add(new ImportDecision(localTrack)); + var imported = new List>(); + imported.Add(new ImportDecision(localTrack)); Mocker.GetMock() .Setup(s => s.GetImportDecisions(It.IsAny>(), It.IsAny(), null)) .Returns(imported); Mocker.GetMock() - .Setup(s => s.Import(It.IsAny>(), true, null, ImportMode.Auto)) + .Setup(s => s.Import(It.IsAny>>(), true, null, ImportMode.Auto)) .Returns(new List()); //Mocker.GetMock() @@ -424,13 +374,13 @@ namespace NzbDrone.Core.Test.MediaFiles private void VerifyNoImport() { - Mocker.GetMock().Verify(c => c.Import(It.IsAny>(), true, null, ImportMode.Auto), + Mocker.GetMock().Verify(c => c.Import(It.IsAny>>(), true, null, ImportMode.Auto), Times.Never()); } private void VerifyImport() { - Mocker.GetMock().Verify(c => c.Import(It.IsAny>(), true, null, ImportMode.Auto), + Mocker.GetMock().Verify(c => c.Import(It.IsAny>>(), true, null, ImportMode.Auto), Times.Once()); } } diff --git a/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedTracksFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedTracksFixture.cs index c0f52e789..6f90f342f 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedTracksFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/ImportApprovedTracksFixture.cs @@ -25,16 +25,16 @@ namespace NzbDrone.Core.Test.MediaFiles [TestFixture] public class ImportApprovedTracksFixture : CoreTest { - private List _rejectedDecisions; - private List _approvedDecisions; + private List> _rejectedDecisions; + private List> _approvedDecisions; private DownloadClientItem _downloadClientItem; [SetUp] public void Setup() { - _rejectedDecisions = new List(); - _approvedDecisions = new List(); + _rejectedDecisions = new List>(); + _approvedDecisions = new List>(); var artist = Builder.CreateNew() .With(e => e.Profile = new Profile { Items = Qualities.QualityFixture.GetDefaultQualities() }) @@ -52,20 +52,21 @@ namespace NzbDrone.Core.Test.MediaFiles var release = Builder.CreateNew() .With(e => e.AlbumId = album.Id) + .With(e => e.Monitored = true) .Build(); + album.AlbumReleases = new List { release }; + var tracks = Builder.CreateListOfSize(5) .Build(); - - - _rejectedDecisions.Add(new ImportDecision(new LocalTrack(), new Rejection("Rejected!"))); - _rejectedDecisions.Add(new ImportDecision(new LocalTrack(), new Rejection("Rejected!"))); - _rejectedDecisions.Add(new ImportDecision(new LocalTrack(), new Rejection("Rejected!"))); + _rejectedDecisions.Add(new ImportDecision(new LocalTrack(), new Rejection("Rejected!"))); + _rejectedDecisions.Add(new ImportDecision(new LocalTrack(), new Rejection("Rejected!"))); + _rejectedDecisions.Add(new ImportDecision(new LocalTrack(), new Rejection("Rejected!"))); foreach (var track in tracks) { - _approvedDecisions.Add(new ImportDecision + _approvedDecisions.Add(new ImportDecision ( new LocalTrack { @@ -75,7 +76,7 @@ namespace NzbDrone.Core.Test.MediaFiles Tracks = new List { track }, Path = Path.Combine(artist.Path, "Alien Ant Farm - 01 - Pilot.mp3"), Quality = new QualityModel(Quality.MP3_256), - ParsedTrackInfo = new ParsedTrackInfo + FileTrackInfo = new ParsedTrackInfo { ReleaseGroup = "DRONE" } @@ -91,6 +92,11 @@ namespace NzbDrone.Core.Test.MediaFiles Mocker.GetMock() .Setup(s => s.GetFilesWithRelativePath(It.IsAny(), It.IsAny())) .Returns(new List()); + + Mocker.GetMock() + .Setup(s => s.GetFilesByAlbum(It.IsAny())) + .Returns(new List()); + } [Test] @@ -110,7 +116,7 @@ namespace NzbDrone.Core.Test.MediaFiles [Test] public void should_only_import_approved() { - var all = new List(); + var all = new List>(); all.AddRange(_rejectedDecisions); all.AddRange(_approvedDecisions); @@ -123,9 +129,9 @@ namespace NzbDrone.Core.Test.MediaFiles [Test] public void should_only_import_each_track_once() { - var all = new List(); + var all = new List>(); all.AddRange(_approvedDecisions); - all.Add(new ImportDecision(_approvedDecisions.First().LocalTrack)); + all.Add(new ImportDecision(_approvedDecisions.First().Item)); var result = Subject.Import(all, false); @@ -135,17 +141,17 @@ namespace NzbDrone.Core.Test.MediaFiles [Test] public void should_move_new_downloads() { - Subject.Import(new List { _approvedDecisions.First() }, true); + Subject.Import(new List> { _approvedDecisions.First() }, true); Mocker.GetMock() - .Verify(v => v.UpgradeTrackFile(It.IsAny(), _approvedDecisions.First().LocalTrack, false), + .Verify(v => v.UpgradeTrackFile(It.IsAny(), _approvedDecisions.First().Item, false), Times.Once()); } [Test] public void should_publish_TrackImportedEvent_for_new_downloads() { - Subject.Import(new List { _approvedDecisions.First() }, true); + Subject.Import(new List> { _approvedDecisions.First() }, true); Mocker.GetMock() .Verify(v => v.PublishEvent(It.IsAny()), Times.Once()); @@ -154,10 +160,10 @@ namespace NzbDrone.Core.Test.MediaFiles [Test] public void should_not_move_existing_files() { - Subject.Import(new List { _approvedDecisions.First() }, false); + Subject.Import(new List> { _approvedDecisions.First() }, false); Mocker.GetMock() - .Verify(v => v.UpgradeTrackFile(It.IsAny(), _approvedDecisions.First().LocalTrack, false), + .Verify(v => v.UpgradeTrackFile(It.IsAny(), _approvedDecisions.First().Item, false), Times.Never()); } @@ -165,21 +171,21 @@ namespace NzbDrone.Core.Test.MediaFiles public void should_import_larger_files_first() { var fileDecision = _approvedDecisions.First(); - fileDecision.LocalTrack.Size = 1.Gigabytes(); + fileDecision.Item.Size = 1.Gigabytes(); - var sampleDecision = new ImportDecision + var sampleDecision = new ImportDecision (new LocalTrack { - Artist = fileDecision.LocalTrack.Artist, - Album = fileDecision.LocalTrack.Album, - Tracks = new List { fileDecision.LocalTrack.Tracks.First() }, + Artist = fileDecision.Item.Artist, + Album = fileDecision.Item.Album, + Tracks = new List { fileDecision.Item.Tracks.First() }, Path = @"C:\Test\Music\Alien Ant Farm\Alien Ant Farm - 01 - Pilot.mp3".AsOsAgnostic(), Quality = new QualityModel(Quality.MP3_256), Size = 80.Megabytes() }); - var all = new List(); + var all = new List>(); all.Add(fileDecision); all.Add(sampleDecision); @@ -187,25 +193,25 @@ namespace NzbDrone.Core.Test.MediaFiles results.Should().HaveCount(all.Count); results.Should().ContainSingle(d => d.Result == ImportResultType.Imported); - results.Should().ContainSingle(d => d.Result == ImportResultType.Imported && d.ImportDecision.LocalTrack.Size == fileDecision.LocalTrack.Size); + results.Should().ContainSingle(d => d.Result == ImportResultType.Imported && d.ImportDecision.Item.Size == fileDecision.Item.Size); } [Test] public void should_copy_when_cannot_move_files_downloads() { - Subject.Import(new List { _approvedDecisions.First() }, true, new DownloadClientItem { Title = "Alien.Ant.Farm-Truant", CanMoveFiles = false }); + Subject.Import(new List> { _approvedDecisions.First() }, true, new DownloadClientItem { Title = "Alien.Ant.Farm-Truant", CanMoveFiles = false }); Mocker.GetMock() - .Verify(v => v.UpgradeTrackFile(It.IsAny(), _approvedDecisions.First().LocalTrack, true), Times.Once()); + .Verify(v => v.UpgradeTrackFile(It.IsAny(), _approvedDecisions.First().Item, true), Times.Once()); } [Test] public void should_use_override_importmode() { - Subject.Import(new List { _approvedDecisions.First() }, true, new DownloadClientItem { Title = "Alien.Ant.Farm-Truant", CanMoveFiles = false }, ImportMode.Move); + Subject.Import(new List> { _approvedDecisions.First() }, true, new DownloadClientItem { Title = "Alien.Ant.Farm-Truant", CanMoveFiles = false }, ImportMode.Move); Mocker.GetMock() - .Verify(v => v.UpgradeTrackFile(It.IsAny(), _approvedDecisions.First().LocalTrack, false), Times.Once()); + .Verify(v => v.UpgradeTrackFile(It.IsAny(), _approvedDecisions.First().Item, false), Times.Once()); } [Test] @@ -215,7 +221,7 @@ namespace NzbDrone.Core.Test.MediaFiles .Setup(s => s.GetFilesWithRelativePath(It.IsAny(), It.IsAny())) .Returns(Builder.CreateListOfSize(1).BuildList()); - Subject.Import(new List { _approvedDecisions.First() }, false); + Subject.Import(new List> { _approvedDecisions.First() }, false); Mocker.GetMock() .Verify(v => v.Delete(It.IsAny(), DeleteMediaFileReason.ManualOverride), Times.Once()); diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaFileRepositoryFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaFileRepositoryFixture.cs index 5f6e07b65..5cb95e763 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaFileRepositoryFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/MediaFileRepositoryFixture.cs @@ -5,32 +5,31 @@ using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Qualities; using NzbDrone.Core.Music; using NzbDrone.Core.Test.Framework; +using System.Collections.Generic; namespace NzbDrone.Core.Test.MediaFiles { [TestFixture] public class MediaFileRepositoryFixture : DbTest { - [Test] - public void get_files_by_artist() + private Artist artist; + private Album album; + + [SetUp] + public void Setup() { - - var files = Builder.CreateListOfSize(10) - .All() - .With(c => c.Id = 0) - .With(c => c.Quality =new QualityModel(Quality.MP3_192)) - .BuildListOfNew(); - - Db.InsertMany(files); - Db.All().Should().HaveCount(10); + var meta = Builder.CreateNew() + .With(a => a.Id = 0) + .Build(); + Db.Insert(meta); - var artist = Builder.CreateNew() - .With(a => a.ArtistMetadataId = 11) + artist = Builder.CreateNew() + .With(a => a.ArtistMetadataId = meta.Id) .With(a => a.Id = 0) .Build(); Db.Insert(artist); - var album = Builder.CreateNew() + album = Builder.CreateNew() .With(a => a.Id = 0) .With(a => a.ArtistMetadataId = artist.ArtistMetadataId) .Build(); @@ -43,6 +42,15 @@ namespace NzbDrone.Core.Test.MediaFiles .Build(); Db.Insert(release); + var files = Builder.CreateListOfSize(10) + .All() + .With(c => c.Id = 0) + .With(c => c.Quality =new QualityModel(Quality.MP3_192)) + .TheFirst(4) + .With(c => c.AlbumId = album.Id) + .BuildListOfNew(); + Db.InsertMany(files); + var track = Builder.CreateListOfSize(10) .TheFirst(1) .With(a => a.TrackFileId = files[1].Id) @@ -59,16 +67,58 @@ namespace NzbDrone.Core.Test.MediaFiles .With(a => a.AlbumReleaseId = release.Id) .Build(); Db.InsertMany(track); + } + + [Test] + public void get_files_by_artist() + { + VerifyData(); + var artistFiles = Subject.GetFilesByArtist(artist.Id); + VerifyEagerLoaded(artistFiles); + + artistFiles.Should().HaveCount(4); + artistFiles.Should().OnlyContain(c => c.Artist.Value.Id == artist.Id); + } + + [Test] + public void get_files_by_album() + { + VerifyData(); + var files = Subject.GetFilesByAlbum(album.Id); + VerifyEagerLoaded(files); + + files.Should().HaveCount(4); + files.Should().OnlyContain(c => c.AlbumId == album.Id); + } + [Test] + public void get_files_by_relative_path() + { + VerifyData(); + var files = Subject.GetFilesWithRelativePath(artist.Id, "RelativePath2"); + VerifyEagerLoaded(files); + + files.Should().HaveCount(1); + files.Should().OnlyContain(c => c.AlbumId == album.Id); + files.Should().OnlyContain(c => c.RelativePath == "RelativePath2"); + } + + private void VerifyData() + { Db.All().Should().HaveCount(1); Db.All().Should().HaveCount(1); Db.All().Should().HaveCount(10); + Db.All().Should().HaveCount(10); + } - var artistFiles = Subject.GetFilesByArtist(artist.Id); - - artistFiles.Should().HaveCount(4); - - artistFiles.Should().OnlyContain(c => c.ArtistId == artist.Id); + private void VerifyEagerLoaded(List files) + { + foreach (var file in files) + { + file.Album.IsLoaded.Should().BeTrue(); + file.Artist.IsLoaded.Should().BeTrue(); + file.Artist.Value.Metadata.IsLoaded.Should().BeTrue(); + } } } } diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/MediaInfoFormatterTests/FormatAudioChannelsFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/MediaInfoFormatterTests/FormatAudioChannelsFixture.cs deleted file mode 100644 index 5de655cd5..000000000 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/MediaInfoFormatterTests/FormatAudioChannelsFixture.cs +++ /dev/null @@ -1,149 +0,0 @@ -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.MediaFiles.MediaInfo; -using NzbDrone.Test.Common; - -namespace NzbDrone.Core.Test.MediaFiles.MediaInfo.MediaInfoFormatterTests -{ - [TestFixture] - public class FormatAudioChannelsFixture : TestBase - { - [Test] - public void should_subtract_one_from_AudioChannels_as_total_channels_if_LFE_in_AudioChannelPositionsText() - { - var mediaInfoModel = new MediaInfoModel - { - AudioChannels = 6, - AudioChannelPositions = null, - AudioChannelPositionsText = "Front: L C R, Side: L R, LFE" - }; - - MediaInfoFormatter.FormatAudioChannels(mediaInfoModel).Should().Be(5.1m); - } - - [Test] - public void should_use_AudioChannels_as_total_channels_if_LFE_not_in_AudioChannelPositionsText() - { - var mediaInfoModel = new MediaInfoModel - { - AudioChannels = 2, - AudioChannelPositions = null, - AudioChannelPositionsText = "Front: L R" - }; - - MediaInfoFormatter.FormatAudioChannels(mediaInfoModel).Should().Be(2); - } - - [Test] - public void should_return_0_if_schema_revision_is_less_than_3_and_other_properties_are_null() - { - var mediaInfoModel = new MediaInfoModel - { - AudioChannels = 2, - AudioChannelPositions = null, - AudioChannelPositionsText = null, - SchemaRevision = 2 - }; - - MediaInfoFormatter.FormatAudioChannels(mediaInfoModel).Should().Be(0); - } - - [Test] - public void should_use_AudioChannels_if_schema_revision_is_3_and_other_properties_are_null() - { - var mediaInfoModel = new MediaInfoModel - { - AudioChannels = 2, - AudioChannelPositions = null, - AudioChannelPositionsText = null, - SchemaRevision = 3 - }; - - MediaInfoFormatter.FormatAudioChannels(mediaInfoModel).Should().Be(2); - } - - [Test] - public void should_sum_AudioChannelPositions() - { - var mediaInfoModel = new MediaInfoModel - { - AudioChannels = 2, - AudioChannelPositions = "2/0/0", - AudioChannelPositionsText = null, - SchemaRevision = 3 - }; - - MediaInfoFormatter.FormatAudioChannels(mediaInfoModel).Should().Be(2); - } - - [Test] - public void should_sum_AudioChannelPositions_including_decimal() - { - var mediaInfoModel = new MediaInfoModel - { - AudioChannels = 2, - AudioChannelPositions = "3/2/0.1", - AudioChannelPositionsText = null, - SchemaRevision = 3 - }; - - MediaInfoFormatter.FormatAudioChannels(mediaInfoModel).Should().Be(5.1m); - } - - [Test] - public void should_cleanup_extraneous_text_from_AudioChannelPositions() - { - var mediaInfoModel = new MediaInfoModel - { - AudioChannels = 2, - AudioChannelPositions = "Object Based / 3/2/2.1", - AudioChannelPositionsText = null, - SchemaRevision = 3 - }; - - MediaInfoFormatter.FormatAudioChannels(mediaInfoModel).Should().Be(7.1m); - } - - [Test] - public void should_skip_empty_groups_in_AudioChannelPositions() - { - var mediaInfoModel = new MediaInfoModel - { - AudioChannels = 2, - AudioChannelPositions = " / 2/0/0.0", - AudioChannelPositionsText = null, - SchemaRevision = 3 - }; - - MediaInfoFormatter.FormatAudioChannels(mediaInfoModel).Should().Be(2); - } - - [Test] - public void should_sum_first_series_of_numbers_from_AudioChannelPositions() - { - var mediaInfoModel = new MediaInfoModel - { - AudioChannels = 2, - AudioChannelPositions = "3/2/2.1 / 3/2/2.1", - AudioChannelPositionsText = null, - SchemaRevision = 3 - }; - - MediaInfoFormatter.FormatAudioChannels(mediaInfoModel).Should().Be(7.1m); - } - - [Test] - public void should_sum_dual_mono_representation_AudioChannelPositions() - { - var mediaInfoModel = new MediaInfoModel - { - AudioChannels = 2, - AudioChannelPositions = "1+1", - AudioChannelPositionsText = null, - SchemaRevision = 3 - }; - - MediaInfoFormatter.FormatAudioChannels(mediaInfoModel).Should().Be(2.0m); - } - } -} diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/MediaInfoFormatterTests/FormatAudioCodecFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/MediaInfoFormatterTests/FormatAudioCodecFixture.cs deleted file mode 100644 index bcee3170a..000000000 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/MediaInfoFormatterTests/FormatAudioCodecFixture.cs +++ /dev/null @@ -1,70 +0,0 @@ -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.MediaFiles.MediaInfo; -using NzbDrone.Test.Common; - -namespace NzbDrone.Core.Test.MediaFiles.MediaInfo.MediaInfoFormatterTests -{ - [TestFixture] - public class FormatAudioCodecFixture : TestBase - { - [TestCase("AC-3", "AC3")] - [TestCase("E-AC-3", "EAC3")] - [TestCase("MPEG Audio", "MPEG Audio")] - [TestCase("DTS", "DTS")] - public void should_format_audio_format(string audioFormat, string expectedFormat) - { - var mediaInfoModel = new MediaInfoModel - { - AudioFormat = audioFormat - }; - - MediaInfoFormatter.FormatAudioCodec(mediaInfoModel).Should().Be(expectedFormat); - } - - [TestCase("MPEG Audio, A_MPEG/L2, , ", "droned.s01e03.swedish.720p.hdtv.x264-prince", "MP2")] - [TestCase("Vorbis, A_VORBIS, , Xiph.Org libVorbis I 20101101 (Schaufenugget)", "DB Super HDTV", "Vorbis")] - [TestCase("PCM, 1, , ", "DW DVDRip XviD-idTV", "PCM")] // Dubbed most likely - [TestCase("TrueHD, A_TRUEHD, , ", "", "TrueHD")] - [TestCase("WMA, 161, , ", "Droned.wmv", "WMA")] - [TestCase("WMA, 162, Pro, ", "B.N.S04E18.720p.WEB-DL", "WMA")] - public void should_format_audio_format(string audioFormatPack, string sceneName, string expectedFormat) - { - var split = audioFormatPack.Split(new string[] { ", " }, System.StringSplitOptions.None); - var mediaInfoModel = new MediaInfoModel - { - AudioFormat = split[0], - AudioCodecID = split[1], - AudioProfile = split[2], - AudioCodecLibrary = split[3] - }; - - MediaInfoFormatter.FormatAudioCodec(mediaInfoModel).Should().Be(expectedFormat); - } - - [Test] - public void should_return_MP3_for_MPEG_Audio_with_Layer_3_for_the_profile() - { - var mediaInfoModel = new MediaInfoModel - { - AudioFormat = "MPEG Audio", - AudioProfile = "Layer 3" - }; - - MediaInfoFormatter.FormatAudioCodec(mediaInfoModel).Should().Be("MP3"); - } - - [Test] - public void should_return_AudioFormat_by_default() - { - var mediaInfoModel = new MediaInfoModel - { - AudioFormat = "Other Audio Format", - AudioCodecID = "Other Audio Codec" - }; - - MediaInfoFormatter.FormatAudioCodec(mediaInfoModel).Should().Be(mediaInfoModel.AudioFormat); - ExceptionVerification.ExpectedWarns(1); - } - } -} diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/UpdateMediaInfoServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/UpdateMediaInfoServiceFixture.cs deleted file mode 100644 index cb05ec205..000000000 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/UpdateMediaInfoServiceFixture.cs +++ /dev/null @@ -1,184 +0,0 @@ -using System.IO; -using FizzWare.NBuilder; -using Moq; -using NUnit.Framework; -using NzbDrone.Common.Disk; -using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.MediaFiles.Events; -using NzbDrone.Core.MediaFiles.MediaInfo; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Core.Music; -using NzbDrone.Test.Common; -using NzbDrone.Core.Configuration; - -namespace NzbDrone.Core.Test.MediaFiles.MediaInfo -{ - [TestFixture] - public class UpdateMediaInfoServiceFixture : CoreTest - { - private Artist _artist; - - [SetUp] - public void Setup() - { - _artist = new Artist - { - Id = 1, - Path = @"C:\artist".AsOsAgnostic() - }; - - Mocker.GetMock() - .SetupGet(s => s.EnableMediaInfo) - .Returns(true); - } - - private void GivenFileExists() - { - Mocker.GetMock() - .Setup(v => v.FileExists(It.IsAny())) - .Returns(true); - } - - private void GivenSuccessfulScan() - { - Mocker.GetMock() - .Setup(v => v.GetMediaInfo(It.IsAny())) - .Returns(new MediaInfoModel()); - } - - private void GivenFailedScan(string path) - { - Mocker.GetMock() - .Setup(v => v.GetMediaInfo(path)) - .Returns((MediaInfoModel)null); - } - - [Test] - public void should_skip_up_to_date_media_info() - { - var trackFiles = Builder.CreateListOfSize(3) - .All() - .With(v => v.RelativePath = "media.flac") - .TheFirst(1) - .With(v => v.MediaInfo = new MediaInfoModel { SchemaRevision = VideoFileInfoReader.CURRENT_MEDIA_INFO_SCHEMA_REVISION }) - .BuildList(); - - Mocker.GetMock() - .Setup(v => v.GetFilesByArtist(1)) - .Returns(trackFiles); - - GivenFileExists(); - GivenSuccessfulScan(); - - Subject.Handle(new ArtistScannedEvent(_artist)); - - Mocker.GetMock() - .Verify(v => v.GetMediaInfo(Path.Combine(_artist.Path, "media.flac")), Times.Exactly(2)); - - Mocker.GetMock() - .Verify(v => v.Update(It.IsAny()), Times.Exactly(2)); - } - - [Test] - public void should_skip_not_yet_date_media_info() - { - var trackFiles = Builder.CreateListOfSize(3) - .All() - .With(v => v.RelativePath = "media.flac") - .TheFirst(1) - .With(v => v.MediaInfo = new MediaInfoModel { SchemaRevision = VideoFileInfoReader.MINIMUM_MEDIA_INFO_SCHEMA_REVISION }) - .BuildList(); - - Mocker.GetMock() - .Setup(v => v.GetFilesByArtist(1)) - .Returns(trackFiles); - - GivenFileExists(); - GivenSuccessfulScan(); - - Subject.Handle(new ArtistScannedEvent(_artist)); - - Mocker.GetMock() - .Verify(v => v.GetMediaInfo(Path.Combine(_artist.Path, "media.flac")), Times.Exactly(2)); - - Mocker.GetMock() - .Verify(v => v.Update(It.IsAny()), Times.Exactly(2)); - } - - [Test] - public void should_update_outdated_media_info() - { - var trackFiles = Builder.CreateListOfSize(3) - .All() - .With(v => v.RelativePath = "media.flac") - .TheFirst(1) - .With(v => v.MediaInfo = new MediaInfoModel()) - .BuildList(); - - Mocker.GetMock() - .Setup(v => v.GetFilesByArtist(1)) - .Returns(trackFiles); - - GivenFileExists(); - GivenSuccessfulScan(); - - Subject.Handle(new ArtistScannedEvent(_artist)); - - Mocker.GetMock() - .Verify(v => v.GetMediaInfo(Path.Combine(_artist.Path, "media.flac")), Times.Exactly(3)); - - Mocker.GetMock() - .Verify(v => v.Update(It.IsAny()), Times.Exactly(3)); - } - - [Test] - public void should_ignore_missing_files() - { - var trackFiles = Builder.CreateListOfSize(2) - .All() - .With(v => v.RelativePath = "media.flac") - .BuildList(); - - Mocker.GetMock() - .Setup(v => v.GetFilesByArtist(1)) - .Returns(trackFiles); - - GivenSuccessfulScan(); - - Subject.Handle(new ArtistScannedEvent(_artist)); - - Mocker.GetMock() - .Verify(v => v.GetMediaInfo("media.flac"), Times.Never()); - - Mocker.GetMock() - .Verify(v => v.Update(It.IsAny()), Times.Never()); - } - - [Test] - public void should_continue_after_failure() - { - var episodeFiles = Builder.CreateListOfSize(2) - .All() - .With(v => v.RelativePath = "media.flac") - .TheFirst(1) - .With(v => v.RelativePath = "media2.flac") - .BuildList(); - - Mocker.GetMock() - .Setup(v => v.GetFilesByArtist(1)) - .Returns(episodeFiles); - - GivenFileExists(); - GivenSuccessfulScan(); - GivenFailedScan(Path.Combine(_artist.Path, "media2.flac")); - - Subject.Handle(new ArtistScannedEvent(_artist)); - - Mocker.GetMock() - .Verify(v => v.GetMediaInfo(Path.Combine(_artist.Path, "media.flac")), Times.Exactly(1)); - - Mocker.GetMock() - .Verify(v => v.Update(It.IsAny()), Times.Exactly(1)); - } - } -} diff --git a/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/VideoFileInfoReaderFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/VideoFileInfoReaderFixture.cs deleted file mode 100644 index 8e8ea5386..000000000 --- a/src/NzbDrone.Core.Test/MediaFiles/MediaInfo/VideoFileInfoReaderFixture.cs +++ /dev/null @@ -1,115 +0,0 @@ -using System.IO; -using FluentAssertions; -using Moq; -using NUnit.Framework; -using NzbDrone.Common.Disk; -using NzbDrone.Core.MediaFiles.MediaInfo; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Test.Common.Categories; - -namespace NzbDrone.Core.Test.MediaFiles.MediaInfo -{ - [TestFixture] - [DiskAccessTest] - public class VideoFileInfoReaderFixture : CoreTest - { - [SetUp] - public void Setup() - { - Mocker.GetMock() - .Setup(s => s.FileExists(It.IsAny())) - .Returns(true); - - Mocker.GetMock() - .Setup(s => s.OpenReadStream(It.IsAny())) - .Returns(s => new FileStream(s, FileMode.Open, FileAccess.Read)); - } - - [Test] - public void get_runtime() - { - var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Media", "H264_sample.mp4"); - - Subject.GetRunTime(path).Seconds.Should().Be(10); - - } - - - [Test] - public void get_info() - { - var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Media", "H264_sample.mp4"); - - var info = Subject.GetMediaInfo(path); - - info.VideoCodec.Should().BeNull(); - info.VideoFormat.Should().Be("AVC"); - info.VideoCodecID.Should().Be("avc1"); - info.VideoProfile.Should().Be("Baseline@L2.1"); - info.VideoCodecLibrary.Should().Be(""); - info.AudioFormat.Should().Be("AAC"); - info.AudioCodecID.Should().BeOneOf("40", "mp4a-40-2"); - info.AudioProfile.Should().Be("LC"); - info.AudioCodecLibrary.Should().Be(""); - info.AudioBitrate.Should().Be(128000); - info.AudioChannels.Should().Be(2); - info.AudioLanguages.Should().Be("English"); - info.Height.Should().Be(320); - info.RunTime.Seconds.Should().Be(10); - info.ScanType.Should().Be("Progressive"); - info.Subtitles.Should().Be(""); - info.VideoBitrate.Should().Be(193329); - info.VideoFps.Should().Be(24); - info.Width.Should().Be(480); - - } - - [Test] - public void get_info_unicode() - { - var srcPath = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Media", "H264_sample.mp4"); - - var tempPath = GetTempFilePath(); - Directory.CreateDirectory(tempPath); - - var path = Path.Combine(tempPath, "H264_Pok\u00E9mon.mkv"); - - File.Copy(srcPath, path); - - var info = Subject.GetMediaInfo(path); - - info.VideoCodec.Should().BeNull(); - info.VideoFormat.Should().Be("AVC"); - info.VideoCodecID.Should().Be("avc1"); - info.VideoProfile.Should().Be("Baseline@L2.1"); - info.VideoCodecLibrary.Should().Be(""); - info.AudioFormat.Should().Be("AAC"); - info.AudioCodecID.Should().BeOneOf("40", "mp4a-40-2"); - info.AudioProfile.Should().Be("LC"); - info.AudioCodecLibrary.Should().Be(""); - info.AudioBitrate.Should().Be(128000); - info.AudioChannels.Should().Be(2); - info.AudioLanguages.Should().Be("English"); - info.Height.Should().Be(320); - info.RunTime.Seconds.Should().Be(10); - info.ScanType.Should().Be("Progressive"); - info.Subtitles.Should().Be(""); - info.VideoBitrate.Should().Be(193329); - info.VideoFps.Should().Be(24); - info.Width.Should().Be(480); - - } - - [Test] - public void should_dispose_file_after_scanning_mediainfo() - { - var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Media", "H264_sample.mp4"); - - var info = Subject.GetMediaInfo(path); - - var stream = new FileStream(path, FileMode.Open, FileAccess.Write); - - stream.Close(); - } - } -} diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Aggregation/AggregateFilenameInfoFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Aggregation/AggregateFilenameInfoFixture.cs new file mode 100644 index 000000000..4819ba958 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Aggregation/AggregateFilenameInfoFixture.cs @@ -0,0 +1,202 @@ +using NUnit.Framework; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Parser.Model; +using System.Collections.Generic; +using System.Linq; +using System.IO; +using NzbDrone.Test.Common; +using NzbDrone.Core.MediaFiles.TrackImport.Aggregation.Aggregators; +using FluentAssertions; +using System.Text; +using System; +using System.Collections; + +namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Aggregation.Aggregators +{ + [TestFixture] + public class AggregateFilenameInfoFixture : CoreTest + { + + private LocalAlbumRelease GivenTracks(List files, string root) + { + var tracks = files.Select(x => new LocalTrack { + Path = Path.Combine(root, x), + FileTrackInfo = new ParsedTrackInfo { + TrackNumbers = new [] { 0 }, + } + }).ToList(); + return new LocalAlbumRelease(tracks); + } + + private void VerifyData(LocalTrack track, string artist, string title, int trackNum, int disc) + { + track.FileTrackInfo.ArtistTitle.Should().Be(artist); + track.FileTrackInfo.Title.Should().Be(title); + track.FileTrackInfo.TrackNumbers[0].Should().Be(trackNum); + track.FileTrackInfo.DiscNumber.Should().Be(disc); + } + + [Test] + public void should_aggregate_filenames_example() + { + var release = GivenTracks(new List { + "Adele - 19 - 101 - Daydreamer.mp3", + "Adele - 19 - 102 - Best for Last.mp3", + "Adele - 19 - 103 - Chasing Pavements.mp3", + "Adele - 19 - 203 - That's It, I Quit, I'm Moving On.mp3" + }, @"C:\incoming".AsOsAgnostic()); + + Subject.Aggregate(release, true); + + VerifyData(release.LocalTracks[0], "Adele", "Daydreamer", 1, 1); + VerifyData(release.LocalTracks[1], "Adele", "Best for Last", 2, 1); + VerifyData(release.LocalTracks[2], "Adele", "Chasing Pavements", 3, 1); + VerifyData(release.LocalTracks[3], "Adele", "That's It, I Quit, I'm Moving On", 3, 2); + } + + public static class TestCaseFactory + { + private static List tokenList = new List { + + new [] {"trackNum2", "artist", "title", "tag"}, + new [] {"trackNum3", "artist", "title", "tag"}, + new [] {"trackNum2", "artist", "tag", "title"}, + new [] {"trackNum3", "artist", "tag", "title"}, + new [] {"trackNum2", "artist", "title"}, + new [] {"trackNum3", "artist", "title"}, + + new [] {"artist", "tag", "trackNum2", "title"}, + new [] {"artist", "tag", "trackNum3", "title"}, + new [] {"artist", "trackNum2", "title", "tag"}, + new [] {"artist", "trackNum3", "title", "tag"}, + new [] {"artist", "trackNum2", "title"}, + new [] {"artist", "trackNum3", "title"}, + + new [] {"artist", "title", "tag"}, + new [] {"artist", "tag", "title"}, + new [] {"artist", "title"}, + + new [] {"trackNum2", "title"}, + new [] {"trackNum3", "title"}, + + new [] {"title"}, + }; + + private static List> separators = new List> { + Tuple.Create(" - ", " "), + Tuple.Create("_", " "), + Tuple.Create("-", "_") + }; + + private static List> otherCases = new List> { + Tuple.Create(new [] {"track2", "title"}, " ", " "), + Tuple.Create(new [] {"track3", "title"}, " ", " ") + }; + + public static IEnumerable TestCases + { + get + { + int i = 0; + + foreach (var tokens in tokenList) + { + foreach (var separator in separators) + { + i++; + yield return new TestCaseData(Tuple.Create(tokens, separator.Item1, separator.Item2)) + .SetName($"should_aggregate_filenames_auto_{i}") + .SetDescription($"tokens: {string.Join(", ", tokens)}, separator: '{separator.Item1}', whitespace: '{separator.Item2}'"); + } + } + + // and a few other cases where all the permutations don't make sense + foreach (var item in otherCases) + { + i++; + yield return new TestCaseData(item) + .SetName($"should_aggregate_filenames_auto_{i}") + .SetDescription($"tokens: {string.Join(", ", item.Item1)}, separator: '{item.Item2}', whitespace: '{item.Item3}'"); + } + } + } + } + + private List GivenFilenames(string[] fields, string fieldSeparator, string whitespace) + { + var outp = new List(); + for (int i = 1; i <= 3; i++) + { + var components = new List(); + foreach (var field in fields) + { + switch(field) + { + case "artist": + components.Add("artist name".Replace(" ", whitespace)); + break; + case "tag": + components.Add("tag string ignore".Replace(" ", whitespace)); + break; + case "title": + components.Add($"{(char)(96+i)} track title {i}".Replace(" ", whitespace)); + break; + case "trackNum2": + components.Add(i.ToString("00")); + break; + case "trackNum3": + components.Add((100 + i).ToString("000")); + break; + } + } + outp.Add(string.Join(fieldSeparator, components) + ".mp3"); + } + + return outp; + } + + private void VerifyDataAuto(List tracks, string[] tokens, string whitespace) + { + for (int i = 1; i <= tracks.Count; i++) + { + var info = tracks[i-1].FileTrackInfo; + + if (tokens.Contains("artist")) + { + info.ArtistTitle.Should().Be("artist name".Replace(" ", whitespace)); + } + + if (tokens.Contains("title")) + { + info.Title.Should().Be($"{(char)(96+i)} track title {i}".Replace(" ", whitespace)); + } + + if (tokens.Contains("trackNum2") || tokens.Contains("trackNum3")) + { + info.TrackNumbers[0].Should().Be(i); + } + + if (tokens.Contains("trackNum3")) + { + info.DiscNumber.Should().Be(1); + } + else + { + info.DiscNumber.Should().Be(0); + } + } + } + + [Test, TestCaseSource(typeof(TestCaseFactory), "TestCases")] + public void should_aggregate_filenames_auto(Tuple testcase) + { + var files = GivenFilenames(testcase.Item1, testcase.Item2, testcase.Item3); + var release = GivenTracks(files, @"C:\incoming".AsOsAgnostic()); + + Subject.Aggregate(release, true); + + VerifyDataAuto(release.LocalTracks, testcase.Item1, testcase.Item3); + } + + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/AlbumDistanceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/AlbumDistanceFixture.cs new file mode 100644 index 000000000..3128032be --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/AlbumDistanceFixture.cs @@ -0,0 +1,216 @@ +using NUnit.Framework; +using NzbDrone.Core.MediaFiles.TrackImport.Identification; +using FluentAssertions; +using NzbDrone.Core.Test.Framework; +using FizzWare.NBuilder; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Music; +using System.Collections.Generic; +using System.Linq; +using System; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification +{ + [TestFixture] + public class AlbumDistanceFixture : CoreTest + { + + private ArtistMetadata artist; + + [SetUp] + public void Setup() + { + artist = Builder + .CreateNew() + .With(x => x.Name = "artist") + .Build(); + } + + private List GivenTracks(int count) + { + return Builder + .CreateListOfSize(count) + .All() + .With(x => x.ArtistMetadata = artist) + .With(x => x.MediumNumber = 1) + .Build() + .ToList(); + } + + private LocalTrack GivenLocalTrack(Track track, AlbumRelease release) + { + var fileInfo = Builder + .CreateNew() + .With(x => x.Title = track.Title) + .With(x => x.CleanTitle = track.Title.CleanTrackTitle()) + .With(x => x.AlbumTitle = release.Title) + .With(x => x.Disambiguation = release.Disambiguation) + .With(x => x.ReleaseMBId = release.ForeignReleaseId) + .With(x => x.ArtistTitle = track.ArtistMetadata.Value.Name) + .With(x => x.TrackNumbers = new[] { track.AbsoluteTrackNumber }) + .With(x => x.DiscCount = release.Media.Count) + .With(x => x.DiscNumber = track.MediumNumber) + .With(x => x.RecordingMBId = track.ForeignRecordingId) + .With(x => x.Country = IsoCountries.Find("US")) + .With(x => x.Label = release.Label.First()) + .With(x => x.Year = (uint)release.Album.Value.ReleaseDate.Value.Year) + .Build(); + + var localTrack = Builder + .CreateNew() + .With(x => x.FileTrackInfo = fileInfo) + .Build(); + + return localTrack; + } + + private List GivenLocalTracks(List tracks, AlbumRelease release) + { + var output = new List(); + foreach (var track in tracks) + { + output.Add(GivenLocalTrack(track, release)); + } + return output; + } + + private AlbumRelease GivenAlbumRelease(string title, List tracks) + { + var album = Builder + .CreateNew() + .With(x => x.Title = title) + .With(x => x.ArtistMetadata = artist) + .Build(); + + var media = Builder + .CreateListOfSize(tracks.Max(x => x.MediumNumber)) + .Build() + .ToList(); + + return Builder + .CreateNew() + .With(x => x.Tracks = tracks) + .With(x => x.Title = title) + .With(x => x.Album = album) + .With(x => x.Media = media) + .With(x => x.Country = new List { "United States" }) + .With(x => x.Label = new List { "label" }) + .Build(); + } + + private TrackMapping GivenMapping(List local, List remote) + { + var mapping = new TrackMapping(); + var distances = local.Zip(remote, (l, r) => Tuple.Create(r, Subject.TrackDistance(l, r, Subject.GetTotalTrackNumber(r, remote)))); + mapping.Mapping = local.Zip(distances, (l, r) => new { l, r }).ToDictionary(x => x.l, x => x.r); + mapping.LocalExtra = local.Except(mapping.Mapping.Keys).ToList(); + mapping.MBExtra = remote.Except(mapping.Mapping.Values.Select(x => x.Item1)).ToList(); + + return mapping; + } + + [Test] + public void test_identical_albums() + { + var tracks = GivenTracks(3); + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + var mapping = GivenMapping(localTracks, tracks); + + Subject.AlbumReleaseDistance(localTracks, release, mapping).NormalizedDistance().Should().Be(0.0); + } + + [Test] + public void test_incomplete_album() + { + var tracks = GivenTracks(3); + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + localTracks.RemoveAt(1); + var mapping = GivenMapping(localTracks, tracks); + + var dist = Subject.AlbumReleaseDistance(localTracks, release, mapping); + dist.NormalizedDistance().Should().NotBe(0.0); + dist.NormalizedDistance().Should().BeLessThan(0.2); + } + + [Test] + public void test_global_artists_differ() + { + var tracks = GivenTracks(3); + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + var mapping = GivenMapping(localTracks, tracks); + + release.Album.Value.ArtistMetadata = Builder + .CreateNew() + .With(x => x.Name = "different artist") + .Build(); + + Subject.AlbumReleaseDistance(localTracks, release, mapping).NormalizedDistance().Should().NotBe(0.0); + } + + [Test] + public void test_comp_track_artists_match() + { + var tracks = GivenTracks(3); + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + var mapping = GivenMapping(localTracks, tracks); + + release.Album.Value.ArtistMetadata = Builder + .CreateNew() + .With(x => x.Name = "Various Artists") + .With(x => x.ForeignArtistId = "89ad4ac3-39f7-470e-963a-56509c546377") + .Build(); + + Subject.AlbumReleaseDistance(localTracks, release, mapping).NormalizedDistance().Should().Be(0.0); + } + + // TODO: there are a couple more VA tests in beets but we don't support VA yet anyway + + [Test] + public void test_tracks_out_of_order() + { + var tracks = GivenTracks(3); + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + localTracks = new [] {1, 3, 2}.Select(x => localTracks[x-1]).ToList(); + var mapping = GivenMapping(localTracks, tracks); + + var dist = Subject.AlbumReleaseDistance(localTracks, release, mapping); + dist.NormalizedDistance().Should().NotBe(0.0); + dist.NormalizedDistance().Should().BeLessThan(0.2); + } + + [Test] + public void test_two_medium_release() + { + var tracks = GivenTracks(3); + tracks[2].AbsoluteTrackNumber = 1; + tracks[2].MediumNumber = 2; + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + var mapping = GivenMapping(localTracks, tracks); + + Subject.AlbumReleaseDistance(localTracks, release, mapping).NormalizedDistance().Should().Be(0.0); + } + + [Test] + public void test_absolute_track_numbering() + { + var tracks = GivenTracks(3); + tracks[2].AbsoluteTrackNumber = 1; + tracks[2].MediumNumber = 2; + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + localTracks[2].FileTrackInfo.DiscNumber = 2; + localTracks[2].FileTrackInfo.TrackNumbers = new[] { 3 }; + + var mapping = GivenMapping(localTracks, tracks); + + Subject.AlbumReleaseDistance(localTracks, release, mapping).NormalizedDistance().Should().Be(0.0); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/DistanceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/DistanceFixture.cs new file mode 100644 index 000000000..07a50bdca --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/DistanceFixture.cs @@ -0,0 +1,167 @@ +using NUnit.Framework; +using NzbDrone.Core.MediaFiles.TrackImport.Identification; +using NzbDrone.Test.Common; +using FluentAssertions; +using System.Collections.Generic; + +namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification +{ + [TestFixture] + public class DistanceFixture : TestBase + { + [Test] + public void test_add() + { + var dist = new Distance(); + dist.Add("add", 1.0); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"add", new List { 1.0 }}} ); + } + + [Test] + public void test_equality() + { + var dist = new Distance(); + dist.AddEquality("equality", "ghi", new List { "abc", "def", "ghi" }); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"equality", new List { 0.0 }}} ); + + dist.AddEquality("equality", "xyz", new List { "abc", "def", "ghi" }); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"equality", new List { 0.0, 1.0 }}} ); + + dist.AddEquality("equality", "abc", new List { "abc", "def", "ghi" }); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"equality", new List { 0.0, 1.0, 0.0 }}} ); + } + + [Test] + public void test_add_bool() + { + var dist = new Distance(); + dist.AddBool("expr", true); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"expr", new List { 1.0 }}} ); + + dist.AddBool("expr", false); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"expr", new List { 1.0, 0.0 }}} ); + } + + [Test] + public void test_add_number() + { + var dist = new Distance(); + dist.AddNumber("number", 1, 1); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"number", new List { 0.0 }}} ); + + dist.AddNumber("number", 1, 2); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"number", new List { 0.0, 1.0 }}} ); + + dist.AddNumber("number", 2, 1); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"number", new List { 0.0, 1.0, 1.0 }}} ); + + dist.AddNumber("number", -1, 2); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"number", new List { 0.0, 1.0, 1.0, 1.0, 1.0, 1.0 }}} ); + } + + [Test] + public void test_add_priority_value() + { + var dist = new Distance(); + dist.AddPriority("priority", "abc", new List { "abc" }); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"priority", new List { 0.0 }}} ); + + dist.AddPriority("priority", "def", new List { "abc", "def" }); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"priority", new List { 0.0, 0.5 }}} ); + + dist.AddPriority("priority", "xyz", new List { "abc", "def" }); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"priority", new List { 0.0, 0.5, 1.0 }}} ); + } + + [Test] + public void test_add_priority_list() + { + var dist = new Distance(); + dist.AddPriority("priority", new List { "abc" }, new List { "abc" }); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"priority", new List { 0.0 }}} ); + + dist.AddPriority("priority", new List { "def" }, new List { "abc" }); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"priority", new List { 0.0, 1.0 }}} ); + + dist.AddPriority("priority", new List { "abc", "xyz" }, new List { "abc" }); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"priority", new List { 0.0, 1.0, 0.0 }}} ); + + dist.AddPriority("priority", new List { "def", "xyz" }, new List { "abc", "def" }); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"priority", new List { 0.0, 1.0, 0.0, 0.5 }}} ); + } + + [Test] + public void test_add_ratio() + { + var dist = new Distance(); + dist.AddRatio("ratio", 25, 100); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"ratio", new List { 0.25 }}} ); + + dist.AddRatio("ratio", 10, 5); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"ratio", new List { 0.25, 1.0 }}} ); + + dist.AddRatio("ratio", -5, 5); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"ratio", new List { 0.25, 1.0, 0.0 }}} ); + + dist.AddRatio("ratio", 5, 0); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"ratio", new List { 0.25, 1.0, 0.0, 0.0 }}} ); + } + + [Test] + public void test_add_string() + { + var dist = new Distance(); + dist.AddString("string", "abcd", "bcde"); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"string", new List { 0.5 }}} ); + } + + [Test] + public void test_add_string_none() + { + var dist = new Distance(); + dist.AddString("string", string.Empty, "bcd"); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"string", new List { 1.0 }}} ); + } + + [Test] + public void test_add_string_both_none() + { + var dist = new Distance(); + dist.AddString("string", string.Empty, string.Empty); + dist.Penalties.ShouldBeEquivalentTo(new Dictionary> { {"string", new List { 0.0 }}} ); + } + + [Test] + public void test_distance() + { + var dist = new Distance(); + dist.Add("album", 0.5); + dist.Add("media_count", 0.25); + dist.Add("media_count", 0.75); + + dist.NormalizedDistance().Should().Be(0.5); + } + + [Test] + public void test_max_distance() + { + var dist = new Distance(); + dist.Add("album", 0.5); + dist.Add("media_count", 0.0); + dist.Add("media_count", 0.0); + + dist.MaxDistance().Should().Be(5.0); + } + + [Test] + public void test_raw_distance() + { + var dist = new Distance(); + dist.Add("album", 0.5); + dist.Add("media_count", 0.25); + dist.Add("media_count", 0.5); + + dist.RawDistance().Should().Be(2.25); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/GetCandidatesFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/GetCandidatesFixture.cs new file mode 100644 index 000000000..b734bb387 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/GetCandidatesFixture.cs @@ -0,0 +1,155 @@ +using NUnit.Framework; +using NzbDrone.Core.MediaFiles.TrackImport.Identification; +using FluentAssertions; +using NzbDrone.Core.Test.Framework; +using FizzWare.NBuilder; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Music; +using System.Collections.Generic; +using System.Linq; +using System; +using NzbDrone.Core.Parser; +using NzbDrone.Common.Serializer; +using Moq; + +namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification +{ + [TestFixture] + public class GetCandidatesFixture : CoreTest + { + + private ArtistMetadata artist; + + [SetUp] + public void Setup() + { + artist = Builder + .CreateNew() + .With(x => x.Name = "artist") + .Build(); + } + + private List GivenTracks(int count) + { + return Builder + .CreateListOfSize(count) + .All() + .With(x => x.ArtistMetadata = artist) + .Build() + .ToList(); + } + + private ParsedTrackInfo GivenParsedTrackInfo(Track track, AlbumRelease release) + { + return Builder + .CreateNew() + .With(x => x.Title = track.Title) + .With(x => x.AlbumTitle = release.Title) + .With(x => x.Disambiguation = release.Disambiguation) + .With(x => x.ReleaseMBId = release.ForeignReleaseId) + .With(x => x.ArtistTitle = track.ArtistMetadata.Value.Name) + .With(x => x.TrackNumbers = new[] { track.AbsoluteTrackNumber }) + .With(x => x.RecordingMBId = track.ForeignRecordingId) + .With(x => x.Country = IsoCountries.Find("US")) + .With(x => x.Label = release.Label.First()) + .With(x => x.Year = (uint)release.Album.Value.ReleaseDate.Value.Year) + .Build(); + } + + private List GivenLocalTracks(List tracks, AlbumRelease release) + { + var output = Builder + .CreateListOfSize(tracks.Count) + .Build() + .ToList(); + + for (int i = 0; i < tracks.Count; i++) + { + output[i].FileTrackInfo = GivenParsedTrackInfo(tracks[i], release); + } + + return output; + } + + private AlbumRelease GivenAlbumRelease(string title, List tracks) + { + var album = Builder + .CreateNew() + .With(x => x.Title = title) + .With(x => x.ArtistMetadata = artist) + .Build(); + + var media = Builder + .CreateListOfSize(1) + .Build() + .ToList(); + + return Builder + .CreateNew() + .With(x => x.Tracks = tracks) + .With(x => x.Title = title) + .With(x => x.Album = album) + .With(x => x.Media = media) + .With(x => x.Country = new List()) + .With(x => x.Label = new List { "label" }) + .With(x => x.ForeignReleaseId = null) + .Build(); + } + + private LocalAlbumRelease GivenLocalAlbumRelease() + { + var tracks = GivenTracks(3); + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + + return new LocalAlbumRelease(localTracks); + } + + [Test] + public void get_candidates_by_fingerprint_should_not_fail_if_fingerprint_lookup_returned_null() + { + Mocker.GetMock() + .Setup(x => x.Lookup(It.IsAny>(), It.IsAny())) + .Callback((List x, double thres) => { + foreach(var track in x) { + track.AcoustIdResults = null; + } + }); + + Mocker.GetMock() + .Setup(x => x.GetReleasesByRecordingIds(It.IsAny>())) + .Returns(new List()); + + var local = GivenLocalAlbumRelease(); + + Subject.GetCandidatesFromFingerprint(local).ShouldBeEquivalentTo(new List()); + } + + [Test] + public void get_candidates_should_only_return_specified_release_if_set() + { + var tracks = GivenTracks(3); + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + var localAlbumRelease = new LocalAlbumRelease(localTracks); + + Subject.GetCandidatesFromTags(localAlbumRelease, null, null, release).ShouldBeEquivalentTo(new List { release }); + } + + [Test] + public void get_candidates_should_use_consensus_release_id() + { + var tracks = GivenTracks(3); + var release = GivenAlbumRelease("album", tracks); + release.ForeignReleaseId = "xxx"; + var localTracks = GivenLocalTracks(tracks, release); + var localAlbumRelease = new LocalAlbumRelease(localTracks); + + Mocker.GetMock() + .Setup(x => x.GetReleasesByForeignReleaseId(new List{ "xxx" })) + .Returns(new List { release }); + + Subject.GetCandidatesFromTags(localAlbumRelease, null, null, null).ShouldBeEquivalentTo(new List { release }); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/IdentificationServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/IdentificationServiceFixture.cs new file mode 100644 index 000000000..eba69ec8a --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/IdentificationServiceFixture.cs @@ -0,0 +1,140 @@ +using System.IO; +using System.Linq; +using System.Collections; +using FluentAssertions; +using FluentValidation.Results; +using Moq; +using Newtonsoft.Json; +using NUnit.Framework; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.MediaFiles.TrackImport.Identification; +using NzbDrone.Core.Messaging.Commands; +using NzbDrone.Core.MetadataSource; +using NzbDrone.Core.MetadataSource.SkyHook; +using NzbDrone.Core.Music; +using NzbDrone.Core.Music.Commands; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Profiles.Metadata; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Test.Common; + +namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification +{ + [TestFixture] + public class IdentificationServiceFixture : DbTest + { + private ArtistService _artistService; + private AddArtistService _addArtistService; + private RefreshArtistService _refreshArtistService; + + private IdentificationService Subject; + + [SetUp] + public void SetUp() + { + UseRealHttp(); + + // Resolve all the parts we need + Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); + + Mocker.GetMock().Setup(x => x.Exists(It.IsAny())).Returns(true); + + _artistService = Mocker.Resolve(); + Mocker.SetConstant(_artistService); + Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); + + Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); + + _addArtistService = Mocker.Resolve(); + + Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); + _refreshArtistService = Mocker.Resolve(); + + Mocker.GetMock().Setup(x => x.Validate(It.IsAny())).Returns(new ValidationResult()); + + Mocker.SetConstant(Mocker.Resolve()); + Subject = Mocker.Resolve(); + + } + + private void GivenMetadataProfile(MetadataProfile profile) + { + Mocker.GetMock().Setup(x => x.Get(It.IsAny())).Returns(profile); + } + + private Artist GivenArtist(string foreignArtistId) + { + var artist = _addArtistService.AddArtist(new Artist { + Metadata = new ArtistMetadata { + ForeignArtistId = foreignArtistId + }, + Path = @"c:\test".AsOsAgnostic(), + MetadataProfileId = 1 + }); + + var command = new RefreshArtistCommand{ + ArtistId = artist.Id, + Trigger = CommandTrigger.Unspecified + }; + + _refreshArtistService.Execute(command); + + return _artistService.FindById(foreignArtistId); + } + + public static class IdTestCaseFactory + { + // for some reason using Directory.GetFiles causes nUnit to error + private static string[] files = { + "FilesWithMBIds.json", + "PreferMissingToBadMatch.json", + "InconsistentTyposInAlbum.json", + "SucceedWhenManyAlbumsHaveSameTitle.json", + "PenalizeUnknownMedia.json" + }; + + public static IEnumerable TestCases + { + get + { + foreach (var file in files) + { + yield return new TestCaseData(file).SetName($"should_match_tracks_{file.Replace(".json", "")}"); + } + } + } + } + + // these are slow to run so only do so manually + [Explicit] + [Test, TestCaseSource(typeof(IdTestCaseFactory), "TestCases")] + public void should_match_tracks(string file) + { + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Identification", file); + var testcase = JsonConvert.DeserializeObject(File.ReadAllText(path)); + + GivenMetadataProfile(testcase.MetadataProfile); + + var artist = GivenArtist(testcase.Artist); + + var tracks = testcase.Tracks.Select(x => new LocalTrack { + Path = x.Path.AsOsAgnostic(), + FileTrackInfo = x.FileTrackInfo + }).ToList(); + + var result = Subject.Identify(tracks, artist, null, null, testcase.NewDownload, testcase.SingleRelease); + + result.Should().HaveCount(testcase.ExpectedMusicBrainzReleaseIds.Count); + result.Select(x => x.AlbumRelease.ForeignReleaseId).ShouldBeEquivalentTo(testcase.ExpectedMusicBrainzReleaseIds); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/MunkresFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/MunkresFixture.cs new file mode 100644 index 000000000..b9e7ab06c --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/MunkresFixture.cs @@ -0,0 +1,184 @@ +using NUnit.Framework; +using NzbDrone.Core.MediaFiles.TrackImport.Identification; +using NzbDrone.Test.Common; +using FluentAssertions; + +namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification +{ + [TestFixture] + public class MunkresFixture : TestBase + { + // 2d arrays don't play nicely with attributes + public void RunTest(double[,] costMatrix, double expectedCost) + { + var m = new Munkres(costMatrix); + m.Run(); + m.Cost.Should().Be(expectedCost); + } + + [Test] + public void MunkresSquareTest1() + { + var C = new double[,] { + { 1, 2, 3 }, + { 2, 4, 6 }, + { 3, 6, 9 } + }; + + RunTest(C, 10); + + } + + [Test] + public void MunkresSquareTest2() + { + var C = new double[,] { + { 400, 150, 400 }, + { 400, 450, 600 }, + { 300, 225, 300 } + }; + + RunTest(C, 850); + } + + [Test] + public void MunkresSquareTest3() + { + var C = new double[,] { + { 10, 10, 8 }, + { 9, 8, 1 }, + { 9, 7, 4 } + }; + + RunTest(C, 18); + } + + [Test] + public void MunkresSquareTest4() + { + var C = new double[,] { + { 5, 9, 1 }, + { 10, 3, 2 }, + { 8, 7, 4 } + }; + + RunTest(C, 12); + } + + [Test] + public void MunkresSquareTest5() + { + var C = new double[,] { + {12, 26, 17, 0, 0}, + {49, 43, 36, 10, 5}, + {97, 9, 66, 34, 0}, + {52, 42, 19, 36, 0}, + {15, 93, 55, 80, 0} + }; + + RunTest(C, 48); + } + + [Test] + public void Munkres5x5Test() + { + var C = new double[,] { + {12, 9, 27, 10, 23}, + {7, 13, 13, 30, 19}, + {25, 18, 26, 11, 26}, + {9, 28, 26, 23, 13}, + {16, 16, 24, 6, 9} + }; + + RunTest(C, 51); + } + + [Test] + public void Munkres10x10Test() + { + var C = new double[,] { + {37, 34, 29, 26, 19, 8, 9, 23, 19, 29}, + {9, 28, 20, 8, 18, 20, 14, 33, 23, 14}, + {15, 26, 12, 28, 6, 17, 9, 13, 21, 7}, + {2, 8, 38, 36, 39, 5, 36, 2, 38, 27}, + {30, 3, 33, 16, 21, 39, 7, 23, 28, 36}, + {7, 5, 19, 22, 36, 36, 24, 19, 30, 2}, + {34, 20, 13, 36, 12, 33, 9, 10, 23, 5}, + {7, 37, 22, 39, 33, 39, 10, 3, 13, 26}, + {21, 25, 23, 39, 31, 37, 32, 33, 38, 1}, + {17, 34, 40, 10, 29, 37, 40, 3, 25, 3} + }; + + RunTest(C, 66); + } + + [Test] + public void Munkres20x20Test() + { + var C = new double[,] { + {5, 4, 3, 9, 8, 9, 3, 5, 6, 9, 4, 10, 3, 5, 6, 6, 1, 8, 10, 2}, + {10, 9, 9, 2, 8, 3, 9, 9, 10, 1, 7, 10, 8, 4, 2, 1, 4, 8, 4, 8}, + {10, 4, 4, 3, 1, 3, 5, 10, 6, 8, 6, 8, 4, 10, 7, 2, 4, 5, 1, 8}, + {2, 1, 4, 2, 3, 9, 3, 4, 7, 3, 4, 1, 3, 2, 9, 8, 6, 5, 7, 8}, + {3, 4, 4, 1, 4, 10, 1, 2, 6, 4, 5, 10, 2, 2, 3, 9, 10, 9, 9, 10}, + {1, 10, 1, 8, 1, 3, 1, 7, 1, 1, 2, 1, 2, 6, 3, 3, 4, 4, 8, 6}, + {1, 8, 7, 10, 10, 3, 4, 6, 1, 6, 6, 4, 9, 6, 9, 6, 4, 5, 4, 7}, + {8, 10, 3, 9, 4, 9, 3, 3, 4, 6, 4, 2, 6, 7, 7, 4, 4, 3, 4, 7}, + {1, 3, 8, 2, 6, 9, 2, 7, 4, 8, 10, 8, 10, 5, 1, 3, 10, 10, 2, 9}, + {2, 4, 1, 9, 2, 9, 7, 8, 2, 1, 4, 10, 5, 2, 7, 6, 5, 7, 2, 6}, + {4, 5, 1, 4, 2, 3, 3, 4, 1, 8, 8, 2, 6, 9, 5, 9, 6, 3, 9, 3}, + {3, 1, 1, 8, 6, 8, 8, 7, 9, 3, 2, 1, 8, 2, 4, 7, 3, 1, 2, 4}, + {5, 9, 8, 6, 10, 4, 10, 3, 4, 10, 10, 10, 1, 7, 8, 8, 7, 7, 8, 8}, + {1, 4, 6, 1, 6, 1, 2, 10, 5, 10, 2, 6, 2, 4, 5, 5, 3, 5, 1, 5}, + {5, 6, 9, 10, 6, 6, 10, 6, 4, 1, 5, 3, 9, 5, 2, 10, 9, 9, 5, 1}, + {10, 9, 4, 6, 9, 5, 3, 7, 10, 1, 6, 8, 1, 1, 10, 9, 5, 7, 7, 5}, + {2, 6, 6, 6, 6, 2, 9, 4, 7, 5, 3, 2, 10, 3, 4, 5, 10, 9, 1, 7}, + {5, 2, 4, 9, 8, 4, 8, 2, 4, 1, 3, 7, 6, 8, 1, 6, 8, 8, 10, 10}, + {9, 6, 3, 1, 8, 5, 7, 8, 7, 2, 1, 8, 2, 8, 3, 7, 4, 8, 7, 7}, + {8, 4, 4, 9, 7, 10, 6, 2, 1, 5, 8, 5, 1, 1, 1, 9, 1, 3, 5, 3} + }; + + RunTest(C, 22); + } + + [Test] + public void MunkresRectangularTest1() + { + var C = new double[,] { + { 400, 150, 400, 1 }, + { 400, 450, 600, 2 }, + { 300, 225, 300, 3 } + }; + + RunTest(C, 452); + } + + [Test] + public void MunkresRectangularTest2() + { + var C = new double[,] { + { 10, 10, 8, 11 }, + { 9, 8, 1, 1 }, + { 9, 7, 4, 10 } + }; + + RunTest(C, 15); + } + + [Test] + public void MunkresRectangularTest3() + { + var C = new double[,] { + {34, 26, 17, 12}, + {43, 43, 36, 10}, + {97, 47, 66, 34}, + {52, 42, 19, 36}, + {15, 93, 55, 80} + }; + + RunTest(C, 70); + } + + } +} + diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/TrackDistanceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/TrackDistanceFixture.cs new file mode 100644 index 000000000..cda57ecaa --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/TrackDistanceFixture.cs @@ -0,0 +1,99 @@ +using NUnit.Framework; +using NzbDrone.Core.MediaFiles.TrackImport.Identification; +using FluentAssertions; +using NzbDrone.Core.Test.Framework; +using FizzWare.NBuilder; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Music; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification +{ + [TestFixture] + public class TrackDistanceFixture : CoreTest + { + private Track GivenTrack(string title) + { + var artist = Builder + .CreateNew() + .With(x => x.Name = "artist") + .Build(); + + var mbTrack = Builder + .CreateNew() + .With(x => x.Title = title) + .With(x => x.ArtistMetadata = artist) + .Build(); + + return mbTrack; + } + + private LocalTrack GivenLocalTrack(Track track) + { + var fileInfo = Builder + .CreateNew() + .With(x => x.Title = track.Title) + .With(x => x.CleanTitle = track.Title.CleanTrackTitle()) + .With(x => x.ArtistTitle = track.ArtistMetadata.Value.Name) + .With(x => x.TrackNumbers = new[] { 1 }) + .With(x => x.RecordingMBId = track.ForeignRecordingId) + .Build(); + + var localTrack = Builder + .CreateNew() + .With(x => x.FileTrackInfo = fileInfo) + .Build(); + + return localTrack; + } + + [Test] + public void test_identical_tracks() + { + var track = GivenTrack("one"); + var localTrack = GivenLocalTrack(track); + + Subject.TrackDistance(localTrack, track, 1, true).NormalizedDistance().Should().Be(0.0); + } + + [Test] + public void test_feat_removed_from_localtrack() + { + var track = GivenTrack("one"); + var localTrack = GivenLocalTrack(track); + localTrack.FileTrackInfo.Title = "one (feat. two)"; + + Subject.TrackDistance(localTrack, track, 1, true).NormalizedDistance().Should().Be(0.0); + } + + [Test] + public void test_different_title() + { + var track = GivenTrack("one"); + var localTrack = GivenLocalTrack(track); + localTrack.FileTrackInfo.CleanTitle = "foo"; + + Subject.TrackDistance(localTrack, track, 1, true).NormalizedDistance().Should().NotBe(0.0); + } + + [Test] + public void test_different_artist() + { + var track = GivenTrack("one"); + var localTrack = GivenLocalTrack(track); + localTrack.FileTrackInfo.ArtistTitle = "foo"; + + Subject.TrackDistance(localTrack, track, 1, true).NormalizedDistance().Should().NotBe(0.0); + } + + [Test] + public void test_various_artists_tolerated() + { + var track = GivenTrack("one"); + var localTrack = GivenLocalTrack(track); + localTrack.FileTrackInfo.ArtistTitle = "Various Artists"; + + Subject.TrackDistance(localTrack, track, 1, true).NormalizedDistance().Should().Be(0.0); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/TrackGroupingServiceFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/TrackGroupingServiceFixture.cs new file mode 100644 index 000000000..17ab82357 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/TrackGroupingServiceFixture.cs @@ -0,0 +1,369 @@ +using NUnit.Framework; +using NzbDrone.Core.MediaFiles.TrackImport.Identification; +using NzbDrone.Test.Common; +using FluentAssertions; +using NzbDrone.Core.Test.Framework; +using FizzWare.NBuilder; +using NzbDrone.Core.Parser.Model; +using System.Collections.Generic; +using System.Linq; +using System.IO; +using FizzWare.NBuilder.PropertyNaming; +using System.Reflection; +using System.Text; + +namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification +{ + // we need to use random strings to test the va (so we don't just get artist1, artist2 etc which are too similar) + // but the standard random value namer would give paths that are too long on windows + public class RandomValueNamerShortStrings : RandomValuePropertyNamer + { + private readonly IRandomGenerator generator; + private static readonly List allowedChars; + + public RandomValueNamerShortStrings(BuilderSettings settings) : base(settings) + { + generator = new RandomGenerator(); + } + + static RandomValueNamerShortStrings() + { + allowedChars = new List(); + for (char c = 'a'; c < 'z'; c++) + { + allowedChars.Add(c); + } + + for (char c = 'A'; c < 'Z'; c++) + { + allowedChars.Add(c); + } + + for (char c = '0'; c < '9'; c++) + { + allowedChars.Add(c); + } + } + + protected override string GetString(MemberInfo memberInfo) + { + int length = generator.Next(0, 100); + + char[] chars = new char[length]; + + for (int i = 0; i < length; i++) + { + int index = generator.Next(0, allowedChars.Count - 1); + chars[i] = allowedChars[index]; + } + + byte[] bytes = Encoding.UTF8.GetBytes(chars); + return Encoding.UTF8.GetString(bytes, 0, bytes.Length); + } + } + + [TestFixture] + public class TrackGroupingServiceFixture : CoreTest + { + private List GivenTracks(string root, string artist, string album, int count) + { + var fileInfos = Builder + .CreateListOfSize(count) + .All() + .With(f => f.ArtistTitle = artist) + .With(f => f.AlbumTitle = album) + .With(f => f.AlbumMBId = null) + .With(f => f.ReleaseMBId = null) + .Build(); + + var tracks = fileInfos.Select(x => Builder + .CreateNew() + .With(y => y.FileTrackInfo = x) + .With(y => y.Path = Path.Combine(root, x.Title)) + .Build()).ToList(); + + return tracks; + } + + private List GivenTracksWithNoTags(string root, int count) + { + var outp = new List(); + + for (int i = 0; i < count; i++) + { + var track = Builder + .CreateNew() + .With(y => y.FileTrackInfo = new ParsedTrackInfo()) + .With(y => y.Path = Path.Combine(root, $"{i}.mp3")) + .Build(); + outp.Add(track); + } + + return outp; + } + + private List GivenVaTracks(string root, string album, int count) + { + var settings = new BuilderSettings(); + settings.SetPropertyNamerFor(new RandomValueNamerShortStrings(settings)); + + var builder = new Builder(settings); + + var fileInfos = builder + .CreateListOfSize(count) + .All() + .With(f => f.AlbumTitle = "album") + .With(f => f.AlbumMBId = null) + .With(f => f.ReleaseMBId = null) + .Build(); + + var tracks = fileInfos.Select(x => Builder + .CreateNew() + .With(y => y.FileTrackInfo = x) + .With(y => y.Path = Path.Combine(@"C:\music\incoming".AsOsAgnostic(), x.Title)) + .Build()).ToList(); + + return tracks; + } + + [TestCase(1)] + [TestCase(2)] + [TestCase(10)] + public void single_artist_is_not_various_artists(int count) + { + var tracks = GivenTracks(@"C:\music\incoming".AsOsAgnostic(), "artist", "album", count); + TrackGroupingService.IsVariousArtists(tracks).Should().Be(false); + } + + [Test] + public void all_different_artists_is_various_artists() + { + var tracks = GivenVaTracks(@"C:\music\incoming".AsOsAgnostic(), "album", 10); + TrackGroupingService.IsVariousArtists(tracks).Should().Be(true); + } + + [Test] + public void two_artists_is_not_various_artists() + { + var dir = @"C:\music\incoming".AsOsAgnostic(); + var tracks = GivenTracks(dir, "artist1", "album", 10); + tracks.AddRange(GivenTracks(dir, "artist2", "album", 10)); + + TrackGroupingService.IsVariousArtists(tracks).Should().Be(false); + } + + [Test] + public void mostly_different_artists_is_various_artists() + { + var dir = @"C:\music\incoming".AsOsAgnostic(); + var tracks = GivenVaTracks(dir, "album", 10); + tracks.AddRange(GivenTracks(dir, "single_artist", "album", 2)); + TrackGroupingService.IsVariousArtists(tracks).Should().Be(true); + } + + [TestCase("")] + [TestCase("Various Artists")] + [TestCase("Various")] + [TestCase("VA")] + [TestCase("Unknown")] + public void va_artist_title_is_various_artists(string artist) + { + var tracks = GivenTracks(@"C:\music\incoming".AsOsAgnostic(), artist, "album", 10); + TrackGroupingService.IsVariousArtists(tracks).Should().Be(true); + } + + [TestCase(1)] + [TestCase(2)] + [TestCase(10)] + public void should_group_single_artist_album(int count) + { + var tracks = GivenTracks(@"C:\music\incoming".AsOsAgnostic(), "artist", "album", count); + var output = Subject.GroupTracks(tracks); + + TrackGroupingService.IsVariousArtists(tracks).Should().Be(false); + TrackGroupingService.LooksLikeSingleRelease(tracks).Should().Be(true); + + output.Count.Should().Be(1); + output[0].LocalTracks.Count.Should().Be(count); + } + + [TestCase("cd")] + [TestCase("disc")] + [TestCase("disk")] + public void should_group_multi_disc_release(string mediaName) + { + var tracks = GivenTracks($"C:\\music\\incoming\\artist - album\\{mediaName} 1".AsOsAgnostic(), + "artist", "album", 10); + tracks.AddRange(GivenTracks($"C:\\music\\incoming\\artist - album\\{mediaName} 2".AsOsAgnostic(), + "artist", "album", 5)); + + TrackGroupingService.IsVariousArtists(tracks).Should().Be(false); + TrackGroupingService.LooksLikeSingleRelease(tracks).Should().Be(true); + + var output = Subject.GroupTracks(tracks); + output.Count.Should().Be(1); + output[0].LocalTracks.Count.Should().Be(15); + } + + [Test] + public void should_not_group_two_different_albums_by_same_artist() + { + var tracks = GivenTracks($"C:\\music\\incoming\\artist - album1".AsOsAgnostic(), + "artist", "album1", 10); + tracks.AddRange(GivenTracks($"C:\\music\\incoming\\artist - album2".AsOsAgnostic(), + "artist", "album2", 5)); + + TrackGroupingService.IsVariousArtists(tracks).Should().Be(false); + TrackGroupingService.LooksLikeSingleRelease(tracks).Should().Be(false); + + var output = Subject.GroupTracks(tracks); + output.Count.Should().Be(2); + output[0].LocalTracks.Count.Should().Be(10); + output[1].LocalTracks.Count.Should().Be(5); + } + + [Test] + public void should_group_albums_with_typos() + { + var tracks = GivenTracks($"C:\\music\\incoming\\artist - album".AsOsAgnostic(), + "artist", "Rastaman Vibration (Remastered)", 10); + tracks.AddRange(GivenTracks($"C:\\music\\incoming\\artist - album".AsOsAgnostic(), + "artist", "Rastaman Vibration (Remastered", 5)); + + TrackGroupingService.IsVariousArtists(tracks).Should().Be(false); + TrackGroupingService.LooksLikeSingleRelease(tracks).Should().Be(true); + + var output = Subject.GroupTracks(tracks); + output.Count.Should().Be(1); + output[0].LocalTracks.Count.Should().Be(15); + } + + [Test] + public void should_not_group_two_different_tracks_in_same_directory() + { + var tracks = GivenTracks($"C:\\music\\incoming".AsOsAgnostic(), + "artist", "album1", 1); + tracks.AddRange(GivenTracks($"C:\\music\\incoming".AsOsAgnostic(), + "artist", "album2", 1)); + + TrackGroupingService.IsVariousArtists(tracks).Should().Be(false); + TrackGroupingService.LooksLikeSingleRelease(tracks).Should().Be(false); + + var output = Subject.GroupTracks(tracks); + output.Count.Should().Be(2); + output[0].LocalTracks.Count.Should().Be(1); + output[1].LocalTracks.Count.Should().Be(1); + } + + [Test] + public void should_separate_two_albums_in_same_directory() + { + var tracks = GivenTracks($"C:\\music\\incoming\\artist discog".AsOsAgnostic(), + "artist", "album1", 10); + tracks.AddRange(GivenTracks($"C:\\music\\incoming\\artist disog".AsOsAgnostic(), + "artist", "album2", 5)); + + TrackGroupingService.IsVariousArtists(tracks).Should().Be(false); + TrackGroupingService.LooksLikeSingleRelease(tracks).Should().Be(false); + + var output = Subject.GroupTracks(tracks); + output.Count.Should().Be(2); + output[0].LocalTracks.Count.Should().Be(10); + output[1].LocalTracks.Count.Should().Be(5); + } + + [Test] + public void should_separate_many_albums_in_same_directory() + { + var tracks = new List(); + for (int i = 0; i < 100; i++) + { + tracks.AddRange(GivenTracks($"C:\\music".AsOsAgnostic(), + "artist" + i, "album" + i, 10)); + } + + // don't test various artists here because it's designed to only work if there's a common album + TrackGroupingService.LooksLikeSingleRelease(tracks).Should().Be(false); + + var output = Subject.GroupTracks(tracks); + output.Count.Should().Be(100); + output.Select(x => x.LocalTracks.Count).Distinct().ShouldBeEquivalentTo(new List { 10 }); + } + + [Test] + public void should_separate_two_albums_by_different_artists_in_same_directory() + { + var tracks = GivenTracks($"C:\\music\\incoming".AsOsAgnostic(), + "artist1", "album1", 10); + tracks.AddRange(GivenTracks($"C:\\music\\incoming".AsOsAgnostic(), + "artist2", "album2", 5)); + + TrackGroupingService.IsVariousArtists(tracks).Should().Be(false); + TrackGroupingService.LooksLikeSingleRelease(tracks).Should().Be(false); + + var output = Subject.GroupTracks(tracks); + output.Count.Should().Be(2); + output[0].LocalTracks.Count.Should().Be(10); + output[1].LocalTracks.Count.Should().Be(5); + } + + [Test] + public void should_group_va_release() + { + var tracks = GivenVaTracks(@"C:\music\incoming".AsOsAgnostic(), "album", 10); + + TrackGroupingService.IsVariousArtists(tracks).Should().Be(true); + TrackGroupingService.LooksLikeSingleRelease(tracks).Should().Be(true); + + var output = Subject.GroupTracks(tracks); + output.Count.Should().Be(1); + output[0].LocalTracks.Count.Should().Be(10); + } + + [Test] + public void should_not_group_two_albums_by_different_artists_with_same_title() + { + var tracks = GivenTracks($"C:\\music\\incoming\\album".AsOsAgnostic(), + "artist1", "album", 10); + tracks.AddRange(GivenTracks($"C:\\music\\incoming\\album".AsOsAgnostic(), + "artist2", "album", 5)); + + TrackGroupingService.IsVariousArtists(tracks).Should().Be(false); + TrackGroupingService.LooksLikeSingleRelease(tracks).Should().Be(false); + + var output = Subject.GroupTracks(tracks); + + output.Count.Should().Be(2); + output[0].LocalTracks.Count.Should().Be(10); + output[1].LocalTracks.Count.Should().Be(5); + } + + [Test] + public void should_not_fail_if_all_tags_null() + { + var tracks = GivenTracksWithNoTags($"C:\\music\\incoming\\album".AsOsAgnostic(), 10); + + TrackGroupingService.IsVariousArtists(tracks).Should().Be(false); + TrackGroupingService.LooksLikeSingleRelease(tracks).Should().Be(true); + + var output = Subject.GroupTracks(tracks); + output.Count.Should().Be(1); + output[0].LocalTracks.Count.Should().Be(10); + } + + [Test] + public void should_not_fail_if_some_tags_null() + { + var tracks = GivenTracks($"C:\\music\\incoming\\album".AsOsAgnostic(), + "artist1", "album", 10); + tracks.AddRange(GivenTracksWithNoTags($"C:\\music\\incoming\\album".AsOsAgnostic(), 2)); + + TrackGroupingService.IsVariousArtists(tracks).Should().Be(false); + TrackGroupingService.LooksLikeSingleRelease(tracks).Should().Be(true); + + var output = Subject.GroupTracks(tracks); + output.Count.Should().Be(1); + output[0].LocalTracks.Count.Should().Be(12); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/TrackMappingFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/TrackMappingFixture.cs new file mode 100644 index 000000000..d7743de99 --- /dev/null +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/Identification/TrackMappingFixture.cs @@ -0,0 +1,187 @@ +using NUnit.Framework; +using NzbDrone.Core.MediaFiles.TrackImport.Identification; +using FluentAssertions; +using NzbDrone.Core.Test.Framework; +using FizzWare.NBuilder; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Music; +using System.Collections.Generic; +using System.Linq; +using System; +using NzbDrone.Core.Parser; +using NzbDrone.Common.Serializer; + +namespace NzbDrone.Core.Test.MediaFiles.TrackImport.Identification +{ + [TestFixture] + public class TrackMappingFixture : CoreTest + { + + private ArtistMetadata artist; + + [SetUp] + public void Setup() + { + artist = Builder + .CreateNew() + .With(x => x.Name = "artist") + .Build(); + } + + private List GivenTracks(int count) + { + return Builder + .CreateListOfSize(count) + .All() + .With(x => x.ArtistMetadata = artist) + .Build() + .ToList(); + } + + private ParsedTrackInfo GivenParsedTrackInfo(Track track, AlbumRelease release) + { + return Builder + .CreateNew() + .With(x => x.Title = track.Title) + .With(x => x.CleanTitle = track.Title.CleanTrackTitle()) + .With(x => x.AlbumTitle = release.Title) + .With(x => x.Disambiguation = release.Disambiguation) + .With(x => x.ReleaseMBId = release.ForeignReleaseId) + .With(x => x.ArtistTitle = track.ArtistMetadata.Value.Name) + .With(x => x.TrackNumbers = new[] { track.AbsoluteTrackNumber }) + .With(x => x.RecordingMBId = track.ForeignRecordingId) + .With(x => x.Country = IsoCountries.Find("US")) + .With(x => x.Label = release.Label.First()) + .With(x => x.Year = (uint)release.Album.Value.ReleaseDate.Value.Year) + .Build(); + } + + private List GivenLocalTracks(List tracks, AlbumRelease release) + { + var output = Builder + .CreateListOfSize(tracks.Count) + .Build() + .ToList(); + + for (int i = 0; i < tracks.Count; i++) + { + output[i].FileTrackInfo = GivenParsedTrackInfo(tracks[i], release); + } + + return output; + } + + private AlbumRelease GivenAlbumRelease(string title, List tracks) + { + var album = Builder + .CreateNew() + .With(x => x.Title = title) + .With(x => x.ArtistMetadata = artist) + .Build(); + + var media = Builder + .CreateListOfSize(1) + .Build() + .ToList(); + + return Builder + .CreateNew() + .With(x => x.Tracks = tracks) + .With(x => x.Title = title) + .With(x => x.Album = album) + .With(x => x.Media = media) + .With(x => x.Country = new List()) + .With(x => x.Label = new List { "label" }) + .Build(); + } + + [Test] + public void test_reorder_when_track_numbers_incorrect() + { + var tracks = GivenTracks(3); + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + + localTracks[2].FileTrackInfo.TrackNumbers = new [] { 2 }; + localTracks[1].FileTrackInfo.TrackNumbers = new [] { 3 }; + localTracks = new [] {0, 2, 1}.Select(x => localTracks[x]).ToList(); + + var result = Subject.MapReleaseTracks(localTracks, tracks); + + result.Mapping + .ToDictionary(x => x.Key, y => y.Value.Item1) + .ShouldBeEquivalentTo(new Dictionary { + {localTracks[0], tracks[0]}, + {localTracks[1], tracks[2]}, + {localTracks[2], tracks[1]}, + }); + result.LocalExtra.Should().BeEmpty(); + result.MBExtra.Should().BeEmpty(); + } + + [Test] + public void test_order_works_with_invalid_track_numbers() + { + var tracks = GivenTracks(3); + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + + foreach (var track in localTracks) + { + track.FileTrackInfo.TrackNumbers = new[] { 1 }; + } + + var result = Subject.MapReleaseTracks(localTracks, tracks); + + result.Mapping + .ToDictionary(x => x.Key, y => y.Value.Item1) + .ShouldBeEquivalentTo(new Dictionary { + {localTracks[0], tracks[0]}, + {localTracks[1], tracks[1]}, + {localTracks[2], tracks[2]}, + }); + result.LocalExtra.Should().BeEmpty(); + result.MBExtra.Should().BeEmpty(); + } + + [Test] + public void test_order_works_with_missing_tracks() + { + var tracks = GivenTracks(3); + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + localTracks.RemoveAt(1); + + var result = Subject.MapReleaseTracks(localTracks, tracks); + + result.Mapping + .ToDictionary(x => x.Key, y => y.Value.Item1) + .ShouldBeEquivalentTo(new Dictionary { + {localTracks[0], tracks[0]}, + {localTracks[1], tracks[2]} + }); + result.LocalExtra.Should().BeEmpty(); + result.MBExtra.ShouldBeEquivalentTo(new List { tracks[1] }); + } + + [Test] + public void test_order_works_with_extra_tracks() + { + var tracks = GivenTracks(3); + var release = GivenAlbumRelease("album", tracks); + var localTracks = GivenLocalTracks(tracks, release); + tracks.RemoveAt(1); + + var result = Subject.MapReleaseTracks(localTracks, tracks); + + result.Mapping + .ToDictionary(x => x.Key, y => y.Value.Item1) + .ShouldBeEquivalentTo(new Dictionary { + {localTracks[0], tracks[0]}, + {localTracks[2], tracks[1]} + }); + result.LocalExtra.ShouldBeEquivalentTo(new List { localTracks[1] }); + result.MBExtra.Should().BeEmpty(); + } + } +} diff --git a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/ImportDecisionMakerFixture.cs b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/ImportDecisionMakerFixture.cs index cbd2d3c02..2bf6c9a54 100644 --- a/src/NzbDrone.Core.Test/MediaFiles/TrackImport/ImportDecisionMakerFixture.cs +++ b/src/NzbDrone.Core.Test/MediaFiles/TrackImport/ImportDecisionMakerFixture.cs @@ -6,16 +6,18 @@ using NUnit.Framework; using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.TrackImport; -using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Profiles.Qualities; +using NzbDrone.Core.Profiles; using NzbDrone.Core.Qualities; using NzbDrone.Core.Test.Framework; using NzbDrone.Core.Music; using NzbDrone.Test.Common; using FizzWare.NBuilder; -using NzbDrone.Core.Languages; +using NzbDrone.Core.Download; +using NzbDrone.Core.MediaFiles.TrackImport.Aggregation; +using NzbDrone.Core.Profiles.Qualities; using NzbDrone.Core.Profiles.Languages; +using NzbDrone.Core.MediaFiles.TrackImport.Identification; namespace NzbDrone.Core.Test.MediaFiles.TrackImport { @@ -25,27 +27,54 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport private List _audioFiles; private LocalTrack _localTrack; private Artist _artist; + private AlbumRelease _albumRelease; private QualityModel _quality; - private Mock _pass1; - private Mock _pass2; - private Mock _pass3; + private Mock> _albumpass1; + private Mock> _albumpass2; + private Mock> _albumpass3; + + private Mock> _albumfail1; + private Mock> _albumfail2; + private Mock> _albumfail3; - private Mock _fail1; - private Mock _fail2; - private Mock _fail3; + + private Mock> _pass1; + private Mock> _pass2; + private Mock> _pass3; + + private Mock> _fail1; + private Mock> _fail2; + private Mock> _fail3; [SetUp] public void Setup() { - _pass1 = new Mock(); - _pass2 = new Mock(); - _pass3 = new Mock(); + _albumpass1 = new Mock>(); + _albumpass2 = new Mock>(); + _albumpass3 = new Mock>(); - _fail1 = new Mock(); - _fail2 = new Mock(); - _fail3 = new Mock(); + _albumfail1 = new Mock>(); + _albumfail2 = new Mock>(); + _albumfail3 = new Mock>(); + + _pass1 = new Mock>(); + _pass2 = new Mock>(); + _pass3 = new Mock>(); + + _fail1 = new Mock>(); + _fail2 = new Mock>(); + _fail3 = new Mock>(); + + _albumpass1.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(Decision.Accept()); + _albumpass2.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(Decision.Accept()); + _albumpass3.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(Decision.Accept()); + + _albumfail1.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(Decision.Reject("_albumfail1")); + _albumfail2.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(Decision.Reject("_albumfail2")); + _albumfail3.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(Decision.Reject("_albumfail3")); + _pass1.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(Decision.Accept()); _pass2.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(Decision.Accept()); _pass3.Setup(c => c.IsSatisfiedBy(It.IsAny())).Returns(Decision.Accept()); @@ -59,25 +88,33 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport .With(e => e.LanguageProfile = new LanguageProfile { Languages = Languages.LanguageFixture.GetDefaultLanguages() }) .Build(); + _albumRelease = Builder.CreateNew() + .Build(); + _quality = new QualityModel(Quality.MP3_256); _localTrack = new LocalTrack - { + { Artist = _artist, Quality = _quality, - Language = Language.Spanish, Tracks = new List { new Track() }, - Path = @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.Spanish.XviD-OSiTV.avi" + Path = @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV.avi" }; - Mocker.GetMock() - .Setup(c => c.GetLocalTrack(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(_localTrack); + GivenVideoFiles(new List { @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.XviD-OSiTV.avi".AsOsAgnostic() }); + + Mocker.GetMock() + .Setup(s => s.Identify(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((List tracks, Artist artist, Album album, AlbumRelease release, bool newDownload, bool singleRelease) => { + var ret = new LocalAlbumRelease(tracks); + ret.AlbumRelease = _albumRelease; + return new List { ret }; + }); - GivenVideoFiles(new List { @"C:\Test\Unsorted\The.Office.S03E115.DVDRip.Spanish.XviD-OSiTV.avi".AsOsAgnostic() }); + GivenSpecifications(_albumpass1); } - private void GivenSpecifications(params Mock[] mocks) + private void GivenSpecifications(params Mock>[] mocks) { Mocker.SetConstant(mocks.Select(c => c.Object)); } @@ -91,177 +128,142 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport .Returns(_audioFiles); } - [Test] - public void should_call_all_specifications() + private void GivenAugmentationSuccess() { - GivenSpecifications(_pass1, _pass2, _pass3, _fail1, _fail2, _fail3); - - Subject.GetImportDecisions(_audioFiles, new Artist(), null); - - _fail1.Verify(c => c.IsSatisfiedBy(_localTrack), Times.Once()); - _fail2.Verify(c => c.IsSatisfiedBy(_localTrack), Times.Once()); - _fail3.Verify(c => c.IsSatisfiedBy(_localTrack), Times.Once()); - _pass1.Verify(c => c.IsSatisfiedBy(_localTrack), Times.Once()); - _pass2.Verify(c => c.IsSatisfiedBy(_localTrack), Times.Once()); - _pass3.Verify(c => c.IsSatisfiedBy(_localTrack), Times.Once()); + Mocker.GetMock() + .Setup(s => s.Augment(It.IsAny(), It.IsAny())) + .Callback((localTrack, otherFiles) => + { + localTrack.Tracks = _localTrack.Tracks; + }); } [Test] - public void should_return_rejected_if_single_specs_fail() + public void should_call_all_album_specifications() { - GivenSpecifications(_fail1); - - var result = Subject.GetImportDecisions(_audioFiles, new Artist()); - - result.Single().Approved.Should().BeFalse(); + var downloadClientItem = Builder.CreateNew().Build(); + GivenAugmentationSuccess(); + GivenSpecifications(_albumpass1, _albumpass2, _albumpass3, _albumfail1, _albumfail2, _albumfail3); + + Subject.GetImportDecisions(_audioFiles, new Artist(), null, downloadClientItem, null, false, false, false); + + _albumfail1.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Once()); + _albumfail2.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Once()); + _albumfail3.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Once()); + _albumpass1.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Once()); + _albumpass2.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Once()); + _albumpass3.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Once()); } [Test] - public void should_return_rejected_if_one_of_specs_fail() + public void should_call_all_track_specifications_if_album_accepted() { - GivenSpecifications(_pass1, _fail1, _pass2, _pass3); - - var result = Subject.GetImportDecisions(_audioFiles, new Artist()); - - result.Single().Approved.Should().BeFalse(); - } - - [Test] - public void should_return_pass_if_all_specs_pass() - { - GivenSpecifications(_pass1, _pass2, _pass3); + var downloadClientItem = Builder.CreateNew().Build(); + GivenAugmentationSuccess(); + GivenSpecifications(_pass1, _pass2, _pass3, _fail1, _fail2, _fail3); - var result = Subject.GetImportDecisions(_audioFiles, new Artist()); + Subject.GetImportDecisions(_audioFiles, new Artist(), null, downloadClientItem, null, false, false, false); - result.Single().Approved.Should().BeTrue(); + _fail1.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Once()); + _fail2.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Once()); + _fail3.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Once()); + _pass1.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Once()); + _pass2.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Once()); + _pass3.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Once()); } [Test] - public void should_have_same_number_of_rejections_as_specs_that_failed() + public void should_call_no_track_specifications_if_album_rejected() { + var downloadClientItem = Builder.CreateNew().Build(); + GivenAugmentationSuccess(); + GivenSpecifications(_albumpass1, _albumpass2, _albumpass3, _albumfail1, _albumfail2, _albumfail3); GivenSpecifications(_pass1, _pass2, _pass3, _fail1, _fail2, _fail3); - var result = Subject.GetImportDecisions(_audioFiles, new Artist()); - result.Single().Rejections.Should().HaveCount(3); + Subject.GetImportDecisions(_audioFiles, new Artist(), null, downloadClientItem, null, false, false, false); + + _fail1.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Never()); + _fail2.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Never()); + _fail3.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Never()); + _pass1.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Never()); + _pass2.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Never()); + _pass3.Verify(c => c.IsSatisfiedBy(It.IsAny()), Times.Never()); } [Test] - public void should_not_blowup_the_process_due_to_failed_parse() + public void should_return_rejected_if_only_album_spec_fails() { + GivenSpecifications(_albumfail1); GivenSpecifications(_pass1); - Mocker.GetMock() - .Setup(c => c.GetLocalTrack(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Throws(); - - _audioFiles = new List - { - "The.Office.S03E115.DVDRip.XviD-OSiTV", - "The.Office.S03E115.DVDRip.XviD-OSiTV", - "The.Office.S03E115.DVDRip.XviD-OSiTV" - }; - - GivenVideoFiles(_audioFiles); - - Subject.GetImportDecisions(_audioFiles, _artist); - - Mocker.GetMock() - .Verify(c => c.GetLocalTrack(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(_audioFiles.Count)); - - ExceptionVerification.ExpectedErrors(3); - } - - [Test] - public void should_use_file_quality_if_folder_quality_is_null() - { - GivenSpecifications(_pass1, _pass2, _pass3); - var expectedQuality = QualityParser.ParseQuality(_audioFiles.Single(), null, 0); + var result = Subject.GetImportDecisions(_audioFiles, new Artist()); - var result = Subject.GetImportDecisions(_audioFiles, _artist); - - result.Single().LocalTrack.Quality.Should().Be(expectedQuality); + result.Single().Approved.Should().BeFalse(); } [Test] - public void should_use_file_language_if_folder_language_is_null() + public void should_return_rejected_if_only_track_spec_fails() { - GivenSpecifications(_pass1, _pass2, _pass3); - var expectedLanguage = LanguageParser.ParseLanguage(_audioFiles.Single()); + GivenSpecifications(_albumpass1); + GivenSpecifications(_fail1); - var result = Subject.GetImportDecisions(_audioFiles, _artist); + var result = Subject.GetImportDecisions(_audioFiles, new Artist()); - result.Single().LocalTrack.Language.Should().Be(expectedLanguage); + result.Single().Approved.Should().BeFalse(); } - [Test] - public void should_use_file_quality_if_file_quality_was_determined_by_name() + [Test] + public void should_return_rejected_if_one_album_spec_fails() { + GivenSpecifications(_albumpass1, _albumfail1, _albumpass2, _albumpass3); GivenSpecifications(_pass1, _pass2, _pass3); - var expectedQuality = QualityParser.ParseQuality(_audioFiles.Single(), null, 0); - var result = Subject.GetImportDecisions(_audioFiles, _artist, new ParsedTrackInfo{Quality = new QualityModel(Quality.MP3_256) }); + var result = Subject.GetImportDecisions(_audioFiles, new Artist()); - result.Single().LocalTrack.Quality.Should().Be(expectedQuality); + result.Single().Approved.Should().BeFalse(); } [Test] - public void should_use_folder_quality_when_file_quality_was_determined_by_the_extension() + public void should_return_rejected_if_one_track_spec_fails() { - GivenSpecifications(_pass1, _pass2, _pass3); - GivenVideoFiles(new string[] { @"C:\Test\Unsorted\The.Office.S03E115.mkv".AsOsAgnostic() }); - - _localTrack.Path = _audioFiles.Single(); - _localTrack.Quality.QualitySource = QualitySource.Extension; - _localTrack.Quality.Quality = Quality.MP3_256; - - var expectedQuality = new QualityModel(Quality.MP3_256); + GivenSpecifications(_albumpass1, _albumpass2, _albumpass3); + GivenSpecifications(_pass1, _fail1, _pass2, _pass3); - var result = Subject.GetImportDecisions(_audioFiles, _artist, new ParsedTrackInfo { Quality = expectedQuality }); + var result = Subject.GetImportDecisions(_audioFiles, new Artist()); - result.Single().LocalTrack.Quality.Should().Be(expectedQuality); + result.Single().Approved.Should().BeFalse(); } [Test] - public void should_use_folder_quality_when_greater_than_file_quality() + public void should_return_approved_if_all_specs_pass() { + GivenAugmentationSuccess(); + GivenSpecifications(_albumpass1, _albumpass2, _albumpass3); GivenSpecifications(_pass1, _pass2, _pass3); - GivenVideoFiles(new string[] { @"C:\Test\Unsorted\The.Office.S03E115.mkv".AsOsAgnostic() }); - - _localTrack.Path = _audioFiles.Single(); - _localTrack.Quality.Quality = Quality.MP3_256; - var expectedQuality = new QualityModel(Quality.MP3_256); - - var result = Subject.GetImportDecisions(_audioFiles, _artist, new ParsedTrackInfo { Quality = expectedQuality }); + var result = Subject.GetImportDecisions(_audioFiles, new Artist()); - result.Single().LocalTrack.Quality.Should().Be(expectedQuality); + result.Single().Approved.Should().BeTrue(); } [Test] - public void should_use_folder_language_when_greater_than_file_language() + public void should_have_same_number_of_rejections_as_specs_that_failed() { - GivenSpecifications(_pass1, _pass2, _pass3); - GivenVideoFiles(new string[] { @"C:\Test\Unsorted\The.Office.S03E115.Spanish.mkv".AsOsAgnostic() }); - - _localTrack.Path = _audioFiles.Single(); - _localTrack.Quality.Quality = Quality.MP3_320; - _localTrack.Language = Language.Spanish; - - var expectedLanguage = Language.French; - - var result = Subject.GetImportDecisions(_audioFiles, _artist, new ParsedTrackInfo { Language = expectedLanguage, Quality = new QualityModel(Quality.MP3_192) }); + GivenAugmentationSuccess(); + GivenSpecifications(_pass1, _pass2, _pass3, _fail1, _fail2, _fail3); - result.Single().LocalTrack.Language.Should().Be(expectedLanguage); + var result = Subject.GetImportDecisions(_audioFiles, new Artist()); + result.Single().Rejections.Should().HaveCount(3); } -[Test] - public void should_not_throw_if_episodes_are_not_found() + [Test] + public void should_not_blowup_the_process_due_to_failed_augment() { GivenSpecifications(_pass1); - Mocker.GetMock() - .Setup(c => c.GetLocalTrack(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(new LocalTrack() { Path = "test" }); + Mocker.GetMock() + .Setup(c => c.Augment(It.IsAny(), It.IsAny())) + .Throws(); _audioFiles = new List { @@ -272,129 +274,71 @@ namespace NzbDrone.Core.Test.MediaFiles.TrackImport GivenVideoFiles(_audioFiles); - var decisions = Subject.GetImportDecisions(_audioFiles, _artist); - - Mocker.GetMock() - .Verify(c => c.GetLocalTrack(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(_audioFiles.Count)); - - decisions.Should().HaveCount(3); - decisions.First().Rejections.Should().NotBeEmpty(); - } - - [Test] - public void should_not_use_folder_for_full_season() - { - var videoFiles = new[] - { - @"C:\Test\Unsorted\Artist.Title.S01\S01E01.mkv".AsOsAgnostic(), - @"C:\Test\Unsorted\Artist.Title.S01\S01E02.mkv".AsOsAgnostic(), - @"C:\Test\Unsorted\Artist.Title.S01\S01E03.mkv".AsOsAgnostic() - }; - - GivenSpecifications(_pass1); - GivenVideoFiles(videoFiles); - - var folderInfo = Parser.Parser.ParseMusicTitle("Artist.Title.S01"); - - Subject.GetImportDecisions(_audioFiles, _artist, folderInfo); + Subject.GetImportDecisions(_audioFiles, _artist); - Mocker.GetMock() - .Verify(c => c.GetLocalTrack(It.IsAny(), It.IsAny(), It.IsAny(), null), Times.Exactly(3)); + Mocker.GetMock() + .Verify(c => c.Augment(It.IsAny(), It.IsAny()), Times.Exactly(_audioFiles.Count)); - Mocker.GetMock() - .Verify(c => c.GetLocalTrack(It.IsAny(), It.IsAny(), It.IsAny(), It.Is(p => p != null)), Times.Never()); + ExceptionVerification.ExpectedErrors(3); } [Test] - public void should_not_use_folder_when_it_contains_more_than_one_valid_video_file() + public void should_not_throw_if_release_not_identified() { - var videoFiles = new[] - { - @"C:\Test\Unsorted\Artist.Title.S01E01\S01E01.mkv".AsOsAgnostic(), - @"C:\Test\Unsorted\Artist.Title.S01E01\1x01.mkv".AsOsAgnostic() - }; - GivenSpecifications(_pass1); - GivenVideoFiles(videoFiles); - var folderInfo = Parser.Parser.ParseMusicTitle("Artist.Title.S01E01"); - - Subject.GetImportDecisions(_audioFiles, _artist, folderInfo); - - Mocker.GetMock() - .Verify(c => c.GetLocalTrack(It.IsAny(), It.IsAny(), It.IsAny(), null), Times.Exactly(2)); - - Mocker.GetMock() - .Verify(c => c.GetLocalTrack(It.IsAny(), It.IsAny(), It.IsAny(), It.Is(p => p != null)), Times.Never()); - } - - [Test] - public void should_use_folder_when_only_one_video_file() - { - var videoFiles = new[] - { - @"C:\Test\Unsorted\Artist.Title.S01E01\S01E01.mkv".AsOsAgnostic() - }; + _audioFiles = new List + { + "The.Office.S03E115.DVDRip.XviD-OSiTV", + "The.Office.S03E115.DVDRip.XviD-OSiTV", + "The.Office.S03E115.DVDRip.XviD-OSiTV" + }; - GivenSpecifications(_pass1); - GivenVideoFiles(videoFiles); + GivenVideoFiles(_audioFiles); - var folderInfo = Parser.Parser.ParseMusicTitle("Artist.Title.S01E01"); + Mocker.GetMock() + .Setup(s => s.Identify(It.IsAny>(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns((List tracks, Artist artist, Album album, AlbumRelease release, bool newDownload, bool singleRelease) => { + return new List { new LocalAlbumRelease(tracks) }; + }); - Subject.GetImportDecisions(_audioFiles, _artist, folderInfo); + var decisions = Subject.GetImportDecisions(_audioFiles, _artist); - Mocker.GetMock() - .Verify(c => c.GetLocalTrack(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Exactly(1)); + Mocker.GetMock() + .Verify(c => c.Augment(It.IsAny(), It.IsAny()), Times.Exactly(_audioFiles.Count)); - Mocker.GetMock() - .Verify(c => c.GetLocalTrack(It.IsAny(), It.IsAny(), It.IsAny(), null), Times.Never()); + decisions.Should().HaveCount(3); + decisions.First().Rejections.Should().NotBeEmpty(); } [Test] - public void should_not_use_folder_name_if_file_name_is_scene_name() + public void should_not_throw_if_tracks_are_not_found() { - var videoFiles = new[] - { - @"C:\Test\Unsorted\Artist.Title.S01E01.720p.HDTV-LOL\Artist.Title.S01E01.720p.HDTV-LOL.mkv".AsOsAgnostic() - }; - GivenSpecifications(_pass1); - GivenVideoFiles(videoFiles); - - var folderInfo = Parser.Parser.ParseMusicTitle("Artist.Title.S01E01.720p.HDTV-LOL"); - - Subject.GetImportDecisions(_audioFiles, _artist, folderInfo); - Mocker.GetMock() - .Verify(c => c.GetLocalTrack(It.IsAny(), It.IsAny(), It.IsAny(), null), Times.Exactly(1)); - - Mocker.GetMock() - .Verify(c => c.GetLocalTrack(It.IsAny(), It.IsAny(), It.IsAny(), It.Is(p => p != null)), Times.Never()); - } - - [Test] - public void should_not_use_folder_quality_when_it_is_unknown() - { - GivenSpecifications(_pass1, _pass2, _pass3); - - _artist.Profile = new Profile - { - Items = Qualities.QualityFixture.GetDefaultQualities(Quality.MP3_256, Quality.Unknown) - }; + _audioFiles = new List + { + "The.Office.S03E115.DVDRip.XviD-OSiTV", + "The.Office.S03E115.DVDRip.XviD-OSiTV", + "The.Office.S03E115.DVDRip.XviD-OSiTV" + }; + GivenVideoFiles(_audioFiles); - var folderQuality = new QualityModel(Quality.Unknown); + var decisions = Subject.GetImportDecisions(_audioFiles, _artist); - var result = Subject.GetImportDecisions(_audioFiles, _artist, new ParsedTrackInfo { Quality = folderQuality}); + Mocker.GetMock() + .Verify(c => c.Augment(It.IsAny(), It.IsAny()), Times.Exactly(_audioFiles.Count)); - result.Single().LocalTrack.Quality.Should().Be(_quality); + decisions.Should().HaveCount(3); + decisions.First().Rejections.Should().NotBeEmpty(); } [Test] public void should_return_a_decision_when_exception_is_caught() { - Mocker.GetMock() - .Setup(c => c.GetLocalTrack(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + Mocker.GetMock() + .Setup(c => c.Augment(It.IsAny(), It.IsAny())) .Throws(); _audioFiles = new List diff --git a/src/NzbDrone.Core.Test/MusicTests/ArtistServiceTests/FindByNameInexactFixture.cs b/src/NzbDrone.Core.Test/MusicTests/ArtistServiceTests/FindByNameInexactFixture.cs index 2c11b65d4..98409d8ab 100644 --- a/src/NzbDrone.Core.Test/MusicTests/ArtistServiceTests/FindByNameInexactFixture.cs +++ b/src/NzbDrone.Core.Test/MusicTests/ArtistServiceTests/FindByNameInexactFixture.cs @@ -37,6 +37,7 @@ namespace NzbDrone.Core.Test.MusicTests.ArtistServiceTests [TestCase("The Black Eyde Peas", "The Black Eyed Peas")] [TestCase("Black Eyed Peas", "The Black Eyed Peas")] [TestCase("The Black eys", "The Black Keys")] + [TestCase("Black Keys", "The Black Keys")] public void should_find_artist_in_db_by_name_inexact(string name, string expected) { var artist = Subject.FindByNameInexact(name); @@ -45,6 +46,20 @@ namespace NzbDrone.Core.Test.MusicTests.ArtistServiceTests artist.Name.Should().Be(expected); } + [Test] + public void should_find_artist_when_the_is_omitted_from_start() + { + _artists = new List(); + _artists.Add(CreateArtist("Black Keys")); + _artists.Add(CreateArtist("The Black Eyed Peas")); + + Mocker.GetMock() + .Setup(s => s.All()) + .Returns(_artists); + + Subject.FindByNameInexact("The Black Keys").Should().NotBeNull(); + } + [TestCase("The Black Peas")] public void should_not_find_artist_in_db_by_ambiguous_name(string name) { diff --git a/src/NzbDrone.Core.Test/MusicTests/TitleMatchingTests/TitleMatchingFixture.cs b/src/NzbDrone.Core.Test/MusicTests/TitleMatchingTests/TitleMatchingFixture.cs deleted file mode 100644 index fba631707..000000000 --- a/src/NzbDrone.Core.Test/MusicTests/TitleMatchingTests/TitleMatchingFixture.cs +++ /dev/null @@ -1,178 +0,0 @@ -using System.Linq; -using FluentAssertions; -using NUnit.Framework; -using NzbDrone.Core.Music; -using NzbDrone.Core.Test.Framework; -using System.Collections.Generic; -using Moq; - -namespace NzbDrone.Core.Test.MusicTests.TitleMatchingTests -{ - [TestFixture] - public class TitleMatchingFixture : CoreTest - { - private List _tracks; - - [SetUp] - public void Setup() - { - var trackNames = new List { - "Courage", - "Movies", - "Flesh and Bone", - "Whisper", - "Summer", - "Sticks and Stones", - "Attitude", - "Stranded", - "Wish", - "Calico", - "(Happy) Death Day", - "Smooth Criminal", - "Universe / Orange Appeal", - "Christian's Inferno" - }; - - _tracks = new List(); - for (int i = 0; i < trackNames.Count; i++) { - _tracks.Add(new Track - { - Title = trackNames[i], - ForeignTrackId = (i+1).ToString(), - AbsoluteTrackNumber = i+1, - MediumNumber = 1 - }); - } - - Mocker.GetMock() - .Setup(s => s.GetTracksByMedium(It.IsAny(), It.IsAny())) - .Returns(_tracks); - - Mocker.GetMock() - .Setup(s => s.Find(1234, 4321, It.IsAny(), It.IsAny())) - .Returns((int artistid, int albumid, int medium, int track) => _tracks.Where(t => t.AbsoluteTrackNumber == track && t.MediumNumber == medium).Single()); - } - - private void GivenSecondDisc() - { - var trackNames = new List { - "Courage", - "another entry", - "random name" - }; - - for (int i = 0; i < trackNames.Count; i++) { - _tracks.Add(new Track - { - Title = trackNames[i], - ForeignTrackId = (100+i+1).ToString(), - AbsoluteTrackNumber = i+1, - MediumNumber = 2 - }); - } - } - - [Test] - public void should_find_track_in_db_by_tracktitle_longer_then_releasetitle() - { - var track = Subject.FindTrackByTitle(1234, 4321, 1, 1, "Courage with some bla"); - - track.Should().NotBeNull(); - track.Title.Should().Be(Subject.FindTrack(1234, 4321, 1, 1).Title); - } - - [Test] - public void should_find_track_in_db_by_tracktitle_shorter_then_releasetitle() - { - var track = Subject.FindTrackByTitle(1234, 4321, 1, 3, "and Bone"); - - track.Should().NotBeNull(); - track.Title.Should().Be(Subject.FindTrack(1234, 4321, 1, 3).Title); - } - - [Test] - public void should_not_find_track_in_db_by_wrong_title() - { - var track = Subject.FindTrackByTitle(1234, 4321, 1, 1, "Not a track"); - - track.Should().BeNull(); - } - - [TestCase("another entry", 2, 2)] - [TestCase("random name", 2, 3)] - public void should_find_track_on_second_disc_when_disc_tag_missing(string title, int discNumber, int trackNumber) - { - GivenSecondDisc(); - var track = Subject.FindTrackByTitle(1234, 4321, 0, trackNumber, title); - var expected = Subject.FindTrack(1234, 4321, discNumber, trackNumber); - - track.Should().NotBeNull(); - expected.Should().NotBeNull(); - - track.Title.Should().Be(expected.Title); - } - - [Test] - public void should_return_null_if_tracks_with_same_name_and_number_on_different_discs() - { - GivenSecondDisc(); - var track = Subject.FindTrackByTitle(1234, 4321, 0, 1, "Courage"); - track.Should().BeNull(); - } - - [TestCase("Fesh and Bone", 3)] - [TestCase("Atitude", 7)] - [TestCase("Smoth cRimnal", 12)] - [TestCase("Sticks and Stones (live)", 6)] - [TestCase("Sticks and Stones (live) - there's a lot of rubbish here", 6)] - [TestCase("Smoth cRimnal feat. someone I don't care about", 12)] - [TestCase("Christians Inferno", 14)] - [TestCase("xxxyyy some random prefix Christians Infurno", 14)] - public void should_find_track_in_db_by_inexact_title(string title, int trackNumber) - { - var track = Subject.FindTrackByTitleInexact(1234, 4321, 1, trackNumber, title); - var expected = Subject.FindTrack(1234, 4321, 1, trackNumber); - - track.Should().NotBeNull(); - expected.Should().NotBeNull(); - - track.Title.Should().Be(expected.Title); - } - - [TestCase("Fesh and Bone", 1)] - [TestCase("Atitude", 1)] - [TestCase("Smoth cRimnal", 1)] - [TestCase("Sticks and Stones (live)", 1)] - [TestCase("Christians Inferno", 1)] - public void should_not_find_track_in_db_by_inexact_title_with_wrong_tracknumber(string title, int trackNumber) - { - var track = Subject.FindTrackByTitleInexact(1234, 4321, 1, trackNumber, title); - - track.Should().BeNull(); - } - - [TestCase("Movis", 1, 2)] - [TestCase("anoth entry", 2, 2)] - [TestCase("random.name", 2, 3)] - public void should_find_track_in_db_by_inexact_title_when_disc_tag_missing(string title, int discNumber, int trackNumber) - { - GivenSecondDisc(); - var track = Subject.FindTrackByTitleInexact(1234, 4321, 0, trackNumber, title); - var expected = Subject.FindTrack(1234, 4321, discNumber, trackNumber); - - track.Should().NotBeNull(); - expected.Should().NotBeNull(); - - track.Title.Should().Be(expected.Title); - } - - [TestCase("A random title", 1)] - [TestCase("Stones and Sticks", 6)] - public void should_not_find_track_in_db_by_different_inexact_title(string title, int trackId) - { - var track = Subject.FindTrackByTitleInexact(1234, 4321, 1, trackId, title); - - track.Should().BeNull(); - } - } -} diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index 1abccca9f..512d158bf 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -1,4 +1,4 @@ - + Debug @@ -285,8 +285,15 @@ - - + + + + + + + + + @@ -310,13 +317,12 @@ - - + @@ -327,8 +333,6 @@ - - @@ -498,6 +502,12 @@ Always + + Always + + + Always + Always @@ -594,8 +604,14 @@ + + + + - --> - \ No newline at end of file + diff --git a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs index 3d24aba3e..bb2e47e7f 100644 --- a/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs +++ b/src/NzbDrone.Core.Test/OrganizerTests/FileNameBuilderTests/FileNameBuilderFixture.cs @@ -400,38 +400,6 @@ namespace NzbDrone.Core.Test.OrganizerTests.FileNameBuilderTests .Should().Be("Linkin.Park.06"); } - [Test] - public void should_format_mediainfo_properly() - { - _namingConfig.StandardTrackFormat = "{Artist.Name}.{track:00}.{Track.Title}.{MEDIAINFO.FULL}"; - - _trackFile.MediaInfo = new Core.MediaFiles.MediaInfo.MediaInfoModel() - { - AudioFormat = "FLAC", - AudioLanguages = "English/Spanish", - Subtitles = "English/Spanish/Italian" - }; - - Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) - .Should().Be("Linkin.Park.06.City.Sushi.FLAC[EN+ES].[EN+ES+IT]"); - } - - [Test] - public void should_exclude_english_in_mediainfo_audio_language() - { - _namingConfig.StandardTrackFormat = "{Artist.Name}.{track:00}.{Track.Title}.{MEDIAINFO.FULL}"; - - _trackFile.MediaInfo = new Core.MediaFiles.MediaInfo.MediaInfoModel - { - AudioFormat = "FLAC", - AudioLanguages = "English", - Subtitles = "English/Spanish/Italian" - }; - - Subject.BuildTrackFileName(new List { _track1 }, _artist, _album, _trackFile) - .Should().Be("Linkin.Park.06.City.Sushi.FLAC.[EN+ES+IT]"); - } - [Test] public void should_remove_duplicate_non_word_characters() { diff --git a/src/NzbDrone.Core.Test/ParserTests/FingerprintingServiceFixture.cs b/src/NzbDrone.Core.Test/ParserTests/FingerprintingServiceFixture.cs new file mode 100644 index 000000000..c39a32702 --- /dev/null +++ b/src/NzbDrone.Core.Test/ParserTests/FingerprintingServiceFixture.cs @@ -0,0 +1,132 @@ +using FluentAssertions; +using NUnit.Framework; +using NzbDrone.Core.Test.Framework; +using NzbDrone.Core.Parser; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using NzbDrone.Core.Parser.Model; +using System; + +namespace NzbDrone.Core.Test.ParserTests +{ + [TestFixture] + public class FingerprintingServiceFixture : CoreTest + { + + [Test] + public void should_parse_fpcalc_json() + { + var json = "{\"duration\": 229.29, \"fingerprint\": \"AQADtFMURlEURcgP6cwRD43Y4Ptw9FowncWPWkf6GB9-JYdP9OgJHw8u4Apw4SsOHMdxHAfwHIfYET_0HiZ55MfxNCm8Hj-mRM8KLlXwIzqS-Que_Cg7NEseE_2j4DUjJBOvI08UNNUufEd5NM-OHzmSUZsO58er7nhUeN6EK3HYwhe0H-fxXEFzlOSHJwpj3MevID7kHyqVqBH6zEI6GzmSs4HTDz4j9DjxKHDvpLiOH7l4iFUWRXiiqtCJmMqNPmiAZEyY5AgTLclxmSWuwJ-O33jz4KdwHt-PsDHRjBNyCQ_-KcGPhoePsJNyiJOiI-5VPKMj_McvfNQRUkyiImEzAv2Ckxb-RUJ-CVpGhMYnB5em4scvHFeuoU7DwHugT8fRecPVpMF5_Cb8jNvw4n_wJ6iV4xcSHuFxKcfTBhVF5uiPA6OSoWQP5x_6BX-5YDoaJT_OLA3e0Ifr4D2SH2EYB-WShLiE6plg5U2G6jlOSgy-G8-Rk0XD7yiP4DyPfcejiQ2aH9WVHKeOJ5eQP9CeKEfD44zw45kj_NAQJmIWfMTPCJF048IPPTtcB_GP_2ieFydyxYXGvUgffIO1D96SHW10vEeYI9mNR-qLhnpwfYHz49KR8ijnol58pDGLKWuF4wvKTUGjrE-Q79COdFyI8Aze4C70LMP24-YKJLtSeCHqajJ6e2g0qsdzBd94RNdxHY80oomYocdB5hNiUrGIVtrhJ0f4CY-Y48yOF5rFI93sgNF2hNRhTSk65odGaUhJClc5ArOGdi8-oz_SzaqQ3MKV63DkGX8NT07wVcdf5MmO5Av8C9dOONGIS_mRS7gO8fjhLNMFbQqq4zF0jRPir2j2Y9_xH5Z-_EFiHWE-oaePh8JN3CsaiYgkGvrR_OgVaI2iHv_gTLsQaTeuZ8K5HUdMCWUlHpLIIFyO8TiNO2nQOHzwLkfYQg2RHxXlUfDxHX-MO9Pxwc8J90JSEzmDJk-Ji0cpZanw44mOWpKEkkHDsUFkZsfNaJCOqDvK_fg-9NHx5UHtFGEOPUeu47hw4dzwH22Nc9pgK8dZhJ-h8wipwyS8Dw8cZ0evC8kZJcgrHT22jsfhH_QCv4jMHNp25NLx6Hh4tEtaePzgcNHxDOkYQ3tCxBsvPFKOfyzCB-L5IHyCpzhFHo9zvMeP5vrQbdzwK8N_hFG6F0mdo2GOJ4yxM0bJRFaGH3WyHUdzHWWe4uoH8hWS8EGOGuUSBZ-E6w7sZsKpIId-IzyDptLxhD76GpfxNDHSkdCTS0JMFS-8Rh6e48c6Hv2O8CG05MinRcUX9A0cKoSv4Ts8KbSQbCXyDDeDZ_mKUrwHP8tRi8d94UcX3ImMLzq8o3-DnFXwHG2UJ_Au6FlwNLgOXU_xakKv4zp8VMeN_vBz9OCbQzsFP7h8PB96Df8llNJSWI8KfVkqZMcPaw5ePEgf6HKQjj2YTRmOxslyTJ-K_kj3Qj9M6TjCM33QixGcX8PzHNfxDOfhMx2SHflz_Dr-K6hSKVLQ7MSjII-UQ9fQ53Bi6ahMoz5jNA304xDz7RjTI08eXKeChzmeDeUJfw7OEPVGnB_yoz_M6sZj8MOfHE5wXKLhT0V-MMhjlDv0C-mP_jgt4cxRMgoLSz5GkUZ-3MFTPHCpB-W2EheX43iWeUgfDhdJPAsR_oKWmDGeJRMRoyea3fhyVA6RjsuCCxdDiD-2fsizC5eSp_CPh4UnP5gU6sOpL7g3ocyCSDn0I7-L4znKBg3uKDmeW8Nzsriqg5eMHk33Cn9Q_0hPQtMX4oqOfMedHI-I2mPwd0PeQ8yHsByu4zqHPkfzo4-OtE6R7Dq-5fg3o-kuoVU84VGLIDkNqzEFNt1x7RvOHO7wf8hznDz0KA6F6-g1uE2OPseFvKh-9MGp4we7LLmIhxE1JHeDcLyOUD8uDTozND_apQvyiQ9yQcoRxisuBxeVDd-LRrGO-0xRKwuPh4ivHP8GUQ_ig87RN0czSkf0xEMNcRzxw8elHKIikcKlB5bGY-J9PImJ64K4PCDD7rh8_Ogvo5kOmU0RnjmcC2na9LiGbpKhC48RJvo2hI-HD80sVONx58nQ49CfLtCj59CTBz8eDZ4O7fHwBw-hPhJ0HhqlU0h9XB9qKsh_fNmRiTz-qPido6l3lB0X_DiS90QOOxdxbwJ94XqjwmKyHBeLHzfyH1MuaM9xibgeCe2O5gv-QzUz5NtRay8s7Tj6E1cSHH2eIa0tJB9OBVXa4g9-1M6DOsPTo3wwJQo3PA_4TMh_JJEPb4OzHSUTbcIvPCHcI4-gE7mOp8RxRyX6ySvM2EbJbhEeHVeKZOkR_hJKKTvM_SiPu9FhOUes6kJySTneozfO0HiObSy-7AXDp8gbJMd-4TmebB9KiYKfHNWPG8c9JGvUI5Tz4Xle3FEkhD-01DFy4gnKoOTxw23wOfjRjNKPZNp2Io8OpxmuJsW-7DgvoXYenEEtCT2aKDfIMDv-TEJ0JMtxoZk04Uqi4Ic_HLGP5JLRC80_XFJC9JnwjpNRjTueRSuS_4h4NIfzBl00E5-mo3ckNB-FPIE-5BlO9UL5Dudx5vikFB5DF-FKI7myxPjj4Yzw7OjRxOHxB8f3o9wO34V__IR5UkSf7LiOPhLSL-DR4MKlo5zwr8LFAf8h_oBtVTj8GIcAAYgB4YwSDACBiFBMPPGctY6a6pAAxighhEjKAAaBNsgIIAAQRgFFHCBGIACQIBJAoAAADCKiCDLSCCABQYQQwIwEigkArKGII0OUQEwAogBRoCCHCCKQAEcEkQYQYoQSigDjCBMCIUCJIUQJQQRRiDAAHSEKEOCCEAAgDRQBxDEgkBEGEAQQFkIIIAQhAgnBAFTAQIEAQYgxYwBClAkEACMEAEAAMQgISAxCgiiCACESIWQAUWYQBoBDBhBGgBBAMSMkYogAIwjwBCMkACLCCASEIMARg4QCBCAijCYAKweEAkAJYRgSAkDjjBCMQgioMIYBQIgDQhkBGiKEQAeBFAQAZoACBgFKFAEAOQMAEIQYgICShChGCCDKESSYoEIQAIRiFkAAgQJMCWMYRUAIgAQAigFEgFDEEAEwEAYQaQw0BgEHADDWGSCAWEgIBQQxBghmjBQCCEqMA0YpQIwAwgDjHFBECiWUEQYxohABRAhAjAUCESUARgRQYgAARggKMAVOAEIEc4gyJ4ABABCCjGMIKAIAEQIAIAwChAEgAGEAGAeIUMZY4QghSBhADSCCGiKNlAQoogAChjCDEAEEWEIRA0QohoQAQCkECEGQEQIEMUwYgIgBABgECECCKEQAAkIgYZARBgmADDNKIEAEoAgAAAQDBAgCHJCAEGG0QkQAaZgAAkpAkBAGImAgIYI5AoQAAiBBiCDMASGIYQAoB4QgCAlBIHFIICaAIM4aJawAxFIBDAFGGEA5AUQAgZhCTgJBgEKAACEMI4gwRRwQDAEEIQFEIaKIQYQBgYgBCjiGgAHEUQGYAIwwQASSylCjgJKMI8AIhIAAgAQwBBChhCIKAyOAVIIZAYBBihIIiBAACKMUUhI6JYAAigBADFAiAGBAhoZApRRQwABAiCCAGGcAQUIaBQBhgjBDACEEWSEsYAQYBIAVAACCmDAhEUAEEEQJhQA\"}"; + + var result = Subject.ParseFpcalcJsonOutput(json); + + result.Duration.Should().Be(229.29); + result.Fingerprint.Should().Be("AQADtFMURlEURcgP6cwRD43Y4Ptw9FowncWPWkf6GB9-JYdP9OgJHw8u4Apw4SsOHMdxHAfwHIfYET_0HiZ55MfxNCm8Hj-mRM8KLlXwIzqS-Que_Cg7NEseE_2j4DUjJBOvI08UNNUufEd5NM-OHzmSUZsO58er7nhUeN6EK3HYwhe0H-fxXEFzlOSHJwpj3MevID7kHyqVqBH6zEI6GzmSs4HTDz4j9DjxKHDvpLiOH7l4iFUWRXiiqtCJmMqNPmiAZEyY5AgTLclxmSWuwJ-O33jz4KdwHt-PsDHRjBNyCQ_-KcGPhoePsJNyiJOiI-5VPKMj_McvfNQRUkyiImEzAv2Ckxb-RUJ-CVpGhMYnB5em4scvHFeuoU7DwHugT8fRecPVpMF5_Cb8jNvw4n_wJ6iV4xcSHuFxKcfTBhVF5uiPA6OSoWQP5x_6BX-5YDoaJT_OLA3e0Ifr4D2SH2EYB-WShLiE6plg5U2G6jlOSgy-G8-Rk0XD7yiP4DyPfcejiQ2aH9WVHKeOJ5eQP9CeKEfD44zw45kj_NAQJmIWfMTPCJF048IPPTtcB_GP_2ieFydyxYXGvUgffIO1D96SHW10vEeYI9mNR-qLhnpwfYHz49KR8ijnol58pDGLKWuF4wvKTUGjrE-Q79COdFyI8Aze4C70LMP24-YKJLtSeCHqajJ6e2g0qsdzBd94RNdxHY80oomYocdB5hNiUrGIVtrhJ0f4CY-Y48yOF5rFI93sgNF2hNRhTSk65odGaUhJClc5ArOGdi8-oz_SzaqQ3MKV63DkGX8NT07wVcdf5MmO5Av8C9dOONGIS_mRS7gO8fjhLNMFbQqq4zF0jRPir2j2Y9_xH5Z-_EFiHWE-oaePh8JN3CsaiYgkGvrR_OgVaI2iHv_gTLsQaTeuZ8K5HUdMCWUlHpLIIFyO8TiNO2nQOHzwLkfYQg2RHxXlUfDxHX-MO9Pxwc8J90JSEzmDJk-Ji0cpZanw44mOWpKEkkHDsUFkZsfNaJCOqDvK_fg-9NHx5UHtFGEOPUeu47hw4dzwH22Nc9pgK8dZhJ-h8wipwyS8Dw8cZ0evC8kZJcgrHT22jsfhH_QCv4jMHNp25NLx6Hh4tEtaePzgcNHxDOkYQ3tCxBsvPFKOfyzCB-L5IHyCpzhFHo9zvMeP5vrQbdzwK8N_hFG6F0mdo2GOJ4yxM0bJRFaGH3WyHUdzHWWe4uoH8hWS8EGOGuUSBZ-E6w7sZsKpIId-IzyDptLxhD76GpfxNDHSkdCTS0JMFS-8Rh6e48c6Hv2O8CG05MinRcUX9A0cKoSv4Ts8KbSQbCXyDDeDZ_mKUrwHP8tRi8d94UcX3ImMLzq8o3-DnFXwHG2UJ_Au6FlwNLgOXU_xakKv4zp8VMeN_vBz9OCbQzsFP7h8PB96Df8llNJSWI8KfVkqZMcPaw5ePEgf6HKQjj2YTRmOxslyTJ-K_kj3Qj9M6TjCM33QixGcX8PzHNfxDOfhMx2SHflz_Dr-K6hSKVLQ7MSjII-UQ9fQ53Bi6ahMoz5jNA304xDz7RjTI08eXKeChzmeDeUJfw7OEPVGnB_yoz_M6sZj8MOfHE5wXKLhT0V-MMhjlDv0C-mP_jgt4cxRMgoLSz5GkUZ-3MFTPHCpB-W2EheX43iWeUgfDhdJPAsR_oKWmDGeJRMRoyea3fhyVA6RjsuCCxdDiD-2fsizC5eSp_CPh4UnP5gU6sOpL7g3ocyCSDn0I7-L4znKBg3uKDmeW8Nzsriqg5eMHk33Cn9Q_0hPQtMX4oqOfMedHI-I2mPwd0PeQ8yHsByu4zqHPkfzo4-OtE6R7Dq-5fg3o-kuoVU84VGLIDkNqzEFNt1x7RvOHO7wf8hznDz0KA6F6-g1uE2OPseFvKh-9MGp4we7LLmIhxE1JHeDcLyOUD8uDTozND_apQvyiQ9yQcoRxisuBxeVDd-LRrGO-0xRKwuPh4ivHP8GUQ_ig87RN0czSkf0xEMNcRzxw8elHKIikcKlB5bGY-J9PImJ64K4PCDD7rh8_Ogvo5kOmU0RnjmcC2na9LiGbpKhC48RJvo2hI-HD80sVONx58nQ49CfLtCj59CTBz8eDZ4O7fHwBw-hPhJ0HhqlU0h9XB9qKsh_fNmRiTz-qPido6l3lB0X_DiS90QOOxdxbwJ94XqjwmKyHBeLHzfyH1MuaM9xibgeCe2O5gv-QzUz5NtRay8s7Tj6E1cSHH2eIa0tJB9OBVXa4g9-1M6DOsPTo3wwJQo3PA_4TMh_JJEPb4OzHSUTbcIvPCHcI4-gE7mOp8RxRyX6ySvM2EbJbhEeHVeKZOkR_hJKKTvM_SiPu9FhOUes6kJySTneozfO0HiObSy-7AXDp8gbJMd-4TmebB9KiYKfHNWPG8c9JGvUI5Tz4Xle3FEkhD-01DFy4gnKoOTxw23wOfjRjNKPZNp2Io8OpxmuJsW-7DgvoXYenEEtCT2aKDfIMDv-TEJ0JMtxoZk04Uqi4Ic_HLGP5JLRC80_XFJC9JnwjpNRjTueRSuS_4h4NIfzBl00E5-mo3ckNB-FPIE-5BlO9UL5Dudx5vikFB5DF-FKI7myxPjj4Yzw7OjRxOHxB8f3o9wO34V__IR5UkSf7LiOPhLSL-DR4MKlo5zwr8LFAf8h_oBtVTj8GIcAAYgB4YwSDACBiFBMPPGctY6a6pAAxighhEjKAAaBNsgIIAAQRgFFHCBGIACQIBJAoAAADCKiCDLSCCABQYQQwIwEigkArKGII0OUQEwAogBRoCCHCCKQAEcEkQYQYoQSigDjCBMCIUCJIUQJQQRRiDAAHSEKEOCCEAAgDRQBxDEgkBEGEAQQFkIIIAQhAgnBAFTAQIEAQYgxYwBClAkEACMEAEAAMQgISAxCgiiCACESIWQAUWYQBoBDBhBGgBBAMSMkYogAIwjwBCMkACLCCASEIMARg4QCBCAijCYAKweEAkAJYRgSAkDjjBCMQgioMIYBQIgDQhkBGiKEQAeBFAQAZoACBgFKFAEAOQMAEIQYgICShChGCCDKESSYoEIQAIRiFkAAgQJMCWMYRUAIgAQAigFEgFDEEAEwEAYQaQw0BgEHADDWGSCAWEgIBQQxBghmjBQCCEqMA0YpQIwAwgDjHFBECiWUEQYxohABRAhAjAUCESUARgRQYgAARggKMAVOAEIEc4gyJ4ABABCCjGMIKAIAEQIAIAwChAEgAGEAGAeIUMZY4QghSBhADSCCGiKNlAQoogAChjCDEAEEWEIRA0QohoQAQCkECEGQEQIEMUwYgIgBABgECECCKEQAAkIgYZARBgmADDNKIEAEoAgAAAQDBAgCHJCAEGG0QkQAaZgAAkpAkBAGImAgIYI5AoQAAiBBiCDMASGIYQAoB4QgCAlBIHFIICaAIM4aJawAxFIBDAFGGEA5AUQAgZhCTgJBgEKAACEMI4gwRRwQDAEEIQFEIaKIQYQBgYgBCjiGgAHEUQGYAIwwQASSylCjgJKMI8AIhIAAgAQwBBChhCIKAyOAVIIZAYBBihIIiBAACKMUUhI6JYAAigBADFAiAGBAhoZApRRQwABAiCCAGGcAQUIaBQBhgjBDACEEWSEsYAQYBIAVAACCmDAhEUAEEEQJhQA"); + } + + [Test] + public void should_parse_fpcalc_text() + { + var text = @"FILE=Adele - 01 - 21 - Rolling in the Deep.flac +DURATION=229 +FINGERPRINT=AQAHJlMURlEURcgP6cwRD43Y4Ptw9FowncWPWkf6GB9-JYdP9OgJHw8u4Apw4SsOHMdxHAfwHIfYET_0HiZ55MfxNCm8Hj-mRM8KLlXwIzqS-Que_Cg7NEseE_2j4DUjJBOvI08UNNUufEd5NM-OHzmSUZsO58er7nhUeN6EK3HYwhe0H-fxXEFzlOSHJwpj3MevID7kHyqVqBH6zEI6GzmSs4HTDz4j9DjxKHDvpLiOH7l4iFUWRXiiqtCJmMqNPmiAZEyY5AgTLclxmSWuwJ-O33jz4KdwHt-PsDHRjBNyCQ_-KcGPhoePsJNyiJOiI-5VPKMj_McvfNQRUkyiImEzAv2Ckxb-RUJ-CVpGhMYnB5em4scvHFeuoU7DwHugT8fRecPVpMF5_Cb8jNvw4n_wJ6iV4xcSHuFxKcfTBhVF5uiPA6OSoWQP5x_6BX-5YDoaJT_OLA3e0Ifr4D2SH2EYB-WShLiE6plg5U2G6jlOSgy-G8-Rk0XD7yiP4DyPfcejiQ2aH9WVHKeOJ5eQP9CeKEfD44zw45kj_NAQJmIWfMTPCJF048IPPTtcB_GP_2ieFydyxYXGvUgffIO1D96SHW10vEeYI9mNR-qLhnpwfYHz49KR8ijnol58pDGLKWuF4wvKTUGjrE-Q79COdFyI8Aze4C70LMP24-YKJLtSeCHqajJ6e2g0qsdzBd94RNdxHY80oomYocdB5hNiUrGIVtrhJ0f4CY-Y48yOF5rFI93sgNF2hNRhTSk65odGaUhJClc5ArOGdi8-oz_SzaqQ3MKV63DkGX8NT07wVcdf5MmO5Av8C9dOONGIS_mRS7gO8fjhLNMFbQqq4zF0jRPir2j2Y9_xH5Z-_EFiHWE-oaePh8JN3CsaiYgkGvrR_OgVaI2iHv_gTLsQaTeuZ8K5HUdMCWUlHpLIIFyO8TiNO2nQOHzwLkfYQg2RHxXlUfDxHX-MO9Pxwc8J90JSEzmDJk-Ji0cpZanw44mOWpKEkkHDsUFkZsfNaJCOqDvK_fg-9NHx5UHtFGEOPUeu47hw4dzwH22Nc9pgK8dZhJ-h8wipwyS8Dw8cZ0evC8kZJcgrHT22jsfhH_QCv4jMHNp25NLx6Hh4tEtaePzgcNHxDOkYQ3tCxBsvPFKOfyzCB-L5IHyCpzhFHo9zvMeP5vrQbdzwK8N_hFG6F0mdo2GOJ4yxM0bJRFaGH3WyHUdzHWWe4uoH8hWS8EGOGuUSBZ-E6w7sZsKpIId-IzyDptLxhD76GpfxNDHSkdCTS0JMFS-8Rh6e48c6Hv2O8CG05MinRcUX9A0cKoSv4Ts8KbSQbCXyDDeDZ_mKUrwHP8tRi8d94UcX3ImMLzq8o3-DnFXwHG2UJ_Au6FlwNLgOXU_xakKv4zp8VMeN_vBz9OCbQzsFP7h8PB96Df8llNJSWI8KfVkqZMcPaw5ePEgf6HKQjj2YTRmOxslyTJ-K_kj3Qj9M6TjCM33QixGcX8PzHNfxDOfhMx2SHflz_Dr-K6hSKVLQ7MSjII-UQ9fQ53Bi6ahMoz5jNA304xDz7RjTI08eXKeChzmeDeUJfw7OEPVGnB_yoz_M6sZj8MOfHE5wXKLhT0V-MMhjlDv0C-mP_jgt4cxRMgoLSz5GkUZ-3MFTPHCpB-W2EheX43iWeUgfDhdJPAsR_oKWmDGeJRMRoyea3fhyVA6RjsuCCxdDiD-2fsizC5eSp_CPh4UnP5gU6sOpL7g3ocyCSDn0I7-L4znKBg3uKDmeW8Nzsriqg5eMHk33Cn9Q_0hPQtMX4oqOfMedHI-IehuDm92Q9xDzISyH67jOoc_R_OijI61TJLuObzn-zWi6S2gVT3jUIkhOw2pMgU13XPuGM4c7_B_yHCcPPYpD4Tp6DW6To89xIS-qH31w6vjBLksu4mFEDcndIByvI9SPS4PODM2PdumCfOKDXJByhPGKy8FFZcP3olGs4z5T1MrC4yHiK8e_QdSD-KBz9M3RjNIRPfFQQxxH_PBxKYeoSKRw6YGl8Zh4H09i4rogLg_IsDsuHz_6y2imQ2ZThGcO50KaNj2uoZtk6MJjhIm-DeHj4UMzC9V43Hky9Dj0pwv06Dn05MGPR4OnQ3s8_MFDqI8EnYdG6RRSH9eHmgryH192ZCKPPyp-52jqHWXHBT-O5D2Rw85F3JtAX7jeqLCYLMfF4seN_MeUC9pzXCKuR0K7o_mC_1DNDPl21NoLSzuO_sSVBEefZ0hrC8mHU0GVtviDH7XzoM7w9CgfTInCDc8DPhPyH0nkw9vgbEfJRJvwC08I98gj6ESu4ylx3FGJfvIKM7ZRsluER8eVIll6hL-EUsoOcz_K4250WM4Rq7qQXFKO9-iNMzSeYxuLL3vB8CnyBsmxX3iOJ9uHUqLgJ0f148ZxD8ka9QjlfHieF3cUCeEPLXWMnHiCMih5_HAbfA5-NKP0I5m2ncijw2mGq0mxLzvOS6idB2dQS0KPJsoNMsyOP5MQHclyXGgmTbiSKPjhD0fsI7lk9ELzD5eUEH0mvGONatyFZ9GK5D8iHs3hvEEXzcSn6egdCc1HIU-gD3mGU71QvsN5nDk-KYXH0EW40kiuLDH-eDgjPDt6NHF4PHjw_Si3w3fhHz9hnhTRJzuuI3wk-At4NLhw6Sgn_KtwccB_iD9gWxUOP8bhHb8HT0evRcJ_ouuC5oM0t0K_I_7gTUpwXI6RZ4fIE-kkynhzvMwFnzmI1MXeQ1xW9MqDHqkj3Fl0fDyeHa2iB9-CF2EuaD9yBWdi4cHjKEoCZzGPfOGhK8tx4kyHSh3SROODkIpRW4eW54J-NEckOZJGPH8S_MXX5HgiHj18MUMWj8UJ_SL-HL0HOxF0_MZ3xDDzoL_QKzr8gOGH8IH-Ii9-eE8s1Bc-7cS1EY3KHPON2D3axMa344eWx4JmH1dKnDxRUtvQ3DJO4spyaH8ORx9yLVlRNcbRH-4T3NERTXrwD4pJKoGvDlt9IeQS9Drcx7jEdHCqHdO2436Ea0cZIlkmBrmOx8WHK0eZC36Kylks47mNI8zEndAcFzleM2hWxkR_PMH14UOYH4V6ovvh4j7eGflx68H24P_gUsHx7fCNx7g2IX5QLpwW5FqWGDyH_vAVrCXgZ0F-iIqOSswe_ESPZqxRH9su6ISvoz2OR0Xt4Olx96CO43gl-CHqB7rxpzjR7EPlctDNQl6SoxJpIT3-wD_q4cmRB1qOPkbeKI3wa9ijBU9E-HSRL9uh4_7wKA1RK1GI5tFxGe0PMQmNf0iPPyIeHf_xJg9O5IeYv3geFuHJID96SRs-HGePG47xRsTFBfpxScthJ1Eq1D9Cwj-eLcZVH-5wwfvRN0b8BzvCPAE5HemWLOgqGfehHc2XNMj7oOxxpOijBDrBMES6ZclxesQzvBX8oD9e9IJ_tJOMagpzNNfBC4-OqTua96i3w6_RK-HR8DmSWULcxMOV43fwP2iuTMi14UwERUrI6MgX5J-S4BbmQ-yPPDn85DhxamIC_7hSPMf9IJmSuAhzYg9OUkrw6UHzoCdyfUjAHf05NGoWB2-InCeS2XFwH4we5ThODp2i7-Dn4BaHMJKSC7qObPKD5ktm1BS-oEyuBc1xXoiThTmSJ5Qx5VGHXmFwcnjjCJ6SIHmi3Mgf4EqHR7uC7xFOZQiZdUOy7MaNn8eHv4EZ6cFH4T_uI_mHvA9OxUEj1phEDTlzHP-D_UmIo3FIhCxRPeiX4wmuJEeeT2hCCslOPNjvBI80EqwUFE3xI9bICXpUBM0zfej0tPglnDeYXIuRH2KP_Ivw5LiUB1nictCdEvfRTTma73hyfNEjrEw-iLMS4-GFPWlCZPmOZuyPOcR_9JUGuyb64_iFL_hkxH6g7YiXF009PLlw7DkO_kHPFE2VrcSToVeQPkR16M3x-DjCE8ePH_mFhD8eocdxXiu6w2nC6ehxIkHe4cwMVlqOvfBTnJi6LNPR7B_4HOm-QE-OPW4i5DiPXM6E39AEH6Vz9MeRb8ePA7kh3ugRUnoERxH6CrVjfHvgx7BPo1aON8UlPEiupIgfBeuNShmoTTz-FCGpHMnNDp9wncF1XDqSPUeYxMf4ZMecHKd-8DrEHz--m3h6_Cr6ozluyRISh9ERH4d7nMcXBhPJB83xDZP-obGET1oRH8nzB2dYNCeO6Ua-7NDx7InwRyuaH6F148qSJ_jT4SyJ8BLRjBnk5EmG3KLQHdtJosyiJsFvoheyZKeQPMqOL0yk4jz847mEX3g61NfxBrXwWChPNGd0fAuq7ALV9EJ6aDx-hPlRH9eO0PrRHNehJzmKXxkhH-oKHz_S47iEzyJyHDr8RyjxFtV4iCoz4kj_glbh4xPyBR8P_UGYUcfDo9_hLwdv-Ee0EZdwCbqOnHjg_IeT4_ng4zDJD8ls5Ds-JniEx1sC58jxaNAp9LqhH2r8oIKe6LiOntByCbEjVnguR9gvXAmPc0HZo7k5_A7yGr9GaNnR49GFZwo0xDkcHj2uHL3RHEfeQbuI-3CP-yGq6cZPdB6xxT3-Ii-OK4Mr1I6D58MnPGJihDyaP9AV4kXIT2i4ZCeeHtWPX3jm4jh7pNJ6_PgiiM-DXMnxwEctxkap7YeDjsmJij6uBOYiJPuR48aPhyeOKb9RUQyeF8n7IecPJsyI8iVQ80RzjshTaHmD8J3w6EDVCk9zXOlxHM1lBX-RI_nRLwf_DP7hB5WOPwgbEeJb4algxgcvvIE14zhO4j0cKYRmLS_6HfxxHf0nzCz0H82DWsiDLxEndO3hP-jxo9-FMId-5A8-EndUBVX06Wj64yqOvN3wFb9DqNKGPjouAulROccfaUPEP8KTQ9cWPD_CHPXmDG9wXUb4Q8uFXA6eG4zawzochcd__INP9C2yHOIR_sLR5wqavHjU8Dji74Tma0jJ4MdF8B2-GH5mGXvhG-GhqPzQjDpygrFz2GGDn4iT69izEY7Q_7ieLZAVEU1Elsi_HB4DL8dk9fiWoelQnkF-KErErMSTNHikqCq0PrgmBeEp-HRU9CHCjIfmIdJTNFUmTQF7VE2EJqm0Is9SFeVyatj4IyQZXDl6NFWWDpeI3DtOY_SLJ8sM9k2QZ2huVMcTA4cAAYgB4YwSDACBiFBMPPGctY6a6pAAxighhEjKAAaBNsgIIAAQRgFFHCBGIACQIBJAoAAADCKiCDLSCCABQYQQwIwEigkArKGII0OUQEwAogBRoCCHCCKQAEcEkQYQYoQSigDjCBMCIUCJIUQJQQRRiDAAHSEKEOCCEAAgDRQBxDEgkBEGEAQQFkIIIAQhAgnBAFTAQIEAQYgxYwBClAkEACMEAEAAMQgISAxCgiiCACESIWQAUWYQBoBDBhBGgBBAMSMkYogAIwjwBCMkACLCCASEIMARg4QCBCAijCYAKweEAkAJYRgSAkDjjBCMQgioMIYBQIgDQhkBGiKEQAeBFAQAZoACBgFKFAEAOQMAEIQYgICShChGCCDKESSYoEIQAIRiFkAAgQJMCWMYRUAIgAQAigFEgFDEEAEwEAYQaQw0BgEHADDWGSCAWEgIBQQxBghmjBQCCEqMA0YpQIwAwgDjHFBECiWUEQYxohABRAhAjAUCESUARgRQYgAARggKMAVOAEIEc4gyJ4ABABCCjGMIKAIAEQIAIAwChAEgAGEAGAeIUMZY4QghSBhADSCCGiKNlAQoogAChjCDEAEEWEIRA0QohoQAQCkECEGQEQIEMUwYgIgBABgECECCKEQAAkIgYZARSABkmFECASIARQAAIBggQBDggASECKMVIgJIwwQQUAKChDAQAQMJEcwRIAQQAAlCBGEOCEEMA0A5IARBSAgCiUMCMQEEcdYoYQUglgpgCDDCAMoJIAIIxBRyEggCFAIECGEYQYQp4oBgCCAICSAKEUUMIgwIRAxQwDEEDCCOCsAEYIQBIpBUhhoFlGQcAUYgBAQAJIAhgAglFFEYGAGkEswIAAxSlEBAhABAGKWQktApAQRQBABigBIBAAMyNAQqpYACBgACBAHEOAMIEtIoAAgThBkCCCHIChMYAQYBYAUAgCAmTEgEEAEEUUIhNRAABgChCACCKAAEAAAQYwghCAAAAEJgMMEYCEhAAiADCAAFRAACOUKII4gBowwwECABgAJAIKgMoogJAwABQiFBODLECMSQAQYoYCRAAABgjBBGEUiAYQAR6ghiAhBKCFMGEeAFEIIAYBBDABEhFDKKIgOYoAQo4QgToElEAPIMIIAQtVZQgxQoBJIKAENGESSIFAQwgBAgADAAAMBEOGKIQMpJAAgQRCljKCGICKQAIQAYYwQXQCCgDBJECWIIGAgwxgRQRAnDhMMOGICMEAgYIYEiAhADCBGEMiMIAsQQoBAiBAgPiHBEGiMMIIoSoJAABCFJDAFEaKAIAIYyQJQBgigCBAMAgGIAQQYRIZBASAhDABEIIgLEEQA4JgCBSgAAnAEKIIYJoIAYAjAkQCAiCAEMIACYAYAIFDxxSgElGGAcEaGMkIQBBDlSEDFBFRKLAOEEJMIQBYgQgEAGAGEEEKIIcYAZIRChSDOLiAXMMGohUk4IYJFQCAkECCGWGCCIMMwAABATlCgghCjMGWCSEMIAAAigwjhjCAMICQCIEAIZA4gwTgACkGKMMEIVUEwABBzAmBnBlBKCSGIMAcAqpagFBUWCgBRIGCuYUYQhwAggggEIjjKKAyAAMwoE5owIziIiBCSGCAIMAEAoggxFCCAmmFTEWMKUYQgNQBhBgCAEjFNIKeOMQ4AwRYBBwAjGgDDAMQSMUUwAIqAR1jpIEEEEKGMEAkYIhBADQgBEqUFAFEqQIYI4JhSwwihRjUCAGAeQAYgAYrRhgBGkjEBACCEBAEYopIAwQCCGBDAAGUAAoooJJKByRkBDiJDCMa2UAQMYYRgQAGBFADBIGKEAYg4AgAgQSgAoHCFMCgCUAEY5BQBCxjMIEBBEEgAA +"; + + var result = Subject.ParseFpcalcTextOutput(text); + result.Duration.Should().Be(229); + result.Fingerprint.Should().Be("AQAHJlMURlEURcgP6cwRD43Y4Ptw9FowncWPWkf6GB9-JYdP9OgJHw8u4Apw4SsOHMdxHAfwHIfYET_0HiZ55MfxNCm8Hj-mRM8KLlXwIzqS-Que_Cg7NEseE_2j4DUjJBOvI08UNNUufEd5NM-OHzmSUZsO58er7nhUeN6EK3HYwhe0H-fxXEFzlOSHJwpj3MevID7kHyqVqBH6zEI6GzmSs4HTDz4j9DjxKHDvpLiOH7l4iFUWRXiiqtCJmMqNPmiAZEyY5AgTLclxmSWuwJ-O33jz4KdwHt-PsDHRjBNyCQ_-KcGPhoePsJNyiJOiI-5VPKMj_McvfNQRUkyiImEzAv2Ckxb-RUJ-CVpGhMYnB5em4scvHFeuoU7DwHugT8fRecPVpMF5_Cb8jNvw4n_wJ6iV4xcSHuFxKcfTBhVF5uiPA6OSoWQP5x_6BX-5YDoaJT_OLA3e0Ifr4D2SH2EYB-WShLiE6plg5U2G6jlOSgy-G8-Rk0XD7yiP4DyPfcejiQ2aH9WVHKeOJ5eQP9CeKEfD44zw45kj_NAQJmIWfMTPCJF048IPPTtcB_GP_2ieFydyxYXGvUgffIO1D96SHW10vEeYI9mNR-qLhnpwfYHz49KR8ijnol58pDGLKWuF4wvKTUGjrE-Q79COdFyI8Aze4C70LMP24-YKJLtSeCHqajJ6e2g0qsdzBd94RNdxHY80oomYocdB5hNiUrGIVtrhJ0f4CY-Y48yOF5rFI93sgNF2hNRhTSk65odGaUhJClc5ArOGdi8-oz_SzaqQ3MKV63DkGX8NT07wVcdf5MmO5Av8C9dOONGIS_mRS7gO8fjhLNMFbQqq4zF0jRPir2j2Y9_xH5Z-_EFiHWE-oaePh8JN3CsaiYgkGvrR_OgVaI2iHv_gTLsQaTeuZ8K5HUdMCWUlHpLIIFyO8TiNO2nQOHzwLkfYQg2RHxXlUfDxHX-MO9Pxwc8J90JSEzmDJk-Ji0cpZanw44mOWpKEkkHDsUFkZsfNaJCOqDvK_fg-9NHx5UHtFGEOPUeu47hw4dzwH22Nc9pgK8dZhJ-h8wipwyS8Dw8cZ0evC8kZJcgrHT22jsfhH_QCv4jMHNp25NLx6Hh4tEtaePzgcNHxDOkYQ3tCxBsvPFKOfyzCB-L5IHyCpzhFHo9zvMeP5vrQbdzwK8N_hFG6F0mdo2GOJ4yxM0bJRFaGH3WyHUdzHWWe4uoH8hWS8EGOGuUSBZ-E6w7sZsKpIId-IzyDptLxhD76GpfxNDHSkdCTS0JMFS-8Rh6e48c6Hv2O8CG05MinRcUX9A0cKoSv4Ts8KbSQbCXyDDeDZ_mKUrwHP8tRi8d94UcX3ImMLzq8o3-DnFXwHG2UJ_Au6FlwNLgOXU_xakKv4zp8VMeN_vBz9OCbQzsFP7h8PB96Df8llNJSWI8KfVkqZMcPaw5ePEgf6HKQjj2YTRmOxslyTJ-K_kj3Qj9M6TjCM33QixGcX8PzHNfxDOfhMx2SHflz_Dr-K6hSKVLQ7MSjII-UQ9fQ53Bi6ahMoz5jNA304xDz7RjTI08eXKeChzmeDeUJfw7OEPVGnB_yoz_M6sZj8MOfHE5wXKLhT0V-MMhjlDv0C-mP_jgt4cxRMgoLSz5GkUZ-3MFTPHCpB-W2EheX43iWeUgfDhdJPAsR_oKWmDGeJRMRoyea3fhyVA6RjsuCCxdDiD-2fsizC5eSp_CPh4UnP5gU6sOpL7g3ocyCSDn0I7-L4znKBg3uKDmeW8Nzsriqg5eMHk33Cn9Q_0hPQtMX4oqOfMedHI-IehuDm92Q9xDzISyH67jOoc_R_OijI61TJLuObzn-zWi6S2gVT3jUIkhOw2pMgU13XPuGM4c7_B_yHCcPPYpD4Tp6DW6To89xIS-qH31w6vjBLksu4mFEDcndIByvI9SPS4PODM2PdumCfOKDXJByhPGKy8FFZcP3olGs4z5T1MrC4yHiK8e_QdSD-KBz9M3RjNIRPfFQQxxH_PBxKYeoSKRw6YGl8Zh4H09i4rogLg_IsDsuHz_6y2imQ2ZThGcO50KaNj2uoZtk6MJjhIm-DeHj4UMzC9V43Hky9Dj0pwv06Dn05MGPR4OnQ3s8_MFDqI8EnYdG6RRSH9eHmgryH192ZCKPPyp-52jqHWXHBT-O5D2Rw85F3JtAX7jeqLCYLMfF4seN_MeUC9pzXCKuR0K7o_mC_1DNDPl21NoLSzuO_sSVBEefZ0hrC8mHU0GVtviDH7XzoM7w9CgfTInCDc8DPhPyH0nkw9vgbEfJRJvwC08I98gj6ESu4ylx3FGJfvIKM7ZRsluER8eVIll6hL-EUsoOcz_K4250WM4Rq7qQXFKO9-iNMzSeYxuLL3vB8CnyBsmxX3iOJ9uHUqLgJ0f148ZxD8ka9QjlfHieF3cUCeEPLXWMnHiCMih5_HAbfA5-NKP0I5m2ncijw2mGq0mxLzvOS6idB2dQS0KPJsoNMsyOP5MQHclyXGgmTbiSKPjhD0fsI7lk9ELzD5eUEH0mvGONatyFZ9GK5D8iHs3hvEEXzcSn6egdCc1HIU-gD3mGU71QvsN5nDk-KYXH0EW40kiuLDH-eDgjPDt6NHF4PHjw_Si3w3fhHz9hnhTRJzuuI3wk-At4NLhw6Sgn_KtwccB_iD9gWxUOP8bhHb8HT0evRcJ_ouuC5oM0t0K_I_7gTUpwXI6RZ4fIE-kkynhzvMwFnzmI1MXeQ1xW9MqDHqkj3Fl0fDyeHa2iB9-CF2EuaD9yBWdi4cHjKEoCZzGPfOGhK8tx4kyHSh3SROODkIpRW4eW54J-NEckOZJGPH8S_MXX5HgiHj18MUMWj8UJ_SL-HL0HOxF0_MZ3xDDzoL_QKzr8gOGH8IH-Ii9-eE8s1Bc-7cS1EY3KHPON2D3axMa344eWx4JmH1dKnDxRUtvQ3DJO4spyaH8ORx9yLVlRNcbRH-4T3NERTXrwD4pJKoGvDlt9IeQS9Drcx7jEdHCqHdO2436Ea0cZIlkmBrmOx8WHK0eZC36Kylks47mNI8zEndAcFzleM2hWxkR_PMH14UOYH4V6ovvh4j7eGflx68H24P_gUsHx7fCNx7g2IX5QLpwW5FqWGDyH_vAVrCXgZ0F-iIqOSswe_ESPZqxRH9su6ISvoz2OR0Xt4Olx96CO43gl-CHqB7rxpzjR7EPlctDNQl6SoxJpIT3-wD_q4cmRB1qOPkbeKI3wa9ijBU9E-HSRL9uh4_7wKA1RK1GI5tFxGe0PMQmNf0iPPyIeHf_xJg9O5IeYv3geFuHJID96SRs-HGePG47xRsTFBfpxScthJ1Eq1D9Cwj-eLcZVH-5wwfvRN0b8BzvCPAE5HemWLOgqGfehHc2XNMj7oOxxpOijBDrBMES6ZclxesQzvBX8oD9e9IJ_tJOMagpzNNfBC4-OqTua96i3w6_RK-HR8DmSWULcxMOV43fwP2iuTMi14UwERUrI6MgX5J-S4BbmQ-yPPDn85DhxamIC_7hSPMf9IJmSuAhzYg9OUkrw6UHzoCdyfUjAHf05NGoWB2-InCeS2XFwH4we5ThODp2i7-Dn4BaHMJKSC7qObPKD5ktm1BS-oEyuBc1xXoiThTmSJ5Qx5VGHXmFwcnjjCJ6SIHmi3Mgf4EqHR7uC7xFOZQiZdUOy7MaNn8eHv4EZ6cFH4T_uI_mHvA9OxUEj1phEDTlzHP-D_UmIo3FIhCxRPeiX4wmuJEeeT2hCCslOPNjvBI80EqwUFE3xI9bICXpUBM0zfej0tPglnDeYXIuRH2KP_Ivw5LiUB1nictCdEvfRTTma73hyfNEjrEw-iLMS4-GFPWlCZPmOZuyPOcR_9JUGuyb64_iFL_hkxH6g7YiXF009PLlw7DkO_kHPFE2VrcSToVeQPkR16M3x-DjCE8ePH_mFhD8eocdxXiu6w2nC6ehxIkHe4cwMVlqOvfBTnJi6LNPR7B_4HOm-QE-OPW4i5DiPXM6E39AEH6Vz9MeRb8ePA7kh3ugRUnoERxH6CrVjfHvgx7BPo1aON8UlPEiupIgfBeuNShmoTTz-FCGpHMnNDp9wncF1XDqSPUeYxMf4ZMecHKd-8DrEHz--m3h6_Cr6ozluyRISh9ERH4d7nMcXBhPJB83xDZP-obGET1oRH8nzB2dYNCeO6Ua-7NDx7InwRyuaH6F148qSJ_jT4SyJ8BLRjBnk5EmG3KLQHdtJosyiJsFvoheyZKeQPMqOL0yk4jz847mEX3g61NfxBrXwWChPNGd0fAuq7ALV9EJ6aDx-hPlRH9eO0PrRHNehJzmKXxkhH-oKHz_S47iEzyJyHDr8RyjxFtV4iCoz4kj_glbh4xPyBR8P_UGYUcfDo9_hLwdv-Ee0EZdwCbqOnHjg_IeT4_ng4zDJD8ls5Ds-JniEx1sC58jxaNAp9LqhH2r8oIKe6LiOntByCbEjVnguR9gvXAmPc0HZo7k5_A7yGr9GaNnR49GFZwo0xDkcHj2uHL3RHEfeQbuI-3CP-yGq6cZPdB6xxT3-Ii-OK4Mr1I6D58MnPGJihDyaP9AV4kXIT2i4ZCeeHtWPX3jm4jh7pNJ6_PgiiM-DXMnxwEctxkap7YeDjsmJij6uBOYiJPuR48aPhyeOKb9RUQyeF8n7IecPJsyI8iVQ80RzjshTaHmD8J3w6EDVCk9zXOlxHM1lBX-RI_nRLwf_DP7hB5WOPwgbEeJb4algxgcvvIE14zhO4j0cKYRmLS_6HfxxHf0nzCz0H82DWsiDLxEndO3hP-jxo9-FMId-5A8-EndUBVX06Wj64yqOvN3wFb9DqNKGPjouAulROccfaUPEP8KTQ9cWPD_CHPXmDG9wXUb4Q8uFXA6eG4zawzochcd__INP9C2yHOIR_sLR5wqavHjU8Dji74Tma0jJ4MdF8B2-GH5mGXvhG-GhqPzQjDpygrFz2GGDn4iT69izEY7Q_7ieLZAVEU1Elsi_HB4DL8dk9fiWoelQnkF-KErErMSTNHikqCq0PrgmBeEp-HRU9CHCjIfmIdJTNFUmTQF7VE2EJqm0Is9SFeVyatj4IyQZXDl6NFWWDpeI3DtOY_SLJ8sM9k2QZ2huVMcTA4cAAYgB4YwSDACBiFBMPPGctY6a6pAAxighhEjKAAaBNsgIIAAQRgFFHCBGIACQIBJAoAAADCKiCDLSCCABQYQQwIwEigkArKGII0OUQEwAogBRoCCHCCKQAEcEkQYQYoQSigDjCBMCIUCJIUQJQQRRiDAAHSEKEOCCEAAgDRQBxDEgkBEGEAQQFkIIIAQhAgnBAFTAQIEAQYgxYwBClAkEACMEAEAAMQgISAxCgiiCACESIWQAUWYQBoBDBhBGgBBAMSMkYogAIwjwBCMkACLCCASEIMARg4QCBCAijCYAKweEAkAJYRgSAkDjjBCMQgioMIYBQIgDQhkBGiKEQAeBFAQAZoACBgFKFAEAOQMAEIQYgICShChGCCDKESSYoEIQAIRiFkAAgQJMCWMYRUAIgAQAigFEgFDEEAEwEAYQaQw0BgEHADDWGSCAWEgIBQQxBghmjBQCCEqMA0YpQIwAwgDjHFBECiWUEQYxohABRAhAjAUCESUARgRQYgAARggKMAVOAEIEc4gyJ4ABABCCjGMIKAIAEQIAIAwChAEgAGEAGAeIUMZY4QghSBhADSCCGiKNlAQoogAChjCDEAEEWEIRA0QohoQAQCkECEGQEQIEMUwYgIgBABgECECCKEQAAkIgYZARSABkmFECASIARQAAIBggQBDggASECKMVIgJIwwQQUAKChDAQAQMJEcwRIAQQAAlCBGEOCEEMA0A5IARBSAgCiUMCMQEEcdYoYQUglgpgCDDCAMoJIAIIxBRyEggCFAIECGEYQYQp4oBgCCAICSAKEUUMIgwIRAxQwDEEDCCOCsAEYIQBIpBUhhoFlGQcAUYgBAQAJIAhgAglFFEYGAGkEswIAAxSlEBAhABAGKWQktApAQRQBABigBIBAAMyNAQqpYACBgACBAHEOAMIEtIoAAgThBkCCCHIChMYAQYBYAUAgCAmTEgEEAEEUUIhNRAABgChCACCKAAEAAAQYwghCAAAAEJgMMEYCEhAAiADCAAFRAACOUKII4gBowwwECABgAJAIKgMoogJAwABQiFBODLECMSQAQYoYCRAAABgjBBGEUiAYQAR6ghiAhBKCFMGEeAFEIIAYBBDABEhFDKKIgOYoAQo4QgToElEAPIMIIAQtVZQgxQoBJIKAENGESSIFAQwgBAgADAAAMBEOGKIQMpJAAgQRCljKCGICKQAIQAYYwQXQCCgDBJECWIIGAgwxgRQRAnDhMMOGICMEAgYIYEiAhADCBGEMiMIAsQQoBAiBAgPiHBEGiMMIIoSoJAABCFJDAFEaKAIAIYyQJQBgigCBAMAgGIAQQYRIZBASAhDABEIIgLEEQA4JgCBSgAAnAEKIIYJoIAYAjAkQCAiCAEMIACYAYAIFDxxSgElGGAcEaGMkIQBBDlSEDFBFRKLAOEEJMIQBYgQgEAGAGEEEKIIcYAZIRChSDOLiAXMMGohUk4IYJFQCAkECCGWGCCIMMwAABATlCgghCjMGWCSEMIAAAigwjhjCAMICQCIEAIZA4gwTgACkGKMMEIVUEwABBzAmBnBlBKCSGIMAcAqpagFBUWCgBRIGCuYUYQhwAggggEIjjKKAyAAMwoE5owIziIiBCSGCAIMAEAoggxFCCAmmFTEWMKUYQgNQBhBgCAEjFNIKeOMQ4AwRYBBwAjGgDDAMQSMUUwAIqAR1jpIEEEEKGMEAkYIhBADQgBEqUFAFEqQIYI4JhSwwihRjUCAGAeQAYgAYrRhgBGkjEBACCEBAEYopIAwQCCGBDAAGUAAoooJJKByRkBDiJDCMa2UAQMYYRgQAGBFADBIGKEAYg4AgAgQSgAoHCFMCgCUAEY5BQBCxjMIEBBEEgAA"); + } + + [Test] + public void should_parse_fpcalc_text_with_noninteger_duration() + { + var text = @"FILE=Adele - 01 - 21 - Rolling in the Deep.flac +DURATION=229.29 +FINGERPRINT=AQAHJlMURlEURcgP6cwRD43Y4Ptw9FowncWPWkf6GB9-JYdP9OgJHw8u4Apw4SsOHMdxHAfwHIfYET_0HiZ55MfxNCm8Hj-mRM8KLlXwIzqS-Que_Cg7NEseE_2j4DUjJBOvI08UNNUufEd5NM-OHzmSUZsO58er7nhUeN6EK3HYwhe0H-fxXEFzlOSHJwpj3MevID7kHyqVqBH6zEI6GzmSs4HTDz4j9DjxKHDvpLiOH7l4iFUWRXiiqtCJmMqNPmiAZEyY5AgTLclxmSWuwJ-O33jz4KdwHt-PsDHRjBNyCQ_-KcGPhoePsJNyiJOiI-5VPKMj_McvfNQRUkyiImEzAv2Ckxb-RUJ-CVpGhMYnB5em4scvHFeuoU7DwHugT8fRecPVpMF5_Cb8jNvw4n_wJ6iV4xcSHuFxKcfTBhVF5uiPA6OSoWQP5x_6BX-5YDoaJT_OLA3e0Ifr4D2SH2EYB-WShLiE6plg5U2G6jlOSgy-G8-Rk0XD7yiP4DyPfcejiQ2aH9WVHKeOJ5eQP9CeKEfD44zw45kj_NAQJmIWfMTPCJF048IPPTtcB_GP_2ieFydyxYXGvUgffIO1D96SHW10vEeYI9mNR-qLhnpwfYHz49KR8ijnol58pDGLKWuF4wvKTUGjrE-Q79COdFyI8Aze4C70LMP24-YKJLtSeCHqajJ6e2g0qsdzBd94RNdxHY80oomYocdB5hNiUrGIVtrhJ0f4CY-Y48yOF5rFI93sgNF2hNRhTSk65odGaUhJClc5ArOGdi8-oz_SzaqQ3MKV63DkGX8NT07wVcdf5MmO5Av8C9dOONGIS_mRS7gO8fjhLNMFbQqq4zF0jRPir2j2Y9_xH5Z-_EFiHWE-oaePh8JN3CsaiYgkGvrR_OgVaI2iHv_gTLsQaTeuZ8K5HUdMCWUlHpLIIFyO8TiNO2nQOHzwLkfYQg2RHxXlUfDxHX-MO9Pxwc8J90JSEzmDJk-Ji0cpZanw44mOWpKEkkHDsUFkZsfNaJCOqDvK_fg-9NHx5UHtFGEOPUeu47hw4dzwH22Nc9pgK8dZhJ-h8wipwyS8Dw8cZ0evC8kZJcgrHT22jsfhH_QCv4jMHNp25NLx6Hh4tEtaePzgcNHxDOkYQ3tCxBsvPFKOfyzCB-L5IHyCpzhFHo9zvMeP5vrQbdzwK8N_hFG6F0mdo2GOJ4yxM0bJRFaGH3WyHUdzHWWe4uoH8hWS8EGOGuUSBZ-E6w7sZsKpIId-IzyDptLxhD76GpfxNDHSkdCTS0JMFS-8Rh6e48c6Hv2O8CG05MinRcUX9A0cKoSv4Ts8KbSQbCXyDDeDZ_mKUrwHP8tRi8d94UcX3ImMLzq8o3-DnFXwHG2UJ_Au6FlwNLgOXU_xakKv4zp8VMeN_vBz9OCbQzsFP7h8PB96Df8llNJSWI8KfVkqZMcPaw5ePEgf6HKQjj2YTRmOxslyTJ-K_kj3Qj9M6TjCM33QixGcX8PzHNfxDOfhMx2SHflz_Dr-K6hSKVLQ7MSjII-UQ9fQ53Bi6ahMoz5jNA304xDz7RjTI08eXKeChzmeDeUJfw7OEPVGnB_yoz_M6sZj8MOfHE5wXKLhT0V-MMhjlDv0C-mP_jgt4cxRMgoLSz5GkUZ-3MFTPHCpB-W2EheX43iWeUgfDhdJPAsR_oKWmDGeJRMRoyea3fhyVA6RjsuCCxdDiD-2fsizC5eSp_CPh4UnP5gU6sOpL7g3ocyCSDn0I7-L4znKBg3uKDmeW8Nzsriqg5eMHk33Cn9Q_0hPQtMX4oqOfMedHI-IehuDm92Q9xDzISyH67jOoc_R_OijI61TJLuObzn-zWi6S2gVT3jUIkhOw2pMgU13XPuGM4c7_B_yHCcPPYpD4Tp6DW6To89xIS-qH31w6vjBLksu4mFEDcndIByvI9SPS4PODM2PdumCfOKDXJByhPGKy8FFZcP3olGs4z5T1MrC4yHiK8e_QdSD-KBz9M3RjNIRPfFQQxxH_PBxKYeoSKRw6YGl8Zh4H09i4rogLg_IsDsuHz_6y2imQ2ZThGcO50KaNj2uoZtk6MJjhIm-DeHj4UMzC9V43Hky9Dj0pwv06Dn05MGPR4OnQ3s8_MFDqI8EnYdG6RRSH9eHmgryH192ZCKPPyp-52jqHWXHBT-O5D2Rw85F3JtAX7jeqLCYLMfF4seN_MeUC9pzXCKuR0K7o_mC_1DNDPl21NoLSzuO_sSVBEefZ0hrC8mHU0GVtviDH7XzoM7w9CgfTInCDc8DPhPyH0nkw9vgbEfJRJvwC08I98gj6ESu4ylx3FGJfvIKM7ZRsluER8eVIll6hL-EUsoOcz_K4250WM4Rq7qQXFKO9-iNMzSeYxuLL3vB8CnyBsmxX3iOJ9uHUqLgJ0f148ZxD8ka9QjlfHieF3cUCeEPLXWMnHiCMih5_HAbfA5-NKP0I5m2ncijw2mGq0mxLzvOS6idB2dQS0KPJsoNMsyOP5MQHclyXGgmTbiSKPjhD0fsI7lk9ELzD5eUEH0mvGONatyFZ9GK5D8iHs3hvEEXzcSn6egdCc1HIU-gD3mGU71QvsN5nDk-KYXH0EW40kiuLDH-eDgjPDt6NHF4PHjw_Si3w3fhHz9hnhTRJzuuI3wk-At4NLhw6Sgn_KtwccB_iD9gWxUOP8bhHb8HT0evRcJ_ouuC5oM0t0K_I_7gTUpwXI6RZ4fIE-kkynhzvMwFnzmI1MXeQ1xW9MqDHqkj3Fl0fDyeHa2iB9-CF2EuaD9yBWdi4cHjKEoCZzGPfOGhK8tx4kyHSh3SROODkIpRW4eW54J-NEckOZJGPH8S_MXX5HgiHj18MUMWj8UJ_SL-HL0HOxF0_MZ3xDDzoL_QKzr8gOGH8IH-Ii9-eE8s1Bc-7cS1EY3KHPON2D3axMa344eWx4JmH1dKnDxRUtvQ3DJO4spyaH8ORx9yLVlRNcbRH-4T3NERTXrwD4pJKoGvDlt9IeQS9Drcx7jEdHCqHdO2436Ea0cZIlkmBrmOx8WHK0eZC36Kylks47mNI8zEndAcFzleM2hWxkR_PMH14UOYH4V6ovvh4j7eGflx68H24P_gUsHx7fCNx7g2IX5QLpwW5FqWGDyH_vAVrCXgZ0F-iIqOSswe_ESPZqxRH9su6ISvoz2OR0Xt4Olx96CO43gl-CHqB7rxpzjR7EPlctDNQl6SoxJpIT3-wD_q4cmRB1qOPkbeKI3wa9ijBU9E-HSRL9uh4_7wKA1RK1GI5tFxGe0PMQmNf0iPPyIeHf_xJg9O5IeYv3geFuHJID96SRs-HGePG47xRsTFBfpxScthJ1Eq1D9Cwj-eLcZVH-5wwfvRN0b8BzvCPAE5HemWLOgqGfehHc2XNMj7oOxxpOijBDrBMES6ZclxesQzvBX8oD9e9IJ_tJOMagpzNNfBC4-OqTua96i3w6_RK-HR8DmSWULcxMOV43fwP2iuTMi14UwERUrI6MgX5J-S4BbmQ-yPPDn85DhxamIC_7hSPMf9IJmSuAhzYg9OUkrw6UHzoCdyfUjAHf05NGoWB2-InCeS2XFwH4we5ThODp2i7-Dn4BaHMJKSC7qObPKD5ktm1BS-oEyuBc1xXoiThTmSJ5Qx5VGHXmFwcnjjCJ6SIHmi3Mgf4EqHR7uC7xFOZQiZdUOy7MaNn8eHv4EZ6cFH4T_uI_mHvA9OxUEj1phEDTlzHP-D_UmIo3FIhCxRPeiX4wmuJEeeT2hCCslOPNjvBI80EqwUFE3xI9bICXpUBM0zfej0tPglnDeYXIuRH2KP_Ivw5LiUB1nictCdEvfRTTma73hyfNEjrEw-iLMS4-GFPWlCZPmOZuyPOcR_9JUGuyb64_iFL_hkxH6g7YiXF009PLlw7DkO_kHPFE2VrcSToVeQPkR16M3x-DjCE8ePH_mFhD8eocdxXiu6w2nC6ehxIkHe4cwMVlqOvfBTnJi6LNPR7B_4HOm-QE-OPW4i5DiPXM6E39AEH6Vz9MeRb8ePA7kh3ugRUnoERxH6CrVjfHvgx7BPo1aON8UlPEiupIgfBeuNShmoTTz-FCGpHMnNDp9wncF1XDqSPUeYxMf4ZMecHKd-8DrEHz--m3h6_Cr6ozluyRISh9ERH4d7nMcXBhPJB83xDZP-obGET1oRH8nzB2dYNCeO6Ua-7NDx7InwRyuaH6F148qSJ_jT4SyJ8BLRjBnk5EmG3KLQHdtJosyiJsFvoheyZKeQPMqOL0yk4jz847mEX3g61NfxBrXwWChPNGd0fAuq7ALV9EJ6aDx-hPlRH9eO0PrRHNehJzmKXxkhH-oKHz_S47iEzyJyHDr8RyjxFtV4iCoz4kj_glbh4xPyBR8P_UGYUcfDo9_hLwdv-Ee0EZdwCbqOnHjg_IeT4_ng4zDJD8ls5Ds-JniEx1sC58jxaNAp9LqhH2r8oIKe6LiOntByCbEjVnguR9gvXAmPc0HZo7k5_A7yGr9GaNnR49GFZwo0xDkcHj2uHL3RHEfeQbuI-3CP-yGq6cZPdB6xxT3-Ii-OK4Mr1I6D58MnPGJihDyaP9AV4kXIT2i4ZCeeHtWPX3jm4jh7pNJ6_PgiiM-DXMnxwEctxkap7YeDjsmJij6uBOYiJPuR48aPhyeOKb9RUQyeF8n7IecPJsyI8iVQ80RzjshTaHmD8J3w6EDVCk9zXOlxHM1lBX-RI_nRLwf_DP7hB5WOPwgbEeJb4algxgcvvIE14zhO4j0cKYRmLS_6HfxxHf0nzCz0H82DWsiDLxEndO3hP-jxo9-FMId-5A8-EndUBVX06Wj64yqOvN3wFb9DqNKGPjouAulROccfaUPEP8KTQ9cWPD_CHPXmDG9wXUb4Q8uFXA6eG4zawzochcd__INP9C2yHOIR_sLR5wqavHjU8Dji74Tma0jJ4MdF8B2-GH5mGXvhG-GhqPzQjDpygrFz2GGDn4iT69izEY7Q_7ieLZAVEU1Elsi_HB4DL8dk9fiWoelQnkF-KErErMSTNHikqCq0PrgmBeEp-HRU9CHCjIfmIdJTNFUmTQF7VE2EJqm0Is9SFeVyatj4IyQZXDl6NFWWDpeI3DtOY_SLJ8sM9k2QZ2huVMcTA4cAAYgB4YwSDACBiFBMPPGctY6a6pAAxighhEjKAAaBNsgIIAAQRgFFHCBGIACQIBJAoAAADCKiCDLSCCABQYQQwIwEigkArKGII0OUQEwAogBRoCCHCCKQAEcEkQYQYoQSigDjCBMCIUCJIUQJQQRRiDAAHSEKEOCCEAAgDRQBxDEgkBEGEAQQFkIIIAQhAgnBAFTAQIEAQYgxYwBClAkEACMEAEAAMQgISAxCgiiCACESIWQAUWYQBoBDBhBGgBBAMSMkYogAIwjwBCMkACLCCASEIMARg4QCBCAijCYAKweEAkAJYRgSAkDjjBCMQgioMIYBQIgDQhkBGiKEQAeBFAQAZoACBgFKFAEAOQMAEIQYgICShChGCCDKESSYoEIQAIRiFkAAgQJMCWMYRUAIgAQAigFEgFDEEAEwEAYQaQw0BgEHADDWGSCAWEgIBQQxBghmjBQCCEqMA0YpQIwAwgDjHFBECiWUEQYxohABRAhAjAUCESUARgRQYgAARggKMAVOAEIEc4gyJ4ABABCCjGMIKAIAEQIAIAwChAEgAGEAGAeIUMZY4QghSBhADSCCGiKNlAQoogAChjCDEAEEWEIRA0QohoQAQCkECEGQEQIEMUwYgIgBABgECECCKEQAAkIgYZARSABkmFECASIARQAAIBggQBDggASECKMVIgJIwwQQUAKChDAQAQMJEcwRIAQQAAlCBGEOCEEMA0A5IARBSAgCiUMCMQEEcdYoYQUglgpgCDDCAMoJIAIIxBRyEggCFAIECGEYQYQp4oBgCCAICSAKEUUMIgwIRAxQwDEEDCCOCsAEYIQBIpBUhhoFlGQcAUYgBAQAJIAhgAglFFEYGAGkEswIAAxSlEBAhABAGKWQktApAQRQBABigBIBAAMyNAQqpYACBgACBAHEOAMIEtIoAAgThBkCCCHIChMYAQYBYAUAgCAmTEgEEAEEUUIhNRAABgChCACCKAAEAAAQYwghCAAAAEJgMMEYCEhAAiADCAAFRAACOUKII4gBowwwECABgAJAIKgMoogJAwABQiFBODLECMSQAQYoYCRAAABgjBBGEUiAYQAR6ghiAhBKCFMGEeAFEIIAYBBDABEhFDKKIgOYoAQo4QgToElEAPIMIIAQtVZQgxQoBJIKAENGESSIFAQwgBAgADAAAMBEOGKIQMpJAAgQRCljKCGICKQAIQAYYwQXQCCgDBJECWIIGAgwxgRQRAnDhMMOGICMEAgYIYEiAhADCBGEMiMIAsQQoBAiBAgPiHBEGiMMIIoSoJAABCFJDAFEaKAIAIYyQJQBgigCBAMAgGIAQQYRIZBASAhDABEIIgLEEQA4JgCBSgAAnAEKIIYJoIAYAjAkQCAiCAEMIACYAYAIFDxxSgElGGAcEaGMkIQBBDlSEDFBFRKLAOEEJMIQBYgQgEAGAGEEEKIIcYAZIRChSDOLiAXMMGohUk4IYJFQCAkECCGWGCCIMMwAABATlCgghCjMGWCSEMIAAAigwjhjCAMICQCIEAIZA4gwTgACkGKMMEIVUEwABBzAmBnBlBKCSGIMAcAqpagFBUWCgBRIGCuYUYQhwAggggEIjjKKAyAAMwoE5owIziIiBCSGCAIMAEAoggxFCCAmmFTEWMKUYQgNQBhBgCAEjFNIKeOMQ4AwRYBBwAjGgDDAMQSMUUwAIqAR1jpIEEEEKGMEAkYIhBADQgBEqUFAFEqQIYI4JhSwwihRjUCAGAeQAYgAYrRhgBGkjEBACCEBAEYopIAwQCCGBDAAGUAAoooJJKByRkBDiJDCMa2UAQMYYRgQAGBFADBIGKEAYg4AgAgQSgAoHCFMCgCUAEY5BQBCxjMIEBBEEgAA +"; + + var result = Subject.ParseFpcalcTextOutput(text); + result.Duration.Should().Be(229.29); + result.Fingerprint.Should().Be("AQAHJlMURlEURcgP6cwRD43Y4Ptw9FowncWPWkf6GB9-JYdP9OgJHw8u4Apw4SsOHMdxHAfwHIfYET_0HiZ55MfxNCm8Hj-mRM8KLlXwIzqS-Que_Cg7NEseE_2j4DUjJBOvI08UNNUufEd5NM-OHzmSUZsO58er7nhUeN6EK3HYwhe0H-fxXEFzlOSHJwpj3MevID7kHyqVqBH6zEI6GzmSs4HTDz4j9DjxKHDvpLiOH7l4iFUWRXiiqtCJmMqNPmiAZEyY5AgTLclxmSWuwJ-O33jz4KdwHt-PsDHRjBNyCQ_-KcGPhoePsJNyiJOiI-5VPKMj_McvfNQRUkyiImEzAv2Ckxb-RUJ-CVpGhMYnB5em4scvHFeuoU7DwHugT8fRecPVpMF5_Cb8jNvw4n_wJ6iV4xcSHuFxKcfTBhVF5uiPA6OSoWQP5x_6BX-5YDoaJT_OLA3e0Ifr4D2SH2EYB-WShLiE6plg5U2G6jlOSgy-G8-Rk0XD7yiP4DyPfcejiQ2aH9WVHKeOJ5eQP9CeKEfD44zw45kj_NAQJmIWfMTPCJF048IPPTtcB_GP_2ieFydyxYXGvUgffIO1D96SHW10vEeYI9mNR-qLhnpwfYHz49KR8ijnol58pDGLKWuF4wvKTUGjrE-Q79COdFyI8Aze4C70LMP24-YKJLtSeCHqajJ6e2g0qsdzBd94RNdxHY80oomYocdB5hNiUrGIVtrhJ0f4CY-Y48yOF5rFI93sgNF2hNRhTSk65odGaUhJClc5ArOGdi8-oz_SzaqQ3MKV63DkGX8NT07wVcdf5MmO5Av8C9dOONGIS_mRS7gO8fjhLNMFbQqq4zF0jRPir2j2Y9_xH5Z-_EFiHWE-oaePh8JN3CsaiYgkGvrR_OgVaI2iHv_gTLsQaTeuZ8K5HUdMCWUlHpLIIFyO8TiNO2nQOHzwLkfYQg2RHxXlUfDxHX-MO9Pxwc8J90JSEzmDJk-Ji0cpZanw44mOWpKEkkHDsUFkZsfNaJCOqDvK_fg-9NHx5UHtFGEOPUeu47hw4dzwH22Nc9pgK8dZhJ-h8wipwyS8Dw8cZ0evC8kZJcgrHT22jsfhH_QCv4jMHNp25NLx6Hh4tEtaePzgcNHxDOkYQ3tCxBsvPFKOfyzCB-L5IHyCpzhFHo9zvMeP5vrQbdzwK8N_hFG6F0mdo2GOJ4yxM0bJRFaGH3WyHUdzHWWe4uoH8hWS8EGOGuUSBZ-E6w7sZsKpIId-IzyDptLxhD76GpfxNDHSkdCTS0JMFS-8Rh6e48c6Hv2O8CG05MinRcUX9A0cKoSv4Ts8KbSQbCXyDDeDZ_mKUrwHP8tRi8d94UcX3ImMLzq8o3-DnFXwHG2UJ_Au6FlwNLgOXU_xakKv4zp8VMeN_vBz9OCbQzsFP7h8PB96Df8llNJSWI8KfVkqZMcPaw5ePEgf6HKQjj2YTRmOxslyTJ-K_kj3Qj9M6TjCM33QixGcX8PzHNfxDOfhMx2SHflz_Dr-K6hSKVLQ7MSjII-UQ9fQ53Bi6ahMoz5jNA304xDz7RjTI08eXKeChzmeDeUJfw7OEPVGnB_yoz_M6sZj8MOfHE5wXKLhT0V-MMhjlDv0C-mP_jgt4cxRMgoLSz5GkUZ-3MFTPHCpB-W2EheX43iWeUgfDhdJPAsR_oKWmDGeJRMRoyea3fhyVA6RjsuCCxdDiD-2fsizC5eSp_CPh4UnP5gU6sOpL7g3ocyCSDn0I7-L4znKBg3uKDmeW8Nzsriqg5eMHk33Cn9Q_0hPQtMX4oqOfMedHI-IehuDm92Q9xDzISyH67jOoc_R_OijI61TJLuObzn-zWi6S2gVT3jUIkhOw2pMgU13XPuGM4c7_B_yHCcPPYpD4Tp6DW6To89xIS-qH31w6vjBLksu4mFEDcndIByvI9SPS4PODM2PdumCfOKDXJByhPGKy8FFZcP3olGs4z5T1MrC4yHiK8e_QdSD-KBz9M3RjNIRPfFQQxxH_PBxKYeoSKRw6YGl8Zh4H09i4rogLg_IsDsuHz_6y2imQ2ZThGcO50KaNj2uoZtk6MJjhIm-DeHj4UMzC9V43Hky9Dj0pwv06Dn05MGPR4OnQ3s8_MFDqI8EnYdG6RRSH9eHmgryH192ZCKPPyp-52jqHWXHBT-O5D2Rw85F3JtAX7jeqLCYLMfF4seN_MeUC9pzXCKuR0K7o_mC_1DNDPl21NoLSzuO_sSVBEefZ0hrC8mHU0GVtviDH7XzoM7w9CgfTInCDc8DPhPyH0nkw9vgbEfJRJvwC08I98gj6ESu4ylx3FGJfvIKM7ZRsluER8eVIll6hL-EUsoOcz_K4250WM4Rq7qQXFKO9-iNMzSeYxuLL3vB8CnyBsmxX3iOJ9uHUqLgJ0f148ZxD8ka9QjlfHieF3cUCeEPLXWMnHiCMih5_HAbfA5-NKP0I5m2ncijw2mGq0mxLzvOS6idB2dQS0KPJsoNMsyOP5MQHclyXGgmTbiSKPjhD0fsI7lk9ELzD5eUEH0mvGONatyFZ9GK5D8iHs3hvEEXzcSn6egdCc1HIU-gD3mGU71QvsN5nDk-KYXH0EW40kiuLDH-eDgjPDt6NHF4PHjw_Si3w3fhHz9hnhTRJzuuI3wk-At4NLhw6Sgn_KtwccB_iD9gWxUOP8bhHb8HT0evRcJ_ouuC5oM0t0K_I_7gTUpwXI6RZ4fIE-kkynhzvMwFnzmI1MXeQ1xW9MqDHqkj3Fl0fDyeHa2iB9-CF2EuaD9yBWdi4cHjKEoCZzGPfOGhK8tx4kyHSh3SROODkIpRW4eW54J-NEckOZJGPH8S_MXX5HgiHj18MUMWj8UJ_SL-HL0HOxF0_MZ3xDDzoL_QKzr8gOGH8IH-Ii9-eE8s1Bc-7cS1EY3KHPON2D3axMa344eWx4JmH1dKnDxRUtvQ3DJO4spyaH8ORx9yLVlRNcbRH-4T3NERTXrwD4pJKoGvDlt9IeQS9Drcx7jEdHCqHdO2436Ea0cZIlkmBrmOx8WHK0eZC36Kylks47mNI8zEndAcFzleM2hWxkR_PMH14UOYH4V6ovvh4j7eGflx68H24P_gUsHx7fCNx7g2IX5QLpwW5FqWGDyH_vAVrCXgZ0F-iIqOSswe_ESPZqxRH9su6ISvoz2OR0Xt4Olx96CO43gl-CHqB7rxpzjR7EPlctDNQl6SoxJpIT3-wD_q4cmRB1qOPkbeKI3wa9ijBU9E-HSRL9uh4_7wKA1RK1GI5tFxGe0PMQmNf0iPPyIeHf_xJg9O5IeYv3geFuHJID96SRs-HGePG47xRsTFBfpxScthJ1Eq1D9Cwj-eLcZVH-5wwfvRN0b8BzvCPAE5HemWLOgqGfehHc2XNMj7oOxxpOijBDrBMES6ZclxesQzvBX8oD9e9IJ_tJOMagpzNNfBC4-OqTua96i3w6_RK-HR8DmSWULcxMOV43fwP2iuTMi14UwERUrI6MgX5J-S4BbmQ-yPPDn85DhxamIC_7hSPMf9IJmSuAhzYg9OUkrw6UHzoCdyfUjAHf05NGoWB2-InCeS2XFwH4we5ThODp2i7-Dn4BaHMJKSC7qObPKD5ktm1BS-oEyuBc1xXoiThTmSJ5Qx5VGHXmFwcnjjCJ6SIHmi3Mgf4EqHR7uC7xFOZQiZdUOy7MaNn8eHv4EZ6cFH4T_uI_mHvA9OxUEj1phEDTlzHP-D_UmIo3FIhCxRPeiX4wmuJEeeT2hCCslOPNjvBI80EqwUFE3xI9bICXpUBM0zfej0tPglnDeYXIuRH2KP_Ivw5LiUB1nictCdEvfRTTma73hyfNEjrEw-iLMS4-GFPWlCZPmOZuyPOcR_9JUGuyb64_iFL_hkxH6g7YiXF009PLlw7DkO_kHPFE2VrcSToVeQPkR16M3x-DjCE8ePH_mFhD8eocdxXiu6w2nC6ehxIkHe4cwMVlqOvfBTnJi6LNPR7B_4HOm-QE-OPW4i5DiPXM6E39AEH6Vz9MeRb8ePA7kh3ugRUnoERxH6CrVjfHvgx7BPo1aON8UlPEiupIgfBeuNShmoTTz-FCGpHMnNDp9wncF1XDqSPUeYxMf4ZMecHKd-8DrEHz--m3h6_Cr6ozluyRISh9ERH4d7nMcXBhPJB83xDZP-obGET1oRH8nzB2dYNCeO6Ua-7NDx7InwRyuaH6F148qSJ_jT4SyJ8BLRjBnk5EmG3KLQHdtJosyiJsFvoheyZKeQPMqOL0yk4jz847mEX3g61NfxBrXwWChPNGd0fAuq7ALV9EJ6aDx-hPlRH9eO0PrRHNehJzmKXxkhH-oKHz_S47iEzyJyHDr8RyjxFtV4iCoz4kj_glbh4xPyBR8P_UGYUcfDo9_hLwdv-Ee0EZdwCbqOnHjg_IeT4_ng4zDJD8ls5Ds-JniEx1sC58jxaNAp9LqhH2r8oIKe6LiOntByCbEjVnguR9gvXAmPc0HZo7k5_A7yGr9GaNnR49GFZwo0xDkcHj2uHL3RHEfeQbuI-3CP-yGq6cZPdB6xxT3-Ii-OK4Mr1I6D58MnPGJihDyaP9AV4kXIT2i4ZCeeHtWPX3jm4jh7pNJ6_PgiiM-DXMnxwEctxkap7YeDjsmJij6uBOYiJPuR48aPhyeOKb9RUQyeF8n7IecPJsyI8iVQ80RzjshTaHmD8J3w6EDVCk9zXOlxHM1lBX-RI_nRLwf_DP7hB5WOPwgbEeJb4algxgcvvIE14zhO4j0cKYRmLS_6HfxxHf0nzCz0H82DWsiDLxEndO3hP-jxo9-FMId-5A8-EndUBVX06Wj64yqOvN3wFb9DqNKGPjouAulROccfaUPEP8KTQ9cWPD_CHPXmDG9wXUb4Q8uFXA6eG4zawzochcd__INP9C2yHOIR_sLR5wqavHjU8Dji74Tma0jJ4MdF8B2-GH5mGXvhG-GhqPzQjDpygrFz2GGDn4iT69izEY7Q_7ieLZAVEU1Elsi_HB4DL8dk9fiWoelQnkF-KErErMSTNHikqCq0PrgmBeEp-HRU9CHCjIfmIdJTNFUmTQF7VE2EJqm0Is9SFeVyatj4IyQZXDl6NFWWDpeI3DtOY_SLJ8sM9k2QZ2huVMcTA4cAAYgB4YwSDACBiFBMPPGctY6a6pAAxighhEjKAAaBNsgIIAAQRgFFHCBGIACQIBJAoAAADCKiCDLSCCABQYQQwIwEigkArKGII0OUQEwAogBRoCCHCCKQAEcEkQYQYoQSigDjCBMCIUCJIUQJQQRRiDAAHSEKEOCCEAAgDRQBxDEgkBEGEAQQFkIIIAQhAgnBAFTAQIEAQYgxYwBClAkEACMEAEAAMQgISAxCgiiCACESIWQAUWYQBoBDBhBGgBBAMSMkYogAIwjwBCMkACLCCASEIMARg4QCBCAijCYAKweEAkAJYRgSAkDjjBCMQgioMIYBQIgDQhkBGiKEQAeBFAQAZoACBgFKFAEAOQMAEIQYgICShChGCCDKESSYoEIQAIRiFkAAgQJMCWMYRUAIgAQAigFEgFDEEAEwEAYQaQw0BgEHADDWGSCAWEgIBQQxBghmjBQCCEqMA0YpQIwAwgDjHFBECiWUEQYxohABRAhAjAUCESUARgRQYgAARggKMAVOAEIEc4gyJ4ABABCCjGMIKAIAEQIAIAwChAEgAGEAGAeIUMZY4QghSBhADSCCGiKNlAQoogAChjCDEAEEWEIRA0QohoQAQCkECEGQEQIEMUwYgIgBABgECECCKEQAAkIgYZARSABkmFECASIARQAAIBggQBDggASECKMVIgJIwwQQUAKChDAQAQMJEcwRIAQQAAlCBGEOCEEMA0A5IARBSAgCiUMCMQEEcdYoYQUglgpgCDDCAMoJIAIIxBRyEggCFAIECGEYQYQp4oBgCCAICSAKEUUMIgwIRAxQwDEEDCCOCsAEYIQBIpBUhhoFlGQcAUYgBAQAJIAhgAglFFEYGAGkEswIAAxSlEBAhABAGKWQktApAQRQBABigBIBAAMyNAQqpYACBgACBAHEOAMIEtIoAAgThBkCCCHIChMYAQYBYAUAgCAmTEgEEAEEUUIhNRAABgChCACCKAAEAAAQYwghCAAAAEJgMMEYCEhAAiADCAAFRAACOUKII4gBowwwECABgAJAIKgMoogJAwABQiFBODLECMSQAQYoYCRAAABgjBBGEUiAYQAR6ghiAhBKCFMGEeAFEIIAYBBDABEhFDKKIgOYoAQo4QgToElEAPIMIIAQtVZQgxQoBJIKAENGESSIFAQwgBAgADAAAMBEOGKIQMpJAAgQRCljKCGICKQAIQAYYwQXQCCgDBJECWIIGAgwxgRQRAnDhMMOGICMEAgYIYEiAhADCBGEMiMIAsQQoBAiBAgPiHBEGiMMIIoSoJAABCFJDAFEaKAIAIYyQJQBgigCBAMAgGIAQQYRIZBASAhDABEIIgLEEQA4JgCBSgAAnAEKIIYJoIAYAjAkQCAiCAEMIACYAYAIFDxxSgElGGAcEaGMkIQBBDlSEDFBFRKLAOEEJMIQBYgQgEAGAGEEEKIIcYAZIRChSDOLiAXMMGohUk4IYJFQCAkECCGWGCCIMMwAABATlCgghCjMGWCSEMIAAAigwjhjCAMICQCIEAIZA4gwTgACkGKMMEIVUEwABBzAmBnBlBKCSGIMAcAqpagFBUWCgBRIGCuYUYQhwAggggEIjjKKAyAAMwoE5owIziIiBCSGCAIMAEAoggxFCCAmmFTEWMKUYQgNQBhBgCAEjFNIKeOMQ4AwRYBBwAjGgDDAMQSMUUwAIqAR1jpIEEEEKGMEAkYIhBADQgBEqUFAFEqQIYI4JhSwwihRjUCAGAeQAYgAYrRhgBGkjEBACCEBAEYopIAwQCCGBDAAGUAAoooJJKByRkBDiJDCMa2UAQMYYRgQAGBFADBIGKEAYg4AgAgQSgAoHCFMCgCUAEY5BQBCxjMIEBBEEgAA"); + } + + [TestCase("nin.mp3")] + [TestCase("nin.flac")] + public void should_fingerprint_file(string file) + { + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Media", file); + + var fingerprint = Subject.GetFingerprint(path); + fingerprint.Should().NotBeNull(); + fingerprint.Fingerprint.Should().Be("AQACmomSJEySRNISHCa6zDh6OO_hoUfr48cvHD83-JApYL6F8oCMHvlg46qOHYf740L6ascN_fj0wh90aD2OC41FC-Exf0IvPBKyH6qNOGLEgrBxXC3w4MiFB2J--Cce4cYXpLwGXER64RHxQxT142eJFMeL40g_HSbUh7h6xONhuDZ-5NpxFrpwfCrSo4pj_EJYHoehvzjeHcbnDGoOH30WPEYeEz3xI72P0sLRNRHx49IB_IXx4_iH5yF-HOdx2FoE8R5-HM2hJmOF6hFSXziPHdtxmLmQ5sKYHzouFjlMokfOBncsaE91mPoQ_MctTCqRX9CEnziO_zhcCPk_POmOoz8u1KOQ6-ihazgaHw9OaPlxAvmKfDhUpcWH_MNbBr-Do0eYH7p-5PqFPUbD5TK2o6p0_MeP8IcOw0c-H8eRZxCjRniE8HgPhlKKxqmk4jnai8JZBO9SPMauFEeOHxeOFj_-NsgFdaHxIzx8PMzQRsQfRL4GHvoiNLnwXD6uA-Jx_DiM4-Wh5nBUIXx8vEMtBt60wWwQ3mh7ovE1PCmaHMdx9AiOM8SFXMfVC_oToDl64sqSsfiNB-Wh5_iT4xf-D8dh6PjxAP6CP8fjQf9ghM069IYfwxf8C5cQdwcXC5-CPNiP48WBKh_i49DN4Dz6C82Rx8mQH7-wgzls5VDznPhz4PBx_AAr-PjxVBHUB-aHQld2oWfQODnOoKcLX7iW4fHBFcfxHPOgH_Mt_HjEJDoq9cKH44nyQyfCjBeeVBP4VEUfvIvwIIueUYWTJviFH_lx_EeOQ7wG1sibwNGL7oh_6GzQTGKIkFm64CE-1TiYH_6OWHXw_Dh-2Dp0_TgR43mOXjOOkOGhZdKHPHiIvgy2H_0xJlsQX9Cu48gN__iR5_jBHj7U6wiVtyj9oemOz8LR_Ba-IM8VXDPCJ1PBRdMRyhe8aYZ-HP3R48fBvdCeHM9R7Qve48htuMoS0eiR-_jxoDkYLjoeJ9DUU_iA4zr44zec4_EOPcdTeAib4URPPLCPnseniUL0D6HSHf9xoz3wByF73Ic-VA_-fYh_ePwQ_cGlQD-ap-jDHOEpXENWwrEyPMe1I8ePHz_yQz-8D_-NrIbzo7uF-IcuohkZXBpyH89jnAJFHSH5DP9R5fh0FDrB42mGazj-bbhz6DwahGSKq_jxGwx3-EIsbnh-wdYO4YSPHz8OQszxPMSPhsf3HDl6zTCRhwr6g1fxPAej44txfLhw6JiPisfb4LhzlDy0ZDhj5NqIo3-w_UA6Cn2GKk6hHtcAH7-JH4cDNSkeHS9yH9_x4cyQ6yIemM5xBefx9HCqGT-OHzxOaDwefLfwpBfyC-EP_9CZo88snEPOHl-EHn4QMSd-XAN2PAe8HdBlH9SLrzm6HH-hK0d5mCrOoj9-MMe3hrhycPERjw9-YciJHx-EP8cTsRvOw5OOHjpjY14yEj0ycbBxZUXEVHB25BrM4McOhCwwCCDgjAQAFIOoAIICIQSAQiBDFELAEaYUMMtAKgUSACBnRJEQEGCAeAoRRIQQliFAsVYGGOKIcEYAAIVRgjgAJDNGKYCUBgIIKLwxCBHjCVDGECYuAsJBDhUSBhGhCACGKKOEEUUI4AAxHAkFHQEAQWeQJAYIYGQUAgBBgNGAMAEQMABRJwQSD2gCGAIMAOUIYhQBKwBjBCKmlCIBAACUYmYJZA0gRDgBCDDwWQcRAAAARJxACoFuBABOAACIwlY5Q4BwQAEAJDDYKZGQwogIyQwSABFmFDHDCAKAUMwooYAQABEgACPEAEORcQQ4aAAgHBRBpFFMECUoUJQZs4ARSBEFgUHKOUGAAYQ5QxAQyAFADDlAKgKMUAIQBxpQjBDLIBNCAGKsIBQRRQSSwjkFkCPDEOEQIUwKJJiywADhtHGAEaKAUBASxRRBxBgCDFBAKYWAAkIAYQQQBAkLBFEAKEIexEYxQYhFBghiAEDECCABMQAACh1QiBAgCDBUIEUZQAAAwpxYADFzAIAEIQEwkAQIAJhwylElhINSOMEEAwwggJVSAjHjgBQIEcMQcUARBJRT8AKBgAMAGKMIABIxQYVwhACAFbKAAUEAccAR4wmRlAjCiEEGMGIcEAQYZCSCTCInBQ"); + fingerprint.Duration.Should().BeApproximately(85.11, 0.1); + } + + [TestCase("nin.mp3")] + [TestCase("nin.flac")] + public void should_lookup_file(string file) + { + UseRealHttp(); + + var path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Media", file); + var localTrack = new LocalTrack { Path = path }; + Subject.Lookup(new List { localTrack }, 0.5); + localTrack.AcoustIdResults.Should().NotBeNull(); + localTrack.AcoustIdResults.Should().Contain("30f3f33e-8d0c-4e69-8539-cbd701d18f28"); + } + + [Test] + public void should_lookup_list() + { + UseRealHttp(); + + var files = new [] { + "nin.mp3", + "nin.flac" + }.Select(x => new LocalTrack { Path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Media", x) }).ToList(); + Subject.Lookup(files, 0.5); + + files[0].AcoustIdResults.Should().Contain("30f3f33e-8d0c-4e69-8539-cbd701d18f28"); + files[1].AcoustIdResults.Should().Contain("30f3f33e-8d0c-4e69-8539-cbd701d18f28"); + } + + [Test] + public void should_lookup_list_when_fpcalc_fails_for_some_files() + { + UseRealHttp(); + + var files = new [] { + "nin.mp3", + "missing.mp3", + "nin.flac" + }.Select(x => new LocalTrack { Path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Media", x) }).ToList(); + + var idpairs = files.Select(x => Tuple.Create(x, Subject.GetFingerprint(x.Path))).ToList(); + + Subject.Lookup(idpairs, 0.5); + + files[0].AcoustIdResults.Should().Contain("30f3f33e-8d0c-4e69-8539-cbd701d18f28"); + files[1].AcoustIdResults.Should().BeNull(); + files[2].AcoustIdResults.Should().Contain("30f3f33e-8d0c-4e69-8539-cbd701d18f28"); + } + + [Test] + public void should_lookup_list_when_fpcalc_fails_for_all_files() + { + UseRealHttp(); + + var files = new [] { + "missing1.mp3", + "missing2.mp3" + }.Select(x => new LocalTrack { Path = Path.Combine(TestContext.CurrentContext.TestDirectory, "Files", "Media", x) }).ToList(); + + var idpairs = files.Select(x => Tuple.Create(x, null)).ToList(); + + Subject.Lookup(idpairs, 0.5); + + files[0].AcoustIdResults.Should().BeNull(); + files[1].AcoustIdResults.Should().BeNull(); + } + } +} diff --git a/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs index e87f61a76..5420e5dcd 100644 --- a/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/ParserFixture.cs @@ -96,7 +96,7 @@ namespace NzbDrone.Core.Test.ParserTests public void should_parse_quality_from_extension(string title) { Parser.Parser.ParseAlbumTitle(title).Quality.Quality.Should().NotBe(Quality.Unknown); - Parser.Parser.ParseAlbumTitle(title).Quality.QualitySource.Should().Be(QualitySource.Extension); + Parser.Parser.ParseAlbumTitle(title).Quality.QualityDetectionSource.Should().Be(QualityDetectionSource.Extension); } [TestCase("VA - The Best 101 Love Ballads (2017) MP3 [192 kbps]", "VA", "The Best 101 Love Ballads")] diff --git a/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetLocalTrackFixture.cs b/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetLocalTrackFixture.cs deleted file mode 100644 index 48c9202d8..000000000 --- a/src/NzbDrone.Core.Test/ParserTests/ParsingServiceTests/GetLocalTrackFixture.cs +++ /dev/null @@ -1,127 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using FizzWare.NBuilder; -using FluentAssertions; -using Moq; -using NUnit.Framework; -using NzbDrone.Core.Music; -using NzbDrone.Core.Parser; -using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Test.Framework; -using NzbDrone.Test.Common; - -namespace NzbDrone.Core.Test.ParserTests.ParsingServiceTests -{ - [TestFixture] - public class GetLocalTrackFixture : CoreTest - { - private Artist _fakeArtist; - private Album _fakeAlbum; - private Track _fakeTrack; - private ParsedTrackInfo _parsedTrackInfo; - - [SetUp] - public void Setup() - { - _fakeArtist = Builder - .CreateNew() - .Build(); - - _fakeAlbum = Builder - .CreateNew() - .With(e => e.ArtistId = _fakeArtist.Id) - .With(e => e.AlbumReleases = new List - { - new AlbumRelease - { - ForeignReleaseId = "5ecd552b-e54b-4c37-b62c-9d6234834bad", - Monitored = true - } - }) - .Build(); - - _fakeTrack = Builder - .CreateNew() - .With(e => e.Artist = _fakeArtist) - .With(e => e.AlbumId = _fakeAlbum.Id) - .With(e => e.Album = null) - .Build(); - - _parsedTrackInfo = Builder - .CreateNew() - .With(e => e.AlbumTitle = _fakeAlbum.Title) - .With(e => e.Title = _fakeTrack.Title) - .With(e => e.ArtistTitle = _fakeArtist.Name) - .Build(); - - Mocker.GetMock() - .Setup(s => s.FindByTitle(_fakeArtist.Id,_fakeAlbum.Title)) - .Returns(_fakeAlbum); - - Mocker.GetMock() - .Setup(s => s.FindAlbumByRelease(_fakeAlbum.AlbumReleases.Value.First().ForeignReleaseId)) - .Returns(_fakeAlbum); - - Mocker.GetMock() - .Setup(s => s.FindTrackByTitle(_fakeArtist.Id, _fakeAlbum.Id, It.IsAny(), It.IsAny(), _fakeTrack.Title)) - .Returns(_fakeTrack); - } - - private void HasAlbumTitleNoReleaseId() - { - _parsedTrackInfo.AlbumTitle = _fakeAlbum.Title; - _parsedTrackInfo.ReleaseMBId = ""; - } - - private void HasReleaseMbIdNoTitle() - { - _parsedTrackInfo.AlbumTitle = ""; - _parsedTrackInfo.ReleaseMBId = _fakeAlbum.AlbumReleases.Value.First().ForeignReleaseId; - } - - private void HasNoReleaseIdOrTitle() - { - _parsedTrackInfo.AlbumTitle = ""; - _parsedTrackInfo.ReleaseMBId = ""; - } - - [Test] - public void should_find_album_with_title_no_MBID() - { - HasAlbumTitleNoReleaseId(); - - var localTrack = Subject.GetLocalTrack("somfile.mp3", _fakeArtist, _parsedTrackInfo); - - localTrack.Artist.Id.Should().Be(_fakeArtist.Id); - localTrack.Album.Id.Should().Be(_fakeAlbum.Id); - localTrack.Tracks.First().Id.Should().Be(_fakeTrack.Id); - } - - [Test] - public void should_find_album_with_release_MBID_no_title() - { - HasReleaseMbIdNoTitle(); - - var localTrack = Subject.GetLocalTrack("somfile.mp3", _fakeArtist, _parsedTrackInfo); - - localTrack.Artist.Id.Should().Be(_fakeArtist.Id); - localTrack.Album.Id.Should().Be(_fakeAlbum.Id); - localTrack.Tracks.First().Id.Should().Be(_fakeTrack.Id); - } - - [Test] - public void should_not_find_album_with_no_release_MBID_no_title() - { - HasNoReleaseIdOrTitle(); - - var localTrack = Subject.GetLocalTrack("somfile.mp3", _fakeArtist, _parsedTrackInfo); - ExceptionVerification.ExpectedWarns(1); - - localTrack.Should().BeNull(); - - } - } -} diff --git a/src/NzbDrone.Core.Test/ParserTests/QualityParserFixture.cs b/src/NzbDrone.Core.Test/ParserTests/QualityParserFixture.cs index a79a703a3..f4e4db8f1 100644 --- a/src/NzbDrone.Core.Test/ParserTests/QualityParserFixture.cs +++ b/src/NzbDrone.Core.Test/ParserTests/QualityParserFixture.cs @@ -262,7 +262,7 @@ namespace NzbDrone.Core.Test.ParserTests [TestCase("Little Mix - Salute [Deluxe Edition] [2013] [M4A-256]-V3nom [GLT")] public void should_parse_quality_from_name(string title) { - QualityParser.ParseQuality(title, null, 0).QualitySource.Should().Be(QualitySource.Name); + QualityParser.ParseQuality(title, null, 0).QualityDetectionSource.Should().Be(QualityDetectionSource.Name); } [TestCase("01. Kanye West - Ultralight Beam.mp3")] @@ -273,7 +273,7 @@ namespace NzbDrone.Core.Test.ParserTests //[TestCase("01. Kanye West - Ultralight Beam.wav")] public void should_parse_quality_from_extension(string title) { - QualityParser.ParseQuality(title, null, 0).QualitySource.Should().Be(QualitySource.Extension); + QualityParser.ParseQuality(title, null, 0).QualityDetectionSource.Should().Be(QualityDetectionSource.Extension); } private void ParseAndVerifyQuality(string name, string desc, int bitrate, Quality quality, int sampleSize = 0) diff --git a/src/NzbDrone.Core/ArtistStats/ArtistStatisticsService.cs b/src/NzbDrone.Core/ArtistStats/ArtistStatisticsService.cs index f352ff3f5..1d77713b9 100644 --- a/src/NzbDrone.Core/ArtistStats/ArtistStatisticsService.cs +++ b/src/NzbDrone.Core/ArtistStats/ArtistStatisticsService.cs @@ -1,5 +1,10 @@ using System.Collections.Generic; using System.Linq; +using NzbDrone.Common.Cache; +using NzbDrone.Core.MediaFiles.Events; +using NzbDrone.Core.Messaging; +using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Music.Events; namespace NzbDrone.Core.ArtistStats { @@ -9,25 +14,33 @@ namespace NzbDrone.Core.ArtistStats ArtistStatistics ArtistStatistics(int artistId); } - public class ArtistStatisticsService : IArtistStatisticsService + public class ArtistStatisticsService : IArtistStatisticsService, + IHandle, + IHandle, + IHandle, + IHandle, + IHandle { private readonly IArtistStatisticsRepository _artistStatisticsRepository; + private readonly ICached> _cache; - public ArtistStatisticsService(IArtistStatisticsRepository artistStatisticsRepository) + public ArtistStatisticsService(IArtistStatisticsRepository artistStatisticsRepository, + ICacheManager cacheManager) { _artistStatisticsRepository = artistStatisticsRepository; + _cache = cacheManager.GetCache>(GetType()); } public List ArtistStatistics() { - var albumStatistics = _artistStatisticsRepository.ArtistStatistics(); + var albumStatistics = _cache.Get("AllArtists", () => _artistStatisticsRepository.ArtistStatistics()); return albumStatistics.GroupBy(s => s.ArtistId).Select(s => MapArtistStatistics(s.ToList())).ToList(); } public ArtistStatistics ArtistStatistics(int artistId) { - var stats = _artistStatisticsRepository.ArtistStatistics(artistId); + var stats = _cache.Get(artistId.ToString(), () => _artistStatisticsRepository.ArtistStatistics(artistId)); if (stats == null || stats.Count == 0) return new ArtistStatistics(); @@ -49,5 +62,40 @@ namespace NzbDrone.Core.ArtistStats return artistStatistics; } + + [EventHandleOrder(EventHandleOrder.First)] + public void Handle(ArtistUpdatedEvent message) + { + _cache.Remove("AllArtists"); + _cache.Remove(message.Artist.Id.ToString()); + } + + [EventHandleOrder(EventHandleOrder.First)] + public void Handle(ArtistDeletedEvent message) + { + _cache.Remove("AllArtists"); + _cache.Remove(message.Artist.Id.ToString()); + } + + [EventHandleOrder(EventHandleOrder.First)] + public void Handle(AlbumImportedEvent message) + { + _cache.Remove("AllArtists"); + _cache.Remove(message.Artist.Id.ToString()); + } + + [EventHandleOrder(EventHandleOrder.First)] + public void Handle(AlbumEditedEvent message) + { + _cache.Remove("AllArtists"); + _cache.Remove(message.Album.ArtistId.ToString()); + } + + [EventHandleOrder(EventHandleOrder.First)] + public void Handle(TrackFileDeletedEvent message) + { + _cache.Remove("AllArtists"); + _cache.Remove(message.TrackFile.Artist.Value.Id.ToString()); + } } } diff --git a/src/NzbDrone.Core/Configuration/AllowFingerprinting.cs b/src/NzbDrone.Core/Configuration/AllowFingerprinting.cs new file mode 100644 index 000000000..2135eae3d --- /dev/null +++ b/src/NzbDrone.Core/Configuration/AllowFingerprinting.cs @@ -0,0 +1,9 @@ +namespace NzbDrone.Core.Configuration +{ + public enum AllowFingerprinting + { + Never, + NewFiles, + AllFiles + } +} diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index 7d70e1285..e446f2f0a 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -195,13 +195,6 @@ namespace NzbDrone.Core.Configuration set { SetValue("CopyUsingHardlinks", value); } } - public bool EnableMediaInfo - { - get { return GetValueBoolean("EnableMediaInfo", true); } - - set { SetValue("EnableMediaInfo", value); } - } - public bool ImportExtraFiles { get { return GetValueBoolean("ImportExtraFiles", false); } @@ -223,6 +216,13 @@ namespace NzbDrone.Core.Configuration set { SetValue("RescanAfterRefresh", value); } } + public AllowFingerprinting AllowFingerprinting + { + get { return GetValueEnum("AllowFingerprinting", AllowFingerprinting.NewFiles); } + + set { SetValue("AllowFingerprinting", value); } + } + public bool SetPermissionsLinux { get { return GetValueBoolean("SetPermissionsLinux", false); } diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs index b45a226f9..f23d226db 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -30,10 +30,10 @@ namespace NzbDrone.Core.Configuration FileDateType FileDate { get; set; } bool SkipFreeSpaceCheckWhenImporting { get; set; } bool CopyUsingHardlinks { get; set; } - bool EnableMediaInfo { get; set; } bool ImportExtraFiles { get; set; } string ExtraFileExtensions { get; set; } RescanAfterRefreshType RescanAfterRefresh { get; set; } + AllowFingerprinting AllowFingerprinting { get; set; } //Permissions (Media Management) bool SetPermissionsLinux { get; set; } diff --git a/src/NzbDrone.Core/Datastore/BasicRepository.cs b/src/NzbDrone.Core/Datastore/BasicRepository.cs index ae722d876..f94aec2a0 100644 --- a/src/NzbDrone.Core/Datastore/BasicRepository.cs +++ b/src/NzbDrone.Core/Datastore/BasicRepository.cs @@ -31,6 +31,7 @@ namespace NzbDrone.Core.Datastore bool HasItems(); void DeleteMany(IEnumerable ids); void SetFields(TModel model, params Expression>[] properties); + void SetFields(IEnumerable models, params Expression>[] properties); TModel Single(); PagingSpec GetPaged(PagingSpec pagingSpec); } @@ -244,6 +245,30 @@ namespace NzbDrone.Core.Datastore ModelUpdated(model); } + public void SetFields(IEnumerable models, params Expression>[] properties) + { + using (var unitOfWork = new UnitOfWork(() => DataMapper)) + { + unitOfWork.BeginTransaction(IsolationLevel.ReadCommitted); + + foreach (var model in models) + { + if (model.Id == 0) + { + throw new InvalidOperationException("Can't update model with ID 0"); + } + + unitOfWork.DB.Update() + .Where(c => c.Id == model.Id) + .ColumnsIncluding(properties) + .Entity(model) + .Execute(); + } + + unitOfWork.Commit(); + } + } + public virtual PagingSpec GetPaged(PagingSpec pagingSpec) { pagingSpec.Records = GetPagedQuery(Query, pagingSpec).ToList(); diff --git a/src/NzbDrone.Core/Datastore/Migration/024_clear_media_info.cs b/src/NzbDrone.Core/Datastore/Migration/024_clear_media_info.cs new file mode 100644 index 000000000..4004f4efb --- /dev/null +++ b/src/NzbDrone.Core/Datastore/Migration/024_clear_media_info.cs @@ -0,0 +1,14 @@ +using FluentMigrator; +using NzbDrone.Core.Datastore.Migration.Framework; + +namespace NzbDrone.Core.Datastore.Migration +{ + [Migration(024)] + public class NewMediaInfoFormat : NzbDroneMigrationBase + { + protected override void MainDbUpgrade() + { + Update.Table("TrackFiles").Set(new { MediaInfo = "" }).AllRows(); + } + } +} diff --git a/src/NzbDrone.Core/Datastore/TableMapping.cs b/src/NzbDrone.Core/Datastore/TableMapping.cs index 9947ce3bc..87dbe5223 100644 --- a/src/NzbDrone.Core/Datastore/TableMapping.cs +++ b/src/NzbDrone.Core/Datastore/TableMapping.cs @@ -40,6 +40,7 @@ using NzbDrone.Core.Extras.Lyrics; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Music; using NzbDrone.Core.Languages; +using Marr.Data.QGen; namespace NzbDrone.Core.Datastore { @@ -107,8 +108,10 @@ namespace NzbDrone.Core.Datastore .For(rg => rg.AlbumReleases) .LazyLoad(condition: rg => rg.Id > 0, query: (db, rg) => db.Query().Where(r => r.AlbumId == rg.Id).ToList()) .For(rg => rg.Artist) - .LazyLoad(condition: rg => rg.ArtistMetadataId > 0, query: (db, rg) => db.Query().Where(a => a.ArtistMetadataId == rg.ArtistMetadataId).SingleOrDefault()); - + .LazyLoad(condition: rg => rg.ArtistMetadataId > 0, + query: (db, rg) => db.Query() + .Join(JoinType.Inner, a => a.Metadata, (a, m) => a.ArtistMetadataId == m.Id) + .Where(a => a.ArtistMetadataId == rg.ArtistMetadataId).SingleOrDefault()); Mapper.Entity().RegisterModel("AlbumReleases") .Relationship() @@ -119,40 +122,41 @@ namespace NzbDrone.Core.Datastore Mapper.Entity().RegisterModel("Tracks") .Ignore(t => t.HasFile) .Ignore(t => t.AlbumId) - .Ignore(t => t.ArtistId) .Ignore(t => t.Album) .Relationship() .HasOne(track => track.AlbumRelease, track => track.AlbumReleaseId) .HasOne(track => track.ArtistMetadata, track => track.ArtistMetadataId) - .HasOne(track => track.TrackFile, track => track.TrackFileId) + .For(track => track.TrackFile) + .LazyLoad(condition: track => track.TrackFileId > 0, + query: (db, track) => db.Query() + .Join(JoinType.Inner, t => t.Tracks, (t, x) => t.Id == x.TrackFileId) + .Join(JoinType.Inner, t => t.Album, (t, a) => t.AlbumId == a.Id) + .Join(JoinType.Inner, t => t.Artist, (t, a) => t.Album.Value.ArtistMetadataId == a.ArtistMetadataId) + .Join(JoinType.Inner, a => a.Metadata, (a, m) => a.ArtistMetadataId == m.Id) + .Where(t => t.Id == track.TrackFileId) + .SingleOrDefault()) .For(t => t.Artist) - .LazyLoad(condition: t => t.AlbumReleaseId > 0, query: (db, t) => db.Query().QueryText(string.Format( - "/* LazyLoading Artist for Track */\n" + - "SELECT Artists.* " + - "FROM Artists " + - "JOIN Albums ON Albums.ArtistMetadataId = Artists.ArtistMetadataId " + - "JOIN AlbumReleases ON AlbumReleases.AlbumId = Albums.Id " + - "WHERE AlbumReleases.Id = {0}", - t.AlbumReleaseId)).SingleOrDefault()); + .LazyLoad(condition: t => t.AlbumReleaseId > 0, query: (db, t) => db.Query() + .Join(JoinType.Inner, a => a.Metadata, (a, m) => a.ArtistMetadataId == m.Id) + .Join(JoinType.Inner, a => a.Albums, (l, r) => l.ArtistMetadataId == r.ArtistMetadataId) + .Join(JoinType.Inner, a => a.AlbumReleases, (l, r) => l.Id == r.AlbumId) + .Where(r => r.Id == t.AlbumReleaseId) + .SingleOrDefault()); Mapper.Entity().RegisterModel("TrackFiles") .Ignore(f => f.Path) .Relationship() .HasOne(f => f.Album, f => f.AlbumId) - .For("Tracks") - .LazyLoad(condition: parent => parent.Id > 0, - query: (db, parent) => db.Query().Where(c => c.TrackFileId == parent.Id).ToList()) + .For(f => f.Tracks) + .LazyLoad(condition: f => f.Id > 0, query: (db, f) => db.Query() + .Where(x => x.TrackFileId == f.Id) + .ToList()) .For(t => t.Artist) - .LazyLoad(condition: f => f.Id > 0, query: (db, f) => db.Query().QueryText(string.Format( - "/* LazyLoading Artist for TrackFile */\n" + - "SELECT Artists.* " + - "FROM Artists " + - "JOIN Albums ON Albums.ArtistMetadataId = Artists.ArtistMetadataId " + - "JOIN AlbumReleases ON AlbumReleases.AlbumId = Albums.Id " + - "JOIN Tracks ON Tracks.AlbumReleaseId = AlbumReleases.Id " + - "JOIN TrackFiles ON TrackFiles.Id = Tracks.TrackFileId " + - "WHERE TrackFiles.Id = {0}", - f.Id)).SingleOrDefault()); + .LazyLoad(condition: f => f.Id > 0, query: (db, f) => db.Query() + .Join(JoinType.Inner, a => a.Metadata, (a, m) => a.ArtistMetadataId == m.Id) + .Join(JoinType.Inner, a => a.Albums, (l, r) => l.ArtistMetadataId == r.ArtistMetadataId) + .Where(r => r.Id == f.AlbumId) + .SingleOrDefault()); Mapper.Entity().RegisterModel("QualityDefinitions") .Ignore(d => d.GroupName) diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/CutoffSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/CutoffSpecification.cs index 95f21b7ea..5764f4283 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/CutoffSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/CutoffSpecification.cs @@ -4,6 +4,8 @@ using NLog; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Music; +using NzbDrone.Common.Cache; namespace NzbDrone.Core.DecisionEngine.Specifications { @@ -11,13 +13,21 @@ namespace NzbDrone.Core.DecisionEngine.Specifications { private readonly UpgradableSpecification _upgradableSpecification; private readonly IMediaFileService _mediaFileService; + private readonly ITrackService _trackService; private readonly Logger _logger; - - public CutoffSpecification(UpgradableSpecification upgradableSpecification, Logger logger, IMediaFileService mediaFileService) + private readonly ICached _missingFilesCache; + + public CutoffSpecification(UpgradableSpecification upgradableSpecification, + Logger logger, + ICacheManager cacheManager, + IMediaFileService mediaFileService, + ITrackService trackService) { _upgradableSpecification = upgradableSpecification; _logger = logger; _mediaFileService = mediaFileService; + _trackService = trackService; + _missingFilesCache = cacheManager.GetCache(GetType()); } public SpecificationPriority Priority => SpecificationPriority.Default; @@ -30,9 +40,11 @@ namespace NzbDrone.Core.DecisionEngine.Specifications foreach (var album in subject.Albums) { + var tracksMissing = _missingFilesCache.Get(album.Id.ToString(), () => _trackService.TracksWithoutFiles(album.Id).Any(), + TimeSpan.FromSeconds(30)); var trackFiles = _mediaFileService.GetFilesByAlbum(album.Id); - if (trackFiles.Any()) + if (!tracksMissing && trackFiles.Any()) { var lowestQuality = trackFiles.Select(c => c.Quality).OrderBy(c => c.Quality.Id).First(); diff --git a/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradeDiskSpecification.cs b/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradeDiskSpecification.cs index 34ecfe2dd..083312d67 100644 --- a/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradeDiskSpecification.cs +++ b/src/NzbDrone.Core/DecisionEngine/Specifications/UpgradeDiskSpecification.cs @@ -4,20 +4,30 @@ using NLog; using NzbDrone.Core.IndexerSearch.Definitions; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Music; +using NzbDrone.Common.Cache; namespace NzbDrone.Core.DecisionEngine.Specifications { public class UpgradeDiskSpecification : IDecisionEngineSpecification { private readonly IMediaFileService _mediaFileService; + private readonly ITrackService _trackService; private readonly UpgradableSpecification _upgradableSpecification; private readonly Logger _logger; - - public UpgradeDiskSpecification(UpgradableSpecification qualityUpgradableSpecification, IMediaFileService mediaFileService, Logger logger) + private readonly ICached _missingFilesCache; + + public UpgradeDiskSpecification(UpgradableSpecification qualityUpgradableSpecification, + IMediaFileService mediaFileService, + ITrackService trackService, + ICacheManager cacheManager, + Logger logger) { _upgradableSpecification = qualityUpgradableSpecification; _mediaFileService = mediaFileService; + _trackService = trackService; _logger = logger; + _missingFilesCache = cacheManager.GetCache(GetType()); } public SpecificationPriority Priority => SpecificationPriority.Default; @@ -28,9 +38,11 @@ namespace NzbDrone.Core.DecisionEngine.Specifications foreach (var album in subject.Albums) { + var tracksMissing = _missingFilesCache.Get(album.Id.ToString(), () => _trackService.TracksWithoutFiles(album.Id).Any(), + TimeSpan.FromSeconds(30)); var trackFiles = _mediaFileService.GetFilesByAlbum(album.Id); - if (trackFiles.Any()) + if (!tracksMissing && trackFiles.Any()) { var lowestQuality = trackFiles.Select(c => c.Quality).OrderBy(c => c.Quality.Id).First(); diff --git a/src/NzbDrone.Core/Download/CompletedDownloadService.cs b/src/NzbDrone.Core/Download/CompletedDownloadService.cs index 6e889c771..19c39da7a 100644 --- a/src/NzbDrone.Core/Download/CompletedDownloadService.cs +++ b/src/NzbDrone.Core/Download/CompletedDownloadService.cs @@ -2,7 +2,6 @@ using System; using System.IO; using System.Linq; using NLog; -using NzbDrone.Common.Disk; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; using NzbDrone.Core.Configuration; @@ -13,6 +12,7 @@ using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Parser; using NzbDrone.Core.Music; using NzbDrone.Core.MediaFiles.TrackImport; +using NzbDrone.Core.MediaFiles.Events; namespace NzbDrone.Core.Download { @@ -107,11 +107,14 @@ namespace NzbDrone.Core.Download if (importResults.Empty()) { + trackedDownload.State = TrackedDownloadStage.ImportFailed; trackedDownload.Warn("No files found are eligible for import in {0}", outputPath); + _eventAggregator.PublishEvent(new AlbumImportIncompleteEvent(trackedDownload)); return; } - if (importResults.Count(c => c.Result == ImportResultType.Imported) >= Math.Max(1, trackedDownload.RemoteAlbum.Albums.Count)) + if (importResults.All(c => c.Result == ImportResultType.Imported) + || importResults.Count(c => c.Result == ImportResultType.Imported) >= Math.Max(1, trackedDownload.RemoteAlbum.Albums.Sum(x => x.AlbumReleases.Value.Where(y => y.Monitored).Sum(z => z.TrackCount)))) { trackedDownload.State = TrackedDownloadStage.Imported; _eventAggregator.PublishEvent(new DownloadCompletedEvent(trackedDownload)); @@ -120,14 +123,15 @@ namespace NzbDrone.Core.Download if (importResults.Any(c => c.Result != ImportResultType.Imported)) { + trackedDownload.State = TrackedDownloadStage.ImportFailed; var statusMessages = importResults .Where(v => v.Result != ImportResultType.Imported) - .Select(v => new TrackedDownloadStatusMessage(Path.GetFileName(v.ImportDecision.LocalTrack.Path), v.Errors)) + .Select(v => new TrackedDownloadStatusMessage(Path.GetFileName(v.ImportDecision.Item.Path), v.Errors)) .ToArray(); trackedDownload.Warn(statusMessages); + _eventAggregator.PublishEvent(new AlbumImportIncompleteEvent(trackedDownload)); } - } } } diff --git a/src/NzbDrone.Core/Download/TrackedDownloads/DownloadMonitoringService.cs b/src/NzbDrone.Core/Download/TrackedDownloads/DownloadMonitoringService.cs index b1c27d869..6ee24ed4c 100644 --- a/src/NzbDrone.Core/Download/TrackedDownloads/DownloadMonitoringService.cs +++ b/src/NzbDrone.Core/Download/TrackedDownloads/DownloadMonitoringService.cs @@ -152,7 +152,8 @@ namespace NzbDrone.Core.Download.TrackedDownloads private bool DownloadIsTrackable(TrackedDownload trackedDownload) { // If the download has already been imported or failed don't track it - if (trackedDownload.State != TrackedDownloadStage.Downloading) + if (trackedDownload.State == TrackedDownloadStage.DownloadFailed + || trackedDownload.State == TrackedDownloadStage.Imported) { return false; } diff --git a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownload.cs b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownload.cs index 560ed0e01..14d969942 100644 --- a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownload.cs +++ b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownload.cs @@ -36,8 +36,10 @@ namespace NzbDrone.Core.Download.TrackedDownloads public enum TrackedDownloadStage { Downloading, - Imported, - DownloadFailed + DownloadFailed, + Importing, + ImportFailed, + Imported } public enum TrackedDownloadStatus diff --git a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs index a8a64c1f8..66d02f11c 100644 --- a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs +++ b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadService.cs @@ -8,6 +8,7 @@ using NzbDrone.Core.History; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Music; using NzbDrone.Core.Parser; +using NzbDrone.Common.Serializer; namespace NzbDrone.Core.Download.TrackedDownloads { @@ -104,7 +105,12 @@ namespace NzbDrone.Core.Download.TrackedDownloads if (historyItems.Any()) { var firstHistoryItem = historyItems.OrderByDescending(h => h.Date).First(); - trackedDownload.State = GetStateFromHistory(firstHistoryItem.EventType); + trackedDownload.State = GetStateFromHistory(firstHistoryItem); + if (firstHistoryItem.EventType == HistoryEventType.AlbumImportIncomplete) + { + var messages = Json.Deserialize>(firstHistoryItem?.Data["statusMessages"]).ToArray(); + trackedDownload.Warn(messages); + } var grabbedEvent = historyItems.FirstOrDefault(v => v.EventType == HistoryEventType.Grabbed); trackedDownload.Indexer = grabbedEvent?.Data["indexer"]; @@ -186,7 +192,7 @@ namespace NzbDrone.Core.Download.TrackedDownloads existingItem.CanBeRemoved != downloadItem.CanBeRemoved || existingItem.CanMoveFiles != downloadItem.CanMoveFiles) { - _logger.Debug("Tracking '{0}:{1}': ClientState={2}{3} SonarrStage={4} Episode='{5}' OutputPath={6}.", + _logger.Debug("Tracking '{0}:{1}': ClientState={2}{3} LidarrStage={4} Album='{5}' OutputPath={6}.", downloadItem.DownloadClient, downloadItem.Title, downloadItem.Status, downloadItem.CanBeRemoved ? "" : downloadItem.CanMoveFiles ? " (busy)" : " (readonly)", @@ -197,17 +203,25 @@ namespace NzbDrone.Core.Download.TrackedDownloads } - private static TrackedDownloadStage GetStateFromHistory(HistoryEventType eventType) + private static TrackedDownloadStage GetStateFromHistory(NzbDrone.Core.History.History history) { - switch (eventType) + switch (history.EventType) { - case HistoryEventType.DownloadFolderImported: + case HistoryEventType.AlbumImportIncomplete: + return TrackedDownloadStage.ImportFailed; + case HistoryEventType.DownloadImported: return TrackedDownloadStage.Imported; case HistoryEventType.DownloadFailed: return TrackedDownloadStage.DownloadFailed; - default: - return TrackedDownloadStage.Downloading; } + + // Since DownloadComplete is a new event type, we can't assume it exists for old downloads + if (history.EventType == HistoryEventType.TrackFileImported) + { + return DateTime.UtcNow.Subtract(history.Date).TotalSeconds < 60 ? TrackedDownloadStage.Importing : TrackedDownloadStage.Imported; + } + + return TrackedDownloadStage.Downloading; } } } diff --git a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadStatusMessage.cs b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadStatusMessage.cs index e0537b8c3..3e8e5164d 100644 --- a/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadStatusMessage.cs +++ b/src/NzbDrone.Core/Download/TrackedDownloads/TrackedDownloadStatusMessage.cs @@ -21,7 +21,7 @@ namespace NzbDrone.Core.Download.TrackedDownloads } //Constructor for use when deserializing JSON - private TrackedDownloadStatusMessage() + public TrackedDownloadStatusMessage() { } } diff --git a/src/NzbDrone.Core/Extras/Lyrics/ExistingLyricImporter.cs b/src/NzbDrone.Core/Extras/Lyrics/ExistingLyricImporter.cs index 7bf9f2998..5b54e90f9 100644 --- a/src/NzbDrone.Core/Extras/Lyrics/ExistingLyricImporter.cs +++ b/src/NzbDrone.Core/Extras/Lyrics/ExistingLyricImporter.cs @@ -6,22 +6,24 @@ using NzbDrone.Common.Extensions; using NzbDrone.Core.Extras.Files; using NzbDrone.Core.Parser; using NzbDrone.Core.Music; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.MediaFiles.TrackImport.Aggregation; namespace NzbDrone.Core.Extras.Lyrics { public class ExistingLyricImporter : ImportExistingExtraFilesBase { private readonly IExtraFileService _lyricFileService; - private readonly IParsingService _parsingService; + private readonly IAugmentingService _augmentingService; private readonly Logger _logger; public ExistingLyricImporter(IExtraFileService lyricFileService, - IParsingService parsingService, - Logger logger) + IAugmentingService augmentingService, + Logger logger) : base (lyricFileService) { _lyricFileService = lyricFileService; - _parsingService = parsingService; + _augmentingService = augmentingService; _logger = logger; } @@ -34,29 +36,38 @@ namespace NzbDrone.Core.Extras.Lyrics var subtitleFiles = new List(); var filterResult = FilterAndClean(artist, filesOnDisk, importedFiles); - foreach (var possibleSubtitleFile in filterResult.FilesOnDisk) + foreach (var possibleLyricFile in filterResult.FilesOnDisk) { - var extension = Path.GetExtension(possibleSubtitleFile); + var extension = Path.GetExtension(possibleLyricFile); if (LyricFileExtensions.Extensions.Contains(extension)) { - var localTrack = _parsingService.GetLocalTrack(possibleSubtitleFile, artist); + var localTrack = new LocalTrack + { + FileTrackInfo = Parser.Parser.ParseMusicPath(possibleLyricFile), + Artist = artist, + Path = possibleLyricFile + }; - if (localTrack == null) + try + { + _augmentingService.Augment(localTrack, false); + } + catch (AugmentingFailedException) { - _logger.Debug("Unable to parse lyric file: {0}", possibleSubtitleFile); + _logger.Debug("Unable to parse lyric file: {0}", possibleLyricFile); continue; } if (localTrack.Tracks.Empty()) { - _logger.Debug("Cannot find related tracks for: {0}", possibleSubtitleFile); + _logger.Debug("Cannot find related tracks for: {0}", possibleLyricFile); continue; } if (localTrack.Tracks.DistinctBy(e => e.TrackFileId).Count() > 1) { - _logger.Debug("Lyric file: {0} does not match existing files.", possibleSubtitleFile); + _logger.Debug("Lyric file: {0} does not match existing files.", possibleLyricFile); continue; } @@ -65,8 +76,8 @@ namespace NzbDrone.Core.Extras.Lyrics ArtistId = artist.Id, AlbumId = localTrack.Album.Id, TrackFileId = localTrack.Tracks.First().TrackFileId, - RelativePath = artist.Path.GetRelativePath(possibleSubtitleFile), - Language = LanguageParser.ParseSubtitleLanguage(possibleSubtitleFile), + RelativePath = artist.Path.GetRelativePath(possibleLyricFile), + Language = LanguageParser.ParseSubtitleLanguage(possibleLyricFile), Extension = extension }; diff --git a/src/NzbDrone.Core/Extras/Metadata/ExistingMetadataImporter.cs b/src/NzbDrone.Core/Extras/Metadata/ExistingMetadataImporter.cs index 363f85c58..03fbe8a91 100644 --- a/src/NzbDrone.Core/Extras/Metadata/ExistingMetadataImporter.cs +++ b/src/NzbDrone.Core/Extras/Metadata/ExistingMetadataImporter.cs @@ -8,6 +8,8 @@ using NzbDrone.Core.Extras.Metadata.Files; using NzbDrone.Core.Extras.Lyrics; using NzbDrone.Core.Parser; using NzbDrone.Core.Music; +using NzbDrone.Core.MediaFiles.TrackImport.Aggregation; +using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.Extras.Metadata { @@ -15,17 +17,20 @@ namespace NzbDrone.Core.Extras.Metadata { private readonly IExtraFileService _metadataFileService; private readonly IParsingService _parsingService; + private readonly IAugmentingService _augmentingService; private readonly Logger _logger; private readonly List _consumers; public ExistingMetadataImporter(IExtraFileService metadataFileService, IEnumerable consumers, IParsingService parsingService, + IAugmentingService augmentingService, Logger logger) : base(metadataFileService) { _metadataFileService = metadataFileService; _parsingService = parsingService; + _augmentingService = augmentingService; _logger = logger; _consumers = consumers.ToList(); } @@ -72,9 +77,18 @@ namespace NzbDrone.Core.Extras.Metadata if (metadata.Type == MetadataType.TrackMetadata) { - var localTrack = _parsingService.GetLocalTrack(possibleMetadataFile, artist); - - if (localTrack == null) + var localTrack = new LocalTrack + { + FileTrackInfo = Parser.Parser.ParseMusicPath(possibleMetadataFile), + Artist = artist, + Path = possibleMetadataFile + }; + + try + { + _augmentingService.Augment(localTrack, false); + } + catch (AugmentingFailedException) { _logger.Debug("Unable to parse extra file: {0}", possibleMetadataFile); continue; diff --git a/src/NzbDrone.Core/Extras/Others/ExistingOtherExtraImporter.cs b/src/NzbDrone.Core/Extras/Others/ExistingOtherExtraImporter.cs index 95abfebdc..e63baf28a 100644 --- a/src/NzbDrone.Core/Extras/Others/ExistingOtherExtraImporter.cs +++ b/src/NzbDrone.Core/Extras/Others/ExistingOtherExtraImporter.cs @@ -1,27 +1,30 @@ +using System; using System.Collections.Generic; using System.IO; using System.Linq; using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Core.Extras.Files; +using NzbDrone.Core.MediaFiles.TrackImport.Aggregation; using NzbDrone.Core.Parser; using NzbDrone.Core.Music; +using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.Extras.Others { public class ExistingOtherExtraImporter : ImportExistingExtraFilesBase { private readonly IExtraFileService _otherExtraFileService; - private readonly IParsingService _parsingService; + private readonly IAugmentingService _augmentingService; private readonly Logger _logger; public ExistingOtherExtraImporter(IExtraFileService otherExtraFileService, - IParsingService parsingService, + IAugmentingService augmentingService, Logger logger) : base(otherExtraFileService) { _otherExtraFileService = otherExtraFileService; - _parsingService = parsingService; + _augmentingService = augmentingService; _logger = logger; } @@ -44,9 +47,18 @@ namespace NzbDrone.Core.Extras.Others continue; } - var localTrack = _parsingService.GetLocalTrack(possibleExtraFile, artist); - - if (localTrack == null) + var localTrack = new LocalTrack + { + FileTrackInfo = Parser.Parser.ParseMusicPath(possibleExtraFile), + Artist = artist, + Path = possibleExtraFile + }; + + try + { + _augmentingService.Augment(localTrack, false); + } + catch (AugmentingFailedException) { _logger.Debug("Unable to parse extra file: {0}", possibleExtraFile); continue; diff --git a/src/NzbDrone.Core/HealthCheck/Checks/FpcalcCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/FpcalcCheck.cs new file mode 100644 index 000000000..156a4946f --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/Checks/FpcalcCheck.cs @@ -0,0 +1,43 @@ +using System; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.Configuration.Events; +using NzbDrone.Core.Parser; + +namespace NzbDrone.Core.HealthCheck.Checks +{ + [CheckOn(typeof(ConfigSavedEvent))] + public class FpcalcCheck : HealthCheckBase + { + private readonly IFingerprintingService _fingerprintingService; + private readonly IConfigService _configService; + + public FpcalcCheck(IFingerprintingService fingerprintingService, + IConfigService configService) + { + _fingerprintingService = fingerprintingService; + _configService = configService; + } + + public override HealthCheck Check() + { + // always pass if fingerprinting is disabled + if (_configService.AllowFingerprinting == AllowFingerprinting.Never) + { + return new HealthCheck(GetType()); + } + + if (!_fingerprintingService.IsSetup()) + { + return new HealthCheck(GetType(), HealthCheckResult.Warning, $"fpcalc could not be found. Audio fingerprinting disabled.", "#fpcalc-missing"); + } + + var fpcalcVersion = _fingerprintingService.FpcalcVersion(); + if (fpcalcVersion == null || fpcalcVersion < new Version("1.4.3")) + { + return new HealthCheck(GetType(), HealthCheckResult.Warning, $"You have an old version of fpcalc. Please upgrade to 1.4.3.", "#fpcalc-upgrade"); + } + + return new HealthCheck(GetType()); + } + } +} diff --git a/src/NzbDrone.Core/HealthCheck/Checks/MediaInfoDllCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/MediaInfoDllCheck.cs deleted file mode 100644 index badc522f4..000000000 --- a/src/NzbDrone.Core/HealthCheck/Checks/MediaInfoDllCheck.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System; -using System.Runtime.CompilerServices; -using NzbDrone.Core.MediaFiles.MediaInfo; - -namespace NzbDrone.Core.HealthCheck.Checks -{ - public class MediaInfoDllCheck : HealthCheckBase - { - [MethodImpl(MethodImplOptions.NoOptimization)] - public override HealthCheck Check() - { - try - { - var mediaInfo = new MediaInfo(); - } - catch (Exception e) - { - return new HealthCheck(GetType(), HealthCheckResult.Warning, $"MediaInfo Library could not be loaded {e.Message}"); - } - - return new HealthCheck(GetType()); - } - } -} diff --git a/src/NzbDrone.Core/History/History.cs b/src/NzbDrone.Core/History/History.cs index b448481d4..1ac17e01a 100644 --- a/src/NzbDrone.Core/History/History.cs +++ b/src/NzbDrone.Core/History/History.cs @@ -38,9 +38,11 @@ namespace NzbDrone.Core.History Unknown = 0, Grabbed = 1, ArtistFolderImported = 2, - DownloadFolderImported = 3, + TrackFileImported = 3, DownloadFailed = 4, TrackFileDeleted = 5, - TrackFileRenamed = 6 + TrackFileRenamed = 6, + AlbumImportIncomplete = 7, + DownloadImported = 8 } } diff --git a/src/NzbDrone.Core/History/HistoryRepository.cs b/src/NzbDrone.Core/History/HistoryRepository.cs index 1cdaa7986..223266158 100644 --- a/src/NzbDrone.Core/History/HistoryRepository.cs +++ b/src/NzbDrone.Core/History/HistoryRepository.cs @@ -89,7 +89,7 @@ namespace NzbDrone.Core.History h.Quality == quality && (h.EventType == HistoryEventType.Grabbed || h.EventType == HistoryEventType.DownloadFailed || - h.EventType == HistoryEventType.DownloadFolderImported) + h.EventType == HistoryEventType.TrackFileImported) ).ToList(); } diff --git a/src/NzbDrone.Core/History/HistoryService.cs b/src/NzbDrone.Core/History/HistoryService.cs index 14ff4a38d..4cd8febc3 100644 --- a/src/NzbDrone.Core/History/HistoryService.cs +++ b/src/NzbDrone.Core/History/HistoryService.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; using System.IO; using System.Linq; -using Marr.Data.QGen; using NLog; using NzbDrone.Common.Extensions; using NzbDrone.Core.Datastore; @@ -11,11 +10,10 @@ using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Profiles.Qualities; -using NzbDrone.Core.Profiles.Languages; using NzbDrone.Core.Languages; -using NzbDrone.Core.Music; using NzbDrone.Core.Music.Events; +using NzbDrone.Core.Qualities; +using NzbDrone.Common.Serializer; namespace NzbDrone.Core.History { @@ -34,8 +32,10 @@ namespace NzbDrone.Core.History public class HistoryService : IHistoryService, IHandle, + IHandle, IHandle, IHandle, + IHandle, IHandle, IHandle, IHandle @@ -180,6 +180,27 @@ namespace NzbDrone.Core.History } } + public void Handle(AlbumImportIncompleteEvent message) + { + foreach (var album in message.TrackedDownload.RemoteAlbum.Albums) + { + var history = new History + { + EventType = HistoryEventType.AlbumImportIncomplete, + Date = DateTime.UtcNow, + Quality = message.TrackedDownload.RemoteAlbum.ParsedAlbumInfo?.Quality ?? new QualityModel(), + SourceTitle = message.TrackedDownload.DownloadItem.Title, + ArtistId = album.ArtistId, + AlbumId = album.Id, + DownloadId = message.TrackedDownload.DownloadItem.DownloadId, + Language = message.TrackedDownload.RemoteAlbum.ParsedAlbumInfo?.Language ?? Language.English + }; + + history.Data.Add("StatusMessages", message.TrackedDownload.StatusMessages.ToJson()); + _historyRepository.Insert(history); + } + } + public void Handle(TrackImportedEvent message) { if (!message.NewDownload) @@ -198,7 +219,7 @@ namespace NzbDrone.Core.History { var history = new History { - EventType = HistoryEventType.DownloadFolderImported, + EventType = HistoryEventType.TrackFileImported, Date = DateTime.UtcNow, Quality = message.TrackInfo.Quality, SourceTitle = message.ImportedTrack.SceneName ?? Path.GetFileNameWithoutExtension(message.TrackInfo.Path), @@ -242,6 +263,26 @@ namespace NzbDrone.Core.History } } + public void Handle(DownloadCompletedEvent message) + { + foreach (var album in message.TrackedDownload.RemoteAlbum.Albums) + { + var history = new History + { + EventType = HistoryEventType.DownloadImported, + Date = DateTime.UtcNow, + Quality = message.TrackedDownload.RemoteAlbum.ParsedAlbumInfo?.Quality ?? new QualityModel(), + SourceTitle = message.TrackedDownload.DownloadItem.Title, + ArtistId = album.ArtistId, + AlbumId = album.Id, + DownloadId = message.TrackedDownload.DownloadItem.DownloadId, + Language = message.TrackedDownload.RemoteAlbum.ParsedAlbumInfo?.Language ?? Language.English + }; + + _historyRepository.Insert(history); + } + } + public void Handle(TrackFileDeletedEvent message) { if (message.Reason == DeleteMediaFileReason.NoLinkedEpisodes) @@ -264,7 +305,7 @@ namespace NzbDrone.Core.History Date = DateTime.UtcNow, Quality = message.TrackFile.Quality, SourceTitle = message.TrackFile.Path, - ArtistId = message.TrackFile.ArtistId, + ArtistId = message.TrackFile.Artist.Value.Id, AlbumId = message.TrackFile.AlbumId, TrackId = track.Id, }; @@ -290,7 +331,7 @@ namespace NzbDrone.Core.History Date = DateTime.UtcNow, Quality = message.TrackFile.Quality, SourceTitle = message.OriginalPath, - ArtistId = message.TrackFile.ArtistId, + ArtistId = message.TrackFile.Artist.Value.Id, AlbumId = message.TrackFile.AlbumId, TrackId = track.Id, }; diff --git a/src/NzbDrone.Core/Lidarr.Core.dll.config b/src/NzbDrone.Core/Lidarr.Core.dll.config deleted file mode 100644 index a139791b4..000000000 --- a/src/NzbDrone.Core/Lidarr.Core.dll.config +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/src/NzbDrone.Core/MediaFiles/DiskScanService.cs b/src/NzbDrone.Core/MediaFiles/DiskScanService.cs index 36a4b33bf..774198772 100644 --- a/src/NzbDrone.Core/MediaFiles/DiskScanService.cs +++ b/src/NzbDrone.Core/MediaFiles/DiskScanService.cs @@ -17,6 +17,8 @@ using NzbDrone.Core.RootFolders; using NzbDrone.Core.Music; using NzbDrone.Core.Music.Events; using NzbDrone.Core.MediaFiles.TrackImport; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Common; namespace NzbDrone.Core.MediaFiles { @@ -33,6 +35,7 @@ namespace NzbDrone.Core.MediaFiles IExecute { private readonly IDiskProvider _diskProvider; + private readonly IMediaFileService _mediaFileService; private readonly IMakeImportDecision _importDecisionMaker; private readonly IImportApprovedTracks _importApprovedTracks; private readonly IConfigService _configService; @@ -43,6 +46,7 @@ namespace NzbDrone.Core.MediaFiles private readonly Logger _logger; public DiskScanService(IDiskProvider diskProvider, + IMediaFileService mediaFileService, IMakeImportDecision importDecisionMaker, IImportApprovedTracks importApprovedTracks, IConfigService configService, @@ -53,6 +57,7 @@ namespace NzbDrone.Core.MediaFiles Logger logger) { _diskProvider = diskProvider; + _mediaFileService = mediaFileService; _importDecisionMaker = importDecisionMaker; _importApprovedTracks = importApprovedTracks; _configService = configService; @@ -114,11 +119,36 @@ namespace NzbDrone.Core.MediaFiles var decisionsStopwatch = Stopwatch.StartNew(); var decisions = _importDecisionMaker.GetImportDecisions(mediaFileList, artist); decisionsStopwatch.Stop(); - _logger.Trace("Import decisions complete for: {0} [{1}]", artist, decisionsStopwatch.Elapsed); + _logger.Debug("Import decisions complete for: {0} [{1}]", artist, decisionsStopwatch.Elapsed); + + var importStopwatch = Stopwatch.StartNew(); _importApprovedTracks.Import(decisions, false); - RemoveEmptyArtistFolder(artist.Path); + UpdateMediaInfo(artist, decisions.Select(x => x.Item).ToList()); CompletedScanning(artist); + importStopwatch.Stop(); + _logger.Debug("Track import complete for: {0} [{1}]", artist, importStopwatch.Elapsed); + } + + private void UpdateMediaInfo(Artist artist, List mediaFiles) + { + var existingFiles = _mediaFileService.GetFilesByArtist(artist.Id); + var toUpdate = new List(existingFiles.Count); + + foreach (var file in existingFiles) + { + var path = Path.Combine(artist.Path, file.RelativePath); + var scannedFile = mediaFiles.FirstOrDefault(x => PathEqualityComparer.Instance.Equals(path, x.Path)); + + if (scannedFile != null) + { + file.MediaInfo = scannedFile.FileTrackInfo.MediaInfo; + toUpdate.Add(file); + } + } + + _logger.Debug($"Updating Media Info for:\n{string.Join("\n", toUpdate)}"); + _mediaFileService.UpdateMediaInfo(toUpdate); } private void CleanMediaFiles(Artist artist, List mediaFileList) diff --git a/src/NzbDrone.Core/MediaFiles/DownloadedTracksImportService.cs b/src/NzbDrone.Core/MediaFiles/DownloadedTracksImportService.cs index 84af90739..9f94163a0 100644 --- a/src/NzbDrone.Core/MediaFiles/DownloadedTracksImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/DownloadedTracksImportService.cs @@ -232,7 +232,7 @@ namespace NzbDrone.Core.MediaFiles return new List { - new ImportResult(new ImportDecision(new LocalTrack { Path = fileInfo.FullName }, new Rejection("Invalid music file, filename starts with '._'")), "Invalid music file, filename starts with '._'") + new ImportResult(new ImportDecision(new LocalTrack { Path = fileInfo.FullName }, new Rejection("Invalid music file, filename starts with '._'")), "Invalid music file, filename starts with '._'") }; } @@ -263,14 +263,14 @@ namespace NzbDrone.Core.MediaFiles private ImportResult FileIsLockedResult(string audioFile) { _logger.Debug("[{0}] is currently locked by another process, skipping", audioFile); - return new ImportResult(new ImportDecision(new LocalTrack { Path = audioFile }, new Rejection("Locked file, try again later")), "Locked file, try again later"); + return new ImportResult(new ImportDecision(new LocalTrack { Path = audioFile }, new Rejection("Locked file, try again later")), "Locked file, try again later"); } private ImportResult UnknownArtistResult(string message, string audioFile = null) { var localTrack = audioFile == null ? null : new LocalTrack { Path = audioFile }; - return new ImportResult(new ImportDecision(localTrack, new Rejection("Unknown Artist")), message); + return new ImportResult(new ImportDecision(localTrack, new Rejection("Unknown Artist")), message); } } } diff --git a/src/NzbDrone.Core/MediaFiles/Events/AlbumImportIncompleteEvent.cs b/src/NzbDrone.Core/MediaFiles/Events/AlbumImportIncompleteEvent.cs new file mode 100644 index 000000000..08167337a --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/Events/AlbumImportIncompleteEvent.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; +using NzbDrone.Common.Messaging; +using NzbDrone.Core.Download.TrackedDownloads; +using NzbDrone.Core.Music; + +namespace NzbDrone.Core.MediaFiles.Events +{ + public class AlbumImportIncompleteEvent : IEvent + { + public TrackedDownload TrackedDownload { get; private set; } + + public AlbumImportIncompleteEvent(TrackedDownload trackedDownload) + { + TrackedDownload = trackedDownload; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileRepository.cs b/src/NzbDrone.Core/MediaFiles/MediaFileRepository.cs index 3f5c70a3d..ffc79a866 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileRepository.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileRepository.cs @@ -1,8 +1,10 @@ using System; using System.Collections.Generic; +using System.Linq; +using Marr.Data.QGen; using NzbDrone.Core.Datastore; using NzbDrone.Core.Messaging.Events; - +using NzbDrone.Core.Music; namespace NzbDrone.Core.MediaFiles { @@ -10,7 +12,6 @@ namespace NzbDrone.Core.MediaFiles { List GetFilesByArtist(int artistId); List GetFilesByAlbum(int albumId); - List GetFilesWithoutMediaInfo(); List GetFilesWithRelativePath(int artistId, string relativePath); } @@ -22,48 +23,39 @@ namespace NzbDrone.Core.MediaFiles { } - public List GetFilesWithoutMediaInfo() - { - return Query.Where(c => c.MediaInfo == null).ToList(); - } + // always join with all the other good stuff + // needed more often than not so better to load it all now + protected override QueryBuilder Query => + DataMapper.Query() + .Join(JoinType.Inner, t => t.Tracks, (t, x) => t.Id == x.TrackFileId) + .Join(JoinType.Inner, t => t.Album, (t, a) => t.AlbumId == a.Id) + .Join(JoinType.Inner, t => t.Artist, (t, a) => t.Album.Value.ArtistMetadataId == a.ArtistMetadataId) + .Join(JoinType.Inner, a => a.Metadata, (a, m) => a.ArtistMetadataId == m.Id); public List GetFilesByArtist(int artistId) { - string query = string.Format("SELECT TrackFiles.* " + - "FROM Artists " + - "JOIN Albums ON Albums.ArtistMetadataId = Artists.ArtistMetadataId " + - "JOIN AlbumReleases ON AlbumReleases.AlbumId == Albums.Id " + - "JOIN Tracks ON Tracks.AlbumReleaseId == AlbumReleases.Id " + - "JOIN TrackFiles ON TrackFiles.Id == Tracks.TrackFileId " + - "WHERE Artists.Id == {0} " + - "AND AlbumReleases.Monitored = 1", - artistId); - - return Query.QueryText(query).ToList(); + return Query + .Join(JoinType.Inner, a => a.AlbumReleases, (a, r) => a.Id == r.AlbumId) + .Where(r => r.Monitored == true) + .AndWhere(t => t.Artist.Value.Id == artistId) + .ToList(); } public List GetFilesByAlbum(int albumId) { - return Query.Where(c => c.AlbumId == albumId).ToList(); + return Query + .Where(f => f.AlbumId == albumId) + .ToList(); } public List GetFilesWithRelativePath(int artistId, string relativePath) { - var mapper = DataMapper; - mapper.AddParameter("artistId", artistId); - mapper.AddParameter("relativePath", relativePath); - string query = "SELECT TrackFiles.* " + - "FROM Artists " + - "JOIN Albums ON Albums.ArtistMetadataId = Artists.ArtistMetadataId " + - "JOIN AlbumReleases ON AlbumReleases.AlbumId == Albums.Id " + - "JOIN Tracks ON Tracks.AlbumReleaseId == AlbumReleases.Id " + - "JOIN TrackFiles ON TrackFiles.Id == Tracks.TrackFileId " + - "WHERE Artists.Id == @artistId " + - "AND AlbumReleases.Monitored = 1 " + - "AND TrackFiles.RelativePath == @relativePath"; - - return mapper.Query(query); + return Query + .Join(JoinType.Inner, a => a.AlbumReleases, (a, r) => a.Id == r.AlbumId) + .Where(r => r.Monitored == true) + .AndWhere(t => t.Artist.Value.Id == artistId) + .AndWhere(t => t.RelativePath == relativePath) + .ToList(); } - } } diff --git a/src/NzbDrone.Core/MediaFiles/MediaFileService.cs b/src/NzbDrone.Core/MediaFiles/MediaFileService.cs index d18fbecb4..4c0f35c0f 100644 --- a/src/NzbDrone.Core/MediaFiles/MediaFileService.cs +++ b/src/NzbDrone.Core/MediaFiles/MediaFileService.cs @@ -14,18 +14,17 @@ namespace NzbDrone.Core.MediaFiles public interface IMediaFileService { TrackFile Add(TrackFile trackFile); + void AddMany(List trackFiles); void Update(TrackFile trackFile); void Update(List trackFile); void Delete(TrackFile trackFile, DeleteMediaFileReason reason); List GetFilesByArtist(int artistId); List GetFilesByAlbum(int albumId); - List GetFiles(IEnumerable ids); - List GetFilesWithoutMediaInfo(); List FilterExistingFiles(List files, Artist artist); TrackFile Get(int id); List Get(IEnumerable ids); List GetFilesWithRelativePath(int artistId, string relativePath); - + void UpdateMediaInfo(List trackFiles); } public class MediaFileService : IMediaFileService, IHandleAsync @@ -48,6 +47,15 @@ namespace NzbDrone.Core.MediaFiles return addedFile; } + public void AddMany(List trackFiles) + { + _mediaFileRepository.InsertMany(trackFiles); + foreach (var addedFile in trackFiles) + { + _eventAggregator.PublishEvent(new TrackFileAddedEvent(addedFile)); + } + } + public void Update(TrackFile trackFile) { _mediaFileRepository.Update(trackFile); @@ -61,25 +69,12 @@ namespace NzbDrone.Core.MediaFiles public void Delete(TrackFile trackFile, DeleteMediaFileReason reason) { - //Little hack so we have the tracks and artist attached for the event consumers - trackFile.Tracks.LazyLoad(); trackFile.Path = Path.Combine(trackFile.Artist.Value.Path, trackFile.RelativePath); _mediaFileRepository.Delete(trackFile); _eventAggregator.PublishEvent(new TrackFileDeletedEvent(trackFile, reason)); } - public List GetFiles(IEnumerable ids) - { - return _mediaFileRepository.Get(ids).ToList(); - } - - - public List GetFilesWithoutMediaInfo() - { - return _mediaFileRepository.GetFilesWithoutMediaInfo(); - } - public List FilterExistingFiles(List files, Artist artist) { var artistFiles = GetFilesByArtist(artist.Id).Select(f => Path.Combine(artist.Path, f.RelativePath)).ToList(); @@ -119,5 +114,10 @@ namespace NzbDrone.Core.MediaFiles { return _mediaFileRepository.GetFilesByAlbum(albumId); } + + public void UpdateMediaInfo(List trackFiles) + { + _mediaFileRepository.SetFields(trackFiles, t => t.MediaInfo); + } } } diff --git a/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoFormatter.cs b/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoFormatter.cs deleted file mode 100644 index d493a1da2..000000000 --- a/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoFormatter.cs +++ /dev/null @@ -1,244 +0,0 @@ -using System; -using System.Globalization; -using System.IO; -using System.Linq; -using System.Text; -using NLog; -using NLog.Fluent; -using NzbDrone.Common.Extensions; -using NzbDrone.Common.Instrumentation; -using NzbDrone.Common.Instrumentation.Extensions; - -namespace NzbDrone.Core.MediaFiles.MediaInfo -{ - public static class MediaInfoFormatter - { - private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(MediaInfoFormatter)); - - public static string FormatAudioBitrate(MediaInfoModel mediaInfo) - { - int audioBitrate = mediaInfo.AudioBitrate / 1000; - - return audioBitrate + " kbps"; - } - - public static decimal FormatAudioChannels(MediaInfoModel mediaInfo) - { - var audioChannelPositions = mediaInfo.AudioChannelPositions; - var audioChannelPositionsText = mediaInfo.AudioChannelPositionsText; - var audioChannels = mediaInfo.AudioChannels; - - if (audioChannelPositions.IsNullOrWhiteSpace()) - { - if (audioChannelPositionsText.IsNullOrWhiteSpace()) - { - if (mediaInfo.SchemaRevision >= 3) - { - return audioChannels; - } - - return 0; - } - - return mediaInfo.AudioChannelPositionsText.ContainsIgnoreCase("LFE") ? audioChannels - 1 + 0.1m : audioChannels; - } - - if (audioChannelPositions.Contains("+")) - { - return audioChannelPositions.Split('+') - .Sum(s => decimal.Parse(s.Trim(), CultureInfo.InvariantCulture)); - } - - return audioChannelPositions.Replace("Object Based / ", "") - .Split(new[] { " / " }, StringSplitOptions.RemoveEmptyEntries) - .First() - .Split('/') - .Sum(s => decimal.Parse(s, CultureInfo.InvariantCulture)); - } - - public static string FormatAudioCodec(MediaInfoModel mediaInfo) - { - if (mediaInfo.AudioCodecID == null) - { - return FormatAudioCodecLegacy(mediaInfo); - } - - var audioFormat = mediaInfo.AudioFormat; - var audioCodecID = mediaInfo.AudioCodecID ?? string.Empty; - var audioProfile = mediaInfo.AudioProfile ?? string.Empty; - var audioCodecLibrary = mediaInfo.AudioCodecLibrary ?? string.Empty; - - if (audioFormat.IsNullOrWhiteSpace()) - { - return string.Empty; - } - - if (audioFormat.EqualsIgnoreCase("ALAC")) - { - return "ALAC"; - } - - if (audioFormat.EqualsIgnoreCase("AC-3")) - { - return "AC3"; - } - - if (audioFormat.EqualsIgnoreCase("E-AC-3")) - { - return "EAC3"; - } - - if (audioFormat.EqualsIgnoreCase("AAC")) - { - if (audioCodecID == "A_AAC/MPEG4/LC/SBR") - { - return "HE-AAC"; - } - - return "AAC"; - } - - if (audioFormat.EqualsIgnoreCase("DTS")) - { - return "DTS"; - } - - if (audioFormat.EqualsIgnoreCase("FLAC")) - { - return "FLAC"; - } - - if (audioFormat.Trim().EqualsIgnoreCase("MLP")) - { - return "MLP"; - } - - if (audioFormat.Trim().EqualsIgnoreCase("Monkey's Audio")) - { - return "APE"; - } - - if (audioFormat.Trim().EqualsIgnoreCase("mp3")) - { - return "MP3"; - } - - if (audioFormat.EqualsIgnoreCase("MPEG Audio")) - { - if (mediaInfo.AudioCodecID == "55" || mediaInfo.AudioCodecID == "A_MPEG/L3" || mediaInfo.AudioProfile == "Layer 3") - { - return "MP3"; - } - - if (mediaInfo.AudioCodecID == "A_MPEG/L2" || mediaInfo.AudioProfile == "Layer 2") - { - return "MP2"; - } - } - - if (audioFormat.EqualsIgnoreCase("Opus")) - { - return "Opus"; - } - - if (audioFormat.EqualsIgnoreCase("PCM")) - { - return "PCM"; - } - - if (audioFormat.EqualsIgnoreCase("TrueHD")) - { - return "TrueHD"; - } - - if (audioFormat.EqualsIgnoreCase("Vorbis")) - { - return "Vorbis"; - } - - if (audioFormat.EqualsIgnoreCase("WavPack")) - { - return "WavPack"; - } - - if (audioFormat == "WMA") - { - return "WMA"; - } - - Logger.Debug() - .Message("Unknown audio format: '{0}'.", string.Join(", ", audioFormat, audioCodecID, audioProfile, audioCodecLibrary)) - .WriteSentryWarn("UnknownAudioFormat", mediaInfo.ContainerFormat, audioFormat, audioCodecID) - .Write(); - - return audioFormat; - } - - public static string FormatAudioCodecLegacy(MediaInfoModel mediaInfo) - { - var audioFormat = mediaInfo.AudioFormat; - - if (audioFormat.IsNullOrWhiteSpace()) - { - return audioFormat; - } - - if (audioFormat.EqualsIgnoreCase("AC-3")) - { - return "AC3"; - } - - if (audioFormat.EqualsIgnoreCase("ALAC")) - { - return "ALAC"; - } - - if (audioFormat.EqualsIgnoreCase("E-AC-3")) - { - return "EAC3"; - } - - if (audioFormat.EqualsIgnoreCase("AAC")) - { - return "AAC"; - } - - if (audioFormat.EqualsIgnoreCase("MPEG Audio") && mediaInfo.AudioProfile == "Layer 3") - { - return "MP3"; - } - - if (audioFormat.EqualsIgnoreCase("MLP")) - { - return "MLP"; - } - - if (audioFormat.EqualsIgnoreCase("DTS")) - { - return "DTS"; - } - - if (audioFormat.EqualsIgnoreCase("TrueHD")) - { - return "TrueHD"; - } - - if (audioFormat.EqualsIgnoreCase("FLAC")) - { - return "FLAC"; - } - - if (audioFormat.EqualsIgnoreCase("Vorbis")) - { - return "Vorbis"; - } - - if (audioFormat.EqualsIgnoreCase("Opus")) - { - return "Opus"; - } - - return audioFormat; - } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoLib.cs b/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoLib.cs deleted file mode 100644 index 9e55bd2da..000000000 --- a/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoLib.cs +++ /dev/null @@ -1,361 +0,0 @@ -using System; -using System.Diagnostics.CodeAnalysis; -using System.IO; -using System.Runtime.InteropServices; -using System.Text; -using NzbDrone.Common.Instrumentation; -using NLog; - -namespace NzbDrone.Core.MediaFiles.MediaInfo -{ - [Flags] - public enum BufferStatus - { - None = 0, - Accepted = 1, - Filled = 2, - Updated = 4, - Finalized = 8 - } - - public enum StreamKind - { - General, - Video, - Audio, - Text, - Other, - Image, - Menu - } - - public enum InfoKind - { - Name, - Text, - Measure, - Options, - NameText, - MeasureText, - Info, - HowTo - } - - public enum InfoOptions - { - ShowInInform, - Support, - ShowInSupported, - TypeOfValue - } - - public enum InfoFileOptions - { - FileOption_Nothing = 0x00, - FileOption_NoRecursive = 0x01, - FileOption_CloseAll = 0x02, - FileOption_Max = 0x04 - }; - - - public class MediaInfo : IDisposable - { - private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(MediaInfo)); - private IntPtr _handle; - - public bool MustUseAnsi { get; set; } - public Encoding Encoding { get; set; } - - public MediaInfo() - { - _handle = MediaInfo_New(); - - InitializeEncoding(); - } - - ~MediaInfo() - { - if (_handle != IntPtr.Zero) - { - MediaInfo_Delete(_handle); - } - } - - public void Dispose() - { - if (_handle != IntPtr.Zero) - { - MediaInfo_Delete(_handle); - } - GC.SuppressFinalize(this); - } - - private void InitializeEncoding() - { - if (Environment.OSVersion.ToString().IndexOf("Windows") != -1) - { - // Windows guaranteed UCS-2 - MustUseAnsi = false; - Encoding = Encoding.Unicode; - } - else - { - // Linux normally UCS-4. As fallback we try UCS-2 and plain Ansi. - MustUseAnsi = false; - Encoding = Encoding.UTF32; - - if (Option("Info_Version", "").StartsWith("MediaInfoLib")) - { - return; - } - - Encoding = Encoding.Unicode; - - if (Option("Info_Version", "").StartsWith("MediaInfoLib")) - { - return; - } - - MustUseAnsi = true; - Encoding = Encoding.Default; - - if (Option("Info_Version", "").StartsWith("MediaInfoLib")) - { - return; - } - - throw new NotSupportedException("Unsupported MediaInfoLib encoding"); - } - } - - private IntPtr MakeStringParameter(string value) - { - var buffer = Encoding.GetBytes(value); - - Array.Resize(ref buffer, buffer.Length + 4); - - var buf = Marshal.AllocHGlobal(buffer.Length); - Marshal.Copy(buffer, 0, buf, buffer.Length); - - return buf; - } - - private string MakeStringResult(IntPtr value) - { - if (Encoding == Encoding.Unicode) - { - return Marshal.PtrToStringUni(value); - } - else if (Encoding == Encoding.UTF32) - { - int i = 0; - for (; i < 1024; i += 4) - { - var data = Marshal.ReadInt32(value, i); - if (data == 0) - { - break; - } - } - - var buffer = new byte[i]; - Marshal.Copy(value, buffer, 0, i); - - return Encoding.GetString(buffer, 0, i); - } - else - { - return Marshal.PtrToStringAnsi(value); - } - } - - public int Open(string fileName) - { - var pFileName = MakeStringParameter(fileName); - try - { - if (MustUseAnsi) - { - return (int)MediaInfoA_Open(_handle, pFileName); - } - else - { - return (int)MediaInfo_Open(_handle, pFileName); - } - } - finally - { - Marshal.FreeHGlobal(pFileName); - } - } - - public int Open(Stream stream) - { - if (stream.Length < 1024) - { - return 0; - } - - var isValid = (int)MediaInfo_Open_Buffer_Init(_handle, stream.Length, 0); - if (isValid == 1) - { - var buffer = new byte[16 * 1024]; - long seekStart = 0; - long totalRead = 0; - int bufferRead; - - do - { - bufferRead = stream.Read(buffer, 0, buffer.Length); - totalRead += bufferRead; - - var status = (BufferStatus)MediaInfo_Open_Buffer_Continue(_handle, buffer, (IntPtr)bufferRead); - - if (status.HasFlag(BufferStatus.Finalized) || bufferRead == 0) - { - Logger.Trace("Read file offset {0}-{1} ({2} bytes)", seekStart, stream.Position, stream.Position - seekStart); - break; - } - - var seekPos = MediaInfo_Open_Buffer_Continue_GoTo_Get(_handle); - if (seekPos != -1) - { - Logger.Trace("Read file offset {0}-{1} ({2} bytes)", seekStart, stream.Position, stream.Position - seekStart); - seekPos = stream.Seek(seekPos, SeekOrigin.Begin); - seekStart = seekPos; - MediaInfo_Open_Buffer_Init(_handle, stream.Length, seekPos); - } - } while (bufferRead > 0); - - MediaInfo_Open_Buffer_Finalize(_handle); - - Logger.Trace("Read a total of {0} bytes ({1:0.0}%)", totalRead, totalRead * 100.0 / stream.Length); - } - - return isValid; - } - - public void Close() - { - MediaInfo_Close(_handle); - } - - public string Get(StreamKind streamKind, int streamNumber, string parameter, InfoKind infoKind = InfoKind.Text, InfoKind searchKind = InfoKind.Name) - { - var pParameter = MakeStringParameter(parameter); - try - { - if (MustUseAnsi) - { - return MakeStringResult(MediaInfoA_Get(_handle, (IntPtr)streamKind, (IntPtr)streamNumber, pParameter, (IntPtr)infoKind, (IntPtr)searchKind)); - } - else - { - return MakeStringResult(MediaInfo_Get(_handle, (IntPtr)streamKind, (IntPtr)streamNumber, pParameter, (IntPtr)infoKind, (IntPtr)searchKind)); - } - } - finally - { - Marshal.FreeHGlobal(pParameter); - } - } - - public string Get(StreamKind streamKind, int streamNumber, int parameter, InfoKind infoKind) - { - if (MustUseAnsi) - { - return MakeStringResult(MediaInfoA_GetI(_handle, (IntPtr)streamKind, (IntPtr)streamNumber, (IntPtr)parameter, (IntPtr)infoKind)); - } - else - { - return MakeStringResult(MediaInfo_GetI(_handle, (IntPtr)streamKind, (IntPtr)streamNumber, (IntPtr)parameter, (IntPtr)infoKind)); - } - } - - public string Option(string option, string value) - { - var pOption = MakeStringParameter(option); - var pValue = MakeStringParameter(value); - try - { - if (MustUseAnsi) - { - return MakeStringResult(MediaInfoA_Option(_handle, pOption, pValue)); - } - else - { - return MakeStringResult(MediaInfo_Option(_handle, pOption, pValue)); - } - } - finally - { - Marshal.FreeHGlobal(pOption); - Marshal.FreeHGlobal(pValue); - } - } - - public int State_Get() - { - return (int)MediaInfo_State_Get(_handle); - } - - public int Count_Get(StreamKind streamKind, int streamNumber = -1) - { - return (int)MediaInfo_Count_Get(_handle, (IntPtr)streamKind, (IntPtr)streamNumber); - } - - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfo_New(); - [DllImport("MediaInfo.dll")] - private static extern void MediaInfo_Delete(IntPtr handle); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfo_Open(IntPtr handle, IntPtr fileName); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfo_Open_Buffer_Init(IntPtr handle, long fileSize, long fileOffset); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfo_Open_Buffer_Continue(IntPtr handle, byte[] buffer, IntPtr bufferSize); - [DllImport("MediaInfo.dll")] - private static extern long MediaInfo_Open_Buffer_Continue_GoTo_Get(IntPtr handle); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfo_Open_Buffer_Finalize(IntPtr handle); - [DllImport("MediaInfo.dll")] - private static extern void MediaInfo_Close(IntPtr handle); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfo_GetI(IntPtr handle, IntPtr streamKind, IntPtr streamNumber, IntPtr parameter, IntPtr infoKind); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfo_Get(IntPtr handle, IntPtr streamKind, IntPtr streamNumber, IntPtr parameter, IntPtr infoKind, IntPtr searchKind); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfo_Option(IntPtr handle, IntPtr option, IntPtr value); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfo_State_Get(IntPtr handle); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfo_Count_Get(IntPtr handle, IntPtr StreamKind, IntPtr streamNumber); - - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfoA_New(); - [DllImport("MediaInfo.dll")] - private static extern void MediaInfoA_Delete(IntPtr handle); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfoA_Open(IntPtr handle, IntPtr fileName); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfoA_Open_Buffer_Init(IntPtr handle, long fileSize, long fileOffset); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfoA_Open_Buffer_Continue(IntPtr handle, byte[] buffer, IntPtr bufferSize); - [DllImport("MediaInfo.dll")] - private static extern long MediaInfoA_Open_Buffer_Continue_GoTo_Get(IntPtr handle); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfoA_Open_Buffer_Finalize(IntPtr handle); - [DllImport("MediaInfo.dll")] - private static extern void MediaInfoA_Close(IntPtr handle); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfoA_GetI(IntPtr handle, IntPtr streamKind, IntPtr streamNumber, IntPtr parameter, IntPtr infoKind); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfoA_Get(IntPtr handle, IntPtr streamKind, IntPtr streamNumber, IntPtr parameter, IntPtr infoKind, IntPtr searchKind); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfoA_Option(IntPtr handle, IntPtr option, IntPtr value); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfoA_State_Get(IntPtr handle); - [DllImport("MediaInfo.dll")] - private static extern IntPtr MediaInfoA_Count_Get(IntPtr handle, IntPtr StreamKind, IntPtr streamNumber); - } -} diff --git a/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoModel.cs b/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoModel.cs deleted file mode 100644 index b28cc33fc..000000000 --- a/src/NzbDrone.Core/MediaFiles/MediaInfo/MediaInfoModel.cs +++ /dev/null @@ -1,40 +0,0 @@ -using System; -using System.Globalization; -using System.Linq; -using Newtonsoft.Json; -using NzbDrone.Common.Extensions; -using NzbDrone.Core.Datastore; - -namespace NzbDrone.Core.MediaFiles.MediaInfo -{ - public class MediaInfoModel : IEmbeddedDocument - { - public string ContainerFormat { get; set; } - public string VideoCodec { get; set; } - public string VideoFormat { get; set; } - public string VideoCodecID { get; set; } - public string VideoProfile { get; set; } - public string VideoCodecLibrary { get; set; } - public int VideoBitrate { get; set; } - public int VideoBitDepth { get; set; } - public int Width { get; set; } - public int Height { get; set; } - public string AudioFormat { get; set; } - public string AudioCodecID { get; set; } - public string AudioCodecLibrary { get; set; } - public int AudioBitrate { get; set; } - public string AudioBitrateMode { get; set; } - public TimeSpan RunTime { get; set; } - public int AudioStreamCount { get; set; } - public int AudioChannels { get; set; } - public string AudioChannelPositions { get; set; } - public string AudioChannelPositionsText { get; set; } - public string AudioProfile { get; set; } - public decimal VideoFps { get; set; } - public string AudioLanguages { get; set; } - public string Subtitles { get; set; } - public string ScanType { get; set; } - public int SchemaRevision { get; set; } - - } -} diff --git a/src/NzbDrone.Core/MediaFiles/MediaInfo/UpdateMediaInfoService.cs b/src/NzbDrone.Core/MediaFiles/MediaInfo/UpdateMediaInfoService.cs deleted file mode 100644 index a12690b05..000000000 --- a/src/NzbDrone.Core/MediaFiles/MediaInfo/UpdateMediaInfoService.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.IO; -using NLog; -using NzbDrone.Common.Disk; -using NzbDrone.Core.MediaFiles.Events; -using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Music; -using System.Collections.Generic; -using System.Linq; -using NzbDrone.Core.Configuration; - -namespace NzbDrone.Core.MediaFiles.MediaInfo -{ - public class UpdateMediaInfoService : IHandle - { - private readonly IDiskProvider _diskProvider; - private readonly IMediaFileService _mediaFileService; - private readonly IVideoFileInfoReader _videoFileInfoReader; - private readonly IConfigService _configService; - private readonly Logger _logger; - - public UpdateMediaInfoService(IDiskProvider diskProvider, - IMediaFileService mediaFileService, - IVideoFileInfoReader videoFileInfoReader, - IConfigService configService, - Logger logger) - { - _diskProvider = diskProvider; - _mediaFileService = mediaFileService; - _videoFileInfoReader = videoFileInfoReader; - _configService = configService; - _logger = logger; - } - - private void UpdateMediaInfo(Artist artist, List mediaFiles) - { - foreach (var mediaFile in mediaFiles) - { - var path = Path.Combine(artist.Path, mediaFile.RelativePath); - - if (!_diskProvider.FileExists(path)) - { - _logger.Debug("Can't update MediaInfo because '{0}' does not exist", path); - continue; - } - - mediaFile.MediaInfo = _videoFileInfoReader.GetMediaInfo(path); - - if (mediaFile.MediaInfo != null) - { - _mediaFileService.Update(mediaFile); - _logger.Debug("Updated MediaInfo for '{0}'", path); - } - } - } - - public void Handle(ArtistScannedEvent message) - { - if (!_configService.EnableMediaInfo) - { - _logger.Debug("MediaInfo is disabled"); - return; - } - - var allMediaFiles = _mediaFileService.GetFilesByArtist(message.Artist.Id); - var filteredMediaFiles = allMediaFiles.Where(c => c.MediaInfo == null || c.MediaInfo.SchemaRevision < VideoFileInfoReader.MINIMUM_MEDIA_INFO_SCHEMA_REVISION).ToList(); - - UpdateMediaInfo(message.Artist, filteredMediaFiles); - } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/MediaInfo/VideoFileInfoReader.cs b/src/NzbDrone.Core/MediaFiles/MediaInfo/VideoFileInfoReader.cs deleted file mode 100644 index 2a06b2d57..000000000 --- a/src/NzbDrone.Core/MediaFiles/MediaInfo/VideoFileInfoReader.cs +++ /dev/null @@ -1,205 +0,0 @@ -using System; -using System.Globalization; -using System.IO; -using NLog; -using NzbDrone.Common.Disk; - -namespace NzbDrone.Core.MediaFiles.MediaInfo -{ - public interface IVideoFileInfoReader - { - MediaInfoModel GetMediaInfo(string filename); - TimeSpan GetRunTime(string filename); - } - - public class VideoFileInfoReader : IVideoFileInfoReader - { - private readonly IDiskProvider _diskProvider; - private readonly Logger _logger; - - public const int MINIMUM_MEDIA_INFO_SCHEMA_REVISION = 3; - public const int CURRENT_MEDIA_INFO_SCHEMA_REVISION = 4; - - public VideoFileInfoReader(IDiskProvider diskProvider, Logger logger) - { - _diskProvider = diskProvider; - _logger = logger; - } - - - public MediaInfoModel GetMediaInfo(string filename) - { - if (!_diskProvider.FileExists(filename)) - { - throw new FileNotFoundException("Media file does not exist: " + filename); - } - - MediaInfo mediaInfo = null; - - try - { - mediaInfo = new MediaInfo(); - _logger.Debug("Getting media info from {0}", filename); - - if (filename.ToLower().EndsWith(".ts")) - { - mediaInfo.Option("ParseSpeed", "0.3"); - } - else - { - mediaInfo.Option("ParseSpeed", "0.0"); - } - - int open; - - using (var stream = _diskProvider.OpenReadStream(filename)) - { - open = mediaInfo.Open(stream); - } - - if (open != 0) - { - int audioRuntime; - int videoRuntime; - int generalRuntime; - - //Runtime - int.TryParse(mediaInfo.Get(StreamKind.Video, 0, "PlayTime"), out videoRuntime); - int.TryParse(mediaInfo.Get(StreamKind.Audio, 0, "PlayTime"), out audioRuntime); - int.TryParse(mediaInfo.Get(StreamKind.General, 0, "PlayTime"), out generalRuntime); - - if (audioRuntime == 0 && videoRuntime == 0 && generalRuntime == 0) - { - mediaInfo.Option("ParseSpeed", "1.0"); - - using (var stream = _diskProvider.OpenReadStream(filename)) - { - open = mediaInfo.Open(stream); - } - } - } - - if (open != 0) - { - int width; - int height; - int videoBitRate; - int audioBitRate; - int audioRuntime; - int videoRuntime; - int generalRuntime; - int streamCount; - int audioChannels; - int videoBitDepth; - decimal videoFrameRate; - - string subtitles = mediaInfo.Get(StreamKind.General, 0, "Text_Language_List"); - string scanType = mediaInfo.Get(StreamKind.Video, 0, "ScanType"); - int.TryParse(mediaInfo.Get(StreamKind.Video, 0, "Width"), out width); - int.TryParse(mediaInfo.Get(StreamKind.Video, 0, "Height"), out height); - int.TryParse(mediaInfo.Get(StreamKind.Video, 0, "BitRate"), out videoBitRate); - decimal.TryParse(mediaInfo.Get(StreamKind.Video, 0, "FrameRate"), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out videoFrameRate); - int.TryParse(mediaInfo.Get(StreamKind.Video, 0, "BitDepth"), out videoBitDepth); - - //Runtime - int.TryParse(mediaInfo.Get(StreamKind.Video, 0, "PlayTime"), out videoRuntime); - int.TryParse(mediaInfo.Get(StreamKind.Audio, 0, "PlayTime"), out audioRuntime); - int.TryParse(mediaInfo.Get(StreamKind.General, 0, "PlayTime"), out generalRuntime); - - string audioBitRateMode = mediaInfo.Get(StreamKind.Audio, 0, "BitRate_Mode"); - string aBitRate = mediaInfo.Get(StreamKind.Audio, 0, "BitRate").Split(new [] { " /" }, StringSplitOptions.None)[0].Trim(); - - int.TryParse(aBitRate, out audioBitRate); - int.TryParse(mediaInfo.Get(StreamKind.Audio, 0, "StreamCount"), out streamCount); - - - string audioChannelsStr = mediaInfo.Get(StreamKind.Audio, 0, "Channel(s)").Split(new [] { " /" }, StringSplitOptions.None)[0].Trim(); - - var audioChannelPositions = mediaInfo.Get(StreamKind.Audio, 0, "ChannelPositions/String2"); - var audioChannelPositionsText = mediaInfo.Get(StreamKind.Audio, 0, "ChannelPositions"); - - string audioLanguages = mediaInfo.Get(StreamKind.General, 0, "Audio_Language_List"); - string videoProfile = mediaInfo.Get(StreamKind.Video, 0, "Format_Profile").Split(new [] { " /" }, StringSplitOptions.None)[0].Trim(); - string audioProfile = mediaInfo.Get(StreamKind.Audio, 0, "Format_Profile").Split(new [] { " /" }, StringSplitOptions.None)[0].Trim(); - - int.TryParse(audioChannelsStr, out audioChannels); - var mediaInfoModel = new MediaInfoModel - { - ContainerFormat = mediaInfo.Get(StreamKind.General, 0, "Format"), - VideoFormat = mediaInfo.Get(StreamKind.Video, 0, "Format"), - VideoCodecID = mediaInfo.Get(StreamKind.Video, 0, "CodecID"), - VideoProfile = videoProfile, - VideoCodecLibrary = mediaInfo.Get(StreamKind.Video, 0, "Encoded_Library"), - VideoBitrate = videoBitRate, - VideoBitDepth = videoBitDepth, - Height = height, - Width = width, - AudioFormat = mediaInfo.Get(StreamKind.Audio, 0, "Format"), - AudioCodecID = mediaInfo.Get(StreamKind.Audio, 0, "CodecID"), - AudioProfile = audioProfile, - AudioCodecLibrary = mediaInfo.Get(StreamKind.Audio, 0, "Encoded_Library"), - AudioBitrateMode = audioBitRateMode, - AudioBitrate = audioBitRate, - RunTime = GetBestRuntime(audioRuntime, videoRuntime, generalRuntime), - AudioStreamCount = streamCount, - AudioChannels = audioChannels, - AudioChannelPositions = audioChannelPositions, - AudioChannelPositionsText = audioChannelPositionsText, - VideoFps = videoFrameRate, - AudioLanguages = audioLanguages, - Subtitles = subtitles, - ScanType = scanType, - SchemaRevision = CURRENT_MEDIA_INFO_SCHEMA_REVISION - }; - - return mediaInfoModel; - } - else - { - _logger.Warn("Unable to open media info from file: " + filename); - } - } - catch (DllNotFoundException ex) - { - _logger.Error(ex, "mediainfo is required but was not found"); - } - catch (Exception ex) - { - _logger.Error(ex, "Unable to parse media info from file: {0}", filename); - } - finally - { - mediaInfo?.Close(); - } - - return null; - } - - public TimeSpan GetRunTime(string filename) - { - var info = GetMediaInfo(filename); - - if (info == null) - { - return new TimeSpan(); - } - - return info.RunTime; - } - - private TimeSpan GetBestRuntime(int audio, int video, int general) - { - if (video == 0) - { - if (audio == 0) - { - return TimeSpan.FromMilliseconds(general); - } - - return TimeSpan.FromMilliseconds(audio); - } - - return TimeSpan.FromMilliseconds(video); - } - } -} diff --git a/src/NzbDrone.Core/MediaFiles/MediaInfoFormatter.cs b/src/NzbDrone.Core/MediaFiles/MediaInfoFormatter.cs new file mode 100644 index 000000000..f9f3a2fee --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/MediaInfoFormatter.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using NLog; +using NLog.Fluent; +using NzbDrone.Common.Instrumentation; +using NzbDrone.Common.Instrumentation.Extensions; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles +{ + public static class MediaInfoFormatter + { + private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(MediaInfoFormatter)); + + public static string FormatAudioBitrate(MediaInfoModel mediaInfo) + { + return mediaInfo.AudioBitrate + " kbps"; + } + + public static string FormatAudioBitsPerSample(MediaInfoModel mediaInfo) + { + return mediaInfo.AudioBits + "bit"; + } + + public static string FormatAudioSampleRate(MediaInfoModel mediaInfo) + { + return $"{mediaInfo.AudioSampleRate / 1000:0.#}kHz"; + } + + public static decimal FormatAudioChannels(MediaInfoModel mediaInfo) + { + return mediaInfo.AudioChannels; + } + + public static readonly Dictionary CodecNames = new Dictionary { + {Codec.AAC, "AAC"}, + {Codec.AACVBR, "AAC"}, + {Codec.ALAC, "ALAC"}, + {Codec.APE, "APE"}, + {Codec.FLAC, "FLAC"}, + {Codec.MP3CBR, "MP3"}, + {Codec.MP3VBR, "MP3"}, + {Codec.OGG, "OGG"}, + {Codec.WAV, "PCM"}, + {Codec.WAVPACK, "WavPack"}, + {Codec.WMA, "WMA"} + }; + + public static string FormatAudioCodec(MediaInfoModel mediaInfo) + { + var codec = QualityParser.ParseCodec(mediaInfo.AudioFormat, null); + + if (CodecNames.ContainsKey(codec)) + { + return CodecNames[codec]; + } + else + { + Logger.Debug() + .Message("Unknown audio format: '{0}'.", string.Join(", ", mediaInfo.AudioFormat)) + .WriteSentryWarn("UnknownAudioFormat", mediaInfo.AudioFormat) + .Write(); + + return "Unknown"; + } + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackFile.cs b/src/NzbDrone.Core/MediaFiles/TrackFile.cs index e57146d55..4b308d98a 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackFile.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackFile.cs @@ -1,6 +1,5 @@ using Marr.Data; using NzbDrone.Core.Datastore; -using NzbDrone.Core.MediaFiles.MediaInfo; using NzbDrone.Core.Music; using NzbDrone.Core.Qualities; using System; @@ -8,6 +7,7 @@ using System.Collections.Generic; using System.Linq; using System.Text; using NzbDrone.Core.Languages; +using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles { @@ -30,9 +30,6 @@ namespace NzbDrone.Core.MediaFiles public LazyLoaded Artist { get; set; } public LazyLoaded Album { get; set; } - // these are ignored by the database but retained/populated for compatibility - public int ArtistId { get { return Artist.Value?.Id ?? 0; } } - public override string ToString() { return string.Format("[{0}] {1}", Id, RelativePath); diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/AggregationFailedException.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/AggregationFailedException.cs new file mode 100644 index 000000000..a3c4a2682 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/AggregationFailedException.cs @@ -0,0 +1,24 @@ +using System; +using NzbDrone.Common.Exceptions; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Aggregation +{ + public class AugmentingFailedException : NzbDroneException + { + public AugmentingFailedException(string message, params object[] args) : base(message, args) + { + } + + public AugmentingFailedException(string message) : base(message) + { + } + + public AugmentingFailedException(string message, Exception innerException, params object[] args) : base(message, innerException, args) + { + } + + public AugmentingFailedException(string message, Exception innerException) : base(message, innerException) + { + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/AggregationService.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/AggregationService.cs new file mode 100644 index 000000000..aa1313bab --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/AggregationService.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using System.IO; +using NLog; +using NzbDrone.Common.Disk; +using NzbDrone.Core.MediaFiles.TrackImport.Aggregation.Aggregators; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Aggregation +{ + public interface IAugmentingService + { + LocalTrack Augment(LocalTrack localTrack, bool otherFiles); + LocalAlbumRelease Augment(LocalAlbumRelease localAlbum); + } + + public class AugmentingService : IAugmentingService + { + private readonly IEnumerable> _trackAugmenters; + private readonly IEnumerable> _albumAugmenters; + private readonly IDiskProvider _diskProvider; + private readonly Logger _logger; + + public AugmentingService(IEnumerable> trackAugmenters, + IEnumerable> albumAugmenters, + IDiskProvider diskProvider, + Logger logger) + { + _trackAugmenters = trackAugmenters; + _albumAugmenters = albumAugmenters; + _diskProvider = diskProvider; + _logger = logger; + } + + public LocalTrack Augment(LocalTrack localTrack, bool otherFiles) + { + if (localTrack.DownloadClientAlbumInfo == null && + localTrack.FolderTrackInfo == null && + localTrack.FileTrackInfo == null) + { + if (MediaFileExtensions.Extensions.Contains(Path.GetExtension(localTrack.Path))) + { + throw new AugmentingFailedException("Unable to parse track info from path: {0}", localTrack.Path); + } + } + + localTrack.Size = _diskProvider.GetFileSize(localTrack.Path); + localTrack.Language = localTrack.FileTrackInfo.Language; + + foreach (var augmenter in _trackAugmenters) + { + try + { + augmenter.Aggregate(localTrack, otherFiles); + } + catch (Exception ex) + { + _logger.Warn(ex, ex.Message); + } + } + + return localTrack; + } + + public LocalAlbumRelease Augment(LocalAlbumRelease localAlbum) + { + foreach (var augmenter in _albumAugmenters) + { + try + { + augmenter.Aggregate(localAlbum, false); + } + catch (Exception ex) + { + _logger.Warn(ex, ex.Message); + } + + } + + return localAlbum; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/AggregateFilenameInfo.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/AggregateFilenameInfo.cs new file mode 100644 index 000000000..b09d2d630 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/AggregateFilenameInfo.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Aggregation.Aggregators +{ + public class AggregateFilenameInfo : IAggregate + { + private readonly Logger _logger; + + private static readonly List> charsAndSeps = new List> { + Tuple.Create(@"a-z0-9,\(\)\.&'’\s", @"\s_-"), + Tuple.Create(@"a-z0-9,\(\)\.\&'’_", @"\s-") + }; + + private static Regex[] Patterns(string chars, string sep) + { + var sep1 = $@"(?[{sep}]+)"; + var sepn = @"\k"; + var artist = $@"(?[{chars}]+)"; + var track = $@"(?\d+)"; + var title = $@"(?[{chars}]+)"; + var tag = $@"(?<tag>[{chars}]+)"; + + return new [] { + new Regex($@"^{track}{sep1}{artist}{sepn}{title}{sepn}{tag}$", RegexOptions.IgnoreCase), + new Regex($@"^{track}{sep1}{artist}{sepn}{tag}{sepn}{title}$", RegexOptions.IgnoreCase), + new Regex($@"^{track}{sep1}{artist}{sepn}{title}$", RegexOptions.IgnoreCase), + + new Regex($@"^{artist}{sep1}{tag}{sepn}{track}{sepn}{title}$", RegexOptions.IgnoreCase), + new Regex($@"^{artist}{sep1}{track}{sepn}{title}{sepn}{tag}$", RegexOptions.IgnoreCase), + new Regex($@"^{artist}{sep1}{track}{sepn}{title}$", RegexOptions.IgnoreCase), + + new Regex($@"^{artist}{sep1}{title}{sepn}{tag}$", RegexOptions.IgnoreCase), + new Regex($@"^{artist}{sep1}{tag}{sepn}{title}$", RegexOptions.IgnoreCase), + new Regex($@"^{artist}{sep1}{title}$", RegexOptions.IgnoreCase), + + new Regex($@"^{track}{sep1}{title}$", RegexOptions.IgnoreCase), + new Regex($@"^{track}{sep1}{tag}{sepn}{title}$", RegexOptions.IgnoreCase), + new Regex($@"^{track}{sep1}{title}{sepn}{tag}$", RegexOptions.IgnoreCase), + + new Regex($@"^{title}$", RegexOptions.IgnoreCase), + }; + } + + public AggregateFilenameInfo(Logger logger) + { + _logger = logger; + } + + public LocalAlbumRelease Aggregate(LocalAlbumRelease release, bool others) + { + var tracks = release.LocalTracks; + if (tracks.Count(x => x.FileTrackInfo.Title.IsNullOrWhiteSpace()) > 0 + || tracks.Count(x => x.FileTrackInfo.TrackNumbers.First() == 0) > 0 + || tracks.Count(x => x.FileTrackInfo.DiscNumber == 0) > 0) + { + _logger.Debug("Missing data in tags, trying filename augmentation"); + foreach (var charSep in charsAndSeps) + { + foreach (var pattern in Patterns(charSep.Item1, charSep.Item2)) + { + var matches = AllMatches(tracks, pattern); + if (matches != null) + { + ApplyMatches(matches, pattern); + } + } + } + } + + return release; + } + + private Dictionary<LocalTrack, Match> AllMatches(List<LocalTrack> tracks, Regex pattern) + { + var matches = new Dictionary<LocalTrack, Match>(); + foreach (var track in tracks) + { + var filename = Path.GetFileNameWithoutExtension(track.Path).RemoveAccent(); + var match = pattern.Match(filename); + _logger.Trace("Matching '{0}' against regex {1}", filename, pattern); + if (match.Success && match.Groups[0].Success) + { + matches[track] = match; + } + else + { + return null; + } + } + return matches; + } + + private bool EqualFields(IEnumerable<Match> matches, string field) + { + return matches.Select(x => x.Groups[field].Value).Distinct().Count() == 1; + } + + private void ApplyMatches(Dictionary<LocalTrack, Match> matches, Regex pattern) + { + _logger.Debug("Got filename match with regex {0}", pattern); + + var keys = pattern.GetGroupNames(); + var someMatch = matches.First().Value; + + // only proceed if the 'tag' field is equal across all filenames + if (keys.Contains("tag") && !EqualFields(matches.Values, "tag")) + { + _logger.Trace("Abort - 'tag' varies between matches"); + return; + } + + // Given both an "artist" and "title" field, assume that one is + // *actually* the artist, which must be uniform, and use the other + // for the title. This, of course, won't work for VA albums. + string titleField; + string artist; + if (keys.Contains("artist")) + { + if (EqualFields(matches.Values, "artist")) + { + artist = someMatch.Groups["artist"].Value.Trim(); + titleField = "title"; + } + else if (EqualFields(matches.Values, "title")) + { + artist = someMatch.Groups["title"].Value.Trim(); + titleField = "artist"; + } + else + { + _logger.Trace("Abort - both artist and title vary between matches"); + // both vary, abort + return; + } + + _logger.Debug("Got artist from filename: {0}", artist); + + foreach (var track in matches.Keys) + { + if (track.FileTrackInfo.ArtistTitle.IsNullOrWhiteSpace()) + { + track.FileTrackInfo.ArtistTitle = artist; + } + } + } + else + { + // no artist - remaining field is the title + titleField = "title"; + } + + // Apply the title and track + foreach (var track in matches.Keys) + { + if (track.FileTrackInfo.Title.IsNullOrWhiteSpace()) + { + var title = matches[track].Groups[titleField].Value.Trim(); + _logger.Debug("Got title from filename: {0}", title); + track.FileTrackInfo.Title = title; + } + + var trackNums = track.FileTrackInfo.TrackNumbers; + if (keys.Contains("track") && (trackNums.Count() == 0 || trackNums.First() == 0)) + { + var tracknum = Convert.ToInt32(matches[track].Groups["track"].Value); + if (tracknum > 100) + { + track.FileTrackInfo.DiscNumber = tracknum / 100; + _logger.Debug("Got disc number from filename: {0}", tracknum / 100); + tracknum = tracknum % 100; + } + _logger.Debug("Got track number from filename: {0}", tracknum); + track.FileTrackInfo.TrackNumbers = new [] { tracknum }; + } + } + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/AggregateQuality.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/AggregateQuality.cs new file mode 100644 index 000000000..b926a77c8 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/AggregateQuality.cs @@ -0,0 +1,25 @@ +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Aggregation.Aggregators +{ + public class AggregateQuality : IAggregate<LocalTrack> + { + public LocalTrack Aggregate(LocalTrack localTrack, bool otherFiles) + { + var quality = localTrack.FileTrackInfo?.Quality; + + if (quality == null) + { + quality = localTrack.FolderTrackInfo?.Quality; + } + + if (quality == null) + { + quality = localTrack.DownloadClientAlbumInfo?.Quality; + } + + localTrack.Quality = quality; + return localTrack; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/AggregateReleaseGroup.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/AggregateReleaseGroup.cs new file mode 100644 index 000000000..2bc2a80c9 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/AggregateReleaseGroup.cs @@ -0,0 +1,27 @@ +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Aggregation.Aggregators +{ + public class AggregateReleaseGroup : IAggregate<LocalTrack> + { + public LocalTrack Aggregate(LocalTrack localTrack, bool otherFiles) + { + var releaseGroup = localTrack.DownloadClientAlbumInfo?.ReleaseGroup; + + if (releaseGroup.IsNullOrWhiteSpace()) + { + releaseGroup = localTrack.FolderTrackInfo?.ReleaseGroup; + } + + if (releaseGroup.IsNullOrWhiteSpace()) + { + releaseGroup = localTrack.FileTrackInfo?.ReleaseGroup; + } + + localTrack.ReleaseGroup = releaseGroup; + + return localTrack; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/IAggregateLocalTrack.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/IAggregateLocalTrack.cs new file mode 100644 index 000000000..c9a219b5a --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Aggregation/Aggregators/IAggregateLocalTrack.cs @@ -0,0 +1,7 @@ +namespace NzbDrone.Core.MediaFiles.TrackImport.Aggregation.Aggregators +{ + public interface IAggregate<T> + { + T Aggregate(T item, bool otherFiles); + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/IImportDecisionEngineSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/IImportDecisionEngineSpecification.cs index 6fff3ba23..9c4703435 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/IImportDecisionEngineSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/IImportDecisionEngineSpecification.cs @@ -1,10 +1,9 @@ using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles.TrackImport { - public interface IImportDecisionEngineSpecification + public interface IImportDecisionEngineSpecification<T> { - Decision IsSatisfiedBy(LocalTrack localTrack); + Decision IsSatisfiedBy(T item); } } diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/Distance.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/Distance.cs new file mode 100644 index 000000000..565fa260f --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/Distance.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Identification +{ + public class Distance + { + private Dictionary<string, List<double>> penalties; + + // from beets default config + private static readonly Dictionary<string, double> weights = new Dictionary<string, double> + { + { "source", 2.0 }, + { "artist", 3.0 }, + { "album", 3.0 }, + { "media_count", 1.0 }, + { "media_format", 1.0 }, + { "year", 1.0 }, + { "country", 0.5 }, + { "label", 0.5 }, + { "catalog_number", 0.5 }, + { "album_disambiguation", 0.5 }, + { "album_id", 5.0 }, + { "tracks", 2.0 }, + { "missing_tracks", 0.6 }, + { "unmatched_tracks", 0.9 }, + { "track_title", 3.0 }, + { "track_artist", 2.0 }, + { "track_index", 1.0 }, + { "track_length", 2.0 }, + { "recording_id", 10.0 }, + }; + + public Distance() + { + penalties = new Dictionary<string, List<double>>(15); + } + + public Dictionary<string, List<double>> Penalties => penalties; + public string Reasons => penalties.Count(x => x.Value.Max() > 0.0) > 0 ? "[" + string.Join(", ", Penalties.Where(x => x.Value.Max() > 0.0).Select(x => x.Key.Replace('_', ' '))) + "]" : string.Empty; + + private double MaxDistance(Dictionary<string, List<double>> penalties) + { + return penalties.Select(x => x.Value.Count * weights[x.Key]).Sum(); + } + + public double MaxDistance() + { + return MaxDistance(penalties); + } + + private double RawDistance(Dictionary<string, List<double>> penalties) + { + return penalties.Select(x => x.Value.Sum() * weights[x.Key]).Sum(); + } + + public double RawDistance() + { + return RawDistance(penalties); + } + + private double NormalizedDistance(Dictionary<string, List<double>> penalties) + { + var max = MaxDistance(penalties); + return max > 0 ? RawDistance(penalties) / max : 0; + } + + public double NormalizedDistance() + { + return NormalizedDistance(penalties); + } + + public double NormalizedDistanceExcluding(List<string> keys) + { + return NormalizedDistance(penalties.Where(x => !keys.Contains(x.Key)).ToDictionary(y => y.Key, y => y.Value)); + } + + public void Add(string key, double dist) + { + if (penalties.ContainsKey(key)) + { + penalties[key].Add(dist); + } + else + { + penalties[key] = new List<double> { dist }; + } + } + + public void AddRatio(string key, double value, double target) + { + // Adds a distance penalty for value as a ratio of target + // value is between 0 and target + var dist = target > 0 ? Math.Max(Math.Min(value, target), 0.0) / target : 0.0; + Add(key, dist); + } + + public void AddNumber(string key, int value, int target) + { + var diff = Math.Abs(value - target); + if (diff > 0) + { + for (int i = 0; i < diff; i++) + { + Add(key, 1.0); + } + } + else + { + Add(key, 0.0); + } + } + + private static string Clean(string input) + { + char[] arr = input.ToLower().RemoveAccent().ToCharArray(); + + arr = Array.FindAll<char>(arr, c => (char.IsLetterOrDigit(c))); + + return new string(arr); + } + + public void AddString(string key, string value, string target) + { + // Adds a penaltly based on the distance between value and target + var cleanValue = Clean(value); + var cleanTarget = Clean(target); + + if (cleanValue.IsNullOrWhiteSpace() && cleanTarget.IsNotNullOrWhiteSpace()) + { + Add(key, 1.0); + } + else if (cleanValue.IsNullOrWhiteSpace() && cleanTarget.IsNullOrWhiteSpace()) + { + Add(key, 0.0); + } + else + { + Add(key, 1.0 - cleanValue.LevenshteinCoefficient(cleanTarget)); + } + } + + public void AddBool(string key, bool expr) + { + Add(key, expr ? 1.0 : 0.0); + } + + public void AddEquality<T>(string key, T value, List<T> options) where T : IEquatable<T> + { + Add(key, options.Contains(value) ? 0.0 : 1.0); + } + + public void AddPriority<T>(string key, T value, List<T> options) where T : IEquatable<T> + { + var unit = 1.0 / (options.Count > 0 ? (double) options.Count : 1.0); + var index = options.IndexOf(value); + if (index == -1) + { + Add(key, 1.0); + } + else + { + Add(key, index * unit); + } + } + + public void AddPriority<T>(string key, List<T> values, List<T> options) where T : IEquatable<T> + { + for(int i = 0; i < options.Count; i++) + { + if (values.Contains(options[i])) + { + Add(key, i / (double)options.Count); + return; + } + } + + Add(key, 1.0); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/IdentificationService.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/IdentificationService.cs new file mode 100644 index 000000000..489a4b8bb --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/IdentificationService.cs @@ -0,0 +1,575 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; +using NLog; +using NzbDrone.Common.EnsureThat; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Serializer; +using NzbDrone.Core.Configuration; +using NzbDrone.Core.MediaFiles.TrackImport.Aggregation; +using NzbDrone.Core.Music; +using NzbDrone.Core.Parser; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Identification +{ + public interface IIdentificationService + { + List<LocalAlbumRelease> Identify(List<LocalTrack> localTracks, Artist artist, Album album, AlbumRelease release, bool newDownload, bool singleRelease); + } + + public class IdentificationService : IIdentificationService + { + private readonly IArtistService _artistService; + private readonly IAlbumService _albumService; + private readonly IReleaseService _releaseService; + private readonly ITrackService _trackService; + private readonly ITrackGroupingService _trackGroupingService; + private readonly IFingerprintingService _fingerprintingService; + private readonly IAugmentingService _augmentingService; + private readonly IConfigService _configService; + private readonly Logger _logger; + + public IdentificationService(IArtistService artistService, + IAlbumService albumService, + IReleaseService releaseService, + ITrackService trackService, + ITrackGroupingService trackGroupingService, + IFingerprintingService fingerprintingService, + IAugmentingService augmentingService, + IConfigService configService, + Logger logger) + { + _artistService = artistService; + _albumService = albumService; + _releaseService = releaseService; + _trackService = trackService; + _trackGroupingService = trackGroupingService; + _fingerprintingService = fingerprintingService; + _augmentingService = augmentingService; + _configService = configService; + _logger = logger; + } + + private readonly List<IsoCountry> preferredCountries = new List<string> { + "United States", + "United Kingdom", + "Europe", + "[Worldwide]" + }.Select(x => IsoCountries.Find(x)).ToList(); + + private readonly List<string> VariousArtistNames = new List<string> { "various artists", "various", "va", "unknown" }; + private readonly List<string> VariousArtistIds = new List<string> { "89ad4ac3-39f7-470e-963a-56509c546377" }; + + private void LogTestCaseOutput(List<LocalTrack> localTracks, Artist artist, Album album, AlbumRelease release, bool newDownload, bool singleRelease) + { + var trackData = localTracks.Select(x => new BasicLocalTrack { + Path = x.Path, + FileTrackInfo = x.FileTrackInfo + }); + var options = new IdTestCase { + ExpectedMusicBrainzReleaseIds = new List<string> {"expected-id-1", "expected-id-2", "..."}, + MetadataProfile = artist?.MetadataProfile.Value, + Artist = artist?.Metadata.Value.ForeignArtistId, + Album = album?.ForeignAlbumId, + Release = release?.ForeignReleaseId, + NewDownload = newDownload, + SingleRelease = singleRelease, + Tracks = trackData.ToList() + }; + + var SerializerSettings = Json.GetSerializerSettings(); + SerializerSettings.Formatting = Formatting.None; + + var output = JsonConvert.SerializeObject(options, SerializerSettings); + + _logger.Debug($"*** IdentificationService TestCaseGenerator ***\n{output}"); + } + + public List<LocalAlbumRelease> Identify(List<LocalTrack> localTracks, Artist artist, Album album, AlbumRelease release, bool newDownload, bool singleRelease) + { + // 1 group localTracks so that we think they represent a single release + // 2 get candidates given specified artist, album and release + // 3 find best candidate + // 4 If best candidate worse than threshold, try fingerprinting + + var watch = System.Diagnostics.Stopwatch.StartNew(); + + _logger.Debug("Starting track identification"); + LogTestCaseOutput(localTracks, artist, album, release, newDownload, singleRelease); + + List<LocalAlbumRelease> releases = null; + if (singleRelease) + { + releases = new List<LocalAlbumRelease>{ new LocalAlbumRelease(localTracks) }; + } + else + { + releases = _trackGroupingService.GroupTracks(localTracks); + } + + _logger.Debug($"Sorted {localTracks.Count} tracks into {releases.Count} releases in {watch.ElapsedMilliseconds}ms"); + + foreach (var localRelease in releases) + { + try + { + _augmentingService.Augment(localRelease); + } + catch (AugmentingFailedException) + { + _logger.Warn($"Augmentation failed for {localRelease}"); + } + IdentifyRelease(localRelease, artist, album, release, newDownload); + } + + watch.Stop(); + + _logger.Debug($"Track identification for {localTracks.Count} tracks took {watch.ElapsedMilliseconds}ms"); + + return releases; + } + + private bool FingerprintingAllowed(bool newDownload) + { + if (_configService.AllowFingerprinting == AllowFingerprinting.Never || + (_configService.AllowFingerprinting == AllowFingerprinting.NewFiles && !newDownload)) + { + return false; + } + + return true; + } + + private bool ShouldFingerprint(LocalAlbumRelease localAlbumRelease) + { + var worstTrackMatchDist = localAlbumRelease.TrackMapping?.Mapping + .OrderByDescending(x => x.Value.Item2.NormalizedDistance()) + .First() + .Value.Item2.NormalizedDistance() ?? 1.0; + + if (localAlbumRelease.Distance.NormalizedDistance() > 0.15 || + localAlbumRelease.TrackMapping.LocalExtra.Any() || + localAlbumRelease.TrackMapping.MBExtra.Any() || + worstTrackMatchDist > 0.40) + { + return true; + } + + return false; + } + + private void IdentifyRelease(LocalAlbumRelease localAlbumRelease, Artist artist, Album album, AlbumRelease release, bool newDownload) + { + var watch = System.Diagnostics.Stopwatch.StartNew(); + bool fingerprinted = false; + + var candidateReleases = GetCandidatesFromTags(localAlbumRelease, artist, album, release); + if (candidateReleases.Count == 0 && FingerprintingAllowed(newDownload)) + { + _logger.Debug("No candidates found, fingerprinting"); + _fingerprintingService.Lookup(localAlbumRelease.LocalTracks, 0.5); + fingerprinted = true; + candidateReleases = GetCandidatesFromFingerprint(localAlbumRelease); + } + + if (candidateReleases.Count == 0) + { + // can't find any candidates even after fingerprinting + return; + } + + _logger.Debug($"Got {candidateReleases.Count} candidates for {localAlbumRelease.LocalTracks.Count} tracks in {watch.ElapsedMilliseconds}ms"); + + var allTracks = _trackService.GetTracksByReleases(candidateReleases.Select(x => x.Id).ToList()); + + _logger.Debug($"Got tracks in {watch.ElapsedMilliseconds}ms"); + + GetBestRelease(localAlbumRelease, candidateReleases, allTracks); + + // If result isn't great and we haven't fingerprinted, try that + // Note that this can improve the match even if we try the same candidates + if (!fingerprinted && FingerprintingAllowed(newDownload) && ShouldFingerprint(localAlbumRelease)) + { + _logger.Debug($"Match not good enough, fingerprinting"); + _fingerprintingService.Lookup(localAlbumRelease.LocalTracks, 0.5); + + // Only include extra possible candidates if neither album nor release are specified + // Will generally be specified as part of manual import + if (album == null && release == null) + { + var extraCandidates = GetCandidatesFromFingerprint(localAlbumRelease).DistinctBy(x => x.Id); + candidateReleases.AddRange(extraCandidates); + allTracks.AddRange(_trackService.GetTracksByReleases(extraCandidates.Select(x => x.Id).ToList())); + } + + GetBestRelease(localAlbumRelease, candidateReleases, allTracks); + } + + _logger.Debug($"Best release found in {watch.ElapsedMilliseconds}ms"); + + localAlbumRelease.PopulateMatch(); + + _logger.Debug($"IdentifyRelease done in {watch.ElapsedMilliseconds}ms"); + } + + public List<AlbumRelease> GetCandidatesFromTags(LocalAlbumRelease localAlbumRelease, Artist artist, Album album, AlbumRelease release) + { + var watch = System.Diagnostics.Stopwatch.StartNew(); + + // Generally artist, album and release are null. But if they're not then limit candidates appropriately. + // We've tried to make sure that tracks are all for a single release. + + List<AlbumRelease> candidateReleases; + + // if we have a release ID, use that + var releaseIds = localAlbumRelease.LocalTracks.Select(x => x.FileTrackInfo.ReleaseMBId).Distinct().ToList(); + if (releaseIds.Count == 1 && releaseIds[0].IsNotNullOrWhiteSpace()) + { + _logger.Debug("Selecting release from consensus ForeignReleaseId [{0}]", releaseIds[0]); + return _releaseService.GetReleasesByForeignReleaseId(releaseIds); + } + + if (release != null) + { + _logger.Debug("Release {0} [{1} tracks] was forced", release, release.TrackCount); + candidateReleases = new List<AlbumRelease> { release }; + } + else if (album != null) + { + candidateReleases = GetCandidatesByAlbum(localAlbumRelease, album); + } + else if (artist != null) + { + candidateReleases = GetCandidatesByArtist(localAlbumRelease, artist); + } + else + { + candidateReleases = GetCandidates(localAlbumRelease); + } + + watch.Stop(); + _logger.Debug($"Getting candidates from tags for {localAlbumRelease.LocalTracks.Count} tracks took {watch.ElapsedMilliseconds}ms"); + + // if we haven't got any candidates then try fingerprinting + return candidateReleases; + } + + private List<AlbumRelease> GetCandidatesByAlbum(LocalAlbumRelease localAlbumRelease, Album album) + { + // sort candidate releases by closest track count so that we stand a chance of + // getting a perfect match early on + return _releaseService.GetReleasesByAlbum(album.Id) + .OrderBy(x => Math.Abs(localAlbumRelease.TrackCount - x.TrackCount)) + .ToList(); + } + + private List<AlbumRelease> GetCandidatesByArtist(LocalAlbumRelease localAlbumRelease, Artist artist) + { + _logger.Trace("Getting candidates for {0}", artist); + var candidateReleases = new List<AlbumRelease>(); + + var albumTag = MostCommon(localAlbumRelease.LocalTracks.Select(x => x.FileTrackInfo.AlbumTitle)) ?? ""; + if (albumTag.IsNotNullOrWhiteSpace()) + { + var possibleAlbums = _albumService.GetCandidates(artist.ArtistMetadataId, albumTag); + foreach (var album in possibleAlbums) + { + candidateReleases.AddRange(GetCandidatesByAlbum(localAlbumRelease, album)); + } + } + + return candidateReleases; + } + + private List<AlbumRelease> GetCandidates(LocalAlbumRelease localAlbumRelease) + { + // most general version, nothing has been specified. + // get all plausible artists, then all plausible albums, then get releases for each of these. + + // check if it looks like VA. + if (TrackGroupingService.IsVariousArtists(localAlbumRelease.LocalTracks)) + { + throw new NotImplementedException("Various artists not supported"); + } + + var candidateReleases = new List<AlbumRelease>(); + + var artistTag = MostCommon(localAlbumRelease.LocalTracks.Select(x => x.FileTrackInfo.ArtistTitle)) ?? ""; + if (artistTag.IsNotNullOrWhiteSpace()) + { + var possibleArtists = _artistService.GetCandidates(artistTag); + foreach (var artist in possibleArtists) + { + candidateReleases.AddRange(GetCandidatesByArtist(localAlbumRelease, artist)); + } + } + + return candidateReleases; + } + + public List<AlbumRelease> GetCandidatesFromFingerprint(LocalAlbumRelease localAlbumRelease) + { + var recordingIds = localAlbumRelease.LocalTracks.Where(x => x.AcoustIdResults != null).SelectMany(x => x.AcoustIdResults).ToList(); + var allReleases = _releaseService.GetReleasesByRecordingIds(recordingIds); + + return allReleases.Select(x => new { + Release = x, + TrackCount = x.TrackCount, + CommonProportion = x.Tracks.Value.Select(y => y.ForeignRecordingId).Intersect(recordingIds).Count() / localAlbumRelease.TrackCount + }) + .Where(x => x.CommonProportion > 0.6) + .ToList() + .OrderBy(x => Math.Abs(x.TrackCount - localAlbumRelease.TrackCount)) + .ThenByDescending(x => x.CommonProportion) + .Select(x => x.Release) + .Take(10) + .ToList(); + } + + private T MostCommon<T>(IEnumerable<T> items) + { + return items.GroupBy(x => x).OrderByDescending(x => x.Count()).First().Key; + } + + private void GetBestRelease(LocalAlbumRelease localAlbumRelease, List<AlbumRelease> candidateReleases, List<Track> tracks) + { + var watch = System.Diagnostics.Stopwatch.StartNew(); + + _logger.Debug("Matching {0} track files against {1} candidates", localAlbumRelease.TrackCount, candidateReleases.Count); + _logger.Trace("Processing files:\n{0}", string.Join("\n", localAlbumRelease.LocalTracks.Select(x => x.Path))); + + double bestDistance = 1.0; + + foreach (var release in candidateReleases) + { + _logger.Debug("Trying Release {0} [{1}, {2} tracks]", release, release.Title, release.TrackCount); + var rwatch = System.Diagnostics.Stopwatch.StartNew(); + + var mapping = MapReleaseTracks(localAlbumRelease.LocalTracks, tracks.Where(x => x.AlbumReleaseId == release.Id).ToList()); + var distance = AlbumReleaseDistance(localAlbumRelease.LocalTracks, release, mapping); + var currDistance = distance.NormalizedDistance(); + + rwatch.Stop(); + _logger.Debug("Release {0} [{1} tracks] has distance {2} vs best distance {3} [{4}ms]", + release, release.TrackCount, currDistance, bestDistance, rwatch.ElapsedMilliseconds); + if (currDistance < bestDistance) + { + bestDistance = currDistance; + localAlbumRelease.Distance = distance; + localAlbumRelease.AlbumRelease = release; + localAlbumRelease.TrackMapping = mapping; + if (currDistance == 0.0) + { + break; + } + } + } + + watch.Stop(); + _logger.Debug($"Best release: {localAlbumRelease.AlbumRelease} Distance {localAlbumRelease.Distance.NormalizedDistance()} found in {watch.ElapsedMilliseconds}ms"); + } + + public int GetTotalTrackNumber(Track track, List<Track> allTracks) + { + return track.AbsoluteTrackNumber + allTracks.Count(t => t.MediumNumber < track.MediumNumber); + } + + public TrackMapping MapReleaseTracks(List<LocalTrack> localTracks, List<Track> mbTracks) + { + var distances = new Distance[localTracks.Count, mbTracks.Count]; + var costs = new double[localTracks.Count, mbTracks.Count]; + + for (int col = 0; col < mbTracks.Count; col++) + { + var totalTrackNumber = GetTotalTrackNumber(mbTracks[col], mbTracks); + for (int row = 0; row < localTracks.Count; row++) + { + distances[row, col] = TrackDistance(localTracks[row], mbTracks[col], totalTrackNumber, false); + costs[row, col] = distances[row, col].NormalizedDistance(); + } + } + + var m = new Munkres(costs); + m.Run(); + + var result = new TrackMapping(); + foreach (var pair in m.Solution) + { + result.Mapping.Add(localTracks[pair.Item1], Tuple.Create(mbTracks[pair.Item2], distances[pair.Item1, pair.Item2])); + _logger.Trace("Mapped {0} to {1}, dist: {2}", localTracks[pair.Item1], mbTracks[pair.Item2], costs[pair.Item1, pair.Item2]); + } + result.LocalExtra = localTracks.Except(result.Mapping.Keys).ToList(); + result.MBExtra = mbTracks.Except(result.Mapping.Values.Select(x => x.Item1)).ToList(); + + return result; + } + + private bool TrackIndexIncorrect(LocalTrack localTrack, Track mbTrack, int totalTrackNumber) + { + return localTrack.FileTrackInfo.TrackNumbers[0] != mbTrack.AbsoluteTrackNumber && + localTrack.FileTrackInfo.TrackNumbers[0] != totalTrackNumber; + } + + public Distance TrackDistance(LocalTrack localTrack, Track mbTrack, int totalTrackNumber, bool includeArtist = false) + { + var dist = new Distance(); + + var localLength = localTrack.FileTrackInfo.Duration.TotalSeconds; + var mbLength = mbTrack.Duration / 1000; + var diff = Math.Abs(localLength - mbLength) - 10; + + if (mbLength > 0) + { + dist.AddRatio("track_length", diff, 30); + } + + // musicbrainz never has 'featuring' in the track title + // see https://musicbrainz.org/doc/Style/Artist_Credits + dist.AddString("track_title", localTrack.FileTrackInfo.CleanTitle ?? "", mbTrack.Title); + + if (includeArtist && localTrack.FileTrackInfo.ArtistTitle.IsNotNullOrWhiteSpace() + && !VariousArtistNames.Any(x => x.Equals(localTrack.FileTrackInfo.ArtistTitle, StringComparison.InvariantCultureIgnoreCase))) + { + dist.AddString("track_artist", localTrack.FileTrackInfo.ArtistTitle, mbTrack.ArtistMetadata.Value.Name); + } + + if (localTrack.FileTrackInfo.TrackNumbers[0] > 0 && mbTrack.AbsoluteTrackNumber > 0) + { + dist.AddBool("track_index", TrackIndexIncorrect(localTrack, mbTrack, totalTrackNumber)); + } + + var recordingId = localTrack.FileTrackInfo.RecordingMBId; + if (recordingId.IsNotNullOrWhiteSpace()) + { + dist.AddBool("recording_id", localTrack.FileTrackInfo.RecordingMBId != mbTrack.ForeignRecordingId); + } + + // for fingerprinted files + if (localTrack.AcoustIdResults != null) + { + dist.AddBool("recording_id", !localTrack.AcoustIdResults.Contains(mbTrack.ForeignRecordingId)); + } + + return dist; + } + + public Distance AlbumReleaseDistance(List<LocalTrack> localTracks, AlbumRelease release, TrackMapping mapping) + { + var dist = new Distance(); + + if (!VariousArtistIds.Contains(release.Album.Value.ArtistMetadata.Value.ForeignArtistId)) + { + var artist = MostCommon(localTracks.Select(x => x.FileTrackInfo.ArtistTitle)) ?? ""; + dist.AddString("artist", artist, release.Album.Value.ArtistMetadata.Value.Name); + _logger.Trace("artist: {0} vs {1}; {2}", artist, release.Album.Value.ArtistMetadata.Value.Name, dist.NormalizedDistance()); + } + + var title = MostCommon(localTracks.Select(x => x.FileTrackInfo.AlbumTitle)) ?? ""; + // Use the album title since the differences in release titles can cause confusion and + // aren't always correct in the tags + dist.AddString("album", title, release.Album.Value.Title); + _logger.Trace("album: {0} vs {1}; {2}", title, release.Title, dist.NormalizedDistance()); + + // Number of discs, either as tagged or the max disc number seen + var discCount = MostCommon(localTracks.Select(x => x.FileTrackInfo.DiscCount)); + discCount = discCount != 0 ? discCount : localTracks.Max(x => x.FileTrackInfo.DiscNumber); + if (discCount > 0) + { + dist.AddNumber("media_count", discCount, release.Media.Count); + _logger.Trace("media_count: {0} vs {1}; {2}", discCount, release.Media.Count, dist.NormalizedDistance()); + } + + // Media format + if (release.Media.Select(x => x.Format).Contains("Unknown")) + { + dist.Add("media_format", 1.0); + } + + // Year + var localYear = MostCommon(localTracks.Select(x => x.FileTrackInfo.Year)); + if (localYear > 0 && release.Album.Value.ReleaseDate.HasValue) + { + var albumYear = release.Album.Value.ReleaseDate.Value.Year; + var releaseYear = release.ReleaseDate.Value.Year; + if (localYear == albumYear || localYear == releaseYear) + { + dist.Add("year", 0.0); + } + else + { + var diff = Math.Abs(localYear - albumYear); + var diff_max = Math.Abs(DateTime.Now.Year - albumYear); + dist.AddRatio("year", diff, diff_max); + } + _logger.Trace("year: {0} vs {1}; {2}", localYear, release.Album.Value.ReleaseDate?.Year, dist.NormalizedDistance()); + } + + // If we parsed a country from the files use that, otherwise use our preference + var country = MostCommon(localTracks.Select(x => x.FileTrackInfo.Country)); + if (release.Country.Count > 0) + { + if (country != null) + { + dist.AddEquality("country", country.Name, release.Country); + _logger.Trace("country: {0} vs {1}; {2}", country, string.Join(", ", release.Country), dist.NormalizedDistance()); + } + else if (preferredCountries.Count > 0) + { + dist.AddPriority("country", release.Country, preferredCountries.Select(x => x.Name).ToList()); + _logger.Trace("country priority: {0} vs {1}; {2}", string.Join(", ", preferredCountries.Select(x => x.Name)), string.Join(", ", release.Country), dist.NormalizedDistance()); + } + } + else + { + // full penalty if MusicBrainz release is missing a country + dist.Add("country", 1.0); + } + + var label = MostCommon(localTracks.Select(x => x.FileTrackInfo.Label)); + if (label.IsNotNullOrWhiteSpace()) + { + dist.AddEquality("label", label, release.Label); + _logger.Trace("label: {0} vs {1}; {2}", label, string.Join(", ", release.Label), dist.NormalizedDistance()); + } + + var disambig = MostCommon(localTracks.Select(x => x.FileTrackInfo.Disambiguation)); + if (disambig.IsNotNullOrWhiteSpace()) + { + dist.AddString("album_disambiguation", disambig, release.Disambiguation); + _logger.Trace("album_disambiguation: {0} vs {1}; {2}", disambig, release.Disambiguation, dist.NormalizedDistance()); + } + + var mbAlbumId = MostCommon(localTracks.Select(x => x.FileTrackInfo.ReleaseMBId)); + if (mbAlbumId.IsNotNullOrWhiteSpace()) + { + dist.AddEquality("album_id", mbAlbumId, new List<string> { release.ForeignReleaseId }); + _logger.Trace("album_id: {0} vs {1}; {2}", mbAlbumId, release.ForeignReleaseId, dist.NormalizedDistance()); + } + + // tracks + foreach (var pair in mapping.Mapping) + { + dist.Add("tracks", pair.Value.Item2.NormalizedDistance()); + } + _logger.Trace("after trackMapping: {0}", dist.NormalizedDistance()); + + // missing tracks + foreach (var track in mapping.MBExtra.Take(localTracks.Count)) + { + dist.Add("missing_tracks", 1.0); + } + _logger.Trace("after missing tracks: {0}", dist.NormalizedDistance()); + + // unmatched tracks + foreach (var track in mapping.LocalExtra.Take(localTracks.Count)) + { + dist.Add("unmatched_tracks", 1.0); + } + _logger.Trace("after unmatched tracks: {0}", dist.NormalizedDistance()); + + return dist; + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/IdentificationTestCase.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/IdentificationTestCase.cs new file mode 100644 index 000000000..beed88338 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/IdentificationTestCase.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Profiles.Metadata; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Identification +{ + public class BasicLocalTrack + { + public string Path { get; set; } + public ParsedTrackInfo FileTrackInfo { get; set; } + } + + public class IdTestCase + { + public List<string> ExpectedMusicBrainzReleaseIds { get; set; } + public MetadataProfile MetadataProfile { get; set; } + public string Artist { get; set; } + public string Album { get; set; } + public string Release { get; set; } + public bool NewDownload { get; set; } + public bool SingleRelease { get; set; } + public List<BasicLocalTrack> Tracks { get; set; } + } +} + diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/Munkres.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/Munkres.cs new file mode 100644 index 000000000..651fb21a1 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/Munkres.cs @@ -0,0 +1,487 @@ +/* + The MIT License (MIT) + + Copyright (c) 2000 Robert A. Pilgrim + Murray State University + Dept. of Computer Science & Information Systems + Murray,Kentucky + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + + */ + +using System; +using System.Linq; +using System.Collections.Generic; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Identification +{ + public class Munkres + { + private double[,] C; + private readonly double[,] C_orig; + private int[,] M; + private int[,] path; + private int[] RowCover; + private int[] ColCover; + private readonly int n; + private readonly int nrow_orig; + private readonly int ncol_orig; + private int path_count; + private int path_row_0; + private int path_col_0; + private int step; + + public Munkres(double[,] costMatrix) + { + C = PadMatrix(costMatrix); + n = C.GetLength(0); + nrow_orig = costMatrix.GetLength(0); + ncol_orig = costMatrix.GetLength(1); + + C_orig = C.Clone() as double[,]; + RowCover = new int[n]; + ColCover = new int[n]; + M = new int[n, n]; + path = new int[2*n + 1, 2]; + + step = 1; + } + + public int[,] Marked + { + get + { + return M; + } + } + + public List<Tuple<int, int>> Solution + { + get + { + var value = new List<Tuple<int, int>>(); + for (int row = 0; row < nrow_orig; row++) + { + for (int col = 0; col < ncol_orig; col++) + { + if (M[row, col] == 1) + { + value.Add(Tuple.Create(row, col)); + } + } + } + return value; + } + } + + public double Cost + { + get + { + var soln = Solution; + return soln.Select(x => C_orig[x.Item1, x.Item2]).Sum(); + } + } + + private double[,] PadMatrix(double[,] matrix) + { + var newdim = Math.Max(matrix.GetLength(0), matrix.GetLength(1)); + var outp = new double[newdim, newdim]; + for (int row = 0; row < matrix.GetLength(0); row++) + { + for (int col = 0; col < matrix.GetLength(1); col++) + { + outp[row, col] = matrix[row, col]; + } + } + return outp; + } + + //For each row of the cost matrix, find the smallest element and subtract + //it from every element in its row. When finished, Go to Step 2. + private void step_one(ref int step) + { + double min_in_row; + + for (int r = 0; r < n; r++) + { + min_in_row = C[r, 0]; + for (int c = 0; c < n; c++) + { + if (C[r, c] < min_in_row) + { + min_in_row = C[r, c]; + } + } + for (int c = 0; c < n; c++) + { + C[r, c] -= min_in_row; + } + } + step = 2; + } + + //Find a zero (Z) in the resulting matrix. If there is no starred + //zero in its row or column, star Z. Repeat for each element in the + //matrix. Go to Step 3. + private void step_two(ref int step) + { + for (int r = 0; r < n; r++) + { + for (int c = 0; c < n; c++) + { + if (C[r, c] == 0 && RowCover[r] == 0 && ColCover[c] == 0) + { + M[r, c] = 1; + RowCover[r] = 1; + ColCover[c] = 1; + } + } + } + for (int r = 0; r < n; r++) + { + RowCover[r] = 0; + } + for (int c = 0; c < n; c++) + { + ColCover[c] = 0; + } + step = 3; + } + + //Cover each column containing a starred zero. If K columns are covered, + //the starred zeros describe a complete set of unique assignments. In this + //case, Go to DONE, otherwise, Go to Step 4. + private void step_three(ref int step) + { + int colcount; + for (int r = 0; r < n; r++) + { + for (int c = 0; c < n; c++) + { + if (M[r, c] == 1) + { + ColCover[c] = 1; + } + } + } + + colcount = 0; + for (int c = 0; c < n; c++) + { + if (ColCover[c] == 1) + { + colcount += 1; + } + } + if (colcount >= n) + { + step = 7; + } + else + { + step = 4; + } + } + + //methods to support step 4 + private void find_a_zero(ref int row, ref int col) + { + int r = 0; + int c; + bool done; + row = -1; + col = -1; + done = false; + while (!done) + { + c = 0; + while (true) + { + if (C[r, c] == 0 && RowCover[r] == 0 && ColCover[c] == 0) + { + row = r; + col = c; + done = true; + } + c += 1; + if (c >= n || done) + { + break; + } + } + r += 1; + if (r >= n) + { + done = true; + } + } + } + + private bool star_in_row(int row) + { + bool tmp = false; + for (int c = 0; c < n; c++) + { + if (M[row, c] == 1) + { + tmp = true; + } + } + return tmp; + } + + private void find_star_in_row(int row, ref int col) + { + col = -1; + for (int c = 0; c < n; c++) + { + if (M[row, c] == 1) + { + col = c; + } + } + } + + //Find a noncovered zero and prime it. If there is no starred zero + //in the row containing this primed zero, Go to Step 5. Otherwise, + //cover this row and uncover the column containing the starred zero. + //Continue in this manner until there are no uncovered zeros left. + //Save the smallest uncovered value and Go to Step 6. + private void step_four(ref int step) + { + int row = -1; + int col = -1; + bool done; + + done = false; + while (!done) + { + find_a_zero(ref row, ref col); + if (row == -1) + { + done = true; + step = 6; + } + else + { + M[row, col] = 2; + if (star_in_row(row)) + { + find_star_in_row(row, ref col); + RowCover[row] = 1; + ColCover[col] = 0; + } + else + { + done = true; + step = 5; + path_row_0 = row; + path_col_0 = col; + } + } + } + } + + // methods to support step 5 + private void find_star_in_col(int c, ref int r) + { + r = -1; + for (int i = 0; i < n; i++) + { + if (M[i, c] == 1) + { + r = i; + } + } + } + + private void find_prime_in_row(int r, ref int c) + { + for (int j = 0; j < n; j++) + { + if (M[r, j] == 2) + { + c = j; + } + } + } + + private void augment_path() + { + for (int p = 0; p < path_count; p++) + { + if (M[path[p, 0], path[p, 1]] == 1) + { + M[path[p, 0], path[p, 1]] = 0; + } + else + { + M[path[p, 0], path[p, 1]] = 1; + } + } + } + + private void clear_covers() + { + for (int r = 0; r < n; r++) + { + RowCover[r] = 0; + } + for (int c = 0; c < n; c++) + { + ColCover[c] = 0; + } + } + + private void erase_primes() + { + for (int r = 0; r < n; r++) + { + for (int c = 0; c < n; c++) + { + if (M[r, c] == 2) + { + M[r, c] = 0; + } + } + } + } + + + //Construct a series of alternating primed and starred zeros as follows. + //Let Z0 represent the uncovered primed zero found in Step 4. Let Z1 denote + //the starred zero in the column of Z0 (if any). Let Z2 denote the primed zero + //in the row of Z1 (there will always be one). Continue until the series + //terminates at a primed zero that has no starred zero in its column. + //Unstar each starred zero of the series, star each primed zero of the series, + //erase all primes and uncover every line in the matrix. Return to Step 3. + private void step_five(ref int step) + { + bool done; + int r = -1; + int c = -1; + + path_count = 1; + path[path_count - 1, 0] = path_row_0; + path[path_count - 1, 1] = path_col_0; + done = false; + while (!done) + { + find_star_in_col(path[path_count - 1, 1], ref r); + if (r > -1) + { + path_count += 1; + path[path_count - 1, 0] = r; + path[path_count - 1, 1] = path[path_count - 2, 1]; + } + else + { + done = true; + } + if (!done) + { + find_prime_in_row(path[path_count - 1, 0], ref c); + path_count += 1; + path[path_count - 1, 0] = path[path_count - 2, 0]; + path[path_count - 1, 1] = c; + } + } + augment_path(); + clear_covers(); + erase_primes(); + step = 3; + } + + //methods to support step 6 + private void find_smallest(ref double minval) + { + for (int r = 0; r < n; r++) + { + for (int c = 0; c < n; c++) + { + if (RowCover[r] == 0 && ColCover[c] == 0) + { + if (minval > C[r, c]) + { + minval = C[r, c]; + } + } + } + } + } + + //Add the value found in Step 4 to every element of each covered row, and subtract + //it from every element of each uncovered column. Return to Step 4 without + //altering any stars, primes, or covered lines. + private void step_six(ref int step) + { + double minval = double.MaxValue; + find_smallest(ref minval); + for (int r = 0; r < n; r++) + { + for (int c = 0; c < n; c++) + { + if (RowCover[r] == 1) + { + C[r, c] += minval; + } + if (ColCover[c] == 0) + { + C[r, c] -= minval; + } + } + } + step = 4; + } + + public void Run() + { + bool done = false; + while (!done) + { + switch (step) + { + case 1: + step_one(ref step); + break; + case 2: + step_two(ref step); + break; + case 3: + step_three(ref step); + break; + case 4: + step_four(ref step); + break; + case 5: + step_five(ref step); + break; + case 6: + step_six(ref step); + break; + case 7: + done = true; + break; + } + } + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/TrackGroupingService.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/TrackGroupingService.cs new file mode 100644 index 000000000..cc5a062d2 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Identification/TrackGroupingService.cs @@ -0,0 +1,247 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using NLog; +using NzbDrone.Common.Extensions; +using NzbDrone.Common.Instrumentation; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Identification +{ + public interface ITrackGroupingService + { + List<LocalAlbumRelease> GroupTracks(List<LocalTrack> localTracks); + } + + public class TrackGroupingService : ITrackGroupingService + { + private static readonly Logger _logger = NzbDroneLogger.GetLogger(typeof(TrackGroupingService)); + + private static readonly List<string> multiDiscMarkers = new List<string> { @"dis[ck]", @"cd" }; + private static readonly string multiDiscPatternFormat = @"^(?<root>.*%s[\W_]*)\d"; + private static readonly List<string> VariousArtistTitles = new List<string> { "", "various artists", "various", "va", "unknown" }; + + public List<LocalAlbumRelease> GroupTracks(List<LocalTrack> localTracks) + { + var releases = new List<LocalAlbumRelease>(); + + // first attempt, assume grouped by folder + var unprocessed = new List<LocalTrack>(); + foreach (var group in GroupTracksByDirectory(localTracks)) + { + var tracks = group.ToList(); + if (LooksLikeSingleRelease(tracks)) + { + releases.Add(new LocalAlbumRelease(tracks)); + } + else + { + unprocessed.AddRange(tracks); + } + } + + // If anything didn't get grouped correctly, try grouping by Album (to pick up VA) + var unprocessed2 = new List<LocalTrack>(); + foreach (var group in unprocessed.GroupBy(x => x.FileTrackInfo.AlbumTitle)) + { + _logger.Debug("Falling back to grouping by album tag"); + var tracks = group.ToList(); + if (LooksLikeSingleRelease(tracks)) + { + releases.Add(new LocalAlbumRelease(tracks)); + } + else + { + unprocessed2.AddRange(tracks); + } + } + + // Finally fall back to grouping by Album/Artist pair + foreach (var group in unprocessed2.GroupBy(x => new { x.FileTrackInfo.ArtistTitle, x.FileTrackInfo.AlbumTitle} )) + { + _logger.Debug("Falling back to grouping by album+artist tag"); + releases.Add(new LocalAlbumRelease(group.ToList())); + } + + return releases; + } + + private static bool HasCommonEntry(IEnumerable<string> values, double threshold, double fuzz) + { + var groups = values.GroupBy(x => x).OrderByDescending(x => x.Count()); + var distinctCount = groups.Count(); + var mostCommonCount = groups.First().Count(); + var mostCommonEntry = groups.First().Key; + var totalCount = values.Count(); + + // merge groups that are close to the most common value + foreach(var group in groups.Skip(1)) + { + if (mostCommonEntry.IsNotNullOrWhiteSpace() && + group.Key.IsNotNullOrWhiteSpace() && + mostCommonEntry.LevenshteinCoefficient(group.Key) > fuzz) + { + distinctCount--; + mostCommonCount += group.Count(); + } + } + + _logger.Trace($"DistinctCount {distinctCount} MostCommonCount {mostCommonCount} TotalCout {totalCount}"); + + if (distinctCount > 1 && + (distinctCount / (double)totalCount > threshold || + mostCommonCount / (double) totalCount < 1 - threshold)) + { + return false; + } + + return true; + } + + public static bool LooksLikeSingleRelease(List<LocalTrack> tracks) + { + // returns true if we think all the tracks belong to a single release + + // artist/album tags must be the same for 75% of tracks, with no more than 25% having different values + // (except in the case of various artists) + + const double albumTagThreshold = 0.25; + const double artistTagThreshold = 0.25; + const double tagFuzz = 0.9; + + // check that any Album/Release MBID is unique + if (tracks.Select(x => x.FileTrackInfo.AlbumMBId).Distinct().Where(x => x.IsNotNullOrWhiteSpace()).Count() > 1 || + tracks.Select(x => x.FileTrackInfo.ReleaseMBId).Distinct().Where(x => x.IsNotNullOrWhiteSpace()).Count() > 1) + { + _logger.Trace("LooksLikeSingleRelease: MBIDs are not unique"); + return false; + } + + // check that there's a common album tag. + var albumTags = tracks.Select(x => x.FileTrackInfo.AlbumTitle); + if (!HasCommonEntry(albumTags, albumTagThreshold, tagFuzz)) + { + _logger.Trace("LooksLikeSingleRelease: No common album tag"); + return false; + } + + // If not various artists, make sure artists are sensible + if (!IsVariousArtists(tracks)) + { + var artistTags = tracks.Select(x => x.FileTrackInfo.ArtistTitle); + if (!HasCommonEntry(artistTags, artistTagThreshold, tagFuzz)) + { + _logger.Trace("LooksLikeSingleRelease: No common artist tag"); + return false; + } + } + + return true; + } + + public static bool IsVariousArtists(List<LocalTrack> tracks) + { + // checks whether most common title is a known VA title + // Also checks whether more than 75% of tracks have a distinct artist and that the most common artist + // is responsible for < 25% of tracks + const double artistTagThreshold = 0.75; + const double tagFuzz = 0.9; + + var artistTags = tracks.Select(x => x.FileTrackInfo.ArtistTitle); + + if (!HasCommonEntry(artistTags, artistTagThreshold, tagFuzz)) + { + return true; + } + + if (VariousArtistTitles.Contains(artistTags.GroupBy(x => x).OrderByDescending(x => x.Count()).First().Key, StringComparer.OrdinalIgnoreCase)) + { + return true; + } + + return false; + } + + private IEnumerable<List<LocalTrack>> GroupTracksByDirectory(List<LocalTrack> tracks) + { + // we want to check for layouts like: + // xx/CD1/1.mp3 + // xx/CD2/1.mp3 + // or + // yy Disc 1/1.mp3 + // yy Disc 2/1.mp3 + // and group them. + + // we only bother doing this for the immediate parent directory. + var paths = tracks.Select(x => x.Path); + var folders = paths.Select(x => Path.GetDirectoryName(x)).Distinct().ToList(); + folders.Sort(); + + _logger.Trace("Folders:\n{0}", string.Join("\n", folders)); + + Regex subdirRegex = null; + var output = new List<LocalTrack>(); + foreach (var folder in folders) + { + if (subdirRegex != null) + { + if (subdirRegex.IsMatch(folder)) + { + // current folder continues match, so append output + output.AddRange(tracks.Where(x => x.Path.StartsWith(folder))); + continue; + } + } + + // we have finished a multi disc match. yield the previous output + // and check current folder + if (output.Count > 0) + { + _logger.Trace("Yielding from 1:\n{0}", string.Join("\n", output)); + yield return output; + + output = new List<LocalTrack>(); + } + + // reset and put current folder into output + subdirRegex = null; + output.AddRange(tracks.Where(x => x.Path.StartsWith(folder))); + + // check if the start of another multi disc match + foreach (var marker in multiDiscMarkers) + { + // check if this is the first of a multi-disc set of folders + var pattern = multiDiscPatternFormat.Replace("%s", marker); + var multiStartRegex = new Regex(pattern, RegexOptions.IgnoreCase); + + var match = multiStartRegex.Match(folder); + if (match.Success) + { + var subdirPattern = $"^{Regex.Escape(match.Groups["root"].ToString())}\\d+$"; + subdirRegex = new Regex(subdirPattern, RegexOptions.IgnoreCase); + break; + } + } + + if (subdirRegex == null) + { + // not the start of a multi-disc match, yield + _logger.Trace("Yielding from 2:\n{0}", string.Join("\n", output)); + yield return output; + + // reset output + output = new List<LocalTrack>(); + } + } + + // return the final stored output + if (output.Count > 0) + { + _logger.Trace("Yielding final:\n{0}", string.Join("\n", output)); + yield return output; + } + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs index 098b5fae0..f764dade3 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportApprovedTracks.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.IO; using System.Linq; using NLog; using NzbDrone.Common.Disk; @@ -11,19 +12,22 @@ using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Music; using NzbDrone.Core.Music.Events; +using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Qualities; namespace NzbDrone.Core.MediaFiles.TrackImport { public interface IImportApprovedTracks { - List<ImportResult> Import(List<ImportDecision> decisions, bool newDownload, DownloadClientItem downloadClientItem = null, ImportMode importMode = ImportMode.Auto); + List<ImportResult> Import(List<ImportDecision<LocalTrack>> decisions, bool newDownload, DownloadClientItem downloadClientItem = null, ImportMode importMode = ImportMode.Auto); } public class ImportApprovedTracks : IImportApprovedTracks { private readonly IUpgradeMediaFiles _trackFileUpgrader; private readonly IMediaFileService _mediaFileService; + private readonly ITrackService _trackService; + private readonly IRecycleBinProvider _recycleBinProvider; private readonly IExtraService _extraService; private readonly IDiskProvider _diskProvider; private readonly IReleaseService _releaseService; @@ -32,6 +36,8 @@ namespace NzbDrone.Core.MediaFiles.TrackImport public ImportApprovedTracks(IUpgradeMediaFiles trackFileUpgrader, IMediaFileService mediaFileService, + ITrackService trackService, + IRecycleBinProvider recycleBinProvider, IExtraService extraService, IDiskProvider diskProvider, IReleaseService releaseService, @@ -40,6 +46,8 @@ namespace NzbDrone.Core.MediaFiles.TrackImport { _trackFileUpgrader = trackFileUpgrader; _mediaFileService = mediaFileService; + _trackService = trackService; + _recycleBinProvider = recycleBinProvider; _extraService = extraService; _diskProvider = diskProvider; _releaseService = releaseService; @@ -47,13 +55,13 @@ namespace NzbDrone.Core.MediaFiles.TrackImport _logger = logger; } - public List<ImportResult> Import(List<ImportDecision> decisions, bool newDownload, DownloadClientItem downloadClientItem = null, ImportMode importMode = ImportMode.Auto) + public List<ImportResult> Import(List<ImportDecision<LocalTrack>> decisions, bool newDownload, DownloadClientItem downloadClientItem = null, ImportMode importMode = ImportMode.Auto) { var qualifiedImports = decisions.Where(c => c.Approved) - .GroupBy(c => c.LocalTrack.Artist.Id, (i, s) => s - .OrderByDescending(c => c.LocalTrack.Quality, new QualityModelComparer(s.First().LocalTrack.Artist.Profile)) - .ThenByDescending(c => c.LocalTrack.Language, new LanguageComparer(s.First().LocalTrack.Artist.LanguageProfile)) - .ThenByDescending(c => c.LocalTrack.Size)) + .GroupBy(c => c.Item.Artist.Id, (i, s) => s + .OrderByDescending(c => c.Item.Quality, new QualityModelComparer(s.First().Item.Artist.Profile)) + .ThenByDescending(c => c.Item.Language, new LanguageComparer(s.First().Item.Artist.LanguageProfile)) + .ThenByDescending(c => c.Item.Size)) .SelectMany(c => c) .ToList(); @@ -61,32 +69,64 @@ namespace NzbDrone.Core.MediaFiles.TrackImport var allImportedTrackFiles = new List<TrackFile>(); var allOldTrackFiles = new List<TrackFile>(); - var albumDecisions = decisions.Where(e => e.LocalTrack.Album != null) - .GroupBy(e => e.LocalTrack.Album.Id).ToList(); + var albumDecisions = decisions.Where(e => e.Item.Album != null) + .GroupBy(e => e.Item.Album.Id).ToList(); foreach (var albumDecision in albumDecisions) { - // set the correct release to be monitored after doing the import - var album = albumDecision.First().LocalTrack.Album; - var release = albumDecision.First().LocalTrack.Release; - _logger.Debug("Updating release to {0} [{1} tracks]", release, release.TrackCount); - _releaseService.SetMonitored(release); - - // Publish album edited event. - // Deliberatly don't put in the old album since we don't want to trigger an ArtistScan. - _eventAggregator.PublishEvent(new AlbumEditedEvent(album, album)); + var album = albumDecision.First().Item.Album; + var currentRelease = album.AlbumReleases.Value.Single(x => x.Monitored); + + if (albumDecision.Any(x => x.Approved)) + { + var newRelease = albumDecision.First(x => x.Approved).Item.Release; + + if (currentRelease.Id != newRelease.Id) + { + // if we are importing a new release, delete all old files and don't attempt to upgrade + if (newDownload) + { + var artist = albumDecision.First().Item.Artist; + var rootFolder = _diskProvider.GetParentFolder(artist.Path); + var previousFiles = _mediaFileService.GetFilesByAlbum(album.Id); + + foreach (var previousFile in previousFiles) + { + var trackFilePath = Path.Combine(artist.Path, previousFile.RelativePath); + var subfolder = rootFolder.GetRelativePath(_diskProvider.GetParentFolder(trackFilePath)); + if (_diskProvider.FileExists(trackFilePath)) + { + _logger.Debug("Removing existing track file: {0}", previousFile); + _recycleBinProvider.DeleteFile(trackFilePath, subfolder); + } + _mediaFileService.Delete(previousFile, DeleteMediaFileReason.Upgrade); + } + } + + // set the correct release to be monitored before importing the new files + _logger.Debug("Updating release to {0} [{1} tracks]", newRelease, newRelease.TrackCount); + _releaseService.SetMonitored(newRelease); + + // Publish album edited event. + // Deliberatly don't put in the old album since we don't want to trigger an ArtistScan. + _eventAggregator.PublishEvent(new AlbumEditedEvent(album, album)); + } + } } - foreach (var importDecision in qualifiedImports.OrderBy(e => e.LocalTrack.Tracks.Select(track => track.AbsoluteTrackNumber).MinOrDefault()) - .ThenByDescending(e => e.LocalTrack.Size)) + var filesToAdd = new List<TrackFile>(qualifiedImports.Count); + var albumReleasesDict = new Dictionary<int, List<AlbumRelease>>(albumDecisions.Count); + + foreach (var importDecision in qualifiedImports.OrderBy(e => e.Item.Tracks.Select(track => track.AbsoluteTrackNumber).MinOrDefault()) + .ThenByDescending(e => e.Item.Size)) { - var localTrack = importDecision.LocalTrack; + var localTrack = importDecision.Item; var oldFiles = new List<TrackFile>(); try { //check if already imported - if (importResults.SelectMany(r => r.ImportDecision.LocalTrack.Tracks) + if (importResults.SelectMany(r => r.ImportDecision.Item.Tracks) .Select(e => e.Id) .Intersect(localTrack.Tracks.Select(e => e.Id)) .Any()) @@ -95,16 +135,36 @@ namespace NzbDrone.Core.MediaFiles.TrackImport continue; } + // cache album releases and set artist to speed up firing the TrackImported events + // (otherwise they'll be retrieved from the DB for each track) + if (!albumReleasesDict.ContainsKey(localTrack.Album.Id)) + { + albumReleasesDict.Add(localTrack.Album.Id, localTrack.Album.AlbumReleases.Value); + } + if (!localTrack.Album.AlbumReleases.IsLoaded) + { + localTrack.Album.AlbumReleases = albumReleasesDict[localTrack.Album.Id]; + } + localTrack.Album.Artist = localTrack.Artist; + + foreach (var track in localTrack.Tracks) + { + track.Artist = localTrack.Artist; + track.AlbumRelease = localTrack.Release; + track.Album = localTrack.Album; + } var trackFile = new TrackFile { Path = localTrack.Path.CleanFilePath(), Size = _diskProvider.GetFileSize(localTrack.Path), DateAdded = DateTime.UtcNow, - ReleaseGroup = localTrack.ParsedTrackInfo.ReleaseGroup, + ReleaseGroup = localTrack.ReleaseGroup, Quality = localTrack.Quality, - MediaInfo = localTrack.MediaInfo, + MediaInfo = localTrack.FileTrackInfo.MediaInfo, Language = localTrack.Language, AlbumId = localTrack.Album.Id, + Artist = localTrack.Artist, + Album = localTrack.Album, Tracks = localTrack.Tracks }; @@ -144,7 +204,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport } - _mediaFileService.Add(trackFile); + filesToAdd.Add(trackFile); importResults.Add(new ImportResult(importDecision)); if (newDownload) @@ -156,7 +216,6 @@ namespace NzbDrone.Core.MediaFiles.TrackImport allOldTrackFiles.AddRange(oldFiles); _eventAggregator.PublishEvent(new TrackImportedEvent(localTrack, trackFile, oldFiles, newDownload, downloadClientItem)); - } catch (RootFolderNotFoundException e) { @@ -182,14 +241,22 @@ namespace NzbDrone.Core.MediaFiles.TrackImport } } - var albumImports = importResults.Where(e => e.ImportDecision.LocalTrack.Album != null) - .GroupBy(e => e.ImportDecision.LocalTrack.Album.Id).ToList(); + var watch = new System.Diagnostics.Stopwatch(); + watch.Start(); + _mediaFileService.AddMany(filesToAdd); + _logger.Debug($"Inserted new trackfiles in {watch.ElapsedMilliseconds}ms"); + filesToAdd.ForEach(f => f.Tracks.Value.ForEach(t => t.TrackFileId = f.Id)); + _trackService.SetFileIds(filesToAdd.SelectMany(x => x.Tracks.Value).ToList()); + _logger.Debug($"TrackFileIds updated, total {watch.ElapsedMilliseconds}ms"); + + var albumImports = importResults.Where(e => e.ImportDecision.Item.Album != null) + .GroupBy(e => e.ImportDecision.Item.Album.Id).ToList(); foreach (var albumImport in albumImports) { - var release = albumImport.First().ImportDecision.LocalTrack.Release; - var album = albumImport.First().ImportDecision.LocalTrack.Album; - var artist = albumImport.First().ImportDecision.LocalTrack.Artist; + var release = albumImport.First().ImportDecision.Item.Release; + var album = albumImport.First().ImportDecision.Item.Album; + var artist = albumImport.First().ImportDecision.Item.Artist; if (albumImport.Where(e => e.Errors.Count == 0).ToList().Count > 0 && artist != null && album != null) { diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecision.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecision.cs index f43b6fdce..36a1df843 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecision.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecision.cs @@ -8,16 +8,16 @@ using System.Text; namespace NzbDrone.Core.MediaFiles.TrackImport { - public class ImportDecision + public class ImportDecision<T> { - public LocalTrack LocalTrack { get; private set; } + public T Item { get; private set; } public IList<Rejection> Rejections { get; private set; } public bool Approved => Rejections.Empty(); - public ImportDecision(LocalTrack localTrack, params Rejection[] rejections) + public ImportDecision(T localTrack, params Rejection[] rejections) { - LocalTrack = localTrack; + Item = localTrack; Rejections = rejections.ToList(); } diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecisionMaker.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecisionMaker.cs index d644ecb35..d6d039dd9 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecisionMaker.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportDecisionMaker.cs @@ -1,389 +1,230 @@ using System; using System.Collections.Generic; -using System.IO; using System.Linq; using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Core.DecisionEngine; -using NzbDrone.Core.Parser; using NzbDrone.Core.Parser.Model; -using NzbDrone.Core.Qualities; -using NzbDrone.Core.MediaFiles.MediaInfo; using NzbDrone.Core.Music; -using NzbDrone.Core.Languages; using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Music.Events; -using System.Diagnostics; -using NzbDrone.Common.EnsureThat; +using NzbDrone.Core.Download; +using NzbDrone.Core.MediaFiles.TrackImport.Aggregation; +using NzbDrone.Core.MediaFiles.TrackImport.Identification; namespace NzbDrone.Core.MediaFiles.TrackImport { public interface IMakeImportDecision { - List<ImportDecision> GetImportDecisions(List<string> musicFiles, Artist artist); - List<ImportDecision> GetImportDecisions(List<string> musicFiles, Artist artist, ParsedTrackInfo folderInfo); - List<ImportDecision> GetImportDecisions(List<string> musicFiles, Artist artist, ParsedTrackInfo folderInfo, bool filterExistingFiles, bool timidReleaseSwitching); - ImportDecision GetImportDecision(string musicFile, Artist artist, Album album); + List<ImportDecision<LocalTrack>> GetImportDecisions(List<string> musicFiles, Artist artist); + List<ImportDecision<LocalTrack>> GetImportDecisions(List<string> musicFiles, Artist artist, ParsedTrackInfo folderInfo); + List<ImportDecision<LocalTrack>> GetImportDecisions(List<string> musicFiles, Artist artist, Album album, DownloadClientItem downloadClientItem, ParsedTrackInfo folderInfo, bool filterExistingFiles, bool newDownload, bool singleRelease); } public class ImportDecisionMaker : IMakeImportDecision { - private readonly IEnumerable<IImportDecisionEngineSpecification> _specifications; - private readonly IParsingService _parsingService; + private readonly IEnumerable<IImportDecisionEngineSpecification<LocalTrack>> _trackSpecifications; + private readonly IEnumerable<IImportDecisionEngineSpecification<LocalAlbumRelease>> _albumSpecifications; private readonly IMediaFileService _mediaFileService; + private readonly IAugmentingService _augmentingService; + private readonly IIdentificationService _identificationService; private readonly IAlbumService _albumService; private readonly IReleaseService _releaseService; private readonly IEventAggregator _eventAggregator; private readonly IDiskProvider _diskProvider; - private readonly IVideoFileInfoReader _videoFileInfoReader; private readonly Logger _logger; - public ImportDecisionMaker(IEnumerable<IImportDecisionEngineSpecification> specifications, - IParsingService parsingService, + public ImportDecisionMaker(IEnumerable<IImportDecisionEngineSpecification<LocalTrack>> trackSpecifications, + IEnumerable<IImportDecisionEngineSpecification<LocalAlbumRelease>> albumSpecifications, IMediaFileService mediaFileService, + IAugmentingService augmentingService, + IIdentificationService identificationService, IAlbumService albumService, IReleaseService releaseService, IEventAggregator eventAggregator, IDiskProvider diskProvider, - IVideoFileInfoReader videoFileInfoReader, Logger logger) { - _specifications = specifications; - _parsingService = parsingService; + _trackSpecifications = trackSpecifications; + _albumSpecifications = albumSpecifications; _mediaFileService = mediaFileService; + _augmentingService = augmentingService; + _identificationService = identificationService; _albumService = albumService; _releaseService = releaseService; _eventAggregator = eventAggregator; _diskProvider = diskProvider; - _videoFileInfoReader = videoFileInfoReader; _logger = logger; } - public List<ImportDecision> GetImportDecisions(List<string> musicFiles, Artist artist) + public List<ImportDecision<LocalTrack>> GetImportDecisions(List<string> musicFiles, Artist artist) { - return GetImportDecisions(musicFiles, artist, null); + return GetImportDecisions(musicFiles, artist, null, null, null, false, false, false); } - public List<ImportDecision> GetImportDecisions(List<string> musicFiles, Artist artist, ParsedTrackInfo folderInfo) + public List<ImportDecision<LocalTrack>> GetImportDecisions(List<string> musicFiles, Artist artist, ParsedTrackInfo folderInfo) { - return GetImportDecisions(musicFiles, artist, folderInfo, false, false); + return GetImportDecisions(musicFiles, artist, null, null, folderInfo, false, true, false); } - private bool MatchesCurrentRelease(ImportDecision decision) + public List<ImportDecision<LocalTrack>> GetImportDecisions(List<string> musicFiles, Artist artist, Album album, DownloadClientItem downloadClientItem, ParsedTrackInfo folderInfo, bool filterExistingFiles, bool newDownload, bool singleRelease) { - return decision.Approved || decision.Rejections.Select(x => x.Reason).Contains("Has the same filesize as existing file"); - } - - public List<ImportDecision> GetImportDecisions(List<string> musicFiles, Artist artist, ParsedTrackInfo folderInfo, bool filterExistingFiles, bool timidReleaseSwitching) - { - var files = filterExistingFiles ? _mediaFileService.FilterExistingFiles(musicFiles.ToList(), artist) : musicFiles.ToList(); + var watch = new System.Diagnostics.Stopwatch(); + watch.Start(); + + var files = filterExistingFiles && (artist != null) ? _mediaFileService.FilterExistingFiles(musicFiles.ToList(), artist) : musicFiles.ToList(); _logger.Debug("Analyzing {0}/{1} files.", files.Count, musicFiles.Count); - var shouldUseFolderName = ShouldUseFolderName(musicFiles, artist, folderInfo); + ParsedAlbumInfo downloadClientItemInfo = null; - // We have to do this once to match against albums - var decisions = GetImportDecisionsForCurrentRelease(files, artist, folderInfo, shouldUseFolderName); - - // Now we have matched the files against albums, we can group by album and check for the best release - var albums = decisions.Where(x => x.LocalTrack.Album != null) - .Select(x => x.LocalTrack.Album) - .GroupBy(x => x.Id) - .Select(x => x.First()) - .ToList(); - - var revisedDecisions = decisions.Where(x => x.LocalTrack.Album == null).ToList(); - - foreach (var album in albums) + if (downloadClientItem != null) { - var albumDecisions = decisions.Where(x => x.LocalTrack.Album != null && x.LocalTrack.Album.Id == album.Id).ToList(); - revisedDecisions.AddRange(GetImportDecisions(albumDecisions, artist, album, folderInfo, shouldUseFolderName, timidReleaseSwitching)); + downloadClientItemInfo = Parser.Parser.ParseAlbumTitle(downloadClientItem.Title); } - Ensure.That(decisions.Count == revisedDecisions.Count).IsTrue(); + var localTracks = new List<LocalTrack>(); + var decisions = new List<ImportDecision<LocalTrack>>(); - return revisedDecisions; - } - - private List<ImportDecision> GetImportDecisionsForCurrentRelease(List<string> files, Artist artist, ParsedTrackInfo folderInfo, bool shouldUseFolderName) - { - var decisions = new List<ImportDecision>(); - foreach (var file in files) { - decisions.AddIfNotNull(GetDecision(file, artist, null, folderInfo, shouldUseFolderName)); - } - - return decisions; - } - - public ImportDecision GetImportDecision(string file, Artist artist, Album album) - { - return GetDecision(file, artist, album, null, false); - } - - public List<ImportDecision> GetImportDecisions(List<ImportDecision> decisions, Artist artist, Album album, ParsedTrackInfo folderInfo, bool shouldUseFolderName, bool timidReleaseSwitching) - { - _logger.Debug("Importing {0}", album); - var maxTrackCount = album.AlbumReleases.Value.Where(x => x.Monitored).Select(x => x.TrackCount).Max(); - var haveExistingFiles = _mediaFileService.GetFilesByAlbum(album.Id).Any(); - var releaseSwitchingAllowed = !(haveExistingFiles && timidReleaseSwitching); - - if (album.AnyReleaseOk && releaseSwitchingAllowed) - { - if (decisions.Any(x => !MatchesCurrentRelease(x)) || decisions.Count != maxTrackCount) + var localTrack = new LocalTrack { - _logger.Debug("Importing {0}: {1}/{2} files approved for {3} track release", - album, - decisions.Count(x => MatchesCurrentRelease(x)), - decisions.Count, - maxTrackCount); - return GetImportDecisionsForBestRelease(decisions, artist, album, folderInfo, shouldUseFolderName); + Artist = artist, + Album = album, + DownloadClientAlbumInfo = downloadClientItemInfo, + FolderTrackInfo = folderInfo, + Path = file, + FileTrackInfo = Parser.Parser.ParseMusicPath(file), + }; + + try + { + // TODO fix otherfiles? + _augmentingService.Augment(localTrack, true); + localTracks.Add(localTrack); } - else + catch (AugmentingFailedException) { - _logger.Debug("Importing {0}: All files approved and all tracks have a file", album); - return decisions; + decisions.Add(new ImportDecision<LocalTrack>(localTrack, new Rejection("Unable to parse file"))); } - } - else - { - _logger.Debug("Importing {0}: {1}/{2} files approved for {3} track release. Release switching not allowed.", - album, - decisions.Count(x => MatchesCurrentRelease(x)), - decisions.Count, - maxTrackCount); - return decisions; - } - } - - private List<ImportDecision> GetImportDecisionsForBestRelease(List<ImportDecision> decisions, Artist artist, Album album, ParsedTrackInfo folderInfo, bool shouldUseFolderName) - { - var files = decisions.Select(x => x.LocalTrack.Path).ToList(); - - // At the moment we assume only one release can be monitored at a time - var originalRelease = album.AlbumReleases.Value.Where(x => x.Monitored).Single(); - var candidateReleases = album.AlbumReleases.Value.Where(x => x.TrackCount >= files.Count && x.Id != originalRelease.Id).ToList(); - var bestRelease = originalRelease; - var bestMatchCount = decisions.Count(x => MatchesCurrentRelease(x)); - var bestDecisions = decisions; - - foreach (var release in candidateReleases) - { - _logger.Debug("Trying Release {0} [{1} tracks]", release, release.TrackCount); - album.AlbumReleases = _releaseService.SetMonitored(release); - var newDecisions = GetImportDecisionsForCurrentRelease(files, artist, folderInfo, shouldUseFolderName); - - _logger.Debug("Importing {0}: {1}/{2} files approved for {3} track release {4}", - album, - newDecisions.Count(x => MatchesCurrentRelease(x)), - newDecisions.Count, - release.TrackCount, - release); - - // We want the release that matches the most tracks. If there's a tie, - // we want the release with the fewest entries (i.e. fewest missing) - var currentMatchCount = newDecisions.Count(x => MatchesCurrentRelease(x)); - if (currentMatchCount > bestMatchCount - || (currentMatchCount == bestMatchCount && release.TrackCount < bestRelease.TrackCount)) + catch (Exception e) { - bestMatchCount = currentMatchCount; - bestRelease = release; - bestDecisions = newDecisions; - - if (currentMatchCount == release.TrackCount && newDecisions.All(x => MatchesCurrentRelease(x))) - { - break; - } + _logger.Error(e, "Couldn't import file. {0}", localTrack.Path); + + decisions.Add(new ImportDecision<LocalTrack>(localTrack, new Rejection("Unexpected error processing file"))); } } - _logger.Debug("{0} Best release: {1}", album, bestRelease); + _logger.Debug($"Tags parsed for {files.Count} files in {watch.ElapsedMilliseconds}ms"); - // reinstate the original release in case the import isn't run (manual import) - album.AlbumReleases = _releaseService.SetMonitored(originalRelease); - - return bestDecisions; - } + var releases = _identificationService.Identify(localTracks, artist, album, null, newDownload, singleRelease); - private ImportDecision GetDecision(string file, Artist artist, Album album, ParsedTrackInfo folderInfo, bool shouldUseFolderName) - { - ImportDecision decision = null; - - try + foreach (var release in releases) { - var localTrack = _parsingService.GetLocalTrack(file, artist, album, shouldUseFolderName ? folderInfo : null); + release.NewDownload = newDownload; + var releaseDecision = GetDecision(release); - if (localTrack != null) + foreach (var localTrack in release.LocalTracks) { - localTrack.Quality = GetQuality(folderInfo, localTrack.Quality, artist); - localTrack.Language = GetLanguage(folderInfo, localTrack.Language, artist); - localTrack.Size = _diskProvider.GetFileSize(file); - - _logger.Debug("Size: {0}", localTrack.Size); - - //TODO: make it so media info doesn't ruin the import process of a new artist - - if (localTrack.Tracks.Empty()) + if (releaseDecision.Approved) { - decision = localTrack.Album != null ? new ImportDecision(localTrack, new Rejection($"Couldn't parse track from: {localTrack.ParsedTrackInfo}")) : - new ImportDecision(localTrack, new Rejection($"Couldn't parse album from: {localTrack.ParsedTrackInfo}")); + decisions.AddIfNotNull(GetDecision(localTrack)); + } else { - decision = GetDecision(localTrack); + decisions.Add(new ImportDecision<LocalTrack>(localTrack, releaseDecision.Rejections.ToArray())); } } + } - else - { - localTrack = new LocalTrack(); - localTrack.Path = file; - localTrack.Quality = new QualityModel(Quality.Unknown); - localTrack.Language = Language.Unknown; + return decisions; + } - decision = new ImportDecision(localTrack, new Rejection("Unable to parse file")); - } + private ImportDecision<LocalAlbumRelease> GetDecision(LocalAlbumRelease localAlbumRelease) + { + ImportDecision<LocalAlbumRelease> decision = null; + + if (localAlbumRelease.AlbumRelease == null) + { + decision = new ImportDecision<LocalAlbumRelease>(localAlbumRelease, new Rejection($"Couldn't find similar album for {localAlbumRelease}")); } - catch (Exception e) + else { - _logger.Error(e, "Couldn't import file. {0}", file); + var reasons = _albumSpecifications.Select(c => EvaluateSpec(c, localAlbumRelease)) + .Where(c => c != null); - var localTrack = new LocalTrack { Path = file }; - decision = new ImportDecision(localTrack, new Rejection("Unexpected error processing file")); + decision = new ImportDecision<LocalAlbumRelease>(localAlbumRelease, reasons.ToArray()); } if (decision == null) { - _logger.Error("Unable to make a decision on {0}", file); + _logger.Error("Unable to make a decision on {0}", localAlbumRelease); } else if (decision.Rejections.Any()) { - _logger.Debug("File rejected for the following reasons: {0}", string.Join(", ", decision.Rejections)); + _logger.Debug("Album rejected for the following reasons: {0}", string.Join(", ", decision.Rejections)); } else { - _logger.Debug("File accepted"); + _logger.Debug("Album accepted"); } return decision; } - private ImportDecision GetDecision(LocalTrack localTrack) + private ImportDecision<LocalTrack> GetDecision(LocalTrack localTrack) { - var reasons = _specifications.Select(c => EvaluateSpec(c, localTrack)) - .Where(c => c != null); - - return new ImportDecision(localTrack, reasons.ToArray()); - } + ImportDecision<LocalTrack> decision = null; - private Rejection EvaluateSpec(IImportDecisionEngineSpecification spec, LocalTrack localTrack) - { - try + if (localTrack.Tracks.Empty()) { - var result = spec.IsSatisfiedBy(localTrack); - - if (!result.Accepted) - { - return new Rejection(result.Reason); - } + decision = localTrack.Album != null ? new ImportDecision<LocalTrack>(localTrack, new Rejection($"Couldn't parse track from: {localTrack.FileTrackInfo}")) : + new ImportDecision<LocalTrack>(localTrack, new Rejection($"Couldn't parse album from: {localTrack.FileTrackInfo}")); } - catch (Exception e) - { - //e.Data.Add("report", remoteEpisode.Report.ToJson()); - //e.Data.Add("parsed", remoteEpisode.ParsedEpisodeInfo.ToJson()); - _logger.Error(e, "Couldn't evaluate decision on {0}", localTrack.Path); - return new Rejection($"{spec.GetType().Name}: {e.Message}"); - } - - return null; - } - - private bool ShouldUseFolderName(List<string> musicFiles, Artist artist, ParsedTrackInfo folderInfo) - { - if (folderInfo == null) - { - return false; - } - - return musicFiles.Count(file => - { - - if (SceneChecker.IsSceneTitle(Path.GetFileName(file))) - { - return false; - } - - return true; - }) == 1; - } - - private QualityModel GetQuality(ParsedTrackInfo folderInfo, QualityModel fileQuality, Artist artist) - { - if (UseFolderQuality(folderInfo, fileQuality, artist)) + else { - _logger.Debug("Using quality from folder: {0}", folderInfo.Quality); - return folderInfo.Quality; - } + var reasons = _trackSpecifications.Select(c => EvaluateSpec(c, localTrack)) + .Where(c => c != null); - return fileQuality; - } - - private Language GetLanguage(ParsedTrackInfo folderInfo, Language fileLanguage, Artist artist) - { - if (UseFolderLanguage(folderInfo, fileLanguage, artist)) - { - _logger.Debug("Using language from folder: {0}", folderInfo.Language); - return folderInfo.Language; + decision = new ImportDecision<LocalTrack>(localTrack, reasons.ToArray()); } - return fileLanguage; - } - - private bool UseFolderLanguage(ParsedTrackInfo folderInfo, Language fileLanguage, Artist artist) - { - if (folderInfo == null) + if (decision == null) { - return false; + _logger.Error("Unable to make a decision on {0}", localTrack.Path); } - - if (folderInfo.Language == Language.Unknown) + else if (decision.Rejections.Any()) { - return false; + _logger.Debug("File rejected for the following reasons: {0}", string.Join(", ", decision.Rejections)); } - - if (new LanguageComparer(artist.LanguageProfile).Compare(folderInfo.Language, fileLanguage) > 0) + else { - return true; + _logger.Debug("File accepted"); } - return false; + return decision; } - private bool UseFolderQuality(ParsedTrackInfo folderInfo, QualityModel fileQuality, Artist artist) + private Rejection EvaluateSpec<T>(IImportDecisionEngineSpecification<T> spec, T item) { - if (folderInfo == null) + try { - return false; - } + var result = spec.IsSatisfiedBy(item); - if (folderInfo.Quality.Quality == Quality.Unknown) - { - return false; - } - - if (fileQuality.QualitySource == QualitySource.Extension) - { - return true; + if (!result.Accepted) + { + return new Rejection(result.Reason); + } } - - if (new QualityModelComparer(artist.Profile).Compare(folderInfo.Quality, fileQuality) > 0) + catch (Exception e) { - return true; + _logger.Error(e, "Couldn't evaluate decision on {0}", item); + return new Rejection($"{spec.GetType().Name}: {e.Message}"); } - return false; + return null; } } } diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportResult.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportResult.cs index 64f60869b..38235a29a 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/ImportResult.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/ImportResult.cs @@ -1,4 +1,5 @@ using NzbDrone.Common.EnsureThat; +using NzbDrone.Core.Parser.Model; using System; using System.Collections.Generic; using System.Linq; @@ -8,7 +9,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport { public class ImportResult { - public ImportDecision ImportDecision { get; private set; } + public ImportDecision<LocalTrack> ImportDecision { get; private set; } public List<string> Errors { get; private set; } public ImportResultType Result @@ -29,7 +30,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport } } - public ImportResult(ImportDecision importDecision, params string[] errors) + public ImportResult(ImportDecision<LocalTrack> importDecision, params string[] errors) { Ensure.That(importDecision, () => importDecision).IsNotNull(); diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportItem.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportItem.cs index 5e8fc38eb..32b5819e0 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportItem.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportItem.cs @@ -4,6 +4,7 @@ using NzbDrone.Core.Qualities; using NzbDrone.Core.Languages; using NzbDrone.Core.Music; using NzbDrone.Core.Datastore; +using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles.TrackImport.Manual { @@ -22,5 +23,6 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual public Language Language { get; set; } public string DownloadId { get; set; } public IEnumerable<Rejection> Rejections { get; set; } + public ParsedTrackInfo Tags { get; set; } } } diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs index 47125fa41..362e214ce 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Manual/ManualImportService.cs @@ -6,10 +6,8 @@ using NLog; using NzbDrone.Common.Disk; using NzbDrone.Common.Extensions; using NzbDrone.Common.Instrumentation.Extensions; -using NzbDrone.Core.DecisionEngine; using NzbDrone.Core.Download; using NzbDrone.Core.Download.TrackedDownloads; -using NzbDrone.Core.MediaFiles.MediaInfo; using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Parser; @@ -23,7 +21,7 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual public interface IManualImportService { List<ManualImportItem> GetMediaFiles(string path, string downloadId, bool filterExistingFiles); - void UpdateItem(ManualImportItem item); + void UpdateItems(List<ManualImportItem> item); ManualImportItem Find(int id); } @@ -37,7 +35,6 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual private readonly IAlbumService _albumService; private readonly IReleaseService _releaseService; private readonly ITrackService _trackService; - private readonly IVideoFileInfoReader _videoFileInfoReader; private readonly IImportApprovedTracks _importApprovedTracks; private readonly ITrackedDownloadService _trackedDownloadService; private readonly IDownloadedTracksImportService _downloadedTracksImportService; @@ -53,7 +50,6 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual IAlbumService albumService, IReleaseService releaseService, ITrackService trackService, - IVideoFileInfoReader videoFileInfoReader, IImportApprovedTracks importApprovedTracks, ITrackedDownloadService trackedDownloadService, IDownloadedTracksImportService downloadedTracksImportService, @@ -69,7 +65,6 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual _albumService = albumService; _releaseService = releaseService; _trackService = trackService; - _videoFileInfoReader = videoFileInfoReader; _importApprovedTracks = importApprovedTracks; _trackedDownloadService = trackedDownloadService; _downloadedTracksImportService = downloadedTracksImportService; @@ -106,10 +101,11 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual return new List<ManualImportItem>(); } - var decision = ProcessFile(path, downloadId); - _cache.Set(decision.Id.ToString(), decision); + var decision = _importDecisionMaker.GetImportDecisions(new List<string> { path }, null, null, null, null, false, true, false); + var result = MapItem(decision.First(), Path.GetDirectoryName(path), downloadId); + _cache.Set(result.Id.ToString(), result); - return new List<ManualImportItem> { decision }; + return new List<ManualImportItem> { result }; } var items = ProcessFolder(path, downloadId, filterExistingFiles); @@ -132,130 +128,81 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual artist = trackedDownload.RemoteAlbum.Artist; } - if (artist == null) - { - var files = _diskScanService.FilterFiles(folder, _diskScanService.GetAudioFiles(folder)); - - return files.Select(file => ProcessFile(file, downloadId, folder)).Where(i => i != null).ToList(); - } - var folderInfo = Parser.Parser.ParseMusicTitle(directoryInfo.Name); var artistFiles = _diskScanService.GetAudioFiles(folder).ToList(); - var decisions = _importDecisionMaker.GetImportDecisions(artistFiles, artist, folderInfo, filterExistingFiles, true); + var decisions = _importDecisionMaker.GetImportDecisions(artistFiles, artist, null, null, folderInfo, filterExistingFiles, true, false); return decisions.Select(decision => MapItem(decision, folder, downloadId)).ToList(); } - public void UpdateItem(ManualImportItem item) + public void UpdateItems(List<ManualImportItem> items) { - var decision = _importDecisionMaker.GetImportDecision(item.Path, item.Artist, item.Album); - - if (decision.LocalTrack.Artist != null) - { - item.Artist = decision.LocalTrack.Artist; - } - - if (decision.LocalTrack.Album != null) - { - item.Album = decision.LocalTrack.Album; - item.Release = decision.LocalTrack.Release; - } - - if (decision.LocalTrack.Tracks.Any()) + var groupedItems = items.GroupBy(x => x.Album?.Id); + _logger.Debug("UpdateItems, {0} groups", groupedItems.Count()); + foreach(var group in groupedItems) { - item.Tracks = decision.LocalTrack.Tracks; - } - - item.Rejections = decision.Rejections; + // generate dummy decisions that don't match the release + _logger.Debug("UpdateItems, group key: {0}", group.Key); + var decisions = _importDecisionMaker.GetImportDecisions(group.Select(x => x.Path).ToList(), group.First().Artist, group.First().Album, null, null, false, true, true); - _cache.Set(item.Id.ToString(), item); - } - - private ManualImportItem ProcessFile(string file, string downloadId, string folder = null) - { - if (folder.IsNullOrWhiteSpace()) - { - folder = new FileInfo(file).Directory.FullName; - } - - var relativeFile = folder.GetRelativePath(file); - - var artist = _parsingService.GetArtist(relativeFile.Split('\\', '/')[0]); - - if (artist == null) - { - artist = _parsingService.GetArtistFromTag(file); - } + foreach (var decision in decisions) + { + var item = items.Where(x => x.Path == decision.Item.Path).Single(); - if (artist == null && downloadId.IsNotNullOrWhiteSpace()) - { - var trackedDownload = _trackedDownloadService.Find(downloadId); - artist = trackedDownload.RemoteAlbum.Artist; - } + if (decision.Item.Artist != null) + { + item.Artist = decision.Item.Artist; + } - if (artist == null) - { - var localTrack = new LocalTrack(); - localTrack.Path = file; - localTrack.Quality = QualityParser.ParseQuality(file, null, 0); - localTrack.Language = LanguageParser.ParseLanguage(file); - localTrack.Size = _diskProvider.GetFileSize(file); + if (decision.Item.Album != null) + { + item.Album = decision.Item.Album; + item.Release = decision.Item.Release; + } - return MapItem(new ImportDecision(localTrack, new Rejection("Unknown Artist")), folder, downloadId); - } + if (decision.Item.Tracks.Any()) + { + item.Tracks = decision.Item.Tracks; + } - var importDecisions = _importDecisionMaker.GetImportDecisions(new List<string> { file }, - artist, null); + item.Rejections = decision.Rejections; - return importDecisions.Any() ? MapItem(importDecisions.First(), folder, downloadId) : new ManualImportItem - { - Id = HashConverter.GetHashInt31(file), - DownloadId = downloadId, - Path = file, - RelativePath = folder.GetRelativePath(file), - Name = Path.GetFileNameWithoutExtension(file), - Rejections = new List<Rejection> - { - new Rejection("Unable to process file") + _cache.Set(item.Id.ToString(), item); } - }; - } - - private bool SceneSource(Artist artist, string folder) - { - return !(artist.Path.PathEquals(folder) || artist.Path.IsParentPath(folder)); + } } - private ManualImportItem MapItem(ImportDecision decision, string folder, string downloadId) + private ManualImportItem MapItem(ImportDecision<LocalTrack> decision, string folder, string downloadId) { var item = new ManualImportItem(); - item.Id = HashConverter.GetHashInt31(decision.LocalTrack.Path); - item.Path = decision.LocalTrack.Path; - item.RelativePath = folder.GetRelativePath(decision.LocalTrack.Path); - item.Name = Path.GetFileNameWithoutExtension(decision.LocalTrack.Path); + item.Id = HashConverter.GetHashInt31(decision.Item.Path); + item.Path = decision.Item.Path; + item.RelativePath = folder.GetRelativePath(decision.Item.Path); + item.Name = Path.GetFileNameWithoutExtension(decision.Item.Path); item.DownloadId = downloadId; - if (decision.LocalTrack.Artist != null) + if (decision.Item.Artist != null) { - item.Artist = decision.LocalTrack.Artist; + item.Artist = decision.Item.Artist; } - if (decision.LocalTrack.Album != null) + if (decision.Item.Album != null) { - item.Album = decision.LocalTrack.Album; - item.Release = decision.LocalTrack.Release; + item.Album = decision.Item.Album; + item.Release = decision.Item.Release; } - if (decision.LocalTrack.Tracks.Any()) + if (decision.Item.Tracks.Any()) { - item.Tracks = decision.LocalTrack.Tracks; + item.Tracks = decision.Item.Tracks; } - item.Quality = decision.LocalTrack.Quality; - item.Language = decision.LocalTrack.Language; - item.Size = _diskProvider.GetFileSize(decision.LocalTrack.Path); + item.Quality = decision.Item.Quality; + item.Language = decision.Item.Language; + item.Size = _diskProvider.GetFileSize(decision.Item.Path); item.Rejections = decision.Rejections; + item.Tags = decision.Item.FileTrackInfo; return item; } @@ -266,56 +213,63 @@ namespace NzbDrone.Core.MediaFiles.TrackImport.Manual var imported = new List<ImportResult>(); var importedTrackedDownload = new List<ManuallyImportedFile>(); + var albumIds = message.Files.GroupBy(e => e.AlbumId).ToList(); + var fileCount = 0; - for (int i = 0; i < message.Files.Count; i++) + foreach (var importAlbumId in albumIds) { - _logger.ProgressTrace("Processing file {0} of {1}", i + 1, message.Files.Count); - - var file = message.Files[i]; - var artist = _artistService.GetArtist(file.ArtistId); - var album = _albumService.GetAlbum(file.AlbumId); - var release = _releaseService.GetRelease(file.AlbumReleaseId); - var tracks = _trackService.GetTracks(file.TrackIds); - var parsedTrackInfo = Parser.Parser.ParseMusicPath(file.Path) ?? new ParsedTrackInfo(); - var mediaInfo = _videoFileInfoReader.GetMediaInfo(file.Path); - var existingFile = artist.Path.IsParentPath(file.Path); - - var localTrack = new LocalTrack - { - ExistingFile = false, - Tracks = tracks, - MediaInfo = mediaInfo, - ParsedTrackInfo = parsedTrackInfo, - Path = file.Path, - Quality = file.Quality, - Language = file.Language, - Artist = artist, - Album = album, - Release = release, - Size = 0 - }; - - //TODO: Cleanup non-tracked downloads - - var importDecision = new ImportDecision(localTrack); - - if (file.DownloadId.IsNullOrWhiteSpace()) + var albumImportDecisions = new List<ImportDecision<LocalTrack>>(); + + foreach (var file in importAlbumId) { - imported.AddRange(_importApprovedTracks.Import(new List<ImportDecision> { importDecision }, !existingFile, null, message.ImportMode)); + _logger.ProgressTrace("Processing file {0} of {1}", fileCount + 1, message.Files.Count); + + var artist = _artistService.GetArtist(file.ArtistId); + var album = _albumService.GetAlbum(file.AlbumId); + var release = _releaseService.GetRelease(file.AlbumReleaseId); + var tracks = _trackService.GetTracks(file.TrackIds); + var fileTrackInfo = Parser.Parser.ParseMusicPath(file.Path) ?? new ParsedTrackInfo(); + + var localTrack = new LocalTrack + { + ExistingFile = false, + Tracks = tracks, + MediaInfo = null, + FileTrackInfo = fileTrackInfo, + Path = file.Path, + Quality = file.Quality, + Language = file.Language, + Artist = artist, + Album = album, + Release = release, + Size = 0 + }; + + albumImportDecisions.Add(new ImportDecision<LocalTrack>(localTrack)); + fileCount += 1; } + var existingFile = albumImportDecisions.First().Item.Artist.Path.IsParentPath(importAlbumId.First().Path); + + if (importAlbumId.First().DownloadId.IsNullOrWhiteSpace()) + { + imported.AddRange(_importApprovedTracks.Import(albumImportDecisions, !existingFile, null, message.ImportMode)); + } else { - var trackedDownload = _trackedDownloadService.Find(file.DownloadId); - var importResult = _importApprovedTracks.Import(new List<ImportDecision> { importDecision }, true, trackedDownload.DownloadItem, message.ImportMode).First(); + var trackedDownload = _trackedDownloadService.Find(importAlbumId.First().DownloadId); + var importResults = _importApprovedTracks.Import(albumImportDecisions, true, trackedDownload.DownloadItem, message.ImportMode); - imported.Add(importResult); + imported.AddRange(importResults); - importedTrackedDownload.Add(new ManuallyImportedFile + foreach (var importResult in importResults) { - TrackedDownload = trackedDownload, - ImportResult = importResult - }); + importedTrackedDownload.Add(new ManuallyImportedFile + { + TrackedDownload = trackedDownload, + ImportResult = importResult + }); + } } } diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/AlbumUpgradeSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/AlbumUpgradeSpecification.cs new file mode 100644 index 000000000..070cd276a --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/AlbumUpgradeSpecification.cs @@ -0,0 +1,53 @@ +using System; +using System.Linq; +using NLog; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Parser.Model; +using NzbDrone.Core.Qualities; +using NzbDrone.Core.Languages; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications +{ + public class AlbumUpgradeSpecification : IImportDecisionEngineSpecification<LocalAlbumRelease> + { + private readonly Logger _logger; + + public AlbumUpgradeSpecification(Logger logger) + { + _logger = logger; + } + + public Decision IsSatisfiedBy(LocalAlbumRelease localAlbumRelease) + { + var artist = localAlbumRelease.AlbumRelease.Album.Value.Artist.Value; + var qualityComparer = new QualityModelComparer(artist.Profile); + + // check if we are changing release + var currentRelease = localAlbumRelease.AlbumRelease.Album.Value.AlbumReleases.Value.Single(x => x.Monitored); + var newRelease = localAlbumRelease.AlbumRelease; + + // if we are, check we are upgrading + if (newRelease.Id != currentRelease.Id) + { + // min quality of all new tracks + var newMinQuality = localAlbumRelease.LocalTracks.Select(x => x.Quality).OrderBy(x => x, qualityComparer).First(); + _logger.Debug("Min quality of new files: {0}", newMinQuality); + + // get minimum quality of existing release + var existingQualities = currentRelease.Tracks.Value.Where(x => x.TrackFileId != 0).Select(x => x.TrackFile.Value.Quality); + if (existingQualities.Any()) + { + var existingMinQuality = existingQualities.OrderBy(x => x, qualityComparer).First(); + _logger.Debug("Min quality of existing files: {0}", existingMinQuality); + if (qualityComparer.Compare(existingMinQuality, newMinQuality) > 0) + { + _logger.Debug("This album isn't a quality upgrade for all tracks. Skipping {0}", localAlbumRelease); + return Decision.Reject("Not an upgrade for existing album file(s)"); + } + } + } + + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/CloseAlbumMatchSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/CloseAlbumMatchSpecification.cs new file mode 100644 index 000000000..4c6e53f75 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/CloseAlbumMatchSpecification.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using System.Linq; +using NLog; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications +{ + public class CloseAlbumMatchSpecification : IImportDecisionEngineSpecification<LocalAlbumRelease> + { + private readonly Logger _logger; + private const double _albumThreshold = 0.20; + private const double _trackThreshold = 0.40; + + public CloseAlbumMatchSpecification(Logger logger) + { + _logger = logger; + } + + public Decision IsSatisfiedBy(LocalAlbumRelease localAlbumRelease) + { + double dist; + string reasons; + + // strict when a new download + if (localAlbumRelease.NewDownload) + { + dist = localAlbumRelease.Distance.NormalizedDistance(); + reasons = localAlbumRelease.Distance.Reasons; + if (dist > _albumThreshold) + { + _logger.Debug($"Album match is not close enough: {dist} vs {_albumThreshold} {reasons}. Skipping {localAlbumRelease}"); + return Decision.Reject($"Album match is not close enough: {1-dist:P1} vs {1-_albumThreshold:P0} {reasons}"); + } + + var worstTrackMatch = localAlbumRelease.LocalTracks.Where(x => x.Distance != null).OrderByDescending(x => x.Distance.NormalizedDistance()).FirstOrDefault(); + if (worstTrackMatch == null) + { + _logger.Debug($"No tracks matched"); + return Decision.Reject("No tracks matched"); + } + else + { + var maxTrackDist = worstTrackMatch.Distance.NormalizedDistance(); + var trackReasons = worstTrackMatch.Distance.Reasons; + if (maxTrackDist > _trackThreshold) + { + _logger.Debug($"Worst track match: {maxTrackDist} vs {_trackThreshold} {trackReasons}. Skipping {localAlbumRelease}"); + return Decision.Reject($"Worst track match: {1-maxTrackDist:P1} vs {1-_trackThreshold:P0} {trackReasons}"); + } + } + } + // otherwise importing existing files in library + else + { + // get album distance ignoring whether tracks are missing + dist = localAlbumRelease.Distance.NormalizedDistanceExcluding(new List<string> { "missing_tracks", "unmatched_tracks" }); + reasons = localAlbumRelease.Distance.Reasons; + if (dist > _albumThreshold) + { + _logger.Debug($"Album match is not close enough: {dist} vs {_albumThreshold} {reasons}. Skipping {localAlbumRelease}"); + return Decision.Reject($"Album match is not close enough: {1-dist:P1} vs {1-_albumThreshold:P0} {reasons}"); + } + } + + _logger.Debug($"Accepting release {localAlbumRelease}: dist {dist} vs {_albumThreshold} {reasons}"); + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/CloseTrackMatchSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/CloseTrackMatchSpecification.cs new file mode 100644 index 000000000..0603dce95 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/CloseTrackMatchSpecification.cs @@ -0,0 +1,33 @@ +using System.Linq; +using NLog; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications +{ + public class CloseTrackMatchSpecification : IImportDecisionEngineSpecification<LocalTrack> + { + private readonly Logger _logger; + private const double _threshold = 0.4; + + public CloseTrackMatchSpecification(Logger logger) + { + _logger = logger; + } + + public Decision IsSatisfiedBy(LocalTrack localTrack) + { + var dist = localTrack.Distance.NormalizedDistance(); + var reasons = localTrack.Distance.Reasons; + + if (dist > _threshold) + { + _logger.Debug($"Track match is not close enough: {dist} vs {_threshold} {reasons}. Skipping {localTrack}"); + return Decision.Reject($"Track match is not close enough: {1-dist:P1} vs {1-_threshold:P0} {reasons}"); + } + + _logger.Debug($"Track accepted: {dist} vs {_threshold} {reasons}."); + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/FreeSpaceSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/FreeSpaceSpecification.cs index adaa4a0bf..b287cee4d 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/FreeSpaceSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/FreeSpaceSpecification.cs @@ -8,7 +8,7 @@ using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications { - public class FreeSpaceSpecification : IImportDecisionEngineSpecification + public class FreeSpaceSpecification : IImportDecisionEngineSpecification<LocalTrack> { private readonly IDiskProvider _diskProvider; private readonly IConfigService _configService; diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/MoreTracksSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/MoreTracksSpecification.cs new file mode 100644 index 000000000..ef6248ba0 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/MoreTracksSpecification.cs @@ -0,0 +1,31 @@ +using System.Linq; +using NLog; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications +{ + public class MoreTracksSpecification : IImportDecisionEngineSpecification<LocalAlbumRelease> + { + private readonly Logger _logger; + + public MoreTracksSpecification(Logger logger) + { + _logger = logger; + } + + public Decision IsSatisfiedBy(LocalAlbumRelease localAlbumRelease) + { + var existingRelease = localAlbumRelease.AlbumRelease.Album.Value.AlbumReleases.Value.Single(x => x.Monitored); + if (localAlbumRelease.AlbumRelease.Id != existingRelease.Id && + localAlbumRelease.TrackCount < existingRelease.Tracks.Value.Count(x => x.HasFile)) + { + _logger.Debug("This release has fewer tracks than the existing one. Skipping {0}", localAlbumRelease); + return Decision.Reject("Has fewer tracks than existing release"); + } + + _logger.Trace("Accepting release {0}", localAlbumRelease); + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/NoMissingOrUnmatchedTracksSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/NoMissingOrUnmatchedTracksSpecification.cs new file mode 100644 index 000000000..2d072fc91 --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/NoMissingOrUnmatchedTracksSpecification.cs @@ -0,0 +1,34 @@ +using System.Linq; +using NLog; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications +{ + public class NoMissingOrUnmatchedTracksSpecification : IImportDecisionEngineSpecification<LocalAlbumRelease> + { + private readonly Logger _logger; + + public NoMissingOrUnmatchedTracksSpecification(Logger logger) + { + _logger = logger; + } + + public Decision IsSatisfiedBy(LocalAlbumRelease localAlbumRelease) + { + if (localAlbumRelease.NewDownload && localAlbumRelease.TrackMapping.LocalExtra.Count > 0) + { + _logger.Debug("This release has track files that have not been matched. Skipping {0}", localAlbumRelease); + return Decision.Reject("Has unmatched tracks"); + } + + if (localAlbumRelease.NewDownload && localAlbumRelease.TrackMapping.MBExtra.Count > 0) + { + _logger.Debug("This release is missing tracks. Skipping {0}", localAlbumRelease); + return Decision.Reject("Has missing tracks"); + } + + return Decision.Accept(); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/NotUnpackingSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/NotUnpackingSpecification.cs index 37e1139fb..01f27de8d 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/NotUnpackingSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/NotUnpackingSpecification.cs @@ -9,7 +9,7 @@ using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications { - public class NotUnpackingSpecification : IImportDecisionEngineSpecification + public class NotUnpackingSpecification : IImportDecisionEngineSpecification<LocalTrack> { private readonly IDiskProvider _diskProvider; private readonly IConfigService _configService; diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/ReleaseWantedSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/ReleaseWantedSpecification.cs new file mode 100644 index 000000000..6fd7df12e --- /dev/null +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/ReleaseWantedSpecification.cs @@ -0,0 +1,28 @@ +using System.Linq; +using NLog; +using NzbDrone.Core.DecisionEngine; +using NzbDrone.Core.Parser.Model; + +namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications +{ + public class ReleaseWantedSpecification : IImportDecisionEngineSpecification<LocalAlbumRelease> + { + private readonly Logger _logger; + + public ReleaseWantedSpecification(Logger logger) + { + _logger = logger; + } + + public Decision IsSatisfiedBy(LocalAlbumRelease localAlbumRelease) + { + if (localAlbumRelease.AlbumRelease.Monitored || localAlbumRelease.AlbumRelease.Album.Value.AnyReleaseOk) + { + return Decision.Accept(); + } + + _logger.Debug("AlbumRelease {0} was not requested", localAlbumRelease); + return Decision.Reject("Album release not requested"); + } + } +} diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/SameFileSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/SameFileSpecification.cs index 1ef2f8df6..ffb9fee27 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/SameFileSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/SameFileSpecification.cs @@ -6,7 +6,7 @@ using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications { - public class SameFileSpecification : IImportDecisionEngineSpecification + public class SameFileSpecification : IImportDecisionEngineSpecification<LocalTrack> { private readonly Logger _logger; diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/SameTracksImportSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/SameTracksImportSpecification.cs index 28815e87d..1a95620c1 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/SameTracksImportSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/SameTracksImportSpecification.cs @@ -8,7 +8,7 @@ using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications { - public class SameTracksImportSpecification : IImportDecisionEngineSpecification + public class SameTracksImportSpecification : IImportDecisionEngineSpecification<LocalTrack> { private readonly SameTracksSpecification _sameTracksSpecification; private readonly Logger _logger; diff --git a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/UpgradeSpecification.cs b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/UpgradeSpecification.cs index 24a3214ed..385871df6 100644 --- a/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/UpgradeSpecification.cs +++ b/src/NzbDrone.Core/MediaFiles/TrackImport/Specifications/UpgradeSpecification.cs @@ -8,7 +8,7 @@ using NzbDrone.Core.Languages; namespace NzbDrone.Core.MediaFiles.TrackImport.Specifications { - public class UpgradeSpecification : IImportDecisionEngineSpecification + public class UpgradeSpecification : IImportDecisionEngineSpecification<LocalTrack> { private readonly Logger _logger; diff --git a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs index 7242e5cd2..e836a1490 100644 --- a/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs +++ b/src/NzbDrone.Core/MetadataSource/SkyHook/SkyHookProxy.cs @@ -19,13 +19,15 @@ namespace NzbDrone.Core.MetadataSource.SkyHook { private readonly IHttpClient _httpClient; private readonly Logger _logger; - private readonly IArtistService _artistService; private readonly IAlbumService _albumService; private readonly IHttpRequestBuilderFactory _requestBuilder; private readonly IConfigService _configService; private readonly IMetadataProfileService _metadataProfileService; + private static readonly List<string> nonAudioMedia = new List<string> { "DVD", "DVD-Video", "Blu-ray", "HD-DVD", "VCD", "SVCD", "UMD", "VHS" }; + private static readonly List<string> skippedTracks = new List<string> { "[data track]" }; + private IHttpRequestBuilderFactory _customerRequestBuilder; public SkyHookProxy(IHttpClient httpClient, @@ -327,16 +329,29 @@ namespace NzbDrone.Core.MetadataSource.SkyHook release.Disambiguation = resource.Disambiguation; release.Country = resource.Country; release.ReleaseDate = resource.ReleaseDate; - release.TrackCount = resource.TrackCount; - release.Tracks = resource.Tracks.Select(x => MapTrack(x, artistDict)).ToList(); - release.Media = resource.Media.Select(MapMedium).ToList(); - if (!release.Media.Any()) + + // Get the complete set of media/tracks returned by the API, adding missing media if necessary + var allMedia = resource.Media.Select(MapMedium).ToList(); + var allTracks = resource.Tracks.Select(x => MapTrack(x, artistDict)); + if (!allMedia.Any()) { - foreach(int n in release.Tracks.Value.Select(x => x.MediumNumber).Distinct()) + foreach(int n in allTracks.Select(x => x.MediumNumber).Distinct()) { - release.Media.Add(new Medium { Name = "Unknown", Number = n, Format = "Unknown" }); + allMedia.Add(new Medium { Name = "Unknown", Number = n, Format = "Unknown" }); } } + + // Skip non-audio media + var audioMediaNumbers = allMedia.Where(x => !nonAudioMedia.Contains(x.Format)).Select(x => x.Number); + + // Get tracks on the audio media and omit any that are skipped + release.Tracks = allTracks.Where(x => audioMediaNumbers.Contains(x.MediumNumber) && !skippedTracks.Contains(x.Title)).ToList(); + release.TrackCount = release.Tracks.Value.Count; + + // Only include the media that contain the tracks we have selected + var usedMediaNumbers = release.Tracks.Value.Select(track => track.MediumNumber); + release.Media = allMedia.Where(medium => usedMediaNumbers.Contains(medium.Number)).ToList(); + release.Duration = release.Tracks.Value.Sum(x => x.Duration); return release; diff --git a/src/NzbDrone.Core/Music/AlbumService.cs b/src/NzbDrone.Core/Music/AlbumService.cs index f6f3876a2..ab2e692c5 100644 --- a/src/NzbDrone.Core/Music/AlbumService.cs +++ b/src/NzbDrone.Core/Music/AlbumService.cs @@ -21,6 +21,7 @@ namespace NzbDrone.Core.Music List<Album> FindById(List<string> foreignIds); Album FindByTitle(int artistId, string title); Album FindByTitleInexact(int artistId, string title); + List<Album> GetCandidates(int artistId, string title); void DeleteAlbum(int albumId, bool deleteFiles); List<Album> GetAllAlbums(); Album UpdateAlbum(Album album); @@ -105,12 +106,8 @@ namespace NzbDrone.Core.Music return _albumRepository.FindByTitle(artistId, title); } - public Album FindByTitleInexact(int artistId, string title) + private List<Tuple<Func<Album, string, double>, string>> AlbumScoringFunctions(string title, string cleanTitle) { - var cleanTitle = title.CleanArtistName(); - - var albums = GetAlbumsByArtistMetadataId(artistId); - Func< Func<Album, string, double>, string, Tuple<Func<Album, string, double>, string>> tc = Tuple.Create; var scoringFunctions = new List<Tuple<Func<Album, string, double>, string>> { tc((a, t) => a.CleanTitle.FuzzyMatch(t), cleanTitle), @@ -122,19 +119,39 @@ namespace NzbDrone.Core.Music tc((a, t) => t.FuzzyContains(a.Title), title) }; - foreach (var func in scoringFunctions) + return scoringFunctions; + } + + public Album FindByTitleInexact(int artistMetadataId, string title) + { + var albums = GetAlbumsByArtistMetadataId(artistMetadataId); + + foreach (var func in AlbumScoringFunctions(title, title.CleanArtistName())) { - var album = FindByStringInexact(albums, func.Item1, func.Item2); - if (album != null) + var results = FindByStringInexact(albums, func.Item1, func.Item2); + if (results.Count == 1) { - return album; + return results[0]; } } return null; } - private Album FindByStringInexact(List<Album> albums, Func<Album, string, double> scoreFunction, string title) + public List<Album> GetCandidates(int artistMetadataId, string title) + { + var albums = GetAlbumsByArtistMetadataId(artistMetadataId); + var output = new List<Album>(); + + foreach (var func in AlbumScoringFunctions(title, title.CleanArtistName())) + { + output.AddRange(FindByStringInexact(albums, func.Item1, func.Item2)); + } + + return output.DistinctBy(x => x.Id).ToList(); + } + + private List<Album> FindByStringInexact(List<Album> albums, Func<Album, string, double> scoreFunction, string title) { const double fuzzThreshold = 0.7; const double fuzzGap = 0.4; @@ -148,22 +165,14 @@ namespace NzbDrone.Core.Music .OrderByDescending(s => s.MatchProb) .ToList(); - if (!sortedAlbums.Any()) - { - return null; - } - _logger.Trace("\nFuzzy album match on '{0}':\n{1}", title, string.Join("\n", sortedAlbums.Select(x => $"[{x.Album.Title}] {x.Album.CleanTitle}: {x.MatchProb}"))); - if (sortedAlbums[0].MatchProb > fuzzThreshold - && (sortedAlbums.Count == 1 || sortedAlbums[0].MatchProb - sortedAlbums[1].MatchProb > fuzzGap)) - { - return sortedAlbums[0].Album; - } - - return null; + return sortedAlbums.TakeWhile((x, i) => i == 0 ? true : sortedAlbums[i - 1].MatchProb - x.MatchProb < fuzzGap) + .TakeWhile((x, i) => x.MatchProb > fuzzThreshold || (i > 0 && sortedAlbums[i - 1].MatchProb > fuzzThreshold)) + .Select(x => x.Album) + .ToList(); } public List<Album> GetAllAlbums() diff --git a/src/NzbDrone.Core/Music/ArtistService.cs b/src/NzbDrone.Core/Music/ArtistService.cs index e07ff09c1..9d708ec77 100644 --- a/src/NzbDrone.Core/Music/ArtistService.cs +++ b/src/NzbDrone.Core/Music/ArtistService.cs @@ -1,14 +1,12 @@ using NLog; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Music.Events; -using NzbDrone.Core.Organizer; using System; using System.Collections.Generic; using System.Linq; using NzbDrone.Core.Parser; -using System.Text; -using System.IO; using NzbDrone.Common.Extensions; +using NzbDrone.Common.Cache; namespace NzbDrone.Core.Music { @@ -22,6 +20,7 @@ namespace NzbDrone.Core.Music Artist FindById(string spotifyId); Artist FindByName(string title); Artist FindByNameInexact(string title); + List<Artist> GetCandidates(string title); void DeleteArtist(int artistId, bool deleteFiles); List<Artist> GetAllArtists(); List<Artist> AllForTag(int tagId); @@ -39,12 +38,14 @@ namespace NzbDrone.Core.Music private readonly ITrackService _trackService; private readonly IBuildArtistPaths _artistPathBuilder; private readonly Logger _logger; + private readonly ICached<List<Artist>> _cache; public ArtistService(IArtistRepository artistRepository, IArtistMetadataRepository artistMetadataRepository, IEventAggregator eventAggregator, ITrackService trackService, IBuildArtistPaths artistPathBuilder, + ICacheManager cacheManager, Logger logger) { _artistRepository = artistRepository; @@ -52,11 +53,13 @@ namespace NzbDrone.Core.Music _eventAggregator = eventAggregator; _trackService = trackService; _artistPathBuilder = artistPathBuilder; + _cache = cacheManager.GetCache<List<Artist>>(GetType()); _logger = logger; } public Artist AddArtist(Artist newArtist) { + _cache.Clear(); _artistMetadataRepository.Upsert(newArtist); _artistRepository.Insert(newArtist); _eventAggregator.PublishEvent(new ArtistAddedEvent(GetArtist(newArtist.Id))); @@ -66,6 +69,7 @@ namespace NzbDrone.Core.Music public List<Artist> AddArtists(List<Artist> newArtists) { + _cache.Clear(); _artistMetadataRepository.UpsertMany(newArtists); _artistRepository.InsertMany(newArtists); _eventAggregator.PublishEvent(new ArtistsImportedEvent(newArtists.Select(s => s.Id).ToList())); @@ -80,6 +84,7 @@ namespace NzbDrone.Core.Music public void DeleteArtist(int artistId, bool deleteFiles) { + _cache.Clear(); var artist = _artistRepository.Get(artistId); _artistRepository.Delete(artistId); _eventAggregator.PublishEvent(new ArtistDeletedEvent(artist, deleteFiles)); @@ -95,48 +100,82 @@ namespace NzbDrone.Core.Music return _artistRepository.FindByName(title.CleanArtistName()); } - public Artist FindByNameInexact(string title) + public List<Tuple<Func<Artist, string, double>, string>> ArtistScoringFunctions(string title, string cleanTitle) { - const double fuzzThreshold = 0.8; - const double fuzzGap = 0.2; - var cleanTitle = Parser.Parser.CleanArtistName(title); + Func< Func<Artist, string, double>, string, Tuple<Func<Artist, string, double>, string>> tc = Tuple.Create; + var scoringFunctions = new List<Tuple<Func<Artist, string, double>, string>> { + tc((a, t) => a.CleanName.FuzzyMatch(t), cleanTitle), + tc((a, t) => a.Name.FuzzyMatch(t), title), + }; - if (string.IsNullOrEmpty(cleanTitle)) + if (title.StartsWith("The ", StringComparison.CurrentCultureIgnoreCase)) + { + scoringFunctions.Add(tc((a, t) => a.CleanName.FuzzyMatch(t), title.Substring(4).CleanArtistName())); + } + else { - cleanTitle = title; + scoringFunctions.Add(tc((a, t) => a.CleanName.FuzzyMatch(t), "the" + cleanTitle)); } - var sortedArtists = GetAllArtists() - .Select(s => new - { - MatchProb = s.CleanName.FuzzyMatch(cleanTitle), - Artist = s - }) - .ToList() - .OrderByDescending(s => s.MatchProb) - .ToList(); + return scoringFunctions; + } - if (!sortedArtists.Any()) + public Artist FindByNameInexact(string title) + { + var artists = GetAllArtists(); + + foreach (var func in ArtistScoringFunctions(title, title.CleanArtistName())) { - return null; + var results = FindByStringInexact(artists, func.Item1, func.Item2); + if (results.Count == 1) + { + return results[0]; + } } - _logger.Trace("\nFuzzy artist match on '{0}':\n{1}", - cleanTitle, - string.Join("\n", sortedArtists.Select(x => $"{x.Artist.CleanName}: {x.MatchProb}"))); + return null; + } - if (sortedArtists[0].MatchProb > fuzzThreshold - && (sortedArtists.Count == 1 || sortedArtists[0].MatchProb - sortedArtists[1].MatchProb > fuzzGap)) + public List<Artist> GetCandidates(string title) + { + var artists = GetAllArtists(); + var output = new List<Artist>(); + + foreach (var func in ArtistScoringFunctions(title, title.CleanArtistName())) { - return sortedArtists[0].Artist; + output.AddRange(FindByStringInexact(artists, func.Item1, func.Item2)); } - return null; + return output.DistinctBy(x => x.Id).ToList(); + } + + private List<Artist> FindByStringInexact(List<Artist> artists, Func<Artist, string, double> scoreFunction, string title) + { + const double fuzzThreshold = 0.8; + const double fuzzGap = 0.2; + + var sortedArtists = artists.Select(s => new + { + MatchProb = scoreFunction(s, title), + Artist = s + }) + .ToList() + .OrderByDescending(s => s.MatchProb) + .ToList(); + + _logger.Trace("\nFuzzy artist match on '{0}':\n{1}", + title, + string.Join("\n", sortedArtists.Select(x => $"[{x.Artist.Name}] {x.Artist.CleanName}: {x.MatchProb}"))); + + return sortedArtists.TakeWhile((x, i) => i == 0 ? true : sortedArtists[i - 1].MatchProb - x.MatchProb < fuzzGap) + .TakeWhile((x, i) => x.MatchProb > fuzzThreshold || (i > 0 && sortedArtists[i - 1].MatchProb > fuzzThreshold)) + .Select(x => x.Artist) + .ToList(); } public List<Artist> GetAllArtists() { - return _artistRepository.All().ToList(); + return _cache.Get("GetAllArtists", () => _artistRepository.All().ToList(), TimeSpan.FromSeconds(30)); } public List<Artist> AllForTag(int tagId) @@ -167,6 +206,7 @@ namespace NzbDrone.Core.Music public Artist UpdateArtist(Artist artist) { + _cache.Clear(); var storedArtist = GetArtist(artist.Id); // Is it Id or iTunesId? var updatedArtist = _artistMetadataRepository.Update(artist); updatedArtist = _artistRepository.Update(updatedArtist); @@ -177,6 +217,7 @@ namespace NzbDrone.Core.Music public List<Artist> UpdateArtists(List<Artist> artist, bool useExistingRelativeFolder) { + _cache.Clear(); _logger.Debug("Updating {0} artist", artist.Count); foreach (var s in artist) diff --git a/src/NzbDrone.Core/Music/ReleaseRepository.cs b/src/NzbDrone.Core/Music/ReleaseRepository.cs index ea99c2c5a..2042b2fbb 100644 --- a/src/NzbDrone.Core/Music/ReleaseRepository.cs +++ b/src/NzbDrone.Core/Music/ReleaseRepository.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Linq; +using Marr.Data.QGen; using NzbDrone.Common.EnsureThat; using NzbDrone.Core.Datastore; using NzbDrone.Core.Messaging.Events; @@ -9,6 +10,7 @@ namespace NzbDrone.Core.Music public interface IReleaseRepository : IBasicRepository<AlbumRelease> { List<AlbumRelease> FindByAlbum(int id); + List<AlbumRelease> FindByRecordingId(List<string> recordingIds); List<AlbumRelease> SetMonitored(AlbumRelease release); List<AlbumRelease> FindByForeignReleaseId(List<string> foreignReleaseIds); } @@ -22,14 +24,30 @@ namespace NzbDrone.Core.Music public List<AlbumRelease> FindByAlbum(int id) { - return Query.Where(r => r.AlbumId == id).ToList(); + // populate the albums and artist metadata also + // this hopefully speeds up the track matching a lot + return Query + .Join<AlbumRelease, Album>(JoinType.Left, r => r.Album, (r, a) => r.AlbumId == a.Id) + .Join<Album, ArtistMetadata>(JoinType.Left, a => a.ArtistMetadata, (a, m) => a.ArtistMetadataId == m.Id) + .Where<AlbumRelease>(r => r.AlbumId == id) + .ToList(); } public List<AlbumRelease> FindByForeignReleaseId(List<string> foreignReleaseIds) { - var query = "SELECT AlbumReleases.*" + + return Query + .Join<AlbumRelease, Album>(JoinType.Left, r => r.Album, (r, a) => r.AlbumId == a.Id) + .Join<Album, ArtistMetadata>(JoinType.Left, a => a.ArtistMetadata, (a, m) => a.ArtistMetadataId == m.Id) + .Where($"[ForeignReleaseId] IN ('{string.Join("', '", foreignReleaseIds)}')") + .ToList(); + } + + public List<AlbumRelease> FindByRecordingId(List<string> recordingIds) + { + var query = "SELECT DISTINCT AlbumReleases.*" + "FROM AlbumReleases " + - $"WHERE AlbumReleases.ForeignReleaseId IN ('{string.Join("', '", foreignReleaseIds)}')"; + "JOIN Tracks ON Tracks.AlbumReleaseId = AlbumReleases.Id " + + $"WHERE Tracks.ForeignRecordingId IN ('{string.Join("', '", recordingIds)}')"; return Query.QueryText(query).ToList(); } diff --git a/src/NzbDrone.Core/Music/ReleaseService.cs b/src/NzbDrone.Core/Music/ReleaseService.cs index fcd2f6c3b..7f3373828 100644 --- a/src/NzbDrone.Core/Music/ReleaseService.cs +++ b/src/NzbDrone.Core/Music/ReleaseService.cs @@ -12,6 +12,7 @@ namespace NzbDrone.Core.Music void DeleteMany(List<AlbumRelease> releases); List<AlbumRelease> GetReleasesByAlbum(int releaseGroupId); List<AlbumRelease> GetReleasesByForeignReleaseId(List<string> foreignReleaseIds); + List<AlbumRelease> GetReleasesByRecordingIds(List<string> recordingIds); List<AlbumRelease> SetMonitored(AlbumRelease release); } @@ -61,6 +62,11 @@ namespace NzbDrone.Core.Music { return _releaseRepository.FindByForeignReleaseId(foreignReleaseIds); } + + public List<AlbumRelease> GetReleasesByRecordingIds(List<string> recordingIds) + { + return _releaseRepository.FindByRecordingId(recordingIds); + } public List<AlbumRelease> SetMonitored(AlbumRelease release) { diff --git a/src/NzbDrone.Core/Music/Track.cs b/src/NzbDrone.Core/Music/Track.cs index 4d97544c6..ba309e74e 100644 --- a/src/NzbDrone.Core/Music/Track.cs +++ b/src/NzbDrone.Core/Music/Track.cs @@ -35,7 +35,6 @@ namespace NzbDrone.Core.Music // These are retained for compatibility // TODO: Remove set, bodged in because tests expect this to be writable public int AlbumId { get { return AlbumRelease.Value?.Album.Value?.Id ?? 0; } set { /* empty */ } } - public int ArtistId { get { return Artist.Value?.Id ?? 0; } } public Album Album { get; set; } public override string ToString() diff --git a/src/NzbDrone.Core/Music/TrackRepository.cs b/src/NzbDrone.Core/Music/TrackRepository.cs index b4df8b820..2bfbfc2ad 100644 --- a/src/NzbDrone.Core/Music/TrackRepository.cs +++ b/src/NzbDrone.Core/Music/TrackRepository.cs @@ -1,28 +1,23 @@ using NzbDrone.Core.Datastore; using System.Collections.Generic; -using System.Linq; using NLog; using NzbDrone.Core.Messaging.Events; -using NzbDrone.Core.Qualities; -using NzbDrone.Core.MediaFiles; -using Marr.Data.QGen; -using NzbDrone.Core.Datastore.Extensions; -using System; namespace NzbDrone.Core.Music { public interface ITrackRepository : IBasicRepository<Track> { - Track Find(int artistId, int albumId, int mediumNumber, int trackNumber); List<Track> GetTracks(int artistId); List<Track> GetTracksByAlbum(int albumId); List<Track> GetTracksByRelease(int albumReleaseId); + List<Track> GetTracksByReleases(List<int> albumReleaseId); List<Track> GetTracksByForeignReleaseId(string foreignReleaseId); List<Track> GetTracksByForeignTrackIds(List<string> foreignTrackId); - List<Track> GetTracksByMedium(int albumId, int mediumNumber); List<Track> GetTracksByFileId(int fileId); List<Track> TracksWithFiles(int artistId); - void SetFileId(int trackId, int fileId); + List<Track> TracksWithoutFiles(int albumId); + void SetFileId(List<Track> tracks); + void DetachTrackFile(int trackFileId); } public class TrackRepository : BasicRepository<Track>, ITrackRepository @@ -37,24 +32,6 @@ namespace NzbDrone.Core.Music _logger = logger; } - public Track Find(int artistId, int albumId, int mediumNumber, int trackNumber) - { - string query = string.Format("SELECT Tracks.* " + - "FROM Artists " + - "JOIN Albums ON Albums.ArtistMetadataId == Artists.ArtistMetadataId " + - "JOIN AlbumReleases ON AlbumReleases.AlbumId == Albums.Id " + - "JOIN Tracks ON Tracks.AlbumReleaseId == AlbumReleases.Id " + - "WHERE Artists.Id = {0} " + - "AND Albums.Id = {1} " + - "AND AlbumReleases.Monitored = 1 " + - "AND Tracks.MediumNumber = {2} " + - "AND Tracks.AbsoluteTrackNumber = {3}", - artistId, albumId, mediumNumber, trackNumber); - - return Query.QueryText(query).SingleOrDefault(); - } - - public List<Track> GetTracks(int artistId) { string query = string.Format("SELECT Tracks.* " + @@ -87,6 +64,15 @@ namespace NzbDrone.Core.Music return Query.Where(t => t.AlbumReleaseId == albumReleaseId).ToList(); } + public List<Track> GetTracksByReleases(List<int> albumReleaseIds) + { + // this will populate the artist metadata also + return Query + .Join<Track, ArtistMetadata>(Marr.Data.QGen.JoinType.Inner, t => t.ArtistMetadata, (l, r) => l.ArtistMetadataId == r.Id) + .Where($"[AlbumReleaseId] IN ({string.Join(", ", albumReleaseIds)})") + .ToList(); + } + public List<Track> GetTracksByForeignReleaseId(string foreignReleaseId) { string query = string.Format("SELECT Tracks.* " + @@ -108,26 +94,6 @@ namespace NzbDrone.Core.Music return Query.QueryText(query).ToList(); } - public List<Track> GetTracksByMedium(int albumId, int mediumNumber) - { - if (mediumNumber < 1) - { - return GetTracksByAlbum(albumId); - } - - string query = string.Format("SELECT Tracks.* " + - "FROM Albums " + - "JOIN AlbumReleases ON AlbumReleases.AlbumId == Albums.Id " + - "JOIN Tracks ON Tracks.AlbumReleaseId == AlbumReleases.Id " + - "WHERE Albums.Id = {0} " + - "AND AlbumReleases.Monitored = 1 " + - "AND Tracks.MediumNumber = {1}", - albumId, - mediumNumber); - - return Query.QueryText(query).ToList(); - } - public List<Track> GetTracksByFileId(int fileId) { return Query.Where(e => e.TrackFileId == fileId).ToList(); @@ -148,9 +114,33 @@ namespace NzbDrone.Core.Music return Query.QueryText(query).ToList(); } - public void SetFileId(int trackId, int fileId) + public List<Track> TracksWithoutFiles(int albumId) + { + string query = string.Format("SELECT Tracks.* " + + "FROM Albums " + + "JOIN AlbumReleases ON AlbumReleases.AlbumId == Albums.Id " + + "JOIN Tracks ON Tracks.AlbumReleaseId == AlbumReleases.Id " + + "LEFT OUTER JOIN TrackFiles ON TrackFiles.Id == Tracks.TrackFileId " + + "WHERE Albums.Id == {0} " + + "AND AlbumReleases.Monitored = 1 " + + "AND TrackFiles.Id IS NULL", + albumId); + + return Query.QueryText(query).ToList(); + } + + public void SetFileId(List<Track> tracks) + { + SetFields(tracks, t => t.TrackFileId); + } + + public void DetachTrackFile(int trackFileId) { - SetFields(new Track { Id = trackId, TrackFileId = fileId }, track => track.TrackFileId); + DataMapper.Update<Track>() + .Where(x => x.TrackFileId == trackFileId) + .ColumnsIncluding(x => x.TrackFileId) + .Entity(new Track { TrackFileId = 0 }) + .Execute(); } } } diff --git a/src/NzbDrone.Core/Music/TrackService.cs b/src/NzbDrone.Core/Music/TrackService.cs index a6cb4f2c5..12d6610df 100644 --- a/src/NzbDrone.Core/Music/TrackService.cs +++ b/src/NzbDrone.Core/Music/TrackService.cs @@ -1,16 +1,10 @@ using NLog; using NzbDrone.Core.Configuration; -using NzbDrone.Core.Datastore; -using NzbDrone.Core.MediaFiles; using NzbDrone.Core.MediaFiles.Events; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Music.Events; -using NzbDrone.Core.Parser; -using NzbDrone.Common.Extensions; -using System; using System.Collections.Generic; using System.Linq; -using System.Text; namespace NzbDrone.Core.Music { @@ -18,27 +12,25 @@ namespace NzbDrone.Core.Music { Track GetTrack(int id); List<Track> GetTracks(IEnumerable<int> ids); - Track FindTrack(int artistId, int albumId, int mediumNumber, int trackNumber); - Track FindTrackByTitle(int artistId, int albumId, int mediumNumber, int trackNumber, string releaseTitle); - Track FindTrackByTitleInexact(int artistId, int albumId, int mediumNumber, int trackNumber, string releaseTitle); List<Track> GetTracksByArtist(int artistId); List<Track> GetTracksByAlbum(int albumId); List<Track> GetTracksByRelease(int albumReleaseId); + List<Track> GetTracksByReleases(List<int> albumReleaseIds); List<Track> GetTracksByForeignReleaseId(string foreignReleaseId); List<Track> GetTracksByForeignTrackIds(List<string> ids); List<Track> TracksWithFiles(int artistId); + List<Track> TracksWithoutFiles(int albumId); List<Track> GetTracksByFileId(int trackFileId); void UpdateTrack(Track track); - void UpdateTracks(List<Track> tracks); void InsertMany(List<Track> tracks); void UpdateMany(List<Track> tracks); void DeleteMany(List<Track> tracks); + void SetFileIds(List<Track> tracks); } public class TrackService : ITrackService, IHandleAsync<ReleaseDeletedEvent>, - IHandle<TrackFileDeletedEvent>, - IHandle<TrackFileAddedEvent> + IHandle<TrackFileDeletedEvent> { private readonly ITrackRepository _trackRepository; private readonly IConfigService _configService; @@ -61,11 +53,6 @@ namespace NzbDrone.Core.Music return _trackRepository.Get(ids).ToList(); } - public Track FindTrack(int artistId, int albumId, int mediumNumber, int trackNumber) - { - return _trackRepository.Find(artistId, albumId, mediumNumber, trackNumber); - } - public List<Track> GetTracksByArtist(int artistId) { _logger.Debug("Getting Tracks for ArtistId {0}", artistId); @@ -82,6 +69,11 @@ namespace NzbDrone.Core.Music return _trackRepository.GetTracksByRelease(albumReleaseId); } + public List<Track> GetTracksByReleases(List<int> albumReleaseIds) + { + return _trackRepository.GetTracksByReleases(albumReleaseIds); + } + public List<Track> GetTracksByForeignReleaseId(string foreignReleaseId) { return _trackRepository.GetTracksByForeignReleaseId(foreignReleaseId); @@ -92,83 +84,14 @@ namespace NzbDrone.Core.Music return _trackRepository.GetTracksByForeignTrackIds(ids); } - public Track FindTrackByTitle(int artistId, int albumId, int mediumNumber, int trackNumber, string releaseTitle) - { - // TODO: can replace this search mechanism with something smarter/faster/better - var normalizedReleaseTitle = releaseTitle.NormalizeTrackTitle().Replace(".", " "); - var tracks = _trackRepository.GetTracksByMedium(albumId, mediumNumber); - - var matches = tracks.Where(t => (trackNumber == 0 || t.AbsoluteTrackNumber == trackNumber) - && t.Title.Length > 0 - && (normalizedReleaseTitle.Contains(t.Title.NormalizeTrackTitle()) - || t.Title.NormalizeTrackTitle().Contains(normalizedReleaseTitle))); - - return matches.Count() > 1 ? null : matches.SingleOrDefault(); - } - - public Track FindTrackByTitleInexact(int artistId, int albumId, int mediumNumber, int trackNumber, string title) - { - var normalizedTitle = title.NormalizeTrackTitle().Replace(".", " "); - var tracks = _trackRepository.GetTracksByMedium(albumId, mediumNumber); - - Func< Func<Track, string, double>, string, Tuple<Func<Track, string, double>, string>> tc = Tuple.Create; - var scoringFunctions = new List<Tuple<Func<Track, string, double>, string>> { - tc((a, t) => a.Title.NormalizeTrackTitle().FuzzyMatch(t), normalizedTitle), - tc((a, t) => a.Title.NormalizeTrackTitle().FuzzyContains(t), normalizedTitle), - tc((a, t) => t.FuzzyContains(a.Title.NormalizeTrackTitle()), normalizedTitle) - }; - - foreach (var func in scoringFunctions) - { - var track = FindByStringInexact(tracks, func.Item1, func.Item2, trackNumber); - if (track != null) - { - return track; - } - } - - return null; - } - - private Track FindByStringInexact(List<Track> tracks, Func<Track, string, double> scoreFunction, string title, int trackNumber) + public List<Track> TracksWithFiles(int artistId) { - const double fuzzThreshold = 0.7; - const double fuzzGap = 0.2; - - var sortedTracks = tracks.Select(s => new - { - MatchProb = scoreFunction(s, title), - Track = s - }) - .ToList() - .OrderByDescending(s => s.MatchProb) - .ToList(); - - if (!sortedTracks.Any()) - { - return null; - } - - _logger.Trace("\nFuzzy track match on '{0:D2} - {1}':\n{2}", - trackNumber, - title, - string.Join("\n", sortedTracks.Select(x => $"{x.Track.AbsoluteTrackNumber:D2} - {x.Track.Title}: {x.MatchProb}"))); - - if (sortedTracks[0].MatchProb > fuzzThreshold - && (sortedTracks.Count == 1 || sortedTracks[0].MatchProb - sortedTracks[1].MatchProb > fuzzGap) - && (trackNumber == 0 - || sortedTracks[0].Track.AbsoluteTrackNumber == trackNumber - || sortedTracks[0].Track.AbsoluteTrackNumber + tracks.Count(t => t.MediumNumber < sortedTracks[0].Track.MediumNumber) == trackNumber)) - { - return sortedTracks[0].Track; - } - - return null; + return _trackRepository.TracksWithFiles(artistId); } - public List<Track> TracksWithFiles(int artistId) + public List<Track> TracksWithoutFiles(int albumId) { - return _trackRepository.TracksWithFiles(artistId); + return _trackRepository.TracksWithoutFiles(albumId); } public List<Track> GetTracksByFileId(int trackFileId) @@ -181,11 +104,6 @@ namespace NzbDrone.Core.Music _trackRepository.Update(track); } - public void UpdateTracks(List<Track> tracks) - { - _trackRepository.UpdateMany(tracks); - } - public void InsertMany(List<Track> tracks) { _trackRepository.InsertMany(tracks); @@ -201,6 +119,11 @@ namespace NzbDrone.Core.Music _trackRepository.DeleteMany(tracks); } + public void SetFileIds(List<Track> tracks) + { + _trackRepository.SetFileId(tracks); + } + public void HandleAsync(ReleaseDeletedEvent message) { var tracks = GetTracksByRelease(message.Release.Id); @@ -209,21 +132,8 @@ namespace NzbDrone.Core.Music public void Handle(TrackFileDeletedEvent message) { - foreach (var track in GetTracksByFileId(message.TrackFile.Id)) - { - _logger.Debug("Detaching track {0} from file.", track.Id); - track.TrackFileId = 0; - UpdateTrack(track); - } - } - - public void Handle(TrackFileAddedEvent message) - { - foreach (var track in message.TrackFile.Tracks.Value) - { - _trackRepository.SetFileId(track.Id, message.TrackFile.Id); - _logger.Debug("Linking [{0}] > [{1}]", message.TrackFile.RelativePath, track); - } + _logger.Debug($"Detaching tracks from file {message.TrackFile}"); + _trackRepository.DetachTrackFile(message.TrackFile.Id); } } } diff --git a/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs b/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs index 5dee8eb89..a902feef3 100644 --- a/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs +++ b/src/NzbDrone.Core/Notifications/CustomScript/CustomScript.cs @@ -170,7 +170,7 @@ namespace NzbDrone.Core.Notifications.CustomScript var process = _processProvider.StartAndCapture(Settings.Path, Settings.Arguments, environmentVariables); _logger.Debug("Executed external script: {0} - Status: {1}", Settings.Path, process.ExitCode); - _logger.Debug("Script Output: \r\n{0}", string.Join("\r\n", process.Lines)); + _logger.Debug($"Script Output: {System.Environment.NewLine}{string.Join(System.Environment.NewLine, process.Lines)}"); } } } diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 5798ed566..02ca86aab 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -89,10 +89,6 @@ <Reference Include="Prowlin, Version=0.9.4456.26422, Culture=neutral, processorArchitecture=MSIL"> <HintPath>..\packages\Prowlin.0.9.4456.26422\lib\net40\Prowlin.dll</HintPath> </Reference> - <Reference Include="policy.2.0.taglib-sharp, Version=0.0.0.0, Culture=neutral, PublicKeyToken=db62eba44689b5b0, processorArchitecture=MSIL"> - <HintPath>..\packages\taglib.2.1.0.0\lib\policy.2.0.taglib-sharp.dll</HintPath> - <Private>True</Private> - </Reference> <Reference Include="RestSharp, Version=105.2.3.0, Culture=neutral, processorArchitecture=MSIL"> <HintPath>..\packages\RestSharp.105.2.3\lib\net46\RestSharp.dll</HintPath> </Reference> @@ -110,8 +106,8 @@ <Reference Include="System.Data.SQLite"> <HintPath>..\Libraries\Sqlite\System.Data.SQLite.dll</HintPath> </Reference> - <Reference Include="taglib-sharp, Version=2.1.0.0, Culture=neutral, PublicKeyToken=db62eba44689b5b0, processorArchitecture=MSIL"> - <HintPath>..\packages\taglib.2.1.0.0\lib\taglib-sharp.dll</HintPath> + <Reference Include="taglib-sharp, Version=2.2.0.0, Culture=neutral, PublicKeyToken=db62eba44689b5b0, processorArchitecture=MSIL"> + <HintPath>..\packages\TagLibSharp.2.2.0-beta\lib\netstandard2.0\taglib-sharp.dll</HintPath> <Private>True</Private> </Reference> </ItemGroup> @@ -144,6 +140,7 @@ <Compile Include="Configuration\AccessDeniedConfigFileException.cs" /> <Compile Include="Configuration\InvalidConfigFileException.cs" /> <Compile Include="Configuration\RescanAfterRefreshType.cs" /> + <Compile Include="Configuration\AllowFingerprinting.cs" /> <Compile Include="Configuration\ResetApiKeyCommand.cs" /> <Compile Include="CustomFilters\CustomFilter.cs" /> <Compile Include="CustomFilters\CustomFilterRepository.cs" /> @@ -200,6 +197,7 @@ <Compile Include="Datastore\Migration\021_add_custom_filters.cs" /> <Compile Include="Datastore\Migration\022_import_list_tags.cs" /> <Compile Include="Datastore\Migration\023_add_release_groups_etc.cs" /> + <Compile Include="Datastore\Migration\024_clear_media_info.cs" /> <Compile Include="Datastore\Migration\Framework\MigrationContext.cs" /> <Compile Include="Datastore\Migration\Framework\MigrationController.cs" /> <Compile Include="Datastore\Migration\Framework\MigrationDbFactory.cs" /> @@ -483,6 +481,7 @@ <Compile Include="HealthCheck\Checks\AppDataLocationCheck.cs" /> <Compile Include="HealthCheck\Checks\DownloadClientCheck.cs" /> <Compile Include="HealthCheck\Checks\DownloadClientStatusCheck.cs" /> + <Compile Include="HealthCheck\Checks\FpcalcCheck.cs" /> <Compile Include="HealthCheck\Checks\ImportListStatusCheck.cs" /> <Compile Include="HealthCheck\Checks\MonoDebugCheck.cs" /> <Compile Include="HealthCheck\Checks\MonoTlsCheck.cs" /> @@ -491,7 +490,6 @@ <Compile Include="HealthCheck\Checks\IndexerRssCheck.cs" /> <Compile Include="HealthCheck\Checks\IndexerStatusCheck.cs" /> <Compile Include="HealthCheck\Checks\IndexerSearchCheck.cs" /> - <Compile Include="HealthCheck\Checks\MediaInfoDllCheck.cs" /> <Compile Include="HealthCheck\Checks\MonoVersionCheck.cs" /> <Compile Include="HealthCheck\Checks\ProxyCheck.cs" /> <Compile Include="HealthCheck\Checks\RootFolderCheck.cs" /> @@ -713,16 +711,23 @@ <Compile Include="MediaFiles\Commands\DownloadedAlbumsScanCommand.cs" /> <Compile Include="MediaFiles\Commands\RenameArtistCommand.cs" /> <Compile Include="MediaFiles\Events\AlbumImportedEvent.cs" /> + <Compile Include="MediaFiles\Events\AlbumImportIncompleteEvent.cs" /> <Compile Include="MediaFiles\Events\TrackFileRenamedEvent.cs" /> <Compile Include="MediaFiles\Events\TrackFolderCreatedEvent.cs" /> <Compile Include="MediaFiles\Events\TrackImportFailedEvent.cs" /> <Compile Include="MediaFiles\MediaFileDeletionService.cs" /> - <Compile Include="MediaFiles\MediaInfo\MediaInfoFormatter.cs" /> + <Compile Include="MediaFiles\MediaInfoFormatter.cs" /> <Compile Include="MediaFiles\RenameTrackFilePreview.cs" /> <Compile Include="MediaFiles\RenameTrackFileService.cs" /> <Compile Include="MediaFiles\TrackFileMovingService.cs" /> <Compile Include="MediaFiles\TrackFileMoveResult.cs" /> <Compile Include="MediaFiles\TrackImport\ImportMode.cs" /> + <Compile Include="MediaFiles\TrackImport\Aggregation\Aggregators\AggregateQuality.cs" /> + <Compile Include="MediaFiles\TrackImport\Aggregation\Aggregators\AggregateReleaseGroup.cs" /> + <Compile Include="MediaFiles\TrackImport\Aggregation\Aggregators\AggregateFilenameInfo.cs" /> + <Compile Include="MediaFiles\TrackImport\Aggregation\Aggregators\IAggregateLocalTrack.cs" /> + <Compile Include="MediaFiles\TrackImport\Aggregation\AggregationService.cs" /> + <Compile Include="MediaFiles\TrackImport\Aggregation\AggregationFailedException.cs" /> <Compile Include="MediaFiles\Commands\RenameFilesCommand.cs" /> <Compile Include="MediaFiles\DeleteMediaFileReason.cs" /> <Compile Include="MediaFiles\DiskScanService.cs"> @@ -745,6 +750,17 @@ <Compile Include="MediaFiles\TrackImport\Specifications\NotUnpackingSpecification.cs" /> <Compile Include="MediaFiles\TrackImport\Specifications\SameFileSpecification.cs" /> <Compile Include="MediaFiles\TrackImport\Specifications\UpgradeSpecification.cs" /> + <Compile Include="MediaFiles\TrackImport\Specifications\CloseAlbumMatchSpecification.cs" /> + <Compile Include="MediaFiles\TrackImport\Specifications\CloseTrackMatchSpecification.cs" /> + <Compile Include="MediaFiles\TrackImport\Specifications\AlbumUpgradeSpecification.cs" /> + <Compile Include="MediaFiles\TrackImport\Specifications\MoreTracksSpecification.cs" /> + <Compile Include="MediaFiles\TrackImport\Specifications\NoMissingOrUnmatchedTracksSpecification.cs" /> + <Compile Include="MediaFiles\TrackImport\Specifications\ReleaseWantedSpecification.cs" /> + <Compile Include="MediaFiles\TrackImport\Identification\TrackGroupingService.cs" /> + <Compile Include="MediaFiles\TrackImport\Identification\IdentificationService.cs" /> + <Compile Include="MediaFiles\TrackImport\Identification\IdentificationTestCase.cs" /> + <Compile Include="MediaFiles\TrackImport\Identification\Distance.cs" /> + <Compile Include="MediaFiles\TrackImport\Identification\Munkres.cs" /> <Compile Include="MediaFiles\Events\ArtistRenamedEvent.cs" /> <Compile Include="MediaFiles\Events\ArtistScannedEvent.cs" /> <Compile Include="MediaFiles\Events\ArtistScanSkippedEvent.cs" /> @@ -760,10 +776,6 @@ <SubType>Code</SubType> </Compile> <Compile Include="MediaFiles\MediaFileTableCleanupService.cs" /> - <Compile Include="MediaFiles\MediaInfo\MediaInfoLib.cs" /> - <Compile Include="MediaFiles\MediaInfo\MediaInfoModel.cs" /> - <Compile Include="MediaFiles\MediaInfo\UpdateMediaInfoService.cs" /> - <Compile Include="MediaFiles\MediaInfo\VideoFileInfoReader.cs" /> <Compile Include="MediaFiles\RecycleBinProvider.cs" /> <Compile Include="MediaFiles\SameFilenameException.cs" /> <Compile Include="MediaFiles\TrackFile.cs" /> @@ -970,10 +982,14 @@ <Compile Include="Notifications\Twitter\TwitterSettings.cs" /> <Compile Include="Parser\IsoLanguage.cs" /> <Compile Include="Parser\IsoLanguages.cs" /> + <Compile Include="Parser\IsoCountry.cs" /> + <Compile Include="Parser\IsoCountries.cs" /> <Compile Include="Parser\LanguageParser.cs" /> <Compile Include="Parser\Model\ArtistTitleInfo.cs" /> <Compile Include="Parser\Model\ImportListItemInfo.cs" /> + <Compile Include="Parser\Model\LocalAlbumRelease.cs" /> <Compile Include="Parser\Model\LocalTrack.cs" /> + <Compile Include="Parser\Model\MediaInfoModel.cs" /> <Compile Include="Parser\Model\ParsedAlbumInfo.cs" /> <Compile Include="Parser\Model\ParsedTrackInfo.cs" /> <Compile Include="Parser\Model\RemoteAlbum.cs" /> @@ -995,7 +1011,7 @@ <Compile Include="Profiles\Qualities\ProfileRepository.cs" /> <Compile Include="Profiles\Qualities\QualityIndex.cs" /> <Compile Include="ProgressMessaging\ProgressMessageContext.cs" /> - <Compile Include="Qualities\QualitySource.cs" /> + <Compile Include="Qualities\QualityDetectionSource.cs" /> <Compile Include="Qualities\Revision.cs" /> <Compile Include="Queue\EstimatedCompletionTimeComparer.cs" /> <Compile Include="Queue\TimeleftComparer.cs" /> @@ -1093,6 +1109,7 @@ <Compile Include="Parser\Model\TorrentInfo.cs" /> <Compile Include="Parser\Parser.cs" /> <Compile Include="Parser\ParsingService.cs" /> + <Compile Include="Parser\FingerprintingService.cs" /> <Compile Include="Parser\SceneChecker.cs" /> <Compile Include="Parser\QualityParser.cs" /> <Compile Include="Profiles\Qualities\Profile.cs" /> @@ -1220,9 +1237,6 @@ </ItemGroup> <ItemGroup> <None Include="App.config" /> - <None Include="Lidarr.Core.dll.config"> - <CopyToOutputDirectory>Always</CopyToOutputDirectory> - </None> <None Include="packages.config" /> <None Include="Properties\AnalysisRules.ruleset" /> </ItemGroup> @@ -1249,19 +1263,19 @@ </EmbeddedResource> </ItemGroup> <ItemGroup> - <Content Include="..\Libraries\MediaInfo\MediaInfo.dll"> - <Link>MediaInfo.dll</Link> + <Content Include="..\Libraries\Sqlite\libsqlite3.0.dylib"> + <Link>libsqlite3.0.dylib</Link> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </Content> - <Content Include="..\Libraries\MediaInfo\libmediainfo.0.dylib"> - <Link>libmediainfo.0.dylib</Link> + <Compile Include="Notifications\Telegram\TelegramError.cs" /> + </ItemGroup> + <ItemGroup> + <Content Include="..\Libraries\Fpcalc\chromaprint-fpcalc-1.4.3-windows-x86_64\fpcalc.exe"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </Content> - <Content Include="..\Libraries\Sqlite\libsqlite3.0.dylib"> - <Link>libsqlite3.0.dylib</Link> + <Content Include="..\Libraries\Fpcalc\chromaprint-fpcalc-1.4.3-macos-x86_64\fpcalc"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </Content> - <Compile Include="Notifications\Telegram\TelegramError.cs" /> </ItemGroup> <ItemGroup /> <Import Project="$(MSBuildToolsPath)\Microsoft.CSharp.targets" /> diff --git a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs index 4e6fb160b..c78ebee1d 100644 --- a/src/NzbDrone.Core/Organizer/FileNameBuilder.cs +++ b/src/NzbDrone.Core/Organizer/FileNameBuilder.cs @@ -9,7 +9,6 @@ using NzbDrone.Common.Cache; using NzbDrone.Common.EnsureThat; using NzbDrone.Common.Extensions; using NzbDrone.Core.MediaFiles; -using NzbDrone.Core.MediaFiles.MediaInfo; using NzbDrone.Core.Qualities; using NzbDrone.Core.Music; @@ -324,36 +323,14 @@ namespace NzbDrone.Core.Organizer var audioCodec = MediaInfoFormatter.FormatAudioCodec(trackFile.MediaInfo); var audioChannels = MediaInfoFormatter.FormatAudioChannels(trackFile.MediaInfo); - - var mediaInfoAudioLanguages = GetLanguagesToken(trackFile.MediaInfo.AudioLanguages); - if (!mediaInfoAudioLanguages.IsNullOrWhiteSpace()) - { - mediaInfoAudioLanguages = $"[{mediaInfoAudioLanguages}]"; - } - - if (mediaInfoAudioLanguages == "[EN]") - { - mediaInfoAudioLanguages = string.Empty; - } - - var mediaInfoSubtitleLanguages = GetLanguagesToken(trackFile.MediaInfo.Subtitles); - if (!mediaInfoSubtitleLanguages.IsNullOrWhiteSpace()) - { - mediaInfoSubtitleLanguages = $"[{mediaInfoSubtitleLanguages}]"; - } - - var videoBitDepth = trackFile.MediaInfo.VideoBitDepth > 0 ? trackFile.MediaInfo.VideoBitDepth.ToString() : string.Empty; var audioChannelsFormatted = audioChannels > 0 ? audioChannels.ToString("F1", CultureInfo.InvariantCulture) : string.Empty; - tokenHandlers["{MediaInfo Audio}"] = m => audioCodec; tokenHandlers["{MediaInfo AudioCodec}"] = m => audioCodec; tokenHandlers["{MediaInfo AudioChannels}"] = m => audioChannelsFormatted; - - tokenHandlers["{MediaInfo Simple}"] = m => $"{audioCodec}"; - - tokenHandlers["{MediaInfo Full}"] = m => $"{audioCodec}{mediaInfoAudioLanguages} {mediaInfoSubtitleLanguages}"; + tokenHandlers["{MediaInfo AudioBitsPerSample}"] = m => MediaInfoFormatter.FormatAudioBitsPerSample(trackFile.MediaInfo); + tokenHandlers["{MediaInfo AudioSampleRate}"] = m => MediaInfoFormatter.FormatAudioSampleRate(trackFile.MediaInfo); } private string GetLanguagesToken(string mediaInfoLanguages) diff --git a/src/NzbDrone.Core/Organizer/FileNameSampleService.cs b/src/NzbDrone.Core/Organizer/FileNameSampleService.cs index 370a5c157..d4c4fb7d8 100644 --- a/src/NzbDrone.Core/Organizer/FileNameSampleService.cs +++ b/src/NzbDrone.Core/Organizer/FileNameSampleService.cs @@ -2,7 +2,7 @@ using System.Collections.Generic; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Qualities; using NzbDrone.Core.Music; -using NzbDrone.Core.MediaFiles.MediaInfo; +using NzbDrone.Core.Parser.Model; namespace NzbDrone.Core.Organizer { @@ -73,13 +73,11 @@ namespace NzbDrone.Core.Organizer var mediaInfo = new MediaInfoModel() { - VideoCodec = "AVC", - VideoBitDepth = 8, - AudioFormat = "FLAC", - AudioChannels = 6, - AudioChannelPositions = "3/2/0.1", - AudioLanguages = "English", - Subtitles = "English/German" + AudioFormat = "Flac Audio", + AudioChannels = 2, + AudioBitrate = 875, + AudioBits = 24, + AudioSampleRate = 44100 }; _singleTrackFile = new TrackFile diff --git a/src/NzbDrone.Core/Parser/FingerprintingService.cs b/src/NzbDrone.Core/Parser/FingerprintingService.cs new file mode 100644 index 000000000..6b5b11d52 --- /dev/null +++ b/src/NzbDrone.Core/Parser/FingerprintingService.cs @@ -0,0 +1,403 @@ +using System.IO; +using NLog; +using NzbDrone.Core.Parser.Model; +using System.Diagnostics; +using System.Linq; +using NzbDrone.Common.Http; +using NzbDrone.Common.Extensions; +using System.Collections.Generic; +using System.IO.Compression; +using System.Text; +using NzbDrone.Common.Serializer; +using System; +using NzbDrone.Common.EnvironmentInfo; +using System.Threading; +using System.Text.RegularExpressions; + +namespace NzbDrone.Core.Parser +{ + public interface IFingerprintingService + { + bool IsSetup(); + Version FpcalcVersion(); + void Lookup(List<LocalTrack> tracks, double threshold); + } + + public class AcoustId + { + public double Duration { get; set; } + public string Fingerprint { get; set; } + } + + public class FingerprintingService : IFingerprintingService + { + private const string _acoustIdUrl = "https://api.acoustid.org/v2/lookup"; + private const string _acoustIdApiKey = "QANd68ji1L"; + private const int _fingerprintingTimeout = 10000; + + private readonly Logger _logger; + private readonly IHttpClient _httpClient; + private readonly IHttpRequestBuilderFactory _customerRequestBuilder; + + private readonly string _fpcalcPath; + private readonly Version _fpcalcVersion; + private readonly string _fpcalcArgs; + + public FingerprintingService(Logger logger, + IHttpClient httpClient) + { + _logger = logger; + _httpClient = httpClient; + + _customerRequestBuilder = new HttpRequestBuilder(_acoustIdUrl).CreateFactory(); + + _fpcalcPath = GetFpcalcPath(); + if (_fpcalcPath.IsNotNullOrWhiteSpace()) + { + _fpcalcVersion = GetFpcalcVersion(); + _fpcalcArgs = GetFpcalcArgs(); + } + } + + public bool IsSetup() => _fpcalcPath.IsNotNullOrWhiteSpace(); + public Version FpcalcVersion() => _fpcalcVersion; + + private string GetFpcalcPath() + { + string path = null; + if (OsInfo.IsLinux) + { + // must be on users path on Linux + path = "fpcalc"; + + // check that the command exists + Process p = new Process(); + p.StartInfo.FileName = "which"; + p.StartInfo.Arguments = $"{path}"; + p.StartInfo.UseShellExecute = false; + p.StartInfo.RedirectStandardOutput = true; + + p.Start(); + // To avoid deadlocks, always read the output stream first and then wait. + string output = p.StandardOutput.ReadToEnd(); + p.WaitForExit(1000); + + if (p.ExitCode != 0) + { + _logger.Debug("fpcalc not found"); + return null; + } + } + else + { + // on OSX / Windows, we have put fpcalc in the application folder + path = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "fpcalc"); + if (OsInfo.IsWindows) + { + path += ".exe"; + } + + if (!File.Exists(path)) + { + _logger.Warn("fpcalc missing from application directory"); + return null; + } + } + + _logger.Debug($"fpcalc path: {path}"); + return path; + } + + private Version GetFpcalcVersion() + { + if (_fpcalcPath == null) + { + return null; + } + + Process p = new Process(); + p.StartInfo.FileName = _fpcalcPath; + p.StartInfo.Arguments = $"-version"; + p.StartInfo.UseShellExecute = false; + p.StartInfo.RedirectStandardOutput = true; + + p.Start(); + // To avoid deadlocks, always read the output stream first and then wait. + string output = p.StandardOutput.ReadToEnd(); + p.WaitForExit(1000); + + if (p.ExitCode != 0) + { + _logger.Warn("Could not get fpcalc version (may be known issue with fpcalc v1.4)"); + return null; + } + + var versionstring = Regex.Match(output, @"\d\.\d\.\d").Value; + if (versionstring.IsNullOrWhiteSpace()) + { + return null; + } + + var version = new Version(versionstring); + _logger.Debug($"fpcalc version: {version}"); + + return version; + } + + private string GetFpcalcArgs() + { + var args = ""; + + if (_fpcalcVersion == null) + { + return args; + } + + if (_fpcalcVersion >= new Version("1.4.0")) + { + args = "-json"; + } + + if (_fpcalcVersion >= new Version("1.4.3")) + { + args += " -ignore-errors"; + } + + return args; + } + + public AcoustId ParseFpcalcJsonOutput(string output) + { + return Json.Deserialize<AcoustId>(output); + } + + public AcoustId ParseFpcalcTextOutput(string output) + { + var durationstring = Regex.Match(output, @"(?<=DURATION=)[\d\.]+(?=\s)").Value; + double duration; + if (durationstring.IsNullOrWhiteSpace() || !double.TryParse(durationstring, out duration)) + { + return null; + } + + var fingerprint = Regex.Match(output, @"(?<=FINGERPRINT=)[^\s]+").Value; + if (fingerprint.IsNullOrWhiteSpace()) + { + return null; + } + + return new AcoustId { + Duration = duration, + Fingerprint = fingerprint + }; + } + + public AcoustId ParseFpcalcOutput(string output) + { + if (output.IsNullOrWhiteSpace()) + { + return null; + } + + if (_fpcalcArgs.Contains("-json")) + { + return ParseFpcalcJsonOutput(output); + } + else + { + return ParseFpcalcTextOutput(output); + } + } + + public AcoustId GetFingerprint(string file) + { + if (IsSetup() && File.Exists(file)) + { + Process p = new Process(); + p.StartInfo.FileName = _fpcalcPath; + p.StartInfo.Arguments = $"{_fpcalcArgs} \"{file}\""; + p.StartInfo.UseShellExecute = false; + p.StartInfo.RedirectStandardOutput = true; + p.StartInfo.RedirectStandardError = true; + + _logger.Trace("Executing {0} {1}", p.StartInfo.FileName, p.StartInfo.Arguments); + + StringBuilder output = new StringBuilder(); + StringBuilder error = new StringBuilder(); + + // see https://stackoverflow.com/questions/139593/processstartinfo-hanging-on-waitforexit-why?lq=1 + // this is most likely overkill... + using (AutoResetEvent outputWaitHandle = new AutoResetEvent(false)) + { + using (AutoResetEvent errorWaitHandle = new AutoResetEvent(false)) + { + DataReceivedEventHandler outputHandler = delegate(object sender, DataReceivedEventArgs e) + { + if (e.Data == null) + { + outputWaitHandle.Set(); + } + else + { + output.AppendLine(e.Data); + } + }; + + DataReceivedEventHandler errorHandler = delegate(object sender, DataReceivedEventArgs e) + { + if (e.Data == null) + { + errorWaitHandle.Set(); + } + else + { + error.AppendLine(e.Data); + } + }; + + p.OutputDataReceived += outputHandler; + p.ErrorDataReceived += errorHandler; + + p.Start(); + + p.BeginOutputReadLine(); + p.BeginErrorReadLine(); + + if (p.WaitForExit(_fingerprintingTimeout) && + outputWaitHandle.WaitOne(_fingerprintingTimeout) && + errorWaitHandle.WaitOne(_fingerprintingTimeout)) + { + // Process completed. + if (p.ExitCode != 0) + { + _logger.Warn($"fpcalc error: {error}"); + return null; + } + else + { + return ParseFpcalcOutput(output.ToString()); + } + } + else + { + // Timed out. Remove handlers to avoid object disposed error + p.OutputDataReceived -= outputHandler; + p.ErrorDataReceived -= errorHandler; + + _logger.Warn($"fpcalc timed out. {error}"); + return null; + } + } + } + } + + return null; + } + + private static byte[] Compress(byte[] data) + { + using (var compressedStream = new MemoryStream()) + using (var zipStream = new GZipStream(compressedStream, CompressionMode.Compress)) + { + zipStream.Write(data, 0, data.Length); + zipStream.Close(); + return compressedStream.ToArray(); + } + } + + public void Lookup(List<LocalTrack> tracks, double threshold) + { + if (!IsSetup()) + { + return; + } + + Lookup(tracks.Select(x => Tuple.Create(x, GetFingerprint(x.Path))).ToList(), threshold); + } + + public void Lookup(List<Tuple<LocalTrack, AcoustId>> files, double threshold) + { + var toLookup = files.Where(x => x.Item2 != null).ToList(); + if (!toLookup.Any()) + { + return; + } + + var httpRequest = _customerRequestBuilder.Create() + .WithRateLimit(0.334) + .Build(); + + var sb = new StringBuilder($"client={_acoustIdApiKey}&format=json&meta=recordingids&batch=1", 2000); + for (int i = 0; i < toLookup.Count; i++) + { + sb.Append($"&duration.{i}={toLookup[i].Item2.Duration:F0}&fingerprint.{i}={toLookup[i].Item2.Fingerprint}"); + } + + // they prefer a gzipped body + httpRequest.SetContent(Compress(Encoding.UTF8.GetBytes(sb.ToString()))); + httpRequest.Headers.Add("Content-Encoding", "gzip"); + httpRequest.Headers.ContentType = "application/x-www-form-urlencoded"; + + var httpResponse = _httpClient.Post<LookupResponse>(httpRequest); + + if (httpResponse.HasHttpError) + { + throw new HttpException(httpRequest, httpResponse); + } + + var response = httpResponse.Resource; + + if (!string.IsNullOrEmpty(response.ErrorMessage)) + { + _logger.Debug("Webservice error: {0}", response.ErrorMessage); + return; + } + + foreach (var fileResponse in response.Fingerprints) + { + if (fileResponse.Results.Count == 0) + { + _logger.Debug("No results for given fingerprint."); + continue; + } + + foreach (var result in fileResponse.Results.Where(x => x.Recordings != null)) + { + _logger.Trace("Found: {0}, {1}, {2}", result.Id, result.Score, string.Join(", ", result.Recordings.Select(x => x.Id))); + } + + var ids = fileResponse.Results.Where(x => x.Score > threshold && x.Recordings != null).SelectMany(y => y.Recordings.Select(z => z.Id)).Distinct().ToList(); + _logger.Trace("All recordings: {0}", string.Join("\n", ids)); + + toLookup[fileResponse.index].Item1.AcoustIdResults = ids; + } + + _logger.Debug("Fingerprinting complete."); + } + + private class LookupResponse + { + public string StatusCode { get; set; } + public string ErrorMessage { get; set; } + public List<LookupResultListItem> Fingerprints { get; set; } + } + + private class LookupResultListItem + { + public int index { get; set; } + public List<LookupResult> Results { get; set; } + } + + private class LookupResult + { + public string Id { get; set; } + public double Score { get; set; } + public List<RecordingResult> Recordings { get; set; } + } + + private class RecordingResult + { + public string Id { get; set; } + } + } +} diff --git a/src/NzbDrone.Core/Parser/IsoCountries.cs b/src/NzbDrone.Core/Parser/IsoCountries.cs new file mode 100644 index 000000000..a789103ff --- /dev/null +++ b/src/NzbDrone.Core/Parser/IsoCountries.cs @@ -0,0 +1,290 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Common.Extensions; +using NzbDrone.Core.Languages; + +namespace NzbDrone.Core.Parser +{ + public static class IsoCountries + { + // see https://wiki.musicbrainz.org/Release_Country + private static readonly HashSet<IsoCountry> All = new HashSet<IsoCountry> + { + new IsoCountry("AF", "Afghanistan" ), + new IsoCountry("AX", "Åland Islands"), + new IsoCountry("AL", "Albania"), + new IsoCountry("DZ", "Algeria"), + new IsoCountry("AS", "American Samoa"), + new IsoCountry("AD", "Andorra"), + new IsoCountry("AO", "Angola"), + new IsoCountry("AI", "Anguilla"), + new IsoCountry("AQ", "Antarctica"), + new IsoCountry("AG", "Antigua and Barbuda"), + new IsoCountry("AR", "Argentina"), + new IsoCountry("AM", "Armenia"), + new IsoCountry("AW", "Aruba"), + new IsoCountry("AU", "Australia"), + new IsoCountry("AT", "Austria"), + new IsoCountry("AZ", "Azerbaijan"), + new IsoCountry("BS", "Bahamas"), + new IsoCountry("BH", "Bahrain"), + new IsoCountry("BD", "Bangladesh"), + new IsoCountry("BB", "Barbados"), + new IsoCountry("BY", "Belarus"), + new IsoCountry("BE", "Belgium"), + new IsoCountry("BZ", "Belize"), + new IsoCountry("BJ", "Benin"), + new IsoCountry("BM", "Bermuda"), + new IsoCountry("BT", "Bhutan"), + new IsoCountry("BO", "Bolivia"), + new IsoCountry("BA", "Bosnia and Herzegovina"), + new IsoCountry("BW", "Botswana"), + new IsoCountry("BV", "Bouvet Island"), + new IsoCountry("BR", "Brazil"), + new IsoCountry("IO", "British Indian Ocean Territory"), + new IsoCountry("BN", "Brunei Darussalam"), + new IsoCountry("BG", "Bulgaria"), + new IsoCountry("BF", "Burkina Faso"), + new IsoCountry("BI", "Burundi"), + new IsoCountry("KH", "Cambodia"), + new IsoCountry("CM", "Cameroon"), + new IsoCountry("CA", "Canada"), + new IsoCountry("CV", "Cape Verde"), + new IsoCountry("KY", "Cayman Islands"), + new IsoCountry("CF", "Central African Republic"), + new IsoCountry("TD", "Chad"), + new IsoCountry("CL", "Chile"), + new IsoCountry("CN", "China"), + new IsoCountry("CX", "Christmas Island"), + new IsoCountry("CC", "Cocos (Keeling) Islands"), + new IsoCountry("CO", "Colombia"), + new IsoCountry("KM", "Comoros"), + new IsoCountry("CG", "Congo"), + new IsoCountry("CD", "Congo, The Democratic Republic of the"), + new IsoCountry("CK", "Cook Islands"), + new IsoCountry("CR", "Costa Rica"), + new IsoCountry("CI", "Cote d'Ivoire"), + new IsoCountry("HR", "Croatia"), + new IsoCountry("CU", "Cuba"), + new IsoCountry("CY", "Cyprus"), + new IsoCountry("XC", "Czechoslovakia"), + new IsoCountry("CZ", "Czech Republic"), + new IsoCountry("DK", "Denmark"), + new IsoCountry("DJ", "Djibouti"), + new IsoCountry("DM", "Dominica"), + new IsoCountry("DO", "Dominican Republic"), + new IsoCountry("XG", "East Germany"), + new IsoCountry("EC", "Ecuador"), + new IsoCountry("EG", "Egypt"), + new IsoCountry("SV", "El Salvador"), + new IsoCountry("GQ", "Equatorial Guinea"), + new IsoCountry("ER", "Eritrea"), + new IsoCountry("EE", "Estonia"), + new IsoCountry("ET", "Ethiopia"), + new IsoCountry("XE", "Europe"), + new IsoCountry("FK", "Falkland Islands (Malvinas)"), + new IsoCountry("FO", "Faroe Islands"), + new IsoCountry("FJ", "Fiji"), + new IsoCountry("FI", "Finland"), + new IsoCountry("FR", "France"), + new IsoCountry("GF", "French Guiana"), + new IsoCountry("PF", "French Polynesia"), + new IsoCountry("TF", "French Southern Territories"), + new IsoCountry("GA", "Gabon"), + new IsoCountry("GM", "Gambia"), + new IsoCountry("GE", "Georgia"), + new IsoCountry("DE", "Germany"), + new IsoCountry("GH", "Ghana"), + new IsoCountry("GI", "Gibraltar"), + new IsoCountry("GR", "Greece"), + new IsoCountry("GL", "Greenland"), + new IsoCountry("GD", "Grenada"), + new IsoCountry("GP", "Guadeloupe"), + new IsoCountry("GU", "Guam"), + new IsoCountry("GT", "Guatemala"), + new IsoCountry("GG", "Guernsey"), + new IsoCountry("GN", "Guinea"), + new IsoCountry("GW", "Guinea-Bissau"), + new IsoCountry("GY", "Guyana"), + new IsoCountry("HT", "Haiti"), + new IsoCountry("HM", "Heard and Mc Donald Islands"), + new IsoCountry("HN", "Honduras"), + new IsoCountry("HK", "Hong Kong"), + new IsoCountry("HU", "Hungary"), + new IsoCountry("IS", "Iceland"), + new IsoCountry("IN", "India"), + new IsoCountry("ID", "Indonesia"), + new IsoCountry("IR", "Iran (Islamic Republic of)"), + new IsoCountry("IQ", "Iraq"), + new IsoCountry("IE", "Ireland"), + new IsoCountry("IM", "Isle of Man"), + new IsoCountry("IL", "Israel"), + new IsoCountry("IT", "Italy"), + new IsoCountry("JM", "Jamaica"), + new IsoCountry("JP", "Japan"), + new IsoCountry("JE", "Jersey"), + new IsoCountry("JO", "Jordan"), + new IsoCountry("KZ", "Kazakhstan"), + new IsoCountry("KE", "Kenya"), + new IsoCountry("KI", "Kiribati"), + new IsoCountry("KP", "Korea (North), Democratic People's Republic of"), + new IsoCountry("KR", "Korea (South), Republic of"), + new IsoCountry("KW", "Kuwait"), + new IsoCountry("KG", "Kyrgyzstan"), + new IsoCountry("LA", "Lao People's Democratic Republic"), + new IsoCountry("LV", "Latvia"), + new IsoCountry("LB", "Lebanon"), + new IsoCountry("LS", "Lesotho"), + new IsoCountry("LR", "Liberia"), + new IsoCountry("LY", "Libyan Arab Jamahiriya"), + new IsoCountry("LI", "Liechtenstein"), + new IsoCountry("LT", "Lithuania"), + new IsoCountry("LU", "Luxembourg"), + new IsoCountry("MO", "Macau"), + new IsoCountry("MK", "Macedonia, The Former Yugoslav Republic of"), + new IsoCountry("MG", "Madagascar"), + new IsoCountry("MW", "Malawi"), + new IsoCountry("MY", "Malaysia"), + new IsoCountry("MV", "Maldives"), + new IsoCountry("ML", "Mali"), + new IsoCountry("MT", "Malta"), + new IsoCountry("MH", "Marshall Islands"), + new IsoCountry("MQ", "Martinique"), + new IsoCountry("MR", "Mauritania"), + new IsoCountry("MU", "Mauritius"), + new IsoCountry("YT", "Mayotte"), + new IsoCountry("MX", "Mexico"), + new IsoCountry("FM", "Micronesia, Federated States of"), + new IsoCountry("MD", "Moldova, Republic of"), + new IsoCountry("MC", "Monaco"), + new IsoCountry("MN", "Mongolia"), + new IsoCountry("ME", "Montenegro"), + new IsoCountry("MS", "Montserrat"), + new IsoCountry("MA", "Morocco"), + new IsoCountry("MZ", "Mozambique"), + new IsoCountry("MM", "Myanmar"), + new IsoCountry("NA", "Namibia"), + new IsoCountry("NR", "Nauru"), + new IsoCountry("NP", "Nepal"), + new IsoCountry("NL", "Netherlands"), + new IsoCountry("AN", "Netherlands Antilles"), + new IsoCountry("NC", "New Caledonia"), + new IsoCountry("NZ", "New Zealand"), + new IsoCountry("NI", "Nicaragua"), + new IsoCountry("NE", "Niger"), + new IsoCountry("NG", "Nigeria"), + new IsoCountry("NU", "Niue"), + new IsoCountry("NF", "Norfolk Island"), + new IsoCountry("MP", "Northern Mariana Islands"), + new IsoCountry("NO", "Norway"), + new IsoCountry("OM", "Oman"), + new IsoCountry("PK", "Pakistan"), + new IsoCountry("PW", "Palau"), + new IsoCountry("PS", "Palestinian Territory"), + new IsoCountry("PA", "Panama"), + new IsoCountry("PG", "Papua New Guinea"), + new IsoCountry("PY", "Paraguay"), + new IsoCountry("PE", "Peru"), + new IsoCountry("PH", "Philippines"), + new IsoCountry("PN", "Pitcairn"), + new IsoCountry("PL", "Poland"), + new IsoCountry("PT", "Portugal"), + new IsoCountry("PR", "Puerto Rico"), + new IsoCountry("QA", "Qatar"), + new IsoCountry("RE", "Reunion"), + new IsoCountry("RO", "Romania"), + new IsoCountry("RU", "Russian Federation"), + new IsoCountry("RW", "Rwanda"), + new IsoCountry("BL", "Saint Barthélemy"), + new IsoCountry("SH", "Saint Helena"), + new IsoCountry("KN", "Saint Kitts and Nevis"), + new IsoCountry("LC", "Saint Lucia"), + new IsoCountry("MF", "Saint Martin"), + new IsoCountry("PM", "Saint Pierre and Miquelon"), + new IsoCountry("VC", "Saint Vincent and The Grenadines"), + new IsoCountry("WS", "Samoa"), + new IsoCountry("SM", "San Marino"), + new IsoCountry("ST", "Sao Tome and Principe"), + new IsoCountry("SA", "Saudi Arabia"), + new IsoCountry("SN", "Senegal"), + new IsoCountry("RS", "Serbia"), + new IsoCountry("CS", "Serbia and Montenegro"), + new IsoCountry("SC", "Seychelles"), + new IsoCountry("SL", "Sierra Leone"), + new IsoCountry("SG", "Singapore"), + new IsoCountry("SK", "Slovakia"), + new IsoCountry("SI", "Slovenia"), + new IsoCountry("SB", "Solomon Islands"), + new IsoCountry("SO", "Somalia"), + new IsoCountry("ZA", "South Africa"), + new IsoCountry("GS", "South Georgia and the South Sandwich Islands"), + new IsoCountry("SU", "Soviet Union"), + new IsoCountry("ES", "Spain"), + new IsoCountry("LK", "Sri Lanka"), + new IsoCountry("SD", "Sudan"), + new IsoCountry("SR", "Suriname"), + new IsoCountry("SJ", "Svalbard and Jan Mayen"), + new IsoCountry("SZ", "Swaziland"), + new IsoCountry("SE", "Sweden"), + new IsoCountry("CH", "Switzerland"), + new IsoCountry("SY", "Syrian Arab Republic"), + new IsoCountry("TW", "Taiwan"), + new IsoCountry("TJ", "Tajikistan"), + new IsoCountry("TZ", "Tanzania, United Republic of"), + new IsoCountry("TH", "Thailand"), + new IsoCountry("TL", "Timor-Leste"), + new IsoCountry("TG", "Togo"), + new IsoCountry("TK", "Tokelau"), + new IsoCountry("TO", "Tonga"), + new IsoCountry("TT", "Trinidad and Tobago"), + new IsoCountry("TN", "Tunisia"), + new IsoCountry("TR", "Turkey"), + new IsoCountry("TM", "Turkmenistan"), + new IsoCountry("TC", "Turks and Caicos Islands"), + new IsoCountry("TV", "Tuvalu"), + new IsoCountry("UG", "Uganda"), + new IsoCountry("UA", "Ukraine"), + new IsoCountry("AE", "United Arab Emirates"), + new IsoCountry("GB", "United Kingdom"), + new IsoCountry("US", "United States"), + new IsoCountry("UM", "United States Minor Outlying Islands"), + new IsoCountry("XU", "[Unknown Country]"), + new IsoCountry("UY", "Uruguay"), + new IsoCountry("UZ", "Uzbekistan"), + new IsoCountry("VU", "Vanuatu"), + new IsoCountry("VA", "Vatican City State (Holy See)"), + new IsoCountry("VE", "Venezuela"), + new IsoCountry("VN", "Viet Nam"), + new IsoCountry("VG", "Virgin Islands, British"), + new IsoCountry("VI", "Virgin Islands, U.S."), + new IsoCountry("WF", "Wallis and Futuna Islands"), + new IsoCountry("EH", "Western Sahara"), + new IsoCountry("XW", "[Worldwide]"), + new IsoCountry("YE", "Yemen"), + new IsoCountry("YU", "Yugoslavia"), + new IsoCountry("ZM", "Zambia"), + new IsoCountry("ZW", "Zimbabwe") + }; + + public static IsoCountry Find(string value) + { + if (value.IsNullOrWhiteSpace()) + { + return null; + } + else if (value.Length == 2) + { + return All.SingleOrDefault(l => l.TwoLetterCode.Equals(value, StringComparison.InvariantCultureIgnoreCase)); + } + else if (value.Length == 3) + { + return All.SingleOrDefault(l => l.TwoLetterCode.Equals(value.Substring(0, 2), StringComparison.InvariantCultureIgnoreCase)); + } + else + { + return All.SingleOrDefault(l => l.Name.Equals(value, StringComparison.InvariantCultureIgnoreCase)); + } + } + } +} diff --git a/src/NzbDrone.Core/Parser/IsoCountry.cs b/src/NzbDrone.Core/Parser/IsoCountry.cs new file mode 100644 index 000000000..9fd93b1ed --- /dev/null +++ b/src/NzbDrone.Core/Parser/IsoCountry.cs @@ -0,0 +1,14 @@ +namespace NzbDrone.Core.Parser +{ + public class IsoCountry + { + public string TwoLetterCode { get; set; } + public string Name { get; set; } + + public IsoCountry(string twoLetterCode, string name) + { + TwoLetterCode = twoLetterCode; + Name = name; + } + } +} diff --git a/src/NzbDrone.Core/Parser/Model/LocalAlbumRelease.cs b/src/NzbDrone.Core/Parser/Model/LocalAlbumRelease.cs new file mode 100644 index 000000000..4dd3112d7 --- /dev/null +++ b/src/NzbDrone.Core/Parser/Model/LocalAlbumRelease.cs @@ -0,0 +1,75 @@ +using NzbDrone.Core.Music; +using System.Collections.Generic; +using System.Linq; +using NzbDrone.Core.MediaFiles.TrackImport.Identification; +using System.IO; +using System; + +namespace NzbDrone.Core.Parser.Model +{ + public class LocalAlbumRelease + { + public LocalAlbumRelease() + { + LocalTracks = new List<LocalTrack>(); + + // A dummy distance, will be replaced + Distance = new Distance(); + Distance.Add("album_id", 1.0); + } + + public LocalAlbumRelease(List<LocalTrack> tracks) + { + LocalTracks = tracks; + + // A dummy distance, will be replaced + Distance = new Distance(); + Distance.Add("album_id", 1.0); + } + + public List<LocalTrack> LocalTracks { get; set; } + public int TrackCount => LocalTracks.Count; + + public TrackMapping TrackMapping { get; set; } + public Distance Distance { get; set; } + public AlbumRelease AlbumRelease { get; set; } + public bool NewDownload { get; set; } + + public void PopulateMatch() + { + if (AlbumRelease != null) + { + foreach (var localTrack in LocalTracks) + { + localTrack.Release = AlbumRelease; + localTrack.Album = AlbumRelease.Album.Value; + + if (TrackMapping.Mapping.ContainsKey(localTrack)) + { + var track = TrackMapping.Mapping[localTrack].Item1; + localTrack.Tracks = new List<Track> { track }; + localTrack.Distance = TrackMapping.Mapping[localTrack].Item2; + localTrack.Artist = localTrack.Album.Artist.Value; + } + } + } + } + + public override string ToString() + { + return "[" + string.Join(", ", LocalTracks.Select(x => Path.GetDirectoryName(x.Path)).Distinct()) + "]"; + } + } + + public class TrackMapping + { + public TrackMapping() + { + Mapping = new Dictionary<LocalTrack, Tuple<Track, Distance>>(); + } + + public Dictionary<LocalTrack, Tuple<Track, Distance>> Mapping { get; set; } + public List<LocalTrack> LocalExtra { get; set; } + public List<Track> MBExtra { get; set; } + } +} diff --git a/src/NzbDrone.Core/Parser/Model/LocalTrack.cs b/src/NzbDrone.Core/Parser/Model/LocalTrack.cs index d0f8a53b9..5f89e3df6 100644 --- a/src/NzbDrone.Core/Parser/Model/LocalTrack.cs +++ b/src/NzbDrone.Core/Parser/Model/LocalTrack.cs @@ -1,11 +1,8 @@ -using NzbDrone.Core.MediaFiles.MediaInfo; using NzbDrone.Core.Music; using NzbDrone.Core.Qualities; -using System; using System.Collections.Generic; -using System.Linq; -using System.Text; using NzbDrone.Core.Languages; +using NzbDrone.Core.MediaFiles.TrackImport.Identification; namespace NzbDrone.Core.Parser.Model { @@ -18,16 +15,21 @@ namespace NzbDrone.Core.Parser.Model public string Path { get; set; } public long Size { get; set; } - public ParsedTrackInfo ParsedTrackInfo { get; set; } + public ParsedTrackInfo FileTrackInfo { get; set; } + public ParsedTrackInfo FolderTrackInfo { get; set; } + public ParsedAlbumInfo DownloadClientAlbumInfo { get; set; } + public List<string> AcoustIdResults { get; set; } public Artist Artist { get; set; } public Album Album { get; set; } public AlbumRelease Release { get; set; } public List<Track> Tracks { get; set; } + public Distance Distance { get; set; } public QualityModel Quality { get; set; } public Language Language { get; set; } public MediaInfoModel MediaInfo { get; set; } public bool ExistingFile { get; set; } - + public bool SceneSource { get; set; } + public string ReleaseGroup { get; set; } public override string ToString() { diff --git a/src/NzbDrone.Core/Parser/Model/MediaInfoModel.cs b/src/NzbDrone.Core/Parser/Model/MediaInfoModel.cs new file mode 100644 index 000000000..a79d79702 --- /dev/null +++ b/src/NzbDrone.Core/Parser/Model/MediaInfoModel.cs @@ -0,0 +1,13 @@ +using NzbDrone.Core.Datastore; + +namespace NzbDrone.Core.Parser.Model +{ + public class MediaInfoModel : IEmbeddedDocument + { + public string AudioFormat { get; set; } + public int AudioBitrate { get; set; } + public int AudioChannels { get; set; } + public int AudioBits { get; set; } + public int AudioSampleRate { get; set; } + } +} diff --git a/src/NzbDrone.Core/Parser/Model/ParsedTrackInfo.cs b/src/NzbDrone.Core/Parser/Model/ParsedTrackInfo.cs index 45eaa81f3..98a92d1ff 100644 --- a/src/NzbDrone.Core/Parser/Model/ParsedTrackInfo.cs +++ b/src/NzbDrone.Core/Parser/Model/ParsedTrackInfo.cs @@ -1,9 +1,6 @@ -using NzbDrone.Common.Extensions; using NzbDrone.Core.Qualities; using System; -using System.Collections.Generic; using System.Linq; -using System.Text; using NzbDrone.Core.Languages; namespace NzbDrone.Core.Parser.Model @@ -12,15 +9,25 @@ namespace NzbDrone.Core.Parser.Model { //public int TrackNumber { get; set; } public string Title { get; set; } + public string CleanTitle { get; set; } public string ArtistTitle { get; set; } public string AlbumTitle { get; set; } public ArtistTitleInfo ArtistTitleInfo { get; set; } public string ArtistMBId { get; set; } public string AlbumMBId { get; set; } public string ReleaseMBId { get; set; } + public string RecordingMBId { get; set; } public string TrackMBId { get; set; } public int DiscNumber { get; set; } + public int DiscCount { get; set; } + public IsoCountry Country { get; set; } + public uint Year { get; set; } + public string Label { get; set; } + public string CatalogNumber { get; set; } + public string Disambiguation { get; set; } + public TimeSpan Duration { get; set; } public QualityModel Quality { get; set; } + public MediaInfoModel MediaInfo { get; set; } public int[] TrackNumbers { get; set; } public Language Language { get; set; } public string ReleaseGroup { get; set; } diff --git a/src/NzbDrone.Core/Parser/Parser.cs b/src/NzbDrone.Core/Parser/Parser.cs index 76f9395bf..e560d1a6e 100644 --- a/src/NzbDrone.Core/Parser/Parser.cs +++ b/src/NzbDrone.Core/Parser/Parser.cs @@ -10,6 +10,9 @@ using NzbDrone.Core.Music; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Languages; using TagLib; +using TagLib.Id3v2; +using NzbDrone.Common.Serializer; +using Newtonsoft.Json; namespace NzbDrone.Core.Parser { @@ -17,6 +20,14 @@ namespace NzbDrone.Core.Parser { private static readonly Logger Logger = NzbDroneLogger.GetLogger(typeof(Parser)); + private static readonly JsonSerializerSettings SerializerSettings; + + static Parser() + { + SerializerSettings = Json.GetSerializerSettings(); + SerializerSettings.Formatting = Formatting.None; + } + private static readonly Regex[] ReportMusicTitleRegex = new[] { // Track with artist (01 - artist - trackName) @@ -225,7 +236,15 @@ namespace NzbDrone.Core.Parser if (MediaFiles.MediaFileExtensions.Extensions.Contains(fileInfo.Extension)) { - result = ParseAudioTags(path); + try + { + result = ParseAudioTags(path); + } + catch(TagLib.CorruptFileException) + { + Logger.Debug("Caught exception parsing {0}", path); + result = null; + } } else { @@ -602,7 +621,7 @@ namespace NzbDrone.Core.Parser return title; } - public static string CleanAlbumTitle(string album) + public static string CleanAlbumTitle(this string album) { return CommonTagRegex[1].Replace(album, string.Empty).Trim(); } @@ -623,7 +642,7 @@ namespace NzbDrone.Core.Parser return AfterDashRegex.Replace(text, string.Empty).Trim(); } - public static string CleanTrackTitle(string title) + public static string CleanTrackTitle(this string title) { var intermediateTitle = title; foreach (var regex in CommonTagRegex) @@ -636,61 +655,89 @@ namespace NzbDrone.Core.Parser private static ParsedTrackInfo ParseAudioTags(string path) { - var file = TagLib.File.Create(path); - Logger.Debug("Starting Tag Parse for {0}", file.Name); - - var trackNumber = file.Tag.Track; - var trackTitle = file.Tag.Title; - var discNumber = (int)file.Tag.Disc; - - var artist = file.Tag.FirstAlbumArtist; - - if (artist.IsNullOrWhiteSpace()) + using(var file = TagLib.File.Create(path)) { - artist = file.Tag.FirstPerformer; - } + Logger.Debug("Starting Tag Parse for {0}", file.Name); - var artistTitleInfo = new ArtistTitleInfo - { - Title = artist, - Year = (int)file.Tag.Year - }; + var artist = file.Tag.FirstAlbumArtist; - var temp = new int[1]; - temp[0] = (int)trackNumber; + if (artist.IsNullOrWhiteSpace()) + { + artist = file.Tag.FirstPerformer; + } - var result = new ParsedTrackInfo - { - Language = Language.English, //TODO Parse from Tag/Mediainfo - AlbumTitle = file.Tag.Album, - ArtistTitle = artist, - ArtistMBId = file.Tag.MusicBrainzArtistId, - ReleaseMBId = file.Tag.MusicBrainzReleaseId, - DiscNumber = discNumber, - TrackMBId = file.Tag.MusicBrainzTrackId, - TrackNumbers = temp, - ArtistTitleInfo = artistTitleInfo, - Title = trackTitle - }; - - Logger.Trace("File Tags Parsed: Artist: {0}, Album: {1}, Disc: {2}, Track Numbers(s): {3}, TrackTitle: {4}", result.ArtistTitle, result.AlbumTitle, result.DiscNumber, trackNumber, result.Title); + var artistTitleInfo = new ArtistTitleInfo + { + Title = artist, + Year = (int)file.Tag.Year + }; - foreach (ICodec codec in file.Properties.Codecs) - { - IAudioCodec acodec = codec as IAudioCodec; - IVideoCodec vcodec = codec as IVideoCodec; + var result = new ParsedTrackInfo + { + Language = Language.English, //TODO Parse from Tag/Mediainfo + AlbumTitle = file.Tag.Album, + ArtistTitle = artist, + ArtistMBId = file.Tag.MusicBrainzArtistId, + AlbumMBId = file.Tag.MusicBrainzReleaseGroupId, + ReleaseMBId = file.Tag.MusicBrainzReleaseId, + // SIC: the recording ID is stored in this field. + // See https://picard.musicbrainz.org/docs/mappings/ + RecordingMBId = file.Tag.MusicBrainzTrackId, + DiscNumber = (int) file.Tag.Disc, + DiscCount = (int) file.Tag.DiscCount, + Duration = file.Properties.Duration, + Year = file.Tag.Year, + Label = file.Tag.Publisher, + TrackNumbers = new [] { (int) file.Tag.Track }, + ArtistTitleInfo = artistTitleInfo, + Title = file.Tag.Title, + CleanTitle = file.Tag.Title?.CleanTrackTitle(), + Country = IsoCountries.Find(file.Tag.MusicBrainzReleaseCountry) + }; + + // custom tags varying by format + if ((file.TagTypesOnDisk & TagTypes.Id3v2) == TagTypes.Id3v2) + { + var tag = (TagLib.Id3v2.Tag) file.GetTag(TagTypes.Id3v2); + result.CatalogNumber = UserTextInformationFrame.Get(tag, "CATALOGNUMBER", false)?.Text.ExclusiveOrDefault(); + // this one was invented for beets + result.Disambiguation = UserTextInformationFrame.Get(tag, "MusicBrainz Album Comment", false)?.Text.ExclusiveOrDefault(); + result.TrackMBId = UserTextInformationFrame.Get(tag, "MusicBrainz Release Track Id", false)?.Text.ExclusiveOrDefault(); + } + else if ((file.TagTypesOnDisk & TagTypes.Xiph) == TagTypes.Xiph) + { + var tag = (TagLib.Ogg.XiphComment) file.GetTag(TagLib.TagTypes.Xiph); + result.CatalogNumber = tag.GetField("CATALOGNUMBER").ExclusiveOrDefault(); + result.Disambiguation = tag.GetField("MUSICBRAINZ_ALBUMCOMMENT").ExclusiveOrDefault(); + result.TrackMBId = tag.GetField("MUSICBRAINZ_RELEASETRACKID").ExclusiveOrDefault(); + } + + Logger.Debug("File Tags Parsed: {0}", JsonConvert.SerializeObject(result, SerializerSettings)); - if (acodec != null && (acodec.MediaTypes & MediaTypes.Audio) != MediaTypes.None) + foreach (ICodec codec in file.Properties.Codecs) { - Logger.Debug("Audio Properties : " + acodec.Description + ", Bitrate: " + acodec.AudioBitrate + ", Sample Size: " + - file.Properties.BitsPerSample + ", SampleRate: " + acodec.AudioSampleRate + ", Channels: " + acodec.AudioChannels); + IAudioCodec acodec = codec as IAudioCodec; - result.Quality = QualityParser.ParseQuality(file.Name, acodec.Description, acodec.AudioBitrate, file.Properties.BitsPerSample); - Logger.Debug("Quality parsed: {0}", result.Quality); + if (acodec != null && (acodec.MediaTypes & MediaTypes.Audio) != MediaTypes.None) + { + Logger.Debug("Audio Properties : " + acodec.Description + ", Bitrate: " + acodec.AudioBitrate + ", Sample Size: " + + file.Properties.BitsPerSample + ", SampleRate: " + acodec.AudioSampleRate + ", Channels: " + acodec.AudioChannels); + + result.Quality = QualityParser.ParseQuality(file.Name, acodec.Description, acodec.AudioBitrate, file.Properties.BitsPerSample); + Logger.Debug("Quality parsed: {0}", result.Quality); + + result.MediaInfo = new MediaInfoModel { + AudioFormat = acodec.Description, + AudioBitrate = acodec.AudioBitrate, + AudioChannels = acodec.AudioChannels, + AudioBits = file.Properties.BitsPerSample, + AudioSampleRate = acodec.AudioSampleRate + }; + } } + + return result; } - - return result; } private static ParsedTrackInfo ParseMatchMusicCollection(MatchCollection matchCollection) diff --git a/src/NzbDrone.Core/Parser/ParsingService.cs b/src/NzbDrone.Core/Parser/ParsingService.cs index 2f8e4fcfb..101592cb4 100644 --- a/src/NzbDrone.Core/Parser/ParsingService.cs +++ b/src/NzbDrone.Core/Parser/ParsingService.cs @@ -1,5 +1,4 @@ using System.Collections.Generic; -using System.IO; using System.Linq; using NLog; using NzbDrone.Common.Extensions; @@ -8,6 +7,7 @@ using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Parser.Model; using NzbDrone.Core.Music; using System; +using System.IO; namespace NzbDrone.Core.Parser { @@ -18,12 +18,10 @@ namespace NzbDrone.Core.Parser RemoteAlbum Map(ParsedAlbumInfo parsedAlbumInfo, SearchCriteriaBase searchCriteria = null); RemoteAlbum Map(ParsedAlbumInfo parsedAlbumInfo, int artistId, IEnumerable<int> albumIds); List<Album> GetAlbums(ParsedAlbumInfo parsedAlbumInfo, Artist artist, SearchCriteriaBase searchCriteria = null); + Album GetAlbum(Artist artist, ParsedTrackInfo parsedTrackInfo); // Music stuff here Album GetLocalAlbum(string filename, Artist artist); - LocalTrack GetLocalTrack(string filename, Artist artist); - LocalTrack GetLocalTrack(string filename, Artist artist, ParsedTrackInfo folderInfo); - LocalTrack GetLocalTrack(string filename, Artist artist, Album album, ParsedTrackInfo folderInfo); } public class ParsingService : IParsingService @@ -239,66 +237,7 @@ namespace NzbDrone.Core.Parser return tracksInAlbum.Count == 1 ? _albumService.GetAlbum(tracksInAlbum.First().AlbumId) : null; } - public LocalTrack GetLocalTrack(string filename, Artist artist) - { - return GetLocalTrack(filename, artist, null); - } - - public LocalTrack GetLocalTrack(string filename, Artist artist, ParsedTrackInfo folderInfo) - { - return GetLocalTrack(filename, artist, null, folderInfo); - } - - public LocalTrack GetLocalTrack(string filename, Artist artist, Album album, ParsedTrackInfo folderInfo) - { - ParsedTrackInfo parsedTrackInfo; - - - if (folderInfo != null) - { - parsedTrackInfo = folderInfo.JsonClone(); - parsedTrackInfo.Quality = QualityParser.ParseQuality(Path.GetFileName(filename), null, 0); - } else - { - parsedTrackInfo = Parser.ParseMusicPath(filename); - } - - if (parsedTrackInfo == null || (parsedTrackInfo.AlbumTitle.IsNullOrWhiteSpace()) && parsedTrackInfo.ReleaseMBId.IsNullOrWhiteSpace() && album == null) - { - if (MediaFileExtensions.Extensions.Contains(Path.GetExtension(filename))) - { - _logger.Warn("Unable to parse track info from path {0}", filename); - } - - return null; - } - - if (album == null) - { - album = GetAlbum(artist, parsedTrackInfo); - } - - var tracks = new List<Track>(); - if (album != null) - { - tracks = GetTracks(artist, album, parsedTrackInfo); - } - - return new LocalTrack - { - Artist = artist, - Album = album, - Release = album?.AlbumReleases.Value.Single(r => r.Monitored), - Quality = parsedTrackInfo.Quality, - Language = parsedTrackInfo.Language, - Tracks = tracks, - Path = filename, - ParsedTrackInfo = parsedTrackInfo, - ExistingFile = artist.Path.IsParentPath(filename) - }; - } - - private Album GetAlbum(Artist artist, ParsedTrackInfo parsedTrackInfo) + public Album GetAlbum(Artist artist, ParsedTrackInfo parsedTrackInfo) { Album album = null; @@ -342,42 +281,5 @@ namespace NzbDrone.Core.Parser return album; } - - private List<Track> GetTracks(Artist artist, Album album, ParsedTrackInfo parsedTrackInfo) - { - var result = new List<Track>(); - - if (parsedTrackInfo.Title.IsNotNullOrWhiteSpace()) - { - Track trackInfo; - var cleanTrackTitle = Parser.CleanTrackTitle(parsedTrackInfo.Title); - _logger.Debug("Cleaning Track title of common matching issues. Cleaned track title is '{0}'", cleanTrackTitle); - - trackInfo = _trackService.FindTrackByTitle(artist.Id, album.Id, parsedTrackInfo.DiscNumber, parsedTrackInfo.TrackNumbers.FirstOrDefault(), cleanTrackTitle); - - if (trackInfo == null) - { - trackInfo = _trackService.FindTrackByTitle(artist.Id, album.Id, parsedTrackInfo.DiscNumber, parsedTrackInfo.TrackNumbers.FirstOrDefault(), parsedTrackInfo.Title); - } - - if (trackInfo == null) - { - _logger.Debug("Trying inexact track match for {0}", parsedTrackInfo); - trackInfo = _trackService.FindTrackByTitleInexact(artist.Id, album.Id, parsedTrackInfo.DiscNumber, parsedTrackInfo.TrackNumbers.FirstOrDefault(), cleanTrackTitle); - } - - if (trackInfo != null) - { - _logger.Debug("Track {0} selected for {1}", trackInfo, parsedTrackInfo); - result.Add(trackInfo); - } - else - { - _logger.Debug("Unable to find track for {0}", parsedTrackInfo); - } - } - - return result; - } } } diff --git a/src/NzbDrone.Core/Parser/QualityParser.cs b/src/NzbDrone.Core/Parser/QualityParser.cs index 7f1801ee2..84554519a 100644 --- a/src/NzbDrone.Core/Parser/QualityParser.cs +++ b/src/NzbDrone.Core/Parser/QualityParser.cs @@ -70,7 +70,11 @@ namespace NzbDrone.Core.Parser result.Quality = FindQuality(descCodec, fileBitrate, fileSampleSize); - if (result.Quality != Quality.Unknown) { return result; } + if (result.Quality != Quality.Unknown) + { + result.QualityDetectionSource = QualityDetectionSource.TagLib; + return result; + } } var codec = ParseCodec(normalizedName,name); @@ -148,7 +152,7 @@ namespace NzbDrone.Core.Parser try { result.Quality = MediaFileExtensions.GetQualityForExtension(Path.GetExtension(name)); - result.QualitySource = QualitySource.Extension; + result.QualityDetectionSource = QualityDetectionSource.Extension; } catch (ArgumentException) { @@ -160,7 +164,7 @@ namespace NzbDrone.Core.Parser return result; } - private static Codec ParseCodec(string name, string origName) + public static Codec ParseCodec(string name, string origName) { var match = CodecRegex.Match(name); diff --git a/src/NzbDrone.Core/Qualities/QualitySource.cs b/src/NzbDrone.Core/Qualities/QualityDetectionSource.cs similarity index 61% rename from src/NzbDrone.Core/Qualities/QualitySource.cs rename to src/NzbDrone.Core/Qualities/QualityDetectionSource.cs index 5c0c2c81f..9260c14bc 100644 --- a/src/NzbDrone.Core/Qualities/QualitySource.cs +++ b/src/NzbDrone.Core/Qualities/QualityDetectionSource.cs @@ -1,9 +1,9 @@ namespace NzbDrone.Core.Qualities { - public enum QualitySource + public enum QualityDetectionSource { Name, Extension, - MediaInfo + TagLib } } diff --git a/src/NzbDrone.Core/Qualities/QualityModel.cs b/src/NzbDrone.Core/Qualities/QualityModel.cs index b5658415b..7b813e09c 100644 --- a/src/NzbDrone.Core/Qualities/QualityModel.cs +++ b/src/NzbDrone.Core/Qualities/QualityModel.cs @@ -11,8 +11,8 @@ namespace NzbDrone.Core.Qualities public Revision Revision { get; set; } [JsonIgnore] - public QualitySource QualitySource { get; set; } - + public QualityDetectionSource QualityDetectionSource { get; set; } + public QualityModel() : this(Quality.Unknown, new Revision()) { diff --git a/src/NzbDrone.Core/packages.config b/src/NzbDrone.Core/packages.config index 981643d10..d475ac62d 100644 --- a/src/NzbDrone.Core/packages.config +++ b/src/NzbDrone.Core/packages.config @@ -9,7 +9,7 @@ <package id="OAuth" version="1.0.3" targetFramework="net461" /> <package id="Prowlin" version="0.9.4456.26422" targetFramework="net461" /> <package id="RestSharp" version="105.2.3" targetFramework="net461" /> - <package id="taglib" version="2.1.0.0" targetFramework="net461" /> + <package id="TagLibSharp" version="2.2.0-beta" targetFramework="net461" /> <package id="TinyTwitter" version="1.1.2" targetFramework="net461" /> <package id="xmlrpcnet" version="2.5.0" targetFramework="net461" /> </packages> \ No newline at end of file